diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 53fa651..1033a97 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "name": "webmcpify", "source": "./", "description": "WebMCP agent skill for curated core coverage or route-by-route parity — inventory an existing web app, integrate approved tools, then verify and heal them in a real browser.", - "version": "0.5.0" + "version": "0.5.1" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f614366..e9eb59b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "webmcpify", "description": "WebMCP agent skill for curated core coverage or route-by-route parity — inventory an existing web app, integrate approved tools, then verify and heal them in a real browser.", - "version": "0.5.0", + "version": "0.5.1", "author": { "name": "Jonas Tüchler" } diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index f614366..e9eb59b 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "webmcpify", "description": "WebMCP agent skill for curated core coverage or route-by-route parity — inventory an existing web app, integrate approved tools, then verify and heal them in a real browser.", - "version": "0.5.0", + "version": "0.5.1", "author": { "name": "Jonas Tüchler" } diff --git a/CHANGELOG.md b/CHANGELOG.md index fb2b771..dbbcf13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ reconstruct them from git history. ## [Unreleased] +## [0.5.1] — 2026-09-14 + +- Updated verification for the CG draft and Chrome's 2026-09-11 execution + contract: `executeTool` now receives a JavaScript object, while Chrome 150's + deprecated JSON-string input remains supported through a temporary, + side-effect-free capability probe. Real application tools are invoked exactly + once, so a post-mutation handler failure cannot trigger a compatibility retry. +- Kept current and older browser evidence comparable: the harness and visual + Workbench accept object or stringified enumerated schemas, and the native proof + records whether the browser exposes `consequentialHint` instead of pinning the + Chrome 150 omission. + ## [0.5.0] — 2026-09-09 - Added an agent-launched, dependency-free visual WebMCP Workbench with the @@ -58,4 +70,5 @@ reconstruct them from git history. [0.4.0]: https://github.com/TueJon/webmcpify/releases/tag/v0.4.0 [0.5.0]: https://github.com/TueJon/webmcpify/compare/v0.4.0...v0.5.0 -[Unreleased]: https://github.com/TueJon/webmcpify/compare/v0.5.0...HEAD +[0.5.1]: https://github.com/TueJon/webmcpify/compare/v0.5.0...v0.5.1 +[Unreleased]: https://github.com/TueJon/webmcpify/compare/v0.5.1...HEAD diff --git a/README.md b/README.md index 1e17b4c..42e03ad 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Browser AI agents (Gemini in Chrome, extensions, assistive tech) are learning to call structured page tools instead of scraping the DOM. WebMCP is the emerging standard for that, co-authored by Google and Microsoft engineers, in origin trial since Chrome 149. Making an app agent-ready by hand means reading a spec that is -still moving (the API surface changed twice during the trial), learning tool-design +still moving (the API surface has changed repeatedly during the trial), learning tool-design conventions, and building a verification setup — webmcpify packages all of that into one command for your coding agent. @@ -156,8 +156,9 @@ estimate, not a commitment): production exposure needs an [origin-trial token](https://developer.chrome.com/origintrials/), local development needs `chrome://flags/#enable-webmcp-testing`. The API surface has already changed during the trial (testing API removed 2026-07; `navigator` → `document`) — webmcpify -isolates that churn in one vendored file, probes for the current -enumeration/execution surface, and treats Google's live +isolates that churn in one vendored file, and its verification surfaces probe +whether the browser uses current object input or Chrome 150's legacy JSON-string +input without retrying real tools. It treats Google's live [modern-web-guidance](https://github.com/GoogleChrome/modern-web-guidance) as the source of current best practices at integration time. diff --git a/gemini-extension.json b/gemini-extension.json index f614366..e9eb59b 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,7 +1,7 @@ { "name": "webmcpify", "description": "WebMCP agent skill for curated core coverage or route-by-route parity — inventory an existing web app, integrate approved tools, then verify and heal them in a real browser.", - "version": "0.5.0", + "version": "0.5.1", "author": { "name": "Jonas Tüchler" } diff --git a/package-lock.json b/package-lock.json index 444b9b9..9416de6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "webmcpify", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "webmcpify", - "version": "0.5.0", + "version": "0.5.1", "license": "MIT", "devDependencies": { "@playwright/test": "^1.54.0", diff --git a/package.json b/package.json index b903854..9c61774 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "WebMCP agent skill for curated core coverage or route-by-route parity — inventory an existing web app, integrate approved tools, then verify and heal them in a real browser.", "type": "module", "license": "MIT", - "version": "0.5.0", + "version": "0.5.1", "publisher": "TueJon", "repository": { "url": "https://github.com/TueJon/webmcpify" diff --git a/proof/demo/run.mjs b/proof/demo/run.mjs index 7600fef..ff5b6d9 100644 --- a/proof/demo/run.mjs +++ b/proof/demo/run.mjs @@ -12,6 +12,36 @@ const artifacts = join(repo, 'proof', 'artifacts'); const sourceVideo = join(artifacts, 'webmcpify-proof-source.webm'); const delay = (ms) => new Promise((resolve) => setTimeout(resolve, mode === 'record' ? ms : 20)); +async function detectExecuteInputMode(page) { + return page.evaluate(async () => { + const controller = new AbortController(); + const name = `webmcpify_input_probe_${crypto.randomUUID().replaceAll('-', '')}`; + let calls = 0; + await document.modelContext.registerTool({ + name, + description: 'Side-effect-free verification of the browser executeTool input contract.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + async execute() { calls += 1; return 'webmcpify-input-probe'; }, + }, { signal: controller.signal }); + try { + const tool = (await document.modelContext.getTools()).find((candidate) => candidate.name === name); + if (!tool) throw new Error('WebMCP input-contract probe did not register'); + try { + await document.modelContext.executeTool(tool, {}); + if (calls !== 1) throw new Error('Object-input probe did not execute exactly once'); + return 'object'; + } catch (error) { + if (calls !== 0) throw error; + await document.modelContext.executeTool(tool, '{}'); + if (calls !== 1) throw new Error('JSON-string input probe did not execute exactly once'); + return 'json-string'; + } + } finally { + controller.abort(); + } + }); +} + const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }; const server = createServer(async (request, response) => { try { @@ -59,6 +89,7 @@ try { })); assert.deepEqual(nativeSurface, { context: 'object', enumerate: 'function', execute: 'function' }); assert.equal((await page.evaluate(() => document.modelContext.getTools())).length, 0); + const executeInputMode = await detectExecuteInputMode(page); await page.evaluate(() => { window.proof.phase('inventory', 'Inventory the existing app'); @@ -110,12 +141,10 @@ try { assert(tool); assert.equal(tool.annotations.readOnlyHint, false); assert.equal(tool.annotations.untrustedContentHint, false); - assert.equal( - tool.annotations.consequentialHint, - undefined, - 'Chrome compatibility changed: update the dated consequentialHint evidence and harness expectation', - ); - assert.deepEqual(JSON.parse(tool.inputSchema), { + if (tool.annotations.consequentialHint !== undefined) { + assert.equal(tool.annotations.consequentialHint, false); + } + assert.deepEqual(typeof tool.inputSchema === 'string' ? JSON.parse(tool.inputSchema) : tool.inputSchema, { type: 'object', properties: { category: { type: 'string', enum: ['all', 'feature', 'fix'] } }, required: ['category'], @@ -127,10 +156,11 @@ try { await delay(2800); const before = await page.locator('article:visible').count(); - const result = await page.evaluate(async () => { + const result = await page.evaluate(async (inputMode) => { const registered = (await document.modelContext.getTools()).find((item) => item.name === 'set_release_filter'); - return document.modelContext.executeTool(registered, JSON.stringify({ category: 'fix' })); - }); + const args = { category: 'fix' }; + return document.modelContext.executeTool(registered, inputMode === 'object' ? args : JSON.stringify(args)); + }, executeInputMode); const after = await page.locator('article:visible').count(); assert.equal(before, 4); assert.equal(after, 2); @@ -138,18 +168,20 @@ try { await page.evaluate(() => { window.proof.check('valid call changed visible UI: 4 → 2'); window.proof.line('EXECUTE category=fix → 2 release notes visible'); }); await delay(3300); - const invalidResult = await page.evaluate(async () => { + const invalidResult = await page.evaluate(async (inputMode) => { const registered = (await document.modelContext.getTools()).find((item) => item.name === 'set_release_filter'); - return document.modelContext.executeTool(registered, JSON.stringify({ category: 'private' })); - }); + const args = { category: 'private' }; + return document.modelContext.executeTool(registered, inputMode === 'object' ? args : JSON.stringify(args)); + }, executeInputMode); assert.match(invalidResult, /^ERROR:/); assert.equal(await page.locator('article:visible').count(), 2); await page.evaluate(() => { window.proof.check('invalid enum returned bounded error; UI unchanged'); window.proof.line('INVALID category=private → ERROR (no UI side effect)'); }); await delay(3000); - await page.evaluate(async () => { + await page.evaluate(async (inputMode) => { const registered = (await document.modelContext.getTools()).find((item) => item.name === 'set_release_filter'); - return document.modelContext.executeTool(registered, JSON.stringify({ category: 'all' })); - }); + const args = { category: 'all' }; + return document.modelContext.executeTool(registered, inputMode === 'object' ? args : JSON.stringify(args)); + }, executeInputMode); assert.equal(await page.locator('article:visible').count(), 4); await page.evaluate(() => { window.proof.check('cleanup restored all notes'); window.proof.line('CLEANUP category=all → fixture restored'); }); await delay(3000); @@ -177,7 +209,8 @@ try { await rename(generated, sourceVideo); console.log(`recorded ${sourceVideo}`); } - console.log(`proof verified in Chrome ${chromeVersion}: native getTools/executeTool, schema, annotations (consequentialHint omitted by this build), UI delta, bounded invalid input, cleanup`); + const consequentialState = tool.annotations.consequentialHint === undefined ? 'consequentialHint omitted' : 'consequentialHint exposed'; + console.log(`proof verified in Chrome ${chromeVersion}: native getTools/executeTool (${executeInputMode} input), schema, annotations (${consequentialState}), UI delta, bounded invalid input, cleanup`); } finally { await browser?.close().catch(() => {}); await new Promise((resolve) => server.close(resolve)); diff --git a/release/v0.5.1.md b/release/v0.5.1.md new file mode 100644 index 0000000..8be2a18 --- /dev/null +++ b/release/v0.5.1.md @@ -0,0 +1,22 @@ +# webmcpify v0.5.1 + +This patch keeps native verification aligned with the WebMCP CG draft and +Chrome's September 11 execution update. + +- Current browsers receive a JavaScript object in `executeTool()`; Chrome 150's + deprecated JSON-string input remains supported. +- A temporary, side-effect-free probe chooses the browser input mode before any + application tool runs. Real tools are never retried, preventing duplicate + mutations when a handler fails after changing state. +- The verification template, visual Workbench, ambient types, and reproducible + native proof accept both the current object-shaped and older stringified + `inputSchema` evidence. +- Native proof records whether `consequentialHint` is exposed instead of assuming + Chrome 150's omission remains universal. + +Primary compatibility references: + +- https://webmachinelearning.github.io/webmcp/ +- https://developer.chrome.com/docs/ai/webmcp/imperative-api +- https://github.com/web-platform-tests/wpt/commit/1a21db90adf8a264370ad806ed761f39e1d435a0 +- https://github.com/web-platform-tests/wpt/commit/c94abb33b8b0d162ab33f259c1dc44724d42f3d3 diff --git a/skill.json b/skill.json index f322f83..177506e 100644 --- a/skill.json +++ b/skill.json @@ -1,6 +1,6 @@ { "name": "webmcpify", - "version": "0.5.0", + "version": "0.5.1", "description": "WebMCP agent skill for curated core coverage or route-by-route parity — inventory an existing web app, integrate approved tools, then verify and heal them in a real browser.", "license": "MIT", "author": { diff --git a/skills/webmcpify/references/client.md b/skills/webmcpify/references/client.md index e40026e..1ada8e5 100644 --- a/skills/webmcpify/references/client.md +++ b/skills/webmcpify/references/client.md @@ -1,6 +1,6 @@ # ChatGPT client reality — Site tools -Checked 2026-08-31 against the official OpenAI documentation: +Checked 2026-09-14 against the official OpenAI documentation: . Re-check that page before publishing or relying on model/workspace availability; this UI is moving independently of the WebMCP draft and Chrome implementation. diff --git a/skills/webmcpify/references/integrate.md b/skills/webmcpify/references/integrate.md index 1f80993..c51f656 100644 --- a/skills/webmcpify/references/integrate.md +++ b/skills/webmcpify/references/integrate.md @@ -98,7 +98,7 @@ export const searchTicketsTool = { }; ``` -> **Native I/O compat** — `getTools()` returns `inputSchema` stringified on native Chrome but as object in stubs — handle both (`typeof === 'string' ? JSON.parse : id`). `executeTool` needs `JSON.stringify(args)` on native lag, object per spec. 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. +> **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: - **Annotations describe risk; they do not enforce it.** Use diff --git a/skills/webmcpify/references/verify.md b/skills/webmcpify/references/verify.md index 267b45a..b0d41b8 100644 --- a/skills/webmcpify/references/verify.md +++ b/skills/webmcpify/references/verify.md @@ -36,7 +36,9 @@ const tools = await mc.getTools(); Contract facts that generated assertions MUST respect: -- Enumerated `inputSchema` may be stringified (Chrome native lag) or object (spec/stub) — `typeof === "string" ? JSON.parse(s) : s ?? {type:'object',properties:{}}` before comparing. +- Enumerated `inputSchema` may be stringified (older Chrome) or object (current + implementations/spec stubs) — `typeof === "string" ? JSON.parse(s) : s ?? + {type:'object',properties:{}}` before comparing. - The CG draft and Chrome docs define `consequentialHint`, but Chrome 150 accepted it at registration and omitted it from `getTools()`. Assert the expected value when the enumerated property exists; otherwise record a dated browser-compatibility @@ -44,13 +46,13 @@ Contract facts that generated assertions MUST respect: native propagation from an absent field. - `executeTool(...)` resolves to a **JSON string result, or `null` when the execution navigated** — stub may return object; normalize via `typeof` before `toMatch`. -- **Native `executeTool` needs JSON-string arguments even for tools with OMITTED - `inputSchema`** (zero-param): `executeTool(tool, '{}')`, not `executeTool(tool, {})`. - The harness uses an explicit adapter mode: stub `tool.execute(object)` or - spec-shaped `mc.executeTool(tool, object)` when `mc.__webmcpStubObjectMode` is set, - native `mc.executeTool(tool, JSON.stringify(args))` otherwise — preserved when - wrapped and for omitted schemas. No retry: a handler `TypeError` after mutation - must never trigger a second execution. +- The current CG draft and Chrome documentation use a JavaScript object for + `executeTool` input. Chrome 150 still requires a JSON string, including for + tools with omitted `inputSchema`, and Chrome documents string input as + deprecated from 155. The harness registers and executes one temporary, + side-effect-free probe tool to select `object` or `json-string`, then invokes + every application tool exactly once in that mode. Never retry a real tool after + an exception: its handler may already have mutated state. - 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 @@ -71,7 +73,7 @@ Contract facts that generated assertions MUST respect: When an LLM agent loop consumes `getTools` -> OpenAI-compatible `tools` -> `executeTool`: -- `inputSchema` is stringified on native — do `typeof s === "string" ? JSON.parse(s) : s ?? {type:'object',properties:{}}` before sending `parameters: ` to the LLM; otherwise `400 'tools.0.function.parameters must be object'` -> loop 502s. +- Native `inputSchema` may be stringified on older builds or object-shaped on current builds — do `typeof s === "string" ? JSON.parse(s) : s ?? {type:'object',properties:{}}` before sending `parameters: ` to the LLM; otherwise `400 'tools.0.function.parameters must be object'` -> loop 502s. - Missing `tools` with `tool_choice:none` surfaces as `tool_use_failed` — don't force `tool_choice`; use `disable_tool_validation: true` only when needed. - Result may be stringified JSON (`"{\"ok\":true}"`) on native or object on stub — `typeof r === "string" ? try{JSON.parse(r)}catch{ r } : r` and `resultOk` helpers must handle both. diff --git a/skills/webmcpify/templates/webmcp-compat.js b/skills/webmcpify/templates/webmcp-compat.js index ae9b60d..717376f 100644 --- a/skills/webmcpify/templates/webmcp-compat.js +++ b/skills/webmcpify/templates/webmcp-compat.js @@ -25,10 +25,11 @@ * * Full text: https://github.com/TueJon/webmcpify/blob/main/LICENSE * - * Shared string/object compat helpers for the native-vs-stub I/O divergence. + * Shared string/object compat helpers for the native transition from JSON + * strings to JavaScript objects. * Used by templates/webmcp.spec.ts (inlined inside page.evaluate for the * browser-boundary parts) and tests/compat.test.mjs — single source of truth. - * Collapse the string branch when Chrome aligns with the spec (#278/#279). + * Remove the JSON-string branch after Chrome 154 is no longer supported. */ export function parseInputSchema(raw) { @@ -36,14 +37,13 @@ export function parseInputSchema(raw) { } /** - * Explicit adapter mode — no heuristics, no retry: + * Explicit adapter mode — capability-probe first, no retry of a real tool: * - stub via direct tool.execute(object) — headless-era stub - * - stub via mc.executeTool(tool, object) — spec-shaped stub (RegisteredTool - * has no .execute); distinguished by explicit mc.__webmcpStubObjectMode set - * by the stub harness - * - native mc.executeTool(tool, JSON string) — Chrome native (also when wrapped - * or with omitted inputSchema) - * A handler TypeError never double-executes; collapse when spec norms. + * - current native/spec mc.executeTool(tool, object) + * - legacy Chrome mc.executeTool(tool, JSON string) + * The harness determines the native mode with a temporary side-effect-free + * tool before invoking application tools. A handler failure never triggers a + * retry, so a mutation cannot execute twice. */ export function isStubTool(tool) { return typeof tool?.execute === 'function'; diff --git a/skills/webmcpify/templates/webmcp-workbench.js b/skills/webmcpify/templates/webmcp-workbench.js index 58cda05..87c67ad 100644 --- a/skills/webmcpify/templates/webmcp-workbench.js +++ b/skills/webmcpify/templates/webmcp-workbench.js @@ -86,7 +86,7 @@ function installContext(doc) { const nativeContext = doc.modelContext ?? global.navigator?.modelContext; - if (nativeContext) return { context: nativeContext, evidence: 'native' }; + if (nativeContext) return { context: nativeContext, evidence: 'native', inputMode: null }; const context = makeSimulationContext(doc); try { Object.defineProperty(doc, 'modelContext', { configurable: true, value: context }); @@ -94,7 +94,40 @@ doc.modelContext = context; } state.simulatedContext = context; - return { context, evidence: 'simulated' }; + return { context, evidence: 'simulated', inputMode: Promise.resolve('object') }; + } + + async function detectExecuteInputMode(installed) { + if (installed.inputMode) return installed.inputMode; + installed.inputMode = (async () => { + const context = installed.context; + const controller = new AbortController(); + const name = `webmcpify_input_probe_${global.crypto.randomUUID().replaceAll('-', '')}`; + let calls = 0; + await context.registerTool({ + name, + description: 'Side-effect-free verification of the browser executeTool input contract.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + async execute() { calls += 1; return 'webmcpify-input-probe'; }, + }, { signal: controller.signal }); + try { + const tool = (await context.getTools()).find((candidate) => candidate.name === name); + if (!tool) throw new Error('WebMCP input-contract probe did not register.'); + try { + await context.executeTool(tool, {}); + if (calls !== 1) throw new Error('Object-input probe did not execute exactly once.'); + return 'object'; + } catch (error) { + if (calls !== 0) throw error; + await context.executeTool(tool, '{}'); + if (calls !== 1) throw new Error('JSON-string input probe did not execute exactly once.'); + return 'json-string'; + } + } finally { + controller.abort(); + } + })(); + return installed.inputMode; } function removeSimulatedContext(doc, installed) { @@ -366,11 +399,10 @@ let args; try { args = readArguments(); + const inputMode = await detectExecuteInputMode(installed); const result = tool._declarativeForm ? await executeDeclarative(tool, args) - : installed.context.__webmcpStubObjectMode - ? await installed.context.executeTool(tool, args) - : await installed.context.executeTool(tool, JSON.stringify(args)); + : await installed.context.executeTool(tool, inputMode === 'object' ? args : JSON.stringify(args)); ui.result.textContent = json(result); const duration = Math.round(performance.now() - started); ui.resultMeta.textContent = `Succeeded · ${duration} ms`; @@ -605,6 +637,7 @@ formatResult: json, normalizeExpected, createSimulationContext: makeSimulationContext, + detectExecuteInputMode, }; global.WebMCPifyWorkbench = api; if (global.__WEBMCPIFY_WORKBENCH__) api.start(global.__WEBMCPIFY_WORKBENCH__); diff --git a/skills/webmcpify/templates/webmcp.d.ts b/skills/webmcpify/templates/webmcp.d.ts index 9a0f6a1..34247ce 100644 --- a/skills/webmcpify/templates/webmcp.d.ts +++ b/skills/webmcpify/templates/webmcp.d.ts @@ -60,15 +60,15 @@ interface ModelContext extends EventTarget { */ getTools(options?: { fromOrigins?: string[] }): Promise; /** - * Agent/test execution surface in the CG draft; Chrome's origin-trial input - * and result serialization still differ from spec-shaped stubs. + * Agent/test execution surface in the CG draft. Current Chrome accepts an + * object; Chrome 150's JSON-string input remains in the verification adapter. */ executeTool?( tool: RegisteredTool, - inputJson: string | object, // native: JSON string (wrapped-safe, omitted-schema-safe); stub .execute or stub-object mc.executeTool: object — discriminated by explicit capability (tool.execute / mc.__webmcpStubObjectMode), no retry; collapse when spec norms + inputObject?: unknown, options?: { signal?: AbortSignal }, ): Promise; - /** Explicit stub-object capability for spec-shaped mc.executeTool(object) — set by stub harness, never native. */ + /** Internal simulation capability used by the vendored Workbench. */ __webmcpStubObjectMode?: boolean; } @@ -92,7 +92,7 @@ interface ModelContextTool { annotations?: ModelContextToolAnnotations; } -/** Shape returned by getTools(). Native returns STRINGIFIED JSON Schema; stubs may return object — handle both. */ +/** Shape returned by getTools(). Older Chrome may stringify JSON Schema — handle both forms. */ interface RegisteredTool { name: string; title?: string; diff --git a/skills/webmcpify/templates/webmcp.spec.ts b/skills/webmcpify/templates/webmcp.spec.ts index 2cf3a36..60f59b9 100644 --- a/skills/webmcpify/templates/webmcp.spec.ts +++ b/skills/webmcpify/templates/webmcp.spec.ts @@ -52,6 +52,8 @@ const PROFILE_DIR = requiredEnv('WEBMCP_PROFILE_DIR'); let context: BrowserContext; let page: Page; +type ExecuteInputMode = 'object' | 'json-string'; +let executeInputMode: ExecuteInputMode | undefined; test.beforeAll(async () => { context = await chromium.launchPersistentContext(PROFILE_DIR, { @@ -66,7 +68,7 @@ test.afterAll(async () => { await context.close(); }); -/** Enumerate registered tools; native returns STRINGIFIED JSON Schema, stubs may return object — handle both. */ +/** Enumerate registered tools; older native builds may stringify JSON Schema while current builds return objects — handle both. */ async function listTools(p: Page): Promise< Array<{ name: string; @@ -85,14 +87,55 @@ async function listTools(p: Page): Promise< }); } +/** + * Probe the browser contract with a temporary, side-effect-free tool. Chrome + * 150 requires JSON strings; the current CG draft and Chrome docs use objects. + * Real application tools are never retried to avoid duplicating mutations. + */ +async function detectExecuteInputMode(p: Page): Promise { + return p.evaluate(async () => { + const mc = (document as any).modelContext; + if ((mc as any)?.__webmcpStubObjectMode) return 'object'; + if (!mc?.registerTool || !mc?.getTools || !mc?.executeTool) { + throw new Error('No complete document.modelContext execution surface for capability probe'); + } + const controller = new AbortController(); + const name = `webmcpify_input_probe_${crypto.randomUUID().replaceAll('-', '')}`; + const probeState = { calls: 0 }; + await mc.registerTool({ + name, + description: 'Side-effect-free verification of the browser executeTool input contract.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + async execute() { probeState.calls += 1; return 'webmcpify-input-probe'; }, + }, { signal: controller.signal }); + try { + const tool = (await mc.getTools()).find((candidate: { name: string }) => candidate.name === name); + if (!tool) throw new Error('WebMCP input-contract probe did not register'); + try { + await mc.executeTool(tool, {}); + if (Number(probeState.calls) !== 1) throw new Error('Object-input probe did not execute exactly once'); + return 'object'; + } catch (error) { + if (Number(probeState.calls) !== 0) throw error; + await mc.executeTool(tool, '{}'); + if (Number(probeState.calls) !== 1) throw new Error('JSON-string input probe did not execute exactly once'); + return 'json-string'; + } + } finally { + controller.abort(); + } + }); +} + /** * 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 { + executeInputMode ??= await detectExecuteInputMode(p); return p.evaluate( - async ({ name, args }) => { + async ({ name, args, inputMode }) => { // inline helpers: page.evaluate cannot close over outer imports — keep in sync with webmcp-compat.js const normalizeResult = (r: unknown) => (r == null ? null : typeof r === 'string' ? (r as string) : JSON.stringify(r)); const mc = (document as any).modelContext; @@ -100,19 +143,21 @@ async function executeTool(p: Page, name: string, args: object): Promise t.name === name); if (!tool) throw new Error(`tool ${name} is not registered`); - // Explicit adapter mode — no heuristics, no retry: + // Explicit adapter mode — a harmless probe chose the native shape. // - tool.execute(object): headless-era stub // - mc.__webmcpStubObjectMode + mc.executeTool(tool, object): spec-shaped stub (enumerated tool has no .execute) - // - otherwise native mc.executeTool(tool, JSON string): Chrome (wrapped-safe, omitted-schema-safe) + // - current native/spec mc.executeTool(tool, object) + // - legacy Chrome mc.executeTool(tool, JSON string) + // Real tools are never retried: a handler failure may follow a mutation. if (typeof tool?.execute === 'function') return normalizeResult(await tool.execute(args)); if (mc.executeTool) { - if ((mc as any).__webmcpStubObjectMode) return normalizeResult(await mc.executeTool(tool, args)); - return normalizeResult(await mc.executeTool(tool, JSON.stringify(args))); + const input = inputMode === 'object' ? args : JSON.stringify(args); + return normalizeResult(await mc.executeTool(tool, input)); } } throw new Error('No document.modelContext execution surface — insecure origin, headless/wrong Chrome, reused profile, or missing flag'); }, - { name, args }, + { name, args, inputMode: executeInputMode }, ); } @@ -141,6 +186,11 @@ test('verification origin is secure and WebMCP is available', async () => { probe.hasDocumentModelContext, 'Use current headed Chrome, a dedicated profile, and enable chrome://flags/#enable-webmcp-testing', ).toBe(true); + executeInputMode = await detectExecuteInputMode(page); + test.info().annotations.push({ + type: 'webmcp-compatibility', + description: `executeTool input mode: ${executeInputMode}`, + }); }); // ── Generated per manifest tool ────────────────────────────────────────────── diff --git a/tests/compat.test.mjs b/tests/compat.test.mjs index caea491..9bea645 100644 --- a/tests/compat.test.mjs +++ b/tests/compat.test.mjs @@ -18,9 +18,9 @@ function loadTemplateExecuteTool() { const blockStart = specSrc.indexOf('async function executeTool'); const blockEnd = specSrc.indexOf('async function waitForTool'); const block = specSrc.slice(blockStart, blockEnd); - const sig = 'async ({ name, args }) => {'; + const sig = 'async ({ name, args, inputMode }) => {'; const sigAt = block.indexOf(sig); - const argMarker = '{ name, args },'; + const argMarker = '{ name, args, inputMode: executeInputMode },'; const endAt = block.lastIndexOf(argMarker); assert.ok(sigAt >= 0 && endAt > sigAt, 'executeTool page.evaluate callback not found in template'); let body = block.slice(sigAt + sig.length, endAt).replace(/[\s},]*$/, ''); @@ -31,8 +31,8 @@ function loadTemplateExecuteTool() { .replace(/\(document as any\)/g, '(document)') .replace(/\(mc as any\)/g, '(mc)') .replace(/\(r as string\)/g, '(r)'); - return (name, args, modelContext) => - new Function('name', 'args', 'document', `return (async () => {${body}})()`)(name, args, { modelContext }); + return (name, args, modelContext, inputMode = 'object') => + new Function('name', 'args', 'inputMode', 'document', `return (async () => {${body}})()`)(name, args, inputMode, { modelContext }); } test('LLM envelope: string|object/undefined inputSchema → object parameters', () => { @@ -60,14 +60,16 @@ test('webmcp.spec.ts page.evaluate is browser-serializable and heuristic-free', assert.ok(block.includes('const normalizeResult'), 'normalize helper defined inside evaluate'); assert.ok(!block.includes('[native code]'), 'provenance heuristic must not return (wrapped native breaks it)'); assert.ok(!block.match(/typeof tool\?*\.?inputSchema === 'string'/), 'schema-shape discriminator must not return (omitted schemas break it)'); - assert.ok(block.includes('JSON.stringify(args)'), 'native contract is JSON string'); + assert.ok(block.includes("inputMode === 'object' ? args : JSON.stringify(args)"), 'adapter supports current object and legacy string input'); assert.ok(!block.includes('e instanceof TypeError'), 'no TypeError retry — handler must not double-execute'); assert.ok(!block.match(/Failed to parse/), 'no exception-message matching — use explicit capability'); assert.ok(block.includes("typeof tool?.execute === 'function'"), 'stub discriminator via tool.execute'); assert.ok(block.includes('__webmcpStubObjectMode'), 'spec-shaped stub via explicit mc.__webmcpStubObjectMode'); + assert.match(specSrc, /async function detectExecuteInputMode/); + assert.match(specSrc, /Side-effect-free verification/); }); -test('template executeTool: native (unwrapped) with present string schema → string input', async () => { +test('template executeTool: Chrome 150 mode uses string input', async () => { const seen = []; const exec = loadTemplateExecuteTool(); const mc = { @@ -78,12 +80,12 @@ test('template executeTool: native (unwrapped) with present string schema → st return JSON.stringify({ ok: true, sku: 'abc' }); }, }; - const out = await exec('t', { q: 'hi' }, mc); + const out = await exec('t', { q: 'hi' }, mc, 'json-string'); assert.equal(out, JSON.stringify({ ok: true, sku: 'abc' })); assert.deepEqual(seen, ['string'], 'no wasted retry when the string succeeds'); }); -test('template executeTool: WRAPPED native with present string schema → string input (reviewer repro)', async () => { +test('template executeTool: wrapped Chrome 150 mode uses string input', async () => { const exec = loadTemplateExecuteTool(); const nativeImpl = async (tool, input) => { assert.equal(typeof input, 'string', 'wrapped native still needs the JSON string'); @@ -95,7 +97,7 @@ test('template executeTool: WRAPPED native with present string schema → string // instrumentation wrapper — stringifies as JS source, forwards to native executeTool: async (...a) => nativeImpl(...a), }; - assert.equal(await exec('t', { q: 'hi' }, mc), JSON.stringify({ ok: true })); + assert.equal(await exec('t', { q: 'hi' }, mc, 'json-string'), JSON.stringify({ ok: true })); }); test('template executeTool: WRAPPED native with OMITTED inputSchema → string input', async () => { @@ -109,7 +111,7 @@ test('template executeTool: WRAPPED native with OMITTED inputSchema → string i getTools: async () => [{ name: 't' }], // no inputSchema — Chrome 150 native zero-param tool executeTool: async (...a) => nativeImpl(...a), }; - assert.equal(await exec('t', {}, mc), JSON.stringify({ ok: true })); + assert.equal(await exec('t', {}, mc, 'json-string'), JSON.stringify({ ok: true })); }); test('template executeTool: stub via tool.execute with OMITTED inputSchema → object', async () => { @@ -200,19 +202,20 @@ test('template executeTool: spec-shaped stub respects explicit mode even when wr assert.equal(await exec('t', { q: 'hi' }, mc), JSON.stringify({ ok: true })); }); -test('template executeTool: native without flag still uses string even if inputSchema is object-shaped (spec stub not flagged)', async () => { +test('template executeTool: current native mode uses object input even with omitted schema', async () => { const exec = loadTemplateExecuteTool(); const mc = { - getTools: async () => [{ name: 't', inputSchema: { type: 'object' } }], // object but no flag → native assumed + getTools: async () => [{ name: 't' }], executeTool: async (tool, input) => { - assert.equal(typeof input, 'string', 'without explicit stub flag, native string contract applies'); + assert.equal(typeof input, 'object', 'current native contract receives an object'); + assert.deepEqual(input, { q: 'hi' }); return JSON.stringify({ ok: true }); }, }; assert.equal(await exec('t', { q: 'hi' }, mc), JSON.stringify({ ok: true })); }); -test('template executeTool: wrapped native handler TypeError after mutation — no double-execute', async () => { +test('template executeTool: current native handler failure after mutation is never retried', async () => { const exec = loadTemplateExecuteTool(); let calls = 0; let mutations = 0; const nativeImpl = async (tool, input) => { diff --git a/tests/workbench.test.mjs b/tests/workbench.test.mjs index 0ca469b..91f903b 100644 --- a/tests/workbench.test.mjs +++ b/tests/workbench.test.mjs @@ -10,7 +10,7 @@ const source = readFileSync(join(root, 'skills/webmcpify/templates/webmcp-workbe const runnerSource = readFileSync(join(root, 'skills/webmcpify/scripts/workbench.mjs'), 'utf8'); function loadApi(globals = {}) { - const sandbox = { globalThis: { ...globals }, Event }; + const sandbox = { globalThis: { ...globals }, Event, AbortController, DOMException }; sandbox.globalThis.globalThis = sandbox.globalThis; vm.runInNewContext(source, sandbox, { filename: 'webmcp-workbench.js' }); return sandbox.globalThis.WebMCPifyWorkbench; @@ -79,6 +79,31 @@ test('simulated context registers, enumerates, executes and aborts tools', async assert.deepEqual(events, ['toolchange', 'toolchange']); }); +test('execute input probe distinguishes current object and legacy JSON-string browsers', async () => { + const api = loadApi({ crypto: { randomUUID: () => 'probe-id' } }); + const makeContext = (mode) => { + let registered; + return { + async registerTool(tool, options) { + registered = tool; + options.signal.addEventListener('abort', () => { registered = undefined; }, { once: true }); + }, + async getTools() { return registered ? [{ name: registered.name }] : []; }, + async executeTool(tool, input) { + if (mode === 'json-string' && typeof input !== 'string') { + throw new DOMException('Failed to parse input arguments', 'UnknownError'); + } + return registered.execute(typeof input === 'string' ? JSON.parse(input) : input); + }, + }; + }; + for (const mode of ['object', 'json-string']) { + const installed = { context: makeContext(mode), evidence: 'native', inputMode: null }; + assert.equal(await api.detectExecuteInputMode(installed), mode); + assert.equal(await api.detectExecuteInputMode(installed), mode, 'the side-effect-free probe is cached'); + } +}); + test('simulated context rejects duplicate and malformed registrations', async () => { const api = loadApi(); const context = api.createSimulationContext({ dispatchEvent() {} });