diff --git a/pkgs/dart_mcp_server/lib/src/server.dart b/pkgs/dart_mcp_server/lib/src/server.dart index 7cc33980..0238b4b7 100644 --- a/pkgs/dart_mcp_server/lib/src/server.dart +++ b/pkgs/dart_mcp_server/lib/src/server.dart @@ -207,6 +207,51 @@ final class DartMCPServer extends MCPServer validateArguments: validateArguments, ); } + + @override + void addPrompt( + Prompt prompt, + FutureOr Function(GetPromptRequest) impl, + ) { + // For type promotion. + final analytics = this.analytics; + + super.addPrompt( + prompt, + analytics == null + ? impl + : (request) async { + final watch = Stopwatch()..start(); + GetPromptResult? 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.getPrompt.name, + additionalData: GetPromptMetrics( + name: request.name, + success: result != null && result.messages.isNotEmpty, + elapsedMilliseconds: watch.elapsedMilliseconds, + withArguments: request.arguments?.isNotEmpty == true, + ), + ), + ); + } catch (e) { + log( + LoggingLevel.warning, + 'Error sending analytics event: $e', + ); + } + } + }, + ); + } } /// Creates a `Sink` for [logFile]. diff --git a/pkgs/dart_mcp_server/lib/src/utils/analytics.dart b/pkgs/dart_mcp_server/lib/src/utils/analytics.dart index 97c45241..4c423ff2 100644 --- a/pkgs/dart_mcp_server/lib/src/utils/analytics.dart +++ b/pkgs/dart_mcp_server/lib/src/utils/analytics.dart @@ -14,7 +14,7 @@ abstract interface class AnalyticsSupport { Analytics? get analytics; } -enum AnalyticsEvent { callTool, readResource } +enum AnalyticsEvent { callTool, readResource, getPrompt } /// The metrics for a resources/read MCP handler. final class ReadResourceMetrics extends CustomMetrics { @@ -43,6 +43,36 @@ final class ReadResourceMetrics extends CustomMetrics { }; } +/// The metrics for a prompts/get MCP handler. +final class GetPromptMetrics extends CustomMetrics { + /// The name of the prompt that was retrieved. + final String name; + + /// Whether or not the prompt was given with arguments. + final bool withArguments; + + /// The time it took to generate the prompt. + final int elapsedMilliseconds; + + /// Whether or not the prompt call succeeded. + final bool success; + + GetPromptMetrics({ + required this.name, + required this.withArguments, + required this.elapsedMilliseconds, + required this.success, + }); + + @override + Map toMap() => { + _name: name, + _withArguments: withArguments, + _elapsedMilliseconds: elapsedMilliseconds, + _success: success, + }; +} + /// The metrics for a tools/call MCP handler. final class CallToolMetrics extends CustomMetrics { /// The name of the tool that was invoked. @@ -108,5 +138,7 @@ const _elapsedMilliseconds = 'elapsedMilliseconds'; const _failureReason = 'failureReason'; const _kind = 'kind'; const _length = 'length'; +const _name = 'name'; const _success = 'success'; const _tool = 'tool'; +const _withArguments = 'withArguments'; 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 0c703b45..b034e717 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 @@ -47,7 +47,7 @@ void main() { 'client': server.clientInfo.name, 'clientVersion': server.clientInfo.version, 'serverVersion': server.implementation.version, - 'type': 'callTool', + 'type': AnalyticsEvent.callTool.name, 'tool': 'hello', 'success': true, 'elapsedMilliseconds': isA(), @@ -85,7 +85,7 @@ void main() { 'client': server.clientInfo.name, 'clientVersion': server.clientInfo.version, 'serverVersion': server.implementation.version, - 'type': 'callTool', + 'type': AnalyticsEvent.callTool.name, 'tool': tool.name, 'success': false, 'elapsedMilliseconds': isA(), @@ -96,6 +96,125 @@ void main() { } }); + group('are sent for prompts', () { + final helloPrompt = Prompt( + name: 'hello', + arguments: [PromptArgument(name: 'name', required: false)], + ); + 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.single, + isA() + .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(), + '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.single, + isA() + .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(), + 'withArguments': true, + }), + ), + ); + }); + + test('even if they throw', () async { + try { + await testHarness.getPrompt( + GetPromptRequest( + name: helloPrompt.name, + arguments: {'throw': true}, + ), + ); + } catch (_) {} + expect( + analytics.sentEvents.single, + isA() + .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(), + 'withArguments': true, + }), + ), + ); + }); + }); + test('Changelog version matches dart server version', () { final changelogFile = File('CHANGELOG.md'); expect( diff --git a/pkgs/dart_mcp_server/test/test_harness.dart b/pkgs/dart_mcp_server/test/test_harness.dart index a8da3f02..c30364b8 100644 --- a/pkgs/dart_mcp_server/test/test_harness.dart +++ b/pkgs/dart_mcp_server/test/test_harness.dart @@ -196,6 +196,10 @@ class TestHarness { await Future.delayed(Duration(milliseconds: 100 * tryCount)); } } + + /// Calls [getPrompt] on the [mcpServerConnection]. + Future getPrompt(GetPromptRequest request) => + mcpServerConnection.getPrompt(request); } /// The debug session for a single app.