import { KnowledgeWhyRequestSchema, knowledgeWhy, formatKnowledgeWhyText } from '../query/why.js' import { withExecutionProfile, executionContext, isServiceProfile, serviceCoverage, type ExecutionProfile } from '../execution/profile.js' import { serviceConfig } from '../execution/config.js' import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' import { z, ZodError } from 'zod' import type { DocBridgeConfigV1 } from '../config/schema.js' import { retrieveDocBridgeChunks } from '../retriever/doc-bridge-retriever.js' import { runGates } from '../gates/run-gates.js' import { ingestMemoryCandidates } from '../memory/ingest.js' import { classifyMemoryCandidates, draftMemoryPromotion } from '../memory/pipeline.js' import { loadFreshDocBridgeIndex } from '../query/load-index.js' import { runQuery } from '../query/query.js' import { searchIndex } from '../query/search.js' import { findingsFromDiagnostics } from '../findings/report.js' import { budgetedHandoff, formatKnowledgeLookupText, formatKnowledgeSearchText, knowledgeLookup, knowledgeSearch, MAX_LOOKUP_DEPTH } from './knowledge.js' import type { AgentHandoffV1 } from '../schemas/agent-handoff.js' import { RetrievalKindSchema } from '../schemas/retrieval-index.js' import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js' import { PACKAGE_VERSION } from '../version.js' import { loadWorkflowManifest, loadWorkflowStepOutput } from '../workflow/engine.js' import { parseDiscoverySnapshot, parseReconciliationReport } from '../validate.js' import { remediationWorkflow } from '../fixes/workflow.js' import { readBoundedText } from '../lib/bounded-text.js' import { RemediationV1Schema, type RemediationV1 } from '../schemas/findings.js' import { createRegistryAgentAdapter, loadRegistryAgentRunner, persistRegistryAgentProposal } from '../agents/registry-adapter.js' import { decideEnrichment, listEnrichment } from '../enrich/review.js' import { type DiscoverySnapshotV1, type ReconciliationReportV1 } from '../schemas/knowledge.js' import { redactValue, redactSecrets } from '../safety/repository.js' type JsonRpcRequest = { readonly jsonrpc?: '2.0' readonly id?: string | number | null readonly method?: string readonly params?: unknown } export type McpContext = { readonly profile?: ExecutionProfile readonly root: string readonly config: DocBridgeConfigV1 readonly loadIndex?: () => DocBridgeIndexV1 /** Verified exact-partition text; failures must throw rather than read local bytes. */ readonly readDocument?: (path: string) => string } export const MCP_TOOLS = [ ...(['knowledge.decision', 'knowledge.concept', 'knowledge.whyChanged'] as const).map(name => ({ name, title: name, description: 'Read exact indexed knowledge entities with evidence and explicit coverage.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { target: { type: 'string', minLength: 1, maxLength: 512 }, limit: { type: 'integer', minimum: 1, maximum: 100 }, budgetTokens: { type: 'integer', minimum: 1, maximum: 1000000 }, format: { type: 'string', enum: ['json', 'text'] } }, required: ['target'], additionalProperties: false }, })), { name: 'handoff.resolve', title: 'Resolve repository handoff', description: 'Use when changing a package or handing off its ownership: returns the deterministic AgentHandoff for a package id or an ownership id, with start-here documents, edit roots and checks.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'A package id (for example the npm package name) or an ownership id, as listed by "ak-docs list packages".' }, kind: { type: 'string', enum: ['package', 'ownership'], description: 'Whether id names a package or an ownership record. Defaults to ownership.' }, budgetTokens: { type: 'number', description: 'Optional token budget. When set, the handoff may drop related entries and the summary note to fit.' }, }, required: ['id'], }, }, { name: 'doc.search', title: 'Search repository documentation', description: 'Use first for questions about how the repository works, its contracts, architecture, decisions or ownership, before broad grep. Returns ranked indexed documents and entities with their ids, types and paths.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { term: { type: 'string', description: 'The search term: a package name, symbol, concept, decision or document title, in the repository\'s own words. Use a few specific words rather than a whole question.' }, limit: { type: 'number', description: 'Maximum number of results for the plain search, 1 to 100. Defaults to 20.' }, agent: { type: 'boolean', description: 'Return the bounded agent-context form, with next commands, instead of the plain list.' }, mode: { type: 'string', enum: ['discovery', 'editing', 'debugging', 'documentation'], description: 'Agent-context detail level; each mode sets its own limits on matches and next commands. Defaults to discovery.' }, contextBudgetTokens: { type: 'number', description: 'Approximate token budget for the agent-context form when agent is true. Defaults to 128. A declared budget is a hard limit: the call fails if even one grounded match does not fit; without it, an oversized result is returned as one match marked truncated.' }, }, required: ['term'], }, }, { name: 'doc.get', title: 'Read indexed documentation', description: 'Use after doc.search or handoff.resolve to read one cited document in full, by its indexed id or path. Fails for ids or paths that are not in the index.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'The indexed document id, as cited by doc.search or handoff.resolve. Give either id or path.' }, path: { type: 'string', description: 'The indexed repository-relative path of the document, as cited by doc.search. Give either id or path.' }, }, }, }, { name: 'gate.status', title: 'Check documentation gates', description: 'Evaluate documentation gates without writing files.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: {} }, }, { name: 'retriever.query', title: 'Retrieve documentation context', description: 'Return relevant local Doc Bridge index chunks for a query.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'number' } }, required: ['query'], }, }, { name: 'memory.classify', title: 'Classify memory candidates', description: 'Classify local memory candidates into agent, human, playbook, or discard routes.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: {} }, }, { name: 'memory.promoteDraft', title: 'Draft memory promotion', description: 'Build a reviewable draft promotion body from local memory candidates without publishing it.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: {} }, }, { name: 'registry.topology', title: 'Inspect registry topology', description: 'Return the static Doc Bridge curator and delegate topology.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: {} }, }, { name: 'docbridge.snapshot', title: 'Read the latest discovery snapshot', description: 'Read the bounded canonical repository snapshot from the latest workflow run.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { runId: { type: 'string' } } }, }, { name: 'docbridge.report', title: 'Read the latest reconciliation report', description: 'Read the canonical reconciliation report from the latest workflow run.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { runId: { type: 'string' } } }, }, { name: 'docbridge.diagnostics', title: 'Read reconciliation diagnostics', description: 'Read bounded diagnostics from the latest canonical reconciliation report.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { status: { type: 'string' }, severity: { type: 'string' }, format: { type: 'string', enum: ['diagnostic', 'finding'] } } }, }, { name: 'docbridge.relations', title: 'Read architecture relations', description: 'Read bounded observed and declared relations from the latest canonical snapshot.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { kind: { type: 'string' }, limit: { type: 'number' } } }, }, { name: 'docbridge.run', title: 'Read workflow state', description: 'Read the latest resumable workflow state and artifact references.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: {} }, }, { name: 'docbridge.proposals', title: 'Prepare and inspect region remediations', description: 'Prepare and revalidate region remediations; human approval and application use the local CLI.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'propose', 'revalidate', 'suggest', 'enrich-list', 'enrich-approve', 'enrich-reject'] }, base: { type: 'object' }, allowedRoots: { type: 'array', items: { type: 'string' }, minItems: 1 }, approvedBy: { type: 'string' }, proposal: { type: 'object' }, proposalId: { type: 'string' }, reason: { type: 'string' } } }, }, { name: 'knowledge.search', title: 'Search the knowledge graph', description: 'Rank every indexed entity for a query, optionally by kind, with an explanation and within a token budget.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { query: { type: 'string' }, kinds: { type: 'array', items: { type: 'string', enum: ['document', 'module', 'area', 'package', 'intent', 'change'] } }, limit: { type: 'number' }, explain: { type: 'boolean' }, budgetTokens: { type: 'number' }, format: { type: 'string', enum: ['json', 'text'] }, }, required: ['query'], }, }, { name: 'knowledge.lookup', title: 'Look up one entity', description: 'Return an entity with its neighbours by relation kind, the documents about it, its handoff, its open diagnostics and its evidence, within a token budget.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { id: { type: 'string' }, path: { type: 'string' }, depth: { type: 'number' }, budgetTokens: { type: 'number' }, format: { type: 'string', enum: ['json', 'text'] }, }, }, }, ] as const const asRecord = (value: unknown): Record => value && typeof value === 'object' ? (value as Record) : {} /** A budget is a positive count of tokens; the upper bound keeps a typo from meaning "unbounded". */ const BudgetTokensSchema = z.number().int().positive().max(1_000_000) const HandoffResolveArgsSchema = z.object({ id: z.string().min(1), kind: z.enum(['package', 'ownership']).optional(), budgetTokens: BudgetTokensSchema.optional(), }) const KnowledgeSearchArgsSchema = z.object({ query: z.string().min(1), kinds: z.array(RetrievalKindSchema).max(6).optional(), limit: z.number().int().positive().max(100).optional(), explain: z.boolean().optional(), budgetTokens: BudgetTokensSchema.optional(), format: z.enum(['json', 'text']).optional(), }) const KnowledgeLookupArgsSchema = z .object({ id: z.string().min(1).optional(), path: z.string().min(1).optional(), depth: z.number().int().min(1).max(MAX_LOOKUP_DEPTH).optional(), budgetTokens: BudgetTokensSchema.optional(), format: z.enum(['json', 'text']).optional(), }) .refine((args) => args.id || args.path, 'knowledge.lookup requires id or path') const DocSearchArgsSchema = z.object({ term: z.string().min(1), limit: z.number().int().positive().max(100).optional(), agent: z.boolean().optional(), mode: z.enum(['discovery', 'editing', 'debugging', 'documentation']).optional(), contextBudgetTokens: z.number().int().positive().max(1_000_000).optional(), }) const RetrieverQueryArgsSchema = z.object({ query: z.string().min(1), limit: z.number().int().positive().max(100).optional(), }) const DocGetArgsSchema = z .object({ id: z.string().min(1).optional(), path: z.string().min(1).optional(), }) .refine((args) => args.id || args.path, 'doc.get requires id or path') const WorkflowRunArgsSchema = z.object({ runId: z.string().min(1).optional() }) const DiagnosticsArgsSchema = z.object({ status: z.string().min(1).optional(), severity: z.string().min(1).optional(), format: z.enum(['diagnostic', 'finding']).optional() }) const RelationsArgsSchema = z.object({ kind: z.string().min(1).optional(), limit: z.number().int().positive().max(500).optional() }) const ProposalsArgsSchema = z.object({ action: z.enum(['list', 'propose', 'revalidate', 'suggest', 'approve', 'apply', 'enrich-list', 'enrich-approve', 'enrich-reject']).optional(), base: z.unknown().optional(), allowedRoots: z.array(z.string().min(1)).min(1).max(32).optional(), approvedBy: z.string().min(1).optional(), proposal: z.unknown().optional(), proposalId: z.string().min(1).optional(), reason: z.string().max(1_024).optional() }) const parseToolArgs = (tool: string, schema: z.ZodType, value: unknown): T => { try { return schema.parse(value) } catch (error) { if (error instanceof ZodError) { throw new Error(`${tool} invalid arguments: ${error.issues.map((issue) => issue.message).join(', ')}`) } throw error } } const textResult = (value: unknown) => ({ content: [ { type: 'text', text: typeof value === 'string' ? executionContext().profile === 'service' ? redactSecrets(value) : value : JSON.stringify(executionContext().profile === 'service' ? redactValue(value) : value, null, 2), }, ], }) const findDocPath = (index: DocBridgeIndexV1, args: z.infer): string => { if (args.path) { const doc = index.knowledge.find((entry) => entry.path === args.path) if (!doc) throw new Error(`Unknown indexed doc path "${args.path}"`) return doc.path } const id = args.id ?? '' const doc = index.knowledge.find((entry) => entry.id === args.id) if (!doc) throw new Error(`Unknown doc id "${id}"`) return doc.path } const resolveDocPath = (root: string, relPath: string): string => { const rootAbs = realpathSync.native(root) const unresolved = resolve(rootAbs, relPath) const unresolvedRel = relative(rootAbs, unresolved) if (unresolvedRel.startsWith('..')) throw new Error('doc.get path escapes project root') const abs = realpathSync.native(unresolved) const rel = relative(rootAbs, abs) if (rel.startsWith('..')) throw new Error('doc.get path escapes project root') return abs } const workflowStateDir = (ctx: McpContext): string => resolve(ctx.root, ctx.config.workflow?.stateDir ?? '.doc-bridge/workflow') const workflowRun = (ctx: McpContext) => loadWorkflowManifest(workflowStateDir(ctx)) const ensureLatestRun = (ctx: McpContext, runId?: string) => { const run = workflowRun(ctx) if (runId && run.runId !== runId) throw new Error(`Unknown workflow run "${runId}"`) if (run.state === 'stale' || run.state === 'failed') throw new Error(`Workflow run is ${run.state}; resume or create a valid run before reading artifacts.`) return run } const workflowSnapshot = (ctx: McpContext, runId?: string): DiscoverySnapshotV1 => { ensureLatestRun(ctx, runId) return parseDiscoverySnapshot(loadWorkflowStepOutput(workflowStateDir(ctx), 'normalize')) } const workflowReport = (ctx: McpContext, runId?: string): ReconciliationReportV1 => { ensureLatestRun(ctx, runId) return parseReconciliationReport(loadWorkflowStepOutput(workflowStateDir(ctx), 'reconcile')) } const proposalPath = (ctx: McpContext): string => join(ctx.root, '.doc-bridge', 'proposal.json') const readSavedProposal = (ctx: McpContext, input: unknown): RemediationV1 => { if (input !== undefined) return RemediationV1Schema.parse(input) return RemediationV1Schema.parse(JSON.parse(readBoundedText(proposalPath(ctx), { used: 0 })) as unknown) } const saveProposal = (ctx: McpContext, proposal: RemediationV1): void => { mkdirSync(join(ctx.root, '.doc-bridge'), { recursive: true }); writeFileSync(proposalPath(ctx), `${JSON.stringify(proposal, null, 2)}\n`, 'utf8') } const enabledMcpTools = (ctx: McpContext) => { const configured = ctx.config.surfaces?.mcp?.tools if (!configured || configured.length === MCP_TOOLS.length && MCP_TOOLS.every((tool) => configured.includes(tool.name as typeof configured[number]))) return MCP_TOOLS return MCP_TOOLS.filter((tool) => configured.includes(tool.name as typeof configured[number])) } /** The tools a service-profile caller may list and call; everything else is refused. */ const SERVICE_MCP_TOOL_NAMES: readonly string[] = ['knowledge.decision', 'knowledge.concept', 'knowledge.whyChanged', 'knowledge.search', 'knowledge.lookup', 'handoff.resolve', 'doc.search', 'doc.get', 'retriever.query', 'playbook.pattern.get', 'registry.topology'] /** Tools advertised by tools/list for this context: the configured set, narrowed for the service profile. */ const advertisedMcpTools = (ctx: McpContext, service: boolean) => service ? enabledMcpTools(ctx).filter((tool) => SERVICE_MCP_TOOL_NAMES.includes(tool.name)) : enabledMcpTools(ctx) /** * InitializeResult.instructions: a short, factual guide built only from the tools this server advertises. * Returns undefined when none of the guided tools is enabled, so the field is omitted rather than pointing at absent tools. */ export const buildMcpInstructions = (tools: readonly { readonly name: string }[]): string | undefined => { const names = new Set(tools.map((tool) => tool.name)) if (!['doc.search', 'doc.get', 'handoff.resolve'].some((name) => names.has(name))) return undefined const sentences = [ 'Doc Bridge indexes this repository\'s configured documentation (agent and human docs, specifications and ADRs) and its package ownership handoffs.', 'Results come from a deterministic local index; no API key or model call is involved.', 'Each result cites ids, paths and, where the source records them, line ranges.', 'Treat results as pointers and verify facts by reading the cited files.', ] if (names.has('doc.search')) sentences.push('For questions about how the repository works, its contracts, architecture, decisions or ownership, call doc.search before broad grep.') if (names.has('doc.get')) sentences.push('Call doc.get with an id or path from a search or handoff result to read that document in full.') if (names.has('handoff.resolve')) sentences.push('For a package or an owner, call handoff.resolve to get its start-here documents, edit roots and checks.') return sentences.join(' ') } const assertMcpToolEnabled = (ctx: McpContext, name: string): void => { if (!MCP_TOOLS.some((tool) => tool.name === name)) throw new Error(`Unknown tool "${name}"`) if (!enabledMcpTools(ctx).some((tool) => tool.name === name)) throw new Error(`MCP tool "${name}" is disabled by configuration.`) } export const handleMcpRequest = (ctx: McpContext, request: JsonRpcRequest): unknown => withExecutionProfile(isServiceProfile(ctx.config) ? 'service' : ctx.profile, () => { const service = executionContext().profile === 'service' if (service) ctx = { ...ctx, config: serviceConfig(ctx.config).config } const toolResult = (value: unknown) => { const response = textResult(value) if (!service) return response const diagnostics = serviceConfig(ctx.config).diagnostics return { ...response, _meta: { serviceProfile: { diagnostics, coverage: serviceCoverage(diagnostics) } } } } if (request.method === 'initialize') { const instructions = buildMcpInstructions(advertisedMcpTools(ctx, service)) return { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'ak-docs', version: PACKAGE_VERSION }, ...(instructions === undefined ? {} : { instructions }), } } if (request.method === 'tools/list') return { tools: advertisedMcpTools(ctx, service) } if (request.method === 'tools/call') { const params = asRecord(request.params) const name = params.name const args = asRecord(params.arguments) if (typeof name !== 'string') throw new Error('MCP tools/call requires a tool name.') if (service && (!SERVICE_MCP_TOOL_NAMES.includes(name) ||!ctx.loadIndex || (name === 'doc.get' && !ctx.readDocument))) throw new Error('not-analyzed: service profile requires injected reads and refuses mutating/agent or filesystem tools') assertMcpToolEnabled(ctx, name) const index = () => ctx.loadIndex?.() ?? loadFreshDocBridgeIndex(ctx.root, ctx.config) if (name === 'handoff.resolve') { const parsed = parseToolArgs('handoff.resolve', HandoffResolveArgsSchema, args) const loaded = index() const handoff = runQuery(loaded, ctx.config, { kind: parsed.kind === 'package' ? 'package' : 'ownership', id: parsed.id, agent: true, }, service ? {} : { root: ctx.root }) // The payload is the handoff it always was; a declared budget adds `budget` and may shed `related` and the summary note. return toolResult(parsed.budgetTokens === undefined ? handoff : budgetedHandoff(loaded, handoff as AgentHandoffV1, parsed.budgetTokens)) } if (['knowledge.decision', 'knowledge.concept', 'knowledge.whyChanged'].includes(name)) { const { format, ...parsed } = parseToolArgs(name, KnowledgeWhyRequestSchema.omit({ kind: true }).extend({ format: z.enum(['json', 'text']).optional() }), args) const response = knowledgeWhy(index(), { ...parsed, kind: name === 'knowledge.decision' ? 'decision' : name === 'knowledge.concept' ? 'concept' : 'change' }, service) return toolResult(format === 'text' ? formatKnowledgeWhyText(response) : response) } if (name === 'knowledge.search') { const { format, ...parsed } = parseToolArgs('knowledge.search', KnowledgeSearchArgsSchema, args) const response = knowledgeSearch(index(), parsed) return toolResult(format === 'text' ? formatKnowledgeSearchText(response) : response) } if (name === 'knowledge.lookup') { const { format, ...parsed } = parseToolArgs('knowledge.lookup', KnowledgeLookupArgsSchema, args) // Diagnostics come from the latest workflow run when there is one; a repository never checked still gets its lookup. const report = () => { try { return workflowReport(ctx) } catch { return undefined } } const response = knowledgeLookup(index(), ctx.config, parsed, service ? {} : { root: ctx.root, report }) return toolResult(format === 'text' ? formatKnowledgeLookupText(response) : response) } if (name === 'doc.search') { const parsed = parseToolArgs('doc.search', DocSearchArgsSchema, args) if (parsed.agent) return toolResult(runQuery(index(), ctx.config, { kind: 'search', term: parsed.term, agent: true, ...(parsed.mode === undefined ? {} : { mode: parsed.mode }), ...(parsed.contextBudgetTokens === undefined ? {} : { contextBudgetTokens: parsed.contextBudgetTokens }) })) return toolResult(searchIndex(index(), parsed.term, parsed.limit ?? 20)) } if (name === 'doc.get') { const relPath = findDocPath(index(), parseToolArgs('doc.get', DocGetArgsSchema, args)) return toolResult(ctx.readDocument ? ctx.readDocument(relPath) : readFileSync(resolveDocPath(ctx.root, relPath), 'utf8')) } if (name === 'gate.status') return toolResult(runGates(ctx.root, ctx.config)) if (name === 'retriever.query') { const parsed = parseToolArgs('retriever.query', RetrieverQueryArgsSchema, args) return toolResult(retrieveDocBridgeChunks(index(), parsed.query, parsed.limit ? { limit: parsed.limit } : {})) } if (name === 'memory.classify') { return toolResult(classifyMemoryCandidates(ingestMemoryCandidates(ctx.root, ctx.config.intelligence), index())) } if (name === 'memory.promoteDraft') { return toolResult(draftMemoryPromotion(classifyMemoryCandidates(ingestMemoryCandidates(ctx.root, ctx.config.intelligence), index()))) } if (name === 'registry.topology') { return toolResult({ id: 'doc-curator', delegates: ['docs-chat', 'knowledge-promoter', 'code-review'], tools: ['handoff.resolve', 'doc.search', 'doc.get', 'gate.status', 'retriever.query'], steps: ['classify', 'draft', 'verify', 'review'], mergePolicy: { autoMerge: false, requiresHuman: true }, }) } if (name === 'docbridge.snapshot') { const parsed = parseToolArgs('docbridge.snapshot', WorkflowRunArgsSchema, args) return toolResult(redactValue(workflowSnapshot(ctx, parsed.runId))) } if (name === 'docbridge.report') { const parsed = parseToolArgs('docbridge.report', WorkflowRunArgsSchema, args) return toolResult(redactValue(workflowReport(ctx, parsed.runId))) } if (name === 'docbridge.diagnostics') { const parsed = parseToolArgs('docbridge.diagnostics', DiagnosticsArgsSchema, args) const diagnostics = workflowReport(ctx).diagnostics.filter((diagnostic) => (!parsed.status || diagnostic.status === parsed.status) && (!parsed.severity || diagnostic.severity === parsed.severity), ) // `finding` is the ecosystem shape; the filters above still speak the internal vocabulary. if (parsed.format === 'finding') return toolResult(redactValue({ reportHash: workflowReport(ctx).contentHash, findings: findingsFromDiagnostics(diagnostics) })) return toolResult(redactValue({ reportHash: workflowReport(ctx).contentHash, diagnostics })) } if (name === 'docbridge.relations') { const parsed = parseToolArgs('docbridge.relations', RelationsArgsSchema, args) const snapshot = workflowSnapshot(ctx) return toolResult({ snapshotHash: snapshot.contentHash, relations: snapshot.relations.filter((relation) => !parsed.kind || relation.kind === parsed.kind).slice(0, parsed.limit ?? 100) }) } if (name === 'docbridge.run') { parseToolArgs('docbridge.run', z.object({}), args) return toolResult(workflowRun(ctx)) } if (name === 'docbridge.proposals') { const parsed = parseToolArgs('docbridge.proposals', ProposalsArgsSchema, args) const run = (() => { try { return workflowRun(ctx) } catch { return undefined } })() // KR-10: enrichment proposals share this tool; a decision goes through the ecosystem approval gate. if (parsed.action === 'enrich-list') return toolResult(redactValue({ ...(run ? { runId: run.runId } : {}), enrichment: listEnrichment(ctx.root) ?? null })) if (parsed.action === 'enrich-approve' || parsed.action === 'enrich-reject') { if (!parsed.proposalId) throw new Error(`docbridge.proposals ${parsed.action} requires proposalId`) const proposalId = parsed.proposalId return (async () => { const snapshot = (() => { try { return workflowSnapshot(ctx) } catch { return undefined } })() const decided = await decideEnrichment({ root: ctx.root, proposalId, decision: parsed.action === 'enrich-approve' ? 'approved' : 'rejected', by: parsed.approvedBy ?? 'human', ...(parsed.reason ? { reason: parsed.reason } : {}), ...(snapshot ? { snapshot } : {}) }) return toolResult(redactValue({ ...(run ? { runId: run.runId } : {}), approvalId: decided.approvalId, entry: decided.entry, overlayHash: decided.overlay.contentHash })) })() } if (!parsed.action || parsed.action === 'list') { let proposal: RemediationV1 | undefined try { proposal = readSavedProposal(ctx, undefined) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } return toolResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposals: proposal ? [proposal] : [] })) } if (parsed.action === 'suggest') { return (async () => { const runner = ctx.config.intelligence?.registry?.cli ? undefined : await loadRegistryAgentRunner(ctx.root, ctx.config) const snapshot = workflowSnapshot(ctx) const report = workflowReport(ctx) const adapter = createRegistryAgentAdapter(ctx.root, ctx.config, ctx.config.intelligence?.registry?.cli ? undefined : runner) const proposal = await adapter.run(snapshot, report) const savedPath = persistRegistryAgentProposal(workflowStateDir(ctx), proposal) return toolResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal, proposalPath: savedPath })) })() } if (parsed.action === 'approve' || parsed.action === 'apply') throw new Error('MCP cannot approve or apply remediations; return to a human through the local CLI') if (!parsed.base || !parsed.allowedRoots) throw new Error('Region remediation requires base snapshot and caller allowedRoots') const { proposal } = remediationWorkflow(ctx.root, ctx.config, parsed.base, readSavedProposal(ctx, parsed.proposal), parsed.allowedRoots) saveProposal(ctx, proposal) return toolResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal })) } throw new Error(`Unknown tool "${String(name)}"`) } if (request.method?.startsWith('notifications/')) return undefined throw new Error(`Unsupported MCP method "${request.method ?? ''}"`) }) type StdioFraming = 'content-length' | 'json-line' const writeFrame = (payload: unknown, framing: StdioFraming): void => { const body = JSON.stringify(payload) process.stdout.write(framing === 'json-line' ? `${body}\n` : `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`) } export const respondMcpRequest = async (ctx: McpContext, request: JsonRpcRequest, framing: StdioFraming): Promise => { if (request.id === undefined) { try { await handleMcpRequest(ctx, request) } catch { // Notifications do not get responses. } return } try { const result = await handleMcpRequest(ctx, request) writeFrame({ jsonrpc: '2.0', id: request.id, result: result ?? {} }, framing) } catch (error) { writeFrame({ jsonrpc: '2.0', id: request.id, error: { code: -32000, message: error instanceof Error ? error.message : String(error) }, }, framing) } } export const startMcpStdioServer = (ctx: McpContext): void => { let buffer = Buffer.alloc(0) let responseChain = Promise.resolve() const enqueueResponse = (request: JsonRpcRequest, framing: StdioFraming): void => { responseChain = responseChain.then(() => respondMcpRequest(ctx, request, framing)) } process.stdin.on('data', (chunk: Buffer) => { buffer = Buffer.concat([buffer, chunk]) while (true) { if (/^content-length:/i.test(buffer.subarray(0, Math.min(buffer.length, 32)).toString('utf8'))) { const headerEnd = buffer.indexOf('\r\n\r\n') if (headerEnd === -1) return const header = buffer.subarray(0, headerEnd).toString('utf8') const match = /content-length:\s*(\d+)/i.exec(header) if (!match?.[1]) { buffer = buffer.subarray(headerEnd + 4) continue } const length = Number(match[1]) const bodyStart = headerEnd + 4 const bodyEnd = bodyStart + length if (buffer.length < bodyEnd) return const raw = buffer.subarray(bodyStart, bodyEnd).toString('utf8') buffer = buffer.subarray(bodyEnd) enqueueResponse(JSON.parse(raw) as JsonRpcRequest, 'content-length') continue } const lineEnd = buffer.indexOf('\n') if (lineEnd === -1) return const raw = buffer.subarray(0, lineEnd).toString('utf8').trim() buffer = buffer.subarray(lineEnd + 1) if (!raw) continue try { enqueueResponse(JSON.parse(raw) as JsonRpcRequest, 'json-line') } catch { writeFrame({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, 'json-line') } } }) process.stdin.resume() }