From 97b34627667fd699f4e0e054533eb89a205a2878 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 13 Sep 2026 14:12:41 +0900 Subject: feat: return composed WAV as MCP audio content Replace the server-local wav_path result with a protocol-native multimedia response. - Split internal composition execution from MCP ToolResult serialization so filesystem paths never enter public payloads. - Return every successfully composed WAV as AudioContent with the audio/wav MIME type, regardless of file size. - Keep structured audio metadata, duration, note sequence, and validation details alongside the multimedia content. - Use CallToolResult.isError as the authoritative MCP execution status while retaining compose success and validate valid as domain fields. - Treat invalid compose/API requests and runtime failures as tool errors, while invalid MML discovered by validate remains a successful validation operation. - Sanitize synthesis, file-write, audio-serialization, and unexpected failure responses so internal paths and exception details are not exposed. - Add protocol-level coverage for base64 round trips, content block types, error semantics, ResourceLink absence, and path non-disclosure. - Update existing tests for the separated execution layer and path-free result contract. - Document AudioContent and isError behavior in the README, MCP schema, and design specification. - Require the tested FastMCP 3.4.4 API line and exclude the incompatible 4.x major version. Verification: 487 tests passed; ruff check passed; git diff --check passed; uv lock --check passed; and a manual MCP call returned TextContent plus AudioContent with no wav_path. --- tests/test_mcp_multimedia.py | 138 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/test_mcp_multimedia.py (limited to 'tests/test_mcp_multimedia.py') diff --git a/tests/test_mcp_multimedia.py b/tests/test_mcp_multimedia.py new file mode 100644 index 0000000..3baf155 --- /dev/null +++ b/tests/test_mcp_multimedia.py @@ -0,0 +1,138 @@ +"""Protocol-level tests for compose_mml multimedia results.""" + +import asyncio +import base64 +import json +from unittest.mock import patch + +from fastmcp import Client +from mcp.types import AudioContent, ResourceLink, TextContent + +from mml_composemusic_mcp.server import mcp + +VALID_MML = """A t120 l4 o4 v15 q2 + c e g c +""" + + +async def _call_tool(arguments, *, raise_on_error=True): + async with Client(mcp) as client: + return await client.call_tool( + "compose_mml", + arguments, + raise_on_error=raise_on_error, + ) + + +def test_compose_returns_audio_content_without_exposing_path(tmp_output_dir): + result = asyncio.run( + _call_tool({"action": "compose", "mml": VALID_MML, "mode": "ppmck"}) + ) + + assert result.is_error is False + assert result.structured_content["success"] is True + assert "wav_path" not in result.structured_content + assert result.structured_content["audio"] == { + "mime_type": "audio/wav", + "size_bytes": result.structured_content["audio"]["size_bytes"], + "sample_rate": 44100, + } + + audio_blocks = [block for block in result.content if isinstance(block, AudioContent)] + assert len(audio_blocks) == 1 + assert audio_blocks[0].mimeType == "audio/wav" + decoded = base64.b64decode(audio_blocks[0].data, validate=True) + assert len(decoded) > 100_000 + + wav_files = list(tmp_output_dir.rglob("output.wav")) + assert len(wav_files) == 1 + assert decoded == wav_files[0].read_bytes() + assert result.structured_content["audio"]["size_bytes"] == len(decoded) + assert not any(isinstance(block, ResourceLink) for block in result.content) + + text_blocks = [block for block in result.content if isinstance(block, TextContent)] + assert len(text_blocks) == 1 + assert json.loads(text_blocks[0].text) == result.structured_content + assert "wav_path" not in text_blocks[0].text + + +def test_compose_failure_is_mcp_tool_error_without_audio(tmp_output_dir): + result = asyncio.run( + _call_tool( + {"action": "compose", "mml": "A t0 c", "mode": "ppmck"}, + raise_on_error=False, + ) + ) + + assert result.is_error is True + assert result.structured_content["success"] is False + assert result.structured_content["audio"] is None + assert not any(isinstance(block, AudioContent) for block in result.content) + assert not list(tmp_output_dir.rglob("output.wav")) + + +def test_invalid_validate_result_is_not_mcp_tool_error(): + result = asyncio.run( + _call_tool({"action": "validate", "mml": "A t0 c", "mode": "ppmck"}) + ) + + assert result.is_error is False + assert result.structured_content["valid"] is False + assert not any(isinstance(block, AudioContent) for block in result.content) + + +def test_invalid_validate_mode_is_mcp_tool_error(): + result = asyncio.run( + _call_tool( + {"action": "validate", "mml": VALID_MML, "mode": "unknown"}, + raise_on_error=False, + ) + ) + + assert result.is_error is True + assert result.structured_content["valid"] is False + + +def test_unknown_action_is_mcp_tool_error(): + result = asyncio.run( + _call_tool({"action": "unknown"}, raise_on_error=False) + ) + + assert result.is_error is True + assert result.structured_content["success"] is False + + +def test_audio_serialization_failure_is_sanitized_tool_error(tmp_output_dir): + with patch( + "mml_composemusic_mcp.server.Audio.to_audio_content", + side_effect=OSError("C:\\private\\output.wav"), + ): + result = asyncio.run( + _call_tool( + {"action": "compose", "mml": VALID_MML, "mode": "ppmck"}, + raise_on_error=False, + ) + ) + + assert result.is_error is True + assert result.structured_content["success"] is False + assert result.structured_content["audio"] is None + assert "C:\\private" not in json.dumps(result.structured_content) + assert not any(isinstance(block, AudioContent) for block in result.content) + + +def test_unexpected_execution_failure_is_sanitized_tool_error(): + with patch( + "mml_composemusic_mcp.server._execute_compose_mml", + side_effect=OSError("C:\\private\\source.mml"), + ): + result = asyncio.run( + _call_tool( + {"action": "compose", "mml": VALID_MML, "mode": "ppmck"}, + raise_on_error=False, + ) + ) + + assert result.is_error is True + assert result.structured_content["success"] is False + assert "C:\\private" not in json.dumps(result.structured_content) -- cgit v1.2.3