@effected/mcp-kit 0.0.0-stage → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/McpGuard.js +81 -0
- package/McpHarness.js +357 -0
- package/McpProbe.js +94 -0
- package/McpProcess.js +196 -0
- package/McpStdio.js +175 -0
- package/McpTestFailure.js +49 -0
- package/McpToolAudit.js +109 -0
- package/McpToolkit.js +298 -0
- package/README.md +574 -2
- package/StdioCapture.js +146 -0
- package/ToolFailure.js +75 -0
- package/ToolInputSchema.js +180 -0
- package/ToolOutcome.js +119 -0
- package/ToolOutputSchema.js +65 -0
- package/ToolRefusal.js +57 -0
- package/ToolReplay.js +136 -0
- package/guard.d.ts +189 -0
- package/guard.js +3 -0
- package/index.d.ts +1048 -0
- package/index.js +10 -0
- package/internal/LaunchFailed.js +34 -0
- package/internal/StdinFrames.js +201 -0
- package/internal/guardLaunch.js +13 -0
- package/internal/wire.js +65 -0
- package/node.d.ts +126 -0
- package/node.js +3 -0
- package/package.json +60 -5
- package/testing.d.ts +519 -0
- package/testing.js +7 -0
- package/tsdoc-metadata.json +11 -0
package/StdioCapture.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Effect, Fiber } from "effect";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { Writable } from "node:stream";
|
|
4
|
+
|
|
5
|
+
// src/StdioCapture.ts
|
|
6
|
+
/**
|
|
7
|
+
* One registry per process, shared by every installed copy of this package
|
|
8
|
+
* through a `Symbol.for` key on `globalThis`, so two copies never stack
|
|
9
|
+
* patches or split the context they route on.
|
|
10
|
+
*/
|
|
11
|
+
const registry = (() => {
|
|
12
|
+
const key = Symbol.for("@effected/mcp-kit/StdioCapture");
|
|
13
|
+
const global = globalThis;
|
|
14
|
+
global[key] ??= {
|
|
15
|
+
storage: new AsyncLocalStorage(),
|
|
16
|
+
originals: /* @__PURE__ */ new WeakMap()
|
|
17
|
+
};
|
|
18
|
+
return global[key];
|
|
19
|
+
})();
|
|
20
|
+
/** Patches `stream` once and returns its original `write`; a stream already patched keeps its first patch. */
|
|
21
|
+
const patch = (stream, pick) => {
|
|
22
|
+
const known = registry.originals.get(stream);
|
|
23
|
+
if (known !== void 0) return known;
|
|
24
|
+
const original = stream.write;
|
|
25
|
+
registry.originals.set(stream, original);
|
|
26
|
+
const { storage } = registry;
|
|
27
|
+
stream.write = function capturedWrite() {
|
|
28
|
+
const options = storage.getStore();
|
|
29
|
+
const sink = options === void 0 ? void 0 : pick(options);
|
|
30
|
+
return sink === void 0 ? original.apply(this, arguments) : sink.write.apply(sink, arguments);
|
|
31
|
+
};
|
|
32
|
+
return original;
|
|
33
|
+
};
|
|
34
|
+
const callbackOf = (rest) => {
|
|
35
|
+
const last = rest.at(-1);
|
|
36
|
+
return typeof last === "function" ? last : void 0;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Diverts a host's stdout (and optionally stderr) writes to a sink, for code
|
|
40
|
+
* running inside one async context only, while every other context keeps
|
|
41
|
+
* writing to the real streams.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* For an MCP server over stdio, stdout is the JSON-RPC wire. A chatty library
|
|
45
|
+
* run in-process (a test runner, a bundler, a linter) must keep its output off
|
|
46
|
+
* it while other handlers, and the transport itself, keep writing normally.
|
|
47
|
+
*
|
|
48
|
+
* `install` patches the host's `write` methods once, keeping the unbound
|
|
49
|
+
* originals, and routes every write through an `AsyncLocalStorage`: inside a
|
|
50
|
+
* {@link StdioCapture.run} or {@link StdioCapture.capture} context a write goes
|
|
51
|
+
* to that context's sink, and outside it to the original stream.
|
|
52
|
+
*
|
|
53
|
+
* An Effect fiber resumes in the async context it suspended in, not the
|
|
54
|
+
* context of whatever woke it, so a server fiber woken by code inside a
|
|
55
|
+
* capture (its response written to a queue, say) keeps writing to the wire.
|
|
56
|
+
*
|
|
57
|
+
* Two kinds of write escape a capture and reach the original stream:
|
|
58
|
+
*
|
|
59
|
+
* - **Writes from a context the capture lost.** Callbacks registered outside the
|
|
60
|
+
* capture keep the context they were registered in: native addons, worker
|
|
61
|
+
* thread output relayed by the main thread, listeners on a child process's
|
|
62
|
+
* pipes attached earlier. On stdout, that is the wire.
|
|
63
|
+
* - **Fibers started inside the capture keep capturing after it ends.** A fiber
|
|
64
|
+
* forked inside it (`Effect.forkDetach`, a memoized layer first built inside
|
|
65
|
+
* it, a background fiber a library starts) keeps writing to the sink, even
|
|
66
|
+
* after the capture's own work is done.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* import { StdioCapture } from "@effected/mcp-kit/node"
|
|
71
|
+
*
|
|
72
|
+
* const capture = StdioCapture.install(process)
|
|
73
|
+
* // in a tool handler: keep the runner's own console output off the wire
|
|
74
|
+
* const report = yield* runTests(files).pipe(capture.capture({ stdout: capture.toStderr }))
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* @public
|
|
78
|
+
*/
|
|
79
|
+
var StdioCapture = class StdioCapture {
|
|
80
|
+
/** A sink that writes through the host's original, unpatched stderr: captured output stays visible off the wire. */
|
|
81
|
+
toStderr;
|
|
82
|
+
constructor(toStderr) {
|
|
83
|
+
this.toStderr = toStderr;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Patches `host`'s two streams. Idempotent per stream, across every installed
|
|
87
|
+
* copy of this package: a stream already patched (through another host
|
|
88
|
+
* object, or another copy) keeps its one patch and its original `write`.
|
|
89
|
+
*/
|
|
90
|
+
static install = (host) => {
|
|
91
|
+
patch(host.stdout, (options) => options.stdout);
|
|
92
|
+
const stderr = patch(host.stderr, (options) => options.stderr);
|
|
93
|
+
return new StdioCapture({ write: (...args) => stderr.apply(host.stderr, args) });
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* A write-only sink that drops every write and calls the write's callback.
|
|
97
|
+
*
|
|
98
|
+
* @remarks
|
|
99
|
+
* It is a {@link StdioSink}, not a stream: it has no `on`, `pipe` target
|
|
100
|
+
* protocol or `setMaxListeners`, so it cannot stand in where a library
|
|
101
|
+
* expects a `Writable` (a test runner's logger stream that worker output
|
|
102
|
+
* is piped into, for one). Use {@link StdioCapture.nullWritable} there; it
|
|
103
|
+
* serves as a capture sink too.
|
|
104
|
+
*/
|
|
105
|
+
static discard = { write: (_chunk, ...rest) => {
|
|
106
|
+
const callback = callbackOf(rest);
|
|
107
|
+
if (callback !== void 0) queueMicrotask(callback);
|
|
108
|
+
return true;
|
|
109
|
+
} };
|
|
110
|
+
/**
|
|
111
|
+
* A new Node `Writable` that drops everything written to it: a real stream,
|
|
112
|
+
* so it works as a `pipe` target, takes `setMaxListeners`, emits `drain`
|
|
113
|
+
* and `finish`, and is also a {@link StdioSink} for a capture.
|
|
114
|
+
*
|
|
115
|
+
* @remarks
|
|
116
|
+
* A factory, not a shared value: a stream ends once, and a source piped in
|
|
117
|
+
* with the default `{ end: true }` ends its destination. Build one per
|
|
118
|
+
* consumer.
|
|
119
|
+
*/
|
|
120
|
+
static nullWritable = () => new Writable({
|
|
121
|
+
decodeStrings: false,
|
|
122
|
+
write: (_chunk, _encoding, callback) => callback()
|
|
123
|
+
});
|
|
124
|
+
/**
|
|
125
|
+
* Runs `evaluate` with its writes captured, for code that is not Effect.
|
|
126
|
+
* A synchronous throw from `evaluate` rejects the returned promise.
|
|
127
|
+
*/
|
|
128
|
+
run(options, evaluate) {
|
|
129
|
+
return registry.storage.run(options, async () => evaluate());
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Runs an effect with its writes captured: on its own fiber, started inside
|
|
133
|
+
* the capture's context, so the context survives every suspension of that
|
|
134
|
+
* fiber. Interrupting the returned effect interrupts that fiber and waits
|
|
135
|
+
* for it.
|
|
136
|
+
*/
|
|
137
|
+
capture(options) {
|
|
138
|
+
const { storage } = registry;
|
|
139
|
+
return (self) => Effect.uninterruptibleMask((restore) => Effect.withFiber((parent) => {
|
|
140
|
+
const fiber = storage.run(options, () => Effect.runForkWith(parent.context)(self));
|
|
141
|
+
return Effect.ensuring(restore(Fiber.join(fiber)), Fiber.interrupt(fiber));
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export { StdioCapture };
|
package/ToolFailure.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { Remediation } from "@effected/kit";
|
|
3
|
+
|
|
4
|
+
// 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 `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. For a plain
|
|
20
|
+
* refusal with no fields of its own, `ToolRefusal` is this shape ready-made.
|
|
21
|
+
*
|
|
22
|
+
* Only a declared failure keeps its message: core scrubs an undeclared
|
|
23
|
+
* failure or a defect to "Tool execution failed due to an internal server
|
|
24
|
+
* error.", so audit every throw path a tool can take and declare each one the
|
|
25
|
+
* agent should read.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { ToolFailure } from "@effected/mcp-kit";
|
|
30
|
+
* import { Schema } from "effect";
|
|
31
|
+
*
|
|
32
|
+
* class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { ...ToolFailure.fields, id: Schema.String }) {}
|
|
33
|
+
* const remediation = { hint: "List the ids first.", suggestedTool: "list_things" }
|
|
34
|
+
* new NotFound({ id, remediation, message: ToolFailure.message(`No thing "${ToolFailure.truncate(id)}".`, remediation) })
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @public
|
|
38
|
+
*/
|
|
39
|
+
var ToolFailure = class ToolFailure {
|
|
40
|
+
constructor() {}
|
|
41
|
+
/** The cap, in UTF-16 code units, on a caller-supplied value echoed into a message. */
|
|
42
|
+
static ECHO_LIMIT = 200;
|
|
43
|
+
/** The larger cap for a value the engine produced (a path, a diagnostic), not one a caller typed. */
|
|
44
|
+
static ENGINE_ECHO_LIMIT = 2e3;
|
|
45
|
+
/**
|
|
46
|
+
* The `message` and `remediation` fields to spread into a consumer's
|
|
47
|
+
* `Schema.TaggedError`, each carrying a default `description` that a served
|
|
48
|
+
* JSON Schema (a tool's `outputSchema`) shows the agent.
|
|
49
|
+
*/
|
|
50
|
+
static fields = {
|
|
51
|
+
message: Schema.String.annotate({ description: "What went wrong, as one plain sentence." }),
|
|
52
|
+
remediation: Remediation.annotate({ description: "What to do next: a hint, and optionally the tool to call next and the arguments to call it with." })
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* `"<raw> <hint> Try <suggestedTool>."`, dropping any empty part, so a
|
|
56
|
+
* missing tool or an empty hint never leaves a double space.
|
|
57
|
+
*/
|
|
58
|
+
static message = (raw, remediation) => [
|
|
59
|
+
raw,
|
|
60
|
+
remediation.hint,
|
|
61
|
+
remediation.suggestedTool === void 0 ? "" : `Try ${remediation.suggestedTool}.`
|
|
62
|
+
].filter((part) => part !== "").join(" ");
|
|
63
|
+
/**
|
|
64
|
+
* `value` cut to `limit` code units with a trailing ellipsis, backing off one
|
|
65
|
+
* unit rather than splitting a surrogate pair. A hostile multi-megabyte
|
|
66
|
+
* argument is otherwise echoed into the agent's context whole.
|
|
67
|
+
*/
|
|
68
|
+
static truncate = (value, limit = ToolFailure.ECHO_LIMIT) => {
|
|
69
|
+
if (value.length <= limit) return value;
|
|
70
|
+
const end = limit > 0 && isHighSurrogate(value.charCodeAt(limit - 1)) ? limit - 1 : limit;
|
|
71
|
+
return `${value.slice(0, end)}…`;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export { ToolFailure };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { ToolFailure } from "./ToolFailure.js";
|
|
2
|
+
import { Result } from "effect";
|
|
3
|
+
|
|
4
|
+
// 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) {
|
|
81
|
+
const acceptedPatterns = patterns.map(([pattern]) => pattern);
|
|
82
|
+
out.push({
|
|
83
|
+
path,
|
|
84
|
+
unknown: extra,
|
|
85
|
+
accepted,
|
|
86
|
+
...acceptedPatterns.length > 0 ? { acceptedPatterns } : {}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
for (const key of accepted) if (Object.hasOwn(value, key)) walk(value[key], properties[key], [...path, key], depth + 1);
|
|
90
|
+
for (const [key, schema] of patterned) walk(value[key], schema, [...path, key], depth + 1);
|
|
91
|
+
if (additional !== void 0) for (const key of extra) walk(value[key], additional, [...path, key], depth + 1);
|
|
92
|
+
};
|
|
93
|
+
walk(payload, root, [], 0);
|
|
94
|
+
return out;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Pure walkers over a tool's served JSON Schema: every unknown key at every
|
|
98
|
+
* depth, a message naming them all, and an object-rooted form of a union.
|
|
99
|
+
*
|
|
100
|
+
* @remarks
|
|
101
|
+
* For a `Tool.make` tool, core's strict decode already names every unknown
|
|
102
|
+
* key, with every missing or invalid field, in one response. Core never
|
|
103
|
+
* validates a `Tool.dynamic` tool's raw JSON Schema, so these walkers are
|
|
104
|
+
* for that case: a handler runs them against the schema it registered.
|
|
105
|
+
* They run over the schema as served, so what they accept matches what the
|
|
106
|
+
* tool advertises:
|
|
107
|
+
*
|
|
108
|
+
* - Only `additionalProperties: false` closes a node, as in JSON Schema; core
|
|
109
|
+
* emits it on every object node of a strict tool and `true` otherwise.
|
|
110
|
+
* - `allOf` members' properties merge; `oneOf`/`anyOf` members are chosen by an
|
|
111
|
+
* `action`, `kind`, `_tag` or `type` literal (tried in that order), or by
|
|
112
|
+
* being the only object member. A payload whose discriminant matches no
|
|
113
|
+
* member, or is missing, is skipped and left for decoding.
|
|
114
|
+
* - `items`, `prefixItems`, `$ref` into `$defs` (JSON Pointer escapes
|
|
115
|
+
* honoured), and `patternProperties` are followed; a key matched by a
|
|
116
|
+
* pattern is accepted and walked with the first matching pattern's schema.
|
|
117
|
+
* Effect serves a pattern-keyed `Schema.Record` with `patternProperties`
|
|
118
|
+
* only when the key pattern's RegExp has the `u` flag; without it the
|
|
119
|
+
* record is served open, and every key is accepted.
|
|
120
|
+
* - The payload is untrusted: the walk stops descending at depth 256 rather
|
|
121
|
+
* than overflowing the stack, so a deeper unknown key goes unreported and
|
|
122
|
+
* is left for decoding. Every hop counts toward that cap: each property or
|
|
123
|
+
* array position, and also each union member selection, which adds a hop
|
|
124
|
+
* without adding a path segment.
|
|
125
|
+
*
|
|
126
|
+
* @public
|
|
127
|
+
*/
|
|
128
|
+
var ToolInputSchema = class {
|
|
129
|
+
constructor() {}
|
|
130
|
+
/** Every level of `payload` that carries a key `schema` does not accept. */
|
|
131
|
+
static unknownKeys = (payload, schema) => collect(payload, schema);
|
|
132
|
+
/**
|
|
133
|
+
* `Unrecognized parameter(s): a, b.c. Accepted params: x, y.` per level,
|
|
134
|
+
* followed by `Accepted keys matching: ^x-.` when the level also accepts
|
|
135
|
+
* keys by pattern. A level that accepts keys only by pattern names its
|
|
136
|
+
* patterns alone, never `Accepted params: (none).` Each echoed key and
|
|
137
|
+
* pattern is truncated, and at most 20 keys, 20 patterns and 20 levels are
|
|
138
|
+
* named; a trailing `(and N more levels).` counts the levels left out.
|
|
139
|
+
*/
|
|
140
|
+
static formatUnknownKeys = (levels, options = {}) => {
|
|
141
|
+
const limit = options.echoLimit ?? ToolFailure.ECHO_LIMIT;
|
|
142
|
+
const sentences = levels.slice(0, MAX_ECHOED).map((level) => {
|
|
143
|
+
const shown = level.unknown.slice(0, MAX_ECHOED).map((key) => ToolFailure.truncate([...level.path, key].join("."), limit));
|
|
144
|
+
const more = level.unknown.length > MAX_ECHOED ? ` (and ${level.unknown.length - MAX_ECHOED} more)` : "";
|
|
145
|
+
const patterns = (level.acceptedPatterns ?? []).slice(0, MAX_ECHOED).map((pattern) => ToolFailure.truncate(pattern, limit));
|
|
146
|
+
const params = level.accepted.length > 0 ? ` Accepted params: ${level.accepted.join(", ")}.` : patterns.length === 0 ? " Accepted params: (none)." : "";
|
|
147
|
+
const matching = patterns.length > 0 ? ` Accepted keys matching: ${patterns.join(", ")}.` : "";
|
|
148
|
+
return `Unrecognized parameter(s): ${shown.join(", ")}${more}.${params}${matching}`;
|
|
149
|
+
});
|
|
150
|
+
const hidden = levels.length > MAX_ECHOED ? ` (and ${levels.length - MAX_ECHOED} more levels).` : "";
|
|
151
|
+
return `${sentences.join(" ")}${hidden}`;
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* `schema` with a root `$ref` inlined and a top-level union of objects
|
|
155
|
+
* sharing an `action`, `kind`, `_tag` or `type` literal rewritten to an
|
|
156
|
+
* object root carrying `type: "object"`, `oneOf` and `"x-discriminator"` —
|
|
157
|
+
* MCP requires an object root, and core dies at boot on a union root.
|
|
158
|
+
* Returned as the same value when nothing changes.
|
|
159
|
+
*/
|
|
160
|
+
static objectRooted = (schema) => {
|
|
161
|
+
const root = resolveRef(schema, schema);
|
|
162
|
+
const members = Array.isArray(root.anyOf) ? root.anyOf : Array.isArray(root.oneOf) ? root.oneOf : void 0;
|
|
163
|
+
if (root.type === void 0 && members !== void 0) {
|
|
164
|
+
const resolved = members.filter(isNode).map((member) => resolveRef(member, schema));
|
|
165
|
+
const key = resolved.length === members.length ? discriminantOf(resolved) : void 0;
|
|
166
|
+
if (key !== void 0) {
|
|
167
|
+
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = root;
|
|
168
|
+
return {
|
|
169
|
+
type: "object",
|
|
170
|
+
...rest,
|
|
171
|
+
oneOf: resolved,
|
|
172
|
+
"x-discriminator": key
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return root;
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export { ToolInputSchema };
|
package/ToolOutcome.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { ToolFailure } from "./ToolFailure.js";
|
|
2
|
+
import { ToolOutputSchema } from "./ToolOutputSchema.js";
|
|
3
|
+
import { Effect, Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
// src/ToolOutcome.ts
|
|
6
|
+
/**
|
|
7
|
+
* The `ok: false` structured-outcome envelope: an expected error an agent
|
|
8
|
+
* should branch on, returned on a tool's success channel so it reaches the
|
|
9
|
+
* client as `structuredContent` instead of message text.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* Two channels, two jobs, one error shape:
|
|
13
|
+
*
|
|
14
|
+
* - {@link ToolRefusal} is a declared failure: the call itself was wrong, and
|
|
15
|
+
* the agent reads one sentence (`isError: true`, message text only; core
|
|
16
|
+
* never sends a failure's `structuredContent`).
|
|
17
|
+
* - `ToolOutcome` is an expected domain outcome: an id that does not exist, a
|
|
18
|
+
* transition that is not allowed. The agent branches on its `_tag` and
|
|
19
|
+
* fields (`isError: false`, `structuredContent` carries the envelope).
|
|
20
|
+
*
|
|
21
|
+
* Both carry {@link ToolFailure.fields} (`message`, `remediation`), so an error
|
|
22
|
+
* can move between channels without reshaping.
|
|
23
|
+
*
|
|
24
|
+
* Success members are not reshaped. Spread {@link ToolOutcome.ok} into each
|
|
25
|
+
* success struct so a client can discriminate on `ok`; a member that
|
|
26
|
+
* discriminates another way (`found: true | false`) stays legal.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* import { ToolOutcome } from "@effected/mcp-kit"
|
|
31
|
+
* import { Effect, Schema } from "effect"
|
|
32
|
+
* import { Tool } from "effect/ai"
|
|
33
|
+
*
|
|
34
|
+
* const NotFound = ToolOutcome.error("GoalNotFound", { id: Schema.Number })
|
|
35
|
+
* const Lookup = Tool.make("goal", {
|
|
36
|
+
* parameters: Schema.Struct({ id: Schema.Number }),
|
|
37
|
+
* success: ToolOutcome.envelope(Schema.Struct({ ...ToolOutcome.ok, goal: Goal }), [NotFound]),
|
|
38
|
+
* })
|
|
39
|
+
* // in the handler:
|
|
40
|
+
* findGoal(id).pipe(
|
|
41
|
+
* Effect.map((goal) => ({ ok: true as const, goal })),
|
|
42
|
+
* ToolOutcome.catchAsEnvelope({
|
|
43
|
+
* GoalNotFound: (e) => ({ id: e.id, message: `No goal ${e.id}.`, remediation: { hint: "List goals first.", suggestedTool: "goal" } }),
|
|
44
|
+
* }),
|
|
45
|
+
* )
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @public
|
|
49
|
+
*/
|
|
50
|
+
var ToolOutcome = class ToolOutcome {
|
|
51
|
+
constructor() {}
|
|
52
|
+
/** `{ ok: true }` as schema fields, to spread into each success member. */
|
|
53
|
+
static ok = { ok: Schema.Literal(true).annotate({ description: "true: the call succeeded." }) };
|
|
54
|
+
/**
|
|
55
|
+
* The schema of one envelope error: `_tag`, {@link ToolFailure.fields} and
|
|
56
|
+
* `fields`. A field named `_tag`, `message` or `remediation` is rejected by
|
|
57
|
+
* the type, because it would replace the shared one.
|
|
58
|
+
*/
|
|
59
|
+
static error = (tag, fields) => Schema.Struct({
|
|
60
|
+
_tag: Schema.Literal(tag).annotate({ description: "The error's tag: the value to branch on." }),
|
|
61
|
+
...ToolFailure.fields,
|
|
62
|
+
...fields
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
65
|
+
* A tool's success schema: `success`, or `{ ok: false, error }` where `error`
|
|
66
|
+
* is one of `errors`. Object-rooted with {@link ToolOutputSchema.objectRooted},
|
|
67
|
+
* so core serves its `outputSchema` on every protocol revision.
|
|
68
|
+
*
|
|
69
|
+
* @remarks
|
|
70
|
+
* The failure member, its `ok` and its `error` carry default descriptions.
|
|
71
|
+
* `options` names the member (`identifier`, served once under `$defs`) or
|
|
72
|
+
* replaces its description.
|
|
73
|
+
*/
|
|
74
|
+
static envelope = (success, errors, options) => ToolOutputSchema.objectRooted(Schema.Union([success, Schema.Struct({
|
|
75
|
+
ok: Schema.Literal(false).annotate({ description: "false: the call hit an expected error, described in error." }),
|
|
76
|
+
error: Schema.Union(errors).annotate({ description: "The expected error. Branch on its _tag." })
|
|
77
|
+
}).annotate({
|
|
78
|
+
description: options?.description ?? "An expected error the caller should branch on, returned in place of a result.",
|
|
79
|
+
...options?.identifier === void 0 ? {} : { identifier: options.identifier }
|
|
80
|
+
})]));
|
|
81
|
+
/** One `ok: false` value, for a handler that decides on a failure without catching an error. */
|
|
82
|
+
static failure = (tag, error) => ({
|
|
83
|
+
ok: false,
|
|
84
|
+
error: {
|
|
85
|
+
...error,
|
|
86
|
+
_tag: tag
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
/**
|
|
90
|
+
* Turns each handled tagged error into its `ok: false` value on the success
|
|
91
|
+
* channel; every other error stays in the error channel. Typed like core's
|
|
92
|
+
* `Effect.catchTags`: a case for a tag the effect cannot fail with is a type
|
|
93
|
+
* error, and the handled tags leave `E`.
|
|
94
|
+
*
|
|
95
|
+
* @remarks
|
|
96
|
+
* Write it inline in the effect's `pipe`. `E` is inferred from the effect
|
|
97
|
+
* being piped, so `effect.pipe(ToolOutcome.catchAsEnvelope({ … }))` checks
|
|
98
|
+
* every case name against the effect's real tags. Bound to a `const` first,
|
|
99
|
+
* the combinator never sees that effect: `E` infers as `unknown`, the
|
|
100
|
+
* unknown-tag check switches off, and a misspelled tag compiles and never
|
|
101
|
+
* matches. Core's `catchTags` looks a case up only among `cases`' own keys,
|
|
102
|
+
* so an error tagged `"toString"` or `"constructor"` never hits an inherited
|
|
103
|
+
* `Object.prototype` member and stays in the error channel.
|
|
104
|
+
*/
|
|
105
|
+
static catchAsEnvelope = (cases) => (self) => {
|
|
106
|
+
const handlers = {};
|
|
107
|
+
for (const [tag, handle] of Object.entries(cases)) handlers[tag] = (error) => Effect.succeed(ToolOutcome.failure(tag, handle(error)));
|
|
108
|
+
return Effect.catchTags(self, handlers);
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Whether `value` is an `ok: false` envelope member: an object whose `ok` is
|
|
112
|
+
* exactly `false`. A success member that discriminates some other way, or a
|
|
113
|
+
* non-object, is not a failure. Narrows to `{ ok: false }` only: it checks
|
|
114
|
+
* the discriminant, not the `error` payload.
|
|
115
|
+
*/
|
|
116
|
+
static isFailure = (value) => typeof value === "object" && value !== null && value.ok === false;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export { ToolOutcome };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Schema, SchemaAST } from "effect";
|
|
2
|
+
|
|
3
|
+
// src/ToolOutputSchema.ts
|
|
4
|
+
const MARK = "~effected/mcp/objectRooted";
|
|
5
|
+
/** Always passes; contributes `type: "object"` beside whatever the node already emits. */
|
|
6
|
+
const objectRoot = Schema.makeFilter(() => true, {
|
|
7
|
+
[MARK]: true,
|
|
8
|
+
toJsonSchema: () => ({ type: "object" })
|
|
9
|
+
});
|
|
10
|
+
const isObjectRooted = (ast) => ast.checks?.some((check) => check.annotations?.[MARK] === true) === true;
|
|
11
|
+
/**
|
|
12
|
+
* Shaping for a tool's `success` schema, so the `outputSchema` core serves
|
|
13
|
+
* is one every client accepts.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* MCP requires a tool's `outputSchema` to be rooted in an object, and a
|
|
17
|
+
* top-level `Schema.Union` emits a bare `anyOf`. Core drops such an
|
|
18
|
+
* `outputSchema` from `tools/list` on the stateful revisions and serves it
|
|
19
|
+
* verbatim on the stateless one, where strict clients reject it.
|
|
20
|
+
* `McpToolAudit`'s `objectRootedOutput` check reports it either way.
|
|
21
|
+
*
|
|
22
|
+
* {@link ToolOutputSchema.objectRooted} is the fix when every member of the
|
|
23
|
+
* union is an object shape; never apply it to a union with a primitive or
|
|
24
|
+
* array member. It works on the Effect
|
|
25
|
+
* schema, not on JSON Schema — unlike `ToolInputSchema.objectRooted`, which
|
|
26
|
+
* rewrites the served input document of a `Tool.dynamic`.
|
|
27
|
+
*
|
|
28
|
+
* @public
|
|
29
|
+
*/
|
|
30
|
+
var ToolOutputSchema = class {
|
|
31
|
+
constructor() {}
|
|
32
|
+
/**
|
|
33
|
+
* `schema` with `type: "object"` added beside the `anyOf` at its JSON
|
|
34
|
+
* Schema root, through a check that always passes. Decoding, encoding and
|
|
35
|
+
* the schema's type are unchanged.
|
|
36
|
+
*
|
|
37
|
+
* @remarks
|
|
38
|
+
* Order against `.annotate({ identifier })` does not matter. A check added
|
|
39
|
+
* after an identifier starts a new node that would drop the definition
|
|
40
|
+
* name, so the identifier the schema already resolves is carried onto the
|
|
41
|
+
* new check, and the served documents are equal either way (key order
|
|
42
|
+
* can differ when the schema carries a title or description). Applying it
|
|
43
|
+
* twice is a no-op.
|
|
44
|
+
*
|
|
45
|
+
* Every union member must be an object shape: the added `type: "object"`
|
|
46
|
+
* is a claim about the whole union, and nothing checks it. Do not use it
|
|
47
|
+
* on a union with a primitive or array member — the served schema would
|
|
48
|
+
* contradict the values the tool returns; give such a tool an object
|
|
49
|
+
* envelope instead.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* const Result = ToolOutputSchema.objectRooted(Schema.Union([Found, Missing])).annotate({ identifier: "Result" })
|
|
54
|
+
* Tool.make("lookup", { parameters, success: Result })
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
static objectRooted = (schema) => {
|
|
58
|
+
if (isObjectRooted(schema.ast)) return schema;
|
|
59
|
+
const identifier = SchemaAST.resolveIdentifier(schema.ast);
|
|
60
|
+
const checked = schema.check(objectRoot);
|
|
61
|
+
return identifier === void 0 ? checked : checked.annotate({ identifier });
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export { ToolOutputSchema };
|
package/ToolRefusal.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ToolFailure } from "./ToolFailure.js";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
// src/ToolRefusal.ts
|
|
5
|
+
/**
|
|
6
|
+
* A tool call refused for a reason the caller can fix: the kit's ready-made
|
|
7
|
+
* declared failure, built on {@link ToolFailure.fields}.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Core scrubs every undeclared failure and every defect to one generic
|
|
11
|
+
* internal-error sentence, so a refusal the agent must act on (an unknown id,
|
|
12
|
+
* a missing argument, a precondition) reaches it only when the tool declares
|
|
13
|
+
* it. Declare `ToolRefusal` in the tool's `failure` schema and fail with
|
|
14
|
+
* {@link ToolRefusal.refuse}; its message already carries the remediation,
|
|
15
|
+
* because a declared `Error` failure sends `message` and nothing else.
|
|
16
|
+
*
|
|
17
|
+
* Audit every throw path of a tool: any failure left undeclared loses its
|
|
18
|
+
* message on the wire, however carefully it was worded.
|
|
19
|
+
*
|
|
20
|
+
* **Structured data on a refusal is deliberately unreachable: put it in the
|
|
21
|
+
* message.** Core's `McpServer.registerToolkit` builds every failed call's
|
|
22
|
+
* result with `structuredContent` unset and no `_meta`, on every protocol
|
|
23
|
+
* revision. Under the default `failureMode: "error"` a declared `Error`
|
|
24
|
+
* failure sends `message` as the only text; under `failureMode: "return"`
|
|
25
|
+
* it sends the whole encoded failure JSON-stringified into the text, still
|
|
26
|
+
* with no `structuredContent`, which buries the human message in JSON a
|
|
27
|
+
* client must parse. So `refuse` takes no data argument: a list of valid
|
|
28
|
+
* names, the root that was searched, the id that was not found all belong
|
|
29
|
+
* in `reason`, truncated with {@link ToolFailure.truncate}. When an agent
|
|
30
|
+
* must branch on the data itself, return it on the success channel as an
|
|
31
|
+
* `ok: false` envelope, where `structuredContent` does reach the client.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* import { ToolFailure, ToolRefusal } from "@effected/mcp-kit";
|
|
36
|
+
* import { Tool } from "effect/ai";
|
|
37
|
+
*
|
|
38
|
+
* const Lookup = Tool.make("lookup", { parameters, success, failure: ToolRefusal })
|
|
39
|
+
* // in the handler:
|
|
40
|
+
* return yield* ToolRefusal.refuse(`No run "${ToolFailure.truncate(id)}".`, { hint: "List runs first.", suggestedTool: "list_runs" })
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* @public
|
|
44
|
+
*/
|
|
45
|
+
var ToolRefusal = class ToolRefusal extends Schema.TaggedError()("ToolRefusal", ToolFailure.fields) {
|
|
46
|
+
/**
|
|
47
|
+
* A refusal for `reason`, with `remediation` folded into its message by
|
|
48
|
+
* {@link ToolFailure.message}. `reason` must already truncate any
|
|
49
|
+
* caller-supplied value it echoes, with {@link ToolFailure.truncate}.
|
|
50
|
+
*/
|
|
51
|
+
static refuse = (reason, remediation) => new ToolRefusal({
|
|
52
|
+
message: ToolFailure.message(reason, remediation),
|
|
53
|
+
remediation
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export { ToolRefusal };
|