@effected/mcp 0.1.0 → 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 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
@@ -39,8 +39,10 @@ import { McpProtocol } from "effect/unstable/ai";
39
39
  * the wire. Assert it empty.
40
40
  * - On a stateful revision (the default `2025-11-25`), send `initialize`
41
41
  * first. Every other request before it fails with `NotInitialized` and
42
- * is never written: the server would only answer an opaque
43
- * `Invalid request metadata`, `ping` included.
42
+ * is never written, `ping` included: the server would only answer an
43
+ * opaque refusal, `-32602 Invalid request metadata` when a stateless
44
+ * adapter is listed first (as in `McpStdio.protocols`), or
45
+ * `-32603 Internal error` when only stateful revisions are served.
44
46
  *
45
47
  * @public
46
48
  */
@@ -49,6 +51,22 @@ var McpHarness = class McpHarness {
49
51
  protocol;
50
52
  /** `initialize` then `notifications/initialized`; `server/discover` on a stateless revision. Call it first on a stateful revision. */
51
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;
52
70
  /** `server/discover`. */
53
71
  discover;
54
72
  /** Send a request and wait for its response. */
@@ -69,6 +87,8 @@ var McpHarness = class McpHarness {
69
87
  sendRaw;
70
88
  /** The next server-initiated frame with this method, skipping and keeping others; fails with `ServerStopped` once the server stops. */
71
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;
72
92
  /** Everything written to stderr, plus every captured log line. */
73
93
  stderrSoFar;
74
94
  /** Every captured `console.log`, `info` or `debug` call. */
@@ -78,6 +98,7 @@ var McpHarness = class McpHarness {
78
98
  constructor(parts) {
79
99
  this.protocol = parts.protocol;
80
100
  this.initialize = parts.initialize;
101
+ this.initializeWith = parts.initializeWith;
81
102
  this.discover = parts.discover;
82
103
  this.request = parts.request;
83
104
  this.startRequest = parts.startRequest;
@@ -88,6 +109,7 @@ var McpHarness = class McpHarness {
88
109
  this.readResource = parts.readResource;
89
110
  this.sendRaw = parts.sendRaw;
90
111
  this.awaitOutboundMethod = parts.awaitOutboundMethod;
112
+ this.sentSoFar = parts.sentSoFar;
91
113
  this.stderrSoFar = parts.stderrSoFar;
92
114
  this.consoleLogSoFar = parts.consoleLogSoFar;
93
115
  this.close = parts.close;
@@ -162,9 +184,13 @@ var McpHarness = class McpHarness {
162
184
  const awaitResponse = (waiter) => Effect.suspend(() => Deferred.isDoneUnsafe(waiter) ? Deferred.await(waiter) : stopAware(Deferred.await(waiter)));
163
185
  const stateful = !isStateless(protocol);
164
186
  let initializeSent = false;
187
+ const sent = [];
165
188
  const sendRaw = (message) => Effect.suspend(() => {
166
- if (isJsonRpcMessage(message) && message.method === "initialize") initializeSent = true;
167
- return Effect.asVoid(Queue.offer(stdin, encoder.encode(`${JSON.stringify(message)}\n`)));
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`)));
168
194
  });
169
195
  const startRequest = (method, params) => Effect.gen(function* () {
170
196
  if (stateful && !initializeSent && method !== "initialize") return yield* new McpTestFailure({
@@ -188,6 +214,14 @@ var McpHarness = class McpHarness {
188
214
  yield* notify("notifications/initialized");
189
215
  return response;
190
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
+ });
191
225
  const listTools = Effect.flatMap(request("tools/list"), (response) => response.error === void 0 ? Effect.succeed(response.result.tools) : Effect.fail(new McpTestFailure({
192
226
  reason: "ErrorResponse",
193
227
  message: `tools/list failed: ${ToolFailure.truncate(JSON.stringify(response.error))}`
@@ -210,6 +244,7 @@ var McpHarness = class McpHarness {
210
244
  return new McpHarness({
211
245
  protocol,
212
246
  initialize,
247
+ initializeWith,
213
248
  discover,
214
249
  request,
215
250
  startRequest,
@@ -223,6 +258,7 @@ var McpHarness = class McpHarness {
223
258
  readResource: (uri) => request("resources/read", { uri }),
224
259
  sendRaw,
225
260
  awaitOutboundMethod,
261
+ sentSoFar: Effect.sync(() => [...sent]),
226
262
  stderrSoFar: Effect.sync(() => stderr.join("")),
227
263
  consoleLogSoFar: Effect.sync(() => [...consoleLog]),
228
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. Isolate each server with its own `ManagedRuntime`,
100
- * `Effect.provide(layer, { local: true })` or its own process.
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/McpTestFailure.js CHANGED
@@ -20,8 +20,9 @@ import { Schema } from "effect";
20
20
  * because stdin closed while the request was in flight.
21
21
  * - `NotInitialized` (`McpHarness`): a request other than `initialize` was
22
22
  * made on a stateful revision before `initialize` was sent. The request
23
- * is never written; the server would only have refused it with an opaque
24
- * `Invalid request metadata`.
23
+ * is never written; the server would only have refused it opaquely:
24
+ * `-32602 Invalid request metadata` when a stateless adapter is listed
25
+ * first, `-32603 Internal error` when only stateful revisions are served.
25
26
  * - `ErrorResponse` (`McpHarness.listTools`): the server answered with a
26
27
  * JSON-RPC error where a result was required. Every other request method
27
28
  * returns the whole response, error included, as data.
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") report(`outputSchema is not object-rooted (root type: ${String(tool.outputSchema.type ?? "none")})`);
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 ?? ((levels) => ToolInputSchema.formatUnknownKeys(levels));
188
+ const format = options.unknownKeyMessage ?? defaultUnknownKeyMessage;
64
189
  const decorated = McpServer.McpServer.of({
65
190
  ...registry,
66
- addTool: (registration) => Context.get(registration.annotations, Tool.Strict) === true ? registry.addTool({
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:
@@ -156,8 +181,8 @@ below.
156
181
  `McpStdio.layer` is core's `McpServer.layerStdio` with every log line sent to
157
182
  stderr, and with stdin read through a guard. Core's own stdio decoder throws on
158
183
  a line that is not JSON without ever trimming it from its buffer, so one bad
159
- line wedges the server: every later request goes unanswered while stdin EOF
160
- still exits 0. The guard frames stdin exactly as core does — one streaming
184
+ line wedges the server: every later request goes unanswered, yet the process
185
+ still ends at stdin EOF exactly as a healthy session does. The guard frames stdin exactly as core does — one streaming
161
186
  UTF-8 decoder, a byte-order mark stripped only at the start of the stream,
162
187
  lines split on `\n` — and answers each line core would choke on itself, on
163
188
  stdout:
@@ -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. Isolate each server with its
192
- own `ManagedRuntime`, `Effect.provide(layer, { local: true })`, or its own
193
- process.
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