blob: a975fa3d10453d6c272235bef86035349f14e5df [file] [edit]
// 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:io';
import 'package:dart_mcp/server.dart';
import 'package:dart_mcp_server/src/features_configuration.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/names.dart';
import 'package:test/test.dart';
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 an initialize event', () {
expect(
analytics.sentEvents.first,
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': AnalyticsEvent.initialize.name,
'supportsElicitation': true,
'supportsRoots': true,
'supportsSampling': true,
}),
),
);
});
test('are sent for listTools', () async {
await server.listTools();
expect(
analytics.sentEvents.last,
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': AnalyticsEvent.listTools.name,
}),
),
);
});
test('are sent for listPrompts', () async {
await server.listPrompts();
expect(
analytics.sentEvents.last,
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': AnalyticsEvent.listPrompts.name,
}),
),
);
});
test('are sent for listResources', () async {
await server.listResources();
expect(
analytics.sentEvents.last,
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': AnalyticsEvent.listResources.name,
}),
),
);
});
test('are sent for listResourceTemplates', () async {
await server.listResourceTemplates();
expect(
analytics.sentEvents.last,
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': AnalyticsEvent.listResourceTemplates.name,
}),
),
);
});
test('are sent for successful tool calls', () async {
server.registerTool(
Tool(name: 'hello', inputSchema: Schema.object())
..categories = [FeatureCategory.cli],
(_) => 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.last,
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': AnalyticsEvent.callTool.name,
'tool': 'hello',
'success': true,
'elapsedMilliseconds': isA<int>(),
}),
),
);
});
test('are sent for failed tool calls', () async {
analytics.sentEvents.clear();
final tool = Tool(name: 'hello', inputSchema: Schema.object())
..categories = [FeatureCategory.cli];
server.registerTool(
tool,
(_) => CallToolResult(isError: true, content: [])..failureReason = null,
);
final result = await testHarness.mcpServerConnection.callTool(
CallToolRequest(name: tool.name),
);
expect(result.isError, true);
expect(
analytics.sentEvents.last,
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': AnalyticsEvent.callTool.name,
'tool': tool.name,
'success': false,
'elapsedMilliseconds': isA<int>(),
}),
),
);
});
test('are sent for tool calls with argument errors', () async {
analytics.sentEvents.clear();
final tool = Tool(
name: 'hello',
inputSchema: Schema.object(
properties: {'name': Schema.string()},
required: ['name'],
),
)..categories = [FeatureCategory.cli];
server.registerTool(
tool,
(_) => CallToolResult(content: [Content.text(text: 'world')]),
);
final result = await testHarness.mcpServerConnection.callTool(
CallToolRequest(name: tool.name),
);
expect(result.isError, true);
expect(
analytics.sentEvents.last,
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': AnalyticsEvent.callTool.name,
'tool': tool.name,
'success': false,
'failureReason': 'argumentError',
'elapsedMilliseconds': isA<int>(),
}),
),
);
});
test('includes the command in the tool name if present', () async {
analytics.sentEvents.clear();
final tool = Tool(
name: 'meta_tool',
inputSchema: Schema.object(
properties: {ParameterNames.command: Schema.string()},
required: [ParameterNames.command],
),
)..categories = [FeatureCategory.cli];
server.registerTool(
tool,
(request) => CallToolResult(
content: [
Content.text(
text: request.arguments![ParameterNames.command] as String,
),
],
),
);
await testHarness.mcpServerConnection.callTool(
CallToolRequest(
name: tool.name,
arguments: {ParameterNames.command: 'hello'},
),
);
expect(
analytics.sentEvents.last,
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': AnalyticsEvent.callTool.name,
'tool': '${tool.name}.hello',
'success': true,
'elapsedMilliseconds': isA<int>(),
}),
),
);
});
test('includes custom metrics if provided', () async {
analytics.sentEvents.clear();
final tool = Tool(name: 'hello', inputSchema: Schema.object())
..categories = [FeatureCategory.cli];
server.registerTool(
tool,
(_) =>
CallToolResult(content: [Content.text(text: 'world')])
..customMetrics = FakeCustomMetrics('world'),
);
await testHarness.mcpServerConnection.callTool(
CallToolRequest(name: tool.name),
);
expect(
analytics.sentEvents.last,
isA<Event>()
.having((e) => e.eventName, 'eventName', DashEvent.dartMCPEvent)
.having(
(e) => e.eventData['customValue'],
'eventData.customValue',
equals('world'),
),
);
});
group('are sent for prompts', () {
final helloPrompt = Prompt(
name: 'hello',
arguments: [PromptArgument(name: 'name', required: false)],
)..categories = [FeatureCategory.cli];
GetPromptResult getHelloPrompt(GetPromptRequest request) {
assert(request.name == helloPrompt.name);
if (request.arguments?['throw'] == true) {
throw StateError('Oh no!');
}
return GetPromptResult(
messages: [
PromptMessage(
role: Role.user,
content: Content.text(text: 'hello'),
),
if (request.arguments?['name'] case final name?)
PromptMessage(
role: Role.user,
content: Content.text(text: ', my name is $name'),
),
],
);
}
setUp(() {
server.addPrompt(helloPrompt, getHelloPrompt);
});
test('with no arguments', () async {
final result = await testHarness.getPrompt(
GetPromptRequest(name: helloPrompt.name),
);
expect((result.messages.single.content as TextContent).text, 'hello');
expect(result.messages.single.role, Role.user);
expect(
analytics.sentEvents.last,
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': AnalyticsEvent.getPrompt.name,
'name': helloPrompt.name,
'success': true,
'elapsedMilliseconds': isA<int>(),
'withArguments': false,
}),
),
);
});
test('with arguments', () async {
final result = await testHarness.getPrompt(
GetPromptRequest(name: helloPrompt.name, arguments: {'name': 'Bob'}),
);
expect((result.messages[0].content as TextContent).text, 'hello');
expect(result.messages[0].role, Role.user);
expect(
(result.messages[1].content as TextContent).text,
', my name is Bob',
);
expect(result.messages[1].role, Role.user);
expect(
analytics.sentEvents.last,
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': AnalyticsEvent.getPrompt.name,
'name': helloPrompt.name,
'success': true,
'elapsedMilliseconds': isA<int>(),
'withArguments': true,
}),
),
);
});
test('even if they throw', () async {
try {
await testHarness.getPrompt(
GetPromptRequest(
name: helloPrompt.name,
arguments: {'throw': true},
),
);
} catch (_) {}
expect(
analytics.sentEvents.last,
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': AnalyticsEvent.getPrompt.name,
'name': helloPrompt.name,
'success': false,
'elapsedMilliseconds': isA<int>(),
'withArguments': true,
}),
),
);
});
});
test('Changelog version matches dart server version', () {
final changelogFile = File('CHANGELOG.md');
expect(
changelogFile.readAsLinesSync().first.split(' ')[1],
testHarness.serverConnectionPair.server!.implementation.version,
);
});
});
}
final class FakeCustomMetrics extends CustomMetrics {
final String customValue;
FakeCustomMetrics(this.customValue);
@override
Map<String, Object> toMap() => {'customValue': customValue};
}