You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
MCP auth: add authenticated /mcp/me endpoint + OAuth discovery + CORS for Claude/ChatGPT sign-in (#1842)
* Add authenticated MCP endpoint + OAuth discovery for Claude/ChatGPT sign-in
Interactive MCP clients (Claude web/desktop, ChatGPT) only start the OAuth
flow when an endpoint answers an unauthenticated request with 401 +
WWW-Authenticate. The existing /mcp endpoint serves anonymous callers (200),
so those clients connected anonymously and users could never sign in to reach
private resources. Anonymous public access is intentional, so rather than
change /mcp, add a dedicated authenticated entrypoint.
- Add /mcp/me/ which returns 401 + WWW-Authenticate when no token is present,
triggering OAuth 2.1 + PKCE in interactive clients, and serves the user's
private + public resources once signed in. It shares the global stateless
MCP server; auth state is per-request via the _mcp_user ContextVar. Public
/mcp/ is unchanged; a valid bearer token is still honored on either.
- Serve RFC 9728 path-based protected-resource metadata at
/.well-known/oauth-protected-resource/mcp/me (alongside the existing root
document) and advertise a cite-authenticated server in
/.well-known/mcp.json when USE_AUTH0 is enabled.
- Enforce CORS inside the MCP ASGI app, which bypasses Django middleware:
OPTIONS preflight, allow-listed origins via the new MCP_CORS_ALLOWED_ORIGINS
setting (defaults to Claude, ChatGPT, and the MCP Inspector; merges in
CORS_ALLOWED_ORIGINS), and exposing WWW-Authenticate / Mcp-Session-Id.
- Harden the challenge base URL with the new MCP_PUBLIC_BASE_URL setting
(MCP bypasses ALLOWED_HOSTS), falling back to the sanitized Host header.
- Refresh docs/mcp/README.md (previously stated "no authentication") with the
endpoint, discovery flow, and Auth0 audience / DCR / CORS requirements.
- Add ASGI-layer and discovery tests for the new behavior.
* Document MCP auth endpoint + OAuth discovery in CHANGELOG
* Drop redundant inner import json in MCP auth test
* Fix linter: wrap long MCP endpoint description (E501), type-annotate CORS helpers, black-format discovery test
* Fix linter: drop redundant quotes on ASGISend/ASGIMessage forward refs (pyupgrade)
* Format: collapse _wrap_send_with_cors signature after pyupgrade (black)
* Address review: sanitize MCP_PUBLIC_BASE_URL in WWW-Authenticate + pin path coupling
- _derive_public_base_url now strips double-quote and CR/LF from the
operator-configured MCP_PUBLIC_BASE_URL before embedding it in the
WWW-Authenticate quoted-string (RFC 7235), so a stray quote / newline in the
setting can neither break the header nor inject a header line — mirroring the
existing Host-derived sanitization.
- Add a discovery-views test asserting MCP_AUTHED_PATH is in
_OAUTH_PROTECTED_RESOURCES, pinning the documented (but previously
unenforced) coupling so a future rename fails loudly instead of silently
404-ing the new path's RFC 9728 metadata.
* Add MCP /mcp/me auth tests + clarify path-gating comment
Adds four targeted regression tests in MCPAsgiAppAuthTest:
- allow-listed cross-origin 401 on /mcp/me carries Access-Control-Allow-Origin
(so browser clients can read the WWW-Authenticate challenge)
- valid bearer token on /mcp/me reaches downstream (not 401)
- trailing-slash /mcp/me/ without a token still 401s (rstrip normalization)
- MCP_PUBLIC_BASE_URL preferred over Host in the challenge and its quote/CR/LF
chars are stripped so the header stays well-formed
Also documents that the startswith branch in _path_requires_auth
intentionally auth-gates future /mcp/me/* sub-paths.
* Address review: CORS-on-401(invalid-token)/429 tests + frozenset/comment nits
- Add test_invalid_token_401_carries_cors_origin_for_allowlisted_origin: the
existing CORS-on-401 test only covered the missing-token branch; this pins
the invalid/expired-token branch (the token-refresh path a browser client
actually hits after expiry).
- Add test_rate_limited_429_carries_cors_origin_for_allowlisted_origin: confirm
the 429 response carries Access-Control-Allow-Origin (send-wrapping runs
before the rate-limit check).
- Document the deliberate Access-Control-Allow-Credentials omission in
_cors_preflight_headers (MCP is Bearer-token, not cookie, auth).
- Make _OAUTH_PROTECTED_RESOURCES a frozenset to signal membership-test intent.
* Address review: sub-path routing TODO, sanitization-asymmetry comment, hoist override_settings import
- Add a TODO on the exact-match endpoint routing in _handle_mcp_request noting it
won't honor _path_requires_auth's /mcp/me/ startswith subtree until corpus-scoped
sub-paths land (deferred today).
- Document the deliberate sanitization asymmetry in _derive_public_base_url: ','/';'/
whitespace are stripped from the attacker-controlled Host but intentionally NOT from
the operator-pinned MCP_PUBLIC_BASE_URL (stripping would corrupt a legitimate URL).
- Remove 9 redundant in-method 'from django.test import override_settings' imports;
override_settings is already imported at module level.
Left _mcp_cors_allowlist() uncached: the reviewer notes the per-request cost is
acceptable (Django caches settings access) and lru_cache would break override_settings
propagation in tests.
* Address PR #1842 review: validate MCP_PUBLIC_BASE_URL, drop dead CORS branch, seal /mcp/me TODO, +negative test
- _derive_public_base_url now validates the (stripped) MCP_PUBLIC_BASE_URL
against a scheme://host re.fullmatch and returns None when it doesn't
match, so a malformed-but-non-injecting value (e.g. a trailing ';junk')
degrades the 401 challenge to realm-only instead of emitting a mangled
resource_metadata URL or trusting the request Host (review #1). Rewrote
the PR's sanitization test into a valid-wins case + a malformed-degrades
case.
- Drop the dead str-branch in _wrap_send_with_cors header dedup: ASGI
mandates bytes header names, so a plain .lower() suffices (review).
- Convert the dangling /mcp/me exact-match TODO into a documented
intentional-behavior NOTE (sub-paths fall through to the endpoint-catalog
404) (review).
- Add test_path_based_metadata_returns_404_without_auth0 (review).
* Address PR #1842 review: fold Vary:Origin, gate localhost CORS default on DEBUG
- _wrap_send_with_cors now folds Origin into a pre-existing Vary header
(e.g. Accept-Encoding) instead of dropping Vary:Origin on a membership check;
prevents a CDN serving a CORS-stripped cached response cross-origin. Pinned by
test_cors_vary_folds_into_existing_vary_header.
- MCP Inspector loopback origins (:6274) only join the MCP_CORS_ALLOWED_ORIGINS
default under DEBUG, so they never ship in a production default.
- Comment the send-wrap ordering invariant (must precede rate-limit/JWT branches).
- Drop meaningless 'review item 1' cross-references from two test docstrings.
---------
Co-authored-by: Claude <noreply@anthropic.com>
- **Flaky test isolation** (`opencontractserver/tests/research/test_research_report_model.py`, issue #1845): `test_visible_to_user_superuser_sees_all` asserted a global `visible_to_user(admin).count() == 2`. Because the superuser branch returns `.all()`, a sibling `TransactionTestCase` that commits `ResearchReport` rows broke the count under a sequential whole-`research/`-directory run (CI stayed green only because xdist isolates files to separate worker DBs). Scoped the assertion to the test's own rows. Test-only; no production impact.
113
-
114
159
- **Corpus document versioning & path/folder audit — correctness and performance fixes.** A sweep of the dual-tree versioning (`DocumentPath`) and `CorpusFolder` path system surfaced several correctness gaps and N+1s. Regression coverage: `opencontractserver/tests/test_versioning_paths_audit.py`.
115
160
- **`Corpus.add_document` no longer silently supersedes a colliding document** (`opencontractserver/corpuses/models.py`). When the auto-/caller-supplied path (e.g. `/documents/<title>`) collided with an existing active document, the occupant was marked `is_current=False` and the new doc became a "version" of an unrelated content tree — the first document silently vanished from the corpus. `add_document` now disambiguates the path (`/documents/Report` → `/documents/Report_1`) via `CorpusPathService._disambiguate_path`, always creating an independent root path (`parent=None`, `version_number=1`). `add_document` is not a versioning entry point; `import_content` remains the path-versioning surface.
116
161
- **Folder rename / move now reconciles `DocumentPath.path` strings** (`opencontractserver/corpuses/services/folders.py`, `services/paths.py`). `update_folder` (rename) and `move_folder` changed `CorpusFolder.get_path()` but left the folder-derived `path` strings of contained documents (and descendant-folder documents) stale — drifting from the location that document *moves* derive. New `CorpusPathService.reconcile_paths_after_folder_change` rewrites every folder-derived active path under the affected subtree to the new prefix as immutable MOVED history nodes (batch deactivate + `bulk_create` + signal dispatch), inside the same transaction as the folder mutation. Non-folder-derived paths (e.g. an upload's `/documents/<title>`) are intentionally left untouched.
Copy file name to clipboardExpand all lines: docs/mcp/README.md
+61-8Lines changed: 61 additions & 8 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -5,12 +5,20 @@
5
5
OpenContracts exposes a read-only [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for AI assistants to access **public** corpuses, documents, annotations, and discussion threads.
6
6
7
7
**Endpoints**:
8
-
-**Global** (all public corpuses): `POST /mcp/` or `GET /mcp/`
8
+
-**Global** (all public corpuses, anonymous): `POST /mcp/` or `GET /mcp/`
9
+
-**Authenticated** (public + your private resources): `POST /mcp/me/` or `GET /mcp/me/`
9
10
-**Corpus-Scoped** (single corpus): `POST /mcp/corpus/{corpus_slug}/` or `GET /mcp/corpus/{corpus_slug}/`
0 commit comments