Skip to content

Commit 96daa46

Browse files
authored
examples: add PDF viewer w/ chunked data loading, full-screen, model context updates, private tool (#267)
* feat(pdf-server): Interactive PDF viewer example PDF viewer with PDF.js featuring: - Chunked binary loading with progress bar - Text extraction for AI context - arXiv paper support (fetch by ID) - Page navigation with keyboard shortcuts - Zoom controls (including Ctrl+0 reset) - Fullscreen mode support - Horizontal swipe for page changes (disabled when zoomed) - Page persistence in localStorage - Text selection via PDF.js TextLayer - Clickable title link to source URL - Rounded corners and subtle border styling * chore: Add pdf-server to screenshot generation list * refactor(pdf-server): Simplify and generalize PDF loading - Accept any HTTP(s) URLs instead of ArXiv-only - Use HTTP Range requests for chunked binary loading - Remove ArXiv-specific code (arxiv.ts, metadata fetching) - Remove CLAUDE.md index generation - Flatten hierarchical folder structure to simple entries list - Remove dead code: getPdfSummary, httpFileSizes - Simplify base64 encoding using Buffer - Simplify chunk extraction using slice() - Consolidate DEFAULT_PDF_URL constant The server now works with any PDF URL, not just arXiv papers. HTTP Range requests stream chunks on-demand when supported. * feat(pdf-server): Include title and selection in model context - Add pdfTitle to updateModelContext structuredContent - Include selection position (text, start, end) when text is selected - Add debounced selectionchange listener to update context on selection * fix(pdf-server): Restore default URL in view_pdf schema The UI needs the default value in the schema to show it properly. * refactor(pdf-server): Further simplifications - Remove hard-coded test paths from main() - Remove unused resources: pdfs://metadata/{pdfId}, pdfs://content/{pdfId} - Remove unused metadata fields: subject, creator, producer, creationDate, modDate - Remove unused entry fields: relativePath, estimatedTextSize - Remove filterEntriesByFolder and folder filter from list_pdfs - Remove redundant output schema validation (trust typed returns) - Simplify scanDirectory and createLocalEntry signatures Total: 1836 → 1666 lines (-170 lines, -9%) * refactor(pdf-server): Major simplification for didactic focus Simplified the example to focus on key MCP Apps SDK patterns: - Chunked data through size-limited tool calls - Model context updates (page text + selection) - Display modes (fullscreen vs inline) - External links (openLink) Changes: - Remove local file support (HTTP URLs only) - Restrict dynamic URLs to arxiv.org for security - Simplify types: url instead of sourcePath/sourceType - Simplify indexer: 168 → 44 lines - Simplify loader: 318 → 171 lines - Simplify server: 337 → 233 lines - Fix selection text normalization - Rewrite README with didactic focus Total: 1836 → 1236 lines (-33%) * feat(pdf-server): Add file:// URL support for local files - Local paths are converted to file:// URLs on startup - file:// URLs must be in the initial list (strict validation) - Dynamic URLs still restricted to arxiv.org only - Updated README with local file examples * fix(pdf-server): Improve selection detection with logging - Add logging to selectionchange handler to verify it fires - Add fallback matching without spaces (TextLayer spans may lack spaces) - Log selection detection success/failure for debugging The issue: PDF.js TextLayer renders text as positioned spans without space characters between them. When selecting across spans: - pageText has spaces (items joined with ' ') - sel.toString() may not have spaces - indexOf fails to match The fix tries exact match first, then falls back to spaceless matching. * feat(pdf-server): Format model context as markdown with front matter Model context now looks like: ```markdown --- url: https://arxiv.org/pdf/... page: 5/144 --- Page text with <pdf-selection>selected text</pdf-selection> inline. ``` This is cleaner for the model to parse and includes the source URL. * refactor(pdf-server): Extract smart truncation helpers Added two well-designed helpers: formatPageContent(text, maxLength, selection?) - Centers truncation window around selection if present - Adds <truncated-content/> markers at elision points - Wraps selection in <pdf-selection> tags - Allocates 60% context before, 40% after for readability findSelectionInText(pageText, selectedText) - Tries exact match first - Falls back to spaceless match for TextLayer quirks - Returns { start, end } or undefined Example output with selection: ``` <truncated-content/> ...context before... <pdf-selection>selected text</pdf-selection> ...context after... <truncated-content/> ``` * fix(pdf-server): Truncate inside selection tags when selection too long When selection is too large for the budget: <truncated-content/><pdf-selection><truncated-content/>start...end<truncated-content/></pdf-selection><truncated-content/> This keeps the selection structure intact while showing beginning and end. * refactor(pdf-server): Remove unused read_pdf_text, use Attention paper as default - Remove read_pdf_text tool (viewer extracts text client-side with pdfjs) - Remove PdfTextChunk and ReadPdfTextInput types - Remove loadPdfTextChunk from pdf-loader - Change default PDF to 'Attention Is All You Need' (1706.03762) - Update README with modest language * refactor(pdf-server): Simplify to use URL as ID, rename view_pdf to display_pdf Major simplifications: - Use URL directly as identifier (no hashing) - Remove displayName - show elided URL with full URL as tooltip - Rename view_pdf to display_pdf with better description - Update all references from pdfId to url - Simplify storage key and model context The tool description now explains it displays an interactive viewer in the chat. * feat(pdf-server): Normalize arxiv URLs to PDF format arxiv.org/abs/... -> arxiv.org/pdf/... Applied both at startup and when loading dynamic URLs. * docs(pdf-server): Add prompt engineering to display_pdf description * fix(pdf-server): Sharp rendering on retina displays Account for devicePixelRatio when rendering canvas: - Scale canvas dimensions by dpr - Scale context by dpr - Keep CSS size at logical pixels * fix(pdf-server): Normalize arxiv URLs in read_pdf_bytes too * add to e2e spec * add to e2e spec * add to e2e spec * add to e2e spec * regen * chore: regenerate package-lock.json and fix hono vulnerability * docs: add pdf-server screenshot to READMEs * regen * ci: add missing examples to pkg-pr-new publish * ci: add pdf-server to npm publish examples * Update README.md * pdf-server: improve tool response text for better model context * revert unrelated screenshot changes * pdf-server: dynamically add arxiv URLs in read_pdf_bytes Fixes 'PDF not found' error when server restarts between display_pdf (which adds the entry) and read_pdf_bytes (which previously only looked up existing entries). Now read_pdf_bytes mirrors display_pdf's logic and dynamically adds arxiv URLs to the index.
1 parent 63b05b4 commit 96daa46

23 files changed

Lines changed: 2358 additions & 29 deletions

.github/workflows/npm-publish.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ jobs:
108108
- cohort-heatmap-server
109109
- customer-segmentation-server
110110
- map-server
111+
- pdf-server
111112
- scenario-modeler-server
112113
- shadertoy-server
113114
- sheet-music-server

.github/workflows/publish.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,13 @@ jobs:
2929
./examples/budget-allocator-server \
3030
./examples/cohort-heatmap-server \
3131
./examples/customer-segmentation-server \
32+
./examples/map-server \
33+
./examples/pdf-server \
3234
./examples/scenario-modeler-server \
35+
./examples/shadertoy-server \
36+
./examples/sheet-music-server \
3337
./examples/system-monitor-server \
3438
./examples/threejs-server \
39+
./examples/transcript-server \
40+
./examples/video-resource-server \
3541
./examples/wiki-explorer-server

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ Or edit your `package.json` manually:
6767
| [**Scenario Modeler**](examples/scenario-modeler-server) | [**Budget Allocator**](examples/budget-allocator-server) | [**Customer Segmentation**](examples/customer-segmentation-server) |
6868
| [![System Monitor](examples/system-monitor-server/grid-cell.png "Real-time OS metrics")](examples/system-monitor-server) | [![Transcript](examples/transcript-server/grid-cell.png "Live speech transcription")](examples/transcript-server) | [![Video Resource](examples/video-resource-server/grid-cell.png "Binary video via MCP resources")](examples/video-resource-server) |
6969
| [**System Monitor**](examples/system-monitor-server) | [**Transcript**](examples/transcript-server) | [**Video Resource**](examples/video-resource-server) |
70+
| [![PDF Server](examples/pdf-server/grid-cell.png "Interactive PDF viewer with chunked loading")](examples/pdf-server) | | |
71+
| [**PDF Server**](examples/pdf-server) | | |
7072

7173
### Starter Templates
7274

examples/pdf-server/README.md

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# PDF Server
2+
3+
![Screenshot](screenshot.png)
4+
5+
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).
6+
7+
## What This Example Demonstrates
8+
9+
### 1. Chunked Data Through Size-Limited Tool Calls
10+
11+
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:
12+
13+
**Server side** (`pdf-loader.ts`):
14+
15+
```typescript
16+
// Returns chunks with pagination metadata
17+
async function loadPdfBytesChunk(entry, offset, byteCount) {
18+
return {
19+
bytes: base64Chunk,
20+
offset,
21+
byteCount,
22+
totalBytes,
23+
hasMore: offset + byteCount < totalBytes,
24+
};
25+
}
26+
```
27+
28+
**Client side** (`mcp-app.ts`):
29+
30+
```typescript
31+
// Load in chunks with progress
32+
while (hasMore) {
33+
const chunk = await app.callServerTool("read_pdf_bytes", { pdfId, offset });
34+
chunks.push(base64ToBytes(chunk.bytes));
35+
offset += chunk.byteCount;
36+
hasMore = chunk.hasMore;
37+
updateProgress(offset, chunk.totalBytes);
38+
}
39+
```
40+
41+
### 2. Model Context Updates
42+
43+
The viewer keeps the model informed about what the user is seeing:
44+
45+
```typescript
46+
app.updateModelContext({
47+
structuredContent: {
48+
title: pdfTitle,
49+
currentPage,
50+
totalPages,
51+
pageText: pageText.slice(0, 5000),
52+
selection: selectedText ? { text, start, end } : undefined,
53+
},
54+
});
55+
```
56+
57+
This enables the model to answer questions about the current page or selected text.
58+
59+
### 3. Display Modes: Fullscreen vs Inline
60+
61+
- **Inline mode**: App requests height changes to fit content
62+
- **Fullscreen mode**: App fills the screen with internal scrolling
63+
64+
```typescript
65+
// Request fullscreen
66+
app.requestDisplayMode({ mode: "fullscreen" });
67+
68+
// Listen for mode changes
69+
app.ondisplaymodechange = (mode) => {
70+
if (mode === "fullscreen") enableScrolling();
71+
else disableScrolling();
72+
};
73+
```
74+
75+
### 4. External Links (openLink)
76+
77+
The viewer demonstrates opening external links (e.g., to the original arxiv page):
78+
79+
```typescript
80+
titleEl.onclick = () => app.openLink(sourceUrl);
81+
```
82+
83+
## Usage
84+
85+
```bash
86+
# Default: loads a sample arxiv paper
87+
bun examples/pdf-server/server.ts
88+
89+
# Load local files (converted to file:// URLs)
90+
bun examples/pdf-server/server.ts ./docs/paper.pdf /path/to/thesis.pdf
91+
92+
# Load from URLs
93+
bun examples/pdf-server/server.ts https://arxiv.org/pdf/2401.00001.pdf
94+
95+
# Mix local and remote
96+
bun examples/pdf-server/server.ts ./local.pdf https://arxiv.org/pdf/2401.00001.pdf
97+
98+
# stdio mode for MCP clients
99+
bun examples/pdf-server/server.ts --stdio ./papers/
100+
```
101+
102+
**Security**: Dynamic URLs (via `view_pdf` tool) are restricted to arxiv.org. Local files must be in the initial list.
103+
104+
## Tools
105+
106+
| Tool | Visibility | Purpose |
107+
| ---------------- | ---------- | ---------------------------------- |
108+
| `list_pdfs` | Model | List indexed PDFs |
109+
| `display_pdf` | Model + UI | Display interactive viewer in chat |
110+
| `read_pdf_bytes` | App only | Chunked binary loading |
111+
112+
## Architecture
113+
114+
```
115+
server.ts # MCP server (233 lines)
116+
├── src/
117+
│ ├── types.ts # Zod schemas (75 lines)
118+
│ ├── pdf-indexer.ts # URL-based indexing (44 lines)
119+
│ ├── pdf-loader.ts # Chunked loading (171 lines)
120+
│ └── mcp-app.ts # Interactive viewer UI
121+
```
122+
123+
## Key Patterns Shown
124+
125+
| Pattern | Implementation |
126+
| ----------------- | ---------------------------------------- |
127+
| App-only tools | `_meta: { ui: { visibility: ["app"] } }` |
128+
| Chunked responses | `hasMore` + `offset` pagination |
129+
| Model context | `app.updateModelContext()` |
130+
| Display modes | `app.requestDisplayMode()` |
131+
| External links | `app.openLink()` |
132+
| Size negotiation | `app.sendSizeChanged()` |
133+
134+
## Dependencies
135+
136+
- `pdfjs-dist`: PDF rendering
137+
- `@modelcontextprotocol/ext-apps`: MCP Apps SDK

examples/pdf-server/grid-cell.png

29.9 KB
Loading

examples/pdf-server/mcp-app.html

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>PDF Viewer</title>
7+
</head>
8+
<body>
9+
<div class="main">
10+
<!-- Loading State -->
11+
<div id="loading" class="loading">
12+
<div class="spinner"></div>
13+
<p id="loading-text">Loading PDF...</p>
14+
<div id="progress-container" class="progress-container" style="display: none">
15+
<div id="progress-bar" class="progress-bar"></div>
16+
</div>
17+
<p id="progress-text" class="progress-text"></p>
18+
</div>
19+
20+
<!-- Error State -->
21+
<div id="error" class="error" style="display: none">
22+
<div class="error-icon">⚠️</div>
23+
<p id="error-message">An error occurred</p>
24+
</div>
25+
26+
<!-- PDF Viewer -->
27+
<div id="viewer" class="viewer" style="display: none">
28+
<!-- Toolbar -->
29+
<div class="toolbar">
30+
<div class="toolbar-left">
31+
<span id="pdf-title" class="pdf-title">Document</span>
32+
</div>
33+
<div class="toolbar-center">
34+
<button id="prev-btn" class="nav-btn" title="Previous page (←)">
35+
36+
</button>
37+
<div class="page-nav">
38+
<input
39+
id="page-input"
40+
type="number"
41+
class="page-input"
42+
min="1"
43+
value="1"
44+
title="Go to page"
45+
/>
46+
<span id="total-pages" class="total-pages">of 1</span>
47+
</div>
48+
<button id="next-btn" class="nav-btn" title="Next page (→)">
49+
50+
</button>
51+
</div>
52+
<div class="toolbar-right">
53+
<button id="zoom-out-btn" class="zoom-btn" title="Zoom out (-)">
54+
55+
</button>
56+
<span id="zoom-level" class="zoom-level">100%</span>
57+
<button id="zoom-in-btn" class="zoom-btn" title="Zoom in (+)">
58+
+
59+
</button>
60+
<button id="fullscreen-btn" class="fullscreen-btn" title="Toggle fullscreen">
61+
62+
</button>
63+
</div>
64+
</div>
65+
66+
<!-- Single Page Canvas Container -->
67+
<div class="canvas-container">
68+
<div class="page-wrapper">
69+
<canvas id="pdf-canvas"></canvas>
70+
<div id="text-layer" class="text-layer"></div>
71+
</div>
72+
</div>
73+
</div>
74+
</div>
75+
76+
<script type="module" src="./src/mcp-app.ts"></script>
77+
</body>
78+
</html>

examples/pdf-server/package.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "@modelcontextprotocol/server-pdf",
3+
"version": "0.4.0",
4+
"type": "module",
5+
"description": "MCP server for loading and extracting text from PDF files with chunked pagination and interactive viewer",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/modelcontextprotocol/ext-apps",
9+
"directory": "examples/pdf-server"
10+
},
11+
"license": "MIT",
12+
"main": "server.ts",
13+
"files": [
14+
"server.ts",
15+
"server-utils.ts",
16+
"src",
17+
"dist"
18+
],
19+
"scripts": {
20+
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build",
21+
"watch": "cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch",
22+
"serve": "bun --watch server.ts",
23+
"serve:http": "bun --watch server.ts",
24+
"dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'",
25+
"start": "npm run serve"
26+
},
27+
"dependencies": {
28+
"@modelcontextprotocol/ext-apps": "^0.4.0",
29+
"@modelcontextprotocol/sdk": "^1.24.0",
30+
"pdfjs-dist": "^5.0.0",
31+
"zod": "^4.1.13"
32+
},
33+
"devDependencies": {
34+
"@types/cors": "^2.8.19",
35+
"@types/express": "^5.0.0",
36+
"@types/node": "^22.0.0",
37+
"concurrently": "^9.2.1",
38+
"cors": "^2.8.5",
39+
"cross-env": "^10.1.0",
40+
"express": "^5.1.0",
41+
"typescript": "^5.9.3",
42+
"vite": "^6.0.0",
43+
"vite-plugin-singlefile": "^2.3.0"
44+
}
45+
}

examples/pdf-server/screenshot.png

66.8 KB
Loading
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Shared utilities for running MCP servers with Streamable HTTP transport.
3+
*/
4+
5+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
6+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8+
import cors from "cors";
9+
import type { Request, Response } from "express";
10+
11+
export interface ServerOptions {
12+
port: number;
13+
name?: string;
14+
}
15+
16+
/**
17+
* Starts an MCP server with Streamable HTTP transport in stateless mode.
18+
*
19+
* @param createServer - Factory function that creates a new McpServer instance per request.
20+
* @param options - Server configuration options.
21+
*/
22+
export async function startServer(
23+
createServer: () => McpServer,
24+
options: ServerOptions,
25+
): Promise<void> {
26+
const { port, name = "MCP Server" } = options;
27+
28+
const app = createMcpExpressApp({ host: "0.0.0.0" });
29+
app.use(cors());
30+
31+
app.all("/mcp", async (req: Request, res: Response) => {
32+
const server = createServer();
33+
const transport = new StreamableHTTPServerTransport({
34+
sessionIdGenerator: undefined,
35+
});
36+
37+
res.on("close", () => {
38+
transport.close().catch(() => {});
39+
server.close().catch(() => {});
40+
});
41+
42+
try {
43+
await server.connect(transport);
44+
await transport.handleRequest(req, res, req.body);
45+
} catch (error) {
46+
console.error("MCP error:", error);
47+
if (!res.headersSent) {
48+
res.status(500).json({
49+
jsonrpc: "2.0",
50+
error: { code: -32603, message: "Internal server error" },
51+
id: null,
52+
});
53+
}
54+
}
55+
});
56+
57+
const httpServer = app.listen(port, (err) => {
58+
if (err) {
59+
console.error("Failed to start server:", err);
60+
process.exit(1);
61+
}
62+
console.log(`${name} listening on http://localhost:${port}/mcp`);
63+
});
64+
65+
const shutdown = () => {
66+
console.log("\nShutting down...");
67+
httpServer.close(() => process.exit(0));
68+
};
69+
70+
process.on("SIGINT", shutdown);
71+
process.on("SIGTERM", shutdown);
72+
}

0 commit comments

Comments
 (0)