Skip to content

Commit 910c308

Browse files
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
1 parent c022cfe commit 910c308

File tree

26 files changed

+203
-166
lines changed

26 files changed

+203
-166
lines changed

docs/migration.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,48 @@ async with http_client:
5252

5353
The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx.AsyncClient` instead (see example above).
5454

55+
### Resource URI type changed from `AnyUrl` to `str`
56+
57+
The `uri` field on resource-related types now uses `str` instead of Pydantic's `AnyUrl`. This aligns with the MCP specification which defines URIs as plain strings without validation. This change allows relative paths like `users/me` that were previously rejected.
58+
59+
**Before (v1):**
60+
61+
```python
62+
from pydantic import AnyUrl
63+
from mcp.types import Resource
64+
65+
# Required wrapping in AnyUrl
66+
resource = Resource(name="test", uri=AnyUrl("users/me")) # Would fail validation
67+
```
68+
69+
**After (v2):**
70+
71+
```python
72+
from mcp.types import Resource
73+
74+
# Plain strings accepted
75+
resource = Resource(name="test", uri="users/me") # Works
76+
resource = Resource(name="test", uri="custom://scheme") # Works
77+
resource = Resource(name="test", uri="https://example.com") # Works
78+
```
79+
80+
If your code passes `AnyUrl` objects to URI fields, convert them to strings:
81+
82+
```python
83+
# If you have an AnyUrl from elsewhere
84+
uri = str(my_any_url) # Convert to string
85+
```
86+
87+
Affected types:
88+
- `Resource.uri`
89+
- `ReadResourceRequestParams.uri`
90+
- `ResourceContents.uri` (and subclasses `TextResourceContents`, `BlobResourceContents`)
91+
- `SubscribeRequestParams.uri`
92+
- `UnsubscribeRequestParams.uri`
93+
- `ResourceUpdatedNotificationParams.uri`
94+
95+
The `ClientSession.read_resource()`, `subscribe_resource()`, and `unsubscribe_resource()` methods now accept both `str` and `AnyUrl` for backwards compatibility.
96+
5597
## Deprecations
5698

5799
<!-- Add deprecations below -->

examples/servers/everything-server/mcp_everything_server/server.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
TextContent,
3030
TextResourceContents,
3131
)
32-
from pydantic import AnyUrl, BaseModel, Field
32+
from pydantic import BaseModel, Field
3333

3434
logger = logging.getLogger(__name__)
3535

@@ -114,7 +114,7 @@ def test_embedded_resource() -> list[EmbeddedResource]:
114114
EmbeddedResource(
115115
type="resource",
116116
resource=TextResourceContents(
117-
uri=AnyUrl("test://embedded-resource"),
117+
uri="test://embedded-resource",
118118
mimeType="text/plain",
119119
text="This is an embedded resource content.",
120120
),
@@ -131,7 +131,7 @@ def test_multiple_content_types() -> list[TextContent | ImageContent | EmbeddedR
131131
EmbeddedResource(
132132
type="resource",
133133
resource=TextResourceContents(
134-
uri=AnyUrl("test://mixed-content-resource"),
134+
uri="test://mixed-content-resource",
135135
mimeType="application/json",
136136
text='{"test": "data", "value": 123}',
137137
),
@@ -372,7 +372,7 @@ def test_prompt_with_embedded_resource(resourceUri: str) -> list[UserMessage]:
372372
content=EmbeddedResource(
373373
type="resource",
374374
resource=TextResourceContents(
375-
uri=AnyUrl(resourceUri),
375+
uri=resourceUri,
376376
mimeType="text/plain",
377377
text="Embedded resource content for testing.",
378378
),
@@ -402,13 +402,13 @@ async def handle_set_logging_level(level: str) -> None:
402402
# For conformance testing, we just acknowledge the request
403403

404404

405-
async def handle_subscribe(uri: AnyUrl) -> None:
405+
async def handle_subscribe(uri: str) -> None:
406406
"""Handle resource subscription"""
407407
resource_subscriptions.add(str(uri))
408408
logger.info(f"Subscribed to resource: {uri}")
409409

410410

411-
async def handle_unsubscribe(uri: AnyUrl) -> None:
411+
async def handle_unsubscribe(uri: str) -> None:
412412
"""Handle resource unsubscription"""
413413
resource_subscriptions.discard(str(uri))
414414
logger.info(f"Unsubscribed from resource: {uri}")

examples/servers/simple-pagination/mcp_simple_pagination/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentB
160160

161161
# Implement read_resource handler
162162
@app.read_resource()
163-
async def read_resource(uri: AnyUrl) -> str:
163+
async def read_resource(uri: str) -> str:
164164
# Find the resource in our sample data
165165
resource = next((r for r in SAMPLE_RESOURCES if r.uri == uri), None)
166166
if not resource:

examples/servers/simple-resource/mcp_simple_resource/server.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import mcp.types as types
44
from mcp.server.lowlevel import Server
55
from mcp.server.lowlevel.helper_types import ReadResourceContents
6-
from pydantic import AnyUrl, FileUrl
76
from starlette.requests import Request
87

98
SAMPLE_RESOURCES = {
@@ -37,7 +36,7 @@ def main(port: int, transport: str) -> int:
3736
async def list_resources() -> list[types.Resource]:
3837
return [
3938
types.Resource(
40-
uri=FileUrl(f"file:///{name}.txt"),
39+
uri=f"file:///{name}.txt",
4140
name=name,
4241
title=SAMPLE_RESOURCES[name]["title"],
4342
description=f"A sample text resource named {name}",
@@ -47,10 +46,13 @@ async def list_resources() -> list[types.Resource]:
4746
]
4847

4948
@app.read_resource()
50-
async def read_resource(uri: AnyUrl):
51-
if uri.path is None:
49+
async def read_resource(uri: str):
50+
from urllib.parse import urlparse
51+
52+
parsed = urlparse(uri)
53+
if parsed.path is None:
5254
raise ValueError(f"Invalid resource path: {uri}")
53-
name = uri.path.replace(".txt", "").lstrip("/")
55+
name = parsed.path.replace(".txt", "").lstrip("/")
5456

5557
if name not in SAMPLE_RESOURCES:
5658
raise ValueError(f"Unknown resource: {uri}")

examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import mcp.types as types
99
from mcp.server.lowlevel import Server
1010
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
11-
from pydantic import AnyUrl
1211
from starlette.applications import Starlette
1312
from starlette.middleware.cors import CORSMiddleware
1413
from starlette.routing import Mount
@@ -74,7 +73,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentB
7473

7574
# This will send a resource notificaiton though standalone SSE
7675
# established by GET request
77-
await ctx.session.send_resource_updated(uri=AnyUrl("http:///test_resource"))
76+
await ctx.session.send_resource_updated(uri="http:///test_resource")
7877
return [
7978
types.TextContent(
8079
type="text",

src/mcp/client/session.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -332,34 +332,34 @@ async def list_resource_templates(
332332
types.ListResourceTemplatesResult,
333333
)
334334

335-
async def read_resource(self, uri: AnyUrl) -> types.ReadResourceResult:
335+
async def read_resource(self, uri: str | AnyUrl) -> types.ReadResourceResult:
336336
"""Send a resources/read request."""
337337
return await self.send_request(
338338
types.ClientRequest(
339339
types.ReadResourceRequest(
340-
params=types.ReadResourceRequestParams(uri=uri),
340+
params=types.ReadResourceRequestParams(uri=str(uri)),
341341
)
342342
),
343343
types.ReadResourceResult,
344344
)
345345

346-
async def subscribe_resource(self, uri: AnyUrl) -> types.EmptyResult:
346+
async def subscribe_resource(self, uri: str | AnyUrl) -> types.EmptyResult:
347347
"""Send a resources/subscribe request."""
348348
return await self.send_request( # pragma: no cover
349349
types.ClientRequest(
350350
types.SubscribeRequest(
351-
params=types.SubscribeRequestParams(uri=uri),
351+
params=types.SubscribeRequestParams(uri=str(uri)),
352352
)
353353
),
354354
types.EmptyResult,
355355
)
356356

357-
async def unsubscribe_resource(self, uri: AnyUrl) -> types.EmptyResult:
357+
async def unsubscribe_resource(self, uri: str | AnyUrl) -> types.EmptyResult:
358358
"""Send a resources/unsubscribe request."""
359359
return await self.send_request( # pragma: no cover
360360
types.ClientRequest(
361361
types.UnsubscribeRequest(
362-
params=types.UnsubscribeRequestParams(uri=uri),
362+
params=types.UnsubscribeRequestParams(uri=str(uri)),
363363
)
364364
),
365365
types.EmptyResult,

src/mcp/server/fastmcp/resources/base.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
"""Base classes and interfaces for FastMCP resources."""
22

33
import abc
4-
from typing import Annotated, Any
4+
from typing import Any
55

66
from pydantic import (
7-
AnyUrl,
87
BaseModel,
98
ConfigDict,
109
Field,
11-
UrlConstraints,
1210
ValidationInfo,
1311
field_validator,
1412
)
@@ -21,7 +19,7 @@ class Resource(BaseModel, abc.ABC):
2119

2220
model_config = ConfigDict(validate_default=True)
2321

24-
uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field(default=..., description="URI of the resource")
22+
uri: str = Field(default=..., description="URI of the resource")
2523
name: str | None = Field(description="Name of the resource", default=None)
2624
title: str | None = Field(description="Human-readable title of the resource", default=None)
2725
description: str | None = Field(description="Description of the resource", default=None)

src/mcp/server/fastmcp/resources/types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import httpx
1212
import pydantic
1313
import pydantic_core
14-
from pydantic import AnyUrl, Field, ValidationInfo, validate_call
14+
from pydantic import Field, ValidationInfo, validate_call
1515

1616
from mcp.server.fastmcp.resources.base import Resource
1717
from mcp.types import Annotations, Icon
@@ -94,7 +94,7 @@ def from_function(
9494
fn = validate_call(fn)
9595

9696
return cls(
97-
uri=AnyUrl(uri),
97+
uri=uri,
9898
name=func_name,
9999
title=title,
100100
description=description or fn.__doc__ or "",

src/mcp/server/lowlevel/server.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ async def main():
7979
import anyio
8080
import jsonschema
8181
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
82-
from pydantic import AnyUrl
8382
from typing_extensions import TypeVar
8483

8584
import mcp.types as types
@@ -337,7 +336,7 @@ async def handler(_: Any):
337336

338337
def read_resource(self):
339338
def decorator(
340-
func: Callable[[AnyUrl], Awaitable[str | bytes | Iterable[ReadResourceContents]]],
339+
func: Callable[[str], Awaitable[str | bytes | Iterable[ReadResourceContents]]],
341340
):
342341
logger.debug("Registering handler for ReadResourceRequest")
343342

@@ -412,7 +411,7 @@ async def handler(req: types.SetLevelRequest):
412411
return decorator
413412

414413
def subscribe_resource(self): # pragma: no cover
415-
def decorator(func: Callable[[AnyUrl], Awaitable[None]]):
414+
def decorator(func: Callable[[str], Awaitable[None]]):
416415
logger.debug("Registering handler for SubscribeRequest")
417416

418417
async def handler(req: types.SubscribeRequest):
@@ -425,7 +424,7 @@ async def handler(req: types.SubscribeRequest):
425424
return decorator
426425

427426
def unsubscribe_resource(self): # pragma: no cover
428-
def decorator(func: Callable[[AnyUrl], Awaitable[None]]):
427+
def decorator(func: Callable[[str], Awaitable[None]]):
429428
logger.debug("Registering handler for UnsubscribeRequest")
430429

431430
async def handler(req: types.UnsubscribeRequest):

src/mcp/server/session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,12 +227,12 @@ async def send_log_message(
227227
related_request_id,
228228
)
229229

230-
async def send_resource_updated(self, uri: AnyUrl) -> None: # pragma: no cover
230+
async def send_resource_updated(self, uri: str | AnyUrl) -> None: # pragma: no cover
231231
"""Send a resource updated notification."""
232232
await self.send_notification(
233233
types.ServerNotification(
234234
types.ResourceUpdatedNotification(
235-
params=types.ResourceUpdatedNotificationParams(uri=uri),
235+
params=types.ResourceUpdatedNotificationParams(uri=str(uri)),
236236
)
237237
)
238238
)

0 commit comments

Comments
 (0)