開発ブログ

BLOG

Chrome の内蔵 AI から、WebMCP でメトロノームを操作できるようにした

Metronome.fm の操作を WebMCP のツールとして登録し、Chrome の内蔵のモデルから「180 BPM の 7/8 にして」と頼めるようにした。小さいモデルが、何も変わっていないのに「設定しました」と答えるので、ツールが失敗を AI に伝える方法、ツールの分け方、ツールの説明文の書き方を直した。

公開 2026年10月10日

Metronome.fm を WebMCP に対応させ、ブラウザの中で動く AI エージェントから「180 BPM の 7/8、3+2+2 にして」(7 拍を 3・2・2 にまとめる拍子)と頼めるようにしました。エージェントには Chrome の拡張機能を使い、モデルを Chrome に内蔵された小さいモデルにして試したところ、最初は失敗続きでした。設定が変わっていないのに「設定しました」と答えたり、「BPM を 10 上げて」と頼むと 10 下げたりします。

この記事では、WebMCP のツールの登録の仕方と、内蔵モデルで実際に動くまでに直したことを書きます。

WebMCP とは

WebMCP は、Web ページが「このページでできる操作」をツールとしてブラウザに登録する仕組みです。エージェントは画面を読んでボタンを押す代わりに、登録されたツールを直接呼びます。ツールは名前・説明・入力の JSON Schema・実行する関数の組で、JavaScript で登録します。サーバーの側の実装は要りません。

2026 年 10 月の時点では、Chrome がオリジントライアル(Chrome 149〜156)で試している段階です。主要なエージェント(Gemini in Chrome・Claude・ChatGPT)はまだ WebMCP のツールを呼びません。Google は Gemini in Chrome を対応させると発表していますが、時期は決まっていません。すぐに使われるものではないので、今のうちに、作り込まずに小さく試しておくことにしました。

Metronome.fm は WebMCP と相性がよいと考えました。キーボードショートカットやメニューから呼ぶ操作を、アプリの中で 1 か所にまとめ、それぞれに ID を付けているためです。ツールの多くは、その操作を呼ぶだけで作れます。

ツールを登録する

最初は 4 つのツールを登録しました。状態の取得・テンポと拍子とアクセント(拍ごとに強く鳴らすかどうか)の設定・再生(何小節・何秒などで止めるかを指定できる)・停止です。

登録先は、Chrome 150 で navigator.modelContext から document.modelContext に移りました。仕様はまだ変わりそうなので、modelContext を扱う処理は 1 つのファイルにまとめています。

export const getModelContext = (): ModelContext | null => {
  if (typeof document === "undefined") return null;
  const fromDocument: unknown = Reflect.get(document, "modelContext");
  if (isModelContext(fromDocument)) return fromDocument;
  // 古い名前は控えとして見る
  const fromNavigator: unknown = Reflect.get(navigator, "modelContext");
  return isModelContext(fromNavigator) ? fromNavigator : null;
};

// signal を abort すると、登録が外れる
modelContext.registerTool(tool, { signal });

ツールを登録するのは、メトロノームの画面が出ているときだけです。ドキュメントやブログのページに移ると、AbortController で登録を解除します。WebMCP に対応していないブラウザでは何もしません。

ツールの入力はエージェントが作るので、そのまま信用せず、すべて zod で検証します。エージェントに渡す JSON Schema も、同じ zod の定義から z.toJSONSchema で作ります。拍子は "7/8"・"3+2+2/8" のような文字列で受け取ります。アプリが設定を保存するときと同じ書き方です。

const setTempoInputSchema = z.object({
  bpm: z.number().int().min(40).max(300).optional(),
  // "4/4"・"6/8"・"3+2+2/8"(拍のまとまり付き)
  timeSignature: z
    .string()
    .refine((value) => parseMeterKey(value) !== null)
    .optional(),
});

const tool = {
  name: "set_metronome_tempo",
  description: "Set the tempo (BPM) and/or the time signature. ...",
  inputSchema: z.toJSONSchema(setTempoInputSchema, { io: "input" }),
  execute: async (input) => {
    /* 確かめてからストアに反映し、今の状態を JSON の文字列で返す */
  },
};

再生は、画面の再生ボタンと同じ処理を通します。プラン(無料・Pro)ごとの再生時間の上限も、ボタンを押したときと同じように効きます。

試す準備

試したのは Chrome 154 です。chrome://flags で、WebMCP を有効にします。

chrome://flags の WebMCP for testing の項目。番号 1 は Enabled にした選択欄
chrome://flags の WebMCP for testing
  1. Default から Enabled に変える

フラグを有効にすると、オリジントライアルに登録していないサイトでも document.modelContext が使えます。

まずは DevTools のコンソールから、getTools() で一覧を出し、executeTool() で呼んでみました。ここで 1 つ引っかかりました。Chrome 154 では、executeTool に入力をオブジェクトで渡すと Failed to parse input arguments になります。JSON の文字列にして渡すと動きました。Chrome 155 では、反対に文字列で渡す形が非推奨になっています。

const [tool] = await document.modelContext.getTools();
await document.modelContext.executeTool(tool, JSON.stringify({ bpm: 180 }));

エージェントから呼ぶには、Google の Chrome Labs が出している試験用の拡張機能、webmcp-extension を使いました。README の手順どおりにリポジトリを取ってきてビルドし、chrome://extensions から読み込みました。サイドパネルでエージェントと話し、ページのツールを呼ばせられます。

エージェントが使うモデルは、API のキーを置いたサーバーのものも選べますが、キーの要らない Chrome の内蔵モデル(Prompt API で呼ぶモデル)にしました。内蔵モデルにツールを呼ばせるには、もう 1 つフラグが要ります。

chrome://flags の Prompt API Tool Use の項目。番号 1 は Enabled にした選択欄
chrome://flags の Prompt API Tool Use
  1. 内蔵モデルにツールを呼ばせるために Enabled にする
webmcp-extension の設定の画面。番号 1 は Sensitive action alerts の切り替え、番号 2 は On-device model の切り替え。上には内蔵モデルのダウンロードの進み具合が出ている
拡張機能の設定の画面
  1. ページの状態を変えるツールを呼ぶ前に、許可を求める
  2. 内蔵モデルを使う。初めて有効にすると、モデルのダウンロードが始まる

Metronome.fm を開いてサイドパネルを開くと、登録したツールの説明が並びました。

サイドパネルに、Metronome.fm が登録したツールの説明が 3 つ並んでいる(番号 1)。番号 2 は、ページにツールがあることを示すアイコン
サイドパネルに出たツールの説明(最初の版)
  1. 登録したツールの説明。モデルはこれを読んでツールを選ぶ
  2. このページに WebMCP のツールがあることを示すアイコン

ページの状態を変えるツールを呼ぶ前には、拡張機能が許可を求めてきます。ツールの性質(読むだけか)は、登録するときの annotations の readOnlyHint で伝えています。状態の取得だけを readOnlyHint: true にしているので、それ以外のツールでは確認が出ます。

サイドパネルの Allow tool actions の画面。番号 1 は呼ぼうとしているツールの名前と説明、番号 2 は Don't allow・Allow・Allow on metronome.fm for this chat のボタン
ページの状態を変えるツールを呼ぶ前の確認
  1. 呼ぼうとしているツールと、その説明
  2. 許可しない・今回だけ許可・このチャットのあいだ許可

「設定しました」と言うのに、何も変わらない

最初に「180 BPM の 7/8、3+2+2 にして」と頼みました。返事は「180 BPM の 7/8、3+2+2 のテンポでメトロノームを開始しました」です。ところが、画面は 120 BPM・4/4 のまま止まっていました。続けて今の設定を聞くと、エージェント自身が「BPM: 120、拍子: 4/4、停止中」と答えています。

左はメトロノームの画面で、番号 1 は 120 BPM・4/4 のままの表示。右はサイドパネルで、番号 2 は「180 BPM の 7/8、3+2+2 のテンポでメトロノームを開始しました」という返事、番号 3 は今の設定を聞いたときの「BPM: 120、拍子: 4/4」という返事
設定が変わっていないのに「開始しました」と答えた
  1. 画面は 120 BPM・4/4 のまま
  2. 「180 BPM の 7/8、3+2+2 で開始しました」と答えた
  3. 今の設定を聞くと、エージェント自身が 120・4/4 と答えた

原因を探るため、ページの DevTools を開いて、もう一度頼みました。ツールは呼ばれていて、ページのコンソールに、ツールが入力を受け付けなかったエラーが出ていました。モデルは頼まれていないアクセントの指定を付け、しかも値の書き方を崩していました(語を引用符や括弧で包むなど)。ツールが受け付けずに何も変えていないのに、モデルは「設定しました」と答えています。

左下はページの DevTools のコンソールで、番号 1 はツールが入力を受け付けなかった Invalid input のエラー。右はサイドパネルで、番号 2 は「180 BPM、7/8、グループ化 3+2+2 の…を設定しました」という返事
ツールは入力を受け付けなかったのに「設定しました」と答えた
  1. アクセントの値が読めず、ツールが入力を受け付けなかったエラー
  2. 受け付けられなかったのに「設定しました」と答えた

別に頼んだ「bpm を 10 上げて」は、ツールが動いて BPM が変わりました。ただし 120 から 110 に下がっています。ツールは BPM を絶対値でしか受け付けないので、モデルが今の値を読んで足し算する必要があり、そこを間違えたようです。

小さいモデルに合わせて直したこと

受け付けなかったことを結果で返す

入力を受け付けないときは、例外を投げていました。投げた例外の中身がモデルまで届くかは、ブラウザと拡張機能の作り次第で、ページの側からは決められません。実際、モデルは入力が受け付けられなかったことに気づいていませんでした。

そこで、例外は投げずに、何も変えなかったことを結果の文字列に書いて返すようにしました。

const withInputErrors =
  (execute: ModelContextTool["execute"]): ModelContextTool["execute"] =>
  async (input, context) => {
    try {
      return await execute(input, context);
    } catch (error) {
      if (!(error instanceof ToolInputError)) throw error;
      return JSON.stringify({
        error: error.message,
        message:
          "Nothing was changed. Fix the input and call the tool again, or tell the user it failed.",
        state: getMetronomeStateSnapshot(),
      });
    }
  };

あわせて、崩れた値も、読める範囲で受け付けるようにしました。アクセントの値に accent・normal・mute の語が含まれていれば、引用符や括弧で包まれていても受け付けます。BPM は、今の値からの差分(bpmChange)でも頼めるようにしました。「10 上げて」は { bpmChange: 10 } になり、足し算はツールの側でします。

直したあとに同じことを頼むと、モデルは「指定された設定を正確に設定できませんでした」と、失敗を正直に伝えるようになりました。モデルは、受け付けられなかった理由も読み取れています。

サイドパネルの返事。番号 1 は「変更を試みましたが、指定された設定を正確に設定できませんでした」「アクセントの数に関するエラーが発生したため、設定が適用されませんでした」という部分
失敗したことを伝えるようになった
  1. 失敗したことと、その理由(アクセントの数)を伝えている

ただ、設定はまだ変わりません。モデルは頼まれていないアクセントを付け、今度はアクセントの数を拍の数(7 拍なら 7 個)に合わせられませんでした。説明に「頼まれたときだけ付ける」と書いても、付けてきました。

省ける項目を、別のツールに分ける

小さいモデルは、入れなくてよい項目(省ける項目)もつい埋めてしまうようです。そこで、アクセントの指定をテンポのツールから外し、別のツール set_metronome_accents に分けました。テンポのツールの入力から、アクセントの項目がなくなります。それでもモデルがテンポのツールにアクセントを付けてきたときは、その項目だけを無視し、ほかの項目は設定します。

これで、「180 BPM の 7/8、3+2+2 にして」と頼むと、設定が変わるようになりました。ところが、モデルは頼んでいない再生まで始めました。

左は 180 BPM・7/8 になったメトロノームの画面。右はサイドパネルで、番号 1 は Set metronome tempo と Start metronome の 2 つのツールを呼んだ記録、番号 2 は「メトロノームを開始しました」という返事
設定は変わったが、頼んでいない再生まで始めた
  1. テンポの設定に続けて、再生のツールも呼んでいる
  2. 「メトロノームを開始しました」と答えた

頼まれたときだけ再生する

再生のツールの説明に、「再生・開始を頼まれたときだけ呼ぶ。設定を変えただけでは呼ばない」と書き足しました。再生を始められなかったとき(ページをまだ一度も操作していないときや、プランの再生時間の上限に達したとき)も、テンポのツールと同じく error を付けて返します。

直したあとは、設定だけを変え、再生する前に「開始しますか?」と聞いてくるようになりました。

左は 180 BPM・7/8 で、1・4・6 拍目にアクセントが付いたメトロノームの画面(番号 1)。右はサイドパネルで、番号 2 は「7/8 の 3+2+2 の拍子で設定しました。開始しますか?」という返事
設定だけを変え、再生する前に確かめた
  1. 180 BPM・7/8 になり、3+2+2 のまとまりの頭(1・4・6 拍目)にアクセントが付いた
  2. 設定だけを変え、再生してよいかを聞いてきた

この返事の「80 ビート」は、モデルの書き間違いです。画面とツールに渡した値は 180 でした。返事の文章には、まだこうした粗さが残ります。

音を出せるのは、ページを操作したあとだけ

ブラウザは、ページで一度もクリックやタップをしていないうちは、音を出させません(AudioContext を動かせません)。エージェントに頼まれて再生を始めようとしても、音は出ません。

そこで再生のツールは、navigator.userActivation.hasBeenActive が false のときは始めずに、「ページを一度クリックしてもらって」と返すようにしました。ページを操作する前にエージェントから頼まれても、勝手に鳴り出すことはありません。

DevTools のコンソールから呼んだ場合は、ページをクリックしていなくても鳴りました。コンソールで打ったコードは、ページの操作として扱われるためと見ています。拡張機能から呼んだ場合にどう扱われるかは、今回は確かめていません。

やってみて

ツールを登録して呼べるようにするところは、既存の処理を呼ぶだけで済みました。時間がかかったのは、小さいモデルに合わせて、ツールの形と説明を直すところです。

  • 失敗は、例外ではなく結果の文字列で返す。何も変えていないことを書く
  • 省ける項目を減らす。必要なら別のツールに分ける
  • 計算はツールの側でする(bpmChange)
  • ツールを呼んでよい場面を、説明に書く

Metronome.fm には、文章で頼むと設定を作り直す AI の機能がすでにあり、そのモデルの費用は Metronome.fm が払っています。WebMCP で同じことができれば、モデルの費用はエージェントの側が持つことになります。Gemini in Chrome などが対応したら、試したことを書き足します。

この記事を共有

X でポストはてなブックマーク