Dev

WebMCP を実サイトで使う: document.modelContext でツールを登録する方法と、自分の navigator.modelContext スクリプトが何も登録しない理由

この API は 5 月に navigator から document へ移りました。それより前に 3 つのメソッドが削除され、移動の後には登録の呼び出しが Promise を返すようになりました。4 月に検出ツールを満足させるために書いたスクリプトは、現在のブラウザーにツールを 1 つも公開しないことがあります。動く登録コード、エージェントなしでできるテスト、そして素の MCP エンドポイントのほうが良い入口になる場面を取り上げます。

4 月に、このサイトへ WebMCP でツールを 2 つ追加しました。get_contact と list_products です。どちらも読み取り専用で、ページの head に置いた小さなインラインスクリプトから登録しています。10 月 11 日、現在の Chrome でホームページを開き、このページがどのツールを公開しているかをブラウザーに尋ねました。返ってきたのは空のリストでした。

サイトの何かが壊れたわけではありません。スクリプトは今も navigator.modelContext を探していますが、仕様は現在、入口を document に定義しています。もっと居心地が悪いのは、4 月の自分のメモに残っている話です。このスクリプトをブラウザーで試したことは一度もなく、試した相手はスキャナーだけで、そのスキャナーもツールを報告したことはありませんでした。そこでこの記事では、現在のドラフトの記述どおりにツールを登録する手順を、確認のために実行したコマンドと一緒にたどります。自分のスクリプトは、検出ツールでは分からないことの実例として使います。

WebMCP とは何か、API は今どこにあるのか

WebMCP は提案段階のブラウザー API で、ページがエージェントに、名前の付いた JavaScript 関数の一覧を渡せるようにします。各関数には、説明と引数の JSON Schema が付きます。2026 年 10 月 9 日付のドラフトの時点で、入口は document.modelContext で、セキュアコンテキストでのみ使えます。この記事で使う 3 つのメソッドに絞って抜粋します。

webidl
partial interface Document {
  [SecureContext, SameObject] readonly attribute ModelContext modelContext;
};
 
interface ModelContext : EventTarget {
  Promise<undefined> registerTool(ModelContextTool tool,
      optional ModelContextRegisterToolOptions options = {});
  Promise<sequence<RegisteredTool>> getTools(
      optional ModelContextGetToolOptions options = {});
  Promise<DOMString> executeTool(RegisteredTool tool,
      optional object inputObject,
      optional ModelContextExecuteToolOptions options = {});
};

これは Draft Community Group Report です。文書自身が、W3C Standard ではなく、標準化トラックにも乗っていないと述べています。リポジトリの履歴を見ると、早い時期に組み込んだ人の足元で、仕様がどれだけ動いたかが分かります。

日付変更プルリクエスト
2026 年 3 月 5 日provideContext() と clearContext() を削除#132
2026 年 3 月 26 日と 27 日registerTool() が AbortSignal を受け取るようになり、unregisterTool() は explainer から削除#147, #156
2026 年 5 月 27 日modelContext ゲッターが Navigator から Document へ移動#184
2026 年 6 月 8 日registerTool() が Promise を返すようになる#200
2026 年 10 月 8 日フォームベースの宣言的 API を「for now」(当面) という扱いで仕様から削除#338

Chrome は 2 月に早期プレビューを、6 月に Chrome 149 からのオリジントライアルを発表しました。仕様リポジトリの実装状況には、ほかに Edge 150 のオリジントライアル、Brave の Leo での実験的サポート、そしてサポート済みとして ChatGPT Desktop が載っています。Firefox と Safari は、standards position と追跡用 issue へのリンクとして出てくるだけです。

現在の Chrome にはそのオブジェクトがなく、丁寧に書かれたスクリプトは、オブジェクトがないことを「未対応」とみなして黙って終了するからです。自分のスクリプトがまさにそうでした。4 月 18 日に公開したものの中心部分を、整形し、外側の try を外して載せます。

js
function register() {
  if (done) return true;
  var mc = typeof navigator !== 'undefined' && navigator.modelContext;
  if (!mc) return false;
  if (typeof mc.registerTool === 'function') {
    tools.forEach(function (t) { try { mc.registerTool(t); } catch (e) {} });
  }
  if (typeof mc.provideContext === 'function') {
    try { mc.provideContext({ tools: tools }); } catch (e) {}
  }
  done = true;
  return true;
}

その周りには、50 ms 間隔で 10 秒間再試行するポーリングループと、DOMContentLoaded、readystatechange、load のリスナーがありました。理由はその日のコミットメッセージに書いてあります。スキャナーの shim が、自分のスクリプトの実行後に navigator.modelContext を注入すると見込んで、注入されるまで再試行を続けることにしたのです。ログには 11 分間に 4 件のコミットがあります。最初の 1 件がツールを追加し、続く 3 件は、スキャナーのプローブから見えるように登録処理を作り直したものです。結末は同じ日のメモに残っています。スキャナーは相変わらずツールの登録なしと報告し、スクリプトはそのまま残しました。

やり直せるなら変えるのはここです。ブラウザーの実装は、ページ読み込みの途中のどこかで遅れて現れたりしません。フラグがあるか、レスポンスにトライアルトークンがあれば、document.modelContext は最初のスクリプトが走る前からそこにあります。あのポーリングは中身の見えないチェッカーに合わせたもので、そのチェッカーは何も確認してくれず、どの時点でもブラウザーは関わっていませんでした。WebMCP を有効にした Chrome 155 では、typeof navigator.modelContext は undefined で、ループは 10 秒であきらめ、何も登録されません。エラーも警告も出ません。

保証できるのは、自分でテストしたバージョンだけです。navigator という名前がどの Chrome リリースで使えなくなったのかは確認していません。

document.modelContext.registerTool で WebMCP ツールをどう登録するのか

document.modelContext の有無を確かめ、ツールごとに registerTool() を 1 回呼び、返ってくる Promise を処理します。以下は上のスクリプトの置き換えで、このままの形でテストしました (返すデータは掲載用に削ってあります)。

js
(function () {
  var mc = document.modelContext;
  if (!mc || typeof mc.registerTool !== 'function') return;
 
  var empty = { type: 'object', properties: {} };
  var readOnly = { readOnlyHint: true };
 
  var tools = [
    {
      name: 'get_contact',
      description: 'Return contact information for the person behind this site.',
      inputSchema: empty,
      annotations: readOnly,
      execute: async function () {
        return JSON.stringify({ email: 'max@nardit.com', site: 'https://max.nardit.com' });
      },
    },
    {
      name: 'list_products',
      description: 'List the products built by the site owner, with canonical URLs and status.',
      inputSchema: empty,
      annotations: readOnly,
      execute: async function () {
        return JSON.stringify([{ name: 'Beetroot', url: 'https://max.nardit.com/beetroot', status: 'active' }]);
      },
    },
  ];
 
  tools.forEach(function (tool) {
    mc.registerTool(tool).catch(function (err) {
      console.warn('WebMCP: ' + tool.name + ' not registered: ' + err.name);
    });
  });
})();

4 月版との違いはわずかです。そしてその大半は、取り違えると音もなく失敗します。

ポーリングはありません。1 行目で document.modelContext がなければ、ブラウザーはこのページに WebMCP を提供していません。順序について残るルールは 1 つだけです。オリジントライアルのトークンをスクリプトから注入する場合は、その後で登録してください。

registerTool() は Promise を返すので、呼び出しを try で囲んでも何も捕まえられません。同じ名前での 2 回目の登録は、自分の実行では InvalidStateError: Duplicate tool name で reject されました。仕様では、名前や説明が空の場合と、スキーマが不正な場合も reject されます。.catch() がなければ、これらは unhandled rejection になります。

unregisterTool() はありません。ツールは、渡した AbortSignal が発火するまで生き続けます。

js
var controller = new AbortController();
// the signal has to go in with the first registration of this name
await document.modelContext.registerTool(
  {
    name: 'get_cart',
    description: 'Return the items in the current cart.',
    inputSchema: { type: 'object', properties: {} },
    annotations: { readOnlyHint: true },
    execute: async function () { return JSON.stringify([]); },
  },
  { signal: controller.signal }
);
// later, for example when the user logs out:
controller.abort();

自分のテストでは、abort() の後、ツールは getTools() から消えていました。ビューごとにツールを登録するシングルページアプリでは、ここが効いてきます。

アノテーションは、ささいなツールでも設定しておく価値があります。readOnlyHint の既定値は false です。Chrome のセキュリティのページは、このヒントが、ユーザーに確認を求めるタイミングをエージェントが判断する助けになると述べています。それを受けてエージェントがどうするかはエージェント側のポリシーのままなので、ヒントは何も約束しません。それでも、読み取り専用なのにそう名乗らないツールは、伝えられたはずのことをエージェントに伝えていません。安全性に関するヒントはあと 2 つあります。ユーザー生成のテキストや第三者のテキストを含む出力のための untrustedContentHint と、支払いのようなアクションのための consequentialHint です。imperative API のページには、4 つ目のアノテーションとして Chrome 156 からの debugging が載っています。

クロスオリジンの iframe では、登録は既定でオフです。仕様はこの API を tools という Permissions Policy の背後に置いていて、既定の許可リストは 'self' です。そのため、埋め込む側のページが allow="tools" で許可する必要があります。

エージェントなしで WebMCP ツールをどうテストするのか

ページ自身の API を使います。getTools() は、そのドキュメントから使えるツール (自分のものと、条件を満たす子フレームのもの) を一覧し、executeTool() はその 1 つを実行します。つまり、ブラウザーのコンソールがあれば足ります。ローカルでの作業用に、Chrome の概要ページは chrome://flags/#enable-webmcp-testing というフラグを挙げています。自分はコマンドラインから作業し、そこでは下の機能スイッチで API が公開されました。実行はヘッドレスで、DevTools プロトコル経由です。画面を出す場合も起動方法は同じで、ローカルポートで動く任意の静的サーバーに向けます (ページにはセキュアコンテキストが必要で、localhost はその扱いになります)。

bash
google-chrome --version
# Google Chrome 155.0.8059.39
 
google-chrome --enable-features=WebMCPTesting \
  --user-data-dir=/tmp/webmcp-probe http://localhost:8765/

次に、テスト対象のページのコンソールで、プロトコル経由で送ったのと同じ呼び出しを実行します。

js
const tools = await document.modelContext.getTools();
console.log(tools.map((t) => t.name));
const tool = tools.find((t) => t.name === 'get_contact');
if (tool) console.log(await document.modelContext.executeTool(tool, {}));

同じ呼び出しを 3 つのページに対して行いました。機能スイッチなしでは、このビルドには document.modelContext も navigator.modelContext もありません。スイッチありの結果は次のとおりです。

ページgetTools()
ローカルページに置いた 4 月の登録ロジック12 秒後に []
前のセクションのスクリプトget_contact, list_products
2026 年 10 月 11 日時点のこのサイトのホームページ[]

executeTool() は、試した 2 つのケースのどちらでも文字列を返しました。execute が文字列を返したときは JSON テキストがそのまま、プレーンなオブジェクトを返したときはシリアライズされた形です。Chrome の imperative API のページは例を文字列の結果で書いているので、自分も文字列を返しています。

UI のほうがよければ、Chrome DevTools の Application の下に WebMCP ペインがあり、アクティブなタブのツールを一覧して、手動で 1 つ実行できます。今回のテストでは使っていません。

このテストが証明する範囲は狭いです。ブラウザーが登録を受け付け、関数が期待どおりの値を返す、ということだけです。どこかのエージェントがそれを呼ぶことを選ぶかどうかについては、何も言っていません。

エージェントは WebMCP ツールで何ができて、何ができないのか

エージェントが関数を呼べるのは、対応ブラウザーでそのページが開いている間です。制限はすべてこの一文に含まれています。Chrome の比較ページははっきり書いています。

text
Importantly, WebMCP tools are ephemeral. They exist only when your
page is open. Once the user navigates away from your site or closes
the tab, the agent cannot access your site or take actions.

発見は、訪問することで起こります。概要ページはこれを制限として挙げています。呼び出せるツールがあるかどうかを知るには、クライアントやブラウザーがサイトを直接訪れなければなりません。レジストリも well-known ファイルもありません。

オリジントライアルの外では、呼ぶものがありません。素の Chrome で訪れた人には、サイトがトライアルトークンを配信しないかぎり document.modelContext がありません。配信方法は、Chrome のオリジントライアルのガイドにあるとおり、Origin-Trial レスポンスヘッダーか、同等の meta タグです。10 月 11 日に確認した時点で、このサイトはトークンを配信していませんでした。したがって、スクリプトを直しただけでは、その訪問者にとって何も変わりません。テストしたビルドでは、ツールが現れたのはテスト用の機能を有効にしたときだけでした。

ツールの一覧はインターフェースであって、境界ではありません。仕様のセキュリティのセクションは、エージェントがすでにユーザーのセッションを持っているという前提から始まります。

text
Identity inheritance: Agents are able to inherit user identity and
authentication context from the browser. When an agent visits a
website, it carries the user’s logged-in credentials and session state.

Chrome のセキュリティのページは、ホスト権限を持つ拡張機能なら、WebMCP がなくてもカスタム JavaScript を実行してページを操作できると付け加えています。ですから、ログイン済みのアプリに整ったツールをいくつか登録しても、そのタブのエージェントが届く範囲は狭まりません。協力的なエージェントにより良い道を与えるだけで、誰がセッションを握っているかという問いは元の場所に残ります。認可は、これまでどおり各ツールの背後にあるサーバーの仕事です。

エージェントによるヘッドレス利用は、上のヘッドレスでのテストとは別の話ですが、これについては情報源によって力点の置き方が違います。リポジトリには 9 月 4 日に、ヘッドレスのユースケースはスコープ内だとする 1 行が追加されました (#296)。Chrome の概要ページは、ツールをヘッドレスで実行することは可能かもしれないが、この API は主に、人が関与する (human in the loop) ローカルブラウザーのワークフロー向けに設計されている、としています。自分はこれを、許されてはいるが狙いではない、と読みました。

公開の読み取り専用サイトには、WebMCP とリモート MCP サーバーのどちらを選ぶべきか

公開されている読み取り専用のデータなら、エージェントが現時点で到達できるのは MCP エンドポイントのほうです。WebMCP が意味を持つのは、ログイン中のユーザーとページの状態があるときです。10 月 8 日、このサイトの /mcp に小さな読み取り専用の MCP サーバーを置きました。同じ連絡先と製品のデータに加えて、記事検索を提供しています。並べるとこうなります。

WebMCP ツールリモート MCP サーバー
届くのはいつか対応ブラウザーでページが開いているときいつでも、HTTP 経由で
発見ページを訪れることでクライアントの設定に書いた URL、または server card (まだドラフト段階の提案)
呼び出し側の身元ユーザーのブラウザーセッションエンドポイントが求めるもの (自分の場合は何も求めません。公開です)
ページの状態が見えるか見える: DOM、カート、未保存のフォーム見えない
利用できる環境オリジントライアル、フラグ、少数のクライアントリモート HTTP サーバーに対応した MCP クライアント

Chrome はこれを「MCP is for backend」(MCP はバックエンド向け)、「WebMCP is for frontend」(WebMCP はフロントエンド向け) とまとめ、両方を使うことを勧めています。アプリについては、この読みは成り立ちます。チェックアウト、座席表、途中まで入力したフォーム。こうした状態はタブの中にあり、ページからそれを読むツールなら、サーバー側でセッションを組み立て直さずに済みます。

自分のようなサイトには、そういう状態がありません。実際のサイトの get_contact は誰に対しても同じ 7 つのフィールドを返し、タブを必要とする要素は何もありません。自分の見立てでは、公開の読み取り専用サイトで WebMCP に居場所があるのは実験としてであって、本命の連携としてではありません。同じデータを素の HTTP エンドポイントの背後に置けば、ページを一度も開いたことのないエージェントにも応えられます。登録したツールにはそれぞれ名前、説明、スキーマが付いてきて、クライアントがそれをモデルに渡せばコンテキストを消費します。どんなツールカタログにもある同じコストです。エージェントは結果を予測できるツールに手を伸ばす、とも以前論じました。長い一覧より明快なツール 2 つを選ぶ理由になります。

サイトは WebMCP ツールを今登録すべきか、待つべきか

今登録するのは、保守も自分で引き受ける場合だけにしてください。上の表には 7 か月で 5 回の形の変更が並んでいて、そのうち自分のスクリプトを壊した 1 件では、失敗が音もなく起きていたからです。サイトにユーザーごとの本物の状態があり、エージェントのトラフィックを、当て推量のクリックではなく自分で書いた関数に通したいなら、オリジントライアルは学ぶ場所として妥当です。サイトが公開コンテンツなら、先に HTTP エンドポイントを出し、WebMCP は任意の追加として扱ってください。

どちらの場合も、検出ツールではなくブラウザーでテストしてください。フラグを立てた Chrome での getTools() は すぐに済みますし、まっさらなプロファイルには、ブラウザーの代わりに答える shim がありません。機能検出の対象は現在のドラフトが名指しするオブジェクトだけにして、それより古いものは見ないでください。テストしたビルドでは、navigator.modelContext や provideContext() へのフォールバックは死んだコードで、スクリプトを実際より互換性が高そうに見せるだけだからです。そして、連携のコードには、依拠したドラフトの日付を残しておいてください。自分が読んだ時点でドラフトの日付は 2026 年 10 月 9 日で、宣言的 API はその前日に仕様から外れていました。

確かなものとしては扱わずにおきたいのは、Chrome の 2 月の投稿にある主張です。直接のチャネルは「eliminates ambiguity」(曖昧さをなくす) もので、信頼性で生の DOM 操作に勝る、というものです。そうである可能性は十分あります。ただ、投稿に計測値はありません。自分が計測したものはもっと小さく、見栄えもしません。4 月に、結局一度も「はい」と言わなかったスキャナーのために書いた登録処理です。それでも本番に出し、そのまま残したその処理は、初めてブラウザーに尋ねたとき、何も公開していませんでした。

ディスカッション

コメント欄はありません。議論は X で行っています。

Max Nardit

Max Nardit

@mnardit

ほかの記事

サプライチェーンとしての Claude Code スキル: 220 万件の採用、レジストリなし、スキャナーがソースを読む一方で Python はキャッシュを実行する

スキルは実行する前に読む、というのが定番の助言ですが、この助言には穴が 2 つあります。コピーしたスキルには更新の経路がないため、読んだコピーは知らないうちに元のスキルとずれていくことがあります。そして Python を同梱するスキルでは、読んだファイルとインタープリターが読み込むファイルが同じとは限りません。

Claude Code vs Codex: どちらかを選ばず、開発者が 2 つに仕事を振り分ける 7 つの方法

受け渡しの手段は、いまではベンダー自身が文書化しています。レビューコマンド、セッションの転送、双方向のインポーターです。一方、どの仕事をどちらに任せるべきかは、どこにも書かれていません。7 つの分担を出どころまでたどり、「文書化済み」か「報告」かを付けて紹介します。あわせて、既定値が異なるサンドボックスと、今年削除された連携も取り上げます。

コンテキストエンジニアリングは棚卸しから始まる: 一覧にできないコンテキストウィンドウは予算化できない

コンテキストエンジニアリングのガイドが勧める実践は、どれも「選ぶ」動詞です。削る、後回しにする、取りに行く、圧縮する。選ぶには一覧が要ります。Claude Code に付いてくる一覧は、設定を読み込み元とサイズつきで項目化する一方で、会話全体はひとつの数字として報告し、何がいつまで残るのかはどこにも書いていません。