diff --git a/.changeset/enable-webmcp.md b/.changeset/enable-webmcp.md new file mode 100644 index 00000000..70c185da --- /dev/null +++ b/.changeset/enable-webmcp.md @@ -0,0 +1,8 @@ +--- +"@simplepdf/embed": minor +"@simplepdf/react-embed-pdf": minor +--- + +Add `enableWebMCP`: register the editor operations as WebMCP tools on the host page. + +An in-browser agent (ChatGPT's browser, Chrome with WebMCP) discovers tools on the page it is looking at, not inside iframes. `createEmbed({ enableWebMCP: true })` and `` register every agentic operation on the page's `document.modelContext` (same names and camelCase inputs as `@simplepdf/embed/tools`) and forward each call to the editor over the bridge: the PDF bytes stay in the tab and reach no SimplePDF server, while what the agent reads (field values, extracted text) goes to the agent runtime the person attached. `{ exclude: ['submit', ...] }` withholds operations so a person keeps the decision (a malformed value throws `EmbedConfigError`). The readers carry the specification's `readOnlyHint` + `untrustedContentHint`, the other tools MCP's `destructiveHint`; each call resolves with an MCP tool result carrying the editor's Result (`isError` on failure); the editor validates each call like any other request; `dispose()` unregisters everything. Off by default: the WebMCP module loads lazily, once the editor is ready and only when the page exposes a model context, so nobody else downloads it. One WebMCP-enabled embed per page (tool names are page-level). diff --git a/embed/README.md b/embed/README.md index d297956d..ea8da36a 100644 --- a/embed/README.md +++ b/embed/README.md @@ -71,6 +71,23 @@ import { createSimplePDFTools } from '@simplepdf/embed/tanstack-ai' useChat({ connection, tools: createSimplePDFTools({ embed }) }) ``` +## WebMCP site tools + +An agent running in the user's browser (ChatGPT's browser, Chrome with WebMCP) discovers tools on the page it is looking at, not inside iframes. `enableWebMCP` registers the editor's operations on **your** page's `document.modelContext`, forwarding each call to the editor over the bridge. The PDF bytes stay in the tab (nothing reaches a SimplePDF server); what the agent reads through `getFields` / `getDocumentContent` (field values, extracted text) goes to the agent runtime the person attached, so treat that runtime as you would any other party that sees the filled document. + +```ts +// keep the decision with the person: withhold submit (and the page operations), the +// recommended shape when the document can come from a third party (its text reaches +// the agent as untrusted content, and an agent holding `submit` acts on what it reads) +createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, + enableWebMCP: { exclude: ['submit', 'deletePages', 'movePage', 'rotatePage'] } }) + +// every agentic operation (the same names + camelCase inputs as @simplepdf/embed/tools) +createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url }, enableWebMCP: true }) +``` + +Off by default. Tools register once the editor is ready; while no usable model context has been found, the page is probed again on each later lifecycle transition, so a context installed after `EDITOR_READY` is still picked up, and until one appears nothing is loaded (`webmcp.unavailable` is logged, with the reason). The two readers (`getFields`, `getDocumentContent`) carry the specification's `readOnlyHint` and `untrustedContentHint` (their output is document-derived); every other tool carries MCP's `destructiveHint`, read by runtimes that honor MCP's hints. The editor validates each call like any other request (its permission model applies at call time: editing, allowlisted origin, plan, so a tool the tenant configuration refuses resolves with the matching error code), a call resolves with an MCP tool result whose text is the editor's `{ success, data | error }` Result (`isError` on failure), and `dispose()` unregisters everything. A model context is one per page and keyed by tool name, so enable WebMCP on one embed per page: a second one registers only the names the first did not take, and is reported for the rest (`webmcp.tool_already_registered`). In React, pass `enableWebMCP` to ``. + ## Subpaths | Import | Purpose | Peer | @@ -112,6 +129,7 @@ Either way you get the same typed `Embed` handle. | `context` | `object` | opaque data echoed back on submissions | | `iframeAttrs` | `{ title, allow, sandbox, className, style }` | passthrough iframe attributes (container case only); `allow` defaults to `clipboard-read; clipboard-write; web-share` — a custom `allow` MUST keep `web-share` or the editor's iOS share-sheet download is silently denied; a custom `sandbox` MUST include `allow-downloads` (or the editor's Download button is silently blocked) and `allow-modals` (or the editor's "Print document" action is silently ignored) | | `logger` | `BridgeLogger` | structured logs (ids + timing only, never payloads) | +| `enableWebMCP` | `boolean \| { exclude: AgenticToolName[] }` | register the editor operations as WebMCP tools on your page (see [WebMCP site tools](#webmcp-site-tools)); off by default | ## Document source diff --git a/embed/etc/index.api.md b/embed/etc/index.api.md index 5941bf43..3646c660 100644 --- a/embed/etc/index.api.md +++ b/embed/etc/index.api.md @@ -4,6 +4,11 @@ ```ts +// Warning: (ae-forgotten-export) The symbol "AGENTIC_TOOL_NAMES" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type AgenticToolName = (typeof AGENTIC_TOOL_NAMES)[number]; + // @public (undocumented) export type BridgeError = { code: 'bad_request:missing_required_fields'; @@ -73,6 +78,7 @@ export type CreateEmbedArgs = { style?: Partial; }; logger?: BridgeLogger; + enableWebMCP?: WebMCPOptions; }; // @public (undocumented) @@ -289,6 +295,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 AgenticToolName[]; +}; + // Warning: (ae-forgotten-export) The symbol "OVERLAY_TOOL_TYPES" needs to be exported by the entry point index.d.ts // // @public (undocumented) @@ -331,6 +347,11 @@ export type SubmitInput = { // @public (undocumented) export const unwrap: (result: BridgeResult) => TData; +// @public (undocumented) +export type WebMCPOptions = boolean | { + exclude: readonly AgenticToolName[]; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/embed/etc/protocol.api.md b/embed/etc/protocol.api.md index 22d916e8..6d81da09 100644 --- a/embed/etc/protocol.api.md +++ b/embed/etc/protocol.api.md @@ -78,7 +78,7 @@ export const OPERATIONS: readonly [{ readonly request_type: "GET_DOCUMENT_CONTENT"; readonly wire_type: "GET_DOCUMENT_CONTENT"; readonly method: "getDocumentContent"; - readonly description: "Extract the document's text content page by page (pass extraction_mode 'ocr' to force optical recognition). Use it to read what the document says. Returns { name, pages: [{ page, content }] }."; + readonly description: "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }."; readonly error_codes: readonly ["bad_request:invalid_value", "bad_request:no_document_loaded"]; readonly is_agentic_tool: true; readonly has_output: true; diff --git a/embed/etc/tools.api.md b/embed/etc/tools.api.md index 6b9a0e0c..30e9eb00 100644 --- a/embed/etc/tools.api.md +++ b/embed/etc/tools.api.md @@ -64,7 +64,7 @@ export const SIMPLEPDF_TOOLS: { }, zod_v4_core.$strip>; }; readonly getDocumentContent: { - readonly description: "Extract the document's text content page by page (pass extraction_mode 'ocr' to force optical recognition). Use it to read what the document says. Returns { name, pages: [{ page, content }] }."; + readonly description: "Extract the document's content page by page as Markdown (pass extraction_mode 'ocr' to force optical recognition, which returns plain text). Use it to read what the document says. Returns { name, pages: [{ page, content }] }."; readonly inputSchema: zod.ZodObject<{ extractionMode: zod.ZodOptional { +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..4035cfc2 100644 --- a/embed/scripts/generate.mjs +++ b/embed/scripts/generate.mjs @@ -3,13 +3,19 @@ // 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. // - src/generated/schemas.ts : zod schemas (peer dep). Each schema is compile-time // drift-guarded against the plain type in contract.ts, // so a divergence fails `tsc`. +// - src/generated/agentic-tool-names.ts : the agentic tool names alone, the one +// generated VALUE the zero-dep root imports (to +// validate `enableWebMCP.exclude`). +// - src/generated/tool-input-schemas.ts : the agentic operations' input schemas as +// plain JSON (camelCase keys), read only by the +// lazily-loaded WebMCP module. // // 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 @@ -299,6 +305,7 @@ const constArray = (name, values, typeName) => { const contractLines = [] contractLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.') contractLines.push('// Zero runtime dependencies: the zero-dep root imports only from this module.') +contractLines.push("import type { AGENTIC_TOOL_NAMES } from './agentic-tool-names'") contractLines.push('') contractLines.push(constArray('LOCALES', contract.locales, 'Locale')) contractLines.push(constArray('EDITOR_ERROR_CODES', editorErrorCodes, 'EditorErrorCode')) @@ -342,6 +349,54 @@ for (const event of contract.events) { } contractLines.push('') +// The operation's input schema as a WebMCP tool `inputSchema`: the manifest node with +// camelCase property keys at every level (the same SDK-side shape the zod schemas and +// IframeActions use; the bridge lowers the keys to the wire). The root description is +// dropped (the tool description already carries it); everything else rides through. +const toolInputSchema = (node) => { + assertKnownKeywords(node) + if (node.type !== 'object') { + throw new Error(`Unsupported tool input schema root (expected an object): ${JSON.stringify(node)}`) + } + const properties = node.properties ?? {} + for (const required of node.required ?? []) { + if (!(required in properties)) { + throw new Error(`Tool input schema requires '${required}' but declares no such property: ${JSON.stringify(node)}`) + } + } + const camelProperties = Object.fromEntries( + Object.entries(properties).map(([key, property]) => [toCamel(key), toolInputSchemaProperty(property)]), + ) + return { + type: 'object', + ...(Object.keys(camelProperties).length > 0 ? { properties: camelProperties } : {}), + ...(Array.isArray(node.required) && node.required.length > 0 ? { required: node.required.map(toCamel) } : {}), + } +} +const toolInputSchemaProperty = (node) => { + assertKnownKeywords(node) + if (node.const !== undefined || Array.isArray(node.enum)) { + return node + } + if (Array.isArray(node.anyOf)) { + return { ...node, anyOf: node.anyOf.map(toolInputSchemaProperty) } + } + switch (node.type) { + case 'string': + case 'integer': + case 'number': + case 'boolean': + case 'null': + return node + case 'array': + return { ...node, items: toolInputSchemaProperty(node.items) } + case 'object': + return { ...toolInputSchema(node), ...(node.description !== undefined ? { description: node.description } : {}) } + default: + throw new Error(`Unsupported JSON Schema node for a tool input schema: ${JSON.stringify(node)}`) + } +} + // Operation metadata table (the camelCase `method` is the SDK method + agentic tool name). const opMeta = contract.operations.map((op) => { const stem = toPascal(op.request_type) @@ -365,9 +420,11 @@ contractLines.push('export type RequestType = (typeof OPERATIONS)[number]["reque // the bridge transforms to the snake_case wire). The drift guard checks IframeActions // matches MethodName. contractLines.push('export type MethodName = (typeof OPERATIONS)[number]["method"]') -contractLines.push( - 'export type AgenticToolName = Extract<(typeof OPERATIONS)[number], { is_agentic_tool: true }>["method"]', -) +// The agentic tool names live in their own tiny module (createEmbed validates an +// untyped caller's `exclude` against the runtime list, and must not pull this whole +// table into the zero-dep root); the type is derived from it here, and drift.ts pins +// it to the `is_agentic_tool` operations so the two views of one fact cannot diverge. +contractLines.push("export type AgenticToolName = (typeof AGENTIC_TOOL_NAMES)[number]") contractLines.push('') const eventMeta = contract.events.map( @@ -398,6 +455,48 @@ schemaLines.push('') writeFileSync(join(GENERATED_DIR, 'schemas.ts'), renderFile(schemaLines)) +// --- agentic-tool-names.ts (zero runtime deps; the one generated value the root imports) --- + +const agenticToolNames = contract.operations + .filter((op) => !NON_AGENTIC_OPERATIONS.has(op.request_type.toLowerCase())) + .map((op) => toCamel(op.request_type)) +writeFileSync( + join(GENERATED_DIR, 'agentic-tool-names.ts'), + renderFile([ + '// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.', + '// The agentic tool names alone, so createEmbed can validate an `exclude` list without', + '// pulling the operations table into the zero-dep root; contract.ts derives', + '// AgenticToolName from this list.', + `export const AGENTIC_TOOL_NAMES = [${agenticToolNames.map((name) => JSON.stringify(name)).join(', ')}] as const`, + ]), +) + +// --- tool-input-schemas.ts (zero runtime deps, loaded only by the WebMCP module) --- + +const toolInputSchemaLines = [] +toolInputSchemaLines.push('// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand.') +toolInputSchemaLines.push('// The agentic operations\' input schemas as plain JSON Schema with camelCase keys (the') +toolInputSchemaLines.push('// SDK-side shape; the bridge lowers the keys to the wire). Read only by src/webmcp.ts,') +toolInputSchemaLines.push('// which is lazy-loaded, so this table never lands in an entry that did not opt in.') +toolInputSchemaLines.push("import type { AgenticToolName } from './contract'") +toolInputSchemaLines.push('') +toolInputSchemaLines.push('export type ToolInputSchema = {') +toolInputSchemaLines.push(" readonly type: 'object'") +toolInputSchemaLines.push(' readonly properties?: Readonly>') +toolInputSchemaLines.push(' readonly required?: readonly string[]') +toolInputSchemaLines.push('}') +toolInputSchemaLines.push('') +toolInputSchemaLines.push('export const TOOL_INPUT_SCHEMAS = {') +for (const op of contract.operations) { + if (NON_AGENTIC_OPERATIONS.has(op.request_type.toLowerCase())) { + continue + } + toolInputSchemaLines.push(` ${toCamel(op.request_type)}: ${JSON.stringify(toolInputSchema(op.input_schema))},`) +} +toolInputSchemaLines.push('} as const satisfies Record') + +writeFileSync(join(GENERATED_DIR, 'tool-input-schemas.ts'), renderFile(toolInputSchemaLines)) + // --- drift.ts (compile-time drift guards; type-checked, not bundled) -------- // One exported tuple gathers every guard so noUnusedLocals stays happy while the // type-parameter constraints still fail the build the instant a representation @@ -419,6 +518,9 @@ driftLines.push('// every generated outbound event must appear in the hand-maint driftLines.push("// (so React's onEmbedEvent forwarders, guarded against EditorEvent, can't miss one).") driftLines.push('export type DriftGuards = [') driftLines.push(" AssertTrue>,") +driftLines.push( + ' AssertTrue["method"]>>,', +) driftLines.push(" AssertTrue>,") for (const op of contract.operations) { const stem = toPascal(op.request_type) @@ -456,5 +558,5 @@ writeFileSync(join(GENERATED_DIR, 'tools.ts'), renderFile(toolLines)) console.log( `Generated contract.ts (${contract.operations.length} ops, ${contract.events.length} events, ` + - `${contract.locales.length} locales, ${editorErrorCodes.length} editor error codes) + schemas.ts`, + `${contract.locales.length} locales, ${editorErrorCodes.length} editor error codes) + schemas.ts + agentic-tool-names.ts + tool-input-schemas.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..4adc8510 100644 --- a/embed/src/bridge.ts +++ b/embed/src/bridge.ts @@ -12,6 +12,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 +27,8 @@ export type AttachEmbedArgs = { // Optional teardown hook invoked once on dispose() after the bridge has cleaned // up (createEmbed's create path uses it to remove the iframe it created). onDispose?: () => void + // Expose the editor operations as WebMCP tools on the host page (see ./webmcp). + enableWebMCP?: WebMCPOptions // Internal wiring for createEmbed's "load the document once ready" flow: called on // every lifecycle transition (booting -> editorReady -> documentLoaded), including // readiness reached via the liveness probe (which emits no editor event). NOT a @@ -103,6 +106,7 @@ export const attachEmbed = ({ logger: providedLogger = NOOP_LOGGER, onDispose, onStateChange, + enableWebMCP, }: AttachEmbedArgs): Embed => { const logger = makeSafeLogger(providedLogger) const pending = new Map() @@ -155,9 +159,52 @@ 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 webMCP = normalizeWebMCPOptions(enableWebMCP) + // 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 (!webMCP.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: sendRequest, + exclude: webMCP.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> => @@ -470,6 +517,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/agentic-tool-names.ts b/embed/src/generated/agentic-tool-names.ts new file mode 100644 index 00000000..904938e9 --- /dev/null +++ b/embed/src/generated/agentic-tool-names.ts @@ -0,0 +1,5 @@ +// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand. +// The agentic tool names alone, so createEmbed can validate an `exclude` list without +// pulling the operations table into the zero-dep root; contract.ts derives +// AgenticToolName from this list. +export const AGENTIC_TOOL_NAMES = ["createField", "deleteFields", "deletePages", "detectFields", "download", "focusField", "getDocumentContent", "getFields", "goTo", "movePage", "rotatePage", "selectTool", "setFieldValue", "submit"] as const diff --git a/embed/src/generated/contract.ts b/embed/src/generated/contract.ts index a35fdc12..bc40e257 100644 --- a/embed/src/generated/contract.ts +++ b/embed/src/generated/contract.ts @@ -1,5 +1,6 @@ // AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand. // Zero runtime dependencies: the zero-dep root imports only from this module. +import type { AGENTIC_TOOL_NAMES } from './agentic-tool-names' export const LOCALES = ["fr", "en", "it", "de", "pt", "es", "ja", "nl"] as const export type Locale = (typeof LOCALES)[number] @@ -196,7 +197,7 @@ export const OPERATIONS = [ export type WireType = (typeof OPERATIONS)[number]["wire_type"] export type RequestType = (typeof OPERATIONS)[number]["request_type"] export type MethodName = (typeof OPERATIONS)[number]["method"] -export type AgenticToolName = Extract<(typeof OPERATIONS)[number], { is_agentic_tool: true }>["method"] +export type AgenticToolName = (typeof AGENTIC_TOOL_NAMES)[number] export const OUTBOUND_EVENTS = [ { event_type: "PAGE_FOCUSED", description: "Pushed when the focused page changes (the user scrolls to a new page, or a GO_TO completes). The payload reports the current page." }, diff --git a/embed/src/generated/drift.ts b/embed/src/generated/drift.ts index cbb2d9d1..e69f6004 100644 --- a/embed/src/generated/drift.ts +++ b/embed/src/generated/drift.ts @@ -13,6 +13,7 @@ type AssertTrue = T // (so React's onEmbedEvent forwarders, guarded against EditorEvent, can't miss one). export type DriftGuards = [ AssertTrue>, + AssertTrue["method"]>>, AssertTrue>, AssertTrue>, AssertTrue>, diff --git a/embed/src/generated/tool-input-schemas.ts b/embed/src/generated/tool-input-schemas.ts new file mode 100644 index 00000000..59345321 --- /dev/null +++ b/embed/src/generated/tool-input-schemas.ts @@ -0,0 +1,28 @@ +// AUTO-GENERATED from embed-api.json by scripts/generate.mjs. Do not edit by hand. +// The agentic operations' input schemas as plain JSON Schema with camelCase keys (the +// SDK-side shape; the bridge lowers the keys to the wire). Read only by src/webmcp.ts, +// which is lazy-loaded, so this table never lands in an entry that did not opt in. +import type { AgenticToolName } from './contract' + +export type ToolInputSchema = { + readonly type: 'object' + readonly properties?: Readonly> + readonly required?: readonly string[] +} + +export const TOOL_INPUT_SCHEMAS = { + createField: {"type":"object","properties":{"type":{"type":"string","enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"],"description":"Field type to create."},"x":{"type":"number","description":"Field x position, in PDF points."},"y":{"type":"number","description":"Field y position, in PDF points."},"width":{"type":"number","description":"Field width, in PDF points."},"height":{"type":"number","description":"Field height, in PDF points."},"page":{"type":"integer","description":"1-based page to place the field on."},"value":{"description":"Optional initial value. A string for text/checkbox fields, or a data URL for signature/picture fields.","type":"string"}},"required":["type","x","y","width","height","page"]}, + deleteFields: {"type":"object","properties":{"fieldIds":{"description":"IDs of the fields to delete. Omit to delete every field on the target page.","type":"array","items":{"type":"string"}},"page":{"description":"1-based page to scope the deletion to. Omit to target all pages.","type":"integer"}}}, + deletePages: {"type":"object","properties":{"pages":{"type":"array","items":{"type":"integer"},"description":"1-based page numbers to delete."}},"required":["pages"]}, + detectFields: {"type":"object"}, + download: {"type":"object"}, + focusField: {"type":"object","properties":{"fieldId":{"type":"string","description":"ID of the field to focus and scroll into view."}},"required":["fieldId"]}, + getDocumentContent: {"type":"object","properties":{"extractionMode":{"description":"Extraction strategy: 'auto' (default) or 'ocr' to force optical recognition.","type":"string","enum":["auto","ocr"]}}}, + getFields: {"type":"object"}, + goTo: {"type":"object","properties":{"page":{"type":"integer","description":"1-based page to navigate to."}},"required":["page"]}, + movePage: {"type":"object","properties":{"fromPage":{"type":"integer","description":"1-based current position of the page to move."},"toPage":{"type":"integer","description":"1-based destination position for the page."}},"required":["fromPage","toPage"]}, + rotatePage: {"type":"object","properties":{"page":{"type":"integer","description":"1-based page to rotate 90 degrees clockwise."}},"required":["page"]}, + selectTool: {"type":"object","properties":{"tool":{"anyOf":[{"type":"string","enum":["TEXT","SIGNATURE","PICTURE","CHECKBOX","COMB_TEXT"]},{"type":"null"}],"description":"Tool to activate, or null to deselect."}},"required":["tool"]}, + setFieldValue: {"type":"object","properties":{"fieldId":{"type":"string","description":"ID of the field to update."},"value":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"New value for the field, or null to clear it. If the field has options (see get_fields), it must be one of them; otherwise a string (text/checkbox) or a data URL (signature/picture)."}},"required":["fieldId","value"]}, + submit: {"type":"object","properties":{"downloadCopy":{"type":"boolean","description":"When true, the signer also receives a downloaded copy on submit."}},"required":["downloadCopy"]}, +} as const satisfies Record diff --git a/embed/src/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/mount.ts b/embed/src/mount.ts index 09838ab1..99a1b926 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 { AGENTIC_TOOL_NAMES } from './generated/agentic-tool-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. + // `true` registers every agentic operation, `{ exclude: [...] }` withholds some (e.g. + // `submit` when only a person may finalize). Off by default. + enableWebMCP?: WebMCPOptions } const resolveTarget = (target: unknown): HTMLElement => { @@ -194,6 +201,41 @@ const assertValidFileArm = (file: unknown): void => { } } +const AGENTIC_TOOL_NAME_SET: ReadonlySet = new Set(AGENTIC_TOOL_NAMES) + +// `enableWebMCP.exclude` withholds irreversible operations from an agent, so a +// malformed value or a misspelled name from an untyped JS caller must fail loud +// rather than register the operation it meant to withhold. +const assertValidWebMCPOptions = (enableWebMCP: unknown): void => { + if (enableWebMCP === undefined || typeof enableWebMCP === 'boolean') { + return + } + const excludeList = ((): string[] | null => { + if (typeof enableWebMCP !== 'object' || enableWebMCP === null || !('exclude' in enableWebMCP)) { + return null + } + const { exclude } = enableWebMCP + if (!Array.isArray(exclude)) { + return null + } + const entries: unknown[] = exclude + return entries.every((name): name is string => typeof name === 'string') ? entries : null + })() + if (excludeList === null) { + throw new EmbedConfigError( + 'invalid_config', + `enableWebMCP must be a boolean or { exclude: AgenticToolName[] } (received ${describeValue(enableWebMCP)}).`, + ) + } + const unknownNames = excludeList.filter((name) => !AGENTIC_TOOL_NAME_SET.has(name)) + if (unknownNames.length > 0) { + throw new EmbedConfigError( + 'invalid_config', + `enableWebMCP.exclude names no tool: ${unknownNames.join(', ')} (known: ${AGENTIC_TOOL_NAMES.join(', ')}).`, + ) + } +} + const assertValidDocument = (document: unknown): void => { if (document === undefined) { return @@ -461,7 +503,7 @@ const loadDocumentWhenReady = (params: { const attachToIframe = ( iframe: HTMLIFrameElement, editorOrigin: string, - { document: embedDocument, logger = NOOP_LOGGER }: CreateEmbedArgs, + { document: embedDocument, logger = NOOP_LOGGER, enableWebMCP }: CreateEmbedArgs, documentsUrl: { url: URL; origin: string } | null, ): Embed => { // A documents URL loads by NAVIGATING the iframe, which we only do for an iframe @@ -508,6 +550,7 @@ const attachToIframe = ( logger: safeLogger, onDispose: () => documentFetchController.abort(), onStateChange: gate.onStateChange, + enableWebMCP, }) if (embedDocument !== undefined) { loadDocumentWhenReady({ @@ -527,7 +570,7 @@ const attachToIframe = ( const mountIntoContainer = ( container: HTMLElement, editorOrigin: string, - { document: mountDocument, locale, context, iframeAttrs, logger = NOOP_LOGGER }: CreateEmbedArgs, + { document: mountDocument, locale, context, iframeAttrs, logger = NOOP_LOGGER, enableWebMCP }: CreateEmbedArgs, documentsUrl: { url: URL; origin: string } | null, ): Embed => { const hasDocumentUrl = mountDocument !== undefined && 'url' in mountDocument @@ -607,6 +650,7 @@ const mountIntoContainer = ( documentFetchController.abort() iframe.remove() }, + enableWebMCP, }) // A documents URL is loaded by the navigation above; only the PDF / data-URL / @@ -655,6 +699,7 @@ export const createEmbed = (args: CreateEmbedArgs): Embed => { throw new EmbedConfigError('invalid_config', `baseDomain must be a string (received ${describeValue(args.baseDomain)}).`) } assertValidDocument(args.document) + assertValidWebMCPOptions(args.enableWebMCP) const baseDomain = args.baseDomain ?? DEFAULT_BASE_DOMAIN // A SimplePDF documents URL carries its own origin (a possibly-different // companyIdentifier subdomain); the bridge then targets that origin instead of diff --git a/embed/src/types.ts b/embed/src/types.ts index 8caa0a5c..30451371 100644 --- a/embed/src/types.ts +++ b/embed/src/types.ts @@ -29,6 +29,7 @@ import type { } from './generated/contract' export type { + AgenticToolName, CreateFieldInput, CreateFieldOutput, DeleteFieldsInput, diff --git a/embed/src/webmcp-shared.ts b/embed/src/webmcp-shared.ts new file mode 100644 index 00000000..3f6f07f3 --- /dev/null +++ b/embed/src/webmcp-shared.ts @@ -0,0 +1,38 @@ +// What the zero-dep root needs to know about WebMCP without loading the module: +// the option shape and where a model context lives. + +import type { AgenticToolName } from './generated/contract' + +// Every value a page may expose as its model context, document first (the canonical +// install location since Chrome 150; `navigator.modelContext` is the deprecated alias +// older runtimes still expose). The bridge checks presence, the module validity. +export const modelContextCandidates = (): unknown[] => { + const candidates: unknown[] = [] + if ('modelContext' in document) { + candidates.push(document.modelContext) + } + if ('modelContext' in navigator) { + candidates.push(navigator.modelContext) + } + return candidates +} + +// `true` registers every agentic operation; `exclude` withholds the listed ones +// (e.g. `submit` when only a person may finalize). `false` / omitted registers nothing. +export type WebMCPOptions = boolean | { exclude: readonly AgenticToolName[] } + +// The one decoder of the option shape: the bridge (start or not), the WebMCP module +// (what to withhold) and the React layer (a remount key) all read this instead of +// re-deriving the `undefined | false | true | { exclude }` cases. +/** @internal Shared with @simplepdf/react-embed-pdf; not part of the consumer contract. */ +export const normalizeWebMCPOptions = ( + options: WebMCPOptions | undefined, +): { enabled: false } | { enabled: true; exclude: readonly AgenticToolName[] } => { + if (options === undefined || options === false) { + return { enabled: false } + } + if (options === true) { + return { enabled: true, exclude: [] } + } + return { enabled: true, exclude: options.exclude } +} diff --git a/embed/src/webmcp.ts b/embed/src/webmcp.ts new file mode 100644 index 00000000..d1d6fc77 --- /dev/null +++ b/embed/src/webmcp.ts @@ -0,0 +1,143 @@ +// 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. +// +// Loaded lazily by the bridge, once the editor is ready and only when `enableWebMCP` +// is set and the page exposes a model context, so nothing here (nor the schema table +// it reads) is downloaded otherwise. +// CF: https://webmachinelearning.github.io/webmcp/ + +import { OPERATIONS, type AgenticToolName, type WireType } from './generated/contract' +import { TOOL_INPUT_SCHEMAS, type ToolInputSchema } from './generated/tool-input-schemas' +import type { BridgeLogger } from './logger' +import type { BridgeResult } from './types' +import { modelContextCandidates } from './webmcp-shared' + +// The slice of the WebMCP surface this module touches, typed structurally so the +// zero-dependency root pulls in no type package. `readOnlyHint` and +// `untrustedContentHint` are the specification's annotations; `destructiveHint` is +// MCP's, read by runtimes that carry MCP's hint vocabulary and ignored by the others. +type ToolAnnotations = { readOnlyHint?: boolean; untrustedContentHint?: boolean; destructiveHint?: boolean } +// The MCP tool-result envelope. The specification serializes whatever `execute` +// resolves with; this shape is what the runtimes in the field read (and what the +// editor's own in-page tools return), a failed Result additionally flagged `isError`. +type CallToolResult = { content: Array<{ type: 'text'; text: string }>; isError?: boolean } +type WebMCPTool = { + name: string + description: string + inputSchema: ToolInputSchema + annotations: ToolAnnotations + execute: (input: unknown) => Promise +} +type ModelContext = { + registerTool: (tool: WebMCPTool, options: { signal: AbortSignal }) => unknown +} + +type Operation = (typeof OPERATIONS)[number] +type AgenticOperation = Extract + +// The two readers return document-derived content (field values, extracted text), +// which is untrusted from the page's perspective. Every writer declares whether it +// removes or reorders content or finalizes the document; setting a field value is +// not destructive here because the person reviews every value in the editor before +// the one irreversible step, submit. The Record makes a new operation a compile +// error until it is annotated. Kept identical to the editor's in-page tool hints. +// CF: WEBMCP_TOOL_ANNOTATIONS in the editor's lib/iframe/contract.ts (SimplePDF editor repository) +const TOOL_ANNOTATIONS = { + createField: { destructiveHint: false }, + deleteFields: { destructiveHint: true }, + deletePages: { destructiveHint: true }, + detectFields: { destructiveHint: false }, + download: { destructiveHint: false }, + focusField: { destructiveHint: false }, + getDocumentContent: { readOnlyHint: true, untrustedContentHint: true }, + getFields: { readOnlyHint: true, untrustedContentHint: true }, + goTo: { destructiveHint: false }, + movePage: { destructiveHint: true }, + rotatePage: { destructiveHint: true }, + selectTool: { destructiveHint: false }, + setFieldValue: { destructiveHint: false }, + submit: { destructiveHint: true }, +} satisfies Record + +const isAgenticOperation = (operation: Operation): operation is AgenticOperation => operation.is_agentic_tool + +const isModelContext = (value: unknown): value is ModelContext => + typeof value === 'object' && value !== null && 'registerTool' in value && typeof value.registerTool === 'function' + +const readModelContext = (): ModelContext | null => modelContextCandidates().find(isModelContext) ?? null + +const toCallToolResult = (result: BridgeResult): CallToolResult => ({ + content: [{ type: 'text', text: JSON.stringify(result) }], + ...(result.success ? {} : { isError: true }), +}) + +// A model context is a page-level singleton keyed by tool name, so two embeds on one +// page would collide; the first registration of a name wins and the rest are reported. +// 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, +}: { + dispatch: (wireType: WireType, data: unknown) => Promise> + exclude: readonly AgenticToolName[] + 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 operation of OPERATIONS) { + if (!isAgenticOperation(operation) || excluded.has(operation.method)) { + continue + } + if (liveTools.has(operation.method)) { + logger.warn('webmcp.tool_already_registered', { tool: operation.method }) + continue + } + const tool: WebMCPTool = { + name: operation.method, + description: operation.description, + inputSchema: TOOL_INPUT_SCHEMAS[operation.method], + annotations: TOOL_ANNOTATIONS[operation.method], + // A nullish input becomes an empty payload (the no-input operations' wire shape). + execute: async (input) => toCallToolResult(await dispatch(operation.wire_type, 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/mount.test.ts b/embed/test/mount.test.ts index f8b40c72..835723a2 100644 --- a/embed/test/mount.test.ts +++ b/embed/test/mount.test.ts @@ -382,4 +382,27 @@ describe(createEmbed.name, () => { // @ts-expect-error exercising the runtime guard for untyped JS callers expect(() => createEmbed({ target: '#root', companyIdentifier: 'acme', baseDomain: 123 })).toThrow(/baseDomain must be a string/) }) + + // Every malformed shape an untyped JS caller can produce fails loud: `exclude` is + // the control that withholds irreversible operations, so it must never fail open. + it.each([ + ['a string exclude', { exclude: 'submit' }], + ['an option object without exclude', {}], + ['a stringly-typed flag', 'false'], + ['a number', 0], + ['null', null], + ['a non-string exclude entry', { exclude: ['submit', 7] }], + ])('throws EmbedConfigError when enableWebMCP is %s', (_label, enableWebMCP) => { + document.body.innerHTML = '
' + const malformedArgs: unknown = { target: '#root', companyIdentifier: 'acme', enableWebMCP } + // @ts-expect-error exercising the runtime guard for untyped JS callers + expect(() => createEmbed(malformedArgs)).toThrow(/enableWebMCP must be a boolean or \{ exclude: AgenticToolName\[\] \}/) + }) + + it('throws EmbedConfigError when exclude names no tool, so a misspelled name cannot register the operation it meant to withhold', () => { + document.body.innerHTML = '
' + const misspelled: unknown = { target: '#root', companyIdentifier: 'acme', enableWebMCP: { exclude: ['sumbit'] } } + // @ts-expect-error exercising the runtime guard for untyped JS callers + expect(() => createEmbed(misspelled)).toThrow(/enableWebMCP\.exclude names no tool: sumbit \(known: createField/) + }) }) diff --git a/embed/test/webmcp.test.ts b/embed/test/webmcp.test.ts new file mode 100644 index 00000000..ba50194c --- /dev/null +++ b/embed/test/webmcp.test.ts @@ -0,0 +1,365 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { attachEmbed, type AttachEmbedArgs } from '../src/bridge' +import type { BridgeLogger } from '../src/logger' +import type { Embed } from '../src/types' +import { AGENTIC_TOOL_NAMES } from '../src/generated/agentic-tool-names' + +const EDITOR_ORIGIN = 'https://tenant.simplepdf.com' + +// 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 } + execute: (input: unknown) => Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> +} +type FakeModelContext = { + registerTool: (tool: RegisteredTool, options: { signal: AbortSignal }) => void + registered: RegisteredTool[] + liveToolNames: () => string[] +} + +const originalDocumentModelContext = Object.getOwnPropertyDescriptor(document, 'modelContext') +const originalNavigatorModelContext = Object.getOwnPropertyDescriptor(navigator, 'modelContext') + +const restoreModelContext = (target: object, descriptor: PropertyDescriptor | undefined): void => { + if (descriptor === undefined) { + Reflect.deleteProperty(target, 'modelContext') + return + } + Object.defineProperty(target, 'modelContext', descriptor) +} + +// A minimal native-like model context: it records registrations and drops a tool from +// the live set when its registration signal aborts (the spec's unregister mechanism). +const installModelContext = ( + host: Document | Navigator, + { rejectTool }: { rejectTool?: string } = {}, +): FakeModelContext => { + const registered: RegisteredTool[] = [] + const liveTools = new Set() + const modelContext: FakeModelContext = { + registerTool: (tool, { signal }) => { + if (tool.name === rejectTool) { + throw new Error(`runtime rejected ${tool.name}`) + } + registered.push(tool) + liveTools.add(tool.name) + signal.addEventListener('abort', () => liveTools.delete(tool.name), { once: true }) + }, + registered, + liveToolNames: () => [...liveTools], + } + Object.defineProperty(host, 'modelContext', { configurable: true, value: modelContext }) + return modelContext +} + +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 = AGENTIC_TOOL_NAMES.length + +// The bridge's readiness probe posts its own GET_FIELDS requests while the editor is +// booting, so a tool call's request is located by type rather than by position. +const waitForRequest = async (harness: Harness, type: string): Promise => { + await vi.waitFor(() => expect(harness.posted.some((message) => message.type === type)).toBe(true)) + const request = harness.posted.find((message) => message.type === type) + if (request === undefined) { + throw new Error(`no ${type} request posted`) + } + return request +} + +const findTool = (modelContext: FakeModelContext, name: string): RegisteredTool => { + const tool = modelContext.registered.find((candidate) => candidate.name === name) + if (tool === undefined) { + throw new Error(`tool ${name} was not registered`) + } + return tool +} + +describe('attachEmbed({ enableWebMCP })', () => { + afterEach(() => { + for (const harness of harnesses) { + harness.embed.lifecycle.dispose() + } + harnesses.length = 0 + document.body.innerHTML = '' + restoreModelContext(document, originalDocumentModelContext) + restoreModelContext(navigator, originalNavigatorModelContext) + vi.restoreAllMocks() + }) + + it('registers every agentic operation on document.modelContext with the SDK name, description, camelCase input schema and an explicit behavior hint', async () => { + const modelContext = installModelContext(document) + 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';