@effected/mcp 0.2.0 → 0.3.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.
@@ -1,5 +1,5 @@
1
1
  import { Effect, Layer, Stdio, Stream } from "effect";
2
- import { McpSchema } from "effect/unstable/ai";
2
+ import { McpSchema } from "effect/ai";
3
3
 
4
4
  //#region src/internal/StdinFrames.ts
5
5
  /**
@@ -40,27 +40,59 @@ 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/";
43
65
  /** JSON's insignificant whitespace, minus the newline that ends the line. */
44
66
  const BLANK = /^[ \t\r]*$/;
45
67
  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 = "";
46
70
  /**
47
- * The guard's answer to one non-blank line within the cap, or `undefined` to
48
- * forward it to core.
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.
49
73
  *
50
74
  * @remarks
51
- * A frame that parses is forwarded unless core would throw on it or ignore
52
- * it without a reply, which JSON-RPC 2.0 calls an Invalid Request:
53
- *
54
- * - a value that is neither an object nor an array: core throws on `null`,
55
- * dropping every other frame in its chunk, and ignores a number, string or
56
- * boolean without a reply;
57
- * - an object with a `method` that is not a string and no usable `id`: core
58
- * throws on it, dropping the rest of its chunk (with an `id`, core answers
59
- * `-32601` itself);
75
+ * A frame that parses is forwarded unless core would skip it without a
76
+ * reply, where JSON-RPC 2.0 requires an Invalid Request:
77
+ *
78
+ * - a value that is neither an object nor an array;
79
+ * - an object with a `method` that is not a string and no usable `id` (with
80
+ * an `id`, core answers `-32601` itself);
60
81
  * - an object with neither `method` nor `id`: every JSON-RPC request carries a
61
82
  * `method` and every response an `id`, so it is neither, and core ignores it
62
83
  * as a response to nothing.
63
84
  *
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.
95
+ *
64
96
  * Arrays are forwarded: core answers a batch `-32600` itself. So is any object
65
97
  * with an `id` and no `method`, which is a response; JSON-RPC never answers a
66
98
  * response.
@@ -75,7 +107,10 @@ const answerFor = (line) => {
75
107
  if (typeof value !== "object" || value === null) return INVALID_REQUEST_FRAME;
76
108
  if (Array.isArray(value)) return void 0;
77
109
  const message = value;
78
- if (Object.hasOwn(message, "method")) return typeof message.method !== "string" && isNullish(message.id) ? INVALID_REQUEST_FRAME : void 0;
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
114
  return Object.hasOwn(message, "id") ? void 0 : INVALID_REQUEST_FRAME;
80
115
  };
81
116
  /**
@@ -83,15 +118,15 @@ const answerFor = (line) => {
83
118
  * decoder will parse and answer, each with its newline.
84
119
  *
85
120
  * @remarks
86
- * Core's stdio decoder parses each line inside its read loop. A line that is
87
- * not JSON throws before the decoder advances past it, so the line stays at
88
- * the head of its buffer and every later chunk throws on it again: the server
89
- * never reads another frame. A line that is JSON but no JSON-RPC message core
90
- * can handle either throws inside the decoder, dropping the frames after it in
91
- * the same chunk, or is ignored without a reply. The guard frames stdin
121
+ * Core's stdio decoder skips a line it cannot use and keeps serving
122
+ * (Effect-TS/effect PR #8541): a line that is not JSON, a JSON value that is
123
+ * not an object, or an object that is no JSON-RPC message is dropped without
124
+ * a reply, and a line past its buffer cap is logged on stderr and dropped.
125
+ * JSON-RPC 2.0 requires a reply to each of them, so the guard frames stdin
92
126
  * exactly as core does and answers such a line itself, returning
93
127
  * {@link PARSE_ERROR_FRAME} or {@link INVALID_REQUEST_FRAME} for the caller
94
- * to write.
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.
95
130
  *
96
131
  * - Decoding mirrors core: one streaming UTF-8 decoder, so a character split
97
132
  * across chunks decodes whole, a byte-order mark is stripped only at the
@@ -101,7 +136,10 @@ const answerFor = (line) => {
101
136
  * - A line of JSON whitespace only is not a frame: dropped, not answered.
102
137
  * - A line that is JSON is answered `-32600` when it is not an object or an
103
138
  * array, when its `method` is not a string and it has no usable `id`, or
104
- * when it has neither `method` nor `id`; everything else goes to core.
139
+ * 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
+ * - Everything else goes to core.
105
143
  * - A line longer than `maxFrameLength` code units is answered once, as soon
106
144
  * as the held part exceeds the cap, and the rest of it is discarded up to
107
145
  * its newline. Nothing is held beyond the cap.
@@ -143,7 +181,7 @@ const makeFrameGuard = (maxFrameLength = MAX_FRAME_LENGTH) => {
143
181
  if (BLANK.test(line)) continue;
144
182
  const answer = answerFor(line);
145
183
  if (answer === void 0) forward += `${line}\n`;
146
- else replies.push(answer);
184
+ else if (answer !== DROP) replies.push(answer);
147
185
  }
148
186
  pending += text.slice(start);
149
187
  if (pending.length > maxFrameLength) {
@@ -198,4 +236,4 @@ const guardStdin = (stdio) => {
198
236
  const makeGuardedStdio = () => Layer.effect(Stdio.Stdio, Effect.map(Stdio.Stdio, guardStdin));
199
237
 
200
238
  //#endregion
201
- export { INVALID_REQUEST_FRAME, MAX_FRAME_LENGTH, PARSE_ERROR_FRAME, guardStdin, makeFrameGuard, makeGuardedStdio };
239
+ export { INVALID_REQUEST_FRAME, MAX_FRAME_LENGTH, PARSE_ERROR_FRAME, RPC_CONTROL_PREFIX, guardStdin, makeFrameGuard, makeGuardedStdio, methodNotFoundFrame };
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@effected/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
- "description": "The boundary layer of an effect/unstable/ai MCP server: stdio wiring that keeps stdout the wire, tool-failure shaping, strict-input walkers, and test clients",
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": [
7
7
  "effect",
8
8
  "mcp",
@@ -47,8 +47,8 @@
47
47
  "./package.json": "./package.json"
48
48
  },
49
49
  "peerDependencies": {
50
- "@effected/engine": "^0.1.0",
51
- "effect": "4.0.0-rc.117"
50
+ "@effected/engine": "^0.2.0",
51
+ "effect": "4.0.0-rc.118"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.11.0"
package/testing.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Effect, JsonSchema, Layer, PlatformError, Schema, Scope, Stdio } from "effect";
2
- import { McpProtocol } from "effect/unstable/ai";
3
- import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2
+ import { McpProtocol } from "effect/ai";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
4
  //#region src/McpTestFailure.d.ts
5
5
  declare const McpTestFailure_base: Schema.Class<McpTestFailure, Schema.TaggedStruct<"McpTestFailure", {
6
6
  readonly reason: Schema.Literals<readonly ["StreamEnded", "ServerStopped", "NotJsonRpc", "NotInitialized", "ErrorResponse"]>;
@@ -23,8 +23,8 @@ declare const McpTestFailure_base: Schema.Class<McpTestFailure, Schema.TaggedStr
23
23
  * not `catchTag`.
24
24
  * - `ServerStopped` (`McpHarness`): the in-process server stopped, usually
25
25
  * because stdin closed while the request was in flight.
26
- * - `NotInitialized` (`McpHarness`): a request other than `initialize` was
27
- * made on a stateful revision before `initialize` was sent. The request
26
+ * - `NotInitialized` (`McpHarness`): a request other than `initialize` or
27
+ * `ping` was made on a stateful revision before `initialize` was sent. The request
28
28
  * is never written; the server would only have refused it opaquely:
29
29
  * `-32602 Invalid request metadata` when a stateless adapter is listed
30
30
  * first, `-32603 Internal error` when only stateful revisions are served.
@@ -140,11 +140,13 @@ interface McpHarnessOptions {
140
140
  * anything that went through `console.log`, which in a real server is
141
141
  * the wire. Assert it empty.
142
142
  * - On a stateful revision (the default `2025-11-25`), send `initialize`
143
- * first. Every other request before it fails with `NotInitialized` and
144
- * is never written, `ping` included: the server would only answer an
145
- * opaque refusal, `-32602 Invalid request metadata` when a stateless
146
- * adapter is listed first (as in `McpStdio.protocols`), or
147
- * `-32603 Internal error` when only stateful revisions are served.
143
+ * first. `ping` is the exception: the server answers it `{}` before
144
+ * initialization, so it is written. Every other request before
145
+ * `initialize` fails with `NotInitialized` and is never written: the
146
+ * server would only answer an opaque refusal,
147
+ * `-32602 Invalid request metadata` when a stateless adapter is listed
148
+ * first (as in `McpStdio.protocols`), or `-32603 Internal error` when
149
+ * only stateful revisions are served.
148
150
  *
149
151
  * @public
150
152
  */
@@ -173,7 +175,7 @@ export declare class McpHarness {
173
175
  readonly discover: Effect.Effect<JsonRpcMessage, McpTestFailure>;
174
176
  /** Send a request and wait for its response. */
175
177
  readonly request: (method: string, params?: unknown) => Effect.Effect<JsonRpcMessage, McpTestFailure>;
176
- /** Send a request now; wait for its response later. Fails with `NotInitialized`, sending nothing, on a stateful revision before `initialize`. */
178
+ /** Send a request now; wait for its response later. Fails with `NotInitialized`, sending nothing, on a stateful revision before `initialize` (`ping` excepted). */
177
179
  readonly startRequest: (method: string, params?: unknown) => Effect.Effect<{
178
180
  readonly id: number;
179
181
  readonly response: Effect.Effect<JsonRpcMessage, McpTestFailure>;