Skip to content
Open
8 changes: 8 additions & 0 deletions .changeset/enable-webmcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@simplepdf/embed": minor
"@simplepdf/react-embed-pdf": minor
---

Add `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 `<EmbedPDF enableWebMCP />` 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).
18 changes: 18 additions & 0 deletions embed/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<EmbedPDF>`.

## Subpaths

| Import | Purpose | Peer |
Expand Down Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions embed/etc/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -73,6 +78,7 @@ export type CreateEmbedArgs = {
style?: Partial<CSSStyleDeclaration>;
};
logger?: BridgeLogger;
enableWebMCP?: WebMCPOptions;
};

// @public (undocumented)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -331,6 +347,11 @@ export type SubmitInput = {
// @public (undocumented)
export const unwrap: <TData>(result: BridgeResult<TData>) => TData;

// @public (undocumented)
export type WebMCPOptions = boolean | {
exclude: readonly AgenticToolName[];
};

// (No @packageDocumentation comment for this package)

```
2 changes: 1 addition & 1 deletion embed/etc/protocol.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion embed/etc/tools.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<zod.ZodEnum<{
auto: "auto";
Expand Down
2 changes: 1 addition & 1 deletion embed/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"test": "vitest run",
"test:watch": "vitest",
"check:size": "npm run build && node scripts/check-bundle-size.mjs",
"check:exports": "node ../scripts/check-exports.mjs .",
"check:exports": "node ../scripts/check-exports.mjs . && node scripts/check-lazy-chunks.mjs",
"check:api": "node ../scripts/check-api.mjs .",
"check:contract": "node scripts/embed-contract.mjs",
"fix:contract": "node scripts/embed-contract.mjs --fix"
Expand Down
50 changes: 36 additions & 14 deletions embed/scripts/check-bundle-size.mjs
Original file line number Diff line number Diff line change
@@ -1,32 +1,37 @@
// Bundle-size budget guard, run after `npm run build`. Gzips each public entry's local
// closure (the entry file plus the dist chunks it imports; peer deps are external and
// never counted) and fails if any entry exceeds its budget. Export loadability is guarded
// closure (the entry file plus the dist chunks it imports statically; peer deps are
// external and never counted) and fails if any entry exceeds its budget. A chunk an
// entry only `import()`s lazily is budgeted on its own row (it is downloaded only by
// the consumers that trigger it), so the two costs stay visible separately. Export loadability is guarded
// separately by ../../scripts/check-exports.mjs (the `check:exports` script).

import { existsSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { gzipSync } from 'node:zlib'
import { LAZY_CHUNKS } from './lazy-chunks.mjs'

const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist')

// Gzip budget (bytes) per entry's local closure. Each cap is the current size plus
// ~1 KB of headroom, so any non-trivial growth trips the gate and gets reviewed.
// Gzip budget (bytes) per entry's local closure. Each cap sits 0.5–1.5 KB above the
// measured size, so any non-trivial growth trips the gate and gets reviewed.
// The zero-dep root carries the bridge + createEmbed (create + attach paths) + its
// actionable config validation.
// actionable config validation + the WebMCP opt-in hook.
const BUDGETS = {
'index.js': 8 * 1024,
'index.js': 9 * 1024,
'protocol.js': 3.5 * 1024,
'schemas.js': 3 * 1024,
'tools.js': 5 * 1024,
'ai-sdk.js': 5.5 * 1024,
'tanstack-ai.js': 5.5 * 1024,
}

const localImports = (file) => {
const importsOf = (file, pattern) => {
const content = readFileSync(join(DIST, file), 'utf8')
return [...content.matchAll(/from\s*['"](\.\/[^'"]+)['"]/g)].map((match) => match[1].replace(/^\.\//, ''))
return [...content.matchAll(pattern)].map((match) => match[1].replace(/^\.\//, ''))
}
const localImports = (file) => importsOf(file, /from\s*['"](\.\/[^'"]+)['"]/g)
const lazyImports = (file) => importsOf(file, /import\(['"](\.\/[^'"]+)['"]\)/g)

const closureOf = (entry) => {
const seen = new Set()
Expand All @@ -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)
38 changes: 38 additions & 0 deletions embed/scripts/check-lazy-chunks.mjs
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading