ext-apps/examples/pdf-server/server.ts
Olivier Chafik ba31be1f91
perf(pdf-server): lazy form extraction via range transport + incremental viewer scans (#639)
* perf(pdf-server): lazy form extraction via range transport + incremental viewer scans

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(pdf-server): link tombstone fixme to tracking issue #642
2026-04-27 15:32:53 +01:00

2943 lines
102 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";
// Stub DOMMatrix/ImageData/Path2D before pdfjs-dist loads — its legacy
// build instantiates DOMMatrix at module scope and the @napi-rs/canvas
// polyfill is unreliable under npx. See ./pdfjs-polyfill.ts for details.
import "./pdfjs-polyfill.js";
import {
getDocument,
PDFDataRangeTransport,
VerbosityLevel,
version as PDFJS_VERSION,
} from "pdfjs-dist/legacy/build/pdf.mjs";
import type { PDFDocumentProxy } from "pdfjs-dist/types/src/display/api.js";
/**
* 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
/** Max total bytes across all cache entries; oldest evicted first when exceeded. */
export const CACHE_MAX_TOTAL_BYTES = 256 * 1024 * 1024; // 256MB
/** 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;
/**
* Grace period for the viewer's first poll. If interact() arrives before the
* iframe has ever polled, we wait this long for it to show up (iframe mount +
* PDF load + startPolling). If no poll comes, the viewer almost certainly
* never rendered — failing fast beats a silent 45s hang.
*/
const VIEWER_FIRST_POLL_GRACE_MS = 8_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);
});
}
/**
* Wait for the viewer's first poll_pdf_commands call.
*
* Called before waitForPageData() / waitForSaveData() so a viewer that never
* mounted fails in ~8s with a specific message instead of a generic 45s
* "Timeout waiting for ..." that gives no hint why.
*
* Intentionally does NOT touch pollWaiters: piggybacking on that single-slot
* Map races with poll_pdf_commands' batch-wait branch (which never cancels the
* prior waiter) and with concurrent interact calls (which would overwrite each
* other). A plain check loop on viewsPolled is stateless — multiple callers
* can wait independently and all observe the same add() when it happens.
*/
async function ensureViewerIsPolling(uuid: string): Promise<void> {
const deadline = Date.now() + VIEWER_FIRST_POLL_GRACE_MS;
while (!viewsPolled.has(uuid)) {
if (Date.now() >= deadline) {
throw new Error(
`Viewer never connected for viewUUID ${uuid} (no poll within ${VIEWER_FIRST_POLL_GRACE_MS / 1000}s). ` +
`The iframe likely failed to mount — this happens when the conversation ` +
`goes idle before the viewer finishes loading. Call display_pdf again to get a fresh viewUUID.`,
);
}
await new Promise((r) => setTimeout(r, 100));
}
}
// =============================================================================
// Pending save_as Requests (request-response bridge via client)
// =============================================================================
//
// Same shape as get_pages: model's interact call blocks while the viewer
// builds annotated bytes and posts them back. Reuses GET_PAGES_TIMEOUT_MS
// (45s) — generous because pdf-lib reflow on a large doc can take seconds.
const pendingSaveRequests = new Map<string, (v: string | Error) => void>();
/**
* Wait for the viewer to build annotated PDF bytes and submit them as base64.
* Rejects on timeout, abort, or when the viewer reports an error.
*/
function waitForSaveData(
requestId: string,
signal?: AbortSignal,
): Promise<string> {
return new Promise<string>((resolve, reject) => {
const settle = (v: string | Error) => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
pendingSaveRequests.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 PDF bytes from viewer")),
GET_PAGES_TIMEOUT_MS,
);
signal?.addEventListener("abort", onAbort);
pendingSaveRequests.set(requestId, settle);
});
}
const pendingStateRequests = new Map<string, (v: string | Error) => void>();
/**
* Wait for the viewer to report its current state (page, zoom, selection, …)
* as a JSON string. Same timeout/abort semantics as waitForSaveData.
*/
function waitForViewerState(
requestId: string,
signal?: AbortSignal,
): Promise<string> {
return new Promise<string>((resolve, reject) => {
const settle = (v: string | Error) => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
pendingStateRequests.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 viewer state")),
GET_PAGES_TIMEOUT_MS,
);
signal?.addEventListener("abort", onAbort);
pendingStateRequests.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>();
/**
* viewUUIDs that have been polled at least once. A view missing from this set
* means the iframe never reached startPolling() — usually because it wasn't
* mounted yet, or ontoolresult threw before the poll loop started. Used to
* fail fast in get_screenshot/get_text instead of waiting the full 45s for
* a viewer that was never there.
*/
const viewsPolled = new Set<string>();
/**
* Resolved local file path per viewer UUID, for save_as without an explicit
* target. Only set for local files (remote PDFs have nothing to overwrite).
* Populated during display_pdf, cleared by the heartbeat sweep.
*
* Exported for tests.
*/
export const viewSourcePaths = new Map<string, string>();
/** Valid form field names per viewer UUID (populated during display_pdf) */
const viewFieldNames = new Map<string, Set<string>>();
/**
* 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/
* 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);
viewsPolled.delete(uuid);
viewSourcePaths.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: true };
// Loopback HTTP is opt-in (test fixtures, local dev). Off by default so a
// remotely-deployed server can't be made to probe its own internal ports.
if (
process.env.PDF_SERVER_ALLOW_LOOPBACK_HTTP &&
parsed.protocol === "http:" &&
(parsed.hostname === "127.0.0.1" ||
parsed.hostname === "localhost" ||
parsed.hostname === "[::1]")
) {
return { valid: true };
}
return { valid: false, error: `Only HTTPS URLs are allowed: ${url}` };
} 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(
maxTotalBytes: number = CACHE_MAX_TOTAL_BYTES,
): PdfCache {
const cache = new Map<string, CacheEntry>();
let totalBytes = 0;
/** 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);
totalBytes -= entry.data.length;
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);
// Move to end of insertion order so size-cap eviction is LRU.
cache.delete(url);
cache.set(url, entry);
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);
// Evict least-recently-used entries until under the byte cap.
for (const oldest of cache.keys()) {
if (totalBytes + data.length <= maxTotalBytes) break;
deleteCacheEntry(oldest);
}
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);
totalBytes += data.length;
}
/** 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);
}
},
};
}
/**
* pdf.js range transport backed by {@link PdfCache.readPdfRange}. Lets
* getDocument() fetch only the byte ranges it needs (xref, /AcroForm dict)
* instead of the whole file. With disableAutoFetch, a PDF without form
* fields is opened with ~5% of bytes fetched.
*
* pdf.js has no upstream error channel on PDFDataRangeTransport (its
* `abort()` is a no-op stub it calls *on* us, not the other way). Callers
* must `Promise.race` their pdf.js awaits against {@link failed}, which
* rejects on the first fetch error.
*/
export class PdfCacheRangeTransport extends PDFDataRangeTransport {
/** Rejects on the first range-fetch error; never resolves. */
readonly failed: Promise<never>;
private fail!: (e: unknown) => void;
constructor(
private url: string,
length: number,
private readPdfRange: PdfCache["readPdfRange"],
) {
super(length, null);
this.failed = new Promise<never>((_, reject) => {
this.fail = reject;
});
// Don't crash the process if no one is racing yet.
this.failed.catch(() => {});
}
override requestDataRange(begin: number, end: number): void {
void this.deliver(begin, end).catch((e) => this.fail(e));
}
/**
* pdf.js coalesces adjacent missing chunks into one unbounded request, but
* readPdfRange clamps each call to MAX_CHUNK_BYTES. Its reader is keyed by
* the original `begin` and removed after one delivery, so we must accumulate
* slices and call onDataRange exactly once with the full buffer.
*/
private async deliver(begin: number, end: number): Promise<void> {
const buf = new Uint8Array(end - begin);
let off = 0;
while (off < buf.length) {
const want = Math.min(buf.length - off, MAX_CHUNK_BYTES);
const { data } = await this.readPdfRange(this.url, begin + off, want);
if (data.length === 0) {
throw new Error(`empty range at ${begin + off} for ${this.url}`);
}
buf.set(data.subarray(0, Math.min(data.length, buf.length - off)), off);
off += data.length;
}
this.onDataRange(begin, buf);
}
}
// =============================================================================
// 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;
/** Radio button export value (buttonValue) — distinguishes widgets that share a field name. */
exportValue?: string;
/** Dropdown/listbox option values, as seen in the widget's `options` array. */
options?: string[];
}
/**
* Open `url` via {@link PdfCacheRangeTransport} and return form metadata.
* Uses `disableAutoFetch` so PDFs without an AcroForm are probed with only
* the trailer/xref/catalog (~5-25% of bytes); PDFs with forms still walk
* every page via {@link extractFormFieldInfo} but those are typically small.
* All errors (including range-fetch failures surfaced via
* {@link PdfCacheRangeTransport.failed}) resolve to empty results.
*/
async function probeFormFields(
url: string,
totalBytes: number,
readPdfRange: PdfCache["readPdfRange"],
): Promise<{
formSchema: Awaited<ReturnType<typeof extractFormSchema>>;
fieldInfo: FormFieldInfo[];
}> {
// Assigned sequentially below so a throw in extractFormFieldInfo (no per-page
// guard, unlike extractFormSchema) doesn't discard an already-computed schema.
let formSchema: Awaited<ReturnType<typeof extractFormSchema>> = null;
let fieldInfo: FormFieldInfo[] = [];
try {
const transport = new PdfCacheRangeTransport(url, totalBytes, readPdfRange);
const orFail = <T>(p: Promise<T>): Promise<T> =>
Promise.race([p, transport.failed]);
const pdfDoc = await orFail(
getDocument({
range: transport,
length: totalBytes,
disableAutoFetch: true,
disableStream: true,
rangeChunkSize: 64 * 1024,
standardFontDataUrl: STANDARD_FONT_DATA_URL,
StandardFontDataFactory: FetchStandardFontDataFactory,
verbosity: VerbosityLevel.ERRORS,
}).promise,
);
try {
const fieldObjects = (await orFail(pdfDoc.getFieldObjects())) as Record<
string,
PdfJsFieldObject[]
> | null;
if (fieldObjects && Object.keys(fieldObjects).length > 0) {
formSchema = await orFail(extractFormSchema(pdfDoc, fieldObjects));
fieldInfo = await orFail(extractFormFieldInfo(pdfDoc));
}
} finally {
pdfDoc.destroy();
}
} catch {
// Non-fatal — return whatever was assigned before the throw.
}
return { formSchema, fieldInfo };
}
/**
* 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(
pdfDoc: PDFDocumentProxy,
): Promise<FormFieldInfo[]> {
const fields: FormFieldInfo[] = [];
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;
// Choice widgets (combo/listbox) carry `options` as
// [{exportValue, displayValue}]. Expose export values — that's
// what fill_form needs.
let options: string[] | undefined;
if (Array.isArray(ann.options) && ann.options.length > 0) {
options = ann.options
.map((o: { exportValue?: string }) => o?.exportValue)
.filter((v: unknown): v is string => typeof v === "string");
}
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),
// Radio: buttonValue is the per-widget export value — the only
// thing distinguishing three `size [Btn]` lines from each other.
...(ann.radioButton && ann.buttonValue != null
? { exportValue: String(ann.buttonValue) }
: undefined),
...(options?.length ? { options } : undefined),
});
}
}
return fields;
}
export async function extractFormSchema(
pdfDoc: PDFDocumentProxy,
fieldObjects: Record<string, PdfJsFieldObject[]> | null,
): Promise<{
type: "object";
properties: Record<string, PrimitiveSchemaDefinition>;
required?: string[];
} | null> {
if (!fieldObjects || Object.keys(fieldObjects).length === 0) {
return null;
}
const properties: Record<string, PrimitiveSchemaDefinition> = {};
for (const [name, fields] of Object.entries(fieldObjects)) {
// pdfjs returns the full field-tree array: for separated structures
// (pdf-lib) the typed widget is at [1+] behind a container at [0]; for
// merged/leaf entries (W-9, most authoring tools) it's at [0]. Pick the
// first entry that actually has a field type.
const field = fields.find((f) => f.type) ?? fields[0];
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);
});
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;
}
// Module-level singletons so they survive across createServer() calls — in
// stateless HTTP deployments a fresh server is created per request, and
// per-instance caches are discarded immediately.
const sharedPdfCache = createPdfCache();
let cachedAppHtml: string | undefined;
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);
},
);
}
const { readPdfRange } = sharedPdfCache;
// 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). The model should NOT call this tool directly.",
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. The widget exposes app-registered tools for page navigation, text extraction, searching, and zoom control.
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, get_viewer_state (extract content / read selection & current page)
- save_as (write annotated PDF to disk)
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(),
exportValue: z
.string()
.optional()
.describe("Radio button value — pass this to fill_form"),
options: z
.array(z.string())
.optional()
.describe("Dropdown/listbox option values"),
}),
)
.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/
// 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 (!disableInteract) viewSourcePaths.set(uuid, 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);
}
}
const { formSchema, fieldInfo } = await probeFormFields(
normalized,
totalBytes,
readPdfRange,
);
if (formSchema) {
viewFieldNames.set(uuid, new Set(Object.keys(formSchema.properties)));
}
if (fieldInfo.length > 0 && !viewFieldNames.has(uuid)) {
viewFieldNames.set(
uuid,
new Set(fieldInfo.map((f) => f.name).filter(Boolean)),
);
}
// 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, extract, or save to a file: 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)";
// Radio: =<exportValue> tells the model what value to pass.
// Dropdown: options:[...] lists valid choices.
const exportSuffix = f.exportValue ? `=${f.exportValue}` : "";
const optsSuffix = f.options
? ` options:[${f.options.join(", ")}]`
: "";
lines.push(
` ${nameStr}${exportSuffix}${label} [${f.type}] at (${f.x},${f.y}) ${f.width}×${f.height}${optsSuffix}`,
);
}
}
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",
"get_viewer_state",
"save_as",
])
.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.",
),
path: z
.string()
.optional()
.describe(
"Target file path for save_as. Absolute path or file:// URL. Omit to overwrite the original file (requires overwrite: true).",
),
overwrite: z
.boolean()
.optional()
.describe("Overwrite if file exists (for save_as). Default false."),
});
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,
path: savePath,
overwrite,
} = 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 {
await ensureViewerIsPolling(uuid);
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 {
await ensureViewerIsPolling(uuid);
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,
};
}
case "save_as": {
const saveErr = (text: string) => ({
content: [{ type: "text" as const, text }],
isError: true as const,
});
let resolved: string;
if (savePath) {
// Explicit target. Same path normalisation as save_pdf — but NOT
// validateUrl(), which fails on non-existent files.
const filePath = isFileUrl(savePath)
? fileUrlToPath(savePath)
: isLocalPath(savePath)
? decodeURIComponent(savePath)
: null;
if (!filePath)
return saveErr(
"save_as: path must be an absolute local path or file:// URL",
);
resolved = path.resolve(filePath);
if (!isWritablePath(resolved))
return saveErr(
`save_as refused: ${resolved} is not under a mounted ` +
`directory root. Only paths under directory roots ` +
`(or files passed as CLI args) are writable.`,
);
if (!overwrite && fs.existsSync(resolved))
return saveErr(
`File already exists: ${resolved}. ` +
`Set overwrite: true to replace it, or choose a different path.`,
);
} else {
// No target → overwrite the original. Same gate as the viewer's
// save button: isWritablePath + OS-level W_OK (so we don't try
// on read-only mounts). Remote PDFs have no source path stored.
const source = viewSourcePaths.get(uuid);
if (!source)
return saveErr(
"save_as: no `path` given and this viewer has no local source " +
"file to overwrite (it's a remote URL, or the viewUUID is " +
"stale/unknown). Provide an explicit `path`.",
);
if (!overwrite)
return saveErr(
`save_as: omitting \`path\` overwrites the original ` +
`(${source}). Set overwrite: true to confirm.`,
);
if (!isWritablePath(source))
return saveErr(
`save_as refused: ${source} is not writable (the viewer's ` +
`save button is hidden for the same reason).`,
);
try {
await fs.promises.access(source, fs.constants.W_OK);
} catch {
return saveErr(
`save_as refused: ${source} is not writable at the OS level ` +
`(read-only mount or insufficient permissions).`,
);
}
resolved = source;
}
const requestId = randomUUID();
enqueueCommand(uuid, { type: "save_as", requestId });
let data: string;
try {
await ensureViewerIsPolling(uuid);
data = await waitForSaveData(requestId, signal);
} catch (err) {
return saveErr(
`save_as failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
try {
const bytes = Buffer.from(data, "base64");
await fs.promises.writeFile(resolved, bytes);
return {
content: [
{
type: "text",
text: `Saved annotated PDF to ${resolved} (${bytes.length} bytes)`,
},
],
};
} catch (err) {
return saveErr(
`save_as: failed to write ${resolved}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
case "get_viewer_state": {
const requestId = randomUUID();
enqueueCommand(uuid, { type: "get_viewer_state", requestId });
let state: string;
try {
await ensureViewerIsPolling(uuid);
state = await waitForViewerState(requestId, signal);
} catch (err) {
return {
content: [
{
type: "text",
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
return { content: [{ type: "text", text: state }] };
}
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; results are returned in the same order, one content item per command. If a command fails, the batch stops there and that command's slot contains text starting with \`ERROR\` — content.length tells you how far it got. 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\`.
• get_viewer_state: snapshot of the live viewer — JSON {currentPage, pageCount, zoom, displayMode, selectedAnnotationIds, selection:{text,contextBefore,contextAfter,boundingRect}|null}. Use this to read what the user has selected or which page they're on.
**FORMS** — fill_form: fill fields with \`fields\` array of {name, value}.
**SAVE** — save_as: write the annotated PDF (annotations + form values) to a file. Pass \`path\` (absolute path or file://) for a new location, or omit \`path\` to overwrite the original. Set \`overwrite: true\` to replace an existing file (always required when omitting \`path\`).`,
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",
"get_viewer_state",
"save_as",
])
.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.",
),
path: z
.string()
.optional()
.describe(
"Target file path for save_as. Absolute path or file:// URL. Omit to overwrite the original file (requires overwrite: true).",
),
overwrite: z
.boolean()
.optional()
.describe("Overwrite if file exists (for save_as). Default false."),
// 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,
path: savePath,
overwrite,
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,
path: savePath,
overwrite,
},
]
: [];
if (commandList.length === 0) {
return {
content: [
{
type: "text",
text: "No action or commands specified. Provide either `action` (single command) or `commands` (batch).",
},
],
isError: true,
};
}
// 1:1 content array — content[i] is the result of commands[i].
//
// For multi-step batches we do NOT set isError on a step failure:
// LocalAgentMode SDK 2.1.87 collapses isError:true results to a
// bare string of content[0].text, which would drop any images
// from earlier successful steps. Instead, the failed step's slot
// is text starting with "ERROR", and the batch stops there — the
// model reads content.length to see how far it got.
//
// Single-command calls have no prior results to lose, so they
// keep isError:true. The SDK's flatten-to-content[0].text is
// exactly the ERROR text we want it to see.
const allContent: ContentPart[] = [];
let failedAt = -1;
const t0 = Date.now();
for (let i = 0; i < commandList.length; i++) {
const result = await processInteractCommand(
uuid,
commandList[i],
extra.signal,
);
if (result.isError) {
const errText = result.content
.map((c) => (c.type === "text" ? c.text : null))
.filter((t) => t != null)
.join(" — ");
// Normalize the prefix — processInteractCommand's catch blocks
// are inconsistent ("Error: ...", "add_annotations: ...", etc.)
const stripped = errText.replace(/^error:\s*/i, "");
allContent.push({
type: "text",
text:
commandList.length > 1
? `ERROR at step ${i + 1}/${commandList.length} (${commandList[i].action}): ${stripped}`
: `ERROR: ${stripped}`,
});
failedAt = i;
console.error(
`[interact] uuid=${uuid} step ${i + 1}/${commandList.length} ` +
`(${commandList[i].action}) failed after ${Date.now() - t0}ms: ${stripped}`,
);
break;
}
// Squash multi-part successes (e.g. get_text returning multiple
// text blocks) into one slot so 1:1 indexing holds. Preserve a
// lone image as-is — that's the screenshot the model wants.
if (result.content.length === 1) {
allContent.push(result.content[0]);
} else {
const texts = result.content
.map((c) => (c.type === "text" ? c.text : null))
.filter((t) => t != null);
allContent.push({ type: "text", text: texts.join("\n") });
}
}
return {
content: allContent,
// isError flattens to a string in some SDKs, losing the array
// shape. Only set it when there was never going to be an array
// — single command, no positional contract to break.
...(failedAt >= 0 && commandList.length === 1
? { 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). The model should NOT call this tool directly.",
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: submit_save_data (app-only) - Viewer submits annotated PDF bytes
registerAppTool(
server,
"submit_save_data",
{
title: "Submit Save Data",
description:
"Submit annotated PDF bytes for a save_as request (used by viewer). The model should NOT call this tool directly.",
inputSchema: {
requestId: z
.string()
.describe("The request ID from the save_as command"),
data: z.string().optional().describe("Base64-encoded PDF bytes"),
error: z
.string()
.optional()
.describe("Error message if the viewer failed to build bytes"),
},
_meta: { ui: { visibility: ["app"] } },
},
async ({ requestId, data, error }): Promise<CallToolResult> => {
const settle = pendingSaveRequests.get(requestId);
if (!settle) {
return {
content: [
{ type: "text", text: `No pending request for ${requestId}` },
],
isError: true,
};
}
if (error || !data) {
settle(new Error(error || "Viewer returned no data"));
} else {
settle(data);
}
return { content: [{ type: "text", text: "Submitted" }] };
},
);
// Tool: submit_viewer_state (app-only) - Viewer reports its live state
registerAppTool(
server,
"submit_viewer_state",
{
title: "Submit Viewer State",
description:
"Submit a viewer-state snapshot for a get_viewer_state request (used by viewer). The model should NOT call this tool directly.",
inputSchema: {
requestId: z
.string()
.describe("The request ID from the get_viewer_state command"),
state: z
.string()
.optional()
.describe("JSON-encoded viewer state snapshot"),
error: z
.string()
.optional()
.describe("Error message if the viewer failed to read state"),
},
_meta: { ui: { visibility: ["app"] } },
},
async ({ requestId, state, error }): Promise<CallToolResult> => {
const settle = pendingStateRequests.get(requestId);
if (!settle) {
return {
content: [
{ type: "text", text: `No pending request for ${requestId}` },
],
isError: true,
};
}
if (error || !state) {
settle(new Error(error || "Viewer returned no state"));
} else {
settle(state);
}
return { content: [{ type: "text", text: "Submitted" }] };
},
);
// 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. The model should NOT call this tool directly.",
inputSchema: {
viewUUID: z.string().describe("The viewUUID of the PDF viewer"),
},
_meta: { ui: { visibility: ["app"] } },
},
async ({ viewUUID: uuid }): Promise<CallToolResult> => {
viewsPolled.add(uuid);
// 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. The model should NOT call this tool directly — use interact with action: save_as instead.",
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 = (cachedAppHtml ??= 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;
}