blob: 0c703b45e5769a96fb1085ddc2d741c574303f4d [file] [log] [blame]
// 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:async';
import 'dart:io';
import 'package:dart_mcp/server.dart';
import 'package:dart_mcp_server/src/server.dart';
import 'package:dart_mcp_server/src/utils/analytics.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 {
for (var reason in [null, CallToolFailureReason.nonZeroExitCode]) {
analytics.sentEvents.clear();
final tool = Tool(
name: 'hello${reason?.name ?? ''}',
inputSchema: Schema.object(),
);
server.registerTool(
tool,
(_) =>
CallToolResult(isError: true, content: [])
..failureReason = reason,
);
final result = await testHarness.mcpServerConnection.callTool(
CallToolRequest(name: tool.name),
);
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': tool.name,
'success': false,
'elapsedMilliseconds': isA<int>(),
'failureReason': ?reason?.name,
}),
),
);
}
});
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;
setUp(() async {
logDescriptor = d.file('log.txt');
testHarness = await TestHarness.start(
inProcess: false,
cliArgs: ['--log-file', logDescriptor.io.path],
);
});
test('logs traffic to a file', () async {
// It may take a bit for all the lines to show up in the log.
await doWithRetries(
() async => expect(
await File(logDescriptor.io.path).readAsLines(),
containsAll([
allOf(startsWith('<<<'), contains('"method":"initialize"')),
allOf(startsWith('>>>'), contains('"serverInfo"')),
allOf(startsWith('<<<'), contains('"notifications/initialized"')),
]),
),
);
// Ensure the file handle is released before the file is cleaned up.
await testHarness.serverConnectionPair.serverConnection.shutdown();
// Wait for the process to release the file.
await doWithRetries(() => File(logDescriptor.io.path).delete());
});
});
}
/// Performs [action] up to [maxRetries] times, backing off an extra 50ms
/// between each attempt.
FutureOr<T> doWithRetries<T>(
FutureOr<T> Function() action, {
int maxRetries = 5,
}) async {
var count = 0;
while (true) {
try {
return await action();
} catch (_) {
if (count == maxRetries) {
rethrow;
}
}
count++;
await Future<void>.delayed(Duration(milliseconds: 50 * count));
}
}