Part IV showed three tools already registered, already working, on someone
else's page. This is the version where you register one on yours — a real
tool, with the same failure modes any WebMCP tool has, fixed the same way.
No build step, no bundler, no dependency you haven't chosen yourself. Save a
blank index.html, serve it with python3 -m http.server, open
http://localhost:8000, and follow along. Opened straight from disk, the page
has no origin and the tools won't run.
document.modelContext exists natively in Chrome behind a flag
(chrome://flags/#enable-webmcp-testing). Everywhere else, a polyfill can
provide the same surface. Check for the real thing first, fall back
explicitly, and know which one you got — don't silently assume either:
<script type="module">
async function getModelContext() {
if (document.modelContext?.registerTool) {
return { context: document.modelContext, source: 'native' };
}
try {
const { initializeWebMCPPolyfill } =
await import('https://esm.sh/@mcp-b/webmcp-polyfill');
initializeWebMCPPolyfill();
} catch {
return { context: null, source: 'none' };
}
return document.modelContext?.registerTool
? { context: document.modelContext, source: 'polyfill' }
: { context: null, source: 'none' };
}
</script>
If source comes back 'none', stop here and fix that before writing a
single tool — everything below assumes a real context object.
Every block below goes inside the same <script type="module">.
Don't reach for a real YAML parser or a scoring library yet. Prove the shape
works with something you can read in five seconds:
function scoreFacts(yaml) {
const required = ['name', 'goal', 'who', 'what', 'why'];
const present = required.filter((key) => new RegExp(`^\\s*${key}:`, 'm').test(yaml));
return {
score: Math.round((present.length / required.length) * 100),
populated: present.length,
total: required.length,
missing: required.filter((k) => !present.includes(k))
};
}
Five required keys, a regex check per key, a percentage. That's the whole
scorer. It's not what a real implementation should ship — it's what lets you
verify the registration works before you go anywhere near real parsing.
(The \s* matters: a real project's facts are usually nested — who:
sitting indented under a human_context: block, not at column zero. Test
this against a flat file only and it'll look like it works; test it against
a realistically nested one and a too-strict regex reports fields "missing"
that are right there, just indented.)
This is the part that's easy to get wrong: a WebMCP tool that throws is a
tool an agent can't recover from cleanly. Return errors as data, and validate
input before it reaches your function:
async function registerScoreTool(context) {
await context.registerTool({
name: 'score_facts',
description: 'Score a structured facts document 0-100 by which required fields are present.',
inputSchema: {
type: 'object',
properties: { yaml: { type: 'string', description: 'YAML text to score' } },
required: ['yaml']
},
annotations: { readOnlyHint: true },
execute: async (input) => {
const yaml = typeof input?.yaml === 'string' ? input.yaml : '';
if (!yaml.trim()) {
return { error: 'invalid_input', message: 'yaml is required' };
}
try {
return scoreFacts(yaml);
} catch (err) {
return { error: 'score_failed', message: err instanceof Error ? err.message : String(err) };
}
}
});
}
Three things doing real work here, none of them optional:
annotations: { readOnlyHint: true } — tells the caller up front this
can't change anything, before it ever calls the tool.scoreFacts — a missing field is a
normal case, not an exception.try/catch around your own function means a bug in { error, message }, not a stack trace the caller has to
parse to understand what happened.
Wire the two together and open the page:
getModelContext().then(async ({ context, source }) => {
if (!context) return console.warn('WebMCP not available:', source);
await registerScoreTool(context);
console.log('score_facts registered via', source);
});
Install the Model Context Tool Inspector,
open it on your page (the localhost URL, not the file), and check:
[ ] Inspector lists score_facts
[ ] Calling it with valid YAML (containing name:/goal:/who:/what:/why:) returns a score
[ ] Calling it with { yaml: "" } returns { error: "invalid_input", ... } — not a crash
[ ] The console logs which source registered it (native or polyfill)
If any of those fail, stop here. Everything past this point assumes a
working, verified tool.
The moment a tool accepts a URL instead of pasted text — "fetch the YAML
from here" — it's fetching something the caller named, not something you
chose. That's a real risk, not a hypothetical one, and it needs the same
kind of explicit check execute got in Step 3:
const ALLOWED_HOSTS = new Set(['raw.githubusercontent.com']); // your trusted hosts only
function assertAllowedUrl(urlString) {
const url = new URL(urlString); // throws on garbage input — let it
if (url.protocol !== 'https:') {
throw new Error('url must use https');
}
if (!ALLOWED_HOSTS.has(url.hostname)) {
throw new Error(`host not allowlisted: ${url.hostname}`);
}
if (url.pathname.split('/').includes('mcp')) {
throw new Error('mcp endpoints are not allowed');
}
return url;
}
Three checks, each closing a different door:
evil-raw.githubusercontent.com.attacker.net
doesn't pass just because it contains the right string.mcp segment in the path
Call it inside execute's try, before the fetch, so a rejected URL comes
back as { error, message }. Then fetch with redirect: 'error': a redirect
is a new request to a host your check never saw, and the browser sends it
before your code can read response.url.
This whole pattern — detect the context, register with readOnlyHint,
validate before executing, return errors as data, allowlist any fetch — is
the shape faf.one/webmcp uses for score_faf and emit_agents_md, with a
WASM scoring kernel where yours has a regex. Its third tool,
fill_6ws, takes the other route: a plain HTML form with toolname
attributes, registered by the browser. That page is one real,
production instance of it. It's not the only way to build one — it's what
happens when you take Steps 1 through 5 and keep going.
Companion to Context Over MCP, Part IV: No Working Directory At All — the explainer that describes an already-built WebMCP page. This is the version where you build one.