WebMCP — in-browser agent tools on public note pages
Status: plan (not implemented). Scope: public, read-only. We expose the
site's existing read tools (search, similar, federation search, dynamic note
methods) plus a couple of page-context helpers to an AI agent running inside
the visitor's browser, via the emerging WebMCP API (navigator.modelContext).
Admin/write tools (driving the admin panel, GraphQL mutations) are explicitly
out of scope for now. They could come later as a separate, feature-flagged,
session-gated deliverable, but this document does not design or analyze them.
What WebMCP is, and how it differs from our server MCP
We already ship a server-side MCP server at POST /_system/mcp
(internal/case/mcp/endpoint.go:117), JSON-RPC 2.0 over HTTP. An external
MCP client (Claude Desktop, Cursor, a CLI) connects to it over the network and
authenticates with a personal token (t2g_*), an X-API-Key, or a federation
JWT (internal/case/mcp/endpoint.go:48, :71). The agent runs on the user's
machine or in the cloud; trip2g is a remote server it talks to.
WebMCP is the inverse. It is a W3C Web Machine Learning Community Group draft
(accepted Sept 2025; latest CG draft Apr 2026 — not a ratified standard) that
adds a browser API, navigator.modelContext. A web page registers tools in
JavaScript; an AI agent already running in the user's browser (Chrome's
built-in agent, an Edge agent, or an extension) discovers and calls them. The
agent reuses the page's own context and the user's already-authenticated browser
session — no token handshake, because the calls execute as page JavaScript in the
user's tab.
Server MCP (/_system/mcp) |
WebMCP (this plan) | |
|---|---|---|
| Who runs the agent | External client (desktop/cloud) | Inside the visitor's browser |
| Transport | HTTP JSON-RPC to our server | navigator.modelContext JS API |
| Registers tools | Our Go server | Our page JavaScript |
| Auth | t2g_* / X-API-Key / fed JWT |
The visitor's existing browser session/cookies |
| Discovery | tools/list over the wire |
The agent reads tools off the loaded page |
| Availability | Always (any MCP client) | Chrome 146 behind a flag (Feb 2026); broad H2 2026 — progressive enhancement |
Browser status as of mid-2026: Chrome shipped navigator.modelContext in an
early preview behind a flag (Chrome 146, Feb 2026); Edge has begun shipping; broad
support is targeted for H2 2026. The API requires a secure context (HTTPS).
Therefore everything here must be a progressive enhancement: feature-detect,
and silently no-op when the API is absent.
The WebMCP API surface (current draft)
The API lives at navigator.modelContext. Two registration styles:
Imperative — register/unregister individual tools at runtime:
const reg = navigator.modelContext.registerTool({
name: "search_notes",
description: "Search this site's notes by query.",
inputSchema: {
type: "object",
properties: { query: { type: "string", description: "Search query" } },
required: ["query"],
},
async execute({ query }) {
// ... do the work ...
return { content: [{ type: "text", text: "..." }] };
},
});
// later: reg.unregister?.() / navigator.modelContext.unregisterTool(...)
There is also navigator.modelContext.provideContext({ tools: [...] }) to set
the full tool list at once (older shape, still present in some builds), and a
declarative HTML-attribute form. For human-in-the-loop, the draft adds
agent.requestUserInteraction() so a tool can ask the browser to get explicit
user confirmation before a sensitive action.
Notes on the shape (verify against the shipping build at implementation time, the
draft is moving):
- The handler key is
executein the W3C draft; some libraries (@mcp-b/*) use
handler. Targetexecute. - The return value mirrors MCP
CallToolResult:{ content: [{ type: "text", text }] }. This is the same content shape our server already produces
(internal/case/mcp/types.go:61CallToolResult,:67Content) — a clean fit.
Imperative vs declarative — choice
Use the imperative registerTool API. Rationale:
- Our tool set is dynamic per page (a federated KB pointer page vs a plain note
exposes different tools; dynamicmcp_methodnotes vary), which the imperative
API expresses naturally. - We must feature-detect and no-op cleanly; imperative JS is the only path that
lets us guard onnavigator.modelContextbefore doing anything. - The declarative HTML form would require teaching the quicktemplate
(views.html) to emit tool markup; keeping tool definitions in one JS file is
far easier to maintain and test.
Keep provideContext as a fallback call only if a target browser lacks
registerTool (capability-detect both).
Tool inventory to expose (public, read-only)
All of these already exist server-side and are already safe for anonymous public
access (the open-KB anonymous path: /_system/mcp serves public KBs with no
token, see the GET info page internal/case/mcp/endpoint.go:136 "Public access:
no token required for open knowledge bases", and the anonymous branch in
authenticateAnonymousRequest returning ctx, nil at :93). Server-side access
control still applies on every call — private/subscriber notes are filtered by
canReadMCPNote (internal/case/mcp/resolve.go:481) and
filterSearchResults (:409), so an unauthenticated browser agent sees exactly
what an anonymous visitor may see. This is the core reason the public surface is
safe: we are not bypassing any check, we are surfacing the anonymous tool set.
Tools, with their server schemas (source: handleToolsList,
internal/case/mcp/resolve.go:171):
| WebMCP tool | Server tool | Input schema (from resolve.go) |
|---|---|---|
search |
search (:174) |
{ query: string } (required) |
similar |
similar (:185) |
{ path?, href?, pid?, note_id?, limit? } |
note_html |
note_html (:199) |
{ path?, href?, pid?, note_id?, match_id?, context_words?, toc_path?[] } |
federated_search |
federated_search (:219) |
{ query (req), kb_id?, kb_ids?[] } |
federated_similar |
federated_similar (:232) |
{ kb_id (req), path?, href?, pid?, note_id?, limit? } |
federated_note_html |
federated_note_html (:248) |
{ kb_id (req), path?, href?, pid?, note_id?, match_id? } |
dynamic mcp_method notes |
per-note (:266) |
{} — content is the response |
The dynamic-method tools come from notes with mcp_method frontmatter
(internal/case/rendernotepage/model MCPMethod); they are discovered
server-side and filtered by readability. To keep the browser tool list correct,
the page must learn which dynamic tools are available — fetch the list at
runtime (see "Reuse /_system/mcp" below) rather than hardcoding it.
Out of scope (do not register): graphql_introspection and graphql_request
(resolve.go:285–:307). These are gated behind an API key with
EnableMcpAdminTools and must never appear in the public browser surface.
Page-context tools
"Tell the agent what's on the page = use it." The rendered note page already
exposes its identity in a global config script
(internal/defaulttemplate/views.html:52–:55):
note_path: "...",
note_path_id: <PathID>,
note_lang: "...",
note_version_id: "...",
That gives us, with zero new backend work, the data for page-context tools:
get_current_page— returns{ note_path, note_path_id, note_lang, title, url }
read straight from that global object. Lets the agent know what the user is
looking at without guessing from the URL.find_related_to_this_page— convenience wrapper that calls thesimilartool
using the current page'snote_path_id(pid), so the user can say "find
related" with no arguments.read_section— wrapper overnote_htmlwith the currentpidand a
toc_path/match_id, for "summarize this section".
These page-context tools are pure client-side conveniences composed from the read
tools above; they add no new server surface.
Auth / session strategy
The whole public surface is anonymous. The browser agent runs in the
visitor's tab; whatever cookies the visitor has are sent automatically by the
browser on same-origin requests. We do not add any token. Concretely:
- If the tool handler proxies to
/_system/mcp(recommended, below), the
fetch is same-origin and the browser attaches the visitor's session cookies
automatically. A logged-in subscriber's agent therefore transparently sees
their subscriber content; an anonymous visitor sees only public content —
exactly mirroring what they'd see by browsing. No special handling needed. - No
Authorizationheader, noX-API-Key. We never exposet2g_*tokens to
page JS.
Security posture for this scope is simple because the server is the trust
boundary and it already enforces per-note access on every call. The browser
tools are a thin presentation layer over endpoints a visitor can already hit.
The one rule: never register the admin GraphQL tools, never send admin
credentials from page JS. (A future admin surface would need its own session
gating and confirmation design — deliberately not addressed here.)
Reuse /_system/mcp vs call backends directly
Recommendation: proxy to the existing /_system/mcp endpoint. Each WebMCP
tool's execute builds a JSON-RPC tools/call request and fetches
/_system/mcp same-origin.
Trade-offs:
Proxy to /_system/mcp (chosen) |
Call GraphQL/search directly | |
|---|---|---|
| Auth | Free — cookies ride along same-origin | Must wire each backend's auth from JS |
| Code reuse | One code path; server already formats results | Reimplement search/similar/federation glue in JS |
| Access control | Enforced once, server-side (canReadMCPNote) |
Must re-enforce per call client-side (unsafe) |
| Result shape | Server returns MCP content already — pass through |
Must map GraphQL/search shapes to MCP content |
| Tool discovery | tools/list over the same fetch gives dynamic tools |
No discovery; hardcode tools |
| Cost | One extra same-origin round trip per call | Marginally fewer hops |
Proxying wins decisively: the server is already the access-control and formatting
authority, and the response is already in WebMCP's content shape. The browser
layer becomes a small adapter:
- On load,
fetch('/_system/mcp', {method:'POST', credentials:'same-origin'})
with{jsonrpc:"2.0", id, method:"tools/list"}to get the live tool list
(built-ins + readable dynamic methods, with admin tools absent for anonymous). - For each returned tool,
registerTool({ name, description, inputSchema, execute }). execute(args)POSTs{method:"tools/call", params:{name, arguments:args}}
and returns theresult(already{content:[...]}), or maps a JSON-RPC
errorto{ content:[{type:"text", text}], isError:true }.
Page-context tools (get_current_page etc.) are registered locally without a
server round trip (or compose the proxy call, e.g. find_related_to_this_page
forwards to the similar proxy).
This also keeps the WebMCP list automatically in sync with the server: filter a
note to subscribers and it disappears from both surfaces with no client change.
Where / how to inject the JS
Follow the mermaid per-note widget pattern (docs/dev/mermaid.md): a small
committed glue bundle under assets/, embedded via assets/embed.go, emitted as
a <script defer> in the rendered page.
- New bundle:
assets/webmcp/src/index.ts→ built artifactassets/webmcp.js
(mirrorsassets/mermaid/→assets/mermaid.js). - Embed it: add
webmcp.jsto the//go:embedlist inassets/embed.go:8. - Serve + cache-bust: already handled by the asset FS and
AssetURL()
(cache-busting hash), same as mermaid.
Injection point — unconditional on note pages, not per-note. Mermaid is
per-note because only some notes have diagrams; WebMCP tools (search, federation,
page-context) make sense on every rendered note page. Two clean options:
- Option A (preferred): add it to the core bootstrap list
UserJSURLs()
(cmd/server/main.go, the core scripts returned for note pages) so every
default-template page loads it. The template already iteratesctx.JSURLs
into<script defer>tags (internal/defaulttemplate/views.html:89–:91). - Option B: append it in
buildDefaultTemplateCtx
(internal/case/rendernotepage/endpoint.go:495–:502), next to the mermaid
line, but without theHasCodeLanguageguard (i.e. always). Use this if we
want it on note pages only and not other default-template pages.
The script's first action is feature detection:
if (!('modelContext' in navigator) || typeof navigator.modelContext?.registerTool !== 'function') {
return; // WebMCP unsupported — silent no-op, page works as before
}
It must also tolerate non-secure contexts (the API simply won't exist over
plain HTTP, so the guard above covers it).
The admin $mol app (/admin) is not a target of this plan; do not inject
the WebMCP bundle there.
Feature-detection & graceful degradation
- Guard on
navigator.modelContextandregisterToolbefore any call. - Wrap registration in
try/catch; a draft-API mismatch must never break the
page. - If
tools/listfetch fails, fall back to registering the static built-in
tools only (search/similar/note_html/federated_*), skipping dynamic discovery. - Unregister on
pagehide/beforeunloadif the API exposes unregister, to avoid
stale tools in SPA-like navigations (note pages are full reloads today, so this
is defensive).
Build commands
Mirror the mermaid widget build (docs/dev/mermaid.md "Build"):
| Command | When |
|---|---|
npm run webmcp |
After editing assets/webmcp/src/* — builds assets/webmcp.js (add this script to package.json, mirroring npm run mermaid) |
go generate ./internal/defaulttemplate/... |
Only if views.html is touched (Option B without template edits needs none) |
npm run build |
TS typecheck + Vite (does not build the widget bundle) |
npm run test:e2e |
Playwright E2E (see tests below) |
assets/webmcp.js is a committed artifact, like mermaid.js / chart.js.
Story-by-story breakdown
Written TDD-first (Red → Green → Refactor), matching docs/dev/mcp.md style.
Story 1 — WebMCP glue bundle + feature detection
- Build:
assets/webmcp/src/index.ts,esbuild/vite config,npm run webmcp
script inpackage.json. Outputassets/webmcp.js. - Behavior: on load, feature-detect
navigator.modelContext.registerTool;
no-op silently if absent or non-secure context. - TDD: unit-test the detection guard with a stubbed
navigator(jsdom):
absent → does nothing, no throw; present → proceeds. Test the JSON-RPC request
builder and theresult/error→ MCP-content mapping in isolation. - Done when: importing the bundle with no
modelContextis a clean no-op.
Story 2 — Serve and inject the bundle on note pages
- Add
webmcp.jsto//go:embed(assets/embed.go:8). - Inject via Option A (
UserJSURLs()) or Option B (always-append in
buildDefaultTemplateCtx). - TDD: Go test asserting the asset is embedded and resolvable via the asset
FS /AssetURL. Render-path test (or E2E) asserting a<script>for
webmcp.jsappears on a rendered note page. - Done when: every default-template note page ships the (cache-busted)
webmcp.jstag.
Story 3 — Register the built-in read tools (proxy to /_system/mcp)
- In the bundle: register
search,similar,note_html,federated_search,
federated_similar,federated_note_htmlwith the schemas from
resolve.go:174–:262. EachexecutePOSTs atools/callto/_system/mcp
withcredentials:'same-origin'and returns the serverresult. - TDD: mock
fetch; assert each tool sends the right JSON-RPC body and that a
serverresultis returned verbatim and anerrorbecomes
{isError:true,...}. E2E (Playwright, with a stub fornavigator.modelContext
capturing registrations): on/some-note,searchis registered and calling
it returns real results. - Done when: the six read tools are callable through the stub and round-trip
to the server.
Story 4 — Dynamic tool discovery
- On load, call
tools/listagainst/_system/mcp; register any returned
dynamicmcp_methodtools not already registered (and trust the server to omit
admin/private tools for the anonymous/visitor session). - TDD: mock
tools/listreturning a dynamic tool + a built-in; assert the
dynamic one is registered andgraphql_*(if ever present) is filtered out
client-side as a belt-and-suspenders guard. E2E: a note withmcp_method
frontmatter exposes its tool on the page. - Done when: dynamic note tools appear in the browser agent's tool list and
return the note content.
Story 5 — Page-context tools
- Register
get_current_page(readswindow's note config object from
views.html:52–:55),find_related_to_this_page(forwards tosimilarwith
the currentnote_path_id),read_section(forwards tonote_html). - TDD: unit-test that
get_current_pagereturns the globals and that
find_related_to_this_pagecalls thesimilarproxy with the pagepid. E2E:
on a real note page the page-context tool returns the correct path/id. - Done when: an in-browser agent can ask "what page am I on / find related"
with no arguments.
Story 6 — Docs & rollout
- User docs (bilingual, per project convention):
docs/en/user/webmcp.md+
docs/ru/user/webmcp.md— how to enable WebMCP in a supported browser and what
the agent can do on a trip2g site. - Changelog entry in
docs/en/changelog.md+docs/ru/changelog.md
(What/Why/How). - Optional: a
docs/demo/webmcp.mddemo note. - Done when: docs ship and the demo page demonstrates a browser agent calling
search.
Optional: feature-flag gating
The public read-only surface is low-risk (it only re-exposes anonymous,
access-controlled endpoints), so a flag is optional. If desired, gate injection
behind a config/feature flag (the project has a feature-flags system, see
docs/dev/features.md) so an operator can disable the WebMCP script per
instance. A flag becomes mandatory the day any admin/write tool is added —
but that is out of scope here.
Files (anticipated)
| File | Role |
|---|---|
assets/webmcp/src/index.ts |
glue: feature-detect, discover, register tools, proxy to /_system/mcp |
assets/webmcp.js |
built artifact (committed) |
assets/embed.go |
embed webmcp.js |
cmd/server/main.go or internal/case/rendernotepage/endpoint.go |
inject the script tag (Option A / B) |
package.json |
npm run webmcp build script |
docs/en/user/webmcp.md, docs/ru/user/webmcp.md |
user docs |
e2e/vault.spec.js |
E2E: tools registered + callable on note pages |
References (WebMCP spec, mid-2026)
- W3C WebML CG — WebMCP Community Group Draft (
navigator.modelContext,
registerTool/unregisterTool/provideContext,agent.requestUserInteraction). - Chrome 146 early preview behind a flag (Feb 2026); Edge shipping; broad support
targeted H2 2026. Requires a secure context (HTTPS). - Verify the exact
executevshandlerkey and the descriptor shape against the
shipping browser build at implementation time — the draft is still moving.