cd /news/ai-tools/show-hn-agenticschema-turn-structure… · home topics ai-tools article
[ARTICLE · art-99622] src=github.com ↗ pub= topic=ai-tools verified=true sentiment=↑ positive

Show HN: AgenticSchema – Turn structured web data (Schema.org) into MCP tools

AgenticSchema, a new open-source library, converts existing Schema.org structured data (JSON-LD, microdata, RDFa) on web pages into Model Context Protocol (MCP) tools that AI agents can call, eliminating the need for new APIs or backends. The library, available via npm as @agenticschema/server and a script tag build of roughly 27 KB gzipped, registers tools on document.modelContext, with transport handled separately by WebMCP-capable browsers or relays. A live example from Open Food Facts demonstrates a search_web_site tool that lets agents query the site directly.

read37 min views14 publishedAug 17, 2026
Show HN: AgenticSchema – Turn structured web data (Schema.org) into MCP tools
Image: Michielbdejong (auto-discovered)

Turn the Schema.org markup a page already has into MCP tools an AI agent can call.

Most pages already publish structured data. Agents still scrape them. This library closes that gap: it reads the JSON-LD

, microdata and RDFa already in the page and emits Model Context Protocol tools. You write no new API and you run no backend.

              Website
                 │
                 │  the Schema.org markup it already publishes:
                 │  JSON-LD · microdata · RDFa
                 ▼
           AgenticSchema
                 │
                 │  one callable tool per thing the page describes
                 │
           ┌─────┴─────┐
           ▼           ▼
        WebMCP        MCP
       (browser)    (Node)
           └─────┬─────┘
                 ▼
               Agent

Here is what comes out of a page that exists today:

world.openfoodfacts.org/product/3017620422003

  read    get_web_site
  read    get_organization
  read    get_search_action
  action  search_web_site(search_term_string)

search_web_site

is executable. An agent holding it queries Open Food Facts directly instead of guessing a URL or going through a search engine. Nobody published anything new to make that happen: the page has carried a SearchAction

all along, and even the parameter name is the one the page itself declares in query-input

.

Run that page yourself. No browser, and no transport to configure:

npx @agenticschema/server https://world.openfoodfacts.org/product/3017620422003

(get_search_action

in that list is noise, a read tool over the action's own definition. It is a known rough edge, left visible rather than trimmed out of the example.)

On your own site it is one file. The script-tag build is a plain classic script with the WebMCP polyfill already inside, roughly 27 KB gzipped, so it goes wherever a <script>

tag goes: a WordPress theme, a Shopify theme, a React, Next.js or Astro layout, or Google Tag Manager.

<script src="https://cdn.jsdelivr.net/npm/@agenticschema/browser@latest"></script>

You install no package and you configure no bundler. That tag reads the page and registers the tools, which is the whole of step one. Something still has to carry them to an agent: Chrome has run an origin trial for the native path since version 149, and there is a local relay for development. The next section is about that choice, and it is the part people get wrong.

The six sections worth reading before anything else. GitHub's own outline menu has the rest.

Read this first: registration is not transportQuick startWhy this is worth doing at allThe core pipelineSecurityTroubleshooting

This is the single thing that trips people up, so it comes before everything else.

Getting a page's data to an agent takes two steps, and this library only does the first one:

   ①  REGISTRATION                        ②  TRANSPORT
   @agenticschema/browser                 a WebMCP-capable browser,
   reads the page's Schema.org            an extension, or a local relay
   markup and registers tools             carries those tools to the agent
   on document.modelContext
        │                                        │
        └──────────► document.modelContext ◄─────┘
                     (the meeting point)

@agenticschema/browser

writes tools into document.modelContext

. That is the whole job. It does not open a connection to anything, because a browser tab cannot listen on a port. See Three constraints.

So after adding the script tag you have a page whose tools are correctly registered and that no agent can reach yet. Nothing is broken; the second half is simply not there. You pick the transport separately, and the choice depends on who is meant to call the tools. See Choosing a transport.

The symptom of forgetting step ② is very specific and worth recognising: the tools show up in Chrome DevTools (Application panel) but your MCP client reports zero sources. DevTools reads document.modelContext

in-process; your MCP client is a separate program that cannot. Everything is working, and nothing is connected.

Two tags. The first registers the tools, the second carries them to a local MCP client such as Claude Desktop, Cursor or Claude Code.

<!-- ① registration: read this page's Schema.org markup, publish it as WebMCP tools -->
<script src="https://cdn.jsdelivr.net/npm/@agenticschema/browser@latest"></script>

<!-- ② transport (development only): bridge those tools to a local MCP relay -->
<script src="https://cdn.jsdelivr.net/npm/@mcp-b/webmcp-local-relay@4/dist/browser/embed.js"></script>

Then run the relay and point your MCP client at it:

{
  "mcpServers": {
    "webmcp-local-relay": {
      "command": "npx",
      "args": ["-y", "@mcp-b/webmcp-local-relay@latest"]
    }
  }
}

Open the page, and the tools appear in your client. Verify with webmcp_list_sources

: your tab should be listed with a tool count above zero.

Four things worth knowing before you paste that in:

  • Order matters. The relay embed reads whatever is already registered and subscribes to changes, so put it after the registration tag.
  • Leave off type="module"

. The script-tag build is an IIFE, so it runs as an ordinary script, which is what makes it work through a tag manager. Addingtype="module"

still works, but it costs youdocument.currentScript

and with it the simplest way to read options. SeeHow the adapter finds its own tag. - Tag ② is for development. Shipping it to real visitors makes every one of their browsers probe 127.0.0.1

. SeeKeep the relay out of production. - Pin your versions. Unversioned jsDelivr URLs are cached at the edge for days, long enough to keep serving a build you have already replaced. @latest

and@4

above are pins.

If you only want the browser's own built-in agent to use the tools, you need tag ① alone.

That first sentence is measurable rather than a pitch. Schema.org publishes the usage statistics from Google's crawl, counting how many domains use each term:

Term Domains
potentialAction
10M+
SearchAction
10M+
EntryPoint
10M+
urlTemplate
10M+
query-input
10M+

Over ten million domains already declare how to search them, machine-readably, today. That is a capability this library hands to an agent as a callable tool, and nobody had to publish anything new for it to work. The vocabulary's tail is shorter than it looks, too: of 958 types, 16 appear on 10M+ domains, 50 on 1M+ and 95 on 100K+, so a hand-written profile registry can cover the part of the web that exists in practice.

One caveat worth stating plainly: those counts are what sites declare, not what is well formed enough to map. That is a different number, and the table does not claim it. Source: schemaorg/schemaorg data/public_stats/google

, 2026-07.

Three ways in, in rising order of commitment.

Paste any JSON-LD and watch the tools appear. Try the hostile sample: it is the fastest way to see what the library refuses and why. Alongside it, a live page carrying the script tag for the WebMCP path end to end.

Both pages load the packages from jsDelivr at exact versions, so what you are trying is what you would ship, not a local build.

No browser, and no transport question. The Node adapter fetches the page itself and speaks plain MCP over stdio:

npx @agenticschema/server https://en.wikipedia.org/wiki/Backpack

Wire it into Claude Desktop:

{
  "mcpServers": {
    "page": {
      "command": "npx",
      "args": ["-y", "@agenticschema/server", "https://en.wikipedia.org/wiki/Backpack"]
    }
  }
}

Every entity also becomes a readable MCP resource, which the browser adapter cannot do.

This is the shortest path to seeing real output, and the one with the fewest moving parts. If you are evaluating the library, start here.

See Quick start above, then The script tag, in full.

The build is a single classic script with no bundler and no package install behind it, so anywhere you can paste a <script>

tag will do:

Platform Where
WordPress The theme's header template, or any plugin that inserts scripts into <head> .
Shopify theme.liquid , before the closing </head> .
React, Next.js, Astro The shared layout or document component, alongside your other third-party tags.
Plain HTML In <head> , or before </body> .
Google Tag Manager, Cloudflare Zaraz A Custom HTML tag. See

Put it in the layout that every page shares rather than on one page, and as early as you can. In a single-page app the adapter follows history.pushState

and route changes by itself, so a single tag covers every route with no extra wiring.

One thing can stop it outright: a Content-Security-Policy that does not allow the CDN. That is a one-line fix, or you can self-host the file. See Content-Security-Policy.

If you would rather import the package instead, it is on npm, and the JavaScript API takes the options the attributes cannot express.

To read its data-*

options the adapter first has to find the tag it was loaded from. It tries three things, in order:

document.currentScript

, set while aclassic script runs, including one a tag manager inserted, andnull

in a module script because the HTML specification says so,script[data-agenticschema]

, an explicit marker,script[src*="agenticschema"]

, the src of the standard snippet.

Since the build is an IIFE, the plain snippet takes rule 1 and everything works with no marker, whatever the file is called and however it got onto the page:

<script data-max-tools="8"
        src="https://cdn.jsdelivr.net/npm/@agenticschema/browser@latest"></script>

Adding type="module"

gives up rule 1. The tag then has to be identifiable some other way: the URL above still matches rule 3, but a self-hosted copy under an unrelated filename matches nothing, and its options are ignored in silence.

<!-- module script, and nothing in the src says "agenticschema": rule 2 or nothing -->
<script type="module" data-agenticschema data-max-tools="8"
        src="/assets/webmcp-bundle.js"></script>

Using 0.1.2 or earlier?That build was ESM, so the tag neededtype="module"

, rule 1 never applied and rule 3 did not exist.data-agenticschema

was mandatory foranyoption to have an effect, and its absence was silent. Measured on one page with three JSON-LD blocks,data-max-tools="2"

without the marker produced 5 tools instead of 2.

When an option is ignored nothing warns you: the page keeps working and quietly uses defaults, which is indistinguishable from success until you go counting tools.

Google Tag Manager and Cloudflare Zaraz inject a plain <script src>

and never set type="module"

. That is why the script-tag build is an IIFE: an ESM bundle loaded that way is a syntax error before a line of it runs, and what the tag manager reports back is unhelpful.

Use a Custom HTML tag containing the snippet from Quick start, unchanged. Options work too: a dynamically inserted classic script still has document.currentScript

, so data-*

attributes are read even when the tag manager serves the file from its own proxy under a name with no agenticschema

in it.

Two things to watch:

  • Fire it on every page, as early as possible. The adapter maps the markup it finds and then watches for changes, so firing late only delays the first registration. A trigger scoped to one page, though, leaves the rest of the site with no tools.
  • Keep the relay embed out of a tag manager. Tag managers run in production by definition, and that tag has no business on a real visitor's browser. See Keep the relay out of production.

All options are optional. With none of them set you get every default in the right-hand column.

Attribute Values Default What it does
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 a self-hosted build under another filename. Always required in 0.1.2 and earlier.
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.
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.
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 .
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

A page that uses all of them:

<script type="module"
        data-agenticschema
        data-actions="off"
        data-max-tools="8"
        data-watch="off"
        data-allow-hosts="api.example.com, search.example.com"
        src="https://cdn.jsdelivr.net/npm/@agenticschema/browser@latest"></script>

Anything not on this list, such as profiles, payload caps, custom tools and timeouts, is reachable only from the JavaScript API. The attribute surface is deliberately the small, safe subset that makes sense to set from markup.

A page with a CSP has to allow cdn.jsdelivr.net

in script-src

, or the tag never executes:

Content-Security-Policy: script-src 'self' https://cdn.jsdelivr.net;

If you would rather not open the CDN, self-host dist/cdn/auto.js

. It is a single self-contained file, roughly 27 KB gzipped, with the WebMCP polyfill already inside.

The relay embed from tag ② is a second origin to allow, and it also creates a blob:

iframe and opens a WebSocket, so its CSP needs are wider:

Content-Security-Policy: script-src 'self' https://cdn.jsdelivr.net;
                         frame-src blob:;
                         connect-src ws://127.0.0.1:9333;

That is one more reason to keep the relay tag out of your production CSP entirely.

Use an exact version in production. The unpinned specifier always serves the latest release, and jsDelivr caches unversioned URLs at the edge for days.

0.1.1 and earlier register no tools at all on a browser without native WebMCP: the polyfill was left out of the bundle, and because the failure was silent the page looked healthy. Use @0.1.2

or later.

The tools are registered. Something has to carry them to an agent. There are three real options and one non-option:

Transport Who calls the tools Setup Good for
Native browser WebMCP
the browser's own agent none: the browser provides document.modelContext
the end state, once it is on by default
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
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
Browser extension
whatever the extension is wired to install the extension using tools across sites you do not control
Nothing
nobody none registering tools and wondering why no one calls them

Only the local relay needs anything from your page's HTML. That is the one this section covers, because it is the one people reach for first and the one whose failure mode is confusing.

Native WebMCP is not on by default in any browser yet, but it is no longer flag-only. Chrome exposes it for local development via chrome://flags/#enable-webmcp-testing

(restart required); some builds also need --enable-experimental-web-platform-features

. Since Chrome 149 there is also an origin trial: register a token for your origin, serve it, and the native path works for ordinary visitors on production traffic with no flag on their side.

Because it is still off by default for nearly everyone, @mcp-b/webmcp-polyfill

stays a hard dependency of the browser adapter rather than an optional one. The polyfill is the normal case here.

Worth understanding before you put it on a page, because it does more than load a script:

  ┌──────────────────────────────────────┐
  │  Host page                           │
  │  document.modelContext + your tools  │   ← @agenticschema/browser put them here
  └──────────────────┬───────────────────┘
                     │ postMessage
  ┌──────────────────▼───────────────────┐
  │  Hidden iframe (blob: URL)           │   ← embed.js injects this
  │  injected by embed.js                │
  └──────────────────┬───────────────────┘
                     │ WebSocket ws://127.0.0.1:9333
  ┌──────────────────▼───────────────────┐
  │  webmcp-local-relay (Node process)   │   ← npx @mcp-b/webmcp-local-relay
  └──────────────────┬───────────────────┘
                     │ stdio / JSON-RPC
  ┌──────────────────▼───────────────────┐
  │  Claude Desktop / Cursor / any client│
  └──────────────────────────────────────┘

Concretely, on every page load it:

  • injects a hidden <iframe>

from ablob:

URL, - opens a WebSocket tows://127.0.0.1:9333

from inside that iframe, - enumerates the page's tools: document.modelContext.listTools()

+callTool()

when present, falling back tonavigator.modelContextTesting.listTools()

+executeTool()

, - forwards them to the relay, which re-registers them as ordinary MCP tools over stdio, reconnects if the relay is not there, with exponential backoff from 500 ms to 3 s (1.5× multiplier), giving up after 100 attempts.

Its own attributes:

Attribute Default What it does
data-relay-port
9333
Port to connect to. Must match the relay's --port .
data-request-timeout
60000
Per-request ceiling in ms. Raise it if a tool chains slow API calls and might exceed a minute.

And on the relay process:

npx @mcp-b/webmcp-local-relay --port 9444 --widget-origin http://localhost:4321

--widget-origin

restricts which host page origins may register tools. The default is *

, meaning any page open in your browser that loads the embed can expose tools to your MCP client. That is convenient in development and worth tightening as soon as it is not.

If a second relay instance starts while the port is taken, it does not fail: it falls back to client mode and proxies through the existing one, so several MCP clients can share the same browser tabs. A "mode": "client"

in webmcp_list_sources

output is normal and not a symptom of anything.

Tag ② should not reach real visitors. For each of them it would inject a hidden iframe and attempt a WebSocket to 127.0.0.1:9333

, a port that, on their machine, is either nothing at all or something that is none of your business. With the retry policy above that is roughly five minutes of futile reconnection per page view, plus a page that visibly probes the visitor's own loopback interface.

Gate it on your build's development flag. In Astro:

{import.meta.env.DEV && (
  <script src="https://cdn.jsdelivr.net/npm/@mcp-b/webmcp-local-relay@4/dist/browser/embed.js"></script>
)}

Next.js:

{process.env.NODE_ENV === 'development' && (
  <script src="https://cdn.jsdelivr.net/npm/@mcp-b/webmcp-local-relay@4/dist/browser/embed.js" />
)}

Vite or plain HTML with a bundler: wrap it in import.meta.env.DEV

, or simply keep the tag in a local-only template.

Tag ①, @agenticschema/browser

, is designed to ship. It opens no connections, and on a browser with no WebMCP and no polyfill available it registers nothing and logs a warning rather than throwing.

For anything the attributes cannot express, import the package instead of using the script tag:

import { start } from '@agenticschema/browser';

const handle = await start({
  maxTools: 12,
  actions: 'off',
  allowedHosts: ['api.example.com'],
});

handle.tools();         // ToolDescriptor[]: what is currently registered
handle.diagnostics();   // Diagnostic[]: what the pipeline skipped, and why
await handle.refresh(); // remap now; a no-op if the markup has not changed
handle.stop();          // unregister everything and stop watching

start()

accepts every pipeline option plus four of its own:

Option Type Default What it does
document
Document
the page's own The document to read. Lets you map an iframe, or a linkedom /happy-dom document under test.
watch
boolean
true
Follow route changes and markup edits in single-page apps.
debounceMs
number
250
How long to wait after a DOM change before remapping.
modelContext
ModelContext
document.modelContext
The WebMCP surface to register on. Injectable for tests.
Member Returns Notes
tools()
readonly ToolDescriptor[]
What is registered right now.
diagnostics()
readonly Diagnostic[]
Why the rest is not: unparsable blocks, actions refused, fields truncated. Replaced on every remap, not appended.
refresh()
Promise<void>
Remaps immediately. Compares a fingerprint of the markup, so it does nothing when nothing changed.
stop()
void
Aborts every registration and detaches the watchers. tools() and diagnostics() both go empty.

WebMCP has no unregisterTool

, so the adapter registers every tool with an AbortSignal

and aborts the whole batch to replace it. A remap is triggered by:

  • a MutationObserver

onld+json

script blocks and on theitemscope

,itemprop

,itemtype

,typeof

andproperty

attributes, history.pushState

,history.replaceState

andpopstate

, because in a single-page app the route can change before the new markup arrives.

Both signals are debounced together by debounceMs

. The comparison is made against a fingerprint of the markup, not of the tool names: when only a price changes the names stay identical while the tool closures are already stale.

@agenticschema/core

has no MCP and no DOM assumptions, and zero runtime dependencies. It turns a document into tool descriptors and nothing else:

import { toTools } from '@agenticschema/core';

const { tools, diagnostics, graph } = toTools(documentOrHtmlString, options);

The five stages, and where each adapter picks the result up:

                    ┌──────────────── @agenticschema/core ─────────────────┐
 Document │ HTML    │                                                      │
 │ JSON-LD ───────► │  extract ──► normalize ──► select ──► map ──► guard  │ ──► ToolDescriptor[]
                    └──────────────────────────────────────────────────────┘
                                              │
                          ┌───────────────────┴───────────────────┐
                          ▼                                       ▼
             @agenticschema/browser                   @agenticschema/server
             document.modelContext                    stdio / fetch handler
             (script tag, WebMCP)                     (works with any MCP client today)
Stage Does
extract
Pulls out the raw structured-data blobs without interpreting them.
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 .
select
Decides which entities deserve a tool and which collapse together.
map
Applies a type profile to produce names, descriptions and JSON Schemas.
guard
Validates names, cleans descriptions, caps payloads.

One extraction detail that catches people out: if source

is an HTML string, only JSON-LD comes out. Microdata and RDFa need a real HTML parser. Pass a Document

, either the browser's own or one from linkedom

or happy-dom

on Node, to get all three formats.

Shared by toTools()

, start()

and createServer()

.

Extraction

Option Type Default What it does
formats
`Array<'jsonld' 'microdata' 'rdfa'>`

Normalisation

Option Type Default What it does
baseUrl
string
page URL in the browser Base for resolving relative @id values. Also the fallback source of pageOrigin .
maxDepth
number
12
Maximum nesting depth. The guard against circular references; exceeding it emits a depth-limit diagnostic.

Mapping

Option Type Default What it does
profiles
Profile[]
generic profile only The profile registry. @agenticschema/profiles supplies ~20 hand-written ones. Without it every entity falls back to generic naming.
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 .
maxTools
number
24
Ceiling on generated tools. Hitting it emits a tool-limit diagnostic.

In the browser adapter profiles

and ancestorsOf

load automatically, in their own chunk, after the adapter is already running. They weigh more than everything else combined, and a page that includes the script should pay as little as possible up front. If that chunk never arrives the adapter carries on with generic tool names and warns loudly, because tools with generic names look healthy from the outside.

Actions

Option Type Default What it does
actions
`'auto' 'off'` 'auto'
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.
allowedHosts
readonly string[]
[]
Extra hosts allowed beyond the page's own origin.
timeoutMs
number
10000
Ceiling on an action request. Without one, an endpoint that never answers leaves the agent waiting forever.
fetchImpl
typeof fetch
global fetch
Injectable for tests and for the server adapter.

Guard

Option Type Default What it does
maxDescriptionLength
number
320
Longest a tool description may be.
maxPayloadBytes
number
32000
Ceiling on the bytes a tool may return. Truncation emits field-truncated .

Custom tools

Option Type Default What it does
custom
readonly CustomTool[]
[]
Hand-declared tools. On a name clash these win over generated ones. See

toTools()

returns a diagnostics

array alongside the tools. Nothing throws for page-content problems: a malformed page produces fewer tools and a diagnostic, never an exception.

Code Level Means
json-parse-error
warn An ld+json block did not parse. The others are still processed.
unknown-context
warn A node's @context is not recognisable as Schema.org.
depth-limit
warn Nesting exceeded maxDepth ; the branch was cut.
no-structured-data
info The page carries none. Not an error.
action-skipped
info A potentialAction did not pass the rules, with the reason.
tool-limit
info maxTools was reached and the remainder dropped.
field-truncated
info A value was cut to fit maxPayloadBytes or maxDescriptionLength .
remap-failed
error The browser adapter could not rebuild the tools. The page stays remappable, and the next change retries.
no-webmcp-surface
warn No document.modelContext and the polyfill did not load, so nothing was registered.

The last two come from the browser adapter rather than the pipeline. They exist because an adapter with no way to say "I could not do my job" says nothing at all, which reads exactly like a page that simply had no markup.

The Node server prints everything above info

to stderr unless --quiet

is passed. In the browser, handle.diagnostics()

returns the same array.

@agenticschema/server

fetches pages itself and speaks full MCP, so it needs no browser and no transport decision. It is also the only adapter that can expose resources: every entity becomes a readable MCP resource as well as a tool.

npx @agenticschema/server <url> [<url>...] [options]
Flag Default What it does
--max-tools <n>
24
Cap on generated tools.
--no-actions
actions on Do not build executable tools from potentialAction .
--allow-host <host>
none Extra host allowed for actions. Repeatable.
--http
off Serve over HTTP instead of stdio. Binds 127.0.0.1 only.
--port <n>
3111
Port for --http .
--quiet
off Keep diagnostics off stderr.
-h , --help
n/a Print usage and exit.

Multiple URLs are merged into one server. Diagnostics and the tool list go to stderr, always. stdout belongs to the protocol.

Programmatic use:

import { createServer } from '@agenticschema/server';

const { server, tools, diagnostics } = await createServer(
  ['https://example.test/product'],
  { maxTools: 12, actions: 'off', allowedHosts: [] }
);

createHttpHandler()

returns the same mapping behind a fetch

, for a Worker or any runtime that speaks Request

/Response

:

import { createHttpHandler } from '@agenticschema/server';

const handler = await createHttpHandler(['https://example.test/product']);

export default { fetch: (request) => handler.fetch(request) };

The 2026-07-28 revision is stateless, so the SDK builds a fresh server per request. The pages are read once, when the handler is created. Refetching them per request would turn every tools/list

into a live hit on someone else's origin.

It performs no authentication and no host or origin checking. On a Worker that is the platform's job; the --http

CLI path applies the SDK's hostHeaderValidationResponse

and originValidationResponse

against loopback allowlists, because a local port is reachable from whatever page the browser happens to be on and DNS rebinding is a live risk there rather than a theoretical one.

Results of tools/list

, resources/list

and resources/read

carry the ttlMs

/ cacheScope

fields the 2026-07-28 revision requires. Without them the SDK emits the most pessimistic pair it can, ttlMs: 0

with cacheScope: 'private'

, which tells every client to refetch a listing that cannot have changed: pages are read once at startup and never refetched.

Result Default Why
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.
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.

cacheTtlMs

changes the lifetime; 0

restores the SDK default.

The Open Food Facts listing at the top of this file is one of two pages here. The other is Wikipedia, run through the pipeline exactly as it is published today:

en.wikipedia.org/wiki/Backpack
  read    get_article
  read    get_article_author
  read    get_article_publisher
  read    get_media

Nothing executable comes out of that page: all four tools are readers. Open Food Facts is the interesting case because search_web_site

is not. That tool exists because the page publishes a SearchAction

whose target sits on its own origin, which is the only shape that gets past the guard described below.

get_search_action

in the Open Food Facts listing is noise, a reader over the action's own definition, which is of no use to an agent. It is a known rough edge, left visible rather than trimmed out of the example.

Sites were picked for their licensing, not their fame. Wikipedia and Open Food Facts both publish under open licences and permit automated access; plenty of better-known sites forbid it in their terms, and pointing this tool at them is on you.

Those two examples are illustrations. The table below is a measurement, taken over 177 pages pulled from a Common Crawl snapshot — shops, news, recipes, reference, books — each one run through the pipeline in full. Tokens per page, averaged, counted with o200k_base

:

What the model reads Tokens vs raw HTML vs extracted text
Raw HTML, as served 143,771
Extracted text, what a competent scraper sends 2,752 52x
AgenticSchema tool output 1,440 100x
1.9x

The 100x is the number that looks good in a headline and it is the wrong one to quote. Nobody serious feeds raw HTML to a model: a scraper strips the markup first, and that one step accounts for 52 of the 100. Against a competent scraper the honest figure is 1.9x, and it is not uniform:

Vertical Pages Extracted text AgenticSchema vs text
reference 50 3,699 208 18x
news 25 882 528 1.7x
ecommerce 75 2,912 1,815 1.6x
recipe 25 1,549 3,787
0.4x

On recipes the library loses, at more than twice the cost of sending the text. A recipe's structured data is the recipe — every ingredient, every step, every timing, plus a nutrition block — so the tools re-emit in JSON what the page already said in prose, and JSON is the more expensive encoding. That is a gap in the design, not a bug, and nothing in the library currently notices it.

Two things the table does not say. It is a size measurement, not a quality one: it counts what an agent has to read, not whether it answers better, which tool it picks, or how many calls it takes. And the corpus is curated from sites that publish good Schema.org, so every number means "where the markup exists and is done well" rather than "the web".

Build it and check the numbers yourself:

npm run corpus:fetch    # from Common Crawl; no requests to the sites themselves
npm run corpus:report   # what the pages contain
npm run test:corpus     # what the pipeline makes of them

Method, type census, the three defects it caught, and the verticals it cannot reach at all are in docs/corpus.md. No page content and no page list is committed to this repository: only the seed recipe and the aggregate numbers.

The table above is a size measurement. The next question is whether an agent answers better with the tools than with the page's text.

An agent is asked five questions about a page, twice: once with the extracted text in the prompt and no tools, once with an MCP server built from the page's markup and no text. The same answers are then scored against two different keys, because one of them cannot answer the question. A key written from the rendered text alone puts a ceiling on the tools arm — its best possible result is repeating the prose — so a second key reads the text and what the page publishes as data, and counts a fact from either.

Over 120 trials on 12 pages with sonnet

, in one verified configuration:

Referee Text in the prompt AgenticSchema tools
Key written from the page text alone 95% 77%
Key written from text and published data 87% 87%
...over the 11 pages that publish something to map 85% 89%

The fair referee moves both arms — the tools arm up ten points, the text arm down eight, because facts carried only in the markup now count against whoever missed them. Set aside the pages that publish nothing at all, and the tools arm comes out four points ahead. It wins on recipes (+20) and on the two-part questions (+9, where prose scatters two facts across a page and a reader drops one of them); it loses on news, where one publisher credits the wire service in its byline and itself in its markup.

It is not free, and what it costs depends on the page. The tools arm takes 2.2 turns against 1.0, because fetching on demand costs a call and an answer. But the text arm's context is the page, so it grows with the page: on this sample the two read within 1% of each other and the tools arm cost a third less, while on a sample of longer pages the text arm read less. Turns are what one pays; page size is what the other pays.

It also depends on the model. Run the same cells with the arms on haiku

and the result reverses — the text arm barely moves while the tools arm drops six points, because reading a page is easier than calling a tool. This library helps an agent good enough to use what it is handed.

Reading every disagreement one at a time is what makes the number useful, and it found the same defect twice in mirror image — a ProductGroup

keeping its price in hasVariant[].offers

, and a variant keeping its rating in isVariantOf.aggregateRating

. Both sat one hop from where the vocabulary suggests, both were invisible from the code, and following the path took the price questions from 8 of 11 to 11 of 11.

Four points on 55 trials is two trials from a tie. Read it as "no longer behind, plausibly ahead" rather than as a headline — and note that a run four times the size said the same thing, in the same direction, before the library was fixed.

npm run bench:run -- --dry-run          # what it would cost, spending nothing
npm run bench:run                       # 100 cells, one sitting
npm run bench:run -- --arms tools --redo  # after a fix: re-run only what disagreed
npm run bench:report                    # the tables, and the disagreements to read by hand

The full method, the isolation the harness enforces, and what the measurement does not cover are in docs/bench.md.

A page cannot expose an MCP endpoint. Not "it's hard": a browser tab cannot listen on a port. In the browser the transport is document.modelContext

, provided by the browser itself. This library is the mapping layer, not a transport. Everything in Read this first follows from this one sentence.

WebMCP exposes tools only. No resources and no prompts, and the W3C explainer is explicit about it. So entities become read tools in the browser. The Node adapter, which speaks full MCP, exposes them as resources as well.

potentialAction

is rare in the wild. In practice it is almost only SearchAction

, and Google retired the Sitelinks Searchbox in November 2024, so adoption is falling. Auto-derivation alone would produce a read-only library. That is why defineTool()

is a first-class feature rather than an afterthought.

Read tools are always generated. Executable tools are not:

Condition Result
SearchAction , FindAction , ReadAction , ViewAction
eligible
httpMethod absent or GET
eligible
Destination same-origin (or explicitly allow-listed) eligible
Anything else (OrderAction , POST , cross-origin, non-http scheme)
skipped, with a diagnostic

The four eligible types are all idempotent. An OrderAction

or a ReserveAction

has consequences out in the world: generating those automatically would mean that dropping a script onto a site makes its products orderable by any agent that wanders past.

A skipped action is never silent. It produces an action-skipped

diagnostic naming the reason, so if you expected an action tool and did not get one, that diagnostic says why.

Anything with side effects goes through explicit opt-in instead.

custom

is the way in for everything auto-derivation cannot give you: actions with side effects, private endpoints, and anything potentialAction

does not describe.

import { start } from '@agenticschema/browser';

start({
  custom: [{
    name: 'check_stock',
    description: 'Check in-store availability for a postal code',
    inputSchema: {
      type: 'object',
      properties: { postalCode: { type: 'string' } },
      required: ['postalCode'],
      additionalProperties: false,
    },
    execute: async ({ postalCode }) => ({
      content: [{ type: 'text', text: await (await fetch(`/api/stock?cap=${postalCode}`)).text() }],
    }),
  }],
});
Field Required Default Notes
name
yes n/a Must match what the MCP spec allows; the guard rejects anything else.
description
yes n/a Capped at maxDescriptionLength .
inputSchema
no empty object schema Standard JSON Schema with additionalProperties: false .
execute
yes n/a Returns { content: [{ type: 'text', text }] } , optionally with isError .
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 .

Custom tools still pass through the guard: names are validated, descriptions cleaned, payloads capped. They win over a generated tool of the same name.

The library takes page content and puts it into a model's context. Two attack channels are closed in core

, so every adapter inherits them:

Prompt injection. Ald+json

block injected through UGC or a compromised CMS can carry instructions. Page text never enters a tool'snameordescription, only itsdata, and is stripped of HTML and control characters, with length caps. HTML tags go first, since they are the usual way to hide instructions from a human reader but not from a model.@type

is the exception, because a tool is named after it: it is taken only where it is shaped like a type (one word, letters and digits, 40 characters at most) and becomesThing

where it is not.Exfiltration viaurlTemplate

. A hostile action could point elsewhere and receive the parameters. Destinations are same-origin by default, https-only, RFC 6570 level 1 only, and re-validatedafter template expansion so a crafted value cannot move the target. Redirects are refused rather than followed, since a 3xx would land past both checks.

Plus a cap on tool count (default 24, read and action tools sharing the one budget) and on payload size, because agents degrade badly with large or bloated toolsets. Secondary entities of the same type collapse into a single tool: nine indistinguishable get_person

tools are useless to an agent; one list_person

is not.

Two things that are your decision, not the library's:

allowedHosts

and--allow-host

widen the exfiltration surface on purpose. Every host you add is a destination an action's expanded URL may reach. Add hosts you control.- The local relay's default --widget-origin

is*

. Any page in your browser that loads the embed can register tools with your MCP client. Restrict it once you are past first setup.

If you put this in front of an agent that can act on someone's behalf, read SECURITY.md first: it sets out what the threat model does and, more importantly, does not cover.

Symptom Cause Fix
Tools visible in DevTools → Application, but the MCP client shows 0 sources
Registration done, transport missing Add the relay embed tag. See

webmcp_list_sources

returns count: 0

embed.js

is in the page andthe relay process is runningdata-*

option has no effecttype="module"

on a self-hosted src

without agenticschema

in it, or version 0.1.2 or earliertype="module"

, or add data-agenticschema

. See How the adapter finds its own tag@0.1.2

or latercdn.jsdelivr.net

script-src

, or self-host dist/cdn/auto.js

SearchAction

POST

, non-http scheme, or no pageOrigin

action-skipped

diagnostic; set baseUrl

if running headlesslist_*

instead of several get_*

data-watch="off"

, or the markup is replaced in a way the observer missesdata-watch="off"

; call handle.refresh()

manuallymaxTools

reacheddata-max-tools

; check for the tool-limit

diagnosticmaxPayloadBytes

reachedfield-truncated

"mode": "client"

in relay outputHost response timeout

from the relaydata-request-timeout

on the embed tagdata-relay-port

must equal the relay's --port

Listed rather than hidden, because finding them yourself costs more than reading them here.

  • Tag identification is heuristic. document.currentScript

isnull

in module scripts, so the adapter looks fordata-agenticschema

or ansrc

containingagenticschema

. A self-hosted build under an unrelated filename and without the marker matches neither, and its options are ignored in silence. get_search_action

is a read tool over an action's own definition, of no use to an agent.- WebMCP has no unregisterTool

, so every remap aborts and re-registers the whole batch. - Actions need an origin. Headless use with neither baseUrl

norpageOrigin

silently produces no action tools.

Package Purpose
@agenticschema/core
The pipeline. No MCP, no DOM assumptions. Zero runtime dependencies.
@agenticschema/profiles
~20 hand-written type profiles + the Schema.org hierarchy.
@agenticschema/browser
WebMCP adapter. Script-tag build is one self-contained file, 27 KB gzip, polyfill included.
@agenticschema/server
MCP server over stdio, plus a fetch-shaped HTTP handler for Workers and other HTTP runtimes. Speaks the 2026-07-28 revision.

Third-party pieces this works with, both from the @mcp-b

project: @mcp-b/webmcp-polyfill

(a dependency of the browser adapter) and @mcp-b/webmcp-local-relay

(the optional transport).

schema-org-mcp

serves the Schema.orgvocabularyto an LLM (validate types, generate snippets). It does not look at real pages.wmcp.sh

is a hosted SaaS doing something adjacent server-side. This is an embeddable open-source library, client-side first.@mcp-b/*

provide the WebMCP transport and polyfill. This builds on them; it does not replace them.

The mapping layer from Schema.org to MCP is the part that did not exist.

npm install
npm test          # 156 tests over synthetic fixtures, in a couple of seconds
npm run typecheck
npm run build
npm run size      # fails if the script-tag build has an import a browser cannot

The corpus of real pages is a separate command, because half a megabyte of markup per page is minutes rather than seconds and npm test

has to stay quick enough to run on every save:

npm run corpus:fetch    # build it from Common Crawl into fixtures/local (untracked)
npm run corpus:report   # what the pages contain
npm run test:corpus     # the pipeline against all of them

test:corpus

reports sizes in bytes on its own. The token columns need a tokenizer, which is deliberately not a dependency of this repository: it weighs 55 MB installed, for one measurement in one optional suite, and cloning this project to fix a typo should not cost that. Ask for it when you want it, and the suite picks it up:

npm install --no-save gpt-tokenizer

npm run build:hierarchy -w @agenticschema/profiles

regenerates the Schema.org type hierarchy. npm run corpus:fetch:jsonld

is the older fetcher, which pulls JSON-LD from three live pages.

Contributions welcome. See CONTRIBUTING.md and CODE_OF_CONDUCT.md.

Early, pre-1.0, API not stable. WebMCP itself is still a proposal working its way through the W3C. Chrome has it behind a flag for local development and, since Chrome 149, in an origin trial, but no browser turns it on by default yet. That is why the polyfill is a hard dependency of the browser adapter rather than an optional one.

Provided as is, with no warranty of any kind, express or implied. Use at your own risk. The author accepts no liability for any damage, data loss, security incident, or other consequence arising from use of this software. See the MIT licence for the binding terms. If you put this in front of an agent that can act on someone's behalf, read SECURITY.md first: it sets out what the threat model does and, more importantly, does not cover.

MIT.

── more in #ai-tools 4 stories · sorted by recency
── more on @agenticschema 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/show-hn-agenticschem…] indexed:0 read:37min 2026-08-17 ·