ext-apps/examples/pdf-server
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Olivier Chafik 800f6d08fd
fix(pdf-server): viewer liveness, 1:1 batch results, fullscreen jitter (#579)
* fix(pdf-server): fail fast on never-polled viewer + surface batch errors

interact's get_screenshot/get_text waited the full 45s for a viewer that
never mounted (host went idle before iframe reached startPolling), then
timed out with a generic message. Now tracks viewsPolled and fails in ~8s
with a recovery hint. Batch errors were also ordered success-first —
hosts that flatten isError to content[0] showed "Queued: Filled N fields"
and dropped the actual failure entirely. Viewer side: poll even when PDF
load throws, and catch connect() rejection instead of silently blanking.

* fix(pdf-server): squash batch errors into one block, warn on phantom annotation updates

Three issues from a hung interact in production:

1. Batch error swallowed by host flattening. The earlier fix unshifted the
   error before successes, but the prefix went on top of THAT — so
   content[0] was just "Batch failed at step 2/2 (get_screenshot):" with
   the actual timeout text in content[1], which the host dropped. Now all
   text joins into one block; images survive after.

2. update_annotations silent no-op. The viewer's processCommands does
   `if (!existing) continue` for unknown ids. If a prior add_annotations
   failed mid-batch the model gets a happy "Queued" while nothing renders.
   Track ids server-side; warn (not error — user might have drawn the
   annotation manually in the iframe).

3. Per-def isolation in the viewer. A throw in addAnnotation killed the
   whole processCommands batch including any get_pages later in the queue,
   so submit_page_data never fired and the server waited the full 45s.
   Now each def gets its own try/catch.

Also: stderr logging on interact failure (only — not chatty per-call) so
the next hang shows step timing in mcp-server-*.log instead of nowhere.

* fix(pdf-server): images first on success too, not just on error

The host's content[0] flattening drops images on successful batches just
the same: [add_annotations, get_screenshot] → content = ["Queued: ...",
<image>] → model sees only the text. Observed in audit logs where the
exact same iframe + viewUUID returned an image when the batch was
[get_screenshot, get_screenshot] but only text when add_annotations
came first.

Stable-sort images to the front. The Queued lines still arrive, just
after what the model actually asked to see.

* Revert "fix(pdf-server): images first on success too, not just on error"

This reverts commit 129a2dcd7a04e5bd116e59c4d6fc211db4203e2e.

* refactor(pdf-server): 1:1 batch results, drop isError when there's something to lose

LocalAgentMode SDK collapses isError:true results to a bare string of
content[0].text. The squash-everything-into-one-block defense from
0dbb8ed3 worked but lost the positional contract — the model couldn't
tell which command produced which result.

Cleaner: content[i] is the result of commands[i]. If a step fails the
batch stops there with that slot's text starting with "ERROR", and
content.length tells the model how far it got. No isError on multi-step
failures — there's no way to set it without the SDK eating the prior
images. Single-command failures keep isError:true since there's nothing
before content[0] to lose; the SDK's flatten-to-content[0].text is
exactly the ERROR text we want shown.

Tool description documents the contract.

* fix(pdf-server): isError only for single-command failures, not failedAt===0

failedAt===0 in a multi-step batch still has a positional contract to
honor — content.length=1 vs commandList.length=3 is a signal the model
loses if the SDK flattens the array to a string. The ERROR text encodes
"step 1/3" but the simpler rule ("batches never set isError") is
easier to document and reason about.

* fix(pdf-viewer): read live displayMode in requestFitToContent, guard tiny measurements

currentDisplayMode defaults to "inline" and handleHostContextChanged
only writes it `if (ctx.displayMode)`. If the host's initial context
omits the field — or the update lands one tick after a renderPage() —
requestFitToContent measures a near-empty pageWrapper and sends ~85px
(toolbar + padding only), shrinking a fullscreen iframe to a sliver.
Workaround was exit + re-enter fullscreen to force a context update.

Read app.getHostContext().displayMode directly (always fresh), and
separately refuse to send when pageWrapper measures smaller than the
toolbar — that's never a real layout, just an early render.

* revert(pdf-server): drop viewAnnotationIds — wrong about source of truth

The viewer's annotationMap is baseline (loadBaselineAnnotations from the
PDF) + diff restored from localStorage + manual edits + model adds.
viewAnnotationIds only tracks the last category, so the warning fires
for any update to an annotation the model didn't itself add — including
the entire restored state.

That's not "best-effort" miss rate, it's structurally wrong about
where the data lives. Model would learn to ignore the warning. The
viewer-side log.error on the silent skip (mcp-app.ts:4251) is the right
layer for this.
2026-04-02 00:49:29 +01:00
..
src fix(pdf-server): viewer liveness, 1:1 batch results, fullscreen jitter (#579) 2026-04-02 00:49:29 +01:00
.gitignore pdf-server: robust path validation + folder/file root support (#497) 2026-02-24 18:23:57 +00:00
.mcpbignore pdf-server: robust path validation + folder/file root support (#497) 2026-02-24 18:23:57 +00:00
grid-cell.png pdf-server: annotations, interact tool, page extraction & prompt engineering (#506) 2026-03-19 17:41:55 +00:00
icon.png Add MCPB packaging and Claude Code plugin for pdf-server (#491) 2026-02-24 00:51:43 +00:00
main.ts pdf-server: annotations, interact tool, page extraction & prompt engineering (#506) 2026-03-19 17:41:55 +00:00
manifest.json Add MCPB packaging and Claude Code plugin for pdf-server (#491) 2026-02-24 00:51:43 +00:00
mcp-app.html pdf-server: annotations, interact tool, page extraction & prompt engineering (#506) 2026-03-19 17:41:55 +00:00
package.json chore: bump ext-apps to 1.3.2 (#569) 2026-03-27 17:45:09 +00:00
pdf-icon.svg Adding a simple (bad) pdf icon for directory (#330) 2026-01-23 16:04:46 +00:00
README.md pdf-server: add save_as interact action (#580) 2026-04-01 22:45:00 +01:00
screenshot.png pdf-server: annotations, interact tool, page extraction & prompt engineering (#506) 2026-03-19 17:41:55 +00:00
server.test.ts fix(pdf-server): viewer liveness, 1:1 batch results, fullscreen jitter (#579) 2026-04-02 00:49:29 +01:00
server.ts fix(pdf-server): viewer liveness, 1:1 batch results, fullscreen jitter (#579) 2026-04-02 00:49:29 +01:00
tsconfig.json feat(pdf-server): lazy range-based loading + eager background preloading (#480) 2026-02-21 16:33:48 -08:00
tsconfig.server.json feat: compile example servers to JavaScript for npm consumption (#259) 2026-01-14 23:46:17 +00:00
vite.config.ts examples: add PDF viewer w/ chunked data loading, full-screen, model context updates, private tool (#267) 2026-01-14 14:25:24 +00:00

PDF Server

Screenshot

An interactive PDF viewer using PDF.js. Supports local files and remote URLs from academic sources (arxiv, biorxiv, zenodo, etc).

MCP Client Configuration

Add to your MCP client configuration (stdio transport):

{
  "mcpServers": {
    "pdf": {
      "command": "npx",
      "args": [
        "-y",
        "--silent",
        "--registry=https://registry.npmjs.org/",
        "@modelcontextprotocol/server-pdf",
        "--stdio"
      ]
    }
  }
}

Local Development

To test local modifications, use this configuration (replace ~/code/ext-apps with your clone path):

{
  "mcpServers": {
    "pdf": {
      "command": "bash",
      "args": [
        "-c",
        "cd ~/code/ext-apps/examples/pdf-server && npm run build >&2 && node dist/index.js --stdio"
      ]
    }
  }
}

What This Example Demonstrates

1. Chunked Data Through Size-Limited Tool Calls

On some host platforms, tool calls have size limits, so large PDFs cannot be sent in a single response. This example streams PDFs in chunks using HTTP Range requests:

Server side (server.ts):

// Returns chunks with pagination metadata
{
  (bytes, offset, byteCount, totalBytes, hasMore);
}

Client side (mcp-app.ts):

// Load in chunks with progress
while (hasMore) {
  const chunk = await app.callServerTool({
    name: "read_pdf_bytes",
    arguments: { url, offset },
  });
  chunks.push(base64ToBytes(chunk.bytes));
  offset += chunk.byteCount;
  hasMore = chunk.hasMore;
  updateProgress(offset, chunk.totalBytes);
}

2. Model Context Updates

The viewer keeps the model informed about what the user is seeing:

app.updateModelContext({
  content: [
    {
      type: "text",
      text: `PDF viewer | "${title}" | Current Page: ${page}/${total}\n\nPage content:\n${pageText}`,
    },
  ],
});

This enables the model to answer questions about the current page or selected text.

3. Display Modes: Fullscreen vs Inline

  • Inline mode: App requests height changes to fit content
  • Fullscreen mode: App fills the screen with internal scrolling
// Request fullscreen
app.requestDisplayMode({ mode: "fullscreen" });

// Listen for mode changes
app.ondisplaymodechange = (mode) => {
  if (mode === "fullscreen") enableScrolling();
  else disableScrolling();
};

The viewer demonstrates opening external links (e.g., to the original arxiv page):

titleEl.onclick = () => app.openLink(sourceUrl);

5. View Persistence

Page position is saved per-view using viewUUID and localStorage.

6. Dark Mode / Theming

The viewer syncs with the host's theme using CSS light-dark() and the SDK's theming APIs:

app.onhostcontextchanged = (ctx) => {
  if (ctx.theme) applyDocumentTheme(ctx.theme);
  if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
};

Usage

# Default: loads a sample arxiv paper
bun examples/pdf-server/main.ts

# Load local files (converted to file:// URLs)
bun examples/pdf-server/main.ts ./docs/paper.pdf /path/to/thesis.pdf

# Load from URLs
bun examples/pdf-server/main.ts https://arxiv.org/pdf/2401.00001.pdf

# Mix local and remote
bun examples/pdf-server/main.ts ./local.pdf https://arxiv.org/pdf/2401.00001.pdf

# stdio mode for MCP clients
bun examples/pdf-server/main.ts --stdio ./papers/

Additional Flags

  • --debug — Enable verbose server-side logging.
  • --enable-interact — Enable the interact tool in HTTP mode (see Deployment). Not needed for stdio.
  • --writeable-uploads-root — Allow saving annotated PDFs back to files under client roots named uploads (Claude Desktop mounts attachments there; writes are refused by default).

Deployment

The interact tool relies on an in-memory command queue (server enqueues → viewer polls). This constrains how the server can be deployed:

  • stdio (Claude Desktop) — interact is always enabled. The server runs as a single long-lived process, so the in-memory queue works.
  • HTTP, single instance — Pass --enable-interact to opt in. Works as long as all requests land on the same process.
  • HTTP, stateless / multi-instance — interact will not work. Commands enqueued on one instance are invisible to viewers polling another. Leave the flag off; the tool will not be registered.

Security: Client Roots

MCP clients may advertise roots — file:// URIs pointing to directories on the client's file system. The server uses these to allow access to local files under those directories.

  • Stdio mode (--stdio): Client roots are always enabled — the client is typically on the same machine (e.g. Claude Desktop), so the roots are safe.
  • HTTP mode (default): Client roots are ignored by default — the client may be remote, and its roots would be resolved against the server's filesystem. To opt in, pass --use-client-roots:
# Trust that the HTTP client is local and its roots are safe
bun examples/pdf-server/main.ts --use-client-roots

When roots are ignored the server logs:

[pdf-server] Client roots are ignored (default for remote transports). Pass --use-client-roots to allow the client to expose local directories.

Allowed Sources

  • Local files: Must be passed as CLI arguments (or via client roots when enabled)
  • Remote URLs: arxiv.org, biorxiv.org, medrxiv.org, chemrxiv.org, zenodo.org, osf.io, hal.science, ssrn.com, and more

Tools

Tool Visibility Purpose
list_pdfs Model List available local files and origins
display_pdf Model + UI Display interactive viewer
interact¹ Model Navigate, annotate, search, extract pages, fill forms, save to file
read_pdf_bytes App only Stream PDF data in chunks
save_pdf App only Save annotated PDF back to local file

¹ stdio only by default; in HTTP mode requires --enable-interact — see Deployment.

Example Prompts

After the model calls display_pdf, it receives the viewUUID and a description of all capabilities. Here are example prompts and follow-ups that exercise annotation features:

Annotating

User: Show me the Attention Is All You Need paper

Model calls display_pdf → viewer opens

User: Highlight the title and add an APPROVED stamp on the first page.

Model calls interact with highlight_text for the title and add_annotations with a stamp

User: Can you annotate this PDF? Mark important sections for me.

Model calls interact with get_text to read content first, then add_annotations with highlights/notes

User: Add a note on page 1 saying "Key contribution" at position (200, 500), and highlight the abstract.

Model calls interact with add_annotations containing a note and either highlight_text or a highlight annotation

User: Search for "self-attention" in the paper.

Model calls interact with action search, query "self-attention"

User: Go to page 5.

Model calls interact with action navigate, page 5

Page Extraction

User: Give me the text of pages 1–3.

Model calls interact with action get_text, intervals [{start:1, end:3}]

User: Take a screenshot of the first page.

Model calls interact with action get_screenshot, page 1

Stamps & Form Filling

User: Stamp this document as CONFIDENTIAL on every page.

Model calls interact with add_annotations containing stamp annotations on each page

User: Fill in the "Name" field with "Alice" and "Date" with "2026-02-26".

Model calls interact with action fill_form, fields [{name:"Name", value:"Alice"}, {name:"Date", value:"2026-02-26"}]

Saving

User: Save the annotated version as /docs/contract-signed.pdf.

Model calls interact with action save_as, path /docs/contract-signed.pdf. Fails if the file exists; pass overwrite: true to replace. The target path must be under a mounted directory root.

User: Save my changes back to the file.

Model calls interact with action save_as, overwrite: true (no path). Overwrites the original — same writability gate as the viewer's save button. Only works for local files; remote PDFs need an explicit path.

Testing

E2E Tests (Playwright)

# Run annotation E2E tests (renders annotations in a real browser)
npx playwright test tests/e2e/pdf-annotations.spec.ts

# Run all PDF server tests
npx playwright test -g "PDF Server"

API Prompt Discovery Tests

These tests verify that Claude can discover and use annotation capabilities by calling the Anthropic Messages API with the tool schemas. They are disabled by default — skipped unless ANTHROPIC_API_KEY is set:

ANTHROPIC_API_KEY=sk-ant-... npx playwright test tests/e2e/pdf-annotations-api.spec.ts

The API tests simulate a conversation where display_pdf has already been called, then send a follow-up user message and verify the model uses annotation actions (or at least the interact tool). Three scenarios are tested:

Scenario User prompt Expected model behavior
Direct annotation "Highlight the title and add an APPROVED stamp" Uses highlight_text or add_annotations
Capability discovery "Can you annotate this PDF?" Uses interact or mentions annotations
Specific notes "Add a note saying 'Key contribution' and highlight the abstract" Uses interact tool

Architecture

server.ts                  # MCP server + tools
main.ts                    # CLI entry point
src/
├── mcp-app.ts             # Interactive viewer UI (PDF.js)
├── pdf-annotations.ts     # Annotation types, diff model, PDF import/export
└── pdf-annotations.test.ts # Unit tests for annotation module

Key Patterns Shown

Pattern Implementation
App-only tools _meta: { ui: { visibility: ["app"] } }
Chunked responses hasMore + offset pagination
Model context app.updateModelContext()
Display modes app.requestDisplayMode()
External links app.openLink()
View persistence viewUUID + localStorage
Theming applyDocumentTheme() + CSS light-dark()
Annotations DOM overlays synced with proper PDF annotation dicts
Annotation import Load existing PDF annotations via PDF.js getAnnotations()
Diff-based persistence localStorage stores only additions/removals vs PDF baseline
Proper PDF export pdf-lib low-level API creates real /Type /Annot dictionaries
Save to file App-only save_pdf tool writes annotated bytes back to disk
Dirty flag * prefix on title when unsaved local changes exist
Command queue Server enqueues → client polls + processes
File download app.downloadFile() for annotated PDF
Floating panel with anchoring Magnetic corner-snapping panel for annotation list
Drag, resize, rotate Interactive annotation handles with undo/redo
Keyboard shortcuts Ctrl+Z/Y (undo/redo), Ctrl+S (save), Ctrl+F (search), ⌘Enter

Annotation Types

Supported annotation types (synced with PDF.js):

Type Properties PDF Subtype
highlight rects, color?, content? /Highlight
underline rects, color? /Underline
strikethrough rects, color? /StrikeOut
note x, y, content, color? /Text
rectangle x, y, width, height, color?, etc. /Square
circle x, y, width, height, color?, etc. /Circle
line x1, y1, x2, y2, color? /Line
freetext x, y, content, fontSize?, color? /FreeText
stamp x, y, label, color?, rotation? /Stamp
image x, y, width, height, imageData?/imageUrl?, rotation? /Stamp

Dependencies

  • pdfjs-dist: PDF rendering and annotation import (frontend only)
  • pdf-lib: Client-side PDF modification — creates proper PDF annotation dictionaries for export
  • @modelcontextprotocol/ext-apps: MCP Apps SDK