ext-apps/examples/pdf-server/server.ts
Olivier Chafik 296b5921fb
pdf-server: annotations, interact tool, page extraction & prompt engineering (#506)
* pdf-server: add interact tool to allow model to modify existing view

* pdf-server: add annotations, download, form fill, and highlight_text

Add PDF annotation system with 7 annotation types (highlight, underline,
strikethrough, note, rectangle, freetext, stamp), text-based highlighting,
form filling, and annotated PDF download using pdf-lib.

- Server: annotation Zod schemas, extended interact tool with add/update/remove
  annotations, highlight_text, and fill_form actions
- Client: annotation layer rendering with PDF coordinate conversion, persistence
  via localStorage (using toolInfo.id key), pdf-lib-based download with embedded
  annotations and form fills, uses app.downloadFile() SDK with <a> fallback
- Model context includes annotation summary

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add get_pages tool for batch text/screenshot extraction

New tool `get_pages` lets the model get text and/or screenshots from
arbitrary page ranges without navigating the visible viewer.

- Server: `get_pages` tool with interval-based page ranges (optional
  start/end, open ranges supported), `getText`/`getScreenshots` flags,
  request-response bridge via `submit_page_data` app-only tool
- Client: offscreen rendering (hidden canvas, no visual interference),
  text from cache or on-demand extraction, screenshots scaled to 768px
  max dimension, results submitted back to server
- Max 20 pages per request, 60s timeout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: move get_pages into interact tool as an action

Fold get_pages into the interact tool to minimize tools requiring
approval. Now accessed via `interact(action: "get_pages", ...)`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: improve interact tool annotation discoverability

- Add concrete per-type schema docs with field names in tool description
- Add JSON example showing add_annotations with highlight + stamp
- Replace opaque z.record(z.string(), z.unknown()) with typed union
  of all annotation schemas (full + partial forms) so the model sees
  exact field names and types
- Remove redundant manual safeParse since Zod inputSchema validates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: improve annotation discoverability + add E2E tests

- display_pdf result text now explicitly lists annotation capabilities
  (highlights, stamps, notes, etc.) instead of vague "navigate, search, zoom, etc."
- Restructured interact tool description: annotations promoted to top,
  with clear type reference, JSON example, and bold section headers
- Added pdf-annotations.spec.ts with 6 E2E tests covering:
  - Result text mentions annotation capabilities
  - interact tool available in dropdown
  - add_annotations renders highlight
  - Multiple annotation types render (highlight, note, stamp, freetext, rectangle)
  - remove_annotations removes from DOM
  - highlight_text finds and highlights text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add Claude API prompt discovery tests for annotations

Tests that Claude can discover and use PDF annotation capabilities
by calling the Anthropic Messages API with the tool schemas and
simulated display_pdf result.

Disabled by default — skipped unless ANTHROPIC_API_KEY is set:
  ANTHROPIC_API_KEY=sk-... npx playwright test tests/e2e/pdf-annotations-api.spec.ts

3 scenarios tested:
- Model uses highlight_text when asked to highlight the title
- Model discovers annotation capabilities when asked "can you annotate?"
- Model uses interact (add_annotations or get_pages) when asked to add notes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add example prompts, testing docs, and updated tools table to README

- Example prompts for annotations, navigation, page extraction, stamps, forms
- Documents how to run E2E tests and API prompt discovery tests
- Updated tools table to include interact tool
- Updated key patterns table with annotations, command queue, file download
- Added pdf-lib to dependencies list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: simplify interact annotations schema (7802 → 2239 chars)

The typed Zod union (14 anyOf variants: 7 full + 7 partial annotation
types) produced a 5,817-char JSON schema for the annotations field alone.
This bloated the interact tool schema to 7,802 chars, which may cause
the model to struggle with or skip the tool.

Replace with z.record(z.string(), z.any()) — annotation types are
already fully documented in the tool description. Schema drops to
2,239 chars (71% reduction), annotations field to 254 chars (96% reduction).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: enumerate all interact actions in display_pdf result

The display_pdf result text now lists every action by name (navigate,
search, find, search_navigate, zoom, add_annotations, update_annotations,
remove_annotations, highlight_text, fill_form, get_pages) so the model
knows exactly what commands are available without needing to inspect the
interact tool schema.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: validate viewUUID in interact, clarify in description

The model was passing "pdf-viewer" instead of the actual UUID, causing
get_pages to timeout (commands queued under wrong key, client never
picks them up).

- Add activeViewUUIDs set tracking UUIDs issued by display_pdf
- Validate viewUUID at the top of interact handler with clear error
- Add "IMPORTANT: viewUUID must be the exact UUID returned by display_pdf"
  to the interact tool description

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix annotation clickability and replace emoji icon with SVG

- Raise annotation-layer z-index above text-layer so note annotations
  receive hover/click events (was z-index: 1, now 3; text-layer is 2)
- Replace memo emoji (data-icon attr) with CSS mask SVG document icon
  that respects currentColor for consistent cross-platform rendering

* pdf-server: add annotation side panel with bidirectional linking

Right-side panel (250px) shows all annotations grouped by page with
expand/collapse cards. Clicking a card navigates to the page and pulses
the annotation; clicking a note icon on the PDF highlights its card in
the panel. Panel auto-shows on first annotation, remembers user toggle
preference via localStorage, and shows a badge count when collapsed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: replace 300ms interval polling with long-polling

Server now holds poll_pdf_commands requests open (up to 30s) until
commands arrive, waking waiters via enqueueCommand. Client loops
sequentially instead of using setInterval, with 2s backoff on errors.
Reduces idle RPC traffic from ~3 calls/sec to ~2 calls/min.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add interactive form filling via PDF.js AnnotationLayer

Render PDF.js AnnotationLayer with renderForms:true so form fields
appear as interactive HTML inputs. Build a fieldName→annotationID map
via getFieldObjects() to bridge fill_form (which uses field names) with
annotationStorage (which uses annotation IDs). Sync user input back to
formFieldValues for persistence and PDF download.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add elicit_form_inputs to display_pdf for upfront form filling

When enabled and the client supports elicitation, extracts form fields
from the PDF via pdf-lib and prompts the user to fill them before the
viewer loads. Elicited values are returned in content/structuredContent
and enqueued as a fill_form command for the viewer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: replace pdf-lib with pdfjs-dist for server-side form field extraction

Use pdfjs-dist's getDocument() + getFieldObjects() instead of pdf-lib's
PDFDocument.load() + getForm().getFields() in extractFormSchema(). This
removes the pdf-lib import from the server bundle (pdf-lib is still used
client-side for PDF modification in downloadAnnotatedPdf). Uses the legacy
pdfjs-dist build to avoid DOMMatrix dependency in Node.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix form field font size and programmatic fill_form

Set --scale-factor/--total-scale-factor CSS variables on the form layer
so AnnotationLayer font-size rules resolve correctly instead of falling
back to browser defaults. Also update live DOM elements directly in
fill_form handler so values appear immediately without waiting for a
full re-render.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix listbox font size overflow in AnnotationLayer

After rendering the annotation layer, shrink select[size] font to fit
within the PDF rect height. The default AnnotationLayer CSS uses a fixed
9px * scale-factor which overflows when many options share a small rect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add trash icon to annotation sidebar cards

Show a delete button on each annotation card that appears on hover.
Clicking it removes the annotation from the PDF and persists the change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix space key captured in form fields

Skip keyboard navigation shortcuts (space, arrows, +/-) when any input,
textarea, or select element is focused, not just the search/page inputs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: show form fields in annotation sidebar with clear buttons

Form field values now appear in the sidebar under a "Form Fields" group,
with trash icons to clear individual values. The badge count and auto-show
logic include form fields. The panel open/close now calls
requestFitToContent with width to avoid overflow in inline layout mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: clarify Y coordinate system in interact tool description

Explicitly state that Y=0 is the bottom edge and Y=792 is the top for
US Letter, with concrete guidance on typical values for top/bottom
placement to prevent models from using top-down screen coordinates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: validate fill_form field names and report valid fields

Extract form field names during display_pdf and cache per viewer UUID.
fill_form now soft-fails on unknown field names (applies valid ones,
reports skipped ones). Both display_pdf and fill_form results include
the list of valid field names so the model can self-correct.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add compact annotation strip for inline mode

In inline mode, replace the 250px side panel with a slim bottom strip
that shows one annotation/form-field at a time with prev/next navigation.
The side panel is still used in fullscreen mode.

- Strip shows item swatch, label, preview + counter (N of M · Page P)
- Click item to navigate to its page; delete single or clear all
- requestFitToContent includes strip height in size calculation
- fieldNameToPage map built during init for form field page context
- Display mode toggle switches between strip and panel automatically

* pdf-server: improve display_pdf description for form/annotation use cases

Add explicit guidance on when to use the tool: filling out forms
(tax forms, applications), annotating PDFs, and interactive review.
This helps models route user requests like 'help me fill out this form'
to the display_pdf tool.

* pdf-server: only show download button when host supports downloadFile

* pdf-server: resizable sidebar + human-readable form field labels

- Sidebar panel is now resizable by dragging its left edge (150px min,
  50vw max). Width is persisted in localStorage.
- Form fields show human-readable labels from PDF's TU (alternativeText)
  field when available. Mechanical names like ').F1_01[0]' are replaced
  with the generic label 'Field' when no TU text exists.
- Empty values are no longer shown in the sidebar.

* pdf-server: fix field label collection to use getAnnotations() API

getFieldObjects() doesn't include alternativeText. Instead, iterate
per-page annotations (only pages with known form fields) to collect
the TU/alternativeText labels that PDF authors set as tooltips.

* pdf-server: move strip below toolbar + follow form field focus

- Strip is now positioned below the toolbar (above search bar) instead
  of at the bottom, for better visibility and proximity to the toolbar.
- Form field focus changes automatically sync the strip to show the
  focused field's item.
- Search bar top offset adjusts dynamically when strip is shown/hidden.

* pdf-server: add Clear all to fullscreen sidebar + steer model to reuse views

- Add 'Clear all' button in the fullscreen sidebar panel header
- Update display_pdf description to instruct models to use the interact
  tool with existing viewUUID rather than calling display_pdf again for
  the same PDF (e.g. when user asks to annotate an already-open doc)

* pdf-server: add page dimensions to model context + improve coordinate docs

- Model context now includes 'Page size: 612x792pt (coordinates: origin
  at bottom-left, Y increases upward)' so the model knows actual page
  dimensions when placing annotations.
- Interact tool description expanded with clearer coordinate guidance,
  margin hints, and a tip to use highlight_text for text annotations.
- Download button temporarily shown unconditionally (capability check
  commented out with TODO for re-enabling).

* pdf-server: auto-close panel when empty + stronger display_pdf reuse guidance

- Sidebar/strip auto-closes when all items are cleared (via delete or
  Clear all), and the annotations toolbar button hides.
- display_pdf description now uses CRITICAL warning to prevent models
  from re-calling it to add annotations to an already-open PDF.

* pdf-server: skip elicitation when form fields have mechanical names

Only use elicitation if all editable fields have human-readable names.
Fields with brackets, dots, or ALL_CAPS_UNDERSCORE patterns (and no
alternativeText label from the PDF) are considered mechanical and
would be confusing in an elicitation form. Also use alternativeText
as the elicitation field title when available.

* pdf-server: paint annotations on screenshots + list coords in model context

Screenshots sent to the model (both updateModelContext and get_pages)
now include annotations drawn on top of the PDF canvas, so the model
can visually verify placement. The text context also lists each
annotation on the current page with its coordinates.

* pdf-server: style multiselect/listbox form fields to match viewer theme

* pdf-server: add command batching to interact tool

Extract command processing into a helper function and accept an optional
`commands` array for sequential batch execution. This lets the model
send multiple actions (e.g. add annotations + get screenshot) in a single
tool call, reducing round trips. Stops on first error in a batch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix multiselect rendering by removing font-size override

The custom font-size: inherit on #form-layer select was conflicting with
PDF.js AnnotationLayer's inline font-size, causing doubled/overlapping text
in select options. Remove the override and keep only the checked-option
highlight style.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix form reset button by passing fieldObjects to AnnotationLayer

PDF.js AnnotationLayer needs the fieldObjects parameter to bind reset
form button actions. Cache the result of doc.getFieldObjects() and pass
it through to annotationLayer.render().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: improve annotation labels in sidebar and strip

Use human-readable labels instead of raw type names (e.g. "Rectangle"
instead of "rectangle", "Stamp: APPROVED" instead of "stamp"). Remove
redundant preview text for types that have no additional content (rectangle,
underline, strikethrough, stamp) to avoid "rectangle Rectangle" duplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: split get_pages into get_text and get_screenshot commands

Replace the combined get_pages action (with getText/getScreenshots flags)
with two focused commands:
- get_text: extract text from pages (single page or intervals)
- get_screenshot: capture a single page as PNG image

This makes the API cleaner and returns each command's result as its own
content block in batch mode. Also fix download function syntax error
from earlier capability check commenting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix download by using pdfDocument.getData() instead of re-fetching

The download was failing with "No PDF header found" because it re-fetched
the PDF via read_pdf_bytes tool calls, but stateless MCP transport creates
fresh server instances per request with empty caches. Using getData() from
the already-loaded pdf.js document avoids the round-trip entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: increase express body limit to fix get_screenshot hang

The submit_page_data tool sends base64 PNG screenshots that exceed the
default 100KB express.json() body limit from createMcpExpressApp. This
caused the request to silently fail, making waitForPageData timeout and
the get_screenshot command hang. Replaced createMcpExpressApp with manual
express setup using a 50MB body limit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: use JPEG for screenshots to fix get_screenshot hang

Switch from PNG to JPEG (quality 0.85) for both offscreen screenshots
(get_screenshot) and model context images. JPEG is much smaller than PNG
for document pages, keeping the submit_page_data payload well within the
MCP SDK's default 100KB express body limit. This also reverts the
temporary workaround of replacing createMcpExpressApp with a custom
express setup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add ' - edited' suffix to download filename when modified

Renames the download from '_annotated' to ' - edited' and also triggers
the suffix when form fields have been filled, not just when annotations
are present.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: include viewUUID in display_pdf structuredContent

The viewUUID was only in _meta but not in structuredContent, so the model
couldn't retrieve it from the tool result to pass to interact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: move interact action list from display_pdf content to description

The content response was bloated with static instructions about interact
actions. Moved that to the tool description where it belongs, keeping
only the dynamic PDF URL and viewUUID in the content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix form reset setting dropdowns to first item

PDF.js resetform handler deselects all options but doesn't re-add the
hidden empty placeholder, so the browser falls back to showing the first
real option. Work around this by listening for resetform on combo selects
and re-inserting a hidden blank option when nothing is selected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: sort form fields in sidebar/strip by intrinsic PDF order

Fields are now sorted by page number then top-to-bottom Y position on
the page, matching the visual reading order of the PDF form rather than
the arbitrary insertion order of formFieldValues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add viewUUID to display_pdf outputSchema

The viewUUID was added to structuredContent but not to the outputSchema,
causing MCP validation error -32602.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: allow horizontal scrolling in inline mode

When the PDF is zoomed or wider than the container, users can now scroll
horizontally in inline mode instead of the content being clipped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add viewUUID to model context, remove activeViewUUIDs validation

Include viewUUID in the updateModelContext header so the model can
reference it when calling interact. Remove the activeViewUUIDs set
validation since it breaks after server restarts — command queue TTL
pruning is sufficient cleanup. Also prune empty queues with no active
pollers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix delete button alignment in sidebar annotation cards

Annotations without preview text had the delete button hugging the type
label instead of right-aligning. Add margin-left: auto so the button is
consistently pushed to the right edge of the row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add interactive annotations (select, drag, resize, rotate, undo/redo)

Enable pointer-events on all annotation types so they can be clicked to
select. Draggable types (rectangle, freetext, stamp, note) support smooth
drag-to-move. Rectangles show corner resize handles, stamps show a rotate
handle. Delete/Backspace removes the selected annotation, Ctrl+Z/Shift+Z
provides unlimited undo/redo, and Escape deselects. Sidebar cards sync
with on-canvas selection state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: clicking sidebar card focuses form field input and scrolls into view

Sidebar annotation cards now scroll the annotation into view, and form
field cards navigate to the correct page and focus the input element.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix form widget double-text by making inputs opaque

PDF.js AnnotationLayer renders interactive form inputs with a
semi-transparent background, letting the canvas's static form appearance
bleed through. Since the widget font (9px * scale, sans-serif) doesn't
match the PDF's actual font metrics, this creates a "double text" misalignment
at most zoom levels. Make text/choice widgets opaque so only the interactive
layer's text is visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add selection/focus to model context, double-click to send messages, toggle resize/rotate

- Model context now includes selected annotation ID and focused form
  field name so the model knows what the user is interacting with.
- Double-clicking a sidebar annotation card sends a message prompting
  the model to change that annotation.
- Double-clicking a sidebar form field card sends a message prompting
  the model to fill that field.
- Double-clicking an annotation in the renderer toggles between resize
  and rotate handles (for rectangles and stamps).
- Rectangles now support rotation via the rotate handle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: replace inline-mode strip with floating accordion sidebar

Replace the compact horizontal annotation strip with a floating panel
at top-right in inline mode. Uses accordion-style collapsible sections
(per-page annotations + form fields) with at most one section open at
a time. Refactors renderAnnotationPanel into reusable helpers
(appendAccordionSection, createAnnotationCard, createFormFieldCard).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: remove dead annotation strip code

The compact horizontal annotation strip is no longer used since inline
mode now uses a floating accordion sidebar. Remove all strip-related
HTML, CSS, JS (DOM refs, StripItem type, buildStripItems, renderStrip,
navigateToStripItem, deleteStripItem, event listeners, stripHeight
calculations).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: multi-select annotations, natural prompts, form reset sync

- Convert selection from single ID to Set for multi-select (Shift+click)
- Deselect annotations when focusing a form input field
- Fix rotation to always use center origin (stamp was using left-bottom)
- Simplify double-click sendMessage prompts (e.g. "update Notes: ")
- Call updateModelContext before sendMessage to guarantee context is sent
- Listen for PDF.js resetform events to clear formFieldValues, update
  sidebar badge/count, and hide icon when no items remain

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: draggable floating panel with magnetic corner anchoring

- Reduce floating panel width (220→180px), add border-radius
- Panel header is draggable; on drop, snaps to nearest corner
- Persists corner preference to localStorage
- Auto-docks to opposite side when overlapping selected annotations
- Accordion chevron: ▶ when collapsed, ▼ when expanded
- Slightly reduce section header size for compactness

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: allow all accordion sections to be collapsed

Only auto-open the first section on initial render. Once the user has
interacted with accordion headers, respect their choice to collapse all
sections without forcing one back open.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: polish interactions and UI details

- Remove auto-show annotation panel; wait for user to open it
- Badge color: blue (accent) instead of red
- Search bar: offset floating panel below it when anchored top-right
- Text selection deselects annotations and clears field focus
- Click on text layer / empty area clears field focus too
- Zoom keeps selected annotation visible (scrollIntoView after render)
- Fullscreen button: distinct exit icon + tooltip with Esc hint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix text deselect, stamp dblclick, rotation direction, export

- Text selection and text-layer mousedown now deselect annotations
- Add user-select: none on draggable annotations (prevents stamp text
  selection on double-click, allowing resize/rotate toggle to work)
- Fix rotation direction: remove CSS transform negation so clockwise
  drag = clockwise rotation on screen
- Fix stamp export: rotate around center (matching CSS transformOrigin)
  and negate rotation for PDF coordinate convention
- Add rotation support to rectangle PDF export
- Remove auto-show annotation panel; badge uses blue accent color

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: allow any text as stamp label, not just predefined enum

The StampLabel was unnecessarily restricted to 6 predefined values
(APPROVED, DRAFT, etc). The PDF format supports arbitrary stamp text,
so open it up to z.string() while keeping common labels as examples
in the schema description.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: show resize + rotate handles simultaneously, dblclick sends message

Remove the interactionMode toggle — instead show both corner resize handles
and the rotate handle at the same time on selected annotations. Rectangle
gets resize corners + rotate handle; stamp gets rotate handle only.
Double-click on annotation elements now sends a message to modify the
annotation (matching sidebar card behavior) instead of toggling modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: SVG fullscreen icons, Y-coord docs, dirty flag

- Replace Unicode fullscreen chars (⛶/⊠) with proper SVG expand/collapse icons
- Add Cmd+Enter keyboard shortcut for fullscreen toggle
- Improve coordinate system docs: emphasize rectangle x,y is BOTTOM-LEFT corner, add worked examples
- Add isDirty state with * prefix on title for unsaved changes
- Track dirty state through persistAnnotations, suppress during restore

* pdf-server: diff-based annotation persistence, proper PDF annotation export, PDF annotation import

Extract annotation logic into pdf-annotations.ts module with comprehensive tests. Replace content-stream drawing (drawRectangle/drawText) with proper PDF annotation dicts (/Type /Annot) that are editable in Acrobat/Preview. Persistence now stores only a diff (additions/removals) relative to the PDF's native annotations, keeping localStorage small. On load, annotations are imported from the PDF via pdf.js and merged with the user's diff.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: enhance page shadow for better depth perception

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: use floating panel in fullscreen, respect insets, direction-aware resize

Always use the floating annotations panel (both inline and fullscreen) instead
of the sidebar in fullscreen mode. Panel anchorage now respects safe area insets
(especially bottom). Resize handle repositions to the correct edge based on
which corner the panel is anchored to. Initial width reduced from 180px to 135px.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add save_pdf tool and save button for local files

Add server-side save_pdf tool that writes annotated PDF bytes back to the
original local file. Client shows a save button (floppy disk icon) next to
the download button for file:// URLs. Ctrl/Cmd+S shortcut saves when dirty.
Saving clears the dirty flag and localStorage diff.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add circle and line annotation types synced with PDF.js

Add circle (ellipse) and line annotation support across all layers:
server Zod schemas, pdf-annotations module (import/export), client
renderers, canvas painting for screenshots, CSS, drag/resize handling,
and tool description documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add searchability keywords to display_pdf tool description

Include viewer, render, renderer, display, show, annotate, edit, and
form keywords so the tool is easier to discover by models.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: switch model-facing annotation API to top-left origin coordinates

Convert Y coordinates at the command boundary (add/update_annotations) so the
model uses intuitive top-left origin while internal storage stays in PDF's
native bottom-left origin. Also converts coordinates back to model space in
context strings for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix stamp rotation in PDF export via appearance stream matrix

Stamp rotation was a no-op (empty if-block with a comment). Now computes
a proper rotation matrix around the bounding box center and applies it
to the appearance stream. Also adds verification tests for rectangle
stroke/fill colors and stamp color/rotation in exported PDF dicts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix screenshots to include filled form field values

Screenshots sent to the model were missing form field values because
the canvas render didn't use ENABLE_STORAGE mode. Now both offscreen
renders (model context and get_pages) pass annotationStorage to PDF.js
so filled fields appear in screenshots. Also reuses renderPageOffscreen
for model context screenshots to deduplicate rendering logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: list form fields with page, bounding box, and label in display_pdf result

The model needs field locations to fill forms accurately, especially for
fields with mechanical names or no name at all. Extracts per-page bounding
boxes in model coordinates (top-left origin) and includes labels where
available from PDF alternativeText.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: include full field listing in fill_form result and update UI after fill

fill_form results now include all form fields with names, types, pages, and
bounding boxes so the model can self-correct on unknown field names. Also
updates the annotation panel badge and visibility after programmatic fills.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add formFields to display_pdf structuredContent and output schema

Programmatic consumers need structured access to form field metadata
(names, types, pages, bounding boxes) without parsing text content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add image annotation type with URL/base64 support, drag-drop, and PDF export

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix image annotation click/select, add Shift-resize for aspect ratio, add handle tooltips

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix image annotation to stretch to fill its bounding box

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: fix stamp PDF export to match on-screen appearance

Use non-standard /Name to prevent Preview.app from substituting built-in
stamp graphics, align padding with CSS (4pt/12pt), add ExtGState for 0.6
opacity, and fix text baseline positioning.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: improve annotation panel UX and image resize behavior

- Page accordion click navigates to that page
- Form fields appear in their page's accordion section (before annotations)
- Selecting annotation or focusing form field auto-expands page accordion
- Image resize preserves aspect ratio by default (Shift for free resize)
- Add aspect field ("preserve"|"ignore") to ImageAnnotation type
- Fix selection UI regression: remove overflow:hidden that clipped handles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add signature/signing keywords and image example to tool descriptions

- Add signing/signature keywords to display_pdf description for discoverability
- Add image annotation example (signature placement) to interact tool description
- Clarify that imageUrl supports file paths and HTTP URLs with auto-fetch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: discourage base64 encoding in image annotation, clarify imageUrl accepts file paths

Models tend to encode images as base64 themselves instead of passing file
paths. Make the schema descriptions and tool docs explicit: prefer imageUrl
with a file path, no data: URIs, don't encode images yourself.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: remove imageData from image annotation API, require imageUrl

Models were encoding images as base64 themselves instead of passing file
paths. Remove imageData from the model-facing Zod schema entirely — only
imageUrl (file path or HTTPS URL) is accepted. The server fetches and
embeds the image automatically. imageData remains as an internal field
for client-side rendering and drag-drop. Also mention drag-and-drop as
an alternative in the tool description.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add clipboard support (copy/cut/paste annotations and images)

- Request clipboardWrite permission in resource metadata
- Ctrl/Cmd+C: copy selected annotations as JSON to clipboard
- Ctrl/Cmd+X: cut (copy + delete) selected annotations
- Ctrl/Cmd+V / paste event: paste annotations from clipboard JSON,
  or paste images from clipboard (e.g. screenshots, copied images)
- Refactor drag-drop image creation into shared addImageFromFile()
- Pasted annotations get new IDs and slight offset to avoid overlap
- Pasted images are centered on the current page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: add disableInteract option for distributed deployments

Adds a `disableInteract` boolean to `createServer()` options that skips
registering the `interact`, `poll_pdf_commands`, and `submit_page_data`
tools (all relying on an in-memory command queue). Adjusts `display_pdf`
description and schema accordingly when disabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: flip interact to opt-in, enable only for --stdio

Rename disableInteract → enableInteract (default false) so existing
library consumers get read-only mode without code changes. The CLI
entry point passes enableInteract: true only when running with --stdio.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* pdf-server: log client-roots warning once at startup, not per request

* pdf-server: fix form widget transparency, poll spam, and pdf.js warnings

- Form widgets: PDF.js writes inline background-color (from the
  annotation /BC entry, typically 'transparent'), defeating our CSS.
  Force opaque with !important. Fixes double-text in listboxes and
  text inputs at any zoom.
- Poll spam: view was calling poll_pdf_commands in a tight loop when
  interact is disabled (HTTP mode) — server returns isError, which
  doesn't throw, so the loop never backed off. Server now signals
  interactEnabled via _meta; view only polls when true, and stops
  entirely on isError results.
- Silence Helvetica / inset-border warnings: set verbosity=ERRORS
  for server-side getDocument (only used for form introspection,
  no rendering, so font fallback warnings are noise).

* pdf-server: set standardFontDataUrl for server-side pdf.js

Resolve pdfjs-dist/standard_fonts/ at runtime via createRequire. Works
from both source (bun main.ts) and bundled dist/ since pdfjs-dist is
--external. NodeStandardFontDataFactory reads via fs.readFile, so a
filesystem path with trailing separator is what's expected.

Keep verbosity=ERRORS as backstop for unrelated warnings (e.g.
'Unimplemented border style: inset').

* pdf-server: use unpkg CDN for pdf.js standard fonts, add to CSP

- Single CDN URL pinned to the bundled pdfjs-dist version, used in both
  server (Node) and viewer (browser)
- Node: default NodeStandardFontDataFactory uses fs.readFile which can't
  fetch URLs — pass a minimal FetchStandardFontDataFactory
- Browser: DOMStandardFontDataFactory already fetches, just pass the URL
- Add https://unpkg.com to CSP connectDomains (pdf.js loads font
  binaries via fetch(), maps to connect-src)

* pdf-server: guard against missing totalBytes in viewer

Without this, an invalid totalBytes flows to PDFDataRangeTransport.length
→ worker ChunkedStream sized at 0 → opaque 'Bad end offset: N' error.
The guard turns that into an actionable message (rebuild hint).

* pdf-server: re-probe file size at load time, ignore stale totalBytes from history

The display_pdf result (including totalBytes) is baked into conversation
history. If the user saves the annotated PDF (grows the file) then
reloads the conversation, the host replays the stale size. pdf.js
initializes its ChunkedStream at that stale length, then the first live
read_pdf_bytes returns the current (larger) file — every chunk fails
the 'end === bytes.length' check with 'Bad end offset: N'.

Fix: drop the fileSizeBytes param and always probe via a live 1-byte
read_pdf_bytes call before setting up the range transport.

* pdf-server: file watching, save confirmation, disk-change reload

Server:
- fs.watch on local files when interact is enabled; pushes file_changed
  via the poll_pdf_commands channel. Re-attaches on rename to survive
  atomic writes (vim/vscode). Debounced 150ms. Skips unchanged mtime.
- save_pdf returns {filePath, mtimeMs} so the saving viewer can
  recognise its own write's echo. Other viewers on the same file still
  get notified (their content is genuinely stale).
- stopFileWatch in pruneStaleQueues TTL sweep.

Viewer:
- In-app confirm dialog (no window.confirm — would block the iframe).
- Save button: hidden until first edit, enabled while dirty, disabled
  (but still visible) after save, re-enabled on next edit.
- savePdf() asks for confirmation, records lastSavedMtime.
- loadGeneration counter invalidates stale fetchChunk results and
  aborts the preloader when the PDF is reloaded underneath them.
- reloadPdf() clears all per-document state (byte cache, annotations,
  form fields, undo/redo, text caches, field maps, localStorage diff)
  and loads fresh.
- file_changed handler: suppresses own-save echo via saveInProgress +
  mtime match; auto-reloads when clean; prompts Keep/Discard when dirty.

* pdf-server: add file watching tests

Covers: external write → file_changed enqueued via poll_pdf_commands;
debounced rapid writes; stopFileWatch prevents further events; save_pdf
returns mtimeMs; watcher survives atomic rename (vim/vscode pattern).

* pdf-server: fix confirm-btn-primary hover, show filename only in save prompt

- .confirm-btn:hover's background cascaded to primary buttons too
  (same specificity, primary has both classes) — turning the blue
  button light grey on hover. Re-assert background in primary:hover.
- Save confirm: extract just the filename from pdfUrl (strip file://
  prefix, take basename).

* pdf-server: match confirm dialog to host's native style

Use host-provided CSS variables (--color-*, --font-*, --border-radius-*,
--shadow-*) from applyHostStyleVariables, with local fallbacks.

- Larger border-radius (--border-radius-xl), softer shadow (--shadow-lg)
- Larger bold title (--font-heading-md-size, --font-weight-bold)
- Primary button uses inverse colors (dark bg + light text) like the
  host's downloadFile dialog, not blue
- New .confirm-detail box: monospace bordered rect for filename
- Button order: Cancel first, primary last (native convention)
- Escape resolves to first non-primary button

* pdf-server: derive dirty from diff — undoing all changes clears save button

persistAnnotations() was unconditionally setDirty(true). It already
computes the diff vs baseline — use isDiffEmpty(diff) to decide instead.
Now undoing all the way back to the original state marks the viewer
clean again (save button disables, title loses asterisk).

* pdf-server: import saved form field values from PDF as baseline

getFieldObjects() returns the PDF's stored form values but we were only
reading field IDs/pages from it. After saving a filled form and reopening,
the panel showed nothing and there was no way to see what was filled.

- New pdfBaselineFormValues map, populated in buildFieldNameMap() from
  each field's .value (skipping empty/Off/button values). Seeds
  formFieldValues so the panel shows PDF-stored values on open.
- computeDiff takes an optional baselineFormFields param and only
  includes values that differ — opening a filled PDF doesn't mark dirty,
  editing a field does, reverting to the PDF's value marks clean again.
- importFieldValue() normalises radio-group value lookup (parent entry
  has value=undefined, children have the real export value), checkbox→
  true, listbox array→joined string.

* pdf-server: fix form Reset button; split panel into Reset vs Clear all

Reset-button regression: buildFieldNameMap() now seeds formFieldValues
from the PDF's stored values, but syncFormValuesToStorage() was pushing
them back into annotationStorage in our normalised repr (checkbox→true,
radio→export string). pdf.js's native repr may differ, and overwriting
it breaks the form's Reset button. Fix: skip syncing values that match
baseline — the PDF's own values are already in storage natively.

Panel buttons:
- Reset: revert to what's in the PDF file. Restores baseline annotations
  and form values, clears undo/redo. Empty diff → clean.
  Disabled when not dirty.
- Clear all: removes EVERYTHING including PDF-native items. Non-empty
  diff (baseline items are 'removed') → dirty; saving writes stripped PDF.
  Disabled when nothing to clear.

* pdf-server: fix Clear all to push defaultValue; gate save on writability

Clear all regression: annotationStorage.remove(id) only drops our
override — the widget reverts to the PDF's stored /V (the baseline
value). To actually clear, push each field's defaultValue (/DV) via
setValue instead, which is what the PDF's native Reset button does.

Save button visibility: server now checks fs.access(path, W_OK) and
reports via _meta.writable. Viewer gates save on that instead of just
'is this a local path'. Hides the button for read-only mounts and
Claude Desktop's uploads directory. Removed dead isLocalFileUrl().

* pdf-server: fix annotationStorage keys — use widget annotation IDs

Root cause: getFieldObjects() returns field-dictionary refs (the /T
tree, e.g. '86R'), but annotationStorage is keyed by WIDGET annotation
refs (what page.getAnnotations() returns, e.g. '9R'). These differ
when a PDF's field and its widget /Kids are separate objects.

fieldNameToIds was built from getFieldObjects().id, so EVERY
storage write was keyed wrong and silently no-op'd:
- syncFormValuesToStorage: wrote to dead keys
- fill_form: setValue + querySelector([data-element-id=...]) both missed
- individual field delete: remove() on wrong key
- clearAllItems: setValue to wrong keys

Fix: build fieldNameToIds from page.getAnnotations() which gives the
correct widget IDs. Keep cachedFieldObjects only for type/value/
defaultValue metadata (matched by name, not id) and for passing to
AnnotationLayer.render() where pdf.js uses it internally.

Also extract clearFieldInStorage() helper — pushes defaultValue for a
single field, used by both individual delete and Clear all.

* pdf-server: remap fieldObjects IDs to widget IDs for pdf.js Reset

pdf.js _bindResetFormAction iterates fieldObjects using each entry's .id
to (a) key annotationStorage and (b) querySelector([data-element-id=...]).
Both expect WIDGET annotation IDs. fieldObjects contains field-dict IDs.
Works only when field and widget share a PDF object — which pdf-lib's
save breaks (it splits merged objects on write).

This is a latent pdf.js bug, not a regression from our changes — it was
broken the moment the user first saved via save_pdf.

Fix: rebuild cachedFieldObjects with widget IDs (from fieldNameToIds)
before passing to AnnotationLayer.render({fieldObjects}). Skip parent
entries with no concrete id (radio group /T tree root). Preserve
type/defaultValue/exportValues which Reset needs.

* pdf-server: fix combo reset, scope writability, drop flaky CI assert

Combo reset: pdf.js's resetform handler sets all option.selected =
(option.value === defaultFieldValue); when defaultFieldValue is null
nothing matches. Chrome then synchronously normalises the non-multiple
<select> by auto-selecting option[0] — so by the time our fix-listener
ran, selectedIndex was 0 and the state-check failed. Now: capture
whether a real default exists before the event; if reset didn't land
on it, force-prepend a hidden blank and select it explicitly.
(Was latent — before the ID fix, resetform never dispatched on selects.)

Writability scope: only mark writable if the file is explicitly in
allowedLocalFiles (CLI arg = opt-in) OR strictly under an allowed
root directory (isAncestorDir already excludes the root itself via
rel !== ''), AND fs.access W_OK passes. A root passed by the client
is a boundary, not a target.

CI: drop the post-rename second-write assertion — fs.watch re-attach
semantics differ between kqueue and inotify, inherently racy in CI.
Only assert the rename itself is detected.

* pdf-server: fix Reset bug; show cleared baseline fields w/ revert

Reset-all bug: clearUserFormStorage skipped any field whose name was
in pdfBaselineFormValues — but if the user had EDITED a baseline field,
the edit sits in storage under that name. Skipping it left the widget
showing the stale edit while the panel showed the restored baseline.
Fix: remove ALL storage overrides on reset — every field reverts to the
PDF's /V, which IS baseline. Removed the now-dead helper.

Panel: cleared baseline fields now stay visible instead of vanishing.
State is derived per-field by comparing formFieldValues to
pdfBaselineFormValues (no new data structures — both maps already
exist, the comparison was just never rendered):

- unchanged: current === baseline → solid swatch, trash clears
- modified:  baseline exists, current differs → solid swatch, revert
- cleared:   baseline exists, current empty/absent → outlined cross
             swatch, struck-out label/value, revert restores
- added:     no baseline → solid swatch, trash removes

Panel iterates union(formFieldValues, pdfBaselineFormValues) so cleared
items don't disappear. sidebarItemCount uses the same union so the
toolbar button stays visible as long as there are baseline items OR
edits. Per-item revert: formFieldValues.set(name, baseline) +
storage.remove(id) → widget reverts to /V.

* pdf-server: import baseline from widget fieldValue, not field-dict

page.getAnnotations().fieldValue is what AnnotationLayer actually
renders. getFieldObjects().value reads the field-dict /V — which can
be out of sync in an internally-inconsistent PDF (e.g. after a pdf-lib
setText silently failed on a comb field, leaving the field-dict stale
while the widget still shows the older value from the raw bytes).

Your Form.pdf: ID widget shows 'eeeee', field-dict says 'eew2e'.
Panel was importing 'eew2e' → mismatch with what's on screen.

Fix: capture a.fieldValue during the page.getAnnotations scan (we're
already iterating there for widget IDs) and prefer it over the
field-dict value. Fall back to field-dict if the widget doesn't expose
one. normaliseFieldValue() handles the format differences (choice
widgets give arrays; field-dict gives strings).

* pdf-server: fix radio input → 'on'; use export value for consistency

pdf.js creates <input type=radio> without setting .value (pdf.mjs:
18138-18144), so target.value defaults to the HTML spec's 'on'. Our
input listener read that → panel showed 'on' after clicking a radio,
but baseline import correctly used the export value ('0') — so
clicking then reverting showed two different strings for the same
selection.

Fix: store each radio widget's buttonValue during the annotation scan
(we're already there for widget IDs), read it in the input listener
via data-element-id. Now baseline, click, revert, and save all agree
on the export value.

(Form.pdf happens to use '0'/'1' as export values instead of
'Male'/'Female' — that's the form's design; the labels are just PDF
text next to the widgets, not form data.)

* pdf-server: panel Reset/Clear-all as icon buttons, keep tooltips

* pdf-server: file roots from MCP client are read-only

allowedLocalFiles conflated two sources: CLI args (user explicitly
named the file when starting the server — overwriting is intentional)
and MCP file roots (client-uploaded copies in ad-hoc hidden folders
that the client doesn't expect to change).

New cliLocalFiles set tracks only the CLI-sourced files. Writable now
requires: file is in cliLocalFiles OR strictly under a directory root.
Directory roots are mounted folders where saving is expected.

save_pdf enforces the same scope server-side — the viewer hides the
button based on _meta.writable, but we must not trust the client.

* pdf-server: file root stays read-only even under a directory root

Extracted isWritablePath() with the full policy in one place (was
duplicated between display_pdf and save_pdf):

- CLI file → writable (user explicitly named it)
- MCP file root → read-only, ALWAYS. Even when the path happens to
  fall inside a mounted directory root — the client sending it as a
  file root is the stronger signal ('here's an upload')
- under a directory root at any depth → writable (mounted folder)
- the directory root itself → not writable (rel === '' excluded)
- no roots and no CLI files → nothing writable

8 unit tests cover each case.

* pdf-server: fix e2e (interact for HTTP test harness), skip rename test on Linux

E2E: tests/e2e/pdf-annotations.spec.ts expects 'interact' in the tool
dropdown and calls it for annotation tests. Since 3f2c209c interact is
stdio-only; e2e runs HTTP basic-host → tool absent → all tests fail
(broken since Mar 4). Add --enable-interact CLI flag for HTTP mode and
pass it in 'npm run serve'. The in-memory command queue works fine for
the e2e setup (one long-lived process); the stdio-only restriction is
about stateless multi-instance deployments.

Rename test: inotify watches inodes. When the directory entry is
atomically swapped to a NEW inode, the watcher on the OLD inode gets
no event. kqueue (macOS) watches paths and fires 'rename'. Gate with
skipIf(platform !== 'darwin').

* pdf-server: list_pdfs walks directory roots for *.pdf files

Recurse into allowedLocalDirs (depth ≤ 8, ≤ 500 files) and enumerate
actual .pdf files as file:// URLs alongside the explicitly-registered
ones. Skip dotfiles/dirs and node_modules. Dedup (a file may be both
explicitly registered and under a walked directory). Report truncation.

* pdf-server: fix stale e2e assertion after 2d47f14b

Commit 2d47f14b moved the interact action list from display_pdf's
content to the tool description, but the e2e test still asserted the
result text contained 'add_annotations', 'highlight_text', etc.
Now checks for viewUUID / interactEnabled / 'Displaying PDF' which
are the actual result contents.

* pdf-server: add unpkg to resourceDomains (font-src) for pdf.js FontFace

pdf.js in the browser doesn't just fetch() the .ttf bytes — it also
creates FontFace('name', 'url(https://unpkg.com/...)') which goes
through CSP font-src, not connect-src. Nest's CSP blocked it
('font-src self assets.claude.ai'). pdf.js fell back to system fonts
so nothing broke visually, but the CDN URL was doing nothing.

(Separate finding re: the 'bricked UI' report — logs show fill_form
processed cleanly at 16:27:18, then Nest's router navigated away 13s
later. Poll id=17 returned to an already-unmounted iframe. Normal
teardown, not a brick.)

* pdf-server: validate imageUrl scheme before <img src> assignment

Fixes CodeQL js/xss + js/client-side-unvalidated-url-redirection on
line 1994 (renderImageAnnotation) and the canvas-paint fallback.
The server resolves imageUrl to imageData before enqueueing, so
def.imageUrl only survives to the client if the server-side fetch
fails. safeImageSrc whitelists https/http/data/blob and returns
undefined for javascript: et al.

* pdf-server: fix get_pages timeout race; trim fill_form response

get_pages round-trip (interact → enqueue → poll → render → submit):

- GET_PAGES_TIMEOUT_MS 60s → 45s. The MCP SDK also defaults to 60s, so
  if the round-trip got close, the client timed out first and our error
  went nowhere. Server rejects first now.
- waitForPageData wires extra.signal. If the client cancels interact
  (timeout/user abort), we clean up the pendingPageRequests entry
  immediately instead of leaking until our own timeout. Also simplified
  to a single settle callback instead of {resolve, reject, timer}.
- handleGetPages .catch() → submit empty pages. Was fire-and-forget
  with no error path; any rejection before the inner try/catch meant
  no submit_page_data → server waited the full timeout.

fill_form response: dropped the full field listing that was appended
on every call. display_pdf already returns it; echoing it back after
every successful fill is noise. Only list valid field names when the
model got one wrong.

* pdf-server: return URL.href from safeImageSrc so CodeQL sees sanitizer

The previous attempt returned the raw input string after a scheme
check, which CodeQL's taint tracker doesn't recognise as a sanitiser
(it sees tainted in → tainted out). Now parse with the URL constructor
and return the .href property — this is a canonical sanitiser pattern
CodeQL models as a barrier for js/xss and unvalidated-url-redirection.

Also folded the data: URI branch into safeImageSrc for a single call
site responsible for producing the <img src> string.

* feat(pdf-server): treat uploads-root dirs as read-only by default

Dir roots whose basename is 'uploads' (e.g. Claude Desktop's attachment
drop folder) are now treated as read-only unless --writeable-uploads-root
is passed. This prevents the save button from appearing for attached PDFs
that the client doesn't expect to be overwritten.

Also gates the _debug diagnostic block on --debug and adds save/restore
of writeFlags.allowUploadsRoot in test beforeEach/afterEach.

* feat(pdf-server): add --debug flag, fix interact timeout, skip persist for read-only cmds

- Wire --debug CLI flag through to createServer; _debug diagnostic block
  in display_pdf _meta is now only emitted when --debug is set.
- Viewer: showDebugBubble renders _debug payload as a fixed overlay.
- Fix interact get_pages timeout: await handleGetPages instead of
  fire-and-forget, so submit_page_data doesn't queue behind the next
  30s long-poll on serialized host connections.
- Skip persistAnnotations for read-only commands (get_pages, file_changed).

* fix(pdf-server): rebase annotations baseline after save

After a successful save_pdf, update pdfBaselineAnnotations and
pdfBaselineFormValues to reflect what was written to disk. Without
this, removing all annotations after a save produced an empty diff
(compared to the stale pre-save baseline), incorrectly disabling
the save button even though the file on disk still had annotations.

* fix(pdf-server): sync annotation panel on canvas click, fix tooltip z-index

Clicking an annotation on the canvas now re-renders the panel to
expand the correct page section and scrolls the card into view.
Previously only the selection class toggled without updating the
accordion.

Raise tooltip z-index to 100 within the annotation layer and
promote the annotation layer above the form layer on note hover
(:has selector) so tooltips aren't clipped by the form overlay.

* debug(pdf-server): add __pdfDebug() helper + log skipped baseline annotations

Adds window.__pdfDebug() that dumps annotationMap, baseline,
layer children, and localStorage diff to diagnose ghost annotations
(visible on canvas but not in panel, not selectable).

Also logs when importPdfjsAnnotation returns null for non-widget
annotations, and surfaces any thrown errors during baseline import
instead of silently swallowing them.

* debug(pdf-server): expose pdfDocument.annotationStorage + window.__pdf internals

__pdfDebug() now also dumps PDF.js's annotationStorage contents
(editor stamps live there, invisible to our tracking) and all
localStorage keys matching pdf-annot pattern.

After running __pdfDebug(), internals are exposed as
window.__pdf.{pdfDocument, annotationMap, annotationLayerEl, formLayerEl}
for interactive console poking.

* fix(pdf-server): hide viewUUID when interact disabled, widen annotations panel

- display_pdf result text no longer leaks viewUUID when the interact
  tool is not registered (model has no use for it).
- Annotations panel default width: 250px -> 500px (max-width:50vw
  still caps on narrow viewports).

* refactor(pdf-server): extract src/commands.ts, dedupe PdfCommand type

PdfCommand was defined independently in server.ts and mcp-app.ts with
no compiler check to keep them in sync. Extract to src/commands.ts as
the single source of truth; both sides import the type.

Uses the TS interfaces from pdf-annotations.ts (not Zod-inferred types)
since those already describe the post-resolveImageAnnotation wire shape
(imageData present, x/y/width/height non-optional).

Also deletes ~150 lines of dead Zod schemas (PdfAnnotationDef and the
10 variant schemas that built it) -- they were never used as runtime
validators, only for z.infer<typeof> type inference. Input remains
z.record(z.any()) for model-API forgiveness.

* fix(pdf-server): correctness cleanup bundle

- findTextRectsFromCache: return [] instead of a hardcoded placeholder
  rect when text-layer DOM isn't rendered. The placeholder was persisted
  as real coordinates; caller already guards on empty.
- onteardown: bump loadGeneration to stop the preloader (reuses the
  reload-abort check) and clear searchDebounceTimer.
- startPreloading: re-check loadGeneration inside the pause-wait loop
  so teardown mid-pause exits instead of spinning.
- app.sendMessage calls: wrap in .catch(log.error) so host-unsupported
  doesn't throw an unhandled rejection.
- Remove dead ternary (Highlight:Highlight), stale TODO and commented
  downloadBtn line.

* docs(pdf-server): fix action names in example prompts, document all flags

- Example prompts referenced get_pages with getText/getScreenshots params;
  actual model-facing actions are get_text (intervals) and get_screenshot
  (single page). Fixed 3 prompts.
- Document --debug, --enable-interact, --writeable-uploads-root flags.
- Add image annotation type to the Annotation Types table.
- Note that interact is disabled in HTTP mode unless --enable-interact.
- Add Deployment section (stdio vs HTTP single-instance vs stateless).

* test(pdf-server): add interact tool unit tests

New describe("interact tool") block with 7 tests covering:
- enqueue -> poll_pdf_commands roundtrip
- missing-arg error paths for navigate, fill_form, add_annotations
- command queue isolation across distinct viewUUIDs
- fill_form passthrough when viewFieldNames not registered
- (skip) unknown-UUID poll (LONG_POLL_TIMEOUT_MS not exported, can't
  bypass the 30s wait without changing server.ts)

Surprises found:
- interact never validates viewUUID exists; enqueues to any string
- batch-mode early-exits on first error, silently dropping later commands

* style: auto-fix prettier formatting

* refactor(pdf-server): extract annotation panel to src/annotation-panel.ts

Split ~930 lines from mcp-app.ts into two new modules:

- src/viewer-state.ts (50 lines): shared Maps/Sets/Arrays + types
  (annotationMap, formFieldValues, selectedAnnotationIds, fieldNameTo*,
  undoStack/redoStack, TrackedAnnotation, EditEntry). Only containers
  with stable bindings — both modules mutate contents, never reassign.

- src/annotation-panel.ts (1084 lines): floating panel positioning,
  accordion rendering, annotation/form-field cards, reset/clear-all.
  Coupling to mcp-app flows through PanelDeps injected at init —
  scalars like currentPage/pdfDocument reach the panel via a state()
  getter so the ~100 use sites in mcp-app stay unwrapped.

mcp-app.ts: 5666 → 4721 lines. panelState.open replaces
annotationPanelOpen; panelState.openAccordionSection is written by
selectAnnotation. syncSidebarSelection moved to the panel module.

* style: auto-fix prettier formatting

* test(pdf-server): document computeDiff same-id modification behavior

Two new tests:
- User-added annotation modification: captured in diff.added (id not
  in baseline, so latest content persists correctly).
- Baseline annotation in-place modification: KNOWN LIMITATION. Same-id
  edit produces empty diff (id-set based), so the change vanishes on
  reload. Viewer's addAnnotation() works around via remove+add, but
  updateAnnotation() (interact tool's update_annotations) does not.

* chore(pdf-server): revert unrelated screenshot regenerations

Pre-commit hook runs build:all which regenerates all example
screenshots. These 24 PNGs have no semantic change; reverting
to keep the PR diff focused on pdf-server.

* fix(pdf-server): gate resolveImageAnnotation on validateUrl (arbitrary file read)

resolveImageAnnotation() did fs.readFile on whatever imageUrl the model
sent, bypassing the allowedLocalFiles/allowedLocalDirs machinery that
protects display_pdf and save_pdf. Model could request
{imageUrl:"/Users/x/.ssh/id_rsa"}, server would base64 the bytes into
the add_annotations command, iframe stores it, get_screenshot reads it
back.

Also: fetch() branch accepted plain http:// (SSRF to local network).

Fix: call validateUrl(imageUrl) before touching the filesystem or
network. Throws on rejection; caller converts to {isError:true} so the
model sees a clear error instead of silent skip.

3 tests: reject local path outside roots, reject http://, accept path
under allowed dir (with real readFile + imageData population).

* fix(pdf-server): deleted-baseline zombies, clear-all form strip, height-only coord shift

Three bugs from PR review, all in viewer state/coord handling:

1. Deleted baseline annotations zombied back on reload (mcp-app.ts:2512).
   restoreAnnotations' apply loop was add-only. loadBaselineAnnotations
   runs between the two restore calls and re-seeds annotationMap with
   every baseline id, including the ones in diff.removed. The zombie
   survives, and the next persistAnnotations sees it in currentIds ->
   computeDiff produces removed=[] -> deletion permanently lost.
   Fix: delete diff.removed ids after the merge loop.

2. Clear All didn't strip form values from the saved PDF (mcp-app.ts:2756).
   clearAllItems() empties formFieldValues, but buildAnnotatedPdfBytes
   gates on formFields.size > 0 and only writes entries present in the
   map. Empty map -> zero setText/uncheck calls -> pdf-lib keeps the
   original /V values. Fix: at getAnnotatedPdfBytes time, inject an
   explicit clearing sentinel ("" or false by baseline type) for every
   baseline field absent from formFieldValues.

3. update_annotations shifted rect/circle/image when patching height
   without y (mcp-app.ts:4161). The old code spread existing.def
   (internal coords, y = bottom edge) with update (model coords, y =
   top-left), then key-filtered back to only update keys. But internal
   y = pageHeight - modelY - height; patching height with top fixed
   requires rewriting internal y, which the key-filter discarded.
   Fix: convert existing to model coords, merge in model space, convert
   back, pass the full merged def. Also uses the target page height
   when update.page differs (fixes the low-severity page-move bug too).

* fix(pdf-server): sweep aux maps independently of commandQueues; line annotations sort

Two lower-severity findings from review:

Leak (server.ts:265): pruneStaleQueues iterated commandQueues to clean
up viewFieldNames/viewFieldInfo/viewFileWatches, but display_pdf
populates those maps without creating a commandQueues entry (only
enqueueCommand does), and dequeueCommands deletes the entry on every
poll. Net: the sweep found nothing; aux maps leaked every display_pdf.
viewFileWatches entries hold an fs.StatWatcher -> slow FD exhaustion
on HTTP --enable-interact. Fix: new viewLastActivity heartbeat map,
touched at display_pdf/enqueue/dequeue, swept on TTL. Dead second
loop (entry.commands.length===0 was unreachable) removed.

Line-annotation sort (annotation-panel.ts:400): getAnnotationY checked
'y' in def and 'rects' in def, but LineAnnotation has only x1/y1/x2/y2.
Fell through to return 0 -> all line annotations sorted to panel
bottom. Added y1 branch returning max(y1, y2).

* feat(pdf-server): rewrite display_pdf prompts to steer model toward interact

The model was calling display_pdf again to add annotations instead of
using interact with the existing viewUUID. Root cause: the description's
first two sentences invited exactly that --

  'Use this to display, annotate, edit, and fill form fields'
  'when the user wants to ... annotate, edit, sign, stamp'

-- then the CRITICAL warning arrived three paragraphs later, after the
model had already pattern-matched 'user wants to sign' -> display_pdf.

Three changes:
- First sentence is now narrow: 'Open a PDF. Call this ONCE per PDF.'
- Follow-up-actions-go-through-interact is sentence 2, not paragraph 3.
- Result text is directive at the decision point: tells the model to
  call interact with the viewUUID, warns against re-calling display_pdf.

* fix(pdf-server): correct the 'discards state' claim in display_pdf prompts

Both viewers coexist; nothing is discarded. The actual failure mode is
the model now holds a new viewUUID and addresses the wrong viewer --
interact calls land on the fresh empty one, not the one with the user's
existing annotations. Rewording to say that.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-19 17:41:55 +00:00

2434 lines
81 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* PDF MCP Server
*
* An MCP server that displays PDFs in an interactive viewer.
* Supports local files and remote HTTPS URLs.
*
* Tools:
* - list_pdfs: List available PDFs
* - display_pdf: Show interactive PDF viewer
* - read_pdf_bytes: Stream PDF data in chunks (used by viewer)
*/
import { randomUUID } from "crypto";
import fs from "node:fs";
import path from "node:path";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import {
RootsListChangedNotificationSchema,
type CallToolResult,
type ReadResourceResult,
} from "@modelcontextprotocol/sdk/types.js";
// Use the legacy build to avoid DOMMatrix dependency in Node.js
import {
getDocument,
VerbosityLevel,
version as PDFJS_VERSION,
} from "pdfjs-dist/legacy/build/pdf.mjs";
/**
* PDF Standard-14 fonts from CDN. Used by both server and viewer so we
* declare a single well-known origin in CSP connectDomains.
*
* pdf.js in Node defaults to NodeStandardFontDataFactory (fs.readFile) which
* can't fetch URLs, so we pass {@link FetchStandardFontDataFactory} alongside.
* The browser viewer uses the DOM factory by default and just needs the URL.
*/
export const STANDARD_FONT_DATA_URL = `https://unpkg.com/pdfjs-dist@${PDFJS_VERSION}/standard_fonts/`;
const STANDARD_FONT_ORIGIN = "https://unpkg.com";
/** pdf.js font factory that uses fetch() instead of fs.readFile. */
class FetchStandardFontDataFactory {
baseUrl: string | null;
constructor({ baseUrl = null }: { baseUrl?: string | null }) {
this.baseUrl = baseUrl;
}
async fetch({ filename }: { filename: string }): Promise<Uint8Array> {
if (!this.baseUrl) throw new Error("standardFontDataUrl not provided");
const url = `${this.baseUrl}${filename}`;
const res = await globalThis.fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
}
}
import type {
PrimitiveSchemaDefinition,
ElicitResult,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
// =============================================================================
// Configuration
// =============================================================================
export const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need
export const MAX_CHUNK_BYTES = 512 * 1024; // 512KB max per request
export const RESOURCE_URI = "ui://pdf-viewer/mcp-app.html";
/** Inactivity timeout: clear cache entry if not accessed for this long */
export const CACHE_INACTIVITY_TIMEOUT_MS = 10_000; // 10 seconds
/** Max lifetime: clear cache entry after this time regardless of access */
export const CACHE_MAX_LIFETIME_MS = 60_000; // 60 seconds
/** Max size for cached PDFs (defensive limit to prevent memory exhaustion) */
export const CACHE_MAX_PDF_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
/** Allowed local file paths (CLI args + file roots — read access). */
export const allowedLocalFiles = new Set<string>();
/** Allowed local directories (CLI args + directory roots — read access). */
export const allowedLocalDirs = new Set<string>();
/**
* Subset of allowedLocalFiles that came from CLI args (not MCP roots).
* Only these individual files are writable. File roots from the client
* are uploaded copies in ad-hoc hidden folders — treat as read-only.
* Directory roots are mounted folders; files UNDER them are writable.
*/
export const cliLocalFiles = new Set<string>();
/**
* Write-permission flags. Object wrapper (not a bare `let`) so main.ts can
* mutate via the exported binding without re-import gymnastics — same
* pattern as the Sets above.
*/
export const writeFlags = {
/**
* Claude Desktop mounts its per-conversation drop folder as a directory
* root whose basename is literally `uploads`. Files in there are one-shot
* copies the client doesn't expect us to overwrite. Default: read-only.
* `--writeable-uploads-root` flips this for local testing.
*/
allowUploadsRoot: false,
};
/**
* Saving is allowed iff:
* (a) the file was passed as a CLI arg — the user explicitly named it
* when starting the server, so overwriting is clearly intentional; OR
* (b) the file is STRICTLY UNDER a directory root at any depth
* (isAncestorDir excludes rel === "", so the root itself never
* counts), AND the client did not ALSO send it as a file root.
* A file root is the client's way of saying "here's an upload" —
* treat that signal as authoritative even when the path happens
* to fall inside a mounted directory.
*
* EXCEPTION to (b): a dir root whose basename is `uploads` is treated
* as read-only unless `writeFlags.allowUploadsRoot` is set. This is how
* Claude Desktop surfaces attached files — writing back to them
* surprises the user (the attachment doesn't update).
*
* With no directory roots and no CLI files, nothing is writable.
*/
export function isWritablePath(resolved: string): boolean {
if (cliLocalFiles.has(resolved)) return true;
// MCP file root → always read-only, regardless of ancestry
if (allowedLocalFiles.has(resolved)) return false;
return [...allowedLocalDirs].some((dir) => {
if (!isAncestorDir(dir, resolved)) return false;
if (!writeFlags.allowUploadsRoot && path.basename(dir) === "uploads") {
return false;
}
return true;
});
}
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
: import.meta.dirname;
// =============================================================================
// Command Queue (shared across stateless server instances)
// =============================================================================
/** Commands expire after this many ms if never polled */
const COMMAND_TTL_MS = 60_000; // 60 seconds
/** Periodic sweep interval to drop stale queues */
const SWEEP_INTERVAL_MS = 30_000; // 30 seconds
/** Fixed batch window: when commands are present, wait this long before returning to let more accumulate */
const POLL_BATCH_WAIT_MS = 200;
const LONG_POLL_TIMEOUT_MS = 30_000; // Max time to hold a long-poll request open
// =============================================================================
// Interact Tool Input Schemas (runtime validators)
// =============================================================================
//
// Annotation structure docs live in src/pdf-annotations.ts (the TS
// interfaces) and in the interact tool description. The inputSchema
// for `annotations` accepts z.record(z.any()) to keep the model-facing
// API forgiving; adding strict validation here would be a behavior change.
const FormField = z.object({
name: z.string(),
value: z.union([z.string(), z.boolean()]),
});
const PageInterval = z.object({
start: z.number().min(1).optional(),
end: z.number().min(1).optional(),
});
// =============================================================================
// Command Queue — wire protocol shared with the viewer
// =============================================================================
// PdfCommand is the single source of truth for what flows through the
// poll queue. Defined once in src/commands.ts; both sides import it.
// (`import type` → no pdf-lib bundled into the server.)
import type { PdfCommand } from "./src/commands.js";
export type { PdfCommand };
// =============================================================================
// Pending get_pages Requests (request-response bridge via client)
// =============================================================================
// Keep well under the MCP SDK's DEFAULT_REQUEST_TIMEOUT_MSEC (60s) so we
// reject first and return a real error instead of the client cancelling us.
const GET_PAGES_TIMEOUT_MS = 45_000;
interface PageDataEntry {
page: number;
text?: string;
image?: string; // base64 PNG
}
const pendingPageRequests = new Map<
string,
(data: PageDataEntry[] | Error) => void
>();
/**
* Wait for the viewer to render and submit page data.
* Rejects on timeout or when the interact request is aborted upstream.
*/
function waitForPageData(
requestId: string,
signal?: AbortSignal,
): Promise<PageDataEntry[]> {
return new Promise<PageDataEntry[]>((resolve, reject) => {
const settle = (v: PageDataEntry[] | Error) => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
pendingPageRequests.delete(requestId);
v instanceof Error ? reject(v) : resolve(v);
};
const onAbort = () => settle(new Error("interact request cancelled"));
const timer = setTimeout(
() => settle(new Error("Timeout waiting for page data from viewer")),
GET_PAGES_TIMEOUT_MS,
);
signal?.addEventListener("abort", onAbort);
pendingPageRequests.set(requestId, settle);
});
}
interface QueueEntry {
commands: PdfCommand[];
/** Timestamp of the most recent enqueue or dequeue */
lastActivity: number;
}
const commandQueues = new Map<string, QueueEntry>();
/** Waiters for long-poll: resolve callback wakes up a blocked poll_pdf_commands */
const pollWaiters = new Map<string, () => void>();
/** Valid form field names per viewer UUID (populated during display_pdf) */
const viewFieldNames = new Map<string, Set<string>>();
/** Detailed form field info per viewer UUID (populated during display_pdf) */
const viewFieldInfo = new Map<string, FormFieldInfo[]>();
/**
* Active fs.watch per view. Only created for local files when interact is
* enabled (stdio). Watcher is re-established on `rename` events to survive
* atomic writes (vim/vscode write-to-tmp-then-rename changes the inode).
*/
interface ViewFileWatch {
filePath: string;
watcher: fs.FSWatcher;
lastMtimeMs: number;
debounce: ReturnType<typeof setTimeout> | null;
}
const viewFileWatches = new Map<string, ViewFileWatch>();
/**
* Per-view heartbeat. THIS is what the sweep iterates — not commandQueues.
*
* Why not commandQueues: display_pdf populates viewFieldNames/viewFieldInfo/
* viewFileWatches but never touches commandQueues (only enqueueCommand does,
* and it's triply gated). And dequeueCommands deletes the entry on every poll,
* so even when it exists the sweep's TTL window is ~200ms wide. Net effect:
* the sweep found nothing and the aux maps leaked every display_pdf call.
* viewFileWatches entries hold an fs.StatWatcher (FD + timer) — slow FD
* exhaustion on HTTP --enable-interact.
*/
const viewLastActivity = new Map<string, number>();
/** Register or refresh the heartbeat for a view. */
function touchView(uuid: string): void {
viewLastActivity.set(uuid, Date.now());
}
function pruneStaleQueues(): void {
const now = Date.now();
for (const [uuid, lastActivity] of viewLastActivity) {
if (now - lastActivity > COMMAND_TTL_MS) {
viewLastActivity.delete(uuid);
commandQueues.delete(uuid);
viewFieldNames.delete(uuid);
viewFieldInfo.delete(uuid);
stopFileWatch(uuid);
}
}
}
// Periodic sweep so abandoned views don't leak
setInterval(pruneStaleQueues, SWEEP_INTERVAL_MS).unref();
function enqueueCommand(viewUUID: string, command: PdfCommand): void {
let entry = commandQueues.get(viewUUID);
if (!entry) {
entry = { commands: [], lastActivity: Date.now() };
commandQueues.set(viewUUID, entry);
}
entry.commands.push(command);
entry.lastActivity = Date.now();
touchView(viewUUID);
// Wake up any long-polling request waiting for this viewUUID
const waiter = pollWaiters.get(viewUUID);
if (waiter) {
pollWaiters.delete(viewUUID);
waiter();
}
}
function dequeueCommands(viewUUID: string): PdfCommand[] {
// Poll is activity — keep the view alive even when the queue is empty
// (the common case: viewer polls every ~30s with nothing to receive).
touchView(viewUUID);
const entry = commandQueues.get(viewUUID);
if (!entry) return [];
const commands = entry.commands;
commandQueues.delete(viewUUID);
return commands;
}
// =============================================================================
// File Watching (local files, stdio only)
// =============================================================================
const FILE_WATCH_DEBOUNCE_MS = 150;
export function startFileWatch(viewUUID: string, filePath: string): void {
const resolved = path.resolve(filePath);
let stat: fs.Stats;
try {
stat = fs.statSync(resolved);
} catch {
return; // vanished between validation and here
}
// Replace any existing watcher for this view
stopFileWatch(viewUUID);
const entry: ViewFileWatch = {
filePath: resolved,
watcher: null as unknown as fs.FSWatcher,
lastMtimeMs: stat.mtimeMs,
debounce: null,
};
const onEvent = (eventType: string): void => {
if (entry.debounce) clearTimeout(entry.debounce);
entry.debounce = setTimeout(() => {
entry.debounce = null;
let s: fs.Stats;
try {
s = fs.statSync(resolved);
} catch {
return; // gone mid-atomic-write; next rename will re-attach
}
if (s.mtimeMs === entry.lastMtimeMs) return; // spurious / already sent
entry.lastMtimeMs = s.mtimeMs;
enqueueCommand(viewUUID, { type: "file_changed", mtimeMs: s.mtimeMs });
}, FILE_WATCH_DEBOUNCE_MS);
// Atomic saves replace the inode — old watcher stops firing. Re-attach.
if (eventType === "rename") {
try {
entry.watcher.close();
} catch {
/* already closed */
}
try {
entry.watcher = fs.watch(resolved, onEvent);
} catch {
// File removed, not replaced. Leave closed; pruneStaleQueues cleans up.
}
}
};
try {
entry.watcher = fs.watch(resolved, onEvent);
} catch {
return; // fs.watch unsupported (e.g. some network filesystems)
}
viewFileWatches.set(viewUUID, entry);
}
export function stopFileWatch(viewUUID: string): void {
const entry = viewFileWatches.get(viewUUID);
if (!entry) return;
if (entry.debounce) clearTimeout(entry.debounce);
try {
entry.watcher.close();
} catch {
/* ignore */
}
viewFileWatches.delete(viewUUID);
}
// =============================================================================
// URL Validation & Normalization
// =============================================================================
export function isFileUrl(url: string): boolean {
return url.startsWith("file://") || url.startsWith("computer://");
}
export function isArxivUrl(url: string): boolean {
try {
const parsed = new URL(url);
return (
parsed.hostname === "arxiv.org" || parsed.hostname === "www.arxiv.org"
);
} catch {
return false;
}
}
export function normalizeArxivUrl(url: string): string {
// Convert arxiv abstract URLs to PDF URLs
// https://arxiv.org/abs/1706.03762 -> https://arxiv.org/pdf/1706.03762
return url.replace("/abs/", "/pdf/").replace(/\.pdf$/, "");
}
export function fileUrlToPath(fileUrl: string): string {
// Support both file:// and computer:// (used by some clients for local files)
return decodeURIComponent(fileUrl.replace(/^(?:file|computer):\/\//, ""));
}
export function pathToFileUrl(filePath: string): string {
const absolutePath = path.resolve(filePath);
return `file://${encodeURIComponent(absolutePath).replace(/%2F/g, "/")}`;
}
/**
* Check if `dir` is an ancestor of `filePath` using path.relative,
* which is more robust than string prefix matching (handles normalization).
*/
export function isAncestorDir(dir: string, filePath: string): boolean {
const rel = path.relative(dir, filePath);
// Must be non-empty (not the dir itself when checking files),
// must not start with ".." (escaping), and must not be absolute (different root).
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
}
/**
* Check if `url` looks like an absolute local file path (not a URL scheme).
* Handles Unix paths (/...), home-relative (~), and Windows drive letters (C:\...).
*/
function isLocalPath(url: string): boolean {
return (
url.startsWith("/") || url.startsWith("~") || /^[A-Za-z]:[/\\]/.test(url)
);
}
export function validateUrl(url: string): {
valid: boolean;
error?: string;
} {
if (isFileUrl(url) || isLocalPath(url)) {
// fileUrlToPath already decodes percent-encoding; for bare paths,
// decode here in case the client sends %20 for spaces etc.
const filePath = isFileUrl(url)
? fileUrlToPath(url)
: decodeURIComponent(url);
const resolved = path.resolve(filePath);
// Check exact match (CLI args / roots)
if (allowedLocalFiles.has(resolved)) {
if (!fs.existsSync(resolved)) {
return { valid: false, error: `File not found: ${resolved}` };
}
return { valid: true };
}
// Check directory match (MCP roots / CLI dirs).
// Try both the raw path and its realpath (resolves symlinks).
let realResolved: string | undefined;
try {
realResolved = fs.realpathSync(resolved);
} catch {
// File may not exist yet at this path
}
if (
[...allowedLocalDirs].some((dir) => {
let realDir: string | undefined;
try {
realDir = fs.realpathSync(dir);
} catch {
// Dir may not exist
}
return (
isAncestorDir(dir, resolved) ||
(realResolved != null && isAncestorDir(dir, realResolved)) ||
(realDir != null && isAncestorDir(realDir, resolved)) ||
(realDir != null &&
realResolved != null &&
isAncestorDir(realDir, realResolved))
);
})
) {
if (!fs.existsSync(resolved)) {
return { valid: false, error: `File not found: ${resolved}` };
}
return { valid: true };
}
console.error(
`[pdf-server] Local file not in allowed list: ${resolved}\n Allowed dirs: ${[...allowedLocalDirs].join(", ")}`,
);
return {
valid: false,
error: `Local file not in allowed list: ${resolved}\nAllowed directories: ${[...allowedLocalDirs].join(", ")}`,
};
}
// Remote URL - require HTTPS
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:") {
return { valid: false, error: `Only HTTPS URLs are allowed: ${url}` };
}
return { valid: true };
} catch {
return { valid: false, error: `Invalid URL: ${url}` };
}
}
// =============================================================================
// Session-Local PDF Cache
// =============================================================================
/**
* Cache entry for remote PDFs from servers that don't support Range requests.
* Tracks both inactivity and max lifetime for automatic cleanup.
*/
interface CacheEntry {
/** The cached PDF data */
data: Uint8Array;
/** Timestamp when entry was created (for max lifetime) */
createdAt: number;
/** Timer that fires after CACHE_INACTIVITY_TIMEOUT_MS of no access */
inactivityTimer: ReturnType<typeof setTimeout>;
/** Timer that fires after CACHE_MAX_LIFETIME_MS from creation */
maxLifetimeTimer: ReturnType<typeof setTimeout>;
}
/**
* Session-local PDF cache utilities.
* Each call to createPdfCache() creates an independent cache instance.
*/
export interface PdfCache {
/** Read a range of bytes from a PDF, using cache for servers without Range support */
readPdfRange(
url: string,
offset: number,
byteCount: number,
): Promise<{ data: Uint8Array; totalBytes: number }>;
/** Get current number of cached entries */
getCacheSize(): number;
/** Clear all cached entries and their timers */
clearCache(): void;
}
/**
* Creates a session-local PDF cache with automatic timeout-based cleanup.
*
* When a remote server returns HTTP 200 (full body) instead of 206 (partial),
* the full response is cached so subsequent chunk requests don't re-download.
*
* Entries are automatically cleared after:
* - CACHE_INACTIVITY_TIMEOUT_MS of no access (resets on each access)
* - CACHE_MAX_LIFETIME_MS from creation (absolute timeout)
*/
export function createPdfCache(): PdfCache {
const cache = new Map<string, CacheEntry>();
/** Delete a cache entry and clear its timers */
function deleteCacheEntry(url: string): void {
const entry = cache.get(url);
if (entry) {
clearTimeout(entry.inactivityTimer);
clearTimeout(entry.maxLifetimeTimer);
cache.delete(url);
}
}
/** Get cached data and refresh the inactivity timer */
function getCacheEntry(url: string): Uint8Array | undefined {
const entry = cache.get(url);
if (!entry) return undefined;
// Refresh inactivity timer on access
clearTimeout(entry.inactivityTimer);
entry.inactivityTimer = setTimeout(() => {
deleteCacheEntry(url);
}, CACHE_INACTIVITY_TIMEOUT_MS);
return entry.data;
}
/** Add data to cache with both inactivity and max lifetime timers */
function setCacheEntry(url: string, data: Uint8Array): void {
// Clear any existing entry first
deleteCacheEntry(url);
const entry: CacheEntry = {
data,
createdAt: Date.now(),
inactivityTimer: setTimeout(() => {
deleteCacheEntry(url);
}, CACHE_INACTIVITY_TIMEOUT_MS),
maxLifetimeTimer: setTimeout(() => {
deleteCacheEntry(url);
}, CACHE_MAX_LIFETIME_MS),
};
cache.set(url, entry);
}
/** Slice a cached or freshly-fetched full body to the requested range. */
function sliceToChunk(
fullData: Uint8Array,
offset: number,
clampedByteCount: number,
): { data: Uint8Array; totalBytes: number } {
const totalBytes = fullData.length;
const start = Math.min(offset, totalBytes);
const end = Math.min(start + clampedByteCount, totalBytes);
return { data: fullData.slice(start, end), totalBytes };
}
async function readPdfRange(
url: string,
offset: number,
byteCount: number,
): Promise<{ data: Uint8Array; totalBytes: number }> {
const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
const clampedByteCount = Math.min(byteCount, MAX_CHUNK_BYTES);
if (isFileUrl(normalized) || isLocalPath(normalized)) {
const filePath = isFileUrl(normalized)
? fileUrlToPath(normalized)
: decodeURIComponent(normalized);
const stats = await fs.promises.stat(filePath);
const totalBytes = stats.size;
// Clamp to file bounds
const start = Math.min(offset, totalBytes);
const end = Math.min(start + clampedByteCount, totalBytes);
if (start >= totalBytes) {
return { data: new Uint8Array(0), totalBytes };
}
// Read range from local file
const buffer = Buffer.alloc(end - start);
const fd = await fs.promises.open(filePath, "r");
try {
await fd.read(buffer, 0, end - start, start);
} finally {
await fd.close();
}
return { data: new Uint8Array(buffer), totalBytes };
}
// Serve from cache if we previously downloaded the full body
const cached = getCacheEntry(normalized);
if (cached) {
return sliceToChunk(cached, offset, clampedByteCount);
}
// Remote URL - try Range request, fall back to full GET if not supported
let response = await fetch(normalized, {
headers: {
Range: `bytes=${offset}-${offset + clampedByteCount - 1}`,
},
});
// If server doesn't support Range (501, 416, etc.), fall back to plain GET
if (!response.ok && response.status !== 206) {
response = await fetch(normalized);
if (!response.ok) {
throw new Error(
`Failed to fetch PDF: ${response.status} ${response.statusText}`,
);
}
}
// HTTP 200 means the server ignored our Range header and sent the full body.
// Cache it so subsequent chunk requests don't re-download, then slice.
if (response.status === 200) {
// Check Content-Length header first as a preliminary size check
const contentLength = response.headers.get("content-length");
if (contentLength) {
const declaredSize = parseInt(contentLength, 10);
if (declaredSize > CACHE_MAX_PDF_SIZE_BYTES) {
throw new Error(
`PDF too large to cache: ${declaredSize} bytes exceeds ${CACHE_MAX_PDF_SIZE_BYTES} byte limit`,
);
}
}
const fullData = new Uint8Array(await response.arrayBuffer());
// Check actual size (may differ from Content-Length)
if (fullData.length > CACHE_MAX_PDF_SIZE_BYTES) {
throw new Error(
`PDF too large to cache: ${fullData.length} bytes exceeds ${CACHE_MAX_PDF_SIZE_BYTES} byte limit`,
);
}
setCacheEntry(normalized, fullData);
return sliceToChunk(fullData, offset, clampedByteCount);
}
// HTTP 206 Partial Content — parse total size from Content-Range header
const contentRange = response.headers.get("content-range");
let totalBytes = 0;
if (contentRange) {
const match = contentRange.match(/bytes \d+-\d+\/(\d+)/);
if (match) {
totalBytes = parseInt(match[1], 10);
}
}
const data = new Uint8Array(await response.arrayBuffer());
return { data, totalBytes };
}
return {
readPdfRange,
getCacheSize: () => cache.size,
clearCache: () => {
for (const url of [...cache.keys()]) {
deleteCacheEntry(url);
}
},
};
}
// =============================================================================
// MCP Roots
// =============================================================================
/**
* Query the client for roots and update allowedLocalDirs with any file:// roots
* that point to existing directories.
*/
async function refreshRoots(server: Server): Promise<void> {
if (!server.getClientCapabilities()?.roots) return;
try {
const { roots } = await server.listRoots();
allowedLocalDirs.clear();
for (const root of roots) {
if (isFileUrl(root.uri)) {
const dir = fileUrlToPath(root.uri);
const resolved = path.resolve(dir);
try {
const s = fs.statSync(resolved);
if (s.isFile()) {
console.error(
`[pdf-server] Root is a file, not a directory (skipped): ${resolved}`,
);
allowedLocalFiles.add(resolved);
} else if (s.isDirectory()) {
allowedLocalDirs.add(resolved);
console.error(`[pdf-server] Root directory allowed: ${resolved}`);
}
} catch {
// stat failed — skip non-existent roots
}
}
}
} catch (err) {
console.error(
`[pdf-server] Failed to list roots: ${err instanceof Error ? err.message : err}`,
);
}
}
// =============================================================================
// PDF Form Field Extraction
// =============================================================================
/**
* Extract form fields from a PDF and build an elicitation schema.
* Returns null if the PDF has no form fields.
*/
/** Shape of field objects returned by pdfjs-dist's getFieldObjects(). */
interface PdfJsFieldObject {
type: string;
name: string;
editable: boolean;
exportValues?: string;
items?: Array<{ exportValue: string; displayValue: string }>;
}
/** Detailed info about a form field, including its location on the page. */
interface FormFieldInfo {
name: string;
type: string;
page: number;
label?: string;
/** Bounding box in model coordinates (top-left origin) */
x: number;
y: number;
width: number;
height: number;
}
/**
* Extract detailed form field info (name, type, page, bounding box, label)
* from a PDF. Bounding boxes are converted to model coordinates (top-left origin).
*/
async function extractFormFieldInfo(
url: string,
readRange: (
url: string,
offset: number,
byteCount: number,
) => Promise<{ data: Uint8Array; totalBytes: number }>,
): Promise<FormFieldInfo[]> {
const { totalBytes } = await readRange(url, 0, 1);
const { data } = await readRange(url, 0, totalBytes);
const loadingTask = getDocument({
data,
standardFontDataUrl: STANDARD_FONT_DATA_URL,
StandardFontDataFactory: FetchStandardFontDataFactory,
// We only introspect form fields (never render) — silence residual
// warnings like "Unimplemented border style: inset".
verbosity: VerbosityLevel.ERRORS,
});
const pdfDoc = await loadingTask.promise;
const fields: FormFieldInfo[] = [];
try {
for (let i = 1; i <= pdfDoc.numPages; i++) {
const page = await pdfDoc.getPage(i);
const pageHeight = page.getViewport({ scale: 1.0 }).height;
const annotations = await page.getAnnotations();
for (const ann of annotations) {
// Only include form widgets (annotationType 20)
if (ann.annotationType !== 20) continue;
if (!ann.rect) continue;
const fieldName = ann.fieldName || "";
const fieldType = ann.fieldType || "unknown";
// PDF rect is [x1, y1, x2, y2] in bottom-left origin
const x1 = Math.min(ann.rect[0], ann.rect[2]);
const y1 = Math.min(ann.rect[1], ann.rect[3]);
const x2 = Math.max(ann.rect[0], ann.rect[2]);
const y2 = Math.max(ann.rect[1], ann.rect[3]);
const width = x2 - x1;
const height = y2 - y1;
// Convert to model coords (top-left origin): modelY = pageHeight - pdfY - height
const modelY = pageHeight - y2;
fields.push({
name: fieldName,
type: fieldType,
page: i,
x: Math.round(x1),
y: Math.round(modelY),
width: Math.round(width),
height: Math.round(height),
...(ann.alternativeText ? { label: ann.alternativeText } : undefined),
});
}
}
} finally {
pdfDoc.destroy();
}
return fields;
}
async function extractFormSchema(
url: string,
readRange: (
url: string,
offset: number,
byteCount: number,
) => Promise<{ data: Uint8Array; totalBytes: number }>,
): Promise<{
type: "object";
properties: Record<string, PrimitiveSchemaDefinition>;
required?: string[];
} | null> {
// Read full PDF bytes
const { totalBytes } = await readRange(url, 0, 1);
const { data } = await readRange(url, 0, totalBytes);
const loadingTask = getDocument({
data,
standardFontDataUrl: STANDARD_FONT_DATA_URL,
StandardFontDataFactory: FetchStandardFontDataFactory,
// We only introspect form fields (never render) — silence residual
// warnings like "Unimplemented border style: inset".
verbosity: VerbosityLevel.ERRORS,
});
const pdfDoc = await loadingTask.promise;
let fieldObjects: Record<string, PdfJsFieldObject[]> | null;
try {
fieldObjects = (await pdfDoc.getFieldObjects()) as Record<
string,
PdfJsFieldObject[]
> | null;
} catch {
pdfDoc.destroy();
return null;
}
if (!fieldObjects || Object.keys(fieldObjects).length === 0) {
pdfDoc.destroy();
return null;
}
const properties: Record<string, PrimitiveSchemaDefinition> = {};
for (const [name, fields] of Object.entries(fieldObjects)) {
const field = fields[0]; // first widget determines the type
if (!field.editable) continue;
switch (field.type) {
case "text":
properties[name] = { type: "string", title: name };
break;
case "checkbox":
properties[name] = { type: "boolean", title: name };
break;
case "radiobutton": {
const options = fields
.map((f) => f.exportValues)
.filter((v): v is string => !!v && v !== "Off");
properties[name] =
options.length > 0
? { type: "string", title: name, enum: options }
: { type: "string", title: name };
break;
}
case "combobox":
case "listbox": {
const items = field.items?.map((i) => i.exportValue).filter(Boolean);
properties[name] =
items && items.length > 0
? { type: "string", title: name, enum: items }
: { type: "string", title: name };
break;
}
// Skip "button" (push buttons) and unknown types
}
}
// Collect alternativeText labels from per-page annotations
// (getFieldObjects doesn't include them)
const fieldLabels = new Map<string, string>();
try {
for (let i = 1; i <= pdfDoc.numPages; i++) {
const page = await pdfDoc.getPage(i);
const annotations = await page.getAnnotations();
for (const ann of annotations) {
if (ann.fieldName && ann.alternativeText) {
fieldLabels.set(ann.fieldName, ann.alternativeText);
}
}
}
} catch {
// ignore
}
// Use labels as titles where available
for (const [name, prop] of Object.entries(properties)) {
const label = fieldLabels.get(name);
if (label) {
prop.title = label;
}
}
// If any editable field has a mechanical name (no human-readable label),
// elicitation would be confusing — return null to skip it.
const hasMechanicalNames = Object.keys(properties).some((name) => {
if (fieldLabels.has(name)) return false;
return /[[\]().]/.test(name) || /^[A-Z0-9_]+$/.test(name);
});
pdfDoc.destroy();
if (Object.keys(properties).length === 0) return null;
if (hasMechanicalNames) return null;
return { type: "object", properties };
}
// =============================================================================
// MCP Server Factory
// =============================================================================
export interface CreateServerOptions {
/**
* Enable the `interact` tool and related command-queue infrastructure
* (in-memory command queue, `poll_pdf_commands`, `submit_page_data`).
* Only suitable for single-instance deployments (e.g. stdio transport).
* Defaults to false — server exposes only `list_pdfs` and `display_pdf` (read-only).
*/
enableInteract?: boolean;
/**
* Whether to honour MCP roots sent by the client.
*
* When a server is exposed over HTTP, the connecting client is
* typically remote and may advertise `roots` that refer to
* directories on the **client's** file system. Because the server
* resolves those paths locally, accepting them by default would give
* the remote client access to arbitrary directories on the
* **server's** machine.
*
* For stdio the client is typically local (e.g. Claude Desktop on the
* same machine), so roots are safe and enabled by default.
*
* Set this to `true` for HTTP only when you trust the client, or
* pass the `--use-client-roots` CLI flag.
*
* @default false
*/
useClientRoots?: boolean;
/**
* Emit debug metadata to the viewer (currently: allowed roots shown
* in a floating bubble). Toggled by the `--debug` CLI flag.
*/
debug?: boolean;
}
export function createServer(options: CreateServerOptions = {}): McpServer {
const { enableInteract = false, useClientRoots = false } = options;
const debug = options.debug ?? false;
const disableInteract = !enableInteract;
const server = new McpServer({ name: "PDF Server", version: "2.0.0" });
if (useClientRoots) {
// Fetch roots on initialization and subscribe to changes
server.server.oninitialized = () => {
refreshRoots(server.server);
};
server.server.setNotificationHandler(
RootsListChangedNotificationSchema,
async () => {
await refreshRoots(server.server);
},
);
}
// Create session-local cache (isolated per server instance)
const { readPdfRange } = createPdfCache();
// Tool: list_pdfs - List available PDFs
server.tool(
"list_pdfs",
"List available PDFs that can be displayed",
{},
async (): Promise<CallToolResult> => {
const seen = new Set<string>();
const localFiles: string[] = [];
const addLocal = (filePath: string) => {
const url = pathToFileUrl(filePath);
if (seen.has(url)) return;
seen.add(url);
localFiles.push(url);
};
// Explicitly registered files (CLI args + file roots)
for (const filePath of allowedLocalFiles) addLocal(filePath);
// Walk directory roots for *.pdf files
const WALK_MAX_DEPTH = 8;
const WALK_MAX_FILES = 500;
let truncated = false;
const walk = async (dir: string, depth: number): Promise<void> => {
if (depth > WALK_MAX_DEPTH || localFiles.length >= WALK_MAX_FILES) {
truncated ||= localFiles.length >= WALK_MAX_FILES;
return;
}
let entries;
try {
entries = await fs.promises.readdir(dir, { withFileTypes: true });
} catch {
return; // unreadable — skip silently
}
for (const e of entries) {
if (localFiles.length >= WALK_MAX_FILES) {
truncated = true;
return;
}
// Skip dotfiles/dirs and common noise
if (e.name.startsWith(".") || e.name === "node_modules") continue;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(full, depth + 1);
} else if (e.isFile() && /\.pdf$/i.test(e.name)) {
addLocal(full);
}
}
};
for (const dir of allowedLocalDirs) await walk(dir, 0);
// Build text
const parts: string[] = [];
if (localFiles.length > 0) {
const header = truncated
? `Available PDFs (showing first ${WALK_MAX_FILES}):`
: `Available PDFs:`;
parts.push(`${header}\n${localFiles.map((u) => `- ${u}`).join("\n")}`);
}
if (allowedLocalDirs.size > 0) {
parts.push(
`Allowed local directories:\n${[...allowedLocalDirs].map((d) => `- ${d}`).join("\n")}\nAny PDF file under these directories can be displayed.`,
);
}
parts.push(
`Any remote PDF accessible via HTTPS can also be loaded dynamically.`,
);
return {
content: [{ type: "text", text: parts.join("\n\n") }],
structuredContent: {
localFiles,
allowedDirectories: [...allowedLocalDirs],
truncated,
},
};
},
);
// Tool: read_pdf_bytes (app-only) - Range request for chunks
registerAppTool(
server,
"read_pdf_bytes",
{
title: "Read PDF Bytes",
description: "Read a range of bytes from a PDF (max 512KB per request)",
inputSchema: {
url: z.string().describe("PDF URL or local file path"),
offset: z.number().min(0).default(0).describe("Byte offset"),
byteCount: z
.number()
.min(1)
.max(MAX_CHUNK_BYTES)
.default(MAX_CHUNK_BYTES)
.describe("Bytes to read"),
},
outputSchema: z.object({
url: z.string(),
bytes: z.string().describe("Base64 encoded bytes"),
offset: z.number(),
byteCount: z.number(),
totalBytes: z.number(),
hasMore: z.boolean(),
}),
_meta: { ui: { visibility: ["app"] } },
},
async ({ url, offset, byteCount }): Promise<CallToolResult> => {
const validation = validateUrl(url);
if (!validation.valid) {
return {
content: [{ type: "text", text: validation.error! }],
isError: true,
};
}
try {
const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
const { data, totalBytes } = await readPdfRange(
normalized,
offset,
byteCount,
);
// Base64 encode for JSON transport
const bytes = Buffer.from(data).toString("base64");
const hasMore = offset + data.length < totalBytes;
return {
content: [
{
type: "text",
text: `${data.length} bytes at ${offset}/${totalBytes}`,
},
],
structuredContent: {
url: normalized,
bytes,
offset,
byteCount: data.length,
totalBytes,
hasMore,
},
};
} catch (err) {
return {
content: [
{
type: "text",
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
// Tool: display_pdf - Show interactive viewer
registerAppTool(
server,
"display_pdf",
{
title: "Display PDF",
description: disableInteract
? `Show and render a PDF in a read-only viewer.
Use this tool when the user wants to view or read a PDF. The renderer displays the document for viewing.
Accepts local files (use list_pdfs), client MCP root directories, or any HTTPS URL.`
: `Open a PDF in an interactive viewer. Call this ONCE per PDF.
**All follow-up actions go through the \`interact\` tool** with the returned viewUUID — annotating, signing, stamping, filling forms, navigating, searching, extracting text/screenshots. Calling display_pdf again creates a SEPARATE viewer with a different viewUUID — interact calls using the new UUID will not reach the viewer the user already sees.
Returns a viewUUID in structuredContent. Pass it to \`interact\`:
- add_annotations, update_annotations, remove_annotations, highlight_text
- fill_form (fill PDF form fields)
- navigate, search, find, search_navigate, zoom
- get_text, get_screenshot (extract content)
Accepts local files (use list_pdfs), client MCP root directories, or any HTTPS URL.
Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before display.`,
inputSchema: {
url: z
.string()
.default(DEFAULT_PDF)
.describe("PDF URL or local file path"),
page: z.number().min(1).default(1).describe("Initial page"),
...(disableInteract
? {}
: {
elicit_form_inputs: z
.boolean()
.default(false)
.describe(
"If true and the PDF has form fields, prompt the user to fill them before displaying",
),
}),
},
outputSchema: z.object({
viewUUID: z
.string()
.describe(
"UUID for this viewer instance" +
(disableInteract ? "" : " — pass to interact tool"),
),
url: z.string(),
initialPage: z.number(),
totalBytes: z.number(),
formFieldValues: z
.record(z.string(), z.union([z.string(), z.boolean()]))
.optional()
.describe("Form field values filled by the user via elicitation"),
formFields: z
.array(
z.object({
name: z.string(),
type: z.string(),
page: z.number(),
label: z.string().optional(),
x: z.number(),
y: z.number(),
width: z.number(),
height: z.number(),
}),
)
.optional()
.describe(
"Form fields with bounding boxes in model coordinates (top-left origin)",
),
}),
_meta: { ui: { resourceUri: RESOURCE_URI } },
},
async ({ url, page, elicit_form_inputs }): Promise<CallToolResult> => {
const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
const validation = validateUrl(normalized);
if (!validation.valid) {
return {
content: [{ type: "text", text: validation.error! }],
isError: true,
};
}
// Probe file size so the client can set up range transport without an extra fetch
const { totalBytes } = await readPdfRange(normalized, 0, 1);
const uuid = randomUUID();
// Start the heartbeat now so the sweep can clean up viewFieldNames/
// viewFieldInfo/viewFileWatches even if no interact calls ever happen.
if (!disableInteract) touchView(uuid);
// Check writability (governs save button; see isWritablePath doc).
// Also requires OS-level W_OK so we don't lie on read-only mounts.
let writable = false;
let debugResolved: string | undefined; // only used when --debug
if (isFileUrl(normalized) || isLocalPath(normalized)) {
const localPath = isFileUrl(normalized)
? fileUrlToPath(normalized)
: decodeURIComponent(normalized);
const resolved = path.resolve(localPath);
debugResolved = resolved;
if (isWritablePath(resolved)) {
try {
await fs.promises.access(resolved, fs.constants.W_OK);
writable = true;
} catch {
// Not writable — leave false
}
}
// Watch for external changes (stdio only — needs the poll channel)
if (!disableInteract) {
startFileWatch(uuid, localPath);
}
}
// Extract form field schema (used for elicitation and field name validation)
let formSchema: Awaited<ReturnType<typeof extractFormSchema>> = null;
try {
formSchema = await extractFormSchema(normalized, readPdfRange);
} catch {
// Non-fatal — PDF may not have form fields
}
if (formSchema) {
viewFieldNames.set(uuid, new Set(Object.keys(formSchema.properties)));
}
// Extract detailed form field info (page, bounding box, label)
let fieldInfo: FormFieldInfo[] = [];
try {
fieldInfo = await extractFormFieldInfo(normalized, readPdfRange);
if (fieldInfo.length > 0) {
viewFieldInfo.set(uuid, fieldInfo);
// Also populate viewFieldNames from field info if not already set
if (!viewFieldNames.has(uuid)) {
viewFieldNames.set(
uuid,
new Set(fieldInfo.map((f) => f.name).filter(Boolean)),
);
}
}
} catch {
// Non-fatal
}
// Elicit form field values if requested and client supports it
let formFieldValues: Record<string, string | boolean> | undefined;
let elicitResult: ElicitResult | undefined;
if (elicit_form_inputs && formSchema) {
const clientCaps = server.server.getClientCapabilities();
if (clientCaps?.elicitation?.form) {
try {
elicitResult = await server.server.elicitInput({
message: `Please fill in the PDF form fields for "${normalized.split("/").pop() || normalized}":`,
requestedSchema: formSchema,
});
if (elicitResult.action === "accept" && elicitResult.content) {
formFieldValues = {};
for (const [k, v] of Object.entries(elicitResult.content)) {
if (typeof v === "string" || typeof v === "boolean") {
formFieldValues[k] = v;
}
}
// Queue fill_form command so the viewer picks it up
enqueueCommand(uuid, {
type: "fill_form",
fields: Object.entries(formFieldValues).map(
([name, value]) => ({ name, value }),
),
});
}
} catch (err) {
// Elicitation failed — continue without form values
console.error("[pdf-server] Form elicitation failed:", err);
}
}
}
const contentParts: Array<{ type: "text"; text: string }> = [
{
type: "text",
text: disableInteract
? `Displaying PDF: ${normalized}`
: `PDF opened. viewUUID: ${uuid}
→ To annotate, sign, stamp, fill forms, navigate, or extract: call \`interact\` with this viewUUID.
→ DO NOT call display_pdf again — that spawns a separate viewer with a different viewUUID; your interact calls would target the new empty one, not the one the user is looking at.
URL: ${normalized}`,
},
];
if (formFieldValues && Object.keys(formFieldValues).length > 0) {
const fieldSummary = Object.entries(formFieldValues)
.map(
([name, value]) =>
` ${name}: ${typeof value === "boolean" ? (value ? "checked" : "unchecked") : value}`,
)
.join("\n");
contentParts.push({
type: "text",
text: `\nUser-provided form field values:\n${fieldSummary}`,
});
} else if (
elicit_form_inputs &&
elicitResult &&
elicitResult.action !== "accept"
) {
contentParts.push({
type: "text",
text: `\nForm elicitation was ${elicitResult.action}d by the user.`,
});
}
// Include detailed form field info so the model can locate and fill fields
if (fieldInfo.length > 0) {
// Group by page
const byPage = new Map<number, FormFieldInfo[]>();
for (const f of fieldInfo) {
let list = byPage.get(f.page);
if (!list) {
list = [];
byPage.set(f.page, list);
}
list.push(f);
}
const lines: string[] = [
`\nForm fields (${fieldInfo.length})${disableInteract ? "" : " — use fill_form with {name, value}"}:`,
];
for (const [pg, fields] of [...byPage.entries()].sort(
(a, b) => a[0] - b[0],
)) {
lines.push(` Page ${pg}:`);
for (const f of fields) {
const label = f.label ? ` "${f.label}"` : "";
const nameStr = f.name || "(unnamed)";
lines.push(
` ${nameStr}${label} [${f.type}] at (${f.x},${f.y}) ${f.width}×${f.height}`,
);
}
}
contentParts.push({ type: "text", text: lines.join("\n") });
} else {
// Fallback to simple field name listing if detailed info unavailable
const fieldNames = viewFieldNames.get(uuid);
if (fieldNames && fieldNames.size > 0) {
contentParts.push({
type: "text",
text: `\nForm fields${disableInteract ? "" : " available for fill_form"}: ${[...fieldNames].join(", ")}`,
});
}
}
return {
content: contentParts,
structuredContent: {
viewUUID: uuid,
url: normalized,
initialPage: page,
totalBytes,
...(formFieldValues ? { formFieldValues } : {}),
...(fieldInfo.length > 0 ? { formFields: fieldInfo } : {}),
},
_meta: {
viewUUID: uuid,
interactEnabled: !disableInteract,
writable,
// Debug: viewer renders this in a floating bubble (--debug flag).
...(debug
? {
_debug: {
resolved: debugResolved,
writable,
isWritablePath: debugResolved
? isWritablePath(debugResolved)
: undefined,
cliLocalFiles: [...cliLocalFiles],
allowedLocalFiles: [...allowedLocalFiles],
allowedLocalDirs: [...allowedLocalDirs],
},
}
: {}),
},
};
},
);
if (!disableInteract) {
// Schema for a single interact command (used in commands array)
const InteractCommandSchema = z.object({
action: z
.enum([
"navigate",
"search",
"find",
"search_navigate",
"zoom",
"add_annotations",
"update_annotations",
"remove_annotations",
"highlight_text",
"fill_form",
"get_text",
"get_screenshot",
])
.describe("Action to perform"),
page: z
.number()
.min(1)
.optional()
.describe(
"Page number (for navigate, highlight_text, get_screenshot, get_text)",
),
query: z
.string()
.optional()
.describe("Search text (for search / find / highlight_text)"),
matchIndex: z
.number()
.min(0)
.optional()
.describe("Match index (for search_navigate)"),
scale: z
.number()
.min(0.5)
.max(3.0)
.optional()
.describe("Zoom scale, 1.0 = 100% (for zoom)"),
annotations: z
.array(z.record(z.string(), z.any()))
.optional()
.describe(
"Annotation objects (see types in description). Each needs: id, type, page. For update_annotations only id+type are required.",
),
ids: z
.array(z.string())
.optional()
.describe("Annotation IDs (for remove_annotations)"),
color: z
.string()
.optional()
.describe("Color override (for highlight_text)"),
content: z
.string()
.optional()
.describe("Tooltip/note content (for highlight_text)"),
fields: z
.array(FormField)
.optional()
.describe(
"Form fields to fill (for fill_form): { name, value } where value is string or boolean",
),
intervals: z
.array(PageInterval)
.optional()
.describe(
"Page ranges for get_text. Each has optional start/end. [{start:1,end:5}], [{}] = all pages. Max 20 pages.",
),
});
type InteractCommand = z.infer<typeof InteractCommandSchema>;
type ContentPart =
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string };
/**
* Resolve an image annotation: fetch imageUrl → imageData if needed,
* auto-detect dimensions, and set defaults for x/y.
*
* SECURITY: imageUrl is model-controlled. It must pass the same
* validateUrl() gate as display_pdf/save_pdf — otherwise the model
* can request `{imageUrl:"/Users/x/.ssh/id_rsa"}`, we'd readFile it,
* base64 the bytes, ship them to the iframe, and get_screenshot (or
* any future echo path) reads them back. Throws on rejection so the
* tool result carries the error; silent skip hides the attack attempt.
*/
async function resolveImageAnnotation(
ann: Record<string, any>,
): Promise<void> {
// Fetch image data from URL if no imageData provided
if (!ann.imageData && ann.imageUrl) {
const url = String(ann.imageUrl);
// Same gate as every other local/remote read in this server.
// Local: must be in allowedLocalFiles or under allowedLocalDirs.
// Remote: must be https://.
const check = validateUrl(url);
if (!check.valid) {
throw new Error(
`imageUrl rejected by validateUrl: ${check.error ?? url}`,
);
}
let imgBytes: Uint8Array;
if (url.startsWith("https://")) {
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${url}`);
imgBytes = new Uint8Array(await resp.arrayBuffer());
} else {
// validateUrl already confirmed this path is under an allowed root.
const filePath = isFileUrl(url)
? fileUrlToPath(url)
: decodeURIComponent(url);
imgBytes = await fs.promises.readFile(path.resolve(filePath));
}
ann.imageData = Buffer.from(imgBytes).toString("base64");
}
// Auto-detect mimeType from magic bytes if not set
if (ann.imageData && !ann.mimeType) {
const bytes = Buffer.from(ann.imageData, "base64");
if (
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47
) {
ann.mimeType = "image/png";
} else {
ann.mimeType = "image/jpeg";
}
}
// Auto-detect dimensions from image if not specified
if (ann.imageData && (ann.width == null || ann.height == null)) {
const dims = detectImageDimensions(
Buffer.from(ann.imageData, "base64"),
);
if (dims) {
const maxWidth = 200; // default max width in PDF points
const aspectRatio = dims.height / dims.width;
ann.width = ann.width ?? Math.min(dims.width, maxWidth);
ann.height = ann.height ?? ann.width * aspectRatio;
} else {
ann.width = ann.width ?? 200;
ann.height = ann.height ?? 200;
}
}
// Default position if not specified
ann.x = ann.x ?? 72;
ann.y = ann.y ?? 72;
}
/**
* Detect image dimensions from PNG or JPEG bytes.
*/
function detectImageDimensions(
bytes: Buffer,
): { width: number; height: number } | null {
// PNG: width at offset 16 (4 bytes BE), height at offset 20 (4 bytes BE)
if (
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47
) {
if (bytes.length >= 24) {
const width = bytes.readUInt32BE(16);
const height = bytes.readUInt32BE(20);
return { width, height };
}
}
// JPEG: scan for SOF0/SOF2 markers (0xFF 0xC0 / 0xFF 0xC2)
if (bytes[0] === 0xff && bytes[1] === 0xd8) {
let offset = 2;
while (offset < bytes.length - 8) {
if (bytes[offset] !== 0xff) break;
const marker = bytes[offset + 1];
if (marker === 0xc0 || marker === 0xc2) {
const height = bytes.readUInt16BE(offset + 5);
const width = bytes.readUInt16BE(offset + 7);
return { width, height };
}
const segLen = bytes.readUInt16BE(offset + 2);
offset += 2 + segLen;
}
}
return null;
}
/** Process a single interact command. Returns content parts and an isError flag. */
async function processInteractCommand(
uuid: string,
cmd: InteractCommand,
signal?: AbortSignal,
): Promise<{ content: ContentPart[]; isError?: boolean }> {
const {
action,
page,
query,
matchIndex,
scale,
annotations,
ids,
color,
content,
fields,
intervals,
} = cmd;
let description: string;
switch (action) {
case "navigate":
if (page == null)
return {
content: [{ type: "text", text: "navigate requires `page`" }],
isError: true,
};
enqueueCommand(uuid, { type: "navigate", page });
description = `navigate to page ${page}`;
break;
case "search":
if (!query)
return {
content: [{ type: "text", text: "search requires `query`" }],
isError: true,
};
enqueueCommand(uuid, { type: "search", query });
description = `search for "${query}"`;
break;
case "find":
if (!query)
return {
content: [{ type: "text", text: "find requires `query`" }],
isError: true,
};
enqueueCommand(uuid, { type: "find", query });
description = `find "${query}" (silent)`;
break;
case "search_navigate":
if (matchIndex == null)
return {
content: [
{
type: "text",
text: "search_navigate requires `matchIndex`",
},
],
isError: true,
};
enqueueCommand(uuid, { type: "search_navigate", matchIndex });
description = `go to match #${matchIndex}`;
break;
case "zoom":
if (scale == null)
return {
content: [{ type: "text", text: "zoom requires `scale`" }],
isError: true,
};
enqueueCommand(uuid, { type: "zoom", scale });
description = `zoom to ${Math.round(scale * 100)}%`;
break;
case "add_annotations":
if (!annotations || annotations.length === 0)
return {
content: [
{
type: "text",
text: "add_annotations requires `annotations` array",
},
],
isError: true,
};
// Resolve image annotations: fetch imageUrl → imageData, auto-detect dimensions.
// Rejection (path not allowed, not https, fetch failed) surfaces as
// a tool error so the model sees it — don't silently skip.
try {
for (const ann of annotations) {
if ((ann as any).type === "image") {
await resolveImageAnnotation(ann as any);
}
}
} catch (err) {
return {
content: [
{
type: "text",
text: `add_annotations: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
enqueueCommand(uuid, {
type: "add_annotations",
// resolveImageAnnotation populates optional x/y/width/height;
// input is validated as Record<string,any>[] so this cast is
// the wire-protocol promise, not a compiler guarantee.
annotations: annotations as Extract<
PdfCommand,
{ type: "add_annotations" }
>["annotations"],
});
description = `add ${annotations.length} annotation(s)`;
break;
case "update_annotations":
if (!annotations || annotations.length === 0)
return {
content: [
{
type: "text",
text: "update_annotations requires `annotations` array",
},
],
isError: true,
};
enqueueCommand(uuid, {
type: "update_annotations",
annotations: annotations as Extract<
PdfCommand,
{ type: "update_annotations" }
>["annotations"],
});
description = `update ${annotations.length} annotation(s)`;
break;
case "remove_annotations":
if (!ids || ids.length === 0)
return {
content: [
{
type: "text",
text: "remove_annotations requires `ids` array",
},
],
isError: true,
};
enqueueCommand(uuid, { type: "remove_annotations", ids });
description = `remove ${ids.length} annotation(s)`;
break;
case "highlight_text": {
if (!query)
return {
content: [
{ type: "text", text: "highlight_text requires `query`" },
],
isError: true,
};
const id = `ht_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
enqueueCommand(uuid, {
type: "highlight_text",
id,
query,
page,
color,
content,
});
description = `highlight text "${query}"${page ? ` on page ${page}` : ""}`;
break;
}
case "fill_form": {
if (!fields || fields.length === 0)
return {
content: [
{ type: "text", text: "fill_form requires `fields` array" },
],
isError: true,
};
const knownFields = viewFieldNames.get(uuid);
const validFields: typeof fields = [];
const unknownNames: string[] = [];
for (const f of fields) {
if (knownFields && !knownFields.has(f.name)) {
unknownNames.push(f.name);
} else {
validFields.push(f);
}
}
if (validFields.length > 0) {
enqueueCommand(uuid, { type: "fill_form", fields: validFields });
}
const parts: string[] = [];
if (validFields.length > 0) {
parts.push(
`Filled ${validFields.length} field(s): ${validFields.map((f) => f.name).join(", ")}`,
);
}
if (unknownNames.length > 0) {
parts.push(`Unknown field(s) skipped: ${unknownNames.join(", ")}`);
// Only list valid names when the model got something wrong —
// display_pdf already returned the full field info on open.
if (knownFields && knownFields.size > 0) {
parts.push(`Valid field names: ${[...knownFields].join(", ")}`);
}
}
description = parts.join(". ");
if (unknownNames.length > 0 && validFields.length === 0) {
return {
content: [{ type: "text", text: description }],
isError: true,
};
}
break;
}
case "get_text": {
const resolvedIntervals =
intervals ?? (page ? [{ start: page, end: page }] : [{}]);
const requestId = randomUUID();
enqueueCommand(uuid, {
type: "get_pages",
requestId,
intervals: resolvedIntervals,
getText: true,
getScreenshots: false,
});
let pageData: PageDataEntry[];
try {
pageData = await waitForPageData(requestId, signal);
} catch (err) {
return {
content: [
{
type: "text",
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
const textParts: ContentPart[] = [];
for (const entry of pageData) {
if (entry.text != null) {
textParts.push({
type: "text",
text: `--- Page ${entry.page} ---\n${entry.text}`,
});
}
}
if (textParts.length === 0) {
textParts.push({ type: "text", text: "No text content returned" });
}
return { content: textParts };
}
case "get_screenshot": {
if (page == null)
return {
content: [
{ type: "text", text: "get_screenshot requires `page`" },
],
isError: true,
};
const requestId = randomUUID();
enqueueCommand(uuid, {
type: "get_pages",
requestId,
intervals: [{ start: page, end: page }],
getText: false,
getScreenshots: true,
});
let pageData: PageDataEntry[];
try {
pageData = await waitForPageData(requestId, signal);
} catch (err) {
return {
content: [
{
type: "text",
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
const entry = pageData[0];
if (entry?.image) {
return {
content: [
{
type: "image",
data: entry.image,
mimeType: "image/jpeg",
},
],
};
}
return {
content: [{ type: "text", text: "No screenshot returned" }],
isError: true,
};
}
default:
return {
content: [{ type: "text", text: `Unknown action: ${action}` }],
isError: true,
};
}
return {
content: [{ type: "text", text: `Queued: ${description}` }],
};
}
// Tool: interact - Interact with an existing PDF viewer
server.registerTool(
"interact",
{
title: "Interact with PDF",
description: `Interact with a PDF viewer: annotate, navigate, search, extract text/screenshots, fill forms.
IMPORTANT: viewUUID must be the exact UUID returned by display_pdf (e.g. "a1b2c3d4-..."). Do NOT use arbitrary strings.
**BATCHING**: Send multiple commands in one call via \`commands\` array. Commands run sequentially. TIP: End with \`get_screenshot\` to verify your changes.
**ANNOTATION** — add_annotations with array of annotation objects. Each needs: id (unique string), type, page (1-indexed).
**COORDINATE SYSTEM**: PDF points (1pt = 1/72in), origin at page TOP-LEFT corner. X increases rightward, Y increases downward.
- US Letter = 612×792pt. Margins: top≈y=50, bottom≈y=742, left≈x=72, right≈x=540, center≈(306, 396).
- Rectangle/circle/stamp x,y is the TOP-LEFT corner. To place a 200×30 box at the TOP of the page: x=72, y=50, width=200, height=30.
- For highlights/underlines, each rect's y is the TOP of the highlighted region.
Annotation types:
• highlight: rects:[{x,y,width,height}], color?, content? • underline: rects:[{x,y,w,h}], color?
• strikethrough: rects:[{x,y,w,h}], color? • note: x, y, content, color?
• rectangle: x, y, width, height, color?, fillColor?, rotation? • circle: x, y, width, height, color?, fillColor?
• line: x1, y1, x2, y2, color? • freetext: x, y, content, fontSize?, color?
• stamp: x, y, label (any text, e.g. APPROVED, DRAFT, CONFIDENTIAL), color?, rotation?
• image: imageUrl (required), x?, y?, width?, height?, mimeType?, rotation?, aspect? — places an image (signature, logo, etc.) on the page. Pass a local file path or HTTPS URL (NO data: URIs, NO base64). Width/height auto-detected if omitted. Users can also drag & drop images directly onto the viewer.
TIP: For text annotations, prefer highlight_text (auto-finds text) over manual rects.
Example — add a signature image and a stamp, then screenshot to verify:
\`\`\`json
{"viewUUID":"…","commands":[
{"action":"add_annotations","annotations":[
{"id":"sig1","type":"image","page":1,"x":72,"y":700,"imageUrl":"/path/to/signature.png"},
{"id":"s1","type":"stamp","page":1,"x":300,"y":400,"label":"APPROVED"}
]},
{"action":"get_screenshot","page":1}
]}
\`\`\`
• highlight_text: auto-find and highlight text (query, page?, color?, content?)
• update_annotations: partial update (id+type required) • remove_annotations: remove by ids
**NAVIGATION**: navigate (page), search (query), find (query, silent), search_navigate (matchIndex), zoom (scale 0.5–3.0)
**TEXT/SCREENSHOTS**:
• get_text: extract text from pages. Optional \`page\` for single page, or \`intervals\` for ranges [{start?,end?}]. Max 20 pages.
• get_screenshot: capture a single page as PNG image. Requires \`page\`.
**FORMS** — fill_form: fill fields with \`fields\` array of {name, value}.`,
inputSchema: {
viewUUID: z
.string()
.describe(
"The viewUUID of the PDF viewer (from display_pdf result)",
),
// Single-command mode (backwards-compatible)
action: z
.enum([
"navigate",
"search",
"find",
"search_navigate",
"zoom",
"add_annotations",
"update_annotations",
"remove_annotations",
"highlight_text",
"fill_form",
"get_text",
"get_screenshot",
])
.optional()
.describe(
"Action to perform (for single command). Use `commands` array for batching.",
),
page: z
.number()
.min(1)
.optional()
.describe(
"Page number (for navigate, highlight_text, get_screenshot, get_text)",
),
query: z
.string()
.optional()
.describe("Search text (for search / find / highlight_text)"),
matchIndex: z
.number()
.min(0)
.optional()
.describe("Match index (for search_navigate)"),
scale: z
.number()
.min(0.5)
.max(3.0)
.optional()
.describe("Zoom scale, 1.0 = 100% (for zoom)"),
annotations: z
.array(z.record(z.string(), z.any()))
.optional()
.describe(
"Annotation objects (see types in description). Each needs: id, type, page. For update_annotations only id+type are required.",
),
ids: z
.array(z.string())
.optional()
.describe("Annotation IDs (for remove_annotations)"),
color: z
.string()
.optional()
.describe("Color override (for highlight_text)"),
content: z
.string()
.optional()
.describe("Tooltip/note content (for highlight_text)"),
fields: z
.array(FormField)
.optional()
.describe(
"Form fields to fill (for fill_form): { name, value } where value is string or boolean",
),
intervals: z
.array(PageInterval)
.optional()
.describe(
"Page ranges for get_text. Each has optional start/end. [{start:1,end:5}], [{}] = all pages. Max 20 pages.",
),
// Batch mode
commands: z
.array(InteractCommandSchema)
.optional()
.describe(
"Array of commands to execute sequentially. More efficient than separate calls. Tip: end with get_pages+getScreenshots to verify changes.",
),
},
},
async (
{
viewUUID: uuid,
action,
page,
query,
matchIndex,
scale,
annotations,
ids,
color,
content,
fields,
intervals,
commands,
},
extra,
): Promise<CallToolResult> => {
// Build the list of commands to process
const commandList: InteractCommand[] = commands
? commands
: action
? [
{
action,
page,
query,
matchIndex,
scale,
annotations,
ids,
color,
content,
fields,
intervals,
},
]
: [];
if (commandList.length === 0) {
return {
content: [
{
type: "text",
text: "No action or commands specified. Provide either `action` (single command) or `commands` (batch).",
},
],
isError: true,
};
}
// Process commands sequentially, collecting all content parts
const allContent: ContentPart[] = [];
let hasError = false;
for (let i = 0; i < commandList.length; i++) {
const result = await processInteractCommand(
uuid,
commandList[i],
extra.signal,
);
if (result.isError) {
hasError = true;
}
allContent.push(...result.content);
if (hasError) break; // Stop on first error
}
return {
content: allContent,
...(hasError ? { isError: true } : {}),
};
},
);
// Tool: submit_page_data (app-only) - Client submits rendered page data
registerAppTool(
server,
"submit_page_data",
{
title: "Submit Page Data",
description:
"Submit rendered page data for a get_pages request (used by viewer)",
inputSchema: {
requestId: z
.string()
.describe("The request ID from the get_pages command"),
pages: z
.array(
z.object({
page: z.number(),
text: z.string().optional(),
image: z.string().optional().describe("Base64 PNG image data"),
}),
)
.describe("Page data entries"),
},
_meta: { ui: { visibility: ["app"] } },
},
async ({ requestId, pages }): Promise<CallToolResult> => {
const settle = pendingPageRequests.get(requestId);
if (settle) {
settle(pages);
return {
content: [
{ type: "text", text: `Submitted ${pages.length} page(s)` },
],
};
}
return {
content: [
{ type: "text", text: `No pending request for ${requestId}` },
],
isError: true,
};
},
);
// Tool: poll_pdf_commands (app-only) - Poll for pending commands
registerAppTool(
server,
"poll_pdf_commands",
{
title: "Poll PDF Commands",
description: "Poll for pending commands for a PDF viewer",
inputSchema: {
viewUUID: z.string().describe("The viewUUID of the PDF viewer"),
},
_meta: { ui: { visibility: ["app"] } },
},
async ({ viewUUID: uuid }): Promise<CallToolResult> => {
// If commands are already queued, wait briefly to let more accumulate
if (commandQueues.has(uuid)) {
await new Promise((r) => setTimeout(r, POLL_BATCH_WAIT_MS));
} else {
// Long-poll: wait for commands to arrive or timeout
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
pollWaiters.delete(uuid);
resolve();
}, LONG_POLL_TIMEOUT_MS);
// Cancel any existing waiter for this uuid
const prev = pollWaiters.get(uuid);
if (prev) prev();
pollWaiters.set(uuid, () => {
clearTimeout(timer);
resolve();
});
});
// After waking, wait briefly for batching
if (commandQueues.has(uuid)) {
await new Promise((r) => setTimeout(r, POLL_BATCH_WAIT_MS));
}
}
const commands = dequeueCommands(uuid);
return {
content: [{ type: "text", text: `${commands.length} command(s)` }],
structuredContent: { commands },
};
},
);
} // end if (!disableInteract)
// Tool: save_pdf (app-only) - Save annotated PDF back to local file
registerAppTool(
server,
"save_pdf",
{
title: "Save PDF",
description: "Save annotated PDF bytes back to a local file",
inputSchema: {
url: z.string().describe("Original PDF URL or local file path"),
data: z.string().describe("Base64-encoded PDF bytes"),
},
outputSchema: z.object({
filePath: z.string(),
mtimeMs: z.number(),
}),
_meta: { ui: { visibility: ["app"] } },
},
async ({ url, data }): Promise<CallToolResult> => {
const validation = validateUrl(url);
if (!validation.valid) {
return {
content: [{ type: "text", text: validation.error! }],
isError: true,
};
}
const filePath = isFileUrl(url)
? fileUrlToPath(url)
: isLocalPath(url)
? decodeURIComponent(url)
: null;
if (!filePath) {
return {
content: [
{ type: "text", text: "Save is only supported for local files" },
],
isError: true,
};
}
const resolved = path.resolve(filePath);
// Enforce the same write scope the display_pdf writable flag uses.
// The viewer hides the save button for non-writable files, but we
// must not trust the client: a direct save_pdf call should also refuse.
if (!isWritablePath(resolved)) {
return {
content: [
{
type: "text",
text:
"Save refused: file is not under a mounted directory root " +
"and was not passed as a CLI argument. MCP file roots are " +
"read-only (typically uploaded copies the client doesn't " +
"expect to change).",
},
],
isError: true,
};
}
try {
const bytes = Buffer.from(data, "base64");
await fs.promises.writeFile(resolved, bytes);
const { mtimeMs } = await fs.promises.stat(resolved);
// Don't suppress file_changed here — the saving viewer will recognise
// its own mtime, while other viewers on the same file correctly get
// notified that their content is stale.
return {
content: [{ type: "text", text: `Saved to ${filePath}` }],
structuredContent: { filePath: resolved, mtimeMs },
};
} catch (err) {
return {
content: [
{
type: "text",
text: `Failed to save: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
},
);
// Resource: UI HTML
registerAppResource(
server,
RESOURCE_URI,
RESOURCE_URI,
{ mimeType: RESOURCE_MIME_TYPE },
async (): Promise<ReadResourceResult> => {
const html = await fs.promises.readFile(
path.join(DIST_DIR, "mcp-app.html"),
"utf-8",
);
return {
contents: [
{
uri: RESOURCE_URI,
mimeType: RESOURCE_MIME_TYPE,
text: html,
_meta: {
ui: {
permissions: { clipboardWrite: {} },
csp: {
// pdf.js loads the Standard-14 fonts TWO ways:
// - fetch()s the .ttf bytes → connect-src
// - creates FontFace('name', 'url(...)') → font-src
// resourceDomains maps to font-src; we need both.
connectDomains: [STANDARD_FONT_ORIGIN],
resourceDomains: [STANDARD_FONT_ORIGIN],
},
},
},
},
],
};
},
);
return server;
}