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.
Files changed (35) hide show
  1. drf_react_by_schema_mcp/__init__.py +27 -0
  2. drf_react_by_schema_mcp/apps.py +30 -0
  3. drf_react_by_schema_mcp/auth.py +141 -0
  4. drf_react_by_schema_mcp/client.py +147 -0
  5. drf_react_by_schema_mcp/metadata.py +90 -0
  6. drf_react_by_schema_mcp/schema.py +551 -0
  7. drf_react_by_schema_mcp/server.py +264 -0
  8. drf_react_by_schema_mcp/settings.py +134 -0
  9. drf_react_by_schema_mcp/shortcuts.py +44 -0
  10. drf_react_by_schema_mcp/tests/__init__.py +0 -0
  11. drf_react_by_schema_mcp/tests/test_auth.py +137 -0
  12. drf_react_by_schema_mcp/tests/test_client.py +51 -0
  13. drf_react_by_schema_mcp/tests/test_e2e_oauth.py +183 -0
  14. drf_react_by_schema_mcp/tests/test_prm.py +58 -0
  15. drf_react_by_schema_mcp/tests/test_schema.py +368 -0
  16. drf_react_by_schema_mcp/tests/test_server.py +80 -0
  17. drf_react_by_schema_mcp/tests/test_settings.py +130 -0
  18. drf_react_by_schema_mcp/tests/test_tools.py +629 -0
  19. drf_react_by_schema_mcp/tests/test_validation.py +269 -0
  20. drf_react_by_schema_mcp/tests/test_write_validation.py +156 -0
  21. drf_react_by_schema_mcp/tests/testproject/__init__.py +0 -0
  22. drf_react_by_schema_mcp/tests/testproject/api.py +85 -0
  23. drf_react_by_schema_mcp/tests/testproject/manage.py +17 -0
  24. drf_react_by_schema_mcp/tests/testproject/settings.py +104 -0
  25. drf_react_by_schema_mcp/tests/testproject/shortcuts.py +22 -0
  26. drf_react_by_schema_mcp/tests/testproject/urls.py +16 -0
  27. drf_react_by_schema_mcp/tests/testproject/wsgi.py +10 -0
  28. drf_react_by_schema_mcp/tools.py +845 -0
  29. drf_react_by_schema_mcp/urls.py +36 -0
  30. drf_react_by_schema_mcp/validation.py +322 -0
  31. drf_react_by_schema_mcp-0.3.6.dist-info/METADATA +508 -0
  32. drf_react_by_schema_mcp-0.3.6.dist-info/RECORD +35 -0
  33. drf_react_by_schema_mcp-0.3.6.dist-info/WHEEL +5 -0
  34. drf_react_by_schema_mcp-0.3.6.dist-info/licenses/LICENSE +661 -0
  35. 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