fix: serve MCP over Streamable HTTP instead of legacy SSE
PR Checks / hadolint (pull_request) Successful in 40s
PR Checks / build-test (pull_request) Successful in 3m19s
PR Checks / pytest (pull_request) Successful in 2m47s
Docker Build and Push / test (pull_request) Successful in 3m1s
Docker Build and Push / integration-test (pull_request) Successful in 3m24s
Docker Build and Push / build (pull_request) Successful in 3m4s

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.
This commit is contained in:
Sagent committed 2026-10-17 20:45:26 +00:00
1 parent 4d9bd4c470
commit bc3deb8676
3 files changed
+49 -5

No files matched your search

+1 -1
View File
@@ -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:
+21 -4
View File
@@ -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)
+27
View File
@@ -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()