Skip to content

Commit 992de10

Browse files
committed
feat: Added Audio to FastMCP
1 parent d28a1a6 commit 992de10

File tree

4 files changed

+123
-5
lines changed

4 files changed

+123
-5
lines changed

src/mcp/server/fastmcp/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from importlib.metadata import version
44

55
from .server import Context, FastMCP
6-
from .utilities.types import Image
6+
from .utilities.types import Audio, Image
77

88
__version__ = version("mcp")
9-
__all__ = ["FastMCP", "Context", "Image"]
9+
__all__ = ["FastMCP", "Context", "Image", "Audio"]

src/mcp/server/fastmcp/utilities/func_metadata.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
from mcp.server.fastmcp.exceptions import InvalidSignature
2323
from mcp.server.fastmcp.utilities.logging import get_logger
24-
from mcp.server.fastmcp.utilities.types import Image
24+
from mcp.server.fastmcp.utilities.types import Audio, Image
2525
from mcp.types import ContentBlock, TextContent
2626

2727
logger = get_logger(__name__)
@@ -480,6 +480,9 @@ def _convert_to_content(
480480
if isinstance(result, Image):
481481
return [result.to_image_content()]
482482

483+
if isinstance(result, Audio):
484+
return [result.to_audio_content()]
485+
483486
if isinstance(result, list | tuple):
484487
return list(
485488
chain.from_iterable(

src/mcp/server/fastmcp/utilities/types.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import base64
44
from pathlib import Path
55

6-
from mcp.types import ImageContent
6+
from mcp.types import AudioContent, ImageContent
77

88

99
class Image:
@@ -52,3 +52,52 @@ def to_image_content(self) -> ImageContent:
5252
raise ValueError("No image data available")
5353

5454
return ImageContent(type="image", data=data, mimeType=self._mime_type)
55+
56+
57+
class Audio:
58+
"""Helper class for returning audio from tools."""
59+
60+
def __init__(
61+
self,
62+
path: str | Path | None = None,
63+
data: bytes | None = None,
64+
format: str | None = None,
65+
):
66+
if path is None and data is None:
67+
raise ValueError("Either path or data must be provided")
68+
if path is not None and data is not None:
69+
raise ValueError("Only one of path or data can be provided")
70+
71+
self.path = Path(path) if path else None
72+
self.data = data
73+
self._format = format
74+
self._mime_type = self._get_mime_type()
75+
76+
def _get_mime_type(self) -> str:
77+
"""Get MIME type from format or guess from file extension."""
78+
if self._format:
79+
return f"audio/{self._format.lower()}"
80+
81+
if self.path:
82+
suffix = self.path.suffix.lower()
83+
return {
84+
".wav": "audio/wav",
85+
".mp3": "audio/mpeg",
86+
".ogg": "audio/ogg",
87+
".flac": "audio/flac",
88+
".aac": "audio/aac",
89+
".m4a": "audio/mp4",
90+
}.get(suffix, "application/octet-stream")
91+
return "audio/wav" # default for raw binary data
92+
93+
def to_audio_content(self) -> AudioContent:
94+
"""Convert to MCP AudioContent."""
95+
if self.path:
96+
with open(self.path, "rb") as f:
97+
data = base64.b64encode(f.read()).decode()
98+
elif self.data is not None:
99+
data = base64.b64encode(self.data).decode()
100+
else:
101+
raise ValueError("No audio data available")
102+
103+
return AudioContent(type="audio", data=data, mimeType=self._mime_type)

tests/server/fastmcp/test_server.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from mcp.server.fastmcp import Context, FastMCP
1111
from mcp.server.fastmcp.prompts.base import Message, UserMessage
1212
from mcp.server.fastmcp.resources import FileResource, FunctionResource
13-
from mcp.server.fastmcp.utilities.types import Image
13+
from mcp.server.fastmcp.utilities.types import Audio, Image
1414
from mcp.shared.exceptions import McpError
1515
from mcp.shared.memory import (
1616
create_connected_server_and_client_session as client_session,
@@ -194,6 +194,10 @@ def image_tool_fn(path: str) -> Image:
194194
return Image(path)
195195

196196

197+
def audio_tool_fn(path: str) -> Audio:
198+
return Audio(path)
199+
200+
197201
def mixed_content_tool_fn() -> list[ContentBlock]:
198202
return [
199203
TextContent(type="text", text="Hello"),
@@ -299,6 +303,27 @@ async def test_tool_image_helper(self, tmp_path: Path):
299303
# Check structured content - Image return type should NOT have structured output
300304
assert result.structuredContent is None
301305

306+
@pytest.mark.anyio
307+
async def test_tool_audio_helper(self, tmp_path: Path):
308+
# Create a test audio
309+
audio_path = tmp_path / "test.wav"
310+
audio_path.write_bytes(b"fake wav data")
311+
312+
mcp = FastMCP()
313+
mcp.add_tool(audio_tool_fn)
314+
async with client_session(mcp._mcp_server) as client:
315+
result = await client.call_tool("audio_tool_fn", {"path": str(audio_path)})
316+
assert len(result.content) == 1
317+
content = result.content[0]
318+
assert isinstance(content, AudioContent)
319+
assert content.type == "audio"
320+
assert content.mimeType == "audio/wav"
321+
# Verify base64 encoding
322+
decoded = base64.b64decode(content.data)
323+
assert decoded == b"fake wav data"
324+
# Check structured content - Image return type should NOT have structured output
325+
assert result.structuredContent is None
326+
302327
@pytest.mark.anyio
303328
async def test_tool_mixed_content(self):
304329
mcp = FastMCP()
@@ -371,6 +396,47 @@ def mixed_list_fn() -> list:
371396
# Check structured content - untyped list with Image objects should NOT have structured output
372397
assert result.structuredContent is None
373398

399+
@pytest.mark.anyio
400+
async def test_tool_mixed_list_with_audio(self, tmp_path: Path):
401+
"""Test that lists containing Audio objects and other types are handled
402+
correctly"""
403+
# Create a test audio
404+
audio_path = tmp_path / "test.wav"
405+
audio_path.write_bytes(b"test audio data")
406+
407+
def mixed_list_fn() -> list:
408+
return [
409+
"text message",
410+
Audio(audio_path),
411+
{"key": "value"},
412+
TextContent(type="text", text="direct content"),
413+
]
414+
415+
mcp = FastMCP()
416+
mcp.add_tool(mixed_list_fn)
417+
async with client_session(mcp._mcp_server) as client:
418+
result = await client.call_tool("mixed_list_fn", {})
419+
assert len(result.content) == 4
420+
# Check text conversion
421+
content1 = result.content[0]
422+
assert isinstance(content1, TextContent)
423+
assert content1.text == "text message"
424+
# Check audio conversion
425+
content2 = result.content[1]
426+
assert isinstance(content2, AudioContent)
427+
assert content2.mimeType == "audio/wav"
428+
assert base64.b64decode(content2.data) == b"test audio data"
429+
# Check dict conversion
430+
content3 = result.content[2]
431+
assert isinstance(content3, TextContent)
432+
assert '"key": "value"' in content3.text
433+
# Check direct TextContent
434+
content4 = result.content[3]
435+
assert isinstance(content4, TextContent)
436+
assert content4.text == "direct content"
437+
# Check structured content - untyped list with Audio objects should NOT have structured output
438+
assert result.structuredContent is None
439+
374440
@pytest.mark.anyio
375441
async def test_tool_structured_output_basemodel(self):
376442
"""Test tool with structured output returning BaseModel"""

0 commit comments

Comments
 (0)