@effected/mcp 0.2.0 → 0.4.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/McpHarness.js CHANGED
@@ -2,7 +2,7 @@ import { ToolFailure } from "./ToolFailure.js";
2
2
  import { DEFAULT_CLIENT_INFO, frame, initializeParams, isJsonRpcMessage, isResponse, isStateless, parseFrame, requestKey } from "./internal/wire.js";
3
3
  import { McpTestFailure } from "./McpTestFailure.js";
4
4
  import { Console, Deferred, Effect, Exit, Layer, Queue, Sink, Stdio, Stream } from "effect";
5
- import { McpProtocol } from "effect/unstable/ai";
5
+ import { McpProtocol } from "effect/ai";
6
6
 
7
7
  //#region src/McpHarness.ts
8
8
  /**
@@ -38,11 +38,27 @@ import { McpProtocol } from "effect/unstable/ai";
38
38
  * anything that went through `console.log`, which in a real server is
39
39
  * the wire. Assert it empty.
40
40
  * - On a stateful revision (the default `2025-11-25`), send `initialize`
41
- * first. Every other request before it fails with `NotInitialized` and
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.
41
+ * first. `ping` is the exception: the server answers it `{}` before
42
+ * initialization, so it is written. Every other request before
43
+ * `initialize` fails with `NotInitialized` and is never written: the
44
+ * server would only answer an opaque refusal,
45
+ * `-32602 Invalid request metadata` when a stateless adapter is listed
46
+ * first (as in `McpStdio.protocols`), or `-32603 Internal error` when
47
+ * only stateful revisions are served.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * import { McpHarness } from "@effected/mcp/testing";
52
+ * import { Effect } from "effect";
53
+ *
54
+ * // `ServerLayer` is the server's layer, built without its own `Stdio`.
55
+ * const program = Effect.gen(function* () {
56
+ * const harness = yield* McpHarness.make(ServerLayer);
57
+ * yield* harness.initialize;
58
+ * const tools = yield* harness.listTools;
59
+ * return tools.map((tool) => tool.name);
60
+ * }).pipe(Effect.scoped);
61
+ * ```
46
62
  *
47
63
  * @public
48
64
  */
@@ -71,7 +87,7 @@ var McpHarness = class McpHarness {
71
87
  discover;
72
88
  /** Send a request and wait for its response. */
73
89
  request;
74
- /** Send a request now; wait for its response later. Fails with `NotInitialized`, sending nothing, on a stateful revision before `initialize`. */
90
+ /** Send a request now; wait for its response later. Fails with `NotInitialized`, sending nothing, on a stateful revision before `initialize` (`ping` excepted). */
75
91
  startRequest;
76
92
  /** Send a notification. */
77
93
  notify;
@@ -193,7 +209,7 @@ var McpHarness = class McpHarness {
193
209
  return Effect.asVoid(Queue.offer(stdin, encoder.encode(`${line}\n`)));
194
210
  });
195
211
  const startRequest = (method, params) => Effect.gen(function* () {
196
- if (stateful && !initializeSent && method !== "initialize") return yield* new McpTestFailure({
212
+ if (stateful && !initializeSent && method !== "initialize" && method !== "ping") return yield* new McpTestFailure({
197
213
  reason: "NotInitialized",
198
214
  message: `${method} was not sent: call initialize first on stateful protocol ${protocol.protocolVersion}`
199
215
  });
package/McpProbe.js CHANGED
@@ -3,7 +3,7 @@ import { DEFAULT_CLIENT_INFO, STDERR_HINT, frame, initializeParams, isJsonRpcMes
3
3
  import { McpTestFailure } from "./McpTestFailure.js";
4
4
  import { McpProcess } from "./McpProcess.js";
5
5
  import { Effect } from "effect";
6
- import { McpProtocol } from "effect/unstable/ai";
6
+ import { McpProtocol } from "effect/ai";
7
7
 
8
8
  //#region src/McpProbe.ts
9
9
  /**
@@ -12,8 +12,7 @@ import { McpProtocol } from "effect/unstable/ai";
12
12
  *
13
13
  * @remarks
14
14
  * Stdin stays open until the id-1 response has arrived. Closing it right
15
- * after writing, as every hand-rolled smoke test did, makes an Effect server
16
- * drop the in-flight response and exit 0, so a slow boot reads as a pass with
15
+ * after writing makes an Effect server drop the in-flight response and exit 0, so a slow boot reads as a pass with
17
16
  * no response. The caller asserts `response.error === undefined`, `stderr` is
18
17
  * empty and `exitCode` is 0; this is the MCP half of a packed-install proof.
19
18
  * Checking stderr and the exit code alone passes a server that answers
@@ -27,6 +26,20 @@ import { McpProtocol } from "effect/unstable/ai";
27
26
  * - A child that ignores stdin EOF never exits, and the probe waits for it.
28
27
  * Wrap the probe in `Effect.timeout`.
29
28
  *
29
+ * @example
30
+ * ```ts
31
+ * import { McpProbe } from "@effected/mcp/testing";
32
+ * import { Effect } from "effect";
33
+ * import { ChildProcess } from "effect/process";
34
+ *
35
+ * const program = Effect.gen(function* () {
36
+ * const probe = yield* McpProbe.initialize(ChildProcess.make(process.execPath, ["./bin/server.js"]));
37
+ * // A clean boot: no JSON-RPC error, empty stderr, exit code 0.
38
+ * return probe.response.error === undefined && probe.stderr === "" && probe.exitCode === 0;
39
+ * });
40
+ * // Requires a `ChildProcessSpawner` from the platform layer.
41
+ * ```
42
+ *
30
43
  * @public
31
44
  */
32
45
  var McpProbe = class {
package/McpProcess.js CHANGED
@@ -2,8 +2,8 @@ import { ToolFailure } from "./ToolFailure.js";
2
2
  import { DEFAULT_CLIENT_INFO, STDERR_HINT, frame, initializeParams, isJsonRpcMessage, isResponse, isStateless, parseFrame } from "./internal/wire.js";
3
3
  import { McpTestFailure } from "./McpTestFailure.js";
4
4
  import { Deferred, Effect, Queue, Ref, Stream } from "effect";
5
- import { McpProtocol } from "effect/unstable/ai";
6
- import { ChildProcessSpawner } from "effect/unstable/process";
5
+ import { McpProtocol } from "effect/ai";
6
+ import { ChildProcessSpawner } from "effect/process";
7
7
 
8
8
  //#region src/McpProcess.ts
9
9
  /**
@@ -25,6 +25,23 @@ import { ChildProcessSpawner } from "effect/unstable/process";
25
25
  * - A request in flight when stdin closes is dropped by an Effect server:
26
26
  * read its response first.
27
27
  *
28
+ * @example
29
+ * ```ts
30
+ * import { McpProcess } from "@effected/mcp/testing";
31
+ * import { Effect } from "effect";
32
+ * import { ChildProcess } from "effect/process";
33
+ *
34
+ * const program = Effect.gen(function* () {
35
+ * const child = yield* McpProcess.spawn(ChildProcess.make(process.execPath, ["./bin/server.js"]));
36
+ * yield* child.handshake();
37
+ * yield* child.send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
38
+ * const { response } = yield* child.readUntilResponse(2);
39
+ * yield* child.closeStdin;
40
+ * return response;
41
+ * }).pipe(Effect.scoped);
42
+ * // Requires a `ChildProcessSpawner` from the platform layer.
43
+ * ```
44
+ *
28
45
  * @public
29
46
  */
30
47
  var McpProcess = class McpProcess {
package/McpStdio.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { LaunchFailed } from "./internal/LaunchFailed.js";
2
2
  import { makeGuardedStdio } from "./internal/StdinFrames.js";
3
3
  import { Cause, Effect, Exit, Layer, References, Runtime } from "effect";
4
- import { McpProtocol, McpServer } from "effect/unstable/ai";
4
+ import { McpProtocol, McpServer } from "effect/ai";
5
5
 
6
6
  //#region src/McpStdio.ts
7
7
  /**
@@ -39,10 +39,9 @@ var McpStdio = class McpStdio {
39
39
  * first, then the two newest stateful ones.
40
40
  *
41
41
  * @remarks
42
- * Real clients open with either handshake: Claude Code 2.1.281 was
43
- * measured opening a stdio server with the stateless `server/discover`,
44
- * while most other shipping clients still send `initialize`. The order
45
- * is load-bearing:
42
+ * Real clients open with either handshake: some (Claude Code, for one)
43
+ * open a stdio server with the stateless `server/discover`, while others
44
+ * still send `initialize`. The order is load-bearing:
46
45
  *
47
46
  * - Stateless first: a request with no session and no `_meta` falls to
48
47
  * `protocols[0]`.
@@ -68,26 +67,35 @@ var McpStdio = class McpStdio {
68
67
  *
69
68
  * The server reads stdin through a guard that frames it exactly as core's
70
69
  * decoder does: one streaming UTF-8 decoder, a byte-order mark stripped
71
- * only at the start of the stream, lines split on `\n`. A line that is
72
- * not JSON is answered on stdout with a JSON-RPC parse error, code
73
- * `-32700` and `id: null`, and never reaches core's decoder, which would
74
- * otherwise throw on that line again for every later chunk and stop
75
- * answering. So is a line longer than core's cap of 16 Mi UTF-16 code
76
- * units: it is answered once, as soon as it passes the cap, and the rest
77
- * of it is discarded up to its newline. A line of JSON whitespace (space,
78
- * tab, carriage return) is ignored.
70
+ * only at the start of the stream, lines split on `\n`. Core itself skips
71
+ * a line it cannot use and keeps serving, but sends no reply, where
72
+ * JSON-RPC 2.0 requires one; the guard supplies it. A line that is not
73
+ * JSON is answered on stdout with a parse error, code `-32700` and
74
+ * `id: null`, and never reaches core. So is a line longer than core's cap
75
+ * of 16 Mi UTF-16 code units: it is answered once, as soon as it passes
76
+ * the cap, and the rest of it is discarded up to its newline (core would
77
+ * log it on stderr and drop it). A line of JSON whitespace (space, tab,
78
+ * carriage return) is ignored.
79
79
  *
80
80
  * A line that is JSON but no JSON-RPC message core can handle is answered
81
81
  * with an Invalid Request, code `-32600` and `id: null`, and never reaches
82
- * core: a value that is neither an object nor an array (core throws on
83
- * `null`, dropping every other frame in its chunk, and ignores a number,
84
- * string or boolean without a reply), an object whose `method` is not a
85
- * string and whose `id` is absent or `null` (core throws on it too), and
86
- * an object with neither `method` nor `id`, which is neither a request nor
87
- * a response. Everything else goes to core: an array, which core answers
88
- * `-32600` itself because it serves no batches; an object with an `id`
89
- * and no `method`, which is a response and gets no reply; and a request
90
- * with an `id`, which core answers even when its `method` is malformed.
82
+ * core: a value that is neither an object nor an array, an object whose
83
+ * `method` is not a string and whose `id` is absent or `null`, and an
84
+ * object with neither `method` nor `id`, which is neither a request nor a
85
+ * response.
86
+ *
87
+ * A line whose `method` starts with `@effect/rpc/` never reaches core,
88
+ * whose JSON-RPC decoder would read one without an `id` as its own RPC
89
+ * control message: `@effect/rpc/Eof` alone silently stops the server
90
+ * (Effect-TS/effect#8499, open upstream). Such a request is answered
91
+ * `-32601` Method not found, echoing its `id`; such a notification is
92
+ * dropped. The guard stops doing this once core no longer treats a
93
+ * client's `@effect/rpc/` frame as a control message.
94
+ *
95
+ * Everything else goes to core: an array, which core answers `-32600`
96
+ * itself because it serves no batches; an object with an `id` and no
97
+ * `method`, which is a response and gets no reply; and a request with an
98
+ * `id`, which core answers even when its `method` is malformed.
91
99
  *
92
100
  * Give each server a fresh layer memo map. Core's stdio protocol layer
93
101
  * is a shared constant, so a second `McpStdio.layer` server whose build
package/McpTestFailure.js CHANGED
@@ -18,8 +18,8 @@ import { Schema } from "effect";
18
18
  * not `catchTag`.
19
19
  * - `ServerStopped` (`McpHarness`): the in-process server stopped, usually
20
20
  * because stdin closed while the request was in flight.
21
- * - `NotInitialized` (`McpHarness`): a request other than `initialize` was
22
- * made on a stateful revision before `initialize` was sent. The request
21
+ * - `NotInitialized` (`McpHarness`): a request other than `initialize` or
22
+ * `ping` was made on a stateful revision before `initialize` was sent. The request
23
23
  * is never written; the server would only have refused it opaquely:
24
24
  * `-32602 Invalid request metadata` when a stateless adapter is listed
25
25
  * first, `-32603 Internal error` when only stateful revisions are served.
@@ -30,6 +30,7 @@ import { Schema } from "effect";
30
30
  * @public
31
31
  */
32
32
  var McpTestFailure = class extends Schema.TaggedError()("McpTestFailure", {
33
+ /** Which failure occurred; see the list above for which client raises which. */
33
34
  reason: Schema.Literals([
34
35
  "StreamEnded",
35
36
  "ServerStopped",
@@ -37,6 +38,7 @@ var McpTestFailure = class extends Schema.TaggedError()("McpTestFailure", {
37
38
  "NotInitialized",
38
39
  "ErrorResponse"
39
40
  ]),
41
+ /** A human-readable description of what went wrong. */
40
42
  message: Schema.String
41
43
  }) {};
42
44
 
package/McpToolAudit.js CHANGED
@@ -24,10 +24,12 @@ const objectNodes = (schema) => {
24
24
  return acc;
25
25
  }, Object.create(null));
26
26
  const closed = merged.some((part) => part.additionalProperties === false);
27
+ const droppedKeyCheck = merged.some((part) => isNode(part.additionalProperties) && isNode(part.propertyNames));
27
28
  if (isNode(properties) || merged.some((part) => part.type === "object")) out.push({
28
29
  path,
29
30
  properties,
30
- closed
31
+ closed,
32
+ droppedKeyCheck
31
33
  });
32
34
  if (isNode(properties)) for (const [key, child] of Object.entries(properties)) visit(child, join(path, key));
33
35
  visit(node.items, join(path, "items"));
@@ -81,9 +83,9 @@ var McpToolAudit = class {
81
83
  };
82
84
  if (seen.has(tool.name)) report("duplicate tool name");
83
85
  seen.add(tool.name);
84
- if (policy.input !== "any") for (const { path, properties, closed } of objectNodes(tool.inputSchema)) {
86
+ if (policy.input !== "any") for (const { path, properties, closed, droppedKeyCheck } of objectNodes(tool.inputSchema)) {
85
87
  const where = path === "" ? "the root" : path;
86
- if (policy.input === "closed" && !closed) report(`input schema is open at ${where}`);
88
+ if (policy.input === "closed" && !closed) report(`input schema is open at ${where}${droppedKeyCheck ? " (a Record whose key check was not served; a key pattern is served closed, as patternProperties, only when its RegExp has the u flag, e.g. Schema.isPattern(/^x-/u))" : ""}`);
87
89
  if (policy.input === "open" && isNode(properties) && closed) report(`input schema is closed at ${where}`);
88
90
  }
89
91
  const title = tool.title ?? tool.annotations?.title;
package/McpToolkit.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { ToolFailure } from "./ToolFailure.js";
1
2
  import { ToolInputSchema } from "./ToolInputSchema.js";
2
3
  import { Context, Effect, Layer, Schema } from "effect";
3
- import { McpSchema, McpServer, Tool, Toolkit } from "effect/unstable/ai";
4
+ import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
4
5
 
5
6
  //#region src/McpToolkit.ts
6
7
  /** The union a {@link McpToolkit.unionTool} decodes with; read by the `McpToolkit.layer` decorator. */
@@ -17,79 +18,120 @@ const unionInputJsonSchema = (parameters) => {
17
18
  });
18
19
  };
19
20
  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);
21
+ const MAX_ACCEPTED_LINES = 20;
22
+ /**
23
+ * `path` as core renders an issue path, `["a"][0]["b"]`: a segment indexing
24
+ * an array in `payload` is bare, any other is a JSON string. Truncated, since
25
+ * the segments are the caller's keys.
26
+ */
27
+ const issuePath = (payload, path) => {
28
+ let node = payload;
29
+ let out = "";
30
+ for (const segment of path) {
31
+ out += Array.isArray(node) ? `[${segment}]` : `[${JSON.stringify(segment)}]`;
32
+ node = typeof node === "object" && node !== null ? node[segment] : void 0;
33
+ }
34
+ return ToolFailure.truncate(out);
35
+ };
36
+ /**
37
+ * One line per level of `payload` that carries a key the served
38
+ * `inputSchema` does not accept: `Accepted params at <path>: a, b.`, with
39
+ * `keys matching <pattern>` for a level that also accepts keys by
40
+ * `patternProperties`, and `This tool accepts no params.` for a
41
+ * zero-parameter root. At most 20 lines; empty when no level has an
42
+ * unknown key.
43
+ */
44
+ const acceptedParamsLines = (payload, inputSchema) => ToolInputSchema.unknownKeys(payload, inputSchema).slice(0, MAX_ACCEPTED_LINES).map((level) => {
45
+ const patterns = level.acceptedPatterns ?? [];
46
+ if (level.path.length === 0 && level.accepted.length === 0 && patterns.length === 0) return "This tool accepts no params.";
47
+ const parts = [...level.accepted.length > 0 ? [level.accepted.join(", ")] : [], ...patterns.length > 0 ? [`keys matching ${patterns.join(", ")}`] : []];
48
+ return `Accepted params at ${level.path.length === 0 ? "the root" : issuePath(payload, level.path)}: ${parts.length > 0 ? parts.join("; ") : "(none)"}.`;
49
+ });
50
+ /**
51
+ * `error` with {@link acceptedParamsLines} appended to its message, one per
52
+ * line after core's own report; `error` itself when there is nothing to add.
53
+ * Core stays the decoder: this only annotates the report it produced.
54
+ */
55
+ const withAcceptedParams = (error, payload, inputSchema) => {
56
+ const lines = acceptedParamsLines(payload, inputSchema);
57
+ return lines.length === 0 ? error : new McpSchema.InvalidParams({
58
+ message: [error.message, ...lines].join("\n"),
59
+ ...error.data !== void 0 ? { data: error.data } : {}
60
+ });
61
+ };
22
62
  /**
23
63
  * 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.
64
+ * decorator and {@link McpToolkit.unionHandler}: the union's strict decode
65
+ * with the options core decodes a strict `Tool.make` tool with
66
+ * (`onExcessProperty: "error"`, `errors: "all"`), so every excess, missing and
67
+ * invalid field of the matched member is reported together, worded as core
68
+ * words a `Tool.make` failure, with the accepted params appended as the layer
69
+ * appends them to core's own report.
28
70
  */
29
- const decodeUnionPayload = (name, union, inputSchema, format) => {
71
+ const decodeUnionPayload = (name, union, inputSchema) => {
30
72
  const decode = Schema.decodeUnknownEffect(union);
31
- return (payload) => Effect.suspend(() => {
73
+ return (payload) => {
32
74
  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
- });
75
+ return decode(raw, {
76
+ onExcessProperty: "error",
77
+ errors: "all"
78
+ }).pipe(Effect.mapError((error) => withAcceptedParams(invalidParameters(name, error.message), raw, inputSchema)));
79
+ };
37
80
  };
38
81
  /**
39
82
  * 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.
83
+ * a union tool gets its payload decoded first, with the wrapped handler built
84
+ * only after the decode passes, so a rejected payload never reaches it.
85
+ * Anything else is decoded by core as before, and core's parameter failure,
86
+ * the only `InvalidParams` its `handle` fails with, gets the accepted params
87
+ * appended.
44
88
  *
45
89
  * @internal
46
90
  */
47
- const guardRegistration = (registration, format) => {
91
+ const guardRegistration = (registration) => {
48
92
  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 ? {
93
+ if (union === void 0) return {
57
94
  ...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;
95
+ handle: (payload) => registration.handle(payload).pipe(Effect.catchTag("InvalidParams", (error) => Effect.fail(withAcceptedParams(error, payload ?? {}, registration.tool.inputSchema))))
96
+ };
97
+ const check = decodeUnionPayload(registration.tool.name, union, registration.tool.inputSchema);
98
+ return {
99
+ ...registration,
100
+ handle: (payload) => check(payload).pipe(Effect.flatMap(() => registration.handle(payload)))
101
+ };
63
102
  };
64
103
  const DEFAULT_STRICT = "all";
65
104
  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)));
66
105
  /**
67
- * Register a toolkit exactly as core's `McpServer.toolkit` does, except that a
68
- * strict tool's unknown arguments are all named, at every depth, in one
69
- * response.
106
+ * Register a toolkit exactly as core's `McpServer.toolkit` does, except that
107
+ * every tool is strict by default and a {@link McpToolkit.unionTool} gets its
108
+ * union decoded.
70
109
  *
71
110
  * @remarks
72
- * Rejection itself is core's: a tool annotated `Tool.Strict` is served with
73
- * `additionalProperties: false` on every object node and decoded with
74
- * `onExcessProperty: "error"`. But core reports only the FIRST excess key
75
- * (`Expected no excess property at ["extra"]`), so an agent fixes one typo
76
- * per round trip and never learns about a nested one until the next call.
77
- * This layer is the better report, not the rejecter: it runs core's
111
+ * Rejection and reporting are core's: a tool annotated `Tool.Strict` is
112
+ * served with `additionalProperties: false` on every object node and decoded
113
+ * with `onExcessProperty: "error"` and `errors: "all"`, so one
114
+ * `InvalidParams` names every excess key at every depth together with every
115
+ * missing or invalid field. The layer appends to that report, never in place
116
+ * of it: for every object level of the payload that carries a key the served
117
+ * input schema does not accept, one line naming what that level does accept,
118
+ * `Accepted params at the root: query, filter.` or
119
+ * `Accepted params at ["filter"]: kind, tag.` (the path as core writes it),
120
+ * with `keys matching <pattern>` for keys a `patternProperties` level
121
+ * accepts, and `This tool accepts no params.` for a zero-parameter tool. An
122
+ * agent can fix the call from the reply alone. A failure with no unknown key
123
+ * (a missing or mistyped field only) is core's report unchanged. The lines
124
+ * are appended to core's `InvalidParams` as it leaves the tool's registered
125
+ * handler, the one failure that handler has for bad parameters.
126
+ *
127
+ * On top of that is the policy: under
128
+ * `strict: "all"` (the default) it annotates every tool that carries no
129
+ * `Tool.Strict` annotation of its own as strict, skipping `Tool.dynamic`
130
+ * tools (core dies at registration on a strict dynamic tool). It runs core's
78
131
  * `registerToolkit` unchanged under a registration-scoped `McpServer` whose
79
- * `addTool` puts a `ToolInputSchema.unknownKeys` pre-check in front of each
80
- * handler. The pre-check walks the input schema the tool actually serves
81
- * (after core's own top-level `$ref` inlining), follows `$ref`, `allOf` and
82
- * discriminated `oneOf`/`anyOf` members, and fails with one
83
- * `McpSchema.InvalidParams` naming every unknown key path plus the accepted
84
- * params, before core decodes. Core's strict decode stays behind it as the
85
- * backstop. The pre-check is gated on the same predicate core uses to choose
86
- * strict decoding — the registered tool's `Tool.Strict` annotation is
87
- * `true` — so a lenient tool (explicit `Tool.Strict` false, a
88
- * `Tool.dynamic` tool, or any unannotated tool under `"annotated"`) is
89
- * never rejected here, even when its raw JSON Schema carries
90
- * `additionalProperties: false`. The pre-check runs under `Effect.suspend`,
91
- * so a throwing `unknownKeyMessage` dies inside the call's effect. No core
92
- * internals are patched, so this survives an rc bump without re-diffing.
132
+ * `addTool` puts the union decode in front of a union tool's handler and
133
+ * passes every other registration through untouched. It patches no core
134
+ * internals: it uses only `McpServer.registerToolkit` and `addTool`.
93
135
  *
94
136
  * Registration goes through core's module-level `McpServer.McpServer.layer`,
95
137
  * shared by reference with `McpStdio.layer`'s own copy — provide both into
@@ -128,9 +170,11 @@ var McpToolkit = class {
128
170
  * {@link McpToolkit.unionHandler}, which receives the decoded member.
129
171
  *
130
172
  * 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`
173
+ * treatment as a strict `Tool.make` tool: a strict decode with
174
+ * `errors: "all"` before the handler runs, reporting every excess, missing
175
+ * and invalid field of the matched member in one `InvalidParams`, with the
176
+ * same `Accepted params at <path>: …` lines appended, so bad
177
+ * arguments answer JSON-RPC `-32602` on `2025-06-18` and an `isError`
134
178
  * result on the later revisions, exactly as a `Tool.make` decode failure
135
179
  * does. Registered through core's `McpServer.toolkit` it still decodes
136
180
  * strictly, in the handler, but the `InvalidParams` is then a declared
@@ -141,6 +185,11 @@ var McpToolkit = class {
141
185
  *
142
186
  * @example
143
187
  * ```ts
188
+ * import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
189
+ * import { Schema } from "effect";
190
+ * import { Tool } from "effect/ai";
191
+ *
192
+ * // `AddNote`, `ListNotes`, `Added`, `Listed` are object schemas; `Kit` is a Toolkit that includes `Note`.
144
193
  * const Note = McpToolkit.unionTool("note", {
145
194
  * parameters: Schema.Union([AddNote, ListNotes]),
146
195
  * success: ToolOutputSchema.objectRooted(Schema.Union([Added, Listed])),
@@ -159,36 +208,36 @@ var McpToolkit = class {
159
208
  return Object.assign(tool, { unionParameters: options.parameters });
160
209
  };
161
210
  /**
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`.
211
+ * The handler for a {@link McpToolkit.unionTool}: decodes the raw payload
212
+ * exactly as {@link McpToolkit.layer} does, and passes the decoded member
213
+ * to `handler`.
165
214
  *
166
215
  * @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.
216
+ * A payload that does not decode strictly fails with one
217
+ * `McpSchema.InvalidParams`, worded as core words a `Tool.make` decode
218
+ * failure, naming every excess key at every depth together with every
219
+ * missing or invalid field of the matched member, then one
220
+ * `Accepted params at <path>: …` line per level with an unknown key. It
221
+ * is the same
222
+ * implementation the layer runs, so a handler called directly (a test
223
+ * helper, or a toolkit registered through core's `McpServer.toolkit`)
224
+ * reports what a client of the layer sees. Under {@link McpToolkit.layer}
225
+ * the layer has already rejected a bad call before the handler runs.
226
+ * `options.unknownKeyMessage` is deprecated and ignored.
177
227
  */
178
- static unionHandler = (tool, handler, options = {}) => {
179
- const check = decodeUnionPayload(tool.name, tool.unionParameters, tool.jsonSchema, options.unknownKeyMessage ?? defaultUnknownKeyMessage);
228
+ static unionHandler = (tool, handler, _options = {}) => {
229
+ const check = decodeUnionPayload(tool.name, tool.unionParameters, tool.jsonSchema);
180
230
  return (payload) => check(payload).pipe(Effect.flatMap(handler));
181
231
  };
182
232
  /**
183
- * `McpServer.toolkit`'s registration layer, strict by default and naming
184
- * every unknown argument in one `InvalidParams`.
233
+ * `McpServer.toolkit`'s registration layer, strict by default.
234
+ * `options.unknownKeyMessage` is deprecated and ignored.
185
235
  */
186
236
  static layer = (toolkit, options = {}) => Layer.effectDiscard(Effect.gen(function* () {
187
237
  const registry = yield* McpServer.McpServer;
188
- const format = options.unknownKeyMessage ?? defaultUnknownKeyMessage;
189
238
  const decorated = McpServer.McpServer.of({
190
239
  ...registry,
191
- addTool: (registration) => registry.addTool(guardRegistration(registration, format))
240
+ addTool: (registration) => registry.addTool(guardRegistration(registration))
192
241
  });
193
242
  yield* McpServer.registerToolkit(strictened(toolkit, options.strict ?? DEFAULT_STRICT)).pipe(Effect.provideService(McpServer.McpServer, decorated));
194
243
  })).pipe(Layer.provide(McpServer.McpServer.layer));