Skip to content

feat(mcp): operator-managed OAuth client allowlist - #1440

Draft
JimWallace wants to merge 2 commits into
mainfrom
claude/chickadee-pr-1438-1f1mdb
Draft

JimWallace wants to merge 2 commits into
mainfrom
claude/chickadee-pr-1438-1f1mdb

Conversation

@JimWallace

Copy link
Copy Markdown
Owner

Implements the brief in #1438. Which AI tool may connect to the MCP surface becomes an enforced deployment policy rather than a human judgement — the claim the UW IRA/PIA reviewers are asking for.

Before this, any client could complete Dynamic Client Registration, and the only gate was a course instructor recognising at the consent screen which tools the University had approved. That gate is real but rests on the instructor's knowledge, which a reviewer discounts, correctly.

What it does

The operator lists permitted client redirect origins, one per line, in .mcp-client-allowlist in the work directory. Both /oauth/authorize verbs enforce it — the GET refuses with 403 before any consent token is minted, the POST re-checks on submit — and production refuses to mount the MCP surfaces while the list is empty.

The four §3 decisions, as specified

  • Keyed on the redirect-URI origin, not client_id (DCR-generated) or client_name (self-asserted). Scheme and host lowercased, path dropped, default port normalised away; https required except for localhost / 127.0.0.1.
  • Empty list = open in development, refused in production. Mirrors the allowOpenTransportGuards precedent, so the test corpus is untouched while production gains the fail-closed property. Not deny-all globally.
  • No new environment variable. File-backed actor store following .worker-secret / .local-runner-autostart; added to .gitignore beside them. Blank lines and # comments ignored; an unusable entry is logged at startup rather than silently dropped.
  • 403 at both authorize verbs, Abort(.forbidden,) rather than an OAuth error redirect. The POST re-check exists because the consent token outlives a list change (600 s TTL), mirroring the existing role re-check.
  • /oauth/register left open — inert without consent, no client identity to gate on at registration time, and maxRegisteredClients already backstops flooding.

Three deviations from the brief, all deliberate

  1. The OAuth consent flow gets the mount refusal too, not just /mcp and /admin-mcp (§3.2 named two). Those mount separately from registerMCPOAuthRoutes, so guarding only the transports would leave an empty-list production deployment still minting consent tokens and authorization codes for any client — which contradicts acceptance criterion 1. Three call sites now, not two.
  2. SecurityHeadersMiddleware.cspOrigin(of:) did not fit, as §3.1 anticipated it might: it neither lowercases nor enforces the https rule. MCPClientOrigin.normalized lives next to the store and is tested directly. Both halves of the comparison — operator entries and presented redirect URIs — go through it, so an entry cannot be written in a form the check can't match.
  3. No admin UI (§4 "should-have, not blocking"). Operators edit the file and recycle the container. Because production now will not mount without the file, I documented it in README.md alongside the other MCP setup rather than leaving it discoverable only from the compliance docs.

Tests

27 new tests, all Swift Testing, no force unwraps. Covering §5 items 1–7: origin normalization (case, ports, path, http-except-loopback, malformed), file parsing (absent, empty, comments, blanks, whitespace, duplicates, unusable entries), the store's allow/deny including runtime edits, and the production refusal helper. Route-level: GET refused → 403 and zero MCPConsentRequest rows; GET allowlisted → consent renders; POST after removal → 403 and zero MCPAuthorizationCode rows; POST still-listed → code issued; empty list → both verbs unchanged.

Full suite green. scripts/format.sh, scripts/lint.sh, scripts/swiftlint.sh --strict all clean (0 violations, 1031 files). One full-suite run showed four WorkerDaemonTests/WorkerTests failures under parallel build load (one expected <30 s and took 43.7 s); those 343 tests pass in 6.9 s re-run idle, and this change touches no Worker code.

Operator action required

An existing production deployment with MCP_MODE set must create .mcp-client-allowlist before taking this release, or the MCP surface will not come up. Called out in the changelog fragment.

Notes for review

The brief's §6 says to push to claude/uw-mcp-approval-process-ou02ab; this session is restricted to its own assigned branch, so it went to claude/chickadee-pr-1438-1f1mdb instead. Nothing else in §6 changed — no VERSION / ChickadeeVersion / CHANGELOG.md edits, one changelog.d/ fragment.

§9's out-of-scope items are untouched: no Client ID Metadata Documents, no IdP federation, no MCP_MODE changes.


Generated by Claude Code

claude added 2 commits August 17, 2026 14:05
Which AI tool may connect to the MCP surface becomes an enforced deployment
policy instead of a human judgement. Previously any client could complete
Dynamic Client Registration and the only gate was a course instructor
recognising, at the consent screen, which tools the University had approved.

The operator now lists permitted client redirect origins, one per line, in
`.mcp-client-allowlist` in the work directory — a file-backed actor store
following `.worker-secret` and `.local-runner-autostart`, so no new
environment variable is introduced. Both `/oauth/authorize` verbs enforce it:
the GET refuses with 403 before any consent token is minted, and the POST
re-checks on submit, since the consent token stays submittable for ten
minutes and the list can change inside that window.

Matching is on the redirect URI's origin — the only client-identifying field
that is neither generated per registration (`client_id`) nor self-asserted
(`client_name`). Scheme and host are lowercased, the path is dropped (products
vary callback paths), and a default port is normalised away. https is required
except for localhost / 127.0.0.1.

An empty list still means "allow any", which leaves development and the
existing test corpus untouched. The fail-closed half lives at mount time:
production refuses to mount /mcp, /admin-mcp, and the OAuth consent flow while
the list is empty, logging why. The consent flow is included deliberately —
guarding only the two transports would still let an open production deployment
mint consent tokens and authorization codes.

`/oauth/register` stays open: a registration is inert without consent, and
there is no client identity to gate on at registration time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J3UcFxp4kcgG738B54h6gv
…438-1f1mdb

# Conflicts:
#	Sources/APIServer/Bootstrap/AppDirectories.swift
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants