@effected/mcp 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/McpGuard.js +31 -70
- package/McpHarness.js +38 -9
- package/McpProbe.js +17 -3
- package/McpProcess.js +69 -7
- package/McpStdio.js +13 -4
- package/McpTestFailure.js +8 -2
- package/McpToolkit.js +5 -0
- package/README.md +4 -4
- package/ToolFailure.js +3 -0
- package/ToolRefusal.js +18 -2
- package/guard.d.ts +27 -4
- package/index.d.ts +38 -6
- package/internal/LaunchFailed.js +15 -3
- package/package.json +3 -3
- package/testing.d.ts +108 -14
package/McpGuard.js
CHANGED
|
@@ -1,26 +1,19 @@
|
|
|
1
|
+
import { ProcessGuard } from "@effected/engine/guard";
|
|
2
|
+
|
|
1
3
|
//#region src/McpGuard.ts
|
|
2
|
-
const isInjectedKind = (kind) => kind === "uncaughtException" || kind === "unhandledRejection";
|
|
3
|
-
/** Emit one injected crash through the host, to the listeners the guard installed there. */
|
|
4
|
-
const emitInjected = (host, kind) => {
|
|
5
|
-
const error = /* @__PURE__ */ new Error(`[injected] ${kind}`);
|
|
6
|
-
if (kind === "uncaughtException") {
|
|
7
|
-
host.emit("uncaughtException", error, "uncaughtException");
|
|
8
|
-
return;
|
|
9
|
-
}
|
|
10
|
-
const promise = Promise.reject(error);
|
|
11
|
-
promise.catch(() => void 0);
|
|
12
|
-
host.emit("unhandledRejection", error, promise);
|
|
13
|
-
};
|
|
14
|
-
const fallbackFormat = (error) => error instanceof Error ? error.stack ?? error.message : String(error);
|
|
15
4
|
/**
|
|
16
5
|
* Crash guards for an MCP server process, installed before the server's
|
|
17
6
|
* module graph is loaded. Imported from `@effected/mcp/guard`.
|
|
18
7
|
*
|
|
19
8
|
* @remarks
|
|
20
|
-
* {@link McpGuard.run}
|
|
21
|
-
*
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
9
|
+
* {@link McpGuard.run} is `ProcessGuard.run` from `@effected/engine/guard`
|
|
10
|
+
* with an MCP launch in its `load`: it registers `uncaughtException` and
|
|
11
|
+
* `unhandledRejection` listeners on `host`, then awaits `load()`, then
|
|
12
|
+
* launches the server with `McpStdio.launch` and `McpStdio.teardown` under
|
|
13
|
+
* the `runMain` it returned. A server on another transport (an LSP over
|
|
14
|
+
* `vscode-languageserver`, say) uses `ProcessGuard.run` directly and calls
|
|
15
|
+
* its `markConnected` itself. This entrypoint statically imports only
|
|
16
|
+
* `@effected/engine/guard`, which imports nothing: it loads `effect` and the
|
|
24
17
|
* stdio wiring only after the guards are listening, so a throw while any of
|
|
25
18
|
* it evaluates is still reported on stderr.
|
|
26
19
|
*
|
|
@@ -58,63 +51,31 @@ const fallbackFormat = (error) => error instanceof Error ? error.stack ?? error.
|
|
|
58
51
|
*/
|
|
59
52
|
var McpGuard = class {
|
|
60
53
|
constructor() {}
|
|
54
|
+
/**
|
|
55
|
+
* Parse a test-only crash-injection setting into {@link McpGuardRunOptions.injectCrash}: `<at>:<kind>`, where `at`
|
|
56
|
+
* is `"load"` or `"connected"` and `kind` is `"uncaughtException"` or `"unhandledRejection"`. Anything else, or no
|
|
57
|
+
* value, is `undefined`. The same grammar as `ProcessGuard.parseInjectCrash` from `@effected/engine/guard`, which
|
|
58
|
+
* this is, so an MCP launcher need not import the engine guard for it.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* McpGuard.parseInjectCrash("load:uncaughtException"); // => { at: "load", kind: "uncaughtException" }
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
static parseInjectCrash = ProcessGuard.parseInjectCrash;
|
|
61
66
|
/** Install the guards, load the server, and launch it. Resolves once the server is launched. */
|
|
62
|
-
static run =
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const describe = (error) => {
|
|
69
|
-
try {
|
|
70
|
-
return format(error);
|
|
71
|
-
} catch {
|
|
72
|
-
try {
|
|
73
|
-
return fallbackFormat(error);
|
|
74
|
-
} catch {
|
|
75
|
-
return "<unformattable error value>";
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
};
|
|
79
|
-
const exits = (mode) => mode === "exit" || mode === "exitBeforeConnect" && !connected;
|
|
80
|
-
host.on("uncaughtException", (error, origin) => {
|
|
81
|
-
host.stderr.write(`${label}: uncaughtException (${origin}): ${describe(error)}\n`);
|
|
82
|
-
if (exits(onUncaught)) host.exit(1);
|
|
83
|
-
});
|
|
84
|
-
host.on("unhandledRejection", (reason) => {
|
|
85
|
-
host.stderr.write(`${label}: unhandledRejection: ${describe(reason)}\n`);
|
|
86
|
-
if (exits(onRejection)) host.exit(1);
|
|
87
|
-
});
|
|
88
|
-
const inject = options.injectCrash;
|
|
89
|
-
const injectKind = isInjectedKind(inject?.kind) ? inject.kind : void 0;
|
|
90
|
-
if (inject?.at === "load" && injectKind !== void 0) await new Promise((resolve, reject) => {
|
|
91
|
-
setTimeout(() => {
|
|
92
|
-
try {
|
|
93
|
-
emitInjected(host, injectKind);
|
|
94
|
-
resolve();
|
|
95
|
-
} catch (error) {
|
|
96
|
-
reject(error);
|
|
97
|
-
}
|
|
98
|
-
}, 0);
|
|
99
|
-
});
|
|
100
|
-
const onReady = () => {
|
|
101
|
-
connected = true;
|
|
102
|
-
if (inject?.at === "connected" && injectKind !== void 0) setTimeout(() => {
|
|
103
|
-
try {
|
|
104
|
-
emitInjected(host, injectKind);
|
|
105
|
-
} catch {}
|
|
106
|
-
}, 0);
|
|
107
|
-
};
|
|
108
|
-
try {
|
|
67
|
+
static run = (options) => ProcessGuard.run({
|
|
68
|
+
label: options.label,
|
|
69
|
+
host: options.host,
|
|
70
|
+
policy: options.policy,
|
|
71
|
+
injectCrash: options.injectCrash,
|
|
72
|
+
load: async (guard) => {
|
|
109
73
|
const server = await options.load();
|
|
110
|
-
if (server.format !== void 0)
|
|
74
|
+
if (server.format !== void 0) guard.useFormat(server.format);
|
|
111
75
|
const { launchGuarded } = await import("./internal/guardLaunch.js");
|
|
112
|
-
launchGuarded(server,
|
|
113
|
-
} catch (error) {
|
|
114
|
-
host.stderr.write(`${label}: startup failed: ${describe(error)}\n`);
|
|
115
|
-
host.exit(1);
|
|
76
|
+
launchGuarded(server, guard.markConnected);
|
|
116
77
|
}
|
|
117
|
-
};
|
|
78
|
+
});
|
|
118
79
|
};
|
|
119
80
|
|
|
120
81
|
//#endregion
|
package/McpHarness.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ToolFailure } from "./ToolFailure.js";
|
|
2
2
|
import { DEFAULT_CLIENT_INFO, frame, initializeParams, isJsonRpcMessage, isResponse, isStateless, parseFrame, requestKey } from "./internal/wire.js";
|
|
3
3
|
import { McpTestFailure } from "./McpTestFailure.js";
|
|
4
|
-
import { Console, Deferred, Effect, Exit, Layer, Queue, Sink, Stdio, Stream } from "effect";
|
|
4
|
+
import { Console, Deferred, Effect, Exit, Fiber, Layer, Queue, Sink, Stdio, Stream } from "effect";
|
|
5
5
|
import { McpProtocol } from "effect/ai";
|
|
6
6
|
|
|
7
7
|
//#region src/McpHarness.ts
|
|
@@ -30,9 +30,11 @@ import { McpProtocol } from "effect/ai";
|
|
|
30
30
|
* once and pass `Layer.succeed(Tag, value)`.
|
|
31
31
|
*
|
|
32
32
|
* - Responses are matched by id, so notifications may interleave freely.
|
|
33
|
-
* -
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* - Every response wait and `awaitOutboundMethod` fails with `ServerStopped`
|
|
34
|
+
* when the server stops, and dies when `strictStdout` sees a line that is
|
|
35
|
+
* not JSON-RPC. `stop` stops the server at once; `close` ends stdin and lets
|
|
36
|
+
* in-flight requests answer first, so only `stop` releases a wait on a
|
|
37
|
+
* request that never completes.
|
|
36
38
|
* - With `captureLogs`, the server's console is captured: `stderrSoFar`
|
|
37
39
|
* holds stderr writes and every log line, and `consoleLogSoFar` holds
|
|
38
40
|
* anything that went through `console.log`, which in a real server is
|
|
@@ -46,6 +48,20 @@ import { McpProtocol } from "effect/ai";
|
|
|
46
48
|
* first (as in `McpStdio.protocols`), or `-32603 Internal error` when
|
|
47
49
|
* only stateful revisions are served.
|
|
48
50
|
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* import { McpHarness } from "@effected/mcp/testing";
|
|
54
|
+
* import { Effect } from "effect";
|
|
55
|
+
*
|
|
56
|
+
* // `ServerLayer` is the server's layer, built without its own `Stdio`.
|
|
57
|
+
* const program = Effect.gen(function* () {
|
|
58
|
+
* const harness = yield* McpHarness.make(ServerLayer);
|
|
59
|
+
* yield* harness.initialize;
|
|
60
|
+
* const tools = yield* harness.listTools;
|
|
61
|
+
* return tools.map((tool) => tool.name);
|
|
62
|
+
* }).pipe(Effect.scoped);
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
49
65
|
* @public
|
|
50
66
|
*/
|
|
51
67
|
var McpHarness = class McpHarness {
|
|
@@ -95,8 +111,19 @@ var McpHarness = class McpHarness {
|
|
|
95
111
|
stderrSoFar;
|
|
96
112
|
/** Every captured `console.log`, `info` or `debug` call. */
|
|
97
113
|
consoleLogSoFar;
|
|
98
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* End stdin, as a client disconnecting does. The server drains first:
|
|
116
|
+
* requests already in flight still answer, and the server stops once they
|
|
117
|
+
* have, so a wait on a request that never completes keeps waiting. Use
|
|
118
|
+
* {@link McpHarness.stop} to end the server regardless.
|
|
119
|
+
*/
|
|
99
120
|
close;
|
|
121
|
+
/**
|
|
122
|
+
* Interrupt the server without draining, as a client killing the process
|
|
123
|
+
* does: every pending response wait and `awaitOutboundMethod` fails with
|
|
124
|
+
* `ServerStopped`.
|
|
125
|
+
*/
|
|
126
|
+
stop;
|
|
100
127
|
constructor(parts) {
|
|
101
128
|
this.protocol = parts.protocol;
|
|
102
129
|
this.initialize = parts.initialize;
|
|
@@ -115,6 +142,7 @@ var McpHarness = class McpHarness {
|
|
|
115
142
|
this.stderrSoFar = parts.stderrSoFar;
|
|
116
143
|
this.consoleLogSoFar = parts.consoleLogSoFar;
|
|
117
144
|
this.close = parts.close;
|
|
145
|
+
this.stop = parts.stop;
|
|
118
146
|
}
|
|
119
147
|
/** Build `server` over queue-backed stdio and return a client for it; fails with the layer's own error if it cannot build. */
|
|
120
148
|
static make = (server, options = {}) => Effect.gen(function* () {
|
|
@@ -172,11 +200,11 @@ var McpHarness = class McpHarness {
|
|
|
172
200
|
}
|
|
173
201
|
return Effect.asVoid(Queue.offer(inbound, parsed));
|
|
174
202
|
};
|
|
175
|
-
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);
|
|
176
|
-
yield* Effect.flatMap(Effect.scope, (scope) => Layer.buildWithMemoMap(provided, Layer.makeMemoMapUnsafe(), scope)).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.andThen(Effect.never), Effect.scoped, Effect.onExit((exit) => Effect.andThen(Exit.isFailure(exit) ? Deferred.failCause(ready, exit.cause) : Effect.void, Deferred.fail(stopped, new McpTestFailure({
|
|
203
|
+
const router = yield* Stream.fromQueue(stdout).pipe(Stream.map((chunk) => typeof chunk === "string" ? chunk : stdoutDecoder.decode(chunk, { stream: true })), Stream.splitLines, Stream.runForEach((line) => line.length === 0 ? Effect.void : route(line)), Effect.forkScoped);
|
|
204
|
+
const serverFiber = yield* Effect.flatMap(Effect.scope, (scope) => Layer.buildWithMemoMap(provided, Layer.makeMemoMapUnsafe(), scope)).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.andThen(Effect.never), Effect.scoped, Effect.onExit((exit) => Effect.andThen(Exit.isFailure(exit) ? Deferred.failCause(ready, exit.cause) : Effect.void, Queue.end(stdout).pipe(Effect.andThen(Fiber.await(router)), Effect.andThen(Deferred.fail(stopped, new McpTestFailure({
|
|
177
205
|
reason: "ServerStopped",
|
|
178
206
|
message: "the server stopped before it responded"
|
|
179
|
-
})))), Effect.forkScoped);
|
|
207
|
+
})))))), Effect.forkScoped);
|
|
180
208
|
yield* Deferred.await(ready);
|
|
181
209
|
const stopAware = (wait) => Effect.raceAllFirst([
|
|
182
210
|
wait,
|
|
@@ -263,7 +291,8 @@ var McpHarness = class McpHarness {
|
|
|
263
291
|
sentSoFar: Effect.sync(() => [...sent]),
|
|
264
292
|
stderrSoFar: Effect.sync(() => stderr.join("")),
|
|
265
293
|
consoleLogSoFar: Effect.sync(() => [...consoleLog]),
|
|
266
|
-
close: Effect.asVoid(Queue.end(stdin))
|
|
294
|
+
close: Effect.asVoid(Queue.end(stdin)),
|
|
295
|
+
stop: Fiber.interrupt(serverFiber)
|
|
267
296
|
});
|
|
268
297
|
});
|
|
269
298
|
};
|
package/McpProbe.js
CHANGED
|
@@ -12,9 +12,9 @@ import { McpProtocol } from "effect/ai";
|
|
|
12
12
|
*
|
|
13
13
|
* @remarks
|
|
14
14
|
* Stdin stays open until the id-1 response has arrived. Closing it right
|
|
15
|
-
* after writing
|
|
16
|
-
*
|
|
17
|
-
* no response. The caller asserts `response.error === undefined`, `stderr` is
|
|
15
|
+
* after writing proves nothing against a server that stops at stdin EOF
|
|
16
|
+
* without answering what is in flight: it replies nothing and exits 0, so a
|
|
17
|
+
* slow boot reads as a pass with no response. The caller asserts `response.error === undefined`, `stderr` is
|
|
18
18
|
* empty and `exitCode` is 0; this is the MCP half of a packed-install proof.
|
|
19
19
|
* Checking stderr and the exit code alone passes a server that answers
|
|
20
20
|
* the handshake with a JSON-RPC error, exits 0 and writes nothing.
|
|
@@ -27,6 +27,20 @@ import { McpProtocol } from "effect/ai";
|
|
|
27
27
|
* - A child that ignores stdin EOF never exits, and the probe waits for it.
|
|
28
28
|
* Wrap the probe in `Effect.timeout`.
|
|
29
29
|
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { McpProbe } from "@effected/mcp/testing";
|
|
33
|
+
* import { Effect } from "effect";
|
|
34
|
+
* import { ChildProcess } from "effect/process";
|
|
35
|
+
*
|
|
36
|
+
* const program = Effect.gen(function* () {
|
|
37
|
+
* const probe = yield* McpProbe.initialize(ChildProcess.make(process.execPath, ["./bin/server.js"]));
|
|
38
|
+
* // A clean boot: no JSON-RPC error, empty stderr, exit code 0.
|
|
39
|
+
* return probe.response.error === undefined && probe.stderr === "" && probe.exitCode === 0;
|
|
40
|
+
* });
|
|
41
|
+
* // Requires a `ChildProcessSpawner` from the platform layer.
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
30
44
|
* @public
|
|
31
45
|
*/
|
|
32
46
|
var McpProbe = class {
|
package/McpProcess.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ToolFailure } from "./ToolFailure.js";
|
|
2
2
|
import { DEFAULT_CLIENT_INFO, STDERR_HINT, frame, initializeParams, isJsonRpcMessage, isResponse, isStateless, parseFrame } from "./internal/wire.js";
|
|
3
3
|
import { McpTestFailure } from "./McpTestFailure.js";
|
|
4
|
-
import { Deferred, Effect, Queue, Ref, Stream } from "effect";
|
|
4
|
+
import { Deferred, Duration, Effect, Option, Queue, Ref, Stream, SubscriptionRef } from "effect";
|
|
5
5
|
import { McpProtocol } from "effect/ai";
|
|
6
6
|
import { ChildProcessSpawner } from "effect/process";
|
|
7
7
|
|
|
@@ -22,8 +22,26 @@ import { ChildProcessSpawner } from "effect/process";
|
|
|
22
22
|
* `list_changed`, to the matching id and returns what it saw.
|
|
23
23
|
* - `closeStdin` is `Queue.end`, never `Queue.shutdown`, so every frame
|
|
24
24
|
* already sent is delivered before stdin closes.
|
|
25
|
-
* - A request in flight when stdin closes
|
|
26
|
-
*
|
|
25
|
+
* - A request in flight when stdin closes still answers from an Effect
|
|
26
|
+
* server, which drains before it exits; a server that does not drain
|
|
27
|
+
* drops it, so a portable client reads the response first.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { McpProcess } from "@effected/mcp/testing";
|
|
32
|
+
* import { Effect } from "effect";
|
|
33
|
+
* import { ChildProcess } from "effect/process";
|
|
34
|
+
*
|
|
35
|
+
* const program = Effect.gen(function* () {
|
|
36
|
+
* const child = yield* McpProcess.spawn(ChildProcess.make(process.execPath, ["./bin/server.js"]));
|
|
37
|
+
* yield* child.handshake();
|
|
38
|
+
* yield* child.send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
|
|
39
|
+
* const { response } = yield* child.readUntilResponse(2);
|
|
40
|
+
* yield* child.closeStdin;
|
|
41
|
+
* return response;
|
|
42
|
+
* }).pipe(Effect.scoped);
|
|
43
|
+
* // Requires a `ChildProcessSpawner` from the platform layer.
|
|
44
|
+
* ```
|
|
27
45
|
*
|
|
28
46
|
* @public
|
|
29
47
|
*/
|
|
@@ -60,6 +78,22 @@ var McpProcess = class McpProcess {
|
|
|
60
78
|
exitCode;
|
|
61
79
|
/** Everything written to stderr so far. */
|
|
62
80
|
stderrSoFar;
|
|
81
|
+
/**
|
|
82
|
+
* Wait until everything written to stderr so far satisfies `predicate`,
|
|
83
|
+
* and return that text.
|
|
84
|
+
*
|
|
85
|
+
* @remarks
|
|
86
|
+
* Event-driven: the predicate is checked against the current text at once,
|
|
87
|
+
* then again on every chunk the child writes, never on a polling timer.
|
|
88
|
+
* Fails with `StreamEnded` when stderr ends (the child exited) without the
|
|
89
|
+
* predicate holding, and with `TimedOut` once `options.timeout` passes;
|
|
90
|
+
* both messages carry the stderr seen so far. Reach for it whenever a
|
|
91
|
+
* report lands on a later tick than the responses a test reads, such as
|
|
92
|
+
* `McpGuard`'s `injectCrash: { at: "connected" }` report, instead of
|
|
93
|
+
* reading `stderrSoFar` once. `options.timeout` is real time: run the test
|
|
94
|
+
* under `it.live`.
|
|
95
|
+
*/
|
|
96
|
+
stderrUntil;
|
|
63
97
|
/** Everything written to stderr, once stderr has ended. Waits for the child to exit. */
|
|
64
98
|
stderrFinal;
|
|
65
99
|
constructor(parts) {
|
|
@@ -71,6 +105,7 @@ var McpProcess = class McpProcess {
|
|
|
71
105
|
this.closeStdin = parts.closeStdin;
|
|
72
106
|
this.exitCode = parts.exitCode;
|
|
73
107
|
this.stderrSoFar = parts.stderrSoFar;
|
|
108
|
+
this.stderrUntil = parts.stderrUntil;
|
|
74
109
|
this.stderrFinal = parts.stderrFinal;
|
|
75
110
|
}
|
|
76
111
|
/** Spawn `command` for the life of the current scope. */
|
|
@@ -82,9 +117,35 @@ var McpProcess = class McpProcess {
|
|
|
82
117
|
yield* Stream.run(Stream.fromQueue(stdin), handle.stdin).pipe(Effect.catch((error) => Ref.set(stdinFailure, error.message)), Effect.forkScoped);
|
|
83
118
|
const lines = yield* Queue.unbounded();
|
|
84
119
|
yield* Stream.splitLines(Stream.decodeText(handle.stdout)).pipe(Stream.runForEach((line) => line.length === 0 ? Effect.void : Effect.asVoid(Queue.offer(lines, line))), Effect.ensuring(Queue.end(lines)), Effect.forkScoped);
|
|
85
|
-
const stderr = yield*
|
|
120
|
+
const stderr = yield* SubscriptionRef.make({
|
|
121
|
+
text: "",
|
|
122
|
+
done: false
|
|
123
|
+
});
|
|
86
124
|
const stderrDone = yield* Deferred.make();
|
|
87
|
-
yield* Stream.decodeText(handle.stderr).pipe(Stream.runForEach((text) =>
|
|
125
|
+
yield* Stream.decodeText(handle.stderr).pipe(Stream.runForEach((text) => SubscriptionRef.update(stderr, (sofar) => ({
|
|
126
|
+
text: sofar.text + text,
|
|
127
|
+
done: false
|
|
128
|
+
}))), Effect.ensuring(Effect.andThen(SubscriptionRef.update(stderr, (sofar) => ({
|
|
129
|
+
text: sofar.text,
|
|
130
|
+
done: true
|
|
131
|
+
})), Deferred.succeed(stderrDone, void 0))), Effect.forkScoped);
|
|
132
|
+
const stderrText = Effect.map(SubscriptionRef.get(stderr), (state) => state.text);
|
|
133
|
+
const stderrUntil = (predicate, options) => SubscriptionRef.changes(stderr).pipe(Stream.filter((state) => state.done || predicate(state.text)), Stream.runHead, Effect.flatMap((found) => {
|
|
134
|
+
const state = Option.getOrElse(found, () => ({
|
|
135
|
+
text: "",
|
|
136
|
+
done: true
|
|
137
|
+
}));
|
|
138
|
+
return predicate(state.text) ? Effect.succeed(state.text) : Effect.fail(new McpTestFailure({
|
|
139
|
+
reason: "StreamEnded",
|
|
140
|
+
message: `the server's stderr ended before it matched; stderr: ${ToolFailure.truncate(state.text, ToolFailure.ENGINE_ECHO_LIMIT)}`
|
|
141
|
+
}));
|
|
142
|
+
}), Effect.timeoutOrElse({
|
|
143
|
+
duration: options.timeout,
|
|
144
|
+
orElse: () => Effect.flatMap(stderrText, (text) => Effect.fail(new McpTestFailure({
|
|
145
|
+
reason: "TimedOut",
|
|
146
|
+
message: `the server's stderr did not match within ${Duration.format(Duration.fromInputUnsafe(options.timeout))}; stderr so far: ${ToolFailure.truncate(text, ToolFailure.ENGINE_ECHO_LIMIT)}`
|
|
147
|
+
})))
|
|
148
|
+
}));
|
|
88
149
|
const nextLine = Queue.take(lines).pipe(Effect.catch(() => Effect.flatMap(Ref.get(stdinFailure), (pump) => Effect.fail(new McpTestFailure({
|
|
89
150
|
reason: "StreamEnded",
|
|
90
151
|
message: `the server's stdout ended before the expected line${STDERR_HINT}${pump === void 0 ? "" : `; stdin pump failed: ${ToolFailure.truncate(pump)}`}`
|
|
@@ -125,8 +186,9 @@ var McpProcess = class McpProcess {
|
|
|
125
186
|
handshake,
|
|
126
187
|
closeStdin: Effect.asVoid(Queue.end(stdin)),
|
|
127
188
|
exitCode: Effect.map(handle.exitCode, (code) => Number(code)),
|
|
128
|
-
stderrSoFar:
|
|
129
|
-
|
|
189
|
+
stderrSoFar: stderrText,
|
|
190
|
+
stderrUntil,
|
|
191
|
+
stderrFinal: Effect.andThen(Deferred.await(stderrDone), stderrText)
|
|
130
192
|
});
|
|
131
193
|
});
|
|
132
194
|
};
|
package/McpStdio.js
CHANGED
|
@@ -39,10 +39,9 @@ var McpStdio = class McpStdio {
|
|
|
39
39
|
* first, then the two newest stateful ones.
|
|
40
40
|
*
|
|
41
41
|
* @remarks
|
|
42
|
-
* Real clients open with either handshake: Claude Code
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* is load-bearing:
|
|
42
|
+
* Real clients open with either handshake: some (Claude Code, for one)
|
|
43
|
+
* open a stdio server with the stateless `server/discover`, while others
|
|
44
|
+
* still send `initialize`. The order is load-bearing:
|
|
46
45
|
*
|
|
47
46
|
* - Stateless first: a request with no session and no `_meta` falls to
|
|
48
47
|
* `protocols[0]`.
|
|
@@ -65,6 +64,16 @@ var McpStdio = class McpStdio {
|
|
|
65
64
|
* `Layer.provideMerge(McpStdio.layer(...))`. A sibling merged beside it
|
|
66
65
|
* with `Layer.mergeAll` is not provided by it, so its build logs still
|
|
67
66
|
* go through `console.log`, onto the wire.
|
|
67
|
+
|
|
68
|
+
*
|
|
69
|
+
* `LogToStderr` only reaches a logger that reads it: Effect's default
|
|
70
|
+
* logger and `Logger.consolePretty`. `Logger.consoleJson`,
|
|
71
|
+
* `Logger.consoleLogFmt` and `Logger.consoleStructured` are built with
|
|
72
|
+
* `Logger.withConsoleLog`, which writes through `console.log` whatever
|
|
73
|
+
* `LogToStderr` says, so a server that installs one puts every log line on
|
|
74
|
+
* the wire. Never install them under this layer; wrap the formatter in
|
|
75
|
+
* `Logger.withConsoleError` instead, as in
|
|
76
|
+
* `Logger.layer([Logger.withConsoleError(Logger.formatJson)])`.
|
|
68
77
|
*
|
|
69
78
|
* The server reads stdin through a guard that frames it exactly as core's
|
|
70
79
|
* decoder does: one streaming UTF-8 decoder, a byte-order mark stripped
|
package/McpTestFailure.js
CHANGED
|
@@ -10,7 +10,10 @@ import { Schema } from "effect";
|
|
|
10
10
|
*
|
|
11
11
|
* - `StreamEnded` (`McpProcess`, `McpProbe`): the spawned child's stdout
|
|
12
12
|
* ended first, because the child exited. `McpProbe` folds the exit code
|
|
13
|
-
* and stderr into the message.
|
|
13
|
+
* and stderr into the message. `McpProcess.stderrUntil` raises it when
|
|
14
|
+
* stderr ends before its predicate holds.
|
|
15
|
+
* - `TimedOut` (`McpProcess.stderrUntil`): the predicate did not hold
|
|
16
|
+
* within the given timeout. The message carries the stderr seen so far.
|
|
14
17
|
* - `NotJsonRpc` (`McpProcess.readUntilResponse`, `McpProbe`): a spawned
|
|
15
18
|
* child's stdout line was not a JSON-RPC frame. `McpHarness` never raises
|
|
16
19
|
* it: under `strictStdout` a non-JSON-RPC line DIES the pending wait as a
|
|
@@ -30,13 +33,16 @@ import { Schema } from "effect";
|
|
|
30
33
|
* @public
|
|
31
34
|
*/
|
|
32
35
|
var McpTestFailure = class extends Schema.TaggedError()("McpTestFailure", {
|
|
36
|
+
/** Which failure occurred; see the list above for which client raises which. */
|
|
33
37
|
reason: Schema.Literals([
|
|
34
38
|
"StreamEnded",
|
|
35
39
|
"ServerStopped",
|
|
36
40
|
"NotJsonRpc",
|
|
37
41
|
"NotInitialized",
|
|
38
|
-
"ErrorResponse"
|
|
42
|
+
"ErrorResponse",
|
|
43
|
+
"TimedOut"
|
|
39
44
|
]),
|
|
45
|
+
/** A human-readable description of what went wrong. */
|
|
40
46
|
message: Schema.String
|
|
41
47
|
}) {};
|
|
42
48
|
|
package/McpToolkit.js
CHANGED
|
@@ -185,6 +185,11 @@ var McpToolkit = class {
|
|
|
185
185
|
*
|
|
186
186
|
* @example
|
|
187
187
|
* ```ts
|
|
188
|
+
* import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
|
|
189
|
+
* import { Schema } from "effect";
|
|
190
|
+
* import { Tool } from "effect/ai";
|
|
191
|
+
*
|
|
192
|
+
* // `AddNote`, `ListNotes`, `Added`, `Listed` are object schemas; `Kit` is a Toolkit that includes `Note`.
|
|
188
193
|
* const Note = McpToolkit.unionTool("note", {
|
|
189
194
|
* parameters: Schema.Union([AddNote, ListNotes]),
|
|
190
195
|
* success: ToolOutputSchema.objectRooted(Schema.Union([Added, Listed])),
|
package/README.md
CHANGED
|
@@ -7,10 +7,10 @@ The boundary layer of an `effect/ai` MCP server: stdio wiring that keeps stdout
|
|
|
7
7
|
[](https://nodejs.org/)
|
|
8
8
|
[](https://www.typescriptlang.org/)
|
|
9
9
|
|
|
10
|
-
> **Pre
|
|
11
|
-
>
|
|
12
|
-
> `1.0.0`
|
|
13
|
-
>
|
|
10
|
+
> **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
|
|
11
|
+
> Effect v4 (`effect` `^4.0.0`) and still in `0.x` development. Stable Effect
|
|
12
|
+
> makes a kit `1.0.0` possible, not automatic. To keep your `effect` and
|
|
13
|
+
> `@effect/*` versions on the line the kit is built and tested against, install
|
|
14
14
|
> [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
|
|
15
15
|
>
|
|
16
16
|
> **Stability: unstable.** This package's API surface is not yet considered
|
package/ToolFailure.js
CHANGED
|
@@ -26,6 +26,9 @@ const isHighSurrogate = (code) => code >= 55296 && code <= 56319;
|
|
|
26
26
|
*
|
|
27
27
|
* @example
|
|
28
28
|
* ```ts
|
|
29
|
+
* import { ToolFailure } from "@effected/mcp";
|
|
30
|
+
* import { Schema } from "effect";
|
|
31
|
+
*
|
|
29
32
|
* class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { ...ToolFailure.fields, id: Schema.String }) {}
|
|
30
33
|
* const remediation = { hint: "List the ids first.", suggestedTool: "list_things" }
|
|
31
34
|
* new NotFound({ id, remediation, message: ToolFailure.message(`No thing "${ToolFailure.truncate(id)}".`, remediation) })
|
package/ToolRefusal.js
CHANGED
|
@@ -14,11 +14,27 @@ import { Schema } from "effect";
|
|
|
14
14
|
* {@link ToolRefusal.refuse}; its message already carries the remediation,
|
|
15
15
|
* because a declared `Error` failure sends `message` and nothing else.
|
|
16
16
|
*
|
|
17
|
-
* Audit every throw path
|
|
18
|
-
*
|
|
17
|
+
* Audit every throw path of a tool: any failure left undeclared loses its
|
|
18
|
+
* message on the wire, however carefully it was worded.
|
|
19
|
+
*
|
|
20
|
+
* **Structured data on a refusal is deliberately unreachable: put it in the
|
|
21
|
+
* message.** Core's `McpServer.registerToolkit` builds every failed call's
|
|
22
|
+
* result with `structuredContent` unset and no `_meta`, on every protocol
|
|
23
|
+
* revision. Under the default `failureMode: "error"` a declared `Error`
|
|
24
|
+
* failure sends `message` as the only text; under `failureMode: "return"`
|
|
25
|
+
* it sends the whole encoded failure JSON-stringified into the text, still
|
|
26
|
+
* with no `structuredContent`, which buries the human message in JSON a
|
|
27
|
+
* client must parse. So `refuse` takes no data argument: a list of valid
|
|
28
|
+
* names, the root that was searched, the id that was not found all belong
|
|
29
|
+
* in `reason`, truncated with {@link ToolFailure.truncate}. When an agent
|
|
30
|
+
* must branch on the data itself, return it on the success channel as an
|
|
31
|
+
* `ok: false` envelope, where `structuredContent` does reach the client.
|
|
19
32
|
*
|
|
20
33
|
* @example
|
|
21
34
|
* ```ts
|
|
35
|
+
* import { ToolFailure, ToolRefusal } from "@effected/mcp";
|
|
36
|
+
* import { Tool } from "effect/ai";
|
|
37
|
+
*
|
|
22
38
|
* const Lookup = Tool.make("lookup", { parameters, success, failure: ToolRefusal })
|
|
23
39
|
* // in the handler:
|
|
24
40
|
* return yield* ToolRefusal.refuse(`No run "${ToolFailure.truncate(id)}".`, { hint: "List runs first.", suggestedTool: "list_runs" })
|
package/guard.d.ts
CHANGED
|
@@ -89,6 +89,13 @@ interface McpGuardRunOptions<ROut, E> {
|
|
|
89
89
|
* serving, where `"exitBeforeConnect"` logs and keeps serving. A throw
|
|
90
90
|
* from a test double's `exit` there is dropped.
|
|
91
91
|
*
|
|
92
|
+
* **The `"connected"` report is asynchronous.** It is raised on a later
|
|
93
|
+
* tick, a `setTimeout(0)` after the server starts serving, so it can land
|
|
94
|
+
* after the first responses a test reads: reading stderr once, right
|
|
95
|
+
* after the `initialize` or `tools/list` response, can see an empty
|
|
96
|
+
* buffer. Wait for it with `McpProcess.stderrUntil` from
|
|
97
|
+
* `@effected/mcp/testing` rather than reading it once.
|
|
98
|
+
*
|
|
92
99
|
* Either is raised only through `host.emit`, on a `setTimeout(0)` tick,
|
|
93
100
|
* never as a real throw or rejection, so the guard's listeners handle it
|
|
94
101
|
* the same way under the real `process` and under a test double. Under
|
|
@@ -108,10 +115,14 @@ interface McpGuardRunOptions<ROut, E> {
|
|
|
108
115
|
* module graph is loaded. Imported from `@effected/mcp/guard`.
|
|
109
116
|
*
|
|
110
117
|
* @remarks
|
|
111
|
-
* {@link McpGuard.run}
|
|
112
|
-
*
|
|
113
|
-
* `
|
|
114
|
-
*
|
|
118
|
+
* {@link McpGuard.run} is `ProcessGuard.run` from `@effected/engine/guard`
|
|
119
|
+
* with an MCP launch in its `load`: it registers `uncaughtException` and
|
|
120
|
+
* `unhandledRejection` listeners on `host`, then awaits `load()`, then
|
|
121
|
+
* launches the server with `McpStdio.launch` and `McpStdio.teardown` under
|
|
122
|
+
* the `runMain` it returned. A server on another transport (an LSP over
|
|
123
|
+
* `vscode-languageserver`, say) uses `ProcessGuard.run` directly and calls
|
|
124
|
+
* its `markConnected` itself. This entrypoint statically imports only
|
|
125
|
+
* `@effected/engine/guard`, which imports nothing: it loads `effect` and the
|
|
115
126
|
* stdio wiring only after the guards are listening, so a throw while any of
|
|
116
127
|
* it evaluates is still reported on stderr.
|
|
117
128
|
*
|
|
@@ -149,6 +160,18 @@ interface McpGuardRunOptions<ROut, E> {
|
|
|
149
160
|
*/
|
|
150
161
|
export declare class McpGuard {
|
|
151
162
|
private constructor();
|
|
163
|
+
/**
|
|
164
|
+
* Parse a test-only crash-injection setting into {@link McpGuardRunOptions.injectCrash}: `<at>:<kind>`, where `at`
|
|
165
|
+
* is `"load"` or `"connected"` and `kind` is `"uncaughtException"` or `"unhandledRejection"`. Anything else, or no
|
|
166
|
+
* value, is `undefined`. The same grammar as `ProcessGuard.parseInjectCrash` from `@effected/engine/guard`, which
|
|
167
|
+
* this is, so an MCP launcher need not import the engine guard for it.
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* ```ts
|
|
171
|
+
* McpGuard.parseInjectCrash("load:uncaughtException"); // => { at: "load", kind: "uncaughtException" }
|
|
172
|
+
* ```
|
|
173
|
+
*/
|
|
174
|
+
static readonly parseInjectCrash: (value: string | undefined) => McpGuardRunOptions<unknown, unknown>["injectCrash"];
|
|
152
175
|
/** Install the guards, load the server, and launch it. Resolves once the server is launched. */
|
|
153
176
|
static readonly run: <ROut, E>(options: McpGuardRunOptions<ROut, E>) => Promise<void>;
|
|
154
177
|
}
|
package/index.d.ts
CHANGED
|
@@ -74,10 +74,9 @@ export declare class McpStdio {
|
|
|
74
74
|
* first, then the two newest stateful ones.
|
|
75
75
|
*
|
|
76
76
|
* @remarks
|
|
77
|
-
* Real clients open with either handshake: Claude Code
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* is load-bearing:
|
|
77
|
+
* Real clients open with either handshake: some (Claude Code, for one)
|
|
78
|
+
* open a stdio server with the stateless `server/discover`, while others
|
|
79
|
+
* still send `initialize`. The order is load-bearing:
|
|
81
80
|
*
|
|
82
81
|
* - Stateless first: a request with no session and no `_meta` falls to
|
|
83
82
|
* `protocols[0]`.
|
|
@@ -97,6 +96,15 @@ export declare class McpStdio {
|
|
|
97
96
|
* with `Layer.mergeAll` is not provided by it, so its build logs still
|
|
98
97
|
* go through `console.log`, onto the wire.
|
|
99
98
|
*
|
|
99
|
+
* `LogToStderr` only reaches a logger that reads it: Effect's default
|
|
100
|
+
* logger and `Logger.consolePretty`. `Logger.consoleJson`,
|
|
101
|
+
* `Logger.consoleLogFmt` and `Logger.consoleStructured` are built with
|
|
102
|
+
* `Logger.withConsoleLog`, which writes through `console.log` whatever
|
|
103
|
+
* `LogToStderr` says, so a server that installs one puts every log line on
|
|
104
|
+
* the wire. Never install them under this layer; wrap the formatter in
|
|
105
|
+
* `Logger.withConsoleError` instead, as in
|
|
106
|
+
* `Logger.layer([Logger.withConsoleError(Logger.formatJson)])`.
|
|
107
|
+
*
|
|
100
108
|
* The server reads stdin through a guard that frames it exactly as core's
|
|
101
109
|
* decoder does: one streaming UTF-8 decoder, a byte-order mark stripped
|
|
102
110
|
* only at the start of the stream, lines split on `\n`. Core itself skips
|
|
@@ -429,6 +437,11 @@ export declare class McpToolkit {
|
|
|
429
437
|
*
|
|
430
438
|
* @example
|
|
431
439
|
* ```ts
|
|
440
|
+
* import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
|
|
441
|
+
* import { Schema } from "effect";
|
|
442
|
+
* import { Tool } from "effect/ai";
|
|
443
|
+
*
|
|
444
|
+
* // `AddNote`, `ListNotes`, `Added`, `Listed` are object schemas; `Kit` is a Toolkit that includes `Note`.
|
|
432
445
|
* const Note = McpToolkit.unionTool("note", {
|
|
433
446
|
* parameters: Schema.Union([AddNote, ListNotes]),
|
|
434
447
|
* success: ToolOutputSchema.objectRooted(Schema.Union([Added, Listed])),
|
|
@@ -488,6 +501,9 @@ export declare class McpToolkit {
|
|
|
488
501
|
*
|
|
489
502
|
* @example
|
|
490
503
|
* ```ts
|
|
504
|
+
* import { ToolFailure } from "@effected/mcp";
|
|
505
|
+
* import { Schema } from "effect";
|
|
506
|
+
*
|
|
491
507
|
* class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { ...ToolFailure.fields, id: Schema.String }) {}
|
|
492
508
|
* const remediation = { hint: "List the ids first.", suggestedTool: "list_things" }
|
|
493
509
|
* new NotFound({ id, remediation, message: ToolFailure.message(`No thing "${ToolFailure.truncate(id)}".`, remediation) })
|
|
@@ -594,11 +610,27 @@ declare const ToolRefusal_base: Schema.Class<ToolRefusal, Schema.TaggedStruct<"T
|
|
|
594
610
|
* {@link ToolRefusal.refuse}; its message already carries the remediation,
|
|
595
611
|
* because a declared `Error` failure sends `message` and nothing else.
|
|
596
612
|
*
|
|
597
|
-
* Audit every throw path
|
|
598
|
-
*
|
|
613
|
+
* Audit every throw path of a tool: any failure left undeclared loses its
|
|
614
|
+
* message on the wire, however carefully it was worded.
|
|
615
|
+
*
|
|
616
|
+
* **Structured data on a refusal is deliberately unreachable: put it in the
|
|
617
|
+
* message.** Core's `McpServer.registerToolkit` builds every failed call's
|
|
618
|
+
* result with `structuredContent` unset and no `_meta`, on every protocol
|
|
619
|
+
* revision. Under the default `failureMode: "error"` a declared `Error`
|
|
620
|
+
* failure sends `message` as the only text; under `failureMode: "return"`
|
|
621
|
+
* it sends the whole encoded failure JSON-stringified into the text, still
|
|
622
|
+
* with no `structuredContent`, which buries the human message in JSON a
|
|
623
|
+
* client must parse. So `refuse` takes no data argument: a list of valid
|
|
624
|
+
* names, the root that was searched, the id that was not found all belong
|
|
625
|
+
* in `reason`, truncated with {@link ToolFailure.truncate}. When an agent
|
|
626
|
+
* must branch on the data itself, return it on the success channel as an
|
|
627
|
+
* `ok: false` envelope, where `structuredContent` does reach the client.
|
|
599
628
|
*
|
|
600
629
|
* @example
|
|
601
630
|
* ```ts
|
|
631
|
+
* import { ToolFailure, ToolRefusal } from "@effected/mcp";
|
|
632
|
+
* import { Tool } from "effect/ai";
|
|
633
|
+
*
|
|
602
634
|
* const Lookup = Tool.make("lookup", { parameters, success, failure: ToolRefusal })
|
|
603
635
|
* // in the handler:
|
|
604
636
|
* return yield* ToolRefusal.refuse(`No run "${ToolFailure.truncate(id)}".`, { hint: "List runs first.", suggestedTool: "list_runs" })
|
package/internal/LaunchFailed.js
CHANGED
|
@@ -7,15 +7,27 @@ import { Runtime } from "effect";
|
|
|
7
7
|
* second time — through a logger that writes to stdout — and the original exit
|
|
8
8
|
* code rides along for the teardown.
|
|
9
9
|
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* Both runtime markers are prototype getters rather than own fields, so a JSON
|
|
12
|
+
* or logger dump of the error does not carry them; the code lives in a private
|
|
13
|
+
* field.
|
|
14
|
+
*
|
|
10
15
|
* @internal
|
|
11
16
|
*/
|
|
12
17
|
var LaunchFailed = class extends Error {
|
|
13
|
-
|
|
14
|
-
[Runtime.errorExitCode];
|
|
18
|
+
#code;
|
|
15
19
|
constructor(code) {
|
|
16
20
|
super(`the MCP server failed to launch (exit ${code})`);
|
|
17
21
|
this.name = "LaunchFailed";
|
|
18
|
-
this
|
|
22
|
+
this.#code = code;
|
|
23
|
+
}
|
|
24
|
+
/** Already reported on stderr: `runMain` must not report it again. */
|
|
25
|
+
get [Runtime.errorReported]() {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
/** The exit code the launch failed with. */
|
|
29
|
+
get [Runtime.errorExitCode]() {
|
|
30
|
+
return this.#code;
|
|
19
31
|
}
|
|
20
32
|
};
|
|
21
33
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The boundary layer of an effect/ai MCP server: stdio wiring that keeps stdout the wire, tool-failure shaping, strict-input walkers, and test clients",
|
|
6
6
|
"keywords": [
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"./package.json": "./package.json"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@effected/engine": "^0.
|
|
51
|
-
"effect": "4.0.0
|
|
50
|
+
"@effected/engine": "^0.4.0",
|
|
51
|
+
"effect": "^4.0.0"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=24.11.0"
|
package/testing.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { Effect, JsonSchema, Layer, PlatformError, Schema, Scope, Stdio } from "effect";
|
|
1
|
+
import { Duration, Effect, JsonSchema, Layer, PlatformError, Schema, Scope, Stdio } from "effect";
|
|
2
2
|
import { McpProtocol } from "effect/ai";
|
|
3
3
|
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
4
4
|
//#region src/McpTestFailure.d.ts
|
|
5
5
|
declare const McpTestFailure_base: Schema.Class<McpTestFailure, Schema.TaggedStruct<"McpTestFailure", {
|
|
6
|
-
|
|
6
|
+
/** Which failure occurred; see the list above for which client raises which. */
|
|
7
|
+
readonly reason: Schema.Literals<readonly ["StreamEnded", "ServerStopped", "NotJsonRpc", "NotInitialized", "ErrorResponse", "TimedOut"]>;
|
|
8
|
+
/** A human-readable description of what went wrong. */
|
|
7
9
|
readonly message: Schema.String;
|
|
8
10
|
}>, import("effect/Cause").YieldableError>;
|
|
9
11
|
/**
|
|
@@ -15,7 +17,10 @@ declare const McpTestFailure_base: Schema.Class<McpTestFailure, Schema.TaggedStr
|
|
|
15
17
|
*
|
|
16
18
|
* - `StreamEnded` (`McpProcess`, `McpProbe`): the spawned child's stdout
|
|
17
19
|
* ended first, because the child exited. `McpProbe` folds the exit code
|
|
18
|
-
* and stderr into the message.
|
|
20
|
+
* and stderr into the message. `McpProcess.stderrUntil` raises it when
|
|
21
|
+
* stderr ends before its predicate holds.
|
|
22
|
+
* - `TimedOut` (`McpProcess.stderrUntil`): the predicate did not hold
|
|
23
|
+
* within the given timeout. The message carries the stderr seen so far.
|
|
19
24
|
* - `NotJsonRpc` (`McpProcess.readUntilResponse`, `McpProbe`): a spawned
|
|
20
25
|
* child's stdout line was not a JSON-RPC frame. `McpHarness` never raises
|
|
21
26
|
* it: under `strictStdout` a non-JSON-RPC line DIES the pending wait as a
|
|
@@ -132,9 +137,11 @@ interface McpHarnessOptions {
|
|
|
132
137
|
* once and pass `Layer.succeed(Tag, value)`.
|
|
133
138
|
*
|
|
134
139
|
* - Responses are matched by id, so notifications may interleave freely.
|
|
135
|
-
* -
|
|
136
|
-
*
|
|
137
|
-
*
|
|
140
|
+
* - Every response wait and `awaitOutboundMethod` fails with `ServerStopped`
|
|
141
|
+
* when the server stops, and dies when `strictStdout` sees a line that is
|
|
142
|
+
* not JSON-RPC. `stop` stops the server at once; `close` ends stdin and lets
|
|
143
|
+
* in-flight requests answer first, so only `stop` releases a wait on a
|
|
144
|
+
* request that never completes.
|
|
138
145
|
* - With `captureLogs`, the server's console is captured: `stderrSoFar`
|
|
139
146
|
* holds stderr writes and every log line, and `consoleLogSoFar` holds
|
|
140
147
|
* anything that went through `console.log`, which in a real server is
|
|
@@ -148,6 +155,20 @@ interface McpHarnessOptions {
|
|
|
148
155
|
* first (as in `McpStdio.protocols`), or `-32603 Internal error` when
|
|
149
156
|
* only stateful revisions are served.
|
|
150
157
|
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```ts
|
|
160
|
+
* import { McpHarness } from "@effected/mcp/testing";
|
|
161
|
+
* import { Effect } from "effect";
|
|
162
|
+
*
|
|
163
|
+
* // `ServerLayer` is the server's layer, built without its own `Stdio`.
|
|
164
|
+
* const program = Effect.gen(function* () {
|
|
165
|
+
* const harness = yield* McpHarness.make(ServerLayer);
|
|
166
|
+
* yield* harness.initialize;
|
|
167
|
+
* const tools = yield* harness.listTools;
|
|
168
|
+
* return tools.map((tool) => tool.name);
|
|
169
|
+
* }).pipe(Effect.scoped);
|
|
170
|
+
* ```
|
|
171
|
+
*
|
|
151
172
|
* @public
|
|
152
173
|
*/
|
|
153
174
|
export declare class McpHarness {
|
|
@@ -200,8 +221,19 @@ export declare class McpHarness {
|
|
|
200
221
|
readonly stderrSoFar: Effect.Effect<string>;
|
|
201
222
|
/** Every captured `console.log`, `info` or `debug` call. */
|
|
202
223
|
readonly consoleLogSoFar: Effect.Effect<ReadonlyArray<string>>;
|
|
203
|
-
/**
|
|
224
|
+
/**
|
|
225
|
+
* End stdin, as a client disconnecting does. The server drains first:
|
|
226
|
+
* requests already in flight still answer, and the server stops once they
|
|
227
|
+
* have, so a wait on a request that never completes keeps waiting. Use
|
|
228
|
+
* {@link McpHarness.stop} to end the server regardless.
|
|
229
|
+
*/
|
|
204
230
|
readonly close: Effect.Effect<void>;
|
|
231
|
+
/**
|
|
232
|
+
* Interrupt the server without draining, as a client killing the process
|
|
233
|
+
* does: every pending response wait and `awaitOutboundMethod` fails with
|
|
234
|
+
* `ServerStopped`.
|
|
235
|
+
*/
|
|
236
|
+
readonly stop: Effect.Effect<void>;
|
|
205
237
|
private constructor();
|
|
206
238
|
/** Build `server` over queue-backed stdio and return a client for it; fails with the layer's own error if it cannot build. */
|
|
207
239
|
static readonly make: <ROut, E, R>(server: Layer.Layer<ROut, E, R>, options?: McpHarnessOptions) => Effect.Effect<McpHarness, E, Scope.Scope | Exclude<R, Stdio.Stdio>>;
|
|
@@ -238,9 +270,9 @@ interface McpProbeResult {
|
|
|
238
270
|
*
|
|
239
271
|
* @remarks
|
|
240
272
|
* Stdin stays open until the id-1 response has arrived. Closing it right
|
|
241
|
-
* after writing
|
|
242
|
-
*
|
|
243
|
-
* no response. The caller asserts `response.error === undefined`, `stderr` is
|
|
273
|
+
* after writing proves nothing against a server that stops at stdin EOF
|
|
274
|
+
* without answering what is in flight: it replies nothing and exits 0, so a
|
|
275
|
+
* slow boot reads as a pass with no response. The caller asserts `response.error === undefined`, `stderr` is
|
|
244
276
|
* empty and `exitCode` is 0; this is the MCP half of a packed-install proof.
|
|
245
277
|
* Checking stderr and the exit code alone passes a server that answers
|
|
246
278
|
* the handshake with a JSON-RPC error, exits 0 and writes nothing.
|
|
@@ -253,6 +285,20 @@ interface McpProbeResult {
|
|
|
253
285
|
* - A child that ignores stdin EOF never exits, and the probe waits for it.
|
|
254
286
|
* Wrap the probe in `Effect.timeout`.
|
|
255
287
|
*
|
|
288
|
+
* @example
|
|
289
|
+
* ```ts
|
|
290
|
+
* import { McpProbe } from "@effected/mcp/testing";
|
|
291
|
+
* import { Effect } from "effect";
|
|
292
|
+
* import { ChildProcess } from "effect/process";
|
|
293
|
+
*
|
|
294
|
+
* const program = Effect.gen(function* () {
|
|
295
|
+
* const probe = yield* McpProbe.initialize(ChildProcess.make(process.execPath, ["./bin/server.js"]));
|
|
296
|
+
* // A clean boot: no JSON-RPC error, empty stderr, exit code 0.
|
|
297
|
+
* return probe.response.error === undefined && probe.stderr === "" && probe.exitCode === 0;
|
|
298
|
+
* });
|
|
299
|
+
* // Requires a `ChildProcessSpawner` from the platform layer.
|
|
300
|
+
* ```
|
|
301
|
+
*
|
|
256
302
|
* @public
|
|
257
303
|
*/
|
|
258
304
|
export declare class McpProbe {
|
|
@@ -266,6 +312,20 @@ export declare class McpProbe {
|
|
|
266
312
|
}
|
|
267
313
|
//#endregion
|
|
268
314
|
//#region src/McpProcess.d.ts
|
|
315
|
+
/**
|
|
316
|
+
* Options for {@link McpProcess.stderrUntil}.
|
|
317
|
+
*
|
|
318
|
+
* @public
|
|
319
|
+
*/
|
|
320
|
+
interface McpProcessStderrUntilOptions {
|
|
321
|
+
/**
|
|
322
|
+
* How long to wait for the predicate to hold before failing with
|
|
323
|
+
* `TimedOut`. Real time: under `it.effect`'s `TestClock` it never fires,
|
|
324
|
+
* so a test that waits on stderr runs under `it.live`. Keep it below the
|
|
325
|
+
* test runner's own timeout, or the runner kills the test first.
|
|
326
|
+
*/
|
|
327
|
+
readonly timeout: Duration.Input;
|
|
328
|
+
}
|
|
269
329
|
/**
|
|
270
330
|
* A spawned MCP server bin a test writes to while it runs.
|
|
271
331
|
*
|
|
@@ -282,8 +342,26 @@ export declare class McpProbe {
|
|
|
282
342
|
* `list_changed`, to the matching id and returns what it saw.
|
|
283
343
|
* - `closeStdin` is `Queue.end`, never `Queue.shutdown`, so every frame
|
|
284
344
|
* already sent is delivered before stdin closes.
|
|
285
|
-
* - A request in flight when stdin closes
|
|
286
|
-
*
|
|
345
|
+
* - A request in flight when stdin closes still answers from an Effect
|
|
346
|
+
* server, which drains before it exits; a server that does not drain
|
|
347
|
+
* drops it, so a portable client reads the response first.
|
|
348
|
+
*
|
|
349
|
+
* @example
|
|
350
|
+
* ```ts
|
|
351
|
+
* import { McpProcess } from "@effected/mcp/testing";
|
|
352
|
+
* import { Effect } from "effect";
|
|
353
|
+
* import { ChildProcess } from "effect/process";
|
|
354
|
+
*
|
|
355
|
+
* const program = Effect.gen(function* () {
|
|
356
|
+
* const child = yield* McpProcess.spawn(ChildProcess.make(process.execPath, ["./bin/server.js"]));
|
|
357
|
+
* yield* child.handshake();
|
|
358
|
+
* yield* child.send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
|
|
359
|
+
* const { response } = yield* child.readUntilResponse(2);
|
|
360
|
+
* yield* child.closeStdin;
|
|
361
|
+
* return response;
|
|
362
|
+
* }).pipe(Effect.scoped);
|
|
363
|
+
* // Requires a `ChildProcessSpawner` from the platform layer.
|
|
364
|
+
* ```
|
|
287
365
|
*
|
|
288
366
|
* @public
|
|
289
367
|
*/
|
|
@@ -323,6 +401,22 @@ export declare class McpProcess {
|
|
|
323
401
|
readonly exitCode: Effect.Effect<number, PlatformError.PlatformError>;
|
|
324
402
|
/** Everything written to stderr so far. */
|
|
325
403
|
readonly stderrSoFar: Effect.Effect<string>;
|
|
404
|
+
/**
|
|
405
|
+
* Wait until everything written to stderr so far satisfies `predicate`,
|
|
406
|
+
* and return that text.
|
|
407
|
+
*
|
|
408
|
+
* @remarks
|
|
409
|
+
* Event-driven: the predicate is checked against the current text at once,
|
|
410
|
+
* then again on every chunk the child writes, never on a polling timer.
|
|
411
|
+
* Fails with `StreamEnded` when stderr ends (the child exited) without the
|
|
412
|
+
* predicate holding, and with `TimedOut` once `options.timeout` passes;
|
|
413
|
+
* both messages carry the stderr seen so far. Reach for it whenever a
|
|
414
|
+
* report lands on a later tick than the responses a test reads, such as
|
|
415
|
+
* `McpGuard`'s `injectCrash: { at: "connected" }` report, instead of
|
|
416
|
+
* reading `stderrSoFar` once. `options.timeout` is real time: run the test
|
|
417
|
+
* under `it.live`.
|
|
418
|
+
*/
|
|
419
|
+
readonly stderrUntil: (predicate: (stderr: string) => boolean, options: McpProcessStderrUntilOptions) => Effect.Effect<string, McpTestFailure>;
|
|
326
420
|
/** Everything written to stderr, once stderr has ended. Waits for the child to exit. */
|
|
327
421
|
readonly stderrFinal: Effect.Effect<string>;
|
|
328
422
|
private constructor();
|
|
@@ -349,7 +443,7 @@ interface McpToolAuditPolicy {
|
|
|
349
443
|
readonly requireOutputSchema?: boolean | undefined;
|
|
350
444
|
/**
|
|
351
445
|
* A served `outputSchema` must be rooted at `type: "object"`. Defaults to
|
|
352
|
-
* `true
|
|
446
|
+
* `true`. Only the stateless revision serves a non-object root, so
|
|
353
447
|
* only there does this fire; a union root (`anyOf` or `oneOf`) names
|
|
354
448
|
* `ToolOutputSchema.objectRooted` as the fix for a union whose members are
|
|
355
449
|
* all objects (a union with a primitive member needs an object envelope
|
|
@@ -396,5 +490,5 @@ export declare class McpToolAudit {
|
|
|
396
490
|
static readonly check: (tools: ReadonlyArray<ServedTool>, policy: McpToolAuditPolicy) => ReadonlyArray<string>;
|
|
397
491
|
}
|
|
398
492
|
//#endregion
|
|
399
|
-
export type { JsonRpcMessage, McpHarnessOptions, McpProbeOptions, McpProbeResult, McpToolAuditPolicy, ServedResource, ServedTool };
|
|
493
|
+
export type { JsonRpcMessage, McpHarnessOptions, McpProbeOptions, McpProbeResult, McpProcessStderrUntilOptions, McpToolAuditPolicy, ServedResource, ServedTool };
|
|
400
494
|
//# sourceMappingURL=testing.d.ts.map
|