# Context Over MCP: Build a WebMCP Tool From Scratch

> Source: <https://dev.to/wolfejam/context-over-mcp-build-a-webmcp-tool-from-scratch-92e>
> Published: 2026-09-15 12:30:00+00:00

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:

``` js
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:

``` js
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](https://chromewebstore.google.com/detail/model-context-tool-inspec/gbpdfapgefenggkahomfgkhfehlcenpd),

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:

``` js
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](https://dev.to/wolfejam/context-over-mcp-part-iv-no-working-directory-at-all-g0f) — the explainer that describes an already-built WebMCP page. This
is the version where you build one.*
