{"slug": "context-over-mcp-build-a-webmcp-tool-from-scratch", "title": "Context Over MCP: Build a WebMCP Tool From Scratch", "summary": "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.", "body_md": "Part IV showed three tools already registered, already working, on someone\n\nelse's page. This is the version where you register one on yours — a real\n\ntool, with the same failure modes any WebMCP tool has, fixed the same way.\n\nNo build step, no bundler, no dependency you haven't chosen yourself. Save a\n\nblank `index.html`, serve it with `python3 -m http.server`, open\n\n`http://localhost:8000`, and follow along. Opened straight from disk, the page\n\nhas no origin and the tools won't run.\n\n`document.modelContext` exists natively in Chrome behind a flag\n\n(`chrome://flags/#enable-webmcp-testing`). Everywhere else, a polyfill can\n\nprovide the same surface. Check for the real thing first, fall back\n\nexplicitly, and know which one you got — don't silently assume either:\n\n```\n<script type=\"module\">\nasync function getModelContext() {\n  if (document.modelContext?.registerTool) {\n    return { context: document.modelContext, source: 'native' };\n  }\n  try {\n    const { initializeWebMCPPolyfill } =\n      await import('https://esm.sh/@mcp-b/webmcp-polyfill');\n    initializeWebMCPPolyfill();\n  } catch {\n    return { context: null, source: 'none' };\n  }\n  return document.modelContext?.registerTool\n    ? { context: document.modelContext, source: 'polyfill' }\n    : { context: null, source: 'none' };\n}\n</script>\n```\n\nIf `source` comes back `'none'`, stop here and fix that before writing a\n\nsingle tool — everything below assumes a real context object.\n\nEvery block below goes inside the same `<script type=\"module\">`.\n\nDon't reach for a real YAML parser or a scoring library yet. Prove the shape\n\nworks with something you can read in five seconds:\n\n``` js\nfunction scoreFacts(yaml) {\n  const required = ['name', 'goal', 'who', 'what', 'why'];\n  const present = required.filter((key) => new RegExp(`^\\\\s*${key}:`, 'm').test(yaml));\n  return {\n    score: Math.round((present.length / required.length) * 100),\n    populated: present.length,\n    total: required.length,\n    missing: required.filter((k) => !present.includes(k))\n  };\n}\n```\n\nFive required keys, a regex check per key, a percentage. That's the whole\n\nscorer. It's not what a real implementation should ship — it's what lets you\n\nverify the *registration* works before you go anywhere near real parsing.\n\n(The `\\s*` matters: a real project's facts are usually nested — `who:`\n\nsitting indented under a `human_context:` block, not at column zero. Test\n\nthis against a flat file only and it'll look like it works; test it against\n\na realistically nested one and a too-strict regex reports fields \"missing\"\n\nthat are right there, just indented.)\n\nThis is the part that's easy to get wrong: a WebMCP tool that throws is a\n\ntool an agent can't recover from cleanly. Return errors as data, and validate\n\ninput before it reaches your function:\n\n```\nasync function registerScoreTool(context) {\n  await context.registerTool({\n    name: 'score_facts',\n    description: 'Score a structured facts document 0-100 by which required fields are present.',\n    inputSchema: {\n      type: 'object',\n      properties: { yaml: { type: 'string', description: 'YAML text to score' } },\n      required: ['yaml']\n    },\n    annotations: { readOnlyHint: true },\n    execute: async (input) => {\n      const yaml = typeof input?.yaml === 'string' ? input.yaml : '';\n      if (!yaml.trim()) {\n        return { error: 'invalid_input', message: 'yaml is required' };\n      }\n      try {\n        return scoreFacts(yaml);\n      } catch (err) {\n        return { error: 'score_failed', message: err instanceof Error ? err.message : String(err) };\n      }\n    }\n  });\n}\n```\n\nThree things doing real work here, none of them optional:\n\n`annotations: { readOnlyHint: true }` — tells the caller up front this\ncan't change anything, before it ever calls the tool.`scoreFacts` — a missing field is a\nnormal case, not an exception.`try/catch` around your own function means a bug in `{ error, message }`, not a stack trace the caller has to\nparse to understand what happened.\nWire the two together and open the page:\n\n``` js\ngetModelContext().then(async ({ context, source }) => {\n  if (!context) return console.warn('WebMCP not available:', source);\n  await registerScoreTool(context);\n  console.log('score_facts registered via', source);\n});\n```\n\nInstall the [Model Context Tool\nInspector](https://chromewebstore.google.com/detail/model-context-tool-inspec/gbpdfapgefenggkahomfgkhfehlcenpd),\n\nopen it on your page (the localhost URL, not the file), and check:\n\n```\n[ ] Inspector lists score_facts\n[ ] Calling it with valid YAML (containing name:/goal:/who:/what:/why:) returns a score\n[ ] Calling it with { yaml: \"\" } returns { error: \"invalid_input\", ... } — not a crash\n[ ] The console logs which source registered it (native or polyfill)\n```\n\nIf any of those fail, stop here. Everything past this point assumes a\n\nworking, verified tool.\n\nThe moment a tool accepts a URL instead of pasted text — \"fetch the YAML\n\nfrom here\" — it's fetching something the *caller* named, not something you\n\nchose. That's a real risk, not a hypothetical one, and it needs the same\n\nkind of explicit check `execute` got in Step 3:\n\n``` js\nconst ALLOWED_HOSTS = new Set(['raw.githubusercontent.com']); // your trusted hosts only\n\nfunction assertAllowedUrl(urlString) {\n  const url = new URL(urlString); // throws on garbage input — let it\n  if (url.protocol !== 'https:') {\n    throw new Error('url must use https');\n  }\n  if (!ALLOWED_HOSTS.has(url.hostname)) {\n    throw new Error(`host not allowlisted: ${url.hostname}`);\n  }\n  if (url.pathname.split('/').includes('mcp')) {\n    throw new Error('mcp endpoints are not allowed');\n  }\n  return url;\n}\n```\n\nThree checks, each closing a different door:\n\n`evil-raw.githubusercontent.com.attacker.net`\ndoesn't pass just because it contains the right string.`mcp` segment in the path\nCall it inside `execute`'s `try`, before the fetch, so a rejected URL comes\n\nback as `{ error, message }`. Then fetch with `redirect: 'error'`: a redirect\n\nis a new request to a host your check never saw, and the browser sends it\n\nbefore your code can read `response.url`.\n\nThis whole pattern — detect the context, register with `readOnlyHint`,\n\nvalidate before executing, return errors as data, allowlist any fetch — is\n\nthe shape `faf.one/webmcp` uses for `score_faf` and `emit_agents_md`, with a\n\nWASM scoring kernel where yours has a regex. Its third tool,\n\n`fill_6ws`, takes the other route: a plain HTML form with `toolname`\n\nattributes, registered by the browser. That page is one real,\n\nproduction instance of it. It's not the only way to build one — it's what\n\nhappens when you take Steps 1 through 5 and keep going.\n\n*Companion to [Context Over MCP, Part IV: No Working Directory At\nAll](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\nis the version where you build one.*", "url": "https://wpnews.pro/news/context-over-mcp-build-a-webmcp-tool-from-scratch", "canonical_source": "https://dev.to/wolfejam/context-over-mcp-build-a-webmcp-tool-from-scratch-92e", "published_at": "2026-09-15 12:30:00+00:00", "updated_at": "2026-09-15 12:43:48.011850+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-products"], "entities": ["Chrome", "WebMCP", "document.modelContext", "@mcp-b/webmcp-polyfill"], "alternates": {"html": "https://wpnews.pro/news/context-over-mcp-build-a-webmcp-tool-from-scratch", "markdown": "https://wpnews.pro/news/context-over-mcp-build-a-webmcp-tool-from-scratch.md", "text": "https://wpnews.pro/news/context-over-mcp-build-a-webmcp-tool-from-scratch.txt", "jsonld": "https://wpnews.pro/news/context-over-mcp-build-a-webmcp-tool-from-scratch.jsonld"}}