@effected/mcp 0.2.0 → 0.4.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.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Array, Context, Effect, JsonSchema, Layer, Runtime, Schema, Stdio } from "effect";
2
- import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/unstable/ai";
2
+ import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
3
3
  import { Remediation } from "@effected/engine";
4
4
  //#region src/McpStdio.d.ts
5
5
  /**
@@ -74,10 +74,9 @@ export declare class McpStdio {
74
74
  * first, then the two newest stateful ones.
75
75
  *
76
76
  * @remarks
77
- * Real clients open with either handshake: Claude Code 2.1.281 was
78
- * measured opening a stdio server with the stateless `server/discover`,
79
- * while most other shipping clients still send `initialize`. The order
80
- * is load-bearing:
77
+ * Real clients open with either handshake: some (Claude Code, for one)
78
+ * open a stdio server with the stateless `server/discover`, while others
79
+ * still send `initialize`. The order is load-bearing:
81
80
  *
82
81
  * - Stateless first: a request with no session and no `_meta` falls to
83
82
  * `protocols[0]`.
@@ -99,26 +98,35 @@ export declare class McpStdio {
99
98
  *
100
99
  * The server reads stdin through a guard that frames it exactly as core's
101
100
  * decoder does: one streaming UTF-8 decoder, a byte-order mark stripped
102
- * only at the start of the stream, lines split on `\n`. A line that is
103
- * not JSON is answered on stdout with a JSON-RPC parse error, code
104
- * `-32700` and `id: null`, and never reaches core's decoder, which would
105
- * otherwise throw on that line again for every later chunk and stop
106
- * answering. So is a line longer than core's cap of 16 Mi UTF-16 code
107
- * units: it is answered once, as soon as it passes the cap, and the rest
108
- * of it is discarded up to its newline. A line of JSON whitespace (space,
109
- * tab, carriage return) is ignored.
101
+ * only at the start of the stream, lines split on `\n`. Core itself skips
102
+ * a line it cannot use and keeps serving, but sends no reply, where
103
+ * JSON-RPC 2.0 requires one; the guard supplies it. A line that is not
104
+ * JSON is answered on stdout with a parse error, code `-32700` and
105
+ * `id: null`, and never reaches core. So is a line longer than core's cap
106
+ * of 16 Mi UTF-16 code units: it is answered once, as soon as it passes
107
+ * the cap, and the rest of it is discarded up to its newline (core would
108
+ * log it on stderr and drop it). A line of JSON whitespace (space, tab,
109
+ * carriage return) is ignored.
110
110
  *
111
111
  * A line that is JSON but no JSON-RPC message core can handle is answered
112
112
  * with an Invalid Request, code `-32600` and `id: null`, and never reaches
113
- * core: a value that is neither an object nor an array (core throws on
114
- * `null`, dropping every other frame in its chunk, and ignores a number,
115
- * string or boolean without a reply), an object whose `method` is not a
116
- * string and whose `id` is absent or `null` (core throws on it too), and
117
- * an object with neither `method` nor `id`, which is neither a request nor
118
- * a response. Everything else goes to core: an array, which core answers
119
- * `-32600` itself because it serves no batches; an object with an `id`
120
- * and no `method`, which is a response and gets no reply; and a request
121
- * with an `id`, which core answers even when its `method` is malformed.
113
+ * core: a value that is neither an object nor an array, an object whose
114
+ * `method` is not a string and whose `id` is absent or `null`, and an
115
+ * object with neither `method` nor `id`, which is neither a request nor a
116
+ * response.
117
+ *
118
+ * A line whose `method` starts with `@effect/rpc/` never reaches core,
119
+ * whose JSON-RPC decoder would read one without an `id` as its own RPC
120
+ * control message: `@effect/rpc/Eof` alone silently stops the server
121
+ * (Effect-TS/effect#8499, open upstream). Such a request is answered
122
+ * `-32601` Method not found, echoing its `id`; such a notification is
123
+ * dropped. The guard stops doing this once core no longer treats a
124
+ * client's `@effect/rpc/` frame as a control message.
125
+ *
126
+ * Everything else goes to core: an array, which core answers `-32600`
127
+ * itself because it serves no batches; an object with an `id` and no
128
+ * `method`, which is a response and gets no reply; and a request with an
129
+ * `id`, which core answers even when its `method` is malformed.
122
130
  *
123
131
  * Give each server a fresh layer memo map. Core's stdio protocol layer
124
132
  * is a shared constant, so a second `McpStdio.layer` server whose build
@@ -184,6 +192,11 @@ interface UnknownKeysLevel {
184
192
  readonly unknown: ReadonlyArray<string>;
185
193
  /** Keys this level declares, in schema order. */
186
194
  readonly accepted: ReadonlyArray<string>;
195
+ /**
196
+ * The `patternProperties` patterns this level also accepts keys by, in
197
+ * schema order. Present only when the level has at least one.
198
+ */
199
+ readonly acceptedPatterns?: ReadonlyArray<string>;
187
200
  }
188
201
  /**
189
202
  * Options for {@link ToolInputSchema.formatUnknownKeys}.
@@ -199,10 +212,12 @@ interface FormatUnknownKeysOptions {
199
212
  * depth, a message naming them all, and an object-rooted form of a union.
200
213
  *
201
214
  * @remarks
202
- * Core's `Tool.Strict` rejects unknown keys but reports only the first
203
- * (effect `unstable/ai/McpServer.ts` decodes without `errors: "all"`), so an
204
- * agent fixes one typo per round trip. These walkers run over the schema core
205
- * actually serves, so what they accept matches what the tool advertises:
215
+ * For a `Tool.make` tool, core's strict decode already names every unknown
216
+ * key, with every missing or invalid field, in one response. Core never
217
+ * validates a `Tool.dynamic` tool's raw JSON Schema, so these walkers are
218
+ * for that case: a handler runs them against the schema it registered.
219
+ * They run over the schema as served, so what they accept matches what the
220
+ * tool advertises:
206
221
  *
207
222
  * - Only `additionalProperties: false` closes a node, as in JSON Schema; core
208
223
  * emits it on every object node of a strict tool and `true` otherwise.
@@ -213,6 +228,9 @@ interface FormatUnknownKeysOptions {
213
228
  * - `items`, `prefixItems`, `$ref` into `$defs` (JSON Pointer escapes
214
229
  * honoured), and `patternProperties` are followed; a key matched by a
215
230
  * pattern is accepted and walked with the first matching pattern's schema.
231
+ * Effect serves a pattern-keyed `Schema.Record` with `patternProperties`
232
+ * only when the key pattern's RegExp has the `u` flag; without it the
233
+ * record is served open, and every key is accepted.
216
234
  * - The payload is untrusted: the walk stops descending at depth 256 rather
217
235
  * than overflowing the stack, so a deeper unknown key goes unreported and
218
236
  * is left for decoding. Every hop counts toward that cap: each property or
@@ -226,9 +244,12 @@ export declare class ToolInputSchema {
226
244
  /** Every level of `payload` that carries a key `schema` does not accept. */
227
245
  static readonly unknownKeys: (payload: unknown, schema: JsonSchema.JsonSchema) => ReadonlyArray<UnknownKeysLevel>;
228
246
  /**
229
- * `Unrecognized parameter(s): a, b.c. Accepted params: x, y.` per level. Each
230
- * echoed key is truncated, and at most 20 keys and 20 levels are named; a
231
- * trailing `(and N more levels).` counts the levels left out.
247
+ * `Unrecognized parameter(s): a, b.c. Accepted params: x, y.` per level,
248
+ * followed by `Accepted keys matching: ^x-.` when the level also accepts
249
+ * keys by pattern. A level that accepts keys only by pattern names its
250
+ * patterns alone, never `Accepted params: (none).` Each echoed key and
251
+ * pattern is truncated, and at most 20 keys, 20 patterns and 20 levels are
252
+ * named; a trailing `(and N more levels).` counts the levels left out.
232
253
  */
233
254
  static readonly formatUnknownKeys: (levels: ReadonlyArray<UnknownKeysLevel>, options?: FormatUnknownKeysOptions) => string;
234
255
  /**
@@ -253,16 +274,16 @@ interface McpToolkitOptions {
253
274
  * annotation is served and decoded strict. `"annotated"`: only tools
254
275
  * annotated `Tool.Strict` true are. In both modes an explicit annotation,
255
276
  * true or false, always wins, and a `Tool.dynamic` tool is never
256
- * re-annotated (core dies at registration on a strict dynamic tool). Only
257
- * a tool that ends up strict gets the unknown-key pre-check; a lenient
258
- * one is passed through untouched even if its raw schema is closed.
277
+ * re-annotated (core dies at registration on a strict dynamic tool).
259
278
  */
260
279
  readonly strict?: "all" | "annotated" | undefined;
261
280
  /**
262
- * Replaces the default `ToolInputSchema.formatUnknownKeys` rendering. It
263
- * runs per rejected call inside the handler's effect, so a throw becomes
264
- * a defect of that call alone — the client receives a JSON-RPC internal
265
- * error (-32603) — never a crash of the server.
281
+ * Ignored. Core's strict decode reports every excess key at every depth,
282
+ * together with every missing or invalid field, in one `InvalidParams`,
283
+ * and the layer appends a fixed `Accepted params at <path>: …` line per
284
+ * level that carries an unknown key; this option changes neither.
285
+ *
286
+ * @deprecated A no-op, scheduled for removal in a later minor.
266
287
  */
267
288
  readonly unknownKeyMessage?: ((levels: ReadonlyArray<UnknownKeysLevel>) => string) | undefined;
268
289
  }
@@ -315,39 +336,45 @@ interface UnionToolOptions<P extends Schema.Decoder<unknown>, S extends Schema.C
315
336
  */
316
337
  interface UnionHandlerOptions {
317
338
  /**
318
- * Replaces the default `ToolInputSchema.formatUnknownKeys` rendering, as
319
- * {@link McpToolkitOptions.unknownKeyMessage} does for the layer. Pass the
320
- * same function to both for one report on every path.
339
+ * Ignored. The union decode reports every excess, missing and invalid
340
+ * field in one `InvalidParams`, followed by the same fixed
341
+ * `Accepted params at <path>: …` lines {@link McpToolkit.layer} appends;
342
+ * this option changes neither.
343
+ *
344
+ * @deprecated A no-op, scheduled for removal in a later minor.
321
345
  */
322
346
  readonly unknownKeyMessage?: ((levels: ReadonlyArray<UnknownKeysLevel>) => string) | undefined;
323
347
  }
324
348
  /**
325
- * Register a toolkit exactly as core's `McpServer.toolkit` does, except that a
326
- * strict tool's unknown arguments are all named, at every depth, in one
327
- * response.
349
+ * Register a toolkit exactly as core's `McpServer.toolkit` does, except that
350
+ * every tool is strict by default and a {@link McpToolkit.unionTool} gets its
351
+ * union decoded.
328
352
  *
329
353
  * @remarks
330
- * Rejection itself is core's: a tool annotated `Tool.Strict` is served with
331
- * `additionalProperties: false` on every object node and decoded with
332
- * `onExcessProperty: "error"`. But core reports only the FIRST excess key
333
- * (`Expected no excess property at ["extra"]`), so an agent fixes one typo
334
- * per round trip and never learns about a nested one until the next call.
335
- * This layer is the better report, not the rejecter: it runs core's
354
+ * Rejection and reporting are core's: a tool annotated `Tool.Strict` is
355
+ * served with `additionalProperties: false` on every object node and decoded
356
+ * with `onExcessProperty: "error"` and `errors: "all"`, so one
357
+ * `InvalidParams` names every excess key at every depth together with every
358
+ * missing or invalid field. The layer appends to that report, never in place
359
+ * of it: for every object level of the payload that carries a key the served
360
+ * input schema does not accept, one line naming what that level does accept,
361
+ * `Accepted params at the root: query, filter.` or
362
+ * `Accepted params at ["filter"]: kind, tag.` (the path as core writes it),
363
+ * with `keys matching <pattern>` for keys a `patternProperties` level
364
+ * accepts, and `This tool accepts no params.` for a zero-parameter tool. An
365
+ * agent can fix the call from the reply alone. A failure with no unknown key
366
+ * (a missing or mistyped field only) is core's report unchanged. The lines
367
+ * are appended to core's `InvalidParams` as it leaves the tool's registered
368
+ * handler, the one failure that handler has for bad parameters.
369
+ *
370
+ * On top of that is the policy: under
371
+ * `strict: "all"` (the default) it annotates every tool that carries no
372
+ * `Tool.Strict` annotation of its own as strict, skipping `Tool.dynamic`
373
+ * tools (core dies at registration on a strict dynamic tool). It runs core's
336
374
  * `registerToolkit` unchanged under a registration-scoped `McpServer` whose
337
- * `addTool` puts a `ToolInputSchema.unknownKeys` pre-check in front of each
338
- * handler. The pre-check walks the input schema the tool actually serves
339
- * (after core's own top-level `$ref` inlining), follows `$ref`, `allOf` and
340
- * discriminated `oneOf`/`anyOf` members, and fails with one
341
- * `McpSchema.InvalidParams` naming every unknown key path plus the accepted
342
- * params, before core decodes. Core's strict decode stays behind it as the
343
- * backstop. The pre-check is gated on the same predicate core uses to choose
344
- * strict decoding — the registered tool's `Tool.Strict` annotation is
345
- * `true` — so a lenient tool (explicit `Tool.Strict` false, a
346
- * `Tool.dynamic` tool, or any unannotated tool under `"annotated"`) is
347
- * never rejected here, even when its raw JSON Schema carries
348
- * `additionalProperties: false`. The pre-check runs under `Effect.suspend`,
349
- * so a throwing `unknownKeyMessage` dies inside the call's effect. No core
350
- * internals are patched, so this survives an rc bump without re-diffing.
375
+ * `addTool` puts the union decode in front of a union tool's handler and
376
+ * passes every other registration through untouched. It patches no core
377
+ * internals: it uses only `McpServer.registerToolkit` and `addTool`.
351
378
  *
352
379
  * Registration goes through core's module-level `McpServer.McpServer.layer`,
353
380
  * shared by reference with `McpStdio.layer`'s own copy — provide both into
@@ -386,9 +413,11 @@ export declare class McpToolkit {
386
413
  * {@link McpToolkit.unionHandler}, which receives the decoded member.
387
414
  *
388
415
  * Registered through {@link McpToolkit.layer}, a union tool gets the same
389
- * treatment as a strict `Tool.make` tool: every unknown key named in one
390
- * `InvalidParams`, then a strict decode, both before the handler runs, so
391
- * bad arguments answer JSON-RPC `-32602` on `2025-06-18` and an `isError`
416
+ * treatment as a strict `Tool.make` tool: a strict decode with
417
+ * `errors: "all"` before the handler runs, reporting every excess, missing
418
+ * and invalid field of the matched member in one `InvalidParams`, with the
419
+ * same `Accepted params at <path>: …` lines appended, so bad
420
+ * arguments answer JSON-RPC `-32602` on `2025-06-18` and an `isError`
392
421
  * result on the later revisions, exactly as a `Tool.make` decode failure
393
422
  * does. Registered through core's `McpServer.toolkit` it still decodes
394
423
  * strictly, in the handler, but the `InvalidParams` is then a declared
@@ -399,6 +428,11 @@ export declare class McpToolkit {
399
428
  *
400
429
  * @example
401
430
  * ```ts
431
+ * import { McpToolkit, ToolOutputSchema, ToolRefusal } from "@effected/mcp";
432
+ * import { Schema } from "effect";
433
+ * import { Tool } from "effect/ai";
434
+ *
435
+ * // `AddNote`, `ListNotes`, `Added`, `Listed` are object schemas; `Kit` is a Toolkit that includes `Note`.
402
436
  * const Note = McpToolkit.unionTool("note", {
403
437
  * parameters: Schema.Union([AddNote, ListNotes]),
404
438
  * success: ToolOutputSchema.objectRooted(Schema.Union([Added, Listed])),
@@ -409,26 +443,27 @@ export declare class McpToolkit {
409
443
  */
410
444
  static readonly unionTool: <const Name extends string, P extends Schema.Decoder<unknown>, S extends Schema.Constraint = typeof Schema.Void, F extends Schema.Constraint = typeof Schema.Never, const Dependencies extends ReadonlyArray<Context.Key<unknown, unknown>> = []>(name: Name, options: UnionToolOptions<P, S, F, Dependencies>) => UnionTool<Name, P, S, F, Context.Service.Identifier<Dependencies[number]>>;
411
445
  /**
412
- * The handler for a {@link McpToolkit.unionTool}: checks and decodes the
413
- * raw payload exactly as {@link McpToolkit.layer} does, and passes the
414
- * decoded member to `handler`.
446
+ * The handler for a {@link McpToolkit.unionTool}: decodes the raw payload
447
+ * exactly as {@link McpToolkit.layer} does, and passes the decoded member
448
+ * to `handler`.
415
449
  *
416
450
  * @remarks
417
- * A payload carrying unknown keys fails with one `McpSchema.InvalidParams`
418
- * naming every one of them, at every depth, in the
419
- * `ToolInputSchema.formatUnknownKeys` report (or `options.unknownKeyMessage`'s),
420
- * walked over the tool's served input schema. A payload that then does
421
- * not decode fails `InvalidParams` worded as core words a `Tool.make`
422
- * decode failure. It is the same implementation the layer runs, so a
423
- * handler called directly (a test helper, or a toolkit registered through
424
- * core's `McpServer.toolkit`) reports what a client of the layer sees.
425
- * Under {@link McpToolkit.layer} the layer has already rejected a bad
426
- * call before the handler runs, and its own `unknownKeyMessage` wins.
451
+ * A payload that does not decode strictly fails with one
452
+ * `McpSchema.InvalidParams`, worded as core words a `Tool.make` decode
453
+ * failure, naming every excess key at every depth together with every
454
+ * missing or invalid field of the matched member, then one
455
+ * `Accepted params at <path>: …` line per level with an unknown key. It
456
+ * is the same
457
+ * implementation the layer runs, so a handler called directly (a test
458
+ * helper, or a toolkit registered through core's `McpServer.toolkit`)
459
+ * reports what a client of the layer sees. Under {@link McpToolkit.layer}
460
+ * the layer has already rejected a bad call before the handler runs.
461
+ * `options.unknownKeyMessage` is deprecated and ignored.
427
462
  */
428
- static readonly unionHandler: <Name extends string, P extends Schema.Decoder<unknown>, S extends Schema.Constraint, F extends Schema.Constraint, R, A, E, RH>(tool: UnionTool<Name, P, S, F, R>, handler: (params: P["Type"]) => Effect.Effect<A, E, RH>, options?: UnionHandlerOptions) => ((payload: unknown) => Effect.Effect<A, E | McpSchema.InvalidParams, RH>);
463
+ static readonly unionHandler: <Name extends string, P extends Schema.Decoder<unknown>, S extends Schema.Constraint, F extends Schema.Constraint, R, A, E, RH>(tool: UnionTool<Name, P, S, F, R>, handler: (params: P["Type"]) => Effect.Effect<A, E, RH>, _options?: UnionHandlerOptions) => ((payload: unknown) => Effect.Effect<A, E | McpSchema.InvalidParams, RH>);
429
464
  /**
430
- * `McpServer.toolkit`'s registration layer, strict by default and naming
431
- * every unknown argument in one `InvalidParams`.
465
+ * `McpServer.toolkit`'s registration layer, strict by default.
466
+ * `options.unknownKeyMessage` is deprecated and ignored.
432
467
  */
433
468
  static readonly layer: <Tools extends Record<string, Tool.Any>>(toolkit: Toolkit.Toolkit<Tools>, options?: McpToolkitOptions) => Layer.Layer<never, never, Tool.HandlersFor<Tools> | Exclude<Tool.HandlerServices<Tools>, McpSchema.McpRequestContext>>;
434
469
  }
@@ -440,7 +475,7 @@ export declare class McpToolkit {
440
475
  * @remarks
441
476
  * Core sends a declared failure that is an `Error` instance — every
442
477
  * `Schema.TaggedError` is — as `isError: true` with `error.message` as the only
443
- * text and no `structuredContent` (effect `unstable/ai/McpServer.ts`,
478
+ * text and no `structuredContent` (effect `ai/McpServer.ts`,
444
479
  * `declaredFailureResult`). So the remediation must be folded into `message`
445
480
  * when the error is constructed: whatever is not in the message never reaches
446
481
  * the agent.
@@ -457,6 +492,9 @@ export declare class McpToolkit {
457
492
  *
458
493
  * @example
459
494
  * ```ts
495
+ * import { ToolFailure } from "@effected/mcp";
496
+ * import { Schema } from "effect";
497
+ *
460
498
  * class NotFound extends Schema.TaggedError<NotFound>()("NotFound", { ...ToolFailure.fields, id: Schema.String }) {}
461
499
  * const remediation = { hint: "List the ids first.", suggestedTool: "list_things" }
462
500
  * new NotFound({ id, remediation, message: ToolFailure.message(`No thing "${ToolFailure.truncate(id)}".`, remediation) })
@@ -563,11 +601,14 @@ declare const ToolRefusal_base: Schema.Class<ToolRefusal, Schema.TaggedStruct<"T
563
601
  * {@link ToolRefusal.refuse}; its message already carries the remediation,
564
602
  * because a declared `Error` failure sends `message` and nothing else.
565
603
  *
566
- * Audit every throw path when porting a tool: any failure left undeclared
567
- * loses its message on the wire, however carefully it was worded.
604
+ * Audit every throw path of a tool: any failure left undeclared loses its
605
+ * message on the wire, however carefully it was worded.
568
606
  *
569
607
  * @example
570
608
  * ```ts
609
+ * import { ToolFailure, ToolRefusal } from "@effected/mcp";
610
+ * import { Tool } from "effect/ai";
611
+ *
571
612
  * const Lookup = Tool.make("lookup", { parameters, success, failure: ToolRefusal })
572
613
  * // in the handler:
573
614
  * return yield* ToolRefusal.refuse(`No run "${ToolFailure.truncate(id)}".`, { hint: "List runs first.", suggestedTool: "list_runs" })
@@ -7,15 +7,27 @@ import { Runtime } from "effect";
7
7
  * second time — through a logger that writes to stdout — and the original exit
8
8
  * code rides along for the teardown.
9
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
+ *
10
15
  * @internal
11
16
  */
12
17
  var LaunchFailed = class extends Error {
13
- [Runtime.errorReported] = false;
14
- [Runtime.errorExitCode];
18
+ #code;
15
19
  constructor(code) {
16
20
  super(`the MCP server failed to launch (exit ${code})`);
17
21
  this.name = "LaunchFailed";
18
- this[Runtime.errorExitCode] = code;
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;
19
31
  }
20
32
  };
21
33
 
@@ -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.4.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.3.0",
51
+ "effect": "^4.0.0"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.11.0"