@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.
- package/LICENSE +21 -0
- package/McpHarness.js +234 -0
- package/McpProbe.js +81 -0
- package/McpProcess.js +135 -0
- package/McpStdio.js +143 -0
- package/McpTestFailure.js +43 -0
- package/McpToolAudit.js +105 -0
- package/McpToolkit.js +79 -0
- package/README.md +406 -0
- package/ToolFailure.js +63 -0
- package/ToolInputSchema.js +167 -0
- package/index.d.ts +352 -0
- package/index.js +6 -0
- package/internal/LaunchFailed.js +23 -0
- package/internal/StdinFrames.js +201 -0
- package/internal/wire.js +66 -0
- package/package.json +51 -0
- package/testing.d.ts +365 -0
- package/testing.js +7 -0
- package/tsdoc-metadata.json +11 -0
|
@@ -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 };
|
package/internal/wire.js
ADDED
|
@@ -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
|