ext-appss / examples /pdf-server /src /mcp-app.ts
AbdulElahGwaith's picture
Upload folder using huggingface_hub
e1cc3bc verified
/**
* PDF Viewer MCP App
*
* Interactive PDF viewer with single-page display.
* - Fixed height (no auto-resize)
* - Text selection via PDF.js TextLayer
* - Page navigation, zoom
*/
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import * as pdfjsLib from "pdfjs-dist";
import { TextLayer } from "pdfjs-dist";
import "./global.css";
import "./mcp-app.css";
// const MAX_MODEL_CONTEXT_LENGTH = 5000;
const MAX_MODEL_CONTEXT_LENGTH = 1500;
// Configure PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.mjs",
import.meta.url,
).href;
const log = {
info: console.log.bind(console, "[PDF-VIEWER]"),
error: console.error.bind(console, "[PDF-VIEWER]"),
};
// State
let pdfDocument: pdfjsLib.PDFDocumentProxy | null = null;
let pdfBytes: Uint8Array | null = null;
let currentPage = 1;
let totalPages = 0;
let scale = 1.0;
let pdfUrl = "";
let pdfTitle: string | undefined;
let currentRenderTask: { cancel: () => void } | null = null;
// DOM Elements
const mainEl = document.querySelector(".main") as HTMLElement;
const loadingEl = document.getElementById("loading")!;
const loadingTextEl = document.getElementById("loading-text")!;
const errorEl = document.getElementById("error")!;
const errorMessageEl = document.getElementById("error-message")!;
const viewerEl = document.getElementById("viewer")!;
const canvasContainerEl = document.querySelector(".canvas-container")!;
const canvasEl = document.getElementById("pdf-canvas") as HTMLCanvasElement;
const textLayerEl = document.getElementById("text-layer")!;
const titleEl = document.getElementById("pdf-title")!;
const pageInputEl = document.getElementById("page-input") as HTMLInputElement;
const totalPagesEl = document.getElementById("total-pages")!;
const prevBtn = document.getElementById("prev-btn") as HTMLButtonElement;
const nextBtn = document.getElementById("next-btn") as HTMLButtonElement;
const zoomOutBtn = document.getElementById("zoom-out-btn") as HTMLButtonElement;
const zoomInBtn = document.getElementById("zoom-in-btn") as HTMLButtonElement;
const zoomLevelEl = document.getElementById("zoom-level")!;
const fullscreenBtn = document.getElementById(
"fullscreen-btn",
) as HTMLButtonElement;
const progressContainerEl = document.getElementById("progress-container")!;
const progressBarEl = document.getElementById("progress-bar")!;
const progressTextEl = document.getElementById("progress-text")!;
// Track current display mode
let currentDisplayMode: "inline" | "fullscreen" = "inline";
// Layout constants are no longer used - we calculate dynamically from actual element dimensions
/**
* Request the host to resize the app to fit the current PDF page.
* Only applies in inline mode - fullscreen mode uses scrolling.
*/
function requestFitToContent() {
if (currentDisplayMode === "fullscreen") {
return; // Fullscreen uses scrolling
}
const canvasHeight = canvasEl.height;
if (canvasHeight <= 0) {
return; // No content yet
}
// Get actual element dimensions
const canvasContainerEl = document.querySelector(
".canvas-container",
) as HTMLElement;
const pageWrapperEl = document.querySelector(".page-wrapper") as HTMLElement;
const toolbarEl = document.querySelector(".toolbar") as HTMLElement;
if (!canvasContainerEl || !toolbarEl || !pageWrapperEl) {
return;
}
// Get computed styles
const containerStyle = getComputedStyle(canvasContainerEl);
const paddingTop = parseFloat(containerStyle.paddingTop);
const paddingBottom = parseFloat(containerStyle.paddingBottom);
// Calculate required height:
// toolbar + padding-top + page-wrapper height + padding-bottom + buffer
const toolbarHeight = toolbarEl.offsetHeight;
const pageWrapperHeight = pageWrapperEl.offsetHeight;
const BUFFER = 10; // Buffer for sub-pixel rounding and browser quirks
const totalHeight =
toolbarHeight + paddingTop + pageWrapperHeight + paddingBottom + BUFFER;
app.sendSizeChanged({ height: totalHeight });
}
// Create app instance
// autoResize disabled - app fills its container, doesn't request size changes
const app = new App(
{ name: "PDF Viewer", version: "1.0.0" },
{},
{ autoResize: false },
);
// UI State functions
function showLoading(text: string) {
loadingTextEl.textContent = text;
loadingEl.style.display = "flex";
errorEl.style.display = "none";
viewerEl.style.display = "none";
}
function showError(message: string) {
errorMessageEl.textContent = message;
loadingEl.style.display = "none";
errorEl.style.display = "block";
viewerEl.style.display = "none";
}
function showViewer() {
loadingEl.style.display = "none";
errorEl.style.display = "none";
viewerEl.style.display = "flex";
}
function updateControls() {
// Show URL with CSS ellipsis, full URL as tooltip, clickable to open
titleEl.textContent = pdfUrl;
titleEl.title = pdfUrl;
titleEl.style.textDecoration = "underline";
titleEl.style.cursor = "pointer";
titleEl.onclick = () => app.openLink({ url: pdfUrl });
pageInputEl.value = String(currentPage);
pageInputEl.max = String(totalPages);
totalPagesEl.textContent = `of ${totalPages}`;
prevBtn.disabled = currentPage <= 1;
nextBtn.disabled = currentPage >= totalPages;
zoomLevelEl.textContent = `${Math.round(scale * 100)}%`;
}
/**
* Format page text with optional selection, truncating intelligently.
* - Centers window around selection when truncating
* - Adds <truncated-content/> markers where text is elided
* - If selection itself is too long, truncates inside: <pdf-selection><truncated-content/>...<truncated-content/></pdf-selection>
*/
function formatPageContent(
text: string,
maxLength: number,
selection?: { start: number; end: number },
): string {
const T = "<truncated-content/>";
// No truncation needed
if (text.length <= maxLength) {
if (!selection) return text;
return (
text.slice(0, selection.start) +
`<pdf-selection>${text.slice(selection.start, selection.end)}</pdf-selection>` +
text.slice(selection.end)
);
}
// Truncation needed, no selection - just truncate end
if (!selection) {
return text.slice(0, maxLength) + "\n" + T;
}
// Calculate budgets
const selLen = selection.end - selection.start;
const overhead = "<pdf-selection></pdf-selection>".length + T.length * 2 + 4;
const contextBudget = maxLength - overhead;
// Selection too long - truncate inside the selection tags
if (selLen > contextBudget) {
const keepLen = Math.max(100, contextBudget);
const halfKeep = Math.floor(keepLen / 2);
const selStart = text.slice(selection.start, selection.start + halfKeep);
const selEnd = text.slice(selection.end - halfKeep, selection.end);
return (
T + `<pdf-selection>${T}${selStart}...${selEnd}${T}</pdf-selection>` + T
);
}
// Selection fits - center it with context
const remainingBudget = contextBudget - selLen;
const beforeBudget = Math.floor(remainingBudget / 2);
const afterBudget = remainingBudget - beforeBudget;
const windowStart = Math.max(0, selection.start - beforeBudget);
const windowEnd = Math.min(text.length, selection.end + afterBudget);
const adjStart = selection.start - windowStart;
const adjEnd = selection.end - windowStart;
const windowText = text.slice(windowStart, windowEnd);
return (
(windowStart > 0 ? T + "\n" : "") +
windowText.slice(0, adjStart) +
`<pdf-selection>${windowText.slice(adjStart, adjEnd)}</pdf-selection>` +
windowText.slice(adjEnd) +
(windowEnd < text.length ? "\n" + T : "")
);
}
/**
* Find selection position in page text using fuzzy matching.
* TextLayer spans may lack spaces between them, so we try both exact and spaceless match.
*/
function findSelectionInText(
pageText: string,
selectedText: string,
): { start: number; end: number } | undefined {
if (!selectedText || selectedText.length <= 2) return undefined;
// Try exact match
let start = pageText.indexOf(selectedText);
if (start >= 0) {
return { start, end: start + selectedText.length };
}
// Try spaceless match (TextLayer spans may not have spaces)
const noSpaceSel = selectedText.replace(/\s+/g, "");
const noSpaceText = pageText.replace(/\s+/g, "");
const noSpaceStart = noSpaceText.indexOf(noSpaceSel);
if (noSpaceStart >= 0) {
// Map back to approximate position in original
start = Math.floor((noSpaceStart / noSpaceText.length) * pageText.length);
return { start, end: start + selectedText.length };
}
return undefined;
}
// Extract text from current page and update model context as markdown
async function updatePageContext() {
if (!pdfDocument) return;
try {
const page = await pdfDocument.getPage(currentPage);
const textContent = await page.getTextContent();
const pageText = (textContent.items as Array<{ str?: string }>)
.map((item) => item.str || "")
.join(" ")
.replace(/\s+/g, " ")
.trim();
// Find selection position
const sel = window.getSelection();
const selectedText = sel?.toString().replace(/\s+/g, " ").trim();
const selection = selectedText
? findSelectionInText(pageText, selectedText)
: undefined;
if (selection) {
log.info(
"Selection found:",
selectedText?.slice(0, 30),
"at",
selection.start,
);
}
// Format content with selection and truncation
const content = formatPageContent(
pageText,
MAX_MODEL_CONTEXT_LENGTH,
selection,
);
const markdown = `---
title: ${pdfTitle || ""}
url: ${pdfUrl}
current-page: ${currentPage}/${totalPages}
---
${content}`;
app.updateModelContext({ content: [{ type: "text", text: markdown }] });
} catch (err) {
log.error("Error updating context:", err);
}
}
// Render state - prevents concurrent renders
let isRendering = false;
let pendingPage: number | null = null;
// Render current page with text layer for selection
async function renderPage() {
if (!pdfDocument) return;
// If already rendering, queue this page for later
if (isRendering) {
pendingPage = currentPage;
// Cancel current render to speed up
if (currentRenderTask) {
currentRenderTask.cancel();
}
return;
}
isRendering = true;
pendingPage = null;
try {
const pageToRender = currentPage;
const page = await pdfDocument.getPage(pageToRender);
const viewport = page.getViewport({ scale });
// Account for retina displays
const dpr = window.devicePixelRatio || 1;
const ctx = canvasEl.getContext("2d")!;
// Set canvas size in pixels (scaled for retina)
canvasEl.width = viewport.width * dpr;
canvasEl.height = viewport.height * dpr;
// Set display size in CSS pixels
canvasEl.style.width = `${viewport.width}px`;
canvasEl.style.height = `${viewport.height}px`;
// Scale context for retina
ctx.scale(dpr, dpr);
// Clear and setup text layer
textLayerEl.innerHTML = "";
textLayerEl.style.width = `${viewport.width}px`;
textLayerEl.style.height = `${viewport.height}px`;
// Render canvas - track the task so we can cancel it
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const renderTask = (page.render as any)({
canvasContext: ctx,
viewport,
});
currentRenderTask = renderTask;
try {
await renderTask.promise;
} catch (renderErr) {
// Ignore RenderingCancelledException - it's expected when we cancel
if (
renderErr instanceof Error &&
renderErr.name === "RenderingCancelledException"
) {
log.info("Render cancelled");
return;
}
throw renderErr;
} finally {
currentRenderTask = null;
}
// Only continue if this is still the page we want
if (pageToRender !== currentPage) {
return;
}
// Render text layer for selection
const textContent = await page.getTextContent();
const textLayer = new TextLayer({
textContentSource: textContent,
container: textLayerEl,
viewport,
});
await textLayer.render();
updateControls();
updatePageContext();
// Request host to resize app to fit content (inline mode only)
requestFitToContent();
} catch (err) {
log.error("Error rendering page:", err);
showError(`Failed to render page ${currentPage}`);
} finally {
isRendering = false;
// If there's a pending page, render it now
if (pendingPage !== null && pendingPage !== currentPage) {
currentPage = pendingPage;
renderPage();
} else if (pendingPage === currentPage) {
// Re-render the same page (e.g., after zoom change during render)
renderPage();
}
}
}
// Page persistence
function getStorageKey(): string | null {
if (!pdfUrl) return null;
const ctx = app.getHostContext();
const toolId = ctx?.toolInfo?.id ?? pdfUrl;
return `pdf:${pdfUrl}:${toolId}`;
}
function saveCurrentPage() {
const key = getStorageKey();
log.info("saveCurrentPage: key=", key, "page=", currentPage);
if (key) {
try {
localStorage.setItem(key, String(currentPage));
log.info("saveCurrentPage: saved successfully");
} catch (err) {
log.error("saveCurrentPage: error", err);
}
}
}
function loadSavedPage(): number | null {
const key = getStorageKey();
log.info("loadSavedPage: key=", key);
if (!key) return null;
try {
const saved = localStorage.getItem(key);
log.info("loadSavedPage: saved value=", saved);
if (saved) {
const page = parseInt(saved, 10);
if (!isNaN(page) && page >= 1) {
log.info("loadSavedPage: returning page=", page);
return page;
}
}
} catch (err) {
log.error("loadSavedPage: error", err);
}
log.info("loadSavedPage: returning null");
return null;
}
// Navigation
function goToPage(page: number) {
const targetPage = Math.max(1, Math.min(page, totalPages));
if (targetPage !== currentPage) {
currentPage = targetPage;
saveCurrentPage();
renderPage();
}
pageInputEl.value = String(currentPage);
}
function prevPage() {
goToPage(currentPage - 1);
}
function nextPage() {
goToPage(currentPage + 1);
}
function zoomIn() {
scale = Math.min(scale + 0.25, 3.0);
renderPage();
}
function zoomOut() {
scale = Math.max(scale - 0.25, 0.5);
renderPage();
}
function resetZoom() {
scale = 1.0;
renderPage();
}
async function toggleFullscreen() {
const ctx = app.getHostContext();
if (!ctx?.availableDisplayModes?.includes("fullscreen")) {
log.info("Fullscreen not available");
return;
}
const newMode = currentDisplayMode === "fullscreen" ? "inline" : "fullscreen";
log.info("Requesting display mode:", newMode);
try {
const result = await app.requestDisplayMode({ mode: newMode });
log.info("Display mode result:", result);
currentDisplayMode = result.mode as "inline" | "fullscreen";
updateFullscreenButton();
} catch (err) {
log.error("Failed to change display mode:", err);
}
}
function updateFullscreenButton() {
fullscreenBtn.textContent = currentDisplayMode === "fullscreen" ? "⛶" : "⛶";
fullscreenBtn.title =
currentDisplayMode === "fullscreen" ? "Exit fullscreen" : "Fullscreen";
}
// Event listeners
prevBtn.addEventListener("click", prevPage);
nextBtn.addEventListener("click", nextPage);
zoomOutBtn.addEventListener("click", zoomOut);
zoomInBtn.addEventListener("click", zoomIn);
fullscreenBtn.addEventListener("click", toggleFullscreen);
pageInputEl.addEventListener("change", () => {
const page = parseInt(pageInputEl.value, 10);
if (!isNaN(page)) {
goToPage(page);
} else {
pageInputEl.value = String(currentPage);
}
});
pageInputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
pageInputEl.blur();
}
});
// Keyboard navigation
document.addEventListener("keydown", (e) => {
if (document.activeElement === pageInputEl) return;
// Ctrl/Cmd+0 to reset zoom
if ((e.ctrlKey || e.metaKey) && e.key === "0") {
resetZoom();
e.preventDefault();
return;
}
switch (e.key) {
case "Escape":
if (currentDisplayMode === "fullscreen") {
toggleFullscreen();
e.preventDefault();
}
break;
case "ArrowLeft":
case "PageUp":
prevPage();
e.preventDefault();
break;
case "ArrowRight":
case "PageDown":
case " ":
nextPage();
e.preventDefault();
break;
case "+":
case "=":
zoomIn();
e.preventDefault();
break;
case "-":
zoomOut();
e.preventDefault();
break;
}
});
// Update context when text selection changes (debounced)
let selectionUpdateTimeout: ReturnType<typeof setTimeout> | null = null;
document.addEventListener("selectionchange", () => {
if (selectionUpdateTimeout) clearTimeout(selectionUpdateTimeout);
selectionUpdateTimeout = setTimeout(() => {
const sel = window.getSelection();
const text = sel?.toString().trim();
if (text && text.length > 2) {
log.info("Selection changed:", text.slice(0, 50));
updatePageContext();
}
}, 300);
});
// Horizontal scroll/swipe to change pages (disabled when zoomed)
let horizontalScrollAccumulator = 0;
const SCROLL_THRESHOLD = 50;
canvasContainerEl.addEventListener(
"wheel",
(event) => {
const e = event as WheelEvent;
// Only intercept horizontal scroll, let vertical scroll through
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
// When zoomed, let natural panning happen (no page changes)
if (scale > 1.0) return;
// At 100% zoom, handle page navigation
e.preventDefault();
horizontalScrollAccumulator += e.deltaX;
if (horizontalScrollAccumulator > SCROLL_THRESHOLD) {
nextPage();
horizontalScrollAccumulator = 0;
} else if (horizontalScrollAccumulator < -SCROLL_THRESHOLD) {
prevPage();
horizontalScrollAccumulator = 0;
}
},
{ passive: false },
);
// Parse tool result
function parseToolResult(result: CallToolResult): {
url: string;
title?: string;
pageCount: number;
initialPage: number;
} | null {
return result.structuredContent as {
url: string;
title?: string;
pageCount: number;
initialPage: number;
} | null;
}
// Chunked binary loading types
interface PdfBytesChunk {
url: string;
bytes: string;
offset: number;
byteCount: number;
totalBytes: number;
hasMore: boolean;
}
// Update progress bar
function updateProgress(loaded: number, total: number) {
const percent = Math.round((loaded / total) * 100);
progressBarEl.style.width = `${percent}%`;
progressTextEl.textContent = `${(loaded / 1024).toFixed(0)} KB / ${(total / 1024).toFixed(0)} KB (${percent}%)`;
}
// Load PDF in chunks with progress
async function loadPdfInChunks(urlToLoad: string): Promise<Uint8Array> {
const CHUNK_SIZE = 500 * 1024; // 500KB chunks
const chunks: Uint8Array[] = [];
let offset = 0;
let totalBytes = 0;
let hasMore = true;
// Show progress UI
progressContainerEl.style.display = "block";
updateProgress(0, 1);
while (hasMore) {
const result = await app.callServerTool({
name: "read_pdf_bytes",
arguments: { url: urlToLoad, offset, byteCount: CHUNK_SIZE },
});
// Check for errors
if (result.isError) {
const errorText = result.content
?.map((c) => ("text" in c ? c.text : ""))
.join(" ");
throw new Error(`Tool error: ${errorText}`);
}
if (!result.structuredContent) {
throw new Error("No structuredContent in tool response");
}
const chunk = result.structuredContent as unknown as PdfBytesChunk;
totalBytes = chunk.totalBytes;
hasMore = chunk.hasMore;
// Decode base64 chunk
const binaryString = atob(chunk.bytes);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
chunks.push(bytes);
offset += chunk.byteCount;
updateProgress(offset, totalBytes);
}
// Combine all chunks
const fullPdf = new Uint8Array(totalBytes);
let pos = 0;
for (const chunk of chunks) {
fullPdf.set(chunk, pos);
pos += chunk.length;
}
log.info(
`PDF loaded: ${(totalBytes / 1024).toFixed(0)} KB in ${chunks.length} chunks`,
);
return fullPdf;
}
// Handle tool result
app.ontoolresult = async (result) => {
log.info("Received tool result:", result);
const parsed = parseToolResult(result);
if (!parsed) {
showError("Invalid tool result");
return;
}
pdfUrl = parsed.url;
pdfTitle = parsed.title;
totalPages = parsed.pageCount;
// Restore saved page or use initial page
const savedPage = loadSavedPage();
currentPage =
savedPage && savedPage <= parsed.pageCount ? savedPage : parsed.initialPage;
log.info(
"URL:",
pdfUrl,
"Pages:",
parsed.pageCount,
"Starting:",
currentPage,
);
showLoading("Loading PDF...");
try {
pdfBytes = await loadPdfInChunks(pdfUrl);
showLoading("Rendering PDF...");
pdfDocument = await pdfjsLib.getDocument({ data: pdfBytes }).promise;
totalPages = pdfDocument.numPages;
log.info("PDF loaded, pages:", totalPages);
showViewer();
renderPage();
} catch (err) {
log.error("Error loading PDF:", err);
showError(err instanceof Error ? err.message : String(err));
}
};
app.onerror = (err) => {
log.error("App error:", err);
showError(err instanceof Error ? err.message : String(err));
};
function handleHostContextChanged(ctx: McpUiHostContext) {
log.info("Host context changed:", ctx);
// Apply safe area insets
if (ctx.safeAreaInsets) {
mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
}
// Log containerDimensions for debugging
if (ctx.containerDimensions) {
log.info("Container dimensions:", ctx.containerDimensions);
}
// Handle display mode changes
if (ctx.displayMode) {
const wasFullscreen = currentDisplayMode === "fullscreen";
currentDisplayMode = ctx.displayMode as "inline" | "fullscreen";
if (ctx.displayMode === "fullscreen") {
mainEl.classList.add("fullscreen");
log.info("Fullscreen mode enabled");
} else {
mainEl.classList.remove("fullscreen");
log.info("Inline mode");
// When exiting fullscreen, request resize to fit content
if (wasFullscreen && pdfDocument) {
requestFitToContent();
}
}
updateFullscreenButton();
}
}
app.onhostcontextchanged = handleHostContextChanged;
// Connect to host
app.connect().then(() => {
log.info("Connected to host");
const ctx = app.getHostContext();
if (ctx) {
handleHostContextChanged(ctx);
}
});