Flutter로 MCP 클라이언트 구현하기: 서버와 통신하는 방법

MCP Dev Studio·2025년 4월 29일

Flutter와 Dart를 활용해 Model Context Protocol(MCP) 클라이언트를 구현하고 다양한 MCP 서버와 통신하는 방법을 단계별로 살펴봅니다.

MCP 클라이언트 개요

목차

소개

최근 AI 기술이 발전하면서 대규모 언어 모델(LLM)과 로컬 시스템 간의 통신이 중요해졌습니다. Model Context Protocol(MCP)은 이러한 통신을 표준화하는 프로토콜로, AI 모델과 외부 환경 간의 상호작용을 가능하게 합니다.

이 글에서는 Flutter와 Dart로 MCP 클라이언트를 구현하고, 이를 통해 MCP 서버와 통신하는 방법을 자세히 알아보겠습니다. 이는 MCP 시리즈의 두 번째 글로, 이전 글에서는 Flutter로 MCP 서버 구현하기에 대해 다루었습니다. 이 시리즈는 후속 글에서 MCP와 LLM을 통합하는 방법까지 이어질 예정입니다.

MCP 클라이언트란?

MCP 클라이언트는 Model Context Protocol을 기반으로 서버와 통신하는 클라이언트 측 구현체입니다. 클라이언트는 다음과 같은 주요 기능을 제공합니다:

  1. 서버 연결 및 초기화: STDIO 또는 SSE 전송 방식을 통한 서버 연결
  2. 서버 기능 탐색: 사용 가능한 도구, 리소스, 프롬프트 목록 확인
  3. 도구 호출: 서버에 등록된 도구(함수)를 원격으로 호출
  4. 리소스 접근: 서버가 제공하는 데이터 소스에 접근
  5. 이벤트 처리: 서버에서 발생하는 이벤트 및 알림 처리

MCP 클라이언트는 mcp_client 패키지를 통해 쉽게 구현할 수 있으며, 다양한 MCP 서버와 통신할 수 있습니다.

프로젝트 설정

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

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

pubspec.yaml 파일에 mcp_client 패키지를 추가합니다:

name: mcp_client_example
description: Example of MCP client implementation
version: 1.0.0

environment:
  sdk: ^3.7.2

dependencies:
  mcp_client: ^0.1.7
  uuid: ^4.0.0
  http: ^1.1.0

dev_dependencies:
  lints: ^5.0.0
  test: ^1.24.0

패키지를 다운로드합니다:

dart pub get

클라이언트 초기화

이제 MCP 클라이언트를 초기화해 보겠습니다. bin 디렉토리에 mcp_client_example.dart 파일을 생성하고 기본 구조를 작성합니다.

import 'dart:io';
import 'package:mcp_client/mcp_client.dart';

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

/// MCP 클라이언트 예제 애플리케이션
void main() async {
  // 로깅 설정
  _logger.setLevel(LogLevel.debug);
  
  // 로그 파일 생성
  final logFile = File('mcp_client_example.log');
  final logSink = logFile.openWrite();
  
  logToConsoleAndFile('MCP 클라이언트 예제 시작...', logSink);
  
  try {
    // 클라이언트 생성
    final client = McpClient.createClient(
      name: 'Example MCP Client',
      version: '1.0.0',
      capabilities: ClientCapabilities(
        roots: true,
        rootsListChanged: true,
        sampling: true,
      ),
    );
    
    logToConsoleAndFile('클라이언트가 초기화되었습니다.', logSink);
    
    // 이후 서버 연결 및 기능 구현
    
  } catch (e) {
    logToConsoleAndFile('오류: $e', logSink);
  } finally {
    // 로그 파일 닫기
    await logSink.flush();
    await logSink.close();
  }
}

/// 콘솔과 파일에 동시에 로그 기록
void logToConsoleAndFile(String message, IOSink logSink) {
  // 콘솔에 로그 출력
  _logger.debug(message);
  
  // 파일에도 로그 기록
  logSink.writeln(message);
}

여기서 ClientCapabilities는 클라이언트가 지원하는 기능을 정의합니다:

  • roots: 루트 관리 기능 지원
  • rootsListChanged: 루트 목록 변경 알림 지원
  • sampling: 샘플링 지원 (LLM 연동 시 필요)

서버 연결

MCP 클라이언트는 전송 방식에 따라 크게 두 가지 방법으로 서버와 연결할 수 있습니다:

1. STDIO 전송 방식

이 방식은 표준 입출력 스트림을 통해 프로세스 간 통신하는 방식으로, 로컬 MCP 서버와 연결할 때 유용합니다. 앞서 작성한 예제 코드에 STDIO 전송 방식을 추가해 보겠습니다:

import 'dart:io';
import 'package:mcp_client/mcp_client.dart';

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

/// MCP 클라이언트 예제 애플리케이션
void main() async {
  // 로깅 설정
  _logger.setLevel(LogLevel.debug);
  
  // 로그 파일 생성
  final logFile = File('mcp_client_example.log');
  final logSink = logFile.openWrite();
  
  logToConsoleAndFile('MCP 클라이언트 예제 시작...', logSink);
  
  try {
    // 클라이언트 생성
    final client = McpClient.createClient(
      name: 'Example MCP Client',
      version: '1.0.0',
      capabilities: ClientCapabilities(
        roots: true,
        rootsListChanged: true,
        sampling: true,
      ),
    );
    
    logToConsoleAndFile('클라이언트가 초기화되었습니다.', logSink);
    
    // 파일 시스템 MCP 서버와 STDIO로 연결
    logToConsoleAndFile('MCP 파일 시스템 서버에 연결 중...', logSink);
    
    final transport = await McpClient.createStdioTransport(
      command: 'npx',
      arguments: ['-y', '@modelcontextprotocol/server-filesystem', Directory.current.path],
    );
    
    logToConsoleAndFile('STDIO 전송 메커니즘이 생성되었습니다.', logSink);
    
    // 연결 설정
    await client.connect(transport);
    logToConsoleAndFile('서버에 성공적으로 연결되었습니다!', logSink);
    
    // 서버 정보 및 기능 출력
    final serverInfo = client.serverInfo;
    final serverCapabilities = client.serverCapabilities;
    
    logToConsoleAndFile('서버 정보: $serverInfo', logSink);
    logToConsoleAndFile('서버 기능:', logSink);
    logToConsoleAndFile('- 도구 지원: ${serverCapabilities?.tools}', logSink);
    logToConsoleAndFile('- 리소스 지원: ${serverCapabilities?.resources}', logSink);
    logToConsoleAndFile('- 프롬프트 지원: ${serverCapabilities?.prompts}', logSink);
    
    // 알림 핸들러 등록
    registerNotificationHandlers(client, logSink);
    
    // 서버 건강 상태 확인
    final health = await client.healthCheck();
    logToConsoleAndFile('\n--- 서버 건강 상태 ---', logSink);
    logToConsoleAndFile('서버 실행 중: ${health.isRunning}', logSink);
    logToConsoleAndFile('연결된 세션 수: ${health.connectedSessions}', logSink);
    logToConsoleAndFile('등록된 도구 수: ${health.registeredTools}', logSink);
    logToConsoleAndFile('등록된 리소스 수: ${health.registeredResources}', logSink);
    logToConsoleAndFile('등록된 프롬프트 수: ${health.registeredPrompts}', logSink);
    logToConsoleAndFile('서버 가동 시간: ${health.uptime.inSeconds}초', logSink);
    
    // 이후 도구와 리소스 활용 (다음 섹션에서 구현)
    
  } catch (e, stackTrace) {
    logToConsoleAndFile('오류: $e', logSink);
    logToConsoleAndFile('스택 트레이스: $stackTrace', logSink);
  } finally {
    // 로그 파일 닫기
    await logSink.flush();
    await logSink.close();
  }
}

/// 알림 핸들러 등록
void registerNotificationHandlers(Client client, IOSink logSink) {
  // 도구 목록 변경 알림 처리
  client.onToolsListChanged(() {
    logToConsoleAndFile('도구 목록이 변경되었습니다!', logSink);
  });
  
  // 리소스 목록 변경 알림 처리
  client.onResourcesListChanged(() {
    logToConsoleAndFile('리소스 목록이 변경되었습니다!', logSink);
  });
  
  // 리소스 업데이트 알림 처리
  client.onResourceUpdated((uri) {
    logToConsoleAndFile('리소스가 업데이트되었습니다: $uri', logSink);
  });
  
  // 로깅 알림 처리
  client.onLogging((level, message, logger, data) {
    logToConsoleAndFile('서버 로그 [$level]: $message', logSink);
  });
}

/// 콘솔과 파일에 동시에 로그 기록
void logToConsoleAndFile(String message, IOSink logSink) {
  // 콘솔에 로그 출력
  _logger.debug(message);
  
  // 파일에도 로그 기록
  logSink.writeln(message);
}

2. SSE(Server-Sent Events) 전송 방식

SSE 방식은 HTTP를 통해 클라이언트와 서버가 통신하는 방식으로, 웹 기반 MCP 서버와 연결할 때 유용합니다. 코드는 다음과 같이 작성할 수 있습니다:

// SSE 전송 메커니즘 생성 및 연결 예제
Future<void> connectWithSse() async {
  try {
    final client = McpClient.createClient(
      name: 'SSE MCP Client',
      version: '1.0.0',
      capabilities: ClientCapabilities(),
    );
    
    final transport = await McpClient.createSseTransport(
      serverUrl: 'http://localhost:8080',
      headers: {'Authorization': 'Bearer your-token'},
    );
    
    // 연결 시 재시도 옵션 사용
    await client.connectWithRetry(
      transport,
      maxRetries: 3,
      delay: const Duration(seconds: 2),
    );
    
    print('SSE 서버에 성공적으로 연결되었습니다!');
    
  } catch (e) {
    print('SSE 서버 연결 오류: $e');
  }
}

도구 및 리소스 활용하기

서버에 연결한 후, 서버가 제공하는 도구와 리소스를 활용할 수 있습니다. 앞서 작성한 예제 코드에 도구와 리소스 활용 기능을 추가해 보겠습니다.

도구 목록 확인 및 호출

import 'dart:io';
import 'dart:convert';
import 'package:mcp_client/mcp_client.dart';

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

/// MCP 클라이언트 예제 애플리케이션
void main() async {
  // 로깅 설정
  _logger.setLevel(LogLevel.debug);
  
  // 로그 파일 생성
  final logFile = File('mcp_client_example.log');
  final logSink = logFile.openWrite();
  
  logToConsoleAndFile('MCP 클라이언트 예제 시작...', logSink);
  
  try {
    // 클라이언트 생성
    final client = McpClient.createClient(
      name: 'Example MCP Client',
      version: '1.0.0',
      capabilities: ClientCapabilities(
        roots: true,
        rootsListChanged: true,
        sampling: true,
      ),
    );
    
    logToConsoleAndFile('클라이언트가 초기화되었습니다.', logSink);
    
    // 파일 시스템 MCP 서버와 STDIO로 연결
    logToConsoleAndFile('MCP 파일 시스템 서버에 연결 중...', logSink);
    
    final transport = await McpClient.createStdioTransport(
      command: 'npx',
      arguments: ['-y', '@modelcontextprotocol/server-filesystem', Directory.current.path],
    );
    
    logToConsoleAndFile('STDIO 전송 메커니즘이 생성되었습니다.', logSink);
    
    // 연결 설정
    await client.connect(transport);
    logToConsoleAndFile('서버에 성공적으로 연결되었습니다!', logSink);
    
    // 서버 정보 및 기능 출력
    final serverInfo = client.serverInfo;
    final serverCapabilities = client.serverCapabilities;
    
    logToConsoleAndFile('서버 정보: $serverInfo', logSink);
    logToConsoleAndFile('서버 기능:', logSink);
    logToConsoleAndFile('- 도구 지원: ${serverCapabilities?.tools}', logSink);
    logToConsoleAndFile('- 리소스 지원: ${serverCapabilities?.resources}', logSink);
    logToConsoleAndFile('- 프롬프트 지원: ${serverCapabilities?.prompts}', logSink);
    
    // 알림 핸들러 등록
    registerNotificationHandlers(client, logSink);
    
    // 서버 건강 상태 확인
    final health = await client.healthCheck();
    logToConsoleAndFile('\n--- 서버 건강 상태 ---', logSink);
    logToConsoleAndFile('서버 실행 중: ${health.isRunning}', logSink);
    logToConsoleAndFile('연결된 세션 수: ${health.connectedSessions}', logSink);
    logToConsoleAndFile('등록된 도구 수: ${health.registeredTools}', logSink);
    logToConsoleAndFile('등록된 리소스 수: ${health.registeredResources}', logSink);
    logToConsoleAndFile('등록된 프롬프트 수: ${health.registeredPrompts}', logSink);
    logToConsoleAndFile('서버 가동 시간: ${health.uptime.inSeconds}초', logSink);
    
    // 도구 목록 확인 및 호출
    await listAndCallTools(client, logSink);
    
    // 리소스 목록 확인 및 읽기
    await listAndReadResources(client, logSink);
    
    // 잠시 대기 후 종료
    await Future.delayed(Duration(seconds: 2));
    logToConsoleAndFile('예제 실행을 완료했습니다.', logSink);
    
  } catch (e, stackTrace) {
    logToConsoleAndFile('오류: $e', logSink);
    logToConsoleAndFile('스택 트레이스: $stackTrace', logSink);
  } finally {
    // 로그 파일 닫기
    await logSink.flush();
    await logSink.close();
  }
}

/// 도구 목록 확인 및 호출
Future<void> listAndCallTools(Client client, IOSink logSink) async {
  try {
    // 사용 가능한 도구 목록 확인
    final tools = await client.listTools();
    
    logToConsoleAndFile('\n--- 사용 가능한 도구 목록 ---', logSink);
    
    if (tools.isEmpty) {
      logToConsoleAndFile('사용 가능한 도구가 없습니다.', logSink);
    } else {
      for (final tool in tools) {
        logToConsoleAndFile('도구: ${tool.name} - ${tool.description}', logSink);
      }
    }
    
    // readdir 도구가 있다면 호출
    if (tools.any((tool) => tool.name == 'readdir')) {
      await callReaddirTool(client, logSink);
    }
    
    // file 도구가 있다면 호출
    if (tools.any((tool) => tool.name == 'readFile')) {
      // 예제 파일이 있으면 읽기
      final exampleFilePath = 'README.md';
      if (await File(exampleFilePath).exists()) {
        await callReadFileTool(client, logSink, exampleFilePath);
      }
    }
  } catch (e) {
    logToConsoleAndFile('도구 목록 확인 오류: $e', logSink);
  }
}

/// readdir 도구 호출
Future<void> callReaddirTool(Client client, IOSink logSink) async {
  try {
    logToConsoleAndFile('\n--- readdir 도구 호출 ---', logSink);
    
    final result = await client.callTool('readdir', {
      'path': Directory.current.path
    });
    
    if (result.isError == true) {
      logToConsoleAndFile('오류: ${(result.content.first as TextContent).text}', logSink);
    } else {
      final contentText = (result.content.first as TextContent).text;
      logToConsoleAndFile('현재 디렉토리 내용:', logSink);
      logToConsoleAndFile(contentText, logSink);
    }
  } catch (e) {
    logToConsoleAndFile('readdir 도구 호출 오류: $e', logSink);
  }
}

/// readFile 도구 호출
Future<void> callReadFileTool(Client client, IOSink logSink, String filePath) async {
  try {
    logToConsoleAndFile('\n--- readFile 도구 호출 ($filePath) ---', logSink);
    
    final result = await client.callTool('readFile', {
      'path': filePath
    });
    
    if (result.isError == true) {
      logToConsoleAndFile('오류: ${(result.content.first as TextContent).text}', logSink);
    } else {
      final contentText = (result.content.first as TextContent).text;
      // 내용이 너무 길 경우 일부만 표시
      if (contentText.length > 500) {
        logToConsoleAndFile('${contentText.substring(0, 500)}...\n(내용이 너무 길어 일부만 표시)', logSink);
      } else {
        logToConsoleAndFile(contentText, logSink);
      }
    }
  } catch (e) {
    logToConsoleAndFile('readFile 도구 호출 오류: $e', logSink);
  }
}

/// 리소스 목록 확인 및 읽기
Future<void> listAndReadResources(Client client, IOSink logSink) async {
  try {
    // 사용 가능한 리소스 목록 확인
    final resources = await client.listResources();
    
    logToConsoleAndFile('\n--- 사용 가능한 리소스 목록 ---', logSink);
    
    if (resources.isEmpty) {
      logToConsoleAndFile('사용 가능한 리소스가 없습니다.', logSink);
    } else {
      for (final resource in resources) {
        logToConsoleAndFile('리소스: ${resource.name} (${resource.uri})', logSink);
      }
    }
    
    // file:// 리소스가 있다면 읽기
    final exampleFilePath = 'README.md';
    if (await File(exampleFilePath).exists() && 
        resources.any((resource) => resource.uri.startsWith('file:'))) {
      await readFileResource(client, logSink, exampleFilePath);
    }
  } catch (e) {
    logToConsoleAndFile('리소스 목록 확인 오류: $e', logSink);
  }
}

/// 파일 리소스 읽기
Future<void> readFileResource(Client client, IOSink logSink, String filePath) async {
  try {
    final fullPath = '${Directory.current.path}/$filePath';
    logToConsoleAndFile('\n--- 파일 리소스 읽기 ($filePath) ---', logSink);
    
    final resourceResult = await client.readResource('file://$fullPath');
    
    if (resourceResult.contents.isEmpty) {
      logToConsoleAndFile('리소스에 내용이 없습니다.', logSink);
    } else {
      final content = resourceResult.contents.first.text ?? '';
      // 내용이 너무 길 경우 일부만 표시
      if (content.length > 500) {
        logToConsoleAndFile('${content.substring(0, 500)}...\n(내용이 너무 길어 일부만 표시)', logSink);
      } else {
        logToConsoleAndFile(content, logSink);
      }
    }
  } catch (e) {
    logToConsoleAndFile('파일 리소스 읽기 오류: $e', logSink);
  }
}

/// 알림 핸들러 등록
void registerNotificationHandlers(Client client, IOSink logSink) {
  // 도구 목록 변경 알림 처리
  client.onToolsListChanged(() {
    logToConsoleAndFile('도구 목록이 변경되었습니다!', logSink);
  });
  
  // 리소스 목록 변경 알림 처리
  client.onResourcesListChanged(() {
    logToConsoleAndFile('리소스 목록이 변경되었습니다!', logSink);
  });
  
  // 리소스 업데이트 알림 처리
  client.onResourceUpdated((uri) {
    logToConsoleAndFile('리소스가 업데이트되었습니다: $uri', logSink);
  });
  
  // 로깅 알림 처리
  client.onLogging((level, message, logger, data) {
    logToConsoleAndFile('서버 로그 [$level]: $message', logSink);
  });
}

/// 콘솔과 파일에 동시에 로그 기록
void logToConsoleAndFile(String message, IOSink logSink) {
  // 콘솔에 로그 출력
  _logger.debug(message);
  
  // 파일에도 로그 기록
  logSink.writeln(message);
}

실전 예제: 파일 시스템 서버와 통신하기

이제 완전한 예제를 통해 MCP 클라이언트를 구현하고, 파일 시스템 서버와 통신하는 방법을 살펴보겠습니다.

먼저 파일 시스템 서버를 사용하기 위한 준비를 합니다:

# Node.js 파일 시스템 MCP 서버 설치
npm install -g @modelcontextprotocol/server-filesystem

다음은 mcp_client_example.dart 파일의 최종 코드입니다:

import 'dart:io';
import 'dart:convert';
import 'package:mcp_client/mcp_client.dart';

/// MCP 클라이언트 예제 애플리케이션
void main() async {
  final Logger _logger = Logger.getLogger('mcp_client_example');
  _logger.setLevel(LogLevel.debug);
  
  // 로그 파일 생성
  final logFile = File('mcp_client_example.log');
  final logSink = logFile.openWrite();
  
  logToConsoleAndFile('MCP 클라이언트 예제 시작...', _logger, logSink);
  
  try {
    // 클라이언트 생성
    final client = McpClient.createClient(
      name: 'Example MCP Client',
      version: '1.0.0',
      capabilities: ClientCapabilities(
        roots: true,
        rootsListChanged: true,
        sampling: true,
      ),
    );
    
    logToConsoleAndFile('클라이언트가 초기화되었습니다.', _logger, logSink);
    
    // 파일 시스템 MCP 서버와 STDIO로 연결
    logToConsoleAndFile('MCP 파일 시스템 서버에 연결 중...', _logger, logSink);
    
    final transport = await McpClient.createStdioTransport(
      command: 'npx',
      arguments: ['-y', '@modelcontextprotocol/server-filesystem', Directory.current.path],
    );
    
    logToConsoleAndFile('STDIO 전송 메커니즘이 생성되었습니다.', _logger, logSink);
    
    // 연결 설정
    await client.connect(transport);
    logToConsoleAndFile('서버에 성공적으로 연결되었습니다!', _logger, logSink);
    
    // 알림 핸들러 등록
    client.onToolsListChanged(() {
      logToConsoleAndFile('도구 목록이 변경되었습니다!', _logger, logSink);
    });
    
    client.onResourcesListChanged(() {
      logToConsoleAndFile('리소스 목록이 변경되었습니다!', _logger, logSink);
    });
    
    client.onResourceUpdated((uri) {
      logToConsoleAndFile('리소스가 업데이트되었습니다: $uri', _logger, logSink);
    });
    
    client.onLogging((level, message, logger, data) {
      logToConsoleAndFile('서버 로그 [$level]: $message', _logger, logSink);
    });
    
    // 서버 건강 상태 확인
    final health = await client.healthCheck();
    logToConsoleAndFile('\n--- 서버 건강 상태 ---', _logger, logSink);
    logToConsoleAndFile('서버 실행 중: ${health.isRunning}', _logger, logSink);
    logToConsoleAndFile('연결된 세션 수: ${health.connectedSessions}', _logger, logSink);
    logToConsoleAndFile('등록된 도구 수: ${health.registeredTools}', _logger, logSink);
    logToConsoleAndFile('등록된 리소스 수: ${health.registeredResources}', _logger, logSink);
    logToConsoleAndFile('등록된 프롬프트 수: ${health.registeredPrompts}', _logger, logSink);
    logToConsoleAndFile('서버 가동 시간: ${health.uptime.inSeconds}초', _logger, logSink);
    
    // 도구 목록 확인
    try {
      final tools = await client.listTools();
      logToConsoleAndFile('\n--- 사용 가능한 도구 목록 ---', _logger, logSink);
      
      if (tools.isEmpty) {
        logToConsoleAndFile('사용 가능한 도구가 없습니다.', _logger, logSink);
      } else {
        for (final tool in tools) {
          logToConsoleAndFile('도구: ${tool.name} - ${tool.description}', _logger, logSink);
        }
      }
      
      // 현재 디렉토리 조회
      if (tools.any((tool) => tool.name == 'list_directory')) {
        logToConsoleAndFile('\n--- 현재 디렉토리 내용 조회 ---', _logger, logSink);
        
        final result = await client.callTool('list_directory', {
          'path': Directory.current.path
        });
        
        if (result.isError == true) {
          logToConsoleAndFile('오류: ${(result.content.first as TextContent).text}', _logger, logSink);
        } else {
          final contentText = (result.content.first as TextContent).text;
          logToConsoleAndFile('현재 디렉토리 내용:', _logger, logSink);
          logToConsoleAndFile(contentText, _logger, logSink);
        }
      }
      
      // file_info 도구가 있으면 README.md 파일 정보 조회
      if (tools.any((tool) => tool.name == 'get_file_info')) {
        final readmeFile = 'README.md';
        if (await File(readmeFile).exists()) {
          logToConsoleAndFile('\n--- README.md 파일 정보 조회 ---', _logger, logSink);
          
          final infoResult = await client.callTool('get_file_info', {
            'path': '${Directory.current.path}/$readmeFile'
          });
          
          if (infoResult.isError == true) {
            logToConsoleAndFile('오류: ${(infoResult.content.first as TextContent).text}', _logger, logSink);
          } else {
            final infoText = (infoResult.content.first as TextContent).text;
            logToConsoleAndFile('파일 정보:', _logger, logSink);
            logToConsoleAndFile(infoText, _logger, logSink);
          }
        }
      }
      
      // README.md 파일이 있으면 내용 읽기
      if (tools.any((tool) => tool.name == 'read_file')) {
        final readmeFile = 'README.md';
        if (await File(readmeFile).exists()) {
          logToConsoleAndFile('\n--- README.md 파일 읽기 ---', _logger, logSink);
          
          final readResult = await client.callTool('read_file', {
            'path': '${Directory.current.path}/$readmeFile'
          });
          
          if (readResult.isError == true) {
            logToConsoleAndFile('오류: ${(readResult.content.first as TextContent).text}', _logger, logSink);
          } else {
            final content = (readResult.content.first as TextContent).text;
            
            // 내용이 너무 길 경우 일부만 표시
            if (content.length > 500) {
              logToConsoleAndFile('${content.substring(0, 500)}...\n(내용이 너무 길어 일부만 표시)', _logger, logSink);
            } else {
              logToConsoleAndFile(content, _logger, logSink);
            }
          }
        }
      }
    } catch (e) {
      logToConsoleAndFile('도구 목록 확인 오류: $e', _logger, logSink);
    }
    
    // 리소스 목록 확인 (try-catch로 감싸기)
    try {
      logToConsoleAndFile('\n--- 리소스 목록 확인 ---', _logger, logSink);
      final resources = await client.listResources();
      
      if (resources.isEmpty) {
        logToConsoleAndFile('사용 가능한 리소스가 없습니다.', _logger, logSink);
      } else {
        for (final resource in resources) {
          logToConsoleAndFile('리소스: ${resource.name} (${resource.uri})', _logger, logSink);
        }
        
        // 파일 시스템 리소스가 있으면 README.md 파일 읽기
        final readmeFile = 'README.md';
        if (await File(readmeFile).exists() && 
            resources.any((resource) => resource.uri.startsWith('file:'))) {
          logToConsoleAndFile('\n--- 리소스로 README.md 파일 읽기 ---', _logger, logSink);
          
          try {
            final fullPath = '${Directory.current.path}/$readmeFile';
            final resourceResult = await client.readResource('file://$fullPath');
            
            if (resourceResult.contents.isEmpty) {
              logToConsoleAndFile('리소스에 내용이 없습니다.', _logger, logSink);
            } else {
              final content = resourceResult.contents.first.text ?? '';
              
              // 내용이 너무 길 경우 일부만 표시
              if (content.length > 500) {
                logToConsoleAndFile('${content.substring(0, 500)}...\n(내용이 너무 길어 일부만 표시)', _logger, logSink);
              } else {
                logToConsoleAndFile(content, _logger, logSink);
              }
            }
          } catch (e) {
            logToConsoleAndFile('리소스로 파일 읽기 오류: $e', _logger, logSink);
          }
        }
      }
    } catch (e) {
      logToConsoleAndFile('리소스 기능이 지원되지 않습니다: $e', _logger, logSink);
    }
    
    // 잠시 대기 후 종료
    await Future.delayed(Duration(seconds: 2));
    logToConsoleAndFile('\n예제 실행을 완료했습니다.', _logger, logSink);
    
    // 클라이언트 연결 종료
    client.disconnect();
    logToConsoleAndFile('클라이언트 연결이 종료되었습니다.', _logger, logSink);
    
  } catch (e, stackTrace) {
    logToConsoleAndFile('오류: $e', _logger, logSink);
    logToConsoleAndFile('스택 트레이스: $stackTrace', _logger, logSink);
  } finally {
    // 로그 파일 닫기
    await logSink.flush();
    await logSink.close();
  }
}

/// 콘솔과 파일에 동시에 로그 기록
void logToConsoleAndFile(String message, Logger logger, IOSink logSink) {
  // 콘솔에 로그 출력
  logger.debug(message);
  
  // 파일에도 로그 기록
  logSink.writeln(message);
}

이 예제는 다음과 같은 작업을 수행합니다:

  1. MCP 클라이언트 초기화
  2. 파일 시스템 MCP 서버와 STDIO 방식으로 연결
  3. 알림 핸들러 등록 (도구 목록 변경, 리소스 목록 변경, 리소스 업데이트)
  4. 서버 건강 상태 확인
  5. 사용 가능한 도구 목록 조회
  6. 현재 디렉토리 내용 조회 (readdir 도구 사용)
  7. README.md 파일 내용 읽기 (readFile 도구 사용)
  8. 리소스 API를 통해 README.md 파일 내용 읽기 (file:// 리소스 사용)

프로그램 실행은 다음과 같이 합니다:

# 예제 코드 실행
dart run bin/mcp_client_example.dart

실행 결과는 콘솔 출력과 mcp_client_example.log 파일에서 확인할 수 있습니다.

마무리

이 글에서는 Flutter/Dart로 MCP 클라이언트를 구현하고, 이를 통해 MCP 서버와 통신하는 방법을 살펴보았습니다. mcp_client 패키지를 활용하면 다양한 MCP 서버와 쉽게 통신할 수 있으며, 서버가 제공하는 도구와 리소스를 활용할 수 있습니다.

주요 내용을 요약하면 다음과 같습니다:

  1. MCP 클라이언트 생성 및 초기화
  2. STDIO 및 SSE 전송 방식을 통한 서버 연결
  3. 도구 목록 조회 및 호출
  4. 리소스 목록 조회 및 읽기
  5. 알림 핸들러 등록 및 사용
  6. 파일 시스템 서버와의 통신 예제

MCP 클라이언트를 사용하면 별도의 기능을 직접 구현하지 않고도 MCP 프로토콜을 지원하는 다양한 서버와 통신할 수 있습니다. 이는 특히 다양한 외부 도구나 리소스에 접근해야 하는 애플리케이션 개발에 유용합니다.

다음 글에서는 MCP와 LLM(대규모 언어 모델)을 통합하여 AI 모델이 로컬 시스템과 상호작용하는 방법에 대해 알아보겠습니다. mcp_llm 패키지를 활용하여 Claude와 같은 AI 모델이 로컬 시스템의 도구와 리소스를 활용하는 방법을 살펴볼 예정입니다.

전체 소스 코드는 GitHub에서 확인할 수 있습니다:
https://github.com/app-appplayer/mcp_client.git


개발자 지원하기

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

Support on Patreon

0개의 댓글