Skip to main content
Glama
hossein-finlex

WebMCP Contract Portfolio

WebMCP Contract Portfolio

A commercial financial-lines insurance app that Claude operates directly through navigator.modelContext — the WebMCP (Web Model Context Protocol) API.

Ask "which contracts are expiring in the next 60 days?" and the table filters in front of you. Ask for a renewal and the term rolls forward in Postgres and on screen. The assistant discovers what the page can do at runtime by reading the tool schemas the page publishes — no DOM scraping, no selectors, no screenshots.


Running it

Three processes. You need an Anthropic API key for the real assistant; without one, everything except the model still works (see Without a key below).

# 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

The backend seeds the database itself on first start, so seed.py is only needed if you want to re-seed or change the size (--force, --total 200).

Without a key

  • MOCK_LLM=1 in backend/.env swaps Claude for a scripted stub that speaks the identical protocol. Replies are canned; the tool calls are real, so every actuation path still works. Useful for demoing without spending tokens.

  • With no backend at all, the app still loads and the Direct tool calls panel in the sidebar invokes the WebMCP tools with no model in the loop.


Related MCP server: Salesforce MCP Server

Documentation

WebMCP in Practice — what problem an in-app assistant actually has, what WebMCP is, and how the browser, backend and model communicate, with diagrams of the tool-call sequence and the server-to-page handoff. Open the file in a browser.

CLAUDE.md — orientation for working in this repository: commands, the layering rules, and the traps already hit here.


Try these

Ask

What you should see

"Which contracts are expiring in the next 60 days?"

The table narrows, the filter bar turns purple

"Show me everything with Allianz."

Filters by insurer

"Find the Novaris D&O contract and open it."

Searches, then navigates to the detail view

"Renew the Lumen Digital Health cyber policy for 12 months."

Term rolls 12 months, renewal flag clears, row flashes

"Set up a new Cyber contract for Cortex Robotics with Markel, 3m limit."

The new-contract form opens prefilled but not submitted

"Put FL-0146's premium up to 95,000."

The contract updates in place

"What's the total premium by insurer?"

Aggregated in SQL, shown as a breakdown — no contracts pulled into context

"Which two contracts have the largest limits?"

sort_by + limit in SQL; the table reorders and shows exactly two

"Renew everything expiring in the next 30 days."

A server tool previews the batch. Confirm, and it commits in one transaction, then WebMCP navigates you to the result

"Build me a renewal report for the next 90 days."

Generated on the server, then show_report puts it on screen

"Is FL-0142 priced in line with the market?"

Benchmark data from outside the app — the page has no route to it

The purple border around the left pane means the assistant is driving. The WebMCP panel at the bottom right lists every registered tool — click one to see the JSON Schema Claude actually receives — and logs each call as it crosses the boundary.

Everything also works by hand: click a row, hit Edit, hit Renew. Human and agent share the same API and the same React state, so there is no separate "agent mode" and no way for the two to disagree.


Architecture

The interesting part is that the agent genuinely lives outside the page, which is how WebMCP actually works: the browser hands the agent a tool list and marshals its tool calls back in.

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 never sees the DOM. The backend holds no tool implementations — it only reports what Claude wants to call. Every tool executes in the browser against live React state.

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

Why a manual agentic loop

The Anthropic SDK's tool runner executes tools in-process. Here the tools live in the user's browser, so agent_ws.py drives the stop_reason == "tool_use" loop by hand and awaits each result over the WebSocket. Parallel tool calls are executed concurrently and returned in a single user message, as the API expects.

Two tool surfaces, one tool list

Claude receives one flat list. It neither knows nor cares that some of those tools run in the browser and some run in the backend — but the split is the most important design decision here.

Page tools (WebMCP, navigator.modelContext) are the page's capabilities. Use them when the user should watch the change happen, and for single-record work. They execute against live React state.

Server tools run in the FastAPI process and never touch the browser. Use them when driving a UI would be the wrong shape entirely:

Server tool

Why it does not belong in the UI

run_renewal_batch

Renewing 14 contracts through the page is 14 round-trips through the model, any of which can stop halfway. One call, one transaction, all-or-nothing.

generate_renewal_report

Assembling a document is computation, not clicking.

benchmark_rates

Market rate data lives outside the application. No amount of UI automation would find it.

The pattern that ties them together is the handoff. Server work is invisible — so a server tool returns an artifact id, and the assistant then calls a page tool to put it on screen:

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

Work happens off-page; the result still lands on-page. The chat colours the two differently (purple = the UI moved, amber = work happened elsewhere) and the inspector lists them under separate headings, so which side did what is never a guess.

Bulk changes preview by default. run_renewal_batch is a dry run unless commit=true. A bulk mutation should not happen because a model was 80% sure it was wanted — the assistant shows the plan and waits.

The page tools

Tool

Effect on screen

search_contracts

Filters, sorts and limits the visible table (this is why an agent search is visible)

summarise_portfolio

Aggregates in SQL and opens the breakdown view

get_contract

None — returns the full record

navigate

Switches view

prefill_new_contract_form

Fills the form and stops. The human submits.

create_contract

Writes to Postgres, opens the new contract

update_contract

Updates the row in place

renew_contract

Rolls one term forward, clears the renewal flag

show_batch_result

Displays a server-produced batch record

show_report

Displays a server-produced report

Tool surface is a cost decision

search_contracts gained sort_by / sort_dir / limit, and summarise_portfolio was added, for a specific reason. Asked "which two contracts have the largest sum insured?", the assistant originally called search_contracts({}), pulled all 50 rows into context and sorted them itself — 6,809 input tokens and two tool calls. With sort and limit pushed into SQL the same question costs 518 tokens and one call, and the arithmetic is the database's rather than the model's.

If your agent is reading a lot to answer a little, that is a missing tool, not a prompting problem.

Tool argument names match the API and database columns exactly (snake_case throughout), so there is no mapping layer anywhere for a bug to hide in.

prefill_new_contract_form is the human-in-the-loop case worth noticing: the agent does the typing, the person keeps the decision. The system prompt tells Claude to prefer it over create_contract whenever a detail was inferred.


The data

50 contracts: 12 curated ones with a story in their notes, plus 38 generated.

The generator (seed_gen.py) is deterministic and cares about two things a random-data script usually gets wrong:

  • Correlated figures. Premium is a rate on the limit, with a rate band per product (D&O 0.35–0.75%, Cyber 0.8–1.6%, …), and deductibles scale with the limit. Otherwise nothing the assistant says about the book sounds credible.

  • A realistic expiry pipeline. Terms are placed relative to today against a target status mix — roughly 10% expired, 25% expiring inside 90 days, the rest active, plus two drafts. So "what needs renewing?" is always a real question, and re-seeding in six months still produces a live-looking book rather than one that has entirely lapsed.

Status (active / expiring / expired / draft) is computed from the term, never stored, so it cannot drift. renewal_pending is a separate flag a broker sets.

All insured companies are fictional. Insurer names are real market participants, used the way any broker demo uses them; nothing here represents a real policy.


The polyfill

src/webmcp-polyfill.js does two separate jobs, and the distinction matters:

Page side (the actual polyfill). Native navigator.modelContext is not shipped everywhere yet. If it is missing, the file installs a stub implementing the proposed surface — registerTool, unregisterTool, provideContext — that logs every registration and invocation to the DevTools console. The app never crashes, and the header badge tells you which one you got.

Agent side (a bridge). There is no page-facing API for "be the agent", so the module also mirrors every registered tool and exposes listTools() / executeTool() on top. agentClient.js uses that bridge and nothing else. The mirror is maintained in both native and polyfilled browsers, so behaviour is identical either way.

From the DevTools console:

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

The React trap worth knowing about

The obvious way to register a tool is wrong:

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

Re-registering on every state change is also wrong — the browser would see the whole tool set churn constantly, and an in-flight call could be yanked out from under the agent.

useWebMcpTools.js registers once with a stable indirection: the registered execute resolves the real handler from a ref that every render refreshes. The registration is stable; the handlers always see current state. Under React StrictMode's double-mount you can confirm exactly seven tools are registered, not fourteen and not zero.


Notes and limits

  • SEED_TOTAL / seed.py --total change the book size. Filtering, sorting and limiting already run in SQL (queries.py), so the only thing a much larger book needs is pagination in the list view.

  • Batch records and reports live in memory (artifacts.py, capped at 50). They are job output rather than domain data; a real deployment would persist them, since a bulk-change record is an audit trail.

  • benchmark_rates returns invented numbers. It stands in for a market-data subscription — the point is that it is data the browser has no route to.

  • New contract ids come from max(id) + 1. Two simultaneous creates could collide; a database sequence is the one-line fix.

  • The conversation lives in memory per WebSocket connection, so a reload starts a fresh chat. The portfolio itself is in Postgres and persists.

  • output_config: {effort: "medium"} with adaptive thinking is set in llm.py; raise it to high if you want the assistant to plan multi-step work more carefully.

  • Server-side refusal fallbacks are enabled. If your account or SDK version rejects the parameter, llm.py logs a warning and retries once on the plain path rather than failing the turn.

F
license - not found
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    An MCP server implementation that integrates Claude with Salesforce, enabling natural language interactions with Salesforce data and metadata for querying, modifying, and managing objects and records.
    6
    15
    3,172
    166
    MIT
  • F
    license
    B
    quality
    C
    maintenance
    A customer and product management MCP server using SQLite. It enables Claude Desktop users to manage client and product data through natural language interactions.
    9
    1

View all related MCP servers

Related MCP Connectors

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/hossein-finlex/web-mcp-hello'

If you have feedback or need assistance with the MCP directory API, please join our Discord server