From 1cd556c3f64bb6015536bfecf22cf88178016255 Mon Sep 17 00:00:00 2001 From: Robert Helewka Date: Wed, 29 Apr 2026 12:04:42 -0400 Subject: [PATCH] =?UTF-8?q?fix(asgi):=20redirect=20/mcp=20=E2=86=92=20/mcp?= =?UTF-8?q?/=20for=20clients=20that=20omit=20the=20trailing=20slash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- mnemosyne/mnemosyne/asgi.py | 10 +++++++++- validator/.gitignore | 1 + validator/README.md | 2 +- validator/fastagent.config.yaml | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/mnemosyne/mnemosyne/asgi.py b/mnemosyne/mnemosyne/asgi.py index 675c974..3c0472f 100644 --- a/mnemosyne/mnemosyne/asgi.py +++ b/mnemosyne/mnemosyne/asgi.py @@ -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), diff --git a/validator/.gitignore b/validator/.gitignore index a7e961a..092fa9a 100644 --- a/validator/.gitignore +++ b/validator/.gitignore @@ -1,4 +1,5 @@ fastagent.secrets.yaml +fastagent.jsonl .venv/ *.egg-info/ __pycache__/ diff --git a/validator/README.md b/validator/README.md index 0aaa211..8c1658e 100644 --- a/validator/README.md +++ b/validator/README.md @@ -55,7 +55,7 @@ cd mnemosyne/ uvicorn mnemosyne.asgi:app --host 0.0.0.0 --port 22091 --workers 1 ``` -By default the validator points at `http://localhost:22091/mcp`. If your Mnemosyne is on another host, override `mcp.servers.mnemosyne.url` in `fastagent.secrets.yaml`. +By default the validator points at `http://localhost:22091/mcp/` (note the trailing slash — Starlette's `Mount` only matches paths under the mount prefix, so `/mcp` without the slash falls through to Django). If your Mnemosyne is on another host, override `mcp.servers.mnemosyne.url` in `fastagent.secrets.yaml`. ## Run the validator diff --git a/validator/fastagent.config.yaml b/validator/fastagent.config.yaml index bd6a6e7..b0b07f5 100644 --- a/validator/fastagent.config.yaml +++ b/validator/fastagent.config.yaml @@ -27,6 +27,6 @@ mcp: # override the URL in fastagent.secrets.yaml or via Ansible if remote. mnemosyne: transport: http - url: "http://localhost:22091/mcp" + url: "http://localhost:22091/mcp/" # Bearer token in fastagent.secrets.yaml (provisioned via # `python manage.py create_mcp_token `).