sentinel-ir-mcp 0.5.3 → 0.5.5

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
@@ -28,6 +28,21 @@ of safety. Other languages are not supported yet. What is detected in Python
28
28
  and Rust (and what is not) is listed in the
29
29
  [sentinel-ir README](https://gitlab.com/jakubrojicek11/sentinel-ir#python).
30
30
 
31
+ **0.5.5** fixes the update check in `doctor`: it goes through the proxy npm
32
+ would use (`npm_config_https_proxy`/`npm_config_proxy`, `HTTPS_PROXY`/`HTTP_PROXY`,
33
+ `NO_PROXY`), follows up to 3 registry redirects (never from https to http), and
34
+ says what it found: "newer than npm's stable-0.5 (0.5.3)" when this copy is
35
+ ahead of npm, and "points to ..., not a release version" for a pre-release
36
+ or broken tag instead of "npm has no tag".
37
+
38
+ **0.5.4** makes `doctor` notice an old copy of itself: it asks npm for the
39
+ newest release on its `stable-X.Y` tag (3 s timeout, skipped offline) and,
40
+ when that is newer, warns and says where the old copy runs from (npx cache,
41
+ global install or another folder) and how to replace it. Before, `npx
42
+ sentinel-ir-mcp doctor` without a version could run an old copy and report
43
+ `[ok]`. The last line now counts warnings (`All checks passed, 1 warning
44
+ above.`).
45
+
31
46
  **0.5.3** splits each tool description into short lines (what it returns,
32
47
  parameters, risk scale, when to use another tool) and names the sibling tools
33
48
  in all three; `analyze_code` no longer repeats the language rule its schema
@@ -257,7 +272,8 @@ without a shell failed with `ENOENT` on a real Windows runner. For VS Code and
257
272
  Claude Code, `setup` prints a PowerShell and a Command Prompt version of the
258
273
  command. What was measured on which system: [COMPATIBILITY.md](COMPATIBILITY.md).
259
274
 
260
- `doctor` checks Node.js, the package version, the client config (valid JSON, a
275
+ `doctor` checks Node.js, the package version (and asks the npm registry, or
276
+ `npm_config_registry`, through npm's proxy settings, whether its `stable-X.Y` tag has a newer release), the client config (valid JSON, a
261
277
  `"sentinel-ir"` entry that starts `sentinel-ir-mcp`, existing folders), then
262
278
  starts **its own** server with the same folders, performs a real MCP
263
279
  handshake, expects exactly the three tools and calls `analyze_directory` with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinel-ir-mcp",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "mcpName": "io.github.jakcmencz/sentinel-ir-mcp",
5
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",
package/src/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const readline = require("readline");
4
4
  const { runSetup } = require("./setup");
5
- const { runDoctor } = require("./doctor");
5
+ const { runDoctor, fetchDistTagsIsolated } = require("./doctor");
6
6
  const { GUIDE } = require("./guide");
7
7
  const { runLog } = require("./logView");
8
8
  const { CLIENT_NAMES, PACKAGE_SPEC } = require("./clients");
@@ -103,6 +103,7 @@ function defaultIo() {
103
103
  env: process.env,
104
104
  platform: process.platform,
105
105
  cwd: process.cwd(),
106
+ fetchDistTags: fetchDistTagsIsolated,
106
107
  isTTY,
107
108
  prompt: isTTY ? (question) => new Promise((resolve) => {
108
109
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
package/src/doctor.js CHANGED
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
 
3
3
  const fs = require("fs");
4
+ const http = require("http");
5
+ const https = require("https");
6
+ const net = require("net");
4
7
  const path = require("path");
5
8
  const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
6
9
  const { StdioClientTransport } = require("@modelcontextprotocol/sdk/client/stdio.js");
@@ -44,12 +47,255 @@ function readVersion(file) {
44
47
  }
45
48
  }
46
49
 
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
+ const PACKAGE_DIR = path.join(__dirname, "..");
51
+
52
+ // The _npx/<key> folder the package in dir was started from, or null.
53
+ function npxCopyDir(dir = PACKAGE_DIR) {
54
+ const m = /^(.*[\\/]_npx[\\/][^\\/]+)[\\/]/.exec(dir);
50
55
  return m ? m[1] : null;
51
56
  }
52
57
 
58
+ // The prefix line of an npmrc file (npm's ${VAR} and ~/ expanded), or null.
59
+ function npmrcPrefix(file, env, home) {
60
+ let text;
61
+ try { text = fs.readFileSync(file, "utf8"); } catch { return null; }
62
+ const m = /^[ \t]*prefix[ \t]*=[ \t]*(.*?)[ \t]*$/m.exec(text);
63
+ if (!m || !m[1]) return null;
64
+ // as npm's ini: a quoted value is literal, an unquoted one ends at an unescaped ; or #
65
+ const q = /^(["'])(.*)\1$/.exec(m[1]);
66
+ let v = (q ? q[2] : m[1].replace(/(^|[^\\])[;#].*$/, "$1").trim().replace(/\\([;#\\])/g, "$1")).replace(/\$\{([^}]+)\}/g, (_, k) => env[k] || "");
67
+ if (/^~[\\/]/.test(v) && home) v = path.join(home, v.slice(2));
68
+ return v;
69
+ }
70
+
71
+ // Where npm installs a global sentinel-ir-mcp: the first entry is npm's prefix now; the rest are other
72
+ // places a global install may sit (also %APPDATA%\\npm).
73
+ function globalDirs(env, platform, execPath) {
74
+ const p = platform === "win32" ? path.win32 : path.posix;
75
+ const nodePrefix = platform === "win32" ? p.dirname(execPath) : p.dirname(p.dirname(execPath));
76
+ const home = env.HOME || env.USERPROFILE;
77
+ const cfg = (k) => env[`npm_config_${k}`] || env[`NPM_CONFIG_${k.toUpperCase()}`];
78
+ // npm's prefix: environment, then user, global and builtin npmrc (Homebrew sets it there), then the default
79
+ const builtinPrefix = npmrcPrefix(p.join(nodePrefix, ...(platform === "win32" ? [] : ["lib"]), "node_modules", "npm", "npmrc"), env, home);
80
+ const prefixes = [cfg("prefix"),
81
+ npmrcPrefix(cfg("userconfig") || (home ? p.join(home, ".npmrc") : ""), env, home),
82
+ npmrcPrefix(cfg("globalconfig") || p.join(builtinPrefix || nodePrefix, "etc", "npmrc"), env, home),
83
+ builtinPrefix,
84
+ nodePrefix,
85
+ platform === "win32" && env.APPDATA ? p.join(env.APPDATA, "npm") : null];
86
+ const dirs = prefixes.filter(Boolean).map((x) => p.join(x, ...(platform === "win32" ? [] : ["lib"]), "node_modules", "sentinel-ir-mcp"));
87
+ return [...new Set(dirs)];
88
+ }
89
+
90
+ const realDir = (d) => { try { return fs.realpathSync(d); } catch { return d; } };
91
+ const sameDir = (a, b) => a.replace(/[\\/]+$/, "").toLowerCase() === b.replace(/[\\/]+$/, "").toLowerCase();
92
+
93
+ // Where the package in dir is installed: an npx cache copy, a global install or elsewhere.
94
+ function installKind(dir, globals = []) {
95
+ const npx = npxCopyDir(dir);
96
+ if (npx) return { kind: "npx", dir: npx, key: /[^\\/]+$/.exec(npx)[0] };
97
+ if (/[\\/](lib|npm)[\\/]node_modules[\\/]sentinel-ir-mcp[\\/]?$/i.test(dir) || globals.some((g) => sameDir(g, dir))) return { kind: "global", dir };
98
+ return { kind: "other", dir };
99
+ }
100
+
101
+ const REDIRECTS = new Set([301, 302, 303, 307, 308]);
102
+
103
+ const unescapeLogin = (s) => { try { return decodeURIComponent(s); } catch { return s; } };
104
+
105
+ // The proxy npm would use for url (npm's https-proxy and proxy settings, then HTTPS_PROXY/HTTP_PROXY), null for none,
106
+ // false for a proxy setting doctor cannot use (it then skips instead of going direct).
107
+ function proxyFor(url, env) {
108
+ const noProxy = env.npm_config_noproxy || env.NO_PROXY || env.no_proxy || "";
109
+ const host = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
110
+ for (const rule of noProxy.split(/[\s,]+/).filter(Boolean)) {
111
+ const bare = /^\[(.*)\](:\d+)?$/.exec(rule)?.[1] ?? (rule.split(":").length === 2 ? rule.replace(/:\d+$/, "") : rule);
112
+ const r = bare.replace(/^\*?\.?/, "").replace(/\.$/, "").toLowerCase();
113
+ if (rule === "*" || host === r || host.endsWith("." + r)) return null;
114
+ }
115
+ // npm: its https-proxy, then proxy setting, for http registries too; then the environment.
116
+ const raw = env.npm_config_https_proxy || env.npm_config_proxy || env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy;
117
+ if (!raw) return null;
118
+ try {
119
+ const p = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`);
120
+ return p.protocol === "http:" || p.protocol === "https:" ? p : false;
121
+ } catch { return false; }
122
+ }
123
+
124
+ // GET url directly or through proxy; calls back with the response. Adds every request it opens to open.
125
+ function openGet(url, env, tls, open, onResponse, onFail) {
126
+ try { openGetOrThrow(url, env, tls, open, onResponse, onFail); } catch { onFail(); }
127
+ }
128
+
129
+ function openGetOrThrow(url, env, tls, open, onResponse, onFail) {
130
+ // The registry login goes in a header we build: Node throws on a malformed % in URL credentials.
131
+ const bare = new URL(url.href);
132
+ bare.username = bare.password = "";
133
+ const headers = { accept: "application/json",
134
+ ...(url.username || url.password ? { authorization: "Basic " + Buffer.from(`${unescapeLogin(url.username)}:${unescapeLogin(url.password)}`).toString("base64") } : {}) };
135
+ const proxy = proxyFor(url, env);
136
+ if (proxy === false) { onFail(); return; }
137
+ if (!proxy) {
138
+ const req = (url.protocol === "https:" ? https : http).get(bare, { headers, ...tls }, onResponse);
139
+ req.on("error", onFail);
140
+ open.push(req);
141
+ return;
142
+ }
143
+ const proxyLib = proxy.protocol === "https:" ? https : http;
144
+ const auth = proxy.username
145
+ ? { "proxy-authorization": "Basic " + Buffer.from(`${unescapeLogin(proxy.username)}:${unescapeLogin(proxy.password)}`).toString("base64") }
146
+ : {};
147
+ const target = { host: proxy.hostname.replace(/^\[|\]$/g, ""), port: proxy.port || (proxy.protocol === "https:" ? 443 : 80) };
148
+ if (url.protocol === "http:") {
149
+ const req = proxyLib.get({ ...target, path: bare.href, headers: { ...headers, ...auth, host: bare.host } }, onResponse);
150
+ req.on("error", onFail);
151
+ open.push(req);
152
+ return;
153
+ }
154
+ const connect = proxyLib.request({ ...target, method: "CONNECT", path: `${url.hostname}:${url.port || 443}`, headers: { ...auth, host: `${url.hostname}:${url.port || 443}` } });
155
+ connect.on("connect", (res, socket) => {
156
+ if (res.statusCode !== 200) { socket.destroy(); onFail(); return; }
157
+ const req = https.get(bare, {
158
+ headers, ...tls,
159
+ createConnection: () => require("tls").connect({ socket, host: url.hostname.replace(/^\[|\]$/g, ""), servername: net.isIP(url.hostname.replace(/^\[|\]$/g, "")) ? undefined : url.hostname, ...tls })
160
+ }, onResponse);
161
+ req.on("error", onFail);
162
+ open.push(req);
163
+ });
164
+ connect.on("error", onFail);
165
+ connect.end();
166
+ open.push(connect);
167
+ }
168
+
169
+ // npm's strict-ssl and cafile settings as TLS options; null when the cafile is unreadable (npm fails then too).
170
+ function npmTls(env) {
171
+ const t = {};
172
+ if (/^false$/i.test(env.npm_config_strict_ssl || "")) t.rejectUnauthorized = false;
173
+ if (env.npm_config_cafile) {
174
+ try { t.ca = [...require("tls").rootCertificates, fs.readFileSync(env.npm_config_cafile, "utf8")]; } catch { return null; }
175
+ }
176
+ return t;
177
+ }
178
+
179
+ // npm dist-tags of sentinel-ir-mcp, or null when the registry does not answer in time.
180
+ // Uses npm_config_registry and the proxy npm would use; follows up to 3 redirects.
181
+ function fetchDistTags(env, timeoutMs = 3000, tls = npmTls(env)) {
182
+ return new Promise((resolve) => {
183
+ if (tls === null) { resolve(null); return; }
184
+ let open = [];
185
+ let done = false;
186
+ const finish = (v) => {
187
+ if (done) return;
188
+ done = true;
189
+ clearTimeout(timer);
190
+ for (const r of open) r.destroy();
191
+ resolve(v);
192
+ };
193
+ const timer = setTimeout(() => finish(null), timeoutMs);
194
+ const get = (url, hops) => {
195
+ if (url.protocol !== "http:" && url.protocol !== "https:") { finish(null); return; }
196
+ openGet(url, env, tls, open, (res) => {
197
+ if (REDIRECTS.has(res.statusCode) && res.headers.location && hops < 3) {
198
+ res.destroy();
199
+ let next;
200
+ try { next = new URL(res.headers.location, url); } catch { finish(null); return; }
201
+ if (url.protocol === "https:" && next.protocol !== "https:") { finish(null); return; }
202
+ get(next, hops + 1);
203
+ return;
204
+ }
205
+ if (res.statusCode !== 200) { res.resume(); finish(null); return; }
206
+ const chunks = [];
207
+ let size = 0;
208
+ res.on("data", (d) => { chunks.push(d); size += d.length; if (size > 65536) finish(null); });
209
+ res.on("end", () => {
210
+ try {
211
+ const tags = JSON.parse(Buffer.concat(chunks).toString("utf8"));
212
+ finish(tags && typeof tags === "object" && !Array.isArray(tags) ? tags : null);
213
+ } catch { finish(null); }
214
+ });
215
+ res.on("error", () => finish(null));
216
+ res.on("aborted", () => finish(null));
217
+ }, () => finish(null));
218
+ };
219
+ let url;
220
+ try {
221
+ url = new URL("-/package/sentinel-ir-mcp/dist-tags", (env.npm_config_registry || "https://registry.npmjs.org/").replace(/\/?$/, "/"));
222
+ } catch { finish(null); return; }
223
+ get(url, 0);
224
+ });
225
+ }
226
+
227
+ // fetchDistTags in a child process that is killed on timeout: a DNS lookup that never answers
228
+ // cannot be cancelled and would keep doctor's own process alive for up to 30 s.
229
+ function fetchDistTagsIsolated(env, timeoutMs = 3000) {
230
+ return new Promise((resolve) => {
231
+ let out = "";
232
+ let child;
233
+ let done = false;
234
+ const finish = (v) => {
235
+ if (done) return;
236
+ done = true;
237
+ clearTimeout(timer);
238
+ if (child) child.kill();
239
+ resolve(v);
240
+ };
241
+ const timer = setTimeout(() => finish(null), timeoutMs + 1000);
242
+ try {
243
+ child = require("child_process").spawn(process.execPath,
244
+ ["-e", `require(process.argv[1]).fetchDistTags(process.env, ${Number(timeoutMs)}).then((t) => process.stdout.write(JSON.stringify(t) + "\\n"))`, __filename],
245
+ { env, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
246
+ } catch { finish(null); return; }
247
+ child.on("error", () => finish(null));
248
+ child.on("close", () => finish(null));
249
+ child.stdout.on("data", (d) => {
250
+ out += d;
251
+ if (!out.includes("\n")) return;
252
+ let tags = null;
253
+ try { tags = JSON.parse(out); } catch { /* unusable */ }
254
+ finish(tags && typeof tags === "object" && !Array.isArray(tags) ? tags : null);
255
+ });
256
+ });
257
+ }
258
+
259
+ // Warns when npm has a newer release on this version's stable-X.Y tag; says where this copy runs from.
260
+ async function checkUpdate(io, add) {
261
+ if (typeof io.fetchDistTags !== "function") return;
262
+ const m = /^(\d+)\.(\d+)\./.exec(version);
263
+ const tag = m ? `stable-${m[1]}.${m[2]}` : "latest";
264
+ const tags = await io.fetchDistTags(io.env);
265
+ if (!tags) {
266
+ add("skip", "package.update", "No usable answer from the npm registry about a newer sentinel-ir-mcp (offline, blocked or an unexpected reply)");
267
+ return;
268
+ }
269
+ const newest = Object.prototype.hasOwnProperty.call(tags, tag) ? tags[tag] : undefined;
270
+ if (newest === undefined) {
271
+ add("skip", "package.update", `npm has no ${tag} tag for sentinel-ir-mcp`);
272
+ return;
273
+ }
274
+ if (typeof newest !== "string" || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(newest)) {
275
+ const shown = JSON.stringify(newest) ?? String(newest);
276
+ add("skip", "package.update", `npm's ${tag} tag points to ${shown.length > 40 ? shown.slice(0, 40) + "..." : shown}, not a release version; no update check`);
277
+ return;
278
+ }
279
+ if (newest === version) {
280
+ add("ok", "package.update", `sentinel-ir-mcp ${version} is the newest ${tag} on npm`);
281
+ return;
282
+ }
283
+ if (!olderThan(version, newest)) {
284
+ add("ok", "package.update", `sentinel-ir-mcp ${version} is newer than npm's ${tag} (${newest})`);
285
+ return;
286
+ }
287
+ const spec = `sentinel-ir-mcp@${tag}`;
288
+ const globals = globalDirs(io.env, io.platform, io.execPath || process.execPath).flatMap((g) => [g, realDir(g)]);
289
+ const from = installKind(io.packageDir || PACKAGE_DIR, globals);
290
+ const where = { npx: `the npx cache (${from.dir})`, global: `a global install (${from.dir})`, other: from.dir }[from.kind];
291
+ const fix = {
292
+ npx: `Close the client, delete that folder (npm 11: npm cache npx rm ${from.key}), then run: npx -y ${spec} setup`,
293
+ global: `Run: npm install -g ${spec} (or npm uninstall -g sentinel-ir-mcp), then: npx -y ${spec} doctor`,
294
+ other: `Run the current version: npx -y ${spec} doctor`
295
+ }[from.kind];
296
+ add("warn", "package.update", `This is sentinel-ir-mcp ${version}, npm has ${newest} (${tag}); this copy runs from ${where}`, fix);
297
+ }
298
+
53
299
  // npm's cache folder: the one this copy was started from by npx, else npm's default.
54
300
  function npmCacheDir(env, platform) {
55
301
  const marker = `${path.sep}_npx${path.sep}`;
@@ -88,6 +334,21 @@ function staleNpxCopies(cacheDir) {
88
334
  return stale;
89
335
  }
90
336
 
337
+ // A global install other than this copy that is older or runs an old sentinel-ir:
338
+ // bare `npx sentinel-ir-mcp` and client entries without a version start it.
339
+ function checkGlobalInstall(io, add) {
340
+ const running = io.packageDir || PACKAGE_DIR;
341
+ // only npm's current prefix: bare npx starts the global install there, not one left under an old prefix
342
+ for (const dir of globalDirs(io.env, io.platform, io.execPath || process.execPath).slice(0, 1)) {
343
+ if (sameDir(dir, running) || sameDir(realDir(dir), realDir(running)) || !fs.existsSync(path.join(dir, "package.json"))) continue;
344
+ const mcp = readVersion(path.join(dir, "package.json"));
345
+ const ir = readVersion(path.join(dir, "node_modules", "sentinel-ir", "package.json"));
346
+ if (!olderThan(mcp, version) && !olderThan(ir, IR_MIN)) continue;
347
+ add("warn", "package.global", `A global install of sentinel-ir-mcp ${mcp} (sentinel-ir ${ir}) is at ${dir}; bare npx sentinel-ir-mcp and client entries without a version start it`,
348
+ `Run: npm install -g ${PACKAGE_SPEC} (or npm uninstall -g sentinel-ir-mcp)`);
349
+ }
350
+ }
351
+
91
352
  function checkNpxCache(env, platform, add) {
92
353
  const cacheDir = npmCacheDir(env, platform);
93
354
  const stale = staleNpxCopies(cacheDir);
@@ -279,6 +540,8 @@ async function runDoctor(opts, io) {
279
540
  add("fail", "package", `sentinel-ir-mcp ${version} runs sentinel-ir ${ir}, needs ${IR_MIN} or newer`,
280
541
  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
542
  } else add("ok", "package", `sentinel-ir-mcp ${version}, sentinel-ir ${ir}`);
543
+ await checkUpdate(io, add);
544
+ checkGlobalInstall(io, add);
282
545
  checkNpxCache(io.env, io.platform, add);
283
546
 
284
547
  const project = path.resolve(io.cwd, opts.project || ".");
@@ -323,9 +586,11 @@ async function runDoctor(opts, io) {
323
586
  io.stdout.write(JSON.stringify({ ok: !failed, checks }, null, 2) + "\n");
324
587
  } else {
325
588
  for (const c of checks) io.stdout.write(`${MARK[c.status]} ${c.message}\n${c.fix ? ` -> ${c.fix}\n` : ""}`);
326
- io.stdout.write(failed ? "\nSome checks failed.\n" : "\nAll checks passed.\n");
589
+ const warnings = checks.filter((c) => c.status === "warn").length;
590
+ const warned = warnings ? `, ${warnings} warning${warnings > 1 ? "s" : ""} above` : "";
591
+ io.stdout.write(failed ? `\nSome checks failed${warned}.\n` : `\nAll checks passed${warned}.\n`);
327
592
  }
328
593
  return failed ? 1 : 0;
329
594
  }
330
595
 
331
- module.exports = { runDoctor, entryFolders, olderThan, staleNpxCopies, IR_MIN };
596
+ module.exports = { runDoctor, entryFolders, olderThan, staleNpxCopies, fetchDistTags, fetchDistTagsIsolated, proxyFor, installKind, globalDirs, IR_MIN };