fix(asgi): redirect /mcp → /mcp/ for clients that omit the trailing slash

Starlette's Mount("/mcp", ...) only matches /mcp/* paths. A POST to bare
/mcp falls through to the catch-all Django mount and returns 404. The
fast-agent MCP client and the README example both used the no-slash URL,
so the validator was never able to initialize a session — every call
landed in django.request.

Adds a 307 redirect at /mcp so any client URL works, and points the
validator config at /mcp/ directly to skip the redirect round-trip.
Also gitignores fastagent.jsonl (a runtime log file fast-agent writes
into the working directory).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 12:04:42 -04:00
parent e2a6d45b77
commit 1cd556c3f6
4 changed files with 12 additions and 3 deletions

View File

@@ -20,7 +20,7 @@ django.setup()
from django.core.asgi import get_asgi_application # noqa: E402
from starlette.applications import Starlette # noqa: E402
from starlette.responses import JSONResponse # noqa: E402
from starlette.responses import JSONResponse, RedirectResponse # noqa: E402
from starlette.routing import Mount, Route # noqa: E402
from mcp_server.server import mcp # noqa: E402
@@ -35,6 +35,13 @@ async def health(request):
return JSONResponse({"status": "ok"})
async def mcp_redirect(request):
"""Bare /mcp → /mcp/ — MCP clients that omit the trailing slash hit
Starlette's Mount which only matches /mcp/* paths, falling through to
Django and returning 404. A 307 preserves the POST body."""
return RedirectResponse(url="/mcp/", status_code=307)
@asynccontextmanager
async def lifespan(app):
async with mcp_http_app.lifespan(app), mcp_sse_app.lifespan(app):
@@ -44,6 +51,7 @@ async def lifespan(app):
app = Starlette(
routes=[
Route("/mcp/health", health),
Route("/mcp", mcp_redirect, methods=["GET", "POST"]),
Mount("/mcp/sse", app=mcp_sse_app),
Mount("/mcp", app=mcp_http_app),
Mount("/", app=application),