Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4357664
fix(map-server): simplify location update message format
ochafik Jan 23, 2026
bcfa50e
fix(say-server): pass host to streamable_http_app for non-localhost d…
ochafik Jan 23, 2026
2d4e33f
refactor(pdf-server): simplify to stateless range-query architecture
ochafik Jan 23, 2026
6ad6dd3
feat(pdf-server): add dark mode support
ochafik Jan 23, 2026
50bde38
feat(pdf-server): add list_pdfs tool back
ochafik Jan 23, 2026
146b106
feat(pdf-server): improve tool descriptions and increase context limit
ochafik Jan 23, 2026
9416b31
chore(say-server): remove deployment docs from repo
ochafik Jan 23, 2026
6ae5675
refactor(pdf-server): simplify updateModelContext format
ochafik Jan 23, 2026
a96925c
refactor: rename widgetUUID to viewUUID for consistency
ochafik Jan 23, 2026
b1fb282
viewUUID
ochafik Jan 23, 2026
dbe7980
fix(map-server): fix syntax error in location update message
ochafik Jan 23, 2026
50069b2
fix(pdf-server): restore package.json, improve server docs
ochafik Jan 23, 2026
52c9e6c
chore(pdf-server): remove unused addAllowedOrigin/addAllowedLocalFile
ochafik Jan 23, 2026
1c27452
docs(pdf-server): update README for new architecture
ochafik Jan 23, 2026
88cb166
docs(pdf-server): restore full README, add theming section, remove ge…
ochafik Jan 23, 2026
a3b626a
rm
ochafik Jan 23, 2026
e01fef3
fix(pdf-server): restore createMcpExpressApp and error handling
ochafik Jan 23, 2026
c958d42
fix(say-server): pass host to streamable_http_app for non-localhost d…
ochafik Jan 23, 2026
cc1e83f
chore(say-server): remove deployment docs from repo
ochafik Jan 23, 2026
01b4a84
widgetId->tool id
ochafik Jan 23, 2026
1a01a1f
fix(map-server): add null check for center in location update
ochafik Jan 23, 2026
7e3de46
fix(map-server): use transparent background for rounded corners
ochafik Jan 23, 2026
38861a9
prettier
ochafik Jan 23, 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 examples/map-server/mcp-app.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
padding: 0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: transparent;
}
#cesiumContainer {
width: 100%;
Expand Down
34 changes: 9 additions & 25 deletions examples/map-server/src/mcp-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,40 +404,24 @@ function scheduleLocationUpdate(cesiumViewer: any): void {
const center = getCameraCenter(cesiumViewer);
const extent = getVisibleExtent(cesiumViewer);

if (!extent) {
log.info("No visible extent (camera looking at sky?)");
if (!extent || !center) {
log.info("No visible extent or center (camera looking at sky?)");
return;
}

const { widthKm, heightKm } = getScaleDimensions(extent);

log.info(`Extent: ${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km`);

// Get places visible in the extent (samples multiple points for large areas)
const places = await getVisiblePlaces(extent);

// Build structured markdown with YAML frontmatter (like pdf-server)
// Note: tool name isn't in the notification protocol, so we hardcode it
const frontmatter = [
"---",
`tool: show-map`,
center
? `center: [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]`
: null,
`extent: [${extent.west.toFixed(4)}, ${extent.south.toFixed(4)}, ${extent.east.toFixed(4)}, ${extent.north.toFixed(4)}]`,
`extent-size: ${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km`,
places.length > 0 ? `visible-places: [${places.join(", ")}]` : null,
"---",
]
.filter(Boolean)
.join("\n");

log.info("Updating model context:", frontmatter);

// Update the model's context with the current map location.
// If the host doesn't support this, the request will silently fail.
const content = [
`The map view of ${app.getHostContext()?.toolInfo?.id} is now ${widthKm.toFixed(1)}km wide × ${heightKm.toFixed(1)}km tall `,
`and has changed to the following location: [${places.join(", ")}] `,
`lat. / long. of center of map = [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]`,
].join("\n");
log.info("Updating model context:", content);
app.updateModelContext({
content: [{ type: "text", text: frontmatter }],
content: [{ type: "text", text: content }],
});
}, 1500);
}
Expand Down
100 changes: 55 additions & 45 deletions examples/pdf-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

![Screenshot](screenshot.png)

A simple interactive PDF viewer that uses [PDF.js](https://mozilla.github.io/pdf.js/). Launch it w/ a few PDF files and/or URLs as CLI args (+ support loading any additional pdf from arxiv.org).
An interactive PDF viewer using [PDF.js](https://mozilla.github.io/pdf.js/). Supports local files and remote URLs from academic sources (arxiv, biorxiv, zenodo, etc).

## MCP Client Configuration

Expand All @@ -29,20 +29,14 @@ Add to your MCP client configuration (stdio transport):

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

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

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

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

Expand All @@ -51,7 +45,7 @@ async function loadPdfBytesChunk(entry, offset, byteCount) {
```typescript
// Load in chunks with progress
while (hasMore) {
const chunk = await app.callServerTool("read_pdf_bytes", { pdfId, offset });
const chunk = await app.callServerTool("read_pdf_bytes", { url, offset });
chunks.push(base64ToBytes(chunk.bytes));
offset += chunk.byteCount;
hasMore = chunk.hasMore;
Expand All @@ -65,13 +59,12 @@ The viewer keeps the model informed about what the user is seeing:

```typescript
app.updateModelContext({
structuredContent: {
title: pdfTitle,
currentPage,
totalPages,
pageText: pageText.slice(0, 5000),
selection: selectedText ? { text, start, end } : undefined,
},
content: [
{
type: "text",
text: `PDF viewer | "${title}" | Current Page: ${page}/${total}\n\nPage content:\n${pageText}`,
},
],
});
```

Expand Down Expand Up @@ -101,58 +94,75 @@ The viewer demonstrates opening external links (e.g., to the original arxiv page
titleEl.onclick = () => app.openLink(sourceUrl);
```

### 5. View Persistence

Page position is saved per-view using `viewUUID` and localStorage.

### 6. Dark Mode / Theming

The viewer syncs with the host's theme using CSS `light-dark()` and the SDK's theming APIs:

```typescript
app.onhostcontextchanged = (ctx) => {
if (ctx.theme) applyDocumentTheme(ctx.theme);
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
};
```

## Usage

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

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

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

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

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

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

- **Local files**: Must be passed as CLI arguments
- **Remote URLs**: arxiv.org, biorxiv.org, medrxiv.org, chemrxiv.org, zenodo.org, osf.io, hal.science, ssrn.com, and more

## Tools

| Tool | Visibility | Purpose |
| ---------------- | ---------- | ---------------------------------- |
| `list_pdfs` | Model | List indexed PDFs |
| `display_pdf` | Model + UI | Display interactive viewer in chat |
| `read_pdf_bytes` | App only | Chunked binary loading |
| Tool | Visibility | Purpose |
| ---------------- | ---------- | -------------------------------------- |
| `list_pdfs` | Model | List available local files and origins |
| `display_pdf` | Model + UI | Display interactive viewer |
| `read_pdf_bytes` | App only | Stream PDF data in chunks |

## Architecture

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

## Key Patterns Shown

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

## Dependencies

- `pdfjs-dist`: PDF rendering
- `pdfjs-dist`: PDF rendering (frontend only)
- `@modelcontextprotocol/ext-apps`: MCP Apps SDK
45 changes: 30 additions & 15 deletions examples/pdf-server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,24 @@
* Or: node dist/index.js [--stdio] [pdf-urls...]
*/

/**
* Shared utilities for running MCP servers with Streamable HTTP transport.
*/

import fs from "node:fs";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer, initializePdfIndex } from "./server.js";
import { isArxivUrl, toFileUrl, normalizeArxivUrl } from "./src/pdf-indexer.js";
import {
createServer,
isArxivUrl,
isFileUrl,
normalizeArxivUrl,
pathToFileUrl,
fileUrlToPath,
allowedLocalFiles,
allowedRemoteOrigins,
DEFAULT_PDF,
} from "./server.js";

export interface ServerOptions {
port: number;
Expand All @@ -24,9 +30,6 @@ export interface ServerOptions {

/**
* Starts an MCP server with Streamable HTTP transport in stateless mode.
*
* @param createServer - Factory function that creates a new McpServer instance per request.
* @param options - Server configuration options.
*/
export async function startServer(
createServer: () => McpServer,
Expand Down Expand Up @@ -80,8 +83,6 @@ export async function startServer(
process.on("SIGTERM", shutdown);
}

const DEFAULT_PDF = "https://arxiv.org/pdf/1706.03762"; // Attention Is All You Need

function parseArgs(): { urls: string[]; stdio: boolean } {
const args = process.argv.slice(2);
const urls: string[] = [];
Expand All @@ -98,7 +99,7 @@ function parseArgs(): { urls: string[]; stdio: boolean } {
!arg.startsWith("https://") &&
!arg.startsWith("file://")
) {
url = toFileUrl(arg);
url = pathToFileUrl(arg);
} else if (isArxivUrl(arg)) {
url = normalizeArxivUrl(arg);
}
Expand All @@ -112,9 +113,23 @@ function parseArgs(): { urls: string[]; stdio: boolean } {
async function main() {
const { urls, stdio } = parseArgs();

console.error(`[pdf-server] Initializing with ${urls.length} PDF(s)...`);
await initializePdfIndex(urls);
console.error(`[pdf-server] Ready`);
// Register local files in whitelist
for (const url of urls) {
if (isFileUrl(url)) {
const filePath = fileUrlToPath(url);
if (fs.existsSync(filePath)) {
allowedLocalFiles.add(filePath);
console.error(`[pdf-server] Registered local file: ${filePath}`);
} else {
console.error(`[pdf-server] Warning: File not found: ${filePath}`);
}
}
}

console.error(`[pdf-server] Ready (${urls.length} URL(s) configured)`);
console.error(
`[pdf-server] Allowed origins: ${[...allowedRemoteOrigins].join(", ")}`,
);

if (stdio) {
await createServer().connect(new StdioServerTransport());
Expand Down
Loading
Loading