diff --git a/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart b/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart index 24916fcc..55eb1523 100644 --- a/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart +++ b/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart @@ -19,6 +19,7 @@ import '../arg_parser.dart'; import '../utils/analytics.dart'; import '../utils/constants.dart'; import '../utils/tools_configuration.dart'; +import '../utils/uuid.dart'; /// Constants used by the MCP server to register services on DTD. /// @@ -78,6 +79,34 @@ base mixin DartToolingDaemonSupport /// Whether or not to enable the screenshot tool. bool get enableScreenshots; + /// A unique identifier for this server instance. + /// + /// This is generated on first access and then cached. It is used to create + /// a unique service name when registering services on DTD. + /// + /// Can only be accessed after `initialize` has been called. + String get clientId { + if (_clientId != null) return _clientId!; + final clientName = clientInfo.title ?? clientInfo.name; + _clientId = generateClientId(clientName); + return _clientId!; + } + + String? _clientId; + + @visibleForTesting + String generateClientId(String clientName) { + // Sanitizes the client name by: + // 1. replacing whitespace, '-', and '.' with '_' + // 2. removing all non-alphanumeric characters except '_' + final sanitizedClientName = clientName + .trim() + .toLowerCase() + .replaceAll(RegExp(r'[\s\.\-]+'), '_') + .replaceAll(RegExp(r'[^a-zA-Z0-9_]'), ''); + return '${sanitizedClientName}_${generateShortUUID()}'; + } + /// Called when the DTD connection is lost, resets all associated state. Future _resetDtd() async { _dtd = null; @@ -290,17 +319,11 @@ base mixin DartToolingDaemonSupport final dtd = _dtd!; if (clientCapabilities.sampling != null) { - try { - await dtd.registerService( - McpServiceConstants.serviceName, - McpServiceConstants.samplingRequest, - _handleSamplingRequest, - ); - } on RpcException catch (e) { - // It is expected for there to be an exception if the sampling service - // was already registered by another Dart MCP Server. - if (e.code != RpcErrorCodes.kServiceAlreadyRegistered) rethrow; - } + await dtd.registerService( + '${McpServiceConstants.serviceName}_$clientId', + McpServiceConstants.samplingRequest, + _handleSamplingRequest, + ); } } diff --git a/pkgs/dart_mcp_server/lib/src/utils/uuid.dart b/pkgs/dart_mcp_server/lib/src/utils/uuid.dart new file mode 100644 index 00000000..5f04febd --- /dev/null +++ b/pkgs/dart_mcp_server/lib/src/utils/uuid.dart @@ -0,0 +1,23 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:math' show Random; + +/// Generates a short, 8-character hex string from 32 bits of random data. +/// +/// This is not a standard UUID but is sufficient for use cases where a short, +/// unique-enough identifier is needed. +String generateShortUUID() => _bitsDigits(16, 4) + _bitsDigits(16, 4); + +// Note: The following private helpers were copied over from: +// https://github.com/dart-lang/webdev/blob/e2d14f1050fa07e9a60455cf9d2b8e6f4e9c332c/frontend_server_common/lib/src/uuid.dart +final Random _random = Random(); + +String _bitsDigits(int bitCount, int digitCount) => + _printDigits(_generateBits(bitCount), digitCount); + +int _generateBits(int bitCount) => _random.nextInt(1 << bitCount); + +String _printDigits(int value, int count) => + value.toRadixString(16).padLeft(count, '0'); diff --git a/pkgs/dart_mcp_server/test/tools/dtd_test.dart b/pkgs/dart_mcp_server/test/tools/dtd_test.dart index 3e842265..febcfe04 100644 --- a/pkgs/dart_mcp_server/test/tools/dtd_test.dart +++ b/pkgs/dart_mcp_server/test/tools/dtd_test.dart @@ -107,10 +107,41 @@ void main() { return (responseContent['text'] as String).split('\n'); } + Future getSamplingServiceName( + DartToolingDaemon dtdClient, + ) async { + final services = await dtdClient.getRegisteredServices(); + final samplingService = services.clientServices.firstWhere( + (s) => s.name.startsWith(McpServiceConstants.serviceName), + ); + return samplingService.name; + } + + test('is registered with correct name format', () async { + final dtdClient = testHarness.fakeEditorExtension.dtd; + final services = await dtdClient.getRegisteredServices(); + final samplingService = services.clientServices.first; + final sanitizedClientName = + 'test_client_for_the_dart_tooling_mcp_server'; + expect( + samplingService.name, + startsWith( + '${McpServiceConstants.serviceName}_${sanitizedClientName}_', + ), + ); + // Check that the service name ends with an 8-character ID. + expect(samplingService.name, matches(RegExp(r'[a-f0-9]{8}$'))); + expect( + samplingService.methods, + contains(McpServiceConstants.samplingRequest), + ); + }); + test('can make a sampling request with text', () async { final dtdClient = testHarness.fakeEditorExtension.dtd; + final samplingServiceName = await getSamplingServiceName(dtdClient); final response = await dtdClient.call( - McpServiceConstants.serviceName, + samplingServiceName, McpServiceConstants.samplingRequest, params: { 'messages': [ @@ -130,8 +161,9 @@ void main() { test('can make a sampling request with an image', () async { final dtdClient = testHarness.fakeEditorExtension.dtd; + final samplingServiceName = await getSamplingServiceName(dtdClient); final response = await dtdClient.call( - McpServiceConstants.serviceName, + samplingServiceName, McpServiceConstants.samplingRequest, params: { 'messages': [ @@ -155,8 +187,9 @@ void main() { test('can make a sampling request with audio', () async { final dtdClient = testHarness.fakeEditorExtension.dtd; + final samplingServiceName = await getSamplingServiceName(dtdClient); final response = await dtdClient.call( - McpServiceConstants.serviceName, + samplingServiceName, McpServiceConstants.samplingRequest, params: { 'messages': [ @@ -177,8 +210,9 @@ void main() { test('can make a sampling request with an embedded resource', () async { final dtdClient = testHarness.fakeEditorExtension.dtd; + final samplingServiceName = await getSamplingServiceName(dtdClient); final response = await dtdClient.call( - McpServiceConstants.serviceName, + samplingServiceName, McpServiceConstants.samplingRequest, params: { 'messages': [ @@ -201,8 +235,9 @@ void main() { test('can make a sampling request with mixed content', () async { final dtdClient = testHarness.fakeEditorExtension.dtd; + final samplingServiceName = await getSamplingServiceName(dtdClient); final response = await dtdClient.call( - McpServiceConstants.serviceName, + samplingServiceName, McpServiceConstants.samplingRequest, params: { 'messages': [ @@ -231,8 +266,9 @@ void main() { test('can handle user and assistant messages', () async { final dtdClient = testHarness.fakeEditorExtension.dtd; + final samplingServiceName = await getSamplingServiceName(dtdClient); final response = await dtdClient.call( - McpServiceConstants.serviceName, + samplingServiceName, McpServiceConstants.samplingRequest, params: { 'messages': [ @@ -262,8 +298,9 @@ void main() { test('forwards all messages, even those with unknown types', () async { final dtdClient = testHarness.fakeEditorExtension.dtd; + final samplingServiceName = await getSamplingServiceName(dtdClient); final response = await dtdClient.call( - McpServiceConstants.serviceName, + samplingServiceName, McpServiceConstants.samplingRequest, params: { 'messages': [ @@ -285,9 +322,10 @@ void main() { test('throws for invalid requests', () async { final dtdClient = testHarness.fakeEditorExtension.dtd; + final samplingServiceName = await getSamplingServiceName(dtdClient); try { await dtdClient.call( - McpServiceConstants.serviceName, + samplingServiceName, McpServiceConstants.samplingRequest, params: { 'messages': [ @@ -378,6 +416,65 @@ void main() { await testHarness.connectToDtd(); }); + group('generateClientId creates ID from client name', () { + test('removes whitespaces', () { + // Single whitespace character. + expect( + server.generateClientId('Example Name'), + startsWith('example_name_'), + ); + // Multiple whitespace characters. + expect( + server.generateClientId('Example Name'), + startsWith('example_name_'), + ); + // Newline and other whitespace. + expect( + server.generateClientId('Example\n\tName'), + startsWith('example_name_'), + ); + // Whitespace at the end. + expect( + server.generateClientId('Example Name\n'), + startsWith('example_name_'), + ); + }); + + test('replaces periods and dashes with underscores', () { + // Replaces periods. + expect( + server.generateClientId('Example.Client.Name'), + startsWith('example_client_name_'), + ); + // Replaces dashes. + expect( + server.generateClientId('example-client-name'), + startsWith('example_client_name_'), + ); + }); + + test('removes special characters', () { + expect( + server.generateClientId('Example!@#Client\$%^Name'), + startsWith('exampleclientname_'), + ); + }); + + test('handles a mix of sanitization rules', () { + expect( + server.generateClientId(' Example Client.Name!@# '), + startsWith('example_client_name_'), + ); + }); + + test('ends with an 8-character uuid', () { + expect( + server.generateClientId('Example name'), + matches(RegExp(r'[a-f0-9]{8}$')), + ); + }); + }); + group('$VmService management', () { late Directory appDir; final appPath = 'bin/main.dart';