Skip to content

Commit 8488dea

Browse files
authored
Register samplingRequest with a unique service name (#328)
1 parent c5ee303 commit 8488dea

File tree

3 files changed

+162
-19
lines changed

3 files changed

+162
-19
lines changed

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

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import '../arg_parser.dart';
1919
import '../utils/analytics.dart';
2020
import '../utils/constants.dart';
2121
import '../utils/tools_configuration.dart';
22+
import '../utils/uuid.dart';
2223

2324
/// Constants used by the MCP server to register services on DTD.
2425
///
@@ -78,6 +79,34 @@ base mixin DartToolingDaemonSupport
7879
/// Whether or not to enable the screenshot tool.
7980
bool get enableScreenshots;
8081

82+
/// A unique identifier for this server instance.
83+
///
84+
/// This is generated on first access and then cached. It is used to create
85+
/// a unique service name when registering services on DTD.
86+
///
87+
/// Can only be accessed after `initialize` has been called.
88+
String get clientId {
89+
if (_clientId != null) return _clientId!;
90+
final clientName = clientInfo.title ?? clientInfo.name;
91+
_clientId = generateClientId(clientName);
92+
return _clientId!;
93+
}
94+
95+
String? _clientId;
96+
97+
@visibleForTesting
98+
String generateClientId(String clientName) {
99+
// Sanitizes the client name by:
100+
// 1. replacing whitespace, '-', and '.' with '_'
101+
// 2. removing all non-alphanumeric characters except '_'
102+
final sanitizedClientName = clientName
103+
.trim()
104+
.toLowerCase()
105+
.replaceAll(RegExp(r'[\s\.\-]+'), '_')
106+
.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '');
107+
return '${sanitizedClientName}_${generateShortUUID()}';
108+
}
109+
81110
/// Called when the DTD connection is lost, resets all associated state.
82111
Future<void> _resetDtd() async {
83112
_dtd = null;
@@ -290,17 +319,11 @@ base mixin DartToolingDaemonSupport
290319
final dtd = _dtd!;
291320

292321
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-
}
322+
await dtd.registerService(
323+
'${McpServiceConstants.serviceName}_$clientId',
324+
McpServiceConstants.samplingRequest,
325+
_handleSamplingRequest,
326+
);
304327
}
305328
}
306329

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:math' show Random;
6+
7+
/// Generates a short, 8-character hex string from 32 bits of random data.
8+
///
9+
/// This is not a standard UUID but is sufficient for use cases where a short,
10+
/// unique-enough identifier is needed.
11+
String generateShortUUID() => _bitsDigits(16, 4) + _bitsDigits(16, 4);
12+
13+
// Note: The following private helpers were copied over from:
14+
// https://github.com/dart-lang/webdev/blob/e2d14f1050fa07e9a60455cf9d2b8e6f4e9c332c/frontend_server_common/lib/src/uuid.dart
15+
final Random _random = Random();
16+
17+
String _bitsDigits(int bitCount, int digitCount) =>
18+
_printDigits(_generateBits(bitCount), digitCount);
19+
20+
int _generateBits(int bitCount) => _random.nextInt(1 << bitCount);
21+
22+
String _printDigits(int value, int count) =>
23+
value.toRadixString(16).padLeft(count, '0');

pkgs/dart_mcp_server/test/tools/dtd_test.dart

Lines changed: 105 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,41 @@ void main() {
107107
return (responseContent['text'] as String).split('\n');
108108
}
109109

110+
Future<String> getSamplingServiceName(
111+
DartToolingDaemon dtdClient,
112+
) async {
113+
final services = await dtdClient.getRegisteredServices();
114+
final samplingService = services.clientServices.firstWhere(
115+
(s) => s.name.startsWith(McpServiceConstants.serviceName),
116+
);
117+
return samplingService.name;
118+
}
119+
120+
test('is registered with correct name format', () async {
121+
final dtdClient = testHarness.fakeEditorExtension.dtd;
122+
final services = await dtdClient.getRegisteredServices();
123+
final samplingService = services.clientServices.first;
124+
final sanitizedClientName =
125+
'test_client_for_the_dart_tooling_mcp_server';
126+
expect(
127+
samplingService.name,
128+
startsWith(
129+
'${McpServiceConstants.serviceName}_${sanitizedClientName}_',
130+
),
131+
);
132+
// Check that the service name ends with an 8-character ID.
133+
expect(samplingService.name, matches(RegExp(r'[a-f0-9]{8}$')));
134+
expect(
135+
samplingService.methods,
136+
contains(McpServiceConstants.samplingRequest),
137+
);
138+
});
139+
110140
test('can make a sampling request with text', () async {
111141
final dtdClient = testHarness.fakeEditorExtension.dtd;
142+
final samplingServiceName = await getSamplingServiceName(dtdClient);
112143
final response = await dtdClient.call(
113-
McpServiceConstants.serviceName,
144+
samplingServiceName,
114145
McpServiceConstants.samplingRequest,
115146
params: {
116147
'messages': [
@@ -130,8 +161,9 @@ void main() {
130161

131162
test('can make a sampling request with an image', () async {
132163
final dtdClient = testHarness.fakeEditorExtension.dtd;
164+
final samplingServiceName = await getSamplingServiceName(dtdClient);
133165
final response = await dtdClient.call(
134-
McpServiceConstants.serviceName,
166+
samplingServiceName,
135167
McpServiceConstants.samplingRequest,
136168
params: {
137169
'messages': [
@@ -155,8 +187,9 @@ void main() {
155187

156188
test('can make a sampling request with audio', () async {
157189
final dtdClient = testHarness.fakeEditorExtension.dtd;
190+
final samplingServiceName = await getSamplingServiceName(dtdClient);
158191
final response = await dtdClient.call(
159-
McpServiceConstants.serviceName,
192+
samplingServiceName,
160193
McpServiceConstants.samplingRequest,
161194
params: {
162195
'messages': [
@@ -177,8 +210,9 @@ void main() {
177210

178211
test('can make a sampling request with an embedded resource', () async {
179212
final dtdClient = testHarness.fakeEditorExtension.dtd;
213+
final samplingServiceName = await getSamplingServiceName(dtdClient);
180214
final response = await dtdClient.call(
181-
McpServiceConstants.serviceName,
215+
samplingServiceName,
182216
McpServiceConstants.samplingRequest,
183217
params: {
184218
'messages': [
@@ -201,8 +235,9 @@ void main() {
201235

202236
test('can make a sampling request with mixed content', () async {
203237
final dtdClient = testHarness.fakeEditorExtension.dtd;
238+
final samplingServiceName = await getSamplingServiceName(dtdClient);
204239
final response = await dtdClient.call(
205-
McpServiceConstants.serviceName,
240+
samplingServiceName,
206241
McpServiceConstants.samplingRequest,
207242
params: {
208243
'messages': [
@@ -231,8 +266,9 @@ void main() {
231266

232267
test('can handle user and assistant messages', () async {
233268
final dtdClient = testHarness.fakeEditorExtension.dtd;
269+
final samplingServiceName = await getSamplingServiceName(dtdClient);
234270
final response = await dtdClient.call(
235-
McpServiceConstants.serviceName,
271+
samplingServiceName,
236272
McpServiceConstants.samplingRequest,
237273
params: {
238274
'messages': [
@@ -262,8 +298,9 @@ void main() {
262298

263299
test('forwards all messages, even those with unknown types', () async {
264300
final dtdClient = testHarness.fakeEditorExtension.dtd;
301+
final samplingServiceName = await getSamplingServiceName(dtdClient);
265302
final response = await dtdClient.call(
266-
McpServiceConstants.serviceName,
303+
samplingServiceName,
267304
McpServiceConstants.samplingRequest,
268305
params: {
269306
'messages': [
@@ -285,9 +322,10 @@ void main() {
285322

286323
test('throws for invalid requests', () async {
287324
final dtdClient = testHarness.fakeEditorExtension.dtd;
325+
final samplingServiceName = await getSamplingServiceName(dtdClient);
288326
try {
289327
await dtdClient.call(
290-
McpServiceConstants.serviceName,
328+
samplingServiceName,
291329
McpServiceConstants.samplingRequest,
292330
params: {
293331
'messages': [
@@ -378,6 +416,65 @@ void main() {
378416
await testHarness.connectToDtd();
379417
});
380418

419+
group('generateClientId creates ID from client name', () {
420+
test('removes whitespaces', () {
421+
// Single whitespace character.
422+
expect(
423+
server.generateClientId('Example Name'),
424+
startsWith('example_name_'),
425+
);
426+
// Multiple whitespace characters.
427+
expect(
428+
server.generateClientId('Example Name'),
429+
startsWith('example_name_'),
430+
);
431+
// Newline and other whitespace.
432+
expect(
433+
server.generateClientId('Example\n\tName'),
434+
startsWith('example_name_'),
435+
);
436+
// Whitespace at the end.
437+
expect(
438+
server.generateClientId('Example Name\n'),
439+
startsWith('example_name_'),
440+
);
441+
});
442+
443+
test('replaces periods and dashes with underscores', () {
444+
// Replaces periods.
445+
expect(
446+
server.generateClientId('Example.Client.Name'),
447+
startsWith('example_client_name_'),
448+
);
449+
// Replaces dashes.
450+
expect(
451+
server.generateClientId('example-client-name'),
452+
startsWith('example_client_name_'),
453+
);
454+
});
455+
456+
test('removes special characters', () {
457+
expect(
458+
server.generateClientId('Example!@#Client\$%^Name'),
459+
startsWith('exampleclientname_'),
460+
);
461+
});
462+
463+
test('handles a mix of sanitization rules', () {
464+
expect(
465+
server.generateClientId(' Example Client.Name!@# '),
466+
startsWith('example_client_name_'),
467+
);
468+
});
469+
470+
test('ends with an 8-character uuid', () {
471+
expect(
472+
server.generateClientId('Example name'),
473+
matches(RegExp(r'[a-f0-9]{8}$')),
474+
);
475+
});
476+
});
477+
381478
group('$VmService management', () {
382479
late Directory appDir;
383480
final appPath = 'bin/main.dart';

0 commit comments

Comments
 (0)