@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 +24 -8
- package/McpProbe.js +16 -3
- package/McpProcess.js +19 -2
- package/McpStdio.js +30 -22
- package/McpTestFailure.js +4 -2
- package/McpToolAudit.js +5 -3
- package/McpToolkit.js +125 -76
- package/README.md +79 -35
- package/ToolFailure.js +4 -1
- package/ToolInputSchema.js +28 -14
- package/ToolRefusal.js +5 -2
- package/index.d.ts +125 -84
- package/internal/LaunchFailed.js +15 -3
- package/internal/StdinFrames.js +61 -23
- package/package.json +4 -4
- package/testing.d.ts +61 -13
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/
|
|
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.
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* `-
|
|
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/
|
|
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
|
|
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/
|
|
6
|
-
import { ChildProcessSpawner } from "effect/
|
|
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/
|
|
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
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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`.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* units: it is answered once, as soon as it passes
|
|
77
|
-
* of it is discarded up to its newline
|
|
78
|
-
*
|
|
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
|
|
83
|
-
* `
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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`
|
|
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/
|
|
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
|
-
|
|
21
|
-
|
|
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}:
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
71
|
+
const decodeUnionPayload = (name, union, inputSchema) => {
|
|
30
72
|
const decode = Schema.decodeUnknownEffect(union);
|
|
31
|
-
return (payload) =>
|
|
73
|
+
return (payload) => {
|
|
32
74
|
const raw = payload ?? {};
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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,
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
91
|
+
const guardRegistration = (registration) => {
|
|
48
92
|
const union = Context.get(registration.annotations, UnionParameters);
|
|
49
|
-
if (union
|
|
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.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
|
73
|
-
* `additionalProperties: false` on every object node and decoded
|
|
74
|
-
* `onExcessProperty: "error"
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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:
|
|
132
|
-
* `
|
|
133
|
-
*
|
|
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}:
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* handler called directly (a test
|
|
174
|
-
* core's `McpServer.toolkit`)
|
|
175
|
-
* Under {@link McpToolkit.layer}
|
|
176
|
-
* call before the handler runs
|
|
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,
|
|
179
|
-
const check = decodeUnionPayload(tool.name, tool.unionParameters, tool.jsonSchema
|
|
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
|
|
184
|
-
*
|
|
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
|
|
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));
|