carto-md 2.0.6 → 2.0.8

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.
@@ -7,9 +7,26 @@ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
7
7
  const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
8
8
  const { CallToolRequestSchema, ListToolsRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
9
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');
10
15
 
11
16
  const projectRoot = process.cwd();
12
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
+
13
30
  // Open SQLite directly — no re-indexing, instant startup
14
31
  let store = null;
15
32
 
@@ -17,19 +34,180 @@ function getStore() {
17
34
  if (store) return store;
18
35
  const dbPath = path.join(projectRoot, '.carto', 'carto.db');
19
36
  if (!fs.existsSync(dbPath)) return null;
20
- store = new SQLiteStore(projectRoot);
21
- store.open();
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;
22
44
  return store;
23
45
  }
24
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
+
25
130
  function text(str) {
26
131
  return { content: [{ type: 'text', text: str }] };
27
132
  }
28
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
+
29
162
  function notIndexed() {
30
163
  return text('No .carto/carto.db found. Run `carto sync` first.');
31
164
  }
32
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
+
33
211
  // ─── Tool definitions (same as V1) ──────────────────────────────────────────
34
212
 
35
213
  const TOOLS = [
@@ -49,6 +227,13 @@ const TOOLS = [
49
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'] } },
50
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'] } },
51
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: [] } },
52
237
  ];
53
238
 
54
239
  // ─── Server setup ─────────────────────────────────────────────────────────────
@@ -62,8 +247,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
62
247
 
63
248
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
64
249
  const { name, arguments: args } = request.params;
65
- const s = getStore();
66
- if (!s) return notIndexed();
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();
67
268
 
68
269
  if (name === 'get_routes') {
69
270
  const routes = s.getRoutes();
@@ -74,7 +275,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
74
275
  }
75
276
 
76
277
  if (name === 'get_blast_radius') {
77
- const deps = s.getBlastRadius(args.file);
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);
78
284
  if (!deps) return text(`File not found in index: ${args.file}`);
79
285
  if (deps.length === 0) return text(`No dependents found for: ${args.file}`);
80
286
  const lines = [`# Blast Radius: ${args.file}\n`, `**Affected files:** ${deps.length}\n`];
@@ -105,6 +311,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
105
311
  }
106
312
 
107
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
+ }
108
319
  const domain = s.getDomain(args.domain);
109
320
  if (!domain) return text(`Domain not found: ${args.domain}. Use get_domains_list to see available domains.`);
110
321
 
@@ -133,7 +344,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
133
344
  models: domain.models.map(m => ({
134
345
  ...m,
135
346
  className: m.name,
136
- fields: m.fields_json ? JSON.parse(m.fields_json) : []
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
+ })()
137
352
  })),
138
353
  functions: {},
139
354
  envVars: [],
@@ -167,7 +382,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
167
382
  }
168
383
 
169
384
  if (name === 'get_cross_domain') {
170
- const xd = s.getCrossDomainDeps();
385
+ // Bitmap path with SQLite fallback.
386
+ const sidecar = getSidecar();
387
+ const xd = sidecar ? bitmapTools.crossDomain(sidecar) : s.getCrossDomainDeps();
171
388
  if (xd.length === 0) return text('No cross-domain dependencies found.');
172
389
  const lines = [`# Cross-Domain Dependencies (${xd.length})\n`];
173
390
  lines.push('| From | From Domain | To | To Domain |');
@@ -219,7 +436,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
219
436
  }
220
437
 
221
438
  if (name === 'get_high_impact_files') {
222
- const files = s.getHighImpactFiles(args.limit || 10);
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);
223
449
  if (files.length === 0) return text('No high impact files found.');
224
450
  const lines = [`# High Impact Files\n`];
225
451
  lines.push('| File | Dependents |');
@@ -257,6 +483,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
257
483
  if (st.stack.length > 0) lines.push(`**Stack:** ${st.stack.join(', ')}\n`);
258
484
  lines.push(`**Size:** ${st.meta.totalFiles} files · ${st.meta.totalRoutes} routes · ${st.meta.totalImportEdges} import edges\n`);
259
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
+
260
502
  // Domains
261
503
  if (domains.length > 0) {
262
504
  lines.push('## Domains\n');
@@ -345,6 +587,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
345
587
  }
346
588
 
347
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.
348
615
  const file = s.getFileByPath(args.file);
349
616
  if (!file) return text(`File not found in index: ${args.file}`);
350
617
 
@@ -445,7 +712,267 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
445
712
  return text(lines.join('\n'));
446
713
  }
447
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
+
448
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
+ }
449
976
  });
450
977
 
451
978
  async function main() {