cd /news/ai-agents/context-over-mcp-build-a-webmcp-tool… · home topics ai-agents article
[ARTICLE · art-130197] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Context Over MCP: Build a WebMCP Tool From Scratch

A developer has published a walkthrough for building a WebMCP tool from scratch, registering a "score_facts" tool directly on a web page via the native document.modelContext API in Chrome or a polyfill fallback. The guide emphasizes returning errors as data rather than throwing, validating input before execution, and marking tools with a readOnlyHint annotation so agents can recover cleanly.

by read5 min views2 publishedSep 15, 2026

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.

── more in #ai-agents 4 stories · sorted by recency
── more on @chrome 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/context-over-mcp-bui…] indexed:0 read:5min 2026-09-15 ·