File plan. src/mcp/http-server.mjs (new — wraps the existing createPlatoMcpServer + live-read handlers behind one POST route; Origin check, protocol-version, token extraction, rate-limit, fail-closed status mapping). src/mcp/server.mjs unchanged. src/auth/verify-oidc.mjs + src/auth/jwks-cache.mjs (new). deploy/Dockerfile + deploy/fly.toml (new). tests/hosted-mcp-oidc.test.mjs (the 10 DOD legs). One new gate leg in scripts/gate.sh.
verifyOidc steps (each falsifiable; any failure THROWS, caller maps to 401): (1) split + decode header/payload; (2) alg MUST be RS256 — reject alg:none / algorithm-confusion; (3) look up signing key by kid via injectable jwksSource, refresh-once on unknown kid; (4) RS256-verify via subtle.importKey('jwk',…) + subtle.verify — we never implement RSA; (5) iss === issuer; (6) exp/iat in-window with clock tolerance; (7) repository === expectedRepo (scope enforced inside verify, before the handler sees a claim); (8) return claims.
Seam wiring. Because verifyOidc enforces repository === expectedRepo, a wrong-repo badge throws at verify (→ 401) before reportDeployment runs — scope-before-action holds structurally; the authz closure is a redundant second gate. The read side does NOT build an OIDC authz in v1.
Tests all run against a test-generated 2048-bit RSA keypair exported as JWK for the injected jwksSource — no network, no real GitHub. Each fail-closed floor asserts the store is byte-identical after a rejected request.
Frame-fitness discharge (owned by review): problem class = verify an RS256 JWT from one known OIDC IdP against its JWKS. Field default = the jose library (confirmed via web scan). Strongest alternative = native WebCrypto (present in Node v25, verified). Anti-pattern hunted = hand-rolled JWT verification accepting alg:none, not pinning iss, skipping exp — native path rejects all three explicitly and never implements RSA. Divergence to native is recorded and surfaced as call #1, not silently taken.