🧚‍♀️

ブラウザからMCPを実行するため、MCP Hostを自作のAITuberライブラリに追加した話 (Function Calling)

に公開

実現できたこと

Before: FAQ は別途ページを参照する必要があった
After: チャット欄で AI がリアルタイム回答(Function Calling+FAQツール)
※現在は実験的機能として提供しているため、こちらの方法で有効化して試すことが可能です

以下は AITuber OnAir という、AIアバターとともにYouTubeでAITuber配信が行えるWebサービスの画面です。

AITuber OnAir内でAIアバターにサービス内の質問について回答させているところ

以下の ツール定義ハンドラ読み込ませるだけ で、このリアルタイム FAQ 応答を実装できます

// FAQ検索ツール定義
export const faqSearchTool: ToolDefinition<{ query: string }> = {
  name: 'faqSearch',
  description:
    'AITuber OnAirの使い方やトラブルシューティングに関する質問に回答します。例えば「VOICEVOXの設定方法」や「音声が出ない」などの質問に対応します。',
  parameters: {
    type: 'object',
    properties: {
      query: {
        type: 'string',
        description: 'AITuber OnAirに関する質問や検索キーワード',
      },
    },
    required: ['query'],
  },
};

// FAQツールハンドラー
export const faqSearchHandler = async ({ query }: { query: string }) => {
  try {
    // 回答生成処理は別途定義しています
    const answer = generateAnswer(query);
    return answer;
  } catch (error) {
    console.error('FAQ検索エラー:', error);
    return '申し訳ありません、FAQ検索中にエラーが発生しました。';
  }
};

※上の定義をライブラリの初期化処理に読み込ませるだけで、普段のAIチャット処理にtool呼び出しが加わり、AI側で必要と判断した場合、tool呼び出しやMCPの実行が可能となります。

const aituberOptions: AITuberOnAirCoreOptions = {
  chatProvider,
  ...
  tools: [
    {
      definition: faqSearchTool,
      handler: faqSearchHandler,
    }
  ],
  ...
}

// ここで生成したcoreのインスタンスを利用することで、以降のAIチャット処理では必要に応じてtool呼び出しが実施されていく(もちろんMCPも可能)
const aituberOnairCore = new AITuberOnAirCore(aituberOptions);

この記事について

今日は私が開発しているAITuber OnAir CoreというAIチャット向けのライブラリに、MCPサポート(正確にはFunction calling・tool呼び出し対応)を追加したので、実装時の技術的な内容についてご紹介していきます。

AITuber OnAir logo

このAITuber OnAir Coreというライブラリについての紹介は以下のnoteに書いていますので、興味がある方はチェックしてみてください。

https://note.com/aituberonair/n/n9993e228042d

AITuber OnAir Coreと『ブラウザ完結』の狙い

いきなり本題に入る前に、まずはAITuber OnAir Coreの技術的な紹介だけさせてください。

このライブラリはAITuber OnAirというAITuber配信のためのWebアプリのコアとなる実装を、オープンソースとして外出ししたライブラリとなることは、上に貼った記事にも書きました。

AITuber OnAir自体はサーバーを利用せず、Webフロントエンドに完結したサービスとして運用しています。これはユーザーとサービス提供者側にとって以下のようなメリットがあります

  • サーバーサイドの実装は現時点では0なので、サーバー管理コストがない
  • 利用者が管理する情報はすべてブラウザ側で管理されるため、サービス提供者としては情報を管理するコストがかからない
  • 同様に、ユーザーがチャットした内容もサービス提供者には送られない(利用しているAIのAPIへは送られる)

またWebフロントエンドのみで完結したサービスを提供するという、ある種の縛りプレイにより、OPFSやSQLite Wasmなどを採用したデータ管理を行うなど、技術スタック的にも楽しい形となっています。
(もちろん大変なこともありますが、そこはトレードオフです)

OPFSやSQLite Wasmを活用する話については、以前Zennにも記事を書いているので興味がある方はご覧になってみてください。

https://zenn.dev/shinshin86/articles/fdf4cbe40b2bad

そんなAITuber OnAirからコア機能のみをライブラリに切り出したのがAITuber OnAir Coreになり、当然こちらのライブラリもサーバー不要でAIチャットをするための要件を満たせる実装となっています。

そんなWebフロントエンド完結型に特化させたAIチャットライブラリのAITuber OnAir Coreですが、今後運用しているAITuber OnAirの機能拡張スピードを上げていきたいと思い、tool対応(MCP対応)を実施しました。

今回の記事では「どうやってAITuber OnAir Coreにtool対応を実施したか?」という内容について、まとめています。

全体的な処理の流れ

まずは実際にAITuber OnAir Core内でチャット処理が動く際の全体的な流れです。

基本的にはこのような流れで処理が動き、今回は上の図に記載のある tool に関する機能を新たに追加した形となります。

MCP Host とは何か? (= tool dispatcher)

実装の話に入る前に、もう一点だけ説明をさせてください。
これは私自身、実装の前に悩んだ部分の一つでしたので、改めて前提を書いておきたいです。

MCP HostはLLMとMCP Client/Serverの連結を行う層で、LLM側から見ると通常の function callingと同型となります。

MCPといえば、MCPサーバーについてが一番話題に上がるような気もしますが、今回のテーマとなるのはMCP Clientの呼び出しをハンドリングするための、いわゆるMCP Hostに関する部分となります。

MCP呼び出しにおいては以下の3つの登場人物がいます。
(MCP自体の解説については、すでにたくさんの記事で解説されているので割愛します)

MCP Host <-> MCP Client <-> MCP Server

この中で一番LLM APIに近い場所にいるのがMCP Hostとなります。
(というか同じ位置?)

そしてこのMCP Hostというのは、名称を変えればtool呼び出しの実装にほかならないという理解でいます。

つまりtool呼び出し対応(Function calling)の実装、それ自体がMCP Hostという役割になるという理解に落ち着きました。

実際にtool呼び出し時の処理プロセス

実際にtool呼び出しが行われる処理の順番としては以下のようになります。

  1. ユーザーからの会話をLLMに投げる(この際にtoolに関する情報もリクエストに含まれている)
  2. LLM APIで「これは登録されている tool を使うべきでは?」と判断される
  3. LLM は function calling (tool呼び出し)が必要だと判断すると、APIからのレスポンス(やストリーミング)の中で、どのtoolを利用したいかを宣言してきます
  4. その利用したいtoolを実行(実行対象はただの関数でも、MCPでも良く、MCPの場合はここでMCP Client -> MCP Serverの処理が行われます)
  5. 4で実行したツールの結果を添えて、再度LLMにAPIリクエストを送ります。そしてツール出力を踏まえて、再度ツールを使うのか(その場合、3~5 をOKになるまで繰り返します)、それとも、これでOKと判断して結果をユーザー側に返すのか、というLLM側の判断がレスポンスとして返ってきます
  6. 結果をユーザー側に返してOKとの判断が返ってきた場合、回答内容をユーザーへ返します

このようにMCP含む、tool呼び出しを行う場合、最低2回はLLMへのリクエストが行われます。

ちなみにこの実装を作成している途中にZennで以下のような記事を見かけました。

https://zenn.dev/razokulover/articles/9a0aee8ceb9f3f

一連の流れが分かりやすくまとめられており、この記事を読みながら自分自身理解を深めることが出来て助かりました。

Provider別の実装内容(OpenAI, Claude, Gemini)

AITuber OnAir Coreでは

  • OpenAI
  • Claude
  • Gemini

の3つのAPIのみをサポートしているため、各APIの仕様を見つつ、tool呼び出しに対応するための実装をしていきました。

ここからは実際に各Providerごとに実装していく際の、Provider別の仕様の違いについてまとめていきます。と言っても以下のテーブルを見ると分かるように結構細かな違いがあります。

今回、私もまとめるにあたって、ライブラリのソースコードをLLMに渡し、以下の差分テーブルを作成するという形でまとめさせてもらっています。

OpenAI Chat Completions Claude Messages Gemini generateContent / streamGenerateContent
HTTP パス /v1/chat/completions /v1/messages /v1 または /v1beta/models/{model}:{generateContent ∣ streamGenerateContent}
ツール宣言キー tools: [{type:"function", function:{…}}] [1] tools: [{name, description, input_schema}] [2] tools: [{functionDeclarations:[{name, description, parameters}]}] [3]
LLM からの「呼び出し宣言」 finish_reason:"tool_calls" + tool_calls:[{id,index,function:{name,arguments}}] stop_reason:"tool_use" + content_block(type:"tool_use") parts[*].functionCall:{name,args}
ツール実行結果の返却 role:"tool", tool_call_id, content role:"user" + content_block(type:"tool_result") role:"user" + functionResponse:{name,response}
ストリーム形式 SSE/NDJSON — 行区切り \n\n, data: プレフィックス SSE — 行区切り \n, イベント型 (content_block_delta, input_json_delta 等) NDJSON(各行 data: JSON)
ロール体系 system / user / assistant / tool system / user / assistant + ブロック型 model (=system/assistant) / user; tool は存在せず functionResponse で代替
CamelCase / snake_case 変換 不要 不要 v1beta エンドポイントは snake_case(例 tool_config)が必須
1 回の呼び出しで複数ツール? tool_calls 配列内に複数可 content 内に複数 tool_use ブロック可 parts 内で複数 functionCall
結果返却を省略した場合 再度 LLM 呼び出し必須 tool_result 省略可 — Claude が自己完結する場合あり functionResponse 省略可 — Gemini が推論継続
自動/強制モード指定 tool_choice: "auto"|"none"|{name}|"required" tool_choice: {type:"auto"|"none"|"any"|"tool"} toolConfig.functionCallingConfig.mode: "AUTO"|"ANY"|"NONE"

実装者向けの仕様差分の押さえどころ

  1. ツール宣言と“モード指定”の JSON キーが全員違う
    抽象型 → 各ベンダーフォーマット のマッピング層を作ると拡張がラク。

  2. 呼び出し宣言の形もバラバラ

    • OpenAI: tool_calls 配列index 順に結合。
    • Claude: イベント駆動 (content_block_startinput_json_deltacontent_block_stop)。
    • Gemini: functionCall が ID を持たないので、必要ならクライアント側で UUID 割当。
  3. 実行結果は “role” ではなく
    OpenAI だけ role:"tool" が正式枠。Claude/Gemini は「user メッセージ + 専用ブロック」で返す—違いを知らないと 400 エラーになりやすい(実際に実装時、たくさん400エラーを発生させています)。

  4. ストリームパーサは共有しにくい
    SSE と NDJSON が混在し、改行規則も違うため、ベンダー毎に独立実装 した方が保守負荷が低い。

実際の実装(ストリームパーサの例)

関数単体で完結している実装ではないため、実装を提示したからといって参考になるとは限りませんが、以下はOpenAIのAPIから返ってきたstreamをパースする際の関数となります。

読んでいただくと分かるとおり、返ってきたストリームを愚直にパースして、別の処理に渡すような実装となっています。

こういった処理を各APIの仕様に合わせて、それぞれ書いていくことでtool呼び出しに関する機能を実装することが出来ました。

private async parseStream(
    res: Response,
    onPartial: (t: string) => void,
  ): Promise<ToolChatCompletion> {
    const reader = res.body!.getReader();
    const dec = new TextDecoder();

    const textBlocks: ToolChatBlock[] = [];
    const toolCallsMap = new Map<number, any>();

    let buf = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buf += dec.decode(value, { stream: true });

      // wait for "\n\n"
      let sep;
      while ((sep = buf.indexOf('\n\n')) !== -1) {
        const raw = buf.slice(0, sep).trim(); // 1 line
        buf = buf.slice(sep + 2);

        if (!raw.startsWith('data:')) continue;
        const payload = raw.slice(5).trim(); // after "data:"
        if (payload === '[DONE]') {
          buf = '';
          break;
        }

        const json = JSON.parse(payload);
        const delta = json.choices[0].delta;

        if (delta.content) {
          onPartial(delta.content);
          textBlocks.push({ type: 'text', text: delta.content });
        }

        /* -------------- tool_calls -------------- */
        if (delta.tool_calls) {
          delta.tool_calls.forEach((c: any) => {
            // arguments are incremented for each chunk → concatenate
            const entry = toolCallsMap.get(c.index) ?? {
              id: c.id,
              name: c.function.name,
              args: '',
            };
            entry.args += c.function.arguments || '';
            toolCallsMap.set(c.index, entry);
          });
        }
      }
    }

    // convert tool_callsMap to ToolUseBlock[]
    const toolBlocks: ToolChatBlock[] = Array.from(toolCallsMap.entries())
      .sort((a, b) => a[0] - b[0])
      .map(([_, e]) => ({
        type: 'tool_use',
        id: e.id,
        name: e.name,
        input: JSON.parse(e.args || '{}'),
      }));

    const blocks = [...textBlocks, ...toolBlocks];

    return {
      blocks,
      stop_reason: toolBlocks.length ? 'tool_use' : 'end',
    };
  }

AIコーディングでハマった話と教訓

少しここで余談となりますが、自戒の念を込めて書き残しておきます。

toolサポートのための実装(あるいはMCP Hostの実装)自体はさきほど書いた内容を、各LLMのベンダーごとに対応していけばよいだけです。

「というわけで、あとは手を動かして実装しました」と書ければ良かったのですが、「まあ、扱う項目やパース処理が異なったとしてもやりたいことは明確なんだから、AIでやれるでしょ」と横着してしまい、CursorやChatGPTなどに頼ったのが失敗でした。
(CursorやChatGPTは全く悪くないです)

まず最初にOpenAIから対応を行いましたが、こちらはtoolの仕様を読み込ませてCursor側で実装を指示すれば割とサクッと実装できました。ここまで行ければ、あとは楽勝です(と思っていました)

私自身、AIコーディング分野もなるべくキャッチアップしていきたいと思い、例えばAITuber OnAirの開発などではCursorなどを導入して積極的にAIを用いた開発を行っています。

経験上、

  • 仕様が明確化されている
  • どんな処理を書けばタスクが完了するかが自明

という条件が揃っている場合、基本的にAIに依頼をすれば、よほどのイレギュラーなことがなければ実装は問題なく出来ます。
(最初にAIで7割ほど書いて、あとは自身で調整という流れでしょうか)

今回もこの2つは満たしているし、OpenAIで実際に実装も出来たのだからClaudeとGeminiの対応も問題なさそう、と甘い気持ちで進めてしまいましたが、1つ重要なことを見落としていました。

それは、各ベンダー毎のtool周りの仕様・仕組みをちゃんと理解していなかったことです。

今は一通り実装を終えて、自分の中で理解は出来ているものの、当時はtoolの仕様を理解しつつその場で実装を進めるという見切り発車的な進め方をしていました。

「まあ、でもAIベンダー各社のドキュメントもちゃんとあるし、大丈夫でしょ」という気持ちがあったのですが、その甘さゆえClaudeとGeminiについては苦労しました。

苦労した要因としては意外なことでもなんでもないのですが、それぞれのAPIで利用される項目名が異なることやレスポンスの違いを吸収する部分の実装です。
(特に ストリーム形式 の違いや、 Geminiで利用することになる v1beta エンドポイントでは snake_case での項目指定が必須になる点などはハマった記憶があります)

今となってはちゃんと仕様を調査して手を動かすことが大切とわかるのですが、実装当時はAIに仕様を理解してサクッと実装してもらおう、という甘えに支配されていたので、ここは無駄に時間を溶かした記憶があります。

以下のディレクトリ配下にそれぞれのコードは書かれていますが、そのうちちゃんとリファクタリングしようと思っています。
https://github.com/shinshin86/aituber-onair/tree/main/packages/core/src/services/chat/providers

最小サンプルと UI 連携デモ

今回記載したtool呼び出し対応ですが、実際に動くデモアプリも別途公開しています。

https://github.com/shinshin86/simple-aichat-app-with-aituber-onair-core

AITuber OnAir Coreでtool呼び出しを行っている際のデモ

こちらでは上の画像の通り、ランダムな数値を返すだけのシンプルな機能をtool、またはMCPから呼び出せるように実装しています。実際にライブラリを使う際はチェックしてみてもらえたらと思います。
(MCPは別途サーバーが必要になるのですが、それについてはこちらの投稿を参照してください)

まとめ

AITuber OnAir Core への MCP/Function Calling 対応は、「MCP Host=tool dispatcher」 という発想でシンプルに整理できました。

  • コアは「ツール登録 → 呼び出しループ → 結果の再投入」という共通フローだけを担当。
  • OpenAI・Claude・Gemini は 宣言キー/呼び出し通知/結果返却/ストリーム形式 の4点を差し替える アダプタ として実装。

最大のつまずきは各ベンダーごとの 名称のゆらぎストリーム形式の違いでしたが、抽象層を一段挟むことで「アダプタを1ファイル足せば拡張できる」構成に落ち着きました。

この記事が、Webブラウザ完結でマルチベンダーの Function Calling を導入したい方の参考になれば幸いです。また、お気づきの点があればぜひコメントいただけたらと思います!

Discussion