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 @@ ![Screenshot](screenshot.png) -A simple interactive PDF viewer that uses [PDF.js](https://mozilla.github.io/pdf.js/). Launch it w/ a few PDF files and/or URLs as CLI args (+ support loading any additional pdf from arxiv.org). +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=["*"],