@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.
@@ -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) out.push({
81
- path,
82
- unknown: extra,
83
- accepted
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
- * 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
+ * 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. 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.
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 accepted = level.accepted.length === 0 ? "(none)" : level.accepted.join(", ");
134
- return `Unrecognized parameter(s): ${shown.join(", ")}${more}. Accepted params: ${accepted}.`;
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
@@ -0,0 +1,3 @@
1
+ import { McpGuard } from "./McpGuard.js";
2
+
3
+ export { McpGuard };