Merge pull request #170 from modelcontextprotocol/fix/152-resource-mime-type

fix: respect resource mime type in responses
This commit is contained in:
David Soria Parra
2025-01-28 18:12:39 +00:00
committed by GitHub
9 changed files with 343 additions and 38 deletions

View File

@@ -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>"
```
- 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

View File

@@ -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"
```
@@ -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"})

View File

@@ -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.types import Image
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.stdio import stdio_server
from mcp.shared.context import RequestContext
@@ -197,14 +198,16 @@ class FastMCP:
for template in templates
]
async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
async def read_resource(self, uri: AnyUrl | str) -> ReadResourceContents:
"""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 ReadResourceContents(content=content, mime_type=resource.mime_type)
except Exception as e:
logger.error(f"Error reading resource {uri}: {e}")
raise ResourceError(str(e))
@@ -606,7 +609,7 @@ class Context(BaseModel):
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) -> ReadResourceContents:
"""Read a resource by URI.
Args:

View 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

View File

@@ -74,6 +74,7 @@ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStre
from pydantic import AnyUrl
import mcp.types as types
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
from mcp.server.stdio import stdio_server as stdio_server
@@ -252,25 +253,45 @@ class Server:
return decorator
def read_resource(self):
def decorator(func: Callable[[AnyUrl], Awaitable[str | bytes]]):
def decorator(
func: Callable[[AnyUrl], Awaitable[str | bytes | ReadResourceContents]],
):
logger.debug("Registering handler for ReadResourceRequest")
async def handler(req: types.ReadResourceRequest):
result = await func(req.params.uri)
match result:
case str(s):
content = types.TextResourceContents(
uri=req.params.uri,
text=s,
mimeType="text/plain",
)
case bytes(b):
import base64
content = types.BlobResourceContents(
uri=req.params.uri,
blob=base64.urlsafe_b64encode(b).decode(),
mimeType="application/octet-stream",
def create_content(data: str | bytes, mime_type: str | None):
match data:
case str() as data:
return types.TextResourceContents(
uri=req.params.uri,
text=data,
mimeType=mime_type or "text/plain",
)
case bytes() as data:
import base64
return types.BlobResourceContents(
uri=req.params.uri,
blob=base64.urlsafe_b64encode(data).decode(),
mimeType=mime_type or "application/octet-stream",
)
match result:
case str() | bytes() as data:
warnings.warn(
"Returning str or bytes from read_resource is deprecated. "
"Use ReadResourceContents instead.",
DeprecationWarning,
stacklevel=2,
)
content = create_content(data, None)
case ReadResourceContents() as contents:
content = create_content(contents.content, contents.mime_type)
case _:
raise ValueError(
f"Unexpected return type from read_resource: {type(result)}"
)
return types.ServerResult(

View File

@@ -0,0 +1,146 @@
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.server.lowlevel.helper_types import ReadResourceContents
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 ReadResourceContents(content=base64_string, mime_type="image/png")
elif str(uri) == "test://image_bytes":
return ReadResourceContents(
content=bytes(image_bytes), mime_type="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"

View File

@@ -88,8 +88,10 @@ 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 = json.loads(files)
res = await mcp.read_resource("dir://test_dir")
assert res.mime_type == "text/plain"
files = json.loads(res.content)
assert sorted([Path(f).name for f in files]) == [
"config.json",
@@ -100,8 +102,8 @@ 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")
assert result == "print('hello world')"
res = await mcp.read_resource("file://test_dir/example.py")
assert res.content == "print('hello world')"
@pytest.mark.anyio
@@ -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")
assert result == "File not found"
res = await mcp.read_resource("file://test_dir/example.py")
assert res.content == "File not found"

View File

@@ -581,8 +581,8 @@ class TestContextInjection:
@mcp.tool()
async def tool_with_resource(ctx: Context) -> str:
data = await ctx.read_resource("test://data")
return f"Read resource: {data}"
r = await ctx.read_resource("test://data")
return f"Read resource: {r.content} with mime type {r.mime_type}"
async with client_session(mcp._mcp_server) as client:
result = await client.call_tool("tool_with_resource", {})

View 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"