drf-react-by-schema-mcp 0.3.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- drf_react_by_schema_mcp/__init__.py +27 -0
- drf_react_by_schema_mcp/apps.py +30 -0
- drf_react_by_schema_mcp/auth.py +141 -0
- drf_react_by_schema_mcp/client.py +147 -0
- drf_react_by_schema_mcp/metadata.py +90 -0
- drf_react_by_schema_mcp/schema.py +551 -0
- drf_react_by_schema_mcp/server.py +264 -0
- drf_react_by_schema_mcp/settings.py +134 -0
- drf_react_by_schema_mcp/shortcuts.py +44 -0
- drf_react_by_schema_mcp/tests/__init__.py +0 -0
- drf_react_by_schema_mcp/tests/test_auth.py +137 -0
- drf_react_by_schema_mcp/tests/test_client.py +51 -0
- drf_react_by_schema_mcp/tests/test_e2e_oauth.py +183 -0
- drf_react_by_schema_mcp/tests/test_prm.py +58 -0
- drf_react_by_schema_mcp/tests/test_schema.py +368 -0
- drf_react_by_schema_mcp/tests/test_server.py +80 -0
- drf_react_by_schema_mcp/tests/test_settings.py +130 -0
- drf_react_by_schema_mcp/tests/test_tools.py +629 -0
- drf_react_by_schema_mcp/tests/test_validation.py +269 -0
- drf_react_by_schema_mcp/tests/test_write_validation.py +156 -0
- drf_react_by_schema_mcp/tests/testproject/__init__.py +0 -0
- drf_react_by_schema_mcp/tests/testproject/api.py +85 -0
- drf_react_by_schema_mcp/tests/testproject/manage.py +17 -0
- drf_react_by_schema_mcp/tests/testproject/settings.py +104 -0
- drf_react_by_schema_mcp/tests/testproject/shortcuts.py +22 -0
- drf_react_by_schema_mcp/tests/testproject/urls.py +16 -0
- drf_react_by_schema_mcp/tests/testproject/wsgi.py +10 -0
- drf_react_by_schema_mcp/tools.py +845 -0
- drf_react_by_schema_mcp/urls.py +36 -0
- drf_react_by_schema_mcp/validation.py +322 -0
- drf_react_by_schema_mcp-0.3.6.dist-info/METADATA +508 -0
- drf_react_by_schema_mcp-0.3.6.dist-info/RECORD +35 -0
- drf_react_by_schema_mcp-0.3.6.dist-info/WHEEL +5 -0
- drf_react_by_schema_mcp-0.3.6.dist-info/licenses/LICENSE +661 -0
- drf_react_by_schema_mcp-0.3.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
drf-react-by-schema-mcp
|
|
3
|
+
=======================
|
|
4
|
+
|
|
5
|
+
Generic, schema-driven **MCP server** for `drf-react-by-schema
|
|
6
|
+
<https://gitlab.com/eita/drf-react-by-schema>`_ (DRBS) projects.
|
|
7
|
+
|
|
8
|
+
It exposes any DRBS project's DRF API (``/api/...``) to MCP clients such as
|
|
9
|
+
`opencode <https://opencode.ai>`_ through a small set of generic tools —
|
|
10
|
+
``consultar``, ``resumo``, ``criar``, ``editar``, ``minha_conta`` — plus the
|
|
11
|
+
schema-aware ``knowledge`` tool and per-project shortcuts.
|
|
12
|
+
|
|
13
|
+
The server is a **stateless proxy**: it takes the caller's OAuth 2.1 Bearer
|
|
14
|
+
token (issued by ``drf-react-by-schema-oauth``), forwards it to the project's
|
|
15
|
+
``/api/...`` and lets the project's own DRF permissions scope the data. It
|
|
16
|
+
stores **no credentials**.
|
|
17
|
+
|
|
18
|
+
Adding ``drbs_mcp`` to ``INSTALLED_APPS``, including this package's URLs and
|
|
19
|
+
setting ``DRBS_MCP`` is enough to mount the server; reads are enabled by default
|
|
20
|
+
and writes are opt-in.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__version__ = "0.3.6"
|
|
24
|
+
|
|
25
|
+
from .settings import get_setting # noqa: E402
|
|
26
|
+
|
|
27
|
+
__all__ = ["__version__", "get_setting"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DrbsMcpConfig(AppConfig):
|
|
6
|
+
"""AppConfig for the generic DRBS MCP server.
|
|
7
|
+
|
|
8
|
+
``name`` is the import module (``drf_react_by_schema_mcp``); the Django
|
|
9
|
+
**app label** is kept as ``drbs_mcp`` per the frozen interface contract.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
13
|
+
name = "drf_react_by_schema_mcp"
|
|
14
|
+
label = "drbs_mcp"
|
|
15
|
+
|
|
16
|
+
def ready(self):
|
|
17
|
+
# The MCP server authenticates with the OAuth tokens issued by
|
|
18
|
+
# drf-react-by-schema-oauth and consumes its PRM helper. Fail loudly and
|
|
19
|
+
# early if the consumer forgot to install/enable it.
|
|
20
|
+
from django.apps import apps
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
apps.get_app_config("drbs_oauth")
|
|
24
|
+
except LookupError as exc:
|
|
25
|
+
raise ImproperlyConfigured(
|
|
26
|
+
"drf-react-by-schema-mcp requires drf-react-by-schema-oauth. Add "
|
|
27
|
+
"'oauth2_provider' and 'drf-react-by-schema-oauth' to "
|
|
28
|
+
"INSTALLED_APPS before 'drf_react_by_schema_mcp', and make sure "
|
|
29
|
+
"the 'drf-react-by-schema-oauth' package is installed."
|
|
30
|
+
) from exc
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Bearer-token plumbing for the MCP resource server.
|
|
2
|
+
|
|
3
|
+
The server is a **stateless proxy**: it never stores credentials. It:
|
|
4
|
+
|
|
5
|
+
1. reads the caller's ``Authorization: Bearer <token>`` at the transport
|
|
6
|
+
(:class:`BearerAuthMiddleware`) and answers ``401`` with a
|
|
7
|
+
``WWW-Authenticate`` challenge pointing at the Protected Resource Metadata
|
|
8
|
+
(see :mod:`drf_react_by_schema_mcp.metadata`) when it is missing — which is
|
|
9
|
+
what makes ``opencode mcp auth`` start the OAuth flow;
|
|
10
|
+
2. optionally validates the token against the project's API (the project's own
|
|
11
|
+
``OAuth2Authentication`` is the authority), so an expired token also yields
|
|
12
|
+
``401``; and
|
|
13
|
+
3. forwards the token verbatim on every ``/api/...`` call (see
|
|
14
|
+
:mod:`drf_react_by_schema_mcp.tools`), letting the project's DRF permissions
|
|
15
|
+
enforce scopes (``read`` on safe methods, ``write`` on unsafe ones).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
from starlette.datastructures import Headers
|
|
24
|
+
from starlette.responses import JSONResponse
|
|
25
|
+
|
|
26
|
+
from .metadata import www_authenticate_for
|
|
27
|
+
from .settings import get_setting
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Test seam: an ``httpx`` transport so the suite (and in-process e2e) can reach
|
|
32
|
+
# the API without a real socket. Shared with :mod:`drf_react_by_schema_mcp.tools`.
|
|
33
|
+
_async_transport: httpx.AsyncBaseTransport | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set_async_transport(transport: httpx.AsyncBaseTransport | None) -> None:
|
|
37
|
+
"""Override the HTTP transport used to reach the API (tests only)."""
|
|
38
|
+
global _async_transport
|
|
39
|
+
_async_transport = transport
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_async_transport() -> httpx.AsyncBaseTransport | None:
|
|
43
|
+
return _async_transport
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def bearer_token(headers: Headers) -> str | None:
|
|
47
|
+
"""Return the Bearer token from ``headers``, or ``None`` if absent."""
|
|
48
|
+
value = headers.get("authorization", "")
|
|
49
|
+
if not value.lower().startswith("bearer "):
|
|
50
|
+
return None
|
|
51
|
+
return value.split(" ", 1)[1].strip() or None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _api_base_from_scope(scope) -> str:
|
|
55
|
+
configured = get_setting("API_URL")
|
|
56
|
+
if configured:
|
|
57
|
+
return str(configured).rstrip("/")
|
|
58
|
+
headers = Headers(scope=scope)
|
|
59
|
+
scheme = scope.get("scheme", "http")
|
|
60
|
+
host = headers.get("host", "")
|
|
61
|
+
return f"{scheme}://{host}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _token_is_valid(token: str, base_url: str) -> bool:
|
|
65
|
+
"""Ask the API whether ``token`` is acceptable.
|
|
66
|
+
|
|
67
|
+
Only a ``401`` means "invalid" (reject); a missing validation endpoint
|
|
68
|
+
(``404``) or any other status is treated as "cannot tell" (accept), because
|
|
69
|
+
the project's DRF permissions remain authoritative on the actual calls.
|
|
70
|
+
"""
|
|
71
|
+
path = get_setting("VALIDATE_PATH") or "/api/my_account/"
|
|
72
|
+
async with httpx.AsyncClient(
|
|
73
|
+
base_url=base_url,
|
|
74
|
+
transport=get_async_transport(),
|
|
75
|
+
timeout=get_setting("TIMEOUT"),
|
|
76
|
+
follow_redirects=True,
|
|
77
|
+
) as client:
|
|
78
|
+
response = await client.get(path, headers={"Authorization": f"Bearer {token}"})
|
|
79
|
+
return response.status_code != 401
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BearerAuthMiddleware:
|
|
83
|
+
"""ASGI middleware requiring (and optionally validating) a Bearer token."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, app, *, resource_path: str | None = None):
|
|
86
|
+
self.app = app
|
|
87
|
+
self.resource_path = resource_path
|
|
88
|
+
|
|
89
|
+
async def __call__(self, scope, receive, send):
|
|
90
|
+
if scope["type"] != "http":
|
|
91
|
+
await self.app(scope, receive, send)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
headers = Headers(scope=scope)
|
|
95
|
+
token = bearer_token(headers)
|
|
96
|
+
challenge = www_authenticate_for(
|
|
97
|
+
scope.get("scheme", "http"),
|
|
98
|
+
headers.get("host", ""),
|
|
99
|
+
self.resource_path,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if token is None:
|
|
103
|
+
await self._reject(
|
|
104
|
+
scope,
|
|
105
|
+
receive,
|
|
106
|
+
send,
|
|
107
|
+
"invalid_request",
|
|
108
|
+
"Authorization: Bearer token required.",
|
|
109
|
+
challenge,
|
|
110
|
+
)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
if get_setting("VALIDATE_TOKEN"):
|
|
114
|
+
try:
|
|
115
|
+
valid = await _token_is_valid(token, _api_base_from_scope(scope))
|
|
116
|
+
except httpx.HTTPError as exc:
|
|
117
|
+
# Fail open on transport errors: the API still enforces the token
|
|
118
|
+
# on the actual calls.
|
|
119
|
+
logger.warning("Token validation skipped (transport error): %s", exc)
|
|
120
|
+
valid = True
|
|
121
|
+
if not valid:
|
|
122
|
+
await self._reject(
|
|
123
|
+
scope,
|
|
124
|
+
receive,
|
|
125
|
+
send,
|
|
126
|
+
"invalid_token",
|
|
127
|
+
"The access token is invalid or expired.",
|
|
128
|
+
challenge,
|
|
129
|
+
)
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
await self.app(scope, receive, send)
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
async def _reject(scope, receive, send, error, description, challenge):
|
|
136
|
+
response = JSONResponse(
|
|
137
|
+
{"error": error, "error_description": description}, status_code=401
|
|
138
|
+
)
|
|
139
|
+
if challenge:
|
|
140
|
+
response.headers["WWW-Authenticate"] = challenge
|
|
141
|
+
await response(scope, receive, send)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Shared HTTP plumbing for the MCP proxy.
|
|
2
|
+
|
|
3
|
+
The server is a **stateless proxy**: it never stores credentials. Every request
|
|
4
|
+
forwards the caller's ``Authorization: Bearer`` token to the project's API, which
|
|
5
|
+
identifies the user and enforces scopes. These helpers centralize token
|
|
6
|
+
extraction, base-URL resolution, the ``httpx`` call and response decoding so the
|
|
7
|
+
generic tools (:mod:`drf_react_by_schema_mcp.tools`) and the schema fetcher
|
|
8
|
+
(:mod:`drf_react_by_schema_mcp.schema`) share one set of conventions.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from mcp.server.fastmcp import Context
|
|
18
|
+
|
|
19
|
+
from .auth import get_async_transport
|
|
20
|
+
from .settings import get_setting
|
|
21
|
+
|
|
22
|
+
#: Methods that may be retried after a transport error (they are idempotent).
|
|
23
|
+
#: ``POST``/``PATCH`` are never retried: the API might have applied them.
|
|
24
|
+
_RETRY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
25
|
+
|
|
26
|
+
#: Transport failures worth one retry — a dropped connection, a reset mid-body
|
|
27
|
+
#: or a connect that never completed. A ``ReadTimeout`` is deliberately *not*
|
|
28
|
+
#: here: a slow-but-alive API must not be retried (it would only double latency).
|
|
29
|
+
_RETRY_EXCEPTIONS = (
|
|
30
|
+
httpx.ConnectError,
|
|
31
|
+
httpx.ConnectTimeout,
|
|
32
|
+
httpx.ReadError,
|
|
33
|
+
httpx.WriteError,
|
|
34
|
+
httpx.RemoteProtocolError,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
#: Attempts for a retryable request (``2`` = one retry).
|
|
38
|
+
_RETRY_ATTEMPTS = 2
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def request_object(ctx: Context) -> Any:
|
|
42
|
+
"""Return the underlying Starlette request carried by the MCP context."""
|
|
43
|
+
return getattr(ctx.request_context, "request", None)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def token(ctx: Context) -> str:
|
|
47
|
+
"""Extract the caller's Bearer token, or raise a clear error."""
|
|
48
|
+
request = request_object(ctx)
|
|
49
|
+
headers = getattr(request, "headers", None)
|
|
50
|
+
authorization = headers.get("authorization", "") if headers else ""
|
|
51
|
+
if not authorization.lower().startswith("bearer "):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"OAuth token missing. Authenticate the MCP client so it sends "
|
|
54
|
+
"'Authorization: Bearer <token>'."
|
|
55
|
+
)
|
|
56
|
+
return authorization.split(" ", 1)[1].strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def api_base(ctx: Context) -> str:
|
|
60
|
+
"""Base URL of the project's API (``DRBS_MCP["API_URL"]`` or this origin)."""
|
|
61
|
+
configured = get_setting("API_URL")
|
|
62
|
+
if configured:
|
|
63
|
+
return str(configured).rstrip("/")
|
|
64
|
+
request = request_object(ctx)
|
|
65
|
+
url = request.url
|
|
66
|
+
host = request.headers.get("host") or url.hostname
|
|
67
|
+
return f"{url.scheme}://{host}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def request(ctx: Context, method: str, path: str, **kwargs) -> httpx.Response:
|
|
71
|
+
"""Send an authenticated request to the project's API and return the response.
|
|
72
|
+
|
|
73
|
+
An idempotent request (``GET``/``HEAD``/``OPTIONS``) that fails at the
|
|
74
|
+
transport layer (a dropped/reset connection) is retried once, so a transient
|
|
75
|
+
blip does not surface as a tool error; ``POST``/``PATCH`` are sent once.
|
|
76
|
+
"""
|
|
77
|
+
headers = {"Authorization": f"Bearer {token(ctx)}"}
|
|
78
|
+
headers.update(kwargs.pop("headers", {}) or {})
|
|
79
|
+
base = api_base(ctx)
|
|
80
|
+
attempts = _RETRY_ATTEMPTS if method.upper() in _RETRY_METHODS else 1
|
|
81
|
+
async with httpx.AsyncClient(
|
|
82
|
+
base_url=base,
|
|
83
|
+
transport=get_async_transport(),
|
|
84
|
+
timeout=get_setting("TIMEOUT"),
|
|
85
|
+
follow_redirects=True,
|
|
86
|
+
) as client:
|
|
87
|
+
for attempt in range(attempts):
|
|
88
|
+
try:
|
|
89
|
+
return await client.request(method, path, headers=headers, **kwargs)
|
|
90
|
+
except _RETRY_EXCEPTIONS:
|
|
91
|
+
if attempt + 1 >= attempts:
|
|
92
|
+
raise
|
|
93
|
+
await asyncio.sleep(0.2 * (attempt + 1))
|
|
94
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
#: Max characters of an error body kept in the raised message.
|
|
98
|
+
_ERROR_EXCERPT_MAX = 4000
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _flatten_error(payload: Any) -> str:
|
|
102
|
+
"""Readable text from a DRF error payload (str, list or field-keyed dict)."""
|
|
103
|
+
if isinstance(payload, str):
|
|
104
|
+
return payload
|
|
105
|
+
if isinstance(payload, list):
|
|
106
|
+
return "; ".join(
|
|
107
|
+
part for part in (_flatten_error(item) for item in payload) if part
|
|
108
|
+
)
|
|
109
|
+
if isinstance(payload, dict):
|
|
110
|
+
parts = []
|
|
111
|
+
for key, value in payload.items():
|
|
112
|
+
text = _flatten_error(value)
|
|
113
|
+
if key in ("detail", "non_field_errors"):
|
|
114
|
+
parts.append(text)
|
|
115
|
+
else:
|
|
116
|
+
parts.append(f"{key}: {text}")
|
|
117
|
+
return "; ".join(part for part in parts if part)
|
|
118
|
+
return str(payload)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _error_message(response: httpx.Response) -> str:
|
|
122
|
+
"""Full DRF error detail when the body is JSON, else the raw excerpt."""
|
|
123
|
+
try:
|
|
124
|
+
payload = response.json()
|
|
125
|
+
except Exception:
|
|
126
|
+
return response.text[:_ERROR_EXCERPT_MAX]
|
|
127
|
+
return _flatten_error(payload)[:_ERROR_EXCERPT_MAX]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def json(response: httpx.Response, esperado=(200, 201)) -> Any:
|
|
131
|
+
"""Decode a JSON response, raising a readable error for unexpected statuses.
|
|
132
|
+
|
|
133
|
+
DRF errors (``{"detail": ...}``, a field map, or a list of messages) are
|
|
134
|
+
flattened so the whole message — including a model's valid ``sum_rows``
|
|
135
|
+
measures/dimensions — reaches the agent instead of a truncated raw body.
|
|
136
|
+
"""
|
|
137
|
+
if response.status_code not in esperado:
|
|
138
|
+
detail = _error_message(response)
|
|
139
|
+
if response.status_code == 404 and "Página inválida" in detail:
|
|
140
|
+
detail += (
|
|
141
|
+
" (a página pedida não existe — reduza page= ou veja o total "
|
|
142
|
+
"de páginas com metadados=True ou resumo())"
|
|
143
|
+
)
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"{response.status_code} {response.reason_phrase}: {detail}"
|
|
146
|
+
)
|
|
147
|
+
return response.json()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Protected Resource Metadata (RFC 9728) and the ``WWW-Authenticate`` glue.
|
|
2
|
+
|
|
3
|
+
The MCP server is an OAuth 2.1 **resource server**: on an unauthenticated
|
|
4
|
+
request it must answer ``401`` with a ``WWW-Authenticate: Bearer`` challenge
|
|
5
|
+
pointing at its Protected Resource Metadata document, which in turn points at
|
|
6
|
+
the authorization server (:mod:`drf_react_by_schema_oauth`). That is what lets
|
|
7
|
+
``opencode mcp auth`` start the OAuth flow automatically.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from django.http import JsonResponse
|
|
13
|
+
from django.views import View
|
|
14
|
+
from drf_react_by_schema_oauth.metadata import (
|
|
15
|
+
build_protected_resource_metadata,
|
|
16
|
+
get_issuer,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .settings import get_setting
|
|
20
|
+
|
|
21
|
+
WELL_KNOWN_PRM = "/.well-known/oauth-protected-resource"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _resource_path():
|
|
25
|
+
"""Return the path component of the configured ``RESOURCE_URL``."""
|
|
26
|
+
resource = str(get_setting("RESOURCE_URL") or "/mcp")
|
|
27
|
+
if resource.startswith(("http://", "https://")):
|
|
28
|
+
return urlparse(resource).path or "/"
|
|
29
|
+
return resource if resource.startswith("/") else f"/{resource}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resource_identifier(request):
|
|
33
|
+
"""Absolute identifier of the protected resource (e.g. ``https://h/mcp``)."""
|
|
34
|
+
resource = str(get_setting("RESOURCE_URL") or "/mcp")
|
|
35
|
+
if resource.startswith(("http://", "https://")):
|
|
36
|
+
return resource
|
|
37
|
+
return request.build_absolute_uri(resource if resource.startswith("/") else f"/{resource}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def protected_resource_metadata_url(request):
|
|
41
|
+
"""Absolute URL of this resource's RFC 9728 metadata document.
|
|
42
|
+
|
|
43
|
+
RFC 9728 inserts the resource's path *after* the well-known prefix, so the
|
|
44
|
+
resource ``/mcp`` is described at
|
|
45
|
+
``/.well-known/oauth-protected-resource/mcp``.
|
|
46
|
+
"""
|
|
47
|
+
path = _resource_path()
|
|
48
|
+
suffix = "" if path in ("", "/") else path
|
|
49
|
+
return request.build_absolute_uri(WELL_KNOWN_PRM + suffix)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_prm(request):
|
|
53
|
+
"""Build the RFC 9728 Protected Resource Metadata document."""
|
|
54
|
+
return build_protected_resource_metadata(
|
|
55
|
+
resource=resource_identifier(request),
|
|
56
|
+
authorization_servers=[get_issuer(request)],
|
|
57
|
+
scopes_supported=[
|
|
58
|
+
get_setting("READ_SCOPE"),
|
|
59
|
+
get_setting("WRITE_SCOPE"),
|
|
60
|
+
],
|
|
61
|
+
bearer_methods_supported=["header"],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def prm_url_for(scheme, host, resource_path=None):
|
|
66
|
+
"""Absolute PRM URL built from raw scheme/host (usable outside Django)."""
|
|
67
|
+
path = resource_path if resource_path is not None else _resource_path()
|
|
68
|
+
suffix = "" if path in ("", "/") else path
|
|
69
|
+
return f"{scheme}://{host}{WELL_KNOWN_PRM}{suffix}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def www_authenticate_for(scheme, host, resource_path=None):
|
|
73
|
+
"""``WWW-Authenticate`` value built from raw scheme/host."""
|
|
74
|
+
return f'Bearer resource_metadata="{prm_url_for(scheme, host, resource_path)}"'
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def www_authenticate_header(request):
|
|
78
|
+
"""Return the ``WWW-Authenticate`` value for a ``401`` on this resource."""
|
|
79
|
+
return www_authenticate_for(request.scheme, request.get_host())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ProtectedResourceMetadataView(View):
|
|
83
|
+
"""Serve the RFC 9728 Protected Resource Metadata document."""
|
|
84
|
+
|
|
85
|
+
http_method_names = ["get", "head"]
|
|
86
|
+
|
|
87
|
+
def get(self, request, *args, **kwargs):
|
|
88
|
+
response = JsonResponse(build_prm(request), json_dumps_params={"indent": 2})
|
|
89
|
+
response["Cache-Control"] = "no-store"
|
|
90
|
+
return response
|