fix: change Resource URI fields from AnyUrl to str (#1863)
This commit is contained in:
@@ -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]
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
<!-- Add deprecations below -->
|
||||
|
||||
@@ -29,7 +29,7 @@ from mcp.types import (
|
||||
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}")
|
||||
|
||||
@@ -11,7 +11,6 @@ import anyio
|
||||
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_TOOLS = [
|
||||
|
||||
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 @@ def main(port: int, transport: str) -> int:
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -3,7 +3,6 @@ import click
|
||||
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 @@ def main(port: int, transport: str) -> int:
|
||||
]
|
||||
|
||||
@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}")
|
||||
|
||||
@@ -8,7 +8,6 @@ import click
|
||||
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 @@ def main(
|
||||
|
||||
# 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",
|
||||
|
||||
@@ -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]
|
||||
]
|
||||
|
||||
|
||||
@@ -279,24 +279,24 @@ class ClientSession(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,7 +11,7 @@ import anyio.to_thread
|
||||
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 @@ class FunctionResource(Resource):
|
||||
fn = validate_call(fn)
|
||||
|
||||
return cls(
|
||||
uri=AnyUrl(uri),
|
||||
uri=uri,
|
||||
name=func_name,
|
||||
title=title,
|
||||
description=description or fn.__doc__ or "",
|
||||
|
||||
@@ -79,7 +79,6 @@ from typing import Any, Generic, TypeAlias, cast
|
||||
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 @@ class Server(Generic[LifespanResultT, RequestT]):
|
||||
|
||||
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 @@ class Server(Generic[LifespanResultT, RequestT]):
|
||||
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 @@ class Server(Generic[LifespanResultT, RequestT]):
|
||||
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):
|
||||
|
||||
@@ -227,12 +227,12 @@ class ServerSession(
|
||||
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)),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
+6
-7
@@ -5,7 +5,6 @@ from datetime import datetime
|
||||
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.
|
||||
|
||||
@@ -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 test_lowlevel_resource_mime_type():
|
||||
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":
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""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 pytest
|
||||
|
||||
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}"
|
||||
@@ -13,7 +13,6 @@ import base64
|
||||
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 test_server_base64_encoding_issue():
|
||||
|
||||
# Create a request
|
||||
request = ReadResourceRequest(
|
||||
params=ReadResourceRequestParams(uri=AnyUrl("test://resource")),
|
||||
params=ReadResourceRequestParams(uri="test://resource"),
|
||||
)
|
||||
|
||||
# Call the handler to get the response
|
||||
|
||||
@@ -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 @@ class TestRenderPrompt:
|
||||
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 @@ class TestRenderPrompt:
|
||||
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 @@ class TestRenderPrompt:
|
||||
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 @@ class TestRenderPrompt:
|
||||
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 @@ class TestRenderPrompt:
|
||||
"content": {
|
||||
"type": "resource",
|
||||
"resource": {
|
||||
"uri": FileUrl("file://file.txt"),
|
||||
"uri": "file://file.txt",
|
||||
"text": "File contents",
|
||||
"mimeType": "text/plain",
|
||||
},
|
||||
@@ -175,7 +174,7 @@ class TestRenderPrompt:
|
||||
content=EmbeddedResource(
|
||||
type="resource",
|
||||
resource=TextResourceContents(
|
||||
uri=FileUrl("file://file.txt"),
|
||||
uri="file://file.txt",
|
||||
text="File contents",
|
||||
mimeType="text/plain",
|
||||
),
|
||||
|
||||
@@ -3,7 +3,6 @@ from pathlib import Path
|
||||
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 @@ class TestFileResource:
|
||||
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 @@ class TestFileResource:
|
||||
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 @@ class TestFileResource:
|
||||
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 @@ class TestFileResource:
|
||||
"""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 @@ class TestFileResource:
|
||||
# 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 @@ class TestFileResource:
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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 @@ class TestFunctionResource:
|
||||
return "test content"
|
||||
|
||||
resource = FunctionResource(
|
||||
uri=AnyUrl("fn://test"),
|
||||
uri="fn://test",
|
||||
name="test",
|
||||
description="test function",
|
||||
fn=my_func,
|
||||
@@ -33,7 +33,7 @@ class TestFunctionResource:
|
||||
return "Hello, world!"
|
||||
|
||||
resource = FunctionResource(
|
||||
uri=AnyUrl("function://test"),
|
||||
uri="function://test",
|
||||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
@@ -49,7 +49,7 @@ class TestFunctionResource:
|
||||
return b"Hello, world!"
|
||||
|
||||
resource = FunctionResource(
|
||||
uri=AnyUrl("function://test"),
|
||||
uri="function://test",
|
||||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
@@ -64,7 +64,7 @@ class TestFunctionResource:
|
||||
return {"key": "value"}
|
||||
|
||||
resource = FunctionResource(
|
||||
uri=AnyUrl("function://test"),
|
||||
uri="function://test",
|
||||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
@@ -80,7 +80,7 @@ class TestFunctionResource:
|
||||
raise ValueError("Test error")
|
||||
|
||||
resource = FunctionResource(
|
||||
uri=AnyUrl("function://test"),
|
||||
uri="function://test",
|
||||
name="test",
|
||||
fn=failing_func,
|
||||
)
|
||||
@@ -95,7 +95,7 @@ class TestFunctionResource:
|
||||
name: str
|
||||
|
||||
resource = FunctionResource(
|
||||
uri=AnyUrl("function://test"),
|
||||
uri="function://test",
|
||||
name="test",
|
||||
fn=lambda: MyModel(name="test"),
|
||||
)
|
||||
@@ -114,7 +114,7 @@ class TestFunctionResource:
|
||||
return CustomData()
|
||||
|
||||
resource = FunctionResource(
|
||||
uri=AnyUrl("function://test"),
|
||||
uri="function://test",
|
||||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
@@ -129,7 +129,7 @@ class TestFunctionResource:
|
||||
return "Hello, world!"
|
||||
|
||||
resource = FunctionResource(
|
||||
uri=AnyUrl("function://test"),
|
||||
uri="function://test",
|
||||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
@@ -154,7 +154,7 @@ class TestFunctionResource:
|
||||
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:
|
||||
|
||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
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 @@ class TestResourceManager:
|
||||
"""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 @@ class TestResourceManager:
|
||||
"""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 @@ class TestResourceManager:
|
||||
"""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 @@ class TestResourceManager:
|
||||
"""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 @@ class TestResourceManager:
|
||||
"""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 @@ class TestResourceManager:
|
||||
"""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,
|
||||
)
|
||||
|
||||
@@ -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 @@ from mcp.types import Annotations
|
||||
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 @@ class TestResourceValidation:
|
||||
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 @@ class TestResourceValidation:
|
||||
|
||||
# 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 @@ class TestResourceValidation:
|
||||
|
||||
# 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 TestResourceValidation:
|
||||
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 @@ class TestResourceMetadata:
|
||||
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 @@ class TestResourceMetadata:
|
||||
return "data"
|
||||
|
||||
resource = FunctionResource(
|
||||
uri=AnyUrl("resource://test"),
|
||||
uri="resource://test",
|
||||
name="test",
|
||||
fn=dummy_func,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any
|
||||
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 @@ class TestServerResources:
|
||||
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 @@ class TestServerResources:
|
||||
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 @@ class TestServerResources:
|
||||
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 @@ class TestServerResources:
|
||||
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 @@ class TestServerResources:
|
||||
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 @@ class TestServerResources:
|
||||
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 @@ class TestServerResources:
|
||||
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 @@ class TestServerResourceTemplates:
|
||||
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 @@ class TestServerResourceTemplates:
|
||||
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 @@ class TestServerResourceTemplates:
|
||||
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 @@ class TestServerResourceTemplates:
|
||||
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 @@ class TestServerResourceMetadata:
|
||||
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 @@ class TestContextInjection:
|
||||
|
||||
# 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 @@ class TestContextInjection:
|
||||
|
||||
# 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 @@ class TestContextInjection:
|
||||
|
||||
# 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 @@ class TestServerPrompts:
|
||||
content=EmbeddedResource(
|
||||
type="resource",
|
||||
resource=TextResourceContents(
|
||||
uri=AnyUrl("file://file.txt"),
|
||||
uri="file://file.txt",
|
||||
text="File contents",
|
||||
mimeType="text/plain",
|
||||
),
|
||||
|
||||
@@ -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 @@ async def test_resource_title():
|
||||
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 @@ async def test_resource_title():
|
||||
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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -3,7 +3,6 @@ from pathlib import Path
|
||||
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 test_read_resource_text(temp_file: Path):
|
||||
|
||||
# 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 test_read_resource_binary(temp_file: Path):
|
||||
|
||||
# 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 test_read_resource_default_mime(temp_file: Path):
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
+11
-10
@@ -5,6 +5,7 @@ import time
|
||||
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 pytest
|
||||
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 @@ class ServerTest(Server): # pragma: no cover
|
||||
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
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import time
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
@@ -20,7 +21,6 @@ import pytest
|
||||
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
|
||||
@@ -137,13 +137,14 @@ class ServerTest(Server): # pragma: no cover
|
||||
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 +215,7 @@ class ServerTest(Server): # pragma: no cover
|
||||
|
||||
# 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 +369,7 @@ class ServerTest(Server): # pragma: no cover
|
||||
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 +382,7 @@ class ServerTest(Server): # pragma: no cover
|
||||
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,9 +1010,9 @@ 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 response.contents[0].uri == "foobar://test-resource"
|
||||
assert isinstance(response.contents[0], TextResourceContents)
|
||||
assert response.contents[0].text == "Read test-resource"
|
||||
|
||||
@@ -1035,7 +1036,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 +1062,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)
|
||||
@@ -1130,7 +1131,7 @@ async def test_streamable_http_client_get_stream(basic_server: None, basic_serve
|
||||
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"
|
||||
@@ -2235,10 +2236,10 @@ async def test_standalone_get_stream_reconnection(
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
@@ -49,13 +49,16 @@ class ServerTest(Server): # pragma: no cover
|
||||
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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user