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
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:
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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),),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user