/** * PDF MCP Server * * An MCP server that displays PDFs in an interactive viewer. * Supports local files and remote URLs from academic sources (arxiv, biorxiv, etc). * * 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 { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; import type { CallToolResult, ReadResourceResult, } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; // ============================================================================= // Configuration // ============================================================================= export const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need export const MAX_CHUNK_BYTES = 512 * 1024; // 512KB max per request export const RESOURCE_URI = "ui://pdf-viewer/mcp-app.html"; /** Inactivity timeout: clear cache entry if not accessed for this long */ export const CACHE_INACTIVITY_TIMEOUT_MS = 10_000; // 10 seconds /** Max lifetime: clear cache entry after this time regardless of access */ export const CACHE_MAX_LIFETIME_MS = 60_000; // 60 seconds /** Max size for cached PDFs (defensive limit to prevent memory exhaustion) */ export const CACHE_MAX_PDF_SIZE_BYTES = 50 * 1024 * 1024; // 50MB /** Allowed remote origins (security allowlist) */ export const allowedRemoteOrigins = new Set([ "https://agrirxiv.org", "https://arxiv.org", "https://chemrxiv.org", "https://edarxiv.org", "https://engrxiv.org", "https://hal.science", "https://osf.io", "https://psyarxiv.com", "https://ssrn.com", "https://www.biorxiv.org", "https://www.eartharxiv.org", "https://www.medrxiv.org", "https://www.preprints.org", "https://www.researchsquare.com", "https://www.sportarxiv.org", "https://zenodo.org", ]); /** Allowed local file paths (populated from CLI args) */ export const allowedLocalFiles = new Set(); // 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; // ============================================================================= // URL Validation & Normalization // ============================================================================= export function isFileUrl(url: string): boolean { return url.startsWith("file://"); } 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 { return decodeURIComponent(fileUrl.replace("file://", "")); } export function pathToFileUrl(filePath: string): string { const absolutePath = path.resolve(filePath); return `file://${encodeURIComponent(absolutePath).replace(/%2F/g, "/")}`; } export function validateUrl(url: string): { valid: boolean; error?: string } { if (isFileUrl(url)) { const filePath = fileUrlToPath(url); if (!allowedLocalFiles.has(filePath)) { return { valid: false, error: `Local file not in allowed list: ${filePath}`, }; } if (!fs.existsSync(filePath)) { return { valid: false, error: `File not found: ${filePath}` }; } return { valid: true }; } // Remote URL - check against allowed origins try { const parsed = new URL(url); const origin = `${parsed.protocol}//${parsed.hostname}`; if ( ![...allowedRemoteOrigins].some((allowed) => origin.startsWith(allowed)) ) { return { valid: false, error: `Origin not allowed: ${origin}` }; } return { valid: true }; } catch { return { valid: false, error: `Invalid URL: ${url}` }; } } // ============================================================================= // Session-Local PDF Cache // ============================================================================= /** * Cache entry for remote PDFs from servers that don't support Range requests. * Tracks both inactivity and max lifetime for automatic cleanup. */ interface CacheEntry { /** The cached PDF data */ data: Uint8Array; /** Timestamp when entry was created (for max lifetime) */ createdAt: number; /** Timer that fires after CACHE_INACTIVITY_TIMEOUT_MS of no access */ inactivityTimer: ReturnType; /** Timer that fires after CACHE_MAX_LIFETIME_MS from creation */ maxLifetimeTimer: ReturnType; } /** * Session-local PDF cache utilities. * Each call to createPdfCache() creates an independent cache instance. */ export interface PdfCache { /** Read a range of bytes from a PDF, using cache for servers without Range support */ readPdfRange( url: string, offset: number, byteCount: number, ): Promise<{ data: Uint8Array; totalBytes: number }>; /** Get current number of cached entries */ getCacheSize(): number; /** Clear all cached entries and their timers */ clearCache(): void; } /** * Creates a session-local PDF cache with automatic timeout-based cleanup. * * When a remote server returns HTTP 200 (full body) instead of 206 (partial), * the full response is cached so subsequent chunk requests don't re-download. * * Entries are automatically cleared after: * - CACHE_INACTIVITY_TIMEOUT_MS of no access (resets on each access) * - CACHE_MAX_LIFETIME_MS from creation (absolute timeout) */ export function createPdfCache(): PdfCache { const cache = new Map(); /** Delete a cache entry and clear its timers */ function deleteCacheEntry(url: string): void { const entry = cache.get(url); if (entry) { clearTimeout(entry.inactivityTimer); clearTimeout(entry.maxLifetimeTimer); cache.delete(url); } } /** Get cached data and refresh the inactivity timer */ function getCacheEntry(url: string): Uint8Array | undefined { const entry = cache.get(url); if (!entry) return undefined; // Refresh inactivity timer on access clearTimeout(entry.inactivityTimer); entry.inactivityTimer = setTimeout(() => { deleteCacheEntry(url); }, CACHE_INACTIVITY_TIMEOUT_MS); return entry.data; } /** Add data to cache with both inactivity and max lifetime timers */ function setCacheEntry(url: string, data: Uint8Array): void { // Clear any existing entry first deleteCacheEntry(url); const entry: CacheEntry = { data, createdAt: Date.now(), inactivityTimer: setTimeout(() => { deleteCacheEntry(url); }, CACHE_INACTIVITY_TIMEOUT_MS), maxLifetimeTimer: setTimeout(() => { deleteCacheEntry(url); }, CACHE_MAX_LIFETIME_MS), }; cache.set(url, entry); } /** Slice a cached or freshly-fetched full body to the requested range. */ function sliceToChunk( fullData: Uint8Array, offset: number, clampedByteCount: number, ): { data: Uint8Array; totalBytes: number } { const totalBytes = fullData.length; const start = Math.min(offset, totalBytes); const end = Math.min(start + clampedByteCount, totalBytes); return { data: fullData.slice(start, end), totalBytes }; } async function readPdfRange( url: string, offset: number, byteCount: number, ): Promise<{ data: Uint8Array; totalBytes: number }> { const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url; const clampedByteCount = Math.min(byteCount, MAX_CHUNK_BYTES); if (isFileUrl(normalized)) { const filePath = fileUrlToPath(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 - Range request const response = await fetch(normalized, { headers: { Range: `bytes=${offset}-${offset + clampedByteCount - 1}`, }, }); if (!response.ok && response.status !== 206) { throw new Error( `Range request failed: ${response.status} ${response.statusText}`, ); } // HTTP 200 means the server ignored our Range header and sent the full body. // Cache it so subsequent chunk requests don't re-download, then slice. if (response.status === 200) { // Check Content-Length header first as a preliminary size check const contentLength = response.headers.get("content-length"); if (contentLength) { const declaredSize = parseInt(contentLength, 10); if (declaredSize > CACHE_MAX_PDF_SIZE_BYTES) { throw new Error( `PDF too large to cache: ${declaredSize} bytes exceeds ${CACHE_MAX_PDF_SIZE_BYTES} byte limit`, ); } } const fullData = new Uint8Array(await response.arrayBuffer()); // Check actual size (may differ from Content-Length) if (fullData.length > CACHE_MAX_PDF_SIZE_BYTES) { throw new Error( `PDF too large to cache: ${fullData.length} bytes exceeds ${CACHE_MAX_PDF_SIZE_BYTES} byte limit`, ); } setCacheEntry(normalized, fullData); return sliceToChunk(fullData, offset, clampedByteCount); } // HTTP 206 Partial Content — parse total size from Content-Range header const contentRange = response.headers.get("content-range"); let totalBytes = 0; if (contentRange) { const match = contentRange.match(/bytes \d+-\d+\/(\d+)/); if (match) { totalBytes = parseInt(match[1], 10); } } const data = new Uint8Array(await response.arrayBuffer()); return { data, totalBytes }; } return { readPdfRange, getCacheSize: () => cache.size, clearCache: () => { for (const url of [...cache.keys()]) { deleteCacheEntry(url); } }, }; } // ============================================================================= // MCP Server Factory // ============================================================================= export function createServer(): McpServer { const server = new McpServer({ name: "PDF Server", version: "2.0.0" }); // Create session-local cache (isolated per server instance) const { readPdfRange } = createPdfCache(); // Tool: list_pdfs - List available PDFs (local files + allowed origins) server.tool( "list_pdfs", "List available PDFs that can be displayed", {}, async (): Promise => { const pdfs: Array<{ url: string; type: "local" | "remote" }> = []; // Add local files for (const filePath of allowedLocalFiles) { pdfs.push({ url: pathToFileUrl(filePath), type: "local" }); } // Note: Remote URLs from allowed origins can be loaded dynamically const text = pdfs.length > 0 ? `Available PDFs:\n${pdfs.map((p) => `- ${p.url} (${p.type})`).join("\n")}\n\nRemote PDFs from ${[...allowedRemoteOrigins].join(", ")} can also be loaded dynamically.` : `No local PDFs configured. Remote PDFs from ${[...allowedRemoteOrigins].join(", ")} can be loaded dynamically.`; return { content: [{ type: "text", text }], structuredContent: { localFiles: pdfs.filter((p) => p.type === "local").map((p) => p.url), allowedOrigins: [...allowedRemoteOrigins], }, }; }, ); // Tool: read_pdf_bytes (app-only) - Range request for chunks registerAppTool( server, "read_pdf_bytes", { title: "Read PDF Bytes", description: "Read a range of bytes from a PDF (max 512KB per request)", inputSchema: { url: z.string().describe("PDF URL"), 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 => { 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(url, 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, }; } }, ); // Build allowed domains list for tool description (strip https:// and www.) const allowedDomains = [...allowedRemoteOrigins] .map((origin) => origin.replace(/^https?:\/\/(www\.)?/, "")) .join(", "); // Tool: display_pdf - Show interactive viewer registerAppTool( server, "display_pdf", { title: "Display PDF", description: `Display an interactive PDF viewer. Accepts: - Local files explicitly added to the server (use list_pdfs to see available files) - Remote PDFs from: ${allowedDomains}`, inputSchema: { url: z.string().default(DEFAULT_PDF).describe("PDF URL"), page: z.number().min(1).default(1).describe("Initial page"), }, outputSchema: z.object({ url: z.string(), initialPage: z.number(), }), _meta: { ui: { resourceUri: RESOURCE_URI } }, }, async ({ url, page }): Promise => { const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url; const validation = validateUrl(normalized); if (!validation.valid) { return { content: [{ type: "text", text: validation.error! }], isError: true, }; } return { content: [{ type: "text", text: `Displaying PDF: ${normalized}` }], structuredContent: { url: normalized, initialPage: page, }, _meta: { viewUUID: randomUUID(), }, }; }, ); // Resource: UI HTML registerAppResource( server, RESOURCE_URI, RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async (): Promise => { const html = await fs.promises.readFile( path.join(DIST_DIR, "mcp-app.html"), "utf-8", ); return { contents: [ { uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }, ], }; }, ); return server; }