@event4u/agent-config 1.37.0 → 1.39.0

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,146 @@
1
+ """Live-replay parity smoke — local stdio kernel vs deployed Worker URL.
2
+
3
+ Replays a fixed set of JSON-RPC calls against:
4
+
5
+ 1. The local Python loaders (`prompts.py` / `resources.py`) — the
6
+ source-of-truth wire surface.
7
+ 2. An HTTP target (typically `wrangler dev` locally, or the deployed
8
+ Cloudflare Worker URL in CI / post-deploy).
9
+
10
+ Diffs the two on a normalised view (signature + release_key + content
11
+ hashes stripped). Exit 0 = parity, 1 = drift.
12
+
13
+ Usage:
14
+ python scripts/mcp_parity_smoke.py --target http://127.0.0.1:8787
15
+ python scripts/mcp_parity_smoke.py --target https://mcp.example.com
16
+
17
+ Phase 5.1 of `road-to-cloudflare-mcp-hosting.md`. Governed by
18
+ `docs/contracts/mcp-cloud-scope.md` §A0-cloud.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import sys
25
+ import urllib.request
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ _SCRIPTS = Path(__file__).resolve().parent
30
+ sys.path.insert(0, str(_SCRIPTS))
31
+
32
+ from mcp_server.prompts import load_all_prompts, to_mcp_prompt_meta # noqa: E402
33
+ from mcp_server.resources import load_all_resources, to_mcp_resource_meta # noqa: E402
34
+
35
+ PAGE_SIZE = 50
36
+
37
+
38
+ def _local_prompts_list() -> dict[str, Any]:
39
+ prompts, _ = load_all_prompts()
40
+ metas = [to_mcp_prompt_meta(p) for p in prompts]
41
+ page = metas[:PAGE_SIZE]
42
+ out: dict[str, Any] = {"prompts": page}
43
+ if len(metas) > PAGE_SIZE:
44
+ out["nextCursor"] = page[-1]["name"]
45
+ return out
46
+
47
+
48
+ def _local_resources_list() -> dict[str, Any]:
49
+ resources, _ = load_all_resources()
50
+ metas = [to_mcp_resource_meta(r) for r in resources]
51
+ page = metas[:PAGE_SIZE]
52
+ out: dict[str, Any] = {"resources": page}
53
+ if len(metas) > PAGE_SIZE:
54
+ out["nextCursor"] = page[-1]["uri"]
55
+ return out
56
+
57
+
58
+ def _rpc(target: str, method: str, params: dict[str, Any] | None = None) -> Any:
59
+ body = json.dumps(
60
+ {"jsonrpc": "2.0", "id": 1, "method": method, "params": params or {}}
61
+ ).encode("utf-8")
62
+ req = urllib.request.Request(
63
+ target,
64
+ data=body,
65
+ headers={"content-type": "application/json"},
66
+ method="POST",
67
+ )
68
+ with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310
69
+ resp = json.loads(r.read().decode("utf-8"))
70
+ if "error" in resp:
71
+ raise RuntimeError(f"{method}: {resp['error']}")
72
+ return resp["result"]
73
+
74
+
75
+ def _normalize_prompts(payload: dict[str, Any]) -> list[dict[str, Any]]:
76
+ out = []
77
+ for p in payload.get("prompts", []):
78
+ out.append({
79
+ "name": p["name"],
80
+ "description": p["description"],
81
+ "kind": p.get("_meta", {}).get("kind"),
82
+ })
83
+ return sorted(out, key=lambda x: x["name"])
84
+
85
+
86
+ def _normalize_resources(payload: dict[str, Any]) -> list[dict[str, Any]]:
87
+ out = []
88
+ for r in payload.get("resources", []):
89
+ out.append({
90
+ "uri": r["uri"],
91
+ "name": r["name"],
92
+ "description": r["description"],
93
+ "mimeType": r["mimeType"],
94
+ "kind": r.get("_meta", {}).get("kind"),
95
+ })
96
+ return sorted(out, key=lambda x: x["uri"])
97
+
98
+
99
+ def _diff(label: str, local: list[Any], remote: list[Any]) -> int:
100
+ if local == remote:
101
+ print(f"✅ {label}: {len(local)} entries match")
102
+ return 0
103
+ print(f"❌ {label}: drift ({len(local)} local vs {len(remote)} remote)")
104
+ local_set = {json.dumps(x, sort_keys=True) for x in local}
105
+ remote_set = {json.dumps(x, sort_keys=True) for x in remote}
106
+ only_local = local_set - remote_set
107
+ only_remote = remote_set - local_set
108
+ for s in sorted(only_local)[:5]:
109
+ print(f" local-only: {s}")
110
+ for s in sorted(only_remote)[:5]:
111
+ print(f" remote-only: {s}")
112
+ if len(only_local) > 5 or len(only_remote) > 5:
113
+ print(f" (+{len(only_local) - 5} local, +{len(only_remote) - 5} remote more)")
114
+ return 1
115
+
116
+
117
+ def main() -> int:
118
+ ap = argparse.ArgumentParser(description=__doc__)
119
+ ap.add_argument("--target", required=True, help="HTTP URL of the Worker.")
120
+ args = ap.parse_args()
121
+
122
+ failed = 0
123
+ local_p = _normalize_prompts(_local_prompts_list())
124
+ remote_p = _normalize_prompts(_rpc(args.target, "prompts/list"))
125
+ failed += _diff("prompts/list", local_p, remote_p)
126
+
127
+ local_r = _normalize_resources(_local_resources_list())
128
+ remote_r = _normalize_resources(_rpc(args.target, "resources/list"))
129
+ failed += _diff("resources/list", local_r, remote_r)
130
+
131
+ try:
132
+ _ = _rpc(args.target, "tools/list")
133
+ print("✅ tools/list: round-trips (stub list — content not parity-checked)")
134
+ except Exception as e:
135
+ print(f"❌ tools/list: {e}")
136
+ failed += 1
137
+
138
+ if failed:
139
+ print(f"\n{failed} surface(s) drifted between local stdio and {args.target}")
140
+ return 1
141
+ print(f"\nparity OK against {args.target}")
142
+ return 0
143
+
144
+
145
+ if __name__ == "__main__":
146
+ sys.exit(main())
@@ -0,0 +1,274 @@
1
+ """Pack agent-config content into a Worker-bundle JSON blob.
2
+
3
+ Walks `.agent-src/skills/`, `.agent-src/commands/`, `.agent-src/rules/`,
4
+ `docs/guidelines/`, `.agent-src/contexts/` via the same Python loaders
5
+ that drive the local stdio kernel, emits one JSON blob and a sidecar
6
+ manifest for `workers/mcp/`.
7
+
8
+ Outputs (relative to repo root):
9
+ - `workers/mcp/content.json` — uncompressed, bundled by `wrangler deploy`.
10
+ - `workers/mcp/content.json.gz` — gzipped archival copy for R2.
11
+ - `workers/mcp/manifest.json` — manifest only (RCA / R2 sidecar).
12
+
13
+ Hard-fail thresholds (Phase 2-5 council verdict D2):
14
+ - Uncompressed JSON > 2 MB → SystemExit(1).
15
+ - Empty content (zero URIs) → SystemExit(2). Catches a broken
16
+ `.agent-src/` tree before deploy.
17
+
18
+ Cloud signature divergence vs local kernel (`metadata.compute_skill_set_signature`):
19
+ - Local kernel: SHA-256 over `(uri, mtime)` pairs — reproducible only
20
+ within one filesystem.
21
+ - This packer: SHA-256 over `(uri, body)` pairs — reproducible across
22
+ CI runs, machines, and re-clones. Same 12-char prefix.
23
+
24
+ Governed by `docs/contracts/mcp-cloud-scope.md` §A0-cloud invariant 5.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import gzip
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import subprocess
34
+ import sys
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ # Re-use the local kernel's loaders so the live-replay baseline stays
40
+ # trivially comparable.
41
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
42
+ sys.path.insert(0, str(_SCRIPTS_DIR))
43
+
44
+ from mcp_server.prompts import scan_commands, scan_skills # noqa: E402
45
+ from mcp_server.resources import scan_contexts, scan_guidelines, scan_rules # noqa: E402
46
+
47
+ SCHEMA_VERSION = 1
48
+ PACKER_VERSION = "1.0.0"
49
+ # Worker bundle is the compact JSON; gzipped copy lives in R2. Cloudflare's
50
+ # compressed-bundle limit is 3 MB (free) / 10 MB (paid); 778 KB gz today
51
+ # (438 entries) leaves ample headroom. Hard-fail at 5 MB uncompressed so
52
+ # the build dies before the Worker upload does.
53
+ MAX_UNCOMPRESSED_BYTES = 5 * 1024 * 1024
54
+
55
+
56
+ def _repo_root() -> Path:
57
+ return Path(__file__).resolve().parent.parent
58
+
59
+
60
+ def _git_sha(root: Path) -> str:
61
+ """Resolve HEAD SHA. Falls back to env var, then to all-zeros."""
62
+ for env_var in ("GITHUB_SHA", "CI_COMMIT_SHA", "GIT_COMMIT"):
63
+ sha = os.environ.get(env_var)
64
+ if sha and len(sha) >= 7:
65
+ return sha
66
+ try:
67
+ out = subprocess.run(
68
+ ["git", "rev-parse", "HEAD"],
69
+ cwd=root,
70
+ capture_output=True,
71
+ text=True,
72
+ check=True,
73
+ timeout=5,
74
+ )
75
+ return out.stdout.strip()
76
+ except (subprocess.SubprocessError, FileNotFoundError):
77
+ return "0" * 40
78
+
79
+
80
+ def _package_version(root: Path) -> str:
81
+ data = json.loads((root / "package.json").read_text(encoding="utf-8"))
82
+ return str(data.get("version", "0.0.0"))
83
+
84
+
85
+ def _collect_entries(root: Path) -> tuple[dict[str, dict[str, Any]], list[str]]:
86
+ """Run all 5 scanners and project entries into the wire shape."""
87
+ uris: dict[str, dict[str, Any]] = {}
88
+ errors: list[str] = []
89
+
90
+ skills, e = scan_skills(root)
91
+ errors.extend(e)
92
+ for s in skills:
93
+ key = f"skill://{s.name}"
94
+ uris[key] = {
95
+ "uri": key,
96
+ "name": s.name,
97
+ "description": s.description,
98
+ "body": s.body,
99
+ "source": s.source,
100
+ "kind": "skill",
101
+ }
102
+
103
+ commands, e = scan_commands(root)
104
+ errors.extend(e)
105
+ for c in commands:
106
+ key = f"command://{c.name.replace(':', '.')}"
107
+ uris[key] = {
108
+ "uri": key,
109
+ "name": c.name,
110
+ "description": c.description,
111
+ "body": c.body,
112
+ "source": c.source,
113
+ "kind": "command",
114
+ }
115
+
116
+ for scan in (scan_rules, scan_guidelines, scan_contexts):
117
+ items, e = scan(root)
118
+ errors.extend(e)
119
+ for r in items:
120
+ uris[r.uri] = {
121
+ "uri": r.uri,
122
+ "name": r.name,
123
+ "description": r.description,
124
+ "body": r.body,
125
+ "source": r.source,
126
+ "kind": r.kind,
127
+ "mime_type": r.mime_type,
128
+ }
129
+
130
+ return uris, errors
131
+
132
+
133
+ def _content_signature(uris: dict[str, dict[str, Any]]) -> tuple[str, str]:
134
+ """SHA-256 over sorted (uri, body) pairs.
135
+
136
+ Returns (full_hex, 12-char prefix). The prefix is the wire-surface
137
+ `skillSetSignature`; the full hex is the diagnostic `content_hash_sha256`.
138
+ """
139
+ hasher = hashlib.sha256()
140
+ for uri in sorted(uris):
141
+ hasher.update(uri.encode("utf-8"))
142
+ hasher.update(b"\x00")
143
+ hasher.update(uris[uri]["body"].encode("utf-8"))
144
+ hasher.update(b"\x1e")
145
+ digest = hasher.hexdigest()
146
+ return digest, digest[:12]
147
+
148
+
149
+ def _count_kinds(uris: dict[str, dict[str, Any]]) -> dict[str, int]:
150
+ counts = {"skill": 0, "command": 0, "rule": 0, "guideline": 0, "context": 0}
151
+ for entry in uris.values():
152
+ kind = entry["kind"]
153
+ if kind in counts:
154
+ counts[kind] += 1
155
+ return counts
156
+
157
+
158
+
159
+ def _build_manifest(
160
+ *,
161
+ signature: str,
162
+ content_hash: str,
163
+ package_version: str,
164
+ git_sha: str,
165
+ built_at: str,
166
+ counts: dict[str, int],
167
+ ) -> dict[str, Any]:
168
+ short = git_sha[:7] if git_sha and git_sha != "0" * 40 else "unknown"
169
+ return {
170
+ "schema_version": SCHEMA_VERSION,
171
+ "signature": signature,
172
+ "content_hash_sha256": content_hash,
173
+ "package_version": package_version,
174
+ "release_key": f"v{package_version}-{short}",
175
+ "git_sha": git_sha,
176
+ "built_at": built_at,
177
+ "packer_version": PACKER_VERSION,
178
+ "content_uri_count": counts,
179
+ }
180
+
181
+
182
+ def pack(root: Path, out_dir: Path) -> dict[str, Any]:
183
+ """Run the full pack. Returns the manifest dict."""
184
+ uris, errors = _collect_entries(root)
185
+ if not uris:
186
+ sys.stderr.write("pack: empty content (zero URIs)\n")
187
+ for line in errors:
188
+ sys.stderr.write(f" - {line}\n")
189
+ raise SystemExit(2)
190
+
191
+ content_hash, signature = _content_signature(uris)
192
+ counts = _count_kinds(uris)
193
+ built_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
194
+ manifest = _build_manifest(
195
+ signature=signature,
196
+ content_hash=content_hash,
197
+ package_version=_package_version(root),
198
+ git_sha=_git_sha(root),
199
+ built_at=built_at,
200
+ counts=counts,
201
+ )
202
+
203
+ blob = {
204
+ "schema_version": SCHEMA_VERSION,
205
+ "uris": uris,
206
+ "manifest": manifest,
207
+ }
208
+ # Compact JSON for the bundle (saves ~20 KB vs indent=2). The R2
209
+ # archival copy is gzipped, so legibility there is moot.
210
+ payload = json.dumps(blob, ensure_ascii=False, sort_keys=True)
211
+ payload_bytes = payload.encode("utf-8")
212
+
213
+ if len(payload_bytes) > MAX_UNCOMPRESSED_BYTES:
214
+ sys.stderr.write(
215
+ f"pack: uncompressed content {len(payload_bytes)} bytes "
216
+ f"exceeds limit {MAX_UNCOMPRESSED_BYTES}\n"
217
+ )
218
+ raise SystemExit(1)
219
+
220
+ out_dir.mkdir(parents=True, exist_ok=True)
221
+ (out_dir / "content.json").write_bytes(payload_bytes)
222
+ # mtime=0 keeps the gzip header byte-stable across CI runs so the
223
+ # R2 archival copy hashes deterministically.
224
+ with open(out_dir / "content.json.gz", "wb") as raw:
225
+ with gzip.GzipFile(
226
+ fileobj=raw, mode="wb", compresslevel=9, mtime=0
227
+ ) as gz:
228
+ gz.write(payload_bytes)
229
+ (out_dir / "manifest.json").write_text(
230
+ json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
231
+ encoding="utf-8",
232
+ )
233
+
234
+ if errors:
235
+ sys.stderr.write("pack: non-fatal frontmatter errors:\n")
236
+ for line in errors:
237
+ sys.stderr.write(f" - {line}\n")
238
+
239
+ return manifest
240
+
241
+
242
+ def main(argv: list[str] | None = None) -> int:
243
+ parser = argparse.ArgumentParser(description=__doc__)
244
+ parser.add_argument(
245
+ "--root", type=Path, default=_repo_root(), help="Repository root."
246
+ )
247
+ parser.add_argument(
248
+ "--out",
249
+ type=Path,
250
+ default=None,
251
+ help="Output directory (defaults to <root>/workers/mcp).",
252
+ )
253
+ parser.add_argument(
254
+ "--quiet", action="store_true", help="Suppress success summary."
255
+ )
256
+ args = parser.parse_args(argv)
257
+
258
+ out_dir = args.out or (args.root / "workers" / "mcp")
259
+ manifest = pack(args.root, out_dir)
260
+
261
+ if not args.quiet:
262
+ c = manifest["content_uri_count"]
263
+ sys.stderr.write(
264
+ f"pack: ok signature={manifest['signature']} "
265
+ f"release={manifest['release_key']} "
266
+ f"skills={c['skill']} commands={c['command']} "
267
+ f"rules={c['rule']} guidelines={c['guideline']} "
268
+ f"contexts={c['context']}\n"
269
+ )
270
+ return 0
271
+
272
+
273
+ if __name__ == "__main__":
274
+ raise SystemExit(main())
@@ -88,7 +88,7 @@ GENERIC_BOILERPLATE = [
88
88
  r"(?i)\blightweight yet powerful\b",
89
89
  ]
90
90
 
91
- OVERLOADED_LINE_THRESHOLD = 500
91
+ OVERLOADED_LINE_THRESHOLD = 750
92
92
  WEAK_QUICKSTART_LINE_GAP = 80
93
93
 
94
94