@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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { ToolFailure } from "./ToolFailure.js";
|
|
2
|
+
import { Result } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/ToolInputSchema.ts
|
|
5
|
+
const MAX_DEPTH = 256;
|
|
6
|
+
const MAX_ECHOED = 20;
|
|
7
|
+
const DISCRIMINANTS = [
|
|
8
|
+
"action",
|
|
9
|
+
"kind",
|
|
10
|
+
"_tag",
|
|
11
|
+
"type"
|
|
12
|
+
];
|
|
13
|
+
const REF_PREFIX = "#/$defs/";
|
|
14
|
+
const isNode = (u) => typeof u === "object" && u !== null && !Array.isArray(u);
|
|
15
|
+
const resolveRef = (node, root, seen = /* @__PURE__ */ new Set()) => {
|
|
16
|
+
const ref = node.$ref;
|
|
17
|
+
if (typeof ref !== "string" || !ref.startsWith(REF_PREFIX) || seen.has(ref)) return node;
|
|
18
|
+
const defs = root.$defs;
|
|
19
|
+
const name = ref.slice(8).replaceAll("~1", "/").replaceAll("~0", "~");
|
|
20
|
+
const target = isNode(defs) && Object.hasOwn(defs, name) ? defs[name] : void 0;
|
|
21
|
+
if (!isNode(target)) return node;
|
|
22
|
+
const { $ref: _ref, ...siblings } = node;
|
|
23
|
+
return resolveRef({
|
|
24
|
+
...target,
|
|
25
|
+
...siblings
|
|
26
|
+
}, root, /* @__PURE__ */ new Set([...seen, ref]));
|
|
27
|
+
};
|
|
28
|
+
const literalValues = (node) => {
|
|
29
|
+
if (!isNode(node)) return void 0;
|
|
30
|
+
if (Array.isArray(node.enum)) return node.enum;
|
|
31
|
+
return "const" in node ? [node.const] : void 0;
|
|
32
|
+
};
|
|
33
|
+
const discriminantOf = (members) => members.length === 0 ? void 0 : DISCRIMINANTS.find((key) => members.every((member) => isNode(member.properties) && literalValues(member.properties[key]) !== void 0));
|
|
34
|
+
const selectMember = (members, value, root) => {
|
|
35
|
+
const resolved = members.filter(isNode).map((member) => resolveRef(member, root));
|
|
36
|
+
const key = discriminantOf(resolved);
|
|
37
|
+
if (key !== void 0) return resolved.find((member) => literalValues(member.properties[key])?.includes(value[key]) === true);
|
|
38
|
+
const objects = resolved.filter((member) => isNode(member.properties) || member.type === "object");
|
|
39
|
+
return objects.length === 1 ? objects[0] : void 0;
|
|
40
|
+
};
|
|
41
|
+
const matchesPattern = (pattern, key) => Result.getOrUndefined(Result.try(() => new RegExp(pattern, "u").test(key))) === true;
|
|
42
|
+
const collect = (payload, root) => {
|
|
43
|
+
const out = [];
|
|
44
|
+
const walk = (value, raw, path, depth) => {
|
|
45
|
+
if (depth > MAX_DEPTH || !isNode(raw)) return;
|
|
46
|
+
const node = resolveRef(raw, root);
|
|
47
|
+
const parts = [node, ...Array.isArray(node.allOf) ? node.allOf.filter(isNode).map((m) => resolveRef(m, root)) : []];
|
|
48
|
+
const members = Array.isArray(node.oneOf) ? node.oneOf : Array.isArray(node.anyOf) ? node.anyOf : void 0;
|
|
49
|
+
const additional = parts.map((part) => part.additionalProperties).find(isNode);
|
|
50
|
+
const isClosed = parts.some((part) => part.additionalProperties === false);
|
|
51
|
+
if (Array.isArray(value)) {
|
|
52
|
+
const prefix = Array.isArray(node.prefixItems) ? node.prefixItems : [];
|
|
53
|
+
if (prefix.length === 0 && !isNode(node.items) && members !== void 0) {
|
|
54
|
+
const arrays = members.filter(isNode).map((member) => resolveRef(member, root)).filter((member) => member.type === "array");
|
|
55
|
+
if (arrays.length === 1) walk(value, arrays[0], path, depth + 1);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
for (const [index, item] of value.entries()) walk(item, index < prefix.length ? prefix[index] : node.items, [...path, String(index)], depth + 1);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!isNode(value)) return;
|
|
62
|
+
const declared = parts.filter((part) => isNode(part.properties));
|
|
63
|
+
if (declared.length === 0 && members !== void 0) {
|
|
64
|
+
const member = selectMember(members, value, root);
|
|
65
|
+
if (member !== void 0) walk(value, member, path, depth + 1);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const properties = Object.create(null);
|
|
69
|
+
for (const part of declared) for (const [key, schema] of Object.entries(part.properties)) properties[key] = schema;
|
|
70
|
+
const patterns = parts.flatMap((part) => isNode(part.patternProperties) ? Object.entries(part.patternProperties) : []);
|
|
71
|
+
const accepted = Object.keys(properties);
|
|
72
|
+
const extra = [];
|
|
73
|
+
const patterned = [];
|
|
74
|
+
for (const key of Object.keys(value)) {
|
|
75
|
+
if (Object.hasOwn(properties, key)) continue;
|
|
76
|
+
const match = patterns.find(([pattern]) => matchesPattern(pattern, key));
|
|
77
|
+
if (match === void 0) extra.push(key);
|
|
78
|
+
else patterned.push([key, match[1]]);
|
|
79
|
+
}
|
|
80
|
+
if (extra.length > 0 && isClosed) out.push({
|
|
81
|
+
path,
|
|
82
|
+
unknown: extra,
|
|
83
|
+
accepted
|
|
84
|
+
});
|
|
85
|
+
for (const key of accepted) if (Object.hasOwn(value, key)) walk(value[key], properties[key], [...path, key], depth + 1);
|
|
86
|
+
for (const [key, schema] of patterned) walk(value[key], schema, [...path, key], depth + 1);
|
|
87
|
+
if (additional !== void 0) for (const key of extra) walk(value[key], additional, [...path, key], depth + 1);
|
|
88
|
+
};
|
|
89
|
+
walk(payload, root, [], 0);
|
|
90
|
+
return out;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Pure walkers over a tool's served JSON Schema: every unknown key at every
|
|
94
|
+
* depth, a message naming them all, and an object-rooted form of a union.
|
|
95
|
+
*
|
|
96
|
+
* @remarks
|
|
97
|
+
* Core's `Tool.Strict` rejects unknown keys but reports only the first
|
|
98
|
+
* (effect `unstable/ai/McpServer.ts` decodes without `errors: "all"`), so an
|
|
99
|
+
* agent fixes one typo per round trip. These walkers run over the schema core
|
|
100
|
+
* actually serves, so what they accept matches what the tool advertises:
|
|
101
|
+
*
|
|
102
|
+
* - Only `additionalProperties: false` closes a node, as in JSON Schema; core
|
|
103
|
+
* emits it on every object node of a strict tool and `true` otherwise.
|
|
104
|
+
* - `allOf` members' properties merge; `oneOf`/`anyOf` members are chosen by an
|
|
105
|
+
* `action`, `kind`, `_tag` or `type` literal (tried in that order), or by
|
|
106
|
+
* being the only object member. A payload whose discriminant matches no
|
|
107
|
+
* member, or is missing, is skipped and left for decoding.
|
|
108
|
+
* - `items`, `prefixItems`, `$ref` into `$defs` (JSON Pointer escapes
|
|
109
|
+
* honoured), and `patternProperties` are followed; a key matched by a
|
|
110
|
+
* pattern is accepted and walked with the first matching pattern's schema.
|
|
111
|
+
* - The payload is untrusted: the walk stops descending at depth 256 rather
|
|
112
|
+
* than overflowing the stack, so a deeper unknown key goes unreported and
|
|
113
|
+
* is left for decoding. Every hop counts toward that cap: each property or
|
|
114
|
+
* array position, and also each union member selection, which adds a hop
|
|
115
|
+
* without adding a path segment.
|
|
116
|
+
*
|
|
117
|
+
* @public
|
|
118
|
+
*/
|
|
119
|
+
var ToolInputSchema = class {
|
|
120
|
+
constructor() {}
|
|
121
|
+
/** Every level of `payload` that carries a key `schema` does not accept. */
|
|
122
|
+
static unknownKeys = (payload, schema) => collect(payload, schema);
|
|
123
|
+
/**
|
|
124
|
+
* `Unrecognized parameter(s): a, b.c. Accepted params: x, y.` per level. Each
|
|
125
|
+
* echoed key is truncated, and at most 20 keys and 20 levels are named; a
|
|
126
|
+
* trailing `(and N more levels).` counts the levels left out.
|
|
127
|
+
*/
|
|
128
|
+
static formatUnknownKeys = (levels, options = {}) => {
|
|
129
|
+
const limit = options.echoLimit ?? ToolFailure.ECHO_LIMIT;
|
|
130
|
+
const sentences = levels.slice(0, MAX_ECHOED).map((level) => {
|
|
131
|
+
const shown = level.unknown.slice(0, MAX_ECHOED).map((key) => ToolFailure.truncate([...level.path, key].join("."), limit));
|
|
132
|
+
const more = level.unknown.length > MAX_ECHOED ? ` (and ${level.unknown.length - MAX_ECHOED} more)` : "";
|
|
133
|
+
const accepted = level.accepted.length === 0 ? "(none)" : level.accepted.join(", ");
|
|
134
|
+
return `Unrecognized parameter(s): ${shown.join(", ")}${more}. Accepted params: ${accepted}.`;
|
|
135
|
+
});
|
|
136
|
+
const hidden = levels.length > MAX_ECHOED ? ` (and ${levels.length - MAX_ECHOED} more levels).` : "";
|
|
137
|
+
return `${sentences.join(" ")}${hidden}`;
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* `schema` with a root `$ref` inlined and a top-level union of objects
|
|
141
|
+
* sharing an `action`, `kind`, `_tag` or `type` literal rewritten to an
|
|
142
|
+
* object root carrying `type: "object"`, `oneOf` and `"x-discriminator"` —
|
|
143
|
+
* MCP requires an object root, and core dies at boot on a union root.
|
|
144
|
+
* Returned as the same value when nothing changes.
|
|
145
|
+
*/
|
|
146
|
+
static objectRooted = (schema) => {
|
|
147
|
+
const root = resolveRef(schema, schema);
|
|
148
|
+
const members = Array.isArray(root.anyOf) ? root.anyOf : Array.isArray(root.oneOf) ? root.oneOf : void 0;
|
|
149
|
+
if (root.type === void 0 && members !== void 0) {
|
|
150
|
+
const resolved = members.filter(isNode).map((member) => resolveRef(member, schema));
|
|
151
|
+
const key = resolved.length === members.length ? discriminantOf(resolved) : void 0;
|
|
152
|
+
if (key !== void 0) {
|
|
153
|
+
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = root;
|
|
154
|
+
return {
|
|
155
|
+
type: "object",
|
|
156
|
+
...rest,
|
|
157
|
+
oneOf: resolved,
|
|
158
|
+
"x-discriminator": key
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return root;
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
//#endregion
|
|
167
|
+
export { ToolInputSchema };
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { Array, Effect, JsonSchema, Layer, Runtime, Schema, Stdio } from "effect";
|
|
2
|
+
import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/unstable/ai";
|
|
3
|
+
import { Remediation } from "@effected/engine";
|
|
4
|
+
//#region src/McpStdio.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Options for {@link McpStdio.layer}.
|
|
7
|
+
*
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
interface McpStdioOptions {
|
|
11
|
+
/** The server name reported to the client. */
|
|
12
|
+
readonly name: string;
|
|
13
|
+
/** The server version reported to the client. */
|
|
14
|
+
readonly version: string;
|
|
15
|
+
/** Surfaced in `initialize` and `server/discover`; tell the agent how to use the tools. */
|
|
16
|
+
readonly instructions?: string | undefined;
|
|
17
|
+
/** A one-line description of the server, reported to the client. */
|
|
18
|
+
readonly description?: string | undefined;
|
|
19
|
+
/** Defaults to {@link McpStdio.protocols}. Read its ordering rules before overriding. */
|
|
20
|
+
readonly protocols?: Array.NonEmptyReadonlyArray<McpProtocol.ProtocolAdapter> | undefined;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Serve an MCP server over stdio without ever writing a log line or a failure
|
|
24
|
+
* report onto stdout, which is the JSON-RPC wire.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* The one-line `main.ts`:
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* NodeRuntime.runMain(McpStdio.launch(Main), { teardown: McpStdio.teardown })
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* - {@link McpStdio.layer} is `McpServer.layerStdio` with `LogToStderr`
|
|
34
|
+
* provided and merged into its output, and `Layer.orDie`. It answers a
|
|
35
|
+
* stdin line that is not JSON with a JSON-RPC `-32700` parse error, and
|
|
36
|
+
* JSON that is not a JSON-RPC message with `-32600`, and keeps serving.
|
|
37
|
+
* - {@link McpStdio.launch} reports a launch failure itself, on stderr, and
|
|
38
|
+
* hides it from `runMain`, whose own report is written outside anything
|
|
39
|
+
* the program can provide.
|
|
40
|
+
* - {@link McpStdio.teardown} maps stdin EOF, the normal end of every
|
|
41
|
+
* session, to exit 0 instead of 130.
|
|
42
|
+
*
|
|
43
|
+
* Never `NodeRuntime.runMain(Layer.launch(Main).pipe(Effect.provideService(References.LogToStderr, true)))`:
|
|
44
|
+
* it typechecks and serves, but a launch failure is still reported by
|
|
45
|
+
* `runMain` through `console.log`, straight onto the wire, where the client
|
|
46
|
+
* reads it as a malformed frame instead of an error.
|
|
47
|
+
*
|
|
48
|
+
* @public
|
|
49
|
+
*/
|
|
50
|
+
export declare class McpStdio {
|
|
51
|
+
private constructor();
|
|
52
|
+
/**
|
|
53
|
+
* `[v2026_07_28, v2025_11_25, v2025_06_18]` — the stateless revision
|
|
54
|
+
* first, then the two newest stateful ones.
|
|
55
|
+
*
|
|
56
|
+
* @remarks
|
|
57
|
+
* Real clients open with either handshake: Claude Code 2.1.281 was
|
|
58
|
+
* measured opening a stdio server with the stateless `server/discover`,
|
|
59
|
+
* while most other shipping clients still send `initialize`. The order
|
|
60
|
+
* is load-bearing:
|
|
61
|
+
*
|
|
62
|
+
* - Stateless first: a request with no session and no `_meta` falls to
|
|
63
|
+
* `protocols[0]`.
|
|
64
|
+
* - Never a single entry: `initialize` only matches stateful adapters, so
|
|
65
|
+
* a stateless-only list refuses every client that opens with it.
|
|
66
|
+
* - At most one stateless adapter; a second fails the layer.
|
|
67
|
+
*/
|
|
68
|
+
static readonly protocols: Array.NonEmptyReadonlyArray<McpProtocol.ProtocolAdapter>;
|
|
69
|
+
/**
|
|
70
|
+
* `McpServer.layerStdio` with `LogToStderr` provided and merged into the
|
|
71
|
+
* output, so every layer it provides logs to stderr too. A bad
|
|
72
|
+
* `protocols` list dies: it is the implementer's defect.
|
|
73
|
+
*
|
|
74
|
+
* @remarks
|
|
75
|
+
* Only a layer this one provides inherits `LogToStderr`: compose with
|
|
76
|
+
* `Layer.provideMerge(McpStdio.layer(...))`. A sibling merged beside it
|
|
77
|
+
* with `Layer.mergeAll` is not provided by it, so its build logs still
|
|
78
|
+
* go through `console.log`, onto the wire.
|
|
79
|
+
*
|
|
80
|
+
* The server reads stdin through a guard that frames it exactly as core's
|
|
81
|
+
* decoder does: one streaming UTF-8 decoder, a byte-order mark stripped
|
|
82
|
+
* only at the start of the stream, lines split on `\n`. A line that is
|
|
83
|
+
* not JSON is answered on stdout with a JSON-RPC parse error, code
|
|
84
|
+
* `-32700` and `id: null`, and never reaches core's decoder, which would
|
|
85
|
+
* otherwise throw on that line again for every later chunk and stop
|
|
86
|
+
* answering. So is a line longer than core's cap of 16 Mi UTF-16 code
|
|
87
|
+
* units: it is answered once, as soon as it passes the cap, and the rest
|
|
88
|
+
* of it is discarded up to its newline. A line of JSON whitespace (space,
|
|
89
|
+
* tab, carriage return) is ignored.
|
|
90
|
+
*
|
|
91
|
+
* A line that is JSON but no JSON-RPC message core can handle is answered
|
|
92
|
+
* with an Invalid Request, code `-32600` and `id: null`, and never reaches
|
|
93
|
+
* core: a value that is neither an object nor an array (core throws on
|
|
94
|
+
* `null`, dropping every other frame in its chunk, and ignores a number,
|
|
95
|
+
* string or boolean without a reply), an object whose `method` is not a
|
|
96
|
+
* string and whose `id` is absent or `null` (core throws on it too), and
|
|
97
|
+
* an object with neither `method` nor `id`, which is neither a request nor
|
|
98
|
+
* a response. Everything else goes to core: an array, which core answers
|
|
99
|
+
* `-32600` itself because it serves no batches; an object with an `id`
|
|
100
|
+
* and no `method`, which is a response and gets no reply; and a request
|
|
101
|
+
* with an `id`, which core answers even when its `method` is malformed.
|
|
102
|
+
*
|
|
103
|
+
* Give each server a fresh layer memo map. Core's stdio protocol layer
|
|
104
|
+
* is a shared constant, so a second `McpStdio.layer` server whose build
|
|
105
|
+
* sees the first one's memo map shares its protocol: only the first
|
|
106
|
+
* server reads stdin, and the second never answers. Merging both into
|
|
107
|
+
* one graph does that, and so does building or providing the second
|
|
108
|
+
* anywhere under the first one's `Effect.provide`: nested `Layer.build`
|
|
109
|
+
* and `Effect.provide` fork the ambient memo map rather than starting a
|
|
110
|
+
* new one. Isolate each server with its own `ManagedRuntime`,
|
|
111
|
+
* `Effect.provide(layer, { local: true })` or its own process.
|
|
112
|
+
*
|
|
113
|
+
* Code after a completed `Effect.provide` of a stdio server never runs:
|
|
114
|
+
* core's stdio protocol interrupts the fiber that built it when its stdin
|
|
115
|
+
* loop ends, which closing the provide's scope does. In a test, serve the
|
|
116
|
+
* layer through `McpHarness` from `@effected/mcp/testing` instead.
|
|
117
|
+
*
|
|
118
|
+
* Each call mints a fresh layer; bind the result to a `const` or the
|
|
119
|
+
* server builds twice.
|
|
120
|
+
*/
|
|
121
|
+
static readonly layer: (options: McpStdioOptions) => Layer.Layer<McpServer.McpServer | McpSchema.McpServerClient, never, Stdio.Stdio>;
|
|
122
|
+
/**
|
|
123
|
+
* `Layer.launch`, with any failure other than an interrupt logged on
|
|
124
|
+
* stderr here and re-raised marked as already reported, keeping its exit
|
|
125
|
+
* code.
|
|
126
|
+
*
|
|
127
|
+
* @remarks
|
|
128
|
+
* `runMain` logs an unhandled failure from outside the program, where no
|
|
129
|
+
* `Effect.provideService` reaches, and Effect's default logger writes
|
|
130
|
+
* through `console.log` unless `LogToStderr` is set. For an MCP server
|
|
131
|
+
* that is stdout, which is the wire.
|
|
132
|
+
*/
|
|
133
|
+
static readonly launch: <ROut, E, R>(layer: Layer.Layer<ROut, E, R>) => Effect.Effect<never, Error, R>;
|
|
134
|
+
/**
|
|
135
|
+
* Success or an interrupt-only exit maps to 0. Anything else goes to
|
|
136
|
+
* `Runtime.defaultTeardown`.
|
|
137
|
+
*
|
|
138
|
+
* @remarks
|
|
139
|
+
* Stdin EOF, the normal end of every session, ends in an interrupt-only
|
|
140
|
+
* exit, and so do SIGINT and SIGTERM: `runMain` interrupts the program on
|
|
141
|
+
* either signal. All three exit 0 here, not the 130 a default teardown
|
|
142
|
+
* reports for an interrupt.
|
|
143
|
+
*/
|
|
144
|
+
static readonly teardown: Runtime.Teardown;
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/ToolInputSchema.d.ts
|
|
148
|
+
/**
|
|
149
|
+
* The unknown keys found at one object level of a tool-call payload.
|
|
150
|
+
*
|
|
151
|
+
* @public
|
|
152
|
+
*/
|
|
153
|
+
interface UnknownKeysLevel {
|
|
154
|
+
/** Keys from the payload root to this object; empty at the root. Array positions are decimal strings. */
|
|
155
|
+
readonly path: ReadonlyArray<string>;
|
|
156
|
+
/** Keys the payload carries that this level does not accept, in payload order. */
|
|
157
|
+
readonly unknown: ReadonlyArray<string>;
|
|
158
|
+
/** Keys this level declares, in schema order. */
|
|
159
|
+
readonly accepted: ReadonlyArray<string>;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Options for {@link ToolInputSchema.formatUnknownKeys}.
|
|
163
|
+
*
|
|
164
|
+
* @public
|
|
165
|
+
*/
|
|
166
|
+
interface FormatUnknownKeysOptions {
|
|
167
|
+
/** The cap on each echoed key path, in UTF-16 code units. Defaults to `ToolFailure.ECHO_LIMIT`. */
|
|
168
|
+
readonly echoLimit?: number | undefined;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Pure walkers over a tool's served JSON Schema: every unknown key at every
|
|
172
|
+
* depth, a message naming them all, and an object-rooted form of a union.
|
|
173
|
+
*
|
|
174
|
+
* @remarks
|
|
175
|
+
* Core's `Tool.Strict` rejects unknown keys but reports only the first
|
|
176
|
+
* (effect `unstable/ai/McpServer.ts` decodes without `errors: "all"`), so an
|
|
177
|
+
* agent fixes one typo per round trip. These walkers run over the schema core
|
|
178
|
+
* actually serves, so what they accept matches what the tool advertises:
|
|
179
|
+
*
|
|
180
|
+
* - Only `additionalProperties: false` closes a node, as in JSON Schema; core
|
|
181
|
+
* emits it on every object node of a strict tool and `true` otherwise.
|
|
182
|
+
* - `allOf` members' properties merge; `oneOf`/`anyOf` members are chosen by an
|
|
183
|
+
* `action`, `kind`, `_tag` or `type` literal (tried in that order), or by
|
|
184
|
+
* being the only object member. A payload whose discriminant matches no
|
|
185
|
+
* member, or is missing, is skipped and left for decoding.
|
|
186
|
+
* - `items`, `prefixItems`, `$ref` into `$defs` (JSON Pointer escapes
|
|
187
|
+
* honoured), and `patternProperties` are followed; a key matched by a
|
|
188
|
+
* pattern is accepted and walked with the first matching pattern's schema.
|
|
189
|
+
* - The payload is untrusted: the walk stops descending at depth 256 rather
|
|
190
|
+
* than overflowing the stack, so a deeper unknown key goes unreported and
|
|
191
|
+
* is left for decoding. Every hop counts toward that cap: each property or
|
|
192
|
+
* array position, and also each union member selection, which adds a hop
|
|
193
|
+
* without adding a path segment.
|
|
194
|
+
*
|
|
195
|
+
* @public
|
|
196
|
+
*/
|
|
197
|
+
export declare class ToolInputSchema {
|
|
198
|
+
private constructor();
|
|
199
|
+
/** Every level of `payload` that carries a key `schema` does not accept. */
|
|
200
|
+
static readonly unknownKeys: (payload: unknown, schema: JsonSchema.JsonSchema) => ReadonlyArray<UnknownKeysLevel>;
|
|
201
|
+
/**
|
|
202
|
+
* `Unrecognized parameter(s): a, b.c. Accepted params: x, y.` per level. Each
|
|
203
|
+
* echoed key is truncated, and at most 20 keys and 20 levels are named; a
|
|
204
|
+
* trailing `(and N more levels).` counts the levels left out.
|
|
205
|
+
*/
|
|
206
|
+
static readonly formatUnknownKeys: (levels: ReadonlyArray<UnknownKeysLevel>, options?: FormatUnknownKeysOptions) => string;
|
|
207
|
+
/**
|
|
208
|
+
* `schema` with a root `$ref` inlined and a top-level union of objects
|
|
209
|
+
* sharing an `action`, `kind`, `_tag` or `type` literal rewritten to an
|
|
210
|
+
* object root carrying `type: "object"`, `oneOf` and `"x-discriminator"` —
|
|
211
|
+
* MCP requires an object root, and core dies at boot on a union root.
|
|
212
|
+
* Returned as the same value when nothing changes.
|
|
213
|
+
*/
|
|
214
|
+
static readonly objectRooted: (schema: JsonSchema.JsonSchema) => JsonSchema.JsonSchema;
|
|
215
|
+
}
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/McpToolkit.d.ts
|
|
218
|
+
/**
|
|
219
|
+
* Options for {@link McpToolkit.layer}.
|
|
220
|
+
*
|
|
221
|
+
* @public
|
|
222
|
+
*/
|
|
223
|
+
interface McpToolkitOptions {
|
|
224
|
+
/**
|
|
225
|
+
* `"all"` (the default): every tool without its own `Tool.Strict`
|
|
226
|
+
* annotation is served and decoded strict. `"annotated"`: only tools
|
|
227
|
+
* annotated `Tool.Strict` true are. In both modes an explicit annotation,
|
|
228
|
+
* true or false, always wins, and a `Tool.dynamic` tool is never
|
|
229
|
+
* re-annotated (core dies at registration on a strict dynamic tool). Only
|
|
230
|
+
* a tool that ends up strict gets the unknown-key pre-check; a lenient
|
|
231
|
+
* one is passed through untouched even if its raw schema is closed.
|
|
232
|
+
*/
|
|
233
|
+
readonly strict?: "all" | "annotated" | undefined;
|
|
234
|
+
/**
|
|
235
|
+
* Replaces the default `ToolInputSchema.formatUnknownKeys` rendering. It
|
|
236
|
+
* runs per rejected call inside the handler's effect, so a throw becomes
|
|
237
|
+
* a defect of that call alone — the client receives a JSON-RPC internal
|
|
238
|
+
* error (-32603) — never a crash of the server.
|
|
239
|
+
*/
|
|
240
|
+
readonly unknownKeyMessage?: ((levels: ReadonlyArray<UnknownKeysLevel>) => string) | undefined;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Register a toolkit exactly as core's `McpServer.toolkit` does, except that a
|
|
244
|
+
* strict tool's unknown arguments are all named, at every depth, in one
|
|
245
|
+
* response.
|
|
246
|
+
*
|
|
247
|
+
* @remarks
|
|
248
|
+
* Rejection itself is core's: a tool annotated `Tool.Strict` is served with
|
|
249
|
+
* `additionalProperties: false` on every object node and decoded with
|
|
250
|
+
* `onExcessProperty: "error"`. But core reports only the FIRST excess key
|
|
251
|
+
* (`Expected no excess property at ["extra"]`), so an agent fixes one typo
|
|
252
|
+
* per round trip and never learns about a nested one until the next call.
|
|
253
|
+
* This layer is the better report, not the rejecter: it runs core's
|
|
254
|
+
* `registerToolkit` unchanged under a registration-scoped `McpServer` whose
|
|
255
|
+
* `addTool` puts a `ToolInputSchema.unknownKeys` pre-check in front of each
|
|
256
|
+
* handler. The pre-check walks the input schema the tool actually serves
|
|
257
|
+
* (after core's own top-level `$ref` inlining), follows `$ref`, `allOf` and
|
|
258
|
+
* discriminated `oneOf`/`anyOf` members, and fails with one
|
|
259
|
+
* `McpSchema.InvalidParams` naming every unknown key path plus the accepted
|
|
260
|
+
* params, before core decodes. Core's strict decode stays behind it as the
|
|
261
|
+
* backstop. The pre-check is gated on the same predicate core uses to choose
|
|
262
|
+
* strict decoding — the registered tool's `Tool.Strict` annotation is
|
|
263
|
+
* `true` — so a lenient tool (explicit `Tool.Strict` false, a
|
|
264
|
+
* `Tool.dynamic` tool, or any unannotated tool under `"annotated"`) is
|
|
265
|
+
* never rejected here, even when its raw JSON Schema carries
|
|
266
|
+
* `additionalProperties: false`. The pre-check runs under `Effect.suspend`,
|
|
267
|
+
* so a throwing `unknownKeyMessage` dies inside the call's effect. No core
|
|
268
|
+
* internals are patched, so this survives an rc bump without re-diffing.
|
|
269
|
+
*
|
|
270
|
+
* Registration goes through core's module-level `McpServer.McpServer.layer`,
|
|
271
|
+
* shared by reference with `McpStdio.layer`'s own copy — provide both into
|
|
272
|
+
* the same graph, as with `McpServer.toolkit`. Never wrap that layer in
|
|
273
|
+
* `Layer.fresh`: tools would register into a second registry nobody serves.
|
|
274
|
+
*
|
|
275
|
+
* Each call mints a fresh layer; bind the result to a `const` or the
|
|
276
|
+
* registration runs twice.
|
|
277
|
+
*
|
|
278
|
+
* @example
|
|
279
|
+
* ```ts
|
|
280
|
+
* import { Layer } from "effect";
|
|
281
|
+
* import { McpStdio, McpToolkit } from "@effected/mcp";
|
|
282
|
+
*
|
|
283
|
+
* const ToolsLayer = McpToolkit.layer(MyToolkit).pipe(Layer.provide(MyHandlers));
|
|
284
|
+
* const ServerLayer = ToolsLayer.pipe(Layer.provideMerge(McpStdio.layer({ name: "my-server", version: "1.0.0" })));
|
|
285
|
+
* ```
|
|
286
|
+
*
|
|
287
|
+
* @public
|
|
288
|
+
*/
|
|
289
|
+
export declare class McpToolkit {
|
|
290
|
+
private constructor();
|
|
291
|
+
/**
|
|
292
|
+
* `McpServer.toolkit`'s registration layer, strict by default and naming
|
|
293
|
+
* every unknown argument in one `InvalidParams`.
|
|
294
|
+
*/
|
|
295
|
+
static readonly layer: <Tools extends Record<string, Tool.Any>>(toolkit: Toolkit.Toolkit<Tools>, options?: McpToolkitOptions) => Layer.Layer<never, never, Tool.HandlersFor<Tools> | Exclude<Tool.HandlerServices<Tools>, McpSchema.McpRequestContext>>;
|
|
296
|
+
}
|
|
297
|
+
//#endregion
|
|
298
|
+
//#region src/ToolFailure.d.ts
|
|
299
|
+
/**
|
|
300
|
+
* How a tool's declared failure should read on the wire.
|
|
301
|
+
*
|
|
302
|
+
* @remarks
|
|
303
|
+
* Core sends a declared failure that is an `Error` instance — every
|
|
304
|
+
* `Schema.TaggedError` is — as `isError: true` with `error.message` as the only
|
|
305
|
+
* text and no `structuredContent` (effect `unstable/ai/McpServer.ts`,
|
|
306
|
+
* `declaredFailureResult`). So the remediation must be folded into `message`
|
|
307
|
+
* when the error is constructed: whatever is not in the message never reaches
|
|
308
|
+
* the agent.
|
|
309
|
+
*
|
|
310
|
+
* Spread {@link ToolFailure.fields} into your tool's `Schema.TaggedError`, build
|
|
311
|
+
* `message` with {@link ToolFailure.message}, and pass every caller-supplied
|
|
312
|
+
* value through {@link ToolFailure.truncate} before echoing it.
|
|
313
|
+
*
|
|
314
|
+
* @example
|
|
315
|
+
* ```ts
|
|
316
|
+
* class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { ...ToolFailure.fields, id: Schema.String }) {}
|
|
317
|
+
* const remediation = { hint: "List the ids first.", suggestedTool: "list_things" }
|
|
318
|
+
* new NotFound({ id, remediation, message: ToolFailure.message(`No thing "${ToolFailure.truncate(id)}".`, remediation) })
|
|
319
|
+
* ```
|
|
320
|
+
*
|
|
321
|
+
* @public
|
|
322
|
+
*/
|
|
323
|
+
export declare class ToolFailure {
|
|
324
|
+
private constructor();
|
|
325
|
+
/** The cap, in UTF-16 code units, on a caller-supplied value echoed into a message. */
|
|
326
|
+
static readonly ECHO_LIMIT: number;
|
|
327
|
+
/** The larger cap for a value the engine produced (a path, a diagnostic), not one a caller typed. */
|
|
328
|
+
static readonly ENGINE_ECHO_LIMIT: number;
|
|
329
|
+
/** The `message` and `remediation` fields to spread into a consumer's `Schema.TaggedError`. */
|
|
330
|
+
static readonly fields: {
|
|
331
|
+
readonly message: Schema.String;
|
|
332
|
+
readonly remediation: Schema.Struct<{
|
|
333
|
+
readonly hint: Schema.String;
|
|
334
|
+
readonly suggestedTool: Schema.optionalKey<Schema.String>;
|
|
335
|
+
readonly suggestedArgs: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Unknown>>;
|
|
336
|
+
}>;
|
|
337
|
+
};
|
|
338
|
+
/**
|
|
339
|
+
* `"<raw> <hint> Try <suggestedTool>."`, dropping any empty part, so a
|
|
340
|
+
* missing tool or an empty hint never leaves a double space.
|
|
341
|
+
*/
|
|
342
|
+
static readonly message: (raw: string, remediation: Remediation) => string;
|
|
343
|
+
/**
|
|
344
|
+
* `value` cut to `limit` code units with a trailing ellipsis, backing off one
|
|
345
|
+
* unit rather than splitting a surrogate pair. A hostile multi-megabyte
|
|
346
|
+
* argument is otherwise echoed into the agent's context whole.
|
|
347
|
+
*/
|
|
348
|
+
static readonly truncate: (value: string, limit?: number) => string;
|
|
349
|
+
}
|
|
350
|
+
//#endregion
|
|
351
|
+
export type { FormatUnknownKeysOptions, McpStdioOptions, McpToolkitOptions, UnknownKeysLevel };
|
|
352
|
+
//# sourceMappingURL=index.d.ts.map
|
package/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Runtime } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/LaunchFailed.ts
|
|
4
|
+
/**
|
|
5
|
+
* The failure `McpStdio.launch` re-raises after reporting a launch failure on
|
|
6
|
+
* stderr itself. `errorReported: false` keeps `runMain` from reporting it a
|
|
7
|
+
* second time — through a logger that writes to stdout — and the original exit
|
|
8
|
+
* code rides along for the teardown.
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
var LaunchFailed = class extends Error {
|
|
13
|
+
[Runtime.errorReported] = false;
|
|
14
|
+
[Runtime.errorExitCode];
|
|
15
|
+
constructor(code) {
|
|
16
|
+
super(`the MCP server failed to launch (exit ${code})`);
|
|
17
|
+
this.name = "LaunchFailed";
|
|
18
|
+
this[Runtime.errorExitCode] = code;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
//#endregion
|
|
23
|
+
export { LaunchFailed };
|