Skip to content

Commit dd2b1fa

Browse files
JSv4claude
andauthored
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>
1 parent 2851158 commit dd2b1fa

8 files changed

Lines changed: 957 additions & 86 deletions

File tree

CHANGELOG.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **MCP interactive sign-in for Claude web/desktop and ChatGPT.** Added an
13+
authenticated MCP entrypoint at `/mcp/me/` (`opencontractserver/mcp/server.py`)
14+
that returns `401 + WWW-Authenticate` to unauthenticated callers, triggering
15+
the OAuth 2.1 + PKCE flow in interactive clients; once signed in it serves the
16+
user's private + public resources. The public `/mcp/` endpoint is unchanged
17+
(anonymous = public only) and a valid bearer token is still honored on either.
18+
Both share the global stateless MCP server (auth is per-request via the
19+
`_mcp_user` ContextVar).
20+
- **RFC 9728 path-based protected-resource metadata** at
21+
`/.well-known/oauth-protected-resource/mcp/me` (alongside the existing root
22+
document), plus a `cite-authenticated` server advertised in
23+
`/.well-known/mcp.json` when `USE_AUTH0=True`
24+
(`opencontractserver/discovery/views.py`, `opencontractserver/discovery/urls.py`).
25+
- **CORS for the MCP endpoints.** `/mcp*` bypasses Django middleware, so CORS is
26+
now enforced inside the MCP ASGI app: `OPTIONS` preflight, an allow-list via
27+
the new `MCP_CORS_ALLOWED_ORIGINS` setting (defaults to Claude, ChatGPT, and
28+
the MCP Inspector; merges in `CORS_ALLOWED_ORIGINS`), and exposing
29+
`WWW-Authenticate` / `Mcp-Session-Id` (`config/settings/base.py`). The CORS
30+
`send`-wrapping runs before both the rate-limit check and JWT validation, so
31+
401 (missing **and** expired-token) and 429 responses all carry
32+
`Access-Control-Allow-Origin` — pinned by
33+
`test_invalid_token_401_carries_cors_origin_for_allowlisted_origin` and
34+
`test_rate_limited_429_carries_cors_origin_for_allowlisted_origin`
35+
(`opencontractserver/mcp/tests/test_mcp.py`). `Access-Control-Allow-Credentials`
36+
is intentionally omitted (MCP is Bearer-token, not cookie, auth);
37+
`_OAUTH_PROTECTED_RESOURCES` is a `frozenset` to make its membership-test
38+
intent explicit.
39+
- **Review follow-ups (#1842)**: the CORS `send`-wrapper now folds
40+
`Vary: Origin` into a pre-existing `Vary` header (e.g. `Accept-Encoding`)
41+
instead of dropping it on a membership check, so caches/CDNs still vary on
42+
`Origin` (regression test
43+
`test_cors_vary_folds_into_existing_vary_header`). The MCP Inspector
44+
loopback origins (`http://localhost:6274`, `http://127.0.0.1:6274`) are now
45+
only in the `MCP_CORS_ALLOWED_ORIGINS` default under `DEBUG`, so they never
46+
ship in a production default; operators can still set them explicitly. Added
47+
an inline note on why the `send`-wrap must precede the rate-limit/JWT
48+
branches.
1249
- **Chunked (resumable) uploads for large files** — work around the 100 MB
1350
per-request body ceiling that upstream proxies (Cloudflare) impose on the
1451
document-import REST endpoints. The client slices a file into sub-100 MB
@@ -74,6 +111,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
74111

75112
### Fixed
76113

114+
- **MCP `WWW-Authenticate` base-URL hardening** (`opencontractserver/mcp/server.py`)
115+
— the 401 challenge now prefers the trusted `MCP_PUBLIC_BASE_URL` over the
116+
request `Host` header (MCP bypasses `ALLOWED_HOSTS`), falling back to the
117+
previous sanitized-Host derivation. A configured value that survives
118+
quote/CR/LF stripping but is still not a valid `scheme://host` (a typo such
119+
as a trailing `;junk`, or a header-injection attempt) is now rejected via a
120+
`re.fullmatch` guard and the challenge degrades to a realm-only `Bearer`
121+
value rather than emitting a mangled URL or trusting the request `Host`.
122+
Refreshed `docs/mcp/README.md`, which previously stated "no authentication."
77123
- **Chunked single-document import no longer buffers the whole file in RAM at
78124
`complete` (issue #1843).** Reassembling a chunked single-document upload
79125
previously did `file_bytes = tmp.read()` and passed the whole assembled file
@@ -110,7 +156,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
110156
HTTP tests in `test_document_imports_chunked.py` (streamed-not-buffered
111157
contract, streamed-hash correctness, plain-text round trip).
112158
- **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-
114159
- **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`.
115160
- **`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.
116161
- **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.

config/settings/base.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,6 +1131,10 @@
11311131

11321132
CORS_EXPOSE_HEADERS = [
11331133
"my-custom-header",
1134+
# Let browser-based MCP clients read the auth challenge + transport session
1135+
# id on Django-served responses (e.g. the .well-known discovery endpoints).
1136+
"WWW-Authenticate",
1137+
"Mcp-Session-Id",
11341138
]
11351139

11361140
# When allowing credentials, do not use allow-all origins. Keep explicit list above.
@@ -1529,3 +1533,37 @@
15291533
},
15301534
"cache_ttl": env.int("MCP_CACHE_TTL", default=300),
15311535
}
1536+
1537+
# Origins permitted to call the MCP endpoints (/mcp*) from a browser. MCP
1538+
# requests are routed to the MCP ASGI app *before* Django, so
1539+
# django-cors-headers never runs on them; CORS is enforced inside the MCP app
1540+
# (opencontractserver/mcp/server.py), which also merges in CORS_ALLOWED_ORIGINS
1541+
# at request time. Hosted Claude/ChatGPT connectors call server-side and don't
1542+
# need this, but browser clients and the MCP Inspector do.
1543+
# The hosted connectors are always safe to default-allow. The MCP Inspector
1544+
# loopback origins (npx @modelcontextprotocol/inspector, port 6274) are only
1545+
# folded into the default under DEBUG so they never ship in a production
1546+
# default — any other process binding :6274 on a deployed host would otherwise
1547+
# inherit credentialed cross-origin access. Operators who need them in a
1548+
# non-DEBUG environment can still set MCP_CORS_ALLOWED_ORIGINS explicitly.
1549+
_MCP_CORS_DEFAULT_ORIGINS = [
1550+
"https://claude.ai",
1551+
"https://chatgpt.com",
1552+
"https://chat.openai.com",
1553+
]
1554+
if DEBUG:
1555+
_MCP_CORS_DEFAULT_ORIGINS += [
1556+
"http://localhost:6274",
1557+
"http://127.0.0.1:6274",
1558+
]
1559+
MCP_CORS_ALLOWED_ORIGINS = env.list(
1560+
"MCP_CORS_ALLOWED_ORIGINS",
1561+
default=_MCP_CORS_DEFAULT_ORIGINS,
1562+
)
1563+
1564+
# Optional trusted public base URL (scheme://host) for absolute URLs the MCP
1565+
# server emits — notably the RFC 9728 ``resource_metadata`` pointer in 401
1566+
# challenges. When empty the value is derived from the (sanitized) request
1567+
# Host; because MCP bypasses ALLOWED_HOSTS, pinning this in production is
1568+
# recommended (e.g. MCP_PUBLIC_BASE_URL=https://contracts.opensource.legal).
1569+
MCP_PUBLIC_BASE_URL = env.str("MCP_PUBLIC_BASE_URL", default="")

docs/mcp/README.md

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,20 @@
55
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.
66

77
**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/`
910
- **Corpus-Scoped** (single corpus): `POST /mcp/corpus/{corpus_slug}/` or `GET /mcp/corpus/{corpus_slug}/`
1011
- **SSE** (deprecated): `GET /sse/`, `POST /sse/messages/`
1112

12-
**Scope**: Public resources only (anonymous user visibility)
13-
**Auth**: None required (public data only)
13+
**Scope**: `/mcp/` and `/mcp/corpus/...` expose public resources to anonymous
14+
callers; `/mcp/me/` requires sign-in and additionally exposes private resources
15+
the authenticated user owns or is shared on. A valid `Authorization: Bearer
16+
<JWT>` is honored on *any* endpoint.
17+
18+
**Auth**: Optional on `/mcp/` (anonymous = public only). Required on `/mcp/me/`,
19+
which returns `401 + WWW-Authenticate` to unauthenticated callers so interactive
20+
clients (Claude web/desktop, ChatGPT) start the OAuth 2.1 sign-in flow. See
21+
[Authentication](#authentication) below.
1422

1523
### Claude Desktop Quick Start
1624

@@ -250,18 +258,63 @@ python -m opencontractserver.mcp.server
250258

251259
---
252260

261+
## Authentication
262+
263+
The server accepts an OAuth 2.1 / JWT **Bearer** token on the standard
264+
`Authorization` header and validates it through the same pipeline as the rest of
265+
the app (`config/jwt_utils.py` → Auth0 RS256/JWKS when `USE_AUTH0=True`,
266+
otherwise the local `graphql_jwt` HS256 token).
267+
268+
- **`/mcp/` (and `/mcp/corpus/...`)** — auth is *optional*. No token ⇒ anonymous
269+
(public resources only). A valid token ⇒ that user's private resources are
270+
also visible.
271+
- **`/mcp/me/`** — auth is *required*. An unauthenticated request gets `401`
272+
with a `WWW-Authenticate: Bearer resource_metadata="…"` header (RFC 6750 /
273+
RFC 9728). Interactive MCP clients follow that pointer to
274+
`/.well-known/oauth-protected-resource[/mcp/me]`, discover the authorization
275+
server (Auth0), and run Authorization-Code + PKCE — no preconfigured token
276+
needed. **Register `/mcp/me/` as the server URL in Claude web/desktop or
277+
ChatGPT to get the "Connect / Sign in" prompt.**
278+
279+
### Discovery endpoints
280+
281+
| URL | Purpose |
282+
|-----|---------|
283+
| `/.well-known/mcp.json` | Lists the MCP servers (incl. `cite-authenticated` when Auth0 is on) |
284+
| `/.well-known/oauth-protected-resource` | RFC 9728 metadata for the canonical `/mcp` resource |
285+
| `/.well-known/oauth-protected-resource/mcp/me` | RFC 9728 path-based metadata for the authed resource |
286+
287+
### Auth0 configuration notes
288+
289+
For the interactive flow to complete end-to-end, the access token Auth0 issues
290+
must validate here:
291+
292+
- The Auth0 **API Identifier (audience)** must equal `AUTH0_API_AUDIENCE` — the
293+
server validates `aud` on every token. Map the advertised resource to that API
294+
so the RFC 8707 `resource`/`audience` the client sends yields a JWT (not an
295+
opaque token).
296+
- Enable **Dynamic Client Registration** on the tenant — Claude/ChatGPT register
297+
themselves on the fly (RFC 7591).
298+
- Set `MCP_PUBLIC_BASE_URL` (e.g. `https://contracts.opensource.legal`) so the
299+
challenge advertises a trusted absolute URL rather than one derived from the
300+
request `Host` (MCP bypasses `ALLOWED_HOSTS`).
301+
- Browser clients / the MCP Inspector additionally need the calling origin in
302+
`MCP_CORS_ALLOWED_ORIGINS` (defaults to Claude, ChatGPT, and the Inspector).
303+
253304
## Security Model
254305

255-
- **Read-only**: No mutations, no writes
256-
- **Public only**: Uses `AnonymousUser` for all permission checks
306+
- **Read-mostly**: the only write tool (`create_thread_message`) enforces
307+
authentication and per-resource permissions inside the tool body
308+
- **Permission-filtered**: anonymous callers resolve through `AnonymousUser`;
309+
authenticated callers see only resources they own or are shared on
257310
- **Slug-based**: All identifiers are URL-safe slugs (no internal IDs exposed)
258-
- **No auth required**: Only public resources are accessible
311+
- **Bearer auth**: optional on `/mcp/`, required on `/mcp/me/` (see above)
259312

260313
---
261314

262315
## Limitations
263316

264-
- No authentication (future: JWT/API key support for private resources)
265-
- No write operations (by design)
266317
- No streaming of large documents (text returned in full)
267318
- Semantic search requires corpus to have embeddings configured
319+
- Interactive OAuth sign-in requires `USE_AUTH0=True`; without it, `/mcp/me/`
320+
still accepts a bearer token but cannot advertise an interactive login

opencontractserver/discovery/tests/test_discovery_views.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,28 @@ def test_authentication_advertised_when_auth0_enabled(self):
384384
scoped_server["authentication"], global_server["authentication"]
385385
)
386386

387+
def test_authenticated_server_advertised_under_auth0(self):
388+
"""When Auth0 is enabled, mcp.json advertises the authenticated
389+
/mcp/me entrypoint pointing at its path-based protected-resource
390+
metadata so clients can start the sign-in flow."""
391+
with override_settings(USE_AUTH0=True, AUTH0_DOMAIN="example.auth0.com"):
392+
response = self.client.get("/.well-known/mcp.json")
393+
data = json.loads(response.content)
394+
self.assertIn("cite-authenticated", data["mcpServers"])
395+
authed = data["mcpServers"]["cite-authenticated"]
396+
self.assertIn("/mcp/me/", authed["url"])
397+
self.assertEqual(
398+
authed["authentication"]["metadataUrl"],
399+
"http://testserver/.well-known/oauth-protected-resource/mcp/me",
400+
)
401+
402+
def test_authenticated_server_absent_without_auth0(self):
403+
"""Without an interactive AS, the authed entrypoint is not advertised
404+
(operators hand out bearer tokens out of band)."""
405+
response = self.client.get("/.well-known/mcp.json")
406+
data = json.loads(response.content)
407+
self.assertNotIn("cite-authenticated", data["mcpServers"])
408+
387409

388410
@override_settings(
389411
CACHES={"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}}
@@ -425,6 +447,44 @@ def test_only_get_allowed(self):
425447
response = self.client.post("/.well-known/oauth-protected-resource")
426448
self.assertEqual(response.status_code, 405)
427449

450+
def test_path_based_metadata_for_authed_resource(self):
451+
"""RFC 9728 §3.1 path-based document for /mcp/me returns a matching
452+
``resource`` so clients that derive the URL from the resource id
453+
resolve it."""
454+
with override_settings(USE_AUTH0=True, AUTH0_DOMAIN="example.auth0.com"):
455+
response = self.client.get("/.well-known/oauth-protected-resource/mcp/me")
456+
self.assertEqual(response.status_code, 200)
457+
data = json.loads(response.content)
458+
self.assertEqual(data["resource"], "http://testserver/mcp/me")
459+
self.assertEqual(data["authorization_servers"], ["https://example.auth0.com/"])
460+
461+
def test_path_based_metadata_returns_404_without_auth0(self):
462+
"""The path-based variant must 404 when Auth0 is disabled, exactly like
463+
the root document — the ``auth0_domain`` check fires before the
464+
``resource_path`` lookup, so a known resource path is no excuse to leak
465+
metadata when there is no authorization server to point at."""
466+
with override_settings(USE_AUTH0=False):
467+
response = self.client.get("/.well-known/oauth-protected-resource/mcp/me")
468+
self.assertEqual(response.status_code, 404)
469+
470+
def test_unknown_resource_path_returns_404(self):
471+
"""Only known MCP resources get metadata; arbitrary paths 404 rather
472+
than advertise an AS for something we don't serve."""
473+
with override_settings(USE_AUTH0=True, AUTH0_DOMAIN="example.auth0.com"):
474+
response = self.client.get("/.well-known/oauth-protected-resource/bogus")
475+
self.assertEqual(response.status_code, 404)
476+
477+
def test_authed_path_is_advertised_as_protected_resource(self):
478+
"""Pin the discovery↔MCP coupling: the authenticated MCP path is a
479+
hardcoded literal in ``_OAUTH_PROTECTED_RESOURCES`` (to avoid importing
480+
the heavy MCP server module). If ``MCP_AUTHED_PATH`` is ever renamed
481+
without updating that tuple, discovery would silently 404 the new
482+
path's metadata — fail loudly here instead."""
483+
from opencontractserver.discovery.views import _OAUTH_PROTECTED_RESOURCES
484+
from opencontractserver.mcp.server import MCP_AUTHED_PATH
485+
486+
self.assertIn(MCP_AUTHED_PATH, _OAUTH_PROTECTED_RESOURCES)
487+
428488

429489
@override_settings(
430490
CACHES={"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}}

opencontractserver/discovery/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,12 @@
2323
well_known_oauth_protected_resource,
2424
name="well_known_oauth_protected_resource",
2525
),
26+
# RFC 9728 §3.1 path-based metadata, e.g.
27+
# /.well-known/oauth-protected-resource/mcp/me
28+
path(
29+
".well-known/oauth-protected-resource/<path:resource_path>",
30+
well_known_oauth_protected_resource,
31+
name="well_known_oauth_protected_resource_path",
32+
),
2633
path("api/search/", search_api, name="search_api"),
2734
]

0 commit comments

Comments
 (0)