fix: misc fixes for pdf-server, map-server, and say-server (#329)

* fix(map-server): simplify location update message format

* fix(say-server): pass host to streamable_http_app for non-localhost deployments

* refactor(pdf-server): simplify to stateless range-query architecture

- Remove caching/indexing in favor of direct HTTP Range requests
- Use HEAD requests to get file size (no full download)
- Use Range requests to proxy byte chunks (no caching)
- Local files: fs.read with offset/length
- Remote files: HTTP Range header
- Zero memory footprint (no PDF caching)
- Truly stateless (no session management needed)
- Single server.ts (~350 lines) + main.ts CLI entry point
- Instant startup (no preloading)
- Security: whitelist local files, allowed origins

Removed files:
- src/pdf-indexer.ts
- src/pdf-loader.ts
- src/types.ts

* feat(pdf-server): add dark mode support

- Add CSS variables with light-dark() function for automatic theme switching
- Sync theme with host via applyDocumentTheme()
- Apply host CSS variables via applyHostStyleVariables()

* feat(pdf-server): add list_pdfs tool back

Lists local files and allowed remote origins.
Also moves CHUNK_SIZE to module level constant.

* feat(pdf-server): improve tool descriptions and increase context limit

- display_pdf: list all allowed domains in description
- Increase MAX_MODEL_CONTEXT_LENGTH to 15000
- Move CHUNK_SIZE to module level constant

* chore(say-server): remove deployment docs from repo

Keep server.py fix for non-localhost deployments.
Deployment docs kept locally but not in repo.

* refactor(pdf-server): simplify updateModelContext format

- Add widget ID for multi-widget disambiguation
- Use concise header: 'PDF viewer (id) | title | Current Page: X/Y'
- Add 'Page content:' prefix before text
- Keep selection markers (<pdf-selection>...</pdf-selection>)

* refactor: rename widgetUUID to viewUUID for consistency

Aligns with main's standardization on 'view' terminology (PR #325).
Also dynamically generate allowed domains in display_pdf description.

* viewUUID

* fix(map-server): fix syntax error in location update message

* fix(pdf-server): restore package.json, improve server docs

- Restore --external pdfjs-dist in build
- Rewrite server.ts header for newcomers (list tools, not implementation details)
- Minimize main.ts diff

* chore(pdf-server): remove unused addAllowedOrigin/addAllowedLocalFile

* docs(pdf-server): update README for new architecture

* docs(pdf-server): restore full README, add theming section, remove get_pdf_info

* rm

* fix(pdf-server): restore createMcpExpressApp and error handling

- Use createMcpExpressApp for DNS rebinding protection
- Restore error callback in app.listen()

* fix(say-server): pass host to streamable_http_app for non-localhost deployments

* chore(say-server): remove deployment docs from repo

* widgetId->tool id

* fix(map-server): add null check for center in location update

chore(pdf-server): dedupe and sort allowed origins

* fix(map-server): use transparent background for rounded corners

* prettier
This commit is contained in:
Olivier Chafik 2026-01-23 12:35:07 +00:00 • committed by GitHub
commit 023f1fd233
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 452 additions and 495 deletions

View file

@ -14,6 +14,7 @@
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: transparent;
} }
#cesiumContainer { #cesiumContainer {
width: 100%; width: 100%;

View file

@ -404,40 +404,24 @@ function scheduleLocationUpdate(cesiumViewer: any): void {
const center = getCameraCenter(cesiumViewer); const center = getCameraCenter(cesiumViewer);
const extent = getVisibleExtent(cesiumViewer); const extent = getVisibleExtent(cesiumViewer);
if (!extent) { if (!extent || !center) {
log.info("No visible extent (camera looking at sky?)"); log.info("No visible extent or center (camera looking at sky?)");
return; return;
} }
const { widthKm, heightKm } = getScaleDimensions(extent); 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); 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. // Update the model's context with the current map location.
// If the host doesn't support this, the request will silently fail. // 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({ app.updateModelContext({
content: [{ type: "text", text: frontmatter }], content: [{ type: "text", text: content }],
}); });
}, 1500); }, 1500);
} }

View file

@ -2,7 +2,7 @@
![Screenshot](screenshot.png) ![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 ## MCP Client Configuration
@ -29,20 +29,14 @@ Add to your MCP client configuration (stdio transport):
### 1. Chunked Data Through Size-Limited Tool Calls ### 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 ```typescript
// Returns chunks with pagination metadata // Returns chunks with pagination metadata
async function loadPdfBytesChunk(entry, offset, byteCount) { {
return { (bytes, offset, byteCount, totalBytes, hasMore);
bytes: base64Chunk,
offset,
byteCount,
totalBytes,
hasMore: offset + byteCount < totalBytes,
};
} }
``` ```
@ -51,7 +45,7 @@ async function loadPdfBytesChunk(entry, offset, byteCount) {
```typescript ```typescript
// Load in chunks with progress // Load in chunks with progress
while (hasMore) { 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)); chunks.push(base64ToBytes(chunk.bytes));
offset += chunk.byteCount; offset += chunk.byteCount;
hasMore = chunk.hasMore; hasMore = chunk.hasMore;
@ -65,13 +59,12 @@ The viewer keeps the model informed about what the user is seeing:
```typescript ```typescript
app.updateModelContext({ app.updateModelContext({
structuredContent: { content: [
title: pdfTitle, {
currentPage, type: "text",
totalPages, text: `PDF viewer | "${title}" | Current Page: ${page}/${total}\n\nPage content:\n${pageText}`,
pageText: pageText.slice(0, 5000), },
selection: selectedText ? { text, start, end } : undefined, ],
},
}); });
``` ```
@ -101,58 +94,75 @@ The viewer demonstrates opening external links (e.g., to the original arxiv page
titleEl.onclick = () => app.openLink(sourceUrl); 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 ## Usage
```bash ```bash
# Default: loads a sample arxiv paper # 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) # 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 # 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 # 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 # 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 ## Tools
| Tool | Visibility | Purpose | | Tool | Visibility | Purpose |
| ---------------- | ---------- | ---------------------------------- | | ---------------- | ---------- | -------------------------------------- |
| `list_pdfs` | Model | List indexed PDFs | | `list_pdfs` | Model | List available local files and origins |
| `display_pdf` | Model + UI | Display interactive viewer in chat | | `display_pdf` | Model + UI | Display interactive viewer |
| `read_pdf_bytes` | App only | Chunked binary loading | | `read_pdf_bytes` | App only | Stream PDF data in chunks |
## Architecture ## Architecture
``` ```
server.ts # MCP server (233 lines) server.ts # MCP server + tools
├── src/ main.ts # CLI entry point
│ ├── types.ts # Zod schemas (75 lines) src/
│ ├── pdf-indexer.ts # URL-based indexing (44 lines) └── mcp-app.ts # Interactive viewer UI (PDF.js)
│ ├── pdf-loader.ts # Chunked loading (171 lines)
│ └── mcp-app.ts # Interactive viewer UI
``` ```
## Key Patterns Shown ## Key Patterns Shown
| Pattern | Implementation | | Pattern | Implementation |
| ----------------- | ---------------------------------------- | | ----------------- | ------------------------------------------- |
| App-only tools | `_meta: { ui: { visibility: ["app"] } }` | | App-only tools | `_meta: { ui: { visibility: ["app"] } }` |
| Chunked responses | `hasMore` + `offset` pagination | | Chunked responses | `hasMore` + `offset` pagination |
| Model context | `app.updateModelContext()` | | Model context | `app.updateModelContext()` |
| Display modes | `app.requestDisplayMode()` | | Display modes | `app.requestDisplayMode()` |
| External links | `app.openLink()` | | External links | `app.openLink()` |
| Size negotiation | `app.sendSizeChanged()` | | View persistence | `viewUUID` + localStorage |
| Theming | `applyDocumentTheme()` + CSS `light-dark()` |
## Dependencies ## Dependencies
- `pdfjs-dist`: PDF rendering - `pdfjs-dist`: PDF rendering (frontend only)
- `@modelcontextprotocol/ext-apps`: MCP Apps SDK - `@modelcontextprotocol/ext-apps`: MCP Apps SDK

View file

@ -4,18 +4,24 @@
* Or: node dist/index.js [--stdio] [pdf-urls...] * Or: node dist/index.js [--stdio] [pdf-urls...]
*/ */
/** import fs from "node:fs";
* Shared utilities for running MCP servers with Streamable HTTP transport.
*/
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import cors from "cors"; import cors from "cors";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { createServer, initializePdfIndex } from "./server.js"; import {
import { isArxivUrl, toFileUrl, normalizeArxivUrl } from "./src/pdf-indexer.js"; createServer,
isArxivUrl,
isFileUrl,
normalizeArxivUrl,
pathToFileUrl,
fileUrlToPath,
allowedLocalFiles,
allowedRemoteOrigins,
DEFAULT_PDF,
} from "./server.js";
export interface ServerOptions { export interface ServerOptions {
port: number; port: number;
@ -24,9 +30,6 @@ export interface ServerOptions {
/** /**
* Starts an MCP server with Streamable HTTP transport in stateless mode. * 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( export async function startServer(
createServer: () => McpServer, createServer: () => McpServer,
@ -80,8 +83,6 @@ export async function startServer(
process.on("SIGTERM", shutdown); process.on("SIGTERM", shutdown);
} }
const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need
function parseArgs(): { urls: string[]; stdio: boolean } { function parseArgs(): { urls: string[]; stdio: boolean } {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const urls: string[] = []; const urls: string[] = [];
@ -98,7 +99,7 @@ function parseArgs(): { urls: string[]; stdio: boolean } {
!arg.startsWith("https://") && !arg.startsWith("https://") &&
!arg.startsWith("file://") !arg.startsWith("file://")
) { ) {
url = toFileUrl(arg); url = pathToFileUrl(arg);
} else if (isArxivUrl(arg)) { } else if (isArxivUrl(arg)) {
url = normalizeArxivUrl(arg); url = normalizeArxivUrl(arg);
} }
@ -112,9 +113,23 @@ function parseArgs(): { urls: string[]; stdio: boolean } {
async function main() { async function main() {
const { urls, stdio } = parseArgs(); const { urls, stdio } = parseArgs();
console.error(`[pdf-server] Initializing with ${urls.length} PDF(s)...`); // Register local files in whitelist
await initializePdfIndex(urls); for (const url of urls) {
console.error(`[pdf-server] Ready`); 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) { if (stdio) {
await createServer().connect(new StdioServerTransport()); await createServer().connect(new StdioServerTransport());

View file

@ -1,193 +1,347 @@
/** /**
* PDF MCP Server - Didactic Example * PDF MCP Server
* *
* Demonstrates: * An MCP server that displays PDFs in an interactive viewer.
* - Chunked data through size-limited tool responses * Supports local files and remote URLs from academic sources (arxiv, biorxiv, etc).
* - Model context updates (current page text + selection) *
* - Display modes: fullscreen with scrolling vs inline with resize * Tools:
* - External link opening (openLink) * - 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 { import {
registerAppResource, registerAppResource,
registerAppTool, registerAppTool,
RESOURCE_MIME_TYPE, RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server"; } from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { import type {
CallToolResult, CallToolResult,
ReadResourceResult, ReadResourceResult,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod"; import { z } from "zod";
import { randomUUID } from "crypto";
import { // =============================================================================
buildPdfIndex, // Configuration
findEntryByUrl, // =============================================================================
createEntry,
isArxivUrl, export const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need
isFileUrl, export const MAX_CHUNK_BYTES = 512 * 1024; // 512KB max per request
normalizeArxivUrl, export const RESOURCE_URI = "ui://pdf-viewer/mcp-app.html";
} from "./src/pdf-indexer.js";
import { loadPdfBytesChunk, populatePdfMetadata } from "./src/pdf-loader.js"; /** Allowed remote origins (security allowlist) */
import { export const allowedRemoteOrigins = new Set([
ReadPdfBytesInputSchema, "https://agrirxiv.org",
PdfBytesChunkSchema, "https://arxiv.org",
type PdfIndex, "https://chemrxiv.org",
} from "./src/types.js"; "https://edarxiv.org",
"https://engrxiv.org",
"https://hal.science",
"https://osf.io",
"https://psyarxiv.com",
"https://ssrn.com",
"https://www.biorxiv.org",
"https://www.eartharxiv.org",
"https://www.medrxiv.org",
"https://www.preprints.org",
"https://www.researchsquare.com",
"https://www.sportarxiv.org",
"https://zenodo.org",
]);
/** Allowed local file paths (populated from CLI args) */
export const allowedLocalFiles = new Set<string>();
// Works both from source (server.ts) and compiled (dist/server.js) // Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts") const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist") ? path.join(import.meta.dirname, "dist")
: import.meta.dirname; : 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
// =============================================================================
/** export function isFileUrl(url: string): boolean {
* Initialize the PDF index with the given URLs. return url.startsWith("file://");
* Must be called before createServer().
*/
export async function initializePdfIndex(urls: string[]): Promise<void> {
pdfIndex = await buildPdfIndex(urls);
} }
/** export function isArxivUrl(url: string): boolean {
* Creates a new MCP server instance with tools and resources registered. try {
* Each HTTP session needs its own server instance because McpServer only supports one transport. const parsed = new URL(url);
*/ return (
export function createServer(): McpServer { parsed.hostname === "arxiv.org" || parsed.hostname === "www.arxiv.org"
const server = new McpServer({ name: "PDF Server", version: "1.0.0" }); );
} catch {
return false;
}
}
// Tool: list_pdfs export function normalizeArxivUrl(url: string): string {
// Convert arxiv abstract URLs to PDF URLs
// https://arxiv.org/abs/1706.03762 -> https://arxiv.org/pdf/1706.03762
return url.replace("/abs/", "/pdf/").replace(/\.pdf$/, "");
}
export function fileUrlToPath(fileUrl: string): string {
return decodeURIComponent(fileUrl.replace("file://", ""));
}
export function pathToFileUrl(filePath: string): string {
const absolutePath = path.resolve(filePath);
return `file://${encodeURIComponent(absolutePath).replace(/%2F/g, "/")}`;
}
export function validateUrl(url: string): { valid: boolean; error?: string } {
if (isFileUrl(url)) {
const filePath = fileUrlToPath(url);
if (!allowedLocalFiles.has(filePath)) {
return {
valid: false,
error: `Local file not in allowed list: ${filePath}`,
};
}
if (!fs.existsSync(filePath)) {
return { valid: false, error: `File not found: ${filePath}` };
}
return { valid: true };
}
// Remote URL - check against allowed origins
try {
const parsed = new URL(url);
const origin = `${parsed.protocol}//${parsed.hostname}`;
if (
![...allowedRemoteOrigins].some((allowed) => origin.startsWith(allowed))
) {
return { valid: false, error: `Origin not allowed: ${origin}` };
}
return { valid: true };
} catch {
return { valid: false, error: `Invalid URL: ${url}` };
}
}
// =============================================================================
// Range Request Helpers
// =============================================================================
export async function readPdfRange(
url: string,
offset: number,
byteCount: number,
): Promise<{ data: Uint8Array; totalBytes: number }> {
const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
const clampedByteCount = Math.min(byteCount, MAX_CHUNK_BYTES);
if (isFileUrl(normalized)) {
const filePath = fileUrlToPath(normalized);
const stats = await fs.promises.stat(filePath);
const totalBytes = stats.size;
// Clamp to file bounds
const start = Math.min(offset, totalBytes);
const end = Math.min(start + clampedByteCount, totalBytes);
if (start >= totalBytes) {
return { data: new Uint8Array(0), totalBytes };
}
// Read range from local file
const buffer = Buffer.alloc(end - start);
const fd = await fs.promises.open(filePath, "r");
try {
await fd.read(buffer, 0, end - start, start);
} finally {
await fd.close();
}
return { data: new Uint8Array(buffer), totalBytes };
}
// Remote URL - Range request
const response = await fetch(normalized, {
headers: {
Range: `bytes=${offset}-${offset + clampedByteCount - 1}`,
},
});
if (!response.ok && response.status !== 206) {
throw new Error(
`Range request failed: ${response.status} ${response.statusText}`,
);
}
// Parse total size from Content-Range header
const contentRange = response.headers.get("content-range");
let totalBytes = 0;
if (contentRange) {
const match = contentRange.match(/bytes \d+-\d+\/(\d+)/);
if (match) {
totalBytes = parseInt(match[1], 10);
}
}
const data = new Uint8Array(await response.arrayBuffer());
return { data, totalBytes };
}
// =============================================================================
// MCP Server Factory
// =============================================================================
export function createServer(): McpServer {
const server = new McpServer({ name: "PDF Server", version: "2.0.0" });
// Tool: list_pdfs - List available PDFs (local files + allowed origins)
server.tool( server.tool(
"list_pdfs", "list_pdfs",
"List indexed PDFs", "List available PDFs that can be displayed",
{}, {},
async (): Promise<CallToolResult> => { async (): Promise<CallToolResult> => {
if (!pdfIndex) throw new Error("Not initialized"); const pdfs: Array<{ url: string; type: "local" | "remote" }> = [];
// Add local files
for (const filePath of allowedLocalFiles) {
pdfs.push({ url: pathToFileUrl(filePath), type: "local" });
}
// Note: Remote URLs from allowed origins can be loaded dynamically
const text =
pdfs.length > 0
? `Available PDFs:\n${pdfs.map((p) => `- ${p.url} (${p.type})`).join("\n")}\n\nRemote PDFs from ${[...allowedRemoteOrigins].join(", ")} can also be loaded dynamically.`
: `No local PDFs configured. Remote PDFs from ${[...allowedRemoteOrigins].join(", ")} can be loaded dynamically.`;
return { return {
content: [ content: [{ type: "text", text }],
{ type: "text", text: JSON.stringify(pdfIndex.entries, null, 2) }, structuredContent: {
], localFiles: pdfs.filter((p) => p.type === "local").map((p) => p.url),
structuredContent: { entries: pdfIndex.entries }, allowedOrigins: [...allowedRemoteOrigins],
},
}; };
}, },
); );
// Tool: read_pdf_bytes (app-only) - Chunked binary loading // Tool: read_pdf_bytes (app-only) - Range request for chunks
registerAppTool( registerAppTool(
server, server,
"read_pdf_bytes", "read_pdf_bytes",
{ {
title: "Read PDF Bytes", title: "Read PDF Bytes",
description: "Load binary data in chunks", description: "Read a range of bytes from a PDF (max 512KB per request)",
inputSchema: ReadPdfBytesInputSchema.shape, inputSchema: {
outputSchema: PdfBytesChunkSchema, 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"] } }, _meta: { ui: { visibility: ["app"] } },
}, },
async (args: unknown): Promise<CallToolResult> => { async ({ url, offset, byteCount }): Promise<CallToolResult> => {
if (!pdfIndex) throw new Error("Not initialized"); const validation = validateUrl(url);
const { if (!validation.valid) {
url: rawUrl, return {
offset, content: [{ type: "text", text: validation.error! }],
byteCount, isError: true,
} = 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); try {
return { const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
content: [ const { data, totalBytes } = await readPdfRange(url, offset, byteCount);
{
type: "text", // Base64 encode for JSON transport
text: `${chunk.byteCount} bytes at ${chunk.offset}/${chunk.totalBytes}`, 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( registerAppTool(
server, server,
"display_pdf", "display_pdf",
{ {
title: "Display PDF", title: "Display PDF",
description: `Display an interactive PDF viewer in the chat. description: `Display an interactive PDF viewer.
Use this tool when the user asks to view, display, read, or open a PDF. Accepts: Accepts:
- URLs from list_pdfs (preloaded PDFs) - Local files explicitly added to the server (use list_pdfs to see available files)
- Any arxiv.org URL (loaded dynamically) - Remote PDFs from: ${allowedDomains}`,
The viewer supports zoom, navigation, text selection, and fullscreen mode.`,
inputSchema: { inputSchema: {
url: z url: z.string().default(DEFAULT_PDF).describe("PDF URL"),
.string()
.default(DEFAULT_PDF)
.describe("PDF URL (arxiv.org for dynamic loading)"),
page: z.number().min(1).default(1).describe("Initial page"), page: z.number().min(1).default(1).describe("Initial page"),
}, },
outputSchema: z.object({ outputSchema: z.object({
url: z.string(), url: z.string(),
title: z.string().optional(),
pageCount: z.number(),
initialPage: z.number(), initialPage: z.number(),
}), }),
_meta: { ui: { resourceUri: RESOURCE_URI } }, _meta: { ui: { resourceUri: RESOURCE_URI } },
}, },
async ({ url: rawUrl, page }): Promise<CallToolResult> => { async ({ url, page }): Promise<CallToolResult> => {
if (!pdfIndex) throw new Error("Not initialized"); const normalized = isArxivUrl(url) ? normalizeArxivUrl(url) : url;
const validation = validateUrl(normalized);
// Normalize arxiv URLs to PDF format if (!validation.valid) {
const url = isArxivUrl(rawUrl) ? normalizeArxivUrl(rawUrl) : rawUrl; return {
content: [{ type: "text", text: validation.error! }],
let entry = findEntryByUrl(pdfIndex, url); isError: true,
};
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 { return {
content: [ content: [{ type: "text", text: `Displaying PDF: ${normalized}` }],
{ structuredContent: {
type: "text", url: normalized,
text: `Displaying interactive PDF viewer${entry.metadata.title ? ` for "${entry.metadata.title}"` : ""} (${entry.url}, ${entry.metadata.pageCount} pages)`, initialPage: page,
}, },
],
structuredContent: result,
_meta: { _meta: {
viewUUID: randomUUID(), viewUUID: randomUUID(),
}, },
@ -202,7 +356,7 @@ The viewer supports zoom, navigation, text selection, and fullscreen mode.`,
RESOURCE_URI, RESOURCE_URI,
{ mimeType: RESOURCE_MIME_TYPE }, { mimeType: RESOURCE_MIME_TYPE },
async (): Promise<ReadResourceResult> => { async (): Promise<ReadResourceResult> => {
const html = await fs.readFile( const html = await fs.promises.readFile(
path.join(DIST_DIR, "mcp-app.html"), path.join(DIST_DIR, "mcp-app.html"),
"utf-8", "utf-8",
); );

View file

@ -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 { body {
overscroll-behavior-x: none; overscroll-behavior-x: none;
display: flex;
} }
.main { .main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100%; flex: 1;
width: 100%; width: 100%;
background: var(--bg100, #f5f5f5); background: var(--bg100);
color: var(--text000, #1a1a1a); color: var(--text000);
overflow: hidden; /* Prevent scrollbars in inline mode - we request exact size */ overflow: hidden; /* Prevent scrollbars in inline mode - we request exact size */
border-radius: 0.75rem; border-radius: 0.75rem;
border: 1px solid var(--bg200, rgba(0, 0, 0, 0.08)); border: 1px solid var(--bg200);
} }
/* Loading State */ /* Loading State */
@ -28,8 +48,8 @@ body {
.spinner { .spinner {
width: 24px; width: 24px;
height: 24px; height: 24px;
border: 2px solid var(--bg200, #e0e0e0); border: 2px solid var(--bg200);
border-top-color: var(--text100, #888); border-top-color: var(--text100);
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
@ -42,7 +62,7 @@ body {
#loading-text { #loading-text {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text100, #888); color: var(--text100);
} }
/* Progress Bar */ /* Progress Bar */
@ -50,21 +70,21 @@ body {
width: 100%; width: 100%;
max-width: 200px; max-width: 200px;
height: 3px; height: 3px;
background: var(--bg200, #e0e0e0); background: var(--bg200);
border-radius: 2px; border-radius: 2px;
overflow: hidden; overflow: hidden;
} }
.progress-bar { .progress-bar {
height: 100%; height: 100%;
background: var(--text100, #888); background: var(--text100);
width: 0%; width: 0%;
transition: width 0.15s ease-out; transition: width 0.15s ease-out;
} }
.progress-text { .progress-text {
font-size: 0.7rem; font-size: 0.7rem;
color: var(--text200, #aaa); color: var(--text200);
} }
/* Error State */ /* Error State */
@ -84,7 +104,7 @@ body {
} }
#error-message { #error-message {
color: var(--text200, #999); color: var(--text200);
max-width: 400px; max-width: 400px;
} }
@ -102,8 +122,8 @@ body {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--bg000, #ffffff); background: var(--bg000);
border-bottom: 1px solid var(--bg200, #e0e0e0); border-bottom: 1px solid var(--bg200);
flex-shrink: 0; flex-shrink: 0;
gap: 0.5rem; gap: 0.5rem;
height: 48px; height: 48px;
@ -150,17 +170,17 @@ body {
.page-input { .page-input {
width: 50px; width: 50px;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border: 1px solid var(--bg200, #e0e0e0); border: 1px solid var(--bg200);
border-radius: 4px; border-radius: 4px;
font-size: 0.85rem; font-size: 0.85rem;
text-align: center; text-align: center;
background: var(--bg000, #ffffff); background: var(--bg000);
color: var(--text000, #1a1a1a); color: var(--text000);
} }
.page-input:focus { .page-input:focus {
outline: none; outline: none;
border-color: var(--text100, #666); border-color: var(--text100);
} }
.page-input::-webkit-outer-spin-button, .page-input::-webkit-outer-spin-button,
@ -174,7 +194,7 @@ body {
.total-pages { .total-pages {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text100, #666); color: var(--text100);
white-space: nowrap; white-space: nowrap;
} }
@ -186,10 +206,10 @@ body {
justify-content: center; justify-content: center;
width: 32px; width: 32px;
height: 32px; height: 32px;
border: 1px solid var(--bg200, #e0e0e0); border: 1px solid var(--bg200);
border-radius: 4px; border-radius: 4px;
background: var(--bg000, #ffffff); background: var(--bg000);
color: var(--text000, #1a1a1a); color: var(--text000);
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
transition: all 0.15s ease; transition: all 0.15s ease;
@ -198,8 +218,8 @@ body {
.nav-btn:hover:not(:disabled), .nav-btn:hover:not(:disabled),
.zoom-btn:hover:not(:disabled), .zoom-btn:hover:not(:disabled),
.fullscreen-btn:hover { .fullscreen-btn:hover {
background: var(--bg100, #f5f5f5); background: var(--bg100);
border-color: var(--bg300, #ccc); border-color: var(--bg300);
} }
.nav-btn:disabled, .nav-btn:disabled,
@ -210,7 +230,7 @@ body {
.zoom-level { .zoom-level {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text100, #666); color: var(--text100);
min-width: 50px; min-width: 50px;
text-align: center; text-align: center;
} }
@ -223,12 +243,12 @@ body {
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
padding: 1rem; padding: 1rem;
background: var(--bg200, #e0e0e0); background: var(--bg200);
} }
.page-wrapper { .page-wrapper {
position: relative; position: relative;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-page);
background: white; background: white;
} }
@ -262,7 +282,7 @@ body {
} }
.text-layer ::selection { .text-layer ::selection {
background: rgba(0, 0, 255, 0.3); background: var(--selection-bg);
} }
.text-layer > span { .text-layer > span {

View file

@ -6,15 +6,20 @@
* - Text selection via PDF.js TextLayer * - Text selection via PDF.js TextLayer
* - Page navigation, zoom * - 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 type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import * as pdfjsLib from "pdfjs-dist"; import * as pdfjsLib from "pdfjs-dist";
import { TextLayer } from "pdfjs-dist"; import { TextLayer } from "pdfjs-dist";
import "./global.css"; import "./global.css";
import "./mcp-app.css"; import "./mcp-app.css";
// const MAX_MODEL_CONTEXT_LENGTH = 5000; const MAX_MODEL_CONTEXT_LENGTH = 15000;
const MAX_MODEL_CONTEXT_LENGTH = 1500; const CHUNK_SIZE = 500 * 1024; // 500KB chunks
// Configure PDF.js worker // Configure PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
@ -247,7 +252,7 @@ function findSelectionInText(
return undefined; 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() { async function updatePageContext() {
if (!pdfDocument) return; 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( const content = formatPageContent(
pageText, pageText,
MAX_MODEL_CONTEXT_LENGTH, MAX_MODEL_CONTEXT_LENGTH,
selection, selection,
); );
const markdown = `--- // Build context with tool ID for multi-tool disambiguation
title: ${pdfTitle || ""} const toolId = app.getHostContext()?.toolInfo?.id;
url: ${pdfUrl} const header = [
current-page: ${currentPage}/${totalPages} `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) { } catch (err) {
log.error("Error updating context:", err); log.error("Error updating context:", err);
} }
@ -635,7 +642,6 @@ function updateProgress(loaded: number, total: number) {
// Load PDF in chunks with progress // Load PDF in chunks with progress
async function loadPdfInChunks(urlToLoad: string): Promise<Uint8Array> { async function loadPdfInChunks(urlToLoad: string): Promise<Uint8Array> {
const CHUNK_SIZE = 500 * 1024; // 500KB chunks
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
let offset = 0; let offset = 0;
let totalBytes = 0; let totalBytes = 0;
@ -750,6 +756,16 @@ app.onerror = (err) => {
function handleHostContextChanged(ctx: McpUiHostContext) { function handleHostContextChanged(ctx: McpUiHostContext) {
log.info("Host context changed:", ctx); 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 // Apply safe area insets
if (ctx.safeAreaInsets) { if (ctx.safeAreaInsets) {
mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;

View file

@ -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<PdfIndex> {
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);
}

View file

@ -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<string, Uint8Array>();
// 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<Uint8Array> {
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<PdfBytesChunk> {
// 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<void> {
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<string, unknown>
| 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}`);
}
}

View file

@ -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<typeof PdfMetadataSchema>;
export const PdfEntrySchema = z.object({
url: z.string(), // Also serves as unique ID
metadata: PdfMetadataSchema,
});
export type PdfEntry = z.infer<typeof PdfEntrySchema>;
export const PdfIndexSchema = z.object({
entries: z.array(PdfEntrySchema),
});
export type PdfIndex = z.infer<typeof PdfIndexSchema>;
// ============================================================================
// 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<typeof PdfBytesChunkSchema>;
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<typeof ReadPdfBytesInputSchema>;

View file

@ -1429,7 +1429,8 @@ def load_tts_model():
def create_app(): def create_app():
"""Create the ASGI app (for uvicorn reload mode).""" """Create the ASGI app (for uvicorn reload mode)."""
load_tts_model() 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],