Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/commands/explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 = {
Expand Down Expand Up @@ -74,7 +79,7 @@ export async function runExplain(
dependencies: ExplainDependencies
): Promise<ExplainResult> {
const projectRoot = resolve(options.projectRoot ?? ".");
const snapshot = await readSnapshotOrThrow(projectRoot);
const snapshot = options.loaded?.snapshot ?? await readSnapshotOrThrow(projectRoot);

const loadConfig = dependencies.loadConfig
?? (() => resolveEffectiveConfig(projectRoot));
Expand Down
49 changes: 49 additions & 0 deletions packages/cli/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Expand All @@ -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;
};
Expand Down Expand Up @@ -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(
Expand Down
265 changes: 265 additions & 0 deletions packages/cli/test/mcp-explain.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<AiCompletionResult> {
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<void> }> {
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<string, unknown> = {}
): 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.<github-username>/.
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`"
);
});
Loading
Loading