eclipse-agentready 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,8 @@
1
+ # AgentReady · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
2
+ """eclipse-agentready — the T6 CLI edition of AgentReady: the same
3
+ certified storefront-audit engine as the AgentReady MCP server, in
4
+ command-line form for CI pipelines. Audits *storefronts* for
5
+ shopping-agent readiness — not repositories, not MCP servers."""
6
+ from .engine import CHECKS, __version__, grade_for, run_audit, score_results
7
+
8
+ __all__ = ["CHECKS", "__version__", "grade_for", "run_audit", "score_results"]
@@ -0,0 +1,110 @@
1
+ # AgentReady · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
2
+ """`eclipse-agentready` — AgentReady as a CI artifact (slate T6).
3
+
4
+ eclipse-agentready audit <url> [--fail-under N] [--format json|text]
5
+ [--out PATH]
6
+ eclipse-agentready list-checks [--format json|text]
7
+ eclipse-agentready version
8
+
9
+ Exit codes: audit — 0 when the audit completed and (no --fail-under
10
+ given, or score >= N); 1 when score < --fail-under; 2 when the audit
11
+ itself could not run (unreachable / unparseable target). The grade is
12
+ a point-in-time public-surface observation, printed with its limits.
13
+ """
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from .engine import CHECKS, ToolError, __version__, run_audit
20
+
21
+ SCOPE_FOOTER = (
22
+ "Scope: Public-surface audit only (no credentials, no checkout "
23
+ "execution). No authenticated checkout execution — this tool never "
24
+ "logs in, never buys, and cannot verify a full agent purchase. "
25
+ "Evidence is a point-in-time observation; storefronts change. "
26
+ "A grade is not an endorsement and not a Bazaar listing claim."
27
+ )
28
+
29
+
30
+ def _render_report(rep):
31
+ lines = [f"{rep['url']} — score {rep['score']}/100, grade {rep['grade']}"
32
+ f" (platform: {rep['platform']})"]
33
+ for c in rep["checks"]:
34
+ pts = "n/a" if c["points"] is None else f"{c['points']:g}/{c['weight']}"
35
+ lines.append(f" [{c['status']:<4}] {c['id']:<26} {pts:<7} {c['evidence']}")
36
+ if rep["top_fixes"]:
37
+ lines.append("Top fixes:")
38
+ for f in rep["top_fixes"]:
39
+ lines.append(f" - {f['check']}: {f['fix']}")
40
+ lines.append(SCOPE_FOOTER)
41
+ return "\n".join(lines)
42
+
43
+
44
+ def _checks_payload():
45
+ return {"checks": [{"id": c["id"], "name": c["name"],
46
+ "weight": c["weight"],
47
+ "description": c["description"]} for c in CHECKS],
48
+ "total_weight": sum(c["weight"] for c in CHECKS),
49
+ "scoring": "pass = full weight, warn = half, fail = 0, "
50
+ "na = excluded. Grade: A>=90 B>=75 C>=60 D>=40 F<40."}
51
+
52
+
53
+ def main(argv=None) -> int:
54
+ ap = argparse.ArgumentParser(
55
+ prog="eclipse-agentready",
56
+ description="Audit a public storefront for AI shopping-agent "
57
+ "readiness (UCP profile, live MCP endpoint, llms.txt, "
58
+ "robots.txt AI policy, Product structured data, "
59
+ "sitemap, platform, HTTPS/canonical). Storefronts "
60
+ "only — this is not a repository or MCP-server grader.")
61
+ sub = ap.add_subparsers(dest="cmd", required=True)
62
+
63
+ p = sub.add_parser("audit", help="audit one storefront URL")
64
+ p.add_argument("url")
65
+ p.add_argument("--fail-under", type=int, default=None, metavar="N",
66
+ help="exit 1 when the score is below N (CI gate)")
67
+ p.add_argument("--format", choices=("json", "text"), default="text")
68
+ p.add_argument("--out", default=None, metavar="PATH",
69
+ help="also write the JSON report to this path")
70
+
71
+ p = sub.add_parser("list-checks", help="the check catalog + weights")
72
+ p.add_argument("--format", choices=("json", "text"), default="text")
73
+
74
+ sub.add_parser("version", help="print the package version")
75
+
76
+ args = ap.parse_args(argv)
77
+
78
+ if args.cmd == "version":
79
+ print(__version__)
80
+ return 0
81
+ if args.cmd == "list-checks":
82
+ payload = _checks_payload()
83
+ if args.format == "json":
84
+ print(json.dumps(payload, indent=1))
85
+ else:
86
+ for c in payload["checks"]:
87
+ print(f" {c['id']:<26} {c['weight']:>3} {c['name']}")
88
+ print(f"Total weight: {payload['total_weight']}. "
89
+ f"{payload['scoring']}")
90
+ return 0
91
+ if args.cmd == "audit":
92
+ try:
93
+ rep = run_audit(args.url)
94
+ except ToolError as e:
95
+ print(f"audit failed: {e}", file=sys.stderr)
96
+ return 2
97
+ if args.out:
98
+ Path(args.out).write_text(json.dumps(rep, indent=1) + "\n")
99
+ if args.format == "json":
100
+ print(json.dumps(rep, indent=1))
101
+ else:
102
+ print(_render_report(rep))
103
+ if args.fail_under is not None and rep["score"] < args.fail_under:
104
+ return 1
105
+ return 0
106
+ return 2
107
+
108
+
109
+ if __name__ == "__main__":
110
+ raise SystemExit(main())
@@ -0,0 +1,526 @@
1
+ #!/usr/bin/env python3
2
+ # AgentReady MCP
3
+ # Copyright © 2026 Cyan Eclipse Technologies LLC · cetech.ai
4
+
5
+ """AgentReady MCP — v0.1.0 reference implementation.
6
+
7
+ Powered by Cyan Eclipse Technologies (cetech.ai).
8
+
9
+ What it does: audits a public storefront for AI-agent readiness — can a
10
+ shopping agent (ChatGPT, Claude, Perplexity, Gemini…) discover this store,
11
+ read its catalog in machine-readable form, and get correct price/stock
12
+ data? Research context: only ~36% of US storefronts can complete an agent
13
+ purchase end-to-end (InMobi, Oct 2026); 95% of merchants already see agent
14
+ traffic but only ~20% have machine-readable catalogs (PayPal-cited data).
15
+
16
+ SCOPE (honest): public-surface audit only. Every check reads public
17
+ endpoints with no credentials — UCP profile, MCP endpoints, llms.txt,
18
+ robots.txt, structured data, sitemap. It does NOT execute an authenticated
19
+ checkout, does NOT log in anywhere, and collects no private data.
20
+
21
+ Transport: MCP over stdio (newline-delimited JSON-RPC 2.0), stdlib only.
22
+ The audit engine is also importable (audit_storefront()) for batch use.
23
+ """
24
+ import json
25
+ import re
26
+ import sys
27
+ import time
28
+ import urllib.error
29
+ import urllib.parse
30
+ import urllib.request
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ UA = f"AgentReadyMCP/{__version__} (+https://cetech.ai) public readiness audit"
35
+ TIMEOUT = 10
36
+ MAX_BODY = 1_500_000
37
+
38
+ AI_BOTS = ["GPTBot", "ClaudeBot", "PerplexityBot", "ChatGPT-User", "Google-Extended"]
39
+
40
+
41
+ class ToolError(Exception):
42
+ pass
43
+
44
+
45
+ # --------------------------------------------------------------------- http
46
+ def fetch(url, method="GET", data=None, headers=None, timeout=TIMEOUT):
47
+ """Returns dict(status, final_url, headers, body_text, error).
48
+ Never raises for HTTP errors; network errors land in `error`."""
49
+ req = urllib.request.Request(url, data=data, method=method)
50
+ req.add_header("User-Agent", UA)
51
+ req.add_header("Accept", "*/*")
52
+ for k, v in (headers or {}).items():
53
+ req.add_header(k, v)
54
+ try:
55
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
56
+ chunks, total = [], 0
57
+ try:
58
+ while total < MAX_BODY: # slow pages: keep partial body, don't fail
59
+ chunk = resp.read(65536)
60
+ if not chunk:
61
+ break
62
+ chunks.append(chunk)
63
+ total += len(chunk)
64
+ except Exception:
65
+ pass
66
+ return {"status": resp.status, "final_url": resp.geturl(),
67
+ "headers": dict(resp.headers.items()),
68
+ "body": b"".join(chunks).decode("utf-8", errors="replace"), "error": None}
69
+ except urllib.error.HTTPError as e:
70
+ try:
71
+ raw = e.read(MAX_BODY)
72
+ body = raw.decode("utf-8", errors="replace")
73
+ except Exception:
74
+ body = ""
75
+ return {"status": e.code, "final_url": e.geturl() if hasattr(e, "geturl") else url,
76
+ "headers": dict(e.headers.items()) if e.headers else {},
77
+ "body": body, "error": None}
78
+ except Exception as e:
79
+ return {"status": None, "final_url": url, "headers": {}, "body": "",
80
+ "error": f"{type(e).__name__}: {e}"}
81
+
82
+
83
+ def norm_url(raw):
84
+ u = str(raw or "").strip()
85
+ if not u:
86
+ raise ToolError("url is required")
87
+ if not re.match(r"^https?://", u, re.I):
88
+ u = "https://" + u
89
+ p = urllib.parse.urlparse(u)
90
+ if not p.netloc:
91
+ raise ToolError(f"could not parse host from url: {raw}")
92
+ return urllib.parse.urlunparse((p.scheme, p.netloc, p.path or "/", "", p.query, ""))
93
+
94
+
95
+ def origin_of(url):
96
+ p = urllib.parse.urlparse(url)
97
+ return f"{p.scheme}://{p.netloc}"
98
+
99
+
100
+ # ------------------------------------------------------------------- checks
101
+ # Each check returns (status, evidence, fix_hint); status in pass/warn/fail/na.
102
+ CHECKS = [
103
+ {"id": "ucp_profile", "weight": 15,
104
+ "name": "UCP profile published (/.well-known/ucp)",
105
+ "description": "A Universal Commerce Protocol profile tells agents which commerce services (catalog, checkout) the store exposes and where. Pass = valid JSON profile served at /.well-known/ucp."},
106
+ {"id": "mcp_endpoint", "weight": 20,
107
+ "name": "Live MCP endpoint answers JSON-RPC",
108
+ "description": "An MCP server endpoint (Shopify /api/mcp or /api/ucp/mcp, or generic /mcp) that answers an initialize ping. Pass = JSON-RPC result with server info; warn = endpoint exists but is gated/errors; fail = no MCP endpoint found."},
109
+ {"id": "llms_txt", "weight": 8,
110
+ "name": "llms.txt present",
111
+ "description": "An llms.txt file gives LLM crawlers a curated map of the store. Pass = HTTP 200 with non-empty content at /llms.txt."},
112
+ {"id": "robots_ai_policy", "weight": 10,
113
+ "name": "robots.txt allows AI crawlers",
114
+ "description": "If GPTBot / ClaudeBot / PerplexityBot are disallowed in robots.txt, those agents cannot legally crawl the catalog. Pass = major AI bots allowed; warn = partially blocked or no robots.txt; fail = all major AI bots blocked."},
115
+ {"id": "product_structured_data", "weight": 20,
116
+ "name": "Product structured data (JSON-LD with price + availability)",
117
+ "description": "Agents read JSON-LD Product markup to extract price and stock without guessing from page text. Pass = a product page carries Product JSON-LD with offers.price and offers.availability; warn = Product markup present but incomplete; fail = none found."},
118
+ {"id": "sitemap", "weight": 10,
119
+ "name": "Sitemap present with product URLs",
120
+ "description": "A sitemap.xml lets agent crawlers enumerate the catalog. Pass = sitemap served and product URLs present; warn = sitemap served but no recognizable product URLs; fail = no sitemap."},
121
+ {"id": "platform_detection", "weight": 5,
122
+ "name": "Platform identified",
123
+ "description": "Platform fingerprint (Shopify / WooCommerce / Wix / BigCommerce / Squarespace / Adobe Commerce / custom) from response headers and HTML markers. Informs which native agent features the store can switch on. Pass = identified."},
124
+ {"id": "https_canonical", "weight": 12,
125
+ "name": "HTTPS + canonical sanity",
126
+ "description": "Agents and crawlers need one canonical HTTPS URL per page. Pass = site served over HTTPS and homepage declares a rel=canonical on the same host; warn = HTTPS ok but canonical missing/mismatched; fail = not served over HTTPS."},
127
+ ]
128
+ CHECK_BY_ID = {c["id"]: c for c in CHECKS}
129
+
130
+
131
+ def check_ucp(origin):
132
+ r = fetch(origin + "/.well-known/ucp")
133
+ if r["status"] == 200:
134
+ try:
135
+ doc = json.loads(r["body"])
136
+ keys = sorted(doc.keys())[:8]
137
+ has_ucp = "ucp" in doc or "services" in doc or "capabilities" in doc
138
+ if has_ucp:
139
+ return "pass", f"GET /.well-known/ucp -> 200, JSON profile keys: {keys}", None
140
+ return "warn", f"GET /.well-known/ucp -> 200 JSON but no ucp/services/capabilities keys (keys: {keys})", \
141
+ "Publish a UCP profile describing your catalog/checkout services at /.well-known/ucp."
142
+ except Exception:
143
+ return "warn", "GET /.well-known/ucp -> 200 but body is not valid JSON", \
144
+ "Serve a valid JSON UCP profile at /.well-known/ucp."
145
+ return "fail", f"GET /.well-known/ucp -> {r['status'] or r['error']}", \
146
+ "Publish a UCP profile at /.well-known/ucp (Shopify exposes one via its UCP catalog support; other platforms can hand-serve the JSON document)."
147
+
148
+
149
+ def _mcp_ping(url):
150
+ payload = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize",
151
+ "params": {"protocolVersion": "2025-03-26", "capabilities": {},
152
+ "clientInfo": {"name": "agent-ready-mcp", "version": "0.1.0"}}}).encode()
153
+ r = fetch(url, method="POST", data=payload,
154
+ headers={"Content-Type": "application/json", "Accept": "application/json, text/event-stream"})
155
+ body = r["body"] or ""
156
+ doc = None
157
+ try:
158
+ doc = json.loads(body)
159
+ except Exception:
160
+ for line in body.splitlines(): # SSE framing
161
+ if line.startswith("data:"):
162
+ try:
163
+ doc = json.loads(line[5:].strip())
164
+ break
165
+ except Exception:
166
+ pass
167
+ return r, doc
168
+
169
+
170
+ def check_mcp(origin, platform):
171
+ candidates = ["/api/ucp/mcp", "/api/mcp", "/mcp"]
172
+ if platform == "woocommerce":
173
+ candidates = ["/wp-json/mcp", "/mcp"] + candidates
174
+ seen = []
175
+ for path in candidates:
176
+ r, doc = _mcp_ping(origin + path)
177
+ st = r["status"]
178
+ if doc and isinstance(doc, dict) and "result" in doc:
179
+ info = (doc["result"] or {}).get("serverInfo", {})
180
+ seen.append(f"{path} -> JSON-RPC result (server: {info.get('name', 'unknown')} {info.get('version', '')})".strip())
181
+ return "pass", "; ".join(seen), None
182
+ if doc and isinstance(doc, dict) and "error" in doc:
183
+ seen.append(f"{path} -> HTTP {st}, JSON-RPC error response (endpoint exists)")
184
+ return "warn", "; ".join(seen), \
185
+ "An MCP endpoint exists but returned a protocol error to a standard initialize ping — test it with a real agent client and fix the handshake."
186
+ if st in (401, 403, 405, 406):
187
+ seen.append(f"{path} -> HTTP {st} (endpoint likely exists, gated)")
188
+ return "warn", "; ".join(seen), \
189
+ "An endpoint answers at an MCP path but refuses anonymous pings — confirm public catalog tools are reachable without auth, or document the auth flow for agents."
190
+ seen.append(f"{path} -> {st or r['error']}")
191
+ return "fail", "; ".join(seen), \
192
+ "Expose a public MCP endpoint for catalog search/product lookup (Shopify: enable the Storefront/UCP Catalog MCP; WooCommerce: enable the MCP abilities adapter; other stacks: a hosted catalog MCP)."
193
+
194
+
195
+ def check_llms(origin):
196
+ r = fetch(origin + "/llms.txt")
197
+ if r["status"] == 200 and len(r["body"].strip()) > 20:
198
+ first = r["body"].strip().splitlines()[0][:80]
199
+ return "pass", f"GET /llms.txt -> 200, {len(r['body'])} bytes, starts: {first!r}", None
200
+ return "fail", f"GET /llms.txt -> {r['status'] or r['error']}", \
201
+ "Publish /llms.txt: a short markdown map of your store, top categories and policies for LLM crawlers."
202
+
203
+
204
+ def _robots_policy(body):
205
+ """Parse robots.txt -> {bot: 'blocked'|'allowed'} for AI_BOTS (crude but factual)."""
206
+ groups, current = [], []
207
+ for line in body.splitlines():
208
+ line = line.strip()
209
+ if not line or line.startswith("#"):
210
+ continue
211
+ if line.lower().startswith("user-agent:"):
212
+ if current and any(k.lower().startswith("disallow") for k, _ in current[1:]):
213
+ groups.append(current)
214
+ current = [(line.split(":", 1)[0].lower(), line.split(":", 1)[1].strip())]
215
+ elif current:
216
+ k, _, v = line.partition(":")
217
+ current.append((k.strip().lower(), v.strip()))
218
+ if current:
219
+ groups.append(current)
220
+ policy = {}
221
+ for bot in AI_BOTS:
222
+ blocked = None
223
+ for g in groups:
224
+ agents = [v.lower() for k, v in g if k == "user-agent"]
225
+ rules = [(k, v) for k, v in g if k in ("allow", "disallow")]
226
+ if bot.lower() in agents or "*" in agents:
227
+ for k, v in rules:
228
+ if k == "disallow" and v == "/":
229
+ blocked = True
230
+ if k == "allow" and v == "/":
231
+ blocked = False
232
+ policy[bot] = "blocked" if blocked else "allowed"
233
+ return policy
234
+
235
+
236
+ def check_robots(origin):
237
+ r = fetch(origin + "/robots.txt")
238
+ if r["status"] != 200 or not r["body"].strip():
239
+ return "warn", f"GET /robots.txt -> {r['status'] or r['error']} (no robots policy published)", \
240
+ "Publish a robots.txt that explicitly allows GPTBot, ClaudeBot and PerplexityBot on catalog paths."
241
+ policy = _robots_policy(r["body"])
242
+ blocked = [b for b, v in policy.items() if v == "blocked"]
243
+ ev = ", ".join(f"{b}: {v}" for b, v in policy.items())
244
+ if not blocked:
245
+ return "pass", f"robots.txt policy — {ev}", None
246
+ if len(blocked) >= 4:
247
+ return "fail", f"robots.txt policy — {ev}", \
248
+ "Your robots.txt blocks the major AI crawlers outright — those agents cannot see your catalog. Allow GPTBot, ClaudeBot and PerplexityBot at least on product/category paths."
249
+ return "warn", f"robots.txt policy — {ev}", \
250
+ f"Some AI crawlers are blocked ({', '.join(blocked)}). If that is not deliberate, allow them on catalog paths."
251
+
252
+
253
+ def _jsonld_docs(html_text):
254
+ docs = []
255
+ for m in re.finditer(r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
256
+ html_text, re.I | re.S):
257
+ try:
258
+ docs.append(json.loads(m.group(1).strip()))
259
+ except Exception:
260
+ pass
261
+ return docs
262
+
263
+
264
+ def _products_in(docs):
265
+ found = []
266
+
267
+ def walk(node):
268
+ if isinstance(node, dict):
269
+ t = node.get("@type")
270
+ if t == "Product" or (isinstance(t, list) and "Product" in t):
271
+ found.append(node)
272
+ for v in node.values():
273
+ walk(v)
274
+ elif isinstance(node, list):
275
+ for v in node:
276
+ walk(v)
277
+
278
+ for d in docs:
279
+ walk(d)
280
+ return found
281
+
282
+
283
+ def _product_page_url(home_html, origin, sitemap_urls):
284
+ for m in re.finditer(r'href=["\']([^"\']+)["\']', home_html, re.I):
285
+ href = m.group(1)
286
+ if re.search(r"/products?/", href):
287
+ return urllib.parse.urljoin(origin + "/", href)
288
+ for u in sitemap_urls:
289
+ if re.search(r"/products?/", u):
290
+ return u
291
+ return None
292
+
293
+
294
+ def check_structured_data(home, origin, sitemap_urls):
295
+ home_products = _products_in(_jsonld_docs(home["body"]))
296
+ purl = _product_page_url(home["body"], origin, sitemap_urls)
297
+ target, label = home, "homepage"
298
+ if purl:
299
+ pr = fetch(purl)
300
+ if pr["status"] == 200:
301
+ target, label = pr, f"product page {purl}"
302
+ prods = _products_in(_jsonld_docs(target["body"]))
303
+ if not prods and target is not home:
304
+ prods = home_products
305
+ label = "homepage (product page had none)"
306
+ if not prods:
307
+ return "fail", f"No JSON-LD Product found on homepage" + (f" or {purl}" if purl else " (no product link discovered)"), \
308
+ "Add JSON-LD Product markup (name, offers.price, offers.priceCurrency, offers.availability) to every product page — most platforms do this via their SEO/schema settings or a schema app."
309
+ p = prods[0]
310
+ offers = p.get("offers") or {}
311
+ if isinstance(offers, list):
312
+ offers = offers[0] if offers else {}
313
+ has_price = bool(offers.get("price") or offers.get("lowPrice"))
314
+ has_avail = bool(offers.get("availability"))
315
+ ev = (f"{label}: Product JSON-LD for {p.get('name', 'unnamed')!r}; "
316
+ f"price={'yes' if has_price else 'no'}, availability={'yes' if has_avail else 'no'}")
317
+ if has_price and has_avail:
318
+ return "pass", ev, None
319
+ return "warn", ev, \
320
+ "Your Product JSON-LD is missing price and/or availability in offers — agents then guess from page text and get it wrong. Emit both fields."
321
+
322
+
323
+ def check_sitemap(origin):
324
+ r = fetch(origin + "/sitemap.xml")
325
+ if r["status"] != 200 or "<loc>" not in r["body"]:
326
+ return "fail", f"GET /sitemap.xml -> {r['status'] or r['error']}, no <loc> entries", \
327
+ "Publish a sitemap.xml (your platform generates one) and reference it in robots.txt.", []
328
+ urls = re.findall(r"<loc>([^<]+)</loc>", r["body"])
329
+ followed = ""
330
+ if "<sitemapindex" in r["body"]: # index file: sample children for product URLs
331
+ children = urls[:]
332
+ urls = []
333
+ for child in children[:3]:
334
+ cr = fetch(child)
335
+ if cr["status"] == 200:
336
+ urls += re.findall(r"<loc>([^<]+)</loc>", cr["body"])
337
+ followed = f" (index of {len(children)} children, sampled {min(3, len(children))})"
338
+ prod = [u for u in urls if re.search(r"/products?/", u)]
339
+ ev = f"sitemap.xml -> 200{followed}, {len(urls)} URLs sampled, {len(prod)} product-pattern URLs"
340
+ if prod:
341
+ return "pass", ev, None, urls
342
+ return "warn", ev, \
343
+ "Your sitemap sample has no recognizable product URLs — make sure product pages are included so agent crawlers can enumerate the catalog.", urls
344
+
345
+
346
+ def detect_platform(home):
347
+ """Strong markers only — a comparison page merely *mentioning* a rival
348
+ platform must not fingerprint the store (learned on woocommerce.com)."""
349
+ html_l = home["body"][:400_000].lower()
350
+ hdr_names = [k.lower() for k in home["headers"].keys()]
351
+ hdr_blob = " ".join(f"{k.lower()}:{v.lower()}" for k, v in home["headers"].items())
352
+ if "cdn.shopify.com" in html_l or "myshopify.com" in html_l or \
353
+ any(h.startswith("x-shopify") for h in hdr_names):
354
+ return "shopify", "cdn.shopify.com / x-shopify header marker"
355
+ if "plugins/woocommerce" in html_l or "/woocommerce/assets" in html_l or "wc-blocks" in html_l:
356
+ return "woocommerce", "WooCommerce plugin asset marker in HTML"
357
+ if "wixstatic.com" in html_l or any(h.startswith("x-wix") for h in hdr_names):
358
+ return "wix", "wixstatic.com / x-wix header marker"
359
+ if "mybigcommerce.com" in html_l or "cdn11.bigcommerce.com" in html_l:
360
+ return "bigcommerce", "BigCommerce CDN marker"
361
+ if "static1.squarespace.com" in html_l or "universal-js.squarespace.com" in html_l:
362
+ return "squarespace", "Squarespace CDN marker"
363
+ if "magento" in hdr_blob or "/mage/" in html_l:
364
+ return "adobe-commerce", "Magento marker in headers/assets"
365
+ if "wp-content" in html_l:
366
+ return "wordpress", "wp-content marker (WordPress, WooCommerce not confirmed)"
367
+ return "unknown/custom", "no known platform markers in homepage HTML/headers"
368
+
369
+
370
+ def grade_for(score):
371
+ return "A" if score >= 90 else "B" if score >= 75 else "C" if score >= 60 else "D" if score >= 40 else "F"
372
+
373
+
374
+ # --------------------------------------------------------------------- audit
375
+ def run_audit(raw_url):
376
+ url = norm_url(raw_url)
377
+ started = time.time()
378
+ home = fetch(url)
379
+ if home["status"] is None:
380
+ raise ToolError(f"could not fetch {url}: {home['error']}")
381
+ origin = origin_of(home["final_url"])
382
+ platform, plat_ev = detect_platform(home)
383
+
384
+ results = {}
385
+ sm = check_sitemap(origin)
386
+ results["sitemap"] = (sm[0], sm[1], sm[2])
387
+ sm_urls = sm[3]
388
+
389
+ results["ucp_profile"] = check_ucp(origin)
390
+ results["mcp_endpoint"] = check_mcp(origin, platform)
391
+ results["llms_txt"] = check_llms(origin)
392
+ results["robots_ai_policy"] = check_robots(origin)
393
+ results["product_structured_data"] = check_structured_data(home, origin, sm_urls)
394
+ results["platform_detection"] = ("pass" if platform != "unknown/custom" else "warn",
395
+ f"detected: {platform} ({plat_ev})",
396
+ None if platform != "unknown/custom" else
397
+ "No platform fingerprint found — if you run a custom stack, make sure the UCP/MCP/structured-data checks above are covered by hand.")
398
+ # https + canonical
399
+ final = home["final_url"]
400
+ canon = re.search(r'<link[^>]+rel=["\']canonical["\'][^>]*href=["\']([^"\']+)', home["body"], re.I) or \
401
+ re.search(r'<link[^>]+href=["\']([^"\']+)["\'][^>]*rel=["\']canonical["\']', home["body"], re.I)
402
+ canon_url = canon.group(1) if canon else None
403
+ if not final.startswith("https://"):
404
+ results["https_canonical"] = ("fail", f"final URL is {final} (not HTTPS); canonical={canon_url}", \
405
+ "Serve the store over HTTPS and redirect all HTTP traffic to it.")
406
+ elif canon_url and urllib.parse.urlparse(canon_url).netloc == urllib.parse.urlparse(final).netloc:
407
+ results["https_canonical"] = ("pass", f"final URL {final}; canonical {canon_url} (same host)", None)
408
+ elif canon_url:
409
+ results["https_canonical"] = ("warn", f"final URL {final}; canonical {canon_url} (different host/scheme)", \
410
+ "Point rel=canonical at the same HTTPS host agents land on — cross-host canonicals confuse catalog indexing.")
411
+ else:
412
+ results["https_canonical"] = ("warn", f"final URL {final}; no rel=canonical on homepage", \
413
+ "Add rel=canonical tags so agents see one canonical URL per product instead of duplicates.")
414
+
415
+ checks_out, score, grade, top_fixes = score_results(results)
416
+ return {
417
+ "url": url, "final_url": home["final_url"], "platform": platform,
418
+ "score": score, "grade": grade,
419
+ "checks": checks_out,
420
+ "top_fixes": top_fixes,
421
+ "audited_in_s": round(time.time() - started, 1),
422
+ "scope": "Public-surface audit only (no credentials, no checkout execution).",
423
+ "auditor": f"AgentReady MCP {__version__} — Cyan Eclipse Technologies",
424
+ }
425
+
426
+
427
+ def score_results(results):
428
+ """{check_id: (status, evidence, fix_hint)} -> (checks_out, score,
429
+ grade, top_fixes). The single scoring path: the MCP tool, the
430
+ eclipse-agentready CLI, and the recorded-evidence replays in the
431
+ test suite all score through here, so the surfaces can never
432
+ disagree on the same evidence (gate B2)."""
433
+ checks_out, earned, possible = [], 0.0, 0.0
434
+ for c in CHECKS:
435
+ st, ev, fx = results[c["id"]]
436
+ w = c["weight"]
437
+ if st == "na":
438
+ pts = None
439
+ else:
440
+ possible += w
441
+ pts = w if st == "pass" else (w * 0.5 if st == "warn" else 0)
442
+ earned += pts
443
+ checks_out.append({"id": c["id"], "name": c["name"], "status": st, "weight": w,
444
+ "points": pts, "evidence": ev, "fix_hint": fx})
445
+ score = round(100 * earned / possible) if possible else 0
446
+ fixes = [c for c in checks_out if c["status"] in ("fail", "warn") and c["fix_hint"]]
447
+ fixes.sort(key=lambda c: -c["weight"])
448
+ return checks_out, score, grade_for(score), \
449
+ [{"check": f["id"], "fix": f["fix_hint"]} for f in fixes[:5]]
450
+
451
+
452
+ # --------------------------------------------------------------------- tools
453
+ def t_audit_storefront(args):
454
+ return run_audit(args.get("url", ""))
455
+
456
+
457
+ def t_list_checks(args):
458
+ return {"checks": [{"id": c["id"], "name": c["name"], "weight": c["weight"],
459
+ "description": c["description"]} for c in CHECKS],
460
+ "total_weight": sum(c["weight"] for c in CHECKS),
461
+ "scoring": "pass = full weight, warn = half, fail = 0, na = excluded. Grade: A>=90 B>=75 C>=60 D>=40 F<40."}
462
+
463
+
464
+ TOOLS = {
465
+ "audit_storefront": (t_audit_storefront,
466
+ "Audit a public storefront for AI-agent readiness: UCP profile, live MCP endpoint, llms.txt, robots.txt AI-crawler policy, Product structured data, sitemap, platform, HTTPS/canonical. Returns a 0-100 score, letter grade, per-check evidence and top fixes. Public endpoints only — no credentials.",
467
+ {"url": {"type": "string"}}, ["url"]),
468
+ "list_checks": (t_list_checks,
469
+ "List the readiness check catalog with weights and scoring rules.",
470
+ {}, []),
471
+ }
472
+
473
+
474
+ def tool_list():
475
+ return [{"name": name,
476
+ "description": desc,
477
+ "inputSchema": {"type": "object", "properties": props, "required": req}}
478
+ for name, (_, desc, props, req) in TOOLS.items()]
479
+
480
+
481
+ def handle(req: dict):
482
+ method, rid = req.get("method", ""), req.get("id")
483
+ if method == "initialize":
484
+ return {"jsonrpc": "2.0", "id": rid, "result": {
485
+ "protocolVersion": req.get("params", {}).get("protocolVersion", "2025-03-26"),
486
+ "capabilities": {"tools": {}},
487
+ "serverInfo": {"name": "agent-ready-mcp", "version": "0.1.0"}}}
488
+ if method == "ping":
489
+ return {"jsonrpc": "2.0", "id": rid, "result": {}}
490
+ if method == "tools/list":
491
+ return {"jsonrpc": "2.0", "id": rid, "result": {"tools": tool_list()}}
492
+ if method == "tools/call":
493
+ name = req["params"]["name"]
494
+ args = req["params"].get("arguments", {})
495
+ if name not in TOOLS:
496
+ return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": f"unknown tool {name}"}}
497
+ try:
498
+ out = TOOLS[name][0](args)
499
+ return {"jsonrpc": "2.0", "id": rid, "result": {
500
+ "content": [{"type": "text", "text": json.dumps(out, indent=1)}]}}
501
+ except ToolError as e:
502
+ return {"jsonrpc": "2.0", "id": rid, "result": {
503
+ "content": [{"type": "text", "text": json.dumps({"error": str(e)})}], "isError": True}}
504
+ if rid is not None:
505
+ return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"method not found: {method}"}}
506
+ return None # notification
507
+
508
+
509
+ def main():
510
+ for line in sys.stdin:
511
+ line = line.strip()
512
+ if not line:
513
+ continue
514
+ try:
515
+ resp = handle(json.loads(line))
516
+ except Exception as e: # never crash the transport
517
+ resp = {"jsonrpc": "2.0", "id": None, "error": {"code": -32603, "message": str(e)}}
518
+ if resp is not None:
519
+ sys.stdout.write(json.dumps(resp) + "\n")
520
+ sys.stdout.flush()
521
+
522
+
523
+ if __name__ == "__main__":
524
+ main()
525
+
526
+ # AgentReady MCP · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: eclipse-agentready
3
+ Version: 0.1.0
4
+ Summary: AgentReady (T6 CLI edition) — audit a public storefront for AI shopping-agent readiness from CI: UCP profile, live MCP endpoint, llms.txt, robots AI policy, Product structured data, sitemap, platform, HTTPS/canonical.
5
+ Author: Cyan Eclipse Technologies LLC
6
+ License: MIT
7
+ Project-URL: Homepage, https://cetech.ai
8
+ Project-URL: Repository, https://github.com/CYAN-ECLIPSE-TECHNOLOGIES-LLC/agent-ready-mcp
9
+ Project-URL: Vendor, https://cetech.ai
10
+ Keywords: agent-readiness,storefront,e-commerce,ci,audit,mcp
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Classifier: Topic :: Software Development :: Quality Assurance
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # eclipse-agentready
23
+
24
+ **What this is:** `eclipse-agentready` audits **storefronts** — public
25
+ e-commerce sites — for AI shopping-agent readiness: can an agent
26
+ (ChatGPT, Claude, Perplexity, Gemini…) discover the store, read its
27
+ catalog in machine-readable form, and get correct price/stock data?
28
+
29
+ **What this is not:** it does **not** audit repositories, and it does
30
+ **not** grade MCP servers. (The unrelated PyPI project named
31
+ `agentready` scores *code repositories* for AI-assisted development —
32
+ a different tool for a different job. This one is the CLI edition of
33
+ Cyan Eclipse Technologies' **AgentReady** storefront auditor, also
34
+ available as an MCP server.)
35
+
36
+ ## Use — a CI step for merchants and storefront DevOps
37
+
38
+ ```console
39
+ $ pip install eclipse-agentready
40
+ $ eclipse-agentready audit https://shop.example --fail-under 60
41
+ $ eclipse-agentready audit https://shop.example --format json --out report.json
42
+ $ eclipse-agentready list-checks
43
+ ```
44
+
45
+ Exit codes: `0` = audit completed and (if `--fail-under N` given) the
46
+ score is at least N · `1` = score below N · `2` = the audit itself
47
+ could not run.
48
+
49
+ ## The 8 checks (weights sum to 100)
50
+
51
+ | Check | Weight |
52
+ |---|---|
53
+ | Live MCP endpoint answers JSON-RPC | 20 |
54
+ | Product structured data (JSON-LD with price + availability) | 20 |
55
+ | UCP profile published (/.well-known/ucp) | 15 |
56
+ | HTTPS + canonical sanity | 12 |
57
+ | robots.txt allows AI crawlers | 10 |
58
+ | Sitemap present with product URLs | 10 |
59
+ | llms.txt present | 8 |
60
+ | Platform identified | 5 |
61
+
62
+ Scoring: pass = full weight, warn = half, fail = 0, n/a = excluded.
63
+ Grade: A ≥ 90 · B ≥ 75 · C ≥ 60 · D ≥ 40 · F < 40. Every check reports
64
+ observed evidence only, plus a concrete fix hint when it does not fully
65
+ pass. The CLI and the MCP server run the *identical* check catalog and
66
+ scoring path — same evidence in, same grade out.
67
+
68
+ ## Honest scope
69
+
70
+ - **Public-surface audit only.** All checks read public endpoints with
71
+ no credentials, a declared user agent, 10s timeouts, and redirect
72
+ following.
73
+ - **No authenticated checkout execution** — this tool never logs in,
74
+ never buys, and cannot verify a full agent purchase. A store can
75
+ score well here and still fail at the checkout step (and vice versa).
76
+ - **No private data** is collected or stored; audit output is the
77
+ caller's. Evidence is a point-in-time observation; storefronts change.
78
+ - A grade is not an endorsement and not a Bazaar listing claim.
79
+
80
+ The merchant's path to agent-commerce readiness starts with a grade
81
+ you can watch: run it in CI on every deploy. A hosted monitoring tier
82
+ (scheduled re-audits, score-change alerts) is on the product roadmap
83
+ and is **not yet launched**.
84
+
85
+ Powered by Cyan Eclipse Technologies LLC (cetech.ai) — a Veteran-Owned
86
+ Small Business. MIT licensed. Not affiliated with Shopify, WooCommerce,
87
+ Automattic, or any audited store; platform names describe detection
88
+ results only.
@@ -0,0 +1,9 @@
1
+ eclipse_agentready/__init__.py,sha256=XnDbQwmVkuZ-IlZvw4M4ynmx-0-pE31ye18GJx6cM-4,488
2
+ eclipse_agentready/cli.py,sha256=qIJ4kRfY2gZ-P4KQaVrjX_d6epx4VaylJFWOeowJI5I,4363
3
+ eclipse_agentready/engine.py,sha256=K87nViAlS1V1ItT6SMJPeAQrnwMEFNavs5L_NqY2kWY,25518
4
+ eclipse_agentready-0.1.0.dist-info/licenses/LICENSE,sha256=J64raxD33SRnnCn8p1F-lRl0rnesurM9Jlkd3auDZvA,1086
5
+ eclipse_agentready-0.1.0.dist-info/METADATA,sha256=lcZNpkA_mxpfBLLUeRLh_peE5Lphh4t2NA6PMZw9Lh4,3876
6
+ eclipse_agentready-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ eclipse_agentready-0.1.0.dist-info/entry_points.txt,sha256=U7USQJyDODx6XEE9twEuSriBTBp0u8CUw9U_dmYct4I,67
8
+ eclipse_agentready-0.1.0.dist-info/top_level.txt,sha256=WQcz8zdlIU-beFF5zhPFmm0xpWyk4TShXZc-CF_OPZM,19
9
+ eclipse_agentready-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ eclipse-agentready = eclipse_agentready.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cyan Eclipse Technologies LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ eclipse_agentready