Skip to content

Commit 87b38d3

Browse files
authored
Merge pull request chigwell#276 from omid-io/feat/mcp-protocol-2026-07-28
feat(protocol): support MCP 2026-07-28 and dual transport negotiation (chigwell#262)
2 parents 3c42ca1 + 5ef0b62 commit 87b38d3

10 files changed

Lines changed: 780 additions & 8 deletions

File tree

‎pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ requires-python = ">=3.10"
2525
dependencies = [
2626
"dotenv>=0.9.9",
2727
"httpx>=0.28.1",
28-
"mcp[cli]>=1.8.0,<2",
28+
"mcp[cli]>=1.30.0,<2",
2929
"pillow>=11.0.0",
3030
"pypdf>=4.0.0",
3131
"python-dotenv>=1.1.0",
@@ -54,7 +54,7 @@ whisper = [
5454

5555
[tool.setuptools]
5656
py-modules = ["main", "migrate_session", "sanitize", "session_string_generator"]
57-
packages = ["telegram_mcp", "telegram_mcp.tools"]
57+
packages = ["telegram_mcp", "telegram_mcp.tools", "telegram_mcp.transport"]
5858

5959
[project.scripts]
6060
telegram-mcp = "main:main"

‎requirements.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
dotenv>=0.9.9
22
httpx>=0.28.1
3-
mcp[cli]>=1.8.0,<2
3+
mcp[cli]>=1.30.0,<2
44
pillow>=11.0.0
55
python-dotenv>=1.1.0
66
python-json-logger>=3.3.0

‎telegram_mcp/runtime.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ def parse_schedule_date(
153153
TELEGRAM_API_ID = int(os.getenv("TELEGRAM_API_ID"))
154154
TELEGRAM_API_HASH = os.getenv("TELEGRAM_API_HASH")
155155

156+
# Install protocol compatibility for MCP 2026-07-28 and backwards compatibility
157+
from telegram_mcp.transport import install_protocol_compatibility
158+
159+
install_protocol_compatibility()
160+
156161
# The shared HTTP service can be consumed by long-lived MCP clients. Stateless requests keep
157162
# those clients usable across server-process restarts instead of rejecting their next call
158163
# with "No valid session ID provided". Stdio transport remains unaffected.

‎telegram_mcp/server.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""High-level server module and exports for Telegram MCP.
2+
3+
Provides access to the FastMCP instance, HTTP ASGI application wrapped with
4+
protocol negotiation for MCP 2026-07-28, and transport serving routines.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import Any
10+
11+
from telegram_mcp.transport import (
12+
DEFAULT_PROTOCOL_VERSION,
13+
LATEST_PROTOCOL_VERSION,
14+
SUPPORTED_PROTOCOL_VERSIONS,
15+
create_streamable_http_app,
16+
install_protocol_compatibility,
17+
run_stdio_async,
18+
run_streamable_http_async,
19+
)
20+
from telegram_mcp.runtime import mcp
21+
from telegram_mcp import runner as _runner
22+
23+
# Ensure protocol compatibility is installed upon server module import
24+
install_protocol_compatibility()
25+
26+
__all__ = [
27+
"DEFAULT_PROTOCOL_VERSION",
28+
"LATEST_PROTOCOL_VERSION",
29+
"SUPPORTED_PROTOCOL_VERSIONS",
30+
"create_streamable_http_app",
31+
"get_http_app",
32+
"get_server",
33+
"main",
34+
"mcp",
35+
"run_stdio_async",
36+
"run_streamable_http_async",
37+
"serve",
38+
]
39+
40+
41+
def get_server() -> Any:
42+
"""Return the underlying FastMCP server instance."""
43+
return mcp
44+
45+
46+
def get_http_app() -> Any:
47+
"""Return the Starlette ASGI application with protocol negotiation."""
48+
return create_streamable_http_app(mcp)
49+
50+
51+
async def serve(transport: str = "stdio") -> None:
52+
"""Serve the Telegram MCP server over the requested transport."""
53+
await _runner._serve(transport)
54+
55+
56+
def main() -> None:
57+
"""Server CLI entrypoint."""
58+
_runner.main()
59+
60+
61+
if __name__ == "__main__":
62+
main()

‎telegram_mcp/transport/__init__.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Transport layer and MCP protocol negotiation for telegram-mcp."""
2+
3+
from telegram_mcp.transport.protocol import (
4+
DEFAULT_PROTOCOL_VERSION,
5+
HANDSHAKE_PROTOCOL_VERSIONS,
6+
LATEST_PROTOCOL_VERSION,
7+
MCP_PROTOCOL_VERSION_HEADER,
8+
MODERN_PROTOCOL_VERSIONS,
9+
OLDEST_SUPPORTED_VERSION,
10+
SUPPORTED_PROTOCOL_VERSIONS,
11+
UnsupportedProtocolVersionError,
12+
install_protocol_compatibility,
13+
is_supported_protocol_version,
14+
negotiate_protocol_version,
15+
)
16+
from telegram_mcp.transport.http import (
17+
ProtocolNegotiationMiddleware,
18+
create_streamable_http_app,
19+
run_streamable_http_async,
20+
)
21+
from telegram_mcp.transport.stdio import run_stdio_async
22+
23+
# Ensure runtime patches are applied upon transport import
24+
install_protocol_compatibility()
25+
26+
__all__ = [
27+
"DEFAULT_PROTOCOL_VERSION",
28+
"HANDSHAKE_PROTOCOL_VERSIONS",
29+
"LATEST_PROTOCOL_VERSION",
30+
"MCP_PROTOCOL_VERSION_HEADER",
31+
"MODERN_PROTOCOL_VERSIONS",
32+
"OLDEST_SUPPORTED_VERSION",
33+
"SUPPORTED_PROTOCOL_VERSIONS",
34+
"UnsupportedProtocolVersionError",
35+
"install_protocol_compatibility",
36+
"is_supported_protocol_version",
37+
"negotiate_protocol_version",
38+
"ProtocolNegotiationMiddleware",
39+
"create_streamable_http_app",
40+
"run_streamable_http_async",
41+
"run_stdio_async",
42+
]

‎telegram_mcp/transport/http.py‎

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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

Comments
 (0)