refactor: replace lowlevel Server decorators with on_* constructor kwargs (#1985)
This commit is contained in:
parent
d6d3ad9a7c
commit
0a22a9dc33
5 changed files with 3563 additions and 5481 deletions
|
|
@ -1,15 +1,13 @@
|
|||
import pytest
|
||||
|
||||
from mcp import types
|
||||
from mcp import Client
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resource_templates():
|
||||
# Create an MCP server
|
||||
mcp = MCPServer("Demo")
|
||||
|
||||
# Add a dynamic greeting resource
|
||||
@mcp.resource("greeting://{name}")
|
||||
def get_greeting(name: str) -> str: # pragma: no cover
|
||||
"""Get a personalized greeting"""
|
||||
|
|
@ -20,23 +18,16 @@ async def test_resource_templates():
|
|||
"""Dynamic user data"""
|
||||
return f"Profile data for user {user_id}"
|
||||
|
||||
# Get the list of resource templates using the underlying server
|
||||
# Note: list_resource_templates() returns a decorator that wraps the handler
|
||||
# The handler returns a ServerResult with a ListResourceTemplatesResult inside
|
||||
result = await mcp._lowlevel_server.request_handlers[types.ListResourceTemplatesRequest](
|
||||
types.ListResourceTemplatesRequest(params=None)
|
||||
)
|
||||
assert isinstance(result, types.ListResourceTemplatesResult)
|
||||
templates = result.resource_templates
|
||||
async with Client(mcp) as client:
|
||||
result = await client.list_resource_templates()
|
||||
templates = result.resource_templates
|
||||
|
||||
# Verify we get both templates back
|
||||
assert len(templates) == 2
|
||||
assert len(templates) == 2
|
||||
|
||||
# Verify template details
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -3,9 +3,16 @@ import base64
|
|||
import pytest
|
||||
|
||||
from mcp import Client, types
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.types import (
|
||||
BlobResourceContents,
|
||||
ListResourcesResult,
|
||||
PaginatedRequestParams,
|
||||
ReadResourceRequestParams,
|
||||
ReadResourceResult,
|
||||
TextResourceContents,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
|
@ -58,7 +65,6 @@ async def test_mcpserver_resource_mime_type():
|
|||
|
||||
async def test_lowlevel_resource_mime_type():
|
||||
"""Test that mime_type parameter is respected for resources."""
|
||||
server = Server("test")
|
||||
|
||||
# Create a small test image as bytes
|
||||
image_bytes = b"fake_image_data"
|
||||
|
|
@ -74,17 +80,24 @@ async def test_lowlevel_resource_mime_type():
|
|||
),
|
||||
]
|
||||
|
||||
@server.list_resources()
|
||||
async def handle_list_resources():
|
||||
return test_resources
|
||||
async def handle_list_resources(
|
||||
ctx: ServerRequestContext, params: PaginatedRequestParams | None
|
||||
) -> ListResourcesResult:
|
||||
return ListResourcesResult(resources=test_resources)
|
||||
|
||||
@server.read_resource()
|
||||
async def handle_read_resource(uri: str):
|
||||
if str(uri) == "test://image":
|
||||
return [ReadResourceContents(content=base64_string, mime_type="image/png")]
|
||||
elif str(uri) == "test://image_bytes":
|
||||
return [ReadResourceContents(content=bytes(image_bytes), mime_type="image/png")]
|
||||
raise Exception(f"Resource not found: {uri}") # pragma: no cover
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -13,8 +13,14 @@ These tests verify the fix works end-to-end through the JSON-RPC protocol.
|
|||
import pytest
|
||||
|
||||
from mcp import Client, types
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import (
|
||||
ListResourcesResult,
|
||||
PaginatedRequestParams,
|
||||
ReadResourceRequestParams,
|
||||
ReadResourceResult,
|
||||
TextResourceContents,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
|
@ -26,24 +32,24 @@ async def test_relative_uri_roundtrip():
|
|||
the server would fail to serialize resources with relative URIs,
|
||||
or the URI would be transformed during the roundtrip.
|
||||
"""
|
||||
server = Server("test")
|
||||
|
||||
@server.list_resources()
|
||||
async def list_resources():
|
||||
return [
|
||||
types.Resource(name="user", uri="users/me"),
|
||||
types.Resource(name="config", uri="./config"),
|
||||
types.Resource(name="parent", uri="../parent/resource"),
|
||||
]
|
||||
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"),
|
||||
]
|
||||
)
|
||||
|
||||
@server.read_resource()
|
||||
async def read_resource(uri: str):
|
||||
return [
|
||||
ReadResourceContents(
|
||||
content=f"data for {uri}",
|
||||
mime_type="text/plain",
|
||||
)
|
||||
]
|
||||
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
|
||||
|
|
@ -67,18 +73,23 @@ async def test_custom_scheme_uri_roundtrip():
|
|||
Some MCP servers use custom schemes like "custom://resource".
|
||||
These should work end-to-end.
|
||||
"""
|
||||
server = Server("test")
|
||||
|
||||
@server.list_resources()
|
||||
async def list_resources():
|
||||
return [
|
||||
types.Resource(name="custom", uri="custom://my-resource"),
|
||||
types.Resource(name="file", uri="file:///path/to/file"),
|
||||
]
|
||||
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"),
|
||||
]
|
||||
)
|
||||
|
||||
@server.read_resource()
|
||||
async def read_resource(uri: str):
|
||||
return [ReadResourceContents(content="data", mime_type="text/plain")]
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -1,83 +1,52 @@
|
|||
"""Test for base64 encoding issue in MCP server.
|
||||
|
||||
This test demonstrates the issue in server.py where the server uses
|
||||
urlsafe_b64encode but the BlobResourceContents validator expects standard
|
||||
base64 encoding.
|
||||
|
||||
The test should FAIL before fixing server.py to use b64encode instead of
|
||||
urlsafe_b64encode.
|
||||
After the fix, the test should PASS.
|
||||
This test verifies that binary resource data is encoded with standard base64
|
||||
(not urlsafe_b64encode), so BlobResourceContents validation succeeds.
|
||||
"""
|
||||
|
||||
import base64
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||
from mcp.server.lowlevel.server import Server
|
||||
from mcp.types import (
|
||||
BlobResourceContents,
|
||||
ReadResourceRequest,
|
||||
ReadResourceRequestParams,
|
||||
ReadResourceResult,
|
||||
ServerResult,
|
||||
)
|
||||
from mcp import Client
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.types import BlobResourceContents
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_server_base64_encoding_issue():
|
||||
"""Tests that server response can be validated by BlobResourceContents.
|
||||
async def test_server_base64_encoding():
|
||||
"""Tests that binary resource data round-trips correctly through base64 encoding.
|
||||
|
||||
This test will:
|
||||
1. Set up a server that returns binary data
|
||||
2. Extract the base64-encoded blob from the server's response
|
||||
3. Verify the encoded data can be properly validated by BlobResourceContents
|
||||
|
||||
BEFORE FIX: The test will fail because server uses urlsafe_b64encode
|
||||
AFTER FIX: The test will pass because server uses standard b64encode
|
||||
The test uses binary data that produces different results with urlsafe vs standard
|
||||
base64, ensuring the server uses standard encoding.
|
||||
"""
|
||||
server = Server("test")
|
||||
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)
|
||||
|
||||
# Register a resource handler that returns our test data
|
||||
@server.read_resource()
|
||||
async def read_resource(uri: str) -> list[ReadResourceContents]:
|
||||
return [ReadResourceContents(content=binary_data, mime_type="application/octet-stream")]
|
||||
|
||||
# Get the handler directly from the server
|
||||
handler = server.request_handlers[ReadResourceRequest]
|
||||
|
||||
# Create a request
|
||||
request = ReadResourceRequest(
|
||||
params=ReadResourceRequestParams(uri="test://resource"),
|
||||
)
|
||||
|
||||
# Call the handler to get the response
|
||||
result: ServerResult = await handler(request)
|
||||
|
||||
# After (fixed code):
|
||||
read_result: ReadResourceResult = cast(ReadResourceResult, result)
|
||||
blob_content = read_result.contents[0]
|
||||
|
||||
# First verify our test data actually produces different encodings
|
||||
# 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"
|
||||
assert urlsafe_b64 != standard_b64, "Test data doesn't demonstrate encoding difference"
|
||||
|
||||
# Now validate the server's output with BlobResourceContents.model_validate
|
||||
# Before the fix: This should fail with "Invalid base64" because server
|
||||
# uses urlsafe_b64encode
|
||||
# After the fix: This should pass because server will use standard b64encode
|
||||
model_dict = blob_content.model_dump()
|
||||
@mcp.resource("test://binary", mime_type="application/octet-stream")
|
||||
def get_binary() -> bytes:
|
||||
"""Return binary test data."""
|
||||
return binary_data
|
||||
|
||||
# Direct validation - this will fail before fix, pass after fix
|
||||
blob_model = BlobResourceContents.model_validate(model_dict)
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("test://binary")
|
||||
assert len(result.contents) == 1
|
||||
|
||||
# Verify we can decode the data back correctly
|
||||
decoded = base64.b64decode(blob_model.blob)
|
||||
assert decoded == binary_data
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
"""Test to reproduce issue #88: Random error thrown on response."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
|
@ -11,10 +9,10 @@ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStre
|
|||
|
||||
from mcp import types
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.types import ContentBlock, TextContent
|
||||
from mcp.types import CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, TextContent
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -32,36 +30,38 @@ async def test_notification_validation_error(tmp_path: Path):
|
|||
- Slow operations use minimal timeout (10ms) for quick test execution
|
||||
"""
|
||||
|
||||
server = Server(name="test")
|
||||
request_count = 0
|
||||
slow_request_lock = anyio.Event()
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools() -> list[types.Tool]:
|
||||
return [
|
||||
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_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"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@server.call_tool()
|
||||
async def slow_tool(name: str, arguments: dict[str, Any]) -> Sequence[ContentBlock]:
|
||||
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 name == "slow":
|
||||
if params.name == "slow":
|
||||
await slow_request_lock.wait() # it should timeout here
|
||||
return [TextContent(type="text", text=f"slow {request_count}")]
|
||||
elif name == "fast":
|
||||
return [TextContent(type="text", text=f"fast {request_count}")]
|
||||
return [TextContent(type="text", text=f"unknown {request_count}")] # pragma: no cover
|
||||
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],
|
||||
|
|
|
|||
Loading…
Reference in a new issue