@effected/mcp 0.11.0 → 0.13.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 +5 -562
- package/bin/effected-mcp.js +12 -0
- package/index.d.ts +11 -929
- package/index.js +2 -9
- package/main.d.ts +27 -0
- package/main.js +37 -0
- package/package.json +15 -21
- package/server.js +39 -0
- package/tools/identity.js +28 -0
- package/tsdoc-metadata.json +1 -1
- package/version.js +11 -0
- package/McpGuard.js +0 -81
- package/McpHarness.js +0 -338
- package/McpProbe.js +0 -94
- package/McpProcess.js +0 -196
- package/McpStdio.js +0 -159
- package/McpTestFailure.js +0 -49
- package/McpToolAudit.js +0 -109
- package/McpToolkit.js +0 -246
- package/StdioCapture.js +0 -146
- package/ToolFailure.js +0 -75
- package/ToolInputSchema.js +0 -180
- package/ToolOutcome.js +0 -119
- package/ToolOutputSchema.js +0 -65
- package/ToolRefusal.js +0 -57
- package/ToolReplay.js +0 -122
- package/guard.d.ts +0 -189
- package/guard.js +0 -3
- package/internal/LaunchFailed.js +0 -34
- package/internal/StdinFrames.js +0 -201
- package/internal/guardLaunch.js +0 -13
- package/internal/wire.js +0 -65
- package/node.d.ts +0 -126
- package/node.js +0 -3
- package/testing.d.ts +0 -490
- package/testing.js +0 -7
package/README.md
CHANGED
|
@@ -1,570 +1,13 @@
|
|
|
1
1
|
# @effected/mcp
|
|
2
2
|
|
|
3
|
-
The
|
|
3
|
+
The `effected-mcp` server: the tool's MCP tools on the kit's MCP boundary (`@effected/mcp-kit`).
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
[](https://opensource.org/licenses/MIT)
|
|
7
|
-
[](https://nodejs.org/)
|
|
8
|
-
[](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.0.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-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
|
-
### 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
|
-
|
|
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";
|
|
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/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.
|
|
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
|
-
|
|
295
|
-
```ts
|
|
296
|
-
import { McpToolkit } from "@effected/mcp";
|
|
297
|
-
import { Layer } from "effect";
|
|
298
|
-
import type { Tool, Toolkit } from "effect/ai";
|
|
299
|
-
|
|
300
|
-
// The toolkit and its handlers, as built in "Putting it together".
|
|
301
|
-
declare const MyTools: Toolkit.Toolkit<Record<string, Tool.Any>>;
|
|
302
|
-
declare const MyHandlers: Layer.Layer<never>;
|
|
303
|
-
|
|
304
|
-
// Default: every tool is strict; core's rejection names every bad field at once.
|
|
305
|
-
const ToolsLayer = McpToolkit.layer(MyTools).pipe(Layer.provide(MyHandlers));
|
|
306
|
-
|
|
307
|
-
// Only tools explicitly annotated Tool.Strict are decoded strict.
|
|
308
|
-
const LenientByDefault = McpToolkit.layer(MyTools, { strict: "annotated" }).pipe(Layer.provide(MyHandlers));
|
|
309
|
-
```
|
|
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
|
-
|
|
344
|
-
The dynamic-tool recipe, with the handler reporting every unknown key:
|
|
345
|
-
|
|
346
|
-
```ts
|
|
347
|
-
import { McpToolkit, ToolInputSchema } from "@effected/mcp";
|
|
348
|
-
import { Effect, Layer, Schema } from "effect";
|
|
349
|
-
import { Tool, Toolkit } from "effect/ai";
|
|
350
|
-
|
|
351
|
-
// A top-level union, written as raw JSON Schema and rewritten to an object root.
|
|
352
|
-
const EditInput = ToolInputSchema.objectRooted({
|
|
353
|
-
anyOf: [
|
|
354
|
-
{
|
|
355
|
-
type: "object",
|
|
356
|
-
properties: {
|
|
357
|
-
action: { const: "rename" },
|
|
358
|
-
to: { type: "string" },
|
|
359
|
-
options: { type: "object", properties: { force: { type: "boolean" } }, additionalProperties: false },
|
|
360
|
-
},
|
|
361
|
-
required: ["action", "to"],
|
|
362
|
-
additionalProperties: false,
|
|
363
|
-
},
|
|
364
|
-
{
|
|
365
|
-
type: "object",
|
|
366
|
-
properties: { action: { const: "delete" } },
|
|
367
|
-
required: ["action"],
|
|
368
|
-
additionalProperties: false,
|
|
369
|
-
},
|
|
370
|
-
],
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
class UnknownKeys extends Schema.TaggedError<UnknownKeys>()("UnknownKeys", { message: Schema.String }) {}
|
|
374
|
-
|
|
375
|
-
const Edit = Tool.dynamic("edit", {
|
|
376
|
-
description: "Rename or delete a thing.",
|
|
377
|
-
parameters: EditInput,
|
|
378
|
-
failure: UnknownKeys,
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
const EditTools = Toolkit.make(Edit);
|
|
382
|
-
|
|
383
|
-
const EditHandlers = EditTools.toLayer({
|
|
384
|
-
// A dynamic tool is decoded as Schema.Unknown: the handler sees the raw payload.
|
|
385
|
-
edit: (payload) => {
|
|
386
|
-
const levels = ToolInputSchema.unknownKeys(payload, EditInput);
|
|
387
|
-
return levels.length > 0
|
|
388
|
-
? Effect.fail(new UnknownKeys({ message: ToolInputSchema.formatUnknownKeys(levels) }))
|
|
389
|
-
: Effect.succeed(payload);
|
|
390
|
-
},
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
// McpToolkit never re-annotates a Tool.dynamic tool, so it stays lenient and the handler reports.
|
|
394
|
-
const EditLayer = McpToolkit.layer(EditTools).pipe(Layer.provide(EditHandlers));
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
```text
|
|
398
|
-
edit({ action: "rename", to: "b", extra: 1, options: { force: true, bogus: 2 } })
|
|
399
|
-
=> Unrecognized parameter(s): extra. Accepted params: action, to, options. Unrecognized parameter(s): options.bogus. Accepted params: force.
|
|
400
|
-
```
|
|
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:
|
|
5
|
+
This release serves one tool, `identity`, which returns what the tool reports about itself: `tool`, `version`, `distribution` (the carrier it was launched through, or `null`) and `effect`.
|
|
405
6
|
|
|
406
7
|
```ts
|
|
407
|
-
import {
|
|
408
|
-
import { Effect, Schema } from "effect";
|
|
409
|
-
import { Tool, Toolkit } from "effect/ai";
|
|
8
|
+
import { main } from "@effected/mcp/main";
|
|
410
9
|
|
|
411
|
-
|
|
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
|
-
});
|
|
10
|
+
await main({ distribution: { name: "@effected/plugin", version: "1.0.0" } });
|
|
433
11
|
```
|
|
434
12
|
|
|
435
|
-
|
|
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
|
-
|
|
455
|
-
## Testing
|
|
456
|
-
|
|
457
|
-
`@effected/mcp/testing` is a separate entrypoint — importing it never pulls
|
|
458
|
-
test machinery into a server's runtime import graph.
|
|
459
|
-
|
|
460
|
-
```ts
|
|
461
|
-
import { McpHarness } from "@effected/mcp/testing";
|
|
462
|
-
import { Effect, type Layer, type Stdio } from "effect";
|
|
463
|
-
import type { McpServer } from "effect/ai";
|
|
464
|
-
|
|
465
|
-
// The server layer from "Putting it together" — still requiring Stdio.
|
|
466
|
-
declare const ServerLayer: Layer.Layer<McpServer.McpServer, never, Stdio.Stdio>;
|
|
467
|
-
|
|
468
|
-
const test = Effect.gen(function* () {
|
|
469
|
-
const client = yield* McpHarness.make(ServerLayer);
|
|
470
|
-
yield* client.initialize; // initialize + notifications/initialized: every stateful revision requires it
|
|
471
|
-
const result = yield* client.callTool("get_thing", { id: "known" });
|
|
472
|
-
// result.result.structuredContent === { name: "a known thing" }
|
|
473
|
-
yield* client.close;
|
|
474
|
-
});
|
|
475
|
-
```
|
|
476
|
-
|
|
477
|
-
On a stateful revision (the default is `2025-11-25`), `initialize` comes
|
|
478
|
-
first: any other request sent before it fails fast with `McpTestFailure`
|
|
479
|
-
reason `NotInitialized` instead of the server's opaque `Invalid request
|
|
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.
|
|
482
|
-
|
|
483
|
-
`McpHarness.make` runs the server in-process over queue-backed `Stdio`, so
|
|
484
|
-
a test sees the exact served schemas and wire results a real client would,
|
|
485
|
-
with no child process and no sockets. Pass `server` **without** a `Stdio`
|
|
486
|
-
of its own — a `Stdio` the server provides internally would talk to the
|
|
487
|
-
real terminal instead of the test's queues, including one that arrives
|
|
488
|
-
bundled: `@effect/platform-node`'s `NodeServices.layer` brings its own
|
|
489
|
-
`Stdio` along with `FileSystem`, `Path`, `ChildProcessSpawner`, `Crypto`
|
|
490
|
-
and `Terminal`, so composing the server with that bundle loses the harness's
|
|
491
|
-
`Stdio` the same way. Compose the individual platform layers the server
|
|
492
|
-
needs instead and leave `Stdio` out; the harness supplies it. The harness builds the server
|
|
493
|
-
with a fresh layer memo map, never the ambient one, so a harness made under
|
|
494
|
-
another stdio server's `Effect.provide` still serves its own. For the same
|
|
495
|
-
reason, never pass a server layer that provides a layer the test also
|
|
496
|
-
provides and then reads: `McpHarness.make(server.pipe(Layer.provide(AppLayer)))`
|
|
497
|
-
under `Effect.provide(AppLayer)` builds `AppLayer` twice, so the tools write
|
|
498
|
-
to one instance and the test reads the other. Leave the service in the
|
|
499
|
-
server layer's requirements and provide it once from the test, or build it
|
|
500
|
-
once and pass `Layer.succeed(Tag, value)`. `McpProcess.spawn` and
|
|
501
|
-
`McpProbe.initialize` are the spawned-bin equivalents, for a test that
|
|
502
|
-
must exercise a built artifact rather than a layer — `McpProbe` in
|
|
503
|
-
particular is the MCP half of a packed-install proof: it keeps stdin open
|
|
504
|
-
until the response arrives, and the caller asserts
|
|
505
|
-
`result.response.error === undefined`, empty `stderr` and exit 0 — a
|
|
506
|
-
server that refuses the handshake still answers, exits 0 and stays quiet.
|
|
507
|
-
`McpToolAudit.check` is a pure sweep over a served `tools/list`, for a
|
|
508
|
-
static policy check with no server at all. `client.listResources` and
|
|
509
|
-
`client.readResource(uri)` mirror `listTools`/`callTool` for a server's
|
|
510
|
-
`resources/list` and `resources/read`.
|
|
511
|
-
|
|
512
|
-
`McpProcess` writes a JSON-encoded message with `send`, and anything at all
|
|
513
|
-
with `sendRaw(text: string | Uint8Array)`, which writes the string or bytes
|
|
514
|
-
verbatim. `sendRaw` is the only way to put genuinely malformed input on a
|
|
515
|
-
server's stdin — `send` and `McpHarness` always encode valid JSON — so it is
|
|
516
|
-
how a test proves the stdin guard against a spawned server:
|
|
517
|
-
|
|
518
|
-
```ts
|
|
519
|
-
import { unlinkSync, writeFileSync } from "node:fs";
|
|
520
|
-
import { join } from "node:path";
|
|
521
|
-
import { NodeServices } from "@effect/platform-node";
|
|
522
|
-
import { McpProcess } from "@effected/mcp/testing";
|
|
523
|
-
import { Effect } from "effect";
|
|
524
|
-
import { ChildProcess } from "effect/process";
|
|
525
|
-
|
|
526
|
-
// A minimal server, written to disk so it can be spawned as a real process.
|
|
527
|
-
const serverFile = join(import.meta.dirname, "mcp-guard-demo-server.mjs");
|
|
528
|
-
writeFileSync(
|
|
529
|
-
serverFile,
|
|
530
|
-
`
|
|
531
|
-
import { McpStdio, McpToolkit } from "@effected/mcp";
|
|
532
|
-
import { NodeRuntime, NodeStdio } from "@effect/platform-node";
|
|
533
|
-
import { Effect, Layer } from "effect";
|
|
534
|
-
import { Tool, Toolkit } from "effect/ai";
|
|
535
|
-
|
|
536
|
-
const Ping = Tool.make("ping", { description: "Liveness check.", parameters: Tool.EmptyParams });
|
|
537
|
-
const Tools = Toolkit.make(Ping);
|
|
538
|
-
const Handlers = Tools.toLayer({ ping: () => Effect.void });
|
|
539
|
-
const Main = McpToolkit.layer(Tools).pipe(
|
|
540
|
-
Layer.provide(Handlers),
|
|
541
|
-
Layer.provideMerge(McpStdio.layer({ name: "guard-demo", version: "0.0.0" })),
|
|
542
|
-
Layer.provide(NodeStdio.layer),
|
|
543
|
-
);
|
|
544
|
-
|
|
545
|
-
NodeRuntime.runMain(McpStdio.launch(Main), { teardown: McpStdio.teardown });
|
|
546
|
-
`,
|
|
547
|
-
);
|
|
548
|
-
|
|
549
|
-
const program = Effect.gen(function* () {
|
|
550
|
-
const server = yield* McpProcess.spawn(ChildProcess.make(process.execPath, [serverFile]));
|
|
551
|
-
yield* server.handshake(); // always id 1
|
|
552
|
-
// A bad line and a good one, in the same write.
|
|
553
|
-
yield* server.sendRaw('not json\n{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n');
|
|
554
|
-
const { response, seen } = yield* server.readUntilResponse(2);
|
|
555
|
-
console.log("answered the bad line:", seen.some((frame) => (frame as { id: unknown }).id === null));
|
|
556
|
-
console.log("kept serving:", Array.isArray((response.result as { tools: unknown }).tools));
|
|
557
|
-
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.timeout("5 seconds"));
|
|
558
|
-
|
|
559
|
-
await Effect.runPromise(program).finally(() => unlinkSync(serverFile));
|
|
560
|
-
// => answered the bad line: true
|
|
561
|
-
// => kept serving: true
|
|
562
|
-
```
|
|
563
|
-
|
|
564
|
-
## Tier
|
|
565
|
-
|
|
566
|
-
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.
|
|
567
|
-
|
|
568
|
-
## License
|
|
569
|
-
|
|
570
|
-
[MIT](LICENSE)
|
|
13
|
+
> **Pre-`1.0.0`.** Part of the `@effected/*` kit; unstable across `0.x` releases. Pin an exact version.
|