Repository navigation
Implementing WebMCP page tools
Knowledge transfer for an offline / closed-network build. Written against
@machfivetechchicago/machvive-webmcp-ai@6.3.0. Everything here is verified
against that published package; no network access is needed to implement it.
The model cannot discover anything. It sits on the far side of a stream, and
navigator.modelContext is a browser object it has no access to. There is no
discovery protocol reaching from the model into the page.
Your chat component is the only participant that sees both sides, so it owns the whole loop:
LangChain endpoint ←── HTTP stream ──→ chat component ←──→ navigator.modelContext
(remote) (in the page) (in the page)
Four responsibilities, all local:
- Read the page's tools.
- Send them as tool definitions with each request.
- Execute tool calls the model returns, in the page.
- Return results and continue the stream.
This is an ordinary tool-calling loop. The only unusual part is that the tools are sourced from — and executed in — the page rather than your server.
navigator.modelContext.tools // → array, may be empty, never nullEach entry is exactly:
{
name: 'add_to_cart', // string, unique on the page
description: 'Add a product to the cart', // string, may be undefined
inputSchema: { // JSON Schema, may be undefined
type: 'object',
properties: { sku: { type: 'string', description: 'Product SKU' } },
required: ['sku']
}
}Handlers are stripped by the getter — there is no execute on what you read, and
no way to reach one. Invocation goes through callTool only.
This is the single most common mistake. A page installs the polyfill immediately, then other code registers tools afterwards — a third-party widget fetching its own script can add tools seconds after load.
A component that reads the list once at startup sends a partial tool set to the model. The model then cannot call tools that exist, and nothing in the transcript explains why.
Two defences, use both:
import { TOOLS_CHANGED_EVENT } from './machvive-webmcp-polyfill.js';
// TOOLS_CHANGED_EVENT === 'machvive-webmcp-change'
window.addEventListener(TOOLS_CHANGED_EVENT, (e) => {
// e.detail.tools is the full current list
this.tools = e.detail.tools;
});And re-read navigator.modelContext.tools when composing each request rather
than trusting a cached copy. The array is small and rebuilt on access; the cost is
nothing next to a request.
If you need a settled list at a single moment (a test harness, say), poll until the count stops changing rather than waiting a fixed interval:
async function settledTools(quietMs = 1500, timeoutMs = 12000) {
const deadline = Date.now() + timeoutMs;
let last = -1, stableSince = 0;
while (Date.now() < deadline) {
const now = navigator.modelContext?.tools?.length ?? 0;
if (now !== last) { last = now; stableSince = Date.now(); }
else if (now > 0 && Date.now() - stableSince >= quietMs) break;
await new Promise((r) => setTimeout(r, 250));
}
return navigator.modelContext?.tools ?? [];
}The polyfill declined. See §6 — on an offline build this is usually the secure context check, and it is one attribute to fix.
The shape is deliberately close to the common function-calling formats, so the mapping is near-identity. For an OpenAI-compatible endpoint:
const toolDefs = navigator.modelContext.tools.map((t) => ({
type: 'function',
function: {
name: t.name,
description: t.description ?? '',
parameters: t.inputSchema ?? { type: 'object', properties: {} }
}
}));For an Anthropic-shaped endpoint:
const toolDefs = navigator.modelContext.tools.map((t) => ({
name: t.name,
description: t.description ?? '',
input_schema: t.inputSchema ?? { type: 'object', properties: {} }
}));Two details worth handling rather than discovering later:
-
inputSchemamay be absent on a tool that takes no parameters. Most APIs reject a missing parameter schema, hence the fallback above. - Names are unique per page but not namespaced. If you also inject server-side tools, collisions are yours to resolve — prefix the page ones if in doubt.
const result = await navigator.modelContext.callTool(name, args);This is a contract, not an accident. Every failure path returns a value:
// unknown tool
{ content: [{ type: 'text', text: 'Unknown tool: foo' }], isError: true }
// the handler threw
{ content: [{ type: 'text', text: 'Coupon "INVALID" is not valid.' }], isError: true }
// success
{ content: [{ type: 'text', text: 'Added 2 × Mach Five Tee.' }] }So your loop branches on data, not exceptions. Do not wrap it in a try/catch that swallows — there is nothing to catch, and you would hide the error text.
Pass isError through to the model. A failure the model can read is a failure
it can correct in one turn; a failure you silently retry is a loop. Flatten the
content to text and mark it as an error in whatever way your endpoint expects:
const text = (result.content ?? []).map((b) => b.text ?? '').join('\n');
// OpenAI-shaped: role 'tool', content: text, and say so in the text if isError
// Anthropic-shaped: tool_result block with is_error: result.isError === trueParse with JSON.parse, never string-match the serialized arguments — escaping
varies between models and versions. If the parse fails (a truncated stream, a
malformed emission), return that to the model as a tool error rather than throwing;
it will usually re-emit correctly.
The fiddly part is pausing mid-stream. The shape that works:
async function send(messages) {
for (let turn = 0; turn < MAX_TURNS; turn++) {
const tools = navigator.modelContext?.tools ?? []; // re-read every turn
const { text, toolCalls } = await streamOnce(messages, mapTools(tools));
if (!toolCalls.length) return text; // done
messages.push(assistantTurn(text, toolCalls));
// Execute in parallel — page tools are independent and usually fast.
const results = await Promise.all(
toolCalls.map(async (call) => {
let args = {};
try { args = JSON.parse(call.argumentsJson || '{}'); }
catch (e) { return toolError(call, `arguments were not valid JSON: ${e.message}`); }
const r = await navigator.modelContext.callTool(call.name, args);
return toolResult(call, r);
})
);
// Return every result in one message. Splitting them across messages
// teaches the model to stop making parallel calls.
messages.push(...results);
}
throw new Error('tool loop did not converge');
}Four things that matter:
- Cap the turns. A model that keeps calling a failing tool will otherwise loop until your endpoint's budget runs out.
- Return every tool result, including failures. Dropping one leaves the model with an unanswered call and it will usually stall or repeat.
-
Accumulate tool-call arguments across stream deltas before parsing. Most
streaming formats emit
argumentsin fragments; parsing early gets you the truncated-JSON error described above. - Re-read tools each turn, as in §2 — a tool may have registered since the last.
The components import each other and a shared module. Picking files by name leaves a broken graph, and the file that gets missed is not a component:
| Copying | Also copy |
|---|---|
machvive-webmcp-polyfill |
nothing; standalone |
machvive-webmcp-inspect |
polyfill, shared/theme.js
|
machvive-webmcp-analytics |
shared/theme.js |
machvive-m5t-magnet |
polyfill |
shared/theme.js absent surfaces as a module-resolution error that does not name
it. Relative imports resolve from disk, so preserve the directory layout:
src/wc/machvive-webmcp-polyfill/machvive-webmcp-polyfill.js
src/wc/machvive-webmcp-inspect/machvive-webmcp-inspect.js
src/wc/shared/theme.js
Get a correct set with npm pack on a connected machine and copy package/src/
wholesale. Six files, roughly 45 kB, no runtime network.
The polyfill matches the native API's [SecureContext] requirement by default.
These qualify: https://, localhost, 127.0.0.1, and file:// URLs in
current browsers. These do not: a LAN address such as http://192.168.1.20:8000,
or a custom hostname such as http://appserver.internal.
On a closed network you are usually on the second list. The check protects nothing there — no native implementation is coming — so opt in:
<machvive-webmcp-polyfill allow-insecure></machvive-webmcp-polyfill>import { installWebmcpPolyfill } from './machvive-webmcp-polyfill.js';
installWebmcpPolyfill({ allowInsecureContext: true });The element retries the install, so the attribute works even though the module installs on import. The refusal warning names the current origin, which is the fastest way to tell a genuine non-secure origin from some other failure.
Everything registered on the page is callable by whatever the remote model decides to call, with whatever arguments it supplies. There is no per-call gate.
Consequences worth deciding deliberately rather than inheriting:
- Register only what an agent should be allowed to do. A tool that deletes, pays, sends, or publishes is as reachable as one that searches.
-
Validate inside the handler. The model's arguments are untrusted input, and
inputSchemais a hint to the model, not enforcement — nothing validates against it before your handler runs. - Consider a draft/commit split for anything irreversible: one tool prepares and one commits, with a human between. The magnet bridge in this package works that way — an agent prefills a lead form but cannot submit it, because submission is where consent lives.
- Tool results are model input. If a handler returns content sourced from elsewhere on the page, it reaches the model as text — treat it as data, and do not let it carry instructions you would not have written.
In rough order of how often each is the cause:
| Symptom | Check |
|---|---|
navigator.modelContext is undefined |
Secure context (§6). The console warning names the origin. |
| Model never calls a tool you can see | Are you sending definitions? Log the mapped array. |
| Model calls a tool that "does not exist" | You cached the list at startup (§2). Re-read per turn. |
"Unknown tool" from callTool
|
Name mismatch, or the tool registered after you read. |
| Handler runs but the model ignores the result | Result not returned in the same message as its call id. |
| Loop never terminates | No turn cap, or a failing tool whose error you are not passing back. |
A quick sanity check from the browser console on the page itself:
navigator.modelContext.tools.map(t => t.name)
await navigator.modelContext.callTool('some_tool', { ... })If those work and your chat does not, the fault is in the bridge, not the page.