@tduncan / Cortana / commits / c6011da

fix: MCP integration support in chat services and update settings to handle plugin-based integrations.

abdulmominsakib committed Jul 27, 2026 at 17:34 UTC c6011dabf1fdc9fbd2728a8ea82fdacb9ebfa6f5
3 files changed +226 -6
lib/features/chat/data/chat_service.dart
+33
@@ -16,6 +16,7 @@ import 'tools/adapters/tool_transport_adapter.dart';
16 import '../utils/attachment_helpers.dart';
17 import '../utils/image_upload_utils.dart';
18 import 'tools/adapters/openai_tool_adapter.dart';
19 +import 'tools/adapters/lm_studio_tool_adapter.dart';
20 import 'tools/adapters/openrouter_tool_adapter.dart';
21 import 'tools/adapters/ollama_tool_adapter.dart';
22 import 'chat_api_error.dart';
@@ -180,6 +181,7 @@ class LMStudioChatService implements ChatService {
181 }) async* {
182 _cancelToken = CancelToken();
183 final toolAdapter = OpenAiToolAdapter();
184 + final lmStudioToolAdapter = LmStudioToolAdapter();
185
186 final apiMessages = <Map<String, dynamic>>[];
187 for (final m in messages) {
@@ -226,6 +228,10 @@ class LMStudioChatService implements ChatService {
228 }
229 _applyReasoningControl(body, params);
230
231 + if (integrations != null && integrations.isNotEmpty) {
232 + body['integrations'] = integrations.map((i) => i.toJson()).toList();
233 + }
234 +
235 if (tools != null && tools.isNotEmpty) {
236 final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
237 if (toolsPayload.containsKey('tools')) {
@@ -270,6 +276,15 @@ class LMStudioChatService implements ChatService {
276 ),
277 );
278 }
279 + for (final call in lmStudioToolAdapter.takeServerExecutedCalls()) {
280 + yield ChatResponse(
281 + type: ChatResponseType.toolCall,
282 + toolCall: ToolCallData(
283 + tool: call.name,
284 + arguments: call.arguments,
285 + ),
286 + );
287 + }
288 yield const ChatResponse(type: ChatResponseType.done);
289 return;
290 }
@@ -279,6 +294,20 @@ class LMStudioChatService implements ChatService {
294 final json = jsonDecode(data) as Map<String, dynamic>;
295 toolAdapter.consumeDynamicChunk(json);
296
297 + final eventType = json['type'] as String?;
298 + if (eventType != null) {
299 + lmStudioToolAdapter.consumeEvent(eventType, json);
300 + for (final call in lmStudioToolAdapter.takeServerExecutedCalls()) {
301 + yield ChatResponse(
302 + type: ChatResponseType.toolCall,
303 + toolCall: ToolCallData(
304 + tool: call.name,
305 + arguments: call.arguments,
306 + ),
307 + );
308 + }
309 + }
310 +
311 if (json['error'] != null) {
312 yield ChatResponse(
313 type: ChatResponseType.error,
@@ -487,6 +516,10 @@ class OpenAICompatibleChatService implements ChatService {
516 };
517 _applyReasoningControl(body, params);
518
519 + if (integrations != null && integrations.isNotEmpty) {
520 + body['integrations'] = integrations.map((i) => i.toJson()).toList();
521 + }
522 +
523 if (tools != null && tools.isNotEmpty) {
524 final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
525 if (toolsPayload.containsKey('tools')) {
lib/features/chat/views/components/chat_settings_sheet.dart
+16 -6
@@ -366,13 +366,23 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
366 void _addServer() {
367 final label = _serverLabelController.text.trim();
368 final url = _serverUrlController.text.trim();
369 - if (label.isEmpty || url.isEmpty) return;
369 + if (label.isEmpty && url.isEmpty) return;
370
371 - final integration = McpIntegration(
372 - type: McpIntegrationType.ephemeralMcp,
373 - serverLabel: label,
374 - serverUrl: url,
375 - );
371 + final McpIntegration integration;
372 + if (label.startsWith('mcp/') || url.startsWith('mcp/')) {
373 + final pluginId = label.startsWith('mcp/') ? label : url;
374 + integration = McpIntegration(
375 + type: McpIntegrationType.plugin,
376 + pluginId: pluginId,
377 + );
378 + } else {
379 + if (label.isEmpty || url.isEmpty) return;
380 + integration = McpIntegration(
381 + type: McpIntegrationType.ephemeralMcp,
382 + serverLabel: label,
383 + serverUrl: url,
384 + );
385 + }
386
387 ref.read(chatMcpConfigProvider.notifier).addIntegration(integration);
388
test/features/chat/lm_studio_mcp_integration_test.dart new
+177
@@ -0,0 +1,177 @@
1 +import 'dart:async';
2 +import 'dart:convert';
3 +import 'dart:typed_data';
4 +import 'package:dio/dio.dart';
5 +import 'package:flutter_test/flutter_test.dart';
6 +import 'package:localmind/core/models/enums.dart';
7 +import 'package:localmind/features/chat/data/chat_service.dart';
8 +import 'package:localmind/features/chat/data/models/message.dart';
9 +import 'package:localmind/features/chat/data/models/chat_parameters.dart';
10 +import 'package:localmind/features/chat/data/models/mcp_integration.dart';
11 +import 'package:localmind/features/servers/data/models/server.dart';
12 +
13 +class CapturingStreamInterceptor extends Interceptor {
14 + RequestOptions? capturedRequest;
15 + final List<String> lines;
16 +
17 + CapturingStreamInterceptor(this.lines);
18 +
19 + @override
20 + void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
21 + capturedRequest = options;
22 + final controller = StreamController<Uint8List>();
23 + handler.resolve(Response(
24 + requestOptions: options,
25 + data: ResponseBody(
26 + controller.stream,
27 + 200,
28 + headers: {
29 + Headers.contentTypeHeader: ['application/json'],
30 + },
31 + ),
32 + statusCode: 200,
33 + ));
34 +
35 + Future.microtask(() async {
36 + for (final line in lines) {
37 + controller.add(Uint8List.fromList(utf8.encode('$line\n')));
38 + await Future.delayed(const Duration(milliseconds: 1));
39 + }
40 + await controller.close();
41 + });
42 + }
43 +}
44 +
45 +void main() {
46 + group('LM Studio MCP Integration Tests', () {
47 + final server = Server(
48 + id: 'lmstudio-server',
49 + name: 'LM Studio',
50 + type: ServerType.lmStudio,
51 + host: 'localhost',
52 + port: 1234,
53 + createdAt: DateTime.now(),
54 + lastConnectedAt: DateTime.now(),
55 + );
56 +
57 + final userMessage = Message(
58 + id: 'msg_1',
59 + conversationId: 'conv_1',
60 + role: MessageRole.user,
61 + content: 'Search Hugging Face models',
62 + createdAt: DateTime.now(),
63 + );
64 +
65 + test('LMStudioChatService includes integrations array in body payload', () async {
66 + final interceptor = CapturingStreamInterceptor([
67 + 'data: {"choices":[{"delta":{"content":"Hello"}}]}',
68 + 'data: [DONE]',
69 + ]);
70 + final dio = Dio()..interceptors.add(interceptor);
71 + final service = LMStudioChatService(dio);
72 +
73 + final integrations = [
74 + const McpIntegration(
75 + type: McpIntegrationType.ephemeralMcp,
76 + serverLabel: 'huggingface',
77 + serverUrl: 'https://huggingface.co/mcp',
78 + allowedTools: ['model_search'],
79 + headers: {'Authorization': 'Bearer test-token'},
80 + ),
81 + const McpIntegration(
82 + type: McpIntegrationType.plugin,
83 + pluginId: 'mcp/playwright',
84 + ),
85 + ];
86 +
87 + final responses = await service.sendMessage(
88 + server: server,
89 + modelId: 'test-model',
90 + messages: [userMessage],
91 + params: ChatParameters.defaults(),
92 + integrations: integrations,
93 + ).toList();
94 +
95 + expect(responses.any((r) => r.type == ChatResponseType.message), isTrue);
96 + expect(interceptor.capturedRequest, isNotNull);
97 +
98 + final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
99 + expect(body['integrations'], isNotNull);
100 + final bodyIntegrations = body['integrations'] as List;
101 + expect(bodyIntegrations.length, equals(2));
102 +
103 + expect(bodyIntegrations[0], equals({
104 + 'type': 'ephemeral_mcp',
105 + 'server_label': 'huggingface',
106 + 'server_url': 'https://huggingface.co/mcp',
107 + 'allowed_tools': ['model_search'],
108 + 'headers': {'Authorization': 'Bearer test-token'},
109 + }));
110 +
111 + expect(bodyIntegrations[1], equals({
112 + 'type': 'plugin',
113 + 'id': 'mcp/playwright',
114 + }));
115 + });
116 +
117 + test('LMStudioChatService emits toolCall response on tool_call.success event', () async {
118 + final interceptor = CapturingStreamInterceptor([
119 + 'data: {"type": "tool_call.start", "tool": "model_search"}',
120 + 'data: {"type": "tool_call.success", "tool": "model_search", "arguments": {"sort": "trendingScore"}, "output": "Model A, Model B"}',
121 + 'data: [DONE]',
122 + ]);
123 + final dio = Dio()..interceptors.add(interceptor);
124 + final service = LMStudioChatService(dio);
125 +
126 + final responses = await service.sendMessage(
127 + server: server,
128 + modelId: 'test-model',
129 + messages: [userMessage],
130 + params: ChatParameters.defaults(),
131 + integrations: [
132 + const McpIntegration(
133 + type: McpIntegrationType.ephemeralMcp,
134 + serverLabel: 'huggingface',
135 + serverUrl: 'https://huggingface.co/mcp',
136 + ),
137 + ],
138 + ).toList();
139 +
140 + final toolCalls = responses.where((r) => r.type == ChatResponseType.toolCall).toList();
141 + expect(toolCalls.length, equals(1));
142 + expect(toolCalls.first.toolCall?.tool, equals('model_search'));
143 + expect(toolCalls.first.toolCall?.arguments, equals({'sort': 'trendingScore'}));
144 + });
145 +
146 + test('OpenAICompatibleChatService includes integrations array in payload', () async {
147 + final interceptor = CapturingStreamInterceptor([
148 + 'data: {"choices":[{"delta":{"content":"Hi"}}]}',
149 + 'data: [DONE]',
150 + ]);
151 + final dio = Dio()..interceptors.add(interceptor);
152 + final service = OpenAICompatibleChatService(dio);
153 +
154 + final integrations = [
155 + const McpIntegration(
156 + type: McpIntegrationType.plugin,
157 + pluginId: 'mcp/filesystem',
158 + ),
159 + ];
160 +
161 + await service.sendMessage(
162 + server: server,
163 + modelId: 'test-model',
164 + messages: [userMessage],
165 + params: ChatParameters.defaults(),
166 + integrations: integrations,
167 + ).toList();
168 +
169 + expect(interceptor.capturedRequest, isNotNull);
170 + final body = interceptor.capturedRequest!.data as Map<String, dynamic>;
171 + expect(body['integrations'], isNotNull);
172 + expect(body['integrations'], equals([
173 + {'type': 'plugin', 'id': 'mcp/filesystem'},
174 + ]));
175 + });
176 + });
177 +}