@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.
@@ -0,0 +1,634 @@
1
+ /**
2
+ * What the editor can SEE about this workspace, sent as facts the engine reads.
3
+ *
4
+ * ## The hole this fills
5
+ *
6
+ * Every wrong answer in the 2026-09-10 production read needed one fact that is
7
+ * not in the traceback and never can be, because it is a property of the
8
+ * machine rather than of the error:
9
+ *
10
+ * No module named 'ip_connection_monitor' -> "pip install ip_connection_monitor"
11
+ * the fact: that name is a .py file sitting in their own project
12
+ *
13
+ * No module named 'numpy' x3, one user, three days -> "pip install numpy"
14
+ * the fact: numpy was installed the whole time, under another interpreter
15
+ *
16
+ * import Flask, jsonify, ... -> "pip install Flask"
17
+ * the fact: flask is declared in their own requirements.txt
18
+ *
19
+ * The engine is server-side and will never touch a user's disk. The extension
20
+ * is already ON that disk — and has been reading `package.json`,
21
+ * `requirements.txt` and `pyproject.toml` since v2.1, at
22
+ * `detectWorkspaceFramework()`, purely to guess a one-word framework label. It
23
+ * has been reading the right files for the smallest possible purpose.
24
+ *
25
+ * Nothing here executes anything. Every field is a file read or a read-only
26
+ * VS Code API call, which is what an editor extension already is, so there is
27
+ * no new permission, no consent prompt and no new execution path. Running a
28
+ * command is a separate decision with a separate threat model (see the note on
29
+ * Tier 2 at the bottom) and deliberately not this module.
30
+ *
31
+ * ## The one law: observations, never classifications
32
+ *
33
+ * This module reports what is on disk. It never concludes what it means.
34
+ * "`ip_connection_monitor` is one of your files" and "install it" are both
35
+ * decisions, and decisions live in the engine — beside the error pattern that
36
+ * raised the question, in one place, where they already are.
37
+ *
38
+ * That is not style. It is the same law as Critical Rule 10 (the waitlist never
39
+ * trusts a client-computed privilege field) and the attribution rule (the client
40
+ * reports src and referrer, the SERVER computes `medium`). Applied here it also
41
+ * buys something concrete: the name normalisation that decides whether
42
+ * `python-dotenv` matches `python_dotenv` exists in exactly one file, so there
43
+ * is no cross-boundary copy to drift and no parity test to keep (Rule 15b).
44
+ *
45
+ * ## The other law: a capped list cannot prove an absence
46
+ *
47
+ * `local_modules` and `declared_deps` are capped, because a monorepo has
48
+ * thousands of files and this rides on a request a user is waiting for. A cap
49
+ * is the difference between "numpy is not one of your files" and "numpy is not
50
+ * in the first 600 of your files", and answering the first when you only know
51
+ * the second is how a confident wrong answer gets built.
52
+ *
53
+ * So every list that might be short names itself in `incomplete`, and the
54
+ * engine may draw a POSITIVE conclusion from any list (the name is there, so it
55
+ * exists) but a NEGATIVE one only from a list that is not listed there. Same
56
+ * judgement as MIN_SAMPLE in scanFindings and MIN_COHORT in attribution: an
57
+ * instrument that cannot say "I do not know" will say something false instead.
58
+ *
59
+ * Pure: no `vscode` import, no I/O. The caller supplies the file contents and
60
+ * the directory listings, which is what makes the whole thing testable with no
61
+ * editor — same split as `scanCandidates.ts` and `errorPaths.ts`.
62
+ *
63
+ * ## MIRRORED, byte for byte, in two other places
64
+ *
65
+ * packages/mcp/src/workspaceFacts.ts (published to npm, standalone)
66
+ * apps/extension/src/mcp/workspaceFacts.ts (bundled MCP server)
67
+ *
68
+ * This file has ZERO imports, which is what makes a byte-identical copy the
69
+ * whole of the solution rather than the start of a divergence. `npm publish`
70
+ * cannot reach into `apps/`, so the copies are not optional (Rule 15b).
71
+ *
72
+ * Do not edit a copy. Edit THIS file and re-copy:
73
+ *
74
+ * cp apps/extension/src/workspaceFacts.ts packages/mcp/src/workspaceFacts.ts
75
+ * cp apps/extension/src/workspaceFacts.ts apps/extension/src/mcp/workspaceFacts.ts
76
+ *
77
+ * `mcp/drift.test.ts` compares all three as text and fails on any difference,
78
+ * headers included - deliberately stricter than the other mirrored modules,
79
+ * because this one has no import line to anchor a partial comparison to.
80
+ */
81
+ /**
82
+ * Caps. Chosen so an ordinary project is never truncated and a monorepo is
83
+ * truncated honestly rather than slowly. `findFiles` is given `cap + 1` so the
84
+ * overflow is detected exactly rather than guessed at the boundary.
85
+ */
86
+ export const MAX_DECLARED_DEPS = 400;
87
+ /** Versions are only kept for names that survived the dep cap. */
88
+ export const MAX_VERSION_LENGTH = 60;
89
+ export const MAX_LOCAL_MODULES = 600;
90
+ /** Directory names at the root that mean "a Python environment lives here". */
91
+ const VENV_DIR_NAMES = ['.venv', 'venv', 'env', '.virtualenv'];
92
+ /**
93
+ * Everything a dependency name cannot be. Guards against a manifest that parses
94
+ * into rubbish rather than failing: a 4KB line in `requirements.txt` is not a
95
+ * package name, and shipping it would waste the budget it was meant to save.
96
+ */
97
+ const MAX_DEP_NAME_LENGTH = 100;
98
+ function usableName(name) {
99
+ const n = name.trim();
100
+ return n.length > 0 && n.length <= MAX_DEP_NAME_LENGTH;
101
+ }
102
+ /**
103
+ * A version string worth reporting.
104
+ *
105
+ * Deliberately permissive about FORM - `2.3.0`, `^18.2.0`, `>=3,<4`, `1.0.0-rc.2`
106
+ * and `*` are all real and all informative. It rejects only what cannot be a
107
+ * version: empty, absurdly long, or carrying a newline (which would mean the
108
+ * parse went wrong rather than that the version is unusual).
109
+ *
110
+ * A workspace path is never a version, and `file:../local-pkg` is how npm
111
+ * spells a local dependency - reported as-is, because "this dependency is a
112
+ * path on their disk, not a registry package" is exactly the kind of fact the
113
+ * engine cannot otherwise know.
114
+ */
115
+ function usableVersion(v) {
116
+ if (typeof v !== 'string') {
117
+ return false;
118
+ }
119
+ const t = v.trim();
120
+ return t.length > 0 && t.length <= MAX_VERSION_LENGTH && !/[\r\n]/.test(t);
121
+ }
122
+ /** First write wins, so a later manifest cannot silently overwrite an earlier one. */
123
+ function putVersion(into, name, version) {
124
+ const n = (name || '').trim();
125
+ if (!usableName(n) || n in into) {
126
+ return;
127
+ }
128
+ if (!usableVersion(version)) {
129
+ return;
130
+ }
131
+ into[n] = version.trim();
132
+ }
133
+ /**
134
+ * Merge one manifest's versions into the accumulator, first-write-wins.
135
+ *
136
+ * NOT `Object.assign`, which overwrites and made the last manifest read win -
137
+ * the exact opposite of the documented contract, and caught by the test that
138
+ * declares the same name in a package.json and a .csproj. `putVersion` already
139
+ * refuses to overwrite; the merge has to go through it to inherit that.
140
+ */
141
+ function mergeVersions(into, from) {
142
+ for (const [name, version] of Object.entries(from)) {
143
+ putVersion(into, name, version);
144
+ }
145
+ }
146
+ /**
147
+ * Dependency names declared in a `package.json`, across all four dependency
148
+ * fields.
149
+ *
150
+ * `peerDependencies` and `optionalDependencies` are included on purpose: a
151
+ * `Cannot find module` for a peer dep is a real and common shape (the package
152
+ * is declared, expects you to install it, and nothing did), and leaving them
153
+ * out would make the engine conclude "not declared" about a name that is.
154
+ *
155
+ * Never throws. A malformed `package.json` is itself a plausible thing to be
156
+ * debugging, and losing the whole fact set to it would be absurd.
157
+ */
158
+ export function depsFromPackageJson(text) {
159
+ let pkg;
160
+ try {
161
+ pkg = JSON.parse(text);
162
+ }
163
+ catch {
164
+ return [];
165
+ }
166
+ if (!pkg || typeof pkg !== 'object') {
167
+ return [];
168
+ }
169
+ const out = [];
170
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
171
+ const block = pkg[field];
172
+ if (block && typeof block === 'object' && !Array.isArray(block)) {
173
+ for (const name of Object.keys(block)) {
174
+ if (usableName(name)) {
175
+ out.push(name.trim());
176
+ }
177
+ }
178
+ }
179
+ }
180
+ return out;
181
+ }
182
+ /**
183
+ * Package names from a `requirements.txt`.
184
+ *
185
+ * A requirements line carries far more than a name — version specifiers,
186
+ * extras, environment markers, hashes, editable installs, includes, direct
187
+ * URLs — and every one of those forms appears in real projects. The name is
188
+ * whatever survives stripping them, and a line with no name left (a bare `-r
189
+ * base.txt`, a comment, a flag) contributes nothing rather than contributing
190
+ * garbage.
191
+ *
192
+ * `-r other.txt` is NOT followed. Following it would need another file read
193
+ * driven by content we just parsed, and the honest alternative is cheaper: the
194
+ * caller marks `declared_deps` incomplete, so a name missing from the list
195
+ * proves nothing, which is exactly the truth about a split requirements file.
196
+ */
197
+ export function depsFromRequirements(text) {
198
+ const deps = [];
199
+ let sawInclude = false;
200
+ for (const rawLine of (text || '').split(/\r?\n/)) {
201
+ // A `#` starts a comment anywhere it is not inside a URL fragment. The
202
+ // fragment case matters: `...#egg=name` is how an editable VCS install
203
+ // names its package, and cutting there loses the only name on the line.
204
+ let line = rawLine.trim();
205
+ if (!line || line.startsWith('#')) {
206
+ continue;
207
+ }
208
+ // `-e .`, `--editable`, `-r base.txt`, `--index-url ...`, `--hash=...`
209
+ if (line.startsWith('-')) {
210
+ if (/^(-r|--requirement|-c|--constraint)\b/.test(line)) {
211
+ sawInclude = true;
212
+ continue;
213
+ }
214
+ if (!/^(-e|--editable)\b/.test(line)) {
215
+ continue;
216
+ }
217
+ line = line.replace(/^(-e|--editable)\s+/, '');
218
+ }
219
+ // `git+https://host/x@ref#egg=NAME` — the egg fragment IS the name.
220
+ const egg = line.match(/[#&]egg=([A-Za-z0-9._-]+)/);
221
+ if (egg) {
222
+ if (usableName(egg[1])) {
223
+ deps.push(egg[1]);
224
+ }
225
+ continue;
226
+ }
227
+ // A bare path or URL with no egg fragment names nothing we can use.
228
+ if (/^[./~]/.test(line) || /^[a-z][a-z0-9+.-]*:\/\//i.test(line)) {
229
+ continue;
230
+ }
231
+ // Strip the trailing comment, then everything that is not the name:
232
+ // extras `[a,b]`, markers `; python_version < "3.9"`, specifiers, and
233
+ // a direct reference `name @ url`.
234
+ line = line.split(/\s+#/)[0];
235
+ line = line.split(';')[0];
236
+ line = line.split('@')[0];
237
+ const name = line.split(/[[<>=!~ ,(]/)[0].trim();
238
+ if (usableName(name) && /^[A-Za-z0-9._-]+$/.test(name)) {
239
+ deps.push(name);
240
+ }
241
+ }
242
+ return { deps, sawInclude };
243
+ }
244
+ /**
245
+ * Package names from a `pyproject.toml`, without a TOML parser.
246
+ *
247
+ * Bundling one would be the honest engineering answer if the names had to be
248
+ * exact. They do not: a name that survives this is real (a positive conclusion,
249
+ * always safe), and a name this misses is covered by the caller marking the
250
+ * list incomplete (so no negative conclusion is drawn from it). That trade buys
251
+ * out a dependency in a bundle that ships to 652 installs.
252
+ *
253
+ * Two shapes, which between them cover almost every real file:
254
+ *
255
+ * [project] PEP 621, the modern default
256
+ * dependencies = ["flask>=3", "python-dotenv"]
257
+ *
258
+ * [tool.poetry.dependencies] poetry, still very common
259
+ * flask = "^3.0"
260
+ *
261
+ * `found` is false when the file exists but neither shape was recognised —
262
+ * pdm, hatch's dynamic metadata, a dependency-groups-only file. The caller
263
+ * needs that to be a different state from "no pyproject at all", because one
264
+ * means "we could not read the declarations" and the other means "there are
265
+ * none to read".
266
+ */
267
+ export function depsFromPyproject(text) {
268
+ const src = text || '';
269
+ const deps = [];
270
+ let found = false;
271
+ // [project] dependencies = [...] and [project.optional-dependencies] tables.
272
+ // Matched as an array literal rather than by section, because the same
273
+ // array shape is what both spell and a section walk would need a parser.
274
+ const arrayRe = /(?:^|\n)\s*(?:dependencies|[A-Za-z0-9_-]+)\s*=\s*\[([^\]]*)\]/g;
275
+ let m;
276
+ while ((m = arrayRe.exec(src)) !== null) {
277
+ const body = m[1];
278
+ const entryRe = /["']\s*([A-Za-z0-9][A-Za-z0-9._-]*)/g;
279
+ let e;
280
+ while ((e = entryRe.exec(body)) !== null) {
281
+ if (usableName(e[1])) {
282
+ deps.push(e[1]);
283
+ found = true;
284
+ }
285
+ }
286
+ }
287
+ // [tool.poetry.dependencies] / [tool.poetry.group.*.dependencies]
288
+ const poetryRe = /\[tool\.poetry(?:\.group\.[A-Za-z0-9_-]+)?\.dependencies\]([\s\S]*?)(?=\n\s*\[|$)/g;
289
+ while ((m = poetryRe.exec(src)) !== null) {
290
+ found = true;
291
+ for (const line of m[1].split(/\r?\n/)) {
292
+ const key = line.trim().split('=')[0].trim().replace(/^["']|["']$/g, '');
293
+ // `python = "^3.11"` is the interpreter constraint, not a dependency.
294
+ if (key && key.toLowerCase() !== 'python' && usableName(key) && /^[A-Za-z0-9._-]+$/.test(key)) {
295
+ deps.push(key);
296
+ }
297
+ }
298
+ }
299
+ return { deps, found };
300
+ }
301
+ /**
302
+ * Versions from a `package.json`, keyed by dependency name.
303
+ *
304
+ * A SEPARATE pass rather than a change to `depsFromPackageJson`, on purpose.
305
+ * That function's contract and its tests are shipped and correct; widening a
306
+ * return type to carry a new field is how a working parser acquires a
307
+ * regression. These files are small and already capped, so the second pass
308
+ * costs nothing measurable.
309
+ */
310
+ export function versionsFromPackageJson(text) {
311
+ const out = {};
312
+ let pkg;
313
+ try {
314
+ pkg = JSON.parse(text);
315
+ }
316
+ catch {
317
+ return out;
318
+ }
319
+ if (!pkg || typeof pkg !== 'object') {
320
+ return out;
321
+ }
322
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
323
+ const block = pkg[field];
324
+ if (block && typeof block === 'object' && !Array.isArray(block)) {
325
+ for (const [name, version] of Object.entries(block)) {
326
+ putVersion(out, name, version);
327
+ }
328
+ }
329
+ }
330
+ return out;
331
+ }
332
+ /**
333
+ * Versions from a `requirements.txt`, keyed by package name.
334
+ *
335
+ * Keeps the whole specifier as written - `==3.0.0`, `>=2,<3`, `~=1.4` - because
336
+ * `==` is a fact about what IS installed and `>=` is a fact about what is
337
+ * allowed, and collapsing them would throw away the distinction the engine
338
+ * needs most.
339
+ *
340
+ * A line with no specifier contributes a NAME (via `depsFromRequirements`) and
341
+ * no version, which is honest: `flask` on its own line pins nothing.
342
+ *
343
+ * Never reads an `--index-url`. Those carry credentials in real projects
344
+ * (`https://user:token@pypi.internal/simple`) and a package's version is never
345
+ * on that line anyway.
346
+ */
347
+ export function versionsFromRequirements(text) {
348
+ const out = {};
349
+ for (const rawLine of (text || '').split(/\r?\n/)) {
350
+ let line = rawLine.trim();
351
+ if (!line || line.startsWith('#') || line.startsWith('-')) {
352
+ continue;
353
+ }
354
+ if (/^[./~]/.test(line) || /^[a-z][a-z0-9+.-]*:\/\//i.test(line)) {
355
+ continue;
356
+ }
357
+ line = line.split(/\s+#/)[0];
358
+ line = line.split(';')[0];
359
+ if (line.includes('@')) {
360
+ continue;
361
+ } // `name @ url` pins a URL, not a version
362
+ const m = line.match(/^([A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?\s*(.+)$/);
363
+ if (!m) {
364
+ continue;
365
+ }
366
+ const spec = m[2].trim();
367
+ if (/^[=<>!~^]/.test(spec)) {
368
+ putVersion(out, m[1], spec);
369
+ }
370
+ }
371
+ return out;
372
+ }
373
+ /**
374
+ * Versions from a `pyproject.toml`, keyed by package name.
375
+ *
376
+ * Same two shapes `depsFromPyproject` recognises, and the same trade: a version
377
+ * this finds is real, and one it misses simply is not reported. `declared_deps`
378
+ * already carries `incomplete` for the negative case, and a missing VERSION is
379
+ * never read as "no version constraint" - only as "we did not see one".
380
+ */
381
+ export function versionsFromPyproject(text) {
382
+ const out = {};
383
+ const src = text || '';
384
+ // PEP 621: dependencies = ["flask>=3", "python-dotenv==1.0.0"]
385
+ const arrayRe = /(?:^|\n)\s*(?:dependencies|[A-Za-z0-9_-]+)\s*=\s*\[([^\]]*)\]/g;
386
+ let m;
387
+ while ((m = arrayRe.exec(src)) !== null) {
388
+ const entryRe = /["']\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*([^"']*)["']/g;
389
+ let e;
390
+ while ((e = entryRe.exec(m[1])) !== null) {
391
+ const spec = (e[2] || '').split(';')[0].trim();
392
+ if (/^[=<>!~^]/.test(spec)) {
393
+ putVersion(out, e[1], spec);
394
+ }
395
+ }
396
+ }
397
+ // poetry: flask = "^3.0" · flask = { version = "^3.0", extras = [...] }
398
+ const poetryRe = /\[tool\.poetry(?:\.group\.[A-Za-z0-9_-]+)?\.dependencies\]([\s\S]*?)(?=\n\s*\[|$)/g;
399
+ while ((m = poetryRe.exec(src)) !== null) {
400
+ for (const line of m[1].split(/\r?\n/)) {
401
+ const eq = line.indexOf('=');
402
+ if (eq < 0) {
403
+ continue;
404
+ }
405
+ const key = line.slice(0, eq).trim().replace(/^["']|["']$/g, '');
406
+ if (!key || key.toLowerCase() === 'python') {
407
+ continue;
408
+ }
409
+ const rest = line.slice(eq + 1).trim();
410
+ const inline = rest.match(/^\{[^}]*version\s*=\s*["']([^"']+)["']/);
411
+ const plain = rest.match(/^["']([^"']+)["']/);
412
+ putVersion(out, key, inline ? inline[1] : (plain ? plain[1] : undefined));
413
+ }
414
+ }
415
+ return out;
416
+ }
417
+ /**
418
+ * Package names and versions from a .NET project file (`.csproj` / `.fsproj`).
419
+ *
420
+ * The manifest that would have prevented 2026-09-12. Four consecutive answers
421
+ * guessed at `Elastic.Clients.Elasticsearch` constructor signatures and a fifth
422
+ * invented `.AddPassiveHealthCheckPolicies()` on YARP - while the user's own
423
+ * project file said `Version="2.3.0"` the entire time, and their error text
424
+ * even quoted it back to us.
425
+ *
426
+ * .NET pins are usually EXACT, which makes this the highest-value manifest in
427
+ * the set: `<PackageReference Include="X" Version="2.3.0" />` settles "does
428
+ * this method exist" in a way `^18.2.0` never can.
429
+ *
430
+ * Two spellings, both common and both emitted by `dotnet add package`:
431
+ *
432
+ * <PackageReference Include="X" Version="2.3.0" />
433
+ * <PackageReference Include="X"><Version>2.3.0</Version></PackageReference>
434
+ *
435
+ * Regex rather than an XML parser, for the same reason `depsFromPyproject`
436
+ * avoids a TOML parser: a name this finds is real, a name it misses is covered
437
+ * by `incomplete`, and neither justifies a dependency in a bundle that ships to
438
+ * every install. Attribute order is not assumed - `Version` before `Include`
439
+ * is legal and real.
440
+ */
441
+ export function depsFromCsproj(text) {
442
+ const deps = [];
443
+ const versions = {};
444
+ const src = text || '';
445
+ const refRe = /<PackageReference\b([^>]*?)(\/>|>([\s\S]*?)<\/PackageReference\s*>)/gi;
446
+ let m;
447
+ while ((m = refRe.exec(src)) !== null) {
448
+ const attrs = m[1] || '';
449
+ const body = m[3] || '';
450
+ const nameM = attrs.match(/\bInclude\s*=\s*"([^"]+)"/i)
451
+ || attrs.match(/\bUpdate\s*=\s*"([^"]+)"/i);
452
+ if (!nameM) {
453
+ continue;
454
+ }
455
+ const name = nameM[1].trim();
456
+ if (!usableName(name)) {
457
+ continue;
458
+ }
459
+ deps.push(name);
460
+ const attrV = attrs.match(/\bVersion\s*=\s*"([^"]+)"/i);
461
+ const bodyV = body.match(/<Version\s*>([^<]+)<\/Version\s*>/i);
462
+ const raw = attrV ? attrV[1] : (bodyV ? bodyV[1] : undefined);
463
+ // `$(SomeProperty)` is an unexpanded MSBuild variable, not a version.
464
+ // Reporting it would be reporting a string we cannot resolve as if it
465
+ // were a fact, which is the failure mode this whole module exists for.
466
+ if (raw && !/^\s*\$\(/.test(raw)) {
467
+ putVersion(versions, name, raw.trim());
468
+ }
469
+ }
470
+ return { deps, versions };
471
+ }
472
+ /**
473
+ * The top-level module name a Python file would be imported by, or null.
474
+ *
475
+ * `a/b/foo.py` -> `foo`, `a/b/foo/__init__.py` -> `foo`. A dunder file other
476
+ * than `__init__` (`__main__.py`, `__about__.py`) names no importable module
477
+ * and is dropped, so it cannot be mistaken for one.
478
+ */
479
+ export function moduleNameForPath(relPath) {
480
+ const parts = (relPath || '').split(/[\\/]/).filter(Boolean);
481
+ if (parts.length === 0) {
482
+ return null;
483
+ }
484
+ const file = parts[parts.length - 1];
485
+ if (!file.endsWith('.py')) {
486
+ return null;
487
+ }
488
+ const stem = file.slice(0, -3);
489
+ if (stem === '__init__') {
490
+ const dir = parts.length >= 2 ? parts[parts.length - 2] : '';
491
+ return dir && !dir.startsWith('.') ? dir : null;
492
+ }
493
+ if (stem.startsWith('__') || !stem) {
494
+ return null;
495
+ }
496
+ return stem;
497
+ }
498
+ function dedupeSorted(names) {
499
+ return Array.from(new Set(names)).sort();
500
+ }
501
+ /**
502
+ * Assemble the facts. Total function: any subset of inputs, no throw.
503
+ *
504
+ * Every list is deduped and sorted before capping, so what survives a cap is
505
+ * stable across requests rather than dependent on filesystem walk order — a
506
+ * cache key reads this, and a set that reshuffles per request would miss the
507
+ * cache every time on exactly the large workspaces where a miss costs most.
508
+ */
509
+ export function buildWorkspaceFacts(input) {
510
+ const incomplete = new Set();
511
+ const manifests = [];
512
+ const declared = [];
513
+ const versions = {};
514
+ if (typeof input.packageJson === 'string') {
515
+ manifests.push('package.json');
516
+ declared.push(...depsFromPackageJson(input.packageJson));
517
+ mergeVersions(versions, versionsFromPackageJson(input.packageJson));
518
+ }
519
+ if (typeof input.requirementsTxt === 'string') {
520
+ manifests.push('requirements.txt');
521
+ const { deps, sawInclude } = depsFromRequirements(input.requirementsTxt);
522
+ declared.push(...deps);
523
+ mergeVersions(versions, versionsFromRequirements(input.requirementsTxt));
524
+ // `-r base.txt` means the real list continues in a file we did not read.
525
+ if (sawInclude) {
526
+ incomplete.add('declared_deps');
527
+ }
528
+ }
529
+ if (typeof input.pyprojectToml === 'string') {
530
+ manifests.push('pyproject.toml');
531
+ const { deps, found } = depsFromPyproject(input.pyprojectToml);
532
+ declared.push(...deps);
533
+ mergeVersions(versions, versionsFromPyproject(input.pyprojectToml));
534
+ // The file is there and we could not find its declarations. Saying
535
+ // nothing here would let the engine read our silence as "declares
536
+ // nothing", which is the one reading that is certainly wrong.
537
+ if (!found) {
538
+ incomplete.add('declared_deps');
539
+ }
540
+ }
541
+ if (Array.isArray(input.csprojFiles) && input.csprojFiles.length > 0) {
542
+ manifests.push('*.csproj');
543
+ for (const text of input.csprojFiles) {
544
+ if (typeof text !== 'string') {
545
+ continue;
546
+ }
547
+ const { deps, versions: v } = depsFromCsproj(text);
548
+ declared.push(...deps);
549
+ mergeVersions(versions, v);
550
+ }
551
+ // A solution whose project files we stopped enumerating declares more
552
+ // than we can see, and a name missing from a short list proves nothing.
553
+ if (input.csprojTruncated) {
554
+ incomplete.add('declared_deps');
555
+ }
556
+ }
557
+ const declaredSorted = dedupeSorted(declared);
558
+ if (declaredSorted.length > MAX_DECLARED_DEPS) {
559
+ incomplete.add('declared_deps');
560
+ }
561
+ const modules = dedupeSorted((input.pythonFiles || [])
562
+ .map(moduleNameForPath)
563
+ .filter((n) => n !== null));
564
+ if (input.pythonFilesTruncated || modules.length > MAX_LOCAL_MODULES) {
565
+ incomplete.add('local_modules');
566
+ }
567
+ const rootDirs = input.rootDirs || [];
568
+ const interpreter = typeof input.pythonInterpreter === 'string' && input.pythonInterpreter.trim()
569
+ ? input.pythonInterpreter.trim().slice(0, 500)
570
+ : null;
571
+ const keptDeps = declaredSorted.slice(0, MAX_DECLARED_DEPS);
572
+ // Versions only for names that survived the cap. A version for a name the
573
+ // engine cannot see is payload with no reader, and it would put the two
574
+ // lists out of step in a way every consumer would have to re-check.
575
+ const keptVersions = {};
576
+ for (const name of keptDeps) {
577
+ if (name in versions) {
578
+ keptVersions[name] = versions[name];
579
+ }
580
+ }
581
+ return {
582
+ declared_deps: keptDeps,
583
+ declared_versions: keptVersions,
584
+ manifests,
585
+ local_modules: modules.slice(0, MAX_LOCAL_MODULES),
586
+ venv_dirs: rootDirs.filter(d => VENV_DIR_NAMES.includes(d)),
587
+ has_node_modules: rootDirs.includes('node_modules'),
588
+ python_interpreter: interpreter,
589
+ incomplete: Array.from(incomplete).sort(),
590
+ };
591
+ }
592
+ /**
593
+ * True when these facts carry nothing the engine could act on.
594
+ *
595
+ * An empty fact set is not neutral: it costs bytes on every request, and — far
596
+ * worse — it takes part in the response cache key, so sending an all-empty
597
+ * object from a window with no folder open would split the cache between
598
+ * "no facts" and "no facts, spelled out" for no gain. Send nothing instead.
599
+ */
600
+ export function factsAreEmpty(f) {
601
+ // `declared_versions` is deliberately absent from this check: a version is
602
+ // only ever kept for a name in `declared_deps`, so a fact set with versions
603
+ // and no deps cannot be built. Adding the clause would be adding a branch
604
+ // no input can reach.
605
+ return (f.declared_deps.length === 0 &&
606
+ f.manifests.length === 0 &&
607
+ f.local_modules.length === 0 &&
608
+ f.venv_dirs.length === 0 &&
609
+ !f.has_node_modules &&
610
+ f.python_interpreter === null);
611
+ }
612
+ /**
613
+ * NOT IN THIS MODULE, on purpose: anything that runs.
614
+ *
615
+ * `python -c "import sys; print(sys.executable)"` settles the wrong-interpreter
616
+ * case outright, and `python_interpreter` above only reports what VS Code has
617
+ * SELECTED — which is the right answer for a Run button and not necessarily the
618
+ * interpreter that produced a traceback pasted from an external terminal.
619
+ *
620
+ * Closing that gap means executing something, and the 2026 record of agentic
621
+ * tools is unambiguous about how that goes wrong: every published incident is a
622
+ * variant of one mistake, treating a command STRING as data. Aider ran a
623
+ * repo-supplied `test-cmd` at startup (#5254). Claude Code's allowlist admitted
624
+ * `man --html="touch ..."` (CVE-2025-66032). Cursor's approval dialog showed a
625
+ * workspace path while the write followed a symlink to `~/.ssh/authorized_keys`
626
+ * (CVE-2026-50549).
627
+ *
628
+ * So when Tier 2 lands, the server names a PROBE and its parameters from a
629
+ * catalog this client owns — `{probe: "interpreter"}`, never a command string —
630
+ * the client maps that to argv it wrote itself, and an unknown probe name is
631
+ * refused rather than passed through. That is the difference between MCP
632
+ * elicitation and every incident above, and it is a different module with a
633
+ * consent gate, not a quiet extension of this one.
634
+ */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@debugai/mcp",
3
- "version": "2.4.3",
3
+ "version": "2.5.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",