Skip to content

Commit 44c0004

Browse files
authored
Merge pull request #170 from modelcontextprotocol/fix/152-resource-mime-type
fix: respect resource mime type in responses
2 parents 978cfe3 + 070e841 commit 44c0004

File tree

9 files changed

+342
-37
lines changed

9 files changed

+342
-37
lines changed

CLAUDE.md

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,31 @@ This document contains critical information about working with this codebase. Fo
2525
- New features require tests
2626
- Bug fixes require regression tests
2727

28-
4. Version Control
29-
- Commit messages: conventional format (fix:, feat:)
30-
- PR scope: minimal, focused changes
31-
- PR requirements: description, test plan
32-
- Always include issue numbers
33-
- Quote handling:
34-
```bash
35-
git commit -am "\"fix: message\""
36-
gh pr create --title "\"title\"" --body "\"body\""
37-
```
28+
- For commits fixing bugs or adding features based on user reports add:
29+
```bash
30+
git commit --trailer "Reported-by:<name>"
31+
```
32+
Where `<name>` is the name of the user.
33+
34+
- For commits related to a Github issue, add
35+
```bash
36+
git commit --trailer "Github-Issue:#<number>"
37+
```
38+
- NEVER ever mention a `co-authored-by` or similar aspects. In particular, never
39+
mention the tool used to create the commit message or PR.
40+
41+
## Pull Requests
42+
43+
- Create a detailed message of what changed. Focus on the high level description of
44+
the problem it tries to solve, and how it is solved. Don't go into the specifics of the
45+
code unless it adds clarity.
46+
47+
- Always add `jerome3o-anthropic` and `jspahrsummers` as reviewer.
48+
49+
- NEVER ever mention a `co-authored-by` or similar aspects. In particular, never
50+
mention the tool used to create the commit message or PR.
51+
52+
## Python Tools
3853

3954
## Code Formatting
4055

@@ -96,4 +111,4 @@ This document contains critical information about working with this codebase. Fo
96111
- Keep changes minimal
97112
- Follow existing patterns
98113
- Document public APIs
99-
- Test thoroughly
114+
- Test thoroughly

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ async def long_task(files: list[str], ctx: Context) -> str:
218218
for i, file in enumerate(files):
219219
ctx.info(f"Processing {file}")
220220
await ctx.report_progress(i, len(files))
221-
data = await ctx.read_resource(f"file://{file}")
221+
data, mime_type = await ctx.read_resource(f"file://{file}")
222222
return "Processing complete"
223223
```
224224

@@ -436,7 +436,7 @@ async def run():
436436
tools = await session.list_tools()
437437

438438
# Read a resource
439-
resource = await session.read_resource("file://some/path")
439+
content, mime_type = await session.read_resource("file://some/path")
440440

441441
# Call a tool
442442
result = await session.call_tool("tool-name", arguments={"arg1": "value"})

src/mcp/server/fastmcp/server.py

Lines changed: 6 additions & 3 deletions
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,14 +198,16 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]:
197198
for template in templates
198199
]
199200

200-
async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
201+
async def read_resource(self, uri: AnyUrl | str) -> ReadResourceContents:
201202
"""Read a resource by URI."""
203+
202204
resource = await self._resource_manager.get_resource(uri)
203205
if not resource:
204206
raise ResourceError(f"Unknown resource: {uri}")
205207

206208
try:
207-
return await resource.read()
209+
content = await resource.read()
210+
return ReadResourceContents(content=content, mime_type=resource.mime_type)
208211
except Exception as e:
209212
logger.error(f"Error reading resource {uri}: {e}")
210213
raise ResourceError(str(e))
@@ -606,7 +609,7 @@ async def report_progress(
606609
progress_token=progress_token, progress=progress, total=total
607610
)
608611

609-
async def read_resource(self, uri: str | AnyUrl) -> str | bytes:
612+
async def read_resource(self, uri: str | AnyUrl) -> ReadResourceContents:
610613
"""Read a resource by URI.
611614
612615
Args:
Lines changed: 9 additions & 0 deletions
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

Lines changed: 34 additions & 13 deletions
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
@@ -252,25 +253,45 @@ async def handler(_: Any):
252253
return decorator
253254

254255
def read_resource(self):
255-
def decorator(func: Callable[[AnyUrl], Awaitable[str | bytes]]):
256+
def decorator(
257+
func: Callable[[AnyUrl], Awaitable[str | bytes | ReadResourceContents]],
258+
):
256259
logger.debug("Registering handler for ReadResourceRequest")
257260

258261
async def handler(req: types.ReadResourceRequest):
259262
result = await func(req.params.uri)
263+
264+
def create_content(data: str | bytes, mime_type: str | None):
265+
match data:
266+
case str() as data:
267+
return types.TextResourceContents(
268+
uri=req.params.uri,
269+
text=data,
270+
mimeType=mime_type or "text/plain",
271+
)
272+
case bytes() as data:
273+
import base64
274+
275+
return types.BlobResourceContents(
276+
uri=req.params.uri,
277+
blob=base64.urlsafe_b64encode(data).decode(),
278+
mimeType=mime_type or "application/octet-stream",
279+
)
280+
260281
match result:
261-
case str(s):
262-
content = types.TextResourceContents(
263-
uri=req.params.uri,
264-
text=s,
265-
mimeType="text/plain",
282+
case str() | bytes() as data:
283+
warnings.warn(
284+
"Returning str or bytes from read_resource is deprecated. "
285+
"Use ReadResourceContents instead.",
286+
DeprecationWarning,
287+
stacklevel=2,
266288
)
267-
case bytes(b):
268-
import base64
269-
270-
content = types.BlobResourceContents(
271-
uri=req.params.uri,
272-
blob=base64.urlsafe_b64encode(b).decode(),
273-
mimeType="application/octet-stream",
289+
content = create_content(data, None)
290+
case ReadResourceContents() as contents:
291+
content = create_content(contents.content, contents.mime_type)
292+
case _:
293+
raise ValueError(
294+
f"Unexpected return type from read_resource: {type(result)}"
274295
)
275296

276297
return types.ServerResult(
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import base64
2+
3+
import pytest
4+
from pydantic import AnyUrl
5+
6+
from mcp import types
7+
from mcp.server.fastmcp import FastMCP
8+
from mcp.server.lowlevel import Server
9+
from mcp.server.lowlevel.helper_types import ReadResourceContents
10+
from mcp.shared.memory import (
11+
create_connected_server_and_client_session as client_session,
12+
)
13+
14+
pytestmark = pytest.mark.anyio
15+
16+
17+
async def test_fastmcp_resource_mime_type():
18+
"""Test that mime_type parameter is respected for resources."""
19+
mcp = FastMCP("test")
20+
21+
# Create a small test image as bytes
22+
image_bytes = b"fake_image_data"
23+
base64_string = base64.b64encode(image_bytes).decode("utf-8")
24+
25+
@mcp.resource("test://image", mime_type="image/png")
26+
def get_image_as_string() -> str:
27+
"""Return a test image as base64 string."""
28+
return base64_string
29+
30+
@mcp.resource("test://image_bytes", mime_type="image/png")
31+
def get_image_as_bytes() -> bytes:
32+
"""Return a test image as bytes."""
33+
return image_bytes
34+
35+
# Test that resources are listed with correct mime type
36+
async with client_session(mcp._mcp_server) as client:
37+
# List resources and verify mime types
38+
resources = await client.list_resources()
39+
assert resources.resources is not None
40+
41+
mapping = {str(r.uri): r for r in resources.resources}
42+
43+
# Find our resources
44+
string_resource = mapping["test://image"]
45+
bytes_resource = mapping["test://image_bytes"]
46+
47+
# Verify mime types
48+
assert (
49+
string_resource.mimeType == "image/png"
50+
), "String resource mime type not respected"
51+
assert (
52+
bytes_resource.mimeType == "image/png"
53+
), "Bytes resource mime type not respected"
54+
55+
# Also verify the content can be read correctly
56+
string_result = await client.read_resource(AnyUrl("test://image"))
57+
assert len(string_result.contents) == 1
58+
assert (
59+
getattr(string_result.contents[0], "text") == base64_string
60+
), "Base64 string mismatch"
61+
assert (
62+
string_result.contents[0].mimeType == "image/png"
63+
), "String content mime type not preserved"
64+
65+
bytes_result = await client.read_resource(AnyUrl("test://image_bytes"))
66+
assert len(bytes_result.contents) == 1
67+
assert (
68+
base64.b64decode(getattr(bytes_result.contents[0], "blob")) == image_bytes
69+
), "Bytes mismatch"
70+
assert (
71+
bytes_result.contents[0].mimeType == "image/png"
72+
), "Bytes content mime type not preserved"
73+
74+
75+
async def test_lowlevel_resource_mime_type():
76+
"""Test that mime_type parameter is respected for resources."""
77+
server = Server("test")
78+
79+
# Create a small test image as bytes
80+
image_bytes = b"fake_image_data"
81+
base64_string = base64.b64encode(image_bytes).decode("utf-8")
82+
83+
# Create test resources with specific mime types
84+
test_resources = [
85+
types.Resource(
86+
uri=AnyUrl("test://image"), name="test image", mimeType="image/png"
87+
),
88+
types.Resource(
89+
uri=AnyUrl("test://image_bytes"),
90+
name="test image bytes",
91+
mimeType="image/png",
92+
),
93+
]
94+
95+
@server.list_resources()
96+
async def handle_list_resources():
97+
return test_resources
98+
99+
@server.read_resource()
100+
async def handle_read_resource(uri: AnyUrl):
101+
if str(uri) == "test://image":
102+
return ReadResourceContents(content=base64_string, mime_type="image/png")
103+
elif str(uri) == "test://image_bytes":
104+
return ReadResourceContents(
105+
content=bytes(image_bytes), mime_type="image/png"
106+
)
107+
raise Exception(f"Resource not found: {uri}")
108+
109+
# Test that resources are listed with correct mime type
110+
async with client_session(server) as client:
111+
# List resources and verify mime types
112+
resources = await client.list_resources()
113+
assert resources.resources is not None
114+
115+
mapping = {str(r.uri): r for r in resources.resources}
116+
117+
# Find our resources
118+
string_resource = mapping["test://image"]
119+
bytes_resource = mapping["test://image_bytes"]
120+
121+
# Verify mime types
122+
assert (
123+
string_resource.mimeType == "image/png"
124+
), "String resource mime type not respected"
125+
assert (
126+
bytes_resource.mimeType == "image/png"
127+
), "Bytes resource mime type not respected"
128+
129+
# Also verify the content can be read correctly
130+
string_result = await client.read_resource(AnyUrl("test://image"))
131+
assert len(string_result.contents) == 1
132+
assert (
133+
getattr(string_result.contents[0], "text") == base64_string
134+
), "Base64 string mismatch"
135+
assert (
136+
string_result.contents[0].mimeType == "image/png"
137+
), "String content mime type not preserved"
138+
139+
bytes_result = await client.read_resource(AnyUrl("test://image_bytes"))
140+
assert len(bytes_result.contents) == 1
141+
assert (
142+
base64.b64decode(getattr(bytes_result.contents[0], "blob")) == image_bytes
143+
), "Bytes mismatch"
144+
assert (
145+
bytes_result.contents[0].mimeType == "image/png"
146+
), "Bytes content mime type not preserved"

tests/server/fastmcp/servers/test_file_server.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +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 = await mcp.read_resource("dir://test_dir")
92-
files = json.loads(files)
91+
res = await mcp.read_resource("dir://test_dir")
92+
assert res.mime_type == "text/plain"
93+
94+
files = json.loads(res.content)
9395

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

101103
@pytest.mark.anyio
102104
async def test_read_resource_file(mcp: FastMCP):
103-
result = await mcp.read_resource("file://test_dir/example.py")
104-
assert result == "print('hello world')"
105+
res = await mcp.read_resource("file://test_dir/example.py")
106+
assert res.content == "print('hello world')"
105107

106108

107109
@pytest.mark.anyio
@@ -117,5 +119,5 @@ async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path):
117119
await mcp.call_tool(
118120
"delete_file", arguments=dict(path=str(test_dir / "example.py"))
119121
)
120-
result = await mcp.read_resource("file://test_dir/example.py")
121-
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

Lines changed: 2 additions & 2 deletions
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 = await ctx.read_resource("test://data")
585-
return f"Read resource: {data}"
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", {})

0 commit comments

Comments
 (0)