@effected/mcp 0.12.0 → 0.13.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 +27 -0
- package/main.js +37 -0
- package/package.json +15 -21
- package/server.js +39 -0
- package/tools/identity.js +28 -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/ToolOutcome.js
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { ToolFailure } from "./ToolFailure.js";
|
|
2
|
-
import { ToolOutputSchema } from "./ToolOutputSchema.js";
|
|
3
|
-
import { Effect, Schema } from "effect";
|
|
4
|
-
|
|
5
|
-
// src/ToolOutcome.ts
|
|
6
|
-
/**
|
|
7
|
-
* The `ok: false` structured-outcome envelope: an expected error an agent
|
|
8
|
-
* should branch on, returned on a tool's success channel so it reaches the
|
|
9
|
-
* client as `structuredContent` instead of message text.
|
|
10
|
-
*
|
|
11
|
-
* @remarks
|
|
12
|
-
* Two channels, two jobs, one error shape:
|
|
13
|
-
*
|
|
14
|
-
* - {@link ToolRefusal} is a declared failure: the call itself was wrong, and
|
|
15
|
-
* the agent reads one sentence (`isError: true`, message text only; core
|
|
16
|
-
* never sends a failure's `structuredContent`).
|
|
17
|
-
* - `ToolOutcome` is an expected domain outcome: an id that does not exist, a
|
|
18
|
-
* transition that is not allowed. The agent branches on its `_tag` and
|
|
19
|
-
* fields (`isError: false`, `structuredContent` carries the envelope).
|
|
20
|
-
*
|
|
21
|
-
* Both carry {@link ToolFailure.fields} (`message`, `remediation`), so an error
|
|
22
|
-
* can move between channels without reshaping.
|
|
23
|
-
*
|
|
24
|
-
* Success members are not reshaped. Spread {@link ToolOutcome.ok} into each
|
|
25
|
-
* success struct so a client can discriminate on `ok`; a member that
|
|
26
|
-
* discriminates another way (`found: true | false`) stays legal.
|
|
27
|
-
*
|
|
28
|
-
* @example
|
|
29
|
-
* ```ts
|
|
30
|
-
* import { ToolOutcome } from "@effected/mcp"
|
|
31
|
-
* import { Effect, Schema } from "effect"
|
|
32
|
-
* import { Tool } from "effect/ai"
|
|
33
|
-
*
|
|
34
|
-
* const NotFound = ToolOutcome.error("GoalNotFound", { id: Schema.Number })
|
|
35
|
-
* const Lookup = Tool.make("goal", {
|
|
36
|
-
* parameters: Schema.Struct({ id: Schema.Number }),
|
|
37
|
-
* success: ToolOutcome.envelope(Schema.Struct({ ...ToolOutcome.ok, goal: Goal }), [NotFound]),
|
|
38
|
-
* })
|
|
39
|
-
* // in the handler:
|
|
40
|
-
* findGoal(id).pipe(
|
|
41
|
-
* Effect.map((goal) => ({ ok: true as const, goal })),
|
|
42
|
-
* ToolOutcome.catchAsEnvelope({
|
|
43
|
-
* GoalNotFound: (e) => ({ id: e.id, message: `No goal ${e.id}.`, remediation: { hint: "List goals first.", suggestedTool: "goal" } }),
|
|
44
|
-
* }),
|
|
45
|
-
* )
|
|
46
|
-
* ```
|
|
47
|
-
*
|
|
48
|
-
* @public
|
|
49
|
-
*/
|
|
50
|
-
var ToolOutcome = class ToolOutcome {
|
|
51
|
-
constructor() {}
|
|
52
|
-
/** `{ ok: true }` as schema fields, to spread into each success member. */
|
|
53
|
-
static ok = { ok: Schema.Literal(true).annotate({ description: "true: the call succeeded." }) };
|
|
54
|
-
/**
|
|
55
|
-
* The schema of one envelope error: `_tag`, {@link ToolFailure.fields} and
|
|
56
|
-
* `fields`. A field named `_tag`, `message` or `remediation` is rejected by
|
|
57
|
-
* the type, because it would replace the shared one.
|
|
58
|
-
*/
|
|
59
|
-
static error = (tag, fields) => Schema.Struct({
|
|
60
|
-
_tag: Schema.Literal(tag).annotate({ description: "The error's tag: the value to branch on." }),
|
|
61
|
-
...ToolFailure.fields,
|
|
62
|
-
...fields
|
|
63
|
-
});
|
|
64
|
-
/**
|
|
65
|
-
* A tool's success schema: `success`, or `{ ok: false, error }` where `error`
|
|
66
|
-
* is one of `errors`. Object-rooted with {@link ToolOutputSchema.objectRooted},
|
|
67
|
-
* so core serves its `outputSchema` on every protocol revision.
|
|
68
|
-
*
|
|
69
|
-
* @remarks
|
|
70
|
-
* The failure member, its `ok` and its `error` carry default descriptions.
|
|
71
|
-
* `options` names the member (`identifier`, served once under `$defs`) or
|
|
72
|
-
* replaces its description.
|
|
73
|
-
*/
|
|
74
|
-
static envelope = (success, errors, options) => ToolOutputSchema.objectRooted(Schema.Union([success, Schema.Struct({
|
|
75
|
-
ok: Schema.Literal(false).annotate({ description: "false: the call hit an expected error, described in error." }),
|
|
76
|
-
error: Schema.Union(errors).annotate({ description: "The expected error. Branch on its _tag." })
|
|
77
|
-
}).annotate({
|
|
78
|
-
description: options?.description ?? "An expected error the caller should branch on, returned in place of a result.",
|
|
79
|
-
...options?.identifier === void 0 ? {} : { identifier: options.identifier }
|
|
80
|
-
})]));
|
|
81
|
-
/** One `ok: false` value, for a handler that decides on a failure without catching an error. */
|
|
82
|
-
static failure = (tag, error) => ({
|
|
83
|
-
ok: false,
|
|
84
|
-
error: {
|
|
85
|
-
...error,
|
|
86
|
-
_tag: tag
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
/**
|
|
90
|
-
* Turns each handled tagged error into its `ok: false` value on the success
|
|
91
|
-
* channel; every other error stays in the error channel. Typed like core's
|
|
92
|
-
* `Effect.catchTags`: a case for a tag the effect cannot fail with is a type
|
|
93
|
-
* error, and the handled tags leave `E`.
|
|
94
|
-
*
|
|
95
|
-
* @remarks
|
|
96
|
-
* Write it inline in the effect's `pipe`. `E` is inferred from the effect
|
|
97
|
-
* being piped, so `effect.pipe(ToolOutcome.catchAsEnvelope({ … }))` checks
|
|
98
|
-
* every case name against the effect's real tags. Bound to a `const` first,
|
|
99
|
-
* the combinator never sees that effect: `E` infers as `unknown`, the
|
|
100
|
-
* unknown-tag check switches off, and a misspelled tag compiles and never
|
|
101
|
-
* matches. Core's `catchTags` looks a case up only among `cases`' own keys,
|
|
102
|
-
* so an error tagged `"toString"` or `"constructor"` never hits an inherited
|
|
103
|
-
* `Object.prototype` member and stays in the error channel.
|
|
104
|
-
*/
|
|
105
|
-
static catchAsEnvelope = (cases) => (self) => {
|
|
106
|
-
const handlers = {};
|
|
107
|
-
for (const [tag, handle] of Object.entries(cases)) handlers[tag] = (error) => Effect.succeed(ToolOutcome.failure(tag, handle(error)));
|
|
108
|
-
return Effect.catchTags(self, handlers);
|
|
109
|
-
};
|
|
110
|
-
/**
|
|
111
|
-
* Whether `value` is an `ok: false` envelope member: an object whose `ok` is
|
|
112
|
-
* exactly `false`. A success member that discriminates some other way, or a
|
|
113
|
-
* non-object, is not a failure. Narrows to `{ ok: false }` only: it checks
|
|
114
|
-
* the discriminant, not the `error` payload.
|
|
115
|
-
*/
|
|
116
|
-
static isFailure = (value) => typeof value === "object" && value !== null && value.ok === false;
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
export { ToolOutcome };
|
package/ToolOutputSchema.js
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { Schema, SchemaAST } from "effect";
|
|
2
|
-
|
|
3
|
-
// src/ToolOutputSchema.ts
|
|
4
|
-
const MARK = "~effected/mcp/objectRooted";
|
|
5
|
-
/** Always passes; contributes `type: "object"` beside whatever the node already emits. */
|
|
6
|
-
const objectRoot = Schema.makeFilter(() => true, {
|
|
7
|
-
[MARK]: true,
|
|
8
|
-
toJsonSchema: () => ({ type: "object" })
|
|
9
|
-
});
|
|
10
|
-
const isObjectRooted = (ast) => ast.checks?.some((check) => check.annotations?.[MARK] === true) === true;
|
|
11
|
-
/**
|
|
12
|
-
* Shaping for a tool's `success` schema, so the `outputSchema` core serves
|
|
13
|
-
* is one every client accepts.
|
|
14
|
-
*
|
|
15
|
-
* @remarks
|
|
16
|
-
* MCP requires a tool's `outputSchema` to be rooted in an object, and a
|
|
17
|
-
* top-level `Schema.Union` emits a bare `anyOf`. Core drops such an
|
|
18
|
-
* `outputSchema` from `tools/list` on the stateful revisions and serves it
|
|
19
|
-
* verbatim on the stateless one, where strict clients reject it.
|
|
20
|
-
* `McpToolAudit`'s `objectRootedOutput` check reports it either way.
|
|
21
|
-
*
|
|
22
|
-
* {@link ToolOutputSchema.objectRooted} is the fix when every member of the
|
|
23
|
-
* union is an object shape; never apply it to a union with a primitive or
|
|
24
|
-
* array member. It works on the Effect
|
|
25
|
-
* schema, not on JSON Schema — unlike `ToolInputSchema.objectRooted`, which
|
|
26
|
-
* rewrites the served input document of a `Tool.dynamic`.
|
|
27
|
-
*
|
|
28
|
-
* @public
|
|
29
|
-
*/
|
|
30
|
-
var ToolOutputSchema = class {
|
|
31
|
-
constructor() {}
|
|
32
|
-
/**
|
|
33
|
-
* `schema` with `type: "object"` added beside the `anyOf` at its JSON
|
|
34
|
-
* Schema root, through a check that always passes. Decoding, encoding and
|
|
35
|
-
* the schema's type are unchanged.
|
|
36
|
-
*
|
|
37
|
-
* @remarks
|
|
38
|
-
* Order against `.annotate({ identifier })` does not matter. A check added
|
|
39
|
-
* after an identifier starts a new node that would drop the definition
|
|
40
|
-
* name, so the identifier the schema already resolves is carried onto the
|
|
41
|
-
* new check, and the served documents are equal either way (key order
|
|
42
|
-
* can differ when the schema carries a title or description). Applying it
|
|
43
|
-
* twice is a no-op.
|
|
44
|
-
*
|
|
45
|
-
* Every union member must be an object shape: the added `type: "object"`
|
|
46
|
-
* is a claim about the whole union, and nothing checks it. Do not use it
|
|
47
|
-
* on a union with a primitive or array member — the served schema would
|
|
48
|
-
* contradict the values the tool returns; give such a tool an object
|
|
49
|
-
* envelope instead.
|
|
50
|
-
*
|
|
51
|
-
* @example
|
|
52
|
-
* ```ts
|
|
53
|
-
* const Result = ToolOutputSchema.objectRooted(Schema.Union([Found, Missing])).annotate({ identifier: "Result" })
|
|
54
|
-
* Tool.make("lookup", { parameters, success: Result })
|
|
55
|
-
* ```
|
|
56
|
-
*/
|
|
57
|
-
static objectRooted = (schema) => {
|
|
58
|
-
if (isObjectRooted(schema.ast)) return schema;
|
|
59
|
-
const identifier = SchemaAST.resolveIdentifier(schema.ast);
|
|
60
|
-
const checked = schema.check(objectRoot);
|
|
61
|
-
return identifier === void 0 ? checked : checked.annotate({ identifier });
|
|
62
|
-
};
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
export { ToolOutputSchema };
|
package/ToolRefusal.js
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { ToolFailure } from "./ToolFailure.js";
|
|
2
|
-
import { Schema } from "effect";
|
|
3
|
-
|
|
4
|
-
// src/ToolRefusal.ts
|
|
5
|
-
/**
|
|
6
|
-
* A tool call refused for a reason the caller can fix: the kit's ready-made
|
|
7
|
-
* declared failure, built on {@link ToolFailure.fields}.
|
|
8
|
-
*
|
|
9
|
-
* @remarks
|
|
10
|
-
* Core scrubs every undeclared failure and every defect to one generic
|
|
11
|
-
* internal-error sentence, so a refusal the agent must act on (an unknown id,
|
|
12
|
-
* a missing argument, a precondition) reaches it only when the tool declares
|
|
13
|
-
* it. Declare `ToolRefusal` in the tool's `failure` schema and fail with
|
|
14
|
-
* {@link ToolRefusal.refuse}; its message already carries the remediation,
|
|
15
|
-
* because a declared `Error` failure sends `message` and nothing else.
|
|
16
|
-
*
|
|
17
|
-
* Audit every throw path of a tool: any failure left undeclared loses its
|
|
18
|
-
* message on the wire, however carefully it was worded.
|
|
19
|
-
*
|
|
20
|
-
* **Structured data on a refusal is deliberately unreachable: put it in the
|
|
21
|
-
* message.** Core's `McpServer.registerToolkit` builds every failed call's
|
|
22
|
-
* result with `structuredContent` unset and no `_meta`, on every protocol
|
|
23
|
-
* revision. Under the default `failureMode: "error"` a declared `Error`
|
|
24
|
-
* failure sends `message` as the only text; under `failureMode: "return"`
|
|
25
|
-
* it sends the whole encoded failure JSON-stringified into the text, still
|
|
26
|
-
* with no `structuredContent`, which buries the human message in JSON a
|
|
27
|
-
* client must parse. So `refuse` takes no data argument: a list of valid
|
|
28
|
-
* names, the root that was searched, the id that was not found all belong
|
|
29
|
-
* in `reason`, truncated with {@link ToolFailure.truncate}. When an agent
|
|
30
|
-
* must branch on the data itself, return it on the success channel as an
|
|
31
|
-
* `ok: false` envelope, where `structuredContent` does reach the client.
|
|
32
|
-
*
|
|
33
|
-
* @example
|
|
34
|
-
* ```ts
|
|
35
|
-
* import { ToolFailure, ToolRefusal } from "@effected/mcp";
|
|
36
|
-
* import { Tool } from "effect/ai";
|
|
37
|
-
*
|
|
38
|
-
* const Lookup = Tool.make("lookup", { parameters, success, failure: ToolRefusal })
|
|
39
|
-
* // in the handler:
|
|
40
|
-
* return yield* ToolRefusal.refuse(`No run "${ToolFailure.truncate(id)}".`, { hint: "List runs first.", suggestedTool: "list_runs" })
|
|
41
|
-
* ```
|
|
42
|
-
*
|
|
43
|
-
* @public
|
|
44
|
-
*/
|
|
45
|
-
var ToolRefusal = class ToolRefusal extends Schema.TaggedError()("ToolRefusal", ToolFailure.fields) {
|
|
46
|
-
/**
|
|
47
|
-
* A refusal for `reason`, with `remediation` folded into its message by
|
|
48
|
-
* {@link ToolFailure.message}. `reason` must already truncate any
|
|
49
|
-
* caller-supplied value it echoes, with {@link ToolFailure.truncate}.
|
|
50
|
-
*/
|
|
51
|
-
static refuse = (reason, remediation) => new ToolRefusal({
|
|
52
|
-
message: ToolFailure.message(reason, remediation),
|
|
53
|
-
remediation
|
|
54
|
-
});
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
export { ToolRefusal };
|
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 };
|