bolt-mcp 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.
Files changed (52) hide show
  1. bolt_mcp-0.1.0/.gitignore +24 -0
  2. bolt_mcp-0.1.0/PKG-INFO +176 -0
  3. bolt_mcp-0.1.0/README.md +154 -0
  4. bolt_mcp-0.1.0/pyproject.toml +50 -0
  5. bolt_mcp-0.1.0/src/bolt_mcp/__init__.py +36 -0
  6. bolt_mcp-0.1.0/src/bolt_mcp/_execute.py +58 -0
  7. bolt_mcp-0.1.0/src/bolt_mcp/autoexpose.py +138 -0
  8. bolt_mcp-0.1.0/src/bolt_mcp/context.py +141 -0
  9. bolt_mcp-0.1.0/src/bolt_mcp/oauth/__init__.py +23 -0
  10. bolt_mcp-0.1.0/src/bolt_mcp/oauth/apps.py +17 -0
  11. bolt_mcp-0.1.0/src/bolt_mcp/oauth/config.py +153 -0
  12. bolt_mcp-0.1.0/src/bolt_mcp/oauth/consent.py +96 -0
  13. bolt_mcp-0.1.0/src/bolt_mcp/oauth/endpoints.py +288 -0
  14. bolt_mcp-0.1.0/src/bolt_mcp/oauth/metadata.py +30 -0
  15. bolt_mcp-0.1.0/src/bolt_mcp/oauth/migrations/0001_initial.py +69 -0
  16. bolt_mcp-0.1.0/src/bolt_mcp/oauth/migrations/__init__.py +0 -0
  17. bolt_mcp-0.1.0/src/bolt_mcp/oauth/models.py +69 -0
  18. bolt_mcp-0.1.0/src/bolt_mcp/oauth/pkce.py +27 -0
  19. bolt_mcp-0.1.0/src/bolt_mcp/oauth/sessions.py +100 -0
  20. bolt_mcp-0.1.0/src/bolt_mcp/oauth/store.py +159 -0
  21. bolt_mcp-0.1.0/src/bolt_mcp/oauth/tokens.py +79 -0
  22. bolt_mcp-0.1.0/src/bolt_mcp/registry.py +62 -0
  23. bolt_mcp-0.1.0/src/bolt_mcp/schema.py +81 -0
  24. bolt_mcp-0.1.0/src/bolt_mcp/server.py +501 -0
  25. bolt_mcp-0.1.0/src/bolt_mcp/sessions.py +80 -0
  26. bolt_mcp-0.1.0/src/bolt_mcp/transport.py +390 -0
  27. bolt_mcp-0.1.0/src/bolt_mcp/types.py +48 -0
  28. bolt_mcp-0.1.0/tests/_helpers.py +189 -0
  29. bolt_mcp-0.1.0/tests/conftest.py +65 -0
  30. bolt_mcp-0.1.0/tests/integration/__init__.py +0 -0
  31. bolt_mcp-0.1.0/tests/integration/conftest.py +57 -0
  32. bolt_mcp-0.1.0/tests/integration/test_mcp_auth_integration.py +50 -0
  33. bolt_mcp-0.1.0/tests/integration/test_mcp_features_integration.py +267 -0
  34. bolt_mcp-0.1.0/tests/integration/test_mcp_oauth_integration.py +269 -0
  35. bolt_mcp-0.1.0/tests/integration/test_mcp_sample_integration.py +114 -0
  36. bolt_mcp-0.1.0/tests/integration/test_mcp_server_integration.py +48 -0
  37. bolt_mcp-0.1.0/tests/integration/test_mcp_sse_integration.py +73 -0
  38. bolt_mcp-0.1.0/tests/test_auth_tier1.py +99 -0
  39. bolt_mcp-0.1.0/tests/test_auth_tier2.py +70 -0
  40. bolt_mcp-0.1.0/tests/test_autoexpose.py +51 -0
  41. bolt_mcp-0.1.0/tests/test_context.py +197 -0
  42. bolt_mcp-0.1.0/tests/test_expose_handlers.py +111 -0
  43. bolt_mcp-0.1.0/tests/test_initialize.py +75 -0
  44. bolt_mcp-0.1.0/tests/test_mount_method.py +69 -0
  45. bolt_mcp-0.1.0/tests/test_negotiation.py +55 -0
  46. bolt_mcp-0.1.0/tests/test_oauth_as.py +468 -0
  47. bolt_mcp-0.1.0/tests/test_resources_prompts.py +136 -0
  48. bolt_mcp-0.1.0/tests/test_schema.py +53 -0
  49. bolt_mcp-0.1.0/tests/test_sessions.py +39 -0
  50. bolt_mcp-0.1.0/tests/test_sse_errors.py +37 -0
  51. bolt_mcp-0.1.0/tests/test_streaming.py +103 -0
  52. bolt_mcp-0.1.0/tests/test_tools_call.py +94 -0
@@ -0,0 +1,24 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+ target/
9
+ # Virtual environments
10
+ .venv
11
+ ignore/
12
+ db.sqlite3
13
+ python/django_bolt/_core.cpython-312-x86_64-linux-gnu.so
14
+ plan.md
15
+ python/django_bolt/_core.abi3.so
16
+ python/django_bolt/_core.pyd
17
+ .vscode/
18
+ # Lock files (not used in this project)
19
+ poetry.lock
20
+ Pipfile.lock
21
+ package-lock.json
22
+ yarn.lock
23
+ pnpm-lock.yaml
24
+ .claude/settings.local.json
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: bolt-mcp
3
+ Version: 0.1.0
4
+ Summary: Build MCP (Model Context Protocol) servers on django-bolt with native Streamable HTTP transport
5
+ Project-URL: Homepage, https://github.com/FarhanAliRaza/django-bolt
6
+ Project-URL: Repository, https://github.com/FarhanAliRaza/django-bolt
7
+ Author-email: Farhan <farhanalirazaazeemi@gmail.com>
8
+ License: MIT
9
+ Keywords: agent,django,django-bolt,llm,mcp,model-context-protocol
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Django
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Internet :: WWW/HTTP
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: django-bolt>=0.8.1
19
+ Requires-Dist: msgspec>=0.20
20
+ Requires-Dist: pyjwt>=2.8
21
+ Description-Content-Type: text/markdown
22
+
23
+ # bolt-mcp
24
+
25
+ Build [MCP (Model Context Protocol)](https://modelcontextprotocol.io) servers on top of
26
+ [django-bolt](https://github.com/FarhanAliRaza/django-bolt), served natively over the MCP
27
+ **Streamable HTTP** transport by django-bolt's Rust pipeline — no Starlette/`mcp`-SDK stack.
28
+
29
+ ```python
30
+ from django_bolt import BoltAPI
31
+ from bolt_mcp import MCP
32
+
33
+ api = BoltAPI()
34
+ mcp = MCP("my-server", "1.0.0")
35
+
36
+
37
+ @mcp.tool
38
+ async def greet(name: str) -> dict:
39
+ """Greet someone by name."""
40
+ return {"greeting": f"Hello, {name}!"}
41
+
42
+
43
+ @mcp.resource("config://app", mime_type="application/json")
44
+ async def app_config() -> str:
45
+ return '{"env": "prod"}'
46
+
47
+
48
+ @mcp.prompt
49
+ async def summarize(topic: str) -> str:
50
+ return f"Please summarize: {topic}"
51
+
52
+
53
+ api.mount_mcp(mcp) # MCP endpoint mounted at /mcp
54
+ ```
55
+
56
+ Point an MCP client (Claude Desktop, MCP Inspector) at `http://<host>/mcp`.
57
+
58
+ ## Transport
59
+
60
+ `mount_mcp` registers `POST`/`GET`/`DELETE` on `/mcp`:
61
+
62
+ - **POST** — JSON-RPC requests. By default every request response is streamed as a finite
63
+ `text/event-stream` message (MCP-SDK-faithful). Use `MCP(json_response=True)` to return a single
64
+ `application/json` object instead — the multi-process-friendly mode.
65
+ - **GET** — opens the long-lived SSE listen channel for server→client messages (one per session).
66
+ - **DELETE** — terminates the session.
67
+
68
+ Sessions are tracked in-process via `Mcp-Session-Id`. **Stateful mode requires a single worker**
69
+ (`runbolt --processes 1`) or sticky sessions; for multiple workers use `MCP(stateless=True)`
70
+ (no GET channel, each POST self-contained).
71
+
72
+ ## Streaming tools: progress, logging, sampling, elicitation
73
+
74
+ A tool that takes a `Context` can stream while it runs: call `ctx.report_progress`/`ctx.info`
75
+ as work advances (those become live notifications on the POST SSE stream), then `return` the
76
+ final result.
77
+
78
+ ```python
79
+ from bolt_mcp import Context
80
+
81
+ @mcp.tool
82
+ async def crunch(n: int, ctx: Context) -> dict:
83
+ for i in range(n):
84
+ await ctx.report_progress(i + 1, n) # → notifications/progress (if client sent a progressToken)
85
+ await ctx.info("working") # → notifications/message
86
+ return {"done": n}
87
+ ```
88
+
89
+ `ctx` is injected by type annotation (excluded from the tool's input schema, like `request`).
90
+ Beyond `report_progress`/`debug`/`info`/`warning`/`error` and `read_resource` (one-way / local),
91
+ the Context can call **back into the client and await a reply**:
92
+
93
+ ```python
94
+ @mcp.tool
95
+ async def assist(text: str, ctx: Context) -> dict:
96
+ summary = await ctx.sample(text) # ask the client's LLM (sampling/createMessage)
97
+ ok = await ctx.elicit("Save this summary?") # ask the user (elicitation/create)
98
+ return {"summary": summary["content"]["text"], "saved": ok["action"] == "accept"}
99
+ ```
100
+
101
+ `sample`/`elicit` are bidirectional: the server sends a request on the POST SSE stream and the
102
+ client replies on a separate POST (correlated by id). They therefore require **stateful streaming**
103
+ (`MCP(stateless=False, json_response=False)`, single worker) and a client that advertises those
104
+ capabilities — otherwise they raise (surfaced as an in-band tool error). `report_progress`/logging
105
+ work in stateless mode too.
106
+
107
+ ## Expose existing endpoints as tools
108
+
109
+ Existing REST routes are **never exposed implicitly** — `api.mount_mcp(mcp)` serves only
110
+ native `@mcp.tool`/`@mcp.resource`/`@mcp.prompt` components. To expose REST routes, list
111
+ their handlers explicitly:
112
+
113
+ ```python
114
+ @api.get("/items/{item_id}")
115
+ async def get_item(item_id: int) -> dict:
116
+ """Fetch an item by id."""
117
+ return {"id": item_id}
118
+
119
+
120
+ api.mount_mcp(mcp, expose=[get_item]) # tool name "get_item", description from the docstring
121
+ ```
122
+
123
+ The tool's name comes from the function name and its description from the route's
124
+ description/docstring — no extra decorator needed. Use `@expose_as_tool(name=..., description=...)`
125
+ only to override those. A handler that isn't a route on `api`, that takes file/form
126
+ parameters, or whose name collides with another tool raises `ValueError` rather than being
127
+ silently dropped or shadowed.
128
+
129
+ Exposure is **per-handler by design**: there is no "expose everything" switch, because a
130
+ marker scattered across the codebase must never silently turn a route into an AI-callable
131
+ tool. For deliberate bulk selection, call `expose_routes(mcp, api, include=[...], methods=(...))`
132
+ explicitly before mounting.
133
+
134
+ ## Authentication
135
+
136
+ **Tier 1 — reuse django-bolt auth** (validated in Rust before the handler):
137
+
138
+ ```python
139
+ from django_bolt import JWTAuthentication, IsAuthenticated
140
+
141
+ api.mount_mcp(mcp, auth=[JWTAuthentication(secret=...)], guards=[IsAuthenticated()])
142
+ ```
143
+
144
+ Per-tool guards: `@mcp.tool(guards=[HasPermission("x")])` — failing tools are filtered from
145
+ `tools/list` and rejected on `tools/call`. Tools may declare `request: Request` to read
146
+ `request.context` (the authenticated principal).
147
+
148
+ **Tier 2 — OAuth 2.1 Resource Server** (RFC 9728 metadata + `WWW-Authenticate` challenge):
149
+
150
+ ```python
151
+ from bolt_mcp import ProtectedResource
152
+
153
+ api.mount_mcp(mcp, oauth=ProtectedResource(
154
+ resource_url="https://api.example.com/mcp",
155
+ authorization_servers=["https://idp.example.com"],
156
+ token_verifier=my_verifier, # (token: str) -> claims | None
157
+ ))
158
+ ```
159
+
160
+ ## Development
161
+
162
+ This package is a uv-workspace member of the django-bolt repo.
163
+
164
+ ```bash
165
+ uv sync # install workspace (editable)
166
+ uv run pytest python/bolt-mcp/tests -s -vv # full suite (incl. subprocess integration)
167
+ ```
168
+
169
+ ## Status / v1 scope
170
+
171
+ Implemented: `initialize`/`ping`, `tools/{list,call}`, `resources/{list,read,templates/list}`,
172
+ `prompts/{list,get}`, Streamable HTTP (POST/GET/DELETE), sessions, both auth tiers, auto-expose,
173
+ and streaming tools (progress/logging/sampling/elicitation) via a tool `Context`.
174
+
175
+ Not yet (v2): `completion/complete`, `logging/setLevel`, resumability (`Last-Event-ID`), and
176
+ Host/Origin DNS-rebinding protection.
@@ -0,0 +1,154 @@
1
+ # bolt-mcp
2
+
3
+ Build [MCP (Model Context Protocol)](https://modelcontextprotocol.io) servers on top of
4
+ [django-bolt](https://github.com/FarhanAliRaza/django-bolt), served natively over the MCP
5
+ **Streamable HTTP** transport by django-bolt's Rust pipeline — no Starlette/`mcp`-SDK stack.
6
+
7
+ ```python
8
+ from django_bolt import BoltAPI
9
+ from bolt_mcp import MCP
10
+
11
+ api = BoltAPI()
12
+ mcp = MCP("my-server", "1.0.0")
13
+
14
+
15
+ @mcp.tool
16
+ async def greet(name: str) -> dict:
17
+ """Greet someone by name."""
18
+ return {"greeting": f"Hello, {name}!"}
19
+
20
+
21
+ @mcp.resource("config://app", mime_type="application/json")
22
+ async def app_config() -> str:
23
+ return '{"env": "prod"}'
24
+
25
+
26
+ @mcp.prompt
27
+ async def summarize(topic: str) -> str:
28
+ return f"Please summarize: {topic}"
29
+
30
+
31
+ api.mount_mcp(mcp) # MCP endpoint mounted at /mcp
32
+ ```
33
+
34
+ Point an MCP client (Claude Desktop, MCP Inspector) at `http://<host>/mcp`.
35
+
36
+ ## Transport
37
+
38
+ `mount_mcp` registers `POST`/`GET`/`DELETE` on `/mcp`:
39
+
40
+ - **POST** — JSON-RPC requests. By default every request response is streamed as a finite
41
+ `text/event-stream` message (MCP-SDK-faithful). Use `MCP(json_response=True)` to return a single
42
+ `application/json` object instead — the multi-process-friendly mode.
43
+ - **GET** — opens the long-lived SSE listen channel for server→client messages (one per session).
44
+ - **DELETE** — terminates the session.
45
+
46
+ Sessions are tracked in-process via `Mcp-Session-Id`. **Stateful mode requires a single worker**
47
+ (`runbolt --processes 1`) or sticky sessions; for multiple workers use `MCP(stateless=True)`
48
+ (no GET channel, each POST self-contained).
49
+
50
+ ## Streaming tools: progress, logging, sampling, elicitation
51
+
52
+ A tool that takes a `Context` can stream while it runs: call `ctx.report_progress`/`ctx.info`
53
+ as work advances (those become live notifications on the POST SSE stream), then `return` the
54
+ final result.
55
+
56
+ ```python
57
+ from bolt_mcp import Context
58
+
59
+ @mcp.tool
60
+ async def crunch(n: int, ctx: Context) -> dict:
61
+ for i in range(n):
62
+ await ctx.report_progress(i + 1, n) # → notifications/progress (if client sent a progressToken)
63
+ await ctx.info("working") # → notifications/message
64
+ return {"done": n}
65
+ ```
66
+
67
+ `ctx` is injected by type annotation (excluded from the tool's input schema, like `request`).
68
+ Beyond `report_progress`/`debug`/`info`/`warning`/`error` and `read_resource` (one-way / local),
69
+ the Context can call **back into the client and await a reply**:
70
+
71
+ ```python
72
+ @mcp.tool
73
+ async def assist(text: str, ctx: Context) -> dict:
74
+ summary = await ctx.sample(text) # ask the client's LLM (sampling/createMessage)
75
+ ok = await ctx.elicit("Save this summary?") # ask the user (elicitation/create)
76
+ return {"summary": summary["content"]["text"], "saved": ok["action"] == "accept"}
77
+ ```
78
+
79
+ `sample`/`elicit` are bidirectional: the server sends a request on the POST SSE stream and the
80
+ client replies on a separate POST (correlated by id). They therefore require **stateful streaming**
81
+ (`MCP(stateless=False, json_response=False)`, single worker) and a client that advertises those
82
+ capabilities — otherwise they raise (surfaced as an in-band tool error). `report_progress`/logging
83
+ work in stateless mode too.
84
+
85
+ ## Expose existing endpoints as tools
86
+
87
+ Existing REST routes are **never exposed implicitly** — `api.mount_mcp(mcp)` serves only
88
+ native `@mcp.tool`/`@mcp.resource`/`@mcp.prompt` components. To expose REST routes, list
89
+ their handlers explicitly:
90
+
91
+ ```python
92
+ @api.get("/items/{item_id}")
93
+ async def get_item(item_id: int) -> dict:
94
+ """Fetch an item by id."""
95
+ return {"id": item_id}
96
+
97
+
98
+ api.mount_mcp(mcp, expose=[get_item]) # tool name "get_item", description from the docstring
99
+ ```
100
+
101
+ The tool's name comes from the function name and its description from the route's
102
+ description/docstring — no extra decorator needed. Use `@expose_as_tool(name=..., description=...)`
103
+ only to override those. A handler that isn't a route on `api`, that takes file/form
104
+ parameters, or whose name collides with another tool raises `ValueError` rather than being
105
+ silently dropped or shadowed.
106
+
107
+ Exposure is **per-handler by design**: there is no "expose everything" switch, because a
108
+ marker scattered across the codebase must never silently turn a route into an AI-callable
109
+ tool. For deliberate bulk selection, call `expose_routes(mcp, api, include=[...], methods=(...))`
110
+ explicitly before mounting.
111
+
112
+ ## Authentication
113
+
114
+ **Tier 1 — reuse django-bolt auth** (validated in Rust before the handler):
115
+
116
+ ```python
117
+ from django_bolt import JWTAuthentication, IsAuthenticated
118
+
119
+ api.mount_mcp(mcp, auth=[JWTAuthentication(secret=...)], guards=[IsAuthenticated()])
120
+ ```
121
+
122
+ Per-tool guards: `@mcp.tool(guards=[HasPermission("x")])` — failing tools are filtered from
123
+ `tools/list` and rejected on `tools/call`. Tools may declare `request: Request` to read
124
+ `request.context` (the authenticated principal).
125
+
126
+ **Tier 2 — OAuth 2.1 Resource Server** (RFC 9728 metadata + `WWW-Authenticate` challenge):
127
+
128
+ ```python
129
+ from bolt_mcp import ProtectedResource
130
+
131
+ api.mount_mcp(mcp, oauth=ProtectedResource(
132
+ resource_url="https://api.example.com/mcp",
133
+ authorization_servers=["https://idp.example.com"],
134
+ token_verifier=my_verifier, # (token: str) -> claims | None
135
+ ))
136
+ ```
137
+
138
+ ## Development
139
+
140
+ This package is a uv-workspace member of the django-bolt repo.
141
+
142
+ ```bash
143
+ uv sync # install workspace (editable)
144
+ uv run pytest python/bolt-mcp/tests -s -vv # full suite (incl. subprocess integration)
145
+ ```
146
+
147
+ ## Status / v1 scope
148
+
149
+ Implemented: `initialize`/`ping`, `tools/{list,call}`, `resources/{list,read,templates/list}`,
150
+ `prompts/{list,get}`, Streamable HTTP (POST/GET/DELETE), sessions, both auth tiers, auto-expose,
151
+ and streaming tools (progress/logging/sampling/elicitation) via a tool `Context`.
152
+
153
+ Not yet (v2): `completion/complete`, `logging/setLevel`, resumability (`Last-Event-ID`), and
154
+ Host/Origin DNS-rebinding protection.
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "bolt-mcp"
3
+ dynamic = ["version"] # derived from the `bolt-mcp-v*` git tag (see [tool.hatch.version])
4
+ description = "Build MCP (Model Context Protocol) servers on django-bolt with native Streamable HTTP transport"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Farhan", email = "farhanalirazaazeemi@gmail.com" }]
9
+ keywords = ["django", "mcp", "model-context-protocol", "django-bolt", "llm", "agent"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Framework :: Django",
17
+ "Topic :: Internet :: WWW/HTTP",
18
+ ]
19
+ dependencies = [
20
+ # bolt-mcp reads django-bolt internals (FieldDefinition, handler metadata), so it is
21
+ # version-coupled — keep this floor in lockstep with django-bolt releases.
22
+ "django-bolt>=0.8.1",
23
+ "msgspec>=0.20",
24
+ # The built-in OAuth Authorization Server (bolt_mcp.oauth) signs/validates JWTs with
25
+ # PyJWT. django-bolt already imports it transitively; declared here for hygiene.
26
+ "pyjwt>=2.8",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/FarhanAliRaza/django-bolt"
31
+ Repository = "https://github.com/FarhanAliRaza/django-bolt"
32
+
33
+ [build-system]
34
+ requires = ["hatchling", "hatch-vcs"]
35
+ build-backend = "hatchling.build"
36
+
37
+ # Version comes from git tags — no hardcoded version, nothing to bump by hand.
38
+ # `--match "bolt-mcp-v*"` restricts to this package's tags so django-bolt's `v*`
39
+ # tags are ignored; the default tag regex strips the `bolt-mcp-` prefix
40
+ # (bolt-mcp-v0.1.0 -> 0.1.0). Builds with no matching tag get a dev version.
41
+ [tool.hatch.version]
42
+ source = "vcs"
43
+ # root = "../.." → the git repo is two levels up (python/bolt-mcp is a subdir package).
44
+ raw-options = { root = "../..", git_describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--match", "bolt-mcp-v*"] }
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/bolt_mcp"]
48
+
49
+ [tool.uv.sources]
50
+ django-bolt = { workspace = true }
@@ -0,0 +1,36 @@
1
+ """bolt-mcp: build MCP servers on django-bolt.
2
+
3
+ from django_bolt import BoltAPI
4
+ from bolt_mcp import MCP
5
+
6
+ api = BoltAPI()
7
+ mcp = MCP("my-server")
8
+
9
+ @mcp.tool
10
+ async def greet(name: str) -> dict:
11
+ return {"hello": name}
12
+
13
+ api.mount_mcp(mcp) # serves the MCP Streamable HTTP endpoint at /mcp
14
+
15
+ The free function ``mount_mcp(api, mcp)`` is the underlying implementation, equivalent
16
+ to the ``api.mount_mcp(mcp)`` method.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .autoexpose import expose_as_tool, expose_routes
22
+ from .context import Context
23
+ from .oauth import AuthorizationServer
24
+ from .server import MCP, principal
25
+ from .transport import ProtectedResource, mount_mcp
26
+
27
+ __all__ = [
28
+ "MCP",
29
+ "mount_mcp",
30
+ "ProtectedResource",
31
+ "AuthorizationServer",
32
+ "principal",
33
+ "expose_routes",
34
+ "expose_as_tool",
35
+ "Context",
36
+ ]
@@ -0,0 +1,58 @@
1
+ """Tool invocation and MCP result mapping.
2
+
3
+ Calls sync/async tool functions and maps their return value into an MCP
4
+ ``CallToolResult`` (``content`` text + ``structuredContent``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from typing import Any
11
+
12
+ import msgspec
13
+
14
+ from django_bolt._json import default_serializer
15
+ from django_bolt._json import encode as json_encode
16
+
17
+ from .registry import ToolDef
18
+
19
+
20
+ def _text_content(text: str) -> dict[str, Any]:
21
+ return {"type": "text", "text": text}
22
+
23
+
24
+ def error_result(message: str) -> dict[str, Any]:
25
+ """An in-band CallToolResult error (the MCP way to surface tool failures)."""
26
+ return {"content": [_text_content(message)], "isError": True}
27
+
28
+
29
+ def to_call_tool_result(result: Any) -> dict[str, Any]:
30
+ """Map a tool's return value into a CallToolResult dict."""
31
+ if isinstance(result, str):
32
+ return {"content": [_text_content(result)], "isError": False}
33
+ if isinstance(result, dict):
34
+ text = json_encode(result).decode()
35
+ return {"content": [_text_content(text)], "structuredContent": result, "isError": False}
36
+ payload = msgspec.to_builtins(result, enc_hook=default_serializer)
37
+ structured = payload if isinstance(payload, dict) else {"result": payload}
38
+ text = json_encode(payload).decode()
39
+ return {"content": [_text_content(text)], "structuredContent": structured, "isError": False}
40
+
41
+
42
+ async def run_tool(tool: ToolDef, kwargs: dict[str, Any]) -> Any:
43
+ """Invoke a tool and return its result value (awaiting async, off-loading sync)."""
44
+ if tool.is_async:
45
+ return await tool.fn(**kwargs)
46
+ return await asyncio.to_thread(tool.fn, **kwargs)
47
+
48
+
49
+ async def execute_tool(tool: ToolDef, kwargs: dict[str, Any]) -> dict[str, Any]:
50
+ """Run a tool and map its return value (or exception) to a CallToolResult dict.
51
+
52
+ A raised exception becomes an in-band error result, per MCP tool semantics —
53
+ shared by the non-streaming and streaming call paths.
54
+ """
55
+ try:
56
+ return to_call_tool_result(await run_tool(tool, kwargs))
57
+ except Exception as exc:
58
+ return error_result(str(exc))
@@ -0,0 +1,138 @@
1
+ """Expose existing django-bolt endpoints as MCP tools.
2
+
3
+ ``expose_as_tool`` marks a handler; ``expose_routes`` walks a BoltAPI, reads each
4
+ marked route's pre-computed ``FieldDefinition`` metadata, and registers an MCP tool
5
+ that calls the original handler.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ import inspect
12
+ import re
13
+ from collections.abc import Callable, Iterable
14
+ from typing import Any
15
+
16
+ from . import schema
17
+ from .registry import ToolDef
18
+ from .server import MCP
19
+
20
+ _MARKER_ATTR = "__bolt_mcp__"
21
+ _SKIP_SOURCES = frozenset({"file", "form"})
22
+
23
+
24
+ def expose_as_tool(name: str | None = None, description: str | None = None):
25
+ """Mark a BoltAPI handler so ``expose_routes`` turns it into an MCP tool."""
26
+
27
+ def decorator(fn: Callable) -> Callable:
28
+ setattr(fn, _MARKER_ATTR, {"name": name, "description": description})
29
+ return fn
30
+
31
+ return decorator
32
+
33
+
34
+ def _slug(method: str, path: str) -> str:
35
+ return f"{method.lower()}_{re.sub(r'[^a-zA-Z0-9]+', '_', path).strip('_')}"
36
+
37
+
38
+ def _name(handler: Callable) -> str:
39
+ return getattr(handler, "__name__", repr(handler))
40
+
41
+
42
+ def _tool_name(handler: Callable, method: str, path: str) -> str:
43
+ """Default MCP tool name: the handler's function name, else a path slug."""
44
+ name = getattr(handler, "__name__", None)
45
+ return name if name and not name.startswith("<") else _slug(method, path)
46
+
47
+
48
+ def expose_routes(
49
+ mcp: MCP,
50
+ api: Any,
51
+ *,
52
+ handlers: Iterable[Callable] | None = None,
53
+ include: list[str] | None = None,
54
+ exclude: list[str] | None = None,
55
+ methods: tuple[str, ...] = ("GET", "POST"),
56
+ only_marked: bool = True,
57
+ ) -> None:
58
+ """Synthesize MCP tools from selected BoltAPI routes.
59
+
60
+ By default every ``@expose_as_tool``-marked GET/POST route becomes a tool.
61
+
62
+ ``handlers`` is an explicit allowlist of route handler callables. When given, only
63
+ those handlers are exposed — regardless of HTTP method or marker (naming a handler
64
+ is intent enough) — and ``include``/``exclude``/``methods``/``only_marked`` are
65
+ ignored. A listed handler that isn't a route on ``api``, or that takes file/form
66
+ parameters (which can't be represented as JSON tool arguments), raises ``ValueError``
67
+ rather than being silently skipped.
68
+ """
69
+ explicit = handlers is not None
70
+ # Materialize once — `handlers` may be a generator, and we iterate it twice
71
+ # (to build `allow` here, and to compute `missing` after the route walk).
72
+ handler_list = list(handlers) if handlers is not None else []
73
+ allow = {id(h) for h in handler_list}
74
+ matched: set[int] = set()
75
+ wanted = {m.upper() for m in methods}
76
+
77
+ for method, path, handler_id, handler in api._routes:
78
+ if explicit:
79
+ if id(handler) not in allow:
80
+ continue
81
+ matched.add(id(handler))
82
+ else:
83
+ if method.upper() not in wanted:
84
+ continue
85
+ if include and not any(fnmatch.fnmatch(path, pat) for pat in include):
86
+ continue
87
+ if exclude and any(fnmatch.fnmatch(path, pat) for pat in exclude):
88
+ continue
89
+
90
+ marker = getattr(handler, _MARKER_ATTR, None)
91
+ if not explicit and only_marked and marker is None:
92
+ continue
93
+
94
+ meta = api._handler_meta.get(handler_id) or {}
95
+ fields = meta.get("fields") or []
96
+ unsupported = sorted({f.source for f in fields if f.source in _SKIP_SOURCES})
97
+ if unsupported:
98
+ if explicit:
99
+ raise ValueError(
100
+ f"Cannot expose handler {_name(handler)!r} as an MCP tool: it uses "
101
+ f"unsupported parameter source(s) {unsupported} — file/form uploads "
102
+ f"can't be represented as JSON tool arguments."
103
+ )
104
+ continue
105
+
106
+ marker = marker or {}
107
+ # Name/description derive from the route itself; @expose_as_tool only overrides.
108
+ name = marker.get("name") or _tool_name(handler, method, path)
109
+ if name in mcp._tools:
110
+ raise ValueError(
111
+ f"MCP tool name {name!r} is already registered — two exposed routes (or a "
112
+ f"route and a native tool) resolve to the same name. Disambiguate with "
113
+ f"@expose_as_tool(name=...)."
114
+ )
115
+ description = (
116
+ marker.get("description")
117
+ or meta.get("openapi_description")
118
+ or inspect.getdoc(handler)
119
+ or meta.get("openapi_summary")
120
+ )
121
+ args_struct = schema.struct_from_fields(name, fields)
122
+ mcp.add_tool(
123
+ ToolDef(
124
+ name=name,
125
+ fn=handler,
126
+ description=description,
127
+ args_struct=args_struct,
128
+ input_schema=schema.input_schema_for(args_struct),
129
+ is_async=inspect.iscoroutinefunction(handler),
130
+ injects_request=any(f.source == "request" for f in fields),
131
+ )
132
+ )
133
+
134
+ if explicit:
135
+ missing = [h for h in handler_list if id(h) not in matched]
136
+ if missing:
137
+ names = ", ".join(_name(h) for h in missing)
138
+ raise ValueError(f"Handler(s) not registered as a route on this BoltAPI: {names}")