|
| 1 | +"""Streamable HTTP transport with MCP 2026-07-28 protocol negotiation support.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import logging |
| 6 | +from typing import Any |
| 7 | + |
| 8 | +from starlette.responses import JSONResponse, Response |
| 9 | +from starlette.types import ASGIApp, Message, Receive, Scope, Send |
| 10 | + |
| 11 | +from telegram_mcp.transport.protocol import ( |
| 12 | + DEFAULT_PROTOCOL_VERSION, |
| 13 | + LATEST_PROTOCOL_VERSION, |
| 14 | + MCP_PROTOCOL_VERSION_HEADER, |
| 15 | + SUPPORTED_PROTOCOL_VERSIONS, |
| 16 | + install_protocol_compatibility, |
| 17 | +) |
| 18 | + |
| 19 | +logger = logging.getLogger(__name__) |
| 20 | + |
| 21 | + |
| 22 | +class ProtocolNegotiationMiddleware: |
| 23 | + """ASGI middleware that enforces MCP protocol negotiation and response headers. |
| 24 | +
|
| 25 | + Features: |
| 26 | + - Validates MCP-Protocol-Version request header against supported versions. |
| 27 | + - Rejects unsupported versions with a standard JSON-RPC 400 Bad Request error. |
| 28 | + - Injects negotiated MCP-Protocol-Version into every HTTP response. |
| 29 | + - Handles CORS and OPTIONS preflight requests gracefully. |
| 30 | + - Provides a JSON discovery response for plain GET probes (non-SSE) on the MCP endpoint. |
| 31 | + """ |
| 32 | + |
| 33 | + def __init__(self, app: ASGIApp): |
| 34 | + self.app = app |
| 35 | + |
| 36 | + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: |
| 37 | + if scope["type"] != "http": |
| 38 | + await self.app(scope, receive, send) |
| 39 | + return |
| 40 | + |
| 41 | + headers = dict(scope.get("headers", [])) |
| 42 | + raw_version_bytes = headers.get(MCP_PROTOCOL_VERSION_HEADER.encode("latin-1")) |
| 43 | + requested_version = ( |
| 44 | + raw_version_bytes.decode("latin-1").strip() if raw_version_bytes else None |
| 45 | + ) |
| 46 | + |
| 47 | + # 1. Handle OPTIONS discovery / CORS preflight |
| 48 | + if scope.get("method") == "OPTIONS": |
| 49 | + cors_headers = ( |
| 50 | + "Content-Type, Accept, MCP-Protocol-Version, MCP-Session-Id, Authorization" |
| 51 | + ) |
| 52 | + response = Response( |
| 53 | + status_code=204, |
| 54 | + headers={ |
| 55 | + "Allow": "GET, POST, DELETE, OPTIONS, HEAD", |
| 56 | + MCP_PROTOCOL_VERSION_HEADER: requested_version or LATEST_PROTOCOL_VERSION, |
| 57 | + "Access-Control-Allow-Origin": "*", |
| 58 | + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS, HEAD", |
| 59 | + "Access-Control-Allow-Headers": cors_headers, |
| 60 | + "Access-Control-Expose-Headers": "MCP-Protocol-Version, MCP-Session-Id", |
| 61 | + }, |
| 62 | + ) |
| 63 | + await response(scope, receive, send) |
| 64 | + return |
| 65 | + |
| 66 | + # 2. Validate protocol version if provided |
| 67 | + if requested_version: |
| 68 | + if requested_version not in SUPPORTED_PROTOCOL_VERSIONS: |
| 69 | + supported_str = ", ".join(SUPPORTED_PROTOCOL_VERSIONS) |
| 70 | + error_body = { |
| 71 | + "jsonrpc": "2.0", |
| 72 | + "id": "server-error", |
| 73 | + "error": { |
| 74 | + "code": -32600, |
| 75 | + "message": ( |
| 76 | + f"Bad Request: Unsupported protocol version: {requested_version}. " |
| 77 | + f"Supported versions: {supported_str}" |
| 78 | + ), |
| 79 | + }, |
| 80 | + } |
| 81 | + response = JSONResponse( |
| 82 | + status_code=400, |
| 83 | + content=error_body, |
| 84 | + headers={ |
| 85 | + MCP_PROTOCOL_VERSION_HEADER: DEFAULT_PROTOCOL_VERSION, |
| 86 | + }, |
| 87 | + ) |
| 88 | + await response(scope, receive, send) |
| 89 | + return |
| 90 | + negotiated_version = requested_version |
| 91 | + else: |
| 92 | + negotiated_version = DEFAULT_PROTOCOL_VERSION |
| 93 | + |
| 94 | + # 3. Handle plain GET discovery probe (clients querying without text/event-stream) |
| 95 | + accept_header = headers.get(b"accept", b"").decode("latin-1").lower() |
| 96 | + if scope.get("method") == "GET" and "text/event-stream" not in accept_header: |
| 97 | + discovery_data = { |
| 98 | + "name": "telegram", |
| 99 | + "version": "2.0.1", |
| 100 | + "protocolVersions": list(SUPPORTED_PROTOCOL_VERSIONS), |
| 101 | + "latestProtocolVersion": LATEST_PROTOCOL_VERSION, |
| 102 | + "negotiatedProtocolVersion": negotiated_version, |
| 103 | + } |
| 104 | + response = JSONResponse( |
| 105 | + status_code=200, |
| 106 | + content=discovery_data, |
| 107 | + headers={ |
| 108 | + MCP_PROTOCOL_VERSION_HEADER: negotiated_version, |
| 109 | + "Access-Control-Allow-Origin": "*", |
| 110 | + "Access-Control-Expose-Headers": "MCP-Protocol-Version, MCP-Session-Id", |
| 111 | + }, |
| 112 | + ) |
| 113 | + await response(scope, receive, send) |
| 114 | + return |
| 115 | + |
| 116 | + # 4. Wrap send to inject MCP-Protocol-Version header on all response start messages |
| 117 | + async def send_with_protocol_header(message: Message) -> None: |
| 118 | + if message["type"] == "http.response.start": |
| 119 | + resp_headers = list(message.get("headers", [])) |
| 120 | + header_names = {h[0].lower() for h in resp_headers} |
| 121 | + |
| 122 | + if MCP_PROTOCOL_VERSION_HEADER.encode("latin-1") not in header_names: |
| 123 | + resp_headers.append( |
| 124 | + ( |
| 125 | + MCP_PROTOCOL_VERSION_HEADER.encode("latin-1"), |
| 126 | + negotiated_version.encode("latin-1"), |
| 127 | + ) |
| 128 | + ) |
| 129 | + |
| 130 | + if b"access-control-expose-headers" not in header_names: |
| 131 | + expose_val = b"*, mcp-protocol-version, mcp-session-id" |
| 132 | + resp_headers.append((b"access-control-expose-headers", expose_val)) |
| 133 | + |
| 134 | + message["headers"] = resp_headers |
| 135 | + await send(message) |
| 136 | + |
| 137 | + await self.app(scope, receive, send_with_protocol_header) |
| 138 | + |
| 139 | + |
| 140 | +def patch_fastmcp_streamable_http() -> None: |
| 141 | + """Patch FastMCP.streamable_http_app to enforce MCP 2026-07-28 protocol negotiation.""" |
| 142 | + try: |
| 143 | + from mcp.server.fastmcp import FastMCP |
| 144 | + |
| 145 | + if getattr(FastMCP, "_protocol_negotiation_patched", False): |
| 146 | + return |
| 147 | + |
| 148 | + original_streamable_http_app = FastMCP.streamable_http_app |
| 149 | + |
| 150 | + def wrapped_streamable_http_app(self: Any, *args: Any, **kwargs: Any) -> Any: |
| 151 | + app = original_streamable_http_app(self, *args, **kwargs) |
| 152 | + return ProtocolNegotiationMiddleware(app) |
| 153 | + |
| 154 | + FastMCP.streamable_http_app = wrapped_streamable_http_app # type: ignore[assignment] |
| 155 | + FastMCP._protocol_negotiation_patched = True # type: ignore[attr-defined] |
| 156 | + except (ImportError, AttributeError) as exc: |
| 157 | + logger.debug("Could not monkey-patch FastMCP.streamable_http_app: %s", exc) |
| 158 | + |
| 159 | + |
| 160 | +def create_streamable_http_app(mcp_instance: Any) -> Any: |
| 161 | + """Build and wrap the Starlette app from FastMCP with protocol negotiation.""" |
| 162 | + install_protocol_compatibility() |
| 163 | + if not hasattr(mcp_instance, "streamable_http_app"): |
| 164 | + return mcp_instance |
| 165 | + |
| 166 | + # If the FastMCP session manager already ran and shut down, reset it to allow clean restart |
| 167 | + session_mgr = getattr(mcp_instance, "_session_manager", None) |
| 168 | + if session_mgr is not None and getattr(session_mgr, "_has_started", False): |
| 169 | + mcp_instance._session_manager = None |
| 170 | + |
| 171 | + app = mcp_instance.streamable_http_app() |
| 172 | + # If not already wrapped via monkeypatch |
| 173 | + if not isinstance(app, ProtocolNegotiationMiddleware): |
| 174 | + app = ProtocolNegotiationMiddleware(app) |
| 175 | + return app |
| 176 | + |
| 177 | + |
| 178 | +async def run_streamable_http_async(mcp_instance: Any) -> None: |
| 179 | + """Run FastMCP streamable HTTP transport with protocol negotiation middleware.""" |
| 180 | + install_protocol_compatibility() |
| 181 | + patch_fastmcp_streamable_http() |
| 182 | + |
| 183 | + if not hasattr(mcp_instance, "streamable_http_app"): |
| 184 | + if hasattr(mcp_instance, "run_streamable_http_async"): |
| 185 | + await mcp_instance.run_streamable_http_async() |
| 186 | + return |
| 187 | + |
| 188 | + import uvicorn |
| 189 | + |
| 190 | + app = create_streamable_http_app(mcp_instance) |
| 191 | + |
| 192 | + config = uvicorn.Config( |
| 193 | + app, |
| 194 | + host=mcp_instance.settings.host, |
| 195 | + port=mcp_instance.settings.port, |
| 196 | + log_level=mcp_instance.settings.log_level.lower(), |
| 197 | + ) |
| 198 | + server = uvicorn.Server(config) |
| 199 | + await server.serve() |
0 commit comments