@effected/mcp-kit 0.0.0-stage → 0.14.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/McpGuard.js +81 -0
- package/McpHarness.js +357 -0
- package/McpProbe.js +94 -0
- package/McpProcess.js +196 -0
- package/McpStdio.js +175 -0
- package/McpTestFailure.js +49 -0
- package/McpToolAudit.js +109 -0
- package/McpToolkit.js +298 -0
- package/README.md +574 -2
- package/StdioCapture.js +146 -0
- package/ToolFailure.js +75 -0
- package/ToolInputSchema.js +180 -0
- package/ToolOutcome.js +119 -0
- package/ToolOutputSchema.js +65 -0
- package/ToolRefusal.js +57 -0
- package/ToolReplay.js +136 -0
- package/guard.d.ts +189 -0
- package/guard.js +3 -0
- package/index.d.ts +1048 -0
- package/index.js +10 -0
- package/internal/LaunchFailed.js +34 -0
- package/internal/StdinFrames.js +201 -0
- package/internal/guardLaunch.js +13 -0
- package/internal/wire.js +65 -0
- package/node.d.ts +126 -0
- package/node.js +3 -0
- package/package.json +60 -5
- package/testing.d.ts +519 -0
- package/testing.js +7 -0
- package/tsdoc-metadata.json +11 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/McpGuard.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ProcessGuard } from "@effected/kit/guard";
|
|
2
|
+
|
|
3
|
+
// src/McpGuard.ts
|
|
4
|
+
/**
|
|
5
|
+
* Crash guards for an MCP server process, installed before the server's
|
|
6
|
+
* module graph is loaded. Imported from `@effected/mcp-kit/guard`.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* {@link McpGuard.run} is `ProcessGuard.run` from `@effected/kit/guard`
|
|
10
|
+
* with an MCP launch in its `load`: it registers `uncaughtException` and
|
|
11
|
+
* `unhandledRejection` listeners on `host`, then awaits `load()`, then
|
|
12
|
+
* launches the server with `McpStdio.launch` and `McpStdio.teardown` under
|
|
13
|
+
* the `runMain` it returned. A server on another transport (an LSP over
|
|
14
|
+
* `vscode-languageserver`, say) uses `ProcessGuard.run` directly and calls
|
|
15
|
+
* its `markConnected` itself. This entrypoint statically imports only
|
|
16
|
+
* `@effected/kit/guard`, which imports nothing: it loads `effect` and the
|
|
17
|
+
* stdio wiring only after the guards are listening, so a throw while any of
|
|
18
|
+
* it evaluates is still reported on stderr.
|
|
19
|
+
*
|
|
20
|
+
* - "Connected" means the whole server layer has built, so stdin is being
|
|
21
|
+
* read. That is before any client sends `initialize`.
|
|
22
|
+
* - `injectCrash` drives either half of the policy from a test, before
|
|
23
|
+
* `load()` or once connected; unset, it does nothing.
|
|
24
|
+
* - A `load()` that rejects is reported as `startup failed` and exits 1
|
|
25
|
+
* whatever the policy. Left to a log-only rejection listener, it would
|
|
26
|
+
* let the event loop drain and exit 0 with no server.
|
|
27
|
+
* - Installing an `unhandledRejection` listener switches off Node's default
|
|
28
|
+
* of throwing on one, so `"log"` really does keep the process running.
|
|
29
|
+
* - An exit from a guard skips Effect's finalizers and the teardown.
|
|
30
|
+
* - Keeping on serving after a crash works because the platform `runMain`
|
|
31
|
+
* holds the process open while the server fiber lives.
|
|
32
|
+
* - A layer that fails to build is still reported by `McpStdio.launch`.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* import { McpGuard } from "@effected/mcp-kit/guard";
|
|
37
|
+
*
|
|
38
|
+
* await McpGuard.run({
|
|
39
|
+
* label: "my-server",
|
|
40
|
+
* host: process,
|
|
41
|
+
* policy: { onUncaught: "exitBeforeConnect", onRejection: "log" },
|
|
42
|
+
* load: async () => {
|
|
43
|
+
* const { NodeRuntime } = await import("@effect/platform-node");
|
|
44
|
+
* const { Main } = await import("./server.js");
|
|
45
|
+
* return { layer: Main, runMain: NodeRuntime.runMain };
|
|
46
|
+
* },
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
var McpGuard = class {
|
|
53
|
+
constructor() {}
|
|
54
|
+
/**
|
|
55
|
+
* Parse a test-only crash-injection setting into {@link McpGuardRunOptions.injectCrash}: `<at>:<kind>`, where `at`
|
|
56
|
+
* is `"load"` or `"connected"` and `kind` is `"uncaughtException"` or `"unhandledRejection"`. Anything else, or no
|
|
57
|
+
* value, is `undefined`. The same grammar as `ProcessGuard.parseInjectCrash` from `@effected/kit/guard`, which
|
|
58
|
+
* this is, so an MCP launcher need not import the engine guard for it.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* McpGuard.parseInjectCrash("load:uncaughtException"); // => { at: "load", kind: "uncaughtException" }
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
static parseInjectCrash = ProcessGuard.parseInjectCrash;
|
|
66
|
+
/** Install the guards, load the server, and launch it. Resolves once the server is launched. */
|
|
67
|
+
static run = (options) => ProcessGuard.run({
|
|
68
|
+
label: options.label,
|
|
69
|
+
host: options.host,
|
|
70
|
+
policy: options.policy,
|
|
71
|
+
injectCrash: options.injectCrash,
|
|
72
|
+
load: async (guard) => {
|
|
73
|
+
const server = await options.load();
|
|
74
|
+
if (server.format !== void 0) guard.useFormat(server.format);
|
|
75
|
+
const { launchGuarded } = await import("./internal/guardLaunch.js");
|
|
76
|
+
launchGuarded(server, guard.markConnected);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export { McpGuard };
|
package/McpHarness.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { DEFAULT_CLIENT_INFO, frame, initializeParams, isJsonRpcMessage, isResponse, isStateless, parseFrame, requestKey } from "./internal/wire.js";
|
|
2
|
+
import { McpTestFailure } from "./McpTestFailure.js";
|
|
3
|
+
import { ToolFailure } from "./ToolFailure.js";
|
|
4
|
+
import { Console, Deferred, Effect, Exit, Fiber, Layer, Queue, Sink, Stdio, Stream } from "effect";
|
|
5
|
+
import { McpProtocol } from "effect/ai";
|
|
6
|
+
import { ErrorText } from "@effected/kit";
|
|
7
|
+
|
|
8
|
+
// src/McpHarness.ts
|
|
9
|
+
/** The generic tag `Object.prototype.toString` answers with: a value with no string form of its own. */
|
|
10
|
+
const GENERIC_STRING = /^\[object [^\]]*\]$/;
|
|
11
|
+
/**
|
|
12
|
+
* One captured console argument as text, as close to what the console would show as a runtime-free rendering gets,
|
|
13
|
+
* and never throwing.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* A harness records what the server would have printed, so the record must not lose what the console shows. Hence
|
|
17
|
+
* the order: a value's own string form first (`String(error)` is `Error: boom`, where `ErrorText.describe` would keep
|
|
18
|
+
* only `boom`, and it is the same for any object carrying a `message`), then JSON for an array or a plain object,
|
|
19
|
+
* whose string form is `1,2` or `[object Object]`. `ErrorText.describe` is the last resort for a value whose string
|
|
20
|
+
* form throws and which has no JSON: an object with no prototype has no `toString`, and `String` throws on it inside
|
|
21
|
+
* the server's own console call.
|
|
22
|
+
*/
|
|
23
|
+
const showArg = (arg) => {
|
|
24
|
+
if (typeof arg === "string") return arg;
|
|
25
|
+
if (typeof arg !== "object" || arg === null) return ErrorText.describe(arg);
|
|
26
|
+
try {
|
|
27
|
+
if (!Array.isArray(arg)) {
|
|
28
|
+
const text = String(arg);
|
|
29
|
+
if (!GENERIC_STRING.test(text)) return text;
|
|
30
|
+
}
|
|
31
|
+
} catch {}
|
|
32
|
+
try {
|
|
33
|
+
const json = JSON.stringify(arg);
|
|
34
|
+
if (json !== void 0) return json;
|
|
35
|
+
} catch {}
|
|
36
|
+
return ErrorText.describe(arg);
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* An in-process MCP client for a real server layer, over queue-backed stdio.
|
|
40
|
+
*
|
|
41
|
+
* @remarks
|
|
42
|
+
* Builds your server with a queue-backed `Stdio.layerTest` that satisfies the
|
|
43
|
+
* server's `Stdio` requirement, so tests see the exact served schemas and wire
|
|
44
|
+
* results a real client would, with no child and no sockets. Pass the server
|
|
45
|
+
* WITHOUT a `Stdio` of its own: a `Stdio` the server provides internally wins,
|
|
46
|
+
* and talks to the real terminal. A bundled platform layer such as
|
|
47
|
+
* `NodeServices.layer` (`@effect/platform-node`) brings its own `Stdio` along
|
|
48
|
+
* with `FileSystem`, `Path`, `ChildProcessSpawner`, `Crypto` and `Terminal` —
|
|
49
|
+
* providing it to the server loses `Stdio` the same way, and every wait
|
|
50
|
+
* hangs to timeout with no error naming why. Compose the individual layers
|
|
51
|
+
* the server needs instead and leave `Stdio` out; this harness supplies it.
|
|
52
|
+
*
|
|
53
|
+
* The server is built with a fresh layer memo map, never the ambient one, so
|
|
54
|
+
* a harness made under another stdio server's `Effect.provide` still serves
|
|
55
|
+
* its own. Never pass a server layer that provides a layer the test also
|
|
56
|
+
* provides and then reads — `McpHarness.make(server.pipe(Layer.provide(AppLayer)))`
|
|
57
|
+
* under `Effect.provide(AppLayer)` builds `AppLayer` twice, so the tools write
|
|
58
|
+
* to one instance and the test reads the other. Leave the service in the
|
|
59
|
+
* server layer's requirements and provide it once from the test, or build it
|
|
60
|
+
* once and pass `Layer.succeed(Tag, value)`.
|
|
61
|
+
*
|
|
62
|
+
* - Responses are matched by id, so notifications may interleave freely.
|
|
63
|
+
* - Every response wait and `awaitOutboundMethod` fails with `ServerStopped`
|
|
64
|
+
* when the server stops, and dies when `strictStdout` sees a line that is
|
|
65
|
+
* not JSON-RPC. `stop` stops the server at once; `close` ends stdin and lets
|
|
66
|
+
* in-flight requests answer first, so only `stop` releases a wait on a
|
|
67
|
+
* request that never completes.
|
|
68
|
+
* - With `captureLogs`, the server's console is captured: `stderrSoFar`
|
|
69
|
+
* holds stderr writes and every log line, and `consoleLogSoFar` holds
|
|
70
|
+
* anything that went through `console.log`, which in a real server is
|
|
71
|
+
* the wire. Assert it empty.
|
|
72
|
+
* - On a stateful revision (the default `2025-11-25`), send `initialize`
|
|
73
|
+
* first. `ping` is the exception: the server answers it `{}` before
|
|
74
|
+
* initialization, so it is written. Every other request before
|
|
75
|
+
* `initialize` fails with `NotInitialized` and is never written: the
|
|
76
|
+
* server would only answer an opaque refusal,
|
|
77
|
+
* `-32602 Invalid request metadata` when a stateless adapter is listed
|
|
78
|
+
* first (as in `McpStdio.protocols`), or `-32603 Internal error` when
|
|
79
|
+
* only stateful revisions are served.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```ts
|
|
83
|
+
* import { McpHarness } from "@effected/mcp-kit/testing";
|
|
84
|
+
* import { Effect } from "effect";
|
|
85
|
+
*
|
|
86
|
+
* // `ServerLayer` is the server's layer, built without its own `Stdio`.
|
|
87
|
+
* const program = Effect.gen(function* () {
|
|
88
|
+
* const harness = yield* McpHarness.make(ServerLayer);
|
|
89
|
+
* yield* harness.initialize;
|
|
90
|
+
* const tools = yield* harness.listTools;
|
|
91
|
+
* return tools.map((tool) => tool.name);
|
|
92
|
+
* }).pipe(Effect.scoped);
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* Services the test also seeds go in `options.services`, built once. End the
|
|
96
|
+
* session with `harness.close` (the server answers what is in flight, then
|
|
97
|
+
* stops) or, when a request may never complete, `harness.stop` (the server is
|
|
98
|
+
* interrupted at once):
|
|
99
|
+
*
|
|
100
|
+
* ```ts
|
|
101
|
+
* import { McpHarness } from "@effected/mcp-kit/testing";
|
|
102
|
+
* import { Context, Effect } from "effect";
|
|
103
|
+
*
|
|
104
|
+
* // `Database` is a service the server's tools need, and the test seeds it.
|
|
105
|
+
* const program = Effect.gen(function* () {
|
|
106
|
+
* const database = yield* makeDatabase;
|
|
107
|
+
* const harness = yield* McpHarness.make(ServerLayer, { services: Context.make(Database, database) });
|
|
108
|
+
* yield* harness.initialize;
|
|
109
|
+
* yield* harness.stop;
|
|
110
|
+
* }).pipe(Effect.scoped);
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
113
|
+
* @public
|
|
114
|
+
*/
|
|
115
|
+
var McpHarness = class McpHarness {
|
|
116
|
+
/** The revision this client speaks. */
|
|
117
|
+
protocol;
|
|
118
|
+
/** `initialize` then `notifications/initialized`; `server/discover` on a stateless revision. Call it first on a stateful revision. */
|
|
119
|
+
initialize;
|
|
120
|
+
/**
|
|
121
|
+
* `initialize` asking for `protocolVersion` instead of the harness's own
|
|
122
|
+
* revision, then `notifications/initialized` only when the response
|
|
123
|
+
* carries no error. Returns the whole response: assert the negotiated
|
|
124
|
+
* `result.protocolVersion`, or the JSON-RPC `error`.
|
|
125
|
+
*
|
|
126
|
+
* @remarks
|
|
127
|
+
* It moves between stateful revisions only. A stateful server never
|
|
128
|
+
* refuses a version: it counter-offers its latest stateful revision, so
|
|
129
|
+
* asking for an unknown one, or for the stateless `2026-07-28`, comes back
|
|
130
|
+
* as `2025-11-25`. On a harness made with a stateless revision the server
|
|
131
|
+
* does not serve `initialize` at all, and the response is a JSON-RPC
|
|
132
|
+
* `-32601` "Method not found" error, with no `notifications/initialized`
|
|
133
|
+
* sent after it.
|
|
134
|
+
*/
|
|
135
|
+
initializeWith;
|
|
136
|
+
/** `server/discover`. */
|
|
137
|
+
discover;
|
|
138
|
+
/** Send a request and wait for its response. */
|
|
139
|
+
request;
|
|
140
|
+
/** Send a request now; wait for its response later. Fails with `NotInitialized`, sending nothing, on a stateful revision before `initialize` (`ping` excepted). */
|
|
141
|
+
startRequest;
|
|
142
|
+
/** Send a notification. */
|
|
143
|
+
notify;
|
|
144
|
+
/** `tools/call`, returning the whole response: a JSON-RPC error is data here. */
|
|
145
|
+
callTool;
|
|
146
|
+
/** `tools/list`'s tools; a JSON-RPC error fails with `ErrorResponse`. */
|
|
147
|
+
listTools;
|
|
148
|
+
/** `resources/list`'s resources; a JSON-RPC error fails with `ErrorResponse`. */
|
|
149
|
+
listResources;
|
|
150
|
+
/** `resources/read`, returning the whole response. */
|
|
151
|
+
readResource;
|
|
152
|
+
/** Write any value as one newline-framed line. Never gated; a raw `initialize` counts as sent. */
|
|
153
|
+
sendRaw;
|
|
154
|
+
/** The next server-initiated frame with this method, skipping and keeping others; fails with `ServerStopped` once the server stops. */
|
|
155
|
+
awaitOutboundMethod;
|
|
156
|
+
/** Every frame written to the server's stdin so far, in order, as the JSON-parsed bytes written (a snapshot the caller's later mutations cannot reach): requests, notifications and `sendRaw` values. */
|
|
157
|
+
sentSoFar;
|
|
158
|
+
/** Everything written to stderr, plus every captured log line. */
|
|
159
|
+
stderrSoFar;
|
|
160
|
+
/**
|
|
161
|
+
* Every captured `console.log`, `info` or `debug` call, its arguments joined with a space, each rendered close to
|
|
162
|
+
* what the console would show: a string as itself, a value with its own string form as that form (an `Error` as
|
|
163
|
+
* `Error: boom`, a number, a date), an array or a plain object as JSON (`{"message":"x"}`, not just `x`).
|
|
164
|
+
*
|
|
165
|
+
* @remarks
|
|
166
|
+
* Rendering never throws inside the server's own call. A value whose string form throws (an object with no
|
|
167
|
+
* prototype, a throwing `toString`) and has no JSON falls back to `ErrorText.describe` from `@effected/kit`.
|
|
168
|
+
*/
|
|
169
|
+
consoleLogSoFar;
|
|
170
|
+
/**
|
|
171
|
+
* End stdin, as a client disconnecting does. The server drains first:
|
|
172
|
+
* requests already in flight still answer, and the server stops once they
|
|
173
|
+
* have, so a wait on a request that never completes keeps waiting. Use
|
|
174
|
+
* {@link McpHarness.stop} to end the server regardless.
|
|
175
|
+
*/
|
|
176
|
+
close;
|
|
177
|
+
/**
|
|
178
|
+
* Interrupt the server without draining, as a client killing the process
|
|
179
|
+
* does: every pending response wait and `awaitOutboundMethod` fails with
|
|
180
|
+
* `ServerStopped`.
|
|
181
|
+
*/
|
|
182
|
+
stop;
|
|
183
|
+
constructor(parts) {
|
|
184
|
+
this.protocol = parts.protocol;
|
|
185
|
+
this.initialize = parts.initialize;
|
|
186
|
+
this.initializeWith = parts.initializeWith;
|
|
187
|
+
this.discover = parts.discover;
|
|
188
|
+
this.request = parts.request;
|
|
189
|
+
this.startRequest = parts.startRequest;
|
|
190
|
+
this.notify = parts.notify;
|
|
191
|
+
this.callTool = parts.callTool;
|
|
192
|
+
this.listTools = parts.listTools;
|
|
193
|
+
this.listResources = parts.listResources;
|
|
194
|
+
this.readResource = parts.readResource;
|
|
195
|
+
this.sendRaw = parts.sendRaw;
|
|
196
|
+
this.awaitOutboundMethod = parts.awaitOutboundMethod;
|
|
197
|
+
this.sentSoFar = parts.sentSoFar;
|
|
198
|
+
this.stderrSoFar = parts.stderrSoFar;
|
|
199
|
+
this.consoleLogSoFar = parts.consoleLogSoFar;
|
|
200
|
+
this.close = parts.close;
|
|
201
|
+
this.stop = parts.stop;
|
|
202
|
+
}
|
|
203
|
+
/** Build `server` over queue-backed stdio and return a client for it; fails with the layer's own error if it cannot build. */
|
|
204
|
+
static make = (server, options = {}) => Effect.gen(function* () {
|
|
205
|
+
const protocol = options.protocol ?? McpProtocol.v2025_11_25;
|
|
206
|
+
const clientInfo = options.clientInfo ?? DEFAULT_CLIENT_INFO;
|
|
207
|
+
const strictStdout = options.strictStdout ?? true;
|
|
208
|
+
const stdin = yield* Queue.unbounded();
|
|
209
|
+
const stdout = yield* Queue.unbounded();
|
|
210
|
+
const inbound = yield* Queue.unbounded();
|
|
211
|
+
const retained = [];
|
|
212
|
+
const waiters = /* @__PURE__ */ new Map();
|
|
213
|
+
const stderr = [];
|
|
214
|
+
const consoleLog = [];
|
|
215
|
+
const ready = yield* Deferred.make();
|
|
216
|
+
const stopped = yield* Deferred.make();
|
|
217
|
+
const corrupt = yield* Deferred.make();
|
|
218
|
+
const encoder = new TextEncoder();
|
|
219
|
+
const stdoutDecoder = new TextDecoder();
|
|
220
|
+
const stderrDecoder = new TextDecoder();
|
|
221
|
+
let nextId = 1;
|
|
222
|
+
const stdio = Stdio.layerTest({
|
|
223
|
+
stdin: Stream.fromQueue(stdin),
|
|
224
|
+
stdout: () => Sink.forEach((chunk) => Queue.offer(stdout, chunk)),
|
|
225
|
+
stderr: () => Sink.forEach((chunk) => Effect.sync(() => {
|
|
226
|
+
stderr.push(typeof chunk === "string" ? chunk : stderrDecoder.decode(chunk, { stream: true }));
|
|
227
|
+
}))
|
|
228
|
+
});
|
|
229
|
+
const ambient = yield* Console.Console;
|
|
230
|
+
const show = (args) => args.map(showArg).join(" ");
|
|
231
|
+
const toStdout = (...args) => {
|
|
232
|
+
consoleLog.push(show(args));
|
|
233
|
+
};
|
|
234
|
+
const toStderr = (...args) => {
|
|
235
|
+
stderr.push(`${show(args)}\n`);
|
|
236
|
+
};
|
|
237
|
+
const captured = {
|
|
238
|
+
...ambient,
|
|
239
|
+
log: toStdout,
|
|
240
|
+
info: toStdout,
|
|
241
|
+
debug: toStdout,
|
|
242
|
+
error: toStderr,
|
|
243
|
+
warn: toStderr
|
|
244
|
+
};
|
|
245
|
+
const withLogs = options.captureLogs === false ? server.pipe(Layer.provide(stdio)) : server.pipe(Layer.provide(stdio), Layer.provide(Layer.succeed(Console.Console, captured)));
|
|
246
|
+
const provided = options.services === void 0 ? withLogs : withLogs.pipe(Layer.provide(Layer.succeedContext(options.services)));
|
|
247
|
+
const route = (line) => {
|
|
248
|
+
const parsed = parseFrame(line);
|
|
249
|
+
if (!isJsonRpcMessage(parsed)) return strictStdout ? Effect.asVoid(Deferred.die(corrupt, /* @__PURE__ */ new Error(`stdout carried a line that is not JSON-RPC: ${ToolFailure.truncate(line)}`))) : Effect.void;
|
|
250
|
+
if (isResponse(parsed)) {
|
|
251
|
+
const key = requestKey(parsed.id);
|
|
252
|
+
const waiter = waiters.get(key);
|
|
253
|
+
if (waiter !== void 0) {
|
|
254
|
+
waiters.delete(key);
|
|
255
|
+
return Effect.asVoid(Deferred.succeed(waiter, parsed));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return Effect.asVoid(Queue.offer(inbound, parsed));
|
|
259
|
+
};
|
|
260
|
+
const router = yield* Stream.fromQueue(stdout).pipe(Stream.map((chunk) => typeof chunk === "string" ? chunk : stdoutDecoder.decode(chunk, { stream: true })), Stream.splitLines, Stream.runForEach((line) => line.length === 0 ? Effect.void : route(line)), Effect.forkScoped);
|
|
261
|
+
const serverFiber = yield* Effect.flatMap(Effect.scope, (scope) => Layer.buildWithMemoMap(provided, Layer.makeMemoMapUnsafe(), scope)).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.andThen(Effect.never), Effect.scoped, Effect.onExit((exit) => Effect.andThen(Exit.isFailure(exit) ? Deferred.failCause(ready, exit.cause) : Effect.void, Queue.end(stdout).pipe(Effect.andThen(Fiber.await(router)), Effect.andThen(Deferred.fail(stopped, new McpTestFailure({
|
|
262
|
+
reason: "ServerStopped",
|
|
263
|
+
message: "the server stopped before it responded"
|
|
264
|
+
})))))), Effect.forkScoped);
|
|
265
|
+
yield* Deferred.await(ready);
|
|
266
|
+
const stopAware = (wait) => Effect.raceAllFirst([
|
|
267
|
+
wait,
|
|
268
|
+
Deferred.await(stopped),
|
|
269
|
+
Deferred.await(corrupt)
|
|
270
|
+
]);
|
|
271
|
+
const awaitResponse = (waiter) => Effect.suspend(() => Deferred.isDoneUnsafe(waiter) ? Deferred.await(waiter) : stopAware(Deferred.await(waiter)));
|
|
272
|
+
const stateful = !isStateless(protocol);
|
|
273
|
+
let initializeSent = false;
|
|
274
|
+
const sent = [];
|
|
275
|
+
const sendRaw = (message) => Effect.suspend(() => {
|
|
276
|
+
const line = JSON.stringify(message);
|
|
277
|
+
const written = line === void 0 ? void 0 : JSON.parse(line);
|
|
278
|
+
sent.push(written);
|
|
279
|
+
if (isJsonRpcMessage(written) && written.method === "initialize") initializeSent = true;
|
|
280
|
+
return Effect.asVoid(Queue.offer(stdin, encoder.encode(`${line}\n`)));
|
|
281
|
+
});
|
|
282
|
+
const startRequest = (method, params) => Effect.gen(function* () {
|
|
283
|
+
if (stateful && !initializeSent && method !== "initialize" && method !== "ping") return yield* new McpTestFailure({
|
|
284
|
+
reason: "NotInitialized",
|
|
285
|
+
message: `${method} was not sent: call initialize first on stateful protocol ${protocol.protocolVersion}`
|
|
286
|
+
});
|
|
287
|
+
const id = nextId++;
|
|
288
|
+
const waiter = yield* Deferred.make();
|
|
289
|
+
waiters.set(requestKey(id), waiter);
|
|
290
|
+
yield* sendRaw(frame(protocol, clientInfo, method, params, id));
|
|
291
|
+
return {
|
|
292
|
+
id,
|
|
293
|
+
response: awaitResponse(waiter)
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
const request = (method, params) => Effect.flatMap(startRequest(method, params), (started) => started.response);
|
|
297
|
+
const notify = (method, params) => sendRaw(frame(protocol, clientInfo, method, params));
|
|
298
|
+
const discover = request("server/discover", {});
|
|
299
|
+
const initialize = isStateless(protocol) ? discover : Effect.gen(function* () {
|
|
300
|
+
const response = yield* request("initialize", initializeParams(protocol, clientInfo));
|
|
301
|
+
yield* notify("notifications/initialized");
|
|
302
|
+
return response;
|
|
303
|
+
});
|
|
304
|
+
const initializeWith = (protocolVersion) => Effect.gen(function* () {
|
|
305
|
+
const response = yield* request("initialize", {
|
|
306
|
+
...initializeParams(protocol, clientInfo),
|
|
307
|
+
protocolVersion
|
|
308
|
+
});
|
|
309
|
+
if (response.error === void 0) yield* notify("notifications/initialized");
|
|
310
|
+
return response;
|
|
311
|
+
});
|
|
312
|
+
const listTools = Effect.flatMap(request("tools/list"), (response) => response.error === void 0 ? Effect.succeed(response.result.tools) : Effect.fail(new McpTestFailure({
|
|
313
|
+
reason: "ErrorResponse",
|
|
314
|
+
message: `tools/list failed: ${ToolFailure.truncate(JSON.stringify(response.error))}`
|
|
315
|
+
})));
|
|
316
|
+
const listResources = Effect.flatMap(request("resources/list"), (response) => response.error === void 0 ? Effect.succeed(response.result.resources) : Effect.fail(new McpTestFailure({
|
|
317
|
+
reason: "ErrorResponse",
|
|
318
|
+
message: `resources/list failed: ${ToolFailure.truncate(JSON.stringify(response.error))}`
|
|
319
|
+
})));
|
|
320
|
+
const awaitOutboundMethod = (method) => Effect.suspend(() => {
|
|
321
|
+
const index = retained.findIndex((message) => message.method === method);
|
|
322
|
+
if (index !== -1) return Effect.succeed(retained.splice(index, 1)[0]);
|
|
323
|
+
return stopAware(Effect.gen(function* () {
|
|
324
|
+
while (true) {
|
|
325
|
+
const message = yield* Queue.take(inbound);
|
|
326
|
+
if (message.method === method) return message;
|
|
327
|
+
retained.push(message);
|
|
328
|
+
}
|
|
329
|
+
}));
|
|
330
|
+
});
|
|
331
|
+
return new McpHarness({
|
|
332
|
+
protocol,
|
|
333
|
+
initialize,
|
|
334
|
+
initializeWith,
|
|
335
|
+
discover,
|
|
336
|
+
request,
|
|
337
|
+
startRequest,
|
|
338
|
+
notify,
|
|
339
|
+
callTool: (name, args) => request("tools/call", {
|
|
340
|
+
name,
|
|
341
|
+
arguments: args ?? {}
|
|
342
|
+
}),
|
|
343
|
+
listTools,
|
|
344
|
+
listResources,
|
|
345
|
+
readResource: (uri) => request("resources/read", { uri }),
|
|
346
|
+
sendRaw,
|
|
347
|
+
awaitOutboundMethod,
|
|
348
|
+
sentSoFar: Effect.sync(() => [...sent]),
|
|
349
|
+
stderrSoFar: Effect.sync(() => stderr.join("")),
|
|
350
|
+
consoleLogSoFar: Effect.sync(() => [...consoleLog]),
|
|
351
|
+
close: Effect.asVoid(Queue.end(stdin)),
|
|
352
|
+
stop: Fiber.interrupt(serverFiber)
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
export { McpHarness };
|
package/McpProbe.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { DEFAULT_CLIENT_INFO, STDERR_HINT, frame, initializeParams, isJsonRpcMessage, isResponse, isStateless, parseFrame } from "./internal/wire.js";
|
|
2
|
+
import { McpProcess } from "./McpProcess.js";
|
|
3
|
+
import { McpTestFailure } from "./McpTestFailure.js";
|
|
4
|
+
import { ToolFailure } from "./ToolFailure.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-kit/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 };
|