diff --git a/examples/map-server/mcp-app.html b/examples/map-server/mcp-app.html
index e46c7ebba..8cba83947 100644
--- a/examples/map-server/mcp-app.html
+++ b/examples/map-server/mcp-app.html
@@ -14,6 +14,7 @@
padding: 0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: transparent;
}
#cesiumContainer {
width: 100%;
diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts
index f6edfa56e..73d7aa168 100644
--- a/examples/map-server/src/mcp-app.ts
+++ b/examples/map-server/src/mcp-app.ts
@@ -404,40 +404,24 @@ function scheduleLocationUpdate(cesiumViewer: any): void {
const center = getCameraCenter(cesiumViewer);
const extent = getVisibleExtent(cesiumViewer);
- if (!extent) {
- log.info("No visible extent (camera looking at sky?)");
+ if (!extent || !center) {
+ log.info("No visible extent or center (camera looking at sky?)");
return;
}
const { widthKm, heightKm } = getScaleDimensions(extent);
-
- log.info(`Extent: ${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km`);
-
- // Get places visible in the extent (samples multiple points for large areas)
const places = await getVisiblePlaces(extent);
- // Build structured markdown with YAML frontmatter (like pdf-server)
- // Note: tool name isn't in the notification protocol, so we hardcode it
- const frontmatter = [
- "---",
- `tool: show-map`,
- center
- ? `center: [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]`
- : null,
- `extent: [${extent.west.toFixed(4)}, ${extent.south.toFixed(4)}, ${extent.east.toFixed(4)}, ${extent.north.toFixed(4)}]`,
- `extent-size: ${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km`,
- places.length > 0 ? `visible-places: [${places.join(", ")}]` : null,
- "---",
- ]
- .filter(Boolean)
- .join("\n");
-
- log.info("Updating model context:", frontmatter);
-
// Update the model's context with the current map location.
// If the host doesn't support this, the request will silently fail.
+ const content = [
+ `The map view of ${app.getHostContext()?.toolInfo?.id} is now ${widthKm.toFixed(1)}km wide × ${heightKm.toFixed(1)}km tall `,
+ `and has changed to the following location: [${places.join(", ")}] `,
+ `lat. / long. of center of map = [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]`,
+ ].join("\n");
+ log.info("Updating model context:", content);
app.updateModelContext({
- content: [{ type: "text", text: frontmatter }],
+ content: [{ type: "text", text: content }],
});
}, 1500);
}
diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md
index bdc83bec0..420fc99ba 100644
--- a/examples/pdf-server/README.md
+++ b/examples/pdf-server/README.md
@@ -2,7 +2,7 @@

-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).
+An interactive PDF viewer using [PDF.js](https://mozilla.github.io/pdf.js/). Supports local files and remote URLs from academic sources (arxiv, biorxiv, zenodo, etc).
## MCP Client Configuration
@@ -29,20 +29,14 @@ Add to your MCP client configuration (stdio transport):
### 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:
+On some host platforms, tool calls have size limits, so large PDFs cannot be sent in a single response. This example streams PDFs in chunks using HTTP Range requests:
-**Server side** (`pdf-loader.ts`):
+**Server side** (`server.ts`):
```typescript
// Returns chunks with pagination metadata
-async function loadPdfBytesChunk(entry, offset, byteCount) {
- return {
- bytes: base64Chunk,
- offset,
- byteCount,
- totalBytes,
- hasMore: offset + byteCount < totalBytes,
- };
+{
+ (bytes, offset, byteCount, totalBytes, hasMore);
}
```
@@ -51,7 +45,7 @@ async function loadPdfBytesChunk(entry, offset, byteCount) {
```typescript
// Load in chunks with progress
while (hasMore) {
- const chunk = await app.callServerTool("read_pdf_bytes", { pdfId, offset });
+ const chunk = await app.callServerTool("read_pdf_bytes", { url, offset });
chunks.push(base64ToBytes(chunk.bytes));
offset += chunk.byteCount;
hasMore = chunk.hasMore;
@@ -65,13 +59,12 @@ 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,
- },
+ content: [
+ {
+ type: "text",
+ text: `PDF viewer | "${title}" | Current Page: ${page}/${total}\n\nPage content:\n${pageText}`,
+ },
+ ],
});
```
@@ -101,58 +94,75 @@ The viewer demonstrates opening external links (e.g., to the original arxiv page
titleEl.onclick = () => app.openLink(sourceUrl);
```
+### 5. View Persistence
+
+Page position is saved per-view using `viewUUID` and localStorage.
+
+### 6. Dark Mode / Theming
+
+The viewer syncs with the host's theme using CSS `light-dark()` and the SDK's theming APIs:
+
+```typescript
+app.onhostcontextchanged = (ctx) => {
+ if (ctx.theme) applyDocumentTheme(ctx.theme);
+ if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
+};
+```
+
## Usage
```bash
# Default: loads a sample arxiv paper
-bun examples/pdf-server/server.ts
+bun examples/pdf-server/main.ts
# Load local files (converted to file:// URLs)
-bun examples/pdf-server/server.ts ./docs/paper.pdf /path/to/thesis.pdf
+bun examples/pdf-server/main.ts ./docs/paper.pdf /path/to/thesis.pdf
# Load from URLs
-bun examples/pdf-server/server.ts https://arxiv.org/pdf/2401.00001.pdf
+bun examples/pdf-server/main.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
+bun examples/pdf-server/main.ts ./local.pdf https://arxiv.org/pdf/2401.00001.pdf
# stdio mode for MCP clients
-bun examples/pdf-server/server.ts --stdio ./papers/
+bun examples/pdf-server/main.ts --stdio ./papers/
```
-**Security**: Dynamic URLs (via `view_pdf` tool) are restricted to arxiv.org. Local files must be in the initial list.
+## Allowed Sources
+
+- **Local files**: Must be passed as CLI arguments
+- **Remote URLs**: arxiv.org, biorxiv.org, medrxiv.org, chemrxiv.org, zenodo.org, osf.io, hal.science, ssrn.com, and more
## 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 |
+| Tool | Visibility | Purpose |
+| ---------------- | ---------- | -------------------------------------- |
+| `list_pdfs` | Model | List available local files and origins |
+| `display_pdf` | Model + UI | Display interactive viewer |
+| `read_pdf_bytes` | App only | Stream PDF data in chunks |
## 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
+server.ts # MCP server + tools
+main.ts # CLI entry point
+src/
+└── mcp-app.ts # Interactive viewer UI (PDF.js)
```
## 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()` |
+| 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()` |
+| View persistence | `viewUUID` + localStorage |
+| Theming | `applyDocumentTheme()` + CSS `light-dark()` |
## Dependencies
-- `pdfjs-dist`: PDF rendering
+- `pdfjs-dist`: PDF rendering (frontend only)
- `@modelcontextprotocol/ext-apps`: MCP Apps SDK
diff --git a/examples/pdf-server/main.ts b/examples/pdf-server/main.ts
index ac0b8ccbf..2b0e3ff9f 100644
--- a/examples/pdf-server/main.ts
+++ b/examples/pdf-server/main.ts
@@ -4,18 +4,24 @@
* Or: node dist/index.js [--stdio] [pdf-urls...]
*/
-/**
- * Shared utilities for running MCP servers with Streamable HTTP transport.
- */
-
+import fs from "node:fs";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
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";
-import { createServer, initializePdfIndex } from "./server.js";
-import { isArxivUrl, toFileUrl, normalizeArxivUrl } from "./src/pdf-indexer.js";
+import {
+ createServer,
+ isArxivUrl,
+ isFileUrl,
+ normalizeArxivUrl,
+ pathToFileUrl,
+ fileUrlToPath,
+ allowedLocalFiles,
+ allowedRemoteOrigins,
+ DEFAULT_PDF,
+} from "./server.js";
export interface ServerOptions {
port: number;
@@ -24,9 +30,6 @@ export interface ServerOptions {
/**
* 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,
@@ -80,8 +83,6 @@ export async function startServer(
process.on("SIGTERM", shutdown);
}
-const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need
-
function parseArgs(): { urls: string[]; stdio: boolean } {
const args = process.argv.slice(2);
const urls: string[] = [];
@@ -98,7 +99,7 @@ function parseArgs(): { urls: string[]; stdio: boolean } {
!arg.startsWith("https://") &&
!arg.startsWith("file://")
) {
- url = toFileUrl(arg);
+ url = pathToFileUrl(arg);
} else if (isArxivUrl(arg)) {
url = normalizeArxivUrl(arg);
}
@@ -112,9 +113,23 @@ function parseArgs(): { urls: string[]; stdio: boolean } {
async function main() {
const { urls, stdio } = parseArgs();
- console.error(`[pdf-server] Initializing with ${urls.length} PDF(s)...`);
- await initializePdfIndex(urls);
- console.error(`[pdf-server] Ready`);
+ // Register local files in whitelist
+ for (const url of urls) {
+ if (isFileUrl(url)) {
+ const filePath = fileUrlToPath(url);
+ if (fs.existsSync(filePath)) {
+ allowedLocalFiles.add(filePath);
+ console.error(`[pdf-server] Registered local file: ${filePath}`);
+ } else {
+ console.error(`[pdf-server] Warning: File not found: ${filePath}`);
+ }
+ }
+ }
+
+ console.error(`[pdf-server] Ready (${urls.length} URL(s) configured)`);
+ console.error(
+ `[pdf-server] Allowed origins: ${[...allowedRemoteOrigins].join(", ")}`,
+ );
if (stdio) {
await createServer().connect(new StdioServerTransport());
diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts
index ae32c44bd..40de9aff4 100644
--- a/examples/pdf-server/server.ts
+++ b/examples/pdf-server/server.ts
@@ -1,193 +1,347 @@
/**
- * PDF MCP Server - Didactic Example
+ * PDF MCP Server
*
- * Demonstrates:
- * - Chunked data through size-limited tool responses
- * - Model context updates (current page text + selection)
- * - Display modes: fullscreen with scrolling vs inline with resize
- * - External link opening (openLink)
+ * An MCP server that displays PDFs in an interactive viewer.
+ * Supports local files and remote URLs from academic sources (arxiv, biorxiv, etc).
+ *
+ * Tools:
+ * - list_pdfs: List available PDFs
+ * - display_pdf: Show interactive PDF viewer
+ * - read_pdf_bytes: Stream PDF data in chunks (used by viewer)
*/
+
+import { randomUUID } from "crypto";
+import fs from "node:fs";
+import path from "node:path";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
-import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type {
CallToolResult,
ReadResourceResult,
} from "@modelcontextprotocol/sdk/types.js";
-import fs from "node:fs/promises";
-import path from "node:path";
import { z } from "zod";
-import { randomUUID } from "crypto";
-import {
- buildPdfIndex,
- findEntryByUrl,
- createEntry,
- isArxivUrl,
- isFileUrl,
- normalizeArxivUrl,
-} from "./src/pdf-indexer.js";
-import { loadPdfBytesChunk, populatePdfMetadata } from "./src/pdf-loader.js";
-import {
- ReadPdfBytesInputSchema,
- PdfBytesChunkSchema,
- type PdfIndex,
-} from "./src/types.js";
+// =============================================================================
+// Configuration
+// =============================================================================
+
+export const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need
+export const MAX_CHUNK_BYTES = 512 * 1024; // 512KB max per request
+export const RESOURCE_URI = "ui://pdf-viewer/mcp-app.html";
+
+/** Allowed remote origins (security allowlist) */
+export const allowedRemoteOrigins = new Set([
+ "https://agrirxiv.org",
+ "https://arxiv.org",
+ "https://chemrxiv.org",
+ "https://edarxiv.org",
+ "https://engrxiv.org",
+ "https://hal.science",
+ "https://osf.io",
+ "https://psyarxiv.com",
+ "https://ssrn.com",
+ "https://www.biorxiv.org",
+ "https://www.eartharxiv.org",
+ "https://www.medrxiv.org",
+ "https://www.preprints.org",
+ "https://www.researchsquare.com",
+ "https://www.sportarxiv.org",
+ "https://zenodo.org",
+]);
+
+/** Allowed local file paths (populated from CLI args) */
+export const allowedLocalFiles = new Set();
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
: import.meta.dirname;
-const RESOURCE_URI = "ui://pdf-viewer/mcp-app.html";
-const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need
-let pdfIndex: PdfIndex | null = null;
+// =============================================================================
+// URL Validation & Normalization
+// =============================================================================
-/**
- * Initialize the PDF index with the given URLs.
- * Must be called before createServer().
- */
-export async function initializePdfIndex(urls: string[]): Promise {
- pdfIndex = await buildPdfIndex(urls);
+export function isFileUrl(url: string): boolean {
+ return url.startsWith("file://");
}
-/**
- * Creates a new MCP server instance with tools and resources registered.
- * Each HTTP session needs its own server instance because McpServer only supports one transport.
- */
+export function isArxivUrl(url: string): boolean {
+ try {
+ const parsed = new URL(url);
+ return (
+ parsed.hostname === "arxiv.org" || parsed.hostname === "www.arxiv.org"
+ );
+ } catch {
+ return false;
+ }
+}
+
+export function normalizeArxivUrl(url: string): string {
+ // Convert arxiv abstract URLs to PDF URLs
+ // https://arxiv.org/abs/1706.03762 -> https://arxiv.org/pdf/1706.03762
+ return url.replace("/abs/", "/pdf/").replace(/\.pdf$/, "");
+}
+
+export function fileUrlToPath(fileUrl: string): string {
+ return decodeURIComponent(fileUrl.replace("file://", ""));
+}
+
+export function pathToFileUrl(filePath: string): string {
+ const absolutePath = path.resolve(filePath);
+ return `file://${encodeURIComponent(absolutePath).replace(/%2F/g, "/")}`;
+}
+
+export function validateUrl(url: string): { valid: boolean; error?: string } {
+ if (isFileUrl(url)) {
+ const filePath = fileUrlToPath(url);
+ if (!allowedLocalFiles.has(filePath)) {
+ return {
+ valid: false,
+ error: `Local file not in allowed list: ${filePath}`,
+ };
+ }
+ if (!fs.existsSync(filePath)) {
+ return { valid: false, error: `File not found: ${filePath}` };
+ }
+ return { valid: true };
+ }
+
+ // Remote URL - check against allowed origins
+ try {
+ const parsed = new URL(url);
+ const origin = `${parsed.protocol}//${parsed.hostname}`;
+ if (
+ ![...allowedRemoteOrigins].some((allowed) => origin.startsWith(allowed))
+ ) {
+ return { valid: false, error: `Origin not allowed: ${origin}` };
+ }
+ return { valid: true };
+ } catch {
+ return { valid: false, error: `Invalid URL: ${url}` };
+ }
+}
+
+// =============================================================================
+// Range Request Helpers
+// =============================================================================
+
+export async function readPdfRange(
+ url: string,
+ offset: number,
+ byteCount: number,
+): Promise<{ data: Uint8Array; totalBytes: number }> {
+ const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
+ const clampedByteCount = Math.min(byteCount, MAX_CHUNK_BYTES);
+
+ if (isFileUrl(normalized)) {
+ const filePath = fileUrlToPath(normalized);
+ const stats = await fs.promises.stat(filePath);
+ const totalBytes = stats.size;
+
+ // Clamp to file bounds
+ const start = Math.min(offset, totalBytes);
+ const end = Math.min(start + clampedByteCount, totalBytes);
+
+ if (start >= totalBytes) {
+ return { data: new Uint8Array(0), totalBytes };
+ }
+
+ // Read range from local file
+ const buffer = Buffer.alloc(end - start);
+ const fd = await fs.promises.open(filePath, "r");
+ try {
+ await fd.read(buffer, 0, end - start, start);
+ } finally {
+ await fd.close();
+ }
+
+ return { data: new Uint8Array(buffer), totalBytes };
+ }
+
+ // Remote URL - Range request
+ const response = await fetch(normalized, {
+ headers: {
+ Range: `bytes=${offset}-${offset + clampedByteCount - 1}`,
+ },
+ });
+
+ if (!response.ok && response.status !== 206) {
+ throw new Error(
+ `Range request failed: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ // Parse total size from Content-Range header
+ const contentRange = response.headers.get("content-range");
+ let totalBytes = 0;
+ if (contentRange) {
+ const match = contentRange.match(/bytes \d+-\d+\/(\d+)/);
+ if (match) {
+ totalBytes = parseInt(match[1], 10);
+ }
+ }
+
+ const data = new Uint8Array(await response.arrayBuffer());
+ return { data, totalBytes };
+}
+
+// =============================================================================
+// MCP Server Factory
+// =============================================================================
+
export function createServer(): McpServer {
- const server = new McpServer({ name: "PDF Server", version: "1.0.0" });
+ const server = new McpServer({ name: "PDF Server", version: "2.0.0" });
- // Tool: list_pdfs
+ // Tool: list_pdfs - List available PDFs (local files + allowed origins)
server.tool(
"list_pdfs",
- "List indexed PDFs",
+ "List available PDFs that can be displayed",
{},
async (): Promise => {
- if (!pdfIndex) throw new Error("Not initialized");
+ const pdfs: Array<{ url: string; type: "local" | "remote" }> = [];
+
+ // Add local files
+ for (const filePath of allowedLocalFiles) {
+ pdfs.push({ url: pathToFileUrl(filePath), type: "local" });
+ }
+
+ // Note: Remote URLs from allowed origins can be loaded dynamically
+ const text =
+ pdfs.length > 0
+ ? `Available PDFs:\n${pdfs.map((p) => `- ${p.url} (${p.type})`).join("\n")}\n\nRemote PDFs from ${[...allowedRemoteOrigins].join(", ")} can also be loaded dynamically.`
+ : `No local PDFs configured. Remote PDFs from ${[...allowedRemoteOrigins].join(", ")} can be loaded dynamically.`;
+
return {
- content: [
- { type: "text", text: JSON.stringify(pdfIndex.entries, null, 2) },
- ],
- structuredContent: { entries: pdfIndex.entries },
+ content: [{ type: "text", text }],
+ structuredContent: {
+ localFiles: pdfs.filter((p) => p.type === "local").map((p) => p.url),
+ allowedOrigins: [...allowedRemoteOrigins],
+ },
};
},
);
- // Tool: read_pdf_bytes (app-only) - Chunked binary loading
+ // Tool: read_pdf_bytes (app-only) - Range request for chunks
registerAppTool(
server,
"read_pdf_bytes",
{
title: "Read PDF Bytes",
- description: "Load binary data in chunks",
- inputSchema: ReadPdfBytesInputSchema.shape,
- outputSchema: PdfBytesChunkSchema,
+ description: "Read a range of bytes from a PDF (max 512KB per request)",
+ inputSchema: {
+ url: z.string().describe("PDF URL"),
+ offset: z.number().min(0).default(0).describe("Byte offset"),
+ byteCount: z
+ .number()
+ .min(1)
+ .max(MAX_CHUNK_BYTES)
+ .default(MAX_CHUNK_BYTES)
+ .describe("Bytes to read"),
+ },
+ outputSchema: z.object({
+ url: z.string(),
+ bytes: z.string().describe("Base64 encoded bytes"),
+ offset: z.number(),
+ byteCount: z.number(),
+ totalBytes: z.number(),
+ hasMore: z.boolean(),
+ }),
_meta: { ui: { visibility: ["app"] } },
},
- async (args: unknown): Promise => {
- if (!pdfIndex) throw new Error("Not initialized");
- const {
- url: rawUrl,
- offset,
- byteCount,
- } = ReadPdfBytesInputSchema.parse(args);
- const url = isArxivUrl(rawUrl) ? normalizeArxivUrl(rawUrl) : rawUrl;
- let entry = findEntryByUrl(pdfIndex, url);
-
- // Dynamically add arxiv URLs (handles server restart between display_pdf and read_pdf_bytes)
- if (!entry) {
- if (isFileUrl(url)) {
- throw new Error("File URLs must be in the initial list");
- }
- if (!isArxivUrl(url)) {
- throw new Error(`PDF not found: ${url}`);
- }
- entry = createEntry(url);
- await populatePdfMetadata(entry);
- pdfIndex.entries.push(entry);
+ async ({ url, offset, byteCount }): Promise => {
+ const validation = validateUrl(url);
+ if (!validation.valid) {
+ return {
+ content: [{ type: "text", text: validation.error! }],
+ isError: true,
+ };
}
- const chunk = await loadPdfBytesChunk(entry, offset, byteCount);
- return {
- content: [
- {
- type: "text",
- text: `${chunk.byteCount} bytes at ${chunk.offset}/${chunk.totalBytes}`,
+ try {
+ const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
+ const { data, totalBytes } = await readPdfRange(url, offset, byteCount);
+
+ // Base64 encode for JSON transport
+ const bytes = Buffer.from(data).toString("base64");
+ const hasMore = offset + data.length < totalBytes;
+
+ return {
+ content: [
+ {
+ type: "text",
+ text: `${data.length} bytes at ${offset}/${totalBytes}`,
+ },
+ ],
+ structuredContent: {
+ url: normalized,
+ bytes,
+ offset,
+ byteCount: data.length,
+ totalBytes,
+ hasMore,
},
- ],
- structuredContent: chunk,
- };
+ };
+ } catch (err) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
+ },
+ ],
+ isError: true,
+ };
+ }
},
);
- // Tool: display_pdf - Interactive viewer with UI
+ // Build allowed domains list for tool description (strip https:// and www.)
+ const allowedDomains = [...allowedRemoteOrigins]
+ .map((origin) => origin.replace(/^https?:\/\/(www\.)?/, ""))
+ .join(", ");
+
+ // Tool: display_pdf - Show interactive viewer
registerAppTool(
server,
"display_pdf",
{
title: "Display PDF",
- description: `Display an interactive PDF viewer in the chat.
-
-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)
+ description: `Display an interactive PDF viewer.
-The viewer supports zoom, navigation, text selection, and fullscreen mode.`,
+Accepts:
+- Local files explicitly added to the server (use list_pdfs to see available files)
+- Remote PDFs from: ${allowedDomains}`,
inputSchema: {
- url: z
- .string()
- .default(DEFAULT_PDF)
- .describe("PDF URL (arxiv.org for dynamic loading)"),
+ url: z.string().default(DEFAULT_PDF).describe("PDF URL"),
page: z.number().min(1).default(1).describe("Initial page"),
},
outputSchema: z.object({
url: z.string(),
- title: z.string().optional(),
- pageCount: z.number(),
initialPage: z.number(),
}),
_meta: { ui: { resourceUri: RESOURCE_URI } },
},
- async ({ url: rawUrl, page }): Promise => {
- if (!pdfIndex) throw new Error("Not initialized");
+ async ({ url, page }): Promise => {
+ const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
+ const validation = validateUrl(normalized);
- // Normalize arxiv URLs to PDF format
- const url = isArxivUrl(rawUrl) ? normalizeArxivUrl(rawUrl) : rawUrl;
-
- let entry = findEntryByUrl(pdfIndex, url);
-
- if (!entry) {
- if (isFileUrl(url)) {
- throw new Error("File URLs must be in the initial list");
- }
- if (!isArxivUrl(url)) {
- throw new Error(`Only arxiv.org URLs can be loaded dynamically`);
- }
-
- entry = createEntry(url);
- await populatePdfMetadata(entry);
- pdfIndex.entries.push(entry);
+ if (!validation.valid) {
+ return {
+ content: [{ type: "text", text: validation.error! }],
+ isError: true,
+ };
}
- const result = {
- url: entry.url,
- title: entry.metadata.title,
- pageCount: entry.metadata.pageCount,
- initialPage: Math.min(page, entry.metadata.pageCount),
- };
-
return {
- content: [
- {
- type: "text",
- text: `Displaying interactive PDF viewer${entry.metadata.title ? ` for "${entry.metadata.title}"` : ""} (${entry.url}, ${entry.metadata.pageCount} pages)`,
- },
- ],
- structuredContent: result,
+ content: [{ type: "text", text: `Displaying PDF: ${normalized}` }],
+ structuredContent: {
+ url: normalized,
+ initialPage: page,
+ },
_meta: {
viewUUID: randomUUID(),
},
@@ -202,7 +356,7 @@ The viewer supports zoom, navigation, text selection, and fullscreen mode.`,
RESOURCE_URI,
{ mimeType: RESOURCE_MIME_TYPE },
async (): Promise => {
- const html = await fs.readFile(
+ const html = await fs.promises.readFile(
path.join(DIST_DIR, "mcp-app.html"),
"utf-8",
);
diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css
index 8242ca041..7600be515 100644
--- a/examples/pdf-server/src/mcp-app.css
+++ b/examples/pdf-server/src/mcp-app.css
@@ -1,17 +1,37 @@
+:root {
+ color-scheme: light dark;
+
+ /* Background colors */
+ --bg000: light-dark(#ffffff, #1a1a1a);
+ --bg100: light-dark(#f5f5f5, #252525);
+ --bg200: light-dark(#e0e0e0, #333333);
+ --bg300: light-dark(#cccccc, #444444);
+
+ /* Text colors */
+ --text000: light-dark(#1a1a1a, #f0f0f0);
+ --text100: light-dark(#666666, #aaaaaa);
+ --text200: light-dark(#999999, #888888);
+
+ /* Shadows */
+ --shadow-page: light-dark(0 2px 8px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.4));
+ --selection-bg: light-dark(rgba(0, 0, 255, 0.3), rgba(100, 150, 255, 0.4));
+}
+
body {
overscroll-behavior-x: none;
+ display: flex;
}
.main {
display: flex;
flex-direction: column;
- min-height: 100%;
+ flex: 1;
width: 100%;
- background: var(--bg100, #f5f5f5);
- color: var(--text000, #1a1a1a);
+ background: var(--bg100);
+ color: var(--text000);
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));
+ border: 1px solid var(--bg200);
}
/* Loading State */
@@ -28,8 +48,8 @@ body {
.spinner {
width: 24px;
height: 24px;
- border: 2px solid var(--bg200, #e0e0e0);
- border-top-color: var(--text100, #888);
+ border: 2px solid var(--bg200);
+ border-top-color: var(--text100);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@@ -42,7 +62,7 @@ body {
#loading-text {
font-size: 0.8rem;
- color: var(--text100, #888);
+ color: var(--text100);
}
/* Progress Bar */
@@ -50,21 +70,21 @@ body {
width: 100%;
max-width: 200px;
height: 3px;
- background: var(--bg200, #e0e0e0);
+ background: var(--bg200);
border-radius: 2px;
overflow: hidden;
}
.progress-bar {
height: 100%;
- background: var(--text100, #888);
+ background: var(--text100);
width: 0%;
transition: width 0.15s ease-out;
}
.progress-text {
font-size: 0.7rem;
- color: var(--text200, #aaa);
+ color: var(--text200);
}
/* Error State */
@@ -84,7 +104,7 @@ body {
}
#error-message {
- color: var(--text200, #999);
+ color: var(--text200);
max-width: 400px;
}
@@ -102,8 +122,8 @@ body {
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
- background: var(--bg000, #ffffff);
- border-bottom: 1px solid var(--bg200, #e0e0e0);
+ background: var(--bg000);
+ border-bottom: 1px solid var(--bg200);
flex-shrink: 0;
gap: 0.5rem;
height: 48px;
@@ -150,17 +170,17 @@ body {
.page-input {
width: 50px;
padding: 0.25rem 0.5rem;
- border: 1px solid var(--bg200, #e0e0e0);
+ border: 1px solid var(--bg200);
border-radius: 4px;
font-size: 0.85rem;
text-align: center;
- background: var(--bg000, #ffffff);
- color: var(--text000, #1a1a1a);
+ background: var(--bg000);
+ color: var(--text000);
}
.page-input:focus {
outline: none;
- border-color: var(--text100, #666);
+ border-color: var(--text100);
}
.page-input::-webkit-outer-spin-button,
@@ -174,7 +194,7 @@ body {
.total-pages {
font-size: 0.85rem;
- color: var(--text100, #666);
+ color: var(--text100);
white-space: nowrap;
}
@@ -186,10 +206,10 @@ body {
justify-content: center;
width: 32px;
height: 32px;
- border: 1px solid var(--bg200, #e0e0e0);
+ border: 1px solid var(--bg200);
border-radius: 4px;
- background: var(--bg000, #ffffff);
- color: var(--text000, #1a1a1a);
+ background: var(--bg000);
+ color: var(--text000);
cursor: pointer;
font-size: 1rem;
transition: all 0.15s ease;
@@ -198,8 +218,8 @@ body {
.nav-btn:hover:not(:disabled),
.zoom-btn:hover:not(:disabled),
.fullscreen-btn:hover {
- background: var(--bg100, #f5f5f5);
- border-color: var(--bg300, #ccc);
+ background: var(--bg100);
+ border-color: var(--bg300);
}
.nav-btn:disabled,
@@ -210,7 +230,7 @@ body {
.zoom-level {
font-size: 0.8rem;
- color: var(--text100, #666);
+ color: var(--text100);
min-width: 50px;
text-align: center;
}
@@ -223,12 +243,12 @@ body {
justify-content: center;
align-items: flex-start;
padding: 1rem;
- background: var(--bg200, #e0e0e0);
+ background: var(--bg200);
}
.page-wrapper {
position: relative;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ box-shadow: var(--shadow-page);
background: white;
}
@@ -262,7 +282,7 @@ body {
}
.text-layer ::selection {
- background: rgba(0, 0, 255, 0.3);
+ background: var(--selection-bg);
}
.text-layer > span {
diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts
index 6cb41928d..7c190a79e 100644
--- a/examples/pdf-server/src/mcp-app.ts
+++ b/examples/pdf-server/src/mcp-app.ts
@@ -6,15 +6,20 @@
* - Text selection via PDF.js TextLayer
* - Page navigation, zoom
*/
-import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
+import {
+ App,
+ type McpUiHostContext,
+ applyDocumentTheme,
+ applyHostStyleVariables,
+} 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";
-// const MAX_MODEL_CONTEXT_LENGTH = 5000;
-const MAX_MODEL_CONTEXT_LENGTH = 1500;
+const MAX_MODEL_CONTEXT_LENGTH = 15000;
+const CHUNK_SIZE = 500 * 1024; // 500KB chunks
// Configure PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
@@ -247,7 +252,7 @@ function findSelectionInText(
return undefined;
}
-// Extract text from current page and update model context as markdown
+// Extract text from current page and update model context
async function updatePageContext() {
if (!pdfDocument) return;
@@ -276,22 +281,24 @@ async function updatePageContext() {
);
}
- // Format content with selection and truncation
+ // Format content with selection markers and truncation
const content = formatPageContent(
pageText,
MAX_MODEL_CONTEXT_LENGTH,
selection,
);
- const markdown = `---
-title: ${pdfTitle || ""}
-url: ${pdfUrl}
-current-page: ${currentPage}/${totalPages}
----
+ // Build context with tool ID for multi-tool disambiguation
+ const toolId = app.getHostContext()?.toolInfo?.id;
+ const header = [
+ `PDF viewer${toolId ? ` (${toolId})` : ""}`,
+ pdfTitle ? `"${pdfTitle}"` : pdfUrl,
+ `Current Page: ${currentPage}/${totalPages}`,
+ ].join(" | ");
-${content}`;
+ const contextText = `${header}\n\nPage content:\n${content}`;
- app.updateModelContext({ content: [{ type: "text", text: markdown }] });
+ app.updateModelContext({ content: [{ type: "text", text: contextText }] });
} catch (err) {
log.error("Error updating context:", err);
}
@@ -635,7 +642,6 @@ function updateProgress(loaded: number, total: number) {
// 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;
@@ -750,6 +756,16 @@ app.onerror = (err) => {
function handleHostContextChanged(ctx: McpUiHostContext) {
log.info("Host context changed:", ctx);
+ // Apply theme from host
+ if (ctx.theme) {
+ applyDocumentTheme(ctx.theme);
+ }
+
+ // Apply host CSS variables
+ if (ctx.styles?.variables) {
+ applyHostStyleVariables(ctx.styles.variables);
+ }
+
// Apply safe area insets
if (ctx.safeAreaInsets) {
mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
diff --git a/examples/pdf-server/src/pdf-indexer.ts b/examples/pdf-server/src/pdf-indexer.ts
deleted file mode 100644
index 9dc1f9933..000000000
--- a/examples/pdf-server/src/pdf-indexer.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * 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
deleted file mode 100644
index c5c700052..000000000
--- a/examples/pdf-server/src/pdf-loader.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-/**
- * 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
deleted file mode 100644
index efbfdbd29..000000000
--- a/examples/pdf-server/src/types.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-/**
- * 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/say-server/server.py b/examples/say-server/server.py
index 02ae45ad2..3cad71fc0 100755
--- a/examples/say-server/server.py
+++ b/examples/say-server/server.py
@@ -1429,7 +1429,8 @@ def load_tts_model():
def create_app():
"""Create the ASGI app (for uvicorn reload mode)."""
load_tts_model()
- app = mcp.streamable_http_app(stateless_http=True)
+ # Pass host=HOST to disable DNS rebinding protection for non-localhost deployments
+ app = mcp.streamable_http_app(stateless_http=True, host=HOST)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],