Skip to content

Commit 40687f0

Browse files
committed
Merge origin/main (in-process transport tests #2764/2765/2767)
Conflict in tests/shared/test_streamable_http.py: kept the gh-106749 heal at the resumption-test cancel site, took main's in-process make_client/BASE_URL.
2 parents 1a4fc3a + 19fe9fa commit 40687f0

10 files changed

Lines changed: 1428 additions & 2086 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@
6565
tests. Don't silence warnings from your own code; fix the underlying cause.
6666
Scoped `ignore::` entries for upstream libraries are acceptable in
6767
`pyproject.toml` with a comment explaining why.
68+
- New features from the 2026-07-28 spec must have a matching test in the
69+
[conformance suite](https://github.com/modelcontextprotocol/conformance)
70+
that passes against this SDK (CI runs it via
71+
`.github/workflows/conformance.yml`). If no matching test exists, stop and
72+
tell the user so they can raise an issue on the conformance repo.
6873

6974
### Coverage
7075

src/mcp/server/streamable_http.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ def close_sse_stream(self, request_id: RequestId) -> None:
207207
send_stream.close()
208208
receive_stream.close()
209209

210-
def close_standalone_sse_stream(self) -> None: # pragma: no cover
210+
def close_standalone_sse_stream(self) -> None:
211211
"""Close the standalone GET SSE stream, triggering client reconnection.
212212
213213
This method closes the HTTP connection for the standalone GET stream used
@@ -221,8 +221,6 @@ def close_standalone_sse_stream(self) -> None: # pragma: no cover
221221
This is a no-op if there is no active standalone SSE stream.
222222
Requires event_store to be configured for events to be stored during
223223
the disconnect.
224-
Currently, client reconnection for standalone GET streams is NOT
225-
implemented - this is a known gap (see test_standalone_get_stream_reconnection).
226224
"""
227225
self.close_sse_stream(GET_STREAM_KEY)
228226

@@ -245,7 +243,7 @@ def _create_session_message(
245243
async def close_stream_callback() -> None:
246244
self.close_sse_stream(request_id)
247245

248-
async def close_standalone_stream_callback() -> None: # pragma: no cover
246+
async def close_standalone_stream_callback() -> None:
249247
self.close_standalone_sse_stream()
250248

251249
metadata = ServerMessageMetadata(
@@ -421,7 +419,7 @@ async def _validate_accept_header(self, request: Request, scope: Scope, send: Se
421419
has_json, has_sse = self._check_accept_headers(request)
422420
if self.is_json_response_enabled:
423421
# For JSON-only responses, only require application/json
424-
if not has_json: # pragma: no cover
422+
if not has_json:
425423
response = self._create_error_response(
426424
"Not Acceptable: Client must accept application/json",
427425
HTTPStatus.NOT_ACCEPTABLE,
@@ -672,7 +670,7 @@ async def _handle_get_request(self, request: Request, send: Send) -> None:
672670
await response(request.scope, request.receive, send)
673671
return
674672

675-
if not await self._validate_request_headers(request, send): # pragma: no cover
673+
if not await self._validate_request_headers(request, send):
676674
return
677675

678676
# Handle resumability: check for Last-Event-ID header

tests/client/test_http_unicode.py

Lines changed: 112 additions & 176 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@
44
(server→client and client→server) using the streamable HTTP transport.
55
"""
66

7-
import multiprocessing
8-
import socket
9-
from collections.abc import AsyncGenerator, Generator
7+
from collections.abc import AsyncIterator
108
from contextlib import asynccontextmanager
119

10+
import httpx
1211
import pytest
1312
from starlette.applications import Starlette
1413
from starlette.routing import Mount
@@ -19,7 +18,10 @@
1918
from mcp.server import Server, ServerRequestContext
2019
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
2120
from mcp.types import TextContent, Tool
22-
from tests.test_helpers import wait_for_server
21+
from tests.interaction.transports import StreamingASGITransport
22+
23+
# The in-process app is mounted at this origin purely so URLs are well-formed; nothing listens here.
24+
BASE_URL = "http://127.0.0.1:8000"
2325

2426
# Test constants with various Unicode characters
2527
UNICODE_TEST_STRINGS = {
@@ -41,197 +43,131 @@
4143
}
4244

4345

44-
def run_unicode_server(port: int) -> None: # pragma: no cover
45-
"""Run the Unicode test server in a separate process."""
46-
import uvicorn
47-
48-
# Need to recreate the server setup in this process
49-
async def handle_list_tools(
50-
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
51-
) -> types.ListToolsResult:
52-
return types.ListToolsResult(
53-
tools=[
54-
Tool(
55-
name="echo_unicode",
56-
description="🔤 Echo Unicode text - Hello 👋 World 🌍 - Testing 🧪 Unicode ✨",
57-
input_schema={
58-
"type": "object",
59-
"properties": {
60-
"text": {"type": "string", "description": "Text to echo back"},
61-
},
62-
"required": ["text"],
46+
async def handle_list_tools(
47+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
48+
) -> types.ListToolsResult:
49+
return types.ListToolsResult(
50+
tools=[
51+
Tool(
52+
name="echo_unicode",
53+
description="🔤 Echo Unicode text - Hello 👋 World 🌍 - Testing 🧪 Unicode ✨",
54+
input_schema={
55+
"type": "object",
56+
"properties": {
57+
"text": {"type": "string", "description": "Text to echo back"},
6358
},
64-
),
65-
]
66-
)
67-
68-
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
69-
if params.name == "echo_unicode":
70-
text = params.arguments.get("text", "") if params.arguments else ""
71-
return types.CallToolResult(
72-
content=[
73-
TextContent(
74-
type="text",
75-
text=f"Echo: {text}",
76-
)
77-
]
59+
"required": ["text"],
60+
},
61+
),
62+
]
63+
)
64+
65+
66+
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
67+
assert params.name == "echo_unicode"
68+
assert params.arguments is not None
69+
return types.CallToolResult(content=[TextContent(type="text", text=f"Echo: {params.arguments['text']}")])
70+
71+
72+
async def handle_list_prompts(
73+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
74+
) -> types.ListPromptsResult:
75+
return types.ListPromptsResult(
76+
prompts=[
77+
types.Prompt(
78+
name="unicode_prompt",
79+
description="Unicode prompt - Слой хранилища, где располагаются",
80+
arguments=[],
7881
)
79-
else:
80-
raise ValueError(f"Unknown tool: {params.name}")
81-
82-
async def handle_list_prompts(
83-
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
84-
) -> types.ListPromptsResult:
85-
return types.ListPromptsResult(
86-
prompts=[
87-
types.Prompt(
88-
name="unicode_prompt",
89-
description="Unicode prompt - Слой хранилища, где располагаются",
90-
arguments=[],
91-
)
92-
]
93-
)
94-
95-
async def handle_get_prompt(
96-
ctx: ServerRequestContext, params: types.GetPromptRequestParams
97-
) -> types.GetPromptResult:
98-
if params.name == "unicode_prompt":
99-
return types.GetPromptResult(
100-
messages=[
101-
types.PromptMessage(
102-
role="user",
103-
content=types.TextContent(
104-
type="text",
105-
text="Hello世界🌍Привет안녕مرحباשלום",
106-
),
107-
)
108-
]
82+
]
83+
)
84+
85+
86+
async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult:
87+
assert params.name == "unicode_prompt"
88+
return types.GetPromptResult(
89+
messages=[
90+
types.PromptMessage(
91+
role="user",
92+
content=types.TextContent(type="text", text="Hello世界🌍Привет안녕مرحباשלום"),
10993
)
110-
raise ValueError(f"Unknown prompt: {params.name}")
94+
]
95+
)
11196

97+
98+
@asynccontextmanager
99+
async def unicode_session() -> AsyncIterator[ClientSession]:
100+
"""Yield an initialized ClientSession speaking streamable HTTP (SSE responses) to the
101+
Unicode test server, entirely in process."""
112102
server = Server(
113103
name="unicode_test_server",
114104
on_list_tools=handle_list_tools,
115105
on_call_tool=handle_call_tool,
116106
on_list_prompts=handle_list_prompts,
117107
on_get_prompt=handle_get_prompt,
118108
)
119-
120-
# Create the session manager
121-
session_manager = StreamableHTTPSessionManager(
122-
app=server,
123-
json_response=False, # Use SSE for testing
124-
)
125-
126-
@asynccontextmanager
127-
async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
128-
async with session_manager.run():
129-
yield
130-
131-
# Create an ASGI application
132-
app = Starlette(
133-
debug=True,
134-
routes=[
135-
Mount("/mcp", app=session_manager.handle_request),
136-
],
137-
lifespan=lifespan,
138-
)
139-
140-
# Run the server
141-
config = uvicorn.Config(
142-
app=app,
143-
host="127.0.0.1",
144-
port=port,
145-
log_level="error",
146-
)
147-
uvicorn_server = uvicorn.Server(config)
148-
uvicorn_server.run()
149-
150-
151-
@pytest.fixture
152-
def unicode_server_port() -> int:
153-
"""Find an available port for the Unicode test server."""
154-
with socket.socket() as s:
155-
s.bind(("127.0.0.1", 0))
156-
return s.getsockname()[1]
157-
158-
159-
@pytest.fixture
160-
def running_unicode_server(unicode_server_port: int) -> Generator[str, None, None]:
161-
"""Start a Unicode test server in a separate process."""
162-
proc = multiprocessing.Process(target=run_unicode_server, kwargs={"port": unicode_server_port}, daemon=True)
163-
proc.start()
164-
165-
# Wait for server to be ready
166-
wait_for_server(unicode_server_port)
167-
168-
try:
169-
yield f"http://127.0.0.1:{unicode_server_port}"
170-
finally:
171-
# Clean up - try graceful termination first
172-
proc.terminate()
173-
proc.join(timeout=2)
174-
if proc.is_alive(): # pragma: no cover
175-
proc.kill()
176-
proc.join(timeout=1)
109+
# SSE response mode, so Unicode rides the SSE event encoding rather than a plain JSON body.
110+
session_manager = StreamableHTTPSessionManager(app=server, json_response=False)
111+
app = Starlette(routes=[Mount("/mcp", app=session_manager.handle_request)])
112+
113+
async with (
114+
session_manager.run(),
115+
# follow_redirects matches the SDK's own client factory; Starlette's Mount 307-redirects
116+
# the bare /mcp path to /mcp/.
117+
httpx.AsyncClient(
118+
transport=StreamingASGITransport(app), base_url=BASE_URL, follow_redirects=True
119+
) as http_client,
120+
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) as (read_stream, write_stream),
121+
ClientSession(read_stream, write_stream) as session,
122+
):
123+
await session.initialize()
124+
yield session
177125

178126

179127
@pytest.mark.anyio
180-
async def test_streamable_http_client_unicode_tool_call(running_unicode_server: str) -> None:
128+
async def test_streamable_http_client_unicode_tool_call() -> None:
181129
"""Test that Unicode text is correctly handled in tool calls via streamable HTTP."""
182-
base_url = running_unicode_server
183-
endpoint_url = f"{base_url}/mcp"
184-
185-
async with streamable_http_client(endpoint_url) as (read_stream, write_stream):
186-
async with ClientSession(read_stream, write_stream) as session:
187-
await session.initialize()
188-
189-
# Test 1: List tools (server→client Unicode in descriptions)
190-
tools = await session.list_tools()
191-
assert len(tools.tools) == 1
130+
async with unicode_session() as session:
131+
# Test 1: List tools (server→client Unicode in descriptions)
132+
tools = await session.list_tools()
133+
assert len(tools.tools) == 1
192134

193-
# Check Unicode in tool descriptions
194-
echo_tool = tools.tools[0]
195-
assert echo_tool.name == "echo_unicode"
196-
assert echo_tool.description is not None
197-
assert "🔤" in echo_tool.description
198-
assert "👋" in echo_tool.description
135+
# Check Unicode in tool descriptions
136+
echo_tool = tools.tools[0]
137+
assert echo_tool.name == "echo_unicode"
138+
assert echo_tool.description is not None
139+
assert "🔤" in echo_tool.description
140+
assert "👋" in echo_tool.description
199141

200-
# Test 2: Send Unicode text in tool call (client→server→client)
201-
for test_name, test_string in UNICODE_TEST_STRINGS.items():
202-
result = await session.call_tool("echo_unicode", arguments={"text": test_string})
142+
# Test 2: Send Unicode text in tool call (client→server→client)
143+
for test_name, test_string in UNICODE_TEST_STRINGS.items():
144+
result = await session.call_tool("echo_unicode", arguments={"text": test_string})
203145

204-
# Verify server correctly received and echoed back Unicode
205-
assert len(result.content) == 1
206-
content = result.content[0]
207-
assert content.type == "text"
208-
assert f"Echo: {test_string}" == content.text, f"Failed for {test_name}"
146+
# Verify server correctly received and echoed back Unicode
147+
assert len(result.content) == 1
148+
content = result.content[0]
149+
assert content.type == "text"
150+
assert f"Echo: {test_string}" == content.text, f"Failed for {test_name}"
209151

210152

211153
@pytest.mark.anyio
212-
async def test_streamable_http_client_unicode_prompts(running_unicode_server: str) -> None:
154+
async def test_streamable_http_client_unicode_prompts() -> None:
213155
"""Test that Unicode text is correctly handled in prompts via streamable HTTP."""
214-
base_url = running_unicode_server
215-
endpoint_url = f"{base_url}/mcp"
216-
217-
async with streamable_http_client(endpoint_url) as (read_stream, write_stream):
218-
async with ClientSession(read_stream, write_stream) as session:
219-
await session.initialize()
220-
221-
# Test 1: List prompts (server→client Unicode in descriptions)
222-
prompts = await session.list_prompts()
223-
assert len(prompts.prompts) == 1
224-
225-
prompt = prompts.prompts[0]
226-
assert prompt.name == "unicode_prompt"
227-
assert prompt.description is not None
228-
assert "Слой хранилища, где располагаются" in prompt.description
229-
230-
# Test 2: Get prompt with Unicode content (server→client)
231-
result = await session.get_prompt("unicode_prompt", arguments={})
232-
assert len(result.messages) == 1
233-
234-
message = result.messages[0]
235-
assert message.role == "user"
236-
assert message.content.type == "text"
237-
assert message.content.text == "Hello世界🌍Привет안녕مرحباשלום"
156+
async with unicode_session() as session:
157+
# Test 1: List prompts (server→client Unicode in descriptions)
158+
prompts = await session.list_prompts()
159+
assert len(prompts.prompts) == 1
160+
161+
prompt = prompts.prompts[0]
162+
assert prompt.name == "unicode_prompt"
163+
assert prompt.description is not None
164+
assert "Слой хранилища, где располагаются" in prompt.description
165+
166+
# Test 2: Get prompt with Unicode content (server→client)
167+
result = await session.get_prompt("unicode_prompt", arguments={})
168+
assert len(result.messages) == 1
169+
170+
message = result.messages[0]
171+
assert message.role == "user"
172+
assert message.content.type == "text"
173+
assert message.content.text == "Hello世界🌍Привет안녕مرحباשלום"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Transport-specific interaction tests, and the in-process streaming bridge they are built on.
2+
3+
`StreamingASGITransport` is re-exported here as the sanctioned import point for test code
4+
outside this suite (the bridge module itself is suite-private).
5+
"""
6+
7+
from tests.interaction.transports._bridge import StreamingASGITransport
8+
9+
__all__ = ["StreamingASGITransport"]

0 commit comments

Comments
 (0)