chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
import pytest
from mcp.server.mcpserver import MCPServer
pytestmark = pytest.mark.anyio
async def test_list_tools_returns_all_tools():
mcp = MCPServer("TestTools")
# Create 100 tools with unique names
num_tools = 100
for i in range(num_tools):
@mcp.tool(name=f"tool_{i}")
def dummy_tool_func(): # pragma: no cover
f"""Tool number {i}"""
return i
globals()[f"dummy_tool_{i}"] = dummy_tool_func # Keep reference to avoid garbage collection
# Get all tools
tools = await mcp.list_tools()
# Verify we get all tools
assert len(tools) == num_tools, f"Expected {num_tools} tools, but got {len(tools)}"
# Verify each tool is unique and has the correct name
tool_names = [tool.name for tool in tools]
expected_names = [f"tool_{i}" for i in range(num_tools)]
assert sorted(tool_names) == sorted(expected_names), "Tool names don't match expected names"
@@ -0,0 +1,33 @@
import pytest
from mcp import Client
from mcp.server.mcpserver import MCPServer
@pytest.mark.anyio
async def test_resource_templates():
mcp = MCPServer("Demo")
@mcp.resource("greeting://{name}")
def get_greeting(name: str) -> str: # pragma: no cover
"""Get a personalized greeting"""
return f"Hello, {name}!"
@mcp.resource("users://{user_id}/profile")
def get_user_profile(user_id: str) -> str: # pragma: no cover
"""Dynamic user data"""
return f"Profile data for user {user_id}"
async with Client(mcp) as client:
result = await client.list_resource_templates()
templates = result.resource_templates
assert len(templates) == 2
greeting_template = next(t for t in templates if t.name == "get_greeting")
assert greeting_template.uri_template == "greeting://{name}"
assert greeting_template.description == "Get a personalized greeting"
profile_template = next(t for t in templates if t.name == "get_user_profile")
assert profile_template.uri_template == "users://{user_id}/profile"
assert profile_template.description == "Dynamic user data"
@@ -0,0 +1,142 @@
"""Test icon and metadata support (SEP-973)."""
import pytest
from mcp_types import Icon
from mcp.server.mcpserver import MCPServer
pytestmark = pytest.mark.anyio
async def test_icons_and_website_url():
"""Test that icons and websiteUrl are properly returned in API calls."""
# Create test icon
test_icon = Icon(
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
mime_type="image/png",
sizes=["1x1"],
)
# Create server with website URL and icon
mcp = MCPServer("TestServer", website_url="https://example.com", icons=[test_icon])
# Create tool with icon
@mcp.tool(icons=[test_icon])
def test_tool(message: str) -> str: # pragma: no cover
"""A test tool with an icon."""
return message
# Create resource with icon
@mcp.resource("test://resource", icons=[test_icon])
def test_resource() -> str: # pragma: no cover
"""A test resource with an icon."""
return "test content"
# Create prompt with icon
@mcp.prompt("test_prompt", icons=[test_icon])
def test_prompt(text: str) -> str: # pragma: no cover
"""A test prompt with an icon."""
return text
# Create resource template with icon
@mcp.resource("test://weather/{city}", icons=[test_icon])
def test_resource_template(city: str) -> str: # pragma: no cover
"""Get weather for a city."""
return f"Weather for {city}"
# Test server metadata includes websiteUrl and icons
assert mcp.name == "TestServer"
assert mcp.website_url == "https://example.com"
assert mcp.icons is not None
assert len(mcp.icons) == 1
assert mcp.icons[0].src == test_icon.src
assert mcp.icons[0].mime_type == test_icon.mime_type
assert mcp.icons[0].sizes == test_icon.sizes
# Test tool includes icon
tools = await mcp.list_tools()
assert len(tools) == 1
tool = tools[0]
assert tool.name == "test_tool"
assert tool.icons is not None
assert len(tool.icons) == 1
assert tool.icons[0].src == test_icon.src
# Test resource includes icon
resources = await mcp.list_resources()
assert len(resources) == 1
resource = resources[0]
assert str(resource.uri) == "test://resource"
assert resource.icons is not None
assert len(resource.icons) == 1
assert resource.icons[0].src == test_icon.src
# Test prompt includes icon
prompts = await mcp.list_prompts()
assert len(prompts) == 1
prompt = prompts[0]
assert prompt.name == "test_prompt"
assert prompt.icons is not None
assert len(prompt.icons) == 1
assert prompt.icons[0].src == test_icon.src
# Test resource template includes icon
templates = await mcp.list_resource_templates()
assert len(templates) == 1
template = templates[0]
assert template.name == "test_resource_template"
assert template.uri_template == "test://weather/{city}"
assert template.icons is not None
assert len(template.icons) == 1
assert template.icons[0].src == test_icon.src
async def test_multiple_icons():
"""Test that multiple icons can be added to tools, resources, and prompts."""
# Create multiple test icons
icon1 = Icon(src="data:image/png;base64,icon1", mime_type="image/png", sizes=["16x16"])
icon2 = Icon(src="data:image/png;base64,icon2", mime_type="image/png", sizes=["32x32"])
icon3 = Icon(src="data:image/png;base64,icon3", mime_type="image/png", sizes=["64x64"])
mcp = MCPServer("MultiIconServer")
# Create tool with multiple icons
@mcp.tool(icons=[icon1, icon2, icon3])
def multi_icon_tool() -> str: # pragma: no cover
"""A tool with multiple icons."""
return "success"
# Test tool has all icons
tools = await mcp.list_tools()
assert len(tools) == 1
tool = tools[0]
assert tool.icons is not None
assert len(tool.icons) == 3
assert tool.icons[0].sizes == ["16x16"]
assert tool.icons[1].sizes == ["32x32"]
assert tool.icons[2].sizes == ["64x64"]
async def test_no_icons_or_website():
"""Test that server works without icons or websiteUrl."""
mcp = MCPServer("BasicServer")
@mcp.tool()
def basic_tool() -> str: # pragma: no cover
"""A basic tool without icons."""
return "success"
# Test server metadata has no websiteUrl or icons
assert mcp.name == "BasicServer"
assert mcp.website_url is None
assert mcp.icons is None
# Test tool has no icons
tools = await mcp.list_tools()
assert len(tools) == 1
tool = tools[0]
assert tool.name == "basic_tool"
assert tool.icons is None
@@ -0,0 +1,283 @@
"""Test for issue #1363 - Race condition in StreamableHTTP transport causes ClosedResourceError.
This test reproduces the race condition described in issue #1363 where MCP servers
in HTTP Streamable mode experience ClosedResourceError exceptions when requests
fail validation early (e.g., due to incorrect Accept headers).
The race condition occurs because:
1. Transport setup creates a message_router task
2. Message router enters async for write_stream_reader loop
3. write_stream_reader calls checkpoint() in receive(), yielding control
4. Request handling processes HTTP request
5. If validation fails early, request returns immediately
6. Transport termination closes all streams including write_stream_reader
7. Message router may still be in checkpoint() yield and hasn't returned to check stream state
8. When message router resumes, it encounters a closed stream, raising ClosedResourceError
"""
import logging
import threading
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import anyio
import anyio.to_thread
import httpx
import pytest
from starlette.applications import Starlette
from starlette.routing import Mount
from mcp.server import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
SERVER_NAME = "test_race_condition_server"
class RaceConditionTestServer(Server):
def __init__(self):
super().__init__(SERVER_NAME)
def create_app(json_response: bool = False) -> Starlette:
"""Create a Starlette application for testing."""
app = RaceConditionTestServer()
# Create session manager
session_manager = StreamableHTTPSessionManager(
app=app,
json_response=json_response,
stateless=True, # Use stateless mode to trigger the race condition
)
# Create Starlette app with lifespan
@asynccontextmanager
async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
async with session_manager.run():
yield
routes = [
Mount("/", app=session_manager.handle_request),
]
return Starlette(routes=routes, lifespan=lifespan)
class ServerThread(threading.Thread):
"""Thread that runs the ASGI application lifespan in a separate event loop."""
def __init__(self, app: Starlette):
super().__init__(daemon=True)
self.app = app
self._stop_event = threading.Event()
self._ready_event = threading.Event()
def run(self) -> None:
"""Run the lifespan in a new event loop."""
# Create a new event loop for this thread
async def run_lifespan():
# Use the lifespan context (always present in our tests)
lifespan_context = getattr(self.app.router, "lifespan_context", None)
assert lifespan_context is not None # Tests always create apps with lifespan
async with lifespan_context(self.app):
# Only signal readiness once lifespan startup has completed, i.e. the
# session manager's task group exists and requests can be handled.
self._ready_event.set()
# Wait until stop is requested
while not self._stop_event.is_set():
await anyio.sleep(0.1)
anyio.run(run_lifespan)
def wait_ready(self, timeout: float = 5.0) -> None:
"""Block until the lifespan has started; call from a worker thread, not the event loop."""
assert self._ready_event.wait(timeout), "server thread did not start its lifespan in time"
def stop(self) -> None:
"""Signal the thread to stop."""
self._stop_event.set()
def check_logs_for_race_condition_errors(caplog: pytest.LogCaptureFixture, test_name: str) -> None:
"""Check logs for ClosedResourceError and other race condition errors.
Args:
caplog: pytest log capture fixture
test_name: Name of the test for better error messages
"""
# Check for specific race condition errors in logs
errors_found: list[str] = []
for record in caplog.records: # pragma: lax no cover
message = record.getMessage()
if "ClosedResourceError" in message:
errors_found.append("ClosedResourceError")
if "Error in message router" in message:
errors_found.append("Error in message router")
if "anyio.ClosedResourceError" in message:
errors_found.append("anyio.ClosedResourceError")
# Assert no race condition errors occurred
if errors_found: # pragma: no cover
error_msg = f"Test '{test_name}' found race condition errors in logs: {', '.join(set(errors_found))}\n"
error_msg += "Log records:\n"
for record in caplog.records:
if any(err in record.getMessage() for err in ["ClosedResourceError", "Error in message router"]):
error_msg += f" {record.levelname}: {record.getMessage()}\n"
pytest.fail(error_msg)
@pytest.mark.anyio
async def test_race_condition_invalid_accept_headers(caplog: pytest.LogCaptureFixture):
"""Test the race condition with invalid Accept headers.
This test reproduces the exact scenario described in issue #1363:
- Send POST request with incorrect Accept headers (missing either application/json or text/event-stream)
- Request fails validation early and returns quickly
- This should trigger the race condition where message_router encounters ClosedResourceError
"""
app = create_app()
server_thread = ServerThread(app)
server_thread.start()
try:
# Wait for the server thread to enter the lifespan before sending requests
await anyio.to_thread.run_sync(server_thread.wait_ready)
# Suppress WARNING logs (expected validation errors) and capture ERROR logs
with caplog.at_level(logging.ERROR):
# Test with missing text/event-stream in Accept header
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0
) as client:
response = await client.post(
"/",
json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}},
headers={
"Accept": "application/json", # Missing text/event-stream
"Content-Type": "application/json",
},
)
# Should get 406 Not Acceptable due to missing text/event-stream
assert response.status_code == 406
# Test with missing application/json in Accept header
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0
) as client:
response = await client.post(
"/",
json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}},
headers={
"Accept": "text/event-stream", # Missing application/json
"Content-Type": "application/json",
},
)
# Should get 406 Not Acceptable due to missing application/json
assert response.status_code == 406
# Test with completely invalid Accept header
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0
) as client:
response = await client.post(
"/",
json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}},
headers={
"Accept": "text/plain", # Invalid Accept header
"Content-Type": "application/json",
},
)
# Should get 406 Not Acceptable
assert response.status_code == 406
# Give background tasks time to complete
await anyio.sleep(0.2)
finally:
server_thread.stop()
server_thread.join(timeout=5.0)
# Check logs for race condition errors
check_logs_for_race_condition_errors(caplog, "test_race_condition_invalid_accept_headers")
@pytest.mark.anyio
async def test_race_condition_invalid_content_type(caplog: pytest.LogCaptureFixture):
"""Test the race condition with invalid Content-Type headers.
This test reproduces the race condition scenario with Content-Type validation failure.
"""
app = create_app()
server_thread = ServerThread(app)
server_thread.start()
try:
# Wait for the server thread to enter the lifespan before sending requests
await anyio.to_thread.run_sync(server_thread.wait_ready)
# Suppress WARNING logs (expected validation errors) and capture ERROR logs
with caplog.at_level(logging.ERROR):
# Test with invalid Content-Type
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0
) as client:
response = await client.post(
"/",
json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}},
headers={
"Accept": "application/json, text/event-stream",
"Content-Type": "text/plain", # Invalid Content-Type
},
)
assert response.status_code == 400
# Give background tasks time to complete
await anyio.sleep(0.2)
finally:
server_thread.stop()
server_thread.join(timeout=5.0)
# Check logs for race condition errors
check_logs_for_race_condition_errors(caplog, "test_race_condition_invalid_content_type")
@pytest.mark.anyio
async def test_race_condition_message_router_async_for(caplog: pytest.LogCaptureFixture):
"""Uses json_response=True to trigger the `if self.is_json_response_enabled` branch,
which reproduces the ClosedResourceError when message_router is suspended
in async for loop while transport cleanup closes streams concurrently.
"""
app = create_app(json_response=True)
server_thread = ServerThread(app)
server_thread.start()
try:
# Wait for the server thread to enter the lifespan before sending requests
await anyio.to_thread.run_sync(server_thread.wait_ready)
# Suppress WARNING logs (expected validation errors) and capture ERROR logs
with caplog.at_level(logging.ERROR):
# Use httpx.ASGITransport to test the ASGI app directly
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0
) as client:
# Send a valid initialize request
response = await client.post(
"/",
json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}},
headers={
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
},
)
# Should get a successful response
assert response.status_code in (200, 201)
# Give background tasks time to complete
await anyio.sleep(0.2)
finally:
server_thread.stop()
server_thread.join(timeout=5.0)
# Check logs for race condition errors in message router
check_logs_for_race_condition_errors(caplog, "test_race_condition_message_router_async_for")
+111
View File
@@ -0,0 +1,111 @@
import pytest
from mcp_types import (
InputRequiredResult,
ListResourceTemplatesResult,
TextResourceContents,
)
from mcp import Client
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ResourceError
@pytest.mark.anyio
async def test_resource_template_edge_cases():
"""Test server-side resource template validation"""
mcp = MCPServer("Demo")
# Test case 1: Template with multiple parameters
@mcp.resource("resource://users/{user_id}/posts/{post_id}")
def get_user_post(user_id: str, post_id: str) -> str:
return f"Post {post_id} by user {user_id}"
# Test case 2: Template with optional parameter (should fail)
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
@mcp.resource("resource://users/{user_id}/profile")
def get_user_profile(user_id: str, optional_param: str | None = None) -> str: # pragma: no cover
return f"Profile for user {user_id}"
# Test case 3: Template with mismatched parameters
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
@mcp.resource("resource://users/{user_id}/profile")
def get_user_profile_mismatch(different_param: str) -> str: # pragma: no cover
return f"Profile for user {different_param}"
# Test case 4: Template with extra function parameters
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
@mcp.resource("resource://users/{user_id}/profile")
def get_user_profile_extra(user_id: str, extra_param: str) -> str: # pragma: no cover
return f"Profile for user {user_id}"
# Test case 5: Template with missing function parameters
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
@mcp.resource("resource://users/{user_id}/profile/{section}")
def get_user_profile_missing(user_id: str) -> str: # pragma: no cover
return f"Profile for user {user_id}"
# Verify valid template works
result = await mcp.read_resource("resource://users/123/posts/456")
assert not isinstance(result, InputRequiredResult)
result_list = list(result)
assert len(result_list) == 1
assert result_list[0].content == "Post 456 by user 123"
assert result_list[0].mime_type == "text/plain"
# Verify invalid parameters raise error
with pytest.raises(ResourceError, match="Unknown resource"):
await mcp.read_resource("resource://users/123/posts") # Missing post_id
with pytest.raises(ResourceError, match="Unknown resource"):
await mcp.read_resource("resource://users/123/posts/456/extra") # Extra path component
@pytest.mark.anyio
async def test_resource_template_client_interaction():
"""Test client-side resource template interaction"""
mcp = MCPServer("Demo")
# Register some templated resources
@mcp.resource("resource://users/{user_id}/posts/{post_id}")
def get_user_post(user_id: str, post_id: str) -> str:
return f"Post {post_id} by user {user_id}"
@mcp.resource("resource://users/{user_id}/profile")
def get_user_profile(user_id: str) -> str:
return f"Profile for user {user_id}"
async with Client(mcp) as session:
# List available resources
resources = await session.list_resource_templates()
assert isinstance(resources, ListResourceTemplatesResult)
assert len(resources.resource_templates) == 2
# Verify resource templates are listed correctly
templates = [r.uri_template for r in resources.resource_templates]
assert "resource://users/{user_id}/posts/{post_id}" in templates
assert "resource://users/{user_id}/profile" in templates
# Read a resource with valid parameters
result = await session.read_resource("resource://users/123/posts/456")
contents = result.contents[0]
assert isinstance(contents, TextResourceContents)
assert contents.text == "Post 456 by user 123"
assert contents.mime_type == "text/plain"
# Read another resource with valid parameters
result = await session.read_resource("resource://users/789/profile")
contents = result.contents[0]
assert isinstance(contents, TextResourceContents)
assert contents.text == "Profile for user 789"
assert contents.mime_type == "text/plain"
# Verify invalid resource URIs raise appropriate errors
with pytest.raises(Exception): # Specific exception type may vary
await session.read_resource("resource://users/123/posts") # Missing post_id
with pytest.raises(Exception): # Specific exception type may vary
await session.read_resource("resource://users/123/invalid") # Invalid template
+128
View File
@@ -0,0 +1,128 @@
import base64
import mcp_types as types
import pytest
from mcp_types import (
BlobResourceContents,
ListResourcesResult,
PaginatedRequestParams,
ReadResourceRequestParams,
ReadResourceResult,
TextResourceContents,
)
from mcp import Client
from mcp.server import Server, ServerRequestContext
from mcp.server.mcpserver import MCPServer
pytestmark = pytest.mark.anyio
async def test_mcpserver_resource_mime_type():
"""Test that mime_type parameter is respected for resources."""
mcp = MCPServer("test")
# Create a small test image as bytes
image_bytes = b"fake_image_data"
base64_string = base64.b64encode(image_bytes).decode("utf-8")
@mcp.resource("test://image", mime_type="image/png")
def get_image_as_string() -> str:
"""Return a test image as base64 string."""
return base64_string
@mcp.resource("test://image_bytes", mime_type="image/png")
def get_image_as_bytes() -> bytes:
"""Return a test image as bytes."""
return image_bytes
# Test that resources are listed with correct mime type
async with Client(mcp) as client:
# List resources and verify mime types
resources = await client.list_resources()
assert resources.resources is not None
mapping = {str(r.uri): r for r in resources.resources}
# Find our resources
string_resource = mapping["test://image"]
bytes_resource = mapping["test://image_bytes"]
# Verify mime types
assert string_resource.mime_type == "image/png", "String resource mime type not respected"
assert bytes_resource.mime_type == "image/png", "Bytes resource mime type not respected"
# Also verify the content can be read correctly
string_result = await client.read_resource("test://image")
assert len(string_result.contents) == 1
assert getattr(string_result.contents[0], "text") == base64_string, "Base64 string mismatch"
assert string_result.contents[0].mime_type == "image/png", "String content mime type not preserved"
bytes_result = await client.read_resource("test://image_bytes")
assert len(bytes_result.contents) == 1
assert base64.b64decode(getattr(bytes_result.contents[0], "blob")) == image_bytes, "Bytes mismatch"
assert bytes_result.contents[0].mime_type == "image/png", "Bytes content mime type not preserved"
async def test_lowlevel_resource_mime_type():
"""Test that mime_type parameter is respected for resources."""
# Create a small test image as bytes
image_bytes = b"fake_image_data"
base64_string = base64.b64encode(image_bytes).decode("utf-8")
# Create test resources with specific mime types
test_resources = [
types.Resource(uri="test://image", name="test image", mime_type="image/png"),
types.Resource(
uri="test://image_bytes",
name="test image bytes",
mime_type="image/png",
),
]
async def handle_list_resources(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListResourcesResult:
return ListResourcesResult(resources=test_resources)
resource_contents: dict[str, list[TextResourceContents | BlobResourceContents]] = {
"test://image": [TextResourceContents(uri="test://image", text=base64_string, mime_type="image/png")],
"test://image_bytes": [
BlobResourceContents(
uri="test://image_bytes", blob=base64.b64encode(image_bytes).decode("utf-8"), mime_type="image/png"
)
],
}
async def handle_read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
return ReadResourceResult(contents=resource_contents[str(params.uri)])
server = Server("test", on_list_resources=handle_list_resources, on_read_resource=handle_read_resource)
# Test that resources are listed with correct mime type
async with Client(server) as client:
# List resources and verify mime types
resources = await client.list_resources()
assert resources.resources is not None
mapping = {str(r.uri): r for r in resources.resources}
# Find our resources
string_resource = mapping["test://image"]
bytes_resource = mapping["test://image_bytes"]
# Verify mime types
assert string_resource.mime_type == "image/png", "String resource mime type not respected"
assert bytes_resource.mime_type == "image/png", "Bytes resource mime type not respected"
# Also verify the content can be read correctly
string_result = await client.read_resource("test://image")
assert len(string_result.contents) == 1
assert getattr(string_result.contents[0], "text") == base64_string, "Base64 string mismatch"
assert string_result.contents[0].mime_type == "image/png", "String content mime type not preserved"
bytes_result = await client.read_resource("test://image_bytes")
assert len(bytes_result.contents) == 1
assert base64.b64decode(getattr(bytes_result.contents[0], "blob")) == image_bytes, "Bytes mismatch"
assert bytes_result.contents[0].mime_type == "image/png", "Bytes content mime type not preserved"
@@ -0,0 +1,141 @@
"""Tests for issue #1574: Python SDK incorrectly validates Resource URIs.
The Python SDK previously used Pydantic's AnyUrl for URI fields, which rejected
relative paths like 'users/me' that are valid according to the MCP spec and
accepted by the TypeScript SDK.
The fix changed URI fields to plain strings to match the spec, which defines
uri fields as strings with no JSON Schema format validation.
These tests verify the fix works end-to-end through the JSON-RPC protocol.
"""
import mcp_types as types
import pytest
from mcp_types import (
ListResourcesResult,
PaginatedRequestParams,
ReadResourceRequestParams,
ReadResourceResult,
TextResourceContents,
)
from mcp import Client
from mcp.server import Server, ServerRequestContext
pytestmark = pytest.mark.anyio
async def test_relative_uri_roundtrip():
"""Relative URIs survive the full server-client JSON-RPC roundtrip.
This is the critical regression test - if someone reintroduces AnyUrl,
the server would fail to serialize resources with relative URIs,
or the URI would be transformed during the roundtrip.
"""
async def handle_list_resources(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListResourcesResult:
return ListResourcesResult(
resources=[
types.Resource(name="user", uri="users/me"),
types.Resource(name="config", uri="./config"),
types.Resource(name="parent", uri="../parent/resource"),
]
)
async def handle_read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
return ReadResourceResult(
contents=[TextResourceContents(uri=str(params.uri), text=f"data for {params.uri}", mime_type="text/plain")]
)
server = Server("test", on_list_resources=handle_list_resources, on_read_resource=handle_read_resource)
async with Client(server) as client:
# List should return the exact URIs we specified
resources = await client.list_resources()
uri_map = {r.uri: r for r in resources.resources}
assert "users/me" in uri_map, f"Expected 'users/me' in {list(uri_map.keys())}"
assert "./config" in uri_map, f"Expected './config' in {list(uri_map.keys())}"
assert "../parent/resource" in uri_map, f"Expected '../parent/resource' in {list(uri_map.keys())}"
# Read should work with each relative URI and preserve it in the response
for uri_str in ["users/me", "./config", "../parent/resource"]:
result = await client.read_resource(uri_str)
assert len(result.contents) == 1
assert result.contents[0].uri == uri_str
async def test_custom_scheme_uri_roundtrip():
"""Custom scheme URIs work through the protocol.
Some MCP servers use custom schemes like "custom://resource".
These should work end-to-end.
"""
async def handle_list_resources(
ctx: ServerRequestContext, params: PaginatedRequestParams | None
) -> ListResourcesResult:
return ListResourcesResult(
resources=[
types.Resource(name="custom", uri="custom://my-resource"),
types.Resource(name="file", uri="file:///path/to/file"),
]
)
async def handle_read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
return ReadResourceResult(
contents=[TextResourceContents(uri=str(params.uri), text="data", mime_type="text/plain")]
)
server = Server("test", on_list_resources=handle_list_resources, on_read_resource=handle_read_resource)
async with Client(server) as client:
resources = await client.list_resources()
uri_map = {r.uri: r for r in resources.resources}
assert "custom://my-resource" in uri_map
assert "file:///path/to/file" in uri_map
# Read with custom scheme
result = await client.read_resource("custom://my-resource")
assert len(result.contents) == 1
def test_uri_json_roundtrip_preserves_value():
"""URI is preserved exactly through JSON serialization.
This catches any Pydantic validation or normalization that would
alter the URI during the JSON-RPC message flow.
"""
test_uris = [
"users/me",
"custom://resource",
"./relative",
"../parent",
"file:///absolute/path",
"https://example.com/path",
]
for uri_str in test_uris:
resource = types.Resource(name="test", uri=uri_str)
json_data = resource.model_dump(mode="json")
restored = types.Resource.model_validate(json_data)
assert restored.uri == uri_str, f"URI mutated: {uri_str} -> {restored.uri}"
def test_resource_contents_uri_json_roundtrip():
"""TextResourceContents URI is preserved through JSON serialization."""
test_uris = ["users/me", "./relative", "custom://resource"]
for uri_str in test_uris:
contents = types.TextResourceContents(
uri=uri_str,
text="data",
mime_type="text/plain",
)
json_data = contents.model_dump(mode="json")
restored = types.TextResourceContents.model_validate(json_data)
assert restored.uri == uri_str, f"URI mutated: {uri_str} -> {restored.uri}"
@@ -0,0 +1,67 @@
"""Test for GitHub issue #1754: MIME type validation rejects valid RFC 2045 parameters.
The MIME type validation regex was too restrictive and rejected valid MIME types
with parameters like 'text/html;profile=mcp-app' which are valid per RFC 2045.
"""
import pytest
from mcp import Client
from mcp.server.mcpserver import MCPServer
pytestmark = pytest.mark.anyio
async def test_mime_type_with_parameters():
"""Test that MIME types with parameters are accepted (RFC 2045)."""
mcp = MCPServer("test")
# This should NOT raise a validation error
@mcp.resource("ui://widget", mime_type="text/html;profile=mcp-app")
def widget() -> str:
raise NotImplementedError()
resources = await mcp.list_resources()
assert len(resources) == 1
assert resources[0].mime_type == "text/html;profile=mcp-app"
async def test_mime_type_with_parameters_and_space():
"""Test MIME type with space after semicolon."""
mcp = MCPServer("test")
@mcp.resource("data://json", mime_type="application/json; charset=utf-8")
def data() -> str:
raise NotImplementedError()
resources = await mcp.list_resources()
assert len(resources) == 1
assert resources[0].mime_type == "application/json; charset=utf-8"
async def test_mime_type_with_multiple_parameters():
"""Test MIME type with multiple parameters."""
mcp = MCPServer("test")
@mcp.resource("data://multi", mime_type="text/plain; charset=utf-8; format=fixed")
def data() -> str:
raise NotImplementedError()
resources = await mcp.list_resources()
assert len(resources) == 1
assert resources[0].mime_type == "text/plain; charset=utf-8; format=fixed"
async def test_mime_type_preserved_in_read_resource():
"""Test that MIME type with parameters is preserved when reading resource."""
mcp = MCPServer("test")
@mcp.resource("ui://my-widget", mime_type="text/html;profile=mcp-app")
def my_widget() -> str:
return "<html><body>Hello MCP-UI</body></html>"
async with Client(mcp) as client:
# Read the resource
result = await client.read_resource("ui://my-widget")
assert len(result.contents) == 1
assert result.contents[0].mime_type == "text/html;profile=mcp-app"
+42
View File
@@ -0,0 +1,42 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
from mcp.server.context import ServerRequestContext
from mcp.server.mcpserver import Context
pytestmark = pytest.mark.anyio
async def test_progress_token_zero_first_call():
"""Regression: progress reporting must not be gated on a falsy token.
Issue #176: the original Context.report_progress treated token 0 as "no token" and
silently dropped progress. Context now delegates unconditionally to
ServerSession.report_progress (which calls DispatchContext.progress, whose JSONRPC
implementation gates on `is None`, not truthiness), so a request whose meta carries
a 0-valued token still emits all three reports.
"""
mock_session = AsyncMock()
mock_session.report_progress = AsyncMock()
request_context = ServerRequestContext(
request_id="test-request",
session=mock_session,
method="tools/call",
meta={"progress_token": 0},
lifespan_context=None,
protocol_version="2025-11-25",
)
ctx = Context(request_context=request_context, mcp_server=MagicMock())
await ctx.report_progress(0, 10)
await ctx.report_progress(5, 10)
await ctx.report_progress(10, 10)
assert mock_session.report_progress.await_args_list == [
((0, 10, None),),
((5, 10, None),),
((10, 10, None),),
]
+85
View File
@@ -0,0 +1,85 @@
import anyio
import pytest
from mcp import Client
from mcp.server.mcpserver import MCPServer
@pytest.mark.anyio
async def test_messages_are_executed_concurrently_tools():
server = MCPServer("test")
event = anyio.Event()
tool_started = anyio.Event()
call_order: list[str] = []
@server.tool("sleep")
async def sleep_tool():
call_order.append("waiting_for_event")
tool_started.set()
await event.wait()
call_order.append("tool_end")
return "done"
@server.tool("trigger")
async def trigger():
# Wait for tool to start before setting the event
await tool_started.wait()
call_order.append("trigger_started")
event.set()
call_order.append("trigger_end")
return "slow"
async with Client(server) as client_session:
# First tool will wait on event, second will set it
async with anyio.create_task_group() as tg:
# Start the tool first (it will wait on event)
tg.start_soon(client_session.call_tool, "sleep")
# Then the trigger tool will set the event to allow the first tool to continue
await client_session.call_tool("trigger")
# Verify that both ran concurrently
assert call_order == [
"waiting_for_event",
"trigger_started",
"trigger_end",
"tool_end",
], f"Expected concurrent execution, but got: {call_order}"
@pytest.mark.anyio
async def test_messages_are_executed_concurrently_tools_and_resources():
server = MCPServer("test")
event = anyio.Event()
tool_started = anyio.Event()
call_order: list[str] = []
@server.tool("sleep")
async def sleep_tool():
call_order.append("waiting_for_event")
tool_started.set()
await event.wait()
call_order.append("tool_end")
return "done"
@server.resource("slow://slow_resource")
async def slow_resource():
# Wait for tool to start before setting the event
await tool_started.wait()
event.set()
call_order.append("resource_end")
return "slow"
async with Client(server) as client_session:
# First tool will wait on event, second will set it
async with anyio.create_task_group() as tg:
# Start the tool first (it will wait on event)
tg.start_soon(client_session.call_tool, "sleep")
# Then the resource (it will set the event)
tg.start_soon(client_session.read_resource, "slow://slow_resource")
# Verify that both ran concurrently
assert call_order == [
"waiting_for_event",
"resource_end",
"tool_end",
], f"Expected concurrent execution, but got: {call_order}"
+95
View File
@@ -0,0 +1,95 @@
import anyio
import pytest
from mcp_types import (
ClientCapabilities,
Implementation,
InitializeRequestParams,
JSONRPCMessage,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
NotificationParams,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION
from mcp.server.lowlevel import NotificationOptions, Server
from mcp.server.models import InitializationOptions
from mcp.shared.message import SessionMessage
@pytest.mark.anyio
async def test_request_id_match() -> None:
"""Test that the server preserves request IDs in responses."""
server = Server("test")
custom_request_id = "test-123"
# Create memory streams for communication
client_writer, client_reader = anyio.create_memory_object_stream[SessionMessage | Exception](1)
server_writer, server_reader = anyio.create_memory_object_stream[SessionMessage | Exception](1)
# Server task to process the request
async def run_server():
async with client_reader, server_writer:
await server.run(
client_reader,
server_writer,
InitializationOptions(
server_name="test",
server_version="1.0.0",
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
raise_exceptions=True,
)
# Start server task
async with (
anyio.create_task_group() as tg,
client_writer,
client_reader,
server_writer,
server_reader,
):
tg.start_soon(run_server)
# Send initialize request
init_req = JSONRPCRequest(
id="init-1",
method="initialize",
params=InitializeRequestParams(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ClientCapabilities(),
client_info=Implementation(name="test-client", version="1.0.0"),
).model_dump(by_alias=True, exclude_none=True),
jsonrpc="2.0",
)
await client_writer.send(SessionMessage(init_req))
response = await server_reader.receive() # Get init response but don't need to check it
# Send initialized notification
initialized_notification = JSONRPCNotification(
method="notifications/initialized",
params=NotificationParams().model_dump(by_alias=True, exclude_none=True),
jsonrpc="2.0",
)
await client_writer.send(SessionMessage(initialized_notification))
# Send ping request with custom ID
ping_request = JSONRPCRequest(id=custom_request_id, method="ping", params={}, jsonrpc="2.0")
await client_writer.send(SessionMessage(ping_request))
# Read response
response = await server_reader.receive()
# Verify response ID matches request ID
assert isinstance(response, SessionMessage)
assert isinstance(response.message, JSONRPCMessage)
assert isinstance(response.message, JSONRPCResponse)
assert response.message.id == custom_request_id, "Response ID should match request ID"
# Cancel server task
tg.cancel_scope.cancel()
+52
View File
@@ -0,0 +1,52 @@
"""Test for base64 encoding issue in MCP server.
This test verifies that binary resource data is encoded with standard base64
(not urlsafe_b64encode), so BlobResourceContents validation succeeds.
"""
import base64
import pytest
from mcp_types import BlobResourceContents
from mcp import Client
from mcp.server.mcpserver import MCPServer
pytestmark = pytest.mark.anyio
async def test_server_base64_encoding():
"""Tests that binary resource data round-trips correctly through base64 encoding.
The test uses binary data that produces different results with urlsafe vs standard
base64, ensuring the server uses standard encoding.
"""
mcp = MCPServer("test")
# Create binary data that will definitely result in + and / characters
# when encoded with standard base64
binary_data = bytes(list(range(255)) * 4)
# Sanity check: our test data produces different encodings
urlsafe_b64 = base64.urlsafe_b64encode(binary_data).decode()
standard_b64 = base64.b64encode(binary_data).decode()
assert urlsafe_b64 != standard_b64, "Test data doesn't demonstrate encoding difference"
@mcp.resource("test://binary", mime_type="application/octet-stream")
def get_binary() -> bytes:
"""Return binary test data."""
return binary_data
async with Client(mcp) as client:
result = await client.read_resource("test://binary")
assert len(result.contents) == 1
blob_content = result.contents[0]
assert isinstance(blob_content, BlobResourceContents)
# Verify standard base64 was used (not urlsafe)
assert blob_content.blob == standard_b64
# Verify we can decode the data back correctly
decoded = base64.b64decode(blob_content.blob)
assert decoded == binary_data
+50
View File
@@ -0,0 +1,50 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from mcp.server.mcpserver import Context, MCPServer
class Database: # Replace with your actual DB type
@classmethod
async def connect(cls): # pragma: no cover
return cls()
async def disconnect(self): # pragma: no cover
pass
def query(self): # pragma: no cover
return "Hello, World!"
# Create a named server
mcp = MCPServer("My App")
@dataclass
class AppContext:
db: Database
@asynccontextmanager
async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: # pragma: no cover
"""Manage application lifecycle with type-safe context"""
# Initialize on startup
db = await Database.connect()
try:
yield AppContext(db=db)
finally:
# Cleanup on shutdown
await db.disconnect()
# Pass lifespan to server
mcp = MCPServer("My App", lifespan=app_lifespan)
# Access type-safe lifespan context in tools
@mcp.tool()
def query_db(ctx: Context[AppContext]) -> str: # pragma: no cover
"""Tool that uses initialized resources"""
db = ctx.request_context.lifespan_context.db
return db.query()
+57
View File
@@ -0,0 +1,57 @@
"""Test for issue #552: stdio_client hangs on Windows."""
import json
import sys
from textwrap import dedent
import anyio
import pytest
from mcp_types import InitializeResult
from mcp_types.version import LATEST_HANDSHAKE_VERSION
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test") # pragma: no cover
@pytest.mark.anyio
async def test_initialize_succeeds_and_shutdown_returns_after_the_server_exits_mid_session():
"""Initialize completes and shutdown returns when the server exits mid-session.
This is the proactor pipe scenario that hung on Windows 11 (issue #552). The positive
assertion matters: a session that errors quickly would also "not hang".
"""
# A minimal server: answer initialize correctly, then exit.
server_script = dedent(f"""
import json
import sys
line = sys.stdin.readline()
request = json.loads(line)
response = {{
"jsonrpc": "2.0",
"id": request["id"],
"result": {{
"protocolVersion": {json.dumps(LATEST_HANDSHAKE_VERSION)},
"capabilities": {{}},
"serverInfo": {{"name": "test-server", "version": "1.0"}}
}}
}}
print(json.dumps(response))
sys.stdout.flush()
""").strip()
params = StdioServerParameters(
command=sys.executable,
args=["-c", server_script],
)
with anyio.fail_after(10):
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
result = await session.initialize()
assert isinstance(result, InitializeResult)
assert result.server_info.name == "test-server"
# Exiting ClientSession and stdio_client must not hang even though the
# server process is already gone.
+124
View File
@@ -0,0 +1,124 @@
"""Test to reproduce issue #88: Random error thrown on response."""
from pathlib import Path
import anyio
import mcp_types as types
import pytest
from anyio.abc import TaskStatus
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from mcp_types import (
REQUEST_TIMEOUT,
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
)
from mcp.client.session import ClientSession
from mcp.server import Server, ServerRequestContext
from mcp.shared.exceptions import MCPError
from mcp.shared.message import SessionMessage
@pytest.mark.anyio
async def test_notification_validation_error(tmp_path: Path):
"""Test that timeouts are handled gracefully and don't break the server.
This test verifies that when a client request times out:
1. The server task stays alive
2. The server can still handle new requests
3. The client can make new requests
4. No resources are leaked
Uses per-request timeouts to avoid race conditions:
- Fast operations use no timeout (reliable in any environment)
- Slow operations use minimal timeout (10ms) for quick test execution
"""
request_count = 0
slow_request_lock = anyio.Event()
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(
tools=[
types.Tool(
name="slow",
description="A slow tool",
input_schema={"type": "object"},
),
types.Tool(
name="fast",
description="A fast tool",
input_schema={"type": "object"},
),
]
)
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
nonlocal request_count
request_count += 1
assert params.name in ("slow", "fast"), f"Unknown tool: {params.name}"
if params.name == "slow":
# The client's timeout fires during this wait; the courtesy cancellation then interrupts it.
await slow_request_lock.wait()
text = f"slow {request_count}"
else:
text = f"fast {request_count}"
return CallToolResult(content=[TextContent(type="text", text=text)])
server = Server(name="test", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
async def server_handler(
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],
write_stream: MemoryObjectSendStream[SessionMessage],
task_status: TaskStatus[str] = anyio.TASK_STATUS_IGNORED,
):
with anyio.CancelScope() as scope:
task_status.started(scope) # type: ignore
await server.run(
read_stream,
write_stream,
server.create_initialization_options(),
raise_exceptions=True,
)
async def client(
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],
write_stream: MemoryObjectSendStream[SessionMessage],
scope: anyio.CancelScope,
):
# No session-level timeout to avoid race conditions with fast operations
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
# First call should work (fast operation, no timeout)
result = await session.call_tool("fast", read_timeout_seconds=None)
assert result.content == [TextContent(type="text", text="fast 1")]
assert not slow_request_lock.is_set()
# Second call should timeout (slow operation with minimal timeout)
# Use very small timeout to trigger quickly without waiting
with pytest.raises(MCPError) as exc_info:
await session.call_tool("slow", read_timeout_seconds=0.000001) # artificial timeout that always fails
assert exc_info.value.error.code == REQUEST_TIMEOUT
# No-op if the courtesy cancellation already interrupted the handler.
slow_request_lock.set()
# Third call should work (fast operation, no timeout),
# proving server is still responsive
result = await session.call_tool("fast", read_timeout_seconds=None)
assert result.content == [TextContent(type="text", text="fast 3")]
scope.cancel() # pragma: lax no cover
# Run server and client in separate task groups to avoid cancellation
server_writer, server_reader = anyio.create_memory_object_stream[SessionMessage](1)
client_writer, client_reader = anyio.create_memory_object_stream[SessionMessage](1)
async with anyio.create_task_group() as tg:
scope = await tg.start(server_handler, server_reader, client_writer)
# Run client in a separate task to avoid cancellation
tg.start_soon(client, client_reader, server_writer, scope)
+78
View File
@@ -0,0 +1,78 @@
"""Test that URL-encoded parameters are decoded in resource templates.
Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/973
"""
from mcp.server.mcpserver.resources import ResourceTemplate
def test_template_matches_decodes_space():
"""Test that %20 is decoded to space."""
def search(query: str) -> str: # pragma: no cover
return f"Results for: {query}"
template = ResourceTemplate.from_function(
fn=search,
uri_template="search://{query}",
name="search",
)
params = template.matches("search://hello%20world")
assert params is not None
assert params["query"] == "hello world"
def test_template_matches_decodes_accented_characters():
"""Test that %C3%A9 is decoded to e with accent."""
def search(query: str) -> str: # pragma: no cover
return f"Results for: {query}"
template = ResourceTemplate.from_function(
fn=search,
uri_template="search://{query}",
name="search",
)
params = template.matches("search://caf%C3%A9")
assert params is not None
assert params["query"] == "café"
def test_template_matches_decodes_complex_phrase():
"""Test complex French phrase from the original issue."""
def search(query: str) -> str: # pragma: no cover
return f"Results for: {query}"
template = ResourceTemplate.from_function(
fn=search,
uri_template="search://{query}",
name="search",
)
params = template.matches("search://stick%20correcteur%20teint%C3%A9%20anti-imperfections")
assert params is not None
assert params["query"] == "stick correcteur teinté anti-imperfections"
def test_template_matches_preserves_plus_sign():
"""Test that plus sign remains as plus (not converted to space).
In URI encoding, %20 is space. Plus-as-space is only for
application/x-www-form-urlencoded (HTML forms).
"""
def search(query: str) -> str: # pragma: no cover
return f"Results for: {query}"
template = ResourceTemplate.from_function(
fn=search,
uri_template="search://{query}",
name="search",
)
params = template.matches("search://hello+world")
assert params is not None
assert params["query"] == "hello+world"