From dd60f0793b53892c777830fb6f722940288e1275 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 15 Jan 2026 15:34:47 +0100 Subject: [PATCH 1/4] test: reproduce #1574 Github-Issue: #1574 --- .../test_1574_resource_uri_validation.py | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/issues/test_1574_resource_uri_validation.py diff --git a/tests/issues/test_1574_resource_uri_validation.py b/tests/issues/test_1574_resource_uri_validation.py new file mode 100644 index 0000000000..51845ab56f --- /dev/null +++ b/tests/issues/test_1574_resource_uri_validation.py @@ -0,0 +1,85 @@ +"""Tests for issue #1574: Python SDK incorrectly validates Resource URIs. + +The Python SDK uses Pydantic's AnyUrl for URI fields, which rejects relative paths +like 'users/me' that are valid according to the MCP spec and accepted by the +TypeScript SDK. + +The spec defines uri fields as plain strings with no JSON Schema format validation. +""" + +from mcp import types + + +class TestResourceUriValidation: + """Test that Resource URI fields accept all valid MCP URIs.""" + + def test_relative_path_uri(self): + """ + REPRODUCER: Relative paths like 'users/me' should be accepted. + + Currently fails with: + ValidationError: Input should be a valid URL, relative URL without a base + """ + # This should NOT raise - relative paths are valid per MCP spec + resource = types.Resource(name="test", uri="users/me") + assert str(resource.uri) == "users/me" + + def test_custom_scheme_uri(self): + """Custom scheme URIs should be accepted.""" + resource = types.Resource(name="test", uri="custom://resource") + assert str(resource.uri) == "custom://resource" + + def test_file_url(self): + """File URLs should be accepted.""" + resource = types.Resource(name="test", uri="file:///path/to/file") + assert str(resource.uri) == "file:///path/to/file" + + def test_http_url(self): + """HTTP URLs should be accepted.""" + resource = types.Resource(name="test", uri="https://example.com/resource") + assert str(resource.uri) == "https://example.com/resource" + + +class TestReadResourceRequestParamsUri: + """Test that ReadResourceRequestParams.uri accepts all valid MCP URIs.""" + + def test_relative_path_uri(self): + """Relative paths should be accepted in read requests.""" + params = types.ReadResourceRequestParams(uri="users/me") + assert str(params.uri) == "users/me" + + +class TestResourceContentsUri: + """Test that ResourceContents.uri accepts all valid MCP URIs.""" + + def test_relative_path_uri(self): + """Relative paths should be accepted in resource contents.""" + contents = types.TextResourceContents(uri="users/me", text="content") + assert str(contents.uri) == "users/me" + + +class TestSubscribeRequestParamsUri: + """Test that SubscribeRequestParams.uri accepts all valid MCP URIs.""" + + def test_relative_path_uri(self): + """Relative paths should be accepted in subscribe requests.""" + params = types.SubscribeRequestParams(uri="users/me") + assert str(params.uri) == "users/me" + + +class TestUnsubscribeRequestParamsUri: + """Test that UnsubscribeRequestParams.uri accepts all valid MCP URIs.""" + + def test_relative_path_uri(self): + """Relative paths should be accepted in unsubscribe requests.""" + params = types.UnsubscribeRequestParams(uri="users/me") + assert str(params.uri) == "users/me" + + +class TestResourceUpdatedNotificationParamsUri: + """Test that ResourceUpdatedNotificationParams.uri accepts all valid MCP URIs.""" + + def test_relative_path_uri(self): + """Relative paths should be accepted in resource updated notifications.""" + params = types.ResourceUpdatedNotificationParams(uri="users/me") + assert str(params.uri) == "users/me" From d193b5e4bd538eb98dbd58872fa5b264151f4b67 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 15 Jan 2026 15:45:21 +0100 Subject: [PATCH 2/4] fix: change Resource URI fields from AnyUrl to str (#1574) The Python SDK was incorrectly using Pydantic's AnyUrl for URI fields on resource types. This rejected relative paths like 'users/me' that are valid according to the MCP specification, which defines URIs as plain strings without format validation. This change updates the following types to use str instead of AnyUrl: - Resource.uri - ReadResourceRequestParams.uri - ResourceContents.uri - SubscribeRequestParams.uri - UnsubscribeRequestParams.uri - ResourceUpdatedNotificationParams.uri - FastMCP Resource.uri (base class) Client and server session methods now accept str | AnyUrl for backwards compatibility, converting to string internally. Updates migration.md with documentation of this breaking change. Github-Issue: #1574 --- README.md | 4 +- docs/migration.md | 43 ++++++++++++++++ .../mcp_everything_server/server.py | 12 ++--- .../mcp_simple_pagination/server.py | 5 +- .../mcp_simple_resource/server.py | 12 +++-- .../mcp_simple_streamablehttp/server.py | 3 +- .../snippets/servers/pagination_example.py | 4 +- src/mcp/client/session.py | 12 ++--- src/mcp/server/fastmcp/resources/base.py | 6 +-- src/mcp/server/fastmcp/resources/types.py | 4 +- src/mcp/server/lowlevel/server.py | 7 ++- src/mcp/server/session.py | 4 +- src/mcp/types.py | 13 +++-- tests/issues/test_152_resource_mime_type.py | 6 +-- tests/issues/test_342_base64_encoding.py | 5 +- tests/server/fastmcp/prompts/test_base.py | 13 +++-- .../fastmcp/resources/test_file_resources.py | 15 +++--- .../resources/test_function_resources.py | 20 ++++---- .../resources/test_resource_manager.py | 16 +++--- .../fastmcp/resources/test_resources.py | 51 +++++++++---------- tests/server/fastmcp/test_server.py | 38 +++++++------- tests/server/fastmcp/test_title.py | 9 ++-- tests/server/lowlevel/test_server_listing.py | 5 +- tests/server/test_read_resource.py | 13 +++-- tests/shared/test_memory.py | 3 +- tests/shared/test_sse.py | 21 ++++---- tests/shared/test_streamable_http.py | 24 +++++---- tests/shared/test_ws.py | 13 +++-- 28 files changed, 207 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index 68033eeb75..e268a7f401 100644 --- a/README.md +++ b/README.md @@ -2044,8 +2044,6 @@ For servers that need to handle large datasets, the low-level server provides pa Example of implementing pagination with MCP server decorators. """ -from pydantic import AnyUrl - import mcp.types as types from mcp.server.lowlevel import Server @@ -2070,7 +2068,7 @@ async def list_resources_paginated(request: types.ListResourcesRequest) -> types # Get page of resources page_items = [ - types.Resource(uri=AnyUrl(f"resource://items/{item}"), name=item, description=f"Description for {item}") + types.Resource(uri=f"resource://items/{item}", name=item, description=f"Description for {item}") for item in ITEMS[start:end] ] diff --git a/docs/migration.md b/docs/migration.md index c68e4856e7..8523309a31 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -116,6 +116,49 @@ result = await session.list_resources(params=PaginatedRequestParams(cursor="next result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token")) ``` +### Resource URI type changed from `AnyUrl` to `str` + +The `uri` field on resource-related types now uses `str` instead of Pydantic's `AnyUrl`. This aligns with the [MCP specification schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) which defines URIs as plain strings (`uri: string`) without strict URL validation. This change allows relative paths like `users/me` that were previously rejected. + +**Before (v1):** + +```python +from pydantic import AnyUrl +from mcp.types import Resource + +# Required wrapping in AnyUrl +resource = Resource(name="test", uri=AnyUrl("users/me")) # Would fail validation +``` + +**After (v2):** + +```python +from mcp.types import Resource + +# Plain strings accepted +resource = Resource(name="test", uri="users/me") # Works +resource = Resource(name="test", uri="custom://scheme") # Works +resource = Resource(name="test", uri="https://example.com") # Works +``` + +If your code passes `AnyUrl` objects to URI fields, convert them to strings: + +```python +# If you have an AnyUrl from elsewhere +uri = str(my_any_url) # Convert to string +``` + +Affected types: + +- `Resource.uri` +- `ReadResourceRequestParams.uri` +- `ResourceContents.uri` (and subclasses `TextResourceContents`, `BlobResourceContents`) +- `SubscribeRequestParams.uri` +- `UnsubscribeRequestParams.uri` +- `ResourceUpdatedNotificationParams.uri` + +The `ClientSession.read_resource()`, `subscribe_resource()`, and `unsubscribe_resource()` methods now accept both `str` and `AnyUrl` for backwards compatibility. + ## Deprecations diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index 1f1ee7ecc4..59c60ea654 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -29,7 +29,7 @@ TextContent, TextResourceContents, ) -from pydantic import AnyUrl, BaseModel, Field +from pydantic import BaseModel, Field logger = logging.getLogger(__name__) @@ -114,7 +114,7 @@ def test_embedded_resource() -> list[EmbeddedResource]: EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("test://embedded-resource"), + uri="test://embedded-resource", mimeType="text/plain", text="This is an embedded resource content.", ), @@ -131,7 +131,7 @@ def test_multiple_content_types() -> list[TextContent | ImageContent | EmbeddedR EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("test://mixed-content-resource"), + uri="test://mixed-content-resource", mimeType="application/json", text='{"test": "data", "value": 123}', ), @@ -372,7 +372,7 @@ def test_prompt_with_embedded_resource(resourceUri: str) -> list[UserMessage]: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl(resourceUri), + uri=resourceUri, mimeType="text/plain", text="Embedded resource content for testing.", ), @@ -402,13 +402,13 @@ async def handle_set_logging_level(level: str) -> None: # For conformance testing, we just acknowledge the request -async def handle_subscribe(uri: AnyUrl) -> None: +async def handle_subscribe(uri: str) -> None: """Handle resource subscription""" resource_subscriptions.add(str(uri)) logger.info(f"Subscribed to resource: {uri}") -async def handle_unsubscribe(uri: AnyUrl) -> None: +async def handle_unsubscribe(uri: str) -> None: """Handle resource unsubscription""" resource_subscriptions.discard(str(uri)) logger.info(f"Unsubscribed from resource: {uri}") diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/server.py b/examples/servers/simple-pagination/mcp_simple_pagination/server.py index 360cbc3cff..2412841041 100644 --- a/examples/servers/simple-pagination/mcp_simple_pagination/server.py +++ b/examples/servers/simple-pagination/mcp_simple_pagination/server.py @@ -11,7 +11,6 @@ import click import mcp.types as types from mcp.server.lowlevel import Server -from pydantic import AnyUrl from starlette.requests import Request # Sample data - in real scenarios, this might come from a database @@ -27,7 +26,7 @@ SAMPLE_RESOURCES = [ types.Resource( - uri=AnyUrl(f"file:///path/to/resource_{i}.txt"), + uri=f"file:///path/to/resource_{i}.txt", name=f"resource_{i}", description=f"This is sample resource number {i}", ) @@ -160,7 +159,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentB # Implement read_resource handler @app.read_resource() - async def read_resource(uri: AnyUrl) -> str: + async def read_resource(uri: str) -> str: # Find the resource in our sample data resource = next((r for r in SAMPLE_RESOURCES if r.uri == uri), None) if not resource: diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py index 151a23eab4..26bc316399 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/server.py +++ b/examples/servers/simple-resource/mcp_simple_resource/server.py @@ -3,7 +3,6 @@ import mcp.types as types from mcp.server.lowlevel import Server from mcp.server.lowlevel.helper_types import ReadResourceContents -from pydantic import AnyUrl, FileUrl from starlette.requests import Request SAMPLE_RESOURCES = { @@ -37,7 +36,7 @@ def main(port: int, transport: str) -> int: async def list_resources() -> list[types.Resource]: return [ types.Resource( - uri=FileUrl(f"file:///{name}.txt"), + uri=f"file:///{name}.txt", name=name, title=SAMPLE_RESOURCES[name]["title"], description=f"A sample text resource named {name}", @@ -47,10 +46,13 @@ async def list_resources() -> list[types.Resource]: ] @app.read_resource() - async def read_resource(uri: AnyUrl): - if uri.path is None: + async def read_resource(uri: str): + from urllib.parse import urlparse + + parsed = urlparse(uri) + if not parsed.path: raise ValueError(f"Invalid resource path: {uri}") - name = uri.path.replace(".txt", "").lstrip("/") + name = parsed.path.replace(".txt", "").lstrip("/") if name not in SAMPLE_RESOURCES: raise ValueError(f"Unknown resource: {uri}") diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py index 4b2604b9af..bfa9b23727 100644 --- a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py @@ -8,7 +8,6 @@ import mcp.types as types from mcp.server.lowlevel import Server from mcp.server.streamable_http_manager import StreamableHTTPSessionManager -from pydantic import AnyUrl from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware from starlette.routing import Mount @@ -74,7 +73,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentB # This will send a resource notificaiton though standalone SSE # established by GET request - await ctx.session.send_resource_updated(uri=AnyUrl("http:///test_resource")) + await ctx.session.send_resource_updated(uri="http:///test_resource") return [ types.TextContent( type="text", diff --git a/examples/snippets/servers/pagination_example.py b/examples/snippets/servers/pagination_example.py index 70c3b3492c..d62ee59316 100644 --- a/examples/snippets/servers/pagination_example.py +++ b/examples/snippets/servers/pagination_example.py @@ -2,8 +2,6 @@ Example of implementing pagination with MCP server decorators. """ -from pydantic import AnyUrl - import mcp.types as types from mcp.server.lowlevel import Server @@ -28,7 +26,7 @@ async def list_resources_paginated(request: types.ListResourcesRequest) -> types # Get page of resources page_items = [ - types.Resource(uri=AnyUrl(f"resource://items/{item}"), name=item, description=f"Description for {item}") + types.Resource(uri=f"resource://items/{item}", name=item, description=f"Description for {item}") for item in ITEMS[start:end] ] diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index de87b19aa9..3d6a3979d0 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -279,24 +279,24 @@ async def list_resource_templates( types.ListResourceTemplatesResult, ) - async def read_resource(self, uri: AnyUrl) -> types.ReadResourceResult: + async def read_resource(self, uri: str | AnyUrl) -> types.ReadResourceResult: """Send a resources/read request.""" return await self.send_request( - types.ClientRequest(types.ReadResourceRequest(params=types.ReadResourceRequestParams(uri=uri))), + types.ClientRequest(types.ReadResourceRequest(params=types.ReadResourceRequestParams(uri=str(uri)))), types.ReadResourceResult, ) - async def subscribe_resource(self, uri: AnyUrl) -> types.EmptyResult: + async def subscribe_resource(self, uri: str | AnyUrl) -> types.EmptyResult: """Send a resources/subscribe request.""" return await self.send_request( # pragma: no cover - types.ClientRequest(types.SubscribeRequest(params=types.SubscribeRequestParams(uri=uri))), + types.ClientRequest(types.SubscribeRequest(params=types.SubscribeRequestParams(uri=str(uri)))), types.EmptyResult, ) - async def unsubscribe_resource(self, uri: AnyUrl) -> types.EmptyResult: + async def unsubscribe_resource(self, uri: str | AnyUrl) -> types.EmptyResult: """Send a resources/unsubscribe request.""" return await self.send_request( # pragma: no cover - types.ClientRequest(types.UnsubscribeRequest(params=types.UnsubscribeRequestParams(uri=uri))), + types.ClientRequest(types.UnsubscribeRequest(params=types.UnsubscribeRequestParams(uri=str(uri)))), types.EmptyResult, ) diff --git a/src/mcp/server/fastmcp/resources/base.py b/src/mcp/server/fastmcp/resources/base.py index e34b97a820..b91a0e1203 100644 --- a/src/mcp/server/fastmcp/resources/base.py +++ b/src/mcp/server/fastmcp/resources/base.py @@ -1,14 +1,12 @@ """Base classes and interfaces for FastMCP resources.""" import abc -from typing import Annotated, Any +from typing import Any from pydantic import ( - AnyUrl, BaseModel, ConfigDict, Field, - UrlConstraints, ValidationInfo, field_validator, ) @@ -21,7 +19,7 @@ class Resource(BaseModel, abc.ABC): model_config = ConfigDict(validate_default=True) - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(default=..., description="URI of the resource") + uri: str = Field(default=..., description="URI of the resource") name: str | None = Field(description="Name of the resource", default=None) title: str | None = Field(description="Human-readable title of the resource", default=None) description: str | None = Field(description="Description of the resource", default=None) diff --git a/src/mcp/server/fastmcp/resources/types.py b/src/mcp/server/fastmcp/resources/types.py index 5f724301db..791442f87e 100644 --- a/src/mcp/server/fastmcp/resources/types.py +++ b/src/mcp/server/fastmcp/resources/types.py @@ -11,7 +11,7 @@ import httpx import pydantic import pydantic_core -from pydantic import AnyUrl, Field, ValidationInfo, validate_call +from pydantic import Field, ValidationInfo, validate_call from mcp.server.fastmcp.resources.base import Resource from mcp.types import Annotations, Icon @@ -94,7 +94,7 @@ def from_function( fn = validate_call(fn) return cls( - uri=AnyUrl(uri), + uri=uri, name=func_name, title=title, description=description or fn.__doc__ or "", diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 26f6148c48..491ff7d0b3 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -79,7 +79,6 @@ async def main(): import anyio import jsonschema from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from pydantic import AnyUrl from typing_extensions import TypeVar import mcp.types as types @@ -337,7 +336,7 @@ async def handler(_: Any): def read_resource(self): def decorator( - func: Callable[[AnyUrl], Awaitable[str | bytes | Iterable[ReadResourceContents]]], + func: Callable[[str], Awaitable[str | bytes | Iterable[ReadResourceContents]]], ): logger.debug("Registering handler for ReadResourceRequest") @@ -412,7 +411,7 @@ async def handler(req: types.SetLevelRequest): return decorator def subscribe_resource(self): # pragma: no cover - def decorator(func: Callable[[AnyUrl], Awaitable[None]]): + def decorator(func: Callable[[str], Awaitable[None]]): logger.debug("Registering handler for SubscribeRequest") async def handler(req: types.SubscribeRequest): @@ -425,7 +424,7 @@ async def handler(req: types.SubscribeRequest): return decorator def unsubscribe_resource(self): # pragma: no cover - def decorator(func: Callable[[AnyUrl], Awaitable[None]]): + def decorator(func: Callable[[str], Awaitable[None]]): logger.debug("Registering handler for UnsubscribeRequest") async def handler(req: types.UnsubscribeRequest): diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index fe90cd10fa..b6fd3a2e8f 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -227,12 +227,12 @@ async def send_log_message( related_request_id, ) - async def send_resource_updated(self, uri: AnyUrl) -> None: # pragma: no cover + async def send_resource_updated(self, uri: str | AnyUrl) -> None: # pragma: no cover """Send a resource updated notification.""" await self.send_notification( types.ServerNotification( types.ResourceUpdatedNotification( - params=types.ResourceUpdatedNotificationParams(uri=uri), + params=types.ResourceUpdatedNotificationParams(uri=str(uri)), ) ) ) diff --git a/src/mcp/types.py b/src/mcp/types.py index 2671eb3f7f..6a5ecf35f6 100644 --- a/src/mcp/types.py +++ b/src/mcp/types.py @@ -5,7 +5,6 @@ from typing import Annotated, Any, Final, Generic, Literal, TypeAlias, TypeVar from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel -from pydantic.networks import AnyUrl, UrlConstraints LATEST_PROTOCOL_VERSION = "2025-11-25" @@ -756,7 +755,7 @@ class Annotations(BaseModel): class Resource(BaseMetadata): """A known resource that the server is capable of reading.""" - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + uri: str """The URI of this resource.""" description: str | None = None """A description of what this resource represents.""" @@ -827,7 +826,7 @@ class ListResourceTemplatesResult(PaginatedResult): class ReadResourceRequestParams(RequestParams): """Parameters for reading a resource.""" - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + uri: str """ The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it. @@ -845,7 +844,7 @@ class ReadResourceRequest(Request[ReadResourceRequestParams, Literal["resources/ class ResourceContents(BaseModel): """The contents of a specific resource or sub-resource.""" - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + uri: str """The URI of this resource.""" mimeType: str | None = None """The MIME type of this resource, if known.""" @@ -895,7 +894,7 @@ class ResourceListChangedNotification( class SubscribeRequestParams(RequestParams): """Parameters for subscribing to a resource.""" - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + uri: str """ The URI of the resource to subscribe to. The URI can use any protocol; it is up to the server how to interpret it. @@ -916,7 +915,7 @@ class SubscribeRequest(Request[SubscribeRequestParams, Literal["resources/subscr class UnsubscribeRequestParams(RequestParams): """Parameters for unsubscribing from a resource.""" - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + uri: str """The URI of the resource to unsubscribe from.""" model_config = ConfigDict(extra="allow") @@ -934,7 +933,7 @@ class UnsubscribeRequest(Request[UnsubscribeRequestParams, Literal["resources/un class ResourceUpdatedNotificationParams(NotificationParams): """Parameters for resource update notifications.""" - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] + uri: str """ The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. diff --git a/tests/issues/test_152_resource_mime_type.py b/tests/issues/test_152_resource_mime_type.py index 2a8cd6202e..ea411ea616 100644 --- a/tests/issues/test_152_resource_mime_type.py +++ b/tests/issues/test_152_resource_mime_type.py @@ -70,9 +70,9 @@ async def test_lowlevel_resource_mime_type(): # Create test resources with specific mime types test_resources = [ - types.Resource(uri=AnyUrl("test://image"), name="test image", mimeType="image/png"), + types.Resource(uri="test://image", name="test image", mimeType="image/png"), types.Resource( - uri=AnyUrl("test://image_bytes"), + uri="test://image_bytes", name="test image bytes", mimeType="image/png", ), @@ -83,7 +83,7 @@ async def handle_list_resources(): return test_resources @server.read_resource() - async def handle_read_resource(uri: AnyUrl): + 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": diff --git a/tests/issues/test_342_base64_encoding.py b/tests/issues/test_342_base64_encoding.py index da56959975..2554fbc735 100644 --- a/tests/issues/test_342_base64_encoding.py +++ b/tests/issues/test_342_base64_encoding.py @@ -13,7 +13,6 @@ from typing import cast import pytest -from pydantic import AnyUrl from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.lowlevel.server import Server @@ -46,7 +45,7 @@ async def test_server_base64_encoding_issue(): # Register a resource handler that returns our test data @server.read_resource() - async def read_resource(uri: AnyUrl) -> list[ReadResourceContents]: + 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 @@ -54,7 +53,7 @@ async def read_resource(uri: AnyUrl) -> list[ReadResourceContents]: # Create a request request = ReadResourceRequest( - params=ReadResourceRequestParams(uri=AnyUrl("test://resource")), + params=ReadResourceRequestParams(uri="test://resource"), ) # Call the handler to get the response diff --git a/tests/server/fastmcp/prompts/test_base.py b/tests/server/fastmcp/prompts/test_base.py index 488bd5002c..84c9712681 100644 --- a/tests/server/fastmcp/prompts/test_base.py +++ b/tests/server/fastmcp/prompts/test_base.py @@ -1,7 +1,6 @@ from typing import Any import pytest -from pydantic import FileUrl from mcp.server.fastmcp.prompts.base import AssistantMessage, Message, Prompt, TextContent, UserMessage from mcp.types import EmbeddedResource, TextResourceContents @@ -95,7 +94,7 @@ async def fn() -> UserMessage: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri=FileUrl("file://file.txt"), + uri="file://file.txt", text="File contents", mimeType="text/plain", ), @@ -108,7 +107,7 @@ async def fn() -> UserMessage: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri=FileUrl("file://file.txt"), + uri="file://file.txt", text="File contents", mimeType="text/plain", ), @@ -127,7 +126,7 @@ async def fn() -> list[Message]: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri=FileUrl("file://file.txt"), + uri="file://file.txt", text="File contents", mimeType="text/plain", ), @@ -143,7 +142,7 @@ async def fn() -> list[Message]: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri=FileUrl("file://file.txt"), + uri="file://file.txt", text="File contents", mimeType="text/plain", ), @@ -162,7 +161,7 @@ async def fn() -> dict[str, Any]: "content": { "type": "resource", "resource": { - "uri": FileUrl("file://file.txt"), + "uri": "file://file.txt", "text": "File contents", "mimeType": "text/plain", }, @@ -175,7 +174,7 @@ async def fn() -> dict[str, Any]: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri=FileUrl("file://file.txt"), + uri="file://file.txt", text="File contents", mimeType="text/plain", ), diff --git a/tests/server/fastmcp/resources/test_file_resources.py b/tests/server/fastmcp/resources/test_file_resources.py index c82cf85c5a..0eb24f0632 100644 --- a/tests/server/fastmcp/resources/test_file_resources.py +++ b/tests/server/fastmcp/resources/test_file_resources.py @@ -3,7 +3,6 @@ from tempfile import NamedTemporaryFile import pytest -from pydantic import FileUrl from mcp.server.fastmcp.resources import FileResource @@ -31,7 +30,7 @@ class TestFileResource: def test_file_resource_creation(self, temp_file: Path): """Test creating a FileResource.""" resource = FileResource( - uri=FileUrl(temp_file.as_uri()), + uri=temp_file.as_uri(), name="test", description="test file", path=temp_file, @@ -46,7 +45,7 @@ def test_file_resource_creation(self, temp_file: Path): def test_file_resource_str_path_conversion(self, temp_file: Path): """Test FileResource handles string paths.""" resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=Path(str(temp_file)), ) @@ -57,7 +56,7 @@ def test_file_resource_str_path_conversion(self, temp_file: Path): async def test_read_text_file(self, temp_file: Path): """Test reading a text file.""" resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=temp_file, ) @@ -69,7 +68,7 @@ async def test_read_text_file(self, temp_file: Path): async def test_read_binary_file(self, temp_file: Path): """Test reading a file as binary.""" resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=temp_file, is_binary=True, @@ -82,7 +81,7 @@ def test_relative_path_error(self): """Test error on relative path.""" with pytest.raises(ValueError, match="Path must be absolute"): FileResource( - uri=FileUrl("file:///test.txt"), + uri="file:///test.txt", name="test", path=Path("test.txt"), ) @@ -93,7 +92,7 @@ async def test_missing_file_error(self, temp_file: Path): # Create path to non-existent file missing = temp_file.parent / "missing.txt" resource = FileResource( - uri=FileUrl("file:///missing.txt"), + uri="file:///missing.txt", name="test", path=missing, ) @@ -107,7 +106,7 @@ async def test_permission_error(self, temp_file: Path): # pragma: no cover temp_file.chmod(0o000) # Remove all permissions try: resource = FileResource( - uri=FileUrl(temp_file.as_uri()), + uri=temp_file.as_uri(), name="test", path=temp_file, ) diff --git a/tests/server/fastmcp/resources/test_function_resources.py b/tests/server/fastmcp/resources/test_function_resources.py index 4619fd2e04..61ed44f6c6 100644 --- a/tests/server/fastmcp/resources/test_function_resources.py +++ b/tests/server/fastmcp/resources/test_function_resources.py @@ -1,5 +1,5 @@ import pytest -from pydantic import AnyUrl, BaseModel +from pydantic import BaseModel from mcp.server.fastmcp.resources import FunctionResource @@ -14,7 +14,7 @@ def my_func() -> str: # pragma: no cover return "test content" resource = FunctionResource( - uri=AnyUrl("fn://test"), + uri="fn://test", name="test", description="test function", fn=my_func, @@ -33,7 +33,7 @@ def get_data() -> str: return "Hello, world!" resource = FunctionResource( - uri=AnyUrl("function://test"), + uri="function://test", name="test", fn=get_data, ) @@ -49,7 +49,7 @@ def get_data() -> bytes: return b"Hello, world!" resource = FunctionResource( - uri=AnyUrl("function://test"), + uri="function://test", name="test", fn=get_data, ) @@ -64,7 +64,7 @@ def get_data() -> dict[str, str]: return {"key": "value"} resource = FunctionResource( - uri=AnyUrl("function://test"), + uri="function://test", name="test", fn=get_data, ) @@ -80,7 +80,7 @@ def failing_func() -> str: raise ValueError("Test error") resource = FunctionResource( - uri=AnyUrl("function://test"), + uri="function://test", name="test", fn=failing_func, ) @@ -95,7 +95,7 @@ class MyModel(BaseModel): name: str resource = FunctionResource( - uri=AnyUrl("function://test"), + uri="function://test", name="test", fn=lambda: MyModel(name="test"), ) @@ -114,7 +114,7 @@ def get_data() -> CustomData: return CustomData() resource = FunctionResource( - uri=AnyUrl("function://test"), + uri="function://test", name="test", fn=get_data, ) @@ -129,7 +129,7 @@ async def get_data() -> str: return "Hello, world!" resource = FunctionResource( - uri=AnyUrl("function://test"), + uri="function://test", name="test", fn=get_data, ) @@ -154,7 +154,7 @@ async def get_data() -> str: # pragma: no cover assert resource.description == "get_data returns a string" assert resource.mime_type == "text/plain" assert resource.name == "test" - assert resource.uri == AnyUrl("function://test") + assert resource.uri == "function://test" class TestFunctionResourceMetadata: diff --git a/tests/server/fastmcp/resources/test_resource_manager.py b/tests/server/fastmcp/resources/test_resource_manager.py index 565c816f18..5fd4bc8529 100644 --- a/tests/server/fastmcp/resources/test_resource_manager.py +++ b/tests/server/fastmcp/resources/test_resource_manager.py @@ -2,7 +2,7 @@ from tempfile import NamedTemporaryFile import pytest -from pydantic import AnyUrl, FileUrl +from pydantic import AnyUrl from mcp.server.fastmcp.resources import FileResource, FunctionResource, ResourceManager, ResourceTemplate @@ -31,7 +31,7 @@ def test_add_resource(self, temp_file: Path): """Test adding a resource.""" manager = ResourceManager() resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=temp_file, ) @@ -43,7 +43,7 @@ def test_add_duplicate_resource(self, temp_file: Path): """Test adding the same resource twice.""" manager = ResourceManager() resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=temp_file, ) @@ -56,7 +56,7 @@ def test_warn_on_duplicate_resources(self, temp_file: Path, caplog: pytest.LogCa """Test warning on duplicate resources.""" manager = ResourceManager() resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=temp_file, ) @@ -68,7 +68,7 @@ def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog: pyte """Test disabling warning on duplicate resources.""" manager = ResourceManager(warn_on_duplicate_resources=False) resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=temp_file, ) @@ -81,7 +81,7 @@ async def test_get_resource(self, temp_file: Path): """Test getting a resource by URI.""" manager = ResourceManager() resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test", path=temp_file, ) @@ -120,12 +120,12 @@ def test_list_resources(self, temp_file: Path): """Test listing all resources.""" manager = ResourceManager() resource1 = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=f"file://{temp_file}", name="test1", path=temp_file, ) resource2 = FileResource( - uri=FileUrl(f"file://{temp_file}2"), + uri=f"file://{temp_file}2", name="test2", path=temp_file, ) diff --git a/tests/server/fastmcp/resources/test_resources.py b/tests/server/fastmcp/resources/test_resources.py index d617774fa5..6d346786dc 100644 --- a/tests/server/fastmcp/resources/test_resources.py +++ b/tests/server/fastmcp/resources/test_resources.py @@ -1,5 +1,4 @@ import pytest -from pydantic import AnyUrl from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp.resources import FunctionResource, Resource @@ -9,35 +8,35 @@ class TestResourceValidation: """Test base Resource validation.""" - def test_resource_uri_validation(self): - """Test URI validation.""" + def test_resource_uri_accepts_any_string(self): + """Test that URI field accepts any string per MCP spec.""" def dummy_func() -> str: # pragma: no cover return "data" # Valid URI resource = FunctionResource( - uri=AnyUrl("http://example.com/data"), + uri="http://example.com/data", name="test", fn=dummy_func, ) - assert str(resource.uri) == "http://example.com/data" + assert resource.uri == "http://example.com/data" - # Missing protocol - with pytest.raises(ValueError, match="Input should be a valid URL"): - FunctionResource( - uri=AnyUrl("invalid"), - name="test", - fn=dummy_func, - ) + # Relative path - now accepted per MCP spec + resource = FunctionResource( + uri="users/me", + name="test", + fn=dummy_func, + ) + assert resource.uri == "users/me" - # Missing host - with pytest.raises(ValueError, match="Input should be a valid URL"): - FunctionResource( - uri=AnyUrl("http://"), - name="test", - fn=dummy_func, - ) + # Custom scheme + resource = FunctionResource( + uri="custom://resource", + name="test", + fn=dummy_func, + ) + assert resource.uri == "custom://resource" def test_resource_name_from_uri(self): """Test name is extracted from URI if not provided.""" @@ -46,7 +45,7 @@ def dummy_func() -> str: # pragma: no cover return "data" resource = FunctionResource( - uri=AnyUrl("resource://my-resource"), + uri="resource://my-resource", fn=dummy_func, ) assert resource.name == "resource://my-resource" @@ -65,7 +64,7 @@ def dummy_func() -> str: # pragma: no cover # Explicit name takes precedence over URI resource = FunctionResource( - uri=AnyUrl("resource://uri-name"), + uri="resource://uri-name", name="explicit-name", fn=dummy_func, ) @@ -79,14 +78,14 @@ def dummy_func() -> str: # pragma: no cover # Default mime type resource = FunctionResource( - uri=AnyUrl("resource://test"), + uri="resource://test", fn=dummy_func, ) assert resource.mime_type == "text/plain" # Custom mime type resource = FunctionResource( - uri=AnyUrl("resource://test"), + uri="resource://test", fn=dummy_func, mime_type="application/json", ) @@ -100,7 +99,7 @@ class ConcreteResource(Resource): pass with pytest.raises(TypeError, match="abstract method"): - ConcreteResource(uri=AnyUrl("test://test"), name="test") # type: ignore + ConcreteResource(uri="test://test", name="test") # type: ignore class TestResourceAnnotations: @@ -207,7 +206,7 @@ def dummy_func() -> str: # pragma: no cover metadata = {"version": "1.0", "category": "test"} resource = FunctionResource( - uri=AnyUrl("resource://test"), + uri="resource://test", name="test", fn=dummy_func, meta=metadata, @@ -225,7 +224,7 @@ def dummy_func() -> str: # pragma: no cover return "data" resource = FunctionResource( - uri=AnyUrl("resource://test"), + uri="resource://test", name="test", fn=dummy_func, ) diff --git a/tests/server/fastmcp/test_server.py b/tests/server/fastmcp/test_server.py index b6cd0d5dfa..68adb7ee40 100644 --- a/tests/server/fastmcp/test_server.py +++ b/tests/server/fastmcp/test_server.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from pydantic import AnyUrl, BaseModel +from pydantic import BaseModel from starlette.routing import Mount, Route from mcp.server.fastmcp import Context, FastMCP @@ -743,11 +743,11 @@ async def test_text_resource(self): def get_text(): return "Hello, world!" - resource = FunctionResource(uri=AnyUrl("resource://test"), name="test", fn=get_text) + resource = FunctionResource(uri="resource://test", name="test", fn=get_text) mcp.add_resource(resource) async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://test")) + result = await client.read_resource("resource://test") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Hello, world!" @@ -759,7 +759,7 @@ def get_binary(): return b"Binary data" resource = FunctionResource( - uri=AnyUrl("resource://binary"), + uri="resource://binary", name="binary", fn=get_binary, mime_type="application/octet-stream", @@ -767,7 +767,7 @@ def get_binary(): mcp.add_resource(resource) async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://binary")) + result = await client.read_resource("resource://binary") assert isinstance(result.contents[0], BlobResourceContents) assert result.contents[0].blob == base64.b64encode(b"Binary data").decode() @@ -779,11 +779,11 @@ async def test_file_resource_text(self, tmp_path: Path): text_file = tmp_path / "test.txt" text_file.write_text("Hello from file!") - resource = FileResource(uri=AnyUrl("file://test.txt"), name="test.txt", path=text_file) + resource = FileResource(uri="file://test.txt", name="test.txt", path=text_file) mcp.add_resource(resource) async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("file://test.txt")) + result = await client.read_resource("file://test.txt") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Hello from file!" @@ -796,7 +796,7 @@ async def test_file_resource_binary(self, tmp_path: Path): binary_file.write_bytes(b"Binary file data") resource = FileResource( - uri=AnyUrl("file://test.bin"), + uri="file://test.bin", name="test.bin", path=binary_file, mime_type="application/octet-stream", @@ -804,7 +804,7 @@ async def test_file_resource_binary(self, tmp_path: Path): mcp.add_resource(resource) async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("file://test.bin")) + result = await client.read_resource("file://test.bin") assert isinstance(result.contents[0], BlobResourceContents) assert result.contents[0].blob == base64.b64encode(b"Binary file data").decode() @@ -822,7 +822,7 @@ def get_data() -> str: # pragma: no cover assert len(resources.resources) == 1 resource = resources.resources[0] assert resource.description == "get_data returns a string" - assert resource.uri == AnyUrl("function://test") + assert resource.uri == "function://test" assert resource.name == "test_get_data" assert resource.mimeType == "text/plain" @@ -870,7 +870,7 @@ def get_data(name: str) -> str: return f"Data for {name}" async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://test/data")) + result = await client.read_resource("resource://test/data") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Data for test" @@ -895,7 +895,7 @@ def get_data(org: str, repo: str) -> str: return f"Data for {org}/{repo}" async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://cursor/fastmcp/data")) + result = await client.read_resource("resource://cursor/fastmcp/data") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Data for cursor/fastmcp" @@ -918,7 +918,7 @@ def get_static_data() -> str: return "Static data" async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://static")) + result = await client.read_resource("resource://static") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "Static data" @@ -958,7 +958,7 @@ def get_csv(user: str) -> str: assert template.mimeType == "text/csv" async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://bob/csv")) + result = await client.read_resource("resource://bob/csv") assert isinstance(result.contents[0], TextResourceContents) assert result.contents[0].text == "csv for bob" @@ -1020,7 +1020,7 @@ def get_data() -> str: return "test data" async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://data")) + result = await client.read_resource("resource://data") # Verify content and metadata in protocol response assert isinstance(result.contents[0], TextResourceContents) @@ -1189,7 +1189,7 @@ def resource_with_context(name: str, ctx: Context[ServerSession, None]) -> str: # Test via client async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://context/test")) + result = await client.read_resource("resource://context/test") assert len(result.contents) == 1 content = result.contents[0] assert isinstance(content, TextResourceContents) @@ -1214,7 +1214,7 @@ def resource_no_context(name: str) -> str: # Test via client async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://nocontext/test")) + result = await client.read_resource("resource://nocontext/test") assert len(result.contents) == 1 content = result.contents[0] assert isinstance(content, TextResourceContents) @@ -1239,7 +1239,7 @@ def resource_custom_ctx(id: str, my_ctx: Context[ServerSession, None]) -> str: # Test via client async with client_session(mcp._mcp_server) as client: - result = await client.read_resource(AnyUrl("resource://custom/123")) + result = await client.read_resource("resource://custom/123") assert len(result.contents) == 1 content = result.contents[0] assert isinstance(content, TextResourceContents) @@ -1442,7 +1442,7 @@ def fn() -> Message: content=EmbeddedResource( type="resource", resource=TextResourceContents( - uri=AnyUrl("file://file.txt"), + uri="file://file.txt", text="File contents", mimeType="text/plain", ), diff --git a/tests/server/fastmcp/test_title.py b/tests/server/fastmcp/test_title.py index 774f8dd63c..da9443eb40 100644 --- a/tests/server/fastmcp/test_title.py +++ b/tests/server/fastmcp/test_title.py @@ -1,7 +1,6 @@ """Integration tests for title field functionality.""" import pytest -from pydantic import AnyUrl from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp.resources import FunctionResource @@ -134,7 +133,7 @@ def get_basic_data() -> str: # pragma: no cover return "Basic data" basic_resource = FunctionResource( - uri=AnyUrl("resource://basic"), + uri="resource://basic", name="basic_resource", description="Basic resource", fn=get_basic_data, @@ -146,7 +145,7 @@ def get_titled_data() -> str: # pragma: no cover return "Titled data" titled_resource = FunctionResource( - uri=AnyUrl("resource://titled"), + uri="resource://titled", name="titled_resource", title="User-Friendly Resource", description="Resource with title", @@ -219,10 +218,10 @@ async def test_get_display_name_utility(): assert get_display_name(tool_with_both) == "Primary Title" # Test other types: title > name - resource = Resource(uri=AnyUrl("file://test"), name="test_res") + resource = Resource(uri="file://test", name="test_res") assert get_display_name(resource) == "test_res" - resource_with_title = Resource(uri=AnyUrl("file://test"), name="test_res", title="Test Resource") + resource_with_title = Resource(uri="file://test", name="test_res", title="Test Resource") assert get_display_name(resource_with_title) == "Test Resource" prompt = Prompt(name="test_prompt") diff --git a/tests/server/lowlevel/test_server_listing.py b/tests/server/lowlevel/test_server_listing.py index 23ac7e4519..60823d967c 100644 --- a/tests/server/lowlevel/test_server_listing.py +++ b/tests/server/lowlevel/test_server_listing.py @@ -3,7 +3,6 @@ import warnings import pytest -from pydantic import AnyUrl from mcp.server import Server from mcp.types import ( @@ -52,8 +51,8 @@ async def test_list_resources_basic() -> None: server = Server("test") test_resources = [ - Resource(uri=AnyUrl("file:///test1.txt"), name="Test 1"), - Resource(uri=AnyUrl("file:///test2.txt"), name="Test 2"), + Resource(uri="file:///test1.txt", name="Test 1"), + Resource(uri="file:///test2.txt", name="Test 2"), ] with warnings.catch_warnings(): diff --git a/tests/server/test_read_resource.py b/tests/server/test_read_resource.py index c31b90c557..75a1f19935 100644 --- a/tests/server/test_read_resource.py +++ b/tests/server/test_read_resource.py @@ -3,7 +3,6 @@ from tempfile import NamedTemporaryFile import pytest -from pydantic import AnyUrl, FileUrl import mcp.types as types from mcp.server.lowlevel.server import ReadResourceContents, Server @@ -27,7 +26,7 @@ async def test_read_resource_text(temp_file: Path): server = Server("test") @server.read_resource() - async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: + async def read_resource(uri: str) -> Iterable[ReadResourceContents]: return [ReadResourceContents(content="Hello World", mime_type="text/plain")] # Get the handler directly from the server @@ -35,7 +34,7 @@ async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: # Create a request request = types.ReadResourceRequest( - params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())), + params=types.ReadResourceRequestParams(uri=temp_file.as_uri()), ) # Call the handler @@ -54,7 +53,7 @@ async def test_read_resource_binary(temp_file: Path): server = Server("test") @server.read_resource() - async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: + async def read_resource(uri: str) -> Iterable[ReadResourceContents]: return [ReadResourceContents(content=b"Hello World", mime_type="application/octet-stream")] # Get the handler directly from the server @@ -62,7 +61,7 @@ async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: # Create a request request = types.ReadResourceRequest( - params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())), + params=types.ReadResourceRequestParams(uri=temp_file.as_uri()), ) # Call the handler @@ -80,7 +79,7 @@ async def test_read_resource_default_mime(temp_file: Path): server = Server("test") @server.read_resource() - async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: + async def read_resource(uri: str) -> Iterable[ReadResourceContents]: return [ ReadResourceContents( content="Hello World", @@ -93,7 +92,7 @@ async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]: # Create a request request = types.ReadResourceRequest( - params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())), + params=types.ReadResourceRequestParams(uri=temp_file.as_uri()), ) # Call the handler diff --git a/tests/shared/test_memory.py b/tests/shared/test_memory.py index ca4368e9f8..10f580a6c5 100644 --- a/tests/shared/test_memory.py +++ b/tests/shared/test_memory.py @@ -1,5 +1,4 @@ import pytest -from pydantic import AnyUrl from typing_extensions import AsyncGenerator from mcp.client.session import ClientSession @@ -16,7 +15,7 @@ def mcp_server() -> Server: async def handle_list_resources(): # pragma: no cover return [ Resource( - uri=AnyUrl("memory://test"), + uri="memory://test", name="Test Resource", description="A test resource", ) diff --git a/tests/shared/test_sse.py b/tests/shared/test_sse.py index 7604450f81..99d84515ef 100644 --- a/tests/shared/test_sse.py +++ b/tests/shared/test_sse.py @@ -5,6 +5,7 @@ from collections.abc import AsyncGenerator, Generator from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch +from urllib.parse import urlparse import anyio import httpx @@ -12,7 +13,6 @@ import uvicorn from httpx_sse import ServerSentEvent from inline_snapshot import snapshot -from pydantic import AnyUrl from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import Response @@ -61,13 +61,14 @@ def __init__(self): super().__init__(SERVER_NAME) @self.read_resource() - async def handle_read_resource(uri: AnyUrl) -> str | bytes: - if uri.scheme == "foobar": - return f"Read {uri.host}" - elif uri.scheme == "slow": + async def handle_read_resource(uri: str) -> str | bytes: + parsed = urlparse(uri) + if parsed.scheme == "foobar": + return f"Read {parsed.netloc}" + if parsed.scheme == "slow": # Simulate a slow resource await anyio.sleep(2.0) - return f"Slow response from {uri.host}" + return f"Slow response from {parsed.netloc}" raise McpError(error=ErrorData(code=404, message="OOPS! no resource with that URI was found")) @@ -254,7 +255,7 @@ async def test_sse_client_happy_request_and_response( initialized_sse_client_session: ClientSession, ) -> None: session = initialized_sse_client_session - response = await session.read_resource(uri=AnyUrl("foobar://should-work")) + response = await session.read_resource(uri="foobar://should-work") assert len(response.contents) == 1 assert isinstance(response.contents[0], TextResourceContents) assert response.contents[0].text == "Read should-work" @@ -266,7 +267,7 @@ async def test_sse_client_exception_handling( ) -> None: session = initialized_sse_client_session with pytest.raises(McpError, match="OOPS! no resource with that URI was found"): - await session.read_resource(uri=AnyUrl("xxx://will-not-work")) + await session.read_resource(uri="xxx://will-not-work") @pytest.mark.anyio @@ -277,12 +278,12 @@ async def test_sse_client_timeout( # pragma: no cover session = initialized_sse_client_session # sanity check that normal, fast responses are working - response = await session.read_resource(uri=AnyUrl("foobar://1")) + response = await session.read_resource(uri="foobar://1") assert isinstance(response, ReadResourceResult) with anyio.move_on_after(3): with pytest.raises(McpError, match="Read timed out"): - response = await session.read_resource(uri=AnyUrl("slow://2")) + response = await session.read_resource(uri="slow://2") # we should receive an error here return diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 0ed4250533..a0c0e6b4cf 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -13,6 +13,7 @@ from collections.abc import Generator from typing import Any from unittest.mock import MagicMock +from urllib.parse import urlparse import anyio import httpx @@ -137,13 +138,14 @@ def __init__(self): self._lock = None # Will be initialized in async context @self.read_resource() - async def handle_read_resource(uri: AnyUrl) -> str | bytes: - if uri.scheme == "foobar": - return f"Read {uri.host}" - elif uri.scheme == "slow": + async def handle_read_resource(uri: str) -> str | bytes: + parsed = urlparse(uri) + if parsed.scheme == "foobar": + return f"Read {parsed.netloc}" + if parsed.scheme == "slow": # Simulate a slow resource await anyio.sleep(2.0) - return f"Slow response from {uri.host}" + return f"Slow response from {parsed.netloc}" raise ValueError(f"Unknown resource: {uri}") @@ -214,7 +216,7 @@ async def handle_call_tool(name: str, args: dict[str, Any]) -> list[TextContent] # When the tool is called, send a notification to test GET stream if name == "test_tool_with_standalone_notification": - await ctx.session.send_resource_updated(uri=AnyUrl("http://test_resource")) + await ctx.session.send_resource_updated(uri="http://test_resource") return [TextContent(type="text", text=f"Called {name}")] elif name == "long_running_with_checkpoints": @@ -368,7 +370,7 @@ async def handle_call_tool(name: str, args: dict[str, Any]) -> list[TextContent] elif name == "tool_with_standalone_stream_close": # Test for GET stream reconnection # 1. Send unsolicited notification via GET stream (no related_request_id) - await ctx.session.send_resource_updated(uri=AnyUrl("http://notification_1")) + await ctx.session.send_resource_updated(uri="http://notification_1") # Small delay to ensure notification is flushed before closing await anyio.sleep(0.1) @@ -381,7 +383,7 @@ async def handle_call_tool(name: str, args: dict[str, Any]) -> list[TextContent] await anyio.sleep(1.5) # 4. Send another notification on the new GET stream connection - await ctx.session.send_resource_updated(uri=AnyUrl("http://notification_2")) + await ctx.session.send_resource_updated(uri="http://notification_2") return [TextContent(type="text", text="Standalone stream close test done")] @@ -1009,7 +1011,7 @@ async def test_streamable_http_client_basic_connection(basic_server: None, basic @pytest.mark.anyio async def test_streamable_http_client_resource_read(initialized_client_session: ClientSession): """Test client resource read functionality.""" - response = await initialized_client_session.read_resource(uri=AnyUrl("foobar://test-resource")) + response = await initialized_client_session.read_resource(uri="foobar://test-resource") assert len(response.contents) == 1 assert response.contents[0].uri == AnyUrl("foobar://test-resource") assert isinstance(response.contents[0], TextResourceContents) @@ -1035,7 +1037,7 @@ async def test_streamable_http_client_tool_invocation(initialized_client_session async def test_streamable_http_client_error_handling(initialized_client_session: ClientSession): """Test error handling in client.""" with pytest.raises(McpError) as exc_info: - await initialized_client_session.read_resource(uri=AnyUrl("unknown://test-error")) + await initialized_client_session.read_resource(uri="unknown://test-error") assert exc_info.value.error.code == 0 assert "Unknown resource: unknown://test-error" in exc_info.value.error.message @@ -1061,7 +1063,7 @@ async def test_streamable_http_client_session_persistence(basic_server: None, ba assert len(tools.tools) == 10 # Read a resource - resource = await session.read_resource(uri=AnyUrl("foobar://test-persist")) + resource = await session.read_resource(uri="foobar://test-persist") assert isinstance(resource.contents[0], TextResourceContents) is True content = resource.contents[0] assert isinstance(content, TextResourceContents) diff --git a/tests/shared/test_ws.py b/tests/shared/test_ws.py index f093cb4927..e24063ffc9 100644 --- a/tests/shared/test_ws.py +++ b/tests/shared/test_ws.py @@ -49,13 +49,16 @@ def __init__(self): super().__init__(SERVER_NAME) @self.read_resource() - async def handle_read_resource(uri: AnyUrl) -> str | bytes: - if uri.scheme == "foobar": - return f"Read {uri.host}" - elif uri.scheme == "slow": + async def handle_read_resource(uri: str) -> str | bytes: + from urllib.parse import urlparse + + parsed = urlparse(uri) + if parsed.scheme == "foobar": + return f"Read {parsed.netloc}" + elif parsed.scheme == "slow": # Simulate a slow resource await anyio.sleep(2.0) - return f"Slow response from {uri.host}" + return f"Slow response from {parsed.netloc}" raise McpError(error=ErrorData(code=404, message="OOPS! no resource with that URI was found")) From 9f417f3445f872eab10fb3c98a9555f4012e5e4b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 15 Jan 2026 17:52:15 +0100 Subject: [PATCH 3/4] Replace shallow unit tests with integration tests for #1574 Replace type-instantiation tests with meaningful tests that verify: - Relative URIs survive the full server-client JSON-RPC roundtrip - Custom scheme URIs work end-to-end - URIs are preserved exactly through JSON serialization These tests catch regressions more reliably - if AnyUrl is reintroduced, the integration tests will fail during serialization or URI transformation. Github-Issue: #1574 --- .../test_1574_resource_uri_validation.py | 205 +++++++++++------- 1 file changed, 126 insertions(+), 79 deletions(-) diff --git a/tests/issues/test_1574_resource_uri_validation.py b/tests/issues/test_1574_resource_uri_validation.py index 51845ab56f..10cb558262 100644 --- a/tests/issues/test_1574_resource_uri_validation.py +++ b/tests/issues/test_1574_resource_uri_validation.py @@ -1,85 +1,132 @@ """Tests for issue #1574: Python SDK incorrectly validates Resource URIs. -The Python SDK uses Pydantic's AnyUrl for URI fields, which rejects relative paths -like 'users/me' that are valid according to the MCP spec and accepted by the -TypeScript SDK. +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 spec defines uri fields as plain strings with no JSON Schema format validation. -""" - -from mcp import types - - -class TestResourceUriValidation: - """Test that Resource URI fields accept all valid MCP URIs.""" - - def test_relative_path_uri(self): - """ - REPRODUCER: Relative paths like 'users/me' should be accepted. - - Currently fails with: - ValidationError: Input should be a valid URL, relative URL without a base - """ - # This should NOT raise - relative paths are valid per MCP spec - resource = types.Resource(name="test", uri="users/me") - assert str(resource.uri) == "users/me" - - def test_custom_scheme_uri(self): - """Custom scheme URIs should be accepted.""" - resource = types.Resource(name="test", uri="custom://resource") - assert str(resource.uri) == "custom://resource" - - def test_file_url(self): - """File URLs should be accepted.""" - resource = types.Resource(name="test", uri="file:///path/to/file") - assert str(resource.uri) == "file:///path/to/file" - - def test_http_url(self): - """HTTP URLs should be accepted.""" - resource = types.Resource(name="test", uri="https://example.com/resource") - assert str(resource.uri) == "https://example.com/resource" - - -class TestReadResourceRequestParamsUri: - """Test that ReadResourceRequestParams.uri accepts all valid MCP URIs.""" - - def test_relative_path_uri(self): - """Relative paths should be accepted in read requests.""" - params = types.ReadResourceRequestParams(uri="users/me") - assert str(params.uri) == "users/me" - - -class TestResourceContentsUri: - """Test that ResourceContents.uri accepts all valid MCP URIs.""" - - def test_relative_path_uri(self): - """Relative paths should be accepted in resource contents.""" - contents = types.TextResourceContents(uri="users/me", text="content") - assert str(contents.uri) == "users/me" - - -class TestSubscribeRequestParamsUri: - """Test that SubscribeRequestParams.uri accepts all valid MCP URIs.""" - - def test_relative_path_uri(self): - """Relative paths should be accepted in subscribe requests.""" - params = types.SubscribeRequestParams(uri="users/me") - assert str(params.uri) == "users/me" - - -class TestUnsubscribeRequestParamsUri: - """Test that UnsubscribeRequestParams.uri accepts all valid MCP URIs.""" - - def test_relative_path_uri(self): - """Relative paths should be accepted in unsubscribe requests.""" - params = types.UnsubscribeRequestParams(uri="users/me") - assert str(params.uri) == "users/me" +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. +""" -class TestResourceUpdatedNotificationParamsUri: - """Test that ResourceUpdatedNotificationParams.uri accepts all valid MCP URIs.""" +import pytest - def test_relative_path_uri(self): - """Relative paths should be accepted in resource updated notifications.""" - params = types.ResourceUpdatedNotificationParams(uri="users/me") - assert str(params.uri) == "users/me" +from mcp import types +from mcp.server.lowlevel import Server +from mcp.server.lowlevel.helper_types import ReadResourceContents +from mcp.shared.memory import ( + create_connected_server_and_client_session as client_session, +) + +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. + """ + 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"), + ] + + @server.read_resource() + async def read_resource(uri: str): + return [ + ReadResourceContents( + content=f"data for {uri}", + mime_type="text/plain", + ) + ] + + async with client_session(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. + """ + 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"), + ] + + @server.read_resource() + async def read_resource(uri: str): + return [ReadResourceContents(content="data", mime_type="text/plain")] + + async with client_session(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", + mimeType="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}" From 3a2584e350bad4e0fb5fdb6033fa30e6edaf8bfd Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 16 Jan 2026 09:30:10 +0100 Subject: [PATCH 4/4] fix: update test assertions for str URI type With URI fields changed from AnyUrl to str, URIs are no longer normalized (e.g., trailing slashes not added). Update test assertions to match exact URIs being sent. --- tests/shared/test_streamable_http.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index a0c0e6b4cf..795bd9705e 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -21,7 +21,6 @@ import requests import uvicorn from httpx_sse import ServerSentEvent -from pydantic import AnyUrl from starlette.applications import Starlette from starlette.requests import Request from starlette.routing import Mount @@ -1013,7 +1012,7 @@ async def test_streamable_http_client_resource_read(initialized_client_session: """Test client resource read functionality.""" response = await initialized_client_session.read_resource(uri="foobar://test-resource") assert len(response.contents) == 1 - assert response.contents[0].uri == AnyUrl("foobar://test-resource") + assert response.contents[0].uri == "foobar://test-resource" assert isinstance(response.contents[0], TextResourceContents) assert response.contents[0].text == "Read test-resource" @@ -1132,7 +1131,7 @@ async def message_handler( # pragma: no branch resource_update_found = False for notif in notifications_received: if isinstance(notif.root, types.ResourceUpdatedNotification): # pragma: no branch - assert str(notif.root.params.uri) == "http://test_resource/" + assert str(notif.root.params.uri) == "http://test_resource" resource_update_found = True assert resource_update_found, "ResourceUpdatedNotification not received via GET stream" @@ -2237,10 +2236,10 @@ async def message_handler( assert result.content[0].text == "Standalone stream close test done" # Verify both notifications were received - assert "http://notification_1/" in received_notifications, ( + assert "http://notification_1" in received_notifications, ( f"Should receive notification 1 (sent before GET stream close), got: {received_notifications}" ) - assert "http://notification_2/" in received_notifications, ( + assert "http://notification_2" in received_notifications, ( f"Should receive notification 2 after reconnect, got: {received_notifications}" )