diff --git a/README.md b/README.md
index 3b4370876..de0c4ade7 100644
--- a/README.md
+++ b/README.md
@@ -84,12 +84,12 @@ npm install -S @modelcontextprotocol/ext-apps
The SDK serves three roles: app developers building interactive Views, host developers embedding those Views, and MCP server authors registering tools with UI metadata.
-| Package | Purpose | Docs |
-|---------|---------|------|
-| `@modelcontextprotocol/ext-apps` | Build interactive Views (App class, PostMessageTransport) | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/app.html) |
-| `@modelcontextprotocol/ext-apps/react` | React hooks for Views (useApp, useHostStyles, etc.) | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/_modelcontextprotocol_ext-apps_react.html) |
-| `@modelcontextprotocol/ext-apps/app-bridge` | Embed and communicate with Views in your chat client | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/app-bridge.html) |
-| `@modelcontextprotocol/ext-apps/server` | Register tools and resources on your MCP server | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/server.html) |
+| Package | Purpose | Docs |
+| ------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
+| `@modelcontextprotocol/ext-apps` | Build interactive Views (App class, PostMessageTransport) | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/app.html) |
+| `@modelcontextprotocol/ext-apps/react` | React hooks for Views (useApp, useHostStyles, etc.) | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/_modelcontextprotocol_ext-apps_react.html) |
+| `@modelcontextprotocol/ext-apps/app-bridge` | Embed and communicate with Views in your chat client | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/app-bridge.html) |
+| `@modelcontextprotocol/ext-apps/server` | Register tools and resources on your MCP server | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/server.html) |
There's no _supported_ host implementation in this repo (beyond the [examples/basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example).
diff --git a/examples/pdf-server/mcp-app.html b/examples/pdf-server/mcp-app.html
index cf217235d..8ee8b067e 100644
--- a/examples/pdf-server/mcp-app.html
+++ b/examples/pdf-server/mcp-app.html
@@ -11,7 +11,11 @@
Loading PDF...
-
+
@@ -57,16 +61,58 @@
-
+
+
+
+
+
+ ▲
+
+
+ ▼
+
+
+ ✕
+
+
diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css
index 7600be515..286dc6141 100644
--- a/examples/pdf-server/src/mcp-app.css
+++ b/examples/pdf-server/src/mcp-app.css
@@ -13,7 +13,10 @@
--text200: light-dark(#999999, #888888);
/* Shadows */
- --shadow-page: light-dark(0 2px 8px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.4));
+ --shadow-page: light-dark(
+ 0 2px 8px rgba(0, 0, 0, 0.15),
+ 0 2px 8px rgba(0, 0, 0, 0.4)
+ );
--selection-bg: light-dark(rgba(0, 0, 255, 0.3), rgba(100, 150, 255, 0.4));
}
@@ -114,6 +117,7 @@ body {
flex-direction: column;
flex: 1;
overflow: visible;
+ position: relative;
}
/* Toolbar */
@@ -256,7 +260,10 @@ body {
display: block;
}
-/* Text Layer for Selection */
+/* Text Layer for Selection
+ * Critical: must include font-size and transform rules that PDF.js TextLayer
+ * relies on via CSS custom properties (--font-height, --scale-x, --rotate).
+ * Without these, text layer spans won't align with the canvas rendering. */
.text-layer {
position: absolute;
left: 0;
@@ -270,10 +277,11 @@ body {
forced-color-adjust: none;
transform-origin: 0 0;
z-index: 2;
+ /* PDF.js TextLayer sets --min-font-size on container */
+ --min-font-size-inv: calc(1 / var(--min-font-size, 1));
}
-.text-layer span,
-.text-layer br {
+.text-layer :is(span, br) {
color: transparent;
position: absolute;
white-space: pre;
@@ -281,6 +289,23 @@ body {
transform-origin: 0% 0%;
}
+/* PDF.js sets --font-height, --scale-x, --rotate as inline styles on each span.
+ * These rules apply proper font size and transforms to match the canvas. */
+.text-layer > :not(.markedContent),
+.text-layer .markedContent span:not(.markedContent) {
+ z-index: 1;
+ --font-height: 0;
+ font-size: calc(var(--scale-factor, 1) * var(--font-height));
+ --scale-x: 1;
+ --rotate: 0deg;
+ transform: rotate(var(--rotate)) scaleX(var(--scale-x))
+ scale(var(--min-font-size-inv, 1));
+}
+
+.text-layer .markedContent {
+ display: contents;
+}
+
.text-layer ::selection {
background: var(--selection-bg);
}
@@ -310,3 +335,127 @@ body {
min-height: 0; /* Allow flex item to shrink below content size */
overflow: auto; /* Scroll within the document area only */
}
+
+/* Search Button */
+.search-btn,
+.nav-btn,
+.zoom-btn,
+.fullscreen-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border: 1px solid var(--bg200);
+ border-radius: 4px;
+ background: var(--bg000);
+ color: var(--text000);
+ cursor: pointer;
+ font-size: 1rem;
+ transition: all 0.15s ease;
+}
+
+.search-btn:hover,
+.nav-btn:hover:not(:disabled),
+.zoom-btn:hover:not(:disabled),
+.fullscreen-btn:hover {
+ background: var(--bg100);
+ border-color: var(--bg300);
+}
+
+/* Search Bar */
+.search-bar {
+ display: flex;
+ align-items: center;
+ position: absolute;
+ top: 47px; /* below toolbar (48px - 1px border overlap) */
+ right: -1px; /* align with outer border */
+ padding: 0.375rem 0.5rem;
+ background: var(--bg000);
+ border: 1px solid var(--bg200);
+ border-top: none;
+ border-radius: 0 0 6px 6px;
+ gap: 0.5rem;
+ z-index: 10;
+}
+
+.search-input {
+ width: 200px;
+ padding: 0.25rem 0.5rem;
+ border: 1px solid var(--bg200);
+ border-radius: 4px;
+ font-size: 0.85rem;
+ background: var(--bg000);
+ color: var(--text000);
+}
+
+.search-input:focus {
+ outline: none;
+ border-color: var(--text100);
+}
+
+.search-match-count {
+ font-size: 0.8rem;
+ color: var(--text100);
+ white-space: nowrap;
+ min-width: 60px;
+}
+
+.search-nav-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: 1px solid var(--bg200);
+ border-radius: 4px;
+ background: var(--bg000);
+ color: var(--text000);
+ cursor: pointer;
+ font-size: 0.8rem;
+ transition: all 0.15s ease;
+}
+
+.search-nav-btn:hover:not(:disabled) {
+ background: var(--bg100);
+ border-color: var(--bg300);
+}
+
+.search-nav-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/* Highlight Layer */
+.highlight-layer {
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ pointer-events: none;
+ z-index: 1;
+}
+
+.search-highlight {
+ background: rgba(255, 255, 0, 0.4);
+ mix-blend-mode: multiply;
+ border-radius: 2px;
+ pointer-events: none;
+}
+
+.search-highlight.current {
+ background: rgba(255, 165, 0, 0.6);
+}
+
+@media (prefers-color-scheme: dark) {
+ .search-highlight {
+ background: rgba(255, 255, 0, 0.3);
+ mix-blend-mode: screen;
+ }
+
+ .search-highlight.current {
+ background: rgba(255, 165, 0, 0.5);
+ mix-blend-mode: screen;
+ }
+}
diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts
index ad2b8bf0f..e5299a55a 100644
--- a/examples/pdf-server/src/mcp-app.ts
+++ b/examples/pdf-server/src/mcp-app.ts
@@ -69,6 +69,38 @@ const fullscreenBtn = document.getElementById(
const progressContainerEl = document.getElementById("progress-container")!;
const progressBarEl = document.getElementById("progress-bar")!;
const progressTextEl = document.getElementById("progress-text")!;
+const searchBtn = document.getElementById("search-btn") as HTMLButtonElement;
+searchBtn.innerHTML = ``;
+const searchBarEl = document.getElementById("search-bar")!;
+const searchInputEl = document.getElementById(
+ "search-input",
+) as HTMLInputElement;
+const searchMatchCountEl = document.getElementById("search-match-count")!;
+const searchPrevBtn = document.getElementById(
+ "search-prev-btn",
+) as HTMLButtonElement;
+const searchNextBtn = document.getElementById(
+ "search-next-btn",
+) as HTMLButtonElement;
+const searchCloseBtn = document.getElementById(
+ "search-close-btn",
+) as HTMLButtonElement;
+const highlightLayerEl = document.getElementById("highlight-layer")!;
+
+// Search state
+interface SearchMatch {
+ pageNum: number;
+ index: number;
+ length: number;
+}
+
+let searchOpen = false;
+let searchQuery = "";
+let searchDebounceTimer: ReturnType | null = null;
+const pageTextCache = new Map();
+const pageTextItemsCache = new Map();
+let allMatches: SearchMatch[] = [];
+let currentMatchIndex = -1;
// Track current display mode
let currentDisplayMode: "inline" | "fullscreen" = "inline";
@@ -106,16 +138,262 @@ function requestFitToContent() {
const paddingBottom = parseFloat(containerStyle.paddingBottom);
// Calculate required height:
- // toolbar + padding-top + page-wrapper height + padding-bottom + buffer
+ // toolbar + search-bar + padding-top + page-wrapper height + padding-bottom + buffer
const toolbarHeight = toolbarEl.offsetHeight;
+ const searchBarHeight = searchOpen ? searchBarEl.offsetHeight : 0;
const pageWrapperHeight = pageWrapperEl.offsetHeight;
const BUFFER = 10; // Buffer for sub-pixel rounding and browser quirks
const totalHeight =
- toolbarHeight + paddingTop + pageWrapperHeight + paddingBottom + BUFFER;
+ toolbarHeight +
+ searchBarHeight +
+ paddingTop +
+ pageWrapperHeight +
+ paddingBottom +
+ BUFFER;
app.sendSizeChanged({ height: totalHeight });
}
+// --- Search Functions ---
+
+async function extractAllPageText() {
+ if (!pdfDocument) return;
+ for (let i = 1; i <= totalPages; i++) {
+ if (pageTextCache.has(i)) continue;
+ try {
+ const page = await pdfDocument.getPage(i);
+ const textContent = await page.getTextContent();
+ const items = (textContent.items as Array<{ str?: string }>).map(
+ (item) => item.str || "",
+ );
+ pageTextItemsCache.set(i, items);
+ pageTextCache.set(i, items.join(""));
+ } catch (err) {
+ log.error("Error extracting text for page", i, err);
+ }
+ }
+}
+
+function performSearch(query: string) {
+ allMatches = [];
+ currentMatchIndex = -1;
+ searchQuery = query;
+
+ if (!query) {
+ updateSearchUI();
+ clearHighlights();
+ return;
+ }
+
+ const lowerQuery = query.toLowerCase();
+ for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
+ const pageText = pageTextCache.get(pageNum);
+ if (!pageText) continue;
+ const lowerText = pageText.toLowerCase();
+ let startIdx = 0;
+ while (true) {
+ const idx = lowerText.indexOf(lowerQuery, startIdx);
+ if (idx === -1) break;
+ allMatches.push({ pageNum, index: idx, length: query.length });
+ startIdx = idx + 1;
+ }
+ }
+
+ // Set current match to first match on or after current page
+ if (allMatches.length > 0) {
+ const idx = allMatches.findIndex((m) => m.pageNum >= currentPage);
+ currentMatchIndex = idx >= 0 ? idx : 0;
+ }
+
+ updateSearchUI();
+ renderHighlights();
+
+ // Navigate to match page if needed
+ if (allMatches.length > 0 && currentMatchIndex >= 0) {
+ const match = allMatches[currentMatchIndex];
+ if (match.pageNum !== currentPage) {
+ goToPage(match.pageNum);
+ }
+ }
+}
+
+function renderHighlights() {
+ clearHighlights();
+ if (!searchQuery || allMatches.length === 0) return;
+
+ const spans = Array.from(
+ textLayerEl.querySelectorAll("span"),
+ ) as HTMLElement[];
+ if (spans.length === 0) return;
+
+ const pageMatches = allMatches.filter((m) => m.pageNum === currentPage);
+ if (pageMatches.length === 0) return;
+
+ const lowerQuery = searchQuery.toLowerCase();
+ const lowerQueryLen = lowerQuery.length;
+
+ // Position highlight divs over matching text using Range API.
+ const wrapperEl = textLayerEl.parentElement!;
+ const wrapperRect = wrapperEl.getBoundingClientRect();
+
+ let domMatchOrdinal = 0;
+
+ for (const span of spans) {
+ const text = span.textContent || "";
+ if (text.length === 0) continue;
+ const lowerText = text.toLowerCase();
+ if (!lowerText.includes(lowerQuery)) continue;
+
+ // Find all match positions within this span
+ const matchPositions: number[] = [];
+ let pos = 0;
+ while (true) {
+ const idx = lowerText.indexOf(lowerQuery, pos);
+ if (idx === -1) break;
+ matchPositions.push(idx);
+ pos = idx + 1;
+ }
+ if (matchPositions.length === 0) continue;
+
+ const textNode = span.firstChild;
+ if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue;
+
+ for (const idx of matchPositions) {
+ const isCurrentMatch =
+ domMatchOrdinal < pageMatches.length &&
+ allMatches.indexOf(pageMatches[domMatchOrdinal]) === currentMatchIndex;
+
+ try {
+ const range = document.createRange();
+ range.setStart(textNode, idx);
+ range.setEnd(textNode, Math.min(idx + lowerQueryLen, text.length));
+ const rects = range.getClientRects();
+
+ for (let ri = 0; ri < rects.length; ri++) {
+ const r = rects[ri];
+ const div = document.createElement("div");
+ div.className =
+ "search-highlight" + (isCurrentMatch ? " current" : "");
+ div.style.position = "absolute";
+ div.style.left = `${r.left - wrapperRect.left}px`;
+ div.style.top = `${r.top - wrapperRect.top}px`;
+ div.style.width = `${r.width}px`;
+ div.style.height = `${r.height}px`;
+ highlightLayerEl.appendChild(div);
+ }
+ } catch {
+ // Range errors can happen with stale text nodes
+ }
+
+ domMatchOrdinal++;
+ }
+ }
+
+ // Scroll current highlight into view only if not already visible
+ const currentHL = highlightLayerEl.querySelector(
+ ".search-highlight.current",
+ ) as HTMLElement;
+ if (currentHL) {
+ const scrollParent =
+ currentDisplayMode === "fullscreen"
+ ? document.querySelector(".canvas-container")
+ : null;
+ if (scrollParent) {
+ const sr = scrollParent.getBoundingClientRect();
+ const hr = currentHL.getBoundingClientRect();
+ if (hr.top < sr.top || hr.bottom > sr.bottom) {
+ currentHL.scrollIntoView({ block: "center", behavior: "smooth" });
+ }
+ } else {
+ // Inline mode: check visibility in viewport
+ const hr = currentHL.getBoundingClientRect();
+ if (hr.top < 0 || hr.bottom > window.innerHeight) {
+ currentHL.scrollIntoView({ block: "center", behavior: "smooth" });
+ }
+ }
+ }
+}
+
+function clearHighlights() {
+ highlightLayerEl.innerHTML = "";
+}
+
+function updateSearchUI() {
+ const hasQuery = searchQuery.length > 0;
+ if (allMatches.length === 0) {
+ searchMatchCountEl.textContent = hasQuery ? "No matches" : "";
+ } else {
+ searchMatchCountEl.textContent = `${currentMatchIndex + 1} of ${allMatches.length}`;
+ }
+ searchPrevBtn.disabled = allMatches.length === 0;
+ searchNextBtn.disabled = allMatches.length === 0;
+ // Hide nav controls when there's no query
+ const vis = hasQuery ? "" : "none";
+ searchMatchCountEl.style.display = vis;
+ searchPrevBtn.style.display = vis;
+ searchNextBtn.style.display = vis;
+}
+
+function openSearch() {
+ if (searchOpen) {
+ searchInputEl.focus();
+ searchInputEl.select();
+ return;
+ }
+ searchOpen = true;
+ searchBarEl.style.display = "flex";
+ updateSearchUI();
+ searchInputEl.focus();
+ requestFitToContent();
+ extractAllPageText();
+}
+
+function closeSearch() {
+ if (!searchOpen) return;
+ searchOpen = false;
+ searchBarEl.style.display = "none";
+ searchQuery = "";
+ searchInputEl.value = "";
+ allMatches = [];
+ currentMatchIndex = -1;
+ clearHighlights();
+ updateSearchUI();
+ requestFitToContent();
+}
+
+function toggleSearch() {
+ if (searchOpen) {
+ closeSearch();
+ } else {
+ openSearch();
+ }
+}
+
+function goToNextMatch() {
+ if (allMatches.length === 0) return;
+ currentMatchIndex = (currentMatchIndex + 1) % allMatches.length;
+ const match = allMatches[currentMatchIndex];
+ updateSearchUI();
+ if (match.pageNum !== currentPage) {
+ goToPage(match.pageNum);
+ } else {
+ renderHighlights();
+ }
+}
+
+function goToPrevMatch() {
+ if (allMatches.length === 0) return;
+ currentMatchIndex =
+ (currentMatchIndex - 1 + allMatches.length) % allMatches.length;
+ const match = allMatches[currentMatchIndex];
+ updateSearchUI();
+ if (match.pageNum !== currentPage) {
+ goToPage(match.pageNum);
+ } else {
+ renderHighlights();
+ }
+}
+
// Create app instance
// autoResize disabled - app fills its container, doesn't request size changes
const app = new App(
@@ -391,6 +669,8 @@ async function renderPage() {
textLayerEl.innerHTML = "";
textLayerEl.style.width = `${viewport.width}px`;
textLayerEl.style.height = `${viewport.height}px`;
+ // Set --scale-factor so CSS font-size/transform rules work correctly.
+ textLayerEl.style.setProperty("--scale-factor", `${scale}`);
// Render canvas - track the task so we can cancel it
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -430,6 +710,24 @@ async function renderPage() {
});
await textLayer.render();
+ // Cache page text items if not already cached
+ if (!pageTextItemsCache.has(pageToRender)) {
+ const items = (textContent.items as Array<{ str?: string }>).map(
+ (item) => item.str || "",
+ );
+ pageTextItemsCache.set(pageToRender, items);
+ pageTextCache.set(pageToRender, items.join(""));
+ }
+
+ // Size highlight layer to match canvas
+ highlightLayerEl.style.width = `${viewport.width}px`;
+ highlightLayerEl.style.height = `${viewport.height}px`;
+
+ // Re-render search highlights if search is active
+ if (searchOpen && searchQuery) {
+ renderHighlights();
+ }
+
updateControls();
updatePageContext();
@@ -549,8 +847,34 @@ prevBtn.addEventListener("click", prevPage);
nextBtn.addEventListener("click", nextPage);
zoomOutBtn.addEventListener("click", zoomOut);
zoomInBtn.addEventListener("click", zoomIn);
+searchBtn.addEventListener("click", toggleSearch);
+searchCloseBtn.addEventListener("click", closeSearch);
+searchPrevBtn.addEventListener("click", goToPrevMatch);
+searchNextBtn.addEventListener("click", goToNextMatch);
fullscreenBtn.addEventListener("click", toggleFullscreen);
+// Search input events
+searchInputEl.addEventListener("input", () => {
+ if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
+ searchDebounceTimer = setTimeout(() => {
+ performSearch(searchInputEl.value);
+ }, 300);
+});
+
+searchInputEl.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ if (e.shiftKey) {
+ goToPrevMatch();
+ } else {
+ goToNextMatch();
+ }
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ closeSearch();
+ }
+});
+
pageInputEl.addEventListener("change", () => {
const page = parseInt(pageInputEl.value, 10);
if (!isNaN(page)) {
@@ -568,6 +892,25 @@ pageInputEl.addEventListener("keydown", (e) => {
// Keyboard navigation
document.addEventListener("keydown", (e) => {
+ // Ctrl/Cmd+F: open our search if closed; if already focused, pass through to browser find
+ if ((e.ctrlKey || e.metaKey) && e.key === "f") {
+ if (!searchOpen) {
+ e.preventDefault();
+ openSearch();
+ } else if (document.activeElement === searchInputEl) {
+ // Already focused — close ours and let browser find open
+ closeSearch();
+ } else {
+ // Open but not focused — re-focus our search
+ e.preventDefault();
+ searchInputEl.focus();
+ searchInputEl.select();
+ }
+ return;
+ }
+
+ // Don't handle nav shortcuts when search input is focused
+ if (document.activeElement === searchInputEl) return;
if (document.activeElement === pageInputEl) return;
// Ctrl/Cmd+0 to reset zoom
@@ -579,7 +922,10 @@ document.addEventListener("keydown", (e) => {
switch (e.key) {
case "Escape":
- if (currentDisplayMode === "fullscreen") {
+ if (searchOpen) {
+ closeSearch();
+ e.preventDefault();
+ } else if (currentDisplayMode === "fullscreen") {
toggleFullscreen();
e.preventDefault();
}