protocolprobe-discover 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 ProtocolProbe
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,111 @@
1
- # Temporary Holding Version
1
+ # ProtocolProbe discovery (feature #17)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Lists the MCP servers configured on **this machine**, and which ones Claude Desktop has started. Zero dependencies
4
+ (Node 18+; tests also pass on Node 18.20.8), reads config files only, **sends nothing anywhere**.
5
+
6
+ ```
7
+ npx protocolprobe-discover # once published; from a checkout: node discover.mjs
8
+ npx protocolprobe-discover --dir ../app --dir . # project configs in these directories too
9
+ npx protocolprobe-discover --json # same fields, machine-readable
10
+ npx protocolprobe-discover --help | --version
11
+ ```
12
+
13
+ In a repository checkout only (not in the published package):
14
+
15
+ ```
16
+ node --test discover.test.mjs registry-match.test.mjs package.test.mjs # tests (20)
17
+ node build-index.mjs --registry <registry dump.ndjson> --snapshot YYYY-MM-DD # rebuild the registry snapshot (each registry refresh)
18
+ ```
19
+
20
+ ## Publishing (owner's steps; nothing here has been published)
21
+
22
+ `"private": true` was removed on 2026-10-20 for the first publish (owner decision). The name
23
+ `protocolprobe-discover` was free on npm on 2026-10-14 and again on 2026-10-20 (not a reservation). Before publishing: run the tests and
24
+ `npm pack --dry-run` (the package test checks that exactly 7 files ship: the three sources, the snapshot, README, LICENSE,
25
+ plus package.json), rebuild the snapshot if it is over 60 days old, confirm the licence (ISC, copyright line "ProtocolProbe")
26
+ and the name, then `npm publish --access public` from this folder with 2FA. After the first publish set `SITE_DISCOVER=true` on
27
+ the site so `/docs/discover` goes live (it is off by default because it would describe a command that does not exist yet).
28
+
29
+ ## Match against the official MCP registry (offline)
30
+
31
+ For every package a known runner starts (npm, PyPI, OCI image) and every remote URL, it looks the server up in
32
+ `registry-index.json.gz`, a snapshot of the official registry (latest version of each server) built by
33
+ `build-index.mjs`. The lookup is local: **no network call is made**. The output says `registry: listed as <name> →
34
+ https://protocolprobe.com/mcp/servers/<name>` (that page shows what we checked and what we did not), `shared by N
35
+ registry entries` when a key belongs to several (never guessed), or `not found in the snapshot` (the server may be
36
+ newer, unpublished or not a registry server; this says nothing about its safety). A local script or an unrecognised
37
+ command is not matched. The snapshot date is printed in the output; the file is regenerated on each registry refresh.
38
+
39
+ Measured on the 2026-10-23 snapshot (48,551 servers): 17,144 package keys, 97.3% belong to exactly one server; 31,819
40
+ remote host+path keys, 99.8% belong to exactly one. **Not everything people run is in it:** the reference servers
41
+ `@modelcontextprotocol/server-filesystem`, `-server-memory` and `mcp-server-sqlite-npx` are not registry packages, so they
42
+ show `not found`. Only latest versions count: a package whose only registry row is an old version is not listed.
43
+
44
+ ## What it never shows
45
+
46
+ Config files hold API keys in env values, header values, command arguments, URL queries and sometimes URL paths.
47
+ The rule is: **if a value is not known to be safe, it is not shown, only counted.**
48
+
49
+ - Never shown: env and header values; a remote host that the registry does not list is shortened to its last two
50
+ labels (`*.supabase.co`), and a label of 12+ letters-and-digits is replaced; env variable names that are not in the
51
+ conventional upper-case shape (they are counted); a server name with a single word of 25+ characters; command arguments; URL userinfo, query and fragment; a URL built from
52
+ variables (`${…}`); every URL path segment except generic endpoint words (`mcp`, `sse`, `api`, `v1`, …, see
53
+ `PATH_WORDS` in lib.mjs), shown as `*`, unless the registry lists that exact URL (then the public path is shown); package
54
+ names that are not "plain names" (short lower-case words joined by `.` `_` `-` `~`, optional leading `@`; mixed
55
+ case, hex, many digits or long words fail); a command string that carries its own arguments (`"npx -y pkg --token=…"`); a package name when an
56
+ unknown flag comes before it (its value could be next); server and log names that look like a credential (`ghp_`,
57
+ `sk-`, `AKIA`, JWTs, long letter-and-digit runs, hex, mixed case with digits), replaced by
58
+ "(name not shown: looks like a credential)".
59
+ - Shown: server name, client and scope, transport (one of stdio/http/streamable-http/sse/ws, else "other"), the
60
+ command's base name, a package name from a known runner (`npx`, `bunx`, `pnpm dlx`, `uvx`, `pipx run`,
61
+ `docker run`) when every flag before it is known, a remote URL's origin and plain path, counts of arguments, env
62
+ variables and headers, and env variable **names**. Paths under your home folder are shown as `~`. Control
63
+ characters in names from a config file are escaped, so a config cannot rewrite the terminal output.
64
+ - Tested with config files full of fake secrets in every place an independent review found (2026-10-10): none appears
65
+ in the text or JSON output. Re-checked 2026-10-14 with 13 more planted secrets (arguments, env, headers, URL
66
+ userinfo/query/fragment, docker `-e`, uv index URL, `mcp-remote --header`, names): none appears, and the two paths
67
+ that did pass (a letters-only per-account host, a lower-case env name) are closed and have tests. 15 tests;
68
+ 19 of 21 code mutations of the 2026-10-14 code are caught; the two not caught change no output (a guard that cannot be
69
+ reached because package keys always carry an `npm:`/`pypi:`/`oci:` prefix, and a URL check that a second check repeats). Run on Windows only; macOS and Linux
70
+ paths are asserted, not run.
71
+
72
+ ## Where it looks (from each vendor's documentation, read 2026-10-10)
73
+
74
+ | client | files | key |
75
+ |---|---|---|
76
+ | Claude Desktop | macOS `~/Library/Application Support/Claude/claude_desktop_config.json`, Windows `%APPDATA%\Claude\claude_desktop_config.json`; logs `~/Library/Logs/Claude`, `%APPDATA%\Claude\logs` (`mcp-server-NAME.log`) | `mcpServers` |
77
+ | Claude Code | `~/.claude.json` (user scope, and `projects[path]` local scope), `<project>/.mcp.json` (also read by VS Code) | `mcpServers` |
78
+ | Cursor | `~/.cursor/mcp.json`, `<project>/.cursor/mcp.json` | `mcpServers` |
79
+ | VS Code | `<project>/.vscode/mcp.json`; user `mcp.json` in the profile folder (`%APPDATA%\Code\User`, `~/Library/Application Support/Code/User`, `~/.config/Code/User`, plus `profiles/<id>/`; also `Code - Insiders`); `~/.copilot/mcp-config.json` | `servers` (`mcpServers` for the Copilot file) |
80
+
81
+ Sources: code.claude.com/docs/en/mcp · cursor.com/docs/context/mcp ·
82
+ code.visualstudio.com/docs/copilot/customization/mcp-servers · code.visualstudio.com/docs/configure/profiles.
83
+ The Claude Desktop paths come from the project's research log (real probe, 2026-10-03).
84
+
85
+ ## Limits (stated, not hidden)
86
+
87
+ - Only these clients and locations. Not seen: other clients, enterprise-managed files (`managed-mcp.json`), Claude
88
+ Code plugin servers and its approve/disable lists in `.claude/settings.json` (so a `.mcp.json` server is listed
89
+ even if it was rejected), `CLAUDE_CONFIG_DIR` and `$COPILOT_HOME` relocations, VS Code devcontainer and remote
90
+ configs, VS Code's discovery of Claude Desktop servers, and servers started outside a config file. Claude Desktop
91
+ extensions and the Store build's storage were not checked.
92
+ - Project configs are found only in the directories you pass (default: the current one); it does not search the disk.
93
+ - "Started" evidence exists only for Claude Desktop, and log files persist: a server listed there was started at some
94
+ point, not necessarily now.
95
+ - The VS Code default-profile location (`…/Code/User/mcp.json`) is inferred from VS Code's profile-folder docs; the
96
+ user-level file is documented as "in your user profile folder".
97
+ - The secret heuristics are conservative but not perfect. What can still pass: a credential used as the whole
98
+ registrable domain with fewer than 12 characters; a server name a person picked that is shorter than 25 characters per word (names
99
+ like `Context7` or `Neo4jMemory` stay visible); a project folder name inside a `local (~project)` scope. Hosts and
100
+ env names were tightened on 2026-10-14 after a letters-only per-account host and a lower-case env name passed.
101
+ - `npx mcp-remote <url>` is read as a remote: the URL that follows the package is shortened and matched by the same rules
102
+ as a `url` entry (never shown as written, headers ignored). Other programs' arguments are never read.
103
+ - The registry snapshot ages. After 60 days the output warns that newer servers show as "not found"; rebuild it with
104
+ `build-index.mjs` on each registry refresh (docs/FUTURE-TASKS.md section 6).
105
+ - Over-redaction (safe side, nothing leaks): upper-case path segments (`/MCP` → `/*`), mixed-case package names
106
+ (`Flask-MCP`), `==` version pins (`mcp-proxy==0.8.2`) and images from a registry with a port
107
+ (`localhost:5000/my-mcp`) are not shown as packages or path words.
108
+ - Speed: a plain-JSON file of 44.5 MB parses in about 0.24 s; a JSON-with-comments file (VS Code) of the same size
109
+ would take about 12.5 s (measured). Real VS Code files are small.
110
+ - Tested on Windows against fixture files in every layout (and macOS/Linux paths by assertion); on this machine only
111
+ `~/.claude.json` existed (0 servers).
package/discover.mjs ADDED
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ // ProtocolProbe shadow-MCP discovery (feature #17): lists the MCP servers configured on THIS machine, and which ones
3
+ // Claude Desktop has started (its per-server log files). Runs locally, reads config files only, sends nothing
4
+ // anywhere, needs no install.
5
+ //
6
+ // node discover.mjs user-level configs + the current directory's project configs
7
+ // node discover.mjs --dir ../a --dir b project configs in these directories too
8
+ // node discover.mjs --json machine-readable output (same fields)
9
+ //
10
+ // Never printed (see lib.mjs): env and header values, command arguments, URL userinfo/query/fragment, URL path
11
+ // segments that are not plain words, anything that looks like a credential. Home paths are shown as "~".
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import zlib from "node:zlib";
16
+ import { fileURLToPath } from "node:url";
17
+ import { claudeDesktopLogDir, describe, entriesOf, looksSecret, projectLocations, ranFromLogNames, stripJsonc, userLocations, vscodeProfilesDir } from "./lib.mjs";
18
+ import { validIndex } from "./registry-index.mjs";
19
+
20
+ /** A server or log name that looks like a credential is replaced, in both text and JSON output. */
21
+ // a name a person picked ("Context7") stays visible; one that looks like a credential, or has a single word of 25+
22
+ // characters (a token used as a label), is replaced
23
+ const safeName = (n) => (looksSecret(n, { name: true }) || String(n).split(/[-_. ]/).some((w) => w.length > 24) ? "(name not shown: looks like a credential)" : String(n));
24
+ const STALE_DAYS = 60; // the registry is refreshed monthly; two months old is worth a warning
25
+ const INDEX_FILE =path.join(path.dirname(fileURLToPath(import.meta.url)), "registry-index.json.gz");
26
+
27
+ /** The offline registry snapshot, or null (with the reason) when it is missing or not the shape this code reads. */
28
+ export function loadIndex(file = INDEX_FILE) {
29
+ try {
30
+ const x = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString("utf8"));
31
+ if (!validIndex(x)) return { index: null, why: "registry snapshot file is not in the expected format" };
32
+ return { index: { ...x, hostSet: new Set(x.hosts) } };
33
+ } catch (e) { return { index: null, why: e.code === "ENOENT" ? "registry snapshot file not found" : "registry snapshot file could not be read" }; }
34
+ }
35
+ const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
36
+
37
+ export function scan({ platform = process.platform, home = os.homedir(), appData = process.env.APPDATA, dirs = [process.cwd()], index: given, now = new Date() } = {}) {
38
+ const loaded = given === undefined ? loadIndex() : { index: given };
39
+ const index = loaded.index;
40
+ // a registry snapshot ages: servers published after it show as "not found". Say so once it is old.
41
+ const ageDays = index ? Math.floor((now - new Date(`${index.snapshot}T00:00:00Z`)) / 86400000) : null;
42
+ const tilde = (p) => {
43
+ const h = path.resolve(home);
44
+ const same = platform === "win32" ? p.toLowerCase().startsWith(h.toLowerCase()) : p.startsWith(h);
45
+ return same && (p.length === h.length || /[\\/]/.test(p[h.length])) ? "~" + p.slice(h.length) : p;
46
+ };
47
+ const locations = [...userLocations({ platform, home, appData })];
48
+ for (const l of [...locations]) {
49
+ if (l.key === "servers" && l.scope === "user") {
50
+ const pdir = vscodeProfilesDir(l.file);
51
+ let subs = [];
52
+ try { subs = fs.readdirSync(pdir).filter((n) => isDir(path.join(pdir, n))); } catch {}
53
+ for (const s of subs) locations.push({ ...l, scope: `user (profile ${s})`, file: path.join(pdir, s, "mcp.json") });
54
+ }
55
+ }
56
+ const warnings = loaded.why ? [`${loaded.why}: servers are not matched against the registry and remote hosts are shortened`] : [];
57
+ if (ageDays !== null && ageDays > STALE_DAYS) warnings.push(`the registry snapshot is ${ageDays} days old (${index.snapshot}): servers published since then show as "not found"`);
58
+ for (const d of dirs) {
59
+ const abs = path.resolve(d);
60
+ if (!isDir(abs)) { warnings.push(`--dir ${tilde(abs)} is not a directory; skipped`); continue; }
61
+ locations.push(...projectLocations(abs));
62
+ }
63
+ // the same file can be reached twice (e.g. --dir ~ finds ~/.cursor/mcp.json again): read it once
64
+ const seen = new Set();
65
+ const unique = locations.filter((l) => { const k = path.resolve(l.file).toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; });
66
+
67
+ const files = [], servers = [];
68
+ for (const l of unique) {
69
+ const shown = { ...l, file: tilde(l.file) };
70
+ let text;
71
+ try { text = fs.readFileSync(l.file, "utf8"); } catch (e) { files.push({ ...shown, status: e.code === "ENOENT" || e.code === "ENOTDIR" ? "absent" : "unreadable" }); continue; }
72
+ let json;
73
+ try { json = JSON.parse(stripJsonc(text)); } catch { files.push({ ...shown, status: "not valid JSON" }); continue; }
74
+ const entries = entriesOf(json, l.key);
75
+ files.push({ ...shown, status: "read", servers: entries.length });
76
+ for (const e of entries) servers.push({ client: l.client, scope: e.project ? `local (${tilde(platform === "win32" ? String(e.project).replace(/\//g, "\\") : String(e.project))})` : l.scope, file: shown.file, name: safeName(e.name), ...describe(e.def, index) });
77
+ }
78
+
79
+ let ran = null;
80
+ const logDir = claudeDesktopLogDir({ platform, home, appData });
81
+ if (logDir) {
82
+ try { ran = { dir: tilde(logDir), servers: ranFromLogNames(fs.readdirSync(logDir)).map(safeName) }; } catch { ran = { dir: tilde(logDir), servers: null }; }
83
+ }
84
+ return { scannedAt: new Date().toISOString(), platform, registrySnapshot: index ? { snapshot: index.snapshot, servers: index.servers } : null, warnings, files, servers, claudeDesktopStarted: ran };
85
+ }
86
+
87
+ /**
88
+ * Control characters in names from a config file are shown escaped, never raw: terminal escapes, newlines, line
89
+ * separators, and text-direction controls (a right-to-left override can visually reverse a line).
90
+ */
91
+ const CTRL = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]", "g");
92
+ const clean = (v) => String(v).replace(CTRL, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
93
+
94
+ /** One line about the registry match; "not listed" is a statement about the snapshot, not about the server. */
95
+ function registryLine(m) {
96
+ if (m.state === "listed") return `registry: listed as ${m.name} → ${m.url}`;
97
+ if (m.state === "several") return `registry: shared by ${m.count} registry entries (not guessed which)`;
98
+ return "registry: not found in the snapshot (it may be newer, unpublished, or not a registry server; this says nothing about its safety)";
99
+ }
100
+
101
+ export function render(r) {
102
+ const lines = [];
103
+ lines.push(`MCP servers configured on this machine (${r.platform}, ${r.scannedAt})`);
104
+ lines.push("Nothing was sent anywhere. Not shown: env and header values, command arguments, URL queries, path segments that");
105
+ lines.push("are not plain words, and anything that looks like a credential. Remote hosts are shortened unless the registry lists them.");
106
+ if (r.registrySnapshot) lines.push(`Matched offline (no network) against the official MCP registry as of ${clean(r.registrySnapshot.snapshot)} (${r.registrySnapshot.servers} servers).`);
107
+ for (const w of r.warnings ?? []) lines.push(`warning: ${clean(w)}`);
108
+ lines.push("");
109
+ lines.push(`Config files checked: ${r.files.length} (read ${r.files.filter((f) => f.status === "read").length}, absent ${r.files.filter((f) => f.status === "absent").length}, problem ${r.files.filter((f) => !["read", "absent"].includes(f.status)).length})`);
110
+ for (const f of r.files.filter((f) => f.status !== "absent")) lines.push(` ${f.status === "read" ? `${f.servers} server(s)` : f.status.toUpperCase()} ${clean(f.client)} · ${clean(f.scope)} · ${clean(f.file)}`);
111
+ lines.push("");
112
+ lines.push(`Servers: ${r.servers.length}`);
113
+ for (const s of r.servers) {
114
+ const what = s.remote && s.package ? `${s.package.runner} ${s.package.name} → remote ${s.remote}` : s.remote ? `remote ${s.remote}` : s.package ? `${s.package.runner} ${s.package.name}` : s.command ? `command "${s.command}" (${s.args} argument${s.args === 1 ? "" : "s"})` : "command or URL not shown";
115
+ const extra = [s.transport, s.envVars ? `env vars: ${s.envNames.join(", ") || s.envVars}${s.envNames.length && s.envNames.length < s.envVars ? ` (+${s.envVars - s.envNames.length} not shown)` : ""}` : null, s.headers ? `${s.headers} header(s)` : null, s.disabled ? "disabled" : null].filter(Boolean).join(" · ");
116
+ lines.push(` ${clean(s.name)} — ${clean(what)}`);
117
+ lines.push(` ${clean(s.client)} · ${clean(s.scope)} · ${clean(extra)}`);
118
+ if (s.registry) lines.push(` ${clean(registryLine(s.registry))}`);
119
+ }
120
+ const st = r.claudeDesktopStarted;
121
+ if (st) {
122
+ lines.push("");
123
+ lines.push(st.servers ? `Claude Desktop has started (a log file exists): ${st.servers.length ? st.servers.map(clean).join(", ") : "none"}` : `Claude Desktop logs: not found (${clean(st.dir)})`);
124
+ }
125
+ lines.push("");
126
+ lines.push("A listed server's page shows what ProtocolProbe checked and what it did not: https://protocolprobe.com/mcp/servers/");
127
+ return lines.join("\n");
128
+ }
129
+
130
+ export const USAGE = "usage: protocolprobe-discover [--dir <project>]... [--json] | --help | --version";
131
+
132
+ /** The package's own version (package.json ships next to this file), or null if it cannot be read. */
133
+ export function version() {
134
+ try { return JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "package.json"), "utf8")).version ?? null; } catch { return null; }
135
+ }
136
+
137
+ export function parseArgs(argv) {
138
+ const dirs = [];
139
+ let json = false;
140
+ for (let i = 0; i < argv.length; i++) {
141
+ const a = argv[i];
142
+ if (a === "--help" || a === "-h") return { help: true };
143
+ if (a === "--version" || a === "-v") return { version: true };
144
+ if (a === "--json") json = true;
145
+ else if (a === "--dir") {
146
+ const v = argv[i + 1];
147
+ if (v === undefined || v.startsWith("--")) return { error: "--dir needs a directory" };
148
+ dirs.push(v); i++;
149
+ } else return { error: `unknown argument: ${a}` };
150
+ }
151
+ return { dirs, json };
152
+ }
153
+
154
+ /**
155
+ * True when this file is the program being run. Compared by real path: an installed command (npm's bin link) reaches this
156
+ * file through a symlink, and comparing the raw argv[1] with the module URL would then print nothing at all.
157
+ */
158
+ export function isMain(argv1 = process.argv[1], url = import.meta.url) {
159
+ try { return !!argv1 && fs.realpathSync(argv1) === fs.realpathSync(fileURLToPath(url)); } catch { return false; }
160
+ }
161
+
162
+ if (isMain()) {
163
+ const a = parseArgs(process.argv.slice(2));
164
+ if (a.error) { console.error(`${a.error}\n${USAGE}`); process.exit(2); }
165
+ if (a.help) {
166
+ console.log(`${USAGE}\n\nLists the MCP servers configured on this machine (Claude Desktop, Claude Code, Cursor, VS Code) and, offline, which are in the\nofficial MCP registry. Reads config files only; sends nothing anywhere; never prints env/header values or arguments.\n --dir <project> also read that project's .mcp.json, .cursor/mcp.json and .vscode/mcp.json (default: the current directory)\n --json machine-readable output`);
167
+ } else if (a.version) {
168
+ console.log(version() ?? "unknown");
169
+ } else {
170
+ const r = scan({ dirs: a.dirs.length ? a.dirs : [process.cwd()] });
171
+ console.log(a.json ? JSON.stringify(r, null, 2) : render(r));
172
+ }
173
+ }
package/lib.mjs ADDED
@@ -0,0 +1,280 @@
1
+ // Feature #17, shadow-MCP discovery: which MCP servers are configured (and, for Claude Desktop, which actually ran) on
2
+ // THIS machine. Pure functions here (unit-tested); file access is in discover.mjs.
3
+ //
4
+ // Privacy rules (CLAUDE.md, "Org-wide shadow-MCP discovery"): nothing is sent anywhere; secrets never leave the config
5
+ // file. Never output env values, header values, raw arguments, URL query strings or URL userinfo: config files hold
6
+ // API keys in all of those places. Only names, counts, the command's base name, a package name recognised from a
7
+ // known runner pattern, and a remote URL's origin + path are reported.
8
+ //
9
+ // Locations and formats, each read from the vendor's own documentation on 2026-10-10 (Claude Desktop from the
10
+ // project's research log, verified with a real probe on 2026-10-03):
11
+ // Claude Desktop macOS ~/Library/Application Support/Claude/claude_desktop_config.json,
12
+ // Windows %APPDATA%\Claude\claude_desktop_config.json; logs ~/Library/Logs/Claude, %APPDATA%\Claude\logs
13
+ // (one mcp-server-NAME.log per server that was started) key "mcpServers"
14
+ // Claude Code ~/.claude.json: "mcpServers" (user scope) and "projects"[path]."mcpServers" (local scope);
15
+ // <project>/.mcp.json (project scope) code.claude.com/docs/en/mcp
16
+ // Cursor ~/.cursor/mcp.json, <project>/.cursor/mcp.json cursor.com/docs/context/mcp
17
+ // VS Code <project>/.vscode/mcp.json (key "servers"); user mcp.json in the user profile folder
18
+ // (%APPDATA%\Code\User, ~/Library/Application Support/Code/User, ~/.config/Code/User, and
19
+ // profiles/<id>/ under each); ~/.copilot/mcp-config.json (key "mcpServers")
20
+ // code.visualstudio.com/docs/copilot/customization/mcp-servers
21
+
22
+ import path from "node:path";
23
+ import { matchPackage, matchRemote, normPackage, remoteKey } from "./registry-index.mjs";
24
+
25
+ /** Every user-level file to look at, for one platform and home directory (paths only; nothing is read here). */
26
+ export function userLocations({ platform, home, appData }) {
27
+ const L = [];
28
+ const add = (client, scope, file, key) => L.push({ client, scope, file, key });
29
+ if (platform === "darwin") add("Claude Desktop", "user", path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"), "mcpServers");
30
+ if (platform === "win32" && appData) add("Claude Desktop", "user", path.join(appData, "Claude", "claude_desktop_config.json"), "mcpServers");
31
+ add("Claude Code", "user + local", path.join(home, ".claude.json"), "claude.json");
32
+ add("Cursor", "user", path.join(home, ".cursor", "mcp.json"), "mcpServers");
33
+ add("VS Code / Copilot", "user", path.join(home, ".copilot", "mcp-config.json"), "mcpServers");
34
+ for (const product of ["Code", "Code - Insiders"]) {
35
+ const userDir = platform === "win32" ? (appData ? path.join(appData, product, "User") : null)
36
+ : platform === "darwin" ? path.join(home, "Library", "Application Support", product, "User")
37
+ : path.join(home, ".config", product, "User");
38
+ if (userDir) add(product === "Code" ? "VS Code" : "VS Code Insiders", "user", path.join(userDir, "mcp.json"), "servers");
39
+ }
40
+ return L;
41
+ }
42
+
43
+ /** VS Code profile folders hold their own mcp.json (scanned by the caller when the folder exists). */
44
+ export function vscodeProfilesDir(userMcpFile) {
45
+ return path.join(path.dirname(userMcpFile), "profiles");
46
+ }
47
+
48
+ /** Claude Desktop's log folder (servers that were actually started leave mcp-server-NAME.log). */
49
+ export function claudeDesktopLogDir({ platform, home, appData }) {
50
+ if (platform === "darwin") return path.join(home, "Library", "Logs", "Claude");
51
+ if (platform === "win32" && appData) return path.join(appData, "Claude", "logs");
52
+ return null;
53
+ }
54
+
55
+ /** Project-level files inside one project directory. */
56
+ export function projectLocations(dir) {
57
+ return [
58
+ // .mcp.json is read by Claude Code and, per its docs, by VS Code too
59
+ { client: "Claude Code / VS Code", scope: "project", file: path.join(dir, ".mcp.json"), key: "mcpServers" },
60
+ { client: "Cursor", scope: "project", file: path.join(dir, ".cursor", "mcp.json"), key: "mcpServers" },
61
+ { client: "VS Code", scope: "project", file: path.join(dir, ".vscode", "mcp.json"), key: "servers" },
62
+ ];
63
+ }
64
+
65
+ /** Server entries from a parsed config file: [{ name, def, project? }]. ~/.claude.json has two places. */
66
+ export function entriesOf(json, key) {
67
+ const out = [];
68
+ const take = (obj, project) => {
69
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return;
70
+ for (const [name, def] of Object.entries(obj)) if (def && typeof def === "object") out.push({ name, def, ...(project ? { project } : {}) });
71
+ };
72
+ if (key === "claude.json") {
73
+ take(json?.mcpServers);
74
+ if (json?.projects && typeof json.projects === "object") for (const [p, v] of Object.entries(json.projects)) take(v?.mcpServers, p);
75
+ } else take(json?.[key]);
76
+ return out;
77
+ }
78
+
79
+ // Known runners: which flags take NO value and which take ONE value. If any other flag appears before the first
80
+ // positional argument, we cannot know whether that positional is the package or a flag's value, so nothing is
81
+ // reported (independent review, 2026-10-10: unlisted flags such as `uvx --refresh-package X` or
82
+ // `docker run --hostname X` otherwise turned their value into the "package").
83
+ const RUNNERS = {
84
+ npx: { sub: null, bare: ["-y", "--yes", "--no", "-q", "--quiet", "--no-install", "--ignore-existing", "--prefer-online", "--prefer-offline"], valued: ["-p", "--package", "-c", "--call"] },
85
+ bunx: { sub: null, bare: ["--bun"], valued: [] },
86
+ pnpm: { sub: "dlx", bare: ["-s", "--silent"], valued: ["--package"] },
87
+ uvx: { sub: null, bare: ["-q", "--quiet", "--offline", "--refresh", "--isolated", "--no-cache", "-v", "--verbose"], valued: ["--from", "--with", "--python", "-p", "--index", "--index-url", "--extra-index-url", "--refresh-package", "--with-requirements", "--with-editable", "--env-file"] },
88
+ pipx: { sub: "run", bare: ["--no-cache", "--verbose", "-q", "--quiet"], valued: ["--spec", "--python", "--index-url", "--pip-args"] },
89
+ docker: { sub: "run", bare: ["-i", "-t", "-it", "-ti", "--rm", "-d", "--init", "--privileged", "--interactive", "--tty", "--detach", "--read-only"],
90
+ valued: ["-e", "--env", "--env-file", "-v", "--volume", "--name", "-p", "--publish", "--network", "--net", "-w", "--workdir", "-u", "--user", "--mount",
91
+ "--entrypoint", "--platform", "-l", "--label", "--label-file", "--add-host", "-h", "--hostname", "--dns", "--cap-add", "--cap-drop", "-m", "--memory",
92
+ "--cpus", "--log-opt", "--log-driver", "--device", "--security-opt", "--tmpfs", "--ulimit", "--restart", "--pull", "--gpus", "--ipc", "--pid", "--shm-size"] },
93
+ };
94
+ function firstPositional(a, { bare, valued }) {
95
+ for (let i = 0; i < a.length; i++) {
96
+ const x = a[i];
97
+ if (!x.startsWith("-")) return x;
98
+ if (x.includes("=") && valued.some((f) => x.startsWith(f + "="))) continue; // --flag=value: value stays inside, not printed
99
+ if (valued.includes(x)) { i++; continue; }
100
+ if (bare.includes(x)) continue;
101
+ return null; // a flag we do not know: the next argument may be its value
102
+ }
103
+ return null;
104
+ }
105
+ // a package or image name: letters, digits and @ / . _ - : (version tags allowed); anything else is not reported
106
+ const PKG_RE = /^@?[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)*(@[a-z0-9._^~<>=-]+)?(:[a-z0-9._-]+)?$/i;
107
+ /**
108
+ * Strings that look like credentials are never reported, even where a name is expected. For names a person picked
109
+ * (server, command and env names: "Context7", "Neo4jMemory") the mixed-case rule needs 16+ characters; URL path
110
+ * segments and package names go through plainName(), which is stricter.
111
+ */
112
+ export function looksSecret(x, { name = false } = {}) {
113
+ const s = String(x);
114
+ if (/^(ghp_|gho_|ghu_|ghs_|github_pat_|sk-|sk_|pk_|rk_|xox[abprs]-|AIza|AKIA|ASIA|eyJ)/i.test(s)) return true;
115
+ if (/(ghp_|gho_|github_pat_|xox[abprs]-|AIza|AKIA)[A-Za-z0-9_-]{8,}/.test(s)) return true; // a known prefix inside
116
+ return s.split(/[\/@:._~-]/).some((part) =>
117
+ (part.length >= 20 && /[a-z]/i.test(part) && /\d/.test(part)) || // long letter-and-digit run
118
+ /^[0-9a-f]{12,}$/i.test(part) || // hex string
119
+ (part.length >= (name ? 16 : 8) && /[a-z]/.test(part) && /[A-Z]/.test(part) && /\d/.test(part))); // mixed case + digit
120
+ }
121
+
122
+ /**
123
+ * A "plain name": short lower-case words joined by . _ - ~ (an optional leading @), as real package names and URL
124
+ * path words look. Random tokens fail it (mixed case, a word over 16 characters, many digits, hex).
125
+ */
126
+ export function plainName(x) {
127
+ const s = String(x);
128
+ if (!/^@?[a-z0-9]+([._~-][a-z0-9]+)*$/.test(s) || looksSecret(s)) return false;
129
+ // words up to 16 characters; longer ones (e.g. "modelcontextprotocol") only up to 24 and letters only
130
+ return s.replace(/^@/, "").split(/[._~-]/).every((w) => w.length <= 16 || (w.length <= 24 && /^[a-z]+$/.test(w)))
131
+ && (s.match(/\d/g) ?? []).length <= 8;
132
+ }
133
+
134
+ /**
135
+ * The command's base name, only when the command is a plain program or a path to one. A command string that also
136
+ * carries arguments ("npx -y pkg --token=…", "node C:\srv\server.js --key=…") is never printed.
137
+ */
138
+ export function commandName(cmd) {
139
+ if (typeof cmd !== "string") return null;
140
+ const c = cmd.trim();
141
+ const cut = Math.max(c.lastIndexOf("/"), c.lastIndexOf("\\"));
142
+ const dir = cut >= 0 ? c.slice(0, cut) : "", base = (cut >= 0 ? c.slice(cut + 1) : c).replace(/\.(exe|cmd|bat|ps1|sh)$/i, "");
143
+ if (/\s-|\s--/.test(dir) || /\s/.test(base)) return null; // arguments in the string
144
+ return /^[\w.+-]{1,64}$/.test(base) && !looksSecret(base, { name: true }) ? base : null;
145
+ }
146
+
147
+ /** The package a known runner starts, or null. Never returns an argument that does not look like a package name. */
148
+ export function packageOf(def) {
149
+ const cmd = commandName(def.command)?.toLowerCase();
150
+ const r = cmd ? RUNNERS[cmd] : null;
151
+ if (!r || !Array.isArray(def.args)) return null;
152
+ let args = def.args.map(String);
153
+ if (r.sub) { if (args[0] !== r.sub) return null; args = args.slice(1); }
154
+ const pick = firstPositional(args, r);
155
+ if (!pick || !PKG_RE.test(pick) || pick.length > 214 || looksSecret(pick)) return null;
156
+ const nameOnly = pick.replace(/^(@?[^@]+)@.*$/, "$1").replace(/:[^/]*$/, ""); // without @version or :tag
157
+ return nameOnly.split("/").every((part) => plainName(part)) ? { runner: cmd, name: pick } : null;
158
+ }
159
+
160
+ const TRANSPORTS = ["stdio", "http", "streamable-http", "sse", "ws"];
161
+
162
+ /**
163
+ * A remote URL reduced to its origin and a path in which every segment that is not a plain word is replaced by "*"
164
+ * (tokens are sometimes put in the path). No userinfo, query or fragment. A URL built from variables (`${…}`) is
165
+ * not shown at all. null when it is not a valid http(s)/ws(s) URL.
166
+ */
167
+ export function safeRemote(url, hosts = null, listed = null) {
168
+ const raw = String(url);
169
+ if (raw.includes("${")) return "(URL built from variables)";
170
+ try {
171
+ const u = new URL(raw);
172
+ if (!["http:", "https:", "ws:", "wss:"].includes(u.protocol)) return null;
173
+ // a URL the registry itself lists is public: its path is shown as written. Any other path keeps only the generic
174
+ // endpoint words below; every other segment is "*" (a lower-case token cannot be told apart from a word)
175
+ const key = remoteKey(raw);
176
+ const isListed = !!listed && key !== null && Object.hasOwn(listed, key);
177
+ const segs = u.pathname.split("/").map((seg) => (seg === "" || isListed || PATH_WORDS.has(seg) ? seg : "*"));
178
+ return `${u.protocol}//${shownHost(u, hosts)}${segs.join("/")}`;
179
+ } catch { return null; }
180
+ }
181
+
182
+ // generic endpoint words that carry no account or secret; anything else in an unlisted URL's path is shown as "*"
183
+ const PATH_WORDS = new Set(["mcp", "sse", "api", "v1", "v2", "v3", "http", "stream", "streamable", "streamable-http", "message", "messages", "rpc", "server", "servers", "tools", "public", "beta", "events", "ws", "socket", "jsonrpc"]);
184
+
185
+ /**
186
+ * The URL `mcp-remote` is started with (npx mcp-remote https://… --header …). It is an argument, so it is never shown
187
+ * as written: only its shortened form and its registry match are reported, through the same rules as a "url" entry.
188
+ * null for any other program, or when no URL follows the package.
189
+ */
190
+ export function remoteArgOf(def) {
191
+ const pkg = packageOf(def);
192
+ if (!pkg || normPackage("npm", pkg.name) !== "mcp-remote" || !Array.isArray(def.args)) return null;
193
+ const args = def.args.map(String), i = args.indexOf(pkg.name);
194
+ const url = i < 0 ? undefined : args.slice(i + 1).find((a) => /^(https?|wss?):\/\//i.test(a));
195
+ return url ?? null;
196
+ }
197
+
198
+ /**
199
+ * A host is shown whole only when it is a public host the registry itself lists, an IP address, a single-label name
200
+ * (localhost) or a bare two-label domain. Any other host keeps only its last two labels ("*.supabase.co"): per-account
201
+ * hosts are sometimes the credential (project references, tunnel ids), and a letters-only token cannot be told apart
202
+ * from a word. A label that itself looks like a credential is replaced by "*" even in a two-label domain.
203
+ */
204
+ export function shownHost(u, hosts) {
205
+ const name = u.hostname.toLowerCase(), port = u.port ? `:${u.port}` : "";
206
+ if (u.hostname.startsWith("[") || /^\d{1,3}(\.\d{1,3}){3}$/.test(name) || !name.includes(".")) return u.host;
207
+ if (hosts?.has(name)) return u.host;
208
+ const labels = name.split(".");
209
+ const tail = labels.slice(-2).map((l) => (looksSecret(l) || (l.length >= 12 && /\d/.test(l) && /[a-z]/.test(l)) ? "*" : l)); // the URL parser lower-cases hosts, so the mixed-case rule cannot see a token: 12+ characters of letters and digits is hidden
210
+ return (labels.length > 2 ? "*." : "") + tail.join(".") + port;
211
+ }
212
+
213
+ /**
214
+ * An environment variable name is shown only in the conventional shape: upper case, digits and underscores, every
215
+ * word short (GITHUB_PERSONAL_ACCESS_TOKEN). A lower-case or long single-word name could be a credential used as a
216
+ * key, so it is only counted.
217
+ */
218
+ export function conventionalEnvName(k) {
219
+ const s = String(k);
220
+ return /^[A-Z][A-Z0-9_]{0,63}$/.test(s) && !looksSecret(s, { name: true }) && s.split("_").every((w) => w.length <= 14);
221
+ }
222
+
223
+ /** One reportable server: names, counts and recognised identifiers only. */
224
+ export function describe(def, index = null) {
225
+ const url = def.url ?? def.serverUrl;
226
+ const declared = typeof def.type === "string" ? def.type.toLowerCase() : null;
227
+ const isWs = typeof url === "string" && /^wss?:/i.test(url);
228
+ const transport = declared ? (TRANSPORTS.includes(declared) ? declared : "other") : url ? (isWs ? "ws" : "http") : def.command ? "stdio" : "unknown";
229
+ const plainObj = (o) => o && typeof o === "object" && !Array.isArray(o);
230
+ const viaRemote = url ? null : remoteArgOf(def); // `npx mcp-remote <url>`: the package AND the remote it points at
231
+ const target = url ?? viaRemote;
232
+ return {
233
+ transport,
234
+ command: url ? null : commandName(def.command),
235
+ package: url ? null : packageOf(def),
236
+ remote: target ? safeRemote(target, index ? index.hostSet : null, index ? index.remotes : null) : null,
237
+ // an offline lookup against the registry snapshot (public data only: it matches, it never prints the config's value)
238
+ registry: target ? matchRemote(target, index) : matchPackage(packageOf(def), index),
239
+ args: Array.isArray(def.args) ? def.args.length : typeof def.args === "string" ? 1 : 0,
240
+ envVars: plainObj(def.env) ? Object.keys(def.env).length : 0,
241
+ envNames: plainObj(def.env) ? Object.keys(def.env).filter(conventionalEnvName) : [],
242
+ headers: plainObj(def.headers) ? Object.keys(def.headers).length : 0,
243
+ disabled: def.disabled === true,
244
+ };
245
+ }
246
+
247
+ /** Server names Claude Desktop started at some point: a log file mcp-server-NAME.log exists (logs persist). */
248
+ export function ranFromLogNames(names) {
249
+ return [...new Set(names.map((n) => /^mcp-server-(.+)\.log$/.exec(n)?.[1]).filter(Boolean))].sort();
250
+ }
251
+
252
+ /** JSON with comments and trailing commas (VS Code writes JSONC) → plain JSON text. Strings are left untouched. */
253
+ export function stripJsonc(text) {
254
+ text = String(text).replace(/^/, "");
255
+ try { JSON.parse(text); return text; } catch {} // plain JSON: no character-by-character pass (large ~/.claude.json)
256
+ let out = "", i = 0, inStr = false;
257
+ while (i < text.length) {
258
+ const c = text[i], n = text[i + 1];
259
+ if (inStr) {
260
+ out += c;
261
+ if (c === "\\") { out += n ?? ""; i += 2; continue; }
262
+ if (c === '"') inStr = false;
263
+ i++; continue;
264
+ }
265
+ if (c === '"') { inStr = true; out += c; i++; continue; }
266
+ if (c === "/" && n === "/") { while (i < text.length && text[i] !== "\n") i++; continue; }
267
+ if (c === "/" && n === "*") { i += 2; while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; i += 2; continue; }
268
+ out += c; i++;
269
+ }
270
+ // trailing commas before } or ] (outside strings: strings were copied verbatim, so re-scan with the same state machine)
271
+ let res = "", s = false;
272
+ for (let j = 0; j < out.length; j++) {
273
+ const c = out[j];
274
+ if (s) { res += c; if (c === "\\") { res += out[++j] ?? ""; } else if (c === '"') s = false; continue; }
275
+ if (c === '"') { s = true; res += c; continue; }
276
+ if (c === ",") { let k = j + 1; while (k < out.length && /\s/.test(out[k])) k++; if (out[k] === "}" || out[k] === "]") continue; }
277
+ res += c;
278
+ }
279
+ return res.replace(/^/, "");
280
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "protocolprobe-discover",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Lists the MCP servers configured on this machine and, offline, which are in the official MCP registry. Reads config files only; sends nothing anywhere; never prints secrets.",
5
+ "type": "module",
6
+ "license": "ISC",
7
+ "author": "ProtocolProbe <hello@protocolprobe.com>",
8
+ "homepage": "https://protocolprobe.com/docs/discover",
9
+ "keywords": [
10
+ "mcp",
11
+ "model-context-protocol",
12
+ "security",
13
+ "inventory",
14
+ "shadow-it"
15
+ ],
16
+ "bin": {
17
+ "protocolprobe-discover": "discover.mjs"
18
+ },
19
+ "main": "discover.mjs",
20
+ "files": [
21
+ "discover.mjs",
22
+ "lib.mjs",
23
+ "registry-index.mjs",
24
+ "registry-index.json.gz",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "scripts": {
32
+ "test": "node --test discover.test.mjs registry-match.test.mjs package.test.mjs"
33
+ }
34
+ }
Binary file
@@ -0,0 +1,87 @@
1
+ // Offline match of what is configured on a machine against the official MCP registry (feature #17).
2
+ // The index is a snapshot built from the registry data we already hold (build-index.mjs); matching never touches the
3
+ // network, so using it cannot send anything anywhere. Pure functions here (unit-tested); the file is read in discover.mjs.
4
+ //
5
+ // What is matched: a package a known runner starts (npm / PyPI / OCI image) and a remote URL's host + path. A key that
6
+ // belongs to exactly one registry server maps to its name; a key shared by several is stored as their count and shown
7
+ // as "several", never guessed.
8
+
9
+ export const INDEX_FORMAT = 1;
10
+ // registry server names look like "io.github.owner/name"; anything else is not stored or shown
11
+ const SERVER_NAME_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
12
+
13
+ /** The runner → package ecosystem a recognised package belongs to. */
14
+ export const ECOSYSTEM = { npx: "npm", bunx: "npm", pnpm: "npm", uvx: "pypi", pipx: "pypi", docker: "oci" };
15
+
16
+ /** Package name without version or tag, in the form the registry spells it. null when there is nothing to match. */
17
+ export function normPackage(ecosystem, name) {
18
+ let s = String(name).trim();
19
+ if (!s) return null;
20
+ if (ecosystem === "npm") return s.replace(/^(@?[^@]+)@.*$/, "$1").toLowerCase();
21
+ if (ecosystem === "pypi") return s.replace(/@.*$/, "").toLowerCase().replace(/[-_.]+/g, "-");
22
+ if (ecosystem === "oci") {
23
+ s = s.replace(/@sha256:[0-9a-f]+$/i, "").replace(/:[^/]*$/, "").toLowerCase();
24
+ return s.replace(/^(index\.)?docker\.io\//, "").replace(/^library\//, "");
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /** host + path of a remote URL, lower-case host, no query or fragment, no trailing slash. null if not a valid URL. */
30
+ export function remoteKey(url) {
31
+ try {
32
+ const u = new URL(String(url));
33
+ if (!["http:", "https:", "ws:", "wss:"].includes(u.protocol)) return null;
34
+ return u.host.toLowerCase() + u.pathname.replace(/\/+$/, "");
35
+ } catch { return null; }
36
+ }
37
+
38
+ /** Build the index from registry rows ({server, _meta}); only the latest version of each server counts. */
39
+ export function buildIndex(rows, snapshot) {
40
+ const packages = new Map(), remotes = new Map(), hosts = new Set();
41
+ let servers = 0;
42
+ const add = (map, key, name) => { if (!key) return; (map.get(key) ?? map.set(key, new Set()).get(key)).add(name); };
43
+ for (const r of rows) {
44
+ const s = r?.server;
45
+ if (!s || typeof s.name !== "string" || !SERVER_NAME_RE.test(s.name)) continue;
46
+ if (r._meta?.["io.modelcontextprotocol.registry/official"]?.isLatest === false) continue;
47
+ servers++;
48
+ for (const p of s.packages ?? []) {
49
+ const eco = p.registryType;
50
+ if (!["npm", "pypi", "oci"].includes(eco) || typeof p.identifier !== "string") continue;
51
+ const n = normPackage(eco, p.identifier);
52
+ if (n) add(packages, `${eco}:${n}`, s.name);
53
+ }
54
+ for (const x of s.remotes ?? []) {
55
+ const k = remoteKey(x?.url);
56
+ if (!k) continue;
57
+ add(remotes, k, s.name);
58
+ hosts.add(k.split("/")[0].replace(/:\d+$/, ""));
59
+ }
60
+ }
61
+ const flat = (m) => Object.fromEntries([...m].sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, v]) => [k, v.size === 1 ? [...v][0] : v.size]));
62
+ return { format: INDEX_FORMAT, snapshot, source: "official MCP registry, latest version of each server", servers, packages: flat(packages), remotes: flat(remotes), hosts: [...hosts].sort() };
63
+ }
64
+
65
+ /** True for an index object of the shape this code reads (a corrupt or foreign file must not be trusted). */
66
+ export function validIndex(x) {
67
+ const plain = (o) => o && typeof o === "object" && !Array.isArray(o);
68
+ return plain(x) && x.format === INDEX_FORMAT && typeof x.snapshot === "string" && Number.isInteger(x.servers) && plain(x.packages) && plain(x.remotes) && Array.isArray(x.hosts);
69
+ }
70
+
71
+ const result = (v) => (typeof v === "string" ? { state: "listed", name: v, url: `https://protocolprobe.com/mcp/servers/${v}` } : { state: "several", count: v });
72
+
73
+ /** Registry match for a recognised package ({runner, name}) or a remote URL; null when there is nothing to match. */
74
+ export function matchPackage(pkg, index) {
75
+ if (!index || !pkg) return null;
76
+ const eco = ECOSYSTEM[pkg.runner], n = eco ? normPackage(eco, pkg.name) : null;
77
+ if (!n) return null;
78
+ const v = Object.hasOwn(index.packages, `${eco}:${n}`) ? index.packages[`${eco}:${n}`] : undefined;
79
+ return v === undefined ? { state: "not-listed" } : result(v);
80
+ }
81
+ export function matchRemote(url, index) {
82
+ if (!index) return null;
83
+ const k = remoteKey(url);
84
+ if (!k) return null;
85
+ const v = Object.hasOwn(index.remotes, k) ? index.remotes[k] : undefined;
86
+ return v === undefined ? { state: "not-listed" } : result(v);
87
+ }