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.
@@ -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,7 @@
1
+ """Reusable Model Context API routers and MCP hosting utilities."""
2
+
3
+ from .base import BaseMCARouter, GuideCatalog, MCAError
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["BaseMCARouter", "GuideCatalog", "MCAError", "__version__"]
@@ -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
+ ]