- カテゴリー
- 生成AI・Web開発
- 公開日
- 2026.09.09
Contents
はじめに
自分で作ったWebページの検索機能を、AIからも使えるようにしたい。そんなときに試せるのが、ページの機能を「ツール」として公開するWebMCPです。
この記事は、HTMLのフォームとJavaScriptの関数を学んだ人に向けた実装入門です。架空の学習メモを検索するアプリを作り、通常の検索ボタンとWebMCPから同じ結果を得るところまで進めます。Codexから公開ツールを呼ぶ例も紹介します。
WebMCPは実験的なブラウザAPIです。 この記事のコードはChrome 152の試用機能を対象にしています。一般的なブラウザで常に使える前提にはせず、WebMCPが無効なときも画面の検索が動く構成にします。
作るものと到達点
完成するのは、HTML・CSS・JavaScriptの3ファイルからなる「メモ検索ラボ」です。
- 架空メモ4件を、タイトルと本文の部分一致で検索する。
- 同じ検索関数を、画面と
search_notesツールの両方で使う。 - 空入力や不正な件数を拒否し、検索結果がない場合と区別する。
- Chromeでツールを直接呼び、Codexからも結果を取得する。

メモの編集・永続保存、意味検索、ログインは扱いません。外部ライブラリやAPIキーもアプリには組み込みません。まず、既存の関数をツールにする流れを一つ理解することが目標です。
仕組み:画面とツールで検索処理を共有する
検索ボタンを押すと、画面はsearchNotes()を呼びます。WebMCPでは、ツール名、説明、入力の形、実行する関数をブラウザへ登録します。ツールが呼ばれたときも、同じsearchNotes()へ入力を渡します。
フォーム → searchNotes() → 画面へ表示
Codex → ブラウザのWebMCP → search_notes → searchNotes() → 結果を返す
ツールを使うAIは、説明を読み、例えば「OPFSのメモを2件探す」という依頼をquery: "OPFS"とlimit: 2に変換します。実際の部分一致検索をするのは、AIではなくアプリのJavaScriptです。
WebMCPと、Node.jsなどで動かすMCPサーバーは、実行場所と接続方法が異なります。また、WebMCPにローカルLLMが付属するわけではありません。ツールの実行場所がブラウザでも、接続するAIの推論やデータ処理が外部サービスで行われる場合があります。
公式のImperative APIでは、document.modelContext.registerTool()でJavaScriptの機能を登録します。古い解説にあるnavigator.modelContextと混ぜず、対象ブラウザで利用できるAPIを確認してください。
必要な環境
| 用途 | 必要なもの |
|---|---|
| ファイル作成 | テキストエディター、WindowsのPowerShell |
| ローカル配信 | Python 3。ここではpyコマンドを使用 |
| WebMCPの試用 | Chrome 152、WebMCP for testingを有効化 |
| 画面からの通常検索 | JavaScriptのES Modulesを使えるブラウザ |
| AIからの呼び出し | ページのWebMCPツールを扱えるCodexのブラウザ環境 |
通常検索とブラウザからの直接呼び出しには、有料AIサービスは必要ありません。Codexの利用には、利用中のアカウント・契約・使用量制限が適用されます。この記事ではAPIキーを作成したり、有料APIへ直接リクエストしたりしません。
Pythonがまだない場合は、Pythonのインストールと環境設定を先に進めてください。py --versionでバージョンが表示されれば、この手順を実行できます。
手順1:3つのファイルを作る
作業用フォルダーに、次の構成でファイルを保存します。文字コードはUTF-8です。拡張子が.html.txtなどにならないよう、エクスプローラーでファイルの拡張子を表示しておきます。
webmcp-note-search/
index.html
style.css
app.js
index.html:検索画面を用意する
type="module"でJavaScriptを読み込みます。通常の検索と、開発用のツール呼び出しボタンを別に置いています。後者は、AIを使わずにWebMCPの経路だけを調べるためのものです。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>メモ検索ラボ | Kanimiso-Lab</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="style.css">
<script type="module" src="app.js"></script>
</head>
<body>
<main>
<header><span class="brand">KANIMISO-LAB / WEBMCP</span><span class="badge">練習用デモ</span></header>
<p class="eyebrow">検索する。ツールとして呼び出す。</p>
<h1>メモ検索ラボ</h1>
<p class="intro">同じ検索処理を、画面とWebMCPから使います。<br>登録されているのは、架空の学習メモ4件です。</p>
<p id="api-status" class="status" role="status">WebMCPの状態を確認しています…</p>
<section aria-labelledby="search-heading" class="panel">
<h2 id="search-heading">メモを探す</h2>
<form id="search-form" novalidate>
<div class="fields">
<label>検索語<input id="query" name="query" type="search" value="OPFS" maxlength="80" placeholder="例:OPFS、Python、MCP"></label>
<label class="limit">表示件数<select id="limit" name="limit"><option value="2">2件まで</option><option value="4">4件まで</option></select></label>
<button type="submit">検索する</button>
</div>
</form>
<p id="result-status" role="status" aria-live="polite">検索語を入力してください。</p>
<ul id="results" class="results"></ul>
</section>
<details class="panel">
<summary>WebMCPから同じ検索を試す</summary>
<p>上の入力をJSONにして、ブラウザに登録した <code>search_notes</code> を直接呼び出します。このボタンはAIを使いません。</p>
<button id="tool-search" type="button" disabled>ツールで検索する</button>
<pre id="tool-output" aria-live="polite">呼び出し結果がここに表示されます。</pre>
</details>
<footer>検索は部分一致です。データの保存・編集機能はありません。<br>このデモ自身は外部APIに接続しません。AIや拡張機能と接続すると、検索結果が接続先へ渡る場合があります。</footer>
</main>
</body>
</html>
style.css:画面を整える
表示を整えるCSSです。ブラウザ幅が狭いときは検索欄を折り返します。
:root {
font-family:system-ui,"Yu Gothic",Meiryo,sans-serif;
color:#173b37;
background:#f4f6f2;
font-synthesis:none;
line-height:1.7
}
* {
box-sizing:border-box
}
body {
margin:0
}
main {
max-width:960px;
margin:auto;
padding:40px 32px
}
header {
display:flex;
justify-content:space-between;
align-items:center;
gap:16px;
border-bottom:1px solid #cbd9cf;
padding-bottom:22px
}
.brand {
font-size:13px;
letter-spacing:.12em;
font-weight:750
}
.badge {
font-size:12px;
border:1px solid #b7cabc;
border-radius:30px;
padding:3px 12px
}
.eyebrow {
margin:32px 0 4px;
font-size:14px;
color:#456b60
}
h1 {
font-size:clamp(32px,5vw,48px);
line-height:1.25;
letter-spacing:-.03em;
margin:0 0 16px
}
h2 {
font-size:20px;
margin:0 0 20px
}
.intro {
color:#465e54;
margin-bottom:20px
}
.status {
font-size:14px;
border-left:4px solid #40826d;
padding:10px 16px;
background:#e5eee6
}
.panel {
background:#fff;
border:1px solid #d3ded4;
border-radius:14px;
padding:24px;
margin-top:24px
}
.fields {
display:flex;
gap:16px;
align-items:end
}
label {
display:block;
font-size:13px;
font-weight:650;
flex:1
}
.limit {
flex:0 0 130px
}
input,select,button {
font:inherit;
border-radius:7px;
min-height:46px
}
input,select {
display:block;
width:100%;
border:1px solid #91a69b;
padding:8px 12px;
margin-top:6px;
color:#183b36;
background:#fff
}
button {
border:0;
padding:10px 24px;
background:#205a49;
color:#fff;
font-weight:650;
cursor:pointer
}
button:disabled {
background:#697b73;
cursor:not-allowed
}
button:hover:enabled {
background:#164535
}
:focus-visible {
outline:3px solid #a86312;
outline-offset:3px
}
#result-status {
font-size:14px;
color:#42665a;
margin:20px 0 8px
}
.results {
list-style:none;
padding:0;
margin:0
}
.results li {
border-top:1px solid #e1e8e0;
padding:14px 0
}
.results h3 {
font-size:17px;
margin:0 0 3px
}
.results p {
color:#53655e;
font-size:14px;
margin:0
}
.results small {
color:#557366
}
summary {
font-size:16px;
font-weight:650;
cursor:pointer
}
details p {
font-size:14px
}
pre {
background:#f1f5f0;
padding:16px;
border-radius:6px;
white-space:pre-wrap;
overflow-wrap:anywhere;
font-size:13px;
color:#173b37
}
code {
font-family:ui-monospace,Consolas,monospace
}
footer {
font-size:12px;
color:#54685c;
margin-top:24px
}
@media(max-width:580px) {
main {
padding:24px 16px
}
.fields {
flex-wrap:wrap;
gap:12px
}
.fields label:first-child {
flex-basis:100%
}
.limit {
flex:1
}
.panel {
padding:18px
}
.brand {
font-size:10px;
letter-spacing:.06em
}
.badge {
white-space:nowrap
}
.intro br {
display:none
}
}
app.js:検索とツール登録を実装する
まずは次の全体を保存してください。このあと、入力の確認、登録、呼び出しの部分を順番に解説します。
// 架空の学習データ。実在する個人情報を入れないでください。
const notes = Object.freeze([
{ id: "n1", title: "OPFSでメモを保存", body: "OPFSはオリジン専用のファイル保存領域。保存と読み込みを練習する。" },
{ id: "n2", title: "Pythonの仮想環境", body: "venvで環境を分け、pipでライブラリを管理する。" },
{ id: "n3", title: "MCPツールの入力確認", body: "ツールの引数は、実行する関数でも型と値を確認する。" },
{ id: "n4", title: "OPFSの保存先", body: "OPFSのデータはオリジンごとに分かれる。ポートの違いにも注意する。" }
].map(Object.freeze));
export function searchNotes(input) {
// JSON Schemaの宣言だけに依存せず、実行時にも確認します。
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new TypeError("入力はオブジェクトにしてください。");
}
if (Object.keys(input).some(key => !["query", "limit"].includes(key))) {
throw new TypeError("queryとlimit以外の項目は指定できません。");
}
if (typeof input.query !== "string") {
throw new TypeError("queryは文字列にしてください。");
}
const query = input.query.trim();
if (query.length === 0 || query.length > 80) {
throw new RangeError("検索語は空白を除いて1〜80文字にしてください。");
}
const limit = input.limit === undefined ? 2 : input.limit;
if (!Number.isInteger(limit) || limit < 1 || limit > 4) {
throw new RangeError("limitは1〜4の整数にしてください。");
}
const word = query.toLowerCase();
const matches = notes.filter(note =>
`${note.title}\n${note.body}`.toLowerCase().includes(word)
);
return { query, total: matches.length, notes: matches.slice(0, limit).map(note => ({ ...note })) };
}
export const noteTool = {
name: "search_notes",
description: "架空の学習メモを検索します。queryには探すキーワードを1つ指定してください。タイトルと本文の大文字小文字を区別しない部分一致です。保存や編集は行いません。",
inputSchema: {
type: "object",
properties: {
query: { type: "string", minLength: 1, maxLength: 80, description: "検索語。例:OPFS、Python、MCP。自然言語の依頼文全体ではなくキーワードを渡します。" },
limit: { type: "integer", minimum: 1, maximum: 4, description: "最大表示件数。省略すると2。" }
},
required: ["query"],
additionalProperties: false
},
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: async input => JSON.stringify(searchNotes(input))
};
// 画面の検索はWebMCPの有効・無効に関係なく利用できます。
const form = document.querySelector("#search-form");
const resultStatus = document.querySelector("#result-status");
const results = document.querySelector("#results");
const toolButton = document.querySelector("#tool-search");
const toolOutput = document.querySelector("#tool-output");
const apiStatus = document.querySelector("#api-status");
function readInput() {
return { query: form.elements.query.value, limit: Number(form.elements.limit.value) };
}
function renderResult(result) {
results.replaceChildren();
resultStatus.textContent = `「${result.query}」:${result.total}件見つかりました(${result.notes.length}件表示)。`;
for (const note of result.notes) {
const item = document.createElement("li");
const title = document.createElement("h3");
const body = document.createElement("p");
const id = document.createElement("small");
title.textContent = note.title;
body.textContent = note.body;
id.textContent = note.id;
item.append(title, body, id);
results.append(item);
}
}
form.addEventListener("submit", event => {
event.preventDefault();
try {
renderResult(searchNotes(readInput()));
} catch (error) {
results.replaceChildren();
resultStatus.textContent = error.message;
}
});
async function setupWebMCP() {
try {
const context = document.modelContext;
if (!context || typeof context.registerTool !== "function") {
apiStatus.textContent = "WebMCPは利用できません。通常の検索は使えます。";
return;
}
await context.registerTool(noteTool);
apiStatus.textContent = "WebMCP:search_notesを登録しました。";
if (typeof context.getTools !== "function" || typeof context.executeTool !== "function") {
apiStatus.textContent += " このブラウザでは画面からの直接呼び出しに対応していません。";
return;
}
toolButton.disabled = false;
toolButton.addEventListener("click", async () => {
toolButton.disabled = true;
try {
const tool = (await context.getTools()).find(item => item.name === noteTool.name);
if (!tool) throw new Error("search_notesが見つかりません。ページを再読み込みしてください。");
// 対象ChromeのexecuteToolには、入力をJSON文字列で渡します。
const raw = await context.executeTool(tool, JSON.stringify(readInput()));
if (raw === null) throw new Error("ページ遷移が発生したため、結果を取得できませんでした。");
const result = JSON.parse(raw);
renderResult(result);
toolOutput.textContent = JSON.stringify(result, null, 2);
} catch (error) {
results.replaceChildren();
resultStatus.textContent = "ツールでの検索に失敗しました。";
toolOutput.textContent = `${error.name}: ${error.message}`;
} finally {
toolButton.disabled = false;
}
});
} catch (error) {
apiStatus.textContent = `WebMCPの登録に失敗しました:${error.name}。通常の検索は使えます。`;
}
}
await setupWebMCP();
手順2:ローカルサーバーで開く
作ったフォルダーをエクスプローラーで開き、アドレスバーへpowershellと入力してEnterを押します。そのフォルダーを作業場所としてPowerShellが開きます。
py -m http.server 8765 --bind 127.0.0.1
PowerShellは開いたままにして、Chromeでhttp://127.0.0.1:8765/を開きます。index.htmlをダブルクリックするfile://の開き方は使いません。JavaScriptモジュールとWebMCPをローカルHTTP経由で試すためです。
127.0.0.1は自分のPCを指します。ここでは開発サーバーを自分のPCからだけ接続できるようにしています。終了するときは、サーバーを動かしたPowerShellでCtrl+Cを押します。
最初はWebMCPが無効でも構いません。「検索する」を押し、OPFSを含むメモが2件表示されることを確かめてください。
手順3:WebMCPを有効にする
Chromeでchrome://flags/#enable-webmcp-testingを開き、WebMCP for testingをEnabledに変更します。表示される再起動操作でChromeを再起動し、デモのURLを開き直します。作業中のタブは先に保存してください。
画面に「WebMCP:search_notesを登録しました。」と表示されれば、登録処理まで進んでいます。
公式にはChrome 149からのOrigin Trialも案内されていますが、この記事はローカルでのflagによる試用に限定します。実験が終わったら、同じ設定をDefaultへ戻してChromeを再起動してください。Codex内のブラウザは別の環境なので、通常のChromeのflagを変えれば必ずそちらも変わるわけではありません。
解説:検索処理をツールにするときの3つの要点
1.入力の形はJSON Schema、実際の確認は関数で行う
inputSchemaは、ツールに渡す入力の形を説明します。queryは文字列、limitは1〜4の整数です。
ただし、宣言だけに任せず、searchNotes()でも確認しています。画面以外から呼ばれたときは、フォームの選択肢やmaxlengthを通らないからです。空白だけの検索語、数値として渡された検索語、余計な項目などを実行前に拒否します。
検索は大文字小文字を区別しない部分一致です。「Pythonの環境を分ける方法を教えて」のような文全体を渡しても、その文字列を含むメモがなければ0件です。自然言語から適切な検索語を選ぶ処理と、アプリの検索処理を混同しないようにします。
なお、コードの文字数判定はJavaScriptのlengthです。絵文字などは見た目の1文字が複数として数えられる場合があります。
2.検索結果はデータとして返す
executeは、検索結果をJSON.stringify()で文字列にして返します。返す値には、一致したメモの総件数を表すtotalと、件数制限を適用したnotesがあります。OPFSが2件一致してもlimit: 1なら、totalは2、返すメモは1件です。
readOnlyHintは、このツールがデータを書き換えないことを説明するヒントです。権限制御の代わりにはなりません。untrustedContentHintは、メモなどの内容を指示ではなくデータとして扱うためのヒントです。本物のメモを使う際は、返す情報の範囲をさらに検討してください。
画面への表示にはtextContentを使っています。検索語やメモをHTMLとして組み立てないので、文字列にタグが入っても、そのまま実行する設計にはなっていません。
3.ツールの登録失敗で通常検索を止めない
通常検索のイベントを先に登録し、WebMCPの準備を別のtryブロックで行います。APIがない場合や登録に失敗した場合でも、通常検索はそのまま使えます。
画面の「ツールで検索する」ボタンは、getTools()でツールを探し、executeTool()で呼びます。対象Chromeでは、入力をJSON文字列で渡します。返ってきた文字列をJSONに戻し、通常検索と同じ表示関数に渡しています。
動作確認:画面とWebMCPの結果を比べる
OPFSを入力し、通常の「検索する」を押します。次に「WebMCPから同じ検索を試す」を開き、「ツールで検索する」を押してください。
どちらも、n1「OPFSでメモを保存」とn4「OPFSの保存先」の2件が表示されます。ツール側ではJSONも表示されます。

開発者ツールのConsoleで経路を確認したい場合は、自分で作ったこのデモのページで次を実行します。
const tools = await document.modelContext.getTools();
const tool = tools.find(item => item.name === "search_notes");
if (!tool) throw new Error("search_notesが見つかりません。");
const result = await document.modelContext.executeTool(
tool,
JSON.stringify({ query: "OPFS", limit: 2 })
);
console.log(JSON.parse(result));
これはツールの直接呼び出しです。この結果だけでは、AIが自然言語を理解してツールを選べた証拠にはなりません。 また、Consoleからの呼び出しはデータを返すだけで、画面の検索結果を更新しません。画面の開発用ボタンには、結果を表示する処理を別に書いています。
| 試す入力 | 期待される結果 |
|---|---|
{"query":"OPFS","limit":2} |
totalは2、n1とn4 |
{"query":" pYtHoN "} |
前後の空白を除き、n2が1件 |
{"query":"OPFS","limit":1} |
totalは2、表示対象はn1の1件 |
{"query":"Rust"} |
totalは0、notesは空配列 |
{"query":" "} |
空白だけの入力として拒否 |
{"query":123} |
queryの型違いとして拒否 |
{"query":"OPFS","limit":0} |
件数の範囲外として拒否 |
実装の検証では、ネイティブAPIの正常系5ケース、不正入力12ケース、同名登録と不正JSONの2ケースを確認しました。通常検索は、WebMCPを無効にしたChromeとEdgeでも利用できました。これは試した環境・入力についての結果であり、全ブラウザや全AIクライアントへの対応保証ではありません。
Codexから公開ツールを呼ぶ
Codex側でページのWebMCPツールを取得・呼び出しできるブラウザ機能が利用できることが前提です。CLIにURLを貼るだけで必ず使える、という意味ではありません。利用できる機能やプランが異なる場合は、先にブラウザでの直接呼び出しまで確認してください。
- ローカルサーバーを起動したまま、Codexでこの作業を扱うタスクを開きます。
- 次のように依頼します。
- 実行記録に、ページの
search_notesツールと入力・戻り値があるかを確認します。
アプリ内ブラウザで http://127.0.0.1:8765/ を開いてください。
ページが公開しているWebMCPツールを使い、OPFSについてのメモを2件探してください。
検索ボタンのクリックやソースコードの読み取りではなく、ツールの戻り値を基に答えてください。
この制作では、Codexのアプリ内ブラウザがページのsearch_notesを検出し、query: "OPFS", limit: 2で呼び出して、n1とn4を取得しました。続けてPythonの1件検索とRustの該当なしも、CodexのWebMCP接続経由で確認しました。
検証は、制作中のCodexに用意した検索ケースを実行させたものです。未知の依頼への正答率、独立した新規会話での再現率、一般的なブラウザ操作より速いかどうかは測っていません。「AIなら必ず正しく探せる」とは結論づけません。
Codexから直接ツールを呼んだ場合、ページの検索欄や結果一覧は自動では変わりません。このツールは検索結果を返すだけで、表示を変更しないからです。結果はCodexが取得したツールの戻り値で確認します。
トラブル対処
WebMCPが利用できないと表示される
通常の検索が動くなら、まずChromeのバージョン、試用flag、再起動、URLを確認します。file://ではなく、指定したローカルHTTPのURLを開いてください。企業管理のブラウザでは、設定変更が制限されている場合があります。
登録したのにCodexから見つからない
通常Chromeのタブと、Codexが開いたブラウザのタブは別です。Codexが実際に開いたURLと、そのページで登録が完了しているかを確かめます。登録できても、クライアント側がWebMCPに対応していなければ呼び出せません。
不正な入力でUnknownErrorが返る
対象Chromeでは、検索関数のTypeErrorやRangeErrorが、呼び出し側ではUnknownErrorとして返るケースがありました。Consoleには「queryは文字列にしてください」など、関数が投げた理由が表示されます。JSONの構文エラーも区別して確認してください。
意図的に失敗例を試した際のConsoleエラーは、診断の一部です。正常な検索だけを行った場合には、アプリ由来の予期しないConsoleエラーは確認されませんでした。
Duplicate tool nameになる
同じページでsearch_notesを二度登録すると、対象ChromeではInvalidStateErrorになります。サンプルはページ読み込み時に一度だけ登録します。Consoleで登録コードを繰り返し実行した場合はページを再読み込みしてください。画面の再描画ごとに登録するアプリでは、登録の寿命を別途設計する必要があります。
サーバーを起動できない、app.jsが404になる
ポート8765が使用中なら、別の空いているポートを指定し、ブラウザとCodexへ渡すURLも同じ番号に変更します。app.jsが404なら、PowerShellを開いたフォルダーとファイル名を見直してください。
制約と向かない用途
このデモは小さなデータを対象にした部分一致検索です。大量データ、曖昧な質問への意味検索、複数人の権限管理にはそのまま使えません。
また、読み取り専用でも情報漏えいが起こらないとは限りません。アプリ自身の通信はローカルファイルの取得だけですが、接続するAIや拡張機能にはメモ本文が渡り得ます。まずは架空データを使い、本物のデータを返す前に、接続先のデータの扱いと公開範囲を確認してください。
クロスオリジンの公開設定はこのコードには追加していません。注文・削除などの処理へ発展させる場合は、入力確認に加え、認可、利用者の確認、再実行時の扱いを設計する必要があります。詳しくは公式のツール安全性ガイドを参照してください。
WebMCPは実験的な機能なので、全利用者が使う必須機能や、無人で動き続ける処理の土台として採用する前には、対応環境と運用方法を検討してください。
まとめ
WebMCPでは、JavaScriptの関数を、説明と入力の形を持つツールとしてブラウザへ登録できます。今回のメモ検索では、通常の画面とツールが同じ関数を呼ぶため、結果を照合しながら実装を確かめられました。Codexからのツール呼び出しも確認できました。
最初は読み取り専用の小さな機能を一つ選び、直接呼び出し、AI接続、入力エラーを分けて試してみてください。
次にメモを保存したい場合は、OPFS入門へ進めます。サーバー側で使うMCPとの違いを整理したい場合は、MCP TypeScript SDK v2入門も参考になります。
参考リンク
- Chrome for Developers:WebMCPの概要と試用条件
- Chrome for Developers:Imperative API
- Chrome for Developers:目的からツールを設計する
- Chrome for Developers:WebMCP tool security
- OpenAI Developers:WebMCPを使ったアプリの作例
サンプルコードはKanimiso-LabのMIT Licenseによる教材です。メモは架空データです。本文中の操作画面は、このサンプルを実際に動かしたスクリーンショットです。アイキャッチはメモ検索を表す図解です。


コメント