Skip to content

Commit 78f0b11

Browse files
authored
StreamableHttp - Server transport with state management (#553)
1 parent 2210c1b commit 78f0b11

File tree

14 files changed

+1568
-16
lines changed

14 files changed

+1568
-16
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# MCP Simple StreamableHttp Server Example
2+
3+
A simple MCP server example demonstrating the StreamableHttp transport, which enables HTTP-based communication with MCP servers using streaming.
4+
5+
## Features
6+
7+
- Uses the StreamableHTTP transport for server-client communication
8+
- Supports REST API operations (POST, GET, DELETE) for `/mcp` endpoint
9+
- Task management with anyio task groups
10+
- Ability to send multiple notifications over time to the client
11+
- Proper resource cleanup and lifespan management
12+
13+
## Usage
14+
15+
Start the server on the default or custom port:
16+
17+
```bash
18+
19+
# Using custom port
20+
uv run mcp-simple-streamablehttp --port 3000
21+
22+
# Custom logging level
23+
uv run mcp-simple-streamablehttp --log-level DEBUG
24+
25+
# Enable JSON responses instead of SSE streams
26+
uv run mcp-simple-streamablehttp --json-response
27+
```
28+
29+
The server exposes a tool named "start-notification-stream" that accepts three arguments:
30+
31+
- `interval`: Time between notifications in seconds (e.g., 1.0)
32+
- `count`: Number of notifications to send (e.g., 5)
33+
- `caller`: Identifier string for the caller
34+
35+
## Client
36+
37+
You can connect to this server using an HTTP client, for now only Typescript SDK has streamable HTTP client examples or you can use (Inspector)[https://github.com/modelcontextprotocol/inspector]

examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .server import main
2+
3+
if __name__ == "__main__":
4+
main()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import contextlib
2+
import logging
3+
from http import HTTPStatus
4+
from uuid import uuid4
5+
6+
import anyio
7+
import click
8+
import mcp.types as types
9+
from mcp.server.lowlevel import Server
10+
from mcp.server.streamableHttp import (
11+
MCP_SESSION_ID_HEADER,
12+
StreamableHTTPServerTransport,
13+
)
14+
from starlette.applications import Starlette
15+
from starlette.requests import Request
16+
from starlette.responses import Response
17+
from starlette.routing import Mount
18+
19+
# Configure logging
20+
logger = logging.getLogger(__name__)
21+
22+
# Global task group that will be initialized in the lifespan
23+
task_group = None
24+
25+
26+
@contextlib.asynccontextmanager
27+
async def lifespan(app):
28+
"""Application lifespan context manager for managing task group."""
29+
global task_group
30+
31+
async with anyio.create_task_group() as tg:
32+
task_group = tg
33+
logger.info("Application started, task group initialized!")
34+
try:
35+
yield
36+
finally:
37+
logger.info("Application shutting down, cleaning up resources...")
38+
if task_group:
39+
tg.cancel_scope.cancel()
40+
task_group = None
41+
logger.info("Resources cleaned up successfully.")
42+
43+
44+
@click.command()
45+
@click.option("--port", default=3000, help="Port to listen on for HTTP")
46+
@click.option(
47+
"--log-level",
48+
default="INFO",
49+
help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
50+
)
51+
@click.option(
52+
"--json-response",
53+
is_flag=True,
54+
default=False,
55+
help="Enable JSON responses instead of SSE streams",
56+
)
57+
def main(
58+
port: int,
59+
log_level: str,
60+
json_response: bool,
61+
) -> int:
62+
# Configure logging
63+
logging.basicConfig(
64+
level=getattr(logging, log_level.upper()),
65+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
66+
)
67+
68+
app = Server("mcp-streamable-http-demo")
69+
70+
@app.call_tool()
71+
async def call_tool(
72+
name: str, arguments: dict
73+
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
74+
ctx = app.request_context
75+
interval = arguments.get("interval", 1.0)
76+
count = arguments.get("count", 5)
77+
caller = arguments.get("caller", "unknown")
78+
79+
# Send the specified number of notifications with the given interval
80+
for i in range(count):
81+
await ctx.session.send_log_message(
82+
level="info",
83+
data=f"Notification {i+1}/{count} from caller: {caller}",
84+
logger="notification_stream",
85+
# Associates this notification with the original request
86+
# Ensures notifications are sent to the correct response stream
87+
# Without this, notifications will either go to:
88+
# - a standalone SSE stream (if GET request is supported)
89+
# - nowhere (if GET request isn't supported)
90+
related_request_id=ctx.request_id,
91+
)
92+
if i < count - 1: # Don't wait after the last notification
93+
await anyio.sleep(interval)
94+
95+
return [
96+
types.TextContent(
97+
type="text",
98+
text=(
99+
f"Sent {count} notifications with {interval}s interval"
100+
f" for caller: {caller}"
101+
),
102+
)
103+
]
104+
105+
@app.list_tools()
106+
async def list_tools() -> list[types.Tool]:
107+
return [
108+
types.Tool(
109+
name="start-notification-stream",
110+
description=(
111+
"Sends a stream of notifications with configurable count"
112+
" and interval"
113+
),
114+
inputSchema={
115+
"type": "object",
116+
"required": ["interval", "count", "caller"],
117+
"properties": {
118+
"interval": {
119+
"type": "number",
120+
"description": "Interval between notifications in seconds",
121+
},
122+
"count": {
123+
"type": "number",
124+
"description": "Number of notifications to send",
125+
},
126+
"caller": {
127+
"type": "string",
128+
"description": (
129+
"Identifier of the caller to include in notifications"
130+
),
131+
},
132+
},
133+
},
134+
)
135+
]
136+
137+
# We need to store the server instances between requests
138+
server_instances = {}
139+
# Lock to prevent race conditions when creating new sessions
140+
session_creation_lock = anyio.Lock()
141+
142+
# ASGI handler for streamable HTTP connections
143+
async def handle_streamable_http(scope, receive, send):
144+
request = Request(scope, receive)
145+
request_mcp_session_id = request.headers.get(MCP_SESSION_ID_HEADER)
146+
if (
147+
request_mcp_session_id is not None
148+
and request_mcp_session_id in server_instances
149+
):
150+
transport = server_instances[request_mcp_session_id]
151+
logger.debug("Session already exists, handling request directly")
152+
await transport.handle_request(scope, receive, send)
153+
elif request_mcp_session_id is None:
154+
# try to establish new session
155+
logger.debug("Creating new transport")
156+
# Use lock to prevent race conditions when creating new sessions
157+
async with session_creation_lock:
158+
new_session_id = uuid4().hex
159+
http_transport = StreamableHTTPServerTransport(
160+
mcp_session_id=new_session_id,
161+
is_json_response_enabled=json_response,
162+
)
163+
server_instances[http_transport.mcp_session_id] = http_transport
164+
async with http_transport.connect() as streams:
165+
read_stream, write_stream = streams
166+
167+
async def run_server():
168+
await app.run(
169+
read_stream,
170+
write_stream,
171+
app.create_initialization_options(),
172+
)
173+
174+
if not task_group:
175+
raise RuntimeError("Task group is not initialized")
176+
177+
task_group.start_soon(run_server)
178+
179+
# Handle the HTTP request and return the response
180+
await http_transport.handle_request(scope, receive, send)
181+
else:
182+
response = Response(
183+
"Bad Request: No valid session ID provided",
184+
status_code=HTTPStatus.BAD_REQUEST,
185+
)
186+
await response(scope, receive, send)
187+
188+
# Create an ASGI application using the transport
189+
starlette_app = Starlette(
190+
debug=True,
191+
routes=[
192+
Mount("/mcp", app=handle_streamable_http),
193+
],
194+
lifespan=lifespan,
195+
)
196+
197+
import uvicorn
198+
199+
uvicorn.run(starlette_app, host="0.0.0.0", port=port)
200+
201+
return 0
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[project]
2+
name = "mcp-simple-streamablehttp"
3+
version = "0.1.0"
4+
description = "A simple MCP server exposing a StreamableHttp transport for testing"
5+
readme = "README.md"
6+
requires-python = ">=3.10"
7+
authors = [{ name = "Anthropic, PBC." }]
8+
keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable"]
9+
license = { text = "MIT" }
10+
dependencies = ["anyio>=4.5", "click>=8.1.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"]
11+
12+
[project.scripts]
13+
mcp-simple-streamablehttp = "mcp_simple_streamablehttp.server:main"
14+
15+
[build-system]
16+
requires = ["hatchling"]
17+
build-backend = "hatchling.build"
18+
19+
[tool.hatch.build.targets.wheel]
20+
packages = ["mcp_simple_streamablehttp"]
21+
22+
[tool.pyright]
23+
include = ["mcp_simple_streamablehttp"]
24+
venvPath = "."
25+
venv = ".venv"
26+
27+
[tool.ruff.lint]
28+
select = ["E", "F", "I"]
29+
ignore = []
30+
31+
[tool.ruff]
32+
line-length = 88
33+
target-version = "py310"
34+
35+
[tool.uv]
36+
dev-dependencies = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"]

src/mcp/server/fastmcp/server.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -814,7 +814,10 @@ async def log(
814814
**extra: Additional structured data to include
815815
"""
816816
await self.request_context.session.send_log_message(
817-
level=level, data=message, logger=logger_name
817+
level=level,
818+
data=message,
819+
logger=logger_name,
820+
related_request_id=self.request_id,
818821
)
819822

820823
@property

src/mcp/server/session.py

+14-4
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,11 @@ async def _received_notification(
179179
)
180180

181181
async def send_log_message(
182-
self, level: types.LoggingLevel, data: Any, logger: str | None = None
182+
self,
183+
level: types.LoggingLevel,
184+
data: Any,
185+
logger: str | None = None,
186+
related_request_id: types.RequestId | None = None,
183187
) -> None:
184188
"""Send a log message notification."""
185189
await self.send_notification(
@@ -192,7 +196,8 @@ async def send_log_message(
192196
logger=logger,
193197
),
194198
)
195-
)
199+
),
200+
related_request_id,
196201
)
197202

198203
async def send_resource_updated(self, uri: AnyUrl) -> None:
@@ -261,7 +266,11 @@ async def send_ping(self) -> types.EmptyResult:
261266
)
262267

263268
async def send_progress_notification(
264-
self, progress_token: str | int, progress: float, total: float | None = None
269+
self,
270+
progress_token: str | int,
271+
progress: float,
272+
total: float | None = None,
273+
related_request_id: str | None = None,
265274
) -> None:
266275
"""Send a progress notification."""
267276
await self.send_notification(
@@ -274,7 +283,8 @@ async def send_progress_notification(
274283
total=total,
275284
),
276285
)
277-
)
286+
),
287+
related_request_id,
278288
)
279289

280290
async def send_resource_list_changed(self) -> None:

0 commit comments

Comments
 (0)