@effected/mcp 0.12.0 → 0.14.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/README.md +5 -562
- package/bin/effected-mcp.js +12 -0
- package/index.d.ts +11 -979
- package/index.js +2 -9
- package/main.d.ts +28 -0
- package/main.js +46 -0
- package/package.json +17 -21
- package/server.js +48 -0
- package/tools/identity.js +28 -0
- package/tools/schema.js +158 -0
- package/tsdoc-metadata.json +1 -1
- package/version.js +11 -0
- package/McpGuard.js +0 -81
- package/McpHarness.js +0 -338
- package/McpProbe.js +0 -94
- package/McpProcess.js +0 -196
- package/McpStdio.js +0 -171
- package/McpTestFailure.js +0 -49
- package/McpToolAudit.js +0 -109
- package/McpToolkit.js +0 -257
- package/StdioCapture.js +0 -146
- package/ToolFailure.js +0 -75
- package/ToolInputSchema.js +0 -180
- package/ToolOutcome.js +0 -119
- package/ToolOutputSchema.js +0 -65
- package/ToolRefusal.js +0 -57
- package/ToolReplay.js +0 -129
- package/guard.d.ts +0 -189
- package/guard.js +0 -3
- package/internal/LaunchFailed.js +0 -34
- package/internal/StdinFrames.js +0 -201
- package/internal/guardLaunch.js +0 -13
- package/internal/wire.js +0 -65
- package/node.d.ts +0 -126
- package/node.js +0 -3
- package/testing.d.ts +0 -490
- package/testing.js +0 -7
package/ToolReplay.js
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import { ToolOutcome } from "./ToolOutcome.js";
|
|
2
|
-
import { Effect, Option, Schema } from "effect";
|
|
3
|
-
import { KeyValueStore } from "effect/persistence";
|
|
4
|
-
|
|
5
|
-
// src/ToolReplay.ts
|
|
6
|
-
/**
|
|
7
|
-
* Idempotent replay for a write tool: a repeated call with the same key returns
|
|
8
|
-
* the first call's stored result, marked `_idempotentReplay: true`, instead of
|
|
9
|
-
* running the mutation again.
|
|
10
|
-
*
|
|
11
|
-
* @remarks
|
|
12
|
-
* The store is core's `KeyValueStore` (`effect/persistence`), required in `R`
|
|
13
|
-
* (`KeyValueStore.layerMemory`, `layerFileSystem` or `layerSql`), unless
|
|
14
|
-
* {@link ToolReplayOptions.store} supplies a {@link ToolReplayStore}: just
|
|
15
|
-
* `get` and `set`, for an application's own table. Then `R` carries no
|
|
16
|
-
* `KeyValueStore`.
|
|
17
|
-
*
|
|
18
|
-
* Inside an MCP server, pass the store. A service left in a handler's `R`
|
|
19
|
-
* has to be declared in the tool's `dependencies`, which makes it a
|
|
20
|
-
* request-level dependency that only MCP middleware can meet. Read
|
|
21
|
-
* `KeyValueStore` once when the handlers are built, with the effect form of
|
|
22
|
-
* `Toolkit.toLayer`, and hand it to `wrap` as `store`: a `KeyValueStore`
|
|
23
|
-
* satisfies {@link ToolReplayStore} as is.
|
|
24
|
-
*
|
|
25
|
-
* Semantics, per call:
|
|
26
|
-
*
|
|
27
|
-
* - A key of `Option.none()` runs the handler; nothing is read or stored.
|
|
28
|
-
* - A stored value that decodes through `success` is returned with the marker,
|
|
29
|
-
* and the handler does not run.
|
|
30
|
-
* - A miss runs the handler and stores its result, best-effort. A store that
|
|
31
|
-
* fails, a row that no longer decodes (written under an older schema, or
|
|
32
|
-
* corrupt), and a failed write are each a miss or a skipped write, never a
|
|
33
|
-
* tool error. Each is logged at debug level with the tool name.
|
|
34
|
-
* - A handler failure stores nothing, so a retry runs it again.
|
|
35
|
-
* - An `ok: false` result (a {@link ToolOutcome} envelope failure) is never
|
|
36
|
-
* stored: a "not found" must not replay after the thing exists.
|
|
37
|
-
*
|
|
38
|
-
* This is replay, not exactly-once: two concurrent calls with one key both
|
|
39
|
-
* miss and both run.
|
|
40
|
-
*
|
|
41
|
-
* The key is the whole identity of a request. A hit compares neither the
|
|
42
|
-
* params nor the schema version (a row written under an older schema replays
|
|
43
|
-
* whenever it still decodes), and rows never expire: the store keeps a result
|
|
44
|
-
* until something removes it. A key reused with different params therefore
|
|
45
|
-
* replays the first result, so {@link ToolReplayOptions.key} must fold in a
|
|
46
|
-
* digest of any params that can vary under one id.
|
|
47
|
-
*
|
|
48
|
-
* Wrap it inside {@link ToolOutcome.catchAsEnvelope}, so the envelope sees the
|
|
49
|
-
* handler's real errors and replay sees only real successes.
|
|
50
|
-
*
|
|
51
|
-
* @example
|
|
52
|
-
* ```ts
|
|
53
|
-
* import { ToolReplay } from "@effected/mcp"
|
|
54
|
-
* import { Option, Schema } from "effect"
|
|
55
|
-
*
|
|
56
|
-
* const Created = ToolReplay.success(Schema.Struct({ ok: Schema.Literal(true), id: Schema.Number }))
|
|
57
|
-
* const create = ToolReplay.wrap({
|
|
58
|
-
* tool: "goal_create",
|
|
59
|
-
* success: Created,
|
|
60
|
-
* key: (params: { readonly requestId?: string }) => Option.fromUndefinedOr(params.requestId),
|
|
61
|
-
* })(createGoal)
|
|
62
|
-
* ```
|
|
63
|
-
*
|
|
64
|
-
* @public
|
|
65
|
-
*/
|
|
66
|
-
var ToolReplay = class ToolReplay {
|
|
67
|
-
constructor() {}
|
|
68
|
-
/**
|
|
69
|
-
* The marker field, to spread into each object member of a replayable
|
|
70
|
-
* success schema. {@link ToolReplay.success} adds it for you.
|
|
71
|
-
*/
|
|
72
|
-
static marker = { _idempotentReplay: Schema.optionalKey(Schema.Literal(true).annotate({ description: "Present and true when this result replays an earlier call with the same idempotency key: the stored result is returned and the mutation did not run again." })) };
|
|
73
|
-
/**
|
|
74
|
-
* A struct with {@link ToolReplay.marker} added. Use it for each object
|
|
75
|
-
* success member, so core's encoder keeps the marker on a replayed reply.
|
|
76
|
-
*
|
|
77
|
-
* @remarks
|
|
78
|
-
* Built with core's `fieldsAssign`, which, like `mapFields`, does not carry
|
|
79
|
-
* the struct's own `.check(...)` refinements over: apply checks to the
|
|
80
|
-
* result, not to the struct passed in.
|
|
81
|
-
*/
|
|
82
|
-
static success = (schema) => schema.pipe(Schema.fieldsAssign(ToolReplay.marker));
|
|
83
|
-
/**
|
|
84
|
-
* The `KeyValueStore` key one call is stored under: `<tool>:<key>`. Split on
|
|
85
|
-
* the first `:` to recover both parts; MCP tool names are `[A-Za-z0-9_.-]`.
|
|
86
|
-
*/
|
|
87
|
-
static storeKey = (tool, key) => `${tool}:${key}`;
|
|
88
|
-
/**
|
|
89
|
-
* Wraps a handler with replay. Throws at wrap time when `tool` contains
|
|
90
|
-
* `:`, a wiring mistake, so it surfaces when the server is built.
|
|
91
|
-
*/
|
|
92
|
-
static wrap = (options) => {
|
|
93
|
-
if (options.tool.includes(":")) throw new Error(`ToolReplay.wrap: tool name "${options.tool}" contains ":", the stored-key separator.`);
|
|
94
|
-
const { tool, success } = options;
|
|
95
|
-
const given = options.store;
|
|
96
|
-
const json = Schema.fromJsonString(Schema.toCodecJson(success));
|
|
97
|
-
const decode = Schema.decodeEffect(json);
|
|
98
|
-
const encode = Schema.encodeEffect(json);
|
|
99
|
-
return (handler) => (params) => {
|
|
100
|
-
const found = options.key(params);
|
|
101
|
-
if (Option.isNone(found)) return handler(params);
|
|
102
|
-
const storeKey = ToolReplay.storeKey(tool, found.value);
|
|
103
|
-
return Effect.gen(function* () {
|
|
104
|
-
const store = given ?? (yield* KeyValueStore.KeyValueStore);
|
|
105
|
-
const cached = yield* store.get(storeKey).pipe(Effect.flatMap((raw) => raw === void 0 ? Effect.succeedNone : Effect.asSome(decode(raw))), Effect.tapCause((cause) => Effect.logDebug("ToolReplay lookup failed; treating as a miss", {
|
|
106
|
-
tool,
|
|
107
|
-
cause
|
|
108
|
-
})), Effect.orElseSucceed(() => Option.none()));
|
|
109
|
-
if (Option.isSome(cached)) {
|
|
110
|
-
yield* Effect.annotateCurrentSpan("mcp.tool.replayed", true);
|
|
111
|
-
return withMarker(cached.value);
|
|
112
|
-
}
|
|
113
|
-
yield* Effect.annotateCurrentSpan("mcp.tool.replayed", false);
|
|
114
|
-
const result = yield* handler(params);
|
|
115
|
-
if (!ToolOutcome.isFailure(result)) yield* encode(result).pipe(Effect.flatMap((encoded) => store.set(storeKey, encoded)), Effect.tapCause((cause) => Effect.logDebug("ToolReplay store failed; result not stored", {
|
|
116
|
-
tool,
|
|
117
|
-
cause
|
|
118
|
-
})), Effect.ignore);
|
|
119
|
-
return result;
|
|
120
|
-
}).pipe(Effect.withSpan("ToolReplay.wrap", { attributes: { "mcp.tool.name": tool } }));
|
|
121
|
-
};
|
|
122
|
-
};
|
|
123
|
-
};
|
|
124
|
-
const withMarker = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? {
|
|
125
|
-
...value,
|
|
126
|
-
_idempotentReplay: true
|
|
127
|
-
} : value;
|
|
128
|
-
|
|
129
|
-
export { ToolReplay };
|
package/guard.d.ts
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
import { Effect, Layer, Runtime } from "effect";
|
|
2
|
-
/**
|
|
3
|
-
* The slice of the host process the guard uses. Node's `process` satisfies it.
|
|
4
|
-
*
|
|
5
|
-
* @public
|
|
6
|
-
*/
|
|
7
|
-
interface McpGuardHost {
|
|
8
|
-
/** Register the `uncaughtException` listener. */
|
|
9
|
-
on(event: "uncaughtException", listener: (error: Error, origin: string) => void): unknown;
|
|
10
|
-
/** Register the `unhandledRejection` listener. */
|
|
11
|
-
on(event: "unhandledRejection", listener: (reason: unknown) => void): unknown;
|
|
12
|
-
/** Raise an `uncaughtException` to the registered listeners. Used only by {@link McpGuardRunOptions.injectCrash}. */
|
|
13
|
-
emit(event: "uncaughtException", error: Error, origin: "uncaughtException" | "unhandledRejection"): unknown;
|
|
14
|
-
/** Raise an `unhandledRejection` to the registered listeners. Used only by {@link McpGuardRunOptions.injectCrash}. */
|
|
15
|
-
emit(event: "unhandledRejection", reason: unknown, promise: Promise<unknown>): unknown;
|
|
16
|
-
/** Where every guard report is written. Never stdout: that is the JSON-RPC wire. */
|
|
17
|
-
readonly stderr: {
|
|
18
|
-
write(chunk: string): unknown;
|
|
19
|
-
};
|
|
20
|
-
/** End the process at once. */
|
|
21
|
-
exit(code?: number): never;
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* When a stray exception or rejection ends the process.
|
|
25
|
-
*
|
|
26
|
-
* @remarks
|
|
27
|
-
* `"exit"` exits 1 whenever it happens. `"exitBeforeConnect"` exits 1 until
|
|
28
|
-
* the server is serving, then logs and keeps serving: a server that dies
|
|
29
|
-
* mid-session deregisters every tool from the client. `"log"` never exits.
|
|
30
|
-
*
|
|
31
|
-
* @public
|
|
32
|
-
*/
|
|
33
|
-
interface McpGuardPolicy {
|
|
34
|
-
/** For `uncaughtException`. */
|
|
35
|
-
readonly onUncaught: "exit" | "exitBeforeConnect";
|
|
36
|
-
/** For `unhandledRejection`. Defaults to `"exit"`. */
|
|
37
|
-
readonly onRejection?: "exit" | "exitBeforeConnect" | "log" | undefined;
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* What {@link McpGuardRunOptions.load} resolves to: the server and the
|
|
41
|
-
* platform runner to launch it with.
|
|
42
|
-
*
|
|
43
|
-
* @public
|
|
44
|
-
*/
|
|
45
|
-
interface McpGuardedServer<ROut, E> {
|
|
46
|
-
/** The whole server layer, stdio included, with nothing left to provide. */
|
|
47
|
-
readonly layer: Layer.Layer<ROut, E, never>;
|
|
48
|
-
/** The platform's `runMain`, such as `NodeRuntime.runMain` from `@effect/platform-node`. */
|
|
49
|
-
readonly runMain: (effect: Effect.Effect<never, Error>, options: {
|
|
50
|
-
readonly teardown: Runtime.Teardown;
|
|
51
|
-
}) => void;
|
|
52
|
-
/** Formats a reported error from here on; the guard's own dependency-free formatter until then. */
|
|
53
|
-
readonly format?: ((error: unknown) => string) | undefined;
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Options for {@link McpGuard.run}.
|
|
57
|
-
*
|
|
58
|
-
* @public
|
|
59
|
-
*/
|
|
60
|
-
interface McpGuardRunOptions<ROut, E> {
|
|
61
|
-
/** Prefixes every report the guard writes, as in `my-server: uncaughtException (…): …`. */
|
|
62
|
-
readonly label: string;
|
|
63
|
-
/** The process: pass `process`. */
|
|
64
|
-
readonly host: McpGuardHost;
|
|
65
|
-
/** Defaults to `{ onUncaught: "exit", onRejection: "exit" }`. */
|
|
66
|
-
readonly policy?: McpGuardPolicy | undefined;
|
|
67
|
-
/**
|
|
68
|
-
* Import and assemble the server. Everything that can throw while loading
|
|
69
|
-
* belongs here, behind a dynamic `import()`, so the guards are already
|
|
70
|
-
* listening when it runs.
|
|
71
|
-
*/
|
|
72
|
-
readonly load: () => Promise<McpGuardedServer<ROut, E>>;
|
|
73
|
-
/**
|
|
74
|
-
* For a test of the guards themselves: raise one stray `kind` at `at`, so
|
|
75
|
-
* both halves of a policy can be driven end to end in a real process.
|
|
76
|
-
*
|
|
77
|
-
* @remarks
|
|
78
|
-
* - `"load"` raises it once both listeners are installed and before
|
|
79
|
-
* `load()` is called, then waits for the guard to handle it: `load()`
|
|
80
|
-
* is not called until the listener has run. If a test double's `exit`
|
|
81
|
-
* throws instead of ending the process, `run` rejects with what it
|
|
82
|
-
* threw and never calls `load()`. The pre-connect half of
|
|
83
|
-
* the policy applies, so `"exitBeforeConnect"` exits 1 here, and only
|
|
84
|
-
* an `onRejection` of `"log"` lets the process go on to load and serve.
|
|
85
|
-
* The report uses the guard's own formatter, since no `format` is
|
|
86
|
-
* loaded yet.
|
|
87
|
-
* - `"connected"` raises it on a timer right after the server is
|
|
88
|
-
* serving, where `"exitBeforeConnect"` logs and keeps serving. A throw
|
|
89
|
-
* from a test double's `exit` there is dropped.
|
|
90
|
-
*
|
|
91
|
-
* **The `"connected"` report is asynchronous.** It is raised on a later
|
|
92
|
-
* tick, a `setTimeout(0)` after the server starts serving, so it can land
|
|
93
|
-
* after the first responses a test reads: reading stderr once, right
|
|
94
|
-
* after the `initialize` or `tools/list` response, can see an empty
|
|
95
|
-
* buffer. Wait for it with `McpProcess.stderrUntil` from
|
|
96
|
-
* `@effected/mcp/testing` rather than reading it once.
|
|
97
|
-
*
|
|
98
|
-
* Either is raised only through `host.emit`, on a `setTimeout(0)` tick,
|
|
99
|
-
* never as a real throw or rejection, so the guard's listeners handle it
|
|
100
|
-
* the same way under the real `process` and under a test double. Under
|
|
101
|
-
* `process`, every listener registered for the event sees it, not only
|
|
102
|
-
* the guard's. It carries an `[injected]` message; a rejection is emitted
|
|
103
|
-
* with an already-handled rejected promise. `undefined`, or
|
|
104
|
-
* an `at` or `kind` outside these values, does nothing at all. Wire it to
|
|
105
|
-
* an environment variable only a test sets.
|
|
106
|
-
*/
|
|
107
|
-
readonly injectCrash?: InjectCrash | undefined;
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* One test-only injected crash, as {@link McpGuard.parseInjectCrash} returns
|
|
111
|
-
* it and {@link McpGuardRunOptions.injectCrash} takes it: which point of the
|
|
112
|
-
* launch to raise it at, and which process event to raise. The same shape as
|
|
113
|
-
* `ProcessGuardInjection` from `@effected/engine/guard`.
|
|
114
|
-
*
|
|
115
|
-
* @public
|
|
116
|
-
*/
|
|
117
|
-
interface InjectCrash {
|
|
118
|
-
/** `"load"`: before the server module loads. `"connected"`: once the server is serving. */
|
|
119
|
-
readonly at: "load" | "connected";
|
|
120
|
-
/** The process event raised to the guard's listeners. */
|
|
121
|
-
readonly kind: "uncaughtException" | "unhandledRejection";
|
|
122
|
-
}
|
|
123
|
-
/**
|
|
124
|
-
* Crash guards for an MCP server process, installed before the server's
|
|
125
|
-
* module graph is loaded. Imported from `@effected/mcp/guard`.
|
|
126
|
-
*
|
|
127
|
-
* @remarks
|
|
128
|
-
* {@link McpGuard.run} is `ProcessGuard.run` from `@effected/engine/guard`
|
|
129
|
-
* with an MCP launch in its `load`: it registers `uncaughtException` and
|
|
130
|
-
* `unhandledRejection` listeners on `host`, then awaits `load()`, then
|
|
131
|
-
* launches the server with `McpStdio.launch` and `McpStdio.teardown` under
|
|
132
|
-
* the `runMain` it returned. A server on another transport (an LSP over
|
|
133
|
-
* `vscode-languageserver`, say) uses `ProcessGuard.run` directly and calls
|
|
134
|
-
* its `markConnected` itself. This entrypoint statically imports only
|
|
135
|
-
* `@effected/engine/guard`, which imports nothing: it loads `effect` and the
|
|
136
|
-
* stdio wiring only after the guards are listening, so a throw while any of
|
|
137
|
-
* it evaluates is still reported on stderr.
|
|
138
|
-
*
|
|
139
|
-
* - "Connected" means the whole server layer has built, so stdin is being
|
|
140
|
-
* read. That is before any client sends `initialize`.
|
|
141
|
-
* - `injectCrash` drives either half of the policy from a test, before
|
|
142
|
-
* `load()` or once connected; unset, it does nothing.
|
|
143
|
-
* - A `load()` that rejects is reported as `startup failed` and exits 1
|
|
144
|
-
* whatever the policy. Left to a log-only rejection listener, it would
|
|
145
|
-
* let the event loop drain and exit 0 with no server.
|
|
146
|
-
* - Installing an `unhandledRejection` listener switches off Node's default
|
|
147
|
-
* of throwing on one, so `"log"` really does keep the process running.
|
|
148
|
-
* - An exit from a guard skips Effect's finalizers and the teardown.
|
|
149
|
-
* - Keeping on serving after a crash works because the platform `runMain`
|
|
150
|
-
* holds the process open while the server fiber lives.
|
|
151
|
-
* - A layer that fails to build is still reported by `McpStdio.launch`.
|
|
152
|
-
*
|
|
153
|
-
* @example
|
|
154
|
-
* ```ts
|
|
155
|
-
* import { McpGuard } from "@effected/mcp/guard";
|
|
156
|
-
*
|
|
157
|
-
* await McpGuard.run({
|
|
158
|
-
* label: "my-server",
|
|
159
|
-
* host: process,
|
|
160
|
-
* policy: { onUncaught: "exitBeforeConnect", onRejection: "log" },
|
|
161
|
-
* load: async () => {
|
|
162
|
-
* const { NodeRuntime } = await import("@effect/platform-node");
|
|
163
|
-
* const { Main } = await import("./server.js");
|
|
164
|
-
* return { layer: Main, runMain: NodeRuntime.runMain };
|
|
165
|
-
* },
|
|
166
|
-
* });
|
|
167
|
-
* ```
|
|
168
|
-
*
|
|
169
|
-
* @public
|
|
170
|
-
*/
|
|
171
|
-
export declare class McpGuard {
|
|
172
|
-
private constructor();
|
|
173
|
-
/**
|
|
174
|
-
* Parse a test-only crash-injection setting into {@link McpGuardRunOptions.injectCrash}: `<at>:<kind>`, where `at`
|
|
175
|
-
* is `"load"` or `"connected"` and `kind` is `"uncaughtException"` or `"unhandledRejection"`. Anything else, or no
|
|
176
|
-
* value, is `undefined`. The same grammar as `ProcessGuard.parseInjectCrash` from `@effected/engine/guard`, which
|
|
177
|
-
* this is, so an MCP launcher need not import the engine guard for it.
|
|
178
|
-
*
|
|
179
|
-
* @example
|
|
180
|
-
* ```ts
|
|
181
|
-
* McpGuard.parseInjectCrash("load:uncaughtException"); // => { at: "load", kind: "uncaughtException" }
|
|
182
|
-
* ```
|
|
183
|
-
*/
|
|
184
|
-
static readonly parseInjectCrash: (value: string | undefined) => InjectCrash | undefined;
|
|
185
|
-
/** Install the guards, load the server, and launch it. Resolves once the server is launched. */
|
|
186
|
-
static readonly run: <ROut, E>(options: McpGuardRunOptions<ROut, E>) => Promise<void>;
|
|
187
|
-
}
|
|
188
|
-
export type { InjectCrash, McpGuardHost, McpGuardPolicy, McpGuardRunOptions, McpGuardedServer };
|
|
189
|
-
//# sourceMappingURL=guard.d.ts.map
|
package/guard.js
DELETED
package/internal/LaunchFailed.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { Runtime } from "effect";
|
|
2
|
-
|
|
3
|
-
// src/internal/LaunchFailed.ts
|
|
4
|
-
/**
|
|
5
|
-
* The failure `McpStdio.launch` re-raises after reporting a launch failure on
|
|
6
|
-
* stderr itself. `errorReported: false` keeps `runMain` from reporting it a
|
|
7
|
-
* second time — through a logger that writes to stdout — and the original exit
|
|
8
|
-
* code rides along for the teardown.
|
|
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
|
-
*
|
|
15
|
-
* @internal
|
|
16
|
-
*/
|
|
17
|
-
var LaunchFailed = class extends Error {
|
|
18
|
-
#code;
|
|
19
|
-
constructor(code) {
|
|
20
|
-
super(`the MCP server failed to launch (exit ${code})`);
|
|
21
|
-
this.name = "LaunchFailed";
|
|
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;
|
|
31
|
-
}
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export { LaunchFailed };
|
package/internal/StdinFrames.js
DELETED
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
import { Effect, Layer, Stdio, Stream } from "effect";
|
|
2
|
-
import { McpSchema } from "effect/ai";
|
|
3
|
-
|
|
4
|
-
// src/internal/StdinFrames.ts
|
|
5
|
-
/**
|
|
6
|
-
* The line-length cap core's stdio NDJSON decoder applies, in the same unit
|
|
7
|
-
* (UTF-16 code units of the decoded text) and with the same predicate
|
|
8
|
-
* (`length > cap`). The guard answers a longer line itself, so no line core
|
|
9
|
-
* would reject ever reaches core.
|
|
10
|
-
*
|
|
11
|
-
* @internal
|
|
12
|
-
*/
|
|
13
|
-
const MAX_FRAME_LENGTH = 16777216;
|
|
14
|
-
/**
|
|
15
|
-
* The JSON-RPC 2.0 answer to a frame that is not JSON, newline-framed. The id
|
|
16
|
-
* is `null` because no id can be read from an unparseable frame.
|
|
17
|
-
*
|
|
18
|
-
* @internal
|
|
19
|
-
*/
|
|
20
|
-
const PARSE_ERROR_FRAME = `${JSON.stringify({
|
|
21
|
-
jsonrpc: "2.0",
|
|
22
|
-
id: null,
|
|
23
|
-
error: {
|
|
24
|
-
code: McpSchema.PARSE_ERROR_CODE,
|
|
25
|
-
message: "Parse error"
|
|
26
|
-
}
|
|
27
|
-
})}\n`;
|
|
28
|
-
/**
|
|
29
|
-
* The JSON-RPC 2.0 answer to a frame that is JSON but no JSON-RPC message
|
|
30
|
-
* core can handle, newline-framed. The id is `null` because such a frame
|
|
31
|
-
* carries no usable id.
|
|
32
|
-
*
|
|
33
|
-
* @internal
|
|
34
|
-
*/
|
|
35
|
-
const INVALID_REQUEST_FRAME = `${JSON.stringify({
|
|
36
|
-
jsonrpc: "2.0",
|
|
37
|
-
id: null,
|
|
38
|
-
error: {
|
|
39
|
-
code: McpSchema.INVALID_REQUEST_ERROR_CODE,
|
|
40
|
-
message: "Invalid Request"
|
|
41
|
-
}
|
|
42
|
-
})}\n`;
|
|
43
|
-
/** JSON's insignificant whitespace, minus the newline that ends the line. */
|
|
44
|
-
const BLANK = /^[ \t\r]*$/;
|
|
45
|
-
const isNullish = (value) => value === null || value === void 0;
|
|
46
|
-
/**
|
|
47
|
-
* The guard's answer to one non-blank line within the cap, or `undefined` to
|
|
48
|
-
* forward it to core.
|
|
49
|
-
*
|
|
50
|
-
* @remarks
|
|
51
|
-
* A frame that parses is forwarded unless core would skip it without a
|
|
52
|
-
* reply, where JSON-RPC 2.0 requires an Invalid Request:
|
|
53
|
-
*
|
|
54
|
-
* - a value that is neither an object nor an array;
|
|
55
|
-
* - an object with a `method` that is not a string and no usable `id` (with
|
|
56
|
-
* an `id`, core answers `-32601` itself);
|
|
57
|
-
* - an object with neither `method` nor `id`: every JSON-RPC request carries a
|
|
58
|
-
* `method` and every response an `id`, so it is neither, and core ignores it
|
|
59
|
-
* as a response to nothing.
|
|
60
|
-
*
|
|
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`.
|
|
64
|
-
*
|
|
65
|
-
* Arrays are forwarded: core answers a batch `-32600` itself. So is any object
|
|
66
|
-
* with an `id` and no `method`, which is a response; JSON-RPC never answers a
|
|
67
|
-
* response.
|
|
68
|
-
*/
|
|
69
|
-
const answerFor = (line) => {
|
|
70
|
-
let value;
|
|
71
|
-
try {
|
|
72
|
-
value = JSON.parse(line);
|
|
73
|
-
} catch {
|
|
74
|
-
return PARSE_ERROR_FRAME;
|
|
75
|
-
}
|
|
76
|
-
if (typeof value !== "object" || value === null) return INVALID_REQUEST_FRAME;
|
|
77
|
-
if (Array.isArray(value)) return void 0;
|
|
78
|
-
const message = value;
|
|
79
|
-
if (Object.hasOwn(message, "method")) return typeof message.method !== "string" && isNullish(message.id) ? INVALID_REQUEST_FRAME : void 0;
|
|
80
|
-
return Object.hasOwn(message, "id") ? void 0 : INVALID_REQUEST_FRAME;
|
|
81
|
-
};
|
|
82
|
-
/**
|
|
83
|
-
* A stateful step over stdin chunks that forwards only complete lines core's
|
|
84
|
-
* decoder will parse and answer, each with its newline.
|
|
85
|
-
*
|
|
86
|
-
* @remarks
|
|
87
|
-
* Core's stdio decoder skips a line it cannot use and keeps serving
|
|
88
|
-
* (Effect-TS/effect PR #8541): a line that is not JSON, a JSON value that is
|
|
89
|
-
* not an object, or an object that is no JSON-RPC message is dropped without
|
|
90
|
-
* a reply, and a line past its buffer cap is logged on stderr and dropped.
|
|
91
|
-
* JSON-RPC 2.0 requires a reply to each of them, so the guard frames stdin
|
|
92
|
-
* exactly as core does and answers such a line itself, returning
|
|
93
|
-
* {@link PARSE_ERROR_FRAME} or {@link INVALID_REQUEST_FRAME} for the caller
|
|
94
|
-
* to write.
|
|
95
|
-
*
|
|
96
|
-
* - Decoding mirrors core: one streaming UTF-8 decoder, so a character split
|
|
97
|
-
* across chunks decodes whole, a byte-order mark is stripped only at the
|
|
98
|
-
* start of the stream, and a U+FEFF opening any later line stays in that
|
|
99
|
-
* line, where `JSON.parse` rejects it as core's would.
|
|
100
|
-
* - A partial line is held until its newline arrives.
|
|
101
|
-
* - A line of JSON whitespace only is not a frame: dropped, not answered.
|
|
102
|
-
* - A line that is JSON is answered `-32600` when it is not an object or an
|
|
103
|
-
* array, when its `method` is not a string and it has no usable `id`, or
|
|
104
|
-
* when it has neither `method` nor `id`.
|
|
105
|
-
* - Everything else goes to core.
|
|
106
|
-
* - A line longer than `maxFrameLength` code units is answered once, as soon
|
|
107
|
-
* as the held part exceeds the cap, and the rest of it is discarded up to
|
|
108
|
-
* its newline. Nothing is held beyond the cap.
|
|
109
|
-
*
|
|
110
|
-
* Create one guard per stdin: its state must outlive core re-subscribing to
|
|
111
|
-
* stdin after any failure in its read loop, or a held partial line is lost.
|
|
112
|
-
*
|
|
113
|
-
* @internal
|
|
114
|
-
*/
|
|
115
|
-
const makeFrameGuard = (maxFrameLength = MAX_FRAME_LENGTH) => {
|
|
116
|
-
const decoder = new TextDecoder();
|
|
117
|
-
const encoder = new TextEncoder();
|
|
118
|
-
let pending = "";
|
|
119
|
-
let discarding = false;
|
|
120
|
-
return (chunk) => {
|
|
121
|
-
const text = decoder.decode(chunk, { stream: true });
|
|
122
|
-
let forward = "";
|
|
123
|
-
const replies = [];
|
|
124
|
-
let start = 0;
|
|
125
|
-
if (discarding) {
|
|
126
|
-
const newline = text.indexOf("\n");
|
|
127
|
-
if (newline === -1) return {
|
|
128
|
-
forward: void 0,
|
|
129
|
-
replies
|
|
130
|
-
};
|
|
131
|
-
discarding = false;
|
|
132
|
-
start = newline + 1;
|
|
133
|
-
}
|
|
134
|
-
let newline = text.indexOf("\n", start);
|
|
135
|
-
while (newline !== -1) {
|
|
136
|
-
const line = pending + text.slice(start, newline);
|
|
137
|
-
pending = "";
|
|
138
|
-
start = newline + 1;
|
|
139
|
-
newline = text.indexOf("\n", start);
|
|
140
|
-
if (line.length > maxFrameLength) {
|
|
141
|
-
replies.push(PARSE_ERROR_FRAME);
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
if (BLANK.test(line)) continue;
|
|
145
|
-
const answer = answerFor(line);
|
|
146
|
-
if (answer === void 0) forward += `${line}\n`;
|
|
147
|
-
else replies.push(answer);
|
|
148
|
-
}
|
|
149
|
-
pending += text.slice(start);
|
|
150
|
-
if (pending.length > maxFrameLength) {
|
|
151
|
-
pending = "";
|
|
152
|
-
discarding = true;
|
|
153
|
-
replies.push(PARSE_ERROR_FRAME);
|
|
154
|
-
}
|
|
155
|
-
return {
|
|
156
|
-
forward: forward === "" ? void 0 : encoder.encode(forward),
|
|
157
|
-
replies
|
|
158
|
-
};
|
|
159
|
-
};
|
|
160
|
-
};
|
|
161
|
-
/**
|
|
162
|
-
* `Stdio` whose `stdin` carries only lines core's decoder will parse and
|
|
163
|
-
* answer, writing the guard's reply to every other line on `stdout`: one
|
|
164
|
-
* write per chunk, replies in stdin order. Everything else is the ambient
|
|
165
|
-
* `Stdio`.
|
|
166
|
-
*
|
|
167
|
-
* @remarks
|
|
168
|
-
* One guard per call, shared by every subscription to the returned `stdin`,
|
|
169
|
-
* because core re-subscribes after a failure and builds its own decoder once.
|
|
170
|
-
*
|
|
171
|
-
* @internal
|
|
172
|
-
*/
|
|
173
|
-
const guardStdin = (stdio) => {
|
|
174
|
-
const step = makeFrameGuard();
|
|
175
|
-
return Stdio.make({
|
|
176
|
-
args: stdio.args,
|
|
177
|
-
stdinIsTerminal: stdio.stdinIsTerminal,
|
|
178
|
-
stdoutIsTerminal: stdio.stdoutIsTerminal,
|
|
179
|
-
stdout: (options) => stdio.stdout(options),
|
|
180
|
-
stderr: (options) => stdio.stderr(options),
|
|
181
|
-
stdin: stdio.stdin.pipe(Stream.mapEffect((chunk) => {
|
|
182
|
-
const { forward, replies } = step(chunk);
|
|
183
|
-
return replies.length === 0 ? Effect.succeed(forward) : Stream.run(Stream.make(replies.join("")), stdio.stdout()).pipe(Effect.as(forward));
|
|
184
|
-
}), Stream.filter((chunk) => chunk !== void 0))
|
|
185
|
-
});
|
|
186
|
-
};
|
|
187
|
-
/**
|
|
188
|
-
* A layer replacing the ambient `Stdio` with {@link guardStdin}'s, for one
|
|
189
|
-
* server layer only.
|
|
190
|
-
*
|
|
191
|
-
* @remarks
|
|
192
|
-
* A function, never a module constant: layers memoize by reference, so one
|
|
193
|
-
* shared constant is built once per graph, and a second server in that graph
|
|
194
|
-
* would read the first server's guarded stdin. Each call mints a fresh layer;
|
|
195
|
-
* `McpStdio.layer` calls it once per server.
|
|
196
|
-
*
|
|
197
|
-
* @internal
|
|
198
|
-
*/
|
|
199
|
-
const makeGuardedStdio = () => Layer.effect(Stdio.Stdio, Effect.map(Stdio.Stdio, guardStdin));
|
|
200
|
-
|
|
201
|
-
export { INVALID_REQUEST_FRAME, MAX_FRAME_LENGTH, PARSE_ERROR_FRAME, guardStdin, makeFrameGuard, makeGuardedStdio };
|
package/internal/guardLaunch.js
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { McpStdio } from "../McpStdio.js";
|
|
2
|
-
import { Effect } from "effect";
|
|
3
|
-
|
|
4
|
-
// 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
|
-
export { launchGuarded };
|
package/internal/wire.js
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { Result } from "effect";
|
|
2
|
-
|
|
3
|
-
// src/internal/wire.ts
|
|
4
|
-
/** @internal */
|
|
5
|
-
const DEFAULT_CLIENT_INFO = {
|
|
6
|
-
name: "effected-mcp-testing",
|
|
7
|
-
version: "0.0.0"
|
|
8
|
-
};
|
|
9
|
-
const isRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u);
|
|
10
|
-
/**
|
|
11
|
-
* The pointer `McpProcess` appends to a `StreamEnded` message. `McpProbe`
|
|
12
|
-
* strips it: the probe folds stderr into its own message, so the pointer is
|
|
13
|
-
* stale there.
|
|
14
|
-
*
|
|
15
|
-
* @internal
|
|
16
|
-
*/
|
|
17
|
-
const STDERR_HINT = "; read stderrFinal for why";
|
|
18
|
-
/** @internal */
|
|
19
|
-
const parseFrame = (line) => Result.getOrUndefined(Result.try(() => JSON.parse(line)));
|
|
20
|
-
/** @internal */
|
|
21
|
-
const isJsonRpcMessage = (u) => isRecord(u) && u.jsonrpc === "2.0";
|
|
22
|
-
/** @internal */
|
|
23
|
-
const isResponse = (message) => (typeof message.id === "string" || typeof message.id === "number") && message.method === void 0;
|
|
24
|
-
/** @internal */
|
|
25
|
-
const requestKey = (id) => `${typeof id}:${id}`;
|
|
26
|
-
/** @internal */
|
|
27
|
-
const isStateless = (protocol) => protocol.runtime._tag === "Stateless";
|
|
28
|
-
/**
|
|
29
|
-
* A request (with `id`) or notification frame. On the stateless revision every
|
|
30
|
-
* frame carries the protocol fields under `params._meta`, with any `_meta`
|
|
31
|
-
* the caller passed spread over them, so the caller wins. That matches core's
|
|
32
|
-
* own harness (`McpStdioHarness.ts`, `withRequestMetadata`) and lets a test
|
|
33
|
-
* send a deliberately wrong revision.
|
|
34
|
-
*
|
|
35
|
-
* @internal
|
|
36
|
-
*/
|
|
37
|
-
const frame = (protocol, clientInfo, method, params, id) => {
|
|
38
|
-
const withMeta = () => {
|
|
39
|
-
const base = isRecord(params) ? params : {};
|
|
40
|
-
const caller = isRecord(base._meta) ? base._meta : {};
|
|
41
|
-
return {
|
|
42
|
-
...base,
|
|
43
|
-
_meta: {
|
|
44
|
-
"io.modelcontextprotocol/protocolVersion": protocol.protocolVersion,
|
|
45
|
-
"io.modelcontextprotocol/clientCapabilities": {},
|
|
46
|
-
"io.modelcontextprotocol/clientInfo": clientInfo,
|
|
47
|
-
...caller
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
};
|
|
51
|
-
return {
|
|
52
|
-
jsonrpc: "2.0",
|
|
53
|
-
...id === void 0 ? {} : { id },
|
|
54
|
-
method,
|
|
55
|
-
...isStateless(protocol) ? { params: withMeta() } : params === void 0 ? {} : { params }
|
|
56
|
-
};
|
|
57
|
-
};
|
|
58
|
-
/** @internal */
|
|
59
|
-
const initializeParams = (protocol, clientInfo) => ({
|
|
60
|
-
protocolVersion: protocol.protocolVersion,
|
|
61
|
-
capabilities: {},
|
|
62
|
-
clientInfo
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
export { DEFAULT_CLIENT_INFO, STDERR_HINT, frame, initializeParams, isJsonRpcMessage, isResponse, isStateless, parseFrame, requestKey };
|