model-context-api 0.1.0__tar.gz
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.
- model_context_api-0.1.0/PKG-INFO +30 -0
- model_context_api-0.1.0/README.md +17 -0
- model_context_api-0.1.0/mca/__init__.py +7 -0
- model_context_api-0.1.0/mca/base.py +346 -0
- model_context_api-0.1.0/mca/mcp.py +270 -0
- model_context_api-0.1.0/mca/models.py +61 -0
- model_context_api-0.1.0/mca/ninja.py +341 -0
- model_context_api-0.1.0/mca/pydantic.py +260 -0
- model_context_api-0.1.0/pyproject.toml +32 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: model-context-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Model Context API for AI Agents
|
|
5
|
+
Requires-Dist: pydantic>=2.13.5
|
|
6
|
+
Requires-Dist: mcp>=2.2,<3 ; extra == 'mcp'
|
|
7
|
+
Requires-Dist: django>=6.1.1 ; extra == 'ninja'
|
|
8
|
+
Requires-Dist: django-ninja>=1.7.0 ; extra == 'ninja'
|
|
9
|
+
Requires-Python: >=3.13
|
|
10
|
+
Provides-Extra: mcp
|
|
11
|
+
Provides-Extra: ninja
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# MCA
|
|
15
|
+
|
|
16
|
+
Reusable Model Context API routers for Django Ninja and Pydantic applications.
|
|
17
|
+
|
|
18
|
+
Install optional integrations only when needed:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install mca
|
|
22
|
+
pip install "mca[ninja]"
|
|
23
|
+
pip install "mca[mcp]"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Import adapter classes from `mca.pydantic`, `mca.ninja`, and `mca.mcp`; import
|
|
27
|
+
discovery models from `mca.models`. Routers receive a filesystem path to their
|
|
28
|
+
Markdown guides through `guides_dir` and optionally accept `title` and `version`
|
|
29
|
+
for discovery metadata. Route decorators can also receive `guides=[...]` to list
|
|
30
|
+
relevant guide names in operation discovery.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# MCA
|
|
2
|
+
|
|
3
|
+
Reusable Model Context API routers for Django Ninja and Pydantic applications.
|
|
4
|
+
|
|
5
|
+
Install optional integrations only when needed:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install mca
|
|
9
|
+
pip install "mca[ninja]"
|
|
10
|
+
pip install "mca[mcp]"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Import adapter classes from `mca.pydantic`, `mca.ninja`, and `mca.mcp`; import
|
|
14
|
+
discovery models from `mca.models`. Routers receive a filesystem path to their
|
|
15
|
+
Markdown guides through `guides_dir` and optionally accept `title` and `version`
|
|
16
|
+
for discovery metadata. Route decorators can also receive `guides=[...]` to list
|
|
17
|
+
relevant guide names in operation discovery.
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Framework-neutral MCA route registration and discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
7
|
+
from dataclasses import dataclass, field, replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, TypeVar
|
|
10
|
+
from urllib.parse import unquote
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
14
|
+
|
|
15
|
+
METHOD_PREFIXES = {
|
|
16
|
+
"get_": "GET",
|
|
17
|
+
"make_": "POST",
|
|
18
|
+
"set_": "PUT",
|
|
19
|
+
"update_": "PATCH",
|
|
20
|
+
"remove_": "DELETE",
|
|
21
|
+
}
|
|
22
|
+
_DISCOVERY_OPTIONS = {"guides", "include_in_discovery"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MCAError(Exception):
|
|
26
|
+
def __init__(self, code: str, detail: str, field: str | None = None, status: int = 400):
|
|
27
|
+
super().__init__(detail)
|
|
28
|
+
self.code = code
|
|
29
|
+
self.detail = detail
|
|
30
|
+
self.field = field
|
|
31
|
+
self.status = status
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class GuideCatalog:
|
|
35
|
+
def __init__(self, guides_dir: str | Path):
|
|
36
|
+
self.root = Path(guides_dir)
|
|
37
|
+
|
|
38
|
+
def available(self) -> list[str]:
|
|
39
|
+
return sorted(path.name for path in self.root.glob("*.md") if path.is_file()) if self.root.is_dir() else []
|
|
40
|
+
|
|
41
|
+
def read(self, names: str) -> dict[str, str]:
|
|
42
|
+
requested, available = [item.strip() for item in names.split(",")], set(self.available())
|
|
43
|
+
missing = [item for item in requested if not item or item not in available]
|
|
44
|
+
if missing:
|
|
45
|
+
raise MCAError(
|
|
46
|
+
"unknown_guides",
|
|
47
|
+
f"Unavailable guide(s): {', '.join(missing)}.",
|
|
48
|
+
"guide",
|
|
49
|
+
404,
|
|
50
|
+
)
|
|
51
|
+
return {name: self.root.joinpath(name).read_text(encoding="utf-8") for name in requested}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class RegisteredRoute:
|
|
56
|
+
method: str
|
|
57
|
+
path: str
|
|
58
|
+
operation: str
|
|
59
|
+
endpoint: Callable[..., Any]
|
|
60
|
+
description: str = ""
|
|
61
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def relative_route(self) -> str:
|
|
65
|
+
return f"{self.method} {self.path}"
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def discovery_route(self) -> str:
|
|
69
|
+
return f"{self.method} {self.path.lstrip('/') or '.'}"
|
|
70
|
+
|
|
71
|
+
def meta(self, name: str, default: Any = None) -> Any:
|
|
72
|
+
return self.metadata.get(name, default)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BaseMCARouter:
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
guides_dir: str | Path,
|
|
80
|
+
mca_path: str = "/",
|
|
81
|
+
title: str = "Model Context API",
|
|
82
|
+
version: float = 1.0,
|
|
83
|
+
):
|
|
84
|
+
self.guide_catalog = GuideCatalog(guides_dir)
|
|
85
|
+
self.mca_path = mca_path
|
|
86
|
+
self.title = title
|
|
87
|
+
self.version = version
|
|
88
|
+
self._routes: dict[str, RegisteredRoute] = {}
|
|
89
|
+
for path, operation_id, endpoint, options in self._discovery_endpoints():
|
|
90
|
+
self._register_endpoint(path, operation_id, endpoint, options)
|
|
91
|
+
|
|
92
|
+
def _discovery_endpoints(self) -> Iterable[tuple[str, str, F, Mapping[str, Any]]]:
|
|
93
|
+
raise NotImplementedError
|
|
94
|
+
|
|
95
|
+
def _register_transport_route(
|
|
96
|
+
self,
|
|
97
|
+
route: RegisteredRoute,
|
|
98
|
+
endpoint: F,
|
|
99
|
+
options: Mapping[str, Any],
|
|
100
|
+
*,
|
|
101
|
+
path: str | None = None,
|
|
102
|
+
operation_id: str | None = None,
|
|
103
|
+
include_in_schema: bool | None = None,
|
|
104
|
+
) -> F:
|
|
105
|
+
raise NotImplementedError
|
|
106
|
+
|
|
107
|
+
def _route_metadata(self, endpoint: F, options: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
108
|
+
return {}
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _transport_options(options: Mapping[str, Any]) -> dict[str, Any]:
|
|
112
|
+
return {key: value for key, value in options.items() if key not in _DISCOVERY_OPTIONS}
|
|
113
|
+
|
|
114
|
+
def _route(
|
|
115
|
+
self,
|
|
116
|
+
path: str,
|
|
117
|
+
method: str,
|
|
118
|
+
operation: str,
|
|
119
|
+
endpoint: F,
|
|
120
|
+
options: Mapping[str, Any],
|
|
121
|
+
) -> F:
|
|
122
|
+
metadata = dict(self._route_metadata(endpoint, options))
|
|
123
|
+
if options.get("guides") is not None:
|
|
124
|
+
metadata["guides"] = options["guides"]
|
|
125
|
+
if options.get("include_in_discovery") is False:
|
|
126
|
+
metadata["include_in_discovery"] = False
|
|
127
|
+
route = RegisteredRoute(method, path, operation, endpoint,
|
|
128
|
+
options.get("description") or inspect.getdoc(endpoint) or operation.replace("_", " ").capitalize(),
|
|
129
|
+
metadata)
|
|
130
|
+
transport_options = self._transport_options(options)
|
|
131
|
+
registered_endpoint = self._register_transport_route(route, endpoint, transport_options)
|
|
132
|
+
self._register_route_variant(route, registered_endpoint, transport_options)
|
|
133
|
+
self._routes[operation] = replace(route, endpoint=registered_endpoint)
|
|
134
|
+
return registered_endpoint
|
|
135
|
+
|
|
136
|
+
def _register_endpoint(self, path: str, operation: str, endpoint: F, options: Mapping[str, Any]) -> F:
|
|
137
|
+
method = self._method_for_endpoint(endpoint)
|
|
138
|
+
self._validate_route_registration(path, method, operation)
|
|
139
|
+
return self._route(path, method, operation, endpoint, options)
|
|
140
|
+
|
|
141
|
+
def _register_route_variant(self, route: RegisteredRoute, endpoint: F, options: Mapping[str, Any]) -> None:
|
|
142
|
+
canonical_path = route.path.rstrip("/") or "/"
|
|
143
|
+
if canonical_path == "/":
|
|
144
|
+
return
|
|
145
|
+
variant_path = canonical_path[:-1] if route.path.endswith("/") else f"{canonical_path}/"
|
|
146
|
+
variant_options = dict(options)
|
|
147
|
+
variant_options["include_in_schema"] = False
|
|
148
|
+
self._register_transport_route(
|
|
149
|
+
route,
|
|
150
|
+
endpoint,
|
|
151
|
+
variant_options,
|
|
152
|
+
path=variant_path,
|
|
153
|
+
operation_id=f"{route.operation}__slash_variant",
|
|
154
|
+
include_in_schema=False,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _method_for_endpoint(endpoint: Callable[..., Any]) -> str:
|
|
159
|
+
method = next(
|
|
160
|
+
(method for prefix, method in METHOD_PREFIXES.items() if endpoint.__name__.startswith(prefix)),
|
|
161
|
+
None,
|
|
162
|
+
)
|
|
163
|
+
if method is None:
|
|
164
|
+
prefixes = ", ".join(f"{prefix[:-1]}_" for prefix in METHOD_PREFIXES.values())
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"MCA endpoint {endpoint.__name__!r} must start with one of: {prefixes}."
|
|
167
|
+
)
|
|
168
|
+
return method
|
|
169
|
+
|
|
170
|
+
def register(self, path: str, *, operation_id: str | None = None, **options: Any) -> Callable[[F], F]:
|
|
171
|
+
def decorator(endpoint: F) -> F:
|
|
172
|
+
return self._register_endpoint(path, operation_id or endpoint.__name__, endpoint, options)
|
|
173
|
+
return decorator
|
|
174
|
+
|
|
175
|
+
def register_all(
|
|
176
|
+
self,
|
|
177
|
+
path: str,
|
|
178
|
+
*,
|
|
179
|
+
operation_id: str | None = None,
|
|
180
|
+
methods: Iterable[str] | None = None,
|
|
181
|
+
**options: Any,
|
|
182
|
+
) -> Callable[[F], F]:
|
|
183
|
+
def decorator(endpoint: F) -> F:
|
|
184
|
+
base_operation = operation_id or endpoint.__name__
|
|
185
|
+
selected_methods = tuple(
|
|
186
|
+
method.upper()
|
|
187
|
+
for method in (methods if methods is not None else METHOD_PREFIXES.values())
|
|
188
|
+
)
|
|
189
|
+
method_prefixes = {method: prefix for prefix, method in METHOD_PREFIXES.items()}
|
|
190
|
+
unknown_methods = [method for method in selected_methods if method not in method_prefixes]
|
|
191
|
+
if unknown_methods:
|
|
192
|
+
supported_methods = ", ".join(method_prefixes)
|
|
193
|
+
raise ValueError(f"MCA methods must be selected from: {supported_methods}.")
|
|
194
|
+
if len(set(selected_methods)) != len(selected_methods):
|
|
195
|
+
raise ValueError("MCA methods cannot contain duplicates.")
|
|
196
|
+
if not selected_methods:
|
|
197
|
+
raise ValueError("MCA register_all requires at least one method.")
|
|
198
|
+
|
|
199
|
+
registrations = [
|
|
200
|
+
(method, f"{method_prefixes[method]}{base_operation}")
|
|
201
|
+
for method in selected_methods
|
|
202
|
+
]
|
|
203
|
+
for method, operation in registrations:
|
|
204
|
+
self._validate_route_registration(path, method, operation)
|
|
205
|
+
|
|
206
|
+
for method, operation in registrations:
|
|
207
|
+
self._route(path, method, operation, endpoint, options)
|
|
208
|
+
return endpoint
|
|
209
|
+
|
|
210
|
+
return decorator
|
|
211
|
+
|
|
212
|
+
def _validate_route_registration(self, path: str, method: str, operation: str) -> None:
|
|
213
|
+
if operation in self._routes:
|
|
214
|
+
raise ValueError(f"MCA operation {operation!r} is already registered.")
|
|
215
|
+
if any(route.method == method and route.path == path for route in self._routes.values()):
|
|
216
|
+
raise ValueError(f"MCA route {method} {path!r} is already registered.")
|
|
217
|
+
|
|
218
|
+
def routes(self) -> tuple[RegisteredRoute, ...]: return tuple(self._routes.values())
|
|
219
|
+
|
|
220
|
+
def route(self, operation: str) -> RegisteredRoute:
|
|
221
|
+
route = self._routes.get(operation)
|
|
222
|
+
if route is None:
|
|
223
|
+
raise MCAError(
|
|
224
|
+
"unknown_endpoint",
|
|
225
|
+
f"No endpoint is registered for operation '{operation}'.",
|
|
226
|
+
"endpoint",
|
|
227
|
+
404,
|
|
228
|
+
)
|
|
229
|
+
return route
|
|
230
|
+
|
|
231
|
+
def public_routes(self) -> tuple[tuple[str, str], ...]:
|
|
232
|
+
return tuple((route.operation, route.relative_route) for route in self._routes.values())
|
|
233
|
+
|
|
234
|
+
def resolve(self, method: str, route_path: str) -> tuple[str, dict[str, str]] | None:
|
|
235
|
+
"""Resolve an HTTP method and route path to an operation."""
|
|
236
|
+
parts = lambda value: (value.rstrip("/") or "/").strip("/").split("/") if value.strip("/") else []
|
|
237
|
+
path_parts = parts(route_path)
|
|
238
|
+
routes = sorted(self._routes.values(), key=lambda route: route.path.count("{"))
|
|
239
|
+
|
|
240
|
+
for route in routes:
|
|
241
|
+
if route.method != method.upper():
|
|
242
|
+
continue
|
|
243
|
+
route_parts = parts(route.path)
|
|
244
|
+
if len(route_parts) != len(path_parts):
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
path_params: dict[str, str] = {}
|
|
248
|
+
for route_part, path_part in zip(route_parts, path_parts):
|
|
249
|
+
if route_part.startswith("{") and route_part.endswith("}"):
|
|
250
|
+
name = route_part[1:-1].split(":")[-1]
|
|
251
|
+
path_params[name] = unquote(path_part)
|
|
252
|
+
elif route_part != path_part:
|
|
253
|
+
break
|
|
254
|
+
else:
|
|
255
|
+
return route.operation, path_params
|
|
256
|
+
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
def dispatch(
|
|
260
|
+
self,
|
|
261
|
+
operation: str | None = None,
|
|
262
|
+
params: dict[str, Any] | None = None,
|
|
263
|
+
data: Any = None,
|
|
264
|
+
*,
|
|
265
|
+
method: str = "GET",
|
|
266
|
+
) -> Any:
|
|
267
|
+
if operation is None:
|
|
268
|
+
return self._error("invalid_request", "An operation or route path is required.", "operation")
|
|
269
|
+
|
|
270
|
+
if operation.startswith("/"):
|
|
271
|
+
route_path = operation
|
|
272
|
+
resolved = self.resolve(method, route_path)
|
|
273
|
+
if resolved is None:
|
|
274
|
+
return self._error("unknown_route", f"No route matches {method.upper()} {route_path}.", "path", 404)
|
|
275
|
+
operation, path_params = resolved
|
|
276
|
+
params = {**(params or {}), **path_params}
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
route = self.route(operation)
|
|
280
|
+
return self._dispatch_registered(route, params, data)
|
|
281
|
+
except MCAError as exc:
|
|
282
|
+
return self._dispatch_error(exc)
|
|
283
|
+
|
|
284
|
+
def _dispatch_registered(
|
|
285
|
+
self,
|
|
286
|
+
route: RegisteredRoute,
|
|
287
|
+
params: dict[str, Any] | None,
|
|
288
|
+
data: Any,
|
|
289
|
+
) -> Any:
|
|
290
|
+
raise NotImplementedError
|
|
291
|
+
|
|
292
|
+
def _dispatch_error(self, error: MCAError) -> Any:
|
|
293
|
+
raise error
|
|
294
|
+
|
|
295
|
+
def _error(self, code: str, detail: str, field: str | None = None, status: int = 400) -> Any:
|
|
296
|
+
return self._dispatch_error(MCAError(code, detail, field, status))
|
|
297
|
+
|
|
298
|
+
def discovery(
|
|
299
|
+
self,
|
|
300
|
+
guide: str | None,
|
|
301
|
+
operation_name: str | None,
|
|
302
|
+
schema_factory: Callable[[RegisteredRoute], Any],
|
|
303
|
+
) -> dict[str, Any]:
|
|
304
|
+
if guide is None and operation_name is None:
|
|
305
|
+
index = self.guide_catalog.read("index.md")["index.md"]
|
|
306
|
+
return {
|
|
307
|
+
"title": self.title,
|
|
308
|
+
"version": self.version,
|
|
309
|
+
"index": index,
|
|
310
|
+
"help": (
|
|
311
|
+
"Use GET /?guide={names} and/or GET /?operation={names} with "
|
|
312
|
+
"comma-separated names to read available guides and operation schemas."
|
|
313
|
+
),
|
|
314
|
+
"available_guides": self.guide_catalog.available(),
|
|
315
|
+
"available_operations": dict(
|
|
316
|
+
sorted(
|
|
317
|
+
(
|
|
318
|
+
route.operation,
|
|
319
|
+
f"{route.discovery_route} - {route.description}",
|
|
320
|
+
)
|
|
321
|
+
for route in self._routes.values()
|
|
322
|
+
if route.operation != "get_mca"
|
|
323
|
+
and route.meta("include_in_discovery", True)
|
|
324
|
+
)
|
|
325
|
+
),
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
result: dict[str, Any] = {}
|
|
329
|
+
if guide is not None:
|
|
330
|
+
result["guides"] = self.guide_catalog.read(guide)
|
|
331
|
+
if operation_name is not None:
|
|
332
|
+
names = [item.strip() for item in operation_name.split(",")]
|
|
333
|
+
route_map = dict(self._routes)
|
|
334
|
+
missing = [name for name in names if not name or name not in route_map]
|
|
335
|
+
if missing:
|
|
336
|
+
raise MCAError(
|
|
337
|
+
"unknown_operation",
|
|
338
|
+
f"Unavailable operation(s): {', '.join(missing)}.",
|
|
339
|
+
"operation",
|
|
340
|
+
404,
|
|
341
|
+
)
|
|
342
|
+
result["operations"] = {
|
|
343
|
+
name: schema_factory(route_map[name])
|
|
344
|
+
for name in names
|
|
345
|
+
}
|
|
346
|
+
return result
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""MCP hosting for applications that publish MCA Ninja routers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import parse_qs, urlsplit
|
|
10
|
+
|
|
11
|
+
from mcp.server import MCPServer
|
|
12
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
13
|
+
|
|
14
|
+
from .base import MCAError
|
|
15
|
+
from .ninja import NinjaMCARouter
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
BODY_METHODS = {"POST", "PUT", "PATCH"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class MCPRoute:
|
|
23
|
+
app_label: str
|
|
24
|
+
path: str
|
|
25
|
+
server: MCPServer
|
|
26
|
+
application: Any
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MCPHost:
|
|
30
|
+
def __init__(self):
|
|
31
|
+
self._django_application: Any | None = None
|
|
32
|
+
self._routes: tuple[MCPRoute, ...] = ()
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _json_response(response: Any) -> Any:
|
|
36
|
+
if response.status_code == 204 or not response.content:
|
|
37
|
+
return None
|
|
38
|
+
return json.loads(response.content)
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _tool_error(response: Any) -> ToolError:
|
|
42
|
+
try:
|
|
43
|
+
payload = json.loads(response.content)
|
|
44
|
+
except (TypeError, ValueError, UnicodeDecodeError):
|
|
45
|
+
payload = {
|
|
46
|
+
"code": "mcp_endpoint_error",
|
|
47
|
+
"detail": response.reason_phrase,
|
|
48
|
+
}
|
|
49
|
+
if isinstance(payload, dict):
|
|
50
|
+
payload = {"status": response.status_code, **payload}
|
|
51
|
+
return ToolError(json.dumps(payload, ensure_ascii=False))
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _parse_route(route: str) -> tuple[str, str, dict[str, Any]]:
|
|
55
|
+
if not isinstance(route, str) or not route.strip():
|
|
56
|
+
raise MCAError("invalid_route", "Route must be a non-empty HTTP method and path.", "route")
|
|
57
|
+
|
|
58
|
+
value = route.strip()
|
|
59
|
+
parts = value.split(None, 1)
|
|
60
|
+
if len(parts) != 2:
|
|
61
|
+
raise MCAError("invalid_route", "Route must use the form 'METHOD path'.", "route")
|
|
62
|
+
|
|
63
|
+
method, target = parts[0].upper(), parts[1]
|
|
64
|
+
if not method.isalpha() or any(character.isspace() for character in target):
|
|
65
|
+
raise MCAError("invalid_route", "Route must use the form 'METHOD path'.", "route")
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
parsed = urlsplit(target)
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
raise MCAError("invalid_route", "Route must be a valid HTTP method and path.", "route") from exc
|
|
71
|
+
if parsed.scheme or parsed.netloc or parsed.fragment or not parsed.path:
|
|
72
|
+
raise MCAError(
|
|
73
|
+
"invalid_route",
|
|
74
|
+
"Route must be an API-relative path without a scheme, host, or fragment.",
|
|
75
|
+
"route",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
path = parsed.path if parsed.path.startswith("/") else f"/{parsed.path}"
|
|
79
|
+
values = parse_qs(parsed.query, keep_blank_values=True)
|
|
80
|
+
query_params = {
|
|
81
|
+
key: values[0] if len(values) == 1 else values
|
|
82
|
+
for key, values in values.items()
|
|
83
|
+
}
|
|
84
|
+
return method, path, query_params
|
|
85
|
+
|
|
86
|
+
def _call_operation(
|
|
87
|
+
self,
|
|
88
|
+
registry: NinjaMCARouter,
|
|
89
|
+
operation: str,
|
|
90
|
+
path_params: dict[str, Any],
|
|
91
|
+
query_params: dict[str, Any],
|
|
92
|
+
body: Any,
|
|
93
|
+
) -> str:
|
|
94
|
+
response = registry.execute_http_request(
|
|
95
|
+
operation,
|
|
96
|
+
path_params=path_params,
|
|
97
|
+
query_params=query_params,
|
|
98
|
+
body=body,
|
|
99
|
+
allow_anonymous=True,
|
|
100
|
+
)
|
|
101
|
+
if response.status_code >= 400:
|
|
102
|
+
raise self._tool_error(response)
|
|
103
|
+
return json.dumps(self._json_response(response), ensure_ascii=False)
|
|
104
|
+
|
|
105
|
+
def _call_route(
|
|
106
|
+
self,
|
|
107
|
+
registry: NinjaMCARouter,
|
|
108
|
+
route: str,
|
|
109
|
+
body: Any,
|
|
110
|
+
api_base_path: str,
|
|
111
|
+
) -> str:
|
|
112
|
+
method, path, query_params = self._parse_route(route)
|
|
113
|
+
if path == api_base_path or path.startswith(f"{api_base_path}/"):
|
|
114
|
+
raise MCAError(
|
|
115
|
+
"invalid_route",
|
|
116
|
+
f"Route must be relative to {api_base_path}; omit the API prefix.",
|
|
117
|
+
"route",
|
|
118
|
+
)
|
|
119
|
+
if body is not None and method not in BODY_METHODS:
|
|
120
|
+
raise MCAError(
|
|
121
|
+
"invalid_body",
|
|
122
|
+
f"HTTP {method} routes cannot receive a JSON body.",
|
|
123
|
+
"body",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
resolved = registry.resolve(method, path)
|
|
127
|
+
if resolved is None:
|
|
128
|
+
raise MCAError(
|
|
129
|
+
"unknown_route",
|
|
130
|
+
f"No route matches {method} {path}.",
|
|
131
|
+
"route",
|
|
132
|
+
404,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
operation, path_params = resolved
|
|
136
|
+
return self._call_operation(registry, operation, path_params, query_params, body)
|
|
137
|
+
|
|
138
|
+
def build_server(self, registry: NinjaMCARouter, app_label: str) -> MCPServer:
|
|
139
|
+
server = MCPServer(f"{app_label} API")
|
|
140
|
+
tool_name = f"{app_label}_api"
|
|
141
|
+
rest_base_path = f"/api/{app_label}"
|
|
142
|
+
|
|
143
|
+
@server.tool(
|
|
144
|
+
name=tool_name,
|
|
145
|
+
description=(
|
|
146
|
+
f"Call the {app_label} API with an HTTP-style route relative to {rest_base_path}. "
|
|
147
|
+
"Start with route='GET /' to discover operations, guides and schemas. "
|
|
148
|
+
"Pass body for JSON request data. Results are JSON text; "
|
|
149
|
+
"HTTP 204 responses return null. Direct REST access with "
|
|
150
|
+
f"HTTP/curl at {rest_base_path} is also possible."
|
|
151
|
+
),
|
|
152
|
+
structured_output=False,
|
|
153
|
+
)
|
|
154
|
+
def call_api(
|
|
155
|
+
route: str,
|
|
156
|
+
body: Any = None,
|
|
157
|
+
) -> str:
|
|
158
|
+
try:
|
|
159
|
+
return self._call_route(registry, route, body, rest_base_path)
|
|
160
|
+
except MCAError as exc:
|
|
161
|
+
raise ToolError(json.dumps({"code": exc.code, "detail": exc.detail, "field": exc.field, "status": exc.status}, ensure_ascii=False)) from exc
|
|
162
|
+
|
|
163
|
+
call_api.__name__ = tool_name
|
|
164
|
+
return server
|
|
165
|
+
|
|
166
|
+
def discover_routes(self) -> tuple[MCPRoute, ...]:
|
|
167
|
+
from django.apps import apps
|
|
168
|
+
from importlib import import_module
|
|
169
|
+
|
|
170
|
+
routes: list[MCPRoute] = []
|
|
171
|
+
paths: set[str] = set()
|
|
172
|
+
tool_names: set[str] = set()
|
|
173
|
+
for app_config in apps.get_app_configs():
|
|
174
|
+
module_name = f"{app_config.name}.api"
|
|
175
|
+
try:
|
|
176
|
+
module = import_module(module_name)
|
|
177
|
+
except ModuleNotFoundError as exc:
|
|
178
|
+
if exc.name == module_name:
|
|
179
|
+
continue
|
|
180
|
+
raise
|
|
181
|
+
|
|
182
|
+
registry = getattr(module, "mca_registry", None)
|
|
183
|
+
if registry is None:
|
|
184
|
+
continue
|
|
185
|
+
if not isinstance(registry, NinjaMCARouter):
|
|
186
|
+
raise RuntimeError(
|
|
187
|
+
f"MCA application '{app_config.label}' must expose a NinjaMCARouter "
|
|
188
|
+
"named 'mca_registry' from its api module."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
app_label = app_config.label
|
|
192
|
+
path = f"/api/{app_label}/mcp"
|
|
193
|
+
tool_name = f"{app_label}_api"
|
|
194
|
+
if path in paths:
|
|
195
|
+
raise RuntimeError(f"MCA MCP path '{path}' is registered more than once.")
|
|
196
|
+
if tool_name in tool_names:
|
|
197
|
+
raise RuntimeError(f"MCA MCP tool '{tool_name}' is registered more than once.")
|
|
198
|
+
|
|
199
|
+
server = self.build_server(registry, app_label)
|
|
200
|
+
application = server.streamable_http_app(
|
|
201
|
+
streamable_http_path="/",
|
|
202
|
+
stateless_http=True,
|
|
203
|
+
)
|
|
204
|
+
routes.append(MCPRoute(app_label, path, server, application))
|
|
205
|
+
paths.add(path)
|
|
206
|
+
tool_names.add(tool_name)
|
|
207
|
+
|
|
208
|
+
return tuple(routes)
|
|
209
|
+
|
|
210
|
+
async def _dispatch(self, scope: dict[str, Any], receive: Any, send: Any):
|
|
211
|
+
self._initialize()
|
|
212
|
+
if scope.get("type") == "http":
|
|
213
|
+
path = scope.get("path", "")
|
|
214
|
+
normalized_path = path.rstrip("/") or "/"
|
|
215
|
+
for route in self._routes:
|
|
216
|
+
if normalized_path != route.path:
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
mcp_scope = dict(scope)
|
|
220
|
+
mcp_scope["path"] = "/"
|
|
221
|
+
mcp_scope["raw_path"] = b"/"
|
|
222
|
+
mcp_scope["root_path"] = ""
|
|
223
|
+
return await route.application(mcp_scope, receive, send)
|
|
224
|
+
|
|
225
|
+
return await self._django_application(scope, receive, send)
|
|
226
|
+
|
|
227
|
+
def _initialize(self) -> None:
|
|
228
|
+
if self._django_application is not None:
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
from django.core.asgi import get_asgi_application
|
|
232
|
+
|
|
233
|
+
self._django_application = get_asgi_application()
|
|
234
|
+
self._routes = self.discover_routes()
|
|
235
|
+
|
|
236
|
+
@asynccontextmanager
|
|
237
|
+
async def _mcp_lifespan(self):
|
|
238
|
+
self._initialize()
|
|
239
|
+
async with AsyncExitStack() as stack:
|
|
240
|
+
for route in self._routes:
|
|
241
|
+
await stack.enter_async_context(route.server.session_manager.run())
|
|
242
|
+
yield
|
|
243
|
+
|
|
244
|
+
async def _handle_lifespan(self, receive: Any, send: Any):
|
|
245
|
+
startup_complete = False
|
|
246
|
+
try:
|
|
247
|
+
message = await receive()
|
|
248
|
+
if message.get("type") != "lifespan.startup":
|
|
249
|
+
raise RuntimeError("Expected lifespan.startup message.")
|
|
250
|
+
|
|
251
|
+
async with self._mcp_lifespan():
|
|
252
|
+
await send({"type": "lifespan.startup.complete"})
|
|
253
|
+
startup_complete = True
|
|
254
|
+
message = await receive()
|
|
255
|
+
if message.get("type") != "lifespan.shutdown":
|
|
256
|
+
raise RuntimeError("Expected lifespan.shutdown message.")
|
|
257
|
+
|
|
258
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
259
|
+
except Exception as exc:
|
|
260
|
+
event_type = (
|
|
261
|
+
"lifespan.shutdown.failed"
|
|
262
|
+
if startup_complete
|
|
263
|
+
else "lifespan.startup.failed"
|
|
264
|
+
)
|
|
265
|
+
await send({"type": event_type, "message": str(exc)})
|
|
266
|
+
|
|
267
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any):
|
|
268
|
+
if scope.get("type") == "lifespan":
|
|
269
|
+
return await self._handle_lifespan(receive, send)
|
|
270
|
+
return await self._dispatch(scope, receive, send)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Shared Pydantic models used by MCA transports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ErrorOut(BaseModel):
|
|
11
|
+
code: str = Field(..., description="Stable machine-readable error code.")
|
|
12
|
+
detail: str = Field(..., description="Human-readable explanation of the error.")
|
|
13
|
+
field: str | None = Field(None, description="Related input field, when applicable.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MCAResponseOut(BaseModel):
|
|
17
|
+
title: str = Field(..., description="Title of the Model Context API discovery document.")
|
|
18
|
+
version: float = Field(..., description="Version of the discovery document format.")
|
|
19
|
+
index: str = Field(..., description="Markdown index describing the available API guides.")
|
|
20
|
+
help: str = Field(..., description="Instructions for requesting guide and schema details.")
|
|
21
|
+
available_guides: list[str] = Field(..., description="Available packaged Markdown guide names.")
|
|
22
|
+
available_operations: dict[str, str] = Field(
|
|
23
|
+
...,
|
|
24
|
+
description="Map from operation name to relative route and description.",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class APIRouteSchemaOut(BaseModel):
|
|
29
|
+
route: str = Field(..., description="HTTP method and relative route template.")
|
|
30
|
+
description: str = Field(..., description="Human-readable operation description.")
|
|
31
|
+
guides: list[str] | None = Field(
|
|
32
|
+
None,
|
|
33
|
+
exclude_if=lambda value: value is None,
|
|
34
|
+
description="Relevant guide names for this operation.",
|
|
35
|
+
)
|
|
36
|
+
request_schema: dict[str, Any] | None = Field(None, description="Logical operation input schema.")
|
|
37
|
+
response_schema: dict[str, Any] | None = Field(None, description="Successful response schema.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MCADiscoveryOut(BaseModel):
|
|
41
|
+
guides: dict[str, str] | None = Field(
|
|
42
|
+
None,
|
|
43
|
+
exclude_if=lambda value: value is None,
|
|
44
|
+
description="Requested guide names and Markdown content.",
|
|
45
|
+
)
|
|
46
|
+
operations: dict[str, APIRouteSchemaOut] | None = Field(
|
|
47
|
+
None,
|
|
48
|
+
exclude_if=lambda value: value is None,
|
|
49
|
+
description="Requested operation schemas.",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DiscoveryParams(BaseModel):
|
|
54
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
55
|
+
|
|
56
|
+
guide: str | None = Field(None, description="Comma-separated guide names to read.")
|
|
57
|
+
operation_name: str | None = Field(
|
|
58
|
+
None,
|
|
59
|
+
alias="operation",
|
|
60
|
+
description="Comma-separated operation names whose schemas should be read.",
|
|
61
|
+
)
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Reusable Model Context API support for Django Ninja APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
import json
|
|
8
|
+
import inspect
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, TypeVar
|
|
12
|
+
from urllib.parse import quote, urlencode
|
|
13
|
+
|
|
14
|
+
from django.http import HttpRequest
|
|
15
|
+
from django.http.response import HttpResponseBase
|
|
16
|
+
from ninja import Query
|
|
17
|
+
|
|
18
|
+
from .base import BaseMCARouter, MCAError, RegisteredRoute
|
|
19
|
+
from .models import APIRouteSchemaOut, MCAResponseOut, MCADiscoveryOut
|
|
20
|
+
|
|
21
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
22
|
+
|
|
23
|
+
_PATH_PARAMETER = re.compile(r"\{([^}]+)\}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _request_path(path_template: str, path_params: Mapping[str, Any]) -> str:
|
|
27
|
+
return _PATH_PARAMETER.sub(
|
|
28
|
+
lambda match: quote(
|
|
29
|
+
str(path_params.get(match.group(1), match.group(0))),
|
|
30
|
+
safe="",
|
|
31
|
+
),
|
|
32
|
+
path_template,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MCAExecutionError(Exception):
|
|
37
|
+
def __init__(self, operation: str, detail: str, response: HttpResponseBase | None = None):
|
|
38
|
+
super().__init__(detail)
|
|
39
|
+
self.operation = operation
|
|
40
|
+
self.detail = detail
|
|
41
|
+
self.response = response
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NinjaMCARouter(BaseMCARouter):
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
api: Any,
|
|
48
|
+
*,
|
|
49
|
+
guides_dir: str | Path,
|
|
50
|
+
mca_path: str = "/",
|
|
51
|
+
title: str = "Model Context API",
|
|
52
|
+
version: float = 1.0,
|
|
53
|
+
error_responses: Mapping[int, Any] | None = None,
|
|
54
|
+
):
|
|
55
|
+
self.api = api
|
|
56
|
+
self.error_responses = error_responses or {}
|
|
57
|
+
super().__init__(guides_dir=guides_dir, mca_path=mca_path, title=title, version=version)
|
|
58
|
+
|
|
59
|
+
def _discovery_endpoints(self) -> Iterable[tuple[str, str, F, Mapping[str, Any]]]:
|
|
60
|
+
response = {
|
|
61
|
+
**self.error_responses,
|
|
62
|
+
200: MCAResponseOut | MCADiscoveryOut,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def get_mca(
|
|
66
|
+
request: HttpRequest,
|
|
67
|
+
guide: str | None = Query(None, description="Comma-separated names of Markdown guides to read."),
|
|
68
|
+
operation_name: str | None = Query(
|
|
69
|
+
None,
|
|
70
|
+
alias="operation",
|
|
71
|
+
description="Comma-separated API operation names whose schemas should be read.",
|
|
72
|
+
),
|
|
73
|
+
):
|
|
74
|
+
return self._get_mca(guide, operation_name)
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
(
|
|
78
|
+
self.mca_path,
|
|
79
|
+
"get_mca",
|
|
80
|
+
get_mca,
|
|
81
|
+
{"response": response, "description": "Discover API guides and read route schemas."},
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _register_transport_route(self, route: RegisteredRoute, endpoint: F, options: Mapping[str, Any], **variant: Any) -> F:
|
|
86
|
+
api_register = getattr(self.api, route.method.lower())
|
|
87
|
+
route_options = dict(options)
|
|
88
|
+
route_options.update({key: variant[key] for key in ("include_in_schema",) if key in variant})
|
|
89
|
+
return api_register(
|
|
90
|
+
variant.get("path", route.path),
|
|
91
|
+
operation_id=variant.get("operation_id", route.operation),
|
|
92
|
+
**route_options,
|
|
93
|
+
)(endpoint)
|
|
94
|
+
|
|
95
|
+
def execute_http(
|
|
96
|
+
self,
|
|
97
|
+
operation: str,
|
|
98
|
+
request: HttpRequest,
|
|
99
|
+
path_params: Mapping[str, Any] | None = None,
|
|
100
|
+
*,
|
|
101
|
+
allow_anonymous: bool = False,
|
|
102
|
+
) -> HttpResponseBase:
|
|
103
|
+
route = self.route(operation)
|
|
104
|
+
ninja_operation = self._ninja_operation(route.operation)
|
|
105
|
+
if inspect.iscoroutinefunction(ninja_operation.view_func):
|
|
106
|
+
raise MCAExecutionError(
|
|
107
|
+
operation,
|
|
108
|
+
"Asynchronous endpoints require an asynchronous executor.",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if allow_anonymous:
|
|
112
|
+
request._mca_allow_anonymous = True
|
|
113
|
+
request._dont_enforce_csrf_checks = True
|
|
114
|
+
|
|
115
|
+
return ninja_operation.run(request, **dict(path_params or {}))
|
|
116
|
+
|
|
117
|
+
def execute_http_request(
|
|
118
|
+
self,
|
|
119
|
+
operation: str,
|
|
120
|
+
source_request: HttpRequest | None = None,
|
|
121
|
+
path_params: Mapping[str, Any] | None = None,
|
|
122
|
+
query_params: Mapping[str, Any] | None = None,
|
|
123
|
+
body: Any = None,
|
|
124
|
+
*,
|
|
125
|
+
allow_anonymous: bool = False,
|
|
126
|
+
) -> HttpResponseBase:
|
|
127
|
+
from django.contrib.auth.models import AnonymousUser
|
|
128
|
+
from django.test import RequestFactory
|
|
129
|
+
|
|
130
|
+
route = self.route(operation)
|
|
131
|
+
path_values = dict(path_params or {})
|
|
132
|
+
query_string = urlencode(dict(query_params or {}), doseq=True)
|
|
133
|
+
path = _request_path(route.path, path_values)
|
|
134
|
+
if query_string:
|
|
135
|
+
path = f"{path}?{query_string}"
|
|
136
|
+
|
|
137
|
+
request_factory = RequestFactory()
|
|
138
|
+
if body is None:
|
|
139
|
+
request = request_factory.generic(route.method, path)
|
|
140
|
+
else:
|
|
141
|
+
request = request_factory.generic(
|
|
142
|
+
route.method,
|
|
143
|
+
path,
|
|
144
|
+
data=json.dumps(body).encode("utf-8"),
|
|
145
|
+
content_type="application/json",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if source_request is None:
|
|
149
|
+
request.user = AnonymousUser()
|
|
150
|
+
else:
|
|
151
|
+
request.user = source_request.user
|
|
152
|
+
request.COOKIES = source_request.COOKIES.copy()
|
|
153
|
+
if hasattr(source_request, "session"):
|
|
154
|
+
request.session = source_request.session
|
|
155
|
+
if getattr(source_request, "_dont_enforce_csrf_checks", False):
|
|
156
|
+
request._dont_enforce_csrf_checks = True
|
|
157
|
+
request.META.update(
|
|
158
|
+
{
|
|
159
|
+
key: value
|
|
160
|
+
for key, value in source_request.META.items()
|
|
161
|
+
if key not in {
|
|
162
|
+
"CONTENT_LENGTH",
|
|
163
|
+
"CONTENT_TYPE",
|
|
164
|
+
"PATH_INFO",
|
|
165
|
+
"QUERY_STRING",
|
|
166
|
+
"RAW_URI",
|
|
167
|
+
"REQUEST_URI",
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return self.execute_http(
|
|
173
|
+
operation,
|
|
174
|
+
request,
|
|
175
|
+
path_values,
|
|
176
|
+
allow_anonymous=allow_anonymous,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _ninja_operation(self, operation: str) -> Any:
|
|
180
|
+
for bound_router in self.api._get_bound_routers():
|
|
181
|
+
for path_view in bound_router.path_operations.values():
|
|
182
|
+
for ninja_operation in path_view.operations:
|
|
183
|
+
if ninja_operation.operation_id == operation:
|
|
184
|
+
return ninja_operation
|
|
185
|
+
raise MCAExecutionError(
|
|
186
|
+
operation,
|
|
187
|
+
f"Ninja has no bound operation for '{operation}'.",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def _get_mca(self, guide: str | None, operation_name: str | None):
|
|
191
|
+
return self.discovery(guide, operation_name, self._route_schema)
|
|
192
|
+
|
|
193
|
+
def _openapi_operation(self, name: str) -> dict[str, Any] | None:
|
|
194
|
+
schema = self.api.get_openapi_schema()
|
|
195
|
+
for path_data in schema.get("paths", {}).values():
|
|
196
|
+
for operation in path_data.values():
|
|
197
|
+
if isinstance(operation, dict) and operation.get("operationId") == name:
|
|
198
|
+
return operation
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def _route_schema(self, route: RegisteredRoute) -> dict[str, Any]:
|
|
202
|
+
name = route.operation
|
|
203
|
+
discovery_route = route.discovery_route
|
|
204
|
+
operation = self._openapi_operation(name)
|
|
205
|
+
if operation is None:
|
|
206
|
+
raise MCAError(
|
|
207
|
+
"unknown_operation",
|
|
208
|
+
f"No schema is available for operation '{name}'.",
|
|
209
|
+
"operation",
|
|
210
|
+
404,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
openapi_schema = self.api.get_openapi_schema()
|
|
214
|
+
components = openapi_schema.get("components", {}).get("schemas", {})
|
|
215
|
+
path_properties: dict[str, Any] = {}
|
|
216
|
+
path_required: list[str] = []
|
|
217
|
+
query_properties: dict[str, Any] = {}
|
|
218
|
+
query_required: list[str] = []
|
|
219
|
+
route_path = route.path
|
|
220
|
+
template_path_parameters = re.findall(r"\{([^}]+)\}", route_path)
|
|
221
|
+
openapi_path_parameters = [
|
|
222
|
+
parameter["name"]
|
|
223
|
+
for parameter in operation.get("parameters", [])
|
|
224
|
+
if parameter.get("in") == "path"
|
|
225
|
+
]
|
|
226
|
+
path_parameter_names = dict(zip(openapi_path_parameters, template_path_parameters))
|
|
227
|
+
for parameter in operation.get("parameters", []):
|
|
228
|
+
parameter_name = path_parameter_names.get(parameter["name"], parameter["name"])
|
|
229
|
+
parameter_schema = deepcopy(parameter.get("schema", {}))
|
|
230
|
+
parameter_schema["description"] = parameter.get("description") or f"{parameter_name} request parameter."
|
|
231
|
+
if parameter.get("in") == "path":
|
|
232
|
+
properties = path_properties
|
|
233
|
+
required = path_required
|
|
234
|
+
else:
|
|
235
|
+
properties = query_properties
|
|
236
|
+
required = query_required
|
|
237
|
+
properties[parameter_name] = parameter_schema
|
|
238
|
+
if parameter.get("required"):
|
|
239
|
+
required.append(parameter_name)
|
|
240
|
+
|
|
241
|
+
body_schema = (
|
|
242
|
+
operation.get("requestBody", {})
|
|
243
|
+
.get("content", {})
|
|
244
|
+
.get("application/json", {})
|
|
245
|
+
.get("schema")
|
|
246
|
+
)
|
|
247
|
+
request_properties: dict[str, Any] = {}
|
|
248
|
+
request_required: list[str] = []
|
|
249
|
+
if path_properties:
|
|
250
|
+
request_properties["path_params"] = {
|
|
251
|
+
"description": "Values captured from the operation route.",
|
|
252
|
+
"type": "object",
|
|
253
|
+
"properties": path_properties,
|
|
254
|
+
"required": path_required,
|
|
255
|
+
}
|
|
256
|
+
if path_required:
|
|
257
|
+
request_required.append("path_params")
|
|
258
|
+
if query_properties:
|
|
259
|
+
request_properties["query_params"] = {
|
|
260
|
+
"description": "Values supplied as operation query parameters.",
|
|
261
|
+
"type": "object",
|
|
262
|
+
"properties": query_properties,
|
|
263
|
+
"required": query_required,
|
|
264
|
+
}
|
|
265
|
+
if query_required:
|
|
266
|
+
request_required.append("query_params")
|
|
267
|
+
if body_schema is not None:
|
|
268
|
+
request_properties["body"] = {
|
|
269
|
+
"description": "JSON request body.",
|
|
270
|
+
**deepcopy(body_schema),
|
|
271
|
+
}
|
|
272
|
+
if operation.get("requestBody", {}).get("required"):
|
|
273
|
+
request_required.append("body")
|
|
274
|
+
|
|
275
|
+
request_schema = None
|
|
276
|
+
if request_properties:
|
|
277
|
+
request_shape = {
|
|
278
|
+
"type": "object",
|
|
279
|
+
"properties": request_properties,
|
|
280
|
+
"required": request_required,
|
|
281
|
+
}
|
|
282
|
+
request_schema = {**request_shape}
|
|
283
|
+
request_components = self._referenced_components(request_shape, components)
|
|
284
|
+
if request_components:
|
|
285
|
+
request_schema["components"] = {"schemas": request_components}
|
|
286
|
+
|
|
287
|
+
response_schema = None
|
|
288
|
+
for status in (200, 201):
|
|
289
|
+
response_schema = (
|
|
290
|
+
operation.get("responses", {})
|
|
291
|
+
.get(status, {})
|
|
292
|
+
.get("content", {})
|
|
293
|
+
.get("application/json", {})
|
|
294
|
+
.get("schema")
|
|
295
|
+
)
|
|
296
|
+
if response_schema is not None:
|
|
297
|
+
break
|
|
298
|
+
if response_schema is not None:
|
|
299
|
+
response_schema = deepcopy(response_schema)
|
|
300
|
+
response_components = self._referenced_components(response_schema, components)
|
|
301
|
+
if response_components:
|
|
302
|
+
response_schema["components"] = {"schemas": response_components}
|
|
303
|
+
|
|
304
|
+
schema = {
|
|
305
|
+
"route": discovery_route,
|
|
306
|
+
"description": operation.get("description") or operation.get("summary") or name,
|
|
307
|
+
"request_schema": request_schema,
|
|
308
|
+
"response_schema": response_schema,
|
|
309
|
+
}
|
|
310
|
+
if route.meta("guides") is not None:
|
|
311
|
+
schema["guides"] = route.meta("guides")
|
|
312
|
+
return schema
|
|
313
|
+
|
|
314
|
+
@staticmethod
|
|
315
|
+
def _referenced_components(
|
|
316
|
+
schema: Any,
|
|
317
|
+
components: Mapping[str, Any],
|
|
318
|
+
) -> dict[str, Any]:
|
|
319
|
+
pending = list(NinjaMCARouter._component_references(schema))
|
|
320
|
+
selected: dict[str, Any] = {}
|
|
321
|
+
while pending:
|
|
322
|
+
name = pending.pop()
|
|
323
|
+
if name in selected or name not in components:
|
|
324
|
+
continue
|
|
325
|
+
component = deepcopy(components[name])
|
|
326
|
+
selected[name] = component
|
|
327
|
+
pending.extend(NinjaMCARouter._component_references(component))
|
|
328
|
+
return selected
|
|
329
|
+
|
|
330
|
+
@staticmethod
|
|
331
|
+
def _component_references(value: Any):
|
|
332
|
+
if isinstance(value, dict):
|
|
333
|
+
reference = value.get("$ref")
|
|
334
|
+
prefix = "#/components/schemas/"
|
|
335
|
+
if isinstance(reference, str) and reference.startswith(prefix):
|
|
336
|
+
yield reference.removeprefix(prefix)
|
|
337
|
+
for child in value.values():
|
|
338
|
+
yield from NinjaMCARouter._component_references(child)
|
|
339
|
+
elif isinstance(value, list):
|
|
340
|
+
for child in value:
|
|
341
|
+
yield from NinjaMCARouter._component_references(child)
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Reusable Pydantic MCA route registration, discovery, and dispatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable, Mapping
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from typing import Any, Callable, TypeVar, get_type_hints
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, TypeAdapter, ValidationError
|
|
12
|
+
|
|
13
|
+
from .base import BaseMCARouter, MCAError, RegisteredRoute
|
|
14
|
+
from .models import (
|
|
15
|
+
APIRouteSchemaOut,
|
|
16
|
+
DiscoveryParams,
|
|
17
|
+
ErrorOut,
|
|
18
|
+
MCAResponseOut,
|
|
19
|
+
MCADiscoveryOut,
|
|
20
|
+
)
|
|
21
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DispatchValidationError(ValueError):
|
|
25
|
+
"""Raised when dispatch input or output does not match its route schema."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, source: str, errors: list[dict[str, Any]]):
|
|
28
|
+
self.source = source
|
|
29
|
+
self.errors = errors
|
|
30
|
+
super().__init__(f"Invalid {source}.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _validate_for_dispatch(annotation: Any, value: Any, source: str) -> Any:
|
|
34
|
+
if annotation is None:
|
|
35
|
+
return value
|
|
36
|
+
try:
|
|
37
|
+
return TypeAdapter(annotation).validate_python(value)
|
|
38
|
+
except ValidationError as exc:
|
|
39
|
+
raise DispatchValidationError(source, exc.errors()) from exc
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validation_error_response(exc: DispatchValidationError) -> ErrorOut:
|
|
43
|
+
first_error = exc.errors[0] if exc.errors else {}
|
|
44
|
+
location = first_error.get("loc", ())
|
|
45
|
+
field = ".".join(str(part) for part in location) or exc.source
|
|
46
|
+
code = "invalid_response" if exc.source == "response" else "invalid_request"
|
|
47
|
+
return ErrorOut(
|
|
48
|
+
code=code,
|
|
49
|
+
detail=str(first_error.get("msg", "Validation failed.")),
|
|
50
|
+
field=field,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PydanticMCARouter(BaseMCARouter):
|
|
55
|
+
def _discovery_endpoints(self) -> Iterable[tuple[str, str, F, Mapping[str, Any]]]:
|
|
56
|
+
def get_mca(params: DiscoveryParams) -> MCAResponseOut | MCADiscoveryOut:
|
|
57
|
+
return self._get_mca(params)
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
(
|
|
61
|
+
self.mca_path,
|
|
62
|
+
"get_mca",
|
|
63
|
+
get_mca,
|
|
64
|
+
{"description": "Discover engine guides and read operation schemas."},
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def _register_transport_route(self, route: RegisteredRoute, endpoint: F, options: Mapping[str, Any], **_: Any) -> F:
|
|
69
|
+
return endpoint
|
|
70
|
+
|
|
71
|
+
def _route_metadata(self, endpoint: F, options: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
72
|
+
parameters = inspect.signature(endpoint).parameters
|
|
73
|
+
type_hints = get_type_hints(endpoint)
|
|
74
|
+
params_parameter = parameters.get("params")
|
|
75
|
+
body_parameter = parameters.get("data")
|
|
76
|
+
return {
|
|
77
|
+
"params_type": type_hints.get("params") if "params" in parameters else None,
|
|
78
|
+
"body_type": type_hints.get("data") if "data" in parameters else None,
|
|
79
|
+
"response_type": type_hints.get("return"),
|
|
80
|
+
"params_required": (
|
|
81
|
+
params_parameter is not None
|
|
82
|
+
and params_parameter.default is inspect.Parameter.empty
|
|
83
|
+
),
|
|
84
|
+
"body_required": (
|
|
85
|
+
body_parameter is not None
|
|
86
|
+
and body_parameter.default is inspect.Parameter.empty
|
|
87
|
+
),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _schema_parts(annotation: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
92
|
+
if annotation is None or annotation is Any:
|
|
93
|
+
return {}, {}
|
|
94
|
+
schema = TypeAdapter(annotation).json_schema(
|
|
95
|
+
ref_template="#/components/schemas/{model}"
|
|
96
|
+
)
|
|
97
|
+
components = schema.pop("$defs", {})
|
|
98
|
+
return schema, components
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _is_model_type(annotation: Any) -> bool:
|
|
102
|
+
return isinstance(annotation, type) and issubclass(annotation, BaseModel)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def _referenced_schema(
|
|
106
|
+
cls,
|
|
107
|
+
annotation: Any,
|
|
108
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
109
|
+
schema, components = cls._schema_parts(annotation)
|
|
110
|
+
if cls._is_model_type(annotation):
|
|
111
|
+
name = annotation.__name__
|
|
112
|
+
components[name] = schema
|
|
113
|
+
return {"$ref": f"#/components/schemas/{name}"}, components
|
|
114
|
+
return schema, components
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _path_parameter_names(route: str) -> set[str]:
|
|
118
|
+
return {
|
|
119
|
+
parameter.split(":")[-1]
|
|
120
|
+
for parameter in re.findall(r"\{([^}]+)\}", route)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
def _route_schema(self, route: RegisteredRoute) -> APIRouteSchemaOut:
|
|
124
|
+
request_properties: dict[str, Any] = {}
|
|
125
|
+
request_required: list[str] = []
|
|
126
|
+
components: dict[str, Any] = {}
|
|
127
|
+
|
|
128
|
+
params_type = route.meta("params_type")
|
|
129
|
+
body_type = route.meta("body_type")
|
|
130
|
+
response_type = route.meta("response_type")
|
|
131
|
+
params_schema, params_components = self._schema_parts(params_type)
|
|
132
|
+
components.update(params_components)
|
|
133
|
+
params_properties = params_schema.get("properties", {})
|
|
134
|
+
params_required = set(params_schema.get("required", []))
|
|
135
|
+
path_names = self._path_parameter_names(route.path)
|
|
136
|
+
|
|
137
|
+
path_properties: dict[str, Any] = {}
|
|
138
|
+
path_required: list[str] = []
|
|
139
|
+
query_properties: dict[str, Any] = {}
|
|
140
|
+
query_required: list[str] = []
|
|
141
|
+
for name, property_schema in params_properties.items():
|
|
142
|
+
property_schema = deepcopy(property_schema)
|
|
143
|
+
property_schema.setdefault("description", f"{name} request parameter.")
|
|
144
|
+
if name in path_names:
|
|
145
|
+
path_properties[name] = property_schema
|
|
146
|
+
path_required.append(name)
|
|
147
|
+
else:
|
|
148
|
+
query_properties[name] = property_schema
|
|
149
|
+
if name in params_required:
|
|
150
|
+
query_required.append(name)
|
|
151
|
+
|
|
152
|
+
if path_properties:
|
|
153
|
+
request_properties["path_params"] = {
|
|
154
|
+
"description": "Values captured from the selected operation route.",
|
|
155
|
+
"type": "object",
|
|
156
|
+
"properties": path_properties,
|
|
157
|
+
"required": path_required,
|
|
158
|
+
}
|
|
159
|
+
request_required.append("path_params")
|
|
160
|
+
if query_properties:
|
|
161
|
+
request_properties["query_params"] = {
|
|
162
|
+
"description": "Values supplied as operation query parameters.",
|
|
163
|
+
"type": "object",
|
|
164
|
+
"properties": query_properties,
|
|
165
|
+
"required": query_required,
|
|
166
|
+
}
|
|
167
|
+
if query_required:
|
|
168
|
+
request_required.append("query_params")
|
|
169
|
+
|
|
170
|
+
body_required = bool(route.meta("body_required"))
|
|
171
|
+
if body_type is not None and body_type is not Any:
|
|
172
|
+
body_schema, body_components = self._referenced_schema(body_type)
|
|
173
|
+
components.update(body_components)
|
|
174
|
+
request_properties["body"] = {
|
|
175
|
+
"description": "JSON request body.",
|
|
176
|
+
**body_schema,
|
|
177
|
+
}
|
|
178
|
+
if body_required:
|
|
179
|
+
request_required.append("body")
|
|
180
|
+
|
|
181
|
+
request_schema = None
|
|
182
|
+
if request_properties:
|
|
183
|
+
request_schema = {
|
|
184
|
+
"type": "object",
|
|
185
|
+
"properties": request_properties,
|
|
186
|
+
"required": request_required,
|
|
187
|
+
}
|
|
188
|
+
if components:
|
|
189
|
+
request_schema["components"] = {"schemas": components}
|
|
190
|
+
|
|
191
|
+
response_schema = None
|
|
192
|
+
if response_type is not None and response_type is not Any:
|
|
193
|
+
response_schema, response_components = self._referenced_schema(response_type)
|
|
194
|
+
if response_components:
|
|
195
|
+
response_schema["components"] = {"schemas": response_components}
|
|
196
|
+
|
|
197
|
+
return APIRouteSchemaOut(
|
|
198
|
+
route=route.discovery_route,
|
|
199
|
+
description=route.description,
|
|
200
|
+
guides=route.meta("guides"),
|
|
201
|
+
request_schema=request_schema,
|
|
202
|
+
response_schema=response_schema,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def _get_mca(self, params: DiscoveryParams) -> MCAResponseOut | MCADiscoveryOut:
|
|
206
|
+
result = self.discovery(params.guide, params.operation_name, self._route_schema)
|
|
207
|
+
if params.guide is None and params.operation_name is None:
|
|
208
|
+
return MCAResponseOut(**result)
|
|
209
|
+
return MCADiscoveryOut(**result)
|
|
210
|
+
|
|
211
|
+
def _dispatch_registered(
|
|
212
|
+
self,
|
|
213
|
+
route: RegisteredRoute,
|
|
214
|
+
params: dict[str, Any] | None,
|
|
215
|
+
data: Any,
|
|
216
|
+
) -> Any:
|
|
217
|
+
params_type = route.meta("params_type")
|
|
218
|
+
body_type = route.meta("body_type")
|
|
219
|
+
validated_params = _validate_for_dispatch(params_type, {} if params is None else params, "params")
|
|
220
|
+
validated_body = _validate_for_dispatch(body_type, data, "data")
|
|
221
|
+
|
|
222
|
+
kwargs: dict[str, Any] = {}
|
|
223
|
+
if params_type is not None:
|
|
224
|
+
kwargs["params"] = validated_params
|
|
225
|
+
if body_type is not None:
|
|
226
|
+
kwargs["data"] = validated_body
|
|
227
|
+
|
|
228
|
+
result = route.endpoint(**kwargs)
|
|
229
|
+
return _validate_for_dispatch(route.meta("response_type"), result, "response")
|
|
230
|
+
|
|
231
|
+
def _dispatch_error(self, error: MCAError) -> ErrorOut:
|
|
232
|
+
code = "unknown_operation" if error.code == "unknown_endpoint" else error.code
|
|
233
|
+
field = "operation" if error.code == "unknown_endpoint" else error.field
|
|
234
|
+
return ErrorOut(code=code, detail=error.detail, field=field)
|
|
235
|
+
|
|
236
|
+
def dispatch(
|
|
237
|
+
self,
|
|
238
|
+
operation: str | None = None,
|
|
239
|
+
params: dict[str, Any] | None = None,
|
|
240
|
+
data: Any = None,
|
|
241
|
+
*,
|
|
242
|
+
method: str = "GET",
|
|
243
|
+
) -> Any:
|
|
244
|
+
try:
|
|
245
|
+
return super().dispatch(
|
|
246
|
+
operation,
|
|
247
|
+
params,
|
|
248
|
+
data,
|
|
249
|
+
method=method,
|
|
250
|
+
)
|
|
251
|
+
except DispatchValidationError as exc:
|
|
252
|
+
return _validation_error_response(exc)
|
|
253
|
+
except MCAError as exc:
|
|
254
|
+
return self._dispatch_error(exc)
|
|
255
|
+
except Exception:
|
|
256
|
+
return ErrorOut(
|
|
257
|
+
code="internal_error",
|
|
258
|
+
detail="The endpoint could not complete the operation.",
|
|
259
|
+
field="operation",
|
|
260
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "model-context-api"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Model Context API for AI Agents"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
"pydantic>=2.13.5",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
ninja = [
|
|
14
|
+
"django>=6.1.1",
|
|
15
|
+
"django-ninja>=1.7.0",
|
|
16
|
+
]
|
|
17
|
+
mcp = [
|
|
18
|
+
"mcp>=2.2,<3",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["uv_build>=0.10.8,<0.11.0"]
|
|
23
|
+
build-backend = "uv_build"
|
|
24
|
+
|
|
25
|
+
[tool.uv.build-backend]
|
|
26
|
+
module-name = "mca"
|
|
27
|
+
module-root = ""
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"pre-commit>=4.6.2",
|
|
32
|
+
]
|