49b9bb6724
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
142 lines
5.2 KiB
Python
142 lines
5.2 KiB
Python
"""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}"
|