@effected/mcp 0.1.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.
@@ -0,0 +1,105 @@
1
+ //#region src/McpToolAudit.ts
2
+ const isNode = (u) => typeof u === "object" && u !== null && !Array.isArray(u);
3
+ const HINTS = [
4
+ "readOnlyHint",
5
+ "destructiveHint",
6
+ "idempotentHint",
7
+ "openWorldHint"
8
+ ];
9
+ /** Real branches whose members are separate object shapes, each worth its own report. `allOf` is not one of these: core emits it to merge keywords (declared `properties` plus a Record's `additionalProperties`) onto the SAME node, never to introduce a sibling shape. */
10
+ const BRANCHES = [
11
+ "prefixItems",
12
+ "anyOf",
13
+ "oneOf"
14
+ ];
15
+ const objectNodes = (schema) => {
16
+ const out = [];
17
+ const join = (path, key) => path === "" ? key : `${path}.${key}`;
18
+ const visit = (node, path) => {
19
+ if (!isNode(node)) return;
20
+ const merged = Array.isArray(node.allOf) ? [node, ...node.allOf.filter(isNode)] : [node];
21
+ const declaredParts = merged.filter((part) => isNode(part.properties));
22
+ const properties = declaredParts.length === 0 ? void 0 : declaredParts.reduce((acc, part) => {
23
+ for (const [key, child] of Object.entries(part.properties)) acc[key] = child;
24
+ return acc;
25
+ }, Object.create(null));
26
+ const closed = merged.some((part) => part.additionalProperties === false);
27
+ if (isNode(properties) || merged.some((part) => part.type === "object")) out.push({
28
+ path,
29
+ properties,
30
+ closed
31
+ });
32
+ if (isNode(properties)) for (const [key, child] of Object.entries(properties)) visit(child, join(path, key));
33
+ visit(node.items, join(path, "items"));
34
+ visit(node.additionalProperties, join(path, "additionalProperties"));
35
+ for (const keyword of BRANCHES) {
36
+ const list = node[keyword];
37
+ if (Array.isArray(list)) for (const [index, child] of list.entries()) visit(child, join(path, `${keyword}[${index}]`));
38
+ }
39
+ if (isNode(node.$defs)) for (const [key, child] of Object.entries(node.$defs)) visit(child, `$defs.${key}`);
40
+ };
41
+ visit(schema, "");
42
+ return out;
43
+ };
44
+ /**
45
+ * A pure sweep over a served `tools/list` that returns every policy
46
+ * violation, as `"<tool>: <what>"` strings.
47
+ *
48
+ * @remarks
49
+ * Run it against what a real server serves (`McpHarness.listTools`), not
50
+ * against your own schemas: the served document is what a client sees, and
51
+ * it differs by protocol revision. The stateless `2026-07-28` adapter passes a
52
+ * non-object output through, while the stateful adapters drop it — which is
53
+ * why `objectRootedOutput` defaults to `true`.
54
+ *
55
+ * `"closed"` is only as sound as the walk. It follows `properties`,
56
+ * `items`, a schema-valued `additionalProperties`, `prefixItems`, `anyOf`,
57
+ * `oneOf` and `$defs`, and merges an `allOf` member's `properties` and
58
+ * `additionalProperties: false` onto its node. It does not visit
59
+ * `patternProperties` values, an `allOf` member's `items` or
60
+ * `additionalProperties` schema, or an `allOf` nested inside another; and
61
+ * a key declared in two `allOf` members is last-write-wins. A hand-authored
62
+ * or `Tool.dynamic` schema can therefore pass `"closed"` with an open node
63
+ * the walk never reached. Core-emitted strict schemas close every node, so
64
+ * a strict `Tool.make` tool is reported faithfully.
65
+ *
66
+ * @public
67
+ */
68
+ var McpToolAudit = class {
69
+ constructor() {}
70
+ /**
71
+ * Every violation of `policy` across `tools`, as `"<tool>: <what>"`, in
72
+ * tool order. Empty means the sweep passed. A duplicate tool name is
73
+ * reported under every policy, `input: "any"` included.
74
+ */
75
+ static check = (tools, policy) => {
76
+ const violations = [];
77
+ const seen = /* @__PURE__ */ new Set();
78
+ for (const tool of tools) {
79
+ const report = (what) => {
80
+ violations.push(`${tool.name}: ${what}`);
81
+ };
82
+ if (seen.has(tool.name)) report("duplicate tool name");
83
+ seen.add(tool.name);
84
+ if (policy.input !== "any") for (const { path, properties, closed } of objectNodes(tool.inputSchema)) {
85
+ const where = path === "" ? "the root" : path;
86
+ if (policy.input === "closed" && !closed) report(`input schema is open at ${where}`);
87
+ if (policy.input === "open" && isNode(properties) && closed) report(`input schema is closed at ${where}`);
88
+ }
89
+ const title = tool.title ?? tool.annotations?.title;
90
+ if (policy.requireTitle === true && (typeof title !== "string" || title === "")) report("no title");
91
+ if (policy.requireOutputSchema === true && tool.outputSchema === void 0) report("no outputSchema");
92
+ if ((policy.objectRootedOutput ?? true) && tool.outputSchema !== void 0 && tool.outputSchema.type !== "object") report(`outputSchema is not object-rooted (root type: ${String(tool.outputSchema.type ?? "none")})`);
93
+ const length = (tool.description ?? "").length;
94
+ if (policy.maxDescription !== void 0 && length > policy.maxDescription) report(`description is ${length} characters, over the ${policy.maxDescription} limit`);
95
+ if (policy.requireHints === true) {
96
+ const missing = HINTS.filter((hint) => typeof tool.annotations?.[hint] !== "boolean");
97
+ if (missing.length > 0) report(`missing annotation hints: ${missing.join(", ")}`);
98
+ }
99
+ }
100
+ return violations;
101
+ };
102
+ };
103
+
104
+ //#endregion
105
+ export { McpToolAudit };
package/McpToolkit.js ADDED
@@ -0,0 +1,79 @@
1
+ import { ToolInputSchema } from "./ToolInputSchema.js";
2
+ import { Context, Effect, Layer } from "effect";
3
+ import { McpSchema, McpServer, Tool, Toolkit } from "effect/unstable/ai";
4
+
5
+ //#region src/McpToolkit.ts
6
+ const DEFAULT_STRICT = "all";
7
+ 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
+ /**
9
+ * Register a toolkit exactly as core's `McpServer.toolkit` does, except that a
10
+ * strict tool's unknown arguments are all named, at every depth, in one
11
+ * response.
12
+ *
13
+ * @remarks
14
+ * Rejection itself is core's: a tool annotated `Tool.Strict` is served with
15
+ * `additionalProperties: false` on every object node and decoded with
16
+ * `onExcessProperty: "error"`. But core reports only the FIRST excess key
17
+ * (`Expected no excess property at ["extra"]`), so an agent fixes one typo
18
+ * per round trip and never learns about a nested one until the next call.
19
+ * This layer is the better report, not the rejecter: it runs core's
20
+ * `registerToolkit` unchanged under a registration-scoped `McpServer` whose
21
+ * `addTool` puts a `ToolInputSchema.unknownKeys` pre-check in front of each
22
+ * handler. The pre-check walks the input schema the tool actually serves
23
+ * (after core's own top-level `$ref` inlining), follows `$ref`, `allOf` and
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.
35
+ *
36
+ * Registration goes through core's module-level `McpServer.McpServer.layer`,
37
+ * shared by reference with `McpStdio.layer`'s own copy — provide both into
38
+ * the same graph, as with `McpServer.toolkit`. Never wrap that layer in
39
+ * `Layer.fresh`: tools would register into a second registry nobody serves.
40
+ *
41
+ * Each call mints a fresh layer; bind the result to a `const` or the
42
+ * registration runs twice.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * import { Layer } from "effect";
47
+ * import { McpStdio, McpToolkit } from "@effected/mcp";
48
+ *
49
+ * const ToolsLayer = McpToolkit.layer(MyToolkit).pipe(Layer.provide(MyHandlers));
50
+ * const ServerLayer = ToolsLayer.pipe(Layer.provideMerge(McpStdio.layer({ name: "my-server", version: "1.0.0" })));
51
+ * ```
52
+ *
53
+ * @public
54
+ */
55
+ var McpToolkit = class {
56
+ constructor() {}
57
+ /**
58
+ * `McpServer.toolkit`'s registration layer, strict by default and naming
59
+ * every unknown argument in one `InvalidParams`.
60
+ */
61
+ static layer = (toolkit, options = {}) => Layer.effectDiscard(Effect.gen(function* () {
62
+ const registry = yield* McpServer.McpServer;
63
+ const format = options.unknownKeyMessage ?? ((levels) => ToolInputSchema.formatUnknownKeys(levels));
64
+ const decorated = McpServer.McpServer.of({
65
+ ...registry,
66
+ addTool: (registration) => Context.get(registration.annotations, Tool.Strict) === true ? registry.addTool({
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)
73
+ });
74
+ yield* McpServer.registerToolkit(strictened(toolkit, options.strict ?? DEFAULT_STRICT)).pipe(Effect.provideService(McpServer.McpServer, decorated));
75
+ })).pipe(Layer.provide(McpServer.McpServer.layer));
76
+ };
77
+
78
+ //#endregion
79
+ export { McpToolkit };
package/README.md ADDED
@@ -0,0 +1,406 @@
1
+ # @effected/mcp
2
+
3
+ The boundary layer of an `effect/unstable/ai` MCP server: stdio wiring that keeps stdout the JSON-RPC wire, tool-failure shaping, and strict-input walkers, plus a `./testing` subpath for driving a built server from a test.
4
+
5
+ [![npm](https://img.shields.io/npm/v/@effected%2Fmcp?label=npm&color=cb3837)](https://www.npmjs.com/package/@effected/mcp)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
7
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
8
+ [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
9
+
10
+ > **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
11
+ > development against a single pinned Effect v4 prerelease. Packages graduate to
12
+ > `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
13
+ > exactly the ones the kit is built and tested against, install
14
+ > [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
15
+ >
16
+ > **Stability: unstable.** This package's API surface is not yet considered
17
+ > complete and may change across `0.x` releases. Pin an exact version — even a
18
+ > package marked *stable* before `1.0.0` can introduce a breaking change by
19
+ > accident, and an exact pin turns that into a type-check error rather than a
20
+ > runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install @effected/mcp @effected/engine effect
26
+ ```
27
+
28
+ ```bash
29
+ pnpm add @effected/mcp @effected/engine effect
30
+ ```
31
+
32
+ Requires Node.js >=24.11.0.
33
+
34
+ All `@effected/*` packages are ESM-only: the exports maps publish only `import` conditions, so `require()` — including tools that resolve in CJS mode — fails with Node's `ERR_PACKAGE_PATH_NOT_EXPORTED` rather than loading a CJS build that does not exist. Import from an ES module.
35
+
36
+ `effect` v4 and `@effected/engine` are the only peer dependencies. Boundary tier: `src/` reads no `process` token, imports no `node:` module and no `@effect/platform*` package, and calls no `console.*` — stdout is the JSON-RPC wire a server writes over.
37
+
38
+ ## ToolFailure
39
+
40
+ Core's MCP server sends a tool's declared failure — an `Error` instance, which every `Schema.TaggedError` is — as `isError: true` with `error.message` as the only text and no `structuredContent`. Whatever is not folded into `message` when the error is constructed never reaches the agent. `ToolFailure` is the shared shape for doing that folding consistently across a server's tools.
41
+
42
+ Spread `ToolFailure.fields` into a tool's `Schema.TaggedError`, build `message` with `ToolFailure.message`, and pass every caller-supplied value through `ToolFailure.truncate` before echoing it back:
43
+
44
+ ```ts
45
+ import { ToolFailure } from "@effected/mcp";
46
+ import { Schema } from "effect";
47
+
48
+ class NotFound extends Schema.TaggedError<NotFound>()("NotFound", {
49
+ ...ToolFailure.fields,
50
+ id: Schema.String,
51
+ }) {}
52
+
53
+ const remediation = { hint: "List the ids first.", suggestedTool: "list_things" };
54
+ const id = "missing-id";
55
+
56
+ const error = new NotFound({
57
+ id,
58
+ remediation,
59
+ message: ToolFailure.message(`No thing "${ToolFailure.truncate(id)}".`, remediation),
60
+ });
61
+
62
+ console.log(error.message);
63
+ // => No thing "missing-id". List the ids first. Try list_things.
64
+ ```
65
+
66
+ `ToolFailure.message` drops any empty part, so an omitted `suggestedTool` or an empty `hint` never leaves a double space:
67
+
68
+ ```ts
69
+ import { ToolFailure } from "@effected/mcp";
70
+
71
+ console.log(ToolFailure.message("Config missing.", { hint: "Run init." }));
72
+ // => Config missing. Run init.
73
+ ```
74
+
75
+ `ToolFailure.truncate` caps a caller-supplied value at `ToolFailure.ECHO_LIMIT` (200 UTF-16 code units) before it is echoed into a message, backing off one unit rather than splitting a surrogate pair. A value the engine itself produced — a path, a diagnostic — can take the larger `ToolFailure.ENGINE_ECHO_LIMIT` (2000):
76
+
77
+ ```ts
78
+ import { ToolFailure } from "@effected/mcp";
79
+
80
+ console.log(ToolFailure.truncate("a".repeat(500)).length);
81
+ // => 201 (200 characters, then "…")
82
+
83
+ const enginePath = `/work/${"deep/".repeat(100)}file.ts`;
84
+ console.log(ToolFailure.truncate(enginePath, ToolFailure.ENGINE_ECHO_LIMIT) === enginePath);
85
+ // => true (under the 2000-unit engine cap)
86
+ ```
87
+
88
+ `ToolFailure` is a static-namespace class with a private constructor — it is never instantiated.
89
+
90
+ ## Putting it together
91
+
92
+ A stdio server's `main.ts` is one line, plus the layer that wires it:
93
+
94
+ ```ts
95
+ import { McpStdio, McpToolkit, ToolFailure } from "@effected/mcp";
96
+ import { NodeRuntime, NodeStdio } from "@effect/platform-node";
97
+ import { Effect, Layer, Schema } from "effect";
98
+ import { Tool, Toolkit } from "effect/unstable/ai";
99
+
100
+ class NotFound extends Schema.TaggedError<NotFound>()("NotFound", {
101
+ ...ToolFailure.fields,
102
+ id: Schema.String,
103
+ }) {}
104
+
105
+ const GetThing = Tool.make("get_thing", {
106
+ description: "Fetch a thing by id.",
107
+ parameters: Schema.Struct({ id: Schema.String }),
108
+ success: Schema.Struct({ name: Schema.String }),
109
+ failure: NotFound,
110
+ });
111
+
112
+ const MyTools = Toolkit.make(GetThing);
113
+
114
+ const MyHandlers = MyTools.toLayer({
115
+ get_thing: ({ id }) =>
116
+ id === "known"
117
+ ? Effect.succeed({ name: "a known thing" })
118
+ : Effect.fail(
119
+ new NotFound({
120
+ id,
121
+ remediation: { hint: "List the ids first.", suggestedTool: "list_things" },
122
+ message: ToolFailure.message(`No thing "${ToolFailure.truncate(id)}".`, {
123
+ hint: "List the ids first.",
124
+ suggestedTool: "list_things",
125
+ }),
126
+ }),
127
+ ),
128
+ });
129
+
130
+ const ToolsLayer = McpToolkit.layer(MyTools).pipe(Layer.provide(MyHandlers));
131
+ const ServerLayer = ToolsLayer.pipe(
132
+ Layer.provideMerge(McpStdio.layer({ name: "my-server", version: "1.0.0" })),
133
+ );
134
+
135
+ // The platform Stdio is the one service provided at the edge.
136
+ const Main = ServerLayer.pipe(Layer.provide(NodeStdio.layer));
137
+
138
+ NodeRuntime.runMain(McpStdio.launch(Main), { teardown: McpStdio.teardown });
139
+ ```
140
+
141
+ The platform `Stdio` is the one service provided at the edge: `ServerLayer`
142
+ still requires it, and `Main` supplies `NodeStdio.layer` from
143
+ `@effect/platform-node` just before launch. Leave that line out and
144
+ `McpStdio.launch(Main)` fails to typecheck, because `Stdio` is still in its
145
+ requirements. Test `ServerLayer`, never `Main`: `McpHarness.make` supplies
146
+ its own queue-backed `Stdio`.
147
+
148
+ `McpToolkit.layer` re-annotates every tool without its own `Tool.Strict`
149
+ annotation to strict by default, so `get_thing({ id: "known", extra: 1 })`
150
+ is rejected with one `Unrecognized parameter(s): extra. Accepted params:
151
+ id.` before the handler ever runs — see [Strict input](#strict-input)
152
+ below.
153
+
154
+ ## The stdio boundary
155
+
156
+ `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 throws on
158
+ a line that is not JSON without ever trimming it from its buffer, so one bad
159
+ line wedges the server: every later request goes unanswered while stdin EOF
160
+ still exits 0. The guard frames stdin exactly as core does — one streaming
161
+ UTF-8 decoder, a byte-order mark stripped only at the start of the stream,
162
+ lines split on `\n` — and answers each line core would choke on itself, on
163
+ stdout:
164
+
165
+ - a line that is not JSON gets a JSON-RPC parse error,
166
+ `{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}`,
167
+ and the server keeps serving the lines after it;
168
+ - a line longer than core's cap of 16 Mi UTF-16 code units gets the same
169
+ reply once, as soon as it passes the cap, and the rest of it is discarded
170
+ up to its newline;
171
+ - a line that is JSON but no JSON-RPC message gets an Invalid Request,
172
+ `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid Request"}}`,
173
+ and the server keeps serving. That covers a value that is neither an object
174
+ nor an array (core throws on a bare `null`, dropping every other frame that
175
+ arrived in the same chunk, and ignores a number, string or boolean without a
176
+ reply), an object whose `method` is not a string and whose `id` is absent or
177
+ `null` (core throws on that too), and an object with neither `method` nor
178
+ `id`, which is neither a request nor a response;
179
+ - a line of JSON whitespace (space, tab, carriage return) is ignored.
180
+
181
+ Every other line goes to core. That includes an array, which core answers with
182
+ `-32600` itself because it serves no batches, and an object with an `id` and
183
+ no `method`, which is a response and never gets a reply.
184
+
185
+ **Give each server a fresh layer memo map.** Core's stdio protocol layer is a
186
+ shared constant, so a second `McpStdio.layer` server whose build sees the
187
+ first one's memo map shares its protocol: only the first server reads stdin,
188
+ and the second never answers. Merging both into one graph does that, and so
189
+ does building or providing the second anywhere under the first one's
190
+ `Effect.provide` — a nested `Layer.build` or `Effect.provide` forks the
191
+ ambient memo map rather than starting a new one. Isolate each server with its
192
+ own `ManagedRuntime`, `Effect.provide(layer, { local: true })`, or its own
193
+ process.
194
+
195
+ ## Strict input
196
+
197
+ Two ways to close a tool's input schema against unknown keys, at different
198
+ scopes:
199
+
200
+ - **`ToolInputSchema`** — pure walkers, for a `Tool.dynamic` tool,
201
+ whose raw JSON Schema core never validates strictly: run
202
+ `ToolInputSchema.unknownKeys(payload, schema)` inside its handler against
203
+ the schema you registered, usually paired with `objectRooted(schema)`,
204
+ because a top-level discriminated union only reaches a server as raw
205
+ JSON Schema — core dies at boot on a union root. Not for a `Tool.make`
206
+ tool: core decodes its payload before the handler runs, so an excess key
207
+ is already dropped or rejected by then.
208
+ - **`McpToolkit.layer(toolkit, options?)`** — the registration-scoped
209
+ decorator, and the recommended default: every tool without its own
210
+ `Tool.Strict` annotation is served and decoded strict
211
+ (`options.strict` defaults to `"all"`), and a rejected call names every
212
+ unknown key at every depth in one response, not just the first. An
213
+ explicit `Tool.Strict` annotation always wins, and a `Tool.dynamic` tool
214
+ is never re-annotated. Pass `{ strict: "annotated" }` to leave
215
+ unannotated tools lenient, or annotate an individual tool
216
+ `Tool.Strict` false to opt it out under the default.
217
+
218
+ ```ts
219
+ import { McpToolkit } from "@effected/mcp";
220
+ import { Layer } from "effect";
221
+ import type { Tool, Toolkit } from "effect/unstable/ai";
222
+
223
+ // The toolkit and its handlers, as built in "Putting it together".
224
+ declare const MyTools: Toolkit.Toolkit<Record<string, Tool.Any>>;
225
+ declare const MyHandlers: Layer.Layer<never>;
226
+
227
+ // Default: every tool is strict, and every rejection names every unknown key.
228
+ const ToolsLayer = McpToolkit.layer(MyTools).pipe(Layer.provide(MyHandlers));
229
+
230
+ // Only tools explicitly annotated Tool.Strict are decoded strict.
231
+ const LenientByDefault = McpToolkit.layer(MyTools, { strict: "annotated" }).pipe(Layer.provide(MyHandlers));
232
+ ```
233
+
234
+ The dynamic-tool recipe, with the handler reporting every unknown key:
235
+
236
+ ```ts
237
+ import { McpToolkit, ToolInputSchema } from "@effected/mcp";
238
+ import { Effect, Layer, Schema } from "effect";
239
+ import { Tool, Toolkit } from "effect/unstable/ai";
240
+
241
+ // A top-level union, written as raw JSON Schema and rewritten to an object root.
242
+ const EditInput = ToolInputSchema.objectRooted({
243
+ anyOf: [
244
+ {
245
+ type: "object",
246
+ properties: {
247
+ action: { const: "rename" },
248
+ to: { type: "string" },
249
+ options: { type: "object", properties: { force: { type: "boolean" } }, additionalProperties: false },
250
+ },
251
+ required: ["action", "to"],
252
+ additionalProperties: false,
253
+ },
254
+ {
255
+ type: "object",
256
+ properties: { action: { const: "delete" } },
257
+ required: ["action"],
258
+ additionalProperties: false,
259
+ },
260
+ ],
261
+ });
262
+
263
+ class UnknownKeys extends Schema.TaggedError<UnknownKeys>()("UnknownKeys", { message: Schema.String }) {}
264
+
265
+ const Edit = Tool.dynamic("edit", {
266
+ description: "Rename or delete a thing.",
267
+ parameters: EditInput,
268
+ failure: UnknownKeys,
269
+ });
270
+
271
+ const EditTools = Toolkit.make(Edit);
272
+
273
+ const EditHandlers = EditTools.toLayer({
274
+ // A dynamic tool is decoded as Schema.Unknown: the handler sees the raw payload.
275
+ edit: (payload) => {
276
+ const levels = ToolInputSchema.unknownKeys(payload, EditInput);
277
+ return levels.length > 0
278
+ ? Effect.fail(new UnknownKeys({ message: ToolInputSchema.formatUnknownKeys(levels) }))
279
+ : Effect.succeed(payload);
280
+ },
281
+ });
282
+
283
+ // McpToolkit never re-annotates a Tool.dynamic tool, so it stays lenient and the handler reports.
284
+ const EditLayer = McpToolkit.layer(EditTools).pipe(Layer.provide(EditHandlers));
285
+ ```
286
+
287
+ ```text
288
+ edit({ action: "rename", to: "b", extra: 1, options: { force: true, bogus: 2 } })
289
+ => Unrecognized parameter(s): extra. Accepted params: action, to, options. Unrecognized parameter(s): options.bogus. Accepted params: force.
290
+ ```
291
+
292
+ ## Testing
293
+
294
+ `@effected/mcp/testing` is a separate entrypoint — importing it never pulls
295
+ test machinery into a server's runtime import graph.
296
+
297
+ ```ts
298
+ import { McpHarness } from "@effected/mcp/testing";
299
+ import { Effect, type Layer, type Stdio } from "effect";
300
+ import type { McpServer } from "effect/unstable/ai";
301
+
302
+ // The server layer from "Putting it together" — still requiring Stdio.
303
+ declare const ServerLayer: Layer.Layer<McpServer.McpServer, never, Stdio.Stdio>;
304
+
305
+ const test = Effect.gen(function* () {
306
+ const client = yield* McpHarness.make(ServerLayer);
307
+ yield* client.initialize; // initialize + notifications/initialized: every stateful revision requires it
308
+ const result = yield* client.callTool("get_thing", { id: "known" });
309
+ // result.result.structuredContent === { name: "a known thing" }
310
+ yield* client.close;
311
+ });
312
+ ```
313
+
314
+ On a stateful revision (the default is `2025-11-25`), `initialize` comes
315
+ first: any other request sent before it fails fast with `McpTestFailure`
316
+ reason `NotInitialized` instead of the server's opaque `Invalid request
317
+ metadata`.
318
+
319
+ `McpHarness.make` runs the server in-process over queue-backed `Stdio`, so
320
+ a test sees the exact served schemas and wire results a real client would,
321
+ with no child process and no sockets. Pass `server` **without** a `Stdio`
322
+ of its own — a `Stdio` the server provides internally would talk to the
323
+ real terminal instead of the test's queues, including one that arrives
324
+ bundled: `@effect/platform-node`'s `NodeServices.layer` brings its own
325
+ `Stdio` along with `FileSystem`, `Path`, `ChildProcessSpawner`, `Crypto`
326
+ and `Terminal`, so composing the server with that bundle loses the harness's
327
+ `Stdio` the same way. Compose the individual platform layers the server
328
+ needs instead and leave `Stdio` out; the harness supplies it. The harness builds the server
329
+ with a fresh layer memo map, never the ambient one, so a harness made under
330
+ another stdio server's `Effect.provide` still serves its own. For the same
331
+ reason, never pass a server layer that provides a layer the test also
332
+ provides and then reads: `McpHarness.make(server.pipe(Layer.provide(AppLayer)))`
333
+ under `Effect.provide(AppLayer)` builds `AppLayer` twice, so the tools write
334
+ to one instance and the test reads the other. Leave the service in the
335
+ server layer's requirements and provide it once from the test, or build it
336
+ once and pass `Layer.succeed(Tag, value)`. `McpProcess.spawn` and
337
+ `McpProbe.initialize` are the spawned-bin equivalents, for a test that
338
+ must exercise a built artifact rather than a layer — `McpProbe` in
339
+ particular is the MCP half of a packed-install proof: it keeps stdin open
340
+ until the response arrives, and the caller asserts
341
+ `result.response.error === undefined`, empty `stderr` and exit 0 — a
342
+ server that refuses the handshake still answers, exits 0 and stays quiet.
343
+ `McpToolAudit.check` is a pure sweep over a served `tools/list`, for a
344
+ static policy check with no server at all. `client.listResources` and
345
+ `client.readResource(uri)` mirror `listTools`/`callTool` for a server's
346
+ `resources/list` and `resources/read`.
347
+
348
+ `McpProcess` writes a JSON-encoded message with `send`, and anything at all
349
+ with `sendRaw(text: string | Uint8Array)`, which writes the string or bytes
350
+ verbatim. `sendRaw` is the only way to put genuinely malformed input on a
351
+ server's stdin — `send` and `McpHarness` always encode valid JSON — so it is
352
+ how a test proves the stdin guard against a spawned server:
353
+
354
+ ```ts
355
+ import { unlinkSync, writeFileSync } from "node:fs";
356
+ import { join } from "node:path";
357
+ import { NodeServices } from "@effect/platform-node";
358
+ import { McpProcess } from "@effected/mcp/testing";
359
+ import { Effect } from "effect";
360
+ import { ChildProcess } from "effect/unstable/process";
361
+
362
+ // A minimal server, written to disk so it can be spawned as a real process.
363
+ const serverFile = join(import.meta.dirname, "mcp-guard-demo-server.mjs");
364
+ writeFileSync(
365
+ serverFile,
366
+ `
367
+ import { McpStdio, McpToolkit } from "@effected/mcp";
368
+ import { NodeRuntime, NodeStdio } from "@effect/platform-node";
369
+ import { Effect, Layer } from "effect";
370
+ import { Tool, Toolkit } from "effect/unstable/ai";
371
+
372
+ const Ping = Tool.make("ping", { description: "Liveness check.", parameters: Tool.EmptyParams });
373
+ const Tools = Toolkit.make(Ping);
374
+ const Handlers = Tools.toLayer({ ping: () => Effect.void });
375
+ const Main = McpToolkit.layer(Tools).pipe(
376
+ Layer.provide(Handlers),
377
+ Layer.provideMerge(McpStdio.layer({ name: "guard-demo", version: "0.0.0" })),
378
+ Layer.provide(NodeStdio.layer),
379
+ );
380
+
381
+ NodeRuntime.runMain(McpStdio.launch(Main), { teardown: McpStdio.teardown });
382
+ `,
383
+ );
384
+
385
+ const program = Effect.gen(function* () {
386
+ const server = yield* McpProcess.spawn(ChildProcess.make(process.execPath, [serverFile]));
387
+ yield* server.handshake(); // always id 1
388
+ // A bad line and a good one, in the same write.
389
+ yield* server.sendRaw('not json\n{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n');
390
+ const { response, seen } = yield* server.readUntilResponse(2);
391
+ console.log("answered the bad line:", seen.some((frame) => (frame as { id: unknown }).id === null));
392
+ console.log("kept serving:", Array.isArray((response.result as { tools: unknown }).tools));
393
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.timeout("5 seconds"));
394
+
395
+ await Effect.runPromise(program).finally(() => unlinkSync(serverFile));
396
+ // => answered the bad line: true
397
+ // => kept serving: true
398
+ ```
399
+
400
+ ## Tier
401
+
402
+ Boundary tier. Peers: `@effected/engine` and `effect`. Nothing in the kit depends on `@effected/mcp` except an application; it never depends on `@effected/cli` or `@effected/workspaces` — a CLI boundary and an MCP boundary are siblings, both front ends, never layers on each other.
403
+
404
+ ## License
405
+
406
+ [MIT](LICENSE)
package/ToolFailure.js ADDED
@@ -0,0 +1,63 @@
1
+ import { Schema } from "effect";
2
+ import { Remediation } from "@effected/engine";
3
+
4
+ //#region src/ToolFailure.ts
5
+ const isHighSurrogate = (code) => code >= 55296 && code <= 56319;
6
+ /**
7
+ * How a tool's declared failure should read on the wire.
8
+ *
9
+ * @remarks
10
+ * Core sends a declared failure that is an `Error` instance — every
11
+ * `Schema.TaggedError` is — as `isError: true` with `error.message` as the only
12
+ * text and no `structuredContent` (effect `unstable/ai/McpServer.ts`,
13
+ * `declaredFailureResult`). So the remediation must be folded into `message`
14
+ * when the error is constructed: whatever is not in the message never reaches
15
+ * the agent.
16
+ *
17
+ * Spread {@link ToolFailure.fields} into your tool's `Schema.TaggedError`, build
18
+ * `message` with {@link ToolFailure.message}, and pass every caller-supplied
19
+ * value through {@link ToolFailure.truncate} before echoing it.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { ...ToolFailure.fields, id: Schema.String }) {}
24
+ * const remediation = { hint: "List the ids first.", suggestedTool: "list_things" }
25
+ * new NotFound({ id, remediation, message: ToolFailure.message(`No thing "${ToolFailure.truncate(id)}".`, remediation) })
26
+ * ```
27
+ *
28
+ * @public
29
+ */
30
+ var ToolFailure = class ToolFailure {
31
+ constructor() {}
32
+ /** The cap, in UTF-16 code units, on a caller-supplied value echoed into a message. */
33
+ static ECHO_LIMIT = 200;
34
+ /** The larger cap for a value the engine produced (a path, a diagnostic), not one a caller typed. */
35
+ static ENGINE_ECHO_LIMIT = 2e3;
36
+ /** The `message` and `remediation` fields to spread into a consumer's `Schema.TaggedError`. */
37
+ static fields = {
38
+ message: Schema.String,
39
+ remediation: Remediation
40
+ };
41
+ /**
42
+ * `"<raw> <hint> Try <suggestedTool>."`, dropping any empty part, so a
43
+ * missing tool or an empty hint never leaves a double space.
44
+ */
45
+ static message = (raw, remediation) => [
46
+ raw,
47
+ remediation.hint,
48
+ remediation.suggestedTool === void 0 ? "" : `Try ${remediation.suggestedTool}.`
49
+ ].filter((part) => part !== "").join(" ");
50
+ /**
51
+ * `value` cut to `limit` code units with a trailing ellipsis, backing off one
52
+ * unit rather than splitting a surrogate pair. A hostile multi-megabyte
53
+ * argument is otherwise echoed into the agent's context whole.
54
+ */
55
+ static truncate = (value, limit = ToolFailure.ECHO_LIMIT) => {
56
+ if (value.length <= limit) return value;
57
+ const end = limit > 0 && isHighSurrogate(value.charCodeAt(limit - 1)) ? limit - 1 : limit;
58
+ return `${value.slice(0, end)}…`;
59
+ };
60
+ };
61
+
62
+ //#endregion
63
+ export { ToolFailure };