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) |
| [](examples/system-monitor-server) | [](examples/transcript-server) | [](examples/video-resource-server) |
| [**System Monitor**](examples/system-monitor-server) | [**Transcript**](examples/transcript-server) | [**Video Resource**](examples/video-resource-server) |
+| [](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
+
+
+
+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
+
+
+
+
+
+
+
+
+
⚠️
+
An error occurred
+
+
+
+
+
+
+
+
+
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