@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 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 one tool is
19
- * read-only and stateless, so a stray error while serving has no state to
20
- * corrupt and the guard logs and keeps serving, while the same error before
21
- * serving means a broken boot and exits `1`. Nothing here writes to stdout;
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 one tool is
11
- * read-only and stateless, so a stray error while serving has no state to
12
- * corrupt and the guard logs and keeps serving, while the same error before
13
- * serving means a broken boot and exits `1`. Nothing here writes to stdout;
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(options).pipe(Layer.provide(NodeStdio.layer)),
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.13.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": "^0.12.0",
48
- "@effected/kit": "^0.12.0",
49
- "@effected/mcp-kit": "^0.13.0",
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 `identity` toolkit over the kit's
17
- * `McpStdio.layer`. The adapters are listed newest first, as the kit's
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({ identity: () => handleIdentity(options.distribution) });
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,
@@ -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 };
package/version.js CHANGED
@@ -6,6 +6,6 @@
6
6
  *
7
7
  * @public
8
8
  */
9
- const MCP_VERSION = "0.13.0";
9
+ const MCP_VERSION = "0.14.0";
10
10
 
11
11
  export { MCP_VERSION };