main
dart 516 lines 13.4 KB
Raw
1 // ignore_for_file: prefer_initializing_formals
2
3 import 'dart:async';
4 import 'dart:convert';
5
6 import 'package:dio/dio.dart';
7 import 'package:localmind/core/logger/app_logger.dart';
8
9 class McpClient {
10 final String serverUrl;
11 final Map<String, String>? headers;
12 final String _version;
13 final Dio _dio;
14 bool _initialized = false;
15 McpCapabilities? _capabilities;
16 Future<McpCapabilities>? _initializationFuture;
17
18 bool _useSse = false;
19 StreamSubscription? _sseSubscription;
20 String? _postUrl;
21 final Map<String, Completer<Map<String, dynamic>>> _pendingRequests = {};
22 int _requestIdCounter = 1;
23
24 McpClient({
25 required this.serverUrl,
26 this.headers,
27 String version = '1.0.0',
28 Dio? dio,
29 }) : _version = version,
30 _dio = dio ?? Dio();
31
32 bool get isInitialized => _initialized;
33 McpCapabilities? get capabilities => _capabilities;
34
35 Future<void> _connectSse() async {
36 try {
37 final response = await _dio.get<ResponseBody>(
38 serverUrl,
39 options: Options(
40 headers: {
41 'Accept': 'text/event-stream',
42 'Cache-Control': 'no-cache',
43 ...?headers,
44 },
45 responseType: ResponseType.stream,
46 ),
47 );
48
49 final contentType = response.headers.value('content-type') ?? '';
50 if (!contentType.contains('text/event-stream') &&
51 !serverUrl.contains('sse')) {
52 // Not an SSE stream endpoint, fallback to direct HTTP POST
53 return;
54 }
55
56 _useSse = true;
57 final handshakeCompleter = Completer<void>();
58
59 final stream = response.data!.stream.cast<List<int>>().transform(
60 utf8.decoder,
61 );
62 String buffer = '';
63 String currentEvent = 'message';
64 String currentData = '';
65
66 _sseSubscription = stream.listen(
67 (chunk) {
68 buffer += chunk;
69 final lines = buffer.split('\n');
70 buffer = lines.removeLast();
71
72 for (final line in lines) {
73 final trimmed = line.trim();
74 if (trimmed.isEmpty) {
75 if (currentData.isNotEmpty) {
76 _handleSseMessage(
77 currentEvent,
78 currentData,
79 handshakeCompleter,
80 );
81 currentEvent = 'message';
82 currentData = '';
83 }
84 continue;
85 }
86
87 if (trimmed.startsWith('event:')) {
88 currentEvent = trimmed.substring(6).trim();
89 } else if (trimmed.startsWith('data:')) {
90 final val = trimmed.substring(5).trim();
91 if (currentData.isNotEmpty) {
92 currentData += '\n$val';
93 } else {
94 currentData = val;
95 }
96 }
97 }
98 },
99 onError: (e) {
100 if (!handshakeCompleter.isCompleted) {
101 handshakeCompleter.completeError(e);
102 }
103 _handleSseDisconnect(e);
104 },
105 onDone: () {
106 // Process any remaining data in the buffer before disconnecting.
107 if (buffer.isNotEmpty || currentData.isNotEmpty) {
108 if (currentData.isNotEmpty) {
109 _handleSseMessage(currentEvent, currentData, handshakeCompleter);
110 } else if (buffer.trim().isNotEmpty) {
111 _handleSseMessage(
112 currentEvent,
113 buffer.trim(),
114 handshakeCompleter,
115 );
116 }
117 }
118 if (!handshakeCompleter.isCompleted) {
119 handshakeCompleter.completeError(
120 McpException('SSE stream closed before handshake completed'),
121 );
122 }
123 _handleSseDisconnect(null);
124 },
125 );
126
127 // Wait for the endpoint event or timeout
128 await handshakeCompleter.future.timeout(
129 const Duration(seconds: 10),
130 onTimeout: () {
131 throw McpException(
132 'Handshake timeout waiting for SSE endpoint event',
133 );
134 },
135 );
136 } catch (e) {
137 await _closeSse();
138 _useSse = false;
139 rethrow;
140 }
141 }
142
143 void _handleSseMessage(
144 String event,
145 String data,
146 Completer<void> initCompleter,
147 ) {
148 if (event == 'endpoint') {
149 try {
150 final baseUri = Uri.parse(serverUrl);
151 final resolvedUri = baseUri.resolve(data);
152 _postUrl = resolvedUri.toString();
153 Log.debug('MCP SSE Handshake completed. Message endpoint: $_postUrl');
154 if (!initCompleter.isCompleted) {
155 initCompleter.complete();
156 }
157 } catch (e) {
158 if (!initCompleter.isCompleted) {
159 initCompleter.completeError(e);
160 }
161 }
162 } else {
163 try {
164 final json = jsonDecode(data);
165 if (json is Map<String, dynamic>) {
166 final id = json['id'];
167 if (id != null) {
168 final key = id.toString();
169 final completer = _pendingRequests.remove(key);
170 if (completer != null && !completer.isCompleted) {
171 completer.complete(json);
172 }
173 }
174 }
175 } catch (_) {}
176 }
177 }
178
179 void _handleSseDisconnect(dynamic error) {
180 final ex = McpException(
181 error != null ? 'SSE disconnected: $error' : 'SSE connection closed',
182 );
183 for (final completer in _pendingRequests.values) {
184 if (!completer.isCompleted) {
185 completer.completeError(ex);
186 }
187 }
188 _pendingRequests.clear();
189 _initialized = false;
190 }
191
192 Future<void> _closeSse() async {
193 await _sseSubscription?.cancel();
194 _sseSubscription = null;
195 _postUrl = null;
196 }
197
198 Future<Map<String, dynamic>> _sendJsonRpc(
199 String method,
200 Map<String, dynamic> params,
201 ) async {
202 final id = _requestIdCounter++;
203 final payload = {
204 'jsonrpc': '2.0',
205 'id': id,
206 'method': method,
207 'params': params,
208 };
209
210 if (_useSse) {
211 if (_postUrl == null) {
212 throw McpException(
213 'SSE transport initialized but POST endpoint is missing',
214 );
215 }
216
217 final completer = Completer<Map<String, dynamic>>();
218 _pendingRequests[id.toString()] = completer;
219
220 try {
221 await _dio.post(
222 _postUrl!,
223 data: payload,
224 options: Options(
225 headers: {'Content-Type': 'application/json', ...?headers},
226 ),
227 );
228 } catch (e) {
229 _pendingRequests.remove(id.toString());
230 throw McpException('Failed to post message over SSE transport: $e');
231 }
232
233 final responseJson = await completer.future.timeout(
234 const Duration(seconds: 30),
235 onTimeout: () {
236 _pendingRequests.remove(id.toString());
237 throw McpException(
238 'Timeout waiting for JSON-RPC response over SSE transport',
239 );
240 },
241 );
242
243 if (responseJson.containsKey('error')) {
244 final error = responseJson['error'];
245 final message = error is Map
246 ? (error['message'] ?? 'Unknown error')
247 : 'Unknown error';
248 throw McpException('Server returned error: $message');
249 }
250
251 return responseJson;
252 } else {
253 try {
254 final response = await _dio.post(
255 serverUrl,
256 data: payload,
257 options: Options(
258 headers: {'Content-Type': 'application/json', ...?headers},
259 ),
260 );
261
262 final responseData = response.data;
263 if (responseData is! Map<String, dynamic>) {
264 throw McpException('Invalid JSON-RPC response payload format');
265 }
266
267 if (responseData.containsKey('error')) {
268 final error = responseData['error'];
269 final message = error is Map
270 ? (error['message'] ?? 'Unknown error')
271 : 'Unknown error';
272 throw McpException('Server returned error: $message');
273 }
274
275 return responseData;
276 } catch (e) {
277 throw McpException('Failed to send JSON-RPC request over HTTP: $e');
278 }
279 }
280 }
281
282 Future<McpCapabilities> initialize() async {
283 if (_initialized && _capabilities != null) {
284 return _capabilities!;
285 }
286
287 // If initialization is already in progress, wait for it.
288 if (_initializationFuture != null) {
289 return _initializationFuture!;
290 }
291
292 _initializationFuture = _doInitialize();
293 try {
294 return await _initializationFuture!;
295 } finally {
296 _initializationFuture = null;
297 }
298 }
299
300 Future<McpCapabilities> _doInitialize() async {
301 try {
302 try {
303 await _connectSse();
304 } catch (e) {
305 Log.debug(
306 'Failed to connect via SSE: $e. Falling back to direct HTTP POST.',
307 );
308 _useSse = false;
309 }
310
311 final response = await _sendJsonRpc('initialize', {
312 'protocolVersion': '2024-11-05',
313 'capabilities': {},
314 'clientInfo': {'name': 'localmind', 'version': _version},
315 });
316
317 _capabilities = McpCapabilities.fromJson(
318 response['result']['capabilities'] as Map<String, dynamic>,
319 );
320 _initialized = true;
321
322 await _sendInitializedNotification();
323
324 return _capabilities!;
325 } catch (e) {
326 throw McpException('Failed to initialize MCP client: $e');
327 }
328 }
329
330 Future<void> _sendInitializedNotification() async {
331 try {
332 final payload = {
333 'jsonrpc': '2.0',
334 'method': 'notifications/initialized',
335 'params': {},
336 };
337
338 if (_useSse) {
339 if (_postUrl != null) {
340 await _dio.post(
341 _postUrl!,
342 data: payload,
343 options: Options(
344 headers: {'Content-Type': 'application/json', ...?headers},
345 ),
346 );
347 }
348 } else {
349 await _dio.post(
350 serverUrl,
351 data: payload,
352 options: Options(
353 headers: {'Content-Type': 'application/json', ...?headers},
354 ),
355 );
356 }
357 } catch (_) {}
358 }
359
360 Future<List<McpTool>> listTools() async {
361 if (!_initialized) {
362 await initialize();
363 }
364
365 try {
366 final response = await _sendJsonRpc('tools/list', {});
367
368 final tools = response['result']['tools'] as List?;
369 if (tools == null) return [];
370
371 return tools
372 .map((t) => McpTool.fromJson(t as Map<String, dynamic>))
373 .toList();
374 } catch (e) {
375 throw McpException('Failed to list tools: $e');
376 }
377 }
378
379 Future<String> callTool(String name, Map<String, dynamic> arguments) async {
380 if (!_initialized) {
381 await initialize();
382 }
383
384 try {
385 final response = await _sendJsonRpc('tools/call', {
386 'name': name,
387 'arguments': arguments,
388 });
389
390 final result = response['result'];
391 if (result == null) {
392 throw McpException('Tool call returned no result');
393 }
394
395 final content = result['content'] as List?;
396 if (content == null || content.isEmpty) {
397 return '';
398 }
399
400 return content.first['text'] as String? ?? '';
401 } catch (e) {
402 if (e is McpException) rethrow;
403 throw McpException('Failed to call tool $name: $e');
404 }
405 }
406
407 Future<List<McpResource>> listResources() async {
408 if (!_initialized) {
409 await initialize();
410 }
411
412 try {
413 final response = await _sendJsonRpc('resources/list', {});
414
415 final resources = response['result']['resources'] as List?;
416 if (resources == null) return [];
417
418 return resources
419 .map((r) => McpResource.fromJson(r as Map<String, dynamic>))
420 .toList();
421 } catch (e) {
422 throw McpException('Failed to list resources: $e');
423 }
424 }
425
426 Future<String> readResource(String uri) async {
427 if (!_initialized) {
428 await initialize();
429 }
430
431 try {
432 final response = await _sendJsonRpc('resources/read', {'uri': uri});
433
434 final contents = response['result']['contents'] as List?;
435 if (contents == null || contents.isEmpty) {
436 return '';
437 }
438
439 return contents.first['text'] as String? ?? '';
440 } catch (e) {
441 throw McpException('Failed to read resource $uri: $e');
442 }
443 }
444
445 Future<void> close() async {
446 await _closeSse();
447 _initialized = false;
448 _capabilities = null;
449 }
450 }
451
452 class McpCapabilities {
453 final bool tools;
454 final bool resources;
455 final bool prompts;
456
457 const McpCapabilities({
458 this.tools = false,
459 this.resources = false,
460 this.prompts = false,
461 });
462
463 factory McpCapabilities.fromJson(Map<String, dynamic> json) {
464 return McpCapabilities(
465 tools: json['tools'] == true,
466 resources: json['resources'] == true,
467 prompts: json['prompts'] == true,
468 );
469 }
470 }
471
472 class McpTool {
473 final String name;
474 final String? description;
475 final Map<String, dynamic> inputSchema;
476
477 const McpTool({
478 required this.name,
479 this.description,
480 this.inputSchema = const {},
481 });
482
483 factory McpTool.fromJson(Map<String, dynamic> json) {
484 return McpTool(
485 name: json['name'] as String,
486 description: json['description'] as String?,
487 inputSchema: json['inputSchema'] as Map<String, dynamic>? ?? {},
488 );
489 }
490 }
491
492 class McpResource {
493 final String uri;
494 final String? name;
495 final String? description;
496 final String? mimeType;
497
498 McpResource({required this.uri, this.name, this.description, this.mimeType});
499
500 factory McpResource.fromJson(Map<String, dynamic> json) {
501 return McpResource(
502 uri: json['uri'] as String,
503 name: json['name'] as String?,
504 description: json['description'] as String?,
505 mimeType: json['mimeType'] as String?,
506 );
507 }
508 }
509
510 class McpException implements Exception {
511 final String message;
512 McpException(this.message);
513
514 @override
515 String toString() => 'McpException: $message';
516 }