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. --- README.md | 6 +- doc/Design.md | 39 ++-- doc/mcp.md | 42 ++++- pyproject.toml | 2 +- src/mml_composemusic_mcp/server.py | 323 +++++++++++++++++++++----------- src/mml_composemusic_mcp/synthesizer.py | 4 +- tests/conftest.py | 8 +- tests/test_compose_mml_tool.py | 32 ++-- tests/test_mcp_multimedia.py | 138 ++++++++++++++ tests/test_mml_conformance.py | 6 +- tests/test_server.py | 9 +- tests/test_templates.py | 6 +- 12 files changed, 458 insertions(+), 157 deletions(-) create mode 100644 tests/test_mcp_multimedia.py diff --git a/README.md b/README.md index 0d9c34a..bd55daa 100644 --- a/README.md +++ b/README.md @@ -159,9 +159,11 @@ MCPクライアントでは、たとえば次のように依頼できます。 } ``` -成功時は`success: true`、`wav_path`、`duration_sec`、解析済みの`note_sequence`を返します。指定した出力ディレクトリの下に生成時刻を表す`YYYYMMDD_HHMMSS_mmm`ディレクトリを作り、`output.wav`と、合成に使用したMML原文の`output.mml`を保存します。 +成功時は、MCPレスポンスの`content`に`audio/wav`の`AudioContent`を返します。WAVデータはbase64エンコードされ、ファイルサイズにかかわらず同じ形式で返されます。`structuredContent`には`success: true`、`duration_sec`、音声メタデータ、解析済みの`note_sequence`、検証結果が入ります。 -`wav_path`はMCPサーバが動作しているマシン上のローカルパスです。リモートのHTTPサーバとして運用する場合、このツール自体はWAVのダウンロード配信を行いません。 +指定した出力ディレクトリの下には、従来どおり生成時刻を表す`YYYYMMDD_HHMMSS_mmm`ディレクトリを作り、`output.wav`と合成に使用したMML原文の`output.mml`を保存します。この保存先はサーバ内部の情報であり、MCPレスポンスにはファイルパスを含めません。 + +MCPツールとしての成否は`isError`で判定します。compose、引数、actionなどのエラーは`isError: true`、正常に完了したvalidateは対象MMLが不正でも`isError: false`です。composeの`success`とvalidateの`valid`は、それぞれの処理結果を表す構造化フィールドです。 ## MMLの書き方 diff --git a/doc/Design.md b/doc/Design.md index 7c3d076..49543bf 100644 --- a/doc/Design.md +++ b/doc/Design.md @@ -139,21 +139,32 @@ MML文字列 ```json { - "success": true, - "wav_path": "./data/20260711_120000_123/output.wav", - "duration_sec": 2.0, - "note_sequence": { ... }, - "validation": { - "errors": [], - "warnings": [] - } + "content": [ + {"type": "text", "text": "{\"success\":true,...}"}, + {"type": "audio", "data": "UklGR...", "mimeType": "audio/wav"} + ], + "structuredContent": { + "success": true, + "audio": { + "mime_type": "audio/wav", + "size_bytes": 176444, + "sample_rate": 44100 + }, + "duration_sec": 2.0, + "note_sequence": { ... }, + "validation": { + "errors": [], + "warnings": [] + } + }, + "isError": false } ``` | フィールド | 型 | 説明 | |---|---|---| | `success` | boolean | 合成・WAV出力が成功したか | -| `wav_path` | string \| null | 生成されたWAVファイルパス(失敗時はnull) | +| `audio` | object \| null | MIMEタイプ、WAVのバイト数、サンプルレート。失敗時はnull | | `duration_sec` | number | 生成された音声の長さ(秒) | | `note_sequence` | object \| null | 中間表現(IR)のNoteSequence | | `validation.errors` | array | エラーのリスト | @@ -161,7 +172,11 @@ MML文字列 > `compose` は必ず検証を内包する。エラーがあればWAVは生成せず、検証結果のみ返す。 > -> **出力構成**: 生成時刻を表す `YYYYMMDD_HHMMSS_mmm` ディレクトリを作成し、WAVを `output.wav`、合成元のMML原文をUTF-8の `output.mml` として出力する。MCPの戻り値には従来どおりWAVのパスだけを含める。 +> **MCP出力**: 成功時はWAVをサイズにかかわらず`AudioContent`(`audio/wav`、base64)として返す。`ResourceLink`は使用せず、サーバ内部のファイルパスも公開しない。 +> +> **内部出力構成**: 生成時刻を表す `YYYYMMDD_HHMMSS_mmm` ディレクトリを作成し、WAVを `output.wav`、合成元のMML原文をUTF-8の `output.mml` として保存する。 +> +> **成否**: MCPツールの正式な成否は`CallToolResult.isError`で表す。composeおよびAPI入力の失敗は`true`、正常に完了したvalidateは対象MMLが不正でも`false`とし、妥当性は`valid`で表す。composeの`success`は構造化結果として残し、`success == !isError`を不変条件とする。 **`action: "validate"`** — MMLの構文チェックのみ(音声生成なし) @@ -687,6 +702,8 @@ NoteSequence IRを入力としてPulse1、Pulse2、Triangle、Noiseを合成し | 6 | Noise note_number → period マッピング | ppmck: `period=8, mode=0` 固定。pyxel: マッピング式適用 | | 7 | 出力構成 | `YYYYMMDD_HHMMSS_mmm/output.wav`, `YYYYMMDD_HHMMSS_mmm/output.mml` | | 8 | リピート安全上限 | 各トラックの展開後イベント数は100,000件以下。超過時は展開前に意味error | +| 9 | MCP音声応答 | WAVを常に`AudioContent`(`audio/wav`)で返し、`ResourceLink`とファイルパスは公開しない | +| 10 | MCPエラー判定 | 正式な成否は`CallToolResult.isError`。composeの`success`は構造化結果として同期させる | --- @@ -694,7 +711,7 @@ NoteSequence IRを入力としてPulse1、Pulse2、Triangle、Noiseを合成し | カテゴリ | 実装内容 | |---|---| -| MCPツール | `compose_mml`(compose/validate/template) | +| MCPツール | `compose_mml`(compose/validate/template)。`ToolResult`で構造化結果と`AudioContent`を返却 | | Lexer | ppmck/pyxel 両モード対応、共通トークン定義、INVALIDトークン。ppmck向けに `@v`/`@@`/`@MP`/`@EP`/`@EN`/`^`/`D`/`s`/`v+`/`v-` トークンを追加 | | Parser | ppmck/pyxel 別Parser。必須token、トップレベル順序、case、リピート対応を検証し、ppmckの`RepeatBreakStmt`と専用L文法を構築 | | SemanticAnalyzer | ppmck/pyxel別Analyzer、AST→IR変換。共通反復展開、事前イベント上限、値域→チャンネル→状態→IR順の検証、Lのloop_start算出を実装 | diff --git a/doc/mcp.md b/doc/mcp.md index a59c340..17661de 100644 --- a/doc/mcp.md +++ b/doc/mcp.md @@ -31,25 +31,47 @@ MML文字列を解析し、以下のいずれかの処理を実行します。 ### 戻り値 +ツール結果はMCPの`CallToolResult`として返されます。`content`には構造化結果をJSON化した`TextContent`が入り、`structuredContent`には同じ結果がJSONオブジェクトとして入ります。compose成功時だけ、`content`にWAV本体の`AudioContent`が追加されます。 + +MCPツール呼び出しの正式な成否は`isError`で表します。composeやAPI入力の失敗は`isError: true`、正常に完了した処理は`isError: false`です。validateは対象MMLにエラーがあっても検証処理自体は成功しているため`isError: false`となり、妥当性は`valid`で確認します。 + #### action=`compose` の場合 ```json { - "success": true, - "wav_path": "./data/20260711_120000_123/output.wav", - "duration_sec": 2.0, - "note_sequence": { ... }, - "validation": { - "errors": [], - "warnings": [] - } + "content": [ + { + "type": "text", + "text": "{\"success\":true,...}" + }, + { + "type": "audio", + "data": "UklGR...", + "mimeType": "audio/wav" + } + ], + "structuredContent": { + "success": true, + "audio": { + "mime_type": "audio/wav", + "size_bytes": 176444, + "sample_rate": 44100 + }, + "duration_sec": 2.0, + "note_sequence": { ... }, + "validation": { + "errors": [], + "warnings": [] + } + }, + "isError": false } ``` | フィールド | 型 | 説明 | |-----------|-----|------| | `success` | boolean | 合成・WAV出力が成功したか | -| `wav_path` | string \| null | 生成されたWAVファイルのパス(失敗時はnull) | +| `audio` | object \| null | MIMEタイプ、WAVのバイト数、サンプルレート。失敗時はnull | | `duration_sec` | number | 生成された音声の長さ(秒) | | `note_sequence` | object \| null | 中間表現(IR)のNoteSequence | @@ -59,6 +81,8 @@ MML文字列を解析し、以下のいずれかの処理を実行します。 | `validation.errors` | array | エラーのリスト | | `validation.warnings` | array | 警告のリスト | +WAVファイルのパスは公開しません。WAVはサイズにかかわらず`AudioContent`としてbase64エンコードして返し、`ResourceLink`への切り替えは行いません。サーバ内部では`--output-dir`以下へ`output.wav`と`output.mml`を保存します。 + #### action=`validate` の場合 ```json diff --git a/pyproject.toml b/pyproject.toml index e7032ee..328f6bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "MCP server for LLM-driven retro chiptune-style MML composition" readme = "README.md" requires-python = ">=3.14" dependencies = [ - "fastmcp>=2.1.0", + "fastmcp>=3.4.4,<4", "numpy>=2.0.0", ] diff --git a/src/mml_composemusic_mcp/server.py b/src/mml_composemusic_mcp/server.py index e3612da..f0c8bd1 100644 --- a/src/mml_composemusic_mcp/server.py +++ b/src/mml_composemusic_mcp/server.py @@ -1,10 +1,15 @@ # MCP server exposing compose_mml tool. import argparse +import json +from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path from fastmcp import FastMCP +from fastmcp.tools import ToolResult +from fastmcp.utilities.types import Audio +from mcp.types import TextContent from .ir import ErrorCode, ErrorDetail, ErrorPhase, NoteSequence from .lexer import tokenize @@ -19,6 +24,15 @@ mcp = FastMCP("mml-composemusic-mcp") OUTPUT_DIR = Path("./data") +@dataclass(frozen=True) +class ToolExecution: + """Internal result kept separate from the public MCP payload.""" + + result: dict + is_error: bool = False + wav_path: Path | None = None + + def _is_error(errors: list[ErrorDetail]) -> bool: return any(e.severity == "error" for e in errors) @@ -49,6 +63,33 @@ def _split_errors(errors: list[ErrorDetail]) -> tuple[list[dict], list[dict]]: return errs, warns +def _internal_error_execution() -> ToolExecution: + """Build a path-free MCP tool error for unexpected failures.""" + return ToolExecution( + { + "success": False, + "audio": None, + "duration_sec": 0, + "note_sequence": None, + "validation": { + "errors": [ + { + "code": ErrorCode.RUNTIME_INTERNAL_ERROR.value, + "phase": ErrorPhase.RUNTIME.value, + "line": 0, + "column": 0, + "message": "内部エラーが発生しました。", + "severity": "error", + "hint": "MMLの内容を確認の上、再度お試しください。", + } + ], + "warnings": [], + }, + }, + is_error=True, + ) + + def _parse_mml(mml: str, mode: str) -> tuple[dict | None, list[ErrorDetail]]: """Run lexer, parser, and semantic analyzer for the given mode.""" tokens = tokenize(mml, mode) @@ -82,104 +123,124 @@ def _parse_mml(mml: str, mode: str) -> tuple[dict | None, list[ErrorDetail]]: return (note_sequence.to_dict() if note_sequence else None), errors -@mcp.tool() -def compose_mml( +def _execute_compose_mml( action: str, mml: str = "", mode: str = "", template: str = "basic", sample_rate: int = 44100, normalize: bool = True, -) -> dict: - """Compose, validate, or generate templates for retro chiptune-style MML.""" +) -> ToolExecution: + """Execute a request without exposing internal file paths.""" if action == "template": if mode not in ("ppmck", "pyxel"): mode = "ppmck" if template not in TEMPLATES[mode]: template = "basic" mml_text, description = get_template(mode, template) - return {"mml": mml_text, "description": description} + return ToolExecution({"mml": mml_text, "description": description}) if action in ("compose", "validate"): if not mml or not mode: - return { - "success": False, - "valid": False, - "note_sequence": None, - "validation": { - "errors": [ - { - "code": ErrorCode.VALIDATION_MISSING_PARAMETER.value, - "phase": ErrorPhase.API.value, - "line": 0, - "column": 0, - "message": "mml と mode は compose/validate の必須パラメータです。", - "severity": "error", - "hint": "mml に MML 文字列、mode に 'ppmck' または 'pyxel' を指定してください。", - } - ], - "warnings": [], + return ToolExecution( + { + "success": False, + "valid": False, + "note_sequence": None, + "validation": { + "errors": [ + { + "code": ErrorCode.VALIDATION_MISSING_PARAMETER.value, + "phase": ErrorPhase.API.value, + "line": 0, + "column": 0, + "message": "mml と mode は compose/validate の必須パラメータです。", + "severity": "error", + "hint": "mml に MML 文字列、mode に 'ppmck' または 'pyxel' を指定してください。", + } + ], + "warnings": [], + }, }, - } + is_error=True, + ) try: note_sequence_dict, errors = _parse_mml(mml, mode) errors_list, warnings_list = _split_errors(errors) if action == "validate": - return { - "valid": not _is_error(errors), - "errors": errors_list, - "warnings": warnings_list, - "note_sequence": note_sequence_dict, - "channel_summary": build_channel_summary(note_sequence_dict) - if note_sequence_dict - else [], - } + return ToolExecution( + { + "valid": not _is_error(errors), + "errors": errors_list, + "warnings": warnings_list, + "note_sequence": note_sequence_dict, + "channel_summary": build_channel_summary(note_sequence_dict) + if note_sequence_dict + else [], + }, + is_error=mode not in ("ppmck", "pyxel"), + ) # compose if _is_error(errors): - return { - "success": False, - "wav_path": None, - "duration_sec": 0, - "note_sequence": note_sequence_dict, - "validation": {"errors": errors_list, "warnings": warnings_list}, - } + return ToolExecution( + { + "success": False, + "audio": None, + "duration_sec": 0, + "note_sequence": note_sequence_dict, + "validation": { + "errors": errors_list, + "warnings": warnings_list, + }, + }, + is_error=True, + ) ns = _dict_to_note_sequence(note_sequence_dict) try: wave_data, duration, synth_errors = synthesize( ns, mode, sample_rate, normalize ) - except Exception as exc: - return { - "success": False, - "wav_path": None, - "duration_sec": 0, - "note_sequence": note_sequence_dict, - "validation": { - "errors": [ - { - "code": ErrorCode.RUNTIME_SYNTHESIS_FAILED.value, - "phase": ErrorPhase.RUNTIME.value, - "line": 0, - "column": 0, - "message": f"音声合成中にエラーが発生しました: {exc}", - "severity": "error", - "hint": "MMLの内容を確認の上、再度お試しください。問題が続く場合は、短いMMLから試してください。", - } - ], - "warnings": warnings_list, + except Exception: + return ToolExecution( + { + "success": False, + "audio": None, + "duration_sec": 0, + "note_sequence": note_sequence_dict, + "validation": { + "errors": [ + { + "code": ErrorCode.RUNTIME_SYNTHESIS_FAILED.value, + "phase": ErrorPhase.RUNTIME.value, + "line": 0, + "column": 0, + "message": "音声合成中にエラーが発生しました。", + "severity": "error", + "hint": "MMLの内容を確認の上、再度お試しください。問題が続く場合は、短いMMLから試してください。", + } + ], + "warnings": warnings_list, + }, }, - } + is_error=True, + ) errors.extend(synth_errors) errors_list, warnings_list = _split_errors(errors) if _is_error(errors): - return { - "success": False, - "wav_path": None, - "duration_sec": 0, - "note_sequence": note_sequence_dict, - "validation": {"errors": errors_list, "warnings": warnings_list}, - } + return ToolExecution( + { + "success": False, + "audio": None, + "duration_sec": 0, + "note_sequence": note_sequence_dict, + "validation": { + "errors": errors_list, + "warnings": warnings_list, + }, + }, + is_error=True, + ) output_dir = _create_output_directory() wav_path = output_dir / "output.wav" wav_errors = write_wav(wav_path, wave_data, sample_rate) @@ -187,65 +248,111 @@ def compose_mml( if not _is_error(errors): try: (output_dir / "output.mml").write_text(mml, encoding="utf-8") - except Exception as exc: + except Exception: errors.append( ErrorDetail( code=ErrorCode.RUNTIME_MML_WRITE_FAILED, phase=ErrorPhase.RUNTIME, line=0, column=0, - message=f"MMLファイルの出力に失敗しました: {exc}", + message="MMLファイルの出力に失敗しました。", severity="error", hint="出力先を確認してください。", ) ) errors_list, warnings_list = _split_errors(errors) - return { - "success": not _is_error(errors), - "wav_path": str(wav_path) if not _is_error(errors) else None, + failed = _is_error(errors) + result = { + "success": not failed, + "audio": None + if failed + else { + "mime_type": "audio/wav", + "size_bytes": wav_path.stat().st_size, + "sample_rate": sample_rate, + }, "duration_sec": duration, "note_sequence": note_sequence_dict, "validation": {"errors": errors_list, "warnings": warnings_list}, } - except Exception as exc: - return { - "success": False, - "wav_path": None, - "duration_sec": 0, - "note_sequence": None, - "validation": { - "errors": [ - { - "code": ErrorCode.RUNTIME_INTERNAL_ERROR.value, - "phase": ErrorPhase.RUNTIME.value, - "line": 0, - "column": 0, - "message": f"内部エラーが発生しました: {exc}", - "severity": "error", - "hint": "MMLの内容を確認の上、再度お試しください。", - } - ], - "warnings": [], - }, - } + return ToolExecution( + result, + is_error=failed, + wav_path=None if failed else wav_path, + ) + except Exception: + return _internal_error_execution() - return { - "success": False, - "validation": { - "errors": [ - { - "code": ErrorCode.VALIDATION_INVALID_ACTION.value, - "phase": ErrorPhase.API.value, - "line": 0, - "column": 0, - "message": f"未知の action '{action}' です。", - "severity": "error", - "hint": "action は 'compose', 'validate', 'template' のいずれかを指定してください。", - } - ], - "warnings": [], + return ToolExecution( + { + "success": False, + "validation": { + "errors": [ + { + "code": ErrorCode.VALIDATION_INVALID_ACTION.value, + "phase": ErrorPhase.API.value, + "line": 0, + "column": 0, + "message": f"未知の action '{action}' です。", + "severity": "error", + "hint": "action は 'compose', 'validate', 'template' のいずれかを指定してください。", + } + ], + "warnings": [], + }, }, - } + is_error=True, + ) + + +def _build_tool_result(execution: ToolExecution) -> ToolResult: + """Convert an internal execution result to the public MCP response.""" + result = execution.result + if "wav_path" in result: + raise ValueError("Internal paths must not be included in MCP results") + if "success" in result and result["success"] is execution.is_error: + raise ValueError("success and is_error are inconsistent") + if execution.is_error and execution.wav_path is not None: + raise ValueError("Error results must not contain audio") + + content = [ + TextContent( + type="text", + text=json.dumps(result, ensure_ascii=False), + ) + ] + if execution.wav_path is not None: + content.append(Audio(path=execution.wav_path).to_audio_content()) + + return ToolResult( + content=content, + structured_content=result, + is_error=execution.is_error, + ) + + +@mcp.tool(name="compose_mml", output_schema=None) +def compose_mml( + action: str, + mml: str = "", + mode: str = "", + template: str = "basic", + sample_rate: int = 44100, + normalize: bool = True, +) -> ToolResult: + """Compose, validate, or generate templates for retro chiptune-style MML.""" + try: + execution = _execute_compose_mml( + action=action, + mml=mml, + mode=mode, + template=template, + sample_rate=sample_rate, + normalize=normalize, + ) + return _build_tool_result(execution) + except Exception: + return _build_tool_result(_internal_error_execution()) def _dict_to_note_sequence(data: dict) -> NoteSequence: diff --git a/src/mml_composemusic_mcp/synthesizer.py b/src/mml_composemusic_mcp/synthesizer.py index a289a1e..7085342 100644 --- a/src/mml_composemusic_mcp/synthesizer.py +++ b/src/mml_composemusic_mcp/synthesizer.py @@ -545,14 +545,14 @@ def write_wav(path: Path, wave_data: np.ndarray, sample_rate: int) -> list[Error wav.setsampwidth(2) wav.setframerate(sample_rate) wav.writeframes(pcm.tobytes()) - except Exception as exc: + except Exception: errors.append( ErrorDetail( code=ErrorCode.RUNTIME_WAV_WRITE_FAILED, phase=ErrorPhase.RUNTIME, line=0, column=0, - message=f"WAVファイルの出力に失敗しました: {exc}", + message="WAVファイルの出力に失敗しました。", severity="error", hint="出力先を確認してください。", ) diff --git a/tests/conftest.py b/tests/conftest.py index 7359a06..5854b1d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,5 +16,9 @@ def tmp_output_dir(tmp_path, monkeypatch): @pytest.fixture def compose_mml(tmp_output_dir): - """Return the compose_mml function with output dir patched.""" - return server_mod.compose_mml + """Return structured execution data with output dir patched.""" + + def execute(**kwargs): + return server_mod._execute_compose_mml(**kwargs).result + + return execute diff --git a/tests/test_compose_mml_tool.py b/tests/test_compose_mml_tool.py index c33e737..4f904e4 100644 --- a/tests/test_compose_mml_tool.py +++ b/tests/test_compose_mml_tool.py @@ -1,7 +1,5 @@ """Exhaustive tests for the compose_mml MCP tool.""" -from pathlib import Path - VALID_PPMCK = """#TITLE "Test" A t120 l4 o4 v15 q2 c e g c @@ -27,8 +25,10 @@ VALID_PYXEL = """0: T120 L4 O4 V100 @1 def test_compose_ppmck(compose_mml, tmp_output_dir): result = compose_mml(action="compose", mml=VALID_PPMCK, mode="ppmck") assert result["success"] is True - assert result["wav_path"] is not None - assert Path(result["wav_path"]).exists() + assert result["audio"]["mime_type"] == "audio/wav" + assert result["audio"]["size_bytes"] > 44 + assert "wav_path" not in result + assert list(tmp_output_dir.rglob("output.wav")) assert result["duration_sec"] > 0 assert result["note_sequence"] is not None assert "validation" in result @@ -39,8 +39,9 @@ def test_compose_ppmck(compose_mml, tmp_output_dir): def test_compose_pyxel(compose_mml, tmp_output_dir): result = compose_mml(action="compose", mml=VALID_PYXEL, mode="pyxel") assert result["success"] is True - assert result["wav_path"] is not None - assert Path(result["wav_path"]).exists() + assert result["audio"]["mime_type"] == "audio/wav" + assert "wav_path" not in result + assert list(tmp_output_dir.rglob("output.wav")) assert result["duration_sec"] > 0 @@ -95,7 +96,7 @@ def test_synthesis_templates_compose(compose_mml, tmp_output_dir): f"{mode}/{template} synthesis failed: {result['validation']}" ) assert result["duration_sec"] > 0 - assert Path(result["wav_path"]).exists() + assert result["audio"]["size_bytes"] > 44 def test_template_invalid_fallback(compose_mml): @@ -131,7 +132,8 @@ def test_compose_unknown_mode(compose_mml): def test_compose_with_syntax_error(compose_mml): result = compose_mml(action="compose", mml="A t0\n c", mode="ppmck") assert result["success"] is False - assert result["wav_path"] is None + assert result["audio"] is None + assert "wav_path" not in result assert result["duration_sec"] == 0 assert any(e["severity"] == "error" for e in result["validation"]["errors"]) @@ -168,8 +170,7 @@ def test_compose_normalize(compose_mml, tmp_output_dir): on = compose_mml(action="compose", mml=base, mode="pyxel", normalize=True) off = compose_mml(action="compose", mml=base, mode="pyxel", normalize=False) assert on["success"] and off["success"] - assert Path(on["wav_path"]).exists() - assert Path(off["wav_path"]).exists() + assert len(list(tmp_output_dir.rglob("output.wav"))) == 2 def test_compose_creates_timestamped_output_directory(compose_mml, tmp_output_dir): @@ -182,14 +183,9 @@ def test_compose_creates_timestamped_output_directory(compose_mml, tmp_output_di assert result1["success"] is True assert result2["success"] is True - path1 = result1["wav_path"] - path2 = result2["wav_path"] - assert path1 is not None - assert path2 is not None - assert path1 != path2, "Two compose calls produced the same output directory" - - p1 = Path(path1) - p2 = Path(path2) + paths = sorted(tmp_output_dir.rglob("output.wav")) + assert len(paths) == 2 + p1, p2 = paths assert p1.exists() assert p2.exists() assert p1.name == "output.wav" 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) diff --git a/tests/test_mml_conformance.py b/tests/test_mml_conformance.py index 82ec807..8c3822e 100644 --- a/tests/test_mml_conformance.py +++ b/tests/test_mml_conformance.py @@ -41,7 +41,11 @@ from mml_composemusic_mcp.parser_ppmck import parse_ppmck from mml_composemusic_mcp.parser_pyxel import parse_pyxel from mml_composemusic_mcp.semantic_ppmck import analyze_ppmck from mml_composemusic_mcp.semantic_pyxel import analyze_pyxel -from mml_composemusic_mcp.server import _parse_mml, compose_mml +from mml_composemusic_mcp.server import _execute_compose_mml, _parse_mml + + +def compose_mml(**kwargs): + return _execute_compose_mml(**kwargs).result def parse_pipeline(source: str, mode: str): diff --git a/tests/test_server.py b/tests/test_server.py index 533c88b..f858e0f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,6 +1,10 @@ """Tests for MCP server compose_mml tool.""" -from mml_composemusic_mcp.server import compose_mml +from mml_composemusic_mcp.server import _execute_compose_mml + + +def compose_mml(**kwargs): + return _execute_compose_mml(**kwargs).result PPMCK_MML = """#TITLE "Test" A t120 l4 o4 v15 q2 @@ -11,7 +15,8 @@ A t120 l4 o4 v15 q2 def test_compose_ppmck(): result = compose_mml(action="compose", mml=PPMCK_MML, mode="ppmck") assert result["success"] is True - assert result["wav_path"] is not None + assert result["audio"]["mime_type"] == "audio/wav" + assert "wav_path" not in result assert result["duration_sec"] > 0 diff --git a/tests/test_templates.py b/tests/test_templates.py index 5fcf893..d61f98a 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -2,9 +2,13 @@ import pytest -from mml_composemusic_mcp.server import compose_mml +from mml_composemusic_mcp.server import _execute_compose_mml from mml_composemusic_mcp.templates import DESCRIPTIONS, TEMPLATES, get_template + +def compose_mml(**kwargs): + return _execute_compose_mml(**kwargs).result + TEMPLATE_NAMES = ( "basic", "melody", -- cgit v1.2.3