* 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
2943 lines
102 KiB
TypeScript
2943 lines
102 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|