ext-apps/examples/pdf-server
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Olivier Chafik ba31be1f91
perf(pdf-server): lazy form extraction via range transport + incremental viewer scans (#639)
* perf(pdf-server): lazy form extraction via range transport + incremental viewer scans

Server: display_pdf now opens the document via PDFDataRangeTransport
(disableAutoFetch) and only runs the per-page form/annotation walk when
getFieldObjects() is non-empty, so form-free PDFs are probed with ~10-25%
of bytes instead of a full download. The unused viewFieldInfo Map is
removed.

Viewer: getDocument sets disableAutoFetch/disableStream; baseline
annotation scan and field-name mapping run lazily per rendered page
instead of walking every page after load, so first paint no longer
schedules a full-file pull.

E2E: new range-counting HTTPS fixture (W-9 for forms, generated
text+image PDF for no-forms) with stallAfterBytes control, and four
regression tests asserting form fields are returned, <30% served on
no-forms display_pdf, first page renders while later ranges are stalled,
and overlap stays bounded.

* fix(pdf-server): handle separated field/widget trees in extractFormSchema

pdfjs getFieldObjects() returns the full field-tree array. For PDFs with
a separated structure (pdf-lib, some authoring tools) the typed widget
sits at fields[1+] behind a typeless container at fields[0]; the previous
code only inspected fields[0] and skipped them all. Pick the first entry
with a non-empty type instead.

Makes the e2e forms.pdf fixture fully generated (no checked-in third-party
asset on the hot path); fw9.pdf stays as a unit-test fixture for the
hierarchical/XFA case.

* fix(pdf-server): address range-transport hangs and lazy-scan tombstone loss

PdfCacheRangeTransport:
- abort() is a no-op stub on PDFDataRangeTransport (it's the hook pdfjs
  calls *on* the transport, not an upstream error channel). Expose a
  .failed promise that rejects on the first fetch error and race every
  pdfjs await against it in display_pdf, so transient network errors
  surface into the existing catch instead of hanging the tool call.
- pdfjs coalesces adjacent missing chunks into one unbounded
  requestDataRange; readPdfRange clamps each call to MAX_CHUNK_BYTES.
  Loop and deliver in slices so every requested chunk is marked loaded.

Viewer (mcp-app.ts):
- The lazy per-page baseline scan left pdfBaselineAnnotations incomplete,
  so persistAnnotations and getAnnotatedPdfBytes silently dropped
  restoredRemovedIds tombstones for unvisited pages. Union those ids into
  the computed diff and removedRefs.

Test fixture: release stalled handlers before resetStats/close so a
failing stalled test doesn't hang afterAll; fail fast if started with
NODE_ENV=production. NODE_TLS_REJECT_UNAUTHORIZED scope documented (full
per-process scoping needs a validateUrl localhost allow, tracked
separately).

* test(pdf-server): bridge coverage gaps and switch fixture to gated loopback HTTP

PdfCacheRangeTransport.deliver(): pdf.js's reader is keyed by the
original begin and removed after one delivery, so accumulate slices and
call onDataRange once with the full buffer (the previous multi-call
approach threw inside pdfjs). Covered by a new integration test that
drives getDocument()/getPage(1) on a >1MB PDF through a clamping
in-memory readPdfRange.

validateUrl: allow http://127.0.0.1|localhost|[::1] only when
PDF_SERVER_ALLOW_LOOPBACK_HTTP is set, so a remote deploy can't be made
to probe its own ports. Covered by env-on/off unit tests.

Fixture switched to plain HTTP (no openssl, no
NODE_TLS_REJECT_UNAUTHORIZED). Adds /error.pdf (500s after 50KB) and two
e2e tests: page 2 renders after stall release (>512KB object path), and
display_pdf returns gracefully on mid-load 500. Existing <30% test now
samples stats before the iframe loads.

New unit tests: display_pdf returns (not hangs) on mid-load fetch
failure via in-memory MCP client; computeDiff/serializeDiff contract
tests pinning the restoredRemovedIds tombstone-preservation behaviour.

* test(pdf-server): e2e for restoredRemovedIds tombstone preservation through viewer

Adds /with-native-annot.pdf fixture (2 pages, native /Text annot on
page 2) and a Playwright test that drives delete on page 2 → iframe
reload → interact add_annotations on page 1 (persist with page 2
unscanned) → assert localStorage diff still contains the removed id →
page 2 still shows it gone. Covers the mcp-app.ts glue the unit tests
can't reach.

* refactor(pdf-server): simplify per round-2 review and fix false-pass test

server.ts: extract probeFormFields() so display_pdf calls one helper
instead of inlining 40 lines of transport/orFail plumbing; make
extractFormSchema's fieldObjects param required (the optional branch
only existed for a test helper).

server.test.ts: the >1MB integration test now forces the image XObject
fetch via getOperatorList() and asserts max requestDataRange span >
MAX_CHUNK_BYTES, so it can't go vacuous. Dedupe makeRandomJpeg by
importing from the fixture.

mcp-app.ts: restore per-annotation try/catch isolation in the lazy
baseline scan (a throw was skipping AnnotationLayer.render); only
carry forward restoredRemovedIds while the baseline scan is incomplete
so a stale id can't pin dirty=true once every page is scanned.

tests: bump fixture JPEG to 1.1MB; consolidate 7 e2e tests to 4 (merge
overlap into the byte-budget test, merge stall+page-2, drop the
error-e2e duplicate of the unit integration test); delete
run-fixture.mjs; drop /error.pdf and ERROR_AFTER_BYTES; rename
tombstone test's describe and clarify page-2 covers the viewer
transport.

* fix(pdf-server): preserve partial probeFormFields results when extractFormFieldInfo throws

* test(pdf-server): fix tombstone e2e — deleted native annotations become a cleared card, not removed from DOM

* test(pdf-server): mark tombstone e2e as fixme — needs basic-host iframe-reload replay support

* test(pdf-server): link tombstone fixme to tracking issue #642
2026-04-27 15:32:53 +01:00
..
src perf(pdf-server): lazy form extraction via range transport + incremental viewer scans (#639) 2026-04-27 15:32:53 +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
build-mcpb.mjs fix(pdf-server): npx DOMMatrix crash + broken MCPB bundle (#584) 2026-04-02 04:16:33 +01: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 fix(pdf-server): npx DOMMatrix crash + broken MCPB bundle (#584) 2026-04-02 04:16:33 +01: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.7.0 (#628) 2026-04-21 16:19:22 +01:00
pdf-icon.svg Adding a simple (bad) pdf icon for directory (#330) 2026-01-23 16:04:46 +00:00
pdfjs-polyfill.ts fix(pdf-server): npx DOMMatrix crash + broken MCPB bundle (#584) 2026-04-02 04:16:33 +01: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 perf(pdf-server): lazy form extraction via range transport + incremental viewer scans (#639) 2026-04-27 15:32:53 +01:00
server.ts perf(pdf-server): lazy form extraction via range transport + incremental viewer scans (#639) 2026-04-27 15:32:53 +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