feat(mcp): operator-managed OAuth client allowlist - #1440
Draft
JimWallace wants to merge 2 commits into
Draft
JimWallace wants to merge 2 commits into
JimWallace wants to merge 2 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-allowlistin the work directory. Both/oauth/authorizeverbs enforce it — theGETrefuses with 403 before any consent token is minted, thePOSTre-checks on submit — and production refuses to mount the MCP surfaces while the list is empty.The four §3 decisions, as specified
client_id(DCR-generated) orclient_name(self-asserted). Scheme and host lowercased, path dropped, default port normalised away;httpsrequired except forlocalhost/127.0.0.1.allowOpenTransportGuardsprecedent, so the test corpus is untouched while production gains the fail-closed property. Not deny-all globally..worker-secret/.local-runner-autostart; added to.gitignorebeside them. Blank lines and#comments ignored; an unusable entry is logged at startup rather than silently dropped.Abort(.forbidden,)rather than an OAuth error redirect. ThePOSTre-check exists because the consent token outlives a list change (600 s TTL), mirroring the existing role re-check./oauth/registerleft open — inert without consent, no client identity to gate on at registration time, andmaxRegisteredClientsalready backstops flooding.Three deviations from the brief, all deliberate
/mcpand/admin-mcp(§3.2 named two). Those mount separately fromregisterMCPOAuthRoutes, 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.SecurityHeadersMiddleware.cspOrigin(of:)did not fit, as §3.1 anticipated it might: it neither lowercases nor enforces the https rule.MCPClientOrigin.normalizedlives 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.README.mdalongside 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:
GETrefused → 403 and zeroMCPConsentRequestrows;GETallowlisted → consent renders;POSTafter removal → 403 and zeroMCPAuthorizationCoderows;POSTstill-listed → code issued; empty list → both verbs unchanged.Full suite green.
scripts/format.sh,scripts/lint.sh,scripts/swiftlint.sh --strictall clean (0 violations, 1031 files). One full-suite run showed fourWorkerDaemonTests/WorkerTestsfailures 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_MODEset must create.mcp-client-allowlistbefore 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 toclaude/chickadee-pr-1438-1f1mdbinstead. Nothing else in §6 changed — noVERSION/ChickadeeVersion/CHANGELOG.mdedits, onechangelog.d/fragment.§9's out-of-scope items are untouched: no Client ID Metadata Documents, no IdP federation, no
MCP_MODEchanges.Generated by Claude Code