@effected/mcp 0.1.0 → 0.2.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.
@@ -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 };
package/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Array, Effect, JsonSchema, Layer, Runtime, Schema, Stdio } from "effect";
1
+ import { Array, Context, Effect, JsonSchema, Layer, Runtime, Schema, Stdio } from "effect";
2
2
  import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/unstable/ai";
3
3
  import { Remediation } from "@effected/engine";
4
4
  //#region src/McpStdio.d.ts
@@ -19,6 +19,26 @@ interface McpStdioOptions {
19
19
  /** Defaults to {@link McpStdio.protocols}. Read its ordering rules before overriding. */
20
20
  readonly protocols?: Array.NonEmptyReadonlyArray<McpProtocol.ProtocolAdapter> | undefined;
21
21
  }
22
+ /**
23
+ * Options for {@link McpStdio.launch}.
24
+ *
25
+ * @public
26
+ */
27
+ interface McpLaunchOptions {
28
+ /**
29
+ * Runs once the whole layer has built, and before the launch waits
30
+ * forever. By then the stdio protocol is reading stdin: the server is
31
+ * serving, although no client may have sent `initialize` yet.
32
+ *
33
+ * @remarks
34
+ * `McpGuard.run` (`@effected/mcp/guard`) uses it as its "connected"
35
+ * signal. A layer built after the server layer, as in
36
+ * `Layer.effectDiscard(ready).pipe(Layer.provide(Main))`, gives the same
37
+ * signal, but only if every consumer re-derives that `Layer.provide`
38
+ * builds its dependency first.
39
+ */
40
+ readonly onReady?: Effect.Effect<void> | undefined;
41
+ }
22
42
  /**
23
43
  * Serve an MCP server over stdio without ever writing a log line or a failure
24
44
  * report onto stdout, which is the JSON-RPC wire.
@@ -107,8 +127,12 @@ export declare class McpStdio {
107
127
  * one graph does that, and so does building or providing the second
108
128
  * anywhere under the first one's `Effect.provide`: nested `Layer.build`
109
129
  * 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.
130
+ * new one. The tool registry is shared the same way. Isolate each server
131
+ * by wrapping its whole bundle (its toolkit layers together with this
132
+ * layer) in `Layer.fresh`, or with its own `ManagedRuntime`,
133
+ * `Effect.provide(layer, { local: true })` or its own process. Never put
134
+ * the `Layer.fresh` boundary between a toolkit and this layer: the server
135
+ * would serve an empty registry.
112
136
  *
113
137
  * Code after a completed `Effect.provide` of a stdio server never runs:
114
138
  * core's stdio protocol interrupts the fiber that built it when its stdin
@@ -129,8 +153,11 @@ export declare class McpStdio {
129
153
  * `Effect.provideService` reaches, and Effect's default logger writes
130
154
  * through `console.log` unless `LogToStderr` is set. For an MCP server
131
155
  * that is stdout, which is the wire.
156
+ *
157
+ * `options.onReady` runs after the layer has built and before the launch
158
+ * waits forever, as {@link McpLaunchOptions.onReady} describes.
132
159
  */
133
- static readonly launch: <ROut, E, R>(layer: Layer.Layer<ROut, E, R>) => Effect.Effect<never, Error, R>;
160
+ static readonly launch: <ROut, E, R>(layer: Layer.Layer<ROut, E, R>, options?: McpLaunchOptions) => Effect.Effect<never, Error, R>;
134
161
  /**
135
162
  * Success or an interrupt-only exit maps to 0. Anything else goes to
136
163
  * `Runtime.defaultTeardown`.
@@ -239,6 +266,61 @@ interface McpToolkitOptions {
239
266
  */
240
267
  readonly unknownKeyMessage?: ((levels: ReadonlyArray<UnknownKeysLevel>) => string) | undefined;
241
268
  }
269
+ /**
270
+ * A `Tool.dynamic` made by {@link McpToolkit.unionTool}: served with the
271
+ * strict, object-rooted JSON Schema of a `Schema.Union` of objects, and
272
+ * carrying that union so {@link McpToolkit.unionHandler} can decode it.
273
+ *
274
+ * @remarks
275
+ * `annotate` and `addDependency` keep the type, so a chained
276
+ * `.annotate(Tool.Title, …)` still hands `unionHandler` the union. The
277
+ * declared failure is `failure` together with `McpSchema.InvalidParams`.
278
+ *
279
+ * @public
280
+ */
281
+ interface UnionTool<Name extends string, P extends Schema.Decoder<unknown>, S extends Schema.Constraint, F extends Schema.Constraint, R = never> extends Tool.Dynamic<Name, {
282
+ readonly parameters: JsonSchema.JsonSchema;
283
+ readonly success: S;
284
+ readonly failure: Schema.Union<readonly [F, typeof McpSchema.InvalidParams]>;
285
+ readonly failureMode: "error";
286
+ }, R> {
287
+ /** The union the tool decodes its arguments with. */
288
+ readonly unionParameters: P;
289
+ /** Add an annotation, keeping the union. */
290
+ annotate<I, V>(tag: Context.Key<I, V>, value: V): UnionTool<Name, P, S, F, R>;
291
+ /** Add a request-level dependency, keeping the union. */
292
+ addDependency<Identifier, Service>(tag: Context.Key<Identifier, Service>): UnionTool<Name, P, S, F, Identifier | R>;
293
+ }
294
+ /**
295
+ * Options for {@link McpToolkit.unionTool}.
296
+ *
297
+ * @public
298
+ */
299
+ interface UnionToolOptions<P extends Schema.Decoder<unknown>, S extends Schema.Constraint, F extends Schema.Constraint, Dependencies extends ReadonlyArray<Context.Key<unknown, unknown>>> {
300
+ /** What the tool does, for the agent. */
301
+ readonly description?: string | undefined;
302
+ /** A `Schema.Union` of object schemas, discriminated by an `action`, `kind`, `_tag` or `type` literal. */
303
+ readonly parameters: P;
304
+ /** The success schema. Defaults to `Schema.Void`; object-root a union whose members are all objects with `ToolOutputSchema.objectRooted`. */
305
+ readonly success?: S | undefined;
306
+ /** Declared failures besides `InvalidParams`, such as `ToolRefusal`. Defaults to `Schema.Never`. */
307
+ readonly failure?: F | undefined;
308
+ /** Services the handler needs, as with `Tool.make`. */
309
+ readonly dependencies?: Dependencies | undefined;
310
+ }
311
+ /**
312
+ * Options for {@link McpToolkit.unionHandler}.
313
+ *
314
+ * @public
315
+ */
316
+ interface UnionHandlerOptions {
317
+ /**
318
+ * Replaces the default `ToolInputSchema.formatUnknownKeys` rendering, as
319
+ * {@link McpToolkitOptions.unknownKeyMessage} does for the layer. Pass the
320
+ * same function to both for one report on every path.
321
+ */
322
+ readonly unknownKeyMessage?: ((levels: ReadonlyArray<UnknownKeysLevel>) => string) | undefined;
323
+ }
242
324
  /**
243
325
  * Register a toolkit exactly as core's `McpServer.toolkit` does, except that a
244
326
  * strict tool's unknown arguments are all named, at every depth, in one
@@ -271,6 +353,8 @@ interface McpToolkitOptions {
271
353
  * shared by reference with `McpStdio.layer`'s own copy — provide both into
272
354
  * the same graph, as with `McpServer.toolkit`. Never wrap that layer in
273
355
  * `Layer.fresh`: tools would register into a second registry nobody serves.
356
+ * To give two servers separate registries, wrap each whole bundle (this
357
+ * layer together with `McpStdio.layer`) in `Layer.fresh` instead.
274
358
  *
275
359
  * Each call mints a fresh layer; bind the result to a `const` or the
276
360
  * registration runs twice.
@@ -288,6 +372,60 @@ interface McpToolkitOptions {
288
372
  */
289
373
  export declare class McpToolkit {
290
374
  private constructor();
375
+ /**
376
+ * A tool whose parameters are a `Schema.Union` of objects, which
377
+ * `Tool.make` cannot take: core dies at registration on a non-object
378
+ * `parameters` root.
379
+ *
380
+ * @remarks
381
+ * The tool is a `Tool.dynamic` served with Effect's strict JSON Schema
382
+ * document for `parameters` (`additionalProperties: false` on every
383
+ * object node, definitions as `$defs`), rewritten by
384
+ * `ToolInputSchema.objectRooted` to an object root carrying `type`,
385
+ * `oneOf` and `x-discriminator`. Write its handler with
386
+ * {@link McpToolkit.unionHandler}, which receives the decoded member.
387
+ *
388
+ * Registered through {@link McpToolkit.layer}, a union tool gets the same
389
+ * treatment as a strict `Tool.make` tool: every unknown key named in one
390
+ * `InvalidParams`, then a strict decode, both before the handler runs, so
391
+ * bad arguments answer JSON-RPC `-32602` on `2025-06-18` and an `isError`
392
+ * result on the later revisions, exactly as a `Tool.make` decode failure
393
+ * does. Registered through core's `McpServer.toolkit` it still decodes
394
+ * strictly, in the handler, but the `InvalidParams` is then a declared
395
+ * failure: an `isError` result on every revision.
396
+ *
397
+ * Never annotate it `Tool.Strict` true: core dies at registration on a
398
+ * strict tool with a raw JSON Schema.
399
+ *
400
+ * @example
401
+ * ```ts
402
+ * const Note = McpToolkit.unionTool("note", {
403
+ * parameters: Schema.Union([AddNote, ListNotes]),
404
+ * success: ToolOutputSchema.objectRooted(Schema.Union([Added, Listed])),
405
+ * failure: ToolRefusal,
406
+ * }).annotate(Tool.Title, "Note")
407
+ * const handlers = Kit.toLayer({ note: McpToolkit.unionHandler(Note, (params) => handleNote(params)) })
408
+ * ```
409
+ */
410
+ static readonly unionTool: <const Name extends string, P extends Schema.Decoder<unknown>, S extends Schema.Constraint = typeof Schema.Void, F extends Schema.Constraint = typeof Schema.Never, const Dependencies extends ReadonlyArray<Context.Key<unknown, unknown>> = []>(name: Name, options: UnionToolOptions<P, S, F, Dependencies>) => UnionTool<Name, P, S, F, Context.Service.Identifier<Dependencies[number]>>;
411
+ /**
412
+ * The handler for a {@link McpToolkit.unionTool}: checks and decodes the
413
+ * raw payload exactly as {@link McpToolkit.layer} does, and passes the
414
+ * decoded member to `handler`.
415
+ *
416
+ * @remarks
417
+ * A payload carrying unknown keys fails with one `McpSchema.InvalidParams`
418
+ * naming every one of them, at every depth, in the
419
+ * `ToolInputSchema.formatUnknownKeys` report (or `options.unknownKeyMessage`'s),
420
+ * walked over the tool's served input schema. A payload that then does
421
+ * not decode fails `InvalidParams` worded as core words a `Tool.make`
422
+ * decode failure. It is the same implementation the layer runs, so a
423
+ * handler called directly (a test helper, or a toolkit registered through
424
+ * core's `McpServer.toolkit`) reports what a client of the layer sees.
425
+ * Under {@link McpToolkit.layer} the layer has already rejected a bad
426
+ * call before the handler runs, and its own `unknownKeyMessage` wins.
427
+ */
428
+ static readonly unionHandler: <Name extends string, P extends Schema.Decoder<unknown>, S extends Schema.Constraint, F extends Schema.Constraint, R, A, E, RH>(tool: UnionTool<Name, P, S, F, R>, handler: (params: P["Type"]) => Effect.Effect<A, E, RH>, options?: UnionHandlerOptions) => ((payload: unknown) => Effect.Effect<A, E | McpSchema.InvalidParams, RH>);
291
429
  /**
292
430
  * `McpServer.toolkit`'s registration layer, strict by default and naming
293
431
  * every unknown argument in one `InvalidParams`.
@@ -309,7 +447,13 @@ export declare class McpToolkit {
309
447
  *
310
448
  * Spread {@link ToolFailure.fields} into your tool's `Schema.TaggedError`, build
311
449
  * `message` with {@link ToolFailure.message}, and pass every caller-supplied
312
- * value through {@link ToolFailure.truncate} before echoing it.
450
+ * value through {@link ToolFailure.truncate} before echoing it. For a plain
451
+ * refusal with no fields of its own, `ToolRefusal` is this shape ready-made.
452
+ *
453
+ * Only a declared failure keeps its message: core scrubs an undeclared
454
+ * failure or a defect to "Tool execution failed due to an internal server
455
+ * error.", so audit every throw path a tool can take and declare each one the
456
+ * agent should read.
313
457
  *
314
458
  * @example
315
459
  * ```ts
@@ -348,5 +492,97 @@ export declare class ToolFailure {
348
492
  static readonly truncate: (value: string, limit?: number) => string;
349
493
  }
350
494
  //#endregion
351
- export type { FormatUnknownKeysOptions, McpStdioOptions, McpToolkitOptions, UnknownKeysLevel };
495
+ //#region src/ToolOutputSchema.d.ts
496
+ /**
497
+ * Shaping for a tool's `success` schema, so the `outputSchema` core serves
498
+ * is one every client accepts.
499
+ *
500
+ * @remarks
501
+ * MCP requires a tool's `outputSchema` to be rooted in an object, and a
502
+ * top-level `Schema.Union` emits a bare `anyOf`. Core drops such an
503
+ * `outputSchema` from `tools/list` on the stateful revisions and serves it
504
+ * verbatim on the stateless one, where strict clients reject it.
505
+ * `McpToolAudit`'s `objectRootedOutput` check reports it either way.
506
+ *
507
+ * {@link ToolOutputSchema.objectRooted} is the fix when every member of the
508
+ * union is an object shape; never apply it to a union with a primitive or
509
+ * array member. It works on the Effect
510
+ * schema, not on JSON Schema — unlike `ToolInputSchema.objectRooted`, which
511
+ * rewrites the served input document of a `Tool.dynamic`.
512
+ *
513
+ * @public
514
+ */
515
+ export declare class ToolOutputSchema {
516
+ private constructor();
517
+ /**
518
+ * `schema` with `type: "object"` added beside the `anyOf` at its JSON
519
+ * Schema root, through a check that always passes. Decoding, encoding and
520
+ * the schema's type are unchanged.
521
+ *
522
+ * @remarks
523
+ * Order against `.annotate({ identifier })` does not matter. A check added
524
+ * after an identifier starts a new node that would drop the definition
525
+ * name, so the identifier the schema already resolves is carried onto the
526
+ * new check, and the served documents are equal either way (key order
527
+ * can differ when the schema carries a title or description). Applying it
528
+ * twice is a no-op.
529
+ *
530
+ * Every union member must be an object shape: the added `type: "object"`
531
+ * is a claim about the whole union, and nothing checks it. Do not use it
532
+ * on a union with a primitive or array member — the served schema would
533
+ * contradict the values the tool returns; give such a tool an object
534
+ * envelope instead.
535
+ *
536
+ * @example
537
+ * ```ts
538
+ * const Result = ToolOutputSchema.objectRooted(Schema.Union([Found, Missing])).annotate({ identifier: "Result" })
539
+ * Tool.make("lookup", { parameters, success: Result })
540
+ * ```
541
+ */
542
+ static readonly objectRooted: <S extends Schema.Top>(schema: S) => S;
543
+ }
544
+ //#endregion
545
+ //#region src/ToolRefusal.d.ts
546
+ declare const ToolRefusal_base: Schema.Class<ToolRefusal, Schema.TaggedStruct<"ToolRefusal", {
547
+ readonly message: Schema.String;
548
+ readonly remediation: Schema.Struct<{
549
+ readonly hint: Schema.String;
550
+ readonly suggestedTool: Schema.optionalKey<Schema.String>;
551
+ readonly suggestedArgs: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Unknown>>;
552
+ }>;
553
+ }>, import("effect/Cause").YieldableError>;
554
+ /**
555
+ * A tool call refused for a reason the caller can fix: the kit's ready-made
556
+ * declared failure, built on {@link ToolFailure.fields}.
557
+ *
558
+ * @remarks
559
+ * Core scrubs every undeclared failure and every defect to one generic
560
+ * internal-error sentence, so a refusal the agent must act on (an unknown id,
561
+ * a missing argument, a precondition) reaches it only when the tool declares
562
+ * it. Declare `ToolRefusal` in the tool's `failure` schema and fail with
563
+ * {@link ToolRefusal.refuse}; its message already carries the remediation,
564
+ * because a declared `Error` failure sends `message` and nothing else.
565
+ *
566
+ * Audit every throw path when porting a tool: any failure left undeclared
567
+ * loses its message on the wire, however carefully it was worded.
568
+ *
569
+ * @example
570
+ * ```ts
571
+ * const Lookup = Tool.make("lookup", { parameters, success, failure: ToolRefusal })
572
+ * // in the handler:
573
+ * return yield* ToolRefusal.refuse(`No run "${ToolFailure.truncate(id)}".`, { hint: "List runs first.", suggestedTool: "list_runs" })
574
+ * ```
575
+ *
576
+ * @public
577
+ */
578
+ export declare class ToolRefusal extends ToolRefusal_base {
579
+ /**
580
+ * A refusal for `reason`, with `remediation` folded into its message by
581
+ * {@link ToolFailure.message}. `reason` must already truncate any
582
+ * caller-supplied value it echoes, with {@link ToolFailure.truncate}.
583
+ */
584
+ static readonly refuse: (reason: string, remediation: Remediation) => ToolRefusal;
585
+ }
586
+ //#endregion
587
+ export type { FormatUnknownKeysOptions, McpLaunchOptions, McpStdioOptions, McpToolkitOptions, UnionHandlerOptions, UnionTool, UnionToolOptions, UnknownKeysLevel };
352
588
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -2,5 +2,7 @@ import { McpStdio } from "./McpStdio.js";
2
2
  import { ToolFailure } from "./ToolFailure.js";
3
3
  import { ToolInputSchema } from "./ToolInputSchema.js";
4
4
  import { McpToolkit } from "./McpToolkit.js";
5
+ import { ToolOutputSchema } from "./ToolOutputSchema.js";
6
+ import { ToolRefusal } from "./ToolRefusal.js";
5
7
 
6
- export { McpStdio, McpToolkit, ToolFailure, ToolInputSchema };
8
+ export { McpStdio, McpToolkit, ToolFailure, ToolInputSchema, ToolOutputSchema, ToolRefusal };
@@ -0,0 +1,14 @@
1
+ import { McpStdio } from "../McpStdio.js";
2
+ import { Effect } from "effect";
3
+
4
+ //#region src/internal/guardLaunch.ts
5
+ /**
6
+ * The server half of `McpGuard.run`, reached only through a dynamic import so
7
+ * the guard module itself evaluates nothing but its own code.
8
+ *
9
+ * @internal
10
+ */
11
+ const launchGuarded = (server, onReady) => server.runMain(McpStdio.launch(server.layer, { onReady: Effect.sync(onReady) }), { teardown: McpStdio.teardown });
12
+
13
+ //#endregion
14
+ export { launchGuarded };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "The boundary layer of an effect/unstable/ai MCP server: stdio wiring that keeps stdout the wire, tool-failure shaping, strict-input walkers, and test clients",
6
6
  "keywords": [
@@ -34,6 +34,11 @@
34
34
  "import": "./index.js",
35
35
  "default": "./index.js"
36
36
  },
37
+ "./guard": {
38
+ "types": "./guard.d.ts",
39
+ "import": "./guard.js",
40
+ "default": "./guard.js"
41
+ },
37
42
  "./testing": {
38
43
  "types": "./testing.d.ts",
39
44
  "import": "./testing.js",