Design 1 · MCP endpoint on OAuth 2.1

Implements the MCP authorization spec (rev 2025-06-18): OAuth 2.1 · PKCE (RFC 7636, S256) · AS metadata (RFC 8414) · protected-resource metadata (RFC 9728) · dynamic client registration (RFC 7591) · resource indicators (RFC 8707)

The MCP endpoint becomes https://says.hermione.online/mcp — no secret in the URL. Callers present Authorization: Bearer <token>; tokens are short-lived, space-scoped, revocable, and issued by a small built-in authorization server. Because there are no user accounts, "logging in" at the consent page means pasting the space secret — the same credential as /space_admin/, typed exactly once per client instead of being embedded in every config file.

Why this is "as secure as MCP gets today" This is precisely the authorization model the current MCP spec prescribes for HTTP transports, and it is what claude.ai custom connectors and Claude Code implement natively: discovery → dynamic registration → browser consent with PKCE → token refresh, all automatic. The residual weakness (clients cache refresh tokens in local config files, e.g. ~/.claude.json) is inherent to every MCP client today — but a leaked refresh token is now revocable and space-scoped, unlike the current forever-secret.

New HTTP surface

RouteWhat
/mcpThe MCP endpoint (Streamable HTTP, same JSON-RPC handler as today). Requires Authorization: Bearer; on failure replies 401 with WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource" — this header is how clients bootstrap the whole flow.
/.well-known/oauth-protected-resourceRFC 9728: names the resource and points at the authorization server (same origin).
/.well-known/oauth-authorization-serverRFC 8414: advertises the three endpoints below, code_challenge_methods_supported: ["S256"], grant types authorization_code + refresh_token, token_endpoint_auth_methods_supported: ["none"] (public clients; PKCE carries the security).
/oauth/registerRFC 7591 dynamic client registration. Unauthenticated POST (per spec); stores client_id, name, redirect URIs. Claude registers itself here — no manual client setup ever.
/oauth/authorizeThe only human-facing page: shows the requesting client's name and redirect URI, asks for the space secret, and on approval 302-redirects with a single-use code. Rate-limited per IP.
/oauth/tokenExchanges code + code_verifier for tokens; rotates refresh tokens.
one Django app · says.hermione.online Human MCP client Claude.ai / Claude Code Auth server /oauth/* + /.well-known/* MCP server /mcp 1 · POST /mcp — no token yet 2 · 401 + WWW-Authenticate: resource_metadata=… 3 · GET /.well-known/… (RFC 9728, then RFC 8414) 4 · POST /oauth/register (DCR) → client_id 5 · opens browser: /oauth/authorize + code_challenge (PKCE S256) + resource=/mcp 6 · pastes space secret · reviews client name · Approve 7 · 302 redirect_uri?code=… (single-use, 5 min) 8 · POST /oauth/token — code + code_verifier 9 · access token (1 h) + refresh token (90 d) 10 · POST /mcp · Authorization: Bearer → tools/list, tools/call … later, whenever the access token expires — no human involved 11 · refresh_token grant → new pair, old refresh revoked
The full connect flow. Steps 1–4 and 8–11 are fully automatic in Claude clients; the human only acts in steps 5–6, once per client.

Data model (migration 0005_oauth)

Three new tables in main/models.py; the principal everywhere is the existing Space. All secrets are stored as SHA-256 hashes — the plaintext token exists only in the HTTP response that delivers it.

class OAuthClient(models.Model):          # RFC 7591 registrations
    client_id     = models.CharField(max_length=64, unique=True)   # token_urlsafe
    name          = models.CharField(max_length=128)               # shown on consent page
    redirect_uris = models.JSONField()                             # exact-match at authorize time
    created_at    = models.DateTimeField(auto_now_add=True)

class OAuthCode(models.Model):            # authorization codes
    code_hash      = models.CharField(max_length=64, unique=True)  # sha256(code)
    client         = models.ForeignKey(OAuthClient, on_delete=models.CASCADE)
    space          = models.ForeignKey(Space, on_delete=models.CASCADE)
    redirect_uri   = models.CharField(max_length=512)
    code_challenge = models.CharField(max_length=128)              # PKCE, S256 only
    resource       = models.CharField(max_length=256, blank=True)  # RFC 8707 audience
    expires_at     = models.DateTimeField()                        # now + 5 min
    used           = models.BooleanField(default=False)            # single-use

class OAuthToken(models.Model):           # access + refresh, one table
    token_hash = models.CharField(max_length=64, unique=True, db_index=True)
    kind       = models.CharField(max_length=8)                    # 'access' | 'refresh'
    client     = models.ForeignKey(OAuthClient, on_delete=models.CASCADE)
    space      = models.ForeignKey(Space, related_name='oauth_tokens',
                                   on_delete=models.CASCADE)
    expires_at = models.DateTimeField()   # access: 1 h · refresh: 90 d
    revoked    = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

Token policy

ArtifactLifetimeRules
Authorization code5 minutesSingle-use; bound to client, redirect URI, PKCE challenge and space. Reuse → rejected.
Access token1 hourOpaque secrets.token_urlsafe(43) with prefix sho_at_; sent only in the Authorization header (never query strings — that is the exact sin being retired). Verified per request: hash lookup + expiry + space.active.
Refresh token90 days, slidingPrefix sho_rt_. Rotated on every use: the grant returns a new pair and revokes the old refresh token. Reuse of a revoked refresh token revokes the whole space's tokens (theft signal).
All of the above—Revoked in bulk by: secret rotation at /admin/, space suspension (checked live via the FK), or a future per-client revoke button.

Enforcement — the only change main/mcp.py needs

The JSON-RPC machinery, tools and instructions are untouched. Authentication becomes a resolver in front of the same handler; the legacy URL route keeps working through it during migration.

# main/mcp.py — new entry point; tool code below it is unchanged
def _space_from_bearer(request):
    auth = request.headers.get('Authorization', '')
    if not auth.startswith('Bearer '):
        return None
    token_hash = hashlib.sha256(auth[7:].strip().encode()).hexdigest()
    t = (OAuthToken.objects
         .filter(token_hash=token_hash, kind='access', revoked=False,
                 expires_at__gt=timezone.now())
         .select_related('space').first())
    return t.space if t and t.space.active else None

@csrf_exempt
def mcp_oauth_endpoint(request):          # routed at /mcp
    space = _space_from_bearer(request)
    if space is None:
        resp = HttpResponse('Unauthorized', status=401)
        resp['WWW-Authenticate'] = (
            'Bearer resource_metadata='
            f'"https://{request.get_host()}/.well-known/oauth-protected-resource"')
        return resp
    return _dispatch(request, space)      # today's body of mcp_endpoint

The consent page (the one new human touchpoint)

A sibling of the existing space_admin_login.html: it shows "‹client name› wants to publish to a space on says.hermione.online", the redirect URI, one password field for the space secret, and Approve / Deny. On approve it resolves the space (constant-time, like today's logins), mints the code, and redirects. Wrong secret and per-IP rate limiting reuse the same error UX as the admin logins. If the browser already has a /space_admin/ session, the secret field can be pre-satisfied by that session — nice-to-have, not required.

What connecting looks like afterwards

# claude.ai → Settings → Connectors → Add custom connector
URL: https://says.hermione.online/mcp
→ browser popup opens /oauth/authorize → paste space secret → Approve. Done.

# Claude Code
claude mcp add --transport http says-hermione https://says.hermione.online/mcp
→ on first use: /mcp → Authenticate → same browser consent. Done.

No secret in the command line, in ~/.claude.json project config, or in any URL — only short-lived tokens managed by the client.

Considered and rejected

AlternativeVerdictWhy
Static Bearer header (claude mcp add --header "Authorization: Bearer <secret>")interim onlyOne-line change and gets the secret out of URLs — but it's still a forever-credential in every client config, claude.ai connectors push you toward OAuth anyway, and you'd build the revocation story twice. Acceptable as a stopgap if OAuth slips.
django-oauth-toolkitnoWants django.contrib.auth Users as resource owners; bending it to Space-as-principal costs more than the ~400 hand-rolled lines, and adds a dependency to a deliberately dependency-light app.
JWT access tokensnoSingle-server, single-DB app — an indexed hash lookup is simpler, revocable, and adds no crypto surface.
Third-party IdP (Google etc.)noThe platform has no user accounts by design; introducing them for OAuth's sake inverts the architecture.

Honest limits

Implementation checklist

FileChange
main/models.py+ OAuthClient, OAuthCode, OAuthToken; Space.rotate_secret() also revokes tokens; migration 0005
main/oauth.py (new)metadata views, register, authorize (GET form / POST consent), token (code + refresh grants), rate limiter (~400 lines)
main/mcp.pysplit mcp_endpoint into resolver + _dispatch; add mcp_oauth_endpoint; legacy route honors Space.url_secret_enabled
main/urls.pyroutes for /mcp, /oauth/…, /.well-known/… (before the project catch-alls)
main/naming.pyadd oauth to RESERVED_DIRECTORY_NAMES
templatesoauth_authorize.html (consent), reusing admin-login styling
main/tests.pydiscovery docs · DCR · full code+PKCE happy path · wrong verifier · code reuse · expired/revoked token → 401 with header · refresh rotation + reuse-theft · rotation/suspension revokes · legacy flag on/off

← Overview · Design 2 — htaccess-style web gate →