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,15 @@
|
|
|
1
|
+
# Eclipse Bazaar · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
|
|
2
|
+
"""eclipse-bazaar — one distribution, three surfaces (gate A5):
|
|
3
|
+
|
|
4
|
+
* the Eclipse Bazaar MCP server (console script: eclipse-bazaar)
|
|
5
|
+
* the `bazaar` CLI (search / product / categories / check)
|
|
6
|
+
* the Python SDK (from eclipse_bazaar import Bazaar)
|
|
7
|
+
|
|
8
|
+
Catalog data ships as a stamped bundled snapshot; verdict summaries
|
|
9
|
+
ship as a hash-pinned verdict snapshot (gate A1). Powered by Cyan
|
|
10
|
+
Eclipse Technologies (cetech.ai).
|
|
11
|
+
"""
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .client import Bazaar
|
|
14
|
+
|
|
15
|
+
__all__ = ["__version__", "Bazaar"]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Eclipse Bazaar · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
|
|
2
|
+
"""Data access for the eclipse-bazaar distribution.
|
|
3
|
+
|
|
4
|
+
Resolution order (first hit wins):
|
|
5
|
+
catalog $BAZAAR_CATALOG -> repo-root catalog.json (repo mode only,
|
|
6
|
+
kept canonical by sync_verdicts.py) -> bundled
|
|
7
|
+
data/catalog.json
|
|
8
|
+
corpus see eclipse_bazaar.check (bulk file, sha256-verified)
|
|
9
|
+
scorecard bundled verdict snapshot first (stamped + hash-pinned by
|
|
10
|
+
tools/make_verdict_snapshot.py); repo mode falls back to the
|
|
11
|
+
live Test Bench card when the snapshot lacks the ref.
|
|
12
|
+
|
|
13
|
+
Gate A1: packaged mode never reaches for ../test-bench/results/ — the
|
|
14
|
+
snapshot is the only verdict source an installed wheel needs.
|
|
15
|
+
|
|
16
|
+
Repo mode is a proven state, not a path-existence guess (PY-D1): it
|
|
17
|
+
holds only inside the bazaar-mcp checkout itself (its own markers —
|
|
18
|
+
.git and server.json at the repo root) or when BAZAAR_REPO_MODE=1 is
|
|
19
|
+
set explicitly. An installed wheel is snapshot-only by construction:
|
|
20
|
+
files planted at the computed install-tree levels are never served.
|
|
21
|
+
"""
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import urllib.request
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
PKG_DIR = Path(__file__).resolve().parent
|
|
28
|
+
DATA_DIR = PKG_DIR / "data"
|
|
29
|
+
REPO_ROOT = PKG_DIR.parents[1] # dev layout: bazaar-mcp/ (site-packages when installed)
|
|
30
|
+
CETECH_DIR = PKG_DIR.parents[2] # dev layout: cyan-eclipse/ (meaningless when installed)
|
|
31
|
+
|
|
32
|
+
REFRESH_URLS = [
|
|
33
|
+
"https://raw.githubusercontent.com/CYAN-ECLIPSE-TECHNOLOGIES-LLC/bazaar-mcp/main/catalog.json",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
_LAST_SOURCE = "bundled snapshot"
|
|
37
|
+
_SNAPSHOT = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _repo_mode() -> bool:
|
|
41
|
+
"""True only in the bazaar-mcp checkout itself (proven by its own
|
|
42
|
+
markers) or under an explicit BAZAAR_REPO_MODE=1 opt-in. This gates
|
|
43
|
+
both repo-mode fallbacks below; an installed wheel never satisfies
|
|
44
|
+
it by accident of the install layout (PY-D1)."""
|
|
45
|
+
if os.environ.get("BAZAAR_REPO_MODE") == "1":
|
|
46
|
+
return True
|
|
47
|
+
return (REPO_ROOT / ".git").exists() and \
|
|
48
|
+
(REPO_ROOT / "server.json").exists()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def catalog_path() -> Path:
|
|
52
|
+
env = os.environ.get("BAZAAR_CATALOG")
|
|
53
|
+
if env:
|
|
54
|
+
return Path(env)
|
|
55
|
+
repo_cat = REPO_ROOT / "catalog.json"
|
|
56
|
+
if _repo_mode() and repo_cat.exists():
|
|
57
|
+
return repo_cat
|
|
58
|
+
return DATA_DIR / "catalog.json"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def fetch_catalog(url: str, timeout: int = 15) -> dict:
|
|
62
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
63
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
64
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def catalog_dict(refresh: bool = False) -> dict:
|
|
68
|
+
"""The catalog as a dict. refresh=True tries the public HTTPS JSON
|
|
69
|
+
source first (one bulk document — never per-product queries) and
|
|
70
|
+
falls back to the local snapshot on any failure."""
|
|
71
|
+
global _LAST_SOURCE
|
|
72
|
+
if refresh:
|
|
73
|
+
for url in REFRESH_URLS:
|
|
74
|
+
try:
|
|
75
|
+
cat = fetch_catalog(url)
|
|
76
|
+
if isinstance(cat, dict) and "products" in cat:
|
|
77
|
+
_LAST_SOURCE = f"refreshed from {url}"
|
|
78
|
+
return cat
|
|
79
|
+
except Exception:
|
|
80
|
+
continue
|
|
81
|
+
path = catalog_path()
|
|
82
|
+
cat = json.loads(path.read_text())
|
|
83
|
+
_LAST_SOURCE = ("repo catalog.json" if path == REPO_ROOT / "catalog.json"
|
|
84
|
+
else "bundled snapshot")
|
|
85
|
+
return cat
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def freshness(cat: dict) -> dict:
|
|
89
|
+
return {"catalog_built": cat.get("built"),
|
|
90
|
+
"sources": cat.get("sources", []),
|
|
91
|
+
"served_from": _LAST_SOURCE,
|
|
92
|
+
"note": "Statuses and verdicts are as of the catalog build date; "
|
|
93
|
+
"scorecard summaries come from the stamped verdict "
|
|
94
|
+
"snapshot bundled with this package (hash-pinned to the "
|
|
95
|
+
"Test Bench cards), or the referenced card in repo mode."}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------ scorecards
|
|
99
|
+
def _check_rows(sc: dict):
|
|
100
|
+
"""Normalise the three Test Bench card shapes to (name, verdict):
|
|
101
|
+
list-of-dicts (original), dict-of-results (later cards), and
|
|
102
|
+
checks-absent (scoped re-bench cards -> no rows)."""
|
|
103
|
+
checks = sc.get("checks")
|
|
104
|
+
if isinstance(checks, list):
|
|
105
|
+
for ch in checks:
|
|
106
|
+
if isinstance(ch, dict):
|
|
107
|
+
yield ch.get("name"), ch.get("verdict", "other")
|
|
108
|
+
elif isinstance(checks, dict):
|
|
109
|
+
for name, res in checks.items():
|
|
110
|
+
if isinstance(res, dict):
|
|
111
|
+
if "verdict" in res:
|
|
112
|
+
yield name, res["verdict"]
|
|
113
|
+
elif "pass" in res:
|
|
114
|
+
yield name, "pass" if res["pass"] else "fail"
|
|
115
|
+
else:
|
|
116
|
+
yield name, "other"
|
|
117
|
+
else:
|
|
118
|
+
yield name, "other"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def summarize_card(sc: dict) -> dict:
|
|
122
|
+
"""Scorecard file -> the summary the server returns. Pure function —
|
|
123
|
+
tools/make_verdict_snapshot.py builds the snapshot with this exact
|
|
124
|
+
code, which is what makes packaged/repo parity testable. (The
|
|
125
|
+
pre-packaging server crashed on dict-shaped and checks-absent cards;
|
|
126
|
+
the summary for list-shaped cards is unchanged.)"""
|
|
127
|
+
counts = {"pass": 0, "fail": 0, "na": 0, "other": 0}
|
|
128
|
+
na_names, fail_names = [], []
|
|
129
|
+
total = 0
|
|
130
|
+
for name, v in _check_rows(sc):
|
|
131
|
+
total += 1
|
|
132
|
+
counts[v if v in counts else "other"] += 1
|
|
133
|
+
if v == "na":
|
|
134
|
+
na_names.append(name)
|
|
135
|
+
if v == "fail":
|
|
136
|
+
fail_names.append(name)
|
|
137
|
+
return {"tool": sc.get("tool") or sc.get("product"),
|
|
138
|
+
"version": sc.get("version"),
|
|
139
|
+
"bench_date": sc.get("bench_date") or sc.get("date"),
|
|
140
|
+
"bench_version": sc.get("bench_version"),
|
|
141
|
+
"environment": sc.get("environment"),
|
|
142
|
+
"verdict": sc.get("verdict"),
|
|
143
|
+
"checks_total": total, "counts": counts,
|
|
144
|
+
"not_tested_checks": na_names,
|
|
145
|
+
"failed_checks": fail_names}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def load_snapshot():
|
|
149
|
+
global _SNAPSHOT
|
|
150
|
+
if _SNAPSHOT is None:
|
|
151
|
+
path = DATA_DIR / "verdict_snapshot.json"
|
|
152
|
+
_SNAPSHOT = json.loads(path.read_text()) if path.exists() else {}
|
|
153
|
+
return _SNAPSHOT
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def scorecard_summary(ref: str):
|
|
157
|
+
"""Summary for a catalog scorecard_ref, or None if unavailable."""
|
|
158
|
+
snap = load_snapshot()
|
|
159
|
+
rec = (snap.get("cards") or {}).get(ref)
|
|
160
|
+
if rec is not None:
|
|
161
|
+
return rec["summary"]
|
|
162
|
+
if _repo_mode():
|
|
163
|
+
path = CETECH_DIR / ref # the live Test Bench card, checkout only
|
|
164
|
+
if path.exists():
|
|
165
|
+
return summarize_card(json.loads(path.read_text()))
|
|
166
|
+
return None
|
eclipse_bazaar/check.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# Eclipse Bazaar · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
|
|
2
|
+
"""`bazaar check` — marketplace-standing check for configured MCP servers.
|
|
3
|
+
|
|
4
|
+
Phase 1 (this build). Every configured server is classified into
|
|
5
|
+
exactly one of four classes, from config text + the published Bazaar
|
|
6
|
+
corpus only:
|
|
7
|
+
|
|
8
|
+
CERTIFIED listed, with a Tier-2 Test Bench verdict on record
|
|
9
|
+
LISTED in the catalog, no Tier-2 verdict (listing status shown)
|
|
10
|
+
NOT LISTED no Bazaar record for the resolved identity (a neutral
|
|
11
|
+
fact — not a warning, not a failure)
|
|
12
|
+
UNRESOLVABLE no identity could be extracted from the config entry
|
|
13
|
+
|
|
14
|
+
Later-phase vocabulary for not-yet-public record classes does not exist
|
|
15
|
+
in this binary by construction: score/record fields for unpublished
|
|
16
|
+
data classes are absent — not null, not stubbed, not behind a flag —
|
|
17
|
+
and ship only in a later phase, once the underlying records themselves
|
|
18
|
+
are public (the gate binds the data, not the channel).
|
|
19
|
+
|
|
20
|
+
Fences (breach = gate K1): this module never executes a configured
|
|
21
|
+
server, never reads a tool description, never contacts a configured
|
|
22
|
+
server's endpoint, and never scores or ranks an unlisted server.
|
|
23
|
+
There is deliberately no aggregate score over the report.
|
|
24
|
+
|
|
25
|
+
Architecture (gate S1): the corpus is one bulk file, downloaded whole
|
|
26
|
+
and sha256-verified against its manifest before use, then joined
|
|
27
|
+
locally. Zero per-server queries leave the machine. The bundled corpus
|
|
28
|
+
snapshot is the offline default; a per-name lookup mode does not exist.
|
|
29
|
+
"""
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import urllib.request
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from .catalog import DATA_DIR
|
|
37
|
+
from .configparse import (default_config_paths, extract_identity,
|
|
38
|
+
load_config_entries, unresolvable_reason)
|
|
39
|
+
|
|
40
|
+
CERTIFIED = "CERTIFIED"
|
|
41
|
+
LISTED = "LISTED"
|
|
42
|
+
NOT_LISTED = "NOT LISTED"
|
|
43
|
+
UNRESOLVABLE = "UNRESOLVABLE"
|
|
44
|
+
CLASSES = (CERTIFIED, LISTED, NOT_LISTED, UNRESOLVABLE)
|
|
45
|
+
|
|
46
|
+
CORPUS_BASE = ("https://raw.githubusercontent.com/"
|
|
47
|
+
"CYAN-ECLIPSE-TECHNOLOGIES-LLC/bazaar-mcp/main/corpus")
|
|
48
|
+
CORPUS_URL = CORPUS_BASE + "/corpus.jsonl"
|
|
49
|
+
MANIFEST_URL = CORPUS_BASE + "/corpus.manifest.json"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CorpusError(Exception):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------- corpus
|
|
57
|
+
def _verify(raw: bytes, manifest: dict):
|
|
58
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
59
|
+
if manifest.get("sha256") != digest:
|
|
60
|
+
raise CorpusError(
|
|
61
|
+
f"corpus sha256 mismatch: manifest {manifest.get('sha256')}, "
|
|
62
|
+
f"downloaded {digest} — refusing unverified corpus")
|
|
63
|
+
return digest
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_corpus(raw: bytes):
|
|
67
|
+
records = []
|
|
68
|
+
for line in raw.decode("utf-8").splitlines():
|
|
69
|
+
line = line.strip()
|
|
70
|
+
if line:
|
|
71
|
+
records.append(json.loads(line))
|
|
72
|
+
return records
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _bundled_corpus():
|
|
76
|
+
raw = (DATA_DIR / "corpus.jsonl").read_bytes()
|
|
77
|
+
manifest = json.loads((DATA_DIR / "corpus.manifest.json").read_text())
|
|
78
|
+
digest = _verify(raw, manifest)
|
|
79
|
+
return _parse_corpus(raw), {"source": "bundled snapshot",
|
|
80
|
+
"sha256": digest, "manifest": manifest}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _download(url, timeout=20):
|
|
84
|
+
req = urllib.request.Request(url, headers={"Accept": "*/*"})
|
|
85
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
86
|
+
return resp.read()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _refreshed_corpus():
|
|
90
|
+
manifest = json.loads(_download(MANIFEST_URL).decode("utf-8"))
|
|
91
|
+
raw = _download(CORPUS_URL)
|
|
92
|
+
digest = _verify(raw, manifest)
|
|
93
|
+
cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) \
|
|
94
|
+
/ "eclipse-bazaar"
|
|
95
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
(cache / "corpus.jsonl").write_bytes(raw)
|
|
97
|
+
(cache / "corpus.manifest.json").write_text(json.dumps(manifest, indent=1))
|
|
98
|
+
return _parse_corpus(raw), {"source": f"refreshed from {CORPUS_URL}",
|
|
99
|
+
"sha256": digest, "manifest": manifest}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_corpus(corpus_path=None, refresh=False):
|
|
103
|
+
"""(records, meta). Explicit local file wins; then refresh; the
|
|
104
|
+
bundled snapshot is the always-available fallback."""
|
|
105
|
+
if corpus_path:
|
|
106
|
+
raw = Path(corpus_path).read_bytes()
|
|
107
|
+
manifest_path = Path(str(corpus_path).replace(
|
|
108
|
+
"corpus.jsonl", "corpus.manifest.json"))
|
|
109
|
+
meta = {"source": f"local file {corpus_path}", "sha256": None,
|
|
110
|
+
"manifest": None}
|
|
111
|
+
if manifest_path.exists():
|
|
112
|
+
manifest = json.loads(manifest_path.read_text())
|
|
113
|
+
meta["sha256"] = _verify(raw, manifest)
|
|
114
|
+
meta["manifest"] = manifest
|
|
115
|
+
meta["source"] += " (sha256 verified against sibling manifest)"
|
|
116
|
+
return _parse_corpus(raw), meta
|
|
117
|
+
if refresh:
|
|
118
|
+
try:
|
|
119
|
+
return _refreshed_corpus()
|
|
120
|
+
except Exception as e:
|
|
121
|
+
records, meta = _bundled_corpus()
|
|
122
|
+
meta["refresh_error"] = f"{type(e).__name__}: {e}"
|
|
123
|
+
meta["source"] += " (refresh failed, bundled fallback)"
|
|
124
|
+
return records, meta
|
|
125
|
+
return _bundled_corpus()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_index(records):
|
|
129
|
+
index = {}
|
|
130
|
+
for rec in records:
|
|
131
|
+
for ident in rec.get("identities", []):
|
|
132
|
+
index.setdefault(ident.lower(), rec)
|
|
133
|
+
return index
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------ classify
|
|
137
|
+
def classify(config_name, entry, index):
|
|
138
|
+
identity = extract_identity(entry)
|
|
139
|
+
if identity is None:
|
|
140
|
+
return {"config_name": config_name, "class": UNRESOLVABLE,
|
|
141
|
+
"identity": None,
|
|
142
|
+
"reason": unresolvable_reason(entry),
|
|
143
|
+
"listing": None, "drift": None, "version_note": None}
|
|
144
|
+
rec = index.get(identity["name"])
|
|
145
|
+
if rec is None and "/" in identity["name"]:
|
|
146
|
+
# Qualified reference (registry/owner image path, npm scope,
|
|
147
|
+
# remote authority): standing binds to the FULL qualified
|
|
148
|
+
# identity. A bare-name match under a foreign qualifier is a
|
|
149
|
+
# different source — never an inheritance path (PY-D3). It is
|
|
150
|
+
# reported NOT LISTED with the collision named, so the reader
|
|
151
|
+
# can tell an impersonation shape from an unknown name.
|
|
152
|
+
base = identity["name"].rsplit("/", 1)[-1]
|
|
153
|
+
base_rec = index.get(base)
|
|
154
|
+
if base_rec is not None:
|
|
155
|
+
hint = next((i for i in base_rec.get("identities", [])
|
|
156
|
+
if "/" in i), base_rec["id"])
|
|
157
|
+
return {"config_name": config_name, "class": NOT_LISTED,
|
|
158
|
+
"identity": identity,
|
|
159
|
+
"reason": "no Bazaar listing record for this "
|
|
160
|
+
f"identity; a product named {base!r} is "
|
|
161
|
+
f"listed under a different source ({hint}) "
|
|
162
|
+
"— standing does not transfer across "
|
|
163
|
+
"registries, owners, or scopes",
|
|
164
|
+
"listing": None, "drift": None, "version_note": None}
|
|
165
|
+
if rec is None:
|
|
166
|
+
return {"config_name": config_name, "class": NOT_LISTED,
|
|
167
|
+
"identity": identity,
|
|
168
|
+
"reason": "no Bazaar listing record for this identity",
|
|
169
|
+
"listing": None, "drift": None, "version_note": None}
|
|
170
|
+
cls = CERTIFIED if (rec.get("certified") and rec.get("bench_verdict")) else LISTED
|
|
171
|
+
drift = None
|
|
172
|
+
version_note = None
|
|
173
|
+
pin, listed = identity.get("pin"), rec.get("listing_version")
|
|
174
|
+
if pin and listed and pin != listed:
|
|
175
|
+
drift = {"pinned": pin, "listed": listed,
|
|
176
|
+
"note": f"config pins version {pin}; the listing's current "
|
|
177
|
+
f"version is {listed} — the tested standing below "
|
|
178
|
+
f"belongs to the listed version"}
|
|
179
|
+
elif pin and not listed:
|
|
180
|
+
# The pin cannot be checked: the listing carries no version to
|
|
181
|
+
# check it against. Disclose, never imply verification (PY-D5).
|
|
182
|
+
version_note = ("version not checked: the listing carries no "
|
|
183
|
+
"version, so the pin cannot be checked against it")
|
|
184
|
+
listing = {"id": rec["id"], "name": rec["name"], "status": rec["status"],
|
|
185
|
+
"bench_verdict": rec.get("bench_verdict"),
|
|
186
|
+
"certified_scope": rec.get("certified_scope"),
|
|
187
|
+
"listing_version": listed,
|
|
188
|
+
"scorecard_ref": rec.get("scorecard_ref"),
|
|
189
|
+
"listing_url": rec.get("listing_url")}
|
|
190
|
+
return {"config_name": config_name, "class": cls, "identity": identity,
|
|
191
|
+
"reason": None, "listing": listing, "drift": drift,
|
|
192
|
+
"version_note": version_note}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def run_check(config_paths=None, corpus_path=None, refresh_corpus=False):
|
|
196
|
+
"""The full Phase-1 check. Returns the report dict (no score —
|
|
197
|
+
counts per class only)."""
|
|
198
|
+
paths = [Path(p) for p in config_paths] if config_paths \
|
|
199
|
+
else default_config_paths()
|
|
200
|
+
records, corpus_meta = load_corpus(corpus_path, refresh_corpus)
|
|
201
|
+
index = build_index(records)
|
|
202
|
+
entries, file_notes = [], []
|
|
203
|
+
for path in paths:
|
|
204
|
+
loaded, error = load_config_entries(path)
|
|
205
|
+
if error:
|
|
206
|
+
file_notes.append({"config_file": str(path), "error": error})
|
|
207
|
+
continue
|
|
208
|
+
for name, entry in loaded:
|
|
209
|
+
row = classify(name, entry, index)
|
|
210
|
+
row["config_file"] = str(path)
|
|
211
|
+
entries.append(row)
|
|
212
|
+
summary = {c: 0 for c in CLASSES}
|
|
213
|
+
for row in entries:
|
|
214
|
+
summary[row["class"]] += 1
|
|
215
|
+
return {
|
|
216
|
+
"tool": "eclipse-bazaar check (phase 1)",
|
|
217
|
+
"corpus": {"records": len(records),
|
|
218
|
+
"source": corpus_meta["source"],
|
|
219
|
+
"sha256": corpus_meta["sha256"],
|
|
220
|
+
"generated_at": (corpus_meta.get("manifest") or {}).get("generated_at"),
|
|
221
|
+
"refresh_error": corpus_meta.get("refresh_error")},
|
|
222
|
+
"config_files": [str(p) for p in paths],
|
|
223
|
+
"file_notes": file_notes,
|
|
224
|
+
"summary": summary,
|
|
225
|
+
"entries": entries,
|
|
226
|
+
"notes": [
|
|
227
|
+
"Classes are facts about listing/verdict records, not safety "
|
|
228
|
+
"judgements. NOT LISTED is neutral: the Bazaar catalog v0.1 "
|
|
229
|
+
"covers Cyan Eclipse Technologies products.",
|
|
230
|
+
"env values in your configs were never read, copied, or "
|
|
231
|
+
"transmitted; the join ran locally against the bulk corpus.",
|
|
232
|
+
],
|
|
233
|
+
}
|
eclipse_bazaar/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Eclipse Bazaar · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
|
|
2
|
+
"""`bazaar` command line — terminal face of the eclipse-bazaar SDK.
|
|
3
|
+
|
|
4
|
+
bazaar search <query> [--category C] [--refresh] [--json]
|
|
5
|
+
bazaar product <id> [--json]
|
|
6
|
+
bazaar categories [--json]
|
|
7
|
+
bazaar check [--config PATH ...] [--corpus PATH] [--refresh-corpus]
|
|
8
|
+
[--out PATH] [--json]
|
|
9
|
+
bazaar version
|
|
10
|
+
|
|
11
|
+
`check` prints the config files it is about to read *before* reading
|
|
12
|
+
them, and its report carries counts per class — never an aggregate
|
|
13
|
+
score (see eclipse_bazaar.check for the Phase-1 fences).
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from ._version import __version__
|
|
20
|
+
from .check import run_check
|
|
21
|
+
from .client import Bazaar
|
|
22
|
+
from .configparse import default_config_paths
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _emit(obj, as_json, render):
|
|
26
|
+
if as_json:
|
|
27
|
+
print(json.dumps(obj, indent=1))
|
|
28
|
+
else:
|
|
29
|
+
print(render(obj))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _render_search(res):
|
|
33
|
+
lines = [f"{res['count']} result(s) for {res['query']!r}:"]
|
|
34
|
+
for h in res["results"]:
|
|
35
|
+
cert = h["certification"]
|
|
36
|
+
badge = cert["bench_verdict"] or "no verdict on record"
|
|
37
|
+
lines.append(f" {h['id']} — {h['name']} [{h['status']}] "
|
|
38
|
+
f"({badge})")
|
|
39
|
+
lines.append(f" {h['tagline']}")
|
|
40
|
+
for r in h["match_reasons"]:
|
|
41
|
+
lines.append(f" · {r}")
|
|
42
|
+
return "\n".join(lines)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _render_product(res):
|
|
46
|
+
p = res["product"]
|
|
47
|
+
c = p["certification"]
|
|
48
|
+
lines = [f"{p['name']} ({p['id']}) — {p['status']}",
|
|
49
|
+
f" {p['tagline']}",
|
|
50
|
+
f" pricing: {p['pricing_model']}",
|
|
51
|
+
f" categories: {', '.join(p['categories'])}",
|
|
52
|
+
f" certification: {c['bench_verdict'] or 'NOT YET CERTIFIED'}",
|
|
53
|
+
" capabilities:"]
|
|
54
|
+
lines += [f" - {cap}" for cap in p["capabilities"]]
|
|
55
|
+
if c.get("not_tested"):
|
|
56
|
+
lines.append(" not tested:")
|
|
57
|
+
lines += [f" - {n}" for n in c["not_tested"]]
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _render_categories(res):
|
|
62
|
+
lines = [f"{res['product_total']} products in "
|
|
63
|
+
f"{len(res['categories'])} categories:"]
|
|
64
|
+
for c in res["categories"]:
|
|
65
|
+
by = ", ".join(f"{k}={v}" for k, v in sorted(c["by_status"].items()))
|
|
66
|
+
lines.append(f" {c['category']}: {c['total']} ({by})")
|
|
67
|
+
return "\n".join(lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _render_check(rep):
|
|
71
|
+
lines = [f"Corpus: {rep['corpus']['records']} listing records "
|
|
72
|
+
f"({rep['corpus']['source']}, sha256 "
|
|
73
|
+
f"{(rep['corpus']['sha256'] or 'n/a')[:16]}…)"]
|
|
74
|
+
for note in rep["file_notes"]:
|
|
75
|
+
lines.append(f" ! {note['config_file']}: {note['error']}")
|
|
76
|
+
current = None
|
|
77
|
+
for e in rep["entries"]:
|
|
78
|
+
if e["config_file"] != current:
|
|
79
|
+
current = e["config_file"]
|
|
80
|
+
lines.append(f"\n{current}:")
|
|
81
|
+
ident = e["identity"]
|
|
82
|
+
idtxt = "—" if ident is None else \
|
|
83
|
+
f"{ident['kind']}:{ident['name']}" + \
|
|
84
|
+
(f"@{ident['pin']}" if ident.get("pin") else "")
|
|
85
|
+
lines.append(f" {e['config_name']:<28} {e['class']:<13} {idtxt}")
|
|
86
|
+
if e["listing"]:
|
|
87
|
+
li = e["listing"]
|
|
88
|
+
verdict = li["bench_verdict"] or "no Tier-2 verdict"
|
|
89
|
+
lines.append(f" -> {li['name']} [{li['status']}] — {verdict}")
|
|
90
|
+
if e["drift"]:
|
|
91
|
+
lines.append(f" drift: {e['drift']['note']}")
|
|
92
|
+
if e.get("version_note"):
|
|
93
|
+
lines.append(f" {e['version_note']}")
|
|
94
|
+
if e["reason"] and e["class"] in ("UNRESOLVABLE",):
|
|
95
|
+
lines.append(f" reason: {e['reason']}")
|
|
96
|
+
s = rep["summary"]
|
|
97
|
+
lines.append("\nSummary: " + " · ".join(f"{k} {v}" for k, v in s.items()))
|
|
98
|
+
lines += [f"Note: {n}" for n in rep["notes"]]
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main(argv=None) -> int:
|
|
103
|
+
ap = argparse.ArgumentParser(
|
|
104
|
+
prog="bazaar",
|
|
105
|
+
description="Eclipse Bazaar — Cyan Eclipse Technologies' catalog "
|
|
106
|
+
"of tested MCP products: search it, inspect listings, "
|
|
107
|
+
"and check your MCP client configs against it.")
|
|
108
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
109
|
+
|
|
110
|
+
p = sub.add_parser("search", help="keyword search over the catalog")
|
|
111
|
+
p.add_argument("query")
|
|
112
|
+
p.add_argument("--category", default=None)
|
|
113
|
+
p.add_argument("--refresh", action="store_true",
|
|
114
|
+
help="refresh the catalog (one bulk HTTPS fetch) first")
|
|
115
|
+
p.add_argument("--json", action="store_true")
|
|
116
|
+
|
|
117
|
+
p = sub.add_parser("product", help="full listing for one product id")
|
|
118
|
+
p.add_argument("id")
|
|
119
|
+
p.add_argument("--json", action="store_true")
|
|
120
|
+
|
|
121
|
+
p = sub.add_parser("categories", help="catalog categories + counts")
|
|
122
|
+
p.add_argument("--json", action="store_true")
|
|
123
|
+
|
|
124
|
+
p = sub.add_parser("check", help="classify your configured MCP servers "
|
|
125
|
+
"against the Bazaar corpus (phase 1)")
|
|
126
|
+
p.add_argument("--config", action="append", default=None,
|
|
127
|
+
metavar="PATH", help="config file (repeatable); default: "
|
|
128
|
+
"discover known client locations")
|
|
129
|
+
p.add_argument("--corpus", default=None, metavar="PATH",
|
|
130
|
+
help="local corpus.jsonl (default: bundled snapshot)")
|
|
131
|
+
p.add_argument("--refresh-corpus", action="store_true",
|
|
132
|
+
help="download the current corpus + manifest and verify "
|
|
133
|
+
"its sha256 before use")
|
|
134
|
+
p.add_argument("--out", default=None, metavar="PATH",
|
|
135
|
+
help="also write the JSON report to this path")
|
|
136
|
+
p.add_argument("--json", action="store_true")
|
|
137
|
+
|
|
138
|
+
sub.add_parser("version", help="print the package version")
|
|
139
|
+
|
|
140
|
+
args = ap.parse_args(argv)
|
|
141
|
+
|
|
142
|
+
if args.cmd == "version":
|
|
143
|
+
print(__version__)
|
|
144
|
+
return 0
|
|
145
|
+
if args.cmd == "search":
|
|
146
|
+
bz = Bazaar(refresh=args.refresh)
|
|
147
|
+
_emit(bz.search(args.query, args.category), args.json, _render_search)
|
|
148
|
+
return 0
|
|
149
|
+
if args.cmd == "product":
|
|
150
|
+
_emit(Bazaar().get_listing(args.id), args.json, _render_product)
|
|
151
|
+
return 0
|
|
152
|
+
if args.cmd == "categories":
|
|
153
|
+
_emit(Bazaar().categories(), args.json, _render_categories)
|
|
154
|
+
return 0
|
|
155
|
+
if args.cmd == "check":
|
|
156
|
+
paths = args.config
|
|
157
|
+
if not paths:
|
|
158
|
+
found = default_config_paths()
|
|
159
|
+
print("Config files to read (discovered client locations):",
|
|
160
|
+
file=sys.stderr)
|
|
161
|
+
for fp in found:
|
|
162
|
+
print(f" {fp}", file=sys.stderr)
|
|
163
|
+
if not found:
|
|
164
|
+
print(" (none found — pass --config PATH)", file=sys.stderr)
|
|
165
|
+
return 2
|
|
166
|
+
paths = [str(fp) for fp in found]
|
|
167
|
+
rep = run_check(config_paths=paths, corpus_path=args.corpus,
|
|
168
|
+
refresh_corpus=args.refresh_corpus)
|
|
169
|
+
if args.out:
|
|
170
|
+
from pathlib import Path
|
|
171
|
+
Path(args.out).write_text(json.dumps(rep, indent=1) + "\n")
|
|
172
|
+
_emit(rep, args.json, _render_check)
|
|
173
|
+
return 0
|
|
174
|
+
return 2
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
raise SystemExit(main())
|
eclipse_bazaar/client.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Eclipse Bazaar · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
|
|
2
|
+
"""Python SDK for Eclipse Bazaar (candidate C1 — plumbing, no novelty
|
|
3
|
+
claim). A small read-only client over the same catalog and corpus the
|
|
4
|
+
MCP server and CLI serve: identical ranking, identical match reasons,
|
|
5
|
+
identical status labels, because it calls the same functions.
|
|
6
|
+
|
|
7
|
+
from eclipse_bazaar import Bazaar
|
|
8
|
+
bz = Bazaar() # bundled snapshot (offline)
|
|
9
|
+
bz = Bazaar(refresh=True) # one bulk HTTPS refresh, fallback local
|
|
10
|
+
bz.search("certificate renewal") # -> dict like the MCP tool's result
|
|
11
|
+
bz.get_listing("certbridge-mcp") # full catalog entry
|
|
12
|
+
bz.check() # Phase-1 standing check of your configs
|
|
13
|
+
|
|
14
|
+
No API key (year-one posture: free reads). No telemetry — the client
|
|
15
|
+
sends nothing anywhere except the optional bulk catalog refresh.
|
|
16
|
+
"""
|
|
17
|
+
from . import mcp_server as _srv
|
|
18
|
+
from .catalog import catalog_dict
|
|
19
|
+
from .check import run_check
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Bazaar:
|
|
23
|
+
def __init__(self, refresh: bool = False):
|
|
24
|
+
self._cat = catalog_dict(refresh=refresh)
|
|
25
|
+
|
|
26
|
+
# ------------------------------------------------------ discovery
|
|
27
|
+
def search(self, query: str, category: str = None) -> dict:
|
|
28
|
+
return _srv.t_search_products(
|
|
29
|
+
{"query": query, "category": category or ""}, self._cat)
|
|
30
|
+
|
|
31
|
+
def find_solutions(self, problem: str) -> dict:
|
|
32
|
+
return _srv.t_find_solutions({"problem": problem}, self._cat)
|
|
33
|
+
|
|
34
|
+
def get_listing(self, product_id: str) -> dict:
|
|
35
|
+
return _srv.t_get_product({"id": product_id}, self._cat)
|
|
36
|
+
|
|
37
|
+
get_product = get_listing
|
|
38
|
+
|
|
39
|
+
def compare(self, ids) -> dict:
|
|
40
|
+
return _srv.t_compare_products({"ids": list(ids)}, self._cat)
|
|
41
|
+
|
|
42
|
+
def get_certification(self, product_id: str) -> dict:
|
|
43
|
+
return _srv.t_get_certification({"id": product_id}, self._cat)
|
|
44
|
+
|
|
45
|
+
def categories(self) -> dict:
|
|
46
|
+
return _srv.t_list_categories({}, self._cat)
|
|
47
|
+
|
|
48
|
+
# ------------------------------------------------------ standing check
|
|
49
|
+
def check(self, config_paths=None, corpus_path=None,
|
|
50
|
+
refresh_corpus: bool = False) -> dict:
|
|
51
|
+
return run_check(config_paths=config_paths,
|
|
52
|
+
corpus_path=corpus_path,
|
|
53
|
+
refresh_corpus=refresh_corpus)
|