mirror of
https://github.com/aljazceru/mcp-python-sdk.git
synced 2025-12-19 14:54:24 +01:00
* feat: allow lowlevel servers to return a list of resources The resource/read message in MCP allows of multiple resources to be returned. However, in the SDK we do not allow this. This change is such that we allow returning multiple resource in the lowlevel API if needed. However in FastMCP we stick to one, since a FastMCP resource defines the mime_type in the decorator and hence a resource cannot dynamically return different mime_typed resources. It also is just the better default to only return one resource. However in the lowlevel API we will allow this. Strictly speaking this is not a BC break since the new return value is additive, but if people subclassed server, it will break them. * feat: lower the type requriements for call_tool to Iterable
This commit is contained in:
committed by
GitHub
parent
2628e01f4b
commit
b1942b31c4
@@ -3,7 +3,7 @@
|
|||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator, Iterable
|
||||||
from contextlib import (
|
from contextlib import (
|
||||||
AbstractAsyncContextManager,
|
AbstractAsyncContextManager,
|
||||||
asynccontextmanager,
|
asynccontextmanager,
|
||||||
@@ -236,7 +236,7 @@ class FastMCP:
|
|||||||
for template in templates
|
for template in templates
|
||||||
]
|
]
|
||||||
|
|
||||||
async def read_resource(self, uri: AnyUrl | str) -> ReadResourceContents:
|
async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContents]:
|
||||||
"""Read a resource by URI."""
|
"""Read a resource by URI."""
|
||||||
|
|
||||||
resource = await self._resource_manager.get_resource(uri)
|
resource = await self._resource_manager.get_resource(uri)
|
||||||
@@ -245,7 +245,7 @@ class FastMCP:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
content = await resource.read()
|
content = await resource.read()
|
||||||
return ReadResourceContents(content=content, mime_type=resource.mime_type)
|
return [ReadResourceContents(content=content, mime_type=resource.mime_type)]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error reading resource {uri}: {e}")
|
logger.error(f"Error reading resource {uri}: {e}")
|
||||||
raise ResourceError(str(e))
|
raise ResourceError(str(e))
|
||||||
@@ -649,7 +649,7 @@ class Context(BaseModel):
|
|||||||
progress_token=progress_token, progress=progress, total=total
|
progress_token=progress_token, progress=progress, total=total
|
||||||
)
|
)
|
||||||
|
|
||||||
async def read_resource(self, uri: str | AnyUrl) -> ReadResourceContents:
|
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
|
||||||
"""Read a resource by URI.
|
"""Read a resource by URI.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ messages from the client.
|
|||||||
import contextvars
|
import contextvars
|
||||||
import logging
|
import logging
|
||||||
import warnings
|
import warnings
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable, Iterable
|
||||||
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
|
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
|
||||||
from typing import Any, AsyncIterator, Generic, Sequence, TypeVar
|
from typing import Any, AsyncIterator, Generic, TypeVar
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||||
@@ -279,7 +279,9 @@ class Server(Generic[LifespanResultT]):
|
|||||||
|
|
||||||
def read_resource(self):
|
def read_resource(self):
|
||||||
def decorator(
|
def decorator(
|
||||||
func: Callable[[AnyUrl], Awaitable[str | bytes | ReadResourceContents]],
|
func: Callable[
|
||||||
|
[AnyUrl], Awaitable[str | bytes | Iterable[ReadResourceContents]]
|
||||||
|
],
|
||||||
):
|
):
|
||||||
logger.debug("Registering handler for ReadResourceRequest")
|
logger.debug("Registering handler for ReadResourceRequest")
|
||||||
|
|
||||||
@@ -307,13 +309,22 @@ class Server(Generic[LifespanResultT]):
|
|||||||
case str() | bytes() as data:
|
case str() | bytes() as data:
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
"Returning str or bytes from read_resource is deprecated. "
|
"Returning str or bytes from read_resource is deprecated. "
|
||||||
"Use ReadResourceContents instead.",
|
"Use Iterable[ReadResourceContents] instead.",
|
||||||
DeprecationWarning,
|
DeprecationWarning,
|
||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
content = create_content(data, None)
|
content = create_content(data, None)
|
||||||
case ReadResourceContents() as contents:
|
case Iterable() as contents:
|
||||||
content = create_content(contents.content, contents.mime_type)
|
contents_list = [
|
||||||
|
create_content(content_item.content, content_item.mime_type)
|
||||||
|
for content_item in contents
|
||||||
|
if isinstance(content_item, ReadResourceContents)
|
||||||
|
]
|
||||||
|
return types.ServerResult(
|
||||||
|
types.ReadResourceResult(
|
||||||
|
contents=contents_list,
|
||||||
|
)
|
||||||
|
)
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unexpected return type from read_resource: {type(result)}"
|
f"Unexpected return type from read_resource: {type(result)}"
|
||||||
@@ -387,7 +398,7 @@ class Server(Generic[LifespanResultT]):
|
|||||||
func: Callable[
|
func: Callable[
|
||||||
...,
|
...,
|
||||||
Awaitable[
|
Awaitable[
|
||||||
Sequence[
|
Iterable[
|
||||||
types.TextContent | types.ImageContent | types.EmbeddedResource
|
types.TextContent | types.ImageContent | types.EmbeddedResource
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -51,8 +51,10 @@ async def test_resource_template_edge_cases():
|
|||||||
|
|
||||||
# Verify valid template works
|
# Verify valid template works
|
||||||
result = await mcp.read_resource("resource://users/123/posts/456")
|
result = await mcp.read_resource("resource://users/123/posts/456")
|
||||||
assert result.content == "Post 456 by user 123"
|
result_list = list(result)
|
||||||
assert result.mime_type == "text/plain"
|
assert len(result_list) == 1
|
||||||
|
assert result_list[0].content == "Post 456 by user 123"
|
||||||
|
assert result_list[0].mime_type == "text/plain"
|
||||||
|
|
||||||
# Verify invalid parameters raise error
|
# Verify invalid parameters raise error
|
||||||
with pytest.raises(ValueError, match="Unknown resource"):
|
with pytest.raises(ValueError, match="Unknown resource"):
|
||||||
|
|||||||
@@ -99,11 +99,11 @@ async def test_lowlevel_resource_mime_type():
|
|||||||
@server.read_resource()
|
@server.read_resource()
|
||||||
async def handle_read_resource(uri: AnyUrl):
|
async def handle_read_resource(uri: AnyUrl):
|
||||||
if str(uri) == "test://image":
|
if str(uri) == "test://image":
|
||||||
return ReadResourceContents(content=base64_string, mime_type="image/png")
|
return [ReadResourceContents(content=base64_string, mime_type="image/png")]
|
||||||
elif str(uri) == "test://image_bytes":
|
elif str(uri) == "test://image_bytes":
|
||||||
return ReadResourceContents(
|
return [
|
||||||
content=bytes(image_bytes), mime_type="image/png"
|
ReadResourceContents(content=bytes(image_bytes), mime_type="image/png")
|
||||||
)
|
]
|
||||||
raise Exception(f"Resource not found: {uri}")
|
raise Exception(f"Resource not found: {uri}")
|
||||||
|
|
||||||
# Test that resources are listed with correct mime type
|
# Test that resources are listed with correct mime type
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ async def test_list_resources(mcp: FastMCP):
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_read_resource_dir(mcp: FastMCP):
|
async def test_read_resource_dir(mcp: FastMCP):
|
||||||
res = await mcp.read_resource("dir://test_dir")
|
res_iter = await mcp.read_resource("dir://test_dir")
|
||||||
|
res_list = list(res_iter)
|
||||||
|
assert len(res_list) == 1
|
||||||
|
res = res_list[0]
|
||||||
assert res.mime_type == "text/plain"
|
assert res.mime_type == "text/plain"
|
||||||
|
|
||||||
files = json.loads(res.content)
|
files = json.loads(res.content)
|
||||||
@@ -102,7 +105,10 @@ async def test_read_resource_dir(mcp: FastMCP):
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_read_resource_file(mcp: FastMCP):
|
async def test_read_resource_file(mcp: FastMCP):
|
||||||
res = await mcp.read_resource("file://test_dir/example.py")
|
res_iter = await mcp.read_resource("file://test_dir/example.py")
|
||||||
|
res_list = list(res_iter)
|
||||||
|
assert len(res_list) == 1
|
||||||
|
res = res_list[0]
|
||||||
assert res.content == "print('hello world')"
|
assert res.content == "print('hello world')"
|
||||||
|
|
||||||
|
|
||||||
@@ -119,5 +125,8 @@ async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path):
|
|||||||
await mcp.call_tool(
|
await mcp.call_tool(
|
||||||
"delete_file", arguments=dict(path=str(test_dir / "example.py"))
|
"delete_file", arguments=dict(path=str(test_dir / "example.py"))
|
||||||
)
|
)
|
||||||
res = await mcp.read_resource("file://test_dir/example.py")
|
res_iter = await mcp.read_resource("file://test_dir/example.py")
|
||||||
|
res_list = list(res_iter)
|
||||||
|
assert len(res_list) == 1
|
||||||
|
res = res_list[0]
|
||||||
assert res.content == "File not found"
|
assert res.content == "File not found"
|
||||||
|
|||||||
@@ -581,7 +581,10 @@ class TestContextInjection:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def tool_with_resource(ctx: Context) -> str:
|
async def tool_with_resource(ctx: Context) -> str:
|
||||||
r = await ctx.read_resource("test://data")
|
r_iter = await ctx.read_resource("test://data")
|
||||||
|
r_list = list(r_iter)
|
||||||
|
assert len(r_list) == 1
|
||||||
|
r = r_list[0]
|
||||||
return f"Read resource: {r.content} with mime type {r.mime_type}"
|
return f"Read resource: {r.content} with mime type {r.mime_type}"
|
||||||
|
|
||||||
async with client_session(mcp._mcp_server) as client:
|
async with client_session(mcp._mcp_server) as client:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from collections.abc import Iterable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
@@ -26,8 +27,8 @@ async def test_read_resource_text(temp_file: Path):
|
|||||||
server = Server("test")
|
server = Server("test")
|
||||||
|
|
||||||
@server.read_resource()
|
@server.read_resource()
|
||||||
async def read_resource(uri: AnyUrl) -> ReadResourceContents:
|
async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]:
|
||||||
return ReadResourceContents(content="Hello World", mime_type="text/plain")
|
return [ReadResourceContents(content="Hello World", mime_type="text/plain")]
|
||||||
|
|
||||||
# Get the handler directly from the server
|
# Get the handler directly from the server
|
||||||
handler = server.request_handlers[types.ReadResourceRequest]
|
handler = server.request_handlers[types.ReadResourceRequest]
|
||||||
@@ -54,10 +55,12 @@ async def test_read_resource_binary(temp_file: Path):
|
|||||||
server = Server("test")
|
server = Server("test")
|
||||||
|
|
||||||
@server.read_resource()
|
@server.read_resource()
|
||||||
async def read_resource(uri: AnyUrl) -> ReadResourceContents:
|
async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]:
|
||||||
return ReadResourceContents(
|
return [
|
||||||
|
ReadResourceContents(
|
||||||
content=b"Hello World", mime_type="application/octet-stream"
|
content=b"Hello World", mime_type="application/octet-stream"
|
||||||
)
|
)
|
||||||
|
]
|
||||||
|
|
||||||
# Get the handler directly from the server
|
# Get the handler directly from the server
|
||||||
handler = server.request_handlers[types.ReadResourceRequest]
|
handler = server.request_handlers[types.ReadResourceRequest]
|
||||||
@@ -83,11 +86,13 @@ async def test_read_resource_default_mime(temp_file: Path):
|
|||||||
server = Server("test")
|
server = Server("test")
|
||||||
|
|
||||||
@server.read_resource()
|
@server.read_resource()
|
||||||
async def read_resource(uri: AnyUrl) -> ReadResourceContents:
|
async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]:
|
||||||
return ReadResourceContents(
|
return [
|
||||||
|
ReadResourceContents(
|
||||||
content="Hello World",
|
content="Hello World",
|
||||||
# No mime_type specified, should default to text/plain
|
# No mime_type specified, should default to text/plain
|
||||||
)
|
)
|
||||||
|
]
|
||||||
|
|
||||||
# Get the handler directly from the server
|
# Get the handler directly from the server
|
||||||
handler = server.request_handlers[types.ReadResourceRequest]
|
handler = server.request_handlers[types.ReadResourceRequest]
|
||||||
|
|||||||
Reference in New Issue
Block a user