@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/McpHarness.js +24 -8
- package/McpProbe.js +16 -3
- package/McpProcess.js +19 -2
- package/McpStdio.js +30 -22
- package/McpTestFailure.js +4 -2
- package/McpToolAudit.js +5 -3
- package/McpToolkit.js +125 -76
- package/README.md +79 -35
- package/ToolFailure.js +4 -1
- package/ToolInputSchema.js +28 -14
- package/ToolRefusal.js +5 -2
- package/index.d.ts +125 -84
- package/internal/LaunchFailed.js +15 -3
- package/internal/StdinFrames.js +61 -23
- package/package.json +4 -4
- package/testing.d.ts +61 -13
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/
|
|
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
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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`.
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* units: it is answered once, as soon as it passes
|
|
108
|
-
* of it is discarded up to its newline
|
|
109
|
-
*
|
|
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
|
|
114
|
-
* `
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
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
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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).
|
|
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
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
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
|
|
326
|
-
*
|
|
327
|
-
*
|
|
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
|
|
331
|
-
* `additionalProperties: false` on every object node and decoded
|
|
332
|
-
* `onExcessProperty: "error"
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
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
|
|
338
|
-
*
|
|
339
|
-
*
|
|
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:
|
|
390
|
-
* `
|
|
391
|
-
*
|
|
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}:
|
|
413
|
-
*
|
|
414
|
-
*
|
|
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
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
* handler called directly (a test
|
|
424
|
-
* core's `McpServer.toolkit`)
|
|
425
|
-
* Under {@link McpToolkit.layer}
|
|
426
|
-
* call before the handler runs
|
|
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>,
|
|
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
|
|
431
|
-
*
|
|
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 `
|
|
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
|
|
567
|
-
*
|
|
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" })
|
package/internal/LaunchFailed.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
package/internal/StdinFrames.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect, Layer, Stdio, Stream } from "effect";
|
|
2
|
-
import { McpSchema } from "effect/
|
|
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,
|
|
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
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* - a value that is neither an object nor an array
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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"))
|
|
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
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "The boundary layer of an effect/
|
|
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.
|
|
51
|
-
"effect": "4.0.0
|
|
50
|
+
"@effected/engine": "^0.3.0",
|
|
51
|
+
"effect": "^4.0.0"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=24.11.0"
|