Spec · SPRINT 1 · for red-pen

Hosted MCP + GitHub-OIDC

Put Plato on the internet as one HTTPS endpoint a deploy pipeline can call — with zero stored secret. GitHub hands each CI job a tamper-proof badge that expires in ~15 minutes; Plato checks it against GitHub’s own public keys, confirms the badge’s repo matches the repo being reported, and only then writes. Every fail-closed floor from the live-read core now holds over the wire too.

Draft for Robert’s annotation · not approved · no building yet
01

The story — what changes

Today Plato only answers over a local pipe (stdio). Nobody remote, no CI job, can ask it anything.

SPRINT 1 puts Plato behind one HTTPS endpoint that two kinds of caller reach: a read caller (“what is live in Prism prod?”) gets the never-lie envelope over the wire unchanged; a deploy pipeline (a GitHub Actions job, right after it ships) calls report_deployment to tell Plato what it just shipped.

The thing that makes it safe to expose: Plato stores no secret. The deploy job doesn’t carry a password Plato has to keep. GitHub already hands it a badge that says “I am the job in repo org/prism”. A leaked badge is dead in minutes — nothing to rotate, nothing to store, nothing to lose.

02

What this builds — four pieces, then a deploy artifact

Pure code + config. The literal deploy is the one human handoff.

the build
1 · One HTTPS endpoint (Streamable HTTP)
POST carries one JSON-RPC message; the response is one application/json body (one-shot — no streaming in v1). Exposes both tool families behind the same door: the existing read tools (reach / graph / verify_currency / reach_graph) and the live-read tools (report_deployment / what_is_live / report_running). Origin validated (foreign → 403); MCP-Protocol-Version honored.
the build
2 · A GitHub-OIDC verifier
verifyOidc(token){ repository, …claims } or throws. Fetches GitHub’s JWKS, caches the keys, RS256-verifies the signature, checks iss, exp/iat in-window, and the repository claim. The JWKS source is injectable — the whole verifier is provable with a test-generated key, never reaching the real GitHub.
have it
3 · The seam, wired
The live-read tools already take an injected authz(repo, claims) and fail closed. The endpoint feeds OIDC claims in: the report side authorizes only when the badge’s repository equals the repo being reported (report-only, own repo). The read side (what_is_live) is separately scoped — reporting and reading are different powers.
config
4 + 5 · Fail-closed floors (mechanical) & deploy artifact
Each floor is a test that goes RED if removed (§05). Plus a Dockerfile + fly.toml so the literal ship is fly deploy — built and verified locally; the deploy itself is your handoff.
03

The fail-closed floor — turned onto the wire

Every floor we built the live-read core around now holds over HTTP — and each can’t silently rot, because each is a test that goes RED if the floor is removed.

F1HTTPS only
TLS terminates at the platform edge; the app refuses to treat plain HTTP as trusted. Config gate + a check it never reads a secret over a non-TLS hop.
F2Never log the token
The raw Authorization / JWT never enters any log line, error, or echo. A test scans all emitted log output for the token string — RED if it appears.
F3401 without leaking which check
Bad signature, expired, wrong repo, malformed — all return the same flat 401. A test asserts identical status + body across all four causes (no map for an attacker).
F4JWKS fetch fails → reject
Can’t reach GitHub’s keys → the request is rejected, never accepted-anyway. A test injects a failing JWKS source and asserts reject, store untouched.
F5Data source down → 503
The store being unreadable returns 503, never a fabricated “current” answer. A test breaks the store read and asserts 503.
F6Scope before action
The repository claim is checked before any write touches the store. A wrong-repo badge → 401 and the deployment log is byte-identical after.
F7Rate-limit per token
A token over its budget gets 429, not unbounded writes.
F8Never-lie envelope survives the wire
what_is_live over HTTP/JSON carries provenance + reportedBy + reportedAt + freshness, exactly as local. The existing property test re-run against the HTTP-serialized answer.
04

The one ruling that needs you first — the crypto dependency

The call. Verify the OIDC badge’s RS256 signature with Node’s built-in WebCrypto (zero new dependencies), or add the jose library (the field’s named default for JWT/JWKS)?

Why it matters. This is the one frame-fitness fork in the sprint. The signature check is the load-bearing security primitive — if it’s wrong, every “valid badge” is a forgeable lie. The smooth, available answer is “just add jose”; the frame-fitness floor says smoothness gets more scrutiny, not less.

Recommended
Native WebCrypto · zero-dep
Node v25 ships subtle (RS256 import + verify), global fetch (JWKS), and http.createServer — all three primitives, zero deps (verified by node -e). Cost: ~40 lines of glue we own and test. We never implement RSA — only call Node’s audited subtle.verify. Keeps the trust path dependency-free, which matters more for a security primitive than saving 35 lines.
Alternative
Add jose · the field default
~3 lines (createRemoteJWKSet + jwtVerify). Cost: a new dependency in the trust path (one we’d have to keep current), against a repo with exactly two deps and a strong own-the-trust-path culture. Right call if the verifier ever needs to grow (multiple IdPs, exotic algs) — for one IdP and RS256, native is the fit.

On your word: “Native” → build proceeds exactly as specced. “jose” → a one-line swap in the verifier; everything else (seam, floors, tests, deploy) is identical.

05

Definition of done — autonomously provable

Every one runs in make gate / node --test against a test-generated RSA key — the verifier never touches the real GitHub.

  1. Server starts and answers a tools/list over the HTTP endpoint.
  2. Valid badge → success a token signed by the test key, repository = the scoped repo, to report_deployment → stored.
  3. Invalid / expired / wrong-repo badge → 401, fail-closed the store is byte-identical afterward (nothing written), and the 401 body is generic (doesn’t say which check failed). This is the cardinal floor.
  4. Missing or foreign Origin → 403.
  5. Token never appears in logs capture all log output across a valid + an invalid request; assert the token string is absent.
  6. JWKS fetch fails → reject injected failing JWKS source → 401, store untouched.
  7. Data source down → 503 not a stale/guessed answer.
  8. what_is_live over the wire preserves the never-lie envelope provenance + reportedBy + reportedAt + freshness, or the no-report fence.
  9. Rate-limit past the per-token budget → 429.
  10. make gate green and the deploy artifact (Dockerfile + fly.toml) exists and builds.
06

Out of scope (SPRINT 1)

The literal production deploy
The artifact is built and verified; fly deploy is your one-command handoff. Per the standing rule a deploy needs a proven rollback — the rollback path is documented (§08) and rehearsed once before real traffic.
A full OAuth 2.1 authorization server
Explicitly NOT built. This is a machine-to-machine internal tool; OIDC badge verification is the whole auth story (CP-1).
The optional GET / server-push leg
POST one-shot JSON covers v1; the GET SSE leg is a clean later add, not needed now.
Wiring Prism’s actual CI
The one-line report_deployment call (Lukasz, once the endpoint is blessed).
07

Your calls — the red-pen targets

1
Crypto dependency.
Native zero-dep WebCrypto, or add jose? The one frame-fitness fork — full ruling block in §04. The spec is written to native; one word flips it.
2
Read-side auth for what_is_live in v1.
Open to the world (read-only, never-lie-labeled, nothing sensitive leaks) — or require a lightweight read token from day one? Recommend: open read in v1, gate the read path next.
3
Rate-limit budget.
What per-token write rate is sane for a deploy pipeline? A deploy reports once per ship; a heartbeat a few times an hour. Recommend a generous default (e.g. 60/min) just to cap abuse.
4
Hosting platform.
Fly.io as written, or your preference? Fly terminates TLS, gives a one-command deploy + image-pinned rollback. Render / a container host work identically; the artifact is portable.
08

The machinery — for the builder

Small diff: new files + thin wiring, no rewrite of existing handlers. Full detail in the Markdown (Layer 2); the load-bearing shape:

↘ go deeper — the spec the builder reads

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.

09

Rollback

Code: all-new files + one thin gate leg + a one-line gate addition; nothing in the existing stdio path or handlers changes. Revert the branch → zero residue. rm -rf deploy/ removes the artifact.

Deploy (when blessed): Fly pins each release to an image. Proven rollback = fly deploy --image <previous-release-image>. Per the standing deploy rule the rollback anchor (current image id) is recorded before deploy and the rollback exercised once before the endpoint carries real traffic. Until that rehearsal, the deploy stays a human handoff.