fix: respect resource mime type in responses

The server was ignoring mime types set on resources, defaulting to text/plain
for strings and application/octet-stream for bytes. Now properly preserves
the specified mime type in both FastMCP and low-level server implementations.

Note that this is breaks backwards compatibility as it changes the return
values of read_resource() on FastMCP. It is BC compatible on lowlevel since
it only extends the callback.

Github-Issue: #152
Reported-by: eiseleMichael
This commit is contained in:
David Soria Parra
2025-01-24 14:20:42 +00:00
parent f10c2e3f1f
commit 8ff4b5e9d3
6 changed files with 82 additions and 40 deletions

View File

@@ -25,16 +25,31 @@ This document contains critical information about working with this codebase. Fo
- New features require tests - New features require tests
- Bug fixes require regression tests - Bug fixes require regression tests
4. Version Control - For commits fixing bugs or adding features based on user reports add:
- Commit messages: conventional format (fix:, feat:) ```bash
- PR scope: minimal, focused changes git commit --trailer "Reported-by:<name>"
- PR requirements: description, test plan ```
- Always include issue numbers Where `<name>` is the name of the user.
- Quote handling:
```bash - For commits related to a Github issue, add
git commit -am "\"fix: message\"" ```bash
gh pr create --title "\"title\"" --body "\"body\"" 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 ## Code Formatting
@@ -96,4 +111,4 @@ This document contains critical information about working with this codebase. Fo
- Keep changes minimal - Keep changes minimal
- Follow existing patterns - Follow existing patterns
- Document public APIs - Document public APIs
- Test thoroughly - Test thoroughly

View File

@@ -197,14 +197,16 @@ class FastMCP:
for template in templates 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.""" """Read a resource by URI."""
resource = await self._resource_manager.get_resource(uri) resource = await self._resource_manager.get_resource(uri)
if not resource: if not resource:
raise ResourceError(f"Unknown resource: {uri}") raise ResourceError(f"Unknown resource: {uri}")
try: try:
return await resource.read() content = await resource.read()
return (content, 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))
@@ -606,7 +608,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) -> str | bytes: async def read_resource(self, uri: str | AnyUrl) -> tuple[str | bytes, str]:
"""Read a resource by URI. """Read a resource by URI.
Args: Args:

View File

@@ -252,32 +252,55 @@ class Server:
return decorator return decorator
def read_resource(self): 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") 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):
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: match result:
case str(s): case str() | bytes() as data:
content = types.TextResourceContents( default_mime = (
uri=req.params.uri, "text/plain"
text=s, if isinstance(data, str)
mimeType="text/plain", else "application/octet-stream"
) )
case bytes(b): content = create_content(data, default_mime)
import base64 return types.ServerResult(
types.ReadResourceResult(
content = types.BlobResourceContents( contents=[content],
uri=req.params.uri, )
blob=base64.urlsafe_b64encode(b).decode(), )
mimeType="application/octet-stream", 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 self.request_handlers[types.ReadResourceRequest] = handler
return func return func

View File

@@ -98,9 +98,9 @@ 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 return (base64_string, "image/png")
elif str(uri) == "test://image_bytes": elif str(uri) == "test://image_bytes":
return image_bytes return (bytes(image_bytes), "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

View File

@@ -88,7 +88,9 @@ 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 = 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) files = json.loads(files)
assert sorted([Path(f).name for f in files]) == [ assert sorted([Path(f).name for f in files]) == [
@@ -100,7 +102,7 @@ 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") result, _ = await mcp.read_resource("file://test_dir/example.py")
assert result == "print('hello world')" assert result == "print('hello world')"
@@ -117,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") result, _ = await mcp.read_resource("file://test_dir/example.py")
assert result == "File not found" assert result == "File not found"

View File

@@ -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 = await ctx.read_resource("test://data") data, mime_type = await ctx.read_resource("test://data")
return f"Read resource: {data}" return f"Read resource: {data} with mime type {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", {})