From 15ad2d273cab4a7c16cd89459297a2ef72016eb7 Mon Sep 17 00:00:00 2001 From: Kent Wynn Date: Mon, 29 Jun 2026 10:47:09 +0700 Subject: [PATCH] Add KGraph MCP server orchestration --- README.md | 15 +- docs/wiki/AI-Tool-Integrations.md | 11 + docs/wiki/Command-Guide.md | 20 + docs/wiki/Home.md | 1 + docs/wiki/MCP-Setup.md | 84 ++ docs/wiki/Roadmap.md | 2 +- src/cli/commands/init.ts | 14 + src/cli/commands/integrate.ts | 14 +- src/cli/commands/mcp.ts | 16 + src/cli/help.ts | 9 + src/cli/index.ts | 2 + src/integrations/vscode-mcp.ts | 122 +++ src/integrations/workflow-steps.ts | 35 +- src/mcp/server.ts | 836 ++++++++++++++++++++ tests/fixtures/helpers.ts | 18 + tests/integration/init-integrations.test.ts | 37 +- tests/integration/integrate.test.ts | 38 +- tests/integration/mcp.test.ts | 165 ++++ 18 files changed, 1417 insertions(+), 22 deletions(-) create mode 100644 docs/wiki/MCP-Setup.md create mode 100644 src/cli/commands/mcp.ts create mode 100644 src/integrations/vscode-mcp.ts create mode 100644 src/mcp/server.ts create mode 100644 tests/integration/mcp.test.ts diff --git a/README.md b/README.md index 9905d25..50747ca 100644 --- a/README.md +++ b/README.md @@ -384,6 +384,13 @@ kgraph history --json Show processed capture history. Add a query to find historical work by title, summary, file, symbol, or note body. +```bash +kgraph mcp +kgraph mcp --root /path/to/repo +``` + +Start the local KGraph MCP server over stdio. You normally do not run this command by hand; MCP clients such as VS Code start it automatically after KGraph is registered in that client's MCP config. MCP clients can call typed `kgraph_*` tools for orchestration, context packs, impact analysis, capture, health checks, and command-compatible access to the full CLI surface. Prefer MCP tools when the client exposes them; otherwise use the normal CLI commands. + ## AI Tool Integrations KGraph integrations are local files. They do not start background agents, call AI providers, or send data anywhere. @@ -393,14 +400,18 @@ You do not need integrations to use KGraph manually. They are useful when you wa `kgraph init` detects likely local tools and recommends integrations when possible. You can also manage them explicitly: ```bash +kgraph init --integrations copilot --mcp kgraph integrate add codex copilot cursor claude-code gemini windsurf cline +kgraph integrate add copilot --mcp kgraph integrate add copilot --mode smart kgraph integrate set copilot --mode manual kgraph integrate list kgraph integrate remove cursor ``` -New integrations default to `always` mode, so every chat in the repository starts with the matching KGraph command. Use `--mode smart` to run KGraph only for repo-specific work, or `--mode manual` to run only when explicitly asked. +New integrations default to `always` mode, so every chat in the repository starts with the matching KGraph command. Use `--mode smart` to run KGraph only for repo-specific work, or `--mode manual` to run only when explicitly asked. Plain `kgraph init` stays repo-local; add `--mcp` only when you want KGraph to write editor/client MCP config. + +Add `--mcp` when configuring Copilot from VS Code. KGraph writes a `KGraph` stdio server entry to the VS Code MCP config for this repository and prints the config path; reload VS Code afterward so Copilot can start the server. If an AI client exposes MCP tools named `kgraph_*`, use those tools for KGraph orchestration and context. If MCP is unavailable, use the CLI commands generated in the integration instructions. MCP changes the transport, not the `always`, `smart`, `manual`, or `off` policy. See the [MCP setup wiki page](docs/wiki/MCP-Setup.md) for details. | Mode | Behavior | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -557,5 +568,5 @@ The release workflow builds, tests, packs, publishes the npm package on version - Smarter cross-file symbol and call relationship inference. - Stronger TypeScript path alias and package export resolution. - Richer graph filtering for large repositories. -- Optional MCP and editor integration. +- More MCP client setup targets beyond VS Code/Copilot. - Team-friendly shared knowledge workflows that stay local-first. diff --git a/docs/wiki/AI-Tool-Integrations.md b/docs/wiki/AI-Tool-Integrations.md index a91e882..07dfb84 100644 --- a/docs/wiki/AI-Tool-Integrations.md +++ b/docs/wiki/AI-Tool-Integrations.md @@ -23,6 +23,17 @@ New integrations default to `always` mode, so every chat in the repository start Generated instructions teach agents to use the atom-native workflow: `context`, `pack`, `knowledge`, `stale`, `blame`, `conclude`, `compact`, `repair`, `impact`, `history`, and `session` where supported. +## MCP + +For VS Code/Copilot, add `--mcp` when initializing or adding the Copilot integration: + +```bash +kgraph init --integrations copilot --mcp +kgraph integrate add copilot --mcp +``` + +This registers KGraph as a VS Code MCP server for the current repository. See [MCP Setup](MCP-Setup) for client behavior and setup details. + ## Managed Files | Tool | Files KGraph manages | diff --git a/docs/wiki/Command-Guide.md b/docs/wiki/Command-Guide.md index d7d300f..02007d8 100644 --- a/docs/wiki/Command-Guide.md +++ b/docs/wiki/Command-Guide.md @@ -125,3 +125,23 @@ kgraph visualize --no-open ``` The graph shows files, imports, relationship edges, and canonical knowledge atoms. Symbols are shown in the file detail panel for performance. + +## `kgraph mcp` + +Starts the local KGraph MCP server over stdio. + +```bash +kgraph mcp +kgraph mcp --root /path/to/repo +``` + +MCP clients start this command automatically after KGraph is registered in that client's MCP config; you normally do not run it directly in a terminal. MCP clients can call typed `kgraph_*` tools for orchestration, context packs, impact analysis, capture, health checks, session tracking, and command-compatible access to the full CLI surface. If MCP tools are not available in the client, use the normal CLI commands. + +For VS Code/Copilot, register KGraph automatically while initializing or adding the Copilot integration: + +```bash +kgraph init --integrations copilot --mcp +kgraph integrate add copilot --mcp +``` + +Plain `kgraph init` stays repo-local and does not edit editor config. Add `--mcp` only when you want KGraph to write the VS Code MCP entry for this repository. Reload VS Code after KGraph prints the MCP config path. See [MCP Setup](MCP-Setup) for the full guide. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 90ef0cc..e5810b6 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -11,6 +11,7 @@ KGraph gives AI coding tools a local repo memory: file maps, symbols, relationsh - [Command Guide](Command-Guide) - [AI Tool Integrations](AI-Tool-Integrations) +- [MCP Setup](MCP-Setup) - [Local-First Design](Local-First-Design) - [Roadmap](Roadmap) - [Contributing](Contributing) diff --git a/docs/wiki/MCP-Setup.md b/docs/wiki/MCP-Setup.md new file mode 100644 index 0000000..925b05c --- /dev/null +++ b/docs/wiki/MCP-Setup.md @@ -0,0 +1,84 @@ +# MCP Setup + +KGraph can run as a local MCP server so AI clients can call structured `kgraph_*` tools instead of shelling out to CLI commands. + +## Mental Model + +- `kgraph mcp` starts a stdio MCP server. +- You normally do not run `kgraph mcp` manually in a terminal. +- The AI client starts `kgraph mcp` automatically after KGraph is registered in that client's MCP config. +- MCP changes the transport only. It does not change KGraph's local-first storage, integration modes, or capture policy. + +If MCP tools are available in the client, prefer them. If MCP is not available, use the normal CLI commands. + +## VS Code and Copilot + +For first-time setup in a repository: + +```bash +kgraph init --integrations copilot --mcp +``` + +For an existing KGraph repository: + +```bash +kgraph integrate add copilot --mcp +``` + +KGraph writes a `KGraph` server entry to VS Code's MCP config for the current repository and prints the config path. Reload VS Code after this command so Copilot can start the server. + +On macOS, VS Code's user MCP config is usually: + +```text +~/Library/Application Support/Code/User/mcp.json +``` + +The generated server entry is equivalent to: + +```json +{ + "command": "kgraph", + "args": ["mcp", "--root", "/path/to/repo"], + "type": "stdio" +} +``` + +## Why `--mcp` Is Explicit + +Plain `kgraph init` stays repo-local: + +- creates `.kgraph/` +- writes KGraph config +- scans the repository +- optionally writes repo-local integration instruction files + +MCP setup edits editor/client configuration outside the repository, so KGraph only does it when you pass `--mcp`. + +## Other Clients + +Codex, Cursor, Claude Code, VS Code, and other MCP clients each have their own MCP config location. The current `--mcp` setup targets VS Code/Copilot. For other clients, point the client at: + +```bash +kgraph mcp --root /path/to/repo +``` + +Future KGraph releases can add first-class MCP setup targets for more clients. + +## Available Tools + +KGraph exposes typed tools for common workflows, including: + +- `kgraph_orchestrate` +- `kgraph_context_pack` +- `kgraph_impact` +- `kgraph_capture` +- `kgraph_health` +- `kgraph_command` + +It also exposes command-specific tools for scan, update, context, pack, history, stale, blame, doctor, repair, compact, knowledge, session, integrations, init, uninstall, and visualize workflows. + +Tools are labeled by mutability: + +- `read-only` +- `repo-write` +- `destructive` diff --git a/docs/wiki/Roadmap.md b/docs/wiki/Roadmap.md index cbb1873..2b62f4d 100644 --- a/docs/wiki/Roadmap.md +++ b/docs/wiki/Roadmap.md @@ -13,7 +13,7 @@ KGraph's roadmap stays focused on practical repo intelligence for AI coding work ## Integrations -- Optional MCP support +- More MCP client setup targets beyond VS Code/Copilot - More editor/tool-call integration surfaces - Better session capture for agents that support hooks or tool commands diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index c972087..ad14748 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -7,6 +7,7 @@ import { import { installCopilotMemory } from '../../integrations/copilot-memory.js'; import { normalizeIntegrationNames } from '../../integrations/integration-registry.js'; import { addIntegrations } from '../../integrations/integration-store.js'; +import { installVSCodeMcpServer } from '../../integrations/vscode-mcp.js'; import { ensureKnowledgeStore } from '../../knowledge/atom-store.js'; import { scanRepository } from '../../scanner/repo-scanner.js'; import { ensureWorkspace } from '../../storage/kgraph-paths.js'; @@ -30,6 +31,7 @@ interface InitOptions { integration?: string[]; integrations?: string; mode: string; + mcp?: boolean; } export function registerInitCommand(program: Command): void { @@ -51,6 +53,10 @@ export function registerInitCommand(program: Command): void { 'Integration mode: always, smart, manual, or off', 'always', ) + .option( + '--mcp', + 'Configure the VS Code/Copilot MCP server for this repository', + ) .action((options: InitOptions) => runCommand(async () => { const workspace = await ensureWorkspace(process.cwd()); @@ -74,6 +80,14 @@ export function registerInitCommand(program: Command): void { ); } + if (options.mcp) { + const result = await installVSCodeMcpServer(workspace); + console.log( + `${result.changed ? 'Configured' : 'Verified'} VS Code MCP server: ${result.serverName} (${result.configPath})`, + ); + console.log('Reload VS Code so it starts the KGraph MCP server.'); + } + let config = await loadConfig(workspace); // Workspace detection — only for fresh init, non-destructive diff --git a/src/cli/commands/integrate.ts b/src/cli/commands/integrate.ts index cf9898a..686ab21 100644 --- a/src/cli/commands/integrate.ts +++ b/src/cli/commands/integrate.ts @@ -6,6 +6,7 @@ import { removeIntegrations, setIntegrationMode, } from '../../integrations/integration-store.js'; +import { installVSCodeMcpServer } from '../../integrations/vscode-mcp.js'; import { assertWorkspace } from '../../storage/kgraph-paths.js'; import type { IntegrationMode } from '../../types/config.js'; import { KGraphError, runCommand } from '../errors.js'; @@ -40,7 +41,11 @@ export function registerIntegrateCommand(program: Command): void { .description('Add AI tool integrations') .argument('') .option('--mode ', 'always, smart, manual, or off', 'always') - .action((names: string[], options: { mode: string }) => + .option( + '--mcp', + 'Configure the VS Code/Copilot MCP server for this repository', + ) + .action((names: string[], options: { mode: string; mcp?: boolean }) => runCommand(async () => { const workspace = await assertWorkspace(process.cwd()); const normalized = normalizeIntegrationNames(names); @@ -52,6 +57,13 @@ export function registerIntegrateCommand(program: Command): void { console.log( `Configured integrations: ${changed.map((item) => `${item.name}:${item.mode}`).join(', ')}`, ); + if (options.mcp) { + const result = await installVSCodeMcpServer(workspace); + console.log( + `${result.changed ? 'Configured' : 'Verified'} VS Code MCP server: ${result.serverName} (${result.configPath})`, + ); + console.log('Reload VS Code so it starts the KGraph MCP server.'); + } }), ); diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts new file mode 100644 index 0000000..83daa03 --- /dev/null +++ b/src/cli/commands/mcp.ts @@ -0,0 +1,16 @@ +import type { Command } from 'commander'; +import { runMcpServer } from '../../mcp/server.js'; + +interface McpOptions { + root?: string; +} + +export function registerMcpCommand(program: Command): void { + program + .command('mcp') + .description('Start the local KGraph MCP server over stdio') + .option('--root ', 'Repository root path', process.cwd()) + .action(async (options: McpOptions) => { + await runMcpServer({ rootPath: options.root ?? process.cwd() }); + }); +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 9819434..07226f4 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -32,6 +32,10 @@ export function renderRootHelp(useColor = supportsColor()): string { 'init --integrations codex,gemini', 'Optional: initialize and connect named AI tools', ), + command( + 'init --integrations copilot --mcp', + 'Optional: also register KGraph as a VS Code MCP server', + ), '', sectionTitle(theme, `${accent} Daily workflow`), command('kgraph', 'Refresh scan maps and process pending capture notes'), @@ -99,6 +103,7 @@ export function renderRootHelp(useColor = supportsColor()): string { 'Interactive dependency graph at http://localhost:4242', ), command('history "blog button"', 'Search processed cognition sessions'), + command('mcp', 'Start the local MCP server over stdio'), '', sectionTitle(theme, `${accent} Integrations`), command('integrate list', 'Show configured AI tool integrations'), @@ -110,6 +115,10 @@ export function renderRootHelp(useColor = supportsColor()): string { 'integrate add copilot --mode smart', 'Run KGraph for repo-specific Copilot work only', ), + command( + 'integrate add copilot --mcp', + 'Also register the KGraph MCP server in VS Code', + ), command( 'integrate set copilot --mode manual', 'Only run KGraph when explicitly requested', diff --git a/src/cli/index.ts b/src/cli/index.ts index 4ba70d0..a9b7e0a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -13,6 +13,7 @@ import { registerImpactCommand } from './commands/impact.js'; import { registerInitCommand } from './commands/init.js'; import { registerIntegrateCommand } from './commands/integrate.js'; import { registerKnowledgeCommand } from './commands/knowledge.js'; +import { registerMcpCommand } from './commands/mcp.js'; import { registerPackCommand } from './commands/pack.js'; import { registerRepairCommand } from './commands/repair.js'; import { registerScanCommand } from './commands/scan.js'; @@ -102,6 +103,7 @@ export function createProgram(): Command { registerContextCommand(program); registerPackCommand(program); registerKnowledgeCommand(program); + registerMcpCommand(program); registerStaleCommand(program); registerBlameCommand(program); registerImpactCommand(program); diff --git a/src/integrations/vscode-mcp.ts b/src/integrations/vscode-mcp.ts new file mode 100644 index 0000000..b924921 --- /dev/null +++ b/src/integrations/vscode-mcp.ts @@ -0,0 +1,122 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { KGraphError } from '../cli/errors.js'; +import type { KGraphWorkspace } from '../types/config.js'; + +interface VSCodeMcpConfig { + servers?: Record; + inputs?: unknown[]; + [key: string]: unknown; +} + +export interface VSCodeMcpInstallResult { + configPath: string; + serverName: string; + command: string; + args: string[]; + changed: boolean; +} + +const SERVER_NAME = 'KGraph'; + +export async function installVSCodeMcpServer( + workspace: KGraphWorkspace, +): Promise { + const configPath = resolveVSCodeMcpConfigPath(); + const command = resolveKGraphMcpCommand(); + const args = ['mcp', '--root', workspace.rootPath]; + const existing = await readVSCodeMcpConfig(configPath); + const servers = normalizeServers(existing.servers); + const nextServer = { + command, + args, + type: 'stdio', + }; + const previous = servers[SERVER_NAME]; + servers[SERVER_NAME] = nextServer; + const next: VSCodeMcpConfig = { + ...existing, + servers, + inputs: Array.isArray(existing.inputs) ? existing.inputs : [], + }; + const changed = + JSON.stringify(previous ?? null) !== JSON.stringify(nextServer); + + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8'); + + return { configPath, serverName: SERVER_NAME, command, args, changed }; +} + +export function resolveVSCodeMcpConfigPath(): string { + if (process.env.KGRAPH_VSCODE_MCP_CONFIG) { + return process.env.KGRAPH_VSCODE_MCP_CONFIG; + } + + if (process.platform === 'darwin') { + return path.join( + os.homedir(), + 'Library', + 'Application Support', + 'Code', + 'User', + 'mcp.json', + ); + } + + if (process.platform === 'win32') { + const appData = process.env.APPDATA; + if (!appData) { + throw new KGraphError( + 'APPDATA is not set; cannot locate VS Code MCP config.', + ); + } + return path.join(appData, 'Code', 'User', 'mcp.json'); + } + + return path.join(os.homedir(), '.config', 'Code', 'User', 'mcp.json'); +} + +function resolveKGraphMcpCommand(): string { + if (process.env.KGRAPH_MCP_COMMAND) { + return process.env.KGRAPH_MCP_COMMAND; + } + + const entrypoint = process.argv[1]; + if (entrypoint && path.basename(entrypoint).startsWith('kgraph')) { + return entrypoint; + } + + return 'kgraph'; +} + +async function readVSCodeMcpConfig( + configPath: string, +): Promise { + try { + return JSON.parse(await readFile(configPath, 'utf8')) as VSCodeMcpConfig; + } catch (error) { + if (isMissingFile(error)) { + return { servers: {}, inputs: [] }; + } + const message = error instanceof Error ? error.message : String(error); + throw new KGraphError( + `Invalid VS Code MCP config at ${configPath}: ${message}`, + ); + } +} + +function normalizeServers(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? { ...(value as Record) } + : {}; +} + +function isMissingFile(error: unknown): boolean { + return ( + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/integrations/workflow-steps.ts b/src/integrations/workflow-steps.ts index 0414a8d..f3ba35c 100644 --- a/src/integrations/workflow-steps.ts +++ b/src/integrations/workflow-steps.ts @@ -34,6 +34,7 @@ const COMPACT_STEP = `Run \`kgraph compact --dry-run\` when cognition looks dupl const HISTORY_STEP = `Run \`kgraph history ""\` when the user asks what was done, decided, or changed — it covers the full timeline including notes captured via \`conclude\`, \`--capture\`, and inbox. Prefer history over \`knowledge list\` when the question is about sequence, timing, or authorship rather than atom details.`; const KNOWLEDGE_STEP = `Run \`kgraph knowledge list --topic ""\` or \`kgraph knowledge get \` when the user asks what KGraph remembers or atom provenance/lifecycle matters.`; const STALE_STEP = `Run \`kgraph stale\` when changed or deleted code may have invalidated durable knowledge. Run \`kgraph blame \` when provenance or evidence for a memory matters.`; +const MCP_ROUTING_STEP = `If MCP tools named \`kgraph_*\` are available in this client, prefer those tools for KGraph orchestration and context. If MCP is not available, use the normal CLI commands shown here. MCP changes the transport, not the memory policy.`; const EXPLORATION_BOUNDARY_STEP = `Keep exploration bounded by the task. For simple edits, use KGraph to identify the likely file, then read only that file or a narrow range and make the edit. Do not keep searching after the target file is found, do not retry malformed shell commands with broader variants, and do not run broad \`find\`, recursive \`grep\`, or repeated full-file dumps after KGraph already returned candidate files.`; const VERIFY_EDIT_STEP = `After editing, verify the change actually landed before claiming completion. Prefer a narrow read of the changed range or \`git diff -- \`; if there is no diff or the expected text is missing, say the edit did not apply and fix it before summarizing.`; const SCAN_STEP = `After bulk file creation, deletion, or rename (3+ files), run \`kgraph scan\` before the next \`kgraph pack\` so maps stay accurate.`; @@ -60,24 +61,25 @@ export function numberedWorkflow( options: WorkflowOptions = {}, ): string { return `1. Infer the topic from the user's request. -2. {{KGRAPH_CONTEXT_POLICY}} -3. When a pack item includes an \`excerpt\` field (symbol) or a \`content\` field (file), you already have the source code inline — do not read that file separately. Items in the \`omitted\` array were evaluated and excluded — do not manually search for them unless the user explicitly asks. -4. ${EXPLORATION_BOUNDARY_STEP} -5. ${VERIFY_EDIT_STEP} -6. ${smartRootStep(agentName)} -7. ${KNOWLEDGE_STEP} -8. ${DOCTOR_STEP} -9. ${STALE_STEP} -10. ${SCAN_STEP} -11. ${sessionStep(agentName, options.sessionQualifier)} -12. ${IMPACT_STEP} +2. ${MCP_ROUTING_STEP} +3. {{KGRAPH_CONTEXT_POLICY}} +4. When a pack item includes an \`excerpt\` field (symbol) or a \`content\` field (file), you already have the source code inline — do not read that file separately. Items in the \`omitted\` array were evaluated and excluded — do not manually search for them unless the user explicitly asks. +5. ${EXPLORATION_BOUNDARY_STEP} +6. ${VERIFY_EDIT_STEP} +7. ${smartRootStep(agentName)} +8. ${KNOWLEDGE_STEP} +9. ${DOCTOR_STEP} +10. ${STALE_STEP} +11. ${SCAN_STEP} +12. ${sessionStep(agentName, options.sessionQualifier)} +13. ${IMPACT_STEP} {{KGRAPH_CAPTURE_POLICY}} -13. ${REPAIR_STEP} -14. ${COMPACT_STEP} -15. Run \`kgraph visualize\` when the user wants to inspect the dependency graph — opens an interactive graph locally with PNG export. -16. ${HISTORY_STEP} +14. ${REPAIR_STEP} +15. ${COMPACT_STEP} +16. Run \`kgraph visualize\` when the user wants to inspect the dependency graph — opens an interactive graph locally with PNG export. +17. ${HISTORY_STEP} ${DECISION_CONTEXT}`; } @@ -90,7 +92,8 @@ export function bulletWorkflow( agentName: string, options: WorkflowOptions = {}, ): string { - return `- {{KGRAPH_CONTEXT_POLICY}} + return `- ${MCP_ROUTING_STEP} +- {{KGRAPH_CONTEXT_POLICY}} - When a pack item includes an \`excerpt\` field (symbol) or a \`content\` field (file), you already have the source code inline — do not read that file separately. Items in the \`omitted\` array were evaluated and excluded — do not manually search for them unless the user explicitly asks. - ${EXPLORATION_BOUNDARY_STEP} - ${VERIFY_EDIT_STEP} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..9a73a10 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,836 @@ +import { createProgram } from '../cli/index.js'; +import { KGraphError } from '../cli/errors.js'; +import { concludeTopic } from '../cognition/conclusion.js'; +import { normalizeConfidence, normalizeKind } from '../cli/commands/conclude.js'; +import { loadConfig } from '../config/config.js'; +import { buildContextPack } from '../context/context-pack.js'; +import { queryContext } from '../context/context-query.js'; +import { analyzeImpact } from '../context/impact.js'; +import { + atomToCognitionNote, + refreshKnowledgeAtomStatuses, +} from '../knowledge/atom-store.js'; +import { + assertSessionAgent, + recordSessionEvent, +} from '../session/session-store.js'; +import { listInboxNotes } from '../storage/cognition-store.js'; +import { assertWorkspace } from '../storage/kgraph-paths.js'; +import { mapsExist, readMaps } from '../storage/map-store.js'; + +type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +interface JsonRpcRequest { + jsonrpc: '2.0'; + id?: string | number | null; + method: string; + params?: Record; +} + +interface ToolDefinition { + name: string; + description: string; + mutability: 'read-only' | 'repo-write' | 'destructive'; + inputSchema: JsonValue; + handler: (args: Record) => Promise; +} + +interface ToolResult { + text: string; + structuredContent?: JsonValue; + isError?: boolean; +} + +interface McpServerOptions { + rootPath?: string; + stdin?: NodeJS.ReadableStream; + stdout?: NodeJS.WritableStream; +} + +export async function runMcpServer( + options: McpServerOptions = {}, +): Promise { + const server = new KGraphMcpServer(options.rootPath ?? process.cwd()); + await server.run(options.stdin ?? process.stdin, options.stdout ?? process.stdout); +} + +class KGraphMcpServer { + private readonly tools: ToolDefinition[]; + + constructor(private readonly defaultRootPath: string) { + this.tools = createTools(defaultRootPath); + } + + async run( + stdin: NodeJS.ReadableStream, + stdout: NodeJS.WritableStream, + ): Promise { + let buffer: Buffer = Buffer.alloc(0); + const pending: Promise[] = []; + let queue = Promise.resolve(); + + stdin.on('data', (chunk: Buffer | string) => { + buffer = Buffer.concat([buffer, Buffer.from(chunk)]); + const parsed = parseMessages(buffer); + buffer = parsed.remaining; + for (const raw of parsed.messages) { + queue = queue.then(() => this.handleRawMessage(raw, stdout)); + pending.push(queue); + } + }); + + await new Promise((resolve) => stdin.on('end', () => resolve())); + await Promise.all(pending); + } + + private async handleRawMessage( + raw: string, + stdout: NodeJS.WritableStream, + ): Promise { + let request: JsonRpcRequest; + try { + request = JSON.parse(raw) as JsonRpcRequest; + } catch { + writeMessage(stdout, { + jsonrpc: '2.0', + id: null, + error: { code: -32700, message: 'Parse error' }, + }); + return; + } + + if (request.id === undefined) { + return; + } + + try { + const result = await this.dispatch(request); + writeMessage(stdout, { jsonrpc: '2.0', id: request.id, result }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeMessage(stdout, { + jsonrpc: '2.0', + id: request.id, + error: { code: -32000, message }, + }); + } + } + + private async dispatch(request: JsonRpcRequest): Promise { + if (request.method === 'initialize') { + return { + protocolVersion: '2025-06-18', + capabilities: { tools: {} }, + serverInfo: { name: 'kgraph', version: '0.1.0' }, + }; + } + + if (request.method === 'ping') { + return {}; + } + + if (request.method === 'tools/list') { + return { + tools: this.tools.map((tool) => ({ + name: tool.name, + description: `${tool.description} Mutability: ${tool.mutability}.`, + inputSchema: tool.inputSchema, + })), + }; + } + + if (request.method === 'tools/call') { + const name = stringParam(request.params, 'name'); + const args = objectParam(request.params, 'arguments', {}); + const tool = this.tools.find((candidate) => candidate.name === name); + if (!tool) { + throw new KGraphError(`Unknown MCP tool "${name}".`); + } + const result = await tool.handler(args); + return { + content: [{ type: 'text', text: result.text }], + structuredContent: result.structuredContent ?? {}, + isError: result.isError ?? false, + }; + } + + throw new KGraphError(`Unsupported MCP method "${request.method}".`); + } +} + +function createTools(defaultRootPath: string): ToolDefinition[] { + const command = commandTool(defaultRootPath); + const commandBacked = ( + name: string, + description: string, + mutability: ToolDefinition['mutability'], + buildArgs: (args: Record) => string[], + inputSchema: JsonValue = schema({}), + ): ToolDefinition => ({ + name, + description, + mutability, + inputSchema, + handler: async (args) => command.handler({ ...args, args: buildArgs(args) }), + }); + + return [ + orchestrateTool(defaultRootPath), + contextPackTool(defaultRootPath), + impactTool(defaultRootPath), + captureTool(defaultRootPath), + healthTool(defaultRootPath), + command, + commandBacked('kgraph_scan', 'Refresh deterministic file, symbol, import, and relationship maps.', 'repo-write', (args) => [ + 'scan', + ...(booleanParam(args, 'verbose') ? ['--verbose'] : []), + ]), + commandBacked('kgraph_update', 'Process Markdown cognition notes from .kgraph/inbox.', 'repo-write', (args) => [ + 'update', + ...(booleanParam(args, 'dryRun') ? ['--dry-run'] : []), + ]), + commandBacked('kgraph_context', 'Return compact repo context for a query.', 'read-only', (args) => [ + 'context', + requiredString(args, 'query'), + '--json', + ], schema({ query: { type: 'string' } }, ['query'])), + commandBacked('kgraph_pack', 'Build a budget-aware context pack.', 'read-only', (args) => [ + 'pack', + requiredString(args, 'task'), + '--budget', + String(numberParam(args, 'budget', 8000)), + '--json', + ...agentArgs(args), + ], schema({ task: { type: 'string' }, budget: { type: 'number' }, agent: { type: 'string' } }, ['task'])), + commandBacked('kgraph_history', 'Show processed cognition session history.', 'read-only', (args) => [ + 'history', + ...optionalWords(args, 'query'), + ...numberOption(args, 'last', '--last'), + '--json', + ]), + commandBacked('kgraph_stale', 'Show stale or needs-review knowledge atoms.', 'read-only', () => [ + 'stale', + '--json', + ]), + commandBacked('kgraph_blame', 'Show provenance and evidence for a knowledge atom.', 'read-only', (args) => [ + 'blame', + requiredString(args, 'atomId'), + '--json', + ], schema({ atomId: { type: 'string' } }, ['atomId'])), + commandBacked('kgraph_doctor', 'Check KGraph workspace health.', 'read-only', (args) => [ + 'doctor', + ...(booleanParam(args, 'quality') ? ['--quality'] : []), + ]), + commandBacked('kgraph_repair', 'Clean noisy stale references from knowledge atoms.', 'repo-write', (args) => [ + 'repair', + ...(booleanParam(args, 'dryRun', true) ? ['--dry-run'] : []), + ]), + commandBacked('kgraph_compact', 'Merge duplicate cognition and archive low-value stale entries.', 'repo-write', (args) => [ + 'compact', + ...(booleanParam(args, 'dryRun', true) ? ['--dry-run'] : []), + '--json', + ]), + commandBacked('kgraph_knowledge_list', 'List canonical knowledge atoms.', 'read-only', (args) => [ + 'knowledge', + 'list', + ...stringOption(args, 'type', '--type'), + ...stringOption(args, 'topic', '--topic'), + ...(booleanParam(args, 'includeArchived') ? ['--include-archived'] : []), + '--json', + ]), + commandBacked('kgraph_knowledge_get', 'Get one canonical knowledge atom.', 'read-only', (args) => [ + 'knowledge', + 'get', + requiredString(args, 'atomId'), + '--json', + ], schema({ atomId: { type: 'string' } }, ['atomId'])), + commandBacked('kgraph_knowledge_archive', 'Archive one knowledge atom.', 'repo-write', (args) => [ + 'knowledge', + 'archive', + requiredString(args, 'atomId'), + '--json', + ], schema({ atomId: { type: 'string' } }, ['atomId'])), + commandBacked('kgraph_knowledge_supersede', 'Mark one atom as superseded by another.', 'repo-write', (args) => [ + 'knowledge', + 'supersede', + requiredString(args, 'oldId'), + requiredString(args, 'newId'), + '--json', + ], schema({ oldId: { type: 'string' }, newId: { type: 'string' } }, ['oldId', 'newId'])), + commandBacked('kgraph_session_report', 'Report agent read/write activity and token estimates.', 'read-only', () => [ + 'session', + '--json', + ]), + commandBacked('kgraph_session_start', 'Start lightweight session tracking for an agent.', 'repo-write', (args) => [ + 'session', + 'start', + ...requiredAgentArgs(args), + ...sourceArgs(args), + ]), + commandBacked('kgraph_session_read', 'Record an agent file read.', 'repo-write', (args) => [ + 'session', + 'read', + requiredString(args, 'path'), + ...requiredAgentArgs(args), + ...sourceArgs(args), + ]), + commandBacked('kgraph_session_write', 'Record an agent file write.', 'repo-write', (args) => [ + 'session', + 'write', + requiredString(args, 'path'), + ...requiredAgentArgs(args), + ...sourceArgs(args), + ]), + commandBacked('kgraph_session_end', 'End session tracking for an agent.', 'repo-write', (args) => [ + 'session', + 'end', + ...requiredAgentArgs(args), + ...(booleanParam(args, 'conclude') ? ['--conclude'] : []), + ...stringOption(args, 'topic', '--topic'), + ...stringOption(args, 'note', '--note'), + ...stringOption(args, 'confidence', '--confidence'), + ...sourceArgs(args), + ]), + commandBacked('kgraph_session_reset', 'Clear current session tracking.', 'destructive', () => [ + 'session', + 'reset', + ]), + commandBacked('kgraph_integrate_list', 'List configured AI tool integrations.', 'read-only', () => [ + 'integrate', + 'list', + ]), + commandBacked('kgraph_integrate_add', 'Add AI tool integrations.', 'repo-write', (args) => [ + 'integrate', + 'add', + ...stringArray(args, 'names'), + ...stringOption(args, 'mode', '--mode'), + ]), + commandBacked('kgraph_integrate_set', 'Set AI tool integration modes.', 'repo-write', (args) => [ + 'integrate', + 'set', + ...stringArray(args, 'names'), + ...stringOption(args, 'mode', '--mode'), + ]), + commandBacked('kgraph_integrate_remove', 'Remove AI tool integrations.', 'destructive', (args) => [ + 'integrate', + 'remove', + ...stringArray(args, 'names'), + ]), + commandBacked('kgraph_init', 'Initialize a .kgraph workspace.', 'repo-write', (args) => [ + 'init', + ...stringOption(args, 'integration', '--integration'), + ...stringOption(args, 'integrations', '--integrations'), + ...(booleanParam(args, 'yes') ? ['--yes'] : []), + ]), + commandBacked('kgraph_uninstall', 'Preview or remove KGraph from this repository.', 'destructive', (args) => [ + 'uninstall', + ...(booleanParam(args, 'yes') ? ['--yes'] : []), + ...(booleanParam(args, 'keepIntegrations') ? ['--keep-integrations'] : []), + ...(booleanParam(args, 'memory') ? ['--memory'] : []), + ]), + commandBacked('kgraph_visualize', 'Return visualization command output or local URL guidance.', 'read-only', (args) => [ + 'visualize', + '--no-open', + ...numberOption(args, 'port', '--port'), + ]), + ]; +} + +function commandTool(defaultRootPath: string): ToolDefinition { + return { + name: 'kgraph_command', + description: + 'Compatibility wrapper for the exact KGraph CLI surface. Prefer typed kgraph_* MCP tools when possible.', + mutability: 'repo-write', + inputSchema: schema( + { + args: { type: 'array', items: { type: 'string' } }, + rootPath: { type: 'string' }, + }, + ['args'], + ), + handler: async (args) => { + const commandArgs = stringArray(args, 'args'); + const result = await runCliInProcess( + stringParam(args, 'rootPath', defaultRootPath), + commandArgs, + ); + const parsed = parseJsonOrUndefined(result.stdout); + const structuredContent: Record = { + args: commandArgs, + exitCode: result.code, + stdout: result.stdout, + stderr: result.stderr, + }; + if (parsed !== undefined) { + structuredContent.parsed = parsed; + } + return { + text: summarizeCommand(commandArgs, result), + structuredContent, + isError: result.code !== 0, + }; + }, + }; +} + +function orchestrateTool(defaultRootPath: string): ToolDefinition { + return { + name: 'kgraph_orchestrate', + description: + 'Smart root workflow: refresh maps, process inbox notes, optionally return topic context, run final checks, or capture durable knowledge.', + mutability: 'repo-write', + inputSchema: schema({ + topic: { type: 'string' }, + final: { type: 'boolean' }, + capture: { type: 'string' }, + captureType: { type: 'string' }, + confidence: { type: 'string' }, + domain: { type: 'string' }, + tags: { type: 'array', items: { type: 'string' } }, + files: { type: 'array', items: { type: 'string' } }, + symbols: { type: 'array', items: { type: 'string' } }, + agent: { type: 'string' }, + rootPath: { type: 'string' }, + }), + handler: async (args) => { + const cliArgs = [ + ...optionalWords(args, 'topic'), + ...(booleanParam(args, 'final') ? ['--final'] : []), + ...stringOption(args, 'capture', '--capture'), + ...stringOption(args, 'captureType', '--capture-type'), + ...stringOption(args, 'confidence', '--capture-confidence'), + ...stringOption(args, 'domain', '--capture-domain'), + ...repeatOption(args, 'tags', '--capture-tag'), + ...repeatOption(args, 'files', '--capture-file'), + ...repeatOption(args, 'symbols', '--capture-symbol'), + ...agentArgs(args), + ]; + const result = await runCliInProcess( + stringParam(args, 'rootPath', defaultRootPath), + cliArgs, + ); + return { + text: summarizeCommand(cliArgs, result), + structuredContent: { + args: cliArgs, + exitCode: result.code, + stdout: result.stdout, + stderr: result.stderr, + }, + isError: result.code !== 0, + }; + }, + }; +} + +function contextPackTool(defaultRootPath: string): ToolDefinition { + return { + name: 'kgraph_context_pack', + description: + 'Build a structured budget-aware ContextPack directly from KGraph internals.', + mutability: 'read-only', + inputSchema: schema( + { + task: { type: 'string' }, + budget: { type: 'number' }, + agent: { type: 'string' }, + rootPath: { type: 'string' }, + }, + ['task'], + ), + handler: async (args) => { + const task = requiredString(args, 'task'); + const budget = numberParam(args, 'budget', 8000); + const workspace = await assertWorkspace( + stringParam(args, 'rootPath', defaultRootPath), + ); + if (!(await mapsExist(workspace))) { + throw new KGraphError('KGraph maps are missing. Run `kgraph scan` first.'); + } + const [config, maps] = await Promise.all([ + loadConfig(workspace), + readMaps(workspace), + ]); + const response = await queryContext(workspace, config, maps, task); + const pack = buildContextPack(response, budget, workspace.rootPath); + const pendingInboxFiles = await listInboxNotes(workspace); + if (pendingInboxFiles.length > 0) { + pack.pendingInbox = { + count: pendingInboxFiles.length, + files: pendingInboxFiles, + }; + } + const agent = stringParam(args, 'agent', ''); + if (agent) { + await recordSessionEvent(workspace, { + agent: assertSessionAgent(agent), + type: 'context', + captureSource: 'automatic', + packUsedTokens: pack.usedTokens, + packOmittedTokens: pack.omitted.reduce( + (sum, item) => sum + item.tokenEstimate, + 0, + ), + }); + } + return { + text: `KGraph context pack for "${task}": ${pack.items.length} item(s), ${pack.usedTokens}/${pack.budget} tokens.`, + structuredContent: pack as unknown as JsonValue, + }; + }, + }; +} + +function impactTool(defaultRootPath: string): ToolDefinition { + return { + name: 'kgraph_impact', + description: 'Analyze practical impact for a file, symbol, or topic.', + mutability: 'read-only', + inputSchema: schema({ query: { type: 'string' }, rootPath: { type: 'string' } }, ['query']), + handler: async (args) => { + const query = requiredString(args, 'query'); + const workspace = await assertWorkspace( + stringParam(args, 'rootPath', defaultRootPath), + ); + if (!(await mapsExist(workspace))) { + throw new KGraphError('KGraph maps are missing. Run `kgraph scan` first.'); + } + const [config, maps] = await Promise.all([ + loadConfig(workspace), + readMaps(workspace), + ]); + const { atoms } = await refreshKnowledgeAtomStatuses(workspace, { + fileMap: maps.fileMap, + symbolMap: maps.symbolMap, + }); + const response = analyzeImpact( + query, + maps, + atoms.filter((atom) => atom.status !== 'archived').map(atomToCognitionNote), + config.maxContextItems, + ); + return { + text: `KGraph impact for "${query}": ${response.files.length} file(s), ${response.symbols.length} symbol(s), ${response.risk.length} risk signal(s).`, + structuredContent: response as unknown as JsonValue, + }; + }, + }; +} + +function captureTool(defaultRootPath: string): ToolDefinition { + return { + name: 'kgraph_capture', + description: 'Store a durable typed engineering conclusion.', + mutability: 'repo-write', + inputSchema: schema( + { + topic: { type: 'string' }, + note: { type: 'string' }, + type: { type: 'string' }, + confidence: { type: 'string' }, + domain: { type: 'string' }, + tags: { type: 'array', items: { type: 'string' } }, + files: { type: 'array', items: { type: 'string' } }, + symbols: { type: 'array', items: { type: 'string' } }, + rootPath: { type: 'string' }, + }, + ['topic'], + ), + handler: async (args) => { + const workspace = await assertWorkspace( + stringParam(args, 'rootPath', defaultRootPath), + ); + const note = await concludeTopic(workspace, { + topic: requiredString(args, 'topic'), + body: stringParam(args, 'note', undefined), + kind: normalizeKind(stringParam(args, 'type', 'summary')), + confidence: normalizeConfidence(stringParam(args, 'confidence', 'medium')), + domain: stringParam(args, 'domain', undefined), + tags: stringArray(args, 'tags', []), + relatedFiles: stringArray(args, 'files', []), + relatedSymbols: stringArray(args, 'symbols', []), + source: 'conclude', + }); + return { + text: `Stored ${note.kind} cognition: ${note.title}`, + structuredContent: note as unknown as JsonValue, + }; + }, + }; +} + +function healthTool(defaultRootPath: string): ToolDefinition { + return { + name: 'kgraph_health', + description: 'Check KGraph workspace health and optional cognition quality.', + mutability: 'read-only', + inputSchema: schema({ quality: { type: 'boolean' }, rootPath: { type: 'string' } }), + handler: async (args) => { + const result = await runCliInProcess( + stringParam(args, 'rootPath', defaultRootPath), + ['doctor', ...(booleanParam(args, 'quality') ? ['--quality'] : [])], + ); + return { + text: summarizeCommand(['doctor'], result), + structuredContent: { + exitCode: result.code, + stdout: result.stdout, + stderr: result.stderr, + checks: parseDoctorChecks(result.stdout), + }, + isError: result.code !== 0, + }; + }, + }; +} + +async function runCliInProcess( + rootPath: string, + args: string[], +): Promise<{ stdout: string; stderr: string; code: number }> { + const originalCwd = process.cwd(); + const originalStdout = process.stdout.write; + const originalStderr = process.stderr.write; + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + const originalExitCode = process.exitCode; + let stdout = ''; + let stderr = ''; + + process.chdir(rootPath); + process.exitCode = undefined; + process.stdout.write = ((chunk: string | Uint8Array) => { + stdout += chunk.toString(); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += chunk.toString(); + return true; + }) as typeof process.stderr.write; + console.log = (...items: unknown[]) => { + stdout += `${items.map(String).join(' ')}\n`; + }; + console.error = (...items: unknown[]) => { + stderr += `${items.map(String).join(' ')}\n`; + }; + + try { + await createProgram().parseAsync(['node', 'kgraph', ...args], { + from: 'node', + }); + return { + stdout, + stderr, + code: typeof process.exitCode === 'number' ? process.exitCode : 0, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { stdout, stderr: `${stderr}${message}\n`, code: 1 }; + } finally { + process.stdout.write = originalStdout; + process.stderr.write = originalStderr; + console.log = originalConsoleLog; + console.error = originalConsoleError; + process.exitCode = originalExitCode; + process.chdir(originalCwd); + } +} + +function parseMessages(buffer: Buffer): { + messages: string[]; + remaining: Buffer; +} { + const messages: string[] = []; + let remaining = buffer; + + while (remaining.length > 0) { + const headerEnd = remaining.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + const newline = remaining.indexOf('\n'); + if (newline === -1) break; + const line = remaining.subarray(0, newline).toString('utf8').trim(); + if (!line) { + remaining = remaining.subarray(newline + 1); + continue; + } + messages.push(line); + remaining = remaining.subarray(newline + 1); + continue; + } + + const header = remaining.subarray(0, headerEnd).toString('utf8'); + const match = /content-length:\s*(\d+)/i.exec(header); + if (!match) { + remaining = remaining.subarray(headerEnd + 4); + continue; + } + const length = Number.parseInt(match[1], 10); + const bodyStart = headerEnd + 4; + const bodyEnd = bodyStart + length; + if (remaining.length < bodyEnd) break; + messages.push(remaining.subarray(bodyStart, bodyEnd).toString('utf8')); + remaining = remaining.subarray(bodyEnd); + } + + return { messages, remaining }; +} + +function writeMessage(stdout: NodeJS.WritableStream, message: JsonValue): void { + stdout.write(`${JSON.stringify(message)}\n`); +} + +function schema( + properties: Record, + required: string[] = [], +): JsonValue { + return { + type: 'object', + properties: { + rootPath: { + type: 'string', + description: 'Repository root path. Defaults to the MCP server cwd.', + }, + ...properties, + }, + required, + additionalProperties: false, + }; +} + +function objectParam( + params: Record | undefined, + key: string, + fallback: Record, +): Record { + const value = params?.[key]; + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : fallback; +} + +function stringParam( + args: Record | undefined, + key: string, + fallback = '', +): string { + const value = args?.[key]; + return typeof value === 'string' ? value : fallback; +} + +function requiredString(args: Record, key: string): string { + const value = stringParam(args, key).trim(); + if (!value) throw new KGraphError(`${key} is required.`); + return value; +} + +function booleanParam( + args: Record, + key: string, + fallback = false, +): boolean { + const value = args[key]; + return typeof value === 'boolean' ? value : fallback; +} + +function numberParam( + args: Record, + key: string, + fallback: number, +): number { + const value = args[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function stringArray( + args: Record, + key: string, + fallback: string[] = [], +): string[] { + const value = args[key]; + if (!Array.isArray(value)) return fallback; + return value.filter((item): item is string => typeof item === 'string'); +} + +function stringOption( + args: Record, + key: string, + flag: string, +): string[] { + const value = stringParam(args, key); + return value ? [flag, value] : []; +} + +function numberOption( + args: Record, + key: string, + flag: string, +): string[] { + const value = args[key]; + return typeof value === 'number' && Number.isFinite(value) + ? [flag, String(value)] + : []; +} + +function repeatOption( + args: Record, + key: string, + flag: string, +): string[] { + return stringArray(args, key).flatMap((value) => [flag, value]); +} + +function optionalWords(args: Record, key: string): string[] { + const value = stringParam(args, key); + return value ? [value] : []; +} + +function agentArgs(args: Record): string[] { + return stringOption(args, 'agent', '--agent'); +} + +function requiredAgentArgs(args: Record): string[] { + return ['--agent', requiredString(args, 'agent')]; +} + +function sourceArgs(args: Record): string[] { + return stringOption(args, 'source', '--source'); +} + +function parseJsonOrUndefined(value: string): JsonValue | undefined { + try { + return JSON.parse(value) as JsonValue; + } catch { + return undefined; + } +} + +function summarizeCommand( + args: string[], + result: { stdout: string; stderr: string; code: number }, +): string { + const command = args.length > 0 ? `kgraph ${args.join(' ')}` : 'kgraph'; + const firstLine = + result.stdout.split('\n').find((line) => line.trim()) ?? + result.stderr.split('\n').find((line) => line.trim()) ?? + 'no output'; + return `${command} exited ${result.code}: ${firstLine}`; +} + +function parseDoctorChecks(stdout: string): JsonValue { + return stdout + .split('\n') + .map((line) => /^(OK|FAIL)\s+([^:]+):\s*(.*)$/.exec(line)) + .filter((match): match is RegExpExecArray => Boolean(match)) + .map((match) => ({ + ok: match[1] === 'OK', + label: match[2].trim(), + detail: match[3].trim(), + })); +} diff --git a/tests/fixtures/helpers.ts b/tests/fixtures/helpers.ts index 2cee09a..0f50e3d 100644 --- a/tests/fixtures/helpers.ts +++ b/tests/fixtures/helpers.ts @@ -37,6 +37,8 @@ export async function runCli( const originalDisableMachineDetection = process.env.KGRAPH_DISABLE_MACHINE_DETECTION; const originalCopilotMemoryDir = process.env.KGRAPH_COPILOT_MEMORY_DIR; + const originalVSCodeMcpConfig = process.env.KGRAPH_VSCODE_MCP_CONFIG; + const originalMcpCommand = process.env.KGRAPH_MCP_COMMAND; let stdout = ''; let stderr = ''; process.chdir(repoPath); @@ -46,6 +48,12 @@ export async function runCli( os.tmpdir(), 'kgraph-memory-test-' + path.basename(repoPath), ); + process.env.KGRAPH_VSCODE_MCP_CONFIG = path.join( + os.tmpdir(), + 'kgraph-vscode-mcp-test-' + path.basename(repoPath), + 'mcp.json', + ); + process.env.KGRAPH_MCP_COMMAND = 'kgraph'; process.stdout.write = ((chunk: string | Uint8Array) => { stdout += chunk.toString(); return true; @@ -87,6 +95,16 @@ export async function runCli( } else { process.env.KGRAPH_COPILOT_MEMORY_DIR = originalCopilotMemoryDir; } + if (originalVSCodeMcpConfig === undefined) { + delete process.env.KGRAPH_VSCODE_MCP_CONFIG; + } else { + process.env.KGRAPH_VSCODE_MCP_CONFIG = originalVSCodeMcpConfig; + } + if (originalMcpCommand === undefined) { + delete process.env.KGRAPH_MCP_COMMAND; + } else { + process.env.KGRAPH_MCP_COMMAND = originalMcpCommand; + } process.chdir(originalCwd); } } diff --git a/tests/integration/init-integrations.test.ts b/tests/integration/init-integrations.test.ts index d97979c..5aa9b53 100644 --- a/tests/integration/init-integrations.test.ts +++ b/tests/integration/init-integrations.test.ts @@ -1,4 +1,5 @@ -import { access, readFile } from 'node:fs/promises'; +import { access, readFile, realpath } from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import YAML from 'yaml'; @@ -48,4 +49,38 @@ describe('kgraph init integrations', () => { await cleanupTempRepo(repo); } }); + + it('can configure VS Code MCP during init', async () => { + const repo = await createTempRepo(); + try { + const result = await runCli(repo, [ + 'init', + '--integrations', + 'copilot', + '--mcp', + ]); + expect(result.code).toBe(0); + expect(result.stdout).toContain('Configured integrations: copilot:always'); + expect(result.stdout).toContain('Configured VS Code MCP server: KGraph'); + + const mcp = JSON.parse( + await readFile(vscodeMcpConfigPath(repo), 'utf8'), + ); + expect(mcp.servers.KGraph).toEqual({ + command: 'kgraph', + args: ['mcp', '--root', await realpath(repo)], + type: 'stdio', + }); + } finally { + await cleanupTempRepo(repo); + } + }); }); + +function vscodeMcpConfigPath(repo: string): string { + return path.join( + os.tmpdir(), + 'kgraph-vscode-mcp-test-' + path.basename(repo), + 'mcp.json', + ); +} diff --git a/tests/integration/integrate.test.ts b/tests/integration/integrate.test.ts index f5aea4a..d5388c2 100644 --- a/tests/integration/integrate.test.ts +++ b/tests/integration/integrate.test.ts @@ -1,4 +1,5 @@ -import { access, readFile, writeFile } from 'node:fs/promises'; +import { access, readFile, realpath, writeFile } from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { @@ -108,6 +109,33 @@ describe('kgraph integrate', () => { } }); + it('configures VS Code MCP when requested', async () => { + const repo = await createTempRepo(); + try { + await runCli(repo, ['init']); + const result = await runCli(repo, [ + 'integrate', + 'add', + 'copilot', + '--mcp', + ]); + expect(result.code).toBe(0); + expect(result.stdout).toContain('Configured integrations: copilot:always'); + expect(result.stdout).toContain('Configured VS Code MCP server: KGraph'); + + const mcp = JSON.parse( + await readFile(vscodeMcpConfigPath(repo), 'utf8'), + ); + expect(mcp.servers.KGraph).toEqual({ + command: 'kgraph', + args: ['mcp', '--root', await realpath(repo)], + type: 'stdio', + }); + } finally { + await cleanupTempRepo(repo); + } + }); + it('adds, lists, and removes Gemini, Windsurf, and Cline integrations', async () => { const repo = await createTempRepo(); try { @@ -234,3 +262,11 @@ describe('kgraph integrate', () => { } }); }); + +function vscodeMcpConfigPath(repo: string): string { + return path.join( + os.tmpdir(), + 'kgraph-vscode-mcp-test-' + path.basename(repo), + 'mcp.json', + ); +} diff --git a/tests/integration/mcp.test.ts b/tests/integration/mcp.test.ts new file mode 100644 index 0000000..9b8250d --- /dev/null +++ b/tests/integration/mcp.test.ts @@ -0,0 +1,165 @@ +import { Writable, PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { runMcpServer } from '../../src/mcp/server.js'; +import { + cleanupTempRepo, + createTempRepo, + runCli, + writeText, +} from '../fixtures/helpers.js'; + +interface JsonRpcResponse { + jsonrpc: '2.0'; + id: string | number; + result?: { + tools?: Array<{ name: string; description: string }>; + content?: Array<{ type: string; text: string }>; + structuredContent?: Record; + isError?: boolean; + }; + error?: { code: number; message: string }; +} + +describe('kgraph mcp', () => { + it('lists typed tools for the full KGraph command surface with mutability labels', async () => { + const repo = await createTempRepo(); + try { + const responses = await callMcp(repo, [ + { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, + ]); + + expect(responses[0].result).toMatchObject({ + serverInfo: { name: 'kgraph' }, + }); + const tools = responses[1].result?.tools ?? []; + const names = tools.map((tool) => tool.name); + expect(names).toContain('kgraph_orchestrate'); + expect(names).toContain('kgraph_command'); + expect(names).toContain('kgraph_context_pack'); + expect(names).toContain('kgraph_knowledge_supersede'); + expect(names).toContain('kgraph_integrate_remove'); + expect(names).toContain('kgraph_uninstall'); + expect( + tools.find((tool) => tool.name === 'kgraph_uninstall')?.description, + ).toContain('Mutability: destructive'); + expect( + tools.find((tool) => tool.name === 'kgraph_context_pack')?.description, + ).toContain('Mutability: read-only'); + } finally { + await cleanupTempRepo(repo); + } + }); + + it('runs the compatibility command wrapper in-process with CLI parity', async () => { + const repo = await createTempRepo(); + try { + await runCli(repo, ['init']); + const cli = await runCli(repo, ['doctor']); + + const [response] = await callMcp(repo, [ + { + jsonrpc: '2.0', + id: 'doctor', + method: 'tools/call', + params: { + name: 'kgraph_command', + arguments: { args: ['doctor'] }, + }, + }, + ]); + + expect(response.error).toBeUndefined(); + expect(response.result?.isError).toBe(false); + expect(response.result?.structuredContent).toMatchObject({ + exitCode: cli.code, + stdout: cli.stdout, + }); + } finally { + await cleanupTempRepo(repo); + } + }); + + it('supports typed write and read orchestration through capture and context pack tools', async () => { + const repo = await createTempRepo(); + try { + await writeText( + repo, + 'src/auth.ts', + 'export function issueToken(userId: string) { return `token:${userId}`; }\n', + ); + await runCli(repo, ['init']); + + const responses = await callMcp(repo, [ + { + jsonrpc: '2.0', + id: 'capture', + method: 'tools/call', + params: { + name: 'kgraph_capture', + arguments: { + topic: 'auth token issuing', + note: 'issueToken returns a token string keyed by user id.', + type: 'finding', + confidence: 'high', + files: ['src/auth.ts'], + }, + }, + }, + { + jsonrpc: '2.0', + id: 'pack', + method: 'tools/call', + params: { + name: 'kgraph_context_pack', + arguments: { task: 'auth token', budget: 4000 }, + }, + }, + ]); + + expect(responses[0].result?.isError).toBe(false); + expect(responses[0].result?.content?.[0]?.text).toContain( + 'Stored finding cognition', + ); + expect(responses[1].result?.isError).toBe(false); + expect(responses[1].result?.structuredContent).toMatchObject({ + task: 'auth token', + budget: 4000, + }); + } finally { + await cleanupTempRepo(repo); + } + }); +}); + +async function callMcp( + rootPath: string, + messages: Array>, +): Promise { + const stdin = new PassThrough(); + const chunks: Buffer[] = []; + const stdout = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(Buffer.from(chunk)); + callback(); + }, + }); + + const server = runMcpServer({ rootPath, stdin, stdout }); + for (const message of messages) { + stdin.write(`${JSON.stringify(message)}\n`); + } + stdin.end(); + await server; + const output = Buffer.concat(chunks).toString('utf8'); + expect(output).not.toContain('Content-Length:'); + return parseJsonLines(output); +} + +function parseJsonLines(output: string): JsonRpcResponse[] { + return output + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line) as JsonRpcResponse); +}