=> {
+ 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`):