Add the WebMCPify agent skill 🤖🤖🤖 (#2400)

* feat(skills): add webmcpify skill

* style(skills): quote webmcpify description
This commit is contained in:
Jonas Tüchler
2026-07-23 19:50:25 +02:00
committed by GitHub
parent 44f3f0d06a
commit 03fb5fc96e
13 changed files with 1930 additions and 0 deletions
+331
View File
@@ -0,0 +1,331 @@
---
name: webmcpify
description: 'Make a web app agent-ready — propose a WebMCP tool manifest, integrate, verify in a real browser, heal; unrelated code stays untouched. Use for "webmcpify", "add WebMCP", or "expose app actions to AI agents".'
argument-hint: "[inventory|integrate|verify|status|full] [scope notes]"
license: MIT
metadata:
source: https://github.com/TueJon/webmcpify
---
# webmcpify — make any web app agent-ready, verifiably
You are running the webmcpify pipeline. It takes an existing web application and
exposes its user-facing functionality as [WebMCP](https://webmachinelearning.github.io/webmcp/)
tools (`document.modelContext` — a proposed web standard incubated in the W3C Web
Machine Learning Community Group, currently a Chrome origin trial), so browser AI
agents can operate the app through structured tool calls instead of guessing at the DOM.
```
DETECT ──▶ INVENTORY ──▶ [HUMAN GATE: manifest approval] ──▶ INTEGRATE ──▶ VERIFY ──▶ HEAL ──▶ AUDIT
▲ loop per-area batches on big apps ▲ loop ▲ loop ▲ loop
└── per area └── per manifest entry ──┘
```
Everything you need ships inside this skill directory: phase guides in
`references/`, and vendorable code in `templates/` (runtime, ambient types,
JS variant, React JSX typings, verification spec). Never assume files exist
outside the skill dir.
**Out of scope** (stop and say so): backend-only MCP servers (that's classic MCP,
not WebMCP), automating third-party sites you don't control, and generic SEO work.
## Invocation modes
The user may pass an argument (`/webmcpify <mode>` or plain words):
| Argument | Run | Stop at |
|---|---|---|
| *(none)* or `full` | all phases, resuming from current manifest state | done |
| `inventory` / `map` | DETECT + INVENTORY loops only — **zero code changes** | present the manifest table for review |
| `integrate` | INTEGRATE loop only (requires approved tools in the manifest) | integrated + built |
| `verify` | VERIFY + HEAL loops on integrated/verified tools | green/skipped report |
| `status` | read `.webmcpify/manifest.json`**read-only** | report phase, per-status tool counts, and the recommended next command |
Any other text is scoping guidance (e.g. "only the checkout area", "read-only tools only").
## Ground rules (non-negotiable, enforce in every phase)
1. **Zero unrelated changes.** Every diff hunk you produce must trace to a manifest
entry or the recorded one-time setup. Never refactor, reformat, rename, or
"improve" anything else — note problems in the report instead. Files that were
already dirty at baseline (recorded in the manifest) are **untouchable**: never
modify or revert them.
2. **Read-only tools first.** Mutations are tri-state: `mutating: false`,
`"client"` (browser-local only: prefs, localStorage), or `"server"` (data
leaves the browser). Server-mutating tools require explicit **per-tool** human
approval recorded in the manifest; client-mutating tools may be approved as a
batch at the gate. Never expose destructive, irreversible, or payment actions
in a first integration.
3. **The server is the only trust boundary.** A tool's `execute()` may only call code
paths the UI already uses (same endpoints, same validation, same auth). Never
create new endpoints, never bypass existing checks, never put secrets in tools.
4. **Spec-shaped and dependency-free.** Register via `document.modelContext.registerTool()`
with AbortSignal lifecycle (feature-detect the deprecated `navigator.modelContext`
fallback). No third-party WebMCP runtime dependencies. Everything feature-detected:
the app behaves identically in browsers without WebMCP.
5. **Never `toolautosubmit` on state-changing forms** — neither `mutating: "client"`
nor `"server"`. Only on pure read forms (search, filter, availability).
6. **State lives in files, not in your context.** Read/write `.webmcpify/` constantly;
assume your context can be wiped between any two steps. Write the manifest
atomically (write `manifest.json.tmp`, then rename over `manifest.json`).
7. **Commits are opt-in.** Never commit unless the human chose a commit policy at
the gate (see below). Without git or without permission, leave changes in the
working tree and record progress in the manifest only.
## Fresh, authoritative guidance
WebMCP is an evolving origin-trial API — the surface has already changed during the
trial (testing API removed 2026-07; `navigator``document`). Before Phase 2, if
network is available, pull Google's current official guides rather than relying on
memory:
```sh
npx -y modern-web-guidance@latest retrieve "webmcp,agentic-forms,agentic-javascript-tools"
```
If offline, use `references/integrate.md` — but prefer the live guides when they conflict.
## The state protocol — `.webmcpify/` in the target repo
| File | Purpose |
|---|---|
| `manifest.json` | Single source of truth (schema below; atomic writes) |
| `areas/<id>.tools.json` | Sub-agent shard output during inventory fan-out (merged, then deleted) |
| `report.md` | Human-facing running report; finalized at the end |
**Resume rule:** if `manifest.json` exists, resume — recompute nothing already
recorded. **Merge leftover shards FIRST**: any existing `areas/<id>.tools.json`
files are merged into the manifest (mark those areas `inventoried`, delete the
shards) before redispatching any sub-agents. Then continue at `pipeline.phase`,
the first `pending` area, or the first tool whose status is not terminal.
Terminal statuses: `verified`, `skipped`, `rejected`.
**Phase transitions** (make the atomic manifest write the moment the condition holds):
- `detect → inventory`: `app` recorded, `baselineSha`/`baselineDirty` captured.
- `inventory → gate`: no area `pending`, completeness pass has run.
- `gate → integrate`: every `discovered` tool is `approved`/`rejected`, and
`commitPolicy` + `commitWebmcpifyDir` are set.
- `integrate → verify`: no `approved` tools remain (each `integrated` or terminal),
build green.
- `verify → heal`: verify loop visited every `integrated` tool and ≥1 is `failed`
(none failed → straight to `audit`).
- `heal → audit`: no tool `failed` and post-heal full re-verify passed.
- `audit → done`: every hunk mapped-or-flagged, `report.md` finalized.
Manifest schema (Webmcpify Manifest v3):
```jsonc
{
"webmcpify": 3,
"app": { "stack": "react-vite", "typescript": true, "entry": "src/main.tsx",
"baseUrl": "http://localhost:5173", "startCommand": "npm run dev",
"authFixtures": { // how verify OBTAINS each session
"member": { "obtain": "npm run seed:test-user, then sign in at /login",
"account": "member@example.test",
"env": ["TEST_MEMBER_PASSWORD"] } // env var NAMES only — never secret values
} },
"pipeline": {
"phase": "inventory", // detect|inventory|gate|integrate|verify|heal|audit|done — transition rules above
"setup": { // PATHS created/modified per one-time setup step ([] = not done yet)
"runtimeVendored": ["src/webmcp/webmcpify.ts", "src/webmcp/webmcp.d.ts"],
"harnessInstalled": [".webmcpify/webmcp.spec.ts"],
"originTrialNoted": ["README.md"]
},
"baselineSha": "abc1234", // HEAD at pipeline start; null if no git
"baselineDirty": ["src/wip.ts"], // paths dirty at start — untouchable (ground rule 1)
"commitPolicy": null, // set at the gate: "commit-per-batch" | "no-commit"
"commitWebmcpifyDir": null, // set at the gate: commit .webmcpify/ itself? true | false
"blockers": [] // e.g. "app won't start locally: needs $API_KEY" — surfaced at the gate
},
"areas": [
{ "id": "checkout", "paths": ["src/features/checkout/"], "status": "pending" } // pending|inventoried
],
"tools": [
{
"id": "create_ticket",
"area": "tickets",
"kind": "imperative", // imperative | declarative
"mutating": "server", // false | "client" (browser-local only: prefs, localStorage) | "server" (data leaves the browser)
"priority": 1, // 1 = expose first; 2/3 = later waves
"description": "Creates a new ticket in the currently open project.",
"inputSchema": { /* JSON Schema */ },
"annotations": { "readOnlyHint": false, "untrustedContentHint": false }, // verify asserts these on the enumerated tool
"source": ["src/features/tickets/NewTicket.tsx:42"], // the UI code path it wraps
"route": "/projects/demo/tickets", // where verify navigates
"auth": ["role:member"], // "none" | "session" | ["role:<name>", ...] — keys into app.authFixtures; verify runs once per listed role
"examples": { "valid": { "title": "Test ticket" }, "invalid": {} },
// invalid: null ONLY for readOnlyHint tools with no/empty params —
// verify then asserts dual-outcome: rejects OR resolves with no side effect
"expect": { "result": "created", "navigation": null, "ui": "new row appears in the ticket list" },
// exactly one of result|navigation: result = substring of the resolved string;
// navigation = destination URL/pattern when executeTool resolves null (it navigated)
"cleanup": "delete the created ticket via the UI's own delete path (test data only)", // required for mutating:"server", recommended for "client"
"status": "discovered", // discovered|approved|rejected*|integrated|verified*|failed|skipped* (* = terminal)
"approval": null, // server-mutating tools, once approved: { "note": "...", "at": "2026-07-12",
// "productionSideEffect": null } — set only when verification unavoidably
// causes a real production effect (see VERIFY: production side-effect policy)
"attempts": 0, // heal-fix cycles; the triggering verify failure is attempt 0
"batchCommit": null, // sha under commit-per-batch — lands in the manifest one commit LATER
"notes": ""
}
],
"log": [ "2026-07-12 inventory: area checkout done, 4 candidates" ]
}
```
**v2→v3 migration:** resuming a `"webmcpify": 2` manifest migrates in place on
first write — `auth` string → array; `setup` booleans → path arrays (`false`
`[]`; `true` → recover paths from git/`log`, else `null` = done-but-unrecorded,
audit treats those files flag-only); `mutating: true``"server"`; add
`annotations` (defaults from the inventory table), `blockers: []`,
`commitWebmcpifyDir: null`, `expect.navigation: null`; then bump to 3.
## Phase 0 — DETECT
Identify stack, build + dev-server commands, TypeScript or not, auth model
(including how verify obtains each test session → `app.authFixtures`), test
setup, and how the app starts locally; record under `app`. Record the git baseline:
`pipeline.baselineSha` = current HEAD and `pipeline.baselineDirty` = `git status
--porcelain` paths (both `null`/`[]` without git). If the app cannot be started
locally, append the blocker to `pipeline.blockers` — integration may proceed, but
verification will be blocked and this must be surfaced at the gate. Details:
`references/inventory.md`.
## Phase 1 — INVENTORY (loop; scales to any size)
**Never map a large codebase in one pass.**
1. **Area map first (cheap, structural):** enumerate routes/views/feature modules
from the router config, pages directory, or navigation — without reading
implementation files. Write every area to `areas` with `"pending"`.
2. **Inventory loop — one area per iteration:** deep-read only that area's files;
draft a candidate tool per user action (conventions, tool-count budget, and
overlap rules: `references/inventory.md`) with ALL manifest fields filled,
including `route`, `auth`, `annotations`, `examples`, `expect`, and `cleanup`
(required for `mutating: "server"`, recommended for `"client"`) — the verify
phase runs from these fields alone. Append as `"discovered"`, mark the area
`"inventoried"`, write the manifest, repeat.
- **Sub-agent fan-out:** sub-agents never write `manifest.json`. Each writes only
its own `areas/<id>.tools.json` shard — schema
`{ "webmcpifyShard": 3, "area": "<id>", "tools": [ /* full v3 tool entries */ ] }`,
written atomically (tmp + rename). You (the coordinator) merge shards into
the manifest sequentially, then delete them; on resume, merge existing
shards FIRST before redispatching (Resume rule).
3. **Exit:** no `pending` areas remain, plus one completeness pass — walk the app's
navigation and ask "is any visible user action missing?"
## GATE — manifest approval (the one main checkpoint)
Present the manifest compactly (id, area, kind, mutating, priority, one-line
description) — per-area batches on large apps. Ask the human to decide, in one
exchange where possible:
1. Which tools are `approved` vs `rejected` (**`rejected` is terminal** — rejected
tools are excluded from every later phase and from exit conditions).
`mutating: "server"` tools need individual acknowledgment → record in
`approval`; `mutating: "client"` tools may be approved as a batch.
2. **Commit policy**: `commit-per-batch` (each integration batch committed,
revertable — recommended on a clean baseline) or `no-commit` (leave changes
uncommitted for the human to review/commit) → `pipeline.commitPolicy`. Also
whether `.webmcpify/` itself should be committed (recommended: yes — it
documents the integration) → `pipeline.commitWebmcpifyDir`.
3. Every entry in `pipeline.blockers` (e.g. app won't start). If verifying a tool
will unavoidably cause a real production side effect (e.g. a mailer with an
Origin-allow-listed endpoint), get that approved HERE and record it in the
tool's `approval.productionSideEffect` — see VERIFY.
Apply `references/security.md` to every mutating tool **before** presenting.
## Phase 2 — INTEGRATE (loop)
One-time setup first — record the created/modified file **paths** in
`pipeline.setup` (e.g. `runtimeVendored: ["src/webmcp/webmcpify.ts", ...]`):
vendor the runtime from this skill's `templates/` (`webmcpify.ts`, or
`webmcpify.js` for non-TS projects, plus `webmcp.d.ts` for TS and
`webmcp-jsx.d.ts` for React TSX — keep the full MIT header; see
`references/runtime.md`) and note the origin-trial/flag requirement in the target
README (`originTrialNoted`). Then loop:
1. Pick the next batch of `approved` tools — one area or ≤5 tools.
2. Implement per `references/integrate.md`: declarative attributes for standard
HTML forms (including framework-rendered and fetch-intercepted ones);
imperative registration via the vendored runtime for non-form or
controlled-state actions.
3. Build + typecheck; fix only what the batch broke.
4. Mark tools `"integrated"`, write the manifest. Under `commit-per-batch`:
require a **clean index** before staging (unrelated staged changes → stop and
surface); stage **only the batch's files by path** — never `git add -A`, `-u`,
`.`, or `commit -a`; commit `feat(webmcp): expose <ids> (webmcpify)`. The
commit sha lands in `batchCommit` on the **next** manifest write — one commit
later (the manifest can't contain its own commit's sha). Never amend a
previous batch commit.
5. Repeat until no `approved` tools remain.
## Phase 3 — VERIFY (loop)
Set up once from `templates/webmcp.spec.ts` per `references/verify.md` (real headed
Chrome; production `getTools()`/`executeTool()` surface with legacy fallback probe).
Then loop over every `integrated` tool, using its manifest `route`, `auth`,
`examples`, `expect`, and `annotations` fields:
- assert the tool is registered with the expected schema (enumerated `inputSchema`
is a *stringified* JSON Schema — parse before comparing) **and** the manifest
`annotations`;
- execute the valid example (mutating tools: dev/test data only, then run
`cleanup`) and one invalid example (`invalid: null` zero-param read tools:
dual-outcome assertion — see `references/verify.md`);
- assert on the returned result **and** the resulting UI state per `expect`
(a UI **delta**, or `expect.navigation` when execution resolves `null`).
Pass → `"verified"`. Fail → `"failed"` + failure note. Role-scoped tools: run the
loop once per role listed in `auth`, signing in via the matching
`app.authFixtures` entry.
**Production side-effect policy** — when a tool's verification unavoidably causes
a real production effect (e.g. an email actually sent), ALL THREE are required:
(1) the human approved it at the gate, recorded in `approval.productionSideEffect`;
(2) every test payload is marked `[webmcpify verification]`; (3) the effect is
listed in `report.md`. Without the recorded approval, don't execute the live
path — mark the tool `skipped` with a blocker note.
## Phase 4 — HEAL (loop)
While any tool is `"failed"`: diagnose via `references/heal.md`, fix **only** that
tool's integration — **implementation-only** fixes; if the fix would change the
approved contract (schema, description, `mutating` class, `annotations`,
`expect`), go back to the gate for re-approval instead of silently changing the
manifest. The triggering verify failure is attempt 0; increment `attempts` per
fix cycle and re-verify. At `attempts` = 3 → `"skipped"` with a clear blocker
note (an explicit escalation to the human, not a silent drop). Never widen the
diff or fake a pass. After healing, re-run verification once for **all** tools
with status `integrated` or `verified` (healing one tool can break another —
scope collisions).
**Exit:** every tool is `verified`, `skipped`, or `rejected`; build green.
## Final — AUDIT + report
1. **Diff audit (flag-only, never auto-revert):** collect the pipeline's changes —
`git diff <baselineSha>..HEAD` **plus the index and untracked files** under
`commit-per-batch`, or the working tree + index + untracked under `no-commit`.
Every hunk must map to a manifest entry or a recorded `pipeline.setup` path.
An unmapped hunk → **flag it in the report** with file/line and a suggested
disposition; never revert anything yourself. A hunk in a `baselineDirty` file
→ untouchable, flag only. Without a `baselineSha`, audit the files named in
manifest `source` fields and `pipeline.setup` paths (setup entries recorded as
`null` by the v2→v3 migration: fall back to flag-only for those files).
2. Finalize `.webmcpify/report.md`: tool coverage per area, skipped/rejected tools
with reasons, security notes (which mutating tools exist, what guards them,
any recorded production side effects), how to test manually (flag, DevTools
WebMCP pane, inspector extension), and every blocker that needs a human.
3. Tell the human: what's exposed, what's skipped and why, and how to try it.
## References (read on demand, not upfront)
- `references/inventory.md` — area mapping, naming/schema conventions, budgets/overlap
- `references/integrate.md` — declarative + imperative patterns per stack
- `references/runtime.md` — vendoring + wiring the `templates/` runtime
- `references/verify.md` — harness setup: flags, surfaces, Playwright/Puppeteer, evals
- `references/heal.md` — failure taxonomy → fixes
- `references/security.md` — the security checklist (apply before the gate and at audit)
+69
View File
@@ -0,0 +1,69 @@
# Heal — failure taxonomy → fixes
Work one failed tool at a time. Re-verify after each fix. The triggering verify
failure counts as attempt 0; each fix cycle increments `attempts`. At `attempts`
= 3 → mark `skipped` with a blocker note (this is an explicit escalation to the
human in the final report, not a silent drop) and move on. **Never** widen the
diff, disable a check, or fake a return value to force a pass. **Mutating
tools:** run the manifest `cleanup` between attempts — retrying a mutation
without cleanup duplicates data.
**Heal fixes implementations, not contracts.** The manifest is the
human-approved contract: if the correct fix would change a tool's `inputSchema`,
`description`, `mutating` class, `annotations`, or `expect`, take it back to the
gate as a mini re-approval — never silently edit the manifest to match the code.
## Taxonomy
| Symptom | Likely cause | Fix |
|---|---|---|
| Tool absent from enumeration | **Registration is async** — the test asserted before `registerTool()` settled; or registration never ran (bootstrap not reached, view not mounted) or wrong Chrome build/flags | FIRST make the test poll (`waitForTool`) or await `toolchange` — only if it still fails, trace the registration call; confirm `isWebMCPAvailable()` in the test env; current Chrome + `--enable-features=WebMCP,WebMCPTesting` |
| Whole scope absent | A registration in the batch rejected (duplicate name, invalid schema, policy) — the runtime rolls back the entire scope | Check console for the `onError` report; fix the offending tool contract |
| Tool absent after route change | Scope disposed by navigation (over-scoping) | Move to static app-level registration unless genuinely view-bound |
| Declarative tool missing | `toolname` typo, frame without `allow="tools"`, or page sends `Origin-Agent-Cluster: ?0` | Fix attribute; check Permissions-Policy `tools` and origin-keying headers |
| Schema mismatch (declarative) | Control lacks `name`, description not resolvable, unsupported control type in this build | Add `name`/`toolparamdescription`/`label[for]`; unsupported controls → switch that form to imperative |
| Schema mismatch (imperative) | Manifest and code drifted | Make code match the approved manifest; if the manifest was wrong, that's a contract change — take it back to the gate for re-approval (see above), never silently update it |
| Assertion compares object to string | Enumerated `inputSchema` is a stringified JSON Schema | `JSON.parse` before comparing (see `verify.md`) |
| `executeTool` returns `null` unexpectedly | The execution navigated (normal for submit-navigating declarative forms) | Assert on the post-navigation page instead of the return value |
| `executeTool` rejects | Schema violation or declarative-validation failure — rejection IS the failure signal for these | For invalid-input tests on declarative tools, assert rejection, not an `"ERROR:"` string |
| Mutating declarative execution hangs until timeout | Chrome fills the form, then **pauses the execution awaiting a real submit interaction** — awaiting `executeTool` alone deadlocks | Use the concurrent pattern in the spec template: start `executeTool` unawaited → wait for the agent-filled value → click submit → await. **NEVER heal by adding `toolautosubmit`** (ground rule 5) |
| Backend rejects the harness with 403/CORS despite correct auth | The endpoint **allow-lists the production `Origin`** (mailers, form gateways) — the localhost harness origin is refused before the tool logic runs, and no local fix exists | Verify the live path with the env-gated server-side replay (§Origin-allow-listed endpoints below), only with the production side-effect approval recorded in `approval.productionSideEffect` (see §Origin-allow-listed endpoints below); without it, mark the live path `skipped` with a blocker note |
| Execution times out / canned success while UI still loading | Completion event fired before the async work finished, or listener missing/wrong event name | Fire `tool-completion-<requestId>` with `{ ok, message/error }` AFTER awaiting the real work (`runtime.md` contract) |
| Returns success but UI unchanged | `execute()` bypassed the real UI path (parallel implementation) | Rewrite to call the same handler/store action/endpoint the UI uses |
| Invalid input resolves successfully (imperative) | Missing in-code validation | Validate strictly in code; return `"ERROR: <what/how to fix>"` |
| Fetch-submitted form: agent gets nothing | `preventDefault()` without `respondWith()` | Add the `e.agentInvoked → e.respondWith(promise)` bridge |
| Works manually, fails in Playwright | Headless, missing flags, or profile without the flag | Headed + flags; persistent context; `xvfb-run` in CI |
| 401/403 from `execute()` in test | Tool registered outside the authenticated scope, or test session lacks the role in the manifest `auth` field | Role-scope the registration; sign in with the recorded fixture |
| Flaky: passes alone, fails in suite | Shared state between tool executions | Isolate test data per tool run (use `cleanup`); don't reorder tests to hide it |
## Origin-allow-listed endpoints — the replay pattern
Some production backends (mailers, form gateways) allow-list the production
`Origin` header and refuse everything else — the localhost harness can never
exercise the live path directly. When (and only when) the gate approved the real
production side effect (`approval.productionSideEffect`), verify the live path
with an env-gated replay: intercept the app's own request in Playwright and
re-issue it server-side (Node context — not subject to browser CORS) with the
production `Origin`:
```ts
// Env-gated: runs only with WEBMCP_LIVE_MUTATIONS=1 — never default-on in CI.
if (process.env.WEBMCP_LIVE_MUTATIONS === '1') {
await page.route('**/api/contact', async (route) => {
const response = await context.request.fetch(route.request(), {
headers: { ...route.request().headers(), origin: 'https://example.com' }, // the prod Origin
});
await route.fulfill({ response });
});
}
```
This causes a REAL production side effect. Mark every payload
`[webmcpify verification]`, run the manifest `cleanup`, list the effect in
`report.md`, and never enable the gate by default in CI.
## After healing
Re-run verification once for **all** tools with status `integrated` or `verified`
(not only the healed ones) — healing one tool can unregister or break another;
scope collisions are the classic case. Only then evaluate the exit condition.
+141
View File
@@ -0,0 +1,141 @@
# Integrate — patterns per stack
> Prefer the live official guides when online:
> `npx -y modern-web-guidance@latest retrieve "webmcp,agentic-forms,agentic-javascript-tools"`.
> The patterns below follow Google's reference implementations
> (GoogleChromeLabs/webmcp-tools) and the W3C CG draft.
## Declarative — standard HTML forms
Applies to plain HTML, SSG-emitted, server-rendered, and framework-rendered
(uncontrolled) forms — anywhere a real `<form>` with named controls exists.
Annotate the existing form; do not restructure it.
```html
<form toolname="request_quote"
tooldescription="Requests a project quote. A team member replies within one business day."
action="/contact" method="post">
<label for="email">Email</label>
<input type="email" id="email" name="email" required
toolparamdescription="Email address for the reply">
<!-- …existing fields, each with label[for] + name + toolparamdescription… -->
<button type="submit">Request quote</button>
</form>
```
Rules:
- The browser derives the JSON Schema from the controls — every control needs
`name`, a resolvable description (`toolparamdescription``label[for]` text →
`aria-description`), and correct HTML constraints. Radio groups: description on
the enclosing `<fieldset>`.
- `toolautosubmit` **only** on pure read forms (search/filter/availability).
Never on contact/checkout/settings/messaging forms.
- Fetch-submitted forms (`preventDefault()`) MUST route the result back to the
agent — the most common integration bug is a swallowed submit:
```js
form.addEventListener('submit', (e) => {
e.preventDefault();
const result = doSubmit(new FormData(e.target))
.then(() => 'Request received. Reply within one business day.');
if (e.agentInvoked) e.respondWith(result); // pass the PROMISE, not a value
});
```
- Optional UX (verbatim from Chrome docs): style agent activity with
`form:tool-form-active` / `:tool-submit-active` CSS pseudo-classes.
- Forms that navigate to a thank-you page: `executeTool` returns `null` on
navigation (expected). A JSON-LD `{"@type":"Message","text":"…"}` block on the
target page is best-effort garnish — the mechanism is still under spec debate;
never make behavior depend on it.
### Framework notes — React
- Vendor `templates/webmcp-jsx.d.ts` alongside the ambient types so strict TSX
accepts `toolname`/`tooldescription`/`toolparamdescription` (it augments the
React attribute interfaces; it is a MODULE file — keep it separate from
`webmcp.d.ts`).
- The typings are string-valued (boolean-attribute style): write
`toolautosubmit=""` — and only on pure read forms (ground rule 5).
- In `onSubmit`, the WebMCP fields live on the NATIVE event:
```tsx
const native = e.nativeEvent as SubmitEvent;
if (native.agentInvoked) native.respondWith?.(doSubmit(new FormData(e.currentTarget))
.then(() => 'Request received. Reply within one business day.'));
```
Pass the PROMISE of the result string, not an already-resolved value.
## Imperative — SPAs and dynamic apps
Use the vendored runtime (`runtime.md`). Tools live in a dedicated module per app
(e.g. `src/webmcp/tools.ts`), decoupled from components:
```ts
import { createToolScope, dispatchAndWait } from './webmcpify';
export const searchTicketsTool = {
name: 'search_tickets',
description: 'Searches tickets in the currently open project and shows results on screen.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search terms, exactly as the user phrased them.' },
},
required: ['query'],
},
annotations: { readOnlyHint: true, untrustedContentHint: true },
async execute(input: Record<string, unknown>) {
const q = String(input.query ?? '').trim();
if (!q) return 'ERROR: `query` must be a non-empty string.';
return dispatchAndWait('webmcp:search_tickets', { query: q });
},
};
```
Key rules:
- **`execute()` wraps the existing UI code path** — dispatch the same event / call
the same store action / hit the same API the button does. Never a parallel
implementation.
- **Return only after the interface state is settled**: the component listener
awaits the real work, then fires the completion event with the outcome payload
(`{ ok, message | error }`) — full contract and component example in
`runtime.md`. A canned success before the work finishes is a false green.
- Return short strings; errors as `"ERROR: <what and how to fix>"` so the model can
self-correct. Cap outputs ~1.5k chars.
- Validate strictly in code, loosely in schema — and keep **parity with the
form's native HTML constraints**: when a tool wraps a form, probe the real
constraints on a detached clone instead of re-implementing them —
`const probe = emailInput.cloneNode() as HTMLInputElement; probe.value = value;`
then reject when `!probe.checkValidity()`. `execute()` must refuse exactly what
the form itself would refuse.
### Registration & lifecycle
- **Static registration is the default**: register app-wide tools once at bootstrap.
- **Per-view registration only** for tools meaningless outside their view — via
`createToolScope` in the view's mount/unmount (React `useEffect` cleanup, Vue
`onUnmounted`, Angular `DestroyRef`). Over-scoping makes the toolset flicker and
strands agents mid-plan.
- Registration failures roll back the scope and surface via `onError` — check the
console during integration; a silently missing toolset usually means a duplicate
name or invalid schema rejected the batch.
### Auth / roles (SaaS)
Never register a tool the current session couldn't use through the UI. On
login/logout/role change/tenant switch: dispose the scope and re-register the
correct set (`runtime.md` §Wiring). The server still re-checks everything (ground
rule 3) — role-scoped registration is UX hygiene, not security.
## Origin trial / flags note
WebMCP is a Chrome origin trial (149→, stable milestone still an estimate). For
production exposure the origin needs a token:
`<meta http-equiv="origin-trial" content="TOKEN">` or an `Origin-Trial` response
header — registered at the Chrome Origin Trials console. For local work,
`chrome://flags/#enable-webmcp-testing`. Chrome **silently ignores** expired
tokens, so nothing may depend on WebMCP being present (ground rule 4). Add a short
note about this to the target repo's README as part of setup, and record the
touched file path in `pipeline.setup.originTrialNoted` (e.g. `["README.md"]`).
+120
View File
@@ -0,0 +1,120 @@
# Inventory — mapping a codebase into a tool manifest
## Detect (Phase 0 details)
Establish, in this order:
1. **Stack**: `package.json` deps (react/vue/@angular/next/astro/eleventy…) or the
absence of one (static HTML). Record `app.stack` and `app.typescript`.
2. **Start command + base URL**: `dev`/`start` scripts, framework defaults
(`vite` → 5173, `next` → 3000, static → any file server). Verification needs a
working local run — if the app can't be started, append the blocker to
`pipeline.blockers` and surface it at the gate; don't silently proceed to a
verify phase that cannot run.
3. **Auth model**: none / session / role-based — plus **how a test session signs
in**, recorded per role under `app.authFixtures`: `obtain` (the exact steps —
seed command, login route), `account`, and `env` (the env var **names** the
fixture needs — never secret values in the manifest). The verify phase runs
from this. Role-based apps need role-scoped registration (`integrate.md`
§Auth) and a per-role verify pass.
4. **Git baseline**: `pipeline.baselineSha` = HEAD, `pipeline.baselineDirty` =
`git status --porcelain` paths. Dirty files are untouchable for the whole run.
## Building the area map
The area map is the unit of loop iteration. Sources, in order of preference:
router config (React Router, Next `app/`/`pages/`, Vue Router, Angular routes) →
navigation UI (static/SSG) → feature folders (`src/features/*`). Keep areas
coarse: 530 for a big SaaS, 13 for a landing page. Split an area that turns out
too big; merge trivial ones.
## What counts as a candidate tool
Walk each area's UI code and list **user actions**, not functions:
| UI pattern | Candidate tool | `mutating` | `readOnlyHint` |
|---|---|---|---|
| Search/filter form or input | `search_<noun>` | false | true |
| Data list/detail currently rendered | `list_<noun>` / `get_<noun>` | false | true |
| Create/edit form with submit → API call | `create_<noun>` / `update_<noun>` | "server" | — |
| Button triggering a server state change | `<verb>_<noun>` | "server" | — |
| Preference/theme/localStorage toggle | `<verb>_<noun>` | "client" | — |
| Multi-step flow (wizard, checkout) | `start_<noun>_flow` (initiation) | false* | **never** |
| Contact/booking form (static sites) | declarative form annotation | "server" | — |
*Initiation tools only navigate/open the flow — the human completes it. They are
classified non-mutating (no data changes) **but must NOT carry `readOnlyHint`**:
they change UI state, and agents skip confirmations for hinted-read-only tools.
`readOnlyHint: true` is reserved for genuinely pure data reads.
`mutating` is tri-state: `false` | `"client"` (browser-local only: prefs, theme,
localStorage — nothing leaves the browser) | `"server"` (data leaves the browser).
`"server"` gets the full ceremony — per-tool approval, required `cleanup`,
dev/test-data-only verification; `"client"` may be batch-approved at the gate
(`cleanup` recommended). `toolautosubmit` is banned for **both** mutation classes
(ground rule 5).
**Skip** (do not inventory): login/logout/auth flows, payment execution, account
deletion, user management, anything irreversible, file uploads (v1), and pure
navigation agents can do anyway.
## Tool budget, overlap, and priority (what keeps SaaS toolsets usable)
Agents degrade when many similar tools compete. Enforce while drafting:
- **Budget**: aim for ≤15 tools active in any app state (app-wide + current view).
If an area yields more candidates, keep the highest-value ones as `priority: 1`
and mark the rest `priority: 2/3` — the gate decides which waves ship.
- **Overlap rule**: no two tools whose descriptions could plausibly match the same
user request. Merge them (one tool, richer schema) or sharpen both descriptions
until they are disjoint.
- **Role/tenant coverage**: for role-scoped apps, note per tool which roles can use
it (`auth: ["role:<name>", ...]`); the toolset a given session sees must stay
within budget too.
## Naming and schema conventions (Google's, condensed)
- **Verb-first, execution vs initiation honest**: `create_event` acts immediately;
`start_event_creation_process` merely opens a form. The name must never lie.
- Name ≤30 chars, `[a-zA-Z0-9_.-]`; prefix with the app name if tools may coexist
with other origins' tools in testing (`myapp_search_tickets`).
- Description ≤500 chars, positive capability statement, no marketing. Param
descriptions ≤150 chars. The description must say exactly what `execute()` does —
agents make consent decisions from it.
- **Raw user input rule**: schemas accept what the user would say ("11:00 to
15:00"), never ask the agent to compute or transform. Semantic enum values
(`"High"`, not `priority_id: 3`).
- Tools returning user-generated or external content get
`untrustedContentHint: true`.
## Choosing `kind`
- `declarative` — any standard `<form>` whose fields map 1:1 to the action's
inputs: plain HTML, SSG-emitted, server-rendered, *and* framework-rendered forms
(uncontrolled inputs), including fetch-submitted forms (they bridge results via
`respondWith` — see `integrate.md`).
- `imperative` — non-form actions (buttons, drag/drop, selections), actions whose
inputs come from app state rather than form fields, and React/Vue **controlled**
forms (agent-driven fill would bypass the framework's state).
## Writing manifest entries
Fill EVERY field of the v3 schema:
- `route` + `auth` (array of roles keying into `app.authFixtures`; verify runs
once per role).
- `annotations``readOnlyHint`/`untrustedContentHint` per the candidate table;
verify asserts them on the enumerated tool.
- `examples` — one valid + one invalid. `invalid: null` is allowed ONLY for
readOnly tools with no/empty params (verify then asserts dual-outcome); the
convention for a non-null invalid on zero-param tools is `{"unexpected": true}`.
- `expect` — exactly ONE of `result` (substring of the resolved string) or
`navigation` (destination URL/pattern when `executeTool` resolves `null`),
plus `ui` (a UI assertion a test can check).
- `cleanup` — required for `mutating: "server"`, recommended for `"client"`.
The verify phase must be able to run from the manifest alone, without re-reading
the codebase — that is what makes runs resumable by a different agent.
The completeness pass at the end of Phase 1: start the app (or read the rendered
nav), enumerate what a user can *do* per screen, and diff against the manifest.
+129
View File
@@ -0,0 +1,129 @@
# Runtime — vendoring and wiring the templates
Copy from this skill's `templates/` directory into the target project
(suggested: `src/webmcp/`):
- **TypeScript projects**: `templates/webmcpify.ts` + `templates/webmcp.d.ts`;
**React TSX projects additionally** `templates/webmcp-jsx.d.ts` (JSX typings for
the declarative attributes — a MODULE file; keep it separate from
`webmcp.d.ts`, which must stay a global script file).
- **JavaScript projects**: `templates/webmcpify.js`**ES module only** (`export`):
load via a bundler or `<script type="module">`. For CommonJS/classic-script
projects, transpile or vendor the TS variant instead.
**Vendor, don't depend** — the runtime is small, MIT, and a target repo must not
gain a dependency for an origin-trial API. **Keep the full MIT notice header** in
every copied file: the license's retention condition requires the copyright line
and permission notice to travel with the code, and the header IS that notice —
never trim it down to a bare link.
Record the copied file paths in the manifest
(`pipeline.setup.runtimeVendored: ["src/webmcp/webmcpify.ts", ...]`).
What it provides:
| Export | Purpose |
|---|---|
| `getModelContext()` | The ONLY place `document.modelContext` / deprecated `navigator.modelContext` is referenced — spec churn stays a one-file fix |
| `isWebMCPAvailable()` | Feature detection — the app must work identically without WebMCP |
| `createToolScope(key, tools, options?)` | Registers a tool set under one AbortController; returns a **callable dispose handle** carrying `ready: Promise<boolean>` (true = all registrations committed; false = no WebMCP / duplicate key / failure / disposed first — never rejects). Validates contracts BEFORE registering; **rolls back the whole scope** on any failure, including sync-throwing legacy `registerTool` (reported via `options.onError`, default `console.error` — NOT called when disposed before settling). An already-active key returns a no-op handle — safe under React StrictMode |
| `dispatchAndWait(event, detail?, timeoutMs?)` | Bridges `execute()` to the app's own event/state flow. The dispatched detail carries `requestId` plus `signal` — an AbortSignal aborted on timeout; pass it to `fetch()` and skip state commits once aborted. Resolves only after the component confirms with an explicit **boolean** `ok`; a completion with missing/non-boolean `ok` **fails closed** to an `"ERROR: ..."` string, as do timeouts and `ok: false` (self-correction convention — never rejects). For tools whose confirmation involves a network round-trip (mailers, slow APIs), pass an explicit `timeoutMs` (e.g. `20_000`) instead of relying on the 10 s default |
| `singleFlight(fn, busyMessage?)` | Serializes a tool's `execute`: while one call is in flight, further calls resolve immediately to a busy `"ERROR: ..."` string instead of racing shared UI state |
Validation note: budget checks auto-enable when the bundler substitutes
`process.env.NODE_ENV` (Vite/webpack automatic; esbuild via `--define`) and it
isn't `'production'`; unbundled projects default to off — pass `{ validate: true }`
during development.
## The completion contract (the part integrators get wrong)
`dispatchAndWait` resolves when the component fires `tool-completion-<requestId>`
with `detail: { ok: boolean, message?: string, error?: string }`. `ok` must be an
explicit boolean — anything else fails closed to an ERROR result. Fire it **after
the async work has truly finished** — awaited fetch, committed state, rendered
result — never right after *starting* the action. Agents plan from what is on
screen; a completion fired early produces false greens.
Hardened component bridge (React example — adapt per framework). Five clauses:
**(1)** completion fires from an effect observing the committed state, **(2)**
availability gate, **(3)** single-flight, **(4)** timeout coordination via
`detail.signal`, **(5)** unmount cancellation.
```tsx
const pending = useRef<{ requestId: string; count: number } | null>(null);
const [results, setResults] = useState<Ticket[] | null>(null);
useEffect(() => {
if (!isWebMCPAvailable()) return; // (2) attach only when WebMCP exists
let inFlight = false;
const onSearch = async (e: Event) => {
const { query, requestId, signal } = (e as CustomEvent).detail;
const fail = (error: string) =>
window.dispatchEvent(new CustomEvent(`tool-completion-${requestId}`, {
detail: { ok: false, error },
}));
if (inFlight) return fail('A search is already running.'); // (3) single-flight
inFlight = true;
try {
const found = await runSearch(query, { signal }); // (4) the runtime aborts this signal on timeout
if (signal?.aborted) return; // (4) timed out — runtime already answered; no late commits
pending.current = { requestId, count: found.length };
setResults(found); // commit → the effect below confirms
} catch (err) {
if (signal?.aborted) return;
fail(err instanceof Error ? err.message : 'Search failed.');
} finally {
inFlight = false;
}
};
window.addEventListener('webmcp:search_tickets', onSearch);
return () => window.removeEventListener('webmcp:search_tickets', onSearch); // (5) unmount detaches
}, []);
useEffect(() => {
if (!pending.current || results === null) return; // (1) confirm AFTER the commit rendered
const { requestId, count } = pending.current;
pending.current = null;
window.dispatchEvent(new CustomEvent(`tool-completion-${requestId}`, {
detail: { ok: true, message: `Search finished — ${count} results are now visible.` },
}));
}, [results]);
```
Why clause (1): React 18 batches renders — state set after the `await` is **not
yet on screen** when the next line of the handler runs, so dispatching the
completion there reports success before the user (and the agent's next snapshot)
can see it. Dispatching from an effect keyed on the updated state guarantees the
commit happened. Equivalents: Vue `await nextTick()`; Svelte `await tick()`
then dispatch inline.
## Wiring patterns
```tsx
// bootstrap (app-wide tools, static registration — the default):
import { createToolScope } from './webmcp/webmcpify';
import { appTools } from './webmcp/tools';
createToolScope('app', appTools);
// per-view tools (only when genuinely view-bound):
useEffect(() => createToolScope('tickets-view', ticketViewTools), []);
// the handle IS the dispose fn → React runs it on unmount. StrictMode's
// double-mount is safe: the second call no-ops, an unmount before registration
// settles rolls back silently (ready → false, no onError).
// when you need to know registration committed:
const handle = createToolScope('app', appTools);
handle.ready.then((ok) => { if (!ok) console.warn('WebMCP tools not active'); });
```
Role-scoped SaaS registration — dispose and re-create on auth changes:
```ts
let dispose: (() => void) | undefined;
export function syncToolsForUser(user: User | null) {
dispose?.();
const tools = [...publicTools, ...(user ? memberTools : []),
...(user?.role === 'admin' ? adminTools : [])];
dispose = createToolScope('auth-scoped', tools);
}
// call on login, logout, role change, tenant switch
```
+73
View File
@@ -0,0 +1,73 @@
# Security checklist
Apply at two points: **before the manifest gate** (classify + flag) and **at the
final audit** (verify). Any unchecked box on a `mutating: "server"` tool blocks
it. Client-only mutations (`mutating: "client"`) still must pass the
**Trust boundary** and **Honesty & hints** boxes.
## Threat model in one paragraph
Any Chrome extension with host permissions — and any agent the user runs — can
enumerate and execute your tools **with the user's live session**. The spec has no
agent-identity mechanism. Page-visible strings (descriptions, labels, enum values,
tool outputs) all enter the model's context, so they are prompt-injection surface in
both directions. Design every tool as if it were a public, authenticated API endpoint
— because effectively it is one.
## Checklist
**Trust boundary**
- [ ] Every `execute()` calls only code paths the UI already uses — same endpoints,
same validation, same authz, same rate limits. No new endpoints, no bypasses.
- [ ] No secrets, tokens, or privileged config inside tool code or descriptions.
- [ ] Role-based apps: tools registered per role/session and re-scoped on auth
changes; nothing registered the current session couldn't do via the UI.
**Human-in-the-loop**
- [ ] No `toolautosubmit` on any state-changing form.
- [ ] No destructive/irreversible/payment tools at all in a first integration.
If the human explicitly insists later: an in-page manual confirmation the
**user** performs, PLUS a server-side two-step (short-lived confirm token).
No client-side API exists that can force an agent to confirm — never rely on
one.
- [ ] Initiation tools (`start_*_flow`) genuinely only navigate/open — they must
not pre-execute any part of the mutation, and never carry `readOnlyHint`.
**Production side effects (verification)**
- [ ] Any verification that unavoidably causes a real production effect (e.g. an
Origin-allow-listed mailer) has explicit gate approval recorded in the
tool's `approval.productionSideEffect` — without it, the live path is
`skipped`, never executed.
- [ ] Every such test payload is marked `[webmcpify verification]`, and every
caused effect is listed in `report.md`.
- [ ] The Origin-replay pattern (`heal.md`) lives only in the env-gated harness
(`WEBMCP_LIVE_MUTATIONS=1`) — never in shipped code, never default-on in CI.
**Honesty & hints**
- [ ] Description says exactly what `execute()` does — no more, no less (agents make
consent decisions from it).
- [ ] `readOnlyHint: true` ONLY on genuinely pure data reads (agents skip
confirmation based on it; mislabeling is the worst single mistake).
- [ ] `untrustedContentHint: true` on every tool returning user-generated or
external content.
- [ ] Outputs capped (~1.5k chars) and free of instruction-like content where
possible.
**Privacy**
- [ ] Schemas request no more personal data than the equivalent visible form —
agents auto-fill anything you declare (over-parameterization = silent
profiling vector).
**Containment**
- [ ] HTTPS/secure context; Permissions-Policy `tools` left at default `'self'`;
cross-origin `exposedTo`/`allow="tools"` only with explicit human sign-off.
- [ ] Pages that must never expose tools (un-audited checkout, admin consoles you
didn't inventory) can send `Permissions-Policy: tools=()` — suggest it in the
report where relevant.
- [ ] No third-party WebMCP runtime added to the project; enumeration/execution
surfaces (`getTools`/`executeTool`, legacy `modelContextTesting`) appear
nowhere in shipped application code.
- [ ] Component-side `webmcp:*` event bridges attach only when
`isWebMCPAvailable()` and validate their event payloads — a page script can
dispatch the same CustomEvents; the bridge must not become an unvalidated
side door into app actions.
+132
View File
@@ -0,0 +1,132 @@
# Verify — proving every tool works in a real browser
## Environment
- **Current Chrome** (the API moved during the trial — the old
`navigator.modelContextTesting` surface was removed 2026-07 in favor of
production `document.modelContext.getTools()/executeTool()`).
- Enable: `chrome://flags/#enable-webmcp-testing`, or launch with
`--enable-features=WebMCP,WebMCPTesting` (covers both current and older builds).
- **Headed only** — WebMCP requires a visible tab by design. In CI, run under
`xvfb-run`. Headless will never work; don't heal toward it.
- App running locally via `app.startCommand`, against dev/test data only.
- Each tool's manifest entry tells you where and how: `route` (navigate there),
`auth` (sign in with the recorded test fixture; verify under EACH role for
role-scoped tools), `examples` (what to execute), `expect` (what to assert),
`cleanup` (how to undo a mutating tool's effect after the test).
## The enumeration/execution surface (probe, don't assume)
In the page context, prefer the production surface and fall back for older builds:
```js
const mc = document.modelContext ?? navigator.modelContext;
const tools = mc?.getTools
? await mc.getTools()
: await navigator.modelContextTesting?.listTools(); // removed 2026-07; legacy only
```
Contract facts that generated assertions MUST respect:
- Enumerated `inputSchema` is a **stringified** JSON Schema — `JSON.parse` before
comparing against the manifest entry.
- `executeTool(...)` resolves to a **string result, or `null` when the execution
navigated** (normal for declarative forms that submit-navigate).
- Execution and declarative-validation failures **reject the promise** — they do
not resolve to `"ERROR: ..."`. Only imperative tools following the runtime's
convention resolve with `"ERROR: ..."` strings. Assert accordingly per tool
`kind`.
- **Registration is asynchronous** — `registerTool()` returns a promise, so a tool
is not enumerable the instant the page loads. Poll for it (`waitForTool` in the
template) or await a `toolchange` event; never assert presence immediately
after `goto`.
- **Mutating declarative forms pause mid-execution**: Chrome fills the form, then
waits for a real submit interaction before letting `executeTool` settle —
awaiting it alone deadlocks into a timeout. Use the concurrent pattern: start
`executeTool` unawaited → wait for an agent-filled value to appear → click
submit → await the result (full example in the template).
- These surfaces are for agents/harnesses only — they must never appear in shipped
application code.
For **declarative** tools also verify the *synthesized* schema: the form-control →
schema mapping is only partially specified, so check each annotated control appears
as the expected property in the actual target Chrome build.
## Per-tool checks
1. Registered (poll — registration is async) with the expected name, the (parsed)
schema, **and** the manifest `annotations` on the enumerated tool. The legacy
`modelContextTesting` fallback cannot enumerate annotations — skip that
assertion there and note the gap in the report.
2. Valid example executes: assert the result per `expect``expect.result` as a
substring of the resolved string, or `expect.navigation` as the destination
when `executeTool` resolves `null` (it navigated) — **and** the `expect.ui`
state as a **delta** (capture the relevant state *before* executing; mere
visibility of something already on screen proves nothing). A tool that reports
success without the UI changing is a **fail** (UI-settled rule). Because
executions can navigate, restore the manifest `route` in `beforeEach`, not
`beforeAll`.
3. Invalid example: **prove the tool is present first** (a rejection from a
never-registered tool is not a validation rejection). Then: imperative →
resolves `"ERROR: ..."`; declarative/schema violation → rejects. Zero-param
read tools with `examples.invalid: null` get the dual-outcome assertion
instead: `{"unexpected": true}` may be rejected with a validation reason OR
resolve benignly — both pass; a missing tool/surface fails.
4. Mutating tools: run against disposable data, verify the mutation through the
same read path the UI uses, then execute the manifest `cleanup` — a
`mutating: "server"` tool without working cleanup blocks at the gate, and
heal-loop retries of mutating tools must clean up between attempts.
## Harness
Instantiate `templates/webmcp.spec.ts` (bundled with this skill) — Playwright,
headed persistent Chrome, one describe-block per tool generated from the manifest,
with real assertions (never commented-out placeholders). Put the generated spec
next to the repo's existing e2e tests.
**Repos without a test setup — the standalone-harness recipe.** The spec stays in
`.webmcpify/webmcp.spec.ts` (single source of truth, committed per the gate's
`commitWebmcpifyDir` choice); the Playwright installation lives in a scratch
harness OUTSIDE the repo so the target gains no dependencies:
```sh
mkdir -p /tmp/webmcpify-harness && cd /tmp/webmcpify-harness
npm init -y && npm i -D @playwright/test typescript @types/node
cat > playwright.config.ts <<'EOF'
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: process.env.WEBMCP_SPEC_DIR, // → <target-repo>/.webmcpify
workers: 1, // one shared headed Chrome — never parallelize
});
EOF
WEBMCP_SPEC_DIR=<target-repo>/.webmcpify WEBMCP_BASE_URL=http://localhost:5173 \
NODE_PATH=/tmp/webmcpify-harness/node_modules npx playwright test
```
`NODE_PATH` lets the out-of-repo spec resolve `@playwright/test`; if the target's
tooling ignores `NODE_PATH`, symlink instead:
`ln -s /tmp/webmcpify-harness/node_modules <target-repo>/.webmcpify/node_modules`
(and make sure it isn't committed). Note in the report that verification ran from
a standalone harness.
**Alternative:** Puppeteer ships a first-class experimental WebMCP API
(https://pptr.dev/guides/webmcp) — prefer it when the target repo already uses
Puppeteer.
## Tool-selection evals (recommended; mandatory for SaaS-scale toolsets)
Schema-level verification proves tools *work*, not that an LLM *picks* them.
For apps exposing more than a handful of tools, run Google's **WebMCP Evals CLI**
(GoogleChromeLabs/webmcp-tools, `evals-cli`): write one eval case per tool from the
manifest examples ("user says X → expect tool Y with args Z") and run them —
this catches ambiguous names/descriptions and overlapping tools that Playwright
cannot.
## Manual QA (tell the human in the report)
- DevTools → **Application → WebMCP pane**: live tool list, invocation log,
"Run tool" with editable params.
- **Model Context Tool Inspector** Chrome extension (by Google's François
Beaufort): natural-language smoke tests of tool *selection*.
- Chrome's WebMCP audits flag missing `toolname`/`toolparamdescription`/
`label[for]`/`name` on declarative forms.
+59
View File
@@ -0,0 +1,59 @@
/**
* React JSX typings for the declarative WebMCP attributes — vendored from
* https://github.com/TueJon/webmcpify
*
* MIT License
* Copyright (c) 2026 Jonas Tüchler
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software — keep this header when
* copying this file into your project.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Full text: https://github.com/TueJon/webmcpify/blob/main/LICENSE
*
* NOTE: this file is a MODULE (`declare module 'react'` requires the `import`)
* — keep it SEPARATE from webmcp.d.ts. Merging it there would add an import to
* that file, turning it into a module and un-globalizing its ambient interfaces
* (empirically verified). Vendor both files side by side in React TSX projects.
*/
import 'react';
declare module 'react' {
interface FormHTMLAttributes<T> {
toolname?: string;
tooldescription?: string;
/**
* Boolean attribute — write `toolautosubmit=""` in TSX. ONLY on pure read
* forms (search/filter/availability); never on state-changing forms.
*/
toolautosubmit?: string;
}
interface InputHTMLAttributes<T> {
toolparamdescription?: string;
}
interface SelectHTMLAttributes<T> {
toolparamdescription?: string;
}
interface TextareaHTMLAttributes<T> {
toolparamdescription?: string;
}
interface FieldsetHTMLAttributes<T> {
toolparamdescription?: string;
}
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Ambient types for the WebMCP API — vendored from https://github.com/TueJon/webmcpify
*
* MIT License
* Copyright (c) 2026 Jonas Tüchler
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software — keep this header when
* copying this file into your project.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Full text: https://github.com/TueJon/webmcpify/blob/main/LICENSE
*
* registerTool/ontoolchange/annotations/getTools follow the CG draft
* (https://webmachinelearning.github.io/webmcp/); executeTool is a Chrome-only
* extension not yet in the draft. This API is in flux — re-check against the
* draft and https://developer.chrome.com/docs/ai/webmcp when updating.
*
* This is a GLOBAL script file — no imports (an import would turn it into a
* module and un-globalize every interface). React JSX typings for the declarative
* attributes live in the separate webmcp-jsx.d.ts template.
*/
interface ModelContextToolAnnotations {
readOnlyHint?: boolean;
untrustedContentHint?: boolean;
}
interface ModelContext extends EventTarget {
registerTool(
tool: ModelContextTool,
options?: { signal?: AbortSignal; exposedTo?: string[] },
): Promise<void>;
ontoolchange: ((this: ModelContext, ev: Event) => unknown) | null;
/**
* Enumerates tools exposed to this document. Added to the CG draft in 2026-07;
* intended for in-page agents and test harnesses, not application logic.
*/
getTools(options?: { fromOrigins?: string[] }): Promise<RegisteredTool[]>;
/**
* Chrome-only execution surface (2026-07+; not yet in the CG draft); replaced
* the removed navigator.modelContextTesting API.
*/
executeTool?(
tool: RegisteredTool,
inputJson: string,
options?: { signal?: AbortSignal },
): Promise<string | null>;
}
interface ModelContextTool {
/** [a-zA-Z0-9_.-]; spec allows up to 128 chars, Google recommends ≤30 */
name: string;
/** Optional display label */
title?: string;
/** Natural-language capability statement, ≤500 chars recommended */
description: string;
/** JSON Schema for the tool's input */
inputSchema?: object;
/**
* Only `input` is passed — there is no client/session argument in the IDL.
* IDL: `Promise<any>` — WebIDL auto-wraps synchronous returns (and throws) in
* a promise, so a sync implementation still fulfills this type at runtime;
* declare it async for type fidelity.
*/
execute(input: Record<string, unknown>): Promise<unknown>;
annotations?: ModelContextToolAnnotations;
}
/** Shape returned by getTools(). NOTE inputSchema is a STRINGIFIED JSON Schema. */
interface RegisteredTool {
name: string;
title?: string;
description: string;
inputSchema?: string;
annotations?: ModelContextToolAnnotations;
/** Registering origin (secure origins only). */
origin: string;
/** Owning window (cross-document enumeration). */
window: Window;
}
interface Document {
readonly modelContext?: ModelContext;
}
interface Navigator {
/** Deprecated Chrome 149 origin-trial surface; prefer document.modelContext. */
readonly modelContext?: ModelContext;
}
/** Declarative form submissions: agent-invoked flag + result bridge. */
interface SubmitEvent {
readonly agentInvoked?: boolean;
respondWith?(result: Promise<unknown>): void;
}
+248
View File
@@ -0,0 +1,248 @@
/**
* webmcpify verification template — vendored from https://github.com/TueJon/webmcpify
*
* MIT License
* Copyright (c) 2026 Jonas Tüchler
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software — keep this header when
* copying this file into your project.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Full text: https://github.com/TueJon/webmcpify/blob/main/LICENSE
*
* The webmcpify skill instantiates one describe-block per manifest tool, filling
* route/auth/examples/expect from .webmcpify/manifest.json. The example blocks
* below show the complete patterns with REAL assertions — generated blocks must
* assert, never comment out.
*
* Requirements: real Chrome, HEADED (WebMCP needs a visible tab — headless will
* never work; use xvfb-run in CI). Enumeration/execution uses the production
* document.modelContext.getTools()/executeTool() surface (Chrome 2026-07+), with a
* probe fallback to the removed navigator.modelContextTesting for older builds.
* Alternative harness: Puppeteer's first-class WebMCP API (pptr.dev/guides/webmcp).
*/
import { chromium, expect, test } from '@playwright/test';
import type { BrowserContext, Page } from '@playwright/test';
const BASE_URL = process.env.WEBMCP_BASE_URL ?? 'http://localhost:5173';
let context: BrowserContext;
let page: Page;
test.beforeAll(async () => {
context = await chromium.launchPersistentContext('', {
channel: 'chrome',
headless: false,
args: ['--enable-features=WebMCP,WebMCPTesting'],
});
page = await context.newPage();
});
test.afterAll(async () => {
await context.close();
});
/** Enumerate registered tools; inputSchema comes back as a STRING (JSON Schema). */
async function listTools(p: Page): Promise<
Array<{
name: string;
inputSchema?: string;
annotations?: { readOnlyHint?: boolean; untrustedContentHint?: boolean };
}>
> {
return p.evaluate(async () => {
const mc = (document as any).modelContext ?? (navigator as any).modelContext;
if (mc?.getTools) return mc.getTools();
const legacy = (navigator as any).modelContextTesting; // removed 2026-07; older builds only
if (legacy?.listTools) return legacy.listTools();
throw new Error('No WebMCP enumeration surface — wrong Chrome build or flags');
});
}
/**
* Execute a tool. Contract (Chrome): resolves to a string result, or null when the
* execution navigated; execution/validation failures REJECT — assert with
* expect(...).rejects where a failure is the expected outcome.
*/
async function executeTool(p: Page, name: string, args: object): Promise<string | null> {
return p.evaluate(
async ({ name, args }) => {
const mc = (document as any).modelContext ?? (navigator as any).modelContext;
if (mc?.getTools && mc?.executeTool) {
const tools = await mc.getTools();
const tool = tools.find((t: { name: string }) => t.name === name);
if (!tool) throw new Error(`tool ${name} is not registered`);
return mc.executeTool(tool, JSON.stringify(args));
}
const legacy = (navigator as any).modelContextTesting;
if (legacy?.executeTool) return legacy.executeTool(name, JSON.stringify(args));
throw new Error('No WebMCP execution surface — wrong Chrome build or flags');
},
{ name, args },
);
}
/**
* registerTool is ASYNC — a tool is not enumerable the instant the page loads.
* Poll (or await a `toolchange` event) instead of asserting immediately.
*/
async function waitForTool(p: Page, name: string, timeoutMs = 5000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const tools = await listTools(p);
if (tools.some((t) => t.name === name)) return true;
if (Date.now() >= deadline) return false;
await p.waitForTimeout(100);
}
}
/** The modern surface exposes annotations; the legacy fallback does not. */
async function hasModernSurface(p: Page): Promise<boolean> {
return p.evaluate(() => {
const mc = (document as any).modelContext ?? (navigator as any).modelContext;
return !!mc?.getTools;
});
}
test('WebMCP is available in the test environment', async () => {
await page.goto(BASE_URL);
const available = await page.evaluate(
() => !!(document as any).modelContext || !!(navigator as any).modelContext,
);
expect(available, 'Enable chrome://flags/#enable-webmcp-testing and use current Chrome').toBe(
true,
);
});
// ── Generated per manifest tool ──────────────────────────────────────────────
// Complete example for a read-only imperative tool. Fill route/examples/expect
// from the manifest entry; for `auth != none`, sign in with the recorded
// app.authFixtures fixture before the tool tests — once per role listed in `auth`.
test.describe('search_tickets', () => {
test.beforeEach(async () => {
// Navigate per TEST, not per describe — an earlier test may have navigated
// away (executeTool returning null means exactly that).
await page.goto(`${BASE_URL}/projects/demo/tickets`); // manifest: route
});
test('is registered with the expected schema and annotations', async () => {
expect(await waitForTool(page, 'search_tickets')).toBe(true); // async registration — poll
const tools = await listTools(page);
const tool = tools.find((t) => t.name === 'search_tickets')!;
const schema = JSON.parse(tool.inputSchema ?? '{}'); // stringified → parse first
expect(schema.required).toContain('query'); // manifest: inputSchema
if (await hasModernSurface(page)) {
// manifest: annotations — assert exactly what the manifest recorded
expect(tool.annotations?.readOnlyHint).toBe(true);
expect(tool.annotations?.untrustedContentHint).toBe(true);
} else {
// Legacy modelContextTesting fallback cannot enumerate annotations —
// skip the assertion and record the gap in the report.
test.info().annotations.push({
type: 'webmcpify',
description: 'annotations not enumerable on this Chrome build — assertion skipped',
});
}
});
test('executes the valid example and changes the UI', async () => {
expect(await waitForTool(page, 'search_tickets')).toBe(true);
// Capture the relevant UI state BEFORE executing — success must be a DELTA,
// not mere visibility of something that was already on screen.
const before = await page.getByRole('list', { name: 'Tickets' }).innerText();
const out = await executeTool(page, 'search_tickets', { query: 'test' }); // manifest: examples.valid
expect(out).not.toBeNull(); // null would mean "navigated" — not expected for this tool
expect(out).not.toMatch(/^ERROR:/);
await expect(page.getByRole('list', { name: 'Tickets' })).toBeVisible(); // manifest: expect.ui
const after = await page.getByRole('list', { name: 'Tickets' }).innerText();
expect(after).not.toBe(before); // the UI actually changed
});
test('rejects the invalid example with a self-correcting message', async () => {
// Prove the tool is PRESENT first — otherwise this test can "pass" on a
// rejection that merely means the tool never registered.
expect(await waitForTool(page, 'search_tickets')).toBe(true);
const out = await executeTool(page, 'search_tickets', {}); // manifest: examples.invalid
expect(out).toMatch(/^ERROR:/); // imperative convention: resolves with "ERROR: ..."
// Declarative tools instead REJECT on schema/validation failures — for those,
// generate: await expect(executeTool(page, '<tool>', {})).rejects.toThrow();
});
});
// Complete example for a MUTATING DECLARATIVE form tool. Chrome fills the form,
// then PAUSES the execution until a real submit interaction happens — awaiting
// executeTool alone deadlocks. Start it unawaited, wait for the agent-filled
// value, click submit, then await the result.
test.describe('send_contact_message', () => {
test.beforeEach(async () => {
// Navigate per test — a submit-navigating execution leaves the route.
await page.goto(`${BASE_URL}/contact`); // manifest: route
});
test('executes via the concurrent submit-click pattern', async () => {
expect(await waitForTool(page, 'send_contact_message')).toBe(true);
// 1. Start the execution WITHOUT awaiting it (Chrome pauses it at the form).
const pending = executeTool(page, 'send_contact_message', {
email: 'qa@example.test', // manifest: examples.valid
message: '[webmcpify verification] harness test message',
});
// 2. Wait until the agent-filled value is visible in the form.
await expect(page.getByLabel('Email')).toHaveValue('qa@example.test');
// 3. Perform the real submit interaction that resumes the paused execution.
await page.getByRole('button', { name: 'Send' }).click();
// 4. Now the promise settles.
const out = await pending;
if (out === null) {
// null = the execution navigated (submit-navigating form) — assert the
// destination instead of the return value. beforeEach restores the route.
await expect(page).toHaveURL(/thank-you/); // manifest: expect.navigation
} else {
expect(out).not.toMatch(/^ERROR:/);
expect(out).toContain('received'); // manifest: expect.result
}
// manifest: cleanup — mutating:"server" tools MUST undo the side effect here
// (e.g. delete the test message via the UI's own admin path).
});
});
// Complete example for a ZERO-PARAM READ tool with `examples.invalid` following
// the zero-param convention ({"unexpected": true}). Dual-outcome: rejecting the
// unexpected key OR resolving benignly (accept-and-ignore) are BOTH passes —
// what must never pass is a missing tool or a missing WebMCP surface.
test.describe('get_page_summary', () => {
test.beforeEach(async () => {
await page.goto(BASE_URL); // manifest: route
});
test('handles unexpected input without side effects (dual-outcome)', async () => {
expect(await waitForTool(page, 'get_page_summary')).toBe(true); // presence FIRST
try {
const out = await executeTool(page, 'get_page_summary', { unexpected: true }); // manifest: examples.invalid
// Resolved: must be benign — a normal result (readOnlyHint tool: no side
// effect possible) or a self-correcting "ERROR: ..." string.
expect(out).not.toBeNull();
} catch (err) {
// Rejected: acceptable only as a validation rejection — a missing surface
// or unregistered tool is a real failure, not a pass.
expect(String(err)).not.toMatch(/No WebMCP|is not registered/);
}
});
});
+251
View File
@@ -0,0 +1,251 @@
/**
* webmcpify runtime (JavaScript variant) — vendored from https://github.com/TueJon/webmcpify
*
* MIT License
* Copyright (c) 2026 Jonas Tüchler
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software — keep this header when
* copying this file into your project.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Full text: https://github.com/TueJon/webmcpify/blob/main/LICENSE
*
* Spec-shaped helper around the WebMCP API (document.modelContext). Vendor this
* file; do not add it as a dependency. Everything is feature-detected: in browsers
* without WebMCP every function is a safe no-op. This file is an ES module
* (`export`) — load it via a bundler or `<script type="module">`; for
* CommonJS/classic-script projects, transpile or vendor the TS variant instead.
*/
/**
* @typedef {(() => void) & { ready: Promise<boolean> }} ToolScopeHandle
* Callable dispose handle: call it to dispose (aborts the registration signal,
* frees the key). `ready` resolves true when all registrations committed and the
* scope is still active; false on no WebMCP / key already active / registration
* failure (rolled back) / disposed first. Never rejects — failures go to onError.
*/
/** The ONLY place the raw API is referenced — spec churn is a one-file fix. */
export function getModelContext() {
if (typeof document !== 'undefined' && document.modelContext) return document.modelContext;
// Deprecated surface used by the Chrome 149 origin trial; remove when obsolete.
if (typeof navigator !== 'undefined' && navigator.modelContext) return navigator.modelContext;
return undefined;
}
export function isWebMCPAvailable() {
return getModelContext() !== undefined;
}
const scopes = new Map();
/**
* @param {() => void} dispose
* @param {Promise<boolean>} ready
* @returns {ToolScopeHandle}
*/
function makeHandle(dispose, ready) {
return Object.assign(dispose, { ready });
}
/**
* Register a set of tools under one scope key. Returns a callable dispose handle
* carrying `ready: Promise<boolean>` (see ToolScopeHandle).
*
* - AbortSignal is the spec's only unregistration mechanism — dispose aborts it.
* - Validation runs BEFORE any registration, so a bad contract never leaves a
* half-registered scope.
* - Registration failures — rejections AND synchronously throwing registerTool
* implementations (pre-2026-07 Chromium) — roll back the entire scope, resolve
* `ready` to false, and are reported via `onError` (default: console.error);
* the key is never stranded. `onError` is not called when the scope is
* disposed before registration settles.
* - Disposing before registration settles (e.g. React StrictMode unmount) rolls
* back silently: `ready` resolves false and `onError` is NOT called.
* - Calling with a key that is already active returns a no-op handle
* (`ready` → false) and leaves the existing scope untouched.
*
* @param {string} key
* @param {Array<object>} tools
* @param {{ exposedTo?: string[], validate?: boolean, onError?: (e: unknown) => void }} [options]
* @returns {ToolScopeHandle}
*/
export function createToolScope(key, tools, options) {
const mc = getModelContext();
if (!mc) return makeHandle(() => {}, Promise.resolve(false));
if (scopes.has(key)) return makeHandle(() => {}, Promise.resolve(false));
if (shouldValidate(options)) for (const tool of tools) validateTool(tool);
const controller = new AbortController();
scopes.set(key, controller);
let disposed = false;
const rollback = () => {
if (scopes.get(key) === controller) {
controller.abort();
scopes.delete(key);
}
};
const registerOptions = { signal: controller.signal };
if (options?.exposedTo) registerOptions.exposedTo = options.exposedTo;
let registrations;
try {
// Legacy registerTool implementations throw synchronously instead of
// rejecting — normalize so the rollback path below covers both.
registrations = Promise.all(tools.map((tool) => mc.registerTool(tool, registerOptions)));
} catch (error) {
registrations = Promise.reject(error);
}
const ready = registrations.then(
() => scopes.get(key) === controller, // false when disposed before settling
(error) => {
rollback();
if (!disposed) {
const report =
options?.onError ??
((e) => console.error(`webmcpify: registration failed for scope "${key}"`, e));
report(error);
}
return false;
},
);
return makeHandle(() => {
disposed = true;
rollback();
}, ready);
}
/**
* Bridge execute() to the app's own event/state flow. The dispatched detail
* carries `{ ...detail, requestId, signal }` — `signal` is an AbortSignal aborted
* on timeout; handlers should pass it to fetch() and skip state commits and the
* completion dispatch once aborted. Resolves only after the component confirms
* the outcome by dispatching `tool-completion-<requestId>` with
* `detail: { ok: boolean, message?: string, error?: string }` — and it must do so
* AFTER the async work truly finished (awaited fetch/state commit/render),
* because agents plan from what is on screen.
*
* The completion contract fails closed: `ok === true` resolves the message,
* `ok === false` resolves `"ERROR: <error>"`, and anything else (missing or
* non-boolean `ok`, no detail) resolves an ERROR string reporting an unknown
* outcome. Timeouts resolve an ERROR string too. Never rejects — the model can
* self-correct without unhandled rejections inside execute().
*
* @param {string} eventName
* @param {Record<string, unknown>} [detail]
* @param {number} [timeoutMs]
* @returns {Promise<string>}
*/
export function dispatchAndWait(eventName, detail = {}, timeoutMs = 10000) {
return new Promise((resolve) => {
const requestId = Math.random().toString(36).slice(2, 12);
const completionEvent = `tool-completion-${requestId}`;
const abort = new AbortController();
const cleanup = () => {
clearTimeout(timer);
window.removeEventListener(completionEvent, onDone);
};
const timer = setTimeout(() => {
cleanup();
abort.abort();
resolve(
'ERROR: The interface did not confirm this action in time. The request was signalled to cancel but may still be processing — check the current page state before retrying.',
);
}, timeoutMs);
const onDone = (event) => {
cleanup();
const result = event.detail ?? {};
if (result.ok === true) {
resolve(result.message ?? 'Action completed successfully.');
} else if (result.ok === false) {
resolve(`ERROR: ${result.error ?? 'The action failed.'}`);
} else {
resolve(
'ERROR: The interface sent a completion without a boolean `ok` — the outcome is unknown. Check the current page state before retrying.',
);
}
};
window.addEventListener(completionEvent, onDone);
window.dispatchEvent(
new CustomEvent(eventName, { detail: { ...detail, requestId, signal: abort.signal } }),
);
});
}
/**
* Serialize a tool's execute(): while one call is in flight, further calls
* resolve immediately to a busy ERROR string instead of racing shared UI state.
*
* ```js
* execute: singleFlight(async (input) => dispatchAndWait('webmcp:save', input)),
* ```
*
* @template {unknown[]} A
* @param {(...args: A) => string | Promise<string>} fn
* @param {string} [busyMessage]
* @returns {(...args: A) => Promise<string>}
*/
export function singleFlight(
fn,
busyMessage = 'ERROR: A previous invocation of this tool is still in progress. Wait for it to finish, then check the current page state before retrying.',
) {
let inFlight = false;
return async (...args) => {
if (inFlight) return busyMessage;
inFlight = true;
try {
return await fn(...args);
} finally {
inFlight = false;
}
};
}
/**
* @param {{ validate?: boolean }} [options]
* Default: enabled when the bundler substitutes `process.env.NODE_ENV`
* (Vite/webpack automatic; esbuild via `--define`) and it isn't `'production'`;
* unbundled projects default to false — pass `validate: true` during development.
*/
function shouldValidate(options) {
if (options?.validate !== undefined) return options.validate;
// Bundlers substitute the literal; unbundled, the bare reference throws → false.
try {
return process.env.NODE_ENV !== 'production';
} catch {
return false;
}
}
/** Contract-quality checks (Google's recommended budgets). */
function validateTool(tool) {
const problems = [];
if (!/^[a-zA-Z0-9_.-]{1,30}$/.test(tool.name)) {
problems.push(`name "${tool.name}" should be 1-30 chars of [a-zA-Z0-9_.-]`);
}
if (!tool.description) problems.push(`tool "${tool.name}" is missing a description`);
else if (tool.description.length > 500) {
problems.push(`tool "${tool.name}" description exceeds 500 chars`);
}
if (problems.length) throw new Error(`webmcpify: ${problems.join('; ')}`);
}
+266
View File
@@ -0,0 +1,266 @@
/**
* webmcpify runtime — vendored from https://github.com/TueJon/webmcpify
*
* MIT License
* Copyright (c) 2026 Jonas Tüchler
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software — keep this header when
* copying this file into your project.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Full text: https://github.com/TueJon/webmcpify/blob/main/LICENSE
*
* Spec-shaped helper around the WebMCP API (document.modelContext, W3C Web Machine
* Learning CG draft / Chrome origin trial). Vendor this file; do not add it as a
* dependency. Everything is feature-detected: in browsers without WebMCP every
* function is a safe no-op and the app behaves identically.
*/
/**
* The bundler substitutes `process.env.NODE_ENV` with a literal (Vite/webpack do
* this automatically; esbuild via `--define`). This declaration only satisfies the
* typechecker — no Node ambient types are required or wanted in a browser file.
*/
declare const process: { env: { NODE_ENV?: string } };
export interface ToolScopeOptions {
exposedTo?: string[];
/**
* Contract validation (name/description budgets). Default: enabled when the
* bundler substitutes `process.env.NODE_ENV` (Vite/webpack automatic; esbuild
* via `--define`) and it isn't `'production'`; unbundled projects default to
* false — pass `validate: true` during development.
*/
validate?: boolean;
/**
* Called if any registration in the scope fails (the scope is rolled back).
* Not called when the scope is disposed before registration settles.
*/
onError?: (error: unknown) => void;
}
/**
* Callable dispose handle returned by `createToolScope` — call it to dispose
* (backwards-compatible with `const dispose = createToolScope(...)`).
*/
export interface ToolScopeHandle {
/** Dispose: aborts the registration signal, frees the key. */
(): void;
/**
* true = all registrations committed and the scope is still active;
* false = no WebMCP / key already active / registration failed (rolled back) /
* disposed first. Never rejects — failures go to `onError`.
*/
ready: Promise<boolean>;
}
/** The ONLY place the raw API is referenced — spec churn is a one-file fix. */
export function getModelContext(): ModelContext | undefined {
if (typeof document !== 'undefined' && document.modelContext) return document.modelContext;
// Deprecated surface used by the Chrome 149 origin trial; remove when obsolete.
if (typeof navigator !== 'undefined' && navigator.modelContext) return navigator.modelContext;
return undefined;
}
export function isWebMCPAvailable(): boolean {
return getModelContext() !== undefined;
}
const scopes = new Map<string, AbortController>();
function makeHandle(dispose: () => void, ready: Promise<boolean>): ToolScopeHandle {
const handle = dispose as ToolScopeHandle;
handle.ready = ready;
return handle;
}
/**
* Register a set of tools under one scope key. Returns a callable dispose handle
* carrying `ready: Promise<boolean>` (see ToolScopeHandle).
*
* - AbortSignal is the spec's only unregistration mechanism — dispose aborts it.
* - Validation runs BEFORE any registration, so a bad contract never leaves a
* half-registered scope.
* - Registration failures — rejections AND synchronously throwing registerTool
* implementations (pre-2026-07 Chromium) — roll back the entire scope, resolve
* `ready` to false, and are reported via `onError` (default: console.error);
* the key is never stranded.
* - Disposing before registration settles (e.g. React StrictMode unmount) rolls
* back silently: `ready` resolves false and `onError` is NOT called.
* - Calling with a key that is already active returns a no-op handle
* (`ready` → false) and leaves the existing scope untouched.
*/
export function createToolScope(
key: string,
tools: ModelContextTool[],
options?: ToolScopeOptions,
): ToolScopeHandle {
const mc = getModelContext();
if (!mc) return makeHandle(() => {}, Promise.resolve(false));
if (scopes.has(key)) return makeHandle(() => {}, Promise.resolve(false));
if (shouldValidate(options)) for (const tool of tools) validateTool(tool);
const controller = new AbortController();
scopes.set(key, controller);
let disposed = false;
const rollback = () => {
if (scopes.get(key) === controller) {
controller.abort();
scopes.delete(key);
}
};
const registerOptions: { signal: AbortSignal; exposedTo?: string[] } = {
signal: controller.signal,
};
if (options?.exposedTo) registerOptions.exposedTo = options.exposedTo;
let registrations: Promise<unknown>;
try {
// Legacy registerTool implementations throw synchronously instead of
// rejecting — normalize so the rollback path below covers both.
registrations = Promise.all(tools.map((tool) => mc.registerTool(tool, registerOptions)));
} catch (error) {
registrations = Promise.reject(error);
}
const ready = registrations.then(
() => scopes.get(key) === controller, // false when disposed before settling
(error) => {
rollback();
if (!disposed) {
const report =
options?.onError ??
((e: unknown) => console.error(`webmcpify: registration failed for scope "${key}"`, e));
report(error);
}
return false;
},
);
return makeHandle(() => {
disposed = true;
rollback();
}, ready);
}
/**
* Bridge execute() to the app's own event/state flow. The dispatched detail
* carries `{ ...detail, requestId, signal }` — `signal` is an AbortSignal aborted
* on timeout; handlers should pass it to fetch() and skip state commits and the
* completion dispatch once aborted. Resolves only after the component confirms
* the outcome by dispatching `tool-completion-<requestId>` with
* `detail: { ok: boolean, message?: string, error?: string }` — and it must do so
* AFTER the async work truly finished (awaited fetch/state commit/render),
* because agents plan from what is on screen.
*
* The completion contract fails closed: `ok === true` resolves the message,
* `ok === false` resolves `"ERROR: <error>"`, and anything else (missing or
* non-boolean `ok`, no detail) resolves an ERROR string reporting an unknown
* outcome. Timeouts resolve an ERROR string too. Never rejects — the model can
* self-correct without unhandled rejections inside execute().
*/
export function dispatchAndWait(
eventName: string,
detail: Record<string, unknown> = {},
timeoutMs = 10000,
): Promise<string> {
return new Promise((resolve) => {
const requestId = Math.random().toString(36).slice(2, 12);
const completionEvent = `tool-completion-${requestId}`;
const abort = new AbortController();
const cleanup = () => {
clearTimeout(timer);
window.removeEventListener(completionEvent, onDone as EventListener);
};
const timer = setTimeout(() => {
cleanup();
abort.abort();
resolve(
'ERROR: The interface did not confirm this action in time. The request was signalled to cancel but may still be processing — check the current page state before retrying.',
);
}, timeoutMs);
const onDone = (event: Event) => {
cleanup();
const result =
(event as CustomEvent<{ ok?: unknown; message?: string; error?: string }>).detail ?? {};
if (result.ok === true) {
resolve(result.message ?? 'Action completed successfully.');
} else if (result.ok === false) {
resolve(`ERROR: ${result.error ?? 'The action failed.'}`);
} else {
resolve(
'ERROR: The interface sent a completion without a boolean `ok` — the outcome is unknown. Check the current page state before retrying.',
);
}
};
window.addEventListener(completionEvent, onDone as EventListener);
window.dispatchEvent(
new CustomEvent(eventName, { detail: { ...detail, requestId, signal: abort.signal } }),
);
});
}
/**
* Serialize a tool's execute(): while one call is in flight, further calls
* resolve immediately to a busy ERROR string instead of racing shared UI state.
*
* ```ts
* execute: singleFlight(async (input) => dispatchAndWait('webmcp:save', input)),
* ```
*/
export function singleFlight<A extends unknown[]>(
fn: (...args: A) => string | Promise<string>,
busyMessage = 'ERROR: A previous invocation of this tool is still in progress. Wait for it to finish, then check the current page state before retrying.',
): (...args: A) => Promise<string> {
let inFlight = false;
return async (...args: A) => {
if (inFlight) return busyMessage;
inFlight = true;
try {
return await fn(...args);
} finally {
inFlight = false;
}
};
}
function shouldValidate(options?: ToolScopeOptions): boolean {
if (options?.validate !== undefined) return options.validate;
// Bundlers substitute the literal; unbundled, the bare reference throws → false.
try {
return process.env.NODE_ENV !== 'production';
} catch {
return false;
}
}
/** Contract-quality checks (Google's recommended budgets). */
function validateTool(tool: ModelContextTool): void {
const problems: string[] = [];
if (!/^[a-zA-Z0-9_.-]{1,30}$/.test(tool.name)) {
problems.push(`name "${tool.name}" should be 1-30 chars of [a-zA-Z0-9_.-]`);
}
if (!tool.description) problems.push(`tool "${tool.name}" is missing a description`);
else if (tool.description.length > 500) {
problems.push(`tool "${tool.name}" description exceeds 500 chars`);
}
if (problems.length) throw new Error(`webmcpify: ${problems.join('; ')}`);
}