diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d10db54..ee962a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,9 @@ jobs: - name: Smoke test CLI run: node packages/cli/dist/index.js --version + - name: Smoke test MCP server + run: node packages/cli/test/mcp-smoke.mjs + - name: Build web run: pnpm build:web diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 950bef8..c105279 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -44,6 +44,9 @@ jobs: - name: Smoke test CLI run: node packages/cli/dist/index.js --version + - name: Smoke test MCP server + run: node packages/cli/test/mcp-smoke.mjs + - name: Upgrade npm for OIDC trusted publishing run: npm install -g npm@latest diff --git a/packages/cli/package.json b/packages/cli/package.json index 3f3483d..715a1bc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,5 +1,6 @@ { "name": "@flaid/devmap", + "mcpName": "io.github.itsflaid/devmap", "version": "0.8.4", "description": "CLI that maps your codebase into a reusable context layer for developers and AI agents: entry points, critical files, features and routes for JS/TS projects.", "bin": { diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index cbb332b..34d643f 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -9,7 +9,7 @@ import { import { buildExplainMessages } from "../ai/prompts.js"; import type { AiClient } from "../ai/types.js"; import type { FileIndexEntry, ProjectMap } from "../analyzers/pipeline/index.js"; -import { readSnapshotOrThrow } from "../cache/snapshot.js"; +import { readSnapshotOrThrow, type LoadedSnapshot } from "../cache/snapshot.js"; import { resolveEffectiveConfig, type DevmapConfig } from "../utils/config.js"; import { DevmapError } from "../utils/errors.js"; import { output, withJsonOutput } from "../utils/output.js"; @@ -20,6 +20,11 @@ export type ExplainOptions = { json?: boolean; write?: boolean; projectRoot?: string; + /** + * A snapshot the caller already loaded (long-lived front-ends cache it). + * When set, the snapshot is not re-read from disk. + */ + loaded?: LoadedSnapshot; }; export type ExplainDependencies = { @@ -74,7 +79,7 @@ export async function runExplain( dependencies: ExplainDependencies ): Promise { const projectRoot = resolve(options.projectRoot ?? "."); - const snapshot = await readSnapshotOrThrow(projectRoot); + const snapshot = options.loaded?.snapshot ?? await readSnapshotOrThrow(projectRoot); const loadConfig = dependencies.loadConfig ?? (() => resolveEffectiveConfig(projectRoot)); diff --git a/packages/cli/src/mcp/server.ts b/packages/cli/src/mcp/server.ts index 9fc7b78..025fdef 100644 --- a/packages/cli/src/mcp/server.ts +++ b/packages/cli/src/mcp/server.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; +import { runExplain, type ExplainDependencies } from "../commands/explain.js"; import { runFlow } from "../commands/flow.js"; import { runMap } from "../commands/map.js"; import { DevmapError } from "../utils/errors.js"; @@ -33,6 +34,9 @@ const SERVER_INSTRUCTIONS = [ "Prompts devmap_onboard and devmap_plan_change describe ready-made workflows." ].join(" "); +const EXPLAIN_NOTE = + "devmap_explain sends excerpts of the project's source files to the user's configured AI provider, so it only exists when the server was started with --allow-ai."; + const DEFAULT_ANALYZE_TIMEOUT_SECONDS = 120; const DEFAULT_HEARTBEAT_MS = 5_000; @@ -55,6 +59,8 @@ export type DevmapServerOptions = { refresher?: Refresher; /** Inject the analysis function (tests). */ analyze?: AnalyzeFn; + /** Inject the AI config/client used by devmap_explain (tests). */ + explainDependencies?: ExplainDependencies; /** Interval of progress notifications while devmap_analyze runs. */ heartbeatMs?: number; }; @@ -376,6 +382,49 @@ export function createDevmapServer(options: DevmapServerOptions): DevmapServer { } ); + if (options.allowAi) { + server.registerTool( + "devmap_explain", + { + title: "Explain code with AI", + description: + "Ask the user's configured AI provider to explain what a file, feature or top-level function does, " + + "using DevMap's index to pick the relevant code. Prefer devmap_map, devmap_flow and devmap_impact for structure; " + + "use this for \"what does this do and why\". " + + EXPLAIN_NOTE + + " Requires a provider configured with `devmap init`. Spends the user's AI quota.", + inputSchema: { + target: z.string().min(1).describe("A project-relative file path, a feature name, or a top-level function name.") + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true + } + }, + ({ target }) => respond(async () => { + const loaded = await provider.get(); + const result = await withJsonOutput(() => runExplain( + target, + { projectRoot, json: true, write: false, loaded }, + options.explainDependencies ?? {} + )); + const answer = truncateText(result.answer); + return { + mode: result.mode, + target: result.target, + ...(result.resolvedFile ? { resolvedFile: result.resolvedFile } : {}), + answer: answer.text, + truncated: answer.truncated, + model: result.model, + contextFiles: result.contextFiles, + snapshot: snapshotMeta(loaded) + }; + }) + ); + } + // ── resources ──────────────────────────────────────────────────────────── server.registerResource( diff --git a/packages/cli/test/mcp-explain.test.ts b/packages/cli/test/mcp-explain.test.ts new file mode 100644 index 0000000..5569c7e --- /dev/null +++ b/packages/cli/test/mcp-explain.test.ts @@ -0,0 +1,265 @@ +import assert from "node:assert/strict"; +import { cp, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { + AiClient, + AiCompletionRequest, + AiCompletionResult +} from "../src/ai/types.js"; +import { createProjectMap } from "../src/analyzers/pipeline/projectMap.js"; +import { saveSnapshot } from "../src/cache/snapshot.js"; +import type { ExplainDependencies } from "../src/commands/explain.js"; +import { createDevmapServer, MAX_TEXT_CHARS } from "../src/mcp/server.js"; + +const testDirectory = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(testDirectory, ".."); +const nextFixture = join(testDirectory, "fixtures", "nextjs-project"); + +async function copyFixture(): Promise { + const projectRoot = await mkdtemp(join(tmpdir(), "devmap-mcp-explain-")); + await cp(nextFixture, projectRoot, { recursive: true }); + await saveSnapshot(projectRoot, await createProjectMap(projectRoot)); + return projectRoot; +} + +function fakeAi(content = "It validates the session and returns the current user."): { + dependencies: ExplainDependencies; + requests: AiCompletionRequest[]; +} { + const requests: AiCompletionRequest[] = []; + const client: AiClient = { + async complete(request): Promise { + requests.push(request); + return { + content, + model: request.model, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 } + }; + } + }; + return { + requests, + dependencies: { + loadConfig: async () => ({ provider: "groq", apiKey: "gsk_fixture", model: "auto" }), + createAiClient: () => client + } + }; +} + +async function connect( + projectRoot: string, + options: { allowAi?: boolean; explainDependencies?: ExplainDependencies } = {} +): Promise<{ client: Client; close: () => Promise }> { + const { server } = createDevmapServer({ projectRoot, ...options }); + const client = new Client({ name: "test-client", version: "0.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return { + client, + close: async () => { + await client.close(); + await server.close(); + } + }; +} + +async function callTool( + client: Client, + name: string, + args: Record = {} +): Promise<{ isError: boolean; data: any }> { + const result = (await client.callTool({ name, arguments: args })) as { + isError?: boolean; + content: Array<{ text: string }>; + }; + return { isError: result.isError === true, data: JSON.parse(result.content[0]!.text) }; +} + +test("devmap_explain does not exist unless the server was started with allowAi", async () => { + const projectRoot = await copyFixture(); + const { client, close } = await connect(projectRoot, { explainDependencies: fakeAi().dependencies }); + + try { + const { tools } = await client.listTools(); + assert.equal(tools.some((tool) => tool.name === "devmap_explain"), false); + assert.equal(tools.length, 7); + + // Calling a tool that was never registered must fail loudly, not run anything. + const attempt = await callTool(client, "devmap_explain", { target: "x" }).catch(() => undefined); + assert.equal(attempt, undefined, "the response is a plain-text protocol error, not DevMap JSON"); + const raw = (await client.callTool({ name: "devmap_explain", arguments: { target: "x" } })) as { + isError?: boolean; + content: Array<{ text: string }>; + }; + assert.equal(raw.isError, true); + assert.match(raw.content[0]!.text, /Tool devmap_explain not found/); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("with allowAi, devmap_explain is listed and honest about the AI provider", async () => { + const projectRoot = await copyFixture(); + const { client, close } = await connect(projectRoot, { + allowAi: true, + explainDependencies: fakeAi().dependencies + }); + + try { + const { tools } = await client.listTools(); + const explain = tools.find((tool) => tool.name === "devmap_explain"); + assert.ok(explain, "expected devmap_explain"); + assert.equal(explain.annotations?.openWorldHint, true); + assert.equal(explain.annotations?.destructiveHint, false); + assert.equal(explain.annotations?.idempotentHint, false); + assert.match(explain.description ?? "", /AI provider/); + assert.match(explain.description ?? "", /--allow-ai/); + assert.equal(tools.length, 8); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("devmap_explain answers through the configured AI client without writing anything", async () => { + const projectRoot = await copyFixture(); + const { dependencies, requests } = fakeAi(); + const { client, close } = await connect(projectRoot, { allowAi: true, explainDependencies: dependencies }); + const printed: string[] = []; + const originalLog = console.log; + console.log = (...values: unknown[]) => { printed.push(values.join(" ")); }; + + try { + const snapshotFiles = await readdir(join(projectRoot, ".devmap")); + const result = await callTool(client, "devmap_explain", { target: "lib/auth.ts" }); + + assert.equal(result.isError, false); + assert.equal(result.data.mode, "file"); + assert.equal(result.data.target, "lib/auth.ts"); + assert.equal(result.data.answer, "It validates the session and returns the current user."); + assert.equal(result.data.truncated, false); + assert.ok(result.data.contextFiles.includes("lib/auth.ts")); + assert.equal(typeof result.data.model, "string"); + assert.equal(result.data.snapshot.stale, false); + + assert.equal(requests.length, 1); + assert.deepEqual(printed, [], "no terminal output may leak into the protocol stream"); + assert.deepEqual(await readdir(join(projectRoot, ".devmap")), snapshotFiles, "explain must not write .devmap/explain"); + } finally { + console.log = originalLog; + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("devmap_explain truncates very long answers and says so", async () => { + const projectRoot = await copyFixture(); + const { dependencies } = fakeAi("x".repeat(MAX_TEXT_CHARS + 500)); + const { client, close } = await connect(projectRoot, { allowAi: true, explainDependencies: dependencies }); + + try { + const result = await callTool(client, "devmap_explain", { target: "lib/auth.ts" }); + assert.equal(result.data.truncated, true); + assert.match(result.data.answer, /truncated 500 characters/); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("devmap_explain returns an actionable error when no AI provider is configured", async () => { + const projectRoot = await copyFixture(); + const { client, close } = await connect(projectRoot, { + allowAi: true, + explainDependencies: { loadConfig: async () => null } + }); + + try { + const result = await callTool(client, "devmap_explain", { target: "lib/auth.ts" }); + assert.equal(result.isError, true); + assert.match(result.data.error, /requires an AI provider/); + assert.match(result.data.hint, /devmap init/); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("devmap_explain reports unknown targets with the known features, without calling the AI", async () => { + const projectRoot = await copyFixture(); + const { dependencies, requests } = fakeAi(); + const { client, close } = await connect(projectRoot, { allowAi: true, explainDependencies: dependencies }); + + try { + const result = await callTool(client, "devmap_explain", { target: "definitelyNotAThing" }); + assert.equal(result.isError, true); + assert.match(result.data.error, /isn't a known file, feature, or function/); + assert.match(result.data.hint, /Known features/); + assert.equal(requests.length, 0); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("devmap_explain flags a stale snapshot instead of hiding it", async () => { + const projectRoot = await copyFixture(); + const { dependencies } = fakeAi(); + const { client, close } = await connect(projectRoot, { allowAi: true, explainDependencies: dependencies }); + + try { + await writeFile(join(projectRoot, "lib", "brand-new.ts"), "export const fresh = 1;\n"); + const result = await callTool(client, "devmap_explain", { target: "lib/auth.ts" }); + assert.equal(result.isError, false); + assert.equal(result.data.snapshot.stale, true); + assert.match(result.data.snapshot.note, /devmap_analyze/); + } finally { + await close(); + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("registry metadata: mcpName is set, and server.json (when present) matches the package", async () => { + const pkg = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")) as { + name: string; + version: string; + mcpName?: string; + }; + + // The MCP Registry verifies ownership through this field; with GitHub login + // it must start with io.github./. + assert.match(pkg.mcpName ?? "", /^io\.github\.itsflaid\/[a-z0-9-]+$/); + + const serverJsonPath = join(packageRoot, "..", "..", "server.json"); + const raw = await readFile(serverJsonPath, "utf8").catch(() => null); + if (raw === null) return; // prepared together with the release + + const serverJson = JSON.parse(raw) as { + name: string; + version: string; + packages: Array<{ + registryType: string; + identifier: string; + version: string; + transport: { type: string }; + packageArguments?: Array<{ type: string; value?: string }>; + }>; + }; + assert.equal(serverJson.name, pkg.mcpName); + assert.equal(serverJson.version, pkg.version); + const npmPackage = serverJson.packages.find((entry) => entry.registryType === "npm"); + assert.ok(npmPackage, "server.json must describe the npm package"); + assert.equal(npmPackage.identifier, pkg.name); + assert.equal(npmPackage.version, pkg.version); + assert.equal(npmPackage.transport.type, "stdio"); + assert.ok( + npmPackage.packageArguments?.some((argument) => argument.type === "positional" && argument.value === "mcp"), + "the server is started with `devmap mcp`" + ); +}); diff --git a/packages/cli/test/mcp-smoke.mjs b/packages/cli/test/mcp-smoke.mjs new file mode 100644 index 0000000..35fbab1 --- /dev/null +++ b/packages/cli/test/mcp-smoke.mjs @@ -0,0 +1,90 @@ +// Smoke test for the compiled CLI: starts `devmap mcp` from dist/ as a real stdio +// server, builds the first snapshot through the protocol, and queries it. +// +// Run after `pnpm build:cli`: node packages/cli/test/mcp-smoke.mjs +// Used by CI and by the publish workflow so a broken MCP server never ships. +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const entry = join(packageRoot, "dist", "index.js"); +const TIMEOUT_MS = 90_000; + +const projectRoot = await mkdtemp(join(tmpdir(), "devmap-mcp-smoke-")); +const watchdog = setTimeout(() => { + console.error(`MCP smoke test timed out after ${TIMEOUT_MS / 1000}s`); + process.exit(1); +}, TIMEOUT_MS); +watchdog.unref(); + +let client; +try { + await mkdir(join(projectRoot, "src"), { recursive: true }); + await writeFile( + join(projectRoot, "package.json"), + JSON.stringify({ name: "mcp-smoke-app", version: "1.0.0" }) + ); + await writeFile(join(projectRoot, "src", "index.ts"), 'import { greet } from "./greet";\nconsole.log(greet());\n'); + await writeFile(join(projectRoot, "src", "greet.ts"), 'export const greet = () => "hello";\n'); + + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entry, "mcp", "--root", projectRoot], + stderr: "pipe" + }); + const protocolErrors = []; + transport.onerror = (error) => protocolErrors.push(error); + client = new Client({ name: "mcp-smoke", version: "0.0.0" }); + await client.connect(transport); + + const { tools } = await client.listTools(); + const names = tools.map((tool) => tool.name); + for (const expected of [ + "devmap_overview", + "devmap_map", + "devmap_flow", + "devmap_routes", + "devmap_impact", + "devmap_find", + "devmap_analyze" + ]) { + assert.ok(names.includes(expected), `missing tool ${expected}`); + } + assert.ok(!names.includes("devmap_explain"), "devmap_explain must require --allow-ai"); + + const call = async (name, args = {}) => { + const result = await client.callTool({ name, arguments: args }); + return { isError: result.isError === true, data: JSON.parse(result.content[0].text) }; + }; + + // No snapshot yet: a structured error that tells the agent what to do. + const missing = await call("devmap_overview"); + assert.equal(missing.isError, true); + assert.match(missing.data.hint, /devmap analyze/); + + const analyzed = await call("devmap_analyze"); + assert.equal(analyzed.isError, false); + assert.equal(analyzed.data.status, "refreshed"); + assert.equal(analyzed.data.mode, "static"); + + const overview = await call("devmap_overview"); + assert.equal(overview.isError, false); + assert.ok(overview.data.stats.totalFiles >= 2); + assert.equal(overview.data.snapshot.stale, false); + + const impact = await call("devmap_impact", { file: "src/greet.ts", depth: 2 }); + assert.equal(impact.isError, false); + assert.ok(impact.data.transitive.files.some((entryFile) => entryFile.file === "src/index.ts")); + + assert.deepEqual(protocolErrors, [], "stdout must carry JSON-RPC only"); + console.log(`MCP smoke test passed (${names.length} tools).`); +} finally { + clearTimeout(watchdog); + await client?.close().catch(() => undefined); + await rm(projectRoot, { recursive: true, force: true }); +}