@effected/mcp 0.4.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
@@ -109,8 +111,19 @@ var McpHarness = class McpHarness {
109
111
  stderrSoFar;
110
112
  /** Every captured `console.log`, `info` or `debug` call. */
111
113
  consoleLogSoFar;
112
- /** 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
+ */
113
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;
114
127
  constructor(parts) {
115
128
  this.protocol = parts.protocol;
116
129
  this.initialize = parts.initialize;
@@ -129,6 +142,7 @@ var McpHarness = class McpHarness {
129
142
  this.stderrSoFar = parts.stderrSoFar;
130
143
  this.consoleLogSoFar = parts.consoleLogSoFar;
131
144
  this.close = parts.close;
145
+ this.stop = parts.stop;
132
146
  }
133
147
  /** Build `server` over queue-backed stdio and return a client for it; fails with the layer's own error if it cannot build. */
134
148
  static make = (server, options = {}) => Effect.gen(function* () {
@@ -186,11 +200,11 @@ var McpHarness = class McpHarness {
186
200
  }
187
201
  return Effect.asVoid(Queue.offer(inbound, parsed));
188
202
  };
189
- 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);
190
- 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({
191
205
  reason: "ServerStopped",
192
206
  message: "the server stopped before it responded"
193
- })))), Effect.forkScoped);
207
+ })))))), Effect.forkScoped);
194
208
  yield* Deferred.await(ready);
195
209
  const stopAware = (wait) => Effect.raceAllFirst([
196
210
  wait,
@@ -277,7 +291,8 @@ var McpHarness = class McpHarness {
277
291
  sentSoFar: Effect.sync(() => [...sent]),
278
292
  stderrSoFar: Effect.sync(() => stderr.join("")),
279
293
  consoleLogSoFar: Effect.sync(() => [...consoleLog]),
280
- close: Effect.asVoid(Queue.end(stdin))
294
+ close: Effect.asVoid(Queue.end(stdin)),
295
+ stop: Fiber.interrupt(serverFiber)
281
296
  });
282
297
  });
283
298
  };
package/McpProbe.js CHANGED
@@ -12,8 +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 makes an Effect server drop the in-flight response and exit 0, so a slow boot reads as a pass with
16
- * 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
17
18
  * empty and `exitCode` is 0; this is the MCP half of a packed-install proof.
18
19
  * Checking stderr and the exit code alone passes a server that answers
19
20
  * the handshake with a JSON-RPC error, exits 0 and writes nothing.
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,9 @@ 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.
27
28
  *
28
29
  * @example
29
30
  * ```ts
@@ -77,6 +78,22 @@ var McpProcess = class McpProcess {
77
78
  exitCode;
78
79
  /** Everything written to stderr so far. */
79
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;
80
97
  /** Everything written to stderr, once stderr has ended. Waits for the child to exit. */
81
98
  stderrFinal;
82
99
  constructor(parts) {
@@ -88,6 +105,7 @@ var McpProcess = class McpProcess {
88
105
  this.closeStdin = parts.closeStdin;
89
106
  this.exitCode = parts.exitCode;
90
107
  this.stderrSoFar = parts.stderrSoFar;
108
+ this.stderrUntil = parts.stderrUntil;
91
109
  this.stderrFinal = parts.stderrFinal;
92
110
  }
93
111
  /** Spawn `command` for the life of the current scope. */
@@ -99,9 +117,35 @@ var McpProcess = class McpProcess {
99
117
  yield* Stream.run(Stream.fromQueue(stdin), handle.stdin).pipe(Effect.catch((error) => Ref.set(stdinFailure, error.message)), Effect.forkScoped);
100
118
  const lines = yield* Queue.unbounded();
101
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);
102
- const stderr = yield* Ref.make("");
120
+ const stderr = yield* SubscriptionRef.make({
121
+ text: "",
122
+ done: false
123
+ });
103
124
  const stderrDone = yield* Deferred.make();
104
- 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
+ }));
105
149
  const nextLine = Queue.take(lines).pipe(Effect.catch(() => Effect.flatMap(Ref.get(stdinFailure), (pump) => Effect.fail(new McpTestFailure({
106
150
  reason: "StreamEnded",
107
151
  message: `the server's stdout ended before the expected line${STDERR_HINT}${pump === void 0 ? "" : `; stdin pump failed: ${ToolFailure.truncate(pump)}`}`
@@ -142,8 +186,9 @@ var McpProcess = class McpProcess {
142
186
  handshake,
143
187
  closeStdin: Effect.asVoid(Queue.end(stdin)),
144
188
  exitCode: Effect.map(handle.exitCode, (code) => Number(code)),
145
- stderrSoFar: Ref.get(stderr),
146
- stderrFinal: Effect.andThen(Deferred.await(stderrDone), Ref.get(stderr))
189
+ stderrSoFar: stderrText,
190
+ stderrUntil,
191
+ stderrFinal: Effect.andThen(Deferred.await(stderrDone), stderrText)
147
192
  });
148
193
  });
149
194
  };
package/McpStdio.js CHANGED
@@ -64,6 +64,16 @@ var McpStdio = class McpStdio {
64
64
  * `Layer.provideMerge(McpStdio.layer(...))`. A sibling merged beside it
65
65
  * with `Layer.mergeAll` is not provided by it, so its build logs still
66
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)])`.
67
77
  *
68
78
  * The server reads stdin through a guard that frames it exactly as core's
69
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
@@ -36,7 +39,8 @@ var McpTestFailure = class extends Schema.TaggedError()("McpTestFailure", {
36
39
  "ServerStopped",
37
40
  "NotJsonRpc",
38
41
  "NotInitialized",
39
- "ErrorResponse"
42
+ "ErrorResponse",
43
+ "TimedOut"
40
44
  ]),
41
45
  /** A human-readable description of what went wrong. */
42
46
  message: Schema.String
package/ToolRefusal.js CHANGED
@@ -17,6 +17,19 @@ import { Schema } from "effect";
17
17
  * Audit every throw path of a tool: any failure left undeclared loses its
18
18
  * message on the wire, however carefully it was worded.
19
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.
32
+ *
20
33
  * @example
21
34
  * ```ts
22
35
  * import { ToolFailure, ToolRefusal } from "@effected/mcp";
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
@@ -96,6 +96,15 @@ export declare class McpStdio {
96
96
  * with `Layer.mergeAll` is not provided by it, so its build logs still
97
97
  * go through `console.log`, onto the wire.
98
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
+ *
99
108
  * The server reads stdin through a guard that frames it exactly as core's
100
109
  * decoder does: one streaming UTF-8 decoder, a byte-order mark stripped
101
110
  * only at the start of the stream, lines split on `\n`. Core itself skips
@@ -604,6 +613,19 @@ declare const ToolRefusal_base: Schema.Class<ToolRefusal, Schema.TaggedStruct<"T
604
613
  * Audit every throw path of a tool: any failure left undeclared loses its
605
614
  * message on the wire, however carefully it was worded.
606
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.
628
+ *
607
629
  * @example
608
630
  * ```ts
609
631
  * import { ToolFailure, ToolRefusal } from "@effected/mcp";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/mcp",
3
- "version": "0.4.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,7 +47,7 @@
47
47
  "./package.json": "./package.json"
48
48
  },
49
49
  "peerDependencies": {
50
- "@effected/engine": "^0.3.0",
50
+ "@effected/engine": "^0.4.0",
51
51
  "effect": "^4.0.0"
52
52
  },
53
53
  "engines": {
package/testing.d.ts CHANGED
@@ -1,10 +1,10 @@
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
6
  /** Which failure occurred; see the list above for which client raises which. */
7
- readonly reason: Schema.Literals<readonly ["StreamEnded", "ServerStopped", "NotJsonRpc", "NotInitialized", "ErrorResponse"]>;
7
+ readonly reason: Schema.Literals<readonly ["StreamEnded", "ServerStopped", "NotJsonRpc", "NotInitialized", "ErrorResponse", "TimedOut"]>;
8
8
  /** A human-readable description of what went wrong. */
9
9
  readonly message: Schema.String;
10
10
  }>, import("effect/Cause").YieldableError>;
@@ -17,7 +17,10 @@ declare const McpTestFailure_base: Schema.Class<McpTestFailure, Schema.TaggedStr
17
17
  *
18
18
  * - `StreamEnded` (`McpProcess`, `McpProbe`): the spawned child's stdout
19
19
  * ended first, because the child exited. `McpProbe` folds the exit code
20
- * 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.
21
24
  * - `NotJsonRpc` (`McpProcess.readUntilResponse`, `McpProbe`): a spawned
22
25
  * child's stdout line was not a JSON-RPC frame. `McpHarness` never raises
23
26
  * it: under `strictStdout` a non-JSON-RPC line DIES the pending wait as a
@@ -134,9 +137,11 @@ interface McpHarnessOptions {
134
137
  * once and pass `Layer.succeed(Tag, value)`.
135
138
  *
136
139
  * - Responses are matched by id, so notifications may interleave freely.
137
- * - No wait can hang: every response wait and `awaitOutboundMethod` fails
138
- * with `ServerStopped` when the server stops (stdin closing does that),
139
- * 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.
140
145
  * - With `captureLogs`, the server's console is captured: `stderrSoFar`
141
146
  * holds stderr writes and every log line, and `consoleLogSoFar` holds
142
147
  * anything that went through `console.log`, which in a real server is
@@ -216,8 +221,19 @@ export declare class McpHarness {
216
221
  readonly stderrSoFar: Effect.Effect<string>;
217
222
  /** Every captured `console.log`, `info` or `debug` call. */
218
223
  readonly consoleLogSoFar: Effect.Effect<ReadonlyArray<string>>;
219
- /** 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
+ */
220
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>;
221
237
  private constructor();
222
238
  /** Build `server` over queue-backed stdio and return a client for it; fails with the layer's own error if it cannot build. */
223
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>>;
@@ -254,8 +270,9 @@ interface McpProbeResult {
254
270
  *
255
271
  * @remarks
256
272
  * Stdin stays open until the id-1 response has arrived. Closing it right
257
- * after writing makes an Effect server drop the in-flight response and exit 0, so a slow boot reads as a pass with
258
- * 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
259
276
  * empty and `exitCode` is 0; this is the MCP half of a packed-install proof.
260
277
  * Checking stderr and the exit code alone passes a server that answers
261
278
  * the handshake with a JSON-RPC error, exits 0 and writes nothing.
@@ -295,6 +312,20 @@ export declare class McpProbe {
295
312
  }
296
313
  //#endregion
297
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
+ }
298
329
  /**
299
330
  * A spawned MCP server bin a test writes to while it runs.
300
331
  *
@@ -311,8 +342,9 @@ export declare class McpProbe {
311
342
  * `list_changed`, to the matching id and returns what it saw.
312
343
  * - `closeStdin` is `Queue.end`, never `Queue.shutdown`, so every frame
313
344
  * already sent is delivered before stdin closes.
314
- * - A request in flight when stdin closes is dropped by an Effect server:
315
- * 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.
316
348
  *
317
349
  * @example
318
350
  * ```ts
@@ -369,6 +401,22 @@ export declare class McpProcess {
369
401
  readonly exitCode: Effect.Effect<number, PlatformError.PlatformError>;
370
402
  /** Everything written to stderr so far. */
371
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>;
372
420
  /** Everything written to stderr, once stderr has ended. Waits for the child to exit. */
373
421
  readonly stderrFinal: Effect.Effect<string>;
374
422
  private constructor();
@@ -442,5 +490,5 @@ export declare class McpToolAudit {
442
490
  static readonly check: (tools: ReadonlyArray<ServedTool>, policy: McpToolAuditPolicy) => ReadonlyArray<string>;
443
491
  }
444
492
  //#endregion
445
- export type { JsonRpcMessage, McpHarnessOptions, McpProbeOptions, McpProbeResult, McpToolAuditPolicy, ServedResource, ServedTool };
493
+ export type { JsonRpcMessage, McpHarnessOptions, McpProbeOptions, McpProbeResult, McpProcessStderrUntilOptions, McpToolAuditPolicy, ServedResource, ServedTool };
446
494
  //# sourceMappingURL=testing.d.ts.map