@effected/mcp 0.4.0 → 0.5.1
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 +24 -9
- package/McpProbe.js +3 -2
- package/McpProcess.js +52 -7
- package/McpStdio.js +10 -0
- package/McpTestFailure.js +6 -2
- package/ToolRefusal.js +13 -0
- package/guard.d.ts +27 -4
- package/index.d.ts +22 -0
- package/package.json +3 -3
- package/testing.d.ts +60 -12
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
|
|
@@ -109,8 +111,19 @@ var McpHarness = class McpHarness {
|
|
|
109
111
|
stderrSoFar;
|
|
110
112
|
/** Every captured `console.log`, `info` or `debug` call. */
|
|
111
113
|
consoleLogSoFar;
|
|
112
|
-
/**
|
|
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
|
+
*/
|
|
113
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;
|
|
114
127
|
constructor(parts) {
|
|
115
128
|
this.protocol = parts.protocol;
|
|
116
129
|
this.initialize = parts.initialize;
|
|
@@ -129,6 +142,7 @@ var McpHarness = class McpHarness {
|
|
|
129
142
|
this.stderrSoFar = parts.stderrSoFar;
|
|
130
143
|
this.consoleLogSoFar = parts.consoleLogSoFar;
|
|
131
144
|
this.close = parts.close;
|
|
145
|
+
this.stop = parts.stop;
|
|
132
146
|
}
|
|
133
147
|
/** Build `server` over queue-backed stdio and return a client for it; fails with the layer's own error if it cannot build. */
|
|
134
148
|
static make = (server, options = {}) => Effect.gen(function* () {
|
|
@@ -186,11 +200,11 @@ var McpHarness = class McpHarness {
|
|
|
186
200
|
}
|
|
187
201
|
return Effect.asVoid(Queue.offer(inbound, parsed));
|
|
188
202
|
};
|
|
189
|
-
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);
|
|
190
|
-
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({
|
|
191
205
|
reason: "ServerStopped",
|
|
192
206
|
message: "the server stopped before it responded"
|
|
193
|
-
})))), Effect.forkScoped);
|
|
207
|
+
})))))), Effect.forkScoped);
|
|
194
208
|
yield* Deferred.await(ready);
|
|
195
209
|
const stopAware = (wait) => Effect.raceAllFirst([
|
|
196
210
|
wait,
|
|
@@ -277,7 +291,8 @@ var McpHarness = class McpHarness {
|
|
|
277
291
|
sentSoFar: Effect.sync(() => [...sent]),
|
|
278
292
|
stderrSoFar: Effect.sync(() => stderr.join("")),
|
|
279
293
|
consoleLogSoFar: Effect.sync(() => [...consoleLog]),
|
|
280
|
-
close: Effect.asVoid(Queue.end(stdin))
|
|
294
|
+
close: Effect.asVoid(Queue.end(stdin)),
|
|
295
|
+
stop: Fiber.interrupt(serverFiber)
|
|
281
296
|
});
|
|
282
297
|
});
|
|
283
298
|
};
|
package/McpProbe.js
CHANGED
|
@@ -12,8 +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
|
-
*
|
|
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
|
|
17
18
|
* empty and `exitCode` is 0; this is the MCP half of a packed-install proof.
|
|
18
19
|
* Checking stderr and the exit code alone passes a server that answers
|
|
19
20
|
* the handshake with a JSON-RPC error, exits 0 and writes nothing.
|
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,9 @@ 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.
|
|
27
28
|
*
|
|
28
29
|
* @example
|
|
29
30
|
* ```ts
|
|
@@ -77,6 +78,22 @@ var McpProcess = class McpProcess {
|
|
|
77
78
|
exitCode;
|
|
78
79
|
/** Everything written to stderr so far. */
|
|
79
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;
|
|
80
97
|
/** Everything written to stderr, once stderr has ended. Waits for the child to exit. */
|
|
81
98
|
stderrFinal;
|
|
82
99
|
constructor(parts) {
|
|
@@ -88,6 +105,7 @@ var McpProcess = class McpProcess {
|
|
|
88
105
|
this.closeStdin = parts.closeStdin;
|
|
89
106
|
this.exitCode = parts.exitCode;
|
|
90
107
|
this.stderrSoFar = parts.stderrSoFar;
|
|
108
|
+
this.stderrUntil = parts.stderrUntil;
|
|
91
109
|
this.stderrFinal = parts.stderrFinal;
|
|
92
110
|
}
|
|
93
111
|
/** Spawn `command` for the life of the current scope. */
|
|
@@ -99,9 +117,35 @@ var McpProcess = class McpProcess {
|
|
|
99
117
|
yield* Stream.run(Stream.fromQueue(stdin), handle.stdin).pipe(Effect.catch((error) => Ref.set(stdinFailure, error.message)), Effect.forkScoped);
|
|
100
118
|
const lines = yield* Queue.unbounded();
|
|
101
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);
|
|
102
|
-
const stderr = yield*
|
|
120
|
+
const stderr = yield* SubscriptionRef.make({
|
|
121
|
+
text: "",
|
|
122
|
+
done: false
|
|
123
|
+
});
|
|
103
124
|
const stderrDone = yield* Deferred.make();
|
|
104
|
-
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
|
+
}));
|
|
105
149
|
const nextLine = Queue.take(lines).pipe(Effect.catch(() => Effect.flatMap(Ref.get(stdinFailure), (pump) => Effect.fail(new McpTestFailure({
|
|
106
150
|
reason: "StreamEnded",
|
|
107
151
|
message: `the server's stdout ended before the expected line${STDERR_HINT}${pump === void 0 ? "" : `; stdin pump failed: ${ToolFailure.truncate(pump)}`}`
|
|
@@ -142,8 +186,9 @@ var McpProcess = class McpProcess {
|
|
|
142
186
|
handshake,
|
|
143
187
|
closeStdin: Effect.asVoid(Queue.end(stdin)),
|
|
144
188
|
exitCode: Effect.map(handle.exitCode, (code) => Number(code)),
|
|
145
|
-
stderrSoFar:
|
|
146
|
-
|
|
189
|
+
stderrSoFar: stderrText,
|
|
190
|
+
stderrUntil,
|
|
191
|
+
stderrFinal: Effect.andThen(Deferred.await(stderrDone), stderrText)
|
|
147
192
|
});
|
|
148
193
|
});
|
|
149
194
|
};
|
package/McpStdio.js
CHANGED
|
@@ -64,6 +64,16 @@ var McpStdio = class McpStdio {
|
|
|
64
64
|
* `Layer.provideMerge(McpStdio.layer(...))`. A sibling merged beside it
|
|
65
65
|
* with `Layer.mergeAll` is not provided by it, so its build logs still
|
|
66
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)])`.
|
|
67
77
|
*
|
|
68
78
|
* The server reads stdin through a guard that frames it exactly as core's
|
|
69
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
|
|
@@ -36,7 +39,8 @@ var McpTestFailure = class extends Schema.TaggedError()("McpTestFailure", {
|
|
|
36
39
|
"ServerStopped",
|
|
37
40
|
"NotJsonRpc",
|
|
38
41
|
"NotInitialized",
|
|
39
|
-
"ErrorResponse"
|
|
42
|
+
"ErrorResponse",
|
|
43
|
+
"TimedOut"
|
|
40
44
|
]),
|
|
41
45
|
/** A human-readable description of what went wrong. */
|
|
42
46
|
message: Schema.String
|
package/ToolRefusal.js
CHANGED
|
@@ -17,6 +17,19 @@ import { Schema } from "effect";
|
|
|
17
17
|
* Audit every throw path of a tool: any failure left undeclared loses its
|
|
18
18
|
* message on the wire, however carefully it was worded.
|
|
19
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.
|
|
32
|
+
*
|
|
20
33
|
* @example
|
|
21
34
|
* ```ts
|
|
22
35
|
* import { ToolFailure, ToolRefusal } from "@effected/mcp";
|
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
|
@@ -96,6 +96,15 @@ export declare class McpStdio {
|
|
|
96
96
|
* with `Layer.mergeAll` is not provided by it, so its build logs still
|
|
97
97
|
* go through `console.log`, onto the wire.
|
|
98
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
|
+
*
|
|
99
108
|
* The server reads stdin through a guard that frames it exactly as core's
|
|
100
109
|
* decoder does: one streaming UTF-8 decoder, a byte-order mark stripped
|
|
101
110
|
* only at the start of the stream, lines split on `\n`. Core itself skips
|
|
@@ -604,6 +613,19 @@ declare const ToolRefusal_base: Schema.Class<ToolRefusal, Schema.TaggedStruct<"T
|
|
|
604
613
|
* Audit every throw path of a tool: any failure left undeclared loses its
|
|
605
614
|
* message on the wire, however carefully it was worded.
|
|
606
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.
|
|
628
|
+
*
|
|
607
629
|
* @example
|
|
608
630
|
* ```ts
|
|
609
631
|
* import { ToolFailure, ToolRefusal } from "@effected/mcp";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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": "
|
|
50
|
+
"@effected/engine": "^0.4.1",
|
|
51
|
+
"effect": "4.0.2"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=24.11.0"
|
package/testing.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
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"]>;
|
|
7
|
+
readonly reason: Schema.Literals<readonly ["StreamEnded", "ServerStopped", "NotJsonRpc", "NotInitialized", "ErrorResponse", "TimedOut"]>;
|
|
8
8
|
/** A human-readable description of what went wrong. */
|
|
9
9
|
readonly message: Schema.String;
|
|
10
10
|
}>, import("effect/Cause").YieldableError>;
|
|
@@ -17,7 +17,10 @@ declare const McpTestFailure_base: Schema.Class<McpTestFailure, Schema.TaggedStr
|
|
|
17
17
|
*
|
|
18
18
|
* - `StreamEnded` (`McpProcess`, `McpProbe`): the spawned child's stdout
|
|
19
19
|
* ended first, because the child exited. `McpProbe` folds the exit code
|
|
20
|
-
* 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.
|
|
21
24
|
* - `NotJsonRpc` (`McpProcess.readUntilResponse`, `McpProbe`): a spawned
|
|
22
25
|
* child's stdout line was not a JSON-RPC frame. `McpHarness` never raises
|
|
23
26
|
* it: under `strictStdout` a non-JSON-RPC line DIES the pending wait as a
|
|
@@ -134,9 +137,11 @@ interface McpHarnessOptions {
|
|
|
134
137
|
* once and pass `Layer.succeed(Tag, value)`.
|
|
135
138
|
*
|
|
136
139
|
* - Responses are matched by id, so notifications may interleave freely.
|
|
137
|
-
* -
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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.
|
|
140
145
|
* - With `captureLogs`, the server's console is captured: `stderrSoFar`
|
|
141
146
|
* holds stderr writes and every log line, and `consoleLogSoFar` holds
|
|
142
147
|
* anything that went through `console.log`, which in a real server is
|
|
@@ -216,8 +221,19 @@ export declare class McpHarness {
|
|
|
216
221
|
readonly stderrSoFar: Effect.Effect<string>;
|
|
217
222
|
/** Every captured `console.log`, `info` or `debug` call. */
|
|
218
223
|
readonly consoleLogSoFar: Effect.Effect<ReadonlyArray<string>>;
|
|
219
|
-
/**
|
|
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
|
+
*/
|
|
220
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>;
|
|
221
237
|
private constructor();
|
|
222
238
|
/** Build `server` over queue-backed stdio and return a client for it; fails with the layer's own error if it cannot build. */
|
|
223
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>>;
|
|
@@ -254,8 +270,9 @@ interface McpProbeResult {
|
|
|
254
270
|
*
|
|
255
271
|
* @remarks
|
|
256
272
|
* Stdin stays open until the id-1 response has arrived. Closing it right
|
|
257
|
-
* after writing
|
|
258
|
-
*
|
|
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
|
|
259
276
|
* empty and `exitCode` is 0; this is the MCP half of a packed-install proof.
|
|
260
277
|
* Checking stderr and the exit code alone passes a server that answers
|
|
261
278
|
* the handshake with a JSON-RPC error, exits 0 and writes nothing.
|
|
@@ -295,6 +312,20 @@ export declare class McpProbe {
|
|
|
295
312
|
}
|
|
296
313
|
//#endregion
|
|
297
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
|
+
}
|
|
298
329
|
/**
|
|
299
330
|
* A spawned MCP server bin a test writes to while it runs.
|
|
300
331
|
*
|
|
@@ -311,8 +342,9 @@ export declare class McpProbe {
|
|
|
311
342
|
* `list_changed`, to the matching id and returns what it saw.
|
|
312
343
|
* - `closeStdin` is `Queue.end`, never `Queue.shutdown`, so every frame
|
|
313
344
|
* already sent is delivered before stdin closes.
|
|
314
|
-
* - A request in flight when stdin closes
|
|
315
|
-
*
|
|
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.
|
|
316
348
|
*
|
|
317
349
|
* @example
|
|
318
350
|
* ```ts
|
|
@@ -369,6 +401,22 @@ export declare class McpProcess {
|
|
|
369
401
|
readonly exitCode: Effect.Effect<number, PlatformError.PlatformError>;
|
|
370
402
|
/** Everything written to stderr so far. */
|
|
371
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>;
|
|
372
420
|
/** Everything written to stderr, once stderr has ended. Waits for the child to exit. */
|
|
373
421
|
readonly stderrFinal: Effect.Effect<string>;
|
|
374
422
|
private constructor();
|
|
@@ -442,5 +490,5 @@ export declare class McpToolAudit {
|
|
|
442
490
|
static readonly check: (tools: ReadonlyArray<ServedTool>, policy: McpToolAuditPolicy) => ReadonlyArray<string>;
|
|
443
491
|
}
|
|
444
492
|
//#endregion
|
|
445
|
-
export type { JsonRpcMessage, McpHarnessOptions, McpProbeOptions, McpProbeResult, McpToolAuditPolicy, ServedResource, ServedTool };
|
|
493
|
+
export type { JsonRpcMessage, McpHarnessOptions, McpProbeOptions, McpProbeResult, McpProcessStderrUntilOptions, McpToolAuditPolicy, ServedResource, ServedTool };
|
|
446
494
|
//# sourceMappingURL=testing.d.ts.map
|