@effected/mcp-kit 0.0.0-stage → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,575 @@
1
- # Temporary Holding Version
1
+ # @effected/mcp-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+
5
+ [![npm](https://img.shields.io/npm/v/@effected%2Fmcp?label=npm&color=cb3837)](https://www.npmjs.com/package/@effected/mcp-kit)
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-`1.0.0`.** This package is part of the `@effected/*` kit, built on stable
11
+ > Effect v4 (`effect` `~4.1.0`) and still in `0.x` development. Stable Effect
12
+ > makes a kit `1.0.0` possible, not automatic. To keep your `effect` and
13
+ > `@effect/*` versions on the line the kit is built and tested against, install
14
+ > [`@effected/pnpm-plugin-effected`](https://www.npmjs.com/package/@effected/pnpm-plugin-effected).
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-kit @effected/kit effect
26
+ ```
27
+
28
+ ```bash
29
+ pnpm add @effected/mcp-kit @effected/kit 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/kit` 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-kit";
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-kit";
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-kit";
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
+ ### 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-kit";
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
+
115
+ ## Putting it together
116
+
117
+ A stdio server's `main.ts` is one line, plus the layer that wires it:
118
+
119
+ ```ts
120
+ import { McpStdio, McpToolkit, ToolFailure } from "@effected/mcp-kit";
121
+ import { NodeRuntime, NodeStdio } from "@effect/platform-node";
122
+ import { Effect, Layer, Schema } from "effect";
123
+ import { Tool, Toolkit } from "effect/ai";
124
+
125
+ class NotFound extends Schema.TaggedError<NotFound>()("NotFound", {
126
+ ...ToolFailure.fields,
127
+ id: Schema.String,
128
+ }) {}
129
+
130
+ const GetThing = Tool.make("get_thing", {
131
+ description: "Fetch a thing by id.",
132
+ parameters: Schema.Struct({ id: Schema.String }),
133
+ success: Schema.Struct({ name: Schema.String }),
134
+ failure: NotFound,
135
+ });
136
+
137
+ const MyTools = Toolkit.make(GetThing);
138
+
139
+ const MyHandlers = MyTools.toLayer({
140
+ get_thing: ({ id }) =>
141
+ id === "known"
142
+ ? Effect.succeed({ name: "a known thing" })
143
+ : Effect.fail(
144
+ new NotFound({
145
+ id,
146
+ remediation: { hint: "List the ids first.", suggestedTool: "list_things" },
147
+ message: ToolFailure.message(`No thing "${ToolFailure.truncate(id)}".`, {
148
+ hint: "List the ids first.",
149
+ suggestedTool: "list_things",
150
+ }),
151
+ }),
152
+ ),
153
+ });
154
+
155
+ const ToolsLayer = McpToolkit.layer(MyTools).pipe(Layer.provide(MyHandlers));
156
+ const ServerLayer = ToolsLayer.pipe(
157
+ Layer.provideMerge(McpStdio.layer({ name: "my-server", version: "1.0.0" })),
158
+ );
159
+
160
+ // The platform Stdio is the one service provided at the edge.
161
+ const Main = ServerLayer.pipe(Layer.provide(NodeStdio.layer));
162
+
163
+ NodeRuntime.runMain(McpStdio.launch(Main), { teardown: McpStdio.teardown });
164
+ ```
165
+
166
+ The platform `Stdio` is the one service provided at the edge: `ServerLayer`
167
+ still requires it, and `Main` supplies `NodeStdio.layer` from
168
+ `@effect/platform-node` just before launch. Leave that line out and
169
+ `McpStdio.launch(Main)` fails to typecheck, because `Stdio` is still in its
170
+ requirements. Test `ServerLayer`, never `Main`: `McpHarness.make` supplies
171
+ its own queue-backed `Stdio`.
172
+
173
+ `McpToolkit.layer` re-annotates every tool without its own `Tool.Strict`
174
+ annotation to strict by default, so `get_thing({ id: "known", extra: 1 })`
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.
179
+
180
+ ## The stdio boundary
181
+
182
+ `McpStdio.layer` is core's `McpServer.layerStdio` with every log line sent to
183
+ stderr, and with stdin read through a guard. Core's own stdio decoder skips a
184
+ line it cannot use and keeps serving, but it sends no reply, where JSON-RPC 2.0
185
+ requires one: the client is left waiting on a request it will never hear about.
186
+ The guard frames stdin exactly as core does — one streaming UTF-8 decoder, a
187
+ byte-order mark stripped only at the start of the stream, lines split on `\n`
188
+ — and answers each such line itself, on stdout:
189
+
190
+ - a line that is not JSON gets a JSON-RPC parse error,
191
+ `{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}`,
192
+ and the server keeps serving the lines after it;
193
+ - a line longer than core's cap of 16 Mi UTF-16 code units gets the same
194
+ reply once, as soon as it passes the cap, and the rest of it is discarded
195
+ up to its newline;
196
+ - a line that is JSON but no JSON-RPC message gets an Invalid Request,
197
+ `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid Request"}}`,
198
+ and the server keeps serving. That covers a value that is neither an object
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`;
209
+ - a line of JSON whitespace (space, tab, carriage return) is ignored.
210
+
211
+ Every other line goes to core. That includes an array, which core answers with
212
+ `-32600` itself because it serves no batches, and an object with an `id` and
213
+ no `method`, which is a response and never gets a reply.
214
+
215
+ **Give each server a fresh layer memo map.** Core's stdio protocol layer is a
216
+ shared constant, so a second `McpStdio.layer` server whose build sees the
217
+ first one's memo map shares its protocol: only the first server reads stdin,
218
+ and the second never answers. Merging both into one graph does that, and so
219
+ does building or providing the second anywhere under the first one's
220
+ `Effect.provide` — a nested `Layer.build` or `Effect.provide` forks the
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-kit/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-kit/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.
266
+
267
+ ## Strict input
268
+
269
+ Two ways to close a tool's input schema against unknown keys, at different
270
+ scopes:
271
+
272
+ - **`ToolInputSchema`** — pure walkers, for a `Tool.dynamic` tool,
273
+ whose raw JSON Schema core never validates strictly: run
274
+ `ToolInputSchema.unknownKeys(payload, schema)` inside its handler against
275
+ the schema you registered, usually paired with `objectRooted(schema)`,
276
+ because a top-level discriminated union only reaches a server as raw
277
+ JSON Schema — core dies at boot on a union root. Not for a `Tool.make`
278
+ tool: core decodes its payload before the handler runs, so an excess key
279
+ is already dropped or rejected by then.
280
+ - **`McpToolkit.layer(toolkit, options?)`** — the registration-scoped
281
+ decorator, and the recommended default: every tool without its own
282
+ `Tool.Strict` annotation is served and decoded strict
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
292
+ unannotated tools lenient, or annotate an individual tool
293
+ `Tool.Strict` false to opt it out under the default.
294
+ - **`McpToolkit.provideLifetime(layer)`** — meets the services tools declare
295
+ in `dependencies` from a layer built once with the server's graph. Provide
296
+ the result to `McpToolkit.layer`. It captures what the layer outputs and
297
+ nothing else, so nothing the build ran under shadows a request's own span
298
+ or references; a layer that outputs `Effect.context()` wholesale still leaks.
299
+
300
+ ```ts
301
+ import { McpToolkit } from "@effected/mcp-kit";
302
+ import { Layer } from "effect";
303
+ import type { Tool, Toolkit } from "effect/ai";
304
+
305
+ // The toolkit and its handlers, as built in "Putting it together".
306
+ declare const MyTools: Toolkit.Toolkit<Record<string, Tool.Any>>;
307
+ declare const MyHandlers: Layer.Layer<never>;
308
+
309
+ // Default: every tool is strict; core's rejection names every bad field at once.
310
+ const ToolsLayer = McpToolkit.layer(MyTools).pipe(Layer.provide(MyHandlers));
311
+
312
+ // Only tools explicitly annotated Tool.Strict are decoded strict.
313
+ const LenientByDefault = McpToolkit.layer(MyTools, { strict: "annotated" }).pipe(Layer.provide(MyHandlers));
314
+ ```
315
+
316
+ A strict `search` tool taking `query` and an optional `filter` union, called
317
+ with an unknown key at the root and one inside the selected `filter` member,
318
+ and without its required `query`:
319
+
320
+ ```text
321
+ search({ extra: 1, filter: { kind: "tag", tag: "t", text: "sneaky" } })
322
+ => Invalid parameters for tool 'search': Expected no excess property
323
+ at ["extra"]
324
+ Missing key
325
+ at ["query"]
326
+ Expected no excess property
327
+ at ["filter"]["text"]
328
+ Accepted params at the root: query, filter.
329
+ Accepted params at ["filter"]: kind, tag.
330
+ ```
331
+
332
+ The first six lines are core's. The layer adds the last two: one per object
333
+ level that carries an unknown key, naming its accepted keys, plus
334
+ `keys matching <pattern>` where the level accepts keys by `patternProperties`.
335
+ A zero-parameter tool gets `This tool accepts no params.` instead. A failure
336
+ with no unknown key, only missing or mistyped fields, is core's report
337
+ unchanged.
338
+
339
+ A pattern-keyed `Schema.Record` is served closed, with `patternProperties`,
340
+ only when its key pattern's RegExp carries the Unicode flag:
341
+ `Schema.isPattern(/^x-/u)`. Without `u`, Effect treats the exported pattern as
342
+ approximate and serves the record open (`additionalProperties` set to the value
343
+ schema), so the served schema, and `ToolInputSchema.unknownKeys` walking it,
344
+ accept any key; the strict decode still enforces the pattern. `McpToolAudit`'s `input: "closed"` check reports such
345
+ a record open and suggests the flag. Where a closed level accepts keys by
346
+ pattern, `formatUnknownKeys` names the patterns (`Accepted keys matching: ^x-.`)
347
+ rather than claiming it accepts none.
348
+
349
+ The dynamic-tool recipe, with the handler reporting every unknown key:
350
+
351
+ ```ts
352
+ import { McpToolkit, ToolInputSchema } from "@effected/mcp-kit";
353
+ import { Effect, Layer, Schema } from "effect";
354
+ import { Tool, Toolkit } from "effect/ai";
355
+
356
+ // A top-level union, written as raw JSON Schema and rewritten to an object root.
357
+ const EditInput = ToolInputSchema.objectRooted({
358
+ anyOf: [
359
+ {
360
+ type: "object",
361
+ properties: {
362
+ action: { const: "rename" },
363
+ to: { type: "string" },
364
+ options: { type: "object", properties: { force: { type: "boolean" } }, additionalProperties: false },
365
+ },
366
+ required: ["action", "to"],
367
+ additionalProperties: false,
368
+ },
369
+ {
370
+ type: "object",
371
+ properties: { action: { const: "delete" } },
372
+ required: ["action"],
373
+ additionalProperties: false,
374
+ },
375
+ ],
376
+ });
377
+
378
+ class UnknownKeys extends Schema.TaggedError<UnknownKeys>()("UnknownKeys", { message: Schema.String }) {}
379
+
380
+ const Edit = Tool.dynamic("edit", {
381
+ description: "Rename or delete a thing.",
382
+ parameters: EditInput,
383
+ failure: UnknownKeys,
384
+ });
385
+
386
+ const EditTools = Toolkit.make(Edit);
387
+
388
+ const EditHandlers = EditTools.toLayer({
389
+ // A dynamic tool is decoded as Schema.Unknown: the handler sees the raw payload.
390
+ edit: (payload) => {
391
+ const levels = ToolInputSchema.unknownKeys(payload, EditInput);
392
+ return levels.length > 0
393
+ ? Effect.fail(new UnknownKeys({ message: ToolInputSchema.formatUnknownKeys(levels) }))
394
+ : Effect.succeed(payload);
395
+ },
396
+ });
397
+
398
+ // McpToolkit never re-annotates a Tool.dynamic tool, so it stays lenient and the handler reports.
399
+ const EditLayer = McpToolkit.layer(EditTools).pipe(Layer.provide(EditHandlers));
400
+ ```
401
+
402
+ ```text
403
+ edit({ action: "rename", to: "b", extra: 1, options: { force: true, bogus: 2 } })
404
+ => Unrecognized parameter(s): extra. Accepted params: action, to, options. Unrecognized parameter(s): options.bogus. Accepted params: force.
405
+ ```
406
+
407
+ ## Union-parameter tools
408
+
409
+ 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:
410
+
411
+ ```ts
412
+ import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp-kit";
413
+ import { Effect, Schema } from "effect";
414
+ import { Tool, Toolkit } from "effect/ai";
415
+
416
+ const AddNote = Schema.Struct({ action: Schema.Literal("add"), text: Schema.String });
417
+ const ListNotes = Schema.Struct({ action: Schema.Literal("list"), limit: Schema.optionalKey(Schema.Number) });
418
+
419
+ const Note = McpToolkit.unionTool("note", {
420
+ description: "Add or list notes.",
421
+ parameters: Schema.Union([AddNote, ListNotes]),
422
+ success: ToolOutputSchema.objectRooted(
423
+ Schema.Union([
424
+ Schema.Struct({ kind: Schema.Literal("added") }),
425
+ Schema.Struct({ kind: Schema.Literal("listed"), count: Schema.Number }),
426
+ ]),
427
+ ),
428
+ failure: ToolRefusal,
429
+ }).annotate(Tool.Title, "Note");
430
+
431
+ const Kit = Toolkit.make(Note);
432
+ const Handlers = Kit.toLayer({
433
+ // `params` is AddNote | ListNotes, already decoded.
434
+ note: McpToolkit.unionHandler(Note, (params) =>
435
+ Effect.succeed(params.action === "add" ? { kind: "added" as const } : { kind: "listed" as const, count: 0 }),
436
+ ),
437
+ });
438
+ ```
439
+
440
+ `annotate` and `addDependency` keep the union on the tool, so a chained `.annotate(Tool.Title, …)` still hands `unionHandler` the decoded type.
441
+
442
+ 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.
443
+
444
+ ## Union success schemas
445
+
446
+ 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:
447
+
448
+ ```ts
449
+ import { ToolOutputSchema } from "@effected/mcp-kit";
450
+ import { Schema } from "effect";
451
+
452
+ const Found = Schema.Struct({ kind: Schema.Literal("found"), id: Schema.String });
453
+ const Missing = Schema.Struct({ kind: Schema.Literal("missing"), reason: Schema.String });
454
+
455
+ const Result = ToolOutputSchema.objectRooted(Schema.Union([Found, Missing])).annotate({ identifier: "Result" });
456
+ ```
457
+
458
+ 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.
459
+
460
+ ## Testing
461
+
462
+ `@effected/mcp-kit/testing` is a separate entrypoint — importing it never pulls
463
+ test machinery into a server's runtime import graph.
464
+
465
+ ```ts
466
+ import { McpHarness } from "@effected/mcp-kit/testing";
467
+ import { Effect, type Layer, type Stdio } from "effect";
468
+ import type { McpServer } from "effect/ai";
469
+
470
+ // The server layer from "Putting it together" — still requiring Stdio.
471
+ declare const ServerLayer: Layer.Layer<McpServer.McpServer, never, Stdio.Stdio>;
472
+
473
+ const test = Effect.gen(function* () {
474
+ const client = yield* McpHarness.make(ServerLayer);
475
+ yield* client.initialize; // initialize + notifications/initialized: every stateful revision requires it
476
+ const result = yield* client.callTool("get_thing", { id: "known" });
477
+ // result.result.structuredContent === { name: "a known thing" }
478
+ yield* client.close;
479
+ });
480
+ ```
481
+
482
+ On a stateful revision (the default is `2025-11-25`), `initialize` comes
483
+ first: any other request sent before it fails fast with `McpTestFailure`
484
+ reason `NotInitialized` instead of the server's opaque `Invalid request
485
+ metadata`. `ping` is the exception: the server answers it `{}` before
486
+ 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.
487
+
488
+ `McpHarness.make` runs the server in-process over queue-backed `Stdio`, so
489
+ a test sees the exact served schemas and wire results a real client would,
490
+ with no child process and no sockets. Pass `server` **without** a `Stdio`
491
+ of its own — a `Stdio` the server provides internally would talk to the
492
+ real terminal instead of the test's queues, including one that arrives
493
+ bundled: `@effect/platform-node`'s `NodeServices.layer` brings its own
494
+ `Stdio` along with `FileSystem`, `Path`, `ChildProcessSpawner`
495
+ and `Terminal`, so composing the server with that bundle loses the harness's
496
+ `Stdio` the same way. Compose the individual platform layers the server
497
+ needs instead and leave `Stdio` out; the harness supplies it. The harness builds the server
498
+ with a fresh layer memo map, never the ambient one, so a harness made under
499
+ another stdio server's `Effect.provide` still serves its own. For the same
500
+ reason, never pass a server layer that provides a layer the test also
501
+ provides and then reads: `McpHarness.make(server.pipe(Layer.provide(AppLayer)))`
502
+ under `Effect.provide(AppLayer)` builds `AppLayer` twice, so the tools write
503
+ to one instance and the test reads the other. Leave the service in the
504
+ server layer's requirements and provide it once from the test, or build it
505
+ once and pass `Layer.succeed(Tag, value)`. `McpProcess.spawn` and
506
+ `McpProbe.initialize` are the spawned-bin equivalents, for a test that
507
+ must exercise a built artifact rather than a layer — `McpProbe` in
508
+ particular is the MCP half of a packed-install proof: it keeps stdin open
509
+ until the response arrives, and the caller asserts
510
+ `result.response.error === undefined`, empty `stderr` and exit 0 — a
511
+ server that refuses the handshake still answers, exits 0 and stays quiet.
512
+ `McpToolAudit.check` is a pure sweep over a served `tools/list`, for a
513
+ static policy check with no server at all. `client.listResources` and
514
+ `client.readResource(uri)` mirror `listTools`/`callTool` for a server's
515
+ `resources/list` and `resources/read`.
516
+
517
+ `McpProcess` writes a JSON-encoded message with `send`, and anything at all
518
+ with `sendRaw(text: string | Uint8Array)`, which writes the string or bytes
519
+ verbatim. `sendRaw` is the only way to put genuinely malformed input on a
520
+ server's stdin — `send` and `McpHarness` always encode valid JSON — so it is
521
+ how a test proves the stdin guard against a spawned server:
522
+
523
+ ```ts
524
+ import { unlinkSync, writeFileSync } from "node:fs";
525
+ import { join } from "node:path";
526
+ import { NodeServices } from "@effect/platform-node";
527
+ import { McpProcess } from "@effected/mcp-kit/testing";
528
+ import { Effect } from "effect";
529
+ import { ChildProcess } from "effect/process";
530
+
531
+ // A minimal server, written to disk so it can be spawned as a real process.
532
+ const serverFile = join(import.meta.dirname, "mcp-guard-demo-server.mjs");
533
+ writeFileSync(
534
+ serverFile,
535
+ `
536
+ import { McpStdio, McpToolkit } from "@effected/mcp-kit";
537
+ import { NodeRuntime, NodeStdio } from "@effect/platform-node";
538
+ import { Effect, Layer } from "effect";
539
+ import { Tool, Toolkit } from "effect/ai";
540
+
541
+ const Ping = Tool.make("ping", { description: "Liveness check.", parameters: Tool.EmptyParams });
542
+ const Tools = Toolkit.make(Ping);
543
+ const Handlers = Tools.toLayer({ ping: () => Effect.void });
544
+ const Main = McpToolkit.layer(Tools).pipe(
545
+ Layer.provide(Handlers),
546
+ Layer.provideMerge(McpStdio.layer({ name: "guard-demo", version: "0.0.0" })),
547
+ Layer.provide(NodeStdio.layer),
548
+ );
549
+
550
+ NodeRuntime.runMain(McpStdio.launch(Main), { teardown: McpStdio.teardown });
551
+ `,
552
+ );
553
+
554
+ const program = Effect.gen(function* () {
555
+ const server = yield* McpProcess.spawn(ChildProcess.make(process.execPath, [serverFile]));
556
+ yield* server.handshake(); // always id 1
557
+ // A bad line and a good one, in the same write.
558
+ yield* server.sendRaw('not json\n{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n');
559
+ const { response, seen } = yield* server.readUntilResponse(2);
560
+ console.log("answered the bad line:", seen.some((frame) => (frame as { id: unknown }).id === null));
561
+ console.log("kept serving:", Array.isArray((response.result as { tools: unknown }).tools));
562
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.timeout("5 seconds"));
563
+
564
+ await Effect.runPromise(program).finally(() => unlinkSync(serverFile));
565
+ // => answered the bad line: true
566
+ // => kept serving: true
567
+ ```
568
+
569
+ ## Tier
570
+
571
+ Boundary tier. Peers: `@effected/kit` and `effect`. Nothing in the kit depends on `@effected/mcp-kit` except an application; it never depends on `@effected/cli-kit` or `@effected/workspaces` — a CLI boundary and an MCP boundary are siblings, both front ends, never layers on each other.
572
+
573
+ ## License
574
+
575
+ [MIT](LICENSE)