@effected/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/McpHarness.js +234 -0
- package/McpProbe.js +81 -0
- package/McpProcess.js +135 -0
- package/McpStdio.js +143 -0
- package/McpTestFailure.js +43 -0
- package/McpToolAudit.js +105 -0
- package/McpToolkit.js +79 -0
- package/README.md +406 -0
- package/ToolFailure.js +63 -0
- package/ToolInputSchema.js +167 -0
- package/index.d.ts +352 -0
- package/index.js +6 -0
- package/internal/LaunchFailed.js +23 -0
- package/internal/StdinFrames.js +201 -0
- package/internal/wire.js +66 -0
- package/package.json +51 -0
- package/testing.d.ts +365 -0
- package/testing.js +7 -0
- package/tsdoc-metadata.json +11 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/McpHarness.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { ToolFailure } from "./ToolFailure.js";
|
|
2
|
+
import { DEFAULT_CLIENT_INFO, frame, initializeParams, isJsonRpcMessage, isResponse, isStateless, parseFrame, requestKey } from "./internal/wire.js";
|
|
3
|
+
import { McpTestFailure } from "./McpTestFailure.js";
|
|
4
|
+
import { Console, Deferred, Effect, Exit, Layer, Queue, Sink, Stdio, Stream } from "effect";
|
|
5
|
+
import { McpProtocol } from "effect/unstable/ai";
|
|
6
|
+
|
|
7
|
+
//#region src/McpHarness.ts
|
|
8
|
+
/**
|
|
9
|
+
* An in-process MCP client for a real server layer, over queue-backed stdio.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* Builds your server with a queue-backed `Stdio.layerTest` that satisfies the
|
|
13
|
+
* server's `Stdio` requirement, so tests see the exact served schemas and wire
|
|
14
|
+
* results a real client would, with no child and no sockets. Pass the server
|
|
15
|
+
* WITHOUT a `Stdio` of its own: a `Stdio` the server provides internally wins,
|
|
16
|
+
* and talks to the real terminal. A bundled platform layer such as
|
|
17
|
+
* `NodeServices.layer` (`@effect/platform-node`) brings its own `Stdio` along
|
|
18
|
+
* with `FileSystem`, `Path`, `ChildProcessSpawner`, `Crypto` and `Terminal` —
|
|
19
|
+
* providing it to the server loses `Stdio` the same way, and every wait
|
|
20
|
+
* hangs to timeout with no error naming why. Compose the individual layers
|
|
21
|
+
* the server needs instead and leave `Stdio` out; this harness supplies it.
|
|
22
|
+
*
|
|
23
|
+
* The server is built with a fresh layer memo map, never the ambient one, so
|
|
24
|
+
* a harness made under another stdio server's `Effect.provide` still serves
|
|
25
|
+
* its own. Never pass a server layer that provides a layer the test also
|
|
26
|
+
* provides and then reads — `McpHarness.make(server.pipe(Layer.provide(AppLayer)))`
|
|
27
|
+
* under `Effect.provide(AppLayer)` builds `AppLayer` twice, so the tools write
|
|
28
|
+
* to one instance and the test reads the other. Leave the service in the
|
|
29
|
+
* server layer's requirements and provide it once from the test, or build it
|
|
30
|
+
* once and pass `Layer.succeed(Tag, value)`.
|
|
31
|
+
*
|
|
32
|
+
* - Responses are matched by id, so notifications may interleave freely.
|
|
33
|
+
* - No wait can hang: every response wait and `awaitOutboundMethod` fails
|
|
34
|
+
* with `ServerStopped` when the server stops (stdin closing does that),
|
|
35
|
+
* and dies when `strictStdout` sees a line that is not JSON-RPC.
|
|
36
|
+
* - With `captureLogs`, the server's console is captured: `stderrSoFar`
|
|
37
|
+
* holds stderr writes and every log line, and `consoleLogSoFar` holds
|
|
38
|
+
* anything that went through `console.log`, which in a real server is
|
|
39
|
+
* the wire. Assert it empty.
|
|
40
|
+
* - On a stateful revision (the default `2025-11-25`), send `initialize`
|
|
41
|
+
* first. Every other request before it fails with `NotInitialized` and
|
|
42
|
+
* is never written: the server would only answer an opaque
|
|
43
|
+
* `Invalid request metadata`, `ping` included.
|
|
44
|
+
*
|
|
45
|
+
* @public
|
|
46
|
+
*/
|
|
47
|
+
var McpHarness = class McpHarness {
|
|
48
|
+
/** The revision this client speaks. */
|
|
49
|
+
protocol;
|
|
50
|
+
/** `initialize` then `notifications/initialized`; `server/discover` on a stateless revision. Call it first on a stateful revision. */
|
|
51
|
+
initialize;
|
|
52
|
+
/** `server/discover`. */
|
|
53
|
+
discover;
|
|
54
|
+
/** Send a request and wait for its response. */
|
|
55
|
+
request;
|
|
56
|
+
/** Send a request now; wait for its response later. Fails with `NotInitialized`, sending nothing, on a stateful revision before `initialize`. */
|
|
57
|
+
startRequest;
|
|
58
|
+
/** Send a notification. */
|
|
59
|
+
notify;
|
|
60
|
+
/** `tools/call`, returning the whole response: a JSON-RPC error is data here. */
|
|
61
|
+
callTool;
|
|
62
|
+
/** `tools/list`'s tools; a JSON-RPC error fails with `ErrorResponse`. */
|
|
63
|
+
listTools;
|
|
64
|
+
/** `resources/list`'s resources; a JSON-RPC error fails with `ErrorResponse`. */
|
|
65
|
+
listResources;
|
|
66
|
+
/** `resources/read`, returning the whole response. */
|
|
67
|
+
readResource;
|
|
68
|
+
/** Write any value as one newline-framed line. Never gated; a raw `initialize` counts as sent. */
|
|
69
|
+
sendRaw;
|
|
70
|
+
/** The next server-initiated frame with this method, skipping and keeping others; fails with `ServerStopped` once the server stops. */
|
|
71
|
+
awaitOutboundMethod;
|
|
72
|
+
/** Everything written to stderr, plus every captured log line. */
|
|
73
|
+
stderrSoFar;
|
|
74
|
+
/** Every captured `console.log`, `info` or `debug` call. */
|
|
75
|
+
consoleLogSoFar;
|
|
76
|
+
/** End stdin, as a client disconnecting does. */
|
|
77
|
+
close;
|
|
78
|
+
constructor(parts) {
|
|
79
|
+
this.protocol = parts.protocol;
|
|
80
|
+
this.initialize = parts.initialize;
|
|
81
|
+
this.discover = parts.discover;
|
|
82
|
+
this.request = parts.request;
|
|
83
|
+
this.startRequest = parts.startRequest;
|
|
84
|
+
this.notify = parts.notify;
|
|
85
|
+
this.callTool = parts.callTool;
|
|
86
|
+
this.listTools = parts.listTools;
|
|
87
|
+
this.listResources = parts.listResources;
|
|
88
|
+
this.readResource = parts.readResource;
|
|
89
|
+
this.sendRaw = parts.sendRaw;
|
|
90
|
+
this.awaitOutboundMethod = parts.awaitOutboundMethod;
|
|
91
|
+
this.stderrSoFar = parts.stderrSoFar;
|
|
92
|
+
this.consoleLogSoFar = parts.consoleLogSoFar;
|
|
93
|
+
this.close = parts.close;
|
|
94
|
+
}
|
|
95
|
+
/** Build `server` over queue-backed stdio and return a client for it; fails with the layer's own error if it cannot build. */
|
|
96
|
+
static make = (server, options = {}) => Effect.gen(function* () {
|
|
97
|
+
const protocol = options.protocol ?? McpProtocol.v2025_11_25;
|
|
98
|
+
const clientInfo = options.clientInfo ?? DEFAULT_CLIENT_INFO;
|
|
99
|
+
const strictStdout = options.strictStdout ?? true;
|
|
100
|
+
const stdin = yield* Queue.unbounded();
|
|
101
|
+
const stdout = yield* Queue.unbounded();
|
|
102
|
+
const inbound = yield* Queue.unbounded();
|
|
103
|
+
const retained = [];
|
|
104
|
+
const waiters = /* @__PURE__ */ new Map();
|
|
105
|
+
const stderr = [];
|
|
106
|
+
const consoleLog = [];
|
|
107
|
+
const ready = yield* Deferred.make();
|
|
108
|
+
const stopped = yield* Deferred.make();
|
|
109
|
+
const corrupt = yield* Deferred.make();
|
|
110
|
+
const encoder = new TextEncoder();
|
|
111
|
+
const stdoutDecoder = new TextDecoder();
|
|
112
|
+
const stderrDecoder = new TextDecoder();
|
|
113
|
+
let nextId = 1;
|
|
114
|
+
const stdio = Stdio.layerTest({
|
|
115
|
+
stdin: Stream.fromQueue(stdin),
|
|
116
|
+
stdout: () => Sink.forEach((chunk) => Queue.offer(stdout, chunk)),
|
|
117
|
+
stderr: () => Sink.forEach((chunk) => Effect.sync(() => {
|
|
118
|
+
stderr.push(typeof chunk === "string" ? chunk : stderrDecoder.decode(chunk, { stream: true }));
|
|
119
|
+
}))
|
|
120
|
+
});
|
|
121
|
+
const ambient = yield* Console.Console;
|
|
122
|
+
const show = (args) => args.map((arg) => typeof arg === "string" ? arg : String(arg)).join(" ");
|
|
123
|
+
const toStdout = (...args) => {
|
|
124
|
+
consoleLog.push(show(args));
|
|
125
|
+
};
|
|
126
|
+
const toStderr = (...args) => {
|
|
127
|
+
stderr.push(`${show(args)}\n`);
|
|
128
|
+
};
|
|
129
|
+
const captured = {
|
|
130
|
+
...ambient,
|
|
131
|
+
log: toStdout,
|
|
132
|
+
info: toStdout,
|
|
133
|
+
debug: toStdout,
|
|
134
|
+
error: toStderr,
|
|
135
|
+
warn: toStderr
|
|
136
|
+
};
|
|
137
|
+
const provided = options.captureLogs === false ? server.pipe(Layer.provide(stdio)) : server.pipe(Layer.provide(stdio), Layer.provide(Layer.succeed(Console.Console, captured)));
|
|
138
|
+
const route = (line) => {
|
|
139
|
+
const parsed = parseFrame(line);
|
|
140
|
+
if (!isJsonRpcMessage(parsed)) return strictStdout ? Effect.asVoid(Deferred.die(corrupt, /* @__PURE__ */ new Error(`stdout carried a line that is not JSON-RPC: ${ToolFailure.truncate(line)}`))) : Effect.void;
|
|
141
|
+
if (isResponse(parsed)) {
|
|
142
|
+
const key = requestKey(parsed.id);
|
|
143
|
+
const waiter = waiters.get(key);
|
|
144
|
+
if (waiter !== void 0) {
|
|
145
|
+
waiters.delete(key);
|
|
146
|
+
return Effect.asVoid(Deferred.succeed(waiter, parsed));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return Effect.asVoid(Queue.offer(inbound, parsed));
|
|
150
|
+
};
|
|
151
|
+
yield* Stream.fromQueue(stdout).pipe(Stream.map((chunk) => typeof chunk === "string" ? chunk : stdoutDecoder.decode(chunk, { stream: true })), Stream.splitLines, Stream.runForEach((line) => line.length === 0 ? Effect.void : route(line)), Effect.forkScoped);
|
|
152
|
+
yield* Effect.flatMap(Effect.scope, (scope) => Layer.buildWithMemoMap(provided, Layer.makeMemoMapUnsafe(), scope)).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.andThen(Effect.never), Effect.scoped, Effect.onExit((exit) => Effect.andThen(Exit.isFailure(exit) ? Deferred.failCause(ready, exit.cause) : Effect.void, Deferred.fail(stopped, new McpTestFailure({
|
|
153
|
+
reason: "ServerStopped",
|
|
154
|
+
message: "the server stopped before it responded"
|
|
155
|
+
})))), Effect.forkScoped);
|
|
156
|
+
yield* Deferred.await(ready);
|
|
157
|
+
const stopAware = (wait) => Effect.raceAllFirst([
|
|
158
|
+
wait,
|
|
159
|
+
Deferred.await(stopped),
|
|
160
|
+
Deferred.await(corrupt)
|
|
161
|
+
]);
|
|
162
|
+
const awaitResponse = (waiter) => Effect.suspend(() => Deferred.isDoneUnsafe(waiter) ? Deferred.await(waiter) : stopAware(Deferred.await(waiter)));
|
|
163
|
+
const stateful = !isStateless(protocol);
|
|
164
|
+
let initializeSent = false;
|
|
165
|
+
const sendRaw = (message) => Effect.suspend(() => {
|
|
166
|
+
if (isJsonRpcMessage(message) && message.method === "initialize") initializeSent = true;
|
|
167
|
+
return Effect.asVoid(Queue.offer(stdin, encoder.encode(`${JSON.stringify(message)}\n`)));
|
|
168
|
+
});
|
|
169
|
+
const startRequest = (method, params) => Effect.gen(function* () {
|
|
170
|
+
if (stateful && !initializeSent && method !== "initialize") return yield* new McpTestFailure({
|
|
171
|
+
reason: "NotInitialized",
|
|
172
|
+
message: `${method} was not sent: call initialize first on stateful protocol ${protocol.protocolVersion}`
|
|
173
|
+
});
|
|
174
|
+
const id = nextId++;
|
|
175
|
+
const waiter = yield* Deferred.make();
|
|
176
|
+
waiters.set(requestKey(id), waiter);
|
|
177
|
+
yield* sendRaw(frame(protocol, clientInfo, method, params, id));
|
|
178
|
+
return {
|
|
179
|
+
id,
|
|
180
|
+
response: awaitResponse(waiter)
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
const request = (method, params) => Effect.flatMap(startRequest(method, params), (started) => started.response);
|
|
184
|
+
const notify = (method, params) => sendRaw(frame(protocol, clientInfo, method, params));
|
|
185
|
+
const discover = request("server/discover", {});
|
|
186
|
+
const initialize = isStateless(protocol) ? discover : Effect.gen(function* () {
|
|
187
|
+
const response = yield* request("initialize", initializeParams(protocol, clientInfo));
|
|
188
|
+
yield* notify("notifications/initialized");
|
|
189
|
+
return response;
|
|
190
|
+
});
|
|
191
|
+
const listTools = Effect.flatMap(request("tools/list"), (response) => response.error === void 0 ? Effect.succeed(response.result.tools) : Effect.fail(new McpTestFailure({
|
|
192
|
+
reason: "ErrorResponse",
|
|
193
|
+
message: `tools/list failed: ${ToolFailure.truncate(JSON.stringify(response.error))}`
|
|
194
|
+
})));
|
|
195
|
+
const listResources = Effect.flatMap(request("resources/list"), (response) => response.error === void 0 ? Effect.succeed(response.result.resources) : Effect.fail(new McpTestFailure({
|
|
196
|
+
reason: "ErrorResponse",
|
|
197
|
+
message: `resources/list failed: ${ToolFailure.truncate(JSON.stringify(response.error))}`
|
|
198
|
+
})));
|
|
199
|
+
const awaitOutboundMethod = (method) => Effect.suspend(() => {
|
|
200
|
+
const index = retained.findIndex((message) => message.method === method);
|
|
201
|
+
if (index !== -1) return Effect.succeed(retained.splice(index, 1)[0]);
|
|
202
|
+
return stopAware(Effect.gen(function* () {
|
|
203
|
+
while (true) {
|
|
204
|
+
const message = yield* Queue.take(inbound);
|
|
205
|
+
if (message.method === method) return message;
|
|
206
|
+
retained.push(message);
|
|
207
|
+
}
|
|
208
|
+
}));
|
|
209
|
+
});
|
|
210
|
+
return new McpHarness({
|
|
211
|
+
protocol,
|
|
212
|
+
initialize,
|
|
213
|
+
discover,
|
|
214
|
+
request,
|
|
215
|
+
startRequest,
|
|
216
|
+
notify,
|
|
217
|
+
callTool: (name, args) => request("tools/call", {
|
|
218
|
+
name,
|
|
219
|
+
arguments: args ?? {}
|
|
220
|
+
}),
|
|
221
|
+
listTools,
|
|
222
|
+
listResources,
|
|
223
|
+
readResource: (uri) => request("resources/read", { uri }),
|
|
224
|
+
sendRaw,
|
|
225
|
+
awaitOutboundMethod,
|
|
226
|
+
stderrSoFar: Effect.sync(() => stderr.join("")),
|
|
227
|
+
consoleLogSoFar: Effect.sync(() => [...consoleLog]),
|
|
228
|
+
close: Effect.asVoid(Queue.end(stdin))
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
//#endregion
|
|
234
|
+
export { McpHarness };
|
package/McpProbe.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ToolFailure } from "./ToolFailure.js";
|
|
2
|
+
import { DEFAULT_CLIENT_INFO, STDERR_HINT, frame, initializeParams, isJsonRpcMessage, isResponse, isStateless, parseFrame } from "./internal/wire.js";
|
|
3
|
+
import { McpTestFailure } from "./McpTestFailure.js";
|
|
4
|
+
import { McpProcess } from "./McpProcess.js";
|
|
5
|
+
import { Effect } from "effect";
|
|
6
|
+
import { McpProtocol } from "effect/unstable/ai";
|
|
7
|
+
|
|
8
|
+
//#region src/McpProbe.ts
|
|
9
|
+
/**
|
|
10
|
+
* The smallest proof that an installed MCP bin boots: one initialize, a clean
|
|
11
|
+
* close, and exit 0.
|
|
12
|
+
*
|
|
13
|
+
* @remarks
|
|
14
|
+
* Stdin stays open until the id-1 response has arrived. Closing it right
|
|
15
|
+
* after writing, as every hand-rolled smoke test did, makes an Effect server
|
|
16
|
+
* drop the in-flight response and exit 0, so a slow boot reads as a pass with
|
|
17
|
+
* no response. The caller asserts `response.error === undefined`, `stderr` is
|
|
18
|
+
* empty and `exitCode` is 0; this is the MCP half of a packed-install proof.
|
|
19
|
+
* Checking stderr and the exit code alone passes a server that answers
|
|
20
|
+
* the handshake with a JSON-RPC error, exits 0 and writes nothing.
|
|
21
|
+
*
|
|
22
|
+
* - Any stdout line that is not JSON-RPC fails with `NotJsonRpc`, naming the
|
|
23
|
+
* line: a server that logs to stdout corrupts the wire, so the probe fails
|
|
24
|
+
* rather than skipping the line.
|
|
25
|
+
* - A child that exits before responding fails with `StreamEnded`, and the
|
|
26
|
+
* message carries its exit code and stderr.
|
|
27
|
+
* - A child that ignores stdin EOF never exits, and the probe waits for it.
|
|
28
|
+
* Wrap the probe in `Effect.timeout`.
|
|
29
|
+
*
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
var McpProbe = class {
|
|
33
|
+
constructor() {}
|
|
34
|
+
/**
|
|
35
|
+
* Spawn `command`, send one `initialize` (or `server/discover` on a
|
|
36
|
+
* stateless revision) as id 1, wait for its response, close stdin, and
|
|
37
|
+
* collect stdout, stderr and the exit code.
|
|
38
|
+
*/
|
|
39
|
+
static initialize = (command, options = {}) => Effect.scoped(Effect.gen(function* () {
|
|
40
|
+
const child = yield* McpProcess.spawn(command);
|
|
41
|
+
const protocol = options.protocol ?? McpProtocol.v2025_11_25;
|
|
42
|
+
const stateless = isStateless(protocol);
|
|
43
|
+
yield* child.send(frame(protocol, DEFAULT_CLIENT_INFO, stateless ? "server/discover" : "initialize", stateless ? {} : initializeParams(protocol, DEFAULT_CLIENT_INFO), 1));
|
|
44
|
+
const stdout = [];
|
|
45
|
+
const read = Effect.gen(function* () {
|
|
46
|
+
const line = yield* child.nextLine;
|
|
47
|
+
stdout.push(line);
|
|
48
|
+
const message = parseFrame(line);
|
|
49
|
+
if (!isJsonRpcMessage(message)) return yield* new McpTestFailure({
|
|
50
|
+
reason: "NotJsonRpc",
|
|
51
|
+
message: `stdout carried a line that is not JSON-RPC: ${ToolFailure.truncate(line)}`
|
|
52
|
+
});
|
|
53
|
+
return message;
|
|
54
|
+
});
|
|
55
|
+
const diagnose = (failure) => failure.reason !== "StreamEnded" ? Effect.fail(failure) : Effect.gen(function* () {
|
|
56
|
+
const code = yield* child.exitCode;
|
|
57
|
+
const stderr = yield* child.stderrFinal;
|
|
58
|
+
return yield* new McpTestFailure({
|
|
59
|
+
reason: "StreamEnded",
|
|
60
|
+
message: `${failure.message.replace(STDERR_HINT, "")}; the child exited with code ${code}; stderr: ${stderr === "" ? "(empty)" : ToolFailure.truncate(stderr)}`
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
let response;
|
|
64
|
+
while (response === void 0) {
|
|
65
|
+
const message = yield* Effect.catch(read, diagnose);
|
|
66
|
+
if (isResponse(message) && message.id === 1) response = message;
|
|
67
|
+
}
|
|
68
|
+
yield* child.closeStdin;
|
|
69
|
+
const exitCode = yield* child.exitCode;
|
|
70
|
+
while (true) if (!(yield* Effect.catch(Effect.map(read, () => true), (failure) => failure.reason === "StreamEnded" ? Effect.succeed(false) : Effect.fail(failure)))) break;
|
|
71
|
+
return {
|
|
72
|
+
response,
|
|
73
|
+
stdout,
|
|
74
|
+
stderr: yield* child.stderrFinal,
|
|
75
|
+
exitCode
|
|
76
|
+
};
|
|
77
|
+
}));
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
//#endregion
|
|
81
|
+
export { McpProbe };
|
package/McpProcess.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { ToolFailure } from "./ToolFailure.js";
|
|
2
|
+
import { DEFAULT_CLIENT_INFO, STDERR_HINT, frame, initializeParams, isJsonRpcMessage, isResponse, isStateless, parseFrame } from "./internal/wire.js";
|
|
3
|
+
import { McpTestFailure } from "./McpTestFailure.js";
|
|
4
|
+
import { Deferred, Effect, Queue, Ref, Stream } from "effect";
|
|
5
|
+
import { McpProtocol } from "effect/unstable/ai";
|
|
6
|
+
import { ChildProcessSpawner } from "effect/unstable/process";
|
|
7
|
+
|
|
8
|
+
//#region src/McpProcess.ts
|
|
9
|
+
/**
|
|
10
|
+
* A spawned MCP server bin a test writes to while it runs.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* Build the command in the test file with `execPath` and an explicit `env`:
|
|
14
|
+
* `ChildProcess.make(process.execPath, [bin], { env })`.
|
|
15
|
+
*
|
|
16
|
+
* - `nextLine` fails with `StreamEnded` when stdout ends, so a child that
|
|
17
|
+
* exits early fails the test instead of hanging it.
|
|
18
|
+
* - `send` writes one JSON-encoded line; `sendRaw` writes a string or bytes
|
|
19
|
+
* exactly as given, for a malformed frame or one frame split across
|
|
20
|
+
* writes, even mid-character.
|
|
21
|
+
* - `readUntilResponse` reads past interleaved notifications, such as
|
|
22
|
+
* `list_changed`, to the matching id and returns what it saw.
|
|
23
|
+
* - `closeStdin` is `Queue.end`, never `Queue.shutdown`, so every frame
|
|
24
|
+
* already sent is delivered before stdin closes.
|
|
25
|
+
* - A request in flight when stdin closes is dropped by an Effect server:
|
|
26
|
+
* read its response first.
|
|
27
|
+
*
|
|
28
|
+
* @public
|
|
29
|
+
*/
|
|
30
|
+
var McpProcess = class McpProcess {
|
|
31
|
+
/**
|
|
32
|
+
* Write one JSON-encoded, newline-framed message to the child's stdin.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* Never fails. A frame sent after `closeStdin` is dropped, and a write the
|
|
36
|
+
* child can no longer receive fails the stdin pump instead; the next
|
|
37
|
+
* `StreamEnded` message names that failure.
|
|
38
|
+
*/
|
|
39
|
+
send;
|
|
40
|
+
/**
|
|
41
|
+
* Write `text` to the child's stdin exactly as given: a string as UTF-8,
|
|
42
|
+
* bytes unchanged, with no JSON encoding and no newline added.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* For frames `send` cannot produce: a line that is not JSON, a blank line,
|
|
46
|
+
* or one frame split across several writes, including mid-character by
|
|
47
|
+
* passing bytes. Include the `\n` yourself.
|
|
48
|
+
* Never fails, on the same terms as `send`.
|
|
49
|
+
*/
|
|
50
|
+
sendRaw;
|
|
51
|
+
/** The next non-empty stdout line; fails with `StreamEnded` once stdout ends. */
|
|
52
|
+
nextLine;
|
|
53
|
+
/** Read JSON-RPC lines up to and including the response with this id. */
|
|
54
|
+
readUntilResponse;
|
|
55
|
+
/** `initialize` (id 1) then `notifications/initialized`, or `server/discover` (id 1) on a stateless revision. Use ids of 2 or more for your own requests. */
|
|
56
|
+
handshake;
|
|
57
|
+
/** End stdin after every frame already sent. */
|
|
58
|
+
closeStdin;
|
|
59
|
+
/** Wait for the child to exit. */
|
|
60
|
+
exitCode;
|
|
61
|
+
/** Everything written to stderr so far. */
|
|
62
|
+
stderrSoFar;
|
|
63
|
+
/** Everything written to stderr, once stderr has ended. Waits for the child to exit. */
|
|
64
|
+
stderrFinal;
|
|
65
|
+
constructor(parts) {
|
|
66
|
+
this.send = parts.send;
|
|
67
|
+
this.sendRaw = parts.sendRaw;
|
|
68
|
+
this.nextLine = parts.nextLine;
|
|
69
|
+
this.readUntilResponse = parts.readUntilResponse;
|
|
70
|
+
this.handshake = parts.handshake;
|
|
71
|
+
this.closeStdin = parts.closeStdin;
|
|
72
|
+
this.exitCode = parts.exitCode;
|
|
73
|
+
this.stderrSoFar = parts.stderrSoFar;
|
|
74
|
+
this.stderrFinal = parts.stderrFinal;
|
|
75
|
+
}
|
|
76
|
+
/** Spawn `command` for the life of the current scope. */
|
|
77
|
+
static spawn = (command) => Effect.gen(function* () {
|
|
78
|
+
const handle = yield* (yield* ChildProcessSpawner.ChildProcessSpawner).spawn(command);
|
|
79
|
+
const encoder = new TextEncoder();
|
|
80
|
+
const stdin = yield* Queue.unbounded();
|
|
81
|
+
const stdinFailure = yield* Ref.make(void 0);
|
|
82
|
+
yield* Stream.run(Stream.fromQueue(stdin), handle.stdin).pipe(Effect.catch((error) => Ref.set(stdinFailure, error.message)), Effect.forkScoped);
|
|
83
|
+
const lines = yield* Queue.unbounded();
|
|
84
|
+
yield* Stream.splitLines(Stream.decodeText(handle.stdout)).pipe(Stream.runForEach((line) => line.length === 0 ? Effect.void : Effect.asVoid(Queue.offer(lines, line))), Effect.ensuring(Queue.end(lines)), Effect.forkScoped);
|
|
85
|
+
const stderr = yield* Ref.make("");
|
|
86
|
+
const stderrDone = yield* Deferred.make();
|
|
87
|
+
yield* Stream.decodeText(handle.stderr).pipe(Stream.runForEach((text) => Ref.update(stderr, (sofar) => sofar + text)), Effect.ensuring(Deferred.succeed(stderrDone, void 0)), Effect.forkScoped);
|
|
88
|
+
const nextLine = Queue.take(lines).pipe(Effect.catch(() => Effect.flatMap(Ref.get(stdinFailure), (pump) => Effect.fail(new McpTestFailure({
|
|
89
|
+
reason: "StreamEnded",
|
|
90
|
+
message: `the server's stdout ended before the expected line${STDERR_HINT}${pump === void 0 ? "" : `; stdin pump failed: ${ToolFailure.truncate(pump)}`}`
|
|
91
|
+
})))));
|
|
92
|
+
const readUntilResponse = (id) => Effect.gen(function* () {
|
|
93
|
+
const seen = [];
|
|
94
|
+
while (true) {
|
|
95
|
+
const line = yield* nextLine;
|
|
96
|
+
const message = parseFrame(line);
|
|
97
|
+
if (!isJsonRpcMessage(message)) return yield* new McpTestFailure({
|
|
98
|
+
reason: "NotJsonRpc",
|
|
99
|
+
message: `stdout carried a line that is not JSON-RPC: ${ToolFailure.truncate(line)}`
|
|
100
|
+
});
|
|
101
|
+
seen.push(message);
|
|
102
|
+
if (isResponse(message) && message.id === id) return {
|
|
103
|
+
response: message,
|
|
104
|
+
seen
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
const sendRaw = (text) => Effect.asVoid(Queue.offer(stdin, typeof text === "string" ? encoder.encode(text) : text));
|
|
109
|
+
const send = (message) => sendRaw(`${JSON.stringify(message)}\n`);
|
|
110
|
+
const handshake = (protocol = McpProtocol.v2025_11_25) => Effect.gen(function* () {
|
|
111
|
+
if (isStateless(protocol)) {
|
|
112
|
+
yield* send(frame(protocol, DEFAULT_CLIENT_INFO, "server/discover", {}, 1));
|
|
113
|
+
return (yield* readUntilResponse(1)).response;
|
|
114
|
+
}
|
|
115
|
+
yield* send(frame(protocol, DEFAULT_CLIENT_INFO, "initialize", initializeParams(protocol, DEFAULT_CLIENT_INFO), 1));
|
|
116
|
+
const { response } = yield* readUntilResponse(1);
|
|
117
|
+
yield* send(frame(protocol, DEFAULT_CLIENT_INFO, "notifications/initialized", void 0));
|
|
118
|
+
return response;
|
|
119
|
+
});
|
|
120
|
+
return new McpProcess({
|
|
121
|
+
send,
|
|
122
|
+
sendRaw,
|
|
123
|
+
nextLine,
|
|
124
|
+
readUntilResponse,
|
|
125
|
+
handshake,
|
|
126
|
+
closeStdin: Effect.asVoid(Queue.end(stdin)),
|
|
127
|
+
exitCode: Effect.map(handle.exitCode, (code) => Number(code)),
|
|
128
|
+
stderrSoFar: Ref.get(stderr),
|
|
129
|
+
stderrFinal: Effect.andThen(Deferred.await(stderrDone), Ref.get(stderr))
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
//#endregion
|
|
135
|
+
export { McpProcess };
|
package/McpStdio.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { LaunchFailed } from "./internal/LaunchFailed.js";
|
|
2
|
+
import { makeGuardedStdio } from "./internal/StdinFrames.js";
|
|
3
|
+
import { Cause, Effect, Exit, Layer, References, Runtime } from "effect";
|
|
4
|
+
import { McpProtocol, McpServer } from "effect/unstable/ai";
|
|
5
|
+
|
|
6
|
+
//#region src/McpStdio.ts
|
|
7
|
+
/**
|
|
8
|
+
* Serve an MCP server over stdio without ever writing a log line or a failure
|
|
9
|
+
* report onto stdout, which is the JSON-RPC wire.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* The one-line `main.ts`:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* NodeRuntime.runMain(McpStdio.launch(Main), { teardown: McpStdio.teardown })
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* - {@link McpStdio.layer} is `McpServer.layerStdio` with `LogToStderr`
|
|
19
|
+
* provided and merged into its output, and `Layer.orDie`. It answers a
|
|
20
|
+
* stdin line that is not JSON with a JSON-RPC `-32700` parse error, and
|
|
21
|
+
* JSON that is not a JSON-RPC message with `-32600`, and keeps serving.
|
|
22
|
+
* - {@link McpStdio.launch} reports a launch failure itself, on stderr, and
|
|
23
|
+
* hides it from `runMain`, whose own report is written outside anything
|
|
24
|
+
* the program can provide.
|
|
25
|
+
* - {@link McpStdio.teardown} maps stdin EOF, the normal end of every
|
|
26
|
+
* session, to exit 0 instead of 130.
|
|
27
|
+
*
|
|
28
|
+
* Never `NodeRuntime.runMain(Layer.launch(Main).pipe(Effect.provideService(References.LogToStderr, true)))`:
|
|
29
|
+
* it typechecks and serves, but a launch failure is still reported by
|
|
30
|
+
* `runMain` through `console.log`, straight onto the wire, where the client
|
|
31
|
+
* reads it as a malformed frame instead of an error.
|
|
32
|
+
*
|
|
33
|
+
* @public
|
|
34
|
+
*/
|
|
35
|
+
var McpStdio = class McpStdio {
|
|
36
|
+
constructor() {}
|
|
37
|
+
/**
|
|
38
|
+
* `[v2026_07_28, v2025_11_25, v2025_06_18]` — the stateless revision
|
|
39
|
+
* first, then the two newest stateful ones.
|
|
40
|
+
*
|
|
41
|
+
* @remarks
|
|
42
|
+
* Real clients open with either handshake: Claude Code 2.1.281 was
|
|
43
|
+
* measured opening a stdio server with the stateless `server/discover`,
|
|
44
|
+
* while most other shipping clients still send `initialize`. The order
|
|
45
|
+
* is load-bearing:
|
|
46
|
+
*
|
|
47
|
+
* - Stateless first: a request with no session and no `_meta` falls to
|
|
48
|
+
* `protocols[0]`.
|
|
49
|
+
* - Never a single entry: `initialize` only matches stateful adapters, so
|
|
50
|
+
* a stateless-only list refuses every client that opens with it.
|
|
51
|
+
* - At most one stateless adapter; a second fails the layer.
|
|
52
|
+
*/
|
|
53
|
+
static protocols = [
|
|
54
|
+
McpProtocol.v2026_07_28,
|
|
55
|
+
McpProtocol.v2025_11_25,
|
|
56
|
+
McpProtocol.v2025_06_18
|
|
57
|
+
];
|
|
58
|
+
/**
|
|
59
|
+
* `McpServer.layerStdio` with `LogToStderr` provided and merged into the
|
|
60
|
+
* output, so every layer it provides logs to stderr too. A bad
|
|
61
|
+
* `protocols` list dies: it is the implementer's defect.
|
|
62
|
+
*
|
|
63
|
+
* @remarks
|
|
64
|
+
* Only a layer this one provides inherits `LogToStderr`: compose with
|
|
65
|
+
* `Layer.provideMerge(McpStdio.layer(...))`. A sibling merged beside it
|
|
66
|
+
* with `Layer.mergeAll` is not provided by it, so its build logs still
|
|
67
|
+
* go through `console.log`, onto the wire.
|
|
68
|
+
*
|
|
69
|
+
* The server reads stdin through a guard that frames it exactly as core's
|
|
70
|
+
* decoder does: one streaming UTF-8 decoder, a byte-order mark stripped
|
|
71
|
+
* only at the start of the stream, lines split on `\n`. A line that is
|
|
72
|
+
* not JSON is answered on stdout with a JSON-RPC parse error, code
|
|
73
|
+
* `-32700` and `id: null`, and never reaches core's decoder, which would
|
|
74
|
+
* otherwise throw on that line again for every later chunk and stop
|
|
75
|
+
* answering. So is a line longer than core's cap of 16 Mi UTF-16 code
|
|
76
|
+
* units: it is answered once, as soon as it passes the cap, and the rest
|
|
77
|
+
* of it is discarded up to its newline. A line of JSON whitespace (space,
|
|
78
|
+
* tab, carriage return) is ignored.
|
|
79
|
+
*
|
|
80
|
+
* A line that is JSON but no JSON-RPC message core can handle is answered
|
|
81
|
+
* with an Invalid Request, code `-32600` and `id: null`, and never reaches
|
|
82
|
+
* core: a value that is neither an object nor an array (core throws on
|
|
83
|
+
* `null`, dropping every other frame in its chunk, and ignores a number,
|
|
84
|
+
* string or boolean without a reply), an object whose `method` is not a
|
|
85
|
+
* string and whose `id` is absent or `null` (core throws on it too), and
|
|
86
|
+
* an object with neither `method` nor `id`, which is neither a request nor
|
|
87
|
+
* a response. Everything else goes to core: an array, which core answers
|
|
88
|
+
* `-32600` itself because it serves no batches; an object with an `id`
|
|
89
|
+
* and no `method`, which is a response and gets no reply; and a request
|
|
90
|
+
* with an `id`, which core answers even when its `method` is malformed.
|
|
91
|
+
*
|
|
92
|
+
* Give each server a fresh layer memo map. Core's stdio protocol layer
|
|
93
|
+
* is a shared constant, so a second `McpStdio.layer` server whose build
|
|
94
|
+
* sees the first one's memo map shares its protocol: only the first
|
|
95
|
+
* server reads stdin, and the second never answers. Merging both into
|
|
96
|
+
* one graph does that, and so does building or providing the second
|
|
97
|
+
* anywhere under the first one's `Effect.provide`: nested `Layer.build`
|
|
98
|
+
* and `Effect.provide` fork the ambient memo map rather than starting a
|
|
99
|
+
* new one. Isolate each server with its own `ManagedRuntime`,
|
|
100
|
+
* `Effect.provide(layer, { local: true })` or its own process.
|
|
101
|
+
*
|
|
102
|
+
* Code after a completed `Effect.provide` of a stdio server never runs:
|
|
103
|
+
* core's stdio protocol interrupts the fiber that built it when its stdin
|
|
104
|
+
* loop ends, which closing the provide's scope does. In a test, serve the
|
|
105
|
+
* layer through `McpHarness` from `@effected/mcp/testing` instead.
|
|
106
|
+
*
|
|
107
|
+
* Each call mints a fresh layer; bind the result to a `const` or the
|
|
108
|
+
* server builds twice.
|
|
109
|
+
*/
|
|
110
|
+
static layer = (options) => McpServer.layerStdio({
|
|
111
|
+
name: options.name,
|
|
112
|
+
version: options.version,
|
|
113
|
+
instructions: options.instructions,
|
|
114
|
+
description: options.description,
|
|
115
|
+
protocols: options.protocols ?? McpStdio.protocols
|
|
116
|
+
}).pipe(Layer.provide(makeGuardedStdio()), Layer.provideMerge(Layer.succeed(References.LogToStderr, true)), Layer.orDie);
|
|
117
|
+
/**
|
|
118
|
+
* `Layer.launch`, with any failure other than an interrupt logged on
|
|
119
|
+
* stderr here and re-raised marked as already reported, keeping its exit
|
|
120
|
+
* code.
|
|
121
|
+
*
|
|
122
|
+
* @remarks
|
|
123
|
+
* `runMain` logs an unhandled failure from outside the program, where no
|
|
124
|
+
* `Effect.provideService` reaches, and Effect's default logger writes
|
|
125
|
+
* through `console.log` unless `LogToStderr` is set. For an MCP server
|
|
126
|
+
* that is stdout, which is the wire.
|
|
127
|
+
*/
|
|
128
|
+
static launch = (layer) => Layer.launch(layer).pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logError(cause).pipe(Effect.andThen(Effect.fail(new LaunchFailed(Runtime.getErrorExitCode(Cause.squash(cause))))))), Effect.provideService(References.LogToStderr, true));
|
|
129
|
+
/**
|
|
130
|
+
* Success or an interrupt-only exit maps to 0. Anything else goes to
|
|
131
|
+
* `Runtime.defaultTeardown`.
|
|
132
|
+
*
|
|
133
|
+
* @remarks
|
|
134
|
+
* Stdin EOF, the normal end of every session, ends in an interrupt-only
|
|
135
|
+
* exit, and so do SIGINT and SIGTERM: `runMain` interrupts the program on
|
|
136
|
+
* either signal. All three exit 0 here, not the 130 a default teardown
|
|
137
|
+
* reports for an interrupt.
|
|
138
|
+
*/
|
|
139
|
+
static teardown = (exit, onExit) => Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) ? onExit(0) : Runtime.defaultTeardown(exit, onExit);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
//#endregion
|
|
143
|
+
export { McpStdio };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/McpTestFailure.ts
|
|
4
|
+
/**
|
|
5
|
+
* Why an MCP test client could not produce what was asked of it.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Each reason is raised by a specific client, and not every client raises
|
|
9
|
+
* every reason:
|
|
10
|
+
*
|
|
11
|
+
* - `StreamEnded` (`McpProcess`, `McpProbe`): the spawned child's stdout
|
|
12
|
+
* ended first, because the child exited. `McpProbe` folds the exit code
|
|
13
|
+
* and stderr into the message.
|
|
14
|
+
* - `NotJsonRpc` (`McpProcess.readUntilResponse`, `McpProbe`): a spawned
|
|
15
|
+
* child's stdout line was not a JSON-RPC frame. `McpHarness` never raises
|
|
16
|
+
* it: under `strictStdout` a non-JSON-RPC line DIES the pending wait as a
|
|
17
|
+
* defect instead, so assert it with `Effect.exit` and `Cause.hasDies`,
|
|
18
|
+
* not `catchTag`.
|
|
19
|
+
* - `ServerStopped` (`McpHarness`): the in-process server stopped, usually
|
|
20
|
+
* because stdin closed while the request was in flight.
|
|
21
|
+
* - `NotInitialized` (`McpHarness`): a request other than `initialize` was
|
|
22
|
+
* made on a stateful revision before `initialize` was sent. The request
|
|
23
|
+
* is never written; the server would only have refused it with an opaque
|
|
24
|
+
* `Invalid request metadata`.
|
|
25
|
+
* - `ErrorResponse` (`McpHarness.listTools`): the server answered with a
|
|
26
|
+
* JSON-RPC error where a result was required. Every other request method
|
|
27
|
+
* returns the whole response, error included, as data.
|
|
28
|
+
*
|
|
29
|
+
* @public
|
|
30
|
+
*/
|
|
31
|
+
var McpTestFailure = class extends Schema.TaggedError()("McpTestFailure", {
|
|
32
|
+
reason: Schema.Literals([
|
|
33
|
+
"StreamEnded",
|
|
34
|
+
"ServerStopped",
|
|
35
|
+
"NotJsonRpc",
|
|
36
|
+
"NotInitialized",
|
|
37
|
+
"ErrorResponse"
|
|
38
|
+
]),
|
|
39
|
+
message: Schema.String
|
|
40
|
+
}) {};
|
|
41
|
+
|
|
42
|
+
//#endregion
|
|
43
|
+
export { McpTestFailure };
|