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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ djcrud-client = djcrud_client.__main__:main
@@ -0,0 +1,2 @@
1
+ mcp<2,>=1.0
2
+ httpx>=0.27
@@ -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,8 @@
1
+ {
2
+ "tag": "0.3.1",
3
+ "distance": 0,
4
+ "node": "g3d1f991046136ee7e0438776cda491804f21bcfb",
5
+ "dirty": false,
6
+ "branch": "master",
7
+ "node_date": "2026-07-03"
8
+ }
@@ -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