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
+}