diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-09-13 14:12:41 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-09-13 14:13:04 +0900 |
| commit | 97b34627667fd699f4e0e054533eb89a205a2878 (patch) | |
| tree | 1d4549d56b7c53be9ecb583db9bb1a5593e54126 /src/mml_composemusic_mcp | |
| parent | 3b5bd2f8fd15bcc13542ff5ffd3c35453af9a9df (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.
Diffstat (limited to 'src/mml_composemusic_mcp')
| -rw-r--r-- | src/mml_composemusic_mcp/server.py | 323 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/synthesizer.py | 4 |
2 files changed, 217 insertions, 110 deletions
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="出力先を確認してください。", ) |
