Skip to content

Conversation

@ochafik
Copy link
Collaborator

@ochafik ochafik commented Jan 13, 2026

Summary

A simple interactive PDF viewer that uses PDF.js. Launch it w/ a few PDF files and/or URLs as CLI args (+ support loading any additional pdf from arxiv.org).

image

MCP Apps Patterns Demonstrated

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 returns chunks with pagination metadata
{ bytes, offset, byteCount, totalBytes, hasMore }

// Client loads progressively
while (hasMore) {
  const chunk = await app.callServerTool("read_pdf_bytes", { url, offset });
  offset += chunk.byteCount;
  hasMore = chunk.hasMore;
  ...
}

2. Model Context Updates

The viewer keeps the model informed about what the user sees:

---
title: Attention Is All You Need
url: https://arxiv.org/pdf/1706.03762
current-page: 5/15
---

Page text with <pdf-selection>selected text</pdf-selection> inline.
<truncated-content/>

3. Display Modes

  • Inline mode: Viewer stays unscrolled, requests height changes via sendSizeChanged() to fit content
  • Fullscreen mode: Viewer fills the screen via requestDisplayMode(), with internal scrolling when zoomed

4. External Links

Opens source URLs via app.openLink()

5. App-Only Tools

read_pdf_bytes is hidden from the model (visibility: ["app"]), used only by the viewer UI.

Architecture

server.ts           # MCP server (~160 lines)
├── src/
│   ├── types.ts        # Zod schemas (~50 lines)
│   ├── pdf-indexer.ts  # Indexing (~50 lines)
│   ├── pdf-loader.ts   # Chunked loading (~130 lines)
│   └── mcp-app.ts      # Interactive viewer UI

Usage

# Default: "Attention Is All You Need" paper
bun examples/pdf-server/server.ts

# Local files (converted to file:// URLs)
bun examples/pdf-server/server.ts ./paper.pdf

# Any HTTP URL in initial args
bun examples/pdf-server/server.ts https://arxiv.org/pdf/2401.00001.pdf

Security: Dynamic URLs via display_pdf 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

@ochafik ochafik marked this pull request as draft January 13, 2026 20:54
@ochafik ochafik changed the title feat(pdf-server): Interactive PDF viewer example feat(pdf-server): Didactic PDF viewer demonstrating MCP Apps patterns Jan 13, 2026
@ochafik ochafik changed the title feat(pdf-server): Didactic PDF viewer demonstrating MCP Apps patterns examples: add PDF viewer w/ chunked data loading, full-screen, model context updates, private tool Jan 13, 2026
@ochafik ochafik marked this pull request as ready for review January 13, 2026 22:55
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
- 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.
- Add pdfTitle to updateModelContext structuredContent
- Include selection position (text, start, end) when text is selected
- Add debounced selectionchange listener to update context on selection
The UI needs the default value in the schema to show it properly.
- 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%)
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%)
- 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
- 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.
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.
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/>
```
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.
…r 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
…isplay_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.
arxiv.org/abs/... -> arxiv.org/pdf/...

Applied both at startup and when loading dynamic URLs.
Account for devicePixelRatio when rendering canvas:
- Scale canvas dimensions by dpr
- Scale context by dpr
- Keep CSS size at logical pixels
@ochafik ochafik changed the base branch from ochafik/host-open to main January 13, 2026 23:05
@ochafik ochafik force-pushed the ochafik/pdf-server2 branch from 9c5eb10 to 6008f60 Compare January 13, 2026 23:05
@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 13, 2026

Open in StackBlitz

@modelcontextprotocol/ext-apps

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/ext-apps@267

@modelcontextprotocol/server-basic-react

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-basic-react@267

@modelcontextprotocol/server-basic-vanillajs

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-basic-vanillajs@267

@modelcontextprotocol/server-budget-allocator

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-budget-allocator@267

@modelcontextprotocol/server-cohort-heatmap

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-cohort-heatmap@267

@modelcontextprotocol/server-customer-segmentation

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-customer-segmentation@267

@modelcontextprotocol/server-map

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-map@267

@modelcontextprotocol/server-pdf

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-pdf@267

@modelcontextprotocol/server-scenario-modeler

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-scenario-modeler@267

@modelcontextprotocol/server-shadertoy

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-shadertoy@267

@modelcontextprotocol/server-sheet-music

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-sheet-music@267

@modelcontextprotocol/server-system-monitor

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-system-monitor@267

@modelcontextprotocol/server-threejs

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-threejs@267

@modelcontextprotocol/server-transcript

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-transcript@267

@modelcontextprotocol/server-video-resource

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-video-resource@267

@modelcontextprotocol/server-wiki-explorer

npm i https://pkg.pr.new/modelcontextprotocol/ext-apps/@modelcontextprotocol/server-wiki-explorer@267

commit: 70d360e

Copy link
Member

@jonathanhefner jonathanhefner left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like all the E2E screenshots are being regenerated without any masking. Is that issue particular to this branch, or coming from another base branch, or an existing issue in main?

@ochafik
Copy link
Collaborator Author

ochafik commented Jan 14, 2026

It looks like all the E2E screenshots are being regenerated without any masking. Is that issue particular to this branch, or coming from another base branch, or an existing issue in main?

@jonathanhefner The screenshots w/o masking (and their cropped / resized cell derivatives) are new (#253), used for the top-level gallery and the readmes of most servers. The e2e test goldens are still w/ masks

@ochafik ochafik requested a review from antonpk1 January 14, 2026 11:25
antonpk1
antonpk1 previously approved these changes Jan 14, 2026
content: [
{
type: "text",
text: `Viewing ${entry.url} (${entry.metadata.pageCount} pages)`,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe makes sense to expand this a bit to better explain situation to the agent (otherwise it may go :
"Displaying a widget with interactive PDF viewer to the user. Viewing ${entry.url} (${entry.metadata.pageCount} pages)"

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note the tool description already sets the stage for what's happening (Claude seems to understand) but I'll rework this too

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

            text: `Displaying interactive PDF viewer${entry.metadata.title ? ` for "${entry.metadata.title}"` : ""} (${entry.url}, ${entry.metadata.pageCount} pages)`,

@jonathanhefner
Copy link
Member

The screenshots w/o masking (and their cropped / resized cell derivatives) are new (#253), used for the top-level gallery and the readmes of most servers. The e2e test goldens are still w/ masks

Ah, I see. But why are they being replaced in this PR? Is that a side effect of adding a new screenshot (for a new example) to the gallery?

@ochafik
Copy link
Collaborator Author

ochafik commented Jan 14, 2026

The screenshots w/o masking (and their cropped / resized cell derivatives) are new (#253), used for the top-level gallery and the readmes of most servers. The e2e test goldens are still w/ masks

Ah, I see. But why are they being replaced in this PR? Is that a side effect of adding a new screenshot (for a new example) to the gallery?

@jonathanhefner No good reason (reverted all but pdf-server), the ones w/ masked date seem to vary in length (maybe day of week changes it?), I'll look at regularizing that in a follow up.

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.
@ochafik ochafik merged commit 96daa46 into main Jan 14, 2026
17 of 18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants