@savvy-web/mcp 2.7.5 → 3.0.1

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/index.js CHANGED
@@ -1,5 +1,8 @@
1
+ import { BiomeFailed, BiomeUnavailable, EngineError, InvalidArgument, McpToolError, Remediation, WorkspaceNotFound, composeRemediatedMessage, truncateEchoed } from "./errors.js";
2
+ import { SilkMarkdown } from "./markdown.js";
1
3
  import { makeSilkRuntimeLayer } from "./runtime.js";
4
+ import { SilkToolkit, ToolsLayer } from "./toolkit.js";
2
5
  import { CURRENT_MCP_VERSION } from "./version.js";
3
- import { startMcpServer } from "./server.js";
6
+ import { ServerLayer } from "./server.js";
4
7
 
5
- export { CURRENT_MCP_VERSION, makeSilkRuntimeLayer, startMcpServer };
8
+ export { BiomeFailed, BiomeUnavailable, CURRENT_MCP_VERSION, EngineError, InvalidArgument, McpToolError, Remediation, ServerLayer, SilkMarkdown, SilkToolkit, ToolsLayer, WorkspaceNotFound, composeRemediatedMessage, makeSilkRuntimeLayer, truncateEchoed };
@@ -0,0 +1,25 @@
1
+ //#region src/internal/project-root.ts
2
+ /**
3
+ * Pure resolution of the MCP server's project working directory.
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+ /**
8
+ * Resolves the project directory the MCP server should root its runtime in.
9
+ *
10
+ * @remarks
11
+ * Precedence: a non-empty, non-placeholder `argv[0]`, then
12
+ * `env.SAVVY_MCP_PROJECT_DIR`, then `env.CLAUDE_PROJECT_DIR`, then `cwd()`. A
13
+ * `${...}`-shaped `argv[0]` is an unresolved template placeholder (an MCP
14
+ * client that failed to substitute a config variable) and is ignored, as is
15
+ * a whitespace-only value. Takes `argv`/`env`/`cwd` as parameters rather than
16
+ * reading `process` directly so it stays pure and unit-testable; `main.ts` is
17
+ * the sole caller and supplies the real process bindings.
18
+ */
19
+ function resolveProjectDir(argv, env, cwd) {
20
+ const trimmed = argv[0]?.trim();
21
+ return (trimmed !== void 0 && trimmed.length > 0 && !(trimmed.startsWith("${") && trimmed.endsWith("}")) ? trimmed : void 0) ?? env.SAVVY_MCP_PROJECT_DIR ?? env.CLAUDE_PROJECT_DIR ?? cwd();
22
+ }
23
+
24
+ //#endregion
25
+ export { resolveProjectDir };
package/main.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ //#region src/main.d.ts
2
+ /**
3
+ * Owns the `savvy-mcp` process: crash guards, project-root resolution, and the
4
+ * server layer launched over stdio.
5
+ *
6
+ * @remarks
7
+ * No static imports of the server graph — every module reachable from the
8
+ * MCP tool surface is imported dynamically, after the crash guards are
9
+ * registered, so an error during that import (or anything downstream) is
10
+ * caught by `uncaughtException`/`unhandledRejection` rather than crashing
11
+ * before a handler exists.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+ /**
16
+ * Run the savvy MCP server over stdio. Owns the process.
17
+ *
18
+ * @remarks
19
+ * NO static imports of the server graph — see the module remarks.
20
+ *
21
+ * @public
22
+ */
23
+ export declare const main: () => Promise<void>;
24
+ //#endregion
25
+ //# sourceMappingURL=main.d.ts.map
package/main.js ADDED
@@ -0,0 +1,42 @@
1
+ //#region src/main.ts
2
+ /**
3
+ * Owns the `savvy-mcp` process: crash guards, project-root resolution, and the
4
+ * server layer launched over stdio.
5
+ *
6
+ * @remarks
7
+ * No static imports of the server graph — every module reachable from the
8
+ * MCP tool surface is imported dynamically, after the crash guards are
9
+ * registered, so an error during that import (or anything downstream) is
10
+ * caught by `uncaughtException`/`unhandledRejection` rather than crashing
11
+ * before a handler exists.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+ /* v8 ignore start -- process bootstrap; covered by the server-lifecycle e2e and the silk bins e2e */
16
+ const fatal = (label, error) => {
17
+ process.stderr.write(`savvy-mcp: ${label}: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
18
+ process.exit(1);
19
+ };
20
+ /**
21
+ * Run the savvy MCP server over stdio. Owns the process.
22
+ *
23
+ * @remarks
24
+ * NO static imports of the server graph — see the module remarks.
25
+ *
26
+ * @public
27
+ */
28
+ const main = async () => {
29
+ process.on("uncaughtException", (e) => fatal("uncaught exception", e));
30
+ process.on("unhandledRejection", (r) => fatal("unhandled rejection", r));
31
+ const { NodeRuntime, NodeServices } = await import("@effect/platform-node");
32
+ const { Cause, Exit, Layer, Logger, Runtime } = await import("effect");
33
+ const { resolveProjectDir } = await import("./internal/project-root.js");
34
+ const { ServerLayer } = await import("./server.js");
35
+ const cwd = resolveProjectDir(process.argv.slice(2), process.env, () => process.cwd());
36
+ const program = Layer.launch(ServerLayer(cwd).pipe(Layer.provide(NodeServices.layer), Layer.provide(Logger.layer([Logger.consolePretty()])), Layer.provide(Layer.succeed(Logger.LogToStderr, true))));
37
+ NodeRuntime.runMain(program, { teardown: (exit, onExit) => Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause) ? onExit(0) : Runtime.defaultTeardown(exit, onExit) });
38
+ };
39
+ /* v8 ignore stop */
40
+
41
+ //#endregion
42
+ export { main };
package/markdown.js ADDED
@@ -0,0 +1,32 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/markdown.ts
4
+ /**
5
+ * The tool annotation that carries each tool's markdown transcript renderer
6
+ * to the server's registration step.
7
+ *
8
+ * @remarks
9
+ * rc.115's `McpServer.registerToolkit` renders every success as
10
+ * `content: [{ type: "text", text: JSON.stringify(encodedResult) }]` plus
11
+ * `structuredContent` and offers no hook over the text
12
+ * (`unstable/ai/McpServer.ts:1577-1585`). `server.ts` therefore registers the
13
+ * toolkit itself through the public `McpServer.addTool` and reads this
14
+ * annotation to put the tool's markdown projection in `content[0].text`
15
+ * while `structuredContent` stays the typed object — the dual channel every
16
+ * tool description promises. A tool without the annotation falls back to the
17
+ * framework's JSON text.
18
+ *
19
+ * @packageDocumentation
20
+ */
21
+ /**
22
+ * Renders a tool's decoded success value as the markdown transcript an agent
23
+ * reads. Typed over `unknown` because the annotation is read generically at
24
+ * registration; each tool supplies `Schema.decodeUnknownSync(<X>AsMarkdown)`,
25
+ * whose own decoder is the type guard.
26
+ *
27
+ * @public
28
+ */
29
+ var SilkMarkdown = class extends Context.Service()("@savvy-web/mcp/SilkMarkdown") {};
30
+
31
+ //#endregion
32
+ export { SilkMarkdown };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/mcp",
3
- "version": "2.7.5",
3
+ "version": "3.0.1",
4
4
  "private": false,
5
5
  "description": "The savvy MCP server — Silk Suite tooling and library knowledge for coding agents",
6
6
  "homepage": "https://github.com/savvy-web/systems/tree/main/packages/mcp",
@@ -25,6 +25,11 @@
25
25
  "import": "./index.js",
26
26
  "default": "./index.js"
27
27
  },
28
+ "./main": {
29
+ "types": "./main.d.ts",
30
+ "import": "./main.js",
31
+ "default": "./main.js"
32
+ },
28
33
  "./package.json": "./package.json"
29
34
  },
30
35
  "bin": {
@@ -33,11 +38,9 @@
33
38
  "dependencies": {
34
39
  "@effect/platform-node": "4.0.0-rc.115",
35
40
  "@effected/commands": "^0.7.0",
36
- "@effected/git": "^0.15.0",
37
- "@effected/workspaces": "^0.21.0",
38
- "@modelcontextprotocol/sdk": "^1.30.0",
39
- "@savvy-web/silk-effects": "7.5.3",
40
- "effect": "4.0.0-rc.115",
41
- "zod": "^4.5.4"
41
+ "@effected/git": "^0.15.1",
42
+ "@effected/workspaces": "^0.21.1",
43
+ "@savvy-web/silk-effects": "8.0.1",
44
+ "effect": "4.0.0-rc.115"
42
45
  }
43
46
  }
package/runtime.js CHANGED
@@ -1,7 +1,7 @@
1
+ import { Effect, Layer } from "effect";
1
2
  import { ToolDiscovery } from "@effected/commands";
2
3
  import { Workspaces } from "@effected/workspaces";
3
4
  import { ChangesetConfig, ChangesetConfigReader, Changesets, Repos, SilkPublishability, SilkWorkspaceAnalyzer, Turbo } from "@savvy-web/silk-effects";
4
- import { Effect, Layer } from "effect";
5
5
 
6
6
  //#region src/runtime.ts
7
7
  /**
@@ -10,7 +10,7 @@ import { Effect, Layer } from "effect";
10
10
  * {@link makeSilkRuntimeLayer} builds the full service graph for ONE workspace
11
11
  * root: the `@effected/workspaces` kit layers are root-bound at layer build
12
12
  * (single-root by design), so the server resolves its project directory once
13
- * at startup (bin.ts) and builds the layer with that root. The layer still
13
+ * at startup (main.ts) and builds the layer with that root. The layer still
14
14
  * requires the platform services (`FileSystem` + `Path` +
15
15
  * `ChildProcessSpawner`); the host supplies them via `NodeServices.layer`.
16
16
  *
@@ -22,7 +22,7 @@ import { Effect, Layer } from "effect";
22
22
  * `Changesets.BranchAnalyzer`, `Changesets.ConfigInspector`,
23
23
  * `Changesets.ReleasePlanner`, `Changesets.DepsRegen`, `Repos.ReposManager`,
24
24
  * `Repos.ReposConfigStore`, and `Repos.ReposDrift`; requires `ChildProcessSpawner` + `FileSystem`
25
- * + `Path` from the host's platform layer (`NodeServices.layer` in bin.ts).
25
+ * + `Path` from the host's platform layer (`NodeServices.layer` in main.ts).
26
26
  *
27
27
  * @remarks
28
28
  * The kit graph (`Workspaces.layerWithGitAndConfigDependenciesSubprocess`)
package/server.js CHANGED
@@ -1,244 +1,129 @@
1
- import { effectToZodSchema } from "./schema/effect-to-zod.js";
2
- import { BiomeCheckAsMarkdown, BiomeCheckResult, runBiomeCheck } from "./tools/biome-check.js";
3
- import { ChangesetDepsDetectAsMarkdown, ChangesetDepsDetectResult, changesetDepsDetect } from "./tools/changeset-deps-detect.js";
4
- import { ChangesetDepsRegenAsMarkdown, ChangesetDepsRegenResult, changesetDepsRegen } from "./tools/changeset-deps-regen.js";
5
- import { ChangesetInspectAsMarkdown, ChangesetInspectResult, changesetInspect } from "./tools/changeset-inspect.js";
6
- import { ChangesetPreviewAsMarkdown, ChangesetPreviewResult, changesetPreview } from "./tools/changeset-preview.js";
7
- import { ChangesetValidateAsMarkdown, ChangesetValidateResult, changesetValidate } from "./tools/changeset-validate.js";
8
- import { ReposInspectAsMarkdown, ReposInspectResult, reposInspect } from "./tools/repos-inspect.js";
9
- import { ReposManageAsMarkdown, ReposManageResult, reposManage } from "./tools/repos-manage.js";
10
- import { TurboInspectAsMarkdown, TurboInspectResult, turboInspect } from "./tools/turbo-inspect.js";
11
- import { WorkspaceInfoAsMarkdown, WorkspaceInfoResult, workspaceInfo } from "./tools/workspace-info.js";
1
+ import { SilkMarkdown } from "./markdown.js";
2
+ import { makeSilkRuntimeLayer } from "./runtime.js";
3
+ import { SilkToolkit, ToolsLayer } from "./toolkit.js";
12
4
  import { CURRENT_MCP_VERSION } from "./version.js";
13
- import { Schema } from "effect";
14
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
- import { z } from "zod";
5
+ import { Cause, Context, Effect, ErrorReporter, Layer, Option, Result, Schema, Sink, Stream } from "effect";
6
+ import { AiError, McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai";
17
7
 
18
8
  //#region src/server.ts
9
+ const INTERNAL_TOOL_ERROR_MESSAGE = "Tool execution failed due to an internal server error.";
10
+ const toolErrorResult = (message) => new McpSchema.CallToolResult({
11
+ isError: true,
12
+ content: [{
13
+ type: "text",
14
+ text: message
15
+ }]
16
+ });
19
17
  /**
20
- * Constructs the MCP server, registers tools, and connects the stdio
21
- * transport.
22
- *
23
- * @packageDocumentation
18
+ * Hoist a `$ref`-rooted JSON Schema document onto its referenced definition.
19
+ * `Schema.toJsonSchemaDocument` emits `{ $ref: "#/$defs/<id>", $defs }` for
20
+ * any schema annotated with an `identifier` — every result schema here is —
21
+ * and `McpSchema.ToolJsonSchema` needs a `type: "object"` root, so without
22
+ * this no tool would serve an `outputSchema` (rc.115's own `registerToolkit`
23
+ * has the same blind spot). The definitions stay attached for nested refs.
24
24
  */
25
+ const hoistRootRef = (schema) => {
26
+ const ref = schema.$ref;
27
+ const defs = schema.$defs;
28
+ if (typeof ref !== "string" || !ref.startsWith("#/$defs/") || typeof defs !== "object" || defs === null) return schema;
29
+ const target = defs[ref.slice(8)];
30
+ if (typeof target !== "object" || target === null) return schema;
31
+ const { $ref: _ref, ...rest } = schema;
32
+ return {
33
+ ...rest,
34
+ ...target,
35
+ $defs: defs
36
+ };
37
+ };
38
+ /** MCP models `structuredContent` as a JSON object, so a `null` or array encoded result is omitted. */
39
+ const toStructuredContent = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
25
40
  /**
26
- * The `repos_inspect` wire-level `mode` enum. Exported so tests can assert
27
- * the boundary rejects an unknown mode without duplicating the enum's
28
- * member list.
41
+ * Register a toolkit with the running `McpServer`, rendering each success as
42
+ * the tool's markdown transcript in `content[0].text` and the encoded result
43
+ * in `structuredContent`. A port of rc.115's `McpServer.registerToolkit`
44
+ * (`McpServer.ts:1525-1611`) that differs in the success branch only.
45
+ *
46
+ * @remarks
47
+ * `Effect.context<never>()` captures the registration-time services (the
48
+ * handlers' declared dependencies, discharged by the runtime layer) so each
49
+ * call runs against them; `McpServerClient` is provided per call by the
50
+ * framework and is the one service excluded from that capture.
29
51
  */
30
- const ReposInspectModeSchema = z.enum([
31
- "status",
32
- "config",
33
- "drift",
34
- "gitmodules"
35
- ]).describe("status = drift report; config = the full agent brief; drift = five-authority submodule reconciliation; gitmodules = decoded .gitmodules sections.");
36
- /** Wrap a markdown string + structured object in the dual-channel tool result. */
37
- const structuredResult = (text, structured) => ({
38
- content: [{
39
- type: "text",
40
- text
41
- }],
42
- structuredContent: structured
52
+ const registerSilkToolkit = (toolkit) => Effect.gen(function* () {
53
+ const registry = yield* McpServer.McpServer;
54
+ const built = yield* toolkit;
55
+ const services = yield* Effect.context();
56
+ const reportCause = (cause) => Effect.provideContext(ErrorReporter.report(cause), services);
57
+ for (const tool of Object.values(built.tools)) {
58
+ const annotations = tool.annotations;
59
+ const renderMarkdown = Context.getOrUndefined(annotations, SilkMarkdown);
60
+ const isDeclaredFailure = Schema.is(tool.failureSchema);
61
+ const outputJsonSchema = hoistRootRef(Tool.getJsonSchemaFromSchema(tool.successSchema));
62
+ const outputSchema = outputJsonSchema.type === "object" ? yield* Schema.decodeUnknownEffect(McpSchema.ToolJsonSchema)(outputJsonSchema).pipe(Effect.orDie) : void 0;
63
+ const inputSchema = yield* Schema.decodeUnknownEffect(McpSchema.ToolJsonSchema)(Tool.getJsonSchema(tool)).pipe(Effect.orDie);
64
+ const toolMeta = Context.getOrUndefined(annotations, Tool.Meta);
65
+ const description = Tool.getDescription(tool);
66
+ const mcpTool = new McpSchema.Tool({
67
+ name: tool.name,
68
+ ...description === void 0 ? {} : { description },
69
+ inputSchema,
70
+ ...outputSchema === void 0 ? {} : { outputSchema },
71
+ annotations: {
72
+ ...Context.getOption(annotations, Tool.Title).pipe(Option.map((title) => ({ title })), Option.getOrUndefined),
73
+ readOnlyHint: Context.get(annotations, Tool.Readonly),
74
+ destructiveHint: Context.get(annotations, Tool.Destructive),
75
+ idempotentHint: Context.get(annotations, Tool.Idempotent),
76
+ openWorldHint: Context.get(annotations, Tool.OpenWorld)
77
+ },
78
+ ...toolMeta === void 0 ? {} : { _meta: toolMeta }
79
+ });
80
+ yield* registry.addTool({
81
+ tool: mcpTool,
82
+ annotations,
83
+ handle: (payload) => built.handle(tool.name, payload ?? {}).pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption), Effect.map((result) => new McpSchema.CallToolResult({
84
+ isError: false,
85
+ structuredContent: toStructuredContent(result.encodedResult),
86
+ content: [{
87
+ type: "text",
88
+ text: renderMarkdown === void 0 ? JSON.stringify(result.encodedResult) : renderMarkdown(result.result)
89
+ }]
90
+ })), Effect.provideContext(services), Effect.tapCause(Effect.logError), Effect.catchCause((cause) => {
91
+ const failure = Cause.findError(cause);
92
+ if (Result.isFailure(failure)) return Cause.hasDies(cause) ? Effect.as(reportCause(cause), toolErrorResult(INTERNAL_TOOL_ERROR_MESSAGE)) : Effect.failCause(failure.failure);
93
+ const error = failure.success;
94
+ if (AiError.isAiError(error)) {
95
+ const reason = error.reason;
96
+ return reason._tag === "ToolParameterValidationError" ? Effect.fail(new McpSchema.InvalidParams({ message: reason.message })) : Effect.as(reportCause(cause), toolErrorResult(INTERNAL_TOOL_ERROR_MESSAGE));
97
+ }
98
+ const message = isDeclaredFailure(error) && error instanceof Error ? error.message : INTERNAL_TOOL_ERROR_MESSAGE;
99
+ return Effect.as(reportCause(cause), toolErrorResult(message));
100
+ }))
101
+ });
102
+ }
43
103
  });
44
- /** Build the MCP server for the given context, registering tools. */
45
- function buildServer(ctx) {
46
- const server = new McpServer({
47
- name: "savvy-mcp",
48
- version: CURRENT_MCP_VERSION
49
- });
50
- server.registerTool("workspace_info", {
51
- description: "Use when you need the Silk workspace layout: runtime, package manager, and a per-workspace summary (publishability, versioning, tag/release state). Prefer this over running shell commands to inspect the workspace. Returns markdown in content[] and a typed object in structuredContent.",
52
- inputSchema: { cwd: z.optional(z.string()).describe("Workspace root to analyze. Defaults to the server's project dir.") },
53
- outputSchema: effectToZodSchema(WorkspaceInfoResult)
54
- }, async (args) => {
55
- const root = args.cwd ?? ctx.cwd;
56
- const data = await ctx.runtime.runPromise(workspaceInfo(root));
57
- const text = Schema.decodeUnknownSync(WorkspaceInfoAsMarkdown)(data);
58
- return structuredResult(text, data);
59
- });
60
- server.registerTool("turbo_inspect", {
61
- description: "Read-only Turborepo inspection. mode=cache diagnoses why a task's cache is hitting/missing (per-package status plus the exact hash contributors: input files, env vars, external-dep hashes, global hash). mode=graph returns the task graph and critical path. mode=affected lists changed packages and their dependents. Never executes tasks (uses --dry).",
62
- inputSchema: {
63
- mode: z.enum([
64
- "cache",
65
- "graph",
66
- "affected"
67
- ]).describe("Which inspection to run."),
68
- task: z.optional(z.string()).describe("Task name (defaults to build:dev for cache/graph)."),
69
- base: z.optional(z.string()).describe("Base git ref for affected mode."),
70
- cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
71
- },
72
- outputSchema: effectToZodSchema(TurboInspectResult),
73
- annotations: { readOnlyHint: true }
74
- }, async (args) => {
75
- const data = await ctx.runtime.runPromise(turboInspect(args, ctx.cwd));
76
- const text = Schema.decodeUnknownSync(TurboInspectAsMarkdown)(data);
77
- return structuredResult(text, data);
78
- });
79
- server.registerTool("changeset_inspect", {
80
- description: "Read-only changeset analysis for the changeset-manager workflow. mode=branch diffs the current branch against its base and classifies every changed file by owning package (with packagesAffected and the unmapped paths to ask the user about; an unmapped path may carry a machine-readable unmappedHint reason — e.g. a deleted versionFiles/additionalScopes target or a known template mirror — meaning it is probably already accounted for). mode=config surfaces the resolved .changeset/config.json (release surfaces, versionFiles, ignore list). mode=classify maps arbitrary repo-relative paths to their owning package. Prefer this over shelling out to the savvy CLI.",
81
- inputSchema: {
82
- mode: z.enum([
83
- "branch",
84
- "config",
85
- "classify"
86
- ]).describe("Which inspection to run."),
87
- base: z.optional(z.string()).describe("Override the base branch (branch mode only)."),
88
- paths: z.optional(z.array(z.string())).describe("Paths to classify (classify mode only)."),
89
- cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
90
- },
91
- outputSchema: effectToZodSchema(ChangesetInspectResult),
92
- annotations: { readOnlyHint: true }
93
- }, async (args) => {
94
- const data = await ctx.runtime.runPromise(changesetInspect(args, ctx.cwd));
95
- const text = Schema.decodeUnknownSync(ChangesetInspectAsMarkdown)(data);
96
- return structuredResult(text, data);
97
- });
98
- server.registerTool("changeset_validate", {
99
- description: "Read-only validation of changeset files against the section-aware rules. Pass dir (default .changeset). Returns typed diagnostics (file, rule, line, column, message) plus ok/errorCount in structuredContent. Prefer this over shelling out to savvy changeset lint.",
100
- inputSchema: {
101
- dir: z.optional(z.string()).describe("Changeset directory to validate (default .changeset)."),
102
- cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
103
- },
104
- outputSchema: effectToZodSchema(ChangesetValidateResult),
105
- annotations: { readOnlyHint: true }
106
- }, async (args) => {
107
- const data = await ctx.runtime.runPromise(changesetValidate(args, ctx.cwd));
108
- const text = Schema.decodeUnknownSync(ChangesetValidateAsMarkdown)(data);
109
- return structuredResult(text, data);
110
- });
111
- server.registerTool("changeset_deps_detect", {
112
- description: "Read-only preview of the cumulative dependency diff (merge-base -> working tree) per workspace package. Returns each affected package's resolved dependency-table rows (catalog:/workspace: specifiers resolved per side; devDependencies retained) as the exact rows a pure-dependency changeset would carry, plus a coexisting list of untouched prose-only changesets that reference an in-scope package (informational — no need to re-list .changeset/). Does NOT write or delete any file. Prefer this over shelling out to savvy changeset deps detect.",
113
- inputSchema: {
114
- base: z.optional(z.string()).describe("Override the base branch used to compute the merge-base."),
115
- package: z.optional(z.string()).describe("Restrict output to a single workspace package."),
116
- packages: z.optional(z.array(z.string())).describe("Restrict output to these workspace packages (unioned with package)."),
117
- exclude: z.optional(z.array(z.string())).describe("Drop these packages from the output entirely."),
118
- cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
119
- },
120
- outputSchema: effectToZodSchema(ChangesetDepsDetectResult),
121
- annotations: { readOnlyHint: true }
122
- }, async (args) => {
123
- const data = await ctx.runtime.runPromise(changesetDepsDetect(args, ctx.cwd));
124
- const text = Schema.decodeUnknownSync(ChangesetDepsDetectAsMarkdown)(data);
125
- return structuredResult(text, data);
126
- });
127
- server.registerTool("changeset_preview", {
128
- description: "Read-only preview of the next release. Runs the genuine changesets engine over the pending changesets and returns each package's version bump (old -> new) plus the rendered CHANGELOG block (dependency tables included), exactly as it would ship. Does not modify the repo. Prefer this over hand-merging changeset files.",
129
- inputSchema: { cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.") },
130
- outputSchema: effectToZodSchema(ChangesetPreviewResult),
131
- annotations: { readOnlyHint: true }
132
- }, async (args) => {
133
- const data = await ctx.runtime.runPromise(changesetPreview(args, ctx.cwd));
134
- const text = Schema.decodeUnknownSync(ChangesetPreviewAsMarkdown)(data);
135
- return structuredResult(text, data);
136
- });
137
- server.registerTool("changeset_deps_regen", {
138
- description: "Regenerate pure-dependency changesets: delete stale single-package Dependencies-only changesets and write fresh single-package, patch-bump changesets from the cumulative dependency diff (catalog:/workspace: resolved; devDependencies dropped). Mixed changesets (Dependencies plus other content) are left untouched, and the result's coexisting list accounts for untouched prose-only changesets that reference an in-scope package (informational — no need to re-list .changeset/). Set dryRun=true to preview the plan without touching the filesystem. NOTE: without dryRun this tool MUTATES .changeset/*.md (git-reversible). Prefer this over shelling out to savvy changeset deps regen.",
139
- inputSchema: {
140
- base: z.optional(z.string()).describe("Override the base branch used to compute the merge-base."),
141
- package: z.optional(z.string()).describe("Restrict regeneration to a single workspace package."),
142
- packages: z.optional(z.array(z.string())).describe("Restrict regeneration to these workspace packages (unioned with package)."),
143
- exclude: z.optional(z.array(z.string())).describe("Skip these packages entirely: nothing written, existing changesets untouched."),
144
- dryRun: z.optional(z.boolean()).describe("Compute the plan without writing or deleting any file."),
145
- cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
146
- },
147
- outputSchema: effectToZodSchema(ChangesetDepsRegenResult),
148
- annotations: {
149
- destructiveHint: true,
150
- idempotentHint: false
151
- }
152
- }, async (args) => {
153
- const data = await ctx.runtime.runPromise(changesetDepsRegen(args, ctx.cwd));
154
- const text = Schema.decodeUnknownSync(ChangesetDepsRegenAsMarkdown)(data);
155
- return structuredResult(text, data);
156
- });
157
- server.registerTool("repos_inspect", {
158
- title: "Inspect vendored repos",
159
- description: "Read-only: drift report or parsed .repos/config.json manifest with orientation and notes. mode=status is the per-repo drift summary from ReposManager (present/dirty/commit); mode=config is the parsed manifest; mode=drift reconciles all four submodule authorities (manifest, .gitmodules, worktree, git submodule status) and reports every disagreement; mode=gitmodules decodes the raw .gitmodules file's submodule sections.",
160
- inputSchema: {
161
- mode: ReposInspectModeSchema,
162
- cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
163
- },
164
- outputSchema: effectToZodSchema(ReposInspectResult),
165
- annotations: { readOnlyHint: true }
166
- }, async (args) => {
167
- const data = await ctx.runtime.runPromise(reposInspect(args, ctx.cwd));
168
- const text = Schema.decodeUnknownSync(ReposInspectAsMarkdown)(data);
169
- return structuredResult(text, data);
170
- });
171
- server.registerTool("repos_manage", {
172
- title: "Manage vendored repos",
173
- description: "Mutating: sync (initialize/reconcile submodules per the manifest), pin (re-pin a repo to a new ref), add (vendor a new repo), note (add/remove/promote an agent note), remove (unvendor a repo), rename (rename a vendored repo's manifest key and worktree), restore (hard-reset a repo's worktree back to its pinned gitlink commit and re-apply sparse paths — DESTRUCTIVE to uncommitted worktree edits; never run implicitly), or deregister (clear a STALE submodule.<section> registration from the superproject's local git config — the phantom entry repos_inspect drift reports as localRegistrationDivergence with no matching manifest entry; refuses a section outside .repos/ and any section still backing a live manifest entry — canonically named or gitdir-diverged — and touches local config only, so nothing is staged). Pass action plus the fields that action needs: pin needs name+ref; add needs url+ref+purpose (name/sparse/orientation optional — pass orientation back from a preceding remove's removedEntry to make a re-vendor lossless); note needs name+op, plus note (op=add), id (op=remove), or id+into (op=promote); remove needs name; rename needs name (the old name) + newName; restore takes an optional names list — omitted, it restores every dirty repo and reports the clean ones as skipped; given, it restores exactly those repos even if already clean; deregister needs section (the registration name exactly as the drift report states it, e.g. .repos/old-name — no submodule. prefix). A decode failure names the missing field. The pin result's markdown surfaces commitMessage and staleNoteIds — review and commit after pinning. The remove result's markdown surfaces commitMessage, removedNotes and the removed entry's orientation block — promote any durable notes elsewhere, keep the orientation if you intend to re-vendor, then review and commit. The rename result's markdown surfaces commitMessage — review and commit after renaming. The restore result's markdown names exactly what was discarded. The deregister result's markdown lists the config keys the removed section carried — nothing to commit afterwards.",
174
- inputSchema: {
175
- action: z.enum([
176
- "sync",
177
- "pin",
178
- "add",
179
- "note",
180
- "remove",
181
- "rename",
182
- "restore",
183
- "deregister"
184
- ]).describe("Which mutation to perform."),
185
- name: z.optional(z.string()).describe("Repo name (pin, note, remove, rename; optional override for add)."),
186
- newName: z.optional(z.string()).describe("New repo name (rename)."),
187
- ref: z.optional(z.string()).describe("Git ref to pin/vendor to (pin, add)."),
188
- url: z.optional(z.string()).describe("Repo URL to vendor (add)."),
189
- purpose: z.optional(z.string()).describe("One-line purpose for the manifest (add)."),
190
- sparse: z.optional(z.array(z.string())).describe("Sparse-checkout patterns (add)."),
191
- orientation: z.optional(z.object({
192
- layout: z.optional(z.string()),
193
- keyPaths: z.optional(z.record(z.string(), z.string())),
194
- startHere: z.optional(z.string())
195
- })).describe("Orientation block to write (add). Pass back what a preceding remove reported as removedEntry.orientation — add does NOT restore it on its own, so a re-vendor loses it otherwise."),
196
- op: z.optional(z.enum([
197
- "add",
198
- "remove",
199
- "promote"
200
- ])).describe("Note operation (note)."),
201
- note: z.optional(z.string()).describe("Note text (note, op=add)."),
202
- id: z.optional(z.string()).describe("Note id (note, op=remove|promote)."),
203
- into: z.optional(z.enum(["layout", "startHere"])).describe("Orientation target (note, op=promote)."),
204
- names: z.optional(z.array(z.string())).describe("Repo names to restore (restore); omitted restores every dirty repo."),
205
- section: z.optional(z.string()).describe("Stale registration name to clear (deregister), exactly as the drift report states it (e.g. .repos/old-name); the submodule. prefix is implied."),
206
- cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
207
- },
208
- outputSchema: effectToZodSchema(ReposManageResult),
209
- annotations: {
210
- destructiveHint: true,
211
- idempotentHint: false
212
- }
213
- }, async (args) => {
214
- const data = await ctx.runtime.runPromise(reposManage(args, ctx.cwd));
215
- const text = Schema.decodeUnknownSync(ReposManageAsMarkdown)(data);
216
- return structuredResult(text, data);
217
- });
218
- server.registerTool("biome_check", {
219
- description: "Run Biome over a path and get structured diagnostics back. mode=check (default; lint + format + organize-imports) or mode=lint. Set write=true to apply safe fixes (--write), unsafe=true for unsafe fixes (--write --unsafe). Severities match the project's Biome config (what `biome check` reports); set strict=true to surface project warnings as errors, each marked with its originalSeverity. Prefer this over shelling out to biome; the LSP already covers files you've edited. Returns markdown in content[] and a typed object in structuredContent. NOTE: with write/unsafe this tool MUTATES files (git-reversible).",
220
- inputSchema: {
221
- paths: z.optional(z.array(z.string())).describe("Paths to check. Defaults to the whole workspace."),
222
- mode: z.optional(z.enum(["check", "lint"])).describe("check = lint+format+imports (default); lint = lint only."),
223
- write: z.optional(z.boolean()).describe("Apply safe fixes (--write)."),
224
- unsafe: z.optional(z.boolean()).describe("Apply unsafe fixes (--write --unsafe); implies write."),
225
- strict: z.optional(z.boolean()).describe("Report project warnings as errors (marked with originalSeverity). Default: honor project config."),
226
- cwd: z.optional(z.string()).describe("Directory to run from. May be the server's workspace root, a directory inside it, or a git worktree of the SAME repository — a worktree contains the run to that worktree instead of the main checkout. Anything else is rejected.")
227
- },
228
- outputSchema: effectToZodSchema(BiomeCheckResult)
229
- }, async (args) => {
230
- const data = await runBiomeCheck(args, ctx.cwd);
231
- const text = Schema.decodeUnknownSync(BiomeCheckAsMarkdown)(data);
232
- return structuredResult(text, data);
233
- });
234
- return server;
235
- }
236
- /** Build the server and connect it over stdio. */
237
- async function startMcpServer(ctx) {
238
- const server = buildServer(ctx);
239
- const transport = new StdioServerTransport();
240
- await server.connect(transport);
241
- }
104
+ /**
105
+ * The toolkit registration as a layer: {@link registerSilkToolkit} over
106
+ * `McpServer.layer` (the same reference `McpServer.layerStdio` merges, so
107
+ * layer memoization lands the registration on the served instance), with the
108
+ * handlers bound to `cwd`.
109
+ */
110
+ const SilkToolsLayer = (cwd) => Layer.effectDiscard(registerSilkToolkit(SilkToolkit)).pipe(Layer.provide(McpServer.McpServer.layer), Layer.provide(ToolsLayer(cwd)));
111
+ /**
112
+ * The whole server as one layer: the ten-tool toolkit, the silk-effects
113
+ * runtime discharging its dependencies, over `McpServer.layerStdio`.
114
+ *
115
+ * `protocols` lists the newest two adapters, newest first — see gotcha 2 in
116
+ * the module remarks. `Cause.IllegalArgumentError` in `layerStdio`'s error
117
+ * channel is `orDie`d: `protocols` is a static two-element literal, so it is
118
+ * an implementer-time defect, not a runtime condition.
119
+ *
120
+ * @public
121
+ */
122
+ const ServerLayer = (cwd) => SilkToolsLayer(cwd).pipe(Layer.provide(makeSilkRuntimeLayer(cwd)), Layer.provide(McpServer.layerStdio({
123
+ name: "savvy-mcp",
124
+ version: CURRENT_MCP_VERSION,
125
+ protocols: [McpProtocol.v2025_11_25, McpProtocol.v2025_06_18]
126
+ })), Layer.orDie);
242
127
 
243
128
  //#endregion
244
- export { ReposInspectModeSchema, buildServer, startMcpServer };
129
+ export { ServerLayer };
package/toolkit.js ADDED
@@ -0,0 +1,44 @@
1
+ import { biomeCheckTool, handleBiomeCheck } from "./tools/biome-check.js";
2
+ import { changesetDepsDetectTool, handleChangesetDepsDetect } from "./tools/changeset-deps-detect.js";
3
+ import { changesetDepsRegenTool, handleChangesetDepsRegen } from "./tools/changeset-deps-regen.js";
4
+ import { changesetInspectTool, handleChangesetInspect } from "./tools/changeset-inspect.js";
5
+ import { changesetPreviewTool, handleChangesetPreview } from "./tools/changeset-preview.js";
6
+ import { changesetValidateTool, handleChangesetValidate } from "./tools/changeset-validate.js";
7
+ import { handleReposInspect, reposInspectTool } from "./tools/repos-inspect.js";
8
+ import { handleReposManage, reposManageTool } from "./tools/repos-manage.js";
9
+ import { handleTurboInspect, turboInspectTool } from "./tools/turbo-inspect.js";
10
+ import { handleWorkspaceInfo, workspaceInfoTool } from "./tools/workspace-info.js";
11
+ import { Toolkit } from "effect/unstable/ai";
12
+
13
+ //#region src/toolkit.ts
14
+ /**
15
+ * The ten savvy-mcp tools — seven read-only, three mutating (`biome_check`
16
+ * with `write`/`unsafe`, `changeset_deps_regen`, `repos_manage`) — in the
17
+ * order `tools/list` serves them.
18
+ *
19
+ * @public
20
+ */
21
+ const SilkToolkit = Toolkit.make(workspaceInfoTool, turboInspectTool, changesetInspectTool, changesetValidateTool, changesetDepsDetectTool, changesetPreviewTool, changesetDepsRegenTool, reposInspectTool, reposManageTool, biomeCheckTool);
22
+ /**
23
+ * The handler layer. `cwd` — the project directory `main.ts` resolved once at
24
+ * startup — is closed over as every handler's fallback when a call omits its
25
+ * own `cwd`; the handlers' declared service dependencies are discharged by
26
+ * `makeSilkRuntimeLayer(cwd)` in `server.ts`.
27
+ *
28
+ * @public
29
+ */
30
+ const ToolsLayer = (cwd) => SilkToolkit.toLayer({
31
+ workspace_info: (params) => handleWorkspaceInfo(cwd, params),
32
+ turbo_inspect: (params) => handleTurboInspect(cwd, params),
33
+ changeset_inspect: (params) => handleChangesetInspect(cwd, params),
34
+ changeset_validate: (params) => handleChangesetValidate(cwd, params),
35
+ changeset_deps_detect: (params) => handleChangesetDepsDetect(cwd, params),
36
+ changeset_preview: (params) => handleChangesetPreview(cwd, params),
37
+ changeset_deps_regen: (params) => handleChangesetDepsRegen(cwd, params),
38
+ repos_inspect: (params) => handleReposInspect(cwd, params),
39
+ repos_manage: (params) => handleReposManage(cwd, params),
40
+ biome_check: (params) => handleBiomeCheck(cwd, params)
41
+ });
42
+
43
+ //#endregion
44
+ export { SilkToolkit, ToolsLayer };