@effected/mcp 0.9.0 → 0.11.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/McpHarness.js CHANGED
@@ -3,8 +3,38 @@ import { DEFAULT_CLIENT_INFO, frame, initializeParams, isJsonRpcMessage, isRespo
3
3
  import { McpTestFailure } from "./McpTestFailure.js";
4
4
  import { Console, Deferred, Effect, Exit, Fiber, Layer, Queue, Sink, Stdio, Stream } from "effect";
5
5
  import { McpProtocol } from "effect/ai";
6
+ import { ErrorText } from "@effected/engine";
6
7
 
7
8
  // src/McpHarness.ts
9
+ /** The generic tag `Object.prototype.toString` answers with: a value with no string form of its own. */
10
+ const GENERIC_STRING = /^\[object [^\]]*\]$/;
11
+ /**
12
+ * One captured console argument as text, as close to what the console would show as a runtime-free rendering gets,
13
+ * and never throwing.
14
+ *
15
+ * @remarks
16
+ * A harness records what the server would have printed, so the record must not lose what the console shows. Hence
17
+ * the order: a value's own string form first (`String(error)` is `Error: boom`, where `ErrorText.describe` would keep
18
+ * only `boom`, and it is the same for any object carrying a `message`), then JSON for an array or a plain object,
19
+ * whose string form is `1,2` or `[object Object]`. `ErrorText.describe` is the last resort for a value whose string
20
+ * form throws and which has no JSON: an object with no prototype has no `toString`, and `String` throws on it inside
21
+ * the server's own console call.
22
+ */
23
+ const showArg = (arg) => {
24
+ if (typeof arg === "string") return arg;
25
+ if (typeof arg !== "object" || arg === null) return ErrorText.describe(arg);
26
+ try {
27
+ if (!Array.isArray(arg)) {
28
+ const text = String(arg);
29
+ if (!GENERIC_STRING.test(text)) return text;
30
+ }
31
+ } catch {}
32
+ try {
33
+ const json = JSON.stringify(arg);
34
+ if (json !== void 0) return json;
35
+ } catch {}
36
+ return ErrorText.describe(arg);
37
+ };
8
38
  /**
9
39
  * An in-process MCP client for a real server layer, over queue-backed stdio.
10
40
  *
@@ -109,7 +139,15 @@ var McpHarness = class McpHarness {
109
139
  sentSoFar;
110
140
  /** Everything written to stderr, plus every captured log line. */
111
141
  stderrSoFar;
112
- /** Every captured `console.log`, `info` or `debug` call. */
142
+ /**
143
+ * Every captured `console.log`, `info` or `debug` call, its arguments joined with a space, each rendered close to
144
+ * what the console would show: a string as itself, a value with its own string form as that form (an `Error` as
145
+ * `Error: boom`, a number, a date), an array or a plain object as JSON (`{"message":"x"}`, not just `x`).
146
+ *
147
+ * @remarks
148
+ * Rendering never throws inside the server's own call. A value whose string form throws (an object with no
149
+ * prototype, a throwing `toString`) and has no JSON falls back to `ErrorText.describe` from `@effected/engine`.
150
+ */
113
151
  consoleLogSoFar;
114
152
  /**
115
153
  * End stdin, as a client disconnecting does. The server drains first:
@@ -171,7 +209,7 @@ var McpHarness = class McpHarness {
171
209
  }))
172
210
  });
173
211
  const ambient = yield* Console.Console;
174
- const show = (args) => args.map((arg) => typeof arg === "string" ? arg : String(arg)).join(" ");
212
+ const show = (args) => args.map(showArg).join(" ");
175
213
  const toStdout = (...args) => {
176
214
  consoleLog.push(show(args));
177
215
  };
package/McpStdio.js CHANGED
@@ -94,14 +94,6 @@ var McpStdio = class McpStdio {
94
94
  * object with neither `method` nor `id`, which is neither a request nor a
95
95
  * response.
96
96
  *
97
- * A line whose `method` starts with `@effect/rpc/` never reaches core,
98
- * whose JSON-RPC decoder would read one without an `id` as its own RPC
99
- * control message: `@effect/rpc/Eof` alone silently stops the server
100
- * (Effect-TS/effect#8499, open upstream). Such a request is answered
101
- * `-32601` Method not found, echoing its `id`; such a notification is
102
- * dropped. The guard stops doing this once core no longer treats a
103
- * client's `@effect/rpc/` frame as a control message.
104
- *
105
97
  * Everything else goes to core: an array, which core answers `-32600`
106
98
  * itself because it serves no batches; an object with an `id` and no
107
99
  * `method`, which is a response and gets no reply; and a request with an
@@ -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/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/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 CHANGED
@@ -42,10 +42,14 @@ var ToolFailure = class ToolFailure {
42
42
  static ECHO_LIMIT = 200;
43
43
  /** The larger cap for a value the engine produced (a path, a diagnostic), not one a caller typed. */
44
44
  static ENGINE_ECHO_LIMIT = 2e3;
45
- /** The `message` and `remediation` fields to spread into a consumer's `Schema.TaggedError`. */
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
+ */
46
50
  static fields = {
47
- message: Schema.String,
48
- remediation: Remediation
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." })
49
53
  };
50
54
  /**
51
55
  * `"<raw> <hint> Try <suggestedTool>."`, dropping any empty part, so a
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"
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 };
package/ToolReplay.js ADDED
@@ -0,0 +1,122 @@
1
+ import { ToolOutcome } from "./ToolOutcome.js";
2
+ import { Effect, Option, Schema } from "effect";
3
+ import { KeyValueStore } from "effect/persistence";
4
+
5
+ // src/ToolReplay.ts
6
+ /**
7
+ * Idempotent replay for a write tool: a repeated call with the same key returns
8
+ * the first call's stored result, marked `_idempotentReplay: true`, instead of
9
+ * running the mutation again.
10
+ *
11
+ * @remarks
12
+ * The store is core's `KeyValueStore` (`effect/persistence`), required in `R`
13
+ * (`KeyValueStore.layerMemory`, `layerFileSystem` or `layerSql`), unless
14
+ * {@link ToolReplayOptions.store} supplies a {@link ToolReplayStore}: just
15
+ * `get` and `set`, for an application's own table. Then `R` carries no
16
+ * `KeyValueStore`.
17
+ *
18
+ * Semantics, per call:
19
+ *
20
+ * - A key of `Option.none()` runs the handler; nothing is read or stored.
21
+ * - A stored value that decodes through `success` is returned with the marker,
22
+ * and the handler does not run.
23
+ * - A miss runs the handler and stores its result, best-effort. A store that
24
+ * fails, a row that no longer decodes (written under an older schema, or
25
+ * corrupt), and a failed write are each a miss or a skipped write, never a
26
+ * tool error. Each is logged at debug level with the tool name.
27
+ * - A handler failure stores nothing, so a retry runs it again.
28
+ * - An `ok: false` result (a {@link ToolOutcome} envelope failure) is never
29
+ * stored: a "not found" must not replay after the thing exists.
30
+ *
31
+ * This is replay, not exactly-once: two concurrent calls with one key both
32
+ * miss and both run.
33
+ *
34
+ * The key is the whole identity of a request. A hit compares neither the
35
+ * params nor the schema version (a row written under an older schema replays
36
+ * whenever it still decodes), and rows never expire: the store keeps a result
37
+ * until something removes it. A key reused with different params therefore
38
+ * replays the first result, so {@link ToolReplayOptions.key} must fold in a
39
+ * digest of any params that can vary under one id.
40
+ *
41
+ * Wrap it inside {@link ToolOutcome.catchAsEnvelope}, so the envelope sees the
42
+ * handler's real errors and replay sees only real successes.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * import { ToolReplay } from "@effected/mcp"
47
+ * import { Option, Schema } from "effect"
48
+ *
49
+ * const Created = ToolReplay.success(Schema.Struct({ ok: Schema.Literal(true), id: Schema.Number }))
50
+ * const create = ToolReplay.wrap({
51
+ * tool: "goal_create",
52
+ * success: Created,
53
+ * key: (params: { readonly requestId?: string }) => Option.fromUndefinedOr(params.requestId),
54
+ * })(createGoal)
55
+ * ```
56
+ *
57
+ * @public
58
+ */
59
+ var ToolReplay = class ToolReplay {
60
+ constructor() {}
61
+ /**
62
+ * The marker field, to spread into each object member of a replayable
63
+ * success schema. {@link ToolReplay.success} adds it for you.
64
+ */
65
+ static marker = { _idempotentReplay: Schema.optionalKey(Schema.Literal(true).annotate({ description: "Present and true when this result replays an earlier call with the same idempotency key: the stored result is returned and the mutation did not run again." })) };
66
+ /**
67
+ * A struct with {@link ToolReplay.marker} added. Use it for each object
68
+ * success member, so core's encoder keeps the marker on a replayed reply.
69
+ *
70
+ * @remarks
71
+ * Built with core's `fieldsAssign`, which, like `mapFields`, does not carry
72
+ * the struct's own `.check(...)` refinements over: apply checks to the
73
+ * result, not to the struct passed in.
74
+ */
75
+ static success = (schema) => schema.pipe(Schema.fieldsAssign(ToolReplay.marker));
76
+ /**
77
+ * The `KeyValueStore` key one call is stored under: `<tool>:<key>`. Split on
78
+ * the first `:` to recover both parts; MCP tool names are `[A-Za-z0-9_.-]`.
79
+ */
80
+ static storeKey = (tool, key) => `${tool}:${key}`;
81
+ /**
82
+ * Wraps a handler with replay. Throws at wrap time when `tool` contains
83
+ * `:`, a wiring mistake, so it surfaces when the server is built.
84
+ */
85
+ static wrap = (options) => {
86
+ if (options.tool.includes(":")) throw new Error(`ToolReplay.wrap: tool name "${options.tool}" contains ":", the stored-key separator.`);
87
+ const { tool, success } = options;
88
+ const given = options.store;
89
+ const json = Schema.fromJsonString(Schema.toCodecJson(success));
90
+ const decode = Schema.decodeEffect(json);
91
+ const encode = Schema.encodeEffect(json);
92
+ return (handler) => (params) => {
93
+ const found = options.key(params);
94
+ if (Option.isNone(found)) return handler(params);
95
+ const storeKey = ToolReplay.storeKey(tool, found.value);
96
+ return Effect.gen(function* () {
97
+ const store = given ?? (yield* KeyValueStore.KeyValueStore);
98
+ const cached = yield* store.get(storeKey).pipe(Effect.flatMap((raw) => raw === void 0 ? Effect.succeedNone : Effect.asSome(decode(raw))), Effect.tapCause((cause) => Effect.logDebug("ToolReplay lookup failed; treating as a miss", {
99
+ tool,
100
+ cause
101
+ })), Effect.orElseSucceed(() => Option.none()));
102
+ if (Option.isSome(cached)) {
103
+ yield* Effect.annotateCurrentSpan("mcp.tool.replayed", true);
104
+ return withMarker(cached.value);
105
+ }
106
+ yield* Effect.annotateCurrentSpan("mcp.tool.replayed", false);
107
+ const result = yield* handler(params);
108
+ if (!ToolOutcome.isFailure(result)) yield* encode(result).pipe(Effect.flatMap((encoded) => store.set(storeKey, encoded)), Effect.tapCause((cause) => Effect.logDebug("ToolReplay store failed; result not stored", {
109
+ tool,
110
+ cause
111
+ })), Effect.ignore);
112
+ return result;
113
+ }).pipe(Effect.withSpan("ToolReplay.wrap", { attributes: { "mcp.tool.name": tool } }));
114
+ };
115
+ };
116
+ };
117
+ const withMarker = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? {
118
+ ...value,
119
+ _idempotentReplay: true
120
+ } : value;
121
+
122
+ export { ToolReplay };
package/guard.d.ts CHANGED
@@ -104,10 +104,21 @@ interface McpGuardRunOptions<ROut, E> {
104
104
  * an `at` or `kind` outside these values, does nothing at all. Wire it to
105
105
  * an environment variable only a test sets.
106
106
  */
107
- readonly injectCrash?: {
108
- readonly at: "load" | "connected";
109
- readonly kind: "uncaughtException" | "unhandledRejection";
110
- } | undefined;
107
+ readonly injectCrash?: InjectCrash | undefined;
108
+ }
109
+ /**
110
+ * One test-only injected crash, as {@link McpGuard.parseInjectCrash} returns
111
+ * it and {@link McpGuardRunOptions.injectCrash} takes it: which point of the
112
+ * launch to raise it at, and which process event to raise. The same shape as
113
+ * `ProcessGuardInjection` from `@effected/engine/guard`.
114
+ *
115
+ * @public
116
+ */
117
+ interface InjectCrash {
118
+ /** `"load"`: before the server module loads. `"connected"`: once the server is serving. */
119
+ readonly at: "load" | "connected";
120
+ /** The process event raised to the guard's listeners. */
121
+ readonly kind: "uncaughtException" | "unhandledRejection";
111
122
  }
112
123
  /**
113
124
  * Crash guards for an MCP server process, installed before the server's
@@ -170,9 +181,9 @@ export declare class McpGuard {
170
181
  * McpGuard.parseInjectCrash("load:uncaughtException"); // => { at: "load", kind: "uncaughtException" }
171
182
  * ```
172
183
  */
173
- static readonly parseInjectCrash: (value: string | undefined) => McpGuardRunOptions<unknown, unknown>["injectCrash"];
184
+ static readonly parseInjectCrash: (value: string | undefined) => InjectCrash | undefined;
174
185
  /** Install the guards, load the server, and launch it. Resolves once the server is launched. */
175
186
  static readonly run: <ROut, E>(options: McpGuardRunOptions<ROut, E>) => Promise<void>;
176
187
  }
177
- export type { McpGuardHost, McpGuardPolicy, McpGuardRunOptions, McpGuardedServer };
188
+ export type { InjectCrash, McpGuardHost, McpGuardPolicy, McpGuardRunOptions, McpGuardedServer };
178
189
  //# sourceMappingURL=guard.d.ts.map
package/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { Array, Context, Effect, JsonSchema, Layer, Runtime, Schema, Stdio } from "effect";
1
+ import { Array, Context, Effect, JsonSchema, Layer, Option, Runtime, Schema, Stdio, Struct } from "effect";
2
2
  import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
3
3
  import { Remediation } from "@effected/engine";
4
+ import { KeyValueStore } from "effect/persistence";
4
5
  /**
5
6
  * Options for {@link McpStdio.layer}.
6
7
  *
@@ -124,14 +125,6 @@ export declare class McpStdio {
124
125
  * object with neither `method` nor `id`, which is neither a request nor a
125
126
  * response.
126
127
  *
127
- * A line whose `method` starts with `@effect/rpc/` never reaches core,
128
- * whose JSON-RPC decoder would read one without an `id` as its own RPC
129
- * control message: `@effect/rpc/Eof` alone silently stops the server
130
- * (Effect-TS/effect#8499, open upstream). Such a request is answered
131
- * `-32601` Method not found, echoing its `id`; such a notification is
132
- * dropped. The guard stops doing this once core no longer treats a
133
- * client's `@effect/rpc/` frame as a control message.
134
- *
135
128
  * Everything else goes to core: an array, which core answers `-32600`
136
129
  * itself because it serves no batches; an object with an `id` and no
137
130
  * `method`, which is a response and gets no reply; and a request with an
@@ -511,7 +504,11 @@ export declare class ToolFailure {
511
504
  static readonly ECHO_LIMIT: number;
512
505
  /** The larger cap for a value the engine produced (a path, a diagnostic), not one a caller typed. */
513
506
  static readonly ENGINE_ECHO_LIMIT: number;
514
- /** The `message` and `remediation` fields to spread into a consumer's `Schema.TaggedError`. */
507
+ /**
508
+ * The `message` and `remediation` fields to spread into a consumer's
509
+ * `Schema.TaggedError`, each carrying a default `description` that a served
510
+ * JSON Schema (a tool's `outputSchema`) shows the agent.
511
+ */
515
512
  static readonly fields: {
516
513
  readonly message: Schema.String;
517
514
  readonly remediation: Schema.Struct<{
@@ -532,6 +529,167 @@ export declare class ToolFailure {
532
529
  */
533
530
  static readonly truncate: (value: string, limit?: number) => string;
534
531
  }
532
+ /**
533
+ * What a {@link ToolOutcome.catchAsEnvelope} case returns for one caught error:
534
+ * the same `message` and `remediation` a {@link ToolRefusal} carries, plus any
535
+ * structured fields the agent branches on.
536
+ *
537
+ * @remarks
538
+ * `message` is the plain sentence. The hint stays in `remediation`, where it
539
+ * reaches the agent as data; unlike a refusal's message, nothing is folded in.
540
+ *
541
+ * @public
542
+ */
543
+ interface ToolOutcomeErrorInput {
544
+ readonly message: string;
545
+ readonly remediation: Remediation;
546
+ readonly [field: string]: unknown;
547
+ }
548
+ /**
549
+ * The `ok: false` member of an envelope: `{ ok: false, error: { _tag, message, remediation, ...fields } }`.
550
+ *
551
+ * @public
552
+ */
553
+ interface ToolOutcomeFailure<Tag extends string, I extends ToolOutcomeErrorInput> {
554
+ readonly ok: false;
555
+ readonly error: {
556
+ readonly _tag: Tag;
557
+ } & I;
558
+ }
559
+ /**
560
+ * The failure values {@link ToolOutcome.catchAsEnvelope} produces for a set of
561
+ * cases: one {@link ToolOutcomeFailure} per handled tag.
562
+ *
563
+ * @public
564
+ */
565
+ type ToolOutcomeFailures<Cases> = { [K in keyof Cases]: Cases[K] extends ((...args: ReadonlyArray<never>) => infer I) ? I extends ToolOutcomeErrorInput ? ToolOutcomeFailure<K & string, I> : never : never; }[keyof Cases];
566
+ /**
567
+ * {@link ToolOutcome.envelope}'s options: annotations for the `ok: false`
568
+ * member, so a served `outputSchema` can name and describe it.
569
+ *
570
+ * @public
571
+ */
572
+ interface ToolOutcomeEnvelopeOptions {
573
+ /**
574
+ * The failure member's schema identifier. Core's JSON Schema output then
575
+ * emits it once under `$defs` by this name and refers to it with `$ref`.
576
+ */
577
+ readonly identifier?: string;
578
+ /**
579
+ * The failure member's description, replacing the default one (which says
580
+ * that `ok: false` carries an expected error to branch on).
581
+ */
582
+ readonly description?: string;
583
+ }
584
+ /**
585
+ * The `ok: false` structured-outcome envelope: an expected error an agent
586
+ * should branch on, returned on a tool's success channel so it reaches the
587
+ * client as `structuredContent` instead of message text.
588
+ *
589
+ * @remarks
590
+ * Two channels, two jobs, one error shape:
591
+ *
592
+ * - {@link ToolRefusal} is a declared failure: the call itself was wrong, and
593
+ * the agent reads one sentence (`isError: true`, message text only; core
594
+ * never sends a failure's `structuredContent`).
595
+ * - `ToolOutcome` is an expected domain outcome: an id that does not exist, a
596
+ * transition that is not allowed. The agent branches on its `_tag` and
597
+ * fields (`isError: false`, `structuredContent` carries the envelope).
598
+ *
599
+ * Both carry {@link ToolFailure.fields} (`message`, `remediation`), so an error
600
+ * can move between channels without reshaping.
601
+ *
602
+ * Success members are not reshaped. Spread {@link ToolOutcome.ok} into each
603
+ * success struct so a client can discriminate on `ok`; a member that
604
+ * discriminates another way (`found: true | false`) stays legal.
605
+ *
606
+ * @example
607
+ * ```ts
608
+ * import { ToolOutcome } from "@effected/mcp"
609
+ * import { Effect, Schema } from "effect"
610
+ * import { Tool } from "effect/ai"
611
+ *
612
+ * const NotFound = ToolOutcome.error("GoalNotFound", { id: Schema.Number })
613
+ * const Lookup = Tool.make("goal", {
614
+ * parameters: Schema.Struct({ id: Schema.Number }),
615
+ * success: ToolOutcome.envelope(Schema.Struct({ ...ToolOutcome.ok, goal: Goal }), [NotFound]),
616
+ * })
617
+ * // in the handler:
618
+ * findGoal(id).pipe(
619
+ * Effect.map((goal) => ({ ok: true as const, goal })),
620
+ * ToolOutcome.catchAsEnvelope({
621
+ * GoalNotFound: (e) => ({ id: e.id, message: `No goal ${e.id}.`, remediation: { hint: "List goals first.", suggestedTool: "goal" } }),
622
+ * }),
623
+ * )
624
+ * ```
625
+ *
626
+ * @public
627
+ */
628
+ export declare class ToolOutcome {
629
+ private constructor();
630
+ /** `{ ok: true }` as schema fields, to spread into each success member. */
631
+ static readonly ok: {
632
+ readonly ok: Schema.Literal<true>;
633
+ };
634
+ /**
635
+ * The schema of one envelope error: `_tag`, {@link ToolFailure.fields} and
636
+ * `fields`. A field named `_tag`, `message` or `remediation` is rejected by
637
+ * the type, because it would replace the shared one.
638
+ */
639
+ static readonly error: <const Tag extends string, const Fields extends Schema.Struct.Fields = Record<never, never>>(tag: Tag, fields?: Fields & { readonly [K in "_tag" | "message" | "remediation"]?: never; }) => Schema.Struct<{
640
+ readonly _tag: Schema.Literal<Tag>;
641
+ } & typeof ToolFailure.fields & Fields>;
642
+ /**
643
+ * A tool's success schema: `success`, or `{ ok: false, error }` where `error`
644
+ * is one of `errors`. Object-rooted with {@link ToolOutputSchema.objectRooted},
645
+ * so core serves its `outputSchema` on every protocol revision.
646
+ *
647
+ * @remarks
648
+ * The failure member, its `ok` and its `error` carry default descriptions.
649
+ * `options` names the member (`identifier`, served once under `$defs`) or
650
+ * replaces its description.
651
+ */
652
+ static readonly envelope: <S extends Schema.Top, const Errors extends ReadonlyArray<Schema.Top>>(success: S, errors: Errors, options?: ToolOutcomeEnvelopeOptions) => Schema.Union<readonly [S, Schema.Struct<{
653
+ readonly ok: Schema.Literal<false>;
654
+ readonly error: Schema.Union<Errors>;
655
+ }>]>;
656
+ /** One `ok: false` value, for a handler that decides on a failure without catching an error. */
657
+ static readonly failure: <const Tag extends string, const I extends ToolOutcomeErrorInput>(tag: Tag, error: I) => ToolOutcomeFailure<Tag, I>;
658
+ /**
659
+ * Turns each handled tagged error into its `ok: false` value on the success
660
+ * channel; every other error stays in the error channel. Typed like core's
661
+ * `Effect.catchTags`: a case for a tag the effect cannot fail with is a type
662
+ * error, and the handled tags leave `E`.
663
+ *
664
+ * @remarks
665
+ * Write it inline in the effect's `pipe`. `E` is inferred from the effect
666
+ * being piped, so `effect.pipe(ToolOutcome.catchAsEnvelope({ … }))` checks
667
+ * every case name against the effect's real tags. Bound to a `const` first,
668
+ * the combinator never sees that effect: `E` infers as `unknown`, the
669
+ * unknown-tag check switches off, and a misspelled tag compiles and never
670
+ * matches. Core's `catchTags` looks a case up only among `cases`' own keys,
671
+ * so an error tagged `"toString"` or `"constructor"` never hits an inherited
672
+ * `Object.prototype` member and stays in the error channel.
673
+ */
674
+ static readonly catchAsEnvelope: <E, Cases extends { [K in Extract<E, {
675
+ readonly _tag: string;
676
+ }>["_tag"]]+?: (error: Extract<E, {
677
+ readonly _tag: K;
678
+ }>) => ToolOutcomeErrorInput; } & (unknown extends E ? Record<never, never> : { [K in Exclude<keyof Cases, Extract<E, {
679
+ readonly _tag: string;
680
+ }>["_tag"]>]: never; })>(cases: Cases) => <A, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A | ToolOutcomeFailures<Cases>, Exclude<E, {
681
+ readonly _tag: keyof Cases;
682
+ }>, R>;
683
+ /**
684
+ * Whether `value` is an `ok: false` envelope member: an object whose `ok` is
685
+ * exactly `false`. A success member that discriminates some other way, or a
686
+ * non-object, is not a failure. Narrows to `{ ok: false }` only: it checks
687
+ * the discriminant, not the `error` payload.
688
+ */
689
+ static readonly isFailure: (value: unknown) => value is {
690
+ readonly ok: false;
691
+ };
692
+ }
535
693
  /**
536
694
  * Shaping for a tool's `success` schema, so the `outputSchema` core serves
537
695
  * is one every client accepts.
@@ -636,5 +794,146 @@ export declare class ToolRefusal extends ToolRefusal_base {
636
794
  */
637
795
  static readonly refuse: (reason: string, remediation: Remediation) => ToolRefusal;
638
796
  }
639
- export type { FormatUnknownKeysOptions, McpLaunchOptions, McpStdioOptions, McpToolkitOptions, UnionHandlerOptions, UnionTool, UnionToolOptions, UnknownKeysLevel };
797
+ /**
798
+ * The narrow store {@link ToolReplay.wrap} reads and writes: `get` and `set`
799
+ * on string values, nothing else.
800
+ *
801
+ * @remarks
802
+ * Core's `KeyValueStore` already satisfies it structurally, so a
803
+ * `KeyValueStore` value can be passed as is. An application backed by its
804
+ * own table implements just these two methods, instead of stubbing the
805
+ * `remove`, `clear` and `size` that `KeyValueStore.makeStringOnly` demands.
806
+ *
807
+ * `get` answers `undefined` for a missing key, the same contract as
808
+ * `KeyValueStore.get`. A failure from either method is never a tool error:
809
+ * a failed `get` is a miss and a failed `set` is a skipped write, both
810
+ * logged at debug level, so the error channel is `unknown`.
811
+ *
812
+ * @public
813
+ */
814
+ interface ToolReplayStore {
815
+ /** The stored value for `key`, or `undefined` when nothing is stored under it. */
816
+ readonly get: (key: string) => Effect.Effect<string | undefined, unknown>;
817
+ /** Stores `value` under `key`, replacing any earlier value. */
818
+ readonly set: (key: string, value: string) => Effect.Effect<void, unknown>;
819
+ }
820
+ /**
821
+ * What {@link ToolReplay.wrap} needs to know about one tool.
822
+ *
823
+ * @typeParam Store - The type of {@link ToolReplayOptions.store}; when it is
824
+ * omitted, `wrap` requires `KeyValueStore` in `R` instead.
825
+ *
826
+ * @public
827
+ */
828
+ interface ToolReplayOptions<P, S extends Schema.Top, Store extends ToolReplayStore | undefined = undefined> {
829
+ /** The served tool name. It must not contain `:`, the separator of the stored key ({@link ToolReplay.storeKey}). */
830
+ readonly tool: string;
831
+ /**
832
+ * The tool's success schema. Results are stored encoded through it and
833
+ * decoded through it on replay. It must declare {@link ToolReplay.marker} on
834
+ * each object member, or core's encoder drops the marker from the reply:
835
+ * build each member with {@link ToolReplay.success} so it cannot be left out.
836
+ */
837
+ readonly success: S;
838
+ /**
839
+ * The idempotency key for one call; `Option.none()` runs the handler with
840
+ * nothing stored or replayed.
841
+ *
842
+ * @remarks
843
+ * The key must identify the request completely. A replay compares nothing
844
+ * else: not the params, not the schema version. So a reused key with
845
+ * different params returns the FIRST call's result. When the params under
846
+ * one caller-supplied id can vary, fold a digest of them into the key.
847
+ */
848
+ readonly key: (params: P) => Option.Option<string>;
849
+ /**
850
+ * Where results are stored. Omitted, `wrap` reads core's `KeyValueStore`
851
+ * from `R`; given, it uses this store and `KeyValueStore` leaves `R`.
852
+ */
853
+ readonly store?: Store;
854
+ }
855
+ /**
856
+ * Idempotent replay for a write tool: a repeated call with the same key returns
857
+ * the first call's stored result, marked `_idempotentReplay: true`, instead of
858
+ * running the mutation again.
859
+ *
860
+ * @remarks
861
+ * The store is core's `KeyValueStore` (`effect/persistence`), required in `R`
862
+ * (`KeyValueStore.layerMemory`, `layerFileSystem` or `layerSql`), unless
863
+ * {@link ToolReplayOptions.store} supplies a {@link ToolReplayStore}: just
864
+ * `get` and `set`, for an application's own table. Then `R` carries no
865
+ * `KeyValueStore`.
866
+ *
867
+ * Semantics, per call:
868
+ *
869
+ * - A key of `Option.none()` runs the handler; nothing is read or stored.
870
+ * - A stored value that decodes through `success` is returned with the marker,
871
+ * and the handler does not run.
872
+ * - A miss runs the handler and stores its result, best-effort. A store that
873
+ * fails, a row that no longer decodes (written under an older schema, or
874
+ * corrupt), and a failed write are each a miss or a skipped write, never a
875
+ * tool error. Each is logged at debug level with the tool name.
876
+ * - A handler failure stores nothing, so a retry runs it again.
877
+ * - An `ok: false` result (a {@link ToolOutcome} envelope failure) is never
878
+ * stored: a "not found" must not replay after the thing exists.
879
+ *
880
+ * This is replay, not exactly-once: two concurrent calls with one key both
881
+ * miss and both run.
882
+ *
883
+ * The key is the whole identity of a request. A hit compares neither the
884
+ * params nor the schema version (a row written under an older schema replays
885
+ * whenever it still decodes), and rows never expire: the store keeps a result
886
+ * until something removes it. A key reused with different params therefore
887
+ * replays the first result, so {@link ToolReplayOptions.key} must fold in a
888
+ * digest of any params that can vary under one id.
889
+ *
890
+ * Wrap it inside {@link ToolOutcome.catchAsEnvelope}, so the envelope sees the
891
+ * handler's real errors and replay sees only real successes.
892
+ *
893
+ * @example
894
+ * ```ts
895
+ * import { ToolReplay } from "@effected/mcp"
896
+ * import { Option, Schema } from "effect"
897
+ *
898
+ * const Created = ToolReplay.success(Schema.Struct({ ok: Schema.Literal(true), id: Schema.Number }))
899
+ * const create = ToolReplay.wrap({
900
+ * tool: "goal_create",
901
+ * success: Created,
902
+ * key: (params: { readonly requestId?: string }) => Option.fromUndefinedOr(params.requestId),
903
+ * })(createGoal)
904
+ * ```
905
+ *
906
+ * @public
907
+ */
908
+ export declare class ToolReplay {
909
+ private constructor();
910
+ /**
911
+ * The marker field, to spread into each object member of a replayable
912
+ * success schema. {@link ToolReplay.success} adds it for you.
913
+ */
914
+ static readonly marker: {
915
+ readonly _idempotentReplay: Schema.optionalKey<Schema.Literal<true>>;
916
+ };
917
+ /**
918
+ * A struct with {@link ToolReplay.marker} added. Use it for each object
919
+ * success member, so core's encoder keeps the marker on a replayed reply.
920
+ *
921
+ * @remarks
922
+ * Built with core's `fieldsAssign`, which, like `mapFields`, does not carry
923
+ * the struct's own `.check(...)` refinements over: apply checks to the
924
+ * result, not to the struct passed in.
925
+ */
926
+ static readonly success: <const Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) => Schema.Struct<Struct.Simplify<Struct.Assign<Fields, typeof ToolReplay.marker>>>;
927
+ /**
928
+ * The `KeyValueStore` key one call is stored under: `<tool>:<key>`. Split on
929
+ * the first `:` to recover both parts; MCP tool names are `[A-Za-z0-9_.-]`.
930
+ */
931
+ static readonly storeKey: (tool: string, key: string) => string;
932
+ /**
933
+ * Wraps a handler with replay. Throws at wrap time when `tool` contains
934
+ * `:`, a wiring mistake, so it surfaces when the server is built.
935
+ */
936
+ static readonly wrap: <P, S extends Schema.Top, Store extends ToolReplayStore | undefined = undefined>(options: ToolReplayOptions<P, S, Store>) => (<E, R>(handler: (params: P) => Effect.Effect<S["Type"], E, R>) => (params: P) => Effect.Effect<S["Type"], E, R | ([Store] extends [ToolReplayStore] ? never : KeyValueStore.KeyValueStore) | S["DecodingServices"] | S["EncodingServices"]>);
937
+ }
938
+ export type { FormatUnknownKeysOptions, McpLaunchOptions, McpStdioOptions, McpToolkitOptions, ToolOutcomeEnvelopeOptions, ToolOutcomeErrorInput, ToolOutcomeFailure, ToolOutcomeFailures, ToolReplayOptions, ToolReplayStore, UnionHandlerOptions, UnionTool, UnionToolOptions, UnknownKeysLevel };
640
939
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -3,6 +3,8 @@ import { ToolFailure } from "./ToolFailure.js";
3
3
  import { ToolInputSchema } from "./ToolInputSchema.js";
4
4
  import { McpToolkit } from "./McpToolkit.js";
5
5
  import { ToolOutputSchema } from "./ToolOutputSchema.js";
6
+ import { ToolOutcome } from "./ToolOutcome.js";
6
7
  import { ToolRefusal } from "./ToolRefusal.js";
8
+ import { ToolReplay } from "./ToolReplay.js";
7
9
 
8
- export { McpStdio, McpToolkit, ToolFailure, ToolInputSchema, ToolOutputSchema, ToolRefusal };
10
+ export { McpStdio, McpToolkit, ToolFailure, ToolInputSchema, ToolOutcome, ToolOutputSchema, ToolRefusal, ToolReplay };
@@ -40,36 +40,12 @@ const INVALID_REQUEST_FRAME = `${JSON.stringify({
40
40
  message: "Invalid Request"
41
41
  }
42
42
  })}\n`;
43
- /**
44
- * The JSON-RPC 2.0 answer to a request whose method is one of Effect's
45
- * internal `@effect/rpc/` control messages, newline-framed, echoing the
46
- * request's `id`.
47
- *
48
- * @internal
49
- */
50
- const methodNotFoundFrame = (id) => `${JSON.stringify({
51
- jsonrpc: "2.0",
52
- id,
53
- error: {
54
- code: McpSchema.METHOD_NOT_FOUND_ERROR_CODE,
55
- message: "Method not found"
56
- }
57
- })}\n`;
58
- /**
59
- * The method prefix core's JSON-RPC decoder reserves for its own RPC control
60
- * messages (`Eof`, `Interrupt`, `Ack`, …).
61
- *
62
- * @internal
63
- */
64
- const RPC_CONTROL_PREFIX = "@effect/rpc/";
65
43
  /** JSON's insignificant whitespace, minus the newline that ends the line. */
66
44
  const BLANK = /^[ \t\r]*$/;
67
45
  const isNullish = (value) => value === null || value === void 0;
68
- /** {@link answerFor}'s verdict for a line that is neither forwarded nor answered. */
69
- const DROP = "";
70
46
  /**
71
- * The guard's answer to one non-blank line within the cap, `DROP` to swallow
72
- * it silently, or `undefined` to forward it to core.
47
+ * The guard's answer to one non-blank line within the cap, or `undefined` to
48
+ * forward it to core.
73
49
  *
74
50
  * @remarks
75
51
  * A frame that parses is forwarded unless core would skip it without a
@@ -82,16 +58,9 @@ const DROP = "";
82
58
  * `method` and every response an `id`, so it is neither, and core ignores it
83
59
  * as a response to nothing.
84
60
  *
85
- * A frame whose `method` starts with `@effect/rpc/` never reaches core. Core's
86
- * JSON-RPC decoder reads such a frame with no `id` as one of its own RPC
87
- * control messages, so `{"jsonrpc":"2.0","method":"@effect/rpc/Eof"}` from
88
- * any client silently stops the server (Effect-TS/effect#8499). A
89
- * notification of that shape is dropped; a request, which carries an `id`,
90
- * is answered `-32601` Method not found, as no MCP method has that prefix.
91
- * Remove this branch once Effect-TS/effect#8499 is fixed in the installed
92
- * `effect` (the open PR #8509 proposes the fix) and a probe shows an
93
- * unguarded server still answering a ping sent after an `@effect/rpc/Eof`
94
- * notification.
61
+ * A frame whose `method` starts with `@effect/rpc/` is an ordinary request or
62
+ * notification to core, which serves MCP with Effect's RPC control messages
63
+ * turned off: a notification is ignored and a request answered `-32601`.
95
64
  *
96
65
  * Arrays are forwarded: core answers a batch `-32600` itself. So is any object
97
66
  * with an `id` and no `method`, which is a response; JSON-RPC never answers a
@@ -107,10 +76,7 @@ const answerFor = (line) => {
107
76
  if (typeof value !== "object" || value === null) return INVALID_REQUEST_FRAME;
108
77
  if (Array.isArray(value)) return void 0;
109
78
  const message = value;
110
- if (Object.hasOwn(message, "method")) {
111
- if (typeof message.method === "string" && message.method.startsWith("@effect/rpc/")) return isNullish(message.id) ? DROP : methodNotFoundFrame(message.id);
112
- return typeof message.method !== "string" && isNullish(message.id) ? INVALID_REQUEST_FRAME : void 0;
113
- }
79
+ if (Object.hasOwn(message, "method")) return typeof message.method !== "string" && isNullish(message.id) ? INVALID_REQUEST_FRAME : void 0;
114
80
  return Object.hasOwn(message, "id") ? void 0 : INVALID_REQUEST_FRAME;
115
81
  };
116
82
  /**
@@ -125,8 +91,7 @@ const answerFor = (line) => {
125
91
  * JSON-RPC 2.0 requires a reply to each of them, so the guard frames stdin
126
92
  * exactly as core does and answers such a line itself, returning
127
93
  * {@link PARSE_ERROR_FRAME} or {@link INVALID_REQUEST_FRAME} for the caller
128
- * to write. It also keeps `@effect/rpc/` control methods away from core (see
129
- * `answerFor`), which would otherwise let one client line stop the server.
94
+ * to write.
130
95
  *
131
96
  * - Decoding mirrors core: one streaming UTF-8 decoder, so a character split
132
97
  * across chunks decodes whole, a byte-order mark is stripped only at the
@@ -137,8 +102,6 @@ const answerFor = (line) => {
137
102
  * - A line that is JSON is answered `-32600` when it is not an object or an
138
103
  * array, when its `method` is not a string and it has no usable `id`, or
139
104
  * when it has neither `method` nor `id`.
140
- * - A line whose `method` starts with `@effect/rpc/` is answered `-32601`
141
- * when it carries an `id` and dropped when it does not.
142
105
  * - Everything else goes to core.
143
106
  * - A line longer than `maxFrameLength` code units is answered once, as soon
144
107
  * as the held part exceeds the cap, and the rest of it is discarded up to
@@ -181,7 +144,7 @@ const makeFrameGuard = (maxFrameLength = MAX_FRAME_LENGTH) => {
181
144
  if (BLANK.test(line)) continue;
182
145
  const answer = answerFor(line);
183
146
  if (answer === void 0) forward += `${line}\n`;
184
- else if (answer !== DROP) replies.push(answer);
147
+ else replies.push(answer);
185
148
  }
186
149
  pending += text.slice(start);
187
150
  if (pending.length > maxFrameLength) {
@@ -235,4 +198,4 @@ const guardStdin = (stdio) => {
235
198
  */
236
199
  const makeGuardedStdio = () => Layer.effect(Stdio.Stdio, Effect.map(Stdio.Stdio, guardStdin));
237
200
 
238
- export { INVALID_REQUEST_FRAME, MAX_FRAME_LENGTH, PARSE_ERROR_FRAME, RPC_CONTROL_PREFIX, guardStdin, makeFrameGuard, makeGuardedStdio, methodNotFoundFrame };
201
+ export { INVALID_REQUEST_FRAME, MAX_FRAME_LENGTH, PARSE_ERROR_FRAME, guardStdin, makeFrameGuard, makeGuardedStdio };
package/node.d.ts ADDED
@@ -0,0 +1,126 @@
1
+ import { Writable } from "node:stream";
2
+ import { Effect } from "effect";
3
+ /**
4
+ * Anything with a replaceable `write`: a patched stream (Node's
5
+ * `process.stdout` and `process.stderr`) and a sink captured writes go to (a
6
+ * Node `Writable`, {@link StdioCapture.nullWritable}, {@link StdioCapture.discard},
7
+ * {@link StdioCapture.toStderr}).
8
+ *
9
+ * @remarks
10
+ * A sink's `write` receives exactly the arguments the patched stream's
11
+ * `write` was called with, including any trailing callback, so a sink must
12
+ * call that callback itself, as a `Writable` does.
13
+ *
14
+ * @public
15
+ */
16
+ interface StdioSink {
17
+ write(chunk: any, ...rest: Array<any>): boolean;
18
+ }
19
+ /**
20
+ * The two streams {@link StdioCapture.install} patches. Node's `process`
21
+ * satisfies it; pass it in from `main.ts`.
22
+ *
23
+ * @public
24
+ */
25
+ interface StdioCaptureHost {
26
+ readonly stdout: StdioSink;
27
+ readonly stderr: StdioSink;
28
+ }
29
+ /**
30
+ * The sinks for one capture. `stderr` passes through to the real stream when
31
+ * omitted.
32
+ *
33
+ * @public
34
+ */
35
+ interface StdioCaptureOptions {
36
+ readonly stdout: StdioSink;
37
+ readonly stderr?: StdioSink | undefined;
38
+ }
39
+ /**
40
+ * Diverts a host's stdout (and optionally stderr) writes to a sink, for code
41
+ * running inside one async context only, while every other context keeps
42
+ * writing to the real streams.
43
+ *
44
+ * @remarks
45
+ * For an MCP server over stdio, stdout is the JSON-RPC wire. A chatty library
46
+ * run in-process (a test runner, a bundler, a linter) must keep its output off
47
+ * it while other handlers, and the transport itself, keep writing normally.
48
+ *
49
+ * `install` patches the host's `write` methods once, keeping the unbound
50
+ * originals, and routes every write through an `AsyncLocalStorage`: inside a
51
+ * {@link StdioCapture.run} or {@link StdioCapture.capture} context a write goes
52
+ * to that context's sink, and outside it to the original stream.
53
+ *
54
+ * An Effect fiber resumes in the async context it suspended in, not the
55
+ * context of whatever woke it, so a server fiber woken by code inside a
56
+ * capture (its response written to a queue, say) keeps writing to the wire.
57
+ *
58
+ * Two kinds of write escape a capture and reach the original stream:
59
+ *
60
+ * - **Writes from a context the capture lost.** Callbacks registered outside the
61
+ * capture keep the context they were registered in: native addons, worker
62
+ * thread output relayed by the main thread, listeners on a child process's
63
+ * pipes attached earlier. On stdout, that is the wire.
64
+ * - **Fibers started inside the capture keep capturing after it ends.** A fiber
65
+ * forked inside it (`Effect.forkDetach`, a memoized layer first built inside
66
+ * it, a background fiber a library starts) keeps writing to the sink, even
67
+ * after the capture's own work is done.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * import { StdioCapture } from "@effected/mcp/node"
72
+ *
73
+ * const capture = StdioCapture.install(process)
74
+ * // in a tool handler: keep the runner's own console output off the wire
75
+ * const report = yield* runTests(files).pipe(capture.capture({ stdout: capture.toStderr }))
76
+ * ```
77
+ *
78
+ * @public
79
+ */
80
+ export declare class StdioCapture {
81
+ /** A sink that writes through the host's original, unpatched stderr: captured output stays visible off the wire. */
82
+ readonly toStderr: StdioSink;
83
+ private constructor();
84
+ /**
85
+ * Patches `host`'s two streams. Idempotent per stream, across every installed
86
+ * copy of this package: a stream already patched (through another host
87
+ * object, or another copy) keeps its one patch and its original `write`.
88
+ */
89
+ static readonly install: (host: StdioCaptureHost) => StdioCapture;
90
+ /**
91
+ * A write-only sink that drops every write and calls the write's callback.
92
+ *
93
+ * @remarks
94
+ * It is a {@link StdioSink}, not a stream: it has no `on`, `pipe` target
95
+ * protocol or `setMaxListeners`, so it cannot stand in where a library
96
+ * expects a `Writable` (a test runner's logger stream that worker output
97
+ * is piped into, for one). Use {@link StdioCapture.nullWritable} there; it
98
+ * serves as a capture sink too.
99
+ */
100
+ static readonly discard: StdioSink;
101
+ /**
102
+ * A new Node `Writable` that drops everything written to it: a real stream,
103
+ * so it works as a `pipe` target, takes `setMaxListeners`, emits `drain`
104
+ * and `finish`, and is also a {@link StdioSink} for a capture.
105
+ *
106
+ * @remarks
107
+ * A factory, not a shared value: a stream ends once, and a source piped in
108
+ * with the default `{ end: true }` ends its destination. Build one per
109
+ * consumer.
110
+ */
111
+ static readonly nullWritable: () => Writable;
112
+ /**
113
+ * Runs `evaluate` with its writes captured, for code that is not Effect.
114
+ * A synchronous throw from `evaluate` rejects the returned promise.
115
+ */
116
+ run<A>(options: StdioCaptureOptions, evaluate: () => PromiseLike<A>): Promise<A>;
117
+ /**
118
+ * Runs an effect with its writes captured: on its own fiber, started inside
119
+ * the capture's context, so the context survives every suspension of that
120
+ * fiber. Interrupting the returned effect interrupts that fiber and waits
121
+ * for it.
122
+ */
123
+ capture(options: StdioCaptureOptions): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
124
+ }
125
+ export type { StdioCaptureHost, StdioCaptureOptions, StdioSink };
126
+ //# sourceMappingURL=node.d.ts.map
package/node.js ADDED
@@ -0,0 +1,3 @@
1
+ import { StdioCapture } from "./StdioCapture.js";
2
+
3
+ export { StdioCapture };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/mcp",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "description": "The boundary layer of an effect/ai MCP server: stdio wiring that keeps stdout the wire, tool-failure shaping, strict-input walkers, and test clients",
6
6
  "keywords": [
@@ -39,6 +39,11 @@
39
39
  "import": "./guard.js",
40
40
  "default": "./guard.js"
41
41
  },
42
+ "./node": {
43
+ "types": "./node.d.ts",
44
+ "import": "./node.js",
45
+ "default": "./node.js"
46
+ },
42
47
  "./testing": {
43
48
  "types": "./testing.d.ts",
44
49
  "import": "./testing.js",
@@ -47,7 +52,7 @@
47
52
  "./package.json": "./package.json"
48
53
  },
49
54
  "peerDependencies": {
50
- "@effected/engine": "^0.8.0",
55
+ "@effected/engine": "^0.10.0",
51
56
  "effect": "^4.0.0"
52
57
  },
53
58
  "engines": {
package/testing.d.ts CHANGED
@@ -214,7 +214,15 @@ export declare class McpHarness {
214
214
  readonly sentSoFar: Effect.Effect<ReadonlyArray<unknown>>;
215
215
  /** Everything written to stderr, plus every captured log line. */
216
216
  readonly stderrSoFar: Effect.Effect<string>;
217
- /** Every captured `console.log`, `info` or `debug` call. */
217
+ /**
218
+ * Every captured `console.log`, `info` or `debug` call, its arguments joined with a space, each rendered close to
219
+ * what the console would show: a string as itself, a value with its own string form as that form (an `Error` as
220
+ * `Error: boom`, a number, a date), an array or a plain object as JSON (`{"message":"x"}`, not just `x`).
221
+ *
222
+ * @remarks
223
+ * Rendering never throws inside the server's own call. A value whose string form throws (an object with no
224
+ * prototype, a throwing `toString`) and has no JSON falls back to `ErrorText.describe` from `@effected/engine`.
225
+ */
218
226
  readonly consoleLogSoFar: Effect.Effect<ReadonlyArray<string>>;
219
227
  /**
220
228
  * End stdin, as a client disconnecting does. The server drains first:
@@ -5,7 +5,7 @@
5
5
  "toolPackages": [
6
6
  {
7
7
  "packageName": "@microsoft/api-extractor",
8
- "packageVersion": "7.59.1"
8
+ "packageVersion": "7.59.4"
9
9
  }
10
10
  ]
11
11
  }