Skip to content

Commit bb1a218

Browse files
authored
Merge branch 'main' into fix/stateless-task-group-leak
2 parents 0d513a6 + 92c693b commit bb1a218

File tree

50 files changed

+1446
-1542
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1446
-1542
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/api.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

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

docs/hooks/gen_ref_pages.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Generate the code reference pages and navigation."""
2+
3+
from pathlib import Path
4+
5+
import mkdocs_gen_files
6+
7+
nav = mkdocs_gen_files.Nav()
8+
9+
root = Path(__file__).parent.parent.parent
10+
src = root / "src"
11+
12+
for path in sorted(src.rglob("*.py")):
13+
module_path = path.relative_to(src).with_suffix("")
14+
doc_path = path.relative_to(src).with_suffix(".md")
15+
full_doc_path = Path("api", doc_path)
16+
17+
parts = tuple(module_path.parts)
18+
19+
if parts[-1] == "__init__":
20+
parts = parts[:-1]
21+
doc_path = doc_path.with_name("index.md")
22+
full_doc_path = full_doc_path.with_name("index.md")
23+
elif parts[-1].startswith("_"):
24+
continue
25+
26+
nav[parts] = doc_path.as_posix()
27+
28+
with mkdocs_gen_files.open(full_doc_path, "w") as fd:
29+
ident = ".".join(parts)
30+
fd.write(f"::: {ident}")
31+
32+
mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root))
33+
34+
with mkdocs_gen_files.open("api/SUMMARY.md", "w") as nav_file:
35+
nav_file.writelines(nav.build_literate_nav())

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,4 @@ npx -y @modelcontextprotocol/inspector
6464

6565
## API Reference
6666

67-
Full API documentation is available in the [API Reference](api.md).
67+
Full API documentation is available in the [API Reference](api/mcp/index.md).

docs/migration.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,30 @@ result = await session.list_resources(params=PaginatedRequestParams(cursor="next
169169
result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token"))
170170
```
171171

172+
### `ClientSession.get_server_capabilities()` replaced by `initialize_result` property
173+
174+
`ClientSession` now stores the full `InitializeResult` via an `initialize_result` property. This provides access to `server_info`, `capabilities`, `instructions`, and the negotiated `protocol_version` through a single property. The `get_server_capabilities()` method has been removed.
175+
176+
**Before (v1):**
177+
178+
```python
179+
capabilities = session.get_server_capabilities()
180+
# server_info, instructions, protocol_version were not stored — had to capture initialize() return value
181+
```
182+
183+
**After (v2):**
184+
185+
```python
186+
result = session.initialize_result
187+
if result is not None:
188+
capabilities = result.capabilities
189+
server_info = result.server_info
190+
instructions = result.instructions
191+
version = result.protocol_version
192+
```
193+
194+
The high-level `Client.initialize_result` returns the same `InitializeResult` but is non-nullable — initialization is guaranteed inside the context manager, so no `None` check is needed. This replaces v1's `Client.server_capabilities`; use `client.initialize_result.capabilities` instead.
195+
172196
### `McpError` renamed to `MCPError`
173197

174198
The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK.
@@ -859,6 +883,6 @@ The lowlevel `Server` also now exposes a `session_manager` property to access th
859883

860884
If you encounter issues during migration:
861885

862-
1. Check the [API Reference](api.md) for updated method signatures
886+
1. Check the [API Reference](api/mcp/index.md) for updated method signatures
863887
2. Review the [examples](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples) for updated usage patterns
864888
3. Open an issue on [GitHub](https://github.com/modelcontextprotocol/python-sdk/issues) if you find a bug or need further assistance

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

0 commit comments

Comments
 (0)