A minimal working example of the pattern: one Node process holds a shared,
in-memory store; a browser tab (Lit web component, signal-backed input)
syncs to it over WebSocket; an MCP server exposes get/set tools over the
same store, so an agent and a human can both read and write the same field
live.
browser tab (Lit) <--WS--> [ mcp-tenant-lib ] <--MCP/HTTP--> agent (Claude, etc.)
Store (single
source of truth)
This repo is an npm workspace with two packages:
-
packages/mcp-tenant-lib/— generic tenant/session bookkeeping + MCP/HTTP/WS wiring, reusable across projects. -
packages/mcp-form/— this form app: field config, MCP tool definitions, and the Lit UI. Depends onmcp-tenant-lib.
Fields are declared once, in packages/mcp-form/config/fields.json — the
UI and the MCP tools are both generated from that file. Add a field there and
it's immediately gettable/settable, no new code required.
npm install
npm start -w mcp-formThis starts an HTTP+WebSocket server (default http://localhost:8765)
serving the form page, with MCP exposed over streamable HTTP at /mcp on
the same port.
Open http://localhost:8765 in a browser to see the live form.
The server exposes MCP over streamable HTTP at /mcp on the same port as
the web UI (needs the server already running via npm start -w mcp-form):
{
"mcpServers": {
"mcp-form": {
"type": "http",
"url": "http://localhost:8765/mcp"
}
}
}Add this to claude_desktop_config.json, .mcp.json for Claude Code, or
your Copilot MCP config file.
Then, in a conversation:
-
"What's the form URL?" → calls
get_form_url, gives you the link to open -
"What is the firstName field value?" → calls
get_field -
"Set firstName to Joe" → calls
set_field, which updates the store and broadcasts to every open browser tab over WebSocket — you'll see the input update live, with no page reload
Edit packages/mcp-form/config/fields.json:
{
"fields": [
{ "name": "firstName", "label": "First Name", "type": "string", "default": "" },
{ "name": "email", "label": "Email", "type": "string", "default": "" }
]
}Restart the server. The new field renders in the UI automatically, and
get_field/set_field accept it immediately (the MCP tool schema's field
enum is generated from this file at startup).
-
Per-field types beyond string —
typein the config is currently informational only; wire it into both the<input>'stype=attribute and a per-field zod schema (number,boolean, etc.) forset_field. -
Risk tiers — add a
risk: "safe" | "destructive"field to the config and have the MCP tool require an extra confirmation step for destructive writes before this pattern touches anything real. -
Resources, not just tools — expose each field as an MCP resource
too (not only a tool), so an agent can subscribe to
resources/updatedinstead of pollingget_field. -
Swap the transport — the
Storeclass doesn't know or care whether it's being driven by streamable-HTTP MCP, WebMCP (navigator.modelContext), or a browser-extension bridge — onlypackages/mcp-tenant-lib/src/http.tswould change. -
Reuse the server for another project — see
docs/refactor-plan.mdfor the in-progress plan to makemcp-tenant-libpluggable into non-form projects (e.g. adding MCP tools to an existing TODO app).