@effected/mcp 0.1.1 → 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.
- package/McpGuard.js +121 -0
- package/McpHarness.js +46 -10
- package/McpProbe.js +1 -1
- package/McpProcess.js +2 -2
- package/McpStdio.js +37 -21
- package/McpTestFailure.js +2 -2
- package/McpToolAudit.js +10 -5
- package/McpToolkit.js +199 -36
- package/README.md +195 -31
- package/ToolFailure.js +8 -2
- package/ToolInputSchema.js +28 -14
- package/ToolOutputSchema.js +66 -0
- package/ToolRefusal.js +42 -0
- package/guard.d.ts +157 -0
- package/guard.js +3 -0
- package/index.d.ts +331 -64
- package/index.js +3 -1
- package/internal/StdinFrames.js +61 -23
- package/internal/guardLaunch.js +14 -0
- package/package.json +9 -4
- package/testing.d.ts +44 -12
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 };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { McpStdio } from "../McpStdio.js";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region 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
|
+
//#endregion
|
|
14
|
+
export { launchGuarded };
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -34,6 +34,11 @@
|
|
|
34
34
|
"import": "./index.js",
|
|
35
35
|
"default": "./index.js"
|
|
36
36
|
},
|
|
37
|
+
"./guard": {
|
|
38
|
+
"types": "./guard.d.ts",
|
|
39
|
+
"import": "./guard.js",
|
|
40
|
+
"default": "./guard.js"
|
|
41
|
+
},
|
|
37
42
|
"./testing": {
|
|
38
43
|
"types": "./testing.d.ts",
|
|
39
44
|
"import": "./testing.js",
|
|
@@ -42,8 +47,8 @@
|
|
|
42
47
|
"./package.json": "./package.json"
|
|
43
48
|
},
|
|
44
49
|
"peerDependencies": {
|
|
45
|
-
"@effected/engine": "^0.
|
|
46
|
-
"effect": "4.0.0-rc.
|
|
50
|
+
"@effected/engine": "^0.2.0",
|
|
51
|
+
"effect": "4.0.0-rc.118"
|
|
47
52
|
},
|
|
48
53
|
"engines": {
|
|
49
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/
|
|
3
|
-
import { ChildProcess, ChildProcessSpawner } from "effect/
|
|
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`
|
|
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.
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* `-
|
|
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
|
*/
|
|
@@ -153,11 +155,27 @@ export declare class McpHarness {
|
|
|
153
155
|
readonly protocol: McpProtocol.ProtocolAdapter;
|
|
154
156
|
/** `initialize` then `notifications/initialized`; `server/discover` on a stateless revision. Call it first on a stateful revision. */
|
|
155
157
|
readonly initialize: Effect.Effect<JsonRpcMessage, McpTestFailure>;
|
|
158
|
+
/**
|
|
159
|
+
* `initialize` asking for `protocolVersion` instead of the harness's own
|
|
160
|
+
* revision, then `notifications/initialized` only when the response
|
|
161
|
+
* carries no error. Returns the whole response: assert the negotiated
|
|
162
|
+
* `result.protocolVersion`, or the JSON-RPC `error`.
|
|
163
|
+
*
|
|
164
|
+
* @remarks
|
|
165
|
+
* It moves between stateful revisions only. A stateful server never
|
|
166
|
+
* refuses a version: it counter-offers its latest stateful revision, so
|
|
167
|
+
* asking for an unknown one, or for the stateless `2026-07-28`, comes back
|
|
168
|
+
* as `2025-11-25`. On a harness made with a stateless revision the server
|
|
169
|
+
* does not serve `initialize` at all, and the response is a JSON-RPC
|
|
170
|
+
* `-32601` "Method not found" error, with no `notifications/initialized`
|
|
171
|
+
* sent after it.
|
|
172
|
+
*/
|
|
173
|
+
readonly initializeWith: (protocolVersion: string) => Effect.Effect<JsonRpcMessage, McpTestFailure>;
|
|
156
174
|
/** `server/discover`. */
|
|
157
175
|
readonly discover: Effect.Effect<JsonRpcMessage, McpTestFailure>;
|
|
158
176
|
/** Send a request and wait for its response. */
|
|
159
177
|
readonly request: (method: string, params?: unknown) => Effect.Effect<JsonRpcMessage, McpTestFailure>;
|
|
160
|
-
/** 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). */
|
|
161
179
|
readonly startRequest: (method: string, params?: unknown) => Effect.Effect<{
|
|
162
180
|
readonly id: number;
|
|
163
181
|
readonly response: Effect.Effect<JsonRpcMessage, McpTestFailure>;
|
|
@@ -176,6 +194,8 @@ export declare class McpHarness {
|
|
|
176
194
|
readonly sendRaw: (message: unknown) => Effect.Effect<void>;
|
|
177
195
|
/** The next server-initiated frame with this method, skipping and keeping others; fails with `ServerStopped` once the server stops. */
|
|
178
196
|
readonly awaitOutboundMethod: (method: string) => Effect.Effect<JsonRpcMessage, McpTestFailure>;
|
|
197
|
+
/** Every frame written to the server's stdin so far, in order, as the JSON-parsed bytes written (a snapshot the caller's later mutations cannot reach): requests, notifications and `sendRaw` values. */
|
|
198
|
+
readonly sentSoFar: Effect.Effect<ReadonlyArray<unknown>>;
|
|
179
199
|
/** Everything written to stderr, plus every captured log line. */
|
|
180
200
|
readonly stderrSoFar: Effect.Effect<string>;
|
|
181
201
|
/** Every captured `console.log`, `info` or `debug` call. */
|
|
@@ -321,9 +341,21 @@ interface McpToolAuditPolicy {
|
|
|
321
341
|
readonly input: "open" | "closed" | "any";
|
|
322
342
|
/** Every tool must carry a non-empty `title`, top-level or in `annotations.title`. */
|
|
323
343
|
readonly requireTitle?: boolean | undefined;
|
|
324
|
-
/**
|
|
344
|
+
/**
|
|
345
|
+
* Every tool must serve an `outputSchema`. The stateful revisions drop a
|
|
346
|
+
* non-object one, so check the revision you serve; the violation names
|
|
347
|
+
* the likely cause, a union success schema, and `ToolOutputSchema.objectRooted`.
|
|
348
|
+
*/
|
|
325
349
|
readonly requireOutputSchema?: boolean | undefined;
|
|
326
|
-
/**
|
|
350
|
+
/**
|
|
351
|
+
* A served `outputSchema` must be rooted at `type: "object"`. Defaults to
|
|
352
|
+
* `true` (D10). Only the stateless revision serves a non-object root, so
|
|
353
|
+
* only there does this fire; a union root (`anyOf` or `oneOf`) names
|
|
354
|
+
* `ToolOutputSchema.objectRooted` as the fix for a union whose members are
|
|
355
|
+
* all objects (a union with a primitive member needs an object envelope
|
|
356
|
+
* instead). On a stateful revision the
|
|
357
|
+
* same schema is dropped, and `requireOutputSchema` reports it instead.
|
|
358
|
+
*/
|
|
327
359
|
readonly objectRootedOutput?: boolean | undefined;
|
|
328
360
|
/** The longest `description` allowed, in UTF-16 code units; a missing description counts as 0. */
|
|
329
361
|
readonly maxDescription?: number | undefined;
|