Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
1279c43
feat(pdf-server): Interactive PDF viewer example
ochafik Jan 13, 2026
0697d04
chore: Add pdf-server to screenshot generation list
ochafik Jan 13, 2026
2dcd3e4
refactor(pdf-server): Simplify and generalize PDF loading
ochafik Jan 13, 2026
311d058
feat(pdf-server): Include title and selection in model context
ochafik Jan 13, 2026
11fbda5
fix(pdf-server): Restore default URL in view_pdf schema
ochafik Jan 13, 2026
7799788
refactor(pdf-server): Further simplifications
ochafik Jan 13, 2026
c9f51c3
refactor(pdf-server): Major simplification for didactic focus
ochafik Jan 13, 2026
12b1213
feat(pdf-server): Add file:// URL support for local files
ochafik Jan 13, 2026
db71488
fix(pdf-server): Improve selection detection with logging
ochafik Jan 13, 2026
a001c9f
feat(pdf-server): Format model context as markdown with front matter
ochafik Jan 13, 2026
7fb4687
refactor(pdf-server): Extract smart truncation helpers
ochafik Jan 13, 2026
ae20433
fix(pdf-server): Truncate inside selection tags when selection too long
ochafik Jan 13, 2026
870c23d
refactor(pdf-server): Remove unused read_pdf_text, use Attention pape…
ochafik Jan 13, 2026
02f173d
refactor(pdf-server): Simplify to use URL as ID, rename view_pdf to d…
ochafik Jan 13, 2026
7c154e2
feat(pdf-server): Normalize arxiv URLs to PDF format
ochafik Jan 13, 2026
19e364d
docs(pdf-server): Add prompt engineering to display_pdf description
ochafik Jan 13, 2026
35a7e6d
fix(pdf-server): Sharp rendering on retina displays
ochafik Jan 13, 2026
6008f60
fix(pdf-server): Normalize arxiv URLs in read_pdf_bytes too
ochafik Jan 13, 2026
ab98f5f
add to e2e spec
ochafik Jan 13, 2026
12c0a26
add to e2e spec
ochafik Jan 13, 2026
31bd981
add to e2e spec
ochafik Jan 13, 2026
f51eeae
add to e2e spec
ochafik Jan 13, 2026
fcec16a
regen
ochafik Jan 13, 2026
ed89586
chore: regenerate package-lock.json and fix hono vulnerability
ochafik Jan 13, 2026
4b84450
docs: add pdf-server screenshot to READMEs
ochafik Jan 13, 2026
69a5975
regen
ochafik Jan 13, 2026
6df8f40
Merge branch 'main' into ochafik/pdf-server2
ochafik Jan 14, 2026
5c3e98b
ci: add missing examples to pkg-pr-new publish
ochafik Jan 14, 2026
cc480b4
ci: add pdf-server to npm publish examples
ochafik Jan 14, 2026
c02eef5
Update README.md
ochafik Jan 14, 2026
0dc44a6
Merge remote-tracking branch 'origin/main' into ochafik/pdf-server2
ochafik Jan 14, 2026
b11153a
pdf-server: improve tool response text for better model context
ochafik Jan 14, 2026
7347cf2
revert unrelated screenshot changes
ochafik Jan 14, 2026
70d360e
pdf-server: dynamically add arxiv URLs in read_pdf_bytes
ochafik Jan 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ jobs:
- cohort-heatmap-server
- customer-segmentation-server
- map-server
- pdf-server
- scenario-modeler-server
- shadertoy-server
- sheet-music-server
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,13 @@ jobs:
./examples/budget-allocator-server \
./examples/cohort-heatmap-server \
./examples/customer-segmentation-server \
./examples/map-server \
./examples/pdf-server \
./examples/scenario-modeler-server \
./examples/shadertoy-server \
./examples/sheet-music-server \
./examples/system-monitor-server \
./examples/threejs-server \
./examples/transcript-server \
./examples/video-resource-server \
./examples/wiki-explorer-server
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ Or edit your `package.json` manually:
| [**Scenario Modeler**](examples/scenario-modeler-server) | [**Budget Allocator**](examples/budget-allocator-server) | [**Customer Segmentation**](examples/customer-segmentation-server) |
| [![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) |
| [**System Monitor**](examples/system-monitor-server) | [**Transcript**](examples/transcript-server) | [**Video Resource**](examples/video-resource-server) |
| [![PDF Server](examples/pdf-server/grid-cell.png "Interactive PDF viewer with chunked loading")](examples/pdf-server) | | |
| [**PDF Server**](examples/pdf-server) | | |

### Starter Templates

Expand Down
137 changes: 137 additions & 0 deletions examples/pdf-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# PDF Server

![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).

## What This Example Demonstrates

### 1. Chunked Data Through Size-Limited Tool Calls

On some host platforms, tool calls have size limits, so large PDFs cannot be sent in a single response. This example shows a possible workaround:

**Server side** (`pdf-loader.ts`):

```typescript
// Returns chunks with pagination metadata
async function loadPdfBytesChunk(entry, offset, byteCount) {
return {
bytes: base64Chunk,
offset,
byteCount,
totalBytes,
hasMore: offset + byteCount < totalBytes,
};
}
```

**Client side** (`mcp-app.ts`):

```typescript
// Load in chunks with progress
while (hasMore) {
const chunk = await app.callServerTool("read_pdf_bytes", { pdfId, offset });
chunks.push(base64ToBytes(chunk.bytes));
offset += chunk.byteCount;
hasMore = chunk.hasMore;
updateProgress(offset, chunk.totalBytes);
}
```

### 2. Model Context Updates

The viewer keeps the model informed about what the user is seeing:

```typescript
app.updateModelContext({
structuredContent: {
title: pdfTitle,
currentPage,
totalPages,
pageText: pageText.slice(0, 5000),
selection: selectedText ? { text, start, end } : undefined,
},
});
```

This enables the model to answer questions about the current page or selected text.

### 3. Display Modes: Fullscreen vs Inline

- **Inline mode**: App requests height changes to fit content
- **Fullscreen mode**: App fills the screen with internal scrolling

```typescript
// Request fullscreen
app.requestDisplayMode({ mode: "fullscreen" });

// Listen for mode changes
app.ondisplaymodechange = (mode) => {
if (mode === "fullscreen") enableScrolling();
else disableScrolling();
};
```

### 4. External Links (openLink)

The viewer demonstrates opening external links (e.g., to the original arxiv page):

```typescript
titleEl.onclick = () => app.openLink(sourceUrl);
```

## Usage

```bash
# Default: loads a sample arxiv paper
bun examples/pdf-server/server.ts

# Load local files (converted to file:// URLs)
bun examples/pdf-server/server.ts ./docs/paper.pdf /path/to/thesis.pdf

# Load from URLs
bun examples/pdf-server/server.ts https://arxiv.org/pdf/2401.00001.pdf

# Mix local and remote
bun examples/pdf-server/server.ts ./local.pdf https://arxiv.org/pdf/2401.00001.pdf

# stdio mode for MCP clients
bun examples/pdf-server/server.ts --stdio ./papers/
```

**Security**: Dynamic URLs (via `view_pdf` tool) are restricted to arxiv.org. Local files must be in the initial list.

## Tools

| Tool | Visibility | Purpose |
| ---------------- | ---------- | ---------------------------------- |
| `list_pdfs` | Model | List indexed PDFs |
| `display_pdf` | Model + UI | Display interactive viewer in chat |
| `read_pdf_bytes` | App only | Chunked binary loading |

## Architecture

```
server.ts # MCP server (233 lines)
├── src/
│ ├── types.ts # Zod schemas (75 lines)
│ ├── pdf-indexer.ts # URL-based indexing (44 lines)
│ ├── pdf-loader.ts # Chunked loading (171 lines)
│ └── mcp-app.ts # Interactive viewer UI
```

## Key Patterns Shown

| Pattern | Implementation |
| ----------------- | ---------------------------------------- |
| App-only tools | `_meta: { ui: { visibility: ["app"] } }` |
| Chunked responses | `hasMore` + `offset` pagination |
| Model context | `app.updateModelContext()` |
| Display modes | `app.requestDisplayMode()` |
| External links | `app.openLink()` |
| Size negotiation | `app.sendSizeChanged()` |

## Dependencies

- `pdfjs-dist`: PDF rendering
- `@modelcontextprotocol/ext-apps`: MCP Apps SDK
Binary file added examples/pdf-server/grid-cell.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
78 changes: 78 additions & 0 deletions examples/pdf-server/mcp-app.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PDF Viewer</title>
</head>
<body>
<div class="main">
<!-- Loading State -->
<div id="loading" class="loading">
<div class="spinner"></div>
<p id="loading-text">Loading PDF...</p>
<div id="progress-container" class="progress-container" style="display: none">
<div id="progress-bar" class="progress-bar"></div>
</div>
<p id="progress-text" class="progress-text"></p>
</div>

<!-- Error State -->
<div id="error" class="error" style="display: none">
<div class="error-icon">⚠️</div>
<p id="error-message">An error occurred</p>
</div>

<!-- PDF Viewer -->
<div id="viewer" class="viewer" style="display: none">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<span id="pdf-title" class="pdf-title">Document</span>
</div>
<div class="toolbar-center">
<button id="prev-btn" class="nav-btn" title="Previous page (←)">
</button>
<div class="page-nav">
<input
id="page-input"
type="number"
class="page-input"
min="1"
value="1"
title="Go to page"
/>
<span id="total-pages" class="total-pages">of 1</span>
</div>
<button id="next-btn" class="nav-btn" title="Next page (→)">
</button>
</div>
<div class="toolbar-right">
<button id="zoom-out-btn" class="zoom-btn" title="Zoom out (-)">
</button>
<span id="zoom-level" class="zoom-level">100%</span>
<button id="zoom-in-btn" class="zoom-btn" title="Zoom in (+)">
+
</button>
<button id="fullscreen-btn" class="fullscreen-btn" title="Toggle fullscreen">
</button>
</div>
</div>

<!-- Single Page Canvas Container -->
<div class="canvas-container">
<div class="page-wrapper">
<canvas id="pdf-canvas"></canvas>
<div id="text-layer" class="text-layer"></div>
</div>
</div>
</div>
</div>

<script type="module" src="./src/mcp-app.ts"></script>
</body>
</html>
45 changes: 45 additions & 0 deletions examples/pdf-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"name": "@modelcontextprotocol/server-pdf",
"version": "0.4.0",
"type": "module",
"description": "MCP server for loading and extracting text from PDF files with chunked pagination and interactive viewer",
"repository": {
"type": "git",
"url": "https://github.com/modelcontextprotocol/ext-apps",
"directory": "examples/pdf-server"
},
"license": "MIT",
"main": "server.ts",
"files": [
"server.ts",
"server-utils.ts",
"src",
"dist"
],
"scripts": {
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build",
"watch": "cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch server.ts",
"serve:http": "bun --watch server.ts",
"dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'",
"start": "npm run serve"
},
"dependencies": {
"@modelcontextprotocol/ext-apps": "^0.4.0",
"@modelcontextprotocol/sdk": "^1.24.0",
"pdfjs-dist": "^5.0.0",
"zod": "^4.1.13"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"express": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0"
}
}
Binary file added examples/pdf-server/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
72 changes: 72 additions & 0 deletions examples/pdf-server/server-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Shared utilities for running MCP servers with Streamable HTTP transport.
*/

import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import cors from "cors";
import type { Request, Response } from "express";

export interface ServerOptions {
port: number;
name?: string;
}

/**
* Starts an MCP server with Streamable HTTP transport in stateless mode.
*
* @param createServer - Factory function that creates a new McpServer instance per request.
* @param options - Server configuration options.
*/
export async function startServer(
createServer: () => McpServer,
options: ServerOptions,
): Promise<void> {
const { port, name = "MCP Server" } = options;

const app = createMcpExpressApp({ host: "0.0.0.0" });
app.use(cors());

app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});

res.on("close", () => {
transport.close().catch(() => {});
server.close().catch(() => {});
});

try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error("MCP error:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null,
});
}
}
});

const httpServer = app.listen(port, (err) => {
if (err) {
console.error("Failed to start server:", err);
process.exit(1);
}
console.log(`${name} listening on http://localhost:${port}/mcp`);
});

const shutdown = () => {
console.log("\nShutting down...");
httpServer.close(() => process.exit(0));
};

process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
Loading
Loading