@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.
@@ -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
@@ -0,0 +1,5 @@
1
+ import { SilkRuntimeLive } from "./runtime.js";
2
+ import { CURRENT_MCP_VERSION } from "./version.js";
3
+ import { startMcpServer } from "./server.js";
4
+
5
+ export { CURRENT_MCP_VERSION, SilkRuntimeLive, startMcpServer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/mcp",
3
- "version": "0.4.1",
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 };