update MCPClient.connectStdioServer to operate on streams and sinks (#204)
Updated MCPClient.connectStdioServer to take a stdin and stdout stream instead of a binary name and arguments. It no longer spawns processes on its own. This removes the `dart:io` dependency and allows more flexibility for how processes are spawned (can use package:process or even no process at all).
The migration is pretty easy, just launch your own process and pass in stdin/stdout from that.
- Closes https://github.com/dart-lang/ai/issues/195
- Enables the client to run on the web
- Run all tests on the web, with wasm and dart2js, as well as AOT and kernel (these tests are all very fast)
diff --git a/.github/workflows/dart_mcp.yaml b/.github/workflows/dart_mcp.yaml
index b8fc372..a92d08f 100644
--- a/.github/workflows/dart_mcp.yaml
+++ b/.github/workflows/dart_mcp.yaml
@@ -44,4 +44,4 @@
- run: dart format --output=none --set-exit-if-changed .
if: ${{ matrix.sdk == 'dev' }}
- - run: dart test
+ - run: dart test -p chrome,vm -c dart2wasm,dart2js,kernel,exe
diff --git a/mcp_examples/bin/workflow_client.dart b/mcp_examples/bin/workflow_client.dart
index 08cf973..95af431 100644
--- a/mcp_examples/bin/workflow_client.dart
+++ b/mcp_examples/bin/workflow_client.dart
@@ -409,11 +409,16 @@
for (var server in serverCommands) {
final parts = server.split(' ');
try {
+ final process = await Process.start(
+ parts.first,
+ parts.skip(1).toList(),
+ );
serverConnections.add(
- await connectStdioServer(
- parts.first,
- parts.skip(1).toList(),
+ connectStdioServer(
+ process.stdin,
+ process.stdout,
protocolLogSink: logSink,
+ onDone: process.kill,
),
);
} catch (e) {
diff --git a/pkgs/dart_mcp/CHANGELOG.md b/pkgs/dart_mcp/CHANGELOG.md
index 008ac99..28118c8 100644
--- a/pkgs/dart_mcp/CHANGELOG.md
+++ b/pkgs/dart_mcp/CHANGELOG.md
@@ -10,13 +10,16 @@
`propertyNamesInvalid`, `propertyValueInvalid`, `itemInvalid` and
`prefixItemInvalid`.
- Added a `custom` validation error type.
-- Auto-validate schemas for all tools by default. This can be disabled by
- passing `validateArguments: false` to `registerTool`.
- - This is breaking since this method is overridden by the Dart MCP server.
+- **Breaking**: Auto-validate schemas for all tools by default. This can be
+ disabled by passing `validateArguments: false` to `registerTool`.
- Updates to the latest MCP spec, [2025-06-08](https://modelcontextprotocol.io/specification/2025-06-18/changelog)
- Adds support for Elicitations to allow the server to ask the user questions.
- Adds `ResourceLink` as a tool return content type.
- Adds support for structured tool output.
+- **Breaking**: Change `MCPClient.connectStdioServer` signature to accept stdin
+ and stdout streams instead of starting processes itself. This enables custom
+ process spawning (such as using package:process), and also enables the client
+ to run in browser environments.
## 0.2.2
diff --git a/pkgs/dart_mcp/dart_test.yaml b/pkgs/dart_mcp/dart_test.yaml
new file mode 100644
index 0000000..6335bdd
--- /dev/null
+++ b/pkgs/dart_mcp/dart_test.yaml
@@ -0,0 +1,4 @@
+override_platforms:
+ chrome:
+ settings:
+ headless: true
diff --git a/pkgs/dart_mcp/example/simple_client.dart b/pkgs/dart_mcp/example/simple_client.dart
index bec522a..4a86d0f 100644
--- a/pkgs/dart_mcp/example/simple_client.dart
+++ b/pkgs/dart_mcp/example/simple_client.dart
@@ -2,6 +2,8 @@
// 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/client.dart';
void main() async {
@@ -9,10 +11,16 @@
Implementation(name: 'example dart client', version: '0.1.0'),
);
print('connecting to server');
- final server = await client.connectStdioServer('dart', [
+
+ final process = await Process.start('dart', [
'run',
'example/simple_server.dart',
]);
+ final server = client.connectStdioServer(
+ process.stdin,
+ process.stdout,
+ onDone: process.kill,
+ );
print('server started');
print('initializing server');
diff --git a/pkgs/dart_mcp/lib/src/client/client.dart b/pkgs/dart_mcp/lib/src/client/client.dart
index 1b1a919..b70b7b6 100644
--- a/pkgs/dart_mcp/lib/src/client/client.dart
+++ b/pkgs/dart_mcp/lib/src/client/client.dart
@@ -5,8 +5,6 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
-// TODO: Refactor to drop this dependency?
-import 'dart:io';
import 'package:async/async.dart' hide Result;
import 'package:meta/meta.dart';
@@ -50,29 +48,21 @@
@visibleForTesting
final Set<ServerConnection> connections = {};
- /// Connect to a new MCP server by invoking [command] with [arguments] and
- /// talking to that process over stdin/stdout.
+ /// Connect to a new MCP server over [stdin] and [stdout].
///
/// 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. It is the
/// responsibility of the caller to close this sink.
- Future<ServerConnection> connectStdioServer(
- String command,
- List<String> arguments, {
+ ///
+ /// If [onDone] is passed, it will be invoked when the connection shuts down.
+ ServerConnection connectStdioServer(
+ StreamSink<List<int>> stdin,
+ Stream<List<int>> stdout, {
Sink<String>? protocolLogSink,
- }) async {
- final process = await Process.start(command, arguments);
- process.stderr
- .transform(utf8.decoder)
- .transform(const LineSplitter())
- .listen((line) {
- stderr.writeln('[StdErr from server $command]: $line');
- });
- final channel = StreamChannel.withCloseGuarantee(
- process.stdout,
- process.stdin,
- )
+ void Function()? onDone,
+ }) {
+ final channel = StreamChannel.withCloseGuarantee(stdout, stdin)
.transform(StreamChannelTransformer.fromCodec(utf8))
.transformStream(const LineSplitter())
.transformSink(
@@ -83,13 +73,16 @@
),
);
final connection = connectServer(channel, protocolLogSink: protocolLogSink);
- unawaited(connection.done.then((_) => process.kill()));
+ if (onDone != null) connection.done.then((_) => onDone());
return connection;
}
/// Returns a connection for an MCP server using a [channel], which is already
/// established.
///
+ /// Each [String] sent over [channel] represents an entire JSON request or
+ /// response.
+ ///
/// 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. It is the responsibility of the
diff --git a/pkgs/dart_mcp/test/server/resources_support_test.dart b/pkgs/dart_mcp/test/server/resources_support_test.dart
index e8cd660..536d88c 100644
--- a/pkgs/dart_mcp/test/server/resources_support_test.dart
+++ b/pkgs/dart_mcp/test/server/resources_support_test.dart
@@ -3,8 +3,6 @@
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
-import 'dart:io';
-import 'dart:isolate';
import 'package:async/async.dart';
import 'package:dart_mcp/server.dart';
@@ -204,7 +202,10 @@
test('Resource templates can be listed and queried', () async {
final environment = TestEnvironment(
TestMCPClient(),
- TestMCPServerWithResources.new,
+ (channel) => TestMCPServerWithResources(
+ channel,
+ fileContents: {'package:foo/foo.dart': 'hello world!'},
+ ),
);
await environment.initializeServer();
@@ -220,24 +221,24 @@
);
final readResourceResponse = await serverConnection.readResource(
- ReadResourceRequest(uri: 'package:test/test.dart'),
+ ReadResourceRequest(uri: 'package:foo/foo.dart'),
);
expect(
(readResourceResponse.contents.single as TextResourceContents).text,
- await File.fromUri(
- (await Isolate.resolvePackageUri(Uri.parse('package:test/test.dart')))!,
- ).readAsString(),
+ 'hello world!',
);
});
}
final class TestMCPServerWithResources extends TestMCPServer
with ResourcesSupport {
+ final Map<String, String> fileContents;
+
@override
/// Shorten this delay for the test so they run quickly.
Duration get resourceUpdateThrottleDelay => Duration.zero;
- TestMCPServerWithResources(super.channel);
+ TestMCPServerWithResources(super.channel, {this.fileContents = const {}});
@override
FutureOr<InitializeResult> initialize(InitializeRequest request) {
@@ -260,14 +261,12 @@
if (!request.uri.endsWith('.dart')) {
throw UnsupportedError('Only dart files can be read');
}
- final resolvedUri =
- (await Isolate.resolvePackageUri(Uri.parse(request.uri)))!;
return ReadResourceResult(
contents: [
TextResourceContents(
uri: request.uri,
- text: await File.fromUri(resolvedUri).readAsString(),
+ text: fileContents[request.uri]!,
),
],
);
diff --git a/pkgs/dart_mcp_server/test/test_harness.dart b/pkgs/dart_mcp_server/test/test_harness.dart
index 3e5abdf..1f36e54 100644
--- a/pkgs/dart_mcp_server/test/test_harness.dart
+++ b/pkgs/dart_mcp_server/test/test_harness.dart
@@ -449,12 +449,18 @@
addTearDown(server.shutdown);
connection = client.connectServer(clientChannel);
} else {
- connection = await client.connectStdioServer(sdk.dartExecutablePath, [
+ final process = await Process.start(sdk.dartExecutablePath, [
'pub', // Using `pub` gives us incremental compilation
'run',
'bin/main.dart',
...cliArgs,
]);
+ addTearDown(process.kill);
+ connection = client.connectStdioServer(
+ process.stdin,
+ process.stdout,
+ onDone: process.kill,
+ );
}
final initializeResult = await connection.initialize(