Flutter와 AI의 만남: LlmClient와 mcp_client 통합하기

MCP Dev Studio·2025년 4월 30일

이 글은 Model Context Protocol(MCP) 시리즈의 네 번째 포스트이자 mcp_llm의 두 번째 글로, Flutter와 AI의 만남: mcp_llm 소개에 이어 LlmClient와 mcp_client의 통합에 대해 심층적으로 알아봅니다. MCP 생태계에서 두 컴포넌트의 통합을 통해 AI 앱이 외부 도구와 리소스에 접근하는 방법을 배워봅시다.

Flutter와 AI 통합

목차

LlmClient와 mcp_client의 역할

이전 글에서 mcp_llm 패키지의 개요와 기본 구성 요소를 살펴보았습니다. 이번 글에서는 AI 기능을 Flutter 앱에 통합하는 데 핵심적인 두 가지 컴포넌트인 LlmClientmcp_client의 통합에 초점을 맞추겠습니다.

LlmClient란?

LlmClientmcp_llm 패키지의 핵심 컴포넌트로, 다양한 LLM 제공자(Claude, OpenAI 등)와의 통신을 담당합니다. 주요 기능은 다음과 같습니다:

  • LLM에 메시지 전송 및 응답 수신
  • 채팅 세션 및 컨텍스트 관리
  • 도구 호출 처리 및 응답 스트리밍
  • 시스템 메시지 및 프롬프트 설정

mcp_client란?

mcp_client는 Model Context Protocol(MCP)의 클라이언트 구현체로, 다음과 같은 기능을 제공합니다:

  • MCP 서버와의 통신 및 연결 관리
  • 외부 도구(Tools) 호출 및 실행
  • 리소스(Resources) 접근 및 활용
  • 프롬프트(Prompts) 템플릿 접근 및 활용

통합의 의미

LlmClientmcp_client의 통합은 AI 모델과 외부 환경 간의 상호작용을 가능하게 합니다. 이 통합을 통해:

  1. AI 모델이 외부 도구를 호출할 수 있음
  2. 외부 리소스(데이터베이스, API 등)에 접근할 수 있음
  3. 표준화된 프롬프트 템플릿을 활용할 수 있음
  4. 복잡한 도구 기반 워크플로우를 구현할 수 있음

이러한 통합은 단순 텍스트 생성을 넘어 실제 기능을 수행하는 AI 앱을 구축하는 데 필수적입니다.

통합 아키텍처 이해하기

LlmClientmcp_client의 통합 아키텍처를 이해하는 것이 중요합니다. 이 통합은 다음과 같은 구조로 이루어집니다:

통합 모델

┌────────────────┐       ┌────────────────┐
│   LlmClient    │◄─────►│   mcp_client   │
└───────┬────────┘       └───────┬────────┘
        │                        │
        ▼                        ▼
┌────────────────┐       ┌────────────────┐
│  LLM Provider  │       │   MCP Server   │
│  (Claude, GPT) │       │  (Tools, etc.) │
└────────────────┘       └────────────────┘

통신 흐름

  1. LlmClient가 LLM 제공자에게 사용자 쿼리를 전송합니다.
  2. LLM이 도구 호출이 필요하다고 결정하면 도구 호출 요청을 반환합니다.
  3. LlmClient는 이 도구 호출을 mcp_client로 전달합니다.
  4. mcp_client는 MCP 서버에 연결된 도구를 실행합니다.
  5. 도구 실행 결과가 mcp_client로 반환됩니다.
  6. LlmClient는 도구 결과를 LLM에 다시 전달합니다.
  7. LLM은 도구 결과를 바탕으로 최종 응답을 생성합니다.

핵심 컴포넌트

통합을 구성하는 핵심 컴포넌트들을 살펴보겠습니다:

  1. LlmClient: AI 모델과의 통신을 담당합니다.
  2. mcp_client: MCP 서버와의 통신을 담당합니다.
  3. McpClientManager: 여러 mcp_client 인스턴스를 관리합니다.
  4. LlmClientAdapter: mcp_client와 LlmClient 간의 인터페이스를 제공합니다.
  5. ChatSession: 대화 컨텍스트를 관리합니다.
  6. ToolCallProcessor: 도구 호출 처리를 담당합니다.

이들 컴포넌트가 함께 작동하여 AI와 외부 환경 간의 통합된 경험을 제공합니다.

LlmClient와 mcp_client 설정하기

이제 LlmClientmcp_client를 설정하고 통합하는 방법을 살펴보겠습니다.

프로젝트 설정

앱 생성 및 설정을 위해 다음 단계를 따르세요:

# 새 Flutter 프로젝트 생성
flutter create mcp_llm_client_integration

# 프로젝트 디렉토리로 이동
cd mcp_llm_client_integration

# 필요한 패키지 추가
flutter pub add mcp_llm
flutter pub add mcp_client
flutter pub add flutter_dotenv

환경 설정을 위해 프로젝트 루트에 .env 파일을 생성하세요:

CLAUDE_API_KEY=your-claude-api-key
MCP_SERVER_URL=http://localhost:8999/sse
MCP_AUTH_TOKEN=your-auth-token

pubspec.yaml 파일의 assets 섹션에 .env 파일을 추가하세요:

flutter:
  assets:
    - .env
    # 기타 에셋들...

전체 샘플 코드는 mcp_llm_client_integration 저장소에서 확인할 수 있습니다.

기본 설정

import 'package:mcp_llm/mcp_llm.dart';
import 'package:mcp_client/mcp_client.dart' as mcp;

Future<void> setupIntegration() async {
  // McpLlm 인스턴스 생성
  final mcpLlm = McpLlm();
  
  // 프로바이더 등록
  mcpLlm.registerProvider('claude', ClaudeProviderFactory());
  
  // MCP 클라이언트 생성
  final mcpClient = mcp.McpClient.createClient(
    name: 'my_app',
    version: '1.0.0',
    capabilities: mcp.ClientCapabilities(
      roots: true,
      rootsListChanged: true,
      sampling: true,
    ),
  );
  
  // 트랜스포트 생성 및 연결
  final transport = await mcp.McpClient.createSseTransport(
    serverUrl: 'http://localhost:8999/sse',
    headers: {'Authorization': 'Bearer your_token'},
  );
  
  // MCP 서버에 연결
  await mcpClient.connectWithRetry(
    transport,
    maxRetries: 3,
    delay: const Duration(seconds: 2),
  );
  
  // LlmClient 생성 (mcp_client 연결)
  final llmClient = await mcpLlm.createClient(
    providerName: 'claude',
    config: LlmConfiguration(
      apiKey: 'your-claude-api-key',
      model: 'claude-3-haiku-20240307',
    ),
    mcpClient: mcpClient,  // MCP 클라이언트 연결
    systemPrompt: 'You are a helpful assistant with access to various tools.',
  );
  
  // 이제 llmClient를 통해 AI와 도구를 활용할 수 있습니다
}

주요 설정 옵션

MCP 클라이언트 설정:

final mcpClient = mcp.McpClient.createClient(
  name: 'my_app',         // 클라이언트 이름
  version: '1.0.0',       // 클라이언트 버전
  capabilities: mcp.ClientCapabilities(
    roots: true,          // 루트 사용 가능
    rootsListChanged: true, // 루트 목록 변경 알림 수신
    sampling: true,       // 샘플링 기능 사용 가능
  ),
);

LlmClient 설정:

final llmClient = await mcpLlm.createClient(
  providerName: 'claude', // LLM 제공자
  config: LlmConfiguration(
    apiKey: 'your-claude-api-key',
    model: 'claude-3-haiku-20240307',
    options: {
      'temperature': 0.7,
      'max_tokens': 1500,
    },
  ),
  mcpClient: mcpClient,   // MCP 클라이언트 연결
  systemPrompt: 'You are a helpful assistant with access to various tools.',
  clientId: 'main_client', // 선택적 클라이언트 ID
);

연결 상태 모니터링

MCP 클라이언트의 연결 상태를 모니터링하는 것이 중요합니다:

mcpClient.onNotification('connection_state_changed', (params) {
  final state = params['state'] as String;
  print('MCP 연결 상태: $state');
  // 상태 변화에 따른 UI 업데이트 등
});

MCP 도구 호출 및 활용하기

mcp_client를 통해 사용할 수 있는 도구들을 활용하는 방법을 살펴보겠습니다.

사용 가능한 도구 목록 조회

Future<void> listAvailableTools() async {
  final tools = await mcpClient.listTools();
  
  print('사용 가능한 도구 목록:');
  for (final tool in tools) {
    print('- ${tool.name}: ${tool.description}');
    print('  입력 스키마: ${tool.inputSchema}');
  }
}

도구 목록 변경 모니터링

mcpClient.onNotification('tools_list_changed', (tools) {
  print('도구 목록이 변경되었습니다');
  // 도구 목록 업데이트 처리
});

AI를 통한 도구 호출 (간접 호출)

AI가 필요에 따라 도구를 호출하도록 설정:

Future<void> chatWithToolUse() async {
  final response = await llmClient.chat(
    "오늘 서울의 날씨는 어때?", 
    enableTools: true,  // 도구 사용을 활성화
  );
  
  print('AI 응답: ${response.text}');
  
  // 도구 호출 확인
  if (response.toolCalls != null && response.toolCalls!.isNotEmpty) {
    print('사용된 도구:');
    for (var i = 0; i < response.toolCalls!.length; i++) {
      final toolCall = response.toolCalls![i];
      print('- ${toolCall.name}');
      print('  인자: ${toolCall.arguments}');
    }
  }
}

직접 도구 호출

특정 도구를 직접 호출하는 방법:

Future<void> executeToolDirectly() async {
  try {
    final result = await llmClient.executeTool(
      'weather',  // 도구 이름
      {
        'location': '서울',
        'unit': 'celsius',
      },
    );
    
    print('도구 실행 결과: $result');
  } catch (e) {
    print('도구 실행 오류: $e');
  }
}

스트리밍 응답에서 도구 호출 처리

Future<void> streamChatWithToolUse() async {
  final responseStream = llmClient.streamChat(
    "오늘 서울의 날씨와 내일의 날씨 예보를 알려줘",
    enableTools: true,
  );
  
  final StringBuffer currentResponse = StringBuffer();
  
  await for (final chunk in responseStream) {
    // 텍스트 청크 추가
    if (chunk['textChunk'] != null && chunk['textChunk'].isNotEmpty) {
      currentResponse.write(chunk['textChunk']);
      print('현재 응답: ${currentResponse.toString()}');
    }
    
    // 도구 호출 처리 확인
    if (chunk['processing_tools'] != null) {
      print('도구 호출 처리 중...');
    }
    
    // 도구 결과 확인
    if (chunk['toolCalls'] != null) {
      print('도구 호출 정보 수신:');
      final calls = chunk['toolCalls'] as List;
      for (var i = 0; i < calls.length; i++) {
        print('- ${calls[i]['name']}: ${calls[i]['arguments']}');
      }
    }
    
    // 스트림 완료 확인
    if (chunk['isDone'] == true) {
      print('응답 스트림 완료');
    }
  }
}

MCP 리소스 및 프롬프트 접근하기

MCP 리소스와 프롬프트를 활용하는 방법을 살펴보겠습니다.

사용 가능한 리소스 목록 조회

Future<void> listAvailableResources() async {
  final resources = await mcpClient.listResources();
  
  print('사용 가능한 리소스 목록:');
  for (final resource in resources) {
    print('- ${resource.name}: ${resource.description}');
    print('  URI: ${resource.uri}');
    print('  MIME 타입: ${resource.mimeType}');
  }
}

리소스 읽기

Future<void> readResource() async {
  try {
    final resourceContent = await mcpClient.readResource('company_data');
    
    print('리소스 내용: $resourceContent');
    
    // AI와 함께 리소스 활용
    final response = await llmClient.chat(
      "다음 회사 데이터를 분석해줘: $resourceContent",
    );
    
    print('AI 분석: ${response.text}');
  } catch (e) {
    print('리소스 읽기 오류: $e');
  }
}

템플릿을 사용한 리소스 접근

Future<void> getResourceWithTemplate() async {
  try {
    final result = await mcpClient.getResourceWithTemplate(
      'files://myproject/{filename}',
      {'filename': 'config.json'},
    );
    
    print('템플릿 리소스 결과: $result');
  } catch (e) {
    print('템플릿 리소스 오류: $e');
  }
}

사용 가능한 프롬프트 목록 조회

Future<void> listAvailablePrompts() async {
  final prompts = await mcpClient.listPrompts();
  
  print('사용 가능한 프롬프트 목록:');
  for (final prompt in prompts) {
    print('- ${prompt.name}: ${prompt.description}');
    print('  인자: ${prompt.arguments}');
  }
}

프롬프트 가져오기 및 사용하기

Future<void> usePromptTemplate() async {
  try {
    // 프롬프트 템플릿 가져오기
    final promptResult = await mcpClient.getPrompt(
      'product_description',  // 프롬프트 이름
      {
        'product': '스마트 워치',
        'target_audience': '젊은 전문직',
        'tone': '현대적이고 기술적인',
      },
    );
    
    // 프롬프트 메시지 확인
    print('프롬프트 메시지:');
    for (final message in promptResult.messages) {
      print('- ${message.role}: ${message.content}');
    }
    
    // 프롬프트를 사용하여 AI 응답 생성
    final response = await llmClient.chat(
      "다음 프롬프트를 사용하여 내용을 생성해줘",
      history: promptResult.messages,  // 프롬프트 메시지를 대화 이력으로 사용
    );
    
    print('AI 응답: ${response.text}');
  } catch (e) {
    print('프롬프트 사용 오류: $e');
  }
}

다중 mcp_client 구성 및 관리하기

여러 MCP 클라이언트를 동시에 관리하는 방법을 살펴보겠습니다.

다중 MCP 클라이언트 등록

// 첫 번째 MCP 클라이언트 생성
final toolsClient = mcp.McpClient.createClient(
  name: 'tools_client',
  version: '1.0.0',
  capabilities: mcp.ClientCapabilities(roots: true),
);

// 두 번째 MCP 클라이언트 생성
final resourcesClient = mcp.McpClient.createClient(
  name: 'resources_client',
  version: '1.0.0',
  capabilities: mcp.ClientCapabilities(roots: true),
);

// 클라이언트 연결
await toolsClient.connectWithRetry(toolsTransport, maxRetries: 3);
await resourcesClient.connectWithRetry(resourcesTransport, maxRetries: 3);

// LlmClient 생성 (다중 MCP 클라이언트 등록)
final llmClient = await mcpLlm.createClient(
  providerName: 'claude',
  config: LlmConfiguration(
    apiKey: 'your-claude-api-key',
    model: 'claude-3-haiku-20240307',
  ),
  mcpClients: {
    'tools': toolsClient,
    'resources': resourcesClient,
  },
  systemPrompt: 'You are a helpful assistant with access to various tools and resources.',
);

McpClientManager를 통한 MCP 클라이언트 관리

// 이미 생성된 LlmClient에 새 MCP 클라이언트 추가
await mcpLlm.addMcpClientToLlmClient(
  'main_client',  // LlmClient ID
  'prompts',      // 새 MCP 클라이언트 ID
  promptsClient,  // MCP 클라이언트 인스턴스
);

// MCP 클라이언트 제거
await mcpLlm.removeMcpClientFromLlmClient(
  'main_client',  // LlmClient ID
  'tools',        // 제거할 MCP 클라이언트 ID
);

// 기본 MCP 클라이언트 설정
await mcpLlm.setDefaultMcpClient(
  'main_client',  // LlmClient ID
  'resources',    // 기본으로 설정할 MCP 클라이언트 ID
);

// 등록된 MCP 클라이언트 ID 목록 가져오기
final clientIds = mcpLlm.getMcpClientIds('main_client');
print('등록된 MCP 클라이언트 ID: $clientIds');

특정 MCP 클라이언트로 도구 호출

// 특정 MCP 클라이언트의 도구 직접 호출
final result = await llmClient.executeTool(
  'weather',  // 도구 이름
  {
    'location': '서울',
    'unit': 'celsius',
  },
  mcpClientId: 'tools',  // 특정 MCP 클라이언트 ID
);

오류 처리 및 연결 관리하기

LlmClient와 mcp_client의 통합에서 오류 처리와 연결 관리는 중요한 부분입니다.

연결 오류 처리

try {
  await mcpClient.connectWithRetry(
    transport,
    maxRetries: 5,
    delay: const Duration(seconds: 2),
    onRetry: (retryCount, error) {
      print('연결 재시도 ($retryCount): $error');
      return true; // 계속 재시도
    },
  );
} catch (e) {
  print('MCP 서버 연결 실패: $e');
  // 폴백 전략 구현
}

자동 재연결 설정

// 자동 재연결 설정 및 콜백 등록
bool isReconnecting = false;

mcpClient.onNotification('connection_state_changed', (params) {
  final state = params['state'] as String;
  
  if (state == 'disconnected' && !isReconnecting) {
    isReconnecting = true;
    print('연결이 끊겼습니다. 재연결 시도 중...');
    
    // 재연결 시도
    mcpClient.connectWithRetry(transport, maxRetries: 10).then((_) {
      print('재연결 성공!');
      isReconnecting = false;
    }).catchError((e) {
      print('재연결 실패: $e');
      isReconnecting = false;
    });
  }
});

도구 호출 오류 처리

try {
  final response = await llmClient.chat(
    "오늘 서울의 날씨는 어때?",
    enableTools: true,
  );
  
  print('AI 응답: ${response.text}');
} catch (e) {
  if (e.toString().contains('Tool execution error')) {
    print('도구 실행 오류: $e');
    // 도구 오류 복구 전략 구현
  } else if (e.toString().contains('Network error')) {
    print('네트워크 오류: $e');
    // 네트워크 오류 처리
  } else {
    print('일반 오류: $e');
  }
}

상태 모니터링 및 오류 대응

// 클라이언트 상태 변경 모니터링
llmClient.onStateChanged.listen((state) {
  print('LlmClient 상태 변경: $state');
  
  // 오류 상태 확인
  if (state == LlmClientState.error) {
    // 오류 복구 로직
    retryConnection();
  }
});

// 주요 이벤트 로깅
mcpClient.onNotification('logging', (logData) {
  final level = logData['level'] as String;
  final message = logData['message'] as String;
  
  print('[$level] $message');
  
  // 심각한 오류 감지
  if (level == 'error' || level == 'critical') {
    // 오류 알림 및 처리
    notifyErrorMonitoring(message);
  }
});

실제 통합 예제: AI 도구 활용 앱

이제 실제로 Flutter 앱에서 LlmClient와 mcp_client를 통합하여 도구를 활용하는 예제를 살펴보겠습니다.

AI 서비스 구현

// lib/services/ai_service.dart
import 'dart:async';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:mcp_llm/mcp_llm.dart';
import 'package:mcp_client/mcp_client.dart' as mcp;

class AiService {
  late McpLlm _mcpLlm;
  LlmClient? _llmClient;
  mcp.Client? _mcpClient;
  final _connectionStateController = StreamController<bool>.broadcast();
  
  Stream<bool> get connectionState => _connectionStateController.stream;
  bool get isConnected => _mcpClient != null && _mcpClient!.isConnected;
  
  // 초기화
  Future<void> initialize() async {
    try {
      // McpLlm 인스턴스 생성
      _mcpLlm = McpLlm();
      _mcpLlm.registerProvider('claude', ClaudeProviderFactory());
      
      // MCP 클라이언트 설정
      await _setupMcpClient();
      
      // LLM 클라이언트 설정
      await _setupLlmClient();
      
      // 성공적으로 초기화됨
      _connectionStateController.add(true);
    } catch (e) {
      print('AI 서비스 초기화 오류: $e');
      _connectionStateController.add(false);
      rethrow;
    }
  }
  
  Future<void> _setupMcpClient() async {
    final serverUrl = dotenv.env['MCP_SERVER_URL'] ?? '';
    final authToken = dotenv.env['MCP_AUTH_TOKEN'] ?? '';
    
    if (serverUrl.isEmpty || authToken.isEmpty) {
      throw Exception('MCP 서버 URL 및 인증 토큰을 설정해주세요');
    }
    
    // MCP 클라이언트 생성
    _mcpClient = mcp.McpClient.createClient(
      name: 'flutter_app',
      version: '1.0.0',
      capabilities: mcp.ClientCapabilities(
        roots: true,
        rootsListChanged: true,
        sampling: true,
      ),
    );
    
    // 트랜스포트 생성
    final transport = await mcp.McpClient.createSseTransport(
      serverUrl: serverUrl,
      headers: {'Authorization': 'Bearer $authToken'},
    );
    
    // 이벤트 핸들링 설정 (연결 상태 변경 감지)
    bool isConnectedState = false;
    
    // initialize 메서드를 호출한 후 연결 상태를 갱신
    _mcpClient!.onNotification('connection_state_changed', (params) {
      final state = params['state'] as String;
      isConnectedState = state == 'connected';
      print('MCP 연결 상태: $state');
      _connectionStateController.add(isConnectedState);
    });
    
    // 서버에 연결
    await _mcpClient!.connectWithRetry(
      transport,
      maxRetries: 3,
      delay: const Duration(seconds: 2),
    );
    
    // 초기 연결이 완료되면 상태 업데이트
    _connectionStateController.add(true);
  }
  
  Future<void> _setupLlmClient() async {
    final apiKey = dotenv.env['CLAUDE_API_KEY'] ?? '';
    
    if (apiKey.isEmpty) {
      throw Exception('Claude API 키를 설정해주세요');
    }
    
    // LLM 클라이언트 생성
    _llmClient = await _mcpLlm.createClient(
      providerName: 'claude',
      config: LlmConfiguration(
        apiKey: apiKey,
        model: 'claude-3-haiku-20240307',
        options: {
          'temperature': 0.7,
          'max_tokens': 1500,
        },
      ),
      mcpClient: _mcpClient,
      systemPrompt: 'You are a helpful assistant with access to various tools and resources. Provide concise and accurate responses.',
    );
  }
  
  // 도구 목록 가져오기
  Future<List<mcp.Tool>> getAvailableTools() async {
    if (_mcpClient == null || !isConnected) {
      throw Exception('MCP 클라이언트가 연결되어 있지 않습니다');
    }
    
    return await _mcpClient!.listTools();
  }
  
  // AI와 채팅 (도구 사용 가능)
  Future<LlmResponse> chat(String message, {bool enableTools = true}) async {
    if (_llmClient == null) {
      throw Exception('LLM 클라이언트가 초기화되지 않았습니다');
    }
    
    return await _llmClient!.chat(
      message,
      enableTools: enableTools,
    );
  }
  
  // 스트리밍 채팅
  Stream<dynamic> streamChat(String message, {bool enableTools = true}) {
    if (_llmClient == null) {
      throw Exception('LLM 클라이언트가 초기화되지 않았습니다');
    }
    
    return _llmClient!.streamChat(
      message,
      enableTools: enableTools,
    );
  }
  
  // 특정 도구 직접 실행
  Future<dynamic> executeTool(String toolName, Map<String, dynamic> arguments) async {
    if (_llmClient == null) {
      throw Exception('LLM 클라이언트가 초기화되지 않았습니다');
    }
    
    return await _llmClient!.executeTool(
      toolName,
      arguments,
    );
  }
  
  // 리소스 가져오기
  Future<dynamic> getResource(String resourceName) async {
    if (_mcpClient == null || !isConnected) {
      throw Exception('MCP 클라이언트가 연결되어 있지 않습니다');
    }
    
    return await _mcpClient!.readResource(resourceName);
  }
  
  // 템플릿을 사용하여 리소스 가져오기
  Future<dynamic> getResourceWithTemplate(String templateUri, Map<String, dynamic> params) async {
    if (_mcpClient == null || !isConnected) {
      throw Exception('MCP 클라이언트가 연결되어 있지 않습니다');
    }
    
    return await _mcpClient!.getResourceWithTemplate(templateUri, params);
  }
  
  // 종료
  Future<void> dispose() async {
    await _mcpLlm.shutdown();
    _connectionStateController.close();
  }
}

메인 앱 구현

// lib/main.dart
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'services/ai_service.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'AI 도구 활용 앱',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const AiAssistantScreen(),
    );
  }
}

class AiAssistantScreen extends StatefulWidget {
  const AiAssistantScreen({super.key});

  
  _AiAssistantScreenState createState() => _AiAssistantScreenState();
}

class _AiAssistantScreenState extends State<AiAssistantScreen> {
  final TextEditingController _textController = TextEditingController();
  final List<ChatMessage> _messages = [];
  final AiService _aiService = AiService();
  bool _isConnected = false;
  bool _isTyping = false;
  
  
  void initState() {
    super.initState();
    _initializeAiService();
  }
  
  Future<void> _initializeAiService() async {
    try {
      await _aiService.initialize();
      
      _aiService.connectionState.listen((connected) {
        setState(() {
          _isConnected = connected;
        });
      });
      
      // 사용 가능한 도구 목록 표시
      final tools = await _aiService.getAvailableTools();
      setState(() {
        _messages.add(ChatMessage(
          text: '사용 가능한 도구 목록:\n' +
              tools.map((t) => '- ${t.name}: ${t.description}').join('\n'),
          isUser: false,
        ));
      });
    } catch (e) {
      _showError('AI 서비스 초기화 오류: $e');
    }
  }
  
  void _showError(String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(message)),
    );
  }
  
  void _handleSubmitted(String text) async {
    if (text.trim().isEmpty) return;
    
    _textController.clear();
    
    setState(() {
      _messages.add(ChatMessage(
        text: text,
        isUser: true,
      ));
      _isTyping = true;
    });
    
    try {
      if (text.startsWith('/stream')) {
        // 스트리밍 모드
        await _handleStreamChat(text.replaceFirst('/stream', '').trim());
      } else if (text.startsWith('/tool ')) {
        // 직접 도구 호출
        await _handleDirectToolCall(text.replaceFirst('/tool ', '').trim());
      } else {
        // 일반 채팅
        final response = await _aiService.chat(text);
        
        // 도구 호출 정보 추출
        final List<Map<String, dynamic>> toolCallsList = [];
        if (response.toolCalls != null) {
          for (var i = 0; i < response.toolCalls!.length; i++) {
            final call = response.toolCalls![i];
            toolCallsList.add({
              'name': call.name,
              'arguments': call.arguments,
            });
          }
        }
        
        setState(() {
          _messages.add(ChatMessage(
            text: response.text,
            isUser: false,
            toolCalls: toolCallsList,
          ));
          _isTyping = false;
        });
      }
    } catch (e) {
      _showError('오류: $e');
      setState(() {
        _isTyping = false;
      });
    }
  }
  
  Future<void> _handleStreamChat(String text) async {
    // 스트리밍 응답용 임시 메시지 추가
    final int messageIndex = _messages.length;
    setState(() {
      _messages.add(ChatMessage(
        text: '생성 중...',
        isUser: false,
      ));
    });
    
    final StringBuffer fullResponse = StringBuffer();
    final List<Map<String, dynamic>> toolCallsList = [];
    
    try {
      final responseStream = _aiService.streamChat(text);
      
      await for (final chunk in responseStream) {
        // 텍스트 청크가 있으면 응답에 추가
        if (chunk['textChunk'] != null && chunk['textChunk'].isNotEmpty) {
          fullResponse.write(chunk['textChunk']);
          
          setState(() {
            _messages[messageIndex] = ChatMessage(
              text: fullResponse.toString(),
              isUser: false,
              toolCalls: toolCallsList,
            );
          });
        }
        
        // 도구 호출 정보가 있으면 리스트에 추가
        if (chunk['toolCalls'] != null) {
          final calls = chunk['toolCalls'] as List;
          for (var i = 0; i < calls.length; i++) {
            toolCallsList.add({
              'name': calls[i]['name'],
              'arguments': calls[i]['arguments'],
            });
          }
          
          setState(() {
            _messages[messageIndex] = ChatMessage(
              text: fullResponse.toString(),
              isUser: false,
              toolCalls: toolCallsList,
            );
          });
        }
        
        // 스트림 완료 확인
        if (chunk['isDone'] == true) {
          setState(() {
            _isTyping = false;
          });
        }
      }
    } catch (e) {
      _showError('스트리밍 오류: $e');
      setState(() {
        _isTyping = false;
      });
    }
  }
  
  Future<void> _handleDirectToolCall(String text) async {
    // 도구 명령 형식: /tool {도구이름} {인자들(JSON)}
    final parts = text.split(' ');
    if (parts.length < 2) {
      _showError('도구 호출 형식이 잘못되었습니다. "/tool 도구이름 {인자들}" 형식을 사용하세요.');
      setState(() {
        _isTyping = false;
      });
      return;
    }
    
    final toolName = parts[0];
    final argsText = parts.sublist(1).join(' ');
    Map<String, dynamic> args;
    
    try {
      args = jsonDecode(argsText);
    } catch (e) {
      _showError('인자가 유효한 JSON 형식이 아닙니다: $e');
      setState(() {
        _isTyping = false;
      });
      return;
    }
    
    try {
      final result = await _aiService.executeTool(toolName, args);
      
      setState(() {
        _messages.add(ChatMessage(
          text: '도구 실행 결과: $result',
          isUser: false,
        ));
        _isTyping = false;
      });
    } catch (e) {
      _showError('도구 실행 오류: $e');
      setState(() {
        _isTyping = false;
      });
    }
  }
  
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('AI 도구 활용 앱'),
        actions: [
          Icon(
            _isConnected ? Icons.cloud_done : Icons.cloud_off,
            color: _isConnected ? Colors.green : Colors.red,
          ),
          const SizedBox(width: 16),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              padding: const EdgeInsets.all(8.0),
              reverse: false,
              itemCount: _messages.length,
              itemBuilder: (_, index) => _messages[index],
            ),
          ),
          if (_isTyping)
            const Padding(
              padding: EdgeInsets.all(8.0),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.start,
                children: [
                  CircularProgressIndicator(),
                  SizedBox(width: 8),
                  Text('AI가 응답하는 중...'),
                ],
              ),
            ),
          const Divider(height: 1.0),
          Container(
            decoration: BoxDecoration(
              color: Theme.of(context).cardColor,
            ),
            child: _buildTextComposer(),
          ),
        ],
      ),
    );
  }
  
  Widget _buildTextComposer() {
    return IconTheme(
      data: IconThemeData(color: Theme.of(context).colorScheme.primary),
      child: Container(
        margin: const EdgeInsets.symmetric(horizontal: 8.0),
        padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
        child: Row(
          children: [
            Flexible(
              child: TextField(
                controller: _textController,
                onSubmitted: _handleSubmitted,
                decoration: const InputDecoration.collapsed(
                  hintText: '메시지를 입력하세요 (/stream, /tool 명령 지원)',
                ),
              ),
            ),
            Container(
              margin: const EdgeInsets.symmetric(horizontal: 4.0),
              child: IconButton(
                icon: const Icon(Icons.send),
                onPressed: () => _handleSubmitted(_textController.text),
              ),
            ),
          ],
        ),
      ),
    );
  }
  
  
  void dispose() {
    _aiService.dispose();
    _textController.dispose();
    super.dispose();
  }
}

class ChatMessage extends StatelessWidget {
  final String text;
  final bool isUser;
  final List<Map<String, dynamic>> toolCalls;

  const ChatMessage({
    super.key,
    required this.text,
    required this.isUser,
    this.toolCalls = const [],
  });

  
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.symmetric(vertical: 10.0),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            margin: const EdgeInsets.only(right: 16.0),
            child: CircleAvatar(
              backgroundColor: isUser 
                  ? Theme.of(context).colorScheme.primary 
                  : Theme.of(context).colorScheme.secondary,
              child: Text(isUser ? '나' : 'AI'),
            ),
          ),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  isUser ? '나' : 'AI 비서',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                Container(
                  margin: const EdgeInsets.only(top: 5.0),
                  child: Text(text),
                ),
                // 도구 호출 정보 표시
                if (toolCalls.isNotEmpty)
                  Container(
                    margin: const EdgeInsets.only(top: 10.0),
                    padding: const EdgeInsets.all(8.0),
                    decoration: BoxDecoration(
                      color: Colors.grey[200],
                      borderRadius: BorderRadius.circular(8.0),
                    ),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          '사용된 도구:',
                          style: Theme.of(context).textTheme.bodySmall!.copyWith(
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                        ...toolCalls.map((toolCall) => Padding(
                          padding: const EdgeInsets.only(top: 4.0),
                          child: Text(
                            '- ${toolCall["name"] ?? "알 수 없음"}: ${jsonEncode(toolCall["arguments"] ?? {})}',
                            style: Theme.of(context).textTheme.bodySmall,
                          ),
                        )),
                      ],
                    ),
                  ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

이 예제는 다음과 같은 기능을 제공합니다:

  1. MCP 서버와 연결하여 사용 가능한 도구 목록을 표시합니다.
  2. AI와의 일반 채팅에서 도구 사용을 지원합니다.
  3. /stream 명령어로 스트리밍 응답을 볼 수 있습니다.
  4. /tool 도구이름 {인자들} 명령어로 특정 도구를 직접 호출할 수 있습니다.
  5. 도구 호출 정보를 메시지 아래에 표시합니다.
  6. MCP 서버 연결 상태를 앱 상단에 표시합니다.

다음 단계

이 글에서는 LlmClientmcp_client의 통합에 대해 자세히 살펴보았습니다. 이러한 통합을 통해 AI 모델이 외부 도구와 리소스에 접근하여 더 강력하고 유용한 기능을 제공할 수 있게 되었습니다.

다음 단계로 탐색할 수 있는 주제들은 다음과 같습니다:

  1. LlmServer와 mcp_server 통합하기: 서버 측 AI 기능 제공 및 도구 등록
  2. 다양한 LLM 제공자와 MCP 통합하기: Claude, OpenAI, Together AI 등 다양한 LLM과 MCP 생태계 연결
  3. MCP 플러그인 시스템 확장하기: 새로운 도구와 리소스 플러그인 개발
  4. 분산 MCP 환경 구축하기: 여러 MCP 서버와 클라이언트로 구성된 복잡한 시스템 설계
  5. MCP 기반 RAG 시스템 구현하기: 문서 검색과 AI 응답을 통합한 지식 기반 시스템 구축

MCP 생태계를 통해 AI의 능력을 크게 확장할 수 있으며, Flutter 앱에서 이러한 기능을 쉽게 통합할 수 있습니다. 계속해서 MCP 기반 통합의 다양한 측면을 탐색하며 더 강력한 AI 앱을 구축해 보세요!


참고 자료


개발자 후원하기

이 글이 도움이 되셨다면, 패트론을 통해 개발 활동을 지원해 주세요. 여러분의 후원은 더 많은 무료 콘텐츠를 만드는 데 큰 힘이 됩니다.

Support on Patreon

태그: #Flutter #AI #MCP #LLM #Dart #Claude #OpenAI #ModelContextProtocol #AIIntegration #mcp_client #LlmClient

0개의 댓글