🔥

React不要。HonoだけでOpenAI Apps SDKのウィジェット構築

に公開

はじめに

みなさん、OpenAI Apps SDK 試したことはありますか?

プレビュー版、そして発表当初以降は盛り上がってはいないという印象があるので、あまり多くないかもなと思っていますがどうでしょうか?
(ZennでApps SDKで検索して観測した感じもクローン以外で、開発を試してみているのも2件くらい?👀)
https://zenn.dev/search?q=Apps%2520SDK

個人的にはここからのMCP UIの広がりにも期待しているのもあって、ぜひみなさんにも試してみてほしいなと思っています(参考としてもいろんなデモを見たい気持ちもある)

そこで今回は、いつかの正式リリースに向けて自分の取り組んでみたことを紹介してみつつ、
Apps SDK開発のハードルを下げる一助になればと思い記事にしてみました!

これからApps SDKの構築をしてみようと考えている方の参考になれば幸いです!

ちなみに

自分が手を出してみようと思ったきっかけはフロントエンドカンファレンス北海道2025でのYusukebeさんの発表でした。
まず、MCP UI自体のことをさらっと知りたい方はこちらからご確認ください!

https://speakerdeck.com/yusukebe/aishi-dai-nouihadokohexing-ku

概要

忙しい人のための概要

  1. まず思った「なんか公式サンプル面倒だなぁ」
    • PythonやNode.jsでMCPサーバー立てて、Reactでクライアント作って・・・(サンプルで色んな例を示しているのだからそういうもの)
    • とはいえ依存関係シンプルにならないかな?
  2. 次に考えた「HonoならJSX書けるしシンプルに構成できそうじゃない?」
    • HonoでMCPサーバーを構築したことはあったし、hono/jsxも使ったことあった
    • のでAIにも相談しつつ、構築したらなんかうまくいった(Honoエコシステムのおかげ)

サーバー側で Model Context Protocol (MCP) を実装し、クライアント側でリッチな UI を提供するこの仕組みは非常に強力ですが、まともに作ろうとすると構成が複雑になりそう...「もっとシンプルに、使い慣れた道具だけで作りたい」

そう思って、Honoのエコシステム (hono, hono/jsx, hono/jsx/dom) だけを使って、サーバーからクライアントまで一気通貫で実装するアプローチでやってみました。

サンプルリポジトリはこちら。

https://github.com/ts-76/hono-apps-sdk

なぜHonoなのか?

結論から言うと、Honoという単一のコンテキスト、単一の言語(TypeScript)で完結できるからです(Honoバンザイ🙌)。

All-in-Oneのシンプルさ

Honoはコア機能としてJSXをサポートしていますし、素晴らしいコントリビューターの方々によって豊富なミドルウェアが提供されています。
それらによってシンプルにMCPサーバーとクライアントUIを構築できました!

  1. MCPサーバー: @hono/mcpStreamableHTTPTransport を使い、簡単にMCPサーバーを構築
  2. クライアントUI(ウィジェット): hono/jsx/dom を使い、ReactライクなUIを軽量に実装
  3. アセットサーバー: サーバーとしてクライアントUI用のウィジェット(HTML/JS/CSS)を配信する。

React不要のモダンUI

特に、hono/jsxhono/jsx/domの存在が今回のアプローチを可能にしました🙏

Honoには hono/jsx(サーバー用)と**hono/jsx/dom**(クライアント用)があります。これらはReact互換でありながら非常に軽量です。
特に今回のデモ程度ならそんなにReact内蔵のリッチな機能は必要ないので、使わずに済むのはサイズ面でも依存関係面でも大きなメリットでした!
(デモ程度ならサイズ気にしなくても...というのは置いておきます)

詳しいことはこちらをチェック
https://zenn.dev/yusukebe/articles/b20025ebda310a#client-components
https://hono.dev/docs/guides/jsx-dom

依存関係が超シンプル

そんなこんなでHonoをベースとした依存関係に収まります。超スッキリ👏
これで一応はApps SDKが動かせると考えると素敵ですよね!

{
  "dependencies": {
    "@hono/mcp": "x.x.x",
    "@modelcontextprotocol/sdk": "x.x.x",
    "hono": "x.x.x",
    "zod": "x.x.x"
  },
  "devDependencies": {
    "@types/node": "x.x.x",
    "tsx": "x.x.x",
    "typescript": "x.x.x",
    "vite": "x.x.x",
    "wrangler": "x.x.x"
  }
}

プロジェクト構成

実際のプロジェクト構造を見てみましょう。役割ごとにディレクトリを分けています。

hono-todo/
├── src/
│   ├── server.tsx          # エントリーポイント(Hono + MCP)
│   ├── server/
│   │   ├── assets.ts       # ビルド済みアセットの読み込み
│   │   ├── store.ts        # Todoデータのインメモリストア
│   │   └── widget.tsx      # ウィジェットHTML生成(rootとなるウィジェット)
│   ├── shared/
│   │   └── types.ts        # サーバー・クライアント共通の型定義(Todo等)
│   ├── widget/
│   │   ├── main.tsx        # クライアントエントリー
│   │   ├── controller.tsx  # 状態管理・イベントハンドリング
│   │   ├── types.ts        # Apps SDK型定義(OpenAiGlobals等)
│   │   ├── components/
│   │   │   └── TodoListView.tsx  # 表示専用コンポーネント
│   │   └── lib/
│   │       ├── helpers.ts        # ペイロード解析ユーティリティ
│   │       ├── types.ts          # ウィジェット内部の型定義
│   │       ├── use-openai-global.ts  # window.openai同期フック
│   │       └── use-widget-state.ts   # ウィジェット状態管理フック
│   └── styles/
│       └── todo.css        # ウィジェットのスタイル
├── public/                  # 静的ファイル
├── vite.config.ts           # クライアントビルド設定
└── wrangler.jsonc           # Cloudflare Workers設定

サーバーとクライアントの分離

src/server/ 配下がサーバーサイド(Node.js / Cloudflare Workers)で動作するコード、src/widget/ 配下がブラウザで動作するクライアントコードです。

この分離により、Vite でクライアントをビルドし、Workers がそのビルド成果物を配信するという構成が可能になっています。

動作の外観

この構成で作成した Todo アプリの動作フローは以下のようになります。
Honoだけ、とてもシンプルですね!

MCPサーバーとクライアントの動作フローMCPサーバーとクライアントの動作フロー(Nano Banana 作)

ユーザー操作時のフロー

Server Side: Hono × MCP の統合

Hono 上で MCP サーバーを動かすのは非常に簡単です。@hono/mcp パッケージを使用します。

https://www.npmjs.com/package/@hono/mcp

基本構成

src/server.tsx
import { Hono } from 'hono';
import { StreamableHTTPTransport } from '@hono/mcp';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

const app = new Hono();

// /mcp エンドポイントにマウント
app.all('/mcp', async (c) => {
  const transport = new StreamableHTTPTransport();
  const server = createTodoServer();

  transport.onclose = async () => await server.close();
  await server.connect(transport);

  // Hono の Request/Response を MCP Transport が処理
  return transport.handleRequest(c);
});

export default app;

これだけで、通常の Web サーバーとしての機能(app.get('/', ...) など)を維持したまま、/mcp で MCP サーバーとして振る舞うことができます。

MCPツールの定義

Apps SDK でウィジェットを表示するには、ツール定義に _meta フィールドを含める必要があります。

src/server.tsx
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

function createTodoServer(): McpServer {
  const server = new McpServer({
    name: 'hono-todo',
    version: '0.1.0',
  });

  // todo-list ツールの登録
  server.registerTool(
    'todo-list',
    {
      title: 'Show Todo List',
      description: 'Display the todo list widget',
      inputSchema: {},
      _meta: {
        'openai/outputTemplate': 'ui://widget/todo-list.html',
        'openai/toolInvocation/invoking': 'Rendering todo list…',
        'openai/toolInvocation/invoked': 'Todo list ready',
        'openai/widgetAccessible': true,
        'openai/resultCanProduceWidget': true,  // ウィジェット表示を許可
      },
    },
    async () => {
      const todos = todoStore.getAll();
      return {
        content: [{ type: 'text' as const, text: 'Todo list rendered.' }],
        structuredContent: { todos },
        _meta: { /* 同様のメタデータ */ },
      };
    }
  );

  // toggle-todo ツールの登録(Zodで型安全に)
  server.registerTool(
    'toggle-todo',
    {
      title: 'Toggle Todo',
      description: 'Toggle the completion status of a todo item',
      inputSchema: {
        id: z.number().int().positive().describe('Todo identifier'),
      },
      _meta: {
        'openai/outputTemplate': 'ui://widget/todo-list.html',
        'openai/toolInvocation/invoking': 'Updating todo…',
        'openai/toolInvocation/invoked': 'Todo updated',
        'openai/widgetAccessible': true,
      },
    },
    async ({ id }) => {
      const todo = todoStore.toggle(id);
      return {
        content: [{ type: 'text' as const, text: `Toggled todo #${todo.id}` }],
        structuredContent: todo,
      };
    }
  );

  return server;
}

ウィジェットリソースの配信

ウィジェットのHTMLは text/html+skybridge という特殊なMIMEタイプで返します。
ここら辺はApps SDKのドキュメントの仕様。

src/server.tsx
// ウィジェットリソースの登録
server.registerResource(
  'todo-list-widget',
  'ui://widget/todo-list.html',
  {
    title: 'Todo List Widget',
    description: 'Interactive todo list powered by hono/jsx',
    mimeType: 'text/html+skybridge',  // Apps SDK 専用のMIMEタイプ
  },
  async (uri) => ({
    contents: [
      {
        uri: uri.href,
        mimeType: 'text/html+skybridge',
        text: await renderWidget(),  // サーバーサイドでHTML生成
        _meta: {
          'openai/widgetPrefersBorder': true,
        },
      },
    ],
  })
);

サーバーサイドでのウィジェットHTML生成

hono/jsx を使って、ウィジェットのHTMLをサーバーサイドで生成します。
SPAでよく見るお馴染みのエントリーポイントとなるHTMLを返しましょうというコードですね。

src/server/widget.tsx
import { raw } from 'hono/html';
import { renderToString } from 'hono/jsx/dom/server';

import type { AssetMap } from './assets.js';

const TodoWidget = ({ assets }: { assets: AssetMap }) => (
  <>
    <div id="todo-root"></div>
    {(assets.stylesheetContents ?? []).map((content: string) => (
      <style>{raw(content)}</style>
    ))}
    {assets.scriptContent ? (
      <script>{raw(assets.scriptContent)}</script>
    ) : null}
  </>
);

export function renderWidgetHtml(assets: AssetMap): string {
  return renderToString(<TodoWidget assets={assets} />);
}

https://developers.openai.com/apps-sdk/build/mcp-server/

普段しないので若干のむず痒さはありますが、サンプルに倣ってビルド済みのJS/CSSをインライン展開しています。
外部リクエストなしで自己完結したHTMLを返せるというメリットはありますね🤔

Client Side: hono/jsx/dom によるウィジェット実装

さてここから、クライアント側を hono/jsx/dom を使ってReactライクに実装していきます!

エントリーポイント

Vite でビルドされるクライアントのエントリーポイントです。
window.openai から初期状態を取得してアプリをマウントします。

src/widget/main.tsx
import { render } from 'hono/jsx/dom';
import { TodoApp } from './controller.js';

function getInitialTodos(): Todo[] {
  // ホストから渡された状態を優先的に使用
  const widgetState = window.openai?.widgetState;
  if (isWidgetState(widgetState)) {
    return widgetState.todos.slice();
  }
  // なければツール実行結果から取得
  return extractTodosFromPayload(window.openai?.toolOutput) ?? [];
}

const root = document.getElementById('todo-root');
if (root) {
  render(<TodoApp initialTodos={getInitialTodos()} />, root);
}

useSyncExternalStore でホスト状態を監視

Apps SDK 環境では、window.openai オブジェクトを通じてホスト(ChatGPT)からイベントが飛んできたり、状態が更新されたりします。
これを useEffect で手動で監視するのはバグの温床になりかねないので便利なhooksを使用しましょう。
今回はOpenAI公式ドキュメント・サンプルの通りの実装を使っています。

https://developers.openai.com/apps-sdk/build/chatgpt-ui#understand-the-windowopenai-api

使うのはReact 18で導入された useSyncExternalStore
これを使うことで、外部ストア(window.openai)と UI の同期を堅牢に実装できます。

やさしい解説としてこちら載せておきます。
https://zenn.dev/gemcook/articles/5fd016c4c8fac0

そして、さすがはHono、hono/jsxで同じフックが使えます!
やることは公式のサンプルのインポートを変えるだけ。

src/widget/lib/use-openai-global.ts
import { useSyncExternalStore } from "hono/jsx";
import {
  SET_GLOBALS_EVENT_TYPE,
  SetGlobalsEvent,
  type OpenAiGlobals,
} from "../types";

export function useOpenAiGlobal<K extends keyof OpenAiGlobals>(
  key: K
): OpenAiGlobals[K] | null {
  return useSyncExternalStore(
    // subscribe: ストアの変更を監視する関数
    (onChange) => {
      if (typeof window === "undefined") {
        return () => {};
      }

      const handleSetGlobal = (event: SetGlobalsEvent) => {
        const value = event.detail.globals[key];
        if (value === undefined) {
          return;
        }
        onChange();
      };

      window.addEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal, {
        passive: true,
      });

      return () => {
        window.removeEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal);
      };
    },
    // getSnapshot: 現在の値を返す関数
    () => (window.openai as OpenAiGlobals | undefined)?.[key] ?? null,
    // getServerSnapshot: サーバーサイド実行時の値を返す関数
    () => (window.openai as OpenAiGlobals | undefined)?.[key] ?? null
  );
}

他にもサポートしているフックは色々あるので、ウィジェット開発で困ることはほとんどないと思います!
詳しくは公式ドキュメントを参考にしてください。
https://hono.dev/docs/guides/jsx-dom#hooks-compatible-with-react

コントローラーでの活用

このフックを使えば、ホスト側からの変更に自動的に追従できます。

src/widget/controller.tsx
export function TodoApp({ initialTodos }: { initialTodos: Todo[] }) {
  const [todos, setTodos] = useState<Todo[]>(initialTodos);
  
  // ホスト側の各種状態を監視
  const toolOutputFromGlobals = useOpenAiGlobal('toolOutput');
  const displayModeFromGlobals = useOpenAiGlobal('displayMode');
  const safeAreaFromGlobals = useOpenAiGlobal('safeArea');
  
  // ツール実行結果が届いたらTodoリストを更新
  useEffect(() => {
    if (toolOutputFromGlobals) {
      const extracted = extractTodosFromPayload(toolOutputFromGlobals);
      if (extracted) setTodos(extracted);
    }
  }, [toolOutputFromGlobals]);

  // ...
}

ウィジェットからツールを呼び出す

ユーザーがチェックボックスをクリックしたとき、window.openai.callTool でサーバー側のツールを呼び出します。

src/widget/controller.tsx
const handleToggle = useCallback(async (todoId: number) => {
  const openai = window.openai;
  if (!openai?.callTool) {
    setStatus('ツール API が利用できません。');
    return;
  }

  setStatus('Todo を更新しています...');
  
  try {
    // MCPサーバーの toggle-todo ツールを呼び出し
    const result = await openai.callTool('toggle-todo', { id: todoId });
    
    const updatedTodo = extractTodoFromPayload(result);
    if (updatedTodo) {
      let nextTodos: Todo[] = [];
      setTodos((prev) => {
        nextTodos = prev.map((t) => 
          t.id === updatedTodo.id ? updatedTodo : t
        );
        return nextTodos;
      });
      // ホスト側にも状態を同期
      await openai.setWidgetState?.({ todos: nextTodos });
    }
  } catch (error) {
    console.error('Failed to toggle todo:', error);
    setStatus('更新に失敗しました。');
  }
}, []);

React のエコシステムで培われたベストプラクティスを、React 本体なしでそのまま使えるのは hono/jsx の大きな強みです。

動かしてみる

おおよその概観は掴めたかなと思うので最後に実際に動作していたころの動画を載せておきます!

動作デモ-2.5倍速動作デモ-2.5倍速

まとめ

なんだかHono Advent Calendarみたいな内容になってしまいましたが、Honoだけで完結するOpenAI Apps SDK開発の紹介でした!

Honoを使うメリットを改めてまとめると以下の通りです。

  1. 単一プロセス: MCPもウィジェットもこれ1つ。デプロイも管理も楽。
  2. 単一言語: サーバーからクライアントまでTypeScriptで統一。型安全性も抜群。
  3. 軽量: React不要で、サーバーサイドでのHTML生成と軽量なクライアント動作を実現。
  4. 堅牢: useSyncExternalStoreなど、モダンなUI構築の仕組みを利用可能。

ぜひみなさんもHonoでApps SDK開発、試してみてくださいね!
自分もここからさらに色んな機能を足してHono x Apps SDKの可能性を探っていきたいと思います!

誤りがあればコメントかXにてお知らせください🙏

参考資料

https://developers.openai.com/apps-sdk/

https://hono.dev/docs/

https://azukiazusa.dev/blog/chatgpt-apps-sdk/

マーベリックスのテックブログ

Discussion