少し前に話題になったdraw.ioのMCPサーバー
少しずつダイアグラムを生成する様子が、抜群におしゃれです。
動画を再生してね👇️
このおしゃれなやつ、やってみたいですよね?
検証のためにマーメイド記法の文字列を受け取り少しずつダイアグラムが生成されるものを作りました!
調べたら、なかなかワイルドな実装でした!
①MCPサーバーのツールを呼び出す処理フロー
普通のMCPサーバーのツール呼び出しのシーケンスはこんな感じです。
いってみればただのFunction Calling/Tool Useです。
ここまでは特に違和感無しですね。
②MCP Appsの処理フロー
冒頭の動画で紹介したものは MCP Apps (MCPの拡張仕様で、インタラクティブなUIを構築する機能)で実現しています。
MCP Apps公式サイト
MCP Apps仕様
MCPといえばツールのイメージが大半ですが、実はプロンプトやリソースを提供する仕様もあります。MCP Appsはツールとリソースを組み合わせて実現しています。
MCPツールの情報取得の際に「UIもあるよ」的なメタデータがついており、ここにリソースとしてHTMLのURIが指定されています。
MCPのホスト/クライアントがMCP Appsに対応している場合、このURIのリソースを取得したうえでiFrameで表示することで、インタラクティブUIを実現するのです。
iFrameを使っているのはセキュリティ的なことのようです
MCP Appsの場合の処理フローはこちら。
MCP Appの起動のところがちょっと長いですが、一旦無視しましょう。
MCPツールを実行した結果が、ui/notifications/tool-resultでiFrameに通知されます。iFrameでマーメイド記法の文字列を受け取り、UIに表示します。
具体的にはこんなJSONが通知されます。
{ "name": "render-mermaid", "arguments": { "diagram": "flowchart TD\n A[Start] --> B[End]" } }
通常のMCPツールの呼び出しの要所要所に、MCP Appのフローが追加されている感じですね。
でも、まぁまぁ複雑。
ただこのフローでは、 「リアルタイムにちょっとずつ描画される」は実現できておらず、 完成したダイアグラムが一気に表示されます。
では、リアルタイムに描画するにはどうするのでしょうか?
③MCP Appsのリアルタイム出力の処理フロー
ここでポイントなのは、 「ダイアグラムの文字列をいつ誰が生成しているか」 です。
DrawIO MCPサーバーの実装を確認したところ、実は 「ダイアグラムの文字列はLLMが生成している」 のです。そしてなんと、タイミングとしては、 ツール呼び出し前 です。
MCPサーバー側が賢く生成してるかと思ったらそうではないのです
ここ重要です。
それともう一つ大事なポイントが、LLMのストリームレスポンスは、 ツールのパラメーターをちょっとずつ生成する ということです。こんなイメージです。
delta: {"diag
delta: ram": "flow
delta: chart TD\n A[St
delta: art] --> B[End]"}
で、MCP AppsでUIが少しずつ描画されるのはこの仕組みを活用しています。
「LLMがちょっとずつ生成するツールのパラメーターを順次iFrameに通知する」 ということを行います。ツールのパラメーター全部の生成が終わる前にストリームしてiFrameに渡すのです。
文字にするとなんとも分かりづらいですが、こんなフローです。
また、追加で2つ気にすることがあります。
-
通知をするのはValidなJSONであることを期待する(MCP Appsの仕様)
実装としては、Vercel AI SDKの
parsePartialJsonを使い、不完全なJSONを修復します。パースに成功した場合のみiFrameに転送し、失敗した場合はスキップします。// basic-host/src/implementation.ts let partialJson = ""; for await (const part of result.fullStream) { switch (part.type) { case "tool-input-delta": partialJson += part.delta; { const { value, state } = await parsePartialJson(partialJson); if (value && (state === "successful-parse" || state === "repaired-parse")) { finalArgs = value as Record<string, unknown>; appBridge.sendToolInputPartial({ arguments: finalArgs }); } } break; } }実際のデータでは、こんな感じで補完されます。(parsePartialJsonすごい)
LLMからのdelta 累積文字列 parsePartialJsonの補完結果 {"di {"di {} agram": {"diagram": {} "sequenceD {"diagram": "sequenceD {"diagram":"sequenceD"} iagram\n {"diagram": "sequenceDiagram\n {"diagram":"sequenceDiagram\n "} actor User a {"diagram": "sequenceDiagram\n actor User a {"diagram":"sequenceDiagram\n actor User a"} s ユーザー\n {"diagram": "sequenceDiagram\n actor User as ユーザー\n {"diagram":"sequenceDiagram\n actor User as ユーザー\n "} participa ...ユーザー\n participa {"diagram":"...ユーザー\n participa"} nt Clie ...ユーザー\n participant Clie {"diagram":"...ユーザー\n participant Clie"} nt as クライアン ...participant Client as クライアン {"diagram":"...participant Client as クライアン"} -
中途半端なMermaidダイアグラム文字列を描画しようとするとエラーになるので、Mermaidダイアグラム文字列としてValidかチェックしてOKの場合だけ描画を更新する
iFrameでは、
mermaid.parse()で構文チェックし、問題ないときだけmermaid.render()を呼び出します。// basic-server-react/src/mcp-app.tsx const tryRenderPartial = useCallback(async (diagramSource: string) => { // 構文チェック(エラーを DOM に出さない) try { await mermaid.parse(diagramSource); } catch { return; // 構文エラー → スキップ、前の図を保持 } const id = `mermaid-${++renderIdRef.current}`; try { const { svg } = await mermaid.render(id, diagramSource); setSvg(svg); } catch { // parse は通ったが render に失敗した場合は無視 } }, []);
うーん、カオス
動画取ってみました
Mermaidにした影響だと思うのですが、あまりいい感じのストリーミングはしませんでした。(DrawIOはXMLで分量が多いですが、MermaidはMarkdownなので文字数が少ない)
まとめ
かなりローレベルな実装で実現されていることがわかりました。そのうち簡単に使えるフレームワークが出てくることを期待、ですね。