diff --git a/pkgs/dart_mcp/CHANGELOG.md b/pkgs/dart_mcp/CHANGELOG.md index 2e5da882..7ef10b2e 100644 --- a/pkgs/dart_mcp/CHANGELOG.md +++ b/pkgs/dart_mcp/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.2.2-wip + +- Move the `done` future from the `ServerConnection` into `MCPBase` so it is + available to the `MPCServer` class as well. + ## 0.2.1 - Fix the `protocolLogSink` support when using `MCPClient.connectStdioServer`. diff --git a/pkgs/dart_mcp/lib/src/client/client.dart b/pkgs/dart_mcp/lib/src/client/client.dart index ff807dee..34c22fc2 100644 --- a/pkgs/dart_mcp/lib/src/client/client.dart +++ b/pkgs/dart_mcp/lib/src/client/client.dart @@ -54,7 +54,8 @@ base class MCPClient { /// /// If [protocolLogSink] is provided, all messages sent between the client and /// server will be forwarded to that [Sink] as well, with `<<<` preceding - /// incoming messages and `>>>` preceding outgoing messages. + /// incoming messages and `>>>` preceding outgoing messages. It is the + /// responsibility of the caller to close this sink. Future connectStdioServer( String command, List arguments, { @@ -90,7 +91,8 @@ base class MCPClient { /// /// If [protocolLogSink] is provided, all messages sent on [channel] will be /// forwarded to that [Sink] as well, with `<<<` preceding incoming messages - /// and `>>>` preceding outgoing messages. + /// and `>>>` preceding outgoing messages. It is the responsibility of the + /// caller to close this sink. ServerConnection connectServer( StreamChannel channel, { Sink? protocolLogSink, @@ -187,10 +189,6 @@ base class ServerConnection extends MCPBase { final _logController = StreamController.broadcast(); - /// Completes when [shutdown] is called. - Future get done => _done.future; - final Completer _done = Completer(); - /// A 1:1 connection from a client to a server using [channel]. /// /// If the client supports "roots", then it should provide an implementation @@ -256,7 +254,6 @@ base class ServerConnection extends MCPBase { _resourceUpdatedController.close(), _logController.close(), ]); - _done.complete(); } /// Called after a successful call to [initialize]. diff --git a/pkgs/dart_mcp/lib/src/shared.dart b/pkgs/dart_mcp/lib/src/shared.dart index 145889e6..fa6a5b52 100644 --- a/pkgs/dart_mcp/lib/src/shared.dart +++ b/pkgs/dart_mcp/lib/src/shared.dart @@ -39,6 +39,15 @@ base class MCPBase { /// Whether the connection with the peer is active. bool get isActive => !_peer.isClosed; + /// Completes after [shutdown] is called. + Future get done => _done.future; + final _done = Completer(); + + /// Initializes an MCP connection on [channel]. + /// + /// If [protocolLogSink] is provided, all incoming and outgoing messages will + /// added logged to it. It is the responsibility of the caller to close the + /// sink. MCPBase(StreamChannel channel, {Sink? protocolLogSink}) { _peer = Peer(_maybeForwardMessages(channel, protocolLogSink)); registerNotificationHandler( @@ -48,7 +57,7 @@ base class MCPBase { registerRequestHandler(PingRequest.methodName, _handlePing); - _peer.listen(); + _peer.listen().whenComplete(shutdown); } /// Handles cleanup of all streams and other resources on shutdown. @@ -60,6 +69,7 @@ base class MCPBase { await Future.wait([ for (var controller in progressControllers) controller.close(), ]); + if (!_done.isCompleted) _done.complete(); } /// Registers a handler for the method [name] on this server. diff --git a/pkgs/dart_mcp/pubspec.yaml b/pkgs/dart_mcp/pubspec.yaml index 2c5df2d5..b5c1371e 100644 --- a/pkgs/dart_mcp/pubspec.yaml +++ b/pkgs/dart_mcp/pubspec.yaml @@ -1,5 +1,5 @@ name: dart_mcp -version: 0.2.1 +version: 0.2.2-wip description: A package for making MCP servers and clients. repository: https://github.com/dart-lang/ai/tree/main/pkgs/dart_mcp issue_tracker: https://github.com/dart-lang/ai/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Adart_mcp diff --git a/pkgs/dart_mcp_server/CHANGELOG.md b/pkgs/dart_mcp_server/CHANGELOG.md index 55fe7827..83c27103 100644 --- a/pkgs/dart_mcp_server/CHANGELOG.md +++ b/pkgs/dart_mcp_server/CHANGELOG.md @@ -39,3 +39,4 @@ * Add the beginnings of a Dart tooling MCP server. * Instruct clients to prefer MCP tools over running tools in the shell. * Reduce output size of `run_tests` tool to save on input tokens. +* Add `--log-file` argument to log all protocol traffic to a file. diff --git a/pkgs/dart_mcp_server/bin/main.dart b/pkgs/dart_mcp_server/bin/main.dart index 351dc72b..fee45824 100644 --- a/pkgs/dart_mcp_server/bin/main.dart +++ b/pkgs/dart_mcp_server/bin/main.dart @@ -25,6 +25,9 @@ void main(List args) async { final flutterSdkPath = parsedArgs.option(flutterSdkOption) ?? io.Platform.environment['FLUTTER_SDK']; + final logFilePath = parsedArgs.option(logFileOption); + final logFileSink = + logFilePath == null ? null : createLogSink(io.File(logFilePath)); runZonedGuarded( () { server = DartMCPServer( @@ -40,7 +43,8 @@ void main(List args) async { ), forceRootsFallback: parsedArgs.flag(forceRootsFallback), sdk: Sdk.find(dartSdkPath: dartSdkPath, flutterSdkPath: flutterSdkPath), - ); + protocolLogSink: logFileSink, + )..done.whenComplete(() => logFileSink?.close()); }, (e, s) { if (server != null) { @@ -94,9 +98,37 @@ final argParser = 'cursor which claim to have roots support but do not actually ' 'support it.', ) + ..addOption( + logFileOption, + help: + 'Path to a file to log all MPC protocol traffic to. File will be ' + 'overwritten if it exists.', + ) ..addFlag(help, abbr: 'h', help: 'Show usage text'); const dartSdkOption = 'dart-sdk'; const flutterSdkOption = 'flutter-sdk'; const forceRootsFallback = 'force-roots-fallback'; const help = 'help'; +const logFileOption = 'log-file'; + +/// Creates a `Sink` for [logFile]. +Sink createLogSink(io.File logFile) { + logFile.createSync(recursive: true); + final fileByteSink = logFile.openWrite( + mode: io.FileMode.write, + encoding: utf8, + ); + return fileByteSink.transform( + StreamSinkTransformer.fromHandlers( + handleData: (data, innerSink) { + innerSink.add(utf8.encode(data)); + // It's a log, so we want to make sure it's always up-to-date. + fileByteSink.flush(); + }, + handleDone: (innerSink) { + innerSink.close(); + }, + ), + ); +} diff --git a/pkgs/dart_mcp_server/lib/src/server.dart b/pkgs/dart_mcp_server/lib/src/server.dart index 52afa5f6..b937dce9 100644 --- a/pkgs/dart_mcp_server/lib/src/server.dart +++ b/pkgs/dart_mcp_server/lib/src/server.dart @@ -38,6 +38,7 @@ final class DartMCPServer extends MCPServer @visibleForTesting this.processManager = const LocalProcessManager(), @visibleForTesting this.fileSystem = const LocalFileSystem(), this.forceRootsFallback = false, + super.protocolLogSink, }) : super.fromStreamChannel( implementation: ServerImplementation( name: 'dart and flutter tooling', 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 caa0443d..8d8a4c2c 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 @@ -1,7 +1,38 @@ // 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:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +import 'test_harness.dart'; + void main() { - // TODO: write tests for any Dart Tooling MCP Server functionality that is - // not covered by individual feature tests. + 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 { + 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(); + }); + }); } diff --git a/pkgs/dart_mcp_server/test/test_harness.dart b/pkgs/dart_mcp_server/test/test_harness.dart index 6e9f5772..2b30dc02 100644 --- a/pkgs/dart_mcp_server/test/test_harness.dart +++ b/pkgs/dart_mcp_server/test/test_harness.dart @@ -64,9 +64,14 @@ class TestHarness { /// MCP server is ran in process. /// /// Use [startDebugSession] to start up apps and connect to them. + /// + /// If [cliArgs] are passed, they will be given to the MCP server. This is + /// only supported when [inProcess] is `false`, which is enforced via + /// assertions. static Future start({ bool inProcess = false, FileSystem? fileSystem, + List cliArgs = const [], }) async { final sdk = Sdk.find( dartSdkPath: Platform.environment['DART_SDK'], @@ -82,6 +87,7 @@ class TestHarness { inProcess, fileSystem, sdk, + cliArgs, ); final connection = serverConnectionPair.serverConnection; connection.onLog.listen((log) { @@ -388,10 +394,13 @@ Future _initializeMCPServer( bool inProcess, FileSystem fileSystem, Sdk sdk, + List cliArgs, ) async { ServerConnection connection; DartMCPServer? server; if (inProcess) { + assert(cliArgs.isEmpty); + /// The client side of the communication channel - the stream is the /// incoming data and the sink is outgoing data. final clientController = StreamController(); @@ -421,6 +430,7 @@ Future _initializeMCPServer( 'pub', // Using `pub` gives us incremental compilation 'run', 'bin/main.dart', + ...cliArgs, ]); }