mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-01 23:12:29 +00:00
Add the WebMCPify agent skill 🤖🤖🤖 (#2400)
* feat(skills): add webmcpify skill * style(skills): quote webmcpify description
This commit is contained in:
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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"]`).
|
||||
@@ -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: 5–30 for a big SaaS, 1–3 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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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('; ')}`);
|
||||
}
|
||||
@@ -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('; ')}`);
|
||||
}
|
||||
Reference in New Issue
Block a user