Skip to content

Commit 3299b70

Browse files
authored
Merge branch 'main' into main
2 parents 68e840c + 80c0d23 commit 3299b70

File tree

8 files changed

+347
-17
lines changed

8 files changed

+347
-17
lines changed

examples/fastmcp/icons_demo.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
icon_data = base64.standard_b64encode(icon_path.read_bytes()).decode()
1515
icon_data_uri = f"data:image/png;base64,{icon_data}"
1616

17-
icon_data = Icon(src=icon_data_uri, mimeType="image/png", sizes="64x64")
17+
icon_data = Icon(src=icon_data_uri, mimeType="image/png", sizes=["64x64"])
1818

1919
# Create server with icons in implementation
2020
mcp = FastMCP("Icons Demo Server", website_url="https://github.com/modelcontextprotocol/python-sdk", icons=[icon_data])
@@ -40,9 +40,9 @@ def prompt_with_icon(text: str) -> str:
4040

4141
@mcp.tool(
4242
icons=[
43-
Icon(src=icon_data_uri, mimeType="image/png", sizes="16x16"),
44-
Icon(src=icon_data_uri, mimeType="image/png", sizes="32x32"),
45-
Icon(src=icon_data_uri, mimeType="image/png", sizes="64x64"),
43+
Icon(src=icon_data_uri, mimeType="image/png", sizes=["16x16"]),
44+
Icon(src=icon_data_uri, mimeType="image/png", sizes=["32x32"]),
45+
Icon(src=icon_data_uri, mimeType="image/png", sizes=["64x64"]),
4646
]
4747
)
4848
def multi_icon_tool(action: str) -> str:

src/mcp/client/stdio/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ async def stdout_reader():
154154
try:
155155
message = types.JSONRPCMessage.model_validate_json(line)
156156
except Exception as exc:
157+
logger.exception("Failed to parse JSONRPC message from server")
157158
await read_stream_writer.send(exc)
158159
continue
159160

src/mcp/server/auth/handlers/register.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ async def handle(self, request: Request) -> Response:
6868
),
6969
status_code=400,
7070
)
71-
if set(client_metadata.grant_types) != {"authorization_code", "refresh_token"}:
71+
if not {"authorization_code", "refresh_token"}.issubset(set(client_metadata.grant_types)):
7272
return PydanticJSONResponse(
7373
content=RegistrationErrorResponse(
7474
error="invalid_client_metadata",
@@ -77,6 +77,17 @@ async def handle(self, request: Request) -> Response:
7777
status_code=400,
7878
)
7979

80+
# The MCP spec requires servers to use the authorization `code` flow
81+
# with PKCE
82+
if "code" not in client_metadata.response_types:
83+
return PydanticJSONResponse(
84+
content=RegistrationErrorResponse(
85+
error="invalid_client_metadata",
86+
error_description="response_types must include 'code' for authorization_code grant",
87+
),
88+
status_code=400,
89+
)
90+
8091
client_id_issued_at = int(time.time())
8192
client_secret_expires_at = (
8293
client_id_issued_at + self.options.client_secret_expiry_seconds

src/mcp/shared/auth.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,13 @@ class OAuthClientMetadata(BaseModel):
4747
# ie: we do not support client_secret_basic
4848
token_endpoint_auth_method: Literal["none", "client_secret_post"] = "client_secret_post"
4949
# grant_types: this implementation only supports authorization_code & refresh_token
50-
grant_types: list[Literal["authorization_code", "refresh_token"]] = [
50+
grant_types: list[Literal["authorization_code", "refresh_token"] | str] = [
5151
"authorization_code",
5252
"refresh_token",
5353
]
54-
# this implementation only supports code; ie: it does not support implicit grants
55-
response_types: list[Literal["code"]] = ["code"]
54+
# The MCP spec requires the "code" response type, but OAuth
55+
# servers may also return additional types they support
56+
response_types: list[str] = ["code"]
5657
scope: str | None = None
5758

5859
# these fields are currently unused, but we support & store them for potential

src/mcp/types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,8 @@ class Icon(BaseModel):
222222
mimeType: str | None = None
223223
"""Optional MIME type for the icon."""
224224

225-
sizes: str | None = None
226-
"""Optional string specifying icon dimensions (e.g., "48x48 96x96")."""
225+
sizes: list[str] | None = None
226+
"""Optional list of strings specifying icon dimensions (e.g., ["48x48", "96x96"])."""
227227

228228
model_config = ConfigDict(extra="allow")
229229

tests/client/test_http_unicode.py

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

tests/issues/test_1338_icons_and_metadata.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ async def test_icons_and_website_url():
1515
test_icon = Icon(
1616
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
1717
mimeType="image/png",
18-
sizes="1x1",
18+
sizes=["1x1"],
1919
)
2020

2121
# Create server with website URL and icon
@@ -80,9 +80,9 @@ async def test_multiple_icons():
8080
"""Test that multiple icons can be added to tools, resources, and prompts."""
8181

8282
# Create multiple test icons
83-
icon1 = Icon(src="data:image/png;base64,icon1", mimeType="image/png", sizes="16x16")
84-
icon2 = Icon(src="data:image/png;base64,icon2", mimeType="image/png", sizes="32x32")
85-
icon3 = Icon(src="data:image/png;base64,icon3", mimeType="image/png", sizes="64x64")
83+
icon1 = Icon(src="data:image/png;base64,icon1", mimeType="image/png", sizes=["16x16"])
84+
icon2 = Icon(src="data:image/png;base64,icon2", mimeType="image/png", sizes=["32x32"])
85+
icon3 = Icon(src="data:image/png;base64,icon3", mimeType="image/png", sizes=["64x64"])
8686

8787
mcp = FastMCP("MultiIconServer")
8888

@@ -98,9 +98,9 @@ def multi_icon_tool() -> str:
9898
tool = tools[0]
9999
assert tool.icons is not None
100100
assert len(tool.icons) == 3
101-
assert tool.icons[0].sizes == "16x16"
102-
assert tool.icons[1].sizes == "32x32"
103-
assert tool.icons[2].sizes == "64x64"
101+
assert tool.icons[0].sizes == ["16x16"]
102+
assert tool.icons[1].sizes == ["32x32"]
103+
assert tool.icons[2].sizes == ["64x64"]
104104

105105

106106
async def test_no_icons_or_website():

0 commit comments

Comments
 (0)