@savvy-web/mcp 2.7.5 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/bin/savvy-mcp.js +4 -28
- package/errors.js +181 -0
- package/index.d.ts +695 -18
- package/index.js +5 -2
- package/internal/project-root.js +25 -0
- package/main.d.ts +25 -0
- package/main.js +42 -0
- package/markdown.js +32 -0
- package/package.json +8 -5
- package/runtime.js +3 -3
- package/server.js +119 -234
- package/toolkit.js +44 -0
- package/tools/biome-check.js +64 -11
- package/tools/changeset-deps-detect.js +27 -2
- package/tools/changeset-deps-regen.js +28 -2
- package/tools/changeset-inspect.js +34 -2
- package/tools/changeset-preview.js +21 -2
- package/tools/changeset-validate.js +28 -2
- package/tools/repos-inspect.js +38 -2
- package/tools/repos-manage.js +58 -2
- package/tools/turbo-inspect.js +27 -2
- package/tools/workspace-info.js +25 -2
- package/version.js +1 -1
- package/schema/effect-to-zod.js +0 -205
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 {
|
|
6
|
+
import { ServerLayer } from "./server.js";
|
|
4
7
|
|
|
5
|
-
export { CURRENT_MCP_VERSION, makeSilkRuntimeLayer,
|
|
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": "
|
|
3
|
+
"version": "3.0.0",
|
|
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": {
|
|
@@ -35,9 +40,7 @@
|
|
|
35
40
|
"@effected/commands": "^0.7.0",
|
|
36
41
|
"@effected/git": "^0.15.0",
|
|
37
42
|
"@effected/workspaces": "^0.21.0",
|
|
38
|
-
"@
|
|
39
|
-
"
|
|
40
|
-
"effect": "4.0.0-rc.115",
|
|
41
|
-
"zod": "^4.5.4"
|
|
43
|
+
"@savvy-web/silk-effects": "8.0.0",
|
|
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 (
|
|
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
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 "
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
27
|
-
* the
|
|
28
|
-
*
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 {
|
|
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 };
|