From f1bd85e4231373e40ff9097946d62ee339777291 Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 2 Sep 2026 20:06:34 +0200 Subject: [PATCH 1/8] feat: enableWebMCP registers the editor operations as WebMCP tools on the host page --- .changeset/enable-webmcp.md | 8 + embed/README.md | 16 ++ embed/etc/index.api.md | 13 ++ embed/etc/protocol.api.md | 190 ++++++++++++++++++++++- embed/etc/tools.api.md | 2 +- embed/scripts/generate.mjs | 32 ++++ embed/src/bridge.ts | 19 +++ embed/src/generated/contract.ts | 15 ++ embed/src/index.ts | 1 + embed/src/mount.ts | 12 +- embed/src/types.ts | 1 + embed/src/webmcp.ts | 127 ++++++++++++++++ embed/test/webmcp.test.ts | 262 ++++++++++++++++++++++++++++++++ react/README.md | 6 + react/etc/index.api.md | 1 + react/src/embed-pdf.test.tsx | 21 +++ react/src/embed-pdf.tsx | 36 ++++- 17 files changed, 755 insertions(+), 7 deletions(-) create mode 100644 .changeset/enable-webmcp.md create mode 100644 embed/src/webmcp.ts create mode 100644 embed/test/webmcp.test.ts diff --git a/.changeset/enable-webmcp.md b/.changeset/enable-webmcp.md new file mode 100644 index 00000000..add6f6d0 --- /dev/null +++ b/.changeset/enable-webmcp.md @@ -0,0 +1,8 @@ +--- +"@simplepdf/embed": minor +"@simplepdf/react-embed-pdf": minor +--- + +Add `enableWebMCP`: register the editor operations as WebMCP tools on the host page. + +An in-browser agent (ChatGPT's browser, Chrome with WebMCP) discovers tools on the page it is looking at, not inside iframes. `createEmbed({ enableWebMCP: true })` and `` register every agentic operation on the page's `document.modelContext` (same names and camelCase inputs as `@simplepdf/embed/tools`) and forward each call to the editor over the bridge, so the agent reads and fills the document in the tab and the PDF never leaves the browser. `{ exclude: ['submit', ...] }` withholds operations so a person keeps the decision. Every tool carries an explicit behavior hint (`readOnlyHint` / `destructiveHint`), the editor validates each call like any other request, and `dispose()` unregisters everything. Off by default; the WebMCP code is loaded lazily, so an embedder that does not opt in downloads none of it. diff --git a/embed/README.md b/embed/README.md index d297956d..d5c99214 100644 --- a/embed/README.md +++ b/embed/README.md @@ -71,6 +71,21 @@ import { createSimplePDFTools } from '@simplepdf/embed/tanstack-ai' useChat({ connection, tools: createSimplePDFTools({ embed }) }) ``` +## WebMCP site tools + +An agent running in the user's browser (ChatGPT's browser, Chrome with WebMCP) discovers tools on the page it is looking at, not inside iframes. `enableWebMCP` registers the editor's operations on **your** page's `document.modelContext`, forwarding each call to the editor over the bridge: the agent fills and reads the document in the tab, and the PDF never leaves the browser. + +```ts +// every agentic operation (the same names + camelCase inputs as @simplepdf/embed/tools) +createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, enableWebMCP: true }) + +// keep the decision with the person: withhold submit (and the page operations) +createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, + enableWebMCP: { exclude: ['submit', 'deletePages', 'movePage', 'rotatePage'] } }) +``` + +Off by default. Every tool declares a behavior hint (`readOnlyHint` for the readers, an explicit `destructiveHint` for the rest), the editor validates each call like any other request (its permission model applies: editing, allowlisted origin, plan), and `dispose()` unregisters everything. A browser without a model context loads none of the WebMCP code. In React, pass `enableWebMCP` to ``. + ## Subpaths | Import | Purpose | Peer | @@ -112,6 +127,7 @@ Either way you get the same typed `Embed` handle. | `context` | `object` | opaque data echoed back on submissions | | `iframeAttrs` | `{ title, allow, sandbox, className, style }` | passthrough iframe attributes (container case only); `allow` defaults to `clipboard-read; clipboard-write; web-share` — a custom `allow` MUST keep `web-share` or the editor's iOS share-sheet download is silently denied; a custom `sandbox` MUST include `allow-downloads` (or the editor's Download button is silently blocked) and `allow-modals` (or the editor's "Print document" action is silently ignored) | | `logger` | `BridgeLogger` | structured logs (ids + timing only, never payloads) | +| `enableWebMCP` | `boolean \| { exclude: AgenticToolName[] }` | register the editor operations as WebMCP tools on your page (see [WebMCP site tools](#webmcp-site-tools)); off by default | ## Document source diff --git a/embed/etc/index.api.md b/embed/etc/index.api.md index 5941bf43..8009c09d 100644 --- a/embed/etc/index.api.md +++ b/embed/etc/index.api.md @@ -4,6 +4,13 @@ ```ts +// Warning: (ae-forgotten-export) The symbol "OPERATIONS" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type AgenticToolName = Extract<(typeof OPERATIONS)[number], { + is_agentic_tool: true; +}>["method"]; + // @public (undocumented) export type BridgeError = { code: 'bad_request:missing_required_fields'; @@ -73,6 +80,7 @@ export type CreateEmbedArgs = { style?: Partial; }; logger?: BridgeLogger; + enableWebMCP?: WebMCPOptions; }; // @public (undocumented) @@ -331,6 +339,11 @@ export type SubmitInput = { // @public (undocumented) export const unwrap: (result: BridgeResult) => TData; +// @public (undocumented) +export type WebMCPOptions = boolean | { + exclude: readonly AgenticToolName[]; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/embed/etc/protocol.api.md b/embed/etc/protocol.api.md index 22d916e8..9fd4c309 100644 --- a/embed/etc/protocol.api.md +++ b/embed/etc/protocol.api.md @@ -31,6 +31,41 @@ export const OPERATIONS: readonly [{ readonly wire_type: "CREATE_FIELD"; readonly method: "createField"; readonly description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled."; + readonly input_schema: { + readonly type: "object"; + readonly properties: { + readonly type: { + readonly type: "string"; + readonly enum: readonly ["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT"]; + readonly description: "Field type to create."; + }; + readonly x: { + readonly type: "number"; + readonly description: "Field x position, in PDF points."; + }; + readonly y: { + readonly type: "number"; + readonly description: "Field y position, in PDF points."; + }; + readonly width: { + readonly type: "number"; + readonly description: "Field width, in PDF points."; + }; + readonly height: { + readonly type: "number"; + readonly description: "Field height, in PDF points."; + }; + readonly page: { + readonly type: "integer"; + readonly description: "1-based page to place the field on."; + }; + readonly value: { + readonly description: "Optional initial value. A string for text/checkbox fields, or a data URL for signature/picture fields."; + readonly type: "string"; + }; + }; + readonly required: readonly ["type", "x", "y", "width", "height", "page"]; + }; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:invalid_dimensions", "bad_request:invalid_value", "bad_request:page_out_of_range", "bad_request:page_not_found", "bad_request:invalid_field_type", "bad_request:invalid_signature_url"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -39,6 +74,22 @@ export const OPERATIONS: readonly [{ readonly wire_type: "DELETE_FIELDS"; readonly method: "deleteFields"; readonly description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled."; + readonly input_schema: { + readonly type: "object"; + readonly properties: { + readonly fieldIds: { + readonly description: "IDs of the fields to delete. Omit to delete every field on the target page."; + readonly type: "array"; + readonly items: { + readonly type: "string"; + }; + }; + readonly page: { + readonly description: "1-based page to scope the deletion to. Omit to target all pages."; + readonly type: "integer"; + }; + }; + }; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_field_ids", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -47,6 +98,19 @@ export const OPERATIONS: readonly [{ readonly wire_type: "DELETE_PAGES"; readonly method: "deletePages"; readonly description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled."; + readonly input_schema: { + readonly type: "object"; + readonly properties: { + readonly pages: { + readonly type: "array"; + readonly items: { + readonly type: "integer"; + }; + readonly description: "1-based page numbers to delete."; + }; + }; + readonly required: readonly ["pages"]; + }; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -55,6 +119,9 @@ export const OPERATIONS: readonly [{ readonly wire_type: "DETECT_FIELDS"; readonly method: "detectFields"; readonly description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled."; + readonly input_schema: { + readonly type: "object"; + }; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -63,6 +130,9 @@ export const OPERATIONS: readonly [{ readonly wire_type: "DOWNLOAD"; readonly method: "download"; readonly description: "Generate and download the current document as a PDF. Returns no data."; + readonly input_schema: { + readonly type: "object"; + }; readonly error_codes: readonly ["bad_request:no_document_loaded", "bad_request:missing_required_fields", "bad_request:download_blocked"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -71,6 +141,16 @@ export const OPERATIONS: readonly [{ readonly wire_type: "FOCUS_FIELD"; readonly method: "focusField"; readonly description: "Scroll an existing field into view and focus it, addressed by its id (from get_fields). Returns a hint describing the user action expected next."; + readonly input_schema: { + readonly type: "object"; + readonly properties: { + readonly fieldId: { + readonly type: "string"; + readonly description: "ID of the field to focus and scroll into view."; + }; + }; + readonly required: readonly ["fieldId"]; + }; readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:no_document_loaded", "bad_request:field_not_found"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -78,7 +158,17 @@ export const OPERATIONS: readonly [{ readonly request_type: "GET_DOCUMENT_CONTENT"; readonly wire_type: "GET_DOCUMENT_CONTENT"; readonly method: "getDocumentContent"; - readonly description: "Extract the document's text content page by page (pass extraction_mode 'ocr' to force optical recognition). Use it to read what the document says. Returns { name, pages: [{ page, content }] }."; + readonly description: "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }."; + readonly input_schema: { + readonly type: "object"; + readonly properties: { + readonly extractionMode: { + readonly description: "Extraction strategy: 'auto' (default) or 'ocr' to force optical recognition."; + readonly type: "string"; + readonly enum: readonly ["auto", "ocr"]; + }; + }; + }; readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -87,6 +177,9 @@ export const OPERATIONS: readonly [{ readonly wire_type: "GET_FIELDS"; readonly method: "getFields"; readonly description: "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. Returns { fields }."; + readonly input_schema: { + readonly type: "object"; + }; readonly error_codes: readonly ["bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -95,6 +188,16 @@ export const OPERATIONS: readonly [{ readonly wire_type: "GO_TO"; readonly method: "goTo"; readonly description: "Scroll the editor to a specific 1-based page. Returns no data."; + readonly input_schema: { + readonly type: "object"; + readonly properties: { + readonly page: { + readonly type: "integer"; + readonly description: "1-based page to navigate to."; + }; + }; + readonly required: readonly ["page"]; + }; readonly error_codes: readonly ["bad_request:invalid_page", "bad_request:page_out_of_range"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -103,6 +206,24 @@ export const OPERATIONS: readonly [{ readonly wire_type: "LOAD_DOCUMENT"; readonly method: "loadDocument"; readonly description: "Load a document into the editor from a base64 data URL. This is a host/setup action (no agentic tool); it returns no data."; + readonly input_schema: { + readonly type: "object"; + readonly properties: { + readonly dataUrl: { + readonly type: "string"; + readonly description: "The document to load, as a data URL."; + }; + readonly name: { + readonly description: "Optional display name for the document."; + readonly type: "string"; + }; + readonly page: { + readonly description: "Optional 1-based page to open the document on."; + readonly type: "integer"; + }; + }; + readonly required: readonly ["dataUrl"]; + }; readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:invalid_page"]; readonly is_agentic_tool: false; readonly has_output: false; @@ -111,6 +232,20 @@ export const OPERATIONS: readonly [{ readonly wire_type: "MOVE_PAGE"; readonly method: "movePage"; readonly description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled."; + readonly input_schema: { + readonly type: "object"; + readonly properties: { + readonly fromPage: { + readonly type: "integer"; + readonly description: "1-based current position of the page to move."; + }; + readonly toPage: { + readonly type: "integer"; + readonly description: "1-based destination position for the page."; + }; + }; + readonly required: readonly ["fromPage", "toPage"]; + }; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -119,6 +254,16 @@ export const OPERATIONS: readonly [{ readonly wire_type: "ROTATE_PAGE"; readonly method: "rotatePage"; readonly description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled."; + readonly input_schema: { + readonly type: "object"; + readonly properties: { + readonly page: { + readonly type: "integer"; + readonly description: "1-based page to rotate 90 degrees clockwise."; + }; + }; + readonly required: readonly ["page"]; + }; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -127,6 +272,21 @@ export const OPERATIONS: readonly [{ readonly wire_type: "SELECT_TOOL"; readonly method: "selectTool"; readonly description: "Activate a field-placement tool in the editor toolbar so the user can draw that field type, or pass null to clear the active tool. Returns no data."; + readonly input_schema: { + readonly type: "object"; + readonly properties: { + readonly tool: { + readonly anyOf: readonly [{ + readonly type: "string"; + readonly enum: readonly ["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT"]; + }, { + readonly type: "null"; + }]; + readonly description: "Tool to activate, or null to deselect."; + }; + }; + readonly required: readonly ["tool"]; + }; readonly error_codes: readonly ["bad_request:invalid_tool"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -135,6 +295,24 @@ export const OPERATIONS: readonly [{ readonly wire_type: "SET_FIELD_VALUE"; readonly method: "setFieldValue"; readonly description: "Set the value of an existing field addressed by its id (from get_fields), or clear it with null. If the field has options (see get_fields), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL (signature, picture). Returns no data."; + readonly input_schema: { + readonly type: "object"; + readonly properties: { + readonly fieldId: { + readonly type: "string"; + readonly description: "ID of the field to update."; + }; + readonly value: { + readonly anyOf: readonly [{ + readonly type: "string"; + }, { + readonly type: "null"; + }]; + readonly description: "New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL (signature/picture)."; + }; + }; + readonly required: readonly ["fieldId", "value"]; + }; readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:invalid_signature_url", "bad_request:no_document_loaded", "bad_request:read_only", "bad_request:field_not_found"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -143,6 +321,16 @@ export const OPERATIONS: readonly [{ readonly wire_type: "SUBMIT"; readonly method: "submit"; readonly description: "Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data."; + readonly input_schema: { + readonly type: "object"; + readonly properties: { + readonly downloadCopy: { + readonly type: "boolean"; + readonly description: "When true, the signer also receives a downloaded copy on submit."; + }; + }; + readonly required: readonly ["downloadCopy"]; + }; readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:missing_required_fields"]; readonly is_agentic_tool: true; readonly has_output: false; diff --git a/embed/etc/tools.api.md b/embed/etc/tools.api.md index 6b9a0e0c..30e9eb00 100644 --- a/embed/etc/tools.api.md +++ b/embed/etc/tools.api.md @@ -64,7 +64,7 @@ export const SIMPLEPDF_TOOLS: { }, zod_v4_core.$strip>; }; readonly getDocumentContent: { - readonly description: "Extract the document's text content page by page (pass extraction_mode 'ocr' to force optical recognition). Use it to read what the document says. Returns { name, pages: [{ page, content }] }."; + readonly description: "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }."; readonly inputSchema: zod.ZodObject<{ extractionMode: zod.ZodOptional { + assertKnownKeywords(node) + if (node.type !== 'object') { + throw new Error(`Unsupported tool input schema root (expected an object): ${JSON.stringify(node)}`) + } + const properties = node.properties ?? {} + const camelProperties = Object.fromEntries( + Object.entries(properties).map(([key, property]) => [toCamel(key), toolInputSchemaProperty(property)]), + ) + return { + type: 'object', + ...(Object.keys(camelProperties).length > 0 ? { properties: camelProperties } : {}), + ...(Array.isArray(node.required) && node.required.length > 0 ? { required: node.required.map(toCamel) } : {}), + } +} +const toolInputSchemaProperty = (node) => { + assertKnownKeywords(node) + if (node.type === 'object') { + return { ...toolInputSchema(node), ...(node.description !== undefined ? { description: node.description } : {}) } + } + return { + ...node, + ...(node.items !== undefined ? { items: toolInputSchemaProperty(node.items) } : {}), + ...(Array.isArray(node.anyOf) ? { anyOf: node.anyOf.map(toolInputSchemaProperty) } : {}), + } +} + // Operation metadata table (the camelCase `method` is the SDK method + agentic tool name). const opMeta = contract.operations.map((op) => { const stem = toPascal(op.request_type) @@ -351,6 +382,7 @@ const opMeta = contract.operations.map((op) => { ` wire_type: ${JSON.stringify(op.request_type.toUpperCase())},\n` + ` method: ${JSON.stringify(toCamel(op.request_type))},\n` + ` description: ${JSON.stringify(op.description)},\n` + + ` input_schema: ${JSON.stringify(toolInputSchema(op.input_schema))},\n` + ` error_codes: [${op.error_codes.map((c) => JSON.stringify(c)).join(', ')}] as const,\n` + ` is_agentic_tool: ${!NON_AGENTIC_OPERATIONS.has(op.request_type.toLowerCase())},\n` + ` has_output: ${op.output_schema.type !== 'null'},\n` + diff --git a/embed/src/bridge.ts b/embed/src/bridge.ts index e6687a37..210a96cd 100644 --- a/embed/src/bridge.ts +++ b/embed/src/bridge.ts @@ -12,6 +12,7 @@ import type { PageFocusedPayload, SubmissionSentPayload, } from './types' +import type { WebMCPOptions } from './webmcp' export type AttachEmbedArgs = { // Getter returning the iframe element. Called each time the bridge needs to @@ -26,6 +27,8 @@ export type AttachEmbedArgs = { // Optional teardown hook invoked once on dispose() after the bridge has cleaned // up (createEmbed's create path uses it to remove the iframe it created). onDispose?: () => void + // Expose the editor operations as WebMCP tools on the host page (see ./webmcp). + enableWebMCP?: WebMCPOptions // Internal wiring for createEmbed's "load the document once ready" flow: called on // every lifecycle transition (booting -> editorReady -> documentLoaded), including // readiness reached via the liveness probe (which emits no editor event). NOT a @@ -103,6 +106,7 @@ export const attachEmbed = ({ logger: providedLogger = NOOP_LOGGER, onDispose, onStateChange, + enableWebMCP, }: AttachEmbedArgs): Embed => { const logger = makeSafeLogger(providedLogger) const pending = new Map() @@ -465,11 +469,26 @@ export const attachEmbed = ({ submit: (input) => sendRequest('SUBMIT', input), } satisfies IframeActions + // The WebMCP module (and the operations table it reads) loads only for an embedder + // that opts in; aborting the signal on dispose unregisters every tool it registered. + const webMCPController = new AbortController() + const webMCPOptions = enableWebMCP === undefined || enableWebMCP === false ? null : enableWebMCP + if (webMCPOptions !== null) { + void import('./webmcp') + .then(({ registerWebMCPTools }) => + registerWebMCPTools({ dispatch: sendRequest, options: webMCPOptions, signal: webMCPController.signal, logger }), + ) + .catch((error: unknown) => { + logger.error('webmcp.load_failed', { message: error instanceof Error ? error.message : String(error) }) + }) + } + const dispose = (): void => { if (disposed) { return } disposed = true + webMCPController.abort() window.removeEventListener('message', onMessage) clearReadyTimeout() stopProbing() diff --git a/embed/src/generated/contract.ts b/embed/src/generated/contract.ts index a35fdc12..8e880c01 100644 --- a/embed/src/generated/contract.ts +++ b/embed/src/generated/contract.ts @@ -61,6 +61,7 @@ export const OPERATIONS = [ wire_type: "CREATE_FIELD", method: "createField", description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled.", + input_schema: {"type":"object","properties":{"type":{"type":"string","enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"],"description":"Field type to create."},"x":{"type":"number","description":"Field x position, in PDF points."},"y":{"type":"number","description":"Field y position, in PDF points."},"width":{"type":"number","description":"Field width, in PDF points."},"height":{"type":"number","description":"Field height, in PDF points."},"page":{"type":"integer","description":"1-based page to place the field on."},"value":{"description":"Optional initial value. A string for text/checkbox fields, or a data URL for signature/picture fields.","type":"string"}},"required":["type","x","y","width","height","page"]}, error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:invalid_dimensions", "bad_request:invalid_value", "bad_request:page_out_of_range", "bad_request:page_not_found", "bad_request:invalid_field_type", "bad_request:invalid_signature_url"] as const, is_agentic_tool: true, has_output: true, @@ -70,6 +71,7 @@ export const OPERATIONS = [ wire_type: "DELETE_FIELDS", method: "deleteFields", description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled.", + input_schema: {"type":"object","properties":{"fieldIds":{"description":"IDs of the fields to delete. Omit to delete every field on the target page.","type":"array","items":{"type":"string"}},"page":{"description":"1-based page to scope the deletion to. Omit to target all pages.","type":"integer"}}}, error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_field_ids", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: true, @@ -79,6 +81,7 @@ export const OPERATIONS = [ wire_type: "DELETE_PAGES", method: "deletePages", description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled.", + input_schema: {"type":"object","properties":{"pages":{"type":"array","items":{"type":"integer"},"description":"1-based page numbers to delete."}},"required":["pages"]}, error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: false, @@ -88,6 +91,7 @@ export const OPERATIONS = [ wire_type: "DETECT_FIELDS", method: "detectFields", description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled.", + input_schema: {"type":"object"}, error_codes: ["forbidden:editing_not_allowed", "bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: true, @@ -97,6 +101,7 @@ export const OPERATIONS = [ wire_type: "DOWNLOAD", method: "download", description: "Generate and download the current document as a PDF. Returns no data.", + input_schema: {"type":"object"}, error_codes: ["bad_request:no_document_loaded", "bad_request:missing_required_fields", "bad_request:download_blocked"] as const, is_agentic_tool: true, has_output: false, @@ -106,6 +111,7 @@ export const OPERATIONS = [ wire_type: "FOCUS_FIELD", method: "focusField", description: "Scroll an existing field into view and focus it, addressed by its id (from get_fields). Returns a hint describing the user action expected next.", + input_schema: {"type":"object","properties":{"fieldId":{"type":"string","description":"ID of the field to focus and scroll into view."}},"required":["fieldId"]}, error_codes: ["bad_request:invalid_value", "bad_request:no_document_loaded", "bad_request:field_not_found"] as const, is_agentic_tool: true, has_output: true, @@ -115,6 +121,7 @@ export const OPERATIONS = [ wire_type: "GET_DOCUMENT_CONTENT", method: "getDocumentContent", description: "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }.", + input_schema: {"type":"object","properties":{"extractionMode":{"description":"Extraction strategy: 'auto' (default) or 'ocr' to force optical recognition.","type":"string","enum":["auto","ocr"]}}}, error_codes: ["bad_request:invalid_value", "bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: true, @@ -124,6 +131,7 @@ export const OPERATIONS = [ wire_type: "GET_FIELDS", method: "getFields", description: "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. Returns { fields }.", + input_schema: {"type":"object"}, error_codes: ["bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: true, @@ -133,6 +141,7 @@ export const OPERATIONS = [ wire_type: "GO_TO", method: "goTo", description: "Scroll the editor to a specific 1-based page. Returns no data.", + input_schema: {"type":"object","properties":{"page":{"type":"integer","description":"1-based page to navigate to."}},"required":["page"]}, error_codes: ["bad_request:invalid_page", "bad_request:page_out_of_range"] as const, is_agentic_tool: true, has_output: false, @@ -142,6 +151,7 @@ export const OPERATIONS = [ wire_type: "LOAD_DOCUMENT", method: "loadDocument", description: "Load a document into the editor from a base64 data URL. This is a host/setup action (no agentic tool); it returns no data.", + input_schema: {"type":"object","properties":{"dataUrl":{"type":"string","description":"The document to load, as a data URL."},"name":{"description":"Optional display name for the document.","type":"string"},"page":{"description":"Optional 1-based page to open the document on.","type":"integer"}},"required":["dataUrl"]}, error_codes: ["bad_request:invalid_value", "bad_request:invalid_page"] as const, is_agentic_tool: false, has_output: false, @@ -151,6 +161,7 @@ export const OPERATIONS = [ wire_type: "MOVE_PAGE", method: "movePage", description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled.", + input_schema: {"type":"object","properties":{"fromPage":{"type":"integer","description":"1-based current position of the page to move."},"toPage":{"type":"integer","description":"1-based destination position for the page."}},"required":["fromPage","toPage"]}, error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: false, @@ -160,6 +171,7 @@ export const OPERATIONS = [ wire_type: "ROTATE_PAGE", method: "rotatePage", description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled.", + input_schema: {"type":"object","properties":{"page":{"type":"integer","description":"1-based page to rotate 90 degrees clockwise."}},"required":["page"]}, error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: false, @@ -169,6 +181,7 @@ export const OPERATIONS = [ wire_type: "SELECT_TOOL", method: "selectTool", description: "Activate a field-placement tool in the editor toolbar so the user can draw that field type, or pass null to clear the active tool. Returns no data.", + input_schema: {"type":"object","properties":{"tool":{"anyOf":[{"type":"string","enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"]},{"type":"null"}],"description":"Tool to activate, or null to deselect."}},"required":["tool"]}, error_codes: ["bad_request:invalid_tool"] as const, is_agentic_tool: true, has_output: false, @@ -178,6 +191,7 @@ export const OPERATIONS = [ wire_type: "SET_FIELD_VALUE", method: "setFieldValue", description: "Set the value of an existing field addressed by its id (from get_fields), or clear it with null. If the field has options (see get_fields), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL (signature, picture). Returns no data.", + input_schema: {"type":"object","properties":{"fieldId":{"type":"string","description":"ID of the field to update."},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL (signature/picture)."}},"required":["fieldId","value"]}, error_codes: ["bad_request:invalid_value", "bad_request:invalid_signature_url", "bad_request:no_document_loaded", "bad_request:read_only", "bad_request:field_not_found"] as const, is_agentic_tool: true, has_output: false, @@ -187,6 +201,7 @@ export const OPERATIONS = [ wire_type: "SUBMIT", method: "submit", description: "Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data.", + input_schema: {"type":"object","properties":{"downloadCopy":{"type":"boolean","description":"When true, the signer also receives a downloaded copy on submit."}},"required":["downloadCopy"]}, error_codes: ["bad_request:invalid_value", "bad_request:missing_required_fields"] as const, is_agentic_tool: true, has_output: false, diff --git a/embed/src/index.ts b/embed/src/index.ts index fc6a9e51..5ca98976 100644 --- a/embed/src/index.ts +++ b/embed/src/index.ts @@ -4,6 +4,7 @@ export { createEmbed, EmbedConfigError } from './mount' export type { CreateEmbedArgs, EmbedDocument } from './mount' +export type { WebMCPOptions } from './webmcp' export { NOOP_LOGGER } from './logger' export type { BridgeLogger, LogPayload } from './logger' export { BridgeUnwrapError, unwrap } from './unwrap' diff --git a/embed/src/mount.ts b/embed/src/mount.ts index 09838ab1..07163f85 100644 --- a/embed/src/mount.ts +++ b/embed/src/mount.ts @@ -2,6 +2,7 @@ import { attachEmbed } from './bridge' import { type BridgeLogger, makeSafeLogger, NOOP_LOGGER } from './logger' import type { BridgeState, Embed } from './types' import type { Locale } from './generated/contract' +import type { WebMCPOptions } from './webmcp' // Construction-time configuration error. createEmbed validates its config // synchronously and THROWS this on programmer error (bad target/companyIdentifier/document @@ -86,6 +87,11 @@ export type CreateEmbedArgs = { style?: Partial } logger?: BridgeLogger + // Expose the editor operations as WebMCP tools on YOUR page (`document.modelContext`), + // where an in-browser agent discovers them; tools inside the editor iframe are not. + // `true` registers every agentic operation, `{ exclude: [...] }` withholds some (e.g. + // `submit` when only a person may finalize). Off by default. + enableWebMCP?: WebMCPOptions } const resolveTarget = (target: unknown): HTMLElement => { @@ -461,7 +467,7 @@ const loadDocumentWhenReady = (params: { const attachToIframe = ( iframe: HTMLIFrameElement, editorOrigin: string, - { document: embedDocument, logger = NOOP_LOGGER }: CreateEmbedArgs, + { document: embedDocument, logger = NOOP_LOGGER, enableWebMCP }: CreateEmbedArgs, documentsUrl: { url: URL; origin: string } | null, ): Embed => { // A documents URL loads by NAVIGATING the iframe, which we only do for an iframe @@ -508,6 +514,7 @@ const attachToIframe = ( logger: safeLogger, onDispose: () => documentFetchController.abort(), onStateChange: gate.onStateChange, + enableWebMCP, }) if (embedDocument !== undefined) { loadDocumentWhenReady({ @@ -527,7 +534,7 @@ const attachToIframe = ( const mountIntoContainer = ( container: HTMLElement, editorOrigin: string, - { document: mountDocument, locale, context, iframeAttrs, logger = NOOP_LOGGER }: CreateEmbedArgs, + { document: mountDocument, locale, context, iframeAttrs, logger = NOOP_LOGGER, enableWebMCP }: CreateEmbedArgs, documentsUrl: { url: URL; origin: string } | null, ): Embed => { const hasDocumentUrl = mountDocument !== undefined && 'url' in mountDocument @@ -607,6 +614,7 @@ const mountIntoContainer = ( documentFetchController.abort() iframe.remove() }, + enableWebMCP, }) // A documents URL is loaded by the navigation above; only the PDF / data-URL / diff --git a/embed/src/types.ts b/embed/src/types.ts index 8caa0a5c..30451371 100644 --- a/embed/src/types.ts +++ b/embed/src/types.ts @@ -29,6 +29,7 @@ import type { } from './generated/contract' export type { + AgenticToolName, CreateFieldInput, CreateFieldOutput, DeleteFieldsInput, diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts new file mode 100644 index 00000000..48b8638c --- /dev/null +++ b/embed/src/webmcp.ts @@ -0,0 +1,127 @@ +// Registers the editor operations as WebMCP tools on the HOST page's model context +// (`document.modelContext`; `navigator.modelContext` is the deprecated alias older +// runtimes expose) and executes each call over the bridge's wire dispatch, so the +// editor validates the agent's input exactly as it validates every other request. +// The host page is where an in-browser agent looks: tools registered inside the +// editor iframe are not discovered, which is why the SDK lifts them here. +// +// Loaded lazily by the bridge only when `enableWebMCP` is set, so an embedder that +// does not opt in downloads none of this (nor the operations table it reads). +// CF: https://webmachinelearning.github.io/webmcp/ + +import { OPERATIONS, type AgenticToolName, type WireType } from './generated/contract' +import type { BridgeLogger } from './logger' +import type { BridgeResult } from './types' + +// `true` registers every agentic operation; `exclude` withholds the listed ones +// (e.g. `submit` when only a person may finalize). `false` / omitted registers nothing. +export type WebMCPOptions = boolean | { exclude: readonly AgenticToolName[] } + +// The slice of the WebMCP surface this module touches, typed structurally so the +// zero-dependency root pulls in no type package. +type ToolAnnotations = { readOnlyHint?: boolean; destructiveHint?: boolean } +type CallToolResult = { content: Array<{ type: 'text'; text: string }>; isError?: boolean } +type ToolInputSchema = { + readonly type: 'object' + readonly properties?: Readonly> + readonly required?: readonly string[] +} +type WebMCPTool = { + name: string + description: string + inputSchema: ToolInputSchema + annotations: ToolAnnotations + execute: (input: unknown) => Promise +} +type ModelContext = { + registerTool: (tool: WebMCPTool, options: { signal: AbortSignal }) => unknown +} + +type Operation = (typeof OPERATIONS)[number] +type AgenticOperation = Extract + +// MCP planners treat an unset destructiveHint as true, so every operation declares +// one: true for the ones that remove or reorder content or finalize the document; +// readOnlyHint marks the pure readers. The Record makes a new operation a compile +// error until it is annotated. +const TOOL_ANNOTATIONS = { + createField: { destructiveHint: false }, + deleteFields: { destructiveHint: true }, + deletePages: { destructiveHint: true }, + detectFields: { destructiveHint: false }, + download: { destructiveHint: false }, + focusField: { destructiveHint: false }, + getDocumentContent: { readOnlyHint: true }, + getFields: { readOnlyHint: true }, + goTo: { destructiveHint: false }, + movePage: { destructiveHint: true }, + rotatePage: { destructiveHint: true }, + selectTool: { destructiveHint: false }, + setFieldValue: { destructiveHint: false }, + submit: { destructiveHint: true }, +} satisfies Record + +const isAgenticOperation = (operation: Operation): operation is AgenticOperation => operation.is_agentic_tool + +const isModelContext = (value: unknown): value is ModelContext => + typeof value === 'object' && value !== null && 'registerTool' in value && typeof value.registerTool === 'function' + +const readModelContext = (): ModelContext | null => { + if ('modelContext' in document && isModelContext(document.modelContext)) { + return document.modelContext + } + if ('modelContext' in navigator && isModelContext(navigator.modelContext)) { + return navigator.modelContext + } + return null +} + +// The editor Result, serialized as the JSON-text CallToolResult an agent reads; a +// failed Result is additionally flagged `isError` (the MCP convention). +const toCallToolResult = (result: BridgeResult): CallToolResult => ({ + content: [{ type: 'text', text: JSON.stringify(result) }], + ...(result.success ? {} : { isError: true }), +}) + +export const registerWebMCPTools = ({ + dispatch, + options, + signal, + logger, +}: { + dispatch: (wireType: WireType, data: unknown) => Promise> + options: Exclude + signal: AbortSignal + logger: BridgeLogger +}): void => { + const modelContext = readModelContext() + if (modelContext === null || signal.aborted) { + return + } + const excluded = new Set(options === true ? [] : options.exclude) + for (const operation of OPERATIONS) { + if (!isAgenticOperation(operation) || excluded.has(operation.method)) { + continue + } + const tool: WebMCPTool = { + name: operation.method, + description: operation.description, + inputSchema: operation.input_schema, + annotations: TOOL_ANNOTATIONS[operation.method], + // A no-input tool may be called without arguments; the wire always carries an object. + execute: async (input) => toCallToolResult(await dispatch(operation.wire_type, input ?? {})), + } + // Registration is best-effort: a runtime that rejects one tool must not take the + // others down or escape as an unhandled rejection. + void (async (): Promise => { + try { + await modelContext.registerTool(tool, { signal }) + } catch (error) { + logger.error('webmcp.register_tool_failed', { + tool: tool.name, + message: error instanceof Error ? error.message : String(error), + }) + } + })() + } +} diff --git a/embed/test/webmcp.test.ts b/embed/test/webmcp.test.ts new file mode 100644 index 00000000..39b5b2d4 --- /dev/null +++ b/embed/test/webmcp.test.ts @@ -0,0 +1,262 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { attachEmbed, type AttachEmbedArgs } from '../src/bridge' +import type { BridgeLogger } from '../src/logger' +import type { Embed } from '../src/types' + +const EDITOR_ORIGIN = 'https://tenant.simplepdf.com' + +// The slice of a WebMCP tool descriptor these tests read back. +type RegisteredTool = { + name: string + description: string + inputSchema: { type: string; properties?: Record; required?: readonly string[] } + annotations: { readOnlyHint?: boolean; destructiveHint?: boolean } + execute: (input: unknown) => Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> +} +type FakeModelContext = { + registerTool: (tool: RegisteredTool, options: { signal: AbortSignal }) => void + registered: RegisteredTool[] + liveToolNames: () => string[] +} + +const originalDocumentModelContext = Object.getOwnPropertyDescriptor(document, 'modelContext') +const originalNavigatorModelContext = Object.getOwnPropertyDescriptor(navigator, 'modelContext') + +const restoreModelContext = (target: object, descriptor: PropertyDescriptor | undefined): void => { + if (descriptor === undefined) { + Reflect.deleteProperty(target, 'modelContext') + return + } + Object.defineProperty(target, 'modelContext', descriptor) +} + +// A minimal native-like model context: it records registrations and drops a tool from +// the live set when its registration signal aborts (the spec's unregister mechanism). +const installModelContext = ( + host: Document | Navigator, + { rejectTool }: { rejectTool?: string } = {}, +): FakeModelContext => { + const registered: RegisteredTool[] = [] + const liveTools = new Set() + const modelContext: FakeModelContext = { + registerTool: (tool, { signal }) => { + if (tool.name === rejectTool) { + throw new Error(`runtime rejected ${tool.name}`) + } + registered.push(tool) + liveTools.add(tool.name) + signal.addEventListener('abort', () => liveTools.delete(tool.name), { once: true }) + }, + registered, + liveToolNames: () => [...liveTools], + } + Object.defineProperty(host, 'modelContext', { configurable: true, value: modelContext }) + return modelContext +} + +type Posted = { type: string; request_id: string; data: unknown } +type Harness = { embed: Embed; posted: Posted[]; reply: (request: Posted, result: unknown) => void } + +const harnesses: Harness[] = [] + +const makeHarness = (args: Pick): Harness => { + const iframe = document.createElement('iframe') + document.body.appendChild(iframe) + const contentWindow = iframe.contentWindow + if (contentWindow === null) { + throw new Error('jsdom iframe has no contentWindow') + } + const posted: Posted[] = [] + vi.spyOn(contentWindow, 'postMessage').mockImplementation((message: unknown) => { + if (typeof message === 'string') { + posted.push(JSON.parse(message)) + } + }) + const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, ...args }) + const reply = (request: Posted, result: unknown): void => { + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ type: 'REQUEST_RESULT', data: { request_id: request.request_id, result } }), + origin: EDITOR_ORIGIN, + source: contentWindow, + }), + ) + } + const harness: Harness = { embed, posted, reply } + harnesses.push(harness) + return harness +} + +// Registration is asynchronous (the WebMCP module is lazy-loaded), so every test +// waits for the expected tool set rather than reading it synchronously. +const waitForTools = (modelContext: FakeModelContext, count: number): Promise => + vi.waitFor(() => expect(modelContext.registered).toHaveLength(count)) + +const AGENTIC_TOOL_NAMES = [ + 'createField', + 'deleteFields', + 'deletePages', + 'detectFields', + 'download', + 'focusField', + 'getDocumentContent', + 'getFields', + 'goTo', + 'movePage', + 'rotatePage', + 'selectTool', + 'setFieldValue', + 'submit', +] + +// The bridge's readiness probe posts its own GET_FIELDS requests while the editor is +// booting, so a tool call's request is located by type rather than by position. +const waitForRequest = async (harness: Harness, type: string): Promise => { + await vi.waitFor(() => expect(harness.posted.some((message) => message.type === type)).toBe(true)) + const request = harness.posted.find((message) => message.type === type) + if (request === undefined) { + throw new Error(`no ${type} request posted`) + } + return request +} + +const findTool = (modelContext: FakeModelContext, name: string): RegisteredTool => { + const tool = modelContext.registered.find((candidate) => candidate.name === name) + if (tool === undefined) { + throw new Error(`tool ${name} was not registered`) + } + return tool +} + +describe('attachEmbed({ enableWebMCP })', () => { + afterEach(() => { + for (const harness of harnesses) { + harness.embed.lifecycle.dispose() + } + harnesses.length = 0 + document.body.innerHTML = '' + restoreModelContext(document, originalDocumentModelContext) + restoreModelContext(navigator, originalNavigatorModelContext) + vi.restoreAllMocks() + }) + + it('registers every agentic operation on document.modelContext with the SDK name, description, camelCase input schema and an explicit behavior hint', async () => { + const modelContext = installModelContext(document) + makeHarness({ enableWebMCP: true }) + await waitForTools(modelContext, 14) + + expect(modelContext.registered.map((tool) => tool.name).sort()).toEqual(AGENTIC_TOOL_NAMES) + expect(modelContext.liveToolNames()).toHaveLength(14) + const setFieldValue = findTool(modelContext, 'setFieldValue') + expect(setFieldValue.description).toMatch(/^Set the value of an existing field/) + expect(setFieldValue.inputSchema.type).toBe('object') + expect(Object.keys(setFieldValue.inputSchema.properties ?? {})).toEqual(['fieldId', 'value']) + expect(setFieldValue.inputSchema.required).toEqual(['fieldId', 'value']) + for (const tool of modelContext.registered) { + const hasExplicitHint = tool.annotations.readOnlyHint === true || typeof tool.annotations.destructiveHint === 'boolean' + expect(hasExplicitHint, `${tool.name} declares no behavior hint`).toBe(true) + } + expect(findTool(modelContext, 'getFields').annotations).toEqual({ readOnlyHint: true }) + expect(findTool(modelContext, 'submit').annotations).toEqual({ destructiveHint: true }) + }) + + it('withholds the excluded operations and registers the rest', async () => { + const modelContext = installModelContext(document) + makeHarness({ enableWebMCP: { exclude: ['submit', 'deletePages', 'movePage', 'rotatePage'] } }) + await waitForTools(modelContext, 10) + + const names = modelContext.registered.map((tool) => tool.name) + expect(names).toContain('setFieldValue') + expect(names).toContain('getFields') + expect(names).not.toContain('submit') + expect(names).not.toContain('deletePages') + }) + + it('executes a tool call as the operation request on the wire and returns the editor Result as a JSON-text tool result', async () => { + const modelContext = installModelContext(document) + const harness = makeHarness({ enableWebMCP: true }) + await waitForTools(modelContext, 14) + + const pendingResult = findTool(modelContext, 'setFieldValue').execute({ fieldId: 'f1', value: 'Jane' }) + const request = await waitForRequest(harness, 'SET_FIELD_VALUE') + expect(request.data).toEqual({ field_id: 'f1', value: 'Jane' }) + harness.reply(request, { success: true }) + const toolResult = await pendingResult + expect(toolResult.isError).toBeUndefined() + expect(JSON.parse(toolResult.content[0]?.text ?? '')).toEqual({ success: true, data: null }) + }) + + it('flags a failed editor Result as an error tool result', async () => { + const modelContext = installModelContext(document) + const harness = makeHarness({ enableWebMCP: true }) + await waitForTools(modelContext, 14) + + const pendingResult = findTool(modelContext, 'goTo').execute({ page: 99 }) + const request = await waitForRequest(harness, 'GO_TO') + harness.reply(request, { success: false, error: { code: 'bad_request:page_out_of_range', message: 'no page 99' } }) + const toolResult = await pendingResult + expect(toolResult.isError).toBe(true) + expect(JSON.parse(toolResult.content[0]?.text ?? '')).toEqual({ + success: false, + error: { code: 'bad_request:page_out_of_range', message: 'no page 99' }, + }) + }) + + it('sends an empty payload when a no-input tool is called without arguments', async () => { + const modelContext = installModelContext(document) + const harness = makeHarness({ enableWebMCP: true }) + await waitForTools(modelContext, 14) + + void findTool(modelContext, 'detectFields').execute(undefined) + const request = await waitForRequest(harness, 'DETECT_FIELDS') + expect(request.data).toEqual({}) + }) + + it('unregisters every tool when the embed is disposed', async () => { + const modelContext = installModelContext(document) + const harness = makeHarness({ enableWebMCP: true }) + await waitForTools(modelContext, 14) + expect(modelContext.liveToolNames()).toHaveLength(14) + + harness.embed.lifecycle.dispose() + expect(modelContext.liveToolNames()).toEqual([]) + }) + + it('registers nothing when the option is off, even with a model context present', async () => { + const modelContext = installModelContext(document) + const registerTool = vi.spyOn(modelContext, 'registerTool') + makeHarness({}) + makeHarness({ enableWebMCP: false }) + // Give a would-be lazy registration every chance to run before asserting. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(registerTool).not.toHaveBeenCalled() + }) + + it('falls back to navigator.modelContext when the document exposes none', async () => { + const modelContext = installModelContext(navigator) + makeHarness({ enableWebMCP: true }) + await waitForTools(modelContext, 14) + expect(modelContext.liveToolNames()).toHaveLength(14) + }) + + it('is a no-op without a model context and never throws', async () => { + expect(() => makeHarness({ enableWebMCP: true })).not.toThrow() + await new Promise((resolve) => setTimeout(resolve, 20)) + expect('modelContext' in document).toBe(false) + }) + + it('keeps registering the other tools when the runtime rejects one, and logs the failure', async () => { + const modelContext = installModelContext(document, { rejectTool: 'download' }) + const logger: BridgeLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } + makeHarness({ enableWebMCP: true, logger }) + await waitForTools(modelContext, 13) + + expect(modelContext.registered.map((tool) => tool.name)).not.toContain('download') + await vi.waitFor(() => + expect(logger.error).toHaveBeenCalledWith('webmcp.register_tool_failed', { + tool: 'download', + message: 'runtime rejected download', + }), + ) + }) +}) diff --git a/react/README.md b/react/README.md index 7bb48ad5..5296cccf 100644 --- a/react/README.md +++ b/react/README.md @@ -318,6 +318,12 @@ See [Retrieving PDF Data](../README.md#retrieving-pdf-data) for text extraction, No The document to open (same typed shape as createEmbed): a URL (CORS / authenticated same-origin / a SimplePDF documents URL), a data URL, or a File/Blob + + enableWebMCP + boolean | { exclude: AgenticToolName[] } + No (defaults to off) + Register the editor operations as WebMCP tools on your page, where an in-browser agent (ChatGPT's browser, Chrome with WebMCP) discovers them; exclude withholds operations such as submit. See WebMCP site tools. + style React.CSSProperties diff --git a/react/etc/index.api.md b/react/etc/index.api.md index 9a4bb057..15870c09 100644 --- a/react/etc/index.api.md +++ b/react/etc/index.api.md @@ -15,6 +15,7 @@ import { OverlayToolType } from '@simplepdf/embed'; import * as React_2 from 'react'; import type { SelectToolInput } from '@simplepdf/embed'; import type { SubmitInput } from '@simplepdf/embed'; +import { WebMCPOptions } from '@simplepdf/embed'; // @public (undocumented) export type EmbedActions = Omit & { diff --git a/react/src/embed-pdf.test.tsx b/react/src/embed-pdf.test.tsx index 9a8b70c1..f7628be6 100644 --- a/react/src/embed-pdf.test.tsx +++ b/react/src/embed-pdf.test.tsx @@ -13,6 +13,27 @@ vi.mock('./styles.scss', () => ({})); // onEmbedEvent contract, and the useEmbed contract (null-safe before mount). describe('EmbedPDF (inline)', () => { + it('registers the editor operations as WebMCP tools on the host page when enableWebMCP is set, and unregisters them on unmount', async () => { + const liveTools = new Set(); + const registerTool = vi.fn((tool: { name: string }, { signal }: { signal: AbortSignal }) => { + liveTools.add(tool.name); + signal.addEventListener('abort', () => liveTools.delete(tool.name), { once: true }); + }); + Object.defineProperty(document, 'modelContext', { configurable: true, value: { registerTool } }); + try { + const { unmount } = render( + , + ); + await waitFor(() => expect(liveTools.size).toBe(13)); + expect(liveTools.has('setFieldValue')).toBe(true); + expect(liveTools.has('submit')).toBe(false); + unmount(); + expect(liveTools.size).toBe(0); + } finally { + Reflect.deleteProperty(document, 'modelContext'); + } + }); + it('renders the editor iframe inside the host element for the companyIdentifier origin', () => { const { container } = render(); const iframe = container.querySelector('iframe'); diff --git a/react/src/embed-pdf.tsx b/react/src/embed-pdf.tsx index f95ef01a..2dc08b92 100644 --- a/react/src/embed-pdf.tsx +++ b/react/src/embed-pdf.tsx @@ -15,7 +15,7 @@ import * as React from 'react'; import { createPortal } from 'react-dom'; -import { createEmbed, type EmbedDocument } from '@simplepdf/embed'; +import { createEmbed, type EmbedDocument, type WebMCPOptions } from '@simplepdf/embed'; import type { BridgeLogger, BridgeResult, @@ -97,6 +97,10 @@ type CommonEmbedPDFProps = { onEmbedEvent?: (event: EmbedEvent) => void | Promise; // Optional: structured logging of the bridge lifecycle + errors. logger?: BridgeLogger; + // Register the editor operations as WebMCP tools on YOUR page (same option as + // createEmbed): `true` for every agentic operation, `{ exclude: [...] }` to withhold + // some (e.g. `submit`). Off by default. + enableWebMCP?: WebMCPOptions; }; type InlineEmbedPDFProps = CommonEmbedPDFProps & { @@ -123,6 +127,7 @@ type SurfaceProps = { context?: Record; logger?: BridgeLogger; onEmbedEvent?: (event: EmbedEvent) => void | Promise; + enableWebMCP?: WebMCPOptions; className?: string; style?: React.CSSProperties; }; @@ -131,7 +136,16 @@ type SurfaceProps = { // Mount/unmount of this component drives create/dispose, so the modal gets the // same lifecycle for free (it mounts the surface only while open). const EmbedSurface = React.forwardRef((props, ref) => { - const { companyIdentifier, baseDomain, document: embedDocument, locale, context, className, style } = props; + const { + companyIdentifier, + baseDomain, + document: embedDocument, + locale, + context, + enableWebMCP, + className, + style, + } = props; const containerRef = React.useRef(null); // Keep callbacks + logger in a ref so changing them does not remount the iframe. @@ -190,6 +204,9 @@ const EmbedSurface = React.forwardRef((props, return `unserializable:${Object.keys(context).sort().join(',')}`; } }, [context]); + // Registration happens at mount, so a changed option remounts the editor; keyed on + // the serialized value so a fresh `{ exclude: [...] }` literal each render does not. + const webMCPKey = JSON.stringify(enableWebMCP ?? null); React.useEffect(() => { const container = containerRef.current; @@ -204,6 +221,7 @@ const EmbedSurface = React.forwardRef((props, locale, context, logger: stableLogger, + enableWebMCP, }); assignRef(ref, toEmbedActions(embed)); // Forward each editor event to onEmbedEvent as the verbatim { type, data }. The @@ -244,7 +262,17 @@ const EmbedSurface = React.forwardRef((props, // EXCLUDED: a stable object ref (the useEmbed norm) is captured once, and excluding it // means an unstable inline callback ref can't trigger a full iframe teardown + remount // (which would silently lose editor state) on every parent re-render. - }, [companyIdentifier, baseDomain, locale, documentSource, documentName, documentPage, contextKey, stableLogger]); + }, [ + companyIdentifier, + baseDomain, + locale, + documentSource, + documentName, + documentPage, + contextKey, + webMCPKey, + stableLogger, + ]); return
; }); @@ -330,6 +358,7 @@ export const EmbedPDF = React.forwardRef((pr context={props.context} logger={props.logger} onEmbedEvent={props.onEmbedEvent} + enableWebMCP={props.enableWebMCP} className="simplePDF_iframe" /> @@ -346,6 +375,7 @@ export const EmbedPDF = React.forwardRef((pr context={props.context} logger={props.logger} onEmbedEvent={props.onEmbedEvent} + enableWebMCP={props.enableWebMCP} className={props.className} style={props.style} /> From 88ff1e151c65aa50b41b56e35b32a522cd0d32a9 Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 2 Sep 2026 20:25:01 +0200 Subject: [PATCH 2/8] fix: harden enableWebMCP: spec annotations, readiness-gated lazy load, validated exclude, page-level tool names --- .changeset/enable-webmcp.md | 2 +- embed/README.md | 14 +- embed/etc/protocol.api.md | 188 ---------------------- embed/package.json | 2 +- embed/scripts/check-bundle-size.mjs | 53 ++++-- embed/scripts/check-lazy-chunks.mjs | 35 ++++ embed/scripts/generate.mjs | 63 ++++++-- embed/src/bridge.ts | 43 +++-- embed/src/generated/contract.ts | 15 -- embed/src/generated/tool-input-schemas.ts | 28 ++++ embed/src/mount.ts | 21 +++ embed/src/webmcp.ts | 68 +++++--- embed/test/mount.test.ts | 16 ++ embed/test/webmcp.test.ts | 154 ++++++++++++++---- react/README.md | 2 +- react/etc/index.api.md | 5 + react/src/embed-pdf.test.tsx | 19 ++- react/src/embed-pdf.tsx | 20 ++- react/src/index.tsx | 2 +- 19 files changed, 442 insertions(+), 308 deletions(-) create mode 100644 embed/scripts/check-lazy-chunks.mjs create mode 100644 embed/src/generated/tool-input-schemas.ts diff --git a/.changeset/enable-webmcp.md b/.changeset/enable-webmcp.md index add6f6d0..70c185da 100644 --- a/.changeset/enable-webmcp.md +++ b/.changeset/enable-webmcp.md @@ -5,4 +5,4 @@ Add `enableWebMCP`: register the editor operations as WebMCP tools on the host page. -An in-browser agent (ChatGPT's browser, Chrome with WebMCP) discovers tools on the page it is looking at, not inside iframes. `createEmbed({ enableWebMCP: true })` and `` register every agentic operation on the page's `document.modelContext` (same names and camelCase inputs as `@simplepdf/embed/tools`) and forward each call to the editor over the bridge, so the agent reads and fills the document in the tab and the PDF never leaves the browser. `{ exclude: ['submit', ...] }` withholds operations so a person keeps the decision. Every tool carries an explicit behavior hint (`readOnlyHint` / `destructiveHint`), the editor validates each call like any other request, and `dispose()` unregisters everything. Off by default; the WebMCP code is loaded lazily, so an embedder that does not opt in downloads none of it. +An in-browser agent (ChatGPT's browser, Chrome with WebMCP) discovers tools on the page it is looking at, not inside iframes. `createEmbed({ enableWebMCP: true })` and `` register every agentic operation on the page's `document.modelContext` (same names and camelCase inputs as `@simplepdf/embed/tools`) and forward each call to the editor over the bridge: the PDF bytes stay in the tab and reach no SimplePDF server, while what the agent reads (field values, extracted text) goes to the agent runtime the person attached. `{ exclude: ['submit', ...] }` withholds operations so a person keeps the decision (a malformed value throws `EmbedConfigError`). The readers carry the specification's `readOnlyHint` + `untrustedContentHint`, the other tools MCP's `destructiveHint`; each call resolves with an MCP tool result carrying the editor's Result (`isError` on failure); the editor validates each call like any other request; `dispose()` unregisters everything. Off by default: the WebMCP module loads lazily, once the editor is ready and only when the page exposes a model context, so nobody else downloads it. One WebMCP-enabled embed per page (tool names are page-level). diff --git a/embed/README.md b/embed/README.md index d5c99214..d5f5b6e4 100644 --- a/embed/README.md +++ b/embed/README.md @@ -73,18 +73,20 @@ useChat({ connection, tools: createSimplePDFTools({ embed }) }) ## WebMCP site tools -An agent running in the user's browser (ChatGPT's browser, Chrome with WebMCP) discovers tools on the page it is looking at, not inside iframes. `enableWebMCP` registers the editor's operations on **your** page's `document.modelContext`, forwarding each call to the editor over the bridge: the agent fills and reads the document in the tab, and the PDF never leaves the browser. +An agent running in the user's browser (ChatGPT's browser, Chrome with WebMCP) discovers tools on the page it is looking at, not inside iframes. `enableWebMCP` registers the editor's operations on **your** page's `document.modelContext`, forwarding each call to the editor over the bridge. The PDF bytes stay in the tab (nothing reaches a SimplePDF server); what the agent reads through `getFields` / `getDocumentContent` (field values, extracted text) goes to the agent runtime the person attached, so treat that runtime as you would any other party that sees the filled document. ```ts -// every agentic operation (the same names + camelCase inputs as @simplepdf/embed/tools) -createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, enableWebMCP: true }) - -// keep the decision with the person: withhold submit (and the page operations) +// keep the decision with the person: withhold submit (and the page operations), the +// recommended shape when the document can come from a third party (its text reaches +// the agent as untrusted content, and an agent holding `submit` acts on what it reads) createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, enableWebMCP: { exclude: ['submit', 'deletePages', 'movePage', 'rotatePage'] } }) + +// every agentic operation (the same names + camelCase inputs as @simplepdf/embed/tools) +createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, enableWebMCP: true }) ``` -Off by default. Every tool declares a behavior hint (`readOnlyHint` for the readers, an explicit `destructiveHint` for the rest), the editor validates each call like any other request (its permission model applies: editing, allowlisted origin, plan), and `dispose()` unregisters everything. A browser without a model context loads none of the WebMCP code. In React, pass `enableWebMCP` to ``. +Off by default. Tools register once the editor is ready and only when the page exposes a model context at that moment; otherwise nothing is loaded (the bridge logs `webmcp.unavailable`). The two readers (`getFields`, `getDocumentContent`) carry the specification's `readOnlyHint` and `untrustedContentHint` (their output is document-derived); every other tool carries MCP's `destructiveHint`, read by runtimes that honor MCP's hints. The editor validates each call like any other request (its permission model applies at call time: editing, allowlisted origin, plan, so a tool the tenant configuration refuses resolves with the matching error code), a call resolves with an MCP tool result whose text is the editor's `{ success, data | error }` Result (`isError` on failure), and `dispose()` unregisters everything. A model context is one per page and keyed by tool name, so enable WebMCP on one embed per page: a second one is reported (`webmcp.tool_already_registered`) and registers nothing. In React, pass `enableWebMCP` to ``. ## Subpaths diff --git a/embed/etc/protocol.api.md b/embed/etc/protocol.api.md index 9fd4c309..6d81da09 100644 --- a/embed/etc/protocol.api.md +++ b/embed/etc/protocol.api.md @@ -31,41 +31,6 @@ export const OPERATIONS: readonly [{ readonly wire_type: "CREATE_FIELD"; readonly method: "createField"; readonly description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled."; - readonly input_schema: { - readonly type: "object"; - readonly properties: { - readonly type: { - readonly type: "string"; - readonly enum: readonly ["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT"]; - readonly description: "Field type to create."; - }; - readonly x: { - readonly type: "number"; - readonly description: "Field x position, in PDF points."; - }; - readonly y: { - readonly type: "number"; - readonly description: "Field y position, in PDF points."; - }; - readonly width: { - readonly type: "number"; - readonly description: "Field width, in PDF points."; - }; - readonly height: { - readonly type: "number"; - readonly description: "Field height, in PDF points."; - }; - readonly page: { - readonly type: "integer"; - readonly description: "1-based page to place the field on."; - }; - readonly value: { - readonly description: "Optional initial value. A string for text/checkbox fields, or a data URL for signature/picture fields."; - readonly type: "string"; - }; - }; - readonly required: readonly ["type", "x", "y", "width", "height", "page"]; - }; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:invalid_dimensions", "bad_request:invalid_value", "bad_request:page_out_of_range", "bad_request:page_not_found", "bad_request:invalid_field_type", "bad_request:invalid_signature_url"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -74,22 +39,6 @@ export const OPERATIONS: readonly [{ readonly wire_type: "DELETE_FIELDS"; readonly method: "deleteFields"; readonly description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled."; - readonly input_schema: { - readonly type: "object"; - readonly properties: { - readonly fieldIds: { - readonly description: "IDs of the fields to delete. Omit to delete every field on the target page."; - readonly type: "array"; - readonly items: { - readonly type: "string"; - }; - }; - readonly page: { - readonly description: "1-based page to scope the deletion to. Omit to target all pages."; - readonly type: "integer"; - }; - }; - }; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_field_ids", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -98,19 +47,6 @@ export const OPERATIONS: readonly [{ readonly wire_type: "DELETE_PAGES"; readonly method: "deletePages"; readonly description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled."; - readonly input_schema: { - readonly type: "object"; - readonly properties: { - readonly pages: { - readonly type: "array"; - readonly items: { - readonly type: "integer"; - }; - readonly description: "1-based page numbers to delete."; - }; - }; - readonly required: readonly ["pages"]; - }; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -119,9 +55,6 @@ export const OPERATIONS: readonly [{ readonly wire_type: "DETECT_FIELDS"; readonly method: "detectFields"; readonly description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled."; - readonly input_schema: { - readonly type: "object"; - }; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -130,9 +63,6 @@ export const OPERATIONS: readonly [{ readonly wire_type: "DOWNLOAD"; readonly method: "download"; readonly description: "Generate and download the current document as a PDF. Returns no data."; - readonly input_schema: { - readonly type: "object"; - }; readonly error_codes: readonly ["bad_request:no_document_loaded", "bad_request:missing_required_fields", "bad_request:download_blocked"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -141,16 +71,6 @@ export const OPERATIONS: readonly [{ readonly wire_type: "FOCUS_FIELD"; readonly method: "focusField"; readonly description: "Scroll an existing field into view and focus it, addressed by its id (from get_fields). Returns a hint describing the user action expected next."; - readonly input_schema: { - readonly type: "object"; - readonly properties: { - readonly fieldId: { - readonly type: "string"; - readonly description: "ID of the field to focus and scroll into view."; - }; - }; - readonly required: readonly ["fieldId"]; - }; readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:no_document_loaded", "bad_request:field_not_found"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -159,16 +79,6 @@ export const OPERATIONS: readonly [{ readonly wire_type: "GET_DOCUMENT_CONTENT"; readonly method: "getDocumentContent"; readonly description: "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }."; - readonly input_schema: { - readonly type: "object"; - readonly properties: { - readonly extractionMode: { - readonly description: "Extraction strategy: 'auto' (default) or 'ocr' to force optical recognition."; - readonly type: "string"; - readonly enum: readonly ["auto", "ocr"]; - }; - }; - }; readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -177,9 +87,6 @@ export const OPERATIONS: readonly [{ readonly wire_type: "GET_FIELDS"; readonly method: "getFields"; readonly description: "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. Returns { fields }."; - readonly input_schema: { - readonly type: "object"; - }; readonly error_codes: readonly ["bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -188,16 +95,6 @@ export const OPERATIONS: readonly [{ readonly wire_type: "GO_TO"; readonly method: "goTo"; readonly description: "Scroll the editor to a specific 1-based page. Returns no data."; - readonly input_schema: { - readonly type: "object"; - readonly properties: { - readonly page: { - readonly type: "integer"; - readonly description: "1-based page to navigate to."; - }; - }; - readonly required: readonly ["page"]; - }; readonly error_codes: readonly ["bad_request:invalid_page", "bad_request:page_out_of_range"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -206,24 +103,6 @@ export const OPERATIONS: readonly [{ readonly wire_type: "LOAD_DOCUMENT"; readonly method: "loadDocument"; readonly description: "Load a document into the editor from a base64 data URL. This is a host/setup action (no agentic tool); it returns no data."; - readonly input_schema: { - readonly type: "object"; - readonly properties: { - readonly dataUrl: { - readonly type: "string"; - readonly description: "The document to load, as a data URL."; - }; - readonly name: { - readonly description: "Optional display name for the document."; - readonly type: "string"; - }; - readonly page: { - readonly description: "Optional 1-based page to open the document on."; - readonly type: "integer"; - }; - }; - readonly required: readonly ["dataUrl"]; - }; readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:invalid_page"]; readonly is_agentic_tool: false; readonly has_output: false; @@ -232,20 +111,6 @@ export const OPERATIONS: readonly [{ readonly wire_type: "MOVE_PAGE"; readonly method: "movePage"; readonly description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled."; - readonly input_schema: { - readonly type: "object"; - readonly properties: { - readonly fromPage: { - readonly type: "integer"; - readonly description: "1-based current position of the page to move."; - }; - readonly toPage: { - readonly type: "integer"; - readonly description: "1-based destination position for the page."; - }; - }; - readonly required: readonly ["fromPage", "toPage"]; - }; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -254,16 +119,6 @@ export const OPERATIONS: readonly [{ readonly wire_type: "ROTATE_PAGE"; readonly method: "rotatePage"; readonly description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled."; - readonly input_schema: { - readonly type: "object"; - readonly properties: { - readonly page: { - readonly type: "integer"; - readonly description: "1-based page to rotate 90 degrees clockwise."; - }; - }; - readonly required: readonly ["page"]; - }; readonly error_codes: readonly ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -272,21 +127,6 @@ export const OPERATIONS: readonly [{ readonly wire_type: "SELECT_TOOL"; readonly method: "selectTool"; readonly description: "Activate a field-placement tool in the editor toolbar so the user can draw that field type, or pass null to clear the active tool. Returns no data."; - readonly input_schema: { - readonly type: "object"; - readonly properties: { - readonly tool: { - readonly anyOf: readonly [{ - readonly type: "string"; - readonly enum: readonly ["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT"]; - }, { - readonly type: "null"; - }]; - readonly description: "Tool to activate, or null to deselect."; - }; - }; - readonly required: readonly ["tool"]; - }; readonly error_codes: readonly ["bad_request:invalid_tool"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -295,24 +135,6 @@ export const OPERATIONS: readonly [{ readonly wire_type: "SET_FIELD_VALUE"; readonly method: "setFieldValue"; readonly description: "Set the value of an existing field addressed by its id (from get_fields), or clear it with null. If the field has options (see get_fields), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL (signature, picture). Returns no data."; - readonly input_schema: { - readonly type: "object"; - readonly properties: { - readonly fieldId: { - readonly type: "string"; - readonly description: "ID of the field to update."; - }; - readonly value: { - readonly anyOf: readonly [{ - readonly type: "string"; - }, { - readonly type: "null"; - }]; - readonly description: "New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL (signature/picture)."; - }; - }; - readonly required: readonly ["fieldId", "value"]; - }; readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:invalid_signature_url", "bad_request:no_document_loaded", "bad_request:read_only", "bad_request:field_not_found"]; readonly is_agentic_tool: true; readonly has_output: false; @@ -321,16 +143,6 @@ export const OPERATIONS: readonly [{ readonly wire_type: "SUBMIT"; readonly method: "submit"; readonly description: "Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data."; - readonly input_schema: { - readonly type: "object"; - readonly properties: { - readonly downloadCopy: { - readonly type: "boolean"; - readonly description: "When true, the signer also receives a downloaded copy on submit."; - }; - }; - readonly required: readonly ["downloadCopy"]; - }; readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:missing_required_fields"]; readonly is_agentic_tool: true; readonly has_output: false; diff --git a/embed/package.json b/embed/package.json index 21ac97c3..fe5ef9d4 100644 --- a/embed/package.json +++ b/embed/package.json @@ -64,7 +64,7 @@ "test": "vitest run", "test:watch": "vitest", "check:size": "npm run build && node scripts/check-bundle-size.mjs", - "check:exports": "node ../scripts/check-exports.mjs .", + "check:exports": "node ../scripts/check-exports.mjs . && node scripts/check-lazy-chunks.mjs", "check:api": "node ../scripts/check-api.mjs .", "check:contract": "node scripts/embed-contract.mjs", "fix:contract": "node scripts/embed-contract.mjs --fix" diff --git a/embed/scripts/check-bundle-size.mjs b/embed/scripts/check-bundle-size.mjs index 50fb238a..00d53045 100644 --- a/embed/scripts/check-bundle-size.mjs +++ b/embed/scripts/check-bundle-size.mjs @@ -1,9 +1,11 @@ // Bundle-size budget guard, run after `npm run build`. Gzips each public entry's local -// closure (the entry file plus the dist chunks it imports; peer deps are external and -// never counted) and fails if any entry exceeds its budget. Export loadability is guarded +// closure (the entry file plus the dist chunks it imports statically; peer deps are +// external and never counted) and fails if any entry exceeds its budget. A chunk an +// entry only `import()`s lazily is budgeted on its own row (it is downloaded only by +// the consumers that trigger it), so the two costs stay visible separately. Export loadability is guarded // separately by ../../scripts/check-exports.mjs (the `check:exports` script). -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { gzipSync } from 'node:zlib' @@ -13,9 +15,9 @@ const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist') // Gzip budget (bytes) per entry's local closure. Each cap is the current size plus // ~1 KB of headroom, so any non-trivial growth trips the gate and gets reviewed. // The zero-dep root carries the bridge + createEmbed (create + attach paths) + its -// actionable config validation. +// actionable config validation + the WebMCP opt-in hook. const BUDGETS = { - 'index.js': 8 * 1024, + 'index.js': 9 * 1024, 'protocol.js': 3.5 * 1024, 'schemas.js': 3 * 1024, 'tools.js': 5 * 1024, @@ -23,10 +25,18 @@ const BUDGETS = { 'tanstack-ai.js': 5.5 * 1024, } -const localImports = (file) => { +// Lazily-imported chunks, matched by their un-hashed name prefix. The WebMCP chunk +// carries the tool registration + the generated input-schema table. +const LAZY_BUDGETS = { + 'webmcp-': 5 * 1024, +} + +const importsOf = (file, pattern) => { const content = readFileSync(join(DIST, file), 'utf8') - return [...content.matchAll(/from\s*['"](\.\/[^'"]+)['"]/g)].map((match) => match[1].replace(/^\.\//, '')) + return [...content.matchAll(pattern)].map((match) => match[1].replace(/^\.\//, '')) } +const localImports = (file) => importsOf(file, /from\s*['"](\.\/[^'"]+)['"]/g) +const lazyImports = (file) => importsOf(file, /import\(['"](\.\/[^'"]+)['"]\)/g) const closureOf = (entry) => { const seen = new Set() @@ -46,14 +56,31 @@ const closureOf = (entry) => { const gzipBytes = (files) => files.reduce((total, file) => total + gzipSync(readFileSync(join(DIST, file))).length, 0) -const allWithinBudget = Object.entries(BUDGETS).map(([entry, budget]) => { - if (!existsSync(join(DIST, entry))) { - console.error(`✗ ${entry}: missing from dist (run \`npm run build\` first)`) - return false - } +const checkBudget = (entry, budget) => { const size = gzipBytes(closureOf(entry)) const ok = size <= budget console.log(`${ok ? '✓' : '✗'} ${entry}: ${size} B gzip (budget ${budget} B)`) return ok +} + +const entriesWithinBudget = Object.entries(BUDGETS).map(([entry, budget]) => { + if (!existsSync(join(DIST, entry))) { + console.error(`✗ ${entry}: missing from dist (run \`npm run build\` first)`) + return false + } + return checkBudget(entry, budget) +}) + +// Every lazy chunk an entry references must be built and budgeted; a lazy import +// with no budget row is an unmeasured download. +const lazyChunks = [...new Set(Object.keys(BUDGETS).flatMap((entry) => closureOf(entry).flatMap(lazyImports)))] +const lazyWithinBudget = lazyChunks.map((chunk) => { + const budgetEntry = Object.entries(LAZY_BUDGETS).find(([prefix]) => chunk.startsWith(prefix)) + if (budgetEntry === undefined || !readdirSync(DIST).includes(chunk)) { + console.error(`✗ ${chunk}: lazily imported but not built or not budgeted (add a LAZY_BUDGETS row)`) + return false + } + return checkBudget(chunk, budgetEntry[1]) }) -process.exit(allWithinBudget.every(Boolean) ? 0 : 1) + +process.exit([...entriesWithinBudget, ...lazyWithinBudget].every(Boolean) ? 0 : 1) diff --git a/embed/scripts/check-lazy-chunks.mjs b/embed/scripts/check-lazy-chunks.mjs new file mode 100644 index 00000000..18dc97e3 --- /dev/null +++ b/embed/scripts/check-lazy-chunks.mjs @@ -0,0 +1,35 @@ +// Load guard for the chunks the built entries only import lazily, run after +// `npm run build`. ../../scripts/check-exports.mjs loads every public subpath, but a +// lazily-imported chunk is reached by no subpath, so a chunk that resolves but throws +// at load (in either module format) would fail in the consumer's browser, not in CI. + +import { readdirSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist') +const require = createRequire(import.meta.url) + +const lazyChunks = readdirSync(DIST).filter((file) => /^webmcp-.*\.(js|cjs)$/.test(file)) +if (lazyChunks.length === 0) { + console.error('✗ no lazy webmcp chunk in dist (run `npm run build` first)') + process.exit(1) +} + +const results = [] +for (const chunk of lazyChunks) { + const path = join(DIST, chunk) + try { + const loaded = chunk.endsWith('.cjs') ? require(path) : await import(path) + if (typeof loaded.registerWebMCPTools !== 'function') { + throw new Error('registerWebMCPTools is not exported') + } + console.log(`✓ ${chunk}`) + results.push(true) + } catch (error) { + console.error(`✗ ${chunk}: ${error.code ?? error.message}`) + results.push(false) + } +} +process.exit(results.every(Boolean) ? 0 : 1) diff --git a/embed/scripts/generate.mjs b/embed/scripts/generate.mjs index 12e0a16c..8c740f4a 100644 --- a/embed/scripts/generate.mjs +++ b/embed/scripts/generate.mjs @@ -3,13 +3,16 @@ // source of truth; this script is the only consumer that re-materializes it as // TypeScript. Run via `npm run generate` (wired into prebuild + pretest). // -// Two outputs, both derived from one source so they cannot hand-drift: +// Three outputs, all derived from one source so they cannot hand-drift: // - src/generated/contract.ts : zero-runtime-dep plain TS types + const tables // (locales, error codes, operations, events). // The zero-dep root imports only from here. // - src/generated/schemas.ts : zod schemas (peer dep). Each schema is compile-time // drift-guarded against the plain type in contract.ts, // so a divergence fails `tsc`. +// - src/generated/tool-input-schemas.ts : the agentic operations' input schemas as +// plain JSON (camelCase keys), read only by the +// lazily-loaded WebMCP module. // // The JSON Schema vocabulary in embed-api.json is closed and small (object/string/ // integer/number/boolean/null/array/enum/const/anyOf), so the emitter below covers @@ -352,6 +355,11 @@ const toolInputSchema = (node) => { throw new Error(`Unsupported tool input schema root (expected an object): ${JSON.stringify(node)}`) } const properties = node.properties ?? {} + for (const required of node.required ?? []) { + if (!(required in properties)) { + throw new Error(`Tool input schema requires '${required}' but declares no such property: ${JSON.stringify(node)}`) + } + } const camelProperties = Object.fromEntries( Object.entries(properties).map(([key, property]) => [toCamel(key), toolInputSchemaProperty(property)]), ) @@ -363,13 +371,25 @@ const toolInputSchema = (node) => { } const toolInputSchemaProperty = (node) => { assertKnownKeywords(node) - if (node.type === 'object') { - return { ...toolInputSchema(node), ...(node.description !== undefined ? { description: node.description } : {}) } + if (node.const !== undefined || Array.isArray(node.enum)) { + return node } - return { - ...node, - ...(node.items !== undefined ? { items: toolInputSchemaProperty(node.items) } : {}), - ...(Array.isArray(node.anyOf) ? { anyOf: node.anyOf.map(toolInputSchemaProperty) } : {}), + if (Array.isArray(node.anyOf)) { + return { ...node, anyOf: node.anyOf.map(toolInputSchemaProperty) } + } + switch (node.type) { + case 'string': + case 'integer': + case 'number': + case 'boolean': + case 'null': + return node + case 'array': + return { ...node, items: toolInputSchemaProperty(node.items) } + case 'object': + return { ...toolInputSchema(node), ...(node.description !== undefined ? { description: node.description } : {}) } + default: + throw new Error(`Unsupported JSON Schema node for a tool input schema: ${JSON.stringify(node)}`) } } @@ -382,7 +402,6 @@ const opMeta = contract.operations.map((op) => { ` wire_type: ${JSON.stringify(op.request_type.toUpperCase())},\n` + ` method: ${JSON.stringify(toCamel(op.request_type))},\n` + ` description: ${JSON.stringify(op.description)},\n` + - ` input_schema: ${JSON.stringify(toolInputSchema(op.input_schema))},\n` + ` error_codes: [${op.error_codes.map((c) => JSON.stringify(c)).join(', ')}] as const,\n` + ` is_agentic_tool: ${!NON_AGENTIC_OPERATIONS.has(op.request_type.toLowerCase())},\n` + ` has_output: ${op.output_schema.type !== 'null'},\n` + @@ -430,6 +449,32 @@ schemaLines.push('') writeFileSync(join(GENERATED_DIR, 'schemas.ts'), renderFile(schemaLines)) +// --- tool-input-schemas.ts (zero runtime deps, loaded only by the WebMCP module) --- + +const toolInputSchemaLines = [] +toolInputSchemaLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.') +toolInputSchemaLines.push('// The agentic operations\' input schemas as plain JSON Schema with camelCase keys (the') +toolInputSchemaLines.push('// SDK-side shape; the bridge lowers the keys to the wire). Read only by src/webmcp.ts,') +toolInputSchemaLines.push('// which is lazy-loaded, so this table never lands in an entry that did not opt in.') +toolInputSchemaLines.push("import type { AgenticToolName } from './contract'") +toolInputSchemaLines.push('') +toolInputSchemaLines.push('export type ToolInputSchema = {') +toolInputSchemaLines.push(" readonly type: 'object'") +toolInputSchemaLines.push(' readonly properties?: Readonly>') +toolInputSchemaLines.push(' readonly required?: readonly string[]') +toolInputSchemaLines.push('}') +toolInputSchemaLines.push('') +toolInputSchemaLines.push('export const TOOL_INPUT_SCHEMAS = {') +for (const op of contract.operations) { + if (NON_AGENTIC_OPERATIONS.has(op.request_type.toLowerCase())) { + continue + } + toolInputSchemaLines.push(` ${toCamel(op.request_type)}: ${JSON.stringify(toolInputSchema(op.input_schema))},`) +} +toolInputSchemaLines.push('} as const satisfies Record') + +writeFileSync(join(GENERATED_DIR, 'tool-input-schemas.ts'), renderFile(toolInputSchemaLines)) + // --- drift.ts (compile-time drift guards; type-checked, not bundled) -------- // One exported tuple gathers every guard so noUnusedLocals stays happy while the // type-parameter constraints still fail the build the instant a representation @@ -488,5 +533,5 @@ writeFileSync(join(GENERATED_DIR, 'tools.ts'), renderFile(toolLines)) console.log( `Generated contract.ts (${contract.operations.length} ops, ${contract.events.length} events, ` + - `${contract.locales.length} locales, ${editorErrorCodes.length} editor error codes) + schemas.ts`, + `${contract.locales.length} locales, ${editorErrorCodes.length} editor error codes) + schemas.ts + tool-input-schemas.ts`, ) diff --git a/embed/src/bridge.ts b/embed/src/bridge.ts index 210a96cd..034866fa 100644 --- a/embed/src/bridge.ts +++ b/embed/src/bridge.ts @@ -159,9 +159,38 @@ export const attachEmbed = ({ handler: (data: EditorEventMap[TEventType]) => void, ): (() => void) => channels[type].subscribe(handler) + // WebMCP tools are registered once the editor is alive (an agent enumerating tools + // at page load must not post into an iframe that has no listener yet), only when the + // embedder opted in and the page exposes a model context: the module and the schema + // table it reads load for no one else. Aborting the signal on dispose unregisters + // every tool. + const webMCPController = new AbortController() + const webMCPOptions = enableWebMCP === undefined || enableWebMCP === false ? null : enableWebMCP + let webMCPStarted = false + const startWebMCP = (): void => { + if (webMCPOptions === null || webMCPStarted) { + return + } + webMCPStarted = true + if (!('modelContext' in document) && !('modelContext' in navigator)) { + logger.info('webmcp.unavailable', { reason: 'no_model_context' }) + return + } + void import('./webmcp') + .then(({ registerWebMCPTools }) => + registerWebMCPTools({ dispatch: sendRequest, options: webMCPOptions, signal: webMCPController.signal, logger }), + ) + .catch((error: unknown) => { + logger.error('webmcp.load_failed', { message: error instanceof Error ? error.message : String(error) }) + }) + } + const transitionTo = (next: BridgeState): void => { state = next onStateChange?.(next) + if (next.kind !== 'booting') { + startWebMCP() + } } const sendRequest = (wireType: WireType, data: unknown): Promise> => @@ -469,20 +498,6 @@ export const attachEmbed = ({ submit: (input) => sendRequest('SUBMIT', input), } satisfies IframeActions - // The WebMCP module (and the operations table it reads) loads only for an embedder - // that opts in; aborting the signal on dispose unregisters every tool it registered. - const webMCPController = new AbortController() - const webMCPOptions = enableWebMCP === undefined || enableWebMCP === false ? null : enableWebMCP - if (webMCPOptions !== null) { - void import('./webmcp') - .then(({ registerWebMCPTools }) => - registerWebMCPTools({ dispatch: sendRequest, options: webMCPOptions, signal: webMCPController.signal, logger }), - ) - .catch((error: unknown) => { - logger.error('webmcp.load_failed', { message: error instanceof Error ? error.message : String(error) }) - }) - } - const dispose = (): void => { if (disposed) { return diff --git a/embed/src/generated/contract.ts b/embed/src/generated/contract.ts index 8e880c01..a35fdc12 100644 --- a/embed/src/generated/contract.ts +++ b/embed/src/generated/contract.ts @@ -61,7 +61,6 @@ export const OPERATIONS = [ wire_type: "CREATE_FIELD", method: "createField", description: "Create a new overlay field of the given type at an (x, y) position and size (in PDF points) on a 1-based page. Returns { field_id } for the created field. Requires editing to be enabled.", - input_schema: {"type":"object","properties":{"type":{"type":"string","enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"],"description":"Field type to create."},"x":{"type":"number","description":"Field x position, in PDF points."},"y":{"type":"number","description":"Field y position, in PDF points."},"width":{"type":"number","description":"Field width, in PDF points."},"height":{"type":"number","description":"Field height, in PDF points."},"page":{"type":"integer","description":"1-based page to place the field on."},"value":{"description":"Optional initial value. A string for text/checkbox fields, or a data URL for signature/picture fields.","type":"string"}},"required":["type","x","y","width","height","page"]}, error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:invalid_dimensions", "bad_request:invalid_value", "bad_request:page_out_of_range", "bad_request:page_not_found", "bad_request:invalid_field_type", "bad_request:invalid_signature_url"] as const, is_agentic_tool: true, has_output: true, @@ -71,7 +70,6 @@ export const OPERATIONS = [ wire_type: "DELETE_FIELDS", method: "deleteFields", description: "Delete overlay fields by id; omit field_ids to delete every field on the given 1-based page, or omit both field_ids and page to delete every overlay field in the document. Returns { deleted_count }. Destructive; requires editing to be enabled.", - input_schema: {"type":"object","properties":{"fieldIds":{"description":"IDs of the fields to delete. Omit to delete every field on the target page.","type":"array","items":{"type":"string"}},"page":{"description":"1-based page to scope the deletion to. Omit to target all pages.","type":"integer"}}}, error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_field_ids", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: true, @@ -81,7 +79,6 @@ export const OPERATIONS = [ wire_type: "DELETE_PAGES", method: "deletePages", description: "Delete one or more 1-based pages from the document (it cannot delete every visible page). Returns no data. Destructive; requires editing to be enabled.", - input_schema: {"type":"object","properties":{"pages":{"type":"array","items":{"type":"integer"},"description":"1-based page numbers to delete."}},"required":["pages"]}, error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: false, @@ -91,7 +88,6 @@ export const OPERATIONS = [ wire_type: "DETECT_FIELDS", method: "detectFields", description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled.", - input_schema: {"type":"object"}, error_codes: ["forbidden:editing_not_allowed", "bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: true, @@ -101,7 +97,6 @@ export const OPERATIONS = [ wire_type: "DOWNLOAD", method: "download", description: "Generate and download the current document as a PDF. Returns no data.", - input_schema: {"type":"object"}, error_codes: ["bad_request:no_document_loaded", "bad_request:missing_required_fields", "bad_request:download_blocked"] as const, is_agentic_tool: true, has_output: false, @@ -111,7 +106,6 @@ export const OPERATIONS = [ wire_type: "FOCUS_FIELD", method: "focusField", description: "Scroll an existing field into view and focus it, addressed by its id (from get_fields). Returns a hint describing the user action expected next.", - input_schema: {"type":"object","properties":{"fieldId":{"type":"string","description":"ID of the field to focus and scroll into view."}},"required":["fieldId"]}, error_codes: ["bad_request:invalid_value", "bad_request:no_document_loaded", "bad_request:field_not_found"] as const, is_agentic_tool: true, has_output: true, @@ -121,7 +115,6 @@ export const OPERATIONS = [ wire_type: "GET_DOCUMENT_CONTENT", method: "getDocumentContent", description: "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }.", - input_schema: {"type":"object","properties":{"extractionMode":{"description":"Extraction strategy: 'auto' (default) or 'ocr' to force optical recognition.","type":"string","enum":["auto","ocr"]}}}, error_codes: ["bad_request:invalid_value", "bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: true, @@ -131,7 +124,6 @@ export const OPERATIONS = [ wire_type: "GET_FIELDS", method: "getFields", description: "List every fillable field in the loaded document, including native dropdown and radio AcroFields. Each field reports its id, name, type, page, and current value. Call this first to discover field ids before reading or setting values. Returns { fields }.", - input_schema: {"type":"object"}, error_codes: ["bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: true, @@ -141,7 +133,6 @@ export const OPERATIONS = [ wire_type: "GO_TO", method: "goTo", description: "Scroll the editor to a specific 1-based page. Returns no data.", - input_schema: {"type":"object","properties":{"page":{"type":"integer","description":"1-based page to navigate to."}},"required":["page"]}, error_codes: ["bad_request:invalid_page", "bad_request:page_out_of_range"] as const, is_agentic_tool: true, has_output: false, @@ -151,7 +142,6 @@ export const OPERATIONS = [ wire_type: "LOAD_DOCUMENT", method: "loadDocument", description: "Load a document into the editor from a base64 data URL. This is a host/setup action (no agentic tool); it returns no data.", - input_schema: {"type":"object","properties":{"dataUrl":{"type":"string","description":"The document to load, as a data URL."},"name":{"description":"Optional display name for the document.","type":"string"},"page":{"description":"Optional 1-based page to open the document on.","type":"integer"}},"required":["dataUrl"]}, error_codes: ["bad_request:invalid_value", "bad_request:invalid_page"] as const, is_agentic_tool: false, has_output: false, @@ -161,7 +151,6 @@ export const OPERATIONS = [ wire_type: "MOVE_PAGE", method: "movePage", description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled.", - input_schema: {"type":"object","properties":{"fromPage":{"type":"integer","description":"1-based current position of the page to move."},"toPage":{"type":"integer","description":"1-based destination position for the page."}},"required":["fromPage","toPage"]}, error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: false, @@ -171,7 +160,6 @@ export const OPERATIONS = [ wire_type: "ROTATE_PAGE", method: "rotatePage", description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled.", - input_schema: {"type":"object","properties":{"page":{"type":"integer","description":"1-based page to rotate 90 degrees clockwise."}},"required":["page"]}, error_codes: ["forbidden:editing_not_allowed", "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found"] as const, is_agentic_tool: true, has_output: false, @@ -181,7 +169,6 @@ export const OPERATIONS = [ wire_type: "SELECT_TOOL", method: "selectTool", description: "Activate a field-placement tool in the editor toolbar so the user can draw that field type, or pass null to clear the active tool. Returns no data.", - input_schema: {"type":"object","properties":{"tool":{"anyOf":[{"type":"string","enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"]},{"type":"null"}],"description":"Tool to activate, or null to deselect."}},"required":["tool"]}, error_codes: ["bad_request:invalid_tool"] as const, is_agentic_tool: true, has_output: false, @@ -191,7 +178,6 @@ export const OPERATIONS = [ wire_type: "SET_FIELD_VALUE", method: "setFieldValue", description: "Set the value of an existing field addressed by its id (from get_fields), or clear it with null. If the field has options (see get_fields), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL (signature, picture). Returns no data.", - input_schema: {"type":"object","properties":{"fieldId":{"type":"string","description":"ID of the field to update."},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL (signature/picture)."}},"required":["fieldId","value"]}, error_codes: ["bad_request:invalid_value", "bad_request:invalid_signature_url", "bad_request:no_document_loaded", "bad_request:read_only", "bad_request:field_not_found"] as const, is_agentic_tool: true, has_output: false, @@ -201,7 +187,6 @@ export const OPERATIONS = [ wire_type: "SUBMIT", method: "submit", description: "Submit the completed document through the editor's finalization flow. This is irreversible. When download_copy is true the signer also gets a downloaded copy. Fails with missing_required_fields when required fields are unfilled. Returns no data.", - input_schema: {"type":"object","properties":{"downloadCopy":{"type":"boolean","description":"When true, the signer also receives a downloaded copy on submit."}},"required":["downloadCopy"]}, error_codes: ["bad_request:invalid_value", "bad_request:missing_required_fields"] as const, is_agentic_tool: true, has_output: false, diff --git a/embed/src/generated/tool-input-schemas.ts b/embed/src/generated/tool-input-schemas.ts new file mode 100644 index 00000000..59345321 --- /dev/null +++ b/embed/src/generated/tool-input-schemas.ts @@ -0,0 +1,28 @@ +// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand. +// The agentic operations' input schemas as plain JSON Schema with camelCase keys (the +// SDK-side shape; the bridge lowers the keys to the wire). Read only by src/webmcp.ts, +// which is lazy-loaded, so this table never lands in an entry that did not opt in. +import type { AgenticToolName } from './contract' + +export type ToolInputSchema = { + readonly type: 'object' + readonly properties?: Readonly> + readonly required?: readonly string[] +} + +export const TOOL_INPUT_SCHEMAS = { + createField: {"type":"object","properties":{"type":{"type":"string","enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"],"description":"Field type to create."},"x":{"type":"number","description":"Field x position, in PDF points."},"y":{"type":"number","description":"Field y position, in PDF points."},"width":{"type":"number","description":"Field width, in PDF points."},"height":{"type":"number","description":"Field height, in PDF points."},"page":{"type":"integer","description":"1-based page to place the field on."},"value":{"description":"Optional initial value. A string for text/checkbox fields, or a data URL for signature/picture fields.","type":"string"}},"required":["type","x","y","width","height","page"]}, + deleteFields: {"type":"object","properties":{"fieldIds":{"description":"IDs of the fields to delete. Omit to delete every field on the target page.","type":"array","items":{"type":"string"}},"page":{"description":"1-based page to scope the deletion to. Omit to target all pages.","type":"integer"}}}, + deletePages: {"type":"object","properties":{"pages":{"type":"array","items":{"type":"integer"},"description":"1-based page numbers to delete."}},"required":["pages"]}, + detectFields: {"type":"object"}, + download: {"type":"object"}, + focusField: {"type":"object","properties":{"fieldId":{"type":"string","description":"ID of the field to focus and scroll into view."}},"required":["fieldId"]}, + getDocumentContent: {"type":"object","properties":{"extractionMode":{"description":"Extraction strategy: 'auto' (default) or 'ocr' to force optical recognition.","type":"string","enum":["auto","ocr"]}}}, + getFields: {"type":"object"}, + goTo: {"type":"object","properties":{"page":{"type":"integer","description":"1-based page to navigate to."}},"required":["page"]}, + movePage: {"type":"object","properties":{"fromPage":{"type":"integer","description":"1-based current position of the page to move."},"toPage":{"type":"integer","description":"1-based destination position for the page."}},"required":["fromPage","toPage"]}, + rotatePage: {"type":"object","properties":{"page":{"type":"integer","description":"1-based page to rotate 90 degrees clockwise."}},"required":["page"]}, + selectTool: {"type":"object","properties":{"tool":{"anyOf":[{"type":"string","enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"]},{"type":"null"}],"description":"Tool to activate, or null to deselect."}},"required":["tool"]}, + setFieldValue: {"type":"object","properties":{"fieldId":{"type":"string","description":"ID of the field to update."},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL (signature/picture)."}},"required":["fieldId","value"]}, + submit: {"type":"object","properties":{"downloadCopy":{"type":"boolean","description":"When true, the signer also receives a downloaded copy on submit."}},"required":["downloadCopy"]}, +} as const satisfies Record diff --git a/embed/src/mount.ts b/embed/src/mount.ts index 07163f85..e87969e1 100644 --- a/embed/src/mount.ts +++ b/embed/src/mount.ts @@ -200,6 +200,26 @@ const assertValidFileArm = (file: unknown): void => { } } +// `enableWebMCP` withholds irreversible operations from an agent, so a malformed value +// from an untyped JS caller must fail loud rather than register everything. +const assertValidWebMCPOptions = (enableWebMCP: unknown): void => { + if (enableWebMCP === undefined || typeof enableWebMCP === 'boolean') { + return + } + const isExcludeList = + typeof enableWebMCP === 'object' && + enableWebMCP !== null && + 'exclude' in enableWebMCP && + Array.isArray(enableWebMCP.exclude) && + enableWebMCP.exclude.every((name) => typeof name === 'string') + if (!isExcludeList) { + throw new EmbedConfigError( + 'invalid_config', + `enableWebMCP must be a boolean or { exclude: string[] } (received ${describeValue(enableWebMCP)}).`, + ) + } +} + const assertValidDocument = (document: unknown): void => { if (document === undefined) { return @@ -663,6 +683,7 @@ export const createEmbed = (args: CreateEmbedArgs): Embed => { throw new EmbedConfigError('invalid_config', `baseDomain must be a string (received ${describeValue(args.baseDomain)}).`) } assertValidDocument(args.document) + assertValidWebMCPOptions(args.enableWebMCP) const baseDomain = args.baseDomain ?? DEFAULT_BASE_DOMAIN // A SimplePDF documents URL carries its own origin (a possibly-different // companyIdentifier subdomain); the bridge then targets that origin instead of diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts index 48b8638c..32753e3a 100644 --- a/embed/src/webmcp.ts +++ b/embed/src/webmcp.ts @@ -5,11 +5,13 @@ // The host page is where an in-browser agent looks: tools registered inside the // editor iframe are not discovered, which is why the SDK lifts them here. // -// Loaded lazily by the bridge only when `enableWebMCP` is set, so an embedder that -// does not opt in downloads none of this (nor the operations table it reads). +// Loaded lazily by the bridge, once the editor is ready and only when `enableWebMCP` +// is set and the page exposes a model context, so nothing here (nor the schema table +// it reads) is downloaded otherwise. // CF: https://webmachinelearning.github.io/webmcp/ import { OPERATIONS, type AgenticToolName, type WireType } from './generated/contract' +import { TOOL_INPUT_SCHEMAS, type ToolInputSchema } from './generated/tool-input-schemas' import type { BridgeLogger } from './logger' import type { BridgeResult } from './types' @@ -18,14 +20,14 @@ import type { BridgeResult } from './types' export type WebMCPOptions = boolean | { exclude: readonly AgenticToolName[] } // The slice of the WebMCP surface this module touches, typed structurally so the -// zero-dependency root pulls in no type package. -type ToolAnnotations = { readOnlyHint?: boolean; destructiveHint?: boolean } +// zero-dependency root pulls in no type package. `readOnlyHint` and +// `untrustedContentHint` are the specification's annotations; `destructiveHint` is +// MCP's, read by runtimes that carry MCP's hint vocabulary and ignored by the others. +type ToolAnnotations = { readOnlyHint?: boolean; untrustedContentHint?: boolean; destructiveHint?: boolean } +// The MCP tool-result envelope. The specification serializes whatever `execute` +// resolves with; this shape is what the runtimes in the field read (and what the +// editor's own in-page tools return), a failed Result additionally flagged `isError`. type CallToolResult = { content: Array<{ type: 'text'; text: string }>; isError?: boolean } -type ToolInputSchema = { - readonly type: 'object' - readonly properties?: Readonly> - readonly required?: readonly string[] -} type WebMCPTool = { name: string description: string @@ -40,10 +42,12 @@ type ModelContext = { type Operation = (typeof OPERATIONS)[number] type AgenticOperation = Extract -// MCP planners treat an unset destructiveHint as true, so every operation declares -// one: true for the ones that remove or reorder content or finalize the document; -// readOnlyHint marks the pure readers. The Record makes a new operation a compile -// error until it is annotated. +// The two readers return document-derived content (field values, extracted text), +// which is untrusted from the page's perspective. Every writer declares whether it +// removes or reorders content or finalizes the document; setting a field value is +// not destructive here because the person reviews every value in the editor before +// the one irreversible step, submit. Same map as the editor's in-page tools. The +// Record makes a new operation a compile error until it is annotated. const TOOL_ANNOTATIONS = { createField: { destructiveHint: false }, deleteFields: { destructiveHint: true }, @@ -51,8 +55,8 @@ const TOOL_ANNOTATIONS = { detectFields: { destructiveHint: false }, download: { destructiveHint: false }, focusField: { destructiveHint: false }, - getDocumentContent: { readOnlyHint: true }, - getFields: { readOnlyHint: true }, + getDocumentContent: { readOnlyHint: true, untrustedContentHint: true }, + getFields: { readOnlyHint: true, untrustedContentHint: true }, goTo: { destructiveHint: false }, movePage: { destructiveHint: true }, rotatePage: { destructiveHint: true }, @@ -63,6 +67,8 @@ const TOOL_ANNOTATIONS = { const isAgenticOperation = (operation: Operation): operation is AgenticOperation => operation.is_agentic_tool +const isAgenticToolName = (value: string): value is AgenticToolName => value in TOOL_INPUT_SCHEMAS + const isModelContext = (value: unknown): value is ModelContext => typeof value === 'object' && value !== null && 'registerTool' in value && typeof value.registerTool === 'function' @@ -76,13 +82,15 @@ const readModelContext = (): ModelContext | null => { return null } -// The editor Result, serialized as the JSON-text CallToolResult an agent reads; a -// failed Result is additionally flagged `isError` (the MCP convention). const toCallToolResult = (result: BridgeResult): CallToolResult => ({ content: [{ type: 'text', text: JSON.stringify(result) }], ...(result.success ? {} : { isError: true }), }) +// A model context is a page-level singleton keyed by tool name, so two embeds on one +// page would collide; the first registration of a name wins and the rest are reported. +const liveToolNames = new Set() + export const registerWebMCPTools = ({ dispatch, options, @@ -94,29 +102,47 @@ export const registerWebMCPTools = ({ signal: AbortSignal logger: BridgeLogger }): void => { + if (signal.aborted) { + return + } const modelContext = readModelContext() - if (modelContext === null || signal.aborted) { + if (modelContext === null) { + logger.warn('webmcp.unavailable', { reason: 'invalid_model_context' }) return } - const excluded = new Set(options === true ? [] : options.exclude) + const excluded = new Set() + for (const name of options === true ? [] : options.exclude) { + if (isAgenticToolName(name)) { + excluded.add(name) + } else { + logger.warn('webmcp.unknown_excluded_tool', { tool: name }) + } + } for (const operation of OPERATIONS) { if (!isAgenticOperation(operation) || excluded.has(operation.method)) { continue } + if (liveToolNames.has(operation.method)) { + logger.warn('webmcp.tool_already_registered', { tool: operation.method }) + continue + } const tool: WebMCPTool = { name: operation.method, description: operation.description, - inputSchema: operation.input_schema, + inputSchema: TOOL_INPUT_SCHEMAS[operation.method], annotations: TOOL_ANNOTATIONS[operation.method], - // A no-input tool may be called without arguments; the wire always carries an object. + // A nullish input becomes an empty payload (the no-input operations' wire shape). execute: async (input) => toCallToolResult(await dispatch(operation.wire_type, input ?? {})), } + liveToolNames.add(tool.name) + signal.addEventListener('abort', () => liveToolNames.delete(tool.name), { once: true }) // Registration is best-effort: a runtime that rejects one tool must not take the // others down or escape as an unhandled rejection. void (async (): Promise => { try { await modelContext.registerTool(tool, { signal }) } catch (error) { + liveToolNames.delete(tool.name) logger.error('webmcp.register_tool_failed', { tool: tool.name, message: error instanceof Error ? error.message : String(error), diff --git a/embed/test/mount.test.ts b/embed/test/mount.test.ts index f8b40c72..f7370df8 100644 --- a/embed/test/mount.test.ts +++ b/embed/test/mount.test.ts @@ -382,4 +382,20 @@ describe(createEmbed.name, () => { // @ts-expect-error exercising the runtime guard for untyped JS callers expect(() => createEmbed({ target: '#root', companyIdentifier: 'acme', baseDomain: 123 })).toThrow(/baseDomain must be a string/) }) + + // Every malformed shape an untyped JS caller can produce fails loud: `exclude` is + // the control that withholds irreversible operations, so it must never fail open. + it.each([ + ['a string exclude', { exclude: 'submit' }], + ['an option object without exclude', {}], + ['a stringly-typed flag', 'false'], + ['a number', 0], + ['null', null], + ['a non-string exclude entry', { exclude: ['submit', 7] }], + ])('throws EmbedConfigError when enableWebMCP is %s', (_label, enableWebMCP) => { + document.body.innerHTML = '
' + const malformedArgs: unknown = { target: '#root', companyIdentifier: 'acme', enableWebMCP } + // @ts-expect-error exercising the runtime guard for untyped JS callers + expect(() => createEmbed(malformedArgs)).toThrow(/enableWebMCP must be a boolean or \{ exclude: string\[\] \}/) + }) }) diff --git a/embed/test/webmcp.test.ts b/embed/test/webmcp.test.ts index 39b5b2d4..adfaf5f5 100644 --- a/embed/test/webmcp.test.ts +++ b/embed/test/webmcp.test.ts @@ -10,7 +10,7 @@ type RegisteredTool = { name: string description: string inputSchema: { type: string; properties?: Record; required?: readonly string[] } - annotations: { readOnlyHint?: boolean; destructiveHint?: boolean } + annotations: { readOnlyHint?: boolean; untrustedContentHint?: boolean; destructiveHint?: boolean } execute: (input: unknown) => Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> } type FakeModelContext = { @@ -54,8 +54,16 @@ const installModelContext = ( return modelContext } +const makeLogger = (): BridgeLogger => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }) + type Posted = { type: string; request_id: string; data: unknown } -type Harness = { embed: Embed; posted: Posted[]; reply: (request: Posted, result: unknown) => void } +type Harness = { + embed: Embed + posted: Posted[] + reply: (request: Posted, result: unknown) => void + // Registration waits for the editor to be alive; this is the editor announcing it. + markEditorReady: () => void +} const harnesses: Harness[] = [] @@ -73,22 +81,30 @@ const makeHarness = (args: Pick): Ha } }) const embed = attachEmbed({ getIframe: () => iframe, editorOrigin: EDITOR_ORIGIN, ...args }) - const reply = (request: Posted, result: unknown): void => { + const receive = (message: unknown): void => { window.dispatchEvent( - new MessageEvent('message', { - data: JSON.stringify({ type: 'REQUEST_RESULT', data: { request_id: request.request_id, result } }), - origin: EDITOR_ORIGIN, - source: contentWindow, - }), + new MessageEvent('message', { data: JSON.stringify(message), origin: EDITOR_ORIGIN, source: contentWindow }), ) } - const harness: Harness = { embed, posted, reply } + const harness: Harness = { + embed, + posted, + reply: (request, result) => receive({ type: 'REQUEST_RESULT', data: { request_id: request.request_id, result } }), + markEditorReady: () => receive({ type: 'EDITOR_READY', data: {} }), + } harnesses.push(harness) return harness } -// Registration is asynchronous (the WebMCP module is lazy-loaded), so every test -// waits for the expected tool set rather than reading it synchronously. +// A ready embed with the option on: registration is asynchronous (the WebMCP module +// is lazy-loaded), so callers wait for the expected tool count rather than reading it +// synchronously. +const mountReady = (args: Pick): Harness => { + const harness = makeHarness(args) + harness.markEditorReady() + return harness +} + const waitForTools = (modelContext: FakeModelContext, count: number): Promise => vi.waitFor(() => expect(modelContext.registered).toHaveLength(count)) @@ -142,7 +158,7 @@ describe('attachEmbed({ enableWebMCP })', () => { it('registers every agentic operation on document.modelContext with the SDK name, description, camelCase input schema and an explicit behavior hint', async () => { const modelContext = installModelContext(document) - makeHarness({ enableWebMCP: true }) + mountReady({ enableWebMCP: true }) await waitForTools(modelContext, 14) expect(modelContext.registered.map((tool) => tool.name).sort()).toEqual(AGENTIC_TOOL_NAMES) @@ -156,13 +172,34 @@ describe('attachEmbed({ enableWebMCP })', () => { const hasExplicitHint = tool.annotations.readOnlyHint === true || typeof tool.annotations.destructiveHint === 'boolean' expect(hasExplicitHint, `${tool.name} declares no behavior hint`).toBe(true) } - expect(findTool(modelContext, 'getFields').annotations).toEqual({ readOnlyHint: true }) + // The readers hand document-derived content to the agent: read-only AND untrusted. + expect(findTool(modelContext, 'getFields').annotations).toEqual({ readOnlyHint: true, untrustedContentHint: true }) + expect(findTool(modelContext, 'getDocumentContent').annotations).toEqual({ + readOnlyHint: true, + untrustedContentHint: true, + }) expect(findTool(modelContext, 'submit').annotations).toEqual({ destructiveHint: true }) }) - it('withholds the excluded operations and registers the rest', async () => { + it('waits for the editor to be ready before registering, so an early tool call cannot post into a listener-less iframe', async () => { + const modelContext = installModelContext(document) + const registerTool = vi.spyOn(modelContext, 'registerTool') + const booting = makeHarness({ enableWebMCP: true }) + // Control: a second, ready embed proves the lazy path had time to run while the + // booting one still registered nothing. + const control = installModelContext(navigator) + mountReady({ enableWebMCP: true }) + await waitForTools(control, 0) + expect(registerTool).not.toHaveBeenCalled() + + booting.markEditorReady() + await waitForTools(modelContext, 14) + }) + + it('withholds the excluded operations, registers the rest, and reports an exclusion that names no tool', async () => { const modelContext = installModelContext(document) - makeHarness({ enableWebMCP: { exclude: ['submit', 'deletePages', 'movePage', 'rotatePage'] } }) + const logger = makeLogger() + mountReady({ enableWebMCP: { exclude: ['submit', 'deletePages', 'movePage', 'rotatePage'] }, logger }) await waitForTools(modelContext, 10) const names = modelContext.registered.map((tool) => tool.name) @@ -170,11 +207,20 @@ describe('attachEmbed({ enableWebMCP })', () => { expect(names).toContain('getFields') expect(names).not.toContain('submit') expect(names).not.toContain('deletePages') + expect(logger.warn).not.toHaveBeenCalled() + + // A typo in `exclude` (an untyped caller) registers the tool it meant to withhold: + // the SDK says so instead of staying silent. + modelContext.registered.length = 0 + const typo = makeLogger() + const excludeWithTypo: AttachEmbedArgs['enableWebMCP'] = JSON.parse('{"exclude":["sumbit"]}') + mountReady({ enableWebMCP: excludeWithTypo, logger: typo }) + await vi.waitFor(() => expect(typo.warn).toHaveBeenCalledWith('webmcp.unknown_excluded_tool', { tool: 'sumbit' })) }) it('executes a tool call as the operation request on the wire and returns the editor Result as a JSON-text tool result', async () => { const modelContext = installModelContext(document) - const harness = makeHarness({ enableWebMCP: true }) + const harness = mountReady({ enableWebMCP: true }) await waitForTools(modelContext, 14) const pendingResult = findTool(modelContext, 'setFieldValue').execute({ fieldId: 'f1', value: 'Jane' }) @@ -186,9 +232,9 @@ describe('attachEmbed({ enableWebMCP })', () => { expect(JSON.parse(toolResult.content[0]?.text ?? '')).toEqual({ success: true, data: null }) }) - it('flags a failed editor Result as an error tool result', async () => { + it('flags a failed editor Result as an error tool result that still carries the error code', async () => { const modelContext = installModelContext(document) - const harness = makeHarness({ enableWebMCP: true }) + const harness = mountReady({ enableWebMCP: true }) await waitForTools(modelContext, 14) const pendingResult = findTool(modelContext, 'goTo').execute({ page: 99 }) @@ -204,7 +250,7 @@ describe('attachEmbed({ enableWebMCP })', () => { it('sends an empty payload when a no-input tool is called without arguments', async () => { const modelContext = installModelContext(document) - const harness = makeHarness({ enableWebMCP: true }) + const harness = mountReady({ enableWebMCP: true }) await waitForTools(modelContext, 14) void findTool(modelContext, 'detectFields').execute(undefined) @@ -214,7 +260,7 @@ describe('attachEmbed({ enableWebMCP })', () => { it('unregisters every tool when the embed is disposed', async () => { const modelContext = installModelContext(document) - const harness = makeHarness({ enableWebMCP: true }) + const harness = mountReady({ enableWebMCP: true }) await waitForTools(modelContext, 14) expect(modelContext.liveToolNames()).toHaveLength(14) @@ -222,33 +268,75 @@ describe('attachEmbed({ enableWebMCP })', () => { expect(modelContext.liveToolNames()).toEqual([]) }) + it('registers nothing when the embed is disposed before the lazy module resolves', async () => { + const modelContext = installModelContext(document) + const disposedEarly = mountReady({ enableWebMCP: true }) + disposedEarly.embed.lifecycle.dispose() + // Control: a later embed on the same context registers its full set, proving the + // early one's lazy load had every chance to run and registered nothing. + mountReady({ enableWebMCP: true }) + await waitForTools(modelContext, 14) + expect(modelContext.liveToolNames()).toHaveLength(14) + }) + it('registers nothing when the option is off, even with a model context present', async () => { const modelContext = installModelContext(document) const registerTool = vi.spyOn(modelContext, 'registerTool') - makeHarness({}) - makeHarness({ enableWebMCP: false }) - // Give a would-be lazy registration every chance to run before asserting. - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(registerTool).not.toHaveBeenCalled() + mountReady({}) + mountReady({ enableWebMCP: false }) + // Control: a ready embed with the option on registers, proving the off ones had + // the same chance and took none of it. + mountReady({ enableWebMCP: true }) + await waitForTools(modelContext, 14) + expect(registerTool).toHaveBeenCalledTimes(14) + }) + + it('lets the first embed on a page own each tool name and reports the collision for a second one', async () => { + const modelContext = installModelContext(document) + const logger = makeLogger() + const first = mountReady({ enableWebMCP: true }) + await waitForTools(modelContext, 14) + + mountReady({ enableWebMCP: true, logger }) + await vi.waitFor(() => + expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'submit' }), + ) + expect(logger.warn).toHaveBeenCalledTimes(14) + expect(modelContext.registered).toHaveLength(14) + + // Disposing the owner frees the names for the next embed. + first.embed.lifecycle.dispose() + mountReady({ enableWebMCP: true }) + await waitForTools(modelContext, 28) }) it('falls back to navigator.modelContext when the document exposes none', async () => { const modelContext = installModelContext(navigator) - makeHarness({ enableWebMCP: true }) + mountReady({ enableWebMCP: true }) await waitForTools(modelContext, 14) expect(modelContext.liveToolNames()).toHaveLength(14) }) - it('is a no-op without a model context and never throws', async () => { - expect(() => makeHarness({ enableWebMCP: true })).not.toThrow() - await new Promise((resolve) => setTimeout(resolve, 20)) - expect('modelContext' in document).toBe(false) + it('reports an absent model context instead of loading the module, and never throws', async () => { + const logger = makeLogger() + expect(() => mountReady({ enableWebMCP: true, logger })).not.toThrow() + await vi.waitFor(() => expect(logger.info).toHaveBeenCalledWith('webmcp.unavailable', { reason: 'no_model_context' })) + expect(logger.error).not.toHaveBeenCalled() + }) + + it('reports a model context without registerTool as invalid', async () => { + Object.defineProperty(document, 'modelContext', { configurable: true, value: {} }) + const logger = makeLogger() + mountReady({ enableWebMCP: true, logger }) + await vi.waitFor(() => + expect(logger.warn).toHaveBeenCalledWith('webmcp.unavailable', { reason: 'invalid_model_context' }), + ) }) - it('keeps registering the other tools when the runtime rejects one, and logs the failure', async () => { + it('keeps registering the other tools when the runtime rejects one, logs the failure, and frees that name', async () => { const modelContext = installModelContext(document, { rejectTool: 'download' }) - const logger: BridgeLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } - makeHarness({ enableWebMCP: true, logger }) + const logger = makeLogger() + mountReady({ enableWebMCP: true, logger }) await waitForTools(modelContext, 13) expect(modelContext.registered.map((tool) => tool.name)).not.toContain('download') diff --git a/react/README.md b/react/README.md index 5296cccf..ae0aed9e 100644 --- a/react/README.md +++ b/react/README.md @@ -322,7 +322,7 @@ See [Retrieving PDF Data](../README.md#retrieving-pdf-data) for text extraction, enableWebMCP boolean | { exclude: AgenticToolName[] } No (defaults to off) - Register the editor operations as WebMCP tools on your page, where an in-browser agent (ChatGPT's browser, Chrome with WebMCP) discovers them; exclude withholds operations such as submit. See WebMCP site tools. + Register the editor operations as WebMCP tools on your page, where an in-browser agent (ChatGPT's browser, Chrome with WebMCP) discovers them; exclude withholds operations such as submit. Changing the value remounts the editor (registration happens at mount), so keep it stable while the person is editing. See WebMCP site tools. style diff --git a/react/etc/index.api.md b/react/etc/index.api.md index 15870c09..8647be0a 100644 --- a/react/etc/index.api.md +++ b/react/etc/index.api.md @@ -4,6 +4,7 @@ ```ts +import { AgenticToolName } from '@simplepdf/embed'; import type { BridgeLogger } from '@simplepdf/embed'; import type { BridgeResult } from '@simplepdf/embed'; import type { EditorEvent } from '@simplepdf/embed'; @@ -17,6 +18,8 @@ import type { SelectToolInput } from '@simplepdf/embed'; import type { SubmitInput } from '@simplepdf/embed'; import { WebMCPOptions } from '@simplepdf/embed'; +export { AgenticToolName } + // @public (undocumented) export type EmbedActions = Omit & { selectTool: (input: SelectToolInput | SelectToolInput['tool']) => Promise; @@ -49,6 +52,8 @@ export const useEmbed: () => { actions: EmbedActions; }; +export { WebMCPOptions } + // (No @packageDocumentation comment for this package) ``` diff --git a/react/src/embed-pdf.test.tsx b/react/src/embed-pdf.test.tsx index f7628be6..dc980890 100644 --- a/react/src/embed-pdf.test.tsx +++ b/react/src/embed-pdf.test.tsx @@ -21,9 +21,17 @@ describe('EmbedPDF (inline)', () => { }); Object.defineProperty(document, 'modelContext', { configurable: true, value: { registerTool } }); try { - const { unmount } = render( + const { container, unmount } = render( , ); + // Tools register once the editor announces itself. + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ type: 'EDITOR_READY', data: {} }), + origin: 'https://acme.simplepdf.com', + source: container.querySelector('iframe')?.contentWindow ?? null, + }), + ); await waitFor(() => expect(liveTools.size).toBe(13)); expect(liveTools.has('setFieldValue')).toBe(true); expect(liveTools.has('submit')).toBe(false); @@ -34,6 +42,15 @@ describe('EmbedPDF (inline)', () => { } }); + it('does not remount the editor when enableWebMCP is re-rendered as an equal value', () => { + const { container, rerender } = render( + , + ); + const iframe = container.querySelector('iframe'); + rerender(); + expect(container.querySelector('iframe')).toBe(iframe); + }); + it('renders the editor iframe inside the host element for the companyIdentifier origin', () => { const { container } = render(); const iframe = container.querySelector('iframe'); diff --git a/react/src/embed-pdf.tsx b/react/src/embed-pdf.tsx index 2dc08b92..e449fec6 100644 --- a/react/src/embed-pdf.tsx +++ b/react/src/embed-pdf.tsx @@ -204,9 +204,21 @@ const EmbedSurface = React.forwardRef((props, return `unserializable:${Object.keys(context).sort().join(',')}`; } }, [context]); - // Registration happens at mount, so a changed option remounts the editor; keyed on - // the serialized value so a fresh `{ exclude: [...] }` literal each render does not. - const webMCPKey = JSON.stringify(enableWebMCP ?? null); + // Registration happens at mount, so a changed option remounts the editor (and drops + // the person's edits). Keyed on the normalized value, so a fresh `{ exclude: [...] }` + // literal, a reordered list, or `undefined` vs `false` never remounts. The effect + // reads the option through a ref for the same reason the callbacks do. + const webMCPKey = ((): string => { + if (enableWebMCP === undefined || enableWebMCP === false) { + return 'off'; + } + if (enableWebMCP === true) { + return 'all'; + } + return `exclude:${[...enableWebMCP.exclude].sort().join(',')}`; + })(); + const enableWebMCPRef = React.useRef(enableWebMCP); + enableWebMCPRef.current = enableWebMCP; React.useEffect(() => { const container = containerRef.current; @@ -221,7 +233,7 @@ const EmbedSurface = React.forwardRef((props, locale, context, logger: stableLogger, - enableWebMCP, + enableWebMCP: enableWebMCPRef.current, }); assignRef(ref, toEmbedActions(embed)); // Forward each editor event to onEmbedEvent as the verbatim { type, data }. The diff --git a/react/src/index.tsx b/react/src/index.tsx index 49a86cf9..03cb65d9 100644 --- a/react/src/index.tsx +++ b/react/src/index.tsx @@ -11,4 +11,4 @@ export type { EmbedActions, EmbedEvent, EmbedPDFProps } from './embed-pdf'; // The imperative core (createEmbed, the bridge helpers) and the wire-protocol vocabulary stay // in @simplepdf/embed: a React app uses / useEmbed, so they are intentionally not // re-exported here. Import them from @simplepdf/embed directly if a non-React path needs them. -export type { EmbedDocument, FieldType, OverlayToolType } from '@simplepdf/embed'; +export type { AgenticToolName, EmbedDocument, FieldType, OverlayToolType, WebMCPOptions } from '@simplepdf/embed'; From f42a0ea445d0b4e6ea7cd32ad167a671bb969cd9 Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 2 Sep 2026 20:35:55 +0200 Subject: [PATCH 3/8] fix: validate enableWebMCP.exclude names at createEmbed and re-probe the model context per lifecycle transition --- embed/etc/index.api.md | 6 ++-- embed/scripts/check-bundle-size.mjs | 4 +-- embed/scripts/generate.mjs | 33 ++++++++++++++++++--- embed/src/bridge.ts | 4 ++- embed/src/generated/agentic-tool-names.ts | 5 ++++ embed/src/generated/contract.ts | 3 +- embed/src/generated/drift.ts | 1 + embed/src/mount.ts | 36 ++++++++++++++++------- embed/src/webmcp.ts | 16 +++------- embed/test/mount.test.ts | 9 +++++- embed/test/webmcp.test.ts | 25 ++++++++-------- react/src/embed-pdf.tsx | 4 +-- 12 files changed, 97 insertions(+), 49 deletions(-) create mode 100644 embed/src/generated/agentic-tool-names.ts diff --git a/embed/etc/index.api.md b/embed/etc/index.api.md index 8009c09d..5e793878 100644 --- a/embed/etc/index.api.md +++ b/embed/etc/index.api.md @@ -4,12 +4,10 @@ ```ts -// Warning: (ae-forgotten-export) The symbol "OPERATIONS" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "AGENTIC_TOOL_NAMES" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export type AgenticToolName = Extract<(typeof OPERATIONS)[number], { - is_agentic_tool: true; -}>["method"]; +export type AgenticToolName = (typeof AGENTIC_TOOL_NAMES)[number]; // @public (undocumented) export type BridgeError = { diff --git a/embed/scripts/check-bundle-size.mjs b/embed/scripts/check-bundle-size.mjs index 00d53045..499b9d7c 100644 --- a/embed/scripts/check-bundle-size.mjs +++ b/embed/scripts/check-bundle-size.mjs @@ -5,7 +5,7 @@ // the consumers that trigger it), so the two costs stay visible separately. Export loadability is guarded // separately by ../../scripts/check-exports.mjs (the `check:exports` script). -import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { gzipSync } from 'node:zlib' @@ -76,7 +76,7 @@ const entriesWithinBudget = Object.entries(BUDGETS).map(([entry, budget]) => { const lazyChunks = [...new Set(Object.keys(BUDGETS).flatMap((entry) => closureOf(entry).flatMap(lazyImports)))] const lazyWithinBudget = lazyChunks.map((chunk) => { const budgetEntry = Object.entries(LAZY_BUDGETS).find(([prefix]) => chunk.startsWith(prefix)) - if (budgetEntry === undefined || !readdirSync(DIST).includes(chunk)) { + if (budgetEntry === undefined || !existsSync(join(DIST, chunk))) { console.error(`✗ ${chunk}: lazily imported but not built or not budgeted (add a LAZY_BUDGETS row)`) return false } diff --git a/embed/scripts/generate.mjs b/embed/scripts/generate.mjs index 8c740f4a..2d5f1255 100644 --- a/embed/scripts/generate.mjs +++ b/embed/scripts/generate.mjs @@ -10,6 +10,9 @@ // - src/generated/schemas.ts : zod schemas (peer dep). Each schema is compile-time // drift-guarded against the plain type in contract.ts, // so a divergence fails `tsc`. +// - src/generated/agentic-tool-names.ts : the agentic tool names alone, the one +// generated VALUE the zero-dep root imports (to +// validate `enableWebMCP.exclude`). // - src/generated/tool-input-schemas.ts : the agentic operations' input schemas as // plain JSON (camelCase keys), read only by the // lazily-loaded WebMCP module. @@ -302,6 +305,7 @@ const constArray = (name, values, typeName) => { const contractLines = [] contractLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.') contractLines.push('// Zero runtime dependencies: the zero-dep root imports only from this module.') +contractLines.push("import type { AGENTIC_TOOL_NAMES } from './agentic-tool-names'") contractLines.push('') contractLines.push(constArray('LOCALES', contract.locales, 'Locale')) contractLines.push(constArray('EDITOR_ERROR_CODES', editorErrorCodes, 'EditorErrorCode')) @@ -416,9 +420,11 @@ contractLines.push('export type RequestType = (typeof OPERATIONS)[number]["reque // the bridge transforms to the snake_case wire). The drift guard checks IframeActions // matches MethodName. contractLines.push('export type MethodName = (typeof OPERATIONS)[number]["method"]') -contractLines.push( - 'export type AgenticToolName = Extract<(typeof OPERATIONS)[number], { is_agentic_tool: true }>["method"]', -) +// The agentic tool names live in their own tiny module (createEmbed validates an +// untyped caller's `exclude` against the runtime list, and must not pull this whole +// table into the zero-dep root); the type is derived from it here, and drift.ts pins +// it to the `is_agentic_tool` operations so the two views of one fact cannot diverge. +contractLines.push("export type AgenticToolName = (typeof AGENTIC_TOOL_NAMES)[number]") contractLines.push('') const eventMeta = contract.events.map( @@ -449,6 +455,22 @@ schemaLines.push('') writeFileSync(join(GENERATED_DIR, 'schemas.ts'), renderFile(schemaLines)) +// --- agentic-tool-names.ts (zero runtime deps; the one generated value the root imports) --- + +const agenticToolNames = contract.operations + .filter((op) => !NON_AGENTIC_OPERATIONS.has(op.request_type.toLowerCase())) + .map((op) => toCamel(op.request_type)) +writeFileSync( + join(GENERATED_DIR, 'agentic-tool-names.ts'), + renderFile([ + '// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.', + '// The agentic tool names alone, so createEmbed can validate an `exclude` list without', + '// pulling the operations table into the zero-dep root; contract.ts derives', + '// AgenticToolName from this list.', + `export const AGENTIC_TOOL_NAMES = [${agenticToolNames.map((name) => JSON.stringify(name)).join(', ')}] as const`, + ]), +) + // --- tool-input-schemas.ts (zero runtime deps, loaded only by the WebMCP module) --- const toolInputSchemaLines = [] @@ -496,6 +518,9 @@ driftLines.push('// every generated outbound event must appear in the hand-maint driftLines.push("// (so React's onEmbedEvent forwarders, guarded against EditorEvent, can't miss one).") driftLines.push('export type DriftGuards = [') driftLines.push(" AssertTrue>,") +driftLines.push( + ' AssertTrue["method"]>>,', +) driftLines.push(" AssertTrue>,") for (const op of contract.operations) { const stem = toPascal(op.request_type) @@ -533,5 +558,5 @@ writeFileSync(join(GENERATED_DIR, 'tools.ts'), renderFile(toolLines)) console.log( `Generated contract.ts (${contract.operations.length} ops, ${contract.events.length} events, ` + - `${contract.locales.length} locales, ${editorErrorCodes.length} editor error codes) + schemas.ts + tool-input-schemas.ts`, + `${contract.locales.length} locales, ${editorErrorCodes.length} editor error codes) + schemas.ts + agentic-tool-names.ts + tool-input-schemas.ts`, ) diff --git a/embed/src/bridge.ts b/embed/src/bridge.ts index 034866fa..dfa61ed0 100644 --- a/embed/src/bridge.ts +++ b/embed/src/bridge.ts @@ -171,11 +171,13 @@ export const attachEmbed = ({ if (webMCPOptions === null || webMCPStarted) { return } - webMCPStarted = true + // Probed on every non-booting transition until a context shows up, so a runtime + // that installs one after a fast EDITOR_READY still gets the tools. if (!('modelContext' in document) && !('modelContext' in navigator)) { logger.info('webmcp.unavailable', { reason: 'no_model_context' }) return } + webMCPStarted = true void import('./webmcp') .then(({ registerWebMCPTools }) => registerWebMCPTools({ dispatch: sendRequest, options: webMCPOptions, signal: webMCPController.signal, logger }), diff --git a/embed/src/generated/agentic-tool-names.ts b/embed/src/generated/agentic-tool-names.ts new file mode 100644 index 00000000..904938e9 --- /dev/null +++ b/embed/src/generated/agentic-tool-names.ts @@ -0,0 +1,5 @@ +// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand. +// The agentic tool names alone, so createEmbed can validate an `exclude` list without +// pulling the operations table into the zero-dep root; contract.ts derives +// AgenticToolName from this list. +export const AGENTIC_TOOL_NAMES = ["createField", "deleteFields", "deletePages", "detectFields", "download", "focusField", "getDocumentContent", "getFields", "goTo", "movePage", "rotatePage", "selectTool", "setFieldValue", "submit"] as const diff --git a/embed/src/generated/contract.ts b/embed/src/generated/contract.ts index a35fdc12..bc40e257 100644 --- a/embed/src/generated/contract.ts +++ b/embed/src/generated/contract.ts @@ -1,5 +1,6 @@ // AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand. // Zero runtime dependencies: the zero-dep root imports only from this module. +import type { AGENTIC_TOOL_NAMES } from './agentic-tool-names' export const LOCALES = ["fr", "en", "it", "de", "pt", "es", "ja", "nl"] as const export type Locale = (typeof LOCALES)[number] @@ -196,7 +197,7 @@ export const OPERATIONS = [ export type WireType = (typeof OPERATIONS)[number]["wire_type"] export type RequestType = (typeof OPERATIONS)[number]["request_type"] export type MethodName = (typeof OPERATIONS)[number]["method"] -export type AgenticToolName = Extract<(typeof OPERATIONS)[number], { is_agentic_tool: true }>["method"] +export type AgenticToolName = (typeof AGENTIC_TOOL_NAMES)[number] export const OUTBOUND_EVENTS = [ { event_type: "PAGE_FOCUSED", description: "Pushed when the focused page changes (the user scrolls to a new page, or a GO_TO completes). The payload reports the current page." }, diff --git a/embed/src/generated/drift.ts b/embed/src/generated/drift.ts index cbb2d9d1..e69f6004 100644 --- a/embed/src/generated/drift.ts +++ b/embed/src/generated/drift.ts @@ -13,6 +13,7 @@ type AssertTrue = T // (so React's onEmbedEvent forwarders, guarded against EditorEvent, can't miss one). export type DriftGuards = [ AssertTrue>, + AssertTrue["method"]>>, AssertTrue>, AssertTrue>, AssertTrue>, diff --git a/embed/src/mount.ts b/embed/src/mount.ts index e87969e1..be9a2c54 100644 --- a/embed/src/mount.ts +++ b/embed/src/mount.ts @@ -1,6 +1,7 @@ import { attachEmbed } from './bridge' import { type BridgeLogger, makeSafeLogger, NOOP_LOGGER } from './logger' import type { BridgeState, Embed } from './types' +import { AGENTIC_TOOL_NAMES } from './generated/agentic-tool-names' import type { Locale } from './generated/contract' import type { WebMCPOptions } from './webmcp' @@ -200,22 +201,37 @@ const assertValidFileArm = (file: unknown): void => { } } -// `enableWebMCP` withholds irreversible operations from an agent, so a malformed value -// from an untyped JS caller must fail loud rather than register everything. +// `enableWebMCP.exclude` withholds irreversible operations from an agent, so a +// malformed value or a misspelled name from an untyped JS caller must fail loud +// rather than register the operation it meant to withhold. +const AGENTIC_TOOL_NAME_SET: ReadonlySet = new Set(AGENTIC_TOOL_NAMES) + const assertValidWebMCPOptions = (enableWebMCP: unknown): void => { if (enableWebMCP === undefined || typeof enableWebMCP === 'boolean') { return } - const isExcludeList = - typeof enableWebMCP === 'object' && - enableWebMCP !== null && - 'exclude' in enableWebMCP && - Array.isArray(enableWebMCP.exclude) && - enableWebMCP.exclude.every((name) => typeof name === 'string') - if (!isExcludeList) { + const excludeList = ((): string[] | null => { + if (typeof enableWebMCP !== 'object' || enableWebMCP === null || !('exclude' in enableWebMCP)) { + return null + } + const { exclude } = enableWebMCP + if (!Array.isArray(exclude)) { + return null + } + const entries: unknown[] = exclude + return entries.every((name): name is string => typeof name === 'string') ? entries : null + })() + if (excludeList === null) { + throw new EmbedConfigError( + 'invalid_config', + `enableWebMCP must be a boolean or { exclude: AgenticToolName[] } (received ${describeValue(enableWebMCP)}).`, + ) + } + const unknownNames = excludeList.filter((name) => !AGENTIC_TOOL_NAME_SET.has(name)) + if (unknownNames.length > 0) { throw new EmbedConfigError( 'invalid_config', - `enableWebMCP must be a boolean or { exclude: string[] } (received ${describeValue(enableWebMCP)}).`, + `enableWebMCP.exclude names no tool: ${unknownNames.join(', ')} (known: ${AGENTIC_TOOL_NAMES.join(', ')}).`, ) } } diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts index 32753e3a..50e82cb3 100644 --- a/embed/src/webmcp.ts +++ b/embed/src/webmcp.ts @@ -46,8 +46,9 @@ type AgenticOperation = Extract // which is untrusted from the page's perspective. Every writer declares whether it // removes or reorders content or finalizes the document; setting a field value is // not destructive here because the person reviews every value in the editor before -// the one irreversible step, submit. Same map as the editor's in-page tools. The -// Record makes a new operation a compile error until it is annotated. +// the one irreversible step, submit. The Record makes a new operation a compile +// error until it is annotated. Kept identical to the editor's in-page tool hints. +// CF: WEBMCP_TOOL_ANNOTATIONS in the editor's lib/iframe/handlers.ts (SimplePDF editor repository) const TOOL_ANNOTATIONS = { createField: { destructiveHint: false }, deleteFields: { destructiveHint: true }, @@ -67,8 +68,6 @@ const TOOL_ANNOTATIONS = { const isAgenticOperation = (operation: Operation): operation is AgenticOperation => operation.is_agentic_tool -const isAgenticToolName = (value: string): value is AgenticToolName => value in TOOL_INPUT_SCHEMAS - const isModelContext = (value: unknown): value is ModelContext => typeof value === 'object' && value !== null && 'registerTool' in value && typeof value.registerTool === 'function' @@ -110,14 +109,7 @@ export const registerWebMCPTools = ({ logger.warn('webmcp.unavailable', { reason: 'invalid_model_context' }) return } - const excluded = new Set() - for (const name of options === true ? [] : options.exclude) { - if (isAgenticToolName(name)) { - excluded.add(name) - } else { - logger.warn('webmcp.unknown_excluded_tool', { tool: name }) - } - } + const excluded = new Set(options === true ? [] : options.exclude) for (const operation of OPERATIONS) { if (!isAgenticOperation(operation) || excluded.has(operation.method)) { continue diff --git a/embed/test/mount.test.ts b/embed/test/mount.test.ts index f7370df8..835723a2 100644 --- a/embed/test/mount.test.ts +++ b/embed/test/mount.test.ts @@ -396,6 +396,13 @@ describe(createEmbed.name, () => { document.body.innerHTML = '
' const malformedArgs: unknown = { target: '#root', companyIdentifier: 'acme', enableWebMCP } // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => createEmbed(malformedArgs)).toThrow(/enableWebMCP must be a boolean or \{ exclude: string\[\] \}/) + expect(() => createEmbed(malformedArgs)).toThrow(/enableWebMCP must be a boolean or \{ exclude: AgenticToolName\[\] \}/) + }) + + it('throws EmbedConfigError when exclude names no tool, so a misspelled name cannot register the operation it meant to withhold', () => { + document.body.innerHTML = '
' + const misspelled: unknown = { target: '#root', companyIdentifier: 'acme', enableWebMCP: { exclude: ['sumbit'] } } + // @ts-expect-error exercising the runtime guard for untyped JS callers + expect(() => createEmbed(misspelled)).toThrow(/enableWebMCP\.exclude names no tool: sumbit \(known: createField/) }) }) diff --git a/embed/test/webmcp.test.ts b/embed/test/webmcp.test.ts index adfaf5f5..b287fc22 100644 --- a/embed/test/webmcp.test.ts +++ b/embed/test/webmcp.test.ts @@ -61,8 +61,9 @@ type Harness = { embed: Embed posted: Posted[] reply: (request: Posted, result: unknown) => void - // Registration waits for the editor to be alive; this is the editor announcing it. + // Registration waits for the editor to be alive; these are the editor's lifecycle announcements. markEditorReady: () => void + markDocumentLoaded: () => void } const harnesses: Harness[] = [] @@ -91,6 +92,7 @@ const makeHarness = (args: Pick): Ha posted, reply: (request, result) => receive({ type: 'REQUEST_RESULT', data: { request_id: request.request_id, result } }), markEditorReady: () => receive({ type: 'EDITOR_READY', data: {} }), + markDocumentLoaded: () => receive({ type: 'DOCUMENT_LOADED', data: { document_id: 'doc1' } }), } harnesses.push(harness) return harness @@ -196,7 +198,7 @@ describe('attachEmbed({ enableWebMCP })', () => { await waitForTools(modelContext, 14) }) - it('withholds the excluded operations, registers the rest, and reports an exclusion that names no tool', async () => { + it('withholds the excluded operations and registers the rest', async () => { const modelContext = installModelContext(document) const logger = makeLogger() mountReady({ enableWebMCP: { exclude: ['submit', 'deletePages', 'movePage', 'rotatePage'] }, logger }) @@ -208,14 +210,6 @@ describe('attachEmbed({ enableWebMCP })', () => { expect(names).not.toContain('submit') expect(names).not.toContain('deletePages') expect(logger.warn).not.toHaveBeenCalled() - - // A typo in `exclude` (an untyped caller) registers the tool it meant to withhold: - // the SDK says so instead of staying silent. - modelContext.registered.length = 0 - const typo = makeLogger() - const excludeWithTypo: AttachEmbedArgs['enableWebMCP'] = JSON.parse('{"exclude":["sumbit"]}') - mountReady({ enableWebMCP: excludeWithTypo, logger: typo }) - await vi.waitFor(() => expect(typo.warn).toHaveBeenCalledWith('webmcp.unknown_excluded_tool', { tool: 'sumbit' })) }) it('executes a tool call as the operation request on the wire and returns the editor Result as a JSON-text tool result', async () => { @@ -317,11 +311,18 @@ describe('attachEmbed({ enableWebMCP })', () => { expect(modelContext.liveToolNames()).toHaveLength(14) }) - it('reports an absent model context instead of loading the module, and never throws', async () => { + it('reports an absent model context instead of loading the module, never throws, and registers once a context appears', async () => { const logger = makeLogger() - expect(() => mountReady({ enableWebMCP: true, logger })).not.toThrow() + const harness = makeHarness({ enableWebMCP: true, logger }) + harness.markEditorReady() await vi.waitFor(() => expect(logger.info).toHaveBeenCalledWith('webmcp.unavailable', { reason: 'no_model_context' })) expect(logger.error).not.toHaveBeenCalled() + + // A context installed after a fast EDITOR_READY (an extension injected late) is + // picked up on the next lifecycle transition. + const modelContext = installModelContext(document) + harness.markDocumentLoaded() + await waitForTools(modelContext, 14) }) it('reports a model context without registerTool as invalid', async () => { diff --git a/react/src/embed-pdf.tsx b/react/src/embed-pdf.tsx index e449fec6..aa5f229c 100644 --- a/react/src/embed-pdf.tsx +++ b/react/src/embed-pdf.tsx @@ -206,8 +206,8 @@ const EmbedSurface = React.forwardRef((props, }, [context]); // Registration happens at mount, so a changed option remounts the editor (and drops // the person's edits). Keyed on the normalized value, so a fresh `{ exclude: [...] }` - // literal, a reordered list, or `undefined` vs `false` never remounts. The effect - // reads the option through a ref for the same reason the callbacks do. + // literal, a reordered list, or `undefined` vs `false` never remounts; the effect + // reads the option through a ref so the literal itself stays out of its dependencies. const webMCPKey = ((): string => { if (enableWebMCP === undefined || enableWebMCP === false) { return 'off'; From ff96058acd15cfa58942134d9789d98992558f11 Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 2 Sep 2026 20:44:26 +0200 Subject: [PATCH 4/8] fix: one WebMCP option normalizer, latch that follows the module's finding, one lazy-chunk table --- embed/README.md | 2 +- embed/etc/index.api.md | 8 +++++++ embed/scripts/check-bundle-size.mjs | 15 ++++-------- embed/scripts/check-lazy-chunks.mjs | 37 ++++++++++++++++------------- embed/scripts/generate.mjs | 2 +- embed/scripts/lazy-chunks.mjs | 6 +++++ embed/src/bridge.ts | 25 ++++++++++++------- embed/src/index.ts | 3 ++- embed/src/mount.ts | 6 ++--- embed/src/webmcp-options.ts | 20 ++++++++++++++++ embed/src/webmcp.ts | 21 ++++++++-------- embed/test/webmcp.test.ts | 12 ++++++---- react/src/embed-pdf.tsx | 13 +++------- 13 files changed, 104 insertions(+), 66 deletions(-) create mode 100644 embed/scripts/lazy-chunks.mjs create mode 100644 embed/src/webmcp-options.ts diff --git a/embed/README.md b/embed/README.md index d5f5b6e4..92900069 100644 --- a/embed/README.md +++ b/embed/README.md @@ -86,7 +86,7 @@ createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, enableWebMCP: true }) ``` -Off by default. Tools register once the editor is ready and only when the page exposes a model context at that moment; otherwise nothing is loaded (the bridge logs `webmcp.unavailable`). The two readers (`getFields`, `getDocumentContent`) carry the specification's `readOnlyHint` and `untrustedContentHint` (their output is document-derived); every other tool carries MCP's `destructiveHint`, read by runtimes that honor MCP's hints. The editor validates each call like any other request (its permission model applies at call time: editing, allowlisted origin, plan, so a tool the tenant configuration refuses resolves with the matching error code), a call resolves with an MCP tool result whose text is the editor's `{ success, data | error }` Result (`isError` on failure), and `dispose()` unregisters everything. A model context is one per page and keyed by tool name, so enable WebMCP on one embed per page: a second one is reported (`webmcp.tool_already_registered`) and registers nothing. In React, pass `enableWebMCP` to ``. +Off by default. Tools register once the editor is ready; the page is re-probed for a model context on each later lifecycle transition, so a context installed after `EDITOR_READY` is still picked up, and until one appears nothing is loaded (`webmcp.unavailable` is logged, with the reason). The two readers (`getFields`, `getDocumentContent`) carry the specification's `readOnlyHint` and `untrustedContentHint` (their output is document-derived); every other tool carries MCP's `destructiveHint`, read by runtimes that honor MCP's hints. The editor validates each call like any other request (its permission model applies at call time: editing, allowlisted origin, plan, so a tool the tenant configuration refuses resolves with the matching error code), a call resolves with an MCP tool result whose text is the editor's `{ success, data | error }` Result (`isError` on failure), and `dispose()` unregisters everything. A model context is one per page and keyed by tool name, so enable WebMCP on one embed per page: a second one is reported (`webmcp.tool_already_registered`) and registers nothing. In React, pass `enableWebMCP` to ``. ## Subpaths diff --git a/embed/etc/index.api.md b/embed/etc/index.api.md index 5e793878..d82627b3 100644 --- a/embed/etc/index.api.md +++ b/embed/etc/index.api.md @@ -295,6 +295,14 @@ export type MovePageInput = { // @public (undocumented) export const NOOP_LOGGER: BridgeLogger; +// @public (undocumented) +export const normalizeWebMCPOptions: (options: WebMCPOptions | undefined) => { + enabled: false; +} | { + enabled: true; + exclude: readonly AgenticToolName[]; +}; + // Warning: (ae-forgotten-export) The symbol "OVERLAY_TOOL_TYPES" needs to be exported by the entry point index.d.ts // // @public (undocumented) diff --git a/embed/scripts/check-bundle-size.mjs b/embed/scripts/check-bundle-size.mjs index 499b9d7c..8e1d0c59 100644 --- a/embed/scripts/check-bundle-size.mjs +++ b/embed/scripts/check-bundle-size.mjs @@ -9,6 +9,7 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { gzipSync } from 'node:zlib' +import { LAZY_CHUNKS } from './lazy-chunks.mjs' const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist') @@ -25,12 +26,6 @@ const BUDGETS = { 'tanstack-ai.js': 5.5 * 1024, } -// Lazily-imported chunks, matched by their un-hashed name prefix. The WebMCP chunk -// carries the tool registration + the generated input-schema table. -const LAZY_BUDGETS = { - 'webmcp-': 5 * 1024, -} - const importsOf = (file, pattern) => { const content = readFileSync(join(DIST, file), 'utf8') return [...content.matchAll(pattern)].map((match) => match[1].replace(/^\.\//, '')) @@ -75,12 +70,12 @@ const entriesWithinBudget = Object.entries(BUDGETS).map(([entry, budget]) => { // with no budget row is an unmeasured download. const lazyChunks = [...new Set(Object.keys(BUDGETS).flatMap((entry) => closureOf(entry).flatMap(lazyImports)))] const lazyWithinBudget = lazyChunks.map((chunk) => { - const budgetEntry = Object.entries(LAZY_BUDGETS).find(([prefix]) => chunk.startsWith(prefix)) - if (budgetEntry === undefined || !existsSync(join(DIST, chunk))) { - console.error(`✗ ${chunk}: lazily imported but not built or not budgeted (add a LAZY_BUDGETS row)`) + const lazyChunk = Object.entries(LAZY_CHUNKS).find(([prefix]) => chunk.startsWith(prefix)) + if (lazyChunk === undefined || !existsSync(join(DIST, chunk))) { + console.error(`✗ ${chunk}: lazily imported but not built or not budgeted (add a row to lazy-chunks.mjs)`) return false } - return checkBudget(chunk, budgetEntry[1]) + return checkBudget(chunk, lazyChunk[1].budgetBytes) }) process.exit([...entriesWithinBudget, ...lazyWithinBudget].every(Boolean) ? 0 : 1) diff --git a/embed/scripts/check-lazy-chunks.mjs b/embed/scripts/check-lazy-chunks.mjs index 18dc97e3..741d82f6 100644 --- a/embed/scripts/check-lazy-chunks.mjs +++ b/embed/scripts/check-lazy-chunks.mjs @@ -7,29 +7,32 @@ import { readdirSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { LAZY_CHUNKS } from './lazy-chunks.mjs' const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist') const require = createRequire(import.meta.url) -const lazyChunks = readdirSync(DIST).filter((file) => /^webmcp-.*\.(js|cjs)$/.test(file)) -if (lazyChunks.length === 0) { - console.error('✗ no lazy webmcp chunk in dist (run `npm run build` first)') - process.exit(1) -} - const results = [] -for (const chunk of lazyChunks) { - const path = join(DIST, chunk) - try { - const loaded = chunk.endsWith('.cjs') ? require(path) : await import(path) - if (typeof loaded.registerWebMCPTools !== 'function') { - throw new Error('registerWebMCPTools is not exported') - } - console.log(`✓ ${chunk}`) - results.push(true) - } catch (error) { - console.error(`✗ ${chunk}: ${error.code ?? error.message}`) +for (const [prefix, { exportName }] of Object.entries(LAZY_CHUNKS)) { + const chunks = readdirSync(DIST).filter((file) => file.startsWith(prefix) && /\.(js|cjs)$/.test(file)) + if (chunks.length === 0) { + console.error(`✗ no ${prefix}* chunk in dist (run \`npm run build\` first)`) results.push(false) + continue + } + for (const chunk of chunks) { + const path = join(DIST, chunk) + try { + const loaded = chunk.endsWith('.cjs') ? require(path) : await import(path) + if (typeof loaded[exportName] !== 'function') { + throw new Error(`${exportName} is not exported`) + } + console.log(`✓ ${chunk}`) + results.push(true) + } catch (error) { + console.error(`✗ ${chunk}: ${error.code ? `${error.code}: ` : ''}${error.message}`) + results.push(false) + } } } process.exit(results.every(Boolean) ? 0 : 1) diff --git a/embed/scripts/generate.mjs b/embed/scripts/generate.mjs index 2d5f1255..4035cfc2 100644 --- a/embed/scripts/generate.mjs +++ b/embed/scripts/generate.mjs @@ -3,7 +3,7 @@ // source of truth; this script is the only consumer that re-materializes it as // TypeScript. Run via `npm run generate` (wired into prebuild + pretest). // -// Three outputs, all derived from one source so they cannot hand-drift: +// Four outputs, all derived from one source so they cannot hand-drift: // - src/generated/contract.ts : zero-runtime-dep plain TS types + const tables // (locales, error codes, operations, events). // The zero-dep root imports only from here. diff --git a/embed/scripts/lazy-chunks.mjs b/embed/scripts/lazy-chunks.mjs new file mode 100644 index 00000000..24e8d809 --- /dev/null +++ b/embed/scripts/lazy-chunks.mjs @@ -0,0 +1,6 @@ +// The chunks the built entries only `import()` lazily, keyed by their un-hashed name +// prefix: the gzip budget each closure must stay under (check-bundle-size.mjs) and the +// export the chunk must expose when loaded in either module format (check-lazy-chunks.mjs). +export const LAZY_CHUNKS = { + 'webmcp-': { budgetBytes: 5 * 1024, exportName: 'registerWebMCPTools' }, +} diff --git a/embed/src/bridge.ts b/embed/src/bridge.ts index dfa61ed0..52da69f6 100644 --- a/embed/src/bridge.ts +++ b/embed/src/bridge.ts @@ -12,7 +12,7 @@ import type { PageFocusedPayload, SubmissionSentPayload, } from './types' -import type { WebMCPOptions } from './webmcp' +import { normalizeWebMCPOptions, type WebMCPOptions } from './webmcp-options' export type AttachEmbedArgs = { // Getter returning the iframe element. Called each time the bridge needs to @@ -165,23 +165,32 @@ export const attachEmbed = ({ // table it reads load for no one else. Aborting the signal on dispose unregisters // every tool. const webMCPController = new AbortController() - const webMCPOptions = enableWebMCP === undefined || enableWebMCP === false ? null : enableWebMCP + const webMCP = normalizeWebMCPOptions(enableWebMCP) + // Latched while a registration attempt is in flight or succeeded; released when the + // module finds no usable context, so every later non-booting transition probes again + // and a runtime that installs its context after a fast EDITOR_READY still gets the tools. let webMCPStarted = false const startWebMCP = (): void => { - if (webMCPOptions === null || webMCPStarted) { + if (!webMCP.enabled || webMCPStarted) { return } - // Probed on every non-booting transition until a context shows up, so a runtime - // that installs one after a fast EDITOR_READY still gets the tools. if (!('modelContext' in document) && !('modelContext' in navigator)) { logger.info('webmcp.unavailable', { reason: 'no_model_context' }) return } webMCPStarted = true void import('./webmcp') - .then(({ registerWebMCPTools }) => - registerWebMCPTools({ dispatch: sendRequest, options: webMCPOptions, signal: webMCPController.signal, logger }), - ) + .then(({ registerWebMCPTools }) => { + const registered = registerWebMCPTools({ + dispatch: sendRequest, + exclude: webMCP.exclude, + signal: webMCPController.signal, + logger, + }) + if (!registered) { + webMCPStarted = false + } + }) .catch((error: unknown) => { logger.error('webmcp.load_failed', { message: error instanceof Error ? error.message : String(error) }) }) diff --git a/embed/src/index.ts b/embed/src/index.ts index 5ca98976..38ab50ab 100644 --- a/embed/src/index.ts +++ b/embed/src/index.ts @@ -4,7 +4,8 @@ export { createEmbed, EmbedConfigError } from './mount' export type { CreateEmbedArgs, EmbedDocument } from './mount' -export type { WebMCPOptions } from './webmcp' +export { normalizeWebMCPOptions } from './webmcp-options' +export type { WebMCPOptions } from './webmcp-options' export { NOOP_LOGGER } from './logger' export type { BridgeLogger, LogPayload } from './logger' export { BridgeUnwrapError, unwrap } from './unwrap' diff --git a/embed/src/mount.ts b/embed/src/mount.ts index be9a2c54..81a41c2c 100644 --- a/embed/src/mount.ts +++ b/embed/src/mount.ts @@ -3,7 +3,7 @@ import { type BridgeLogger, makeSafeLogger, NOOP_LOGGER } from './logger' import type { BridgeState, Embed } from './types' import { AGENTIC_TOOL_NAMES } from './generated/agentic-tool-names' import type { Locale } from './generated/contract' -import type { WebMCPOptions } from './webmcp' +import type { WebMCPOptions } from './webmcp-options' // Construction-time configuration error. createEmbed validates its config // synchronously and THROWS this on programmer error (bad target/companyIdentifier/document @@ -201,11 +201,11 @@ const assertValidFileArm = (file: unknown): void => { } } +const AGENTIC_TOOL_NAME_SET: ReadonlySet = new Set(AGENTIC_TOOL_NAMES) + // `enableWebMCP.exclude` withholds irreversible operations from an agent, so a // malformed value or a misspelled name from an untyped JS caller must fail loud // rather than register the operation it meant to withhold. -const AGENTIC_TOOL_NAME_SET: ReadonlySet = new Set(AGENTIC_TOOL_NAMES) - const assertValidWebMCPOptions = (enableWebMCP: unknown): void => { if (enableWebMCP === undefined || typeof enableWebMCP === 'boolean') { return diff --git a/embed/src/webmcp-options.ts b/embed/src/webmcp-options.ts new file mode 100644 index 00000000..e211df33 --- /dev/null +++ b/embed/src/webmcp-options.ts @@ -0,0 +1,20 @@ +import type { AgenticToolName } from './generated/contract' + +// `true` registers every agentic operation; `exclude` withholds the listed ones +// (e.g. `submit` when only a person may finalize). `false` / omitted registers nothing. +export type WebMCPOptions = boolean | { exclude: readonly AgenticToolName[] } + +// The one decoder of the option shape: the bridge (start or not), the WebMCP module +// (what to withhold) and the React layer (a remount key) all read this instead of +// re-deriving the `undefined | false | true | { exclude }` cases. +export const normalizeWebMCPOptions = ( + options: WebMCPOptions | undefined, +): { enabled: false } | { enabled: true; exclude: readonly AgenticToolName[] } => { + if (options === undefined || options === false) { + return { enabled: false } + } + if (options === true) { + return { enabled: true, exclude: [] } + } + return { enabled: true, exclude: options.exclude } +} diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts index 50e82cb3..60524f73 100644 --- a/embed/src/webmcp.ts +++ b/embed/src/webmcp.ts @@ -15,10 +15,6 @@ import { TOOL_INPUT_SCHEMAS, type ToolInputSchema } from './generated/tool-input import type { BridgeLogger } from './logger' import type { BridgeResult } from './types' -// `true` registers every agentic operation; `exclude` withholds the listed ones -// (e.g. `submit` when only a person may finalize). `false` / omitted registers nothing. -export type WebMCPOptions = boolean | { exclude: readonly AgenticToolName[] } - // The slice of the WebMCP surface this module touches, typed structurally so the // zero-dependency root pulls in no type package. `readOnlyHint` and // `untrustedContentHint` are the specification's annotations; `destructiveHint` is @@ -90,26 +86,28 @@ const toCallToolResult = (result: BridgeResult): CallToolResult => ({ // page would collide; the first registration of a name wins and the rest are reported. const liveToolNames = new Set() +// Returns whether a usable model context was found (and the tools handed to it), so +// the bridge can keep probing on later lifecycle transitions when it was not. export const registerWebMCPTools = ({ dispatch, - options, + exclude, signal, logger, }: { dispatch: (wireType: WireType, data: unknown) => Promise> - options: Exclude + exclude: readonly AgenticToolName[] signal: AbortSignal logger: BridgeLogger -}): void => { +}): boolean => { if (signal.aborted) { - return + return false } const modelContext = readModelContext() if (modelContext === null) { - logger.warn('webmcp.unavailable', { reason: 'invalid_model_context' }) - return + logger.info('webmcp.unavailable', { reason: 'invalid_model_context' }) + return false } - const excluded = new Set(options === true ? [] : options.exclude) + const excluded = new Set(exclude) for (const operation of OPERATIONS) { if (!isAgenticOperation(operation) || excluded.has(operation.method)) { continue @@ -142,4 +140,5 @@ export const registerWebMCPTools = ({ } })() } + return true } diff --git a/embed/test/webmcp.test.ts b/embed/test/webmcp.test.ts index b287fc22..c4e36382 100644 --- a/embed/test/webmcp.test.ts +++ b/embed/test/webmcp.test.ts @@ -311,7 +311,7 @@ describe('attachEmbed({ enableWebMCP })', () => { expect(modelContext.liveToolNames()).toHaveLength(14) }) - it('reports an absent model context instead of loading the module, never throws, and registers once a context appears', async () => { + it('reports an absent model context, never throws, and registers once a context appears', async () => { const logger = makeLogger() const harness = makeHarness({ enableWebMCP: true, logger }) harness.markEditorReady() @@ -325,13 +325,17 @@ describe('attachEmbed({ enableWebMCP })', () => { await waitForTools(modelContext, 14) }) - it('reports a model context without registerTool as invalid', async () => { + it('reports a model context without registerTool as invalid and keeps probing, so a placeholder filled in later still gets the tools', async () => { Object.defineProperty(document, 'modelContext', { configurable: true, value: {} }) const logger = makeLogger() - mountReady({ enableWebMCP: true, logger }) + const harness = mountReady({ enableWebMCP: true, logger }) await vi.waitFor(() => - expect(logger.warn).toHaveBeenCalledWith('webmcp.unavailable', { reason: 'invalid_model_context' }), + expect(logger.info).toHaveBeenCalledWith('webmcp.unavailable', { reason: 'invalid_model_context' }), ) + + const modelContext = installModelContext(document) + harness.markDocumentLoaded() + await waitForTools(modelContext, 14) }) it('keeps registering the other tools when the runtime rejects one, logs the failure, and frees that name', async () => { diff --git a/react/src/embed-pdf.tsx b/react/src/embed-pdf.tsx index aa5f229c..5eca718f 100644 --- a/react/src/embed-pdf.tsx +++ b/react/src/embed-pdf.tsx @@ -15,7 +15,7 @@ import * as React from 'react'; import { createPortal } from 'react-dom'; -import { createEmbed, type EmbedDocument, type WebMCPOptions } from '@simplepdf/embed'; +import { createEmbed, normalizeWebMCPOptions, type EmbedDocument, type WebMCPOptions } from '@simplepdf/embed'; import type { BridgeLogger, BridgeResult, @@ -208,15 +208,8 @@ const EmbedSurface = React.forwardRef((props, // the person's edits). Keyed on the normalized value, so a fresh `{ exclude: [...] }` // literal, a reordered list, or `undefined` vs `false` never remounts; the effect // reads the option through a ref so the literal itself stays out of its dependencies. - const webMCPKey = ((): string => { - if (enableWebMCP === undefined || enableWebMCP === false) { - return 'off'; - } - if (enableWebMCP === true) { - return 'all'; - } - return `exclude:${[...enableWebMCP.exclude].sort().join(',')}`; - })(); + const webMCP = normalizeWebMCPOptions(enableWebMCP); + const webMCPKey = webMCP.enabled ? `on:${[...webMCP.exclude].sort().join(',')}` : 'off'; const enableWebMCPRef = React.useRef(enableWebMCP); enableWebMCPRef.current = enableWebMCP; From 2c3de3643badce1f3bdbcb8898b5698c8019ee3e Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 2 Sep 2026 20:52:08 +0200 Subject: [PATCH 5/8] fix: a WebMCP tool name is freed only by the signal that owns it --- embed/README.md | 2 +- embed/etc/index.api.md | 4 +- embed/scripts/check-bundle-size.mjs | 4 +- embed/src/bridge.ts | 5 +- embed/src/case-transform.ts | 9 +-- embed/src/webmcp-options.ts | 1 + embed/src/webmcp.ts | 17 ++++-- embed/test/webmcp.test.ts | 85 ++++++++++++++++------------- react/src/embed-pdf.test.tsx | 6 ++ 9 files changed, 79 insertions(+), 54 deletions(-) diff --git a/embed/README.md b/embed/README.md index 92900069..22c7179f 100644 --- a/embed/README.md +++ b/embed/README.md @@ -86,7 +86,7 @@ createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, enableWebMCP: true }) ``` -Off by default. Tools register once the editor is ready; the page is re-probed for a model context on each later lifecycle transition, so a context installed after `EDITOR_READY` is still picked up, and until one appears nothing is loaded (`webmcp.unavailable` is logged, with the reason). The two readers (`getFields`, `getDocumentContent`) carry the specification's `readOnlyHint` and `untrustedContentHint` (their output is document-derived); every other tool carries MCP's `destructiveHint`, read by runtimes that honor MCP's hints. The editor validates each call like any other request (its permission model applies at call time: editing, allowlisted origin, plan, so a tool the tenant configuration refuses resolves with the matching error code), a call resolves with an MCP tool result whose text is the editor's `{ success, data | error }` Result (`isError` on failure), and `dispose()` unregisters everything. A model context is one per page and keyed by tool name, so enable WebMCP on one embed per page: a second one is reported (`webmcp.tool_already_registered`) and registers nothing. In React, pass `enableWebMCP` to ``. +Off by default. Tools register once the editor is ready; while no usable model context has been found, the page is probed again on each later lifecycle transition, so a context installed after `EDITOR_READY` is still picked up, and until one appears nothing is loaded (`webmcp.unavailable` is logged, with the reason). The two readers (`getFields`, `getDocumentContent`) carry the specification's `readOnlyHint` and `untrustedContentHint` (their output is document-derived); every other tool carries MCP's `destructiveHint`, read by runtimes that honor MCP's hints. The editor validates each call like any other request (its permission model applies at call time: editing, allowlisted origin, plan, so a tool the tenant configuration refuses resolves with the matching error code), a call resolves with an MCP tool result whose text is the editor's `{ success, data | error }` Result (`isError` on failure), and `dispose()` unregisters everything. A model context is one per page and keyed by tool name, so enable WebMCP on one embed per page: a second one is reported (`webmcp.tool_already_registered`) and registers nothing. In React, pass `enableWebMCP` to ``. ## Subpaths diff --git a/embed/etc/index.api.md b/embed/etc/index.api.md index d82627b3..3646c660 100644 --- a/embed/etc/index.api.md +++ b/embed/etc/index.api.md @@ -295,7 +295,9 @@ export type MovePageInput = { // @public (undocumented) export const NOOP_LOGGER: BridgeLogger; -// @public (undocumented) +// Warning: (ae-internal-missing-underscore) The name "normalizeWebMCPOptions" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal export const normalizeWebMCPOptions: (options: WebMCPOptions | undefined) => { enabled: false; } | { diff --git a/embed/scripts/check-bundle-size.mjs b/embed/scripts/check-bundle-size.mjs index 8e1d0c59..193033a0 100644 --- a/embed/scripts/check-bundle-size.mjs +++ b/embed/scripts/check-bundle-size.mjs @@ -13,8 +13,8 @@ import { LAZY_CHUNKS } from './lazy-chunks.mjs' const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist') -// Gzip budget (bytes) per entry's local closure. Each cap is the current size plus -// ~1 KB of headroom, so any non-trivial growth trips the gate and gets reviewed. +// Gzip budget (bytes) per entry's local closure. Each cap sits 0.5–1 KB above the +// measured size, so any non-trivial growth trips the gate and gets reviewed. // The zero-dep root carries the bridge + createEmbed (create + attach paths) + its // actionable config validation + the WebMCP opt-in hook. const BUDGETS = { diff --git a/embed/src/bridge.ts b/embed/src/bridge.ts index 52da69f6..c5982196 100644 --- a/embed/src/bridge.ts +++ b/embed/src/bridge.ts @@ -167,8 +167,9 @@ export const attachEmbed = ({ const webMCPController = new AbortController() const webMCP = normalizeWebMCPOptions(enableWebMCP) // Latched while a registration attempt is in flight or succeeded; released when the - // module finds no usable context, so every later non-booting transition probes again - // and a runtime that installs its context after a fast EDITOR_READY still gets the tools. + // module finds no usable context, so the next non-booting transition probes again and + // a runtime that installs its context after a fast EDITOR_READY still gets the tools + // (a transition during the load itself needs no replay: the module probes on arrival). let webMCPStarted = false const startWebMCP = (): void => { if (!webMCP.enabled || webMCPStarted) { diff --git a/embed/src/case-transform.ts b/embed/src/case-transform.ts index 3a51e306..5173199c 100644 --- a/embed/src/case-transform.ts +++ b/embed/src/case-transform.ts @@ -3,10 +3,11 @@ // while the wire stays snake_case. // // KEYS ONLY: string / number / boolean values pass through untouched, so a field -// value that happens to contain underscores is never mangled. A generic deep -// key-map is safe here because NO operation payload carries an object with -// arbitrary (data-controlled) keys — the only such value, the editor `context`, -// is baked into the iframe URL at mount and never travels as an op payload. +// value that happens to contain underscores is never mangled. Payload keys are the +// contract's (SDK callers) or an agent's (the WebMCP path forwards its input as-is); +// `__proto__` is dropped below and the editor validates every payload, so a +// data-controlled key can neither pollute a prototype nor reach an operation unchecked. +// The editor `context` is baked into the iframe URL at mount and never travels as an op payload. const camelToSnakeKey = (key: string): string => key.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`) diff --git a/embed/src/webmcp-options.ts b/embed/src/webmcp-options.ts index e211df33..8128b008 100644 --- a/embed/src/webmcp-options.ts +++ b/embed/src/webmcp-options.ts @@ -7,6 +7,7 @@ export type WebMCPOptions = boolean | { exclude: readonly AgenticToolName[] } // The one decoder of the option shape: the bridge (start or not), the WebMCP module // (what to withhold) and the React layer (a remount key) all read this instead of // re-deriving the `undefined | false | true | { exclude }` cases. +/** @internal Shared with @simplepdf/react-embed-pdf; not part of the consumer contract. */ export const normalizeWebMCPOptions = ( options: WebMCPOptions | undefined, ): { enabled: false } | { enabled: true; exclude: readonly AgenticToolName[] } => { diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts index 60524f73..73ac86ee 100644 --- a/embed/src/webmcp.ts +++ b/embed/src/webmcp.ts @@ -84,7 +84,14 @@ const toCallToolResult = (result: BridgeResult): CallToolResult => ({ // A model context is a page-level singleton keyed by tool name, so two embeds on one // page would collide; the first registration of a name wins and the rest are reported. -const liveToolNames = new Set() +// Each name records the signal that owns it, so only its owner ever frees it. +const liveTools = new Map() + +const freeTool = (name: string, owner: AbortSignal): void => { + if (liveTools.get(name) === owner) { + liveTools.delete(name) + } +} // Returns whether a usable model context was found (and the tools handed to it), so // the bridge can keep probing on later lifecycle transitions when it was not. @@ -112,7 +119,7 @@ export const registerWebMCPTools = ({ if (!isAgenticOperation(operation) || excluded.has(operation.method)) { continue } - if (liveToolNames.has(operation.method)) { + if (liveTools.has(operation.method)) { logger.warn('webmcp.tool_already_registered', { tool: operation.method }) continue } @@ -124,15 +131,15 @@ export const registerWebMCPTools = ({ // A nullish input becomes an empty payload (the no-input operations' wire shape). execute: async (input) => toCallToolResult(await dispatch(operation.wire_type, input ?? {})), } - liveToolNames.add(tool.name) - signal.addEventListener('abort', () => liveToolNames.delete(tool.name), { once: true }) + liveTools.set(tool.name, signal) + signal.addEventListener('abort', () => freeTool(tool.name, signal), { once: true }) // Registration is best-effort: a runtime that rejects one tool must not take the // others down or escape as an unhandled rejection. void (async (): Promise => { try { await modelContext.registerTool(tool, { signal }) } catch (error) { - liveToolNames.delete(tool.name) + freeTool(tool.name, signal) logger.error('webmcp.register_tool_failed', { tool: tool.name, message: error instanceof Error ? error.message : String(error), diff --git a/embed/test/webmcp.test.ts b/embed/test/webmcp.test.ts index c4e36382..a734602d 100644 --- a/embed/test/webmcp.test.ts +++ b/embed/test/webmcp.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { attachEmbed, type AttachEmbedArgs } from '../src/bridge' import type { BridgeLogger } from '../src/logger' import type { Embed } from '../src/types' +import { AGENTIC_TOOL_NAMES } from '../src/generated/agentic-tool-names' const EDITOR_ORIGIN = 'https://tenant.simplepdf.com' @@ -110,22 +111,7 @@ const mountReady = (args: Pick): Har const waitForTools = (modelContext: FakeModelContext, count: number): Promise => vi.waitFor(() => expect(modelContext.registered).toHaveLength(count)) -const AGENTIC_TOOL_NAMES = [ - 'createField', - 'deleteFields', - 'deletePages', - 'detectFields', - 'download', - 'focusField', - 'getDocumentContent', - 'getFields', - 'goTo', - 'movePage', - 'rotatePage', - 'selectTool', - 'setFieldValue', - 'submit', -] +const TOOL_COUNT = AGENTIC_TOOL_NAMES.length // The bridge's readiness probe posts its own GET_FIELDS requests while the editor is // booting, so a tool call's request is located by type rather than by position. @@ -161,10 +147,10 @@ describe('attachEmbed({ enableWebMCP })', () => { it('registers every agentic operation on document.modelContext with the SDK name, description, camelCase input schema and an explicit behavior hint', async () => { const modelContext = installModelContext(document) mountReady({ enableWebMCP: true }) - await waitForTools(modelContext, 14) + await waitForTools(modelContext, TOOL_COUNT) - expect(modelContext.registered.map((tool) => tool.name).sort()).toEqual(AGENTIC_TOOL_NAMES) - expect(modelContext.liveToolNames()).toHaveLength(14) + expect(modelContext.registered.map((tool) => tool.name).sort()).toEqual([...AGENTIC_TOOL_NAMES].sort()) + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) const setFieldValue = findTool(modelContext, 'setFieldValue') expect(setFieldValue.description).toMatch(/^Set the value of an existing field/) expect(setFieldValue.inputSchema.type).toBe('object') @@ -195,14 +181,14 @@ describe('attachEmbed({ enableWebMCP })', () => { expect(registerTool).not.toHaveBeenCalled() booting.markEditorReady() - await waitForTools(modelContext, 14) + await waitForTools(modelContext, TOOL_COUNT) }) it('withholds the excluded operations and registers the rest', async () => { const modelContext = installModelContext(document) const logger = makeLogger() mountReady({ enableWebMCP: { exclude: ['submit', 'deletePages', 'movePage', 'rotatePage'] }, logger }) - await waitForTools(modelContext, 10) + await waitForTools(modelContext, TOOL_COUNT - 4) const names = modelContext.registered.map((tool) => tool.name) expect(names).toContain('setFieldValue') @@ -215,7 +201,7 @@ describe('attachEmbed({ enableWebMCP })', () => { it('executes a tool call as the operation request on the wire and returns the editor Result as a JSON-text tool result', async () => { const modelContext = installModelContext(document) const harness = mountReady({ enableWebMCP: true }) - await waitForTools(modelContext, 14) + await waitForTools(modelContext, TOOL_COUNT) const pendingResult = findTool(modelContext, 'setFieldValue').execute({ fieldId: 'f1', value: 'Jane' }) const request = await waitForRequest(harness, 'SET_FIELD_VALUE') @@ -229,7 +215,7 @@ describe('attachEmbed({ enableWebMCP })', () => { it('flags a failed editor Result as an error tool result that still carries the error code', async () => { const modelContext = installModelContext(document) const harness = mountReady({ enableWebMCP: true }) - await waitForTools(modelContext, 14) + await waitForTools(modelContext, TOOL_COUNT) const pendingResult = findTool(modelContext, 'goTo').execute({ page: 99 }) const request = await waitForRequest(harness, 'GO_TO') @@ -245,7 +231,7 @@ describe('attachEmbed({ enableWebMCP })', () => { it('sends an empty payload when a no-input tool is called without arguments', async () => { const modelContext = installModelContext(document) const harness = mountReady({ enableWebMCP: true }) - await waitForTools(modelContext, 14) + await waitForTools(modelContext, TOOL_COUNT) void findTool(modelContext, 'detectFields').execute(undefined) const request = await waitForRequest(harness, 'DETECT_FIELDS') @@ -255,8 +241,8 @@ describe('attachEmbed({ enableWebMCP })', () => { it('unregisters every tool when the embed is disposed', async () => { const modelContext = installModelContext(document) const harness = mountReady({ enableWebMCP: true }) - await waitForTools(modelContext, 14) - expect(modelContext.liveToolNames()).toHaveLength(14) + await waitForTools(modelContext, TOOL_COUNT) + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) harness.embed.lifecycle.dispose() expect(modelContext.liveToolNames()).toEqual([]) @@ -269,8 +255,8 @@ describe('attachEmbed({ enableWebMCP })', () => { // Control: a later embed on the same context registers its full set, proving the // early one's lazy load had every chance to run and registered nothing. mountReady({ enableWebMCP: true }) - await waitForTools(modelContext, 14) - expect(modelContext.liveToolNames()).toHaveLength(14) + await waitForTools(modelContext, TOOL_COUNT) + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) }) it('registers nothing when the option is off, even with a model context present', async () => { @@ -281,34 +267,55 @@ describe('attachEmbed({ enableWebMCP })', () => { // Control: a ready embed with the option on registers, proving the off ones had // the same chance and took none of it. mountReady({ enableWebMCP: true }) - await waitForTools(modelContext, 14) - expect(registerTool).toHaveBeenCalledTimes(14) + await waitForTools(modelContext, TOOL_COUNT) + expect(registerTool).toHaveBeenCalledTimes(TOOL_COUNT) }) it('lets the first embed on a page own each tool name and reports the collision for a second one', async () => { const modelContext = installModelContext(document) const logger = makeLogger() const first = mountReady({ enableWebMCP: true }) - await waitForTools(modelContext, 14) + await waitForTools(modelContext, TOOL_COUNT) mountReady({ enableWebMCP: true, logger }) await vi.waitFor(() => expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'submit' }), ) - expect(logger.warn).toHaveBeenCalledTimes(14) - expect(modelContext.registered).toHaveLength(14) + expect(logger.warn).toHaveBeenCalledTimes(TOOL_COUNT) + expect(modelContext.registered).toHaveLength(TOOL_COUNT) // Disposing the owner frees the names for the next embed. first.embed.lifecycle.dispose() mountReady({ enableWebMCP: true }) - await waitForTools(modelContext, 28) + await waitForTools(modelContext, TOOL_COUNT * 2) + }) + + it('frees a rejected name only for its owner, so a later embed that took the name keeps it', async () => { + // A: the runtime rejects `download`; A's abort must not later free a name it never owned. + const modelContext = installModelContext(document, { rejectTool: 'download' }) + const first = mountReady({ enableWebMCP: true, logger: makeLogger() }) + await waitForTools(modelContext, TOOL_COUNT - 1) + + // B: on an accepting context, takes `download` (the rest are reported as A's). + const accepting = installModelContext(document) + const second = mountReady({ enableWebMCP: true, logger: makeLogger() }) + await waitForTools(accepting, 1) + expect(accepting.registered[0]?.name).toBe('download') + + // A disposes: B's `download` stays owned, so C is refused it. + first.embed.lifecycle.dispose() + const logger = makeLogger() + mountReady({ enableWebMCP: true, logger }) + await vi.waitFor(() => expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'download' })) + expect(accepting.registered).toHaveLength(1) + second.embed.lifecycle.dispose() }) it('falls back to navigator.modelContext when the document exposes none', async () => { const modelContext = installModelContext(navigator) mountReady({ enableWebMCP: true }) - await waitForTools(modelContext, 14) - expect(modelContext.liveToolNames()).toHaveLength(14) + await waitForTools(modelContext, TOOL_COUNT) + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) }) it('reports an absent model context, never throws, and registers once a context appears', async () => { @@ -322,7 +329,7 @@ describe('attachEmbed({ enableWebMCP })', () => { // picked up on the next lifecycle transition. const modelContext = installModelContext(document) harness.markDocumentLoaded() - await waitForTools(modelContext, 14) + await waitForTools(modelContext, TOOL_COUNT) }) it('reports a model context without registerTool as invalid and keeps probing, so a placeholder filled in later still gets the tools', async () => { @@ -335,14 +342,14 @@ describe('attachEmbed({ enableWebMCP })', () => { const modelContext = installModelContext(document) harness.markDocumentLoaded() - await waitForTools(modelContext, 14) + await waitForTools(modelContext, TOOL_COUNT) }) it('keeps registering the other tools when the runtime rejects one, logs the failure, and frees that name', async () => { const modelContext = installModelContext(document, { rejectTool: 'download' }) const logger = makeLogger() mountReady({ enableWebMCP: true, logger }) - await waitForTools(modelContext, 13) + await waitForTools(modelContext, TOOL_COUNT - 1) expect(modelContext.registered.map((tool) => tool.name)).not.toContain('download') await vi.waitFor(() => diff --git a/react/src/embed-pdf.test.tsx b/react/src/embed-pdf.test.tsx index dc980890..19c0050d 100644 --- a/react/src/embed-pdf.test.tsx +++ b/react/src/embed-pdf.test.tsx @@ -49,6 +49,12 @@ describe('EmbedPDF (inline)', () => { const iframe = container.querySelector('iframe'); rerender(); expect(container.querySelector('iframe')).toBe(iframe); + + // A different value does remount: registration happens at mount. + rerender(); + const remounted = container.querySelector('iframe'); + expect(remounted).not.toBeNull(); + expect(remounted).not.toBe(iframe); }); it('renders the editor iframe inside the host element for the companyIdentifier origin', () => { From 0f189a4c856e6de0a139df45cc9c5beebcb26eb4 Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 2 Sep 2026 20:53:26 +0200 Subject: [PATCH 6/8] test: pin that a disposed embed frees only the names it owned --- embed/test/webmcp.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/embed/test/webmcp.test.ts b/embed/test/webmcp.test.ts index a734602d..6b14cc42 100644 --- a/embed/test/webmcp.test.ts +++ b/embed/test/webmcp.test.ts @@ -302,12 +302,14 @@ describe('attachEmbed({ enableWebMCP })', () => { await waitForTools(accepting, 1) expect(accepting.registered[0]?.name).toBe('download') - // A disposes: B's `download` stays owned, so C is refused it. + // A disposes: its 13 names are freed for C, but B's `download` stays owned, so C is refused it. first.embed.lifecycle.dispose() const logger = makeLogger() mountReady({ enableWebMCP: true, logger }) - await vi.waitFor(() => expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'download' })) - expect(accepting.registered).toHaveLength(1) + await waitForTools(accepting, TOOL_COUNT) + expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'download' }) + expect(logger.warn).toHaveBeenCalledTimes(1) + expect(accepting.registered.filter((tool) => tool.name === 'download')).toHaveLength(1) second.embed.lifecycle.dispose() }) From 7c6a42604f9fa5afc5ccf402164653caa66f1439 Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 2 Sep 2026 21:04:46 +0200 Subject: [PATCH 7/8] test: pin the readiness gate with a control on the read host; share the model-context host list; retry after a failed chunk load --- embed/README.md | 2 +- embed/scripts/check-bundle-size.mjs | 2 +- embed/src/bridge.ts | 12 +++++++----- embed/src/index.ts | 4 ++-- embed/src/mount.ts | 2 +- .../src/{webmcp-options.ts => webmcp-shared.ts} | 17 +++++++++++++++++ embed/src/webmcp.ts | 14 +++----------- embed/test/webmcp.test.ts | 17 +++++++++-------- react/src/embed-pdf.test.tsx | 4 ++-- 9 files changed, 43 insertions(+), 31 deletions(-) rename embed/src/{webmcp-options.ts => webmcp-shared.ts} (60%) diff --git a/embed/README.md b/embed/README.md index 22c7179f..ea8da36a 100644 --- a/embed/README.md +++ b/embed/README.md @@ -86,7 +86,7 @@ createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, enableWebMCP: true }) ``` -Off by default. Tools register once the editor is ready; while no usable model context has been found, the page is probed again on each later lifecycle transition, so a context installed after `EDITOR_READY` is still picked up, and until one appears nothing is loaded (`webmcp.unavailable` is logged, with the reason). The two readers (`getFields`, `getDocumentContent`) carry the specification's `readOnlyHint` and `untrustedContentHint` (their output is document-derived); every other tool carries MCP's `destructiveHint`, read by runtimes that honor MCP's hints. The editor validates each call like any other request (its permission model applies at call time: editing, allowlisted origin, plan, so a tool the tenant configuration refuses resolves with the matching error code), a call resolves with an MCP tool result whose text is the editor's `{ success, data | error }` Result (`isError` on failure), and `dispose()` unregisters everything. A model context is one per page and keyed by tool name, so enable WebMCP on one embed per page: a second one is reported (`webmcp.tool_already_registered`) and registers nothing. In React, pass `enableWebMCP` to ``. +Off by default. Tools register once the editor is ready; while no usable model context has been found, the page is probed again on each later lifecycle transition, so a context installed after `EDITOR_READY` is still picked up, and until one appears nothing is loaded (`webmcp.unavailable` is logged, with the reason). The two readers (`getFields`, `getDocumentContent`) carry the specification's `readOnlyHint` and `untrustedContentHint` (their output is document-derived); every other tool carries MCP's `destructiveHint`, read by runtimes that honor MCP's hints. The editor validates each call like any other request (its permission model applies at call time: editing, allowlisted origin, plan, so a tool the tenant configuration refuses resolves with the matching error code), a call resolves with an MCP tool result whose text is the editor's `{ success, data | error }` Result (`isError` on failure), and `dispose()` unregisters everything. A model context is one per page and keyed by tool name, so enable WebMCP on one embed per page: a second one registers only the names the first did not take, and is reported for the rest (`webmcp.tool_already_registered`). In React, pass `enableWebMCP` to ``. ## Subpaths diff --git a/embed/scripts/check-bundle-size.mjs b/embed/scripts/check-bundle-size.mjs index 193033a0..fcfd2f56 100644 --- a/embed/scripts/check-bundle-size.mjs +++ b/embed/scripts/check-bundle-size.mjs @@ -13,7 +13,7 @@ import { LAZY_CHUNKS } from './lazy-chunks.mjs' const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist') -// Gzip budget (bytes) per entry's local closure. Each cap sits 0.5–1 KB above the +// Gzip budget (bytes) per entry's local closure. Each cap sits 0.5–1.5 KB above the // measured size, so any non-trivial growth trips the gate and gets reviewed. // The zero-dep root carries the bridge + createEmbed (create + attach paths) + its // actionable config validation + the WebMCP opt-in hook. diff --git a/embed/src/bridge.ts b/embed/src/bridge.ts index c5982196..4adc8510 100644 --- a/embed/src/bridge.ts +++ b/embed/src/bridge.ts @@ -12,7 +12,7 @@ import type { PageFocusedPayload, SubmissionSentPayload, } from './types' -import { normalizeWebMCPOptions, type WebMCPOptions } from './webmcp-options' +import { modelContextCandidates, normalizeWebMCPOptions, type WebMCPOptions } from './webmcp-shared' export type AttachEmbedArgs = { // Getter returning the iframe element. Called each time the bridge needs to @@ -167,15 +167,16 @@ export const attachEmbed = ({ const webMCPController = new AbortController() const webMCP = normalizeWebMCPOptions(enableWebMCP) // Latched while a registration attempt is in flight or succeeded; released when the - // module finds no usable context, so the next non-booting transition probes again and - // a runtime that installs its context after a fast EDITOR_READY still gets the tools - // (a transition during the load itself needs no replay: the module probes on arrival). + // module finds no usable context or fails to load, so the next non-booting transition + // probes again and a runtime that installs its context after a fast EDITOR_READY (or a + // transient chunk fetch failure) still gets the tools. A transition during the load + // itself needs no replay: the module probes on arrival. let webMCPStarted = false const startWebMCP = (): void => { if (!webMCP.enabled || webMCPStarted) { return } - if (!('modelContext' in document) && !('modelContext' in navigator)) { + if (modelContextCandidates().length === 0) { logger.info('webmcp.unavailable', { reason: 'no_model_context' }) return } @@ -193,6 +194,7 @@ export const attachEmbed = ({ } }) .catch((error: unknown) => { + webMCPStarted = false logger.error('webmcp.load_failed', { message: error instanceof Error ? error.message : String(error) }) }) } diff --git a/embed/src/index.ts b/embed/src/index.ts index 38ab50ab..41a396ec 100644 --- a/embed/src/index.ts +++ b/embed/src/index.ts @@ -4,8 +4,8 @@ export { createEmbed, EmbedConfigError } from './mount' export type { CreateEmbedArgs, EmbedDocument } from './mount' -export { normalizeWebMCPOptions } from './webmcp-options' -export type { WebMCPOptions } from './webmcp-options' +export { normalizeWebMCPOptions } from './webmcp-shared' +export type { WebMCPOptions } from './webmcp-shared' export { NOOP_LOGGER } from './logger' export type { BridgeLogger, LogPayload } from './logger' export { BridgeUnwrapError, unwrap } from './unwrap' diff --git a/embed/src/mount.ts b/embed/src/mount.ts index 81a41c2c..99a1b926 100644 --- a/embed/src/mount.ts +++ b/embed/src/mount.ts @@ -3,7 +3,7 @@ import { type BridgeLogger, makeSafeLogger, NOOP_LOGGER } from './logger' import type { BridgeState, Embed } from './types' import { AGENTIC_TOOL_NAMES } from './generated/agentic-tool-names' import type { Locale } from './generated/contract' -import type { WebMCPOptions } from './webmcp-options' +import type { WebMCPOptions } from './webmcp-shared' // Construction-time configuration error. createEmbed validates its config // synchronously and THROWS this on programmer error (bad target/companyIdentifier/document diff --git a/embed/src/webmcp-options.ts b/embed/src/webmcp-shared.ts similarity index 60% rename from embed/src/webmcp-options.ts rename to embed/src/webmcp-shared.ts index 8128b008..3f6f07f3 100644 --- a/embed/src/webmcp-options.ts +++ b/embed/src/webmcp-shared.ts @@ -1,5 +1,22 @@ +// What the zero-dep root needs to know about WebMCP without loading the module: +// the option shape and where a model context lives. + import type { AgenticToolName } from './generated/contract' +// Every value a page may expose as its model context, document first (the canonical +// install location since Chrome 150; `navigator.modelContext` is the deprecated alias +// older runtimes still expose). The bridge checks presence, the module validity. +export const modelContextCandidates = (): unknown[] => { + const candidates: unknown[] = [] + if ('modelContext' in document) { + candidates.push(document.modelContext) + } + if ('modelContext' in navigator) { + candidates.push(navigator.modelContext) + } + return candidates +} + // `true` registers every agentic operation; `exclude` withholds the listed ones // (e.g. `submit` when only a person may finalize). `false` / omitted registers nothing. export type WebMCPOptions = boolean | { exclude: readonly AgenticToolName[] } diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts index 73ac86ee..bf1a5458 100644 --- a/embed/src/webmcp.ts +++ b/embed/src/webmcp.ts @@ -1,6 +1,5 @@ // Registers the editor operations as WebMCP tools on the HOST page's model context -// (`document.modelContext`; `navigator.modelContext` is the deprecated alias older -// runtimes expose) and executes each call over the bridge's wire dispatch, so the +// and executes each call over the bridge's wire dispatch, so the // editor validates the agent's input exactly as it validates every other request. // The host page is where an in-browser agent looks: tools registered inside the // editor iframe are not discovered, which is why the SDK lifts them here. @@ -14,6 +13,7 @@ import { OPERATIONS, type AgenticToolName, type WireType } from './generated/con import { TOOL_INPUT_SCHEMAS, type ToolInputSchema } from './generated/tool-input-schemas' import type { BridgeLogger } from './logger' import type { BridgeResult } from './types' +import { modelContextCandidates } from './webmcp-shared' // The slice of the WebMCP surface this module touches, typed structurally so the // zero-dependency root pulls in no type package. `readOnlyHint` and @@ -67,15 +67,7 @@ const isAgenticOperation = (operation: Operation): operation is AgenticOperation const isModelContext = (value: unknown): value is ModelContext => typeof value === 'object' && value !== null && 'registerTool' in value && typeof value.registerTool === 'function' -const readModelContext = (): ModelContext | null => { - if ('modelContext' in document && isModelContext(document.modelContext)) { - return document.modelContext - } - if ('modelContext' in navigator && isModelContext(navigator.modelContext)) { - return navigator.modelContext - } - return null -} +const readModelContext = (): ModelContext | null => modelContextCandidates().find(isModelContext) ?? null const toCallToolResult = (result: BridgeResult): CallToolResult => ({ content: [{ type: 'text', text: JSON.stringify(result) }], diff --git a/embed/test/webmcp.test.ts b/embed/test/webmcp.test.ts index 6b14cc42..ba50194c 100644 --- a/embed/test/webmcp.test.ts +++ b/embed/test/webmcp.test.ts @@ -171,17 +171,18 @@ describe('attachEmbed({ enableWebMCP })', () => { it('waits for the editor to be ready before registering, so an early tool call cannot post into a listener-less iframe', async () => { const modelContext = installModelContext(document) - const registerTool = vi.spyOn(modelContext, 'registerTool') const booting = makeHarness({ enableWebMCP: true }) - // Control: a second, ready embed proves the lazy path had time to run while the - // booting one still registered nothing. - const control = installModelContext(navigator) - mountReady({ enableWebMCP: true }) - await waitForTools(control, 0) - expect(registerTool).not.toHaveBeenCalled() + // Control: a ready embed on the same context proves the lazy path had time to run; + // every live name is the control's, so the booting embed registered nothing. + const control = mountReady({ enableWebMCP: true }) + await waitForTools(modelContext, TOOL_COUNT) + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) + control.embed.lifecycle.dispose() + expect(modelContext.liveToolNames()).toEqual([]) booting.markEditorReady() - await waitForTools(modelContext, TOOL_COUNT) + await waitForTools(modelContext, TOOL_COUNT * 2) + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) }) it('withholds the excluded operations and registers the rest', async () => { diff --git a/react/src/embed-pdf.test.tsx b/react/src/embed-pdf.test.tsx index 19c0050d..19235ab8 100644 --- a/react/src/embed-pdf.test.tsx +++ b/react/src/embed-pdf.test.tsx @@ -32,8 +32,8 @@ describe('EmbedPDF (inline)', () => { source: container.querySelector('iframe')?.contentWindow ?? null, }), ); - await waitFor(() => expect(liveTools.size).toBe(13)); - expect(liveTools.has('setFieldValue')).toBe(true); + await waitFor(() => expect(liveTools.has('setFieldValue')).toBe(true)); + expect(liveTools.size).toBeGreaterThan(1); expect(liveTools.has('submit')).toBe(false); unmount(); expect(liveTools.size).toBe(0); From c22d8ce71a7f3dd40c56613756d617ac9a90dcf5 Mon Sep 17 00:00:00 2001 From: ben Date: Thu, 3 Sep 2026 11:53:34 +0200 Subject: [PATCH 8/8] docs: repoint the hints breadcrumb to the editor's contract module --- embed/src/webmcp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts index bf1a5458..d1d6fc77 100644 --- a/embed/src/webmcp.ts +++ b/embed/src/webmcp.ts @@ -44,7 +44,7 @@ type AgenticOperation = Extract // not destructive here because the person reviews every value in the editor before // the one irreversible step, submit. The Record makes a new operation a compile // error until it is annotated. Kept identical to the editor's in-page tool hints. -// CF: WEBMCP_TOOL_ANNOTATIONS in the editor's lib/iframe/handlers.ts (SimplePDF editor repository) +// CF: WEBMCP_TOOL_ANNOTATIONS in the editor's lib/iframe/contract.ts (SimplePDF editor repository) const TOOL_ANNOTATIONS = { createField: { destructiveHint: false }, deleteFields: { destructiveHint: true },