Skip to content

Commit 6b84c96

Browse files
authored
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 #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)
1 parent 04eee07 commit 6b84c96

File tree

8 files changed

+58
-40
lines changed

8 files changed

+58
-40
lines changed

.github/workflows/dart_mcp.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,4 @@ jobs:
4444
- run: dart format --output=none --set-exit-if-changed .
4545
if: ${{ matrix.sdk == 'dev' }}
4646

47-
- run: dart test
47+
- run: dart test -p chrome,vm -c dart2wasm,dart2js,kernel,exe

mcp_examples/bin/workflow_client.dart

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,11 +409,16 @@ final class WorkflowClient extends MCPClient with RootsSupport {
409409
for (var server in serverCommands) {
410410
final parts = server.split(' ');
411411
try {
412+
final process = await Process.start(
413+
parts.first,
414+
parts.skip(1).toList(),
415+
);
412416
serverConnections.add(
413-
await connectStdioServer(
414-
parts.first,
415-
parts.skip(1).toList(),
417+
connectStdioServer(
418+
process.stdin,
419+
process.stdout,
416420
protocolLogSink: logSink,
421+
onDone: process.kill,
417422
),
418423
);
419424
} catch (e) {

pkgs/dart_mcp/CHANGELOG.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@
1010
`propertyNamesInvalid`, `propertyValueInvalid`, `itemInvalid` and
1111
`prefixItemInvalid`.
1212
- Added a `custom` validation error type.
13-
- Auto-validate schemas for all tools by default. This can be disabled by
14-
passing `validateArguments: false` to `registerTool`.
15-
- This is breaking since this method is overridden by the Dart MCP server.
13+
- **Breaking**: Auto-validate schemas for all tools by default. This can be
14+
disabled by passing `validateArguments: false` to `registerTool`.
1615
- Updates to the latest MCP spec, [2025-06-08](https://modelcontextprotocol.io/specification/2025-06-18/changelog)
1716
- Adds support for Elicitations to allow the server to ask the user questions.
1817
- Adds `ResourceLink` as a tool return content type.
1918
- Adds support for structured tool output.
19+
- **Breaking**: Change `MCPClient.connectStdioServer` signature to accept stdin
20+
and stdout streams instead of starting processes itself. This enables custom
21+
process spawning (such as using package:process), and also enables the client
22+
to run in browser environments.
2023

2124
## 0.2.2
2225

pkgs/dart_mcp/dart_test.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
override_platforms:
2+
chrome:
3+
settings:
4+
headless: true

pkgs/dart_mcp/example/simple_client.dart

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,25 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5+
import 'dart:io';
6+
57
import 'package:dart_mcp/client.dart';
68

79
void main() async {
810
final client = MCPClient(
911
Implementation(name: 'example dart client', version: '0.1.0'),
1012
);
1113
print('connecting to server');
12-
final server = await client.connectStdioServer('dart', [
14+
15+
final process = await Process.start('dart', [
1316
'run',
1417
'example/simple_server.dart',
1518
]);
19+
final server = client.connectStdioServer(
20+
process.stdin,
21+
process.stdout,
22+
onDone: process.kill,
23+
);
1624
print('server started');
1725

1826
print('initializing server');

pkgs/dart_mcp/lib/src/client/client.dart

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
import 'dart:async';
66
import 'dart:collection';
77
import 'dart:convert';
8-
// TODO: Refactor to drop this dependency?
9-
import 'dart:io';
108

119
import 'package:async/async.dart' hide Result;
1210
import 'package:meta/meta.dart';
@@ -50,29 +48,21 @@ base class MCPClient {
5048
@visibleForTesting
5149
final Set<ServerConnection> connections = {};
5250

53-
/// Connect to a new MCP server by invoking [command] with [arguments] and
54-
/// talking to that process over stdin/stdout.
51+
/// Connect to a new MCP server over [stdin] and [stdout].
5552
///
5653
/// If [protocolLogSink] is provided, all messages sent between the client and
5754
/// server will be forwarded to that [Sink] as well, with `<<<` preceding
5855
/// incoming messages and `>>>` preceding outgoing messages. It is the
5956
/// responsibility of the caller to close this sink.
60-
Future<ServerConnection> connectStdioServer(
61-
String command,
62-
List<String> arguments, {
57+
///
58+
/// If [onDone] is passed, it will be invoked when the connection shuts down.
59+
ServerConnection connectStdioServer(
60+
StreamSink<List<int>> stdin,
61+
Stream<List<int>> stdout, {
6362
Sink<String>? protocolLogSink,
64-
}) async {
65-
final process = await Process.start(command, arguments);
66-
process.stderr
67-
.transform(utf8.decoder)
68-
.transform(const LineSplitter())
69-
.listen((line) {
70-
stderr.writeln('[StdErr from server $command]: $line');
71-
});
72-
final channel = StreamChannel.withCloseGuarantee(
73-
process.stdout,
74-
process.stdin,
75-
)
63+
void Function()? onDone,
64+
}) {
65+
final channel = StreamChannel.withCloseGuarantee(stdout, stdin)
7666
.transform(StreamChannelTransformer.fromCodec(utf8))
7767
.transformStream(const LineSplitter())
7868
.transformSink(
@@ -83,13 +73,16 @@ base class MCPClient {
8373
),
8474
);
8575
final connection = connectServer(channel, protocolLogSink: protocolLogSink);
86-
unawaited(connection.done.then((_) => process.kill()));
76+
if (onDone != null) connection.done.then((_) => onDone());
8777
return connection;
8878
}
8979

9080
/// Returns a connection for an MCP server using a [channel], which is already
9181
/// established.
9282
///
83+
/// Each [String] sent over [channel] represents an entire JSON request or
84+
/// response.
85+
///
9386
/// If [protocolLogSink] is provided, all messages sent on [channel] will be
9487
/// forwarded to that [Sink] as well, with `<<<` preceding incoming messages
9588
/// and `>>>` preceding outgoing messages. It is the responsibility of the

pkgs/dart_mcp/test/server/resources_support_test.dart

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
// BSD-style license that can be found in the LICENSE file.
44

55
import 'dart:async';
6-
import 'dart:io';
7-
import 'dart:isolate';
86

97
import 'package:async/async.dart';
108
import 'package:dart_mcp/server.dart';
@@ -204,7 +202,10 @@ void main() {
204202
test('Resource templates can be listed and queried', () async {
205203
final environment = TestEnvironment(
206204
TestMCPClient(),
207-
TestMCPServerWithResources.new,
205+
(channel) => TestMCPServerWithResources(
206+
channel,
207+
fileContents: {'package:foo/foo.dart': 'hello world!'},
208+
),
208209
);
209210
await environment.initializeServer();
210211

@@ -220,24 +221,24 @@ void main() {
220221
);
221222

222223
final readResourceResponse = await serverConnection.readResource(
223-
ReadResourceRequest(uri: 'package:test/test.dart'),
224+
ReadResourceRequest(uri: 'package:foo/foo.dart'),
224225
);
225226
expect(
226227
(readResourceResponse.contents.single as TextResourceContents).text,
227-
await File.fromUri(
228-
(await Isolate.resolvePackageUri(Uri.parse('package:test/test.dart')))!,
229-
).readAsString(),
228+
'hello world!',
230229
);
231230
});
232231
}
233232

234233
final class TestMCPServerWithResources extends TestMCPServer
235234
with ResourcesSupport {
235+
final Map<String, String> fileContents;
236+
236237
@override
237238
/// Shorten this delay for the test so they run quickly.
238239
Duration get resourceUpdateThrottleDelay => Duration.zero;
239240

240-
TestMCPServerWithResources(super.channel);
241+
TestMCPServerWithResources(super.channel, {this.fileContents = const {}});
241242

242243
@override
243244
FutureOr<InitializeResult> initialize(InitializeRequest request) {
@@ -260,14 +261,12 @@ final class TestMCPServerWithResources extends TestMCPServer
260261
if (!request.uri.endsWith('.dart')) {
261262
throw UnsupportedError('Only dart files can be read');
262263
}
263-
final resolvedUri =
264-
(await Isolate.resolvePackageUri(Uri.parse(request.uri)))!;
265264

266265
return ReadResourceResult(
267266
contents: [
268267
TextResourceContents(
269268
uri: request.uri,
270-
text: await File.fromUri(resolvedUri).readAsString(),
269+
text: fileContents[request.uri]!,
271270
),
272271
],
273272
);

pkgs/dart_mcp_server/test/test_harness.dart

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,12 +449,18 @@ Future<ServerConnectionPair> _initializeMCPServer(
449449
addTearDown(server.shutdown);
450450
connection = client.connectServer(clientChannel);
451451
} else {
452-
connection = await client.connectStdioServer(sdk.dartExecutablePath, [
452+
final process = await Process.start(sdk.dartExecutablePath, [
453453
'pub', // Using `pub` gives us incremental compilation
454454
'run',
455455
'bin/main.dart',
456456
...cliArgs,
457457
]);
458+
addTearDown(process.kill);
459+
connection = client.connectStdioServer(
460+
process.stdin,
461+
process.stdout,
462+
onDone: process.kill,
463+
);
458464
}
459465

460466
final initializeResult = await connection.initialize(

0 commit comments

Comments
 (0)