carto-md 2.0.8 → 2.1.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.
Files changed (182) hide show
  1. package/CONTRIBUTING.md +19 -15
  2. package/README.md +203 -286
  3. package/docs/anci/v0.1-DRAFT.md +420 -0
  4. package/docs/api/README.md +99 -0
  5. package/docs/api/did_we_discuss_this.md +34 -0
  6. package/docs/api/dismiss_suggestion.md +34 -0
  7. package/docs/api/explain_change_in_natural_language.md +33 -0
  8. package/docs/api/find_consumers_of_api.md +34 -0
  9. package/docs/api/get_action_patterns.md +32 -0
  10. package/docs/api/get_active_drift.md +32 -0
  11. package/docs/api/get_active_suggestions.md +25 -0
  12. package/docs/api/get_ai_cost_attribution.md +32 -0
  13. package/docs/api/get_arch_events.md +42 -0
  14. package/docs/api/get_architectural_drift.md +37 -0
  15. package/docs/api/get_architecture.md +25 -0
  16. package/docs/api/get_blast_radius.md +34 -0
  17. package/docs/api/get_canonical_pattern.md +39 -0
  18. package/docs/api/get_change_plan.md +34 -0
  19. package/docs/api/get_change_velocity.md +32 -0
  20. package/docs/api/get_churn_vs_blast_radius.md +32 -0
  21. package/docs/api/get_complexity_trend.md +39 -0
  22. package/docs/api/get_context.md +34 -0
  23. package/docs/api/get_conventions.md +32 -0
  24. package/docs/api/get_cross_domain.md +25 -0
  25. package/docs/api/get_cross_language_call_graph.md +25 -0
  26. package/docs/api/get_cross_repo_blast_radius.md +34 -0
  27. package/docs/api/get_cross_team_coupling.md +25 -0
  28. package/docs/api/get_data_flow.md +33 -0
  29. package/docs/api/get_dead_code_with_confidence.md +32 -0
  30. package/docs/api/get_decision_log.md +32 -0
  31. package/docs/api/get_dependency_surface.md +25 -0
  32. package/docs/api/get_domain.md +34 -0
  33. package/docs/api/get_domain_evolution.md +39 -0
  34. package/docs/api/get_domain_health.md +32 -0
  35. package/docs/api/get_domains_list.md +25 -0
  36. package/docs/api/get_drift_digest.md +32 -0
  37. package/docs/api/get_env_vars.md +32 -0
  38. package/docs/api/get_evolution_delta.md +36 -0
  39. package/docs/api/get_file_ownership.md +33 -0
  40. package/docs/api/get_file_summary.md +34 -0
  41. package/docs/api/get_high_impact_files.md +32 -0
  42. package/docs/api/get_hot_in_prod_no_tests.md +34 -0
  43. package/docs/api/get_hotspot_files.md +37 -0
  44. package/docs/api/get_iac_resources.md +25 -0
  45. package/docs/api/get_interface_contract.md +33 -0
  46. package/docs/api/get_intervention_history.md +32 -0
  47. package/docs/api/get_invariants.md +37 -0
  48. package/docs/api/get_llm_enrichment.md +33 -0
  49. package/docs/api/get_microservice_cut_points.md +32 -0
  50. package/docs/api/get_microservices_migration_cut_points.md +25 -0
  51. package/docs/api/get_minimal_context_for_intent.md +39 -0
  52. package/docs/api/get_models.md +32 -0
  53. package/docs/api/get_neighbors.md +39 -0
  54. package/docs/api/get_org_architecture.md +25 -0
  55. package/docs/api/get_org_domain_mapping.md +25 -0
  56. package/docs/api/get_pending_decisions.md +32 -0
  57. package/docs/api/get_predictive_risk.md +32 -0
  58. package/docs/api/get_progressive_disclosure_tree.md +25 -0
  59. package/docs/api/get_recent_decisions.md +37 -0
  60. package/docs/api/get_risk_weighted_blast_radius.md +32 -0
  61. package/docs/api/get_routes.md +25 -0
  62. package/docs/api/get_safety_checklist.md +33 -0
  63. package/docs/api/get_semantic_diff.md +34 -0
  64. package/docs/api/get_service_boundary_violations.md +25 -0
  65. package/docs/api/get_service_dependency_graph.md +25 -0
  66. package/docs/api/get_session_context.md +32 -0
  67. package/docs/api/get_similar_patterns.md +39 -0
  68. package/docs/api/get_stale_docs.md +25 -0
  69. package/docs/api/get_structure.md +25 -0
  70. package/docs/api/get_temporal_context.md +34 -0
  71. package/docs/api/get_test_coverage_map.md +25 -0
  72. package/docs/api/get_token_budget_report.md +36 -0
  73. package/docs/api/get_upgrade_risk.md +25 -0
  74. package/docs/api/get_working_memory.md +25 -0
  75. package/docs/api/ingest_otlp_traces.md +34 -0
  76. package/docs/api/scaffold_for_intent.md +34 -0
  77. package/docs/api/search_routes.md +34 -0
  78. package/docs/api/simulate_change_impact.md +37 -0
  79. package/docs/api/validate_change.md +39 -0
  80. package/docs/api/validate_diff.md +39 -0
  81. package/docs/concepts/anci.md +87 -0
  82. package/docs/concepts/blast-radius.md +66 -0
  83. package/docs/concepts/domains.md +91 -0
  84. package/docs/concepts/import-graph.md +102 -0
  85. package/docs/concepts/mcp-integration.md +148 -0
  86. package/docs/guides/adding-feature-safely.md +101 -0
  87. package/docs/guides/ci-integration.md +175 -0
  88. package/docs/guides/monorepo-setup.md +121 -0
  89. package/docs/guides/onboarding-new-engineer.md +121 -0
  90. package/docs/guides/pre-merge-review.md +139 -0
  91. package/docs/migration/v1-to-v2.md +110 -0
  92. package/docs/quickstart.md +95 -0
  93. package/docs/scale.md +129 -0
  94. package/docs/troubleshooting.md +180 -0
  95. package/package.json +12 -5
  96. package/scripts/gen-api-docs.js +170 -0
  97. package/scripts/postinstall.js +391 -24
  98. package/src/acp/agent.js +83 -11
  99. package/src/acp/config.js +64 -0
  100. package/src/acp/persistence.js +146 -0
  101. package/src/acp/providers/anthropic.js +179 -27
  102. package/src/acp/providers/index.js +15 -2
  103. package/src/acp/providers/openai.js +164 -38
  104. package/src/acp/providers/sse.js +82 -0
  105. package/src/acp/safety.js +128 -0
  106. package/src/acp/session.js +73 -0
  107. package/src/adjacent/call-graph.js +170 -0
  108. package/src/adjacent/iac.js +167 -0
  109. package/src/adjacent/llm-enrich.js +35 -0
  110. package/src/adjacent/runtime.js +216 -0
  111. package/src/adjacent/semantic-diff.js +143 -0
  112. package/src/agents/leiden.js +4 -4
  113. package/src/agents/scan-structure.js +2 -2
  114. package/src/ai/context-builder.js +215 -0
  115. package/src/ai/retrieval/lexical.js +122 -0
  116. package/src/ai/retrieval/rrf.js +121 -0
  117. package/src/ai/retrieval/semantic.js +35 -0
  118. package/src/ai/retrieval/structural.js +82 -0
  119. package/src/ai/tools.js +423 -0
  120. package/src/anci/consumer.js +305 -0
  121. package/src/anci/deserialize.js +160 -0
  122. package/src/anci/emit.js +85 -0
  123. package/src/anci/serialize.js +264 -0
  124. package/src/anci/yaml.js +401 -0
  125. package/src/bitmap/index.js +1 -1
  126. package/src/bitmap/tools.js +2 -2
  127. package/src/brain/conventions/index.js +185 -0
  128. package/src/brain/index.js +31 -0
  129. package/src/brain/invariants/index.js +252 -0
  130. package/src/brain/procedural/index.js +181 -0
  131. package/src/brain/suggestions/index.js +153 -0
  132. package/src/brain/working/index.js +170 -0
  133. package/src/cli/anci.js +237 -0
  134. package/src/cli/check.js +47 -1
  135. package/src/cli/diff.js +83 -0
  136. package/src/cli/doctor.js +270 -0
  137. package/src/cli/explain.js +61 -0
  138. package/src/cli/index.js +115 -0
  139. package/src/cli/init.js +144 -6
  140. package/src/cli/inspect.js +1 -1
  141. package/src/cli/org.js +172 -0
  142. package/src/cli/pr-impact.js +554 -0
  143. package/src/cli/serve.js +1 -1
  144. package/src/cli/status.js +211 -0
  145. package/src/cli/sync.js +2 -2
  146. package/src/cli/temporal.js +188 -0
  147. package/src/cli/validate.js +201 -0
  148. package/src/cli/watch.js +4 -4
  149. package/src/cli/why.js +101 -0
  150. package/src/extractors/frameworks.js +236 -0
  151. package/src/extractors/languages/dart.js +138 -0
  152. package/src/extractors/languages/go.js +11 -1
  153. package/src/extractors/languages/javascript.js +16 -6
  154. package/src/extractors/languages/kotlin.js +169 -0
  155. package/src/extractors/languages/php.js +195 -0
  156. package/src/extractors/languages/python.js +12 -1
  157. package/src/extractors/languages/swift.js +140 -0
  158. package/src/extractors/languages/typescript.js +15 -2
  159. package/src/extractors/plugin-api.js +102 -0
  160. package/src/mcp/change-plan.js +8 -8
  161. package/src/mcp/files-without-tests.js +285 -0
  162. package/src/mcp/middleware/index.js +451 -0
  163. package/src/mcp/server.js +2292 -0
  164. package/src/mcp/validate.js +1 -1
  165. package/src/org/detect.js +262 -0
  166. package/src/org/queries.js +144 -0
  167. package/src/org/store.js +173 -0
  168. package/src/org/sync.js +106 -0
  169. package/src/predictive/cut-points.js +83 -0
  170. package/src/predictive/drift-digest.js +88 -0
  171. package/src/predictive/ownership.js +145 -0
  172. package/src/predictive/risk-score.js +121 -0
  173. package/src/predictive/validate-change.js +55 -0
  174. package/src/store/store-adapter.js +3 -3
  175. package/src/store/{sync-v2.js → sync.js} +105 -16
  176. package/src/temporal/backfill.js +211 -0
  177. package/src/temporal/delta.js +85 -0
  178. package/src/temporal/events.js +180 -0
  179. package/src/temporal/queries.js +358 -0
  180. package/src/temporal/snapshot.js +151 -0
  181. package/src/temporal/store.js +400 -0
  182. package/src/mcp/server-v2.js +0 -986
@@ -1,986 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- const path = require('path');
5
- const fs = require('fs');
6
- const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
7
- const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
8
- const { CallToolRequestSchema, ListToolsRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
9
- const { SQLiteStore } = require('../store/sqlite-store');
10
- const { normalizeFileArg } = require('../store/path-utils');
11
- const { syncFiles } = require('../store/sync-v2');
12
- const bitmapTools = require('../bitmap/tools');
13
- const { ensureBitmapFresh, invalidate: invalidateBitmap } = require('../bitmap/index');
14
- const { validateDiff, recordSideEffects } = require('./validate');
15
-
16
- const projectRoot = process.cwd();
17
-
18
- // Process-level safety nets. Without these, any error that
19
- // escapes the request handler (very rare, but possible from native bindings
20
- // or async stack frames) takes the whole MCP server down, and Claude Code /
21
- // Kiro surface `-32000 Failed to reconnect`. We log to stderr (which the
22
- // host logs but never terminates the JSON-RPC channel) and stay alive.
23
- process.on('uncaughtException', (err) => {
24
- process.stderr.write(`[CARTO MCP] Uncaught exception: ${err && err.stack ? err.stack : err}\n`);
25
- });
26
- process.on('unhandledRejection', (reason) => {
27
- process.stderr.write(`[CARTO MCP] Unhandled rejection: ${reason && reason.stack ? reason.stack : reason}\n`);
28
- });
29
-
30
- // Open SQLite directly — no re-indexing, instant startup
31
- let store = null;
32
-
33
- function getStore() {
34
- if (store) return store;
35
- const dbPath = path.join(projectRoot, '.carto', 'carto.db');
36
- if (!fs.existsSync(dbPath)) return null;
37
- // Open BEFORE assigning to the module-scoped `store` so that if open()
38
- // throws (corrupt DB, locked file, schema mismatch) we don't poison the
39
- // cache with a broken instance — every subsequent call would otherwise
40
- // return the broken object and never recover.
41
- const s = new SQLiteStore(projectRoot);
42
- s.open({ readonly: true }); // Defense in depth: MCP tools never write
43
- store = s;
44
- return store;
45
- }
46
-
47
- /**
48
- * getSidecar() — bitmap engine entry point.
49
- *
50
- * Lazily loads (or rebuilds) the in-memory bitmap sidecar for the
51
- * bitmap-eligible MCP tools. Returns null on any failure so callers can
52
- * fall back to the SQLite query path silently — bitmap is a *speedup*,
53
- * never a behavior change. Stale-disk and corrupt-disk are handled
54
- * inside `ensureBitmapFresh` (rebuilds from the SQLite source of truth).
55
- *
56
- * Failure modes that drop us back to SQLite:
57
- * - SQLite store unavailable (no `.carto/carto.db`).
58
- * - DB row read fails mid-build (race with a concurrent writer).
59
- * - Disk write to bitmap.bin fails (read-only FS, disk full) — the
60
- * in-memory sidecar is still returned, but if even build threw we
61
- * surface the error and use SQLite.
62
- */
63
- function getSidecar() {
64
- const s = getStore();
65
- if (!s) return null;
66
- const cartoDir = path.join(projectRoot, '.carto');
67
- try {
68
- return ensureBitmapFresh(cartoDir, s);
69
- } catch (err) {
70
- process.stderr.write(
71
- `[CARTO MCP] bitmap load failed, falling back to SQLite: ` +
72
- `${err && err.message ? err.message : err}\n`
73
- );
74
- return null;
75
- }
76
- }
77
-
78
- /**
79
- * lazyReparseFile(file) — MCP-side freshness check.
80
- *
81
- * Before answering a file-aware tool call, mtime+size check the requested
82
- * file against the indexed row. If the file is stale (user edited it
83
- * between the last `carto sync` and this MCP query — e.g. uncommitted
84
- * work), re-parse it inline so the answer reflects current code.
85
- *
86
- * Three states:
87
- * - File missing on disk → leave the index alone. The user may have
88
- * deleted-and-recreated mid-session; we don't want to drop a row that
89
- * a sync would re-add seconds later.
90
- * - File present, in index, mtime+size match → fast path, no work.
91
- * - File present, stale or unknown → call syncFiles() with a transient
92
- * writable connection. syncFiles opens, writes, closes its own
93
- * connection so the cached read-only `store` keeps the readonly
94
- * guarantee that the MCP read path itself can never write.
95
- *
96
- * Best-effort: any failure (stat, parse, write contention) falls through
97
- * to "answer with whatever the index has." Stale data beats a crash.
98
- */
99
- function lazyReparseFile(file) {
100
- if (!file || typeof file !== 'string') return;
101
-
102
- const fullPath = path.resolve(projectRoot, file);
103
- let stat;
104
- try {
105
- stat = fs.statSync(fullPath);
106
- } catch {
107
- return; // File doesn't exist on disk — leave index alone.
108
- }
109
-
110
- const s = getStore();
111
- if (!s) return;
112
-
113
- const existing = s.getFileByPath(file);
114
- const mtime = Math.floor(stat.mtimeMs);
115
- const size = stat.size;
116
-
117
- // Fresh row → nothing to do.
118
- if (existing && existing.mtime === mtime && existing.size === size) return;
119
-
120
- // Stale or unknown — reparse just this file. syncFiles() opens its own
121
- // writable connection and closes it, so the readonly `store` stays
122
- // readonly. Costs ~5-50ms per stale file.
123
- try {
124
- syncFiles(projectRoot, [file]);
125
- } catch (err) {
126
- process.stderr.write(`[CARTO MCP] Lazy reparse failed for ${file}: ${err && err.message ? err.message : err}\n`);
127
- }
128
- }
129
-
130
- function text(str) {
131
- return { content: [{ type: 'text', text: str }] };
132
- }
133
-
134
- /**
135
- * withWriter(fn) — run `fn(writer)` against a brief writable connection
136
- * scoped to a single MCP call. The cached MCP `store` is opened readonly
137
- * (so a buggy tool path can never write through SQLite); episodic-memory
138
- * tools that need to record decisions/interventions go through this
139
- * helper instead. The writer opens, writes, closes within this function
140
- * — the readonly `store` is untouched. Failures are swallowed (returned
141
- * as null) because validation should always degrade gracefully — the
142
- * read result matters more than the audit log row.
143
- */
144
- function withWriter(fn) {
145
- let writer = null;
146
- try {
147
- writer = new SQLiteStore(projectRoot);
148
- writer.open();
149
- return fn(writer);
150
- } catch (err) {
151
- process.stderr.write(
152
- `[CARTO MCP] writer connection failed: ${err && err.message ? err.message : err}\n`
153
- );
154
- return null;
155
- } finally {
156
- if (writer) {
157
- try { writer.close(); } catch {}
158
- }
159
- }
160
- }
161
-
162
- function notIndexed() {
163
- return text('No .carto/carto.db found. Run `carto sync` first.');
164
- }
165
-
166
- /**
167
- * parseTimeRange("7d" | "24h" | "1h" | "30m" | "60s") → ms | null
168
- *
169
- * Small parser for the `get_recent_decisions` time_range arg.
170
- * Returns null on malformed input so the caller can surface a clear
171
- * error message instead of silently treating "auth" as 0ms.
172
- */
173
- function parseTimeRange(s) {
174
- if (typeof s !== 'string' || s.length === 0) return null;
175
- const m = s.trim().match(/^(\d+)\s*([smhdw])?$/i);
176
- if (!m) return null;
177
- const n = parseInt(m[1], 10);
178
- if (!Number.isFinite(n) || n < 0) return null;
179
- const unit = (m[2] || 'd').toLowerCase();
180
- const mult = unit === 's' ? 1000 :
181
- unit === 'm' ? 60_000 :
182
- unit === 'h' ? 3_600_000 :
183
- unit === 'd' ? 86_400_000 :
184
- unit === 'w' ? 604_800_000 : null;
185
- if (mult === null) return null;
186
- return n * mult;
187
- }
188
-
189
- /**
190
- * summarizeDecisionPayload(json) → short string
191
- *
192
- * Renders a one-line summary of a `decisions.payload_json` row for the
193
- * Markdown tables. Defensive against missing/malformed JSON — never
194
- * throws, never echoes raw payload bytes.
195
- */
196
- function summarizeDecisionPayload(json) {
197
- if (!json) return '—';
198
- let obj;
199
- try { obj = JSON.parse(json); } catch { return '_(unparseable payload)_'; }
200
- if (!obj || typeof obj !== 'object') return '—';
201
- const parts = [];
202
- if (obj.risk) parts.push(`risk=${obj.risk}`);
203
- if (typeof obj.violationCount === 'number') parts.push(`violations=${obj.violationCount}`);
204
- if (typeof obj.blastUnion === 'number') parts.push(`blast=${obj.blastUnion}`);
205
- if (Array.isArray(obj.files) && obj.files.length > 0) {
206
- parts.push(`files=${obj.files.length === 1 ? obj.files[0] : `${obj.files.length}`}`);
207
- }
208
- return parts.length === 0 ? '—' : parts.join(', ');
209
- }
210
-
211
- // ─── Tool definitions (same as V1) ──────────────────────────────────────────
212
-
213
- const TOOLS = [
214
- { name: 'get_routes', description: 'Get all API routes in this project including REST, tRPC, and webhooks.', inputSchema: { type: 'object', properties: {}, required: [] } },
215
- { name: 'get_blast_radius', description: 'Get all files, routes, and domains affected by changing a specific file. Includes risk level per route.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Relative file path from project root' } }, required: ['file'] } },
216
- { name: 'get_structure', description: 'Get project structure: import graph, entry points, high impact files, tech stack, and domains.', inputSchema: { type: 'object', properties: {}, required: [] } },
217
- { name: 'get_domain', description: 'Get all routes, models, functions, and context for a specific domain (AUTH, PAYMENTS, TRPC, DATABASE, EVENTS, NOTIFICATIONS, CORE).', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Domain name e.g. AUTH, PAYMENTS, DATABASE' } }, required: ['domain'] } },
218
- { name: 'get_neighbors', description: 'Get import graph neighbors of a file — files it imports and files that import it. Returns nodes and edges for visualization.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Relative file path from project root' }, hops: { type: 'number', description: 'How many hops to traverse (default 1, max 3)' } }, required: ['file'] } },
219
- { name: 'get_cross_domain', description: 'Get all import edges that cross domain boundaries — e.g. AUTH importing PAYMENTS. Use to detect unexpected coupling.', inputSchema: { type: 'object', properties: {}, required: [] } },
220
- { name: 'get_context', description: 'Get full structural context for a file: domain, blast radius, import neighbors, routes, models, env vars, and cross-domain dependencies. Single call for everything.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Relative file path from project root' } }, required: ['file'] } },
221
- { name: 'search_routes', description: 'Search API routes by path or method. Case-insensitive.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query e.g. "auth", "POST", "/api/users"' } }, required: ['query'] } },
222
- { name: 'get_models', description: 'Get all data models (Prisma, Pydantic, TypeScript interfaces, Zod schemas) across the project, optionally filtered by domain.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Optional domain filter e.g. AUTH, DATABASE' } }, required: [] } },
223
- { name: 'get_high_impact_files', description: 'Get the files with the highest blast radius — most other files depend on them. Changing these files is highest risk.', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Number of files to return (default 10)' } }, required: [] } },
224
- { name: 'get_env_vars', description: 'Get all environment variables used in this project, with which files use them and which domains they belong to.', inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Optional domain filter e.g. AUTH, PAYMENTS' } }, required: [] } },
225
- { name: 'get_domains_list', description: 'Get all detected domains with file counts, route counts, and model counts.', inputSchema: { type: 'object', properties: {}, required: [] } },
226
- { name: 'get_architecture', description: 'Get a 500-word markdown summary of the project: domains, entry points, tech stack, key patterns, and size metrics. Use this as your first call when entering a new repo.', inputSchema: { type: 'object', properties: {}, required: [] } },
227
- { name: 'get_file_summary', description: 'Get a 3-sentence description of what a file does, its role in the project, and its key dependencies and dependents.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Relative file path from project root' } }, required: ['file'] } },
228
- { name: 'get_change_plan', description: 'Given a natural-language intent (e.g. "add rate limiting to /api/users"), returns: files to touch, domains affected, blast radius, and similar patterns in the codebase.', inputSchema: { type: 'object', properties: { intent: { type: 'string', description: 'Natural language description of the change you want to make' } }, required: ['intent'] } },
229
- { name: 'get_similar_patterns', description: 'Given a file, find structurally similar files — same import pattern, same route shape, or same domain. Use to find conventions to follow before writing new code.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Relative file path from project root' }, limit: { type: 'number', description: 'Max results to return (default 5)' } }, required: ['file'] } },
230
- { name: 'simulate_change_impact', description: 'Given a list of files, returns all files transitively affected by changing them simultaneously, with hop distance. Powered by the bitmap engine — only feasible at this speed (sub-millisecond) with bitmap OR-aggregation. Use when planning a refactor that touches multiple files.', inputSchema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' }, description: 'Array of relative file paths from project root' } }, required: ['files'] } },
231
- // ─── Validation API + Episodic Memory ─────────────────────────────
232
- { name: 'validate_diff', description: 'Given a unified diff, returns: violations (cross-domain imports, high-blast files), blast radius per file, risk level (SAFE/LOW/MEDIUM/HIGH), and suggestions. Sub-15ms p99 on a 7K-file repo. Each call is recorded in the episodic memory log so other tools can ask "did we discuss this?".', inputSchema: { type: 'object', properties: { diff: { type: 'string', description: 'Unified diff text (output of `git diff` / GitHub PR patch).' }, session_id: { type: 'number', description: 'Optional session id. Defaults to the most recent active session, or a fresh one.' } }, required: ['diff'] } },
233
- { name: 'get_recent_decisions', description: 'List recent validation decisions and architectural choices the AI has made in this project. Returns time-descending rows.', inputSchema: { type: 'object', properties: { time_range: { type: 'string', description: 'Time window like "7d", "24h", "1h" (default "7d").' }, kind: { type: 'string', description: 'Optional filter — e.g. "validation".' } }, required: [] } },
234
- { name: 'get_session_context', description: 'Full context for an AI session: every decision and every intervention, ordered chronologically. Use to recap what happened in a long-running session.', inputSchema: { type: 'object', properties: { session_id: { type: 'number', description: 'Session id. Defaults to the most recent active session.' } }, required: [] } },
235
- { name: 'did_we_discuss_this', description: 'Substring search over the episodic memory log (decisions + interventions) for prior discussions of a topic. Use to avoid re-deciding settled questions.', inputSchema: { type: 'object', properties: { topic: { type: 'string', description: 'Topic to search for, e.g. "auth", "snake_case", "blast radius".' } }, required: ['topic'] } },
236
- { name: 'get_intervention_history', description: 'List interventions (Carto-issued violations and suggestions) optionally filtered by file. Use to see prior warnings on a file before editing it.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'Optional file filter (relative path from project root).' } }, required: [] } },
237
- ];
238
-
239
- // ─── Server setup ─────────────────────────────────────────────────────────────
240
-
241
- const server = new Server(
242
- { name: 'carto', version: '2.0.0' },
243
- { capabilities: { tools: {} } }
244
- );
245
-
246
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
247
-
248
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
249
- const { name, arguments: args } = request.params;
250
- // Normalize file-arg tools to the canonical SQLite-stored form so
251
- // `./lib/x.js`, absolute paths, and Windows separators all resolve.
252
- // Tools that don't take a `file` arg are unaffected.
253
- if (args && typeof args.file === 'string') {
254
- args.file = normalizeFileArg(projectRoot, args.file);
255
- // Lazy mtime+size check. Re-parse the requested file inline
256
- // if it's stale on disk (user edited but didn't commit). Best-effort:
257
- // failures here never block the answer.
258
- lazyReparseFile(args.file);
259
- }
260
- // Wrap entire handler body so any tool error (SQLite, null deref, bad
261
- // input) returns a structured error response instead of crashing the
262
- // MCP transport. An unhandled throw here would kill the stdio
263
- // connection and Claude Code/Kiro would surface
264
- // `-32000 Failed to reconnect`.
265
- try {
266
- const s = getStore();
267
- if (!s) return notIndexed();
268
-
269
- if (name === 'get_routes') {
270
- const routes = s.getRoutes();
271
- if (routes.length === 0) return text('No routes found.');
272
- const lines = ['# All Routes\n', '| Method | Path | File |', '|--------|------|------|'];
273
- for (const r of routes) lines.push(`| ${r.method} | ${r.path} | ${r.file} |`);
274
- return text(lines.join('\n'));
275
- }
276
-
277
- if (name === 'get_blast_radius') {
278
- // Bitmap path with SQLite fallback. Output shape and
279
- // formatting are identical between the two paths.
280
- const sidecar = getSidecar();
281
- const deps = sidecar
282
- ? bitmapTools.blastRadius(sidecar, args.file)
283
- : s.getBlastRadius(args.file);
284
- if (!deps) return text(`File not found in index: ${args.file}`);
285
- if (deps.length === 0) return text(`No dependents found for: ${args.file}`);
286
- const lines = [`# Blast Radius: ${args.file}\n`, `**Affected files:** ${deps.length}\n`];
287
- lines.push('| File | Hops |');
288
- lines.push('|------|------|');
289
- for (const d of deps) lines.push(`| ${d.file} | ${d.hop_distance} |`);
290
- return text(lines.join('\n'));
291
- }
292
-
293
- if (name === 'get_structure') {
294
- const st = s.getStructure();
295
- const lines = ['# Project Structure\n'];
296
- if (st.stack.length > 0) lines.push(`**Stack:** ${st.stack.join(', ')}\n`);
297
- lines.push(`**Meta:** ${st.meta.totalFiles} files, ${st.meta.totalRoutes} routes, ${st.meta.totalImportEdges} import edges\n`);
298
- if (st.domains.length > 0) lines.push(`**Domains:** ${st.domains.join(', ')}\n`);
299
- if (st.entryPoints.length > 0) {
300
- lines.push('## Entry Points');
301
- for (const e of st.entryPoints) lines.push(`- ${e}`);
302
- lines.push('');
303
- }
304
- if (st.highImpact.length > 0) {
305
- lines.push('## High Impact Files');
306
- lines.push('| File | Dependents |');
307
- lines.push('|------|------------|');
308
- for (const h of st.highImpact) lines.push(`| ${h.file} | ${h.dependents} |`);
309
- }
310
- return text(lines.join('\n'));
311
- }
312
-
313
- if (name === 'get_domain') {
314
- // Guard: AI clients sometimes call this with no/empty `domain` arg.
315
- // Without the guard we'd call args.domain.toUpperCase() on undefined and crash.
316
- if (!args.domain || typeof args.domain !== 'string') {
317
- return text('Missing required argument: domain. Use get_domains_list to see available domains.');
318
- }
319
- const domain = s.getDomain(args.domain);
320
- if (!domain) return text(`Domain not found: ${args.domain}. Use get_domains_list to see available domains.`);
321
-
322
- const ctxPath = path.join(projectRoot, '.carto', 'context', `${args.domain.toUpperCase()}.md`);
323
- const lastSync = s.getMeta('last_full_sync');
324
-
325
- // Check if cached context file is fresh
326
- let cacheIsFresh = false;
327
- if (fs.existsSync(ctxPath) && lastSync) {
328
- try {
329
- const fileMtime = fs.statSync(ctxPath).mtimeMs;
330
- const syncTime = new Date(lastSync).getTime();
331
- cacheIsFresh = fileMtime >= syncTime;
332
- } catch {}
333
- }
334
-
335
- if (cacheIsFresh) {
336
- try { return text(fs.readFileSync(ctxPath, 'utf-8')); } catch {}
337
- }
338
-
339
- // Regenerate lazily from DB
340
- const { formatDomainFile } = require('../agents/formatter');
341
- const cluster = {
342
- files: domain.files,
343
- routes: domain.routes.map(r => ({ ...r, functionName: r.handler_name || '' })),
344
- models: domain.models.map(m => ({
345
- ...m,
346
- className: m.name,
347
- // Wrap parse so one corrupt row doesn't take down the whole tool call.
348
- fields: (() => {
349
- if (!m.fields_json) return [];
350
- try { return JSON.parse(m.fields_json); } catch { return []; }
351
- })()
352
- })),
353
- functions: {},
354
- envVars: [],
355
- dbTables: [],
356
- fileMap: []
357
- };
358
- const content = formatDomainFile(args.domain.toUpperCase(), cluster);
359
-
360
- // Cache to disk for next call
361
- try {
362
- fs.mkdirSync(path.dirname(ctxPath), { recursive: true });
363
- fs.writeFileSync(ctxPath, content, 'utf-8');
364
- } catch {}
365
-
366
- return text(content);
367
- }
368
-
369
- if (name === 'get_neighbors') {
370
- const hops = Math.min(args.hops || 1, 3);
371
- const nb = s.getNeighbors(args.file, hops);
372
- if (nb.nodes.length === 0) return text(`File not found or no neighbors: ${args.file}`);
373
- const lines = [`# Import Neighbors: ${args.file} (${hops} hop${hops > 1 ? 's' : ''})\n`];
374
- lines.push('| File | Domain | Root |');
375
- lines.push('|------|--------|------|');
376
- for (const n of nb.nodes) lines.push(`| ${n.id} | ${n.domain} | ${n.isRoot ? '✓' : ''} |`);
377
- lines.push('');
378
- lines.push(`## Edges (${nb.edges.length})`);
379
- for (const e of nb.edges.slice(0, 50)) lines.push(`- ${e.source} → ${e.target}`);
380
- if (nb.edges.length > 50) lines.push(`_...and ${nb.edges.length - 50} more_`);
381
- return text(lines.join('\n'));
382
- }
383
-
384
- if (name === 'get_cross_domain') {
385
- // Bitmap path with SQLite fallback.
386
- const sidecar = getSidecar();
387
- const xd = sidecar ? bitmapTools.crossDomain(sidecar) : s.getCrossDomainDeps();
388
- if (xd.length === 0) return text('No cross-domain dependencies found.');
389
- const lines = [`# Cross-Domain Dependencies (${xd.length})\n`];
390
- lines.push('| From | From Domain | To | To Domain |');
391
- lines.push('|------|------------|-----|-----------|');
392
- for (const d of xd.slice(0, 100)) lines.push(`| ${d.from} | ${d.fromDomain} | ${d.to} | ${d.toDomain} |`);
393
- if (xd.length > 100) lines.push(`\n_...and ${xd.length - 100} more_`);
394
- return text(lines.join('\n'));
395
- }
396
-
397
- if (name === 'get_context') {
398
- const file = s.getFileByPath(args.file);
399
- if (!file) return text(`File not found: ${args.file}`);
400
- const domain = s.getDomainForFile(args.file);
401
- const blastDeps = s.getBlastRadius(args.file) || [];
402
- const nb = s.getNeighbors(args.file, 2);
403
- const lines = [
404
- `# Context: ${args.file}\n`,
405
- `**Domain:** ${domain || 'CORE'}`,
406
- `**Blast radius:** ${blastDeps.length} dependent files\n`,
407
- ];
408
- if (nb.nodes.length > 1) {
409
- lines.push(`## Neighbors (2 hops): ${nb.nodes.length - 1} files`);
410
- for (const n of nb.nodes.filter(n => !n.isRoot).slice(0, 15)) {
411
- lines.push(`- ${n.id} [${n.domain}]`);
412
- }
413
- if (nb.nodes.length > 16) lines.push(`_...and ${nb.nodes.length - 16} more_`);
414
- }
415
- return text(lines.join('\n'));
416
- }
417
-
418
- if (name === 'search_routes') {
419
- const results = s.searchRoutes(args.query);
420
- if (results.length === 0) return text(`No routes matching: ${args.query}`);
421
- const lines = [`# Routes matching "${args.query}" (${results.length})\n`];
422
- lines.push('| Method | Path | File |');
423
- lines.push('|--------|------|------|');
424
- for (const r of results) lines.push(`| ${r.method} | ${r.path} | ${r.file} |`);
425
- return text(lines.join('\n'));
426
- }
427
-
428
- if (name === 'get_models') {
429
- const models = s.getModels(args.domain);
430
- if (models.length === 0) return text(args.domain ? `No models in domain: ${args.domain}` : 'No models found.');
431
- const lines = [`# Models${args.domain ? ` — ${args.domain}` : ''} (${models.length})\n`];
432
- lines.push('| Model | Kind | File |');
433
- lines.push('|-------|------|------|');
434
- for (const m of models) lines.push(`| ${m.name} | ${m.kind} | ${m.file} |`);
435
- return text(lines.join('\n'));
436
- }
437
-
438
- if (name === 'get_high_impact_files') {
439
- // Bitmap path with SQLite fallback. The bitmap layer
440
- // pre-builds `popcountIndex` (sorted DESC by direct-dependent count)
441
- // at sidecar-build time so this becomes an O(1) array slice — much
442
- // faster than walking the reverse bitmaps + popcount per file at
443
- // query time.
444
- const sidecar = getSidecar();
445
- const limit = args.limit || 10;
446
- const files = sidecar
447
- ? bitmapTools.highImpactFiles(sidecar, limit)
448
- : s.getHighImpactFiles(limit);
449
- if (files.length === 0) return text('No high impact files found.');
450
- const lines = [`# High Impact Files\n`];
451
- lines.push('| File | Dependents |');
452
- lines.push('|------|------------|');
453
- for (const f of files) lines.push(`| ${f.file} | ${f.dependents} |`);
454
- return text(lines.join('\n'));
455
- }
456
-
457
- if (name === 'get_env_vars') {
458
- const vars = s.getEnvVars(args.domain);
459
- if (vars.length === 0) return text('No env vars found.');
460
- const lines = [`# Environment Variables (${vars.length})\n`];
461
- lines.push('| Variable | File |');
462
- lines.push('|----------|------|');
463
- for (const v of vars) lines.push(`| ${v.name} | ${v.file} |`);
464
- return text(lines.join('\n'));
465
- }
466
-
467
- if (name === 'get_domains_list') {
468
- const domains = s.getDomainsList();
469
- if (domains.length === 0) return text('No domains detected.');
470
- const lines = [`# Domains (${domains.length})\n`];
471
- lines.push('| Domain | Files | Routes | Models |');
472
- lines.push('|--------|-------|--------|--------|');
473
- for (const d of domains) lines.push(`| ${d.name} | ${d.fileCount} | ${d.routeCount} | ${d.modelCount} |`);
474
- return text(lines.join('\n'));
475
- }
476
-
477
- if (name === 'get_architecture') {
478
- const st = s.getStructure();
479
- const domains = s.getDomainsList();
480
- const lines = ['# Project Architecture\n'];
481
-
482
- // Stack + size
483
- if (st.stack.length > 0) lines.push(`**Stack:** ${st.stack.join(', ')}\n`);
484
- lines.push(`**Size:** ${st.meta.totalFiles} files · ${st.meta.totalRoutes} routes · ${st.meta.totalImportEdges} import edges\n`);
485
-
486
- // Surface extractor failures so the agent knows the index
487
- // is partial (and which routes/models may be missing).
488
- const errCountRaw = s.getMeta('extraction_error_count');
489
- const errCount = errCountRaw ? parseInt(errCountRaw, 10) : 0;
490
- if (errCount > 0) {
491
- lines.push(`> ⚠️ **${errCount} extraction error${errCount === 1 ? '' : 's'}** — some files failed to parse and their routes/models are missing. Run \`carto check\` for details.\n`);
492
- }
493
-
494
- // Surface unavailable grammars so the agent knows which
495
- // languages have reduced extraction accuracy.
496
- const unavailRaw = s.getMeta('unavailable_languages_json');
497
- const unavailLangs = unavailRaw ? (() => { try { return JSON.parse(unavailRaw); } catch { return []; } })() : [];
498
- if (unavailLangs.length > 0) {
499
- lines.push(`> ⚠️ **${unavailLangs.length} language grammar${unavailLangs.length === 1 ? '' : 's'} unavailable** (${unavailLangs.join(', ')}) — these languages use regex-only extraction with reduced accuracy.\n`);
500
- }
501
-
502
- // Domains
503
- if (domains.length > 0) {
504
- lines.push('## Domains\n');
505
- for (const d of domains) {
506
- if (d.fileCount === 0) continue;
507
- lines.push(`**${d.name}** — ${d.fileCount} files${d.routeCount > 0 ? `, ${d.routeCount} routes` : ''}${d.modelCount > 0 ? `, ${d.modelCount} models` : ''}`);
508
- }
509
- lines.push('');
510
- }
511
-
512
- // Entry points
513
- if (st.entryPoints.length > 0) {
514
- lines.push('## Entry Points\n');
515
- for (const e of st.entryPoints.slice(0, 10)) lines.push(`- \`${e}\``);
516
- lines.push('');
517
- }
518
-
519
- // High impact files
520
- if (st.highImpact.length > 0) {
521
- lines.push('## Highest Impact Files\n');
522
- lines.push('These files have the most dependents — changes here carry the highest risk:\n');
523
- for (const h of st.highImpact.slice(0, 10)) {
524
- lines.push(`- \`${h.file}\` (${h.dependents} dependents)`);
525
- }
526
- lines.push('');
527
- }
528
-
529
- // Routes summary
530
- if (st.meta.totalRoutes > 0) {
531
- const routes = s.getRoutes().slice(0, 8);
532
- lines.push('## Sample Routes\n');
533
- for (const r of routes) lines.push(`- \`${r.method} ${r.path}\` → \`${r.file}\``);
534
- if (st.meta.totalRoutes > 8) lines.push(`_...and ${st.meta.totalRoutes - 8} more_`);
535
- lines.push('');
536
- }
537
-
538
- lines.push(`_Last indexed: ${st.meta.lastIndexed || 'unknown'}_`);
539
- return text(lines.join('\n'));
540
- }
541
-
542
- if (name === 'get_file_summary') {
543
- const file = s.getFileByPath(args.file);
544
- if (!file) return text(`File not found in index: ${args.file}`);
545
-
546
- const domain = s.getDomainForFile(args.file) || 'CORE';
547
- const blastDeps = s.getBlastRadius(args.file) || [];
548
- const nb = s.getNeighbors(args.file, 1);
549
-
550
- // Gather symbols
551
- const symbols = s.db.prepare(
552
- 'SELECT name, kind FROM symbols WHERE file_id = ? AND exported = 1 LIMIT 5'
553
- ).all(file.id);
554
-
555
- // Outgoing imports (what this file depends on)
556
- const outgoing = nb.edges
557
- .filter(e => e.source === args.file)
558
- .map(e => e.target)
559
- .slice(0, 4);
560
-
561
- // Incoming imports (what depends on this file)
562
- const incoming = nb.edges
563
- .filter(e => e.target === args.file)
564
- .map(e => e.source)
565
- .slice(0, 4);
566
-
567
- const lines = [`# File: ${args.file}\n`];
568
- lines.push(`**Domain:** ${domain} · **Dependents:** ${blastDeps.length} files\n`);
569
-
570
- if (symbols.length > 0) {
571
- lines.push(`**Exports:** ${symbols.map(s => `\`${s.name}\` (${s.kind})`).join(', ')}\n`);
572
- }
573
-
574
- if (outgoing.length > 0) {
575
- lines.push(`**Imports:** ${outgoing.map(f => `\`${f}\``).join(', ')}`);
576
- }
577
- if (incoming.length > 0) {
578
- lines.push(`**Imported by:** ${incoming.map(f => `\`${f}\``).join(', ')}`);
579
- }
580
-
581
- return text(lines.join('\n'));
582
- }
583
-
584
- if (name === 'get_change_plan') {
585
- const { planChange, formatPlanMarkdown } = require('./change-plan');
586
- return text(formatPlanMarkdown(planChange(s, args.intent || '')));
587
- }
588
-
589
- if (name === 'get_similar_patterns') {
590
- // Bitmap path: Jaccard similarity over forward-import sets.
591
- // Different semantics from the legacy 3-strategy SQL (same domain /
592
- // same routes / shared imports) but the standard graph similarity
593
- // metric and ~100× faster on large repos. Falls back to SQLite when
594
- // bitmap is unavailable.
595
- const sidecar = getSidecar();
596
- if (sidecar) {
597
- const limit = Math.min(args.limit || 5, 20);
598
- const results = bitmapTools.similarPatterns(sidecar, args.file, limit);
599
- if (results === null) return text(`File not found in index: ${args.file}`);
600
- const lines = [`# Similar Patterns to: ${args.file}\n`];
601
- if (results.length === 0) {
602
- lines.push('_No similar files found — this file has no resolved imports to compare against._');
603
- return text(lines.join('\n'));
604
- }
605
- lines.push('Files ranked by Jaccard similarity over their import sets:\n');
606
- lines.push('| File | Score | Shared Imports |');
607
- lines.push('|------|-------|----------------|');
608
- for (const r of results) {
609
- lines.push(`| \`${r.file}\` | ${r.score.toFixed(2)} | ${r.shared} |`);
610
- }
611
- return text(lines.join('\n'));
612
- }
613
-
614
- // SQLite fallback path — kept for the (rare) case bitmap load fails.
615
- const file = s.getFileByPath(args.file);
616
- if (!file) return text(`File not found in index: ${args.file}`);
617
-
618
- const limit = Math.min(args.limit || 5, 20);
619
- const domain = s.getDomainForFile(args.file);
620
-
621
- // Get this file's imports and routes for comparison
622
- const fileImports = s.db.prepare(
623
- 'SELECT to_path FROM imports WHERE from_file_id = ?'
624
- ).all(file.id).map(r => r.to_path);
625
-
626
- const fileRoutes = s.db.prepare(
627
- 'SELECT method, path FROM routes WHERE file_id = ?'
628
- ).all(file.id);
629
-
630
- const fileSymbols = s.db.prepare(
631
- 'SELECT name, kind FROM symbols WHERE file_id = ? AND exported = 1 LIMIT 10'
632
- ).all(file.id);
633
-
634
- const lines = [`# Similar Patterns to: ${args.file}\n`];
635
-
636
- // Strategy 1: Files in same domain with similar import count
637
- if (domain) {
638
- const domainFiles = s.db.prepare(`
639
- SELECT f.path, f.language,
640
- (SELECT COUNT(*) FROM imports WHERE from_file_id = f.id) as import_count,
641
- (SELECT COUNT(*) FROM routes WHERE file_id = f.id) as route_count
642
- FROM files f
643
- JOIN domain_assignments da ON da.file_id = f.id
644
- JOIN domains d ON da.domain_id = d.id
645
- WHERE d.name = ? AND f.path != ?
646
- ORDER BY ABS(import_count - ?) ASC
647
- LIMIT ?
648
- `).all(domain, args.file, fileImports.length, limit);
649
-
650
- if (domainFiles.length > 0) {
651
- lines.push(`## Files in same domain (${domain}) with similar structure\n`);
652
- lines.push('| File | Language | Imports | Routes |');
653
- lines.push('|------|----------|---------|--------|');
654
- for (const f of domainFiles) {
655
- lines.push(`| \`${f.path}\` | ${f.language} | ${f.import_count} | ${f.route_count} |`);
656
- }
657
- lines.push('');
658
- }
659
- }
660
-
661
- // Strategy 2: Files with same route patterns (same HTTP methods)
662
- if (fileRoutes.length > 0) {
663
- const methods = [...new Set(fileRoutes.map(r => r.method))];
664
- const methodPlaceholders = methods.map(() => '?').join(',');
665
- const similarRouteFiles = s.db.prepare(`
666
- SELECT DISTINCT f.path, COUNT(r.id) as route_count
667
- FROM files f
668
- JOIN routes r ON r.file_id = f.id
669
- WHERE r.method IN (${methodPlaceholders}) AND f.path != ?
670
- GROUP BY f.id
671
- ORDER BY ABS(route_count - ?) ASC
672
- LIMIT ?
673
- `).all(...methods, args.file, fileRoutes.length, limit);
674
-
675
- if (similarRouteFiles.length > 0) {
676
- lines.push(`## Files with similar route patterns (${methods.join(', ')})\n`);
677
- for (const f of similarRouteFiles) {
678
- lines.push(`- \`${f.path}\` (${f.route_count} routes)`);
679
- }
680
- lines.push('');
681
- }
682
- }
683
-
684
- // Strategy 3: Files with overlapping imports (shared dependencies)
685
- if (fileImports.length > 0) {
686
- const importPaths = fileImports.slice(0, 5);
687
- const placeholders = importPaths.map(() => '?').join(',');
688
- const sharedImportFiles = s.db.prepare(`
689
- SELECT f.path, COUNT(DISTINCT i.to_path) as shared_count
690
- FROM files f
691
- JOIN imports i ON i.from_file_id = f.id
692
- WHERE i.to_path IN (${placeholders}) AND f.path != ?
693
- GROUP BY f.id
694
- HAVING shared_count >= 2
695
- ORDER BY shared_count DESC
696
- LIMIT ?
697
- `).all(...importPaths, args.file, limit);
698
-
699
- if (sharedImportFiles.length > 0) {
700
- lines.push('## Files sharing common dependencies\n');
701
- for (const f of sharedImportFiles) {
702
- lines.push(`- \`${f.path}\` (${f.shared_count} shared imports)`);
703
- }
704
- lines.push('');
705
- }
706
- }
707
-
708
- if (lines.length === 1) {
709
- lines.push('_No similar patterns found. The file may be unique in the codebase._');
710
- }
711
-
712
- return text(lines.join('\n'));
713
- }
714
-
715
- if (name === 'simulate_change_impact') {
716
- // Returns the union of every transitively
717
- // affected file when a *set* of files changes simultaneously. Only
718
- // feasible with bitmaps: an N×SQL `getBlastRadius` approach takes
719
- // hundreds of milliseconds on large repos; bitmap OR-aggregate runs
720
- // in microseconds. If the bitmap engine is unavailable for any
721
- // reason, surface a clear "unsupported" error rather than an
722
- // O(N×F×E) SQL fallback that would block the agent.
723
- if (!Array.isArray(args.files) || args.files.length === 0) {
724
- return text('Missing or empty argument: files (array of relative paths from project root).');
725
- }
726
-
727
- // Normalize each input path the same way the single-file tools do,
728
- // and run lazy mtime check so any locally-edited input file is
729
- // re-parsed before we read the index. The lazy reparse will
730
- // invalidate the bitmap singleton if it triggers — getSidecar()
731
- // below picks that up.
732
- const normalizedFiles = [];
733
- for (const f of args.files) {
734
- if (typeof f !== 'string' || f.length === 0) continue;
735
- const norm = normalizeFileArg(projectRoot, f);
736
- normalizedFiles.push(norm);
737
- lazyReparseFile(norm);
738
- }
739
- if (normalizedFiles.length === 0) {
740
- return text('No valid file paths in `files` argument.');
741
- }
742
-
743
- const sidecar = getSidecar();
744
- if (!sidecar) {
745
- return text(
746
- '`simulate_change_impact` requires the bitmap engine, which failed to load. ' +
747
- 'Run `carto sync` to rebuild `.carto/bitmap.bin`.'
748
- );
749
- }
750
-
751
- const result = bitmapTools.simulateChangeImpact(sidecar, normalizedFiles);
752
- const lines = [
753
- `# Simulate Change Impact\n`,
754
- `Changing **${normalizedFiles.length}** file${normalizedFiles.length === 1 ? '' : 's'} ` +
755
- `simultaneously affects **${result.count}** transitive dependent` +
756
- `${result.count === 1 ? '' : 's'}.\n`,
757
- ];
758
- lines.push('## Input files\n');
759
- for (const f of normalizedFiles) lines.push(`- \`${f}\``);
760
- lines.push('');
761
- if (result.count === 0) {
762
- lines.push('_No additional files would be affected. None of the input files have dependents in the index._');
763
- } else {
764
- lines.push('## Affected files\n');
765
- lines.push('| File | Min Hop |');
766
- lines.push('|------|---------|');
767
- for (const r of result.files.slice(0, 200)) {
768
- lines.push(`| \`${r.file}\` | ${r.hop_distance} |`);
769
- }
770
- if (result.count > 200) lines.push(`\n_...and ${result.count - 200} more._`);
771
- }
772
- return text(lines.join('\n'));
773
- }
774
-
775
- // ─── Validation API + Episodic Memory ─────────────────────────────
776
-
777
- if (name === 'validate_diff') {
778
- if (!args || typeof args.diff !== 'string' || args.diff.length === 0) {
779
- return text('Missing required argument: diff (unified diff text).');
780
- }
781
- const sidecar = getSidecar();
782
- const result = validateDiff(s, sidecar, args.diff);
783
-
784
- // Persist via brief writer connection. Don't fail the
785
- // user-facing response if the audit log write fails (read-only FS,
786
- // disk full, schema migration in flight). Per-call `session_id`
787
- // override falls through to "create a session if none exists".
788
- withWriter((writer) => {
789
- let sessionId = args.session_id;
790
- if (typeof sessionId !== 'number' || sessionId <= 0) {
791
- const session = writer.getOrCreateActiveSession('mcp');
792
- sessionId = session.id;
793
- }
794
- recordSideEffects(writer, sessionId, args.diff, result);
795
- });
796
-
797
- // Render a markdown response. The shape is the visible artifact —
798
- // every AI tool the user runs will see this output.
799
- const lines = ['# Diff Validation\n'];
800
- const riskBadge = {
801
- SAFE: '🟢 SAFE',
802
- LOW: '🟡 LOW',
803
- MEDIUM: '🟠 MEDIUM',
804
- HIGH: '🔴 HIGH',
805
- }[result.risk] || result.risk;
806
- lines.push(`**Risk:** ${riskBadge}`);
807
- lines.push(`**Files changed:** ${result.diff.length}`);
808
- lines.push(`**Union blast radius:** ${result.blast_radius.union} transitive dependents\n`);
809
-
810
- if (result.diff.length > 0) {
811
- lines.push('## Files\n');
812
- lines.push('| File | Kind | +Lines | -Lines | Blast |');
813
- lines.push('|------|------|-------:|-------:|------:|');
814
- for (const d of result.diff) {
815
- const blast = result.blast_radius.perFile[d.path] || 0;
816
- lines.push(`| \`${d.path}\` | ${d.kind} | ${d.addedCount} | ${d.removedCount} | ${blast} |`);
817
- }
818
- lines.push('');
819
- }
820
-
821
- if (result.violations.length > 0) {
822
- lines.push(`## Violations (${result.violations.length})\n`);
823
- lines.push('| Severity | Kind | File | Detail |');
824
- lines.push('|----------|------|------|--------|');
825
- for (const v of result.violations) {
826
- lines.push(`| ${v.severity} | ${v.kind} | \`${v.file}\` | ${v.message} |`);
827
- }
828
- lines.push('');
829
- } else {
830
- lines.push('_No violations detected._\n');
831
- }
832
-
833
- if (result.suggestions.length > 0) {
834
- lines.push(`## Suggestions (${result.suggestions.length})\n`);
835
- for (const sug of result.suggestions) {
836
- lines.push(`- **${sug.kind}** on \`${sug.file}\`: ${sug.message}`);
837
- }
838
- lines.push('');
839
- }
840
-
841
- return text(lines.join('\n'));
842
- }
843
-
844
- if (name === 'get_recent_decisions') {
845
- const range = (args && args.time_range) || '7d';
846
- const ms = parseTimeRange(range);
847
- if (ms === null) {
848
- return text(`Invalid time_range: "${range}". Use formats like "7d", "24h", "1h".`);
849
- }
850
- const kind = args && args.kind ? String(args.kind) : null;
851
- const rows = s.getRecentDecisions(ms, kind);
852
- if (rows.length === 0) {
853
- return text(`No decisions in the last ${range}${kind ? ` (kind=${kind})` : ''}.`);
854
- }
855
- const lines = [`# Recent Decisions (last ${range}${kind ? `, kind=${kind}` : ''})\n`];
856
- lines.push(`**${rows.length}** decision${rows.length === 1 ? '' : 's'} found.\n`);
857
- lines.push('| When | Kind | File | Summary |');
858
- lines.push('|------|------|------|---------|');
859
- for (const r of rows.slice(0, 50)) {
860
- const when = new Date(r.ts).toISOString();
861
- const summary = summarizeDecisionPayload(r.payload_json);
862
- lines.push(`| ${when} | ${r.kind} | ${r.file ? `\`${r.file}\`` : '—'} | ${summary} |`);
863
- }
864
- if (rows.length > 50) lines.push(`\n_...and ${rows.length - 50} more._`);
865
- return text(lines.join('\n'));
866
- }
867
-
868
- if (name === 'get_session_context') {
869
- let sessionId = args && args.session_id;
870
- if (typeof sessionId !== 'number' || sessionId <= 0) {
871
- const cur = s.getCurrentSession();
872
- if (!cur) return text('No active session found. Run a tool that creates one (e.g. `validate_diff`) first.');
873
- sessionId = cur.id;
874
- }
875
- const ctx = s.getSessionContext(sessionId);
876
- if (!ctx) return text(`Session not found: ${sessionId}`);
877
- const lines = [`# Session ${ctx.session.id}\n`];
878
- lines.push(`**Started:** ${new Date(ctx.session.started_at).toISOString()}`);
879
- if (ctx.session.ended_at) {
880
- lines.push(`**Ended:** ${new Date(ctx.session.ended_at).toISOString()}`);
881
- } else {
882
- lines.push('**Ended:** _(active)_');
883
- }
884
- if (ctx.session.client_name) lines.push(`**Client:** ${ctx.session.client_name}`);
885
- lines.push('');
886
- lines.push(`## Decisions (${ctx.decisions.length})\n`);
887
- if (ctx.decisions.length === 0) {
888
- lines.push('_None._\n');
889
- } else {
890
- lines.push('| When | Kind | File | Summary |');
891
- lines.push('|------|------|------|---------|');
892
- for (const d of ctx.decisions.slice(0, 50)) {
893
- const when = new Date(d.ts).toISOString();
894
- const summary = summarizeDecisionPayload(d.payload_json);
895
- lines.push(`| ${when} | ${d.kind} | ${d.file ? `\`${d.file}\`` : '—'} | ${summary} |`);
896
- }
897
- if (ctx.decisions.length > 50) lines.push(`\n_...and ${ctx.decisions.length - 50} more._`);
898
- lines.push('');
899
- }
900
- lines.push(`## Interventions (${ctx.interventions.length})\n`);
901
- if (ctx.interventions.length === 0) {
902
- lines.push('_None._');
903
- } else {
904
- lines.push('| When | Severity | Kind | File | Message |');
905
- lines.push('|------|----------|------|------|---------|');
906
- for (const iv of ctx.interventions.slice(0, 50)) {
907
- const when = new Date(iv.ts).toISOString();
908
- lines.push(`| ${when} | ${iv.severity || '—'} | ${iv.kind} | ${iv.file ? `\`${iv.file}\`` : '—'} | ${iv.message || ''} |`);
909
- }
910
- if (ctx.interventions.length > 50) lines.push(`\n_...and ${ctx.interventions.length - 50} more._`);
911
- }
912
- return text(lines.join('\n'));
913
- }
914
-
915
- if (name === 'did_we_discuss_this') {
916
- if (!args || typeof args.topic !== 'string' || args.topic.length === 0) {
917
- return text('Missing required argument: topic (string).');
918
- }
919
- const decisions = s.searchDecisions(args.topic);
920
- const interventions = s.searchInterventions(args.topic);
921
- if (decisions.length === 0 && interventions.length === 0) {
922
- return text(`No prior discussion of "${args.topic}" found in the episodic memory log.`);
923
- }
924
- const lines = [`# Prior discussions of "${args.topic}"\n`];
925
- if (decisions.length > 0) {
926
- lines.push(`## Decisions (${decisions.length})\n`);
927
- lines.push('| When | Session | Kind | File | Summary |');
928
- lines.push('|------|---------|------|------|---------|');
929
- for (const d of decisions.slice(0, 25)) {
930
- const when = new Date(d.ts).toISOString();
931
- const summary = summarizeDecisionPayload(d.payload_json);
932
- lines.push(`| ${when} | ${d.session_id || '—'} | ${d.kind} | ${d.file ? `\`${d.file}\`` : '—'} | ${summary} |`);
933
- }
934
- if (decisions.length > 25) lines.push(`\n_...and ${decisions.length - 25} more._`);
935
- lines.push('');
936
- }
937
- if (interventions.length > 0) {
938
- lines.push(`## Interventions (${interventions.length})\n`);
939
- lines.push('| When | Session | Severity | Kind | File | Message |');
940
- lines.push('|------|---------|----------|------|------|---------|');
941
- for (const iv of interventions.slice(0, 25)) {
942
- const when = new Date(iv.ts).toISOString();
943
- lines.push(`| ${when} | ${iv.session_id || '—'} | ${iv.severity || '—'} | ${iv.kind} | ${iv.file ? `\`${iv.file}\`` : '—'} | ${iv.message || ''} |`);
944
- }
945
- if (interventions.length > 25) lines.push(`\n_...and ${interventions.length - 25} more._`);
946
- }
947
- return text(lines.join('\n'));
948
- }
949
-
950
- if (name === 'get_intervention_history') {
951
- const file = args && args.file ? args.file : null;
952
- const rows = s.getInterventionsForFile(file);
953
- if (rows.length === 0) {
954
- return text(file ? `No interventions for \`${file}\`.` : 'No interventions in the log.');
955
- }
956
- const lines = [`# Intervention History${file ? `: \`${file}\`` : ''}\n`];
957
- lines.push(`**${rows.length}** intervention${rows.length === 1 ? '' : 's'} found.\n`);
958
- lines.push('| When | Severity | Kind | File | Message |');
959
- lines.push('|------|----------|------|------|---------|');
960
- for (const iv of rows.slice(0, 100)) {
961
- const when = new Date(iv.ts).toISOString();
962
- lines.push(`| ${when} | ${iv.severity || '—'} | ${iv.kind} | ${iv.file ? `\`${iv.file}\`` : '—'} | ${iv.message || ''} |`);
963
- }
964
- if (rows.length > 100) lines.push(`\n_...and ${rows.length - 100} more._`);
965
- return text(lines.join('\n'));
966
- }
967
-
968
- return text(`Unknown tool: ${name}`);
969
- } catch (err) {
970
- process.stderr.write(`[CARTO MCP] Tool "${name}" error: ${err.stack || err.message || err}\n`);
971
- return {
972
- content: [{ type: 'text', text: `Error in ${name}: ${err.message || String(err)}` }],
973
- isError: true,
974
- };
975
- }
976
- });
977
-
978
- async function main() {
979
- const transport = new StdioServerTransport();
980
- await server.connect(transport);
981
- }
982
-
983
- main().catch(err => {
984
- console.error('[CARTO MCP] Fatal:', err.message);
985
- process.exit(1);
986
- });