@effected/mcp 0.1.1 → 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/McpGuard.js +121 -0
- package/McpHarness.js +46 -10
- package/McpProbe.js +1 -1
- package/McpProcess.js +2 -2
- package/McpStdio.js +37 -21
- package/McpTestFailure.js +2 -2
- package/McpToolAudit.js +10 -5
- package/McpToolkit.js +199 -36
- package/README.md +195 -31
- package/ToolFailure.js +8 -2
- package/ToolInputSchema.js +28 -14
- package/ToolOutputSchema.js +66 -0
- package/ToolRefusal.js +42 -0
- package/guard.d.ts +157 -0
- package/guard.js +3 -0
- package/index.d.ts +331 -64
- package/index.js +3 -1
- package/internal/StdinFrames.js +61 -23
- package/internal/guardLaunch.js +14 -0
- package/package.json +9 -4
- package/testing.d.ts +44 -12
package/McpToolkit.js
CHANGED
|
@@ -1,42 +1,144 @@
|
|
|
1
|
+
import { ToolFailure } from "./ToolFailure.js";
|
|
1
2
|
import { ToolInputSchema } from "./ToolInputSchema.js";
|
|
2
|
-
import { Context, Effect, Layer } from "effect";
|
|
3
|
-
import { McpSchema, McpServer, Tool, Toolkit } from "effect/
|
|
3
|
+
import { Context, Effect, Layer, Schema } from "effect";
|
|
4
|
+
import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
|
|
4
5
|
|
|
5
6
|
//#region src/McpToolkit.ts
|
|
7
|
+
/** The union a {@link McpToolkit.unionTool} decodes with; read by the `McpToolkit.layer` decorator. */
|
|
8
|
+
const UnionParameters = Context.Reference("@effected/mcp/UnionParameters", { defaultValue: () => void 0 });
|
|
9
|
+
/**
|
|
10
|
+
* Effect's strict JSON Schema document for `parameters`, definitions attached
|
|
11
|
+
* as `$defs`, then object-rooted: the served input schema of a union tool.
|
|
12
|
+
*/
|
|
13
|
+
const unionInputJsonSchema = (parameters) => {
|
|
14
|
+
const document = Schema.toJsonSchemaDocument(parameters, { onExcessProperty: "error" });
|
|
15
|
+
return ToolInputSchema.objectRooted(Object.keys(document.definitions).length === 0 ? document.schema : {
|
|
16
|
+
...document.schema,
|
|
17
|
+
$defs: document.definitions
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
const invalidParameters = (name, message) => new McpSchema.InvalidParams({ message: `Invalid parameters for tool '${name}': ${message}` });
|
|
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
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* The one decode a union tool's payload gets, shared by the `McpToolkit.layer`
|
|
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.
|
|
70
|
+
*/
|
|
71
|
+
const decodeUnionPayload = (name, union, inputSchema) => {
|
|
72
|
+
const decode = Schema.decodeUnknownEffect(union);
|
|
73
|
+
return (payload) => {
|
|
74
|
+
const raw = payload ?? {};
|
|
75
|
+
return decode(raw, {
|
|
76
|
+
onExcessProperty: "error",
|
|
77
|
+
errors: "all"
|
|
78
|
+
}).pipe(Effect.mapError((error) => withAcceptedParams(invalidParameters(name, error.message), raw, inputSchema)));
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* The registration `McpToolkit.layer` hands core in place of `registration`:
|
|
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.
|
|
88
|
+
*
|
|
89
|
+
* @internal
|
|
90
|
+
*/
|
|
91
|
+
const guardRegistration = (registration) => {
|
|
92
|
+
const union = Context.get(registration.annotations, UnionParameters);
|
|
93
|
+
if (union === void 0) return {
|
|
94
|
+
...registration,
|
|
95
|
+
handle: (payload) => registration.handle(payload).pipe(Effect.catchTag("InvalidParams", (error) => Effect.fail(withAcceptedParams(error, payload ?? {}, registration.tool.inputSchema))))
|
|
96
|
+
};
|
|
97
|
+
const check = decodeUnionPayload(registration.tool.name, union, registration.tool.inputSchema);
|
|
98
|
+
return {
|
|
99
|
+
...registration,
|
|
100
|
+
handle: (payload) => check(payload).pipe(Effect.flatMap(() => registration.handle(payload)))
|
|
101
|
+
};
|
|
102
|
+
};
|
|
6
103
|
const DEFAULT_STRICT = "all";
|
|
7
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)));
|
|
8
105
|
/**
|
|
9
|
-
* Register a toolkit exactly as core's `McpServer.toolkit` does, except that
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
12
109
|
*
|
|
13
110
|
* @remarks
|
|
14
|
-
* Rejection
|
|
15
|
-
* `additionalProperties: false` on every object node and decoded
|
|
16
|
-
* `onExcessProperty: "error"
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
20
131
|
* `registerToolkit` unchanged under a registration-scoped `McpServer` whose
|
|
21
|
-
* `addTool` puts
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* discriminated `oneOf`/`anyOf` members, and fails with one
|
|
25
|
-
* `McpSchema.InvalidParams` naming every unknown key path plus the accepted
|
|
26
|
-
* params, before core decodes. Core's strict decode stays behind it as the
|
|
27
|
-
* backstop. The pre-check is gated on the same predicate core uses to choose
|
|
28
|
-
* strict decoding — the registered tool's `Tool.Strict` annotation is
|
|
29
|
-
* `true` — so a lenient tool (explicit `Tool.Strict` false, a
|
|
30
|
-
* `Tool.dynamic` tool, or any unannotated tool under `"annotated"`) is
|
|
31
|
-
* never rejected here, even when its raw JSON Schema carries
|
|
32
|
-
* `additionalProperties: false`. The pre-check runs under `Effect.suspend`,
|
|
33
|
-
* so a throwing `unknownKeyMessage` dies inside the call's effect. No core
|
|
34
|
-
* 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`.
|
|
35
135
|
*
|
|
36
136
|
* Registration goes through core's module-level `McpServer.McpServer.layer`,
|
|
37
137
|
* shared by reference with `McpStdio.layer`'s own copy — provide both into
|
|
38
138
|
* the same graph, as with `McpServer.toolkit`. Never wrap that layer in
|
|
39
139
|
* `Layer.fresh`: tools would register into a second registry nobody serves.
|
|
140
|
+
* To give two servers separate registries, wrap each whole bundle (this
|
|
141
|
+
* layer together with `McpStdio.layer`) in `Layer.fresh` instead.
|
|
40
142
|
*
|
|
41
143
|
* Each call mints a fresh layer; bind the result to a `const` or the
|
|
42
144
|
* registration runs twice.
|
|
@@ -55,25 +157,86 @@ const strictened = (toolkit, mode) => mode === "annotated" ? toolkit : Toolkit.m
|
|
|
55
157
|
var McpToolkit = class {
|
|
56
158
|
constructor() {}
|
|
57
159
|
/**
|
|
58
|
-
* `
|
|
59
|
-
*
|
|
160
|
+
* A tool whose parameters are a `Schema.Union` of objects, which
|
|
161
|
+
* `Tool.make` cannot take: core dies at registration on a non-object
|
|
162
|
+
* `parameters` root.
|
|
163
|
+
*
|
|
164
|
+
* @remarks
|
|
165
|
+
* The tool is a `Tool.dynamic` served with Effect's strict JSON Schema
|
|
166
|
+
* document for `parameters` (`additionalProperties: false` on every
|
|
167
|
+
* object node, definitions as `$defs`), rewritten by
|
|
168
|
+
* `ToolInputSchema.objectRooted` to an object root carrying `type`,
|
|
169
|
+
* `oneOf` and `x-discriminator`. Write its handler with
|
|
170
|
+
* {@link McpToolkit.unionHandler}, which receives the decoded member.
|
|
171
|
+
*
|
|
172
|
+
* Registered through {@link McpToolkit.layer}, a union tool gets the same
|
|
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`
|
|
178
|
+
* result on the later revisions, exactly as a `Tool.make` decode failure
|
|
179
|
+
* does. Registered through core's `McpServer.toolkit` it still decodes
|
|
180
|
+
* strictly, in the handler, but the `InvalidParams` is then a declared
|
|
181
|
+
* failure: an `isError` result on every revision.
|
|
182
|
+
*
|
|
183
|
+
* Never annotate it `Tool.Strict` true: core dies at registration on a
|
|
184
|
+
* strict tool with a raw JSON Schema.
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* ```ts
|
|
188
|
+
* const Note = McpToolkit.unionTool("note", {
|
|
189
|
+
* parameters: Schema.Union([AddNote, ListNotes]),
|
|
190
|
+
* success: ToolOutputSchema.objectRooted(Schema.Union([Added, Listed])),
|
|
191
|
+
* failure: ToolRefusal,
|
|
192
|
+
* }).annotate(Tool.Title, "Note")
|
|
193
|
+
* const handlers = Kit.toLayer({ note: McpToolkit.unionHandler(Note, (params) => handleNote(params)) })
|
|
194
|
+
* ```
|
|
195
|
+
*/
|
|
196
|
+
static unionTool = (name, options) => {
|
|
197
|
+
const tool = Tool.dynamic(name, {
|
|
198
|
+
description: options.description,
|
|
199
|
+
parameters: unionInputJsonSchema(options.parameters),
|
|
200
|
+
success: options.success ?? Schema.Void,
|
|
201
|
+
failure: Schema.Union([options.failure ?? Schema.Never, McpSchema.InvalidParams])
|
|
202
|
+
}).annotate(UnionParameters, options.parameters);
|
|
203
|
+
return Object.assign(tool, { unionParameters: options.parameters });
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* The handler for a {@link McpToolkit.unionTool}: decodes the raw payload
|
|
207
|
+
* exactly as {@link McpToolkit.layer} does, and passes the decoded member
|
|
208
|
+
* to `handler`.
|
|
209
|
+
*
|
|
210
|
+
* @remarks
|
|
211
|
+
* A payload that does not decode strictly fails with one
|
|
212
|
+
* `McpSchema.InvalidParams`, worded as core words a `Tool.make` decode
|
|
213
|
+
* failure, naming every excess key at every depth together with every
|
|
214
|
+
* missing or invalid field of the matched member, then one
|
|
215
|
+
* `Accepted params at <path>: …` line per level with an unknown key. It
|
|
216
|
+
* is the same
|
|
217
|
+
* implementation the layer runs, so a handler called directly (a test
|
|
218
|
+
* helper, or a toolkit registered through core's `McpServer.toolkit`)
|
|
219
|
+
* reports what a client of the layer sees. Under {@link McpToolkit.layer}
|
|
220
|
+
* the layer has already rejected a bad call before the handler runs.
|
|
221
|
+
* `options.unknownKeyMessage` is deprecated and ignored.
|
|
222
|
+
*/
|
|
223
|
+
static unionHandler = (tool, handler, _options = {}) => {
|
|
224
|
+
const check = decodeUnionPayload(tool.name, tool.unionParameters, tool.jsonSchema);
|
|
225
|
+
return (payload) => check(payload).pipe(Effect.flatMap(handler));
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* `McpServer.toolkit`'s registration layer, strict by default.
|
|
229
|
+
* `options.unknownKeyMessage` is deprecated and ignored.
|
|
60
230
|
*/
|
|
61
231
|
static layer = (toolkit, options = {}) => Layer.effectDiscard(Effect.gen(function* () {
|
|
62
232
|
const registry = yield* McpServer.McpServer;
|
|
63
|
-
const format = options.unknownKeyMessage ?? ((levels) => ToolInputSchema.formatUnknownKeys(levels));
|
|
64
233
|
const decorated = McpServer.McpServer.of({
|
|
65
234
|
...registry,
|
|
66
|
-
addTool: (registration) =>
|
|
67
|
-
...registration,
|
|
68
|
-
handle: (payload) => Effect.suspend(() => {
|
|
69
|
-
const levels = ToolInputSchema.unknownKeys(payload ?? {}, registration.tool.inputSchema);
|
|
70
|
-
return levels.length > 0 ? Effect.fail(new McpSchema.InvalidParams({ message: format(levels) })) : registration.handle(payload);
|
|
71
|
-
})
|
|
72
|
-
}) : registry.addTool(registration)
|
|
235
|
+
addTool: (registration) => registry.addTool(guardRegistration(registration))
|
|
73
236
|
});
|
|
74
237
|
yield* McpServer.registerToolkit(strictened(toolkit, options.strict ?? DEFAULT_STRICT)).pipe(Effect.provideService(McpServer.McpServer, decorated));
|
|
75
238
|
})).pipe(Layer.provide(McpServer.McpServer.layer));
|
|
76
239
|
};
|
|
77
240
|
|
|
78
241
|
//#endregion
|
|
79
|
-
export { McpToolkit };
|
|
242
|
+
export { McpToolkit, guardRegistration };
|
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)
|
|
@@ -87,6 +87,31 @@ console.log(ToolFailure.truncate(enginePath, ToolFailure.ENGINE_ECHO_LIMIT) ===
|
|
|
87
87
|
|
|
88
88
|
`ToolFailure` is a static-namespace class with a private constructor — it is never instantiated.
|
|
89
89
|
|
|
90
|
+
### ToolRefusal
|
|
91
|
+
|
|
92
|
+
For a refusal with no fields of its own, `ToolRefusal` is that shape ready-made: a `Schema.TaggedError` on `ToolFailure.fields`, built with `ToolRefusal.refuse(reason, remediation)`, which folds the remediation into `message`. Declare it in the tool's `failure` schema:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { ToolFailure, ToolRefusal } from "@effected/mcp";
|
|
96
|
+
import { Effect, Schema } from "effect";
|
|
97
|
+
import { Tool } from "effect/ai";
|
|
98
|
+
|
|
99
|
+
const GetRun = Tool.make("get_run", {
|
|
100
|
+
parameters: Schema.Struct({ id: Schema.String }),
|
|
101
|
+
success: Schema.Struct({ status: Schema.String }),
|
|
102
|
+
failure: ToolRefusal,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const refusal = ToolRefusal.refuse(`No run "${ToolFailure.truncate("r9")}".`, {
|
|
106
|
+
hint: "List the runs first.",
|
|
107
|
+
suggestedTool: "list_runs",
|
|
108
|
+
});
|
|
109
|
+
console.log(refusal.message);
|
|
110
|
+
// => No run "r9". List the runs first. Try list_runs.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Audit every throw path when you port a tool.** Core scrubs an undeclared failure, and every defect, to `Tool execution failed due to an internal server error.` The message you wrote never reaches the agent, and nothing warns you. Any failure the agent should act on must be declared in the tool's `failure` schema, as a `ToolRefusal` or your own `ToolFailure`-shaped error.
|
|
114
|
+
|
|
90
115
|
## Putting it together
|
|
91
116
|
|
|
92
117
|
A stdio server's `main.ts` is one line, plus the layer that wires it:
|
|
@@ -95,7 +120,7 @@ A stdio server's `main.ts` is one line, plus the layer that wires it:
|
|
|
95
120
|
import { McpStdio, McpToolkit, ToolFailure } from "@effected/mcp";
|
|
96
121
|
import { NodeRuntime, NodeStdio } from "@effect/platform-node";
|
|
97
122
|
import { Effect, Layer, Schema } from "effect";
|
|
98
|
-
import { Tool, Toolkit } from "effect/
|
|
123
|
+
import { Tool, Toolkit } from "effect/ai";
|
|
99
124
|
|
|
100
125
|
class NotFound extends Schema.TaggedError<NotFound>()("NotFound", {
|
|
101
126
|
...ToolFailure.fields,
|
|
@@ -147,20 +172,20 @@ its own queue-backed `Stdio`.
|
|
|
147
172
|
|
|
148
173
|
`McpToolkit.layer` re-annotates every tool without its own `Tool.Strict`
|
|
149
174
|
annotation to strict by default, so `get_thing({ id: "known", extra: 1 })`
|
|
150
|
-
is rejected
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
153
179
|
|
|
154
180
|
## The stdio boundary
|
|
155
181
|
|
|
156
182
|
`McpStdio.layer` is core's `McpServer.layerStdio` with every log line sent to
|
|
157
|
-
stderr, and with stdin read through a guard. Core's own stdio decoder
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
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:
|
|
164
189
|
|
|
165
190
|
- a line that is not JSON gets a JSON-RPC parse error,
|
|
166
191
|
`{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}`,
|
|
@@ -171,11 +196,16 @@ stdout:
|
|
|
171
196
|
- a line that is JSON but no JSON-RPC message gets an Invalid Request,
|
|
172
197
|
`{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid Request"}}`,
|
|
173
198
|
and the server keeps serving. That covers a value that is neither an object
|
|
174
|
-
nor an array
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
`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`;
|
|
179
209
|
- a line of JSON whitespace (space, tab, carriage return) is ignored.
|
|
180
210
|
|
|
181
211
|
Every other line goes to core. That includes an array, which core answers with
|
|
@@ -188,9 +218,51 @@ first one's memo map shares its protocol: only the first server reads stdin,
|
|
|
188
218
|
and the second never answers. Merging both into one graph does that, and so
|
|
189
219
|
does building or providing the second anywhere under the first one's
|
|
190
220
|
`Effect.provide` — a nested `Layer.build` or `Effect.provide` forks the
|
|
191
|
-
ambient memo map rather than starting a new one.
|
|
192
|
-
|
|
193
|
-
|
|
221
|
+
ambient memo map rather than starting a new one. The tool registry is shared
|
|
222
|
+
the same way. Isolate each server by wrapping its whole bundle (its toolkit
|
|
223
|
+
layers together with `McpStdio.layer`) in `Layer.fresh`, or give it its own
|
|
224
|
+
`ManagedRuntime`, `Effect.provide(layer, { local: true })`, or its own
|
|
225
|
+
process. Never put the `Layer.fresh` boundary between a toolkit and
|
|
226
|
+
`McpStdio.layer`: the server would serve an empty registry.
|
|
227
|
+
|
|
228
|
+
## Crash guards
|
|
229
|
+
|
|
230
|
+
A stray `uncaughtException` or `unhandledRejection` in a stdio server either kills it silently or, worse, happens while its modules are still loading, before anything is listening. `@effected/mcp/guard` installs the listeners first. The entrypoint has no static runtime import, so the guards are in place before `effect` or your server graph is evaluated:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
import { McpGuard } from "@effected/mcp/guard";
|
|
234
|
+
|
|
235
|
+
await McpGuard.run({
|
|
236
|
+
label: "my-server",
|
|
237
|
+
host: process,
|
|
238
|
+
policy: { onUncaught: "exitBeforeConnect", onRejection: "log" },
|
|
239
|
+
load: async () => {
|
|
240
|
+
const NodeRuntime = await import("@effect/platform-node/NodeRuntime");
|
|
241
|
+
const { Main } = await import("./server.js");
|
|
242
|
+
return { layer: Main, runMain: NodeRuntime.runMain };
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`McpGuard.run` registers both listeners on `host`, awaits `load()`, and launches the returned layer with `McpStdio.launch` and `McpStdio.teardown` under the returned `runMain`. Keep every import that could throw inside `load`, behind a dynamic `import()`.
|
|
248
|
+
|
|
249
|
+
The policy decides when a stray exception or rejection ends the process. `"exit"` exits 1 whenever it happens. `"exitBeforeConnect"` exits 1 until the server is serving, then logs and keeps serving, because a server that dies mid-session takes every tool away from the client. `"log"` (rejections only) never exits. Both default to `"exit"`.
|
|
250
|
+
|
|
251
|
+
Some behaviour worth knowing:
|
|
252
|
+
|
|
253
|
+
- "Connected" means the whole server layer has built, so stdin is being read. That comes before any client sends `initialize`. `McpStdio.launch`'s `onReady` option is where the guard gets that signal.
|
|
254
|
+
- A `load()` that rejects is reported as `startup failed` and exits 1, whatever the policy. Left to a log-only rejection listener it would let the event loop drain and exit 0 with no server running.
|
|
255
|
+
- An `unhandledRejection` listener switches off Node's default of throwing on one, so `"log"` really does keep the process running.
|
|
256
|
+
- A guard's exit skips Effect's finalizers and the teardown.
|
|
257
|
+
- Carrying on after a crash works because the platform `runMain` holds the process open while the server fiber lives.
|
|
258
|
+
- A layer that fails to build is still reported by `McpStdio.launch`, on stderr.
|
|
259
|
+
|
|
260
|
+
To test the guards themselves, pass `injectCrash: { at, kind }`, with `kind` either `"uncaughtException"` or `"unhandledRejection"`. Wire it to an environment variable only your test sets; unset, it does nothing. The crash is raised through `host.emit` on a `setTimeout(0)` tick, so the guard's own listeners handle it identically under the real `process` and under a test double for `McpGuardHost` (which therefore needs `on`, `emit`, `stderr` and `exit`). If a double's `exit` throws at `"load"`, `run` rejects with what it threw instead of waiting.
|
|
261
|
+
|
|
262
|
+
- `at: "load"` raises it once both listeners are installed and before `load()` is called, and `load()` waits until the guard has handled it. The pre-connect half of the policy applies: `"exitBeforeConnect"` and `"exit"` both exit 1 here, and only `onRejection: "log"` lets the process go on to load and serve. The report uses the guard's own formatter, because no `format` is loaded yet.
|
|
263
|
+
- `at: "connected"` raises it on a timer just after the server is serving, where `"exitBeforeConnect"` logs and keeps serving.
|
|
264
|
+
|
|
265
|
+
Together they drive both halves of `"exitBeforeConnect"` in a real child process.
|
|
194
266
|
|
|
195
267
|
## Strict input
|
|
196
268
|
|
|
@@ -208,35 +280,73 @@ scopes:
|
|
|
208
280
|
- **`McpToolkit.layer(toolkit, options?)`** — the registration-scoped
|
|
209
281
|
decorator, and the recommended default: every tool without its own
|
|
210
282
|
`Tool.Strict` annotation is served and decoded strict
|
|
211
|
-
(`options.strict` defaults to `"all"`)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
215
292
|
unannotated tools lenient, or annotate an individual tool
|
|
216
293
|
`Tool.Strict` false to opt it out under the default.
|
|
217
294
|
|
|
218
295
|
```ts
|
|
219
296
|
import { McpToolkit } from "@effected/mcp";
|
|
220
297
|
import { Layer } from "effect";
|
|
221
|
-
import type { Tool, Toolkit } from "effect/
|
|
298
|
+
import type { Tool, Toolkit } from "effect/ai";
|
|
222
299
|
|
|
223
300
|
// The toolkit and its handlers, as built in "Putting it together".
|
|
224
301
|
declare const MyTools: Toolkit.Toolkit<Record<string, Tool.Any>>;
|
|
225
302
|
declare const MyHandlers: Layer.Layer<never>;
|
|
226
303
|
|
|
227
|
-
// Default: every tool is strict
|
|
304
|
+
// Default: every tool is strict; core's rejection names every bad field at once.
|
|
228
305
|
const ToolsLayer = McpToolkit.layer(MyTools).pipe(Layer.provide(MyHandlers));
|
|
229
306
|
|
|
230
307
|
// Only tools explicitly annotated Tool.Strict are decoded strict.
|
|
231
308
|
const LenientByDefault = McpToolkit.layer(MyTools, { strict: "annotated" }).pipe(Layer.provide(MyHandlers));
|
|
232
309
|
```
|
|
233
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
|
+
|
|
234
344
|
The dynamic-tool recipe, with the handler reporting every unknown key:
|
|
235
345
|
|
|
236
346
|
```ts
|
|
237
347
|
import { McpToolkit, ToolInputSchema } from "@effected/mcp";
|
|
238
348
|
import { Effect, Layer, Schema } from "effect";
|
|
239
|
-
import { Tool, Toolkit } from "effect/
|
|
349
|
+
import { Tool, Toolkit } from "effect/ai";
|
|
240
350
|
|
|
241
351
|
// A top-level union, written as raw JSON Schema and rewritten to an object root.
|
|
242
352
|
const EditInput = ToolInputSchema.objectRooted({
|
|
@@ -289,6 +399,59 @@ edit({ action: "rename", to: "b", extra: 1, options: { force: true, bogus: 2 } }
|
|
|
289
399
|
=> Unrecognized parameter(s): extra. Accepted params: action, to, options. Unrecognized parameter(s): options.bogus. Accepted params: force.
|
|
290
400
|
```
|
|
291
401
|
|
|
402
|
+
## Union-parameter tools
|
|
403
|
+
|
|
404
|
+
A tool whose parameters are a `Schema.Union` of objects cannot be a `Tool.make` tool: core dies at registration on a non-object `parameters` root. `McpToolkit.unionTool` makes it a `Tool.dynamic` served with Effect's strict JSON Schema document for the union (`additionalProperties: false` on every object node), object-rooted by `ToolInputSchema.objectRooted`. `McpToolkit.unionHandler` wraps a handler that receives the decoded member:
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
|
|
408
|
+
import { Effect, Schema } from "effect";
|
|
409
|
+
import { Tool, Toolkit } from "effect/ai";
|
|
410
|
+
|
|
411
|
+
const AddNote = Schema.Struct({ action: Schema.Literal("add"), text: Schema.String });
|
|
412
|
+
const ListNotes = Schema.Struct({ action: Schema.Literal("list"), limit: Schema.optionalKey(Schema.Number) });
|
|
413
|
+
|
|
414
|
+
const Note = McpToolkit.unionTool("note", {
|
|
415
|
+
description: "Add or list notes.",
|
|
416
|
+
parameters: Schema.Union([AddNote, ListNotes]),
|
|
417
|
+
success: ToolOutputSchema.objectRooted(
|
|
418
|
+
Schema.Union([
|
|
419
|
+
Schema.Struct({ kind: Schema.Literal("added") }),
|
|
420
|
+
Schema.Struct({ kind: Schema.Literal("listed"), count: Schema.Number }),
|
|
421
|
+
]),
|
|
422
|
+
),
|
|
423
|
+
failure: ToolRefusal,
|
|
424
|
+
}).annotate(Tool.Title, "Note");
|
|
425
|
+
|
|
426
|
+
const Kit = Toolkit.make(Note);
|
|
427
|
+
const Handlers = Kit.toLayer({
|
|
428
|
+
// `params` is AddNote | ListNotes, already decoded.
|
|
429
|
+
note: McpToolkit.unionHandler(Note, (params) =>
|
|
430
|
+
Effect.succeed(params.action === "add" ? { kind: "added" as const } : { kind: "listed" as const, count: 0 }),
|
|
431
|
+
),
|
|
432
|
+
});
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
`annotate` and `addDependency` keep the union on the tool, so a chained `.annotate(Tool.Title, …)` still hands `unionHandler` the decoded type.
|
|
436
|
+
|
|
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.
|
|
438
|
+
|
|
439
|
+
## Union success schemas
|
|
440
|
+
|
|
441
|
+
MCP requires a tool's `outputSchema` to be rooted in an object, and a top-level `Schema.Union` emits a bare `anyOf`. Core drops such an `outputSchema` from `tools/list` on the stateful revisions, and serves it verbatim on the stateless one, where strict clients reject it. When every member of the union is an object shape, `ToolOutputSchema.objectRooted` adds `type: "object"` beside the `anyOf` through a check that always passes, so decoding and the schema's type stay the same:
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
import { ToolOutputSchema } from "@effected/mcp";
|
|
445
|
+
import { Schema } from "effect";
|
|
446
|
+
|
|
447
|
+
const Found = Schema.Struct({ kind: Schema.Literal("found"), id: Schema.String });
|
|
448
|
+
const Missing = Schema.Struct({ kind: Schema.Literal("missing"), reason: Schema.String });
|
|
449
|
+
|
|
450
|
+
const Result = ToolOutputSchema.objectRooted(Schema.Union([Found, Missing])).annotate({ identifier: "Result" });
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
It can go before or after `.annotate({ identifier })`: the identifier the schema already carries moves onto the new check, so the served documents are equal either way. Every union member must be an object shape: do not use it on a union with a primitive or array member, because the added `type: "object"` is a claim about the whole union that nothing checks, and the served schema would then contradict what the tool returns — give that tool an object envelope instead. `McpToolAudit`'s `objectRootedOutput` check names this helper when it finds a union root.
|
|
454
|
+
|
|
292
455
|
## Testing
|
|
293
456
|
|
|
294
457
|
`@effected/mcp/testing` is a separate entrypoint — importing it never pulls
|
|
@@ -297,7 +460,7 @@ test machinery into a server's runtime import graph.
|
|
|
297
460
|
```ts
|
|
298
461
|
import { McpHarness } from "@effected/mcp/testing";
|
|
299
462
|
import { Effect, type Layer, type Stdio } from "effect";
|
|
300
|
-
import type { McpServer } from "effect/
|
|
463
|
+
import type { McpServer } from "effect/ai";
|
|
301
464
|
|
|
302
465
|
// The server layer from "Putting it together" — still requiring Stdio.
|
|
303
466
|
declare const ServerLayer: Layer.Layer<McpServer.McpServer, never, Stdio.Stdio>;
|
|
@@ -314,7 +477,8 @@ const test = Effect.gen(function* () {
|
|
|
314
477
|
On a stateful revision (the default is `2025-11-25`), `initialize` comes
|
|
315
478
|
first: any other request sent before it fails fast with `McpTestFailure`
|
|
316
479
|
reason `NotInitialized` instead of the server's opaque `Invalid request
|
|
317
|
-
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.
|
|
318
482
|
|
|
319
483
|
`McpHarness.make` runs the server in-process over queue-backed `Stdio`, so
|
|
320
484
|
a test sees the exact served schemas and wire results a real client would,
|
|
@@ -357,7 +521,7 @@ import { join } from "node:path";
|
|
|
357
521
|
import { NodeServices } from "@effect/platform-node";
|
|
358
522
|
import { McpProcess } from "@effected/mcp/testing";
|
|
359
523
|
import { Effect } from "effect";
|
|
360
|
-
import { ChildProcess } from "effect/
|
|
524
|
+
import { ChildProcess } from "effect/process";
|
|
361
525
|
|
|
362
526
|
// A minimal server, written to disk so it can be spawned as a real process.
|
|
363
527
|
const serverFile = join(import.meta.dirname, "mcp-guard-demo-server.mjs");
|
|
@@ -367,7 +531,7 @@ writeFileSync(
|
|
|
367
531
|
import { McpStdio, McpToolkit } from "@effected/mcp";
|
|
368
532
|
import { NodeRuntime, NodeStdio } from "@effect/platform-node";
|
|
369
533
|
import { Effect, Layer } from "effect";
|
|
370
|
-
import { Tool, Toolkit } from "effect/
|
|
534
|
+
import { Tool, Toolkit } from "effect/ai";
|
|
371
535
|
|
|
372
536
|
const Ping = Tool.make("ping", { description: "Liveness check.", parameters: Tool.EmptyParams });
|
|
373
537
|
const Tools = Toolkit.make(Ping);
|
package/ToolFailure.js
CHANGED
|
@@ -9,14 +9,20 @@ 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.
|
|
16
16
|
*
|
|
17
17
|
* Spread {@link ToolFailure.fields} into your tool's `Schema.TaggedError`, build
|
|
18
18
|
* `message` with {@link ToolFailure.message}, and pass every caller-supplied
|
|
19
|
-
* value through {@link ToolFailure.truncate} before echoing it.
|
|
19
|
+
* value through {@link ToolFailure.truncate} before echoing it. For a plain
|
|
20
|
+
* refusal with no fields of its own, `ToolRefusal` is this shape ready-made.
|
|
21
|
+
*
|
|
22
|
+
* Only a declared failure keeps its message: core scrubs an undeclared
|
|
23
|
+
* failure or a defect to "Tool execution failed due to an internal server
|
|
24
|
+
* error.", so audit every throw path a tool can take and declare each one the
|
|
25
|
+
* agent should read.
|
|
20
26
|
*
|
|
21
27
|
* @example
|
|
22
28
|
* ```ts
|