@effected/mcp 0.11.0 → 0.13.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/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 };
package/McpStdio.js DELETED
@@ -1,159 +0,0 @@
1
- import { LaunchFailed } from "./internal/LaunchFailed.js";
2
- import { makeGuardedStdio } from "./internal/StdinFrames.js";
3
- import { Cause, Effect, Exit, Layer, References, Runtime } from "effect";
4
- import { McpProtocol, McpServer } from "effect/ai";
5
-
6
- // src/McpStdio.ts
7
- /**
8
- * Serve an MCP server over stdio without ever writing a log line or a failure
9
- * report onto stdout, which is the JSON-RPC wire.
10
- *
11
- * @remarks
12
- * The one-line `main.ts`:
13
- *
14
- * ```ts
15
- * NodeRuntime.runMain(McpStdio.launch(Main), { teardown: McpStdio.teardown })
16
- * ```
17
- *
18
- * - {@link McpStdio.layer} is `McpServer.layerStdio` with `LogToStderr`
19
- * provided and merged into its output, and `Layer.orDie`. It answers a
20
- * stdin line that is not JSON with a JSON-RPC `-32700` parse error, and
21
- * JSON that is not a JSON-RPC message with `-32600`, and keeps serving.
22
- * - {@link McpStdio.launch} reports a launch failure itself, on stderr, and
23
- * hides it from `runMain`, whose own report is written outside anything
24
- * the program can provide.
25
- * - {@link McpStdio.teardown} maps stdin EOF, the normal end of every
26
- * session, to exit 0 instead of 130.
27
- *
28
- * Never `NodeRuntime.runMain(Layer.launch(Main).pipe(Effect.provideService(References.LogToStderr, true)))`:
29
- * it typechecks and serves, but a launch failure is still reported by
30
- * `runMain` through `console.log`, straight onto the wire, where the client
31
- * reads it as a malformed frame instead of an error.
32
- *
33
- * @public
34
- */
35
- var McpStdio = class McpStdio {
36
- constructor() {}
37
- /**
38
- * `[v2026_07_28, v2025_11_25, v2025_06_18]` — the stateless revision
39
- * first, then the two newest stateful ones.
40
- *
41
- * @remarks
42
- * Real clients open with either handshake: 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:
45
- *
46
- * - Stateless first: a request with no session and no `_meta` falls to
47
- * `protocols[0]`.
48
- * - Never a single entry: `initialize` only matches stateful adapters, so
49
- * a stateless-only list refuses every client that opens with it.
50
- * - At most one stateless adapter; a second fails the layer.
51
- */
52
- static protocols = [
53
- McpProtocol.v2026_07_28,
54
- McpProtocol.v2025_11_25,
55
- McpProtocol.v2025_06_18
56
- ];
57
- /**
58
- * `McpServer.layerStdio` with `LogToStderr` provided and merged into the
59
- * output, so every layer it provides logs to stderr too. A bad
60
- * `protocols` list dies: it is the implementer's defect.
61
- *
62
- * @remarks
63
- * Only a layer this one provides inherits `LogToStderr`: compose with
64
- * `Layer.provideMerge(McpStdio.layer(...))`. A sibling merged beside it
65
- * with `Layer.mergeAll` is not provided by it, so its build logs still
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)])`.
77
- *
78
- * The server reads stdin through a guard that frames it exactly as core's
79
- * decoder does: one streaming UTF-8 decoder, a byte-order mark stripped
80
- * only at the start of the stream, lines split on `\n`. Core itself skips
81
- * a line it cannot use and keeps serving, but sends no reply, where
82
- * JSON-RPC 2.0 requires one; the guard supplies it. A line that is not
83
- * JSON is answered on stdout with a parse error, code `-32700` and
84
- * `id: null`, and never reaches core. So is a line longer than core's cap
85
- * of 16 Mi UTF-16 code units: it is answered once, as soon as it passes
86
- * the cap, and the rest of it is discarded up to its newline (core would
87
- * log it on stderr and drop it). A line of JSON whitespace (space, tab,
88
- * carriage return) is ignored.
89
- *
90
- * A line that is JSON but no JSON-RPC message core can handle is answered
91
- * with an Invalid Request, code `-32600` and `id: null`, and never reaches
92
- * core: a value that is neither an object nor an array, an object whose
93
- * `method` is not a string and whose `id` is absent or `null`, and an
94
- * object with neither `method` nor `id`, which is neither a request nor a
95
- * response.
96
- *
97
- * Everything else goes to core: an array, which core answers `-32600`
98
- * itself because it serves no batches; an object with an `id` and no
99
- * `method`, which is a response and gets no reply; and a request with an
100
- * `id`, which core answers even when its `method` is malformed.
101
- *
102
- * Give each server a fresh layer memo map. Core's stdio protocol layer
103
- * is a shared constant, so a second `McpStdio.layer` server whose build
104
- * sees the first one's memo map shares its protocol: only the first
105
- * server reads stdin, and the second never answers. Merging both into
106
- * one graph does that, and so does building or providing the second
107
- * anywhere under the first one's `Effect.provide`: nested `Layer.build`
108
- * and `Effect.provide` fork the ambient memo map rather than starting a
109
- * new one. The tool registry is shared the same way. Isolate each server
110
- * by wrapping its whole bundle (its toolkit layers together with this
111
- * layer) in `Layer.fresh`, or with its own `ManagedRuntime`,
112
- * `Effect.provide(layer, { local: true })` or its own process. Never put
113
- * the `Layer.fresh` boundary between a toolkit and this layer: the server
114
- * would serve an empty registry.
115
- *
116
- * Code after a completed `Effect.provide` of a stdio server never runs:
117
- * core's stdio protocol interrupts the fiber that built it when its stdin
118
- * loop ends, which closing the provide's scope does. In a test, serve the
119
- * layer through `McpHarness` from `@effected/mcp/testing` instead.
120
- *
121
- * Each call mints a fresh layer; bind the result to a `const` or the
122
- * server builds twice.
123
- */
124
- static layer = (options) => McpServer.layerStdio({
125
- name: options.name,
126
- version: options.version,
127
- instructions: options.instructions,
128
- description: options.description,
129
- protocols: options.protocols ?? McpStdio.protocols
130
- }).pipe(Layer.provide(makeGuardedStdio()), Layer.provideMerge(Layer.succeed(References.LogToStderr, true)), Layer.orDie);
131
- /**
132
- * `Layer.launch`, with any failure other than an interrupt logged on
133
- * stderr here and re-raised marked as already reported, keeping its exit
134
- * code.
135
- *
136
- * @remarks
137
- * `runMain` logs an unhandled failure from outside the program, where no
138
- * `Effect.provideService` reaches, and Effect's default logger writes
139
- * through `console.log` unless `LogToStderr` is set. For an MCP server
140
- * that is stdout, which is the wire.
141
- *
142
- * `options.onReady` runs after the layer has built and before the launch
143
- * waits forever, as {@link McpLaunchOptions.onReady} describes.
144
- */
145
- static launch = (layer, options = {}) => (options.onReady === void 0 ? Layer.launch(layer) : Effect.scoped(Layer.build(layer).pipe(Effect.andThen(options.onReady), Effect.andThen(Effect.never)))).pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logError(cause).pipe(Effect.andThen(Effect.fail(new LaunchFailed(Runtime.getErrorExitCode(Cause.squash(cause))))))), Effect.provideService(References.LogToStderr, true));
146
- /**
147
- * Success or an interrupt-only exit maps to 0. Anything else goes to
148
- * `Runtime.defaultTeardown`.
149
- *
150
- * @remarks
151
- * Stdin EOF, the normal end of every session, ends in an interrupt-only
152
- * exit, and so do SIGINT and SIGTERM: `runMain` interrupts the program on
153
- * either signal. All three exit 0 here, not the 130 a default teardown
154
- * reports for an interrupt.
155
- */
156
- static teardown = (exit, onExit) => Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) ? onExit(0) : Runtime.defaultTeardown(exit, onExit);
157
- };
158
-
159
- export { McpStdio };
package/McpTestFailure.js DELETED
@@ -1,49 +0,0 @@
1
- import { Schema } from "effect";
2
-
3
- // src/McpTestFailure.ts
4
- /**
5
- * Why an MCP test client could not produce what was asked of it.
6
- *
7
- * @remarks
8
- * Each reason is raised by a specific client, and not every client raises
9
- * every reason:
10
- *
11
- * - `StreamEnded` (`McpProcess`, `McpProbe`): the spawned child's stdout
12
- * ended first, because the child exited. `McpProbe` folds the exit code
13
- * and stderr into the message. `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.
17
- * - `NotJsonRpc` (`McpProcess.readUntilResponse`, `McpProbe`): a spawned
18
- * child's stdout line was not a JSON-RPC frame. `McpHarness` never raises
19
- * it: under `strictStdout` a non-JSON-RPC line DIES the pending wait as a
20
- * defect instead, so assert it with `Effect.exit` and `Cause.hasDies`,
21
- * not `catchTag`.
22
- * - `ServerStopped` (`McpHarness`): the in-process server stopped, usually
23
- * because stdin closed while the request was in flight.
24
- * - `NotInitialized` (`McpHarness`): a request other than `initialize` or
25
- * `ping` was made on a stateful revision before `initialize` was sent. The request
26
- * is never written; the server would only have refused it opaquely:
27
- * `-32602 Invalid request metadata` when a stateless adapter is listed
28
- * first, `-32603 Internal error` when only stateful revisions are served.
29
- * - `ErrorResponse` (`McpHarness.listTools`): the server answered with a
30
- * JSON-RPC error where a result was required. Every other request method
31
- * returns the whole response, error included, as data.
32
- *
33
- * @public
34
- */
35
- var McpTestFailure = class extends Schema.TaggedError()("McpTestFailure", {
36
- /** Which failure occurred; see the list above for which client raises which. */
37
- reason: Schema.Literals([
38
- "StreamEnded",
39
- "ServerStopped",
40
- "NotJsonRpc",
41
- "NotInitialized",
42
- "ErrorResponse",
43
- "TimedOut"
44
- ]),
45
- /** A human-readable description of what went wrong. */
46
- message: Schema.String
47
- }) {};
48
-
49
- export { McpTestFailure };
package/McpToolAudit.js DELETED
@@ -1,109 +0,0 @@
1
- // src/McpToolAudit.ts
2
- const isNode = (u) => typeof u === "object" && u !== null && !Array.isArray(u);
3
- const HINTS = [
4
- "readOnlyHint",
5
- "destructiveHint",
6
- "idempotentHint",
7
- "openWorldHint"
8
- ];
9
- /** Real branches whose members are separate object shapes, each worth its own report. `allOf` is not one of these: core emits it to merge keywords (declared `properties` plus a Record's `additionalProperties`) onto the SAME node, never to introduce a sibling shape. */
10
- const BRANCHES = [
11
- "prefixItems",
12
- "anyOf",
13
- "oneOf"
14
- ];
15
- const objectNodes = (schema) => {
16
- const out = [];
17
- const join = (path, key) => path === "" ? key : `${path}.${key}`;
18
- const visit = (node, path) => {
19
- if (!isNode(node)) return;
20
- const merged = Array.isArray(node.allOf) ? [node, ...node.allOf.filter(isNode)] : [node];
21
- const declaredParts = merged.filter((part) => isNode(part.properties));
22
- const properties = declaredParts.length === 0 ? void 0 : declaredParts.reduce((acc, part) => {
23
- for (const [key, child] of Object.entries(part.properties)) acc[key] = child;
24
- return acc;
25
- }, Object.create(null));
26
- const closed = merged.some((part) => part.additionalProperties === false);
27
- const droppedKeyCheck = merged.some((part) => isNode(part.additionalProperties) && isNode(part.propertyNames));
28
- if (isNode(properties) || merged.some((part) => part.type === "object")) out.push({
29
- path,
30
- properties,
31
- closed,
32
- droppedKeyCheck
33
- });
34
- if (isNode(properties)) for (const [key, child] of Object.entries(properties)) visit(child, join(path, key));
35
- visit(node.items, join(path, "items"));
36
- visit(node.additionalProperties, join(path, "additionalProperties"));
37
- for (const keyword of BRANCHES) {
38
- const list = node[keyword];
39
- if (Array.isArray(list)) for (const [index, child] of list.entries()) visit(child, join(path, `${keyword}[${index}]`));
40
- }
41
- if (isNode(node.$defs)) for (const [key, child] of Object.entries(node.$defs)) visit(child, `$defs.${key}`);
42
- };
43
- visit(schema, "");
44
- return out;
45
- };
46
- /**
47
- * A pure sweep over a served `tools/list` that returns every policy
48
- * violation, as `"<tool>: <what>"` strings.
49
- *
50
- * @remarks
51
- * Run it against what a real server serves (`McpHarness.listTools`), not
52
- * against your own schemas: the served document is what a client sees, and
53
- * it differs by protocol revision. The stateless `2026-07-28` adapter passes a
54
- * non-object output through, while the stateful adapters drop it — which is
55
- * why `objectRootedOutput` defaults to `true`.
56
- *
57
- * `"closed"` is only as sound as the walk. It follows `properties`,
58
- * `items`, a schema-valued `additionalProperties`, `prefixItems`, `anyOf`,
59
- * `oneOf` and `$defs`, and merges an `allOf` member's `properties` and
60
- * `additionalProperties: false` onto its node. It does not visit
61
- * `patternProperties` values, an `allOf` member's `items` or
62
- * `additionalProperties` schema, or an `allOf` nested inside another; and
63
- * a key declared in two `allOf` members is last-write-wins. A hand-authored
64
- * or `Tool.dynamic` schema can therefore pass `"closed"` with an open node
65
- * the walk never reached. Core-emitted strict schemas close every node, so
66
- * a strict `Tool.make` tool is reported faithfully.
67
- *
68
- * @public
69
- */
70
- var McpToolAudit = class {
71
- constructor() {}
72
- /**
73
- * Every violation of `policy` across `tools`, as `"<tool>: <what>"`, in
74
- * tool order. Empty means the sweep passed. A duplicate tool name is
75
- * reported under every policy, `input: "any"` included.
76
- */
77
- static check = (tools, policy) => {
78
- const violations = [];
79
- const seen = /* @__PURE__ */ new Set();
80
- for (const tool of tools) {
81
- const report = (what) => {
82
- violations.push(`${tool.name}: ${what}`);
83
- };
84
- if (seen.has(tool.name)) report("duplicate tool name");
85
- seen.add(tool.name);
86
- if (policy.input !== "any") for (const { path, properties, closed, droppedKeyCheck } of objectNodes(tool.inputSchema)) {
87
- const where = path === "" ? "the root" : path;
88
- if (policy.input === "closed" && !closed) report(`input schema is open at ${where}${droppedKeyCheck ? " (a Record whose key check was not served; a key pattern is served closed, as patternProperties, only when its RegExp has the u flag, e.g. Schema.isPattern(/^x-/u))" : ""}`);
89
- if (policy.input === "open" && isNode(properties) && closed) report(`input schema is closed at ${where}`);
90
- }
91
- const title = tool.title ?? tool.annotations?.title;
92
- if (policy.requireTitle === true && (typeof title !== "string" || title === "")) report("no title");
93
- if (policy.requireOutputSchema === true && tool.outputSchema === void 0) report("no outputSchema (a union success schema is dropped on stateful revisions; see ToolOutputSchema.objectRooted)");
94
- if ((policy.objectRootedOutput ?? true) && tool.outputSchema !== void 0 && tool.outputSchema.type !== "object") {
95
- const union = Array.isArray(tool.outputSchema.anyOf) || Array.isArray(tool.outputSchema.oneOf);
96
- report(`outputSchema is not object-rooted (root type: ${String(tool.outputSchema.type ?? "none")})${union ? "; if every union member is an object, wrap the union success schema in ToolOutputSchema.objectRooted" : ""}`);
97
- }
98
- const length = (tool.description ?? "").length;
99
- if (policy.maxDescription !== void 0 && length > policy.maxDescription) report(`description is ${length} characters, over the ${policy.maxDescription} limit`);
100
- if (policy.requireHints === true) {
101
- const missing = HINTS.filter((hint) => typeof tool.annotations?.[hint] !== "boolean");
102
- if (missing.length > 0) report(`missing annotation hints: ${missing.join(", ")}`);
103
- }
104
- }
105
- return violations;
106
- };
107
- };
108
-
109
- export { McpToolAudit };