Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 34 additions & 11 deletions pkgs/dart_mcp_server/lib/src/mixins/dtd.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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<void> _resetDtd() async {
_dtd = null;
Expand Down Expand Up @@ -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,
);
}
}

Expand Down
23 changes: 23 additions & 0 deletions pkgs/dart_mcp_server/lib/src/utils/uuid.dart
Original file line number Diff line number Diff line change
@@ -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');
113 changes: 105 additions & 8 deletions pkgs/dart_mcp_server/test/tools/dtd_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,41 @@ void main() {
return (responseContent['text'] as String).split('\n');
}

Future<String> 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': [
Expand All @@ -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': [
Expand All @@ -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': [
Expand All @@ -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': [
Expand All @@ -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': [
Expand Down Expand Up @@ -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': [
Expand Down Expand Up @@ -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': [
Expand All @@ -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': [
Expand Down Expand Up @@ -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';
Expand Down