Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
f1bd85e
feat: enableWebMCP registers the editor operations as WebMCP tools on…
bendersej Sep 2, 2026
88ff1e1
fix: harden enableWebMCP: spec annotations, readiness-gated lazy load…
bendersej Sep 2, 2026
f42a0ea
fix: validate enableWebMCP.exclude names at createEmbed and re-probe …
bendersej Sep 2, 2026
ff96058
fix: one WebMCP option normalizer, latch that follows the module's fi…
bendersej Sep 2, 2026
2c3de36
fix: a WebMCP tool name is freed only by the signal that owns it
bendersej Sep 2, 2026
0f189a4
test: pin that a disposed embed frees only the names it owned
bendersej Sep 2, 2026
7c6a426
test: pin the readiness gate with a control on the read host; share t…
bendersej Sep 2, 2026
c22d8ce
docs: repoint the hints breadcrumb to the editor's contract module
bendersej Sep 3, 2026
5c9b4da
feat: getAnnotatedPage and the lifecycle events from the live manifes…
bendersej Sep 12, 2026
f138a8d
feat: webMCP registers the manifest tool records verbatim, loadDocume…
bendersej Sep 12, 2026
aa65b05
refactor: the WebMCP records carry their wire type, so the lazy modul…
bendersej Sep 12, 2026
7ea2b70
chore: restore the documented headroom on the root and WebMCP bundle …
bendersej Sep 12, 2026
41f0819
fix: a misspelled webMCP key fails loud; the annotation type derives …
bendersej Sep 12, 2026
2a8f9ed
refactor: the tool-result envelope is chosen per operation in an exha…
bendersej Sep 13, 2026
acbb9f9
fix: the data-path claim states what runs in the browser; an aborted …
bendersej Sep 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/enable-webmcp.md
Original file line number Diff line number Diff line change
@@ -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 `<EmbedPDF mode="inline" webMCP={{ enabled: true }} />` 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).
8 changes: 8 additions & 0 deletions .changeset/get-annotated-page.md
Original file line number Diff line number Diff line change
@@ -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`.
5 changes: 5 additions & 0 deletions .changeset/lifecycle-events-in-manifest.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 29 additions & 1 deletion embed/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<EmbedPDF mode="inline" companyIdentifier="acme" document={{ url: 'https://example.com/form.pdf' }} webMCP={{ enabled: true, exclude: ['submit'] }} />
```

- **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 |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:

Expand Down
Loading
Loading