Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: respect resource mime type in responses #170

Merged
merged 4 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 26 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,31 @@ This document contains critical information about working with this codebase. Fo
- New features require tests
- Bug fixes require regression tests

4. Version Control
- Commit messages: conventional format (fix:, feat:)
- PR scope: minimal, focused changes
- PR requirements: description, test plan
- Always include issue numbers
- Quote handling:
```bash
git commit -am "\"fix: message\""
gh pr create --title "\"title\"" --body "\"body\""
```
- For commits fixing bugs or adding features based on user reports add:
```bash
git commit --trailer "Reported-by:<name>"
```
Where `<name>` is the name of the user.

- For commits related to a Github issue, add
```bash
git commit --trailer "Github-Issue:#<number>"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
git commit --trailer "Github-Issue:#<number>"
git commit --trailer "GitHub-Issue:#<number>"

```
- NEVER ever mention a `co-authored-by` or similar aspects. In particular, never
mention the tool used to create the commit message or PR.

## Pull Requests

- Create a detailed message of what changed. Focus on the high level description of
the problem it tries to solve, and how it is solved. Don't go into the specifics of the
code unless it adds clarity.

- Always add `jerome3o-anthropic` and `jspahrsummers` as reviewer.

- NEVER ever mention a `co-authored-by` or similar aspects. In particular, never
mention the tool used to create the commit message or PR.

## Python Tools

## Code Formatting

Expand Down Expand Up @@ -96,4 +111,4 @@ This document contains critical information about working with this codebase. Fo
- Keep changes minimal
- Follow existing patterns
- Document public APIs
- Test thoroughly
- Test thoroughly
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ async def long_task(files: list[str], ctx: Context) -> str:
for i, file in enumerate(files):
ctx.info(f"Processing {file}")
await ctx.report_progress(i, len(files))
data = await ctx.read_resource(f"file://{file}")
data, mime_type = await ctx.read_resource(f"file://{file}")
return "Processing complete"
```

Expand Down Expand Up @@ -436,7 +436,7 @@ async def run():
tools = await session.list_tools()

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

# Call a tool
result = await session.call_tool("tool-name", arguments={"arg1": "value"})
Expand Down
8 changes: 5 additions & 3 deletions src/mcp/server/fastmcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,14 +197,16 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]:
for template in templates
]

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

resource = await self._resource_manager.get_resource(uri)
if not resource:
raise ResourceError(f"Unknown resource: {uri}")

try:
return await resource.read()
content = await resource.read()
return (content, resource.mime_type)
except Exception as e:
logger.error(f"Error reading resource {uri}: {e}")
raise ResourceError(str(e))
Expand Down Expand Up @@ -606,7 +608,7 @@ async def report_progress(
progress_token=progress_token, progress=progress, total=total
)

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

Args:
Expand Down
61 changes: 42 additions & 19 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,32 +252,55 @@ async def handler(_: Any):
return decorator

def read_resource(self):
def decorator(func: Callable[[AnyUrl], Awaitable[str | bytes]]):
def decorator(
func: Callable[[AnyUrl], Awaitable[str | bytes | tuple[str | bytes, str]]],
):
logger.debug("Registering handler for ReadResourceRequest")

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

def create_content(data: str | bytes, mime_type: str):
match data:
case str() as data:
return types.TextResourceContents(
uri=req.params.uri,
text=data,
mimeType=mime_type,
)
case bytes() as data:
import base64

return types.BlobResourceContents(
uri=req.params.uri,
blob=base64.urlsafe_b64encode(data).decode(),
mimeType=mime_type,
)

match result:
case str(s):
content = types.TextResourceContents(
uri=req.params.uri,
text=s,
mimeType="text/plain",
case str() | bytes() as data:
default_mime = (
"text/plain"
if isinstance(data, str)
else "application/octet-stream"
)
case bytes(b):
import base64

content = types.BlobResourceContents(
uri=req.params.uri,
blob=base64.urlsafe_b64encode(b).decode(),
mimeType="application/octet-stream",
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],
)
)
case _:
raise ValueError(
f"Unexpected return type from read_resource: {type(result)}"
)

return types.ServerResult(
types.ReadResourceResult(
contents=[content],
)
)

self.request_handlers[types.ReadResourceRequest] = handler
return func
Expand Down
143 changes: 143 additions & 0 deletions tests/issues/test_152_resource_mime_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import base64

import pytest
from pydantic import AnyUrl

from mcp import types
from mcp.server.fastmcp import FastMCP
from mcp.server.lowlevel import Server
from mcp.shared.memory import (
create_connected_server_and_client_session as client_session,
)

pytestmark = pytest.mark.anyio


async def test_fastmcp_resource_mime_type():
"""Test that mime_type parameter is respected for resources."""
mcp = FastMCP("test")

# Create a small test image as bytes
image_bytes = b"fake_image_data"
base64_string = base64.b64encode(image_bytes).decode("utf-8")

@mcp.resource("test://image", mime_type="image/png")
def get_image_as_string() -> str:
"""Return a test image as base64 string."""
return base64_string

@mcp.resource("test://image_bytes", mime_type="image/png")
def get_image_as_bytes() -> bytes:
"""Return a test image as bytes."""
return image_bytes

# Test that resources are listed with correct mime type
async with client_session(mcp._mcp_server) as client:
# List resources and verify mime types
resources = await client.list_resources()
assert resources.resources is not None

mapping = {str(r.uri): r for r in resources.resources}

# Find our resources
string_resource = mapping["test://image"]
bytes_resource = mapping["test://image_bytes"]

# Verify mime types
assert (
string_resource.mimeType == "image/png"
), "String resource mime type not respected"
assert (
bytes_resource.mimeType == "image/png"
), "Bytes resource mime type not respected"

# Also verify the content can be read correctly
string_result = await client.read_resource(AnyUrl("test://image"))
assert len(string_result.contents) == 1
assert (
getattr(string_result.contents[0], "text") == base64_string
), "Base64 string mismatch"
assert (
string_result.contents[0].mimeType == "image/png"
), "String content mime type not preserved"

bytes_result = await client.read_resource(AnyUrl("test://image_bytes"))
assert len(bytes_result.contents) == 1
assert (
base64.b64decode(getattr(bytes_result.contents[0], "blob")) == image_bytes
), "Bytes mismatch"
assert (
bytes_result.contents[0].mimeType == "image/png"
), "Bytes content mime type not preserved"


async def test_lowlevel_resource_mime_type():
"""Test that mime_type parameter is respected for resources."""
server = Server("test")

# Create a small test image as bytes
image_bytes = b"fake_image_data"
base64_string = base64.b64encode(image_bytes).decode("utf-8")

# Create test resources with specific mime types
test_resources = [
types.Resource(
uri=AnyUrl("test://image"), name="test image", mimeType="image/png"
),
types.Resource(
uri=AnyUrl("test://image_bytes"),
name="test image bytes",
mimeType="image/png",
),
]

@server.list_resources()
async def handle_list_resources():
return test_resources

@server.read_resource()
async def handle_read_resource(uri: AnyUrl):
if str(uri) == "test://image":
return (base64_string, "image/png")
elif str(uri) == "test://image_bytes":
return (bytes(image_bytes), "image/png")
raise Exception(f"Resource not found: {uri}")

# Test that resources are listed with correct mime type
async with client_session(server) as client:
# List resources and verify mime types
resources = await client.list_resources()
assert resources.resources is not None

mapping = {str(r.uri): r for r in resources.resources}

# Find our resources
string_resource = mapping["test://image"]
bytes_resource = mapping["test://image_bytes"]

# Verify mime types
assert (
string_resource.mimeType == "image/png"
), "String resource mime type not respected"
assert (
bytes_resource.mimeType == "image/png"
), "Bytes resource mime type not respected"

# Also verify the content can be read correctly
string_result = await client.read_resource(AnyUrl("test://image"))
assert len(string_result.contents) == 1
assert (
getattr(string_result.contents[0], "text") == base64_string
), "Base64 string mismatch"
assert (
string_result.contents[0].mimeType == "image/png"
), "String content mime type not preserved"

bytes_result = await client.read_resource(AnyUrl("test://image_bytes"))
assert len(bytes_result.contents) == 1
assert (
base64.b64decode(getattr(bytes_result.contents[0], "blob")) == image_bytes
), "Bytes mismatch"
assert (
bytes_result.contents[0].mimeType == "image/png"
), "Bytes content mime type not preserved"
8 changes: 5 additions & 3 deletions tests/server/fastmcp/servers/test_file_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ async def test_list_resources(mcp: FastMCP):

@pytest.mark.anyio
async def test_read_resource_dir(mcp: FastMCP):
files = await mcp.read_resource("dir://test_dir")
files, mime_type = await mcp.read_resource("dir://test_dir")
assert mime_type == "text/plain"

files = json.loads(files)

assert sorted([Path(f).name for f in files]) == [
Expand All @@ -100,7 +102,7 @@ async def test_read_resource_dir(mcp: FastMCP):

@pytest.mark.anyio
async def test_read_resource_file(mcp: FastMCP):
result = await mcp.read_resource("file://test_dir/example.py")
result, _ = await mcp.read_resource("file://test_dir/example.py")
assert result == "print('hello world')"


Expand All @@ -117,5 +119,5 @@ 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"))
)
result = await mcp.read_resource("file://test_dir/example.py")
result, _ = await mcp.read_resource("file://test_dir/example.py")
assert result == "File not found"
4 changes: 2 additions & 2 deletions tests/server/fastmcp/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,8 +581,8 @@ def test_resource() -> str:

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

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