sentinel-ir-mcp 0.5.4 → 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,13 @@ 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
+
31
38
  **0.5.4** makes `doctor` notice an old copy of itself: it asks npm for the
32
39
  newest release on its `stable-X.Y` tag (3 s timeout, skipped offline) and,
33
40
  when that is newer, warns and says where the old copy runs from (npx cache,
@@ -266,7 +273,7 @@ Claude Code, `setup` prints a PowerShell and a Command Prompt version of the
266
273
  command. What was measured on which system: [COMPATIBILITY.md](COMPATIBILITY.md).
267
274
 
268
275
  `doctor` checks Node.js, the package version (and asks the npm registry, or
269
- `npm_config_registry`, whether its `stable-X.Y` tag has a newer release), the client config (valid JSON, a
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
270
277
  `"sentinel-ir"` entry that starts `sentinel-ir-mcp`, existing folders), then
271
278
  starts **its own** server with the same folders, performs a real MCP
272
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.4",
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, fetchDistTags } = 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,7 +103,7 @@ function defaultIo() {
103
103
  env: process.env,
104
104
  platform: process.platform,
105
105
  cwd: process.cwd(),
106
- fetchDistTags,
106
+ fetchDistTags: fetchDistTagsIsolated,
107
107
  isTTY,
108
108
  prompt: isTTY ? (question) => new Promise((resolve) => {
109
109
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
package/src/doctor.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require("fs");
4
4
  const http = require("http");
5
5
  const https = require("https");
6
+ const net = require("net");
6
7
  const path = require("path");
7
8
  const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
8
9
  const { StdioClientTransport } = require("@modelcontextprotocol/sdk/client/stdio.js");
@@ -54,39 +55,204 @@ function npxCopyDir(dir = PACKAGE_DIR) {
54
55
  return m ? m[1] : null;
55
56
  }
56
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
+
57
93
  // Where the package in dir is installed: an npx cache copy, a global install or elsewhere.
58
- function installKind(dir) {
94
+ function installKind(dir, globals = []) {
59
95
  const npx = npxCopyDir(dir);
60
96
  if (npx) return { kind: "npx", dir: npx, key: /[^\\/]+$/.exec(npx)[0] };
61
- if (/[\\/](lib|npm)[\\/]node_modules[\\/]sentinel-ir-mcp[\\/]?$/i.test(dir)) return { kind: "global", dir };
97
+ if (/[\\/](lib|npm)[\\/]node_modules[\\/]sentinel-ir-mcp[\\/]?$/i.test(dir) || globals.some((g) => sameDir(g, dir))) return { kind: "global", dir };
62
98
  return { kind: "other", dir };
63
99
  }
64
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
+
65
179
  // npm dist-tags of sentinel-ir-mcp, or null when the registry does not answer in time.
66
- function fetchDistTags(env, timeoutMs = 3000) {
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)) {
67
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
+ };
68
219
  let url;
69
220
  try {
70
221
  url = new URL("-/package/sentinel-ir-mcp/dist-tags", (env.npm_config_registry || "https://registry.npmjs.org/").replace(/\/?$/, "/"));
71
- } catch { resolve(null); return; }
72
- const lib = url.protocol === "http:" ? http : url.protocol === "https:" ? https : null;
73
- if (!lib) { resolve(null); return; }
74
- const req = lib.get(url, { timeout: timeoutMs, headers: { accept: "application/json" } }, (res) => {
75
- if (res.statusCode !== 200) { res.resume(); resolve(null); return; }
76
- let body = "";
77
- res.setEncoding("utf8");
78
- res.on("data", (d) => { body += d; if (body.length > 65536) { resolve(null); req.destroy(); } });
79
- res.on("end", () => {
80
- try {
81
- const tags = JSON.parse(body);
82
- resolve(tags && typeof tags === "object" && !Array.isArray(tags) ? tags : null);
83
- } catch { resolve(null); }
84
- });
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);
85
255
  });
86
- const timer = setTimeout(() => req.destroy(), timeoutMs);
87
- req.on("timeout", () => req.destroy());
88
- req.on("error", () => resolve(null));
89
- req.on("close", () => { clearTimeout(timer); resolve(null); });
90
256
  });
91
257
  }
92
258
 
@@ -97,20 +263,30 @@ async function checkUpdate(io, add) {
97
263
  const tag = m ? `stable-${m[1]}.${m[2]}` : "latest";
98
264
  const tags = await io.fetchDistTags(io.env);
99
265
  if (!tags) {
100
- add("skip", "package.update", "Could not reach the npm registry to look for a newer sentinel-ir-mcp (offline or blocked)");
266
+ add("skip", "package.update", "No usable answer from the npm registry about a newer sentinel-ir-mcp (offline, blocked or an unexpected reply)");
101
267
  return;
102
268
  }
103
- const newest = tags[tag];
104
- if (typeof newest !== "string" || !/^\d+\.\d+\.\d+$/.test(newest)) {
269
+ const newest = Object.prototype.hasOwnProperty.call(tags, tag) ? tags[tag] : undefined;
270
+ if (newest === undefined) {
105
271
  add("skip", "package.update", `npm has no ${tag} tag for sentinel-ir-mcp`);
106
272
  return;
107
273
  }
108
- if (!olderThan(version, newest)) {
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) {
109
280
  add("ok", "package.update", `sentinel-ir-mcp ${version} is the newest ${tag} on npm`);
110
281
  return;
111
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
+ }
112
287
  const spec = `sentinel-ir-mcp@${tag}`;
113
- const from = installKind(io.packageDir || PACKAGE_DIR);
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);
114
290
  const where = { npx: `the npx cache (${from.dir})`, global: `a global install (${from.dir})`, other: from.dir }[from.kind];
115
291
  const fix = {
116
292
  npx: `Close the client, delete that folder (npm 11: npm cache npx rm ${from.key}), then run: npx -y ${spec} setup`,
@@ -158,6 +334,21 @@ function staleNpxCopies(cacheDir) {
158
334
  return stale;
159
335
  }
160
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
+
161
352
  function checkNpxCache(env, platform, add) {
162
353
  const cacheDir = npmCacheDir(env, platform);
163
354
  const stale = staleNpxCopies(cacheDir);
@@ -350,6 +541,7 @@ async function runDoctor(opts, io) {
350
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"]}.`);
351
542
  } else add("ok", "package", `sentinel-ir-mcp ${version}, sentinel-ir ${ir}`);
352
543
  await checkUpdate(io, add);
544
+ checkGlobalInstall(io, add);
353
545
  checkNpxCache(io.env, io.platform, add);
354
546
 
355
547
  const project = path.resolve(io.cwd, opts.project || ".");
@@ -401,4 +593,4 @@ async function runDoctor(opts, io) {
401
593
  return failed ? 1 : 0;
402
594
  }
403
595
 
404
- module.exports = { runDoctor, entryFolders, olderThan, staleNpxCopies, fetchDistTags, installKind, IR_MIN };
596
+ module.exports = { runDoctor, entryFolders, olderThan, staleNpxCopies, fetchDistTags, fetchDistTagsIsolated, proxyFor, installKind, globalDirs, IR_MIN };