@effected/mcp 0.10.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
package/index.d.ts CHANGED
@@ -125,14 +125,6 @@ export declare class McpStdio {
125
125
  * object with neither `method` nor `id`, which is neither a request nor a
126
126
  * response.
127
127
  *
128
- * A line whose `method` starts with `@effect/rpc/` never reaches core,
129
- * whose JSON-RPC decoder would read one without an `id` as its own RPC
130
- * control message: `@effect/rpc/Eof` alone silently stops the server
131
- * (Effect-TS/effect#8499, open upstream). Such a request is answered
132
- * `-32601` Method not found, echoing its `id`; such a notification is
133
- * dropped. The guard stops doing this once core no longer treats a
134
- * client's `@effect/rpc/` frame as a control message.
135
- *
136
128
  * Everything else goes to core: an array, which core answers `-32600`
137
129
  * itself because it serves no batches; an object with an `id` and no
138
130
  * `method`, which is a response and gets no reply; and a request with an
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/mcp",
3
- "version": "0.10.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": [
@@ -52,7 +52,7 @@
52
52
  "./package.json": "./package.json"
53
53
  },
54
54
  "peerDependencies": {
55
- "@effected/engine": "^0.9.0",
55
+ "@effected/engine": "^0.10.0",
56
56
  "effect": "^4.0.0"
57
57
  },
58
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: