@effected/mcp-kit 0.0.0-stage → 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/index.js ADDED
@@ -0,0 +1,10 @@
1
+ import { McpStdio } from "./McpStdio.js";
2
+ import { McpToolkit } from "./McpToolkit.js";
3
+ import { ToolFailure } from "./ToolFailure.js";
4
+ import { ToolInputSchema } from "./ToolInputSchema.js";
5
+ import { ToolOutcome } from "./ToolOutcome.js";
6
+ import { ToolOutputSchema } from "./ToolOutputSchema.js";
7
+ import { ToolRefusal } from "./ToolRefusal.js";
8
+ import { ToolReplay } from "./ToolReplay.js";
9
+
10
+ export { McpStdio, McpToolkit, ToolFailure, ToolInputSchema, ToolOutcome, ToolOutputSchema, ToolRefusal, ToolReplay };
@@ -0,0 +1,34 @@
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 };
@@ -0,0 +1,201 @@
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 };
@@ -0,0 +1,13 @@
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 };
@@ -0,0 +1,65 @@
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 };
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-kit/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,61 @@
1
1
  {
2
- "name": "@effected/mcp-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@effected/mcp-kit",
3
+ "version": "0.14.0",
4
+ "private": false,
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
+ "keywords": [
7
+ "effect",
8
+ "mcp",
9
+ "model-context-protocol",
10
+ "stdio",
11
+ "json-rpc",
12
+ "effected"
13
+ ],
14
+ "homepage": "https://github.com/spencerbeggs/effected/tree/main/packages/mcp-kit#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/spencerbeggs/effected/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/spencerbeggs/effected.git",
21
+ "directory": "packages/mcp-kit"
22
+ },
23
+ "license": "MIT",
24
+ "author": {
25
+ "name": "C. Spencer Beggs",
26
+ "email": "spencer@beggs.codes",
27
+ "url": "https://spencerbeg.gs"
28
+ },
29
+ "sideEffects": false,
30
+ "type": "module",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./index.d.ts",
34
+ "import": "./index.js",
35
+ "default": "./index.js"
36
+ },
37
+ "./guard": {
38
+ "types": "./guard.d.ts",
39
+ "import": "./guard.js",
40
+ "default": "./guard.js"
41
+ },
42
+ "./node": {
43
+ "types": "./node.d.ts",
44
+ "import": "./node.js",
45
+ "default": "./node.js"
46
+ },
47
+ "./testing": {
48
+ "types": "./testing.d.ts",
49
+ "import": "./testing.js",
50
+ "default": "./testing.js"
51
+ },
52
+ "./package.json": "./package.json"
53
+ },
54
+ "peerDependencies": {
55
+ "@effected/kit": "^0.13.0",
56
+ "effect": "~4.1.0"
57
+ },
58
+ "engines": {
59
+ "node": ">=24.11.0"
60
+ }
61
+ }