Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ reconstruct them from git history.

## [Unreleased]

- Preserve the original execution callback options and signal through the TS/JS
result guards; retain the existing null/undefined guard and single-flight behavior.
- Add caller cancellation to the event bridge, including preabort without dispatch,
cooperative handler signalling, unknown-outcome errors, and listener/timer cleanup.
Registration disposal remains separate; application tools are never retried automatically.
- Date current-draft/Chrome compatibility guidance, type callback options and
ModelContext activation/cancellation events plus the debugging annotation, and
distinguish serialized native results from callback values and simulation evidence.
- Align Workbench imperative simulation with execution cancellation, omitted input
and serialized returns; add JS/transpiled-TS regressions. Simulation is not proof
of current native Chrome behavior.
- Correct the README release callout to describe v0.7.0's existing mutation journal.

## [0.7.0] — 2026-09-19

- Added a dependency-free host-side mutation journal for verification runners.
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ npx skills add TueJon/webmcpify # once
```

> [!TIP]
> **New in [v0.6.0](https://github.com/TueJon/webmcpify/releases/tag/v0.6.0):**
> evidence-aware resume — changed app files, contracts or browsers invalidate the
> verification they affect — plus scoped browser access and independent checks for
> every mutation.
> **New in [v0.7.0](https://github.com/TueJon/webmcpify/releases/tag/v0.7.0):**
> a host-side mutation journal records dispatch and settlement durably, serializes
> cooperating verification runners with an OS lock, and retains interrupted
> `started` entries for reconciliation. It does not guarantee exactly-once effects.

## How it works

Expand Down
14 changes: 10 additions & 4 deletions skills/webmcpify/references/integrate.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Use the vendored runtime (`runtime.md`). Tools live in a dedicated module per ap
(e.g. `src/webmcp/tools.ts`), decoupled from components:

```ts
import { createToolScope, dispatchAndWait } from './webmcpify';
import { createToolScope, dispatchAndWait, singleFlight } from './webmcpify';

export const searchTicketsTool = {
name: 'search_tickets',
Expand All @@ -91,14 +91,20 @@ export const searchTicketsTool = {
required: ['query'],
},
annotations: { readOnlyHint: true, untrustedContentHint: true, consequentialHint: false },
async execute(input: Record<string, unknown>) {
execute: singleFlight(async (input: Record<string, unknown>, options?: ToolExecuteCallbackOptions) => {
const q = String(input.query ?? '').trim();
if (!q) return 'ERROR: `query` must be a non-empty string.';
return dispatchAndWait('webmcp:search_tickets', { query: q });
},
return dispatchAndWait('webmcp:search_tickets', { query: q }, 10000, options?.signal);
}),
};
```

Execution options must reach the bridge unchanged through wrappers; use
`options?.signal` for legacy browsers that omit the second argument. Registration
abort removes availability; it is separate from execution cancellation. Cancelled
or timed-out actions have an unknown outcome: inspect state before any manual retry,
and never automatically retry an application tool. See [dated compatibility](native-compatibility.md).

> **Native I/O compat** — `getTools()` may return `inputSchema` as a string on older Chrome or an object on current implementations — handle both (`typeof === 'string' ? JSON.parse : id`). Current `executeTool` takes an object; Chrome 150 needs `JSON.stringify(args)`, so use the capability-probe adapter from the verification template rather than retrying a real tool. For `validate:true`, register with `inputSchema` only. Runner LLM envelope: `const raw=t.inputSchema; const schema=typeof raw==='string'?JSON.parse(raw):raw??{type:'object',properties:{}}; const llmTool={function:{parameters:schema}}` — never pass `parameters` through WebMCP.

Key rules:
Expand Down
88 changes: 88 additions & 0 deletions skills/webmcpify/references/native-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Native compatibility — checked 2026-10-05

WebMCP is evolving. These are separate evidence levels; simulation results are
not native browser acceptance.

## Current primary documentation

The [CG draft](https://webmachinelearning.github.io/webmcp/) specifies callback
options containing an execution signal, optional input defaulting to `{}`, and
JSON serialization of successful imperative results. A callback returning `"done"`
therefore produces the JSON string text `"done"` at `executeTool`, including the
quotes; a callback object produces serialized JSON. Callback values and native
client results are different boundaries. Browser-owned `null` for declarative
navigation is separate from the runtime guard against absent imperative results.

The [Chrome imperative guide](https://developer.chrome.com/docs/ai/webmcp/imperative-api)
(updated 2026-09-21) documents execution cancellation via callback `{ signal }`,
Chrome 153 preserving in-flight executions when tools are unregistered, Chrome 155
deprecating JSON-string input in favor of object input, and the `debugging`
annotation from Chrome 156. These version claims are documentation, not proof
from the installed Chrome 150 run.

The [Chrome declarative guide](https://developer.chrome.com/docs/ai/webmcp/declarative-api)
(updated 2026-09-25) documents `agentInvoked` and `respondWith` after
`preventDefault()`. Activation/cancellation events target `document.modelContext`;
window event handlers are unsupported from Chrome 156. Declarative submission and
navigation are not covered by imperative simulation.

## Accepted native evidence: Chrome 150

Governor report: `/tmp/webmcpify-native-browser-governor-report.md`, run
2026-10-05T05:31:43.012Z, Chrome 150.0.7871.186, headed with WebMCP and
WebMCPTesting, temporary synthetic loopback fixture only:

| Probe | Observed result |
|---|---|
| Callback options | Second argument absent; no execution signal received. |
| Caller abort | Caller rejected with AbortError; delayed side effect still ran. |
| Registration abort during execution | Tool disappeared; caller rejected with UnknownError; callback continued and side effect ran. |
| Omitted execute input | TypeError requiring two arguments; callback was not invoked. |

These observations do not establish modern cancellation, omitted-input defaults,
or current-draft result serialization in Chrome 150. Use the existing side-effect-free
input capability probe to select object versus legacy JSON-string input; never
retry a real application tool to discover compatibility.

## Accepted native evidence: Chrome Beta 156

Governor evidence: `/tmp/webmcpify-beta-governor-evidence.json`, run
2026-10-05T05:38:51.840Z, Chrome Beta 156.0.8078.4, headless with WebMCP and
WebMCPTesting, temporary synthetic loopback fixture only. All five probes pass:

| Probe | Observed result |
|---|---|
| Input shape | Object input invoked the callback exactly once. |
| Callback options | Second argument contained an AbortSignal. |
| Caller abort | Started callback received the signal; caller rejected with AbortError; cooperative handler suppressed the delayed side effect. |
| Registration abort during execution | Tool disappeared; already-started callback completed, side effect ran, and caller fulfilled. |
| Omitted execute input | Callback invoked once with `{}`. |

Successful imperative results arrived as serialized JSON strings: the callback
object `{ accepted: true }` produced the string `{"accepted":true}`. `unregisterTool`
was absent; removal was tested only through the registration AbortSignal, not a
distinct unregister operation. These five probes do not establish native WPT,
declarative submission/navigation, or debugging-annotation conformance.

## Local runtime and verification boundaries

Forward `options?.signal` unchanged through wrappers to the fourth argument:

```ts
execute: singleFlight((input, options?: ToolExecuteCallbackOptions) =>
dispatchAndWait('webmcp:save', input, 10000, options?.signal)),
```

The bridge handles preabort without dispatch, cooperative cancellation and cleanup.
Handlers must pass the dispatched signal to asynchronous work and check it before
committing state. Cancellation/timeout leaves an unknown outcome when handlers or
servers continue; inspect state before a manual retry. Registration disposal removes
availability and is not an execution-cancellation mechanism.

`tests/parity.test.mjs` compares JS and transpiled TS client runtime behavior;
`tests/workbench.test.mjs` tests imperative simulation (default input, serialized
returns and execution/registration separation). `tests/workbench-browser.mjs` tests
the Workbench UI using simulation, not native API conformance. Mutation-journal
checks in `tests/manifest-lock.test.mjs` cover host-side durable dispatch/settlement,
locking and interrupted entries; neither journaling nor cancellation guarantees
exactly-once effects. No continuation-token support is implemented here.
20 changes: 14 additions & 6 deletions skills/webmcpify/references/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ What it provides:
|---|---|
| `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). Missing WebMCP remains a safe no-op, but development builds warn once with secure-origin/Chrome/flag diagnostics. Validates contracts BEFORE registering; wraps every imperative `execute()` so bare `null`/`undefined` becomes a structured error; **rolls back the whole scope** on any registration 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 |
| `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). Missing WebMCP remains a safe no-op, but development builds warn once with secure-origin/Chrome/flag diagnostics. Validates contracts BEFORE registering; forwards execution options unchanged and wraps every imperative `execute()` so bare `null`/`undefined` becomes a structured error; **rolls back the whole scope** on any registration 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?, callerSignal?)` | Bridges `execute()` to the app's own event/state flow. The dispatched detail carries `requestId` plus `signal` — an AbortSignal aborted on timeout or caller cancellation; 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
Expand All @@ -35,13 +35,21 @@ isn't `'production'`; unbundled projects default to off — pass `{ validate: tr
during development.

Result note: application tools must return a JSON-safe value. Bare `null` and
`undefined` are forbidden for imperative tools because current Chrome clients can
`undefined` are forbidden for imperative tools because browser clients can
surface them as ambiguous success, the string `"null"`, or an execution-context
failure. The runtime converts an accidental absent result to
`{ ok: false, error: "…" }` (and logs it in development), but integrations should
return the intended structured result themselves. A browser-owned `null` from a
navigating declarative form is a different execution path and remains valid.

Cancellation note (2026-10-05): pass `options?.signal` as the fourth bridge
argument, including inside `singleFlight`. A preaborted caller dispatches nothing.
Every settlement removes the completion/caller listeners and timeout. Cancellation
signals cooperative handlers and reports an unknown outcome; it cannot prevent side
effects in handlers that ignore the signal. Never automatically retry. Scope disposal
only aborts registration; the current draft and Chrome guide (153+) describe preserving started invocations,
but installed Chrome 150 does not prove that behavior. See [compatibility](native-compatibility.md).

## The completion contract (the part integrators get wrong)

`dispatchAndWait` resolves when the component fires `tool-completion-<requestId>`
Expand All @@ -53,7 +61,7 @@ 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
availability gate, **(3)** single-flight, **(4)** timeout/caller-cancellation coordination via
`detail.signal`, **(5)** unmount cancellation.

```tsx
Expand All @@ -72,8 +80,8 @@ useEffect(() => {
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
const found = await runSearch(query, { signal }); // (4) the runtime aborts this signal on timeout or caller cancellation
if (signal?.aborted) return; // (4) cancelled/timed out — runtime already answered; no late commits
pending.current = { requestId, count: found.length };
setResults(found); // commit → the effect below confirms
} catch (err) {
Expand Down
36 changes: 34 additions & 2 deletions skills/webmcpify/templates/webmcp-workbench.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
throw new TypeError('Simulated WebMCP tools need a name and execute function.');
}
if (tools.has(tool.name)) throw new Error(`Tool "${tool.name}" is already registered.`);
if (options.signal?.aborted) throw options.signal.reason;
tools.set(tool.name, { public: { ...tool, execute: undefined }, execute: tool.execute });
options.signal?.addEventListener('abort', () => {
if (tools.delete(tool.name)) notify();
Expand All @@ -74,11 +75,42 @@
async getTools() {
return Array.from(tools.values(), ({ public: tool }) => ({ ...tool }));
},
async executeTool(tool, input) {
// Simulation of current imperative cancellation, not native browser evidence.
async executeTool(tool, input = {}, options = {}) {
if (options.signal?.aborted) throw options.signal.reason;
const registered = tools.get(tool?.name);
if (!registered) throw new Error(`Tool "${tool?.name ?? 'unknown'}" is not registered.`);
const args = typeof input === 'string' ? JSON.parse(input) : input;
return registered.execute(args ?? {});
const execution = new AbortController();
return new Promise((resolve, reject) => {
let settled = false;
const cleanup = () => options.signal?.removeEventListener('abort', onAbort);
const onAbort = () => {
if (settled) return;
settled = true;
cleanup();
execution.abort(options.signal.reason);
reject(options.signal.reason);
};
options.signal?.addEventListener('abort', onAbort, { once: true });
Promise.resolve().then(() => {
if (settled) return;
return registered.execute(args ?? {}, { signal: execution.signal });
}).then((result) => {
if (settled) return;
// Native executeTool returns the JSON serialization, including strings.
const serialized = JSON.stringify(result);
if (serialized === undefined) throw new TypeError('Tool result is not JSON serializable.');
settled = true;
cleanup();
resolve(serialized);
}).catch((error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
});
});
},
};
return context;
Expand Down
Loading
Loading