@effected/mcp 0.8.0 → 0.10.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/McpGuard.js +1 -2
- package/McpHarness.js +1 -2
- package/McpProbe.js +1 -2
- package/McpProcess.js +1 -2
- package/McpStdio.js +1 -2
- package/McpTestFailure.js +1 -2
- package/McpToolAudit.js +1 -2
- package/McpToolkit.js +1 -2
- package/StdioCapture.js +146 -0
- package/ToolFailure.js +8 -5
- package/ToolInputSchema.js +1 -2
- package/ToolOutcome.js +119 -0
- package/ToolOutputSchema.js +1 -2
- package/ToolRefusal.js +1 -2
- package/ToolReplay.js +122 -0
- package/guard.d.ts +17 -8
- package/index.d.ts +310 -15
- package/index.js +3 -1
- package/internal/LaunchFailed.js +1 -2
- package/internal/StdinFrames.js +1 -2
- package/internal/guardLaunch.js +1 -2
- package/internal/wire.js +1 -2
- package/node.d.ts +126 -0
- package/node.js +3 -0
- package/package.json +7 -2
- package/testing.d.ts +0 -12
- package/tsdoc-metadata.json +1 -1
package/McpGuard.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ProcessGuard } from "@effected/engine/guard";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// src/McpGuard.ts
|
|
4
4
|
/**
|
|
5
5
|
* Crash guards for an MCP server process, installed before the server's
|
|
6
6
|
* module graph is loaded. Imported from `@effected/mcp/guard`.
|
|
@@ -78,5 +78,4 @@ var McpGuard = class {
|
|
|
78
78
|
});
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
-
//#endregion
|
|
82
81
|
export { McpGuard };
|
package/McpHarness.js
CHANGED
|
@@ -4,7 +4,7 @@ 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
6
|
|
|
7
|
-
|
|
7
|
+
// src/McpHarness.ts
|
|
8
8
|
/**
|
|
9
9
|
* An in-process MCP client for a real server layer, over queue-backed stdio.
|
|
10
10
|
*
|
|
@@ -297,5 +297,4 @@ var McpHarness = class McpHarness {
|
|
|
297
297
|
});
|
|
298
298
|
};
|
|
299
299
|
|
|
300
|
-
//#endregion
|
|
301
300
|
export { McpHarness };
|
package/McpProbe.js
CHANGED
|
@@ -5,7 +5,7 @@ import { McpProcess } from "./McpProcess.js";
|
|
|
5
5
|
import { Effect } from "effect";
|
|
6
6
|
import { McpProtocol } from "effect/ai";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// src/McpProbe.ts
|
|
9
9
|
/**
|
|
10
10
|
* The smallest proof that an installed MCP bin boots: one initialize, a clean
|
|
11
11
|
* close, and exit 0.
|
|
@@ -91,5 +91,4 @@ var McpProbe = class {
|
|
|
91
91
|
}));
|
|
92
92
|
};
|
|
93
93
|
|
|
94
|
-
//#endregion
|
|
95
94
|
export { McpProbe };
|
package/McpProcess.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Deferred, Duration, Effect, Option, Queue, Ref, Stream, SubscriptionRef
|
|
|
5
5
|
import { McpProtocol } from "effect/ai";
|
|
6
6
|
import { ChildProcessSpawner } from "effect/process";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// src/McpProcess.ts
|
|
9
9
|
/**
|
|
10
10
|
* A spawned MCP server bin a test writes to while it runs.
|
|
11
11
|
*
|
|
@@ -193,5 +193,4 @@ var McpProcess = class McpProcess {
|
|
|
193
193
|
});
|
|
194
194
|
};
|
|
195
195
|
|
|
196
|
-
//#endregion
|
|
197
196
|
export { McpProcess };
|
package/McpStdio.js
CHANGED
|
@@ -3,7 +3,7 @@ import { makeGuardedStdio } from "./internal/StdinFrames.js";
|
|
|
3
3
|
import { Cause, Effect, Exit, Layer, References, Runtime } from "effect";
|
|
4
4
|
import { McpProtocol, McpServer } from "effect/ai";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// src/McpStdio.ts
|
|
7
7
|
/**
|
|
8
8
|
* Serve an MCP server over stdio without ever writing a log line or a failure
|
|
9
9
|
* report onto stdout, which is the JSON-RPC wire.
|
|
@@ -164,5 +164,4 @@ var McpStdio = class McpStdio {
|
|
|
164
164
|
static teardown = (exit, onExit) => Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) ? onExit(0) : Runtime.defaultTeardown(exit, onExit);
|
|
165
165
|
};
|
|
166
166
|
|
|
167
|
-
//#endregion
|
|
168
167
|
export { McpStdio };
|
package/McpTestFailure.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// src/McpTestFailure.ts
|
|
4
4
|
/**
|
|
5
5
|
* Why an MCP test client could not produce what was asked of it.
|
|
6
6
|
*
|
|
@@ -46,5 +46,4 @@ var McpTestFailure = class extends Schema.TaggedError()("McpTestFailure", {
|
|
|
46
46
|
message: Schema.String
|
|
47
47
|
}) {};
|
|
48
48
|
|
|
49
|
-
//#endregion
|
|
50
49
|
export { McpTestFailure };
|
package/McpToolAudit.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// src/McpToolAudit.ts
|
|
2
2
|
const isNode = (u) => typeof u === "object" && u !== null && !Array.isArray(u);
|
|
3
3
|
const HINTS = [
|
|
4
4
|
"readOnlyHint",
|
|
@@ -106,5 +106,4 @@ var McpToolAudit = class {
|
|
|
106
106
|
};
|
|
107
107
|
};
|
|
108
108
|
|
|
109
|
-
//#endregion
|
|
110
109
|
export { McpToolAudit };
|
package/McpToolkit.js
CHANGED
|
@@ -3,7 +3,7 @@ import { ToolInputSchema } from "./ToolInputSchema.js";
|
|
|
3
3
|
import { Context, Effect, Layer, Schema } from "effect";
|
|
4
4
|
import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// src/McpToolkit.ts
|
|
7
7
|
/** The union a {@link McpToolkit.unionTool} decodes with; read by the `McpToolkit.layer` decorator. */
|
|
8
8
|
const UnionParameters = Context.Reference("@effected/mcp/UnionParameters", { defaultValue: () => void 0 });
|
|
9
9
|
/**
|
|
@@ -243,5 +243,4 @@ var McpToolkit = class {
|
|
|
243
243
|
})).pipe(Layer.provide(McpServer.McpServer.layer));
|
|
244
244
|
};
|
|
245
245
|
|
|
246
|
-
//#endregion
|
|
247
246
|
export { McpToolkit, guardRegistration };
|
package/StdioCapture.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Effect, Fiber } from "effect";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { Writable } from "node:stream";
|
|
4
|
+
|
|
5
|
+
// src/StdioCapture.ts
|
|
6
|
+
/**
|
|
7
|
+
* One registry per process, shared by every installed copy of this package
|
|
8
|
+
* through a `Symbol.for` key on `globalThis`, so two copies never stack
|
|
9
|
+
* patches or split the context they route on.
|
|
10
|
+
*/
|
|
11
|
+
const registry = (() => {
|
|
12
|
+
const key = Symbol.for("@effected/mcp/StdioCapture");
|
|
13
|
+
const global = globalThis;
|
|
14
|
+
global[key] ??= {
|
|
15
|
+
storage: new AsyncLocalStorage(),
|
|
16
|
+
originals: /* @__PURE__ */ new WeakMap()
|
|
17
|
+
};
|
|
18
|
+
return global[key];
|
|
19
|
+
})();
|
|
20
|
+
/** Patches `stream` once and returns its original `write`; a stream already patched keeps its first patch. */
|
|
21
|
+
const patch = (stream, pick) => {
|
|
22
|
+
const known = registry.originals.get(stream);
|
|
23
|
+
if (known !== void 0) return known;
|
|
24
|
+
const original = stream.write;
|
|
25
|
+
registry.originals.set(stream, original);
|
|
26
|
+
const { storage } = registry;
|
|
27
|
+
stream.write = function capturedWrite() {
|
|
28
|
+
const options = storage.getStore();
|
|
29
|
+
const sink = options === void 0 ? void 0 : pick(options);
|
|
30
|
+
return sink === void 0 ? original.apply(this, arguments) : sink.write.apply(sink, arguments);
|
|
31
|
+
};
|
|
32
|
+
return original;
|
|
33
|
+
};
|
|
34
|
+
const callbackOf = (rest) => {
|
|
35
|
+
const last = rest.at(-1);
|
|
36
|
+
return typeof last === "function" ? last : void 0;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Diverts a host's stdout (and optionally stderr) writes to a sink, for code
|
|
40
|
+
* running inside one async context only, while every other context keeps
|
|
41
|
+
* writing to the real streams.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* For an MCP server over stdio, stdout is the JSON-RPC wire. A chatty library
|
|
45
|
+
* run in-process (a test runner, a bundler, a linter) must keep its output off
|
|
46
|
+
* it while other handlers, and the transport itself, keep writing normally.
|
|
47
|
+
*
|
|
48
|
+
* `install` patches the host's `write` methods once, keeping the unbound
|
|
49
|
+
* originals, and routes every write through an `AsyncLocalStorage`: inside a
|
|
50
|
+
* {@link StdioCapture.run} or {@link StdioCapture.capture} context a write goes
|
|
51
|
+
* to that context's sink, and outside it to the original stream.
|
|
52
|
+
*
|
|
53
|
+
* An Effect fiber resumes in the async context it suspended in, not the
|
|
54
|
+
* context of whatever woke it, so a server fiber woken by code inside a
|
|
55
|
+
* capture (its response written to a queue, say) keeps writing to the wire.
|
|
56
|
+
*
|
|
57
|
+
* Two kinds of write escape a capture and reach the original stream:
|
|
58
|
+
*
|
|
59
|
+
* - **Writes from a context the capture lost.** Callbacks registered outside the
|
|
60
|
+
* capture keep the context they were registered in: native addons, worker
|
|
61
|
+
* thread output relayed by the main thread, listeners on a child process's
|
|
62
|
+
* pipes attached earlier. On stdout, that is the wire.
|
|
63
|
+
* - **Fibers started inside the capture keep capturing after it ends.** A fiber
|
|
64
|
+
* forked inside it (`Effect.forkDetach`, a memoized layer first built inside
|
|
65
|
+
* it, a background fiber a library starts) keeps writing to the sink, even
|
|
66
|
+
* after the capture's own work is done.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* import { StdioCapture } from "@effected/mcp/node"
|
|
71
|
+
*
|
|
72
|
+
* const capture = StdioCapture.install(process)
|
|
73
|
+
* // in a tool handler: keep the runner's own console output off the wire
|
|
74
|
+
* const report = yield* runTests(files).pipe(capture.capture({ stdout: capture.toStderr }))
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* @public
|
|
78
|
+
*/
|
|
79
|
+
var StdioCapture = class StdioCapture {
|
|
80
|
+
/** A sink that writes through the host's original, unpatched stderr: captured output stays visible off the wire. */
|
|
81
|
+
toStderr;
|
|
82
|
+
constructor(toStderr) {
|
|
83
|
+
this.toStderr = toStderr;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Patches `host`'s two streams. Idempotent per stream, across every installed
|
|
87
|
+
* copy of this package: a stream already patched (through another host
|
|
88
|
+
* object, or another copy) keeps its one patch and its original `write`.
|
|
89
|
+
*/
|
|
90
|
+
static install = (host) => {
|
|
91
|
+
patch(host.stdout, (options) => options.stdout);
|
|
92
|
+
const stderr = patch(host.stderr, (options) => options.stderr);
|
|
93
|
+
return new StdioCapture({ write: (...args) => stderr.apply(host.stderr, args) });
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* A write-only sink that drops every write and calls the write's callback.
|
|
97
|
+
*
|
|
98
|
+
* @remarks
|
|
99
|
+
* It is a {@link StdioSink}, not a stream: it has no `on`, `pipe` target
|
|
100
|
+
* protocol or `setMaxListeners`, so it cannot stand in where a library
|
|
101
|
+
* expects a `Writable` (a test runner's logger stream that worker output
|
|
102
|
+
* is piped into, for one). Use {@link StdioCapture.nullWritable} there; it
|
|
103
|
+
* serves as a capture sink too.
|
|
104
|
+
*/
|
|
105
|
+
static discard = { write: (_chunk, ...rest) => {
|
|
106
|
+
const callback = callbackOf(rest);
|
|
107
|
+
if (callback !== void 0) queueMicrotask(callback);
|
|
108
|
+
return true;
|
|
109
|
+
} };
|
|
110
|
+
/**
|
|
111
|
+
* A new Node `Writable` that drops everything written to it: a real stream,
|
|
112
|
+
* so it works as a `pipe` target, takes `setMaxListeners`, emits `drain`
|
|
113
|
+
* and `finish`, and is also a {@link StdioSink} for a capture.
|
|
114
|
+
*
|
|
115
|
+
* @remarks
|
|
116
|
+
* A factory, not a shared value: a stream ends once, and a source piped in
|
|
117
|
+
* with the default `{ end: true }` ends its destination. Build one per
|
|
118
|
+
* consumer.
|
|
119
|
+
*/
|
|
120
|
+
static nullWritable = () => new Writable({
|
|
121
|
+
decodeStrings: false,
|
|
122
|
+
write: (_chunk, _encoding, callback) => callback()
|
|
123
|
+
});
|
|
124
|
+
/**
|
|
125
|
+
* Runs `evaluate` with its writes captured, for code that is not Effect.
|
|
126
|
+
* A synchronous throw from `evaluate` rejects the returned promise.
|
|
127
|
+
*/
|
|
128
|
+
run(options, evaluate) {
|
|
129
|
+
return registry.storage.run(options, async () => evaluate());
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Runs an effect with its writes captured: on its own fiber, started inside
|
|
133
|
+
* the capture's context, so the context survives every suspension of that
|
|
134
|
+
* fiber. Interrupting the returned effect interrupts that fiber and waits
|
|
135
|
+
* for it.
|
|
136
|
+
*/
|
|
137
|
+
capture(options) {
|
|
138
|
+
const { storage } = registry;
|
|
139
|
+
return (self) => Effect.uninterruptibleMask((restore) => Effect.withFiber((parent) => {
|
|
140
|
+
const fiber = storage.run(options, () => Effect.runForkWith(parent.context)(self));
|
|
141
|
+
return Effect.ensuring(restore(Fiber.join(fiber)), Fiber.interrupt(fiber));
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export { StdioCapture };
|
package/ToolFailure.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
import { Remediation } from "@effected/engine";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// src/ToolFailure.ts
|
|
5
5
|
const isHighSurrogate = (code) => code >= 55296 && code <= 56319;
|
|
6
6
|
/**
|
|
7
7
|
* How a tool's declared failure should read on the wire.
|
|
@@ -42,10 +42,14 @@ var ToolFailure = class ToolFailure {
|
|
|
42
42
|
static ECHO_LIMIT = 200;
|
|
43
43
|
/** The larger cap for a value the engine produced (a path, a diagnostic), not one a caller typed. */
|
|
44
44
|
static ENGINE_ECHO_LIMIT = 2e3;
|
|
45
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* The `message` and `remediation` fields to spread into a consumer's
|
|
47
|
+
* `Schema.TaggedError`, each carrying a default `description` that a served
|
|
48
|
+
* JSON Schema (a tool's `outputSchema`) shows the agent.
|
|
49
|
+
*/
|
|
46
50
|
static fields = {
|
|
47
|
-
message: Schema.String,
|
|
48
|
-
remediation: Remediation
|
|
51
|
+
message: Schema.String.annotate({ description: "What went wrong, as one plain sentence." }),
|
|
52
|
+
remediation: Remediation.annotate({ description: "What to do next: a hint, and optionally the tool to call next and the arguments to call it with." })
|
|
49
53
|
};
|
|
50
54
|
/**
|
|
51
55
|
* `"<raw> <hint> Try <suggestedTool>."`, dropping any empty part, so a
|
|
@@ -68,5 +72,4 @@ var ToolFailure = class ToolFailure {
|
|
|
68
72
|
};
|
|
69
73
|
};
|
|
70
74
|
|
|
71
|
-
//#endregion
|
|
72
75
|
export { ToolFailure };
|
package/ToolInputSchema.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ToolFailure } from "./ToolFailure.js";
|
|
2
2
|
import { Result } from "effect";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// src/ToolInputSchema.ts
|
|
5
5
|
const MAX_DEPTH = 256;
|
|
6
6
|
const MAX_ECHOED = 20;
|
|
7
7
|
const DISCRIMINANTS = [
|
|
@@ -177,5 +177,4 @@ var ToolInputSchema = class {
|
|
|
177
177
|
};
|
|
178
178
|
};
|
|
179
179
|
|
|
180
|
-
//#endregion
|
|
181
180
|
export { ToolInputSchema };
|
package/ToolOutcome.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Schema, SchemaAST } from "effect";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// src/ToolOutputSchema.ts
|
|
4
4
|
const MARK = "~effected/mcp/objectRooted";
|
|
5
5
|
/** Always passes; contributes `type: "object"` beside whatever the node already emits. */
|
|
6
6
|
const objectRoot = Schema.makeFilter(() => true, {
|
|
@@ -62,5 +62,4 @@ var ToolOutputSchema = class {
|
|
|
62
62
|
};
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
-
//#endregion
|
|
66
65
|
export { ToolOutputSchema };
|
package/ToolRefusal.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ToolFailure } from "./ToolFailure.js";
|
|
2
2
|
import { Schema } from "effect";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// src/ToolRefusal.ts
|
|
5
5
|
/**
|
|
6
6
|
* A tool call refused for a reason the caller can fix: the kit's ready-made
|
|
7
7
|
* declared failure, built on {@link ToolFailure.fields}.
|
|
@@ -54,5 +54,4 @@ var ToolRefusal = class ToolRefusal extends Schema.TaggedError()("ToolRefusal",
|
|
|
54
54
|
});
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
-
//#endregion
|
|
58
57
|
export { ToolRefusal };
|
package/ToolReplay.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
* Semantics, per call:
|
|
19
|
+
*
|
|
20
|
+
* - A key of `Option.none()` runs the handler; nothing is read or stored.
|
|
21
|
+
* - A stored value that decodes through `success` is returned with the marker,
|
|
22
|
+
* and the handler does not run.
|
|
23
|
+
* - A miss runs the handler and stores its result, best-effort. A store that
|
|
24
|
+
* fails, a row that no longer decodes (written under an older schema, or
|
|
25
|
+
* corrupt), and a failed write are each a miss or a skipped write, never a
|
|
26
|
+
* tool error. Each is logged at debug level with the tool name.
|
|
27
|
+
* - A handler failure stores nothing, so a retry runs it again.
|
|
28
|
+
* - An `ok: false` result (a {@link ToolOutcome} envelope failure) is never
|
|
29
|
+
* stored: a "not found" must not replay after the thing exists.
|
|
30
|
+
*
|
|
31
|
+
* This is replay, not exactly-once: two concurrent calls with one key both
|
|
32
|
+
* miss and both run.
|
|
33
|
+
*
|
|
34
|
+
* The key is the whole identity of a request. A hit compares neither the
|
|
35
|
+
* params nor the schema version (a row written under an older schema replays
|
|
36
|
+
* whenever it still decodes), and rows never expire: the store keeps a result
|
|
37
|
+
* until something removes it. A key reused with different params therefore
|
|
38
|
+
* replays the first result, so {@link ToolReplayOptions.key} must fold in a
|
|
39
|
+
* digest of any params that can vary under one id.
|
|
40
|
+
*
|
|
41
|
+
* Wrap it inside {@link ToolOutcome.catchAsEnvelope}, so the envelope sees the
|
|
42
|
+
* handler's real errors and replay sees only real successes.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* import { ToolReplay } from "@effected/mcp"
|
|
47
|
+
* import { Option, Schema } from "effect"
|
|
48
|
+
*
|
|
49
|
+
* const Created = ToolReplay.success(Schema.Struct({ ok: Schema.Literal(true), id: Schema.Number }))
|
|
50
|
+
* const create = ToolReplay.wrap({
|
|
51
|
+
* tool: "goal_create",
|
|
52
|
+
* success: Created,
|
|
53
|
+
* key: (params: { readonly requestId?: string }) => Option.fromUndefinedOr(params.requestId),
|
|
54
|
+
* })(createGoal)
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @public
|
|
58
|
+
*/
|
|
59
|
+
var ToolReplay = class ToolReplay {
|
|
60
|
+
constructor() {}
|
|
61
|
+
/**
|
|
62
|
+
* The marker field, to spread into each object member of a replayable
|
|
63
|
+
* success schema. {@link ToolReplay.success} adds it for you.
|
|
64
|
+
*/
|
|
65
|
+
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." })) };
|
|
66
|
+
/**
|
|
67
|
+
* A struct with {@link ToolReplay.marker} added. Use it for each object
|
|
68
|
+
* success member, so core's encoder keeps the marker on a replayed reply.
|
|
69
|
+
*
|
|
70
|
+
* @remarks
|
|
71
|
+
* Built with core's `fieldsAssign`, which, like `mapFields`, does not carry
|
|
72
|
+
* the struct's own `.check(...)` refinements over: apply checks to the
|
|
73
|
+
* result, not to the struct passed in.
|
|
74
|
+
*/
|
|
75
|
+
static success = (schema) => schema.pipe(Schema.fieldsAssign(ToolReplay.marker));
|
|
76
|
+
/**
|
|
77
|
+
* The `KeyValueStore` key one call is stored under: `<tool>:<key>`. Split on
|
|
78
|
+
* the first `:` to recover both parts; MCP tool names are `[A-Za-z0-9_.-]`.
|
|
79
|
+
*/
|
|
80
|
+
static storeKey = (tool, key) => `${tool}:${key}`;
|
|
81
|
+
/**
|
|
82
|
+
* Wraps a handler with replay. Throws at wrap time when `tool` contains
|
|
83
|
+
* `:`, a wiring mistake, so it surfaces when the server is built.
|
|
84
|
+
*/
|
|
85
|
+
static wrap = (options) => {
|
|
86
|
+
if (options.tool.includes(":")) throw new Error(`ToolReplay.wrap: tool name "${options.tool}" contains ":", the stored-key separator.`);
|
|
87
|
+
const { tool, success } = options;
|
|
88
|
+
const given = options.store;
|
|
89
|
+
const json = Schema.fromJsonString(Schema.toCodecJson(success));
|
|
90
|
+
const decode = Schema.decodeEffect(json);
|
|
91
|
+
const encode = Schema.encodeEffect(json);
|
|
92
|
+
return (handler) => (params) => {
|
|
93
|
+
const found = options.key(params);
|
|
94
|
+
if (Option.isNone(found)) return handler(params);
|
|
95
|
+
const storeKey = ToolReplay.storeKey(tool, found.value);
|
|
96
|
+
return Effect.gen(function* () {
|
|
97
|
+
const store = given ?? (yield* KeyValueStore.KeyValueStore);
|
|
98
|
+
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", {
|
|
99
|
+
tool,
|
|
100
|
+
cause
|
|
101
|
+
})), Effect.orElseSucceed(() => Option.none()));
|
|
102
|
+
if (Option.isSome(cached)) {
|
|
103
|
+
yield* Effect.annotateCurrentSpan("mcp.tool.replayed", true);
|
|
104
|
+
return withMarker(cached.value);
|
|
105
|
+
}
|
|
106
|
+
yield* Effect.annotateCurrentSpan("mcp.tool.replayed", false);
|
|
107
|
+
const result = yield* handler(params);
|
|
108
|
+
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", {
|
|
109
|
+
tool,
|
|
110
|
+
cause
|
|
111
|
+
})), Effect.ignore);
|
|
112
|
+
return result;
|
|
113
|
+
}).pipe(Effect.withSpan("ToolReplay.wrap", { attributes: { "mcp.tool.name": tool } }));
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
const withMarker = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? {
|
|
118
|
+
...value,
|
|
119
|
+
_idempotentReplay: true
|
|
120
|
+
} : value;
|
|
121
|
+
|
|
122
|
+
export { ToolReplay };
|
package/guard.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Effect, Layer, Runtime } from "effect";
|
|
2
|
-
//#region src/McpGuard.d.ts
|
|
3
2
|
/**
|
|
4
3
|
* The slice of the host process the guard uses. Node's `process` satisfies it.
|
|
5
4
|
*
|
|
@@ -105,10 +104,21 @@ interface McpGuardRunOptions<ROut, E> {
|
|
|
105
104
|
* an `at` or `kind` outside these values, does nothing at all. Wire it to
|
|
106
105
|
* an environment variable only a test sets.
|
|
107
106
|
*/
|
|
108
|
-
readonly injectCrash?:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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";
|
|
112
122
|
}
|
|
113
123
|
/**
|
|
114
124
|
* Crash guards for an MCP server process, installed before the server's
|
|
@@ -171,10 +181,9 @@ export declare class McpGuard {
|
|
|
171
181
|
* McpGuard.parseInjectCrash("load:uncaughtException"); // => { at: "load", kind: "uncaughtException" }
|
|
172
182
|
* ```
|
|
173
183
|
*/
|
|
174
|
-
static readonly parseInjectCrash: (value: string | undefined) =>
|
|
184
|
+
static readonly parseInjectCrash: (value: string | undefined) => InjectCrash | undefined;
|
|
175
185
|
/** Install the guards, load the server, and launch it. Resolves once the server is launched. */
|
|
176
186
|
static readonly run: <ROut, E>(options: McpGuardRunOptions<ROut, E>) => Promise<void>;
|
|
177
187
|
}
|
|
178
|
-
|
|
179
|
-
export type { McpGuardHost, McpGuardPolicy, McpGuardRunOptions, McpGuardedServer };
|
|
188
|
+
export type { InjectCrash, McpGuardHost, McpGuardPolicy, McpGuardRunOptions, McpGuardedServer };
|
|
180
189
|
//# sourceMappingURL=guard.d.ts.map
|
package/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Array, Context, Effect, JsonSchema, Layer, Runtime, Schema, Stdio } from "effect";
|
|
1
|
+
import { Array, Context, Effect, JsonSchema, Layer, Option, Runtime, Schema, Stdio, Struct } from "effect";
|
|
2
2
|
import { McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai";
|
|
3
3
|
import { Remediation } from "@effected/engine";
|
|
4
|
-
|
|
4
|
+
import { KeyValueStore } from "effect/persistence";
|
|
5
5
|
/**
|
|
6
6
|
* Options for {@link McpStdio.layer}.
|
|
7
7
|
*
|
|
@@ -188,8 +188,6 @@ export declare class McpStdio {
|
|
|
188
188
|
*/
|
|
189
189
|
static readonly teardown: Runtime.Teardown;
|
|
190
190
|
}
|
|
191
|
-
//#endregion
|
|
192
|
-
//#region src/ToolInputSchema.d.ts
|
|
193
191
|
/**
|
|
194
192
|
* The unknown keys found at one object level of a tool-call payload.
|
|
195
193
|
*
|
|
@@ -271,8 +269,6 @@ export declare class ToolInputSchema {
|
|
|
271
269
|
*/
|
|
272
270
|
static readonly objectRooted: (schema: JsonSchema.JsonSchema) => JsonSchema.JsonSchema;
|
|
273
271
|
}
|
|
274
|
-
//#endregion
|
|
275
|
-
//#region src/McpToolkit.d.ts
|
|
276
272
|
/**
|
|
277
273
|
* Options for {@link McpToolkit.layer}.
|
|
278
274
|
*
|
|
@@ -477,8 +473,6 @@ export declare class McpToolkit {
|
|
|
477
473
|
*/
|
|
478
474
|
static readonly layer: <Tools extends Record<string, Tool.Any>>(toolkit: Toolkit.Toolkit<Tools>, options?: McpToolkitOptions) => Layer.Layer<never, never, Tool.HandlersFor<Tools> | Exclude<Tool.HandlerServices<Tools>, McpSchema.McpRequestContext>>;
|
|
479
475
|
}
|
|
480
|
-
//#endregion
|
|
481
|
-
//#region src/ToolFailure.d.ts
|
|
482
476
|
/**
|
|
483
477
|
* How a tool's declared failure should read on the wire.
|
|
484
478
|
*
|
|
@@ -518,7 +512,11 @@ export declare class ToolFailure {
|
|
|
518
512
|
static readonly ECHO_LIMIT: number;
|
|
519
513
|
/** The larger cap for a value the engine produced (a path, a diagnostic), not one a caller typed. */
|
|
520
514
|
static readonly ENGINE_ECHO_LIMIT: number;
|
|
521
|
-
/**
|
|
515
|
+
/**
|
|
516
|
+
* The `message` and `remediation` fields to spread into a consumer's
|
|
517
|
+
* `Schema.TaggedError`, each carrying a default `description` that a served
|
|
518
|
+
* JSON Schema (a tool's `outputSchema`) shows the agent.
|
|
519
|
+
*/
|
|
522
520
|
static readonly fields: {
|
|
523
521
|
readonly message: Schema.String;
|
|
524
522
|
readonly remediation: Schema.Struct<{
|
|
@@ -539,8 +537,167 @@ export declare class ToolFailure {
|
|
|
539
537
|
*/
|
|
540
538
|
static readonly truncate: (value: string, limit?: number) => string;
|
|
541
539
|
}
|
|
542
|
-
|
|
543
|
-
|
|
540
|
+
/**
|
|
541
|
+
* What a {@link ToolOutcome.catchAsEnvelope} case returns for one caught error:
|
|
542
|
+
* the same `message` and `remediation` a {@link ToolRefusal} carries, plus any
|
|
543
|
+
* structured fields the agent branches on.
|
|
544
|
+
*
|
|
545
|
+
* @remarks
|
|
546
|
+
* `message` is the plain sentence. The hint stays in `remediation`, where it
|
|
547
|
+
* reaches the agent as data; unlike a refusal's message, nothing is folded in.
|
|
548
|
+
*
|
|
549
|
+
* @public
|
|
550
|
+
*/
|
|
551
|
+
interface ToolOutcomeErrorInput {
|
|
552
|
+
readonly message: string;
|
|
553
|
+
readonly remediation: Remediation;
|
|
554
|
+
readonly [field: string]: unknown;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* The `ok: false` member of an envelope: `{ ok: false, error: { _tag, message, remediation, ...fields } }`.
|
|
558
|
+
*
|
|
559
|
+
* @public
|
|
560
|
+
*/
|
|
561
|
+
interface ToolOutcomeFailure<Tag extends string, I extends ToolOutcomeErrorInput> {
|
|
562
|
+
readonly ok: false;
|
|
563
|
+
readonly error: {
|
|
564
|
+
readonly _tag: Tag;
|
|
565
|
+
} & I;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* The failure values {@link ToolOutcome.catchAsEnvelope} produces for a set of
|
|
569
|
+
* cases: one {@link ToolOutcomeFailure} per handled tag.
|
|
570
|
+
*
|
|
571
|
+
* @public
|
|
572
|
+
*/
|
|
573
|
+
type ToolOutcomeFailures<Cases> = { [K in keyof Cases]: Cases[K] extends ((...args: ReadonlyArray<never>) => infer I) ? I extends ToolOutcomeErrorInput ? ToolOutcomeFailure<K & string, I> : never : never; }[keyof Cases];
|
|
574
|
+
/**
|
|
575
|
+
* {@link ToolOutcome.envelope}'s options: annotations for the `ok: false`
|
|
576
|
+
* member, so a served `outputSchema` can name and describe it.
|
|
577
|
+
*
|
|
578
|
+
* @public
|
|
579
|
+
*/
|
|
580
|
+
interface ToolOutcomeEnvelopeOptions {
|
|
581
|
+
/**
|
|
582
|
+
* The failure member's schema identifier. Core's JSON Schema output then
|
|
583
|
+
* emits it once under `$defs` by this name and refers to it with `$ref`.
|
|
584
|
+
*/
|
|
585
|
+
readonly identifier?: string;
|
|
586
|
+
/**
|
|
587
|
+
* The failure member's description, replacing the default one (which says
|
|
588
|
+
* that `ok: false` carries an expected error to branch on).
|
|
589
|
+
*/
|
|
590
|
+
readonly description?: string;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* The `ok: false` structured-outcome envelope: an expected error an agent
|
|
594
|
+
* should branch on, returned on a tool's success channel so it reaches the
|
|
595
|
+
* client as `structuredContent` instead of message text.
|
|
596
|
+
*
|
|
597
|
+
* @remarks
|
|
598
|
+
* Two channels, two jobs, one error shape:
|
|
599
|
+
*
|
|
600
|
+
* - {@link ToolRefusal} is a declared failure: the call itself was wrong, and
|
|
601
|
+
* the agent reads one sentence (`isError: true`, message text only; core
|
|
602
|
+
* never sends a failure's `structuredContent`).
|
|
603
|
+
* - `ToolOutcome` is an expected domain outcome: an id that does not exist, a
|
|
604
|
+
* transition that is not allowed. The agent branches on its `_tag` and
|
|
605
|
+
* fields (`isError: false`, `structuredContent` carries the envelope).
|
|
606
|
+
*
|
|
607
|
+
* Both carry {@link ToolFailure.fields} (`message`, `remediation`), so an error
|
|
608
|
+
* can move between channels without reshaping.
|
|
609
|
+
*
|
|
610
|
+
* Success members are not reshaped. Spread {@link ToolOutcome.ok} into each
|
|
611
|
+
* success struct so a client can discriminate on `ok`; a member that
|
|
612
|
+
* discriminates another way (`found: true | false`) stays legal.
|
|
613
|
+
*
|
|
614
|
+
* @example
|
|
615
|
+
* ```ts
|
|
616
|
+
* import { ToolOutcome } from "@effected/mcp"
|
|
617
|
+
* import { Effect, Schema } from "effect"
|
|
618
|
+
* import { Tool } from "effect/ai"
|
|
619
|
+
*
|
|
620
|
+
* const NotFound = ToolOutcome.error("GoalNotFound", { id: Schema.Number })
|
|
621
|
+
* const Lookup = Tool.make("goal", {
|
|
622
|
+
* parameters: Schema.Struct({ id: Schema.Number }),
|
|
623
|
+
* success: ToolOutcome.envelope(Schema.Struct({ ...ToolOutcome.ok, goal: Goal }), [NotFound]),
|
|
624
|
+
* })
|
|
625
|
+
* // in the handler:
|
|
626
|
+
* findGoal(id).pipe(
|
|
627
|
+
* Effect.map((goal) => ({ ok: true as const, goal })),
|
|
628
|
+
* ToolOutcome.catchAsEnvelope({
|
|
629
|
+
* GoalNotFound: (e) => ({ id: e.id, message: `No goal ${e.id}.`, remediation: { hint: "List goals first.", suggestedTool: "goal" } }),
|
|
630
|
+
* }),
|
|
631
|
+
* )
|
|
632
|
+
* ```
|
|
633
|
+
*
|
|
634
|
+
* @public
|
|
635
|
+
*/
|
|
636
|
+
export declare class ToolOutcome {
|
|
637
|
+
private constructor();
|
|
638
|
+
/** `{ ok: true }` as schema fields, to spread into each success member. */
|
|
639
|
+
static readonly ok: {
|
|
640
|
+
readonly ok: Schema.Literal<true>;
|
|
641
|
+
};
|
|
642
|
+
/**
|
|
643
|
+
* The schema of one envelope error: `_tag`, {@link ToolFailure.fields} and
|
|
644
|
+
* `fields`. A field named `_tag`, `message` or `remediation` is rejected by
|
|
645
|
+
* the type, because it would replace the shared one.
|
|
646
|
+
*/
|
|
647
|
+
static readonly error: <const Tag extends string, const Fields extends Schema.Struct.Fields = Record<never, never>>(tag: Tag, fields?: Fields & { readonly [K in "_tag" | "message" | "remediation"]?: never; }) => Schema.Struct<{
|
|
648
|
+
readonly _tag: Schema.Literal<Tag>;
|
|
649
|
+
} & typeof ToolFailure.fields & Fields>;
|
|
650
|
+
/**
|
|
651
|
+
* A tool's success schema: `success`, or `{ ok: false, error }` where `error`
|
|
652
|
+
* is one of `errors`. Object-rooted with {@link ToolOutputSchema.objectRooted},
|
|
653
|
+
* so core serves its `outputSchema` on every protocol revision.
|
|
654
|
+
*
|
|
655
|
+
* @remarks
|
|
656
|
+
* The failure member, its `ok` and its `error` carry default descriptions.
|
|
657
|
+
* `options` names the member (`identifier`, served once under `$defs`) or
|
|
658
|
+
* replaces its description.
|
|
659
|
+
*/
|
|
660
|
+
static readonly envelope: <S extends Schema.Top, const Errors extends ReadonlyArray<Schema.Top>>(success: S, errors: Errors, options?: ToolOutcomeEnvelopeOptions) => Schema.Union<readonly [S, Schema.Struct<{
|
|
661
|
+
readonly ok: Schema.Literal<false>;
|
|
662
|
+
readonly error: Schema.Union<Errors>;
|
|
663
|
+
}>]>;
|
|
664
|
+
/** One `ok: false` value, for a handler that decides on a failure without catching an error. */
|
|
665
|
+
static readonly failure: <const Tag extends string, const I extends ToolOutcomeErrorInput>(tag: Tag, error: I) => ToolOutcomeFailure<Tag, I>;
|
|
666
|
+
/**
|
|
667
|
+
* Turns each handled tagged error into its `ok: false` value on the success
|
|
668
|
+
* channel; every other error stays in the error channel. Typed like core's
|
|
669
|
+
* `Effect.catchTags`: a case for a tag the effect cannot fail with is a type
|
|
670
|
+
* error, and the handled tags leave `E`.
|
|
671
|
+
*
|
|
672
|
+
* @remarks
|
|
673
|
+
* Write it inline in the effect's `pipe`. `E` is inferred from the effect
|
|
674
|
+
* being piped, so `effect.pipe(ToolOutcome.catchAsEnvelope({ … }))` checks
|
|
675
|
+
* every case name against the effect's real tags. Bound to a `const` first,
|
|
676
|
+
* the combinator never sees that effect: `E` infers as `unknown`, the
|
|
677
|
+
* unknown-tag check switches off, and a misspelled tag compiles and never
|
|
678
|
+
* matches. Core's `catchTags` looks a case up only among `cases`' own keys,
|
|
679
|
+
* so an error tagged `"toString"` or `"constructor"` never hits an inherited
|
|
680
|
+
* `Object.prototype` member and stays in the error channel.
|
|
681
|
+
*/
|
|
682
|
+
static readonly catchAsEnvelope: <E, Cases extends { [K in Extract<E, {
|
|
683
|
+
readonly _tag: string;
|
|
684
|
+
}>["_tag"]]+?: (error: Extract<E, {
|
|
685
|
+
readonly _tag: K;
|
|
686
|
+
}>) => ToolOutcomeErrorInput; } & (unknown extends E ? Record<never, never> : { [K in Exclude<keyof Cases, Extract<E, {
|
|
687
|
+
readonly _tag: string;
|
|
688
|
+
}>["_tag"]>]: never; })>(cases: Cases) => <A, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A | ToolOutcomeFailures<Cases>, Exclude<E, {
|
|
689
|
+
readonly _tag: keyof Cases;
|
|
690
|
+
}>, R>;
|
|
691
|
+
/**
|
|
692
|
+
* Whether `value` is an `ok: false` envelope member: an object whose `ok` is
|
|
693
|
+
* exactly `false`. A success member that discriminates some other way, or a
|
|
694
|
+
* non-object, is not a failure. Narrows to `{ ok: false }` only: it checks
|
|
695
|
+
* the discriminant, not the `error` payload.
|
|
696
|
+
*/
|
|
697
|
+
static readonly isFailure: (value: unknown) => value is {
|
|
698
|
+
readonly ok: false;
|
|
699
|
+
};
|
|
700
|
+
}
|
|
544
701
|
/**
|
|
545
702
|
* Shaping for a tool's `success` schema, so the `outputSchema` core serves
|
|
546
703
|
* is one every client accepts.
|
|
@@ -589,8 +746,6 @@ export declare class ToolOutputSchema {
|
|
|
589
746
|
*/
|
|
590
747
|
static readonly objectRooted: <S extends Schema.Top>(schema: S) => S;
|
|
591
748
|
}
|
|
592
|
-
//#endregion
|
|
593
|
-
//#region src/ToolRefusal.d.ts
|
|
594
749
|
declare const ToolRefusal_base: Schema.Class<ToolRefusal, Schema.TaggedStruct<"ToolRefusal", {
|
|
595
750
|
readonly message: Schema.String;
|
|
596
751
|
readonly remediation: Schema.Struct<{
|
|
@@ -647,6 +802,146 @@ export declare class ToolRefusal extends ToolRefusal_base {
|
|
|
647
802
|
*/
|
|
648
803
|
static readonly refuse: (reason: string, remediation: Remediation) => ToolRefusal;
|
|
649
804
|
}
|
|
650
|
-
|
|
651
|
-
|
|
805
|
+
/**
|
|
806
|
+
* The narrow store {@link ToolReplay.wrap} reads and writes: `get` and `set`
|
|
807
|
+
* on string values, nothing else.
|
|
808
|
+
*
|
|
809
|
+
* @remarks
|
|
810
|
+
* Core's `KeyValueStore` already satisfies it structurally, so a
|
|
811
|
+
* `KeyValueStore` value can be passed as is. An application backed by its
|
|
812
|
+
* own table implements just these two methods, instead of stubbing the
|
|
813
|
+
* `remove`, `clear` and `size` that `KeyValueStore.makeStringOnly` demands.
|
|
814
|
+
*
|
|
815
|
+
* `get` answers `undefined` for a missing key, the same contract as
|
|
816
|
+
* `KeyValueStore.get`. A failure from either method is never a tool error:
|
|
817
|
+
* a failed `get` is a miss and a failed `set` is a skipped write, both
|
|
818
|
+
* logged at debug level, so the error channel is `unknown`.
|
|
819
|
+
*
|
|
820
|
+
* @public
|
|
821
|
+
*/
|
|
822
|
+
interface ToolReplayStore {
|
|
823
|
+
/** The stored value for `key`, or `undefined` when nothing is stored under it. */
|
|
824
|
+
readonly get: (key: string) => Effect.Effect<string | undefined, unknown>;
|
|
825
|
+
/** Stores `value` under `key`, replacing any earlier value. */
|
|
826
|
+
readonly set: (key: string, value: string) => Effect.Effect<void, unknown>;
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* What {@link ToolReplay.wrap} needs to know about one tool.
|
|
830
|
+
*
|
|
831
|
+
* @typeParam Store - The type of {@link ToolReplayOptions.store}; when it is
|
|
832
|
+
* omitted, `wrap` requires `KeyValueStore` in `R` instead.
|
|
833
|
+
*
|
|
834
|
+
* @public
|
|
835
|
+
*/
|
|
836
|
+
interface ToolReplayOptions<P, S extends Schema.Top, Store extends ToolReplayStore | undefined = undefined> {
|
|
837
|
+
/** The served tool name. It must not contain `:`, the separator of the stored key ({@link ToolReplay.storeKey}). */
|
|
838
|
+
readonly tool: string;
|
|
839
|
+
/**
|
|
840
|
+
* The tool's success schema. Results are stored encoded through it and
|
|
841
|
+
* decoded through it on replay. It must declare {@link ToolReplay.marker} on
|
|
842
|
+
* each object member, or core's encoder drops the marker from the reply:
|
|
843
|
+
* build each member with {@link ToolReplay.success} so it cannot be left out.
|
|
844
|
+
*/
|
|
845
|
+
readonly success: S;
|
|
846
|
+
/**
|
|
847
|
+
* The idempotency key for one call; `Option.none()` runs the handler with
|
|
848
|
+
* nothing stored or replayed.
|
|
849
|
+
*
|
|
850
|
+
* @remarks
|
|
851
|
+
* The key must identify the request completely. A replay compares nothing
|
|
852
|
+
* else: not the params, not the schema version. So a reused key with
|
|
853
|
+
* different params returns the FIRST call's result. When the params under
|
|
854
|
+
* one caller-supplied id can vary, fold a digest of them into the key.
|
|
855
|
+
*/
|
|
856
|
+
readonly key: (params: P) => Option.Option<string>;
|
|
857
|
+
/**
|
|
858
|
+
* Where results are stored. Omitted, `wrap` reads core's `KeyValueStore`
|
|
859
|
+
* from `R`; given, it uses this store and `KeyValueStore` leaves `R`.
|
|
860
|
+
*/
|
|
861
|
+
readonly store?: Store;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Idempotent replay for a write tool: a repeated call with the same key returns
|
|
865
|
+
* the first call's stored result, marked `_idempotentReplay: true`, instead of
|
|
866
|
+
* running the mutation again.
|
|
867
|
+
*
|
|
868
|
+
* @remarks
|
|
869
|
+
* The store is core's `KeyValueStore` (`effect/persistence`), required in `R`
|
|
870
|
+
* (`KeyValueStore.layerMemory`, `layerFileSystem` or `layerSql`), unless
|
|
871
|
+
* {@link ToolReplayOptions.store} supplies a {@link ToolReplayStore}: just
|
|
872
|
+
* `get` and `set`, for an application's own table. Then `R` carries no
|
|
873
|
+
* `KeyValueStore`.
|
|
874
|
+
*
|
|
875
|
+
* Semantics, per call:
|
|
876
|
+
*
|
|
877
|
+
* - A key of `Option.none()` runs the handler; nothing is read or stored.
|
|
878
|
+
* - A stored value that decodes through `success` is returned with the marker,
|
|
879
|
+
* and the handler does not run.
|
|
880
|
+
* - A miss runs the handler and stores its result, best-effort. A store that
|
|
881
|
+
* fails, a row that no longer decodes (written under an older schema, or
|
|
882
|
+
* corrupt), and a failed write are each a miss or a skipped write, never a
|
|
883
|
+
* tool error. Each is logged at debug level with the tool name.
|
|
884
|
+
* - A handler failure stores nothing, so a retry runs it again.
|
|
885
|
+
* - An `ok: false` result (a {@link ToolOutcome} envelope failure) is never
|
|
886
|
+
* stored: a "not found" must not replay after the thing exists.
|
|
887
|
+
*
|
|
888
|
+
* This is replay, not exactly-once: two concurrent calls with one key both
|
|
889
|
+
* miss and both run.
|
|
890
|
+
*
|
|
891
|
+
* The key is the whole identity of a request. A hit compares neither the
|
|
892
|
+
* params nor the schema version (a row written under an older schema replays
|
|
893
|
+
* whenever it still decodes), and rows never expire: the store keeps a result
|
|
894
|
+
* until something removes it. A key reused with different params therefore
|
|
895
|
+
* replays the first result, so {@link ToolReplayOptions.key} must fold in a
|
|
896
|
+
* digest of any params that can vary under one id.
|
|
897
|
+
*
|
|
898
|
+
* Wrap it inside {@link ToolOutcome.catchAsEnvelope}, so the envelope sees the
|
|
899
|
+
* handler's real errors and replay sees only real successes.
|
|
900
|
+
*
|
|
901
|
+
* @example
|
|
902
|
+
* ```ts
|
|
903
|
+
* import { ToolReplay } from "@effected/mcp"
|
|
904
|
+
* import { Option, Schema } from "effect"
|
|
905
|
+
*
|
|
906
|
+
* const Created = ToolReplay.success(Schema.Struct({ ok: Schema.Literal(true), id: Schema.Number }))
|
|
907
|
+
* const create = ToolReplay.wrap({
|
|
908
|
+
* tool: "goal_create",
|
|
909
|
+
* success: Created,
|
|
910
|
+
* key: (params: { readonly requestId?: string }) => Option.fromUndefinedOr(params.requestId),
|
|
911
|
+
* })(createGoal)
|
|
912
|
+
* ```
|
|
913
|
+
*
|
|
914
|
+
* @public
|
|
915
|
+
*/
|
|
916
|
+
export declare class ToolReplay {
|
|
917
|
+
private constructor();
|
|
918
|
+
/**
|
|
919
|
+
* The marker field, to spread into each object member of a replayable
|
|
920
|
+
* success schema. {@link ToolReplay.success} adds it for you.
|
|
921
|
+
*/
|
|
922
|
+
static readonly marker: {
|
|
923
|
+
readonly _idempotentReplay: Schema.optionalKey<Schema.Literal<true>>;
|
|
924
|
+
};
|
|
925
|
+
/**
|
|
926
|
+
* A struct with {@link ToolReplay.marker} added. Use it for each object
|
|
927
|
+
* success member, so core's encoder keeps the marker on a replayed reply.
|
|
928
|
+
*
|
|
929
|
+
* @remarks
|
|
930
|
+
* Built with core's `fieldsAssign`, which, like `mapFields`, does not carry
|
|
931
|
+
* the struct's own `.check(...)` refinements over: apply checks to the
|
|
932
|
+
* result, not to the struct passed in.
|
|
933
|
+
*/
|
|
934
|
+
static readonly success: <const Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) => Schema.Struct<Struct.Simplify<Struct.Assign<Fields, typeof ToolReplay.marker>>>;
|
|
935
|
+
/**
|
|
936
|
+
* The `KeyValueStore` key one call is stored under: `<tool>:<key>`. Split on
|
|
937
|
+
* the first `:` to recover both parts; MCP tool names are `[A-Za-z0-9_.-]`.
|
|
938
|
+
*/
|
|
939
|
+
static readonly storeKey: (tool: string, key: string) => string;
|
|
940
|
+
/**
|
|
941
|
+
* Wraps a handler with replay. Throws at wrap time when `tool` contains
|
|
942
|
+
* `:`, a wiring mistake, so it surfaces when the server is built.
|
|
943
|
+
*/
|
|
944
|
+
static readonly wrap: <P, S extends Schema.Top, Store extends ToolReplayStore | undefined = undefined>(options: ToolReplayOptions<P, S, Store>) => (<E, R>(handler: (params: P) => Effect.Effect<S["Type"], E, R>) => (params: P) => Effect.Effect<S["Type"], E, R | ([Store] extends [ToolReplayStore] ? never : KeyValueStore.KeyValueStore) | S["DecodingServices"] | S["EncodingServices"]>);
|
|
945
|
+
}
|
|
946
|
+
export type { FormatUnknownKeysOptions, McpLaunchOptions, McpStdioOptions, McpToolkitOptions, ToolOutcomeEnvelopeOptions, ToolOutcomeErrorInput, ToolOutcomeFailure, ToolOutcomeFailures, ToolReplayOptions, ToolReplayStore, UnionHandlerOptions, UnionTool, UnionToolOptions, UnknownKeysLevel };
|
|
652
947
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -3,6 +3,8 @@ import { ToolFailure } from "./ToolFailure.js";
|
|
|
3
3
|
import { ToolInputSchema } from "./ToolInputSchema.js";
|
|
4
4
|
import { McpToolkit } from "./McpToolkit.js";
|
|
5
5
|
import { ToolOutputSchema } from "./ToolOutputSchema.js";
|
|
6
|
+
import { ToolOutcome } from "./ToolOutcome.js";
|
|
6
7
|
import { ToolRefusal } from "./ToolRefusal.js";
|
|
8
|
+
import { ToolReplay } from "./ToolReplay.js";
|
|
7
9
|
|
|
8
|
-
export { McpStdio, McpToolkit, ToolFailure, ToolInputSchema, ToolOutputSchema, ToolRefusal };
|
|
10
|
+
export { McpStdio, McpToolkit, ToolFailure, ToolInputSchema, ToolOutcome, ToolOutputSchema, ToolRefusal, ToolReplay };
|
package/internal/LaunchFailed.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Runtime } from "effect";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// src/internal/LaunchFailed.ts
|
|
4
4
|
/**
|
|
5
5
|
* The failure `McpStdio.launch` re-raises after reporting a launch failure on
|
|
6
6
|
* stderr itself. `errorReported: false` keeps `runMain` from reporting it a
|
|
@@ -31,5 +31,4 @@ var LaunchFailed = class extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
//#endregion
|
|
35
34
|
export { LaunchFailed };
|
package/internal/StdinFrames.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Effect, Layer, Stdio, Stream } from "effect";
|
|
2
2
|
import { McpSchema } from "effect/ai";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// src/internal/StdinFrames.ts
|
|
5
5
|
/**
|
|
6
6
|
* The line-length cap core's stdio NDJSON decoder applies, in the same unit
|
|
7
7
|
* (UTF-16 code units of the decoded text) and with the same predicate
|
|
@@ -235,5 +235,4 @@ const guardStdin = (stdio) => {
|
|
|
235
235
|
*/
|
|
236
236
|
const makeGuardedStdio = () => Layer.effect(Stdio.Stdio, Effect.map(Stdio.Stdio, guardStdin));
|
|
237
237
|
|
|
238
|
-
//#endregion
|
|
239
238
|
export { INVALID_REQUEST_FRAME, MAX_FRAME_LENGTH, PARSE_ERROR_FRAME, RPC_CONTROL_PREFIX, guardStdin, makeFrameGuard, makeGuardedStdio, methodNotFoundFrame };
|
package/internal/guardLaunch.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { McpStdio } from "../McpStdio.js";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// src/internal/guardLaunch.ts
|
|
5
5
|
/**
|
|
6
6
|
* The server half of `McpGuard.run`, reached only through a dynamic import so
|
|
7
7
|
* the guard module itself evaluates nothing but its own code.
|
|
@@ -10,5 +10,4 @@ import { Effect } from "effect";
|
|
|
10
10
|
*/
|
|
11
11
|
const launchGuarded = (server, onReady) => server.runMain(McpStdio.launch(server.layer, { onReady: Effect.sync(onReady) }), { teardown: McpStdio.teardown });
|
|
12
12
|
|
|
13
|
-
//#endregion
|
|
14
13
|
export { launchGuarded };
|
package/internal/wire.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Result } from "effect";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// src/internal/wire.ts
|
|
4
4
|
/** @internal */
|
|
5
5
|
const DEFAULT_CLIENT_INFO = {
|
|
6
6
|
name: "effected-mcp-testing",
|
|
@@ -62,5 +62,4 @@ const initializeParams = (protocol, clientInfo) => ({
|
|
|
62
62
|
clientInfo
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
//#endregion
|
|
66
65
|
export { DEFAULT_CLIENT_INFO, STDERR_HINT, frame, initializeParams, isJsonRpcMessage, isResponse, isStateless, parseFrame, requestKey };
|
package/node.d.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Writable } from "node:stream";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
/**
|
|
4
|
+
* Anything with a replaceable `write`: a patched stream (Node's
|
|
5
|
+
* `process.stdout` and `process.stderr`) and a sink captured writes go to (a
|
|
6
|
+
* Node `Writable`, {@link StdioCapture.nullWritable}, {@link StdioCapture.discard},
|
|
7
|
+
* {@link StdioCapture.toStderr}).
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* A sink's `write` receives exactly the arguments the patched stream's
|
|
11
|
+
* `write` was called with, including any trailing callback, so a sink must
|
|
12
|
+
* call that callback itself, as a `Writable` does.
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
interface StdioSink {
|
|
17
|
+
write(chunk: any, ...rest: Array<any>): boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The two streams {@link StdioCapture.install} patches. Node's `process`
|
|
21
|
+
* satisfies it; pass it in from `main.ts`.
|
|
22
|
+
*
|
|
23
|
+
* @public
|
|
24
|
+
*/
|
|
25
|
+
interface StdioCaptureHost {
|
|
26
|
+
readonly stdout: StdioSink;
|
|
27
|
+
readonly stderr: StdioSink;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The sinks for one capture. `stderr` passes through to the real stream when
|
|
31
|
+
* omitted.
|
|
32
|
+
*
|
|
33
|
+
* @public
|
|
34
|
+
*/
|
|
35
|
+
interface StdioCaptureOptions {
|
|
36
|
+
readonly stdout: StdioSink;
|
|
37
|
+
readonly stderr?: StdioSink | undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Diverts a host's stdout (and optionally stderr) writes to a sink, for code
|
|
41
|
+
* running inside one async context only, while every other context keeps
|
|
42
|
+
* writing to the real streams.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* For an MCP server over stdio, stdout is the JSON-RPC wire. A chatty library
|
|
46
|
+
* run in-process (a test runner, a bundler, a linter) must keep its output off
|
|
47
|
+
* it while other handlers, and the transport itself, keep writing normally.
|
|
48
|
+
*
|
|
49
|
+
* `install` patches the host's `write` methods once, keeping the unbound
|
|
50
|
+
* originals, and routes every write through an `AsyncLocalStorage`: inside a
|
|
51
|
+
* {@link StdioCapture.run} or {@link StdioCapture.capture} context a write goes
|
|
52
|
+
* to that context's sink, and outside it to the original stream.
|
|
53
|
+
*
|
|
54
|
+
* An Effect fiber resumes in the async context it suspended in, not the
|
|
55
|
+
* context of whatever woke it, so a server fiber woken by code inside a
|
|
56
|
+
* capture (its response written to a queue, say) keeps writing to the wire.
|
|
57
|
+
*
|
|
58
|
+
* Two kinds of write escape a capture and reach the original stream:
|
|
59
|
+
*
|
|
60
|
+
* - **Writes from a context the capture lost.** Callbacks registered outside the
|
|
61
|
+
* capture keep the context they were registered in: native addons, worker
|
|
62
|
+
* thread output relayed by the main thread, listeners on a child process's
|
|
63
|
+
* pipes attached earlier. On stdout, that is the wire.
|
|
64
|
+
* - **Fibers started inside the capture keep capturing after it ends.** A fiber
|
|
65
|
+
* forked inside it (`Effect.forkDetach`, a memoized layer first built inside
|
|
66
|
+
* it, a background fiber a library starts) keeps writing to the sink, even
|
|
67
|
+
* after the capture's own work is done.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* import { StdioCapture } from "@effected/mcp/node"
|
|
72
|
+
*
|
|
73
|
+
* const capture = StdioCapture.install(process)
|
|
74
|
+
* // in a tool handler: keep the runner's own console output off the wire
|
|
75
|
+
* const report = yield* runTests(files).pipe(capture.capture({ stdout: capture.toStderr }))
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* @public
|
|
79
|
+
*/
|
|
80
|
+
export declare class StdioCapture {
|
|
81
|
+
/** A sink that writes through the host's original, unpatched stderr: captured output stays visible off the wire. */
|
|
82
|
+
readonly toStderr: StdioSink;
|
|
83
|
+
private constructor();
|
|
84
|
+
/**
|
|
85
|
+
* Patches `host`'s two streams. Idempotent per stream, across every installed
|
|
86
|
+
* copy of this package: a stream already patched (through another host
|
|
87
|
+
* object, or another copy) keeps its one patch and its original `write`.
|
|
88
|
+
*/
|
|
89
|
+
static readonly install: (host: StdioCaptureHost) => StdioCapture;
|
|
90
|
+
/**
|
|
91
|
+
* A write-only sink that drops every write and calls the write's callback.
|
|
92
|
+
*
|
|
93
|
+
* @remarks
|
|
94
|
+
* It is a {@link StdioSink}, not a stream: it has no `on`, `pipe` target
|
|
95
|
+
* protocol or `setMaxListeners`, so it cannot stand in where a library
|
|
96
|
+
* expects a `Writable` (a test runner's logger stream that worker output
|
|
97
|
+
* is piped into, for one). Use {@link StdioCapture.nullWritable} there; it
|
|
98
|
+
* serves as a capture sink too.
|
|
99
|
+
*/
|
|
100
|
+
static readonly discard: StdioSink;
|
|
101
|
+
/**
|
|
102
|
+
* A new Node `Writable` that drops everything written to it: a real stream,
|
|
103
|
+
* so it works as a `pipe` target, takes `setMaxListeners`, emits `drain`
|
|
104
|
+
* and `finish`, and is also a {@link StdioSink} for a capture.
|
|
105
|
+
*
|
|
106
|
+
* @remarks
|
|
107
|
+
* A factory, not a shared value: a stream ends once, and a source piped in
|
|
108
|
+
* with the default `{ end: true }` ends its destination. Build one per
|
|
109
|
+
* consumer.
|
|
110
|
+
*/
|
|
111
|
+
static readonly nullWritable: () => Writable;
|
|
112
|
+
/**
|
|
113
|
+
* Runs `evaluate` with its writes captured, for code that is not Effect.
|
|
114
|
+
* A synchronous throw from `evaluate` rejects the returned promise.
|
|
115
|
+
*/
|
|
116
|
+
run<A>(options: StdioCaptureOptions, evaluate: () => PromiseLike<A>): Promise<A>;
|
|
117
|
+
/**
|
|
118
|
+
* Runs an effect with its writes captured: on its own fiber, started inside
|
|
119
|
+
* the capture's context, so the context survives every suspension of that
|
|
120
|
+
* fiber. Interrupting the returned effect interrupts that fiber and waits
|
|
121
|
+
* for it.
|
|
122
|
+
*/
|
|
123
|
+
capture(options: StdioCaptureOptions): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
|
|
124
|
+
}
|
|
125
|
+
export type { StdioCaptureHost, StdioCaptureOptions, StdioSink };
|
|
126
|
+
//# sourceMappingURL=node.d.ts.map
|
package/node.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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": [
|
|
@@ -39,6 +39,11 @@
|
|
|
39
39
|
"import": "./guard.js",
|
|
40
40
|
"default": "./guard.js"
|
|
41
41
|
},
|
|
42
|
+
"./node": {
|
|
43
|
+
"types": "./node.d.ts",
|
|
44
|
+
"import": "./node.js",
|
|
45
|
+
"default": "./node.js"
|
|
46
|
+
},
|
|
42
47
|
"./testing": {
|
|
43
48
|
"types": "./testing.d.ts",
|
|
44
49
|
"import": "./testing.js",
|
|
@@ -47,7 +52,7 @@
|
|
|
47
52
|
"./package.json": "./package.json"
|
|
48
53
|
},
|
|
49
54
|
"peerDependencies": {
|
|
50
|
-
"@effected/engine": "^0.
|
|
55
|
+
"@effected/engine": "^0.9.0",
|
|
51
56
|
"effect": "^4.0.0"
|
|
52
57
|
},
|
|
53
58
|
"engines": {
|
package/testing.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Duration, Effect, JsonSchema, Layer, PlatformError, Schema, Scope, Stdio } from "effect";
|
|
2
2
|
import { McpProtocol } from "effect/ai";
|
|
3
3
|
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
4
|
-
//#region src/McpTestFailure.d.ts
|
|
5
4
|
declare const McpTestFailure_base: Schema.Class<McpTestFailure, Schema.TaggedStruct<"McpTestFailure", {
|
|
6
5
|
/** Which failure occurred; see the list above for which client raises which. */
|
|
7
6
|
readonly reason: Schema.Literals<readonly ["StreamEnded", "ServerStopped", "NotJsonRpc", "NotInitialized", "ErrorResponse", "TimedOut"]>;
|
|
@@ -40,8 +39,6 @@ declare const McpTestFailure_base: Schema.Class<McpTestFailure, Schema.TaggedStr
|
|
|
40
39
|
* @public
|
|
41
40
|
*/
|
|
42
41
|
export declare class McpTestFailure extends McpTestFailure_base {}
|
|
43
|
-
//#endregion
|
|
44
|
-
//#region src/McpWire.d.ts
|
|
45
42
|
/**
|
|
46
43
|
* One JSON-RPC 2.0 frame as an MCP test client sees it.
|
|
47
44
|
*
|
|
@@ -92,8 +89,6 @@ interface ServedResource {
|
|
|
92
89
|
readonly [key: string]: unknown;
|
|
93
90
|
} | undefined;
|
|
94
91
|
}
|
|
95
|
-
//#endregion
|
|
96
|
-
//#region src/McpHarness.d.ts
|
|
97
92
|
/**
|
|
98
93
|
* Options for {@link McpHarness.make}.
|
|
99
94
|
*
|
|
@@ -238,8 +233,6 @@ export declare class McpHarness {
|
|
|
238
233
|
/** Build `server` over queue-backed stdio and return a client for it; fails with the layer's own error if it cannot build. */
|
|
239
234
|
static readonly make: <ROut, E, R>(server: Layer.Layer<ROut, E, R>, options?: McpHarnessOptions) => Effect.Effect<McpHarness, E, Scope.Scope | Exclude<R, Stdio.Stdio>>;
|
|
240
235
|
}
|
|
241
|
-
//#endregion
|
|
242
|
-
//#region src/McpProbe.d.ts
|
|
243
236
|
/**
|
|
244
237
|
* Options for {@link McpProbe.initialize}.
|
|
245
238
|
*
|
|
@@ -310,8 +303,6 @@ export declare class McpProbe {
|
|
|
310
303
|
*/
|
|
311
304
|
static readonly initialize: (command: ChildProcess.Command, options?: McpProbeOptions) => Effect.Effect<McpProbeResult, McpTestFailure | PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner>;
|
|
312
305
|
}
|
|
313
|
-
//#endregion
|
|
314
|
-
//#region src/McpProcess.d.ts
|
|
315
306
|
/**
|
|
316
307
|
* Options for {@link McpProcess.stderrUntil}.
|
|
317
308
|
*
|
|
@@ -423,8 +414,6 @@ export declare class McpProcess {
|
|
|
423
414
|
/** Spawn `command` for the life of the current scope. */
|
|
424
415
|
static readonly spawn: (command: ChildProcess.Command) => Effect.Effect<McpProcess, PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope>;
|
|
425
416
|
}
|
|
426
|
-
//#endregion
|
|
427
|
-
//#region src/McpToolAudit.d.ts
|
|
428
417
|
/**
|
|
429
418
|
* The policy {@link McpToolAudit.check} enforces.
|
|
430
419
|
*
|
|
@@ -489,6 +478,5 @@ export declare class McpToolAudit {
|
|
|
489
478
|
*/
|
|
490
479
|
static readonly check: (tools: ReadonlyArray<ServedTool>, policy: McpToolAuditPolicy) => ReadonlyArray<string>;
|
|
491
480
|
}
|
|
492
|
-
//#endregion
|
|
493
481
|
export type { JsonRpcMessage, McpHarnessOptions, McpProbeOptions, McpProbeResult, McpProcessStderrUntilOptions, McpToolAuditPolicy, ServedResource, ServedTool };
|
|
494
482
|
//# sourceMappingURL=testing.d.ts.map
|