From f1bd85e4231373e40ff9097946d62ee339777291 Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 2 Sep 2026 20:06:34 +0200 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 }, From 5c9b4daaebf4e90d14b0c219bf3a73f526dc545f Mon Sep 17 00:00:00 2001 From: ben Date: Sat, 12 Sep 2026 16:04:25 +0200 Subject: [PATCH 09/15] feat: getAnnotatedPage and the lifecycle events from the live manifest pin Re-sync embed-api.json to the served /embed/json (16 operations): getAnnotatedPage joins IframeActions, the tool subpaths and the WebMCP tools; the generator honors an object schema whose additionalProperties is a value schema (a map: Record / z.record), the shape of the badges output. EDITOR_READY and DOCUMENT_LOADED come from the manifest events like PAGE_FOCUSED and SUBMISSION_SENT: internal-protocol.ts is gone, the EditorEvent union is drift-guarded for exact equality with the generated outbound events, and the root exports EditorReadyPayload / DocumentLoadedPayload. The lazy WebMCP chunk budget follows the extra operation. Changesets for both packages. --- .changeset/get-annotated-page.md | 6 + .changeset/lifecycle-events-in-manifest.md | 5 + embed/README.md | 4 +- embed/embed-api.json | 450 +++++++++++++++++++-- embed/etc/index.api.md | 29 +- embed/etc/protocol.api.md | 28 +- embed/etc/schemas.api.md | 8 + embed/etc/tanstack-ai.api.md | 2 +- embed/etc/tools.api.md | 12 +- embed/scripts/generate.mjs | 41 +- embed/scripts/lazy-chunks.mjs | 2 +- embed/src/bridge.ts | 12 +- embed/src/generated/agentic-tool-names.ts | 2 +- embed/src/generated/contract.ts | 23 +- embed/src/generated/drift.ts | 8 +- embed/src/generated/schemas.ts | 18 +- embed/src/generated/tool-input-schemas.ts | 5 +- embed/src/generated/tools.ts | 7 +- embed/src/internal-protocol.ts | 13 - embed/src/protocol.ts | 7 +- embed/src/tools.ts | 3 + embed/src/types.ts | 16 +- embed/src/webmcp.ts | 1 + embed/test/tanstack-ai.test.ts | 8 +- embed/test/tools.test.ts | 4 +- react/README.md | 2 +- react/src/embed-pdf.tsx | 1 + skills/build-with-simplepdf/SKILL.md | 4 +- 28 files changed, 610 insertions(+), 111 deletions(-) create mode 100644 .changeset/get-annotated-page.md create mode 100644 .changeset/lifecycle-events-in-manifest.md delete mode 100644 embed/src/internal-protocol.ts diff --git a/.changeset/get-annotated-page.md b/.changeset/get-annotated-page.md new file mode 100644 index 00000000..6de9f740 --- /dev/null +++ b/.changeset/get-annotated-page.md @@ -0,0 +1,6 @@ +--- +'@simplepdf/embed': minor +'@simplepdf/react-embed-pdf': minor +--- + +Add `getAnnotatedPage({ page })` (the editor's `GET_ANNOTATED_PAGE`): a PNG render of one page with every field outlined and numbered, plus a `badges` map from each number to its `field_id`, so a vision model can label fields by looking at the printed form. Available as `embed.actions.getAnnotatedPage` / `useEmbed().actions.getAnnotatedPage`, as the `getAnnotatedPage` agentic tool on every tool subpath, and as a WebMCP tool (a reader: `readOnlyHint` + `untrustedContentHint`). The contract pin follows the live manifest (`GET_FIELDS` now points agents at `get_annotated_page`). diff --git a/.changeset/lifecycle-events-in-manifest.md b/.changeset/lifecycle-events-in-manifest.md new file mode 100644 index 00000000..a59e197a --- /dev/null +++ b/.changeset/lifecycle-events-in-manifest.md @@ -0,0 +1,5 @@ +--- +'@simplepdf/embed': minor +--- + +`EDITOR_READY` and `DOCUMENT_LOADED` now come from the editor manifest (`/embed/json` `events`), like `PAGE_FOCUSED` and `SUBMISSION_SENT`: `OUTBOUND_EVENTS` / `OutboundEventType` on `@simplepdf/embed/protocol` list all four (a widening: exhaustive consumers of `OutboundEventType` gain two members), and the root exports the `EditorReadyPayload` / `DocumentLoadedPayload` types. The `EditorEvent` shapes are unchanged. diff --git a/embed/README.md b/embed/README.md index ea8da36a..4183298b 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 registers only the names the first did not take, and is reported for the rest (`webmcp.tool_already_registered`). 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 readers (`getFields`, `getDocumentContent`, `getAnnotatedPage`) 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 @@ -174,7 +174,7 @@ await embed.actions.rotatePage({ page: 1 }) await embed.actions.download() ``` -Full set: `createField`, `deleteFields`, `deletePages`, `detectFields`, `download`, `focusField`, `getDocumentContent`, `getFields`, `goTo`, `loadDocument`, `movePage`, `rotatePage`, `selectTool`, `setFieldValue`, `submit`. +Full set: `createField`, `deleteFields`, `deletePages`, `detectFields`, `download`, `focusField`, `getAnnotatedPage`, `getDocumentContent`, `getFields`, `goTo`, `loadDocument`, `movePage`, `rotatePage`, `selectTool`, `setFieldValue`, `submit`. **"Fill and read this document for me"** is just these operations in sequence, exactly what the agentic tools expose to a model: diff --git a/embed/embed-api.json b/embed/embed-api.json index bf0242bd..60a1e129 100644 --- a/embed/embed-api.json +++ b/embed/embed-api.json @@ -1,5 +1,5 @@ { - "editor_version": "e0d6facf-20260825T141653Z", + "editor_version": "0b688f24-20260911T151304Z", "description": "This is the SimplePDF editor interface contract. Drive the editor programmatically over window.postMessage with the editor iframe: post a JSON string { \"type\": {{operation request_type}}, \"request_id\": {{a request correlation id you generate}}, \"data\": {{input matching input_schema}} } to the iframe; the editor replies with { \"type\": \"REQUEST_RESULT\", \"data\": { \"request_id\": {{the same correlation id}}, \"result\": {{result}} } } (match each reply to its request by this id), where result is { \"success\": true, \"data\": {{a value matching the operation's output_schema; null for ops that return nothing}} } or { \"success\": false, \"error\": { code, message } }. Each operation lists the op-specific `error_codes` it can return; on top of those, any op may also fail with a gateway/permission code (origin/plan/signup gating, editor-not-ready, or an internal error). Every code is within `editor_error_schema` (the complete closed union to narrow against), where each code const carries a `description` of its meaning. Outbound events (see `events`) are pushed the same way. `operations` lists every operation the editor supports. Most require the embedding origin to be allowlisted (\"whitelisted\") for the tenant in the SimplePDF admin dashboard, and the tenant plan to permit them (LOAD_DOCUMENT is always available). A call the current setup does not permit returns the matching gateway code: forbidden:origin_not_whitelisted when the origin is not allowlisted, or bad_request:plan_upgrade_required when the plan excludes it. All JSON schemas use the json_schema_dialect declared at the root. We recommend the @simplepdf/embed package for a typed, ergonomic wrapper over this contract (https://github.com/SimplePDF/simplepdf-embed).", "json_schema_dialect": "https://json-schema.org/draft/2020-12/schema", "protocol": { @@ -108,7 +108,7 @@ "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.", + "description": "Optional initial value. A string for text/checkbox fields, or a data URL or http(s) URL (fetched by the editor) for signature/picture fields.", "type": "string" } }, @@ -133,7 +133,50 @@ "bad_request:page_not_found", "bad_request:invalid_field_type", "bad_request:invalid_signature_url" - ] + ], + "tool": { + "name": "simplepdf_embed_create_field", + "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": { + "enum": ["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT"], + "description": "Field type to create.", + "type": "string" + }, + "x": { + "description": "Field x position, in PDF points.", + "type": "number" + }, + "y": { + "description": "Field y position, in PDF points.", + "type": "number" + }, + "width": { + "description": "Field width, in PDF points.", + "type": "number" + }, + "height": { + "description": "Field height, in PDF points.", + "type": "number" + }, + "page": { + "description": "1-based page to place the field on.", + "type": "integer" + }, + "value": { + "description": "Optional initial value. A string for text/checkbox fields, or a data URL or http(s) URL (fetched by the editor) for signature/picture fields.", + "type": "string" + } + }, + "required": ["type", "x", "y", "width", "height", "page"] + }, + "annotations": { + "destructiveHint": false, + "openWorldHint": true + } + } }, { "request_type": "DELETE_FIELDS", @@ -170,7 +213,30 @@ "bad_request:invalid_page", "bad_request:page_out_of_range", "bad_request:page_not_found" - ] + ], + "tool": { + "name": "simplepdf_embed_delete_fields", + "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": { + "field_ids": { + "description": "IDs of the fields to delete. Omit to delete every field on the target page.", + "items": { + "type": "string" + }, + "type": "array" + }, + "page": { + "description": "1-based page to scope the deletion to. Omit to target all pages.", + "type": "integer" + } + } + }, + "annotations": { + "destructiveHint": true + } + } }, { "request_type": "DELETE_PAGES", @@ -198,7 +264,27 @@ "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found" - ] + ], + "tool": { + "name": "simplepdf_embed_delete_pages", + "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": { + "items": { + "type": "integer" + }, + "description": "1-based page numbers to delete.", + "type": "array" + } + }, + "required": ["pages"] + }, + "annotations": { + "destructiveHint": true + } + } }, { "request_type": "DETECT_FIELDS", @@ -217,7 +303,18 @@ }, "required": ["detected_count"] }, - "error_codes": ["forbidden:editing_not_allowed", "bad_request:no_document_loaded"] + "error_codes": ["forbidden:editing_not_allowed", "bad_request:no_document_loaded"], + "tool": { + "name": "simplepdf_embed_detect_fields", + "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", + "properties": {} + }, + "annotations": { + "destructiveHint": false + } + } }, { "request_type": "DOWNLOAD", @@ -234,11 +331,22 @@ "bad_request:no_document_loaded", "bad_request:missing_required_fields", "bad_request:download_blocked" - ] + ], + "tool": { + "name": "simplepdf_embed_download", + "description": "Generate and download the current document as a PDF. Returns no data.", + "input_schema": { + "type": "object", + "properties": {} + }, + "annotations": { + "destructiveHint": false + } + } }, { "request_type": "FOCUS_FIELD", - "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.", + "description": "Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next.", "input_schema": { "type": "object", "properties": { @@ -248,7 +356,7 @@ } }, "required": ["field_id"], - "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." + "description": "Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next." }, "output_schema": { "type": "object", @@ -269,7 +377,87 @@ }, "required": ["hint"] }, - "error_codes": ["bad_request:invalid_value", "bad_request:no_document_loaded", "bad_request:field_not_found"] + "error_codes": ["bad_request:invalid_value", "bad_request:no_document_loaded", "bad_request:field_not_found"], + "tool": { + "name": "simplepdf_embed_focus_field", + "description": "Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next.", + "input_schema": { + "type": "object", + "properties": { + "field_id": { + "description": "ID of the field to focus and scroll into view.", + "type": "string" + } + }, + "required": ["field_id"] + }, + "annotations": { + "destructiveHint": false + } + } + }, + { + "request_type": "GET_ANNOTATED_PAGE", + "description": "Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant.", + "input_schema": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "1-based page to render, at its current position." + } + }, + "required": ["page"], + "description": "Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant." + }, + "output_schema": { + "type": "object", + "properties": { + "page": { + "type": "integer", + "description": "1-based page position this render shows." + }, + "image_data_url": { + "type": "string", + "description": "PNG render of the page as a data URL, with every field outlined and a numbered badge drawn inside it on the right, or just to its left when the field is too small to hold it." + }, + "image_width": { + "type": "integer", + "description": "Render width in pixels." + }, + "image_height": { + "type": "integer", + "description": "Render height in pixels." + }, + "badges": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Badge number (as drawn on the render) to field_id, for every field on the page." + } + }, + "required": ["page", "image_data_url", "image_width", "image_height", "badges"] + }, + "error_codes": ["bad_request:invalid_page", "bad_request:page_out_of_range"], + "tool": { + "name": "simplepdf_embed_get_annotated_page", + "description": "Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant.", + "input_schema": { + "type": "object", + "properties": { + "page": { + "description": "1-based page to render, at its current position.", + "type": "integer" + } + }, + "required": ["page"] + }, + "annotations": { + "readOnlyHint": true, + "untrustedContentHint": true + } + } }, { "request_type": "GET_DOCUMENT_CONTENT", @@ -309,15 +497,33 @@ }, "required": ["name", "pages"] }, - "error_codes": ["bad_request:invalid_value", "bad_request:no_document_loaded"] + "error_codes": ["bad_request:invalid_value", "bad_request:no_document_loaded"], + "tool": { + "name": "simplepdf_embed_get_document_content", + "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": { + "extraction_mode": { + "description": "Extraction strategy: 'auto' (default) or 'ocr' to force optical recognition.", + "enum": ["auto", "ocr"], + "type": "string" + } + } + }, + "annotations": { + "readOnlyHint": true, + "untrustedContentHint": true + } + } }, { "request_type": "GET_FIELDS", - "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 }.", + "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. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.", "input_schema": { "type": "object", "properties": {}, - "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 }." + "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. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }." }, "output_schema": { "type": "object", @@ -345,7 +551,8 @@ "enum": ["TEXT", "SIGNATURE", "PICTURE", "CHECKBOX", "COMB_TEXT", "DROPDOWN", "RADIO"] }, "page": { - "type": "integer" + "type": "integer", + "description": "1-based SOURCE page number the field sits on (its position when the document loaded). Page operations and get_annotated_page address CURRENT positions, so after move_page or delete_pages the two can differ." }, "value": { "anyOf": [ @@ -370,7 +577,7 @@ "type": "null" } ], - "description": "Valid values for this field (a DROPDOWN/RADIO's choices), or null. When set, the value passed to set_field_value or create_field must be one of these." + "description": "Valid values for this field (a DROPDOWN/RADIO's choices), or null. When set, a value set on or created for the field must be one of these." } }, "required": ["field_id", "name", "type", "page", "value", "options"] @@ -379,7 +586,19 @@ }, "required": ["fields"] }, - "error_codes": ["bad_request:no_document_loaded"] + "error_codes": ["bad_request:no_document_loaded"], + "tool": { + "name": "simplepdf_embed_get_fields", + "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. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.", + "input_schema": { + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "untrustedContentHint": true + } + } }, { "request_type": "GO_TO", @@ -398,17 +617,34 @@ "output_schema": { "type": "null" }, - "error_codes": ["bad_request:invalid_page", "bad_request:page_out_of_range"] + "error_codes": ["bad_request:invalid_page", "bad_request:page_out_of_range"], + "tool": { + "name": "simplepdf_embed_go_to", + "description": "Scroll the editor to a specific 1-based page. Returns no data.", + "input_schema": { + "type": "object", + "properties": { + "page": { + "description": "1-based page to navigate to.", + "type": "integer" + } + }, + "required": ["page"] + }, + "annotations": { + "destructiveHint": false + } + } }, { "request_type": "LOAD_DOCUMENT", - "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.", + "description": "Replace the document in the editor with one supplied as a base64 data URL or an http(s) URL the editor fetches. Destructive: the current document and every edit in it are discarded. Returns no data.", "input_schema": { "type": "object", "properties": { "data_url": { "type": "string", - "description": "The document to load, as a data URL." + "description": "The document to load: a data URL, or an http(s) URL the editor fetches." }, "name": { "description": "Optional display name for the document.", @@ -420,12 +656,38 @@ } }, "required": ["data_url"], - "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." + "description": "Replace the document in the editor with one supplied as a base64 data URL or an http(s) URL the editor fetches. Destructive: the current document and every edit in it are discarded. Returns no data." }, "output_schema": { "type": "null" }, - "error_codes": ["bad_request:invalid_value", "bad_request:invalid_page"] + "error_codes": ["bad_request:invalid_value", "bad_request:invalid_page"], + "tool": { + "name": "simplepdf_embed_load_document", + "description": "Replace the document in the editor with one supplied as a base64 data URL or an http(s) URL the editor fetches. Destructive: the current document and every edit in it are discarded. Returns no data.", + "input_schema": { + "type": "object", + "properties": { + "data_url": { + "description": "The document to load: a data URL, or an http(s) URL the editor fetches.", + "type": "string" + }, + "name": { + "description": "Optional display name for the document.", + "type": "string" + }, + "page": { + "description": "Optional 1-based page to open the document on.", + "type": "integer" + } + }, + "required": ["data_url"] + }, + "annotations": { + "destructiveHint": true, + "openWorldHint": true + } + } }, { "request_type": "MOVE_PAGE", @@ -454,7 +716,28 @@ "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found" - ] + ], + "tool": { + "name": "simplepdf_embed_move_page", + "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": { + "from_page": { + "description": "1-based current position of the page to move.", + "type": "integer" + }, + "to_page": { + "description": "1-based destination position for the page.", + "type": "integer" + } + }, + "required": ["from_page", "to_page"] + }, + "annotations": { + "destructiveHint": true + } + } }, { "request_type": "ROTATE_PAGE", @@ -479,7 +762,24 @@ "bad_request:page_out_of_range", "bad_request:no_document_loaded", "bad_request:page_not_found" - ] + ], + "tool": { + "name": "simplepdf_embed_rotate_page", + "description": "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled.", + "input_schema": { + "type": "object", + "properties": { + "page": { + "description": "1-based page to rotate 90 degrees clockwise.", + "type": "integer" + } + }, + "required": ["page"] + }, + "annotations": { + "destructiveHint": true + } + } }, { "request_type": "SELECT_TOOL", @@ -506,11 +806,36 @@ "output_schema": { "type": "null" }, - "error_codes": ["bad_request:invalid_tool"] + "error_codes": ["bad_request:invalid_tool"], + "tool": { + "name": "simplepdf_embed_select_tool", + "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"] + }, + "annotations": { + "destructiveHint": false + } + } }, { "request_type": "SET_FIELD_VALUE", - "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.", + "description": "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Returns no data.", "input_schema": { "type": "object", "properties": { @@ -527,11 +852,11 @@ "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)." + "description": "New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." } }, "required": ["field_id", "value"], - "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." + "description": "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Returns no data." }, "output_schema": { "type": "null" @@ -542,7 +867,36 @@ "bad_request:no_document_loaded", "bad_request:read_only", "bad_request:field_not_found" - ] + ], + "tool": { + "name": "simplepdf_embed_set_field_value", + "description": "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Returns no data.", + "input_schema": { + "type": "object", + "properties": { + "field_id": { + "description": "ID of the field to update.", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)." + } + }, + "required": ["field_id", "value"] + }, + "annotations": { + "destructiveHint": false, + "openWorldHint": true + } + } }, { "request_type": "SUBMIT", @@ -561,10 +915,48 @@ "output_schema": { "type": "null" }, - "error_codes": ["bad_request:invalid_value", "bad_request:missing_required_fields"] + "error_codes": ["bad_request:invalid_value", "bad_request:missing_required_fields"], + "tool": { + "name": "simplepdf_embed_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": { + "download_copy": { + "description": "When true, the signer also receives a downloaded copy on submit.", + "type": "boolean" + } + }, + "required": ["download_copy"] + }, + "annotations": { + "destructiveHint": true + } + } } ], "events": [ + { + "event_type": "EDITOR_READY", + "description": "Pushed once when the editor iframe boots in loading-placeholder mode (the loadingPlaceholder=true iframe query flag, which @simplepdf/embed sets while it waits to post LOAD_DOCUMENT) and accepts operations; before it, every operation fails with bad_request:editor_not_ready. An iframe opened with a document instead goes straight to DOCUMENT_LOADED. It is not replayed: a listener attached after boot never receives it, so treat bad_request:editor_not_ready as \"retry shortly\" rather than waiting for this event.", + "payload_schema": { + "type": "object", + "properties": {} + } + }, + { + "event_type": "DOCUMENT_LOADED", + "description": "Pushed exactly once per loaded document, when the document and its fields are ready; the payload carries the document_id. Wait for it before operating on the document: until it fires, operations other than LOAD_DOCUMENT fail with bad_request:no_document_loaded or bad_request:editor_not_ready, and GET_FIELDS may report an incomplete field list. On a blank editor it fires once a document is loaded, by LOAD_DOCUMENT or by the user.", + "payload_schema": { + "type": "object", + "properties": { + "document_id": { + "type": "string" + } + }, + "required": ["document_id"] + } + }, { "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/etc/index.api.md b/embed/etc/index.api.md index 3646c660..f57ed4b7 100644 --- a/embed/etc/index.api.md +++ b/embed/etc/index.api.md @@ -124,6 +124,11 @@ export type DocumentContentPage = GetDocumentContentOutput['pages'][number]; // @public (undocumented) export type DocumentContentResult = GetDocumentContentOutput; +// @public (undocumented) +export type DocumentLoadedPayload = { + document_id: string; +}; + // Warning: (ae-forgotten-export) The symbol "EDITOR_ERROR_CODES" needs to be exported by the entry point index.d.ts // // @public (undocumented) @@ -132,12 +137,10 @@ export type EditorErrorCode = (typeof EDITOR_ERROR_CODES)[number]; // @public (undocumented) export type EditorEvent = { type: 'EDITOR_READY'; - data: Record; + data: EditorReadyPayload; } | { type: 'DOCUMENT_LOADED'; - data: { - document_id: string; - }; + data: DocumentLoadedPayload; } | { type: 'PAGE_FOCUSED'; data: PageFocusedPayload; @@ -151,6 +154,9 @@ export type EditorEventMap = { [TEvent in EditorEvent as TEvent['type']]: TEvent['data']; }; +// @public (undocumented) +export type EditorReadyPayload = Record; + // @public (undocumented) export type Embed = { actions: IframeActions; @@ -216,6 +222,20 @@ export type FocusFieldOutput = { }; }; +// @public (undocumented) +export type GetAnnotatedPageInput = { + page: number; +}; + +// @public (undocumented) +export type GetAnnotatedPageOutput = { + page: number; + imageDataUrl: string; + imageWidth: number; + imageHeight: number; + badges: Record; +}; + // @public (undocumented) export type GetDocumentContentInput = { extractionMode?: ExtractionMode; @@ -255,6 +275,7 @@ export type IframeActions = { detectFields: () => Promise>; download: () => Promise; focusField: (input: FocusFieldInput) => Promise>; + getAnnotatedPage: (input: GetAnnotatedPageInput) => Promise>; getDocumentContent: (input?: GetDocumentContentInput) => Promise>; getFields: () => Promise>; goTo: (input: GoToInput) => Promise; diff --git a/embed/etc/protocol.api.md b/embed/etc/protocol.api.md index 6d81da09..65e18ef7 100644 --- a/embed/etc/protocol.api.md +++ b/embed/etc/protocol.api.md @@ -70,10 +70,18 @@ export const OPERATIONS: readonly [{ readonly request_type: "FOCUS_FIELD"; 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 description: "Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next."; 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; +}, { + readonly request_type: "GET_ANNOTATED_PAGE"; + readonly wire_type: "GET_ANNOTATED_PAGE"; + readonly method: "getAnnotatedPage"; + readonly description: "Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant."; + readonly error_codes: readonly ["bad_request:invalid_page", "bad_request:page_out_of_range"]; + readonly is_agentic_tool: true; + readonly has_output: true; }, { readonly request_type: "GET_DOCUMENT_CONTENT"; readonly wire_type: "GET_DOCUMENT_CONTENT"; @@ -86,7 +94,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "GET_FIELDS"; 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 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. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }."; readonly error_codes: readonly ["bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -102,7 +110,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "LOAD_DOCUMENT"; 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 description: "Replace the document in the editor with one supplied as a base64 data URL or an http(s) URL the editor fetches. Destructive: the current document and every edit in it are discarded. Returns no data."; readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:invalid_page"]; readonly is_agentic_tool: false; readonly has_output: false; @@ -134,7 +142,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "SET_FIELD_VALUE"; 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 description: "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Returns no data."; 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; @@ -149,10 +157,16 @@ export const OPERATIONS: readonly [{ }]; // @public (undocumented) -export const OUTBOUND_EVENT_TYPES: ("PAGE_FOCUSED" | "SUBMISSION_SENT")[]; +export const OUTBOUND_EVENT_TYPES: ("EDITOR_READY" | "DOCUMENT_LOADED" | "PAGE_FOCUSED" | "SUBMISSION_SENT")[]; // @public (undocumented) export const OUTBOUND_EVENTS: readonly [{ + readonly event_type: "EDITOR_READY"; + readonly description: "Pushed once when the editor iframe boots in loading-placeholder mode (the loadingPlaceholder=true iframe query flag, which @simplepdf/embed sets while it waits to post LOAD_DOCUMENT) and accepts operations; before it, every operation fails with bad_request:editor_not_ready. An iframe opened with a document instead goes straight to DOCUMENT_LOADED. It is not replayed: a listener attached after boot never receives it, so treat bad_request:editor_not_ready as \"retry shortly\" rather than waiting for this event."; +}, { + readonly event_type: "DOCUMENT_LOADED"; + readonly description: "Pushed exactly once per loaded document, when the document and its fields are ready; the payload carries the document_id. Wait for it before operating on the document: until it fires, operations other than LOAD_DOCUMENT fail with bad_request:no_document_loaded or bad_request:editor_not_ready, and GET_FIELDS may report an incomplete field list. On a blank editor it fires once a document is loaded, by LOAD_DOCUMENT or by the user."; +}, { readonly event_type: "PAGE_FOCUSED"; readonly 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."; }, { @@ -170,13 +184,13 @@ export const OVERLAY_TOOL_TYPES: readonly ["TEXT", "SIGNATURE", "PICTURE", "CHEC export type OverlayToolType = (typeof OVERLAY_TOOL_TYPES)[number]; // @public (undocumented) -export const REQUEST_TYPES: ("CREATE_FIELD" | "DELETE_FIELDS" | "DELETE_PAGES" | "DETECT_FIELDS" | "DOWNLOAD" | "FOCUS_FIELD" | "GET_DOCUMENT_CONTENT" | "GET_FIELDS" | "GO_TO" | "LOAD_DOCUMENT" | "MOVE_PAGE" | "ROTATE_PAGE" | "SELECT_TOOL" | "SET_FIELD_VALUE" | "SUBMIT")[]; +export const REQUEST_TYPES: ("CREATE_FIELD" | "DELETE_FIELDS" | "DELETE_PAGES" | "DETECT_FIELDS" | "DOWNLOAD" | "FOCUS_FIELD" | "GET_ANNOTATED_PAGE" | "GET_DOCUMENT_CONTENT" | "GET_FIELDS" | "GO_TO" | "LOAD_DOCUMENT" | "MOVE_PAGE" | "ROTATE_PAGE" | "SELECT_TOOL" | "SET_FIELD_VALUE" | "SUBMIT")[]; // @public (undocumented) export type RequestType = (typeof OPERATIONS)[number]["request_type"]; // @public (undocumented) -export const WIRE_TYPES: ("CREATE_FIELD" | "DELETE_FIELDS" | "DELETE_PAGES" | "DETECT_FIELDS" | "DOWNLOAD" | "FOCUS_FIELD" | "GET_DOCUMENT_CONTENT" | "GET_FIELDS" | "GO_TO" | "LOAD_DOCUMENT" | "MOVE_PAGE" | "ROTATE_PAGE" | "SELECT_TOOL" | "SET_FIELD_VALUE" | "SUBMIT")[]; +export const WIRE_TYPES: ("CREATE_FIELD" | "DELETE_FIELDS" | "DELETE_PAGES" | "DETECT_FIELDS" | "DOWNLOAD" | "FOCUS_FIELD" | "GET_ANNOTATED_PAGE" | "GET_DOCUMENT_CONTENT" | "GET_FIELDS" | "GO_TO" | "LOAD_DOCUMENT" | "MOVE_PAGE" | "ROTATE_PAGE" | "SELECT_TOOL" | "SET_FIELD_VALUE" | "SUBMIT")[]; // @public (undocumented) export type WireType = (typeof OPERATIONS)[number]["wire_type"]; diff --git a/embed/etc/schemas.api.md b/embed/etc/schemas.api.md index 6bb4202e..51663198 100644 --- a/embed/etc/schemas.api.md +++ b/embed/etc/schemas.api.md @@ -63,6 +63,14 @@ export const FocusFieldInput: z.ZodObject<{ // @public (undocumented) export type FocusFieldInput = z.infer; +// @public (undocumented) +export const GetAnnotatedPageInput: z.ZodObject<{ + page: z.ZodNumber; +}, z.core.$strip>; + +// @public (undocumented) +export type GetAnnotatedPageInput = z.infer; + // @public (undocumented) export const GetDocumentContentInput: z.ZodObject<{ extractionMode: z.ZodOptional; }; readonly 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 description: "Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next."; readonly inputSchema: zod.ZodObject<{ fieldId: zod.ZodString; }, zod_v4_core.$strip>; }; + readonly getAnnotatedPage: { + readonly description: "Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant."; + readonly inputSchema: zod.ZodObject<{ + page: zod.ZodNumber; + }, zod_v4_core.$strip>; + }; readonly 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 inputSchema: zod.ZodObject<{ @@ -73,7 +79,7 @@ export const SIMPLEPDF_TOOLS: { }, zod_v4_core.$strip>; }; readonly 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 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. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }."; readonly inputSchema: zod.ZodObject<{}, zod_v4_core.$strip>; }; readonly goTo: { @@ -108,7 +114,7 @@ export const SIMPLEPDF_TOOLS: { }, zod_v4_core.$strip>; }; readonly 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 description: "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Returns no data."; readonly inputSchema: zod.ZodObject<{ fieldId: zod.ZodString; value: zod.ZodNullable; diff --git a/embed/scripts/generate.mjs b/embed/scripts/generate.mjs index 4035cfc2..fd734075 100644 --- a/embed/scripts/generate.mjs +++ b/embed/scripts/generate.mjs @@ -65,15 +65,17 @@ const NAMED_ENUMS = new Map() // --------------------------------------------------------------------------- // The closed vocabulary the emitter understands. A node carrying any other -// keyword (minLength, pattern, format, minimum, additionalProperties, oneOf, -// allOf, $ref, ...) fails loud so a new manifest constraint can never be -// silently dropped from the generated types/schemas. +// keyword (minLength, pattern, format, minimum, oneOf, allOf, $ref, ...) fails +// loud so a new manifest constraint can never be silently dropped from the +// generated types/schemas. `additionalProperties` is honored only as a schema on +// an object with no `properties` (a map: Record / z.record). const KNOWN_SCHEMA_KEYWORDS = new Set([ 'type', 'enum', 'const', 'anyOf', 'properties', + 'additionalProperties', 'required', 'items', 'description', @@ -86,8 +88,21 @@ const assertKnownKeywords = (node) => { ) } } + if (node.additionalProperties !== undefined && !isMapNode(node)) { + throw new Error( + `Unsupported 'additionalProperties' in ${JSON.stringify(node)} — only a schema on an object without 'properties' is honored (a map)`, + ) + } } +// An object whose every key maps to one value schema (`{ additionalProperties: }` +// with no `properties`): emitted as Record / z.record. +const isMapNode = (node) => + node.type === 'object' && + node.properties === undefined && + typeof node.additionalProperties === 'object' && + node.additionalProperties !== null + // Recursively assert every node in a schema tree carries only known keywords, so // a new constraint anywhere in the manifest (op I/O, events, the error schema, or // the protocol envelopes) fails the build instead of being silently ignored. @@ -104,6 +119,9 @@ const preflightSchema = (node) => { if (node.items !== undefined) { preflightSchema(node.items) } + if (isMapNode(node)) { + preflightSchema(node.additionalProperties) + } if (Array.isArray(node.anyOf)) { for (const sub of node.anyOf) { preflightSchema(sub) @@ -156,6 +174,9 @@ const tsForNode = (node, camelKeys) => { } const tsForObject = (node, camelKeys) => { + if (isMapNode(node)) { + return `Record` + } const properties = node.properties ?? {} const required = new Set(node.required ?? []) const keys = Object.keys(properties) @@ -217,6 +238,9 @@ const zodForNode = (node, { withDescription }) => { } const zodForObject = (node) => { + if (isMapNode(node)) { + return `z.record(z.string(), ${zodForNode(node.additionalProperties, { withDescription: false })})` + } const properties = node.properties ?? {} const required = new Set(node.required ?? []) const keys = Object.keys(properties) @@ -355,8 +379,8 @@ contractLines.push('') // dropped (the tool description already carries it); everything else rides through. const toolInputSchema = (node) => { assertKnownKeywords(node) - if (node.type !== 'object') { - throw new Error(`Unsupported tool input schema root (expected an object): ${JSON.stringify(node)}`) + if (node.type !== 'object' || isMapNode(node)) { + throw new Error(`Unsupported tool input schema root (expected an object with properties): ${JSON.stringify(node)}`) } const properties = node.properties ?? {} for (const required of node.required ?? []) { @@ -509,19 +533,18 @@ driftLines.push("import type * as Schemas from './schemas'") driftLines.push("import type * as Contract from './contract'") driftLines.push('') driftLines.push('type Exact = [A] extends [B] ? ([B] extends [A] ? true : false) : false') -driftLines.push('type Extends = [A] extends [B] ? true : false') driftLines.push('type AssertTrue = T') driftLines.push('') driftLines.push('// IframeActions method set must exactly equal the generated operation methods,') driftLines.push('// each zod schema must stay mutually assignable to its plain contract type, and') -driftLines.push('// every generated outbound event must appear in the hand-maintained EditorEvent union') -driftLines.push("// (so React's onEmbedEvent forwarders, guarded against EditorEvent, can't miss one).") +driftLines.push('// the hand-maintained EditorEvent union must exactly match the generated outbound events') +driftLines.push("// (so React's onEmbedEvent forwarders, guarded against EditorEvent, can't miss or invent one).") driftLines.push('export type DriftGuards = [') driftLines.push(" AssertTrue>,") driftLines.push( ' AssertTrue["method"]>>,', ) -driftLines.push(" AssertTrue>,") +driftLines.push(" AssertTrue>,") for (const op of contract.operations) { const stem = toPascal(op.request_type) driftLines.push(` AssertTrue>,`) diff --git a/embed/scripts/lazy-chunks.mjs b/embed/scripts/lazy-chunks.mjs index 24e8d809..1a1140b3 100644 --- a/embed/scripts/lazy-chunks.mjs +++ b/embed/scripts/lazy-chunks.mjs @@ -2,5 +2,5 @@ // 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' }, + 'webmcp-': { budgetBytes: 6 * 1024, exportName: 'registerWebMCPTools' }, } diff --git a/embed/src/bridge.ts b/embed/src/bridge.ts index 4adc8510..858e3f62 100644 --- a/embed/src/bridge.ts +++ b/embed/src/bridge.ts @@ -1,5 +1,4 @@ import { fromWireData, toWireData } from './case-transform' -import { INTERNAL_PROTOCOL } from './internal-protocol' import { type BridgeLogger, makeSafeLogger, NOOP_LOGGER } from './logger' import { isBridgeResultLike } from './result' import type { OutboundEventType, WireType } from './generated/contract' @@ -49,8 +48,12 @@ const EDITOR_READY_HARD_FALLBACK_MS = 30_000 // remain members of the generated vocabulary, or `tsc` fails (an editor rename // would otherwise silently stop the bridge emitting that event). Type-only, so // no generated value (the OPERATIONS table) is pulled into the zero-dep root. +const EDITOR_READY_EVENT: Extract = 'EDITOR_READY' +const DOCUMENT_LOADED_EVENT: Extract = 'DOCUMENT_LOADED' const SUBMISSION_SENT_EVENT: Extract = 'SUBMISSION_SENT' const PAGE_FOCUSED_EVENT: Extract = 'PAGE_FOCUSED' +// The reply envelope (manifest `protocol`), not an event: never part of OutboundEventType. +const REQUEST_RESULT_TYPE = 'REQUEST_RESULT' const generateRequestId = (): string => { if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { @@ -387,13 +390,13 @@ export const attachEmbed = ({ // Log the message type + correlation id only — never the body (PII). logger.debug('iframe.message_received', { type: payload.type, request_id: payload.request_id }) - if (payload.type === INTERNAL_PROTOCOL.EDITOR_READY) { + if (payload.type === EDITOR_READY_EVENT) { markEditorReady('editor_ready_event') channels.EDITOR_READY.emit({}) return } - if (payload.type === INTERNAL_PROTOCOL.DOCUMENT_LOADED) { + if (payload.type === DOCUMENT_LOADED_EVENT) { const rawDocId = payload.data?.document_id if (typeof rawDocId === 'string' && rawDocId !== '') { // Forward every real DOCUMENT_LOADED verbatim (snake wire data). @@ -428,7 +431,7 @@ export const attachEmbed = ({ return } - if (payload.type !== INTERNAL_PROTOCOL.REQUEST_RESULT) { + if (payload.type !== REQUEST_RESULT_TYPE) { return } @@ -501,6 +504,7 @@ export const attachEmbed = ({ detectFields: () => sendRequest('DETECT_FIELDS', {}), download: () => sendRequest('DOWNLOAD', {}), focusField: (input) => sendRequest('FOCUS_FIELD', input), + getAnnotatedPage: (input) => sendRequest('GET_ANNOTATED_PAGE', input), getDocumentContent: (input) => sendRequest('GET_DOCUMENT_CONTENT', input ?? {}), getFields: () => sendRequest('GET_FIELDS', {}), goTo: (input) => sendRequest('GO_TO', input), diff --git a/embed/src/generated/agentic-tool-names.ts b/embed/src/generated/agentic-tool-names.ts index 904938e9..564d722d 100644 --- a/embed/src/generated/agentic-tool-names.ts +++ b/embed/src/generated/agentic-tool-names.ts @@ -2,4 +2,4 @@ // 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 +export const AGENTIC_TOOL_NAMES = ["createField", "deleteFields", "deletePages", "detectFields", "download", "focusField", "getAnnotatedPage", "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 bc40e257..8edb790a 100644 --- a/embed/src/generated/contract.ts +++ b/embed/src/generated/contract.ts @@ -29,6 +29,8 @@ export type DownloadInput = Record export type DownloadOutput = null export type FocusFieldInput = { fieldId: string } export type FocusFieldOutput = { hint: { type: "user_action_expected"; message: string } } +export type GetAnnotatedPageInput = { page: number } +export type GetAnnotatedPageOutput = { page: number; imageDataUrl: string; imageWidth: number; imageHeight: number; badges: Record } export type GetDocumentContentInput = { extractionMode?: ExtractionMode } export type GetDocumentContentOutput = { name: string; pages: Array<{ page: number; content: string }> } export type GetFieldsInput = Record @@ -53,6 +55,8 @@ export type DocumentContentPage = GetDocumentContentOutput['pages'][number] export type MissingRequiredFieldsDetails = { unfilledRequiredFieldsCount: number } +export type EditorReadyPayload = Record +export type DocumentLoadedPayload = { document_id: string } export type PageFocusedPayload = { previous_page: number | null; current_page: number; total_pages: number } export type SubmissionSentPayload = { document_id: string; submission_id: string } @@ -106,11 +110,20 @@ export const OPERATIONS = [ request_type: "FOCUS_FIELD", 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.", + description: "Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next.", 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, } /* FocusField */, + { + request_type: "GET_ANNOTATED_PAGE", + wire_type: "GET_ANNOTATED_PAGE", + method: "getAnnotatedPage", + description: "Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant.", + error_codes: ["bad_request:invalid_page", "bad_request:page_out_of_range"] as const, + is_agentic_tool: true, + has_output: true, + } /* GetAnnotatedPage */, { request_type: "GET_DOCUMENT_CONTENT", wire_type: "GET_DOCUMENT_CONTENT", @@ -124,7 +137,7 @@ export const OPERATIONS = [ request_type: "GET_FIELDS", 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 }.", + 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. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.", error_codes: ["bad_request:no_document_loaded"] as const, is_agentic_tool: true, has_output: true, @@ -142,7 +155,7 @@ export const OPERATIONS = [ request_type: "LOAD_DOCUMENT", 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.", + description: "Replace the document in the editor with one supplied as a base64 data URL or an http(s) URL the editor fetches. Destructive: the current document and every edit in it are discarded. Returns no data.", error_codes: ["bad_request:invalid_value", "bad_request:invalid_page"] as const, is_agentic_tool: false, has_output: false, @@ -178,7 +191,7 @@ export const OPERATIONS = [ request_type: "SET_FIELD_VALUE", 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.", + description: "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Returns no data.", 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, @@ -200,6 +213,8 @@ export type MethodName = (typeof OPERATIONS)[number]["method"] export type AgenticToolName = (typeof AGENTIC_TOOL_NAMES)[number] export const OUTBOUND_EVENTS = [ + { event_type: "EDITOR_READY", description: "Pushed once when the editor iframe boots in loading-placeholder mode (the loadingPlaceholder=true iframe query flag, which @simplepdf/embed sets while it waits to post LOAD_DOCUMENT) and accepts operations; before it, every operation fails with bad_request:editor_not_ready. An iframe opened with a document instead goes straight to DOCUMENT_LOADED. It is not replayed: a listener attached after boot never receives it, so treat bad_request:editor_not_ready as \"retry shortly\" rather than waiting for this event." }, + { event_type: "DOCUMENT_LOADED", description: "Pushed exactly once per loaded document, when the document and its fields are ready; the payload carries the document_id. Wait for it before operating on the document: until it fires, operations other than LOAD_DOCUMENT fail with bad_request:no_document_loaded or bad_request:editor_not_ready, and GET_FIELDS may report an incomplete field list. On a blank editor it fires once a document is loaded, by LOAD_DOCUMENT or by the user." }, { 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." }, { event_type: "SUBMISSION_SENT", description: "Pushed after a SUBMIT completes successfully. This is how you confirm a submission landed: the SUBMIT operation itself resolves with data: null, so listen for this event to get the resulting document_id and submission_id." }, ] as const diff --git a/embed/src/generated/drift.ts b/embed/src/generated/drift.ts index e69f6004..1ab75ae2 100644 --- a/embed/src/generated/drift.ts +++ b/embed/src/generated/drift.ts @@ -4,23 +4,23 @@ import type * as Schemas from './schemas' import type * as Contract from './contract' type Exact = [A] extends [B] ? ([B] extends [A] ? true : false) : false -type Extends = [A] extends [B] ? true : false type AssertTrue = T // IframeActions method set must exactly equal the generated operation methods, // each zod schema must stay mutually assignable to its plain contract type, and -// every generated outbound event must appear in the hand-maintained EditorEvent union -// (so React's onEmbedEvent forwarders, guarded against EditorEvent, can't miss one). +// the hand-maintained EditorEvent union must exactly match the generated outbound events +// (so React's onEmbedEvent forwarders, guarded against EditorEvent, can't miss or invent one). export type DriftGuards = [ AssertTrue>, AssertTrue["method"]>>, - AssertTrue>, + AssertTrue>, AssertTrue>, AssertTrue>, AssertTrue>, AssertTrue>, AssertTrue>, AssertTrue>, + AssertTrue>, AssertTrue>, AssertTrue>, AssertTrue>, diff --git a/embed/src/generated/schemas.ts b/embed/src/generated/schemas.ts index c7abdb64..cb8795ab 100644 --- a/embed/src/generated/schemas.ts +++ b/embed/src/generated/schemas.ts @@ -8,7 +8,7 @@ export const CreateFieldInput = z.object({ width: z.number().describe("Field width, in PDF points."), height: z.number().describe("Field height, in PDF points."), page: z.number().int().describe("1-based page to place the field on."), - value: z.string().describe("Optional initial value. A string for text/checkbox fields, or a data URL for signature/picture fields.").optional(), + value: z.string().describe("Optional initial value. A string for text/checkbox fields, or a data URL or http(s) URL (fetched by the editor) for signature/picture fields.").optional(), }).describe("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.") export type CreateFieldInput = z.infer export const DeleteFieldsInput = z.object({ @@ -26,23 +26,27 @@ export const DownloadInput = z.object({}).describe("Generate and download the cu export type DownloadInput = z.infer export const FocusFieldInput = z.object({ fieldId: z.string().describe("ID of the field to focus and scroll into view."), -}).describe("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.") +}).describe("Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next.") export type FocusFieldInput = z.infer +export const GetAnnotatedPageInput = z.object({ + page: z.number().int().describe("1-based page to render, at its current position."), +}).describe("Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant.") +export type GetAnnotatedPageInput = z.infer export const GetDocumentContentInput = z.object({ extractionMode: z.enum(["auto", "ocr"]).describe("Extraction strategy: 'auto' (default) or 'ocr' to force optical recognition.").optional(), }).describe("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 }] }.") export type GetDocumentContentInput = z.infer -export const GetFieldsInput = z.object({}).describe("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 }.") +export const GetFieldsInput = z.object({}).describe("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. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.") export type GetFieldsInput = z.infer export const GoToInput = z.object({ page: z.number().int().describe("1-based page to navigate to."), }).describe("Scroll the editor to a specific 1-based page. Returns no data.") export type GoToInput = z.infer export const LoadDocumentInput = z.object({ - dataUrl: z.string().describe("The document to load, as a data URL."), + dataUrl: z.string().describe("The document to load: a data URL, or an http(s) URL the editor fetches."), name: z.string().describe("Optional display name for the document.").optional(), page: z.number().int().describe("Optional 1-based page to open the document on.").optional(), -}).describe("Load a document into the editor from a base64 data URL. This is a host/setup action (no agentic tool); it returns no data.") +}).describe("Replace the document in the editor with one supplied as a base64 data URL or an http(s) URL the editor fetches. Destructive: the current document and every edit in it are discarded. Returns no data.") export type LoadDocumentInput = z.infer export const MovePageInput = z.object({ fromPage: z.number().int().describe("1-based current position of the page to move."), @@ -59,8 +63,8 @@ export const SelectToolInput = z.object({ export type SelectToolInput = z.infer export const SetFieldValueInput = z.object({ fieldId: z.string().describe("ID of the field to update."), - value: z.string().nullable().describe("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)."), -}).describe("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.") + value: z.string().nullable().describe("New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."), +}).describe("Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Returns no data.") export type SetFieldValueInput = z.infer export const SubmitInput = z.object({ downloadCopy: z.boolean().describe("When true, the signer also receives a downloaded copy on submit."), diff --git a/embed/src/generated/tool-input-schemas.ts b/embed/src/generated/tool-input-schemas.ts index 59345321..18f84d70 100644 --- a/embed/src/generated/tool-input-schemas.ts +++ b/embed/src/generated/tool-input-schemas.ts @@ -11,18 +11,19 @@ export type ToolInputSchema = { } 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"]}, + 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 or http(s) URL (fetched by the editor) 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"]}, + getAnnotatedPage: {"type":"object","properties":{"page":{"type":"integer","description":"1-based page to render, at its current position."}},"required":["page"]}, 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"]}, + 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 the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (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/generated/tools.ts b/embed/src/generated/tools.ts index 239802c7..2ad238f3 100644 --- a/embed/src/generated/tools.ts +++ b/embed/src/generated/tools.ts @@ -9,14 +9,15 @@ export const TOOL_DEFINITIONS = { 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.", inputSchema: Schemas.DeletePagesInput }, detectFields: { description: "Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled.", inputSchema: Schemas.DetectFieldsInput }, download: { description: "Generate and download the current document as a PDF. Returns no data.", inputSchema: Schemas.DownloadInput }, - 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.", inputSchema: Schemas.FocusFieldInput }, + focusField: { description: "Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next.", inputSchema: Schemas.FocusFieldInput }, + getAnnotatedPage: { description: "Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant.", inputSchema: Schemas.GetAnnotatedPageInput }, 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 }] }.", inputSchema: Schemas.GetDocumentContentInput }, - 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 }.", inputSchema: Schemas.GetFieldsInput }, + 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. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.", inputSchema: Schemas.GetFieldsInput }, goTo: { description: "Scroll the editor to a specific 1-based page. Returns no data.", inputSchema: Schemas.GoToInput }, movePage: { description: "Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled.", inputSchema: Schemas.MovePageInput }, rotatePage: { description: "Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled.", inputSchema: Schemas.RotatePageInput }, 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.", inputSchema: Schemas.SelectToolInput }, - 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.", inputSchema: Schemas.SetFieldValueInput }, + setFieldValue: { description: "Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Returns no data.", inputSchema: Schemas.SetFieldValueInput }, 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.", inputSchema: Schemas.SubmitInput }, } as const diff --git a/embed/src/internal-protocol.ts b/embed/src/internal-protocol.ts deleted file mode 100644 index e6785460..00000000 --- a/embed/src/internal-protocol.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Internal protocol message types (editor-owned, hand-authored). These drive the -// bridge lifecycle and request correlation; they are intentionally excluded from -// the public operation/event vocabulary and from embed-api.json. Kept in their -// own zero-dependency module so the bridge (and therefore the root entry) does -// not pull the generated OPERATIONS table that /protocol re-exports. - -export const INTERNAL_PROTOCOL = { - EDITOR_READY: 'EDITOR_READY', - DOCUMENT_LOADED: 'DOCUMENT_LOADED', - REQUEST_RESULT: 'REQUEST_RESULT', -} as const - -export type InternalProtocolType = (typeof INTERNAL_PROTOCOL)[keyof typeof INTERNAL_PROTOCOL] diff --git a/embed/src/protocol.ts b/embed/src/protocol.ts index 83be1429..214de241 100644 --- a/embed/src/protocol.ts +++ b/embed/src/protocol.ts @@ -1,7 +1,6 @@ -// Wire protocol constants. The PUBLIC operation + outbound-event vocabulary is -// generated from embed-api.json (the editor iframe lib is the source); the -// INTERNAL protocol frames the editor uses to drive the bridge are hand-authored -// here and are never part of the public operation/event surface. Zero runtime +// Wire protocol constants: the operation + outbound-event vocabulary, generated +// from embed-api.json (the editor iframe lib is the source). The REQUEST_RESULT +// reply envelope is not an event and lives with the bridge. Zero runtime // dependencies. import { OPERATIONS, OUTBOUND_EVENTS } from './generated/contract' diff --git a/embed/src/tools.ts b/embed/src/tools.ts index c512e4bc..9904a511 100644 --- a/embed/src/tools.ts +++ b/embed/src/tools.ts @@ -13,6 +13,7 @@ import { DetectFieldsInput, DownloadInput, FocusFieldInput, + GetAnnotatedPageInput, GetDocumentContentInput, GetFieldsInput, GoToInput, @@ -74,6 +75,8 @@ export const routeToolCall = ( return dispatch(DownloadInput, input ?? {}, () => actions.download()) case 'focusField': return dispatch(FocusFieldInput, input, (value) => actions.focusField(value)) + case 'getAnnotatedPage': + return dispatch(GetAnnotatedPageInput, input, (value) => actions.getAnnotatedPage(value)) case 'getDocumentContent': return dispatch(GetDocumentContentInput, input ?? {}, (value) => actions.getDocumentContent(value)) case 'getFields': diff --git a/embed/src/types.ts b/embed/src/types.ts index 30451371..31b23718 100644 --- a/embed/src/types.ts +++ b/embed/src/types.ts @@ -10,9 +10,13 @@ import type { DeleteFieldsOutput, DeletePagesInput, DetectFieldsOutput, + DocumentLoadedPayload, EditorErrorCode, + EditorReadyPayload, FocusFieldInput, FocusFieldOutput, + GetAnnotatedPageInput, + GetAnnotatedPageOutput, GetDocumentContentInput, GetDocumentContentOutput, GetFieldsOutput, @@ -38,11 +42,15 @@ export type { DetectFieldsOutput, DocumentContentPage, DocumentContentResult, + DocumentLoadedPayload, EditorErrorCode, + EditorReadyPayload, ExtractionMode, FieldType, FocusFieldInput, FocusFieldOutput, + GetAnnotatedPageInput, + GetAnnotatedPageOutput, GetDocumentContentInput, GetDocumentContentOutput, GetFieldsOutput, @@ -110,11 +118,10 @@ export type BridgeState = // The editor's outbound events, forwarded to onEmbedEvent VERBATIM: SCREAMING_SNAKE // `type` + snake_case `data` (the stable, established contract — deliberately NOT -// camelCased, unlike op payloads). EDITOR_READY / DOCUMENT_LOADED are the lifecycle -// wire events; PAGE_FOCUSED / SUBMISSION_SENT take their payloads from the manifest. +// camelCased, unlike op payloads). Every payload comes from the manifest `events`. export type EditorEvent = - | { type: 'EDITOR_READY'; data: Record } - | { type: 'DOCUMENT_LOADED'; data: { document_id: string } } + | { type: 'EDITOR_READY'; data: EditorReadyPayload } + | { type: 'DOCUMENT_LOADED'; data: DocumentLoadedPayload } | { type: 'PAGE_FOCUSED'; data: PageFocusedPayload } | { type: 'SUBMISSION_SENT'; data: SubmissionSentPayload } @@ -135,6 +142,7 @@ export type IframeActions = { detectFields: () => Promise> download: () => Promise focusField: (input: FocusFieldInput) => Promise> + getAnnotatedPage: (input: GetAnnotatedPageInput) => Promise> getDocumentContent: (input?: GetDocumentContentInput) => Promise> getFields: () => Promise> goTo: (input: GoToInput) => Promise diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts index d1d6fc77..a1c9958a 100644 --- a/embed/src/webmcp.ts +++ b/embed/src/webmcp.ts @@ -52,6 +52,7 @@ const TOOL_ANNOTATIONS = { detectFields: { destructiveHint: false }, download: { destructiveHint: false }, focusField: { destructiveHint: false }, + getAnnotatedPage: { readOnlyHint: true, untrustedContentHint: true }, getDocumentContent: { readOnlyHint: true, untrustedContentHint: true }, getFields: { readOnlyHint: true, untrustedContentHint: true }, goTo: { destructiveHint: false }, diff --git a/embed/test/tanstack-ai.test.ts b/embed/test/tanstack-ai.test.ts index 6469fd8e..1b93ada9 100644 --- a/embed/test/tanstack-ai.test.ts +++ b/embed/test/tanstack-ai.test.ts @@ -4,9 +4,9 @@ import type { BridgeResult } from '../src/types' import { makeEmbedStub } from './helpers' describe('simplePDFToolDefinitions', () => { - it('returns the 14 agentic operations as execute-less definitions (loadDocument excluded)', () => { + it('returns the 15 agentic operations as execute-less definitions (loadDocument excluded)', () => { const definitions = simplePDFToolDefinitions() - expect(definitions).toHaveLength(14) + expect(definitions).toHaveLength(15) expect(definitions.map((definition) => definition.name)).not.toContain('loadDocument') for (const definition of definitions) { expect(typeof definition.description).toBe('string') @@ -16,9 +16,9 @@ describe('simplePDFToolDefinitions', () => { }) describe('createSimplePDFTools', () => { - it('produces a client tool for each of the 14 agentic operations', () => { + it('produces a client tool for each of the 15 agentic operations', () => { const tools = createSimplePDFTools({ embed: makeEmbedStub() }) - expect(tools).toHaveLength(14) + expect(tools).toHaveLength(15) expect(tools.every((tool) => typeof tool.execute === 'function')).toBe(true) }) diff --git a/embed/test/tools.test.ts b/embed/test/tools.test.ts index cf059634..17cd38bb 100644 --- a/embed/test/tools.test.ts +++ b/embed/test/tools.test.ts @@ -17,9 +17,9 @@ describe(isSimplePDFToolName.name, () => { }) describe('SIMPLEPDF_TOOLS', () => { - it('exposes the 14 agentic operations with descriptions + input schemas (loadDocument excluded)', () => { + it('exposes the 15 agentic operations with descriptions + input schemas (loadDocument excluded)', () => { const names = Object.keys(SIMPLEPDF_TOOLS) - expect(names).toHaveLength(14) + expect(names).toHaveLength(15) expect(names).not.toContain('loadDocument') for (const definition of Object.values(SIMPLEPDF_TOOLS)) { expect(typeof definition.description).toBe('string') diff --git a/react/README.md b/react/README.md index ae0aed9e..3add2c90 100644 --- a/react/README.md +++ b/react/README.md @@ -157,7 +157,7 @@ Actions are camelCase (the editor's snake_case wire is transformed for you). `us | `actions.setFieldValue({ fieldId, value })` | Set a field's value | | `actions.submit({ downloadCopy })` | Submit the document | -…plus `createField`, `getFields`, `focusField`, `movePage`, `rotatePage`, `deletePages`, `download`, and `loadDocument`. All actions return a `Promise` with a result object: `{ success: true, data: ... }` or `{ success: false, error: { code, message } }`. +…plus `createField`, `getFields`, `getAnnotatedPage`, `focusField`, `movePage`, `rotatePage`, `deletePages`, `download`, and `loadDocument`. All actions return a `Promise` with a result object: `{ success: true, data: ... }` or `{ success: false, error: { code, message } }`. ```jsx import { EmbedPDF, useEmbed } from '@simplepdf/react-embed-pdf'; diff --git a/react/src/embed-pdf.tsx b/react/src/embed-pdf.tsx index 5eca718f..756017b5 100644 --- a/react/src/embed-pdf.tsx +++ b/react/src/embed-pdf.tsx @@ -423,6 +423,7 @@ export const useEmbed = (): { detectFields: () => embedRef.current?.detectFields() ?? notMounted(), download: () => embedRef.current?.download() ?? notMounted(), focusField: (input) => embedRef.current?.focusField(input) ?? notMounted(), + getAnnotatedPage: (input) => embedRef.current?.getAnnotatedPage(input) ?? notMounted(), getDocumentContent: (input) => embedRef.current?.getDocumentContent(input) ?? notMounted(), getFields: () => embedRef.current?.getFields() ?? notMounted(), goTo: (input) => embedRef.current?.goTo(input) ?? notMounted(), diff --git a/skills/build-with-simplepdf/SKILL.md b/skills/build-with-simplepdf/SKILL.md index dded8d1f..e18d4c35 100644 --- a/skills/build-with-simplepdf/SKILL.md +++ b/skills/build-with-simplepdf/SKILL.md @@ -135,9 +135,9 @@ export function ControlledEditor() { } ``` -Typical operations: `getFields()`, `setFieldValue({ fieldId, value })`, `getDocumentContent({ extractionMode })`, `goTo({ page })`, `focusField({ fieldId })`, `selectTool({ tool })`, `detectFields()`, `deleteFields({ fieldIds?, page? })`, `submit({ downloadCopy })`. Verify the exact current method names and input shapes from the installed package/docs before coding. +Typical operations: `getFields()`, `setFieldValue({ fieldId, value })`, `getDocumentContent({ extractionMode })`, `getAnnotatedPage({ page })`, `goTo({ page })`, `focusField({ fieldId })`, `selectTool({ tool })`, `detectFields()`, `deleteFields({ fieldIds?, page? })`, `submit({ downloadCopy })`. Verify the exact current method names and input shapes from the installed package/docs before coding. -Editor events arrive via the `onEmbedEvent` prop (React) or `embed.events` (core) — the outbound events are `PAGE_FOCUSED` and `SUBMISSION_SENT` (`submit()` itself resolves with `data: null`; the event carries the resulting ids). Actions fail with `bad_request:editor_not_ready` until the editor is ready — handle or retry rather than racing mount. +Editor events arrive via the `onEmbedEvent` prop (React) or `embed.events` (core) — the outbound events are `EDITOR_READY`, `DOCUMENT_LOADED`, `PAGE_FOCUSED` and `SUBMISSION_SENT` (`submit()` itself resolves with `data: null`; the event carries the resulting ids). Wait for `DOCUMENT_LOADED` before operating on the document; until then actions other than `loadDocument()` fail with `bad_request:editor_not_ready` or `bad_request:no_document_loaded` (and `getFields()` may report an incomplete list) — handle or retry rather than racing mount. When relevant (`AskUserQuestion`, header `Editor UI`): From f138a8de3dfa60c10f8c216573cdd90963393b9f Mon Sep 17 00:00:00 2001 From: ben Date: Sat, 12 Sep 2026 16:16:07 +0200 Subject: [PATCH 10/15] feat: webMCP registers the manifest tool records verbatim, loadDocument included The host-page WebMCP tools are the editor's own records from /embed/json (operations[].tool): the simplepdf_embed_* name, description, snake_case input schema and behavior hints, generated into webmcp-tools.ts and registered as-is, so a page gets one tool set whether the editor is embedded or opened directly. The hand-kept TOOL_ANNOTATIONS table and the camelCase tool-input-schemas.ts are gone. All 16 operations register (loadDocument included, like the editor); the /tools, /ai-sdk and /tanstack-ai subpaths are unchanged. The option is webMCP: { enabled: false } | { enabled: true; exclude?: MethodName[] } on createEmbed and ; { enabled: false } and omitted are one state; exclude takes SDK method names, validated at construction against the generated method-names.ts list. Tool calls resolve with the editor's wire-shaped Result (what the record's description promises); the annotated page render travels once, as an MCP image content block, with the badges map in the text block. Lazy chunk budget follows the verbatim records. --- .changeset/enable-webmcp.md | 4 +- embed/README.md | 12 +- embed/etc/index.api.md | 21 +-- embed/scripts/generate.mjs | 186 ++++++++++------------ embed/scripts/lazy-chunks.mjs | 2 +- embed/src/bridge.ts | 50 ++++-- embed/src/generated/agentic-tool-names.ts | 5 - embed/src/generated/contract.ts | 5 +- embed/src/generated/drift.ts | 2 +- embed/src/generated/method-names.ts | 5 + embed/src/generated/tool-input-schemas.ts | 29 ---- embed/src/generated/webmcp-tools.ts | 42 +++++ embed/src/mount.ts | 68 ++++---- embed/src/types.ts | 2 +- embed/src/webmcp-shared.ts | 26 +-- embed/src/webmcp.ts | 116 +++++++------- embed/test/mount.test.ts | 20 ++- embed/test/webmcp.test.ts | 186 +++++++++++++++------- react/README.md | 6 +- react/etc/index.api.md | 6 +- react/src/embed-pdf.test.tsx | 18 ++- react/src/embed-pdf.tsx | 39 ++--- react/src/index.tsx | 2 +- 23 files changed, 469 insertions(+), 383 deletions(-) delete mode 100644 embed/src/generated/agentic-tool-names.ts create mode 100644 embed/src/generated/method-names.ts delete mode 100644 embed/src/generated/tool-input-schemas.ts create mode 100644 embed/src/generated/webmcp-tools.ts diff --git a/.changeset/enable-webmcp.md b/.changeset/enable-webmcp.md index 70c185da..9477f679 100644 --- a/.changeset/enable-webmcp.md +++ b/.changeset/enable-webmcp.md @@ -3,6 +3,6 @@ "@simplepdf/react-embed-pdf": minor --- -Add `enableWebMCP`: register the editor operations as WebMCP tools on the host page. +Add `webMCP`: 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: 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). +An in-browser agent (ChatGPT's browser, Chrome with WebMCP) discovers tools on the page it is looking at, not inside iframes. `createEmbed({ webMCP: { enabled: true } })` and `` register every operation on the page's `document.modelContext` 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, a page render) goes to the agent runtime the person attached. Each tool is the record the editor publishes in its manifest and registers on its own page (the `simplepdf_embed_*` name, description, snake_case input schema and behavior hints), so a page gets the same tools whether the editor is embedded or opened directly. `exclude: ['submit', ...]` withholds operations by SDK method name so a person keeps the decision (a malformed value throws `EmbedConfigError`). Each call resolves with an MCP tool result carrying the editor's wire-shaped Result (`isError` on failure; the annotated page render as an `image` block); the editor validates each call like any other request; `dispose()` unregisters everything. Off by default (`{ enabled: false }` and omitting the option are the same): 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 4183298b..770b019a 100644 --- a/embed/README.md +++ b/embed/README.md @@ -73,20 +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 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. +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. `webMCP: { enabled: true }` 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 the readers (`simplepdf_embed_get_fields`, `simplepdf_embed_get_document_content`, `simplepdf_embed_get_annotated_page`: field values, extracted text, a page render) goes to the agent runtime the person attached, so treat that runtime as you would any other party that sees the filled document. ```ts // 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'] } }) + webMCP: { enabled: true, 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 }) +// every operation +createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, webMCP: { enabled: 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 readers (`getFields`, `getDocumentContent`, `getAnnotatedPage`) 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 ``. +Off by default (`{ enabled: false }` and omitting the option are the same state). Each tool is the record the editor publishes in its manifest (`https://simplepdf.com/embed/json`, `operations[].tool`) and registers on its own page: the `simplepdf_embed_*` name, description, snake_case input schema and behavior hints (the readers carry the specification's `readOnlyHint` and `untrustedContentHint`; every other tool MCP's `destructiveHint`; the three that fetch an agent-supplied URL `openWorldHint`), so a page gets the same tools whether the editor is embedded or opened directly. `exclude` takes SDK method names. 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 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 wire-shaped `{ success, data | error }` Result (`isError` on failure); `simplepdf_embed_get_annotated_page` carries its PNG as an `image` content block, with the badges map in the text block. `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 `webMCP` to ``. ## Subpaths @@ -129,7 +129,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 | +| `webMCP` | `{ enabled: false } \| { enabled: true; exclude?: MethodName[] }` | 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 f57ed4b7..0e83df63 100644 --- a/embed/etc/index.api.md +++ b/embed/etc/index.api.md @@ -4,11 +4,6 @@ ```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 = (typeof AGENTIC_TOOL_NAMES)[number]; - // @public (undocumented) export type BridgeError = { code: 'bad_request:missing_required_fields'; @@ -78,7 +73,7 @@ export type CreateEmbedArgs = { style?: Partial; }; logger?: BridgeLogger; - enableWebMCP?: WebMCPOptions; + webMCP?: WebMCPOptions; }; // @public (undocumented) @@ -302,6 +297,11 @@ export type Locale = (typeof LOCALES)[number]; // @public (undocumented) export type LogPayload = Record; +// Warning: (ae-forgotten-export) The symbol "METHOD_NAMES" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type MethodName = (typeof METHOD_NAMES)[number]; + // @public (undocumented) export type MissingRequiredFieldsDetails = { unfilledRequiredFieldsCount: number; @@ -323,7 +323,7 @@ export const normalizeWebMCPOptions: (options: WebMCPOptions | undefined) => { enabled: false; } | { enabled: true; - exclude: readonly AgenticToolName[]; + exclude: readonly MethodName[]; }; // Warning: (ae-forgotten-export) The symbol "OVERLAY_TOOL_TYPES" needs to be exported by the entry point index.d.ts @@ -369,8 +369,11 @@ export type SubmitInput = { export const unwrap: (result: BridgeResult) => TData; // @public (undocumented) -export type WebMCPOptions = boolean | { - exclude: readonly AgenticToolName[]; +export type WebMCPOptions = { + enabled: false; +} | { + enabled: true; + exclude?: readonly MethodName[]; }; // (No @packageDocumentation comment for this package) diff --git a/embed/scripts/generate.mjs b/embed/scripts/generate.mjs index fd734075..6dff0e41 100644 --- a/embed/scripts/generate.mjs +++ b/embed/scripts/generate.mjs @@ -10,12 +10,12 @@ // - 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. +// - src/generated/method-names.ts : the SDK method names alone, the one generated +// VALUE the zero-dep root imports (to validate +// `webMCP.exclude`). +// - src/generated/webmcp-tools.ts : each operation's WebMCP tool record (manifest +// `tool`), verbatim, 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 @@ -329,7 +329,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("import type { METHOD_NAMES } from './method-names'") contractLines.push('') contractLines.push(constArray('LOCALES', contract.locales, 'Locale')) contractLines.push(constArray('EDITOR_ERROR_CODES', editorErrorCodes, 'EditorErrorCode')) @@ -373,54 +373,6 @@ for (const event of contract.events) { } contractLines.push('') -// The operation's input schema as a WebMCP tool `inputSchema`: the manifest node with -// camelCase property keys at every level (the same SDK-side shape the zod schemas and -// IframeActions use; the bridge lowers the keys to the wire). The root description is -// dropped (the tool description already carries it); everything else rides through. -const toolInputSchema = (node) => { - assertKnownKeywords(node) - if (node.type !== 'object' || isMapNode(node)) { - throw new Error(`Unsupported tool input schema root (expected an object with properties): ${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)]), - ) - 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.const !== undefined || Array.isArray(node.enum)) { - return node - } - 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)}`) - } -} - // 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) @@ -440,15 +392,13 @@ contractLines.push(`export const OPERATIONS = [\n${opMeta.join(',\n')},\n] as co contractLines.push('') contractLines.push('export type WireType = (typeof OPERATIONS)[number]["wire_type"]') contractLines.push('export type RequestType = (typeof OPERATIONS)[number]["request_type"]') -// The JS method/tool name is the camelCase of the wire op (the SDK is camelCase; -// the bridge transforms to the snake_case wire). The drift guard checks IframeActions -// matches MethodName. -contractLines.push('export type MethodName = (typeof OPERATIONS)[number]["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]") +// The JS method name is the camelCase of the wire op (the SDK is camelCase; the +// bridge transforms to the snake_case wire). The names live in their own tiny module +// (createEmbed validates an untyped caller's `webMCP.exclude` against the runtime +// list, and must not pull this whole table into the zero-dep root); the type derives +// from it here, and drift.ts pins it to the OPERATIONS methods (and IframeActions to +// it) so the views of one fact cannot diverge. +contractLines.push('export type MethodName = (typeof METHOD_NAMES)[number]') contractLines.push('') const eventMeta = contract.events.map( @@ -479,47 +429,87 @@ schemaLines.push('') writeFileSync(join(GENERATED_DIR, 'schemas.ts'), renderFile(schemaLines)) -// --- agentic-tool-names.ts (zero runtime deps; the one generated value the root imports) --- +// --- method-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)) +const methodNames = contract.operations.map((op) => toCamel(op.request_type)) writeFileSync( - join(GENERATED_DIR, 'agentic-tool-names.ts'), + join(GENERATED_DIR, 'method-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`, + '// The SDK method names alone, so createEmbed can validate a `webMCP.exclude` list', + '// without pulling the operations table into the zero-dep root; contract.ts derives', + '// MethodName from this list.', + `export const METHOD_NAMES = [${methodNames.map((name) => JSON.stringify(name)).join(', ')}] as const`, ]), ) -// --- 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 +// --- webmcp-tools.ts (zero runtime deps, loaded only by the WebMCP module) --- +// The manifest's `tool` record per operation, verbatim: the same record the editor +// registers on its own page. The generator only checks the record's shape (a new key +// or hint fails loud) and renames `input_schema` to WebMCP's `inputSchema`. + +const WEBMCP_ANNOTATION_KEYS = new Set(['destructiveHint', 'openWorldHint', 'readOnlyHint', 'untrustedContentHint']) + +const webmcpToolRecord = (op) => { + const tool = op.tool + if (typeof tool !== 'object' || tool === null) { + throw new Error(`Operation ${op.request_type} publishes no tool record`) + } + const { name, description, input_schema: inputSchema, annotations, ...unknownKeys } = tool + if (Object.keys(unknownKeys).length > 0) { + throw new Error( + `Unsupported tool record keys on ${op.request_type}: ${Object.keys(unknownKeys).join(', ')} — extend the generator to honor them`, + ) + } + const isWellFormed = + typeof name === 'string' && + typeof description === 'string' && + inputSchema?.type === 'object' && + typeof annotations === 'object' && + annotations !== null + if (!isWellFormed) { + throw new Error(`Malformed tool record on ${op.request_type}: ${JSON.stringify(tool)}`) + } + for (const hint of Object.keys(annotations)) { + if (!WEBMCP_ANNOTATION_KEYS.has(hint)) { + throw new Error(`Unsupported tool annotation '${hint}' on ${op.request_type} — extend the generator to honor it`) + } } - toolInputSchemaLines.push(` ${toCamel(op.request_type)}: ${JSON.stringify(toolInputSchema(op.input_schema))},`) + return { name, description, inputSchema, annotations } } -toolInputSchemaLines.push('} as const satisfies Record') -writeFileSync(join(GENERATED_DIR, 'tool-input-schemas.ts'), renderFile(toolInputSchemaLines)) +const webmcpToolLines = [] +webmcpToolLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.') +webmcpToolLines.push('// The WebMCP tool each operation publishes (the manifest `tool`: name, description,') +webmcpToolLines.push('// input schema, behavior hints), verbatim, keyed by SDK method name so `webMCP.exclude`') +webmcpToolLines.push('// maps straight onto it. The editor registers the same record on its own page. Read') +webmcpToolLines.push('// only by src/webmcp.ts, which is lazy-loaded, so this table never lands in an entry') +webmcpToolLines.push('// that did not opt in.') +webmcpToolLines.push("import type { MethodName } from './contract'") +webmcpToolLines.push('') +webmcpToolLines.push('export type WebMCPToolRecord = {') +webmcpToolLines.push(' readonly name: string') +webmcpToolLines.push(' readonly description: string') +webmcpToolLines.push(' readonly inputSchema: {') +webmcpToolLines.push(" readonly type: 'object'") +webmcpToolLines.push(' readonly properties?: Readonly>') +webmcpToolLines.push(' readonly required?: readonly string[]') +webmcpToolLines.push(' }') +webmcpToolLines.push(' readonly annotations: {') +webmcpToolLines.push(' readonly destructiveHint?: boolean') +webmcpToolLines.push(' readonly openWorldHint?: boolean') +webmcpToolLines.push(' readonly readOnlyHint?: boolean') +webmcpToolLines.push(' readonly untrustedContentHint?: boolean') +webmcpToolLines.push(' }') +webmcpToolLines.push('}') +webmcpToolLines.push('') +webmcpToolLines.push('export const WEBMCP_TOOLS = {') +for (const op of contract.operations) { + webmcpToolLines.push(` ${toCamel(op.request_type)}: ${JSON.stringify(webmcpToolRecord(op))},`) +} +webmcpToolLines.push('} as const satisfies Record') + +writeFileSync(join(GENERATED_DIR, 'webmcp-tools.ts'), renderFile(webmcpToolLines)) // --- drift.ts (compile-time drift guards; type-checked, not bundled) -------- // One exported tuple gathers every guard so noUnusedLocals stays happy while the @@ -541,9 +531,7 @@ driftLines.push('// the hand-maintained EditorEvent union must exactly match the driftLines.push("// (so React's onEmbedEvent forwarders, guarded against EditorEvent, can't miss or invent one).") driftLines.push('export type DriftGuards = [') driftLines.push(" AssertTrue>,") -driftLines.push( - ' AssertTrue["method"]>>,', -) +driftLines.push(' AssertTrue>,') driftLines.push(" AssertTrue>,") for (const op of contract.operations) { const stem = toPascal(op.request_type) @@ -581,5 +569,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 + agentic-tool-names.ts + tool-input-schemas.ts`, + `${contract.locales.length} locales, ${editorErrorCodes.length} editor error codes) + schemas.ts + method-names.ts + webmcp-tools.ts`, ) diff --git a/embed/scripts/lazy-chunks.mjs b/embed/scripts/lazy-chunks.mjs index 1a1140b3..b9a25e57 100644 --- a/embed/scripts/lazy-chunks.mjs +++ b/embed/scripts/lazy-chunks.mjs @@ -2,5 +2,5 @@ // 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: 6 * 1024, exportName: 'registerWebMCPTools' }, + 'webmcp-': { budgetBytes: 7.5 * 1024, exportName: 'registerWebMCPTools' }, } diff --git a/embed/src/bridge.ts b/embed/src/bridge.ts index 858e3f62..8679f814 100644 --- a/embed/src/bridge.ts +++ b/embed/src/bridge.ts @@ -27,7 +27,7 @@ export type AttachEmbedArgs = { // 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 + webMCP?: 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 @@ -73,8 +73,13 @@ const relabelResult = (result: BridgeResult): BridgeResult) => void + resultShape: ResultShape wireType: WireType startedAtMs: number timeoutId: ReturnType @@ -109,7 +114,7 @@ export const attachEmbed = ({ logger: providedLogger = NOOP_LOGGER, onDispose, onStateChange, - enableWebMCP, + webMCP, }: AttachEmbedArgs): Embed => { const logger = makeSafeLogger(providedLogger) const pending = new Map() @@ -168,7 +173,7 @@ export const attachEmbed = ({ // table it reads load for no one else. Aborting the signal on dispose unregisters // every tool. const webMCPController = new AbortController() - const webMCP = normalizeWebMCPOptions(enableWebMCP) + const webMCPOptions = normalizeWebMCPOptions(webMCP) // Latched while a registration attempt is in flight or succeeded; released when the // 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 @@ -176,7 +181,7 @@ export const attachEmbed = ({ // itself needs no replay: the module probes on arrival. let webMCPStarted = false const startWebMCP = (): void => { - if (!webMCP.enabled || webMCPStarted) { + if (!webMCPOptions.enabled || webMCPStarted) { return } if (modelContextCandidates().length === 0) { @@ -187,8 +192,8 @@ export const attachEmbed = ({ void import('./webmcp') .then(({ registerWebMCPTools }) => { const registered = registerWebMCPTools({ - dispatch: sendRequest, - exclude: webMCP.exclude, + dispatch: (wireType, data) => postRequest(wireType, data, 'wire'), + exclude: webMCPOptions.exclude, signal: webMCPController.signal, logger, }) @@ -210,7 +215,7 @@ export const attachEmbed = ({ } } - const sendRequest = (wireType: WireType, data: unknown): Promise> => + const postRequest = (wireType: WireType, data: unknown, resultShape: ResultShape): Promise> => new Promise>((resolve) => { if (disposed) { resolve({ @@ -248,6 +253,7 @@ export const attachEmbed = ({ pending.set(requestId, { resolve: (result) => resolve(relabelResult(result)), + resultShape, wireType, startedAtMs, timeoutId, @@ -272,6 +278,9 @@ export const attachEmbed = ({ } }) + const sendRequest = (wireType: WireType, data: unknown): Promise> => + postRequest(wireType, data, 'sdk') + // --- Editor-readiness probing --------------------------------------------- // Fire a GET_FIELDS every 500ms until the editor confirms a document is loaded // (success: true) or the hard fallback fires. A success: false with @@ -470,17 +479,26 @@ export const attachEmbed = ({ if (rawResult.success === true && !('data' in rawResult)) { return { success: true, data: null } } - // wire (snake_case) -> SDK (camelCase) for the data / error.details payloads. - // The transform preserves the envelope shape, so re-narrowing with the same - // guard re-types it without a cast. - const camelCased = fromWireData(rawResult) - if (!isBridgeResultLike(camelCased)) { - return { - success: false, - error: { code: 'unexpected:malformed_result', message: 'REQUEST_RESULT payload had no valid result' }, + switch (entry.resultShape) { + case 'wire': + return rawResult + case 'sdk': { + // wire (snake_case) -> SDK (camelCase) for the data / error.details payloads. + // The transform preserves the envelope shape, so re-narrowing with the same + // guard re-types it without a cast. + const camelCased = fromWireData(rawResult) + if (!isBridgeResultLike(camelCased)) { + return { + success: false, + error: { code: 'unexpected:malformed_result', message: 'REQUEST_RESULT payload had no valid result' }, + } + } + return camelCased } + default: + entry.resultShape satisfies never + return rawResult } - return camelCased })() logger.info('iframe.request_received', { request_id: requestId, diff --git a/embed/src/generated/agentic-tool-names.ts b/embed/src/generated/agentic-tool-names.ts deleted file mode 100644 index 564d722d..00000000 --- a/embed/src/generated/agentic-tool-names.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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", "getAnnotatedPage", "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 8edb790a..4f9f68a7 100644 --- a/embed/src/generated/contract.ts +++ b/embed/src/generated/contract.ts @@ -1,6 +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' +import type { METHOD_NAMES } from './method-names' export const LOCALES = ["fr", "en", "it", "de", "pt", "es", "ja", "nl"] as const export type Locale = (typeof LOCALES)[number] @@ -209,8 +209,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 = (typeof AGENTIC_TOOL_NAMES)[number] +export type MethodName = (typeof METHOD_NAMES)[number] export const OUTBOUND_EVENTS = [ { event_type: "EDITOR_READY", description: "Pushed once when the editor iframe boots in loading-placeholder mode (the loadingPlaceholder=true iframe query flag, which @simplepdf/embed sets while it waits to post LOAD_DOCUMENT) and accepts operations; before it, every operation fails with bad_request:editor_not_ready. An iframe opened with a document instead goes straight to DOCUMENT_LOADED. It is not replayed: a listener attached after boot never receives it, so treat bad_request:editor_not_ready as \"retry shortly\" rather than waiting for this event." }, diff --git a/embed/src/generated/drift.ts b/embed/src/generated/drift.ts index 1ab75ae2..75cefc55 100644 --- a/embed/src/generated/drift.ts +++ b/embed/src/generated/drift.ts @@ -12,7 +12,7 @@ type AssertTrue = T // (so React's onEmbedEvent forwarders, guarded against EditorEvent, can't miss or invent one). export type DriftGuards = [ AssertTrue>, - AssertTrue["method"]>>, + AssertTrue>, AssertTrue>, AssertTrue>, AssertTrue>, diff --git a/embed/src/generated/method-names.ts b/embed/src/generated/method-names.ts new file mode 100644 index 00000000..cd19a9a9 --- /dev/null +++ b/embed/src/generated/method-names.ts @@ -0,0 +1,5 @@ +// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand. +// The SDK method names alone, so createEmbed can validate a `webMCP.exclude` list +// without pulling the operations table into the zero-dep root; contract.ts derives +// MethodName from this list. +export const METHOD_NAMES = ["createField", "deleteFields", "deletePages", "detectFields", "download", "focusField", "getAnnotatedPage", "getDocumentContent", "getFields", "goTo", "loadDocument", "movePage", "rotatePage", "selectTool", "setFieldValue", "submit"] as const diff --git a/embed/src/generated/tool-input-schemas.ts b/embed/src/generated/tool-input-schemas.ts deleted file mode 100644 index 18f84d70..00000000 --- a/embed/src/generated/tool-input-schemas.ts +++ /dev/null @@ -1,29 +0,0 @@ -// 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 or http(s) URL (fetched by the editor) 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"]}, - getAnnotatedPage: {"type":"object","properties":{"page":{"type":"integer","description":"1-based page to render, at its current position."}},"required":["page"]}, - 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 the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (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/generated/webmcp-tools.ts b/embed/src/generated/webmcp-tools.ts new file mode 100644 index 00000000..ab2977a2 --- /dev/null +++ b/embed/src/generated/webmcp-tools.ts @@ -0,0 +1,42 @@ +// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand. +// The WebMCP tool each operation publishes (the manifest `tool`: name, description, +// input schema, behavior hints), verbatim, keyed by SDK method name so `webMCP.exclude` +// maps straight onto it. The editor registers the same record on its own page. 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 { MethodName } from './contract' + +export type WebMCPToolRecord = { + readonly name: string + readonly description: string + readonly inputSchema: { + readonly type: 'object' + readonly properties?: Readonly> + readonly required?: readonly string[] + } + readonly annotations: { + readonly destructiveHint?: boolean + readonly openWorldHint?: boolean + readonly readOnlyHint?: boolean + readonly untrustedContentHint?: boolean + } +} + +export const WEBMCP_TOOLS = { + createField: {"name":"simplepdf_embed_create_field","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.","inputSchema":{"type":"object","properties":{"type":{"enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"],"description":"Field type to create.","type":"string"},"x":{"description":"Field x position, in PDF points.","type":"number"},"y":{"description":"Field y position, in PDF points.","type":"number"},"width":{"description":"Field width, in PDF points.","type":"number"},"height":{"description":"Field height, in PDF points.","type":"number"},"page":{"description":"1-based page to place the field on.","type":"integer"},"value":{"description":"Optional initial value. A string for text/checkbox fields, or a data URL or http(s) URL (fetched by the editor) for signature/picture fields.","type":"string"}},"required":["type","x","y","width","height","page"]},"annotations":{"destructiveHint":false,"openWorldHint":true}}, + deleteFields: {"name":"simplepdf_embed_delete_fields","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.","inputSchema":{"type":"object","properties":{"field_ids":{"description":"IDs of the fields to delete. Omit to delete every field on the target page.","items":{"type":"string"},"type":"array"},"page":{"description":"1-based page to scope the deletion to. Omit to target all pages.","type":"integer"}}},"annotations":{"destructiveHint":true}}, + deletePages: {"name":"simplepdf_embed_delete_pages","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.","inputSchema":{"type":"object","properties":{"pages":{"items":{"type":"integer"},"description":"1-based page numbers to delete.","type":"array"}},"required":["pages"]},"annotations":{"destructiveHint":true}}, + detectFields: {"name":"simplepdf_embed_detect_fields","description":"Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled.","inputSchema":{"type":"object","properties":{}},"annotations":{"destructiveHint":false}}, + download: {"name":"simplepdf_embed_download","description":"Generate and download the current document as a PDF. Returns no data.","inputSchema":{"type":"object","properties":{}},"annotations":{"destructiveHint":false}}, + focusField: {"name":"simplepdf_embed_focus_field","description":"Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next.","inputSchema":{"type":"object","properties":{"field_id":{"description":"ID of the field to focus and scroll into view.","type":"string"}},"required":["field_id"]},"annotations":{"destructiveHint":false}}, + getAnnotatedPage: {"name":"simplepdf_embed_get_annotated_page","description":"Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to render, at its current position.","type":"integer"}},"required":["page"]},"annotations":{"readOnlyHint":true,"untrustedContentHint":true}}, + getDocumentContent: {"name":"simplepdf_embed_get_document_content","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 }] }.","inputSchema":{"type":"object","properties":{"extraction_mode":{"description":"Extraction strategy: 'auto' (default) or 'ocr' to force optical recognition.","enum":["auto","ocr"],"type":"string"}}},"annotations":{"readOnlyHint":true,"untrustedContentHint":true}}, + getFields: {"name":"simplepdf_embed_get_fields","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. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true,"untrustedContentHint":true}}, + goTo: {"name":"simplepdf_embed_go_to","description":"Scroll the editor to a specific 1-based page. Returns no data.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to navigate to.","type":"integer"}},"required":["page"]},"annotations":{"destructiveHint":false}}, + loadDocument: {"name":"simplepdf_embed_load_document","description":"Replace the document in the editor with one supplied as a base64 data URL or an http(s) URL the editor fetches. Destructive: the current document and every edit in it are discarded. Returns no data.","inputSchema":{"type":"object","properties":{"data_url":{"description":"The document to load: a data URL, or an http(s) URL the editor fetches.","type":"string"},"name":{"description":"Optional display name for the document.","type":"string"},"page":{"description":"Optional 1-based page to open the document on.","type":"integer"}},"required":["data_url"]},"annotations":{"destructiveHint":true,"openWorldHint":true}}, + movePage: {"name":"simplepdf_embed_move_page","description":"Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled.","inputSchema":{"type":"object","properties":{"from_page":{"description":"1-based current position of the page to move.","type":"integer"},"to_page":{"description":"1-based destination position for the page.","type":"integer"}},"required":["from_page","to_page"]},"annotations":{"destructiveHint":true}}, + rotatePage: {"name":"simplepdf_embed_rotate_page","description":"Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to rotate 90 degrees clockwise.","type":"integer"}},"required":["page"]},"annotations":{"destructiveHint":true}}, + selectTool: {"name":"simplepdf_embed_select_tool","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.","inputSchema":{"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"]},"annotations":{"destructiveHint":false}}, + setFieldValue: {"name":"simplepdf_embed_set_field_value","description":"Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Returns no data.","inputSchema":{"type":"object","properties":{"field_id":{"description":"ID of the field to update.","type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."}},"required":["field_id","value"]},"annotations":{"destructiveHint":false,"openWorldHint":true}}, + submit: {"name":"simplepdf_embed_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.","inputSchema":{"type":"object","properties":{"download_copy":{"description":"When true, the signer also receives a downloaded copy on submit.","type":"boolean"}},"required":["download_copy"]},"annotations":{"destructiveHint":true}}, +} as const satisfies Record diff --git a/embed/src/mount.ts b/embed/src/mount.ts index 99a1b926..a9c4e56a 100644 --- a/embed/src/mount.ts +++ b/embed/src/mount.ts @@ -1,7 +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 { METHOD_NAMES } from './generated/method-names' import type { Locale } from './generated/contract' import type { WebMCPOptions } from './webmcp-shared' @@ -90,9 +90,9 @@ export type CreateEmbedArgs = { 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 + // `{ enabled: true }` registers every operation, `exclude` withholds some by method + // name (e.g. `submit` when only a person may finalize). Off by default. + webMCP?: WebMCPOptions } const resolveTarget = (target: unknown): HTMLElement => { @@ -201,37 +201,39 @@ const assertValidFileArm = (file: unknown): void => { } } -const AGENTIC_TOOL_NAME_SET: ReadonlySet = new Set(AGENTIC_TOOL_NAMES) +const METHOD_NAME_SET: ReadonlySet = new Set(METHOD_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 assertValidWebMCPOptions = (enableWebMCP: unknown): void => { - if (enableWebMCP === undefined || typeof enableWebMCP === 'boolean') { +// `webMCP.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 assertValidWebMCPOptions = (webMCP: unknown): void => { + if (webMCP === undefined) { return } - 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 shapeError = new EmbedConfigError( + 'invalid_config', + `webMCP must be { enabled: false } or { enabled: true, exclude?: MethodName[] } (received ${describeValue(webMCP)}).`, + ) + const isObject = typeof webMCP === 'object' && webMCP !== null + if (!isObject || !('enabled' in webMCP) || typeof webMCP.enabled !== 'boolean') { + throw shapeError + } + const exclude = 'exclude' in webMCP ? webMCP.exclude : undefined + if (exclude === undefined) { + return + } + if (!Array.isArray(exclude)) { + throw shapeError + } + const entries: unknown[] = exclude + if (!entries.every((name): name is string => typeof name === 'string')) { + throw shapeError } - const unknownNames = excludeList.filter((name) => !AGENTIC_TOOL_NAME_SET.has(name)) + const unknownNames = entries.filter((name) => !METHOD_NAME_SET.has(name)) if (unknownNames.length > 0) { throw new EmbedConfigError( 'invalid_config', - `enableWebMCP.exclude names no tool: ${unknownNames.join(', ')} (known: ${AGENTIC_TOOL_NAMES.join(', ')}).`, + `webMCP.exclude names no tool: ${unknownNames.join(', ')} (known: ${METHOD_NAMES.join(', ')}).`, ) } } @@ -503,7 +505,7 @@ const loadDocumentWhenReady = (params: { const attachToIframe = ( iframe: HTMLIFrameElement, editorOrigin: string, - { document: embedDocument, logger = NOOP_LOGGER, enableWebMCP }: CreateEmbedArgs, + { document: embedDocument, logger = NOOP_LOGGER, webMCP }: CreateEmbedArgs, documentsUrl: { url: URL; origin: string } | null, ): Embed => { // A documents URL loads by NAVIGATING the iframe, which we only do for an iframe @@ -550,7 +552,7 @@ const attachToIframe = ( logger: safeLogger, onDispose: () => documentFetchController.abort(), onStateChange: gate.onStateChange, - enableWebMCP, + webMCP, }) if (embedDocument !== undefined) { loadDocumentWhenReady({ @@ -570,7 +572,7 @@ const attachToIframe = ( const mountIntoContainer = ( container: HTMLElement, editorOrigin: string, - { document: mountDocument, locale, context, iframeAttrs, logger = NOOP_LOGGER, enableWebMCP }: CreateEmbedArgs, + { document: mountDocument, locale, context, iframeAttrs, logger = NOOP_LOGGER, webMCP }: CreateEmbedArgs, documentsUrl: { url: URL; origin: string } | null, ): Embed => { const hasDocumentUrl = mountDocument !== undefined && 'url' in mountDocument @@ -650,7 +652,7 @@ const mountIntoContainer = ( documentFetchController.abort() iframe.remove() }, - enableWebMCP, + webMCP, }) // A documents URL is loaded by the navigation above; only the PDF / data-URL / @@ -699,7 +701,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) + assertValidWebMCPOptions(args.webMCP) 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/types.ts b/embed/src/types.ts index 31b23718..f9994f7e 100644 --- a/embed/src/types.ts +++ b/embed/src/types.ts @@ -33,7 +33,6 @@ import type { } from './generated/contract' export type { - AgenticToolName, CreateFieldInput, CreateFieldOutput, DeleteFieldsInput, @@ -57,6 +56,7 @@ export type { GoToInput, Locale, LoadDocumentInput, + MethodName, MissingRequiredFieldsDetails, MovePageInput, OverlayToolType, diff --git a/embed/src/webmcp-shared.ts b/embed/src/webmcp-shared.ts index 3f6f07f3..eefc1cda 100644 --- a/embed/src/webmcp-shared.ts +++ b/embed/src/webmcp-shared.ts @@ -1,7 +1,7 @@ // 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' +import type { MethodName } 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 @@ -17,22 +17,28 @@ export const modelContextCandidates = (): unknown[] => { 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[] } +// `{ enabled: true }` registers every operation; `exclude` withholds the listed ones +// by SDK method name (e.g. `submit` when only a person may finalize). `{ enabled: false }` +// and omitted are one state. The object is the home of every WebMCP-specific setting. +export type WebMCPOptions = { enabled: false } | { enabled: true; exclude?: readonly MethodName[] } // 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. +// re-deriving the `undefined | { enabled: false } | { enabled: 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[] } => { - if (options === undefined || options === false) { +): { enabled: false } | { enabled: true; exclude: readonly MethodName[] } => { + if (options === undefined) { return { enabled: false } } - if (options === true) { - return { enabled: true, exclude: [] } + switch (options.enabled) { + case false: + return { enabled: false } + case true: + return { enabled: true, exclude: options.exclude ?? [] } + default: + options satisfies never + return { enabled: false } } - return { enabled: true, exclude: options.exclude } } diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts index a1c9958a..6287c83b 100644 --- a/embed/src/webmcp.ts +++ b/embed/src/webmcp.ts @@ -2,79 +2,70 @@ // 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. +// editor iframe are not discovered, which is why the SDK lifts them here. Each tool +// is the manifest's record, the one the editor registers on its own page: same name, +// description, snake_case input schema and hints, and the same wire-shaped Result. // -// 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. +// Loaded lazily by the bridge, once the editor is ready and only when `webMCP` is +// enabled and the page exposes a model context, so nothing here (nor the record +// 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 { OPERATIONS, type MethodName, type WireType } from './generated/contract' +import { WEBMCP_TOOLS, type WebMCPToolRecord } from './generated/webmcp-tools' 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 -// `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 WebMCPTool = { - name: string - description: string - inputSchema: ToolInputSchema - annotations: ToolAnnotations - execute: (input: unknown) => Promise -} +// resolves with as JSON text; this shape is what runtimes that map results onto MCP's +// CallToolResult read (and what the editor's own in-page tools return): a failed +// Result additionally flagged `isError`, a page render carried as an `image` block. +type ToolContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: 'image/png' } +type CallToolResult = { content: ToolContent[]; isError?: boolean } +type WebMCPTool = WebMCPToolRecord & { execute: (input: unknown) => Promise } type ModelContext = { registerTool: (tool: WebMCPTool, options: { signal: AbortSignal }) => unknown } -type Operation = (typeof OPERATIONS)[number] -type AgenticOperation = Extract - -// 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. 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/contract.ts (SimplePDF editor repository) -const TOOL_ANNOTATIONS = { - createField: { destructiveHint: false }, - deleteFields: { destructiveHint: true }, - deletePages: { destructiveHint: true }, - detectFields: { destructiveHint: false }, - download: { destructiveHint: false }, - focusField: { destructiveHint: false }, - getAnnotatedPage: { readOnlyHint: true, untrustedContentHint: true }, - getDocumentContent: { readOnlyHint: true, untrustedContentHint: true }, - getFields: { readOnlyHint: true, untrustedContentHint: 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 PNG_DATA_URL_PREFIX = 'data:image/png;base64,' const isModelContext = (value: unknown): value is ModelContext => typeof value === 'object' && value !== null && 'registerTool' in value && typeof value.registerTool === 'function' const readModelContext = (): ModelContext | null => modelContextCandidates().find(isModelContext) ?? null -const toCallToolResult = (result: BridgeResult): CallToolResult => ({ +const toTextToolResult = (result: BridgeResult): CallToolResult => ({ content: [{ type: 'text', text: JSON.stringify(result) }], ...(result.success ? {} : { isError: true }), }) +// The render travels once, as the image block a vision-capable runtime shows the +// model; the text block keeps the rest of the result (page, size, badges). Anything +// but a successful PNG render takes the plain text envelope. +const toAnnotatedPageToolResult = (result: BridgeResult): CallToolResult => { + if (!result.success) { + return toTextToolResult(result) + } + const render: unknown = result.data + if (typeof render !== 'object' || render === null || !('image_data_url' in render)) { + return toTextToolResult(result) + } + const { image_data_url: imageDataUrl, ...renderWithoutImage } = render + if (typeof imageDataUrl !== 'string' || !imageDataUrl.startsWith(PNG_DATA_URL_PREFIX)) { + return toTextToolResult(result) + } + return { + content: [ + { type: 'image', data: imageDataUrl.slice(PNG_DATA_URL_PREFIX.length), mimeType: 'image/png' }, + { type: 'text', text: JSON.stringify({ success: true, data: renderWithoutImage }) }, + ], + } +} + +const toCallToolResult = (wireType: WireType, result: BridgeResult): CallToolResult => + wireType === 'GET_ANNOTATED_PAGE' ? toAnnotatedPageToolResult(result) : toTextToolResult(result) + // 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. // Each name records the signal that owns it, so only its owner ever frees it. @@ -94,8 +85,10 @@ export const registerWebMCPTools = ({ signal, logger, }: { + // Resolves with the editor's wire-shaped Result (snake_case, what the record's + // description promises), not the SDK's camelCased one. dispatch: (wireType: WireType, data: unknown) => Promise> - exclude: readonly AgenticToolName[] + exclude: readonly MethodName[] signal: AbortSignal logger: BridgeLogger }): boolean => { @@ -107,22 +100,23 @@ export const registerWebMCPTools = ({ logger.info('webmcp.unavailable', { reason: 'invalid_model_context' }) return false } - const excluded = new Set(exclude) + const excluded = new Set(exclude) for (const operation of OPERATIONS) { - if (!isAgenticOperation(operation) || excluded.has(operation.method)) { + if (excluded.has(operation.method)) { continue } - if (liveTools.has(operation.method)) { - logger.warn('webmcp.tool_already_registered', { tool: operation.method }) + const record = WEBMCP_TOOLS[operation.method] + if (liveTools.has(record.name)) { + logger.warn('webmcp.tool_already_registered', { tool: record.name }) continue } const tool: WebMCPTool = { - name: operation.method, - description: operation.description, - inputSchema: TOOL_INPUT_SCHEMAS[operation.method], - annotations: TOOL_ANNOTATIONS[operation.method], + name: record.name, + description: record.description, + inputSchema: record.inputSchema, + annotations: record.annotations, // A nullish input becomes an empty payload (the no-input operations' wire shape). - execute: async (input) => toCallToolResult(await dispatch(operation.wire_type, input ?? {})), + execute: async (input) => toCallToolResult(operation.wire_type, await dispatch(operation.wire_type, input ?? {})), } liveTools.set(tool.name, signal) signal.addEventListener('abort', () => freeTool(tool.name, signal), { once: true }) diff --git a/embed/test/mount.test.ts b/embed/test/mount.test.ts index 835723a2..cf52100c 100644 --- a/embed/test/mount.test.ts +++ b/embed/test/mount.test.ts @@ -386,23 +386,27 @@ describe(createEmbed.name, () => { // 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 bare boolean', true], + ['an object without enabled', { exclude: ['submit'] }], + ['a stringly-typed enabled', { enabled: 'true' }], ['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) => { + ['a string exclude', { enabled: true, exclude: 'submit' }], + ['a non-string exclude entry', { enabled: true, exclude: ['submit', 7] }], + ])('throws EmbedConfigError when webMCP is %s', (_label, webMCP) => { document.body.innerHTML = '
' - const malformedArgs: unknown = { target: '#root', companyIdentifier: 'acme', enableWebMCP } + const malformedArgs: unknown = { target: '#root', companyIdentifier: 'acme', webMCP } // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => createEmbed(malformedArgs)).toThrow(/enableWebMCP must be a boolean or \{ exclude: AgenticToolName\[\] \}/) + expect(() => createEmbed(malformedArgs)).toThrow( + /webMCP must be \{ enabled: false \} or \{ enabled: true, exclude\?: MethodName\[\] \}/, + ) }) 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'] } } + const misspelled: unknown = { target: '#root', companyIdentifier: 'acme', webMCP: { enabled: true, 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/) + expect(() => createEmbed(misspelled)).toThrow(/webMCP\.exclude names no tool: sumbit \(known: createField/) }) }) diff --git a/embed/test/webmcp.test.ts b/embed/test/webmcp.test.ts index ba50194c..03fe818c 100644 --- a/embed/test/webmcp.test.ts +++ b/embed/test/webmcp.test.ts @@ -2,7 +2,8 @@ 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' +import { METHOD_NAMES } from '../src/generated/method-names' +import { WEBMCP_TOOLS } from '../src/generated/webmcp-tools' const EDITOR_ORIGIN = 'https://tenant.simplepdf.com' @@ -11,8 +12,11 @@ type RegisteredTool = { name: string description: string inputSchema: { type: string; properties?: Record; required?: readonly string[] } - annotations: { readOnlyHint?: boolean; untrustedContentHint?: boolean; destructiveHint?: boolean } - execute: (input: unknown) => Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> + annotations: { readOnlyHint?: boolean; untrustedContentHint?: boolean; destructiveHint?: boolean; openWorldHint?: boolean } + execute: (input: unknown) => Promise<{ + content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> + isError?: boolean + }> } type FakeModelContext = { registerTool: (tool: RegisteredTool, options: { signal: AbortSignal }) => void @@ -69,7 +73,7 @@ type Harness = { const harnesses: Harness[] = [] -const makeHarness = (args: Pick): Harness => { +const makeHarness = (args: Pick): Harness => { const iframe = document.createElement('iframe') document.body.appendChild(iframe) const contentWindow = iframe.contentWindow @@ -102,7 +106,7 @@ const makeHarness = (args: Pick): Ha // 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 mountReady = (args: Pick): Harness => { const harness = makeHarness(args) harness.markEditorReady() return harness @@ -111,7 +115,8 @@ const mountReady = (args: Pick): Har const waitForTools = (modelContext: FakeModelContext, count: number): Promise => vi.waitFor(() => expect(modelContext.registered).toHaveLength(count)) -const TOOL_COUNT = AGENTIC_TOOL_NAMES.length +const TOOL_COUNT = METHOD_NAMES.length +const toolName = (method: keyof typeof WEBMCP_TOOLS): string => WEBMCP_TOOLS[method].name // 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. @@ -132,7 +137,7 @@ const findTool = (modelContext: FakeModelContext, name: string): RegisteredTool return tool } -describe('attachEmbed({ enableWebMCP })', () => { +describe('attachEmbed({ webMCP })', () => { afterEach(() => { for (const harness of harnesses) { harness.embed.lifecycle.dispose() @@ -144,37 +149,51 @@ describe('attachEmbed({ enableWebMCP })', () => { vi.restoreAllMocks() }) - it('registers every agentic operation on document.modelContext with the SDK name, description, camelCase input schema and an explicit behavior hint', async () => { + it('registers every operation on document.modelContext as the manifest tool record: prefixed name, description, snake_case input schema and behavior hints', async () => { const modelContext = installModelContext(document) - mountReady({ enableWebMCP: true }) + mountReady({ webMCP: { enabled: true } }) await waitForTools(modelContext, TOOL_COUNT) - expect(modelContext.registered.map((tool) => tool.name).sort()).toEqual([...AGENTIC_TOOL_NAMES].sort()) + expect(modelContext.registered.map((tool) => tool.name).sort()).toEqual( + Object.values(WEBMCP_TOOLS) + .map((tool) => tool.name) + .sort(), + ) expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) - const setFieldValue = findTool(modelContext, 'setFieldValue') + // loadDocument is a host-page tool like it is in the editor's own registration. + expect(modelContext.liveToolNames()).toContain('simplepdf_embed_load_document') + const setFieldValue = findTool(modelContext, 'simplepdf_embed_set_field_value') 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']) + expect(Object.keys(setFieldValue.inputSchema.properties ?? {})).toEqual(['field_id', 'value']) + expect(setFieldValue.inputSchema.required).toEqual(['field_id', '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) } // 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({ + expect(findTool(modelContext, 'simplepdf_embed_get_fields').annotations).toEqual({ + readOnlyHint: true, + untrustedContentHint: true, + }) + expect(findTool(modelContext, 'simplepdf_embed_get_annotated_page').annotations).toEqual({ readOnlyHint: true, untrustedContentHint: true, }) - expect(findTool(modelContext, 'submit').annotations).toEqual({ destructiveHint: true }) + expect(findTool(modelContext, 'simplepdf_embed_submit').annotations).toEqual({ destructiveHint: true }) + // The hints are the manifest's, openWorldHint included (the editor fetches an agent-supplied URL). + expect(findTool(modelContext, 'simplepdf_embed_set_field_value').annotations).toEqual({ + destructiveHint: false, + openWorldHint: true, + }) }) 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 booting = makeHarness({ enableWebMCP: true }) + const booting = makeHarness({ webMCP: { enabled: true } }) // 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 }) + const control = mountReady({ webMCP: { enabled: true } }) await waitForTools(modelContext, TOOL_COUNT) expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) control.embed.lifecycle.dispose() @@ -188,60 +207,107 @@ describe('attachEmbed({ enableWebMCP })', () => { 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 }) + mountReady({ webMCP: { enabled: true, exclude: ['submit', 'deletePages', 'movePage', 'rotatePage'] }, logger }) await waitForTools(modelContext, TOOL_COUNT - 4) 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') + expect(names).toContain(toolName('setFieldValue')) + expect(names).toContain(toolName('getFields')) + expect(names).not.toContain(toolName('submit')) + expect(names).not.toContain(toolName('deletePages')) expect(logger.warn).not.toHaveBeenCalled() }) - it('executes a tool call as the operation request on the wire and returns the editor Result as a JSON-text tool result', async () => { + it('executes a tool call as the operation request on the wire and returns the editor Result, wire-shaped, as a JSON-text tool result', async () => { const modelContext = installModelContext(document) - const harness = mountReady({ enableWebMCP: true }) + const harness = mountReady({ webMCP: { enabled: true } }) await waitForTools(modelContext, TOOL_COUNT) - const pendingResult = findTool(modelContext, 'setFieldValue').execute({ fieldId: 'f1', value: 'Jane' }) + const pendingResult = findTool(modelContext, 'simplepdf_embed_set_field_value').execute({ field_id: '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 }) + expect(toolResult.content).toEqual([{ type: 'text', text: JSON.stringify({ success: true, data: null }) }]) + }) + + it('hands the agent the wire-shaped result its tool description promises (snake_case, not the SDK camelCase)', async () => { + const modelContext = installModelContext(document) + const harness = mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + + const pendingResult = findTool(modelContext, 'simplepdf_embed_create_field').execute({ type: 'TEXT', x: 1, y: 2, width: 3, height: 4, page: 1 }) + const request = await waitForRequest(harness, 'CREATE_FIELD') + harness.reply(request, { success: true, data: { field_id: 'f_new' } }) + const toolResult = await pendingResult + expect(toolResult.content).toEqual([{ type: 'text', text: JSON.stringify({ success: true, data: { field_id: 'f_new' } }) }]) + }) + + it('returns the annotated page render once, as an image block, with the badges map in the text block', async () => { + const modelContext = installModelContext(document) + const harness = mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + + const pendingResult = findTool(modelContext, 'simplepdf_embed_get_annotated_page').execute({ page: 1 }) + const request = await waitForRequest(harness, 'GET_ANNOTATED_PAGE') + harness.reply(request, { + success: true, + data: { page: 1, image_data_url: 'data:image/png;base64,iVBORw0KGgo=', image_width: 10, image_height: 12, badges: { '1': 'f1' } }, + }) + const toolResult = await pendingResult + expect(toolResult.isError).toBeUndefined() + expect(toolResult.content).toEqual([ + { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + { type: 'text', text: JSON.stringify({ success: true, data: { page: 1, image_width: 10, image_height: 12, badges: { '1': 'f1' } } }) }, + ]) + }) + + it('keeps the text envelope for a failed annotated page render', async () => { + const modelContext = installModelContext(document) + const harness = mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + + const pendingResult = findTool(modelContext, 'simplepdf_embed_get_annotated_page').execute({ page: 99 }) + const request = await waitForRequest(harness, 'GET_ANNOTATED_PAGE') + const failure = { success: false, error: { code: 'bad_request:page_out_of_range', message: 'no page 99' } } + harness.reply(request, failure) + const toolResult = await pendingResult + expect(toolResult.isError).toBe(true) + expect(toolResult.content).toEqual([{ type: 'text', text: JSON.stringify(failure) }]) }) 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 }) + const harness = mountReady({ webMCP: { enabled: true } }) await waitForTools(modelContext, TOOL_COUNT) - const pendingResult = findTool(modelContext, 'goTo').execute({ page: 99 }) + const pendingResult = findTool(modelContext, 'simplepdf_embed_go_to').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' }, - }) + expect(toolResult.content).toEqual([ + { + type: 'text', + text: JSON.stringify({ 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 = mountReady({ enableWebMCP: true }) + const harness = mountReady({ webMCP: { enabled: true } }) await waitForTools(modelContext, TOOL_COUNT) - void findTool(modelContext, 'detectFields').execute(undefined) + void findTool(modelContext, 'simplepdf_embed_detect_fields').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 = mountReady({ enableWebMCP: true }) + const harness = mountReady({ webMCP: { enabled: true } }) await waitForTools(modelContext, TOOL_COUNT) expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) @@ -251,11 +317,11 @@ describe('attachEmbed({ enableWebMCP })', () => { it('registers nothing when the embed is disposed before the lazy module resolves', async () => { const modelContext = installModelContext(document) - const disposedEarly = mountReady({ enableWebMCP: true }) + const disposedEarly = mountReady({ webMCP: { enabled: 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 }) + mountReady({ webMCP: { enabled: true } }) await waitForTools(modelContext, TOOL_COUNT) expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) }) @@ -264,10 +330,10 @@ describe('attachEmbed({ enableWebMCP })', () => { const modelContext = installModelContext(document) const registerTool = vi.spyOn(modelContext, 'registerTool') mountReady({}) - mountReady({ enableWebMCP: false }) + mountReady({ webMCP: { enabled: 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 }) + mountReady({ webMCP: { enabled: true } }) await waitForTools(modelContext, TOOL_COUNT) expect(registerTool).toHaveBeenCalledTimes(TOOL_COUNT) }) @@ -275,55 +341,55 @@ describe('attachEmbed({ enableWebMCP })', () => { 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 }) + const first = mountReady({ webMCP: { enabled: true } }) await waitForTools(modelContext, TOOL_COUNT) - mountReady({ enableWebMCP: true, logger }) + mountReady({ webMCP: { enabled: true }, logger }) await vi.waitFor(() => - expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'submit' }), + expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'simplepdf_embed_submit' }), ) 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 }) + mountReady({ webMCP: { enabled: true } }) 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() }) + const modelContext = installModelContext(document, { rejectTool: 'simplepdf_embed_download' }) + const first = mountReady({ webMCP: { enabled: 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() }) + const second = mountReady({ webMCP: { enabled: true }, logger: makeLogger() }) await waitForTools(accepting, 1) - expect(accepting.registered[0]?.name).toBe('download') + expect(accepting.registered[0]?.name).toBe('simplepdf_embed_download') - // A disposes: its 13 names are freed for C, but B's `download` stays owned, so C is refused it. + // A disposes: its 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 }) + mountReady({ webMCP: { enabled: true }, logger }) await waitForTools(accepting, TOOL_COUNT) - expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'download' }) + expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'simplepdf_embed_download' }) expect(logger.warn).toHaveBeenCalledTimes(1) - expect(accepting.registered.filter((tool) => tool.name === 'download')).toHaveLength(1) + expect(accepting.registered.filter((tool) => tool.name === 'simplepdf_embed_download')).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 }) + mountReady({ webMCP: { enabled: true } }) 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 () => { const logger = makeLogger() - const harness = makeHarness({ enableWebMCP: true, logger }) + const harness = makeHarness({ webMCP: { enabled: true }, logger }) harness.markEditorReady() await vi.waitFor(() => expect(logger.info).toHaveBeenCalledWith('webmcp.unavailable', { reason: 'no_model_context' })) expect(logger.error).not.toHaveBeenCalled() @@ -338,7 +404,7 @@ describe('attachEmbed({ enableWebMCP })', () => { 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() - const harness = mountReady({ enableWebMCP: true, logger }) + const harness = mountReady({ webMCP: { enabled: true }, logger }) await vi.waitFor(() => expect(logger.info).toHaveBeenCalledWith('webmcp.unavailable', { reason: 'invalid_model_context' }), ) @@ -349,16 +415,16 @@ describe('attachEmbed({ enableWebMCP })', () => { }) 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 modelContext = installModelContext(document, { rejectTool: 'simplepdf_embed_download' }) const logger = makeLogger() - mountReady({ enableWebMCP: true, logger }) + mountReady({ webMCP: { enabled: true }, logger }) await waitForTools(modelContext, TOOL_COUNT - 1) - expect(modelContext.registered.map((tool) => tool.name)).not.toContain('download') + expect(modelContext.registered.map((tool) => tool.name)).not.toContain('simplepdf_embed_download') await vi.waitFor(() => expect(logger.error).toHaveBeenCalledWith('webmcp.register_tool_failed', { - tool: 'download', - message: 'runtime rejected download', + tool: 'simplepdf_embed_download', + message: 'runtime rejected simplepdf_embed_download', }), ) }) diff --git a/react/README.md b/react/README.md index 3add2c90..d4b87292 100644 --- a/react/README.md +++ b/react/README.md @@ -319,10 +319,10 @@ See [Retrieving PDF Data](../README.md#retrieving-pdf-data) for text extraction, 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[] } + webMCP + { enabled: false } | { enabled: true; exclude?: MethodName[] } 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. Changing the value remounts the editor (registration happens at mount), so keep it stable while the person is editing. 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 by method name, 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 8647be0a..e1f2025c 100644 --- a/react/etc/index.api.md +++ b/react/etc/index.api.md @@ -4,7 +4,6 @@ ```ts -import { AgenticToolName } from '@simplepdf/embed'; import type { BridgeLogger } from '@simplepdf/embed'; import type { BridgeResult } from '@simplepdf/embed'; import type { EditorEvent } from '@simplepdf/embed'; @@ -12,14 +11,13 @@ import { EmbedDocument } from '@simplepdf/embed'; import { FieldType } from '@simplepdf/embed'; import type { IframeActions } from '@simplepdf/embed'; import type { Locale } from '@simplepdf/embed'; +import { MethodName } from '@simplepdf/embed'; 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'; -export { AgenticToolName } - // @public (undocumented) export type EmbedActions = Omit & { selectTool: (input: SelectToolInput | SelectToolInput['tool']) => Promise; @@ -44,6 +42,8 @@ export type EmbedPDFProps = InlineEmbedPDFProps | ModalEmbedPDFProps; export { FieldType } +export { MethodName } + export { OverlayToolType } // @public (undocumented) diff --git a/react/src/embed-pdf.test.tsx b/react/src/embed-pdf.test.tsx index 19235ab8..4d472269 100644 --- a/react/src/embed-pdf.test.tsx +++ b/react/src/embed-pdf.test.tsx @@ -13,7 +13,7 @@ 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 () => { + it('registers the editor operations as WebMCP tools on the host page when webMCP is enabled, and unregisters them on unmount', async () => { const liveTools = new Set(); const registerTool = vi.fn((tool: { name: string }, { signal }: { signal: AbortSignal }) => { liveTools.add(tool.name); @@ -22,7 +22,7 @@ describe('EmbedPDF (inline)', () => { Object.defineProperty(document, 'modelContext', { configurable: true, value: { registerTool } }); try { const { container, unmount } = render( - , + , ); // Tools register once the editor announces itself. window.dispatchEvent( @@ -32,9 +32,9 @@ describe('EmbedPDF (inline)', () => { source: container.querySelector('iframe')?.contentWindow ?? null, }), ); - await waitFor(() => expect(liveTools.has('setFieldValue')).toBe(true)); + await waitFor(() => expect(liveTools.has('simplepdf_embed_set_field_value')).toBe(true)); expect(liveTools.size).toBeGreaterThan(1); - expect(liveTools.has('submit')).toBe(false); + expect(liveTools.has('simplepdf_embed_submit')).toBe(false); unmount(); expect(liveTools.size).toBe(0); } finally { @@ -42,16 +42,18 @@ describe('EmbedPDF (inline)', () => { } }); - it('does not remount the editor when enableWebMCP is re-rendered as an equal value', () => { + it('does not remount the editor when webMCP is re-rendered as an equal value', () => { const { container, rerender } = render( - , + , ); const iframe = container.querySelector('iframe'); - rerender(); + rerender( + , + ); expect(container.querySelector('iframe')).toBe(iframe); // A different value does remount: registration happens at mount. - rerender(); + rerender(); const remounted = container.querySelector('iframe'); expect(remounted).not.toBeNull(); expect(remounted).not.toBe(iframe); diff --git a/react/src/embed-pdf.tsx b/react/src/embed-pdf.tsx index 756017b5..5a78eec0 100644 --- a/react/src/embed-pdf.tsx +++ b/react/src/embed-pdf.tsx @@ -98,9 +98,9 @@ type CommonEmbedPDFProps = { // 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; + // createEmbed): `{ enabled: true }` for every operation, `exclude` to withhold some + // by method name (e.g. `submit`). Off by default. + webMCP?: WebMCPOptions; }; type InlineEmbedPDFProps = CommonEmbedPDFProps & { @@ -127,7 +127,7 @@ type SurfaceProps = { context?: Record; logger?: BridgeLogger; onEmbedEvent?: (event: EmbedEvent) => void | Promise; - enableWebMCP?: WebMCPOptions; + webMCP?: WebMCPOptions; className?: string; style?: React.CSSProperties; }; @@ -136,16 +136,7 @@ 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, - enableWebMCP, - className, - style, - } = props; + const { companyIdentifier, baseDomain, document: embedDocument, locale, context, webMCP, className, style } = props; const containerRef = React.useRef(null); // Keep callbacks + logger in a ref so changing them does not remount the iframe. @@ -205,13 +196,13 @@ 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 so the literal itself stays out of its dependencies. - const webMCP = normalizeWebMCPOptions(enableWebMCP); - const webMCPKey = webMCP.enabled ? `on:${[...webMCP.exclude].sort().join(',')}` : 'off'; - const enableWebMCPRef = React.useRef(enableWebMCP); - enableWebMCPRef.current = enableWebMCP; + // the person's edits). Keyed on the normalized value, so a fresh option literal, a + // reordered `exclude`, or `undefined` vs `{ enabled: false }` never remounts; the + // effect reads the option through a ref so the literal itself stays out of its dependencies. + const webMCPOptions = normalizeWebMCPOptions(webMCP); + const webMCPKey = webMCPOptions.enabled ? `on:${[...webMCPOptions.exclude].sort().join(',')}` : 'off'; + const webMCPRef = React.useRef(webMCP); + webMCPRef.current = webMCP; React.useEffect(() => { const container = containerRef.current; @@ -226,7 +217,7 @@ const EmbedSurface = React.forwardRef((props, locale, context, logger: stableLogger, - enableWebMCP: enableWebMCPRef.current, + webMCP: webMCPRef.current, }); assignRef(ref, toEmbedActions(embed)); // Forward each editor event to onEmbedEvent as the verbatim { type, data }. The @@ -363,7 +354,7 @@ export const EmbedPDF = React.forwardRef((pr context={props.context} logger={props.logger} onEmbedEvent={props.onEmbedEvent} - enableWebMCP={props.enableWebMCP} + webMCP={props.webMCP} className="simplePDF_iframe" /> @@ -380,7 +371,7 @@ export const EmbedPDF = React.forwardRef((pr context={props.context} logger={props.logger} onEmbedEvent={props.onEmbedEvent} - enableWebMCP={props.enableWebMCP} + webMCP={props.webMCP} className={props.className} style={props.style} /> diff --git a/react/src/index.tsx b/react/src/index.tsx index 03cb65d9..bcbfaa34 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 { AgenticToolName, EmbedDocument, FieldType, OverlayToolType, WebMCPOptions } from '@simplepdf/embed'; +export type { EmbedDocument, FieldType, MethodName, OverlayToolType, WebMCPOptions } from '@simplepdf/embed'; From aa65b054d5be66576d2529984fffc35dc5ac60a9 Mon Sep 17 00:00:00 2001 From: ben Date: Sat, 12 Sep 2026 16:29:23 +0200 Subject: [PATCH 11/15] refactor: the WebMCP records carry their wire type, so the lazy module needs no OPERATIONS table The opt-in chunk dropped the shared operations table (a second copy of every description and the error-code arrays, 43% of its download) now that each generated record names the operation it dispatches to; its budget follows (4.5 KB). A map node with a required list fails the generator instead of losing the list; the option validator builds its error only when it throws; the protocol header scopes is_agentic_tool to the tool registries; the README recommends withholding loadDocument alongside submit; the React remount key's two equivalence pairs are pinned. --- .changeset/enable-webmcp.md | 2 +- embed/README.md | 11 +++++---- embed/scripts/generate.mjs | 20 +++++++++------ embed/scripts/lazy-chunks.mjs | 2 +- embed/src/generated/tools.ts | 2 +- embed/src/generated/webmcp-tools.ts | 38 +++++++++++++++-------------- embed/src/mount.ts | 15 ++++++------ embed/src/protocol.ts | 5 ++-- embed/src/webmcp.ts | 13 +++++----- react/src/embed-pdf.test.tsx | 9 +++++++ 10 files changed, 69 insertions(+), 48 deletions(-) diff --git a/.changeset/enable-webmcp.md b/.changeset/enable-webmcp.md index 9477f679..f370f32e 100644 --- a/.changeset/enable-webmcp.md +++ b/.changeset/enable-webmcp.md @@ -5,4 +5,4 @@ Add `webMCP`: 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({ webMCP: { enabled: true } })` and `` register every operation on the page's `document.modelContext` 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, a page render) goes to the agent runtime the person attached. Each tool is the record the editor publishes in its manifest and registers on its own page (the `simplepdf_embed_*` name, description, snake_case input schema and behavior hints), so a page gets the same tools whether the editor is embedded or opened directly. `exclude: ['submit', ...]` withholds operations by SDK method name so a person keeps the decision (a malformed value throws `EmbedConfigError`). Each call resolves with an MCP tool result carrying the editor's wire-shaped Result (`isError` on failure; the annotated page render as an `image` block); the editor validates each call like any other request; `dispose()` unregisters everything. Off by default (`{ enabled: false }` and omitting the option are the same): 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). +An in-browser agent (ChatGPT's browser, Chrome with WebMCP) discovers tools on the page it is looking at, not inside iframes. `createEmbed({ webMCP: { enabled: true } })` and `` register every operation (`loadDocument` included) on the page's `document.modelContext` 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, a page render) goes to the agent runtime the person attached. Each tool is the record the editor publishes in its manifest and registers on its own page (the `simplepdf_embed_*` name, description, snake_case input schema and behavior hints), so a page gets the same tools whether the editor is embedded or opened directly. `exclude: ['submit', ...]` withholds operations by SDK method name so a person keeps the decision (a malformed value throws `EmbedConfigError`). Each call resolves with an MCP tool result carrying the editor's wire-shaped Result (`isError` on failure; the annotated page render as an `image` block); the editor validates each call like any other request; `dispose()` unregisters everything. Off by default (`{ enabled: false }` and omitting the option are the same): 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 770b019a..2786f0c2 100644 --- a/embed/README.md +++ b/embed/README.md @@ -76,13 +76,14 @@ useChat({ connection, tools: createSimplePDFTools({ embed }) }) 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. `webMCP: { enabled: true }` 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 the readers (`simplepdf_embed_get_fields`, `simplepdf_embed_get_document_content`, `simplepdf_embed_get_annotated_page`: field values, extracted text, a page render) goes to the agent runtime the person attached, so treat that runtime as you would any other party that sees the filled document. ```ts -// 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) +// keep the decision with the person: withhold submit, the page operations and +// loadDocument (an agent could otherwise swap the document), 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 }, - webMCP: { enabled: true, exclude: ['submit', 'deletePages', 'movePage', 'rotatePage'] } }) + webMCP: { enabled: true, exclude: ['submit', 'loadDocument', 'deletePages', 'movePage', 'rotatePage'] } }) -// every operation +// every operation, loadDocument included (the editor registers it on its own page too) createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, webMCP: { enabled: true } }) ``` diff --git a/embed/scripts/generate.mjs b/embed/scripts/generate.mjs index 6dff0e41..acd4a97b 100644 --- a/embed/scripts/generate.mjs +++ b/embed/scripts/generate.mjs @@ -35,8 +35,9 @@ const contract = JSON.parse(readFileSync(join(PKG_ROOT, 'embed-api.json'), 'utf8 // `git diff --check` would flag). const renderFile = (lines) => `${lines.join('\n').replace(/\n+$/, '')}\n` -// Operations that exist on the wire but are NOT exposed as agentic tools. -// load_document is a host/setup action (the contract description says so). +// Operations withheld from the /tools, /ai-sdk and /tanstack-ai subpaths (the WebMCP +// surface registers all of them, like the editor): load_document is a host/setup action +// there (the contract description says so). const NON_AGENTIC_OPERATIONS = new Set(['load_document']) // --------------------------------------------------------------------------- @@ -93,6 +94,9 @@ const assertKnownKeywords = (node) => { `Unsupported 'additionalProperties' in ${JSON.stringify(node)} — only a schema on an object without 'properties' is honored (a map)`, ) } + if (isMapNode(node) && node.required !== undefined) { + throw new Error(`Unsupported 'required' on a map node ${JSON.stringify(node)} — a map has no fixed keys to require`) + } } // An object whose every key maps to one value schema (`{ additionalProperties: }` @@ -373,7 +377,7 @@ for (const event of contract.events) { } contractLines.push('') -// Operation metadata table (the camelCase `method` is the SDK method + agentic tool name). +// Operation metadata table (the camelCase `method` is the SDK method name, also the /tools tool name). const opMeta = contract.operations.map((op) => { const stem = toPascal(op.request_type) return ( @@ -475,7 +479,7 @@ const webmcpToolRecord = (op) => { throw new Error(`Unsupported tool annotation '${hint}' on ${op.request_type} — extend the generator to honor it`) } } - return { name, description, inputSchema, annotations } + return { name, description, inputSchema, annotations, wireType: op.request_type.toUpperCase() } } const webmcpToolLines = [] @@ -484,10 +488,12 @@ webmcpToolLines.push('// The WebMCP tool each operation publishes (the manifest webmcpToolLines.push('// input schema, behavior hints), verbatim, keyed by SDK method name so `webMCP.exclude`') webmcpToolLines.push('// maps straight onto it. The editor registers the same record on its own page. Read') webmcpToolLines.push('// only by src/webmcp.ts, which is lazy-loaded, so this table never lands in an entry') -webmcpToolLines.push('// that did not opt in.') -webmcpToolLines.push("import type { MethodName } from './contract'") +webmcpToolLines.push('// that did not opt in. `wireType` is the operation the record dispatches to, carried') +webmcpToolLines.push('// here so the lazy module needs nothing from the OPERATIONS table.') +webmcpToolLines.push("import type { MethodName, WireType } from './contract'") webmcpToolLines.push('') webmcpToolLines.push('export type WebMCPToolRecord = {') +webmcpToolLines.push(' readonly wireType: WireType') webmcpToolLines.push(' readonly name: string') webmcpToolLines.push(' readonly description: string') webmcpToolLines.push(' readonly inputSchema: {') @@ -552,7 +558,7 @@ toolLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. D toolLines.push("import * as Schemas from './schemas'") toolLines.push('') toolLines.push('// The agentic tool registry. Each tool name is the camelCase operation name;') -toolLines.push('// load_document is excluded (it is a host/setup action, not an agentic tool).') +toolLines.push('// load_document is excluded here (a host/setup action; the WebMCP surface registers it).') toolLines.push('export const TOOL_DEFINITIONS = {') for (const op of agenticOperations) { const stem = toPascal(op.request_type) diff --git a/embed/scripts/lazy-chunks.mjs b/embed/scripts/lazy-chunks.mjs index b9a25e57..4f3fd2dd 100644 --- a/embed/scripts/lazy-chunks.mjs +++ b/embed/scripts/lazy-chunks.mjs @@ -2,5 +2,5 @@ // 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: 7.5 * 1024, exportName: 'registerWebMCPTools' }, + 'webmcp-': { budgetBytes: 4.5 * 1024, exportName: 'registerWebMCPTools' }, } diff --git a/embed/src/generated/tools.ts b/embed/src/generated/tools.ts index 2ad238f3..5623e3db 100644 --- a/embed/src/generated/tools.ts +++ b/embed/src/generated/tools.ts @@ -2,7 +2,7 @@ import * as Schemas from './schemas' // The agentic tool registry. Each tool name is the camelCase operation name; -// load_document is excluded (it is a host/setup action, not an agentic tool). +// load_document is excluded here (a host/setup action; the WebMCP surface registers it). export const TOOL_DEFINITIONS = { 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.", inputSchema: Schemas.CreateFieldInput }, 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.", inputSchema: Schemas.DeleteFieldsInput }, diff --git a/embed/src/generated/webmcp-tools.ts b/embed/src/generated/webmcp-tools.ts index ab2977a2..3c4376a9 100644 --- a/embed/src/generated/webmcp-tools.ts +++ b/embed/src/generated/webmcp-tools.ts @@ -3,10 +3,12 @@ // input schema, behavior hints), verbatim, keyed by SDK method name so `webMCP.exclude` // maps straight onto it. The editor registers the same record on its own page. 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 { MethodName } from './contract' +// that did not opt in. `wireType` is the operation the record dispatches to, carried +// here so the lazy module needs nothing from the OPERATIONS table. +import type { MethodName, WireType } from './contract' export type WebMCPToolRecord = { + readonly wireType: WireType readonly name: string readonly description: string readonly inputSchema: { @@ -23,20 +25,20 @@ export type WebMCPToolRecord = { } export const WEBMCP_TOOLS = { - createField: {"name":"simplepdf_embed_create_field","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.","inputSchema":{"type":"object","properties":{"type":{"enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"],"description":"Field type to create.","type":"string"},"x":{"description":"Field x position, in PDF points.","type":"number"},"y":{"description":"Field y position, in PDF points.","type":"number"},"width":{"description":"Field width, in PDF points.","type":"number"},"height":{"description":"Field height, in PDF points.","type":"number"},"page":{"description":"1-based page to place the field on.","type":"integer"},"value":{"description":"Optional initial value. A string for text/checkbox fields, or a data URL or http(s) URL (fetched by the editor) for signature/picture fields.","type":"string"}},"required":["type","x","y","width","height","page"]},"annotations":{"destructiveHint":false,"openWorldHint":true}}, - deleteFields: {"name":"simplepdf_embed_delete_fields","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.","inputSchema":{"type":"object","properties":{"field_ids":{"description":"IDs of the fields to delete. Omit to delete every field on the target page.","items":{"type":"string"},"type":"array"},"page":{"description":"1-based page to scope the deletion to. Omit to target all pages.","type":"integer"}}},"annotations":{"destructiveHint":true}}, - deletePages: {"name":"simplepdf_embed_delete_pages","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.","inputSchema":{"type":"object","properties":{"pages":{"items":{"type":"integer"},"description":"1-based page numbers to delete.","type":"array"}},"required":["pages"]},"annotations":{"destructiveHint":true}}, - detectFields: {"name":"simplepdf_embed_detect_fields","description":"Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled.","inputSchema":{"type":"object","properties":{}},"annotations":{"destructiveHint":false}}, - download: {"name":"simplepdf_embed_download","description":"Generate and download the current document as a PDF. Returns no data.","inputSchema":{"type":"object","properties":{}},"annotations":{"destructiveHint":false}}, - focusField: {"name":"simplepdf_embed_focus_field","description":"Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next.","inputSchema":{"type":"object","properties":{"field_id":{"description":"ID of the field to focus and scroll into view.","type":"string"}},"required":["field_id"]},"annotations":{"destructiveHint":false}}, - getAnnotatedPage: {"name":"simplepdf_embed_get_annotated_page","description":"Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to render, at its current position.","type":"integer"}},"required":["page"]},"annotations":{"readOnlyHint":true,"untrustedContentHint":true}}, - getDocumentContent: {"name":"simplepdf_embed_get_document_content","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 }] }.","inputSchema":{"type":"object","properties":{"extraction_mode":{"description":"Extraction strategy: 'auto' (default) or 'ocr' to force optical recognition.","enum":["auto","ocr"],"type":"string"}}},"annotations":{"readOnlyHint":true,"untrustedContentHint":true}}, - getFields: {"name":"simplepdf_embed_get_fields","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. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true,"untrustedContentHint":true}}, - goTo: {"name":"simplepdf_embed_go_to","description":"Scroll the editor to a specific 1-based page. Returns no data.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to navigate to.","type":"integer"}},"required":["page"]},"annotations":{"destructiveHint":false}}, - loadDocument: {"name":"simplepdf_embed_load_document","description":"Replace the document in the editor with one supplied as a base64 data URL or an http(s) URL the editor fetches. Destructive: the current document and every edit in it are discarded. Returns no data.","inputSchema":{"type":"object","properties":{"data_url":{"description":"The document to load: a data URL, or an http(s) URL the editor fetches.","type":"string"},"name":{"description":"Optional display name for the document.","type":"string"},"page":{"description":"Optional 1-based page to open the document on.","type":"integer"}},"required":["data_url"]},"annotations":{"destructiveHint":true,"openWorldHint":true}}, - movePage: {"name":"simplepdf_embed_move_page","description":"Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled.","inputSchema":{"type":"object","properties":{"from_page":{"description":"1-based current position of the page to move.","type":"integer"},"to_page":{"description":"1-based destination position for the page.","type":"integer"}},"required":["from_page","to_page"]},"annotations":{"destructiveHint":true}}, - rotatePage: {"name":"simplepdf_embed_rotate_page","description":"Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to rotate 90 degrees clockwise.","type":"integer"}},"required":["page"]},"annotations":{"destructiveHint":true}}, - selectTool: {"name":"simplepdf_embed_select_tool","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.","inputSchema":{"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"]},"annotations":{"destructiveHint":false}}, - setFieldValue: {"name":"simplepdf_embed_set_field_value","description":"Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Returns no data.","inputSchema":{"type":"object","properties":{"field_id":{"description":"ID of the field to update.","type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."}},"required":["field_id","value"]},"annotations":{"destructiveHint":false,"openWorldHint":true}}, - submit: {"name":"simplepdf_embed_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.","inputSchema":{"type":"object","properties":{"download_copy":{"description":"When true, the signer also receives a downloaded copy on submit.","type":"boolean"}},"required":["download_copy"]},"annotations":{"destructiveHint":true}}, + createField: {"name":"simplepdf_embed_create_field","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.","inputSchema":{"type":"object","properties":{"type":{"enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"],"description":"Field type to create.","type":"string"},"x":{"description":"Field x position, in PDF points.","type":"number"},"y":{"description":"Field y position, in PDF points.","type":"number"},"width":{"description":"Field width, in PDF points.","type":"number"},"height":{"description":"Field height, in PDF points.","type":"number"},"page":{"description":"1-based page to place the field on.","type":"integer"},"value":{"description":"Optional initial value. A string for text/checkbox fields, or a data URL or http(s) URL (fetched by the editor) for signature/picture fields.","type":"string"}},"required":["type","x","y","width","height","page"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"CREATE_FIELD"}, + deleteFields: {"name":"simplepdf_embed_delete_fields","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.","inputSchema":{"type":"object","properties":{"field_ids":{"description":"IDs of the fields to delete. Omit to delete every field on the target page.","items":{"type":"string"},"type":"array"},"page":{"description":"1-based page to scope the deletion to. Omit to target all pages.","type":"integer"}}},"annotations":{"destructiveHint":true},"wireType":"DELETE_FIELDS"}, + deletePages: {"name":"simplepdf_embed_delete_pages","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.","inputSchema":{"type":"object","properties":{"pages":{"items":{"type":"integer"},"description":"1-based page numbers to delete.","type":"array"}},"required":["pages"]},"annotations":{"destructiveHint":true},"wireType":"DELETE_PAGES"}, + detectFields: {"name":"simplepdf_embed_detect_fields","description":"Automatically detect fillable fields in the loaded document and add them as editable fields. Returns { detected_count }. Requires editing to be enabled.","inputSchema":{"type":"object","properties":{}},"annotations":{"destructiveHint":false},"wireType":"DETECT_FIELDS"}, + download: {"name":"simplepdf_embed_download","description":"Generate and download the current document as a PDF. Returns no data.","inputSchema":{"type":"object","properties":{}},"annotations":{"destructiveHint":false},"wireType":"DOWNLOAD"}, + focusField: {"name":"simplepdf_embed_focus_field","description":"Scroll an existing field into view and focus it, addressed by its id (from the field list). Returns a hint describing the user action expected next.","inputSchema":{"type":"object","properties":{"field_id":{"description":"ID of the field to focus and scroll into view.","type":"string"}},"required":["field_id"]},"annotations":{"destructiveHint":false},"wireType":"FOCUS_FIELD"}, + getAnnotatedPage: {"name":"simplepdf_embed_get_annotated_page","description":"Render a page as a PNG with every field on it outlined and numbered, so a vision model can SEE which field sits where on the printed form. Feed the image and the badges map to a multimodal model to label fields; get_fields returns the matching ids. The render shows the printed form and field placement, not filled-in values (read those with get_fields). Returns { page, image_data_url, image_width, image_height, badges } where badges maps each number drawn on the image to its field_id. It renders document content, so it is gated exactly like get_document_content: the embedding origin must be whitelisted for the tenant.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to render, at its current position.","type":"integer"}},"required":["page"]},"annotations":{"readOnlyHint":true,"untrustedContentHint":true},"wireType":"GET_ANNOTATED_PAGE"}, + getDocumentContent: {"name":"simplepdf_embed_get_document_content","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 }] }.","inputSchema":{"type":"object","properties":{"extraction_mode":{"description":"Extraction strategy: 'auto' (default) or 'ocr' to force optical recognition.","enum":["auto","ocr"],"type":"string"}}},"annotations":{"readOnlyHint":true,"untrustedContentHint":true},"wireType":"GET_DOCUMENT_CONTENT"}, + getFields: {"name":"simplepdf_embed_get_fields","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. To SEE where each field sits on the printed page, call get_annotated_page. Returns { fields }.","inputSchema":{"type":"object","properties":{}},"annotations":{"readOnlyHint":true,"untrustedContentHint":true},"wireType":"GET_FIELDS"}, + goTo: {"name":"simplepdf_embed_go_to","description":"Scroll the editor to a specific 1-based page. Returns no data.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to navigate to.","type":"integer"}},"required":["page"]},"annotations":{"destructiveHint":false},"wireType":"GO_TO"}, + loadDocument: {"name":"simplepdf_embed_load_document","description":"Replace the document in the editor with one supplied as a base64 data URL or an http(s) URL the editor fetches. Destructive: the current document and every edit in it are discarded. Returns no data.","inputSchema":{"type":"object","properties":{"data_url":{"description":"The document to load: a data URL, or an http(s) URL the editor fetches.","type":"string"},"name":{"description":"Optional display name for the document.","type":"string"},"page":{"description":"Optional 1-based page to open the document on.","type":"integer"}},"required":["data_url"]},"annotations":{"destructiveHint":true,"openWorldHint":true},"wireType":"LOAD_DOCUMENT"}, + movePage: {"name":"simplepdf_embed_move_page","description":"Move a page from one 1-based position to another, reordering the document. Returns no data. Destructive; requires editing to be enabled.","inputSchema":{"type":"object","properties":{"from_page":{"description":"1-based current position of the page to move.","type":"integer"},"to_page":{"description":"1-based destination position for the page.","type":"integer"}},"required":["from_page","to_page"]},"annotations":{"destructiveHint":true},"wireType":"MOVE_PAGE"}, + rotatePage: {"name":"simplepdf_embed_rotate_page","description":"Rotate a 1-based page 90 degrees clockwise. Returns no data. Destructive; requires editing to be enabled.","inputSchema":{"type":"object","properties":{"page":{"description":"1-based page to rotate 90 degrees clockwise.","type":"integer"}},"required":["page"]},"annotations":{"destructiveHint":true},"wireType":"ROTATE_PAGE"}, + selectTool: {"name":"simplepdf_embed_select_tool","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.","inputSchema":{"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"]},"annotations":{"destructiveHint":false},"wireType":"SELECT_TOOL"}, + setFieldValue: {"name":"simplepdf_embed_set_field_value","description":"Set the value of an existing field addressed by its id (from the field list), or clear it with null. If the field has options (see the field list), value must be one of them; otherwise value is a string (text or checkbox value) or a data URL or http(s) URL the editor fetches (signature, picture). Returns no data.","inputSchema":{"type":"object","properties":{"field_id":{"description":"ID of the field to update.","type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see the field list), it must be one of them; otherwise a string (text/checkbox) or a data URL or http(s) URL, fetched by the editor (signature/picture)."}},"required":["field_id","value"]},"annotations":{"destructiveHint":false,"openWorldHint":true},"wireType":"SET_FIELD_VALUE"}, + submit: {"name":"simplepdf_embed_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.","inputSchema":{"type":"object","properties":{"download_copy":{"description":"When true, the signer also receives a downloaded copy on submit.","type":"boolean"}},"required":["download_copy"]},"annotations":{"destructiveHint":true},"wireType":"SUBMIT"}, } as const satisfies Record diff --git a/embed/src/mount.ts b/embed/src/mount.ts index a9c4e56a..0fbf3bfa 100644 --- a/embed/src/mount.ts +++ b/embed/src/mount.ts @@ -210,24 +210,25 @@ const assertValidWebMCPOptions = (webMCP: unknown): void => { if (webMCP === undefined) { return } - const shapeError = new EmbedConfigError( - 'invalid_config', - `webMCP must be { enabled: false } or { enabled: true, exclude?: MethodName[] } (received ${describeValue(webMCP)}).`, - ) + const shapeError = (): EmbedConfigError => + new EmbedConfigError( + 'invalid_config', + `webMCP must be { enabled: false } or { enabled: true, exclude?: MethodName[] } (received ${describeValue(webMCP)}).`, + ) const isObject = typeof webMCP === 'object' && webMCP !== null if (!isObject || !('enabled' in webMCP) || typeof webMCP.enabled !== 'boolean') { - throw shapeError + throw shapeError() } const exclude = 'exclude' in webMCP ? webMCP.exclude : undefined if (exclude === undefined) { return } if (!Array.isArray(exclude)) { - throw shapeError + throw shapeError() } const entries: unknown[] = exclude if (!entries.every((name): name is string => typeof name === 'string')) { - throw shapeError + throw shapeError() } const unknownNames = entries.filter((name) => !METHOD_NAME_SET.has(name)) if (unknownNames.length > 0) { diff --git a/embed/src/protocol.ts b/embed/src/protocol.ts index 214de241..7ae05b0f 100644 --- a/embed/src/protocol.ts +++ b/embed/src/protocol.ts @@ -1,7 +1,8 @@ // Wire protocol constants: the operation + outbound-event vocabulary, generated // from embed-api.json (the editor iframe lib is the source). The REQUEST_RESULT -// reply envelope is not an event and lives with the bridge. Zero runtime -// dependencies. +// reply envelope is not an event and lives with the bridge. `is_agentic_tool` +// scopes the /tools, /ai-sdk and /tanstack-ai registries; the WebMCP surface +// registers every operation. Zero runtime dependencies. import { OPERATIONS, OUTBOUND_EVENTS } from './generated/contract' diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts index 6287c83b..48237b5f 100644 --- a/embed/src/webmcp.ts +++ b/embed/src/webmcp.ts @@ -11,7 +11,8 @@ // table it reads) is downloaded otherwise. // CF: https://webmachinelearning.github.io/webmcp/ -import { OPERATIONS, type MethodName, type WireType } from './generated/contract' +import type { MethodName, WireType } from './generated/contract' +import { METHOD_NAMES } from './generated/method-names' import { WEBMCP_TOOLS, type WebMCPToolRecord } from './generated/webmcp-tools' import type { BridgeLogger } from './logger' import type { BridgeResult } from './types' @@ -23,7 +24,7 @@ import { modelContextCandidates } from './webmcp-shared' // Result additionally flagged `isError`, a page render carried as an `image` block. type ToolContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: 'image/png' } type CallToolResult = { content: ToolContent[]; isError?: boolean } -type WebMCPTool = WebMCPToolRecord & { execute: (input: unknown) => Promise } +type WebMCPTool = Omit & { execute: (input: unknown) => Promise } type ModelContext = { registerTool: (tool: WebMCPTool, options: { signal: AbortSignal }) => unknown } @@ -101,11 +102,11 @@ export const registerWebMCPTools = ({ return false } const excluded = new Set(exclude) - for (const operation of OPERATIONS) { - if (excluded.has(operation.method)) { + for (const method of METHOD_NAMES) { + if (excluded.has(method)) { continue } - const record = WEBMCP_TOOLS[operation.method] + const record = WEBMCP_TOOLS[method] if (liveTools.has(record.name)) { logger.warn('webmcp.tool_already_registered', { tool: record.name }) continue @@ -116,7 +117,7 @@ export const registerWebMCPTools = ({ inputSchema: record.inputSchema, annotations: record.annotations, // A nullish input becomes an empty payload (the no-input operations' wire shape). - execute: async (input) => toCallToolResult(operation.wire_type, await dispatch(operation.wire_type, input ?? {})), + execute: async (input) => toCallToolResult(record.wireType, await dispatch(record.wireType, input ?? {})), } liveTools.set(tool.name, signal) signal.addEventListener('abort', () => freeTool(tool.name, signal), { once: true }) diff --git a/react/src/embed-pdf.test.tsx b/react/src/embed-pdf.test.tsx index 4d472269..5582748d 100644 --- a/react/src/embed-pdf.test.tsx +++ b/react/src/embed-pdf.test.tsx @@ -57,6 +57,15 @@ describe('EmbedPDF (inline)', () => { const remounted = container.querySelector('iframe'); expect(remounted).not.toBeNull(); expect(remounted).not.toBe(iframe); + + // The two equivalent spellings of each state never remount. + rerender(); + expect(container.querySelector('iframe')).toBe(remounted); + rerender(); + const off = container.querySelector('iframe'); + expect(off).not.toBe(remounted); + rerender(); + expect(container.querySelector('iframe')).toBe(off); }); it('renders the editor iframe inside the host element for the companyIdentifier origin', () => { From 7ea2b7027a676af6cfb88fda0d2ae8c363658a9d Mon Sep 17 00:00:00 2001 From: ben Date: Sat, 12 Sep 2026 16:34:50 +0200 Subject: [PATCH 12/15] chore: restore the documented headroom on the root and WebMCP bundle budgets The root grew 197 B gzip for the 16th operation, the manifest lifecycle events and the result-shape plumbing, leaving 68 B under its cap against the file's 0.5-1.5 KB convention; the caps follow the measured sizes here, where the growth is explained. --- embed/scripts/check-bundle-size.mjs | 2 +- embed/scripts/lazy-chunks.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/embed/scripts/check-bundle-size.mjs b/embed/scripts/check-bundle-size.mjs index fcfd2f56..2d436271 100644 --- a/embed/scripts/check-bundle-size.mjs +++ b/embed/scripts/check-bundle-size.mjs @@ -18,7 +18,7 @@ const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist') // The zero-dep root carries the bridge + createEmbed (create + attach paths) + its // actionable config validation + the WebMCP opt-in hook. const BUDGETS = { - 'index.js': 9 * 1024, + 'index.js': 10 * 1024, 'protocol.js': 3.5 * 1024, 'schemas.js': 3 * 1024, 'tools.js': 5 * 1024, diff --git a/embed/scripts/lazy-chunks.mjs b/embed/scripts/lazy-chunks.mjs index 4f3fd2dd..24e8d809 100644 --- a/embed/scripts/lazy-chunks.mjs +++ b/embed/scripts/lazy-chunks.mjs @@ -2,5 +2,5 @@ // 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: 4.5 * 1024, exportName: 'registerWebMCPTools' }, + 'webmcp-': { budgetBytes: 5 * 1024, exportName: 'registerWebMCPTools' }, } From 41f08192f6b0562496f74f2e37ed6a9e631fd74c Mon Sep 17 00:00:00 2001 From: ben Date: Sat, 12 Sep 2026 16:49:35 +0200 Subject: [PATCH 13/15] fix: a misspelled webMCP key fails loud; the annotation type derives from the hint set; scope the in-tab claim An unknown key on the webMCP option throws at createEmbed, so { enabled: true, exlude: [...] } can no longer read as nothing withheld. The generated annotation type lists the same hint names the generator validates. The README and changeset state what stays in the tab: editing, until a submit call sends the document through the tenant's submission flow. --- .changeset/enable-webmcp.md | 2 +- embed/README.md | 2 +- embed/scripts/generate.mjs | 7 +++---- embed/src/mount.ts | 9 +++++++++ embed/test/mount.test.ts | 7 +++++++ 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.changeset/enable-webmcp.md b/.changeset/enable-webmcp.md index f370f32e..be2a7352 100644 --- a/.changeset/enable-webmcp.md +++ b/.changeset/enable-webmcp.md @@ -5,4 +5,4 @@ Add `webMCP`: 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({ webMCP: { enabled: true } })` and `` register every operation (`loadDocument` included) on the page's `document.modelContext` 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, a page render) goes to the agent runtime the person attached. Each tool is the record the editor publishes in its manifest and registers on its own page (the `simplepdf_embed_*` name, description, snake_case input schema and behavior hints), so a page gets the same tools whether the editor is embedded or opened directly. `exclude: ['submit', ...]` withholds operations by SDK method name so a person keeps the decision (a malformed value throws `EmbedConfigError`). Each call resolves with an MCP tool result carrying the editor's wire-shaped Result (`isError` on failure; the annotated page render as an `image` block); the editor validates each call like any other request; `dispose()` unregisters everything. Off by default (`{ enabled: false }` and omitting the option are the same): 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). +An in-browser agent (ChatGPT's browser, Chrome with WebMCP) discovers tools on the page it is looking at, not inside iframes. `createEmbed({ webMCP: { enabled: true } })` and `` register every operation (`loadDocument` included) on the page's `document.modelContext` and forward each call to the editor over the bridge: editing stays in the tab (a `submit` call sends the document through the tenant's submission flow, SimplePDF-managed storage or the configured BYOS bucket), while what the agent reads (field values, extracted text, a page render) goes to the agent runtime the person attached. Each tool is the record the editor publishes in its manifest and registers on its own page (the `simplepdf_embed_*` name, description, snake_case input schema and behavior hints), so a page gets the same tools whether the editor is embedded or opened directly. `exclude: ['submit', ...]` withholds operations by SDK method name so a person keeps the decision (a malformed value throws `EmbedConfigError`). Each call resolves with an MCP tool result carrying the editor's wire-shaped Result (`isError` on failure; the annotated page render as an `image` block); the editor validates each call like any other request; `dispose()` unregisters everything. Off by default (`{ enabled: false }` and omitting the option are the same): 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 2786f0c2..6ba4cb73 100644 --- a/embed/README.md +++ b/embed/README.md @@ -73,7 +73,7 @@ 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. `webMCP: { enabled: true }` 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 the readers (`simplepdf_embed_get_fields`, `simplepdf_embed_get_document_content`, `simplepdf_embed_get_annotated_page`: field values, extracted text, a page render) goes to the agent runtime the person attached, so treat that runtime as you would any other party that sees the filled document. +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. `webMCP: { enabled: true }` registers the editor's operations on **your** page's `document.modelContext`, forwarding each call to the editor over the bridge. Editing stays in the tab (no SimplePDF server sees the document until `submit`, which sends it through the tenant's submission flow: SimplePDF-managed storage, or the BYOS bucket the tenant configured); what the agent reads through the readers (`simplepdf_embed_get_fields`, `simplepdf_embed_get_document_content`, `simplepdf_embed_get_annotated_page`: field values, extracted text, a page render) goes to the agent runtime the person attached, so treat that runtime as you would any other party that sees the filled document. ```ts // keep the decision with the person: withhold submit, the page operations and diff --git a/embed/scripts/generate.mjs b/embed/scripts/generate.mjs index acd4a97b..bf69e4f1 100644 --- a/embed/scripts/generate.mjs +++ b/embed/scripts/generate.mjs @@ -502,10 +502,9 @@ webmcpToolLines.push(' readonly properties?: Readonly webmcpToolLines.push(' readonly required?: readonly string[]') webmcpToolLines.push(' }') webmcpToolLines.push(' readonly annotations: {') -webmcpToolLines.push(' readonly destructiveHint?: boolean') -webmcpToolLines.push(' readonly openWorldHint?: boolean') -webmcpToolLines.push(' readonly readOnlyHint?: boolean') -webmcpToolLines.push(' readonly untrustedContentHint?: boolean') +for (const hint of WEBMCP_ANNOTATION_KEYS) { + webmcpToolLines.push(` readonly ${hint}?: boolean`) +} webmcpToolLines.push(' }') webmcpToolLines.push('}') webmcpToolLines.push('') diff --git a/embed/src/mount.ts b/embed/src/mount.ts index 0fbf3bfa..b0952dce 100644 --- a/embed/src/mount.ts +++ b/embed/src/mount.ts @@ -202,6 +202,7 @@ const assertValidFileArm = (file: unknown): void => { } const METHOD_NAME_SET: ReadonlySet = new Set(METHOD_NAMES) +const WEBMCP_OPTION_KEYS: ReadonlySet = new Set(['enabled', 'exclude'] satisfies Array>) // `webMCP.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 @@ -219,6 +220,14 @@ const assertValidWebMCPOptions = (webMCP: unknown): void => { if (!isObject || !('enabled' in webMCP) || typeof webMCP.enabled !== 'boolean') { throw shapeError() } + // A misspelled `exclude` key would read as "nothing withheld"; only the two known keys pass. + const unknownKeys = Object.keys(webMCP).filter((key) => !WEBMCP_OPTION_KEYS.has(key)) + if (unknownKeys.length > 0) { + throw new EmbedConfigError( + 'invalid_config', + `webMCP has no option ${unknownKeys.join(', ')} (known: ${[...WEBMCP_OPTION_KEYS].join(', ')}).`, + ) + } const exclude = 'exclude' in webMCP ? webMCP.exclude : undefined if (exclude === undefined) { return diff --git a/embed/test/mount.test.ts b/embed/test/mount.test.ts index cf52100c..8770d2ea 100644 --- a/embed/test/mount.test.ts +++ b/embed/test/mount.test.ts @@ -403,6 +403,13 @@ describe(createEmbed.name, () => { ) }) + it('throws EmbedConfigError when the exclude key itself is misspelled, so the typo cannot read as "nothing withheld"', () => { + document.body.innerHTML = '
' + const misspelledKey: unknown = { target: '#root', companyIdentifier: 'acme', webMCP: { enabled: true, exlude: ['submit'] } } + // @ts-expect-error exercising the runtime guard for untyped JS callers + expect(() => createEmbed(misspelledKey)).toThrow(/webMCP has no option exlude \(known: enabled, exclude\)/) + }) + 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', webMCP: { enabled: true, exclude: ['sumbit'] } } From 2a8f9edb04fafbed039c4dc5c4442e14f61f6614 Mon Sep 17 00:00:00 2001 From: ben Date: Sun, 13 Sep 2026 14:53:06 +0200 Subject: [PATCH 14/15] refactor: the tool-result envelope is chosen per operation in an exhaustive switch A new operation now has to state at this site whether its result is text or a picture; the single-operation predicate let it take the text envelope unnoticed. --- embed/src/webmcp.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts index 48237b5f..15236302 100644 --- a/embed/src/webmcp.ts +++ b/embed/src/webmcp.ts @@ -64,8 +64,33 @@ const toAnnotatedPageToolResult = (result: BridgeResult): CallToolResul } } -const toCallToolResult = (wireType: WireType, result: BridgeResult): CallToolResult => - wireType === 'GET_ANNOTATED_PAGE' ? toAnnotatedPageToolResult(result) : toTextToolResult(result) +// One arm per operation on purpose: a new operation must decide here whether its result +// is text or a picture instead of taking the text envelope unnoticed. +const toCallToolResult = (wireType: WireType, result: BridgeResult): CallToolResult => { + switch (wireType) { + case 'GET_ANNOTATED_PAGE': + return toAnnotatedPageToolResult(result) + case 'CREATE_FIELD': + case 'DELETE_FIELDS': + case 'DELETE_PAGES': + case 'DETECT_FIELDS': + case 'DOWNLOAD': + case 'FOCUS_FIELD': + case 'GET_DOCUMENT_CONTENT': + case 'GET_FIELDS': + case 'GO_TO': + case 'LOAD_DOCUMENT': + case 'MOVE_PAGE': + case 'ROTATE_PAGE': + case 'SELECT_TOOL': + case 'SET_FIELD_VALUE': + case 'SUBMIT': + return toTextToolResult(result) + default: + wireType satisfies never + return toTextToolResult(result) + } +} // 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. From acbb9f97f4ffe33c7396586d4cd1fb527c4f6239 Mon Sep 17 00:00:00 2001 From: ben Date: Sun, 13 Sep 2026 15:10:06 +0200 Subject: [PATCH 15/15] fix: the data-path claim states what runs in the browser; an aborted tool call never posts; docs and stubs follow the 16-op contract The README and changeset no longer say the document reaches no SimplePDF storage until submit (a default embed uploads it to the configured storage at load); they state what is true: every operation runs in the browser, nothing the agent reads is computed server-side, and storage follows the account's configuration (SimplePDF-managed, S3, Azure Blob Storage or SharePoint) exactly as without WebMCP. A WebMCP call whose signal is already aborted rejects before reaching the editor. The changelog names the loadDocument contract change the manifest sync brought in; the shared actions stub and the tool router test cover getAnnotatedPage; the option-key guard is a real drift guard; the contract header names both root imports; the build-with-simplepdf skill documents webMCP. --- .changeset/enable-webmcp.md | 6 +++--- .changeset/get-annotated-page.md | 4 +++- embed/README.md | 17 +++++++++++++---- embed/scripts/generate.mjs | 5 +++-- embed/src/generated/contract.ts | 2 +- embed/src/mount.ts | 4 +++- embed/src/protocol.ts | 4 ++-- embed/src/webmcp.ts | 13 ++++++++++--- embed/test/helpers.ts | 1 + embed/test/tools.test.ts | 6 ++++++ embed/test/webmcp.test.ts | 19 ++++++++++++++++++- skills/build-with-simplepdf/SKILL.md | 2 ++ 12 files changed, 65 insertions(+), 18 deletions(-) diff --git a/.changeset/enable-webmcp.md b/.changeset/enable-webmcp.md index be2a7352..f64ca3a9 100644 --- a/.changeset/enable-webmcp.md +++ b/.changeset/enable-webmcp.md @@ -1,8 +1,8 @@ --- -"@simplepdf/embed": minor -"@simplepdf/react-embed-pdf": minor +'@simplepdf/embed': minor +'@simplepdf/react-embed-pdf': minor --- Add `webMCP`: 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({ webMCP: { enabled: true } })` and `` register every operation (`loadDocument` included) on the page's `document.modelContext` and forward each call to the editor over the bridge: editing stays in the tab (a `submit` call sends the document through the tenant's submission flow, SimplePDF-managed storage or the configured BYOS bucket), while what the agent reads (field values, extracted text, a page render) goes to the agent runtime the person attached. Each tool is the record the editor publishes in its manifest and registers on its own page (the `simplepdf_embed_*` name, description, snake_case input schema and behavior hints), so a page gets the same tools whether the editor is embedded or opened directly. `exclude: ['submit', ...]` withholds operations by SDK method name so a person keeps the decision (a malformed value throws `EmbedConfigError`). Each call resolves with an MCP tool result carrying the editor's wire-shaped Result (`isError` on failure; the annotated page render as an `image` block); the editor validates each call like any other request; `dispose()` unregisters everything. Off by default (`{ enabled: false }` and omitting the option are the same): 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). +An in-browser agent (ChatGPT's browser, Chrome with WebMCP) discovers tools on the page it is looking at, not inside iframes. `createEmbed({ webMCP: { enabled: true } })` and `` register every operation (`loadDocument` included) on the page's model context and forward each call to the editor over the bridge. Each tool is the record the editor publishes in its manifest and registers on its own page (the `simplepdf_embed_*` name, description, snake_case input schema and behavior hints), so a page gets the same tools whether the editor is embedded or opened directly. `exclude: ['submit', ...]` withholds operations by SDK method name so a person keeps the decision; a malformed value, an unknown key or an unknown name throws `EmbedConfigError`. Every operation runs in the browser and nothing the agent reads is computed server-side; document storage follows your account's configuration exactly as it does without WebMCP. Each call resolves with an MCP tool result carrying the editor's wire-shaped Result (`isError` on failure; the annotated page render as an `image` block); a call aborted before it ran rejects; `dispose()` unregisters everything. Off by default (`{ enabled: false }` and omitting the option are the same): the WebMCP module loads lazily, once the editor is ready and only when the page exposes a model context. One WebMCP-enabled embed per page (tool names are page-level). diff --git a/.changeset/get-annotated-page.md b/.changeset/get-annotated-page.md index 6de9f740..93838222 100644 --- a/.changeset/get-annotated-page.md +++ b/.changeset/get-annotated-page.md @@ -3,4 +3,6 @@ '@simplepdf/react-embed-pdf': minor --- -Add `getAnnotatedPage({ page })` (the editor's `GET_ANNOTATED_PAGE`): a PNG render of one page with every field outlined and numbered, plus a `badges` map from each number to its `field_id`, so a vision model can label fields by looking at the printed form. Available as `embed.actions.getAnnotatedPage` / `useEmbed().actions.getAnnotatedPage`, as the `getAnnotatedPage` agentic tool on every tool subpath, and as a WebMCP tool (a reader: `readOnlyHint` + `untrustedContentHint`). The contract pin follows the live manifest (`GET_FIELDS` now points agents at `get_annotated_page`). +Add `getAnnotatedPage({ page })` (the editor's `GET_ANNOTATED_PAGE`): a PNG render of one page with every field outlined and numbered, plus a `badges` map from each number to its `field_id`, so a vision model can label fields by looking at the printed form. Available as `embed.actions.getAnnotatedPage` / `useEmbed().actions.getAnnotatedPage`, as the `getAnnotatedPage` agentic tool on every tool subpath, and as a WebMCP tool (a reader: `readOnlyHint` + `untrustedContentHint`). + +The contract pin follows the live manifest: `loadDocument` also accepts an http(s) URL the editor fetches and its description states that it discards the current document and every edit in it; `getFields` points agents at `get_annotated_page`. diff --git a/embed/README.md b/embed/README.md index 6ba4cb73..4787cbf9 100644 --- a/embed/README.md +++ b/embed/README.md @@ -73,21 +73,30 @@ 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. `webMCP: { enabled: true }` registers the editor's operations on **your** page's `document.modelContext`, forwarding each call to the editor over the bridge. Editing stays in the tab (no SimplePDF server sees the document until `submit`, which sends it through the tenant's submission flow: SimplePDF-managed storage, or the BYOS bucket the tenant configured); what the agent reads through the readers (`simplepdf_embed_get_fields`, `simplepdf_embed_get_document_content`, `simplepdf_embed_get_annotated_page`: field values, extracted text, a page render) goes to the agent runtime the person attached, so treat that runtime as you would any other party that sees the filled document. +An agent running in the user's browser (ChatGPT's browser, Chrome with [WebMCP](https://webmachinelearning.github.io/webmcp/)) discovers tools on the page it is looking at, not inside iframes. `webMCP: { enabled: true }` registers the editor's operations on **your** page's model context (`document.modelContext`, or the older `navigator.modelContext`), forwarding each call to the editor over the bridge. ```ts // keep the decision with the person: withhold submit, the page operations and // loadDocument (an agent could otherwise swap the document), 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 }, +createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url: 'https://example.com/form.pdf' }, webMCP: { enabled: true, exclude: ['submit', 'loadDocument', 'deletePages', 'movePage', 'rotatePage'] } }) // every operation, loadDocument included (the editor registers it on its own page too) -createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, webMCP: { enabled: true } }) +createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url: 'https://example.com/form.pdf' }, webMCP: { enabled: true } }) ``` -Off by default (`{ enabled: false }` and omitting the option are the same state). Each tool is the record the editor publishes in its manifest (`https://simplepdf.com/embed/json`, `operations[].tool`) and registers on its own page: the `simplepdf_embed_*` name, description, snake_case input schema and behavior hints (the readers carry the specification's `readOnlyHint` and `untrustedContentHint`; every other tool MCP's `destructiveHint`; the three that fetch an agent-supplied URL `openWorldHint`), so a page gets the same tools whether the editor is embedded or opened directly. `exclude` takes SDK method names. 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 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 wire-shaped `{ success, data | error }` Result (`isError` on failure); `simplepdf_embed_get_annotated_page` carries its PNG as an `image` content block, with the badges map in the text block. `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 `webMCP` to ``. +```tsx + +``` + +- **Off by default.** `{ enabled: false }` and omitting the option are the same state. +- **The tools are the editor's own.** Each one is the record the editor publishes in its manifest (`https://simplepdf.com/embed/json`, `operations[].tool`) and registers on its own page: the `simplepdf_embed_*` name, description, snake_case input schema and behavior hints (the readers carry the specification's `readOnlyHint` and `untrustedContentHint`; every other tool MCP's `destructiveHint`; the three that fetch an agent-supplied URL `openWorldHint`). A page gets the same tools whether the editor is embedded or opened directly. `exclude` takes SDK method names. +- **Data path.** Every operation an agent can call runs in the browser, and nothing the agent reads (field values, extracted text, a page render) is computed server-side; it goes to the agent runtime the person attached, so treat that runtime as you would any other party that sees the filled document. Document storage is unchanged by this option: it follows your account's configuration exactly as it does without WebMCP (SimplePDF-managed storage, or your own S3, Azure Blob Storage or SharePoint), and `submit` sends the document through the same submission flow as a click on Submit. +- **Timing.** 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). +- **Results.** The editor validates each call like any other request (its permission model applies at call time: editing, allowlisted origin, plan, so a tool your configuration refuses resolves with the matching error code). A call resolves with an MCP tool result whose text is the editor's wire-shaped `{ success, data | error }` Result (`isError` on failure); `simplepdf_embed_get_annotated_page` carries its PNG as an `image` content block, with the badges map in the text block. A call the runtime aborted before it ran rejects and never reaches the editor. +- **One embed per page.** A model context is one per page and keyed by tool name: a second WebMCP-enabled embed registers only the names the first did not take, and is reported for the rest (`webmcp.tool_already_registered`). `dispose()` unregisters everything. ## Subpaths diff --git a/embed/scripts/generate.mjs b/embed/scripts/generate.mjs index bf69e4f1..632d57cd 100644 --- a/embed/scripts/generate.mjs +++ b/embed/scripts/generate.mjs @@ -6,7 +6,8 @@ // 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. +// The zero-dep root imports only from here and +// from method-names.ts. // - 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`. @@ -332,7 +333,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('// Zero runtime dependencies: the zero-dep root imports only from this module and method-names.ts.') contractLines.push("import type { METHOD_NAMES } from './method-names'") contractLines.push('') contractLines.push(constArray('LOCALES', contract.locales, 'Locale')) diff --git a/embed/src/generated/contract.ts b/embed/src/generated/contract.ts index 4f9f68a7..82e5dc59 100644 --- a/embed/src/generated/contract.ts +++ b/embed/src/generated/contract.ts @@ -1,5 +1,5 @@ // 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. +// Zero runtime dependencies: the zero-dep root imports only from this module and method-names.ts. import type { METHOD_NAMES } from './method-names' export const LOCALES = ["fr", "en", "it", "de", "pt", "es", "ja", "nl"] as const diff --git a/embed/src/mount.ts b/embed/src/mount.ts index b0952dce..bf19eeb4 100644 --- a/embed/src/mount.ts +++ b/embed/src/mount.ts @@ -202,7 +202,9 @@ const assertValidFileArm = (file: unknown): void => { } const METHOD_NAME_SET: ReadonlySet = new Set(METHOD_NAMES) -const WEBMCP_OPTION_KEYS: ReadonlySet = new Set(['enabled', 'exclude'] satisfies Array>) +const WEBMCP_OPTION_KEYS: ReadonlySet = new Set( + Object.keys({ enabled: true, exclude: true } satisfies Record, true>), +) // `webMCP.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 diff --git a/embed/src/protocol.ts b/embed/src/protocol.ts index 7ae05b0f..b6e9e9e2 100644 --- a/embed/src/protocol.ts +++ b/embed/src/protocol.ts @@ -1,8 +1,8 @@ // Wire protocol constants: the operation + outbound-event vocabulary, generated // from embed-api.json (the editor iframe lib is the source). The REQUEST_RESULT // reply envelope is not an event and lives with the bridge. `is_agentic_tool` -// scopes the /tools, /ai-sdk and /tanstack-ai registries; the WebMCP surface -// registers every operation. Zero runtime dependencies. +// marks the operations the /tools, /ai-sdk and /tanstack-ai registries carry; the +// WebMCP surface registers every operation. Zero runtime dependencies. import { OPERATIONS, OUTBOUND_EVENTS } from './generated/contract' diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts index 15236302..7c355e50 100644 --- a/embed/src/webmcp.ts +++ b/embed/src/webmcp.ts @@ -24,7 +24,9 @@ import { modelContextCandidates } from './webmcp-shared' // Result additionally flagged `isError`, a page render carried as an `image` block. type ToolContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: 'image/png' } type CallToolResult = { content: ToolContent[]; isError?: boolean } -type WebMCPTool = Omit & { execute: (input: unknown) => Promise } +type WebMCPTool = Omit & { + execute: (input: unknown, options?: { signal: AbortSignal }) => Promise +} type ModelContext = { registerTool: (tool: WebMCPTool, options: { signal: AbortSignal }) => unknown } @@ -141,8 +143,13 @@ export const registerWebMCPTools = ({ description: record.description, inputSchema: record.inputSchema, annotations: record.annotations, - // A nullish input becomes an empty payload (the no-input operations' wire shape). - execute: async (input) => toCallToolResult(record.wireType, await dispatch(record.wireType, input ?? {})), + // A call the runtime already aborted never reaches the editor; one aborted after it + // was posted still runs there (the wire has no cancel frame). A nullish input becomes + // an empty payload (the no-input operations' wire shape). + execute: async (input, options) => { + options?.signal.throwIfAborted() + return toCallToolResult(record.wireType, await dispatch(record.wireType, input ?? {})) + }, } liveTools.set(tool.name, signal) signal.addEventListener('abort', () => freeTool(tool.name, signal), { once: true }) diff --git a/embed/test/helpers.ts b/embed/test/helpers.ts index 7c0454ec..823ea02c 100644 --- a/embed/test/helpers.ts +++ b/embed/test/helpers.ts @@ -14,6 +14,7 @@ export const makeActionsStub = (): IframeActions => { detectFields: vi.fn(method), download: vi.fn(method), focusField: vi.fn(method), + getAnnotatedPage: vi.fn(method), getDocumentContent: vi.fn(method), getFields: vi.fn(method), goTo: vi.fn(method), diff --git a/embed/test/tools.test.ts b/embed/test/tools.test.ts index 17cd38bb..dfb4639e 100644 --- a/embed/test/tools.test.ts +++ b/embed/test/tools.test.ts @@ -45,6 +45,12 @@ describe(routeToolCall.name, () => { expect(actions.goTo).not.toHaveBeenCalled() }) + it('routes getAnnotatedPage to its action with the validated page', async () => { + const actions = makeActionsStub() + await routeToolCall(actions, 'getAnnotatedPage', { page: 1 }) + expect(actions.getAnnotatedPage).toHaveBeenCalledWith({ page: 1 }) + }) + it('dispatches no-input tools without requiring input', async () => { const actions = makeActionsStub() await routeToolCall(actions, 'getFields', undefined) diff --git a/embed/test/webmcp.test.ts b/embed/test/webmcp.test.ts index 03fe818c..ed6ada51 100644 --- a/embed/test/webmcp.test.ts +++ b/embed/test/webmcp.test.ts @@ -13,7 +13,10 @@ type RegisteredTool = { description: string inputSchema: { type: string; properties?: Record; required?: readonly string[] } annotations: { readOnlyHint?: boolean; untrustedContentHint?: boolean; destructiveHint?: boolean; openWorldHint?: boolean } - execute: (input: unknown) => Promise<{ + execute: ( + input: unknown, + options?: { signal: AbortSignal }, + ) => Promise<{ content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> isError?: boolean }> @@ -295,6 +298,20 @@ describe('attachEmbed({ webMCP })', () => { ]) }) + it('rejects a call whose signal is already aborted and posts nothing to the editor', async () => { + const modelContext = installModelContext(document) + const harness = mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + + const aborted = new AbortController() + aborted.abort() + const postedBefore = harness.posted.length + await expect( + findTool(modelContext, 'simplepdf_embed_submit').execute({ download_copy: false }, { signal: aborted.signal }), + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(harness.posted).toHaveLength(postedBefore) + }) + it('sends an empty payload when a no-input tool is called without arguments', async () => { const modelContext = installModelContext(document) const harness = mountReady({ webMCP: { enabled: true } }) diff --git a/skills/build-with-simplepdf/SKILL.md b/skills/build-with-simplepdf/SKILL.md index e18d4c35..b8edafc7 100644 --- a/skills/build-with-simplepdf/SKILL.md +++ b/skills/build-with-simplepdf/SKILL.md @@ -139,6 +139,8 @@ Typical operations: `getFields()`, `setFieldValue({ fieldId, value })`, `getDocu Editor events arrive via the `onEmbedEvent` prop (React) or `embed.events` (core) — the outbound events are `EDITOR_READY`, `DOCUMENT_LOADED`, `PAGE_FOCUSED` and `SUBMISSION_SENT` (`submit()` itself resolves with `data: null`; the event carries the resulting ids). Wait for `DOCUMENT_LOADED` before operating on the document; until then actions other than `loadDocument()` fail with `bad_request:editor_not_ready` or `bad_request:no_document_loaded` (and `getFields()` may report an incomplete list) — handle or retry rather than racing mount. +Browser agents (WebMCP): `webMCP: { enabled: true, exclude: ['submit'] }` on `createEmbed` / `` registers the editor's operations as tools on the host page (`simplepdf_embed_*`, the same records the editor registers on its own page; `exclude` takes SDK method names). Off by default; one WebMCP-enabled embed per page. + When relevant (`AskUserQuestion`, header `Editor UI`): **"Should SimplePDF's built-in controls remain visible, or should your app own most of the PDF controls?"**