@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/McpHarness.js +10 -8
- package/McpProbe.js +1 -1
- package/McpProcess.js +2 -2
- package/McpStdio.js +27 -18
- package/McpTestFailure.js +2 -2
- package/McpToolAudit.js +5 -3
- package/McpToolkit.js +120 -76
- package/README.md +75 -31
- package/ToolFailure.js +1 -1
- package/ToolInputSchema.js +28 -14
- package/index.d.ts +109 -78
- package/internal/StdinFrames.js +61 -23
- package/package.json +4 -4
- package/testing.d.ts +12 -10
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @effected/mcp
|
|
2
2
|
|
|
3
|
-
The boundary layer of an `effect/
|
|
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
|
[](https://www.npmjs.com/package/@effected/mcp)
|
|
6
6
|
[](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/
|
|
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/
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
`id
|
|
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"`)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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/
|
|
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
|
|
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/
|
|
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/
|
|
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
|
|
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/
|
|
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`. `
|
|
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/
|
|
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/
|
|
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 `
|
|
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.
|
package/ToolInputSchema.js
CHANGED
|
@@ -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)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
|
134
|
-
|
|
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/
|
|
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`.
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* units: it is answered once, as soon as it passes
|
|
108
|
-
* of it is discarded up to its newline
|
|
109
|
-
*
|
|
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
|
|
114
|
-
* `
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
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
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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).
|
|
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
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
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
|
|
326
|
-
*
|
|
327
|
-
*
|
|
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
|
|
331
|
-
* `additionalProperties: false` on every object node and decoded
|
|
332
|
-
* `onExcessProperty: "error"
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
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
|
|
338
|
-
*
|
|
339
|
-
*
|
|
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:
|
|
390
|
-
* `
|
|
391
|
-
*
|
|
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}:
|
|
413
|
-
*
|
|
414
|
-
*
|
|
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
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
* handler called directly (a test
|
|
424
|
-
* core's `McpServer.toolkit`)
|
|
425
|
-
* Under {@link McpToolkit.layer}
|
|
426
|
-
* call before the handler runs
|
|
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>,
|
|
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
|
|
431
|
-
*
|
|
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 `
|
|
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.
|