Browse Source

feat(skills): Add staleness verifier to mcp-ops

Catalog the fast-moving facts mcp-ops encodes (the MCP SDK package names +
spec URL) in assets/mcp-facts.json and guard them against silent drift with
scripts/check-mcp-facts.py, following the §7 offline/live split:

- --offline (PR CI): assets/mcp-facts.json parses + carries schema/as_of;
  every catalogued package's prose_token is still named in the skill prose;
  the spec URL token is still cited; SKILL.md still carries a dated currency
  note. Exit 0 consistent / 10 drift.
- --live (freshness job, never blocks a PR): each package still resolves on
  npm/PyPI (404 = gone = drift); tracked SDK majors compared to the sampled
  major; spec URL must answer 200. Exit 10 drift / 7 registries unreachable.

SKILL.md gains a "> Ecosystem facts verified as of 2026-07." currency note
and a staleness-verifier citation block; tests/run.sh exercises the offline
contract (frontmatter, citations, exit-code semantics, --json envelope, and
a negative drift case).

--offline: PASS (exit 0, 6 checks).
--live (recorded, prose intentionally NOT rewritten per task):
  - @modelcontextprotocol/sdk  npm   latest 1.29.0  (major 1 == sampled) OK
  - @modelcontextprotocol/inspector npm latest 0.22.0  resolves            OK
  - mcp                        PyPI  latest 1.28.1  (major 1 == sampled) OK
  - fastmcp                    PyPI  latest 3.4.2   major 3 != sampled 2  DRIFT
  - spec.modelcontextprotocol.io    unreachable from run environment     UNAVAILABLE
Drift surfaced: FastMCP has shipped a 3.x major (skill sampled 2) — the one
review signal this verifier exists to catch. The spec URL was unreachable
(advisory; not confirmed dead).

Co-Authored-By: Claude <noreply@anthropic.com>
0xDarkMatter 2 weeks ago
parent
commit
4579a31af2

+ 19 - 0
skills/mcp-ops/SKILL.md

@@ -12,6 +12,8 @@ metadata:
 
 Comprehensive patterns for building, testing, and deploying Model Context Protocol servers in Python and TypeScript.
 
+> Ecosystem facts verified as of 2026-07.
+
 ## MCP Architecture Quick Reference
 
 ```
@@ -307,6 +309,23 @@ async def get_valid_token() -> str:
 | `references/transport-auth.md` | ~550 | stdio/SSE/HTTP transports, session management, OAuth2, rate limiting, TLS |
 | `references/testing-debugging.md` | ~550 | MCP Inspector, unit/integration testing, protocol debugging, CI, performance |
 
+## Staleness verifier
+
+This skill encodes fast-moving facts (the MCP SDK package names + spec URL). [`scripts/check-mcp-facts.py`](scripts/check-mcp-facts.py) guards them against silent drift:
+
+```bash
+# Structural (PR CI, no network): every catalogued package's prose_token is
+# still named in this skill's prose, the spec URL is still cited, and the
+# currency note still carries a year.
+python scripts/check-mcp-facts.py --offline        # exit 0 consistent, 10 drift
+
+# Live (freshness job, never blocks a PR): each SDK still resolves on
+# npm/PyPI, no tracked major has moved off the sampled major, spec URL 200.
+python scripts/check-mcp-facts.py --live            # exit 10 drift, 7 registries unreachable
+```
+
+The canonical fact set lives in [`assets/mcp-facts.json`](assets/mcp-facts.json); when you add or drop a package, update it to match or `--offline` fails CI.
+
 ## See Also
 
 - **MCP Specification**: https://spec.modelcontextprotocol.io

+ 44 - 0
skills/mcp-ops/assets/mcp-facts.json

@@ -0,0 +1,44 @@
+{
+  "_comment": "Canonical fast-moving facts the mcp-ops skill encodes. scripts/check-mcp-facts.py asserts the skill prose still names these SDK packages + spec URL and still carries a dated currency note (--offline), and probes the npm/PyPI registries + the spec URL for drift (--live). Edit deliberately: a change here is a skill-content decision, not housekeeping.",
+  "schema": "claude-mods.mcp-ops.facts/v1",
+  "as_of": "2026-07-05",
+  "spec": {
+    "url": "https://spec.modelcontextprotocol.io",
+    "prose_token": "spec.modelcontextprotocol.io",
+    "_comment": "The MCP specification URL cited in See Also. --offline asserts the token is named in the skill prose; --live expects the URL to answer HTTP 200 (after redirects)."
+  },
+  "packages": {
+    "@modelcontextprotocol/sdk": {
+      "registry": "npm",
+      "role": "TypeScript SDK",
+      "sampled_major": 1,
+      "track_major": true,
+      "prose_token": "@modelcontextprotocol/sdk",
+      "_comment": "The official TS SDK. --live flags drift if the latest dist-tag leaves major 1 (a 2.x would be an API-break release warranting a skill review)."
+    },
+    "@modelcontextprotocol/inspector": {
+      "registry": "npm",
+      "role": "MCP Inspector (dev/debug UI)",
+      "sampled_major": 0,
+      "track_major": false,
+      "prose_token": "@modelcontextprotocol/inspector",
+      "_comment": "The interactive inspector invoked via `npx`. Only resolution is checked live (0.x versioning is chatty); a 404 means it was renamed/removed."
+    },
+    "mcp": {
+      "registry": "pypi",
+      "role": "Python SDK (FastMCP ships inside as mcp.server.fastmcp)",
+      "sampled_major": 1,
+      "track_major": true,
+      "prose_token": "mcp[cli]",
+      "_comment": "The official Python SDK on PyPI, installed via `uv add mcp[cli]`. --live flags drift if the latest release leaves major 1."
+    },
+    "fastmcp": {
+      "registry": "pypi",
+      "role": "FastMCP high-level framework (gofastmcp.com)",
+      "sampled_major": 2,
+      "track_major": true,
+      "prose_token": "fastmcp",
+      "_comment": "The standalone FastMCP package (FastMCP 2 +). Named in prose as FastMCP and via gofastmcp.com; matched case-insensitively."
+    }
+  }
+}

+ 284 - 0
skills/mcp-ops/scripts/check-mcp-facts.py

@@ -0,0 +1,284 @@
+#!/usr/bin/env python3
+"""Staleness verifier for mcp-ops: the MCP SDK packages + spec URL the skill
+names must stay real, named, and current.
+
+mcp-ops tells the reader to install `@modelcontextprotocol/sdk` (npm),
+`@modelcontextprotocol/inspector` (npm), `mcp[cli]` / `fastmcp` (PyPI), and
+cites the spec at spec.modelcontextprotocol.io. Those are exactly the facts
+that drift silently (SKILL-RESOURCE-PROTOCOL.md §7): an SDK is renamed, a
+package is unpublished, the prose stops mentioning one the catalog still
+lists, or the spec URL goes dead — and nobody notices for months. Two modes:
+
+  --offline (default, safe for PR CI): structural consistency, no network.
+    * assets/mcp-facts.json parses and carries the schema + an as_of date
+    * every catalogued package's prose_token is still named somewhere in the
+      skill prose (SKILL.md + references/*.md) — the catalog can't drift from
+      the docs
+    * the spec URL token is still cited in the prose
+    * SKILL.md still carries a dated "verified as of <year>" currency note
+  --live (scheduled freshness job, never a PR gate): does each package still
+    resolve on its registry (npm latest / PyPI JSON), and has any tracked
+    SDK's major moved off the sampled major? Does the spec URL answer 200?
+
+Usage:   check-mcp-facts.py [--offline | --live] [--catalog FILE] [--skill DIR] [--json] [--timeout S]
+Input:   argv flags only (no stdin).
+Output:  stdout = findings (plain rows, or a --json envelope). Data only.
+Stderr:  the verdict line, notices, errors.
+Exit:    0 ok, 2 usage, 3 catalog/skill missing, 4 catalog unparseable,
+         7 registries/spec unreachable (live, advisory — never a real failure),
+         10 drift found (offline: unnamed/currency-note gone; live: package
+            gone, SDK major moved, or spec URL dead)
+
+Examples:
+  check-mcp-facts.py --offline                 # PR CI: catalog ⇆ prose consistency
+  check-mcp-facts.py --live                    # weekly: SDKs still resolve + spec 200
+  check-mcp-facts.py --offline --json | jq '.data[]'
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+EX_OK = 0
+EX_USAGE = 2
+EX_NOTFOUND = 3
+EX_UNPARSEABLE = 4
+EX_UNAVAILABLE = 7
+EX_DRIFT = 10
+
+SCHEMA = "claude-mods.mcp-ops.facts/v1"
+
+HERE = Path(__file__).resolve().parent
+DEFAULT_CATALOG = HERE.parent / "assets" / "mcp-facts.json"
+DEFAULT_SKILL = HERE.parent
+
+NPM_REGISTRY = "https://registry.npmjs.org"
+PYPI_REGISTRY = "https://pypi.org/pypi"
+
+CURRENCY_RE = re.compile(r"verified as of\s+(\d{4})", re.IGNORECASE)
+AS_OF_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
+
+
+class Finding:
+    __slots__ = ("check", "status", "detail")
+
+    def __init__(self, check: str, status: str, detail: str) -> None:
+        self.check = check
+        self.status = status  # ok | drift | unavailable
+        self.detail = detail
+
+    def as_dict(self) -> dict:
+        return {"check": self.check, "status": self.status, "detail": self.detail}
+
+
+def load_catalog(path: Path) -> dict:
+    if not path.is_file():
+        print(f"error: facts catalog not found: {path}", file=sys.stderr)
+        raise SystemExit(EX_NOTFOUND)
+    try:
+        data = json.loads(path.read_text(encoding="utf-8"))
+        if not isinstance(data, dict) or data.get("schema") != SCHEMA:
+            raise ValueError(f"schema must be {SCHEMA!r}")
+        if not AS_OF_RE.match(str(data.get("as_of", ""))):
+            raise ValueError(f"as_of must be YYYY-MM-DD, got {data.get('as_of')!r}")
+        if not isinstance(data.get("packages"), dict) or not data["packages"]:
+            raise ValueError("'packages' must be a non-empty object")
+        return data
+    except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
+        print(f"error: could not parse catalog {path}: {exc}", file=sys.stderr)
+        raise SystemExit(EX_UNPARSEABLE)
+
+
+def read_corpus(skill_dir: Path) -> tuple[str, str]:
+    """Returns (skill_md_text, all_prose_text) across SKILL.md + references/*.md."""
+    doc = skill_dir / "SKILL.md"
+    if not doc.is_file():
+        print(f"error: SKILL.md not found under {skill_dir}", file=sys.stderr)
+        raise SystemExit(EX_NOTFOUND)
+    skill_md = doc.read_text(encoding="utf-8", errors="replace")
+    parts = [skill_md]
+    ref_dir = skill_dir / "references"
+    if ref_dir.is_dir():
+        for ref in sorted(ref_dir.glob("*.md")):
+            parts.append(ref.read_text(encoding="utf-8", errors="replace"))
+    return skill_md, "\n".join(parts)
+
+
+def check_offline(catalog: dict, skill_dir: Path) -> list[Finding]:
+    skill_md, corpus = read_corpus(skill_dir)
+    findings: list[Finding] = []
+    lower_corpus = corpus.lower()
+
+    # Currency note still dated.
+    if CURRENCY_RE.search(skill_md):
+        m = CURRENCY_RE.search(skill_md)
+        findings.append(Finding("currency-note", "ok", f"currency note dated {m.group(1)}"))
+    else:
+        findings.append(Finding("currency-note", "drift",
+                                "no dated 'verified as of <year>' currency note in SKILL.md"))
+
+    # Spec URL token still cited.
+    spec = catalog.get("spec", {})
+    spec_token = str(spec.get("prose_token", ""))
+    if spec_token and spec_token.lower() in lower_corpus:
+        findings.append(Finding("spec-cited", "ok", f"{spec_token} named in prose"))
+    else:
+        findings.append(Finding("spec-cited", "drift",
+                                f"spec token {spec_token!r} no longer named in skill prose"))
+
+    # Every catalogued package still named in the prose.
+    for name, meta in catalog.get("packages", {}).items():
+        token = str(meta.get("prose_token", name))
+        if token.lower() in lower_corpus:
+            findings.append(Finding(f"pkg-named:{name}", "ok", "named in skill prose"))
+        else:
+            findings.append(Finding(f"pkg-named:{name}", "drift",
+                                    f"prose_token {token!r} not found in skill prose"))
+    return findings
+
+
+def _fetch(url: str, timeout: float, accept: str = "application/json") -> tuple[str, object]:
+    """Return (ok|notfound|unavailable, payload-or-status)."""
+    req = urllib.request.Request(url, headers={"User-Agent": "claude-mods-mcp-ops-check/1",
+                                               "Accept": accept})
+    try:
+        with urllib.request.urlopen(req, timeout=timeout) as resp:
+            body = resp.read().decode("utf-8", errors="replace")
+            return "ok", body
+    except urllib.error.HTTPError as exc:
+        if exc.code in (404, 410):
+            return "notfound", exc.code
+        return "unavailable", exc.code
+    except (urllib.error.URLError, TimeoutError, OSError):
+        return "unavailable", None
+
+
+def _npm_latest(pkg: str, timeout: float) -> tuple[str, str]:
+    """Return (status, version-or-status-detail). status in ok|notfound|unavailable."""
+    url = f"{NPM_REGISTRY}/{urllib.parse.quote(pkg, safe='')}/latest"
+    status, payload = _fetch(url, timeout)
+    if status != "ok":
+        return status, str(payload)
+    try:
+        ver = json.loads(payload).get("version", "")
+        return "ok", ver
+    except json.JSONDecodeError:
+        return "unavailable", "bad-json"
+
+
+def _pypi_latest(pkg: str, timeout: float) -> tuple[str, str]:
+    url = f"{PYPI_REGISTRY}/{urllib.parse.quote(pkg, safe='')}/json"
+    status, payload = _fetch(url, timeout)
+    if status != "ok":
+        return status, str(payload)
+    try:
+        ver = json.loads(payload).get("info", {}).get("version", "")
+        return "ok", ver
+    except json.JSONDecodeError:
+        return "unavailable", "bad-json"
+
+
+def _major(ver: str) -> str:
+    """Leading integer component of a version string ('1.2.3' -> '1')."""
+    m = re.match(r"\s*(\d+)", ver)
+    return m.group(1) if m else ""
+
+
+def check_live(catalog: dict, timeout: float) -> list[Finding]:
+    findings: list[Finding] = []
+
+    for name, meta in catalog.get("packages", {}).items():
+        registry = meta.get("registry", "npm")
+        if registry == "npm":
+            status, info = _npm_latest(name, timeout)
+        else:
+            status, info = _pypi_latest(name, timeout)
+        if status == "notfound":
+            findings.append(Finding(f"npm:{name}", "drift",
+                                    "package gone from registry — renamed/removed, review skill"))
+            continue
+        if status == "unavailable":
+            findings.append(Finding(f"npm:{name}", "unavailable", "registry unreachable"))
+            continue
+        ver = str(info)
+        if meta.get("track_major"):
+            sampled = str(meta.get("sampled_major", ""))
+            latest_major = _major(ver)
+            if latest_major and latest_major != sampled:
+                findings.append(Finding(f"npm:{name}", "drift",
+                                        f"{name}@{ver} major {latest_major} != sampled {sampled} — review skill"))
+            else:
+                findings.append(Finding(f"npm:{name}", "ok", f"latest {ver} (major {latest_major})"))
+        else:
+            findings.append(Finding(f"npm:{name}", "ok", f"latest {ver}"))
+
+    # Spec URL must answer 200 (follows redirects).
+    spec_url = str(catalog.get("spec", {}).get("url", ""))
+    if spec_url:
+        status, info = _fetch(spec_url, timeout, accept="text/html,application/json")
+        if status == "ok":
+            findings.append(Finding("spec-url", "ok", f"{spec_url} answered 200"))
+        elif status == "notfound":
+            findings.append(Finding("spec-url", "drift", f"{spec_url} returned 4xx — spec URL dead"))
+        else:
+            findings.append(Finding("spec-url", "unavailable", f"{spec_url} unreachable"))
+    return findings
+
+
+def main(argv: list[str]) -> int:
+    p = argparse.ArgumentParser(
+        prog="check-mcp-facts.py",
+        description="Verify mcp-ops' SDK packages + spec URL stay named (offline) and live (live).",
+    )
+    mode = p.add_mutually_exclusive_group()
+    mode.add_argument("--offline", action="store_true", help="structural consistency, no network (default)")
+    mode.add_argument("--live", action="store_true", help="probe npm/PyPI registries + the spec URL")
+    p.add_argument("--catalog", default=str(DEFAULT_CATALOG), help="facts catalog JSON")
+    p.add_argument("--skill", default=str(DEFAULT_SKILL), help="skill directory (SKILL.md + references/)")
+    p.add_argument("--timeout", type=float, default=10.0, help="per-request timeout seconds (live)")
+    p.add_argument("--json", action="store_true", help="emit a JSON envelope")
+    p.add_argument("-q", "--quiet", action="store_true", help="suppress stderr progress/summary")
+    try:
+        args = p.parse_args(argv)
+    except SystemExit as exc:
+        return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
+
+    catalog = load_catalog(Path(args.catalog))
+    live = args.live and not args.offline
+    mode_name = "live" if live else "offline"
+    findings = check_live(catalog, args.timeout) if live else check_offline(catalog, Path(args.skill))
+
+    n_drift = sum(1 for f in findings if f.status == "drift")
+    n_unavail = sum(1 for f in findings if f.status == "unavailable")
+
+    if args.json:
+        print(json.dumps({
+            "data": [f.as_dict() for f in findings],
+            "meta": {"mode": mode_name, "count": len(findings),
+                     "drift": n_drift, "unavailable": n_unavail, "schema": SCHEMA},
+        }, indent=2))
+    else:
+        for f in findings:
+            print(f"{f.check}\t{f.status}\t{f.detail}")
+
+    for f in findings:
+        if f.status != "ok":
+            print(f"  [{f.status.upper()}] {f.check}: {f.detail}", file=sys.stderr)
+    if not args.quiet:
+        print(f"-- {len(findings)} checks: {n_drift} drift, {n_unavail} unavailable", file=sys.stderr)
+
+    if n_drift:
+        return EX_DRIFT
+    if n_unavail:
+        return EX_UNAVAILABLE
+    return EX_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))

+ 109 - 0
skills/mcp-ops/tests/run.sh

@@ -0,0 +1,109 @@
+#!/usr/bin/env bash
+# Offline self-test for the mcp-ops skill — structure, frontmatter, and the
+# staleness-verifier contract (SKILL-RESOURCE-PROTOCOL §7, §10).
+#
+# Usage:   tests/run.sh
+# Input:   none (self-contained; no network, no MCP server required)
+# Output:  TAP-ish progress on stderr; final PASS/FAIL line.
+# Exit:    0 all pass (or skipped on unsupported platform), 1 any failure.
+#
+# Examples:
+#   tests/run.sh
+#   bash skills/mcp-ops/tests/run.sh
+set -uo pipefail
+
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+fail=0
+pass=0
+note() { printf '  %s %s\n' "$1" "$2" >&2; }
+ok()   { pass=$((pass+1)); note "ok  " "$1"; }
+bad()  { fail=$((fail+1)); note "FAIL" "$1"; }
+
+# Resolve a *working* python (python3, else python). The bare `command -v` is not
+# enough on Windows, where `python3` is a Microsoft Store stub that exits nonzero.
+PY=""
+for cand in python3 python; do
+  if command -v "$cand" >/dev/null 2>&1 && "$cand" --version >/dev/null 2>&1; then
+    PY="$cand"; break
+  fi
+done
+if [ -z "$PY" ]; then
+  echo "SKIP: no working python interpreter on this platform" >&2
+  exit 0
+fi
+
+# 1. Required directories exist
+for d in scripts references assets tests; do
+  [ -d "$here/$d" ] && ok "dir $d/ exists" || bad "missing dir $d/"
+done
+
+# 2. SKILL.md frontmatter house rules
+skill="$here/SKILL.md"
+if [ -f "$skill" ]; then
+  ok "SKILL.md present"
+  grep -q '^name: mcp-ops$' "$skill" && ok "name matches directory" || bad "name != mcp-ops"
+  grep -q '^license: MIT$' "$skill" && ok "license: MIT" || bad "missing license: MIT"
+  grep -q '^  author: claude-mods$' "$skill" && ok "metadata.author" || bad "missing metadata.author"
+else
+  bad "SKILL.md missing"
+fi
+
+# 3. Every reference on disk is cited from SKILL.md (no dead weight)
+for ref in "$here"/references/*.md; do
+  base="references/$(basename "$ref")"
+  grep -qF "$base" "$skill" && ok "cited: $base" || bad "uncited reference: $base"
+done
+
+# 4. Every SKILL.md-cited bundled resource exists on disk
+for res in assets/mcp-facts.json scripts/check-mcp-facts.py; do
+  [ -f "$here/$res" ] && ok "resource present: $res" || bad "missing resource: $res"
+done
+
+# 5. check-mcp-facts.py — staleness verifier contract (§7, §10), offline-safe
+verifier="$here/scripts/check-mcp-facts.py"
+catalog="$here/assets/mcp-facts.json"
+ec() { local want="$1" lbl="$2"; shift 2; "$@" >/dev/null 2>&1; local got=$?
+       [ "$got" = "$want" ] && ok "$lbl (exit $got)" || bad "$lbl (want $want got $got)"; }
+if [ -f "$verifier" ]; then
+  "$PY" -m py_compile "$verifier" && ok "verifier: py_compile clean" || bad "verifier: py_compile failed"
+  grep -qE '^Examples:$' "$verifier" && ok "verifier: has Examples block" || bad "verifier: no Examples block (docstring)"
+  "$PY" "$verifier" --help >/dev/null 2>&1 && ok "verifier: --help exits 0" || bad "verifier: --help nonzero"
+  # Offline mode must pass on the skill's own content (internal consistency).
+  ec 0 "verifier: --offline consistent"  "$PY" "$verifier" --offline
+  # Bad flag → USAGE (exit 2); conflicting modes → USAGE.
+  ec 2 "verifier: bad flag → exit 2"     "$PY" "$verifier" --bogus
+  ec 2 "verifier: --offline --live → 2"  "$PY" "$verifier" --offline --live
+  # stdout is data-only: --offline --json must emit parseable JSON.
+  "$PY" "$verifier" --offline --json -q 2>/dev/null \
+    | "$PY" -c 'import json,sys; d=json.load(sys.stdin); assert d["meta"]["schema"]=="claude-mods.mcp-ops.facts/v1"' \
+    && ok "verifier: --json envelope parses (stdout clean)" || bad "verifier: --json envelope broken"
+  # Error paths: missing catalog → 3, malformed catalog → 4, drift catalog → 10.
+  TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
+  ec 3 "verifier: missing catalog -> 3"  "$PY" "$verifier" --offline --catalog "$TMP/nope.json"
+  printf '{"packages":"x"}' > "$TMP/bad.json"
+  ec 4 "verifier: malformed catalog -> 4" "$PY" "$verifier" --offline --catalog "$TMP/bad.json"
+  # Minimal drift catalog (argv path so MSYS translates it): a package whose
+  # prose_token is not in the real skill prose -> drift -> exit 10.
+  printf '%s\n' '{"schema":"claude-mods.mcp-ops.facts/v1","as_of":"2026-07-05","spec":{"url":"https://spec.example","prose_token":"zzz.no.such.spec"},"packages":{"@no-such/sdk":{"registry":"npm","prose_token":"@no-such/sdk","sampled_major":1,"track_major":true}}}' > "$TMP/drift.json"
+  ec 10 "verifier: unnamed package -> 10" "$PY" "$verifier" --offline --catalog "$TMP/drift.json"
+  # cited from SKILL.md
+  grep -qF "scripts/check-mcp-facts.py" "$skill" && ok "verifier: cited from SKILL.md" || bad "verifier: uncited"
+else
+  bad "check-mcp-facts.py missing"
+fi
+
+# 6. mcp-facts.json — parses, carries schema the verifier depends on
+"$PY" -c "
+import json, sys
+d = json.load(open(sys.argv[1], encoding='utf-8'))
+assert d['schema'] == 'claude-mods.mcp-ops.facts/v1'
+assert d['packages'], 'no packages committed'
+assert '@modelcontextprotocol/sdk' in d['packages']
+assert d['spec']['url'].startswith('https://')
+" "$catalog" && ok "mcp-facts.json schema + packages" || bad "mcp-facts.json invalid"
+
+# 7. Currency note present near the top of the body
+grep -qE 'verified as of [0-9]{4}' "$skill" && ok "currency note present" || bad "no dated currency note"
+
+echo "mcp-ops self-test: $pass passed, $fail failed" >&2
+[ "$fail" -eq 0 ]