sentinel-ir-mcp 0.4.6 → 0.5.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 +83 -35
- package/bin/sentinel-ir-mcp.js +1 -1
- package/package.json +4 -3
- package/src/analyze.js +26 -4
- package/src/clients.js +7 -2
- package/src/doctor.js +96 -6
- package/src/paths.js +2 -1
- package/src/server.js +15 -15
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
MCP server for [`sentinel-ir`](https://www.npmjs.com/package/sentinel-ir):
|
|
4
4
|
it lets AI agents in Claude Desktop, Cursor, Windsurf or any other
|
|
5
5
|
[Model Context Protocol](https://modelcontextprotocol.io) client read a
|
|
6
|
-
compact **structural IR** of JavaScript, TypeScript and
|
|
6
|
+
compact **structural IR** of JavaScript, TypeScript, Python and Rust files instead of the raw source.
|
|
7
7
|
|
|
8
8
|
- npm: <https://www.npmjs.com/package/sentinel-ir-mcp>
|
|
9
9
|
- GitLab (main repository): <https://gitlab.com/jakubrojicek11/sentinel-ir-mcp>
|
|
@@ -18,15 +18,55 @@ hundred characters. The agent can triage a folder first and then read the raw
|
|
|
18
18
|
source only of the files that matter.
|
|
19
19
|
|
|
20
20
|
**Supported:** JavaScript (`.js`, `.mjs`, `.cjs`), TypeScript (`.ts`, `.mts`,
|
|
21
|
-
`.cts`, `.tsx`; since 0.2.0)
|
|
21
|
+
`.cts`, `.tsx`; since 0.2.0), Python (`.py`, `.pyi`; since 0.3.0) and Rust
|
|
22
|
+
(`.rs`; since 0.5.0). The
|
|
22
23
|
language is picked from the file extension; `analyze_code` also takes `target`
|
|
23
|
-
or an explicit `language` (`javascript` | `typescript` | `tsx` | `python`). Every result carries `language` and
|
|
24
|
+
or an explicit `language` (`javascript` | `typescript` | `tsx` | `python` | `rust`). Every result carries `language` and
|
|
24
25
|
`analysisStatus`; a file that does not parse cleanly (including JSX in a `.ts`
|
|
25
26
|
file) is `"incomplete"`, and a low risk on an incomplete result is not evidence
|
|
26
27
|
of safety. Other languages are not supported yet. What is detected in Python
|
|
27
|
-
(and what is not) is listed in the
|
|
28
|
+
and Rust (and what is not) is listed in the
|
|
28
29
|
[sentinel-ir README](https://gitlab.com/jakubrojicek11/sentinel-ir#python).
|
|
29
30
|
|
|
31
|
+
**0.5.1** makes sure an installed server gets fixes:
|
|
32
|
+
|
|
33
|
+
- It needs `sentinel-ir` 0.5.6 or newer. 0.5.3–0.5.6 fix a comment between
|
|
34
|
+
two tokens hiding dangerous code, a broken Python or Rust file showing
|
|
35
|
+
`low`, a parser that could hang for minutes on one file, and the line of
|
|
36
|
+
Rust `use std::process::Command` signals.
|
|
37
|
+
- `setup` writes `sentinel-ir-mcp@stable-0.5`, an npm tag that points at the
|
|
38
|
+
newest 0.5.x. `npx` keeps starting the copy it downloaded first for a
|
|
39
|
+
version or range such as `@0.5` and never picks up a fix; a tag is looked
|
|
40
|
+
up again on every start. Measured with npm 10.8.3 and 11.21.2 against a
|
|
41
|
+
local registry: after the tag moved, the next start ran the new version;
|
|
42
|
+
with the registry unreachable (connection refused), the cached copy still
|
|
43
|
+
started.
|
|
44
|
+
- `doctor` warns about an entry that pins a version or range (`@0.5`,
|
|
45
|
+
`@0.5.0`, ...) and about every copy in npm's `_npx` cache whose
|
|
46
|
+
`sentinel-ir` is older than 0.5.6, with the folder to delete. It fails when
|
|
47
|
+
the server itself runs an older `sentinel-ir`.
|
|
48
|
+
|
|
49
|
+
If you set up 0.5.0 or older, run `setup` again **with the new tag** (`npx -y
|
|
50
|
+
sentinel-ir-mcp@0.5 ...` would start your cached 0.5.0):
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npx -y sentinel-ir-mcp@stable-0.5 setup --client cursor
|
|
54
|
+
npx -y sentinel-ir-mcp@stable-0.5 doctor --client cursor
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**0.5.0** reads Rust (`.rs`) with `sentinel-ir` 0.5.2: `unsafe` and FFI,
|
|
58
|
+
`std::process::Command`, file deletes and writes, HTTP clients and routes
|
|
59
|
+
(`axum`, `actix-web`, ...). A `build.rs` or procedural macro that touches
|
|
60
|
+
files, the network or secret environment variables is at least `high`,
|
|
61
|
+
because it runs on your machine during `cargo build`.
|
|
62
|
+
|
|
63
|
+
- `analyze_directory` skips Cargo's build folder: a `target` folder with
|
|
64
|
+
Cargo's `CACHEDIR.TAG` file, listed in `skipped.cargoTarget`. A plain folder
|
|
65
|
+
named `target` (e.g. in a JS project) is still scanned.
|
|
66
|
+
- `setup` writes `sentinel-ir-mcp@0.5`; `doctor` asks entries pinned to 0.4
|
|
67
|
+
or older to run `setup` again (since 0.5.1 `setup` writes
|
|
68
|
+
`sentinel-ir-mcp@stable-0.5`, see above).
|
|
69
|
+
|
|
30
70
|
**0.4.6** is listed in the official
|
|
31
71
|
[MCP Registry](https://registry.modelcontextprotocol.io) as
|
|
32
72
|
`io.github.jakcmencz/sentinel-ir-mcp`, so clients that browse the registry
|
|
@@ -41,7 +81,7 @@ can check later which version of a file the agent was shown (see
|
|
|
41
81
|
- **One question in `setup`:** *"Keep a log of what the agent asks for (file
|
|
42
82
|
names and results, never your code)? [y/N]"*. Or `setup --audit-log`;
|
|
43
83
|
`--no-audit-log` turns it off. Running `setup` again keeps the setting.
|
|
44
|
-
- **One command to read it:** `npx -y sentinel-ir-mcp@0.
|
|
84
|
+
- **One command to read it:** `npx -y sentinel-ir-mcp@stable-0.5 log` prints the last
|
|
45
85
|
50 entries as plain lines, `--all` everything, `--json` for scripts:
|
|
46
86
|
|
|
47
87
|
```text
|
|
@@ -170,9 +210,9 @@ nothing to compile (the parser is WebAssembly).
|
|
|
170
210
|
Run it in your project folder:
|
|
171
211
|
|
|
172
212
|
```bash
|
|
173
|
-
npx -y sentinel-ir-mcp@0.
|
|
174
|
-
npx -y sentinel-ir-mcp@0.
|
|
175
|
-
npx -y sentinel-ir-mcp@0.
|
|
213
|
+
npx -y sentinel-ir-mcp@stable-0.5 setup --client cursor # or windsurf, claude-desktop, vscode, claude-code
|
|
214
|
+
npx -y sentinel-ir-mcp@stable-0.5 doctor --client cursor # after restarting the client
|
|
215
|
+
npx -y sentinel-ir-mcp@stable-0.5 guide # optional: text for AGENTS.md / client rules
|
|
176
216
|
```
|
|
177
217
|
|
|
178
218
|
`setup` (without `--client` it asks which client):
|
|
@@ -186,8 +226,8 @@ npx -y sentinel-ir-mcp@0.4 guide # optional: text for AGENTS
|
|
|
186
226
|
touched;
|
|
187
227
|
- gives the agent one folder (`--project`, default: the current folder) and
|
|
188
228
|
refuses your home folder or a whole disk;
|
|
189
|
-
- writes `sentinel-ir-mcp@0.
|
|
190
|
-
new features;
|
|
229
|
+
- writes `sentinel-ir-mcp@stable-0.5`, an npm tag for the newest 0.5.x, so
|
|
230
|
+
clients get 0.5.x fixes on their next start but no unexpected new features;
|
|
191
231
|
- asks which files or folders the agent must not read (`--exclude <name>`,
|
|
192
232
|
repeatable, skips the question; see [Excluding files](#excluding-files));
|
|
193
233
|
- for VS Code and Claude Code it writes nothing and prints their own install
|
|
@@ -201,7 +241,7 @@ npx -y sentinel-ir-mcp@0.4 guide # optional: text for AGENTS
|
|
|
201
241
|
| `vscode`, `claude-code` | nothing; prints the command to run |
|
|
202
242
|
|
|
203
243
|
On Windows the entry is `"command": "cmd", "args": ["/c", "npx", "-y",
|
|
204
|
-
"sentinel-ir-mcp@0.
|
|
244
|
+
"sentinel-ir-mcp@stable-0.5", "<folder>"]`: there `npx` is `npx.cmd`, and starting it
|
|
205
245
|
without a shell failed with `ENOENT` on a real Windows runner. For VS Code and
|
|
206
246
|
Claude Code, `setup` prints a PowerShell and a Command Prompt version of the
|
|
207
247
|
command. What was measured on which system: [COMPATIBILITY.md](COMPATIBILITY.md).
|
|
@@ -225,21 +265,26 @@ Open Cursor Settings → Customize → MCPs and turn on the switch next to
|
|
|
225
265
|
*Connected* and 3 tools. A server installed with the link below (user-level
|
|
226
266
|
`~/.cursor/mcp.json`) connected without this step.
|
|
227
267
|
|
|
228
|
-
### Getting a newer
|
|
229
|
-
|
|
230
|
-
`npx` keeps the package it downloaded for
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
268
|
+
### Getting a newer version
|
|
269
|
+
|
|
270
|
+
`npx` keeps the package it downloaded for a version or range
|
|
271
|
+
(`sentinel-ir-mcp@0.5`, `@0.2`, ...) and keeps starting that copy, together
|
|
272
|
+
with the `sentinel-ir` it got then, even when newer versions are on npm
|
|
273
|
+
(measured with npm 10.8.2, 10.8.3, 10.9.9 and 11.21.2; on npm 10
|
|
274
|
+
`--prefer-online` and `npm cache clean --force` did not change it). The tag
|
|
275
|
+
`setup` writes since 0.5.1 (`@stable-0.5`) is looked up again on every start
|
|
276
|
+
instead. If a client still starts an old copy:
|
|
277
|
+
|
|
278
|
+
1. Run `npx -y sentinel-ir-mcp@stable-0.5 doctor`. It prints the versions it
|
|
279
|
+
runs and lists every cached copy with a `sentinel-ir` older than 0.5.6,
|
|
280
|
+
with its folder.
|
|
281
|
+
2. Close the client app and delete those folders (npm 11: `npm cache npx rm
|
|
282
|
+
<key>`). Deleting the whole `_npx` folder in npm's cache also works
|
|
239
283
|
(`npm config get cache` shows where it is; usually `~/.npm/_npx`, on
|
|
240
|
-
Windows `%LocalAppData%\npm-cache\_npx`)
|
|
241
|
-
by `npx`,
|
|
242
|
-
3.
|
|
284
|
+
Windows `%LocalAppData%\npm-cache\_npx`); it removes all packages cached
|
|
285
|
+
by `npx`, which are downloaded again when needed.
|
|
286
|
+
3. Run `npx -y sentinel-ir-mcp@stable-0.5 setup` for your client and start the
|
|
287
|
+
client again.
|
|
243
288
|
|
|
244
289
|
A folder that is literally named `setup`, `doctor` or `guide` must now be
|
|
245
290
|
passed as `./setup` (or an absolute path); the bare word is the command.
|
|
@@ -261,8 +306,8 @@ VS Code 1.143 and Cursor 3.26 (see [COMPATIBILITY.md](COMPATIBILITY.md)). A link
|
|
|
261
306
|
project path, so it uses `${workspaceFolder}`; if your client does not expand
|
|
262
307
|
it, use `setup` instead.
|
|
263
308
|
|
|
264
|
-
- Cursor: [Install in Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=sentinel-ir&config=
|
|
265
|
-
- VS Code: [Install in VS Code](vscode:mcp/install?%7B%22name%22%3A%22sentinel-ir%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22sentinel-ir-mcp%
|
|
309
|
+
- Cursor: [Install in Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=sentinel-ir&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsInNlbnRpbmVsLWlyLW1jcEBzdGFibGUtMC41IiwiJHt3b3Jrc3BhY2VGb2xkZXJ9Il19)
|
|
310
|
+
- VS Code: [Install in VS Code](vscode:mcp/install?%7B%22name%22%3A%22sentinel-ir%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22sentinel-ir-mcp%40stable-0.5%22%2C%22%24%7BworkspaceFolder%7D%22%5D%7D)
|
|
266
311
|
|
|
267
312
|
Some sites (including GitLab and npm) may not open `cursor://` or `vscode:`
|
|
268
313
|
links; copy the link into the browser address bar or use `setup`.
|
|
@@ -281,7 +326,7 @@ Settings → Developer → Edit Config opens `claude_desktop_config.json`
|
|
|
281
326
|
"mcpServers": {
|
|
282
327
|
"sentinel-ir": {
|
|
283
328
|
"command": "npx",
|
|
284
|
-
"args": ["-y", "sentinel-ir-mcp@0.
|
|
329
|
+
"args": ["-y", "sentinel-ir-mcp@stable-0.5", "/Users/you/projects/my-app"]
|
|
285
330
|
}
|
|
286
331
|
}
|
|
287
332
|
}
|
|
@@ -300,7 +345,7 @@ project entry has to be switched on once in Cursor Settings → Customize → MC
|
|
|
300
345
|
"mcpServers": {
|
|
301
346
|
"sentinel-ir": {
|
|
302
347
|
"command": "npx",
|
|
303
|
-
"args": ["-y", "sentinel-ir-mcp@0.
|
|
348
|
+
"args": ["-y", "sentinel-ir-mcp@stable-0.5", "${workspaceFolder}"]
|
|
304
349
|
}
|
|
305
350
|
}
|
|
306
351
|
}
|
|
@@ -316,8 +361,8 @@ with your project's absolute path.
|
|
|
316
361
|
#### Other clients
|
|
317
362
|
|
|
318
363
|
Any MCP client that starts stdio servers works with
|
|
319
|
-
`command: "npx"`, `args: ["-y", "sentinel-ir-mcp@0.
|
|
320
|
-
(on Windows: `command: "cmd"`, `args: ["/c", "npx", "-y", "sentinel-ir-mcp@0.
|
|
364
|
+
`command: "npx"`, `args: ["-y", "sentinel-ir-mcp@stable-0.5", "<folder>"]`
|
|
365
|
+
(on Windows: `command: "cmd"`, `args: ["/c", "npx", "-y", "sentinel-ir-mcp@stable-0.5", "<folder>"]`).
|
|
321
366
|
Without folder arguments the server uses the client's
|
|
322
367
|
[MCP roots](https://modelcontextprotocol.io/specification/latest/client/roots)
|
|
323
368
|
if the client offers them; if it offers none, only `analyze_code` works.
|
|
@@ -325,7 +370,7 @@ if the client offers them; if it offers none, only `analyze_code` works.
|
|
|
325
370
|
### Excluding files
|
|
326
371
|
|
|
327
372
|
```bash
|
|
328
|
-
npx -y sentinel-ir-mcp@0.
|
|
373
|
+
npx -y sentinel-ir-mcp@stable-0.5 setup --client cursor --exclude secrets --exclude "*.min.js"
|
|
329
374
|
```
|
|
330
375
|
|
|
331
376
|
| you write | excludes |
|
|
@@ -350,8 +395,8 @@ other tools of its client is told about them, but this server cannot stop it.
|
|
|
350
395
|
### Audit log
|
|
351
396
|
|
|
352
397
|
```bash
|
|
353
|
-
npx -y sentinel-ir-mcp@0.
|
|
354
|
-
npx -y sentinel-ir-mcp@0.
|
|
398
|
+
npx -y sentinel-ir-mcp@stable-0.5 setup --client cursor --audit-log # or answer "y" in setup
|
|
399
|
+
npx -y sentinel-ir-mcp@stable-0.5 log # what the agent asked for
|
|
355
400
|
```
|
|
356
401
|
|
|
357
402
|
| system | folder |
|
|
@@ -508,7 +553,10 @@ A second CI job runs on a real Windows runner (Node 20/22/24): the full test
|
|
|
508
553
|
suite, the packed package with `setup`/`doctor` against the real `%APPDATA%`,
|
|
509
554
|
starting the published package through `npx`, and the printed VS Code/Claude
|
|
510
555
|
Code commands pasted into PowerShell 5.1, PowerShell 7 and cmd.exe. See
|
|
511
|
-
[COMPATIBILITY.md](COMPATIBILITY.md).
|
|
556
|
+
[COMPATIBILITY.md](COMPATIBILITY.md). On every push it runs on GitHub Actions
|
|
557
|
+
in the mirror (`.github/workflows/windows.yml`); on GitLab it runs on release
|
|
558
|
+
tags before `publish`, and by hand on other branches. The informational
|
|
559
|
+
large-file size probe runs on release tags only.
|
|
512
560
|
|
|
513
561
|
Release: bump `version` in `package.json` and both `version` fields in
|
|
514
562
|
`server.json` (a test checks they match), push a protected tag `vX.Y.Z`;
|
package/bin/sentinel-ir-mcp.js
CHANGED
|
@@ -12,7 +12,7 @@ const { normalizePattern, ExcludeError, MAX_PATTERNS } = require("../src/exclude
|
|
|
12
12
|
const { AuditLogError, defaultAuditDir, normalizeAuditDir, assertOutsideProjects } = require("../src/auditLog");
|
|
13
13
|
const { version } = require("../package.json");
|
|
14
14
|
|
|
15
|
-
const USAGE = `sentinel-ir-mcp ${version} — MCP server for sentinel-ir (JavaScript, TypeScript and
|
|
15
|
+
const USAGE = `sentinel-ir-mcp ${version} — MCP server for sentinel-ir (JavaScript, TypeScript, Python and Rust)
|
|
16
16
|
|
|
17
17
|
Usage: sentinel-ir-mcp [options] [folder ...]
|
|
18
18
|
sentinel-ir-mcp setup | doctor | guide | log [--help]
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sentinel-ir-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"mcpName": "io.github.jakcmencz/sentinel-ir-mcp",
|
|
5
|
-
"description": "MCP server for sentinel-ir: lets AI agents (Claude Desktop, Cursor, Windsurf, ...) read a compact structural IR of JavaScript, TypeScript and
|
|
5
|
+
"description": "MCP server for sentinel-ir: lets AI agents (Claude Desktop, Cursor, Windsurf, ...) read a compact structural IR of JavaScript, TypeScript, Python and Rust files instead of the raw source. Read-only, sandboxed to allowed folders.",
|
|
6
6
|
"main": "src/server.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"sentinel-ir-mcp": "bin/sentinel-ir-mcp.js"
|
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
23
|
-
"sentinel-ir": "^0.
|
|
23
|
+
"sentinel-ir": "^0.5.6",
|
|
24
24
|
"zod": "^4.1.0"
|
|
25
25
|
},
|
|
26
26
|
"keywords": [
|
|
27
27
|
"python",
|
|
28
|
+
"rust",
|
|
28
29
|
"mcp",
|
|
29
30
|
"model-context-protocol",
|
|
30
31
|
"mcp-server",
|
package/src/analyze.js
CHANGED
|
@@ -87,6 +87,25 @@ function realNative(p) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
const SKIP_DIRS = new Set(["node_modules", ".git", "__pycache__", "venv", "site-packages"]);
|
|
90
|
+
// Cargo marks its build folder with a CACHEDIR.TAG (cache directory tagging
|
|
91
|
+
// spec); a plain folder named "target" in a JS project is still scanned.
|
|
92
|
+
const CACHEDIR_SIGNATURE = "Signature: 8a477f597d28d172789f06886806bc55";
|
|
93
|
+
async function isCargoTarget(dir) {
|
|
94
|
+
let handle;
|
|
95
|
+
try {
|
|
96
|
+
const tag = path.join(dir, "CACHEDIR.TAG");
|
|
97
|
+
if (!(await fs.lstat(tag)).isFile()) return false;
|
|
98
|
+
handle = await fs.open(tag, OPEN_FLAGS);
|
|
99
|
+
const buffer = Buffer.alloc(CACHEDIR_SIGNATURE.length);
|
|
100
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
101
|
+
return buffer.toString("latin1", 0, bytesRead) === CACHEDIR_SIGNATURE;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
} finally {
|
|
105
|
+
await handle?.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
90
109
|
const RISK_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
91
110
|
|
|
92
111
|
async function analyzeCode(code, target, language) {
|
|
@@ -102,7 +121,7 @@ async function analyzeFile(requested, roots, { maxFileBytes, excluder = NONE, ha
|
|
|
102
121
|
const stat = await fs.stat(real);
|
|
103
122
|
if (!stat.isFile()) throw new AccessError(`Not a file: ${requested}`);
|
|
104
123
|
if (!isSourceFile(real)) {
|
|
105
|
-
throw new AccessError(`Unsupported file type: ${requested} (only .js, .mjs, .cjs, .ts, .mts, .cts, .tsx, .py and .
|
|
124
|
+
throw new AccessError(`Unsupported file type: ${requested} (only .js, .mjs, .cjs, .ts, .mts, .cts, .tsx, .py, .pyi and .rs)`);
|
|
106
125
|
}
|
|
107
126
|
if (stat.size > maxFileBytes) {
|
|
108
127
|
throw new AccessError(`File too large: ${requested} is ${stat.size} bytes, limit is ${maxFileBytes}`);
|
|
@@ -112,11 +131,12 @@ async function analyzeFile(requested, roots, { maxFileBytes, excluder = NONE, ha
|
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
// Breadth-first, sorted walk. Symlinks are never followed; node_modules, .git,
|
|
115
|
-
// __pycache__, venv, site-packages
|
|
134
|
+
// __pycache__, venv, site-packages, hidden folders and Cargo build folders
|
|
135
|
+
// (target/ with a CACHEDIR.TAG, listed in skipped.cargoTarget) are skipped. Excluded
|
|
116
136
|
// files and folders are only counted: their names can be private too.
|
|
117
137
|
async function collectFiles(dir, root, { maxFiles, maxFileBytes, recursive, excluder = NONE }) {
|
|
118
138
|
const files = [];
|
|
119
|
-
const skipped = { excluded: 0, tooLarge: [], symlinks: 0, unreadable: [], hardlinks: [] };
|
|
139
|
+
const skipped = { excluded: 0, tooLarge: [], symlinks: 0, unreadable: [], hardlinks: [], cargoTarget: [] };
|
|
120
140
|
// Exclusions match the real names: on Windows dir may be spelled with
|
|
121
141
|
// short names (SRC~1) that the patterns do not know.
|
|
122
142
|
const prefix = excluder.active ? path.relative(realNative(root), realNative(dir)) : "";
|
|
@@ -142,7 +162,9 @@ async function collectFiles(dir, root, { maxFiles, maxFileBytes, recursive, excl
|
|
|
142
162
|
} else if (entry.isSymbolicLink()) {
|
|
143
163
|
skipped.symlinks++;
|
|
144
164
|
} else if (entry.isDirectory()) {
|
|
145
|
-
if (recursive
|
|
165
|
+
if (!recursive || SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
166
|
+
if (entry.name === "target" && await isCargoTarget(full)) skipped.cargoTarget.push(rel);
|
|
167
|
+
else queue.push(full);
|
|
146
168
|
} else if (entry.isFile() && isSourceFile(entry.name)) {
|
|
147
169
|
let size;
|
|
148
170
|
try {
|
package/src/clients.js
CHANGED
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
const os = require("os");
|
|
4
4
|
const path = require("path");
|
|
5
5
|
|
|
6
|
+
const { version } = require("../package.json");
|
|
7
|
+
|
|
6
8
|
const SERVER_NAME = "sentinel-ir";
|
|
7
|
-
|
|
9
|
+
// An npm dist-tag, not a range: npx keeps starting a cached copy that still
|
|
10
|
+
// satisfies a range such as @0.5, but resolves a tag again on every start.
|
|
11
|
+
const STABLE_TAG = `stable-${version.split(".").slice(0, 2).join(".")}`;
|
|
12
|
+
const PACKAGE_SPEC = `sentinel-ir-mcp@${STABLE_TAG}`;
|
|
8
13
|
const WORKSPACE_VAR = "${workspaceFolder}";
|
|
9
14
|
|
|
10
15
|
function homeOf(env) {
|
|
@@ -169,6 +174,6 @@ function installLinks() {
|
|
|
169
174
|
}
|
|
170
175
|
|
|
171
176
|
module.exports = {
|
|
172
|
-
SERVER_NAME, PACKAGE_SPEC, WORKSPACE_VAR, CLIENTS, CLIENT_NAMES, FILE_CLIENTS,
|
|
177
|
+
SERVER_NAME, STABLE_TAG, PACKAGE_SPEC, WORKSPACE_VAR, CLIENTS, CLIENT_NAMES, FILE_CLIENTS,
|
|
173
178
|
homeOf, npxEntry, serverEntry, excludeArgs, entryExcludes, serverFlags, entryAuditLog, isWindowsNpxWrapper, shellQuote, powershellQuote, installCommand, installLinks
|
|
174
179
|
};
|
package/src/doctor.js
CHANGED
|
@@ -4,8 +4,8 @@ const fs = require("fs");
|
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
|
|
6
6
|
const { StdioClientTransport } = require("@modelcontextprotocol/sdk/client/stdio.js");
|
|
7
|
-
const { version } = require("../package.json");
|
|
8
|
-
const { SERVER_NAME, PACKAGE_SPEC, WORKSPACE_VAR, CLIENTS, FILE_CLIENTS, isWindowsNpxWrapper, entryExcludes, entryAuditLog, serverFlags } = require("./clients");
|
|
7
|
+
const { version, dependencies } = require("../package.json");
|
|
8
|
+
const { SERVER_NAME, PACKAGE_SPEC, homeOf, WORKSPACE_VAR, CLIENTS, FILE_CLIENTS, isWindowsNpxWrapper, entryExcludes, entryAuditLog, serverFlags } = require("./clients");
|
|
9
9
|
const { AuditLogError, defaultAuditDir, normalizeAuditDir, assertOutsideProjects, logFiles, dayName, createAuditLog } = require("./auditLog");
|
|
10
10
|
const { readConfig } = require("./setup");
|
|
11
11
|
const { createExcluder, ExcludeError } = require("./exclude");
|
|
@@ -16,6 +16,8 @@ const BIN = path.join(__dirname, "..", "bin", "sentinel-ir-mcp.js");
|
|
|
16
16
|
const EXPECTED_TOOLS = ["analyze_code", "analyze_directory", "analyze_file"];
|
|
17
17
|
const VALUE_OPTIONS = new Set(["--max-file-bytes", "--max-files", "--time-budget-ms", "--exclude"]);
|
|
18
18
|
|
|
19
|
+
const IR_MIN = dependencies["sentinel-ir"].replace(/^[\^~>=]+/, "");
|
|
20
|
+
|
|
19
21
|
function irVersion() {
|
|
20
22
|
try {
|
|
21
23
|
return require("sentinel-ir/package.json").version;
|
|
@@ -24,6 +26,87 @@ function irVersion() {
|
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
// true when version a is older than b ("unknown" or junk counts as older).
|
|
30
|
+
function olderThan(a, b) {
|
|
31
|
+
const pa = /^(\d+)\.(\d+)\.(\d+)/.exec(String(a));
|
|
32
|
+
if (!pa) return true;
|
|
33
|
+
const pb = b.split(".").map(Number);
|
|
34
|
+
for (let i = 0; i < 3; i++) if (Number(pa[i + 1]) !== pb[i]) return Number(pa[i + 1]) < pb[i];
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readVersion(file) {
|
|
39
|
+
try {
|
|
40
|
+
const v = JSON.parse(fs.readFileSync(file, "utf8")).version;
|
|
41
|
+
return typeof v === "string" ? v : "unknown";
|
|
42
|
+
} catch {
|
|
43
|
+
return "unknown";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The _npx/<key> folder this copy was started from, or null.
|
|
48
|
+
function npxCopyDir() {
|
|
49
|
+
const m = new RegExp(`^(.*\\${path.sep}_npx\\${path.sep}[^\\${path.sep}]+)\\${path.sep}`).exec(__dirname);
|
|
50
|
+
return m ? m[1] : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// npm's cache folder: the one this copy was started from by npx, else npm's default.
|
|
54
|
+
function npmCacheDir(env, platform) {
|
|
55
|
+
const marker = `${path.sep}_npx${path.sep}`;
|
|
56
|
+
const i = __dirname.lastIndexOf(marker);
|
|
57
|
+
if (i > 0) return __dirname.slice(0, i);
|
|
58
|
+
if (env.npm_config_cache) return env.npm_config_cache;
|
|
59
|
+
if (platform === "win32") return path.join(env.LOCALAPPDATA || path.join(homeOf(env), "AppData", "Local"), "npm-cache");
|
|
60
|
+
return path.join(homeOf(env), ".npm");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Copies of sentinel-ir-mcp that npx cached and whose sentinel-ir is older
|
|
64
|
+
// than this release needs. npx keeps starting such a copy for its spec.
|
|
65
|
+
function staleNpxCopies(cacheDir) {
|
|
66
|
+
const root = path.join(cacheDir, "_npx");
|
|
67
|
+
let keys;
|
|
68
|
+
try { keys = fs.readdirSync(root); } catch { return []; }
|
|
69
|
+
const stale = [];
|
|
70
|
+
for (const key of keys.sort()) {
|
|
71
|
+
const dir = path.join(root, key);
|
|
72
|
+
const mod = path.join(dir, "node_modules");
|
|
73
|
+
if (!fs.existsSync(path.join(mod, "sentinel-ir-mcp", "package.json"))) continue;
|
|
74
|
+
// npm nests sentinel-ir under the server when another package in this
|
|
75
|
+
// npx folder needs a different sentinel-ir at the top level.
|
|
76
|
+
const nested = path.join(mod, "sentinel-ir-mcp", "node_modules", "sentinel-ir", "package.json");
|
|
77
|
+
const ir = readVersion(fs.existsSync(nested) ? nested : path.join(mod, "sentinel-ir", "package.json"));
|
|
78
|
+
if (!olderThan(ir, IR_MIN)) continue;
|
|
79
|
+
let spec = "sentinel-ir-mcp";
|
|
80
|
+
try {
|
|
81
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
82
|
+
const named = pkg._npx && Array.isArray(pkg._npx.packages) && pkg._npx.packages.find((p) => /^sentinel-ir-mcp(@|$)/.test(p));
|
|
83
|
+
if (named) spec = named;
|
|
84
|
+
else if (pkg.dependencies && typeof pkg.dependencies["sentinel-ir-mcp"] === "string") spec = `sentinel-ir-mcp@${pkg.dependencies["sentinel-ir-mcp"]}`;
|
|
85
|
+
} catch { /* keep the bare name */ }
|
|
86
|
+
stale.push({ key, dir, spec, mcp: readVersion(path.join(mod, "sentinel-ir-mcp", "package.json")), ir });
|
|
87
|
+
}
|
|
88
|
+
return stale;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function checkNpxCache(env, platform, add) {
|
|
92
|
+
const cacheDir = npmCacheDir(env, platform);
|
|
93
|
+
const stale = staleNpxCopies(cacheDir);
|
|
94
|
+
if (stale.length === 0) {
|
|
95
|
+
add("ok", "npx.cache", `npx cache (${path.join(cacheDir, "_npx")}): no copy with sentinel-ir older than ${IR_MIN}`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const c of stale) {
|
|
99
|
+
add("warn", "npx.cache", `npx keeps starting a cached ${c.spec} (sentinel-ir-mcp ${c.mcp}, sentinel-ir ${c.ir}) from ${c.dir}; sentinel-ir older than ${IR_MIN} misses security fixes`,
|
|
100
|
+
`Close the client, delete that folder (npm 11: npm cache npx rm ${c.key}), then run: npx -y ${PACKAGE_SPEC} setup`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The version part of the configured sentinel-ir-mcp argument, "" when none.
|
|
105
|
+
function entrySpec(args) {
|
|
106
|
+
const a = args.find((x) => typeof x === "string" && /^sentinel-ir-mcp(@|$)/.test(x));
|
|
107
|
+
return a ? a.slice("sentinel-ir-mcp@".length) : "";
|
|
108
|
+
}
|
|
109
|
+
|
|
27
110
|
// Folders the configured entry passes to the server (arguments after the package).
|
|
28
111
|
function entryFolders(args) {
|
|
29
112
|
const i = args.findIndex((a) => typeof a === "string" && /^sentinel-ir-mcp(@|$)/.test(a));
|
|
@@ -84,8 +167,10 @@ function checkConfig(client, file, explicit, project, add, platform, env) {
|
|
|
84
167
|
const runs = `${label}: "${SERVER_NAME}" runs ${entry.command} ${args.join(" ")}`;
|
|
85
168
|
if (entry.command === "npx" && platform === "win32") {
|
|
86
169
|
add("warn", `${client}.entry`, runs, `On Windows a client may not find "npx" without a shell. Run: npx -y ${PACKAGE_SPEC} setup --client ${client}`);
|
|
87
|
-
} else if (
|
|
88
|
-
add("warn", `${client}.entry`, runs, `This entry stays on an older version without
|
|
170
|
+
} else if (/^[\^~=v]*0\.[0-4](\.|$)/.test(entrySpec(args))) {
|
|
171
|
+
add("warn", `${client}.entry`, runs, `This entry stays on an older version without Rust support. Run: npx -y ${PACKAGE_SPEC} setup --client ${client}`);
|
|
172
|
+
} else if (/^[\^~=v<>]*\d/.test(entrySpec(args))) {
|
|
173
|
+
add("warn", `${client}.entry`, runs, `npx keeps starting the copy it downloaded first for a version or range and misses later fixes. Run: npx -y ${PACKAGE_SPEC} setup --client ${client}`);
|
|
89
174
|
} else if (entry.command === "npx" || isWindowsNpxWrapper(entry)) {
|
|
90
175
|
add("ok", `${client}.entry`, runs);
|
|
91
176
|
} else {
|
|
@@ -189,7 +274,12 @@ async function runDoctor(opts, io) {
|
|
|
189
274
|
const nodeVersion = io.nodeVersion || process.versions.node;
|
|
190
275
|
if (Number(nodeVersion.split(".")[0]) >= 20) add("ok", "node", `Node.js v${nodeVersion}`);
|
|
191
276
|
else add("fail", "node", `Node.js v${nodeVersion} is too old`, "Install Node.js 20 or newer.");
|
|
192
|
-
|
|
277
|
+
const ir = irVersion();
|
|
278
|
+
if (olderThan(ir, IR_MIN)) {
|
|
279
|
+
add("fail", "package", `sentinel-ir-mcp ${version} runs sentinel-ir ${ir}, needs ${IR_MIN} or newer`,
|
|
280
|
+
npxCopyDir() ? `Close the client, delete ${npxCopyDir()}, then run: npx -y ${PACKAGE_SPEC} setup` : `Reinstall it so that npm installs sentinel-ir ${dependencies["sentinel-ir"]}.`);
|
|
281
|
+
} else add("ok", "package", `sentinel-ir-mcp ${version}, sentinel-ir ${ir}`);
|
|
282
|
+
checkNpxCache(io.env, io.platform, add);
|
|
193
283
|
|
|
194
284
|
const project = path.resolve(io.cwd, opts.project || ".");
|
|
195
285
|
if (opts.client && !Object.prototype.hasOwnProperty.call(CLIENTS, opts.client)) {
|
|
@@ -238,4 +328,4 @@ async function runDoctor(opts, io) {
|
|
|
238
328
|
return failed ? 1 : 0;
|
|
239
329
|
}
|
|
240
330
|
|
|
241
|
-
module.exports = { runDoctor, entryFolders };
|
|
331
|
+
module.exports = { runDoctor, entryFolders, olderThan, staleNpxCopies, IR_MIN };
|
package/src/paths.js
CHANGED
|
@@ -7,7 +7,8 @@ const { fileURLToPath } = require("url");
|
|
|
7
7
|
const JS_EXTENSIONS = new Set([".js", ".mjs", ".cjs"]);
|
|
8
8
|
const TS_EXTENSIONS = new Set([".ts", ".mts", ".cts", ".tsx"]);
|
|
9
9
|
const PY_EXTENSIONS = new Set([".py", ".pyi"]);
|
|
10
|
-
const
|
|
10
|
+
const RS_EXTENSIONS = new Set([".rs"]);
|
|
11
|
+
const SOURCE_EXTENSIONS = new Set([...JS_EXTENSIONS, ...TS_EXTENSIONS, ...PY_EXTENSIONS, ...RS_EXTENSIONS]);
|
|
11
12
|
|
|
12
13
|
class AccessError extends Error {}
|
|
13
14
|
|
package/src/server.js
CHANGED
|
@@ -39,10 +39,10 @@ const RISK_NOTE =
|
|
|
39
39
|
"is unknown: e.g. readFileSync(req.query.f) is low. Start with critical files, and report a problem only after reading the code.";
|
|
40
40
|
|
|
41
41
|
const INSTRUCTIONS =
|
|
42
|
-
"sentinel-ir-mcp turns JavaScript, TypeScript and
|
|
42
|
+
"sentinel-ir-mcp turns JavaScript, TypeScript, Python and Rust source into a compact structural IR (risk level, dangerous " +
|
|
43
43
|
"capabilities, routes, exports, imports, env reads, network/filesystem/process/crypto calls). " +
|
|
44
44
|
"Prefer analyze_file or analyze_directory over reading raw source when you only need structure " +
|
|
45
|
-
"or risk, then read the raw source of the files that matter. Supported: JavaScript (.js, .mjs, .cjs), TypeScript (.ts, .mts, .cts, .tsx)
|
|
45
|
+
"or risk, then read the raw source of the files that matter. Supported: JavaScript (.js, .mjs, .cjs), TypeScript (.ts, .mts, .cts, .tsx), Python (.py, .pyi) and Rust (.rs); the language is picked from the file extension. " +
|
|
46
46
|
"Rely on analysisStatus, world.risk, forbidden and facts; analysisStatus \"incomplete\" means the file did not parse cleanly. " +
|
|
47
47
|
"analyze_directory lists such files separately in 'incomplete'; their low risk is not evidence of safety. " +
|
|
48
48
|
"Suggested order: analyze_directory first, check 'incomplete', 'skipped.unreadable' and 'notAnalyzed' (set when the time budget ran out), pick the relevant files, " +
|
|
@@ -131,45 +131,45 @@ function createServer(options = {}) {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
server.registerTool("analyze_code", {
|
|
134
|
-
title: "Analyze JavaScript, TypeScript or
|
|
134
|
+
title: "Analyze JavaScript, TypeScript, Python or Rust source",
|
|
135
135
|
description:
|
|
136
|
-
"Analyze JavaScript, TypeScript or
|
|
136
|
+
"Analyze JavaScript, TypeScript, Python or Rust source text and return its compact structural IR: " +
|
|
137
137
|
"{analysisStatus, language, mission, world.risk, constraints, forbidden, summary, facts}. " +
|
|
138
138
|
"Use analyze_file instead when the code is on disk, so the source does not have to pass " +
|
|
139
139
|
"through the conversation. " + UNTRUSTED_NOTE,
|
|
140
140
|
inputSchema: {
|
|
141
|
-
code: z.string().max(limits.maxFileBytes).describe("JavaScript, TypeScript or
|
|
142
|
-
target: z.string().max(1024).optional().describe("Optional file name shown as mission.target; its extension (.ts, .tsx, .py, ...) selects the language"),
|
|
143
|
-
language: z.enum(["javascript", "typescript", "tsx", "python"]).optional().describe("Overrides the language picked from target (default javascript)")
|
|
141
|
+
code: z.string().max(limits.maxFileBytes).describe("JavaScript, TypeScript, Python or Rust source"),
|
|
142
|
+
target: z.string().max(1024).optional().describe("Optional file name shown as mission.target; its extension (.ts, .tsx, .py, .rs, ...) selects the language"),
|
|
143
|
+
language: z.enum(["javascript", "typescript", "tsx", "python", "rust"]).optional().describe("Overrides the language picked from target (default javascript)")
|
|
144
144
|
},
|
|
145
|
-
annotations: { title: "Analyze JavaScript, TypeScript or
|
|
145
|
+
annotations: { title: "Analyze JavaScript, TypeScript, Python or Rust source", ...READ_ONLY }
|
|
146
146
|
}, ({ code, target, language }) => guarded(() => analyzeCode(code, target, language), auditor("analyze_code", { code, target, language })));
|
|
147
147
|
|
|
148
148
|
server.registerTool("analyze_file", {
|
|
149
|
-
title: "Analyze a JavaScript, TypeScript or
|
|
149
|
+
title: "Analyze a JavaScript, TypeScript, Python or Rust file",
|
|
150
150
|
description:
|
|
151
|
-
"Read one .js/.mjs/.cjs/.ts/.mts/.cts/.tsx/.py/.pyi file inside the allowed folders and return its compact structural IR. " +
|
|
151
|
+
"Read one .js/.mjs/.cjs/.ts/.mts/.cts/.tsx/.py/.pyi/.rs file inside the allowed folders and return its compact structural IR. " +
|
|
152
152
|
`Relative paths are resolved against the first allowed folder. Files over ${limits.maxFileBytes} bytes are refused. ` +
|
|
153
153
|
UNTRUSTED_NOTE + excludeNote,
|
|
154
154
|
inputSchema: {
|
|
155
155
|
path: z.string().min(1).describe("File path, absolute or relative to the first allowed folder")
|
|
156
156
|
},
|
|
157
|
-
annotations: { title: "Analyze a JavaScript, TypeScript or
|
|
157
|
+
annotations: { title: "Analyze a JavaScript, TypeScript, Python or Rust file", ...READ_ONLY }
|
|
158
158
|
}, async ({ path }) => {
|
|
159
159
|
const hashes = auditLog.active ? new Map() : null;
|
|
160
160
|
return guarded(async () => analyzeFile(path, await allowedRoots(), { ...limits, excluder, hashes }), auditor("analyze_file", { path, hashes }));
|
|
161
161
|
});
|
|
162
162
|
|
|
163
163
|
server.registerTool("analyze_directory", {
|
|
164
|
-
title: "Analyze a folder of JavaScript, TypeScript and
|
|
164
|
+
title: "Analyze a folder of JavaScript, TypeScript, Python and Rust files",
|
|
165
165
|
description:
|
|
166
|
-
"Scan a folder inside the allowed folders and return one short entry per JavaScript, TypeScript or
|
|
166
|
+
"Scan a folder inside the allowed folders and return one short entry per JavaScript, TypeScript, Python or Rust file, " +
|
|
167
167
|
"sorted from highest to lowest risk, plus risk counts. By default (detail \"summary\") each entry is " +
|
|
168
168
|
"{path, risk, analysisStatus, language, lines, operations, riskSignals, moreRiskSignals?, parseError?}: " +
|
|
169
169
|
"riskSignals are up to 3 'kind:code@line' signals (code cut to 120 characters) that set the file's risk level (empty when the risk comes from " +
|
|
170
170
|
"the text fallback of an incomplete file or from a path containing 'crypto'), moreRiskSignals counts the rest. " +
|
|
171
171
|
"detail \"full\" adds forbidden, objective and all facts per file instead (much larger). " + RISK_NOTE + " " +
|
|
172
|
-
"Skips node_modules, .git, __pycache__, venv, site-packages, hidden folders " +
|
|
172
|
+
"Skips node_modules, .git, __pycache__, venv, site-packages, hidden folders, Cargo build folders (target/ with a CACHEDIR.TAG, listed in skipped.cargoTarget) " +
|
|
173
173
|
`and symlinks. Analyzes at most max_files files (default ${limits.maxFiles}); 'truncated' tells ` +
|
|
174
174
|
"you when there were more. 'incomplete' lists files whose analysis did not finish (e.g. parse errors); " +
|
|
175
175
|
"their risk is not reliable, check them separately. statusCounts counts complete vs incomplete. " +
|
|
@@ -186,7 +186,7 @@ function createServer(options = {}) {
|
|
|
186
186
|
detail: z.enum(["summary", "full"]).optional()
|
|
187
187
|
.describe("\"summary\" (default): short entries; \"full\": all facts per file, much larger")
|
|
188
188
|
},
|
|
189
|
-
annotations: { title: "Analyze a folder of JavaScript, TypeScript and
|
|
189
|
+
annotations: { title: "Analyze a folder of JavaScript, TypeScript, Python and Rust files", ...READ_ONLY }
|
|
190
190
|
}, async ({ path, max_files, recursive, detail }) => {
|
|
191
191
|
const hashes = auditLog.active ? new Map() : null;
|
|
192
192
|
return guarded(async () => analyzeDirectory(path ?? ".", await allowedRoots(), {
|