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.
- eclipse_bazaar/__init__.py +15 -0
- eclipse_bazaar/_version.py +2 -0
- eclipse_bazaar/catalog.py +166 -0
- eclipse_bazaar/check.py +233 -0
- eclipse_bazaar/cli.py +178 -0
- eclipse_bazaar/client.py +53 -0
- eclipse_bazaar/configparse.py +314 -0
- eclipse_bazaar/data/catalog.json +1 -0
- eclipse_bazaar/data/corpus.jsonl +101 -0
- eclipse_bazaar/data/corpus.manifest.json +8 -0
- eclipse_bazaar/data/verdict_snapshot.json +1825 -0
- eclipse_bazaar/integrations/__init__.py +3 -0
- eclipse_bazaar/integrations/crewai_tools.py +44 -0
- eclipse_bazaar/integrations/langchain_tools.py +43 -0
- eclipse_bazaar/mcp_server.py +314 -0
- eclipse_bazaar-0.1.0.dist-info/METADATA +109 -0
- eclipse_bazaar-0.1.0.dist-info/RECORD +21 -0
- eclipse_bazaar-0.1.0.dist-info/WHEEL +5 -0
- eclipse_bazaar-0.1.0.dist-info/entry_points.txt +3 -0
- eclipse_bazaar-0.1.0.dist-info/licenses/LICENSE +21 -0
- eclipse_bazaar-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|