@effected/mcp 0.1.1 → 0.2.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 +36 -2
- package/McpStdio.js +10 -3
- package/McpToolAudit.js +5 -2
- package/McpToolkit.js +129 -10
- package/README.md +124 -4
- package/ToolFailure.js +7 -1
- 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 +242 -6
- package/index.js +3 -1
- package/internal/guardLaunch.js +14 -0
- package/package.json +6 -1
- package/testing.d.ts +32 -2
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
|
@@ -51,6 +51,22 @@ var McpHarness = class McpHarness {
|
|
|
51
51
|
protocol;
|
|
52
52
|
/** `initialize` then `notifications/initialized`; `server/discover` on a stateless revision. Call it first on a stateful revision. */
|
|
53
53
|
initialize;
|
|
54
|
+
/**
|
|
55
|
+
* `initialize` asking for `protocolVersion` instead of the harness's own
|
|
56
|
+
* revision, then `notifications/initialized` only when the response
|
|
57
|
+
* carries no error. Returns the whole response: assert the negotiated
|
|
58
|
+
* `result.protocolVersion`, or the JSON-RPC `error`.
|
|
59
|
+
*
|
|
60
|
+
* @remarks
|
|
61
|
+
* It moves between stateful revisions only. A stateful server never
|
|
62
|
+
* refuses a version: it counter-offers its latest stateful revision, so
|
|
63
|
+
* asking for an unknown one, or for the stateless `2026-07-28`, comes back
|
|
64
|
+
* as `2025-11-25`. On a harness made with a stateless revision the server
|
|
65
|
+
* does not serve `initialize` at all, and the response is a JSON-RPC
|
|
66
|
+
* `-32601` "Method not found" error, with no `notifications/initialized`
|
|
67
|
+
* sent after it.
|
|
68
|
+
*/
|
|
69
|
+
initializeWith;
|
|
54
70
|
/** `server/discover`. */
|
|
55
71
|
discover;
|
|
56
72
|
/** Send a request and wait for its response. */
|
|
@@ -71,6 +87,8 @@ var McpHarness = class McpHarness {
|
|
|
71
87
|
sendRaw;
|
|
72
88
|
/** The next server-initiated frame with this method, skipping and keeping others; fails with `ServerStopped` once the server stops. */
|
|
73
89
|
awaitOutboundMethod;
|
|
90
|
+
/** 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. */
|
|
91
|
+
sentSoFar;
|
|
74
92
|
/** Everything written to stderr, plus every captured log line. */
|
|
75
93
|
stderrSoFar;
|
|
76
94
|
/** Every captured `console.log`, `info` or `debug` call. */
|
|
@@ -80,6 +98,7 @@ var McpHarness = class McpHarness {
|
|
|
80
98
|
constructor(parts) {
|
|
81
99
|
this.protocol = parts.protocol;
|
|
82
100
|
this.initialize = parts.initialize;
|
|
101
|
+
this.initializeWith = parts.initializeWith;
|
|
83
102
|
this.discover = parts.discover;
|
|
84
103
|
this.request = parts.request;
|
|
85
104
|
this.startRequest = parts.startRequest;
|
|
@@ -90,6 +109,7 @@ var McpHarness = class McpHarness {
|
|
|
90
109
|
this.readResource = parts.readResource;
|
|
91
110
|
this.sendRaw = parts.sendRaw;
|
|
92
111
|
this.awaitOutboundMethod = parts.awaitOutboundMethod;
|
|
112
|
+
this.sentSoFar = parts.sentSoFar;
|
|
93
113
|
this.stderrSoFar = parts.stderrSoFar;
|
|
94
114
|
this.consoleLogSoFar = parts.consoleLogSoFar;
|
|
95
115
|
this.close = parts.close;
|
|
@@ -164,9 +184,13 @@ var McpHarness = class McpHarness {
|
|
|
164
184
|
const awaitResponse = (waiter) => Effect.suspend(() => Deferred.isDoneUnsafe(waiter) ? Deferred.await(waiter) : stopAware(Deferred.await(waiter)));
|
|
165
185
|
const stateful = !isStateless(protocol);
|
|
166
186
|
let initializeSent = false;
|
|
187
|
+
const sent = [];
|
|
167
188
|
const sendRaw = (message) => Effect.suspend(() => {
|
|
168
|
-
|
|
169
|
-
|
|
189
|
+
const line = JSON.stringify(message);
|
|
190
|
+
const written = line === void 0 ? void 0 : JSON.parse(line);
|
|
191
|
+
sent.push(written);
|
|
192
|
+
if (isJsonRpcMessage(written) && written.method === "initialize") initializeSent = true;
|
|
193
|
+
return Effect.asVoid(Queue.offer(stdin, encoder.encode(`${line}\n`)));
|
|
170
194
|
});
|
|
171
195
|
const startRequest = (method, params) => Effect.gen(function* () {
|
|
172
196
|
if (stateful && !initializeSent && method !== "initialize") return yield* new McpTestFailure({
|
|
@@ -190,6 +214,14 @@ var McpHarness = class McpHarness {
|
|
|
190
214
|
yield* notify("notifications/initialized");
|
|
191
215
|
return response;
|
|
192
216
|
});
|
|
217
|
+
const initializeWith = (protocolVersion) => Effect.gen(function* () {
|
|
218
|
+
const response = yield* request("initialize", {
|
|
219
|
+
...initializeParams(protocol, clientInfo),
|
|
220
|
+
protocolVersion
|
|
221
|
+
});
|
|
222
|
+
if (response.error === void 0) yield* notify("notifications/initialized");
|
|
223
|
+
return response;
|
|
224
|
+
});
|
|
193
225
|
const listTools = Effect.flatMap(request("tools/list"), (response) => response.error === void 0 ? Effect.succeed(response.result.tools) : Effect.fail(new McpTestFailure({
|
|
194
226
|
reason: "ErrorResponse",
|
|
195
227
|
message: `tools/list failed: ${ToolFailure.truncate(JSON.stringify(response.error))}`
|
|
@@ -212,6 +244,7 @@ var McpHarness = class McpHarness {
|
|
|
212
244
|
return new McpHarness({
|
|
213
245
|
protocol,
|
|
214
246
|
initialize,
|
|
247
|
+
initializeWith,
|
|
215
248
|
discover,
|
|
216
249
|
request,
|
|
217
250
|
startRequest,
|
|
@@ -225,6 +258,7 @@ var McpHarness = class McpHarness {
|
|
|
225
258
|
readResource: (uri) => request("resources/read", { uri }),
|
|
226
259
|
sendRaw,
|
|
227
260
|
awaitOutboundMethod,
|
|
261
|
+
sentSoFar: Effect.sync(() => [...sent]),
|
|
228
262
|
stderrSoFar: Effect.sync(() => stderr.join("")),
|
|
229
263
|
consoleLogSoFar: Effect.sync(() => [...consoleLog]),
|
|
230
264
|
close: Effect.asVoid(Queue.end(stdin))
|
package/McpStdio.js
CHANGED
|
@@ -96,8 +96,12 @@ var McpStdio = class McpStdio {
|
|
|
96
96
|
* one graph does that, and so does building or providing the second
|
|
97
97
|
* anywhere under the first one's `Effect.provide`: nested `Layer.build`
|
|
98
98
|
* and `Effect.provide` fork the ambient memo map rather than starting a
|
|
99
|
-
* new one.
|
|
100
|
-
*
|
|
99
|
+
* new one. The tool registry is shared the same way. Isolate each server
|
|
100
|
+
* by wrapping its whole bundle (its toolkit layers together with this
|
|
101
|
+
* layer) in `Layer.fresh`, or with its own `ManagedRuntime`,
|
|
102
|
+
* `Effect.provide(layer, { local: true })` or its own process. Never put
|
|
103
|
+
* the `Layer.fresh` boundary between a toolkit and this layer: the server
|
|
104
|
+
* would serve an empty registry.
|
|
101
105
|
*
|
|
102
106
|
* Code after a completed `Effect.provide` of a stdio server never runs:
|
|
103
107
|
* core's stdio protocol interrupts the fiber that built it when its stdin
|
|
@@ -124,8 +128,11 @@ var McpStdio = class McpStdio {
|
|
|
124
128
|
* `Effect.provideService` reaches, and Effect's default logger writes
|
|
125
129
|
* through `console.log` unless `LogToStderr` is set. For an MCP server
|
|
126
130
|
* that is stdout, which is the wire.
|
|
131
|
+
*
|
|
132
|
+
* `options.onReady` runs after the layer has built and before the launch
|
|
133
|
+
* waits forever, as {@link McpLaunchOptions.onReady} describes.
|
|
127
134
|
*/
|
|
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));
|
|
135
|
+
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
136
|
/**
|
|
130
137
|
* Success or an interrupt-only exit maps to 0. Anything else goes to
|
|
131
138
|
* `Runtime.defaultTeardown`.
|
package/McpToolAudit.js
CHANGED
|
@@ -88,8 +88,11 @@ var McpToolAudit = class {
|
|
|
88
88
|
}
|
|
89
89
|
const title = tool.title ?? tool.annotations?.title;
|
|
90
90
|
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")
|
|
91
|
+
if (policy.requireOutputSchema === true && tool.outputSchema === void 0) report("no outputSchema (a union success schema is dropped on stateful revisions; see ToolOutputSchema.objectRooted)");
|
|
92
|
+
if ((policy.objectRootedOutput ?? true) && tool.outputSchema !== void 0 && tool.outputSchema.type !== "object") {
|
|
93
|
+
const union = Array.isArray(tool.outputSchema.anyOf) || Array.isArray(tool.outputSchema.oneOf);
|
|
94
|
+
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" : ""}`);
|
|
95
|
+
}
|
|
93
96
|
const length = (tool.description ?? "").length;
|
|
94
97
|
if (policy.maxDescription !== void 0 && length > policy.maxDescription) report(`description is ${length} characters, over the ${policy.maxDescription} limit`);
|
|
95
98
|
if (policy.requireHints === true) {
|
package/McpToolkit.js
CHANGED
|
@@ -1,8 +1,66 @@
|
|
|
1
1
|
import { ToolInputSchema } from "./ToolInputSchema.js";
|
|
2
|
-
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { Context, Effect, Layer, Schema } from "effect";
|
|
3
3
|
import { McpSchema, McpServer, Tool, Toolkit } from "effect/unstable/ai";
|
|
4
4
|
|
|
5
5
|
//#region src/McpToolkit.ts
|
|
6
|
+
/** The union a {@link McpToolkit.unionTool} decodes with; read by the `McpToolkit.layer` decorator. */
|
|
7
|
+
const UnionParameters = Context.Reference("@effected/mcp/UnionParameters", { defaultValue: () => void 0 });
|
|
8
|
+
/**
|
|
9
|
+
* Effect's strict JSON Schema document for `parameters`, definitions attached
|
|
10
|
+
* as `$defs`, then object-rooted: the served input schema of a union tool.
|
|
11
|
+
*/
|
|
12
|
+
const unionInputJsonSchema = (parameters) => {
|
|
13
|
+
const document = Schema.toJsonSchemaDocument(parameters, { onExcessProperty: "error" });
|
|
14
|
+
return ToolInputSchema.objectRooted(Object.keys(document.definitions).length === 0 ? document.schema : {
|
|
15
|
+
...document.schema,
|
|
16
|
+
$defs: document.definitions
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
const invalidParameters = (name, message) => new McpSchema.InvalidParams({ message: `Invalid parameters for tool '${name}': ${message}` });
|
|
20
|
+
/** The rendering both union paths use when the caller names none: `ToolInputSchema.formatUnknownKeys`. */
|
|
21
|
+
const defaultUnknownKeyMessage = (levels) => ToolInputSchema.formatUnknownKeys(levels);
|
|
22
|
+
/**
|
|
23
|
+
* The one decode a union tool's payload gets, shared by the `McpToolkit.layer`
|
|
24
|
+
* decorator and {@link McpToolkit.unionHandler}: every unknown key at every
|
|
25
|
+
* depth named in one `InvalidParams` (walked over the served `inputSchema`),
|
|
26
|
+
* then the union's strict decode, whose failure is worded as core words a
|
|
27
|
+
* `Tool.make` one. Suspended, so a throwing `format` dies inside the call.
|
|
28
|
+
*/
|
|
29
|
+
const decodeUnionPayload = (name, union, inputSchema, format) => {
|
|
30
|
+
const decode = Schema.decodeUnknownEffect(union);
|
|
31
|
+
return (payload) => Effect.suspend(() => {
|
|
32
|
+
const raw = payload ?? {};
|
|
33
|
+
const levels = ToolInputSchema.unknownKeys(raw, inputSchema);
|
|
34
|
+
if (levels.length > 0) return Effect.fail(new McpSchema.InvalidParams({ message: format(levels) }));
|
|
35
|
+
return decode(raw, { onExcessProperty: "error" }).pipe(Effect.mapError((error) => invalidParameters(name, error.message)));
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The registration `McpToolkit.layer` hands core in place of `registration`:
|
|
40
|
+
* a union tool gets its payload decoded first, a strict tool its unknown keys
|
|
41
|
+
* refused first, and anything else passes through untouched. The wrapped
|
|
42
|
+
* handler is built only after the check passes, so a rejected payload never
|
|
43
|
+
* reaches it.
|
|
44
|
+
*
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
47
|
+
const guardRegistration = (registration, format) => {
|
|
48
|
+
const union = Context.get(registration.annotations, UnionParameters);
|
|
49
|
+
if (union !== void 0) {
|
|
50
|
+
const check = decodeUnionPayload(registration.tool.name, union, registration.tool.inputSchema, format);
|
|
51
|
+
return {
|
|
52
|
+
...registration,
|
|
53
|
+
handle: (payload) => check(payload).pipe(Effect.flatMap(() => registration.handle(payload)))
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return Context.get(registration.annotations, Tool.Strict) === true ? {
|
|
57
|
+
...registration,
|
|
58
|
+
handle: (payload) => Effect.suspend(() => {
|
|
59
|
+
const levels = ToolInputSchema.unknownKeys(payload ?? {}, registration.tool.inputSchema);
|
|
60
|
+
return levels.length > 0 ? Effect.fail(new McpSchema.InvalidParams({ message: format(levels) })) : registration.handle(payload);
|
|
61
|
+
})
|
|
62
|
+
} : registration;
|
|
63
|
+
};
|
|
6
64
|
const DEFAULT_STRICT = "all";
|
|
7
65
|
const strictened = (toolkit, mode) => mode === "annotated" ? toolkit : Toolkit.make(...Object.values(toolkit.tools).map((tool) => Tool.isDynamic(tool) || Tool.getStrictMode(tool) !== void 0 ? tool : tool.annotate(Tool.Strict, true)));
|
|
8
66
|
/**
|
|
@@ -37,6 +95,8 @@ const strictened = (toolkit, mode) => mode === "annotated" ? toolkit : Toolkit.m
|
|
|
37
95
|
* shared by reference with `McpStdio.layer`'s own copy — provide both into
|
|
38
96
|
* the same graph, as with `McpServer.toolkit`. Never wrap that layer in
|
|
39
97
|
* `Layer.fresh`: tools would register into a second registry nobody serves.
|
|
98
|
+
* To give two servers separate registries, wrap each whole bundle (this
|
|
99
|
+
* layer together with `McpStdio.layer`) in `Layer.fresh` instead.
|
|
40
100
|
*
|
|
41
101
|
* Each call mints a fresh layer; bind the result to a `const` or the
|
|
42
102
|
* registration runs twice.
|
|
@@ -55,25 +115,84 @@ const strictened = (toolkit, mode) => mode === "annotated" ? toolkit : Toolkit.m
|
|
|
55
115
|
var McpToolkit = class {
|
|
56
116
|
constructor() {}
|
|
57
117
|
/**
|
|
118
|
+
* A tool whose parameters are a `Schema.Union` of objects, which
|
|
119
|
+
* `Tool.make` cannot take: core dies at registration on a non-object
|
|
120
|
+
* `parameters` root.
|
|
121
|
+
*
|
|
122
|
+
* @remarks
|
|
123
|
+
* The tool is a `Tool.dynamic` served with Effect's strict JSON Schema
|
|
124
|
+
* document for `parameters` (`additionalProperties: false` on every
|
|
125
|
+
* object node, definitions as `$defs`), rewritten by
|
|
126
|
+
* `ToolInputSchema.objectRooted` to an object root carrying `type`,
|
|
127
|
+
* `oneOf` and `x-discriminator`. Write its handler with
|
|
128
|
+
* {@link McpToolkit.unionHandler}, which receives the decoded member.
|
|
129
|
+
*
|
|
130
|
+
* Registered through {@link McpToolkit.layer}, a union tool gets the same
|
|
131
|
+
* treatment as a strict `Tool.make` tool: every unknown key named in one
|
|
132
|
+
* `InvalidParams`, then a strict decode, both before the handler runs, so
|
|
133
|
+
* bad arguments answer JSON-RPC `-32602` on `2025-06-18` and an `isError`
|
|
134
|
+
* result on the later revisions, exactly as a `Tool.make` decode failure
|
|
135
|
+
* does. Registered through core's `McpServer.toolkit` it still decodes
|
|
136
|
+
* strictly, in the handler, but the `InvalidParams` is then a declared
|
|
137
|
+
* failure: an `isError` result on every revision.
|
|
138
|
+
*
|
|
139
|
+
* Never annotate it `Tool.Strict` true: core dies at registration on a
|
|
140
|
+
* strict tool with a raw JSON Schema.
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* ```ts
|
|
144
|
+
* const Note = McpToolkit.unionTool("note", {
|
|
145
|
+
* parameters: Schema.Union([AddNote, ListNotes]),
|
|
146
|
+
* success: ToolOutputSchema.objectRooted(Schema.Union([Added, Listed])),
|
|
147
|
+
* failure: ToolRefusal,
|
|
148
|
+
* }).annotate(Tool.Title, "Note")
|
|
149
|
+
* const handlers = Kit.toLayer({ note: McpToolkit.unionHandler(Note, (params) => handleNote(params)) })
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
static unionTool = (name, options) => {
|
|
153
|
+
const tool = Tool.dynamic(name, {
|
|
154
|
+
description: options.description,
|
|
155
|
+
parameters: unionInputJsonSchema(options.parameters),
|
|
156
|
+
success: options.success ?? Schema.Void,
|
|
157
|
+
failure: Schema.Union([options.failure ?? Schema.Never, McpSchema.InvalidParams])
|
|
158
|
+
}).annotate(UnionParameters, options.parameters);
|
|
159
|
+
return Object.assign(tool, { unionParameters: options.parameters });
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* The handler for a {@link McpToolkit.unionTool}: checks and decodes the
|
|
163
|
+
* raw payload exactly as {@link McpToolkit.layer} does, and passes the
|
|
164
|
+
* decoded member to `handler`.
|
|
165
|
+
*
|
|
166
|
+
* @remarks
|
|
167
|
+
* A payload carrying unknown keys fails with one `McpSchema.InvalidParams`
|
|
168
|
+
* naming every one of them, at every depth, in the
|
|
169
|
+
* `ToolInputSchema.formatUnknownKeys` report (or `options.unknownKeyMessage`'s),
|
|
170
|
+
* walked over the tool's served input schema. A payload that then does
|
|
171
|
+
* not decode fails `InvalidParams` worded as core words a `Tool.make`
|
|
172
|
+
* decode failure. It is the same implementation the layer runs, so a
|
|
173
|
+
* handler called directly (a test helper, or a toolkit registered through
|
|
174
|
+
* core's `McpServer.toolkit`) reports what a client of the layer sees.
|
|
175
|
+
* Under {@link McpToolkit.layer} the layer has already rejected a bad
|
|
176
|
+
* call before the handler runs, and its own `unknownKeyMessage` wins.
|
|
177
|
+
*/
|
|
178
|
+
static unionHandler = (tool, handler, options = {}) => {
|
|
179
|
+
const check = decodeUnionPayload(tool.name, tool.unionParameters, tool.jsonSchema, options.unknownKeyMessage ?? defaultUnknownKeyMessage);
|
|
180
|
+
return (payload) => check(payload).pipe(Effect.flatMap(handler));
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
58
183
|
* `McpServer.toolkit`'s registration layer, strict by default and naming
|
|
59
184
|
* every unknown argument in one `InvalidParams`.
|
|
60
185
|
*/
|
|
61
186
|
static layer = (toolkit, options = {}) => Layer.effectDiscard(Effect.gen(function* () {
|
|
62
187
|
const registry = yield* McpServer.McpServer;
|
|
63
|
-
const format = options.unknownKeyMessage ??
|
|
188
|
+
const format = options.unknownKeyMessage ?? defaultUnknownKeyMessage;
|
|
64
189
|
const decorated = McpServer.McpServer.of({
|
|
65
190
|
...registry,
|
|
66
|
-
addTool: (registration) =>
|
|
67
|
-
...registration,
|
|
68
|
-
handle: (payload) => Effect.suspend(() => {
|
|
69
|
-
const levels = ToolInputSchema.unknownKeys(payload ?? {}, registration.tool.inputSchema);
|
|
70
|
-
return levels.length > 0 ? Effect.fail(new McpSchema.InvalidParams({ message: format(levels) })) : registration.handle(payload);
|
|
71
|
-
})
|
|
72
|
-
}) : registry.addTool(registration)
|
|
191
|
+
addTool: (registration) => registry.addTool(guardRegistration(registration, format))
|
|
73
192
|
});
|
|
74
193
|
yield* McpServer.registerToolkit(strictened(toolkit, options.strict ?? DEFAULT_STRICT)).pipe(Effect.provideService(McpServer.McpServer, decorated));
|
|
75
194
|
})).pipe(Layer.provide(McpServer.McpServer.layer));
|
|
76
195
|
};
|
|
77
196
|
|
|
78
197
|
//#endregion
|
|
79
|
-
export { McpToolkit };
|
|
198
|
+
export { McpToolkit, guardRegistration };
|
package/README.md
CHANGED
|
@@ -87,6 +87,31 @@ console.log(ToolFailure.truncate(enginePath, ToolFailure.ENGINE_ECHO_LIMIT) ===
|
|
|
87
87
|
|
|
88
88
|
`ToolFailure` is a static-namespace class with a private constructor — it is never instantiated.
|
|
89
89
|
|
|
90
|
+
### ToolRefusal
|
|
91
|
+
|
|
92
|
+
For a refusal with no fields of its own, `ToolRefusal` is that shape ready-made: a `Schema.TaggedError` on `ToolFailure.fields`, built with `ToolRefusal.refuse(reason, remediation)`, which folds the remediation into `message`. Declare it in the tool's `failure` schema:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { ToolFailure, ToolRefusal } from "@effected/mcp";
|
|
96
|
+
import { Effect, Schema } from "effect";
|
|
97
|
+
import { Tool } from "effect/unstable/ai";
|
|
98
|
+
|
|
99
|
+
const GetRun = Tool.make("get_run", {
|
|
100
|
+
parameters: Schema.Struct({ id: Schema.String }),
|
|
101
|
+
success: Schema.Struct({ status: Schema.String }),
|
|
102
|
+
failure: ToolRefusal,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const refusal = ToolRefusal.refuse(`No run "${ToolFailure.truncate("r9")}".`, {
|
|
106
|
+
hint: "List the runs first.",
|
|
107
|
+
suggestedTool: "list_runs",
|
|
108
|
+
});
|
|
109
|
+
console.log(refusal.message);
|
|
110
|
+
// => No run "r9". List the runs first. Try list_runs.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Audit every throw path when you port a tool.** Core scrubs an undeclared failure, and every defect, to `Tool execution failed due to an internal server error.` The message you wrote never reaches the agent, and nothing warns you. Any failure the agent should act on must be declared in the tool's `failure` schema, as a `ToolRefusal` or your own `ToolFailure`-shaped error.
|
|
114
|
+
|
|
90
115
|
## Putting it together
|
|
91
116
|
|
|
92
117
|
A stdio server's `main.ts` is one line, plus the layer that wires it:
|
|
@@ -188,9 +213,51 @@ first one's memo map shares its protocol: only the first server reads stdin,
|
|
|
188
213
|
and the second never answers. Merging both into one graph does that, and so
|
|
189
214
|
does building or providing the second anywhere under the first one's
|
|
190
215
|
`Effect.provide` — a nested `Layer.build` or `Effect.provide` forks the
|
|
191
|
-
ambient memo map rather than starting a new one.
|
|
192
|
-
|
|
193
|
-
|
|
216
|
+
ambient memo map rather than starting a new one. The tool registry is shared
|
|
217
|
+
the same way. Isolate each server by wrapping its whole bundle (its toolkit
|
|
218
|
+
layers together with `McpStdio.layer`) in `Layer.fresh`, or give it its own
|
|
219
|
+
`ManagedRuntime`, `Effect.provide(layer, { local: true })`, or its own
|
|
220
|
+
process. Never put the `Layer.fresh` boundary between a toolkit and
|
|
221
|
+
`McpStdio.layer`: the server would serve an empty registry.
|
|
222
|
+
|
|
223
|
+
## Crash guards
|
|
224
|
+
|
|
225
|
+
A stray `uncaughtException` or `unhandledRejection` in a stdio server either kills it silently or, worse, happens while its modules are still loading, before anything is listening. `@effected/mcp/guard` installs the listeners first. The entrypoint has no static runtime import, so the guards are in place before `effect` or your server graph is evaluated:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { McpGuard } from "@effected/mcp/guard";
|
|
229
|
+
|
|
230
|
+
await McpGuard.run({
|
|
231
|
+
label: "my-server",
|
|
232
|
+
host: process,
|
|
233
|
+
policy: { onUncaught: "exitBeforeConnect", onRejection: "log" },
|
|
234
|
+
load: async () => {
|
|
235
|
+
const NodeRuntime = await import("@effect/platform-node/NodeRuntime");
|
|
236
|
+
const { Main } = await import("./server.js");
|
|
237
|
+
return { layer: Main, runMain: NodeRuntime.runMain };
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`McpGuard.run` registers both listeners on `host`, awaits `load()`, and launches the returned layer with `McpStdio.launch` and `McpStdio.teardown` under the returned `runMain`. Keep every import that could throw inside `load`, behind a dynamic `import()`.
|
|
243
|
+
|
|
244
|
+
The policy decides when a stray exception or rejection ends the process. `"exit"` exits 1 whenever it happens. `"exitBeforeConnect"` exits 1 until the server is serving, then logs and keeps serving, because a server that dies mid-session takes every tool away from the client. `"log"` (rejections only) never exits. Both default to `"exit"`.
|
|
245
|
+
|
|
246
|
+
Some behaviour worth knowing:
|
|
247
|
+
|
|
248
|
+
- "Connected" means the whole server layer has built, so stdin is being read. That comes before any client sends `initialize`. `McpStdio.launch`'s `onReady` option is where the guard gets that signal.
|
|
249
|
+
- A `load()` that rejects is reported as `startup failed` and exits 1, whatever the policy. Left to a log-only rejection listener it would let the event loop drain and exit 0 with no server running.
|
|
250
|
+
- An `unhandledRejection` listener switches off Node's default of throwing on one, so `"log"` really does keep the process running.
|
|
251
|
+
- A guard's exit skips Effect's finalizers and the teardown.
|
|
252
|
+
- Carrying on after a crash works because the platform `runMain` holds the process open while the server fiber lives.
|
|
253
|
+
- A layer that fails to build is still reported by `McpStdio.launch`, on stderr.
|
|
254
|
+
|
|
255
|
+
To test the guards themselves, pass `injectCrash: { at, kind }`, with `kind` either `"uncaughtException"` or `"unhandledRejection"`. Wire it to an environment variable only your test sets; unset, it does nothing. The crash is raised through `host.emit` on a `setTimeout(0)` tick, so the guard's own listeners handle it identically under the real `process` and under a test double for `McpGuardHost` (which therefore needs `on`, `emit`, `stderr` and `exit`). If a double's `exit` throws at `"load"`, `run` rejects with what it threw instead of waiting.
|
|
256
|
+
|
|
257
|
+
- `at: "load"` raises it once both listeners are installed and before `load()` is called, and `load()` waits until the guard has handled it. The pre-connect half of the policy applies: `"exitBeforeConnect"` and `"exit"` both exit 1 here, and only `onRejection: "log"` lets the process go on to load and serve. The report uses the guard's own formatter, because no `format` is loaded yet.
|
|
258
|
+
- `at: "connected"` raises it on a timer just after the server is serving, where `"exitBeforeConnect"` logs and keeps serving.
|
|
259
|
+
|
|
260
|
+
Together they drive both halves of `"exitBeforeConnect"` in a real child process.
|
|
194
261
|
|
|
195
262
|
## Strict input
|
|
196
263
|
|
|
@@ -289,6 +356,59 @@ edit({ action: "rename", to: "b", extra: 1, options: { force: true, bogus: 2 } }
|
|
|
289
356
|
=> Unrecognized parameter(s): extra. Accepted params: action, to, options. Unrecognized parameter(s): options.bogus. Accepted params: force.
|
|
290
357
|
```
|
|
291
358
|
|
|
359
|
+
## Union-parameter tools
|
|
360
|
+
|
|
361
|
+
A tool whose parameters are a `Schema.Union` of objects cannot be a `Tool.make` tool: core dies at registration on a non-object `parameters` root. `McpToolkit.unionTool` makes it a `Tool.dynamic` served with Effect's strict JSON Schema document for the union (`additionalProperties: false` on every object node), object-rooted by `ToolInputSchema.objectRooted`. `McpToolkit.unionHandler` wraps a handler that receives the decoded member:
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
|
|
365
|
+
import { Effect, Schema } from "effect";
|
|
366
|
+
import { Tool, Toolkit } from "effect/unstable/ai";
|
|
367
|
+
|
|
368
|
+
const AddNote = Schema.Struct({ action: Schema.Literal("add"), text: Schema.String });
|
|
369
|
+
const ListNotes = Schema.Struct({ action: Schema.Literal("list"), limit: Schema.optionalKey(Schema.Number) });
|
|
370
|
+
|
|
371
|
+
const Note = McpToolkit.unionTool("note", {
|
|
372
|
+
description: "Add or list notes.",
|
|
373
|
+
parameters: Schema.Union([AddNote, ListNotes]),
|
|
374
|
+
success: ToolOutputSchema.objectRooted(
|
|
375
|
+
Schema.Union([
|
|
376
|
+
Schema.Struct({ kind: Schema.Literal("added") }),
|
|
377
|
+
Schema.Struct({ kind: Schema.Literal("listed"), count: Schema.Number }),
|
|
378
|
+
]),
|
|
379
|
+
),
|
|
380
|
+
failure: ToolRefusal,
|
|
381
|
+
}).annotate(Tool.Title, "Note");
|
|
382
|
+
|
|
383
|
+
const Kit = Toolkit.make(Note);
|
|
384
|
+
const Handlers = Kit.toLayer({
|
|
385
|
+
// `params` is AddNote | ListNotes, already decoded.
|
|
386
|
+
note: McpToolkit.unionHandler(Note, (params) =>
|
|
387
|
+
Effect.succeed(params.action === "add" ? { kind: "added" as const } : { kind: "listed" as const, count: 0 }),
|
|
388
|
+
),
|
|
389
|
+
});
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
`annotate` and `addDependency` keep the union on the tool, so a chained `.annotate(Tool.Title, …)` still hands `unionHandler` the decoded type.
|
|
393
|
+
|
|
394
|
+
Registered through `McpToolkit.layer`, a union tool is checked like a strict `Tool.make` tool: every unknown key at every depth is named in one `InvalidParams`, then the union is decoded strictly, both before the handler runs. Bad arguments therefore answer as a `Tool.make` decode failure does: a JSON-RPC `-32602` on `2025-06-18`, an `isError` result on the later revisions. Registered through core's `McpServer.toolkit` instead, or called directly (a test helper, say), `unionHandler` runs the same check itself, so the message is identical: every unknown key named per level in the `ToolInputSchema.formatUnknownKeys` report, then core's `Tool.make` wording for a bad value. Under `McpServer.toolkit` that `InvalidParams` is a declared failure, an `isError` result on every revision. A custom rendering goes in `unionHandler`'s third argument, `{ unknownKeyMessage }`, as it does in `McpToolkit.layer`'s options: pass the same function to both. Never annotate a union tool `Tool.Strict` true: core dies at registration on a strict tool with a raw JSON Schema.
|
|
395
|
+
|
|
396
|
+
## Union success schemas
|
|
397
|
+
|
|
398
|
+
MCP requires a tool's `outputSchema` to be rooted in an object, and a top-level `Schema.Union` emits a bare `anyOf`. Core drops such an `outputSchema` from `tools/list` on the stateful revisions, and serves it verbatim on the stateless one, where strict clients reject it. When every member of the union is an object shape, `ToolOutputSchema.objectRooted` adds `type: "object"` beside the `anyOf` through a check that always passes, so decoding and the schema's type stay the same:
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
import { ToolOutputSchema } from "@effected/mcp";
|
|
402
|
+
import { Schema } from "effect";
|
|
403
|
+
|
|
404
|
+
const Found = Schema.Struct({ kind: Schema.Literal("found"), id: Schema.String });
|
|
405
|
+
const Missing = Schema.Struct({ kind: Schema.Literal("missing"), reason: Schema.String });
|
|
406
|
+
|
|
407
|
+
const Result = ToolOutputSchema.objectRooted(Schema.Union([Found, Missing])).annotate({ identifier: "Result" });
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
It can go before or after `.annotate({ identifier })`: the identifier the schema already carries moves onto the new check, so the served documents are equal either way. Every union member must be an object shape: do not use it on a union with a primitive or array member, because the added `type: "object"` is a claim about the whole union that nothing checks, and the served schema would then contradict what the tool returns — give that tool an object envelope instead. `McpToolAudit`'s `objectRootedOutput` check names this helper when it finds a union root.
|
|
411
|
+
|
|
292
412
|
## Testing
|
|
293
413
|
|
|
294
414
|
`@effected/mcp/testing` is a separate entrypoint — importing it never pulls
|
|
@@ -314,7 +434,7 @@ const test = Effect.gen(function* () {
|
|
|
314
434
|
On a stateful revision (the default is `2025-11-25`), `initialize` comes
|
|
315
435
|
first: any other request sent before it fails fast with `McpTestFailure`
|
|
316
436
|
reason `NotInitialized` instead of the server's opaque `Invalid request
|
|
317
|
-
metadata`.
|
|
437
|
+
metadata`. `client.initializeWith("2025-06-18")` asks for a different stateful revision than the harness speaks and returns the whole response, so a test can assert the negotiated `protocolVersion`. A stateful server never refuses a version: an unknown one, or the stateless `2026-07-28`, is counter-offered `2025-11-25`. On a harness made with the stateless revision, `initialize` itself is not served, and the response is a JSON-RPC `-32601` error with no `notifications/initialized` sent after it. `client.sentSoFar` holds every frame the harness has written, in order.
|
|
318
438
|
|
|
319
439
|
`McpHarness.make` runs the server in-process over queue-backed `Stdio`, so
|
|
320
440
|
a test sees the exact served schemas and wire results a real client would,
|
package/ToolFailure.js
CHANGED
|
@@ -16,7 +16,13 @@ const isHighSurrogate = (code) => code >= 55296 && code <= 56319;
|
|
|
16
16
|
*
|
|
17
17
|
* Spread {@link ToolFailure.fields} into your tool's `Schema.TaggedError`, build
|
|
18
18
|
* `message` with {@link ToolFailure.message}, and pass every caller-supplied
|
|
19
|
-
* value through {@link ToolFailure.truncate} before echoing it.
|
|
19
|
+
* value through {@link ToolFailure.truncate} before echoing it. For a plain
|
|
20
|
+
* refusal with no fields of its own, `ToolRefusal` is this shape ready-made.
|
|
21
|
+
*
|
|
22
|
+
* Only a declared failure keeps its message: core scrubs an undeclared
|
|
23
|
+
* failure or a defect to "Tool execution failed due to an internal server
|
|
24
|
+
* error.", so audit every throw path a tool can take and declare each one the
|
|
25
|
+
* agent should read.
|
|
20
26
|
*
|
|
21
27
|
* @example
|
|
22
28
|
* ```ts
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Schema, SchemaAST } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/ToolOutputSchema.ts
|
|
4
|
+
const MARK = "~effected/mcp/objectRooted";
|
|
5
|
+
/** Always passes; contributes `type: "object"` beside whatever the node already emits. */
|
|
6
|
+
const objectRoot = Schema.makeFilter(() => true, {
|
|
7
|
+
[MARK]: true,
|
|
8
|
+
toJsonSchema: () => ({ type: "object" })
|
|
9
|
+
});
|
|
10
|
+
const isObjectRooted = (ast) => ast.checks?.some((check) => check.annotations?.[MARK] === true) === true;
|
|
11
|
+
/**
|
|
12
|
+
* Shaping for a tool's `success` schema, so the `outputSchema` core serves
|
|
13
|
+
* is one every client accepts.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* MCP requires a tool's `outputSchema` to be rooted in an object, and a
|
|
17
|
+
* top-level `Schema.Union` emits a bare `anyOf`. Core drops such an
|
|
18
|
+
* `outputSchema` from `tools/list` on the stateful revisions and serves it
|
|
19
|
+
* verbatim on the stateless one, where strict clients reject it.
|
|
20
|
+
* `McpToolAudit`'s `objectRootedOutput` check reports it either way.
|
|
21
|
+
*
|
|
22
|
+
* {@link ToolOutputSchema.objectRooted} is the fix when every member of the
|
|
23
|
+
* union is an object shape; never apply it to a union with a primitive or
|
|
24
|
+
* array member. It works on the Effect
|
|
25
|
+
* schema, not on JSON Schema — unlike `ToolInputSchema.objectRooted`, which
|
|
26
|
+
* rewrites the served input document of a `Tool.dynamic`.
|
|
27
|
+
*
|
|
28
|
+
* @public
|
|
29
|
+
*/
|
|
30
|
+
var ToolOutputSchema = class {
|
|
31
|
+
constructor() {}
|
|
32
|
+
/**
|
|
33
|
+
* `schema` with `type: "object"` added beside the `anyOf` at its JSON
|
|
34
|
+
* Schema root, through a check that always passes. Decoding, encoding and
|
|
35
|
+
* the schema's type are unchanged.
|
|
36
|
+
*
|
|
37
|
+
* @remarks
|
|
38
|
+
* Order against `.annotate({ identifier })` does not matter. A check added
|
|
39
|
+
* after an identifier starts a new node that would drop the definition
|
|
40
|
+
* name, so the identifier the schema already resolves is carried onto the
|
|
41
|
+
* new check, and the served documents are equal either way (key order
|
|
42
|
+
* can differ when the schema carries a title or description). Applying it
|
|
43
|
+
* twice is a no-op.
|
|
44
|
+
*
|
|
45
|
+
* Every union member must be an object shape: the added `type: "object"`
|
|
46
|
+
* is a claim about the whole union, and nothing checks it. Do not use it
|
|
47
|
+
* on a union with a primitive or array member — the served schema would
|
|
48
|
+
* contradict the values the tool returns; give such a tool an object
|
|
49
|
+
* envelope instead.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* const Result = ToolOutputSchema.objectRooted(Schema.Union([Found, Missing])).annotate({ identifier: "Result" })
|
|
54
|
+
* Tool.make("lookup", { parameters, success: Result })
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
static objectRooted = (schema) => {
|
|
58
|
+
if (isObjectRooted(schema.ast)) return schema;
|
|
59
|
+
const identifier = SchemaAST.resolveIdentifier(schema.ast);
|
|
60
|
+
const checked = schema.check(objectRoot);
|
|
61
|
+
return identifier === void 0 ? checked : checked.annotate({ identifier });
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
//#endregion
|
|
66
|
+
export { ToolOutputSchema };
|
package/ToolRefusal.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ToolFailure } from "./ToolFailure.js";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/ToolRefusal.ts
|
|
5
|
+
/**
|
|
6
|
+
* A tool call refused for a reason the caller can fix: the kit's ready-made
|
|
7
|
+
* declared failure, built on {@link ToolFailure.fields}.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Core scrubs every undeclared failure and every defect to one generic
|
|
11
|
+
* internal-error sentence, so a refusal the agent must act on (an unknown id,
|
|
12
|
+
* a missing argument, a precondition) reaches it only when the tool declares
|
|
13
|
+
* it. Declare `ToolRefusal` in the tool's `failure` schema and fail with
|
|
14
|
+
* {@link ToolRefusal.refuse}; its message already carries the remediation,
|
|
15
|
+
* because a declared `Error` failure sends `message` and nothing else.
|
|
16
|
+
*
|
|
17
|
+
* Audit every throw path when porting a tool: any failure left undeclared
|
|
18
|
+
* loses its message on the wire, however carefully it was worded.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* const Lookup = Tool.make("lookup", { parameters, success, failure: ToolRefusal })
|
|
23
|
+
* // in the handler:
|
|
24
|
+
* return yield* ToolRefusal.refuse(`No run "${ToolFailure.truncate(id)}".`, { hint: "List runs first.", suggestedTool: "list_runs" })
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @public
|
|
28
|
+
*/
|
|
29
|
+
var ToolRefusal = class ToolRefusal extends Schema.TaggedError()("ToolRefusal", ToolFailure.fields) {
|
|
30
|
+
/**
|
|
31
|
+
* A refusal for `reason`, with `remediation` folded into its message by
|
|
32
|
+
* {@link ToolFailure.message}. `reason` must already truncate any
|
|
33
|
+
* caller-supplied value it echoes, with {@link ToolFailure.truncate}.
|
|
34
|
+
*/
|
|
35
|
+
static refuse = (reason, remediation) => new ToolRefusal({
|
|
36
|
+
message: ToolFailure.message(reason, remediation),
|
|
37
|
+
remediation
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
//#endregion
|
|
42
|
+
export { ToolRefusal };
|
package/guard.d.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Effect, Layer, Runtime } from "effect";
|
|
2
|
+
//#region src/McpGuard.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The slice of the host process the guard uses. Node's `process` satisfies it.
|
|
5
|
+
*
|
|
6
|
+
* @public
|
|
7
|
+
*/
|
|
8
|
+
interface McpGuardHost {
|
|
9
|
+
/** Register the `uncaughtException` listener. */
|
|
10
|
+
on(event: "uncaughtException", listener: (error: Error, origin: string) => void): unknown;
|
|
11
|
+
/** Register the `unhandledRejection` listener. */
|
|
12
|
+
on(event: "unhandledRejection", listener: (reason: unknown) => void): unknown;
|
|
13
|
+
/** Raise an `uncaughtException` to the registered listeners. Used only by {@link McpGuardRunOptions.injectCrash}. */
|
|
14
|
+
emit(event: "uncaughtException", error: Error, origin: "uncaughtException" | "unhandledRejection"): unknown;
|
|
15
|
+
/** Raise an `unhandledRejection` to the registered listeners. Used only by {@link McpGuardRunOptions.injectCrash}. */
|
|
16
|
+
emit(event: "unhandledRejection", reason: unknown, promise: Promise<unknown>): unknown;
|
|
17
|
+
/** Where every guard report is written. Never stdout: that is the JSON-RPC wire. */
|
|
18
|
+
readonly stderr: {
|
|
19
|
+
write(chunk: string): unknown;
|
|
20
|
+
};
|
|
21
|
+
/** End the process at once. */
|
|
22
|
+
exit(code?: number): never;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* When a stray exception or rejection ends the process.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* `"exit"` exits 1 whenever it happens. `"exitBeforeConnect"` exits 1 until
|
|
29
|
+
* the server is serving, then logs and keeps serving: a server that dies
|
|
30
|
+
* mid-session deregisters every tool from the client. `"log"` never exits.
|
|
31
|
+
*
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
interface McpGuardPolicy {
|
|
35
|
+
/** For `uncaughtException`. */
|
|
36
|
+
readonly onUncaught: "exit" | "exitBeforeConnect";
|
|
37
|
+
/** For `unhandledRejection`. Defaults to `"exit"`. */
|
|
38
|
+
readonly onRejection?: "exit" | "exitBeforeConnect" | "log" | undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* What {@link McpGuardRunOptions.load} resolves to: the server and the
|
|
42
|
+
* platform runner to launch it with.
|
|
43
|
+
*
|
|
44
|
+
* @public
|
|
45
|
+
*/
|
|
46
|
+
interface McpGuardedServer<ROut, E> {
|
|
47
|
+
/** The whole server layer, stdio included, with nothing left to provide. */
|
|
48
|
+
readonly layer: Layer.Layer<ROut, E, never>;
|
|
49
|
+
/** The platform's `runMain`, such as `NodeRuntime.runMain` from `@effect/platform-node`. */
|
|
50
|
+
readonly runMain: (effect: Effect.Effect<never, Error>, options: {
|
|
51
|
+
readonly teardown: Runtime.Teardown;
|
|
52
|
+
}) => void;
|
|
53
|
+
/** Formats a reported error from here on; the guard's own dependency-free formatter until then. */
|
|
54
|
+
readonly format?: ((error: unknown) => string) | undefined;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Options for {@link McpGuard.run}.
|
|
58
|
+
*
|
|
59
|
+
* @public
|
|
60
|
+
*/
|
|
61
|
+
interface McpGuardRunOptions<ROut, E> {
|
|
62
|
+
/** Prefixes every report the guard writes, as in `my-server: uncaughtException (…): …`. */
|
|
63
|
+
readonly label: string;
|
|
64
|
+
/** The process: pass `process`. */
|
|
65
|
+
readonly host: McpGuardHost;
|
|
66
|
+
/** Defaults to `{ onUncaught: "exit", onRejection: "exit" }`. */
|
|
67
|
+
readonly policy?: McpGuardPolicy | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* Import and assemble the server. Everything that can throw while loading
|
|
70
|
+
* belongs here, behind a dynamic `import()`, so the guards are already
|
|
71
|
+
* listening when it runs.
|
|
72
|
+
*/
|
|
73
|
+
readonly load: () => Promise<McpGuardedServer<ROut, E>>;
|
|
74
|
+
/**
|
|
75
|
+
* For a test of the guards themselves: raise one stray `kind` at `at`, so
|
|
76
|
+
* both halves of a policy can be driven end to end in a real process.
|
|
77
|
+
*
|
|
78
|
+
* @remarks
|
|
79
|
+
* - `"load"` raises it once both listeners are installed and before
|
|
80
|
+
* `load()` is called, then waits for the guard to handle it: `load()`
|
|
81
|
+
* is not called until the listener has run. If a test double's `exit`
|
|
82
|
+
* throws instead of ending the process, `run` rejects with what it
|
|
83
|
+
* threw and never calls `load()`. The pre-connect half of
|
|
84
|
+
* the policy applies, so `"exitBeforeConnect"` exits 1 here, and only
|
|
85
|
+
* an `onRejection` of `"log"` lets the process go on to load and serve.
|
|
86
|
+
* The report uses the guard's own formatter, since no `format` is
|
|
87
|
+
* loaded yet.
|
|
88
|
+
* - `"connected"` raises it on a timer right after the server is
|
|
89
|
+
* serving, where `"exitBeforeConnect"` logs and keeps serving. A throw
|
|
90
|
+
* from a test double's `exit` there is dropped.
|
|
91
|
+
*
|
|
92
|
+
* Either is raised only through `host.emit`, on a `setTimeout(0)` tick,
|
|
93
|
+
* never as a real throw or rejection, so the guard's listeners handle it
|
|
94
|
+
* the same way under the real `process` and under a test double. Under
|
|
95
|
+
* `process`, every listener registered for the event sees it, not only
|
|
96
|
+
* the guard's. It carries an `[injected]` message; a rejection is emitted
|
|
97
|
+
* with an already-handled rejected promise. `undefined`, or
|
|
98
|
+
* an `at` or `kind` outside these values, does nothing at all. Wire it to
|
|
99
|
+
* an environment variable only a test sets.
|
|
100
|
+
*/
|
|
101
|
+
readonly injectCrash?: {
|
|
102
|
+
readonly at: "load" | "connected";
|
|
103
|
+
readonly kind: "uncaughtException" | "unhandledRejection";
|
|
104
|
+
} | undefined;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Crash guards for an MCP server process, installed before the server's
|
|
108
|
+
* module graph is loaded. Imported from `@effected/mcp/guard`.
|
|
109
|
+
*
|
|
110
|
+
* @remarks
|
|
111
|
+
* {@link McpGuard.run} registers `uncaughtException` and `unhandledRejection`
|
|
112
|
+
* listeners on `host`, then awaits `load()`, then launches the server with
|
|
113
|
+
* `McpStdio.launch` and `McpStdio.teardown` under the `runMain` it returned.
|
|
114
|
+
* This entrypoint has no static runtime import: it loads `effect` and the
|
|
115
|
+
* stdio wiring only after the guards are listening, so a throw while any of
|
|
116
|
+
* it evaluates is still reported on stderr.
|
|
117
|
+
*
|
|
118
|
+
* - "Connected" means the whole server layer has built, so stdin is being
|
|
119
|
+
* read. That is before any client sends `initialize`.
|
|
120
|
+
* - `injectCrash` drives either half of the policy from a test, before
|
|
121
|
+
* `load()` or once connected; unset, it does nothing.
|
|
122
|
+
* - A `load()` that rejects is reported as `startup failed` and exits 1
|
|
123
|
+
* whatever the policy. Left to a log-only rejection listener, it would
|
|
124
|
+
* let the event loop drain and exit 0 with no server.
|
|
125
|
+
* - Installing an `unhandledRejection` listener switches off Node's default
|
|
126
|
+
* of throwing on one, so `"log"` really does keep the process running.
|
|
127
|
+
* - An exit from a guard skips Effect's finalizers and the teardown.
|
|
128
|
+
* - Keeping on serving after a crash works because the platform `runMain`
|
|
129
|
+
* holds the process open while the server fiber lives.
|
|
130
|
+
* - A layer that fails to build is still reported by `McpStdio.launch`.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* import { McpGuard } from "@effected/mcp/guard";
|
|
135
|
+
*
|
|
136
|
+
* await McpGuard.run({
|
|
137
|
+
* label: "my-server",
|
|
138
|
+
* host: process,
|
|
139
|
+
* policy: { onUncaught: "exitBeforeConnect", onRejection: "log" },
|
|
140
|
+
* load: async () => {
|
|
141
|
+
* const { NodeRuntime } = await import("@effect/platform-node");
|
|
142
|
+
* const { Main } = await import("./server.js");
|
|
143
|
+
* return { layer: Main, runMain: NodeRuntime.runMain };
|
|
144
|
+
* },
|
|
145
|
+
* });
|
|
146
|
+
* ```
|
|
147
|
+
*
|
|
148
|
+
* @public
|
|
149
|
+
*/
|
|
150
|
+
export declare class McpGuard {
|
|
151
|
+
private constructor();
|
|
152
|
+
/** Install the guards, load the server, and launch it. Resolves once the server is launched. */
|
|
153
|
+
static readonly run: <ROut, E>(options: McpGuardRunOptions<ROut, E>) => Promise<void>;
|
|
154
|
+
}
|
|
155
|
+
//#endregion
|
|
156
|
+
export type { McpGuardHost, McpGuardPolicy, McpGuardRunOptions, McpGuardedServer };
|
|
157
|
+
//# sourceMappingURL=guard.d.ts.map
|
package/guard.js
ADDED
package/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Array, Effect, JsonSchema, Layer, Runtime, Schema, Stdio } from "effect";
|
|
1
|
+
import { Array, Context, Effect, JsonSchema, Layer, Runtime, Schema, Stdio } from "effect";
|
|
2
2
|
import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/unstable/ai";
|
|
3
3
|
import { Remediation } from "@effected/engine";
|
|
4
4
|
//#region src/McpStdio.d.ts
|
|
@@ -19,6 +19,26 @@ interface McpStdioOptions {
|
|
|
19
19
|
/** Defaults to {@link McpStdio.protocols}. Read its ordering rules before overriding. */
|
|
20
20
|
readonly protocols?: Array.NonEmptyReadonlyArray<McpProtocol.ProtocolAdapter> | undefined;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Options for {@link McpStdio.launch}.
|
|
24
|
+
*
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
interface McpLaunchOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Runs once the whole layer has built, and before the launch waits
|
|
30
|
+
* forever. By then the stdio protocol is reading stdin: the server is
|
|
31
|
+
* serving, although no client may have sent `initialize` yet.
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* `McpGuard.run` (`@effected/mcp/guard`) uses it as its "connected"
|
|
35
|
+
* signal. A layer built after the server layer, as in
|
|
36
|
+
* `Layer.effectDiscard(ready).pipe(Layer.provide(Main))`, gives the same
|
|
37
|
+
* signal, but only if every consumer re-derives that `Layer.provide`
|
|
38
|
+
* builds its dependency first.
|
|
39
|
+
*/
|
|
40
|
+
readonly onReady?: Effect.Effect<void> | undefined;
|
|
41
|
+
}
|
|
22
42
|
/**
|
|
23
43
|
* Serve an MCP server over stdio without ever writing a log line or a failure
|
|
24
44
|
* report onto stdout, which is the JSON-RPC wire.
|
|
@@ -107,8 +127,12 @@ export declare class McpStdio {
|
|
|
107
127
|
* one graph does that, and so does building or providing the second
|
|
108
128
|
* anywhere under the first one's `Effect.provide`: nested `Layer.build`
|
|
109
129
|
* and `Effect.provide` fork the ambient memo map rather than starting a
|
|
110
|
-
* new one.
|
|
111
|
-
*
|
|
130
|
+
* new one. The tool registry is shared the same way. Isolate each server
|
|
131
|
+
* by wrapping its whole bundle (its toolkit layers together with this
|
|
132
|
+
* layer) in `Layer.fresh`, or with its own `ManagedRuntime`,
|
|
133
|
+
* `Effect.provide(layer, { local: true })` or its own process. Never put
|
|
134
|
+
* the `Layer.fresh` boundary between a toolkit and this layer: the server
|
|
135
|
+
* would serve an empty registry.
|
|
112
136
|
*
|
|
113
137
|
* Code after a completed `Effect.provide` of a stdio server never runs:
|
|
114
138
|
* core's stdio protocol interrupts the fiber that built it when its stdin
|
|
@@ -129,8 +153,11 @@ export declare class McpStdio {
|
|
|
129
153
|
* `Effect.provideService` reaches, and Effect's default logger writes
|
|
130
154
|
* through `console.log` unless `LogToStderr` is set. For an MCP server
|
|
131
155
|
* that is stdout, which is the wire.
|
|
156
|
+
*
|
|
157
|
+
* `options.onReady` runs after the layer has built and before the launch
|
|
158
|
+
* waits forever, as {@link McpLaunchOptions.onReady} describes.
|
|
132
159
|
*/
|
|
133
|
-
static readonly launch: <ROut, E, R>(layer: Layer.Layer<ROut, E, R
|
|
160
|
+
static readonly launch: <ROut, E, R>(layer: Layer.Layer<ROut, E, R>, options?: McpLaunchOptions) => Effect.Effect<never, Error, R>;
|
|
134
161
|
/**
|
|
135
162
|
* Success or an interrupt-only exit maps to 0. Anything else goes to
|
|
136
163
|
* `Runtime.defaultTeardown`.
|
|
@@ -239,6 +266,61 @@ interface McpToolkitOptions {
|
|
|
239
266
|
*/
|
|
240
267
|
readonly unknownKeyMessage?: ((levels: ReadonlyArray<UnknownKeysLevel>) => string) | undefined;
|
|
241
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* A `Tool.dynamic` made by {@link McpToolkit.unionTool}: served with the
|
|
271
|
+
* strict, object-rooted JSON Schema of a `Schema.Union` of objects, and
|
|
272
|
+
* carrying that union so {@link McpToolkit.unionHandler} can decode it.
|
|
273
|
+
*
|
|
274
|
+
* @remarks
|
|
275
|
+
* `annotate` and `addDependency` keep the type, so a chained
|
|
276
|
+
* `.annotate(Tool.Title, …)` still hands `unionHandler` the union. The
|
|
277
|
+
* declared failure is `failure` together with `McpSchema.InvalidParams`.
|
|
278
|
+
*
|
|
279
|
+
* @public
|
|
280
|
+
*/
|
|
281
|
+
interface UnionTool<Name extends string, P extends Schema.Decoder<unknown>, S extends Schema.Constraint, F extends Schema.Constraint, R = never> extends Tool.Dynamic<Name, {
|
|
282
|
+
readonly parameters: JsonSchema.JsonSchema;
|
|
283
|
+
readonly success: S;
|
|
284
|
+
readonly failure: Schema.Union<readonly [F, typeof McpSchema.InvalidParams]>;
|
|
285
|
+
readonly failureMode: "error";
|
|
286
|
+
}, R> {
|
|
287
|
+
/** The union the tool decodes its arguments with. */
|
|
288
|
+
readonly unionParameters: P;
|
|
289
|
+
/** Add an annotation, keeping the union. */
|
|
290
|
+
annotate<I, V>(tag: Context.Key<I, V>, value: V): UnionTool<Name, P, S, F, R>;
|
|
291
|
+
/** Add a request-level dependency, keeping the union. */
|
|
292
|
+
addDependency<Identifier, Service>(tag: Context.Key<Identifier, Service>): UnionTool<Name, P, S, F, Identifier | R>;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Options for {@link McpToolkit.unionTool}.
|
|
296
|
+
*
|
|
297
|
+
* @public
|
|
298
|
+
*/
|
|
299
|
+
interface UnionToolOptions<P extends Schema.Decoder<unknown>, S extends Schema.Constraint, F extends Schema.Constraint, Dependencies extends ReadonlyArray<Context.Key<unknown, unknown>>> {
|
|
300
|
+
/** What the tool does, for the agent. */
|
|
301
|
+
readonly description?: string | undefined;
|
|
302
|
+
/** A `Schema.Union` of object schemas, discriminated by an `action`, `kind`, `_tag` or `type` literal. */
|
|
303
|
+
readonly parameters: P;
|
|
304
|
+
/** The success schema. Defaults to `Schema.Void`; object-root a union whose members are all objects with `ToolOutputSchema.objectRooted`. */
|
|
305
|
+
readonly success?: S | undefined;
|
|
306
|
+
/** Declared failures besides `InvalidParams`, such as `ToolRefusal`. Defaults to `Schema.Never`. */
|
|
307
|
+
readonly failure?: F | undefined;
|
|
308
|
+
/** Services the handler needs, as with `Tool.make`. */
|
|
309
|
+
readonly dependencies?: Dependencies | undefined;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Options for {@link McpToolkit.unionHandler}.
|
|
313
|
+
*
|
|
314
|
+
* @public
|
|
315
|
+
*/
|
|
316
|
+
interface UnionHandlerOptions {
|
|
317
|
+
/**
|
|
318
|
+
* Replaces the default `ToolInputSchema.formatUnknownKeys` rendering, as
|
|
319
|
+
* {@link McpToolkitOptions.unknownKeyMessage} does for the layer. Pass the
|
|
320
|
+
* same function to both for one report on every path.
|
|
321
|
+
*/
|
|
322
|
+
readonly unknownKeyMessage?: ((levels: ReadonlyArray<UnknownKeysLevel>) => string) | undefined;
|
|
323
|
+
}
|
|
242
324
|
/**
|
|
243
325
|
* Register a toolkit exactly as core's `McpServer.toolkit` does, except that a
|
|
244
326
|
* strict tool's unknown arguments are all named, at every depth, in one
|
|
@@ -271,6 +353,8 @@ interface McpToolkitOptions {
|
|
|
271
353
|
* shared by reference with `McpStdio.layer`'s own copy — provide both into
|
|
272
354
|
* the same graph, as with `McpServer.toolkit`. Never wrap that layer in
|
|
273
355
|
* `Layer.fresh`: tools would register into a second registry nobody serves.
|
|
356
|
+
* To give two servers separate registries, wrap each whole bundle (this
|
|
357
|
+
* layer together with `McpStdio.layer`) in `Layer.fresh` instead.
|
|
274
358
|
*
|
|
275
359
|
* Each call mints a fresh layer; bind the result to a `const` or the
|
|
276
360
|
* registration runs twice.
|
|
@@ -288,6 +372,60 @@ interface McpToolkitOptions {
|
|
|
288
372
|
*/
|
|
289
373
|
export declare class McpToolkit {
|
|
290
374
|
private constructor();
|
|
375
|
+
/**
|
|
376
|
+
* A tool whose parameters are a `Schema.Union` of objects, which
|
|
377
|
+
* `Tool.make` cannot take: core dies at registration on a non-object
|
|
378
|
+
* `parameters` root.
|
|
379
|
+
*
|
|
380
|
+
* @remarks
|
|
381
|
+
* The tool is a `Tool.dynamic` served with Effect's strict JSON Schema
|
|
382
|
+
* document for `parameters` (`additionalProperties: false` on every
|
|
383
|
+
* object node, definitions as `$defs`), rewritten by
|
|
384
|
+
* `ToolInputSchema.objectRooted` to an object root carrying `type`,
|
|
385
|
+
* `oneOf` and `x-discriminator`. Write its handler with
|
|
386
|
+
* {@link McpToolkit.unionHandler}, which receives the decoded member.
|
|
387
|
+
*
|
|
388
|
+
* Registered through {@link McpToolkit.layer}, a union tool gets the same
|
|
389
|
+
* treatment as a strict `Tool.make` tool: every unknown key named in one
|
|
390
|
+
* `InvalidParams`, then a strict decode, both before the handler runs, so
|
|
391
|
+
* bad arguments answer JSON-RPC `-32602` on `2025-06-18` and an `isError`
|
|
392
|
+
* result on the later revisions, exactly as a `Tool.make` decode failure
|
|
393
|
+
* does. Registered through core's `McpServer.toolkit` it still decodes
|
|
394
|
+
* strictly, in the handler, but the `InvalidParams` is then a declared
|
|
395
|
+
* failure: an `isError` result on every revision.
|
|
396
|
+
*
|
|
397
|
+
* Never annotate it `Tool.Strict` true: core dies at registration on a
|
|
398
|
+
* strict tool with a raw JSON Schema.
|
|
399
|
+
*
|
|
400
|
+
* @example
|
|
401
|
+
* ```ts
|
|
402
|
+
* const Note = McpToolkit.unionTool("note", {
|
|
403
|
+
* parameters: Schema.Union([AddNote, ListNotes]),
|
|
404
|
+
* success: ToolOutputSchema.objectRooted(Schema.Union([Added, Listed])),
|
|
405
|
+
* failure: ToolRefusal,
|
|
406
|
+
* }).annotate(Tool.Title, "Note")
|
|
407
|
+
* const handlers = Kit.toLayer({ note: McpToolkit.unionHandler(Note, (params) => handleNote(params)) })
|
|
408
|
+
* ```
|
|
409
|
+
*/
|
|
410
|
+
static readonly unionTool: <const Name extends string, P extends Schema.Decoder<unknown>, S extends Schema.Constraint = typeof Schema.Void, F extends Schema.Constraint = typeof Schema.Never, const Dependencies extends ReadonlyArray<Context.Key<unknown, unknown>> = []>(name: Name, options: UnionToolOptions<P, S, F, Dependencies>) => UnionTool<Name, P, S, F, Context.Service.Identifier<Dependencies[number]>>;
|
|
411
|
+
/**
|
|
412
|
+
* The handler for a {@link McpToolkit.unionTool}: checks and decodes the
|
|
413
|
+
* raw payload exactly as {@link McpToolkit.layer} does, and passes the
|
|
414
|
+
* decoded member to `handler`.
|
|
415
|
+
*
|
|
416
|
+
* @remarks
|
|
417
|
+
* A payload carrying unknown keys fails with one `McpSchema.InvalidParams`
|
|
418
|
+
* naming every one of them, at every depth, in the
|
|
419
|
+
* `ToolInputSchema.formatUnknownKeys` report (or `options.unknownKeyMessage`'s),
|
|
420
|
+
* walked over the tool's served input schema. A payload that then does
|
|
421
|
+
* not decode fails `InvalidParams` worded as core words a `Tool.make`
|
|
422
|
+
* decode failure. It is the same implementation the layer runs, so a
|
|
423
|
+
* handler called directly (a test helper, or a toolkit registered through
|
|
424
|
+
* core's `McpServer.toolkit`) reports what a client of the layer sees.
|
|
425
|
+
* Under {@link McpToolkit.layer} the layer has already rejected a bad
|
|
426
|
+
* call before the handler runs, and its own `unknownKeyMessage` wins.
|
|
427
|
+
*/
|
|
428
|
+
static readonly unionHandler: <Name extends string, P extends Schema.Decoder<unknown>, S extends Schema.Constraint, F extends Schema.Constraint, R, A, E, RH>(tool: UnionTool<Name, P, S, F, R>, handler: (params: P["Type"]) => Effect.Effect<A, E, RH>, options?: UnionHandlerOptions) => ((payload: unknown) => Effect.Effect<A, E | McpSchema.InvalidParams, RH>);
|
|
291
429
|
/**
|
|
292
430
|
* `McpServer.toolkit`'s registration layer, strict by default and naming
|
|
293
431
|
* every unknown argument in one `InvalidParams`.
|
|
@@ -309,7 +447,13 @@ export declare class McpToolkit {
|
|
|
309
447
|
*
|
|
310
448
|
* Spread {@link ToolFailure.fields} into your tool's `Schema.TaggedError`, build
|
|
311
449
|
* `message` with {@link ToolFailure.message}, and pass every caller-supplied
|
|
312
|
-
* value through {@link ToolFailure.truncate} before echoing it.
|
|
450
|
+
* value through {@link ToolFailure.truncate} before echoing it. For a plain
|
|
451
|
+
* refusal with no fields of its own, `ToolRefusal` is this shape ready-made.
|
|
452
|
+
*
|
|
453
|
+
* Only a declared failure keeps its message: core scrubs an undeclared
|
|
454
|
+
* failure or a defect to "Tool execution failed due to an internal server
|
|
455
|
+
* error.", so audit every throw path a tool can take and declare each one the
|
|
456
|
+
* agent should read.
|
|
313
457
|
*
|
|
314
458
|
* @example
|
|
315
459
|
* ```ts
|
|
@@ -348,5 +492,97 @@ export declare class ToolFailure {
|
|
|
348
492
|
static readonly truncate: (value: string, limit?: number) => string;
|
|
349
493
|
}
|
|
350
494
|
//#endregion
|
|
351
|
-
|
|
495
|
+
//#region src/ToolOutputSchema.d.ts
|
|
496
|
+
/**
|
|
497
|
+
* Shaping for a tool's `success` schema, so the `outputSchema` core serves
|
|
498
|
+
* is one every client accepts.
|
|
499
|
+
*
|
|
500
|
+
* @remarks
|
|
501
|
+
* MCP requires a tool's `outputSchema` to be rooted in an object, and a
|
|
502
|
+
* top-level `Schema.Union` emits a bare `anyOf`. Core drops such an
|
|
503
|
+
* `outputSchema` from `tools/list` on the stateful revisions and serves it
|
|
504
|
+
* verbatim on the stateless one, where strict clients reject it.
|
|
505
|
+
* `McpToolAudit`'s `objectRootedOutput` check reports it either way.
|
|
506
|
+
*
|
|
507
|
+
* {@link ToolOutputSchema.objectRooted} is the fix when every member of the
|
|
508
|
+
* union is an object shape; never apply it to a union with a primitive or
|
|
509
|
+
* array member. It works on the Effect
|
|
510
|
+
* schema, not on JSON Schema — unlike `ToolInputSchema.objectRooted`, which
|
|
511
|
+
* rewrites the served input document of a `Tool.dynamic`.
|
|
512
|
+
*
|
|
513
|
+
* @public
|
|
514
|
+
*/
|
|
515
|
+
export declare class ToolOutputSchema {
|
|
516
|
+
private constructor();
|
|
517
|
+
/**
|
|
518
|
+
* `schema` with `type: "object"` added beside the `anyOf` at its JSON
|
|
519
|
+
* Schema root, through a check that always passes. Decoding, encoding and
|
|
520
|
+
* the schema's type are unchanged.
|
|
521
|
+
*
|
|
522
|
+
* @remarks
|
|
523
|
+
* Order against `.annotate({ identifier })` does not matter. A check added
|
|
524
|
+
* after an identifier starts a new node that would drop the definition
|
|
525
|
+
* name, so the identifier the schema already resolves is carried onto the
|
|
526
|
+
* new check, and the served documents are equal either way (key order
|
|
527
|
+
* can differ when the schema carries a title or description). Applying it
|
|
528
|
+
* twice is a no-op.
|
|
529
|
+
*
|
|
530
|
+
* Every union member must be an object shape: the added `type: "object"`
|
|
531
|
+
* is a claim about the whole union, and nothing checks it. Do not use it
|
|
532
|
+
* on a union with a primitive or array member — the served schema would
|
|
533
|
+
* contradict the values the tool returns; give such a tool an object
|
|
534
|
+
* envelope instead.
|
|
535
|
+
*
|
|
536
|
+
* @example
|
|
537
|
+
* ```ts
|
|
538
|
+
* const Result = ToolOutputSchema.objectRooted(Schema.Union([Found, Missing])).annotate({ identifier: "Result" })
|
|
539
|
+
* Tool.make("lookup", { parameters, success: Result })
|
|
540
|
+
* ```
|
|
541
|
+
*/
|
|
542
|
+
static readonly objectRooted: <S extends Schema.Top>(schema: S) => S;
|
|
543
|
+
}
|
|
544
|
+
//#endregion
|
|
545
|
+
//#region src/ToolRefusal.d.ts
|
|
546
|
+
declare const ToolRefusal_base: Schema.Class<ToolRefusal, Schema.TaggedStruct<"ToolRefusal", {
|
|
547
|
+
readonly message: Schema.String;
|
|
548
|
+
readonly remediation: Schema.Struct<{
|
|
549
|
+
readonly hint: Schema.String;
|
|
550
|
+
readonly suggestedTool: Schema.optionalKey<Schema.String>;
|
|
551
|
+
readonly suggestedArgs: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Unknown>>;
|
|
552
|
+
}>;
|
|
553
|
+
}>, import("effect/Cause").YieldableError>;
|
|
554
|
+
/**
|
|
555
|
+
* A tool call refused for a reason the caller can fix: the kit's ready-made
|
|
556
|
+
* declared failure, built on {@link ToolFailure.fields}.
|
|
557
|
+
*
|
|
558
|
+
* @remarks
|
|
559
|
+
* Core scrubs every undeclared failure and every defect to one generic
|
|
560
|
+
* internal-error sentence, so a refusal the agent must act on (an unknown id,
|
|
561
|
+
* a missing argument, a precondition) reaches it only when the tool declares
|
|
562
|
+
* it. Declare `ToolRefusal` in the tool's `failure` schema and fail with
|
|
563
|
+
* {@link ToolRefusal.refuse}; its message already carries the remediation,
|
|
564
|
+
* because a declared `Error` failure sends `message` and nothing else.
|
|
565
|
+
*
|
|
566
|
+
* Audit every throw path when porting a tool: any failure left undeclared
|
|
567
|
+
* loses its message on the wire, however carefully it was worded.
|
|
568
|
+
*
|
|
569
|
+
* @example
|
|
570
|
+
* ```ts
|
|
571
|
+
* const Lookup = Tool.make("lookup", { parameters, success, failure: ToolRefusal })
|
|
572
|
+
* // in the handler:
|
|
573
|
+
* return yield* ToolRefusal.refuse(`No run "${ToolFailure.truncate(id)}".`, { hint: "List runs first.", suggestedTool: "list_runs" })
|
|
574
|
+
* ```
|
|
575
|
+
*
|
|
576
|
+
* @public
|
|
577
|
+
*/
|
|
578
|
+
export declare class ToolRefusal extends ToolRefusal_base {
|
|
579
|
+
/**
|
|
580
|
+
* A refusal for `reason`, with `remediation` folded into its message by
|
|
581
|
+
* {@link ToolFailure.message}. `reason` must already truncate any
|
|
582
|
+
* caller-supplied value it echoes, with {@link ToolFailure.truncate}.
|
|
583
|
+
*/
|
|
584
|
+
static readonly refuse: (reason: string, remediation: Remediation) => ToolRefusal;
|
|
585
|
+
}
|
|
586
|
+
//#endregion
|
|
587
|
+
export type { FormatUnknownKeysOptions, McpLaunchOptions, McpStdioOptions, McpToolkitOptions, UnionHandlerOptions, UnionTool, UnionToolOptions, UnknownKeysLevel };
|
|
352
588
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -2,5 +2,7 @@ import { McpStdio } from "./McpStdio.js";
|
|
|
2
2
|
import { ToolFailure } from "./ToolFailure.js";
|
|
3
3
|
import { ToolInputSchema } from "./ToolInputSchema.js";
|
|
4
4
|
import { McpToolkit } from "./McpToolkit.js";
|
|
5
|
+
import { ToolOutputSchema } from "./ToolOutputSchema.js";
|
|
6
|
+
import { ToolRefusal } from "./ToolRefusal.js";
|
|
5
7
|
|
|
6
|
-
export { McpStdio, McpToolkit, ToolFailure, ToolInputSchema };
|
|
8
|
+
export { McpStdio, McpToolkit, ToolFailure, ToolInputSchema, ToolOutputSchema, ToolRefusal };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { McpStdio } from "../McpStdio.js";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/internal/guardLaunch.ts
|
|
5
|
+
/**
|
|
6
|
+
* The server half of `McpGuard.run`, reached only through a dynamic import so
|
|
7
|
+
* the guard module itself evaluates nothing but its own code.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
const launchGuarded = (server, onReady) => server.runMain(McpStdio.launch(server.layer, { onReady: Effect.sync(onReady) }), { teardown: McpStdio.teardown });
|
|
12
|
+
|
|
13
|
+
//#endregion
|
|
14
|
+
export { launchGuarded };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The boundary layer of an effect/unstable/ai MCP server: stdio wiring that keeps stdout the wire, tool-failure shaping, strict-input walkers, and test clients",
|
|
6
6
|
"keywords": [
|
|
@@ -34,6 +34,11 @@
|
|
|
34
34
|
"import": "./index.js",
|
|
35
35
|
"default": "./index.js"
|
|
36
36
|
},
|
|
37
|
+
"./guard": {
|
|
38
|
+
"types": "./guard.d.ts",
|
|
39
|
+
"import": "./guard.js",
|
|
40
|
+
"default": "./guard.js"
|
|
41
|
+
},
|
|
37
42
|
"./testing": {
|
|
38
43
|
"types": "./testing.d.ts",
|
|
39
44
|
"import": "./testing.js",
|
package/testing.d.ts
CHANGED
|
@@ -153,6 +153,22 @@ export declare class McpHarness {
|
|
|
153
153
|
readonly protocol: McpProtocol.ProtocolAdapter;
|
|
154
154
|
/** `initialize` then `notifications/initialized`; `server/discover` on a stateless revision. Call it first on a stateful revision. */
|
|
155
155
|
readonly initialize: Effect.Effect<JsonRpcMessage, McpTestFailure>;
|
|
156
|
+
/**
|
|
157
|
+
* `initialize` asking for `protocolVersion` instead of the harness's own
|
|
158
|
+
* revision, then `notifications/initialized` only when the response
|
|
159
|
+
* carries no error. Returns the whole response: assert the negotiated
|
|
160
|
+
* `result.protocolVersion`, or the JSON-RPC `error`.
|
|
161
|
+
*
|
|
162
|
+
* @remarks
|
|
163
|
+
* It moves between stateful revisions only. A stateful server never
|
|
164
|
+
* refuses a version: it counter-offers its latest stateful revision, so
|
|
165
|
+
* asking for an unknown one, or for the stateless `2026-07-28`, comes back
|
|
166
|
+
* as `2025-11-25`. On a harness made with a stateless revision the server
|
|
167
|
+
* does not serve `initialize` at all, and the response is a JSON-RPC
|
|
168
|
+
* `-32601` "Method not found" error, with no `notifications/initialized`
|
|
169
|
+
* sent after it.
|
|
170
|
+
*/
|
|
171
|
+
readonly initializeWith: (protocolVersion: string) => Effect.Effect<JsonRpcMessage, McpTestFailure>;
|
|
156
172
|
/** `server/discover`. */
|
|
157
173
|
readonly discover: Effect.Effect<JsonRpcMessage, McpTestFailure>;
|
|
158
174
|
/** Send a request and wait for its response. */
|
|
@@ -176,6 +192,8 @@ export declare class McpHarness {
|
|
|
176
192
|
readonly sendRaw: (message: unknown) => Effect.Effect<void>;
|
|
177
193
|
/** The next server-initiated frame with this method, skipping and keeping others; fails with `ServerStopped` once the server stops. */
|
|
178
194
|
readonly awaitOutboundMethod: (method: string) => Effect.Effect<JsonRpcMessage, McpTestFailure>;
|
|
195
|
+
/** 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. */
|
|
196
|
+
readonly sentSoFar: Effect.Effect<ReadonlyArray<unknown>>;
|
|
179
197
|
/** Everything written to stderr, plus every captured log line. */
|
|
180
198
|
readonly stderrSoFar: Effect.Effect<string>;
|
|
181
199
|
/** Every captured `console.log`, `info` or `debug` call. */
|
|
@@ -321,9 +339,21 @@ interface McpToolAuditPolicy {
|
|
|
321
339
|
readonly input: "open" | "closed" | "any";
|
|
322
340
|
/** Every tool must carry a non-empty `title`, top-level or in `annotations.title`. */
|
|
323
341
|
readonly requireTitle?: boolean | undefined;
|
|
324
|
-
/**
|
|
342
|
+
/**
|
|
343
|
+
* Every tool must serve an `outputSchema`. The stateful revisions drop a
|
|
344
|
+
* non-object one, so check the revision you serve; the violation names
|
|
345
|
+
* the likely cause, a union success schema, and `ToolOutputSchema.objectRooted`.
|
|
346
|
+
*/
|
|
325
347
|
readonly requireOutputSchema?: boolean | undefined;
|
|
326
|
-
/**
|
|
348
|
+
/**
|
|
349
|
+
* A served `outputSchema` must be rooted at `type: "object"`. Defaults to
|
|
350
|
+
* `true` (D10). Only the stateless revision serves a non-object root, so
|
|
351
|
+
* only there does this fire; a union root (`anyOf` or `oneOf`) names
|
|
352
|
+
* `ToolOutputSchema.objectRooted` as the fix for a union whose members are
|
|
353
|
+
* all objects (a union with a primitive member needs an object envelope
|
|
354
|
+
* instead). On a stateful revision the
|
|
355
|
+
* same schema is dropped, and `requireOutputSchema` reports it instead.
|
|
356
|
+
*/
|
|
327
357
|
readonly objectRootedOutput?: boolean | undefined;
|
|
328
358
|
/** The longest `description` allowed, in UTF-16 code units; a missing description counts as 0. */
|
|
329
359
|
readonly maxDescription?: number | undefined;
|