@effected/mcp-kit 0.0.0-stage → 0.13.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/ToolReplay.js ADDED
@@ -0,0 +1,129 @@
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
+ * Inside an MCP server, pass the store. A service left in a handler's `R`
19
+ * has to be declared in the tool's `dependencies`, which makes it a
20
+ * request-level dependency that only MCP middleware can meet. Read
21
+ * `KeyValueStore` once when the handlers are built, with the effect form of
22
+ * `Toolkit.toLayer`, and hand it to `wrap` as `store`: a `KeyValueStore`
23
+ * satisfies {@link ToolReplayStore} as is.
24
+ *
25
+ * Semantics, per call:
26
+ *
27
+ * - A key of `Option.none()` runs the handler; nothing is read or stored.
28
+ * - A stored value that decodes through `success` is returned with the marker,
29
+ * and the handler does not run.
30
+ * - A miss runs the handler and stores its result, best-effort. A store that
31
+ * fails, a row that no longer decodes (written under an older schema, or
32
+ * corrupt), and a failed write are each a miss or a skipped write, never a
33
+ * tool error. Each is logged at debug level with the tool name.
34
+ * - A handler failure stores nothing, so a retry runs it again.
35
+ * - An `ok: false` result (a {@link ToolOutcome} envelope failure) is never
36
+ * stored: a "not found" must not replay after the thing exists.
37
+ *
38
+ * This is replay, not exactly-once: two concurrent calls with one key both
39
+ * miss and both run.
40
+ *
41
+ * The key is the whole identity of a request. A hit compares neither the
42
+ * params nor the schema version (a row written under an older schema replays
43
+ * whenever it still decodes), and rows never expire: the store keeps a result
44
+ * until something removes it. A key reused with different params therefore
45
+ * replays the first result, so {@link ToolReplayOptions.key} must fold in a
46
+ * digest of any params that can vary under one id.
47
+ *
48
+ * Wrap it inside {@link ToolOutcome.catchAsEnvelope}, so the envelope sees the
49
+ * handler's real errors and replay sees only real successes.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * import { ToolReplay } from "@effected/mcp-kit"
54
+ * import { Option, Schema } from "effect"
55
+ *
56
+ * const Created = ToolReplay.success(Schema.Struct({ ok: Schema.Literal(true), id: Schema.Number }))
57
+ * const create = ToolReplay.wrap({
58
+ * tool: "goal_create",
59
+ * success: Created,
60
+ * key: (params: { readonly requestId?: string }) => Option.fromUndefinedOr(params.requestId),
61
+ * })(createGoal)
62
+ * ```
63
+ *
64
+ * @public
65
+ */
66
+ var ToolReplay = class ToolReplay {
67
+ constructor() {}
68
+ /**
69
+ * The marker field, to spread into each object member of a replayable
70
+ * success schema. {@link ToolReplay.success} adds it for you.
71
+ */
72
+ 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." })) };
73
+ /**
74
+ * A struct with {@link ToolReplay.marker} added. Use it for each object
75
+ * success member, so core's encoder keeps the marker on a replayed reply.
76
+ *
77
+ * @remarks
78
+ * Built with core's `fieldsAssign`, which, like `mapFields`, does not carry
79
+ * the struct's own `.check(...)` refinements over: apply checks to the
80
+ * result, not to the struct passed in.
81
+ */
82
+ static success = (schema) => schema.pipe(Schema.fieldsAssign(ToolReplay.marker));
83
+ /**
84
+ * The `KeyValueStore` key one call is stored under: `<tool>:<key>`. Split on
85
+ * the first `:` to recover both parts; MCP tool names are `[A-Za-z0-9_.-]`.
86
+ */
87
+ static storeKey = (tool, key) => `${tool}:${key}`;
88
+ /**
89
+ * Wraps a handler with replay. Throws at wrap time when `tool` contains
90
+ * `:`, a wiring mistake, so it surfaces when the server is built.
91
+ */
92
+ static wrap = (options) => {
93
+ if (options.tool.includes(":")) throw new Error(`ToolReplay.wrap: tool name "${options.tool}" contains ":", the stored-key separator.`);
94
+ const { tool, success } = options;
95
+ const given = options.store;
96
+ const json = Schema.fromJsonString(Schema.toCodecJson(success));
97
+ const decode = Schema.decodeEffect(json);
98
+ const encode = Schema.encodeEffect(json);
99
+ return (handler) => (params) => {
100
+ const found = options.key(params);
101
+ if (Option.isNone(found)) return handler(params);
102
+ const storeKey = ToolReplay.storeKey(tool, found.value);
103
+ return Effect.gen(function* () {
104
+ const store = given ?? (yield* KeyValueStore.KeyValueStore);
105
+ 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", {
106
+ tool,
107
+ cause
108
+ })), Effect.orElseSucceed(() => Option.none()));
109
+ if (Option.isSome(cached)) {
110
+ yield* Effect.annotateCurrentSpan("mcp.tool.replayed", true);
111
+ return withMarker(cached.value);
112
+ }
113
+ yield* Effect.annotateCurrentSpan("mcp.tool.replayed", false);
114
+ const result = yield* handler(params);
115
+ 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", {
116
+ tool,
117
+ cause
118
+ })), Effect.ignore);
119
+ return result;
120
+ }).pipe(Effect.withSpan("ToolReplay.wrap", { attributes: { "mcp.tool.name": tool } }));
121
+ };
122
+ };
123
+ };
124
+ const withMarker = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? {
125
+ ...value,
126
+ _idempotentReplay: true
127
+ } : value;
128
+
129
+ export { ToolReplay };
package/guard.d.ts ADDED
@@ -0,0 +1,189 @@
1
+ import { Effect, Layer, Runtime } from "effect";
2
+ /**
3
+ * The slice of the host process the guard uses. Node's `process` satisfies it.
4
+ *
5
+ * @public
6
+ */
7
+ interface McpGuardHost {
8
+ /** Register the `uncaughtException` listener. */
9
+ on(event: "uncaughtException", listener: (error: Error, origin: string) => void): unknown;
10
+ /** Register the `unhandledRejection` listener. */
11
+ on(event: "unhandledRejection", listener: (reason: unknown) => void): unknown;
12
+ /** Raise an `uncaughtException` to the registered listeners. Used only by {@link McpGuardRunOptions.injectCrash}. */
13
+ emit(event: "uncaughtException", error: Error, origin: "uncaughtException" | "unhandledRejection"): unknown;
14
+ /** Raise an `unhandledRejection` to the registered listeners. Used only by {@link McpGuardRunOptions.injectCrash}. */
15
+ emit(event: "unhandledRejection", reason: unknown, promise: Promise<unknown>): unknown;
16
+ /** Where every guard report is written. Never stdout: that is the JSON-RPC wire. */
17
+ readonly stderr: {
18
+ write(chunk: string): unknown;
19
+ };
20
+ /** End the process at once. */
21
+ exit(code?: number): never;
22
+ }
23
+ /**
24
+ * When a stray exception or rejection ends the process.
25
+ *
26
+ * @remarks
27
+ * `"exit"` exits 1 whenever it happens. `"exitBeforeConnect"` exits 1 until
28
+ * the server is serving, then logs and keeps serving: a server that dies
29
+ * mid-session deregisters every tool from the client. `"log"` never exits.
30
+ *
31
+ * @public
32
+ */
33
+ interface McpGuardPolicy {
34
+ /** For `uncaughtException`. */
35
+ readonly onUncaught: "exit" | "exitBeforeConnect";
36
+ /** For `unhandledRejection`. Defaults to `"exit"`. */
37
+ readonly onRejection?: "exit" | "exitBeforeConnect" | "log" | undefined;
38
+ }
39
+ /**
40
+ * What {@link McpGuardRunOptions.load} resolves to: the server and the
41
+ * platform runner to launch it with.
42
+ *
43
+ * @public
44
+ */
45
+ interface McpGuardedServer<ROut, E> {
46
+ /** The whole server layer, stdio included, with nothing left to provide. */
47
+ readonly layer: Layer.Layer<ROut, E, never>;
48
+ /** The platform's `runMain`, such as `NodeRuntime.runMain` from `@effect/platform-node`. */
49
+ readonly runMain: (effect: Effect.Effect<never, Error>, options: {
50
+ readonly teardown: Runtime.Teardown;
51
+ }) => void;
52
+ /** Formats a reported error from here on; the guard's own dependency-free formatter until then. */
53
+ readonly format?: ((error: unknown) => string) | undefined;
54
+ }
55
+ /**
56
+ * Options for {@link McpGuard.run}.
57
+ *
58
+ * @public
59
+ */
60
+ interface McpGuardRunOptions<ROut, E> {
61
+ /** Prefixes every report the guard writes, as in `my-server: uncaughtException (…): …`. */
62
+ readonly label: string;
63
+ /** The process: pass `process`. */
64
+ readonly host: McpGuardHost;
65
+ /** Defaults to `{ onUncaught: "exit", onRejection: "exit" }`. */
66
+ readonly policy?: McpGuardPolicy | undefined;
67
+ /**
68
+ * Import and assemble the server. Everything that can throw while loading
69
+ * belongs here, behind a dynamic `import()`, so the guards are already
70
+ * listening when it runs.
71
+ */
72
+ readonly load: () => Promise<McpGuardedServer<ROut, E>>;
73
+ /**
74
+ * For a test of the guards themselves: raise one stray `kind` at `at`, so
75
+ * both halves of a policy can be driven end to end in a real process.
76
+ *
77
+ * @remarks
78
+ * - `"load"` raises it once both listeners are installed and before
79
+ * `load()` is called, then waits for the guard to handle it: `load()`
80
+ * is not called until the listener has run. If a test double's `exit`
81
+ * throws instead of ending the process, `run` rejects with what it
82
+ * threw and never calls `load()`. The pre-connect half of
83
+ * the policy applies, so `"exitBeforeConnect"` exits 1 here, and only
84
+ * an `onRejection` of `"log"` lets the process go on to load and serve.
85
+ * The report uses the guard's own formatter, since no `format` is
86
+ * loaded yet.
87
+ * - `"connected"` raises it on a timer right after the server is
88
+ * serving, where `"exitBeforeConnect"` logs and keeps serving. A throw
89
+ * from a test double's `exit` there is dropped.
90
+ *
91
+ * **The `"connected"` report is asynchronous.** It is raised on a later
92
+ * tick, a `setTimeout(0)` after the server starts serving, so it can land
93
+ * after the first responses a test reads: reading stderr once, right
94
+ * after the `initialize` or `tools/list` response, can see an empty
95
+ * buffer. Wait for it with `McpProcess.stderrUntil` from
96
+ * `@effected/mcp-kit/testing` rather than reading it once.
97
+ *
98
+ * Either is raised only through `host.emit`, on a `setTimeout(0)` tick,
99
+ * never as a real throw or rejection, so the guard's listeners handle it
100
+ * the same way under the real `process` and under a test double. Under
101
+ * `process`, every listener registered for the event sees it, not only
102
+ * the guard's. It carries an `[injected]` message; a rejection is emitted
103
+ * with an already-handled rejected promise. `undefined`, or
104
+ * an `at` or `kind` outside these values, does nothing at all. Wire it to
105
+ * an environment variable only a test sets.
106
+ */
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/kit/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";
122
+ }
123
+ /**
124
+ * Crash guards for an MCP server process, installed before the server's
125
+ * module graph is loaded. Imported from `@effected/mcp-kit/guard`.
126
+ *
127
+ * @remarks
128
+ * {@link McpGuard.run} is `ProcessGuard.run` from `@effected/kit/guard`
129
+ * with an MCP launch in its `load`: it registers `uncaughtException` and
130
+ * `unhandledRejection` listeners on `host`, then awaits `load()`, then
131
+ * launches the server with `McpStdio.launch` and `McpStdio.teardown` under
132
+ * the `runMain` it returned. A server on another transport (an LSP over
133
+ * `vscode-languageserver`, say) uses `ProcessGuard.run` directly and calls
134
+ * its `markConnected` itself. This entrypoint statically imports only
135
+ * `@effected/kit/guard`, which imports nothing: it loads `effect` and the
136
+ * stdio wiring only after the guards are listening, so a throw while any of
137
+ * it evaluates is still reported on stderr.
138
+ *
139
+ * - "Connected" means the whole server layer has built, so stdin is being
140
+ * read. That is before any client sends `initialize`.
141
+ * - `injectCrash` drives either half of the policy from a test, before
142
+ * `load()` or once connected; unset, it does nothing.
143
+ * - A `load()` that rejects is reported as `startup failed` and exits 1
144
+ * whatever the policy. Left to a log-only rejection listener, it would
145
+ * let the event loop drain and exit 0 with no server.
146
+ * - Installing an `unhandledRejection` listener switches off Node's default
147
+ * of throwing on one, so `"log"` really does keep the process running.
148
+ * - An exit from a guard skips Effect's finalizers and the teardown.
149
+ * - Keeping on serving after a crash works because the platform `runMain`
150
+ * holds the process open while the server fiber lives.
151
+ * - A layer that fails to build is still reported by `McpStdio.launch`.
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * import { McpGuard } from "@effected/mcp-kit/guard";
156
+ *
157
+ * await McpGuard.run({
158
+ * label: "my-server",
159
+ * host: process,
160
+ * policy: { onUncaught: "exitBeforeConnect", onRejection: "log" },
161
+ * load: async () => {
162
+ * const { NodeRuntime } = await import("@effect/platform-node");
163
+ * const { Main } = await import("./server.js");
164
+ * return { layer: Main, runMain: NodeRuntime.runMain };
165
+ * },
166
+ * });
167
+ * ```
168
+ *
169
+ * @public
170
+ */
171
+ export declare class McpGuard {
172
+ private constructor();
173
+ /**
174
+ * Parse a test-only crash-injection setting into {@link McpGuardRunOptions.injectCrash}: `<at>:<kind>`, where `at`
175
+ * is `"load"` or `"connected"` and `kind` is `"uncaughtException"` or `"unhandledRejection"`. Anything else, or no
176
+ * value, is `undefined`. The same grammar as `ProcessGuard.parseInjectCrash` from `@effected/kit/guard`, which
177
+ * this is, so an MCP launcher need not import the engine guard for it.
178
+ *
179
+ * @example
180
+ * ```ts
181
+ * McpGuard.parseInjectCrash("load:uncaughtException"); // => { at: "load", kind: "uncaughtException" }
182
+ * ```
183
+ */
184
+ static readonly parseInjectCrash: (value: string | undefined) => InjectCrash | undefined;
185
+ /** Install the guards, load the server, and launch it. Resolves once the server is launched. */
186
+ static readonly run: <ROut, E>(options: McpGuardRunOptions<ROut, E>) => Promise<void>;
187
+ }
188
+ export type { InjectCrash, McpGuardHost, McpGuardPolicy, McpGuardRunOptions, McpGuardedServer };
189
+ //# sourceMappingURL=guard.d.ts.map
package/guard.js ADDED
@@ -0,0 +1,3 @@
1
+ import { McpGuard } from "./McpGuard.js";
2
+
3
+ export { McpGuard };