{"id":"26c5c9d8-ec8c-42b4-9630-18988b3b40fc","slug":"agenticschema","kind":"mcp","name":"agenticschema","summary":"Turn Schema.org, JSON-LD, RDFa and Microdata into tools for AI agents. Zero backend, browser-first, with MCP and WebMCP support","owner":"searchstefano","repoUrl":"https://github.com/searchstefano/agenticschema","license":"MIT","licenseTier":"permissive","trust":"community","stars":4,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-08-09T09:57:49.000Z","addedAt":"2026-10-14T05:12:23.346Z","logoUrl":"https://avatars.githubusercontent.com/u/26073241?u=1f8affdef9f150f8a11b582a8b4b2424818ad509&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["utilities","browser-web","cloud-infra"],"tags":["ai","ai-agent","ai-agents","ai-tools","artificial-intelligence","schema","schema-org","json-ld"],"runtime":"binary","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-17T09:40:48.354Z","sample":false,"url":"https://conduits.dev/tools/agenticschema","language":"TypeScript","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# AgenticSchema &middot; [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/searchstefano/agenticschema/blob/main/LICENSE) [![npm version](https://img.shields.io/npm/v/@agenticschema/core.svg?style=flat)](https://www.npmjs.com/package/@agenticschema/core) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/searchstefano/agenticschema/blob/main/CONTRIBUTING.md)\n\nTurn the Schema.org markup a page already has into MCP tools an AI agent can call.\n\nMost pages already publish structured data. Agents still scrape them. This library closes that\ngap: it reads the `JSON-LD`, microdata and RDFa already in the page and emits Model Context\nProtocol tools. You write no new API and you run no backend.\n\n```\n              Website\n                 │\n                 │  the Schema.org markup it already publishes:\n                 │  JSON-LD · microdata · RDFa\n                 ▼\n           AgenticSchema\n                 │\n                 │  one callable tool per thing the page describes\n                 │\n           ┌─────┴─────┐\n           ▼           ▼\n        WebMCP        MCP\n       (browser)    (Node)\n           └─────┬─────┘\n                 ▼\n               Agent\n```\n\nHere is what comes out of a page that exists today:\n\n```\nworld.openfoodfacts.org/product/3017620422003\n\n  read    get_web_site\n  read    get_organization\n  read    get_search_action\n  action  search_web_site(search_term_string)\n```\n\n`search_web_site` is executable. An agent holding it queries Open Food Facts directly instead of\nguessing a URL or going through a search engine. Nobody published anything new to make that\nhappen: the page has carried a `SearchAction` all along, and even the parameter name is the one\nthe page itself declares in `query-input`.\n\nRun that page yourself. No browser, and no transport to configure:\n\n```bash\nnpx @agenticschema/server https://world.openfoodfacts.org/product/3017620422003\n```\n\n(`get_search_action` in that list is noise, a read tool over the action's own definition. It is a\n[known rough edge](#known-rough-edges), left visible rather than trimmed out of the example.)\n\nOn your own site it is one file. The script-tag build is a plain classic script with the WebMCP\npolyfill already inside, roughly 27 KB gzipped, so it goes wherever a `<script>` tag goes: a\nWordPress theme, a Shopify theme, a React, Next.js or Astro layout, or Google Tag Manager.\n\n```html\n<script src=\"https://cdn.jsdelivr.net/npm/@agenticschema/browser@latest\"></script>\n```\n\nYou install no package and you configure no bundler. That tag reads the page and registers the\ntools, which is the whole of step one. Something still has to carry them to an agent: Chrome has\nrun an origin trial for the native path since version 149, and there is a local relay for\ndevelopment. [The next section](#read-this-first-registration-is-not-transport) is about that\nchoice, and it is the part people get wrong.\n\n\n\n## Contents\n\nThe six sections worth reading before anything else. GitHub's own outline menu has the rest.\n\n- [Read this first: registration is not transport](#read-this-first-registration-is-not-transport)\n- [Quick start](#quick-start)\n- [Why this is worth doing at all](#why-this-is-worth-doing-at-all)\n- [The core pipeline](#the-core-pipeline)\n- [Security](#security)\n- [Troubleshooting](#troubleshooting)\n\n---\n\n\n\n## Read this first: registration is not transport\n\nThis is the single thing that trips people up, so it comes before everything else.\n\nGetting a page's data to an agent takes **two** steps, and this library only does the first one:\n\n```\n   ①  REGISTRATION                        ②  TRANSPORT\n   @agenticschema/browser                 a WebMCP-capable browser,\n   reads the page's Schema.org            an extension, or a local relay\n   markup and registers tools             carries those tools to the agent\n   on document.modelContext\n        │                                        │\n        └──────────► document.modelContext ◄─────┘\n                     (the meeting point)\n```\n\n`@agenticschema/browser` writes tools into `document.modelContext`. That is the whole job. It\ndoes **not** open a connection to anything, because a browser tab cannot listen on a port. See\n[Three constraints](#three-constraints-that-shaped-the-design).\n\nSo after adding the script tag you have a page whose tools are correctly registered and that\n**no agent can reach yet**. Nothing is broken; the second half is simply not there. You pick the\ntransport separately, and the choice depends on who is meant to call the tools. See\n[Choosing a transport](#choosing-a-transport).\n\nThe symptom of forgetting step ② is very specific and worth recognising: **the tools show up in\nChrome DevTools (Application panel) but your MCP client reports zero sources.** DevTools reads\n`document.modelContext` in-process; your MCP client is a separate program that cannot. Everything\nis working, and nothing is connected.\n\n---\n\n\n\n## Quick start\n\nTwo tags. The first registers the tools, the second carries them to a local MCP client such as\nClaude Desktop, Cursor or Claude Code.\n\n```html\n<!-- ① registration: read this page's Schema.org markup, publish it as WebMCP tools -->\n<script src=\"https://cdn.jsdelivr.net/npm/@agenticschema/browser@latest\"></script>\n\n<!-- ② transport (development only): bridge those tools to a local MCP relay -->\n<script src=\"https://cdn.jsdelivr.net/npm/@mcp-b/webmcp-local-relay@4/dist/browser/embed.js\"></script>\n```\n\nThen run the relay and point your MCP client at it:\n\n```json\n{\n  \"mcpServers\": {\n    \"webmcp-local-relay\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@mcp-b/webmcp-local-relay@latest\"]\n    }\n  }\n}\n```\n\nOpen the page, and the tools appear in your client. Verify with `webmcp_list_sources`: your tab\nshould be listed with a tool count above zero.\n\nFour things worth knowing before you paste that in:\n\n- Order matters. The relay embed reads whatever is already registered and subscribes to changes,\nso put it after the registration tag.\n- Leave off `type=\"module\"`. The script-tag build is an IIFE, so it runs as an ordinary script,\nwhich is what makes it work through a tag manager. Adding `type=\"module\"` still works, but it\ncosts you `document.currentScript` and with it the simplest way to read options. See\n[How the adapter finds its own tag](#how-the-adapter-finds-its-own-tag).\n- Tag ② is for development. Shipping it to real visitors makes every one of their browsers probe\n`127.0.0.1`. See [Keep the relay out of production](#keep-the-relay-out-of-production).\n- Pin your versions. Unversioned jsDelivr URLs are cached at the edge for days, long enough to\nkeep serving a build you have already replaced. `@latest` and `@4` above are pins.\n\nIf you only want the browser's own built-in agent to use the tools, you need tag ① alone.\n\n---\n\n\n\n## Why this is worth doing at all\n\nThat first sentence is measurable rather than a pitch. Schema.org publishes the usage statistics\nfrom Google's crawl, counting how many domains use each term:\n\n\n| Term              | Domains |\n| ----------------- | ------- |\n| `potentialAction` | 10M+    |\n| `SearchAction`    | 10M+    |\n| `EntryPoint`      | 10M+    |\n| `urlTemplate`     | 10M+    |\n| `query-input`     | 10M+    |\n\n\nOver ten million domains already declare how to search them, machine-readably, today. That is a\ncapability this library hands to an agent as a callable tool, and nobody had to publish anything\nnew for it to work. The vocabulary's tail is shorter than it looks, too: of 958 types, 16 appear\non 10M+ domains, 50 on 1M+ and 95 on 100K+, so a hand-written profile registry can cover the part\nof the web that exists in practice.\n\nOne caveat worth stating plainly: those counts are what sites **declare**, not what is well formed\nenough to map. That is a different number, and the table does not claim it. Source:\n[schemaorg/schemaorg](https://github.com/schemaorg/schemaorg/tree/main/data/public_stats/google) `data/public_stats/google`,\n2026-07.\n\n---\n\n\n\n## Try it without committing to anything\n\nThree ways in, in rising order of commitment.\n\n### 1. In the browser, nothing installed\n\n**[Open the playground →](https://searchstefano.github.io/agenticschema/)**\n\nPaste any JSON-LD and watch the tools appear. Try the hostile sample: it is the fastest way to\nsee what the library *refuses* and why. Alongside it,\n[a live page carrying the script tag](https://searchstefano.github.io/agenticschema/demo.html)\nfor the WebMCP path end to end.\n\nBoth pages load the packages from jsDelivr at exact versions, so what you are trying is what\nyou would ship, not a local build.\n\n### 2. Read a real page from the terminal\n\nNo browser, and no transport question. The Node adapter fetches the page itself and speaks plain\nMCP over stdio:\n\n```bash\nnpx @agenticschema/server https://en.wikipedia.org/wiki/Backpack\n```\n\nWire it into Claude Desktop:\n\n```json\n{\n  \"mcpServers\": {\n    \"page\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@agenticschema/server\", \"https://en.wikipedia.org/wiki/Backpack\"]\n    }\n  }\n}\n```\n\nEvery entity also becomes a readable MCP resource, which the browser adapter cannot do.\n\nThis is the shortest path to seeing real output, and the one with the fewest moving parts. If\nyou are evaluating the library, start here.\n\n### 3. On your own site\n\nSee [Quick start](#quick-start) above, then [The script tag, in full](#the-script-tag-in-full).\n\n---\n\n\n\n## The script tag, in full\n\n\n\n### Where the tag goes\n\nThe build is a single classic script with no bundler and no package install behind it, so\nanywhere you can paste a `<script>` tag will do:\n\n| Platform | Where |\n| --- | --- |\n| WordPress | The theme's header template, or any plugin that inserts scripts into `<head>`. |\n| Shopify | `theme.liquid`, before the closing `</head>`. |\n| React, Next.js, Astro | The shared layout or document component, alongside your other third-party tags. |\n| Plain HTML | In `<head>`, or before `</body>`. |\n| Google Tag Manager, Cloudflare Zaraz | A Custom HTML tag. See [Through a tag manager](#through-a-tag-manager). |\n\nPut it in the layout that every page shares rather than on one page, and as early as you can. In\na single-page app the adapter follows `history.pushState` and route changes by itself, so a\nsingle tag covers every route with no extra wiring.\n\nOne thing can stop it outright: a Content-Security-Policy that does not allow the CDN. That is a\none-line fix, or you can self-host the file. See\n[Content-Security-Policy](#content-security-policy).\n\nIf you would rather import the package instead, it is on npm, and\n[the JavaScript API](#the-javascript-api) takes the options the attributes cannot express.\n\n### How the adapter finds its own tag\n\nTo read its `data-*` options the adapter first has to find the tag it was loaded from. It tries\nthree things, in order:\n\n1. `document.currentScript`, set while a **classic** script runs, including one a tag manager\n  inserted, and `null` in a module script because the HTML specification says so,\n2. `script[data-agenticschema]`, an explicit marker,\n3. `script[src*=\"agenticschema\"]`, the src of the standard snippet.\n\nSince the build is an IIFE, the plain snippet takes rule 1 and everything works with no marker,\nwhatever the file is called and however it got onto the page:\n\n```html\n<script data-max-tools=\"8\"\n        src=\"https://cdn.jsdelivr.net/npm/@agenticschema/browser@latest\"></script>\n```\n\n**Adding** `type=\"module\"` **gives up rule 1.** The tag then has to be identifiable some other way:\nthe URL above still matches rule 3, but a self-hosted copy under an unrelated filename matches\nnothing, and its options are ignored in silence.\n\n```html\n<!-- module script, and nothing in the src says \"agenticschema\": rule 2 or nothing -->\n<script type=\"module\" data-agenticschema data-max-tools=\"8\"\n        src=\"/assets/webmcp-bundle.js\"></script>\n```\n\n> **Using 0.1.2 or earlier?** That build was ESM, so the tag needed `type=\"module\"`, rule 1\n> never applied and rule 3 did not exist. `data-agenticschema` was mandatory for *any* option\n> to have an effect, and its absence was silent. Measured on one page with three JSON-LD blocks,\n> `data-max-tools=\"2\"` without the marker produced 5 tools instead of 2.\n\nWhen an option is ignored nothing warns you: the page keeps working and quietly uses defaults,\nwhich is indistinguishable from success until you go counting tools.\n\n### Through a tag manager\n\nGoogle Tag Manager and Cloudflare Zaraz inject a plain `<script src>` and never set\n`type=\"module\"`. That is why the script-tag build is an IIFE: an ESM bundle loaded that way is a\nsyntax error before a line of it runs, and what the tag manager reports back is unhelpful.\n\nUse a Custom HTML tag containing the snippet from [Quick start](#quick-start), unchanged. Options\nwork too: a dynamically inserted classic script still has `document.currentScript`, so `data-*`\nattributes are read even when the tag manager serves the file from its own proxy under a name\nwith no `agenticschema` in it.\n\nTwo things to watch:\n\n- Fire it on every page, as early as possible. The adapter maps the markup it finds and then\nwatches for changes, so firing late only delays the first registration. A trigger scoped to one\npage, though, leaves the rest of the site with no tools.\n- Keep the relay embed out of a tag manager. Tag managers run in production by definition, and\nthat tag has no business on a real visitor's browser. See\n[Keep the relay out of production](#keep-the-relay-out-of-production).\n\n\n\n### Every attribute\n\nAll options are optional. With none of them set you get every default in the right-hand column.\n\n\n| Attribute            | Values                | Default           | What it does                                                                                                                                                                                                                                                            |\n| -------------------- | --------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `data-agenticschema` | present / absent      | absent            | Marks the tag so the adapter can find it. Needed only when `document.currentScript` is unavailable *and* the `src` does not contain `agenticschema`, that is, a module script loading a self-hosted build under another filename. Always required in 0.1.2 and earlier. |\n| `data-actions`       | `off`                 | actions generated | Turns off executable tools entirely. Read tools are unaffected. Use this if you publish a `SearchAction` you would rather agents did not call.                                                                                                                          |\n| `data-max-tools`     | integer > 0           | `24`              | Ceiling on generated tools. Agents degrade as a toolset grows; a page listing 200 products has no business producing 200 tools. Values that are not a finite number above zero are ignored.                                                                             |\n| `data-watch`         | `off`                 | watching on       | Stops the adapter following DOM changes and History API navigations. Turn it off on a static page to save a `MutationObserver`.                                                                                                                                         |\n| `data-allow-hosts`   | comma-separated hosts | page origin only  | Extra hosts an action's destination may point at, beyond the page's own origin. Whitespace around each entry is trimmed. Widening this deliberately widens the exfiltration surface, so read [Security](#security) first.                                               |\n\n\nA page that uses all of them:\n\n```html\n<script type=\"module\"\n        data-agenticschema\n        data-actions=\"off\"\n        data-max-tools=\"8\"\n        data-watch=\"off\"\n        data-allow-hosts=\"api.example.com, search.example.com\"\n        src=\"https://cdn.jsdelivr.net/npm/@agenticschema/browser@latest\"></script>\n```\n\nAnything not on this list, such as profiles, payload caps, custom tools and timeouts, is\nreachable only from [the JavaScript API](#the-javascript-api). The attribute surface is\ndeliberately the small, safe subset that makes sense to set from markup.\n\n### Content-Security-Policy\n\nA page with a CSP has to allow `cdn.jsdelivr.net` in `script-src`, or the tag never executes:\n\n```\nContent-Security-Policy: script-src 'self' https://cdn.jsdelivr.net;\n```\n\nIf you would rather not open the CDN, self-host `dist/cdn/auto.js`. It is a single\nself-contained file, roughly 27 KB gzipped, with the WebMCP polyfill already inside.\n\nThe relay embed from tag ② is a **second** origin to allow, and it also creates a `blob:` iframe\nand opens a WebSocket, so its CSP needs are wider:\n\n```\nContent-Security-Policy: script-src 'self' https://cdn.jsdelivr.net;\n                         frame-src blob:;\n                         connect-src ws://127.0.0.1:9333;\n```\n\nThat is one more reason to keep the relay tag out of your production CSP entirely.\n\n### Version pinning\n\nUse an exact version in production. The unpinned specifier always serves the latest release, and\njsDelivr caches unversioned URLs at the edge for days.\n\n**0.1.1 and earlier register no tools at all** on a browser without native WebMCP: the polyfill\nwas left out of the bundle, and because the failure was silent the page looked healthy. Use\n`@0.1.2` or later.\n\n---\n\n\n\n## Choosing a transport\n\nThe tools are registered. Something has to carry them to an agent. There are three real options\nand one non-option:\n\n\n| Transport                    | Who calls the tools                                          | Setup                                              | Good for                                              |\n| ---------------------------- | ------------------------------------------------------------ | -------------------------------------------------- | ----------------------------------------------------- |\n| **Native browser WebMCP**    | the browser's own agent                                      | none: the browser provides `document.modelContext` | the end state, once it is on by default               |\n| **Chrome origin trial**      | the browser's own agent, on your visitors' own Chrome        | register a token for your origin and serve it      | trying the native path on real traffic today          |\n| **Local relay** (`embed.js`) | your desktop MCP client: Claude Desktop, Cursor, Claude Code | one script tag + `npx @mcp-b/webmcp-local-relay`   | development, testing, personal automation             |\n| **Browser extension**        | whatever the extension is wired to                           | install the extension                              | using tools across sites you do not control           |\n| **Nothing**                  | nobody                                                       | none                                               | registering tools and wondering why no one calls them |\n\n\nOnly the local relay needs anything from your page's HTML. That is the one this section covers,\nbecause it is the one people reach for first and the one whose failure mode is confusing.\n\nNative WebMCP is not on by default in any browser yet, but it is no longer flag-only. Chrome\nexposes it for local development via `chrome://flags/#enable-webmcp-testing` (restart required);\nsome builds also need `--enable-experimental-web-platform-features`. Since Chrome 149 there is\nalso an [origin trial](https://developer.chrome.com/blog/ai-webmcp-origin-trial): register a token\nfor your origin, serve it, and the native path works for ordinary visitors on production traffic\nwith no flag on their side.\n\nBecause it is still off by default for nearly everyone, `@mcp-b/webmcp-polyfill` stays a hard\ndependency of the browser adapter rather than an optional one. The polyfill is the normal case\nhere.\n\n### What `embed.js` actually does\n\nWorth understanding before you put it on a page, because it does more than load a script:\n\n```\n  ┌──────────────────────────────────────┐\n  │  Host page                           │\n  │  document.modelContext + your tools  │   ← @agenticschema/browser put them here\n  └──────────────────┬───────────────────┘\n                     │ postMessage\n  ┌──────────────────▼───────────────────┐\n  │  Hidden iframe (blob: URL)           │   ← embed.js injects this\n  │  injected by embed.js                │\n  └──────────────────┬───────────────────┘\n                     │ WebSocket ws://127.0.0.1:9333\n  ┌──────────────────▼───────────────────┐\n  │  webmcp-local-relay (Node process)   │   ← npx @mcp-b/webmcp-local-relay\n  └──────────────────┬───────────────────┘\n                     │ stdio / JSON-RPC\n  ┌──────────────────▼───────────────────┐\n  │  Claude Desktop / Cursor / any client│\n  └──────────────────────────────────────┘\n```\n\nConcretely, on every page load it:\n\n1. injects a hidden `<iframe>` from a `blob:` URL,\n2. opens a **WebSocket to** `ws://127.0.0.1:9333` from inside that iframe,\n3. enumerates the page's tools: `document.modelContext.listTools()` + `callTool()` when present,\n  falling back to `navigator.modelContextTesting.listTools()` + `executeTool()`,\n4. forwards them to the relay, which re-registers them as ordinary MCP tools over stdio,\n5. **reconnects if the relay is not there**, with exponential backoff from 500 ms to 3 s\n  (1.5× multiplier), giving up after 100 attempts.\n\nIts own attributes:\n\n\n| Attribute              | Default | What it does                                                                                   |\n| ---------------------- | ------- | ---------------------------------------------------------------------------------------------- |\n| `data-relay-port`      | `9333`  | Port to connect to. Must match the relay's `--port`.                                           |\n| `data-request-timeout` | `60000` | Per-request ceiling in ms. Raise it if a tool chains slow API calls and might exceed a minute. |\n\n\nAnd on the relay process:\n\n```bash\nnpx @mcp-b/webmcp-local-relay --port 9444 --widget-origin http://localhost:4321\n```\n\n`--widget-origin` restricts which host page origins may register tools. The default is `*`,\nmeaning **any page open in your browser that loads the embed can expose tools to your MCP\nclient**. That is convenient in development and worth tightening as soon as it is not.\n\nIf a second relay instance starts while the port is taken, it does not fail: it falls back to\n*client mode* and proxies through the existing one, so several MCP clients can share the same\nbrowser tabs. A `\"mode\": \"client\"` in `webmcp_list_sources` output is normal and not a symptom of\nanything.\n\n### Keep the relay out of production\n\nTag ② should not reach real visitors. For each of them it would inject a hidden iframe and\nattempt a WebSocket to `127.0.0.1:9333`, a port that, on their machine, is either nothing at all\nor something that is none of your business. With the retry policy above that is roughly five\nminutes of futile reconnection per page view, plus a page that visibly probes the visitor's own\nloopback interface.\n\nGate it on your build's development flag. In Astro:\n\n```astro\n{import.meta.env.DEV && (\n  <script src=\"https://cdn.jsdelivr.net/npm/@mcp-b/webmcp-local-relay@4/dist/browser/embed.js\"></script>\n)}\n```\n\nNext.js:\n\n```jsx\n{process.env.NODE_ENV === 'development' && (\n  <script src=\"https://cdn.jsdelivr.net/npm/@mcp-b/webmcp-local-relay@4/dist/browser/embed.js\" />\n)}\n```\n\nVite or plain HTML with a bundler: wrap it in `import.meta.env.DEV`, or simply keep the tag in a\nlocal-only template.\n\nTag ①, `@agenticschema/browser`, is designed to ship. It opens no connections, and on a browser\nwith no WebMCP and no polyfill available it registers nothing and logs a warning rather than\nthrowing.\n\n---\n\n\n\n## The JavaScript API\n\nFor anything the attributes cannot express, import the package instead of using the script tag:\n\n```js\nimport { start } from '@agenticschema/browser';\n\nconst handle = await start({\n  maxTools: 12,\n  actions: 'off',\n  allowedHosts: ['api.example.com'],\n});\n\nhandle.tools();         // ToolDescriptor[]: what is currently registered\nhandle.diagnostics();   // Diagnostic[]: what the pipeline skipped, and why\nawait handle.refresh(); // remap now; a no-op if the markup has not changed\nhandle.stop();          // unregister everything and stop watching\n```\n\n`start()` accepts every [pipeline option](#every-pipeline-option) plus four of its own:\n\n\n| Option         | Type           | Default                 | What it does                                                                                   |\n| -------------- | -------------- | ----------------------- | ---------------------------------------------------------------------------------------------- |\n| `document`     | `Document`     | the page's own          | The document to read. Lets you map an iframe, or a `linkedom`/`happy-dom` document under test. |\n| `watch`        | `boolean`      | `true`                  | Follow route changes and markup edits in single-page apps.                                     |\n| `debounceMs`   | `number`       | `250`                   | How long to wait after a DOM change before remapping.                                          |\n| `modelContext` | `ModelContext` | `document.modelContext` | The WebMCP surface to register on. Injectable for tests.                                       |\n\n\n\n\n### The returned `Handle`\n\n\n| Member          | Returns                     | Notes                                                                                                          |\n| --------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------- |\n| `tools()`       | `readonly ToolDescriptor[]` | What is registered right now.                                                                                  |\n| `diagnostics()` | `readonly Diagnostic[]`     | Why the rest is not: unparsable blocks, actions refused, fields truncated. Replaced on every remap, not appended. |\n| `refresh()`     | `Promise<void>`             | Remaps immediately. Compares a fingerprint of the markup, so it does nothing when nothing changed.              |\n| `stop()`        | `void`                      | Aborts every registration and detaches the watchers. `tools()` and `diagnostics()` both go empty.               |\n\n\n\n\n### How watching works\n\nWebMCP has no `unregisterTool`, so the adapter registers every tool with an `AbortSignal` and\naborts the whole batch to replace it.\n\nSince Chrome 153 that abort retires the tools but no longer cancels what they already had in\nflight, so the adapter carries the cancellation itself: every `execute` runs under the batch's\nsignal combined with the per-execution signal WebMCP passes in, and an action's `fetch` is bound\nby both plus its own timeout. A route change therefore stops the request the previous route\nstarted, which until 153 came free with the unregistration.\n\nA remap is triggered by:\n\n- a `MutationObserver` on `ld+json` script blocks and on the `itemscope`, `itemprop`, `itemtype`,\n`typeof` and `property` attributes,\n- `history.pushState`, `history.replaceState` and `popstate`, because in a single-page app the\nroute can change before the new markup arrives.\n\nBoth signals are debounced together by `debounceMs`. The comparison is made against a fingerprint\nof the *markup*, not of the tool names: when only a price changes the names stay identical while\nthe tool closures are already stale.\n\n---\n\n\n\n## The core pipeline\n\n`@agenticschema/core` has no MCP and no DOM assumptions, and zero runtime dependencies. It turns\na document into tool descriptors and nothing else:\n\n```js\nimport { toTools } from '@agenticschema/core';\n\nconst { tools, diagnostics, graph } = toTools(documentOrHtmlString, options);\n```\n\nThe five stages, and where each adapter picks the result up:\n\n```\n                    ┌──────────────── @agenticschema/core ─────────────────┐\n Document │ HTML    │                                                      │\n │ JSON-LD ───────► │  extract ──► normalize ──► select ──► map ──► guard  │ ──► ToolDescriptor[]\n                    └──────────────────────────────────────────────────────┘\n                                              │\n                          ┌───────────────────┴───────────────────┐\n                          ▼                                       ▼\n             @agenticschema/browser                   @agenticschema/server\n             document.modelContext                    stdio / fetch handler\n             (script tag, WebMCP)                     (works with any MCP client today)\n```\n\n\n| Stage         | Does                                                                                                                                                                    |\n| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **extract**   | Pulls out the raw structured-data blobs without interpreting them.                                                                                                      |\n| **normalize** | Flattens `@graph`, resolves `@id`, strips vocabulary prefixes, makes `@type` and all values arrays, hoists nested entities to top level, merges nodes sharing an `@id`. |\n| **select**    | Decides which entities deserve a tool and which collapse together.                                                                                                      |\n| **map**       | Applies a type profile to produce names, descriptions and JSON Schemas.                                                                                                 |\n| **guard**     | Validates names, cleans descriptions, caps payloads.                                                                                                                    |\n\n\nOne extraction detail that catches people out: **if** `source` **is an HTML string, only JSON-LD comes\nout.** Microdata and RDFa need a real HTML parser. Pass a `Document`, either the browser's own or\none from `linkedom` or `happy-dom` on Node, to get all three formats.\n\n### Every pipeline option\n\nShared by `toTools()`, `start()` and `createServer()`.\n\n**Extraction**\n\n\n| Option    | Type                                     | Default   | What it does                                                                           |\n| --------- | ---------------------------------------- | --------- | -------------------------------------------------------------------------------------- |\n| `formats` | `Array<'jsonld' | 'microdata' | 'rdfa'>` | all three | Which formats to read. Only `jsonld` has any effect when the source is an HTML string. |\n\n\n**Normalisation**\n\n\n| Option     | Type     | Default                 | What it does                                                                                                 |\n| ---------- | -------- | ----------------------- | ------------------------------------------------------------------------------------------------------------ |\n| `baseUrl`  | `string` | page URL in the browser | Base for resolving relative `@id` values. Also the fallback source of `pageOrigin`.                          |\n| `maxDepth` | `number` | `12`                    | Maximum nesting depth. The guard against circular references; exceeding it emits a `depth-limit` diagnostic. |\n\n\n**Mapping**\n\n\n| Option        | Type                         | Default              | What it does                                                                                                                                         |\n| ------------- | ---------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `profiles`    | `Profile[]`                  | generic profile only | The profile registry. `@agenticschema/profiles` supplies ~20 hand-written ones. Without it every entity falls back to generic naming.                |\n| `ancestorsOf` | `(type: string) => string[]` | none                 | Resolves a Schema.org type's ancestors, so `Vehicle` can use the `Product` profile without anyone declaring it. Also from `@agenticschema/profiles`. |\n| `maxTools`    | `number`                     | `24`                 | Ceiling on generated tools. Hitting it emits a `tool-limit` diagnostic.                                                                              |\n\n\nIn the browser adapter `profiles` and `ancestorsOf` load automatically, in their own chunk, after\nthe adapter is already running. They weigh more than everything else combined, and a page that\nincludes the script should pay as little as possible up front. If that chunk never arrives the\nadapter carries on with generic tool names and warns loudly, because tools with generic names look\nhealthy from the outside.\n\n**Actions**\n\n\n| Option         | Type                | Default                | What it does                                                                                                                                                                     |\n| -------------- | ------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `actions`      | `'auto' | 'off'`    | `'auto'`               | `off` disables executable tool generation entirely.                                                                                                                              |\n| `pageOrigin`   | `string`            | derived from `baseUrl` | The origin actions are vetted against. **With neither this nor** `baseUrl`**, no action tools are generated at all**, because there is no way to check where a request would go. |\n| `allowedHosts` | `readonly string[]` | `[]`                   | Extra hosts allowed beyond the page's own origin.                                                                                                                                |\n| `timeoutMs`    | `number`            | `10000`                | Ceiling on an action request. Without one, an endpoint that never answers leaves the agent waiting forever.                                                                      |\n| `fetchImpl`    | `typeof fetch`      | global `fetch`         | Injectable for tests and for the server adapter.                                                                                                                                 |\n\n\n**Guard**\n\n\n| Option                 | Type     | Default | What it does                                                                |\n| ---------------------- | -------- | ------- | --------------------------------------------------------------------------- |\n| `maxDescriptionLength` | `number` | `320`   | Longest a tool description may be.                                          |\n| `maxPayloadBytes`      | `number` | `32000` | Ceiling on the bytes a tool may return. Truncation emits `field-truncated`. |\n\n\n**Custom tools**\n\n\n| Option   | Type                    | Default | What it does                                                                                           |\n| -------- | ----------------------- | ------- | ------------------------------------------------------------------------------------------------------ |\n| `custom` | `readonly CustomTool[]` | `[]`    | Hand-declared tools. On a name clash these win over generated ones. See [Custom tools](#custom-tools). |\n\n\n\n\n### Diagnostics\n\n`toTools()` returns a `diagnostics` array alongside the tools. Nothing throws for page-content\nproblems: a malformed page produces fewer tools and a diagnostic, never an exception.\n\n\n| Code                 | Level | Means                                                               |\n| -------------------- | ----- | ------------------------------------------------------------------- |\n| `json-parse-error`   | warn  | An `ld+json` block did not parse. The others are still processed.   |\n| `unknown-context`    | warn  | A node's `@context` is not recognisable as Schema.org.              |\n| `depth-limit`        | warn  | Nesting exceeded `maxDepth`; the branch was cut.                    |\n| `no-structured-data` | info  | The page carries none. Not an error.                                |\n| `action-skipped`     | info  | A `potentialAction` did not pass the rules, with the reason.        |\n| `tool-limit`         | info  | `maxTools` was reached and the remainder dropped.                   |\n| `field-truncated`    | info  | A value was cut to fit `maxPayloadBytes` or `maxDescriptionLength`. |\n| `remap-failed`       | error | The browser adapter could not rebuild the tools. The page stays remappable, and the next change retries. |\n| `no-webmcp-surface`  | warn  | No `document.modelContext` and the polyfill did not load, so nothing was registered. |\n| `register-failed`    | warn  | The WebMCP surface refused the tool. Nearly always a second copy of the adapter on the page holding that name already: see [Two copies on one page](#two-copies-on-one-page). |\n\n### Two copies on one page\n\n`InvalidStateError: Duplicate tool name` in the console means the adapter is\nrunning twice on the same document. WebMCP refuses a name that is already live,\nand the second copy is asking for every name the first one registered.\n\nThe usual cause is one script tag written by hand and a second one injected by a\ntag manager — Zaraz, Google Tag Manager — often pinned to different versions:\n\n```html\n<script src=\"https://cdn.jsdelivr.net/npm/@agenticschema/browser@0.1\"></script>\n<!-- and, from the tag manager, invisible in the page source: -->\n<script src=\"https://cdn.jsdelivr.net/npm/@agenticschema/browser@latest\"></script>\n```\n\nTwo URLs are two downloads and two executions. Nothing dedupes them: the CDN\nbuild is an IIFE, so it does not get the module map's URL-keyed dedupe, and the\ntwo specifiers would not match under it anyway.\n\nFrom 0.3.1 the script-tag entry point latches on the document, so a second copy\nof that version or later reuses the first one's handle, registers nothing, and\nsays so with a console warning. **Delete one of the two tags regardless.** The\nlatch cannot help when the copy that loads first predates it, and the surviving\ntag should be the one whose `data-` attributes you actually want — the ignored\ncopy's configuration is dropped, not merged.\n\nTo check a live page:\n\n```js\n[...document.querySelectorAll('script[src]')].map((s) => s.src).filter((s) => s.includes('agenticschema'))\n```\n\n\n\nThe last two come from the browser adapter rather than the pipeline. They exist because an\nadapter with no way to say \"I could not do my job\" says nothing at all, which reads exactly like\na page that simply had no markup.\n\nThe Node server prints everything above `info` to stderr unless `--quiet` is passed. In the\nbrowser, `handle.diagnostics()` returns the same array.\n\n---\n\n\n\n## The Node server\n\n`@agenticschema/server` fetches pages itself and speaks full MCP, so it needs no browser and no\ntransport decision. It is also the only adapter that can expose **resources**: every entity\nbecomes a readable MCP resource as well as a tool.\n\n```bash\nnpx @agenticschema/server <url> [<url>...] [options]\n```\n\n\n| Flag                  | Default    | What it does                                              |\n| --------------------- | ---------- | --------------------------------------------------------- |\n| `--max-tools <n>`     | `24`       | Cap on generated tools.                                   |\n| `--no-actions`        | actions on | Do not build executable tools from `potentialAction`.     |\n| `--allow-host <host>` | none       | Extra host allowed for actions. Repeatable.               |\n| `--http`              | off        | Serve over HTTP instead of stdio. Binds `127.0.0.1` only. |\n| `--port <n>`          | `3111`     | Port for `--http`.                                        |\n| `--quiet`             | off        | Keep diagnostics off stderr.                              |\n| `-h`, `--help`        | n/a        | Print usage and exit.                                     |\n\n\nMultiple URLs are merged into one server. Diagnostics and the tool list go to **stderr**, always.\nstdout belongs to the protocol.\n\nProgrammatic use:\n\n```js\nimport { createServer } from '@agenticschema/server';\n\nconst { server, tools, diagnostics } = await createServer(\n  ['https://example.test/product'],\n  { maxTools: 12, actions: 'off', allowedHosts: [] }\n);\n```\n\n\n\n### Over HTTP\n\n`createHttpHandler()` returns the same mapping behind a `fetch`, for a Worker or any runtime\nthat speaks `Request`/`Response`:\n\n```js\nimport { createHttpHandler } from '@agenticschema/server';\n\nconst handler = await createHttpHandler(['https://example.test/product']);\n\nexport default { fetch: (request) => handler.fetch(request) };\n```\n\nThe 2026-07-28 revision is stateless, so the SDK builds a fresh server per request. The pages\nare read once, when the handler is created. Refetching them per request would turn every\n`tools/list` into a live hit on someone else's origin.\n\n**It performs no authentication and no host or origin checking.** On a Worker that is the\nplatform's job; the `--http` CLI path applies the SDK's `hostHeaderValidationResponse` and\n`originValidationResponse` against loopback allowlists, because a local port is reachable from\nwhatever page the browser happens to be on and DNS rebinding is a live risk there rather than a\ntheoretical one.\n\n### Cache hints\n\nResults of `tools/list`, `resources/list` and `resources/read` carry the `ttlMs` / `cacheScope`\nfields the 2026-07-28 revision requires. Without them the SDK emits the most pessimistic pair it\ncan, `ttlMs: 0` with `cacheScope: 'private'`, which tells every client to refetch a listing that\ncannot have changed: pages are read once at startup and never refetched.\n\n\n| Result                                            | Default                | Why                                                                                                                                                        |\n| ------------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `tools/list`, `resources/list`, `server/discover` | `300000` ms, `public`  | The guard keeps page text out of tool descriptions, so nothing in a listing belongs to whoever asked, and a shared cache may hold it.                      |\n| `resources/read`                                  | `300000` ms, `private` | This is the page's own content. A caller can hand us `html` from somewhere we know nothing about, so authorising a shared cache over it is not ours to do. |\n\n\n`cacheTtlMs` changes the lifetime; `0` restores the SDK default.\n\n---\n\n\n\n## What it produces on real pages\n\nThe Open Food Facts listing at the top of this file is one of two pages here. The other is\nWikipedia, run through the pipeline exactly as it is published today:\n\n```\nen.wikipedia.org/wiki/Backpack\n  read    get_article\n  read    get_article_author\n  read    get_article_publisher\n  read    get_media\n```\n\nNothing executable comes out of that page: all four tools are readers. Open Food Facts is the\ninteresting case because `search_web_site` is not. That tool exists because the page publishes a\n`SearchAction` whose target sits on its own origin, which is the only shape that gets past the\nguard described below.\n\n`get_search_action` in the Open Food Facts listing is noise, a reader over the action's own\ndefinition, which is of no use to an agent. It is a known rough edge, left visible rather than\ntrimmed out of the example.\n\nSites were picked for their licensing, not their fame. Wikipedia and Open Food Facts both\npublish under open licences and permit automated access; plenty of better-known sites forbid\nit in their terms, and pointing this tool at them is on you.\n\n### Measured on 177 real pages\n\nThose two examples are illustrations. The table below is a measurement, taken over 177 pages\npulled from a Common Crawl snapshot — shops, news, recipes, reference, books — each one run\nthrough the pipeline in full. Tokens per page, averaged, counted with `o200k_base`:\n\n| What the model reads | Tokens | vs raw HTML | vs extracted text |\n| --- | ---: | ---: | ---: |\n| Raw HTML, as served | 143,771 | — | — |\n| Extracted text, what a competent scraper sends | 2,752 | 52x | — |\n| AgenticSchema tool output | 1,440 | **100x** | **1.9x** |\n\nThe 100x is the number that looks good in a headline and it is the wrong one to quote. Nobody\nserious feeds raw HTML to a model: a scraper strips the markup first, and that one step accounts\nfor 52 of the 100. **Against a competent scraper the honest figure is 1.9x**, and it is not\nuniform:\n\n| Vertical | Pages | Extracted text | AgenticSchema | vs text |\n| --- | ---: | ---: | ---: | ---: |\n| reference | 50 | 3,699 | 208 | 18x |\n| news | 25 | 882 | 528 | 1.7x |\n| ecommerce | 75 | 2,912 | 1,815 | 1.6x |\n| recipe | 25 | 1,549 | **3,787** | **0.4x** |\n\nOn recipes the library **loses**, at more than twice the cost of sending the text. A recipe's\nstructured data is the recipe — every ingredient, every step, every timing, plus a nutrition\nblock — so the tools re-emit in JSON what the page already said in prose, and JSON is the more\nexpensive encoding. That is a gap in the design, not a bug, and nothing in the library currently\nnotices it.\n\nTwo things the table does not say. It is a size measurement, not a quality one: it counts what an\nagent has to read, not whether it answers better, which tool it picks, or how many calls it takes.\nAnd the corpus is curated from sites that publish good Schema.org, so every number means \"where\nthe markup exists and is done well\" rather than \"the web\".\n\nBuild it and check the numbers yourself:\n\n```bash\nnpm run corpus:fetch    # from Common Crawl; no requests to the sites themselves\nnpm run corpus:report   # what the pages contain\nnpm run test:corpus     # what the pipeline makes of them\n```\n\nMethod, type census, the three defects it caught, and the verticals it cannot reach at all are in\n[docs/corpus.md](docs/corpus.md). No page content and no page list is committed to this\nrepository: only the seed recipe and the aggregate numbers.\n\n### Does an agent actually answer better?\n\nThe table above is a size measurement. The next question is whether an agent *answers* better with\nthe tools than with the page's text.\n\nAn agent is asked five questions about a page, twice: once with the extracted text in the prompt\nand no tools, once with an MCP server built from the page's markup and no text. The same answers\nare then scored against two different keys, because one of them cannot answer the question. A key\nwritten from the rendered text alone puts a ceiling on the tools arm — its best possible result is\nrepeating the prose — so a second key reads the text *and* what the page publishes as data, and\ncounts a fact from either.\n\nOver **120 trials on 12 pages** with `sonnet`, in one verified configuration:\n\n| Referee | Text in the prompt | AgenticSchema tools |\n| --- | ---: | ---: |\n| Key written from the page text alone | 95% | 77% |\n| Key written from text and published data | 87% | 87% |\n| ...over the 11 pages that publish something to map | 85% | **89%** |\n\nThe fair referee moves both arms — the tools arm up ten points, the text arm *down* eight, because\nfacts carried only in the markup now count against whoever missed them. Set aside the pages that\npublish nothing at all, and the tools arm comes out **four points ahead**. It wins on recipes (+20)\nand on the two-part questions (+9, where prose scatters two facts across a page and a reader drops\none of them); it loses on news, where one publisher credits the wire service in its byline and\nitself in its markup.\n\nIt is not free, and what it costs depends on the page. The tools arm takes **2.2 turns against\n1.0**, because fetching on demand costs a call and an answer. But the text arm's context *is* the\npage, so it grows with the page: on this sample the two read within 1% of each other and the tools\narm cost a third less, while on a sample of longer pages the text arm read less. **Turns are what\none pays; page size is what the other pays.**\n\nIt also depends on the model. Run the same cells with the arms on `haiku` and the result reverses —\nthe text arm barely moves while the tools arm drops six points, because reading a page is easier\nthan calling a tool. This library helps an agent good enough to use what it is handed.\n\nReading every disagreement one at a time is what makes the number useful, and it found the same\ndefect twice in mirror image — a `ProductGroup` keeping its price in `hasVariant[].offers`, and a\nvariant keeping its rating in `isVariantOf.aggregateRating`. Both sat one hop from where the\nvocabulary suggests, both were invisible from the code, and following the path took the price\nquestions from 8 of 11 to 11 of 11.\n\nFour points on 55 trials is two trials from a tie. Read it as \"no longer behind, plausibly ahead\"\nrather than as a headline — and note that a run four times the size said the same thing, in the\nsame direction, before the library was fixed.\n\n```bash\nnpm run bench:run -- --dry-run          # what it would cost, spending nothing\nnpm run bench:run                       # 100 cells, one sitting\nnpm run bench:run -- --arms tools --redo  # after a fix: re-run only what disagreed\nnpm run bench:report                    # the tables, and the disagreements to read by hand\n```\n\nThe full method, the isolation the harness enforces, and what the measurement does not cover are\nin [docs/bench.md](docs/bench.md).\n\n---\n\n\n\n## Three constraints that shaped the design\n\n**A page cannot expose an MCP endpoint.** Not \"it's hard\": a browser tab cannot listen on a port.\nIn the browser the transport is `document.modelContext`, provided by the browser itself. This\nlibrary is the mapping layer, not a transport. Everything in\n[Read this first](#read-this-first-registration-is-not-transport) follows from this one sentence.\n\n**WebMCP exposes tools only.** No resources and no prompts, and the W3C explainer is explicit\nabout it. So entities become *read tools* in the browser. The Node adapter, which speaks full\nMCP, exposes them as resources *as well*.\n\n`potentialAction` **is rare in the wild.** In practice it is almost only `SearchAction`, and\nGoogle retired the Sitelinks Searchbox in November 2024, so adoption is falling. Auto-derivation\nalone would produce a read-only library. That is why `defineTool()` is a first-class feature\nrather than an afterthought.\n\n---\n\n\n\n## Actions are deliberately restricted\n\nRead tools are always generated. Executable tools are not:\n\n\n| Condition                                                            | Result                         |\n| -------------------------------------------------------------------- | ------------------------------ |\n| `SearchAction`, `FindAction`, `ReadAction`, `ViewAction`             | eligible                       |\n| `httpMethod` absent or `GET`                                         | eligible                       |\n| Destination same-origin (or explicitly allow-listed)                 | eligible                       |\n| Anything else (`OrderAction`, `POST`, cross-origin, non-http scheme) | **skipped, with a diagnostic** |\n\n\nThe four eligible types are all idempotent. An `OrderAction` or a `ReserveAction` has consequences\nout in the world: generating those automatically would mean that dropping a script onto a site\nmakes its products orderable by any agent that wanders past.\n\nA skipped action is never silent. It produces an `action-skipped` diagnostic naming the reason,\nso if you expected an action tool and did not get one, that diagnostic says why.\n\nAnything with side effects goes through explicit opt-in instead.\n\n---\n\n\n\n## Custom tools\n\n`custom` is the way in for everything auto-derivation cannot give you: actions with side effects,\nprivate endpoints, and anything `potentialAction` does not describe.\n\n```js\nimport { start } from '@agenticschema/browser';\n\nstart({\n  custom: [{\n    name: 'check_stock',\n    description: 'Check in-store availability for a postal code',\n    inputSchema: {\n      type: 'object',\n      properties: { postalCode: { type: 'string' } },\n      required: ['postalCode'],\n      additionalProperties: false,\n    },\n    execute: async ({ postalCode }) => ({\n      content: [{ type: 'text', text: await (await fetch(`/api/stock?cap=${postalCode}`)).text() }],\n    }),\n  }],\n});\n```\n\n\n| Field         | Required | Default                                      | Notes                                                                                                                                         |\n| ------------- | -------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |\n| `name`        | yes      | n/a                                          | Must match what the MCP spec allows; the guard rejects anything else.                                                                         |\n| `description` | yes      | n/a                                          | Capped at `maxDescriptionLength`.                                                                                                             |\n| `inputSchema` | no       | empty object schema                          | Standard JSON Schema with `additionalProperties: false`.                                                                                      |\n| `execute`     | yes      | n/a                                          | Returns `{ content: [{ type: 'text', text }] }`, optionally with `isError`.                                                                   |\n| `annotations` | no       | `readOnlyHint: false`, `openWorldHint: true` | Defaults assume a hand-declared tool is meant to *do* something, the opposite of generated read tools, which are always `readOnlyHint: true`. |\n\n\nCustom tools still pass through the guard: names are validated, descriptions cleaned, payloads\ncapped. They win over a generated tool of the same name.\n\n---\n\n\n\n## Security\n\nThe library takes page content and puts it into a model's context. Two attack channels are\nclosed in `core`, so every adapter inherits them:\n\n- **Prompt injection.** A `ld+json` block injected through UGC or a compromised CMS can carry\ninstructions. Page text never enters a tool's *name* or *description*, only its *data*, and\nis stripped of HTML and control characters, with length caps. HTML tags go first, since they\nare the usual way to hide instructions from a human reader but not from a model. `@type` is the\nexception, because a tool is named after it: it is taken only where it is shaped like a type\n(one word, letters and digits, 40 characters at most) and becomes `Thing` where it is not.\n- **Exfiltration via** `urlTemplate`**.** A hostile action could point elsewhere and receive the\nparameters. Destinations are same-origin by default, https-only, RFC 6570 level 1 only, and\nre-validated **after** template expansion so a crafted value cannot move the target. Redirects\nare refused rather than followed, since a 3xx would land past both checks.\n\nPlus a cap on tool count (default 24, read and action tools sharing the one budget) and on\npayload size, because agents degrade badly with large or bloated toolsets. Secondary entities of\nthe same type collapse into a single tool: nine indistinguishable `get_person` tools are useless\nto an agent; one `list_person` is not.\n\nTwo things that are **your** decision, not the library's:\n\n- `allowedHosts` and `--allow-host` widen the exfiltration surface on purpose. Every host you add\nis a destination an action's expanded URL may reach. Add hosts you control.\n- The local relay's default `--widget-origin` is `*`. Any page in your browser that loads the\nembed can register tools with your MCP client. Restrict it once you are past first setup.\n\nIf you put this in front of an agent that can act on someone's behalf, read\n[SECURITY.md](SECURITY.md) first: it sets out what the threat model does and, more importantly,\ndoes not cover.\n\n---\n\n\n\n## Troubleshooting\n\n\n| Symptom                                                                         | Cause                                                                                                                                      | Fix                                                                                                                            |\n| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |\n| Tools visible in DevTools → Application, but the MCP client shows **0 sources** | Registration done, transport missing                                                                                                       | Add the relay embed tag. See [Choosing a transport](#choosing-a-transport)                                                     |\n| `webmcp_list_sources` returns `count: 0`                                        | The page never opened the WebSocket                                                                                                        | Confirm `embed.js` is in the page *and* the relay process is running                                                           |\n| A `data-*` option has no effect                                                 | The adapter cannot identify its own tag: `type=\"module\"` on a self-hosted `src` without `agenticschema` in it, or version 0.1.2 or earlier | Drop `type=\"module\"`, or add `data-agenticschema`. See [How the adapter finds its own tag](#how-the-adapter-finds-its-own-tag) |\n| No tools at all, no errors                                                      | Version 0.1.1 or earlier on a browser without native WebMCP                                                                                | Upgrade to `@0.1.2` or later                                                                                                   |\n| The script tag never runs                                                       | CSP blocks `cdn.jsdelivr.net`                                                                                                              | Allow it in `script-src`, or self-host `dist/cdn/auto.js`                                                                      |\n| Tools appear with generic names                                                 | The profiles chunk failed to load                                                                                                          | Check the console for the warning; check CSP and network                                                                       |\n| No action tool from a `SearchAction`                                            | Cross-origin target, `POST`, non-http scheme, or no `pageOrigin`                                                                           | Read the `action-skipped` diagnostic; set `baseUrl` if running headless                                                        |\n| `list_*` instead of several `get_*`                                             | Working as intended: same-type entities collapse                                                                                           | Nothing to fix                                                                                                                 |\n| Tools stop updating in an SPA                                                   | `data-watch=\"off\"`, or the markup is replaced in a way the observer misses                                                                 | Remove `data-watch=\"off\"`; call `handle.refresh()` manually                                                                    |\n| Fewer tools than entities                                                       | `maxTools` reached                                                                                                                         | Raise `data-max-tools`; check for the `tool-limit` diagnostic                                                                  |\n| Truncated values in a tool result                                               | `maxPayloadBytes` reached                                                                                                                  | Raise it via the JS API; check for `field-truncated`                                                                           |\n| `\"mode\": \"client\"` in relay output                                              | A second relay instance is sharing the first                                                                                               | Normal, not a fault                                                                                                            |\n| `Host response timeout` from the relay                                          | A tool took longer than 60 s                                                                                                               | Raise `data-request-timeout` on the embed tag                                                                                  |\n| Connection refused on the relay port                                            | Port mismatch                                                                                                                              | `data-relay-port` must equal the relay's `--port`                                                                              |\n\n\n---\n\n\n\n## Known rough edges\n\nListed rather than hidden, because finding them yourself costs more than reading them here.\n\n- Tag identification is heuristic. `document.currentScript` is `null` in module scripts, so the\nadapter looks for `data-agenticschema` or an `src` containing `agenticschema`. A self-hosted\nbuild under an unrelated filename and without the marker matches neither, and its options are\nignored in silence.\n- `get_search_action` is a read tool over an action's own definition, of no use to an agent.\n- WebMCP has no `unregisterTool`, so every remap aborts and re-registers the whole batch.\n- Actions need an origin. Headless use with neither `baseUrl` nor `pageOrigin` silently produces\nno action tools.\n\n---\n\n\n\n## Packages\n\n\n| Package                   | Purpose                                                                                                                      |\n| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |\n| `@agenticschema/core`     | The pipeline. No MCP, no DOM assumptions. Zero runtime dependencies.                                                         |\n| `@agenticschema/profiles` | ~20 hand-written type profiles + the Schema.org hierarchy.                                                                   |\n| `@agenticschema/browser`  | WebMCP adapter. Script-tag build is one self-contained file, 27 KB gzip, polyfill included.                                  |\n| `@agenticschema/server`   | MCP server over stdio, plus a fetch-shaped HTTP handler for Workers and other HTTP runtimes. Speaks the 2026-07-28 revision. |\n\n\nThird-party pieces this works with, both from the `@mcp-b` project: `@mcp-b/webmcp-polyfill`\n(a dependency of the browser adapter) and `@mcp-b/webmcp-local-relay` (the optional transport).\n\n---\n\n\n\n## How it compares\n\n- `schema-org-mcp` serves the Schema.org *vocabulary* to an LLM (validate types, generate\nsnippets). It does not look at real pages.\n- `wmcp.sh` is a hosted SaaS doing something adjacent server-side. This is an embeddable\nopen-source library, client-side first.\n- `@mcp-b/*` provide the WebMCP transport and polyfill. This builds on them; it does not\nreplace them.\n\nThe mapping layer from Schema.org to MCP is the part that did not exist.\n\n---\n\n\n\n## Development\n\n```bash\nnpm install\nnpm test          # 156 tests over synthetic fixtures, in a couple of seconds\nnpm run typecheck\nnpm run build\nnpm run size      # fails if the script-tag build has an import a browser cannot\n                  # resolve, or goes over 30 KB gzip\n```\n\nThe corpus of real pages is a separate command, because half a megabyte of markup per page is\nminutes rather than seconds and `npm test` has to stay quick enough to run on every save:\n\n```bash\nnpm run corpus:fetch    # build it from Common Crawl into fixtures/local (untracked)\nnpm run corpus:report   # what the pages contain\nnpm run test:corpus     # the pipeline against all of them\n```\n\n`test:corpus` reports sizes in bytes on its own. The token columns need a tokenizer, which is\ndeliberately not a dependency of this repository: it weighs 55 MB installed, for one measurement\nin one optional suite, and cloning this project to fix a typo should not cost that. Ask for it\nwhen you want it, and the suite picks it up:\n\n```bash\nnpm install --no-save gpt-tokenizer\n```\n\n`npm run build:hierarchy -w @agenticschema/profiles` regenerates the Schema.org type hierarchy.\n`npm run corpus:fetch:jsonld` is the older fetcher, which pulls JSON-LD from three live pages.\n\nContributions welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) and\n[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).\n\n---\n\n\n\n## Status and disclaimer\n\nEarly, pre-1.0, API not stable. WebMCP itself is still a proposal working its way through the\nW3C. Chrome has it behind a flag for local development and, since Chrome 149, in an origin trial,\nbut no browser turns it on by default yet. That is why the polyfill is a hard dependency of the\nbrowser adapter rather than an optional one.\n\n**Provided as is, with no warranty of any kind, express or implied.** Use at your own risk. The\nauthor accepts no liability for any damage, data loss, security incident, or other consequence\narising from use of this software. See the MIT licence for the binding terms. If you put this in\nfront of an agent that can act on someone's behalf, read [SECURITY.md](SECURITY.md) first: it\nsets out what the threat model does and, more importantly, does not cover.\n\nMIT.\n"}