DEV Community

Cover image for Context Over MCP: Build a WebMCP Tool From Scratch
wolfejam.dev
wolfejam.dev Subscriber

Posted on AI-assisted

Context Over MCP: Build a WebMCP Tool From Scratch

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.

Step 1 — detect WebMCP, don't assume it

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>
Enter fullscreen mode Exit fullscreen mode

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">.

Step 2 — write the tool as a plain function first

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))
  };
}
Enter fullscreen mode Exit fullscreen mode

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.)

Step 3 — register it the way errors don't crash the caller

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) };
      }
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

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.
  • The empty-input check runs before scoreFacts — a missing field is a normal case, not an exception.
  • The try/catch around your own function means a bug in scoreFacts comes back as { 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);
});
Enter fullscreen mode Exit fullscreen mode

Step 4 — verify it before you add anything else

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)
Enter fullscreen mode Exit fullscreen mode

If any of those fail, stop here. Everything past this point assumes a
working, verified tool.

Step 5 — the part most tutorials skip: don't trust a URL parameter

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;
}
Enter fullscreen mode Exit fullscreen mode

Three checks, each closing a different door:

  • Exact hostname match, not a substring check — evil-raw.githubusercontent.com.attacker.net doesn't pass just because it contains the right string.
  • HTTPS only — a tool that fetches over plain HTTP hands a man-in-the-middle the exact document it's about to trust.
  • No mcp segment in the path — a page tool has no business talking to an MCP server; naming the block explicitly makes it a decision, not an accident.

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.

Step 6 — the same shape, in production

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.

Top comments (0)