@debugai/mcp 2.1.1 → 2.3.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.
package/README.md CHANGED
@@ -75,13 +75,29 @@ Give it an error, get an analysis.
75
75
  |-------|----------|-------------|
76
76
  | `errorText` | yes | Full error message, exception, or stack trace. |
77
77
  | `language` | no | `javascript`, `typescript`, `python`, `go`, `rust`, or `auto` (default). |
78
- | `codeSnippet` | no | Code around the failing line, if the agent has it. |
78
+ | `codeSnippet` | no | Usually unnecessary see below. Overrides the local read when given. |
79
79
  | `filePath` | no | Path to the file that threw. |
80
80
 
81
- Returns the root cause, up to 3 fixes ranked by confidence, the detected framework, and whether the answer came from cache. Since 2.0 each fix also carries, where derivable: `edits` (exact old/new strings your agent's edit tool can apply directly), `unified_diff`, and `verify_with` (a syntax-level check command to run after applying). Read-only: it never touches your files. Applying a fix is your agent's call, and yours.
81
+ Returns the root cause, up to 3 fixes ranked by confidence, the detected framework, and whether the answer came from cache. Since 2.0 each fix also carries, where derivable: `edits` (exact old/new strings your agent's edit tool can apply directly), `unified_diff`, and `verify_with` (a syntax-level check command to run after applying). It never writes to your files. Applying a fix is your agent's call, and yours.
82
82
 
83
83
  Every fix is labeled with its verification state, and there are three of them, not two: **verified** (a mechanical check passed, currently parse and import classes), **failed check** (confidence capped hard), or **not verified** (the confidence number is the model's own estimate, nothing checked it). We label the third case instead of hiding it.
84
84
 
85
+ #### It reads the source the trace names (2.2)
86
+
87
+ Paste a raw traceback and nothing else. This server runs on your machine, so it resolves the files in the stack trace and sends the relevant code along. Before 2.2 it could not, and an error pasted without code came back as *"Insufficient context for specific fix"* at 20% confidence — the common case, not the edge one.
88
+
89
+ The response names every file it read (`Read locally: src/api.ts, src/db.ts`, and `local_files_read` in the structured payload), so a wrong guess is visible rather than silent.
90
+
91
+ Exactly what it will and will not read:
92
+
93
+ - **Inside your project only.** The boundary is your git repo root, or the working directory when that is not a repo. It is checked *after* symlinks resolve, so a path in an error message cannot walk out of the project — `../../.ssh/id_rsa` and a symlink pointing outside both fail the same check.
94
+ - **Source files only**, by extension allowlist (`.ts .tsx .js .jsx .mjs .cjs .py .go .rs .rb .java .kt .php .cs .swift .vue .svelte`). `.env`, `.pem`, key files, dumps and images are not on it, so they are never candidates.
95
+ - **Bounded**: at most 3 files, 256 KB each, and a large file is sent as a ±40-line window around the failing line rather than whole. Windowed blocks say so.
96
+ - **Vendored code is skipped** — `node_modules`, `site-packages`, `dist`, `.venv` and friends. Your code is what needs explaining.
97
+ - **Your snippet always wins.** Pass `codeSnippet` and nothing is read from disk.
98
+
99
+ Whatever it reads is sent to DebugAI's API for analysis, same as the error text. If that is not acceptable for a given repo, pass `codeSnippet` explicitly and the local read never happens.
100
+
85
101
  ### `report_outcome`
86
102
 
87
103
  Tell DebugAI whether an applied fix actually worked.
@@ -172,7 +188,8 @@ Then run `npx -y @debugai/mcp login` once to store your key. If you would rather
172
188
  - Free tier: 10 debugs/day. Pro ($12/mo): 1,000/mo soft cap, never hard-blocked at it.
173
189
  - When you hit the daily cap the tool says so and stops. It will not silently retry.
174
190
  - Simple errors route to a fast model. Ugly cross-file ones route to a stronger one on paid tiers. The `Model:` badge in each response tells you which one answered.
175
- - Analyses run on DebugAI's servers. The error text and any snippet you pass are sent there, and Claude (Anthropic) does the analysis. Privacy policy: [debugai.io/privacy](https://debugai.io/privacy?src=npm).
191
+ - Analyses run on DebugAI's servers. The error text, any snippet you pass, and — since 2.2 — the project source files the stack trace names are sent there, and Claude (Anthropic) does the analysis. The response lists every file that was read. Scope and limits are spelled out under [`debug_error`](#it-reads-the-source-the-trace-names-22). Privacy policy: [debugai.io/privacy](https://debugai.io/privacy?src=npm).
192
+ - Errors are remembered per project so a repeat hit starts from a fix that was already confirmed. The project identity is a hash of your repo root path; the path itself is never sent.
176
193
 
177
194
  ## Troubleshooting
178
195
 
@@ -185,6 +202,17 @@ Run `npx -y @debugai/mcp doctor` first. It checks your Node version, whether a k
185
202
 
186
203
  ## Changelog
187
204
 
205
+ **Note on updates**: the analysis runs on DebugAI's servers, so improvements to
206
+ error parsing, retrieval and root-cause quality reach you without upgrading this
207
+ package. Recent server-side work: pytest, unittest, jest, vitest and mocha output
208
+ now yields real file paths, so a failing test gets cross-file context instead of
209
+ none. Client releases are only for things that change on your machine.
210
+
211
+ **2.2.0**: two things that were advertised but not delivered.
212
+
213
+ - **Local source resolution.** The server now reads the files a stack trace names and sends them with the request. Before this, an agent that pasted a traceback got "Insufficient context for specific fix" at 20% confidence, because the only other source of code was an index that nothing outside the VS Code extension populates. Bounded to your repo root, source extensions, 3 files. The response names what it read.
214
+ - **Project-scoped error memory.** Nothing in this package ever sent a `project_id`, and the engine gates recurrence counts and confirmed fixes on it — so every agent request got no recall and recorded none, while the server instructions told agents it did. Now derived from your git repo root, matching the VS Code extension's id for the same checkout, so both surfaces share one memory. `debug_error` surfaces "seen 3x before, fix confirmed" when it applies.
215
+
188
216
  **2.1.1**: metadata only. Adds `mcpName` for official MCP registry ownership verification, fixes the npm package page's repository link.
189
217
 
190
218
  **2.1.0**: one-command setup. Browser sign-in over a device link (no key pasting), automatic client config writing with backups, `doctor` for diagnosing a broken setup, and in-conversation sign-in when an agent calls a tool before you have an account.
package/dist/backend.d.ts CHANGED
@@ -42,7 +42,17 @@ export interface DebugResponse {
42
42
  error_signature?: string;
43
43
  session_id?: string;
44
44
  memory_hit?: boolean;
45
+ memory_times_seen?: number;
45
46
  memory_fix_confirmed?: boolean;
47
+ /**
48
+ * Four-valued: 'none' | 'confirmed' | 'unproven' | 'anti_pattern'. Sent by
49
+ * the engine since 2026-08-21. Absent from an older engine, where
50
+ * memory_fix_confirmed is the whole story — and that boolean now goes false
51
+ * on demotion, so the degraded reading is safe rather than wrong.
52
+ */
53
+ memory_fix_state?: string;
54
+ /** Times a live session watched the error stop after the fix. A click does not count. */
55
+ memory_verified_count?: number;
46
56
  }
47
57
  export interface OutcomeRequest {
48
58
  debug_log_id: string;
package/dist/index.js CHANGED
@@ -41,7 +41,9 @@ Commands:
41
41
 
42
42
  Tools exposed to your agent:
43
43
  debug_error hand it an error or stack trace, get root cause + ranked
44
- fixes with machine-applicable edits (v2 contract)
44
+ fixes with machine-applicable edits (v2 contract). Reads the
45
+ source files the trace names, from inside this project only —
46
+ no need to locate and paste code first.
45
47
  report_outcome tell DebugAI whether an applied fix worked — failed-fix
46
48
  follow-ups improve future answers for your codebase
47
49
 
@@ -0,0 +1,25 @@
1
+ /** Repo root per git, or null when this is not a git worktree. */
2
+ export declare function gitToplevel(cwd?: string): string | null;
3
+ export declare function hashPath(p: string): string;
4
+ /**
5
+ * Resolve the project id for this process. Never throws: a debug request
6
+ * without memory is far better than a debug request that failed.
7
+ */
8
+ export declare function resolveProjectId(cwd?: string): string | null;
9
+ /**
10
+ * Cached across the process. The MCP server is long-lived and its cwd does not
11
+ * move, so shelling out to git once per debug call would be wasted work.
12
+ */
13
+ export declare function getProjectId(): string | null;
14
+ /**
15
+ * The directory local source resolution is allowed to read inside.
16
+ *
17
+ * Same value the project id is derived from — git toplevel, falling back to
18
+ * cwd — so "the project we remember errors for" and "the project we may read
19
+ * files from" can never be two different directories. sourceContext.ts treats
20
+ * this as a hard boundary: a path that resolves outside it is not read, which
21
+ * is what keeps a crafted stack trace from turning into an arbitrary file read.
22
+ */
23
+ export declare function getProjectRoot(cwd?: string): string;
24
+ /** Test seam. */
25
+ export declare function __resetProjectIdCache(): void;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Project identity for the MCP path.
3
+ *
4
+ * Team Error Memory (recurrence counts, confirmed fixes) is project-scoped in
5
+ * the engine: a request with no project_id is never recalled and never stored.
6
+ * Until this module existed, nothing in this package ever set project_id, so
7
+ * every agent request went unscoped and the memory features were silently
8
+ * inert on the MCP path while the server instructions advertised them.
9
+ *
10
+ * What identifies a "project" here:
11
+ *
12
+ * md5(git toplevel path) → falling back to md5(cwd)
13
+ *
14
+ * Two constraints drove that choice.
15
+ *
16
+ * 1. It has to MATCH the VS Code extension, which uses
17
+ * md5(workspaceFolder.fsPath). Someone running both against one repo should
18
+ * get one memory, not two. Git toplevel equals the workspace folder in the
19
+ * normal case, and resolving to the repo root also means an agent invoked
20
+ * from a subdirectory lands on the same id as one invoked from the root.
21
+ *
22
+ * 2. It does NOT need to be unguessable. The API gateway namespaces every
23
+ * incoming project_id as `u_<user.id>__<project_id>` before the engine sees
24
+ * it (apps/api/src/routes/debug.ts), so the authenticated user is the real
25
+ * tenancy boundary. A path string that collides across two users (`/app` in
26
+ * a devcontainer is the obvious case) still resolves to two different
27
+ * scoped ids server-side.
28
+ *
29
+ * Format note: the gateway rejects anything not matching
30
+ * /^[A-Za-z0-9_\-]{1,64}$/, and md5 hex satisfies that.
31
+ */
32
+ import { createHash } from 'node:crypto';
33
+ import { execFileSync } from 'node:child_process';
34
+ let cached;
35
+ /** Repo root per git, or null when this is not a git worktree. */
36
+ export function gitToplevel(cwd = process.cwd()) {
37
+ try {
38
+ const out = execFileSync('git', ['rev-parse', '--show-toplevel'], {
39
+ cwd,
40
+ encoding: 'utf8',
41
+ stdio: ['ignore', 'pipe', 'ignore'],
42
+ timeout: 2000,
43
+ });
44
+ const trimmed = out.trim();
45
+ return trimmed.length > 0 ? trimmed : null;
46
+ }
47
+ catch {
48
+ // Not a repo, git missing, or timeout. All three mean "fall back", not "fail".
49
+ return null;
50
+ }
51
+ }
52
+ export function hashPath(p) {
53
+ return createHash('md5').update(p).digest('hex');
54
+ }
55
+ /**
56
+ * Resolve the project id for this process. Never throws: a debug request
57
+ * without memory is far better than a debug request that failed.
58
+ */
59
+ export function resolveProjectId(cwd = process.cwd()) {
60
+ try {
61
+ const root = gitToplevel(cwd) ?? cwd;
62
+ if (!root)
63
+ return null;
64
+ return hashPath(root);
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ /**
71
+ * Cached across the process. The MCP server is long-lived and its cwd does not
72
+ * move, so shelling out to git once per debug call would be wasted work.
73
+ */
74
+ export function getProjectId() {
75
+ if (cached === undefined)
76
+ cached = resolveProjectId();
77
+ return cached;
78
+ }
79
+ /**
80
+ * The directory local source resolution is allowed to read inside.
81
+ *
82
+ * Same value the project id is derived from — git toplevel, falling back to
83
+ * cwd — so "the project we remember errors for" and "the project we may read
84
+ * files from" can never be two different directories. sourceContext.ts treats
85
+ * this as a hard boundary: a path that resolves outside it is not read, which
86
+ * is what keeps a crafted stack trace from turning into an arbitrary file read.
87
+ */
88
+ export function getProjectRoot(cwd = process.cwd()) {
89
+ return gitToplevel(cwd) ?? cwd;
90
+ }
91
+ /** Test seam. */
92
+ export function __resetProjectIdCache() {
93
+ cached = undefined;
94
+ }
package/dist/server.js CHANGED
@@ -18,6 +18,13 @@ you cannot derive from the code in front of you:
18
18
  FAILED, or not checked at all (never treat "not checked" as verified)
19
19
  - machine-applicable edits (exact old/new strings) you can apply directly
20
20
 
21
+ Pass the raw error or traceback. You do not need to find and paste the source
22
+ first: this server runs on the same machine as the code, so it resolves the
23
+ files the trace names and sends them along. It reads source files inside the
24
+ current project only, and the response says which ones. Pass codeSnippet
25
+ yourself only when you already know the relevant code is somewhere the trace
26
+ does not name.
27
+
21
28
  After you apply or abandon a fix, call report_outcome with the debug_log_id.
22
29
  That is what turns a one-off answer into memory for the next person who hits
23
30
  the same error. Skipping it costs the user the feature they are paying for.
@@ -0,0 +1,75 @@
1
+ /** Files read per request. Three frames is a cause, a caller, and one spare. */
2
+ export declare const MAX_FILES = 3;
3
+ /** Per file. Larger than this and the window, not the file, is what we send. */
4
+ export declare const MAX_BYTES = 256000;
5
+ /** Lines of context either side of the failing line. */
6
+ export declare const WINDOW = 40;
7
+ export interface Frame {
8
+ path: string;
9
+ line: number | null;
10
+ }
11
+ export interface ResolvedFile {
12
+ /** Path as it will be shown to the model — relative to the root when inside it. */
13
+ label: string;
14
+ line: number | null;
15
+ text: string;
16
+ /** True when only a window around `line` was sent, not the whole file. */
17
+ windowed: boolean;
18
+ }
19
+ /**
20
+ * Frames in the order worth reading: innermost user frame first.
21
+ *
22
+ * Python tracebacks print outermost-first, so the interesting frame is LAST and
23
+ * the list is reversed. JS stacks print innermost-first and are read forward.
24
+ * Getting this backwards names the caller instead of the bug — the exact
25
+ * regression the extension shipped and had to fix.
26
+ */
27
+ export declare function extractFrames(errorText: string): Frame[];
28
+ /** Inside `root` after symlinks resolve? The check that stops `../../.ssh/…`. */
29
+ export declare function isInsideRoot(candidate: string, root: string): boolean;
30
+ /**
31
+ * The extension ALLOWLIST is the mechanism — there is deliberately no secret
32
+ * denylist beside it.
33
+ *
34
+ * A denylist was written here first and deleted. Everything it caught was
35
+ * already unreachable: `.env`, `id_rsa` and `credentials` have no extension,
36
+ * `.env.local` extends to `.local`, `server.pem` to `.pem` — none are in
37
+ * SOURCE_EXT, so all four were refused before the denylist was consulted. It
38
+ * bought nothing.
39
+ *
40
+ * What it did buy was a bug. `^\.?env(\..+)?$` also matches `env.js` and
41
+ * `env.ts`, and `src/env.ts` is a real, common source file (it is the standard
42
+ * name for typed environment schemas). A config error names that file more
43
+ * often than any other, and the denylist would have silently dropped it while
44
+ * looking like a security control.
45
+ *
46
+ * If a denylist is ever wanted back, it needs a case the allowlist does not
47
+ * already cover, and a test proving it.
48
+ */
49
+ export declare function isReadableSource(p: string): boolean;
50
+ /**
51
+ * Read the source behind an error. Returns null when there is nothing honest to
52
+ * send, which is a real answer: a wrong file is worse than no file, and the
53
+ * engine's own advice path handles the empty case.
54
+ *
55
+ * Never throws. A debug request that loses local context is worth far more than
56
+ * one that fails because a path was odd.
57
+ */
58
+ export declare function resolveSourceContext(opts: {
59
+ errorText: string;
60
+ filePath?: string;
61
+ root: string;
62
+ cwd?: string;
63
+ maxFiles?: number;
64
+ }): {
65
+ snippet: string;
66
+ files: ResolvedFile[];
67
+ } | null;
68
+ /**
69
+ * One block per file, labelled with the path and the failing line.
70
+ *
71
+ * The label matters as much as the code: the engine's v2 contract derives
72
+ * machine-applicable edits from what it was sent, and an edit is only
73
+ * applicable if the file it names is the file the agent can open.
74
+ */
75
+ export declare function formatSnippet(files: ResolvedFile[]): string;
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Local source resolution — read the files a stack trace names.
3
+ *
4
+ * ## Why this is here and not in the engine
5
+ *
6
+ * The engine cannot read your disk. It resolves a trace to `bad.js`, asks
7
+ * pgvector for that file, and gets nothing back unless the project was indexed
8
+ * first — and indexing only ever happened from the VS Code extension. So an
9
+ * agent on the MCP path hit this, every time, on every project:
10
+ *
11
+ * Fix 1 — Insufficient context for specific fix 20% confidence
12
+ *
13
+ * and the advice attached to it (services/context_advice.py) names
14
+ * "DebugAI: Index Entire Workspace", a VS Code palette command. A Claude Code
15
+ * or Cursor agent has no palette and no such command. That is the same defect
16
+ * we fixed in the extension on 2026-08-08 — advice naming a step the reader
17
+ * cannot take — aimed at a different audience.
18
+ *
19
+ * Rewording it would be the small fix. This is the real one: the MCP server is
20
+ * a local process. It has the filesystem the engine lacks, so it can send the
21
+ * actual source and skip the whole missing-context branch rather than write
22
+ * better copy about it.
23
+ *
24
+ * ## What it does
25
+ *
26
+ * Reads the error text, ranks the frames it names, resolves them against the
27
+ * project root, and returns a labelled snippet windowed around the failing
28
+ * lines. The agent's own `codeSnippet` always wins — this only fills a gap.
29
+ *
30
+ * ## What it refuses to do
31
+ *
32
+ * This module turns text the model produced into filesystem reads, so its
33
+ * limits are a security boundary, not tidiness:
34
+ *
35
+ * - every candidate must resolve INSIDE the project root, checked after
36
+ * symlink resolution, so `../../.ssh/id_rsa` in a crafted error cannot
37
+ * walk out
38
+ * - secret-shaped names (.env, *.pem, id_rsa, *.key) are never read even
39
+ * when they sit inside the root
40
+ * - source extensions only, MAX_FILES files, MAX_BYTES each
41
+ *
42
+ * Frame ORDERING is duplicated from apps/extension/src/errorPaths.ts on
43
+ * purpose: this package publishes to npm standalone and cannot import from the
44
+ * extension. The rule both must keep — Python innermost LAST, JS innermost
45
+ * FIRST — is stated in both files and tested in both.
46
+ */
47
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
48
+ import { isAbsolute, join, relative, basename, extname } from 'node:path';
49
+ /** Files read per request. Three frames is a cause, a caller, and one spare. */
50
+ export const MAX_FILES = 3;
51
+ /** Per file. Larger than this and the window, not the file, is what we send. */
52
+ export const MAX_BYTES = 256_000;
53
+ /** Lines of context either side of the failing line. */
54
+ export const WINDOW = 40;
55
+ const SOURCE_EXT = new Set([
56
+ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
57
+ '.py', '.go', '.rs', '.rb', '.java', '.kt', '.php', '.cs', '.swift',
58
+ '.vue', '.svelte',
59
+ ]);
60
+ const VENDOR = /(^|\/)(node_modules|site-packages|\.venv|venv|vendor|dist|build|\.next|target)(\/|$)/;
61
+ // ── Frame extraction ─────────────────────────────────────────────────────────
62
+ //
63
+ // Same shapes as the extension's parser, but this one keeps the LINE NUMBER and
64
+ // returns every frame rather than one best guess: the window we send is built
65
+ // around the line, and a cause plus its caller is usually worth two reads.
66
+ const PY_FRAME = /File ["']([^"'\r\n]+\.py)["'],\s*line\s*(\d+)/g;
67
+ const PY_TEST_ID = /([\w./\\-]+\.py)::/g;
68
+ const PY_LINE_REF = /(?:^|\s)([\w./\\-]+\.py):(\d+):/gm;
69
+ const JS_FRAME = /(?:at\s+\S+\s+\()?((?:[A-Za-z]:)?[^\s()]+\.(?:js|ts|jsx|tsx|mjs|cjs)):(\d+)/g;
70
+ /** Go, Rust, Ruby, Java and friends: `path/file.ext:123` on its own. */
71
+ const GENERIC = /(?:^|[\s(])((?:[A-Za-z]:)?[^\s():]+\.(?:go|rs|rb|java|kt|php|cs|swift|vue|svelte)):(\d+)/g;
72
+ function collect(text, re, withLine) {
73
+ return [...text.matchAll(re)].map((m) => ({
74
+ path: m[1],
75
+ line: withLine && m[2] ? Number(m[2]) : null,
76
+ }));
77
+ }
78
+ function isVendor(p) {
79
+ return VENDOR.test(p.replace(/\\/g, '/'));
80
+ }
81
+ /**
82
+ * Frames in the order worth reading: innermost user frame first.
83
+ *
84
+ * Python tracebacks print outermost-first, so the interesting frame is LAST and
85
+ * the list is reversed. JS stacks print innermost-first and are read forward.
86
+ * Getting this backwards names the caller instead of the bug — the exact
87
+ * regression the extension shipped and had to fix.
88
+ */
89
+ export function extractFrames(errorText) {
90
+ if (!errorText)
91
+ return [];
92
+ const out = [];
93
+ const seen = new Set();
94
+ const push = (f) => {
95
+ const key = `${f.path}:${f.line ?? ''}`;
96
+ if (!isVendor(f.path) && !seen.has(key)) {
97
+ seen.add(key);
98
+ out.push(f);
99
+ }
100
+ };
101
+ collect(errorText, PY_FRAME, true).reverse().forEach(push);
102
+ collect(errorText, JS_FRAME, true).forEach(push);
103
+ collect(errorText, GENERIC, true).forEach(push);
104
+ // Test-runner shapes carry no frame at all. Last, because a pytest node id
105
+ // names the TEST file, which is a weaker guess than any real frame.
106
+ collect(errorText, PY_LINE_REF, true).forEach(push);
107
+ collect(errorText, PY_TEST_ID, false).forEach(push);
108
+ return out;
109
+ }
110
+ // ── Safety ───────────────────────────────────────────────────────────────────
111
+ /** Inside `root` after symlinks resolve? The check that stops `../../.ssh/…`. */
112
+ export function isInsideRoot(candidate, root) {
113
+ let realRoot;
114
+ let realCandidate;
115
+ try {
116
+ realRoot = realpathSync(root);
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ try {
122
+ realCandidate = realpathSync(candidate);
123
+ }
124
+ catch {
125
+ // Not a real file. Nothing to read, so nothing to allow.
126
+ return false;
127
+ }
128
+ const rel = relative(realRoot, realCandidate);
129
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
130
+ }
131
+ /**
132
+ * The extension ALLOWLIST is the mechanism — there is deliberately no secret
133
+ * denylist beside it.
134
+ *
135
+ * A denylist was written here first and deleted. Everything it caught was
136
+ * already unreachable: `.env`, `id_rsa` and `credentials` have no extension,
137
+ * `.env.local` extends to `.local`, `server.pem` to `.pem` — none are in
138
+ * SOURCE_EXT, so all four were refused before the denylist was consulted. It
139
+ * bought nothing.
140
+ *
141
+ * What it did buy was a bug. `^\.?env(\..+)?$` also matches `env.js` and
142
+ * `env.ts`, and `src/env.ts` is a real, common source file (it is the standard
143
+ * name for typed environment schemas). A config error names that file more
144
+ * often than any other, and the denylist would have silently dropped it while
145
+ * looking like a security control.
146
+ *
147
+ * If a denylist is ever wanted back, it needs a case the allowlist does not
148
+ * already cover, and a test proving it.
149
+ */
150
+ export function isReadableSource(p) {
151
+ return SOURCE_EXT.has(extname(p).toLowerCase());
152
+ }
153
+ // ── Reading ──────────────────────────────────────────────────────────────────
154
+ /**
155
+ * Candidate absolute paths for one frame, best first.
156
+ *
157
+ * A trace may carry an absolute path (already correct), a path relative to the
158
+ * root, or a path relative to a subdirectory the process ran from. All three
159
+ * are tried; the first that exists and passes the root check wins.
160
+ */
161
+ function candidatesFor(frame, root, cwd) {
162
+ const p = frame.path.replace(/^file:\/\//, '');
163
+ if (isAbsolute(p))
164
+ return [p];
165
+ const out = [join(root, p), join(cwd, p)];
166
+ // `src/app/x.ts` printed from inside `src/` — strip one leading segment.
167
+ const stripped = p.split('/').slice(1).join('/');
168
+ if (stripped)
169
+ out.push(join(root, stripped));
170
+ return out;
171
+ }
172
+ function windowAround(text, line) {
173
+ if (line === null) {
174
+ const lines = text.split('\n');
175
+ if (lines.length <= WINDOW * 2 + 1)
176
+ return { text, windowed: false };
177
+ return { text: lines.slice(0, WINDOW * 2 + 1).join('\n'), windowed: true };
178
+ }
179
+ const lines = text.split('\n');
180
+ if (lines.length <= WINDOW * 2 + 1)
181
+ return { text, windowed: false };
182
+ const start = Math.max(0, line - 1 - WINDOW);
183
+ const end = Math.min(lines.length, line + WINDOW);
184
+ return { text: lines.slice(start, end).join('\n'), windowed: true };
185
+ }
186
+ function readOne(frame, root, cwd) {
187
+ for (const candidate of candidatesFor(frame, root, cwd)) {
188
+ if (!isReadableSource(candidate))
189
+ continue;
190
+ if (!isInsideRoot(candidate, root))
191
+ continue;
192
+ try {
193
+ const st = statSync(candidate);
194
+ if (!st.isFile())
195
+ continue;
196
+ const raw = st.size > MAX_BYTES
197
+ ? readFileSync(candidate, 'utf8').slice(0, MAX_BYTES)
198
+ : readFileSync(candidate, 'utf8');
199
+ const win = windowAround(raw, frame.line);
200
+ const real = realpathSync(candidate);
201
+ const rel = relative(realpathSync(root), real);
202
+ return { label: rel || basename(real), line: frame.line, text: win.text, windowed: win.windowed };
203
+ }
204
+ catch {
205
+ continue; // unreadable, binary, permissions — try the next candidate
206
+ }
207
+ }
208
+ return null;
209
+ }
210
+ /**
211
+ * Read the source behind an error. Returns null when there is nothing honest to
212
+ * send, which is a real answer: a wrong file is worse than no file, and the
213
+ * engine's own advice path handles the empty case.
214
+ *
215
+ * Never throws. A debug request that loses local context is worth far more than
216
+ * one that fails because a path was odd.
217
+ */
218
+ export function resolveSourceContext(opts) {
219
+ const cwd = opts.cwd ?? opts.root;
220
+ const maxFiles = opts.maxFiles ?? MAX_FILES;
221
+ try {
222
+ const frames = [];
223
+ // An explicit filePath from the agent outranks anything parsed out of text.
224
+ // Its line, if the trace names one for the same file, comes along.
225
+ if (opts.filePath) {
226
+ const fromText = extractFrames(opts.errorText)
227
+ .find((f) => f.path.endsWith(basename(opts.filePath)));
228
+ frames.push({ path: opts.filePath, line: fromText?.line ?? null });
229
+ }
230
+ frames.push(...extractFrames(opts.errorText));
231
+ const files = [];
232
+ const takenLabels = new Set();
233
+ for (const frame of frames) {
234
+ if (files.length >= maxFiles)
235
+ break;
236
+ const got = readOne(frame, opts.root, cwd);
237
+ if (got && !takenLabels.has(got.label)) {
238
+ takenLabels.add(got.label);
239
+ files.push(got);
240
+ }
241
+ }
242
+ if (files.length === 0)
243
+ return null;
244
+ return { snippet: formatSnippet(files), files };
245
+ }
246
+ catch {
247
+ return null;
248
+ }
249
+ }
250
+ /**
251
+ * One block per file, labelled with the path and the failing line.
252
+ *
253
+ * The label matters as much as the code: the engine's v2 contract derives
254
+ * machine-applicable edits from what it was sent, and an edit is only
255
+ * applicable if the file it names is the file the agent can open.
256
+ */
257
+ export function formatSnippet(files) {
258
+ return files
259
+ .map((f) => {
260
+ const where = f.line !== null ? `${f.label}:${f.line}` : f.label;
261
+ const note = f.windowed
262
+ ? ` (lines around ${f.line ?? 1}; file truncated for context budget)`
263
+ : '';
264
+ return `--- ${where}${note} ---\n${f.text}`;
265
+ })
266
+ .join('\n\n');
267
+ }
@@ -2,6 +2,8 @@ import { z } from 'zod';
2
2
  import { callDebugBackend } from '../backend.js';
3
3
  import { mapBackendErrorToToolResult } from '../errors.js';
4
4
  import { resolveAuth } from './authGate.js';
5
+ import { getProjectId, getProjectRoot } from '../project.js';
6
+ import { resolveSourceContext } from '../sourceContext.js';
5
7
  // Tri-state verification labeling (docs/plan-v2-contract-phase1.md §1).
6
8
  // The null case is rendered ON PURPOSE: a confidence number nothing checked
7
9
  // must never look the same as one that was mechanically verified.
@@ -45,6 +47,8 @@ export function registerDebugError(server, config) {
45
47
  '"debug this stack trace", "fix this exception", "analyze this traceback", or shows a ' +
46
48
  'Traceback / TypeError / ReferenceError / AttributeError. ' +
47
49
  'Works for Python, JavaScript, TypeScript, Go, Rust. ' +
50
+ 'Pass the raw error text — this server runs locally and reads the source files the ' +
51
+ 'stack trace names, so you do not need to locate and paste the code first. ' +
48
52
  'Returns root cause explanation plus up to 3 ranked fixes with machine-applicable code edits. ' +
49
53
  'After applying a fix, report whether it worked via the report_outcome tool.',
50
54
  inputSchema: {
@@ -59,7 +63,9 @@ export function registerDebugError(server, config) {
59
63
  codeSnippet: z
60
64
  .string()
61
65
  .optional()
62
- .describe('Surrounding code lines near where the error was thrown, if available.'),
66
+ .describe('Optional. Surrounding code near where the error was thrown. Usually unnecessary — ' +
67
+ 'the server reads the files the trace names off local disk. Supply it only when the ' +
68
+ 'relevant code is somewhere the trace does not name; it overrides the local read.'),
63
69
  filePath: z
64
70
  .string()
65
71
  .optional()
@@ -73,12 +79,34 @@ export function registerDebugError(server, config) {
73
79
  const gate = await resolveAuth(config);
74
80
  if (!gate.ok)
75
81
  return gate.result;
82
+ // Local source resolution. The agent's own snippet always wins — it knows
83
+ // what it was looking at. This only fills the gap, and the gap was the
84
+ // normal case: an agent that pastes a traceback and nothing else used to
85
+ // get "Insufficient context for specific fix" at 20% confidence, because
86
+ // the engine's only other source of code is a pgvector index that nothing
87
+ // outside the VS Code extension ever populates. See ../sourceContext.ts.
88
+ let resolved = null;
89
+ if (!codeSnippet) {
90
+ resolved = resolveSourceContext({
91
+ errorText,
92
+ filePath,
93
+ root: getProjectRoot(),
94
+ });
95
+ }
96
+ const snippet = codeSnippet ?? resolved?.snippet;
97
+ const effectiveFilePath = filePath ?? resolved?.files[0]?.label;
76
98
  try {
77
99
  const result = await callDebugBackend({
78
100
  error_message: errorText,
79
101
  language: language !== 'auto' ? language : undefined,
80
- code_snippet: codeSnippet,
81
- file_path: filePath,
102
+ code_snippet: snippet,
103
+ file_path: effectiveFilePath,
104
+ // Project scope. Team Error Memory is project-scoped in the engine:
105
+ // without this, recurrence is never recalled and never recorded, and
106
+ // the confirmed-fix promotion in feedback.ts cannot match. Derived
107
+ // from the git repo root so it agrees with the VS Code extension's
108
+ // md5(workspaceFolder) for the same checkout. See ../project.ts.
109
+ project_id: getProjectId() ?? undefined,
82
110
  // framework_hint deliberately omitted: a language ('python') is not a
83
111
  // framework ('fastapi'), and sending it bypasses the engine's
84
112
  // framework detection — FastAPI/React errors lose their expert hints.
@@ -90,7 +118,70 @@ export function registerDebugError(server, config) {
90
118
  sections.push(formatFix(fix, i + 1));
91
119
  });
92
120
  }
121
+ // Memory first, above the badges: "this project has hit this exact
122
+ // error before, and a fix was confirmed" is the one thing here the
123
+ // agent cannot derive from the code in front of it, so it should not
124
+ // be buried in a metadata footer.
125
+ if (result.memory_hit) {
126
+ const seen = typeof result.memory_times_seen === 'number'
127
+ ? `${result.memory_times_seen}x before in this project`
128
+ : 'before in this project';
129
+ // Four states, not two. Until 2026-08-21 this said "a fix was
130
+ // confirmed working" on the strength of somebody clicking Apply,
131
+ // and a fix that had been tried and FAILED was indistinguishable
132
+ // from one nobody had ever tried — both fell into the else branch
133
+ // and read "no fix has been confirmed yet". An agent reading that
134
+ // will happily re-propose the fix that already did not work, which
135
+ // is the loop this whole change exists to break.
136
+ //
137
+ // memory_fix_state is what the engine sends now. When it is absent
138
+ // (older engine) the boolean still decides, exactly as before.
139
+ const state = typeof result.memory_fix_state === 'string'
140
+ ? result.memory_fix_state
141
+ : (result.memory_fix_confirmed ? 'confirmed' : 'none');
142
+ const verified = typeof result.memory_verified_count === 'number'
143
+ ? result.memory_verified_count
144
+ : 0;
145
+ let memoryBody;
146
+ if (state === 'confirmed' && verified > 0) {
147
+ memoryBody =
148
+ `This error has been seen ${seen}. A fix for it was VERIFIED BY ` +
149
+ `OBSERVATION ${verified}x: a live session watched the error stop ` +
150
+ `after that fix was applied. It led the analysis above.`;
151
+ }
152
+ else if (state === 'confirmed') {
153
+ memoryBody =
154
+ `This error has been seen ${seen}. A fix for it was applied and ` +
155
+ `accepted by a developer, though nothing has observed it working. ` +
156
+ `It led the analysis above. Treat it as a strong lead, not proof.`;
157
+ }
158
+ else if (state === 'unproven') {
159
+ memoryBody =
160
+ `This error has been seen ${seen}. A fix was applied for it and ` +
161
+ `THE ERROR HAS RECURRED ONCE SINCE, so that fix is unproven. The ` +
162
+ `analysis above was told not to lead with it.`;
163
+ }
164
+ else if (state === 'anti_pattern') {
165
+ memoryBody =
166
+ `This error has been seen ${seen}. A fix was applied for it and ` +
167
+ `THE ERROR KEPT HAPPENING. That fix did not work and the analysis ` +
168
+ `above was told not to propose it again. If you are about to ` +
169
+ `suggest something equivalent, the cause is somewhere it does not ` +
170
+ `touch.`;
171
+ }
172
+ else {
173
+ memoryBody = `This error has been seen ${seen}. No fix has been confirmed for it yet.`;
174
+ }
175
+ sections.push(`\n## Seen before\n${memoryBody}`);
176
+ }
93
177
  const badges = [];
178
+ // Named, not counted. "Read 2 files" is unverifiable; "read src/api.ts,
179
+ // src/db.ts" lets the agent notice DebugAI looked at the wrong thing —
180
+ // which is the failure mode local resolution introduces and the one
181
+ // worth making visible.
182
+ if (resolved) {
183
+ badges.push(`Read locally: ${resolved.files.map((f) => f.label).join(', ')}`);
184
+ }
94
185
  if (result.model_used) {
95
186
  badges.push(`Model: ${result.model_used}`);
96
187
  }
@@ -123,6 +214,16 @@ export function registerDebugError(server, config) {
123
214
  has_project_context: result.has_project_context ?? false,
124
215
  debug_log_id: result.debug_log_id ?? null,
125
216
  error_signature: result.error_signature ?? null,
217
+ // Advertised in the server instructions ("whether this exact error
218
+ // has been seen before in this project, and how often"), so they
219
+ // belong in the structured payload, not only in the prose.
220
+ memory_hit: result.memory_hit ?? false,
221
+ memory_times_seen: result.memory_times_seen ?? null,
222
+ memory_fix_confirmed: result.memory_fix_confirmed ?? false,
223
+ memory_fix_state: result.memory_fix_state ?? null,
224
+ // Which files this server read off disk, if any. Empty when the
225
+ // agent supplied its own snippet or nothing resolved.
226
+ local_files_read: resolved?.files.map((f) => f.label) ?? [],
126
227
  },
127
228
  };
128
229
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugai/mcp",
3
- "version": "2.1.1",
3
+ "version": "2.3.0",
4
4
  "mcpName": "io.github.1shizaan/debugai-mcp",
5
5
  "description": "DebugAI MCP server. One command sets it up in Claude Desktop, Claude Code, Cursor, Zed, Windsurf, Cline or any MCP client: browser sign-in, no key pasting, no config editing.",
6
6
  "license": "MIT",