@effected/mcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @effected/mcp
2
2
 
3
- The boundary layer of an `effect/unstable/ai` MCP server: stdio wiring that keeps stdout the JSON-RPC wire, tool-failure shaping, and strict-input walkers, plus a `./testing` subpath for driving a built server from a test.
3
+ The boundary layer of an `effect/ai` MCP server: stdio wiring that keeps stdout the JSON-RPC wire, tool-failure shaping, and strict-input walkers, plus a `./testing` subpath for driving a built server from a test.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@effected%2Fmcp?label=npm&color=cb3837)](https://www.npmjs.com/package/@effected/mcp)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
@@ -94,7 +94,7 @@ For a refusal with no fields of its own, `ToolRefusal` is that shape ready-made:
94
94
  ```ts
95
95
  import { ToolFailure, ToolRefusal } from "@effected/mcp";
96
96
  import { Effect, Schema } from "effect";
97
- import { Tool } from "effect/unstable/ai";
97
+ import { Tool } from "effect/ai";
98
98
 
99
99
  const GetRun = Tool.make("get_run", {
100
100
  parameters: Schema.Struct({ id: Schema.String }),
@@ -120,7 +120,7 @@ A stdio server's `main.ts` is one line, plus the layer that wires it:
120
120
  import { McpStdio, McpToolkit, ToolFailure } from "@effected/mcp";
121
121
  import { NodeRuntime, NodeStdio } from "@effect/platform-node";
122
122
  import { Effect, Layer, Schema } from "effect";
123
- import { Tool, Toolkit } from "effect/unstable/ai";
123
+ import { Tool, Toolkit } from "effect/ai";
124
124
 
125
125
  class NotFound extends Schema.TaggedError<NotFound>()("NotFound", {
126
126
  ...ToolFailure.fields,
@@ -172,20 +172,20 @@ its own queue-backed `Stdio`.
172
172
 
173
173
  `McpToolkit.layer` re-annotates every tool without its own `Tool.Strict`
174
174
  annotation to strict by default, so `get_thing({ id: "known", extra: 1 })`
175
- is rejected with one `Unrecognized parameter(s): extra. Accepted params:
176
- id.` before the handler ever runs — see [Strict input](#strict-input)
177
- below.
175
+ is rejected by core's strict decode, which names every excess, missing and
176
+ invalid field in one `InvalidParams`, before the handler ever runs, and the
177
+ layer adds `Accepted params at the root: id.` — see
178
+ [Strict input](#strict-input) below.
178
179
 
179
180
  ## The stdio boundary
180
181
 
181
182
  `McpStdio.layer` is core's `McpServer.layerStdio` with every log line sent to
182
- stderr, and with stdin read through a guard. Core's own stdio decoder throws on
183
- a line that is not JSON without ever trimming it from its buffer, so one bad
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
186
- UTF-8 decoder, a byte-order mark stripped only at the start of the stream,
187
- lines split on `\n` — and answers each line core would choke on itself, on
188
- stdout:
183
+ stderr, and with stdin read through a guard. Core's own stdio decoder skips a
184
+ line it cannot use and keeps serving, but it sends no reply, where JSON-RPC 2.0
185
+ requires one: the client is left waiting on a request it will never hear about.
186
+ The guard frames stdin exactly as core does — one streaming UTF-8 decoder, a
187
+ byte-order mark stripped only at the start of the stream, lines split on `\n`
188
+ — and answers each such line itself, on stdout:
189
189
 
190
190
  - a line that is not JSON gets a JSON-RPC parse error,
191
191
  `{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}`,
@@ -196,11 +196,16 @@ stdout:
196
196
  - a line that is JSON but no JSON-RPC message gets an Invalid Request,
197
197
  `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid Request"}}`,
198
198
  and the server keeps serving. That covers a value that is neither an object
199
- nor an array (core throws on a bare `null`, dropping every other frame that
200
- arrived in the same chunk, and ignores a number, string or boolean without a
201
- reply), an object whose `method` is not a string and whose `id` is absent or
202
- `null` (core throws on that too), and an object with neither `method` nor
203
- `id`, which is neither a request nor a response;
199
+ nor an array, an object whose `method` is not a string and whose `id` is
200
+ absent or `null`, and an object with neither `method` nor `id`, which is
201
+ neither a request nor a response;
202
+ - a line whose `method` starts with `@effect/rpc/` never reaches core, which
203
+ would read one without an `id` as its own RPC control message:
204
+ `{"jsonrpc":"2.0","method":"@effect/rpc/Eof"}` alone silently stops an
205
+ unguarded server ([Effect-TS/effect#8499](https://github.com/Effect-TS/effect/issues/8499),
206
+ open upstream). Such a notification is
207
+ dropped; such a request is answered `-32601` Method not found, echoing its
208
+ `id`;
204
209
  - a line of JSON whitespace (space, tab, carriage return) is ignored.
205
210
 
206
211
  Every other line goes to core. That includes an array, which core answers with
@@ -275,35 +280,73 @@ scopes:
275
280
  - **`McpToolkit.layer(toolkit, options?)`** — the registration-scoped
276
281
  decorator, and the recommended default: every tool without its own
277
282
  `Tool.Strict` annotation is served and decoded strict
278
- (`options.strict` defaults to `"all"`), and a rejected call names every
279
- unknown key at every depth in one response, not just the first. An
280
- explicit `Tool.Strict` annotation always wins, and a `Tool.dynamic` tool
281
- is never re-annotated. Pass `{ strict: "annotated" }` to leave
283
+ (`options.strict` defaults to `"all"`). Rejection and the report are
284
+ core's: a strict tool is decoded with `errors: "all"`, so one
285
+ `InvalidParams` names every excess key at every depth together with every
286
+ missing or invalid field. The layer appends to that report, never in
287
+ place of it: one `Accepted params at <path>: …` line for each object level
288
+ of the payload that carries an unknown key, so an agent can fix the call
289
+ from the reply alone (example below). An explicit `Tool.Strict`
290
+ annotation always wins, and a `Tool.dynamic` tool is never re-annotated.
291
+ `options.unknownKeyMessage` is deprecated and ignored. Pass `{ strict: "annotated" }` to leave
282
292
  unannotated tools lenient, or annotate an individual tool
283
293
  `Tool.Strict` false to opt it out under the default.
284
294
 
285
295
  ```ts
286
296
  import { McpToolkit } from "@effected/mcp";
287
297
  import { Layer } from "effect";
288
- import type { Tool, Toolkit } from "effect/unstable/ai";
298
+ import type { Tool, Toolkit } from "effect/ai";
289
299
 
290
300
  // The toolkit and its handlers, as built in "Putting it together".
291
301
  declare const MyTools: Toolkit.Toolkit<Record<string, Tool.Any>>;
292
302
  declare const MyHandlers: Layer.Layer<never>;
293
303
 
294
- // Default: every tool is strict, and every rejection names every unknown key.
304
+ // Default: every tool is strict; core's rejection names every bad field at once.
295
305
  const ToolsLayer = McpToolkit.layer(MyTools).pipe(Layer.provide(MyHandlers));
296
306
 
297
307
  // Only tools explicitly annotated Tool.Strict are decoded strict.
298
308
  const LenientByDefault = McpToolkit.layer(MyTools, { strict: "annotated" }).pipe(Layer.provide(MyHandlers));
299
309
  ```
300
310
 
311
+ A strict `search` tool taking `query` and an optional `filter` union, called
312
+ with an unknown key at the root and one inside the selected `filter` member,
313
+ and without its required `query`:
314
+
315
+ ```text
316
+ search({ extra: 1, filter: { kind: "tag", tag: "t", text: "sneaky" } })
317
+ => Invalid parameters for tool 'search': Expected no excess property
318
+ at ["extra"]
319
+ Missing key
320
+ at ["query"]
321
+ Expected no excess property
322
+ at ["filter"]["text"]
323
+ Accepted params at the root: query, filter.
324
+ Accepted params at ["filter"]: kind, tag.
325
+ ```
326
+
327
+ The first six lines are core's. The layer adds the last two: one per object
328
+ level that carries an unknown key, naming its accepted keys, plus
329
+ `keys matching <pattern>` where the level accepts keys by `patternProperties`.
330
+ A zero-parameter tool gets `This tool accepts no params.` instead. A failure
331
+ with no unknown key, only missing or mistyped fields, is core's report
332
+ unchanged.
333
+
334
+ A pattern-keyed `Schema.Record` is served closed, with `patternProperties`,
335
+ only when its key pattern's RegExp carries the Unicode flag:
336
+ `Schema.isPattern(/^x-/u)`. Without `u`, Effect treats the exported pattern as
337
+ approximate and serves the record open (`additionalProperties` set to the value
338
+ schema), so the served schema, and `ToolInputSchema.unknownKeys` walking it,
339
+ accept any key; the strict decode still enforces the pattern. `McpToolAudit`'s `input: "closed"` check reports such
340
+ a record open and suggests the flag. Where a closed level accepts keys by
341
+ pattern, `formatUnknownKeys` names the patterns (`Accepted keys matching: ^x-.`)
342
+ rather than claiming it accepts none.
343
+
301
344
  The dynamic-tool recipe, with the handler reporting every unknown key:
302
345
 
303
346
  ```ts
304
347
  import { McpToolkit, ToolInputSchema } from "@effected/mcp";
305
348
  import { Effect, Layer, Schema } from "effect";
306
- import { Tool, Toolkit } from "effect/unstable/ai";
349
+ import { Tool, Toolkit } from "effect/ai";
307
350
 
308
351
  // A top-level union, written as raw JSON Schema and rewritten to an object root.
309
352
  const EditInput = ToolInputSchema.objectRooted({
@@ -363,7 +406,7 @@ A tool whose parameters are a `Schema.Union` of objects cannot be a `Tool.make`
363
406
  ```ts
364
407
  import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
365
408
  import { Effect, Schema } from "effect";
366
- import { Tool, Toolkit } from "effect/unstable/ai";
409
+ import { Tool, Toolkit } from "effect/ai";
367
410
 
368
411
  const AddNote = Schema.Struct({ action: Schema.Literal("add"), text: Schema.String });
369
412
  const ListNotes = Schema.Struct({ action: Schema.Literal("list"), limit: Schema.optionalKey(Schema.Number) });
@@ -391,7 +434,7 @@ const Handlers = Kit.toLayer({
391
434
 
392
435
  `annotate` and `addDependency` keep the union on the tool, so a chained `.annotate(Tool.Title, …)` still hands `unionHandler` the decoded type.
393
436
 
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.
437
+ Registered through `McpToolkit.layer`, a union tool is decoded like a strict `Tool.make` tool, before the handler runs: strictly, with `errors: "all"`, so one `InvalidParams` in core's `Tool.make` wording names every excess key at every depth together with every missing or invalid field of the matched member, followed by the same `Accepted params at <path>: …` lines. 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 decode itself, so the message is identical. Under `McpServer.toolkit` that `InvalidParams` is a declared failure, an `isError` result on every revision. `unionHandler`'s third argument, `{ unknownKeyMessage }`, is deprecated and ignored. Never annotate a union tool `Tool.Strict` true: core dies at registration on a strict tool with a raw JSON Schema.
395
438
 
396
439
  ## Union success schemas
397
440
 
@@ -417,7 +460,7 @@ test machinery into a server's runtime import graph.
417
460
  ```ts
418
461
  import { McpHarness } from "@effected/mcp/testing";
419
462
  import { Effect, type Layer, type Stdio } from "effect";
420
- import type { McpServer } from "effect/unstable/ai";
463
+ import type { McpServer } from "effect/ai";
421
464
 
422
465
  // The server layer from "Putting it together" — still requiring Stdio.
423
466
  declare const ServerLayer: Layer.Layer<McpServer.McpServer, never, Stdio.Stdio>;
@@ -434,7 +477,8 @@ const test = Effect.gen(function* () {
434
477
  On a stateful revision (the default is `2025-11-25`), `initialize` comes
435
478
  first: any other request sent before it fails fast with `McpTestFailure`
436
479
  reason `NotInitialized` instead of the server's opaque `Invalid request
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.
480
+ metadata`. `ping` is the exception: the server answers it `{}` before
481
+ initialization, so the harness sends it. `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.
438
482
 
439
483
  `McpHarness.make` runs the server in-process over queue-backed `Stdio`, so
440
484
  a test sees the exact served schemas and wire results a real client would,
@@ -477,7 +521,7 @@ import { join } from "node:path";
477
521
  import { NodeServices } from "@effect/platform-node";
478
522
  import { McpProcess } from "@effected/mcp/testing";
479
523
  import { Effect } from "effect";
480
- import { ChildProcess } from "effect/unstable/process";
524
+ import { ChildProcess } from "effect/process";
481
525
 
482
526
  // A minimal server, written to disk so it can be spawned as a real process.
483
527
  const serverFile = join(import.meta.dirname, "mcp-guard-demo-server.mjs");
@@ -487,7 +531,7 @@ writeFileSync(
487
531
  import { McpStdio, McpToolkit } from "@effected/mcp";
488
532
  import { NodeRuntime, NodeStdio } from "@effect/platform-node";
489
533
  import { Effect, Layer } from "effect";
490
- import { Tool, Toolkit } from "effect/unstable/ai";
534
+ import { Tool, Toolkit } from "effect/ai";
491
535
 
492
536
  const Ping = Tool.make("ping", { description: "Liveness check.", parameters: Tool.EmptyParams });
493
537
  const Tools = Toolkit.make(Ping);
package/ToolFailure.js CHANGED
@@ -9,7 +9,7 @@ const isHighSurrogate = (code) => code >= 55296 && code <= 56319;
9
9
  * @remarks
10
10
  * Core sends a declared failure that is an `Error` instance — every
11
11
  * `Schema.TaggedError` is — as `isError: true` with `error.message` as the only
12
- * text and no `structuredContent` (effect `unstable/ai/McpServer.ts`,
12
+ * text and no `structuredContent` (effect `ai/McpServer.ts`,
13
13
  * `declaredFailureResult`). So the remediation must be folded into `message`
14
14
  * when the error is constructed: whatever is not in the message never reaches
15
15
  * the agent.
@@ -77,11 +77,15 @@ const collect = (payload, root) => {
77
77
  if (match === void 0) extra.push(key);
78
78
  else patterned.push([key, match[1]]);
79
79
  }
80
- if (extra.length > 0 && isClosed) out.push({
81
- path,
82
- unknown: extra,
83
- accepted
84
- });
80
+ if (extra.length > 0 && isClosed) {
81
+ const acceptedPatterns = patterns.map(([pattern]) => pattern);
82
+ out.push({
83
+ path,
84
+ unknown: extra,
85
+ accepted,
86
+ ...acceptedPatterns.length > 0 ? { acceptedPatterns } : {}
87
+ });
88
+ }
85
89
  for (const key of accepted) if (Object.hasOwn(value, key)) walk(value[key], properties[key], [...path, key], depth + 1);
86
90
  for (const [key, schema] of patterned) walk(value[key], schema, [...path, key], depth + 1);
87
91
  if (additional !== void 0) for (const key of extra) walk(value[key], additional, [...path, key], depth + 1);
@@ -94,10 +98,12 @@ const collect = (payload, root) => {
94
98
  * depth, a message naming them all, and an object-rooted form of a union.
95
99
  *
96
100
  * @remarks
97
- * Core's `Tool.Strict` rejects unknown keys but reports only the first
98
- * (effect `unstable/ai/McpServer.ts` decodes without `errors: "all"`), so an
99
- * agent fixes one typo per round trip. These walkers run over the schema core
100
- * actually serves, so what they accept matches what the tool advertises:
101
+ * For a `Tool.make` tool, core's strict decode already names every unknown
102
+ * key, with every missing or invalid field, in one response. Core never
103
+ * validates a `Tool.dynamic` tool's raw JSON Schema, so these walkers are
104
+ * for that case: a handler runs them against the schema it registered.
105
+ * They run over the schema as served, so what they accept matches what the
106
+ * tool advertises:
101
107
  *
102
108
  * - Only `additionalProperties: false` closes a node, as in JSON Schema; core
103
109
  * emits it on every object node of a strict tool and `true` otherwise.
@@ -108,6 +114,9 @@ const collect = (payload, root) => {
108
114
  * - `items`, `prefixItems`, `$ref` into `$defs` (JSON Pointer escapes
109
115
  * honoured), and `patternProperties` are followed; a key matched by a
110
116
  * pattern is accepted and walked with the first matching pattern's schema.
117
+ * Effect serves a pattern-keyed `Schema.Record` with `patternProperties`
118
+ * only when the key pattern's RegExp has the `u` flag; without it the
119
+ * record is served open, and every key is accepted.
111
120
  * - The payload is untrusted: the walk stops descending at depth 256 rather
112
121
  * than overflowing the stack, so a deeper unknown key goes unreported and
113
122
  * is left for decoding. Every hop counts toward that cap: each property or
@@ -121,17 +130,22 @@ var ToolInputSchema = class {
121
130
  /** Every level of `payload` that carries a key `schema` does not accept. */
122
131
  static unknownKeys = (payload, schema) => collect(payload, schema);
123
132
  /**
124
- * `Unrecognized parameter(s): a, b.c. Accepted params: x, y.` per level. Each
125
- * echoed key is truncated, and at most 20 keys and 20 levels are named; a
126
- * trailing `(and N more levels).` counts the levels left out.
133
+ * `Unrecognized parameter(s): a, b.c. Accepted params: x, y.` per level,
134
+ * followed by `Accepted keys matching: ^x-.` when the level also accepts
135
+ * keys by pattern. A level that accepts keys only by pattern names its
136
+ * patterns alone, never `Accepted params: (none).` Each echoed key and
137
+ * pattern is truncated, and at most 20 keys, 20 patterns and 20 levels are
138
+ * named; a trailing `(and N more levels).` counts the levels left out.
127
139
  */
128
140
  static formatUnknownKeys = (levels, options = {}) => {
129
141
  const limit = options.echoLimit ?? ToolFailure.ECHO_LIMIT;
130
142
  const sentences = levels.slice(0, MAX_ECHOED).map((level) => {
131
143
  const shown = level.unknown.slice(0, MAX_ECHOED).map((key) => ToolFailure.truncate([...level.path, key].join("."), limit));
132
144
  const more = level.unknown.length > MAX_ECHOED ? ` (and ${level.unknown.length - MAX_ECHOED} more)` : "";
133
- const accepted = level.accepted.length === 0 ? "(none)" : level.accepted.join(", ");
134
- return `Unrecognized parameter(s): ${shown.join(", ")}${more}. Accepted params: ${accepted}.`;
145
+ const patterns = (level.acceptedPatterns ?? []).slice(0, MAX_ECHOED).map((pattern) => ToolFailure.truncate(pattern, limit));
146
+ const params = level.accepted.length > 0 ? ` Accepted params: ${level.accepted.join(", ")}.` : patterns.length === 0 ? " Accepted params: (none)." : "";
147
+ const matching = patterns.length > 0 ? ` Accepted keys matching: ${patterns.join(", ")}.` : "";
148
+ return `Unrecognized parameter(s): ${shown.join(", ")}${more}.${params}${matching}`;
135
149
  });
136
150
  const hidden = levels.length > MAX_ECHOED ? ` (and ${levels.length - MAX_ECHOED} more levels).` : "";
137
151
  return `${sentences.join(" ")}${hidden}`;
package/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Array, Context, Effect, JsonSchema, Layer, Runtime, Schema, Stdio } from "effect";
2
- import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/unstable/ai";
2
+ import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
3
3
  import { Remediation } from "@effected/engine";
4
4
  //#region src/McpStdio.d.ts
5
5
  /**
@@ -99,26 +99,35 @@ export declare class McpStdio {
99
99
  *
100
100
  * The server reads stdin through a guard that frames it exactly as core's
101
101
  * decoder does: one streaming UTF-8 decoder, a byte-order mark stripped
102
- * only at the start of the stream, lines split on `\n`. A line that is
103
- * not JSON is answered on stdout with a JSON-RPC parse error, code
104
- * `-32700` and `id: null`, and never reaches core's decoder, which would
105
- * otherwise throw on that line again for every later chunk and stop
106
- * answering. So is a line longer than core's cap of 16 Mi UTF-16 code
107
- * units: it is answered once, as soon as it passes the cap, and the rest
108
- * of it is discarded up to its newline. A line of JSON whitespace (space,
109
- * tab, carriage return) is ignored.
102
+ * only at the start of the stream, lines split on `\n`. Core itself skips
103
+ * a line it cannot use and keeps serving, but sends no reply, where
104
+ * JSON-RPC 2.0 requires one; the guard supplies it. A line that is not
105
+ * JSON is answered on stdout with a parse error, code `-32700` and
106
+ * `id: null`, and never reaches core. So is a line longer than core's cap
107
+ * of 16 Mi UTF-16 code units: it is answered once, as soon as it passes
108
+ * the cap, and the rest of it is discarded up to its newline (core would
109
+ * log it on stderr and drop it). A line of JSON whitespace (space, tab,
110
+ * carriage return) is ignored.
110
111
  *
111
112
  * A line that is JSON but no JSON-RPC message core can handle is answered
112
113
  * with an Invalid Request, code `-32600` and `id: null`, and never reaches
113
- * core: a value that is neither an object nor an array (core throws on
114
- * `null`, dropping every other frame in its chunk, and ignores a number,
115
- * string or boolean without a reply), an object whose `method` is not a
116
- * string and whose `id` is absent or `null` (core throws on it too), and
117
- * an object with neither `method` nor `id`, which is neither a request nor
118
- * a response. Everything else goes to core: an array, which core answers
119
- * `-32600` itself because it serves no batches; an object with an `id`
120
- * and no `method`, which is a response and gets no reply; and a request
121
- * with an `id`, which core answers even when its `method` is malformed.
114
+ * core: a value that is neither an object nor an array, an object whose
115
+ * `method` is not a string and whose `id` is absent or `null`, and an
116
+ * object with neither `method` nor `id`, which is neither a request nor a
117
+ * response.
118
+ *
119
+ * A line whose `method` starts with `@effect/rpc/` never reaches core,
120
+ * whose JSON-RPC decoder would read one without an `id` as its own RPC
121
+ * control message: `@effect/rpc/Eof` alone silently stops the server
122
+ * (Effect-TS/effect#8499, open upstream). Such a request is answered
123
+ * `-32601` Method not found, echoing its `id`; such a notification is
124
+ * dropped. The guard stops doing this once core no longer treats a
125
+ * client's `@effect/rpc/` frame as a control message.
126
+ *
127
+ * Everything else goes to core: an array, which core answers `-32600`
128
+ * itself because it serves no batches; an object with an `id` and no
129
+ * `method`, which is a response and gets no reply; and a request with an
130
+ * `id`, which core answers even when its `method` is malformed.
122
131
  *
123
132
  * Give each server a fresh layer memo map. Core's stdio protocol layer
124
133
  * is a shared constant, so a second `McpStdio.layer` server whose build
@@ -184,6 +193,11 @@ interface UnknownKeysLevel {
184
193
  readonly unknown: ReadonlyArray<string>;
185
194
  /** Keys this level declares, in schema order. */
186
195
  readonly accepted: ReadonlyArray<string>;
196
+ /**
197
+ * The `patternProperties` patterns this level also accepts keys by, in
198
+ * schema order. Present only when the level has at least one.
199
+ */
200
+ readonly acceptedPatterns?: ReadonlyArray<string>;
187
201
  }
188
202
  /**
189
203
  * Options for {@link ToolInputSchema.formatUnknownKeys}.
@@ -199,10 +213,12 @@ interface FormatUnknownKeysOptions {
199
213
  * depth, a message naming them all, and an object-rooted form of a union.
200
214
  *
201
215
  * @remarks
202
- * Core's `Tool.Strict` rejects unknown keys but reports only the first
203
- * (effect `unstable/ai/McpServer.ts` decodes without `errors: "all"`), so an
204
- * agent fixes one typo per round trip. These walkers run over the schema core
205
- * actually serves, so what they accept matches what the tool advertises:
216
+ * For a `Tool.make` tool, core's strict decode already names every unknown
217
+ * key, with every missing or invalid field, in one response. Core never
218
+ * validates a `Tool.dynamic` tool's raw JSON Schema, so these walkers are
219
+ * for that case: a handler runs them against the schema it registered.
220
+ * They run over the schema as served, so what they accept matches what the
221
+ * tool advertises:
206
222
  *
207
223
  * - Only `additionalProperties: false` closes a node, as in JSON Schema; core
208
224
  * emits it on every object node of a strict tool and `true` otherwise.
@@ -213,6 +229,9 @@ interface FormatUnknownKeysOptions {
213
229
  * - `items`, `prefixItems`, `$ref` into `$defs` (JSON Pointer escapes
214
230
  * honoured), and `patternProperties` are followed; a key matched by a
215
231
  * pattern is accepted and walked with the first matching pattern's schema.
232
+ * Effect serves a pattern-keyed `Schema.Record` with `patternProperties`
233
+ * only when the key pattern's RegExp has the `u` flag; without it the
234
+ * record is served open, and every key is accepted.
216
235
  * - The payload is untrusted: the walk stops descending at depth 256 rather
217
236
  * than overflowing the stack, so a deeper unknown key goes unreported and
218
237
  * is left for decoding. Every hop counts toward that cap: each property or
@@ -226,9 +245,12 @@ export declare class ToolInputSchema {
226
245
  /** Every level of `payload` that carries a key `schema` does not accept. */
227
246
  static readonly unknownKeys: (payload: unknown, schema: JsonSchema.JsonSchema) => ReadonlyArray<UnknownKeysLevel>;
228
247
  /**
229
- * `Unrecognized parameter(s): a, b.c. Accepted params: x, y.` per level. Each
230
- * echoed key is truncated, and at most 20 keys and 20 levels are named; a
231
- * trailing `(and N more levels).` counts the levels left out.
248
+ * `Unrecognized parameter(s): a, b.c. Accepted params: x, y.` per level,
249
+ * followed by `Accepted keys matching: ^x-.` when the level also accepts
250
+ * keys by pattern. A level that accepts keys only by pattern names its
251
+ * patterns alone, never `Accepted params: (none).` Each echoed key and
252
+ * pattern is truncated, and at most 20 keys, 20 patterns and 20 levels are
253
+ * named; a trailing `(and N more levels).` counts the levels left out.
232
254
  */
233
255
  static readonly formatUnknownKeys: (levels: ReadonlyArray<UnknownKeysLevel>, options?: FormatUnknownKeysOptions) => string;
234
256
  /**
@@ -253,16 +275,16 @@ interface McpToolkitOptions {
253
275
  * annotation is served and decoded strict. `"annotated"`: only tools
254
276
  * annotated `Tool.Strict` true are. In both modes an explicit annotation,
255
277
  * true or false, always wins, and a `Tool.dynamic` tool is never
256
- * re-annotated (core dies at registration on a strict dynamic tool). Only
257
- * a tool that ends up strict gets the unknown-key pre-check; a lenient
258
- * one is passed through untouched even if its raw schema is closed.
278
+ * re-annotated (core dies at registration on a strict dynamic tool).
259
279
  */
260
280
  readonly strict?: "all" | "annotated" | undefined;
261
281
  /**
262
- * Replaces the default `ToolInputSchema.formatUnknownKeys` rendering. It
263
- * runs per rejected call inside the handler's effect, so a throw becomes
264
- * a defect of that call alone — the client receives a JSON-RPC internal
265
- * error (-32603) — never a crash of the server.
282
+ * Ignored. Core's strict decode reports every excess key at every depth,
283
+ * together with every missing or invalid field, in one `InvalidParams`,
284
+ * and the layer appends a fixed `Accepted params at <path>: …` line per
285
+ * level that carries an unknown key; this option changes neither.
286
+ *
287
+ * @deprecated A no-op, scheduled for removal in a later minor.
266
288
  */
267
289
  readonly unknownKeyMessage?: ((levels: ReadonlyArray<UnknownKeysLevel>) => string) | undefined;
268
290
  }
@@ -315,39 +337,45 @@ interface UnionToolOptions<P extends Schema.Decoder<unknown>, S extends Schema.C
315
337
  */
316
338
  interface UnionHandlerOptions {
317
339
  /**
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.
340
+ * Ignored. The union decode reports every excess, missing and invalid
341
+ * field in one `InvalidParams`, followed by the same fixed
342
+ * `Accepted params at <path>: …` lines {@link McpToolkit.layer} appends;
343
+ * this option changes neither.
344
+ *
345
+ * @deprecated A no-op, scheduled for removal in a later minor.
321
346
  */
322
347
  readonly unknownKeyMessage?: ((levels: ReadonlyArray<UnknownKeysLevel>) => string) | undefined;
323
348
  }
324
349
  /**
325
- * Register a toolkit exactly as core's `McpServer.toolkit` does, except that a
326
- * strict tool's unknown arguments are all named, at every depth, in one
327
- * response.
350
+ * Register a toolkit exactly as core's `McpServer.toolkit` does, except that
351
+ * every tool is strict by default and a {@link McpToolkit.unionTool} gets its
352
+ * union decoded.
328
353
  *
329
354
  * @remarks
330
- * Rejection itself is core's: a tool annotated `Tool.Strict` is served with
331
- * `additionalProperties: false` on every object node and decoded with
332
- * `onExcessProperty: "error"`. But core reports only the FIRST excess key
333
- * (`Expected no excess property at ["extra"]`), so an agent fixes one typo
334
- * per round trip and never learns about a nested one until the next call.
335
- * This layer is the better report, not the rejecter: it runs core's
355
+ * Rejection and reporting are core's: a tool annotated `Tool.Strict` is
356
+ * served with `additionalProperties: false` on every object node and decoded
357
+ * with `onExcessProperty: "error"` and `errors: "all"`, so one
358
+ * `InvalidParams` names every excess key at every depth together with every
359
+ * missing or invalid field. The layer appends to that report, never in place
360
+ * of it: for every object level of the payload that carries a key the served
361
+ * input schema does not accept, one line naming what that level does accept,
362
+ * `Accepted params at the root: query, filter.` or
363
+ * `Accepted params at ["filter"]: kind, tag.` (the path as core writes it),
364
+ * with `keys matching <pattern>` for keys a `patternProperties` level
365
+ * accepts, and `This tool accepts no params.` for a zero-parameter tool. An
366
+ * agent can fix the call from the reply alone. A failure with no unknown key
367
+ * (a missing or mistyped field only) is core's report unchanged. The lines
368
+ * are appended to core's `InvalidParams` as it leaves the tool's registered
369
+ * handler, the one failure that handler has for bad parameters.
370
+ *
371
+ * On top of that is the policy: under
372
+ * `strict: "all"` (the default) it annotates every tool that carries no
373
+ * `Tool.Strict` annotation of its own as strict, skipping `Tool.dynamic`
374
+ * tools (core dies at registration on a strict dynamic tool). It runs core's
336
375
  * `registerToolkit` unchanged under a registration-scoped `McpServer` whose
337
- * `addTool` puts a `ToolInputSchema.unknownKeys` pre-check in front of each
338
- * handler. The pre-check walks the input schema the tool actually serves
339
- * (after core's own top-level `$ref` inlining), follows `$ref`, `allOf` and
340
- * discriminated `oneOf`/`anyOf` members, and fails with one
341
- * `McpSchema.InvalidParams` naming every unknown key path plus the accepted
342
- * params, before core decodes. Core's strict decode stays behind it as the
343
- * backstop. The pre-check is gated on the same predicate core uses to choose
344
- * strict decoding — the registered tool's `Tool.Strict` annotation is
345
- * `true` — so a lenient tool (explicit `Tool.Strict` false, a
346
- * `Tool.dynamic` tool, or any unannotated tool under `"annotated"`) is
347
- * never rejected here, even when its raw JSON Schema carries
348
- * `additionalProperties: false`. The pre-check runs under `Effect.suspend`,
349
- * so a throwing `unknownKeyMessage` dies inside the call's effect. No core
350
- * internals are patched, so this survives an rc bump without re-diffing.
376
+ * `addTool` puts the union decode in front of a union tool's handler and
377
+ * passes every other registration through untouched. It patches no core
378
+ * internals: it uses only `McpServer.registerToolkit` and `addTool`.
351
379
  *
352
380
  * Registration goes through core's module-level `McpServer.McpServer.layer`,
353
381
  * shared by reference with `McpStdio.layer`'s own copy — provide both into
@@ -386,9 +414,11 @@ export declare class McpToolkit {
386
414
  * {@link McpToolkit.unionHandler}, which receives the decoded member.
387
415
  *
388
416
  * 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`
417
+ * treatment as a strict `Tool.make` tool: a strict decode with
418
+ * `errors: "all"` before the handler runs, reporting every excess, missing
419
+ * and invalid field of the matched member in one `InvalidParams`, with the
420
+ * same `Accepted params at <path>: …` lines appended, so bad
421
+ * arguments answer JSON-RPC `-32602` on `2025-06-18` and an `isError`
392
422
  * result on the later revisions, exactly as a `Tool.make` decode failure
393
423
  * does. Registered through core's `McpServer.toolkit` it still decodes
394
424
  * strictly, in the handler, but the `InvalidParams` is then a declared
@@ -409,26 +439,27 @@ export declare class McpToolkit {
409
439
  */
410
440
  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
441
  /**
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`.
442
+ * The handler for a {@link McpToolkit.unionTool}: decodes the raw payload
443
+ * exactly as {@link McpToolkit.layer} does, and passes the decoded member
444
+ * to `handler`.
415
445
  *
416
446
  * @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.
447
+ * A payload that does not decode strictly fails with one
448
+ * `McpSchema.InvalidParams`, worded as core words a `Tool.make` decode
449
+ * failure, naming every excess key at every depth together with every
450
+ * missing or invalid field of the matched member, then one
451
+ * `Accepted params at <path>: …` line per level with an unknown key. It
452
+ * is the same
453
+ * implementation the layer runs, so a handler called directly (a test
454
+ * helper, or a toolkit registered through core's `McpServer.toolkit`)
455
+ * reports what a client of the layer sees. Under {@link McpToolkit.layer}
456
+ * the layer has already rejected a bad call before the handler runs.
457
+ * `options.unknownKeyMessage` is deprecated and ignored.
427
458
  */
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>);
459
+ 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>);
429
460
  /**
430
- * `McpServer.toolkit`'s registration layer, strict by default and naming
431
- * every unknown argument in one `InvalidParams`.
461
+ * `McpServer.toolkit`'s registration layer, strict by default.
462
+ * `options.unknownKeyMessage` is deprecated and ignored.
432
463
  */
433
464
  static readonly layer: <Tools extends Record<string, Tool.Any>>(toolkit: Toolkit.Toolkit<Tools>, options?: McpToolkitOptions) => Layer.Layer<never, never, Tool.HandlersFor<Tools> | Exclude<Tool.HandlerServices<Tools>, McpSchema.McpRequestContext>>;
434
465
  }
@@ -440,7 +471,7 @@ export declare class McpToolkit {
440
471
  * @remarks
441
472
  * Core sends a declared failure that is an `Error` instance — every
442
473
  * `Schema.TaggedError` is — as `isError: true` with `error.message` as the only
443
- * text and no `structuredContent` (effect `unstable/ai/McpServer.ts`,
474
+ * text and no `structuredContent` (effect `ai/McpServer.ts`,
444
475
  * `declaredFailureResult`). So the remediation must be folded into `message`
445
476
  * when the error is constructed: whatever is not in the message never reaches
446
477
  * the agent.