Skip to content

Commit c5ee303

Browse files
authored
Add service extension for sending a sampling request to the MCP server (#325)
1 parent 4e5f447 commit c5ee303

File tree

6 files changed

+289
-1
lines changed

6 files changed

+289
-1
lines changed

pkgs/dart_mcp/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
been possible to use in a functional manner, so it is assumed that it had
77
no usage previously.
88
- Fix the `type` getter on `EmbeddedResource` to read the actual type field.
9+
- Add `toJson` method to the `CreateMessageResult` of a sampling request.
910

1011
## 0.4.0
1112

pkgs/dart_mcp/lib/src/api/sampling.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ extension type CreateMessageResult.fromMap(Map<String, Object?> _value)
123123
/// Known reasons are "endTurn", "stopSequence", "maxTokens", or any other
124124
/// reason.
125125
String? get stopReason => _value['stopReason'] as String?;
126+
127+
/// The JSON representation of this object.
128+
Map<String, Object?> toJson() => _value;
126129
}
127130

128131
/// Describes a message issued to or received from an LLM API.

pkgs/dart_mcp_server/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
resources or resource links (when reading a directory).
1111
- Add `additionalProperties: false` to most schemas so they provide better
1212
errors when invoked with incorrect arguments.
13+
- Add `DartMcpServer.samplingRequest` service extension method to send a sampling
14+
request over DTD.
1315

1416
# 0.1.1 (Dart SDK 3.10.0)
1517

pkgs/dart_mcp_server/lib/src/mixins/dtd.dart

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ import '../utils/analytics.dart';
2020
import '../utils/constants.dart';
2121
import '../utils/tools_configuration.dart';
2222

23+
/// Constants used by the MCP server to register services on DTD.
24+
///
25+
/// TODO(elliette): Add these to package:dtd instead.
26+
extension McpServiceConstants on Never {
27+
/// Service name for the Dart MCP Server.
28+
static const serviceName = 'DartMcpServer';
29+
30+
/// Service method name for the method to send a sampling request to the MCP
31+
/// client.
32+
static const samplingRequest = 'samplingRequest';
33+
}
34+
2335
/// Mix this in to any MCPServer to add support for connecting to the Dart
2436
/// Tooling Daemon and all of its associated functionality (see
2537
/// https://pub.dev/packages/dtd).
@@ -251,6 +263,7 @@ base mixin DartToolingDaemonSupport
251263
}
252264
unawaited(_dtd!.done.then((_) async => await _resetDtd()));
253265

266+
await _registerServices();
254267
await _listenForServices();
255268
return CallToolResult(
256269
content: [TextContent(text: 'Connection succeeded')],
@@ -272,6 +285,36 @@ base mixin DartToolingDaemonSupport
272285
}
273286
}
274287

288+
/// Registers all MCP server-provided services on the connected DTD instance.
289+
Future<void> _registerServices() async {
290+
final dtd = _dtd!;
291+
292+
if (clientCapabilities.sampling != null) {
293+
try {
294+
await dtd.registerService(
295+
McpServiceConstants.serviceName,
296+
McpServiceConstants.samplingRequest,
297+
_handleSamplingRequest,
298+
);
299+
} on RpcException catch (e) {
300+
// It is expected for there to be an exception if the sampling service
301+
// was already registered by another Dart MCP Server.
302+
if (e.code != RpcErrorCodes.kServiceAlreadyRegistered) rethrow;
303+
}
304+
}
305+
}
306+
307+
Future<Map<String, Object?>> _handleSamplingRequest(Parameters params) async {
308+
final result = await createMessage(
309+
CreateMessageRequest.fromMap(params.asMap.cast<String, Object?>()),
310+
);
311+
312+
return {
313+
'type': 'Success', // Type is required by DTD.
314+
...result.toJson(),
315+
};
316+
}
317+
275318
/// Listens to the `ConnectedApp` and `Editor` streams to get app and IDE
276319
/// state information.
277320
///

pkgs/dart_mcp_server/test/test_harness.dart

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,14 +303,42 @@ final class AppDebugSession {
303303
}
304304

305305
/// A basic MCP client which is started as a part of the harness.
306-
final class DartToolingMCPClient extends MCPClient with RootsSupport {
306+
final class DartToolingMCPClient extends MCPClient
307+
with RootsSupport, SamplingSupport {
307308
DartToolingMCPClient()
308309
: super(
309310
Implementation(
310311
name: 'test client for the dart tooling mcp server',
311312
version: '0.1.0',
312313
),
313314
);
315+
316+
@override
317+
FutureOr<CreateMessageResult> handleCreateMessage(
318+
CreateMessageRequest request,
319+
Implementation serverInfo,
320+
) {
321+
final messageTexts = request.messages
322+
.map((message) {
323+
final role = message.role.name;
324+
return switch (message.content) {
325+
final TextContent c when c.isText => '[$role] ${c.text}',
326+
final ImageContent c when c.isImage => '[$role] ${c.mimeType}',
327+
final AudioContent c when c.isAudio => '[$role] ${c.mimeType}',
328+
final EmbeddedResource c when c.isEmbeddedResource =>
329+
'[$role] ${c.resource.uri}',
330+
_ => 'UNKNOWN',
331+
};
332+
})
333+
.join('\n');
334+
return CreateMessageResult(
335+
role: Role.assistant,
336+
content: Content.text(
337+
text: 'TOKENS: ${request.maxTokens}\n$messageTexts',
338+
),
339+
model: 'test-model',
340+
);
341+
}
314342
}
315343

316344
/// The dart tooling daemon currently expects to get vm service uris through

pkgs/dart_mcp_server/test/tools/dtd_test.dart

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import 'package:dart_mcp_server/src/server.dart';
1313
import 'package:dart_mcp_server/src/utils/analytics.dart';
1414
import 'package:dart_mcp_server/src/utils/constants.dart';
1515
import 'package:devtools_shared/devtools_shared.dart';
16+
import 'package:dtd/dtd.dart';
17+
import 'package:json_rpc_2/json_rpc_2.dart';
1618
import 'package:test/test.dart';
1719
import 'package:unified_analytics/testing.dart';
1820
import 'package:unified_analytics/unified_analytics.dart' as ua;
@@ -98,6 +100,215 @@ void main() {
98100
});
99101
});
100102

103+
group('sampling service extension', () {
104+
List<String> extractResponse(DTDResponse response) {
105+
final responseContent =
106+
response.result['content'] as Map<String, Object?>;
107+
return (responseContent['text'] as String).split('\n');
108+
}
109+
110+
test('can make a sampling request with text', () async {
111+
final dtdClient = testHarness.fakeEditorExtension.dtd;
112+
final response = await dtdClient.call(
113+
McpServiceConstants.serviceName,
114+
McpServiceConstants.samplingRequest,
115+
params: {
116+
'messages': [
117+
{
118+
'role': 'user',
119+
'content': {'type': 'text', 'text': 'hello world'},
120+
},
121+
],
122+
'maxTokens': 512,
123+
},
124+
);
125+
expect(extractResponse(response), [
126+
'TOKENS: 512',
127+
'[user] hello world',
128+
]);
129+
});
130+
131+
test('can make a sampling request with an image', () async {
132+
final dtdClient = testHarness.fakeEditorExtension.dtd;
133+
final response = await dtdClient.call(
134+
McpServiceConstants.serviceName,
135+
McpServiceConstants.samplingRequest,
136+
params: {
137+
'messages': [
138+
{
139+
'role': 'user',
140+
'content': {
141+
'type': 'image',
142+
'data': 'fake-data',
143+
'mimeType': 'image/png',
144+
},
145+
},
146+
],
147+
'maxTokens': 256,
148+
},
149+
);
150+
expect(extractResponse(response), [
151+
'TOKENS: 256',
152+
'[user] image/png',
153+
]);
154+
});
155+
156+
test('can make a sampling request with audio', () async {
157+
final dtdClient = testHarness.fakeEditorExtension.dtd;
158+
final response = await dtdClient.call(
159+
McpServiceConstants.serviceName,
160+
McpServiceConstants.samplingRequest,
161+
params: {
162+
'messages': [
163+
{
164+
'role': 'user',
165+
'content': {
166+
'type': 'audio',
167+
'data': 'fake-data',
168+
'mimeType': 'audio',
169+
},
170+
},
171+
],
172+
'maxTokens': 256,
173+
},
174+
);
175+
expect(extractResponse(response), ['TOKENS: 256', '[user] audio']);
176+
});
177+
178+
test('can make a sampling request with an embedded resource', () async {
179+
final dtdClient = testHarness.fakeEditorExtension.dtd;
180+
final response = await dtdClient.call(
181+
McpServiceConstants.serviceName,
182+
McpServiceConstants.samplingRequest,
183+
params: {
184+
'messages': [
185+
{
186+
'role': 'user',
187+
'content': {
188+
'type': 'resource',
189+
'resource': {'uri': 'www.google.com', 'text': 'Google'},
190+
},
191+
},
192+
],
193+
'maxTokens': 256,
194+
},
195+
);
196+
expect(extractResponse(response), [
197+
'TOKENS: 256',
198+
'[user] www.google.com',
199+
]);
200+
});
201+
202+
test('can make a sampling request with mixed content', () async {
203+
final dtdClient = testHarness.fakeEditorExtension.dtd;
204+
final response = await dtdClient.call(
205+
McpServiceConstants.serviceName,
206+
McpServiceConstants.samplingRequest,
207+
params: {
208+
'messages': [
209+
{
210+
'role': 'user',
211+
'content': {'type': 'text', 'text': 'hello world'},
212+
},
213+
{
214+
'role': 'user',
215+
'content': {
216+
'type': 'image',
217+
'data': 'fake-data',
218+
'mimeType': 'image/jpeg',
219+
},
220+
},
221+
],
222+
'maxTokens': 128,
223+
},
224+
);
225+
expect(extractResponse(response), [
226+
'TOKENS: 128',
227+
'[user] hello world',
228+
'[user] image/jpeg',
229+
]);
230+
});
231+
232+
test('can handle user and assistant messages', () async {
233+
final dtdClient = testHarness.fakeEditorExtension.dtd;
234+
final response = await dtdClient.call(
235+
McpServiceConstants.serviceName,
236+
McpServiceConstants.samplingRequest,
237+
params: {
238+
'messages': [
239+
{
240+
'role': 'user',
241+
'content': {'type': 'text', 'text': 'Hi! I have a question.'},
242+
},
243+
{
244+
'role': 'assistant',
245+
'content': {'type': 'text', 'text': 'What is your question?'},
246+
},
247+
{
248+
'role': 'user',
249+
'content': {'type': 'text', 'text': 'How big is the sun?'},
250+
},
251+
],
252+
'maxTokens': 512,
253+
},
254+
);
255+
expect(extractResponse(response), [
256+
'TOKENS: 512',
257+
'[user] Hi! I have a question.',
258+
'[assistant] What is your question?',
259+
'[user] How big is the sun?',
260+
]);
261+
});
262+
263+
test('forwards all messages, even those with unknown types', () async {
264+
final dtdClient = testHarness.fakeEditorExtension.dtd;
265+
final response = await dtdClient.call(
266+
McpServiceConstants.serviceName,
267+
McpServiceConstants.samplingRequest,
268+
params: {
269+
'messages': [
270+
{
271+
'role': 'user',
272+
'content': {
273+
// Not of type text, image, audio, or resource.
274+
'type': 'unknown',
275+
'text': 'Hi there!',
276+
'data': 'Hi there!',
277+
},
278+
},
279+
],
280+
'maxTokens': 512,
281+
},
282+
);
283+
expect(extractResponse(response), ['TOKENS: 512', 'UNKNOWN']);
284+
});
285+
286+
test('throws for invalid requests', () async {
287+
final dtdClient = testHarness.fakeEditorExtension.dtd;
288+
try {
289+
await dtdClient.call(
290+
McpServiceConstants.serviceName,
291+
McpServiceConstants.samplingRequest,
292+
params: {
293+
'messages': [
294+
{
295+
'role': 'dog', // Invalid role.
296+
'content': {
297+
'type': 'text',
298+
'text': 'Hi! I have a question.',
299+
},
300+
},
301+
],
302+
'maxTokens': 512,
303+
},
304+
);
305+
fail('Expected an RpcException to be thrown.');
306+
} catch (e) {
307+
expect(e, isA<RpcException>());
308+
}
309+
});
310+
});
311+
101312
group('dart cli tests', () {
102313
test('can perform a hot reload', () async {
103314
final exampleApp = await Directory.systemTemp.createTemp('dart_app');

0 commit comments

Comments
 (0)