Replace the hardcoded list of allowed remote origins with a simple HTTPS protocol check. Any HTTPS URL is now accepted for remote PDFs. Local file allowlisting remains unchanged.
581 lines
18 KiB
TypeScript
581 lines
18 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";
|
|
import { z } from "zod";
|
|
|
|
// =============================================================================
|
|
// Configuration
|
|
// =============================================================================
|
|
|
|
export const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need
|
|
export const MAX_CHUNK_BYTES = 512 * 1024; // 512KB max per request
|
|
export const RESOURCE_URI = "ui://pdf-viewer/mcp-app.html";
|
|
|
|
/** Inactivity timeout: clear cache entry if not accessed for this long */
|
|
export const CACHE_INACTIVITY_TIMEOUT_MS = 10_000; // 10 seconds
|
|
|
|
/** Max lifetime: clear cache entry after this time regardless of access */
|
|
export const CACHE_MAX_LIFETIME_MS = 60_000; // 60 seconds
|
|
|
|
/** Max size for cached PDFs (defensive limit to prevent memory exhaustion) */
|
|
export const CACHE_MAX_PDF_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
|
|
|
/** Allowed local file paths (populated from CLI args) */
|
|
export const allowedLocalFiles = new Set<string>();
|
|
|
|
/** Allowed local directories (populated from MCP roots) */
|
|
export const allowedLocalDirs = new Set<string>();
|
|
|
|
// 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);
|
|
const resolved = path.resolve(filePath);
|
|
|
|
// Check exact match (CLI args)
|
|
const exactMatch = allowedLocalFiles.has(filePath);
|
|
|
|
// Check directory match (MCP roots)
|
|
const dirMatch = [...allowedLocalDirs].some(
|
|
(dir) => resolved === dir || resolved.startsWith(dir + path.sep),
|
|
);
|
|
|
|
if (!exactMatch && !dirMatch) {
|
|
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 - require HTTPS
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (parsed.protocol !== "https:") {
|
|
return { valid: false, error: `Only HTTPS URLs are allowed: ${url}` };
|
|
}
|
|
return { valid: true };
|
|
} catch {
|
|
return { valid: false, error: `Invalid URL: ${url}` };
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Session-Local PDF Cache
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Cache entry for remote PDFs from servers that don't support Range requests.
|
|
* Tracks both inactivity and max lifetime for automatic cleanup.
|
|
*/
|
|
interface CacheEntry {
|
|
/** The cached PDF data */
|
|
data: Uint8Array;
|
|
/** Timestamp when entry was created (for max lifetime) */
|
|
createdAt: number;
|
|
/** Timer that fires after CACHE_INACTIVITY_TIMEOUT_MS of no access */
|
|
inactivityTimer: ReturnType<typeof setTimeout>;
|
|
/** Timer that fires after CACHE_MAX_LIFETIME_MS from creation */
|
|
maxLifetimeTimer: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
/**
|
|
* Session-local PDF cache utilities.
|
|
* Each call to createPdfCache() creates an independent cache instance.
|
|
*/
|
|
export interface PdfCache {
|
|
/** Read a range of bytes from a PDF, using cache for servers without Range support */
|
|
readPdfRange(
|
|
url: string,
|
|
offset: number,
|
|
byteCount: number,
|
|
): Promise<{ data: Uint8Array; totalBytes: number }>;
|
|
/** Get current number of cached entries */
|
|
getCacheSize(): number;
|
|
/** Clear all cached entries and their timers */
|
|
clearCache(): void;
|
|
}
|
|
|
|
/**
|
|
* Creates a session-local PDF cache with automatic timeout-based cleanup.
|
|
*
|
|
* When a remote server returns HTTP 200 (full body) instead of 206 (partial),
|
|
* the full response is cached so subsequent chunk requests don't re-download.
|
|
*
|
|
* Entries are automatically cleared after:
|
|
* - CACHE_INACTIVITY_TIMEOUT_MS of no access (resets on each access)
|
|
* - CACHE_MAX_LIFETIME_MS from creation (absolute timeout)
|
|
*/
|
|
export function createPdfCache(): PdfCache {
|
|
const cache = new Map<string, CacheEntry>();
|
|
|
|
/** Delete a cache entry and clear its timers */
|
|
function deleteCacheEntry(url: string): void {
|
|
const entry = cache.get(url);
|
|
if (entry) {
|
|
clearTimeout(entry.inactivityTimer);
|
|
clearTimeout(entry.maxLifetimeTimer);
|
|
cache.delete(url);
|
|
}
|
|
}
|
|
|
|
/** Get cached data and refresh the inactivity timer */
|
|
function getCacheEntry(url: string): Uint8Array | undefined {
|
|
const entry = cache.get(url);
|
|
if (!entry) return undefined;
|
|
|
|
// Refresh inactivity timer on access
|
|
clearTimeout(entry.inactivityTimer);
|
|
entry.inactivityTimer = setTimeout(() => {
|
|
deleteCacheEntry(url);
|
|
}, CACHE_INACTIVITY_TIMEOUT_MS);
|
|
|
|
return entry.data;
|
|
}
|
|
|
|
/** Add data to cache with both inactivity and max lifetime timers */
|
|
function setCacheEntry(url: string, data: Uint8Array): void {
|
|
// Clear any existing entry first
|
|
deleteCacheEntry(url);
|
|
|
|
const entry: CacheEntry = {
|
|
data,
|
|
createdAt: Date.now(),
|
|
inactivityTimer: setTimeout(() => {
|
|
deleteCacheEntry(url);
|
|
}, CACHE_INACTIVITY_TIMEOUT_MS),
|
|
maxLifetimeTimer: setTimeout(() => {
|
|
deleteCacheEntry(url);
|
|
}, CACHE_MAX_LIFETIME_MS),
|
|
};
|
|
|
|
cache.set(url, entry);
|
|
}
|
|
|
|
/** Slice a cached or freshly-fetched full body to the requested range. */
|
|
function sliceToChunk(
|
|
fullData: Uint8Array,
|
|
offset: number,
|
|
clampedByteCount: number,
|
|
): { data: Uint8Array; totalBytes: number } {
|
|
const totalBytes = fullData.length;
|
|
const start = Math.min(offset, totalBytes);
|
|
const end = Math.min(start + clampedByteCount, totalBytes);
|
|
return { data: fullData.slice(start, end), totalBytes };
|
|
}
|
|
|
|
async function readPdfRange(
|
|
url: string,
|
|
offset: number,
|
|
byteCount: number,
|
|
): Promise<{ data: Uint8Array; totalBytes: number }> {
|
|
const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
|
|
const clampedByteCount = Math.min(byteCount, MAX_CHUNK_BYTES);
|
|
|
|
if (isFileUrl(normalized)) {
|
|
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 - try Range request, fall back to full GET if not supported
|
|
let response = await fetch(normalized, {
|
|
headers: {
|
|
Range: `bytes=${offset}-${offset + clampedByteCount - 1}`,
|
|
},
|
|
});
|
|
|
|
// If server doesn't support Range (501, 416, etc.), fall back to plain GET
|
|
if (!response.ok && response.status !== 206) {
|
|
response = await fetch(normalized);
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Failed to fetch PDF: ${response.status} ${response.statusText}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// HTTP 200 means the server ignored our Range header and sent the full body.
|
|
// Cache it so subsequent chunk requests don't re-download, then slice.
|
|
if (response.status === 200) {
|
|
// Check Content-Length header first as a preliminary size check
|
|
const contentLength = response.headers.get("content-length");
|
|
if (contentLength) {
|
|
const declaredSize = parseInt(contentLength, 10);
|
|
if (declaredSize > CACHE_MAX_PDF_SIZE_BYTES) {
|
|
throw new Error(
|
|
`PDF too large to cache: ${declaredSize} bytes exceeds ${CACHE_MAX_PDF_SIZE_BYTES} byte limit`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const fullData = new Uint8Array(await response.arrayBuffer());
|
|
|
|
// Check actual size (may differ from Content-Length)
|
|
if (fullData.length > CACHE_MAX_PDF_SIZE_BYTES) {
|
|
throw new Error(
|
|
`PDF too large to cache: ${fullData.length} bytes exceeds ${CACHE_MAX_PDF_SIZE_BYTES} byte limit`,
|
|
);
|
|
}
|
|
|
|
setCacheEntry(normalized, fullData);
|
|
return sliceToChunk(fullData, offset, clampedByteCount);
|
|
}
|
|
|
|
// HTTP 206 Partial Content — parse total size from Content-Range header
|
|
const contentRange = response.headers.get("content-range");
|
|
let totalBytes = 0;
|
|
if (contentRange) {
|
|
const match = contentRange.match(/bytes \d+-\d+\/(\d+)/);
|
|
if (match) {
|
|
totalBytes = parseInt(match[1], 10);
|
|
}
|
|
}
|
|
|
|
const data = new Uint8Array(await response.arrayBuffer());
|
|
return { data, totalBytes };
|
|
}
|
|
|
|
return {
|
|
readPdfRange,
|
|
getCacheSize: () => cache.size,
|
|
clearCache: () => {
|
|
for (const url of [...cache.keys()]) {
|
|
deleteCacheEntry(url);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// MCP Roots
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Query the client for roots and update allowedLocalDirs with any file:// roots
|
|
* that point to existing directories.
|
|
*/
|
|
async function refreshRoots(server: Server): Promise<void> {
|
|
if (!server.getClientCapabilities()?.roots) return;
|
|
|
|
try {
|
|
const { roots } = await server.listRoots();
|
|
allowedLocalDirs.clear();
|
|
for (const root of roots) {
|
|
if (root.uri.startsWith("file://")) {
|
|
const dir = fileUrlToPath(root.uri);
|
|
const resolved = path.resolve(dir);
|
|
try {
|
|
if (fs.statSync(resolved).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}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// MCP Server Factory
|
|
// =============================================================================
|
|
|
|
export function createServer(): McpServer {
|
|
const server = new McpServer({ name: "PDF Server", version: "2.0.0" });
|
|
|
|
// Fetch roots on initialization and subscribe to changes
|
|
server.server.oninitialized = () => {
|
|
refreshRoots(server.server);
|
|
};
|
|
server.server.setNotificationHandler(
|
|
RootsListChangedNotificationSchema,
|
|
async () => {
|
|
await refreshRoots(server.server);
|
|
},
|
|
);
|
|
|
|
// Create session-local cache (isolated per server instance)
|
|
const { readPdfRange } = createPdfCache();
|
|
|
|
// Tool: list_pdfs - List available PDFs
|
|
server.tool(
|
|
"list_pdfs",
|
|
"List available PDFs that can be displayed",
|
|
{},
|
|
async (): Promise<CallToolResult> => {
|
|
const pdfs: Array<{ url: string; type: "local" | "remote" }> = [];
|
|
|
|
// Add local files
|
|
for (const filePath of allowedLocalFiles) {
|
|
pdfs.push({ url: pathToFileUrl(filePath), type: "local" });
|
|
}
|
|
|
|
// Build text
|
|
const parts: string[] = [];
|
|
if (pdfs.length > 0) {
|
|
parts.push(
|
|
`Available PDFs:\n${pdfs.map((p) => `- ${p.url} (${p.type})`).join("\n")}`,
|
|
);
|
|
}
|
|
if (allowedLocalDirs.size > 0) {
|
|
parts.push(
|
|
`Allowed local directories (from client roots):\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: pdfs.filter((p) => p.type === "local").map((p) => p.url),
|
|
allowedDirectories: [...allowedLocalDirs],
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
// 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<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(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,
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
// 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)
|
|
- Local files under directories provided by the client as MCP roots
|
|
- Any remote PDF accessible via HTTPS`,
|
|
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(),
|
|
totalBytes: z.number(),
|
|
}),
|
|
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
|
},
|
|
async ({ url, page }): 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);
|
|
|
|
return {
|
|
content: [{ type: "text", text: `Displaying PDF: ${normalized}` }],
|
|
structuredContent: {
|
|
url: normalized,
|
|
initialPage: page,
|
|
totalBytes,
|
|
},
|
|
_meta: {
|
|
viewUUID: randomUUID(),
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
// Resource: UI HTML
|
|
registerAppResource(
|
|
server,
|
|
RESOURCE_URI,
|
|
RESOURCE_URI,
|
|
{ mimeType: RESOURCE_MIME_TYPE },
|
|
async (): Promise<ReadResourceResult> => {
|
|
const html = await fs.promises.readFile(
|
|
path.join(DIST_DIR, "mcp-app.html"),
|
|
"utf-8",
|
|
);
|
|
return {
|
|
contents: [
|
|
{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html },
|
|
],
|
|
};
|
|
},
|
|
);
|
|
|
|
return server;
|
|
}
|