Skip to main content
Glama
WebMCP Contract Portfolio

WebMCP Contract Portfolio

WebMCP Contract Portfolio

一款由 Claude 通过 navigator.modelContext —— WebMCP(Web Model Context Protocol)API 直接操作的商业金融险应用。

问 “未来 60 天有哪些合同到期?”,表格会在你眼前筛选。要求续保,期限便会在 Postgres 和屏幕上向前推进。助手通过阅读页面发布的工具 schema,在运行时发现页面能做什么——不做 DOM 抓取、不用选择器、不依赖截图。


运行

需要三个进程。要使用真正的助手,你需要一个 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 换成一个使用相同协议的脚本化桩。回复是预设好的;工具调用是真实的,因此每条执行路径仍然有效。适合在不消耗 token 的情况下演示。

  • 即使完全没有后端,应用依然可以加载,侧边栏中的 直接工具调用面板会在没有模型参与的情况下调用 WebMCP 工具。


Related MCP server: Salesforce MCP Server

文档

WebMCP 实践 —— 应用内助手实际面临什么问题、WebMCP 是什么,以及浏览器、后端和模型之间如何通信,配有工具调用序列和服务端到页面交接的示意图。请在浏览器中打开该文件。

CLAUDE.md —— 在本仓库中工作的指引:命令、分层规则,以及这里已经踩过的坑。


试试这些

提问

预期效果

"未来 60 天有哪些合同到期?"

表格收窄,筛选栏变成紫色

"显示所有 Allianz 的合同。"

按保险公司筛选

"找到 Novaris 的 D&O 合同并打开它。"

搜索,然后导航到详情视图

"将 Lumen Digital Health 的网络保险续保 12 个月。"

期限滚动 12 个月,续保标记清除,行闪烁

"为 Cortex Robotics 与 Markel 新建一份 Cyber 合同,限额 300 万。"

新合同表单打开,已预填但未提交

"将 FL-0146 的保费提高到 95,000。"

合同就地更新

"按保险公司统计的保费总额是多少?"

在 SQL 中聚合,显示为明细——不把任何合同拉入上下文

"哪两份合同的限额最大?"

在 SQL 中使用 sort_by + limit;表格重新排序,并且恰好显示两份

"续保未来 30 天内到期的所有合同。"

一个服务器工具预览批次。确认后,它会在一个事务中提交,然后 WebMCP 将你导航到结果

"为我生成一份未来 90 天的续保报告。"

在服务器上生成,然后 show_report 将其显示在屏幕上

"FL-0142 的定价与市场一致吗?"

基准数据来自应用之外——页面没有获取它的路径

左侧窗格周围的紫色边框表示助手正在驱动。右下角的 WebMCP 面板列出每个已注册的工具——点击一个即可查看 Claude 实际收到的 JSON Schema——并记录每次跨越边界的调用。

一切也都可以手动完成:点击一行,点击“编辑”,点击“续保”。人类和代理共享相同的 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 消息中返回。

两个工具面,一份工具列表

Claude 收到一份扁平列表。它既不知道也不关心其中一些工具在浏览器中运行、另一些在后端运行——但这个拆分是这里最重要的设计决策。

页面工具(WebMCP、navigator.modelContext)是页面的能力。当用户应当看到变化发生,以及处理单条记录的工作时,使用它们。它们针对实时 React 状态执行。

服务器工具在 FastAPI 进程中运行,从不接触浏览器。当驱动 UI 完全不是正确的形态时,使用它们:

服务器工具

为什么它不属于 UI

run_renewal_batch

通过页面续保 14 份合同需要经过模型的 14 次往返,其中任何一次都可能中途停止。一次调用、一个事务、全有或全无。

generate_renewal_report

组装文档是计算,而不是点击。

benchmark_rates

市场费率数据存在于应用之外。再多的 UI 自动化也找不到它。

把它们联系在一起的模式是交接。服务器工作是不可见的——因此服务器工具会返回一个产物 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

工作在页面之外进行;但结果仍然落在页面上。聊天界面用不同颜色区分两者(紫色 = UI 发生了变化,琥珀色 = 工作发生在别处),检查器将它们列在不同的标题下,因此哪一侧做了什么永远无需猜测。

批量变更默认先预览。 run_renewal_batch 默认是试运行,除非 commit=true。批量变更不应仅仅因为模型有 80% 的把握认为用户想要它,就贸然执行——助手会先展示计划并等待。

页面工具

工具

屏幕上的效果

search_contracts

筛选、排序并限制可见表格(这就是为什么代理搜索是可见的)

summarise_portfolio

在 SQL 中聚合,并打开明细视图

get_contract

无——返回完整记录

navigate

切换视图

prefill_new_contract_form

填写表单并停下。由人工提交。

create_contract

写入 Postgres,打开新合同

update_contract

就地更新行

renew_contract

向前滚动一个期限,清除续保标记

show_batch_result

显示服务器生成的批次记录

show_report

显示服务器生成的报告

工具面是一项成本决策

search_contracts 增加了 sort_by / sort_dir / limit,并新增了 summarise_portfolio,这是出于一个特定的原因。当被问到 “哪两份合同的保额最大?” 时,助手最初调用 search_contracts({}),将所有 50 行拉入上下文并自行排序——6,809 个输入 token 和两次工具调用。把排序和限制下推到 SQL 后,同一个问题只需 518 个 token 和一次调用,而且算术是由数据库完成的,而不是模型。

如果你的代理读了很多却只回答很少,那是缺少工具,而不是提示词的问题。

工具参数名与 API 和数据库列完全一致(全部使用 snake_case),因此任何地方都没有映射层可以让 bug 藏身。

prefill_new_contract_form 是一个值得注意的人机协作案例:代理负责输入,人保留决定权。系统提示词告诉 Claude,凡是推断出的细节,都应优先使用它而不是 create_contract。


数据

50 份合同:12 份经过精心挑选、备注中带有故事背景,外加 38 份生成的。

生成器(seed_gen.py)是确定性的,并且关注随机数据脚本通常会搞错的两件事:

  • 关联的数字。 保费是限额的一定比例,每种产品有一个费率区间(D&O 0.35–0.75%,Cyber 0.8–1.6%,……),免赔额随限额成比例缩放。否则,助手对这份保单组合所说的任何话听起来都不可信。

  • 现实的到期管道。 期限是相对于今天,按照目标状态组合来放置的——大约 10% 已过期、25% 在 90 天内到期、其余为有效状态,外加两份草稿。因此“哪些需要续保?”始终是一个真实的问题,而且六个月后重新填充数据仍会产生一份看起来活跃的保单组合,而不是一份已经全部失效的组合。

状态(active / expiring / expired / draft)是从期限计算得出的,从不存储,因此不会发生漂移。renewal_pending 是经纪人设置的一个独立标志。

所有被保险的公司都是虚构的。保险公司名称是真实的市场参与者,其使用方式与任何经纪人演示相同;这里没有任何内容代表真实保单。


Polyfill

src/webmcp-polyfill.js 承担两项不同的工作,区分它们很重要:

页面侧(真正的 polyfill)。 原生 navigator.modelContext 尚未在所有环境中提供。如果缺失,该文件会安装一个桩,实现提议的接口——registerTool、unregisterTool、provideContext——它会把每次注册和调用记录到 DevTools 控制台。应用永远不会崩溃,顶部徽章会告诉你你用的是哪一个。

Agent 侧(一座桥)。 没有面向页面的 API 可以"充当 agent",因此 该模块还镜像了每个已注册的工具,并在其上暴露 listTools() / executeTool()。agentClient.js 只使用这座桥,不依赖其他任何东西。 镜像在本机浏览器和 polyfill 浏览器中均保持维护,因此两种方式下行为完全一致。

从 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

在每次状态变更时重新注册同样是错误的——浏览器会看到整个工具集不断翻腾,而一个进行中的调用可能会被从 agent 脚下抽走。

useWebMcpTools.js 通过一个稳定的间接层只注册一次:注册的 execute 从一个每次渲染都会刷新的 ref 中解析真正的处理器。注册是稳定的;处理器始终能看到当前状态。在 React StrictMode 的双重挂载下,你可以确认恰好注册了七个工具, 既不是十四个,也不是零个。


备注与限制

  • SEED_TOTAL / seed.py --total 改变书籍规模。过滤、排序和 限制已经在 SQL 中执行(queries.py),因此更大的书籍唯一需要的就是列表视图中的分页。

  • 批量记录和报告存放在内存中(artifacts.py,上限为 50)。它们 是任务输出而非领域数据;真实部署会持久化它们, 因为批量变更记录就是一条审计轨迹。

  • benchmark_rates 返回虚构的数字。它代替市场数据订阅—— 关键在于这些是浏览器没有途径获取的数据。

  • 新的合约 id 来自 max(id) + 1。两个并发的创建操作可能会 冲突;使用数据库序列是一行代码就能解决的修复。

  • 对话按每个 WebSocket 连接保存在内存中,因此重新加载会开启 一个新的聊天。投资组合本身存储在 Postgres 中并会持久化。

  • output_config: {effort: "medium"} 与自适应思考在 llm.py 中设置;如果你希望助手更仔细地规划多步骤 工作,可以将其提高到 high。

  • 服务端拒绝回退已启用。如果你的账户或 SDK 版本 拒绝了该参数,llm.py 会记录一条警告并在普通路径上重试一次, 而不是让该轮对话失败。

Related MCP Connectors

Related MCP Servers