@effected/mcp 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/main.d.ts +6 -5
- package/main.js +15 -6
- package/package.json +6 -4
- package/server.js +15 -6
- package/tools/schema.js +158 -0
- package/version.js +1 -1
package/main.d.ts
CHANGED
|
@@ -15,11 +15,12 @@ export interface MainOptions {
|
|
|
15
15
|
* `McpGuard.run` installs the crash guards before `load` evaluates the server
|
|
16
16
|
* graph, so a throw during module evaluation is still reported on stderr; this
|
|
17
17
|
* module therefore carries no static runtime import (`Distribution` is
|
|
18
|
-
* type-only). Policy is `exitBeforeConnect` for both events: the
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* serving means a broken boot and exits
|
|
22
|
-
* it is the JSON-RPC wire.
|
|
18
|
+
* type-only). Policy is `exitBeforeConnect` for both events: the server holds
|
|
19
|
+
* no state between calls, and a stray error while serving is likely a config's
|
|
20
|
+
* own (config modules run in this process), so the guard logs and keeps
|
|
21
|
+
* serving, while the same error before serving means a broken boot and exits
|
|
22
|
+
* `1`. Nothing here writes to stdout; it is the JSON-RPC wire. A config can, so
|
|
23
|
+
* a tool call runs under `StdioCapture`, its stdout diverted to stderr.
|
|
23
24
|
*
|
|
24
25
|
* @public
|
|
25
26
|
*/
|
package/main.js
CHANGED
|
@@ -7,11 +7,12 @@ import { McpGuard } from "@effected/mcp-kit/guard";
|
|
|
7
7
|
* `McpGuard.run` installs the crash guards before `load` evaluates the server
|
|
8
8
|
* graph, so a throw during module evaluation is still reported on stderr; this
|
|
9
9
|
* module therefore carries no static runtime import (`Distribution` is
|
|
10
|
-
* type-only). Policy is `exitBeforeConnect` for both events: the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* serving means a broken boot and exits
|
|
14
|
-
* it is the JSON-RPC wire.
|
|
10
|
+
* type-only). Policy is `exitBeforeConnect` for both events: the server holds
|
|
11
|
+
* no state between calls, and a stray error while serving is likely a config's
|
|
12
|
+
* own (config modules run in this process), so the guard logs and keeps
|
|
13
|
+
* serving, while the same error before serving means a broken boot and exits
|
|
14
|
+
* `1`. Nothing here writes to stdout; it is the JSON-RPC wire. A config can, so
|
|
15
|
+
* a tool call runs under `StdioCapture`, its stdout diverted to stderr.
|
|
15
16
|
*
|
|
16
17
|
* @public
|
|
17
18
|
*/
|
|
@@ -25,10 +26,18 @@ const main = (options = {}) => McpGuard.run({
|
|
|
25
26
|
load: async () => {
|
|
26
27
|
const NodeRuntime = await import("@effect/platform-node/NodeRuntime");
|
|
27
28
|
const NodeStdio = await import("@effect/platform-node/NodeStdio");
|
|
29
|
+
const NodeFileSystem = await import("@effect/platform-node/NodeFileSystem");
|
|
30
|
+
const NodePath = await import("@effect/platform-node/NodePath");
|
|
31
|
+
const { StdioCapture } = await import("@effected/mcp-kit/node");
|
|
28
32
|
const { Layer } = await import("effect");
|
|
29
33
|
const { ServerLayer } = await import("./server.js");
|
|
34
|
+
const capture = StdioCapture.install(process);
|
|
30
35
|
return {
|
|
31
|
-
layer: ServerLayer(
|
|
36
|
+
layer: ServerLayer({
|
|
37
|
+
...options,
|
|
38
|
+
cwd: process.cwd(),
|
|
39
|
+
confineStdout: capture.capture({ stdout: capture.toStderr })
|
|
40
|
+
}).pipe(Layer.provide(Layer.mergeAll(NodeStdio.layer, NodeFileSystem.layer, NodePath.layer))),
|
|
32
41
|
runMain: NodeRuntime.runMain
|
|
33
42
|
};
|
|
34
43
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The effected-mcp server: the tool's MCP tools on the kit's MCP boundary",
|
|
6
6
|
"keywords": [
|
|
@@ -44,9 +44,11 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@effect/platform-node": "~4.1.2",
|
|
47
|
-
"@effected/engine": "
|
|
48
|
-
"@effected/
|
|
49
|
-
"@effected/
|
|
47
|
+
"@effected/engine": "0.13.0",
|
|
48
|
+
"@effected/glob": "^0.19.0",
|
|
49
|
+
"@effected/kit": "^0.13.0",
|
|
50
|
+
"@effected/mcp-kit": "^0.14.0",
|
|
51
|
+
"@effected/walker": "^0.25.1",
|
|
50
52
|
"effect": "~4.1.2"
|
|
51
53
|
},
|
|
52
54
|
"engines": {
|
package/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { handleIdentity, identity } from "./tools/identity.js";
|
|
2
|
+
import { makeSchemaHandlers, schemaBuild, schemaCheck, schemaValidate } from "./tools/schema.js";
|
|
2
3
|
import { MCP_VERSION } from "./version.js";
|
|
3
4
|
import { McpStdio, McpToolkit } from "@effected/mcp-kit";
|
|
4
|
-
import { Layer } from "effect";
|
|
5
|
+
import { Effect, Layer } from "effect";
|
|
5
6
|
import { McpProtocol, Toolkit } from "effect/ai";
|
|
6
7
|
|
|
7
8
|
// src/server.ts
|
|
@@ -11,22 +12,30 @@ import { McpProtocol, Toolkit } from "effect/ai";
|
|
|
11
12
|
*
|
|
12
13
|
* @public
|
|
13
14
|
*/
|
|
14
|
-
const SERVER_INSTRUCTIONS = "effected-mcp serves the effected tool. Call identity to learn the tool name, this server's version, the distribution it was launched through and the resolved effect version.";
|
|
15
|
+
const SERVER_INSTRUCTIONS = "effected-mcp serves the effected tool. Call identity to learn the tool name, this server's version, the distribution it was launched through and the resolved effect version. schema_check reports whether the generated schema documents match the config and writes nothing, schema_build writes them, and schema_validate checks a JSON payload against one. Drift, staleness and a failed validation arrive as results (a verdict, or valid: false), not as errors; an error means the config could not be found or loaded, a path was outside the workspace root, or the run could not finish. Each call reloads the config and the TypeScript and JSON it imports, but an imported .mjs, .cjs or built .js under type: module stays cached, so restart this server after rebuilding a sibling package the config uses.";
|
|
15
16
|
/**
|
|
16
|
-
* The whole server as one layer: the
|
|
17
|
-
*
|
|
17
|
+
* The whole server as one layer: the toolkit over the kit's `McpStdio.layer`,
|
|
18
|
+
* its schema handlers capturing `FileSystem` and `Path` when they are built.
|
|
19
|
+
* The adapters are listed newest first, as the kit's
|
|
18
20
|
* default lists them; a client that opens with `initialize` needs the
|
|
19
21
|
* stateful ones.
|
|
20
22
|
*
|
|
21
23
|
* @public
|
|
22
24
|
*/
|
|
23
25
|
const ServerLayer = (options = {}) => {
|
|
24
|
-
const EffectedToolkit = Toolkit.make(identity);
|
|
25
|
-
const handlers = EffectedToolkit.toLayer(
|
|
26
|
+
const EffectedToolkit = Toolkit.make(identity, schemaCheck, schemaBuild, schemaValidate);
|
|
27
|
+
const handlers = EffectedToolkit.toLayer(Effect.map(makeSchemaHandlers(options), (schema) => ({
|
|
28
|
+
identity: () => handleIdentity(options.distribution),
|
|
29
|
+
...schema
|
|
30
|
+
})));
|
|
26
31
|
return McpStdio.layer({
|
|
27
32
|
name: "effected",
|
|
28
33
|
version: MCP_VERSION,
|
|
29
34
|
instructions: SERVER_INSTRUCTIONS,
|
|
35
|
+
cacheHints: { tools: {
|
|
36
|
+
ttl: "1 hour",
|
|
37
|
+
cacheScope: "private"
|
|
38
|
+
} },
|
|
30
39
|
protocols: [
|
|
31
40
|
McpProtocol.v2026_07_28,
|
|
32
41
|
McpProtocol.v2025_11_25,
|
package/tools/schema.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { ToolFailure, ToolRefusal } from "@effected/mcp-kit";
|
|
2
|
+
import { Effect, FileSystem, Option, Path, Ref, Schema } from "effect";
|
|
3
|
+
import { Tool } from "effect/ai";
|
|
4
|
+
import { ConfigLoader, SchemaRun, SchemaRunResult, SchemaValidate, ValidateReport } from "@effected/engine/schema";
|
|
5
|
+
import { Exclusive, PathContainment } from "@effected/kit";
|
|
6
|
+
import { Walker } from "@effected/walker";
|
|
7
|
+
|
|
8
|
+
// src/tools/schema.ts
|
|
9
|
+
const CONFIG_DESCRIPTION = "The schemastore config, resolved against the workspace root and refused outside it. Omitted, discovery walks upward from the server's working directory, stopping at the workspace root.";
|
|
10
|
+
const RunParameters = Schema.Struct({
|
|
11
|
+
config: Schema.optionalKey(Schema.String.annotate({ description: CONFIG_DESCRIPTION })),
|
|
12
|
+
drift: Schema.optionalKey(Schema.Literals([
|
|
13
|
+
"strict",
|
|
14
|
+
"semantic",
|
|
15
|
+
"allow"
|
|
16
|
+
]).annotate({ description: "One drift tolerance over every schema's own." })),
|
|
17
|
+
onDrift: Schema.optionalKey(Schema.Literals(["error", "warn"]).annotate({ description: "Overrides the config's onDrift." })),
|
|
18
|
+
force: Schema.optionalKey(Schema.Boolean.annotate({ description: "Shorthand for drift \"allow\", and refused beside any other drift." }))
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* The `schema_check` tool: what `schema_build` would do, writing nothing.
|
|
22
|
+
*
|
|
23
|
+
* @public
|
|
24
|
+
*/
|
|
25
|
+
const schemaCheck = Tool.make("schema_check", {
|
|
26
|
+
description: "Checks the generated schema documents against the config, writing nothing. Drift and staleness arrive as the result's verdict, with each changed document's full diff; they are not errors. Reloads the config on every call.",
|
|
27
|
+
parameters: RunParameters,
|
|
28
|
+
success: SchemaRunResult,
|
|
29
|
+
failure: ToolRefusal
|
|
30
|
+
}).annotate(Tool.Title, "Check schema documents").annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true).annotate(Tool.OpenWorld, false);
|
|
31
|
+
/**
|
|
32
|
+
* The `schema_build` tool: writes the generated documents and catalogs.
|
|
33
|
+
*
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
const schemaBuild = Tool.make("schema_build", {
|
|
37
|
+
description: "Generates the schema documents and catalogs the config declares and writes the ones that changed, wherever the config puts them. A refused drift or a failed gate writes nothing and arrives as the result's verdict. Reloads the config on every call.",
|
|
38
|
+
parameters: RunParameters,
|
|
39
|
+
success: SchemaRunResult,
|
|
40
|
+
failure: ToolRefusal
|
|
41
|
+
}).annotate(Tool.Title, "Build schema documents").annotate(Tool.Readonly, false).annotate(Tool.Idempotent, true).annotate(Tool.OpenWorld, false);
|
|
42
|
+
/**
|
|
43
|
+
* The `schema_validate` tool: does one payload conform to a published document.
|
|
44
|
+
*
|
|
45
|
+
* @public
|
|
46
|
+
*/
|
|
47
|
+
const schemaValidate = Tool.make("schema_validate", {
|
|
48
|
+
description: "Validates a JSON payload file against a local schema document, with each finding's line and column. A payload that does not conform is valid: false, not an error.",
|
|
49
|
+
parameters: Schema.Struct({
|
|
50
|
+
payload: Schema.String.annotate({ description: "The payload file, resolved against the workspace root and refused outside it." }),
|
|
51
|
+
config: Schema.optionalKey(Schema.String.annotate({ description: CONFIG_DESCRIPTION })),
|
|
52
|
+
schema: Schema.optionalKey(Schema.String.annotate({ description: "The document: a file path under the workspace root, or an $id or url the config derives. Omitted, the payload's own $schema." }))
|
|
53
|
+
}),
|
|
54
|
+
success: ValidateReport,
|
|
55
|
+
failure: ToolRefusal
|
|
56
|
+
}).annotate(Tool.Title, "Validate a payload").annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true).annotate(Tool.OpenWorld, false);
|
|
57
|
+
const WALK_TIMEOUT = "2 minutes";
|
|
58
|
+
const findRoot = (cwd) => Effect.gen(function* () {
|
|
59
|
+
const fs = yield* FileSystem.FileSystem;
|
|
60
|
+
const path = yield* Path.Path;
|
|
61
|
+
const chain = yield* Walker.ascend(cwd);
|
|
62
|
+
const holding = (marker) => Walker.findRoot(chain, (dir) => fs.exists(path.join(dir, marker)));
|
|
63
|
+
const workspace = yield* holding("pnpm-workspace.yaml");
|
|
64
|
+
return Option.isSome(workspace) ? workspace.value : Option.getOrElse(yield* holding(".git"), () => cwd);
|
|
65
|
+
});
|
|
66
|
+
const refusalOf = (error, root, mismatch) => {
|
|
67
|
+
const refuse = (reason, hint, suggestedTool) => ToolRefusal.refuse([ToolFailure.truncate(reason, ToolFailure.ENGINE_ECHO_LIMIT), ...mismatch !== void 0 ? [`Version mismatch: ${mismatch.message}`] : []].join(" "), {
|
|
68
|
+
hint,
|
|
69
|
+
...suggestedTool !== void 0 ? { suggestedTool } : {}
|
|
70
|
+
});
|
|
71
|
+
switch (error._tag) {
|
|
72
|
+
case "ConfigNotFoundError": return refuse(error.message, `Add a schemastore.config.ts, or pass config with its path under the workspace root ${root}.`);
|
|
73
|
+
case "ConfigLoadError": return refuse(`Failed to load schemastore config ${error.path}: ${error.reason.split("\n", 1)[0]}`, "Fix the config and call the tool again; it reloads on every call.");
|
|
74
|
+
case "ConflictingFlagsError": return refuse(`force means drift "allow", so it conflicts with drift "${error.policy}".`, "Pass one or the other.");
|
|
75
|
+
case "MissingSchemaRefError": return refuse(`Payload ${error.path} has no "$schema" property.`, "Name the document with the schema parameter: a file path, or an $id or url the config derives.");
|
|
76
|
+
case "ExclusiveTimeout": return refuse(error.message, "Documents may be part-written; call the tool to see what differs.", "schema_check");
|
|
77
|
+
default: return refuse(error.message, "Fix what the message names and call the tool again.");
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const payloadRef = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Struct({ $schema: Schema.String })));
|
|
81
|
+
/**
|
|
82
|
+
* The three handlers, closed over the filesystem and path the server was built with, the workspace root found once
|
|
83
|
+
* from `options.cwd`, and one lane.
|
|
84
|
+
*
|
|
85
|
+
* @remarks
|
|
86
|
+
* `schema_build` and `schema_check` load the config OUTSIDE the lane and hand the engine what was loaded. A timed-out
|
|
87
|
+
* lane interrupts its body, and the engine's `Effect.tryPromise` around the import abandons the promise on interrupt,
|
|
88
|
+
* so a load inside would leave a config still evaluating while the next call holds the permit. A hanging config
|
|
89
|
+
* therefore never holds up another call. `schema_validate` stays out: it only reads one document, and each document is
|
|
90
|
+
* replaced atomically, so it sees the old text or the new.
|
|
91
|
+
*
|
|
92
|
+
* @public
|
|
93
|
+
*/
|
|
94
|
+
const makeSchemaHandlers = (options = {}) => Effect.gen(function* () {
|
|
95
|
+
const fs = yield* FileSystem.FileSystem;
|
|
96
|
+
const path = yield* Path.Path;
|
|
97
|
+
const cwd = path.resolve(options.cwd ?? ".");
|
|
98
|
+
const root = yield* findRoot(cwd);
|
|
99
|
+
const lane = yield* Exclusive.make;
|
|
100
|
+
const importer = options.importModule !== void 0 ? { importModule: options.importModule } : {};
|
|
101
|
+
const inside = (label, candidate) => Effect.flatMap(PathContainment.isWithin(root, candidate), (within) => within ? Effect.void : Effect.fail(ToolRefusal.refuse(`The ${label} "${ToolFailure.truncate(candidate)}" is outside the workspace root ${root}.`, { hint: `Pass a path under ${root}.` })));
|
|
102
|
+
const configOf = (config) => config !== void 0 ? Effect.as(inside("config", config), { config }) : Effect.match(ConfigLoader.discover(cwd, root), {
|
|
103
|
+
onFailure: () => ({}),
|
|
104
|
+
onSuccess: (found) => ({ config: found })
|
|
105
|
+
});
|
|
106
|
+
const call = (run) => Effect.gen(function* () {
|
|
107
|
+
const warned = yield* Ref.make(void 0);
|
|
108
|
+
return yield* run((event) => event._tag === "Started" && event.versionMismatch !== void 0 ? Ref.set(warned, event.versionMismatch) : Effect.void).pipe(Effect.catch((error) => error._tag === "ToolRefusal" ? Effect.fail(error) : Effect.gen(function* () {
|
|
109
|
+
yield* Effect.logDebug("schema tool refused", { error: error.message });
|
|
110
|
+
return yield* Effect.fail(refusalOf(error, root, yield* Ref.get(warned)));
|
|
111
|
+
})));
|
|
112
|
+
}).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), options.confineStdout ?? ((self) => self));
|
|
113
|
+
const walk = (mode) => (params) => call((onProgress) => Effect.gen(function* () {
|
|
114
|
+
const scoped = yield* configOf(params.config);
|
|
115
|
+
const loaded = yield* ConfigLoader.load({
|
|
116
|
+
cwd: root,
|
|
117
|
+
ceiling: root,
|
|
118
|
+
...scoped.config !== void 0 ? { explicit: scoped.config } : {},
|
|
119
|
+
...importer
|
|
120
|
+
});
|
|
121
|
+
return yield* lane.run(SchemaRun.run({
|
|
122
|
+
mode,
|
|
123
|
+
config: loaded.path,
|
|
124
|
+
...params.drift !== void 0 ? { drift: params.drift } : {},
|
|
125
|
+
...params.onDrift !== void 0 ? { onDrift: params.onDrift } : {},
|
|
126
|
+
force: params.force ?? false,
|
|
127
|
+
versionMismatch: "warn"
|
|
128
|
+
}, {
|
|
129
|
+
cwd: root,
|
|
130
|
+
discoveryCeiling: root,
|
|
131
|
+
importModule: () => Promise.resolve(loaded.config),
|
|
132
|
+
onProgress
|
|
133
|
+
}), { timeout: WALK_TIMEOUT });
|
|
134
|
+
}));
|
|
135
|
+
return {
|
|
136
|
+
schema_check: walk("check"),
|
|
137
|
+
schema_build: walk("build"),
|
|
138
|
+
schema_validate: (params) => call((onProgress) => Effect.gen(function* () {
|
|
139
|
+
yield* inside("payload", params.payload);
|
|
140
|
+
const ref = params.schema ?? Option.getOrUndefined(Option.map(yield* fs.readFileString(path.resolve(root, params.payload)).pipe(Effect.map((text) => payloadRef(text)), Effect.orElseSucceed(() => Option.none())), (payload) => payload.$schema));
|
|
141
|
+
if (ref !== void 0) yield* inside("schema", ref);
|
|
142
|
+
const scoped = yield* configOf(params.config);
|
|
143
|
+
return yield* SchemaValidate.run({
|
|
144
|
+
payload: params.payload,
|
|
145
|
+
...ref !== void 0 ? { schema: ref } : {},
|
|
146
|
+
...scoped,
|
|
147
|
+
versionMismatch: "warn"
|
|
148
|
+
}, {
|
|
149
|
+
cwd: root,
|
|
150
|
+
discoveryCeiling: root,
|
|
151
|
+
...importer,
|
|
152
|
+
onProgress
|
|
153
|
+
});
|
|
154
|
+
}))
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
export { makeSchemaHandlers, schemaBuild, schemaCheck, schemaValidate };
|