Skip to content

Commit 5f9c50f

Browse files
authored
add analytics support to the Dart MCP server (#174)
Based on dart-lang/tools#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.
1 parent 12ac0a4 commit 5f9c50f

File tree

9 files changed

+301
-7
lines changed

9 files changed

+301
-7
lines changed

pkgs/dart_mcp_server/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Dart SDK 3.8.0 - WP
1+
# 0.1.0 (Dart SDK 3.8.0) - WP
22

33
* Add documentation/homepage/repository links to pub results.
44
* Handle relative paths under roots without trailing slashes.
@@ -41,3 +41,4 @@
4141
* Reduce output size of `run_tests` tool to save on input tokens.
4242
* Add `--log-file` argument to log all protocol traffic to a file.
4343
* Improve error text for failed DTD connections as well as the tool description.
44+
* Add support for injecting an `Analytics` instance to track usage.

pkgs/dart_mcp_server/bin/main.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ void main(List<String> args) async {
4343
),
4444
forceRootsFallback: parsedArgs.flag(forceRootsFallback),
4545
sdk: Sdk.find(dartSdkPath: dartSdkPath, flutterSdkPath: flutterSdkPath),
46+
analytics: null,
4647
protocolLogSink: logFileSink,
4748
)..done.whenComplete(() => logFileSink?.close());
4849
},

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import 'package:dds_service_extensions/dds_service_extensions.dart';
1010
import 'package:dtd/dtd.dart';
1111
import 'package:json_rpc_2/json_rpc_2.dart';
1212
import 'package:meta/meta.dart';
13+
import 'package:unified_analytics/unified_analytics.dart' as ua;
1314
import 'package:vm_service/vm_service.dart';
1415
import 'package:vm_service/vm_service_io.dart';
1516
import 'package:web_socket/web_socket.dart';
1617

18+
import '../utils/analytics.dart';
1719
import '../utils/constants.dart';
1820

1921
/// Mix this in to any MCPServer to add support for connecting to the Dart
@@ -22,7 +24,8 @@ import '../utils/constants.dart';
2224
///
2325
/// The MCPServer must already have the [ToolsSupport] mixin applied.
2426
base mixin DartToolingDaemonSupport
25-
on ToolsSupport, LoggingSupport, ResourcesSupport {
27+
on ToolsSupport, LoggingSupport, ResourcesSupport
28+
implements AnalyticsSupport {
2629
DartToolingDaemon? _dtd;
2730

2831
/// Whether or not the DTD extension to get the active debug sessions is
@@ -115,12 +118,32 @@ base mixin DartToolingDaemonSupport
115118
'"${debugSession.name}".',
116119
);
117120
addResource(resource, (request) async {
118-
return ReadResourceResult(
121+
final watch = Stopwatch()..start();
122+
final result = ReadResourceResult(
119123
contents: [
120124
for (var error in errorService.errorLog.errors)
121125
TextResourceContents(uri: resource.uri, text: error),
122126
],
123127
);
128+
watch.stop();
129+
try {
130+
analytics?.send(
131+
ua.Event.dartMCPEvent(
132+
client: clientInfo.name,
133+
clientVersion: clientInfo.version,
134+
serverVersion: implementation.version,
135+
type: AnalyticsEvent.readResource.name,
136+
additionalData: ReadResourceMetrics(
137+
kind: ResourceKind.runtimeErrors,
138+
length: result.contents.length,
139+
elapsedMilliseconds: watch.elapsedMilliseconds,
140+
),
141+
),
142+
);
143+
} catch (e) {
144+
log(LoggingLevel.warning, 'Error sending analytics event: $e');
145+
}
146+
return result;
124147
});
125148
errorService.errorsStream.listen((_) => updateResource(resource));
126149
unawaited(

pkgs/dart_mcp_server/lib/src/server.dart

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,22 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5+
import 'dart:async';
6+
57
import 'package:dart_mcp/server.dart';
68
import 'package:file/file.dart';
79
import 'package:file/local.dart';
810
import 'package:meta/meta.dart';
911
import 'package:process/process.dart';
12+
import 'package:unified_analytics/unified_analytics.dart';
1013

1114
import 'mixins/analyzer.dart';
1215
import 'mixins/dash_cli.dart';
1316
import 'mixins/dtd.dart';
1417
import 'mixins/pub.dart';
1518
import 'mixins/pub_dev_search.dart';
1619
import 'mixins/roots_fallback_support.dart';
20+
import 'utils/analytics.dart';
1721
import 'utils/file_system.dart';
1822
import 'utils/process_manager.dart';
1923
import 'utils/sdk.dart';
@@ -31,18 +35,23 @@ final class DartMCPServer extends MCPServer
3135
PubSupport,
3236
PubDevSupport,
3337
DartToolingDaemonSupport
34-
implements ProcessManagerSupport, FileSystemSupport, SdkSupport {
38+
implements
39+
AnalyticsSupport,
40+
ProcessManagerSupport,
41+
FileSystemSupport,
42+
SdkSupport {
3543
DartMCPServer(
3644
super.channel, {
3745
required this.sdk,
46+
this.analytics,
3847
@visibleForTesting this.processManager = const LocalProcessManager(),
3948
@visibleForTesting this.fileSystem = const LocalFileSystem(),
4049
this.forceRootsFallback = false,
4150
super.protocolLogSink,
4251
}) : super.fromStreamChannel(
4352
implementation: Implementation(
4453
name: 'dart and flutter tooling',
45-
version: '0.1.0-wip',
54+
version: '0.1.0',
4655
),
4756
instructions:
4857
'This server helps to connect Dart and Flutter developers to '
@@ -62,4 +71,50 @@ final class DartMCPServer extends MCPServer
6271

6372
@override
6473
final Sdk sdk;
74+
75+
@override
76+
final Analytics? analytics;
77+
78+
@override
79+
/// Automatically logs all tool calls via analytics by wrapping the [impl],
80+
/// if [analytics] is not `null`.
81+
void registerTool(
82+
Tool tool,
83+
FutureOr<CallToolResult> Function(CallToolRequest) impl,
84+
) {
85+
// For type promotion.
86+
final analytics = this.analytics;
87+
88+
super.registerTool(
89+
tool,
90+
analytics == null
91+
? impl
92+
: (CallToolRequest request) async {
93+
final watch = Stopwatch()..start();
94+
CallToolResult? result;
95+
try {
96+
return result = await impl(request);
97+
} finally {
98+
watch.stop();
99+
try {
100+
analytics.send(
101+
Event.dartMCPEvent(
102+
client: clientInfo.name,
103+
clientVersion: clientInfo.version,
104+
serverVersion: implementation.version,
105+
type: AnalyticsEvent.callTool.name,
106+
additionalData: CallToolMetrics(
107+
tool: request.name,
108+
success: result != null && result.isError != true,
109+
elapsedMilliseconds: watch.elapsedMilliseconds,
110+
),
111+
),
112+
);
113+
} catch (e) {
114+
log(LoggingLevel.warning, 'Error sending analytics event: $e');
115+
}
116+
}
117+
},
118+
);
119+
}
65120
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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 'package:unified_analytics/unified_analytics.dart';
6+
7+
/// An interface class that provides a access to an [Analytics] instance, if
8+
/// enabled.
9+
///
10+
/// The `DartMCPServer` class implements this class so that [Analytics]
11+
/// methods can be easily mocked during testing.
12+
abstract interface class AnalyticsSupport {
13+
Analytics? get analytics;
14+
}
15+
16+
enum AnalyticsEvent { callTool, readResource }
17+
18+
/// The metrics for a resources/read MCP handler.
19+
final class ReadResourceMetrics extends CustomMetrics {
20+
/// The kind of resource that was read.
21+
///
22+
/// We don't want to record the full URI.
23+
final ResourceKind kind;
24+
25+
/// The length of the resource.
26+
final int length;
27+
28+
/// The time it took to read the resource.
29+
final int elapsedMilliseconds;
30+
31+
ReadResourceMetrics({
32+
required this.kind,
33+
required this.length,
34+
required this.elapsedMilliseconds,
35+
});
36+
37+
@override
38+
Map<String, Object> toMap() => {
39+
_kind: kind.name,
40+
_length: length,
41+
_elapsedMilliseconds: elapsedMilliseconds,
42+
};
43+
}
44+
45+
/// The metrics for a tools/call MCP handler.
46+
final class CallToolMetrics extends CustomMetrics {
47+
/// The name of the tool that was invoked.
48+
final String tool;
49+
50+
/// Whether or not the tool call succeeded.
51+
final bool success;
52+
53+
/// The time it took to invoke the tool.
54+
final int elapsedMilliseconds;
55+
56+
CallToolMetrics({
57+
required this.tool,
58+
required this.success,
59+
required this.elapsedMilliseconds,
60+
});
61+
62+
@override
63+
Map<String, Object> toMap() => {
64+
_tool: tool,
65+
_success: success,
66+
_elapsedMilliseconds: elapsedMilliseconds,
67+
};
68+
}
69+
70+
enum ResourceKind { runtimeErrors }
71+
72+
const _elapsedMilliseconds = 'elapsedMilliseconds';
73+
const _kind = 'kind';
74+
const _length = 'length';
75+
const _success = 'success';
76+
const _tool = 'tool';

pkgs/dart_mcp_server/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ name: dart_mcp_server
22
description: >-
33
An MCP server for Dart projects, exposing various developer tools to AI
44
models.
5-
65
publish_to: none
76

87
environment:
@@ -33,6 +32,7 @@ dependencies:
3332
pool: ^1.5.1
3433
process: ^5.0.3
3534
stream_channel: ^2.1.4
35+
unified_analytics: ^8.0.2
3636
vm_service: ^15.0.0
3737
watcher: ^1.1.1
3838
web_socket: ^1.0.1

pkgs/dart_mcp_server/test/dart_tooling_mcp_server_test.dart

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,94 @@
55
import 'dart:async';
66
import 'dart:io';
77

8+
import 'package:dart_mcp/server.dart';
9+
import 'package:dart_mcp_server/src/server.dart';
810
import 'package:test/test.dart';
911
import 'package:test_descriptor/test_descriptor.dart' as d;
12+
import 'package:unified_analytics/testing.dart';
13+
import 'package:unified_analytics/unified_analytics.dart';
1014

1115
import 'test_harness.dart';
1216

1317
void main() {
18+
group('analytics', () {
19+
late TestHarness testHarness;
20+
late DartMCPServer server;
21+
late FakeAnalytics analytics;
22+
23+
setUp(() async {
24+
testHarness = await TestHarness.start(inProcess: true);
25+
server = testHarness.serverConnectionPair.server!;
26+
analytics = server.analytics as FakeAnalytics;
27+
});
28+
29+
test('sends analytics for successful tool calls', () async {
30+
server.registerTool(
31+
Tool(name: 'hello', inputSchema: Schema.object()),
32+
(_) => CallToolResult(content: [Content.text(text: 'world')]),
33+
);
34+
final result = await testHarness.callToolWithRetry(
35+
CallToolRequest(name: 'hello'),
36+
);
37+
expect((result.content.single as TextContent).text, 'world');
38+
expect(
39+
analytics.sentEvents.single,
40+
isA<Event>()
41+
.having((e) => e.eventName, 'eventName', DashEvent.dartMCPEvent)
42+
.having(
43+
(e) => e.eventData,
44+
'eventData',
45+
equals({
46+
'client': server.clientInfo.name,
47+
'clientVersion': server.clientInfo.version,
48+
'serverVersion': server.implementation.version,
49+
'type': 'callTool',
50+
'tool': 'hello',
51+
'success': true,
52+
'elapsedMilliseconds': isA<int>(),
53+
}),
54+
),
55+
);
56+
});
57+
58+
test('sends analytics for failed tool calls', () async {
59+
server.registerTool(
60+
Tool(name: 'hello', inputSchema: Schema.object()),
61+
(_) => CallToolResult(isError: true, content: []),
62+
);
63+
final result = await testHarness.mcpServerConnection.callTool(
64+
CallToolRequest(name: 'hello'),
65+
);
66+
expect(result.isError, true);
67+
expect(
68+
analytics.sentEvents.single,
69+
isA<Event>()
70+
.having((e) => e.eventName, 'eventName', DashEvent.dartMCPEvent)
71+
.having(
72+
(e) => e.eventData,
73+
'eventData',
74+
equals({
75+
'client': server.clientInfo.name,
76+
'clientVersion': server.clientInfo.version,
77+
'serverVersion': server.implementation.version,
78+
'type': 'callTool',
79+
'tool': 'hello',
80+
'success': false,
81+
'elapsedMilliseconds': isA<int>(),
82+
}),
83+
),
84+
);
85+
});
86+
87+
test('Changelog version matches dart server version', () {
88+
final changelogFile = File('CHANGELOG.md');
89+
expect(
90+
changelogFile.readAsLinesSync().first.split(' ')[1],
91+
testHarness.serverConnectionPair.server!.implementation.version,
92+
);
93+
});
94+
});
95+
1496
group('--log-file', () {
1597
late d.FileDescriptor logDescriptor;
1698
late TestHarness testHarness;
@@ -41,7 +123,7 @@ void main() {
41123

42124
// Wait for the process to release the file.
43125
await doWithRetries(() => File(logDescriptor.io.path).delete());
44-
});
126+
}, skip: 'https://github.com/dart-lang/ai/issues/181');
45127
});
46128
}
47129

0 commit comments

Comments
 (0)