djcrud-client 0.3.1__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.
- djcrud_client-0.3.1/PKG-INFO +26 -0
- djcrud_client-0.3.1/README.md +16 -0
- djcrud_client-0.3.1/pyproject.toml +33 -0
- djcrud_client-0.3.1/setup.cfg +4 -0
- djcrud_client-0.3.1/src/djcrud_client/__init__.py +23 -0
- djcrud_client-0.3.1/src/djcrud_client/__main__.py +107 -0
- djcrud_client-0.3.1/src/djcrud_client/api.py +151 -0
- djcrud_client-0.3.1/src/djcrud_client/config.py +34 -0
- djcrud_client-0.3.1/src/djcrud_client/profile.py +55 -0
- djcrud_client-0.3.1/src/djcrud_client/schema.py +93 -0
- djcrud_client-0.3.1/src/djcrud_client/server.py +91 -0
- djcrud_client-0.3.1/src/djcrud_client/tools.py +171 -0
- djcrud_client-0.3.1/src/djcrud_client.egg-info/PKG-INFO +26 -0
- djcrud_client-0.3.1/src/djcrud_client.egg-info/SOURCES.txt +21 -0
- djcrud_client-0.3.1/src/djcrud_client.egg-info/dependency_links.txt +1 -0
- djcrud_client-0.3.1/src/djcrud_client.egg-info/entry_points.txt +2 -0
- djcrud_client-0.3.1/src/djcrud_client.egg-info/requires.txt +2 -0
- djcrud_client-0.3.1/src/djcrud_client.egg-info/scm_file_list.json +17 -0
- djcrud_client-0.3.1/src/djcrud_client.egg-info/scm_version.json +8 -0
- djcrud_client-0.3.1/src/djcrud_client.egg-info/top_level.txt +1 -0
- djcrud_client-0.3.1/tests/test_config.py +17 -0
- djcrud_client-0.3.1/tests/test_resolve_registry.py +42 -0
- djcrud_client-0.3.1/tests/test_standalone.py +113 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: djcrud-client
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Stdio MCP bridge for djcrud JSON APIs (no Django required)
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: mcp<2,>=1.0
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
|
|
11
|
+
# djcrud-client
|
|
12
|
+
|
|
13
|
+
Stdio MCP bridge for [djcrud](https://github.com/yourlabs/djcrud) JSON APIs. No Django required in the subprocess — FastMCP runs here, not on the Django host.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install djcrud-client
|
|
19
|
+
djcrud-client -mcp
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Host setup
|
|
23
|
+
|
|
24
|
+
Declare `McpProfile` classes in your app's `djcrud.py` and register them on `djcrud_mcp.site` (see `djcrud_example/mcp_example/djcrud.py` in the djcrud repo).
|
|
25
|
+
|
|
26
|
+
Add `djcrud_mcp` to `INSTALLED_APPS` and include `djcrud_drf.site` URLs on the host. Register one `McpProfile` in your app's `djcrud.py` (see `mcp_example/djcrud.py`). Run `djcrud-client -mcp` with `DJCRUD_TOKEN` set.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# djcrud-client
|
|
2
|
+
|
|
3
|
+
Stdio MCP bridge for [djcrud](https://github.com/yourlabs/djcrud) JSON APIs. No Django required in the subprocess — FastMCP runs here, not on the Django host.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install djcrud-client
|
|
9
|
+
djcrud-client -mcp
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Host setup
|
|
13
|
+
|
|
14
|
+
Declare `McpProfile` classes in your app's `djcrud.py` and register them on `djcrud_mcp.site` (see `djcrud_example/mcp_example/djcrud.py` in the djcrud repo).
|
|
15
|
+
|
|
16
|
+
Add `djcrud_mcp` to `INSTALLED_APPS` and include `djcrud_drf.site` URLs on the host. Register one `McpProfile` in your app's `djcrud.py` (see `mcp_example/djcrud.py`). Run `djcrud-client -mcp` with `DJCRUD_TOKEN` set.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "setuptools-scm>=8"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "djcrud-client"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Stdio MCP bridge for djcrud JSON APIs (no Django required)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
dependencies = [
|
|
13
|
+
"mcp>=1.0,<2",
|
|
14
|
+
"httpx>=0.27",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
djcrud-client = "djcrud_client.__main__:main"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools]
|
|
21
|
+
package-dir = {"" = "src"}
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
25
|
+
|
|
26
|
+
[tool.setuptools_scm]
|
|
27
|
+
root = ".."
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
python_files = ["test_*.py"]
|
|
32
|
+
python_functions = ["test_*"]
|
|
33
|
+
addopts = ["-ra", "--strict-markers"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Stdio MCP bridge for djcrud JSON APIs (no Django required)."""
|
|
2
|
+
|
|
3
|
+
from .api import CrudApi, login
|
|
4
|
+
from .config import get_base_url, get_registry_key, get_token
|
|
5
|
+
from .profile import McpProfile, profile_meta
|
|
6
|
+
from .schema import all_tools_for_profile, build_tools_for_profile, build_tools_from_schema
|
|
7
|
+
from .server import create_mcp_server, fetch_schema, run_stdio
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"CrudApi",
|
|
11
|
+
"McpProfile",
|
|
12
|
+
"all_tools_for_profile",
|
|
13
|
+
"build_tools_for_profile",
|
|
14
|
+
"build_tools_from_schema",
|
|
15
|
+
"create_mcp_server",
|
|
16
|
+
"fetch_schema",
|
|
17
|
+
"get_base_url",
|
|
18
|
+
"get_registry_key",
|
|
19
|
+
"get_token",
|
|
20
|
+
"login",
|
|
21
|
+
"profile_meta",
|
|
22
|
+
"run_stdio",
|
|
23
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from djcrud_client.api import CrudApi, login
|
|
9
|
+
from djcrud_client.config import get_base_url, get_registry_key, get_token
|
|
10
|
+
from djcrud_client.schema import all_tools_for_profile
|
|
11
|
+
from djcrud_client.server import create_mcp_server, fetch_schema, load_profile
|
|
12
|
+
from djcrud_client.tools import render_path, split_arguments
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_token(
|
|
16
|
+
*,
|
|
17
|
+
base_url: str,
|
|
18
|
+
username: str | None,
|
|
19
|
+
password: str | None,
|
|
20
|
+
) -> str:
|
|
21
|
+
token = get_token()
|
|
22
|
+
if token:
|
|
23
|
+
return token
|
|
24
|
+
user = username or os.environ.get("DJCRUD_USERNAME", "").strip()
|
|
25
|
+
pwd = password or os.environ.get("DJCRUD_PASSWORD", "").strip()
|
|
26
|
+
if not user or not pwd:
|
|
27
|
+
raise SystemExit(
|
|
28
|
+
"Authentication required: set DJCRUD_TOKEN "
|
|
29
|
+
"or provide --user/--password (or DJCRUD_USERNAME/DJCRUD_PASSWORD)."
|
|
30
|
+
)
|
|
31
|
+
return login(base_url=base_url, username=user, password=pwd)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def call_tool(
|
|
35
|
+
*,
|
|
36
|
+
tool_name: str,
|
|
37
|
+
arguments: dict,
|
|
38
|
+
base_url: str,
|
|
39
|
+
token: str,
|
|
40
|
+
registry: str,
|
|
41
|
+
) -> None:
|
|
42
|
+
profile = load_profile(registry, base_url=base_url)
|
|
43
|
+
schema = fetch_schema(base_url=base_url)
|
|
44
|
+
tools = all_tools_for_profile(schema, profile)
|
|
45
|
+
tool = next((entry for entry in tools if entry["name"] == tool_name), None)
|
|
46
|
+
if tool is None:
|
|
47
|
+
raise SystemExit(f"Unknown tool: {tool_name}")
|
|
48
|
+
|
|
49
|
+
api = CrudApi(base_url=base_url, token=token)
|
|
50
|
+
path_args, body = split_arguments(
|
|
51
|
+
tool["path"], tool.get("operation", {}), arguments
|
|
52
|
+
)
|
|
53
|
+
rendered = render_path(tool["path"], path_args)
|
|
54
|
+
if "?" not in rendered and not rendered.endswith("/"):
|
|
55
|
+
rendered = f"{rendered}/"
|
|
56
|
+
response = api.request(tool["method"], rendered, json_body=body)
|
|
57
|
+
print(response.text)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main(argv: list[str] | None = None) -> None:
|
|
61
|
+
parser = argparse.ArgumentParser(prog="djcrud-client")
|
|
62
|
+
parser.add_argument("-mcp", action="store_true", help="Run stdio MCP server")
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--registry",
|
|
65
|
+
default=None,
|
|
66
|
+
metavar="KEY",
|
|
67
|
+
help="Profile key (default: host default from GET /api/mcp/profiles/)",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument("--call", metavar="TOOL", help="Call one tool and exit")
|
|
70
|
+
parser.add_argument("--json", default="{}", help="Tool arguments as JSON object")
|
|
71
|
+
parser.add_argument("--user", help="Username for /api/login/")
|
|
72
|
+
parser.add_argument("--password", help="Password for /api/login/")
|
|
73
|
+
args = parser.parse_args(argv)
|
|
74
|
+
|
|
75
|
+
base_url = get_base_url()
|
|
76
|
+
registry = args.registry or get_registry_key(base_url=base_url)
|
|
77
|
+
|
|
78
|
+
if args.call:
|
|
79
|
+
token = resolve_token(
|
|
80
|
+
base_url=base_url,
|
|
81
|
+
username=args.user,
|
|
82
|
+
password=args.password,
|
|
83
|
+
)
|
|
84
|
+
arguments = json.loads(args.json)
|
|
85
|
+
call_tool(
|
|
86
|
+
tool_name=args.call,
|
|
87
|
+
arguments=arguments,
|
|
88
|
+
base_url=base_url,
|
|
89
|
+
token=token,
|
|
90
|
+
registry=registry,
|
|
91
|
+
)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
token = resolve_token(
|
|
95
|
+
base_url=base_url,
|
|
96
|
+
username=args.user,
|
|
97
|
+
password=args.password,
|
|
98
|
+
)
|
|
99
|
+
create_mcp_server(
|
|
100
|
+
base_url=base_url,
|
|
101
|
+
token=token,
|
|
102
|
+
registry=registry,
|
|
103
|
+
).run(transport="stdio")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main(sys.argv[1:])
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .profile import McpProfile
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def login(*, base_url: str, username: str, password: str, timeout: float = 30.0) -> str:
|
|
11
|
+
url = f"{base_url.rstrip('/')}/api/login/"
|
|
12
|
+
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
13
|
+
response = client.post(
|
|
14
|
+
url,
|
|
15
|
+
json={"username": username, "password": password},
|
|
16
|
+
headers={"Accept": "application/json"},
|
|
17
|
+
)
|
|
18
|
+
response.raise_for_status()
|
|
19
|
+
payload = response.json()
|
|
20
|
+
token = payload.get("token")
|
|
21
|
+
if not isinstance(token, str) or not token:
|
|
22
|
+
raise ValueError("login response missing token")
|
|
23
|
+
return token
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CrudApi:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
base_url: str,
|
|
31
|
+
token: str,
|
|
32
|
+
timeout: float = 30.0,
|
|
33
|
+
extra_headers: dict[str, str] | Callable[[], dict[str, str]] | None = None,
|
|
34
|
+
):
|
|
35
|
+
self.base_url = base_url.rstrip("/")
|
|
36
|
+
self.token = token
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
self._extra_headers = extra_headers
|
|
39
|
+
|
|
40
|
+
def _resolved_extra_headers(self) -> dict[str, str]:
|
|
41
|
+
if self._extra_headers is None:
|
|
42
|
+
return {}
|
|
43
|
+
if callable(self._extra_headers):
|
|
44
|
+
return dict(self._extra_headers())
|
|
45
|
+
return dict(self._extra_headers)
|
|
46
|
+
|
|
47
|
+
def _headers(self, accept_json: bool = True) -> dict[str, str]:
|
|
48
|
+
headers = {"Authorization": f"Bearer {self.token}"}
|
|
49
|
+
if accept_json:
|
|
50
|
+
headers["Accept"] = "application/json"
|
|
51
|
+
headers.update(self._resolved_extra_headers())
|
|
52
|
+
return headers
|
|
53
|
+
|
|
54
|
+
def request(
|
|
55
|
+
self,
|
|
56
|
+
method: str,
|
|
57
|
+
path: str,
|
|
58
|
+
*,
|
|
59
|
+
params: dict[str, Any] | None = None,
|
|
60
|
+
json_body: dict[str, Any] | None = None,
|
|
61
|
+
timeout: float | None = None,
|
|
62
|
+
) -> httpx.Response:
|
|
63
|
+
url = f"{self.base_url}{path}"
|
|
64
|
+
effective_timeout = self.timeout if timeout is None else timeout
|
|
65
|
+
with httpx.Client(timeout=effective_timeout, follow_redirects=True) as client:
|
|
66
|
+
return client.request(
|
|
67
|
+
method.upper(),
|
|
68
|
+
url,
|
|
69
|
+
headers=self._headers(),
|
|
70
|
+
params=params,
|
|
71
|
+
json=json_body,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def fetch_schema(self) -> dict[str, Any]:
|
|
75
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
76
|
+
response = client.get(
|
|
77
|
+
f"{self.base_url}/api/schema/",
|
|
78
|
+
headers={"Accept": "application/json"},
|
|
79
|
+
)
|
|
80
|
+
response.raise_for_status()
|
|
81
|
+
return response.json()
|
|
82
|
+
|
|
83
|
+
def fetch_json(self, path: str) -> Any:
|
|
84
|
+
with httpx.Client(timeout=self.timeout, follow_redirects=True) as client:
|
|
85
|
+
response = client.get(
|
|
86
|
+
f"{self.base_url}{path}",
|
|
87
|
+
headers={"Accept": "application/json"},
|
|
88
|
+
)
|
|
89
|
+
response.raise_for_status()
|
|
90
|
+
return response.json()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def fetch_profile_catalog(*, base_url: str) -> tuple[list[str], str | None]:
|
|
94
|
+
payload = CrudApi(base_url=base_url, token="").fetch_json("/api/mcp/profiles/")
|
|
95
|
+
if isinstance(payload, dict):
|
|
96
|
+
keys = payload.get("profiles", payload.get("keys", []))
|
|
97
|
+
default = payload.get("default")
|
|
98
|
+
else:
|
|
99
|
+
keys = payload
|
|
100
|
+
default = None
|
|
101
|
+
profiles = [str(key).strip().lower() for key in keys]
|
|
102
|
+
default_key = (
|
|
103
|
+
str(default).strip().lower()
|
|
104
|
+
if isinstance(default, str) and default.strip()
|
|
105
|
+
else None
|
|
106
|
+
)
|
|
107
|
+
return profiles, default_key
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def list_profiles(*, base_url: str) -> list[str]:
|
|
111
|
+
profiles, _default = fetch_profile_catalog(base_url=base_url)
|
|
112
|
+
return profiles
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def resolve_registry_key(*, base_url: str, explicit: str | None = None) -> str:
|
|
116
|
+
if explicit and explicit.strip():
|
|
117
|
+
return explicit.strip().lower()
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
profiles, default_key = fetch_profile_catalog(base_url=base_url)
|
|
121
|
+
if default_key:
|
|
122
|
+
return default_key
|
|
123
|
+
if len(profiles) == 1:
|
|
124
|
+
return profiles[0]
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
return "default"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def fetch_profile(*, base_url: str, key: str) -> McpProfile:
|
|
132
|
+
normalized = key.strip().lower()
|
|
133
|
+
payload = CrudApi(base_url=base_url, token="").fetch_json(
|
|
134
|
+
f"/api/mcp/profiles/{normalized}/"
|
|
135
|
+
)
|
|
136
|
+
return McpProfile.from_dict(payload)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def fetch_viewsets(*, base_url: str) -> list[dict[str, str]]:
|
|
140
|
+
payload = CrudApi(base_url=base_url, token="").fetch_json("/api/mcp/viewsets/")
|
|
141
|
+
if isinstance(payload, dict):
|
|
142
|
+
entries = payload.get("viewsets", [])
|
|
143
|
+
else:
|
|
144
|
+
entries = payload
|
|
145
|
+
return [
|
|
146
|
+
{
|
|
147
|
+
"model": str(entry["model"]),
|
|
148
|
+
"prefix": str(entry["prefix"]),
|
|
149
|
+
}
|
|
150
|
+
for entry in entries
|
|
151
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from .api import resolve_registry_key
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _first_env(*keys: str) -> str:
|
|
9
|
+
for key in keys:
|
|
10
|
+
value = os.environ.get(key, "").strip()
|
|
11
|
+
if value:
|
|
12
|
+
return value
|
|
13
|
+
return ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_base_url() -> str:
|
|
17
|
+
value = _first_env("DJCRUD_BASE_URL", "DJMVC_BASE_URL", "TILDETTE_BASE_URL")
|
|
18
|
+
return (value or "http://127.0.0.1:8000").rstrip("/")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_token() -> str:
|
|
22
|
+
return _first_env("DJCRUD_TOKEN", "DJMVC_TOKEN", "TILDETTE_TOKEN")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_registry_key(*, base_url: str | None = None) -> str:
|
|
26
|
+
"""Host default profile key from ``GET /api/mcp/profiles/`` (not an env var)."""
|
|
27
|
+
return resolve_registry_key(base_url=(base_url or get_base_url()).rstrip("/"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_profile_from_env():
|
|
31
|
+
from .server import load_profile
|
|
32
|
+
|
|
33
|
+
base_url = get_base_url()
|
|
34
|
+
return load_profile(get_registry_key(base_url=base_url), base_url=base_url)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class McpProfile:
|
|
7
|
+
"""Wire-format MCP profile fetched from ``GET /api/mcp/profiles/{key}/``."""
|
|
8
|
+
|
|
9
|
+
key: str
|
|
10
|
+
server_name: str
|
|
11
|
+
instructions: str
|
|
12
|
+
info_tool_name: str
|
|
13
|
+
api_prefixes: tuple[str, ...]
|
|
14
|
+
meta: dict[str, Any]
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self.key = ""
|
|
18
|
+
self.server_name = ""
|
|
19
|
+
self.instructions = ""
|
|
20
|
+
self.info_tool_name = ""
|
|
21
|
+
self.api_prefixes = ()
|
|
22
|
+
self.meta = {}
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_dict(cls, payload: dict[str, Any]) -> McpProfile:
|
|
26
|
+
profile = cls()
|
|
27
|
+
profile.key = str(payload["key"])
|
|
28
|
+
profile.server_name = str(payload["server_name"])
|
|
29
|
+
profile.instructions = str(payload["instructions"])
|
|
30
|
+
profile.info_tool_name = str(payload["info_tool_name"])
|
|
31
|
+
profile.api_prefixes = tuple(payload.get("api_prefixes", ()))
|
|
32
|
+
profile.meta = dict(payload.get("meta", {}))
|
|
33
|
+
return profile
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
return {
|
|
37
|
+
"key": self.key,
|
|
38
|
+
"server_name": self.server_name,
|
|
39
|
+
"instructions": self.instructions,
|
|
40
|
+
"info_tool_name": self.info_tool_name,
|
|
41
|
+
"api_prefixes": list(self.api_prefixes),
|
|
42
|
+
"meta": dict(self.meta),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
def __eq__(self, other: object) -> bool:
|
|
46
|
+
if not isinstance(other, McpProfile):
|
|
47
|
+
return NotImplemented
|
|
48
|
+
return self.to_dict() == other.to_dict()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def profile_meta(profile: McpProfile) -> dict[str, Any]:
|
|
52
|
+
meta = dict(profile.meta)
|
|
53
|
+
if profile.api_prefixes:
|
|
54
|
+
meta.setdefault("api_prefixes", list(profile.api_prefixes))
|
|
55
|
+
return meta
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .profile import McpProfile
|
|
6
|
+
from .tools import build_tool_definition, infer_action
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def prefix_map_from_profile(profile: McpProfile) -> dict[str, str]:
|
|
10
|
+
"""Build ``{model_name: api_prefix}`` from host-published ``api_prefixes``."""
|
|
11
|
+
result: dict[str, str] = {}
|
|
12
|
+
for prefix in profile.api_prefixes:
|
|
13
|
+
model_name = prefix.rstrip("/").split("/")[-1]
|
|
14
|
+
normalized = prefix if prefix.endswith("/") else prefix + "/"
|
|
15
|
+
result[model_name] = normalized
|
|
16
|
+
return result
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_tools_for_profile(
|
|
20
|
+
schema: dict[str, Any],
|
|
21
|
+
profile: McpProfile,
|
|
22
|
+
) -> list[dict[str, Any]]:
|
|
23
|
+
return _build_tools_from_prefix_map(schema, prefix_map_from_profile(profile))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def all_tools_for_profile(
|
|
27
|
+
schema: dict[str, Any],
|
|
28
|
+
profile: McpProfile,
|
|
29
|
+
) -> list[dict[str, Any]]:
|
|
30
|
+
return build_tools_for_profile(schema, profile)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_tools_from_schema(
|
|
34
|
+
schema: dict[str, Any],
|
|
35
|
+
*,
|
|
36
|
+
prefixes: dict[str, str],
|
|
37
|
+
) -> list[dict[str, Any]]:
|
|
38
|
+
return _build_tools_from_prefix_map(schema, prefixes)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _build_tools_from_prefix_map(
|
|
42
|
+
schema: dict[str, Any],
|
|
43
|
+
prefixes: dict[str, str],
|
|
44
|
+
) -> list[dict[str, Any]]:
|
|
45
|
+
by_name: dict[str, dict[str, Any]] = {}
|
|
46
|
+
paths: dict[str, Any] = {}
|
|
47
|
+
for path, operations in schema.get("paths", {}).items():
|
|
48
|
+
normalized = path.rstrip("/")
|
|
49
|
+
if any(
|
|
50
|
+
normalized == pfx.rstrip("/") or normalized.startswith(pfx.rstrip("/") + "/")
|
|
51
|
+
for pfx in prefixes.values()
|
|
52
|
+
):
|
|
53
|
+
paths[path] = operations
|
|
54
|
+
|
|
55
|
+
for path, operations in paths.items():
|
|
56
|
+
for method, operation in operations.items():
|
|
57
|
+
if method.startswith("x-"):
|
|
58
|
+
continue
|
|
59
|
+
model_name = _model_for_path(path, prefixes)
|
|
60
|
+
if model_name is None:
|
|
61
|
+
continue
|
|
62
|
+
action = infer_action(
|
|
63
|
+
path=path,
|
|
64
|
+
method=method,
|
|
65
|
+
api_prefix=prefixes[model_name],
|
|
66
|
+
model_name=model_name,
|
|
67
|
+
operation=operation,
|
|
68
|
+
)
|
|
69
|
+
if not action:
|
|
70
|
+
continue
|
|
71
|
+
tool = build_tool_definition(
|
|
72
|
+
path=path,
|
|
73
|
+
method=method,
|
|
74
|
+
operation=operation,
|
|
75
|
+
model_name=model_name,
|
|
76
|
+
action=action,
|
|
77
|
+
)
|
|
78
|
+
existing = by_name.get(tool["name"])
|
|
79
|
+
if existing is None or method == "patch":
|
|
80
|
+
by_name[tool["name"]] = tool
|
|
81
|
+
return list(by_name.values())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _model_for_path(path: str, prefixes: dict[str, str]) -> str | None:
|
|
85
|
+
normalized = path.rstrip("/") + "/"
|
|
86
|
+
matches = [
|
|
87
|
+
model_name
|
|
88
|
+
for model_name, prefix in prefixes.items()
|
|
89
|
+
if normalized.startswith(prefix.rstrip("/") + "/") or normalized == prefix
|
|
90
|
+
]
|
|
91
|
+
if not matches:
|
|
92
|
+
return None
|
|
93
|
+
return min(matches, key=len)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from .api import CrudApi, fetch_profile
|
|
9
|
+
from .config import get_base_url, get_registry_key, get_token
|
|
10
|
+
from .profile import McpProfile, profile_meta
|
|
11
|
+
from .schema import build_tools_for_profile
|
|
12
|
+
from .tools import render_path, split_arguments
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fetch_schema(*, base_url: str) -> dict[str, Any]:
|
|
16
|
+
return CrudApi(base_url=base_url, token="").fetch_schema()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_profile(key: str, *, base_url: str) -> McpProfile:
|
|
20
|
+
return fetch_profile(base_url=base_url, key=key)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_mcp_server(
|
|
24
|
+
*,
|
|
25
|
+
base_url: str | None = None,
|
|
26
|
+
token: str | None = None,
|
|
27
|
+
profile: McpProfile | str | None = None,
|
|
28
|
+
registry: str | None = None,
|
|
29
|
+
extra_headers: dict[str, str] | None = None,
|
|
30
|
+
) -> FastMCP:
|
|
31
|
+
base_url = (base_url or get_base_url()).rstrip("/")
|
|
32
|
+
registry_key = (
|
|
33
|
+
profile
|
|
34
|
+
if isinstance(profile, str)
|
|
35
|
+
else (registry or get_registry_key(base_url=base_url))
|
|
36
|
+
)
|
|
37
|
+
if isinstance(profile, str) or profile is None:
|
|
38
|
+
profile = load_profile(str(registry_key), base_url=base_url)
|
|
39
|
+
token = token if token is not None else get_token()
|
|
40
|
+
schema = fetch_schema(base_url=base_url)
|
|
41
|
+
api = CrudApi(base_url=base_url, token=token, extra_headers=extra_headers)
|
|
42
|
+
mcp = FastMCP(profile.server_name, instructions=profile.instructions)
|
|
43
|
+
|
|
44
|
+
def registry_info() -> str:
|
|
45
|
+
return json.dumps(profile_meta(profile), indent=2)
|
|
46
|
+
|
|
47
|
+
registry_info.__name__ = profile.info_tool_name
|
|
48
|
+
mcp.add_tool(
|
|
49
|
+
registry_info,
|
|
50
|
+
name=profile.info_tool_name,
|
|
51
|
+
description=profile.instructions,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
for tool in build_tools_for_profile(schema, profile):
|
|
55
|
+
_register_tool(mcp, api, tool)
|
|
56
|
+
|
|
57
|
+
return mcp
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _register_tool(mcp: FastMCP, api: CrudApi, tool: dict[str, Any]) -> None:
|
|
61
|
+
path = tool["path"]
|
|
62
|
+
method = tool["method"]
|
|
63
|
+
operation = tool.get("operation", {})
|
|
64
|
+
|
|
65
|
+
def handler(**arguments: Any) -> str:
|
|
66
|
+
if set(arguments.keys()) == {"arguments"} and isinstance(
|
|
67
|
+
arguments["arguments"], dict
|
|
68
|
+
):
|
|
69
|
+
arguments = arguments["arguments"]
|
|
70
|
+
path_args, body = split_arguments(path, operation, arguments)
|
|
71
|
+
rendered = render_path(path, path_args)
|
|
72
|
+
if "?" not in rendered and not rendered.endswith("/"):
|
|
73
|
+
rendered = f"{rendered}/"
|
|
74
|
+
response = api.request(method, rendered, json_body=body)
|
|
75
|
+
try:
|
|
76
|
+
payload: Any = response.json()
|
|
77
|
+
except Exception:
|
|
78
|
+
payload = {"status_code": response.status_code, "text": response.text}
|
|
79
|
+
return json.dumps(payload, indent=2)
|
|
80
|
+
|
|
81
|
+
handler.__name__ = tool["name"]
|
|
82
|
+
handler.__doc__ = tool["description"]
|
|
83
|
+
mcp.add_tool(
|
|
84
|
+
handler,
|
|
85
|
+
name=tool["name"],
|
|
86
|
+
description=tool["description"],
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def run_stdio(*, registry: str | None = None) -> None:
|
|
91
|
+
create_mcp_server(registry=registry).run(transport="stdio")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
STANDARD_ACTIONS = (
|
|
6
|
+
"partial_update",
|
|
7
|
+
"destroy",
|
|
8
|
+
"retrieve",
|
|
9
|
+
"update",
|
|
10
|
+
"create",
|
|
11
|
+
"list",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
METHOD_DEFAULT_ACTION = {
|
|
15
|
+
"get": "list",
|
|
16
|
+
"post": "create",
|
|
17
|
+
"put": "update",
|
|
18
|
+
"patch": "partial_update",
|
|
19
|
+
"delete": "destroy",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def tool_name(model_name: str, action: str) -> str:
|
|
24
|
+
return f"{model_name.lower()}_{action}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_METHOD_SUFFIXES = (
|
|
28
|
+
"_partial_update",
|
|
29
|
+
"_destroy",
|
|
30
|
+
"_retrieve",
|
|
31
|
+
"_update",
|
|
32
|
+
"_create",
|
|
33
|
+
"_list",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_operation_id(operation_id: str, *, model_name: str) -> str | None:
|
|
38
|
+
prefix = f"{model_name}_"
|
|
39
|
+
if not operation_id.startswith(prefix):
|
|
40
|
+
return None
|
|
41
|
+
suffix = operation_id[len(prefix) :]
|
|
42
|
+
if suffix in STANDARD_ACTIONS:
|
|
43
|
+
return suffix
|
|
44
|
+
for method_suffix in _METHOD_SUFFIXES:
|
|
45
|
+
if suffix.endswith(method_suffix):
|
|
46
|
+
action = suffix[: -len(method_suffix)]
|
|
47
|
+
if action:
|
|
48
|
+
return action
|
|
49
|
+
return suffix
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def infer_action(
|
|
53
|
+
*,
|
|
54
|
+
path: str,
|
|
55
|
+
method: str,
|
|
56
|
+
api_prefix: str,
|
|
57
|
+
model_name: str,
|
|
58
|
+
operation: dict[str, Any],
|
|
59
|
+
) -> str | None:
|
|
60
|
+
operation_id = operation.get("operationId") or ""
|
|
61
|
+
parsed = parse_operation_id(operation_id, model_name=model_name)
|
|
62
|
+
if parsed:
|
|
63
|
+
return parsed
|
|
64
|
+
|
|
65
|
+
normalized_path = path.rstrip("/") + "/"
|
|
66
|
+
normalized_prefix = api_prefix.rstrip("/") + "/"
|
|
67
|
+
if not normalized_path.startswith(normalized_prefix):
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
relative = normalized_path[len(normalized_prefix) :]
|
|
71
|
+
method = method.lower()
|
|
72
|
+
|
|
73
|
+
if not relative:
|
|
74
|
+
return METHOD_DEFAULT_ACTION.get(method)
|
|
75
|
+
|
|
76
|
+
segments = [segment for segment in relative.split("/") if segment]
|
|
77
|
+
if not segments:
|
|
78
|
+
return METHOD_DEFAULT_ACTION.get(method)
|
|
79
|
+
|
|
80
|
+
if segments[0].startswith("{"):
|
|
81
|
+
if len(segments) == 1:
|
|
82
|
+
return METHOD_DEFAULT_ACTION.get(method)
|
|
83
|
+
return segments[1].rstrip("/")
|
|
84
|
+
|
|
85
|
+
return segments[0].rstrip("/")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def render_path(path: str, arguments: dict[str, Any]) -> str:
|
|
89
|
+
rendered = path
|
|
90
|
+
for key, value in arguments.items():
|
|
91
|
+
rendered = rendered.replace("{" + key + "}", str(value))
|
|
92
|
+
return rendered
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _path_parameters(operation: dict[str, Any]) -> dict[str, Any]:
|
|
96
|
+
properties: dict[str, Any] = {}
|
|
97
|
+
for parameter in operation.get("parameters", []):
|
|
98
|
+
if parameter.get("in") == "path":
|
|
99
|
+
schema = parameter.get("schema", {})
|
|
100
|
+
properties[parameter["name"]] = {
|
|
101
|
+
"type": schema.get("type", "string"),
|
|
102
|
+
}
|
|
103
|
+
return properties
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _body_properties(operation: dict[str, Any]) -> dict[str, Any]:
|
|
107
|
+
for parameter in operation.get("parameters", []):
|
|
108
|
+
if parameter.get("in") == "body":
|
|
109
|
+
schema = parameter.get("schema", {})
|
|
110
|
+
return schema.get("properties", {})
|
|
111
|
+
|
|
112
|
+
request_body = operation.get("requestBody") or {}
|
|
113
|
+
for media in (request_body.get("content") or {}).values():
|
|
114
|
+
schema = media.get("schema") or {}
|
|
115
|
+
return schema.get("properties", {})
|
|
116
|
+
return {}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _body_required(operation: dict[str, Any]) -> list[str]:
|
|
120
|
+
for parameter in operation.get("parameters", []):
|
|
121
|
+
if parameter.get("in") == "body":
|
|
122
|
+
schema = parameter.get("schema", {})
|
|
123
|
+
return list(schema.get("required", []))
|
|
124
|
+
|
|
125
|
+
request_body = operation.get("requestBody") or {}
|
|
126
|
+
for media in (request_body.get("content") or {}).values():
|
|
127
|
+
schema = media.get("schema") or {}
|
|
128
|
+
return list(schema.get("required", []))
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def split_arguments(
|
|
133
|
+
path: str,
|
|
134
|
+
operation: dict[str, Any],
|
|
135
|
+
arguments: dict[str, Any],
|
|
136
|
+
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
|
137
|
+
path_args = {
|
|
138
|
+
key: value for key, value in arguments.items() if "{" + key + "}" in path
|
|
139
|
+
}
|
|
140
|
+
body_keys = set(_body_properties(operation))
|
|
141
|
+
body = {key: value for key, value in arguments.items() if key in body_keys}
|
|
142
|
+
return path_args, body or None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_tool_definition(
|
|
146
|
+
*,
|
|
147
|
+
path: str,
|
|
148
|
+
method: str,
|
|
149
|
+
operation: dict[str, Any],
|
|
150
|
+
model_name: str,
|
|
151
|
+
action: str,
|
|
152
|
+
) -> dict[str, Any]:
|
|
153
|
+
properties: dict[str, Any] = {}
|
|
154
|
+
properties.update(_path_parameters(operation))
|
|
155
|
+
properties.update(_body_properties(operation))
|
|
156
|
+
required = [name for name in properties if "{" + name + "}" in path]
|
|
157
|
+
required.extend(
|
|
158
|
+
name for name in _body_required(operation) if name not in required
|
|
159
|
+
)
|
|
160
|
+
return {
|
|
161
|
+
"name": tool_name(model_name, action),
|
|
162
|
+
"description": operation.get("summary") or f"{method.upper()} {path}",
|
|
163
|
+
"method": method.lower(),
|
|
164
|
+
"path": path,
|
|
165
|
+
"operation": operation,
|
|
166
|
+
"input_schema": {
|
|
167
|
+
"type": "object",
|
|
168
|
+
"properties": properties,
|
|
169
|
+
"required": required,
|
|
170
|
+
},
|
|
171
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: djcrud-client
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Stdio MCP bridge for djcrud JSON APIs (no Django required)
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: mcp<2,>=1.0
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
|
|
11
|
+
# djcrud-client
|
|
12
|
+
|
|
13
|
+
Stdio MCP bridge for [djcrud](https://github.com/yourlabs/djcrud) JSON APIs. No Django required in the subprocess — FastMCP runs here, not on the Django host.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install djcrud-client
|
|
19
|
+
djcrud-client -mcp
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Host setup
|
|
23
|
+
|
|
24
|
+
Declare `McpProfile` classes in your app's `djcrud.py` and register them on `djcrud_mcp.site` (see `djcrud_example/mcp_example/djcrud.py` in the djcrud repo).
|
|
25
|
+
|
|
26
|
+
Add `djcrud_mcp` to `INSTALLED_APPS` and include `djcrud_drf.site` URLs on the host. Register one `McpProfile` in your app's `djcrud.py` (see `mcp_example/djcrud.py`). Run `djcrud-client -mcp` with `DJCRUD_TOKEN` set.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/djcrud_client/__init__.py
|
|
4
|
+
src/djcrud_client/__main__.py
|
|
5
|
+
src/djcrud_client/api.py
|
|
6
|
+
src/djcrud_client/config.py
|
|
7
|
+
src/djcrud_client/profile.py
|
|
8
|
+
src/djcrud_client/schema.py
|
|
9
|
+
src/djcrud_client/server.py
|
|
10
|
+
src/djcrud_client/tools.py
|
|
11
|
+
src/djcrud_client.egg-info/PKG-INFO
|
|
12
|
+
src/djcrud_client.egg-info/SOURCES.txt
|
|
13
|
+
src/djcrud_client.egg-info/dependency_links.txt
|
|
14
|
+
src/djcrud_client.egg-info/entry_points.txt
|
|
15
|
+
src/djcrud_client.egg-info/requires.txt
|
|
16
|
+
src/djcrud_client.egg-info/scm_file_list.json
|
|
17
|
+
src/djcrud_client.egg-info/scm_version.json
|
|
18
|
+
src/djcrud_client.egg-info/top_level.txt
|
|
19
|
+
tests/test_config.py
|
|
20
|
+
tests/test_resolve_registry.py
|
|
21
|
+
tests/test_standalone.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": [
|
|
3
|
+
"README.md",
|
|
4
|
+
"pyproject.toml",
|
|
5
|
+
"tests/test_config.py",
|
|
6
|
+
"tests/test_resolve_registry.py",
|
|
7
|
+
"tests/test_standalone.py",
|
|
8
|
+
"src/djcrud_client/server.py",
|
|
9
|
+
"src/djcrud_client/tools.py",
|
|
10
|
+
"src/djcrud_client/config.py",
|
|
11
|
+
"src/djcrud_client/__init__.py",
|
|
12
|
+
"src/djcrud_client/profile.py",
|
|
13
|
+
"src/djcrud_client/schema.py",
|
|
14
|
+
"src/djcrud_client/__main__.py",
|
|
15
|
+
"src/djcrud_client/api.py"
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
djcrud_client
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Tests for djcrud_client.config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from djcrud_client.config import get_registry_key
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_get_registry_key_ignores_registry_env_var(monkeypatch):
|
|
11
|
+
monkeypatch.setenv("DJCRUD_MCP_REGISTRY", "ignored")
|
|
12
|
+
monkeypatch.setenv("TILDETTE_MCP_REGISTRY", "also-ignored")
|
|
13
|
+
with patch(
|
|
14
|
+
"djcrud_client.api.fetch_profile_catalog",
|
|
15
|
+
return_value=(["tasks", "mcp"], "tasks"),
|
|
16
|
+
):
|
|
17
|
+
assert get_registry_key(base_url="http://example.test") == "tasks"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Tests for host-published default MCP registry resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from djcrud_client.api import resolve_registry_key
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_resolve_registry_key_prefers_explicit():
|
|
11
|
+
key = resolve_registry_key(
|
|
12
|
+
base_url="http://example.test",
|
|
13
|
+
explicit="custom",
|
|
14
|
+
)
|
|
15
|
+
assert key == "custom"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_resolve_registry_key_uses_host_default():
|
|
19
|
+
with patch(
|
|
20
|
+
"djcrud_client.api.fetch_profile_catalog",
|
|
21
|
+
return_value=(["tasks", "mcp"], "tasks"),
|
|
22
|
+
):
|
|
23
|
+
key = resolve_registry_key(base_url="http://example.test")
|
|
24
|
+
assert key == "tasks"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_resolve_registry_key_uses_single_profile():
|
|
28
|
+
with patch(
|
|
29
|
+
"djcrud_client.api.fetch_profile_catalog",
|
|
30
|
+
return_value=(["articles"], None),
|
|
31
|
+
):
|
|
32
|
+
key = resolve_registry_key(base_url="http://example.test")
|
|
33
|
+
assert key == "articles"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_resolve_registry_key_falls_back_to_default():
|
|
37
|
+
with patch(
|
|
38
|
+
"djcrud_client.api.fetch_profile_catalog",
|
|
39
|
+
side_effect=OSError("offline"),
|
|
40
|
+
):
|
|
41
|
+
key = resolve_registry_key(base_url="http://example.test")
|
|
42
|
+
assert key == "default"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Tests for djcrud-client without Django."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
SAMPLE_SCHEMA = {
|
|
12
|
+
"paths": {
|
|
13
|
+
"/api/product/": {
|
|
14
|
+
"get": {
|
|
15
|
+
"operationId": "product_list",
|
|
16
|
+
"summary": "List products",
|
|
17
|
+
"parameters": [],
|
|
18
|
+
},
|
|
19
|
+
"post": {
|
|
20
|
+
"operationId": "product_create",
|
|
21
|
+
"summary": "Create product",
|
|
22
|
+
"requestBody": {
|
|
23
|
+
"content": {
|
|
24
|
+
"application/json": {
|
|
25
|
+
"schema": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"properties": {"name": {"type": "string"}},
|
|
28
|
+
"required": ["name"],
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_imports_no_django():
|
|
40
|
+
script = """
|
|
41
|
+
import sys
|
|
42
|
+
for name in list(sys.modules):
|
|
43
|
+
if name.startswith("django"):
|
|
44
|
+
del sys.modules[name]
|
|
45
|
+
import djcrud_client.server
|
|
46
|
+
assert not [name for name in sys.modules if name.startswith("django")]
|
|
47
|
+
"""
|
|
48
|
+
subprocess.run([sys.executable, "-c", script], check=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_build_tools_from_api_prefixes():
|
|
52
|
+
from djcrud_client import McpProfile
|
|
53
|
+
from djcrud_client.schema import build_tools_for_profile
|
|
54
|
+
|
|
55
|
+
profile = McpProfile.from_dict(
|
|
56
|
+
{
|
|
57
|
+
"key": "items",
|
|
58
|
+
"server_name": "test",
|
|
59
|
+
"instructions": "test",
|
|
60
|
+
"info_tool_name": "info",
|
|
61
|
+
"api_prefixes": ["/api/product/"],
|
|
62
|
+
"meta": {},
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
tools = build_tools_for_profile(SAMPLE_SCHEMA, profile)
|
|
66
|
+
names = {tool["name"] for tool in tools}
|
|
67
|
+
assert names == {"product_list", "product_create"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_create_mcp_server_with_api_prefixes():
|
|
71
|
+
from djcrud_client import McpProfile
|
|
72
|
+
from djcrud_client.server import create_mcp_server
|
|
73
|
+
|
|
74
|
+
profile = McpProfile.from_dict(
|
|
75
|
+
{
|
|
76
|
+
"key": "custom",
|
|
77
|
+
"server_name": "test-custom",
|
|
78
|
+
"instructions": "Custom tools.",
|
|
79
|
+
"info_tool_name": "custom_registry_info",
|
|
80
|
+
"api_prefixes": ["/api/product/"],
|
|
81
|
+
"meta": {},
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
with patch("djcrud_client.server.fetch_schema", return_value=SAMPLE_SCHEMA):
|
|
86
|
+
mcp = create_mcp_server(
|
|
87
|
+
base_url="http://testserver",
|
|
88
|
+
token="tok",
|
|
89
|
+
profile=profile,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
tool_names = set(mcp._tool_manager._tools.keys())
|
|
93
|
+
assert "product_list" in tool_names
|
|
94
|
+
assert "custom_registry_info" in tool_names
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_profile_meta_uses_api_prefixes():
|
|
98
|
+
from djcrud_client import McpProfile
|
|
99
|
+
from djcrud_client.profile import profile_meta
|
|
100
|
+
|
|
101
|
+
profile = McpProfile.from_dict(
|
|
102
|
+
{
|
|
103
|
+
"key": "items",
|
|
104
|
+
"server_name": "test",
|
|
105
|
+
"instructions": "test",
|
|
106
|
+
"info_tool_name": "info",
|
|
107
|
+
"api_prefixes": ["/api/product/"],
|
|
108
|
+
"meta": {},
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
meta = profile_meta(profile)
|
|
112
|
+
assert meta["api_prefixes"] == ["/api/product/"]
|
|
113
|
+
assert "viewsets" not in meta
|