Skip to content

Commit 14321bf

Browse files
committed
Merge main: add pdf-server with library/binary structure
- Added pdf-server example from main - Refactored to library/binary structure like other examples - server.ts exports createServer() and initializePdfIndex() - main.ts is the CLI entry point - Removed server-utils.ts (inlined into main.ts)
2 parents d26eeb1 + 96daa46 commit 14321bf

25 files changed

Lines changed: 2384 additions & 47 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/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist/

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/main.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* Entry point for running the MCP server.
3+
* Run with: npx mcp-pdf-server
4+
* Or: node dist/index.js [--stdio] [pdf-urls...]
5+
*/
6+
7+
/**
8+
* Shared utilities for running MCP servers with Streamable HTTP transport.
9+
*/
10+
11+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
13+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
15+
import cors from "cors";
16+
import type { Request, Response } from "express";
17+
import { createServer, initializePdfIndex } from "./server.js";
18+
import {
19+
isArxivUrl,
20+
toFileUrl,
21+
normalizeArxivUrl,
22+
} from "./src/pdf-indexer.js";
23+
24+
export interface ServerOptions {
25+
port: number;
26+
name?: string;
27+
}
28+
29+
/**
30+
* Starts an MCP server with Streamable HTTP transport in stateless mode.
31+
*
32+
* @param createServer - Factory function that creates a new McpServer instance per request.
33+
* @param options - Server configuration options.
34+
*/
35+
export async function startServer(
36+
createServer: () => McpServer,
37+
options: ServerOptions,
38+
): Promise<void> {
39+
const { port, name = "MCP Server" } = options;
40+
41+
const app = createMcpExpressApp({ host: "0.0.0.0" });
42+
app.use(cors());
43+
44+
app.all("/mcp", async (req: Request, res: Response) => {
45+
const server = createServer();
46+
const transport = new StreamableHTTPServerTransport({
47+
sessionIdGenerator: undefined,
48+
});
49+
50+
res.on("close", () => {
51+
transport.close().catch(() => {});
52+
server.close().catch(() => {});
53+
});
54+
55+
try {
56+
await server.connect(transport);
57+
await transport.handleRequest(req, res, req.body);
58+
} catch (error) {
59+
console.error("MCP error:", error);
60+
if (!res.headersSent) {
61+
res.status(500).json({
62+
jsonrpc: "2.0",
63+
error: { code: -32603, message: "Internal server error" },
64+
id: null,
65+
});
66+
}
67+
}
68+
});
69+
70+
const httpServer = app.listen(port, (err) => {
71+
if (err) {
72+
console.error("Failed to start server:", err);
73+
process.exit(1);
74+
}
75+
console.log(`${name} listening on http://localhost:${port}/mcp`);
76+
});
77+
78+
const shutdown = () => {
79+
console.log("\nShutting down...");
80+
httpServer.close(() => process.exit(0));
81+
};
82+
83+
process.on("SIGINT", shutdown);
84+
process.on("SIGTERM", shutdown);
85+
}
86+
87+
const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need
88+
89+
function parseArgs(): { urls: string[]; stdio: boolean } {
90+
const args = process.argv.slice(2);
91+
const urls: string[] = [];
92+
let stdio = false;
93+
94+
for (const arg of args) {
95+
if (arg === "--stdio") {
96+
stdio = true;
97+
} else if (!arg.startsWith("-")) {
98+
// Convert local paths to file:// URLs, normalize arxiv URLs
99+
let url = arg;
100+
if (
101+
!arg.startsWith("http://") &&
102+
!arg.startsWith("https://") &&
103+
!arg.startsWith("file://")
104+
) {
105+
url = toFileUrl(arg);
106+
} else if (isArxivUrl(arg)) {
107+
url = normalizeArxivUrl(arg);
108+
}
109+
urls.push(url);
110+
}
111+
}
112+
113+
return { urls: urls.length > 0 ? urls : [DEFAULT_PDF], stdio };
114+
}
115+
116+
async function main() {
117+
const { urls, stdio } = parseArgs();
118+
119+
console.error(`[pdf-server] Initializing with ${urls.length} PDF(s)...`);
120+
await initializePdfIndex(urls);
121+
console.error(`[pdf-server] Ready`);
122+
123+
if (stdio) {
124+
await createServer().connect(new StdioServerTransport());
125+
} else {
126+
const port = parseInt(process.env.PORT ?? "3120", 10);
127+
await startServer(createServer, { port, name: "PDF Server" });
128+
}
129+
}
130+
131+
main().catch((e) => {
132+
console.error(e);
133+
process.exit(1);
134+
});

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>

0 commit comments

Comments
 (0)