diff --git a/.changeset/enable-webmcp.md b/.changeset/enable-webmcp.md new file mode 100644 index 00000000..f64ca3a9 --- /dev/null +++ b/.changeset/enable-webmcp.md @@ -0,0 +1,8 @@ +--- +'@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 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 new file mode 100644 index 00000000..93838222 --- /dev/null +++ b/.changeset/get-annotated-page.md @@ -0,0 +1,8 @@ +--- +'@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: `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/.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 d297956d..4787cbf9 100644 --- a/embed/README.md +++ b/embed/README.md @@ -71,6 +71,33 @@ 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](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: '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: 'https://example.com/form.pdf' }, webMCP: { enabled: true } }) +``` + +```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 | Import | Purpose | Peer | @@ -112,6 +139,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) | +| `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 @@ -156,7 +184,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 5941bf43..0e83df63 100644 --- a/embed/etc/index.api.md +++ b/embed/etc/index.api.md @@ -73,6 +73,7 @@ export type CreateEmbedArgs = { style?: Partial; }; logger?: BridgeLogger; + webMCP?: WebMCPOptions; }; // @public (undocumented) @@ -118,6 +119,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) @@ -126,12 +132,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; @@ -145,6 +149,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; @@ -210,6 +217,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; @@ -249,6 +270,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; @@ -275,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; @@ -289,6 +316,16 @@ export type MovePageInput = { // @public (undocumented) export const NOOP_LOGGER: BridgeLogger; +// 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; +} | { + enabled: true; + exclude: readonly MethodName[]; +}; + // Warning: (ae-forgotten-export) The symbol "OVERLAY_TOOL_TYPES" needs to be exported by the entry point index.d.ts // // @public (undocumented) @@ -331,6 +368,14 @@ export type SubmitInput = { // @public (undocumented) export const unwrap: (result: BridgeResult) => TData; +// @public (undocumented) +export type WebMCPOptions = { + enabled: false; +} | { + enabled: true; + exclude?: readonly MethodName[]; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/embed/etc/protocol.api.md b/embed/etc/protocol.api.md index 22d916e8..65e18ef7 100644 --- a/embed/etc/protocol.api.md +++ b/embed/etc/protocol.api.md @@ -70,15 +70,23 @@ 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"; 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 error_codes: readonly ["bad_request:invalid_value", "bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: true; @@ -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 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; }; 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/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..2d436271 100644 --- a/embed/scripts/check-bundle-size.mjs +++ b/embed/scripts/check-bundle-size.mjs @@ -1,21 +1,24 @@ // 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 { 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') -// 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.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. +// actionable config validation + the WebMCP opt-in hook. const BUDGETS = { - 'index.js': 8 * 1024, + 'index.js': 10 * 1024, 'protocol.js': 3.5 * 1024, 'schemas.js': 3 * 1024, 'tools.js': 5 * 1024, @@ -23,10 +26,12 @@ const BUDGETS = { 'tanstack-ai.js': 5.5 * 1024, } -const localImports = (file) => { +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 +51,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) }) -process.exit(allWithinBudget.every(Boolean) ? 0 : 1) + +// 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 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, 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 new file mode 100644 index 00000000..741d82f6 --- /dev/null +++ b/embed/scripts/check-lazy-chunks.mjs @@ -0,0 +1,38 @@ +// 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' +import { LAZY_CHUNKS } from './lazy-chunks.mjs' + +const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist') +const require = createRequire(import.meta.url) + +const results = [] +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 c346a0de..632d57cd 100644 --- a/embed/scripts/generate.mjs +++ b/embed/scripts/generate.mjs @@ -3,13 +3,20 @@ // 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: +// 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`. +// - 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 @@ -29,8 +36,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']) // --------------------------------------------------------------------------- @@ -59,15 +67,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', @@ -80,8 +90,24 @@ 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)`, + ) + } + 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: }` +// 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. @@ -98,6 +124,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) @@ -150,6 +179,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) @@ -211,6 +243,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) @@ -298,7 +333,8 @@ 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')) contractLines.push(constArray('EDITOR_ERROR_CODES', editorErrorCodes, 'EditorErrorCode')) @@ -342,7 +378,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 ( @@ -361,13 +397,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"]') -contractLines.push( - 'export type AgenticToolName = Extract<(typeof OPERATIONS)[number], { is_agentic_tool: true }>["method"]', -) +// 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( @@ -398,6 +434,89 @@ schemaLines.push('') writeFileSync(join(GENERATED_DIR, 'schemas.ts'), renderFile(schemaLines)) +// --- method-names.ts (zero runtime deps; the one generated value the root imports) --- + +const methodNames = contract.operations.map((op) => toCamel(op.request_type)) +writeFileSync( + join(GENERATED_DIR, 'method-names.ts'), + renderFile([ + '// 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 = [${methodNames.map((name) => JSON.stringify(name)).join(', ')}] as const`, + ]), +) + +// --- 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`) + } + } + return { name, description, inputSchema, annotations, wireType: op.request_type.toUpperCase() } +} + +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. `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: {') +webmcpToolLines.push(" readonly type: 'object'") +webmcpToolLines.push(' readonly properties?: Readonly>') +webmcpToolLines.push(' readonly required?: readonly string[]') +webmcpToolLines.push(' }') +webmcpToolLines.push(' readonly annotations: {') +for (const hint of WEBMCP_ANNOTATION_KEYS) { + webmcpToolLines.push(` readonly ${hint}?: 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 // type-parameter constraints still fail the build the instant a representation @@ -410,16 +529,16 @@ 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>,") +driftLines.push(' AssertTrue>,') +driftLines.push(" AssertTrue>,") for (const op of contract.operations) { const stem = toPascal(op.request_type) driftLines.push(` AssertTrue>,`) @@ -439,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) @@ -456,5 +575,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 + method-names.ts + webmcp-tools.ts`, ) 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 e6687a37..8679f814 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' @@ -12,6 +11,7 @@ import type { PageFocusedPayload, SubmissionSentPayload, } from './types' +import { modelContextCandidates, normalizeWebMCPOptions, type WebMCPOptions } from './webmcp-shared' export type AttachEmbedArgs = { // Getter returning the iframe element. Called each time the bridge needs to @@ -26,6 +26,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). + 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 @@ -46,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) { @@ -67,8 +73,13 @@ const relabelResult = (result: BridgeResult): BridgeResult) => void + resultShape: ResultShape wireType: WireType startedAtMs: number timeoutId: ReturnType @@ -103,6 +114,7 @@ export const attachEmbed = ({ logger: providedLogger = NOOP_LOGGER, onDispose, onStateChange, + webMCP, }: AttachEmbedArgs): Embed => { const logger = makeSafeLogger(providedLogger) const pending = new Map() @@ -155,12 +167,55 @@ 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 = 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 + // 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 (!webMCPOptions.enabled || webMCPStarted) { + return + } + if (modelContextCandidates().length === 0) { + logger.info('webmcp.unavailable', { reason: 'no_model_context' }) + return + } + webMCPStarted = true + void import('./webmcp') + .then(({ registerWebMCPTools }) => { + const registered = registerWebMCPTools({ + dispatch: (wireType, data) => postRequest(wireType, data, 'wire'), + exclude: webMCPOptions.exclude, + signal: webMCPController.signal, + logger, + }) + if (!registered) { + webMCPStarted = false + } + }) + .catch((error: unknown) => { + webMCPStarted = false + 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> => + const postRequest = (wireType: WireType, data: unknown, resultShape: ResultShape): Promise> => new Promise>((resolve) => { if (disposed) { resolve({ @@ -198,6 +253,7 @@ export const attachEmbed = ({ pending.set(requestId, { resolve: (result) => resolve(relabelResult(result)), + resultShape, wireType, startedAtMs, timeoutId, @@ -222,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 @@ -340,13 +399,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). @@ -381,7 +440,7 @@ export const attachEmbed = ({ return } - if (payload.type !== INTERNAL_PROTOCOL.REQUEST_RESULT) { + if (payload.type !== REQUEST_RESULT_TYPE) { return } @@ -420,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, @@ -454,6 +522,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), @@ -470,6 +539,7 @@ export const attachEmbed = ({ return } disposed = true + webMCPController.abort() window.removeEventListener('message', onMessage) clearReadyTimeout() stopProbing() 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/generated/contract.ts b/embed/src/generated/contract.ts index a35fdc12..82e5dc59 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. +// 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 export type Locale = (typeof LOCALES)[number] @@ -28,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 @@ -52,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 } @@ -105,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", @@ -123,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, @@ -141,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, @@ -177,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, @@ -195,10 +209,11 @@ 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 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." }, + { 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 cbb2d9d1..75cefc55 100644 --- a/embed/src/generated/drift.ts +++ b/embed/src/generated/drift.ts @@ -4,22 +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>, + AssertTrue>, + AssertTrue>, AssertTrue>, AssertTrue>, AssertTrue>, AssertTrue>, AssertTrue>, AssertTrue>, + 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/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/tools.ts b/embed/src/generated/tools.ts index 239802c7..5623e3db 100644 --- a/embed/src/generated/tools.ts +++ b/embed/src/generated/tools.ts @@ -2,21 +2,22 @@ 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 }, 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/generated/webmcp-tools.ts b/embed/src/generated/webmcp-tools.ts new file mode 100644 index 00000000..3c4376a9 --- /dev/null +++ b/embed/src/generated/webmcp-tools.ts @@ -0,0 +1,44 @@ +// 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. `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: { + 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},"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/index.ts b/embed/src/index.ts index fc6a9e51..41a396ec 100644 --- a/embed/src/index.ts +++ b/embed/src/index.ts @@ -4,6 +4,8 @@ export { createEmbed, EmbedConfigError } from './mount' export type { CreateEmbedArgs, EmbedDocument } from './mount' +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/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/mount.ts b/embed/src/mount.ts index 09838ab1..bf19eeb4 100644 --- a/embed/src/mount.ts +++ b/embed/src/mount.ts @@ -1,7 +1,9 @@ import { attachEmbed } from './bridge' import { type BridgeLogger, makeSafeLogger, NOOP_LOGGER } from './logger' import type { BridgeState, Embed } from './types' +import { METHOD_NAMES } from './generated/method-names' import type { Locale } from './generated/contract' +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 @@ -86,6 +88,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. + // `{ 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 => { @@ -194,6 +201,55 @@ const assertValidFileArm = (file: unknown): void => { } } +const METHOD_NAME_SET: ReadonlySet = new Set(METHOD_NAMES) +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 +// register the operation it meant to withhold. +const assertValidWebMCPOptions = (webMCP: unknown): void => { + if (webMCP === undefined) { + return + } + 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() + } + // 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 + } + if (!Array.isArray(exclude)) { + throw shapeError() + } + const entries: unknown[] = exclude + if (!entries.every((name): name is string => typeof name === 'string')) { + throw shapeError() + } + const unknownNames = entries.filter((name) => !METHOD_NAME_SET.has(name)) + if (unknownNames.length > 0) { + throw new EmbedConfigError( + 'invalid_config', + `webMCP.exclude names no tool: ${unknownNames.join(', ')} (known: ${METHOD_NAMES.join(', ')}).`, + ) + } +} + const assertValidDocument = (document: unknown): void => { if (document === undefined) { return @@ -461,7 +517,7 @@ const loadDocumentWhenReady = (params: { const attachToIframe = ( iframe: HTMLIFrameElement, editorOrigin: string, - { document: embedDocument, logger = NOOP_LOGGER }: 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 @@ -508,6 +564,7 @@ const attachToIframe = ( logger: safeLogger, onDispose: () => documentFetchController.abort(), onStateChange: gate.onStateChange, + webMCP, }) if (embedDocument !== undefined) { loadDocumentWhenReady({ @@ -527,7 +584,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, webMCP }: CreateEmbedArgs, documentsUrl: { url: URL; origin: string } | null, ): Embed => { const hasDocumentUrl = mountDocument !== undefined && 'url' in mountDocument @@ -607,6 +664,7 @@ const mountIntoContainer = ( documentFetchController.abort() iframe.remove() }, + webMCP, }) // A documents URL is loaded by the navigation above; only the PDF / data-URL / @@ -655,6 +713,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.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/protocol.ts b/embed/src/protocol.ts index 83be1429..b6e9e9e2 100644 --- a/embed/src/protocol.ts +++ b/embed/src/protocol.ts @@ -1,8 +1,8 @@ -// 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 -// dependencies. +// 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` +// 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/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 8caa0a5c..f9994f7e 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, @@ -37,17 +41,22 @@ export type { DetectFieldsOutput, DocumentContentPage, DocumentContentResult, + DocumentLoadedPayload, EditorErrorCode, + EditorReadyPayload, ExtractionMode, FieldType, FocusFieldInput, FocusFieldOutput, + GetAnnotatedPageInput, + GetAnnotatedPageOutput, GetDocumentContentInput, GetDocumentContentOutput, GetFieldsOutput, GoToInput, Locale, LoadDocumentInput, + MethodName, MissingRequiredFieldsDetails, MovePageInput, OverlayToolType, @@ -109,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 } @@ -134,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-shared.ts b/embed/src/webmcp-shared.ts new file mode 100644 index 00000000..eefc1cda --- /dev/null +++ b/embed/src/webmcp-shared.ts @@ -0,0 +1,44 @@ +// 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 { 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 +// 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 +} + +// `{ 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 | { 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 MethodName[] } => { + if (options === undefined) { + return { enabled: false } + } + switch (options.enabled) { + case false: + return { enabled: false } + case true: + return { enabled: true, exclude: options.exclude ?? [] } + default: + options satisfies never + return { enabled: false } + } +} diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts new file mode 100644 index 00000000..7c355e50 --- /dev/null +++ b/embed/src/webmcp.ts @@ -0,0 +1,171 @@ +// Registers the editor operations as WebMCP tools on the HOST page's model context +// 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. 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 `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 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' +import { modelContextCandidates } from './webmcp-shared' + +// The MCP tool-result envelope. The specification serializes whatever `execute` +// 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 = Omit & { + execute: (input: unknown, options?: { signal: AbortSignal }) => Promise +} +type ModelContext = { + registerTool: (tool: WebMCPTool, options: { signal: AbortSignal }) => unknown +} + +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 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 }) }, + ], + } +} + +// 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. +// 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. +export const registerWebMCPTools = ({ + dispatch, + exclude, + 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 MethodName[] + signal: AbortSignal + logger: BridgeLogger +}): boolean => { + if (signal.aborted) { + return false + } + const modelContext = readModelContext() + if (modelContext === null) { + logger.info('webmcp.unavailable', { reason: 'invalid_model_context' }) + return false + } + const excluded = new Set(exclude) + for (const method of METHOD_NAMES) { + if (excluded.has(method)) { + continue + } + const record = WEBMCP_TOOLS[method] + if (liveTools.has(record.name)) { + logger.warn('webmcp.tool_already_registered', { tool: record.name }) + continue + } + const tool: WebMCPTool = { + name: record.name, + description: record.description, + inputSchema: record.inputSchema, + annotations: record.annotations, + // 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 }) + // 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) { + freeTool(tool.name, signal) + logger.error('webmcp.register_tool_failed', { + tool: tool.name, + message: error instanceof Error ? error.message : String(error), + }) + } + })() + } + return 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/mount.test.ts b/embed/test/mount.test.ts index f8b40c72..8770d2ea 100644 --- a/embed/test/mount.test.ts +++ b/embed/test/mount.test.ts @@ -382,4 +382,38 @@ 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 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 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', webMCP } + // @ts-expect-error exercising the runtime guard for untyped JS callers + expect(() => createEmbed(malformedArgs)).toThrow( + /webMCP must be \{ enabled: false \} or \{ enabled: true, exclude\?: MethodName\[\] \}/, + ) + }) + + 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'] } } + // @ts-expect-error exercising the runtime guard for untyped JS callers + expect(() => createEmbed(misspelled)).toThrow(/webMCP\.exclude names no tool: sumbit \(known: createField/) + }) }) 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..dfb4639e 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') @@ -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 new file mode 100644 index 00000000..ed6ada51 --- /dev/null +++ b/embed/test/webmcp.test.ts @@ -0,0 +1,448 @@ +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 { METHOD_NAMES } from '../src/generated/method-names' +import { WEBMCP_TOOLS } from '../src/generated/webmcp-tools' + +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; untrustedContentHint?: boolean; destructiveHint?: boolean; openWorldHint?: boolean } + execute: ( + input: unknown, + options?: { signal: AbortSignal }, + ) => Promise<{ + content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: 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 +} + +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 + // Registration waits for the editor to be alive; these are the editor's lifecycle announcements. + markEditorReady: () => void + markDocumentLoaded: () => 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 receive = (message: unknown): void => { + window.dispatchEvent( + new MessageEvent('message', { data: JSON.stringify(message), origin: EDITOR_ORIGIN, source: contentWindow }), + ) + } + 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: {} }), + markDocumentLoaded: () => receive({ type: 'DOCUMENT_LOADED', data: { document_id: 'doc1' } }), + } + harnesses.push(harness) + return harness +} + +// 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)) + +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. +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({ webMCP })', () => { + 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 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({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + + expect(modelContext.registered.map((tool) => tool.name).sort()).toEqual( + Object.values(WEBMCP_TOOLS) + .map((tool) => tool.name) + .sort(), + ) + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) + // 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(['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, '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, '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({ 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({ webMCP: { enabled: 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 * 2) + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) + }) + + it('withholds the excluded operations and registers the rest', async () => { + const modelContext = installModelContext(document) + const logger = makeLogger() + 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(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, wire-shaped, as a JSON-text tool result', async () => { + const modelContext = installModelContext(document) + const harness = mountReady({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + + 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(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({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + + 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(toolResult.content).toEqual([ + { + type: 'text', + text: JSON.stringify({ success: false, error: { code: 'bad_request:page_out_of_range', message: 'no page 99' } }), + }, + ]) + }) + + 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 } }) + await waitForTools(modelContext, TOOL_COUNT) + + 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({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT) + + harness.embed.lifecycle.dispose() + 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({ 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({ webMCP: { enabled: true } }) + 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 () => { + const modelContext = installModelContext(document) + const registerTool = vi.spyOn(modelContext, 'registerTool') + mountReady({}) + 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({ webMCP: { enabled: true } }) + 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({ webMCP: { enabled: true } }) + await waitForTools(modelContext, TOOL_COUNT) + + mountReady({ webMCP: { enabled: true }, logger }) + await vi.waitFor(() => + 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({ 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: '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({ webMCP: { enabled: true }, logger: makeLogger() }) + await waitForTools(accepting, 1) + expect(accepting.registered[0]?.name).toBe('simplepdf_embed_download') + + // 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({ webMCP: { enabled: true }, logger }) + await waitForTools(accepting, TOOL_COUNT) + expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'simplepdf_embed_download' }) + expect(logger.warn).toHaveBeenCalledTimes(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({ 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({ webMCP: { enabled: 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, 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 () => { + Object.defineProperty(document, 'modelContext', { configurable: true, value: {} }) + const logger = makeLogger() + const harness = mountReady({ webMCP: { enabled: true }, logger }) + await vi.waitFor(() => + expect(logger.info).toHaveBeenCalledWith('webmcp.unavailable', { reason: 'invalid_model_context' }), + ) + + const modelContext = installModelContext(document) + harness.markDocumentLoaded() + 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: 'simplepdf_embed_download' }) + const logger = makeLogger() + mountReady({ webMCP: { enabled: true }, logger }) + await waitForTools(modelContext, TOOL_COUNT - 1) + + expect(modelContext.registered.map((tool) => tool.name)).not.toContain('simplepdf_embed_download') + await vi.waitFor(() => + expect(logger.error).toHaveBeenCalledWith('webmcp.register_tool_failed', { + tool: 'simplepdf_embed_download', + message: 'runtime rejected simplepdf_embed_download', + }), + ) + }) +}) diff --git a/react/README.md b/react/README.md index 7bb48ad5..d4b87292 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'; @@ -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 + + 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 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 React.CSSProperties diff --git a/react/etc/index.api.md b/react/etc/index.api.md index 9a4bb057..e1f2025c 100644 --- a/react/etc/index.api.md +++ b/react/etc/index.api.md @@ -11,10 +11,12 @@ 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'; // @public (undocumented) export type EmbedActions = Omit & { @@ -40,6 +42,8 @@ export type EmbedPDFProps = InlineEmbedPDFProps | ModalEmbedPDFProps; export { FieldType } +export { MethodName } + export { OverlayToolType } // @public (undocumented) @@ -48,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 9a8b70c1..5582748d 100644 --- a/react/src/embed-pdf.test.tsx +++ b/react/src/embed-pdf.test.tsx @@ -13,6 +13,61 @@ 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 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); + signal.addEventListener('abort', () => liveTools.delete(tool.name), { once: true }); + }); + Object.defineProperty(document, 'modelContext', { configurable: true, value: { registerTool } }); + try { + 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.has('simplepdf_embed_set_field_value')).toBe(true)); + expect(liveTools.size).toBeGreaterThan(1); + expect(liveTools.has('simplepdf_embed_submit')).toBe(false); + unmount(); + expect(liveTools.size).toBe(0); + } finally { + Reflect.deleteProperty(document, 'modelContext'); + } + }); + + 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( + , + ); + 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); + + // 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', () => { const { container } = render(); const iframe = container.querySelector('iframe'); diff --git a/react/src/embed-pdf.tsx b/react/src/embed-pdf.tsx index f95ef01a..5a78eec0 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, normalizeWebMCPOptions, 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): `{ enabled: true }` for every operation, `exclude` to withhold some + // by method name (e.g. `submit`). Off by default. + webMCP?: WebMCPOptions; }; type InlineEmbedPDFProps = CommonEmbedPDFProps & { @@ -123,6 +127,7 @@ type SurfaceProps = { context?: Record; logger?: BridgeLogger; onEmbedEvent?: (event: EmbedEvent) => void | Promise; + webMCP?: WebMCPOptions; className?: string; style?: React.CSSProperties; }; @@ -131,7 +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, 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. @@ -190,6 +195,14 @@ const EmbedSurface = React.forwardRef((props, return `unserializable:${Object.keys(context).sort().join(',')}`; } }, [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 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; @@ -204,6 +217,7 @@ const EmbedSurface = React.forwardRef((props, locale, context, logger: stableLogger, + webMCP: webMCPRef.current, }); assignRef(ref, toEmbedActions(embed)); // Forward each editor event to onEmbedEvent as the verbatim { type, data }. The @@ -244,7 +258,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 +354,7 @@ export const EmbedPDF = React.forwardRef((pr context={props.context} logger={props.logger} onEmbedEvent={props.onEmbedEvent} + webMCP={props.webMCP} className="simplePDF_iframe" /> @@ -346,6 +371,7 @@ export const EmbedPDF = React.forwardRef((pr context={props.context} logger={props.logger} onEmbedEvent={props.onEmbedEvent} + webMCP={props.webMCP} className={props.className} style={props.style} /> @@ -388,6 +414,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/react/src/index.tsx b/react/src/index.tsx index 49a86cf9..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 { EmbedDocument, FieldType, OverlayToolType } from '@simplepdf/embed'; +export type { EmbedDocument, FieldType, MethodName, OverlayToolType, WebMCPOptions } from '@simplepdf/embed'; diff --git a/skills/build-with-simplepdf/SKILL.md b/skills/build-with-simplepdf/SKILL.md index dded8d1f..b8edafc7 100644 --- a/skills/build-with-simplepdf/SKILL.md +++ b/skills/build-with-simplepdf/SKILL.md @@ -135,9 +135,11 @@ 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. + +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`):