Skip to content

Commit fadbe04

Browse files
Merge branch 'main' into feat/docstring-param-descriptions
2 parents 735bc50 + ff50351 commit fadbe04

File tree

23 files changed

+258
-429
lines changed

23 files changed

+258
-429
lines changed

CLAUDE.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,31 @@ This document contains critical information about working with this codebase. Fo
2929
- IMPORTANT: The `tests/client/test_client.py` is the most well designed test file. Follow its patterns.
3030
- IMPORTANT: Be minimal, and focus on E2E tests: Use the `mcp.client.Client` whenever possible.
3131
- Coverage: CI requires 100% (`fail_under = 100`, `branch = true`).
32-
- Full check: `./scripts/test` (~20s, matches CI exactly)
33-
- Targeted check while iterating:
32+
- Full check: `./scripts/test` (~23s). Runs coverage + `strict-no-cover` on the
33+
default Python. Not identical to CI: CI also runs 3.10–3.14 × {ubuntu, windows},
34+
and some branch-coverage quirks only surface on specific matrix entries.
35+
- Targeted check while iterating (~4s, deterministic):
3436

3537
```bash
3638
uv run --frozen coverage erase
3739
uv run --frozen coverage run -m pytest tests/path/test_foo.py
3840
uv run --frozen coverage combine
3941
uv run --frozen coverage report --include='src/mcp/path/foo.py' --fail-under=0
42+
UV_FROZEN=1 uv run --frozen strict-no-cover
4043
```
4144

4245
Partial runs can't hit 100% (coverage tracks `tests/` too), so `--fail-under=0`
43-
and `--include` scope the report to what you actually changed.
46+
and `--include` scope the report. `strict-no-cover` has no false positives on
47+
partial runs — if your new test executes a line marked `# pragma: no cover`,
48+
even a single-file run catches it.
49+
- Coverage pragmas:
50+
- `# pragma: no cover` — line is never executed. CI's `strict-no-cover` fails if
51+
it IS executed. When your test starts covering such a line, remove the pragma.
52+
- `# pragma: lax no cover` — excluded from coverage but not checked by
53+
`strict-no-cover`. Use for lines covered on some platforms/versions but not
54+
others.
55+
- `# pragma: no branch` — excludes branch arcs only. coverage.py misreports the
56+
`->exit` arc for nested `async with` on Python 3.11+ (worse on 3.14/Windows).
4457
- Avoid `anyio.sleep()` with a fixed duration to wait for async operations. Instead:
4558
- Use `anyio.Event`set it in the callback/handler, `await event.wait()` in the test
4659
- For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()`

docs/experimental/tasks-server.md

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -408,16 +408,10 @@ For custom error messages, call `task.fail()` before raising.
408408
For web applications, use the Streamable HTTP transport:
409409

410410
```python
411-
from collections.abc import AsyncIterator
412-
from contextlib import asynccontextmanager
413-
414411
import uvicorn
415-
from starlette.applications import Starlette
416-
from starlette.routing import Mount
417412

418413
from mcp.server import Server
419414
from mcp.server.experimental.task_context import ServerTaskContext
420-
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
421415
from mcp.types import (
422416
CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, TASK_REQUIRED,
423417
)
@@ -462,22 +456,8 @@ async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTask
462456
return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True)
463457

464458

465-
def create_app():
466-
session_manager = StreamableHTTPSessionManager(app=server)
467-
468-
@asynccontextmanager
469-
async def lifespan(app: Starlette) -> AsyncIterator[None]:
470-
async with session_manager.run():
471-
yield
472-
473-
return Starlette(
474-
routes=[Mount("/mcp", app=session_manager.handle_request)],
475-
lifespan=lifespan,
476-
)
477-
478-
479459
if __name__ == "__main__":
480-
uvicorn.run(create_app(), host="127.0.0.1", port=8000)
460+
uvicorn.run(server.streamable_http_app(), host="127.0.0.1", port=8000)
481461
```
482462

483463
## Testing Task Servers

examples/servers/simple-pagination/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ A simple MCP server demonstrating pagination for tools, resources, and prompts u
44

55
## Usage
66

7-
Start the server using either stdio (default) or SSE transport:
7+
Start the server using either stdio (default) or Streamable HTTP transport:
88

99
```bash
1010
# Using stdio transport (default)
1111
uv run mcp-simple-pagination
1212

13-
# Using SSE transport on custom port
14-
uv run mcp-simple-pagination --transport sse --port 8000
13+
# Using Streamable HTTP transport on custom port
14+
uv run mcp-simple-pagination --transport streamable-http --port 8000
1515
```
1616

1717
The server exposes:

examples/servers/simple-pagination/mcp_simple_pagination/server.py

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import click
1111
from mcp import types
1212
from mcp.server import Server, ServerRequestContext
13-
from starlette.requests import Request
1413

1514
T = TypeVar("T")
1615

@@ -143,10 +142,10 @@ async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRe
143142

144143

145144
@click.command()
146-
@click.option("--port", default=8000, help="Port to listen on for SSE")
145+
@click.option("--port", default=8000, help="Port to listen on for HTTP")
147146
@click.option(
148147
"--transport",
149-
type=click.Choice(["stdio", "sse"]),
148+
type=click.Choice(["stdio", "streamable-http"]),
150149
default="stdio",
151150
help="Transport type",
152151
)
@@ -161,30 +160,10 @@ def main(port: int, transport: str) -> int:
161160
on_get_prompt=handle_get_prompt,
162161
)
163162

164-
if transport == "sse":
165-
from mcp.server.sse import SseServerTransport
166-
from starlette.applications import Starlette
167-
from starlette.responses import Response
168-
from starlette.routing import Mount, Route
169-
170-
sse = SseServerTransport("/messages/")
171-
172-
async def handle_sse(request: Request):
173-
async with sse.connect_sse(request.scope, request.receive, request._send) as streams: # type: ignore[reportPrivateUsage]
174-
await app.run(streams[0], streams[1], app.create_initialization_options())
175-
return Response()
176-
177-
starlette_app = Starlette(
178-
debug=True,
179-
routes=[
180-
Route("/sse", endpoint=handle_sse, methods=["GET"]),
181-
Mount("/messages/", app=sse.handle_post_message),
182-
],
183-
)
184-
163+
if transport == "streamable-http":
185164
import uvicorn
186165

187-
uvicorn.run(starlette_app, host="127.0.0.1", port=port)
166+
uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port)
188167
else:
189168
from mcp.server.stdio import stdio_server
190169

examples/servers/simple-prompt/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ A simple MCP server that exposes a customizable prompt template with optional co
44

55
## Usage
66

7-
Start the server using either stdio (default) or SSE transport:
7+
Start the server using either stdio (default) or Streamable HTTP transport:
88

99
```bash
1010
# Using stdio transport (default)
1111
uv run mcp-simple-prompt
1212

13-
# Using SSE transport on custom port
14-
uv run mcp-simple-prompt --transport sse --port 8000
13+
# Using Streamable HTTP transport on custom port
14+
uv run mcp-simple-prompt --transport streamable-http --port 8000
1515
```
1616

1717
The server exposes a prompt named "simple" that accepts two optional arguments:

examples/servers/simple-prompt/mcp_simple_prompt/server.py

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import click
33
from mcp import types
44
from mcp.server import Server, ServerRequestContext
5-
from starlette.requests import Request
65

76

87
def create_messages(context: str | None = None, topic: str | None = None) -> list[types.PromptMessage]:
@@ -69,10 +68,10 @@ async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRe
6968

7069

7170
@click.command()
72-
@click.option("--port", default=8000, help="Port to listen on for SSE")
71+
@click.option("--port", default=8000, help="Port to listen on for HTTP")
7372
@click.option(
7473
"--transport",
75-
type=click.Choice(["stdio", "sse"]),
74+
type=click.Choice(["stdio", "streamable-http"]),
7675
default="stdio",
7776
help="Transport type",
7877
)
@@ -83,30 +82,10 @@ def main(port: int, transport: str) -> int:
8382
on_get_prompt=handle_get_prompt,
8483
)
8584

86-
if transport == "sse":
87-
from mcp.server.sse import SseServerTransport
88-
from starlette.applications import Starlette
89-
from starlette.responses import Response
90-
from starlette.routing import Mount, Route
91-
92-
sse = SseServerTransport("/messages/")
93-
94-
async def handle_sse(request: Request):
95-
async with sse.connect_sse(request.scope, request.receive, request._send) as streams: # type: ignore[reportPrivateUsage]
96-
await app.run(streams[0], streams[1], app.create_initialization_options())
97-
return Response()
98-
99-
starlette_app = Starlette(
100-
debug=True,
101-
routes=[
102-
Route("/sse", endpoint=handle_sse),
103-
Mount("/messages/", app=sse.handle_post_message),
104-
],
105-
)
106-
85+
if transport == "streamable-http":
10786
import uvicorn
10887

109-
uvicorn.run(starlette_app, host="127.0.0.1", port=port)
88+
uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port)
11089
else:
11190
from mcp.server.stdio import stdio_server
11291

examples/servers/simple-resource/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ A simple MCP server that exposes sample text files as resources.
44

55
## Usage
66

7-
Start the server using either stdio (default) or SSE transport:
7+
Start the server using either stdio (default) or Streamable HTTP transport:
88

99
```bash
1010
# Using stdio transport (default)
1111
uv run mcp-simple-resource
1212

13-
# Using SSE transport on custom port
14-
uv run mcp-simple-resource --transport sse --port 8000
13+
# Using Streamable HTTP transport on custom port
14+
uv run mcp-simple-resource --transport streamable-http --port 8000
1515
```
1616

1717
The server exposes some basic text file resources that can be read by clients.

examples/servers/simple-resource/mcp_simple_resource/server.py

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import click
55
from mcp import types
66
from mcp.server import Server, ServerRequestContext
7-
from starlette.requests import Request
87

98
SAMPLE_RESOURCES = {
109
"greeting": {
@@ -62,10 +61,10 @@ async def handle_read_resource(
6261

6362

6463
@click.command()
65-
@click.option("--port", default=8000, help="Port to listen on for SSE")
64+
@click.option("--port", default=8000, help="Port to listen on for HTTP")
6665
@click.option(
6766
"--transport",
68-
type=click.Choice(["stdio", "sse"]),
67+
type=click.Choice(["stdio", "streamable-http"]),
6968
default="stdio",
7069
help="Transport type",
7170
)
@@ -76,30 +75,10 @@ def main(port: int, transport: str) -> int:
7675
on_read_resource=handle_read_resource,
7776
)
7877

79-
if transport == "sse":
80-
from mcp.server.sse import SseServerTransport
81-
from starlette.applications import Starlette
82-
from starlette.responses import Response
83-
from starlette.routing import Mount, Route
84-
85-
sse = SseServerTransport("/messages/")
86-
87-
async def handle_sse(request: Request):
88-
async with sse.connect_sse(request.scope, request.receive, request._send) as streams: # type: ignore[reportPrivateUsage]
89-
await app.run(streams[0], streams[1], app.create_initialization_options())
90-
return Response()
91-
92-
starlette_app = Starlette(
93-
debug=True,
94-
routes=[
95-
Route("/sse", endpoint=handle_sse, methods=["GET"]),
96-
Mount("/messages/", app=sse.handle_post_message),
97-
],
98-
)
99-
78+
if transport == "streamable-http":
10079
import uvicorn
10180

102-
uvicorn.run(starlette_app, host="127.0.0.1", port=port)
81+
uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port)
10382
else:
10483
from mcp.server.stdio import stdio_server
10584

examples/servers/simple-streamablehttp-stateless/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ A stateless MCP server example demonstrating the StreamableHttp transport withou
77
- Uses the StreamableHTTP transport in stateless mode (mcp_session_id=None)
88
- Each request creates a new ephemeral connection
99
- No session state maintained between requests
10-
- Task lifecycle scoped to individual requests
1110
- Suitable for deployment in multi-node environments
1211

1312
## Usage

examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py

Lines changed: 3 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
1-
import contextlib
21
import logging
3-
from collections.abc import AsyncIterator
42

53
import anyio
64
import click
75
import uvicorn
86
from mcp import types
97
from mcp.server import Server, ServerRequestContext
10-
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
11-
from starlette.applications import Starlette
128
from starlette.middleware.cors import CORSMiddleware
13-
from starlette.routing import Mount
14-
from starlette.types import Receive, Scope, Send
159

1610
logger = logging.getLogger(__name__)
1711

@@ -104,39 +98,17 @@ def main(
10498
on_call_tool=handle_call_tool,
10599
)
106100

107-
# Create the session manager with true stateless mode
108-
session_manager = StreamableHTTPSessionManager(
109-
app=app,
110-
event_store=None,
101+
starlette_app = app.streamable_http_app(
102+
stateless_http=True,
111103
json_response=json_response,
112-
stateless=True,
113-
)
114-
115-
async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None:
116-
await session_manager.handle_request(scope, receive, send)
117-
118-
@contextlib.asynccontextmanager
119-
async def lifespan(app: Starlette) -> AsyncIterator[None]:
120-
"""Context manager for session manager."""
121-
async with session_manager.run():
122-
logger.info("Application started with StreamableHTTP session manager!")
123-
try:
124-
yield
125-
finally:
126-
logger.info("Application shutting down...")
127-
128-
# Create an ASGI application using the transport
129-
starlette_app = Starlette(
130104
debug=True,
131-
routes=[Mount("/mcp", app=handle_streamable_http)],
132-
lifespan=lifespan,
133105
)
134106

135107
# Wrap ASGI application with CORS middleware to expose Mcp-Session-Id header
136108
# for browser-based clients (ensures 500 errors get proper CORS headers)
137109
starlette_app = CORSMiddleware(
138110
starlette_app,
139-
allow_origins=["*"], # Allow all origins - adjust as needed for production
111+
allow_origins=["*"], # Note: streamable_http_app() enforces localhost-only Origin by default
140112
allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods
141113
expose_headers=["Mcp-Session-Id"],
142114
)

0 commit comments

Comments
 (0)