diff --git a/skills/webmcpify/references/client.md b/skills/webmcpify/references/client.md index 8f6cc1f..5bb2771 100644 --- a/skills/webmcpify/references/client.md +++ b/skills/webmcpify/references/client.md @@ -1,6 +1,6 @@ # ChatGPT client reality — Site tools -Checked 2026-10-12 against the official OpenAI documentation: +Checked 2026-11-09 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. @@ -11,20 +11,23 @@ WebMCP draft and Chrome implementation. browser, open **Site tools** in the address bar and then **Available site tools** to inspect the page's registrations. This is the fastest client-side ground truth for the currently open page. -- Site tools are available to ChatGPT Work and Codex with GPT-5.6 Sol or GPT-6 Sol. GPT-5.6 Luna currently has WebMCP disabled. The desktop app must be - current, and availability still depends on rollout and the current page. -- Enterprise requires an administrator to allow Site tools through workspace - policy. Site tools remain unavailable in Edu; rollout and page availability vary. -- ChatGPT's built-in browser currently implements only a subset of WebMCP: it - discovers imperative tools registered with JavaScript in the top-level page, - not declarative form tools or tools registered inside iframes. This is a - client compatibility boundary, not evidence that either broader WebMCP form - or frame support is invalid in Chrome. +- Site tools require a supported model and access through the account or managed + workspace's browser policy. Browser settings and site permissions still apply. + The current page does not list model names or a workspace exclusion matrix; + removing the old matrix is not proof of access in a particular account. +- The official guide documents JavaScript registration and HTML form tools in + the top-level page and same-origin/cross-origin iframes. Each frame must have a + secure context; access is checked for the tool provider's origin. +- The guide permits omitting `exposedTo` for agent discovery. This is distinct + from in-page cross-origin `getTools`/`executeTool` access: preserve embedder + `tools` delegation, provider `exposedTo`, consumer `fromOrigins`, and the + explicit approval boundary in `security.md`. - Tools belong to the page that registered them. Closing or navigating away can make them unavailable; a client that tears down the page between turns cannot call that old registration. - Each invocation in the built-in browser receives a safety review. Normal access - and confirmation policies still apply. + and confirmation policies still apply. Website tool definitions and results + remain untrusted. No account/browser integration was tested for this update. Date every public summary and link the official page. Do not publish model names from memory. @@ -41,14 +44,13 @@ customer report or publishing a claim. 1. In the built-in browser, keep the page open and inspect **Site tools → Available site tools**. -2. Confirm the desktop app is current and the selected model is one the official - page currently lists. -3. Confirm the account/workspace is eligible; absence on the wrong model can look - exactly like a broken integration because the assistant falls back to ordinary - browser interaction. -4. Confirm the expected Site tool is registered imperatively in the top-level - page. A declarative form or iframe registration may be valid WebMCP but is not - currently discoverable as a ChatGPT Site tool. +2. Confirm the desktop app is current and the selected model supports Site tools; + check current official guidance rather than a historical model list. +3. Check account/workspace access and browser/site permissions, including + **Settings → Browser → Permissions → Enable site tools**. Fallback ordinary + browser interaction alone does not diagnose a broken integration. +4. Inspect the actual owning document's JavaScript registration or form metadata, + frame secure context, provider-origin access and document lifetime. 5. For the website itself, confirm a secure context and live registration. Use the headed-Chrome harness in `verify.md` for developer proof; ChatGPT availability and Chrome harness success are separate checks. diff --git a/skills/webmcpify/references/integrate.md b/skills/webmcpify/references/integrate.md index 179aa19..2908e0b 100644 --- a/skills/webmcpify/references/integrate.md +++ b/skills/webmcpify/references/integrate.md @@ -12,6 +12,15 @@ Applies to plain HTML, SSG-emitted, server-rendered, and framework-rendered (uncontrolled) forms — anywhere a real `
` with named controls exists. Annotate the existing form; do not restructure it. +**Check existing framework exposure first.** Angular Signal Forms can already +register an imperative tool through `provideExperimentalWebMcpForms` and +`experimentalWebMcpTool`. Inventory that registration and its actual submit +behavior before adding markup. Never add declarative `toolname` attributes or a +second imperative owner for the same form/action. An implicit Signal Form tool +can populate the model and invoke submission directly: classify and approve it +as a mutation before invoking it. Do not enable framework providers or install +adapter dependencies automatically. See [Angular WebMCP](https://angular.dev/ai/webmcp). + ```html [key, canonical(value[key])])); } + // Schemas are JSON snapshots, not live references to caller-owned objects. + function serializeSchema(schema) { + if (schema === undefined) return undefined; + if (schema === null || !['object', 'function'].includes(typeof schema)) { + throw new TypeError('Tool schemas must be objects when provided.'); + } + const serialized = JSON.stringify(schema); + if (serialized === undefined) throw new TypeError('Tool schema is not JSON serializable.'); + return serialized; + } + + const optionalSchema = (schema) => schema === undefined ? undefined : parseSchema(schema); + function makeSimulationContext(doc) { const tools = new Map(); const notify = () => doc.dispatchEvent(new Event('toolchange')); @@ -66,14 +79,23 @@ } 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 }); + const inputSchema = serializeSchema(tool.inputSchema); + const outputSchema = serializeSchema(tool.outputSchema); + tools.set(tool.name, { + public: { ...tool, inputSchema: undefined, outputSchema: undefined, execute: undefined }, + inputSchema, outputSchema, execute: tool.execute, + }); options.signal?.addEventListener('abort', () => { if (tools.delete(tool.name)) notify(); }, { once: true }); notify(); }, async getTools() { - return Array.from(tools.values(), ({ public: tool }) => ({ ...tool })); + return Array.from(tools.values(), ({ public: tool, inputSchema, outputSchema }) => ({ + ...tool, + inputSchema: inputSchema === undefined ? undefined : JSON.parse(inputSchema), + outputSchema: outputSchema === undefined ? undefined : JSON.parse(outputSchema), + })); }, // Simulation of current imperative cancellation, not native browser evidence. async executeTool(tool, input = {}, options = {}) { @@ -98,7 +120,7 @@ return registered.execute(args ?? {}, { signal: execution.signal }); }).then((result) => { if (settled) return; - // Native executeTool returns the JSON serialization, including strings. + // Draft-shaped simulation; dated Chrome scalar behavior can differ. const serialized = JSON.stringify(result); if (serialized === undefined) throw new TypeError('Tool result is not JSON serializable.'); settled = true; @@ -180,6 +202,7 @@ ...tool, name: tool.name ?? tool.id, inputSchema: parseSchema(tool.inputSchema), + outputSchema: optionalSchema(tool.outputSchema), })); } @@ -300,9 +323,10 @@ const expectedTool = expectedByName.get(tool.name); if (!expectedTool) return 'Observed only'; const schemaMatches = JSON.stringify(canonical(parseSchema(tool.inputSchema))) === JSON.stringify(canonical(expectedTool.inputSchema)); + const outputMatches = JSON.stringify(canonical(optionalSchema(tool.outputSchema))) === JSON.stringify(canonical(expectedTool.outputSchema)); const annotationsMatch = !expectedTool.annotations || JSON.stringify(canonical(tool.annotations ?? {})) === JSON.stringify(canonical(expectedTool.annotations)); const descriptionMatches = !expectedTool.description || tool.description === expectedTool.description; - return schemaMatches && annotationsMatch && descriptionMatches ? 'Observed + expected' : 'Contract differs'; + return schemaMatches && outputMatches && annotationsMatch && descriptionMatches ? 'Observed + expected' : 'Contract differs'; } function renderList() { @@ -388,11 +412,12 @@ const schema = parseSchema(tool.inputSchema); ui.title.textContent = tool.name; ui.meta.textContent = `${mutationLabel(tool)} · ${statusLabel(tool)}`; - ui.schema.textContent = json(schema); + ui.schema.textContent = json(tool.outputSchema === undefined ? schema : { inputSchema: schema, outputSchema: optionalSchema(tool.outputSchema) }); const expectedTool = expectedByName.get(tool.name); ui.expected.textContent = expectedTool ? json({ description: expectedTool.description, inputSchema: expectedTool.inputSchema, + outputSchema: expectedTool.outputSchema, annotations: expectedTool.annotations, mutating: expectedTool.mutating, }) : 'Not present in the approved manifest.'; @@ -470,11 +495,11 @@ const uniqueForms = portableForms.filter((tool) => !registeredNames.has(tool.name)); for (const tool of uniqueForms) registeredNames.add(tool.name); const nextTools = [ - ...observed.map((tool) => ({ ...tool, inputSchema: parseSchema(tool.inputSchema), _observed: true })), + ...observed.map((tool) => ({ ...tool, inputSchema: parseSchema(tool.inputSchema), outputSchema: optionalSchema(tool.outputSchema), _observed: true })), ...uniqueForms.map((tool) => ({ ...tool, _observed: true })), ...expected.filter((tool) => !registeredNames.has(tool.name)).map((tool) => ({ ...tool, _observed: false })), ]; - const nextFingerprint = JSON.stringify(nextTools.map((tool) => [tool.name, tool.description, tool.inputSchema, tool.annotations, tool._observed])); + const nextFingerprint = JSON.stringify(nextTools.map((tool) => [tool.name, tool.description, tool.inputSchema, tool.outputSchema, tool.annotations, tool._observed])); if (nextFingerprint === toolFingerprint) return; toolFingerprint = nextFingerprint; tools = nextTools; diff --git a/skills/webmcpify/templates/webmcp.d.ts b/skills/webmcpify/templates/webmcp.d.ts index e5bc8c2..aacef04 100644 --- a/skills/webmcpify/templates/webmcp.d.ts +++ b/skills/webmcpify/templates/webmcp.d.ts @@ -90,7 +90,8 @@ interface ModelContext extends EventTarget { getTools(options?: { fromOrigins?: string[] }): Promise; /** * Agent/test execution surface in the CG draft. Current Chrome accepts an - * object and returns its serialized result; navigation null is retained for + * object; scalar result serialization can differ from the draft (see dated + * native compatibility evidence). Navigation null is retained for * Chrome/declarative compatibility even though the 2026-10-02 draft IDL lists * DOMString. Chrome 150's JSON-string input remains in the verification adapter. */ @@ -112,11 +113,14 @@ interface ModelContextTool { description: string; /** JSON Schema for the tool's input */ inputSchema?: object; + /** Optional expected output contract in the current CG draft; not runtime authorization/validation. */ + outputSchema?: object; /** * Current draft passes options with a required signal; second argument is optional * here to represent legacy Chrome 150. Forward options unchanged; registration * signals only control availability. Return JSON-safe results, never bare absence. - * Native executeTool serializes this callback result (checked 2026-10-05). + * Native result representation is browser/version-dependent; preserve raw + * results rather than assuming every scalar is JSON text (checked 2026-11-09). */ execute(input: Record, options?: ToolExecuteCallbackOptions): Promise; annotations?: ModelContextToolAnnotations; @@ -128,6 +132,8 @@ interface RegisteredTool { title?: string; description: string; inputSchema?: string | object; + /** Current draft metadata; older browsers may omit it even when provided at registration. */ + outputSchema?: string | object; annotations?: ModelContextToolAnnotations; /** Registering origin (secure origins only). */ origin: string; diff --git a/skills/webmcpify/templates/webmcp.spec.ts b/skills/webmcpify/templates/webmcp.spec.ts index 7d0f6af..9b21db3 100644 --- a/skills/webmcpify/templates/webmcp.spec.ts +++ b/skills/webmcpify/templates/webmcp.spec.ts @@ -98,6 +98,7 @@ async function listTools(p: Page): Promise< Array<{ name: string; inputSchema?: string | object; + outputSchema?: string | object; annotations?: { readOnlyHint?: boolean; untrustedContentHint?: boolean; diff --git a/tests/output-schema-usage.ts b/tests/output-schema-usage.ts new file mode 100644 index 0000000..8384180 --- /dev/null +++ b/tests/output-schema-usage.ts @@ -0,0 +1,20 @@ +// Compile-only contract fixture; never run in a browser. +const outputContractTool: ModelContextTool = { + name: 'read_fixture', + description: 'Reads a synthetic fixture label.', + outputSchema: { type: 'object', properties: { label: { type: 'string' } } }, + execute: async () => ({ label: 'fixture' }), +}; +void outputContractTool; +const invalidOutputContract: ModelContextTool = { + name: 'invalid_fixture', + description: 'Invalid compile-only contract.', + // @ts-expect-error registration accepts an object schema, never a serialized string + outputSchema: '{"type":"object"}', + execute: async () => ({ label: 'fixture' }), +}; +void invalidOutputContract; +function readOutputContract(tool: RegisteredTool): object | string | undefined { + return tool.outputSchema; +} +void readOutputContract; diff --git a/tests/parity.test.mjs b/tests/parity.test.mjs index 062da83..a8476b8 100644 --- a/tests/parity.test.mjs +++ b/tests/parity.test.mjs @@ -88,6 +88,19 @@ test('export surface parity', () => { assert.deepEqual(Object.keys(jsMod).sort(), expected); }); +test('both runtime wrappers preserve optional output contract metadata', async () => { + for (const [variant, mod] of variants) { + let registered; + const outputSchema = { type: 'object', properties: { value: { type: 'string' } } }; + setModelContext({ registerTool: async (tool) => { registered = tool; } }); + const handle = mod.createToolScope(`output-contract-${variant}`, [{ ...validTool('read_fixture'), outputSchema }]); + assert.equal(await handle.ready, true); + assert.equal(registered.outputSchema, outputSchema); + assert.equal(await registered.execute({}), 'ok'); + handle(); + } +}); + test('dispatchAndWait: ok:true with EMPTY message resolves the empty string (?? vs || drift)', async () => { const results = []; for (const [, mod] of variants) results.push(await dispatchScenario(mod, { ok: true, message: '' })); diff --git a/tests/schema-browser.mjs b/tests/schema-browser.mjs new file mode 100644 index 0000000..1b696de --- /dev/null +++ b/tests/schema-browser.mjs @@ -0,0 +1,36 @@ +// Dedicated read-only Workbench output-contract refresh regression. +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { chromium } from 'playwright'; +const source = await readFile(new URL('../skills/webmcpify/templates/webmcp-workbench.js', import.meta.url), 'utf8'); +const server = createServer((_request, response) => response.writeHead(200, { 'content-type': 'text/html' }).end('Read-only schema fixture')); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +let browser; +try { + browser = await chromium.launch({ channel: 'chrome', headless: true }); + const page = await browser.newPage(); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.addScriptTag({ content: source }); + await page.evaluate(async () => { + const inputSchema = { type: 'object', properties: {} }; + const outputSchema = { type: 'object', properties: { value: { type: 'string' } } }; + window.schemaController = new AbortController(); + window.WebMCPifyWorkbench.start({ expectedTools: [{ id: 'read_schema', mutating: false, inputSchema, outputSchema }], open: true }); + await document.modelContext.registerTool({ name: 'read_schema', description: 'Reads a synthetic label.', inputSchema, outputSchema, execute: async () => ({ value: 'fixture' }) }, { signal: window.schemaController.signal }); + }); + const host = page.locator('#webmcpify-workbench'); + await page.waitForFunction(() => document.querySelector('#webmcpify-workbench').shadowRoot.querySelector('.tool-meta').textContent.includes('Observed + expected')); + assert.match(await host.locator('.schema').textContent(), /outputSchema/); + await page.evaluate(async () => { + schemaController.abort(); + await document.modelContext.registerTool({ name: 'read_schema', description: 'Reads a synthetic label.', inputSchema: { type: 'object', properties: {} }, outputSchema: { type: 'object', properties: { value: { type: 'number' } } }, execute: async () => ({ value: 1 }) }); + }); + await page.waitForFunction(() => document.querySelector('#webmcpify-workbench').shadowRoot.querySelector('.tool-meta').textContent.includes('Contract differs')); + assert.match(await host.locator('.schema').textContent(), /number/); + assert.match(await host.locator('.expected').textContent(), /string/); + console.log('Read-only simulated browser: output-only drift changes contract status and renders current/expected schemas.'); +} finally { + await browser?.close(); + await new Promise(resolve => server.close(resolve)); +} diff --git a/tests/workbench.test.mjs b/tests/workbench.test.mjs index 1b0227c..f26e1de 100644 --- a/tests/workbench.test.mjs +++ b/tests/workbench.test.mjs @@ -112,6 +112,54 @@ test('simulated context rejects duplicate and malformed registrations', async () await assert.rejects(() => context.registerTool({ name: 'one', execute() {} }), /already registered/); }); +test('simulation snapshots input/output schemas at registration and each enumeration', async () => { + const context = loadApi().createSimulationContext({ dispatchEvent() {} }); + const inputSchema = { type: 'object', properties: { q: { type: 'string' } } }; + const outputSchema = { type: 'object', properties: { value: { type: 'string' } } }; + await context.registerTool({ name: 'read_schema', description: 'Reads fixture data.', inputSchema, outputSchema, execute: async () => ({ value: 'ok' }) }); + inputSchema.properties.q.type = 'number'; + outputSchema.properties.value.type = 'number'; + const [first] = await context.getTools(); + assert.equal(first.inputSchema.properties.q.type, 'string'); + assert.equal(first.outputSchema.properties.value.type, 'string'); + first.inputSchema.properties.q.type = 'boolean'; + first.outputSchema.properties.value.type = 'boolean'; + const [second] = await context.getTools(); + assert.equal(second.inputSchema.properties.q.type, 'string'); + assert.equal(second.outputSchema.properties.value.type, 'string'); + assert.equal(await context.executeTool(second, {}), '{"value":"ok"}'); +}); + +test('schema serialization fails before registration and leaves the name reusable', async () => { + for (const field of ['inputSchema', 'outputSchema']) { + for (const makeInvalid of [ + () => { const schema = {}; schema.self = schema; return schema; }, + () => ({ value: 1n }), + () => ({ toJSON() { return undefined; } }), + () => 'not an object', + () => null, + ]) { + const events = []; + const context = loadApi().createSimulationContext({ dispatchEvent(event) { events.push(event.type); } }); + await assert.rejects(context.registerTool({ name: 'read_schema', [field]: makeInvalid(), execute: async () => ({ ok: true }) }), { name: 'TypeError' }); + assert.equal((await context.getTools()).length, 0); + assert.equal(events.length, 0); + await context.registerTool({ name: 'read_schema', execute: async () => ({ ok: true }) }); + assert.equal((await context.getTools()).length, 1); + } + } +}); + +test('schema snapshots use JSON serialization, permit omission and preserve metadata', async () => { + const context = loadApi().createSimulationContext({ dispatchEvent() {} }); + await context.registerTool({ name: 'read_custom', outputSchema: { toJSON() { return { type: 'string' }; } }, execute: async () => 'value' }); + await context.registerTool({ name: 'read_omitted', inputSchema: undefined, outputSchema: undefined, execute: async () => 'value' }); + const [custom, omitted] = await context.getTools(); + assert.equal(JSON.stringify(custom.outputSchema), '{"type":"string"}'); + assert.equal(omitted.inputSchema, undefined); + assert.equal(omitted.outputSchema, undefined); +}); + test('stopping before DOM readiness removes the pending simulation context', async () => { const listeners = new Map(); const document = { diff --git a/tsconfig.templates.json b/tsconfig.templates.json index 02e02f2..0aa86fd 100644 --- a/tsconfig.templates.json +++ b/tsconfig.templates.json @@ -11,6 +11,7 @@ }, "files": [ "skills/webmcpify/templates/webmcpify.ts", - "skills/webmcpify/templates/webmcp.d.ts" + "skills/webmcpify/templates/webmcp.d.ts", + "tests/output-schema-usage.ts" ] }