sentinel-ir-mcp 0.3.0 → 0.4.1
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 +77 -19
- package/bin/sentinel-ir-mcp.js +11 -4
- package/package.json +2 -2
- package/src/analyze.js +91 -10
- package/src/clients.js +1 -1
- package/src/doctor.js +3 -3
- package/src/guide.js +5 -2
- package/src/server.js +14 -3
package/README.md
CHANGED
|
@@ -25,6 +25,44 @@ of safety. Other languages are not supported yet. What is detected in Python
|
|
|
25
25
|
(and what is not) is listed in the
|
|
26
26
|
[sentinel-ir README](https://gitlab.com/jakubrojicek11/sentinel-ir#python).
|
|
27
27
|
|
|
28
|
+
**0.4.1** uses `sentinel-ir` 0.4.3:
|
|
29
|
+
|
|
30
|
+
- **Faster on large JavaScript:** a valid 1 MiB file takes about 5 s instead
|
|
31
|
+
of about 170 s, so `analyze_directory` no longer runs past the 60 s request
|
|
32
|
+
timeout of MCP SDK clients on one big file.
|
|
33
|
+
- **Read-time check:** files are opened first and then checked, so a file
|
|
34
|
+
swapped for a symlink between listing and reading is refused instead of read
|
|
35
|
+
from outside the allowed folders. A file removed during a scan no longer
|
|
36
|
+
fails the whole scan; it is listed in `skipped.unreadable`. Files with more
|
|
37
|
+
than one hard link are refused (`skipped.hardlinks`).
|
|
38
|
+
- **Finite limits:** `--max-file-bytes` is capped at 2 MiB. Valid JavaScript
|
|
39
|
+
with `sentinel-ir` 0.4.3:
|
|
40
|
+
|
|
41
|
+
| size | Linux | Windows CI (Node 20/22/24) |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| 1 MiB | 4–5 s, 0.9 GB | 13–23 s, 0.8 GB |
|
|
44
|
+
| 2 MiB | 10–14 s, 1.6 GB | 29–40 s, 1.5 GB |
|
|
45
|
+
| 4 MiB | 25–29 s, 3 GB | process aborts |
|
|
46
|
+
|
|
47
|
+
A file above 2 MiB can take longer than the 60 s request timeout on its own.
|
|
48
|
+
- **Time budget:** `analyze_directory` starts no new file after 30 s
|
|
49
|
+
(`--time-budget-ms`). Then `timedOut` is `true` and the remaining files are
|
|
50
|
+
listed in `notAnalyzed`. A file already being analyzed is not interrupted.
|
|
51
|
+
- **Parser errors:** incomplete files carry `facts.parseError` with the line,
|
|
52
|
+
column and kind of the first parser error (from `sentinel-ir` 0.4.3).
|
|
53
|
+
|
|
54
|
+
**0.4.0** uses `sentinel-ir` 0.4.2:
|
|
55
|
+
|
|
56
|
+
- **Native code:** `ctypes` / `cffi`, `process.dlopen`, `*.node` addons and
|
|
57
|
+
FFI packages (`koffi`, `ffi-napi`, `bun:ffi`, ...) are `critical`, with
|
|
58
|
+
`native_code` in `forbidden` and `nativeCode` in `facts.operations`.
|
|
59
|
+
- **Long sessions:** older versions never freed the WebAssembly parser
|
|
60
|
+
memory. After roughly 87 MB of analyzed source in one server process every
|
|
61
|
+
later result became `"incomplete"` (text fallback) without an error. With
|
|
62
|
+
`sentinel-ir-mcp` 0.3.0 + `sentinel-ir` 0.3.1 over stdio, `analyze_code` on
|
|
63
|
+
an 18 KB file went `incomplete` from call 4,918 on; 0.4.0 stays `complete`
|
|
64
|
+
(measurement below).
|
|
65
|
+
|
|
28
66
|
## Tools
|
|
29
67
|
|
|
30
68
|
| tool | input | returns |
|
|
@@ -68,9 +106,9 @@ nothing to compile (the parser is WebAssembly).
|
|
|
68
106
|
Run it in your project folder:
|
|
69
107
|
|
|
70
108
|
```bash
|
|
71
|
-
npx -y sentinel-ir-mcp@0.
|
|
72
|
-
npx -y sentinel-ir-mcp@0.
|
|
73
|
-
npx -y sentinel-ir-mcp@0.
|
|
109
|
+
npx -y sentinel-ir-mcp@0.4 setup --client cursor # or windsurf, claude-desktop, vscode, claude-code
|
|
110
|
+
npx -y sentinel-ir-mcp@0.4 doctor --client cursor # after restarting the client
|
|
111
|
+
npx -y sentinel-ir-mcp@0.4 guide # optional: text for AGENTS.md / client rules
|
|
74
112
|
```
|
|
75
113
|
|
|
76
114
|
`setup` (without `--client` it asks which client):
|
|
@@ -84,7 +122,7 @@ npx -y sentinel-ir-mcp@0.3 guide # optional: text for AGENTS
|
|
|
84
122
|
touched;
|
|
85
123
|
- gives the agent one folder (`--project`, default: the current folder) and
|
|
86
124
|
refuses your home folder or a whole disk;
|
|
87
|
-
- writes `sentinel-ir-mcp@0.
|
|
125
|
+
- writes `sentinel-ir-mcp@0.4`, so clients get 0.4.x fixes but no unexpected
|
|
88
126
|
new features;
|
|
89
127
|
- for VS Code and Claude Code it writes nothing and prints their own install
|
|
90
128
|
command (`code --add-mcp ...`, `claude mcp add ...`) instead.
|
|
@@ -97,7 +135,7 @@ npx -y sentinel-ir-mcp@0.3 guide # optional: text for AGENTS
|
|
|
97
135
|
| `vscode`, `claude-code` | nothing; prints the command to run |
|
|
98
136
|
|
|
99
137
|
On Windows the entry is `"command": "cmd", "args": ["/c", "npx", "-y",
|
|
100
|
-
"sentinel-ir-mcp@0.
|
|
138
|
+
"sentinel-ir-mcp@0.4", "<folder>"]`: there `npx` is `npx.cmd`, and starting it
|
|
101
139
|
without a shell failed with `ENOENT` on a real Windows runner. For VS Code and
|
|
102
140
|
Claude Code, `setup` prints a PowerShell and a Command Prompt version of the
|
|
103
141
|
command. What was measured on which system: [COMPATIBILITY.md](COMPATIBILITY.md).
|
|
@@ -138,12 +176,14 @@ To get the newest 0.2.x:
|
|
|
138
176
|
A folder that is literally named `setup`, `doctor` or `guide` must now be
|
|
139
177
|
passed as `./setup` (or an absolute path); the bare word is the command.
|
|
140
178
|
|
|
141
|
-
### Upgrading from 0.
|
|
179
|
+
### Upgrading from 0.3.x or older
|
|
142
180
|
|
|
143
|
-
Configs written by
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
181
|
+
Configs written by older versions pin `sentinel-ir-mcp@0.3` (or `@0.2`) and
|
|
182
|
+
keep running that line. 0.3.x has no native-code detection, and its parser
|
|
183
|
+
stops working in long sessions (see 0.4.0 below); 0.2.x also has no Python
|
|
184
|
+
support. Run `setup` again for your client (it replaces only the
|
|
185
|
+
`"sentinel-ir"` entry); `doctor` warns about an entry that still pins 0.3 or
|
|
186
|
+
older. If the client still starts an old version, see
|
|
147
187
|
[Getting a newer version](#getting-a-newer-version).
|
|
148
188
|
|
|
149
189
|
### One-click install links
|
|
@@ -173,7 +213,7 @@ Settings → Developer → Edit Config opens `claude_desktop_config.json`
|
|
|
173
213
|
"mcpServers": {
|
|
174
214
|
"sentinel-ir": {
|
|
175
215
|
"command": "npx",
|
|
176
|
-
"args": ["-y", "sentinel-ir-mcp@0.
|
|
216
|
+
"args": ["-y", "sentinel-ir-mcp@0.4", "/Users/you/projects/my-app"]
|
|
177
217
|
}
|
|
178
218
|
}
|
|
179
219
|
}
|
|
@@ -192,7 +232,7 @@ project entry has to be switched on once in Cursor Settings → Customize → MC
|
|
|
192
232
|
"mcpServers": {
|
|
193
233
|
"sentinel-ir": {
|
|
194
234
|
"command": "npx",
|
|
195
|
-
"args": ["-y", "sentinel-ir-mcp@0.
|
|
235
|
+
"args": ["-y", "sentinel-ir-mcp@0.4", "${workspaceFolder}"]
|
|
196
236
|
}
|
|
197
237
|
}
|
|
198
238
|
}
|
|
@@ -208,8 +248,8 @@ with your project's absolute path.
|
|
|
208
248
|
#### Other clients
|
|
209
249
|
|
|
210
250
|
Any MCP client that starts stdio servers works with
|
|
211
|
-
`command: "npx"`, `args: ["-y", "sentinel-ir-mcp@0.
|
|
212
|
-
(on Windows: `command: "cmd"`, `args: ["/c", "npx", "-y", "sentinel-ir-mcp@0.
|
|
251
|
+
`command: "npx"`, `args: ["-y", "sentinel-ir-mcp@0.4", "<folder>"]`
|
|
252
|
+
(on Windows: `command: "cmd"`, `args: ["/c", "npx", "-y", "sentinel-ir-mcp@0.4", "<folder>"]`).
|
|
213
253
|
Without folder arguments the server uses the client's
|
|
214
254
|
[MCP roots](https://modelcontextprotocol.io/specification/latest/client/roots)
|
|
215
255
|
if the client offers them; if it offers none, only `analyze_code` works.
|
|
@@ -217,9 +257,10 @@ if the client offers them; if it offers none, only `analyze_code` works.
|
|
|
217
257
|
### Options
|
|
218
258
|
|
|
219
259
|
```text
|
|
220
|
-
sentinel-ir-mcp [--max-file-bytes <n>] [--max-files <n>] [folder ...]
|
|
260
|
+
sentinel-ir-mcp [--max-file-bytes <n>] [--max-files <n>] [--time-budget-ms <n>] [folder ...]
|
|
221
261
|
|
|
222
|
-
--max-file-bytes <n> largest file to analyze (default 1048576 = 1 MiB)
|
|
262
|
+
--max-file-bytes <n> largest file to analyze (default 1048576 = 1 MiB, max 2097152 = 2 MiB)
|
|
263
|
+
--time-budget-ms <n> analyze_directory starts no new file after this (default 30000, max 600000)
|
|
223
264
|
--max-files <n> default file limit for analyze_directory (default 50, max 500)
|
|
224
265
|
```
|
|
225
266
|
|
|
@@ -227,14 +268,22 @@ sentinel-ir-mcp [--max-file-bytes <n>] [--max-files <n>] [folder ...]
|
|
|
227
268
|
|
|
228
269
|
- **Reads only.** The MCP server never writes files, runs commands or opens
|
|
229
270
|
network connections (only the separate `setup` command writes, and only the
|
|
230
|
-
client config file you confirm; `doctor` starts this package's own server). The only files it opens are `.js`/`.mjs`/`.cjs`/`.ts`/`.mts`/`.cts`/`.tsx` files up to
|
|
271
|
+
client config file you confirm; `doctor` starts this package's own server). The only files it opens are `.js`/`.mjs`/`.cjs`/`.ts`/`.mts`/`.cts`/`.tsx`/`.py`/`.pyi` files up to
|
|
231
272
|
`--max-file-bytes`.
|
|
232
273
|
- **Only inside the allowed folders.** Every path is resolved to its real path
|
|
233
274
|
(symlinks followed) and must lie inside a folder given on the command line,
|
|
234
275
|
or, without folder arguments, inside one of the client's MCP roots. `../`,
|
|
235
276
|
absolute paths elsewhere and symlinks pointing outside are refused.
|
|
236
277
|
- **`analyze_directory`** skips `node_modules`, `.git`, hidden folders and
|
|
237
|
-
every symlink, and stops after `max_files` files (`truncated: true`)
|
|
278
|
+
every symlink, and stops after `max_files` files (`truncated: true`) or
|
|
279
|
+
when the time budget is spent (`timedOut: true`, rest in `notAnalyzed`).
|
|
280
|
+
Each file is checked again when it is opened: it must still be a regular
|
|
281
|
+
file inside the allowed folders.
|
|
282
|
+
- **Hard links are refused.** A hard link inside the folder can point to a
|
|
283
|
+
file outside it, and the other names of a file cannot be found. So files
|
|
284
|
+
with more than one hard link are refused (`analyze_file` error,
|
|
285
|
+
`skipped.hardlinks` in `analyze_directory`). This also refuses harmless hard
|
|
286
|
+
links, e.g. pnpm's in `node_modules`.
|
|
238
287
|
- **The IR is still untrusted text.** Comments, string bodies and function
|
|
239
288
|
bodies are not copied into it, so instructions hidden there do not reach the
|
|
240
289
|
agent through the IR. It does contain identifiers and some literals from the
|
|
@@ -264,13 +313,22 @@ very small files. `analyze_file` on `src/ast-facts.js` took about 160 ms per
|
|
|
264
313
|
call (average of 10, Node 24, Linux cloud VM); the first call also loads the
|
|
265
314
|
WebAssembly parser.
|
|
266
315
|
|
|
316
|
+
Long session (one server process over stdio, 6,000 `analyze_code` calls on
|
|
317
|
+
the same 17.8 KB JavaScript file, about 107 MB in total, Node 24):
|
|
318
|
+
|
|
319
|
+
| version | calls with a different `analysisStatus` than the first | first such call |
|
|
320
|
+
|---|---:|---:|
|
|
321
|
+
| 0.3.0 + `sentinel-ir` 0.3.1 | 1,083 | 4,918 |
|
|
322
|
+
| 0.4.0 + `sentinel-ir` 0.4.2 | 0 | — |
|
|
323
|
+
|
|
267
324
|
## Dependencies
|
|
268
325
|
|
|
269
326
|
Three runtime dependencies: the official
|
|
270
327
|
[`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk),
|
|
271
328
|
`zod` (its schema peer dependency) and `sentinel-ir`. With their own
|
|
272
329
|
dependencies that is about 100 packages in `node_modules`; `npm audit`
|
|
273
|
-
reported 0 vulnerabilities at release time.
|
|
330
|
+
reported 0 vulnerabilities at release time (0.4.0: after updating
|
|
331
|
+
`body-parser` to 2.4.0 in the lock file). Unlike `sentinel-ir` itself,
|
|
274
332
|
this package is not dependency-free.
|
|
275
333
|
|
|
276
334
|
## Development
|
package/bin/sentinel-ir-mcp.js
CHANGED
|
@@ -23,7 +23,8 @@ Usage: sentinel-ir-mcp [options] [folder ...]
|
|
|
23
23
|
command must be written as ./setup, ./doctor or ./guide. Without folders the
|
|
24
24
|
server uses the MCP roots offered by the client; if the
|
|
25
25
|
client offers none, only analyze_code works.
|
|
26
|
-
--max-file-bytes <n> Largest file to analyze (default ${DEFAULT_LIMITS.maxFileBytes})
|
|
26
|
+
--max-file-bytes <n> Largest file to analyze (default ${DEFAULT_LIMITS.maxFileBytes}, max ${DEFAULT_LIMITS.hardMaxFileBytes})
|
|
27
|
+
--time-budget-ms <n> analyze_directory starts no new file after this time (default ${DEFAULT_LIMITS.timeBudgetMs}, max ${DEFAULT_LIMITS.hardMaxTimeBudgetMs})
|
|
27
28
|
--max-files <n> Default file limit for analyze_directory (default ${DEFAULT_LIMITS.maxFiles}, max ${DEFAULT_LIMITS.hardMaxFiles})
|
|
28
29
|
-h, --help Show this help
|
|
29
30
|
-v, --version Show the version
|
|
@@ -36,6 +37,12 @@ function die(message) {
|
|
|
36
37
|
process.exit(2);
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
const NUMBER_OPTIONS = {
|
|
41
|
+
"--max-file-bytes": ["maxFileBytes", DEFAULT_LIMITS.hardMaxFileBytes],
|
|
42
|
+
"--max-files": ["maxFiles", DEFAULT_LIMITS.hardMaxFiles],
|
|
43
|
+
"--time-budget-ms": ["timeBudgetMs", DEFAULT_LIMITS.hardMaxTimeBudgetMs]
|
|
44
|
+
};
|
|
45
|
+
|
|
39
46
|
function parseArgs(argv) {
|
|
40
47
|
const opts = { roots: [] };
|
|
41
48
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -44,12 +51,12 @@ function parseArgs(argv) {
|
|
|
44
51
|
if (arg.startsWith("--") && arg.includes("=")) [arg, value] = [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)];
|
|
45
52
|
if (arg === "-h" || arg === "--help") { process.stdout.write(USAGE + "\n"); process.exit(0); }
|
|
46
53
|
if (arg === "-v" || arg === "--version") { process.stdout.write(version + "\n"); process.exit(0); }
|
|
47
|
-
if (
|
|
54
|
+
if (Object.hasOwn(NUMBER_OPTIONS, arg)) {
|
|
55
|
+
const [key, max] = NUMBER_OPTIONS[arg];
|
|
48
56
|
if (value === undefined) value = argv[++i];
|
|
49
57
|
const n = Number(value);
|
|
50
|
-
const max = arg === "--max-files" ? DEFAULT_LIMITS.hardMaxFiles : Number.MAX_SAFE_INTEGER;
|
|
51
58
|
if (!Number.isInteger(n) || n < 1 || n > max) die(`${arg} must be an integer between 1 and ${max}`);
|
|
52
|
-
opts[
|
|
59
|
+
opts[key] = n;
|
|
53
60
|
} else if (arg.startsWith("-")) {
|
|
54
61
|
die(`unknown option ${arg}`);
|
|
55
62
|
} else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sentinel-ir-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "MCP server for sentinel-ir: lets AI agents (Claude Desktop, Cursor, Windsurf, ...) read a compact structural IR of JavaScript, TypeScript and Python files instead of the raw source. Read-only, sandboxed to allowed folders.",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
22
|
-
"sentinel-ir": "^0.3
|
|
22
|
+
"sentinel-ir": "^0.4.3",
|
|
23
23
|
"zod": "^4.1.0"
|
|
24
24
|
},
|
|
25
25
|
"keywords": [
|
package/src/analyze.js
CHANGED
|
@@ -1,9 +1,64 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const fs = require("fs/promises");
|
|
4
|
+
const { constants, realpathSync } = require("fs");
|
|
4
5
|
const path = require("path");
|
|
5
6
|
const { analyzeAsync, compress } = require("sentinel-ir");
|
|
6
|
-
const { AccessError, resolveInside, isSourceFile } = require("./paths");
|
|
7
|
+
const { AccessError, resolveInside, isInside, isSourceFile } = require("./paths");
|
|
8
|
+
|
|
9
|
+
// O_NONBLOCK: opening a FIFO swapped in after listing must not block the
|
|
10
|
+
// server forever. Neither flag exists on Windows; the inode check covers that.
|
|
11
|
+
const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
|
|
12
|
+
|
|
13
|
+
class TooLargeError extends AccessError {}
|
|
14
|
+
class HardlinkError extends AccessError {}
|
|
15
|
+
|
|
16
|
+
// Reads the file that is actually opened, not the one checked earlier: the
|
|
17
|
+
// path may be swapped for a symlink between the check and the read. The open
|
|
18
|
+
// handle must be a regular file whose inode is the one now found at a real
|
|
19
|
+
// path inside the allowed folders, and at most maxFileBytes are read.
|
|
20
|
+
async function readInside(full, roots, maxFileBytes, label) {
|
|
21
|
+
let handle;
|
|
22
|
+
try {
|
|
23
|
+
handle = await fs.open(full, OPEN_FLAGS);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (err && err.code === "ELOOP") throw new AccessError(`Access denied: ${label} is a symlink`);
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const opened = await handle.stat({ bigint: true });
|
|
30
|
+
if (!opened.isFile()) throw new AccessError(`Not a file: ${label}`);
|
|
31
|
+
if (opened.size > BigInt(maxFileBytes)) {
|
|
32
|
+
throw new TooLargeError(`File too large: ${label} is ${opened.size} bytes, limit is ${maxFileBytes}`);
|
|
33
|
+
}
|
|
34
|
+
// Same realpath as resolveInside: on Windows fs.promises.realpath
|
|
35
|
+
// expands 8.3 short names (GITLAB~1) and would never match the roots.
|
|
36
|
+
const real = realpathSync(full);
|
|
37
|
+
const current = await fs.stat(real, { bigint: true });
|
|
38
|
+
if (!roots.some((r) => isInside(real, r)) || current.dev !== opened.dev || current.ino !== opened.ino) {
|
|
39
|
+
throw new AccessError(`Access denied: ${label} changed while it was being read`);
|
|
40
|
+
}
|
|
41
|
+
// Another name of the same file may lie outside the allowed folders,
|
|
42
|
+
// and where it lies cannot be found out, so such files are refused.
|
|
43
|
+
// Both counts: the handle's alone can read 1 while the path is swapped.
|
|
44
|
+
const links = opened.nlink > current.nlink ? opened.nlink : current.nlink;
|
|
45
|
+
if (links > 1n) {
|
|
46
|
+
throw new HardlinkError(`Access denied: ${label} has ${links} hard links; files with more than one are refused`);
|
|
47
|
+
}
|
|
48
|
+
const buffer = Buffer.alloc(Number(opened.size) + 1);
|
|
49
|
+
let length = 0;
|
|
50
|
+
while (length < buffer.length) {
|
|
51
|
+
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
|
|
52
|
+
if (!bytesRead) break;
|
|
53
|
+
length += bytesRead;
|
|
54
|
+
}
|
|
55
|
+
if (length > maxFileBytes) throw new TooLargeError(`File too large: ${label} grew past the limit of ${maxFileBytes} bytes`);
|
|
56
|
+
if (length === buffer.length) throw new AccessError(`Access denied: ${label} changed while it was being read`);
|
|
57
|
+
return buffer.toString("utf8", 0, length);
|
|
58
|
+
} finally {
|
|
59
|
+
await handle.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
7
62
|
|
|
8
63
|
const SKIP_DIRS = new Set(["node_modules", ".git", "__pycache__", "venv", "site-packages"]);
|
|
9
64
|
const RISK_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
@@ -26,7 +81,7 @@ async function analyzeFile(requested, roots, { maxFileBytes }) {
|
|
|
26
81
|
if (stat.size > maxFileBytes) {
|
|
27
82
|
throw new AccessError(`File too large: ${requested} is ${stat.size} bytes, limit is ${maxFileBytes}`);
|
|
28
83
|
}
|
|
29
|
-
const code = await
|
|
84
|
+
const code = await readInside(real, roots, maxFileBytes, requested);
|
|
30
85
|
return analyzeCode(code, display);
|
|
31
86
|
}
|
|
32
87
|
|
|
@@ -34,12 +89,20 @@ async function analyzeFile(requested, roots, { maxFileBytes }) {
|
|
|
34
89
|
// __pycache__, venv, site-packages and hidden folders are skipped.
|
|
35
90
|
async function collectFiles(dir, root, { maxFiles, maxFileBytes, recursive }) {
|
|
36
91
|
const files = [];
|
|
37
|
-
const skipped = { tooLarge: [], symlinks: 0, unreadable: [] };
|
|
92
|
+
const skipped = { tooLarge: [], symlinks: 0, unreadable: [], hardlinks: [] };
|
|
38
93
|
let truncated = false;
|
|
39
94
|
const queue = [dir];
|
|
40
95
|
while (queue.length && !truncated) {
|
|
41
96
|
const current = queue.shift();
|
|
42
|
-
|
|
97
|
+
let entries;
|
|
98
|
+
try {
|
|
99
|
+
entries = await fs.readdir(current, { withFileTypes: true });
|
|
100
|
+
} catch {
|
|
101
|
+
// Removed or made unreadable while the scan runs.
|
|
102
|
+
skipped.unreadable.push(path.relative(root, current).split(path.sep).join("/") || ".");
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
entries = entries
|
|
43
106
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
44
107
|
for (const entry of entries) {
|
|
45
108
|
const full = path.join(current, entry.name);
|
|
@@ -49,7 +112,13 @@ async function collectFiles(dir, root, { maxFiles, maxFileBytes, recursive }) {
|
|
|
49
112
|
} else if (entry.isDirectory()) {
|
|
50
113
|
if (recursive && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) queue.push(full);
|
|
51
114
|
} else if (entry.isFile() && isSourceFile(entry.name)) {
|
|
52
|
-
|
|
115
|
+
let size;
|
|
116
|
+
try {
|
|
117
|
+
({ size } = await fs.lstat(full));
|
|
118
|
+
} catch {
|
|
119
|
+
skipped.unreadable.push(rel);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
53
122
|
if (size > maxFileBytes) {
|
|
54
123
|
skipped.tooLarge.push(rel);
|
|
55
124
|
} else if (files.length >= maxFiles) {
|
|
@@ -64,20 +133,30 @@ async function collectFiles(dir, root, { maxFiles, maxFileBytes, recursive }) {
|
|
|
64
133
|
return { files, skipped, truncated };
|
|
65
134
|
}
|
|
66
135
|
|
|
67
|
-
async function analyzeDirectory(requested, roots, { maxFiles, maxFileBytes, recursive = true }) {
|
|
136
|
+
async function analyzeDirectory(requested, roots, { maxFiles, maxFileBytes, recursive = true, timeBudgetMs = Infinity }) {
|
|
137
|
+
const started = Date.now();
|
|
68
138
|
const { real, root, display } = resolveInside(requested, roots);
|
|
69
139
|
const stat = await fs.stat(real);
|
|
70
140
|
if (!stat.isDirectory()) throw new AccessError(`Not a folder: ${requested}`);
|
|
71
141
|
|
|
72
142
|
const { files, skipped, truncated } = await collectFiles(real, root, { maxFiles, maxFileBytes, recursive });
|
|
73
143
|
const results = [];
|
|
144
|
+
const notAnalyzed = [];
|
|
74
145
|
for (const { full, rel } of files) {
|
|
146
|
+
// A running analysis cannot be interrupted, so the budget only stops
|
|
147
|
+
// new files from starting; the rest are listed in notAnalyzed.
|
|
148
|
+
if (notAnalyzed.length || Date.now() - started >= timeBudgetMs) {
|
|
149
|
+
notAnalyzed.push(rel);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
75
152
|
// One file that cannot be read or analyzed must not hide the others.
|
|
76
153
|
let ir;
|
|
77
154
|
try {
|
|
78
|
-
ir = await analyzeCode(await
|
|
79
|
-
} catch {
|
|
80
|
-
skipped.
|
|
155
|
+
ir = await analyzeCode(await readInside(full, [root], maxFileBytes, rel), rel);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const list = err instanceof TooLargeError ? skipped.tooLarge
|
|
158
|
+
: err instanceof HardlinkError ? skipped.hardlinks : skipped.unreadable;
|
|
159
|
+
list.push(rel);
|
|
81
160
|
continue;
|
|
82
161
|
}
|
|
83
162
|
results.push({
|
|
@@ -107,6 +186,8 @@ async function analyzeDirectory(requested, roots, { maxFiles, maxFileBytes, recu
|
|
|
107
186
|
analyzed: results.length,
|
|
108
187
|
truncated,
|
|
109
188
|
maxFiles,
|
|
189
|
+
timedOut: notAnalyzed.length > 0,
|
|
190
|
+
notAnalyzed,
|
|
110
191
|
skipped,
|
|
111
192
|
riskCounts,
|
|
112
193
|
statusCounts,
|
|
@@ -115,4 +196,4 @@ async function analyzeDirectory(requested, roots, { maxFiles, maxFileBytes, recu
|
|
|
115
196
|
};
|
|
116
197
|
}
|
|
117
198
|
|
|
118
|
-
module.exports = { analyzeCode, analyzeFile, analyzeDirectory, SKIP_DIRS };
|
|
199
|
+
module.exports = { analyzeCode, analyzeFile, analyzeDirectory, readInside, SKIP_DIRS };
|
package/src/clients.js
CHANGED
package/src/doctor.js
CHANGED
|
@@ -10,7 +10,7 @@ const { readConfig } = require("./setup");
|
|
|
10
10
|
|
|
11
11
|
const BIN = path.join(__dirname, "..", "bin", "sentinel-ir-mcp.js");
|
|
12
12
|
const EXPECTED_TOOLS = ["analyze_code", "analyze_directory", "analyze_file"];
|
|
13
|
-
const VALUE_OPTIONS = new Set(["--max-file-bytes", "--max-files"]);
|
|
13
|
+
const VALUE_OPTIONS = new Set(["--max-file-bytes", "--max-files", "--time-budget-ms"]);
|
|
14
14
|
|
|
15
15
|
function irVersion() {
|
|
16
16
|
try {
|
|
@@ -67,8 +67,8 @@ function checkConfig(client, file, explicit, project, add, platform) {
|
|
|
67
67
|
const runs = `${label}: "${SERVER_NAME}" runs ${entry.command} ${args.join(" ")}`;
|
|
68
68
|
if (entry.command === "npx" && platform === "win32") {
|
|
69
69
|
add("warn", `${client}.entry`, runs, `On Windows a client may not find "npx" without a shell. Run: npx -y ${PACKAGE_SPEC} setup --client ${client}`);
|
|
70
|
-
} else if (args.some(a => /^sentinel-ir-mcp@0\.[0-
|
|
71
|
-
add("warn", `${client}.entry`, runs, `This entry stays on an older version without
|
|
70
|
+
} else if (args.some(a => /^sentinel-ir-mcp@0\.[0-3](\.|$)/.test(a))) {
|
|
71
|
+
add("warn", `${client}.entry`, runs, `This entry stays on an older version without native-code detection, whose parser stops working in long sessions. Run: npx -y ${PACKAGE_SPEC} setup --client ${client}`);
|
|
72
72
|
} else if (entry.command === "npx" || isWindowsNpxWrapper(entry)) {
|
|
73
73
|
add("ok", `${client}.entry`, runs);
|
|
74
74
|
} else {
|
package/src/guide.js
CHANGED
|
@@ -8,9 +8,12 @@ env reads and network/filesystem/process/crypto calls. It reads files only.
|
|
|
8
8
|
|
|
9
9
|
Recommended order:
|
|
10
10
|
1. Call analyze_directory on the project (or a sub-folder) first.
|
|
11
|
-
2. Look at "incomplete"
|
|
11
|
+
2. Look at "incomplete", "skipped.unreadable" and "skipped.hardlinks" before
|
|
12
|
+
anything else. Those
|
|
12
13
|
files were not fully analyzed: a low risk there is NOT evidence of safety.
|
|
13
|
-
Read their raw source if they matter.
|
|
14
|
+
Read their raw source if they matter. If "timedOut" is true, the files in
|
|
15
|
+
"notAnalyzed" were not analyzed at all: call analyze_directory on a
|
|
16
|
+
sub-folder or analyze_file for them.
|
|
14
17
|
3. Pick the relevant files from "files" (sorted from highest to lowest risk).
|
|
15
18
|
4. Call analyze_file for the full IR of a single file.
|
|
16
19
|
5. Read raw source only for the files you actually need to change or verify.
|
package/src/server.js
CHANGED
|
@@ -8,6 +8,12 @@ const { analyzeCode, analyzeFile, analyzeDirectory } = require("./analyze");
|
|
|
8
8
|
|
|
9
9
|
const DEFAULT_LIMITS = Object.freeze({
|
|
10
10
|
maxFileBytes: 1024 * 1024,
|
|
11
|
+
// 4 MiB of JavaScript aborts the process on Windows CI (3 GB on Linux).
|
|
12
|
+
hardMaxFileBytes: 2 * 1024 * 1024,
|
|
13
|
+
// Plus one 1 MiB file (up to 23 s on Windows CI) stays below the 60 s
|
|
14
|
+
// request timeout of MCP SDK clients.
|
|
15
|
+
timeBudgetMs: 30000,
|
|
16
|
+
hardMaxTimeBudgetMs: 600000,
|
|
11
17
|
maxFiles: 50,
|
|
12
18
|
hardMaxFiles: 500
|
|
13
19
|
});
|
|
@@ -30,7 +36,7 @@ const INSTRUCTIONS =
|
|
|
30
36
|
"or risk, then read the raw source of the files that matter. Supported: JavaScript (.js, .mjs, .cjs), TypeScript (.ts, .mts, .cts, .tsx) and Python (.py, .pyi); the language is picked from the file extension. " +
|
|
31
37
|
"Rely on analysisStatus, world.risk, forbidden and facts; analysisStatus \"incomplete\" means the file did not parse cleanly. " +
|
|
32
38
|
"analyze_directory lists such files separately in 'incomplete'; their low risk is not evidence of safety. " +
|
|
33
|
-
"Suggested order: analyze_directory first, check 'incomplete'
|
|
39
|
+
"Suggested order: analyze_directory first, check 'incomplete', 'skipped.unreadable' and 'notAnalyzed' (set when the time budget ran out), pick the relevant files, " +
|
|
34
40
|
"then analyze_file, and read raw source only where needed. " +
|
|
35
41
|
"Read-only: nothing is written, executed or sent over the network. " + UNTRUSTED_NOTE;
|
|
36
42
|
|
|
@@ -56,8 +62,9 @@ async function guarded(fn) {
|
|
|
56
62
|
|
|
57
63
|
function createServer(options = {}) {
|
|
58
64
|
const limits = {
|
|
59
|
-
maxFileBytes: options.maxFileBytes ?? DEFAULT_LIMITS.maxFileBytes,
|
|
60
|
-
maxFiles: options.maxFiles ?? DEFAULT_LIMITS.maxFiles
|
|
65
|
+
maxFileBytes: Math.min(options.maxFileBytes ?? DEFAULT_LIMITS.maxFileBytes, DEFAULT_LIMITS.hardMaxFileBytes),
|
|
66
|
+
maxFiles: options.maxFiles ?? DEFAULT_LIMITS.maxFiles,
|
|
67
|
+
timeBudgetMs: Math.min(options.timeBudgetMs ?? DEFAULT_LIMITS.timeBudgetMs, DEFAULT_LIMITS.hardMaxTimeBudgetMs)
|
|
61
68
|
};
|
|
62
69
|
const fixedRoots = canonicalRoots(options.roots || []);
|
|
63
70
|
|
|
@@ -112,6 +119,9 @@ function createServer(options = {}) {
|
|
|
112
119
|
"you when there were more. 'incomplete' lists files whose analysis did not finish (e.g. parse errors); " +
|
|
113
120
|
"their risk is not reliable, check them separately. statusCounts counts complete vs incomplete. " +
|
|
114
121
|
"Files that could not be read are listed in skipped.unreadable and are not counted as analyzed. " +
|
|
122
|
+
"Files with more than one hard link are refused and listed in skipped.hardlinks. " +
|
|
123
|
+
`No new file is started after ${limits.timeBudgetMs / 1000} s: then timedOut is true and the remaining files are in notAnalyzed; ` +
|
|
124
|
+
"call again on a subfolder or use analyze_file for them. " +
|
|
115
125
|
"Use analyze_file for the full IR of a single file. " + UNTRUSTED_NOTE,
|
|
116
126
|
inputSchema: {
|
|
117
127
|
path: z.string().min(1).optional().describe("Folder path, absolute or relative to the first allowed folder (default: the first allowed folder)"),
|
|
@@ -124,6 +134,7 @@ function createServer(options = {}) {
|
|
|
124
134
|
analyzeDirectory(path ?? ".", await allowedRoots(), {
|
|
125
135
|
maxFiles: max_files ?? limits.maxFiles,
|
|
126
136
|
maxFileBytes: limits.maxFileBytes,
|
|
137
|
+
timeBudgetMs: limits.timeBudgetMs,
|
|
127
138
|
recursive: recursive ?? true
|
|
128
139
|
})));
|
|
129
140
|
|