@effected/mcp 0.3.0 → 0.5.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/McpGuard.js CHANGED
@@ -1,26 +1,19 @@
1
+ import { ProcessGuard } from "@effected/engine/guard";
2
+
1
3
  //#region src/McpGuard.ts
2
- const isInjectedKind = (kind) => kind === "uncaughtException" || kind === "unhandledRejection";
3
- /** Emit one injected crash through the host, to the listeners the guard installed there. */
4
- const emitInjected = (host, kind) => {
5
- const error = /* @__PURE__ */ new Error(`[injected] ${kind}`);
6
- if (kind === "uncaughtException") {
7
- host.emit("uncaughtException", error, "uncaughtException");
8
- return;
9
- }
10
- const promise = Promise.reject(error);
11
- promise.catch(() => void 0);
12
- host.emit("unhandledRejection", error, promise);
13
- };
14
- const fallbackFormat = (error) => error instanceof Error ? error.stack ?? error.message : String(error);
15
4
  /**
16
5
  * Crash guards for an MCP server process, installed before the server's
17
6
  * module graph is loaded. Imported from `@effected/mcp/guard`.
18
7
  *
19
8
  * @remarks
20
- * {@link McpGuard.run} registers `uncaughtException` and `unhandledRejection`
21
- * listeners on `host`, then awaits `load()`, then launches the server with
22
- * `McpStdio.launch` and `McpStdio.teardown` under the `runMain` it returned.
23
- * This entrypoint has no static runtime import: it loads `effect` and the
9
+ * {@link McpGuard.run} is `ProcessGuard.run` from `@effected/engine/guard`
10
+ * with an MCP launch in its `load`: it registers `uncaughtException` and
11
+ * `unhandledRejection` listeners on `host`, then awaits `load()`, then
12
+ * launches the server with `McpStdio.launch` and `McpStdio.teardown` under
13
+ * the `runMain` it returned. A server on another transport (an LSP over
14
+ * `vscode-languageserver`, say) uses `ProcessGuard.run` directly and calls
15
+ * its `markConnected` itself. This entrypoint statically imports only
16
+ * `@effected/engine/guard`, which imports nothing: it loads `effect` and the
24
17
  * stdio wiring only after the guards are listening, so a throw while any of
25
18
  * it evaluates is still reported on stderr.
26
19
  *
@@ -58,63 +51,31 @@ const fallbackFormat = (error) => error instanceof Error ? error.stack ?? error.
58
51
  */
59
52
  var McpGuard = class {
60
53
  constructor() {}
54
+ /**
55
+ * Parse a test-only crash-injection setting into {@link McpGuardRunOptions.injectCrash}: `<at>:<kind>`, where `at`
56
+ * is `"load"` or `"connected"` and `kind` is `"uncaughtException"` or `"unhandledRejection"`. Anything else, or no
57
+ * value, is `undefined`. The same grammar as `ProcessGuard.parseInjectCrash` from `@effected/engine/guard`, which
58
+ * this is, so an MCP launcher need not import the engine guard for it.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * McpGuard.parseInjectCrash("load:uncaughtException"); // => { at: "load", kind: "uncaughtException" }
63
+ * ```
64
+ */
65
+ static parseInjectCrash = ProcessGuard.parseInjectCrash;
61
66
  /** Install the guards, load the server, and launch it. Resolves once the server is launched. */
62
- static run = async (options) => {
63
- const { host, label } = options;
64
- const onUncaught = options.policy?.onUncaught ?? "exit";
65
- const onRejection = options.policy?.onRejection ?? "exit";
66
- let connected = false;
67
- let format = fallbackFormat;
68
- const describe = (error) => {
69
- try {
70
- return format(error);
71
- } catch {
72
- try {
73
- return fallbackFormat(error);
74
- } catch {
75
- return "<unformattable error value>";
76
- }
77
- }
78
- };
79
- const exits = (mode) => mode === "exit" || mode === "exitBeforeConnect" && !connected;
80
- host.on("uncaughtException", (error, origin) => {
81
- host.stderr.write(`${label}: uncaughtException (${origin}): ${describe(error)}\n`);
82
- if (exits(onUncaught)) host.exit(1);
83
- });
84
- host.on("unhandledRejection", (reason) => {
85
- host.stderr.write(`${label}: unhandledRejection: ${describe(reason)}\n`);
86
- if (exits(onRejection)) host.exit(1);
87
- });
88
- const inject = options.injectCrash;
89
- const injectKind = isInjectedKind(inject?.kind) ? inject.kind : void 0;
90
- if (inject?.at === "load" && injectKind !== void 0) await new Promise((resolve, reject) => {
91
- setTimeout(() => {
92
- try {
93
- emitInjected(host, injectKind);
94
- resolve();
95
- } catch (error) {
96
- reject(error);
97
- }
98
- }, 0);
99
- });
100
- const onReady = () => {
101
- connected = true;
102
- if (inject?.at === "connected" && injectKind !== void 0) setTimeout(() => {
103
- try {
104
- emitInjected(host, injectKind);
105
- } catch {}
106
- }, 0);
107
- };
108
- try {
67
+ static run = (options) => ProcessGuard.run({
68
+ label: options.label,
69
+ host: options.host,
70
+ policy: options.policy,
71
+ injectCrash: options.injectCrash,
72
+ load: async (guard) => {
109
73
  const server = await options.load();
110
- if (server.format !== void 0) format = server.format;
74
+ if (server.format !== void 0) guard.useFormat(server.format);
111
75
  const { launchGuarded } = await import("./internal/guardLaunch.js");
112
- launchGuarded(server, onReady);
113
- } catch (error) {
114
- host.stderr.write(`${label}: startup failed: ${describe(error)}\n`);
115
- host.exit(1);
76
+ launchGuarded(server, guard.markConnected);
116
77
  }
117
- };
78
+ });
118
79
  };
119
80
 
120
81
  //#endregion
package/McpHarness.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ToolFailure } from "./ToolFailure.js";
2
2
  import { DEFAULT_CLIENT_INFO, frame, initializeParams, isJsonRpcMessage, isResponse, isStateless, parseFrame, requestKey } from "./internal/wire.js";
3
3
  import { McpTestFailure } from "./McpTestFailure.js";
4
- import { Console, Deferred, Effect, Exit, Layer, Queue, Sink, Stdio, Stream } from "effect";
4
+ import { Console, Deferred, Effect, Exit, Fiber, Layer, Queue, Sink, Stdio, Stream } from "effect";
5
5
  import { McpProtocol } from "effect/ai";
6
6
 
7
7
  //#region src/McpHarness.ts
@@ -30,9 +30,11 @@ import { McpProtocol } from "effect/ai";
30
30
  * once and pass `Layer.succeed(Tag, value)`.
31
31
  *
32
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.
33
+ * - Every response wait and `awaitOutboundMethod` fails with `ServerStopped`
34
+ * when the server stops, and dies when `strictStdout` sees a line that is
35
+ * not JSON-RPC. `stop` stops the server at once; `close` ends stdin and lets
36
+ * in-flight requests answer first, so only `stop` releases a wait on a
37
+ * request that never completes.
36
38
  * - With `captureLogs`, the server's console is captured: `stderrSoFar`
37
39
  * holds stderr writes and every log line, and `consoleLogSoFar` holds
38
40
  * anything that went through `console.log`, which in a real server is
@@ -46,6 +48,20 @@ import { McpProtocol } from "effect/ai";
46
48
  * first (as in `McpStdio.protocols`), or `-32603 Internal error` when
47
49
  * only stateful revisions are served.
48
50
  *
51
+ * @example
52
+ * ```ts
53
+ * import { McpHarness } from "@effected/mcp/testing";
54
+ * import { Effect } from "effect";
55
+ *
56
+ * // `ServerLayer` is the server's layer, built without its own `Stdio`.
57
+ * const program = Effect.gen(function* () {
58
+ * const harness = yield* McpHarness.make(ServerLayer);
59
+ * yield* harness.initialize;
60
+ * const tools = yield* harness.listTools;
61
+ * return tools.map((tool) => tool.name);
62
+ * }).pipe(Effect.scoped);
63
+ * ```
64
+ *
49
65
  * @public
50
66
  */
51
67
  var McpHarness = class McpHarness {
@@ -95,8 +111,19 @@ var McpHarness = class McpHarness {
95
111
  stderrSoFar;
96
112
  /** Every captured `console.log`, `info` or `debug` call. */
97
113
  consoleLogSoFar;
98
- /** End stdin, as a client disconnecting does. */
114
+ /**
115
+ * End stdin, as a client disconnecting does. The server drains first:
116
+ * requests already in flight still answer, and the server stops once they
117
+ * have, so a wait on a request that never completes keeps waiting. Use
118
+ * {@link McpHarness.stop} to end the server regardless.
119
+ */
99
120
  close;
121
+ /**
122
+ * Interrupt the server without draining, as a client killing the process
123
+ * does: every pending response wait and `awaitOutboundMethod` fails with
124
+ * `ServerStopped`.
125
+ */
126
+ stop;
100
127
  constructor(parts) {
101
128
  this.protocol = parts.protocol;
102
129
  this.initialize = parts.initialize;
@@ -115,6 +142,7 @@ var McpHarness = class McpHarness {
115
142
  this.stderrSoFar = parts.stderrSoFar;
116
143
  this.consoleLogSoFar = parts.consoleLogSoFar;
117
144
  this.close = parts.close;
145
+ this.stop = parts.stop;
118
146
  }
119
147
  /** Build `server` over queue-backed stdio and return a client for it; fails with the layer's own error if it cannot build. */
120
148
  static make = (server, options = {}) => Effect.gen(function* () {
@@ -172,11 +200,11 @@ var McpHarness = class McpHarness {
172
200
  }
173
201
  return Effect.asVoid(Queue.offer(inbound, parsed));
174
202
  };
175
- 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);
176
- 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({
203
+ 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);
204
+ 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({
177
205
  reason: "ServerStopped",
178
206
  message: "the server stopped before it responded"
179
- })))), Effect.forkScoped);
207
+ })))))), Effect.forkScoped);
180
208
  yield* Deferred.await(ready);
181
209
  const stopAware = (wait) => Effect.raceAllFirst([
182
210
  wait,
@@ -263,7 +291,8 @@ var McpHarness = class McpHarness {
263
291
  sentSoFar: Effect.sync(() => [...sent]),
264
292
  stderrSoFar: Effect.sync(() => stderr.join("")),
265
293
  consoleLogSoFar: Effect.sync(() => [...consoleLog]),
266
- close: Effect.asVoid(Queue.end(stdin))
294
+ close: Effect.asVoid(Queue.end(stdin)),
295
+ stop: Fiber.interrupt(serverFiber)
267
296
  });
268
297
  });
269
298
  };
package/McpProbe.js CHANGED
@@ -12,9 +12,9 @@ import { McpProtocol } from "effect/ai";
12
12
  *
13
13
  * @remarks
14
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
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
18
  * empty and `exitCode` is 0; this is the MCP half of a packed-install proof.
19
19
  * Checking stderr and the exit code alone passes a server that answers
20
20
  * the handshake with a JSON-RPC error, exits 0 and writes nothing.
@@ -27,6 +27,20 @@ import { McpProtocol } from "effect/ai";
27
27
  * - A child that ignores stdin EOF never exits, and the probe waits for it.
28
28
  * Wrap the probe in `Effect.timeout`.
29
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
+ *
30
44
  * @public
31
45
  */
32
46
  var McpProbe = class {
package/McpProcess.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ToolFailure } from "./ToolFailure.js";
2
2
  import { DEFAULT_CLIENT_INFO, STDERR_HINT, frame, initializeParams, isJsonRpcMessage, isResponse, isStateless, parseFrame } from "./internal/wire.js";
3
3
  import { McpTestFailure } from "./McpTestFailure.js";
4
- import { Deferred, Effect, Queue, Ref, Stream } from "effect";
4
+ import { Deferred, Duration, Effect, Option, Queue, Ref, Stream, SubscriptionRef } from "effect";
5
5
  import { McpProtocol } from "effect/ai";
6
6
  import { ChildProcessSpawner } from "effect/process";
7
7
 
@@ -22,8 +22,26 @@ import { ChildProcessSpawner } from "effect/process";
22
22
  * `list_changed`, to the matching id and returns what it saw.
23
23
  * - `closeStdin` is `Queue.end`, never `Queue.shutdown`, so every frame
24
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.
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
+ * ```
27
45
  *
28
46
  * @public
29
47
  */
@@ -60,6 +78,22 @@ var McpProcess = class McpProcess {
60
78
  exitCode;
61
79
  /** Everything written to stderr so far. */
62
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;
63
97
  /** Everything written to stderr, once stderr has ended. Waits for the child to exit. */
64
98
  stderrFinal;
65
99
  constructor(parts) {
@@ -71,6 +105,7 @@ var McpProcess = class McpProcess {
71
105
  this.closeStdin = parts.closeStdin;
72
106
  this.exitCode = parts.exitCode;
73
107
  this.stderrSoFar = parts.stderrSoFar;
108
+ this.stderrUntil = parts.stderrUntil;
74
109
  this.stderrFinal = parts.stderrFinal;
75
110
  }
76
111
  /** Spawn `command` for the life of the current scope. */
@@ -82,9 +117,35 @@ var McpProcess = class McpProcess {
82
117
  yield* Stream.run(Stream.fromQueue(stdin), handle.stdin).pipe(Effect.catch((error) => Ref.set(stdinFailure, error.message)), Effect.forkScoped);
83
118
  const lines = yield* Queue.unbounded();
84
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);
85
- const stderr = yield* Ref.make("");
120
+ const stderr = yield* SubscriptionRef.make({
121
+ text: "",
122
+ done: false
123
+ });
86
124
  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);
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
+ }));
88
149
  const nextLine = Queue.take(lines).pipe(Effect.catch(() => Effect.flatMap(Ref.get(stdinFailure), (pump) => Effect.fail(new McpTestFailure({
89
150
  reason: "StreamEnded",
90
151
  message: `the server's stdout ended before the expected line${STDERR_HINT}${pump === void 0 ? "" : `; stdin pump failed: ${ToolFailure.truncate(pump)}`}`
@@ -125,8 +186,9 @@ var McpProcess = class McpProcess {
125
186
  handshake,
126
187
  closeStdin: Effect.asVoid(Queue.end(stdin)),
127
188
  exitCode: Effect.map(handle.exitCode, (code) => Number(code)),
128
- stderrSoFar: Ref.get(stderr),
129
- stderrFinal: Effect.andThen(Deferred.await(stderrDone), Ref.get(stderr))
189
+ stderrSoFar: stderrText,
190
+ stderrUntil,
191
+ stderrFinal: Effect.andThen(Deferred.await(stderrDone), stderrText)
130
192
  });
131
193
  });
132
194
  };
package/McpStdio.js CHANGED
@@ -39,10 +39,9 @@ var McpStdio = class McpStdio {
39
39
  * first, then the two newest stateful ones.
40
40
  *
41
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:
42
+ * Real clients open with either handshake: some (Claude Code, for one)
43
+ * open a stdio server with the stateless `server/discover`, while others
44
+ * still send `initialize`. The order is load-bearing:
46
45
  *
47
46
  * - Stateless first: a request with no session and no `_meta` falls to
48
47
  * `protocols[0]`.
@@ -65,6 +64,16 @@ var McpStdio = class McpStdio {
65
64
  * `Layer.provideMerge(McpStdio.layer(...))`. A sibling merged beside it
66
65
  * with `Layer.mergeAll` is not provided by it, so its build logs still
67
66
  * go through `console.log`, onto the wire.
67
+
68
+ *
69
+ * `LogToStderr` only reaches a logger that reads it: Effect's default
70
+ * logger and `Logger.consolePretty`. `Logger.consoleJson`,
71
+ * `Logger.consoleLogFmt` and `Logger.consoleStructured` are built with
72
+ * `Logger.withConsoleLog`, which writes through `console.log` whatever
73
+ * `LogToStderr` says, so a server that installs one puts every log line on
74
+ * the wire. Never install them under this layer; wrap the formatter in
75
+ * `Logger.withConsoleError` instead, as in
76
+ * `Logger.layer([Logger.withConsoleError(Logger.formatJson)])`.
68
77
  *
69
78
  * The server reads stdin through a guard that frames it exactly as core's
70
79
  * decoder does: one streaming UTF-8 decoder, a byte-order mark stripped
package/McpTestFailure.js CHANGED
@@ -10,7 +10,10 @@ import { Schema } from "effect";
10
10
  *
11
11
  * - `StreamEnded` (`McpProcess`, `McpProbe`): the spawned child's stdout
12
12
  * ended first, because the child exited. `McpProbe` folds the exit code
13
- * and stderr into the message.
13
+ * and stderr into the message. `McpProcess.stderrUntil` raises it when
14
+ * stderr ends before its predicate holds.
15
+ * - `TimedOut` (`McpProcess.stderrUntil`): the predicate did not hold
16
+ * within the given timeout. The message carries the stderr seen so far.
14
17
  * - `NotJsonRpc` (`McpProcess.readUntilResponse`, `McpProbe`): a spawned
15
18
  * child's stdout line was not a JSON-RPC frame. `McpHarness` never raises
16
19
  * it: under `strictStdout` a non-JSON-RPC line DIES the pending wait as a
@@ -30,13 +33,16 @@ import { Schema } from "effect";
30
33
  * @public
31
34
  */
32
35
  var McpTestFailure = class extends Schema.TaggedError()("McpTestFailure", {
36
+ /** Which failure occurred; see the list above for which client raises which. */
33
37
  reason: Schema.Literals([
34
38
  "StreamEnded",
35
39
  "ServerStopped",
36
40
  "NotJsonRpc",
37
41
  "NotInitialized",
38
- "ErrorResponse"
42
+ "ErrorResponse",
43
+ "TimedOut"
39
44
  ]),
45
+ /** A human-readable description of what went wrong. */
40
46
  message: Schema.String
41
47
  }) {};
42
48
 
package/McpToolkit.js CHANGED
@@ -185,6 +185,11 @@ var McpToolkit = class {
185
185
  *
186
186
  * @example
187
187
  * ```ts
188
+ * import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
189
+ * import { Schema } from "effect";
190
+ * import { Tool } from "effect/ai";
191
+ *
192
+ * // `AddNote`, `ListNotes`, `Added`, `Listed` are object schemas; `Kit` is a Toolkit that includes `Note`.
188
193
  * const Note = McpToolkit.unionTool("note", {
189
194
  * parameters: Schema.Union([AddNote, ListNotes]),
190
195
  * success: ToolOutputSchema.objectRooted(Schema.Union([Added, Listed])),
package/README.md CHANGED
@@ -7,10 +7,10 @@ The boundary layer of an `effect/ai` MCP server: stdio wiring that keeps stdout
7
7
  [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
8
8
  [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
9
9
 
10
- > **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
11
- > development against a single pinned Effect v4 prerelease. Packages graduate to
12
- > `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
13
- > exactly the ones the kit is built and tested against, install
10
+ > **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
11
+ > Effect v4 (`effect` `^4.0.0`) and still in `0.x` development. Stable Effect
12
+ > makes a kit `1.0.0` possible, not automatic. To keep your `effect` and
13
+ > `@effect/*` versions on the line the kit is built and tested against, install
14
14
  > [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
15
15
  >
16
16
  > **Stability: unstable.** This package's API surface is not yet considered
package/ToolFailure.js CHANGED
@@ -26,6 +26,9 @@ const isHighSurrogate = (code) => code >= 55296 && code <= 56319;
26
26
  *
27
27
  * @example
28
28
  * ```ts
29
+ * import { ToolFailure } from "@effected/mcp";
30
+ * import { Schema } from "effect";
31
+ *
29
32
  * class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { ...ToolFailure.fields, id: Schema.String }) {}
30
33
  * const remediation = { hint: "List the ids first.", suggestedTool: "list_things" }
31
34
  * new NotFound({ id, remediation, message: ToolFailure.message(`No thing "${ToolFailure.truncate(id)}".`, remediation) })
package/ToolRefusal.js CHANGED
@@ -14,11 +14,27 @@ import { Schema } from "effect";
14
14
  * {@link ToolRefusal.refuse}; its message already carries the remediation,
15
15
  * because a declared `Error` failure sends `message` and nothing else.
16
16
  *
17
- * Audit every throw path when porting a tool: any failure left undeclared
18
- * loses its message on the wire, however carefully it was worded.
17
+ * Audit every throw path of a tool: any failure left undeclared loses its
18
+ * message on the wire, however carefully it was worded.
19
+ *
20
+ * **Structured data on a refusal is deliberately unreachable: put it in the
21
+ * message.** Core's `McpServer.registerToolkit` builds every failed call's
22
+ * result with `structuredContent` unset and no `_meta`, on every protocol
23
+ * revision. Under the default `failureMode: "error"` a declared `Error`
24
+ * failure sends `message` as the only text; under `failureMode: "return"`
25
+ * it sends the whole encoded failure JSON-stringified into the text, still
26
+ * with no `structuredContent`, which buries the human message in JSON a
27
+ * client must parse. So `refuse` takes no data argument: a list of valid
28
+ * names, the root that was searched, the id that was not found all belong
29
+ * in `reason`, truncated with {@link ToolFailure.truncate}. When an agent
30
+ * must branch on the data itself, return it on the success channel as an
31
+ * `ok: false` envelope, where `structuredContent` does reach the client.
19
32
  *
20
33
  * @example
21
34
  * ```ts
35
+ * import { ToolFailure, ToolRefusal } from "@effected/mcp";
36
+ * import { Tool } from "effect/ai";
37
+ *
22
38
  * const Lookup = Tool.make("lookup", { parameters, success, failure: ToolRefusal })
23
39
  * // in the handler:
24
40
  * return yield* ToolRefusal.refuse(`No run "${ToolFailure.truncate(id)}".`, { hint: "List runs first.", suggestedTool: "list_runs" })
package/guard.d.ts CHANGED
@@ -89,6 +89,13 @@ interface McpGuardRunOptions<ROut, E> {
89
89
  * serving, where `"exitBeforeConnect"` logs and keeps serving. A throw
90
90
  * from a test double's `exit` there is dropped.
91
91
  *
92
+ * **The `"connected"` report is asynchronous.** It is raised on a later
93
+ * tick, a `setTimeout(0)` after the server starts serving, so it can land
94
+ * after the first responses a test reads: reading stderr once, right
95
+ * after the `initialize` or `tools/list` response, can see an empty
96
+ * buffer. Wait for it with `McpProcess.stderrUntil` from
97
+ * `@effected/mcp/testing` rather than reading it once.
98
+ *
92
99
  * Either is raised only through `host.emit`, on a `setTimeout(0)` tick,
93
100
  * never as a real throw or rejection, so the guard's listeners handle it
94
101
  * the same way under the real `process` and under a test double. Under
@@ -108,10 +115,14 @@ interface McpGuardRunOptions<ROut, E> {
108
115
  * module graph is loaded. Imported from `@effected/mcp/guard`.
109
116
  *
110
117
  * @remarks
111
- * {@link McpGuard.run} registers `uncaughtException` and `unhandledRejection`
112
- * listeners on `host`, then awaits `load()`, then launches the server with
113
- * `McpStdio.launch` and `McpStdio.teardown` under the `runMain` it returned.
114
- * This entrypoint has no static runtime import: it loads `effect` and the
118
+ * {@link McpGuard.run} is `ProcessGuard.run` from `@effected/engine/guard`
119
+ * with an MCP launch in its `load`: it registers `uncaughtException` and
120
+ * `unhandledRejection` listeners on `host`, then awaits `load()`, then
121
+ * launches the server with `McpStdio.launch` and `McpStdio.teardown` under
122
+ * the `runMain` it returned. A server on another transport (an LSP over
123
+ * `vscode-languageserver`, say) uses `ProcessGuard.run` directly and calls
124
+ * its `markConnected` itself. This entrypoint statically imports only
125
+ * `@effected/engine/guard`, which imports nothing: it loads `effect` and the
115
126
  * stdio wiring only after the guards are listening, so a throw while any of
116
127
  * it evaluates is still reported on stderr.
117
128
  *
@@ -149,6 +160,18 @@ interface McpGuardRunOptions<ROut, E> {
149
160
  */
150
161
  export declare class McpGuard {
151
162
  private constructor();
163
+ /**
164
+ * Parse a test-only crash-injection setting into {@link McpGuardRunOptions.injectCrash}: `<at>:<kind>`, where `at`
165
+ * is `"load"` or `"connected"` and `kind` is `"uncaughtException"` or `"unhandledRejection"`. Anything else, or no
166
+ * value, is `undefined`. The same grammar as `ProcessGuard.parseInjectCrash` from `@effected/engine/guard`, which
167
+ * this is, so an MCP launcher need not import the engine guard for it.
168
+ *
169
+ * @example
170
+ * ```ts
171
+ * McpGuard.parseInjectCrash("load:uncaughtException"); // => { at: "load", kind: "uncaughtException" }
172
+ * ```
173
+ */
174
+ static readonly parseInjectCrash: (value: string | undefined) => McpGuardRunOptions<unknown, unknown>["injectCrash"];
152
175
  /** Install the guards, load the server, and launch it. Resolves once the server is launched. */
153
176
  static readonly run: <ROut, E>(options: McpGuardRunOptions<ROut, E>) => Promise<void>;
154
177
  }
package/index.d.ts CHANGED
@@ -74,10 +74,9 @@ export declare class McpStdio {
74
74
  * first, then the two newest stateful ones.
75
75
  *
76
76
  * @remarks
77
- * Real clients open with either handshake: Claude Code 2.1.281 was
78
- * measured opening a stdio server with the stateless `server/discover`,
79
- * while most other shipping clients still send `initialize`. The order
80
- * is load-bearing:
77
+ * Real clients open with either handshake: some (Claude Code, for one)
78
+ * open a stdio server with the stateless `server/discover`, while others
79
+ * still send `initialize`. The order is load-bearing:
81
80
  *
82
81
  * - Stateless first: a request with no session and no `_meta` falls to
83
82
  * `protocols[0]`.
@@ -97,6 +96,15 @@ export declare class McpStdio {
97
96
  * with `Layer.mergeAll` is not provided by it, so its build logs still
98
97
  * go through `console.log`, onto the wire.
99
98
  *
99
+ * `LogToStderr` only reaches a logger that reads it: Effect's default
100
+ * logger and `Logger.consolePretty`. `Logger.consoleJson`,
101
+ * `Logger.consoleLogFmt` and `Logger.consoleStructured` are built with
102
+ * `Logger.withConsoleLog`, which writes through `console.log` whatever
103
+ * `LogToStderr` says, so a server that installs one puts every log line on
104
+ * the wire. Never install them under this layer; wrap the formatter in
105
+ * `Logger.withConsoleError` instead, as in
106
+ * `Logger.layer([Logger.withConsoleError(Logger.formatJson)])`.
107
+ *
100
108
  * The server reads stdin through a guard that frames it exactly as core's
101
109
  * decoder does: one streaming UTF-8 decoder, a byte-order mark stripped
102
110
  * only at the start of the stream, lines split on `\n`. Core itself skips
@@ -429,6 +437,11 @@ export declare class McpToolkit {
429
437
  *
430
438
  * @example
431
439
  * ```ts
440
+ * import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
441
+ * import { Schema } from "effect";
442
+ * import { Tool } from "effect/ai";
443
+ *
444
+ * // `AddNote`, `ListNotes`, `Added`, `Listed` are object schemas; `Kit` is a Toolkit that includes `Note`.
432
445
  * const Note = McpToolkit.unionTool("note", {
433
446
  * parameters: Schema.Union([AddNote, ListNotes]),
434
447
  * success: ToolOutputSchema.objectRooted(Schema.Union([Added, Listed])),
@@ -488,6 +501,9 @@ export declare class McpToolkit {
488
501
  *
489
502
  * @example
490
503
  * ```ts
504
+ * import { ToolFailure } from "@effected/mcp";
505
+ * import { Schema } from "effect";
506
+ *
491
507
  * class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { ...ToolFailure.fields, id: Schema.String }) {}
492
508
  * const remediation = { hint: "List the ids first.", suggestedTool: "list_things" }
493
509
  * new NotFound({ id, remediation, message: ToolFailure.message(`No thing "${ToolFailure.truncate(id)}".`, remediation) })
@@ -594,11 +610,27 @@ declare const ToolRefusal_base: Schema.Class<ToolRefusal, Schema.TaggedStruct<"T
594
610
  * {@link ToolRefusal.refuse}; its message already carries the remediation,
595
611
  * because a declared `Error` failure sends `message` and nothing else.
596
612
  *
597
- * Audit every throw path when porting a tool: any failure left undeclared
598
- * loses its message on the wire, however carefully it was worded.
613
+ * Audit every throw path of a tool: any failure left undeclared loses its
614
+ * message on the wire, however carefully it was worded.
615
+ *
616
+ * **Structured data on a refusal is deliberately unreachable: put it in the
617
+ * message.** Core's `McpServer.registerToolkit` builds every failed call's
618
+ * result with `structuredContent` unset and no `_meta`, on every protocol
619
+ * revision. Under the default `failureMode: "error"` a declared `Error`
620
+ * failure sends `message` as the only text; under `failureMode: "return"`
621
+ * it sends the whole encoded failure JSON-stringified into the text, still
622
+ * with no `structuredContent`, which buries the human message in JSON a
623
+ * client must parse. So `refuse` takes no data argument: a list of valid
624
+ * names, the root that was searched, the id that was not found all belong
625
+ * in `reason`, truncated with {@link ToolFailure.truncate}. When an agent
626
+ * must branch on the data itself, return it on the success channel as an
627
+ * `ok: false` envelope, where `structuredContent` does reach the client.
599
628
  *
600
629
  * @example
601
630
  * ```ts
631
+ * import { ToolFailure, ToolRefusal } from "@effected/mcp";
632
+ * import { Tool } from "effect/ai";
633
+ *
602
634
  * const Lookup = Tool.make("lookup", { parameters, success, failure: ToolRefusal })
603
635
  * // in the handler:
604
636
  * return yield* ToolRefusal.refuse(`No run "${ToolFailure.truncate(id)}".`, { hint: "List runs first.", suggestedTool: "list_runs" })
@@ -7,15 +7,27 @@ import { Runtime } from "effect";
7
7
  * second time — through a logger that writes to stdout — and the original exit
8
8
  * code rides along for the teardown.
9
9
  *
10
+ * @remarks
11
+ * Both runtime markers are prototype getters rather than own fields, so a JSON
12
+ * or logger dump of the error does not carry them; the code lives in a private
13
+ * field.
14
+ *
10
15
  * @internal
11
16
  */
12
17
  var LaunchFailed = class extends Error {
13
- [Runtime.errorReported] = false;
14
- [Runtime.errorExitCode];
18
+ #code;
15
19
  constructor(code) {
16
20
  super(`the MCP server failed to launch (exit ${code})`);
17
21
  this.name = "LaunchFailed";
18
- this[Runtime.errorExitCode] = code;
22
+ this.#code = code;
23
+ }
24
+ /** Already reported on stderr: `runMain` must not report it again. */
25
+ get [Runtime.errorReported]() {
26
+ return false;
27
+ }
28
+ /** The exit code the launch failed with. */
29
+ get [Runtime.errorExitCode]() {
30
+ return this.#code;
19
31
  }
20
32
  };
21
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/mcp",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "The boundary layer of an effect/ai MCP server: stdio wiring that keeps stdout the wire, tool-failure shaping, strict-input walkers, and test clients",
6
6
  "keywords": [
@@ -47,8 +47,8 @@
47
47
  "./package.json": "./package.json"
48
48
  },
49
49
  "peerDependencies": {
50
- "@effected/engine": "^0.2.0",
51
- "effect": "4.0.0-rc.118"
50
+ "@effected/engine": "^0.4.0",
51
+ "effect": "^4.0.0"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.11.0"
package/testing.d.ts CHANGED
@@ -1,9 +1,11 @@
1
- import { Effect, JsonSchema, Layer, PlatformError, Schema, Scope, Stdio } from "effect";
1
+ import { Duration, Effect, JsonSchema, Layer, PlatformError, Schema, Scope, Stdio } from "effect";
2
2
  import { McpProtocol } from "effect/ai";
3
3
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
4
  //#region src/McpTestFailure.d.ts
5
5
  declare const McpTestFailure_base: Schema.Class<McpTestFailure, Schema.TaggedStruct<"McpTestFailure", {
6
- readonly reason: Schema.Literals<readonly ["StreamEnded", "ServerStopped", "NotJsonRpc", "NotInitialized", "ErrorResponse"]>;
6
+ /** Which failure occurred; see the list above for which client raises which. */
7
+ readonly reason: Schema.Literals<readonly ["StreamEnded", "ServerStopped", "NotJsonRpc", "NotInitialized", "ErrorResponse", "TimedOut"]>;
8
+ /** A human-readable description of what went wrong. */
7
9
  readonly message: Schema.String;
8
10
  }>, import("effect/Cause").YieldableError>;
9
11
  /**
@@ -15,7 +17,10 @@ declare const McpTestFailure_base: Schema.Class<McpTestFailure, Schema.TaggedStr
15
17
  *
16
18
  * - `StreamEnded` (`McpProcess`, `McpProbe`): the spawned child's stdout
17
19
  * ended first, because the child exited. `McpProbe` folds the exit code
18
- * and stderr into the message.
20
+ * and stderr into the message. `McpProcess.stderrUntil` raises it when
21
+ * stderr ends before its predicate holds.
22
+ * - `TimedOut` (`McpProcess.stderrUntil`): the predicate did not hold
23
+ * within the given timeout. The message carries the stderr seen so far.
19
24
  * - `NotJsonRpc` (`McpProcess.readUntilResponse`, `McpProbe`): a spawned
20
25
  * child's stdout line was not a JSON-RPC frame. `McpHarness` never raises
21
26
  * it: under `strictStdout` a non-JSON-RPC line DIES the pending wait as a
@@ -132,9 +137,11 @@ interface McpHarnessOptions {
132
137
  * once and pass `Layer.succeed(Tag, value)`.
133
138
  *
134
139
  * - Responses are matched by id, so notifications may interleave freely.
135
- * - No wait can hang: every response wait and `awaitOutboundMethod` fails
136
- * with `ServerStopped` when the server stops (stdin closing does that),
137
- * and dies when `strictStdout` sees a line that is not JSON-RPC.
140
+ * - Every response wait and `awaitOutboundMethod` fails with `ServerStopped`
141
+ * when the server stops, and dies when `strictStdout` sees a line that is
142
+ * not JSON-RPC. `stop` stops the server at once; `close` ends stdin and lets
143
+ * in-flight requests answer first, so only `stop` releases a wait on a
144
+ * request that never completes.
138
145
  * - With `captureLogs`, the server's console is captured: `stderrSoFar`
139
146
  * holds stderr writes and every log line, and `consoleLogSoFar` holds
140
147
  * anything that went through `console.log`, which in a real server is
@@ -148,6 +155,20 @@ interface McpHarnessOptions {
148
155
  * first (as in `McpStdio.protocols`), or `-32603 Internal error` when
149
156
  * only stateful revisions are served.
150
157
  *
158
+ * @example
159
+ * ```ts
160
+ * import { McpHarness } from "@effected/mcp/testing";
161
+ * import { Effect } from "effect";
162
+ *
163
+ * // `ServerLayer` is the server's layer, built without its own `Stdio`.
164
+ * const program = Effect.gen(function* () {
165
+ * const harness = yield* McpHarness.make(ServerLayer);
166
+ * yield* harness.initialize;
167
+ * const tools = yield* harness.listTools;
168
+ * return tools.map((tool) => tool.name);
169
+ * }).pipe(Effect.scoped);
170
+ * ```
171
+ *
151
172
  * @public
152
173
  */
153
174
  export declare class McpHarness {
@@ -200,8 +221,19 @@ export declare class McpHarness {
200
221
  readonly stderrSoFar: Effect.Effect<string>;
201
222
  /** Every captured `console.log`, `info` or `debug` call. */
202
223
  readonly consoleLogSoFar: Effect.Effect<ReadonlyArray<string>>;
203
- /** End stdin, as a client disconnecting does. */
224
+ /**
225
+ * End stdin, as a client disconnecting does. The server drains first:
226
+ * requests already in flight still answer, and the server stops once they
227
+ * have, so a wait on a request that never completes keeps waiting. Use
228
+ * {@link McpHarness.stop} to end the server regardless.
229
+ */
204
230
  readonly close: Effect.Effect<void>;
231
+ /**
232
+ * Interrupt the server without draining, as a client killing the process
233
+ * does: every pending response wait and `awaitOutboundMethod` fails with
234
+ * `ServerStopped`.
235
+ */
236
+ readonly stop: Effect.Effect<void>;
205
237
  private constructor();
206
238
  /** Build `server` over queue-backed stdio and return a client for it; fails with the layer's own error if it cannot build. */
207
239
  static readonly make: <ROut, E, R>(server: Layer.Layer<ROut, E, R>, options?: McpHarnessOptions) => Effect.Effect<McpHarness, E, Scope.Scope | Exclude<R, Stdio.Stdio>>;
@@ -238,9 +270,9 @@ interface McpProbeResult {
238
270
  *
239
271
  * @remarks
240
272
  * Stdin stays open until the id-1 response has arrived. Closing it right
241
- * after writing, as every hand-rolled smoke test did, makes an Effect server
242
- * drop the in-flight response and exit 0, so a slow boot reads as a pass with
243
- * no response. The caller asserts `response.error === undefined`, `stderr` is
273
+ * after writing proves nothing against a server that stops at stdin EOF
274
+ * without answering what is in flight: it replies nothing and exits 0, so a
275
+ * slow boot reads as a pass with no response. The caller asserts `response.error === undefined`, `stderr` is
244
276
  * empty and `exitCode` is 0; this is the MCP half of a packed-install proof.
245
277
  * Checking stderr and the exit code alone passes a server that answers
246
278
  * the handshake with a JSON-RPC error, exits 0 and writes nothing.
@@ -253,6 +285,20 @@ interface McpProbeResult {
253
285
  * - A child that ignores stdin EOF never exits, and the probe waits for it.
254
286
  * Wrap the probe in `Effect.timeout`.
255
287
  *
288
+ * @example
289
+ * ```ts
290
+ * import { McpProbe } from "@effected/mcp/testing";
291
+ * import { Effect } from "effect";
292
+ * import { ChildProcess } from "effect/process";
293
+ *
294
+ * const program = Effect.gen(function* () {
295
+ * const probe = yield* McpProbe.initialize(ChildProcess.make(process.execPath, ["./bin/server.js"]));
296
+ * // A clean boot: no JSON-RPC error, empty stderr, exit code 0.
297
+ * return probe.response.error === undefined && probe.stderr === "" && probe.exitCode === 0;
298
+ * });
299
+ * // Requires a `ChildProcessSpawner` from the platform layer.
300
+ * ```
301
+ *
256
302
  * @public
257
303
  */
258
304
  export declare class McpProbe {
@@ -266,6 +312,20 @@ export declare class McpProbe {
266
312
  }
267
313
  //#endregion
268
314
  //#region src/McpProcess.d.ts
315
+ /**
316
+ * Options for {@link McpProcess.stderrUntil}.
317
+ *
318
+ * @public
319
+ */
320
+ interface McpProcessStderrUntilOptions {
321
+ /**
322
+ * How long to wait for the predicate to hold before failing with
323
+ * `TimedOut`. Real time: under `it.effect`'s `TestClock` it never fires,
324
+ * so a test that waits on stderr runs under `it.live`. Keep it below the
325
+ * test runner's own timeout, or the runner kills the test first.
326
+ */
327
+ readonly timeout: Duration.Input;
328
+ }
269
329
  /**
270
330
  * A spawned MCP server bin a test writes to while it runs.
271
331
  *
@@ -282,8 +342,26 @@ export declare class McpProbe {
282
342
  * `list_changed`, to the matching id and returns what it saw.
283
343
  * - `closeStdin` is `Queue.end`, never `Queue.shutdown`, so every frame
284
344
  * already sent is delivered before stdin closes.
285
- * - A request in flight when stdin closes is dropped by an Effect server:
286
- * read its response first.
345
+ * - A request in flight when stdin closes still answers from an Effect
346
+ * server, which drains before it exits; a server that does not drain
347
+ * drops it, so a portable client reads the response first.
348
+ *
349
+ * @example
350
+ * ```ts
351
+ * import { McpProcess } from "@effected/mcp/testing";
352
+ * import { Effect } from "effect";
353
+ * import { ChildProcess } from "effect/process";
354
+ *
355
+ * const program = Effect.gen(function* () {
356
+ * const child = yield* McpProcess.spawn(ChildProcess.make(process.execPath, ["./bin/server.js"]));
357
+ * yield* child.handshake();
358
+ * yield* child.send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
359
+ * const { response } = yield* child.readUntilResponse(2);
360
+ * yield* child.closeStdin;
361
+ * return response;
362
+ * }).pipe(Effect.scoped);
363
+ * // Requires a `ChildProcessSpawner` from the platform layer.
364
+ * ```
287
365
  *
288
366
  * @public
289
367
  */
@@ -323,6 +401,22 @@ export declare class McpProcess {
323
401
  readonly exitCode: Effect.Effect<number, PlatformError.PlatformError>;
324
402
  /** Everything written to stderr so far. */
325
403
  readonly stderrSoFar: Effect.Effect<string>;
404
+ /**
405
+ * Wait until everything written to stderr so far satisfies `predicate`,
406
+ * and return that text.
407
+ *
408
+ * @remarks
409
+ * Event-driven: the predicate is checked against the current text at once,
410
+ * then again on every chunk the child writes, never on a polling timer.
411
+ * Fails with `StreamEnded` when stderr ends (the child exited) without the
412
+ * predicate holding, and with `TimedOut` once `options.timeout` passes;
413
+ * both messages carry the stderr seen so far. Reach for it whenever a
414
+ * report lands on a later tick than the responses a test reads, such as
415
+ * `McpGuard`'s `injectCrash: { at: "connected" }` report, instead of
416
+ * reading `stderrSoFar` once. `options.timeout` is real time: run the test
417
+ * under `it.live`.
418
+ */
419
+ readonly stderrUntil: (predicate: (stderr: string) => boolean, options: McpProcessStderrUntilOptions) => Effect.Effect<string, McpTestFailure>;
326
420
  /** Everything written to stderr, once stderr has ended. Waits for the child to exit. */
327
421
  readonly stderrFinal: Effect.Effect<string>;
328
422
  private constructor();
@@ -349,7 +443,7 @@ interface McpToolAuditPolicy {
349
443
  readonly requireOutputSchema?: boolean | undefined;
350
444
  /**
351
445
  * A served `outputSchema` must be rooted at `type: "object"`. Defaults to
352
- * `true` (D10). Only the stateless revision serves a non-object root, so
446
+ * `true`. Only the stateless revision serves a non-object root, so
353
447
  * only there does this fire; a union root (`anyOf` or `oneOf`) names
354
448
  * `ToolOutputSchema.objectRooted` as the fix for a union whose members are
355
449
  * all objects (a union with a primitive member needs an object envelope
@@ -396,5 +490,5 @@ export declare class McpToolAudit {
396
490
  static readonly check: (tools: ReadonlyArray<ServedTool>, policy: McpToolAuditPolicy) => ReadonlyArray<string>;
397
491
  }
398
492
  //#endregion
399
- export type { JsonRpcMessage, McpHarnessOptions, McpProbeOptions, McpProbeResult, McpToolAuditPolicy, ServedResource, ServedTool };
493
+ export type { JsonRpcMessage, McpHarnessOptions, McpProbeOptions, McpProbeResult, McpProcessStderrUntilOptions, McpToolAuditPolicy, ServedResource, ServedTool };
400
494
  //# sourceMappingURL=testing.d.ts.map