Skip to content

Commit 0884269

Browse files
committed
Add comprehensive Unicode tests for streamable HTTP transport
- Test Unicode text transmission in both directions (client→server→client) - Verify Unicode handling in tool descriptions, arguments, and responses - Verify Unicode handling in prompt descriptions and content - Test with various Unicode scripts including Cyrillic, Chinese, Japanese, Korean, Arabic, Hebrew, Greek, emoji, and special characters - Use multiprocessing to run server in separate process (avoids ResourceWarnings) - Follow existing test patterns from test_streamable_http_manager.py
1 parent 9323efa commit 0884269

File tree

1 file changed

+247
-0
lines changed

1 file changed

+247
-0
lines changed

tests/client/test_http_unicode.py

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
"""
2+
Tests for Unicode handling in streamable HTTP transport.
3+
4+
Verifies that Unicode text is correctly transmitted and received in both directions
5+
(server→client and client→server) using the streamable HTTP transport.
6+
"""
7+
8+
from collections.abc import Generator
9+
10+
import pytest
11+
12+
from mcp.client.session import ClientSession
13+
from mcp.client.streamable_http import streamablehttp_client
14+
15+
# Test constants with various Unicode characters
16+
UNICODE_TEST_STRINGS = {
17+
"cyrillic": "Слой хранилища, где располагаются",
18+
"cyrillic_short": "Привет мир",
19+
"chinese": "你好世界 - 这是一个测试",
20+
"japanese": "こんにちは世界 - これはテストです",
21+
"korean": "안녕하세요 세계 - 이것은 테스트입니다",
22+
"arabic": "مرحبا بالعالم - هذا اختبار",
23+
"hebrew": "שלום עולם - זה מבחן",
24+
"greek": "Γεια σου κόσμε - αυτό είναι δοκιμή",
25+
"emoji": "Hello 👋 World 🌍 - Testing 🧪 Unicode ✨",
26+
"math": "∑ ∫ √ ∞ ≠ ≤ ≥ ∈ ∉ ⊆ ⊇",
27+
"accented": "Café, naïve, résumé, piñata, Zürich",
28+
"mixed": "Hello世界🌍Привет안녕مرحباשלום",
29+
"special": "Line\nbreak\ttab\r\nCRLF",
30+
"quotes": '«French» „German" "English" 「Japanese」',
31+
"currency": "€100 £50 ¥1000 ₹500 ₽200 ¢99",
32+
}
33+
34+
35+
def run_unicode_server(port: int) -> None:
36+
"""Run the Unicode test server in a separate process."""
37+
# Import inside the function since this runs in a separate process
38+
from collections.abc import AsyncGenerator
39+
from contextlib import asynccontextmanager
40+
from typing import Any
41+
42+
import uvicorn
43+
from starlette.applications import Starlette
44+
from starlette.routing import Mount
45+
46+
import mcp.types as types
47+
from mcp.server import Server
48+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
49+
from mcp.types import TextContent, Tool
50+
51+
# Need to recreate the server setup in this process
52+
server = Server(name="unicode_test_server")
53+
54+
@server.list_tools()
55+
async def list_tools() -> list[Tool]:
56+
"""List tools with Unicode descriptions."""
57+
return [
58+
Tool(
59+
name="echo_unicode",
60+
description="🔤 Echo Unicode text - Hello 👋 World 🌍 - Testing 🧪 Unicode ✨",
61+
inputSchema={
62+
"type": "object",
63+
"properties": {
64+
"text": {"type": "string", "description": "Text to echo back"},
65+
},
66+
"required": ["text"],
67+
},
68+
),
69+
]
70+
71+
@server.call_tool()
72+
async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[TextContent]:
73+
"""Handle tool calls with Unicode content."""
74+
if name == "echo_unicode":
75+
text = arguments.get("text", "") if arguments else ""
76+
return [
77+
TextContent(
78+
type="text",
79+
text=f"Echo: {text}",
80+
)
81+
]
82+
else:
83+
raise ValueError(f"Unknown tool: {name}")
84+
85+
@server.list_prompts()
86+
async def list_prompts() -> list[types.Prompt]:
87+
"""List prompts with Unicode names and descriptions."""
88+
return [
89+
types.Prompt(
90+
name="unicode_prompt",
91+
description="Unicode prompt - Слой хранилища, где располагаются",
92+
arguments=[],
93+
)
94+
]
95+
96+
@server.get_prompt()
97+
async def get_prompt(name: str, arguments: dict[str, Any] | None) -> types.GetPromptResult:
98+
"""Get a prompt with Unicode content."""
99+
if name == "unicode_prompt":
100+
return types.GetPromptResult(
101+
messages=[
102+
types.PromptMessage(
103+
role="user",
104+
content=types.TextContent(
105+
type="text",
106+
text="Hello世界🌍Привет안녕مرحباשלום",
107+
),
108+
)
109+
]
110+
)
111+
raise ValueError(f"Unknown prompt: {name}")
112+
113+
# Create the session manager
114+
session_manager = StreamableHTTPSessionManager(
115+
app=server,
116+
json_response=False, # Use SSE for testing
117+
)
118+
119+
@asynccontextmanager
120+
async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
121+
async with session_manager.run():
122+
yield
123+
124+
# Create an ASGI application
125+
app = Starlette(
126+
debug=True,
127+
routes=[
128+
Mount("/mcp", app=session_manager.handle_request),
129+
],
130+
lifespan=lifespan,
131+
)
132+
133+
# Run the server
134+
config = uvicorn.Config(
135+
app=app,
136+
host="127.0.0.1",
137+
port=port,
138+
log_level="error",
139+
)
140+
uvicorn_server = uvicorn.Server(config)
141+
uvicorn_server.run()
142+
143+
144+
@pytest.fixture
145+
def unicode_server_port() -> int:
146+
"""Find an available port for the Unicode test server."""
147+
import socket
148+
149+
with socket.socket() as s:
150+
s.bind(("127.0.0.1", 0))
151+
return s.getsockname()[1]
152+
153+
154+
@pytest.fixture
155+
def running_unicode_server(unicode_server_port: int) -> Generator[str, None, None]:
156+
"""Start a Unicode test server in a separate process."""
157+
import multiprocessing
158+
import socket
159+
import time
160+
161+
proc = multiprocessing.Process(target=run_unicode_server, kwargs={"port": unicode_server_port}, daemon=True)
162+
proc.start()
163+
164+
# Wait for server to be running
165+
max_attempts = 20
166+
attempt = 0
167+
while attempt < max_attempts:
168+
try:
169+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
170+
s.connect(("127.0.0.1", unicode_server_port))
171+
break
172+
except ConnectionRefusedError:
173+
time.sleep(0.1)
174+
attempt += 1
175+
else:
176+
raise RuntimeError(f"Server failed to start after {max_attempts} attempts")
177+
178+
try:
179+
yield f"http://127.0.0.1:{unicode_server_port}"
180+
finally:
181+
# Clean up - try graceful termination first
182+
proc.terminate()
183+
proc.join(timeout=2)
184+
if proc.is_alive():
185+
proc.kill()
186+
proc.join(timeout=1)
187+
188+
189+
@pytest.mark.anyio
190+
async def test_streamable_http_client_unicode_tool_call(running_unicode_server: str) -> None:
191+
"""Test that Unicode text is correctly handled in tool calls via streamable HTTP."""
192+
base_url = running_unicode_server
193+
endpoint_url = f"{base_url}/mcp"
194+
195+
async with streamablehttp_client(endpoint_url) as (read_stream, write_stream, _get_session_id):
196+
async with ClientSession(read_stream, write_stream) as session:
197+
await session.initialize()
198+
199+
# Test 1: List tools (server→client Unicode in descriptions)
200+
tools = await session.list_tools()
201+
assert len(tools.tools) == 1
202+
203+
# Check Unicode in tool descriptions
204+
echo_tool = tools.tools[0]
205+
assert echo_tool.name == "echo_unicode"
206+
assert echo_tool.description is not None
207+
assert "🔤" in echo_tool.description
208+
assert "👋" in echo_tool.description
209+
210+
# Test 2: Send Unicode text in tool call (client→server→client)
211+
for test_name, test_string in UNICODE_TEST_STRINGS.items():
212+
result = await session.call_tool("echo_unicode", arguments={"text": test_string})
213+
214+
# Verify server correctly received and echoed back Unicode
215+
assert len(result.content) == 1
216+
content = result.content[0]
217+
assert content.type == "text"
218+
assert f"Echo: {test_string}" == content.text, f"Failed for {test_name}"
219+
220+
221+
@pytest.mark.anyio
222+
async def test_streamable_http_client_unicode_prompts(running_unicode_server: str) -> None:
223+
"""Test that Unicode text is correctly handled in prompts via streamable HTTP."""
224+
base_url = running_unicode_server
225+
endpoint_url = f"{base_url}/mcp"
226+
227+
async with streamablehttp_client(endpoint_url) as (read_stream, write_stream, _get_session_id):
228+
async with ClientSession(read_stream, write_stream) as session:
229+
await session.initialize()
230+
231+
# Test 1: List prompts (server→client Unicode in descriptions)
232+
prompts = await session.list_prompts()
233+
assert len(prompts.prompts) == 1
234+
235+
prompt = prompts.prompts[0]
236+
assert prompt.name == "unicode_prompt"
237+
assert prompt.description is not None
238+
assert "Слой хранилища, где располагаются" in prompt.description
239+
240+
# Test 2: Get prompt with Unicode content (server→client)
241+
result = await session.get_prompt("unicode_prompt", arguments={})
242+
assert len(result.messages) == 1
243+
244+
message = result.messages[0]
245+
assert message.role == "user"
246+
assert message.content.type == "text"
247+
assert message.content.text == "Hello世界🌍Привет안녕مرحباשלום"

0 commit comments

Comments
 (0)