@savvy-web/mcp 0.4.1 → 0.4.2
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/bin/savvy-mcp.d.ts +1 -0
- package/index.d.ts +122 -0
- package/index.js +5 -0
- package/package.json +1 -4
- 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 +38 -0
- package/schema/effect-to-zod.js +52 -0
- package/server.js +95 -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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { 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, 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`, and
|
|
97
|
+
* `Turbo.TurboInspector`; requires `CommandExecutor` + `FileSystem` + `Path`
|
|
98
|
+
* from the host's platform layer (`NodeContext.layer` in bin.ts).
|
|
99
|
+
*
|
|
100
|
+
* `TurboInspectorLive` is fed its own `ToolDiscoveryLive`, whose
|
|
101
|
+
* `PackageManagerDetector` + `WorkspaceRoot` requirements are satisfied by
|
|
102
|
+
* {@link DepsLive}; the leftover `CommandExecutor` + `FileSystem` flow up to the
|
|
103
|
+
* host platform layer.
|
|
104
|
+
*/
|
|
105
|
+
declare const SilkRuntimeLive: Layer.Layer<import("@savvy-web/silk-effects").SilkWorkspaceAnalyzer | import("workspaces-effect").WorkspaceRoot | Turbo.TurboInspector, import("workspaces-effect").WorkspaceDiscoveryError, import("@effect/platform/FileSystem").FileSystem | import("@effect/platform/Path").Path | import("@effect/platform/CommandExecutor").CommandExecutor>;
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/server.d.ts
|
|
108
|
+
/** Build the server and connect it over stdio. */
|
|
109
|
+
declare function startMcpServer(ctx: McpContext): Promise<void>;
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/version.d.ts
|
|
112
|
+
/**
|
|
113
|
+
* The package version, replaced at build time. `0.0.0` in dev/test indicates
|
|
114
|
+
* an unbuilt source run. Kept in its own module so both `server.ts` and the
|
|
115
|
+
* `index.ts` barrel can import it without forming an import cycle.
|
|
116
|
+
*
|
|
117
|
+
* @packageDocumentation
|
|
118
|
+
*/
|
|
119
|
+
declare const CURRENT_MCP_VERSION = "0.0.0";
|
|
120
|
+
//#endregion
|
|
121
|
+
export { CURRENT_MCP_VERSION, type DocIndex, type Manifest, type ManifestEntry, type McpContext, type SearchOptions, type SearchResult, SilkRuntimeLive, startMcpServer };
|
|
122
|
+
//# 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.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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",
|
|
@@ -28,9 +28,6 @@
|
|
|
28
28
|
"bin": {
|
|
29
29
|
"savvy-mcp": "bin/savvy-mcp.js"
|
|
30
30
|
},
|
|
31
|
-
"files": [
|
|
32
|
-
"public"
|
|
33
|
-
],
|
|
34
31
|
"dependencies": {
|
|
35
32
|
"@effect/platform": "^0.96.1",
|
|
36
33
|
"@effect/platform-node": "^0.106.0",
|
|
@@ -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,38 @@
|
|
|
1
|
+
import { ChangesetConfigReaderLive, 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
|
+
/**
|
|
26
|
+
* The MCP runtime layer. Provides `SilkWorkspaceAnalyzer`, `WorkspaceRoot`, and
|
|
27
|
+
* `Turbo.TurboInspector`; requires `CommandExecutor` + `FileSystem` + `Path`
|
|
28
|
+
* from the host's platform layer (`NodeContext.layer` in bin.ts).
|
|
29
|
+
*
|
|
30
|
+
* `TurboInspectorLive` is fed its own `ToolDiscoveryLive`, whose
|
|
31
|
+
* `PackageManagerDetector` + `WorkspaceRoot` requirements are satisfied by
|
|
32
|
+
* {@link DepsLive}; the leftover `CommandExecutor` + `FileSystem` flow up to the
|
|
33
|
+
* host platform layer.
|
|
34
|
+
*/
|
|
35
|
+
const SilkRuntimeLive = Layer.mergeAll(SilkWorkspaceAnalyzerLive, WorkspaceRootLive, Turbo.TurboInspectorLive.pipe(Layer.provide(ToolDiscoveryLive))).pipe(Layer.provide(DepsLive));
|
|
36
|
+
|
|
37
|
+
//#endregion
|
|
38
|
+
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,95 @@
|
|
|
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 { DocsSearchResult, DocsSearchResultAsMarkdown, runDocsSearch } from "./tools/docs-search.js";
|
|
5
|
+
import { TurboInspectAsMarkdown, TurboInspectResult, turboInspect } from "./tools/turbo-inspect.js";
|
|
6
|
+
import { WorkspaceInfoAsMarkdown, WorkspaceInfoResult, workspaceInfo } from "./tools/workspace-info.js";
|
|
7
|
+
import { CURRENT_MCP_VERSION } from "./version.js";
|
|
8
|
+
import { Schema } from "effect";
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
//#region src/server.ts
|
|
14
|
+
/**
|
|
15
|
+
* Constructs the MCP server, registers tools and resources, and connects the
|
|
16
|
+
* stdio transport.
|
|
17
|
+
*
|
|
18
|
+
* @packageDocumentation
|
|
19
|
+
*/
|
|
20
|
+
/** Wrap a markdown string + structured object in the dual-channel tool result. */
|
|
21
|
+
const structuredResult = (text, structured) => ({
|
|
22
|
+
content: [{
|
|
23
|
+
type: "text",
|
|
24
|
+
text
|
|
25
|
+
}],
|
|
26
|
+
structuredContent: structured
|
|
27
|
+
});
|
|
28
|
+
/** Build the MCP server for the given context, registering tools + resources. */
|
|
29
|
+
function buildServer(ctx) {
|
|
30
|
+
const server = new McpServer({
|
|
31
|
+
name: "savvy-mcp",
|
|
32
|
+
version: CURRENT_MCP_VERSION
|
|
33
|
+
});
|
|
34
|
+
server.registerTool("workspace_info", {
|
|
35
|
+
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.",
|
|
36
|
+
inputSchema: { cwd: z.optional(z.string()).describe("Workspace root to analyze. Defaults to the server's project dir.") },
|
|
37
|
+
outputSchema: effectToZodSchema(WorkspaceInfoResult)
|
|
38
|
+
}, async (args) => {
|
|
39
|
+
const root = args.cwd ?? ctx.cwd;
|
|
40
|
+
const data = await ctx.runtime.runPromise(workspaceInfo(root));
|
|
41
|
+
return structuredResult(Schema.decodeSync(WorkspaceInfoAsMarkdown)(data), data);
|
|
42
|
+
});
|
|
43
|
+
server.registerTool("silk_docs_search", {
|
|
44
|
+
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.",
|
|
45
|
+
inputSchema: {
|
|
46
|
+
query: z.string().describe("Keywords or a short phrase describing the doc you need."),
|
|
47
|
+
limit: z.optional(z.number()).describe("Max results (default 10)."),
|
|
48
|
+
tier: z.optional(z.enum([
|
|
49
|
+
"standards",
|
|
50
|
+
"packages",
|
|
51
|
+
"guides"
|
|
52
|
+
])).describe("Restrict to one tier.")
|
|
53
|
+
},
|
|
54
|
+
outputSchema: effectToZodSchema(DocsSearchResult),
|
|
55
|
+
annotations: { readOnlyHint: true }
|
|
56
|
+
}, async (args) => {
|
|
57
|
+
const data = runDocsSearch(ctx.docIndex, args.query, {
|
|
58
|
+
...args.limit !== void 0 ? { limit: args.limit } : {},
|
|
59
|
+
...args.tier !== void 0 ? { tier: args.tier } : {}
|
|
60
|
+
}, stderrQueryLogger);
|
|
61
|
+
return structuredResult(Schema.decodeSync(DocsSearchResultAsMarkdown)(data), data);
|
|
62
|
+
});
|
|
63
|
+
server.registerTool("turbo_inspect", {
|
|
64
|
+
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).",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
mode: z.enum([
|
|
67
|
+
"cache",
|
|
68
|
+
"graph",
|
|
69
|
+
"affected"
|
|
70
|
+
]).describe("Which inspection to run."),
|
|
71
|
+
task: z.optional(z.string()).describe("Task name (defaults to build:dev for cache/graph)."),
|
|
72
|
+
base: z.optional(z.string()).describe("Base git ref for affected mode."),
|
|
73
|
+
cwd: z.optional(z.string()).describe("Directory to resolve the workspace root from.")
|
|
74
|
+
},
|
|
75
|
+
outputSchema: effectToZodSchema(TurboInspectResult),
|
|
76
|
+
annotations: { readOnlyHint: true }
|
|
77
|
+
}, async (args) => {
|
|
78
|
+
const data = await ctx.runtime.runPromise(turboInspect(args, ctx.cwd));
|
|
79
|
+
return structuredResult(Schema.decodeSync(TurboInspectAsMarkdown)(data), data);
|
|
80
|
+
});
|
|
81
|
+
registerAllResources(server, {
|
|
82
|
+
manifest: ctx.manifest,
|
|
83
|
+
contentRoot: ctx.contentRoot
|
|
84
|
+
});
|
|
85
|
+
return server;
|
|
86
|
+
}
|
|
87
|
+
/** Build the server and connect it over stdio. */
|
|
88
|
+
async function startMcpServer(ctx) {
|
|
89
|
+
const server = buildServer(ctx);
|
|
90
|
+
const transport = new StdioServerTransport();
|
|
91
|
+
await server.connect(transport);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
//#endregion
|
|
95
|
+
export { startMcpServer };
|
|
@@ -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 };
|