이 글은 Model Context Protocol(MCP) 시리즈의 네 번째 포스트이자 mcp_llm의 두 번째 글로, Flutter와 AI의 만남: mcp_llm 소개에 이어 LlmClient와 mcp_client의 통합에 대해 심층적으로 알아봅니다. MCP 생태계에서 두 컴포넌트의 통합을 통해 AI 앱이 외부 도구와 리소스에 접근하는 방법을 배워봅시다.
이전 글에서 mcp_llm 패키지의 개요와 기본 구성 요소를 살펴보았습니다. 이번 글에서는 AI 기능을 Flutter 앱에 통합하는 데 핵심적인 두 가지 컴포넌트인 LlmClient와 mcp_client의 통합에 초점을 맞추겠습니다.
LlmClient는 mcp_llm 패키지의 핵심 컴포넌트로, 다양한 LLM 제공자(Claude, OpenAI 등)와의 통신을 담당합니다. 주요 기능은 다음과 같습니다:
mcp_client는 Model Context Protocol(MCP)의 클라이언트 구현체로, 다음과 같은 기능을 제공합니다:
LlmClient와 mcp_client의 통합은 AI 모델과 외부 환경 간의 상호작용을 가능하게 합니다. 이 통합을 통해:
이러한 통합은 단순 텍스트 생성을 넘어 실제 기능을 수행하는 AI 앱을 구축하는 데 필수적입니다.
LlmClient와 mcp_client의 통합 아키텍처를 이해하는 것이 중요합니다. 이 통합은 다음과 같은 구조로 이루어집니다:
┌────────────────┐ ┌────────────────┐
│ LlmClient │◄─────►│ mcp_client │
└───────┬────────┘ └───────┬────────┘
│ │
▼ ▼
┌────────────────┐ ┌────────────────┐
│ LLM Provider │ │ MCP Server │
│ (Claude, GPT) │ │ (Tools, etc.) │
└────────────────┘ └────────────────┘
LlmClient가 LLM 제공자에게 사용자 쿼리를 전송합니다.LlmClient는 이 도구 호출을 mcp_client로 전달합니다.mcp_client는 MCP 서버에 연결된 도구를 실행합니다.mcp_client로 반환됩니다.LlmClient는 도구 결과를 LLM에 다시 전달합니다.통합을 구성하는 핵심 컴포넌트들을 살펴보겠습니다:
이들 컴포넌트가 함께 작동하여 AI와 외부 환경 간의 통합된 경험을 제공합니다.
이제 LlmClient와 mcp_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_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가 필요에 따라 도구를 호출하도록 설정:
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 리소스와 프롬프트를 활용하는 방법을 살펴보겠습니다.
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 클라이언트를 동시에 관리하는 방법을 살펴보겠습니다.
// 첫 번째 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.',
);
// 이미 생성된 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 클라이언트의 도구 직접 호출
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);
}
});
이제 실제로 Flutter 앱에서 LlmClient와 mcp_client를 통합하여 도구를 활용하는 예제를 살펴보겠습니다.
// 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,
),
)),
],
),
),
],
),
),
],
),
);
}
}
이 예제는 다음과 같은 기능을 제공합니다:
/stream 명령어로 스트리밍 응답을 볼 수 있습니다./tool 도구이름 {인자들} 명령어로 특정 도구를 직접 호출할 수 있습니다.이 글에서는 LlmClient와 mcp_client의 통합에 대해 자세히 살펴보았습니다. 이러한 통합을 통해 AI 모델이 외부 도구와 리소스에 접근하여 더 강력하고 유용한 기능을 제공할 수 있게 되었습니다.
다음 단계로 탐색할 수 있는 주제들은 다음과 같습니다:
MCP 생태계를 통해 AI의 능력을 크게 확장할 수 있으며, Flutter 앱에서 이러한 기능을 쉽게 통합할 수 있습니다. 계속해서 MCP 기반 통합의 다양한 측면을 탐색하며 더 강력한 AI 앱을 구축해 보세요!
이 글이 도움이 되셨다면, 패트론을 통해 개발 활동을 지원해 주세요. 여러분의 후원은 더 많은 무료 콘텐츠를 만드는 데 큰 힘이 됩니다.
태그: #Flutter #AI #MCP #LLM #Dart #Claude #OpenAI #ModelContextProtocol #AIIntegration #mcp_client #LlmClient