@agentskit/doc-bridge 1.4.3 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'
3
+
4
+ import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
5
+ import { FixProposalV1Schema, type FixProposalV1 } from '../schemas/knowledge.js'
6
+ import { containedPath } from '../safety/repository.js'
7
+
8
+ export type FixProposalOptions = {
9
+ readonly baseRevision: string
10
+ readonly configurationHash: string
11
+ readonly projectName?: string
12
+ readonly toolVersion?: string
13
+ }
14
+
15
+ export type FixApplyOptions = {
16
+ readonly currentRevision?: string
17
+ readonly verify?: (changedPaths: readonly string[]) => void
18
+ }
19
+
20
+ type FixChange = { readonly path: string; readonly before: string; readonly after: string }
21
+
22
+ const hash = (value: unknown): string => sha256NormalizedV1(value)
23
+
24
+ const artifactMetadata = (root: string, options: FixProposalOptions) => ({
25
+ schemaVersion: 1 as const,
26
+ contentHash: '0'.repeat(64),
27
+ contentHashAlgo: 'sha256-normalized-v1' as const,
28
+ project: { name: options.projectName ?? basename(resolve(root)), root: '.' },
29
+ sourceRevision: options.baseRevision,
30
+ sourceRevisionKind: options.baseRevision.length === 40 ? 'git' as const : 'content' as const,
31
+ configurationHash: options.configurationHash,
32
+ pipelineVersion: '1.0.0',
33
+ analyzerVersions: { fixes: options.toolVersion ?? '1.0.0' },
34
+ })
35
+
36
+ const unifiedDiff = (changes: readonly FixChange[]): string => changes.map((change) => {
37
+ const before = change.before.split('\n').map((line) => `-${line}`).join('\n')
38
+ const after = change.after.split('\n').map((line) => `+${line}`).join('\n')
39
+ return `--- a/${change.path}\n+++ b/${change.path}\n@@\n${before}\n${after}`
40
+ }).join('\n')
41
+
42
+ const makeProposal = (root: string, options: FixProposalOptions, changes: readonly FixChange[], preconditions: readonly string[], postconditions: readonly string[]): FixProposalV1 => {
43
+ const draft = {
44
+ ...artifactMetadata(root, options),
45
+ type: 'fix-proposal' as const,
46
+ proposalId: `fix-${hash(changes).slice(0, 20)}`,
47
+ baseRevision: options.baseRevision,
48
+ affectedFiles: changes.map(({ path, before }) => ({ path, contentHash: sha256NormalizedV1(before) })),
49
+ changes,
50
+ preconditions,
51
+ diff: unifiedDiff(changes),
52
+ postconditions,
53
+ status: 'proposed' as const,
54
+ }
55
+ return FixProposalV1Schema.parse({ ...draft, contentHash: contentHashForArtifactV1(draft) })
56
+ }
57
+
58
+ const walkMarkdown = (root: string, directory = root): string[] => readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
59
+ if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build') return []
60
+ const path = join(directory, entry.name)
61
+ if (entry.isDirectory()) return walkMarkdown(root, path)
62
+ return entry.isFile() && ['.md', '.mdx'].includes(extname(entry.name).toLowerCase()) ? [relative(root, path).split(sep).join('/')] : []
63
+ })
64
+
65
+ const localLink = /(!?)\[([^\]]*)\]\(([^)\s]+)(?:\s+["'][^)]*["'])?\)/g
66
+
67
+ const sortJson = (value: unknown): unknown => Array.isArray(value)
68
+ ? value.map(sortJson)
69
+ : value && typeof value === 'object'
70
+ ? Object.fromEntries(Object.entries(value as Record<string, unknown>).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, sortJson(item)]))
71
+ : value
72
+
73
+ export const createMarkdownLinkFixProposal = (root: string, options: FixProposalOptions): FixProposalV1 | undefined => {
74
+ const projectRoot = realpathSync.native(resolve(root))
75
+ const paths = walkMarkdown(projectRoot)
76
+ const changes: FixChange[] = []
77
+ for (const path of paths) {
78
+ const content = readFileSync(join(projectRoot, path), 'utf8')
79
+ let next = content
80
+ for (const match of content.matchAll(localLink)) {
81
+ const target = match[3]
82
+ if (!target || match[1] === '!' || /^(?:[a-z]+:|\/|#)/i.test(target)) continue
83
+ const targetPath = target.split('#')[0]?.split('?')[0]
84
+ if (!targetPath || existsSync(resolve(projectRoot, dirname(path), targetPath))) continue
85
+ const targetStem = basename(targetPath, extname(targetPath)).toLowerCase()
86
+ const labelStem = (match[2] ?? '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '')
87
+ const candidates = paths.filter((candidate) => basename(candidate, extname(candidate)).toLowerCase() === targetStem || basename(candidate, extname(candidate)).toLowerCase().replace(/[^a-z0-9]+/g, '') === labelStem)
88
+ if (candidates.length !== 1) continue
89
+ let replacement = relative(dirname(path), candidates[0]!).split(sep).join('/')
90
+ if (target.startsWith('./') && !replacement.startsWith('.')) replacement = `./${replacement}`
91
+ next = next.replace(match[0], match[0].replace(target, replacement))
92
+ }
93
+ if (next !== content) changes.push({ path, before: content, after: next })
94
+ }
95
+ return changes.length ? makeProposal(projectRoot, options, changes, ['Each replacement has exactly one Markdown target.'], ['All corrected local Markdown links resolve.']) : undefined
96
+ }
97
+
98
+ export const createArtifactNormalizationProposal = (root: string, artifactPath: string, options: FixProposalOptions): FixProposalV1 | undefined => {
99
+ const projectRoot = realpathSync.native(resolve(root))
100
+ const path = artifactPath.split(sep).join('/')
101
+ const absolute = containedPath(projectRoot, path)
102
+ if (!absolute || !existsSync(absolute) || !statSync(absolute).isFile()) return undefined
103
+ const before = readFileSync(absolute, 'utf8')
104
+ let after: string
105
+ try { after = `${JSON.stringify(sortJson(JSON.parse(before) as unknown), null, 2)}\n` } catch { return undefined }
106
+ return after === before ? undefined : makeProposal(projectRoot, options, [{ path: relative(projectRoot, absolute).split(sep).join('/'), before, after }], ['The artifact contains valid JSON.'], ['The artifact is valid canonical JSON with one trailing newline.'])
107
+ }
108
+
109
+ export const approveFixProposal = (proposalInput: unknown, approvedBy: string, approvedAt = new Date().toISOString()): FixProposalV1 => {
110
+ const proposal = FixProposalV1Schema.parse(proposalInput)
111
+ if (proposal.status !== 'proposed') throw new Error(`Only proposed fixes can be approved; received "${proposal.status}".`)
112
+ const approved = { ...proposal, status: 'approved' as const, approval: { proposalHash: proposal.contentHash, approvedAt, approvedBy }, contentHash: '0'.repeat(64) }
113
+ return FixProposalV1Schema.parse({ ...approved, contentHash: contentHashForArtifactV1(approved) })
114
+ }
115
+
116
+ const bindingHash = (proposal: FixProposalV1): string => {
117
+ const { approval: _approval, contentHash: _contentHash, status: _status, ...rest } = proposal
118
+ return contentHashForArtifactV1({ ...rest, contentHash: '0'.repeat(64), status: 'proposed' as const })
119
+ }
120
+
121
+ export const applyFixProposal = (root: string, proposalInput: unknown, options: FixApplyOptions = {}): FixProposalV1 => {
122
+ const proposal = FixProposalV1Schema.parse(proposalInput)
123
+ if (proposal.status !== 'approved' || !proposal.approval) throw new Error('Only an explicitly approved fix proposal can be applied.')
124
+ if (proposal.approval.proposalHash !== bindingHash(proposal)) throw new Error('Approval is not bound to the exact proposal content.')
125
+ if (options.currentRevision && options.currentRevision !== proposal.baseRevision) throw new Error('The repository revision changed since this proposal was created.')
126
+ if (!proposal.changes?.length) throw new Error('This proposal has no executable changes.')
127
+
128
+ const projectRoot = realpathSync.native(resolve(root))
129
+ const originals = new Map<string, string>()
130
+ const affected = new Map(proposal.affectedFiles.map((file) => [file.path, file]))
131
+ if (proposal.changes.some((change) => !affected.has(change.path) || affected.get(change.path)?.contentHash !== sha256NormalizedV1(change.before)) || affected.size !== proposal.changes.length) {
132
+ throw new Error('Proposal changes do not match its affected-file hashes.')
133
+ }
134
+ for (const file of proposal.affectedFiles) {
135
+ const absolute = containedPath(projectRoot, file.path)
136
+ if (!absolute || !existsSync(absolute)) throw new Error(`Affected file is unavailable or escapes the repository root: ${file.path}`)
137
+ const current = readFileSync(absolute, 'utf8')
138
+ if (sha256NormalizedV1(current) !== file.contentHash) throw new Error(`Affected file changed since proposal creation: ${file.path}`)
139
+ originals.set(absolute, current)
140
+ }
141
+
142
+ try {
143
+ for (const change of proposal.changes) {
144
+ const absolute = resolve(projectRoot, change.path)
145
+ writeFileSync(`${absolute}.docbridge-${process.pid}.tmp`, change.after, 'utf8')
146
+ }
147
+ for (const change of proposal.changes) {
148
+ const absolute = resolve(projectRoot, change.path)
149
+ renameSync(`${absolute}.docbridge-${process.pid}.tmp`, absolute)
150
+ }
151
+ for (const change of proposal.changes) {
152
+ if (readFileSync(resolve(projectRoot, change.path), 'utf8') !== change.after) throw new Error(`Postcondition failed for ${change.path}`)
153
+ }
154
+ options.verify?.(proposal.changes.map((change) => change.path))
155
+ } catch (error) {
156
+ for (const [absolute, content] of originals) writeFileSync(absolute, content, 'utf8')
157
+ for (const change of proposal.changes) {
158
+ const temp = `${resolve(projectRoot, change.path)}.docbridge-${process.pid}.tmp`
159
+ if (existsSync(temp)) unlinkSync(temp)
160
+ }
161
+ throw error
162
+ }
163
+ const applied = { ...proposal, status: 'applied' as const, contentHash: '0'.repeat(64) }
164
+ return FixProposalV1Schema.parse({ ...applied, contentHash: contentHashForArtifactV1(applied) })
165
+ }
@@ -13,7 +13,14 @@ const sortValue = (value: unknown): unknown => {
13
13
  return value
14
14
  }
15
15
 
16
+ export const canonicalJsonV1 = (payload: unknown): string => JSON.stringify(sortValue(payload))
17
+
16
18
  export const sha256NormalizedV1 = (payload: unknown): string => {
17
- const normalized = JSON.stringify(sortValue(payload))
19
+ const normalized = canonicalJsonV1(payload)
18
20
  return createHash('sha256').update(normalized, 'utf8').digest('hex')
19
- }
21
+ }
22
+
23
+ export const contentHashForArtifactV1 = <T extends { readonly contentHash: string }>(artifact: T): string => {
24
+ const { contentHash: _contentHash, ...payload } = artifact
25
+ return sha256NormalizedV1(payload)
26
+ }
package/src/index.ts CHANGED
@@ -15,6 +15,16 @@ export {
15
15
  type DocBridgeConfigV1,
16
16
  type AgentCorpusConfig,
17
17
  type DocumentationStandardV1Config,
18
+ RuleIdSchema,
19
+ RuleSeveritySchema,
20
+ RulesConfigSchema,
21
+ WorkflowConfigSchema,
22
+ RepositorySafetyConfigSchema,
23
+ type RuleId,
24
+ type RuleSeverity,
25
+ type RulesConfig,
26
+ type WorkflowConfig,
27
+ type RepositorySafetyConfig,
18
28
  } from './config/schema.js'
19
29
 
20
30
  export {
@@ -55,16 +65,63 @@ export {
55
65
 
56
66
  export {
57
67
  parseAgentHandoff,
68
+ parseAgentProposal,
58
69
  parseAgentSearch,
59
70
  parseDocBridgeConfig,
60
71
  parseDocBridgeIndex,
72
+ parseDiscoverySnapshot,
73
+ parseFixProposal,
61
74
  parseMemoryCandidate,
75
+ parseReconciliationReport,
76
+ parseWorkflowRun,
62
77
  safeParseAgentHandoff,
63
78
  type ParseIssue,
64
79
  type ParseResult,
65
80
  } from './validate.js'
66
81
 
67
82
  export { buildDocBridgeIndex, type BuildIndexOptions, type BuildIndexResult } from './index-builder/build-index.js'
83
+ export { discoverRepository, type DiscoveryOptions } from './discovery/repository.js'
84
+ export { containedPath, DEFAULT_SAFETY_EXCLUDES, redactSecrets, redactValue, safeWalkFiles, type SafeWalkOptions, type SafeWalkResult } from './safety/repository.js'
85
+ export { DEFAULT_REGISTRY_AGENT_ID, createRegistryAgentAdapter, loadRegistryAgentMetadata, loadRegistryAgentRunner, persistRegistryAgentProposal, type RegistryAgentAdapter, type RegistryAgentContext, type RegistryAgentMetadata, type RegistryAgentRunner } from './agents/registry-adapter.js'
86
+ export {
87
+ applyDocumentationDeclarations,
88
+ parseDocumentationDeclarations,
89
+ type DocumentationAnalysisResult,
90
+ type DocumentationDeclarationInput,
91
+ type DocumentationDeclarationOptions,
92
+ type DocumentationDeclarationResult,
93
+ type DocumentationDiagnostic,
94
+ } from './discovery/documentation.js'
95
+ export { reconcileKnowledge } from './reconciliation/reconcile.js'
96
+ export { renderOfflineReport, type OfflineReportInput, type OfflineReportOptions } from './report/html.js'
97
+ export {
98
+ applyFixProposal,
99
+ approveFixProposal,
100
+ createArtifactNormalizationProposal,
101
+ createMarkdownLinkFixProposal,
102
+ type FixApplyOptions,
103
+ type FixProposalOptions,
104
+ } from './fixes/proposals.js'
105
+ export {
106
+ WORKFLOW_STAGES,
107
+ loadWorkflowManifest,
108
+ loadWorkflowStepOutput,
109
+ runWorkflow,
110
+ type WorkflowExecutionResult,
111
+ type WorkflowOptions,
112
+ type WorkflowStage,
113
+ type WorkflowStageContext,
114
+ type WorkflowStageHandler,
115
+ } from './workflow/engine.js'
116
+ export {
117
+ evaluateRules,
118
+ parseRuleId,
119
+ parseRuleSeverity,
120
+ type RuleEngineOptions,
121
+ type RuleEvaluationResult,
122
+ type RuleFinding,
123
+ type RuleMode,
124
+ } from './rules/engine.js'
68
125
  export {
69
126
  formatEcosystemLlmsBlock,
70
127
  formatEcosystemLlmsSection,
@@ -98,7 +155,7 @@ export {
98
155
  type DocumentationStandardRuleResult,
99
156
  type DocumentationStandardRuleStatus,
100
157
  } from './conformance/documentation-standard-v1.js'
101
- export { MCP_TOOLS, handleMcpRequest, startMcpStdioServer } from './mcp/server.js'
158
+ export { MCP_TOOLS, handleMcpRequest, respondMcpRequest, startMcpStdioServer } from './mcp/server.js'
102
159
  export { installMcpConfig, mcpSnippet, type McpInstallResult, type McpInstallTarget } from './mcp/install.js'
103
160
  export { runDoctor, formatDoctorText, type DoctorReport, type DoctorIssue, type DoctorCoverage } from './doctor/run-doctor.js'
104
161
  export {
@@ -115,7 +172,47 @@ export {
115
172
  type GithubPrOptions,
116
173
  type GithubPrResult,
117
174
  } from './memory/github-pr.js'
118
- export { sha256NormalizedV1 } from './index-builder/content-hash.js'
175
+ export { canonicalJsonV1, contentHashForArtifactV1, sha256NormalizedV1 } from './index-builder/content-hash.js'
176
+ export {
177
+ AgentProposalV1Schema,
178
+ AffectedFileSchema,
179
+ CoverageSchema,
180
+ DiagnosticSeveritySchema,
181
+ DiscoverySnapshotV1Schema,
182
+ EntitySchema,
183
+ EvidenceSchema,
184
+ EvidenceSourceSchema,
185
+ FindingStatusSchema,
186
+ FixProposalStatusSchema,
187
+ FixChangeSchema,
188
+ FixProposalV1Schema,
189
+ KNOWLEDGE_CONTENT_HASH_ALGO,
190
+ KNOWLEDGE_SCHEMA_VERSION,
191
+ ProvenanceSchema,
192
+ ProjectIdentitySchema,
193
+ ProposalOriginSchema,
194
+ ReconciliationReportV1Schema,
195
+ RelationSchema,
196
+ WorkflowRunV1Schema,
197
+ WorkflowStepSchema,
198
+ WorkflowStateSchema,
199
+ WorkflowTransitionSchema,
200
+ type AgentProposalV1,
201
+ type DiagnosticSeverity,
202
+ type DiscoverySnapshotV1,
203
+ type Evidence,
204
+ type FindingStatus,
205
+ type FixProposalV1,
206
+ type FixChange,
207
+ type KnowledgeArtifactV1,
208
+ type KnowledgeDiagnostic,
209
+ type KnowledgeEntity,
210
+ type KnowledgeRelation,
211
+ type Provenance,
212
+ type ReconciliationReportV1,
213
+ type WorkflowRunV1,
214
+ type WorkflowState,
215
+ } from './schemas/knowledge.js'
119
216
  export { IndexNotFoundError, indexFilePath, loadDocBridgeIndex, resolveRoot } from './query/load-index.js'
120
217
  export { runQuery, type QueryKind, type QueryRequest, type QueryResult } from './query/query.js'
121
218
  export { searchIndex, type SearchMatch } from './query/search.js'
package/src/mcp/server.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { readFileSync, realpathSync } from 'node:fs'
2
- import { relative, resolve } from 'node:path'
1
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
2
+ import { join, relative, resolve } from 'node:path'
3
3
 
4
4
  import { z, ZodError } from 'zod'
5
5
 
@@ -13,6 +13,14 @@ import { runQuery } from '../query/query.js'
13
13
  import { searchIndex } from '../query/search.js'
14
14
  import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
15
15
  import { PACKAGE_VERSION } from '../version.js'
16
+ import { loadWorkflowManifest, loadWorkflowStepOutput } from '../workflow/engine.js'
17
+ import { parseDiscoverySnapshot, parseReconciliationReport } from '../validate.js'
18
+ import { applyFixProposal, approveFixProposal, createArtifactNormalizationProposal, createMarkdownLinkFixProposal } from '../fixes/proposals.js'
19
+ import { createRegistryAgentAdapter, loadRegistryAgentRunner, persistRegistryAgentProposal } from '../agents/registry-adapter.js'
20
+ import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
21
+ import { discoverRepository } from '../discovery/repository.js'
22
+ import { FixProposalV1Schema, type DiscoverySnapshotV1, type ReconciliationReportV1, type FixProposalV1 } from '../schemas/knowledge.js'
23
+ import { redactValue } from '../safety/repository.js'
16
24
 
17
25
  type JsonRpcRequest = {
18
26
  readonly jsonrpc?: '2.0'
@@ -99,6 +107,47 @@ export const MCP_TOOLS = [
99
107
  annotations: { readOnlyHint: true },
100
108
  inputSchema: { type: 'object', properties: {} },
101
109
  },
110
+ {
111
+ name: 'docbridge.snapshot',
112
+ title: 'Read the latest discovery snapshot',
113
+ description: 'Read the bounded canonical repository snapshot from the latest workflow run.',
114
+ annotations: { readOnlyHint: true },
115
+ inputSchema: { type: 'object', properties: { runId: { type: 'string' } } },
116
+ },
117
+ {
118
+ name: 'docbridge.report',
119
+ title: 'Read the latest reconciliation report',
120
+ description: 'Read the canonical reconciliation report from the latest workflow run.',
121
+ annotations: { readOnlyHint: true },
122
+ inputSchema: { type: 'object', properties: { runId: { type: 'string' } } },
123
+ },
124
+ {
125
+ name: 'docbridge.diagnostics',
126
+ title: 'Read reconciliation diagnostics',
127
+ description: 'Read bounded diagnostics from the latest canonical reconciliation report.',
128
+ annotations: { readOnlyHint: true },
129
+ inputSchema: { type: 'object', properties: { status: { type: 'string' }, severity: { type: 'string' } } },
130
+ },
131
+ {
132
+ name: 'docbridge.relations',
133
+ title: 'Read architecture relations',
134
+ description: 'Read bounded observed and declared relations from the latest canonical snapshot.',
135
+ annotations: { readOnlyHint: true },
136
+ inputSchema: { type: 'object', properties: { kind: { type: 'string' }, limit: { type: 'number' } } },
137
+ },
138
+ {
139
+ name: 'docbridge.run',
140
+ title: 'Read workflow state',
141
+ description: 'Read the latest resumable workflow state and artifact references.',
142
+ annotations: { readOnlyHint: true },
143
+ inputSchema: { type: 'object', properties: {} },
144
+ },
145
+ {
146
+ name: 'docbridge.proposals',
147
+ title: 'Read or approve proposals',
148
+ description: 'Create, inspect, approve and apply deterministic proposals through the shared human-gated workflow.',
149
+ inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'propose-links', 'propose-normalize', 'suggest', 'approve', 'apply'] }, proposalHash: { type: 'string' }, artifactPath: { type: 'string' }, approvedBy: { type: 'string' }, proposal: { type: 'object' } } },
150
+ },
102
151
  ] as const
103
152
 
104
153
  const asRecord = (value: unknown): Record<string, unknown> =>
@@ -126,6 +175,11 @@ const DocGetArgsSchema = z
126
175
  })
127
176
  .refine((args) => args.id || args.path, 'doc.get requires id or path')
128
177
 
178
+ const WorkflowRunArgsSchema = z.object({ runId: z.string().min(1).optional() })
179
+ const DiagnosticsArgsSchema = z.object({ status: z.string().min(1).optional(), severity: z.string().min(1).optional() })
180
+ const RelationsArgsSchema = z.object({ kind: z.string().min(1).optional(), limit: z.number().int().positive().max(500).optional() })
181
+ const ProposalsArgsSchema = z.object({ action: z.enum(['list', 'propose-links', 'propose-normalize', 'suggest', 'approve', 'apply']).optional(), proposalHash: z.string().min(1).optional(), artifactPath: z.string().min(1).optional(), approvedBy: z.string().min(1).optional(), proposal: z.unknown().optional() })
182
+
129
183
  const parseToolArgs = <T>(tool: string, schema: z.ZodType<T>, value: unknown): T => {
130
184
  try {
131
185
  return schema.parse(value)
@@ -169,6 +223,45 @@ const resolveDocPath = (root: string, relPath: string): string => {
169
223
  return abs
170
224
  }
171
225
 
226
+ const workflowStateDir = (ctx: McpContext): string => resolve(ctx.root, ctx.config.workflow?.stateDir ?? '.doc-bridge/workflow')
227
+
228
+ const workflowRun = (ctx: McpContext) => loadWorkflowManifest(workflowStateDir(ctx))
229
+
230
+ const ensureLatestRun = (ctx: McpContext, runId?: string) => {
231
+ const run = workflowRun(ctx)
232
+ if (runId && run.runId !== runId) throw new Error(`Unknown workflow run "${runId}"`)
233
+ if (run.state === 'stale' || run.state === 'failed') throw new Error(`Workflow run is ${run.state}; resume or create a valid run before reading artifacts.`)
234
+ return run
235
+ }
236
+
237
+ const workflowSnapshot = (ctx: McpContext, runId?: string): DiscoverySnapshotV1 => {
238
+ ensureLatestRun(ctx, runId)
239
+ return parseDiscoverySnapshot(loadWorkflowStepOutput(workflowStateDir(ctx), 'normalize'))
240
+ }
241
+
242
+ const workflowReport = (ctx: McpContext, runId?: string): ReconciliationReportV1 => {
243
+ ensureLatestRun(ctx, runId)
244
+ return parseReconciliationReport(loadWorkflowStepOutput(workflowStateDir(ctx), 'reconcile'))
245
+ }
246
+
247
+ const proposalPath = (ctx: McpContext): string => join(ctx.root, '.doc-bridge', 'proposal.json')
248
+ const readSavedProposal = (ctx: McpContext, input: unknown): FixProposalV1 => {
249
+ if (input !== undefined) return FixProposalV1Schema.parse(input)
250
+ try { return FixProposalV1Schema.parse(JSON.parse(readFileSync(proposalPath(ctx), 'utf8')) as unknown) } catch { throw new Error(`No saved fix proposal at ${proposalPath(ctx)}.`) }
251
+ }
252
+ const saveProposal = (ctx: McpContext, proposal: FixProposalV1): void => { mkdirSync(join(ctx.root, '.doc-bridge'), { recursive: true }); writeFileSync(proposalPath(ctx), `${JSON.stringify(proposal, null, 2)}\n`, 'utf8') }
253
+
254
+ const enabledMcpTools = (ctx: McpContext) => {
255
+ const configured = ctx.config.surfaces?.mcp?.tools
256
+ if (!configured || configured.length === MCP_TOOLS.length && MCP_TOOLS.every((tool) => configured.includes(tool.name as typeof configured[number]))) return MCP_TOOLS
257
+ return MCP_TOOLS.filter((tool) => configured.includes(tool.name as typeof configured[number]))
258
+ }
259
+
260
+ const assertMcpToolEnabled = (ctx: McpContext, name: string): void => {
261
+ if (!MCP_TOOLS.some((tool) => tool.name === name)) throw new Error(`Unknown tool "${name}"`)
262
+ if (!enabledMcpTools(ctx).some((tool) => tool.name === name)) throw new Error(`MCP tool "${name}" is disabled by configuration.`)
263
+ }
264
+
172
265
  export const handleMcpRequest = (ctx: McpContext, request: JsonRpcRequest): unknown => {
173
266
  if (request.method === 'initialize') {
174
267
  return {
@@ -178,12 +271,14 @@ export const handleMcpRequest = (ctx: McpContext, request: JsonRpcRequest): unkn
178
271
  }
179
272
  }
180
273
 
181
- if (request.method === 'tools/list') return { tools: MCP_TOOLS }
274
+ if (request.method === 'tools/list') return { tools: enabledMcpTools(ctx) }
182
275
 
183
276
  if (request.method === 'tools/call') {
184
277
  const params = asRecord(request.params)
185
278
  const name = params.name
186
279
  const args = asRecord(params.arguments)
280
+ if (typeof name !== 'string') throw new Error('MCP tools/call requires a tool name.')
281
+ assertMcpToolEnabled(ctx, name)
187
282
  const index = () => ctx.loadIndex?.() ?? loadDocBridgeIndex(ctx.root, ctx.config)
188
283
 
189
284
  if (name === 'handoff.resolve') {
@@ -232,6 +327,77 @@ export const handleMcpRequest = (ctx: McpContext, request: JsonRpcRequest): unkn
232
327
  })
233
328
  }
234
329
 
330
+ if (name === 'docbridge.snapshot') {
331
+ const parsed = parseToolArgs('docbridge.snapshot', WorkflowRunArgsSchema, args)
332
+ return textResult(redactValue(workflowSnapshot(ctx, parsed.runId)))
333
+ }
334
+
335
+ if (name === 'docbridge.report') {
336
+ const parsed = parseToolArgs('docbridge.report', WorkflowRunArgsSchema, args)
337
+ return textResult(redactValue(workflowReport(ctx, parsed.runId)))
338
+ }
339
+
340
+ if (name === 'docbridge.diagnostics') {
341
+ const parsed = parseToolArgs('docbridge.diagnostics', DiagnosticsArgsSchema, args)
342
+ const diagnostics = workflowReport(ctx).diagnostics.filter((diagnostic) =>
343
+ (!parsed.status || diagnostic.status === parsed.status) && (!parsed.severity || diagnostic.severity === parsed.severity),
344
+ )
345
+ return textResult(redactValue({ reportHash: workflowReport(ctx).contentHash, diagnostics }))
346
+ }
347
+
348
+ if (name === 'docbridge.relations') {
349
+ const parsed = parseToolArgs('docbridge.relations', RelationsArgsSchema, args)
350
+ const snapshot = workflowSnapshot(ctx)
351
+ return textResult({ snapshotHash: snapshot.contentHash, relations: snapshot.relations.filter((relation) => !parsed.kind || relation.kind === parsed.kind).slice(0, parsed.limit ?? 100) })
352
+ }
353
+
354
+ if (name === 'docbridge.run') {
355
+ parseToolArgs('docbridge.run', z.object({}), args)
356
+ return textResult(workflowRun(ctx))
357
+ }
358
+
359
+ if (name === 'docbridge.proposals') {
360
+ const parsed = parseToolArgs('docbridge.proposals', ProposalsArgsSchema, args)
361
+ const run = (() => { try { return workflowRun(ctx) } catch { return undefined } })()
362
+ if (!parsed.action || parsed.action === 'list') {
363
+ let proposal: FixProposalV1 | undefined
364
+ try { proposal = readSavedProposal(ctx, undefined) } catch { proposal = undefined }
365
+ return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposals: proposal ? [proposal] : [] }))
366
+ }
367
+ if (parsed.action === 'suggest') {
368
+ return loadRegistryAgentRunner(ctx.root, ctx.config).then(async (runner) => {
369
+ const snapshot = workflowSnapshot(ctx)
370
+ const report = workflowReport(ctx)
371
+ const adapter = createRegistryAgentAdapter(ctx.root, ctx.config, runner)
372
+ const proposal = await adapter.run(snapshot, report)
373
+ const savedPath = persistRegistryAgentProposal(workflowStateDir(ctx), proposal)
374
+ return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal, proposalPath: savedPath }))
375
+ })
376
+ }
377
+ const discovered = discoverRepository({ root: ctx.root, config: ctx.config })
378
+ const options = { baseRevision: discovered.sourceRevision, configurationHash: sha256NormalizedV1(ctx.config), ...(ctx.config.project?.name ? { projectName: ctx.config.project.name } : {}) }
379
+ if (parsed.action === 'propose-links') {
380
+ const proposal = createMarkdownLinkFixProposal(ctx.root, options)
381
+ if (proposal) saveProposal(ctx, proposal)
382
+ return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal: proposal ?? null }))
383
+ }
384
+ if (parsed.action === 'propose-normalize') {
385
+ if (!parsed.artifactPath) throw new Error('docbridge.proposals propose-normalize requires artifactPath')
386
+ const proposal = createArtifactNormalizationProposal(ctx.root, parsed.artifactPath, options)
387
+ if (proposal) saveProposal(ctx, proposal)
388
+ return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal: proposal ?? null }))
389
+ }
390
+ if (parsed.action === 'approve') {
391
+ const proposal = approveFixProposal(readSavedProposal(ctx, parsed.proposal), parsed.approvedBy ?? 'human')
392
+ if (parsed.proposalHash && proposal.approval?.proposalHash !== parsed.proposalHash) throw new Error('proposalHash does not match the saved proposal')
393
+ saveProposal(ctx, proposal)
394
+ return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal }))
395
+ }
396
+ const proposal = applyFixProposal(ctx.root, readSavedProposal(ctx, parsed.proposal), { currentRevision: discovered.sourceRevision })
397
+ saveProposal(ctx, proposal)
398
+ return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal }))
399
+ }
400
+
235
401
  throw new Error(`Unknown tool "${String(name)}"`)
236
402
  }
237
403
 
@@ -246,10 +412,10 @@ const writeFrame = (payload: unknown, framing: StdioFraming): void => {
246
412
  process.stdout.write(framing === 'json-line' ? `${body}\n` : `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
247
413
  }
248
414
 
249
- const respond = (ctx: McpContext, request: JsonRpcRequest, framing: StdioFraming): void => {
415
+ export const respondMcpRequest = async (ctx: McpContext, request: JsonRpcRequest, framing: StdioFraming): Promise<void> => {
250
416
  if (request.id === undefined) {
251
417
  try {
252
- handleMcpRequest(ctx, request)
418
+ await handleMcpRequest(ctx, request)
253
419
  } catch {
254
420
  // Notifications do not get responses.
255
421
  }
@@ -257,7 +423,7 @@ const respond = (ctx: McpContext, request: JsonRpcRequest, framing: StdioFraming
257
423
  }
258
424
 
259
425
  try {
260
- const result = handleMcpRequest(ctx, request)
426
+ const result = await handleMcpRequest(ctx, request)
261
427
  writeFrame({ jsonrpc: '2.0', id: request.id, result: result ?? {} }, framing)
262
428
  } catch (error) {
263
429
  writeFrame({
@@ -270,6 +436,10 @@ const respond = (ctx: McpContext, request: JsonRpcRequest, framing: StdioFraming
270
436
 
271
437
  export const startMcpStdioServer = (ctx: McpContext): void => {
272
438
  let buffer = Buffer.alloc(0)
439
+ let responseChain = Promise.resolve()
440
+ const enqueueResponse = (request: JsonRpcRequest, framing: StdioFraming): void => {
441
+ responseChain = responseChain.then(() => respondMcpRequest(ctx, request, framing))
442
+ }
273
443
  process.stdin.on('data', (chunk: Buffer) => {
274
444
  buffer = Buffer.concat([buffer, chunk])
275
445
  while (true) {
@@ -288,7 +458,7 @@ export const startMcpStdioServer = (ctx: McpContext): void => {
288
458
  if (buffer.length < bodyEnd) return
289
459
  const raw = buffer.subarray(bodyStart, bodyEnd).toString('utf8')
290
460
  buffer = buffer.subarray(bodyEnd)
291
- respond(ctx, JSON.parse(raw) as JsonRpcRequest, 'content-length')
461
+ enqueueResponse(JSON.parse(raw) as JsonRpcRequest, 'content-length')
292
462
  continue
293
463
  }
294
464
 
@@ -298,7 +468,7 @@ export const startMcpStdioServer = (ctx: McpContext): void => {
298
468
  buffer = buffer.subarray(lineEnd + 1)
299
469
  if (!raw) continue
300
470
  try {
301
- respond(ctx, JSON.parse(raw) as JsonRpcRequest, 'json-line')
471
+ enqueueResponse(JSON.parse(raw) as JsonRpcRequest, 'json-line')
302
472
  } catch {
303
473
  writeFrame({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, 'json-line')
304
474
  }