Fix #177: Returning multiple tool results (#222)

* 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:
David Soria Parra
2025-02-20 21:31:26 +00:00
committed by GitHub
parent 2628e01f4b
commit b1942b31c4
7 changed files with 62 additions and 32 deletions

View File

@@ -3,7 +3,7 @@
import inspect
import json
import re
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Iterable
from contextlib import (
AbstractAsyncContextManager,
asynccontextmanager,
@@ -236,7 +236,7 @@ class FastMCP:
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."""
resource = await self._resource_manager.get_resource(uri)
@@ -245,7 +245,7 @@ class FastMCP:
try:
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:
logger.error(f"Error reading resource {uri}: {e}")
raise ResourceError(str(e))
@@ -649,7 +649,7 @@ class Context(BaseModel):
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.
Args:

View File

@@ -67,9 +67,9 @@ messages from the client.
import contextvars
import logging
import warnings
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Iterable
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
from typing import Any, AsyncIterator, Generic, Sequence, TypeVar
from typing import Any, AsyncIterator, Generic, TypeVar
import anyio
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
@@ -279,7 +279,9 @@ class Server(Generic[LifespanResultT]):
def read_resource(self):
def decorator(
func: Callable[[AnyUrl], Awaitable[str | bytes | ReadResourceContents]],
func: Callable[
[AnyUrl], Awaitable[str | bytes | Iterable[ReadResourceContents]]
],
):
logger.debug("Registering handler for ReadResourceRequest")
@@ -307,13 +309,22 @@ class Server(Generic[LifespanResultT]):
case str() | bytes() as data:
warnings.warn(
"Returning str or bytes from read_resource is deprecated. "
"Use ReadResourceContents instead.",
"Use Iterable[ReadResourceContents] instead.",
DeprecationWarning,
stacklevel=2,
)
content = create_content(data, None)
case ReadResourceContents() as contents:
content = create_content(contents.content, contents.mime_type)
case Iterable() as contents:
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 _:
raise ValueError(
f"Unexpected return type from read_resource: {type(result)}"
@@ -387,7 +398,7 @@ class Server(Generic[LifespanResultT]):
func: Callable[
...,
Awaitable[
Sequence[
Iterable[
types.TextContent | types.ImageContent | types.EmbeddedResource
]
],

View File

@@ -51,8 +51,10 @@ async def test_resource_template_edge_cases():
# Verify valid template works
result = await mcp.read_resource("resource://users/123/posts/456")
assert result.content == "Post 456 by user 123"
assert result.mime_type == "text/plain"
result_list = list(result)
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
with pytest.raises(ValueError, match="Unknown resource"):

View File

@@ -99,11 +99,11 @@ async def test_lowlevel_resource_mime_type():
@server.read_resource()
async def handle_read_resource(uri: AnyUrl):
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":
return ReadResourceContents(
content=bytes(image_bytes), mime_type="image/png"
)
return [
ReadResourceContents(content=bytes(image_bytes), mime_type="image/png")
]
raise Exception(f"Resource not found: {uri}")
# Test that resources are listed with correct mime type

View File

@@ -88,7 +88,10 @@ async def test_list_resources(mcp: FastMCP):
@pytest.mark.anyio
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"
files = json.loads(res.content)
@@ -102,7 +105,10 @@ async def test_read_resource_dir(mcp: FastMCP):
@pytest.mark.anyio
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')"
@@ -119,5 +125,8 @@ async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path):
await mcp.call_tool(
"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"

View File

@@ -581,7 +581,10 @@ class TestContextInjection:
@mcp.tool()
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}"
async with client_session(mcp._mcp_server) as client:

View File

@@ -1,3 +1,4 @@
from collections.abc import Iterable
from pathlib import Path
from tempfile import NamedTemporaryFile
@@ -26,8 +27,8 @@ async def test_read_resource_text(temp_file: Path):
server = Server("test")
@server.read_resource()
async def read_resource(uri: AnyUrl) -> ReadResourceContents:
return ReadResourceContents(content="Hello World", mime_type="text/plain")
async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]:
return [ReadResourceContents(content="Hello World", mime_type="text/plain")]
# Get the handler directly from the server
handler = server.request_handlers[types.ReadResourceRequest]
@@ -54,10 +55,12 @@ async def test_read_resource_binary(temp_file: Path):
server = Server("test")
@server.read_resource()
async def read_resource(uri: AnyUrl) -> ReadResourceContents:
return ReadResourceContents(
async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]:
return [
ReadResourceContents(
content=b"Hello World", mime_type="application/octet-stream"
)
]
# Get the handler directly from the server
handler = server.request_handlers[types.ReadResourceRequest]
@@ -83,11 +86,13 @@ async def test_read_resource_default_mime(temp_file: Path):
server = Server("test")
@server.read_resource()
async def read_resource(uri: AnyUrl) -> ReadResourceContents:
return ReadResourceContents(
async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]:
return [
ReadResourceContents(
content="Hello World",
# No mime_type specified, should default to text/plain
)
]
# Get the handler directly from the server
handler = server.request_handlers[types.ReadResourceRequest]