mirror of
https://github.com/aljazceru/mcp-python-sdk.git
synced 2025-12-19 14:54:24 +01:00
refactor: standardize resource response format
Introduce ReadResourceContents type to properly handle MIME types in resource responses. Breaking change in FastMCP read_resource() return type. Github-Issue:#152
This commit is contained in:
@@ -20,6 +20,7 @@ from mcp.server.fastmcp.tools import ToolManager
|
|||||||
from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger
|
from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger
|
||||||
from mcp.server.fastmcp.utilities.types import Image
|
from mcp.server.fastmcp.utilities.types import Image
|
||||||
from mcp.server.lowlevel import Server as MCPServer
|
from mcp.server.lowlevel import Server as MCPServer
|
||||||
|
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||||
from mcp.server.sse import SseServerTransport
|
from mcp.server.sse import SseServerTransport
|
||||||
from mcp.server.stdio import stdio_server
|
from mcp.server.stdio import stdio_server
|
||||||
from mcp.shared.context import RequestContext
|
from mcp.shared.context import RequestContext
|
||||||
@@ -197,7 +198,7 @@ class FastMCP:
|
|||||||
for template in templates
|
for template in templates
|
||||||
]
|
]
|
||||||
|
|
||||||
async def read_resource(self, uri: AnyUrl | str) -> tuple[str | bytes, str]:
|
async def read_resource(self, uri: AnyUrl | str) -> 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)
|
||||||
@@ -206,7 +207,7 @@ class FastMCP:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
content = await resource.read()
|
content = await resource.read()
|
||||||
return (content, 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))
|
||||||
@@ -608,7 +609,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) -> tuple[str | bytes, str]:
|
async def read_resource(self, uri: str | AnyUrl) -> ReadResourceContents:
|
||||||
"""Read a resource by URI.
|
"""Read a resource by URI.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
9
src/mcp/server/lowlevel/helper_types.py
Normal file
9
src/mcp/server/lowlevel/helper_types.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReadResourceContents:
|
||||||
|
"""Contents returned from a read_resource call."""
|
||||||
|
|
||||||
|
content: str | bytes
|
||||||
|
mime_type: str | None = None
|
||||||
@@ -74,6 +74,7 @@ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStre
|
|||||||
from pydantic import AnyUrl
|
from pydantic import AnyUrl
|
||||||
|
|
||||||
import mcp.types as types
|
import mcp.types as types
|
||||||
|
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||||
from mcp.server.models import InitializationOptions
|
from mcp.server.models import InitializationOptions
|
||||||
from mcp.server.session import ServerSession
|
from mcp.server.session import ServerSession
|
||||||
from mcp.server.stdio import stdio_server as stdio_server
|
from mcp.server.stdio import stdio_server as stdio_server
|
||||||
@@ -253,20 +254,20 @@ class Server:
|
|||||||
|
|
||||||
def read_resource(self):
|
def read_resource(self):
|
||||||
def decorator(
|
def decorator(
|
||||||
func: Callable[[AnyUrl], Awaitable[str | bytes | tuple[str | bytes, str]]],
|
func: Callable[[AnyUrl], Awaitable[str | bytes | ReadResourceContents]],
|
||||||
):
|
):
|
||||||
logger.debug("Registering handler for ReadResourceRequest")
|
logger.debug("Registering handler for ReadResourceRequest")
|
||||||
|
|
||||||
async def handler(req: types.ReadResourceRequest):
|
async def handler(req: types.ReadResourceRequest):
|
||||||
result = await func(req.params.uri)
|
result = await func(req.params.uri)
|
||||||
|
|
||||||
def create_content(data: str | bytes, mime_type: str):
|
def create_content(data: str | bytes, mime_type: str | None):
|
||||||
match data:
|
match data:
|
||||||
case str() as data:
|
case str() as data:
|
||||||
return types.TextResourceContents(
|
return types.TextResourceContents(
|
||||||
uri=req.params.uri,
|
uri=req.params.uri,
|
||||||
text=data,
|
text=data,
|
||||||
mimeType=mime_type,
|
mimeType=mime_type or "text/plain",
|
||||||
)
|
)
|
||||||
case bytes() as data:
|
case bytes() as data:
|
||||||
import base64
|
import base64
|
||||||
@@ -274,34 +275,31 @@ class Server:
|
|||||||
return types.BlobResourceContents(
|
return types.BlobResourceContents(
|
||||||
uri=req.params.uri,
|
uri=req.params.uri,
|
||||||
blob=base64.urlsafe_b64encode(data).decode(),
|
blob=base64.urlsafe_b64encode(data).decode(),
|
||||||
mimeType=mime_type,
|
mimeType=mime_type or "application/octet-stream",
|
||||||
)
|
)
|
||||||
|
|
||||||
match result:
|
match result:
|
||||||
case str() | bytes() as data:
|
case str() | bytes() as data:
|
||||||
default_mime = (
|
warnings.warn(
|
||||||
"text/plain"
|
"Returning str or bytes from read_resource is deprecated. "
|
||||||
if isinstance(data, str)
|
"Use ReadResourceContents instead.",
|
||||||
else "application/octet-stream"
|
DeprecationWarning,
|
||||||
)
|
stacklevel=2,
|
||||||
content = create_content(data, default_mime)
|
|
||||||
return types.ServerResult(
|
|
||||||
types.ReadResourceResult(
|
|
||||||
contents=[content],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
case (data, mime_type):
|
|
||||||
content = create_content(data, mime_type)
|
|
||||||
return types.ServerResult(
|
|
||||||
types.ReadResourceResult(
|
|
||||||
contents=[content],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
content = create_content(data, None)
|
||||||
|
case ReadResourceContents() as contents:
|
||||||
|
content = create_content(contents.content, contents.mime_type)
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unexpected return type from read_resource: {type(result)}"
|
f"Unexpected return type from read_resource: {type(result)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return types.ServerResult(
|
||||||
|
types.ReadResourceResult(
|
||||||
|
contents=[content],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
self.request_handlers[types.ReadResourceRequest] = handler
|
self.request_handlers[types.ReadResourceRequest] = handler
|
||||||
return func
|
return func
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from pydantic import AnyUrl
|
|||||||
from mcp import types
|
from mcp import types
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
from mcp.server.lowlevel import Server
|
from mcp.server.lowlevel import Server
|
||||||
|
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||||
from mcp.shared.memory import (
|
from mcp.shared.memory import (
|
||||||
create_connected_server_and_client_session as client_session,
|
create_connected_server_and_client_session as client_session,
|
||||||
)
|
)
|
||||||
@@ -98,9 +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 (base64_string, "image/png")
|
return ReadResourceContents(content=base64_string, mime_type="image/png")
|
||||||
elif str(uri) == "test://image_bytes":
|
elif str(uri) == "test://image_bytes":
|
||||||
return (bytes(image_bytes), "image/png")
|
return 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,10 +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):
|
||||||
files, mime_type = await mcp.read_resource("dir://test_dir")
|
res = await mcp.read_resource("dir://test_dir")
|
||||||
assert mime_type == "text/plain"
|
assert res.mime_type == "text/plain"
|
||||||
|
|
||||||
files = json.loads(files)
|
files = json.loads(res.content)
|
||||||
|
|
||||||
assert sorted([Path(f).name for f in files]) == [
|
assert sorted([Path(f).name for f in files]) == [
|
||||||
"config.json",
|
"config.json",
|
||||||
@@ -102,8 +102,8 @@ 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):
|
||||||
result, _ = await mcp.read_resource("file://test_dir/example.py")
|
res = await mcp.read_resource("file://test_dir/example.py")
|
||||||
assert result == "print('hello world')"
|
assert res.content == "print('hello world')"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -119,5 +119,5 @@ 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"))
|
||||||
)
|
)
|
||||||
result, _ = await mcp.read_resource("file://test_dir/example.py")
|
res = await mcp.read_resource("file://test_dir/example.py")
|
||||||
assert result == "File not found"
|
assert res.content == "File not found"
|
||||||
|
|||||||
@@ -581,8 +581,8 @@ class TestContextInjection:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def tool_with_resource(ctx: Context) -> str:
|
async def tool_with_resource(ctx: Context) -> str:
|
||||||
data, mime_type = await ctx.read_resource("test://data")
|
r = await ctx.read_resource("test://data")
|
||||||
return f"Read resource: {data} with mime type {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:
|
||||||
result = await client.call_tool("tool_with_resource", {})
|
result = await client.call_tool("tool_with_resource", {})
|
||||||
|
|||||||
109
tests/server/test_read_resource.py
Normal file
109
tests/server/test_read_resource.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_file():
|
||||||
|
"""Create a temporary file for testing."""
|
||||||
|
with NamedTemporaryFile(mode="w", delete=False) as f:
|
||||||
|
f.write("test content")
|
||||||
|
path = Path(f.name).resolve()
|
||||||
|
yield path
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Get the handler directly from the server
|
||||||
|
handler = server.request_handlers[types.ReadResourceRequest]
|
||||||
|
|
||||||
|
# Create a request
|
||||||
|
request = types.ReadResourceRequest(
|
||||||
|
method="resources/read",
|
||||||
|
params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call the handler
|
||||||
|
result = await handler(request)
|
||||||
|
assert isinstance(result.root, types.ReadResourceResult)
|
||||||
|
assert len(result.root.contents) == 1
|
||||||
|
|
||||||
|
content = result.root.contents[0]
|
||||||
|
assert isinstance(content, types.TextResourceContents)
|
||||||
|
assert content.text == "Hello World"
|
||||||
|
assert content.mimeType == "text/plain"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_read_resource_binary(temp_file: Path):
|
||||||
|
server = Server("test")
|
||||||
|
|
||||||
|
@server.read_resource()
|
||||||
|
async def read_resource(uri: AnyUrl) -> 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]
|
||||||
|
|
||||||
|
# Create a request
|
||||||
|
request = types.ReadResourceRequest(
|
||||||
|
method="resources/read",
|
||||||
|
params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call the handler
|
||||||
|
result = await handler(request)
|
||||||
|
assert isinstance(result.root, types.ReadResourceResult)
|
||||||
|
assert len(result.root.contents) == 1
|
||||||
|
|
||||||
|
content = result.root.contents[0]
|
||||||
|
assert isinstance(content, types.BlobResourceContents)
|
||||||
|
assert content.mimeType == "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
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(
|
||||||
|
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]
|
||||||
|
|
||||||
|
# Create a request
|
||||||
|
request = types.ReadResourceRequest(
|
||||||
|
method="resources/read",
|
||||||
|
params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call the handler
|
||||||
|
result = await handler(request)
|
||||||
|
assert isinstance(result.root, types.ReadResourceResult)
|
||||||
|
assert len(result.root.contents) == 1
|
||||||
|
|
||||||
|
content = result.root.contents[0]
|
||||||
|
assert isinstance(content, types.TextResourceContents)
|
||||||
|
assert content.text == "Hello World"
|
||||||
|
assert content.mimeType == "text/plain"
|
||||||
Reference in New Issue
Block a user