이 글에서는 Dart를 사용하여 Model Context Protocol(MCP) 서버를 구현하고, Claude Desktop과 연결하는 방법을 단계별로 알아보겠습니다.
Model Context Protocol(MCP)은 AI 모델과 외부 환경 간의 통신을 표준화하는 프로토콜입니다. 이를 통해 AI 모델은 외부 도구, 리소스, 파일 시스템 등과 상호작용할 수 있습니다. Claude와 같은 AI 모델이 로컬 파일이나 시스템 기능에 접근하기 위해서는 이러한 프로토콜 구현이 필요합니다.
MCP의 주요 구성 요소:
먼저 Dart 프로젝트를 설정하고 필요한 패키지를 설치합니다.
# 새 Dart 프로젝트 생성
dart create mcp_server_example
cd mcp_server_example
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 서버 구현 코드입니다:
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);
}
}
MCP 서버는 두 가지 주요 전송 메커니즘을 지원합니다:
STDIO 모드는 표준 입출력 스트림을 통해 통신합니다. Claude Desktop과 같은 AI 애플리케이션과 직접 연결하는 데 유용합니다.
// STDIO 전송 생성
transport = McpServer.createStdioTransport();
SSE 모드는 HTTP를 통해 통신하며, 주로 웹 애플리케이션과 연결할 때 사용합니다. 서버는 지정된 포트에서 이벤트를 전송합니다.
// SSE 전송 생성
transport = McpServer.createSseTransport(
endpoint: '/sse',
messagesEndpoint: '/message',
port: port,
fallbackPorts: [port + 1, port + 2, port + 3],
);
이제 AI가 사용할 도구, 리소스, 프롬프트를 등록해 보겠습니다.
도구는 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
);
}
},
);
}
리소스는 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');
}
},
);
}
프롬프트는 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 모델은 이를 활용하여 다양한 작업을 수행할 수 있습니다. 예를 들어, 현재 시간을 확인하거나, 간단한 계산을 수행하거나, 코드 리뷰를 받을 수 있습니다.
이제 구현한 MCP 서버를 Claude Desktop과 연결해 보겠습니다.
먼저 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
Claude Desktop 설정 파일을 수정하여 MCP 서버를 연결합니다:
claude_desktop_config.json 파일이 열리면 다음과 같이 수정합니다:{
"mcpServers": {
"flutter-test": {
"command": "/path/to/mcp_server/example/mcp_server_example",
"args": ["--mcp-stdio-mode"]
}
}
}
이제 Claude에게 도구와 리소스를 사용해보라고 요청할 수 있습니다:
hello 도구: "안녕, Claude!"와 같은 인사말 생성calculator 도구: 두 숫자 간의 산술 연산 수행currentDateTime 도구: 현재 날짜와 시간 정보 제공dart://system-info 리소스: 시스템 정보 액세스dart://env-vars 리소스: 환경 변수 액세스MCP 서버의 로깅과 상태 모니터링 기능을 활용하면 서버 동작을 쉽게 추적하고 문제를 해결할 수 있습니다.
// 로그 레벨 설정
_logger.setLevel(LogLevel.debug);
// 로그 메시지 전송
server.sendLog(McpLogLevel.info, 'Flutter MCP Server started successfully');
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 서버는 다양한 방식으로 확장할 수 있습니다:
특정 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, 맑음')]);
},
);
로컬 파일 시스템에 접근하는 리소스를 추가할 수 있습니다:
// 파일 시스템 리소스 예시
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 {
// 파일 접근 코드...
}
);
데이터베이스와 연결하여 정보를 제공하는 리소스를 만들 수 있습니다:
// 데이터베이스 리소스 예시
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을 통해 더 많은 무료 콘텐츠 제작을 지원해 주세요. 여러분의 후원은 더 많은 고품질 개발 튜토리얼을 만드는 데 큰 힘이 됩니다.