@effected/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ import { Effect, Layer, Stdio, Stream } from "effect";
2
+ import { McpSchema } from "effect/unstable/ai";
3
+
4
+ //#region src/internal/StdinFrames.ts
5
+ /**
6
+ * The line-length cap core's stdio NDJSON decoder applies, in the same unit
7
+ * (UTF-16 code units of the decoded text) and with the same predicate
8
+ * (`length > cap`). The guard answers a longer line itself, so no line core
9
+ * would reject ever reaches core.
10
+ *
11
+ * @internal
12
+ */
13
+ const MAX_FRAME_LENGTH = 16777216;
14
+ /**
15
+ * The JSON-RPC 2.0 answer to a frame that is not JSON, newline-framed. The id
16
+ * is `null` because no id can be read from an unparseable frame.
17
+ *
18
+ * @internal
19
+ */
20
+ const PARSE_ERROR_FRAME = `${JSON.stringify({
21
+ jsonrpc: "2.0",
22
+ id: null,
23
+ error: {
24
+ code: McpSchema.PARSE_ERROR_CODE,
25
+ message: "Parse error"
26
+ }
27
+ })}\n`;
28
+ /**
29
+ * The JSON-RPC 2.0 answer to a frame that is JSON but no JSON-RPC message
30
+ * core can handle, newline-framed. The id is `null` because such a frame
31
+ * carries no usable id.
32
+ *
33
+ * @internal
34
+ */
35
+ const INVALID_REQUEST_FRAME = `${JSON.stringify({
36
+ jsonrpc: "2.0",
37
+ id: null,
38
+ error: {
39
+ code: McpSchema.INVALID_REQUEST_ERROR_CODE,
40
+ message: "Invalid Request"
41
+ }
42
+ })}\n`;
43
+ /** JSON's insignificant whitespace, minus the newline that ends the line. */
44
+ const BLANK = /^[ \t\r]*$/;
45
+ const isNullish = (value) => value === null || value === void 0;
46
+ /**
47
+ * The guard's answer to one non-blank line within the cap, or `undefined` to
48
+ * forward it to core.
49
+ *
50
+ * @remarks
51
+ * A frame that parses is forwarded unless core would throw on it or ignore
52
+ * it without a reply, which JSON-RPC 2.0 calls an Invalid Request:
53
+ *
54
+ * - a value that is neither an object nor an array: core throws on `null`,
55
+ * dropping every other frame in its chunk, and ignores a number, string or
56
+ * boolean without a reply;
57
+ * - an object with a `method` that is not a string and no usable `id`: core
58
+ * throws on it, dropping the rest of its chunk (with an `id`, core answers
59
+ * `-32601` itself);
60
+ * - an object with neither `method` nor `id`: every JSON-RPC request carries a
61
+ * `method` and every response an `id`, so it is neither, and core ignores it
62
+ * as a response to nothing.
63
+ *
64
+ * Arrays are forwarded: core answers a batch `-32600` itself. So is any object
65
+ * with an `id` and no `method`, which is a response; JSON-RPC never answers a
66
+ * response.
67
+ */
68
+ const answerFor = (line) => {
69
+ let value;
70
+ try {
71
+ value = JSON.parse(line);
72
+ } catch {
73
+ return PARSE_ERROR_FRAME;
74
+ }
75
+ if (typeof value !== "object" || value === null) return INVALID_REQUEST_FRAME;
76
+ if (Array.isArray(value)) return void 0;
77
+ const message = value;
78
+ if (Object.hasOwn(message, "method")) return typeof message.method !== "string" && isNullish(message.id) ? INVALID_REQUEST_FRAME : void 0;
79
+ return Object.hasOwn(message, "id") ? void 0 : INVALID_REQUEST_FRAME;
80
+ };
81
+ /**
82
+ * A stateful step over stdin chunks that forwards only complete lines core's
83
+ * decoder will parse and answer, each with its newline.
84
+ *
85
+ * @remarks
86
+ * Core's stdio decoder parses each line inside its read loop. A line that is
87
+ * not JSON throws before the decoder advances past it, so the line stays at
88
+ * the head of its buffer and every later chunk throws on it again: the server
89
+ * never reads another frame. A line that is JSON but no JSON-RPC message core
90
+ * can handle either throws inside the decoder, dropping the frames after it in
91
+ * the same chunk, or is ignored without a reply. The guard frames stdin
92
+ * exactly as core does and answers such a line itself, returning
93
+ * {@link PARSE_ERROR_FRAME} or {@link INVALID_REQUEST_FRAME} for the caller
94
+ * to write.
95
+ *
96
+ * - Decoding mirrors core: one streaming UTF-8 decoder, so a character split
97
+ * across chunks decodes whole, a byte-order mark is stripped only at the
98
+ * start of the stream, and a U+FEFF opening any later line stays in that
99
+ * line, where `JSON.parse` rejects it as core's would.
100
+ * - A partial line is held until its newline arrives.
101
+ * - A line of JSON whitespace only is not a frame: dropped, not answered.
102
+ * - A line that is JSON is answered `-32600` when it is not an object or an
103
+ * array, when its `method` is not a string and it has no usable `id`, or
104
+ * when it has neither `method` nor `id`; everything else goes to core.
105
+ * - A line longer than `maxFrameLength` code units is answered once, as soon
106
+ * as the held part exceeds the cap, and the rest of it is discarded up to
107
+ * its newline. Nothing is held beyond the cap.
108
+ *
109
+ * Create one guard per stdin: its state must outlive core re-subscribing to
110
+ * stdin after any failure in its read loop, or a held partial line is lost.
111
+ *
112
+ * @internal
113
+ */
114
+ const makeFrameGuard = (maxFrameLength = MAX_FRAME_LENGTH) => {
115
+ const decoder = new TextDecoder();
116
+ const encoder = new TextEncoder();
117
+ let pending = "";
118
+ let discarding = false;
119
+ return (chunk) => {
120
+ const text = decoder.decode(chunk, { stream: true });
121
+ let forward = "";
122
+ const replies = [];
123
+ let start = 0;
124
+ if (discarding) {
125
+ const newline = text.indexOf("\n");
126
+ if (newline === -1) return {
127
+ forward: void 0,
128
+ replies
129
+ };
130
+ discarding = false;
131
+ start = newline + 1;
132
+ }
133
+ let newline = text.indexOf("\n", start);
134
+ while (newline !== -1) {
135
+ const line = pending + text.slice(start, newline);
136
+ pending = "";
137
+ start = newline + 1;
138
+ newline = text.indexOf("\n", start);
139
+ if (line.length > maxFrameLength) {
140
+ replies.push(PARSE_ERROR_FRAME);
141
+ continue;
142
+ }
143
+ if (BLANK.test(line)) continue;
144
+ const answer = answerFor(line);
145
+ if (answer === void 0) forward += `${line}\n`;
146
+ else replies.push(answer);
147
+ }
148
+ pending += text.slice(start);
149
+ if (pending.length > maxFrameLength) {
150
+ pending = "";
151
+ discarding = true;
152
+ replies.push(PARSE_ERROR_FRAME);
153
+ }
154
+ return {
155
+ forward: forward === "" ? void 0 : encoder.encode(forward),
156
+ replies
157
+ };
158
+ };
159
+ };
160
+ /**
161
+ * `Stdio` whose `stdin` carries only lines core's decoder will parse and
162
+ * answer, writing the guard's reply to every other line on `stdout`: one
163
+ * write per chunk, replies in stdin order. Everything else is the ambient
164
+ * `Stdio`.
165
+ *
166
+ * @remarks
167
+ * One guard per call, shared by every subscription to the returned `stdin`,
168
+ * because core re-subscribes after a failure and builds its own decoder once.
169
+ *
170
+ * @internal
171
+ */
172
+ const guardStdin = (stdio) => {
173
+ const step = makeFrameGuard();
174
+ return Stdio.make({
175
+ args: stdio.args,
176
+ stdinIsTerminal: stdio.stdinIsTerminal,
177
+ stdoutIsTerminal: stdio.stdoutIsTerminal,
178
+ stdout: (options) => stdio.stdout(options),
179
+ stderr: (options) => stdio.stderr(options),
180
+ stdin: stdio.stdin.pipe(Stream.mapEffect((chunk) => {
181
+ const { forward, replies } = step(chunk);
182
+ return replies.length === 0 ? Effect.succeed(forward) : Stream.run(Stream.make(replies.join("")), stdio.stdout()).pipe(Effect.as(forward));
183
+ }), Stream.filter((chunk) => chunk !== void 0))
184
+ });
185
+ };
186
+ /**
187
+ * A layer replacing the ambient `Stdio` with {@link guardStdin}'s, for one
188
+ * server layer only.
189
+ *
190
+ * @remarks
191
+ * A function, never a module constant: layers memoize by reference, so one
192
+ * shared constant is built once per graph, and a second server in that graph
193
+ * would read the first server's guarded stdin. Each call mints a fresh layer;
194
+ * `McpStdio.layer` calls it once per server.
195
+ *
196
+ * @internal
197
+ */
198
+ const makeGuardedStdio = () => Layer.effect(Stdio.Stdio, Effect.map(Stdio.Stdio, guardStdin));
199
+
200
+ //#endregion
201
+ export { INVALID_REQUEST_FRAME, MAX_FRAME_LENGTH, PARSE_ERROR_FRAME, guardStdin, makeFrameGuard, makeGuardedStdio };
@@ -0,0 +1,66 @@
1
+ import { Result } from "effect";
2
+
3
+ //#region src/internal/wire.ts
4
+ /** @internal */
5
+ const DEFAULT_CLIENT_INFO = {
6
+ name: "effected-mcp-testing",
7
+ version: "0.0.0"
8
+ };
9
+ const isRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u);
10
+ /**
11
+ * The pointer `McpProcess` appends to a `StreamEnded` message. `McpProbe`
12
+ * strips it: the probe folds stderr into its own message, so the pointer is
13
+ * stale there.
14
+ *
15
+ * @internal
16
+ */
17
+ const STDERR_HINT = "; read stderrFinal for why";
18
+ /** @internal */
19
+ const parseFrame = (line) => Result.getOrUndefined(Result.try(() => JSON.parse(line)));
20
+ /** @internal */
21
+ const isJsonRpcMessage = (u) => isRecord(u) && u.jsonrpc === "2.0";
22
+ /** @internal */
23
+ const isResponse = (message) => (typeof message.id === "string" || typeof message.id === "number") && message.method === void 0;
24
+ /** @internal */
25
+ const requestKey = (id) => `${typeof id}:${id}`;
26
+ /** @internal */
27
+ const isStateless = (protocol) => protocol.runtime._tag === "Stateless";
28
+ /**
29
+ * A request (with `id`) or notification frame. On the stateless revision every
30
+ * frame carries the protocol fields under `params._meta`, with any `_meta`
31
+ * the caller passed spread over them, so the caller wins. That matches core's
32
+ * own harness (`McpStdioHarness.ts`, `withRequestMetadata`) and lets a test
33
+ * send a deliberately wrong revision.
34
+ *
35
+ * @internal
36
+ */
37
+ const frame = (protocol, clientInfo, method, params, id) => {
38
+ const withMeta = () => {
39
+ const base = isRecord(params) ? params : {};
40
+ const caller = isRecord(base._meta) ? base._meta : {};
41
+ return {
42
+ ...base,
43
+ _meta: {
44
+ "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion,
45
+ "io.modelcontextprotocol/clientCapabilities": {},
46
+ "io.modelcontextprotocol/clientInfo": clientInfo,
47
+ ...caller
48
+ }
49
+ };
50
+ };
51
+ return {
52
+ jsonrpc: "2.0",
53
+ ...id === void 0 ? {} : { id },
54
+ method,
55
+ ...isStateless(protocol) ? { params: withMeta() } : params === void 0 ? {} : { params }
56
+ };
57
+ };
58
+ /** @internal */
59
+ const initializeParams = (protocol, clientInfo) => ({
60
+ protocolVersion: protocol.protocolVersion,
61
+ capabilities: {},
62
+ clientInfo
63
+ });
64
+
65
+ //#endregion
66
+ export { DEFAULT_CLIENT_INFO, STDERR_HINT, frame, initializeParams, isJsonRpcMessage, isResponse, isStateless, parseFrame, requestKey };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@effected/mcp",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "The boundary layer of an effect/unstable/ai MCP server: stdio wiring that keeps stdout the wire, tool-failure shaping, strict-input walkers, and test clients",
6
+ "keywords": [
7
+ "effect",
8
+ "mcp",
9
+ "model-context-protocol",
10
+ "stdio",
11
+ "json-rpc",
12
+ "effected"
13
+ ],
14
+ "homepage": "https://github.com/spencerbeggs/effected/tree/main/packages/mcp#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/spencerbeggs/effected/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/spencerbeggs/effected.git",
21
+ "directory": "packages/mcp"
22
+ },
23
+ "license": "MIT",
24
+ "author": {
25
+ "name": "C. Spencer Beggs",
26
+ "email": "spencer@beggs.codes",
27
+ "url": "https://spencerbeg.gs"
28
+ },
29
+ "sideEffects": false,
30
+ "type": "module",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./index.d.ts",
34
+ "import": "./index.js",
35
+ "default": "./index.js"
36
+ },
37
+ "./testing": {
38
+ "types": "./testing.d.ts",
39
+ "import": "./testing.js",
40
+ "default": "./testing.js"
41
+ },
42
+ "./package.json": "./package.json"
43
+ },
44
+ "peerDependencies": {
45
+ "@effected/engine": "^0.1.0",
46
+ "effect": "4.0.0-rc.117"
47
+ },
48
+ "engines": {
49
+ "node": ">=24.11.0"
50
+ }
51
+ }
package/testing.d.ts ADDED
@@ -0,0 +1,365 @@
1
+ import { Effect, JsonSchema, Layer, PlatformError, Schema, Scope, Stdio } from "effect";
2
+ import { McpProtocol } from "effect/unstable/ai";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4
+ //#region src/McpTestFailure.d.ts
5
+ declare const McpTestFailure_base: Schema.Class<McpTestFailure, Schema.TaggedStruct<"McpTestFailure", {
6
+ readonly reason: Schema.Literals<readonly ["StreamEnded", "ServerStopped", "NotJsonRpc", "NotInitialized", "ErrorResponse"]>;
7
+ readonly message: Schema.String;
8
+ }>, import("effect/Cause").YieldableError>;
9
+ /**
10
+ * Why an MCP test client could not produce what was asked of it.
11
+ *
12
+ * @remarks
13
+ * Each reason is raised by a specific client, and not every client raises
14
+ * every reason:
15
+ *
16
+ * - `StreamEnded` (`McpProcess`, `McpProbe`): the spawned child's stdout
17
+ * ended first, because the child exited. `McpProbe` folds the exit code
18
+ * and stderr into the message.
19
+ * - `NotJsonRpc` (`McpProcess.readUntilResponse`, `McpProbe`): a spawned
20
+ * child's stdout line was not a JSON-RPC frame. `McpHarness` never raises
21
+ * it: under `strictStdout` a non-JSON-RPC line DIES the pending wait as a
22
+ * defect instead, so assert it with `Effect.exit` and `Cause.hasDies`,
23
+ * not `catchTag`.
24
+ * - `ServerStopped` (`McpHarness`): the in-process server stopped, usually
25
+ * because stdin closed while the request was in flight.
26
+ * - `NotInitialized` (`McpHarness`): a request other than `initialize` was
27
+ * made on a stateful revision before `initialize` was sent. The request
28
+ * is never written; the server would only have refused it with an opaque
29
+ * `Invalid request metadata`.
30
+ * - `ErrorResponse` (`McpHarness.listTools`): the server answered with a
31
+ * JSON-RPC error where a result was required. Every other request method
32
+ * returns the whole response, error included, as data.
33
+ *
34
+ * @public
35
+ */
36
+ export declare class McpTestFailure extends McpTestFailure_base {}
37
+ //#endregion
38
+ //#region src/McpWire.d.ts
39
+ /**
40
+ * One JSON-RPC 2.0 frame as an MCP test client sees it.
41
+ *
42
+ * @public
43
+ */
44
+ interface JsonRpcMessage {
45
+ readonly jsonrpc: "2.0";
46
+ readonly id?: string | number | null | undefined;
47
+ readonly method?: string | undefined;
48
+ readonly params?: unknown;
49
+ readonly result?: unknown;
50
+ readonly error?: unknown;
51
+ }
52
+ /**
53
+ * One entry of a `tools/list` result.
54
+ *
55
+ * @public
56
+ */
57
+ interface ServedTool {
58
+ readonly name: string;
59
+ readonly title?: string | undefined;
60
+ readonly description?: string | undefined;
61
+ readonly inputSchema: JsonSchema.JsonSchema;
62
+ readonly outputSchema?: JsonSchema.JsonSchema | undefined;
63
+ readonly annotations?: {
64
+ readonly [key: string]: unknown;
65
+ } | undefined;
66
+ readonly _meta?: {
67
+ readonly [key: string]: unknown;
68
+ } | undefined;
69
+ }
70
+ /**
71
+ * One entry of a `resources/list` result.
72
+ *
73
+ * @public
74
+ */
75
+ interface ServedResource {
76
+ readonly uri: string;
77
+ readonly name: string;
78
+ readonly title?: string | undefined;
79
+ readonly description?: string | undefined;
80
+ readonly mimeType?: string | undefined;
81
+ readonly annotations?: {
82
+ readonly [key: string]: unknown;
83
+ } | undefined;
84
+ readonly size?: number | undefined;
85
+ readonly _meta?: {
86
+ readonly [key: string]: unknown;
87
+ } | undefined;
88
+ }
89
+ //#endregion
90
+ //#region src/McpHarness.d.ts
91
+ /**
92
+ * Options for {@link McpHarness.make}.
93
+ *
94
+ * @public
95
+ */
96
+ interface McpHarnessOptions {
97
+ /** The revision this client speaks. Defaults to `McpProtocol.v2025_11_25`; a stateless adapter switches to `server/discover` and per-request `_meta`. */
98
+ readonly protocol?: McpProtocol.ProtocolAdapter | undefined;
99
+ /** The `clientInfo` sent in `initialize` and in stateless `_meta`. */
100
+ readonly clientInfo?: {
101
+ readonly name: string;
102
+ readonly version: string;
103
+ } | undefined;
104
+ /** Capture the server's console so logs are asserted, not printed. Defaults to `true`. */
105
+ readonly captureLogs?: boolean | undefined;
106
+ /** Treat any stdout line that is not JSON-RPC as a defect. Defaults to `true`. */
107
+ readonly strictStdout?: boolean | undefined;
108
+ }
109
+ /**
110
+ * An in-process MCP client for a real server layer, over queue-backed stdio.
111
+ *
112
+ * @remarks
113
+ * Builds your server with a queue-backed `Stdio.layerTest` that satisfies the
114
+ * server's `Stdio` requirement, so tests see the exact served schemas and wire
115
+ * results a real client would, with no child and no sockets. Pass the server
116
+ * WITHOUT a `Stdio` of its own: a `Stdio` the server provides internally wins,
117
+ * and talks to the real terminal. A bundled platform layer such as
118
+ * `NodeServices.layer` (`@effect/platform-node`) brings its own `Stdio` along
119
+ * with `FileSystem`, `Path`, `ChildProcessSpawner`, `Crypto` and `Terminal` —
120
+ * providing it to the server loses `Stdio` the same way, and every wait
121
+ * hangs to timeout with no error naming why. Compose the individual layers
122
+ * the server needs instead and leave `Stdio` out; this harness supplies it.
123
+ *
124
+ * The server is built with a fresh layer memo map, never the ambient one, so
125
+ * a harness made under another stdio server's `Effect.provide` still serves
126
+ * its own. Never pass a server layer that provides a layer the test also
127
+ * provides and then reads — `McpHarness.make(server.pipe(Layer.provide(AppLayer)))`
128
+ * under `Effect.provide(AppLayer)` builds `AppLayer` twice, so the tools write
129
+ * to one instance and the test reads the other. Leave the service in the
130
+ * server layer's requirements and provide it once from the test, or build it
131
+ * once and pass `Layer.succeed(Tag, value)`.
132
+ *
133
+ * - Responses are matched by id, so notifications may interleave freely.
134
+ * - No wait can hang: every response wait and `awaitOutboundMethod` fails
135
+ * with `ServerStopped` when the server stops (stdin closing does that),
136
+ * and dies when `strictStdout` sees a line that is not JSON-RPC.
137
+ * - With `captureLogs`, the server's console is captured: `stderrSoFar`
138
+ * holds stderr writes and every log line, and `consoleLogSoFar` holds
139
+ * anything that went through `console.log`, which in a real server is
140
+ * the wire. Assert it empty.
141
+ * - On a stateful revision (the default `2025-11-25`), send `initialize`
142
+ * first. Every other request before it fails with `NotInitialized` and
143
+ * is never written: the server would only answer an opaque
144
+ * `Invalid request metadata`, `ping` included.
145
+ *
146
+ * @public
147
+ */
148
+ export declare class McpHarness {
149
+ /** The revision this client speaks. */
150
+ readonly protocol: McpProtocol.ProtocolAdapter;
151
+ /** `initialize` then `notifications/initialized`; `server/discover` on a stateless revision. Call it first on a stateful revision. */
152
+ readonly initialize: Effect.Effect<JsonRpcMessage, McpTestFailure>;
153
+ /** `server/discover`. */
154
+ readonly discover: Effect.Effect<JsonRpcMessage, McpTestFailure>;
155
+ /** Send a request and wait for its response. */
156
+ readonly request: (method: string, params?: unknown) => Effect.Effect<JsonRpcMessage, McpTestFailure>;
157
+ /** Send a request now; wait for its response later. Fails with `NotInitialized`, sending nothing, on a stateful revision before `initialize`. */
158
+ readonly startRequest: (method: string, params?: unknown) => Effect.Effect<{
159
+ readonly id: number;
160
+ readonly response: Effect.Effect<JsonRpcMessage, McpTestFailure>;
161
+ }, McpTestFailure>;
162
+ /** Send a notification. */
163
+ readonly notify: (method: string, params?: unknown) => Effect.Effect<void>;
164
+ /** `tools/call`, returning the whole response: a JSON-RPC error is data here. */
165
+ readonly callTool: (name: string, args?: unknown) => Effect.Effect<JsonRpcMessage, McpTestFailure>;
166
+ /** `tools/list`'s tools; a JSON-RPC error fails with `ErrorResponse`. */
167
+ readonly listTools: Effect.Effect<ReadonlyArray<ServedTool>, McpTestFailure>;
168
+ /** `resources/list`'s resources; a JSON-RPC error fails with `ErrorResponse`. */
169
+ readonly listResources: Effect.Effect<ReadonlyArray<ServedResource>, McpTestFailure>;
170
+ /** `resources/read`, returning the whole response. */
171
+ readonly readResource: (uri: string) => Effect.Effect<JsonRpcMessage, McpTestFailure>;
172
+ /** Write any value as one newline-framed line. Never gated; a raw `initialize` counts as sent. */
173
+ readonly sendRaw: (message: unknown) => Effect.Effect<void>;
174
+ /** The next server-initiated frame with this method, skipping and keeping others; fails with `ServerStopped` once the server stops. */
175
+ readonly awaitOutboundMethod: (method: string) => Effect.Effect<JsonRpcMessage, McpTestFailure>;
176
+ /** Everything written to stderr, plus every captured log line. */
177
+ readonly stderrSoFar: Effect.Effect<string>;
178
+ /** Every captured `console.log`, `info` or `debug` call. */
179
+ readonly consoleLogSoFar: Effect.Effect<ReadonlyArray<string>>;
180
+ /** End stdin, as a client disconnecting does. */
181
+ readonly close: Effect.Effect<void>;
182
+ private constructor();
183
+ /** Build `server` over queue-backed stdio and return a client for it; fails with the layer's own error if it cannot build. */
184
+ static readonly make: <ROut, E, R>(server: Layer.Layer<ROut, E, R>, options?: McpHarnessOptions) => Effect.Effect<McpHarness, E, Scope.Scope | Exclude<R, Stdio.Stdio>>;
185
+ }
186
+ //#endregion
187
+ //#region src/McpProbe.d.ts
188
+ /**
189
+ * Options for {@link McpProbe.initialize}.
190
+ *
191
+ * @public
192
+ */
193
+ interface McpProbeOptions {
194
+ /** Defaults to `McpProtocol.v2025_11_25`; a stateless adapter probes with `server/discover`. */
195
+ readonly protocol?: McpProtocol.ProtocolAdapter | undefined;
196
+ }
197
+ /**
198
+ * What {@link McpProbe.initialize} observed.
199
+ *
200
+ * @public
201
+ */
202
+ interface McpProbeResult {
203
+ /** The id-1 response. A refused handshake is still a response: assert `response.error === undefined`. */
204
+ readonly response: JsonRpcMessage;
205
+ /** Every raw stdout line, in order. */
206
+ readonly stdout: ReadonlyArray<string>;
207
+ /** Everything the child wrote to stderr; a clean boot leaves it empty. */
208
+ readonly stderr: string;
209
+ /** The child's exit code after stdin closed; a clean boot exits 0. */
210
+ readonly exitCode: number;
211
+ }
212
+ /**
213
+ * The smallest proof that an installed MCP bin boots: one initialize, a clean
214
+ * close, and exit 0.
215
+ *
216
+ * @remarks
217
+ * Stdin stays open until the id-1 response has arrived. Closing it right
218
+ * after writing, as every hand-rolled smoke test did, makes an Effect server
219
+ * drop the in-flight response and exit 0, so a slow boot reads as a pass with
220
+ * no response. The caller asserts `response.error === undefined`, `stderr` is
221
+ * empty and `exitCode` is 0; this is the MCP half of a packed-install proof.
222
+ * Checking stderr and the exit code alone passes a server that answers
223
+ * the handshake with a JSON-RPC error, exits 0 and writes nothing.
224
+ *
225
+ * - Any stdout line that is not JSON-RPC fails with `NotJsonRpc`, naming the
226
+ * line: a server that logs to stdout corrupts the wire, so the probe fails
227
+ * rather than skipping the line.
228
+ * - A child that exits before responding fails with `StreamEnded`, and the
229
+ * message carries its exit code and stderr.
230
+ * - A child that ignores stdin EOF never exits, and the probe waits for it.
231
+ * Wrap the probe in `Effect.timeout`.
232
+ *
233
+ * @public
234
+ */
235
+ export declare class McpProbe {
236
+ private constructor();
237
+ /**
238
+ * Spawn `command`, send one `initialize` (or `server/discover` on a
239
+ * stateless revision) as id 1, wait for its response, close stdin, and
240
+ * collect stdout, stderr and the exit code.
241
+ */
242
+ static readonly initialize: (command: ChildProcess.Command, options?: McpProbeOptions) => Effect.Effect<McpProbeResult, McpTestFailure | PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner>;
243
+ }
244
+ //#endregion
245
+ //#region src/McpProcess.d.ts
246
+ /**
247
+ * A spawned MCP server bin a test writes to while it runs.
248
+ *
249
+ * @remarks
250
+ * Build the command in the test file with `execPath` and an explicit `env`:
251
+ * `ChildProcess.make(process.execPath, [bin], { env })`.
252
+ *
253
+ * - `nextLine` fails with `StreamEnded` when stdout ends, so a child that
254
+ * exits early fails the test instead of hanging it.
255
+ * - `send` writes one JSON-encoded line; `sendRaw` writes a string or bytes
256
+ * exactly as given, for a malformed frame or one frame split across
257
+ * writes, even mid-character.
258
+ * - `readUntilResponse` reads past interleaved notifications, such as
259
+ * `list_changed`, to the matching id and returns what it saw.
260
+ * - `closeStdin` is `Queue.end`, never `Queue.shutdown`, so every frame
261
+ * already sent is delivered before stdin closes.
262
+ * - A request in flight when stdin closes is dropped by an Effect server:
263
+ * read its response first.
264
+ *
265
+ * @public
266
+ */
267
+ export declare class McpProcess {
268
+ /**
269
+ * Write one JSON-encoded, newline-framed message to the child's stdin.
270
+ *
271
+ * @remarks
272
+ * Never fails. A frame sent after `closeStdin` is dropped, and a write the
273
+ * child can no longer receive fails the stdin pump instead; the next
274
+ * `StreamEnded` message names that failure.
275
+ */
276
+ readonly send: (message: unknown) => Effect.Effect<void>;
277
+ /**
278
+ * Write `text` to the child's stdin exactly as given: a string as UTF-8,
279
+ * bytes unchanged, with no JSON encoding and no newline added.
280
+ *
281
+ * @remarks
282
+ * For frames `send` cannot produce: a line that is not JSON, a blank line,
283
+ * or one frame split across several writes, including mid-character by
284
+ * passing bytes. Include the `\n` yourself.
285
+ * Never fails, on the same terms as `send`.
286
+ */
287
+ readonly sendRaw: (text: string | Uint8Array) => Effect.Effect<void>;
288
+ /** The next non-empty stdout line; fails with `StreamEnded` once stdout ends. */
289
+ readonly nextLine: Effect.Effect<string, McpTestFailure>;
290
+ /** Read JSON-RPC lines up to and including the response with this id. */
291
+ readonly readUntilResponse: (id: string | number) => Effect.Effect<{
292
+ readonly response: JsonRpcMessage;
293
+ readonly seen: ReadonlyArray<JsonRpcMessage>;
294
+ }, McpTestFailure>;
295
+ /** `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. */
296
+ readonly handshake: (protocol?: McpProtocol.ProtocolAdapter) => Effect.Effect<JsonRpcMessage, McpTestFailure>;
297
+ /** End stdin after every frame already sent. */
298
+ readonly closeStdin: Effect.Effect<void>;
299
+ /** Wait for the child to exit. */
300
+ readonly exitCode: Effect.Effect<number, PlatformError.PlatformError>;
301
+ /** Everything written to stderr so far. */
302
+ readonly stderrSoFar: Effect.Effect<string>;
303
+ /** Everything written to stderr, once stderr has ended. Waits for the child to exit. */
304
+ readonly stderrFinal: Effect.Effect<string>;
305
+ private constructor();
306
+ /** Spawn `command` for the life of the current scope. */
307
+ static readonly spawn: (command: ChildProcess.Command) => Effect.Effect<McpProcess, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope>;
308
+ }
309
+ //#endregion
310
+ //#region src/McpToolAudit.d.ts
311
+ /**
312
+ * The policy {@link McpToolAudit.check} enforces.
313
+ *
314
+ * @public
315
+ */
316
+ interface McpToolAuditPolicy {
317
+ /** `"closed"`: every object node rejects unknown keys. `"open"`: none with properties does. `"any"`: not checked. */
318
+ readonly input: "open" | "closed" | "any";
319
+ /** Every tool must carry a non-empty `title`, top-level or in `annotations.title`. */
320
+ readonly requireTitle?: boolean | undefined;
321
+ /** Every tool must serve an `outputSchema`. The stateful revisions drop a non-object one, so check the revision you serve. */
322
+ readonly requireOutputSchema?: boolean | undefined;
323
+ /** A served `outputSchema` must be rooted at `type: "object"`. Defaults to `true` (D10). */
324
+ readonly objectRootedOutput?: boolean | undefined;
325
+ /** The longest `description` allowed, in UTF-16 code units; a missing description counts as 0. */
326
+ readonly maxDescription?: number | undefined;
327
+ /** All four MCP annotation hints must be present as booleans. */
328
+ readonly requireHints?: boolean | undefined;
329
+ }
330
+ /**
331
+ * A pure sweep over a served `tools/list` that returns every policy
332
+ * violation, as `"<tool>: <what>"` strings.
333
+ *
334
+ * @remarks
335
+ * Run it against what a real server serves (`McpHarness.listTools`), not
336
+ * against your own schemas: the served document is what a client sees, and
337
+ * it differs by protocol revision. The stateless `2026-07-28` adapter passes a
338
+ * non-object output through, while the stateful adapters drop it — which is
339
+ * why `objectRootedOutput` defaults to `true`.
340
+ *
341
+ * `"closed"` is only as sound as the walk. It follows `properties`,
342
+ * `items`, a schema-valued `additionalProperties`, `prefixItems`, `anyOf`,
343
+ * `oneOf` and `$defs`, and merges an `allOf` member's `properties` and
344
+ * `additionalProperties: false` onto its node. It does not visit
345
+ * `patternProperties` values, an `allOf` member's `items` or
346
+ * `additionalProperties` schema, or an `allOf` nested inside another; and
347
+ * a key declared in two `allOf` members is last-write-wins. A hand-authored
348
+ * or `Tool.dynamic` schema can therefore pass `"closed"` with an open node
349
+ * the walk never reached. Core-emitted strict schemas close every node, so
350
+ * a strict `Tool.make` tool is reported faithfully.
351
+ *
352
+ * @public
353
+ */
354
+ export declare class McpToolAudit {
355
+ private constructor();
356
+ /**
357
+ * Every violation of `policy` across `tools`, as `"<tool>: <what>"`, in
358
+ * tool order. Empty means the sweep passed. A duplicate tool name is
359
+ * reported under every policy, `input: "any"` included.
360
+ */
361
+ static readonly check: (tools: ReadonlyArray<ServedTool>, policy: McpToolAuditPolicy) => ReadonlyArray<string>;
362
+ }
363
+ //#endregion
364
+ export type { JsonRpcMessage, McpHarnessOptions, McpProbeOptions, McpProbeResult, McpToolAuditPolicy, ServedResource, ServedTool };
365
+ //# sourceMappingURL=testing.d.ts.map