=> {
+ await vi.waitFor(() => expect(harness.posted.some((message) => message.type === type)).toBe(true))
+ const request = harness.posted.find((message) => message.type === type)
+ if (request === undefined) {
+ throw new Error(`no ${type} request posted`)
+ }
+ return request
+}
+
+const findTool = (modelContext: FakeModelContext, name: string): RegisteredTool => {
+ const tool = modelContext.registered.find((candidate) => candidate.name === name)
+ if (tool === undefined) {
+ throw new Error(`tool ${name} was not registered`)
+ }
+ return tool
+}
+
+describe('attachEmbed({ enableWebMCP })', () => {
+ afterEach(() => {
+ for (const harness of harnesses) {
+ harness.embed.lifecycle.dispose()
+ }
+ harnesses.length = 0
+ document.body.innerHTML = ''
+ restoreModelContext(document, originalDocumentModelContext)
+ restoreModelContext(navigator, originalNavigatorModelContext)
+ vi.restoreAllMocks()
+ })
+
+ it('registers every agentic operation on document.modelContext with the SDK name, description, camelCase input schema and an explicit behavior hint', async () => {
+ const modelContext = installModelContext(document)
+ mountReady({ enableWebMCP: true })
+ await waitForTools(modelContext, TOOL_COUNT)
+
+ expect(modelContext.registered.map((tool) => tool.name).sort()).toEqual([...AGENTIC_TOOL_NAMES].sort())
+ expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT)
+ const setFieldValue = findTool(modelContext, 'setFieldValue')
+ expect(setFieldValue.description).toMatch(/^Set the value of an existing field/)
+ expect(setFieldValue.inputSchema.type).toBe('object')
+ expect(Object.keys(setFieldValue.inputSchema.properties ?? {})).toEqual(['fieldId', 'value'])
+ expect(setFieldValue.inputSchema.required).toEqual(['fieldId', 'value'])
+ for (const tool of modelContext.registered) {
+ const hasExplicitHint = tool.annotations.readOnlyHint === true || typeof tool.annotations.destructiveHint === 'boolean'
+ expect(hasExplicitHint, `${tool.name} declares no behavior hint`).toBe(true)
+ }
+ // The readers hand document-derived content to the agent: read-only AND untrusted.
+ expect(findTool(modelContext, 'getFields').annotations).toEqual({ readOnlyHint: true, untrustedContentHint: true })
+ expect(findTool(modelContext, 'getDocumentContent').annotations).toEqual({
+ readOnlyHint: true,
+ untrustedContentHint: true,
+ })
+ expect(findTool(modelContext, 'submit').annotations).toEqual({ destructiveHint: true })
+ })
+
+ it('waits for the editor to be ready before registering, so an early tool call cannot post into a listener-less iframe', async () => {
+ const modelContext = installModelContext(document)
+ const booting = makeHarness({ enableWebMCP: true })
+ // Control: a ready embed on the same context proves the lazy path had time to run;
+ // every live name is the control's, so the booting embed registered nothing.
+ const control = mountReady({ enableWebMCP: true })
+ await waitForTools(modelContext, TOOL_COUNT)
+ expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT)
+ control.embed.lifecycle.dispose()
+ expect(modelContext.liveToolNames()).toEqual([])
+
+ booting.markEditorReady()
+ await waitForTools(modelContext, TOOL_COUNT * 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({ enableWebMCP: { exclude: ['submit', 'deletePages', 'movePage', 'rotatePage'] }, logger })
+ await waitForTools(modelContext, TOOL_COUNT - 4)
+
+ const names = modelContext.registered.map((tool) => tool.name)
+ expect(names).toContain('setFieldValue')
+ expect(names).toContain('getFields')
+ expect(names).not.toContain('submit')
+ expect(names).not.toContain('deletePages')
+ expect(logger.warn).not.toHaveBeenCalled()
+ })
+
+ it('executes a tool call as the operation request on the wire and returns the editor Result as a JSON-text tool result', async () => {
+ const modelContext = installModelContext(document)
+ const harness = mountReady({ enableWebMCP: true })
+ await waitForTools(modelContext, TOOL_COUNT)
+
+ const pendingResult = findTool(modelContext, 'setFieldValue').execute({ fieldId: 'f1', value: 'Jane' })
+ const request = await waitForRequest(harness, 'SET_FIELD_VALUE')
+ expect(request.data).toEqual({ field_id: 'f1', value: 'Jane' })
+ harness.reply(request, { success: true })
+ const toolResult = await pendingResult
+ expect(toolResult.isError).toBeUndefined()
+ expect(JSON.parse(toolResult.content[0]?.text ?? '')).toEqual({ success: true, data: null })
+ })
+
+ it('flags a failed editor Result as an error tool result that still carries the error code', async () => {
+ const modelContext = installModelContext(document)
+ const harness = mountReady({ enableWebMCP: true })
+ await waitForTools(modelContext, TOOL_COUNT)
+
+ const pendingResult = findTool(modelContext, 'goTo').execute({ page: 99 })
+ const request = await waitForRequest(harness, 'GO_TO')
+ harness.reply(request, { success: false, error: { code: 'bad_request:page_out_of_range', message: 'no page 99' } })
+ const toolResult = await pendingResult
+ expect(toolResult.isError).toBe(true)
+ expect(JSON.parse(toolResult.content[0]?.text ?? '')).toEqual({
+ success: false,
+ error: { code: 'bad_request:page_out_of_range', message: 'no page 99' },
+ })
+ })
+
+ it('sends an empty payload when a no-input tool is called without arguments', async () => {
+ const modelContext = installModelContext(document)
+ const harness = mountReady({ enableWebMCP: true })
+ await waitForTools(modelContext, TOOL_COUNT)
+
+ void findTool(modelContext, 'detectFields').execute(undefined)
+ const request = await waitForRequest(harness, 'DETECT_FIELDS')
+ expect(request.data).toEqual({})
+ })
+
+ it('unregisters every tool when the embed is disposed', async () => {
+ const modelContext = installModelContext(document)
+ const harness = mountReady({ enableWebMCP: 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({ enableWebMCP: true })
+ disposedEarly.embed.lifecycle.dispose()
+ // Control: a later embed on the same context registers its full set, proving the
+ // early one's lazy load had every chance to run and registered nothing.
+ mountReady({ enableWebMCP: true })
+ await waitForTools(modelContext, 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({ enableWebMCP: false })
+ // Control: a ready embed with the option on registers, proving the off ones had
+ // the same chance and took none of it.
+ mountReady({ enableWebMCP: true })
+ await waitForTools(modelContext, TOOL_COUNT)
+ expect(registerTool).toHaveBeenCalledTimes(TOOL_COUNT)
+ })
+
+ it('lets the first embed on a page own each tool name and reports the collision for a second one', async () => {
+ const modelContext = installModelContext(document)
+ const logger = makeLogger()
+ const first = mountReady({ enableWebMCP: true })
+ await waitForTools(modelContext, TOOL_COUNT)
+
+ mountReady({ enableWebMCP: true, logger })
+ await vi.waitFor(() =>
+ expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'submit' }),
+ )
+ expect(logger.warn).toHaveBeenCalledTimes(TOOL_COUNT)
+ expect(modelContext.registered).toHaveLength(TOOL_COUNT)
+
+ // Disposing the owner frees the names for the next embed.
+ first.embed.lifecycle.dispose()
+ mountReady({ enableWebMCP: true })
+ await waitForTools(modelContext, TOOL_COUNT * 2)
+ })
+
+ it('frees a rejected name only for its owner, so a later embed that took the name keeps it', async () => {
+ // A: the runtime rejects `download`; A's abort must not later free a name it never owned.
+ const modelContext = installModelContext(document, { rejectTool: 'download' })
+ const first = mountReady({ enableWebMCP: true, logger: makeLogger() })
+ await waitForTools(modelContext, TOOL_COUNT - 1)
+
+ // B: on an accepting context, takes `download` (the rest are reported as A's).
+ const accepting = installModelContext(document)
+ const second = mountReady({ enableWebMCP: true, logger: makeLogger() })
+ await waitForTools(accepting, 1)
+ expect(accepting.registered[0]?.name).toBe('download')
+
+ // A disposes: its 13 names are freed for C, but B's `download` stays owned, so C is refused it.
+ first.embed.lifecycle.dispose()
+ const logger = makeLogger()
+ mountReady({ enableWebMCP: true, logger })
+ await waitForTools(accepting, TOOL_COUNT)
+ expect(logger.warn).toHaveBeenCalledWith('webmcp.tool_already_registered', { tool: 'download' })
+ expect(logger.warn).toHaveBeenCalledTimes(1)
+ expect(accepting.registered.filter((tool) => tool.name === 'download')).toHaveLength(1)
+ second.embed.lifecycle.dispose()
+ })
+
+ it('falls back to navigator.modelContext when the document exposes none', async () => {
+ const modelContext = installModelContext(navigator)
+ mountReady({ enableWebMCP: true })
+ await waitForTools(modelContext, TOOL_COUNT)
+ expect(modelContext.liveToolNames()).toHaveLength(TOOL_COUNT)
+ })
+
+ it('reports an absent model context, never throws, and registers once a context appears', async () => {
+ const logger = makeLogger()
+ const harness = makeHarness({ enableWebMCP: true, logger })
+ 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({ enableWebMCP: 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: 'download' })
+ const logger = makeLogger()
+ mountReady({ enableWebMCP: true, logger })
+ await waitForTools(modelContext, TOOL_COUNT - 1)
+
+ expect(modelContext.registered.map((tool) => tool.name)).not.toContain('download')
+ await vi.waitFor(() =>
+ expect(logger.error).toHaveBeenCalledWith('webmcp.register_tool_failed', {
+ tool: 'download',
+ message: 'runtime rejected download',
+ }),
+ )
+ })
+})
diff --git a/react/README.md b/react/README.md
index 7bb48ad5..ae0aed9e 100644
--- a/react/README.md
+++ b/react/README.md
@@ -318,6 +318,12 @@ See [Retrieving PDF Data](../README.md#retrieving-pdf-data) for text extraction,
| No |
The document to open (same typed shape as createEmbed): a URL (CORS / authenticated same-origin / a SimplePDF documents URL), a data URL, or a File/Blob |
+
+ | enableWebMCP |
+ boolean | { exclude: AgenticToolName[] } |
+ No (defaults to off) |
+ Register the editor operations as WebMCP tools on your page, where an in-browser agent (ChatGPT's browser, Chrome with WebMCP) discovers them; exclude withholds operations such as submit. 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..8647be0a 100644
--- a/react/etc/index.api.md
+++ b/react/etc/index.api.md
@@ -4,6 +4,7 @@
```ts
+import { AgenticToolName } from '@simplepdf/embed';
import type { BridgeLogger } from '@simplepdf/embed';
import type { BridgeResult } from '@simplepdf/embed';
import type { EditorEvent } from '@simplepdf/embed';
@@ -15,6 +16,9 @@ import { OverlayToolType } from '@simplepdf/embed';
import * as React_2 from 'react';
import type { SelectToolInput } from '@simplepdf/embed';
import type { SubmitInput } from '@simplepdf/embed';
+import { WebMCPOptions } from '@simplepdf/embed';
+
+export { AgenticToolName }
// @public (undocumented)
export type EmbedActions = Omit & {
@@ -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..19235ab8 100644
--- a/react/src/embed-pdf.test.tsx
+++ b/react/src/embed-pdf.test.tsx
@@ -13,6 +13,50 @@ vi.mock('./styles.scss', () => ({}));
// onEmbedEvent contract, and the useEmbed contract (null-safe before mount).
describe('EmbedPDF (inline)', () => {
+ it('registers the editor operations as WebMCP tools on the host page when enableWebMCP is set, and unregisters them on unmount', async () => {
+ const liveTools = new Set();
+ const registerTool = vi.fn((tool: { name: string }, { signal }: { signal: AbortSignal }) => {
+ liveTools.add(tool.name);
+ signal.addEventListener('abort', () => liveTools.delete(tool.name), { once: true });
+ });
+ Object.defineProperty(document, 'modelContext', { configurable: true, value: { registerTool } });
+ try {
+ const { 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('setFieldValue')).toBe(true));
+ expect(liveTools.size).toBeGreaterThan(1);
+ expect(liveTools.has('submit')).toBe(false);
+ unmount();
+ expect(liveTools.size).toBe(0);
+ } finally {
+ Reflect.deleteProperty(document, 'modelContext');
+ }
+ });
+
+ it('does not remount the editor when enableWebMCP is re-rendered as an equal value', () => {
+ const { container, rerender } = render(
+ ,
+ );
+ const iframe = container.querySelector('iframe');
+ rerender();
+ expect(container.querySelector('iframe')).toBe(iframe);
+
+ // A different value does remount: registration happens at mount.
+ rerender();
+ const remounted = container.querySelector('iframe');
+ expect(remounted).not.toBeNull();
+ expect(remounted).not.toBe(iframe);
+ });
+
it('renders the editor iframe inside the host element for the companyIdentifier origin', () => {
const { container } = render();
const iframe = container.querySelector('iframe');
diff --git a/react/src/embed-pdf.tsx b/react/src/embed-pdf.tsx
index f95ef01a..5eca718f 100644
--- a/react/src/embed-pdf.tsx
+++ b/react/src/embed-pdf.tsx
@@ -15,7 +15,7 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
-import { createEmbed, type EmbedDocument } 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): `true` for every agentic operation, `{ exclude: [...] }` to withhold
+ // some (e.g. `submit`). Off by default.
+ enableWebMCP?: WebMCPOptions;
};
type InlineEmbedPDFProps = CommonEmbedPDFProps & {
@@ -123,6 +127,7 @@ type SurfaceProps = {
context?: Record;
logger?: BridgeLogger;
onEmbedEvent?: (event: EmbedEvent) => void | Promise;
+ enableWebMCP?: WebMCPOptions;
className?: string;
style?: React.CSSProperties;
};
@@ -131,7 +136,16 @@ type SurfaceProps = {
// Mount/unmount of this component drives create/dispose, so the modal gets the
// same lifecycle for free (it mounts the surface only while open).
const EmbedSurface = React.forwardRef((props, ref) => {
- const { companyIdentifier, baseDomain, document: embedDocument, locale, context, className, style } = props;
+ const {
+ companyIdentifier,
+ baseDomain,
+ document: embedDocument,
+ locale,
+ context,
+ enableWebMCP,
+ className,
+ style,
+ } = props;
const containerRef = React.useRef(null);
// Keep callbacks + logger in a ref so changing them does not remount the iframe.
@@ -190,6 +204,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 `{ exclude: [...] }`
+ // literal, a reordered list, or `undefined` vs `false` never remounts; the effect
+ // reads the option through a ref so the literal itself stays out of its dependencies.
+ const webMCP = normalizeWebMCPOptions(enableWebMCP);
+ const webMCPKey = webMCP.enabled ? `on:${[...webMCP.exclude].sort().join(',')}` : 'off';
+ const enableWebMCPRef = React.useRef(enableWebMCP);
+ enableWebMCPRef.current = enableWebMCP;
React.useEffect(() => {
const container = containerRef.current;
@@ -204,6 +226,7 @@ const EmbedSurface = React.forwardRef((props,
locale,
context,
logger: stableLogger,
+ enableWebMCP: enableWebMCPRef.current,
});
assignRef(ref, toEmbedActions(embed));
// Forward each editor event to onEmbedEvent as the verbatim { type, data }. The
@@ -244,7 +267,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 +363,7 @@ export const EmbedPDF = React.forwardRef((pr
context={props.context}
logger={props.logger}
onEmbedEvent={props.onEmbedEvent}
+ enableWebMCP={props.enableWebMCP}
className="simplePDF_iframe"
/>
@@ -346,6 +380,7 @@ export const EmbedPDF = React.forwardRef((pr
context={props.context}
logger={props.logger}
onEmbedEvent={props.onEmbedEvent}
+ enableWebMCP={props.enableWebMCP}
className={props.className}
style={props.style}
/>
diff --git a/react/src/index.tsx b/react/src/index.tsx
index 49a86cf9..03cb65d9 100644
--- a/react/src/index.tsx
+++ b/react/src/index.tsx
@@ -11,4 +11,4 @@ export type { EmbedActions, EmbedEvent, EmbedPDFProps } from './embed-pdf';
// The imperative core (createEmbed, the bridge helpers) and the wire-protocol vocabulary stay
// in @simplepdf/embed: a React app uses / useEmbed, so they are intentionally not
// re-exported here. Import them from @simplepdf/embed directly if a non-React path needs them.
-export type { EmbedDocument, FieldType, OverlayToolType } from '@simplepdf/embed';
+export type { AgenticToolName, EmbedDocument, FieldType, OverlayToolType, WebMCPOptions } from '@simplepdf/embed';