Flutter로 MCP 서버 구현하기: Claude Desktop과 연결하는 방법

MCP Dev Studio·2025년 4월 29일

이 글에서는 Dart를 사용하여 Model Context Protocol(MCP) 서버를 구현하고, Claude Desktop과 연결하는 방법을 단계별로 알아보겠습니다.

목차

  1. MCP(Model Context Protocol)란?
  2. 프로젝트 설정
  3. MCP 서버 구현하기
  4. 전송 메커니즘 설정: STDIO vs SSE
  5. 도구, 리소스, 프롬프트 등록하기
  6. Claude Desktop과 연결하기
  7. 서버 로깅과 상태 모니터링
  8. 추가 기능 및 확장 가능성
  9. 마무리

MCP(Model Context Protocol)란?

Model Context Protocol(MCP)은 AI 모델과 외부 환경 간의 통신을 표준화하는 프로토콜입니다. 이를 통해 AI 모델은 외부 도구, 리소스, 파일 시스템 등과 상호작용할 수 있습니다. Claude와 같은 AI 모델이 로컬 파일이나 시스템 기능에 접근하기 위해서는 이러한 프로토콜 구현이 필요합니다.

MCP의 주요 구성 요소:

  • 도구(Tools): AI가 호출할 수 있는 함수나 API
  • 리소스(Resources): AI가 접근할 수 있는 파일이나 데이터
  • 프롬프트(Prompts): 재사용 가능한 대화 템플릿

프로젝트 설정

먼저 Dart 프로젝트를 설정하고 필요한 패키지를 설치합니다.

2.1 Dart 프로젝트 생성

# 새 Dart 프로젝트 생성
dart create mcp_server_example
cd mcp_server_example

2.2 종속성 추가

pubspec.yaml 파일을 열고 다음과 같이 수정합니다:

name: mcp_server_example
description: MCP 서버 예제 구현
version: 1.0.0

environment:
  sdk: '>=2.18.0 <3.0.0'

dependencies:
  uuid: ^3.0.7
  mcp_server: ^1.0.0

종속성을 다운로드합니다:

dart pub get

MCP 서버 구현하기

이제 MCP 서버를 구현하겠습니다. 기본 구조는 다음과 같습니다:

  1. 서버 인스턴스 생성
  2. 전송 메커니즘 설정 (STDIO 또는 SSE)
  3. 도구, 리소스, 프롬프트 등록
  4. 서버 시작 및 연결

다음은 기본적인 MCP 서버 구현 코드입니다:

import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:mcp_server/mcp_server.dart';

final Logger _logger = Logger.getLogger('mcp_server_example');

void main(List<String> args) async {
  _logger.setLevel(LogLevel.debug);
  
  // MCP STDIO Mode
  if (args.contains('--mcp-stdio-mode')) {
    await startMcpServer(mode: 'stdio');
  } else {
    // SSE Mode
    int port = 8999;
    await startMcpServer(mode: 'sse', port: port);
  }
}

Future<void> startMcpServer({required String mode, int port = 8080}) async {
  try {
    // 서버 생성 및 기능 설정
    final server = McpServer.createServer(
      name: 'Flutter MCP Demo',
      version: '1.0.0',
      capabilities: ServerCapabilities(
        tools: true,
        toolsListChanged: true,
        resources: true,
        resourcesListChanged: true,
        prompts: true,
        promptsListChanged: true,
      ),
    );

    // 도구, 리소스, 프롬프트 등록
    _registerTools(server);
    _registerResources(server);
    _registerPrompts(server);

    // 전송 메커니즘 설정
    ServerTransport transport;
    if (mode == 'stdio') {
      _logger.debug('Starting server in STDIO mode');
      transport = McpServer.createStdioTransport();
    } else {
      _logger.debug('Starting server in SSE mode on port $port');
      transport = McpServer.createSseTransport(
        endpoint: '/sse',
        messagesEndpoint: '/message',
        port: port,
        fallbackPorts: [port + 1, port + 2, port + 3],
      );
    }

    // 전송 종료 처리
    transport.onClose.then((_) {
      _logger.debug('Transport closed, shutting down.');
      exit(0);
    });

    // 서버와 전송 연결
    server.connect(transport);

    // 로그 메시지 전송
    server.sendLog(McpLogLevel.info, 'Flutter MCP Server started successfully');

    if (mode == 'sse') {
      _logger.debug('SSE Server is running on:');
      _logger.debug('- SSE endpoint:     http://localhost:$port/sse');
      _logger.debug('- Message endpoint: http://localhost:$port/message');
      _logger.debug('Press Ctrl+C to stop the server');
    } else {
      _logger.debug('STDIO Server initialized and connected to transport');
    }

  } catch (e, stackTrace) {
    _logger.debug('Error initializing MCP server: $e');
    _logger.debug(stackTrace.toString());
    exit(1);
  }
}

전송 메커니즘 설정: STDIO vs SSE

MCP 서버는 두 가지 주요 전송 메커니즘을 지원합니다:

4.1 STDIO 모드

STDIO 모드는 표준 입출력 스트림을 통해 통신합니다. Claude Desktop과 같은 AI 애플리케이션과 직접 연결하는 데 유용합니다.

// STDIO 전송 생성
transport = McpServer.createStdioTransport();

4.2 SSE(Server-Sent Events) 모드

SSE 모드는 HTTP를 통해 통신하며, 주로 웹 애플리케이션과 연결할 때 사용합니다. 서버는 지정된 포트에서 이벤트를 전송합니다.

// SSE 전송 생성
transport = McpServer.createSseTransport(
  endpoint: '/sse',
  messagesEndpoint: '/message',
  port: port,
  fallbackPorts: [port + 1, port + 2, port + 3],
);

도구, 리소스, 프롬프트 등록하기

이제 AI가 사용할 도구, 리소스, 프롬프트를 등록해 보겠습니다.

5.1 도구 등록

도구는 AI가 호출할 수 있는 함수입니다. 간단한 인사말 도구와 계산기 도구를 구현해 보겠습니다.

void _registerTools(Server server) {
  // 인사말 도구
  server.addTool(
    name: 'hello',
    description: '누군가에게 인사하는 도구',
    inputSchema: {
      'type': 'object',
      'properties': {
        'name': {
          'type': 'string',
          'description': '인사할 이름'
        }
      },
      'required': []
    },
    handler: (args) async {
      final name = args['name'] ?? 'world';
      return CallToolResult([TextContent(text: 'Hello, $name!')]);
    },
  );

  // 계산기 도구
  server.addTool(
    name: 'calculator',
    description: '기본 산술 연산 수행',
    inputSchema: {
      'type': 'object',
      'properties': {
        'operation': {
          'type': 'string',
          'enum': ['add', 'subtract', 'multiply', 'divide'],
          'description': '수행할 수학 연산'
        },
        'a': {
          'type': 'number',
          'description': '첫 번째 피연산자'
        },
        'b': {
          'type': 'number',
          'description': '두 번째 피연산자'
        }
      },
      'required': ['operation', 'a', 'b']
    },
    handler: (args) async {
      final operation = args['operation'] as String;
      final a = (args['a'] is int) ? (args['a'] as int).toDouble() : args['a'] as double;
      final b = (args['b'] is int) ? (args['b'] as int).toDouble() : args['b'] as double;

      double result;
      switch (operation) {
        case 'add':
          result = a + b;
          break;
        case 'subtract':
          result = a - b;
          break;
        case 'multiply':
          result = a * b;
          break;
        case 'divide':
          if (b == 0) {
            throw McpError('Cannot divide by zero');
          }
          result = a / b;
          break;
        default:
          throw McpError('Unknown operation: $operation');
      }

      return CallToolResult([TextContent(text: 'Result: $result')]);
    },
  );

  // 현재 날짜 및 시간 도구
  server.addTool(
    name: 'currentDateTime',
    description: '현재 날짜와 시간 가져오기',
    inputSchema: {
      'type': 'object',
      'properties': {
        'format': {
          'type': 'string',
          'description': '출력 형식 (full, date, time)',
          'default': 'full'
        }
      },
      'required': []
    },
    handler: (args) async {
      try {
        _logger.debug("[DateTime Tool] Received args: $args");

        String format;
        if (args['format'] == null) {
          format = 'full';
        } else if (args['format'] is String) {
          format = args['format'] as String;
        } else {
          format = args['format'].toString();
        }

        _logger.debug("[DateTime Tool] Using format: $format");

        final now = DateTime.now();
        _logger.debug("[DateTime Tool] Current DateTime: $now");

        String result;
        switch (format) {
          case 'date':
            result = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
            break;
          case 'time':
            try {
              final hour = now.hour.toString().padLeft(2, '0');
              final minute = now.minute.toString().padLeft(2, '0');
              final second = now.second.toString().padLeft(2, '0');
              result = '$hour:$minute:$second';
            } catch (e) {
              _logger.debug("[DateTime Tool] Error formatting time: $e");
              result = "Error formatting time: $e";
            }
            break;
          case 'full':
          default:
            try {
              result = now.toIso8601String();
            } catch (e) {
              _logger.debug("[DateTime Tool] Error with ISO format: $e");
              result = "${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} " +
                  "${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}";
            }
            break;
        }

        _logger.debug("[DateTime Tool] Result: $result");
        return CallToolResult([TextContent(text: result)]);
      } catch (e, stackTrace) {
        _logger.debug("[DateTime Tool] Unexpected error: $e");
        _logger.debug("[DateTime Tool] Stack trace: $stackTrace");
        return CallToolResult(
            [TextContent(text: "Error getting date/time: $e")],
            isError: true
        );
      }
    },
  );
}

5.2 리소스 등록

리소스는 AI가 접근할 수 있는 데이터 소스입니다. 시스템 정보와 환경 변수를 제공하는 리소스를 구현해 보겠습니다.

void _registerResources(Server server) {
  // 시스템 정보 리소스
  server.addResource(
      uri: 'dart://system-info',
      name: 'System Information',
      description: '현재 시스템에 대한 상세 정보',
      mimeType: 'application/json',
      uriTemplate: {
        'type': 'object',
        'properties': {}
      },
      handler: (uri, params) async {
        final systemInfo = {
          'operatingSystem': Platform.operatingSystem,
          'operatingSystemVersion': Platform.operatingSystemVersion,
          'localHostname': Platform.localHostname,
          'numberOfProcessors': Platform.numberOfProcessors,
          'localeName': Platform.localeName,
          'executable': Platform.executable,
          'resolvedExecutable': Platform.resolvedExecutable,
          'script': Platform.script.toString(),
        };

        final contents = systemInfo.entries.map((entry) =>
            ResourceContent(
              uri: 'dart://system-info/${entry.key}',
              text: '${entry.key}: ${entry.value}',
            )
        ).toList();

        return ReadResourceResult(
          content: jsonEncode(systemInfo),
          mimeType: 'application/json',
          contents: contents,
        );
      }
  );

  // 환경 변수 리소스
  server.addResource(
      uri: 'dart://env-vars',
      name: 'Environment Variables',
      description: '시스템 환경 변수 목록',
      mimeType: 'application/json',
      uriTemplate: {
        'type': 'object',
        'properties': {}
      },
      handler: (uri, params) async {
        final envVars = Platform.environment;
        final contents = envVars.entries.map((entry) =>
            ResourceContent(
              uri: 'dart://env-vars/${entry.key}',
              text: '${entry.key}: ${entry.value}',
            )
        ).toList();

        return ReadResourceResult(
          content: jsonEncode(envVars),
          mimeType: 'application/json',
          contents: contents,
        );
      }
  );

  // 파일 리소스 예시 (URI 템플릿 사용)
  server.addResource(
    uri: 'file://{path}',
    name: 'File Resource',
    description: '시스템의 파일에 접근',
    mimeType: 'application/octet-stream',
    uriTemplate: {
      'type': 'object',
      'properties': {
        'path': {
          'type': 'string',
          'description': '파일 경로'
        }
      }
    },
    handler: (uri, params) async {
      try {
        // URI에서 경로 추출
        String? path = params['path'] ?? uri.substring('file://'.length);

        if (path == null || path.isEmpty) {
          throw McpError('경로가 제공되지 않았습니다');
        }

        // 보안 확인 - 임시 디렉토리 로직
        final tempDir = Directory.systemTemp;
        if (!path.startsWith(tempDir.path)) {
          throw McpError('임시 디렉토리 외부 경로에 접근할 수 없습니다');
        }

        // 파일 읽기
        final file = File(path);
        if (!await file.exists()) {
          throw McpError('파일을 찾을 수 없습니다: $path');
        }

        final contents = await file.readAsString();
        String mimeType = 'text/plain';

        // 간단한 MIME 타입 감지
        if (path.endsWith('.json')) {
          mimeType = 'application/json';
        } else if (path.endsWith('.html')) {
          mimeType = 'text/html';
        } else if (path.endsWith('.css')) {
          mimeType = 'text/css';
        } else if (path.endsWith('.js')) {
          mimeType = 'application/javascript';
        }

        return ReadResourceResult(
          content: contents,
          mimeType: mimeType,
          contents: [
            ResourceContent(
              uri: 'file://$path',
              text: contents,
            )
          ],
        );
      } catch (e) {
        throw McpError('파일 읽기 오류: $e');
      }
    },
  );
}

5.3 프롬프트 등록

프롬프트는 AI와의 대화를 위한 템플릿입니다. 간단한 인사말 프롬프트와 코드 리뷰 프롬프트를 구현해 보겠습니다.

void _registerPrompts(Server server) {
  // 인사말 프롬프트
  server.addPrompt(
    name: 'greeting',
    description: '사용자를 위한 인사말 생성',
    arguments: [
      PromptArgument(
        name: 'name',
        description: '인사할 사람의 이름',
        required: true,
      ),
      PromptArgument(
        name: 'formal',
        description: '공식적인 인사말 스타일 사용 여부',
        required: false,
      ),
    ],
    handler: (args) async {
      final name = args['name'] as String;
      final formal = args['formal'] as bool? ?? false;

      final String systemPrompt = formal
          ? '당신은 공식적인 어시스턴트입니다. 존경과 격식을 갖춰 사용자에게 말하세요.'
          : '당신은 친근한 어시스턴트입니다. 따뜻하고 편안한 톤으로 말하세요.';

      final messages = [
        Message(
          role: MessageRole.system.toString().split('.').last,
          content: TextContent(text: systemPrompt),
        ),
        Message(
          role: MessageRole.user.toString().split('.').last,
          content: TextContent(text: '$name에게 인사해 주세요'),
        ),
      ];

      return GetPromptResult(
        description: '${formal ? '공식적인' : '친근한'} $name을 위한 인사말',
        messages: messages,
      );
    },
  );

  // 코드 리뷰 프롬프트
  server.addPrompt(
    name: 'codeReview',
    description: '코드 스니펫에 대한 코드 리뷰 생성',
    arguments: [
      PromptArgument(
        name: 'code',
        description: '리뷰할 코드',
        required: true,
      ),
      PromptArgument(
        name: 'language',
        description: '코드의 프로그래밍 언어',
        required: true,
      ),
    ],
    handler: (args) async {
      final code = args['code'] as String;
      final language = args['language'] as String;

      final systemPrompt = '''
당신은 전문 코드 리뷰어입니다. 다음 가이드라인에 따라 제공된 코드를 검토하세요:
1. 잠재적인 버그나 문제 식별
2. 성능이나 가독성을 위한 최적화 제안
3. 코드에서 사용된 좋은 관행 강조
4. 개선을 위한 건설적인 피드백 제공
피드백을 구체적으로 제시하고 변경 사항을 제안할 때 코드 예제를 제공하세요.
''';

      final messages = [
        Message(
          role: MessageRole.system.toString().split('.').last,
          content: TextContent(text: systemPrompt),
        ),
        Message(
          role: MessageRole.user.toString().split('.').last,
          content: TextContent(text: '다음 $language 코드를 리뷰해 주세요:\n\n```$language\n$code\n```'),
        ),
      ];

      return GetPromptResult(
        description: '$language 코드에 대한 코드 리뷰',
        messages: messages,
      );
    },
  );
}

이러한 도구, 리소스, 프롬프트가 등록되면 AI 모델은 이를 활용하여 다양한 작업을 수행할 수 있습니다. 예를 들어, 현재 시간을 확인하거나, 간단한 계산을 수행하거나, 코드 리뷰를 받을 수 있습니다.

Claude Desktop과 연결하기

이제 구현한 MCP 서버를 Claude Desktop과 연결해 보겠습니다.

6.1 실행 파일 만들기

먼저 Dart 코드를 실행 파일로 컴파일합니다:

# bin 디렉토리의 mcp_server_example.dart 파일을 컴파일
cd /path/to/mcp_server/example
dart compile exe bin/mcp_server_example.dart -o mcp_server_example

컴파일된 실행 파일에 실행 권한을 추가합니다:

chmod +x mcp_server_example

6.2 Claude Desktop 설정

Claude Desktop 설정 파일을 수정하여 MCP 서버를 연결합니다:

  1. Claude Desktop을 실행합니다.
  2. 상단 메뉴에서 "설정(Settings)" > "개발자(Developer)" 항목으로 이동합니다.
  3. "Edit Config" 버튼을 클릭합니다.
  4. claude_desktop_config.json 파일이 열리면 다음과 같이 수정합니다:
{
    "mcpServers": {
        "flutter-test": {
            "command": "/path/to/mcp_server/example/mcp_server_example",
            "args": ["--mcp-stdio-mode"]
        }
    }
}
  1. 파일을 저장하고 Claude Desktop을 재시작합니다.

6.3 연결 및 사용

  1. Claude Desktop이 재시작되면, 왼쪽 하단의 "MCP" 드롭다운 메뉴를 클릭합니다.
  2. "flutter-test" 항목을 클릭하여 MCP 서버에 연결합니다.
  3. 연결이 성공하면 채팅창 아래에 망치 모양의 도구 아이콘이 표시됩니다.

이제 Claude에게 도구와 리소스를 사용해보라고 요청할 수 있습니다:

  • hello 도구: "안녕, Claude!"와 같은 인사말 생성
  • calculator 도구: 두 숫자 간의 산술 연산 수행
  • currentDateTime 도구: 현재 날짜와 시간 정보 제공
  • dart://system-info 리소스: 시스템 정보 액세스
  • dart://env-vars 리소스: 환경 변수 액세스

서버 로깅과 상태 모니터링

MCP 서버의 로깅과 상태 모니터링 기능을 활용하면 서버 동작을 쉽게 추적하고 문제를 해결할 수 있습니다.

7.1 로깅 설정

// 로그 레벨 설정
_logger.setLevel(LogLevel.debug);

// 로그 메시지 전송
server.sendLog(McpLogLevel.info, 'Flutter MCP Server started successfully');

7.2 상태 모니터링

MCP 서버는 health/check 엔드포인트를 통해 상태 정보를 제공합니다. 서버 상태를 모니터링하려면:

final health = server.getHealth();
_logger.debug('Server is running: ${health.isRunning}');
_logger.debug('Connected sessions: ${health.connectedSessions}');
_logger.debug('Registered tools: ${health.registeredTools}');
_logger.debug('Uptime: ${health.uptime.inSeconds} seconds');

추가 기능 및 확장 가능성

MCP 서버는 다양한 방식으로 확장할 수 있습니다:

8.1 새로운 도구 추가

특정 API나 데이터베이스와 상호작용하는 새 도구를 추가할 수 있습니다:

// 날씨 API 도구 예시
server.addTool(
  name: 'weather',
  description: '특정 도시의 날씨 정보 가져오기',
  inputSchema: {
    'type': 'object',
    'properties': {
      'city': {
        'type': 'string',
        'description': '도시 이름'
      }
    },
    'required': ['city']
  },
  handler: (args) async {
    final city = args['city'] as String;
    // 날씨 API 호출 코드...
    return CallToolResult([TextContent(text: '서울의 현재 온도: 22°C, 맑음')]);
  },
);

8.2 파일 시스템 접근

로컬 파일 시스템에 접근하는 리소스를 추가할 수 있습니다:

// 파일 시스템 리소스 예시
server.addResource(
  uri: 'file://{path}',
  name: 'File Resource',
  description: '시스템의 파일에 접근',
  mimeType: 'application/octet-stream',
  uriTemplate: {
    'type': 'object',
    'properties': {
      'path': {
        'type': 'string',
        'description': '파일 경로'
      }
    }
  },
  handler: (uri, params) async {
    // 파일 접근 코드...
  }
);

8.3 데이터베이스 연결

데이터베이스와 연결하여 정보를 제공하는 리소스를 만들 수 있습니다:

// 데이터베이스 리소스 예시
server.addResource(
  uri: 'db://users/{id}',
  name: 'User Database',
  description: '사용자 정보에 접근',
  mimeType: 'application/json',
  uriTemplate: {
    'type': 'object',
    'properties': {
      'id': {
        'type': 'string',
        'description': '사용자 ID'
      }
    }
  },
  handler: (uri, params) async {
    // 데이터베이스 접근 코드...
  }
);

마무리

이 글에서는 Flutter와 Dart를 사용하여 MCP 서버를 구현하고 Claude Desktop과 연결하는 방법을 살펴보았습니다. MCP를 통해 AI 모델은 로컬 시스템 자원과 상호작용하여 더 강력하고 유용한 기능을 제공할 수 있습니다.

MCP 서버를 확장하여 더 많은 도구와 리소스를 추가하면, Claude와 같은 AI 모델의 능력을 크게 향상시킬 수 있습니다. 파일 시스템 접근, API 연동, 데이터베이스 쿼리 등 다양한 기능을 구현해 보세요.

더 알아보기


💡 Tip: MCP 서버를 개발할 때는 항상 보안에 주의하세요. 민감한 시스템 리소스에 대한 접근을 제한하고, 입력 값을 철저히 검증하세요.

코드를 직접 실행해보고 궁금한 점이나 개선 사항이 있으면 댓글로 남겨주세요!


💪 개발자 지원하기

이 튜토리얼이 도움이 되셨다면, Patreon을 통해 더 많은 무료 콘텐츠 제작을 지원해 주세요. 여러분의 후원은 더 많은 고품질 개발 튜토리얼을 만드는 데 큰 힘이 됩니다.

Support on Patreon

0개의 댓글