Skip to content

Commit 070e841

Browse files
committed
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
1 parent f90cf6a commit 070e841

File tree

7 files changed

+155
-35
lines changed

7 files changed

+155
-35
lines changed

src/mcp/server/fastmcp/server.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from mcp.server.fastmcp.utilities.logging import configure_logging, get_logger
2121
from mcp.server.fastmcp.utilities.types import Image
2222
from mcp.server.lowlevel import Server as MCPServer
23+
from mcp.server.lowlevel.helper_types import ReadResourceContents
2324
from mcp.server.sse import SseServerTransport
2425
from mcp.server.stdio import stdio_server
2526
from mcp.shared.context import RequestContext
@@ -197,7 +198,7 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]:
197198
for template in templates
198199
]
199200

200-
async def read_resource(self, uri: AnyUrl | str) -> tuple[str | bytes, str]:
201+
async def read_resource(self, uri: AnyUrl | str) -> ReadResourceContents:
201202
"""Read a resource by URI."""
202203

203204
resource = await self._resource_manager.get_resource(uri)
@@ -206,7 +207,7 @@ async def read_resource(self, uri: AnyUrl | str) -> tuple[str | bytes, str]:
206207

207208
try:
208209
content = await resource.read()
209-
return (content, resource.mime_type)
210+
return ReadResourceContents(content=content, mime_type=resource.mime_type)
210211
except Exception as e:
211212
logger.error(f"Error reading resource {uri}: {e}")
212213
raise ResourceError(str(e))
@@ -608,7 +609,7 @@ async def report_progress(
608609
progress_token=progress_token, progress=progress, total=total
609610
)
610611

611-
async def read_resource(self, uri: str | AnyUrl) -> tuple[str | bytes, str]:
612+
async def read_resource(self, uri: str | AnyUrl) -> ReadResourceContents:
612613
"""Read a resource by URI.
613614
614615
Args:
+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from dataclasses import dataclass
2+
3+
4+
@dataclass
5+
class ReadResourceContents:
6+
"""Contents returned from a read_resource call."""
7+
8+
content: str | bytes
9+
mime_type: str | None = None

src/mcp/server/lowlevel/server.py

+19-21
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ async def main():
7474
from pydantic import AnyUrl
7575

7676
import mcp.types as types
77+
from mcp.server.lowlevel.helper_types import ReadResourceContents
7778
from mcp.server.models import InitializationOptions
7879
from mcp.server.session import ServerSession
7980
from mcp.server.stdio import stdio_server as stdio_server
@@ -253,55 +254,52 @@ async def handler(_: Any):
253254

254255
def read_resource(self):
255256
def decorator(
256-
func: Callable[[AnyUrl], Awaitable[str | bytes | tuple[str | bytes, str]]],
257+
func: Callable[[AnyUrl], Awaitable[str | bytes | ReadResourceContents]],
257258
):
258259
logger.debug("Registering handler for ReadResourceRequest")
259260

260261
async def handler(req: types.ReadResourceRequest):
261262
result = await func(req.params.uri)
262263

263-
def create_content(data: str | bytes, mime_type: str):
264+
def create_content(data: str | bytes, mime_type: str | None):
264265
match data:
265266
case str() as data:
266267
return types.TextResourceContents(
267268
uri=req.params.uri,
268269
text=data,
269-
mimeType=mime_type,
270+
mimeType=mime_type or "text/plain",
270271
)
271272
case bytes() as data:
272273
import base64
273274

274275
return types.BlobResourceContents(
275276
uri=req.params.uri,
276277
blob=base64.urlsafe_b64encode(data).decode(),
277-
mimeType=mime_type,
278+
mimeType=mime_type or "application/octet-stream",
278279
)
279280

280281
match result:
281282
case str() | bytes() as data:
282-
default_mime = (
283-
"text/plain"
284-
if isinstance(data, str)
285-
else "application/octet-stream"
286-
)
287-
content = create_content(data, default_mime)
288-
return types.ServerResult(
289-
types.ReadResourceResult(
290-
contents=[content],
291-
)
292-
)
293-
case (data, mime_type):
294-
content = create_content(data, mime_type)
295-
return types.ServerResult(
296-
types.ReadResourceResult(
297-
contents=[content],
298-
)
283+
warnings.warn(
284+
"Returning str or bytes from read_resource is deprecated. "
285+
"Use ReadResourceContents instead.",
286+
DeprecationWarning,
287+
stacklevel=2,
299288
)
289+
content = create_content(data, None)
290+
case ReadResourceContents() as contents:
291+
content = create_content(contents.content, contents.mime_type)
300292
case _:
301293
raise ValueError(
302294
f"Unexpected return type from read_resource: {type(result)}"
303295
)
304296

297+
return types.ServerResult(
298+
types.ReadResourceResult(
299+
contents=[content],
300+
)
301+
)
302+
305303
self.request_handlers[types.ReadResourceRequest] = handler
306304
return func
307305

tests/issues/test_152_resource_mime_type.py

+5-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from mcp import types
77
from mcp.server.fastmcp import FastMCP
88
from mcp.server.lowlevel import Server
9+
from mcp.server.lowlevel.helper_types import ReadResourceContents
910
from mcp.shared.memory import (
1011
create_connected_server_and_client_session as client_session,
1112
)
@@ -98,9 +99,11 @@ async def handle_list_resources():
9899
@server.read_resource()
99100
async def handle_read_resource(uri: AnyUrl):
100101
if str(uri) == "test://image":
101-
return (base64_string, "image/png")
102+
return ReadResourceContents(content=base64_string, mime_type="image/png")
102103
elif str(uri) == "test://image_bytes":
103-
return (bytes(image_bytes), "image/png")
104+
return ReadResourceContents(
105+
content=bytes(image_bytes), mime_type="image/png"
106+
)
104107
raise Exception(f"Resource not found: {uri}")
105108

106109
# Test that resources are listed with correct mime type

tests/server/fastmcp/servers/test_file_server.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,10 @@ async def test_list_resources(mcp: FastMCP):
8888

8989
@pytest.mark.anyio
9090
async def test_read_resource_dir(mcp: FastMCP):
91-
files, mime_type = await mcp.read_resource("dir://test_dir")
92-
assert mime_type == "text/plain"
91+
res = await mcp.read_resource("dir://test_dir")
92+
assert res.mime_type == "text/plain"
9393

94-
files = json.loads(files)
94+
files = json.loads(res.content)
9595

9696
assert sorted([Path(f).name for f in files]) == [
9797
"config.json",
@@ -102,8 +102,8 @@ async def test_read_resource_dir(mcp: FastMCP):
102102

103103
@pytest.mark.anyio
104104
async def test_read_resource_file(mcp: FastMCP):
105-
result, _ = await mcp.read_resource("file://test_dir/example.py")
106-
assert result == "print('hello world')"
105+
res = await mcp.read_resource("file://test_dir/example.py")
106+
assert res.content == "print('hello world')"
107107

108108

109109
@pytest.mark.anyio
@@ -119,5 +119,5 @@ async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path):
119119
await mcp.call_tool(
120120
"delete_file", arguments=dict(path=str(test_dir / "example.py"))
121121
)
122-
result, _ = await mcp.read_resource("file://test_dir/example.py")
123-
assert result == "File not found"
122+
res = await mcp.read_resource("file://test_dir/example.py")
123+
assert res.content == "File not found"

tests/server/fastmcp/test_server.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -581,8 +581,8 @@ def test_resource() -> str:
581581

582582
@mcp.tool()
583583
async def tool_with_resource(ctx: Context) -> str:
584-
data, mime_type = await ctx.read_resource("test://data")
585-
return f"Read resource: {data} with mime type {mime_type}"
584+
r = await ctx.read_resource("test://data")
585+
return f"Read resource: {r.content} with mime type {r.mime_type}"
586586

587587
async with client_session(mcp._mcp_server) as client:
588588
result = await client.call_tool("tool_with_resource", {})

tests/server/test_read_resource.py

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
from pathlib import Path
2+
from tempfile import NamedTemporaryFile
3+
4+
import pytest
5+
from pydantic import AnyUrl, FileUrl
6+
7+
import mcp.types as types
8+
from mcp.server.lowlevel.server import ReadResourceContents, Server
9+
10+
11+
@pytest.fixture
12+
def temp_file():
13+
"""Create a temporary file for testing."""
14+
with NamedTemporaryFile(mode="w", delete=False) as f:
15+
f.write("test content")
16+
path = Path(f.name).resolve()
17+
yield path
18+
try:
19+
path.unlink()
20+
except FileNotFoundError:
21+
pass
22+
23+
24+
@pytest.mark.anyio
25+
async def test_read_resource_text(temp_file: Path):
26+
server = Server("test")
27+
28+
@server.read_resource()
29+
async def read_resource(uri: AnyUrl) -> ReadResourceContents:
30+
return ReadResourceContents(content="Hello World", mime_type="text/plain")
31+
32+
# Get the handler directly from the server
33+
handler = server.request_handlers[types.ReadResourceRequest]
34+
35+
# Create a request
36+
request = types.ReadResourceRequest(
37+
method="resources/read",
38+
params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())),
39+
)
40+
41+
# Call the handler
42+
result = await handler(request)
43+
assert isinstance(result.root, types.ReadResourceResult)
44+
assert len(result.root.contents) == 1
45+
46+
content = result.root.contents[0]
47+
assert isinstance(content, types.TextResourceContents)
48+
assert content.text == "Hello World"
49+
assert content.mimeType == "text/plain"
50+
51+
52+
@pytest.mark.anyio
53+
async def test_read_resource_binary(temp_file: Path):
54+
server = Server("test")
55+
56+
@server.read_resource()
57+
async def read_resource(uri: AnyUrl) -> ReadResourceContents:
58+
return ReadResourceContents(
59+
content=b"Hello World", mime_type="application/octet-stream"
60+
)
61+
62+
# Get the handler directly from the server
63+
handler = server.request_handlers[types.ReadResourceRequest]
64+
65+
# Create a request
66+
request = types.ReadResourceRequest(
67+
method="resources/read",
68+
params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())),
69+
)
70+
71+
# Call the handler
72+
result = await handler(request)
73+
assert isinstance(result.root, types.ReadResourceResult)
74+
assert len(result.root.contents) == 1
75+
76+
content = result.root.contents[0]
77+
assert isinstance(content, types.BlobResourceContents)
78+
assert content.mimeType == "application/octet-stream"
79+
80+
81+
@pytest.mark.anyio
82+
async def test_read_resource_default_mime(temp_file: Path):
83+
server = Server("test")
84+
85+
@server.read_resource()
86+
async def read_resource(uri: AnyUrl) -> ReadResourceContents:
87+
return ReadResourceContents(
88+
content="Hello World",
89+
# No mime_type specified, should default to text/plain
90+
)
91+
92+
# Get the handler directly from the server
93+
handler = server.request_handlers[types.ReadResourceRequest]
94+
95+
# Create a request
96+
request = types.ReadResourceRequest(
97+
method="resources/read",
98+
params=types.ReadResourceRequestParams(uri=FileUrl(temp_file.as_uri())),
99+
)
100+
101+
# Call the handler
102+
result = await handler(request)
103+
assert isinstance(result.root, types.ReadResourceResult)
104+
assert len(result.root.contents) == 1
105+
106+
content = result.root.contents[0]
107+
assert isinstance(content, types.TextResourceContents)
108+
assert content.text == "Hello World"
109+
assert content.mimeType == "text/plain"

0 commit comments

Comments
 (0)