@debugai/mcp 2.4.3 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -233,6 +233,13 @@ Recent server-side work, all of it live for agents already:
233
233
  - pytest, unittest, jest, vitest and mocha output yields real file paths, so a
234
234
  failing test gets cross-file context instead of none.
235
235
 
236
+ **2.5.0** - The server now reads what your project declares and sends it with the
237
+ error: dependency names and the versions your own manifests pin, including
238
+ `.csproj` for .NET and nested manifests in a monorepo. The engine could never see
239
+ your disk, so it used to guess at third-party APIs - it now knows when a version
240
+ is pinned exactly, and says it does not know when nothing pins it. `.env` is
241
+ never opened; only its existence is reported.
242
+
236
243
  **2.4.2**: the bundled server prints the memory it advertises. This package and
237
244
  the copy bundled in the VS Code extension are the same server, and only this one
238
245
  was rendering the recurrence count and confirmed fix that both of them promise an
package/dist/backend.d.ts CHANGED
@@ -6,6 +6,17 @@ export interface DebugRequest {
6
6
  file_path?: string;
7
7
  project_id?: string;
8
8
  framework_hint?: string;
9
+ /**
10
+ * What this repository declares, read off disk by the local server.
11
+ * Optional on purpose: an older gateway ignores an unknown field, and a newer
12
+ * engine treats its absence as "we do not know" rather than "there is none".
13
+ * See ../gatherWorkspaceFacts.ts.
14
+ *
15
+ * Typed as an opaque object rather than a named shape: this module's job is
16
+ * to forward JSON, and importing the fact type here would couple the
17
+ * transport to a contract that gains fields independently of it.
18
+ */
19
+ workspace_facts?: object;
9
20
  }
10
21
  export interface DebugEdit {
11
22
  file: string;
@@ -0,0 +1,64 @@
1
+ import { type WorkspaceFacts } from './workspaceFacts.js';
2
+ /**
3
+ * Opened by exact name only. `.env` is absent on purpose - see the header.
4
+ *
5
+ * Load bearing, and it took a mutation pass to make it so: the reads used to be
6
+ * driven by three hard-coded `joined('package.json')` calls, which meant adding
7
+ * `.env` to this Set changed no behaviour and turned no test red. A guard that
8
+ * cannot be broken by editing it is not a guard. Everything now iterates this
9
+ * list, and `drift.test.ts` pins its exact contents.
10
+ */
11
+ export declare const MANIFEST_FILES: readonly ["package.json", "requirements.txt", "pyproject.toml"];
12
+ /** A manifest larger than this is not a manifest we can use. */
13
+ export declare const MAX_MANIFEST_BYTES: number;
14
+ /** Directories visited. A monorepo must cost a bounded walk, not a full one. */
15
+ export declare const MAX_DIRS = 400;
16
+ /** How deep. Deep enough for `services/api/src`, shallow enough to stay cheap. */
17
+ export declare const MAX_DEPTH = 6;
18
+ /** Python files collected for `local_modules`. Matches the extension's cap. */
19
+ export declare const MAX_PY_FILES = 600;
20
+ /** .NET project files read. Six services is a normal solution; twelve is plenty. */
21
+ export declare const MAX_CSPROJ = 12;
22
+ /**
23
+ * Nested manifests read, beyond the root ones.
24
+ *
25
+ * A monorepo keeps its real dependencies in subdirectories - this repository's
26
+ * root `package.json` declares exactly one package while `apps/web` declares
27
+ * forty, and salman's react-dom error was about `web/package.json` in a tree
28
+ * whose root is a .NET solution. Reading only the root would have made the
29
+ * feature answer confidently about the wrong half of his project.
30
+ */
31
+ export declare const MAX_NESTED_MANIFESTS = 8;
32
+ /** Wall clock. A slow or network filesystem costs the facts, never the debug. */
33
+ export declare const DEADLINE_MS = 1500;
34
+ /**
35
+ * How long a gathered set stays good.
36
+ *
37
+ * The VS Code extension has cached these on the same TTL since 2026-09-10 (it
38
+ * shares the framework hint's window) and this copy shipped without one - an
39
+ * agent working through a failing build calls `debug_error` repeatedly, and
40
+ * every call was walking the tree again. Five minutes is also the CORRECTNESS
41
+ * bound, and it is the reason the TTL is short rather than long: install a
42
+ * package and the facts are stale, so the window has to be small enough that a
43
+ * wrong answer cannot outlive the mistake by much.
44
+ */
45
+ export declare const CACHE_TTL_MS: number;
46
+ /** Test seam. Never called in production. */
47
+ export declare function __resetWorkspaceFactsCache(): void;
48
+ /** What the gatherer adds on top of the shared pure contract. */
49
+ export interface GatheredFacts extends WorkspaceFacts {
50
+ /**
51
+ * A `.env` (or `.env.*`) sits at the project root.
52
+ *
53
+ * Recorded because two of the 76 fixes in the 2026-09-13 corpus turned on it
54
+ * - one workspace had only `.env.example`, another had a deployed value that
55
+ * differed from the local file - and because "there is one" is the whole of
56
+ * what can be safely known. The contents are never read.
57
+ */
58
+ has_env_file: boolean;
59
+ }
60
+ /**
61
+ * Gather what this repository declares. Returns null when there is nothing
62
+ * worth sending, when the root is unusable, or on any failure at all.
63
+ */
64
+ export declare function gatherWorkspaceFacts(root: string | null | undefined): GatheredFacts | null;
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Read this project's manifests off disk, so the engine stops guessing.
3
+ *
4
+ * ## The hole
5
+ *
6
+ * `workspaceFacts.ts` (pure, beside this file) has existed since 2026-09-10 and
7
+ * answers the question every wrong diagnosis needed: what is actually on this
8
+ * machine. It runs in the VS Code extension only. It has never run here.
9
+ *
10
+ * A read of 76 production `debug_feedback.actual_fix` rows on 2026-09-13 put
11
+ * numbers on that: the largest single class of wrong answer, 24%, needed a fact
12
+ * about the MACHINE rather than about the error - an interpreter, a declared
13
+ * dependency, an installed version. And 54 of the last 83 debugs arrived over
14
+ * MCP, where none of those facts are sent.
15
+ *
16
+ * The clearest case is one line of one file on 2026-09-12. Four consecutive
17
+ * answers guessed at `Elastic.Clients.Elasticsearch` constructor signatures and
18
+ * a fifth invented `.AddPassiveHealthCheckPolicies()` on YARP, at confidence 92,
19
+ * while the user's own `.csproj` said `Version="2.3.0"` the whole time - and
20
+ * this process was running inside that repository.
21
+ *
22
+ * ## Why the server reads them rather than asking the agent
23
+ *
24
+ * Same reason `sourceContext.ts` reads the files a trace names: this is a local
25
+ * process with the filesystem the engine lacks, and an agent that has to be
26
+ * asked for something will be asked differently by every client. Reading costs
27
+ * one bounded walk; asking costs a contract with every MCP host there is.
28
+ *
29
+ * ## What it refuses to do
30
+ *
31
+ * The security model here is an EXACT-NAME allowlist, and it has to be, because
32
+ * the extension allowlist that protects `sourceContext.ts` does not apply: a
33
+ * manifest has no source extension. Only the names in `MANIFEST_FILES` are ever
34
+ * opened, plus files matching `PROJECT_EXT` whose names vary by project.
35
+ *
36
+ * `.env` is deliberately NOT in that list, and never will be. It is the single
37
+ * most useful-looking file here and it holds secrets; two production rows had a
38
+ * live Stripe key and a Discord bot token pasted into an error box already, and
39
+ * we are not going to be the ones who send the next one. Its EXISTENCE is a
40
+ * fact worth having and costs nothing - `has_env_file` says so without reading
41
+ * a byte.
42
+ *
43
+ * Everything resolves inside the project root after symlinks, the walk is
44
+ * depth- and count-bounded, and every read is size-capped. A repository is
45
+ * untrusted input; it was cloned, not written.
46
+ *
47
+ * ## Never throws
48
+ *
49
+ * Returns `null` rather than raising, on every path. A debug that fails because
50
+ * the fact-gatherer tripped over a permissions error would be strictly worse
51
+ * than the guessing this replaces.
52
+ */
53
+ import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
54
+ import { join, resolve, extname, relative, sep } from 'node:path';
55
+ import { buildWorkspaceFacts } from './workspaceFacts.js';
56
+ /**
57
+ * Opened by exact name only. `.env` is absent on purpose - see the header.
58
+ *
59
+ * Load bearing, and it took a mutation pass to make it so: the reads used to be
60
+ * driven by three hard-coded `joined('package.json')` calls, which meant adding
61
+ * `.env` to this Set changed no behaviour and turned no test red. A guard that
62
+ * cannot be broken by editing it is not a guard. Everything now iterates this
63
+ * list, and `drift.test.ts` pins its exact contents.
64
+ */
65
+ export const MANIFEST_FILES = [
66
+ 'package.json',
67
+ 'requirements.txt',
68
+ 'pyproject.toml',
69
+ ];
70
+ function isManifestName(name) {
71
+ return MANIFEST_FILES.includes(name);
72
+ }
73
+ /** .NET project files. Names vary per project, so these go by extension. */
74
+ const PROJECT_EXT = new Set(['.csproj', '.fsproj', '.vbproj']);
75
+ /** Never descended into. Same list the chunker and sourceContext use. */
76
+ const SKIP_DIRS = new Set([
77
+ 'node_modules', '.git', '__pycache__', '.venv', 'venv', 'env', '.virtualenv',
78
+ 'dist', 'build', 'out', '.next', '.nuxt', 'coverage', 'target', 'bin', 'obj',
79
+ '.idea', '.vscode', '.cache', 'vendor', 'site-packages',
80
+ ]);
81
+ /** A manifest larger than this is not a manifest we can use. */
82
+ export const MAX_MANIFEST_BYTES = 512 * 1024;
83
+ /** Directories visited. A monorepo must cost a bounded walk, not a full one. */
84
+ export const MAX_DIRS = 400;
85
+ /** How deep. Deep enough for `services/api/src`, shallow enough to stay cheap. */
86
+ export const MAX_DEPTH = 6;
87
+ /** Python files collected for `local_modules`. Matches the extension's cap. */
88
+ export const MAX_PY_FILES = 600;
89
+ /** .NET project files read. Six services is a normal solution; twelve is plenty. */
90
+ export const MAX_CSPROJ = 12;
91
+ /**
92
+ * Nested manifests read, beyond the root ones.
93
+ *
94
+ * A monorepo keeps its real dependencies in subdirectories - this repository's
95
+ * root `package.json` declares exactly one package while `apps/web` declares
96
+ * forty, and salman's react-dom error was about `web/package.json` in a tree
97
+ * whose root is a .NET solution. Reading only the root would have made the
98
+ * feature answer confidently about the wrong half of his project.
99
+ */
100
+ export const MAX_NESTED_MANIFESTS = 8;
101
+ /** Wall clock. A slow or network filesystem costs the facts, never the debug. */
102
+ export const DEADLINE_MS = 1500;
103
+ /**
104
+ * How long a gathered set stays good.
105
+ *
106
+ * The VS Code extension has cached these on the same TTL since 2026-09-10 (it
107
+ * shares the framework hint's window) and this copy shipped without one - an
108
+ * agent working through a failing build calls `debug_error` repeatedly, and
109
+ * every call was walking the tree again. Five minutes is also the CORRECTNESS
110
+ * bound, and it is the reason the TTL is short rather than long: install a
111
+ * package and the facts are stale, so the window has to be small enough that a
112
+ * wrong answer cannot outlive the mistake by much.
113
+ */
114
+ export const CACHE_TTL_MS = 5 * 60 * 1000;
115
+ let cached = null;
116
+ /** Test seam. Never called in production. */
117
+ export function __resetWorkspaceFactsCache() {
118
+ cached = null;
119
+ }
120
+ function insideRoot(candidate, root) {
121
+ let realRoot;
122
+ let realCandidate;
123
+ try {
124
+ realRoot = realpathSync(root);
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ try {
130
+ realCandidate = realpathSync(candidate);
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ const rel = relative(realRoot, realCandidate);
136
+ return rel === '' || (!rel.startsWith('..') && !rel.startsWith(sep + '..'));
137
+ }
138
+ function readCapped(path, root) {
139
+ try {
140
+ if (!insideRoot(path, root)) {
141
+ return null;
142
+ }
143
+ const st = statSync(path);
144
+ if (!st.isFile() || st.size > MAX_MANIFEST_BYTES) {
145
+ return null;
146
+ }
147
+ return readFileSync(path, 'utf8');
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ }
153
+ /**
154
+ * One bounded pass over the tree.
155
+ *
156
+ * Breadth-first so that a shallow, relevant directory is always visited before
157
+ * a deep one - when the caps bite, what survives is the top of the project
158
+ * rather than whichever branch the walk happened to enter first.
159
+ */
160
+ function walk(root, deadline) {
161
+ const out = {
162
+ pythonFiles: [], pythonTruncated: false,
163
+ csprojPaths: [], csprojTruncated: false,
164
+ nested: [], nestedTruncated: false,
165
+ rootDirs: [], hasEnvFile: false,
166
+ };
167
+ const queue = [{ dir: root, depth: 0 }];
168
+ let visited = 0;
169
+ while (queue.length > 0) {
170
+ if (Date.now() > deadline) {
171
+ out.pythonTruncated = true;
172
+ out.csprojTruncated = true;
173
+ break;
174
+ }
175
+ if (visited >= MAX_DIRS) {
176
+ out.pythonTruncated = true;
177
+ out.csprojTruncated = true;
178
+ break;
179
+ }
180
+ const next = queue.shift();
181
+ if (!next) {
182
+ break;
183
+ }
184
+ const { dir, depth } = next;
185
+ visited += 1;
186
+ let entries;
187
+ try {
188
+ entries = readdirSync(dir, { withFileTypes: true })
189
+ .map(d => ({ name: d.name, isDir: d.isDirectory() }));
190
+ }
191
+ catch {
192
+ continue;
193
+ }
194
+ for (const e of entries) {
195
+ // A symlink reports `isDirectory() === false`, so a link to a directory
196
+ // is never descended into - which was accidental protection until a
197
+ // mutation pass showed the containment test passing for that reason
198
+ // rather than for the check it was written to exercise. A symlinked FILE
199
+ // does fall through to the file branch below, gets collected, and is
200
+ // stopped by `insideRoot` inside `readCapped`. Both halves are needed and
201
+ // both are now pinned by their own test.
202
+ if (e.isDir) {
203
+ if (depth === 0) {
204
+ out.rootDirs.push(e.name);
205
+ }
206
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith('.')) {
207
+ continue;
208
+ }
209
+ if (depth + 1 <= MAX_DEPTH) {
210
+ queue.push({ dir: join(dir, e.name), depth: depth + 1 });
211
+ }
212
+ continue;
213
+ }
214
+ if (depth === 0 && (e.name === '.env' || e.name.startsWith('.env.'))) {
215
+ // Recorded, never opened. See the header.
216
+ out.hasEnvFile = true;
217
+ }
218
+ if (depth > 0 && isManifestName(e.name)) {
219
+ if (out.nested.length < MAX_NESTED_MANIFESTS) {
220
+ out.nested.push({ name: e.name, path: join(dir, e.name) });
221
+ }
222
+ else {
223
+ out.nestedTruncated = true;
224
+ }
225
+ }
226
+ const ext = extname(e.name).toLowerCase();
227
+ if (ext === '.py') {
228
+ if (out.pythonFiles.length < MAX_PY_FILES) {
229
+ out.pythonFiles.push(relative(root, join(dir, e.name)));
230
+ }
231
+ else {
232
+ out.pythonTruncated = true;
233
+ }
234
+ }
235
+ else if (PROJECT_EXT.has(ext)) {
236
+ if (out.csprojPaths.length < MAX_CSPROJ) {
237
+ out.csprojPaths.push(join(dir, e.name));
238
+ }
239
+ else {
240
+ out.csprojTruncated = true;
241
+ }
242
+ }
243
+ }
244
+ }
245
+ return out;
246
+ }
247
+ /**
248
+ * Gather what this repository declares. Returns null when there is nothing
249
+ * worth sending, when the root is unusable, or on any failure at all.
250
+ */
251
+ export function gatherWorkspaceFacts(root) {
252
+ try {
253
+ if (!root || typeof root !== 'string') {
254
+ return null;
255
+ }
256
+ const abs = resolve(root);
257
+ // Keyed by root, not merely time: a single agent process can be pointed at
258
+ // more than one repository, and serving one project's manifests for another
259
+ // would be worse than sending nothing at all.
260
+ const now = Date.now();
261
+ if (cached && cached.root === abs && (now - cached.at) < CACHE_TTL_MS) {
262
+ return cached.facts;
263
+ }
264
+ try {
265
+ if (!statSync(abs).isDirectory()) {
266
+ return null;
267
+ }
268
+ }
269
+ catch {
270
+ // Deliberately NOT cached. A root that does not exist yet may exist on
271
+ // the next call (a clone finishing, a folder being opened), and caching
272
+ // the failure would hold the feature off for five minutes for no reason.
273
+ return null;
274
+ }
275
+ const deadline = Date.now() + DEADLINE_MS;
276
+ const walked = walk(abs, deadline);
277
+ const manifest = (name) => isManifestName(name) ? readCapped(join(abs, name), abs) : null;
278
+ // Nested manifests, concatenated per KIND into the same string the root one
279
+ // occupies. Concatenation is safe for every format here and keeps the pure
280
+ // builder's contract untouched: requirements.txt is line-oriented, and both
281
+ // JSON and TOML parsers in `workspaceFacts.ts` are regex-shaped rather than
282
+ // strict, by the design documented there. A package.json that fails to
283
+ // parse contributes nothing rather than breaking the set.
284
+ const extraByKind = {};
285
+ for (const n of walked.nested) {
286
+ if (Date.now() > deadline) {
287
+ walked.nestedTruncated = true;
288
+ break;
289
+ }
290
+ const text = readCapped(n.path, abs);
291
+ if (text === null) {
292
+ continue;
293
+ }
294
+ (extraByKind[n.name] ||= []).push(text);
295
+ }
296
+ const joined = (name) => {
297
+ const root = manifest(name);
298
+ const extra = extraByKind[name] || [];
299
+ if (root === null && extra.length === 0) {
300
+ return null;
301
+ }
302
+ if (name === 'package.json') {
303
+ // JSON cannot be concatenated. Merge the dependency blocks instead,
304
+ // which is the only part `depsFromPackageJson` and its version twin
305
+ // read. Anything unparseable is skipped, never thrown.
306
+ const merged = {};
307
+ for (const text of [root, ...extra]) {
308
+ if (text === null) {
309
+ continue;
310
+ }
311
+ let pkg;
312
+ try {
313
+ pkg = JSON.parse(text);
314
+ }
315
+ catch {
316
+ continue;
317
+ }
318
+ if (!pkg || typeof pkg !== 'object') {
319
+ continue;
320
+ }
321
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
322
+ const block = pkg[field];
323
+ if (!block || typeof block !== 'object' || Array.isArray(block)) {
324
+ continue;
325
+ }
326
+ merged[field] = { ...block, ...(merged[field] || {}) }; // first file wins
327
+ }
328
+ }
329
+ return JSON.stringify(merged);
330
+ }
331
+ return [root, ...extra].filter((t) => t !== null).join('\n');
332
+ };
333
+ const csprojFiles = [];
334
+ for (const p of walked.csprojPaths) {
335
+ if (Date.now() > deadline) {
336
+ walked.csprojTruncated = true;
337
+ break;
338
+ }
339
+ const text = readCapped(p, abs);
340
+ if (text !== null) {
341
+ csprojFiles.push(text);
342
+ }
343
+ }
344
+ const facts = buildWorkspaceFacts({
345
+ packageJson: joined('package.json'),
346
+ requirementsTxt: joined('requirements.txt'),
347
+ pyprojectToml: joined('pyproject.toml'),
348
+ csprojFiles,
349
+ // A manifest list we stopped enumerating declares more than we can see,
350
+ // and a name missing from a short list must prove nothing.
351
+ csprojTruncated: walked.csprojTruncated || walked.nestedTruncated,
352
+ pythonFiles: walked.pythonFiles,
353
+ pythonFilesTruncated: walked.pythonTruncated,
354
+ rootDirs: walked.rootDirs,
355
+ // Deliberately absent: `python_interpreter`. VS Code's Python extension
356
+ // reports which interpreter is SELECTED; this process has no equivalent
357
+ // and could only report its own `process.execPath`, which is node. A
358
+ // wrong fact is worse than a missing one - the engine treats absence as
359
+ // "we do not know" and says so, which is true here.
360
+ pythonInterpreter: null,
361
+ });
362
+ const out = { ...facts, has_env_file: walked.hasEnvFile };
363
+ cached = { root: abs, at: now, facts: out };
364
+ return out;
365
+ }
366
+ catch {
367
+ return null;
368
+ }
369
+ }
@@ -5,6 +5,7 @@ import { resolveAuth } from './authGate.js';
5
5
  import { getProjectId, getProjectRoot } from '../project.js';
6
6
  import { memorySection } from '../memorySection.js';
7
7
  import { resolveSourceContext } from '../sourceContext.js';
8
+ import { gatherWorkspaceFacts } from '../gatherWorkspaceFacts.js';
8
9
  // Tri-state verification labeling (docs/plan-v2-contract-phase1.md §1).
9
10
  // The null case is rendered ON PURPOSE: a confidence number nothing checked
10
11
  // must never look the same as one that was mechanically verified.
@@ -96,6 +97,16 @@ export function registerDebugError(server, config) {
96
97
  }
97
98
  const snippet = codeSnippet ?? resolved?.snippet;
98
99
  const effectiveFilePath = filePath ?? resolved?.files[0]?.label;
100
+ // What this repository declares - dependency names and the versions its
101
+ // own manifests pin. The engine cannot read a disk and this process is
102
+ // sitting on the one that matters.
103
+ //
104
+ // Unconditional, unlike the source resolution above: an agent's snippet
105
+ // can replace what WE would have read, but no agent sends a .csproj, and
106
+ // 24% of the wrong answers in the 2026-09-13 corpus needed exactly this.
107
+ // Bounded and total - see ../gatherWorkspaceFacts.ts - so the worst case
108
+ // is null and a prompt identical to today's.
109
+ const workspaceFacts = gatherWorkspaceFacts(getProjectRoot());
99
110
  try {
100
111
  const result = await callDebugBackend({
101
112
  error_message: errorText,
@@ -108,6 +119,7 @@ export function registerDebugError(server, config) {
108
119
  // from the git repo root so it agrees with the VS Code extension's
109
120
  // md5(workspaceFolder) for the same checkout. See ../project.ts.
110
121
  project_id: getProjectId() ?? undefined,
122
+ workspace_facts: workspaceFacts ?? undefined,
111
123
  // framework_hint deliberately omitted: a language ('python') is not a
112
124
  // framework ('fastapi'), and sending it bypasses the engine's
113
125
  // framework detection — FastAPI/React errors lose their expert hints.