diff --git a/examples/get_file.py b/examples/get_file.py index bf1a1ff93..0b00ac47d 100644 --- a/examples/get_file.py +++ b/examples/get_file.py @@ -1,7 +1,9 @@ import aiohttp + from fastmcp.server import FastMCP from fastmcp.utilities.types import File + def create_server(): mcp = FastMCP(name="File Demo", instructions="Get files from the server or URL.") @@ -11,9 +13,11 @@ def create_server(): Get a test file from the server. If the path is not provided, it defaults to 'requirements.txt'. """ return File(path=path) - + @mcp.tool() - async def get_test_pdf_from_url(url: str = "https://mozilla.github.io/pdf.js/web/compressed.tracemonkey-pldi-09.pdf") -> File: + async def get_test_pdf_from_url( + url: str = "https://mozilla.github.io/pdf.js/web/compressed.tracemonkey-pldi-09.pdf", + ) -> File: """ Get a test PDF file from a URL. If the URL is not provided, it defaults to a sample PDF. """ @@ -21,7 +25,9 @@ def create_server(): async with session.get(url) as response: pdf_data = await response.read() return File(data=pdf_data, format="pdf") - + return mcp + + if __name__ == "__main__": - create_server().run(transport="sse", host="0.0.0.0", port=8001, path="/sse") \ No newline at end of file + create_server().run(transport="sse", host="0.0.0.0", port=8001, path="/sse") diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 2747a270a..a200dd4ed 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -18,8 +18,8 @@ from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import ( Audio, - Image, File, + Image, MCPContent, find_kwarg_by_type, get_cached_typeadapter, diff --git a/src/fastmcp/utilities/types.py b/src/fastmcp/utilities/types.py index be3064bbd..640ede9db 100644 --- a/src/fastmcp/utilities/types.py +++ b/src/fastmcp/utilities/types.py @@ -2,22 +2,22 @@ import base64 import inspect +import mimetypes from collections.abc import Callable from functools import lru_cache from pathlib import Path from types import UnionType from typing import Annotated, TypeAlias, TypeVar, Union, get_args, get_origin -import mimetypes from mcp.types import ( Annotations, AudioContent, + BlobResourceContents, EmbeddedResource, ImageContent, TextContent, - BlobResourceContents ) -from pydantic import BaseModel, ConfigDict, TypeAdapter +from pydantic import AnyUrl, BaseModel, ConfigDict, TypeAdapter, UrlConstraints T = TypeVar("T") @@ -239,7 +239,7 @@ class File: mime_type, _ = mimetypes.guess_type(self.path) if mime_type: return mime_type - + return "application/octet-stream" def to_resource_content( @@ -250,23 +250,28 @@ class File: if self.path: with open(self.path, "rb") as f: data = base64.b64encode(f.read()).decode() - uri=self.path.resolve().as_uri() + uri_str = self.path.resolve().as_uri() elif self.data is not None: data = base64.b64encode(self.data).decode() - uri=self.path or (self._name and f"file:///{self._name}.{self._mime_type.split('/')[1]}") or f"file:///resource.{self._mime_type.split('/')[1]}" + if self._name: + uri_str = f"file:///{self._name}.{self._mime_type.split('/')[1]}" + else: + uri_str = f"file:///resource.{self._mime_type.split('/')[1]}" else: raise ValueError("No resource data available") + UriType = Annotated[AnyUrl, UrlConstraints(host_required=False)] + uri = TypeAdapter(UriType).validate_python(uri_str) resource = BlobResourceContents( - blob=data, - mimeType=mime_type or self._mime_type, - uri=uri, - ) + blob=data, + mimeType=mime_type or self._mime_type, + uri=uri, + ) return EmbeddedResource( type="resource", resource=resource, annotations=annotations or self.annotations, - ) \ No newline at end of file + ) diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 467a88c1d..23e7b9957 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -11,11 +11,11 @@ import pytest from mcp import McpError from mcp.types import ( AudioContent, + BlobResourceContents, EmbeddedResource, ImageContent, TextContent, TextResourceContents, - BlobResourceContents, ) from pydantic import AnyUrl, Field @@ -62,7 +62,14 @@ def tool_server(): return [ TextContent(type="text", text="Hello"), ImageContent(type="image", data="abc", mimeType="application/octet-stream"), - EmbeddedResource(type="resource", resource=BlobResourceContents(blob="abc", mimeType="application/octet-stream", uri=AnyUrl("abc"))), + EmbeddedResource( + type="resource", + resource=BlobResourceContents( + blob=base64.b64encode(b"abc").decode(), + mimeType="application/octet-stream", + uri=AnyUrl("file:///test.bin"), + ), + ), ] @mcp.tool diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 2db61b6e3..6960b060c 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -113,7 +113,7 @@ class TestToolFromFunction: result = await tool.run({"data": "test.wav"}) assert tool.parameters["properties"]["data"]["type"] == "string" assert isinstance(result[0], AudioContent) - + async def test_tool_with_file_return(self): def file_tool(data: bytes) -> File: return File(data=data, format="octet-stream") @@ -620,7 +620,7 @@ class TestConvertResultToContent: text_content_count = sum(isinstance(item, TextContent) for item in result) embedded_content_count = sum( - isinstance(item, EmbeddedResource) and item.type == "resource" + isinstance(item, EmbeddedResource) and item.type == "resource" for item in result ) @@ -631,7 +631,8 @@ class TestConvertResultToContent: assert text_item.text == '{\n "a": 1\n}' embedded_item = next( - item for item in result + item + for item in result if isinstance(item, EmbeddedResource) and item.type == "resource" ) resource = embedded_item.resource diff --git a/tests/utilities/test_types.py b/tests/utilities/test_types.py index 01edc2277..044cc3d31 100644 --- a/tests/utilities/test_types.py +++ b/tests/utilities/test_types.py @@ -3,6 +3,7 @@ from types import EllipsisType from typing import Annotated, Any import pytest +from mcp.types import BlobResourceContents from fastmcp.utilities.types import ( Audio, @@ -345,7 +346,9 @@ class TestFile: def test_get_mime_type_from_path(self, tmp_path): """Test MIME type detection from file extension.""" file_path = tmp_path / "test.txt" - file_path.write_text("test content") # Need to write content for MIME type detection + file_path.write_text( + "test content" + ) # Need to write content for MIME type detection file = File(path=file_path) # The MIME type should be detected from the .txt extension assert file._mime_type == "text/plain" @@ -363,7 +366,8 @@ class TestFile: assert resource.resource.mimeType == "text/plain" # Convert both to strings for comparison assert str(resource.resource.uri) == file_path.resolve().as_uri() - assert resource.resource.blob == base64.b64encode(test_data).decode() + if isinstance(resource.resource, BlobResourceContents): + assert resource.resource.blob == base64.b64encode(test_data).decode() def test_to_resource_content_with_data(self): """Test conversion to ResourceContent with data.""" @@ -375,7 +379,8 @@ class TestFile: assert resource.resource.mimeType == "application/pdf" # Convert URI to string for comparison assert str(resource.resource.uri) == "file:///resource.pdf" - assert resource.resource.blob == base64.b64encode(test_data).decode() + if isinstance(resource.resource, BlobResourceContents): + assert resource.resource.blob == base64.b64encode(test_data).decode() def test_to_resource_content_error(self, monkeypatch): """Test error case in to_resource_content."""