@effected/mcp 0.1.1 → 0.3.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/McpGuard.js +121 -0
- package/McpHarness.js +46 -10
- package/McpProbe.js +1 -1
- package/McpProcess.js +2 -2
- package/McpStdio.js +37 -21
- package/McpTestFailure.js +2 -2
- package/McpToolAudit.js +10 -5
- package/McpToolkit.js +199 -36
- package/README.md +195 -31
- package/ToolFailure.js +8 -2
- package/ToolInputSchema.js +28 -14
- package/ToolOutputSchema.js +66 -0
- package/ToolRefusal.js +42 -0
- package/guard.d.ts +157 -0
- package/guard.js +3 -0
- package/index.d.ts +331 -64
- package/index.js +3 -1
- package/internal/StdinFrames.js +61 -23
- package/internal/guardLaunch.js +14 -0
- package/package.json +9 -4
- package/testing.d.ts +44 -12
package/ToolInputSchema.js
CHANGED
|
@@ -77,11 +77,15 @@ const collect = (payload, root) => {
|
|
|
77
77
|
if (match === void 0) extra.push(key);
|
|
78
78
|
else patterned.push([key, match[1]]);
|
|
79
79
|
}
|
|
80
|
-
if (extra.length > 0 && isClosed)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
}
|
|
85
89
|
for (const key of accepted) if (Object.hasOwn(value, key)) walk(value[key], properties[key], [...path, key], depth + 1);
|
|
86
90
|
for (const [key, schema] of patterned) walk(value[key], schema, [...path, key], depth + 1);
|
|
87
91
|
if (additional !== void 0) for (const key of extra) walk(value[key], additional, [...path, key], depth + 1);
|
|
@@ -94,10 +98,12 @@ const collect = (payload, root) => {
|
|
|
94
98
|
* depth, a message naming them all, and an object-rooted form of a union.
|
|
95
99
|
*
|
|
96
100
|
* @remarks
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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:
|
|
101
107
|
*
|
|
102
108
|
* - Only `additionalProperties: false` closes a node, as in JSON Schema; core
|
|
103
109
|
* emits it on every object node of a strict tool and `true` otherwise.
|
|
@@ -108,6 +114,9 @@ const collect = (payload, root) => {
|
|
|
108
114
|
* - `items`, `prefixItems`, `$ref` into `$defs` (JSON Pointer escapes
|
|
109
115
|
* honoured), and `patternProperties` are followed; a key matched by a
|
|
110
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.
|
|
111
120
|
* - The payload is untrusted: the walk stops descending at depth 256 rather
|
|
112
121
|
* than overflowing the stack, so a deeper unknown key goes unreported and
|
|
113
122
|
* is left for decoding. Every hop counts toward that cap: each property or
|
|
@@ -121,17 +130,22 @@ var ToolInputSchema = class {
|
|
|
121
130
|
/** Every level of `payload` that carries a key `schema` does not accept. */
|
|
122
131
|
static unknownKeys = (payload, schema) => collect(payload, schema);
|
|
123
132
|
/**
|
|
124
|
-
* `Unrecognized parameter(s): a, b.c. Accepted params: x, y.` per level
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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.
|
|
127
139
|
*/
|
|
128
140
|
static formatUnknownKeys = (levels, options = {}) => {
|
|
129
141
|
const limit = options.echoLimit ?? ToolFailure.ECHO_LIMIT;
|
|
130
142
|
const sentences = levels.slice(0, MAX_ECHOED).map((level) => {
|
|
131
143
|
const shown = level.unknown.slice(0, MAX_ECHOED).map((key) => ToolFailure.truncate([...level.path, key].join("."), limit));
|
|
132
144
|
const more = level.unknown.length > MAX_ECHOED ? ` (and ${level.unknown.length - MAX_ECHOED} more)` : "";
|
|
133
|
-
const
|
|
134
|
-
|
|
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}`;
|
|
135
149
|
});
|
|
136
150
|
const hidden = levels.length > MAX_ECHOED ? ` (and ${levels.length - MAX_ECHOED} more levels).` : "";
|
|
137
151
|
return `${sentences.join(" ")}${hidden}`;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Schema, SchemaAST } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region 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
|
+
//#endregion
|
|
66
|
+
export { ToolOutputSchema };
|
package/ToolRefusal.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ToolFailure } from "./ToolFailure.js";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region 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 when porting a tool: any failure left undeclared
|
|
18
|
+
* loses its message on the wire, however carefully it was worded.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* const Lookup = Tool.make("lookup", { parameters, success, failure: ToolRefusal })
|
|
23
|
+
* // in the handler:
|
|
24
|
+
* return yield* ToolRefusal.refuse(`No run "${ToolFailure.truncate(id)}".`, { hint: "List runs first.", suggestedTool: "list_runs" })
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @public
|
|
28
|
+
*/
|
|
29
|
+
var ToolRefusal = class ToolRefusal extends Schema.TaggedError()("ToolRefusal", ToolFailure.fields) {
|
|
30
|
+
/**
|
|
31
|
+
* A refusal for `reason`, with `remediation` folded into its message by
|
|
32
|
+
* {@link ToolFailure.message}. `reason` must already truncate any
|
|
33
|
+
* caller-supplied value it echoes, with {@link ToolFailure.truncate}.
|
|
34
|
+
*/
|
|
35
|
+
static refuse = (reason, remediation) => new ToolRefusal({
|
|
36
|
+
message: ToolFailure.message(reason, remediation),
|
|
37
|
+
remediation
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
//#endregion
|
|
42
|
+
export { ToolRefusal };
|
package/guard.d.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Effect, Layer, Runtime } from "effect";
|
|
2
|
+
//#region src/McpGuard.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The slice of the host process the guard uses. Node's `process` satisfies it.
|
|
5
|
+
*
|
|
6
|
+
* @public
|
|
7
|
+
*/
|
|
8
|
+
interface McpGuardHost {
|
|
9
|
+
/** Register the `uncaughtException` listener. */
|
|
10
|
+
on(event: "uncaughtException", listener: (error: Error, origin: string) => void): unknown;
|
|
11
|
+
/** Register the `unhandledRejection` listener. */
|
|
12
|
+
on(event: "unhandledRejection", listener: (reason: unknown) => void): unknown;
|
|
13
|
+
/** Raise an `uncaughtException` to the registered listeners. Used only by {@link McpGuardRunOptions.injectCrash}. */
|
|
14
|
+
emit(event: "uncaughtException", error: Error, origin: "uncaughtException" | "unhandledRejection"): unknown;
|
|
15
|
+
/** Raise an `unhandledRejection` to the registered listeners. Used only by {@link McpGuardRunOptions.injectCrash}. */
|
|
16
|
+
emit(event: "unhandledRejection", reason: unknown, promise: Promise<unknown>): unknown;
|
|
17
|
+
/** Where every guard report is written. Never stdout: that is the JSON-RPC wire. */
|
|
18
|
+
readonly stderr: {
|
|
19
|
+
write(chunk: string): unknown;
|
|
20
|
+
};
|
|
21
|
+
/** End the process at once. */
|
|
22
|
+
exit(code?: number): never;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* When a stray exception or rejection ends the process.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* `"exit"` exits 1 whenever it happens. `"exitBeforeConnect"` exits 1 until
|
|
29
|
+
* the server is serving, then logs and keeps serving: a server that dies
|
|
30
|
+
* mid-session deregisters every tool from the client. `"log"` never exits.
|
|
31
|
+
*
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
interface McpGuardPolicy {
|
|
35
|
+
/** For `uncaughtException`. */
|
|
36
|
+
readonly onUncaught: "exit" | "exitBeforeConnect";
|
|
37
|
+
/** For `unhandledRejection`. Defaults to `"exit"`. */
|
|
38
|
+
readonly onRejection?: "exit" | "exitBeforeConnect" | "log" | undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* What {@link McpGuardRunOptions.load} resolves to: the server and the
|
|
42
|
+
* platform runner to launch it with.
|
|
43
|
+
*
|
|
44
|
+
* @public
|
|
45
|
+
*/
|
|
46
|
+
interface McpGuardedServer<ROut, E> {
|
|
47
|
+
/** The whole server layer, stdio included, with nothing left to provide. */
|
|
48
|
+
readonly layer: Layer.Layer<ROut, E, never>;
|
|
49
|
+
/** The platform's `runMain`, such as `NodeRuntime.runMain` from `@effect/platform-node`. */
|
|
50
|
+
readonly runMain: (effect: Effect.Effect<never, Error>, options: {
|
|
51
|
+
readonly teardown: Runtime.Teardown;
|
|
52
|
+
}) => void;
|
|
53
|
+
/** Formats a reported error from here on; the guard's own dependency-free formatter until then. */
|
|
54
|
+
readonly format?: ((error: unknown) => string) | undefined;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Options for {@link McpGuard.run}.
|
|
58
|
+
*
|
|
59
|
+
* @public
|
|
60
|
+
*/
|
|
61
|
+
interface McpGuardRunOptions<ROut, E> {
|
|
62
|
+
/** Prefixes every report the guard writes, as in `my-server: uncaughtException (…): …`. */
|
|
63
|
+
readonly label: string;
|
|
64
|
+
/** The process: pass `process`. */
|
|
65
|
+
readonly host: McpGuardHost;
|
|
66
|
+
/** Defaults to `{ onUncaught: "exit", onRejection: "exit" }`. */
|
|
67
|
+
readonly policy?: McpGuardPolicy | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* Import and assemble the server. Everything that can throw while loading
|
|
70
|
+
* belongs here, behind a dynamic `import()`, so the guards are already
|
|
71
|
+
* listening when it runs.
|
|
72
|
+
*/
|
|
73
|
+
readonly load: () => Promise<McpGuardedServer<ROut, E>>;
|
|
74
|
+
/**
|
|
75
|
+
* For a test of the guards themselves: raise one stray `kind` at `at`, so
|
|
76
|
+
* both halves of a policy can be driven end to end in a real process.
|
|
77
|
+
*
|
|
78
|
+
* @remarks
|
|
79
|
+
* - `"load"` raises it once both listeners are installed and before
|
|
80
|
+
* `load()` is called, then waits for the guard to handle it: `load()`
|
|
81
|
+
* is not called until the listener has run. If a test double's `exit`
|
|
82
|
+
* throws instead of ending the process, `run` rejects with what it
|
|
83
|
+
* threw and never calls `load()`. The pre-connect half of
|
|
84
|
+
* the policy applies, so `"exitBeforeConnect"` exits 1 here, and only
|
|
85
|
+
* an `onRejection` of `"log"` lets the process go on to load and serve.
|
|
86
|
+
* The report uses the guard's own formatter, since no `format` is
|
|
87
|
+
* loaded yet.
|
|
88
|
+
* - `"connected"` raises it on a timer right after the server is
|
|
89
|
+
* serving, where `"exitBeforeConnect"` logs and keeps serving. A throw
|
|
90
|
+
* from a test double's `exit` there is dropped.
|
|
91
|
+
*
|
|
92
|
+
* Either is raised only through `host.emit`, on a `setTimeout(0)` tick,
|
|
93
|
+
* never as a real throw or rejection, so the guard's listeners handle it
|
|
94
|
+
* the same way under the real `process` and under a test double. Under
|
|
95
|
+
* `process`, every listener registered for the event sees it, not only
|
|
96
|
+
* the guard's. It carries an `[injected]` message; a rejection is emitted
|
|
97
|
+
* with an already-handled rejected promise. `undefined`, or
|
|
98
|
+
* an `at` or `kind` outside these values, does nothing at all. Wire it to
|
|
99
|
+
* an environment variable only a test sets.
|
|
100
|
+
*/
|
|
101
|
+
readonly injectCrash?: {
|
|
102
|
+
readonly at: "load" | "connected";
|
|
103
|
+
readonly kind: "uncaughtException" | "unhandledRejection";
|
|
104
|
+
} | undefined;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Crash guards for an MCP server process, installed before the server's
|
|
108
|
+
* module graph is loaded. Imported from `@effected/mcp/guard`.
|
|
109
|
+
*
|
|
110
|
+
* @remarks
|
|
111
|
+
* {@link McpGuard.run} registers `uncaughtException` and `unhandledRejection`
|
|
112
|
+
* listeners on `host`, then awaits `load()`, then launches the server with
|
|
113
|
+
* `McpStdio.launch` and `McpStdio.teardown` under the `runMain` it returned.
|
|
114
|
+
* This entrypoint has no static runtime import: it loads `effect` and the
|
|
115
|
+
* stdio wiring only after the guards are listening, so a throw while any of
|
|
116
|
+
* it evaluates is still reported on stderr.
|
|
117
|
+
*
|
|
118
|
+
* - "Connected" means the whole server layer has built, so stdin is being
|
|
119
|
+
* read. That is before any client sends `initialize`.
|
|
120
|
+
* - `injectCrash` drives either half of the policy from a test, before
|
|
121
|
+
* `load()` or once connected; unset, it does nothing.
|
|
122
|
+
* - A `load()` that rejects is reported as `startup failed` and exits 1
|
|
123
|
+
* whatever the policy. Left to a log-only rejection listener, it would
|
|
124
|
+
* let the event loop drain and exit 0 with no server.
|
|
125
|
+
* - Installing an `unhandledRejection` listener switches off Node's default
|
|
126
|
+
* of throwing on one, so `"log"` really does keep the process running.
|
|
127
|
+
* - An exit from a guard skips Effect's finalizers and the teardown.
|
|
128
|
+
* - Keeping on serving after a crash works because the platform `runMain`
|
|
129
|
+
* holds the process open while the server fiber lives.
|
|
130
|
+
* - A layer that fails to build is still reported by `McpStdio.launch`.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* import { McpGuard } from "@effected/mcp/guard";
|
|
135
|
+
*
|
|
136
|
+
* await McpGuard.run({
|
|
137
|
+
* label: "my-server",
|
|
138
|
+
* host: process,
|
|
139
|
+
* policy: { onUncaught: "exitBeforeConnect", onRejection: "log" },
|
|
140
|
+
* load: async () => {
|
|
141
|
+
* const { NodeRuntime } = await import("@effect/platform-node");
|
|
142
|
+
* const { Main } = await import("./server.js");
|
|
143
|
+
* return { layer: Main, runMain: NodeRuntime.runMain };
|
|
144
|
+
* },
|
|
145
|
+
* });
|
|
146
|
+
* ```
|
|
147
|
+
*
|
|
148
|
+
* @public
|
|
149
|
+
*/
|
|
150
|
+
export declare class McpGuard {
|
|
151
|
+
private constructor();
|
|
152
|
+
/** Install the guards, load the server, and launch it. Resolves once the server is launched. */
|
|
153
|
+
static readonly run: <ROut, E>(options: McpGuardRunOptions<ROut, E>) => Promise<void>;
|
|
154
|
+
}
|
|
155
|
+
//#endregion
|
|
156
|
+
export type { McpGuardHost, McpGuardPolicy, McpGuardRunOptions, McpGuardedServer };
|
|
157
|
+
//# sourceMappingURL=guard.d.ts.map
|
package/guard.js
ADDED