summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-09-13 14:12:41 +0900
committertanaka takashi <isazaka@gmail.com>2026-09-13 14:13:04 +0900
commit97b34627667fd699f4e0e054533eb89a205a2878 (patch)
tree1d4549d56b7c53be9ecb583db9bb1a5593e54126
parent3b5bd2f8fd15bcc13542ff5ffd3c35453af9a9df (diff)
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.
-rw-r--r--README.md6
-rw-r--r--doc/Design.md39
-rw-r--r--doc/mcp.md42
-rw-r--r--pyproject.toml2
-rw-r--r--src/mml_composemusic_mcp/server.py323
-rw-r--r--src/mml_composemusic_mcp/synthesizer.py4
-rw-r--r--tests/conftest.py8
-rw-r--r--tests/test_compose_mml_tool.py32
-rw-r--r--tests/test_mcp_multimedia.py138
-rw-r--r--tests/test_mml_conformance.py6
-rw-r--r--tests/test_server.py9
-rw-r--r--tests/test_templates.py6
12 files changed, 458 insertions, 157 deletions
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",