fix: misc fixes for pdf-server, map-server, and say-server (#329)
* fix(map-server): simplify location update message format * fix(say-server): pass host to streamable_http_app for non-localhost deployments * refactor(pdf-server): simplify to stateless range-query architecture - Remove caching/indexing in favor of direct HTTP Range requests - Use HEAD requests to get file size (no full download) - Use Range requests to proxy byte chunks (no caching) - Local files: fs.read with offset/length - Remote files: HTTP Range header - Zero memory footprint (no PDF caching) - Truly stateless (no session management needed) - Single server.ts (~350 lines) + main.ts CLI entry point - Instant startup (no preloading) - Security: whitelist local files, allowed origins Removed files: - src/pdf-indexer.ts - src/pdf-loader.ts - src/types.ts * feat(pdf-server): add dark mode support - Add CSS variables with light-dark() function for automatic theme switching - Sync theme with host via applyDocumentTheme() - Apply host CSS variables via applyHostStyleVariables() * feat(pdf-server): add list_pdfs tool back Lists local files and allowed remote origins. Also moves CHUNK_SIZE to module level constant. * feat(pdf-server): improve tool descriptions and increase context limit - display_pdf: list all allowed domains in description - Increase MAX_MODEL_CONTEXT_LENGTH to 15000 - Move CHUNK_SIZE to module level constant * chore(say-server): remove deployment docs from repo Keep server.py fix for non-localhost deployments. Deployment docs kept locally but not in repo. * refactor(pdf-server): simplify updateModelContext format - Add widget ID for multi-widget disambiguation - Use concise header: 'PDF viewer (id) | title | Current Page: X/Y' - Add 'Page content:' prefix before text - Keep selection markers (<pdf-selection>...</pdf-selection>) * refactor: rename widgetUUID to viewUUID for consistency Aligns with main's standardization on 'view' terminology (PR #325). Also dynamically generate allowed domains in display_pdf description. * viewUUID * fix(map-server): fix syntax error in location update message * fix(pdf-server): restore package.json, improve server docs - Restore --external pdfjs-dist in build - Rewrite server.ts header for newcomers (list tools, not implementation details) - Minimize main.ts diff * chore(pdf-server): remove unused addAllowedOrigin/addAllowedLocalFile * docs(pdf-server): update README for new architecture * docs(pdf-server): restore full README, add theming section, remove get_pdf_info * rm * fix(pdf-server): restore createMcpExpressApp and error handling - Use createMcpExpressApp for DNS rebinding protection - Restore error callback in app.listen() * fix(say-server): pass host to streamable_http_app for non-localhost deployments * chore(say-server): remove deployment docs from repo * widgetId->tool id * fix(map-server): add null check for center in location update chore(pdf-server): dedupe and sort allowed origins * fix(map-server): use transparent background for rounded corners * prettier
This commit is contained in:
parent
4fc71656a3
commit
023f1fd233
1 changed files with 452 additions and 495 deletions
|
|
@ -1,193 +1,347 @@
|
|||
/**
|
||||
* PDF MCP Server - Didactic Example
|
||||
* PDF MCP Server
|
||||
*
|
||||
* Demonstrates:
|
||||
* - Chunked data through size-limited tool responses
|
||||
* - Model context updates (current page text + selection)
|
||||
* - Display modes: fullscreen with scrolling vs inline with resize
|
||||
* - External link opening (openLink)
|
||||
* 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 { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type {
|
||||
CallToolResult,
|
||||
ReadResourceResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
import {
|
||||
buildPdfIndex,
|
||||
findEntryByUrl,
|
||||
createEntry,
|
||||
isArxivUrl,
|
||||
isFileUrl,
|
||||
normalizeArxivUrl,
|
||||
} from "./src/pdf-indexer.js";
|
||||
import { loadPdfBytesChunk, populatePdfMetadata } from "./src/pdf-loader.js";
|
||||
import {
|
||||
ReadPdfBytesInputSchema,
|
||||
PdfBytesChunkSchema,
|
||||
type PdfIndex,
|
||||
} from "./src/types.js";
|
||||
// =============================================================================
|
||||
// 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";
|
||||
|
||||
/** 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<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;
|
||||
const RESOURCE_URI = "ui://pdf-viewer/mcp-app.html";
|
||||
const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need
|
||||
|
||||
let pdfIndex: PdfIndex | null = null;
|
||||
// =============================================================================
|
||||
// URL Validation & Normalization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Initialize the PDF index with the given URLs.
|
||||
* Must be called before createServer().
|
||||
*/
|
||||
export async function initializePdfIndex(urls: string[]): Promise<void> {
|
||||
pdfIndex = await buildPdfIndex(urls);
|
||||
export function isFileUrl(url: string): boolean {
|
||||
return url.startsWith("file://");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new MCP server instance with tools and resources registered.
|
||||
* Each HTTP session needs its own server instance because McpServer only supports one transport.
|
||||
*/
|
||||
export function createServer(): McpServer {
|
||||
const server = new McpServer({ name: "PDF Server", version: "1.0.0" });
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Tool: list_pdfs
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Range Request Helpers
|
||||
// =============================================================================
|
||||
|
||||
export 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 };
|
||||
}
|
||||
|
||||
// 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}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MCP Server Factory
|
||||
// =============================================================================
|
||||
|
||||
export function createServer(): McpServer {
|
||||
const server = new McpServer({ name: "PDF Server", version: "2.0.0" });
|
||||
|
||||
// Tool: list_pdfs - List available PDFs (local files + allowed origins)
|
||||
server.tool(
|
||||
"list_pdfs",
|
||||
"List indexed PDFs",
|
||||
"List available PDFs that can be displayed",
|
||||
{},
|
||||
async (): Promise<CallToolResult> => {
|
||||
if (!pdfIndex) throw new Error("Not initialized");
|
||||
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: JSON.stringify(pdfIndex.entries, null, 2) },
|
||||
],
|
||||
structuredContent: { entries: pdfIndex.entries },
|
||||
content: [{ type: "text", text }],
|
||||
structuredContent: {
|
||||
localFiles: pdfs.filter((p) => p.type === "local").map((p) => p.url),
|
||||
allowedOrigins: [...allowedRemoteOrigins],
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: read_pdf_bytes (app-only) - Chunked binary loading
|
||||
// Tool: read_pdf_bytes (app-only) - Range request for chunks
|
||||
registerAppTool(
|
||||
server,
|
||||
"read_pdf_bytes",
|
||||
{
|
||||
title: "Read PDF Bytes",
|
||||
description: "Load binary data in chunks",
|
||||
inputSchema: ReadPdfBytesInputSchema.shape,
|
||||
outputSchema: PdfBytesChunkSchema,
|
||||
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 (args: unknown): Promise<CallToolResult> => {
|
||||
if (!pdfIndex) throw new Error("Not initialized");
|
||||
const {
|
||||
url: rawUrl,
|
||||
offset,
|
||||
byteCount,
|
||||
} = ReadPdfBytesInputSchema.parse(args);
|
||||
const url = isArxivUrl(rawUrl) ? normalizeArxivUrl(rawUrl) : rawUrl;
|
||||
let entry = findEntryByUrl(pdfIndex, url);
|
||||
|
||||
// Dynamically add arxiv URLs (handles server restart between display_pdf and read_pdf_bytes)
|
||||
if (!entry) {
|
||||
if (isFileUrl(url)) {
|
||||
throw new Error("File URLs must be in the initial list");
|
||||
}
|
||||
if (!isArxivUrl(url)) {
|
||||
throw new Error(`PDF not found: ${url}`);
|
||||
}
|
||||
entry = createEntry(url);
|
||||
await populatePdfMetadata(entry);
|
||||
pdfIndex.entries.push(entry);
|
||||
async ({ url, offset, byteCount }): Promise<CallToolResult> => {
|
||||
const validation = validateUrl(url);
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
content: [{ type: "text", text: validation.error! }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const chunk = await loadPdfBytesChunk(entry, offset, byteCount);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${chunk.byteCount} bytes at ${chunk.offset}/${chunk.totalBytes}`,
|
||||
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,
|
||||
},
|
||||
],
|
||||
structuredContent: chunk,
|
||||
};
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: display_pdf - Interactive viewer with UI
|
||||
// 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 in the chat.
|
||||
description: `Display an interactive PDF viewer.
|
||||
|
||||
Use this tool when the user asks to view, display, read, or open a PDF. Accepts:
|
||||
- URLs from list_pdfs (preloaded PDFs)
|
||||
- Any arxiv.org URL (loaded dynamically)
|
||||
|
||||
The viewer supports zoom, navigation, text selection, and fullscreen mode.`,
|
||||
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 (arxiv.org for dynamic loading)"),
|
||||
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(),
|
||||
title: z.string().optional(),
|
||||
pageCount: z.number(),
|
||||
initialPage: z.number(),
|
||||
}),
|
||||
_meta: { ui: { resourceUri: RESOURCE_URI } },
|
||||
},
|
||||
async ({ url: rawUrl, page }): Promise<CallToolResult> => {
|
||||
if (!pdfIndex) throw new Error("Not initialized");
|
||||
async ({ url, page }): Promise<CallToolResult> => {
|
||||
const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
|
||||
const validation = validateUrl(normalized);
|
||||
|
||||
// Normalize arxiv URLs to PDF format
|
||||
const url = isArxivUrl(rawUrl) ? normalizeArxivUrl(rawUrl) : rawUrl;
|
||||
|
||||
let entry = findEntryByUrl(pdfIndex, url);
|
||||
|
||||
if (!entry) {
|
||||
if (isFileUrl(url)) {
|
||||
throw new Error("File URLs must be in the initial list");
|
||||
}
|
||||
if (!isArxivUrl(url)) {
|
||||
throw new Error(`Only arxiv.org URLs can be loaded dynamically`);
|
||||
}
|
||||
|
||||
entry = createEntry(url);
|
||||
await populatePdfMetadata(entry);
|
||||
pdfIndex.entries.push(entry);
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
content: [{ type: "text", text: validation.error! }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = {
|
||||
url: entry.url,
|
||||
title: entry.metadata.title,
|
||||
pageCount: entry.metadata.pageCount,
|
||||
initialPage: Math.min(page, entry.metadata.pageCount),
|
||||
};
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Displaying interactive PDF viewer${entry.metadata.title ? ` for "${entry.metadata.title}"` : ""} (${entry.url}, ${entry.metadata.pageCount} pages)`,
|
||||
},
|
||||
],
|
||||
structuredContent: result,
|
||||
content: [{ type: "text", text: `Displaying PDF: ${normalized}` }],
|
||||
structuredContent: {
|
||||
url: normalized,
|
||||
initialPage: page,
|
||||
},
|
||||
_meta: {
|
||||
viewUUID: randomUUID(),
|
||||
},
|
||||
|
|
@ -202,7 +356,7 @@ The viewer supports zoom, navigation, text selection, and fullscreen mode.`,
|
|||
RESOURCE_URI,
|
||||
{ mimeType: RESOURCE_MIME_TYPE },
|
||||
async (): Promise<ReadResourceResult> => {
|
||||
const html = await fs.readFile(
|
||||
const html = await fs.promises.readFile(
|
||||
path.join(DIST_DIR, "mcp-app.html"),
|
||||
"utf-8",
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue