桜も散ってしまい、センチメンタルな曲が聞きたくなりました。
Table of Contents
- 忙しい人向け
- はじめに
- MCP Appsとは
- MCP Appsのアーキテクチャとライフサイクル
- プログレッシブレンダリング
- Appクラスのフックでライフサイクルを管理する
- Viewからの双方向通信
- ツール結果のデータ経路とVisibility
- Claude.aiで使ってみる
- 最後に
忙しい人向け
今回作ったリモートMCPサーバーのURLはこちらです。
https://midi-mcp-server.tubone24.workers.dev
Claude.aiから使う場合は、 設定 → コネクタ → カスタムコネクタを追加 から、上記URLを貼り付けるだけで接続できます。認証なども不要です。
https://midi-mcp-server.tubone24.workers.dev
接続後の使い方や実際の動作イメージは Claude.aiで使ってみる セクションを参照してください。
はじめに
ちょうど1年くらい前に、MCPについてLTで登壇したことがありました(といっても浅い内容でお恥ずかしい限りですが)。
時が経つのは早いですね…。こわいです。
そのときはStdIOベースのMIDI MCP Serverを作ってClineと連携させる話だったのですが、MCPの世界はそこからさらに広がっていきました。
その1つが、2026年1月にリリースされたMCP Appsという拡張仕様です。
これは従来のテキスト応答に加えて、チャットUI上にインタラクティブなHTML画面を直接埋め込めるというものです。はじめて知ったときは、Googleが主導するA2UI(Agent-to-User Interface)と何が違うのか?と思って、自分の理解力では追いつけず、正直あまり向き合っていませんでした。
ですが、実際に触ってみるとこれがかなり面白いんですよね。
もともとギターを弾いていた(下手の横好きですが)こともあり、MIDIには馴染みがありました。MIDIファイルを生成するだけならSkillsでもできますが、AIが作曲した楽曲をその場でピアノロール譜面として可視化し、さらにUI上のボタンからサーバーのツールを呼び出したり、Claudeに「続きを作って」とリクエストしたりできるとしたら、MCP Appsの仕様を広くデモできるのでは…と考えたのがきっかけです。
そこで作った(大幅に作り変えた)のがmidi-mcp-serverです。自分の手作りで粗削りなところも多いですが、よければお付き合いください。

以下では、midi-mcp-serverの実装を題材にしつつ、MCP Appsの仕様を実際の画面とコードで追っていきます。
MCP Appsとは
従来のMCPツールとの違い
@modelcontextprotocol/sdkの server.registerTool() でMCPサーバーを作ったことがある方なら、MCPサーバーでツールを作り、AIエージェントが単なるテキスト生成を超えた仕事をこなすことができることは想像できるでしょう。
プレゼンテーションの資料を作り、出来上がった資料をメールに添付して送信…。なんてことも可能になるわけです。
しかし、この方式には限界があります。それは、MCPホスト上のチャットUIから体験が離れてしまうということです。
例えばデータの可視化をしたい場合、テキストで数値を並べても直感的ではありません。
チャートやグラフを使って表現したいですが、従来のチャットUIにそれらを表示させることは難しいので、生成したチャートやグラフを画像化し、それらをダウンロードさせて確認してもらう、という体験になります。
以前のmidi-mcp-serverやその進化系Skillsのmidi-agent-skillでも、生成したMIDIやWAVファイルをclaude.aiの画面からダウンロードし、それを自分で再生ソフトを用いて再生する、という体験になってました。

MCP Appsはこのような課題を解決するためのアイディアです。
MCPサーバーがサンドボックス化されたiframe内にインタラクティブなHTML UIを直接配信できる仕組みで、専用のAIエージェントを一から作らなくても、既存のチャットクライアント(Claude.aiなど)上で、シームレスにリッチな体験を作ることができます。
なぜWebアプリではなくMCP Appsなのか
「別にWebアプリを作ってリンクを送ればいいのでは?」という疑問もあるかもしれません。まぁそうですよね。
MCP Appsを使う利点は、会話のコンテキスト内にUIが存在する点にあります。
ユーザーはブラウザのタブを切り替えることなく、テキストでは表現しきれない情報にアクセスできます。さらにMCP Appsのiframeからサーバーのツールを呼び出したり、ホスト(Claude.ai)にメッセージを送信してモデルに再度推論を依頼できます。この一貫性が魅力なのです。
セキュリティ面では、サンドボックスiframeによりホスト側のDOM、Cookie、LocalStorageへのアクセスが制限されているため、サードパーティのMCPサーバーが提供するUIでも安全にレンダリングできるのもポイントです。
なお、今回の記事ではMCPホストはClaude.aiでの動作を前提に進めていきますので他のサービスで利用できるかは調査してないです。
MCP Appsのアーキテクチャとライフサイクル
MCP Appsのアーキテクチャを、midi-mcp-serverの動作を追いながら見ていきます。
全体の流れ
MCP Appsの動作は、大きく4つのフェーズに分けて理解できます(MCP Apps Specification(2026-01-26)より)。
まずConnection & Discoveryフェーズでは、ホスト(Claude.ai)がMCPサーバーに接続してツールリストを取得します。このとき、ツールの _meta.ui.resourceUri フィールドがあればこのツールはMCP Appsとしてレンダリング可能なUI付きツールと判断されます。
次のUI Initializationフェーズでは、ホストがサンドボックスiframeを作成し、 ui:// URIスキームで指定されたHTMLリソースをロードします。ここでホストとView間のハンドシェイクが行なわれます。ポイントは、ツールが実際に呼び出される前にUIリソースを事前に読み込めることです。(読み込むかどうかはMCPホスト次第ではあります)これが後述するプログレッシブレンダリングを可能にしています。
Interactive PhaseではLLMがツールを呼び出すと、ツール入力やツール結果がViewにプッシュされます。そしてLLMがまだツール引数を生成している途中でも、部分的なJSONがViewに逐次プッシュされます。
最後のCleanupフェーズでは、ホストがViewを破棄する前に onteardown フックを通じてクリーンアップの通知が送られます。
この一連の流れを今回のmidi-mcp-serverのシーケンス図で表すと次のようになります。
UIリソースの事前読み込み
ツールに _meta.ui.resourceUri を設定すると、ホストはツールの呼び出しを待たずにUIリソースを事前読み込みできます。midi-mcp-serverでは @modelcontextprotocol/ext-apps パッケージが提供する registerAppTool と registerAppResource でこの設定を行なっています(MCP Apps Build Guideより)。
const RESOURCE_URI = 'ui://midi-preview/app.html';
// UIリソース(バンドル済みHTML)を登録
registerAppResource(server, 'MIDI Preview', RESOURCE_URI, {}, async () => ({
contents: [{
uri: RESOURCE_URI,
mimeType: RESOURCE_MIME_TYPE,
text: builtHtml, // Viteでバンドルした単一HTMLファイル
}],
}));
// MCP Apps対応ツールを登録
registerAppTool(server, 'create_midi', {
title: 'Create MIDI',
description: 'Generate a MIDI file from structured composition data...',
inputSchema: {
title: z.string().describe('Title of the composition'),
composition: z.any().describe('Composition object with bpm, tracks...'),
},
outputSchema: {
midiBase64: z.string(),
title: z.string(),
bpm: z.number(),
trackCount: z.number(),
},
_meta: {
ui: { resourceUri: RESOURCE_URI },
},
}, async ({ title, composition: rawComposition }) => {
const composition = preprocessComposition(rawComposition);
const midiBase64 = generateMidiBase64(composition);
return {
content: [
{ type: 'text', text: `MIDI file "${title}" generated successfully.` },
],
structuredContent: {
midiBase64,
title,
bpm: composition.bpm,
trackCount: composition.tracks.length,
},
};
});
registerAppResource で登録したHTMLリソースは、ホストからのリクエストに応じて配信されます。このHTMLはViteのvite-plugin-singlefileで単一ファイルにバンドルされたもので、CSS・JavaScriptがすべてインラインに含まれています。
ちなみに、MCP Apps対応でないツールは従来どおり server.registerTool() で登録すればOKです。UIを持つツールだけ registerAppTool を使い分けます。
midi-mcp-serverではUIリソースとは別に、7つの音楽理論リソース(和声法、コード進行、対位法、モード・スケール、オーケストレーション、リズムパターン、ボイスリーディング)を server.registerResource() で登録しています。ここでいうResourceはMCP AppsのUIリソースではなく、MCPのプリミティブのリソースです(紛らわしい…。)。
これらはチャットUIから直接呼び出すことはできませんが、リソースUI(以降Viewと呼ぶ)のJavaScriptから readServerResource を使ってアクセスもできます。
例えば、Music Theory Referenceというパネルでは、7つの音楽理論リソースをタブ切り替えで参照できるようにしていますが、表示しているMarkdownの内容はMCPサーバーのリソース(プリミティブ)から取得したものです。

このように既存のMCPプリミティブとも組み合わせて使えるのもMCP Appsの魅力の1つです。
プログレッシブレンダリング
MCP Appsで一番「おお…」となる仕様がこれです。 自分の拙い説明で伝わるか不安ですが、がんばって書いてみます。
ご自身でWeb画面をもったAIエージェントアプリを作ったことがある方ならピンとくるかもしれませんが、AIがツール引数のJSONを生成している途中の段階をあたかも作っていますよ〜と可視化する体験って結構難しくないでしょうか?
テキストであれば、ストリーミングで逐次文字を出すことによって、それらは簡単に実現できますが、リッチなUIでこれを実現しようとすると難しさがあります。
なぜならLLMがツール引数のJSONを生成しているとき、まだJSONは途中までしかできていません。普通に考えれば構文エラーのJSONなのでパースできないはずです。なので、生成途中のJSONを使って何かをする、ということは基本的にはできないわけです。
ところがMCP Appsのホストは、この不完全なJSONを常にvalidな形にヒール(閉じられていない ] や } などのブラケット・ブレースを閉じて、構文的に有効なJSONを生成)して、Viewに逐次プッシュしてくれます。このLLMのストリーミングに合わせて段階的にUIを描画することをプログレッシブレンダリングと呼びます。
これが ontoolinputpartial フックで受け取れるデータです。
app.ontoolinputpartial = (params) => {
try {
const args = params.arguments as unknown as ToolInput;
if (args?.composition?.tracks) {
loadComposition(args); // 部分データでも描画を試みる
}
} catch (_e) {
// まだデータが不完全な場合は無視
}
};
midi-mcp-serverでは、AIが1つ目のトラックの音符を生成し始めた瞬間からピアノロールが描画され始め、音符が追加されるたびにリアルタイムで譜面が更新されていきます。テキストが1文字ずつ表示されるストリーミングのピアノロール版と思っていただければわかりやすいでしょう。

実装上の工夫として、 args?.composition?.tracks の存在チェックをしています。ヒール済みとはいえ、まだ tracks プロパティが存在しないタイミングもあるため、描画可能な状態になるまではスキップしています。
そして引数の生成が完了すると ontoolinput フックが発火し、完全なデータで最終描画とMIDI生成を行ないます。
app.ontoolinput = (params) => {
loadComposition(params.arguments as unknown as ToolInput, { generateMidi: true });
};
ontoolinputpartial との違いは { generateMidi: true } オプションです。部分データの段階ではMIDI生成を行なわず(どうせまだ不完全なので)、 ontoolinput で完全なデータが揃ってから生成する、という使い分けです。
プログレッシブレンダリングを実装するときの定型パターンとして覚えておくと便利です。
Appクラスのフックでライフサイクルを管理する
@modelcontextprotocol/ext-apps の App クラスには6つのフックがあり、 connect() を呼ぶ前にすべて登録する必要があります。先ほどの ontoolinputpartial と ontoolinput に加えて、残り4つのフックも見ていきましょう。
ontoolresult
サーバーでツールの実行が完了すると ontoolresult が発火します。midi-mcp-serverでは、サーバーが生成したMIDI base64データをここで取得しています。
app.ontoolresult = (params) => {
if (params.isError || currentMidiBase64) return;
// structuredContent から取得(LLMにトークンを消費させない)
const sc = params.structuredContent;
if (sc?.midiBase64 && typeof sc.midiBase64 === 'string') {
currentMidiBase64 = sc.midiBase64;
btnDownload.disabled = false;
return;
}
};
ここでのポイントは、サーバーが返したMIDI base64データを structuredContent 経由 で取得している点です。 content ではなく structuredContent を使うことで、LLMにbase64の大量トークンを消費させずにView側だけにデータを渡せるのがうれしいところです。 structuredContent の詳細は後述しますが、MIDIのbase64は数KB〜数十KBに及ぶため、 content で返してしまうとLLMが毎ターンこの塊を読み続けることになってしまい、もったいないわけです。
また、MCP AppsのViewはサンドボックスiframe内で動作するため、 <a download> のような通常のダウンロード手法が使えません。代わりに、ここで取得したBase64データを後述の app.downloadFile() に渡してホスト経由でダウンロードさせる仕組みになっています。

ontoolcancelledとonteardown
ユーザーがツール呼び出しをキャンセルしたり、チャットを閉じたりした場合のクリーンアップも重要です。
app.ontoolcancelled = (_params) => {
player.stop();
btnPlay.disabled = true; btnStop.disabled = true;
btnDownload.disabled = true;
statusText.textContent = 'Cancelled';
};
app.onteardown = (_params, _e) => {
player.destroy(); // AudioContext破棄、キャッシュクリア
return {};
};
ontoolcancelled はユーザーがツール呼び出しを中断した場合に発火し、 onteardown はホストがView自体を破棄する際に発火します。midi-mcp-serverでは音声再生の停止やAudioContextの破棄を行なっています。リソースリークを防ぐためにも、この2つのフックは忘れずに実装しておきたいところです。
onhostcontextchanged
ホスト環境が変化したとき(ダークモード/ライトモードの切り替え等)に発火するフックです。
app.onhostcontextchanged = (params) => {
if (params.context) {
applyHostStyleVariables(params.context);
applyDocumentTheme(params.context);
applyHostFonts(params.context);
if (params.context.displayMode) updateFsBtn(params.context.displayMode);
if (currentComposition) renderNotation(currentComposition, notationDiv);
}
};
@modelcontextprotocol/ext-apps が提供する applyHostStyleVariables ・ applyDocumentTheme ・ applyHostFonts を呼ぶだけでホストのスタイル変数・テーマ・フォントを反映できます。テーマが変わったらピアノロールも再描画して、グリッド線やテキストの色を合わせるようにしています。

さらに params.context.displayMode で現在の表示モード(後述する fullscreen 等)も取得できるため、表示モード変更時のUI更新もここで行なえます。
フック登録とconnect()
これらのフックはすべて app.connect() の前に登録する必要があります。 connect() が成功すると getHostContext() でホストの初期状態を取得できます。
const app = new App({ name: 'midi-preview', version: '0.3.0' }, {});
// ↑ この後にフックを登録(前述の6つ)
app.connect()
.then(() => {
const ctx = app.getHostContext();
if (ctx) {
applyHostStyleVariables(ctx);
applyDocumentTheme(ctx);
applyHostFonts(ctx);
}
})
.catch(() => {
statusText.textContent = 'Standalone mode — waiting for data…';
});
connect() が失敗した場合(スタンドアロンでHTMLを開いた場合など)のフォールバックも入れておくと開発時に便利です。
Viewからの双方向通信
MCP Appsが単なる表示するだけのUIではなくアプリケーションと呼ばれる所以が、この双方向通信の仕組みです。 App クラスはフック以外にも、View側からホストやサーバーと対話するためのメソッドを提供しています。
midi-mcp-serverではこれらのメソッドをフル活用(ただ使いたくて詰め込んだだけ)しているので、1つずつ実際の動作と合わせて見ていきましょう。
callServerTool
View側(iframe内のUI)からMCPサーバーのツールを直接呼び出せます。midi-mcp-serverでは、Chord Analyzerパネルでこの仕組みを使っています。
async function analyzeChord() {
const chord = chordInput.value.trim();
const octave = parseInt(chordOctave.value, 10);
try {
const result = await app.callServerTool({
name: 'parse_chord',
arguments: { chord, octave },
});
const data = JSON.parse(result.content[0].text);
chordResult.innerHTML = `
<div class="chord-name">${data.chord}</div>
<div class="chord-notes">Notes: <strong>${data.noteNames.join(' – ')}</strong></div>
<div class="chord-midi">MIDI: ${data.midiNumbers.join(', ')}</div>
`;
} catch (e) {
// エラー表示
}
}
ユーザーがChord Analyzerにコード名(例: Cmaj7)を入力してAnalyzeボタンを押すと、View側から parse_chord ツールが呼び出されます。このやり取りはLLMを介さず、View→サーバー間で直接行なわれるのがポイントです。LLMのターンを消費せず、レスポンスも高速です。

サーバー側の parse_chord ツールは通常の server.registerTool() で登録したMCP Apps非対応のツールプリミティブですが、View側から callServerTool で呼び出せます。つまり、UIを持たない既存のツールでもView側から利用できるので再利用性が高いです。
readServerResource
MCPサーバーが公開しているプリミティブのリソースをMCP AppsのView側から直接読み取ることもできます。前述しましたがmidi-mcp-serverでは、サーバーに登録した7つの音楽理論リソースをMusic Theory Referenceパネルから参照できるようにしています。
theorySelect.addEventListener('change', async () => {
const uri = theorySelect.value; // e.g. 'music-theory://harmony'
if (!uri) return;
try {
const result = await app.readServerResource({ uri });
theoryContent.innerHTML = renderMarkdown(result.contents[0]?.text ?? '');
} catch (e) {
theoryContent.innerHTML = `<p class="error">Failed: ${e.message}</p>`;
}
});
サーバー側では server.registerResource() で通常のMCPプリミティブのリソースとして登録しているだけです。
for (const res of MUSIC_THEORY_RESOURCES) {
const content = loadResource(res.file);
server.registerResource(
res.name,
res.uri, // e.g. 'music-theory://harmony'
{ description: res.description, mimeType: 'text/markdown' },
async () => ({
contents: [{ uri: res.uri, mimeType: 'text/markdown', text: content }],
})
);
}
これは registerAppResource(UIリソース用)とは別で、通常のMCPリソース登録です。MCP Appsのツールでないリソースでも、リソースUI側から readServerResource で読めるということを意味しています。チャット画面を離れることなく、AI作曲のバックグラウンド知識を参照できるわけです。
sendMessage
sendMessage はチャットにメッセージを送信し、モデルに即座に新しいターンを開始させるメソッドです。midi-mcp-serverではContinueボタンで使っています。
btnAskClaude.addEventListener('click', async () => {
if (!currentComposition) return;
const title = titleEl.textContent || 'this piece';
const bpm = currentComposition.bpm;
const tracks = currentComposition.tracks.map((t) => t.name || 'unnamed').join(', ');
await app.sendMessage({
role: 'user',
content: [{
type: 'text',
text: `Please continue "${title}" by adding 8 more bars. ` +
`Keep the same key, tempo (${bpm} BPM), and style. Tracks: ${tracks}.`,
}],
});
});
ボタンを押すと、現在の楽曲情報を含んだメッセージがClaudeに送られ、Claudeが新しいターンとして続きの8小節を作曲し始めます。UIの操作がそのままAIへの指示になるという、MCP Appsならではの体験です。

downloadFileとrequestDisplayMode
先述の ontoolresult で触れたとおり、MCP AppsのViewはサンドボックスiframe内で動作するため、通常の <a download> によるファイルダウンロードが使えません。 downloadFile はこの制約を回避するメソッドで、ホストにBase64データを渡して代わりにダウンロードしてもらう仕組みです。midi-mcp-serverではMIDIファイルのダウンロードに使っています。
await app.downloadFile({
contents: [{
type: 'resource',
resource: {
uri: `file:///${filename}`,
mimeType: 'audio/midi',
blob: currentMidiBase64,
},
}],
});
requestDisplayMode はViewの表示モードを切り替えるメソッドです。midi-mcp-serverでは全画面表示の切り替えに使っています。 getHostContext() で現在の表示モードと利用可能なモードを取得し、 inline と fullscreen を切り替えます。ピアノロールのような視覚的なUIは全画面で見たくなるので、地味にこの機能はありがたいです。
btnFullscreen.addEventListener('click', async () => {
const ctx = app.getHostContext();
const current = ctx?.displayMode ?? 'inline';
const next = current === 'fullscreen' ? 'inline' : 'fullscreen';
await app.requestDisplayMode({ mode: next });
});

ここまで見てきたように、 App クラスのメソッドにより、MCP Appsはホスト・サーバー・Viewの三者間で双方向にデータをやり取りできるアプリケーション基盤として機能します。
ツール結果のデータ経路とVisibility
content, structuredContent, _metaの使い分け
MCP Appsのツール結果には3つのデータ経路があり、用途によって使い分けます(MCP Apps Build Guideより)。
content はLLMに公開されるデータです。テキストレスポンスや生成結果のサマリーなど、モデルが次のターンで参照するべき情報を入れます。midi-mcp-serverでは MIDI file generated successfully. 2 track(s), 120 BPM. のようなテキストを返しています。
structuredContent はViewのレンダリング用に渡す構造化データです。ホストの実装によってはLLMのコンテキストに渡らないよう制御されるため、大量のデータをUIに渡しつつトークン消費を抑えたい場合に便利です。midi-mcp-serverでも、まさにこの仕組みを使ってMIDI base64データをViewに渡しています。MIDIのbase64は数KB〜数十KBに及ぶため、これを content で返してしまうとLLMが大量のトークンを消費してしまいますが、 structuredContent 経由ならダウンロード用にView側でのみ利用できます。波形データや詳細な分析結果など、人間向けの表示データを大量に渡すケースでも同様に活用できます。

structuredContent を返すには、ツール定義時に outputSchema を宣言しておく必要があります。先ほどの registerAppTool の例でも、 midiBase64 ・ title ・ bpm ・ trackCount のスキーマを定義しておくことで、型付きの structuredContent を返せるようにしています。
_meta はタイムスタンプやバージョンなどのメタデータ用で、LLMには非公開です。
この使い分けは、MCP Appsを設計するうえでかなり重要です。UIの表示にしか使わないデータは structuredContent に逃がすのが定番で、 content に大量のデータを詰めるとトークンを消費してしまいます。
Claude.aiで使ってみる
midi-mcp-serverをClaude.aiから使うには、設定画面でリモートMCPサーバーのURLを追加するだけです。
https://midi-mcp-server.tubone24.workers.dev

接続ができたら、あとはシンプルに頼むだけです。
最近流行りのJ-POPをMIDIで作って
これだけで、AIが create_midi ツールを呼び出し、BPM、トラック、ノート情報を構造化データとして生成し始めます。
もし、もう少し音楽理論に沿って作曲してほしければ、MCPのリソースプリミティブで展開されているMusic Theory Referenceを+ボタンから選択し、コンテキストに追加するのも便利です。

そして、AIがツール引数のJSONを生成している最中、 ontoolinputpartial フックによって部分的なデータがViewに逐次送られます。
最初のトラックの最初の数音が生成された時点で、ピアノロール上にノートが現れ始めます。AIが音符を追加するたびに譜面がリアルタイムに更新されていく様子は、未来を感じざるをえません(あばれるくん)!

トラック情報パネルにはトラック名、GM instrument番号、ノート数が表示されるので、AIがどんな構成で楽曲を作ったのかも一目でわかります。
Chord Analyzerでコードの構成音を調べたり、Music Theory Referenceで和声法やコード進行の基本を参照しながら、Continueボタンで「あと8小節追加して」とClaudeにリクエストする…という一連の流れが、すべてチャット画面を離れることなく完結します。
これがMCP Appsの魅力なのです。(ぜひ使ってみてください)
最後に
MCP Appsを使って、AIが作曲したMIDI楽曲をピアノロール譜面としてリアルタイムに可視化するMCPサーバーを作ってみました。
正直、 ontoolinputpartial による段階的な譜面描画は、想像していた以上に「おお…」となる体験でした。テキストのストリーミング表示とはまた違った没入感があります。そして callServerTool や sendMessage を組み合わせることで、単なるビジュアライザーではなく、サーバーのツールを呼び出したりモデルに再指示を出したりできるインタラクティブなアプリケーションになるのは、MCP Appsの本領発揮だと感じます。
@modelcontextprotocol/ext-apps のexamplesディレクトリを眺めると、3DモデルビューアやPDFリーダー、QRコード生成器など、すでに面白い事例がたくさん出てきています。MCP Appsはまだ登場して間もない仕様ですが、自分のような実装力に自信のない人間でもそれなりに形にできたので、データ可視化やフォーム入力、メディアプレビューなど、テキストだけでは表現しきれないユースケースに可能性を感じます。
自分の理解がまだまだ浅いのはさておき、MCPもまた触ると楽しいですね、というこの頃です。

