@effected/mcp 0.12.0 → 0.14.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 DELETED
@@ -1,129 +0,0 @@
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"
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 DELETED
@@ -1,189 +0,0 @@
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/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/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";
122
- }
123
- /**
124
- * Crash guards for an MCP server process, installed before the server's
125
- * module graph is loaded. Imported from `@effected/mcp/guard`.
126
- *
127
- * @remarks
128
- * {@link McpGuard.run} is `ProcessGuard.run` from `@effected/engine/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/engine/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/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/engine/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 DELETED
@@ -1,3 +0,0 @@
1
- import { McpGuard } from "./McpGuard.js";
2
-
3
- export { McpGuard };
@@ -1,34 +0,0 @@
1
- import { Runtime } from "effect";
2
-
3
- // src/internal/LaunchFailed.ts
4
- /**
5
- * The failure `McpStdio.launch` re-raises after reporting a launch failure on
6
- * stderr itself. `errorReported: false` keeps `runMain` from reporting it a
7
- * second time — through a logger that writes to stdout — and the original exit
8
- * code rides along for the teardown.
9
- *
10
- * @remarks
11
- * Both runtime markers are prototype getters rather than own fields, so a JSON
12
- * or logger dump of the error does not carry them; the code lives in a private
13
- * field.
14
- *
15
- * @internal
16
- */
17
- var LaunchFailed = class extends Error {
18
- #code;
19
- constructor(code) {
20
- super(`the MCP server failed to launch (exit ${code})`);
21
- this.name = "LaunchFailed";
22
- this.#code = code;
23
- }
24
- /** Already reported on stderr: `runMain` must not report it again. */
25
- get [Runtime.errorReported]() {
26
- return false;
27
- }
28
- /** The exit code the launch failed with. */
29
- get [Runtime.errorExitCode]() {
30
- return this.#code;
31
- }
32
- };
33
-
34
- export { LaunchFailed };
@@ -1,201 +0,0 @@
1
- import { Effect, Layer, Stdio, Stream } from "effect";
2
- import { McpSchema } from "effect/ai";
3
-
4
- // src/internal/StdinFrames.ts
5
- /**
6
- * The line-length cap core's stdio NDJSON decoder applies, in the same unit
7
- * (UTF-16 code units of the decoded text) and with the same predicate
8
- * (`length > cap`). The guard answers a longer line itself, so no line core
9
- * would reject ever reaches core.
10
- *
11
- * @internal
12
- */
13
- const MAX_FRAME_LENGTH = 16777216;
14
- /**
15
- * The JSON-RPC 2.0 answer to a frame that is not JSON, newline-framed. The id
16
- * is `null` because no id can be read from an unparseable frame.
17
- *
18
- * @internal
19
- */
20
- const PARSE_ERROR_FRAME = `${JSON.stringify({
21
- jsonrpc: "2.0",
22
- id: null,
23
- error: {
24
- code: McpSchema.PARSE_ERROR_CODE,
25
- message: "Parse error"
26
- }
27
- })}\n`;
28
- /**
29
- * The JSON-RPC 2.0 answer to a frame that is JSON but no JSON-RPC message
30
- * core can handle, newline-framed. The id is `null` because such a frame
31
- * carries no usable id.
32
- *
33
- * @internal
34
- */
35
- const INVALID_REQUEST_FRAME = `${JSON.stringify({
36
- jsonrpc: "2.0",
37
- id: null,
38
- error: {
39
- code: McpSchema.INVALID_REQUEST_ERROR_CODE,
40
- message: "Invalid Request"
41
- }
42
- })}\n`;
43
- /** JSON's insignificant whitespace, minus the newline that ends the line. */
44
- const BLANK = /^[ \t\r]*$/;
45
- const isNullish = (value) => value === null || value === void 0;
46
- /**
47
- * The guard's answer to one non-blank line within the cap, or `undefined` to
48
- * forward it to core.
49
- *
50
- * @remarks
51
- * A frame that parses is forwarded unless core would skip it without a
52
- * reply, where JSON-RPC 2.0 requires an Invalid Request:
53
- *
54
- * - a value that is neither an object nor an array;
55
- * - an object with a `method` that is not a string and no usable `id` (with
56
- * an `id`, core answers `-32601` itself);
57
- * - an object with neither `method` nor `id`: every JSON-RPC request carries a
58
- * `method` and every response an `id`, so it is neither, and core ignores it
59
- * as a response to nothing.
60
- *
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`.
64
- *
65
- * Arrays are forwarded: core answers a batch `-32600` itself. So is any object
66
- * with an `id` and no `method`, which is a response; JSON-RPC never answers a
67
- * response.
68
- */
69
- const answerFor = (line) => {
70
- let value;
71
- try {
72
- value = JSON.parse(line);
73
- } catch {
74
- return PARSE_ERROR_FRAME;
75
- }
76
- if (typeof value !== "object" || value === null) return INVALID_REQUEST_FRAME;
77
- if (Array.isArray(value)) return void 0;
78
- const message = value;
79
- if (Object.hasOwn(message, "method")) return typeof message.method !== "string" && isNullish(message.id) ? INVALID_REQUEST_FRAME : void 0;
80
- return Object.hasOwn(message, "id") ? void 0 : INVALID_REQUEST_FRAME;
81
- };
82
- /**
83
- * A stateful step over stdin chunks that forwards only complete lines core's
84
- * decoder will parse and answer, each with its newline.
85
- *
86
- * @remarks
87
- * Core's stdio decoder skips a line it cannot use and keeps serving
88
- * (Effect-TS/effect PR #8541): a line that is not JSON, a JSON value that is
89
- * not an object, or an object that is no JSON-RPC message is dropped without
90
- * a reply, and a line past its buffer cap is logged on stderr and dropped.
91
- * JSON-RPC 2.0 requires a reply to each of them, so the guard frames stdin
92
- * exactly as core does and answers such a line itself, returning
93
- * {@link PARSE_ERROR_FRAME} or {@link INVALID_REQUEST_FRAME} for the caller
94
- * to write.
95
- *
96
- * - Decoding mirrors core: one streaming UTF-8 decoder, so a character split
97
- * across chunks decodes whole, a byte-order mark is stripped only at the
98
- * start of the stream, and a U+FEFF opening any later line stays in that
99
- * line, where `JSON.parse` rejects it as core's would.
100
- * - A partial line is held until its newline arrives.
101
- * - A line of JSON whitespace only is not a frame: dropped, not answered.
102
- * - A line that is JSON is answered `-32600` when it is not an object or an
103
- * array, when its `method` is not a string and it has no usable `id`, or
104
- * when it has neither `method` nor `id`.
105
- * - Everything else goes to core.
106
- * - A line longer than `maxFrameLength` code units is answered once, as soon
107
- * as the held part exceeds the cap, and the rest of it is discarded up to
108
- * its newline. Nothing is held beyond the cap.
109
- *
110
- * Create one guard per stdin: its state must outlive core re-subscribing to
111
- * stdin after any failure in its read loop, or a held partial line is lost.
112
- *
113
- * @internal
114
- */
115
- const makeFrameGuard = (maxFrameLength = MAX_FRAME_LENGTH) => {
116
- const decoder = new TextDecoder();
117
- const encoder = new TextEncoder();
118
- let pending = "";
119
- let discarding = false;
120
- return (chunk) => {
121
- const text = decoder.decode(chunk, { stream: true });
122
- let forward = "";
123
- const replies = [];
124
- let start = 0;
125
- if (discarding) {
126
- const newline = text.indexOf("\n");
127
- if (newline === -1) return {
128
- forward: void 0,
129
- replies
130
- };
131
- discarding = false;
132
- start = newline + 1;
133
- }
134
- let newline = text.indexOf("\n", start);
135
- while (newline !== -1) {
136
- const line = pending + text.slice(start, newline);
137
- pending = "";
138
- start = newline + 1;
139
- newline = text.indexOf("\n", start);
140
- if (line.length > maxFrameLength) {
141
- replies.push(PARSE_ERROR_FRAME);
142
- continue;
143
- }
144
- if (BLANK.test(line)) continue;
145
- const answer = answerFor(line);
146
- if (answer === void 0) forward += `${line}\n`;
147
- else replies.push(answer);
148
- }
149
- pending += text.slice(start);
150
- if (pending.length > maxFrameLength) {
151
- pending = "";
152
- discarding = true;
153
- replies.push(PARSE_ERROR_FRAME);
154
- }
155
- return {
156
- forward: forward === "" ? void 0 : encoder.encode(forward),
157
- replies
158
- };
159
- };
160
- };
161
- /**
162
- * `Stdio` whose `stdin` carries only lines core's decoder will parse and
163
- * answer, writing the guard's reply to every other line on `stdout`: one
164
- * write per chunk, replies in stdin order. Everything else is the ambient
165
- * `Stdio`.
166
- *
167
- * @remarks
168
- * One guard per call, shared by every subscription to the returned `stdin`,
169
- * because core re-subscribes after a failure and builds its own decoder once.
170
- *
171
- * @internal
172
- */
173
- const guardStdin = (stdio) => {
174
- const step = makeFrameGuard();
175
- return Stdio.make({
176
- args: stdio.args,
177
- stdinIsTerminal: stdio.stdinIsTerminal,
178
- stdoutIsTerminal: stdio.stdoutIsTerminal,
179
- stdout: (options) => stdio.stdout(options),
180
- stderr: (options) => stdio.stderr(options),
181
- stdin: stdio.stdin.pipe(Stream.mapEffect((chunk) => {
182
- const { forward, replies } = step(chunk);
183
- return replies.length === 0 ? Effect.succeed(forward) : Stream.run(Stream.make(replies.join("")), stdio.stdout()).pipe(Effect.as(forward));
184
- }), Stream.filter((chunk) => chunk !== void 0))
185
- });
186
- };
187
- /**
188
- * A layer replacing the ambient `Stdio` with {@link guardStdin}'s, for one
189
- * server layer only.
190
- *
191
- * @remarks
192
- * A function, never a module constant: layers memoize by reference, so one
193
- * shared constant is built once per graph, and a second server in that graph
194
- * would read the first server's guarded stdin. Each call mints a fresh layer;
195
- * `McpStdio.layer` calls it once per server.
196
- *
197
- * @internal
198
- */
199
- const makeGuardedStdio = () => Layer.effect(Stdio.Stdio, Effect.map(Stdio.Stdio, guardStdin));
200
-
201
- export { INVALID_REQUEST_FRAME, MAX_FRAME_LENGTH, PARSE_ERROR_FRAME, guardStdin, makeFrameGuard, makeGuardedStdio };
@@ -1,13 +0,0 @@
1
- import { McpStdio } from "../McpStdio.js";
2
- import { Effect } from "effect";
3
-
4
- // 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
- export { launchGuarded };
package/internal/wire.js DELETED
@@ -1,65 +0,0 @@
1
- import { Result } from "effect";
2
-
3
- // src/internal/wire.ts
4
- /** @internal */
5
- const DEFAULT_CLIENT_INFO = {
6
- name: "effected-mcp-testing",
7
- version: "0.0.0"
8
- };
9
- const isRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u);
10
- /**
11
- * The pointer `McpProcess` appends to a `StreamEnded` message. `McpProbe`
12
- * strips it: the probe folds stderr into its own message, so the pointer is
13
- * stale there.
14
- *
15
- * @internal
16
- */
17
- const STDERR_HINT = "; read stderrFinal for why";
18
- /** @internal */
19
- const parseFrame = (line) => Result.getOrUndefined(Result.try(() => JSON.parse(line)));
20
- /** @internal */
21
- const isJsonRpcMessage = (u) => isRecord(u) && u.jsonrpc === "2.0";
22
- /** @internal */
23
- const isResponse = (message) => (typeof message.id === "string" || typeof message.id === "number") && message.method === void 0;
24
- /** @internal */
25
- const requestKey = (id) => `${typeof id}:${id}`;
26
- /** @internal */
27
- const isStateless = (protocol) => protocol.runtime._tag === "Stateless";
28
- /**
29
- * A request (with `id`) or notification frame. On the stateless revision every
30
- * frame carries the protocol fields under `params._meta`, with any `_meta`
31
- * the caller passed spread over them, so the caller wins. That matches core's
32
- * own harness (`McpStdioHarness.ts`, `withRequestMetadata`) and lets a test
33
- * send a deliberately wrong revision.
34
- *
35
- * @internal
36
- */
37
- const frame = (protocol, clientInfo, method, params, id) => {
38
- const withMeta = () => {
39
- const base = isRecord(params) ? params : {};
40
- const caller = isRecord(base._meta) ? base._meta : {};
41
- return {
42
- ...base,
43
- _meta: {
44
- "io.modelcontextprotocol/protocolVersion": protocol.protocolVersion,
45
- "io.modelcontextprotocol/clientCapabilities": {},
46
- "io.modelcontextprotocol/clientInfo": clientInfo,
47
- ...caller
48
- }
49
- };
50
- };
51
- return {
52
- jsonrpc: "2.0",
53
- ...id === void 0 ? {} : { id },
54
- method,
55
- ...isStateless(protocol) ? { params: withMeta() } : params === void 0 ? {} : { params }
56
- };
57
- };
58
- /** @internal */
59
- const initializeParams = (protocol, clientInfo) => ({
60
- protocolVersion: protocol.protocolVersion,
61
- capabilities: {},
62
- clientInfo
63
- });
64
-
65
- export { DEFAULT_CLIENT_INFO, STDERR_HINT, frame, initializeParams, isJsonRpcMessage, isResponse, isStateless, parseFrame, requestKey };