mirror of
https://github.com/aljazceru/mcp-python-sdk.git
synced 2025-12-19 14:54:24 +01:00
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:
31
CLAUDE.md
31
CLAUDE.md
@@ -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:)
|
|
||||||
- PR scope: minimal, focused changes
|
|
||||||
- PR requirements: description, test plan
|
|
||||||
- Always include issue numbers
|
|
||||||
- Quote handling:
|
|
||||||
```bash
|
```bash
|
||||||
git commit -am "\"fix: message\""
|
git commit --trailer "Reported-by:<name>"
|
||||||
gh pr create --title "\"title\"" --body "\"body\""
|
|
||||||
```
|
```
|
||||||
|
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
|
## Code Formatting
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
match result:
|
|
||||||
case str(s):
|
def create_content(data: str | bytes, mime_type: str):
|
||||||
content = types.TextResourceContents(
|
match data:
|
||||||
|
case str() as data:
|
||||||
|
return types.TextResourceContents(
|
||||||
uri=req.params.uri,
|
uri=req.params.uri,
|
||||||
text=s,
|
text=data,
|
||||||
mimeType="text/plain",
|
mimeType=mime_type,
|
||||||
)
|
)
|
||||||
case bytes(b):
|
case bytes() as data:
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
content = types.BlobResourceContents(
|
return types.BlobResourceContents(
|
||||||
uri=req.params.uri,
|
uri=req.params.uri,
|
||||||
blob=base64.urlsafe_b64encode(b).decode(),
|
blob=base64.urlsafe_b64encode(data).decode(),
|
||||||
mimeType="application/octet-stream",
|
mimeType=mime_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
match result:
|
||||||
|
case str() | bytes() as data:
|
||||||
|
default_mime = (
|
||||||
|
"text/plain"
|
||||||
|
if isinstance(data, str)
|
||||||
|
else "application/octet-stream"
|
||||||
|
)
|
||||||
|
content = create_content(data, default_mime)
|
||||||
return types.ServerResult(
|
return types.ServerResult(
|
||||||
types.ReadResourceResult(
|
types.ReadResourceResult(
|
||||||
contents=[content],
|
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)}"
|
||||||
|
)
|
||||||
|
|
||||||
self.request_handlers[types.ReadResourceRequest] = handler
|
self.request_handlers[types.ReadResourceRequest] = handler
|
||||||
return func
|
return func
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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", {})
|
||||||
|
|||||||
Reference in New Issue
Block a user