From bc3deb86769681cdbee5ef96d3e6b0c2d5b0e6c9 Mon Sep 17 00:00:00 2001 From: Sagent Date: Sat, 17 Oct 2026 20:45:26 +0000 Subject: [PATCH] fix: serve MCP over Streamable HTTP instead of legacy SSE Current MCP clients (OpenCode, Claude, ...) only speak Streamable HTTP: they POST JSON-RPC to the configured URL, so a legacy SSE endpoint answers 405 Method Not Allowed and not a single tool can be reached. Run FastMCP's http_app (build_app()) under uvicorn instead of mcp.run(sse): the MCP endpoint is now /mcp, and the custom GET /health route keeps serving the Kubernetes probes. Add transport tests asserting /mcp and /health are exposed and /sse is gone. --- docker-compose.yml | 2 +- src/server.py | 25 +++++++++++++++++++++---- tests/test_transport.py | 27 +++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 tests/test_transport.py diff --git a/docker-compose.yml b/docker-compose.yml index e29f7e2..a3e7f76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: dockerfile: pkg/Dockerfile container_name: mcp_maildir_server ports: - - "127.0.0.1:8000:8000" # Expose the MCP HTTP (SSE) server + - "127.0.0.1:8000:8000" # Expose the MCP Streamable HTTP server (/mcp) env_file: - .env environment: diff --git a/src/server.py b/src/server.py index 79d30d3..30a9c38 100644 --- a/src/server.py +++ b/src/server.py @@ -9,6 +9,7 @@ from typing import Any, Optional import qdrant_client as qdrant_pkg from dateutil import parser as date_parser from email.utils import parseaddr +import uvicorn from dotenv import load_dotenv from starlette.requests import Request from starlette.responses import JSONResponse @@ -340,6 +341,23 @@ def read_email(message_id: str): return {"error": f"read_email failed: {exc}"} +# Streamable HTTP is the transport supported by current MCP clients (OpenCode, +# Claude, ...). The legacy SSE transport is deprecated and is also a POST +# target for clients that only speak Streamable HTTP, which answers 405. +MCP_HOST = "0.0.0.0" +MCP_PORT = 8000 +MCP_HTTP_PATH = "/mcp" + + +def build_app(): + """Builds the ASGI app serving the MCP endpoint over Streamable HTTP. + + The MCP endpoint lives at MCP_HTTP_PATH; the custom route declared above + keeps serving GET /health for the Kubernetes probes. + """ + return mcp.http_app(path=MCP_HTTP_PATH) + + if __name__ == "__main__": logger.info("Initializing models before starting server...") try: @@ -347,7 +365,6 @@ if __name__ == "__main__": logger.info("Models loaded successfully.") except Exception as e: logger.error(f"Failed to load models: {e}") - - logger.info("Starting SSE server on 0.0.0.0:8000...") - # Start the MCP server using SSE (Server-Sent Events) over HTTP - mcp.run(transport="sse", host="0.0.0.0", port=8000) + + logger.info(f"Starting Streamable HTTP server on {MCP_HOST}:{MCP_PORT}{MCP_HTTP_PATH}...") + uvicorn.run(build_app(), host=MCP_HOST, port=MCP_PORT) diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..17811b8 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,27 @@ +"""Transport-level tests for the mcp-maildir ASGI app. + +Current MCP clients (OpenCode, Claude, ...) only speak Streamable HTTP: they +POST JSON-RPC to the configured URL. Serving the legacy SSE transport alone +makes those clients fail with `405 Method Not Allowed`, so the app built by +`server.build_app()` must expose the MCP endpoint at /mcp. + +Env vars are set in tests/conftest.py (shared session-wide). +""" + +from server import build_app, MCP_HTTP_PATH + + +def app_paths() -> set: + return {getattr(route, "path", None) for route in build_app().routes} + + +class TestTransport: + def test_mcp_endpoint_is_streamable_http(self): + assert MCP_HTTP_PATH == "/mcp" + assert MCP_HTTP_PATH in app_paths() + + def test_health_endpoint_is_exposed_for_probes(self): + assert "/health" in app_paths() + + def test_legacy_sse_endpoint_is_gone(self): + assert "/sse" not in app_paths() -- 2.54.0