@effected/mcp-kit 0.0.0-stage → 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/LICENSE +21 -0
- package/McpGuard.js +81 -0
- package/McpHarness.js +357 -0
- package/McpProbe.js +94 -0
- package/McpProcess.js +196 -0
- package/McpStdio.js +175 -0
- package/McpTestFailure.js +49 -0
- package/McpToolAudit.js +109 -0
- package/McpToolkit.js +298 -0
- package/README.md +574 -2
- package/StdioCapture.js +146 -0
- package/ToolFailure.js +75 -0
- package/ToolInputSchema.js +180 -0
- package/ToolOutcome.js +119 -0
- package/ToolOutputSchema.js +65 -0
- package/ToolRefusal.js +57 -0
- package/ToolReplay.js +136 -0
- package/guard.d.ts +189 -0
- package/guard.js +3 -0
- package/index.d.ts +1048 -0
- package/index.js +10 -0
- package/internal/LaunchFailed.js +34 -0
- package/internal/StdinFrames.js +201 -0
- package/internal/guardLaunch.js +13 -0
- package/internal/wire.js +65 -0
- package/node.d.ts +126 -0
- package/node.js +3 -0
- package/package.json +60 -5
- package/testing.d.ts +519 -0
- package/testing.js +7 -0
- package/tsdoc-metadata.json +11 -0
package/ToolReplay.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
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-kit"
|
|
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
|
+
* @remarks
|
|
93
|
+
* Call it where the handler is built, once: the effect form of
|
|
94
|
+
* `Toolkit.toLayer`, reading the store's services there and passing them as
|
|
95
|
+
* `store`. Calling it per request, inside the handler, rebuilds the JSON
|
|
96
|
+
* codec on every call and turns the wiring check's throw into a defect at
|
|
97
|
+
* the first call instead of a failure when the server is built.
|
|
98
|
+
*/
|
|
99
|
+
static wrap = (options) => {
|
|
100
|
+
if (options.tool.includes(":")) throw new Error(`ToolReplay.wrap: tool name "${options.tool}" contains ":", the stored-key separator.`);
|
|
101
|
+
const { tool, success } = options;
|
|
102
|
+
const given = options.store;
|
|
103
|
+
const json = Schema.fromJsonString(Schema.toCodecJson(success));
|
|
104
|
+
const decode = Schema.decodeEffect(json);
|
|
105
|
+
const encode = Schema.encodeEffect(json);
|
|
106
|
+
return (handler) => (params) => {
|
|
107
|
+
const found = options.key(params);
|
|
108
|
+
if (Option.isNone(found)) return handler(params);
|
|
109
|
+
const storeKey = ToolReplay.storeKey(tool, found.value);
|
|
110
|
+
return Effect.gen(function* () {
|
|
111
|
+
const store = given ?? (yield* KeyValueStore.KeyValueStore);
|
|
112
|
+
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", {
|
|
113
|
+
tool,
|
|
114
|
+
cause
|
|
115
|
+
})), Effect.orElseSucceed(() => Option.none()));
|
|
116
|
+
if (Option.isSome(cached)) {
|
|
117
|
+
yield* Effect.annotateCurrentSpan("mcp.tool.replayed", true);
|
|
118
|
+
return withMarker(cached.value);
|
|
119
|
+
}
|
|
120
|
+
yield* Effect.annotateCurrentSpan("mcp.tool.replayed", false);
|
|
121
|
+
const result = yield* handler(params);
|
|
122
|
+
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", {
|
|
123
|
+
tool,
|
|
124
|
+
cause
|
|
125
|
+
})), Effect.ignore);
|
|
126
|
+
return result;
|
|
127
|
+
}).pipe(Effect.withSpan("ToolReplay.wrap", { attributes: { "mcp.tool.name": tool } }));
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
const withMarker = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? {
|
|
132
|
+
...value,
|
|
133
|
+
_idempotentReplay: true
|
|
134
|
+
} : value;
|
|
135
|
+
|
|
136
|
+
export { ToolReplay };
|
package/guard.d.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
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-kit/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/kit/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-kit/guard`.
|
|
126
|
+
*
|
|
127
|
+
* @remarks
|
|
128
|
+
* {@link McpGuard.run} is `ProcessGuard.run` from `@effected/kit/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/kit/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-kit/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/kit/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
ADDED