Jeremiah Lowin commited on
Commit
9d54c51
·
unverified ·
2 Parent(s): 208bdd7135c70a

Merge pull request #843 from gorocode/feature/file-utility-type

Browse files
docs/servers/tools.mdx CHANGED
@@ -258,6 +258,7 @@ FastMCP automatically converts the value returned by your function into the appr
258
  - **`bytes`**: Base64 encoded and sent as `BlobResourceContents` (often within an `EmbeddedResource`).
259
  - **`fastmcp.utilities.types.Image`**: A helper class for easily returning image data. Sent as `ImageContent`.
260
  - **`fastmcp.utilities.types.Audio`**: A helper class for easily returning audio data. Sent as `AudioContent`.
 
261
  - **A list of any of the above**: Automatically converts each item appropriately.
262
  - **`None`**: Results in an empty response (no content is sent back to the client).
263
 
 
258
  - **`bytes`**: Base64 encoded and sent as `BlobResourceContents` (often within an `EmbeddedResource`).
259
  - **`fastmcp.utilities.types.Image`**: A helper class for easily returning image data. Sent as `ImageContent`.
260
  - **`fastmcp.utilities.types.Audio`**: A helper class for easily returning audio data. Sent as `AudioContent`.
261
+ - **`fastmcp.utilities.types.File`**: A helper class for easily returning binary data as base64-encoded content. Sent as `EmbeddedResource`.
262
  - **A list of any of the above**: Automatically converts each item appropriately.
263
  - **`None`**: Results in an empty response (no content is sent back to the client).
264
 
examples/get_file.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import aiohttp
2
+
3
+ from fastmcp.server import FastMCP
4
+ from fastmcp.utilities.types import File
5
+
6
+
7
+ def create_server():
8
+ mcp = FastMCP(name="File Demo", instructions="Get files from the server or URL.")
9
+
10
+ @mcp.tool()
11
+ async def get_test_file_from_server(path: str = "requirements.txt") -> File:
12
+ """
13
+ Get a test file from the server. If the path is not provided, it defaults to 'requirements.txt'.
14
+ """
15
+ return File(path=path)
16
+
17
+ @mcp.tool()
18
+ async def get_test_pdf_from_url(
19
+ url: str = "https://mozilla.github.io/pdf.js/web/compressed.tracemonkey-pldi-09.pdf",
20
+ ) -> File:
21
+ """
22
+ Get a test PDF file from a URL. If the URL is not provided, it defaults to a sample PDF.
23
+ """
24
+ async with aiohttp.ClientSession() as session:
25
+ async with session.get(url) as response:
26
+ pdf_data = await response.read()
27
+ return File(data=pdf_data, format="pdf")
28
+
29
+ return mcp
30
+
31
+
32
+ if __name__ == "__main__":
33
+ create_server().run(transport="sse", host="0.0.0.0", port=8001, path="/sse")
src/fastmcp/tools/tool.py CHANGED
@@ -18,6 +18,7 @@ from fastmcp.utilities.json_schema import compress_schema
18
  from fastmcp.utilities.logging import get_logger
19
  from fastmcp.utilities.types import (
20
  Audio,
 
21
  Image,
22
  MCPContent,
23
  find_kwarg_by_type,
@@ -277,6 +278,9 @@ def _convert_to_content(
277
  elif isinstance(result, Audio):
278
  return [result.to_audio_content()]
279
 
 
 
 
280
  if isinstance(result, list | tuple) and not _process_as_single_item:
281
  # if the result is a list, then it could either be a list of MCP types,
282
  # or a "regular" list that the tool is returning, or a mix of both.
@@ -288,7 +292,7 @@ def _convert_to_content(
288
  other_content = []
289
 
290
  for item in result:
291
- if isinstance(item, MCPContent | Image | Audio):
292
  mcp_types.append(_convert_to_content(item)[0])
293
  else:
294
  other_content.append(item)
 
18
  from fastmcp.utilities.logging import get_logger
19
  from fastmcp.utilities.types import (
20
  Audio,
21
+ File,
22
  Image,
23
  MCPContent,
24
  find_kwarg_by_type,
 
278
  elif isinstance(result, Audio):
279
  return [result.to_audio_content()]
280
 
281
+ elif isinstance(result, File):
282
+ return [result.to_resource_content()]
283
+
284
  if isinstance(result, list | tuple) and not _process_as_single_item:
285
  # if the result is a list, then it could either be a list of MCP types,
286
  # or a "regular" list that the tool is returning, or a mix of both.
 
292
  other_content = []
293
 
294
  for item in result:
295
+ if isinstance(item, MCPContent | Image | Audio | File):
296
  mcp_types.append(_convert_to_content(item)[0])
297
  else:
298
  other_content.append(item)
src/fastmcp/utilities/types.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import base64
4
  import inspect
 
5
  from collections.abc import Callable
6
  from functools import lru_cache
7
  from pathlib import Path
@@ -11,11 +12,13 @@ from typing import Annotated, TypeAlias, TypeVar, Union, get_args, get_origin
11
  from mcp.types import (
12
  Annotations,
13
  AudioContent,
 
14
  EmbeddedResource,
15
  ImageContent,
16
  TextContent,
 
17
  )
18
- from pydantic import BaseModel, ConfigDict, TypeAdapter
19
 
20
  T = TypeVar("T")
21
 
@@ -203,3 +206,89 @@ class Audio:
203
  mimeType=mime_type or self._mime_type,
204
  annotations=annotations or self.annotations,
205
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import base64
4
  import inspect
5
+ import mimetypes
6
  from collections.abc import Callable
7
  from functools import lru_cache
8
  from pathlib import Path
 
12
  from mcp.types import (
13
  Annotations,
14
  AudioContent,
15
+ BlobResourceContents,
16
  EmbeddedResource,
17
  ImageContent,
18
  TextContent,
19
+ TextResourceContents, # Added import
20
  )
21
+ from pydantic import AnyUrl, BaseModel, ConfigDict, TypeAdapter, UrlConstraints
22
 
23
  T = TypeVar("T")
24
 
 
206
  mimeType=mime_type or self._mime_type,
207
  annotations=annotations or self.annotations,
208
  )
209
+
210
+
211
+ class File:
212
+ """Helper class for returning audio from tools."""
213
+
214
+ def __init__(
215
+ self,
216
+ path: str | Path | None = None,
217
+ data: bytes | None = None,
218
+ format: str | None = None,
219
+ name: str | None = None,
220
+ annotations: Annotations | None = None,
221
+ ):
222
+ if path is None and data is None:
223
+ raise ValueError("Either path or data must be provided")
224
+ if path is not None and data is not None:
225
+ raise ValueError("Only one of path or data can be provided")
226
+
227
+ self.path = Path(path) if path else None
228
+ self.data = data
229
+ self._format = format
230
+ self._mime_type = self._get_mime_type()
231
+ self._name = name
232
+ self.annotations = annotations
233
+
234
+ def _get_mime_type(self) -> str:
235
+ """Get MIME type from format or guess from file extension."""
236
+ if self._format:
237
+ fmt = self._format.lower()
238
+ # Map common text formats to text/plain
239
+ if fmt in {"plain", "txt", "text"}:
240
+ return "text/plain"
241
+ return f"application/{fmt}"
242
+
243
+ if self.path:
244
+ mime_type, _ = mimetypes.guess_type(self.path)
245
+ if mime_type:
246
+ return mime_type
247
+
248
+ return "application/octet-stream"
249
+
250
+ def to_resource_content(
251
+ self,
252
+ mime_type: str | None = None,
253
+ annotations: Annotations | None = None,
254
+ ) -> EmbeddedResource:
255
+ if self.path:
256
+ with open(self.path, "rb") as f:
257
+ raw_data = f.read()
258
+ uri_str = self.path.resolve().as_uri()
259
+ elif self.data is not None:
260
+ raw_data = self.data
261
+ if self._name:
262
+ uri_str = f"file:///{self._name}.{self._mime_type.split('/')[1]}"
263
+ else:
264
+ uri_str = f"file:///resource.{self._mime_type.split('/')[1]}"
265
+ else:
266
+ raise ValueError("No resource data available")
267
+
268
+ mime = mime_type or self._mime_type
269
+ UriType = Annotated[AnyUrl, UrlConstraints(host_required=False)]
270
+ uri = TypeAdapter(UriType).validate_python(uri_str)
271
+
272
+ if mime.startswith("text/"):
273
+ try:
274
+ text = raw_data.decode("utf-8")
275
+ except UnicodeDecodeError:
276
+ text = raw_data.decode("latin-1")
277
+ resource = TextResourceContents(
278
+ text=text,
279
+ mimeType=mime,
280
+ uri=uri,
281
+ )
282
+ else:
283
+ data = base64.b64encode(raw_data).decode()
284
+ resource = BlobResourceContents(
285
+ blob=data,
286
+ mimeType=mime,
287
+ uri=uri,
288
+ )
289
+
290
+ return EmbeddedResource(
291
+ type="resource",
292
+ resource=resource,
293
+ annotations=annotations or self.annotations,
294
+ )
tests/server/test_server_interactions.py CHANGED
@@ -11,6 +11,7 @@ import pytest
11
  from mcp import McpError
12
  from mcp.types import (
13
  AudioContent,
 
14
  EmbeddedResource,
15
  ImageContent,
16
  TextContent,
@@ -25,7 +26,7 @@ from fastmcp.prompts.prompt import Prompt, PromptMessage
25
  from fastmcp.resources import FileResource, ResourceTemplate
26
  from fastmcp.resources.resource import FunctionResource
27
  from fastmcp.tools.tool import Tool
28
- from fastmcp.utilities.types import Audio, Image
29
 
30
 
31
  @pytest.fixture
@@ -53,10 +54,22 @@ def tool_server():
53
  return Audio(path)
54
 
55
  @mcp.tool
56
- def mixed_content_tool() -> list[TextContent | ImageContent]:
 
 
 
 
57
  return [
58
  TextContent(type="text", text="Hello"),
59
- ImageContent(type="image", data="abc", mimeType="image/png"),
 
 
 
 
 
 
 
 
60
  ]
61
 
62
  @mcp.tool
@@ -77,6 +90,20 @@ def tool_server():
77
  TextContent(type="text", text="direct content"),
78
  ]
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  return mcp
81
 
82
 
@@ -88,7 +115,7 @@ class TestTools:
88
 
89
  async def test_list_tools(self, tool_server: FastMCP):
90
  async with Client(tool_server) as client:
91
- assert len(await client.list_tools()) == 8
92
 
93
  async def test_call_tool(self, tool_server: FastMCP):
94
  async with Client(tool_server) as client:
@@ -129,6 +156,17 @@ class TestTools:
129
  result = await client.call_tool("list_tool", {})
130
  assert result[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined]
131
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
  class TestToolTags:
134
  def create_server(self, include_tags=None, exclude_tags=None):
@@ -304,17 +342,52 @@ class TestToolReturnTypes:
304
  decoded = base64.b64decode(content.data)
305
  assert decoded == b"fake wav data"
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  async def test_tool_mixed_content(self, tool_server: FastMCP):
308
  async with Client(tool_server) as client:
309
  result = await client.call_tool("mixed_content_tool", {})
310
- assert len(result) == 2
311
  content1 = result[0]
312
  content2 = result[1]
 
313
  assert isinstance(content1, TextContent)
314
  assert content1.text == "Hello"
315
  assert isinstance(content2, ImageContent)
316
- assert content2.mimeType == "image/png"
317
  assert content2.data == "abc"
 
 
 
 
 
 
 
 
318
 
319
  async def test_tool_mixed_list_with_image(
320
  self, tool_server: FastMCP, tmp_path: Path
@@ -372,6 +445,38 @@ class TestToolReturnTypes:
372
  assert isinstance(content3, TextContent)
373
  assert content3.text == "direct content"
374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
 
376
  class TestToolParameters:
377
  async def test_parameter_descriptions_with_field_annotations(self):
 
11
  from mcp import McpError
12
  from mcp.types import (
13
  AudioContent,
14
+ BlobResourceContents,
15
  EmbeddedResource,
16
  ImageContent,
17
  TextContent,
 
26
  from fastmcp.resources import FileResource, ResourceTemplate
27
  from fastmcp.resources.resource import FunctionResource
28
  from fastmcp.tools.tool import Tool
29
+ from fastmcp.utilities.types import Audio, File, Image
30
 
31
 
32
  @pytest.fixture
 
54
  return Audio(path)
55
 
56
  @mcp.tool
57
+ def file_tool(path: str) -> File:
58
+ return File(path)
59
+
60
+ @mcp.tool
61
+ def mixed_content_tool() -> list[TextContent | ImageContent | EmbeddedResource]:
62
  return [
63
  TextContent(type="text", text="Hello"),
64
+ ImageContent(type="image", data="abc", mimeType="application/octet-stream"),
65
+ EmbeddedResource(
66
+ type="resource",
67
+ resource=BlobResourceContents(
68
+ blob=base64.b64encode(b"abc").decode(),
69
+ mimeType="application/octet-stream",
70
+ uri=AnyUrl("file:///test.bin"),
71
+ ),
72
+ ),
73
  ]
74
 
75
  @mcp.tool
 
90
  TextContent(type="text", text="direct content"),
91
  ]
92
 
93
+ @mcp.tool
94
+ def mixed_file_list_fn(file_path: str) -> list:
95
+ return [
96
+ "text message",
97
+ File(file_path),
98
+ {"key": "value"},
99
+ TextContent(type="text", text="direct content"),
100
+ ]
101
+
102
+ @mcp.tool
103
+ def file_text_tool() -> File:
104
+ # Return a File with text data and text/plain format
105
+ return File(data=b"hello world", format="plain")
106
+
107
  return mcp
108
 
109
 
 
115
 
116
  async def test_list_tools(self, tool_server: FastMCP):
117
  async with Client(tool_server) as client:
118
+ assert len(await client.list_tools()) == 11
119
 
120
  async def test_call_tool(self, tool_server: FastMCP):
121
  async with Client(tool_server) as client:
 
156
  result = await client.call_tool("list_tool", {})
157
  assert result[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined]
158
 
159
+ async def test_file_text_tool(self, tool_server: FastMCP):
160
+ async with Client(tool_server) as client:
161
+ result = await client.call_tool("file_text_tool", {})
162
+ assert len(result) == 1
163
+ embedded = result[0]
164
+ assert isinstance(embedded, EmbeddedResource)
165
+ resource = embedded.resource
166
+ assert isinstance(resource, TextResourceContents)
167
+ assert resource.mimeType == "text/plain"
168
+ assert resource.text == "hello world"
169
+
170
 
171
  class TestToolTags:
172
  def create_server(self, include_tags=None, exclude_tags=None):
 
342
  decoded = base64.b64decode(content.data)
343
  assert decoded == b"fake wav data"
344
 
345
+ async def test_file(self, tmp_path: Path):
346
+ mcp = FastMCP()
347
+
348
+ @mcp.tool
349
+ def file_tool(path: str) -> File:
350
+ return File(path)
351
+
352
+ # Create a test file
353
+ file_path = tmp_path / "test.bin"
354
+ file_path.write_bytes(b"test file data")
355
+
356
+ async with Client(mcp) as client:
357
+ result = await client.call_tool("file_tool", {"path": str(file_path)})
358
+ content = result[0]
359
+ assert isinstance(content, EmbeddedResource)
360
+ assert content.type == "resource"
361
+ resource = content.resource
362
+ assert resource.mimeType == "application/octet-stream"
363
+ # Verify base64 encoding
364
+ assert hasattr(resource, "blob")
365
+ blob_data = getattr(resource, "blob")
366
+ decoded = base64.b64decode(blob_data)
367
+ assert decoded == b"test file data"
368
+ # Verify URI points to the file
369
+ assert str(resource.uri) == file_path.resolve().as_uri()
370
+
371
  async def test_tool_mixed_content(self, tool_server: FastMCP):
372
  async with Client(tool_server) as client:
373
  result = await client.call_tool("mixed_content_tool", {})
374
+ assert len(result) == 3
375
  content1 = result[0]
376
  content2 = result[1]
377
+ content3 = result[2]
378
  assert isinstance(content1, TextContent)
379
  assert content1.text == "Hello"
380
  assert isinstance(content2, ImageContent)
381
+ assert content2.mimeType == "application/octet-stream"
382
  assert content2.data == "abc"
383
+ assert isinstance(content3, EmbeddedResource)
384
+ assert content3.type == "resource"
385
+ resource = content3.resource
386
+ assert resource.mimeType == "application/octet-stream"
387
+ assert hasattr(resource, "blob")
388
+ blob_data = getattr(resource, "blob")
389
+ decoded = base64.b64decode(blob_data)
390
+ assert decoded == b"abc"
391
 
392
  async def test_tool_mixed_list_with_image(
393
  self, tool_server: FastMCP, tmp_path: Path
 
445
  assert isinstance(content3, TextContent)
446
  assert content3.text == "direct content"
447
 
448
+ async def test_tool_mixed_list_with_file(
449
+ self, tool_server: FastMCP, tmp_path: Path
450
+ ):
451
+ """Test that lists containing File objects and other types are handled
452
+ correctly. Note that the non-MCP content will be grouped together."""
453
+ # Create a test file
454
+ file_path = tmp_path / "test.bin"
455
+ file_path.write_bytes(b"test file data")
456
+
457
+ async with Client(tool_server) as client:
458
+ result = await client.call_tool(
459
+ "mixed_file_list_fn", {"file_path": str(file_path)}
460
+ )
461
+ assert len(result) == 3
462
+ # Check text conversion
463
+ content1 = result[0]
464
+ assert isinstance(content1, TextContent)
465
+ assert json.loads(content1.text) == ["text message", {"key": "value"}]
466
+ # Check file conversion
467
+ content2 = result[1]
468
+ assert isinstance(content2, EmbeddedResource)
469
+ assert content2.type == "resource"
470
+ resource = content2.resource
471
+ assert resource.mimeType == "application/octet-stream"
472
+ assert hasattr(resource, "blob")
473
+ blob_data = getattr(resource, "blob")
474
+ assert base64.b64decode(blob_data) == b"test file data"
475
+ # Check direct TextContent
476
+ content3 = result[2]
477
+ assert isinstance(content3, TextContent)
478
+ assert content3.text == "direct content"
479
+
480
 
481
  class TestToolParameters:
482
  async def test_parameter_descriptions_with_field_annotations(self):
tests/tools/test_tool.py CHANGED
@@ -13,7 +13,7 @@ from fastmcp.client import Client
13
  from fastmcp.exceptions import ToolError
14
  from fastmcp.tools.tool import Tool, _convert_to_content
15
  from fastmcp.utilities.tests import temporary_settings
16
- from fastmcp.utilities.types import Audio, Image
17
 
18
 
19
  class TestToolFromFunction:
@@ -114,6 +114,21 @@ class TestToolFromFunction:
114
  assert tool.parameters["properties"]["data"]["type"] == "string"
115
  assert isinstance(result[0], AudioContent)
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  def test_non_callable_fn(self):
118
  with pytest.raises(TypeError, match="not a callable object"):
119
  Tool.from_function(1) # type: ignore
@@ -468,6 +483,38 @@ class TestConvertResultToContent:
468
  assert isinstance(result[0], AudioContent)
469
  assert result[0].data == "ZmFrZWF1ZGlvZGF0YQ=="
470
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  def test_basic_type_result(self):
472
  """Test that a basic type is converted to TextContent."""
473
  result = _convert_to_content(123)
@@ -574,6 +621,39 @@ class TestConvertResultToContent:
574
  audio_item = next(item for item in result if isinstance(item, AudioContent))
575
  assert audio_item.data == "ZmFrZWF1ZGlvZGF0YQ=="
576
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  def test_empty_list(self):
578
  """Test that an empty list results in an empty list."""
579
  result = _convert_to_content([])
 
13
  from fastmcp.exceptions import ToolError
14
  from fastmcp.tools.tool import Tool, _convert_to_content
15
  from fastmcp.utilities.tests import temporary_settings
16
+ from fastmcp.utilities.types import Audio, File, Image
17
 
18
 
19
  class TestToolFromFunction:
 
114
  assert tool.parameters["properties"]["data"]["type"] == "string"
115
  assert isinstance(result[0], AudioContent)
116
 
117
+ async def test_tool_with_file_return(self):
118
+ def file_tool(data: bytes) -> File:
119
+ return File(data=data, format="octet-stream")
120
+
121
+ tool = Tool.from_function(file_tool)
122
+
123
+ result = await tool.run({"data": "test.bin"})
124
+ assert tool.parameters["properties"]["data"]["type"] == "string"
125
+ assert len(result) == 1
126
+ assert isinstance(result[0], EmbeddedResource)
127
+ assert result[0].type == "resource"
128
+ assert hasattr(result[0], "resource")
129
+ resource = result[0].resource
130
+ assert resource.mimeType == "application/octet-stream"
131
+
132
  def test_non_callable_fn(self):
133
  with pytest.raises(TypeError, match="not a callable object"):
134
  Tool.from_function(1) # type: ignore
 
483
  assert isinstance(result[0], AudioContent)
484
  assert result[0].data == "ZmFrZWF1ZGlvZGF0YQ=="
485
 
486
+ def test_file_object_result(self):
487
+ """Test that a File object is converted to EmbeddedResource with BlobResourceContents."""
488
+ file_obj = File(data=b"filedata", format="octet-stream")
489
+
490
+ result = _convert_to_content(file_obj)
491
+
492
+ assert isinstance(result, list)
493
+ assert len(result) == 1
494
+ assert isinstance(result[0], EmbeddedResource)
495
+ assert result[0].type == "resource"
496
+ assert hasattr(result[0], "resource")
497
+ resource = result[0].resource
498
+ assert resource.mimeType == "application/octet-stream"
499
+ # Check for blob attribute and its value
500
+ assert hasattr(resource, "blob")
501
+ assert getattr(resource, "blob") == "ZmlsZWRhdGE=" # base64 encoded "filedata"
502
+ # Convert URI to string for startswith check
503
+ assert str(resource.uri).startswith("file:///resource.octet-stream")
504
+
505
+ def test_file_object_text_result(self):
506
+ """Test that a File object with text data is converted to EmbeddedResource with TextResourceContents."""
507
+ file_obj = File(data=b"sometext", format="plain")
508
+ result = _convert_to_content(file_obj)
509
+ assert isinstance(result, list)
510
+ assert len(result) == 1
511
+ assert isinstance(result[0], EmbeddedResource)
512
+ assert result[0].type == "resource"
513
+ resource = result[0].resource
514
+ assert isinstance(resource, TextResourceContents)
515
+ assert resource.mimeType == "text/plain"
516
+ assert resource.text == "sometext"
517
+
518
  def test_basic_type_result(self):
519
  """Test that a basic type is converted to TextContent."""
520
  result = _convert_to_content(123)
 
621
  audio_item = next(item for item in result if isinstance(item, AudioContent))
622
  assert audio_item.data == "ZmFrZWF1ZGlvZGF0YQ=="
623
 
624
+ def test_list_of_mixed_types_with_file(self):
625
+ """Test that a list of mixed types including File is converted correctly."""
626
+ content1 = TextContent(type="text", text="hello")
627
+ file_obj = File(data=b"filedata", format="octet-stream")
628
+ basic_data = {"a": 1}
629
+ result = _convert_to_content([content1, file_obj, basic_data])
630
+
631
+ assert isinstance(result, list)
632
+ assert len(result) == 3
633
+
634
+ text_content_count = sum(isinstance(item, TextContent) for item in result)
635
+ embedded_content_count = sum(
636
+ isinstance(item, EmbeddedResource) and item.type == "resource"
637
+ for item in result
638
+ )
639
+
640
+ assert text_content_count == 2
641
+ assert embedded_content_count == 1
642
+
643
+ text_item = next(item for item in result if isinstance(item, TextContent))
644
+ assert text_item.text == '{\n "a": 1\n}'
645
+
646
+ embedded_item = next(
647
+ item
648
+ for item in result
649
+ if isinstance(item, EmbeddedResource) and item.type == "resource"
650
+ )
651
+ resource = embedded_item.resource
652
+ assert resource.mimeType == "application/octet-stream"
653
+ # Check for blob attribute and its value
654
+ assert hasattr(resource, "blob")
655
+ assert getattr(resource, "blob") == "ZmlsZWRhdGE="
656
+
657
  def test_empty_list(self):
658
  """Test that an empty list results in an empty list."""
659
  result = _convert_to_content([])
tests/utilities/test_types.py CHANGED
@@ -3,9 +3,11 @@ from types import EllipsisType
3
  from typing import Annotated, Any
4
 
5
  import pytest
 
6
 
7
  from fastmcp.utilities.types import (
8
  Audio,
 
9
  Image,
10
  find_kwarg_by_type,
11
  is_class_member_of_type,
@@ -300,6 +302,118 @@ class TestAudio:
300
  assert content.data == base64.b64encode(test_data).decode()
301
 
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  class TestFindKwargByType:
304
  def test_exact_type_match(self):
305
  """Test finding parameter with exact type match."""
 
3
  from typing import Annotated, Any
4
 
5
  import pytest
6
+ from mcp.types import BlobResourceContents, TextResourceContents
7
 
8
  from fastmcp.utilities.types import (
9
  Audio,
10
+ File,
11
  Image,
12
  find_kwarg_by_type,
13
  is_class_member_of_type,
 
302
  assert content.data == base64.b64encode(test_data).decode()
303
 
304
 
305
+ class TestFile:
306
+ def test_file_initialization_with_path(self):
307
+ """Test file initialization with a path."""
308
+ # Mock test - we're not actually going to read a file
309
+ file = File(path="test.txt")
310
+ assert file.path is not None
311
+ assert file.data is None
312
+ assert file._mime_type == "text/plain"
313
+
314
+ def test_file_initialization_with_data(self):
315
+ """Test initialization with data and format."""
316
+ test_data = b"test data"
317
+ file = File(data=test_data, format="octet-stream")
318
+ assert file.data == test_data
319
+ # The format parameter should set the MIME type
320
+ assert file._mime_type == "application/octet-stream"
321
+ assert file._name is None
322
+ assert file.annotations is None
323
+
324
+ def test_file_initialization_with_format(self):
325
+ """Test file initialization with a specific format."""
326
+ file = File(data=b"test", format="pdf")
327
+ assert file._mime_type == "application/pdf"
328
+
329
+ def test_file_initialization_with_name(self):
330
+ """Test file initialization with a custom name."""
331
+ file = File(data=b"test", name="custom")
332
+ assert file._name == "custom"
333
+
334
+ def test_missing_data_and_path_raises_error(self):
335
+ """Test that error is raised when neither path nor data is provided."""
336
+ with pytest.raises(ValueError, match="Either path or data must be provided"):
337
+ File()
338
+
339
+ def test_both_data_and_path_raises_error(self):
340
+ """Test that error is raised when both path and data are provided."""
341
+ with pytest.raises(
342
+ ValueError, match="Only one of path or data can be provided"
343
+ ):
344
+ File(path="test.txt", data=b"test")
345
+
346
+ def test_get_mime_type_from_path(self, tmp_path):
347
+ """Test MIME type detection from file extension."""
348
+ file_path = tmp_path / "test.txt"
349
+ file_path.write_text(
350
+ "test content"
351
+ ) # Need to write content for MIME type detection
352
+ file = File(path=file_path)
353
+ # The MIME type should be detected from the .txt extension
354
+ assert file._mime_type == "text/plain"
355
+
356
+ def test_to_resource_content_with_path(self, tmp_path):
357
+ """Test conversion to ResourceContent with path."""
358
+ file_path = tmp_path / "test.txt"
359
+ test_data = b"test file data"
360
+ file_path.write_bytes(test_data)
361
+
362
+ file = File(path=file_path)
363
+ resource = file.to_resource_content()
364
+
365
+ assert resource.type == "resource"
366
+ assert resource.resource.mimeType == "text/plain"
367
+ # Convert both to strings for comparison
368
+ assert str(resource.resource.uri) == file_path.resolve().as_uri()
369
+ if isinstance(resource.resource, BlobResourceContents):
370
+ assert resource.resource.blob == base64.b64encode(test_data).decode()
371
+
372
+ def test_to_resource_content_with_data(self):
373
+ """Test conversion to ResourceContent with data."""
374
+ test_data = b"test file data"
375
+ file = File(data=test_data, format="pdf")
376
+ resource = file.to_resource_content()
377
+
378
+ assert resource.type == "resource"
379
+ assert resource.resource.mimeType == "application/pdf"
380
+ # Convert URI to string for comparison
381
+ assert str(resource.resource.uri) == "file:///resource.pdf"
382
+ if isinstance(resource.resource, BlobResourceContents):
383
+ assert resource.resource.blob == base64.b64encode(test_data).decode()
384
+
385
+ def test_to_resource_content_with_text_data(self):
386
+ """Test conversion to ResourceContent with text data (TextResourceContents)."""
387
+ test_data = b"hello world"
388
+ file = File(data=test_data, format="plain")
389
+ resource = file.to_resource_content()
390
+ assert resource.type == "resource"
391
+ # Should be TextResourceContents for text/plain
392
+ assert isinstance(resource.resource, TextResourceContents)
393
+ assert resource.resource.mimeType == "text/plain"
394
+ assert resource.resource.text == "hello world"
395
+
396
+ def test_to_resource_content_error(self, monkeypatch):
397
+ """Test error case in to_resource_content."""
398
+ file = File(data=b"test")
399
+ monkeypatch.setattr(file, "path", None)
400
+ monkeypatch.setattr(file, "data", None)
401
+
402
+ with pytest.raises(ValueError, match="No resource data available"):
403
+ file.to_resource_content()
404
+
405
+ def test_to_resource_content_with_override_mime_type(self, tmp_path):
406
+ """Test conversion to ResourceContent with override MIME type."""
407
+ file_path = tmp_path / "test.txt"
408
+ test_data = b"test file data"
409
+ file_path.write_bytes(test_data)
410
+
411
+ file = File(path=file_path)
412
+ resource = file.to_resource_content(mime_type="application/custom")
413
+
414
+ assert resource.resource.mimeType == "application/custom"
415
+
416
+
417
  class TestFindKwargByType:
418
  def test_exact_type_match(self):
419
  """Test finding parameter with exact type match."""