@effected/mcp 0.1.1 → 0.3.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 +121 -0
- package/McpHarness.js +46 -10
- package/McpProbe.js +1 -1
- package/McpProcess.js +2 -2
- package/McpStdio.js +37 -21
- package/McpTestFailure.js +2 -2
- package/McpToolAudit.js +10 -5
- package/McpToolkit.js +199 -36
- package/README.md +195 -31
- package/ToolFailure.js +8 -2
- package/ToolInputSchema.js +28 -14
- package/ToolOutputSchema.js +66 -0
- package/ToolRefusal.js +42 -0
- package/guard.d.ts +157 -0
- package/guard.js +3 -0
- package/index.d.ts +331 -64
- package/index.js +3 -1
- package/internal/StdinFrames.js +61 -23
- package/internal/guardLaunch.js +14 -0
- package/package.json +9 -4
- package/testing.d.ts +44 -12
package/McpGuard.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
//#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
|
+
/**
|
|
16
|
+
* Crash guards for an MCP server process, installed before the server's
|
|
17
|
+
* module graph is loaded. Imported from `@effected/mcp/guard`.
|
|
18
|
+
*
|
|
19
|
+
* @remarks
|
|
20
|
+
* {@link McpGuard.run} registers `uncaughtException` and `unhandledRejection`
|
|
21
|
+
* listeners on `host`, then awaits `load()`, then launches the server with
|
|
22
|
+
* `McpStdio.launch` and `McpStdio.teardown` under the `runMain` it returned.
|
|
23
|
+
* This entrypoint has no static runtime import: it loads `effect` and the
|
|
24
|
+
* stdio wiring only after the guards are listening, so a throw while any of
|
|
25
|
+
* it evaluates is still reported on stderr.
|
|
26
|
+
*
|
|
27
|
+
* - "Connected" means the whole server layer has built, so stdin is being
|
|
28
|
+
* read. That is before any client sends `initialize`.
|
|
29
|
+
* - `injectCrash` drives either half of the policy from a test, before
|
|
30
|
+
* `load()` or once connected; unset, it does nothing.
|
|
31
|
+
* - A `load()` that rejects is reported as `startup failed` and exits 1
|
|
32
|
+
* whatever the policy. Left to a log-only rejection listener, it would
|
|
33
|
+
* let the event loop drain and exit 0 with no server.
|
|
34
|
+
* - Installing an `unhandledRejection` listener switches off Node's default
|
|
35
|
+
* of throwing on one, so `"log"` really does keep the process running.
|
|
36
|
+
* - An exit from a guard skips Effect's finalizers and the teardown.
|
|
37
|
+
* - Keeping on serving after a crash works because the platform `runMain`
|
|
38
|
+
* holds the process open while the server fiber lives.
|
|
39
|
+
* - A layer that fails to build is still reported by `McpStdio.launch`.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { McpGuard } from "@effected/mcp/guard";
|
|
44
|
+
*
|
|
45
|
+
* await McpGuard.run({
|
|
46
|
+
* label: "my-server",
|
|
47
|
+
* host: process,
|
|
48
|
+
* policy: { onUncaught: "exitBeforeConnect", onRejection: "log" },
|
|
49
|
+
* load: async () => {
|
|
50
|
+
* const { NodeRuntime } = await import("@effect/platform-node");
|
|
51
|
+
* const { Main } = await import("./server.js");
|
|
52
|
+
* return { layer: Main, runMain: NodeRuntime.runMain };
|
|
53
|
+
* },
|
|
54
|
+
* });
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @public
|
|
58
|
+
*/
|
|
59
|
+
var McpGuard = class {
|
|
60
|
+
constructor() {}
|
|
61
|
+
/** Install the guards, load the server, and launch it. Resolves once the server is launched. */
|
|
62
|
+
static run = async (options) => {
|
|
63
|
+
const { host, label } = options;
|
|
64
|
+
const onUncaught = options.policy?.onUncaught ?? "exit";
|
|
65
|
+
const onRejection = options.policy?.onRejection ?? "exit";
|
|
66
|
+
let connected = false;
|
|
67
|
+
let format = fallbackFormat;
|
|
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 {
|
|
109
|
+
const server = await options.load();
|
|
110
|
+
if (server.format !== void 0) format = server.format;
|
|
111
|
+
const { launchGuarded } = await import("./internal/guardLaunch.js");
|
|
112
|
+
launchGuarded(server, onReady);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
host.stderr.write(`${label}: startup failed: ${describe(error)}\n`);
|
|
115
|
+
host.exit(1);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
//#endregion
|
|
121
|
+
export { McpGuard };
|
package/McpHarness.js
CHANGED
|
@@ -2,7 +2,7 @@ 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
4
|
import { Console, Deferred, Effect, Exit, Layer, Queue, Sink, Stdio, Stream } from "effect";
|
|
5
|
-
import { McpProtocol } from "effect/
|
|
5
|
+
import { McpProtocol } from "effect/ai";
|
|
6
6
|
|
|
7
7
|
//#region src/McpHarness.ts
|
|
8
8
|
/**
|
|
@@ -38,11 +38,13 @@ import { McpProtocol } from "effect/unstable/ai";
|
|
|
38
38
|
* anything that went through `console.log`, which in a real server is
|
|
39
39
|
* the wire. Assert it empty.
|
|
40
40
|
* - On a stateful revision (the default `2025-11-25`), send `initialize`
|
|
41
|
-
* first.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* `-
|
|
41
|
+
* first. `ping` is the exception: the server answers it `{}` before
|
|
42
|
+
* initialization, so it is written. Every other request before
|
|
43
|
+
* `initialize` fails with `NotInitialized` and is never written: the
|
|
44
|
+
* server would only answer an opaque refusal,
|
|
45
|
+
* `-32602 Invalid request metadata` when a stateless adapter is listed
|
|
46
|
+
* first (as in `McpStdio.protocols`), or `-32603 Internal error` when
|
|
47
|
+
* only stateful revisions are served.
|
|
46
48
|
*
|
|
47
49
|
* @public
|
|
48
50
|
*/
|
|
@@ -51,11 +53,27 @@ var McpHarness = class McpHarness {
|
|
|
51
53
|
protocol;
|
|
52
54
|
/** `initialize` then `notifications/initialized`; `server/discover` on a stateless revision. Call it first on a stateful revision. */
|
|
53
55
|
initialize;
|
|
56
|
+
/**
|
|
57
|
+
* `initialize` asking for `protocolVersion` instead of the harness's own
|
|
58
|
+
* revision, then `notifications/initialized` only when the response
|
|
59
|
+
* carries no error. Returns the whole response: assert the negotiated
|
|
60
|
+
* `result.protocolVersion`, or the JSON-RPC `error`.
|
|
61
|
+
*
|
|
62
|
+
* @remarks
|
|
63
|
+
* It moves between stateful revisions only. A stateful server never
|
|
64
|
+
* refuses a version: it counter-offers its latest stateful revision, so
|
|
65
|
+
* asking for an unknown one, or for the stateless `2026-07-28`, comes back
|
|
66
|
+
* as `2025-11-25`. On a harness made with a stateless revision the server
|
|
67
|
+
* does not serve `initialize` at all, and the response is a JSON-RPC
|
|
68
|
+
* `-32601` "Method not found" error, with no `notifications/initialized`
|
|
69
|
+
* sent after it.
|
|
70
|
+
*/
|
|
71
|
+
initializeWith;
|
|
54
72
|
/** `server/discover`. */
|
|
55
73
|
discover;
|
|
56
74
|
/** Send a request and wait for its response. */
|
|
57
75
|
request;
|
|
58
|
-
/** Send a request now; wait for its response later. Fails with `NotInitialized`, sending nothing, on a stateful revision before `initialize
|
|
76
|
+
/** Send a request now; wait for its response later. Fails with `NotInitialized`, sending nothing, on a stateful revision before `initialize` (`ping` excepted). */
|
|
59
77
|
startRequest;
|
|
60
78
|
/** Send a notification. */
|
|
61
79
|
notify;
|
|
@@ -71,6 +89,8 @@ var McpHarness = class McpHarness {
|
|
|
71
89
|
sendRaw;
|
|
72
90
|
/** The next server-initiated frame with this method, skipping and keeping others; fails with `ServerStopped` once the server stops. */
|
|
73
91
|
awaitOutboundMethod;
|
|
92
|
+
/** 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. */
|
|
93
|
+
sentSoFar;
|
|
74
94
|
/** Everything written to stderr, plus every captured log line. */
|
|
75
95
|
stderrSoFar;
|
|
76
96
|
/** Every captured `console.log`, `info` or `debug` call. */
|
|
@@ -80,6 +100,7 @@ var McpHarness = class McpHarness {
|
|
|
80
100
|
constructor(parts) {
|
|
81
101
|
this.protocol = parts.protocol;
|
|
82
102
|
this.initialize = parts.initialize;
|
|
103
|
+
this.initializeWith = parts.initializeWith;
|
|
83
104
|
this.discover = parts.discover;
|
|
84
105
|
this.request = parts.request;
|
|
85
106
|
this.startRequest = parts.startRequest;
|
|
@@ -90,6 +111,7 @@ var McpHarness = class McpHarness {
|
|
|
90
111
|
this.readResource = parts.readResource;
|
|
91
112
|
this.sendRaw = parts.sendRaw;
|
|
92
113
|
this.awaitOutboundMethod = parts.awaitOutboundMethod;
|
|
114
|
+
this.sentSoFar = parts.sentSoFar;
|
|
93
115
|
this.stderrSoFar = parts.stderrSoFar;
|
|
94
116
|
this.consoleLogSoFar = parts.consoleLogSoFar;
|
|
95
117
|
this.close = parts.close;
|
|
@@ -164,12 +186,16 @@ var McpHarness = class McpHarness {
|
|
|
164
186
|
const awaitResponse = (waiter) => Effect.suspend(() => Deferred.isDoneUnsafe(waiter) ? Deferred.await(waiter) : stopAware(Deferred.await(waiter)));
|
|
165
187
|
const stateful = !isStateless(protocol);
|
|
166
188
|
let initializeSent = false;
|
|
189
|
+
const sent = [];
|
|
167
190
|
const sendRaw = (message) => Effect.suspend(() => {
|
|
168
|
-
|
|
169
|
-
|
|
191
|
+
const line = JSON.stringify(message);
|
|
192
|
+
const written = line === void 0 ? void 0 : JSON.parse(line);
|
|
193
|
+
sent.push(written);
|
|
194
|
+
if (isJsonRpcMessage(written) && written.method === "initialize") initializeSent = true;
|
|
195
|
+
return Effect.asVoid(Queue.offer(stdin, encoder.encode(`${line}\n`)));
|
|
170
196
|
});
|
|
171
197
|
const startRequest = (method, params) => Effect.gen(function* () {
|
|
172
|
-
if (stateful && !initializeSent && method !== "initialize") return yield* new McpTestFailure({
|
|
198
|
+
if (stateful && !initializeSent && method !== "initialize" && method !== "ping") return yield* new McpTestFailure({
|
|
173
199
|
reason: "NotInitialized",
|
|
174
200
|
message: `${method} was not sent: call initialize first on stateful protocol ${protocol.protocolVersion}`
|
|
175
201
|
});
|
|
@@ -190,6 +216,14 @@ var McpHarness = class McpHarness {
|
|
|
190
216
|
yield* notify("notifications/initialized");
|
|
191
217
|
return response;
|
|
192
218
|
});
|
|
219
|
+
const initializeWith = (protocolVersion) => Effect.gen(function* () {
|
|
220
|
+
const response = yield* request("initialize", {
|
|
221
|
+
...initializeParams(protocol, clientInfo),
|
|
222
|
+
protocolVersion
|
|
223
|
+
});
|
|
224
|
+
if (response.error === void 0) yield* notify("notifications/initialized");
|
|
225
|
+
return response;
|
|
226
|
+
});
|
|
193
227
|
const listTools = Effect.flatMap(request("tools/list"), (response) => response.error === void 0 ? Effect.succeed(response.result.tools) : Effect.fail(new McpTestFailure({
|
|
194
228
|
reason: "ErrorResponse",
|
|
195
229
|
message: `tools/list failed: ${ToolFailure.truncate(JSON.stringify(response.error))}`
|
|
@@ -212,6 +246,7 @@ var McpHarness = class McpHarness {
|
|
|
212
246
|
return new McpHarness({
|
|
213
247
|
protocol,
|
|
214
248
|
initialize,
|
|
249
|
+
initializeWith,
|
|
215
250
|
discover,
|
|
216
251
|
request,
|
|
217
252
|
startRequest,
|
|
@@ -225,6 +260,7 @@ var McpHarness = class McpHarness {
|
|
|
225
260
|
readResource: (uri) => request("resources/read", { uri }),
|
|
226
261
|
sendRaw,
|
|
227
262
|
awaitOutboundMethod,
|
|
263
|
+
sentSoFar: Effect.sync(() => [...sent]),
|
|
228
264
|
stderrSoFar: Effect.sync(() => stderr.join("")),
|
|
229
265
|
consoleLogSoFar: Effect.sync(() => [...consoleLog]),
|
|
230
266
|
close: Effect.asVoid(Queue.end(stdin))
|
package/McpProbe.js
CHANGED
|
@@ -3,7 +3,7 @@ import { DEFAULT_CLIENT_INFO, STDERR_HINT, frame, initializeParams, isJsonRpcMes
|
|
|
3
3
|
import { McpTestFailure } from "./McpTestFailure.js";
|
|
4
4
|
import { McpProcess } from "./McpProcess.js";
|
|
5
5
|
import { Effect } from "effect";
|
|
6
|
-
import { McpProtocol } from "effect/
|
|
6
|
+
import { McpProtocol } from "effect/ai";
|
|
7
7
|
|
|
8
8
|
//#region src/McpProbe.ts
|
|
9
9
|
/**
|
package/McpProcess.js
CHANGED
|
@@ -2,8 +2,8 @@ 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
4
|
import { Deferred, Effect, Queue, Ref, Stream } from "effect";
|
|
5
|
-
import { McpProtocol } from "effect/
|
|
6
|
-
import { ChildProcessSpawner } from "effect/
|
|
5
|
+
import { McpProtocol } from "effect/ai";
|
|
6
|
+
import { ChildProcessSpawner } from "effect/process";
|
|
7
7
|
|
|
8
8
|
//#region src/McpProcess.ts
|
|
9
9
|
/**
|
package/McpStdio.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { LaunchFailed } from "./internal/LaunchFailed.js";
|
|
2
2
|
import { makeGuardedStdio } from "./internal/StdinFrames.js";
|
|
3
3
|
import { Cause, Effect, Exit, Layer, References, Runtime } from "effect";
|
|
4
|
-
import { McpProtocol, McpServer } from "effect/
|
|
4
|
+
import { McpProtocol, McpServer } from "effect/ai";
|
|
5
5
|
|
|
6
6
|
//#region src/McpStdio.ts
|
|
7
7
|
/**
|
|
@@ -68,26 +68,35 @@ var McpStdio = class McpStdio {
|
|
|
68
68
|
*
|
|
69
69
|
* The server reads stdin through a guard that frames it exactly as core's
|
|
70
70
|
* decoder does: one streaming UTF-8 decoder, a byte-order mark stripped
|
|
71
|
-
* only at the start of the stream, lines split on `\n`.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* units: it is answered once, as soon as it passes
|
|
77
|
-
* of it is discarded up to its newline
|
|
78
|
-
*
|
|
71
|
+
* only at the start of the stream, lines split on `\n`. Core itself skips
|
|
72
|
+
* a line it cannot use and keeps serving, but sends no reply, where
|
|
73
|
+
* JSON-RPC 2.0 requires one; the guard supplies it. A line that is not
|
|
74
|
+
* JSON is answered on stdout with a parse error, code `-32700` and
|
|
75
|
+
* `id: null`, and never reaches core. So is a line longer than core's cap
|
|
76
|
+
* of 16 Mi UTF-16 code units: it is answered once, as soon as it passes
|
|
77
|
+
* the cap, and the rest of it is discarded up to its newline (core would
|
|
78
|
+
* log it on stderr and drop it). A line of JSON whitespace (space, tab,
|
|
79
|
+
* carriage return) is ignored.
|
|
79
80
|
*
|
|
80
81
|
* A line that is JSON but no JSON-RPC message core can handle is answered
|
|
81
82
|
* with an Invalid Request, code `-32600` and `id: null`, and never reaches
|
|
82
|
-
* core: a value that is neither an object nor an array
|
|
83
|
-
* `
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
83
|
+
* core: a value that is neither an object nor an array, an object whose
|
|
84
|
+
* `method` is not a string and whose `id` is absent or `null`, and an
|
|
85
|
+
* object with neither `method` nor `id`, which is neither a request nor a
|
|
86
|
+
* response.
|
|
87
|
+
*
|
|
88
|
+
* A line whose `method` starts with `@effect/rpc/` never reaches core,
|
|
89
|
+
* whose JSON-RPC decoder would read one without an `id` as its own RPC
|
|
90
|
+
* control message: `@effect/rpc/Eof` alone silently stops the server
|
|
91
|
+
* (Effect-TS/effect#8499, open upstream). Such a request is answered
|
|
92
|
+
* `-32601` Method not found, echoing its `id`; such a notification is
|
|
93
|
+
* dropped. The guard stops doing this once core no longer treats a
|
|
94
|
+
* client's `@effect/rpc/` frame as a control message.
|
|
95
|
+
*
|
|
96
|
+
* Everything else goes to core: an array, which core answers `-32600`
|
|
97
|
+
* itself because it serves no batches; an object with an `id` and no
|
|
98
|
+
* `method`, which is a response and gets no reply; and a request with an
|
|
99
|
+
* `id`, which core answers even when its `method` is malformed.
|
|
91
100
|
*
|
|
92
101
|
* Give each server a fresh layer memo map. Core's stdio protocol layer
|
|
93
102
|
* is a shared constant, so a second `McpStdio.layer` server whose build
|
|
@@ -96,8 +105,12 @@ var McpStdio = class McpStdio {
|
|
|
96
105
|
* one graph does that, and so does building or providing the second
|
|
97
106
|
* anywhere under the first one's `Effect.provide`: nested `Layer.build`
|
|
98
107
|
* and `Effect.provide` fork the ambient memo map rather than starting a
|
|
99
|
-
* new one.
|
|
100
|
-
*
|
|
108
|
+
* new one. The tool registry is shared the same way. Isolate each server
|
|
109
|
+
* by wrapping its whole bundle (its toolkit layers together with this
|
|
110
|
+
* layer) in `Layer.fresh`, or with its own `ManagedRuntime`,
|
|
111
|
+
* `Effect.provide(layer, { local: true })` or its own process. Never put
|
|
112
|
+
* the `Layer.fresh` boundary between a toolkit and this layer: the server
|
|
113
|
+
* would serve an empty registry.
|
|
101
114
|
*
|
|
102
115
|
* Code after a completed `Effect.provide` of a stdio server never runs:
|
|
103
116
|
* core's stdio protocol interrupts the fiber that built it when its stdin
|
|
@@ -124,8 +137,11 @@ var McpStdio = class McpStdio {
|
|
|
124
137
|
* `Effect.provideService` reaches, and Effect's default logger writes
|
|
125
138
|
* through `console.log` unless `LogToStderr` is set. For an MCP server
|
|
126
139
|
* that is stdout, which is the wire.
|
|
140
|
+
*
|
|
141
|
+
* `options.onReady` runs after the layer has built and before the launch
|
|
142
|
+
* waits forever, as {@link McpLaunchOptions.onReady} describes.
|
|
127
143
|
*/
|
|
128
|
-
static launch = (layer) => Layer.launch(layer).pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logError(cause).pipe(Effect.andThen(Effect.fail(new LaunchFailed(Runtime.getErrorExitCode(Cause.squash(cause))))))), Effect.provideService(References.LogToStderr, true));
|
|
144
|
+
static launch = (layer, options = {}) => (options.onReady === void 0 ? Layer.launch(layer) : Effect.scoped(Layer.build(layer).pipe(Effect.andThen(options.onReady), Effect.andThen(Effect.never)))).pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logError(cause).pipe(Effect.andThen(Effect.fail(new LaunchFailed(Runtime.getErrorExitCode(Cause.squash(cause))))))), Effect.provideService(References.LogToStderr, true));
|
|
129
145
|
/**
|
|
130
146
|
* Success or an interrupt-only exit maps to 0. Anything else goes to
|
|
131
147
|
* `Runtime.defaultTeardown`.
|
package/McpTestFailure.js
CHANGED
|
@@ -18,8 +18,8 @@ import { Schema } from "effect";
|
|
|
18
18
|
* not `catchTag`.
|
|
19
19
|
* - `ServerStopped` (`McpHarness`): the in-process server stopped, usually
|
|
20
20
|
* because stdin closed while the request was in flight.
|
|
21
|
-
* - `NotInitialized` (`McpHarness`): a request other than `initialize`
|
|
22
|
-
* made on a stateful revision before `initialize` was sent. The request
|
|
21
|
+
* - `NotInitialized` (`McpHarness`): a request other than `initialize` or
|
|
22
|
+
* `ping` was made on a stateful revision before `initialize` was sent. The request
|
|
23
23
|
* is never written; the server would only have refused it opaquely:
|
|
24
24
|
* `-32602 Invalid request metadata` when a stateless adapter is listed
|
|
25
25
|
* first, `-32603 Internal error` when only stateful revisions are served.
|
package/McpToolAudit.js
CHANGED
|
@@ -24,10 +24,12 @@ const objectNodes = (schema) => {
|
|
|
24
24
|
return acc;
|
|
25
25
|
}, Object.create(null));
|
|
26
26
|
const closed = merged.some((part) => part.additionalProperties === false);
|
|
27
|
+
const droppedKeyCheck = merged.some((part) => isNode(part.additionalProperties) && isNode(part.propertyNames));
|
|
27
28
|
if (isNode(properties) || merged.some((part) => part.type === "object")) out.push({
|
|
28
29
|
path,
|
|
29
30
|
properties,
|
|
30
|
-
closed
|
|
31
|
+
closed,
|
|
32
|
+
droppedKeyCheck
|
|
31
33
|
});
|
|
32
34
|
if (isNode(properties)) for (const [key, child] of Object.entries(properties)) visit(child, join(path, key));
|
|
33
35
|
visit(node.items, join(path, "items"));
|
|
@@ -81,15 +83,18 @@ var McpToolAudit = class {
|
|
|
81
83
|
};
|
|
82
84
|
if (seen.has(tool.name)) report("duplicate tool name");
|
|
83
85
|
seen.add(tool.name);
|
|
84
|
-
if (policy.input !== "any") for (const { path, properties, closed } of objectNodes(tool.inputSchema)) {
|
|
86
|
+
if (policy.input !== "any") for (const { path, properties, closed, droppedKeyCheck } of objectNodes(tool.inputSchema)) {
|
|
85
87
|
const where = path === "" ? "the root" : path;
|
|
86
|
-
if (policy.input === "closed" && !closed) report(`input schema is open at ${where}`);
|
|
88
|
+
if (policy.input === "closed" && !closed) report(`input schema is open at ${where}${droppedKeyCheck ? " (a Record whose key check was not served; a key pattern is served closed, as patternProperties, only when its RegExp has the u flag, e.g. Schema.isPattern(/^x-/u))" : ""}`);
|
|
87
89
|
if (policy.input === "open" && isNode(properties) && closed) report(`input schema is closed at ${where}`);
|
|
88
90
|
}
|
|
89
91
|
const title = tool.title ?? tool.annotations?.title;
|
|
90
92
|
if (policy.requireTitle === true && (typeof title !== "string" || title === "")) report("no title");
|
|
91
|
-
if (policy.requireOutputSchema === true && tool.outputSchema === void 0) report("no outputSchema");
|
|
92
|
-
if ((policy.objectRootedOutput ?? true) && tool.outputSchema !== void 0 && tool.outputSchema.type !== "object")
|
|
93
|
+
if (policy.requireOutputSchema === true && tool.outputSchema === void 0) report("no outputSchema (a union success schema is dropped on stateful revisions; see ToolOutputSchema.objectRooted)");
|
|
94
|
+
if ((policy.objectRootedOutput ?? true) && tool.outputSchema !== void 0 && tool.outputSchema.type !== "object") {
|
|
95
|
+
const union = Array.isArray(tool.outputSchema.anyOf) || Array.isArray(tool.outputSchema.oneOf);
|
|
96
|
+
report(`outputSchema is not object-rooted (root type: ${String(tool.outputSchema.type ?? "none")})${union ? "; if every union member is an object, wrap the union success schema in ToolOutputSchema.objectRooted" : ""}`);
|
|
97
|
+
}
|
|
93
98
|
const length = (tool.description ?? "").length;
|
|
94
99
|
if (policy.maxDescription !== void 0 && length > policy.maxDescription) report(`description is ${length} characters, over the ${policy.maxDescription} limit`);
|
|
95
100
|
if (policy.requireHints === true) {
|