eclipse-bazaar 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,314 @@
1
+ # Eclipse Bazaar · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
2
+ """MCP client config discovery + identity extraction for `bazaar check`.
3
+
4
+ Privacy law (gate S3) is structural here, not a promise:
5
+ * redact_config() runs on the parsed document before anything else
6
+ touches it. Every `env` mapping is replaced by a key *count*;
7
+ header mappings likewise; secret-named keys are dropped to a
8
+ placeholder; `docker run -e KEY=value` args keep only the key.
9
+ * extract_identity() only ever sees the redacted copy and returns a
10
+ small identity dict (kind, name, pin) — raw args, env, and URLs'
11
+ query strings never leave this module.
12
+ * URL userinfo (user[:password]@) is stripped at extraction, on both
13
+ the entry-URL path and the package-spec path: credentials are
14
+ ABSENT from every identity, never masked — a masked value still
15
+ proves a credential was present and reveals its shape (gate S3,
16
+ card PY-D2).
17
+ * Nothing in this module executes a configured server, opens a
18
+ socket, or reads a tool description (gate C2 fence).
19
+ """
20
+ import json
21
+ import os
22
+ import re
23
+ from pathlib import Path
24
+
25
+ SECRET_KEY_NAMES = {"authorization", "api_key", "apikey", "api-key",
26
+ "token", "access_token", "secret", "password",
27
+ "passwd", "client_secret"}
28
+ SCRIPT_SUFFIXES = {".py", ".js", ".mjs", ".cjs", ".ts"}
29
+ VALUE_FLAGS = {"-e", "--env", "-v", "--volume", "--mount", "-p", "--publish",
30
+ "--name", "--network", "--hostname", "-w", "--workdir",
31
+ "--entrypoint", "--user", "-u", "--pull", "--platform",
32
+ "--env-file", "--from", "--with", "--python", "--package",
33
+ "-p", "--index-url", "--extra-index-url"}
34
+
35
+
36
+ # ------------------------------------------------------------- discovery
37
+ def default_config_paths():
38
+ """Known MCP client config locations that exist on this machine.
39
+ The CLI prints this list before reading any of them."""
40
+ home = Path.home()
41
+ candidates = [
42
+ home / "Library/Application Support/Claude/claude_desktop_config.json",
43
+ home / ".config/Claude/claude_desktop_config.json",
44
+ home / ".claude.json",
45
+ home / ".cursor/mcp.json",
46
+ home / ".codeium/windsurf/mcp_config.json",
47
+ home / ".config/Code/User/mcp.json",
48
+ Path.cwd() / ".mcp.json",
49
+ ]
50
+ appdata = os.environ.get("APPDATA")
51
+ if appdata:
52
+ candidates.insert(0, Path(appdata) / "Claude/claude_desktop_config.json")
53
+ seen, out = set(), []
54
+ for p in candidates:
55
+ rp = str(p)
56
+ if rp not in seen and p.exists():
57
+ seen.add(rp)
58
+ out.append(p)
59
+ return out
60
+
61
+
62
+ # ------------------------------------------------------------- redaction
63
+ def _redact_envish(value):
64
+ return "<redacted>"
65
+
66
+
67
+ def redact_config(node, _prev=None):
68
+ """Deep-copy a parsed config with every secret carrier neutralised.
69
+ Runs before extraction; the original is discarded by the caller."""
70
+ if isinstance(node, dict):
71
+ out = {}
72
+ for k, v in node.items():
73
+ lk = str(k).lower()
74
+ if lk == "env" and isinstance(v, dict):
75
+ out[k] = {"<redacted-env-key-count>": len(v)}
76
+ elif lk == "headers" and isinstance(v, dict):
77
+ out[k] = {"<redacted-header-key-count>": len(v)}
78
+ elif lk in SECRET_KEY_NAMES:
79
+ out[k] = _redact_envish(v)
80
+ else:
81
+ out[k] = redact_config(v)
82
+ return out
83
+ if isinstance(node, list):
84
+ out = []
85
+ for i, item in enumerate(node):
86
+ prev = node[i - 1] if i > 0 else None
87
+ if (isinstance(item, str) and prev in ("-e", "--env")
88
+ and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", item)):
89
+ out.append(item.split("=", 1)[0] + "=<redacted>")
90
+ else:
91
+ out.append(redact_config(item))
92
+ return out
93
+ return node
94
+
95
+
96
+ def server_entries(doc):
97
+ """Yield (config_name, entry_dict) from a parsed client config.
98
+ Supports the `mcpServers` shape (Claude Desktop/Code, Cursor,
99
+ Windsurf, project .mcp.json) and the `servers` shape (VS Code)."""
100
+ if not isinstance(doc, dict):
101
+ return
102
+ for key in ("mcpServers", "servers"):
103
+ block = doc.get(key)
104
+ if isinstance(block, dict):
105
+ for name, entry in block.items():
106
+ if isinstance(entry, dict):
107
+ yield str(name), entry
108
+
109
+
110
+ # ------------------------------------------------------------- identity
111
+ def _strip_url_userinfo(text):
112
+ """Drop userinfo (user[:password]@) from the authority of a
113
+ URL-shaped string. Credentials are removed outright, never masked
114
+ (gate S3 / PY-D2): a masked value still proves a credential was
115
+ present and leaks its shape. Non-URL strings pass through."""
116
+ if "://" not in text:
117
+ return text
118
+ scheme, rest = text.split("://", 1)
119
+ end = len(rest)
120
+ for ch in "/?#":
121
+ i = rest.find(ch)
122
+ if i != -1:
123
+ end = min(end, i)
124
+ authority, tail = rest[:end], rest[end:]
125
+ if "@" in authority:
126
+ authority = authority.rsplit("@", 1)[-1]
127
+ return scheme + "://" + authority + tail
128
+
129
+
130
+ def _parse_spec(spec):
131
+ """Package spec -> (name, pin). Handles name@1.2, name==1.2,
132
+ @scope/name@1.2, github:owner/repo. pin 'latest'/'*' -> None."""
133
+ spec = spec.strip().strip('"').strip("'")
134
+ if not spec:
135
+ return None, None
136
+ spec = _strip_url_userinfo(spec)
137
+ if spec.startswith("github:"):
138
+ return spec.split("/", 1)[-1].lower(), None
139
+ name, pin = spec, None
140
+ for sep in ("==", ">="):
141
+ if sep in spec:
142
+ name, pin = spec.split(sep, 1)
143
+ break
144
+ else:
145
+ if spec.startswith("@"):
146
+ rest = spec[1:]
147
+ if "/" in rest:
148
+ scope_name, _, tail = rest.partition("/")
149
+ if "@" in tail:
150
+ nm, _, pn = tail.partition("@")
151
+ name, pin = "@" + scope_name + "/" + nm, pn
152
+ else:
153
+ name = spec
154
+ elif "@" in spec:
155
+ name, _, pin = spec.partition("@")
156
+ if pin in ("", "latest", "*"):
157
+ pin = None
158
+ return name.lower(), pin
159
+
160
+
161
+ def _first_positional(args):
162
+ """First non-flag arg, skipping values of known value-flags and any
163
+ -e payloads (already redacted to KEY=<redacted>)."""
164
+ i = 0
165
+ while i < len(args):
166
+ a = args[i]
167
+ if a == "--":
168
+ return args[i + 1] if i + 1 < len(args) else None
169
+ if a in VALUE_FLAGS:
170
+ i += 2
171
+ continue
172
+ if a.startswith("-"):
173
+ i += 1
174
+ continue
175
+ return a
176
+ return None
177
+
178
+
179
+ def _package_from_runner_args(args):
180
+ """npx/uvx-style args -> parsed spec of the package being run."""
181
+ if "--from" in args:
182
+ spec = args[args.index("--from") + 1] if args.index("--from") + 1 < len(args) else None
183
+ return _parse_spec(spec) if spec else (None, None)
184
+ if "--package" in args or "-p" in args:
185
+ idx = args.index("--package") if "--package" in args else args.index("-p")
186
+ spec = args[idx + 1] if idx + 1 < len(args) else None
187
+ return _parse_spec(spec) if spec else (None, None)
188
+ pos = _first_positional(args)
189
+ return _parse_spec(pos) if pos else (None, None)
190
+
191
+
192
+ def _docker_image(args):
193
+ if "run" not in args:
194
+ return None, None
195
+ rest = args[args.index("run") + 1:]
196
+ img = _first_positional(rest)
197
+ if not img:
198
+ return None, None
199
+ pin = None
200
+ name = img
201
+ if "@" in img:
202
+ name, _, pin = img.partition("@")
203
+ else:
204
+ last = img.rsplit("/", 1)[-1]
205
+ if ":" in last:
206
+ name = img[: img.rfind(":")]
207
+ pin = last.split(":", 1)[1]
208
+ if pin == "latest":
209
+ pin = None
210
+ return name.lower(), pin
211
+
212
+
213
+ def _local_identity(arg):
214
+ p = Path(arg)
215
+ if p.suffix.lower() in SCRIPT_SUFFIXES:
216
+ parent = p.parent.name
217
+ return (parent or p.stem).lower()
218
+ return (p.name or p.stem).lower()
219
+
220
+
221
+ def extract_identity(entry):
222
+ """Redacted config entry -> {"kind","name","pin"} or None.
223
+ None means no identity could be extracted; the caller classifies
224
+ the entry UNRESOLVABLE with `unresolvable_reason(entry)`."""
225
+ url = entry.get("url") or entry.get("httpUrl") or entry.get("serverUrl")
226
+ if isinstance(url, str) and url.strip():
227
+ from urllib.parse import urlparse
228
+ u = urlparse(_strip_url_userinfo(url.strip()))
229
+ if u.netloc:
230
+ host = u.hostname or ""
231
+ if ":" in host and not host.startswith("["):
232
+ host = f"[{host}]"
233
+ try:
234
+ port = u.port
235
+ except ValueError:
236
+ port = None
237
+ netloc = host + (f":{port}" if port else "")
238
+ name = (netloc + u.path).rstrip("/").lower()
239
+ else:
240
+ # Schemeless authority form (user:pass@host/path): the
241
+ # first segment is the authority — strip userinfo there too.
242
+ first, sep, rest = u.path.partition("/")
243
+ if "@" in first:
244
+ first = first.rsplit("@", 1)[-1]
245
+ name = (first + sep + rest).rstrip("/").lower()
246
+ return {"kind": "remote", "name": name, "pin": None}
247
+ command = entry.get("command")
248
+ if not isinstance(command, str) or not command.strip():
249
+ return None
250
+ base = Path(command.strip()).name.lower()
251
+ if base.endswith(".exe"):
252
+ base = base[:-4]
253
+ args = [a for a in (entry.get("args") or []) if isinstance(a, str)]
254
+ if base in ("npx", "npm", "pnpm", "bunx", "yarn"):
255
+ work = args[1:] if (base == "yarn" and args[:1] == ["dlx"]) else args
256
+ if base == "npm" and work[:1] == ["exec"]:
257
+ work = work[1:]
258
+ name, pin = _package_from_runner_args(work)
259
+ return {"kind": "package", "name": name, "pin": pin} if name else None
260
+ if base == "uvx":
261
+ name, pin = _package_from_runner_args(args)
262
+ return {"kind": "package", "name": name, "pin": pin} if name else None
263
+ if base == "uv":
264
+ work = args
265
+ if work[:2] == ["tool", "run"]:
266
+ work = work[2:]
267
+ elif work[:1] == ["run"]:
268
+ work = work[1:]
269
+ if work and work[0].endswith(".py"):
270
+ return {"kind": "local", "name": _local_identity(work[0]), "pin": None}
271
+ name, pin = _package_from_runner_args(work)
272
+ return {"kind": "package", "name": name, "pin": pin} if name else None
273
+ if base in ("docker", "podman"):
274
+ name, pin = _docker_image(args)
275
+ return {"kind": "image", "name": name, "pin": pin} if name else None
276
+ if base in ("python", "python3", "py"):
277
+ if "-m" in args:
278
+ mod = args[args.index("-m") + 1] if args.index("-m") + 1 < len(args) else None
279
+ return {"kind": "python-module", "name": mod.lower(), "pin": None} if mod else None
280
+ if "-c" in args:
281
+ return None
282
+ target = _first_positional(args)
283
+ return {"kind": "local", "name": _local_identity(target), "pin": None} if target else None
284
+ if base == "node":
285
+ target = _first_positional(args)
286
+ return {"kind": "local", "name": _local_identity(target), "pin": None} if target else None
287
+ if base in ("sh", "bash", "zsh", "cmd", "powershell", "pwsh", "env"):
288
+ return None
289
+ return {"kind": "command", "name": base, "pin": None}
290
+
291
+
292
+ def unresolvable_reason(entry):
293
+ if not isinstance(entry, dict):
294
+ return "entry is not an object"
295
+ if not entry.get("command") and not (entry.get("url") or entry.get("httpUrl")):
296
+ return "no command or url to extract an identity from"
297
+ cmd = entry.get("command")
298
+ if isinstance(cmd, str):
299
+ base = Path(cmd.strip()).name.lower()
300
+ if base in ("sh", "bash", "zsh", "cmd", "powershell", "pwsh", "env"):
301
+ return f"shell wrapper command ({base}) — the wrapped target is not visible in the config"
302
+ if base in ("python", "python3", "py") and "-c" in (entry.get("args") or []):
303
+ return "inline interpreter code (-c) — no package or path identity"
304
+ return "no package, image, path, or URL identity could be extracted"
305
+
306
+
307
+ def load_config_entries(path):
308
+ """(entries, error) — entries are (name, redacted_entry) pairs."""
309
+ try:
310
+ doc = json.loads(Path(path).read_text())
311
+ except Exception as e:
312
+ return [], f"unparseable config ({type(e).__name__})"
313
+ redacted = redact_config(doc)
314
+ return list(server_entries(redacted)), None