Skip to content

Commit f44fcaf

Browse files
authored
Merge pull request #696 from MicroPyramid/dev
feat: implement HTTP transport with per-request authentication, dynam…
2 parents 3ab79d7 + cf6d4d8 commit f44fcaf

10 files changed

Lines changed: 2155 additions & 865 deletions

File tree

backend/crm/asgi.py

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@
44
for the in-app notifications SSE stream — async views serving long-lived
55
connections will hold a worker hostage when run under WSGI.
66
7+
It also optionally mounts the BottleCRM **MCP server** at ``/mcp`` so AI agents
8+
can connect over HTTP with no local install (each request authenticates with
9+
its own ``Authorization: Bearer <pat>`` header). The mount is best-effort: if
10+
the optional ``bcrm-mcp`` dependency is not installed, or ``BCRM_MCP_ENABLED``
11+
is false, this module serves Django alone — exactly as before.
12+
713
Production deploy must run an ASGI server pointing at this module:
814
915
uvicorn crm.asgi:application --host 0.0.0.0 --port 8000
1016
11-
`runserver` already supports async views, so dev workflows are unchanged.
17+
`runserver` uses WSGI, so the MCP mount is only active under a real ASGI
18+
server; dev workflows for the Django app itself are unchanged.
1219
"""
1320

1421
import os
@@ -22,4 +29,99 @@
2229

2330
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "crm.settings")
2431

25-
application = get_asgi_application()
32+
django_application = get_asgi_application()
33+
34+
35+
def _mcp_disabled():
36+
return os.environ.get("BCRM_MCP_ENABLED", "true").strip().lower() in (
37+
"0",
38+
"false",
39+
"no",
40+
"off",
41+
)
42+
43+
44+
def _build_application():
45+
"""Return the ASGI app: Django alone, or Django with the MCP server at /mcp.
46+
47+
Falls back to the plain Django app whenever the MCP server can't or
48+
shouldn't be mounted, so an incomplete install never takes the site down.
49+
"""
50+
if _mcp_disabled():
51+
return django_application
52+
53+
try:
54+
from bcrm_mcp.auth import extract_bearer_token
55+
from bcrm_mcp.server import build_http_app
56+
except ImportError:
57+
# Optional `mcp` extra not installed — serve Django only.
58+
return django_application
59+
60+
# The CRM REST root the MCP tools call. Mounted in-process, so this is a
61+
# loopback to this very server; override via env for a different internal
62+
# address.
63+
base_url = os.environ.get("BCRM_BASE_URL", "http://127.0.0.1:8000")
64+
65+
# MCP streamable endpoint lives at exactly "/mcp". We dispatch by prefix
66+
# ourselves rather than using Starlette's Mount, which (in the vendored
67+
# starlette) only matches "/mcp/…" and lets a slashless "/mcp" fall through
68+
# to Django — users configure ".../mcp" without a trailing slash.
69+
mcp_app = build_http_app(base_url, path="/mcp")
70+
71+
def _bearer_token(scope):
72+
for name, value in scope.get("headers", []):
73+
if name == b"authorization":
74+
return extract_bearer_token(
75+
{"authorization": value.decode("latin-1")}
76+
)
77+
return None
78+
79+
async def application(scope, receive, send):
80+
kind = scope.get("type")
81+
# Drive ONLY the MCP app's lifespan (it starts the MCP session
82+
# manager). Django needs no lifespan, so it never sees this scope.
83+
if kind == "lifespan":
84+
await mcp_app(scope, receive, send)
85+
return
86+
if kind in ("http", "websocket"):
87+
path = scope.get("path", "")
88+
if path == "/mcp" or path.startswith("/mcp/"):
89+
# Edge auth: reject anything without a well-formed bearer token
90+
# BEFORE it reaches the MCP layer — so initialize/list-tools are
91+
# unreachable unauthenticated, not just tool calls. (The token's
92+
# validity is still checked by the backend on each API call.)
93+
if _bearer_token(scope) is None:
94+
await _unauthorized(scope, send)
95+
return
96+
await mcp_app(scope, receive, send)
97+
return
98+
await django_application(scope, receive, send)
99+
100+
return application
101+
102+
103+
async def _unauthorized(scope, send):
104+
"""Send a 401 (http) or reject the connection (websocket) for /mcp requests
105+
that carry no usable bearer token."""
106+
if scope.get("type") == "websocket":
107+
await send({"type": "websocket.close", "code": 1008})
108+
return
109+
body = (
110+
b'{"error":"unauthorized","detail":"Missing or malformed Authorization '
111+
b'header. Send: Authorization: Bearer <bcrm_pat_...>"}'
112+
)
113+
await send(
114+
{
115+
"type": "http.response.start",
116+
"status": 401,
117+
"headers": [
118+
(b"content-type", b"application/json"),
119+
(b"www-authenticate", b"Bearer"),
120+
(b"content-length", str(len(body)).encode("ascii")),
121+
],
122+
}
123+
)
124+
await send({"type": "http.response.body", "body": body})
125+
126+
127+
application = _build_application()

backend/pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ dependencies = [
6969
"uvicorn[standard]>=0.30",
7070
]
7171

72+
[project.optional-dependencies]
73+
# Hosted MCP server, mounted at /mcp by crm.asgi (opt-in: install with
74+
# `uv sync --extra mcp`). Pulls in fastmcp + starlette. The core app does not
75+
# depend on it, so the default install stays lean.
76+
mcp = ["bcrm-mcp"]
77+
78+
[tool.uv.sources]
79+
bcrm-mcp = { path = "../mcp_server", editable = true }
80+
7281
[project.urls]
7382
Homepage = "https://github.com/MicroPyramid/Django-CRM"
7483
Repository = "https://github.com/MicroPyramid/Django-CRM"

0 commit comments

Comments
 (0)