@effected/mcp 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/McpHarness.js +40 -2
- package/McpStdio.js +0 -8
- package/index.d.ts +0 -8
- package/internal/StdinFrames.js +9 -46
- package/package.json +2 -2
- package/testing.d.ts +9 -1
package/McpHarness.js
CHANGED
|
@@ -3,8 +3,38 @@ import { DEFAULT_CLIENT_INFO, frame, initializeParams, isJsonRpcMessage, isRespo
|
|
|
3
3
|
import { McpTestFailure } from "./McpTestFailure.js";
|
|
4
4
|
import { Console, Deferred, Effect, Exit, Fiber, Layer, Queue, Sink, Stdio, Stream } from "effect";
|
|
5
5
|
import { McpProtocol } from "effect/ai";
|
|
6
|
+
import { ErrorText } from "@effected/engine";
|
|
6
7
|
|
|
7
8
|
// src/McpHarness.ts
|
|
9
|
+
/** The generic tag `Object.prototype.toString` answers with: a value with no string form of its own. */
|
|
10
|
+
const GENERIC_STRING = /^\[object [^\]]*\]$/;
|
|
11
|
+
/**
|
|
12
|
+
* One captured console argument as text, as close to what the console would show as a runtime-free rendering gets,
|
|
13
|
+
* and never throwing.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* A harness records what the server would have printed, so the record must not lose what the console shows. Hence
|
|
17
|
+
* the order: a value's own string form first (`String(error)` is `Error: boom`, where `ErrorText.describe` would keep
|
|
18
|
+
* only `boom`, and it is the same for any object carrying a `message`), then JSON for an array or a plain object,
|
|
19
|
+
* whose string form is `1,2` or `[object Object]`. `ErrorText.describe` is the last resort for a value whose string
|
|
20
|
+
* form throws and which has no JSON: an object with no prototype has no `toString`, and `String` throws on it inside
|
|
21
|
+
* the server's own console call.
|
|
22
|
+
*/
|
|
23
|
+
const showArg = (arg) => {
|
|
24
|
+
if (typeof arg === "string") return arg;
|
|
25
|
+
if (typeof arg !== "object" || arg === null) return ErrorText.describe(arg);
|
|
26
|
+
try {
|
|
27
|
+
if (!Array.isArray(arg)) {
|
|
28
|
+
const text = String(arg);
|
|
29
|
+
if (!GENERIC_STRING.test(text)) return text;
|
|
30
|
+
}
|
|
31
|
+
} catch {}
|
|
32
|
+
try {
|
|
33
|
+
const json = JSON.stringify(arg);
|
|
34
|
+
if (json !== void 0) return json;
|
|
35
|
+
} catch {}
|
|
36
|
+
return ErrorText.describe(arg);
|
|
37
|
+
};
|
|
8
38
|
/**
|
|
9
39
|
* An in-process MCP client for a real server layer, over queue-backed stdio.
|
|
10
40
|
*
|
|
@@ -109,7 +139,15 @@ var McpHarness = class McpHarness {
|
|
|
109
139
|
sentSoFar;
|
|
110
140
|
/** Everything written to stderr, plus every captured log line. */
|
|
111
141
|
stderrSoFar;
|
|
112
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* Every captured `console.log`, `info` or `debug` call, its arguments joined with a space, each rendered close to
|
|
144
|
+
* what the console would show: a string as itself, a value with its own string form as that form (an `Error` as
|
|
145
|
+
* `Error: boom`, a number, a date), an array or a plain object as JSON (`{"message":"x"}`, not just `x`).
|
|
146
|
+
*
|
|
147
|
+
* @remarks
|
|
148
|
+
* Rendering never throws inside the server's own call. A value whose string form throws (an object with no
|
|
149
|
+
* prototype, a throwing `toString`) and has no JSON falls back to `ErrorText.describe` from `@effected/engine`.
|
|
150
|
+
*/
|
|
113
151
|
consoleLogSoFar;
|
|
114
152
|
/**
|
|
115
153
|
* End stdin, as a client disconnecting does. The server drains first:
|
|
@@ -171,7 +209,7 @@ var McpHarness = class McpHarness {
|
|
|
171
209
|
}))
|
|
172
210
|
});
|
|
173
211
|
const ambient = yield* Console.Console;
|
|
174
|
-
const show = (args) => args.map(
|
|
212
|
+
const show = (args) => args.map(showArg).join(" ");
|
|
175
213
|
const toStdout = (...args) => {
|
|
176
214
|
consoleLog.push(show(args));
|
|
177
215
|
};
|
package/McpStdio.js
CHANGED
|
@@ -94,14 +94,6 @@ var McpStdio = class McpStdio {
|
|
|
94
94
|
* object with neither `method` nor `id`, which is neither a request nor a
|
|
95
95
|
* response.
|
|
96
96
|
*
|
|
97
|
-
* A line whose `method` starts with `@effect/rpc/` never reaches core,
|
|
98
|
-
* whose JSON-RPC decoder would read one without an `id` as its own RPC
|
|
99
|
-
* control message: `@effect/rpc/Eof` alone silently stops the server
|
|
100
|
-
* (Effect-TS/effect#8499, open upstream). Such a request is answered
|
|
101
|
-
* `-32601` Method not found, echoing its `id`; such a notification is
|
|
102
|
-
* dropped. The guard stops doing this once core no longer treats a
|
|
103
|
-
* client's `@effect/rpc/` frame as a control message.
|
|
104
|
-
*
|
|
105
97
|
* Everything else goes to core: an array, which core answers `-32600`
|
|
106
98
|
* itself because it serves no batches; an object with an `id` and no
|
|
107
99
|
* `method`, which is a response and gets no reply; and a request with an
|
package/index.d.ts
CHANGED
|
@@ -125,14 +125,6 @@ export declare class McpStdio {
|
|
|
125
125
|
* object with neither `method` nor `id`, which is neither a request nor a
|
|
126
126
|
* response.
|
|
127
127
|
*
|
|
128
|
-
* A line whose `method` starts with `@effect/rpc/` never reaches core,
|
|
129
|
-
* whose JSON-RPC decoder would read one without an `id` as its own RPC
|
|
130
|
-
* control message: `@effect/rpc/Eof` alone silently stops the server
|
|
131
|
-
* (Effect-TS/effect#8499, open upstream). Such a request is answered
|
|
132
|
-
* `-32601` Method not found, echoing its `id`; such a notification is
|
|
133
|
-
* dropped. The guard stops doing this once core no longer treats a
|
|
134
|
-
* client's `@effect/rpc/` frame as a control message.
|
|
135
|
-
*
|
|
136
128
|
* Everything else goes to core: an array, which core answers `-32600`
|
|
137
129
|
* itself because it serves no batches; an object with an `id` and no
|
|
138
130
|
* `method`, which is a response and gets no reply; and a request with an
|
package/internal/StdinFrames.js
CHANGED
|
@@ -40,36 +40,12 @@ const INVALID_REQUEST_FRAME = `${JSON.stringify({
|
|
|
40
40
|
message: "Invalid Request"
|
|
41
41
|
}
|
|
42
42
|
})}\n`;
|
|
43
|
-
/**
|
|
44
|
-
* The JSON-RPC 2.0 answer to a request whose method is one of Effect's
|
|
45
|
-
* internal `@effect/rpc/` control messages, newline-framed, echoing the
|
|
46
|
-
* request's `id`.
|
|
47
|
-
*
|
|
48
|
-
* @internal
|
|
49
|
-
*/
|
|
50
|
-
const methodNotFoundFrame = (id) => `${JSON.stringify({
|
|
51
|
-
jsonrpc: "2.0",
|
|
52
|
-
id,
|
|
53
|
-
error: {
|
|
54
|
-
code: McpSchema.METHOD_NOT_FOUND_ERROR_CODE,
|
|
55
|
-
message: "Method not found"
|
|
56
|
-
}
|
|
57
|
-
})}\n`;
|
|
58
|
-
/**
|
|
59
|
-
* The method prefix core's JSON-RPC decoder reserves for its own RPC control
|
|
60
|
-
* messages (`Eof`, `Interrupt`, `Ack`, …).
|
|
61
|
-
*
|
|
62
|
-
* @internal
|
|
63
|
-
*/
|
|
64
|
-
const RPC_CONTROL_PREFIX = "@effect/rpc/";
|
|
65
43
|
/** JSON's insignificant whitespace, minus the newline that ends the line. */
|
|
66
44
|
const BLANK = /^[ \t\r]*$/;
|
|
67
45
|
const isNullish = (value) => value === null || value === void 0;
|
|
68
|
-
/** {@link answerFor}'s verdict for a line that is neither forwarded nor answered. */
|
|
69
|
-
const DROP = "";
|
|
70
46
|
/**
|
|
71
|
-
* The guard's answer to one non-blank line within the cap, `
|
|
72
|
-
*
|
|
47
|
+
* The guard's answer to one non-blank line within the cap, or `undefined` to
|
|
48
|
+
* forward it to core.
|
|
73
49
|
*
|
|
74
50
|
* @remarks
|
|
75
51
|
* A frame that parses is forwarded unless core would skip it without a
|
|
@@ -82,16 +58,9 @@ const DROP = "";
|
|
|
82
58
|
* `method` and every response an `id`, so it is neither, and core ignores it
|
|
83
59
|
* as a response to nothing.
|
|
84
60
|
*
|
|
85
|
-
* A frame whose `method` starts with `@effect/rpc/`
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* any client silently stops the server (Effect-TS/effect#8499). A
|
|
89
|
-
* notification of that shape is dropped; a request, which carries an `id`,
|
|
90
|
-
* is answered `-32601` Method not found, as no MCP method has that prefix.
|
|
91
|
-
* Remove this branch once Effect-TS/effect#8499 is fixed in the installed
|
|
92
|
-
* `effect` (the open PR #8509 proposes the fix) and a probe shows an
|
|
93
|
-
* unguarded server still answering a ping sent after an `@effect/rpc/Eof`
|
|
94
|
-
* notification.
|
|
61
|
+
* A frame whose `method` starts with `@effect/rpc/` is an ordinary request or
|
|
62
|
+
* notification to core, which serves MCP with Effect's RPC control messages
|
|
63
|
+
* turned off: a notification is ignored and a request answered `-32601`.
|
|
95
64
|
*
|
|
96
65
|
* Arrays are forwarded: core answers a batch `-32600` itself. So is any object
|
|
97
66
|
* with an `id` and no `method`, which is a response; JSON-RPC never answers a
|
|
@@ -107,10 +76,7 @@ const answerFor = (line) => {
|
|
|
107
76
|
if (typeof value !== "object" || value === null) return INVALID_REQUEST_FRAME;
|
|
108
77
|
if (Array.isArray(value)) return void 0;
|
|
109
78
|
const message = value;
|
|
110
|
-
if (Object.hasOwn(message, "method"))
|
|
111
|
-
if (typeof message.method === "string" && message.method.startsWith("@effect/rpc/")) return isNullish(message.id) ? DROP : methodNotFoundFrame(message.id);
|
|
112
|
-
return typeof message.method !== "string" && isNullish(message.id) ? INVALID_REQUEST_FRAME : void 0;
|
|
113
|
-
}
|
|
79
|
+
if (Object.hasOwn(message, "method")) return typeof message.method !== "string" && isNullish(message.id) ? INVALID_REQUEST_FRAME : void 0;
|
|
114
80
|
return Object.hasOwn(message, "id") ? void 0 : INVALID_REQUEST_FRAME;
|
|
115
81
|
};
|
|
116
82
|
/**
|
|
@@ -125,8 +91,7 @@ const answerFor = (line) => {
|
|
|
125
91
|
* JSON-RPC 2.0 requires a reply to each of them, so the guard frames stdin
|
|
126
92
|
* exactly as core does and answers such a line itself, returning
|
|
127
93
|
* {@link PARSE_ERROR_FRAME} or {@link INVALID_REQUEST_FRAME} for the caller
|
|
128
|
-
* to write.
|
|
129
|
-
* `answerFor`), which would otherwise let one client line stop the server.
|
|
94
|
+
* to write.
|
|
130
95
|
*
|
|
131
96
|
* - Decoding mirrors core: one streaming UTF-8 decoder, so a character split
|
|
132
97
|
* across chunks decodes whole, a byte-order mark is stripped only at the
|
|
@@ -137,8 +102,6 @@ const answerFor = (line) => {
|
|
|
137
102
|
* - A line that is JSON is answered `-32600` when it is not an object or an
|
|
138
103
|
* array, when its `method` is not a string and it has no usable `id`, or
|
|
139
104
|
* when it has neither `method` nor `id`.
|
|
140
|
-
* - A line whose `method` starts with `@effect/rpc/` is answered `-32601`
|
|
141
|
-
* when it carries an `id` and dropped when it does not.
|
|
142
105
|
* - Everything else goes to core.
|
|
143
106
|
* - A line longer than `maxFrameLength` code units is answered once, as soon
|
|
144
107
|
* as the held part exceeds the cap, and the rest of it is discarded up to
|
|
@@ -181,7 +144,7 @@ const makeFrameGuard = (maxFrameLength = MAX_FRAME_LENGTH) => {
|
|
|
181
144
|
if (BLANK.test(line)) continue;
|
|
182
145
|
const answer = answerFor(line);
|
|
183
146
|
if (answer === void 0) forward += `${line}\n`;
|
|
184
|
-
else
|
|
147
|
+
else replies.push(answer);
|
|
185
148
|
}
|
|
186
149
|
pending += text.slice(start);
|
|
187
150
|
if (pending.length > maxFrameLength) {
|
|
@@ -235,4 +198,4 @@ const guardStdin = (stdio) => {
|
|
|
235
198
|
*/
|
|
236
199
|
const makeGuardedStdio = () => Layer.effect(Stdio.Stdio, Effect.map(Stdio.Stdio, guardStdin));
|
|
237
200
|
|
|
238
|
-
export { INVALID_REQUEST_FRAME, MAX_FRAME_LENGTH, PARSE_ERROR_FRAME,
|
|
201
|
+
export { INVALID_REQUEST_FRAME, MAX_FRAME_LENGTH, PARSE_ERROR_FRAME, guardStdin, makeFrameGuard, makeGuardedStdio };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The boundary layer of an effect/ai MCP server: stdio wiring that keeps stdout the wire, tool-failure shaping, strict-input walkers, and test clients",
|
|
6
6
|
"keywords": [
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"./package.json": "./package.json"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"@effected/engine": "^0.
|
|
55
|
+
"@effected/engine": "^0.10.0",
|
|
56
56
|
"effect": "^4.0.0"
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|
package/testing.d.ts
CHANGED
|
@@ -214,7 +214,15 @@ export declare class McpHarness {
|
|
|
214
214
|
readonly sentSoFar: Effect.Effect<ReadonlyArray<unknown>>;
|
|
215
215
|
/** Everything written to stderr, plus every captured log line. */
|
|
216
216
|
readonly stderrSoFar: Effect.Effect<string>;
|
|
217
|
-
/**
|
|
217
|
+
/**
|
|
218
|
+
* Every captured `console.log`, `info` or `debug` call, its arguments joined with a space, each rendered close to
|
|
219
|
+
* what the console would show: a string as itself, a value with its own string form as that form (an `Error` as
|
|
220
|
+
* `Error: boom`, a number, a date), an array or a plain object as JSON (`{"message":"x"}`, not just `x`).
|
|
221
|
+
*
|
|
222
|
+
* @remarks
|
|
223
|
+
* Rendering never throws inside the server's own call. A value whose string form throws (an object with no
|
|
224
|
+
* prototype, a throwing `toString`) and has no JSON falls back to `ErrorText.describe` from `@effected/engine`.
|
|
225
|
+
*/
|
|
218
226
|
readonly consoleLogSoFar: Effect.Effect<ReadonlyArray<string>>;
|
|
219
227
|
/**
|
|
220
228
|
* End stdin, as a client disconnecting does. The server drains first:
|