diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-07-11 21:48:34 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-07-11 21:48:34 +0900 |
| commit | 4ed98f4ec8d354f8fdb009f0b7829c2628adb2a6 (patch) | |
| tree | 07fd47ae951fa3ad776834e4876764adb09d5068 | |
| parent | 79cd969c98893e04382fe6075f420589c3a1652e (diff) | |
fix: stdio transport 起動時の TypeError 修正とトラック外コマンド警告の追加
| -rw-r--r-- | README.md | 364 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/ir.py | 1 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/parser_ppmck.py | 14 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/parser_pyxel.py | 14 | ||||
| -rw-r--r-- | src/mml_composemusic_mcp/server.py | 9 | ||||
| -rw-r--r-- | tests/test_error_codes.py | 13 | ||||
| -rw-r--r-- | tests/test_transports.py | 12 |
7 files changed, 394 insertions, 33 deletions
@@ -1,53 +1,359 @@ # mml-composemusic-mcp -ファミコン音源(2A03 APU)風のMMLをLLMに作曲させるためのMCPサーバ。 +ファミコン音源(2A03 APU)風のMMLをLLMに作曲させるためのMCPサーバです。 -## 機能 +`compose_mml` という1つのツールで、MMLの**作曲(WAV生成)**、**構文検証**、**テンプレート生成**を行えます。 -- `compose_mml` ツールを提供 -- ppmck 形式と pyxel 形式のMMLを解析・合成 -- MIDIノート番号に変換後、NES APU風の矩形波/三角波/ノイズでWAV出力 -- バリデーション、テンプレート生成に対応 +--- -## ツール引数 +## 目次 -| 引数 | 説明 | -|------|------| -| `action` | `compose`, `validate`, `template` のいずれか | -| `mml` | MML文字列(compose/validate時必須) | -| `mode` | `ppmck` または `pyxel`(compose/validate時必須) | -| `template` | `basic`, `melody`, `chord`, `drum`, `empty`(action=template時) | -| `sample_rate` | WAV出力サンプリングレート(デフォルト44100) | -| `normalize` | 出力振幅の正規化(デフォルトtrue) | +1. [機能概要](#機能概要) +2. [動作環境](#動作環境) +3. [インストール](#インストール) +4. [MCPクライアントへの登録](#mcpクライアントへの登録) +5. [ツールの使い方](#ツールの使い方) +6. [MML形式](#mml形式) +7. [テスト・開発](#テスト開発) +8. [ディレクトリ構成](#ディレクトリ構成) +9. [ライセンス](#ライセンス) -## 実行 +--- + +## 機能概要 + +| 機能 | 説明 | +|---|---| +| `compose` | MMLを解析し、NES APU風の矩形波/三角波/ノイズでWAVファイルを生成する | +| `validate` | MMLの構文を検証し、エラー・警告・チャンネル概要を返す | +| `template` | `ppmck` / `pyxel` 用のテンプレートMMLを生成する | + +### 対応MML形式 + +| モード | 特徴 | +|---|---| +| `ppmck` | PPMCKインスパイアのFC準拠形式。小文字コマンド、`A`/`B`/`T`/`N` トラック | +| `pyxel` | Pyxel MML準拠形式。大文字コマンド、`0:`〜`3:` トラック、リピート・ゲートタイム対応 | + +--- + +## 動作環境 + +- Python 3.14 以上 +- [uv](https://docs.astral.sh/uv/)(推奨) +- 依存パッケージ: `fastmcp`, `numpy` + +--- + +## インストール + +### 1. リポジトリをクローンまたは配置 ```bash -uv run mml-composemusic-mcp --output-dir ./data +cd C:\\path\\to\\mml-composemusic-mcp ``` -または +### 2. 依存関係をインストール + +`uv` を使う場合: ```bash -uv run python -m mml_composemusic_mcp.server --output-dir ./data +uv sync ``` -## テスト +`pip` を使う場合: + +```bash +pip install -e . +``` + +### 3. 起動確認 + +```bash +uv run mml-composemusic-mcp --help +``` + +以下のように表示されればOKです。 + +``` +usage: mml-composemusic-mcp [-h] [--output-dir OUTPUT_DIR] + [--transport {stdio,http,sse,streamable-http}] [--host HOST] [--port PORT] +``` + +--- + +## MCPクライアントへの登録 + +### Claude Desktop の場合 + +1. 設定ファイルを開きます。 + + | OS | パス | + |---|---| + | Windows | `%APPDATA%\Claude\claude_desktop_config.json` | + | macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` | + +2. `mcpServers` に以下を追加します。 + + ```json + { + "mcpServers": { + "mml-composemusic": { + "command": "uv", + "args": [ + "run", + "--project", + "C:\\path\\to\\mml-composemusic-mcp", + "mml-composemusic-mcp", + "--output-dir", + "C:\\path\\to\\mml-composemusic-mcp\\data" + ] + } + } + } + ``` + +3. Claude Desktop を再起動します。 + +### 設定ファイル例 + +[`doc/mcp-client-config-example.json`](doc/mcp-client-config-example.json) に設定例があります。必要に応じてコピーしてください。 + +### トランスポートの選択 + +| トランスポート | 用途 | 設定方法 | +|---|---|---| +| `stdio` | 標準的なMCPクライアント接続(デフォルト) | `command`/`args` で指定 | +| `http` | HTTPエンドポイント | `--transport http` で起動後、`url` で接続 | +| `sse` | Server-Sent Events | `--transport sse` で起動後、`url` で接続 | +| `streamable-http` | Streamable HTTP | `--transport streamable-http` で起動 | + +#### SSEで使う場合の例 + +```bash +uv run mml-composemusic-mcp --transport sse --port 8080 --output-dir ./data +``` + +```json +{ + "mcpServers": { + "mml-composemusic": { + "url": "http://127.0.0.1:8080/sse" + } + } +} +``` + +### 注意点 + +- `--output-dir` は相対パスでも動きますが、MCPクライアントの作業ディレクトリが不定なため、**絶対パスを推奨**します。 +- 初回起動時は `uv` が依存関係を解決するため、少し時間がかかることがあります。 + +--- + +## ツールの使い方 + +### ツール名 + +`compose_mml` + +### 引数 + +| 引数名 | 型 | 必須 | デフォルト | 説明 | +|---|---|---|---|---| +| `action` | string | yes | — | `compose`, `validate`, `template` のいずれか | +| `mml` | string | compose/validate時 | `""` | MMLソース文字列 | +| `mode` | string | compose/validate時 | `""` | `ppmck` または `pyxel` | +| `template` | string | template時 | `"basic"` | `basic`, `melody`, `chord`, `drum`, `empty` | +| `sample_rate` | integer | no | `44100` | 出力WAVのサンプリングレート(Hz) | +| `normalize` | boolean | no | `true` | 出力振幅を正規化するか | + +### action=`compose` — WAVを生成 + +```json +{ + "action": "compose", + "mml": "0: T120 L8 O4 V100 @1\n C D E F | G A B >C", + "mode": "pyxel", + "sample_rate": 44100, + "normalize": true +} +``` + +#### 戻り値 + +```json +{ + "success": true, + "wav_path": "./data/output.wav", + "duration_sec": 2.0, + "note_sequence": { ... }, + "validation": { + "errors": [], + "warnings": [] + } +} +``` + +- `success` が `false` の場合、`wav_path` は `null` になります。 +- エラーがある場合は `validation.errors` に詳細なエラー情報が入ります。 + +### action=`validate` — 構文チェック + +```json +{ + "action": "validate", + "mml": "A t120 l8 o4 v15 q2\n c d e f", + "mode": "ppmck" +} +``` + +#### 戻り値 + +```json +{ + "valid": true, + "errors": [], + "warnings": [], + "note_sequence": { ... }, + "channel_summary": [ + { + "channel": "Pulse1", + "note_count": 4, + "octave_range": [4, 5], + "duration_ticks": 768 + } + ] +} +``` + +### action=`template` — テンプレート生成 + +```json +{ + "action": "template", + "mode": "pyxel", + "template": "basic" +} +``` + +#### 戻り値 + +```json +{ + "mml": "0: T120 L8 O4 V100 @1\n C D E F | ...", + "description": "基本的な4ch構成(メロディ+和音+ベース+リズム)" +} +``` + +### テンプレート種別 + +| テンプレート | 内容 | +|---|---| +| `basic` | 基本的な4ch構成(メロディ+和音+ベース+リズム) | +| `melody` | メロディ重視(Pulse1主旋律、他は伴奏最小限) | +| `chord` | コード伴奏重視(Pulse2で和音、Triangleでベース) | +| `drum` | リズム重視(Noise中心のビートパターン) | +| `empty` | 各チャンネルのヘッダーのみ(空のテンプレート) | + +--- + +## MML形式 + +### ppmck 形式の例 + +``` +#TITLE "My Song" +#COMPOSER "LLM" + +A t150 l8 o4 v15 q2 + c d e f | g a b > c + +B l8 o3 v12 q1 + c r g r c r g r + +T l4 o3 v7 + c2 c2 g2 g2 + +N l8 v10 + r c r c r c r c +``` + +### pyxel 形式の例 + +``` +0: T150 L8 O4 V100 @1 + C D E F G A B >C + +1: L8 O3 V80 @2 + E G B R E G B R + +2: L4 O3 V60 + C2 G2 E2 C2 + +3: L8 V80 + C R C R C R C R +``` + +### 詳細仕様 + +詳細なMMLコマンド仕様、IR構造、エラーコードは [`doc/Design.md`](doc/Design.md) を参照してください。 + +--- + +## テスト・開発 + +### テスト実行 ```bash uv run pytest +``` + +### リント・フォーマット + +```bash uv run ruff check . uv run ruff format . ``` +### 手動でサーバを起動 + +```bash +uv run mml-composemusic-mcp --output-dir ./data +``` + +または: + +```bash +uv run python -m mml_composemusic_mcp.server --output-dir ./data +``` + +--- + ## ディレクトリ構成 -- `src/mml_composemusic_mcp/` - - `server.py` - MCPサーバ - - `lexer.py` - MML字句解析 - - `parser_ppmck.py`, `parser_pyxel.py` - 構文解析 - - `ir.py` - 中間表現・エラー型 - - `synthesizer.py` - APU風合成・WAV出力 - - `templates.py` - テンプレート -- `tests/` - テスト -- `doc/` - 設計書 +``` +. +├── doc/ # 設計書・設定例 +│ ├── Design.md # 統合設計書 +│ ├── mcp.md # MCPツールスキーマ +│ └── mcp-client-config-example.json # MCPクライアント設定例 +├── src/mml_composemusic_mcp/ # ソースコード +│ ├── server.py # MCPサーバ +│ ├── lexer.py # MML字句解析 +│ ├── parser_ppmck.py # ppmckパーサ +│ ├── parser_pyxel.py # pyxelパーサ +│ ├── parser_base.py # パーサ共通処理 +│ ├── ir.py # 中間表現・エラー型 +│ ├── synthesizer.py # APU風合成・WAV出力 +│ └── templates.py # テンプレート +├── tests/ # テスト +├── README.md # このファイル +└── pyproject.toml # プロジェクト設定 +``` + +--- + +## ライセンス + +(プロジェクトのライセンスをここに記載してください) diff --git a/src/mml_composemusic_mcp/ir.py b/src/mml_composemusic_mcp/ir.py index 42adc13..bd741a6 100644 --- a/src/mml_composemusic_mcp/ir.py +++ b/src/mml_composemusic_mcp/ir.py @@ -20,6 +20,7 @@ class ErrorCode(Enum): SYNTAX_EMPTY_TRACK = "SYNTAX_EMPTY_TRACK" SYNTAX_NOTE_OUT_OF_RANGE = "SYNTAX_NOTE_OUT_OF_RANGE" SYNTAX_CHANNEL_MISMATCH = "SYNTAX_CHANNEL_MISMATCH" + SYNTAX_OUTSIDE_TRACK = "SYNTAX_OUTSIDE_TRACK" SYNTAX_UNTERMINATED_HEADER = "SYNTAX_UNTERMINATED_HEADER" SYNTAX_UNDEFINED_REFERENCE = "SYNTAX_UNDEFINED_REFERENCE" diff --git a/src/mml_composemusic_mcp/parser_ppmck.py b/src/mml_composemusic_mcp/parser_ppmck.py index db91950..67e5093 100644 --- a/src/mml_composemusic_mcp/parser_ppmck.py +++ b/src/mml_composemusic_mcp/parser_ppmck.py @@ -62,6 +62,7 @@ class PpmckParser: self._parse_track_header() continue if self.current_channel is None: + self._warn_outside_track(token) self.ctx.advance() continue self._parse_statement() @@ -109,6 +110,19 @@ class PpmckParser: ) self.headers.append((key, value)) + def _warn_outside_track(self, token: Token) -> None: + if token.type in (TokenType.EOF, TokenType.COMMENT): + return + self.ctx.add_error( + code=ErrorCode.SYNTAX_OUTSIDE_TRACK, + line=token.line, + column=token.column, + message=f"'{token.raw}' はトラック外に書かれています。", + severity="warning", + hint="コマンドはトラックヘッダー(A, B, T, N)の後に記述してください。", + context=self._context_line(token), + ) + def _parse_track_header(self) -> None: token = self.ctx.advance() ch = token.value.upper() diff --git a/src/mml_composemusic_mcp/parser_pyxel.py b/src/mml_composemusic_mcp/parser_pyxel.py index 336a55e..f75c169 100644 --- a/src/mml_composemusic_mcp/parser_pyxel.py +++ b/src/mml_composemusic_mcp/parser_pyxel.py @@ -76,6 +76,7 @@ class PyxelParser: self._parse_track_header() continue if self.current_channel is None: + self._warn_outside_track(token) self.ctx.advance() continue self._parse_statement() @@ -102,6 +103,19 @@ class PyxelParser: return self.note_sequence, self.ctx.errors + def _warn_outside_track(self, token: Token) -> None: + if token.type == TokenType.EOF: + return + self.ctx.add_error( + code=ErrorCode.SYNTAX_OUTSIDE_TRACK, + line=token.line, + column=token.column, + message=f"'{token.raw}' はトラック外に書かれています。", + severity="warning", + hint="コマンドはトラックヘッダー(0:, 1:, 2:, 3:)の後に記述してください。", + context=self._context_line(token), + ) + def _parse_track_header(self) -> None: token = self.ctx.advance() ch = token.value diff --git a/src/mml_composemusic_mcp/server.py b/src/mml_composemusic_mcp/server.py index 3061e6d..f21159a 100644 --- a/src/mml_composemusic_mcp/server.py +++ b/src/mml_composemusic_mcp/server.py @@ -274,7 +274,14 @@ def main() -> None: args = parser.parse_args() global OUTPUT_DIR OUTPUT_DIR = Path(args.output_dir) - mcp.run(transport=args.transport, host=args.host, port=args.port) + if args.transport == "stdio": + mcp.run(transport="stdio") + else: + mcp.run( + transport=args.transport, + host=args.host, + port=args.port, + ) if __name__ == "__main__": diff --git a/tests/test_error_codes.py b/tests/test_error_codes.py index 496ce84..eca73ad 100644 --- a/tests/test_error_codes.py +++ b/tests/test_error_codes.py @@ -82,6 +82,19 @@ def test_syntax_unexpected_token(compose_mml): assert result["valid"] is True +# --- SYNTAX_OUTSIDE_TRACK --- +def test_syntax_outside_track_ppmck(compose_mml): + result = compose_mml(action="validate", mml="t120\nA l4\n c", mode="ppmck") + assert result["valid"] is True + assert _find_code(result, ErrorCode.SYNTAX_OUTSIDE_TRACK.value, "warnings") + + +def test_syntax_outside_track_pyxel(compose_mml): + result = compose_mml(action="validate", mml="T120\n0: L4\n C", mode="pyxel") + assert result["valid"] is True + assert _find_code(result, ErrorCode.SYNTAX_OUTSIDE_TRACK.value, "warnings") + + # --- SYNTAX_UNTERMINATED_REPEAT --- def test_syntax_unterminated_repeat_pyxel(compose_mml): result = compose_mml(action="validate", mml="0: T120 L4\n[ C D", mode="pyxel") diff --git a/tests/test_transports.py b/tests/test_transports.py index 395977b..a8bf12f 100644 --- a/tests/test_transports.py +++ b/tests/test_transports.py @@ -8,13 +8,15 @@ from mml_composemusic_mcp.server import main def test_transport_stdio_default(): - """Default transport should be stdio.""" + """Default transport should be stdio without host/port.""" with patch("sys.argv", ["server", "--output-dir", "./data"]): with patch("mml_composemusic_mcp.server.mcp.run") as mock_run: main() mock_run.assert_called_once() call_kwargs = mock_run.call_args assert call_kwargs[1]["transport"] == "stdio" + assert "host" not in call_kwargs[1] + assert "port" not in call_kwargs[1] def test_transport_http(): @@ -32,21 +34,25 @@ def test_transport_http(): def test_transport_sse(): - """SSE transport should be accepted.""" + """SSE transport should pass host and port.""" with patch("sys.argv", ["server", "--transport", "sse", "--port", "8080"]): with patch("mml_composemusic_mcp.server.mcp.run") as mock_run: main() call_kwargs = mock_run.call_args assert call_kwargs[1]["transport"] == "sse" + assert "host" in call_kwargs[1] + assert call_kwargs[1]["port"] == 8080 def test_transport_streamable_http(): - """Streamable HTTP transport should be accepted.""" + """Streamable HTTP transport should pass host and port.""" with patch("sys.argv", ["server", "--transport", "streamable-http"]): with patch("mml_composemusic_mcp.server.mcp.run") as mock_run: main() call_kwargs = mock_run.call_args assert call_kwargs[1]["transport"] == "streamable-http" + assert "host" in call_kwargs[1] + assert "port" in call_kwargs[1] def test_transport_invalid_rejected(): |
