Skip to main content
Glama
hossein-finlex

WebMCP Contract Portfolio

WebMCP Contract Portfolio

商用向けフィナンシャルラインズ保険アプリです。navigator.modelContext を介して Claude が直接操作します。これは WebMCP(Web Model Context Protocol)API です。

「今後60日以内に満期を迎える契約はどれ?」 と尋ねると、目の前のテーブルが絞り込まれます。更新を依頼すると、Postgres と画面の両方で契約期間が先に進みます。アシスタントは、ページが公開しているツールスキーマを実行時に読み取って、そのページでできることを発見します。DOM スクレイピングも、セレクターも、スクリーンショットもありません。


実行方法

プロセスが3つあります。実際のアシスタントには Anthropic API キーが必要です。キーがない場合、モデル以外のすべても動作します(下記の キーなしの場合 を参照)。

# 1. Postgres  (port 5434 — 5432 and 5433 are already taken on this machine)
docker compose up -d

# 2. Backend
cd backend
uv venv .venv && uv pip install --python .venv/bin/python -r requirements.txt
cp .env.example .env          # then put your ANTHROPIC_API_KEY in it
.venv/bin/python seed.py      # 50 contracts
.venv/bin/uvicorn app.main:app --reload --port 8000

# 3. Frontend
npm install
PORT=3002 npm start           # http://localhost:3002

バックエンドは初回起動時にデータベースを自動でシードするため、seed.py が必要なのは、シードを作り直すかサイズを変更する場合(--force、--total 200)だけです。

キーなしの場合

  • backend/.env で MOCK_LLM=1 を設定すると、Claude の代わりに同一プロトコルを話すスクリプト化されたスタブが使われます。応答は定型文ですが、ツール呼び出しは実動ツール呼び出しであるため、すべてのアクチュエーションパスは動作します。トークンを消費せずにデモを行うのに便利です。

  • バックエンドが完全にない場合でも、アプリは読み込まれ、サイドバーの Direct tool calls パネルが、モデルを介さずに WebMCP ツールを呼び出します。


Related MCP server: Salesforce MCP Server

ドキュメント

WebMCP in Practice — アプリ内アシスタントが実際に持つ問題、WebMCP とは何か、ブラウザ・バックエンド・モデルがどのように通信するかを、ツール呼び出しシーケンスとサーバーからページへのハンドオフの図とともに説明しています。ファイルをブラウザで開いてください。

CLAUDE.md — このリポジトリで作業するためのオリエンテーション:コマンド、レイヤー規則、すでにここで遭遇済みの落とし穴。


試してみる

依頼

何が表示されるか

「今後60日以内に満期を迎える契約はどれ?」

テーブルが絞り込まれ、フィルターバーが紫になります

「Allianz との全契約を表示して。」

保険会社で絞り込みます

「Novaris の D&O 契約を探して開いて。」

検索後、明細ビューへ移動します

「Lumen Digital Health のサイバー保険契約を12か月更新して。」

契約期間が12か月進み、更新フラグがクリアされ、行が点滅します

「Markel で、Cortex Robotics に対し new Cyber 契約をを設定して。限度額は 3m。」

新規契約フォームが プレフィルされた状態で開きます。ただし送信はされません

「FL-0146 の保険料金を95,000に引き上げて。」

契約がその場で更新されます

「保険会社ごとの保険料合計は?」

SQL で集計され、内訳が表示されます。契約内容はコンテキストに入り込みません

「限度額が最大の2つの契約はどれ?」

sort_by + limit を SQL 内で実行。テーブルが並び替わり、正確に2件が表示されます

「今後30日以内に満期のものはすべて更新して。」

サーバーツールがバッチをプレビューします。確認すると、1つのトランザクションでコミットされ、WebMCP が結果ページへナビゲートします

「今後90日分の更新レポートを作成して。」

サーバーで生成し、show_report で画面に表示されます

「FL-0142 の価格は市場と釣り合っていますか?」

アプリ外部のデータとのベンチマーク — ページからはそのデータへの経路はありません

左ペインを囲む紫色は、アシスタントが操作していることを示します。右下の WebMCP パネル には登録されたすべてのツールが一覧表示され、クリックすると Claude が実際に受信する JSON Schema を確認できます。また、境界を通過する各呼び出しをログに記録します。

すべて手作業でも機能します:行をクリックし、Edit を押し、Renew を押すだけです。人間とエージェントは同じ API と画面同じ React 状態を共有するため、別の「エージェントモード」や、両者が矛盾し合うことはありません。


アーキテクチャ

おもしろいところは、エージェントが実際にページの外に存在するという点です。これが WebMCP の実際の仕組みです。ブラウザはエージェントにツールリストを渡し、そのツール呼び出しをマーシャリングして返します。

browser (React)                backend (FastAPI)              Claude
  │  user_message + tool list        │                           │
  │─────────────────────────────────>│  messages.stream(tools=…)  │
  │                                  │──────────────────────────> │
  │          text_delta              │      streamed text         │
  │<─────────────────────────────────│<─────────────────────────── │
  │          tool_use                │   stop_reason=tool_use     │
  │<─────────────────────────────────│<─────────────────────────── │
  │                                                               │
  │  executeTool() → REST → Postgres → React state → repaint      │
  │                                                               │
  │          tool_result             │                           │
  │─────────────────────────────────>│  append, continue loop     │
  │                                  │──────────────────────────> │
  │          turn_end                │   stop_reason=end_turn     │
  │<─────────────────────────────────│<─────────────────────────── │

Claude は DOM を一切見ません。バックエンドはツール実装を一切保持しません。バックエンドが行うのは Claude が呼び出そうとしているツールを伝えることだけです。すべてのツールはブラウザ内で、ライブな React 状態に対して実行されます。

docker-compose.yml            Postgres 17 on :5434
backend/
├── seed.py                   seeding CLI
└── app/
    ├── main.py               FastAPI: REST + /ws/agent
    ├── db.py                 engine, session dependency, readiness wait
    ├── models.py             SQLModel table + validated API schemas
    ├── repository.py         all SQL lives here
    ├── seed_data.py          12 curated contracts (terms relative to today)
    ├── seed_gen.py           deterministic generator for the rest
    ├── queries.py            filtering, sorting and aggregation in SQL
    ├── server_tools.py       tools that run here, not in the page
    ├── artifacts.py          batch records and reports
    ├── llm.py                Claude client + the mock provider
    └── agent_ws.py           the bridge: routes each tool call to the right side
src/
├── webmcp-polyfill.js        polyfill + agent-side bridge
├── useWebMcpTools.js         registration lifecycle hook
├── api.js                    REST client
├── App.js                    owns state; registers the seven tools
├── agent/agentClient.js      WebSocket client; executes tool calls
└── components/               ContractList · ContractDetail · NewContractForm ·
                              PortfolioSummary · BatchResult · ReportView ·
                              AssistantChat · ToolInspector

なぜ手動のエージェンティックループか

Anthropic SDK のツールランナーは、プロセス内でツールを実行します。このではツールはユーザーのブラウザにあります。そのため agent_ws.py が stop_reason == "tool_use" ループを手動で駆動し、各結果を WebSocket 経由で待ち受けます。並列なツール呼び出しは並行して実行され、API が期待するように単一の user メッセージで返されます。

2つのツールサーフェス、1つのツールリスト

Claude はフラットなリストを1つ受け取ります。一部のツールがブラウザで実行され、一部がバックエンドで実行されることを Claude は知ることも、気にすることもありません。しかし、この分割こそが、ここでの最重要設計判断です。

ページツール(WebMCP、navigator.modelContext)は、ページ 自身の ケイパビリティです。ユーザーに変更を目視させたい場合や、単一レコードの作業に使用します。それらはライブな React 状態に対して実行されます。

サーバーツールは FastAPI プロセス内で実行され、ブラウザに一切触れません。UI を操作することがまったく意味をなさない場合に使います。

サーバーツール

なぜ UI には合わないか

run_renewal_batch

14件の契約をページ経由で更新すると、モデルを14回往復することになり、その途中で停止する可能性があります。呼び出し1回・トランザクション1回・全か無かです。

generate_renewal_report

文書を組み立てるのは計算であり、クリックではありません。

benchmark_rates

マーケットレートデータは外部にあります。UI を自動化するだけでは、決して見つけることはありません。

これらを結びつけるパターンはハンドオフです。サーバーの作業は不可視であるため、サーバーツールは artifact id を返し、アシスタントはそれを画面に表示するページツールを呼び出します。

run_renewal_batch(expiring_within_days=30)      ← server: previews, changes nothing
   → "4 contracts, €413,400. Shall I commit?"
run_renewal_batch(..., commit=true)             ← server: one transaction
   → batch_id: BATCH-0002
show_batch_result(batch_id="BATCH-0002")        ← page:  navigates the user there

作業はページ外で発生します。しかし、結果 は最終的にページ上で表示されます。チャットはこの2つを異なる色で表示し(紫=UI が動いた・琥珀=それ以外の場所で作業)、インスペクタは2つを別見出しに分けるため、どちらの網が何をしたかは明白です。

バルク変更はデフォルトでプレビューされます。 run_renewal_batch は、API をcommit=true にしない限りドライランです。モデルが80%強の確信度でそれを望んでいたからといってバルク変更が実行されてしまうことはあってはいけません。アシスタントはまず計画を示し、待ちます。

ページツール

ツール

画面上の効果

search_contracts

表示中のテーブルをフィルタリングし、ソートし、件数を制限する(これがエージェントの検索が可視である理由です)

summarise_portfolio

SQL で集計し、内訳ビューを開きます

get_contract

なし — 完全なレコードを返します

navigate

ビューの切り替え

prefill_new_contract_form

フォームを埋めて停止します。送信するのは人間です。

create_contract

Postgres に書き込み、新しい契約を開きます

update_contract

行をその場で更新します

renew_contract

契約期間を1つ先に進め、更新フラグをクリアします

show_batch_result

サーバーで生成されたバッチレコードを表示します

show_report

サーバーで生成されたレポートを表示します

ツールサーフェスはコストの判断

search_contracts に sort_by / sort_dir / limit が追加され、summarise_portfolio が追加されたのは特定の理由があります。「合計保険額が最大の2つの契約はどれか」と尋ねられた際、アシスタントは当初 search_contracts({}) を呼び、50行すべて into コンテキストに引き込んで自分でソートしていました — 6,809 input tokens と2回ツール呼び出し。ソートと limit を SQL に押し込むと、同じ質問には 518 tokens で、呼び出し1回で済み、演算はモデルに行わせるのではなくデータベースが行います。

エージェントが「小さな回答のために多くのことを読む」場合は、それはプロンプトの問題ではありません。ツールが足りないのです。

ツールの引数名は、API とデータベースのカラム名に完全一致しています(snake_case で一貫)。したがって、不具合が潜むマッピングレイヤーはどこにもありません。

prefill_new_contract_form は、見ておくべき human-in-the-loop ケースです。エージェントが入力を行い、人間が決定を保持します。システムプロンプトは、詳細が推定された場合は常に、create_contract よりもこれを優先するよう Claude に指示しています。


データ

50件の契約:12件は厳選された ainit ones(ノートに背景のあるもの)+生成された38件。

ジェネレーター(seed_gen.py)は決定論的で、ランダムデータのスクリプトが通常おかす2つを考慮しています:

  • 相関する数値。 保険料は限度額に対してのレートで、商品ごとのレート帯(D&O 0.35–0.75%、Cyber 0.8–1.6%、…)があります。免責金額も限度額とスケールします。そうでないと、アシスタントがポートフォリオについて発言することには何の信頼性もありません。

  • 現実的な満期パイプライン。 契約期間は、目標のステータスミックスに対して 今日 基準で配置されます — 約10%が満期切れ、25%が90日以内にに満期、残りが有効、さらに2つの ステータスdraft があります。つまり「何を更新する必要があるか」は常に生の質問であり、6か月後に再シードしても、月落ちした本ではなく、生きた本で表示されます。

ステータス(active / expiring / expired / draft)は、契約期間から計算されており、保存はないため、ずれることはありません。renewal_pending はブローカーが設定する独立したフラグです。

被保険企業はすべて架空です。保険会社の名前は実際のマーケット参加者であり、どの保険ブローカーのデモでもそうであるような使われ方で、ここにあるものは実在の保険契約を表すものではありません。


ポリフィル

src/webmcp-polyfill.js は2つの別々の役割を果たします。この区別は重要です:

ページ側(実際のポリフィル) ネイティブ navigator.modelContext は、まだどこでも出荷されているとは限りません。それが存在しない場合、このファイルは提案されたサーフェスを実装するスタブ — registerTool、unregisterTool、provideContext — をインストールし、登録と実行のすべてを DevTools コンソールにログ出力します。アプリは決してクラッシュせず、ヘッダーのバッジがどちらを取得したたかを示します。

エージェント側(ブリッジ)。 「エージェントになる」ためのページ向けAPIは存在しないため、このモジュールは登録済みのすべてのツールをミラーリングし、さらにlistTools() / executeTool()を公開する。agentClient.jsはそのブリッジだけを使用し、それ以外は何も使用しない。ミラーはネイティブブラウザとポリフィルされたブラウザの両方で維持されるため、どちらの場合でも動作は同一である。

DevToolsコンソールから:

await webmcp.listTools()
await webmcp.executeTool('search_contracts', { product: 'Cyber', status: 'expiring' })
await webmcp.executeTool('renew_contract', { contract_id: 'FL-0142', months: 24 })

知っておくべきReactの罠

ツールを登録する明白な方法は間違っている:

useEffect(() => {
  const h = registerTool({ name: 'x', execute: () => doThingWith(contracts) });
  return () => h.unregister();
}, []);                       // `contracts` is frozen at mount forever

状態が変わるたびに再登録するのも間違っている。ブラウザからはツールセット全体が絶えず入れ替わって見えることになり、実行中の呼び出しがエージェントの足元から引き抜かれる可能性がある。

useWebMcpTools.jsは安定した間接参照により一度だけ登録する:登録されたexecuteは、すべてのレンダーが更新するrefから実際のハンドラを解決する。登録は安定しており、ハンドラは常に現在の状態を参照する。React StrictModeの二重マウントでは、登録されるツールが正確に7つであることを確認できる。14でも0でもない。


注意事項と制限

  • SEED_TOTAL / seed.py --totalはブックのサイズを変更する。フィルタリング、ソート、制限はすでにSQL(queries.py)内で実行されているため、はるかに大きなブックに必要なのはリストビューのページネーションだけである。

  • バッチレコードとレポートはメモリ内に保持される(artifacts.py、上限50件)。これらはドメインデータではなくジョブ出力であり、本番デプロイでは永続化されるだろう。一括変更のレコードは監査証跡だからだ。

  • benchmark_ratesは架空の数値を返す。これはマーケットデータ購読の代役を務める。重要なのは、それがブラウザからは到達経路のないデータだということである。

  • 新しい契約IDはmax(id) + 1から生成される。2つの同時作成は衝突する可能性がある。修正はデータベースシーケンスを使う1行で済む。

  • 会話はWebSocket接続ごとにメモリ内に保持されるため、リロードすると新しいチャットが始まる。ポートフォリオ自体はPostgresに格納されており、永続化される。

  • adaptive thinkingを伴うoutput_config: {effort: "medium"}はllm.pyで設定されている。アシスタントに複数ステップの作業をより慎重に計画させたい場合は、highに引き上げればよい。

  • サーバー側の拒否フォールバックが有効になっている。アカウントまたはSDKバージョンがパラメータを拒否する場合、llm.pyは警告をログに記録し、ターンを失敗させる代わりにプレーンなパスで一度再試行する。

Related MCP Connectors

Related MCP Servers