@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 +7 -0
- package/dist/backend.d.ts +11 -0
- package/dist/gatherWorkspaceFacts.d.ts +64 -0
- package/dist/gatherWorkspaceFacts.js +369 -0
- package/dist/tools/debugError.js +12 -0
- package/dist/workspaceFacts.d.ts +336 -0
- package/dist/workspaceFacts.js +634 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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",
|