@savvy-web/mcp 0.4.1 → 0.5.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 +1 -0
- package/bin/savvy-mcp.d.ts +1 -0
- package/index.d.ts +123 -0
- package/index.js +5 -0
- package/package.json +6 -8
- package/resources/catalog.js +29 -0
- package/resources/doc-index.js +112 -0
- package/resources/index.js +48 -0
- package/resources/load.js +38 -0
- package/resources/paths.js +23 -0
- package/resources/query-log.js +20 -0
- package/resources/schema.js +40 -0
- package/runtime.js +40 -0
- package/schema/effect-to-zod.js +52 -0
- package/server.js +124 -0
- package/tools/biome-check.js +188 -0
- package/tools/changeset-inspect.js +108 -0
- package/tools/docs-search.js +59 -0
- package/tools/turbo-inspect.js +113 -0
- package/tools/workspace-info.js +92 -0
- package/version.js +12 -0
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@ npx @modelcontextprotocol/inspector savvy-mcp .
|
|
|
44
44
|
- `workspace_info` — returns a flat, structured projection of the workspace analysis: linked and fixed package groups as name arrays plus resolved registry targets. Backed by the same `silk-effects` analyzer the `savvy` CLI uses.
|
|
45
45
|
- `silk_docs_search` — ranks documents in the corpus against a plain keyword or phrase query and returns hits with a normalized confidence score plus a high/medium/low label. It never returns empty: when nothing matches, it falls back to the priority-ordered top results.
|
|
46
46
|
- `turbo_inspect` — read-only Turborepo inspection over `turbo --dry`: diagnose a task's per-package cache hits, derive the task graph or list the packages affected by recent changes. It never runs a task. Backed by the same `silk-effects` `Turbo` inspector an agent would otherwise drive by hand.
|
|
47
|
+
- `changeset_inspect` — read-only changeset analysis for release work: `mode=branch` diffs the current branch against its base and attributes every changed file to its owning package, `mode=config` surfaces the resolved `.changeset/config.json` with its release surfaces, version files and ignore list. It never writes a changeset. Backed by the same `silk-effects` changeset services the `savvy` CLI uses.
|
|
47
48
|
|
|
48
49
|
## Resources
|
|
49
50
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Changesets, SilkWorkspaceAnalyzer, Turbo } from "@savvy-web/silk-effects";
|
|
2
|
+
import { Layer, ManagedRuntime, Schema } from "effect";
|
|
3
|
+
import { WorkspaceDiscoveryError, WorkspaceRoot } from "workspaces-effect";
|
|
4
|
+
//#region src/resources/schema.d.ts
|
|
5
|
+
/** A manifest entry = decoded front-matter + the derived uri + lastModified. */
|
|
6
|
+
declare const ManifestEntry: Schema.Struct<{
|
|
7
|
+
uri: typeof Schema.String;
|
|
8
|
+
lastModified: Schema.optional<typeof Schema.String>;
|
|
9
|
+
id: Schema.filter<typeof Schema.String>;
|
|
10
|
+
title: typeof Schema.NonEmptyString;
|
|
11
|
+
summary: typeof Schema.NonEmptyString;
|
|
12
|
+
tier: Schema.Literal<["standards", "packages", "guides"]>;
|
|
13
|
+
source: Schema.Literal<["hand", "generated"]>;
|
|
14
|
+
status: Schema.optionalWith<Schema.Literal<["draft", "stable", "deprecated"]>, {
|
|
15
|
+
default: () => "stable";
|
|
16
|
+
}>;
|
|
17
|
+
supersededBy: Schema.optional<typeof Schema.String>;
|
|
18
|
+
tags: Schema.Array$<typeof Schema.NonEmptyString>;
|
|
19
|
+
audience: Schema.optionalWith<Schema.Array$<Schema.Literal<["user", "assistant"]>>, {
|
|
20
|
+
default: () => ReadonlyArray<"assistant">;
|
|
21
|
+
}>;
|
|
22
|
+
priority: Schema.optionalWith<Schema.filter<typeof Schema.Number>, {
|
|
23
|
+
default: () => number;
|
|
24
|
+
}>;
|
|
25
|
+
related: Schema.optionalWith<Schema.Array$<typeof Schema.String>, {
|
|
26
|
+
default: () => ReadonlyArray<string>;
|
|
27
|
+
}>;
|
|
28
|
+
}>;
|
|
29
|
+
type ManifestEntry = Schema.Schema.Type<typeof ManifestEntry>;
|
|
30
|
+
declare const Manifest: Schema.Struct<{
|
|
31
|
+
entries: Schema.Array$<Schema.Struct<{
|
|
32
|
+
uri: typeof Schema.String;
|
|
33
|
+
lastModified: Schema.optional<typeof Schema.String>;
|
|
34
|
+
id: Schema.filter<typeof Schema.String>;
|
|
35
|
+
title: typeof Schema.NonEmptyString;
|
|
36
|
+
summary: typeof Schema.NonEmptyString;
|
|
37
|
+
tier: Schema.Literal<["standards", "packages", "guides"]>;
|
|
38
|
+
source: Schema.Literal<["hand", "generated"]>;
|
|
39
|
+
status: Schema.optionalWith<Schema.Literal<["draft", "stable", "deprecated"]>, {
|
|
40
|
+
default: () => "stable";
|
|
41
|
+
}>;
|
|
42
|
+
supersededBy: Schema.optional<typeof Schema.String>;
|
|
43
|
+
tags: Schema.Array$<typeof Schema.NonEmptyString>;
|
|
44
|
+
audience: Schema.optionalWith<Schema.Array$<Schema.Literal<["user", "assistant"]>>, {
|
|
45
|
+
default: () => ReadonlyArray<"assistant">;
|
|
46
|
+
}>;
|
|
47
|
+
priority: Schema.optionalWith<Schema.filter<typeof Schema.Number>, {
|
|
48
|
+
default: () => number;
|
|
49
|
+
}>;
|
|
50
|
+
related: Schema.optionalWith<Schema.Array$<typeof Schema.String>, {
|
|
51
|
+
default: () => ReadonlyArray<string>;
|
|
52
|
+
}>;
|
|
53
|
+
}>>;
|
|
54
|
+
}>;
|
|
55
|
+
type Manifest = Schema.Schema.Type<typeof Manifest>;
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/resources/doc-index.d.ts
|
|
58
|
+
interface SearchResult {
|
|
59
|
+
readonly uri: string;
|
|
60
|
+
readonly title: string;
|
|
61
|
+
readonly summary: string;
|
|
62
|
+
readonly tags: ReadonlyArray<string>;
|
|
63
|
+
readonly tier: ManifestEntry["tier"];
|
|
64
|
+
readonly confidence: number;
|
|
65
|
+
readonly confidenceLabel: "high" | "medium" | "low";
|
|
66
|
+
readonly matchedOn: ReadonlyArray<string>;
|
|
67
|
+
readonly related: ReadonlyArray<string>;
|
|
68
|
+
}
|
|
69
|
+
interface SearchOptions {
|
|
70
|
+
readonly limit?: number;
|
|
71
|
+
readonly tier?: ManifestEntry["tier"];
|
|
72
|
+
}
|
|
73
|
+
declare class DocIndex {
|
|
74
|
+
private readonly fuse;
|
|
75
|
+
private readonly entries;
|
|
76
|
+
private readonly byUri;
|
|
77
|
+
private constructor();
|
|
78
|
+
static fromManifest(manifest: Manifest, bodies: Readonly<Record<string, string>>): DocIndex;
|
|
79
|
+
search(query: string, opts?: SearchOptions): SearchResult[];
|
|
80
|
+
/** Never return empty: surface the top entries (by priority) as low-confidence. */
|
|
81
|
+
private fallback;
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/context.d.ts
|
|
85
|
+
/** The long-lived runtime, the project working directory, and the resource layer. */
|
|
86
|
+
interface McpContext {
|
|
87
|
+
readonly runtime: ManagedRuntime.ManagedRuntime<SilkWorkspaceAnalyzer | WorkspaceRoot | Turbo.TurboInspector | Changesets.BranchAnalyzer | Changesets.ConfigInspector, WorkspaceDiscoveryError>;
|
|
88
|
+
readonly cwd: string;
|
|
89
|
+
readonly docIndex: DocIndex;
|
|
90
|
+
readonly manifest: Manifest;
|
|
91
|
+
readonly contentRoot: string;
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/runtime.d.ts
|
|
95
|
+
/**
|
|
96
|
+
* The MCP runtime layer. Provides `SilkWorkspaceAnalyzer`, `WorkspaceRoot`,
|
|
97
|
+
* `Turbo.TurboInspector`, `Changesets.BranchAnalyzer`, and
|
|
98
|
+
* `Changesets.ConfigInspector`; requires `CommandExecutor` + `FileSystem` +
|
|
99
|
+
* `Path` from the host's platform layer (`NodeContext.layer` in bin.ts).
|
|
100
|
+
*
|
|
101
|
+
* `TurboInspectorLive` is fed its own `ToolDiscoveryLive`, whose
|
|
102
|
+
* `PackageManagerDetector` + `WorkspaceRoot` requirements are satisfied by
|
|
103
|
+
* {@link DepsLive}; the leftover `CommandExecutor` + `FileSystem` flow up to the
|
|
104
|
+
* host platform layer.
|
|
105
|
+
*/
|
|
106
|
+
declare const SilkRuntimeLive: Layer.Layer<import("@savvy-web/silk-effects").SilkWorkspaceAnalyzer | import("workspaces-effect").WorkspaceRoot | Turbo.TurboInspector | Changesets.BranchAnalyzer | Changesets.ConfigInspector, import("workspaces-effect").WorkspaceDiscoveryError, import("@effect/platform/FileSystem").FileSystem | import("@effect/platform/Path").Path | import("@effect/platform/CommandExecutor").CommandExecutor>;
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/server.d.ts
|
|
109
|
+
/** Build the server and connect it over stdio. */
|
|
110
|
+
declare function startMcpServer(ctx: McpContext): Promise<void>;
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/version.d.ts
|
|
113
|
+
/**
|
|
114
|
+
* The package version, replaced at build time. `0.0.0` in dev/test indicates
|
|
115
|
+
* an unbuilt source run. Kept in its own module so both `server.ts` and the
|
|
116
|
+
* `index.ts` barrel can import it without forming an import cycle.
|
|
117
|
+
*
|
|
118
|
+
* @packageDocumentation
|
|
119
|
+
*/
|
|
120
|
+
declare const CURRENT_MCP_VERSION = "0.0.0";
|
|
121
|
+
//#endregion
|
|
122
|
+
export { CURRENT_MCP_VERSION, type DocIndex, type Manifest, type ManifestEntry, type McpContext, type SearchOptions, type SearchResult, SilkRuntimeLive, startMcpServer };
|
|
123
|
+
//# sourceMappingURL=index.d.ts.map
|
package/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@savvy-web/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
|
@@ -23,20 +23,18 @@
|
|
|
23
23
|
".": {
|
|
24
24
|
"types": "./index.d.ts",
|
|
25
25
|
"import": "./index.js"
|
|
26
|
-
}
|
|
26
|
+
},
|
|
27
|
+
"./package.json": "./package.json"
|
|
27
28
|
},
|
|
28
29
|
"bin": {
|
|
29
30
|
"savvy-mcp": "bin/savvy-mcp.js"
|
|
30
31
|
},
|
|
31
|
-
"files": [
|
|
32
|
-
"public"
|
|
33
|
-
],
|
|
34
32
|
"dependencies": {
|
|
35
33
|
"@effect/platform": "^0.96.1",
|
|
36
|
-
"@effect/platform-node": "^0.
|
|
34
|
+
"@effect/platform-node": "^0.107.0",
|
|
37
35
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
|
-
"@savvy-web/silk-effects": "1.0
|
|
39
|
-
"effect": "^3.21.
|
|
36
|
+
"@savvy-web/silk-effects": "1.1.0",
|
|
37
|
+
"effect": "^3.21.3",
|
|
40
38
|
"fuse.js": "^7.4.0",
|
|
41
39
|
"workspaces-effect": "^1.2.0",
|
|
42
40
|
"zod": "^4.4.3"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//#region src/resources/catalog.ts
|
|
2
|
+
const TIER_HEADINGS = [
|
|
3
|
+
["standards", "Standards"],
|
|
4
|
+
["packages", "Packages"],
|
|
5
|
+
["guides", "Guides"]
|
|
6
|
+
];
|
|
7
|
+
function renderCatalogMarkdown(manifest) {
|
|
8
|
+
const lines = [
|
|
9
|
+
"# silk://catalog",
|
|
10
|
+
"",
|
|
11
|
+
"Read this first to orient. To fetch a doc, call `resources/read` with its `silk://` URI.",
|
|
12
|
+
"To search by intent, use the `silk_docs_search` tool.",
|
|
13
|
+
""
|
|
14
|
+
];
|
|
15
|
+
for (const [tier, heading] of TIER_HEADINGS) {
|
|
16
|
+
const entries = manifest.entries.filter((e) => e.tier === tier && e.status !== "deprecated");
|
|
17
|
+
if (entries.length === 0) continue;
|
|
18
|
+
lines.push(`## ${heading}`, "");
|
|
19
|
+
for (const e of entries) {
|
|
20
|
+
const gen = e.source === "generated" ? " (generated)" : "";
|
|
21
|
+
lines.push(`- \`${e.uri}\`${gen} — ${e.title}. load when: ${e.summary}`);
|
|
22
|
+
}
|
|
23
|
+
lines.push("");
|
|
24
|
+
}
|
|
25
|
+
return lines.join("\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
export { renderCatalogMarkdown };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import Fuse from "fuse.js";
|
|
2
|
+
|
|
3
|
+
//#region src/resources/doc-index.ts
|
|
4
|
+
const STOP_WORDS = new Set([
|
|
5
|
+
"how",
|
|
6
|
+
"do",
|
|
7
|
+
"i",
|
|
8
|
+
"the",
|
|
9
|
+
"a",
|
|
10
|
+
"an",
|
|
11
|
+
"to",
|
|
12
|
+
"of",
|
|
13
|
+
"in",
|
|
14
|
+
"for",
|
|
15
|
+
"is",
|
|
16
|
+
"what",
|
|
17
|
+
"when",
|
|
18
|
+
"use"
|
|
19
|
+
]);
|
|
20
|
+
const FUSE_THRESHOLD = .6;
|
|
21
|
+
const confidenceLabel = (confidence) => confidence >= .6 ? "high" : confidence >= .4 ? "medium" : "low";
|
|
22
|
+
const toRanked = (item, score, matches) => ({
|
|
23
|
+
item,
|
|
24
|
+
confidence: 1 - (score ?? 1),
|
|
25
|
+
matchedOn: matches.map((m) => m.key ?? "")
|
|
26
|
+
});
|
|
27
|
+
var DocIndex = class DocIndex {
|
|
28
|
+
fuse;
|
|
29
|
+
entries;
|
|
30
|
+
byUri;
|
|
31
|
+
constructor(fuse, entries, byUri) {
|
|
32
|
+
this.fuse = fuse;
|
|
33
|
+
this.entries = entries;
|
|
34
|
+
this.byUri = byUri;
|
|
35
|
+
}
|
|
36
|
+
static fromManifest(manifest, bodies) {
|
|
37
|
+
const entries = manifest.entries.filter((e) => e.status !== "deprecated").map((e) => ({
|
|
38
|
+
...e,
|
|
39
|
+
body: bodies[e.uri] ?? ""
|
|
40
|
+
}));
|
|
41
|
+
return new DocIndex(new Fuse(entries, {
|
|
42
|
+
useExtendedSearch: true,
|
|
43
|
+
ignoreLocation: true,
|
|
44
|
+
includeScore: true,
|
|
45
|
+
includeMatches: true,
|
|
46
|
+
minMatchCharLength: 2,
|
|
47
|
+
threshold: FUSE_THRESHOLD,
|
|
48
|
+
keys: [
|
|
49
|
+
{
|
|
50
|
+
name: "title",
|
|
51
|
+
weight: .55
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "tags",
|
|
55
|
+
weight: .3
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "summary",
|
|
59
|
+
weight: .12
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "body",
|
|
63
|
+
weight: .03
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
}), entries, new Map(entries.map((e) => [e.uri, e])));
|
|
67
|
+
}
|
|
68
|
+
search(query, opts = {}) {
|
|
69
|
+
const limit = opts.limit ?? 10;
|
|
70
|
+
const tokens = query.toLowerCase().split(/\s+/).filter((t) => t.length >= 2 && !STOP_WORDS.has(t));
|
|
71
|
+
const raw = tokens.length > 0 ? this.fuse.search(tokens.map((t) => `'${t}`).join(" | ")) : [];
|
|
72
|
+
const filteredByTier = opts.tier ? raw.filter((r) => r.item.tier === opts.tier) : raw;
|
|
73
|
+
const ranked = filteredByTier.length > 0 ? filteredByTier.map((r) => toRanked(r.item, r.score, r.matches ?? [])) : this.fallback(opts.tier);
|
|
74
|
+
ranked.sort((a, b) => b.confidence - a.confidence || (b.item.priority ?? .5) - (a.item.priority ?? .5));
|
|
75
|
+
const present = new Set(ranked.map((r) => r.item.uri));
|
|
76
|
+
const seeAlso = [];
|
|
77
|
+
for (const r of ranked.slice(0, 3)) for (const rel of r.item.related) {
|
|
78
|
+
const uri = rel.startsWith("silk://") ? rel : `silk://${rel}`;
|
|
79
|
+
if (present.has(uri)) continue;
|
|
80
|
+
const neighbor = this.byUri.get(uri);
|
|
81
|
+
if (!neighbor) continue;
|
|
82
|
+
present.add(uri);
|
|
83
|
+
seeAlso.push({
|
|
84
|
+
item: neighbor,
|
|
85
|
+
confidence: 0,
|
|
86
|
+
matchedOn: ["related"]
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return [...ranked, ...seeAlso].slice(0, limit).map(({ item, confidence, matchedOn }) => ({
|
|
90
|
+
uri: item.uri,
|
|
91
|
+
title: item.title,
|
|
92
|
+
summary: item.summary,
|
|
93
|
+
tags: item.tags,
|
|
94
|
+
tier: item.tier,
|
|
95
|
+
confidence: Number(confidence.toFixed(3)),
|
|
96
|
+
confidenceLabel: confidenceLabel(confidence),
|
|
97
|
+
matchedOn: [...new Set(matchedOn)].filter(Boolean),
|
|
98
|
+
related: item.related
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
/** Never return empty: surface the top entries (by priority) as low-confidence. */
|
|
102
|
+
fallback(tier) {
|
|
103
|
+
return this.entries.filter((e) => tier ? e.tier === tier : true).slice().sort((a, b) => (b.priority ?? .5) - (a.priority ?? .5)).slice(0, 5).map((item) => ({
|
|
104
|
+
item,
|
|
105
|
+
confidence: 0,
|
|
106
|
+
matchedOn: []
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
//#endregion
|
|
112
|
+
export { DocIndex };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { renderCatalogMarkdown } from "./catalog.js";
|
|
2
|
+
import { readDocBody } from "./load.js";
|
|
3
|
+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
|
|
5
|
+
//#region src/resources/index.ts
|
|
6
|
+
const resourceName = (entry) => `silk_${entry.id.replace(/[^A-Za-z0-9]/g, "_")}`;
|
|
7
|
+
const toSdkAnnotations = (e) => ({
|
|
8
|
+
audience: [...e.audience],
|
|
9
|
+
priority: e.priority,
|
|
10
|
+
...e.lastModified ? { lastModified: e.lastModified } : {}
|
|
11
|
+
});
|
|
12
|
+
function registerAllResources(server, deps) {
|
|
13
|
+
const { manifest, bodies, contentRoot } = deps;
|
|
14
|
+
server.registerResource("silk_catalog", "silk://catalog", {
|
|
15
|
+
title: "Silk resource catalog",
|
|
16
|
+
description: "Read this first. Lists every Silk resource grouped by tier with a 'load when' hint. Fetch a doc with resources/read <uri>; search by intent with silk_docs_search.",
|
|
17
|
+
mimeType: "text/markdown"
|
|
18
|
+
}, async (uri) => ({ contents: [{
|
|
19
|
+
uri: uri.href,
|
|
20
|
+
mimeType: "text/markdown",
|
|
21
|
+
text: renderCatalogMarkdown(manifest)
|
|
22
|
+
}] }));
|
|
23
|
+
const byUri = new Map(manifest.entries.map((e) => [e.uri, e]));
|
|
24
|
+
const readBody = (uri, relPath) => bodies ? bodies[uri] ?? "" : readDocBody(contentRoot, relPath);
|
|
25
|
+
server.registerResource("silk_doc", new ResourceTemplate("silk://{+path}", { list: async () => ({ resources: manifest.entries.filter((e) => e.status !== "deprecated").map((e) => ({
|
|
26
|
+
name: resourceName(e),
|
|
27
|
+
uri: e.uri,
|
|
28
|
+
title: e.title,
|
|
29
|
+
description: e.summary,
|
|
30
|
+
mimeType: "text/markdown",
|
|
31
|
+
annotations: toSdkAnnotations(e)
|
|
32
|
+
})) }) }), {
|
|
33
|
+
title: "Silk documentation resource",
|
|
34
|
+
mimeType: "text/markdown"
|
|
35
|
+
}, async (uri, variables) => {
|
|
36
|
+
const relPath = String(variables.path ?? "");
|
|
37
|
+
const entry = byUri.get(uri.href);
|
|
38
|
+
return { contents: [{
|
|
39
|
+
uri: uri.href,
|
|
40
|
+
mimeType: "text/markdown",
|
|
41
|
+
text: readBody(uri.href, relPath),
|
|
42
|
+
...entry ? { annotations: toSdkAnnotations(entry) } : {}
|
|
43
|
+
}] };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
export { registerAllResources };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { resolveResourcePath } from "./paths.js";
|
|
2
|
+
import { Manifest } from "./schema.js";
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
//#region src/resources/load.ts
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the content root across source/built layouts and load the manifest
|
|
11
|
+
* + a doc body by relative path.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Locate the directory holding `manifest.json` + the content markdown. The
|
|
17
|
+
* built bundle copies `public/` next to the emitted chunk (`<pkg>/public/content`);
|
|
18
|
+
* the source layout keeps it under the package root (`packages/mcp/public/content`).
|
|
19
|
+
* Probe both and fail loudly if neither has a manifest, so a broken build surfaces a
|
|
20
|
+
* clear path list instead of an opaque ENOENT later in {@link loadManifest}.
|
|
21
|
+
*/
|
|
22
|
+
function resolveContentRoot() {
|
|
23
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const candidates = [join(here, "..", "public", "content"), join(here, "..", "..", "public", "content")];
|
|
25
|
+
for (const candidate of candidates) if (existsSync(join(candidate, "manifest.json"))) return candidate;
|
|
26
|
+
throw new Error(`[savvy-mcp] cannot locate public/content with a manifest.json (tried: ${candidates.join(", ")})`);
|
|
27
|
+
}
|
|
28
|
+
function loadManifest(contentRoot) {
|
|
29
|
+
const raw = JSON.parse(readFileSync(join(contentRoot, "manifest.json"), "utf8"));
|
|
30
|
+
return Schema.decodeUnknownSync(Manifest)(raw);
|
|
31
|
+
}
|
|
32
|
+
/** Read a doc body markdown by its relative path (the `{+path}` template var). */
|
|
33
|
+
function readDocBody(contentRoot, relPath) {
|
|
34
|
+
return readFileSync(resolveResourcePath(contentRoot, relPath), "utf8");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
//#endregion
|
|
38
|
+
export { loadManifest, readDocBody, resolveContentRoot };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isAbsolute, normalize, resolve, sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
//#region src/resources/paths.ts
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a relative content path against the content root, appending `.md`
|
|
6
|
+
* (or `index.md` for a trailing slash) and rejecting traversal. Operates on
|
|
7
|
+
* the manifest-resolved relative path, never on URL components.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
function resolveResourcePath(root, relativePath) {
|
|
12
|
+
if (relativePath.includes("\0")) throw new Error("path contains null byte");
|
|
13
|
+
if (isAbsolute(relativePath)) throw new Error("absolute path not allowed");
|
|
14
|
+
const stripped = relativePath.replace(/^\/+/, "");
|
|
15
|
+
const withIndex = stripped === "" || stripped.endsWith("/") ? `${stripped}index` : stripped;
|
|
16
|
+
const resolved = resolve(root, normalize(withIndex.endsWith(".md") ? withIndex : `${withIndex}.md`));
|
|
17
|
+
const rootWithSep = root.endsWith(sep) ? root : `${root}${sep}`;
|
|
18
|
+
if (!resolved.startsWith(rootWithSep) && resolved !== root) throw new Error("path escapes content root");
|
|
19
|
+
return resolved;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
//#endregion
|
|
23
|
+
export { resolveResourcePath };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/resources/query-log.ts
|
|
2
|
+
/** Build the structured stderr line for one search (pure; no I/O). */
|
|
3
|
+
function formatQueryLogLine(query, results) {
|
|
4
|
+
const top = results.slice(0, 3);
|
|
5
|
+
const belowThreshold = results.length === 0 || results.every((r) => r.confidenceLabel === "low");
|
|
6
|
+
const payload = {
|
|
7
|
+
query,
|
|
8
|
+
topResults: top.map((r) => r.uri),
|
|
9
|
+
topConfidence: results[0]?.confidence ?? 0,
|
|
10
|
+
belowThreshold
|
|
11
|
+
};
|
|
12
|
+
return `[savvy-mcp] docs-search ${JSON.stringify(payload)}`;
|
|
13
|
+
}
|
|
14
|
+
/** Write a query log line to stderr. */
|
|
15
|
+
const stderrQueryLogger = (line) => {
|
|
16
|
+
process.stderr.write(`${line}\n`);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
//#endregion
|
|
20
|
+
export { formatQueryLogLine, stderrQueryLogger };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/resources/schema.ts
|
|
4
|
+
/**
|
|
5
|
+
* Front-matter and manifest schemas for the Silk resource corpus.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
/** `id` = the stable URI suffix: tier-prefixed, slash-separated slug segments. */
|
|
10
|
+
const ID_PATTERN = /^(standards|packages|guides)\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\/?$/;
|
|
11
|
+
const Tier = Schema.Literal("standards", "packages", "guides");
|
|
12
|
+
const Audience = Schema.Array(Schema.Literal("user", "assistant"));
|
|
13
|
+
const Source = Schema.Literal("hand", "generated");
|
|
14
|
+
const Status = Schema.Literal("draft", "stable", "deprecated");
|
|
15
|
+
/** YAML front-matter on every content markdown file. */
|
|
16
|
+
const DocFrontMatter = Schema.Struct({
|
|
17
|
+
id: Schema.String.pipe(Schema.pattern(ID_PATTERN)),
|
|
18
|
+
title: Schema.NonEmptyString,
|
|
19
|
+
summary: Schema.NonEmptyString,
|
|
20
|
+
tier: Tier,
|
|
21
|
+
source: Source,
|
|
22
|
+
status: Schema.optionalWith(Status, { default: () => "stable" }),
|
|
23
|
+
supersededBy: Schema.optional(Schema.String),
|
|
24
|
+
tags: Schema.Array(Schema.NonEmptyString),
|
|
25
|
+
audience: Schema.optionalWith(Audience, { default: () => ["assistant"] }),
|
|
26
|
+
priority: Schema.optionalWith(Schema.Number.pipe(Schema.between(0, 1)), { default: () => .5 }),
|
|
27
|
+
related: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] })
|
|
28
|
+
});
|
|
29
|
+
/** A manifest entry = decoded front-matter + the derived uri + lastModified. */
|
|
30
|
+
const ManifestEntry = Schema.Struct({
|
|
31
|
+
...DocFrontMatter.fields,
|
|
32
|
+
uri: Schema.String,
|
|
33
|
+
lastModified: Schema.optional(Schema.String)
|
|
34
|
+
});
|
|
35
|
+
const Manifest = Schema.Struct({ entries: Schema.Array(ManifestEntry) });
|
|
36
|
+
const decodeDocFrontMatter = Schema.decodeUnknown(DocFrontMatter);
|
|
37
|
+
const decodeManifest = Schema.decodeUnknown(Manifest);
|
|
38
|
+
|
|
39
|
+
//#endregion
|
|
40
|
+
export { Manifest };
|
package/runtime.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ChangesetConfigReaderLive, Changesets, SilkWorkspaceAnalyzerLive, TagStrategyLive, ToolDiscoveryLive, Turbo, VersioningStrategyLive } from "@savvy-web/silk-effects";
|
|
2
|
+
import { Layer } from "effect";
|
|
3
|
+
import { WorkspaceRootLive, WorkspacesLive } from "workspaces-effect";
|
|
4
|
+
|
|
5
|
+
//#region src/runtime.ts
|
|
6
|
+
/**
|
|
7
|
+
* Composes the long-lived Effect runtime layer for the MCP server.
|
|
8
|
+
*
|
|
9
|
+
* `SilkRuntimeLive` provides {@link SilkWorkspaceAnalyzer} and {@link WorkspaceRoot}.
|
|
10
|
+
* `WorkspaceRoot` lets the server resolve the workspace root by walking up from
|
|
11
|
+
* its launch directory. The layer still requires `FileSystem` + `Path`; the host
|
|
12
|
+
* (bin.ts) supplies them via `NodeContext.layer`.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The silk-effects dependency set fed to {@link SilkWorkspaceAnalyzerLive}.
|
|
18
|
+
*
|
|
19
|
+
* `WorkspacesLive` supplies the workspace trio plus `DependencyGraph` and
|
|
20
|
+
* `TopologicalSorter` (all required by the analyzer). `VersioningStrategyLive`
|
|
21
|
+
* is provided its own `ChangesetConfigReader` because `Layer.mergeAll` does not
|
|
22
|
+
* cross-feed sibling layers.
|
|
23
|
+
*/
|
|
24
|
+
const DepsLive = Layer.mergeAll(WorkspacesLive, ChangesetConfigReaderLive, TagStrategyLive, VersioningStrategyLive.pipe(Layer.provide(ChangesetConfigReaderLive)));
|
|
25
|
+
const InspectorAndAnalyzerLive = Changesets.BranchAnalyzerLive.pipe(Layer.provideMerge(Changesets.ConfigInspectorLive));
|
|
26
|
+
/**
|
|
27
|
+
* The MCP runtime layer. Provides `SilkWorkspaceAnalyzer`, `WorkspaceRoot`,
|
|
28
|
+
* `Turbo.TurboInspector`, `Changesets.BranchAnalyzer`, and
|
|
29
|
+
* `Changesets.ConfigInspector`; requires `CommandExecutor` + `FileSystem` +
|
|
30
|
+
* `Path` from the host's platform layer (`NodeContext.layer` in bin.ts).
|
|
31
|
+
*
|
|
32
|
+
* `TurboInspectorLive` is fed its own `ToolDiscoveryLive`, whose
|
|
33
|
+
* `PackageManagerDetector` + `WorkspaceRoot` requirements are satisfied by
|
|
34
|
+
* {@link DepsLive}; the leftover `CommandExecutor` + `FileSystem` flow up to the
|
|
35
|
+
* host platform layer.
|
|
36
|
+
*/
|
|
37
|
+
const SilkRuntimeLive = Layer.mergeAll(SilkWorkspaceAnalyzerLive, WorkspaceRootLive, Turbo.TurboInspectorLive.pipe(Layer.provide(ToolDiscoveryLive)), InspectorAndAnalyzerLive).pipe(Layer.provide(DepsLive));
|
|
38
|
+
|
|
39
|
+
//#endregion
|
|
40
|
+
export { SilkRuntimeLive };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { JSONSchema } from "effect";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
//#region src/schema/effect-to-zod.ts
|
|
5
|
+
/**
|
|
6
|
+
* Convert an Effect `Schema.Schema<A, I, never>` to a zod schema:
|
|
7
|
+
* `JSONSchema.make`, then inline every `#/$defs/*` `$ref`, then `z.fromJSONSchema`.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* - Effect-only refinements (custom predicates, brands) erase during the
|
|
11
|
+
* round-trip; declare zod directly if boundary enforcement matters.
|
|
12
|
+
* - The source schema must not use `Schema.suspend` (recursive `$ref`s would
|
|
13
|
+
* make `inlineAllRefs` non-terminating). MCP tool output schemas in this
|
|
14
|
+
* package are non-recursive projections by construction.
|
|
15
|
+
* - The MCP SDK normalises `outputSchema` to an object; non-object results
|
|
16
|
+
* (e.g. a bare union) are wrapped in a permissive object so the SDK accepts
|
|
17
|
+
* them.
|
|
18
|
+
*/
|
|
19
|
+
const effectToZodSchema = (schema) => {
|
|
20
|
+
const inlined = inlineAllRefs(JSONSchema.make(schema));
|
|
21
|
+
const zodSchema = z.fromJSONSchema(inlined);
|
|
22
|
+
if (isObjectLike(zodSchema)) return zodSchema;
|
|
23
|
+
return z.object({}).catchall(z.unknown());
|
|
24
|
+
};
|
|
25
|
+
const isObjectLike = (schema) => schema instanceof z.ZodObject;
|
|
26
|
+
const REF_PREFIX = "#/$defs/";
|
|
27
|
+
/**
|
|
28
|
+
* Replace every `$ref: "#/$defs/X"` node with the contents of `$defs.X`,
|
|
29
|
+
* recursively, and drop the `$defs` table. Assumes acyclic refs.
|
|
30
|
+
*/
|
|
31
|
+
const inlineAllRefs = (root) => {
|
|
32
|
+
const defs = root.$defs ?? {};
|
|
33
|
+
const visit = (value) => {
|
|
34
|
+
if (Array.isArray(value)) return value.map(visit);
|
|
35
|
+
if (value === null || typeof value !== "object") return value;
|
|
36
|
+
const obj = value;
|
|
37
|
+
if (typeof obj.$ref === "string" && obj.$ref.startsWith(REF_PREFIX)) {
|
|
38
|
+
const target = defs[obj.$ref.slice(8)];
|
|
39
|
+
if (target !== void 0) return visit(target);
|
|
40
|
+
}
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
43
|
+
if (k === "$defs") continue;
|
|
44
|
+
out[k] = visit(v);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
};
|
|
48
|
+
return visit(root);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
//#endregion
|
|
52
|
+
export { effectToZodSchema };
|
package/server.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { registerAllResources } from "./resources/index.js";
|
|
2
|
+
import { stderrQueryLogger } from "./resources/query-log.js";
|
|
3
|
+
import { effectToZodSchema } from "./schema/effect-to-zod.js";
|
|
4
|
+
import { BiomeCheckAsMarkdown, BiomeCheckResult, runBiomeCheck } from "./tools/biome-check.js";
|
|
5
|
+
import { ChangesetInspectAsMarkdown, ChangesetInspectResult, changesetInspect } from "./tools/changeset-inspect.js";
|
|
6
|
+
import { DocsSearchResult, DocsSearchResultAsMarkdown, runDocsSearch } from "./tools/docs-search.js";
|
|
7
|
+
import { TurboInspectAsMarkdown, TurboInspectResult, turboInspect } from "./tools/turbo-inspect.js";
|
|
8
|
+
import { WorkspaceInfoAsMarkdown, WorkspaceInfoResult, workspaceInfo } from "./tools/workspace-info.js";
|
|
9
|
+
import { CURRENT_MCP_VERSION } from "./version.js";
|
|
10
|
+
import { Schema } from "effect";
|
|
11
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
//#region src/server.ts
|
|
16
|
+
/**
|
|
17
|
+
* Constructs the MCP server, registers tools and resources, and connects the
|
|
18
|
+
* stdio transport.
|
|
19
|
+
*
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
/** Wrap a markdown string + structured object in the dual-channel tool result. */
|
|
23
|
+
const structuredResult = (text, structured) => ({
|
|
24
|
+
content: [{
|
|
25
|
+
type: "text",
|
|
26
|
+
text
|
|
27
|
+
}],
|
|
28
|
+
structuredContent: structured
|
|
29
|
+
});
|
|
30
|
+
/** Build the MCP server for the given context, registering tools + resources. */
|
|
31
|
+
function buildServer(ctx) {
|
|
32
|
+
const server = new McpServer({
|
|
33
|
+
name: "savvy-mcp",
|
|
34
|
+
version: CURRENT_MCP_VERSION
|
|
35
|
+
});
|
|
36
|
+
server.registerTool("workspace_info", {
|
|
37
|
+
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.",
|
|
38
|
+
inputSchema: { cwd: z.optional(z.string()).describe("Workspace root to analyze. Defaults to the server's project dir.") },
|
|
39
|
+
outputSchema: effectToZodSchema(WorkspaceInfoResult)
|
|
40
|
+
}, async (args) => {
|
|
41
|
+
const root = args.cwd ?? ctx.cwd;
|
|
42
|
+
const data = await ctx.runtime.runPromise(workspaceInfo(root));
|
|
43
|
+
return structuredResult(Schema.decodeSync(WorkspaceInfoAsMarkdown)(data), data);
|
|
44
|
+
});
|
|
45
|
+
server.registerTool("silk_docs_search", {
|
|
46
|
+
description: "Search Silk documentation by intent. Pass plain keywords or a short phrase describing what you need (e.g. 'changeset bump rules'). Returns ranked docs with a confidence label; fetch a hit with resources/read <uri>. Read silk://catalog first to orient.",
|
|
47
|
+
inputSchema: {
|
|
48
|
+
query: z.string().describe("Keywords or a short phrase describing the doc you need."),
|
|
49
|
+
limit: z.optional(z.number()).describe("Max results (default 10)."),
|
|
50
|
+
tier: z.optional(z.enum([
|
|
51
|
+
"standards",
|
|
52
|
+
"packages",
|
|
53
|
+
"guides"
|
|
54
|
+
])).describe("Restrict to one tier.")
|
|
55
|
+
},
|
|
56
|
+
outputSchema: effectToZodSchema(DocsSearchResult),
|
|
57
|
+
annotations: { readOnlyHint: true }
|
|
58
|
+
}, async (args) => {
|
|
59
|
+
const data = runDocsSearch(ctx.docIndex, args.query, {
|
|
60
|
+
...args.limit !== void 0 ? { limit: args.limit } : {},
|
|
61
|
+
...args.tier !== void 0 ? { tier: args.tier } : {}
|
|
62
|
+
}, stderrQueryLogger);
|
|
63
|
+
return structuredResult(Schema.decodeSync(DocsSearchResultAsMarkdown)(data), data);
|
|
64
|
+
});
|
|
65
|
+
server.registerTool("turbo_inspect", {
|
|
66
|
+
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).",
|
|
67
|
+
inputSchema: {
|
|
68
|
+
mode: z.enum([
|
|
69
|
+
"cache",
|
|
70
|
+
"graph",
|
|
71
|
+
"affected"
|
|
72
|
+
]).describe("Which inspection to run."),
|
|
73
|
+
task: z.optional(z.string()).describe("Task name (defaults to build:dev for cache/graph)."),
|
|
74
|
+
base: z.optional(z.string()).describe("Base git ref for affected mode."),
|
|
75
|
+
cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
|
|
76
|
+
},
|
|
77
|
+
outputSchema: effectToZodSchema(TurboInspectResult),
|
|
78
|
+
annotations: { readOnlyHint: true }
|
|
79
|
+
}, async (args) => {
|
|
80
|
+
const data = await ctx.runtime.runPromise(turboInspect(args, ctx.cwd));
|
|
81
|
+
return structuredResult(Schema.decodeSync(TurboInspectAsMarkdown)(data), data);
|
|
82
|
+
});
|
|
83
|
+
server.registerTool("changeset_inspect", {
|
|
84
|
+
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). mode=config surfaces the resolved .changeset/config.json (release surfaces, versionFiles, ignore list). Prefer this over shelling out to the savvy CLI.",
|
|
85
|
+
inputSchema: {
|
|
86
|
+
mode: z.enum(["branch", "config"]).describe("Which inspection to run."),
|
|
87
|
+
base: z.optional(z.string()).describe("Override the base branch (branch mode only)."),
|
|
88
|
+
cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
|
|
89
|
+
},
|
|
90
|
+
outputSchema: effectToZodSchema(ChangesetInspectResult),
|
|
91
|
+
annotations: { readOnlyHint: true }
|
|
92
|
+
}, async (args) => {
|
|
93
|
+
const data = await ctx.runtime.runPromise(changesetInspect(args, ctx.cwd));
|
|
94
|
+
return structuredResult(Schema.decodeSync(ChangesetInspectAsMarkdown)(data), data);
|
|
95
|
+
});
|
|
96
|
+
server.registerTool("biome_check", {
|
|
97
|
+
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). 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).",
|
|
98
|
+
inputSchema: {
|
|
99
|
+
paths: z.optional(z.array(z.string())).describe("Paths to check. Defaults to the whole workspace."),
|
|
100
|
+
mode: z.optional(z.enum(["check", "lint"])).describe("check = lint+format+imports (default); lint = lint only."),
|
|
101
|
+
write: z.optional(z.boolean()).describe("Apply safe fixes (--write)."),
|
|
102
|
+
unsafe: z.optional(z.boolean()).describe("Apply unsafe fixes (--write --unsafe); implies write."),
|
|
103
|
+
cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
|
|
104
|
+
},
|
|
105
|
+
outputSchema: effectToZodSchema(BiomeCheckResult)
|
|
106
|
+
}, async (args) => {
|
|
107
|
+
const data = await runBiomeCheck(args, ctx.cwd);
|
|
108
|
+
return structuredResult(Schema.decodeSync(BiomeCheckAsMarkdown)(data), data);
|
|
109
|
+
});
|
|
110
|
+
registerAllResources(server, {
|
|
111
|
+
manifest: ctx.manifest,
|
|
112
|
+
contentRoot: ctx.contentRoot
|
|
113
|
+
});
|
|
114
|
+
return server;
|
|
115
|
+
}
|
|
116
|
+
/** Build the server and connect it over stdio. */
|
|
117
|
+
async function startMcpServer(ctx) {
|
|
118
|
+
const server = buildServer(ctx);
|
|
119
|
+
const transport = new StdioServerTransport();
|
|
120
|
+
await server.connect(transport);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
//#endregion
|
|
124
|
+
export { startMcpServer };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { Lint } from "@savvy-web/silk-effects";
|
|
2
|
+
import { ParseResult, Schema } from "effect";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { relative, resolve, sep } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
//#region src/tools/biome-check.ts
|
|
8
|
+
/**
|
|
9
|
+
* The `biome_check` MCP tool: a thin proxy that runs Biome over a path with the
|
|
10
|
+
* gitlab reporter, parses it into a typed result, and can apply fixes. Unlike the
|
|
11
|
+
* other savvy-mcp tools this one MUTATES the working tree when `write`/`unsafe`
|
|
12
|
+
* is set — the first intentional exception to the read-only convention.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
/** Normalized diagnostic severity. */
|
|
17
|
+
const BiomeSeverity = Schema.Literal("error", "warning", "info");
|
|
18
|
+
/** A single normalized Biome diagnostic. */
|
|
19
|
+
const BiomeDiagnostic = Schema.Struct({
|
|
20
|
+
file: Schema.String,
|
|
21
|
+
line: Schema.Number,
|
|
22
|
+
severity: BiomeSeverity,
|
|
23
|
+
rule: Schema.String,
|
|
24
|
+
message: Schema.String
|
|
25
|
+
}).annotations({ identifier: "BiomeDiagnostic" });
|
|
26
|
+
/** The `biome_check` tool result. */
|
|
27
|
+
const BiomeCheckResult = Schema.Struct({
|
|
28
|
+
summary: Schema.Struct({
|
|
29
|
+
errors: Schema.Number,
|
|
30
|
+
warnings: Schema.Number
|
|
31
|
+
}),
|
|
32
|
+
diagnostics: Schema.Array(BiomeDiagnostic),
|
|
33
|
+
wrote: Schema.Boolean,
|
|
34
|
+
guidance: Schema.String
|
|
35
|
+
}).annotations({
|
|
36
|
+
identifier: "BiomeCheckResult",
|
|
37
|
+
title: "biome_check result",
|
|
38
|
+
description: "Structured Biome diagnostics, with a flag for whether a --write pass ran."
|
|
39
|
+
});
|
|
40
|
+
/** Guardrail shown to the agent so it fixes code rather than silencing rules. */
|
|
41
|
+
const GUIDANCE = "Fix the actual code. Do NOT disable rules or add config overrides to silence these.";
|
|
42
|
+
/** Shape of a single diagnostic in Biome's `--reporter=gitlab` output. */
|
|
43
|
+
const GitlabDiagnostic = Schema.Struct({
|
|
44
|
+
description: Schema.String,
|
|
45
|
+
check_name: Schema.String,
|
|
46
|
+
severity: Schema.Literal("info", "minor", "major", "critical", "blocker"),
|
|
47
|
+
location: Schema.Struct({
|
|
48
|
+
path: Schema.String,
|
|
49
|
+
lines: Schema.Struct({ begin: Schema.Number })
|
|
50
|
+
})
|
|
51
|
+
});
|
|
52
|
+
const GitlabArray = Schema.Array(GitlabDiagnostic);
|
|
53
|
+
const decodeGitlab = Schema.decodeUnknownSync(GitlabArray);
|
|
54
|
+
/** Map a gitlab severity onto our three-level scale. */
|
|
55
|
+
const mapSeverity = (s) => s === "info" ? "info" : s === "minor" ? "warning" : "error";
|
|
56
|
+
/**
|
|
57
|
+
* Parse Biome `--reporter=gitlab` stdout into normalized diagnostics. Returns []
|
|
58
|
+
* for empty, non-JSON, or shape-mismatched input (never throws).
|
|
59
|
+
*/
|
|
60
|
+
const parseBiomeGitlab = (stdout) => {
|
|
61
|
+
const trimmed = stdout.trim();
|
|
62
|
+
if (!trimmed) return [];
|
|
63
|
+
let raw;
|
|
64
|
+
try {
|
|
65
|
+
raw = JSON.parse(trimmed);
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
let decoded;
|
|
70
|
+
try {
|
|
71
|
+
decoded = decodeGitlab(raw);
|
|
72
|
+
} catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
return decoded.map((d) => ({
|
|
76
|
+
file: d.location.path,
|
|
77
|
+
line: d.location.lines.begin,
|
|
78
|
+
severity: mapSeverity(d.severity),
|
|
79
|
+
rule: d.check_name,
|
|
80
|
+
message: d.description
|
|
81
|
+
}));
|
|
82
|
+
};
|
|
83
|
+
/** Assemble the structured result from normalized diagnostics + the write flag. */
|
|
84
|
+
const buildBiomeResult = (params) => {
|
|
85
|
+
return {
|
|
86
|
+
summary: {
|
|
87
|
+
errors: params.diagnostics.filter((d) => d.severity === "error").length,
|
|
88
|
+
warnings: params.diagnostics.filter((d) => d.severity === "warning").length
|
|
89
|
+
},
|
|
90
|
+
diagnostics: [...params.diagnostics],
|
|
91
|
+
wrote: params.wrote,
|
|
92
|
+
guidance: GUIDANCE
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
/** Render the structured result as markdown. */
|
|
96
|
+
const renderMarkdown = (data) => {
|
|
97
|
+
if (data.diagnostics.length === 0) return `# biome — clean\n\n✅ No remaining diagnostics.${data.wrote ? " A --write pass ran; check `git diff` for what changed." : ""}`;
|
|
98
|
+
const lines = [`# biome — ${data.summary.errors} error(s), ${data.summary.warnings} warning(s)`, ``];
|
|
99
|
+
if (data.wrote) lines.push(`A --write pass ran; the diagnostics below remain unfixed.`, ``);
|
|
100
|
+
for (const d of data.diagnostics) lines.push(`- \`${d.file}:${d.line}\` **${d.severity}** ${d.rule} — ${d.message}`);
|
|
101
|
+
lines.push(``, `---`, data.guidance);
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
};
|
|
104
|
+
/** One-way transform: result to markdown. Encoding back is forbidden. */
|
|
105
|
+
const BiomeCheckAsMarkdown = Schema.transformOrFail(BiomeCheckResult, Schema.String, {
|
|
106
|
+
strict: true,
|
|
107
|
+
decode: (data) => ParseResult.succeed(renderMarkdown(data)),
|
|
108
|
+
encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "BiomeCheckAsMarkdown is one-way: markdown cannot be parsed back."))
|
|
109
|
+
});
|
|
110
|
+
/**
|
|
111
|
+
* Run Biome and return structured diagnostics. When `write`/`unsafe` is set,
|
|
112
|
+
* runs a fix pass first, then a read-only gitlab pass to report what remains.
|
|
113
|
+
*
|
|
114
|
+
* @remarks Resolves the Biome binary via {@link Lint.Biome.findBiome} (global
|
|
115
|
+
* first, then the project's package manager). Throws if Biome is unavailable or
|
|
116
|
+
* exits with status > 1 (Biome itself failed, vs. status 1 = lint issues found).
|
|
117
|
+
*/
|
|
118
|
+
const runBiomeCheck = async (args, fallbackCwd) => {
|
|
119
|
+
const mode = args.mode ?? "check";
|
|
120
|
+
const canonicalize = (p) => {
|
|
121
|
+
try {
|
|
122
|
+
return realpathSync(p);
|
|
123
|
+
} catch {
|
|
124
|
+
return resolve(p);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const root = canonicalize(fallbackCwd);
|
|
128
|
+
const within = (abs) => abs === root || abs.startsWith(`${root}${sep}`);
|
|
129
|
+
const cwd = canonicalize(args.cwd ?? fallbackCwd);
|
|
130
|
+
if (!within(cwd)) throw new Error(`cwd escapes the workspace root: ${args.cwd}`);
|
|
131
|
+
const paths = (args.paths && args.paths.length > 0 ? args.paths : ["."]).map((p) => {
|
|
132
|
+
const lexical = resolve(cwd, p);
|
|
133
|
+
if (!within(canonicalize(lexical))) throw new Error(`path escapes the workspace root: ${p}`);
|
|
134
|
+
return relative(cwd, lexical) || ".";
|
|
135
|
+
});
|
|
136
|
+
const doWrite = Boolean(args.write || args.unsafe);
|
|
137
|
+
const biomeCmd = Lint.Biome.findBiome();
|
|
138
|
+
if (!biomeCmd) throw new Error("Biome not found. Install @biomejs/biome globally (recommended) or as a devDependency.");
|
|
139
|
+
const parts = biomeCmd.split(" ");
|
|
140
|
+
const bin = parts[0];
|
|
141
|
+
const prefix = parts.slice(1);
|
|
142
|
+
const maxBuffer = 64 * 1024 * 1024;
|
|
143
|
+
const timeout = 12e4;
|
|
144
|
+
const killSignal = "SIGKILL";
|
|
145
|
+
let wrote = false;
|
|
146
|
+
if (doWrite) {
|
|
147
|
+
const fix = spawnSync(bin, [
|
|
148
|
+
...prefix,
|
|
149
|
+
mode,
|
|
150
|
+
"--write",
|
|
151
|
+
...args.unsafe ? ["--unsafe"] : [],
|
|
152
|
+
"--no-errors-on-unmatched",
|
|
153
|
+
...paths
|
|
154
|
+
], {
|
|
155
|
+
cwd,
|
|
156
|
+
encoding: "utf8",
|
|
157
|
+
maxBuffer,
|
|
158
|
+
timeout,
|
|
159
|
+
killSignal
|
|
160
|
+
});
|
|
161
|
+
if (fix.error) throw fix.error;
|
|
162
|
+
if ((fix.status ?? 0) > 1) throw new Error(`Biome --write failed (exit ${fix.status}): ${(fix.stderr ?? "").trim() || "unknown error"}`);
|
|
163
|
+
wrote = true;
|
|
164
|
+
}
|
|
165
|
+
const read = spawnSync(bin, [
|
|
166
|
+
...prefix,
|
|
167
|
+
mode,
|
|
168
|
+
"--reporter=gitlab",
|
|
169
|
+
"--error-on-warnings",
|
|
170
|
+
"--no-errors-on-unmatched",
|
|
171
|
+
...paths
|
|
172
|
+
], {
|
|
173
|
+
cwd,
|
|
174
|
+
encoding: "utf8",
|
|
175
|
+
maxBuffer,
|
|
176
|
+
timeout,
|
|
177
|
+
killSignal
|
|
178
|
+
});
|
|
179
|
+
if (read.error) throw read.error;
|
|
180
|
+
if ((read.status ?? 0) > 1) throw new Error(`Biome failed (exit ${read.status}): ${(read.stderr ?? "").trim() || "unknown error"}`);
|
|
181
|
+
return buildBiomeResult({
|
|
182
|
+
diagnostics: parseBiomeGitlab(read.stdout ?? ""),
|
|
183
|
+
wrote
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
//#endregion
|
|
188
|
+
export { BiomeCheckAsMarkdown, BiomeCheckResult, runBiomeCheck };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Changesets } from "@savvy-web/silk-effects";
|
|
2
|
+
import { Effect, ParseResult, Schema } from "effect";
|
|
3
|
+
import { WorkspaceRoot } from "workspaces-effect";
|
|
4
|
+
|
|
5
|
+
//#region src/tools/changeset-inspect.ts
|
|
6
|
+
/**
|
|
7
|
+
* The `changeset_inspect` MCP tool: a discriminated-union result keyed by `mode`
|
|
8
|
+
* (branch | config), each variant embedding the corresponding resolved-output
|
|
9
|
+
* schema from silk-effects' Changesets namespace, plus a one-way markdown
|
|
10
|
+
* transform. Read-only.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
/** Branch-analysis variant. */
|
|
15
|
+
const ChangesetBranchResult = Schema.Struct({
|
|
16
|
+
mode: Schema.Literal("branch"),
|
|
17
|
+
result: Changesets.BranchAnalysisSchema
|
|
18
|
+
}).annotations({ identifier: "ChangesetBranchResult" });
|
|
19
|
+
/** Config-inspection variant. */
|
|
20
|
+
const ChangesetConfigResult = Schema.Struct({
|
|
21
|
+
mode: Schema.Literal("config"),
|
|
22
|
+
result: Changesets.InspectedConfigSchema
|
|
23
|
+
}).annotations({ identifier: "ChangesetConfigResult" });
|
|
24
|
+
/** The `changeset_inspect` tool result — a discriminated union keyed by `mode`. */
|
|
25
|
+
const ChangesetInspectResult = Schema.Union(ChangesetBranchResult, ChangesetConfigResult).annotations({
|
|
26
|
+
identifier: "ChangesetInspectResult",
|
|
27
|
+
title: "changeset_inspect result",
|
|
28
|
+
description: "Read-only changeset analysis grouped by mode (branch | config)."
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* Render a repo/config-derived value as an inert markdown code span. Escapes
|
|
32
|
+
* backticks and backslashes so a crafted filename or package name cannot inject
|
|
33
|
+
* markdown structure into the transcript that an agent reads.
|
|
34
|
+
*/
|
|
35
|
+
const mdInline = (value) => `\`${value.replace(/[`\\]/g, "\\$&")}\``;
|
|
36
|
+
/** Render the structured result as a markdown transcript. */
|
|
37
|
+
const renderMarkdown = (data) => {
|
|
38
|
+
switch (data.mode) {
|
|
39
|
+
case "branch": {
|
|
40
|
+
const r = data.result;
|
|
41
|
+
const lines = [
|
|
42
|
+
`# changeset branch analysis — base ${mdInline(r.baseBranch)}`,
|
|
43
|
+
``,
|
|
44
|
+
`merge base: ${mdInline(r.mergeBaseSha)}`,
|
|
45
|
+
``,
|
|
46
|
+
`## Packages affected`,
|
|
47
|
+
r.packagesAffected.map((p) => `- ${mdInline(p)}`).join("\n") || "(none)",
|
|
48
|
+
``,
|
|
49
|
+
`## Files`
|
|
50
|
+
];
|
|
51
|
+
for (const f of r.files) {
|
|
52
|
+
const owner = f.package ? mdInline(f.package) : "<unmapped>";
|
|
53
|
+
lines.push(`- ${mdInline(f.status)} ${mdInline(f.path)} -> ${owner}`);
|
|
54
|
+
}
|
|
55
|
+
if (r.unmappedFiles.length > 0) {
|
|
56
|
+
lines.push(``, `## Unmapped (ask the user)`);
|
|
57
|
+
for (const p of r.unmappedFiles) lines.push(`- ${mdInline(p)}`);
|
|
58
|
+
}
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
}
|
|
61
|
+
case "config": {
|
|
62
|
+
const r = data.result;
|
|
63
|
+
const lines = [
|
|
64
|
+
`# changeset config — ${mdInline(r.configPath)}`,
|
|
65
|
+
``,
|
|
66
|
+
`base branch: ${mdInline(r.baseBranch)}`,
|
|
67
|
+
`access: ${r.access}`,
|
|
68
|
+
`changelog: ${r.changelog ? mdInline(r.changelog) : "(none)"}`,
|
|
69
|
+
`ignored: ${r.ignore.map(mdInline).join(", ") || "(none)"}`,
|
|
70
|
+
``,
|
|
71
|
+
`## Packages`
|
|
72
|
+
];
|
|
73
|
+
for (const p of r.packages) {
|
|
74
|
+
lines.push(`### ${mdInline(p.name)} (${mdInline(p.version)})`, `- dir: ${mdInline(p.workspaceDir)}`);
|
|
75
|
+
if (p.additionalScopes.length > 0) lines.push(`- additionalScopes: ${p.additionalScopes.map(mdInline).join(", ")}`);
|
|
76
|
+
if (p.versionFiles.length > 0) lines.push(`- versionFiles: ${p.versionFiles.map((v) => mdInline(v.glob)).join(", ")}`);
|
|
77
|
+
}
|
|
78
|
+
if (r.packages.length === 0) lines.push("(none resolved)");
|
|
79
|
+
return lines.join("\n");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/** One-way transform: result to markdown. Encoding back is forbidden. */
|
|
84
|
+
const ChangesetInspectAsMarkdown = Schema.transformOrFail(ChangesetInspectResult, Schema.String, {
|
|
85
|
+
strict: true,
|
|
86
|
+
decode: (data) => ParseResult.succeed(renderMarkdown(data)),
|
|
87
|
+
encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "ChangesetInspectAsMarkdown is one-way: markdown cannot be parsed back."))
|
|
88
|
+
});
|
|
89
|
+
/**
|
|
90
|
+
* Effect handler: resolve the workspace root, then dispatch to the matching
|
|
91
|
+
* Changesets service keyed by `mode`. Mirrors `turboInspect`.
|
|
92
|
+
*/
|
|
93
|
+
const changesetInspect = (args, fallbackCwd) => Effect.gen(function* () {
|
|
94
|
+
const root = yield* (yield* WorkspaceRoot).find(args.cwd ?? fallbackCwd);
|
|
95
|
+
switch (args.mode) {
|
|
96
|
+
case "branch": return {
|
|
97
|
+
mode: "branch",
|
|
98
|
+
result: yield* (yield* Changesets.BranchAnalyzer).analyzeBranch(root, args.base ? { baseBranch: args.base } : void 0)
|
|
99
|
+
};
|
|
100
|
+
case "config": return {
|
|
101
|
+
mode: "config",
|
|
102
|
+
result: yield* (yield* Changesets.ConfigInspector).inspect(root)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
//#endregion
|
|
108
|
+
export { ChangesetInspectAsMarkdown, ChangesetInspectResult, changesetInspect };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { formatQueryLogLine } from "../resources/query-log.js";
|
|
2
|
+
import { ParseResult, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/tools/docs-search.ts
|
|
5
|
+
/**
|
|
6
|
+
* The `silk_docs_search` MCP tool: a read-only Fuse-backed search over the
|
|
7
|
+
* resource corpus. Takes a plain keyword/phrase query (no operator DSL),
|
|
8
|
+
* returns ranked results with normalized higher-is-better confidence, and
|
|
9
|
+
* never returns empty.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
const DocsSearchHit = Schema.Struct({
|
|
14
|
+
uri: Schema.String,
|
|
15
|
+
title: Schema.String,
|
|
16
|
+
summary: Schema.String,
|
|
17
|
+
tags: Schema.Array(Schema.String),
|
|
18
|
+
tier: Schema.Literal("standards", "packages", "guides"),
|
|
19
|
+
confidence: Schema.Number,
|
|
20
|
+
confidenceLabel: Schema.Literal("high", "medium", "low"),
|
|
21
|
+
matchedOn: Schema.Array(Schema.String),
|
|
22
|
+
related: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] })
|
|
23
|
+
}).annotations({ identifier: "DocsSearchHit" });
|
|
24
|
+
const DocsSearchResult = Schema.Struct({
|
|
25
|
+
query: Schema.String,
|
|
26
|
+
results: Schema.Array(DocsSearchHit)
|
|
27
|
+
}).annotations({
|
|
28
|
+
identifier: "DocsSearchResult",
|
|
29
|
+
title: "silk_docs_search result",
|
|
30
|
+
description: "Ranked Silk documentation matches. Fetch a hit with resources/read <uri>."
|
|
31
|
+
});
|
|
32
|
+
const formatDocsSearchMarkdown = (data) => {
|
|
33
|
+
const lines = [`# silk_docs_search: ${data.query}`, ""];
|
|
34
|
+
if (data.results.length === 0) {
|
|
35
|
+
lines.push("No results. Read `silk://catalog` and select a doc by reasoning.");
|
|
36
|
+
return lines.join("\n");
|
|
37
|
+
}
|
|
38
|
+
if (data.results.every((r) => r.confidenceLabel === "low")) lines.push("_No high-confidence match — read `silk://catalog` and pick by reasoning._", "");
|
|
39
|
+
for (const r of data.results) lines.push(`- \`${r.uri}\` (${r.confidenceLabel}) — ${r.title}. ${r.summary}`);
|
|
40
|
+
lines.push("", "Fetch any hit with `resources/read <uri>`.");
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
};
|
|
43
|
+
const DocsSearchResultAsMarkdown = Schema.transformOrFail(DocsSearchResult, Schema.String, {
|
|
44
|
+
strict: true,
|
|
45
|
+
decode: (data) => ParseResult.succeed(formatDocsSearchMarkdown(data)),
|
|
46
|
+
encode: (text, _opts, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "DocsSearchResultAsMarkdown is one-way."))
|
|
47
|
+
});
|
|
48
|
+
/** Run a search against the in-memory index (synchronous; no Effect runtime). */
|
|
49
|
+
const runDocsSearch = (index, query, opts, logger) => {
|
|
50
|
+
const results = index.search(query, opts);
|
|
51
|
+
if (logger) logger(formatQueryLogLine(query, results));
|
|
52
|
+
return {
|
|
53
|
+
query,
|
|
54
|
+
results
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
//#endregion
|
|
59
|
+
export { DocsSearchResult, DocsSearchResultAsMarkdown, runDocsSearch };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Turbo } from "@savvy-web/silk-effects";
|
|
2
|
+
import { Effect, ParseResult, Schema } from "effect";
|
|
3
|
+
import { WorkspaceRoot } from "workspaces-effect";
|
|
4
|
+
|
|
5
|
+
//#region src/tools/turbo-inspect.ts
|
|
6
|
+
/**
|
|
7
|
+
* The `turbo_inspect` MCP tool: a discriminated-union result schema keyed by
|
|
8
|
+
* `mode` (cache | graph | affected), each variant embedding the corresponding
|
|
9
|
+
* `Turbo` result schema from silk-effects, plus a one-way markdown transform.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
/** Cache-diagnosis variant of the `turbo_inspect` result. */
|
|
14
|
+
const TurboCacheResult = Schema.Struct({
|
|
15
|
+
mode: Schema.Literal("cache"),
|
|
16
|
+
result: Turbo.CacheDiagnosis
|
|
17
|
+
}).annotations({ identifier: "TurboCacheResult" });
|
|
18
|
+
/** Task-graph variant of the `turbo_inspect` result. */
|
|
19
|
+
const TurboGraphResult = Schema.Struct({
|
|
20
|
+
mode: Schema.Literal("graph"),
|
|
21
|
+
result: Turbo.TaskGraphResult
|
|
22
|
+
}).annotations({ identifier: "TurboGraphResult" });
|
|
23
|
+
/** Affected-packages variant of the `turbo_inspect` result. */
|
|
24
|
+
const TurboAffectedResult = Schema.Struct({
|
|
25
|
+
mode: Schema.Literal("affected"),
|
|
26
|
+
result: Turbo.AffectedResult
|
|
27
|
+
}).annotations({ identifier: "TurboAffectedResult" });
|
|
28
|
+
/** The `turbo_inspect` tool result — a discriminated union keyed by `mode`. */
|
|
29
|
+
const TurboInspectResult = Schema.Union(TurboCacheResult, TurboGraphResult, TurboAffectedResult).annotations({
|
|
30
|
+
identifier: "TurboInspectResult",
|
|
31
|
+
title: "turbo_inspect result",
|
|
32
|
+
description: "Read-only Turborepo inspection grouped by mode (cache | graph | affected)."
|
|
33
|
+
});
|
|
34
|
+
/** Render the structured result as a markdown transcript. */
|
|
35
|
+
const renderMarkdown = (data) => {
|
|
36
|
+
switch (data.mode) {
|
|
37
|
+
case "cache": {
|
|
38
|
+
const r = data.result;
|
|
39
|
+
const lines = [
|
|
40
|
+
`# turbo cache — ${r.task}`,
|
|
41
|
+
``,
|
|
42
|
+
`**${r.hits}/${r.totalTasks} cached**, ${r.misses} miss(es).`,
|
|
43
|
+
``,
|
|
44
|
+
`## Global hash`,
|
|
45
|
+
`- rootKey: \`${r.global.rootKey}\``,
|
|
46
|
+
`- global files: ${r.global.globalFileCount}`,
|
|
47
|
+
`- external deps hash: \`${r.global.externalDependenciesHash}\``,
|
|
48
|
+
`- internal deps hash: \`${r.global.internalDependenciesHash}\``,
|
|
49
|
+
`- global env: ${r.global.globalEnvVars.join(", ") || "(none)"}`
|
|
50
|
+
];
|
|
51
|
+
if (r.explanations.length > 0) {
|
|
52
|
+
lines.push(``, `## Misses`);
|
|
53
|
+
for (const m of r.explanations) lines.push(`### ${m.package} (\`${m.taskId}\`)`, `- hash: \`${m.hash}\``, `- input files: ${m.inputFileCount}`, `- hashed env: ${m.hashedEnvVars.join(", ") || "(none)"}`, `- external deps hash: \`${m.externalDependenciesHash}\``, `- depends on: ${m.dependsOn.join(", ") || "(none)"}`);
|
|
54
|
+
}
|
|
55
|
+
return lines.join("\n");
|
|
56
|
+
}
|
|
57
|
+
case "graph": {
|
|
58
|
+
const r = data.result;
|
|
59
|
+
return [
|
|
60
|
+
`# turbo task graph${r.task ? ` — ${r.task}` : ""}`,
|
|
61
|
+
``,
|
|
62
|
+
`${r.nodeCount} task node(s).`,
|
|
63
|
+
``,
|
|
64
|
+
`## Critical path`,
|
|
65
|
+
r.criticalPath.map((id, i) => `${i + 1}. \`${id}\``).join("\n") || "(empty)"
|
|
66
|
+
].join("\n");
|
|
67
|
+
}
|
|
68
|
+
case "affected": {
|
|
69
|
+
const r = data.result;
|
|
70
|
+
return [
|
|
71
|
+
`# turbo affected — base ${r.base}`,
|
|
72
|
+
``,
|
|
73
|
+
`## Changed packages`,
|
|
74
|
+
r.packages.map((p) => `- ${p}`).join("\n") || "(none)",
|
|
75
|
+
``,
|
|
76
|
+
`## Dependents`,
|
|
77
|
+
r.dependents.map((p) => `- ${p}`).join("\n") || "(none)"
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
/** One-way transform: result to markdown. Encoding back is forbidden. */
|
|
83
|
+
const TurboInspectAsMarkdown = Schema.transformOrFail(TurboInspectResult, Schema.String, {
|
|
84
|
+
strict: true,
|
|
85
|
+
decode: (data) => ParseResult.succeed(renderMarkdown(data)),
|
|
86
|
+
encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "TurboInspectAsMarkdown is one-way: markdown cannot be parsed back."))
|
|
87
|
+
});
|
|
88
|
+
/**
|
|
89
|
+
* Effect handler: resolve the workspace root by walking up from the requested
|
|
90
|
+
* directory, then dispatch to the matching {@link Turbo.TurboInspector} method
|
|
91
|
+
* keyed by `mode`. Mirrors `workspaceInfo`'s `WorkspaceRoot.find` resolution.
|
|
92
|
+
*/
|
|
93
|
+
const turboInspect = (args, fallbackCwd) => Effect.gen(function* () {
|
|
94
|
+
const root = yield* (yield* WorkspaceRoot).find(args.cwd ?? fallbackCwd);
|
|
95
|
+
const inspector = yield* Turbo.TurboInspector;
|
|
96
|
+
switch (args.mode) {
|
|
97
|
+
case "cache": return {
|
|
98
|
+
mode: "cache",
|
|
99
|
+
result: yield* inspector.diagnoseCache(args.task ?? "build:dev", root)
|
|
100
|
+
};
|
|
101
|
+
case "graph": return {
|
|
102
|
+
mode: "graph",
|
|
103
|
+
result: yield* inspector.taskGraph(root, args.task)
|
|
104
|
+
};
|
|
105
|
+
case "affected": return {
|
|
106
|
+
mode: "affected",
|
|
107
|
+
result: yield* inspector.affected(root, args.base)
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
//#endregion
|
|
113
|
+
export { TurboInspectAsMarkdown, TurboInspectResult, turboInspect };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { SilkWorkspaceAnalyzer } from "@savvy-web/silk-effects";
|
|
2
|
+
import { Effect, ParseResult, Schema } from "effect";
|
|
3
|
+
import { WorkspaceRoot } from "workspaces-effect";
|
|
4
|
+
|
|
5
|
+
//#region src/tools/workspace-info.ts
|
|
6
|
+
/** A flattened, non-recursive summary of one analyzed workspace. */
|
|
7
|
+
const WorkspaceSummary = Schema.Struct({
|
|
8
|
+
name: Schema.String,
|
|
9
|
+
version: Schema.String,
|
|
10
|
+
path: Schema.String,
|
|
11
|
+
root: Schema.Boolean,
|
|
12
|
+
publishable: Schema.Boolean,
|
|
13
|
+
targets: Schema.Array(Schema.String).annotations({ description: "Publish registry URLs." }),
|
|
14
|
+
versioned: Schema.Boolean,
|
|
15
|
+
tagged: Schema.Boolean,
|
|
16
|
+
released: Schema.Boolean,
|
|
17
|
+
linked: Schema.Array(Schema.String).annotations({ description: "Names of linked workspaces." }),
|
|
18
|
+
fixed: Schema.Array(Schema.String).annotations({ description: "Names of fixed-group siblings." })
|
|
19
|
+
}).annotations({ identifier: "WorkspaceSummary" });
|
|
20
|
+
/** The `workspace_info` tool result — a projection of `WorkspaceAnalysis`. */
|
|
21
|
+
const WorkspaceInfoResult = Schema.Struct({
|
|
22
|
+
root: Schema.String,
|
|
23
|
+
runtime: Schema.Literal("node", "bun"),
|
|
24
|
+
packageManager: Schema.Struct({
|
|
25
|
+
type: Schema.Literal("npm", "pnpm", "yarn", "bun"),
|
|
26
|
+
version: Schema.optional(Schema.String)
|
|
27
|
+
}),
|
|
28
|
+
workspaceCount: Schema.Number,
|
|
29
|
+
workspaces: Schema.Array(WorkspaceSummary)
|
|
30
|
+
}).annotations({
|
|
31
|
+
identifier: "WorkspaceInfoResult",
|
|
32
|
+
title: "workspace_info result",
|
|
33
|
+
description: "Structured snapshot of the Silk workspace: runtime, package manager, and a per-workspace summary (publishability, versioning, tag/release state, linked/fixed relations)."
|
|
34
|
+
});
|
|
35
|
+
const toSummary = (w) => ({
|
|
36
|
+
name: w.name,
|
|
37
|
+
version: w.version.current,
|
|
38
|
+
path: w.path,
|
|
39
|
+
root: w.root,
|
|
40
|
+
publishable: w.publishable,
|
|
41
|
+
targets: w.targets.map((t) => t.registry),
|
|
42
|
+
versioned: w.versioned,
|
|
43
|
+
tagged: w.tagged,
|
|
44
|
+
released: w.released,
|
|
45
|
+
linked: w.linked.map((l) => l.name),
|
|
46
|
+
fixed: w.fixed.map((f) => f.name)
|
|
47
|
+
});
|
|
48
|
+
/** Project a full `WorkspaceAnalysis` to the flat tool result. */
|
|
49
|
+
const toWorkspaceInfoResult = (analysis) => ({
|
|
50
|
+
root: analysis.root,
|
|
51
|
+
runtime: analysis.runtime,
|
|
52
|
+
packageManager: {
|
|
53
|
+
type: analysis.packageManager.type,
|
|
54
|
+
...analysis.packageManager.version !== void 0 ? { version: analysis.packageManager.version } : {}
|
|
55
|
+
},
|
|
56
|
+
workspaceCount: analysis.workspaces.length,
|
|
57
|
+
workspaces: analysis.workspaces.map(toSummary)
|
|
58
|
+
});
|
|
59
|
+
/** Render the structured result as a markdown transcript. */
|
|
60
|
+
const formatWorkspaceInfoMarkdown = (data) => {
|
|
61
|
+
const pm = data.packageManager.version ? `${data.packageManager.type}@${data.packageManager.version}` : data.packageManager.type;
|
|
62
|
+
const lines = [
|
|
63
|
+
`# Workspace: ${data.root}`,
|
|
64
|
+
"",
|
|
65
|
+
`- runtime: ${data.runtime}`,
|
|
66
|
+
`- package manager: ${pm}`,
|
|
67
|
+
`- workspaces: ${data.workspaceCount}`,
|
|
68
|
+
"",
|
|
69
|
+
"| name | version | publishable | versioned | tagged | released |",
|
|
70
|
+
"| --- | --- | --- | --- | --- | --- |"
|
|
71
|
+
];
|
|
72
|
+
for (const w of data.workspaces) lines.push(`| ${w.name} | ${w.version} | ${w.publishable} | ${w.versioned} | ${w.tagged} | ${w.released} |`);
|
|
73
|
+
return lines.join("\n");
|
|
74
|
+
};
|
|
75
|
+
/** One-way transform: result to markdown. Encoding back is forbidden. */
|
|
76
|
+
const WorkspaceInfoAsMarkdown = Schema.transformOrFail(WorkspaceInfoResult, Schema.String, {
|
|
77
|
+
strict: true,
|
|
78
|
+
decode: (data) => ParseResult.succeed(formatWorkspaceInfoMarkdown(data)),
|
|
79
|
+
encode: (text, _options, ast) => ParseResult.fail(new ParseResult.Forbidden(ast, text, "WorkspaceInfoAsMarkdown is one-way: markdown cannot be parsed back."))
|
|
80
|
+
});
|
|
81
|
+
/**
|
|
82
|
+
* Effect handler: resolve the workspace root by walking up from `base`, analyze
|
|
83
|
+
* that root, and project to the tool result. Fails with `WorkspaceRootNotFoundError`
|
|
84
|
+
* when `base` is not inside a workspace.
|
|
85
|
+
*/
|
|
86
|
+
const workspaceInfo = (base) => Effect.gen(function* () {
|
|
87
|
+
const root = yield* (yield* WorkspaceRoot).find(base);
|
|
88
|
+
return toWorkspaceInfoResult(yield* (yield* SilkWorkspaceAnalyzer).analyze(root));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
//#endregion
|
|
92
|
+
export { WorkspaceInfoAsMarkdown, WorkspaceInfoResult, workspaceInfo };
|
package/version.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/version.ts
|
|
2
|
+
/**
|
|
3
|
+
* The package version, replaced at build time. `0.0.0` in dev/test indicates
|
|
4
|
+
* an unbuilt source run. Kept in its own module so both `server.ts` and the
|
|
5
|
+
* `index.ts` barrel can import it without forming an import cycle.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
const CURRENT_MCP_VERSION = "0.0.0";
|
|
10
|
+
|
|
11
|
+
//#endregion
|
|
12
|
+
export { CURRENT_MCP_VERSION };
|