@effected/mcp 0.12.0 → 0.14.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/McpHarness.js DELETED
@@ -1,338 +0,0 @@
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, Fiber, Layer, Queue, Sink, Stdio, Stream } from "effect";
5
- import { McpProtocol } from "effect/ai";
6
- import { ErrorText } from "@effected/engine";
7
-
8
- // src/McpHarness.ts
9
- /** The generic tag `Object.prototype.toString` answers with: a value with no string form of its own. */
10
- const GENERIC_STRING = /^\[object [^\]]*\]$/;
11
- /**
12
- * One captured console argument as text, as close to what the console would show as a runtime-free rendering gets,
13
- * and never throwing.
14
- *
15
- * @remarks
16
- * A harness records what the server would have printed, so the record must not lose what the console shows. Hence
17
- * the order: a value's own string form first (`String(error)` is `Error: boom`, where `ErrorText.describe` would keep
18
- * only `boom`, and it is the same for any object carrying a `message`), then JSON for an array or a plain object,
19
- * whose string form is `1,2` or `[object Object]`. `ErrorText.describe` is the last resort for a value whose string
20
- * form throws and which has no JSON: an object with no prototype has no `toString`, and `String` throws on it inside
21
- * the server's own console call.
22
- */
23
- const showArg = (arg) => {
24
- if (typeof arg === "string") return arg;
25
- if (typeof arg !== "object" || arg === null) return ErrorText.describe(arg);
26
- try {
27
- if (!Array.isArray(arg)) {
28
- const text = String(arg);
29
- if (!GENERIC_STRING.test(text)) return text;
30
- }
31
- } catch {}
32
- try {
33
- const json = JSON.stringify(arg);
34
- if (json !== void 0) return json;
35
- } catch {}
36
- return ErrorText.describe(arg);
37
- };
38
- /**
39
- * An in-process MCP client for a real server layer, over queue-backed stdio.
40
- *
41
- * @remarks
42
- * Builds your server with a queue-backed `Stdio.layerTest` that satisfies the
43
- * server's `Stdio` requirement, so tests see the exact served schemas and wire
44
- * results a real client would, with no child and no sockets. Pass the server
45
- * WITHOUT a `Stdio` of its own: a `Stdio` the server provides internally wins,
46
- * and talks to the real terminal. A bundled platform layer such as
47
- * `NodeServices.layer` (`@effect/platform-node`) brings its own `Stdio` along
48
- * with `FileSystem`, `Path`, `ChildProcessSpawner`, `Crypto` and `Terminal` —
49
- * providing it to the server loses `Stdio` the same way, and every wait
50
- * hangs to timeout with no error naming why. Compose the individual layers
51
- * the server needs instead and leave `Stdio` out; this harness supplies it.
52
- *
53
- * The server is built with a fresh layer memo map, never the ambient one, so
54
- * a harness made under another stdio server's `Effect.provide` still serves
55
- * its own. Never pass a server layer that provides a layer the test also
56
- * provides and then reads — `McpHarness.make(server.pipe(Layer.provide(AppLayer)))`
57
- * under `Effect.provide(AppLayer)` builds `AppLayer` twice, so the tools write
58
- * to one instance and the test reads the other. Leave the service in the
59
- * server layer's requirements and provide it once from the test, or build it
60
- * once and pass `Layer.succeed(Tag, value)`.
61
- *
62
- * - Responses are matched by id, so notifications may interleave freely.
63
- * - Every response wait and `awaitOutboundMethod` fails with `ServerStopped`
64
- * when the server stops, and dies when `strictStdout` sees a line that is
65
- * not JSON-RPC. `stop` stops the server at once; `close` ends stdin and lets
66
- * in-flight requests answer first, so only `stop` releases a wait on a
67
- * request that never completes.
68
- * - With `captureLogs`, the server's console is captured: `stderrSoFar`
69
- * holds stderr writes and every log line, and `consoleLogSoFar` holds
70
- * anything that went through `console.log`, which in a real server is
71
- * the wire. Assert it empty.
72
- * - On a stateful revision (the default `2025-11-25`), send `initialize`
73
- * first. `ping` is the exception: the server answers it `{}` before
74
- * initialization, so it is written. Every other request before
75
- * `initialize` fails with `NotInitialized` and is never written: the
76
- * server would only answer an opaque refusal,
77
- * `-32602 Invalid request metadata` when a stateless adapter is listed
78
- * first (as in `McpStdio.protocols`), or `-32603 Internal error` when
79
- * only stateful revisions are served.
80
- *
81
- * @example
82
- * ```ts
83
- * import { McpHarness } from "@effected/mcp/testing";
84
- * import { Effect } from "effect";
85
- *
86
- * // `ServerLayer` is the server's layer, built without its own `Stdio`.
87
- * const program = Effect.gen(function* () {
88
- * const harness = yield* McpHarness.make(ServerLayer);
89
- * yield* harness.initialize;
90
- * const tools = yield* harness.listTools;
91
- * return tools.map((tool) => tool.name);
92
- * }).pipe(Effect.scoped);
93
- * ```
94
- *
95
- * @public
96
- */
97
- var McpHarness = class McpHarness {
98
- /** The revision this client speaks. */
99
- protocol;
100
- /** `initialize` then `notifications/initialized`; `server/discover` on a stateless revision. Call it first on a stateful revision. */
101
- initialize;
102
- /**
103
- * `initialize` asking for `protocolVersion` instead of the harness's own
104
- * revision, then `notifications/initialized` only when the response
105
- * carries no error. Returns the whole response: assert the negotiated
106
- * `result.protocolVersion`, or the JSON-RPC `error`.
107
- *
108
- * @remarks
109
- * It moves between stateful revisions only. A stateful server never
110
- * refuses a version: it counter-offers its latest stateful revision, so
111
- * asking for an unknown one, or for the stateless `2026-07-28`, comes back
112
- * as `2025-11-25`. On a harness made with a stateless revision the server
113
- * does not serve `initialize` at all, and the response is a JSON-RPC
114
- * `-32601` "Method not found" error, with no `notifications/initialized`
115
- * sent after it.
116
- */
117
- initializeWith;
118
- /** `server/discover`. */
119
- discover;
120
- /** Send a request and wait for its response. */
121
- request;
122
- /** Send a request now; wait for its response later. Fails with `NotInitialized`, sending nothing, on a stateful revision before `initialize` (`ping` excepted). */
123
- startRequest;
124
- /** Send a notification. */
125
- notify;
126
- /** `tools/call`, returning the whole response: a JSON-RPC error is data here. */
127
- callTool;
128
- /** `tools/list`'s tools; a JSON-RPC error fails with `ErrorResponse`. */
129
- listTools;
130
- /** `resources/list`'s resources; a JSON-RPC error fails with `ErrorResponse`. */
131
- listResources;
132
- /** `resources/read`, returning the whole response. */
133
- readResource;
134
- /** Write any value as one newline-framed line. Never gated; a raw `initialize` counts as sent. */
135
- sendRaw;
136
- /** The next server-initiated frame with this method, skipping and keeping others; fails with `ServerStopped` once the server stops. */
137
- awaitOutboundMethod;
138
- /** Every frame written to the server's stdin so far, in order, as the JSON-parsed bytes written (a snapshot the caller's later mutations cannot reach): requests, notifications and `sendRaw` values. */
139
- sentSoFar;
140
- /** Everything written to stderr, plus every captured log line. */
141
- stderrSoFar;
142
- /**
143
- * Every captured `console.log`, `info` or `debug` call, its arguments joined with a space, each rendered close to
144
- * what the console would show: a string as itself, a value with its own string form as that form (an `Error` as
145
- * `Error: boom`, a number, a date), an array or a plain object as JSON (`{"message":"x"}`, not just `x`).
146
- *
147
- * @remarks
148
- * Rendering never throws inside the server's own call. A value whose string form throws (an object with no
149
- * prototype, a throwing `toString`) and has no JSON falls back to `ErrorText.describe` from `@effected/engine`.
150
- */
151
- consoleLogSoFar;
152
- /**
153
- * End stdin, as a client disconnecting does. The server drains first:
154
- * requests already in flight still answer, and the server stops once they
155
- * have, so a wait on a request that never completes keeps waiting. Use
156
- * {@link McpHarness.stop} to end the server regardless.
157
- */
158
- close;
159
- /**
160
- * Interrupt the server without draining, as a client killing the process
161
- * does: every pending response wait and `awaitOutboundMethod` fails with
162
- * `ServerStopped`.
163
- */
164
- stop;
165
- constructor(parts) {
166
- this.protocol = parts.protocol;
167
- this.initialize = parts.initialize;
168
- this.initializeWith = parts.initializeWith;
169
- this.discover = parts.discover;
170
- this.request = parts.request;
171
- this.startRequest = parts.startRequest;
172
- this.notify = parts.notify;
173
- this.callTool = parts.callTool;
174
- this.listTools = parts.listTools;
175
- this.listResources = parts.listResources;
176
- this.readResource = parts.readResource;
177
- this.sendRaw = parts.sendRaw;
178
- this.awaitOutboundMethod = parts.awaitOutboundMethod;
179
- this.sentSoFar = parts.sentSoFar;
180
- this.stderrSoFar = parts.stderrSoFar;
181
- this.consoleLogSoFar = parts.consoleLogSoFar;
182
- this.close = parts.close;
183
- this.stop = parts.stop;
184
- }
185
- /** Build `server` over queue-backed stdio and return a client for it; fails with the layer's own error if it cannot build. */
186
- static make = (server, options = {}) => Effect.gen(function* () {
187
- const protocol = options.protocol ?? McpProtocol.v2025_11_25;
188
- const clientInfo = options.clientInfo ?? DEFAULT_CLIENT_INFO;
189
- const strictStdout = options.strictStdout ?? true;
190
- const stdin = yield* Queue.unbounded();
191
- const stdout = yield* Queue.unbounded();
192
- const inbound = yield* Queue.unbounded();
193
- const retained = [];
194
- const waiters = /* @__PURE__ */ new Map();
195
- const stderr = [];
196
- const consoleLog = [];
197
- const ready = yield* Deferred.make();
198
- const stopped = yield* Deferred.make();
199
- const corrupt = yield* Deferred.make();
200
- const encoder = new TextEncoder();
201
- const stdoutDecoder = new TextDecoder();
202
- const stderrDecoder = new TextDecoder();
203
- let nextId = 1;
204
- const stdio = Stdio.layerTest({
205
- stdin: Stream.fromQueue(stdin),
206
- stdout: () => Sink.forEach((chunk) => Queue.offer(stdout, chunk)),
207
- stderr: () => Sink.forEach((chunk) => Effect.sync(() => {
208
- stderr.push(typeof chunk === "string" ? chunk : stderrDecoder.decode(chunk, { stream: true }));
209
- }))
210
- });
211
- const ambient = yield* Console.Console;
212
- const show = (args) => args.map(showArg).join(" ");
213
- const toStdout = (...args) => {
214
- consoleLog.push(show(args));
215
- };
216
- const toStderr = (...args) => {
217
- stderr.push(`${show(args)}\n`);
218
- };
219
- const captured = {
220
- ...ambient,
221
- log: toStdout,
222
- info: toStdout,
223
- debug: toStdout,
224
- error: toStderr,
225
- warn: toStderr
226
- };
227
- const provided = options.captureLogs === false ? server.pipe(Layer.provide(stdio)) : server.pipe(Layer.provide(stdio), Layer.provide(Layer.succeed(Console.Console, captured)));
228
- const route = (line) => {
229
- const parsed = parseFrame(line);
230
- 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;
231
- if (isResponse(parsed)) {
232
- const key = requestKey(parsed.id);
233
- const waiter = waiters.get(key);
234
- if (waiter !== void 0) {
235
- waiters.delete(key);
236
- return Effect.asVoid(Deferred.succeed(waiter, parsed));
237
- }
238
- }
239
- return Effect.asVoid(Queue.offer(inbound, parsed));
240
- };
241
- const router = 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);
242
- const serverFiber = 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, Queue.end(stdout).pipe(Effect.andThen(Fiber.await(router)), Effect.andThen(Deferred.fail(stopped, new McpTestFailure({
243
- reason: "ServerStopped",
244
- message: "the server stopped before it responded"
245
- })))))), Effect.forkScoped);
246
- yield* Deferred.await(ready);
247
- const stopAware = (wait) => Effect.raceAllFirst([
248
- wait,
249
- Deferred.await(stopped),
250
- Deferred.await(corrupt)
251
- ]);
252
- const awaitResponse = (waiter) => Effect.suspend(() => Deferred.isDoneUnsafe(waiter) ? Deferred.await(waiter) : stopAware(Deferred.await(waiter)));
253
- const stateful = !isStateless(protocol);
254
- let initializeSent = false;
255
- const sent = [];
256
- const sendRaw = (message) => Effect.suspend(() => {
257
- const line = JSON.stringify(message);
258
- const written = line === void 0 ? void 0 : JSON.parse(line);
259
- sent.push(written);
260
- if (isJsonRpcMessage(written) && written.method === "initialize") initializeSent = true;
261
- return Effect.asVoid(Queue.offer(stdin, encoder.encode(`${line}\n`)));
262
- });
263
- const startRequest = (method, params) => Effect.gen(function* () {
264
- if (stateful && !initializeSent && method !== "initialize" && method !== "ping") return yield* new McpTestFailure({
265
- reason: "NotInitialized",
266
- message: `${method} was not sent: call initialize first on stateful protocol ${protocol.protocolVersion}`
267
- });
268
- const id = nextId++;
269
- const waiter = yield* Deferred.make();
270
- waiters.set(requestKey(id), waiter);
271
- yield* sendRaw(frame(protocol, clientInfo, method, params, id));
272
- return {
273
- id,
274
- response: awaitResponse(waiter)
275
- };
276
- });
277
- const request = (method, params) => Effect.flatMap(startRequest(method, params), (started) => started.response);
278
- const notify = (method, params) => sendRaw(frame(protocol, clientInfo, method, params));
279
- const discover = request("server/discover", {});
280
- const initialize = isStateless(protocol) ? discover : Effect.gen(function* () {
281
- const response = yield* request("initialize", initializeParams(protocol, clientInfo));
282
- yield* notify("notifications/initialized");
283
- return response;
284
- });
285
- const initializeWith = (protocolVersion) => Effect.gen(function* () {
286
- const response = yield* request("initialize", {
287
- ...initializeParams(protocol, clientInfo),
288
- protocolVersion
289
- });
290
- if (response.error === void 0) yield* notify("notifications/initialized");
291
- return response;
292
- });
293
- const listTools = Effect.flatMap(request("tools/list"), (response) => response.error === void 0 ? Effect.succeed(response.result.tools) : Effect.fail(new McpTestFailure({
294
- reason: "ErrorResponse",
295
- message: `tools/list failed: ${ToolFailure.truncate(JSON.stringify(response.error))}`
296
- })));
297
- const listResources = Effect.flatMap(request("resources/list"), (response) => response.error === void 0 ? Effect.succeed(response.result.resources) : Effect.fail(new McpTestFailure({
298
- reason: "ErrorResponse",
299
- message: `resources/list failed: ${ToolFailure.truncate(JSON.stringify(response.error))}`
300
- })));
301
- const awaitOutboundMethod = (method) => Effect.suspend(() => {
302
- const index = retained.findIndex((message) => message.method === method);
303
- if (index !== -1) return Effect.succeed(retained.splice(index, 1)[0]);
304
- return stopAware(Effect.gen(function* () {
305
- while (true) {
306
- const message = yield* Queue.take(inbound);
307
- if (message.method === method) return message;
308
- retained.push(message);
309
- }
310
- }));
311
- });
312
- return new McpHarness({
313
- protocol,
314
- initialize,
315
- initializeWith,
316
- discover,
317
- request,
318
- startRequest,
319
- notify,
320
- callTool: (name, args) => request("tools/call", {
321
- name,
322
- arguments: args ?? {}
323
- }),
324
- listTools,
325
- listResources,
326
- readResource: (uri) => request("resources/read", { uri }),
327
- sendRaw,
328
- awaitOutboundMethod,
329
- sentSoFar: Effect.sync(() => [...sent]),
330
- stderrSoFar: Effect.sync(() => stderr.join("")),
331
- consoleLogSoFar: Effect.sync(() => [...consoleLog]),
332
- close: Effect.asVoid(Queue.end(stdin)),
333
- stop: Fiber.interrupt(serverFiber)
334
- });
335
- });
336
- };
337
-
338
- export { McpHarness };
package/McpProbe.js DELETED
@@ -1,94 +0,0 @@
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/ai";
7
-
8
- // 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 proves nothing against a server that stops at stdin EOF
16
- * without answering what is in flight: it replies nothing and exits 0, so a
17
- * slow boot reads as a pass with 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
- * @example
31
- * ```ts
32
- * import { McpProbe } from "@effected/mcp/testing";
33
- * import { Effect } from "effect";
34
- * import { ChildProcess } from "effect/process";
35
- *
36
- * const program = Effect.gen(function* () {
37
- * const probe = yield* McpProbe.initialize(ChildProcess.make(process.execPath, ["./bin/server.js"]));
38
- * // A clean boot: no JSON-RPC error, empty stderr, exit code 0.
39
- * return probe.response.error === undefined && probe.stderr === "" && probe.exitCode === 0;
40
- * });
41
- * // Requires a `ChildProcessSpawner` from the platform layer.
42
- * ```
43
- *
44
- * @public
45
- */
46
- var McpProbe = class {
47
- constructor() {}
48
- /**
49
- * Spawn `command`, send one `initialize` (or `server/discover` on a
50
- * stateless revision) as id 1, wait for its response, close stdin, and
51
- * collect stdout, stderr and the exit code.
52
- */
53
- static initialize = (command, options = {}) => Effect.scoped(Effect.gen(function* () {
54
- const child = yield* McpProcess.spawn(command);
55
- const protocol = options.protocol ?? McpProtocol.v2025_11_25;
56
- const stateless = isStateless(protocol);
57
- yield* child.send(frame(protocol, DEFAULT_CLIENT_INFO, stateless ? "server/discover" : "initialize", stateless ? {} : initializeParams(protocol, DEFAULT_CLIENT_INFO), 1));
58
- const stdout = [];
59
- const read = Effect.gen(function* () {
60
- const line = yield* child.nextLine;
61
- stdout.push(line);
62
- const message = parseFrame(line);
63
- if (!isJsonRpcMessage(message)) return yield* new McpTestFailure({
64
- reason: "NotJsonRpc",
65
- message: `stdout carried a line that is not JSON-RPC: ${ToolFailure.truncate(line)}`
66
- });
67
- return message;
68
- });
69
- const diagnose = (failure) => failure.reason !== "StreamEnded" ? Effect.fail(failure) : Effect.gen(function* () {
70
- const code = yield* child.exitCode;
71
- const stderr = yield* child.stderrFinal;
72
- return yield* new McpTestFailure({
73
- reason: "StreamEnded",
74
- message: `${failure.message.replace(STDERR_HINT, "")}; the child exited with code ${code}; stderr: ${stderr === "" ? "(empty)" : ToolFailure.truncate(stderr)}`
75
- });
76
- });
77
- let response;
78
- while (response === void 0) {
79
- const message = yield* Effect.catch(read, diagnose);
80
- if (isResponse(message) && message.id === 1) response = message;
81
- }
82
- yield* child.closeStdin;
83
- const exitCode = yield* child.exitCode;
84
- while (true) if (!(yield* Effect.catch(Effect.map(read, () => true), (failure) => failure.reason === "StreamEnded" ? Effect.succeed(false) : Effect.fail(failure)))) break;
85
- return {
86
- response,
87
- stdout,
88
- stderr: yield* child.stderrFinal,
89
- exitCode
90
- };
91
- }));
92
- };
93
-
94
- export { McpProbe };
package/McpProcess.js DELETED
@@ -1,196 +0,0 @@
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, Duration, Effect, Option, Queue, Ref, Stream, SubscriptionRef } from "effect";
5
- import { McpProtocol } from "effect/ai";
6
- import { ChildProcessSpawner } from "effect/process";
7
-
8
- // 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 still answers from an Effect
26
- * server, which drains before it exits; a server that does not drain
27
- * drops it, so a portable client reads the response first.
28
- *
29
- * @example
30
- * ```ts
31
- * import { McpProcess } from "@effected/mcp/testing";
32
- * import { Effect } from "effect";
33
- * import { ChildProcess } from "effect/process";
34
- *
35
- * const program = Effect.gen(function* () {
36
- * const child = yield* McpProcess.spawn(ChildProcess.make(process.execPath, ["./bin/server.js"]));
37
- * yield* child.handshake();
38
- * yield* child.send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
39
- * const { response } = yield* child.readUntilResponse(2);
40
- * yield* child.closeStdin;
41
- * return response;
42
- * }).pipe(Effect.scoped);
43
- * // Requires a `ChildProcessSpawner` from the platform layer.
44
- * ```
45
- *
46
- * @public
47
- */
48
- var McpProcess = class McpProcess {
49
- /**
50
- * Write one JSON-encoded, newline-framed message to the child's stdin.
51
- *
52
- * @remarks
53
- * Never fails. A frame sent after `closeStdin` is dropped, and a write the
54
- * child can no longer receive fails the stdin pump instead; the next
55
- * `StreamEnded` message names that failure.
56
- */
57
- send;
58
- /**
59
- * Write `text` to the child's stdin exactly as given: a string as UTF-8,
60
- * bytes unchanged, with no JSON encoding and no newline added.
61
- *
62
- * @remarks
63
- * For frames `send` cannot produce: a line that is not JSON, a blank line,
64
- * or one frame split across several writes, including mid-character by
65
- * passing bytes. Include the `\n` yourself.
66
- * Never fails, on the same terms as `send`.
67
- */
68
- sendRaw;
69
- /** The next non-empty stdout line; fails with `StreamEnded` once stdout ends. */
70
- nextLine;
71
- /** Read JSON-RPC lines up to and including the response with this id. */
72
- readUntilResponse;
73
- /** `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. */
74
- handshake;
75
- /** End stdin after every frame already sent. */
76
- closeStdin;
77
- /** Wait for the child to exit. */
78
- exitCode;
79
- /** Everything written to stderr so far. */
80
- stderrSoFar;
81
- /**
82
- * Wait until everything written to stderr so far satisfies `predicate`,
83
- * and return that text.
84
- *
85
- * @remarks
86
- * Event-driven: the predicate is checked against the current text at once,
87
- * then again on every chunk the child writes, never on a polling timer.
88
- * Fails with `StreamEnded` when stderr ends (the child exited) without the
89
- * predicate holding, and with `TimedOut` once `options.timeout` passes;
90
- * both messages carry the stderr seen so far. Reach for it whenever a
91
- * report lands on a later tick than the responses a test reads, such as
92
- * `McpGuard`'s `injectCrash: { at: "connected" }` report, instead of
93
- * reading `stderrSoFar` once. `options.timeout` is real time: run the test
94
- * under `it.live`.
95
- */
96
- stderrUntil;
97
- /** Everything written to stderr, once stderr has ended. Waits for the child to exit. */
98
- stderrFinal;
99
- constructor(parts) {
100
- this.send = parts.send;
101
- this.sendRaw = parts.sendRaw;
102
- this.nextLine = parts.nextLine;
103
- this.readUntilResponse = parts.readUntilResponse;
104
- this.handshake = parts.handshake;
105
- this.closeStdin = parts.closeStdin;
106
- this.exitCode = parts.exitCode;
107
- this.stderrSoFar = parts.stderrSoFar;
108
- this.stderrUntil = parts.stderrUntil;
109
- this.stderrFinal = parts.stderrFinal;
110
- }
111
- /** Spawn `command` for the life of the current scope. */
112
- static spawn = (command) => Effect.gen(function* () {
113
- const handle = yield* (yield* ChildProcessSpawner.ChildProcessSpawner).spawn(command);
114
- const encoder = new TextEncoder();
115
- const stdin = yield* Queue.unbounded();
116
- const stdinFailure = yield* Ref.make(void 0);
117
- yield* Stream.run(Stream.fromQueue(stdin), handle.stdin).pipe(Effect.catch((error) => Ref.set(stdinFailure, error.message)), Effect.forkScoped);
118
- const lines = yield* Queue.unbounded();
119
- 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);
120
- const stderr = yield* SubscriptionRef.make({
121
- text: "",
122
- done: false
123
- });
124
- const stderrDone = yield* Deferred.make();
125
- yield* Stream.decodeText(handle.stderr).pipe(Stream.runForEach((text) => SubscriptionRef.update(stderr, (sofar) => ({
126
- text: sofar.text + text,
127
- done: false
128
- }))), Effect.ensuring(Effect.andThen(SubscriptionRef.update(stderr, (sofar) => ({
129
- text: sofar.text,
130
- done: true
131
- })), Deferred.succeed(stderrDone, void 0))), Effect.forkScoped);
132
- const stderrText = Effect.map(SubscriptionRef.get(stderr), (state) => state.text);
133
- const stderrUntil = (predicate, options) => SubscriptionRef.changes(stderr).pipe(Stream.filter((state) => state.done || predicate(state.text)), Stream.runHead, Effect.flatMap((found) => {
134
- const state = Option.getOrElse(found, () => ({
135
- text: "",
136
- done: true
137
- }));
138
- return predicate(state.text) ? Effect.succeed(state.text) : Effect.fail(new McpTestFailure({
139
- reason: "StreamEnded",
140
- message: `the server's stderr ended before it matched; stderr: ${ToolFailure.truncate(state.text, ToolFailure.ENGINE_ECHO_LIMIT)}`
141
- }));
142
- }), Effect.timeoutOrElse({
143
- duration: options.timeout,
144
- orElse: () => Effect.flatMap(stderrText, (text) => Effect.fail(new McpTestFailure({
145
- reason: "TimedOut",
146
- message: `the server's stderr did not match within ${Duration.format(Duration.fromInputUnsafe(options.timeout))}; stderr so far: ${ToolFailure.truncate(text, ToolFailure.ENGINE_ECHO_LIMIT)}`
147
- })))
148
- }));
149
- const nextLine = Queue.take(lines).pipe(Effect.catch(() => Effect.flatMap(Ref.get(stdinFailure), (pump) => Effect.fail(new McpTestFailure({
150
- reason: "StreamEnded",
151
- message: `the server's stdout ended before the expected line${STDERR_HINT}${pump === void 0 ? "" : `; stdin pump failed: ${ToolFailure.truncate(pump)}`}`
152
- })))));
153
- const readUntilResponse = (id) => Effect.gen(function* () {
154
- const seen = [];
155
- while (true) {
156
- const line = yield* nextLine;
157
- const message = parseFrame(line);
158
- if (!isJsonRpcMessage(message)) return yield* new McpTestFailure({
159
- reason: "NotJsonRpc",
160
- message: `stdout carried a line that is not JSON-RPC: ${ToolFailure.truncate(line)}`
161
- });
162
- seen.push(message);
163
- if (isResponse(message) && message.id === id) return {
164
- response: message,
165
- seen
166
- };
167
- }
168
- });
169
- const sendRaw = (text) => Effect.asVoid(Queue.offer(stdin, typeof text === "string" ? encoder.encode(text) : text));
170
- const send = (message) => sendRaw(`${JSON.stringify(message)}\n`);
171
- const handshake = (protocol = McpProtocol.v2025_11_25) => Effect.gen(function* () {
172
- if (isStateless(protocol)) {
173
- yield* send(frame(protocol, DEFAULT_CLIENT_INFO, "server/discover", {}, 1));
174
- return (yield* readUntilResponse(1)).response;
175
- }
176
- yield* send(frame(protocol, DEFAULT_CLIENT_INFO, "initialize", initializeParams(protocol, DEFAULT_CLIENT_INFO), 1));
177
- const { response } = yield* readUntilResponse(1);
178
- yield* send(frame(protocol, DEFAULT_CLIENT_INFO, "notifications/initialized", void 0));
179
- return response;
180
- });
181
- return new McpProcess({
182
- send,
183
- sendRaw,
184
- nextLine,
185
- readUntilResponse,
186
- handshake,
187
- closeStdin: Effect.asVoid(Queue.end(stdin)),
188
- exitCode: Effect.map(handle.exitCode, (code) => Number(code)),
189
- stderrSoFar: stderrText,
190
- stderrUntil,
191
- stderrFinal: Effect.andThen(Deferred.await(stderrDone), stderrText)
192
- });
193
- });
194
- };
195
-
196
- export { McpProcess };