@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.
- package/LICENSE +21 -0
- package/McpHarness.js +234 -0
- package/McpProbe.js +81 -0
- package/McpProcess.js +135 -0
- package/McpStdio.js +143 -0
- package/McpTestFailure.js +43 -0
- package/McpToolAudit.js +105 -0
- package/McpToolkit.js +79 -0
- package/README.md +406 -0
- package/ToolFailure.js +63 -0
- package/ToolInputSchema.js +167 -0
- package/index.d.ts +352 -0
- package/index.js +6 -0
- package/internal/LaunchFailed.js +23 -0
- package/internal/StdinFrames.js +201 -0
- package/internal/wire.js +66 -0
- package/package.json +51 -0
- package/testing.d.ts +365 -0
- package/testing.js +7 -0
- package/tsdoc-metadata.json +11 -0
package/McpToolAudit.js
ADDED
|
@@ -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
|
+
[](https://www.npmjs.com/package/@effected/mcp)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://nodejs.org/)
|
|
8
|
+
[](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 };
|