CubaseをMCPから操作できたら面白そうだなと思って、Cubase MCPを作りました。

MCPクライアントから「再生して」「止めて」「今の状態を教えて」と指示すると、Cubaseのトランスポートを操作したり状態を取得したりできます。

まだCubaseのすべてを操作できるわけではありません。今回はまず、MCPとCubaseをつなぐところまでを小さく作ってみました。

何を作ったのか

実装はRustで書いたIntegration Daemonです。

MCPサーバーというと、MCPのToolを実装する部分だけを想像しがちですが、今回はその先にCubaseとの通信も必要になります。

そこで、MCPクライアントからの要求を受け取るデーモンと、Cubaseへ要求を届けるBridgeを分けました。

現在使えるToolは次の6個です。

MCP Toolできること
cubase.get_statusCubaseとの接続状態と基本的な状態を取得する
cubase.play再生を開始する
cubase.stop再生または録音を停止する
cubase.record録音を開始する
cubase.get_transport再生状態、録音状態、テンポ、演奏位置を取得する
cubase.get_capabilities接続中のBridgeが対応している機能を取得する

録音もToolとして用意していますが、これは本当に録音が始まります。録音先やRecord Enableの状態を確認してから実行する必要があります。

全体の構成

全体の流れは次のようになっています。

flowchart LR
    CLIENT["MCPクライアント"] -->|"stdio / JSON-RPC"| DAEMON["cubase_mcp<br/>Rust Integration Daemon"]
    DAEMON -->|"Bridge Protocol v1"| MIDI["MIDI / SysEx Bridge"]
    MIDI -->|"MIDI Remote API"| REMOTE["Cubase MIDI Remote script"]
    REMOTE --> CUBASE["Cubase"]

MCPクライアントとcubase_mcpの間は、改行区切りのJSON-RPCをstdioでやり取りします。

cubase_mcpの内部ではMCPのTool名を、transport.playtransport.getのようなCubase側のメソッドへ変換します。

その先のBridgeは、Cubaseと通信するための部分です。Bridgeの論理プロトコルはMCPとは別に定義していて、TCP、MIDI、モックのどれを使っても同じ要求と応答を扱えるようにしています。

実際のCubaseにはMIDI Bridgeを使います。

CubaseとはMIDI Remoteで通信する

Cubaseの中にはMIDI Remote APIがあります。

外部のプログラムからCubaseを直接操作するAPIを探すのではなく、Cubase内で動くMIDI Remoteスクリプトを用意して、そこをControl Bridgeにすることにしました。

MIDI RemoteスクリプトはCubaseのトランスポートへアクセスできます。

var transport = page.mHostAccess.mTransport

このスクリプトが外部から受け取った要求を、Cubaseの再生、停止、録音へ変換します。

macOSでは、デーモンが次の仮想MIDIポートを作ります。

Cubase MCP To Cubase
Cubase MCP From Cubase

Cubase側のMIDI Remoteはこのポートを検出します。外部のMIDIループバックドライバを別に用意しなくても動かせる構成です。

インストールしたMIDI Remoteスクリプトは、CubaseのMIDI Remote下ゾーンにCubaseMCP - CubaseMCPとして表示されます。

なぜSysExを使うのか

MIDIにはノートやControl Changeなど、音楽制作用のメッセージがいくつかあります。

今回は、MCPの要求と応答をJSONのまま運ぶ必要があるので、任意のデータを載せられるSysExを使います。

ただしMIDIのSysExには、データバイトを7bit以内に収める必要があります。JSONをUTF-8のバイト列にしただけでは、そのまま載せられない場合があります。

そこで、JSONの各バイトを上位4bitと下位4bitに分けて、2バイトとして格納しています。これなら各データバイトが7bit以内に収まります。

フレームは次のような形です。

F0 7D 43 4D 43 50 01 <payload> F7

payloadの中身は、Bridge Protocol v1のJSONです。

例えば再生要求は、論理的には次のようになります。

{
  "version": 1,
  "id": "req-123",
  "type": "request",
  "method": "transport.play",
  "params": {}
}

MIDIは単なるコマンド送信のためではなく、要求と応答を対応づけるための通信路として使っています。

コマンドを送っただけで成功にしない

再生要求をMIDIで送信できたからといって、Cubaseが実際に再生を開始したとは限りません。

そこでMIDI Remote側では、Cubaseのトランスポート状態をValue Bindingで監視しています。

playstoprecordは要求を受け取った時点で成功を返すのではなく、Cubase側の状態変化を確認してから応答します。

このあたりは最初、MIDIメッセージを送れば終わりだと思っていました。しかし実際には、送信できたこととCubaseが状態を変更したことは別です。

Bridgeからは、状態変更に失敗した場合や応答が返らなかった場合に、NOT_CONNECTEDTIMEOUTBUSYなどのエラーを返します。

取得できない値を適当に補完しないようにもしています。例えば、現時点でプロジェクトの有無をBridgeから取得できない場合は、falseと断定せずnullを返します。

Cubaseの複数インスタンスをどうするか

MIDIポートから応答が返ってきたとしても、それがどのCubaseインスタンスから来たものかを区別する必要があります。

MIDI Bridgeは最初にsystem.discoverを送信して、MIDI Remoteから一時的なinstance IDを取得します。その後の要求には対象のinstance IDを付けます。

複数のCubaseインスタンスが見つかった場合は、どれかを勝手に選びません。状態を変更する要求を間違ったプロジェクトへ送る可能性があるため、BUSYとして処理を止めます。

このへんは実装してみるまであまり考えていませんでしたが、再生や録音を扱う以上、間違った対象へ送らないことのほうが重要です。

インストールして使う

Rustのリリースビルドを作り、MIDI Remoteスクリプトをインストールします。

cargo build --release
./target/release/cubase_mcp --install-midi-remote

その後、MCPクライアントの設定へcubase_mcpを登録します。

{
  "mcpServers": {
    "cubase": {
      "command": "/absolute/path/to/cubase_mcp/target/release/cubase_mcp",
      "args": ["--bridge", "midi", "--timeout-ms", "3000"]
    }
  }
}

初回はIntegration Daemonを起動した状態でCubaseを起動します。

Cubaseを開いたままMIDI Remoteスクリプトをインストールまたは更新した場合は、MIDI RemoteのScripting ToolsからReload Scriptsを実行するか、Cubaseを再起動します。

実機で使う場合はmacOS/Linuxでは仮想MIDIポートを使えます。Windowsや既存のループバックMIDIポートを使う場合は、--midi-input--midi-outputでポートを指定できます。

まだできないこと

現状はトランスポート周りだけです。

次の機能はまだ未実装です。

  • トラック一覧の取得や選択
  • トラックのMute、Solo、Volume、Pan
  • マーカー操作
  • Cubaseコマンドの実行
  • VSTプラグインのパラメータ操作
  • オーディオ解析

最終的には、例えば「このトラックを選択して音量を少し下げる」や「この範囲を解析して」といった操作もできると便利そうです。

ただ、最初から全部を実装しようとすると、MCP、Cubase、MIDI Remote、非同期の状態取得のどこで問題が起きているのか分からなくなります。

そのため、まずは再生、停止、録音、状態取得という小さい範囲で、要求を送って応答を受け取るところまで作りました。