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 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 Python files instead of the raw source.
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) and Python (`.py`, `.pyi`; since 0.3.0). The
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.4 log` prints the last
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.4 setup --client cursor # or windsurf, claude-desktop, vscode, claude-code
174
- npx -y sentinel-ir-mcp@0.4 doctor --client cursor # after restarting the client
175
- npx -y sentinel-ir-mcp@0.4 guide # optional: text for AGENTS.md / client rules
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.4`, so clients get 0.4.x fixes but no unexpected
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.4", "<folder>"]`: there `npx` is `npx.cmd`, and starting it
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 0.2.x
229
-
230
- `npx` keeps the package it downloaded for `sentinel-ir-mcp@0.2` and keeps
231
- starting that copy, even when a newer 0.2.x is on npm (measured with npm
232
- 10.8.2, 10.8.3 and 10.9.9: a cached 0.2.2 kept running after 0.2.3 was
233
- released; `--prefer-online` and `npm cache clean --force` did not change it).
234
- To get the newest 0.2.x:
235
-
236
- 1. Check what runs: `npx -y sentinel-ir-mcp@0.2 doctor` prints
237
- `sentinel-ir-mcp <version>`.
238
- 2. Close the client app and delete the `_npx` folder in npm's cache
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`). This removes all packages cached
241
- by `npx`, not only this one; they are downloaded again when needed.
242
- 3. Start the client again. The next start downloads the newest 0.2.x.
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=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsInNlbnRpbmVsLWlyLW1jcEAwLjMiLCIke3dvcmtzcGFjZUZvbGRlcn0iXX0=)
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%400.3%22%2C%22%24%7BworkspaceFolder%7D%22%5D%7D)
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.4", "/Users/you/projects/my-app"]
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.4", "${workspaceFolder}"]
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.4", "<folder>"]`
320
- (on Windows: `command: "cmd"`, `args: ["/c", "npx", "-y", "sentinel-ir-mcp@0.4", "<folder>"]`).
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.4 setup --client cursor --exclude secrets --exclude "*.min.js"
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.4 setup --client cursor --audit-log # or answer "y" in setup
354
- npx -y sentinel-ir-mcp@0.4 log # what the agent asked for
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`;
@@ -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 Python)
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.4.6",
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 Python files instead of the raw source. Read-only, sandboxed to allowed folders.",
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.4.5",
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 .pyi)`);
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 and hidden folders are skipped. Excluded
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 && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) queue.push(full);
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
- const PACKAGE_SPEC = "sentinel-ir-mcp@0.4";
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 (args.some(a => /^sentinel-ir-mcp@0\.[0-3](\.|$)/.test(a))) {
88
- 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}`);
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
- add("ok", "package", `sentinel-ir-mcp ${version}, sentinel-ir ${irVersion()}`);
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 SOURCE_EXTENSIONS = new Set([...JS_EXTENSIONS, ...TS_EXTENSIONS, ...PY_EXTENSIONS]);
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 Python source into a compact structural IR (risk level, dangerous " +
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) and Python (.py, .pyi); the language is picked from the file extension. " +
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 Python source",
134
+ title: "Analyze JavaScript, TypeScript, Python or Rust source",
135
135
  description:
136
- "Analyze JavaScript, TypeScript or Python source text and return its compact structural IR: " +
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 Python source"),
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 Python source", ...READ_ONLY }
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 Python file",
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 Python file", ...READ_ONLY }
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 Python files",
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 Python file, " +
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 Python files", ...READ_ONLY }
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(), {