@effected/mcp 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/McpHarness.js +10 -8
- package/McpProbe.js +1 -1
- package/McpProcess.js +2 -2
- package/McpStdio.js +27 -18
- package/McpTestFailure.js +2 -2
- package/McpToolAudit.js +5 -3
- package/McpToolkit.js +120 -76
- package/README.md +75 -31
- package/ToolFailure.js +1 -1
- package/ToolInputSchema.js +28 -14
- package/index.d.ts +109 -78
- package/internal/StdinFrames.js +61 -23
- package/package.json +4 -4
- package/testing.d.ts +12 -10
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.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",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"./package.json": "./package.json"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@effected/engine": "^0.
|
|
51
|
-
"effect": "4.0.0-rc.
|
|
50
|
+
"@effected/engine": "^0.2.0",
|
|
51
|
+
"effect": "4.0.0-rc.118"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=24.11.0"
|
package/testing.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect, JsonSchema, Layer, PlatformError, Schema, Scope, Stdio } from "effect";
|
|
2
|
-
import { McpProtocol } from "effect/
|
|
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
|
*/
|
|
@@ -173,7 +175,7 @@ export declare class McpHarness {
|
|
|
173
175
|
readonly discover: Effect.Effect<JsonRpcMessage, McpTestFailure>;
|
|
174
176
|
/** Send a request and wait for its response. */
|
|
175
177
|
readonly request: (method: string, params?: unknown) => Effect.Effect<JsonRpcMessage, McpTestFailure>;
|
|
176
|
-
/** Send a request now; wait for its response later. Fails with `NotInitialized`, sending nothing, on a stateful revision before `initialize
|
|
178
|
+
/** Send a request now; wait for its response later. Fails with `NotInitialized`, sending nothing, on a stateful revision before `initialize` (`ping` excepted). */
|
|
177
179
|
readonly startRequest: (method: string, params?: unknown) => Effect.Effect<{
|
|
178
180
|
readonly id: number;
|
|
179
181
|
readonly response: Effect.Effect<JsonRpcMessage, McpTestFailure>;
|