* feat(pdf-server): Interactive PDF viewer example
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
* chore: Add pdf-server to screenshot generation list
* refactor(pdf-server): Simplify and generalize PDF loading
- 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.
* feat(pdf-server): Include title and selection in model context
- Add pdfTitle to updateModelContext structuredContent
- Include selection position (text, start, end) when text is selected
- Add debounced selectionchange listener to update context on selection
* fix(pdf-server): Restore default URL in view_pdf schema
The UI needs the default value in the schema to show it properly.
* refactor(pdf-server): Further simplifications
- 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%)
* refactor(pdf-server): Major simplification for didactic focus
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%)
* feat(pdf-server): Add file:// URL support for local files
- 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
* fix(pdf-server): Improve selection detection with logging
- 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.
* feat(pdf-server): Format model context as markdown with front matter
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.
* refactor(pdf-server): Extract smart truncation helpers
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/>
```
* fix(pdf-server): Truncate inside selection tags when selection too long
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.
* refactor(pdf-server): Remove unused read_pdf_text, use Attention paper 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
* refactor(pdf-server): Simplify to use URL as ID, rename view_pdf to display_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.
* feat(pdf-server): Normalize arxiv URLs to PDF format
arxiv.org/abs/... -> arxiv.org/pdf/...
Applied both at startup and when loading dynamic URLs.
* docs(pdf-server): Add prompt engineering to display_pdf description
* fix(pdf-server): Sharp rendering on retina displays
Account for devicePixelRatio when rendering canvas:
- Scale canvas dimensions by dpr
- Scale context by dpr
- Keep CSS size at logical pixels
* fix(pdf-server): Normalize arxiv URLs in read_pdf_bytes too
* add to e2e spec
* add to e2e spec
* add to e2e spec
* add to e2e spec
* regen
* chore: regenerate package-lock.json and fix hono vulnerability
* docs: add pdf-server screenshot to READMEs
* regen
* ci: add missing examples to pkg-pr-new publish
* ci: add pdf-server to npm publish examples
* Update README.md
* pdf-server: improve tool response text for better model context
* revert unrelated screenshot changes
* pdf-server: dynamically add arxiv URLs in read_pdf_bytes
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.
196 lines
7.3 KiB
TypeScript
196 lines
7.3 KiB
TypeScript
import { test, expect, type Page, type ConsoleMessage } from "@playwright/test";
|
|
|
|
// Dynamic element selectors to mask for screenshot comparison
|
|
//
|
|
// Note: CSS modules generate unique class names, so we use attribute selectors
|
|
// with partial matches (e.g., [class*="heatmapWrapper"]) for those components
|
|
//
|
|
// Note: map-server uses SLOW_SERVERS timeout instead of masking to wait for tiles
|
|
const DYNAMIC_MASKS: Record<string, string[]> = {
|
|
integration: ["#server-time"], // Server time display
|
|
"basic-preact": ["#server-time"], // Server time display
|
|
"basic-react": ["#server-time"], // Server time display
|
|
"basic-solid": ["#server-time"], // Server time display
|
|
"basic-svelte": ["#server-time"], // Server time display
|
|
"basic-vanillajs": ["#server-time"], // Server time display
|
|
"basic-vue": ["#server-time"], // Server time display
|
|
"cohort-heatmap": ['[class*="heatmapWrapper"]'], // Heatmap grid (random data)
|
|
"customer-segmentation": [".chart-container"], // Scatter plot (random data)
|
|
shadertoy: ["#canvas"], // WebGL shader canvas (animated)
|
|
"system-monitor": [
|
|
".chart-container", // CPU chart (highly dynamic)
|
|
"#status-text", // Current timestamp
|
|
"#memory-percent", // Memory percentage
|
|
"#memory-detail", // Memory usage details
|
|
"#memory-bar-fill", // Memory bar fill level
|
|
"#info-uptime", // System uptime
|
|
],
|
|
threejs: ["#threejs-canvas", ".threejs-container"], // 3D render canvas (dynamic animation)
|
|
"wiki-explorer": ["#graph"], // Force-directed graph (dynamic layout)
|
|
};
|
|
|
|
// Servers that need extra stabilization time (e.g., for tile loading, WebGL init)
|
|
const SLOW_SERVERS: Record<string, number> = {
|
|
"map-server": 5000, // CesiumJS needs time for tiles to load
|
|
threejs: 2000, // Three.js WebGL initialization
|
|
};
|
|
|
|
// Server configurations (key is used for screenshot filenames, name is the MCP server name)
|
|
const SERVERS = [
|
|
{ key: "integration", name: "Integration Test Server" },
|
|
{ key: "basic-preact", name: "Basic MCP App Server (Preact)" },
|
|
{ key: "basic-react", name: "Basic MCP App Server (React)" },
|
|
{ key: "basic-solid", name: "Basic MCP App Server (Solid)" },
|
|
{ key: "basic-svelte", name: "Basic MCP App Server (Svelte)" },
|
|
{ key: "basic-vanillajs", name: "Basic MCP App Server (Vanilla JS)" },
|
|
{ key: "basic-vue", name: "Basic MCP App Server (Vue)" },
|
|
{ key: "budget-allocator", name: "Budget Allocator Server" },
|
|
{ key: "cohort-heatmap", name: "Cohort Heatmap Server" },
|
|
{ key: "customer-segmentation", name: "Customer Segmentation Server" },
|
|
{ key: "map-server", name: "CesiumJS Map Server" },
|
|
{ key: "pdf-server", name: "PDF Server" },
|
|
{ key: "scenario-modeler", name: "SaaS Scenario Modeler" },
|
|
{ key: "shadertoy", name: "ShaderToy Server" },
|
|
{ key: "sheet-music", name: "Sheet Music Server" },
|
|
{ key: "system-monitor", name: "System Monitor Server" },
|
|
{ key: "threejs", name: "Three.js Server" },
|
|
{ key: "transcript", name: "Transcript Server" },
|
|
{ key: "wiki-explorer", name: "Wiki Explorer" },
|
|
];
|
|
|
|
/**
|
|
* Helper to get the app frame locator (nested: sandbox > app)
|
|
*/
|
|
function getAppFrame(page: Page) {
|
|
return page.frameLocator("iframe").first().frameLocator("iframe").first();
|
|
}
|
|
|
|
/**
|
|
* Collect console messages with [HOST] prefix
|
|
*/
|
|
function captureHostLogs(page: Page): string[] {
|
|
const logs: string[] = [];
|
|
page.on("console", (msg: ConsoleMessage) => {
|
|
const text = msg.text();
|
|
if (text.includes("[HOST]")) {
|
|
logs.push(text);
|
|
}
|
|
});
|
|
return logs;
|
|
}
|
|
|
|
/**
|
|
* Wait for the MCP App to load inside nested iframes.
|
|
* Structure: page > iframe (sandbox) > iframe (app)
|
|
*/
|
|
async function waitForAppLoad(page: Page) {
|
|
const outerFrame = page.frameLocator("iframe").first();
|
|
await expect(outerFrame.locator("iframe")).toBeVisible();
|
|
}
|
|
|
|
/**
|
|
* Load a server by selecting it by name and clicking Call Tool
|
|
*/
|
|
async function loadServer(page: Page, serverName: string) {
|
|
await page.goto("/");
|
|
// Wait for servers to connect (select becomes enabled when servers are ready)
|
|
await expect(page.locator("select").first()).toBeEnabled({ timeout: 30000 });
|
|
await page.locator("select").first().selectOption({ label: serverName });
|
|
await page.click('button:has-text("Call Tool")');
|
|
await waitForAppLoad(page);
|
|
}
|
|
|
|
/**
|
|
* Get mask locators for dynamic elements inside the nested app iframe.
|
|
*/
|
|
function getMaskLocators(page: Page, serverKey: string) {
|
|
const selectors = DYNAMIC_MASKS[serverKey];
|
|
if (!selectors) return [];
|
|
|
|
const appFrame = getAppFrame(page);
|
|
return selectors.map((selector) => appFrame.locator(selector));
|
|
}
|
|
|
|
test.describe("Host UI", () => {
|
|
test("initial state shows controls", async ({ page }) => {
|
|
await page.goto("/");
|
|
await expect(page.locator("label:has-text('Server')")).toBeVisible();
|
|
await expect(page.locator("label:has-text('Tool')")).toBeVisible();
|
|
await expect(page.locator('button:has-text("Call Tool")')).toBeVisible();
|
|
});
|
|
|
|
test("screenshot of initial state", async ({ page }) => {
|
|
await page.goto("/");
|
|
await expect(page.locator('button:has-text("Call Tool")')).toBeVisible();
|
|
await expect(page).toHaveScreenshot("host-initial.png");
|
|
});
|
|
});
|
|
|
|
// Define tests for each server using forEach to avoid for-loop issues
|
|
SERVERS.forEach((server) => {
|
|
test.describe(server.name, () => {
|
|
test("loads app UI", async ({ page }) => {
|
|
await loadServer(page, server.name);
|
|
});
|
|
|
|
test("screenshot matches golden", async ({ page }) => {
|
|
await loadServer(page, server.name);
|
|
|
|
// Some servers (WebGL, tile-based) need extra stabilization time
|
|
const stabilizationMs = SLOW_SERVERS[server.key] ?? 500;
|
|
await page.waitForTimeout(stabilizationMs);
|
|
|
|
// Get mask locators for dynamic content (timestamps, charts, etc.)
|
|
const mask = getMaskLocators(page, server.key);
|
|
|
|
await expect(page).toHaveScreenshot(`${server.key}.png`, {
|
|
mask,
|
|
maxDiffPixelRatio: 0.06,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// Interaction tests for integration server (tests all SDK communication APIs)
|
|
const integrationServer = SERVERS.find((s) => s.key === "integration")!;
|
|
|
|
test.describe(`${integrationServer.name} - Interactions`, () => {
|
|
test("Send Message button triggers host callback", async ({ page }) => {
|
|
const logs = captureHostLogs(page);
|
|
await loadServer(page, integrationServer.name);
|
|
|
|
const appFrame = getAppFrame(page);
|
|
await appFrame.locator('button:has-text("Send Message")').click();
|
|
|
|
// Wait for the async message to be processed
|
|
await page.waitForTimeout(500);
|
|
|
|
expect(logs.some((log) => log.includes("Message from MCP App"))).toBe(true);
|
|
});
|
|
|
|
test("Send Log button triggers host callback", async ({ page }) => {
|
|
const logs = captureHostLogs(page);
|
|
await loadServer(page, integrationServer.name);
|
|
|
|
const appFrame = getAppFrame(page);
|
|
await appFrame.locator('button:has-text("Send Log")').click();
|
|
|
|
await page.waitForTimeout(500);
|
|
|
|
expect(logs.some((log) => log.includes("Log message from MCP App"))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("Open Link button triggers host callback", async ({ page }) => {
|
|
const logs = captureHostLogs(page);
|
|
await loadServer(page, integrationServer.name);
|
|
|
|
const appFrame = getAppFrame(page);
|
|
await appFrame.locator('button:has-text("Open Link")').click();
|
|
|
|
await page.waitForTimeout(500);
|
|
|
|
expect(logs.some((log) => log.includes("Open link request"))).toBe(true);
|
|
});
|
|
});
|