diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 5c4f7709..3b237cbe 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -108,6 +108,7 @@ jobs: - cohort-heatmap-server - customer-segmentation-server - map-server + - pdf-server - scenario-modeler-server - shadertoy-server - sheet-music-server diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4174e6fe..283577b2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,7 +29,13 @@ jobs: ./examples/budget-allocator-server \ ./examples/cohort-heatmap-server \ ./examples/customer-segmentation-server \ + ./examples/map-server \ + ./examples/pdf-server \ ./examples/scenario-modeler-server \ + ./examples/shadertoy-server \ + ./examples/sheet-music-server \ ./examples/system-monitor-server \ ./examples/threejs-server \ + ./examples/transcript-server \ + ./examples/video-resource-server \ ./examples/wiki-explorer-server diff --git a/README.md b/README.md index 55f69044..d9a24066 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,8 @@ Or edit your `package.json` manually: | [**Scenario Modeler**](examples/scenario-modeler-server) | [**Budget Allocator**](examples/budget-allocator-server) | [**Customer Segmentation**](examples/customer-segmentation-server) | | [![System Monitor](examples/system-monitor-server/grid-cell.png "Real-time OS metrics")](examples/system-monitor-server) | [![Transcript](examples/transcript-server/grid-cell.png "Live speech transcription")](examples/transcript-server) | [![Video Resource](examples/video-resource-server/grid-cell.png "Binary video via MCP resources")](examples/video-resource-server) | | [**System Monitor**](examples/system-monitor-server) | [**Transcript**](examples/transcript-server) | [**Video Resource**](examples/video-resource-server) | +| [![PDF Server](examples/pdf-server/grid-cell.png "Interactive PDF viewer with chunked loading")](examples/pdf-server) | | | +| [**PDF Server**](examples/pdf-server) | | | ### Starter Templates diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md new file mode 100644 index 00000000..48964a8f --- /dev/null +++ b/examples/pdf-server/README.md @@ -0,0 +1,137 @@ +# PDF Server + +![Screenshot](screenshot.png) + +A simple interactive PDF viewer that uses [PDF.js](https://mozilla.github.io/pdf.js/). Launch it w/ a few PDF files and/or URLs as CLI args (+ support loading any additional pdf from arxiv.org). + +## What This Example Demonstrates + +### 1. Chunked Data Through Size-Limited Tool Calls + +On some host platforms, tool calls have size limits, so large PDFs cannot be sent in a single response. This example shows a possible workaround: + +**Server side** (`pdf-loader.ts`): + +```typescript +// Returns chunks with pagination metadata +async function loadPdfBytesChunk(entry, offset, byteCount) { + return { + bytes: base64Chunk, + offset, + byteCount, + totalBytes, + hasMore: offset + byteCount < totalBytes, + }; +} +``` + +**Client side** (`mcp-app.ts`): + +```typescript +// Load in chunks with progress +while (hasMore) { + const chunk = await app.callServerTool("read_pdf_bytes", { pdfId, offset }); + chunks.push(base64ToBytes(chunk.bytes)); + offset += chunk.byteCount; + hasMore = chunk.hasMore; + updateProgress(offset, chunk.totalBytes); +} +``` + +### 2. Model Context Updates + +The viewer keeps the model informed about what the user is seeing: + +```typescript +app.updateModelContext({ + structuredContent: { + title: pdfTitle, + currentPage, + totalPages, + pageText: pageText.slice(0, 5000), + selection: selectedText ? { text, start, end } : undefined, + }, +}); +``` + +This enables the model to answer questions about the current page or selected text. + +### 3. Display Modes: Fullscreen vs Inline + +- **Inline mode**: App requests height changes to fit content +- **Fullscreen mode**: App fills the screen with internal scrolling + +```typescript +// Request fullscreen +app.requestDisplayMode({ mode: "fullscreen" }); + +// Listen for mode changes +app.ondisplaymodechange = (mode) => { + if (mode === "fullscreen") enableScrolling(); + else disableScrolling(); +}; +``` + +### 4. External Links (openLink) + +The viewer demonstrates opening external links (e.g., to the original arxiv page): + +```typescript +titleEl.onclick = () => app.openLink(sourceUrl); +``` + +## Usage + +```bash +# Default: loads a sample arxiv paper +bun examples/pdf-server/server.ts + +# Load local files (converted to file:// URLs) +bun examples/pdf-server/server.ts ./docs/paper.pdf /path/to/thesis.pdf + +# Load from URLs +bun examples/pdf-server/server.ts https://arxiv.org/pdf/2401.00001.pdf + +# Mix local and remote +bun examples/pdf-server/server.ts ./local.pdf https://arxiv.org/pdf/2401.00001.pdf + +# stdio mode for MCP clients +bun examples/pdf-server/server.ts --stdio ./papers/ +``` + +**Security**: Dynamic URLs (via `view_pdf` tool) are restricted to arxiv.org. Local files must be in the initial list. + +## Tools + +| Tool | Visibility | Purpose | +| ---------------- | ---------- | ---------------------------------- | +| `list_pdfs` | Model | List indexed PDFs | +| `display_pdf` | Model + UI | Display interactive viewer in chat | +| `read_pdf_bytes` | App only | Chunked binary loading | + +## Architecture + +``` +server.ts # MCP server (233 lines) +├── src/ +│ ├── types.ts # Zod schemas (75 lines) +│ ├── pdf-indexer.ts # URL-based indexing (44 lines) +│ ├── pdf-loader.ts # Chunked loading (171 lines) +│ └── mcp-app.ts # Interactive viewer UI +``` + +## Key Patterns Shown + +| Pattern | Implementation | +| ----------------- | ---------------------------------------- | +| App-only tools | `_meta: { ui: { visibility: ["app"] } }` | +| Chunked responses | `hasMore` + `offset` pagination | +| Model context | `app.updateModelContext()` | +| Display modes | `app.requestDisplayMode()` | +| External links | `app.openLink()` | +| Size negotiation | `app.sendSizeChanged()` | + +## Dependencies + +- `pdfjs-dist`: PDF rendering +- `@modelcontextprotocol/ext-apps`: MCP Apps SDK diff --git a/examples/pdf-server/grid-cell.png b/examples/pdf-server/grid-cell.png new file mode 100644 index 00000000..5acd2227 Binary files /dev/null and b/examples/pdf-server/grid-cell.png differ diff --git a/examples/pdf-server/mcp-app.html b/examples/pdf-server/mcp-app.html new file mode 100644 index 00000000..cf217235 --- /dev/null +++ b/examples/pdf-server/mcp-app.html @@ -0,0 +1,78 @@ + + + + + + PDF Viewer + + +
+ +
+
+

Loading PDF...

+ +

+
+ + + + + + +
+ + + + diff --git a/examples/pdf-server/package.json b/examples/pdf-server/package.json new file mode 100644 index 00000000..40a3d7cc --- /dev/null +++ b/examples/pdf-server/package.json @@ -0,0 +1,45 @@ +{ + "name": "@modelcontextprotocol/server-pdf", + "version": "0.4.0", + "type": "module", + "description": "MCP server for loading and extracting text from PDF files with chunked pagination and interactive viewer", + "repository": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/ext-apps", + "directory": "examples/pdf-server" + }, + "license": "MIT", + "main": "server.ts", + "files": [ + "server.ts", + "server-utils.ts", + "src", + "dist" + ], + "scripts": { + "build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build", + "watch": "cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch", + "serve": "bun --watch server.ts", + "serve:http": "bun --watch server.ts", + "dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'", + "start": "npm run serve" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "^0.4.0", + "@modelcontextprotocol/sdk": "^1.24.0", + "pdfjs-dist": "^5.0.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "cross-env": "^10.1.0", + "express": "^5.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/examples/pdf-server/screenshot.png b/examples/pdf-server/screenshot.png new file mode 100644 index 00000000..bdb8f176 Binary files /dev/null and b/examples/pdf-server/screenshot.png differ diff --git a/examples/pdf-server/server-utils.ts b/examples/pdf-server/server-utils.ts new file mode 100644 index 00000000..9fe9745a --- /dev/null +++ b/examples/pdf-server/server-utils.ts @@ -0,0 +1,72 @@ +/** + * Shared utilities for running MCP servers with Streamable HTTP transport. + */ + +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import cors from "cors"; +import type { Request, Response } from "express"; + +export interface ServerOptions { + port: number; + name?: string; +} + +/** + * Starts an MCP server with Streamable HTTP transport in stateless mode. + * + * @param createServer - Factory function that creates a new McpServer instance per request. + * @param options - Server configuration options. + */ +export async function startServer( + createServer: () => McpServer, + options: ServerOptions, +): Promise { + const { port, name = "MCP Server" } = options; + + const app = createMcpExpressApp({ host: "0.0.0.0" }); + app.use(cors()); + + app.all("/mcp", async (req: Request, res: Response) => { + const server = createServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + res.on("close", () => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("MCP error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + + const httpServer = app.listen(port, (err) => { + if (err) { + console.error("Failed to start server:", err); + process.exit(1); + } + console.log(`${name} listening on http://localhost:${port}/mcp`); + }); + + const shutdown = () => { + console.log("\nShutting down..."); + httpServer.close(() => process.exit(0)); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts new file mode 100644 index 00000000..964c1b02 --- /dev/null +++ b/examples/pdf-server/server.ts @@ -0,0 +1,250 @@ +/** + * PDF MCP Server - Didactic Example + * + * 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) + */ +import { + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.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 { + buildPdfIndex, + findEntryByUrl, + createEntry, + isArxivUrl, + isFileUrl, + toFileUrl, + normalizeArxivUrl, +} from "./src/pdf-indexer.js"; +import { loadPdfBytesChunk, populatePdfMetadata } from "./src/pdf-loader.js"; +import { + ReadPdfBytesInputSchema, + PdfBytesChunkSchema, + type PdfIndex, +} from "./src/types.js"; +import { startServer } from "./server-utils.js"; + +const DIST_DIR = path.join(import.meta.dirname, "dist"); +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; + +export function createServer(): McpServer { + const server = new McpServer({ name: "PDF Server", version: "1.0.0" }); + + // Tool: list_pdfs + server.tool( + "list_pdfs", + "List indexed PDFs", + {}, + async (): Promise => { + if (!pdfIndex) throw new Error("Not initialized"); + return { + content: [ + { type: "text", text: JSON.stringify(pdfIndex.entries, null, 2) }, + ], + structuredContent: { entries: pdfIndex.entries }, + }; + }, + ); + + // Tool: read_pdf_bytes (app-only) - Chunked binary loading + registerAppTool( + server, + "read_pdf_bytes", + { + title: "Read PDF Bytes", + description: "Load binary data in chunks", + inputSchema: ReadPdfBytesInputSchema.shape, + outputSchema: PdfBytesChunkSchema, + _meta: { ui: { visibility: ["app"] } }, + }, + async (args: unknown): Promise => { + 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); + } + + const chunk = await loadPdfBytesChunk(entry, offset, byteCount); + return { + content: [ + { + type: "text", + text: `${chunk.byteCount} bytes at ${chunk.offset}/${chunk.totalBytes}`, + }, + ], + structuredContent: chunk, + }; + }, + ); + + // Tool: display_pdf - Interactive viewer with UI + registerAppTool( + server, + "display_pdf", + { + title: "Display PDF", + description: `Display an interactive PDF viewer in the chat. + +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.`, + inputSchema: { + url: z + .string() + .default(DEFAULT_PDF) + .describe("PDF URL (arxiv.org for dynamic loading)"), + 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 => { + if (!pdfIndex) throw new Error("Not initialized"); + + // 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); + } + + 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, + }; + }, + ); + + // Resource: UI HTML + registerAppResource( + server, + RESOURCE_URI, + RESOURCE_URI, + { mimeType: RESOURCE_MIME_TYPE }, + async (): Promise => { + const html = await fs.readFile( + path.join(DIST_DIR, "mcp-app.html"), + "utf-8", + ); + return { + contents: [ + { uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html }, + ], + }; + }, + ); + + return server; +} + +// CLI +function parseArgs(): { urls: string[]; stdio: boolean } { + const args = process.argv.slice(2); + const urls: string[] = []; + let stdio = false; + + for (const arg of args) { + if (arg === "--stdio") { + stdio = true; + } else if (!arg.startsWith("-")) { + // Convert local paths to file:// URLs, normalize arxiv URLs + let url = arg; + if ( + !arg.startsWith("http://") && + !arg.startsWith("https://") && + !arg.startsWith("file://") + ) { + url = toFileUrl(arg); + } else if (isArxivUrl(arg)) { + url = normalizeArxivUrl(arg); + } + urls.push(url); + } + } + + return { urls: urls.length > 0 ? urls : [DEFAULT_PDF], stdio }; +} + +async function main() { + const { urls, stdio } = parseArgs(); + + console.error(`[pdf-server] Initializing with ${urls.length} PDF(s)...`); + pdfIndex = await buildPdfIndex(urls); + console.error(`[pdf-server] Ready`); + + if (stdio) { + await createServer().connect(new StdioServerTransport()); + } else { + const port = parseInt(process.env.PORT ?? "3001", 10); + await startServer(createServer, { port, name: "PDF Server" }); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/pdf-server/src/global.css b/examples/pdf-server/src/global.css new file mode 100644 index 00000000..44f8c678 --- /dev/null +++ b/examples/pdf-server/src/global.css @@ -0,0 +1,12 @@ +* { + box-sizing: border-box; +} + +html, body { + font-family: system-ui, -apple-system, sans-serif; + font-size: 1rem; + margin: 0; + padding: 0; + height: 100%; + width: 100%; +} diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css new file mode 100644 index 00000000..94093b2a --- /dev/null +++ b/examples/pdf-server/src/mcp-app.css @@ -0,0 +1,292 @@ +body { + overscroll-behavior-x: none; +} + +.main { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background: var(--bg100, #f5f5f5); + color: var(--text000, #1a1a1a); + overflow: hidden; /* Prevent scrollbars in inline mode - we request exact size */ + border-radius: 0.75rem; + border: 1px solid var(--bg200, rgba(0, 0, 0, 0.08)); +} + +/* Loading State */ +.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + gap: 0.75rem; + padding: 2rem; +} + +.spinner { + width: 24px; + height: 24px; + border: 2px solid var(--bg200, #e0e0e0); + border-top-color: var(--text100, #888); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +#loading-text { + font-size: 0.8rem; + color: var(--text100, #888); +} + +/* Progress Bar */ +.progress-container { + width: 100%; + max-width: 200px; + height: 3px; + background: var(--bg200, #e0e0e0); + border-radius: 2px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + background: var(--text100, #888); + width: 0%; + transition: width 0.15s ease-out; +} + +.progress-text { + font-size: 0.7rem; + color: var(--text200, #aaa); +} + +/* Error State */ +.error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 1rem; + padding: 2rem; + text-align: center; +} + +.error-icon { + font-size: 3rem; +} + +#error-message { + color: var(--text200, #999); + max-width: 400px; +} + +/* Viewer Container */ +.viewer { + display: flex; + flex-direction: column; + flex: 1; + overflow: visible; +} + +/* Toolbar */ +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + background: var(--bg000, #ffffff); + border-bottom: 1px solid var(--bg200, #e0e0e0); + flex-shrink: 0; + gap: 0.5rem; + height: 48px; + box-sizing: border-box; +} + +.toolbar-left { + flex: 1; + min-width: 0; + overflow: hidden; +} + +.pdf-title { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.9rem; + display: block; + max-width: 100%; +} + +.toolbar-center { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.toolbar-right { + display: flex; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; +} + +/* Page Navigation */ +.page-nav { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.page-input { + width: 50px; + padding: 0.25rem 0.5rem; + border: 1px solid var(--bg200, #e0e0e0); + border-radius: 4px; + font-size: 0.85rem; + text-align: center; + background: var(--bg000, #ffffff); + color: var(--text000, #1a1a1a); +} + +.page-input:focus { + outline: none; + border-color: var(--text100, #666); +} + +.page-input::-webkit-outer-spin-button, +.page-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +.page-input[type="number"] { + -moz-appearance: textfield; +} + +.total-pages { + font-size: 0.85rem; + color: var(--text100, #666); + white-space: nowrap; +} + +.nav-btn, +.zoom-btn, +.fullscreen-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--bg200, #e0e0e0); + border-radius: 4px; + background: var(--bg000, #ffffff); + color: var(--text000, #1a1a1a); + cursor: pointer; + font-size: 1rem; + transition: all 0.15s ease; +} + +.nav-btn:hover:not(:disabled), +.zoom-btn:hover:not(:disabled), +.fullscreen-btn:hover { + background: var(--bg100, #f5f5f5); + border-color: var(--bg300, #ccc); +} + +.nav-btn:disabled, +.zoom-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.zoom-level { + font-size: 0.8rem; + color: var(--text100, #666); + min-width: 50px; + text-align: center; +} + +/* Single Page Canvas Container */ +.canvas-container { + flex: 1; + overflow: visible; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 1rem; + background: var(--bg200, #e0e0e0); +} + +.page-wrapper { + position: relative; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + background: white; +} + +#pdf-canvas { + display: block; +} + +/* Text Layer for Selection */ +.text-layer { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + overflow: hidden; + opacity: 0.2; + line-height: 1; + text-size-adjust: none; + forced-color-adjust: none; + transform-origin: 0 0; + z-index: 2; +} + +.text-layer span, +.text-layer br { + color: transparent; + position: absolute; + white-space: pre; + cursor: text; + transform-origin: 0% 0%; +} + +.text-layer ::selection { + background: rgba(0, 0, 255, 0.3); +} + +.text-layer > span { + pointer-events: all; +} + +/* Fullscreen mode */ +.main.fullscreen { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + overflow: hidden; /* No scrolling on main - only canvas-container scrolls */ + border-radius: 0; + border: none; +} + +.main.fullscreen .viewer { + min-height: 0; /* Allow flex item to shrink below content size */ +} + +.main.fullscreen .canvas-container { + min-height: 0; /* Allow flex item to shrink below content size */ + overflow: auto; /* Scroll within the document area only */ +} diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts new file mode 100644 index 00000000..d56c3d81 --- /dev/null +++ b/examples/pdf-server/src/mcp-app.ts @@ -0,0 +1,795 @@ +/** + * PDF Viewer MCP App + * + * Interactive PDF viewer with single-page display. + * - Fixed height (no auto-resize) + * - Text selection via PDF.js TextLayer + * - Page navigation, zoom + */ +import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import * as pdfjsLib from "pdfjs-dist"; +import { TextLayer } from "pdfjs-dist"; +import "./global.css"; +import "./mcp-app.css"; + +// Configure PDF.js worker +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/build/pdf.worker.mjs", + import.meta.url, +).href; + +const log = { + info: console.log.bind(console, "[PDF-VIEWER]"), + error: console.error.bind(console, "[PDF-VIEWER]"), +}; + +// State +let pdfDocument: pdfjsLib.PDFDocumentProxy | null = null; +let pdfBytes: Uint8Array | null = null; +let currentPage = 1; +let totalPages = 0; +let scale = 1.0; +let pdfUrl = ""; +let pdfTitle: string | undefined; +let currentRenderTask: { cancel: () => void } | null = null; + +// DOM Elements +const mainEl = document.querySelector(".main") as HTMLElement; +const loadingEl = document.getElementById("loading")!; +const loadingTextEl = document.getElementById("loading-text")!; +const errorEl = document.getElementById("error")!; +const errorMessageEl = document.getElementById("error-message")!; +const viewerEl = document.getElementById("viewer")!; +const canvasContainerEl = document.querySelector(".canvas-container")!; +const canvasEl = document.getElementById("pdf-canvas") as HTMLCanvasElement; +const textLayerEl = document.getElementById("text-layer")!; +const titleEl = document.getElementById("pdf-title")!; +const pageInputEl = document.getElementById("page-input") as HTMLInputElement; +const totalPagesEl = document.getElementById("total-pages")!; +const prevBtn = document.getElementById("prev-btn") as HTMLButtonElement; +const nextBtn = document.getElementById("next-btn") as HTMLButtonElement; +const zoomOutBtn = document.getElementById("zoom-out-btn") as HTMLButtonElement; +const zoomInBtn = document.getElementById("zoom-in-btn") as HTMLButtonElement; +const zoomLevelEl = document.getElementById("zoom-level")!; +const fullscreenBtn = document.getElementById( + "fullscreen-btn", +) as HTMLButtonElement; +const progressContainerEl = document.getElementById("progress-container")!; +const progressBarEl = document.getElementById("progress-bar")!; +const progressTextEl = document.getElementById("progress-text")!; + +// Track current display mode +let currentDisplayMode: "inline" | "fullscreen" = "inline"; + +// Layout constants are no longer used - we calculate dynamically from actual element dimensions + +/** + * Request the host to resize the app to fit the current PDF page. + * Only applies in inline mode - fullscreen mode uses scrolling. + */ +function requestFitToContent() { + if (currentDisplayMode === "fullscreen") { + return; // Fullscreen uses scrolling + } + + const canvasHeight = canvasEl.height; + if (canvasHeight <= 0) { + return; // No content yet + } + + // Get actual element dimensions + const canvasContainerEl = document.querySelector( + ".canvas-container", + ) as HTMLElement; + const pageWrapperEl = document.querySelector(".page-wrapper") as HTMLElement; + const toolbarEl = document.querySelector(".toolbar") as HTMLElement; + + if (!canvasContainerEl || !toolbarEl || !pageWrapperEl) { + return; + } + + // Get computed styles + const containerStyle = getComputedStyle(canvasContainerEl); + const paddingTop = parseFloat(containerStyle.paddingTop); + const paddingBottom = parseFloat(containerStyle.paddingBottom); + + // Calculate required height: + // toolbar + padding-top + page-wrapper height + padding-bottom + buffer + const toolbarHeight = toolbarEl.offsetHeight; + const pageWrapperHeight = pageWrapperEl.offsetHeight; + const BUFFER = 10; // Buffer for sub-pixel rounding and browser quirks + const totalHeight = + toolbarHeight + paddingTop + pageWrapperHeight + paddingBottom + BUFFER; + + app.sendSizeChanged({ height: totalHeight }); +} + +// Create app instance +// autoResize disabled - app fills its container, doesn't request size changes +const app = new App( + { name: "PDF Viewer", version: "1.0.0" }, + {}, + { autoResize: false }, +); + +// UI State functions +function showLoading(text: string) { + loadingTextEl.textContent = text; + loadingEl.style.display = "flex"; + errorEl.style.display = "none"; + viewerEl.style.display = "none"; +} + +function showError(message: string) { + errorMessageEl.textContent = message; + loadingEl.style.display = "none"; + errorEl.style.display = "block"; + viewerEl.style.display = "none"; +} + +function showViewer() { + loadingEl.style.display = "none"; + errorEl.style.display = "none"; + viewerEl.style.display = "flex"; +} + +function updateControls() { + // Show URL with CSS ellipsis, full URL as tooltip, clickable to open + titleEl.textContent = pdfUrl; + titleEl.title = pdfUrl; + titleEl.style.textDecoration = "underline"; + titleEl.style.cursor = "pointer"; + titleEl.onclick = () => app.openLink({ url: pdfUrl }); + pageInputEl.value = String(currentPage); + pageInputEl.max = String(totalPages); + totalPagesEl.textContent = `of ${totalPages}`; + prevBtn.disabled = currentPage <= 1; + nextBtn.disabled = currentPage >= totalPages; + zoomLevelEl.textContent = `${Math.round(scale * 100)}%`; +} + +/** + * Format page text with optional selection, truncating intelligently. + * - Centers window around selection when truncating + * - Adds markers where text is elided + * - If selection itself is too long, truncates inside: ... + */ +function formatPageContent( + text: string, + maxLength: number, + selection?: { start: number; end: number }, +): string { + const T = ""; + + // No truncation needed + if (text.length <= maxLength) { + if (!selection) return text; + return ( + text.slice(0, selection.start) + + `${text.slice(selection.start, selection.end)}` + + text.slice(selection.end) + ); + } + + // Truncation needed, no selection - just truncate end + if (!selection) { + return text.slice(0, maxLength) + "\n" + T; + } + + // Calculate budgets + const selLen = selection.end - selection.start; + const overhead = "".length + T.length * 2 + 4; + const contextBudget = maxLength - overhead; + + // Selection too long - truncate inside the selection tags + if (selLen > contextBudget) { + const keepLen = Math.max(100, contextBudget); + const halfKeep = Math.floor(keepLen / 2); + const selStart = text.slice(selection.start, selection.start + halfKeep); + const selEnd = text.slice(selection.end - halfKeep, selection.end); + return ( + T + `${T}${selStart}...${selEnd}${T}` + T + ); + } + + // Selection fits - center it with context + const remainingBudget = contextBudget - selLen; + const beforeBudget = Math.floor(remainingBudget / 2); + const afterBudget = remainingBudget - beforeBudget; + + const windowStart = Math.max(0, selection.start - beforeBudget); + const windowEnd = Math.min(text.length, selection.end + afterBudget); + + const adjStart = selection.start - windowStart; + const adjEnd = selection.end - windowStart; + const windowText = text.slice(windowStart, windowEnd); + + return ( + (windowStart > 0 ? T + "\n" : "") + + windowText.slice(0, adjStart) + + `${windowText.slice(adjStart, adjEnd)}` + + windowText.slice(adjEnd) + + (windowEnd < text.length ? "\n" + T : "") + ); +} + +/** + * Find selection position in page text using fuzzy matching. + * TextLayer spans may lack spaces between them, so we try both exact and spaceless match. + */ +function findSelectionInText( + pageText: string, + selectedText: string, +): { start: number; end: number } | undefined { + if (!selectedText || selectedText.length <= 2) return undefined; + + // Try exact match + let start = pageText.indexOf(selectedText); + if (start >= 0) { + return { start, end: start + selectedText.length }; + } + + // Try spaceless match (TextLayer spans may not have spaces) + const noSpaceSel = selectedText.replace(/\s+/g, ""); + const noSpaceText = pageText.replace(/\s+/g, ""); + const noSpaceStart = noSpaceText.indexOf(noSpaceSel); + if (noSpaceStart >= 0) { + // Map back to approximate position in original + start = Math.floor((noSpaceStart / noSpaceText.length) * pageText.length); + return { start, end: start + selectedText.length }; + } + + return undefined; +} + +// Extract text from current page and update model context as markdown +async function updatePageContext() { + if (!pdfDocument) return; + + try { + const page = await pdfDocument.getPage(currentPage); + const textContent = await page.getTextContent(); + const pageText = (textContent.items as Array<{ str?: string }>) + .map((item) => item.str || "") + .join(" ") + .replace(/\s+/g, " ") + .trim(); + + // Find selection position + const sel = window.getSelection(); + const selectedText = sel?.toString().replace(/\s+/g, " ").trim(); + const selection = selectedText + ? findSelectionInText(pageText, selectedText) + : undefined; + + if (selection) { + log.info( + "Selection found:", + selectedText?.slice(0, 30), + "at", + selection.start, + ); + } + + // Format content with selection and truncation + const content = formatPageContent(pageText, 5000, selection); + + const markdown = `--- +title: ${pdfTitle || ""} +url: ${pdfUrl} +current-page: ${currentPage}/${totalPages} +--- + +${content}`; + + app.updateModelContext({ content: [{ type: "text", text: markdown }] }); + } catch (err) { + log.error("Error updating context:", err); + } +} + +// Render state - prevents concurrent renders +let isRendering = false; +let pendingPage: number | null = null; + +// Render current page with text layer for selection +async function renderPage() { + if (!pdfDocument) return; + + // If already rendering, queue this page for later + if (isRendering) { + pendingPage = currentPage; + // Cancel current render to speed up + if (currentRenderTask) { + currentRenderTask.cancel(); + } + return; + } + + isRendering = true; + pendingPage = null; + + try { + const pageToRender = currentPage; + const page = await pdfDocument.getPage(pageToRender); + const viewport = page.getViewport({ scale }); + + // Account for retina displays + const dpr = window.devicePixelRatio || 1; + const ctx = canvasEl.getContext("2d")!; + + // Set canvas size in pixels (scaled for retina) + canvasEl.width = viewport.width * dpr; + canvasEl.height = viewport.height * dpr; + + // Set display size in CSS pixels + canvasEl.style.width = `${viewport.width}px`; + canvasEl.style.height = `${viewport.height}px`; + + // Scale context for retina + ctx.scale(dpr, dpr); + + // Clear and setup text layer + textLayerEl.innerHTML = ""; + textLayerEl.style.width = `${viewport.width}px`; + textLayerEl.style.height = `${viewport.height}px`; + + // Render canvas - track the task so we can cancel it + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const renderTask = (page.render as any)({ + canvasContext: ctx, + viewport, + }); + currentRenderTask = renderTask; + + try { + await renderTask.promise; + } catch (renderErr) { + // Ignore RenderingCancelledException - it's expected when we cancel + if ( + renderErr instanceof Error && + renderErr.name === "RenderingCancelledException" + ) { + log.info("Render cancelled"); + return; + } + throw renderErr; + } finally { + currentRenderTask = null; + } + + // Only continue if this is still the page we want + if (pageToRender !== currentPage) { + return; + } + + // Render text layer for selection + const textContent = await page.getTextContent(); + const textLayer = new TextLayer({ + textContentSource: textContent, + container: textLayerEl, + viewport, + }); + await textLayer.render(); + + updateControls(); + updatePageContext(); + + // Request host to resize app to fit content (inline mode only) + requestFitToContent(); + } catch (err) { + log.error("Error rendering page:", err); + showError(`Failed to render page ${currentPage}`); + } finally { + isRendering = false; + + // If there's a pending page, render it now + if (pendingPage !== null && pendingPage !== currentPage) { + currentPage = pendingPage; + renderPage(); + } else if (pendingPage === currentPage) { + // Re-render the same page (e.g., after zoom change during render) + renderPage(); + } + } +} + +// Page persistence +function getStorageKey(): string | null { + if (!pdfUrl) return null; + const ctx = app.getHostContext(); + const toolId = ctx?.toolInfo?.id ?? pdfUrl; + return `pdf:${pdfUrl}:${toolId}`; +} + +function saveCurrentPage() { + const key = getStorageKey(); + log.info("saveCurrentPage: key=", key, "page=", currentPage); + if (key) { + try { + localStorage.setItem(key, String(currentPage)); + log.info("saveCurrentPage: saved successfully"); + } catch (err) { + log.error("saveCurrentPage: error", err); + } + } +} + +function loadSavedPage(): number | null { + const key = getStorageKey(); + log.info("loadSavedPage: key=", key); + if (!key) return null; + try { + const saved = localStorage.getItem(key); + log.info("loadSavedPage: saved value=", saved); + if (saved) { + const page = parseInt(saved, 10); + if (!isNaN(page) && page >= 1) { + log.info("loadSavedPage: returning page=", page); + return page; + } + } + } catch (err) { + log.error("loadSavedPage: error", err); + } + log.info("loadSavedPage: returning null"); + return null; +} + +// Navigation +function goToPage(page: number) { + const targetPage = Math.max(1, Math.min(page, totalPages)); + if (targetPage !== currentPage) { + currentPage = targetPage; + saveCurrentPage(); + renderPage(); + } + pageInputEl.value = String(currentPage); +} + +function prevPage() { + goToPage(currentPage - 1); +} + +function nextPage() { + goToPage(currentPage + 1); +} + +function zoomIn() { + scale = Math.min(scale + 0.25, 3.0); + renderPage(); +} + +function zoomOut() { + scale = Math.max(scale - 0.25, 0.5); + renderPage(); +} + +function resetZoom() { + scale = 1.0; + renderPage(); +} + +async function toggleFullscreen() { + const ctx = app.getHostContext(); + if (!ctx?.availableDisplayModes?.includes("fullscreen")) { + log.info("Fullscreen not available"); + return; + } + + const newMode = currentDisplayMode === "fullscreen" ? "inline" : "fullscreen"; + log.info("Requesting display mode:", newMode); + + try { + const result = await app.requestDisplayMode({ mode: newMode }); + log.info("Display mode result:", result); + currentDisplayMode = result.mode as "inline" | "fullscreen"; + updateFullscreenButton(); + } catch (err) { + log.error("Failed to change display mode:", err); + } +} + +function updateFullscreenButton() { + fullscreenBtn.textContent = currentDisplayMode === "fullscreen" ? "⛶" : "⛶"; + fullscreenBtn.title = + currentDisplayMode === "fullscreen" ? "Exit fullscreen" : "Fullscreen"; +} + +// Event listeners +prevBtn.addEventListener("click", prevPage); +nextBtn.addEventListener("click", nextPage); +zoomOutBtn.addEventListener("click", zoomOut); +zoomInBtn.addEventListener("click", zoomIn); +fullscreenBtn.addEventListener("click", toggleFullscreen); + +pageInputEl.addEventListener("change", () => { + const page = parseInt(pageInputEl.value, 10); + if (!isNaN(page)) { + goToPage(page); + } else { + pageInputEl.value = String(currentPage); + } +}); + +pageInputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + pageInputEl.blur(); + } +}); + +// Keyboard navigation +document.addEventListener("keydown", (e) => { + if (document.activeElement === pageInputEl) return; + + // Ctrl/Cmd+0 to reset zoom + if ((e.ctrlKey || e.metaKey) && e.key === "0") { + resetZoom(); + e.preventDefault(); + return; + } + + switch (e.key) { + case "Escape": + if (currentDisplayMode === "fullscreen") { + toggleFullscreen(); + e.preventDefault(); + } + break; + case "ArrowLeft": + case "PageUp": + prevPage(); + e.preventDefault(); + break; + case "ArrowRight": + case "PageDown": + case " ": + nextPage(); + e.preventDefault(); + break; + case "+": + case "=": + zoomIn(); + e.preventDefault(); + break; + case "-": + zoomOut(); + e.preventDefault(); + break; + } +}); + +// Update context when text selection changes (debounced) +let selectionUpdateTimeout: ReturnType | null = null; +document.addEventListener("selectionchange", () => { + if (selectionUpdateTimeout) clearTimeout(selectionUpdateTimeout); + selectionUpdateTimeout = setTimeout(() => { + const sel = window.getSelection(); + const text = sel?.toString().trim(); + if (text && text.length > 2) { + log.info("Selection changed:", text.slice(0, 50)); + updatePageContext(); + } + }, 300); +}); + +// Horizontal scroll/swipe to change pages (disabled when zoomed) +let horizontalScrollAccumulator = 0; +const SCROLL_THRESHOLD = 50; + +canvasContainerEl.addEventListener( + "wheel", + (event) => { + const e = event as WheelEvent; + + // Only intercept horizontal scroll, let vertical scroll through + if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; + + // When zoomed, let natural panning happen (no page changes) + if (scale > 1.0) return; + + // At 100% zoom, handle page navigation + e.preventDefault(); + horizontalScrollAccumulator += e.deltaX; + if (horizontalScrollAccumulator > SCROLL_THRESHOLD) { + nextPage(); + horizontalScrollAccumulator = 0; + } else if (horizontalScrollAccumulator < -SCROLL_THRESHOLD) { + prevPage(); + horizontalScrollAccumulator = 0; + } + }, + { passive: false }, +); + +// Parse tool result +function parseToolResult(result: CallToolResult): { + url: string; + title?: string; + pageCount: number; + initialPage: number; +} | null { + return result.structuredContent as { + url: string; + title?: string; + pageCount: number; + initialPage: number; + } | null; +} + +// Chunked binary loading types +interface PdfBytesChunk { + url: string; + bytes: string; + offset: number; + byteCount: number; + totalBytes: number; + hasMore: boolean; +} + +// Update progress bar +function updateProgress(loaded: number, total: number) { + const percent = Math.round((loaded / total) * 100); + progressBarEl.style.width = `${percent}%`; + progressTextEl.textContent = `${(loaded / 1024).toFixed(0)} KB / ${(total / 1024).toFixed(0)} KB (${percent}%)`; +} + +// Load PDF in chunks with progress +async function loadPdfInChunks(urlToLoad: string): Promise { + const CHUNK_SIZE = 500 * 1024; // 500KB chunks + const chunks: Uint8Array[] = []; + let offset = 0; + let totalBytes = 0; + let hasMore = true; + + // Show progress UI + progressContainerEl.style.display = "block"; + updateProgress(0, 1); + + while (hasMore) { + const result = await app.callServerTool({ + name: "read_pdf_bytes", + arguments: { url: urlToLoad, offset, byteCount: CHUNK_SIZE }, + }); + + // Check for errors + if (result.isError) { + const errorText = result.content + ?.map((c) => ("text" in c ? c.text : "")) + .join(" "); + throw new Error(`Tool error: ${errorText}`); + } + + if (!result.structuredContent) { + throw new Error("No structuredContent in tool response"); + } + + const chunk = result.structuredContent as unknown as PdfBytesChunk; + totalBytes = chunk.totalBytes; + hasMore = chunk.hasMore; + + // Decode base64 chunk + const binaryString = atob(chunk.bytes); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + chunks.push(bytes); + + offset += chunk.byteCount; + updateProgress(offset, totalBytes); + } + + // Combine all chunks + const fullPdf = new Uint8Array(totalBytes); + let pos = 0; + for (const chunk of chunks) { + fullPdf.set(chunk, pos); + pos += chunk.length; + } + + log.info( + `PDF loaded: ${(totalBytes / 1024).toFixed(0)} KB in ${chunks.length} chunks`, + ); + return fullPdf; +} + +// Handle tool result +app.ontoolresult = async (result) => { + log.info("Received tool result:", result); + + const parsed = parseToolResult(result); + if (!parsed) { + showError("Invalid tool result"); + return; + } + + pdfUrl = parsed.url; + pdfTitle = parsed.title; + totalPages = parsed.pageCount; + + // Restore saved page or use initial page + const savedPage = loadSavedPage(); + currentPage = + savedPage && savedPage <= parsed.pageCount ? savedPage : parsed.initialPage; + + log.info( + "URL:", + pdfUrl, + "Pages:", + parsed.pageCount, + "Starting:", + currentPage, + ); + + showLoading("Loading PDF..."); + + try { + pdfBytes = await loadPdfInChunks(pdfUrl); + + showLoading("Rendering PDF..."); + + pdfDocument = await pdfjsLib.getDocument({ data: pdfBytes }).promise; + totalPages = pdfDocument.numPages; + + log.info("PDF loaded, pages:", totalPages); + + showViewer(); + renderPage(); + } catch (err) { + log.error("Error loading PDF:", err); + showError(err instanceof Error ? err.message : String(err)); + } +}; + +app.onerror = (err) => { + log.error("App error:", err); + showError(err instanceof Error ? err.message : String(err)); +}; + +function handleHostContextChanged(ctx: McpUiHostContext) { + log.info("Host context changed:", ctx); + + // Apply safe area insets + if (ctx.safeAreaInsets) { + mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; + mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`; + mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; + mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; + } + + // Log containerDimensions for debugging + if (ctx.containerDimensions) { + log.info("Container dimensions:", ctx.containerDimensions); + } + + // Handle display mode changes + if (ctx.displayMode) { + const wasFullscreen = currentDisplayMode === "fullscreen"; + currentDisplayMode = ctx.displayMode as "inline" | "fullscreen"; + if (ctx.displayMode === "fullscreen") { + mainEl.classList.add("fullscreen"); + log.info("Fullscreen mode enabled"); + } else { + mainEl.classList.remove("fullscreen"); + log.info("Inline mode"); + // When exiting fullscreen, request resize to fit content + if (wasFullscreen && pdfDocument) { + requestFitToContent(); + } + } + updateFullscreenButton(); + } +} + +app.onhostcontextchanged = handleHostContextChanged; + +// Connect to host +app.connect().then(() => { + log.info("Connected to host"); + const ctx = app.getHostContext(); + if (ctx) { + handleHostContextChanged(ctx); + } +}); diff --git a/examples/pdf-server/src/pdf-indexer.ts b/examples/pdf-server/src/pdf-indexer.ts new file mode 100644 index 00000000..9dc1f993 --- /dev/null +++ b/examples/pdf-server/src/pdf-indexer.ts @@ -0,0 +1,57 @@ +/** + * PDF Indexer + */ +import path from "node:path"; +import type { PdfIndex, PdfEntry } from "./types.js"; +import { populatePdfMetadata } from "./pdf-loader.js"; + +/** Check if URL is from arxiv.org */ +export function isArxivUrl(url: string): boolean { + return /^https?:\/\/arxiv\.org\//.test(url); +} + +/** Normalize arxiv URL to PDF format */ +export function normalizeArxivUrl(url: string): string { + return url.replace(/arxiv\.org\/abs\//, "arxiv.org/pdf/"); +} + +/** Check if URL is a file:// URL */ +export function isFileUrl(url: string): boolean { + return url.startsWith("file://"); +} + +/** Convert local path to file:// URL */ +export function toFileUrl(filePath: string): string { + return `file://${path.resolve(filePath)}`; +} + +/** Create a PdfEntry from a URL */ +export function createEntry(url: string): PdfEntry { + return { + url, + metadata: { pageCount: 0, fileSizeBytes: 0 }, + }; +} + +/** Build index from a list of URLs */ +export async function buildPdfIndex(urls: string[]): Promise { + const entries: PdfEntry[] = []; + + for (const url of urls) { + console.error(`[indexer] Loading: ${url}`); + const entry = createEntry(url); + await populatePdfMetadata(entry); + entries.push(entry); + } + + console.error(`[indexer] Indexed ${entries.length} PDFs`); + return { entries }; +} + +/** Find entry by URL */ +export function findEntryByUrl( + index: PdfIndex, + url: string, +): PdfEntry | undefined { + return index.entries.find((e) => e.url === url); +} diff --git a/examples/pdf-server/src/pdf-loader.ts b/examples/pdf-server/src/pdf-loader.ts new file mode 100644 index 00000000..c5c70005 --- /dev/null +++ b/examples/pdf-server/src/pdf-loader.ts @@ -0,0 +1,136 @@ +/** + * PDF Loader - Loads PDFs and extracts content in chunks + * + * Demonstrates: + * - Chunked data loading with size limits + * - HTTP Range requests for streaming + * - Caching for repeated requests + */ +import fs from "node:fs/promises"; +import type { PdfEntry, PdfBytesChunk } from "./types.js"; +import { MAX_CHUNK_BYTES } from "./types.js"; +import { isFileUrl } from "./pdf-indexer.js"; + +// Cache for loaded PDFs +const pdfCache = new Map(); + +// Lazy-load pdfjs +let pdfjs: typeof import("pdfjs-dist"); +async function getPdfjs() { + if (!pdfjs) { + pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); + } + return pdfjs; +} + +// ============================================================================ +// PDF Data Loading +// ============================================================================ + +/** Fetch PDF data (with caching) */ +export async function loadPdfData(entry: PdfEntry): Promise { + const cached = pdfCache.get(entry.url); + if (cached) return cached; + + console.error(`[loader] Fetching: ${entry.url}`); + + let data: Uint8Array; + if (isFileUrl(entry.url)) { + const filePath = entry.url.replace("file://", ""); + data = new Uint8Array(await fs.readFile(filePath)); + } else { + const response = await fetch(entry.url); + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`); + } + data = new Uint8Array(await response.arrayBuffer()); + } + + pdfCache.set(entry.url, data); + return data; +} + +/** Try HTTP Range request for partial content */ +async function fetchRange( + url: string, + start: number, + end: number, +): Promise<{ data: Uint8Array; total: number } | null> { + try { + const res = await fetch(url, { + headers: { Range: `bytes=${start}-${end}` }, + }); + if (res.status !== 206) return null; + + const total = parseInt( + res.headers.get("Content-Range")?.split("/")[1] || "0", + ); + return { data: new Uint8Array(await res.arrayBuffer()), total }; + } catch { + return null; + } +} + +// ============================================================================ +// Chunked Binary Loading (demonstrates size-limited responses) +// ============================================================================ + +export async function loadPdfBytesChunk( + entry: PdfEntry, + offset = 0, + byteCount = MAX_CHUNK_BYTES, +): Promise { + // Try Range request first (streaming without full download) + if (!pdfCache.has(entry.url)) { + const range = await fetchRange(entry.url, offset, offset + byteCount - 1); + if (range) { + return { + url: entry.url, + bytes: Buffer.from(range.data).toString("base64"), + offset, + byteCount: range.data.length, + totalBytes: range.total, + hasMore: offset + range.data.length < range.total, + }; + } + } + + // Fallback: load full PDF and slice + const data = await loadPdfData(entry); + const chunk = data.slice(offset, offset + byteCount); + + return { + url: entry.url, + bytes: Buffer.from(chunk).toString("base64"), + offset, + byteCount: chunk.length, + totalBytes: data.length, + hasMore: offset + chunk.length < data.length, + }; +} + +// ============================================================================ +// Metadata Extraction +// ============================================================================ + +export async function populatePdfMetadata(entry: PdfEntry): Promise { + try { + const lib = await getPdfjs(); + const data = await loadPdfData(entry); + + entry.metadata.fileSizeBytes = data.length; + + const pdf = await lib.getDocument({ data: new Uint8Array(data) }).promise; + entry.metadata.pageCount = pdf.numPages; + + const info = (await pdf.getMetadata()).info as + | Record + | undefined; + if (info?.Title) entry.metadata.title = String(info.Title); + if (info?.Author) entry.metadata.author = String(info.Author); + + await pdf.destroy(); + } catch (err) { + console.error(`[loader] Metadata error: ${err}`); + } +} diff --git a/examples/pdf-server/src/types.ts b/examples/pdf-server/src/types.ts new file mode 100644 index 00000000..efbfdbd2 --- /dev/null +++ b/examples/pdf-server/src/types.ts @@ -0,0 +1,51 @@ +/** + * PDF Server Types - Simplified for didactic purposes + */ +import { z } from "zod"; + +// ============================================================================ +// Core Types +// ============================================================================ + +export const PdfMetadataSchema = z.object({ + title: z.string().optional(), + author: z.string().optional(), + pageCount: z.number(), + fileSizeBytes: z.number(), +}); +export type PdfMetadata = z.infer; + +export const PdfEntrySchema = z.object({ + url: z.string(), // Also serves as unique ID + metadata: PdfMetadataSchema, +}); +export type PdfEntry = z.infer; + +export const PdfIndexSchema = z.object({ + entries: z.array(PdfEntrySchema), +}); +export type PdfIndex = z.infer; + +// ============================================================================ +// Chunked Binary Loading +// ============================================================================ + +/** Max bytes per response chunk */ +export const MAX_CHUNK_BYTES = 500 * 1024; // 500KB + +export const PdfBytesChunkSchema = z.object({ + url: z.string(), + bytes: z.string(), // base64 + offset: z.number(), + byteCount: z.number(), + totalBytes: z.number(), + hasMore: z.boolean(), +}); +export type PdfBytesChunk = z.infer; + +export const ReadPdfBytesInputSchema = z.object({ + url: z.string().describe("PDF URL"), + offset: z.number().min(0).default(0).describe("Byte offset"), + byteCount: z.number().default(MAX_CHUNK_BYTES).describe("Bytes to read"), +}); +export type ReadPdfBytesInput = z.infer; diff --git a/examples/pdf-server/tsconfig.json b/examples/pdf-server/tsconfig.json new file mode 100644 index 00000000..52166b77 --- /dev/null +++ b/examples/pdf-server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "server.ts", "server-utils.ts"] +} diff --git a/examples/pdf-server/vite.config.ts b/examples/pdf-server/vite.config.ts new file mode 100644 index 00000000..2a24d21b --- /dev/null +++ b/examples/pdf-server/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; + +export default defineConfig({ + plugins: [viteSingleFile()], + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + + rollupOptions: { + input: INPUT, + }, + outDir: "dist", + emptyOutDir: false, + }, + optimizeDeps: { + include: ["pdfjs-dist"], + }, +}); diff --git a/package-lock.json b/package-lock.json index e3f14e04..9f548c65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -105,6 +105,8 @@ }, "examples/basic-host/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -142,6 +144,8 @@ }, "examples/basic-server-preact/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -182,6 +186,8 @@ }, "examples/basic-server-react/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -219,6 +225,8 @@ }, "examples/basic-server-solid/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -256,6 +264,8 @@ }, "examples/basic-server-svelte/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -291,6 +301,8 @@ }, "examples/basic-server-vanillajs/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -328,6 +340,8 @@ }, "examples/basic-server-vue/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -364,6 +378,8 @@ }, "examples/budget-allocator-server/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -404,6 +420,8 @@ }, "examples/cohort-heatmap-server/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -440,6 +458,8 @@ }, "examples/customer-segmentation-server/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -477,6 +497,8 @@ }, "examples/integration-server/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -512,6 +534,46 @@ }, "examples/map-server/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "examples/pdf-server": { + "name": "@modelcontextprotocol/server-pdf", + "version": "0.4.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^0.4.0", + "@modelcontextprotocol/sdk": "^1.24.0", + "pdfjs-dist": "^5.0.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "cross-env": "^10.1.0", + "express": "^5.1.0", + "typescript": "^5.9.3", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } + }, + "examples/pdf-server/node_modules/@types/node": { + "version": "22.19.6", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "examples/pdf-server/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -553,6 +615,8 @@ }, "examples/scenario-modeler-server/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -588,6 +652,8 @@ }, "examples/shadertoy-server/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -624,6 +690,8 @@ }, "examples/sheet-music-server/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -661,6 +729,8 @@ }, "examples/system-monitor-server/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -703,6 +773,8 @@ }, "examples/threejs-server/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -739,11 +811,15 @@ }, "examples/transcript-server/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, "examples/transcript-server/node_modules/zod": { "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -781,6 +857,8 @@ }, "examples/video-resource-server/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -818,6 +896,8 @@ }, "examples/wiki-explorer-server/node_modules/undici-types": { "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, @@ -2328,6 +2408,10 @@ "resolved": "examples/map-server", "link": true }, + "node_modules/@modelcontextprotocol/server-pdf": { + "resolved": "examples/pdf-server", + "link": true + }, "node_modules/@modelcontextprotocol/server-scenario-modeler": { "resolved": "examples/scenario-modeler-server", "link": true @@ -2360,6 +2444,256 @@ "resolved": "examples/wiki-explorer-server", "link": true }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz", + "integrity": "sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.88", + "@napi-rs/canvas-darwin-arm64": "0.1.88", + "@napi-rs/canvas-darwin-x64": "0.1.88", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.88", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.88", + "@napi-rs/canvas-linux-arm64-musl": "0.1.88", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.88", + "@napi-rs/canvas-linux-x64-gnu": "0.1.88", + "@napi-rs/canvas-linux-x64-musl": "0.1.88", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.88", + "@napi-rs/canvas-win32-x64-msvc": "0.1.88" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.88.tgz", + "integrity": "sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.88.tgz", + "integrity": "sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.88.tgz", + "integrity": "sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.88.tgz", + "integrity": "sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.88.tgz", + "integrity": "sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.88.tgz", + "integrity": "sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.88.tgz", + "integrity": "sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.88.tgz", + "integrity": "sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.88.tgz", + "integrity": "sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.88.tgz", + "integrity": "sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.88.tgz", + "integrity": "sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@oclif/core": { "version": "4.8.0", "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.8.0.tgz", @@ -2417,9 +2751,9 @@ } }, "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.5.tgz", - "integrity": "sha512-8GvNtMo0NINM7Emk9cNAviCG3teEgr3BUX9be0+GD029zIagx2Sf54jMui1Eu1IpFm7nWHODuLEefGOQNaJ0gQ==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.6.tgz", + "integrity": "sha512-27rypIapNkYboOSylkf1tD9UW9Ado2I+P1NBL46Qz29KmOjTL6WuJ7mHDC5O66CYxlOkF5r93NPDAC3lFHYBXw==", "cpu": [ "arm64" ], @@ -2430,9 +2764,9 @@ ] }, "node_modules/@oven/bun-darwin-x64": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.5.tgz", - "integrity": "sha512-r33eHQOHAwkuiBJIwmkXIyqONQOQMnd1GMTpDzaxx9vf9+svby80LZO9Hcm1ns6KT/TBRFyODC/0loA7FAaffg==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.6.tgz", + "integrity": "sha512-I82xGzPkBxzBKgbl8DsA0RfMQCWTWjNmLjIEkW1ECiv3qK02kHGQ5FGUr/29L/SuvnGsULW4tBTRNZiMzL37nA==", "cpu": [ "x64" ], @@ -2443,9 +2777,9 @@ ] }, "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.5.tgz", - "integrity": "sha512-p5q3rJk48qhLuLBOFehVc+kqCE03YrswTc6NCxbwsxiwfySXwcAvpF2KWKF/ZZObvvR8hCCvqe1F81b2p5r2dg==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.6.tgz", + "integrity": "sha512-nqtr+pTsHqusYpG2OZc6s+AmpWDB/FmBvstrK0y5zkti4OqnCuu7Ev2xNjS7uyb47NrAFF40pWqkpaio5XEd7w==", "cpu": [ "x64" ], @@ -2482,9 +2816,9 @@ ] }, "node_modules/@oven/bun-linux-x64": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.5.tgz", - "integrity": "sha512-n7zhKTSDZS0yOYg5Rq8easZu5Y/o47sv0c7yGr2ciFdcie9uYV55fZ7QMqhWMGK33ezCSikh5EDkUMCIvfWpjA==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.6.tgz", + "integrity": "sha512-egfngj0dfJ868cf30E7B+ye9KUWSebYxOG4l9YP5eWeMXCtenpenx0zdKtAn9qxJgEJym5AN6trtlk+J6x8Lig==", "cpu": [ "x64" ], @@ -2495,9 +2829,9 @@ ] }, "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.5.tgz", - "integrity": "sha512-FeCQyBU62DMuB0nn01vPnf3McXrKOsrK9p7sHaBFYycw0mmoU8kCq/WkBkGMnLuvQljJSyen8QBTx+fXdNupWg==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.6.tgz", + "integrity": "sha512-jRmnX18ak8WzqLrex3siw0PoVKyIeI5AiCv4wJLgSs7VKfOqrPycfHIWfIX2jdn7ngqbHFPzI09VBKANZ4Pckg==", "cpu": [ "x64" ], @@ -2508,9 +2842,9 @@ ] }, "node_modules/@oven/bun-linux-x64-musl": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.5.tgz", - "integrity": "sha512-XkCCHkByYn8BIDvoxnny898znju4xnW2kvFE8FT5+0Y62cWdcBGMZ9RdsEUTeRz16k8hHtJpaSfLcEmNTFIwRQ==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.6.tgz", + "integrity": "sha512-YeXcJ9K6vJAt1zSkeA21J6pTe7PgDMLTHKGI3nQBiMYnYf7Ob3K+b/ChSCznrJG7No5PCPiQPg4zTgA+BOTmSA==", "cpu": [ "x64" ], @@ -2521,9 +2855,9 @@ ] }, "node_modules/@oven/bun-linux-x64-musl-baseline": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.5.tgz", - "integrity": "sha512-TJiYC7KCr0XxFTsxgwQOeE7dncrEL/RSyL0EzSL3xRkrxJMWBCvCSjQn7LV1i6T7hFst0+3KoN3VWvD5BinqHA==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.6.tgz", + "integrity": "sha512-7FjVnxnRTp/AgWqSQRT/Vt9TYmvnZ+4M+d9QOKh/Lf++wIFXFGSeAgD6bV1X/yr2UPVmZDk+xdhr2XkU7l2v3w==", "cpu": [ "x64" ], @@ -2534,9 +2868,9 @@ ] }, "node_modules/@oven/bun-windows-x64": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.5.tgz", - "integrity": "sha512-T3xkODItb/0ftQPFsZDc7EAX2D6A4TEazQ2YZyofZToO8Q7y8YT8ooWdhd0BQiTCd66uEvgE1DCZetynwg2IoA==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.6.tgz", + "integrity": "sha512-Sr1KwUcbB0SEpnSPO22tNJppku2khjFluEst+mTGhxHzAGQTQncNeJxDnt3F15n+p9Q+mlcorxehd68n1siikQ==", "cpu": [ "x64" ], @@ -2547,9 +2881,9 @@ ] }, "node_modules/@oven/bun-windows-x64-baseline": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.5.tgz", - "integrity": "sha512-rtVQB9/1XK8FWJgFtsOthbPifRMYypgJwxu+pK3NHx8WvFKmq7HcPDqNr8xLzGULjQEO7eAo2aOZfONOwYz+5g==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.6.tgz", + "integrity": "sha512-PFUa7JL4lGoyyppeS4zqfuoXXih+gSE0XxhDMrCPVEUev0yhGNd/tbWBvcdpYnUth80owENoGjc8s5Knopv9wA==", "cpu": [ "x64" ], @@ -6652,6 +6986,18 @@ "node": ">= 14.16" } }, + "node_modules/pdfjs-dist": { + "version": "5.4.530", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz", + "integrity": "sha512-r1hWsSIGGmyYUAHR26zSXkxYWLXLMd6AwqcaFYG9YUZ0GBf5GvcjJSeo512tabM4GYFhxhl5pMCmPr7Q72Rq2Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.84" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index e0b4dbba..ee47f11a 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "test:e2e:update": "playwright test --update-snapshots", "test:e2e:ui": "playwright test --ui", "test:e2e:docker": "docker run --rm -v $(pwd):/work -w /work -it mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'npm i -g bun && npm ci && npx playwright test'", - "test:e2e:docker:update": "docker run --rm -v $(pwd):/work -w /work -it mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'npm i -g bun && npm ci && npx playwright test --update-snapshots'", + "test:e2e:docker:update": "npm run build:all && docker run --rm -v $(pwd):/work -w /work -it mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'npm i -g bun && npm ci && npx playwright test --update-snapshots'", "preexamples:build": "npm run build", "examples:build": "bun examples/run-all.ts build", "examples:start": "NODE_ENV=development npm run build && bun examples/run-all.ts start", @@ -64,7 +64,7 @@ "prepare": "node scripts/setup-bun.mjs && npm run build && husky", "docs": "typedoc", "docs:watch": "typedoc --watch", - "generate:screenshots": "docker run --rm -v $(pwd):/work -w /work mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'npm i -g bun && npm ci && npx playwright test tests/e2e/generate-grid-screenshots.spec.ts'", + "generate:screenshots": "npm run build:all && docker run --rm -v $(pwd):/work -w /work mcr.microsoft.com/playwright:v1.57.0-noble sh -c 'npm i -g bun && npm ci && npx playwright test tests/e2e/generate-grid-screenshots.spec.ts'", "prettier": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --check", "prettier:fix": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --write", "check:versions": "node scripts/check-versions.mjs" diff --git a/tests/e2e/generate-grid-screenshots.spec.ts b/tests/e2e/generate-grid-screenshots.spec.ts index c6863044..ce1336ce 100644 --- a/tests/e2e/generate-grid-screenshots.spec.ts +++ b/tests/e2e/generate-grid-screenshots.spec.ts @@ -22,6 +22,7 @@ const DEFAULT_WAIT_MS = 5000; // Extra wait time for slow-loading servers (tiles, etc.) const EXTRA_WAIT_MS: Record = { "map-server": 45000, // CesiumJS needs time for map tiles + "pdf-server": 45000, // Chunked loading of file }; // Servers to skip (screenshots maintained manually) @@ -52,6 +53,7 @@ const SERVERS = [ dir: "customer-segmentation-server", }, { key: "map-server", name: "CesiumJS Map Server", dir: "map-server" }, + { key: "pdf-server", name: "PDF Server", dir: "pdf-server" }, { key: "scenario-modeler", name: "SaaS Scenario Modeler", diff --git a/tests/e2e/servers.spec.ts b/tests/e2e/servers.spec.ts index 5856485e..bf94be86 100644 --- a/tests/e2e/servers.spec.ts +++ b/tests/e2e/servers.spec.ts @@ -48,6 +48,7 @@ const SERVERS = [ { key: "cohort-heatmap", name: "Cohort Heatmap Server" }, { key: "customer-segmentation", name: "Customer Segmentation Server" }, { key: "map-server", name: "CesiumJS Map Server" }, + { key: "pdf-server", name: "PDF Server" }, { key: "scenario-modeler", name: "SaaS Scenario Modeler" }, { key: "shadertoy", name: "ShaderToy Server" }, { key: "sheet-music", name: "Sheet Music Server" }, diff --git a/tests/e2e/servers.spec.ts-snapshots/pdf-server.png b/tests/e2e/servers.spec.ts-snapshots/pdf-server.png new file mode 100644 index 00000000..98643966 Binary files /dev/null and b/tests/e2e/servers.spec.ts-snapshots/pdf-server.png differ