add analytics support to the Dart MCP server (#174)
Based on https://github.com/dart-lang/tools/pull/2112
Adds analytics events for all tool calls as well as for the runtime errors resource.
We could consider a generic resource read event which includes the URI, but we could accidentally create URIs that have some information we don't want to capture, so I chose to instead just customize this and not provide the URI, but a field that describes the type of resource that was read.
I did not add any debouncing because I don't think it is necessary, LLMs do not invoke tools in super rapid succession generally, but let me know if you all disagree and think I should do some debouncing.
diff --git a/pkgs/dart_mcp_server/CHANGELOG.md b/pkgs/dart_mcp_server/CHANGELOG.md
index 32429e8..91854d3 100644
--- a/pkgs/dart_mcp_server/CHANGELOG.md
+++ b/pkgs/dart_mcp_server/CHANGELOG.md
@@ -1,4 +1,4 @@
-# Dart SDK 3.8.0 - WP
+# 0.1.0 (Dart SDK 3.8.0) - WP
* Add documentation/homepage/repository links to pub results.
* Handle relative paths under roots without trailing slashes.
@@ -41,3 +41,4 @@
* Reduce output size of `run_tests` tool to save on input tokens.
* Add `--log-file` argument to log all protocol traffic to a file.
* Improve error text for failed DTD connections as well as the tool description.
+* Add support for injecting an `Analytics` instance to track usage.
diff --git a/pkgs/dart_mcp_server/bin/main.dart b/pkgs/dart_mcp_server/bin/main.dart
index fee4582..9870f90 100644
--- a/pkgs/dart_mcp_server/bin/main.dart
+++ b/pkgs/dart_mcp_server/bin/main.dart
@@ -43,6 +43,7 @@
),
forceRootsFallback: parsedArgs.flag(forceRootsFallback),
sdk: Sdk.find(dartSdkPath: dartSdkPath, flutterSdkPath: flutterSdkPath),
+ analytics: null,
protocolLogSink: logFileSink,
)..done.whenComplete(() => logFileSink?.close());
},
diff --git a/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart b/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart
index 0ce7d5d..c390004 100644
--- a/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart
+++ b/pkgs/dart_mcp_server/lib/src/mixins/dtd.dart
@@ -10,10 +10,12 @@
import 'package:dtd/dtd.dart';
import 'package:json_rpc_2/json_rpc_2.dart';
import 'package:meta/meta.dart';
+import 'package:unified_analytics/unified_analytics.dart' as ua;
import 'package:vm_service/vm_service.dart';
import 'package:vm_service/vm_service_io.dart';
import 'package:web_socket/web_socket.dart';
+import '../utils/analytics.dart';
import '../utils/constants.dart';
/// Mix this in to any MCPServer to add support for connecting to the Dart
@@ -22,7 +24,8 @@
///
/// The MCPServer must already have the [ToolsSupport] mixin applied.
base mixin DartToolingDaemonSupport
- on ToolsSupport, LoggingSupport, ResourcesSupport {
+ on ToolsSupport, LoggingSupport, ResourcesSupport
+ implements AnalyticsSupport {
DartToolingDaemon? _dtd;
/// Whether or not the DTD extension to get the active debug sessions is
@@ -115,12 +118,32 @@
'"${debugSession.name}".',
);
addResource(resource, (request) async {
- return ReadResourceResult(
+ final watch = Stopwatch()..start();
+ final result = ReadResourceResult(
contents: [
for (var error in errorService.errorLog.errors)
TextResourceContents(uri: resource.uri, text: error),
],
);
+ watch.stop();
+ try {
+ analytics?.send(
+ ua.Event.dartMCPEvent(
+ client: clientInfo.name,
+ clientVersion: clientInfo.version,
+ serverVersion: implementation.version,
+ type: AnalyticsEvent.readResource.name,
+ additionalData: ReadResourceMetrics(
+ kind: ResourceKind.runtimeErrors,
+ length: result.contents.length,
+ elapsedMilliseconds: watch.elapsedMilliseconds,
+ ),
+ ),
+ );
+ } catch (e) {
+ log(LoggingLevel.warning, 'Error sending analytics event: $e');
+ }
+ return result;
});
errorService.errorsStream.listen((_) => updateResource(resource));
unawaited(
diff --git a/pkgs/dart_mcp_server/lib/src/server.dart b/pkgs/dart_mcp_server/lib/src/server.dart
index 6b8a68e..2979db4 100644
--- a/pkgs/dart_mcp_server/lib/src/server.dart
+++ b/pkgs/dart_mcp_server/lib/src/server.dart
@@ -2,11 +2,14 @@
// 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:async';
+
import 'package:dart_mcp/server.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:meta/meta.dart';
import 'package:process/process.dart';
+import 'package:unified_analytics/unified_analytics.dart';
import 'mixins/analyzer.dart';
import 'mixins/dash_cli.dart';
@@ -14,6 +17,7 @@
import 'mixins/pub.dart';
import 'mixins/pub_dev_search.dart';
import 'mixins/roots_fallback_support.dart';
+import 'utils/analytics.dart';
import 'utils/file_system.dart';
import 'utils/process_manager.dart';
import 'utils/sdk.dart';
@@ -31,10 +35,15 @@
PubSupport,
PubDevSupport,
DartToolingDaemonSupport
- implements ProcessManagerSupport, FileSystemSupport, SdkSupport {
+ implements
+ AnalyticsSupport,
+ ProcessManagerSupport,
+ FileSystemSupport,
+ SdkSupport {
DartMCPServer(
super.channel, {
required this.sdk,
+ this.analytics,
@visibleForTesting this.processManager = const LocalProcessManager(),
@visibleForTesting this.fileSystem = const LocalFileSystem(),
this.forceRootsFallback = false,
@@ -42,7 +51,7 @@
}) : super.fromStreamChannel(
implementation: Implementation(
name: 'dart and flutter tooling',
- version: '0.1.0-wip',
+ version: '0.1.0',
),
instructions:
'This server helps to connect Dart and Flutter developers to '
@@ -62,4 +71,50 @@
@override
final Sdk sdk;
+
+ @override
+ final Analytics? analytics;
+
+ @override
+ /// Automatically logs all tool calls via analytics by wrapping the [impl],
+ /// if [analytics] is not `null`.
+ void registerTool(
+ Tool tool,
+ FutureOr<CallToolResult> Function(CallToolRequest) impl,
+ ) {
+ // For type promotion.
+ final analytics = this.analytics;
+
+ super.registerTool(
+ tool,
+ analytics == null
+ ? impl
+ : (CallToolRequest request) async {
+ final watch = Stopwatch()..start();
+ CallToolResult? result;
+ try {
+ return result = await impl(request);
+ } finally {
+ watch.stop();
+ try {
+ analytics.send(
+ Event.dartMCPEvent(
+ client: clientInfo.name,
+ clientVersion: clientInfo.version,
+ serverVersion: implementation.version,
+ type: AnalyticsEvent.callTool.name,
+ additionalData: CallToolMetrics(
+ tool: request.name,
+ success: result != null && result.isError != true,
+ elapsedMilliseconds: watch.elapsedMilliseconds,
+ ),
+ ),
+ );
+ } catch (e) {
+ log(LoggingLevel.warning, 'Error sending analytics event: $e');
+ }
+ }
+ },
+ );
+ }
}
diff --git a/pkgs/dart_mcp_server/lib/src/utils/analytics.dart b/pkgs/dart_mcp_server/lib/src/utils/analytics.dart
new file mode 100644
index 0000000..9c71dd9
--- /dev/null
+++ b/pkgs/dart_mcp_server/lib/src/utils/analytics.dart
@@ -0,0 +1,76 @@
+// 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 'package:unified_analytics/unified_analytics.dart';
+
+/// An interface class that provides a access to an [Analytics] instance, if
+/// enabled.
+///
+/// The `DartMCPServer` class implements this class so that [Analytics]
+/// methods can be easily mocked during testing.
+abstract interface class AnalyticsSupport {
+ Analytics? get analytics;
+}
+
+enum AnalyticsEvent { callTool, readResource }
+
+/// The metrics for a resources/read MCP handler.
+final class ReadResourceMetrics extends CustomMetrics {
+ /// The kind of resource that was read.
+ ///
+ /// We don't want to record the full URI.
+ final ResourceKind kind;
+
+ /// The length of the resource.
+ final int length;
+
+ /// The time it took to read the resource.
+ final int elapsedMilliseconds;
+
+ ReadResourceMetrics({
+ required this.kind,
+ required this.length,
+ required this.elapsedMilliseconds,
+ });
+
+ @override
+ Map<String, Object> toMap() => {
+ _kind: kind.name,
+ _length: length,
+ _elapsedMilliseconds: elapsedMilliseconds,
+ };
+}
+
+/// The metrics for a tools/call MCP handler.
+final class CallToolMetrics extends CustomMetrics {
+ /// The name of the tool that was invoked.
+ final String tool;
+
+ /// Whether or not the tool call succeeded.
+ final bool success;
+
+ /// The time it took to invoke the tool.
+ final int elapsedMilliseconds;
+
+ CallToolMetrics({
+ required this.tool,
+ required this.success,
+ required this.elapsedMilliseconds,
+ });
+
+ @override
+ Map<String, Object> toMap() => {
+ _tool: tool,
+ _success: success,
+ _elapsedMilliseconds: elapsedMilliseconds,
+ };
+}
+
+enum ResourceKind { runtimeErrors }
+
+const _elapsedMilliseconds = 'elapsedMilliseconds';
+const _kind = 'kind';
+const _length = 'length';
+const _success = 'success';
+const _tool = 'tool';
diff --git a/pkgs/dart_mcp_server/pubspec.yaml b/pkgs/dart_mcp_server/pubspec.yaml
index 5edba19..b78cbf7 100644
--- a/pkgs/dart_mcp_server/pubspec.yaml
+++ b/pkgs/dart_mcp_server/pubspec.yaml
@@ -2,7 +2,6 @@
description: >-
An MCP server for Dart projects, exposing various developer tools to AI
models.
-
publish_to: none
environment:
@@ -33,6 +32,7 @@
pool: ^1.5.1
process: ^5.0.3
stream_channel: ^2.1.4
+ unified_analytics: ^8.0.2
vm_service: ^15.0.0
watcher: ^1.1.1
web_socket: ^1.0.1
diff --git a/pkgs/dart_mcp_server/test/dart_tooling_mcp_server_test.dart b/pkgs/dart_mcp_server/test/dart_tooling_mcp_server_test.dart
index 607c56e..606be7f 100644
--- a/pkgs/dart_mcp_server/test/dart_tooling_mcp_server_test.dart
+++ b/pkgs/dart_mcp_server/test/dart_tooling_mcp_server_test.dart
@@ -5,12 +5,94 @@
import 'dart:async';
import 'dart:io';
+import 'package:dart_mcp/server.dart';
+import 'package:dart_mcp_server/src/server.dart';
import 'package:test/test.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;
+import 'package:unified_analytics/testing.dart';
+import 'package:unified_analytics/unified_analytics.dart';
import 'test_harness.dart';
void main() {
+ group('analytics', () {
+ late TestHarness testHarness;
+ late DartMCPServer server;
+ late FakeAnalytics analytics;
+
+ setUp(() async {
+ testHarness = await TestHarness.start(inProcess: true);
+ server = testHarness.serverConnectionPair.server!;
+ analytics = server.analytics as FakeAnalytics;
+ });
+
+ test('sends analytics for successful tool calls', () async {
+ server.registerTool(
+ Tool(name: 'hello', inputSchema: Schema.object()),
+ (_) => CallToolResult(content: [Content.text(text: 'world')]),
+ );
+ final result = await testHarness.callToolWithRetry(
+ CallToolRequest(name: 'hello'),
+ );
+ expect((result.content.single as TextContent).text, 'world');
+ expect(
+ analytics.sentEvents.single,
+ isA<Event>()
+ .having((e) => e.eventName, 'eventName', DashEvent.dartMCPEvent)
+ .having(
+ (e) => e.eventData,
+ 'eventData',
+ equals({
+ 'client': server.clientInfo.name,
+ 'clientVersion': server.clientInfo.version,
+ 'serverVersion': server.implementation.version,
+ 'type': 'callTool',
+ 'tool': 'hello',
+ 'success': true,
+ 'elapsedMilliseconds': isA<int>(),
+ }),
+ ),
+ );
+ });
+
+ test('sends analytics for failed tool calls', () async {
+ server.registerTool(
+ Tool(name: 'hello', inputSchema: Schema.object()),
+ (_) => CallToolResult(isError: true, content: []),
+ );
+ final result = await testHarness.mcpServerConnection.callTool(
+ CallToolRequest(name: 'hello'),
+ );
+ expect(result.isError, true);
+ expect(
+ analytics.sentEvents.single,
+ isA<Event>()
+ .having((e) => e.eventName, 'eventName', DashEvent.dartMCPEvent)
+ .having(
+ (e) => e.eventData,
+ 'eventData',
+ equals({
+ 'client': server.clientInfo.name,
+ 'clientVersion': server.clientInfo.version,
+ 'serverVersion': server.implementation.version,
+ 'type': 'callTool',
+ 'tool': 'hello',
+ 'success': false,
+ 'elapsedMilliseconds': isA<int>(),
+ }),
+ ),
+ );
+ });
+
+ test('Changelog version matches dart server version', () {
+ final changelogFile = File('CHANGELOG.md');
+ expect(
+ changelogFile.readAsLinesSync().first.split(' ')[1],
+ testHarness.serverConnectionPair.server!.implementation.version,
+ );
+ });
+ });
+
group('--log-file', () {
late d.FileDescriptor logDescriptor;
late TestHarness testHarness;
@@ -41,7 +123,7 @@
// Wait for the process to release the file.
await doWithRetries(() => File(logDescriptor.io.path).delete());
- });
+ }, skip: 'https://github.com/dart-lang/ai/issues/181');
});
}
diff --git a/pkgs/dart_mcp_server/test/test_harness.dart b/pkgs/dart_mcp_server/test/test_harness.dart
index c11d887..3e5abdf 100644
--- a/pkgs/dart_mcp_server/test/test_harness.dart
+++ b/pkgs/dart_mcp_server/test/test_harness.dart
@@ -16,11 +16,13 @@
import 'package:dtd/dtd.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
+import 'package:file/memory.dart';
import 'package:path/path.dart' as p;
import 'package:process/process.dart';
import 'package:stream_channel/stream_channel.dart';
import 'package:test/test.dart';
import 'package:test_process/test_process.dart';
+import 'package:unified_analytics/unified_analytics.dart';
/// A full environment for integration testing the MCP server.
///
@@ -417,11 +419,32 @@
clientController.stream,
serverController.sink,
);
+ final analyticsFileSystem = MemoryFileSystem();
+ final analyticsHomeDir = analyticsFileSystem.directory('home');
+ late Analytics analytics;
+ // Need to create it twice, for the first run analytics are never sent.
+ for (var i = 0; i < 2; i++) {
+ analytics = Analytics.fake(
+ tool: DashTool.dartTool,
+ dartVersion: Platform.version.substring(
+ 0,
+ Platform.version.indexOf(' '),
+ ),
+ fs: analyticsFileSystem,
+ homeDirectory: analyticsHomeDir,
+ toolsMessageVersion: -2, // Required or else analytics are disabled
+ );
+ }
+ // Required to enable telemetry
+ analytics.clientShowedMessage();
+ expect(analytics.okToSend, true);
+
server = DartMCPServer(
serverChannel,
processManager: TestProcessManager(),
fileSystem: fileSystem,
sdk: sdk,
+ analytics: analytics,
);
addTearDown(server.shutdown);
connection = client.connectServer(clientChannel);
diff --git a/pkgs/dart_mcp_server/test/tools/dtd_test.dart b/pkgs/dart_mcp_server/test/tools/dtd_test.dart
index ba07aa8..713fd1a 100644
--- a/pkgs/dart_mcp_server/test/tools/dtd_test.dart
+++ b/pkgs/dart_mcp_server/test/tools/dtd_test.dart
@@ -9,8 +9,12 @@
import 'package:async/async.dart';
import 'package:dart_mcp/server.dart';
import 'package:dart_mcp_server/src/mixins/dtd.dart';
+import 'package:dart_mcp_server/src/server.dart';
+import 'package:dart_mcp_server/src/utils/analytics.dart';
import 'package:dart_mcp_server/src/utils/constants.dart';
import 'package:test/test.dart';
+import 'package:unified_analytics/testing.dart';
+import 'package:unified_analytics/unified_analytics.dart' as ua;
import 'package:vm_service/vm_service.dart';
import '../test_harness.dart';
@@ -148,6 +152,8 @@
});
group('[in process]', () {
+ late ua.FakeAnalytics analytics;
+ late DartMCPServer server;
setUp(() async {
DartToolingDaemonSupport.debugAwaitVmServiceDisposal = true;
addTearDown(
@@ -155,6 +161,8 @@
);
testHarness = await TestHarness.start(inProcess: true);
+ server = testHarness.serverConnectionPair.server!;
+ analytics = server.analytics! as ua.FakeAnalytics;
await testHarness.connectToDtd();
});
@@ -480,6 +488,31 @@
ReadResourceRequest(uri: resource.uri),
)).contents;
expect(finalContents, isEmpty);
+
+ expect(
+ analytics.sentEvents,
+ contains(
+ isA<ua.Event>()
+ .having(
+ (e) => e.eventName,
+ 'eventName',
+ DashEvent.dartMCPEvent,
+ )
+ .having(
+ (e) => e.eventData,
+ 'eventData',
+ equals({
+ 'client': server.clientInfo.name,
+ 'clientVersion': server.clientInfo.version,
+ 'serverVersion': server.implementation.version,
+ 'type': 'readResource',
+ 'kind': ResourceKind.runtimeErrors.name,
+ 'length': isA<int>(),
+ 'elapsedMilliseconds': isA<int>(),
+ }),
+ ),
+ ),
+ );
},
onPlatform: {
'windows': const Skip('https://github.com/dart-lang/ai/issues/151'),