* pdf-server: add save_as interact action
Adds a model-callable save_as action to the interact tool that writes the
viewer's current annotated state (annotations + form values) to a new file.
Reuses the get_pages request/reply pattern: model's interact call blocks
while the viewer builds bytes via getAnnotatedPdfBytes() and posts them
back through a new submit_save_data app-only tool.
Validation runs synchronously before bothering the viewer:
- path must be absolute or file://
- isWritablePath gate (same as save_pdf)
- existence check; fails with a descriptive error unless overwrite: true
The viewer can report build errors back via submit_save_data({error}),
so the model gets a real message instead of the 45s timeout.
* pdf-server: warn model off app-only tools in descriptions
Hosts that don't filter visibility:["app"] tools from the model's tool
list will still surface these. The save_pdf one in particular is a
near-miss for the new save_as interact action — point the model at the
right one.
* pdf-server: save_as without path overwrites original
When path is omitted, save_as falls back to the displayed PDF's local
path (tracked per-viewUUID by display_pdf). Same writability gate as
the viewer's save button: isWritablePath + OS-level W_OK probe, so it
refuses on read-only mounts before the viewer roundtrip.
Requires explicit overwrite: true since the original always exists —
the model can't accidentally clobber it.
Remote PDFs have no source path stored; the error tells the model to
pass an explicit path.
346 lines
14 KiB
Markdown
346 lines
14 KiB
Markdown
# PDF Server
|
||
|
||

|
||
|
||
An interactive PDF viewer using [PDF.js](https://mozilla.github.io/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):
|
||
|
||
```json
|
||
{
|
||
"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):
|
||
|
||
```json
|
||
{
|
||
"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`):
|
||
|
||
```typescript
|
||
// Returns chunks with pagination metadata
|
||
{
|
||
(bytes, offset, byteCount, totalBytes, hasMore);
|
||
}
|
||
```
|
||
|
||
**Client side** (`mcp-app.ts`):
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
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
|
||
|
||
```typescript
|
||
// Request fullscreen
|
||
app.requestDisplayMode({ mode: "fullscreen" });
|
||
|
||
// Listen for mode changes
|
||
app.ondisplaymodechange = (mode) => {
|
||
if (mode === "fullscreen") enableScrolling();
|
||
else disableScrolling();
|
||
};
|
||
```
|
||
|
||
### 4. External Links (openLink)
|
||
|
||
The viewer demonstrates opening external links (e.g., to the original arxiv page):
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
app.onhostcontextchanged = (ctx) => {
|
||
if (ctx.theme) applyDocumentTheme(ctx.theme);
|
||
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
|
||
};
|
||
```
|
||
|
||
## Usage
|
||
|
||
```bash
|
||
# 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](#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`:
|
||
|
||
```bash
|
||
# 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](#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_
|
||
|
||
### Navigation & Search
|
||
|
||
> **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)
|
||
|
||
```bash
|
||
# 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:
|
||
|
||
```bash
|
||
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
|