diff --git a/examples/basic-server-react/grid-cell.png b/examples/basic-server-react/grid-cell.png
index e6a309d7..2136c161 100644
Binary files a/examples/basic-server-react/grid-cell.png and b/examples/basic-server-react/grid-cell.png differ
diff --git a/examples/basic-server-react/screenshot.png b/examples/basic-server-react/screenshot.png
index 31df3010..b9a9245a 100644
Binary files a/examples/basic-server-react/screenshot.png and b/examples/basic-server-react/screenshot.png differ
diff --git a/examples/budget-allocator-server/grid-cell.png b/examples/budget-allocator-server/grid-cell.png
index dee5a313..b2019ff0 100644
Binary files a/examples/budget-allocator-server/grid-cell.png and b/examples/budget-allocator-server/grid-cell.png differ
diff --git a/examples/budget-allocator-server/screenshot.png b/examples/budget-allocator-server/screenshot.png
index 1bd9783c..b50b9ac9 100644
Binary files a/examples/budget-allocator-server/screenshot.png and b/examples/budget-allocator-server/screenshot.png differ
diff --git a/examples/cohort-heatmap-server/grid-cell.png b/examples/cohort-heatmap-server/grid-cell.png
index 4349361f..21d188e2 100644
Binary files a/examples/cohort-heatmap-server/grid-cell.png and b/examples/cohort-heatmap-server/grid-cell.png differ
diff --git a/examples/cohort-heatmap-server/screenshot.png b/examples/cohort-heatmap-server/screenshot.png
index e469da81..21d9ecd8 100644
Binary files a/examples/cohort-heatmap-server/screenshot.png and b/examples/cohort-heatmap-server/screenshot.png differ
diff --git a/examples/customer-segmentation-server/grid-cell.png b/examples/customer-segmentation-server/grid-cell.png
index 992f95b7..70b2edb6 100644
Binary files a/examples/customer-segmentation-server/grid-cell.png and b/examples/customer-segmentation-server/grid-cell.png differ
diff --git a/examples/customer-segmentation-server/screenshot.png b/examples/customer-segmentation-server/screenshot.png
index e643013d..f75c8893 100644
Binary files a/examples/customer-segmentation-server/screenshot.png and b/examples/customer-segmentation-server/screenshot.png differ
diff --git a/examples/debug-server/grid-cell.png b/examples/debug-server/grid-cell.png
index 2f1814f7..fe8d0647 100644
Binary files a/examples/debug-server/grid-cell.png and b/examples/debug-server/grid-cell.png differ
diff --git a/examples/debug-server/screenshot.png b/examples/debug-server/screenshot.png
index 869fec08..4be854d9 100644
Binary files a/examples/debug-server/screenshot.png and b/examples/debug-server/screenshot.png differ
diff --git a/examples/map-server/grid-cell.png b/examples/map-server/grid-cell.png
index 41ebc224..f680d997 100644
Binary files a/examples/map-server/grid-cell.png and b/examples/map-server/grid-cell.png differ
diff --git a/examples/map-server/screenshot.png b/examples/map-server/screenshot.png
index 97c91912..edd01512 100644
Binary files a/examples/map-server/screenshot.png and b/examples/map-server/screenshot.png differ
diff --git a/examples/pdf-server/README.md b/examples/pdf-server/README.md
index b73622cf..e70e9565 100644
--- a/examples/pdf-server/README.md
+++ b/examples/pdf-server/README.md
@@ -156,11 +156,92 @@ bun examples/pdf-server/main.ts --stdio ./papers/
## Tools
-| 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 |
+| Tool | Visibility | Purpose |
+| ---------------- | ---------- | ----------------------------------------------------- |
+| `list_pdfs` | Model | List available local files and origins |
+| `display_pdf` | Model + UI | Display interactive viewer |
+| `interact` | Model | Navigate, annotate, search, extract pages, fill forms |
+| `read_pdf_bytes` | App only | Stream PDF data in chunks |
+
+## Example Prompts
+
+After the model calls `display_pdf`, it receives the `viewUUID` and a description of all capabilities. Here are example prompts and follow-ups that exercise annotation features:
+
+### Annotating
+
+> **User:** Show me the Attention Is All You Need paper
+>
+> _Model calls `display_pdf` → viewer opens_
+>
+> **User:** Highlight the title and add an APPROVED stamp on the first page.
+>
+> _Model calls `interact` with `highlight_text` for the title and `add_annotations` with a stamp_
+
+> **User:** Can you annotate this PDF? Mark important sections for me.
+>
+> _Model calls `interact` with `get_pages` to read content first, then `add_annotations` with highlights/notes_
+
+> **User:** Add a note on page 1 saying "Key contribution" at position (200, 500), and highlight the abstract.
+>
+> _Model calls `interact` with `add_annotations` containing a `note` and either `highlight_text` or a `highlight` annotation_
+
+### Navigation & Search
+
+> **User:** Search for "self-attention" in the paper.
+>
+> _Model calls `interact` with action `search`, query `"self-attention"`_
+
+> **User:** Go to page 5.
+>
+> _Model calls `interact` with action `navigate`, page `5`_
+
+### Page Extraction
+
+> **User:** Give me the text of pages 1–3.
+>
+> _Model calls `interact` with action `get_pages`, intervals `[{start:1, end:3}]`, getText `true`_
+
+> **User:** Take a screenshot of the first page.
+>
+> _Model calls `interact` with action `get_pages`, intervals `[{start:1, end:1}]`, getScreenshots `true`_
+
+### Stamps & Form Filling
+
+> **User:** Stamp this document as CONFIDENTIAL on every page.
+>
+> _Model calls `interact` with `add_annotations` containing `stamp` annotations on each page_
+
+> **User:** Fill in the "Name" field with "Alice" and "Date" with "2026-02-26".
+>
+> _Model calls `interact` with action `fill_form`, fields `[{name:"Name", value:"Alice"}, {name:"Date", value:"2026-02-26"}]`_
+
+## Testing
+
+### E2E Tests (Playwright)
+
+```bash
+# Run annotation E2E tests (renders annotations in a real browser)
+npx playwright test tests/e2e/pdf-annotations.spec.ts
+
+# Run all PDF server tests
+npx playwright test -g "PDF Server"
+```
+
+### API Prompt Discovery Tests
+
+These tests verify that Claude can discover and use annotation capabilities by calling the Anthropic Messages API with the tool schemas. They are **disabled by default** — skipped unless `ANTHROPIC_API_KEY` is set:
+
+```bash
+ANTHROPIC_API_KEY=sk-ant-... npx playwright test tests/e2e/pdf-annotations-api.spec.ts
+```
+
+The API tests simulate a conversation where `display_pdf` has already been called, then send a follow-up user message and verify the model uses annotation actions (or at least the `interact` tool). Three scenarios are tested:
+
+| Scenario | User prompt | Expected model behavior |
+| -------------------- | ----------------------------------------------------------------- | ------------------------------------------ |
+| Direct annotation | "Highlight the title and add an APPROVED stamp" | Uses `highlight_text` or `add_annotations` |
+| Capability discovery | "Can you annotate this PDF?" | Uses interact or mentions annotations |
+| Specific notes | "Add a note saying 'Key contribution' and highlight the abstract" | Uses `interact` tool |
## Architecture
@@ -182,8 +263,12 @@ src/
| External links | `app.openLink()` |
| View persistence | `viewUUID` + localStorage |
| Theming | `applyDocumentTheme()` + CSS `light-dark()` |
+| Annotations | DOM overlays + pdf-lib embed on download |
+| Command queue | Server enqueues → client polls + processes |
+| File download | `app.downloadFile()` for annotated PDF |
## Dependencies
- `pdfjs-dist`: PDF rendering (frontend only)
+- `pdf-lib`: Client-side PDF modification for annotated download
- `@modelcontextprotocol/ext-apps`: MCP Apps SDK
diff --git a/examples/pdf-server/grid-cell.png b/examples/pdf-server/grid-cell.png
index 5acd2227..0e6e571d 100644
Binary files a/examples/pdf-server/grid-cell.png and b/examples/pdf-server/grid-cell.png differ
diff --git a/examples/pdf-server/mcp-app.html b/examples/pdf-server/mcp-app.html
index c18345d2..3c56a382 100644
--- a/examples/pdf-server/mcp-app.html
+++ b/examples/pdf-server/mcp-app.html
@@ -60,6 +60,14 @@
+
+
+
+
diff --git a/examples/pdf-server/package.json b/examples/pdf-server/package.json
index 56cb7c8c..ca8924f7 100644
--- a/examples/pdf-server/package.json
+++ b/examples/pdf-server/package.json
@@ -28,6 +28,7 @@
"@modelcontextprotocol/sdk": "^1.24.0",
"cors": "^2.8.5",
"express": "^5.1.0",
+ "pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.0.0",
"zod": "^4.1.13"
},
diff --git a/examples/pdf-server/screenshot.png b/examples/pdf-server/screenshot.png
index bdb8f176..fd87f8f5 100644
Binary files a/examples/pdf-server/screenshot.png and b/examples/pdf-server/screenshot.png differ
diff --git a/examples/pdf-server/server.ts b/examples/pdf-server/server.ts
index ccc400f0..fb2e3df1 100644
--- a/examples/pdf-server/server.ts
+++ b/examples/pdf-server/server.ts
@@ -55,6 +55,231 @@ const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
: import.meta.dirname;
+// =============================================================================
+// Command Queue (shared across stateless server instances)
+// =============================================================================
+
+/** Commands expire after this many ms if never polled */
+const COMMAND_TTL_MS = 60_000; // 60 seconds
+
+/** Periodic sweep interval to drop stale queues */
+const SWEEP_INTERVAL_MS = 30_000; // 30 seconds
+
+/** Fixed batch window: when commands are present, wait this long before returning to let more accumulate */
+const POLL_BATCH_WAIT_MS = 200;
+
+// =============================================================================
+// Annotation Types
+// =============================================================================
+
+/** Rectangle in PDF coordinate space (bottom-left origin, in PDF points) */
+const RectSchema = z.object({
+ x: z.number(),
+ y: z.number(),
+ width: z.number(),
+ height: z.number(),
+});
+
+const StampLabel = z.enum([
+ "APPROVED",
+ "DRAFT",
+ "CONFIDENTIAL",
+ "FINAL",
+ "VOID",
+ "REJECTED",
+]);
+
+const AnnotationBase = z.object({
+ id: z.string(),
+ page: z.number().min(1),
+});
+
+const HighlightAnnotation = AnnotationBase.extend({
+ type: z.literal("highlight"),
+ rects: z.array(RectSchema).min(1),
+ color: z.string().optional(),
+ content: z.string().optional(),
+});
+
+const UnderlineAnnotation = AnnotationBase.extend({
+ type: z.literal("underline"),
+ rects: z.array(RectSchema).min(1),
+ color: z.string().optional(),
+});
+
+const StrikethroughAnnotation = AnnotationBase.extend({
+ type: z.literal("strikethrough"),
+ rects: z.array(RectSchema).min(1),
+ color: z.string().optional(),
+});
+
+const NoteAnnotation = AnnotationBase.extend({
+ type: z.literal("note"),
+ x: z.number(),
+ y: z.number(),
+ content: z.string(),
+ color: z.string().optional(),
+});
+
+const RectangleAnnotation = AnnotationBase.extend({
+ type: z.literal("rectangle"),
+ x: z.number(),
+ y: z.number(),
+ width: z.number(),
+ height: z.number(),
+ color: z.string().optional(),
+ fillColor: z.string().optional(),
+});
+
+const FreetextAnnotation = AnnotationBase.extend({
+ type: z.literal("freetext"),
+ x: z.number(),
+ y: z.number(),
+ content: z.string(),
+ fontSize: z.number().optional(),
+ color: z.string().optional(),
+});
+
+const StampAnnotation = AnnotationBase.extend({
+ type: z.literal("stamp"),
+ x: z.number(),
+ y: z.number(),
+ label: StampLabel,
+ color: z.string().optional(),
+ rotation: z.number().optional(),
+});
+
+const PdfAnnotationDef = z.discriminatedUnion("type", [
+ HighlightAnnotation,
+ UnderlineAnnotation,
+ StrikethroughAnnotation,
+ NoteAnnotation,
+ RectangleAnnotation,
+ FreetextAnnotation,
+ StampAnnotation,
+]);
+
+/** Partial annotation update — id + type required, rest optional */
+const PdfAnnotationUpdate = z.union([
+ HighlightAnnotation.partial().required({ id: true, type: true }),
+ UnderlineAnnotation.partial().required({ id: true, type: true }),
+ StrikethroughAnnotation.partial().required({ id: true, type: true }),
+ NoteAnnotation.partial().required({ id: true, type: true }),
+ RectangleAnnotation.partial().required({ id: true, type: true }),
+ FreetextAnnotation.partial().required({ id: true, type: true }),
+ StampAnnotation.partial().required({ id: true, type: true }),
+]);
+
+const FormField = z.object({
+ name: z.string(),
+ value: z.union([z.string(), z.boolean()]),
+});
+
+const PageInterval = z.object({
+ start: z.number().min(1).optional(),
+ end: z.number().min(1).optional(),
+});
+
+// =============================================================================
+// Command Queue (shared across stateless server instances)
+// =============================================================================
+
+export type PdfCommand =
+ | { type: "navigate"; page: number }
+ | { type: "search"; query: string }
+ | { type: "find"; query: string }
+ | { type: "search_navigate"; matchIndex: number }
+ | { type: "zoom"; scale: number }
+ | {
+ type: "add_annotations";
+ annotations: z.infer[];
+ }
+ | {
+ type: "update_annotations";
+ annotations: z.infer[];
+ }
+ | { type: "remove_annotations"; ids: string[] }
+ | {
+ type: "highlight_text";
+ id: string;
+ query: string;
+ page?: number;
+ color?: string;
+ content?: string;
+ }
+ | {
+ type: "fill_form";
+ fields: z.infer[];
+ }
+ | {
+ type: "get_pages";
+ requestId: string;
+ intervals: Array<{ start?: number; end?: number }>;
+ getText: boolean;
+ getScreenshots: boolean;
+ };
+
+// =============================================================================
+// Pending get_pages Requests (request-response bridge via client)
+// =============================================================================
+
+const GET_PAGES_TIMEOUT_MS = 60_000; // 60s — rendering many pages can be slow
+
+interface PageDataEntry {
+ page: number;
+ text?: string;
+ image?: string; // base64 PNG
+}
+
+interface PendingPageRequest {
+ resolve: (data: PageDataEntry[]) => void;
+ reject: (error: Error) => void;
+ timer: ReturnType;
+}
+
+const pendingPageRequests = new Map();
+
+interface QueueEntry {
+ commands: PdfCommand[];
+ /** Timestamp of the most recent enqueue or dequeue */
+ lastActivity: number;
+}
+
+const commandQueues = new Map();
+
+/** Active viewer UUIDs — tracks UUIDs issued by display_pdf */
+const activeViewUUIDs = new Set();
+
+function pruneStaleQueues(): void {
+ const now = Date.now();
+ for (const [uuid, entry] of commandQueues) {
+ if (now - entry.lastActivity > COMMAND_TTL_MS) {
+ commandQueues.delete(uuid);
+ }
+ }
+}
+
+// Periodic sweep so abandoned queues don't leak
+setInterval(pruneStaleQueues, SWEEP_INTERVAL_MS).unref();
+
+function enqueueCommand(viewUUID: string, command: PdfCommand): void {
+ let entry = commandQueues.get(viewUUID);
+ if (!entry) {
+ entry = { commands: [], lastActivity: Date.now() };
+ commandQueues.set(viewUUID, entry);
+ }
+ entry.commands.push(command);
+ entry.lastActivity = Date.now();
+}
+
+function dequeueCommands(viewUUID: string): PdfCommand[] {
+ const entry = commandQueues.get(viewUUID);
+ if (!entry) return [];
+ const commands = entry.commands;
+ commandQueues.delete(viewUUID);
+ return commands;
+}
+
// =============================================================================
// URL Validation & Normalization
// =============================================================================
@@ -616,21 +841,477 @@ Accepts:
// Probe file size so the client can set up range transport without an extra fetch
const { totalBytes } = await readPdfRange(normalized, 0, 1);
+ const uuid = randomUUID();
+ activeViewUUIDs.add(uuid);
return {
- content: [{ type: "text", text: `Displaying PDF: ${normalized}` }],
+ content: [
+ {
+ type: "text",
+ text: `Displaying PDF (viewUUID: ${uuid}): ${normalized}.\n\nUse the \`interact\` tool with this viewUUID. Available actions:\n- navigate: go to a page\n- search / find: search text (search highlights in UI, find is silent)\n- search_navigate: jump to a search match by index\n- zoom: set zoom level (0.5–3.0)\n- add_annotations: add highlights, underlines, strikethroughs, notes, rectangles, freetext, stamps (APPROVED/DRAFT/CONFIDENTIAL/FINAL/VOID/REJECTED)\n- update_annotations: partially update existing annotations\n- remove_annotations: remove annotations by ID\n- highlight_text: find text by query and highlight it automatically\n- fill_form: fill PDF form fields\n- get_pages: extract text and/or screenshots from page ranges without navigating`,
+ },
+ ],
structuredContent: {
url: normalized,
initialPage: page,
totalBytes,
},
_meta: {
- viewUUID: randomUUID(),
+ viewUUID: uuid,
},
};
},
);
+ // Tool: interact - Interact with an existing PDF viewer
+ server.registerTool(
+ "interact",
+ {
+ title: "Interact with PDF",
+ description: `Interact with a PDF viewer: annotate, navigate, search, extract pages, fill forms.
+IMPORTANT: viewUUID must be the exact UUID returned by display_pdf (e.g. "a1b2c3d4-..."). Do NOT use arbitrary strings.
+
+**ANNOTATION** — You can add visual annotations to any page. Use add_annotations with an array of annotation objects.
+Each annotation needs: id (unique string), type, page (1-indexed).
+Coordinates use PDF points (72 dpi), bottom-left origin.
+
+Annotation types:
+• highlight: rects:[{x,y,width,height}], color?, content? — semi-transparent overlay on text regions
+• underline: rects:[{x,y,width,height}], color? — underline below text
+• strikethrough: rects:[{x,y,width,height}], color? — line through text
+• note: x, y, content, color? — sticky note icon with tooltip
+• rectangle: x, y, width, height, color?, fillColor? — outlined/filled box
+• freetext: x, y, content, fontSize?, color? — arbitrary text label
+• stamp: x, y, label (APPROVED|DRAFT|CONFIDENTIAL|FINAL|VOID|REJECTED), color?, rotation? — stamp overlay
+
+Example — add a highlight and a stamp on page 1:
+\`\`\`json
+{"action":"add_annotations","viewUUID":"…","annotations":[
+ {"id":"h1","type":"highlight","page":1,"rects":[{"x":72,"y":700,"width":200,"height":12}]},
+ {"id":"s1","type":"stamp","page":1,"x":300,"y":500,"label":"APPROVED","color":"green","rotation":-15}
+]}
+\`\`\`
+
+**HIGHLIGHT TEXT** — highlight_text: auto-find and highlight text by query. Requires \`query\`. Optional: page, color, content.
+
+**ANNOTATION MANAGEMENT**:
+• update_annotations: partial update (id+type required). • remove_annotations: remove by ids.
+
+**NAVIGATION & SEARCH**:
+• navigate: go to page (requires \`page\`)
+• search: highlight matches in UI (requires \`query\`). Results in model context.
+• find: silent search, no UI change (requires \`query\`). Results in model context.
+• search_navigate: jump to match (requires \`matchIndex\`)
+• zoom: set scale 0.5–3.0 (requires \`scale\`)
+
+**PAGE EXTRACTION** — get_pages: extract text/screenshots from page ranges without navigating. \`intervals\` = [{start?,end?}], e.g. [{}] for all. \`getText\` (default true), \`getScreenshots\` (default false). Max 20 pages.
+
+**FORMS** — fill_form: fill fields with \`fields\` array of {name, value}.`,
+ inputSchema: {
+ viewUUID: z
+ .string()
+ .describe("The viewUUID of the PDF viewer (from display_pdf result)"),
+ action: z
+ .enum([
+ "navigate",
+ "search",
+ "find",
+ "search_navigate",
+ "zoom",
+ "add_annotations",
+ "update_annotations",
+ "remove_annotations",
+ "highlight_text",
+ "fill_form",
+ "get_pages",
+ ])
+ .describe("Action to perform"),
+ page: z
+ .number()
+ .min(1)
+ .optional()
+ .describe("Page number (for navigate, highlight_text)"),
+ query: z
+ .string()
+ .optional()
+ .describe("Search text (for search / find / highlight_text)"),
+ matchIndex: z
+ .number()
+ .min(0)
+ .optional()
+ .describe("Match index (for search_navigate)"),
+ scale: z
+ .number()
+ .min(0.5)
+ .max(3.0)
+ .optional()
+ .describe("Zoom scale, 1.0 = 100% (for zoom)"),
+ annotations: z
+ .array(z.record(z.string(), z.any()))
+ .optional()
+ .describe(
+ "Annotation objects (see types in description). Each needs: id, type, page. For update_annotations only id+type are required.",
+ ),
+ ids: z
+ .array(z.string())
+ .optional()
+ .describe("Annotation IDs (for remove_annotations)"),
+ color: z
+ .string()
+ .optional()
+ .describe("Color override (for highlight_text)"),
+ content: z
+ .string()
+ .optional()
+ .describe("Tooltip/note content (for highlight_text)"),
+ fields: z
+ .array(FormField)
+ .optional()
+ .describe(
+ "Form fields to fill (for fill_form): { name, value } where value is string or boolean",
+ ),
+ intervals: z
+ .array(PageInterval)
+ .optional()
+ .describe(
+ "Page ranges for get_pages. Each has optional start/end. [{start:1,end:5}], [{}] = all pages.",
+ ),
+ getText: z
+ .boolean()
+ .optional()
+ .describe("Include text content (for get_pages, default true)"),
+ getScreenshots: z
+ .boolean()
+ .optional()
+ .describe(
+ "Include page screenshots as PNG images (for get_pages, default false)",
+ ),
+ },
+ },
+ async ({
+ viewUUID: uuid,
+ action,
+ page,
+ query,
+ matchIndex,
+ scale,
+ annotations,
+ ids,
+ color,
+ content,
+ fields,
+ intervals,
+ getText,
+ getScreenshots,
+ }): Promise => {
+ // Validate viewUUID — must be one issued by display_pdf
+ if (!activeViewUUIDs.has(uuid)) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Unknown viewUUID "${uuid}". Use the exact viewUUID returned by display_pdf (a UUID like "abc12345-...").`,
+ },
+ ],
+ isError: true,
+ };
+ }
+ let description: string;
+ switch (action) {
+ case "navigate":
+ if (page == null)
+ return {
+ content: [{ type: "text", text: "navigate requires `page`" }],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "navigate", page });
+ description = `navigate to page ${page}`;
+ break;
+ case "search":
+ if (!query)
+ return {
+ content: [{ type: "text", text: "search requires `query`" }],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "search", query });
+ description = `search for "${query}"`;
+ break;
+ case "find":
+ if (!query)
+ return {
+ content: [{ type: "text", text: "find requires `query`" }],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "find", query });
+ description = `find "${query}" (silent)`;
+ break;
+ case "search_navigate":
+ if (matchIndex == null)
+ return {
+ content: [
+ {
+ type: "text",
+ text: "search_navigate requires `matchIndex`",
+ },
+ ],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "search_navigate", matchIndex });
+ description = `go to match #${matchIndex}`;
+ break;
+ case "zoom":
+ if (scale == null)
+ return {
+ content: [{ type: "text", text: "zoom requires `scale`" }],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "zoom", scale });
+ description = `zoom to ${Math.round(scale * 100)}%`;
+ break;
+ case "add_annotations":
+ if (!annotations || annotations.length === 0)
+ return {
+ content: [
+ {
+ type: "text",
+ text: "add_annotations requires `annotations` array",
+ },
+ ],
+ isError: true,
+ };
+ // annotations are already validated by Zod inputSchema
+ enqueueCommand(uuid, {
+ type: "add_annotations",
+ annotations: annotations as z.infer[],
+ });
+ description = `add ${annotations.length} annotation(s)`;
+ break;
+ case "update_annotations":
+ if (!annotations || annotations.length === 0)
+ return {
+ content: [
+ {
+ type: "text",
+ text: "update_annotations requires `annotations` array",
+ },
+ ],
+ isError: true,
+ };
+ enqueueCommand(uuid, {
+ type: "update_annotations",
+ annotations: annotations as z.infer[],
+ });
+ description = `update ${annotations.length} annotation(s)`;
+ break;
+ case "remove_annotations":
+ if (!ids || ids.length === 0)
+ return {
+ content: [
+ {
+ type: "text",
+ text: "remove_annotations requires `ids` array",
+ },
+ ],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "remove_annotations", ids });
+ description = `remove ${ids.length} annotation(s)`;
+ break;
+ case "highlight_text": {
+ if (!query)
+ return {
+ content: [
+ { type: "text", text: "highlight_text requires `query`" },
+ ],
+ isError: true,
+ };
+ const id = `ht_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ enqueueCommand(uuid, {
+ type: "highlight_text",
+ id,
+ query,
+ page,
+ color,
+ content,
+ });
+ description = `highlight text "${query}"${page ? ` on page ${page}` : ""}`;
+ break;
+ }
+ case "fill_form":
+ if (!fields || fields.length === 0)
+ return {
+ content: [
+ { type: "text", text: "fill_form requires `fields` array" },
+ ],
+ isError: true,
+ };
+ enqueueCommand(uuid, { type: "fill_form", fields });
+ description = `fill ${fields.length} form field(s)`;
+ break;
+ case "get_pages": {
+ const resolvedIntervals = intervals ?? [{}];
+ const resolvedGetText = getText ?? true;
+ const resolvedGetScreenshots = getScreenshots ?? false;
+ if (!resolvedGetText && !resolvedGetScreenshots) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: "get_pages: at least one of getText or getScreenshots must be true",
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ const requestId = randomUUID();
+
+ // Enqueue command for client to process offscreen
+ enqueueCommand(uuid, {
+ type: "get_pages",
+ requestId,
+ intervals: resolvedIntervals,
+ getText: resolvedGetText,
+ getScreenshots: resolvedGetScreenshots,
+ });
+
+ // Wait for client to render and submit results (unlike other actions,
+ // get_pages returns page content directly instead of just "Queued")
+ let pageData: PageDataEntry[];
+ try {
+ pageData = await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ pendingPageRequests.delete(requestId);
+ reject(new Error("Timeout waiting for page data from viewer"));
+ }, GET_PAGES_TIMEOUT_MS);
+ pendingPageRequests.set(requestId, {
+ resolve,
+ reject,
+ timer,
+ });
+ });
+ } catch (err) {
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`,
+ },
+ ],
+ isError: true,
+ };
+ }
+
+ // Format as MCP content blocks — interleaved text + image per page
+ const pageContent: Array<
+ | { type: "text"; text: string }
+ | { type: "image"; data: string; mimeType: string }
+ > = [];
+ for (const entry of pageData) {
+ if (entry.text != null) {
+ pageContent.push({
+ type: "text",
+ text: `--- Page ${entry.page} ---\n${entry.text}`,
+ });
+ }
+ if (entry.image) {
+ pageContent.push({
+ type: "image",
+ data: entry.image,
+ mimeType: "image/png",
+ });
+ }
+ }
+ if (pageContent.length === 0) {
+ pageContent.push({
+ type: "text",
+ text: "No page data returned",
+ });
+ }
+ return { content: pageContent };
+ }
+ default:
+ return {
+ content: [{ type: "text", text: `Unknown action: ${action}` }],
+ isError: true,
+ };
+ }
+ return {
+ content: [{ type: "text", text: `Queued: ${description}` }],
+ };
+ },
+ );
+
+ // Tool: submit_page_data (app-only) - Client submits rendered page data
+ registerAppTool(
+ server,
+ "submit_page_data",
+ {
+ title: "Submit Page Data",
+ description:
+ "Submit rendered page data for a get_pages request (used by viewer)",
+ inputSchema: {
+ requestId: z
+ .string()
+ .describe("The request ID from the get_pages command"),
+ pages: z
+ .array(
+ z.object({
+ page: z.number(),
+ text: z.string().optional(),
+ image: z.string().optional().describe("Base64 PNG image data"),
+ }),
+ )
+ .describe("Page data entries"),
+ },
+ _meta: { ui: { visibility: ["app"] } },
+ },
+ async ({ requestId, pages }): Promise => {
+ const pending = pendingPageRequests.get(requestId);
+ if (pending) {
+ clearTimeout(pending.timer);
+ pendingPageRequests.delete(requestId);
+ pending.resolve(pages);
+ return {
+ content: [
+ { type: "text", text: `Submitted ${pages.length} page(s)` },
+ ],
+ };
+ }
+ return {
+ content: [
+ { type: "text", text: `No pending request for ${requestId}` },
+ ],
+ isError: true,
+ };
+ },
+ );
+
+ // Tool: poll_pdf_commands (app-only) - Poll for pending commands
+ registerAppTool(
+ server,
+ "poll_pdf_commands",
+ {
+ title: "Poll PDF Commands",
+ description: "Poll for pending commands for a PDF viewer",
+ inputSchema: {
+ viewUUID: z.string().describe("The viewUUID of the PDF viewer"),
+ },
+ _meta: { ui: { visibility: ["app"] } },
+ },
+ async ({ viewUUID: uuid }): Promise => {
+ // If commands are queued, wait a fixed window to let more accumulate
+ if (commandQueues.has(uuid)) {
+ await new Promise((r) => setTimeout(r, POLL_BATCH_WAIT_MS));
+ }
+ const commands = dequeueCommands(uuid);
+ return {
+ content: [{ type: "text", text: `${commands.length} command(s)` }],
+ structuredContent: { commands },
+ };
+ },
+ );
+
// Resource: UI HTML
registerAppResource(
server,
diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css
index 008b22a6..8b8b2294 100644
--- a/examples/pdf-server/src/mcp-app.css
+++ b/examples/pdf-server/src/mcp-app.css
@@ -408,6 +408,136 @@ body {
cursor: not-allowed;
}
+/* Annotation Layer */
+.annotation-layer {
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ pointer-events: none;
+ z-index: 1;
+}
+
+.annotation-highlight {
+ position: absolute;
+ background: rgba(255, 255, 0, 0.35);
+ mix-blend-mode: multiply;
+ border-radius: 1px;
+}
+
+.annotation-underline {
+ position: absolute;
+ border-bottom: 2px solid #ff0000;
+ box-sizing: border-box;
+}
+
+.annotation-strikethrough {
+ position: absolute;
+ box-sizing: border-box;
+}
+.annotation-strikethrough::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 50%;
+ border-top: 2px solid #ff0000;
+}
+
+.annotation-note {
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ cursor: pointer;
+ pointer-events: auto;
+ font-size: 16px;
+ line-height: 20px;
+ text-align: center;
+}
+.annotation-note::after {
+ content: attr(data-icon);
+}
+.annotation-note .annotation-tooltip {
+ display: none;
+ position: absolute;
+ bottom: 100%;
+ left: 0;
+ background: var(--bg000, #fff);
+ color: var(--text000, #000);
+ border: 1px solid var(--bg200, #ccc);
+ border-radius: 4px;
+ padding: 4px 8px;
+ font-size: 0.8rem;
+ white-space: pre-wrap;
+ max-width: 200px;
+ z-index: 10;
+ pointer-events: none;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
+}
+.annotation-note:hover .annotation-tooltip {
+ display: block;
+}
+
+.annotation-rectangle {
+ position: absolute;
+ border: 2px solid #0066cc;
+ box-sizing: border-box;
+}
+
+.annotation-freetext {
+ position: absolute;
+ font-family: Helvetica, Arial, sans-serif;
+ white-space: pre-wrap;
+ pointer-events: none;
+}
+
+.annotation-stamp {
+ position: absolute;
+ font-family: Helvetica, Arial, sans-serif;
+ font-weight: bold;
+ font-size: 24px;
+ border: 3px solid currentColor;
+ padding: 4px 12px;
+ opacity: 0.6;
+ text-transform: uppercase;
+ white-space: nowrap;
+ pointer-events: none;
+}
+
+@media (prefers-color-scheme: dark) {
+ .annotation-highlight {
+ background: rgba(255, 255, 0, 0.3);
+ mix-blend-mode: screen;
+ }
+}
+
+/* Download Button */
+.download-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;
+}
+
+.download-btn:hover:not(:disabled) {
+ background: var(--bg100);
+ border-color: var(--bg300);
+}
+
+.download-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
/* Highlight Layer */
.highlight-layer {
position: absolute;
diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts
index 4278d928..6191b638 100644
--- a/examples/pdf-server/src/mcp-app.ts
+++ b/examples/pdf-server/src/mcp-app.ts
@@ -16,6 +16,7 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js";
import * as pdfjsLib from "pdfjs-dist";
import { TextLayer } from "pdfjs-dist";
+import { PDFDocument, rgb, StandardFonts, degrees } from "pdf-lib";
import "./global.css";
import "./mcp-app.css";
@@ -42,6 +43,103 @@ let pdfTitle: string | undefined;
let viewUUID: string | undefined;
let currentRenderTask: { cancel: () => void } | null = null;
+// =============================================================================
+// Annotation Types (mirrors server schemas)
+// =============================================================================
+
+interface Rect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+type StampLabel =
+ | "APPROVED"
+ | "DRAFT"
+ | "CONFIDENTIAL"
+ | "FINAL"
+ | "VOID"
+ | "REJECTED";
+
+interface AnnotationBase {
+ id: string;
+ page: number;
+}
+
+interface HighlightAnnotation extends AnnotationBase {
+ type: "highlight";
+ rects: Rect[];
+ color?: string;
+ content?: string;
+}
+
+interface UnderlineAnnotation extends AnnotationBase {
+ type: "underline";
+ rects: Rect[];
+ color?: string;
+}
+
+interface StrikethroughAnnotation extends AnnotationBase {
+ type: "strikethrough";
+ rects: Rect[];
+ color?: string;
+}
+
+interface NoteAnnotation extends AnnotationBase {
+ type: "note";
+ x: number;
+ y: number;
+ content: string;
+ color?: string;
+}
+
+interface RectangleAnnotation extends AnnotationBase {
+ type: "rectangle";
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ color?: string;
+ fillColor?: string;
+}
+
+interface FreetextAnnotation extends AnnotationBase {
+ type: "freetext";
+ x: number;
+ y: number;
+ content: string;
+ fontSize?: number;
+ color?: string;
+}
+
+interface StampAnnotation extends AnnotationBase {
+ type: "stamp";
+ x: number;
+ y: number;
+ label: StampLabel;
+ color?: string;
+ rotation?: number;
+}
+
+type PdfAnnotationDef =
+ | HighlightAnnotation
+ | UnderlineAnnotation
+ | StrikethroughAnnotation
+ | NoteAnnotation
+ | RectangleAnnotation
+ | FreetextAnnotation
+ | StampAnnotation;
+
+interface TrackedAnnotation {
+ def: PdfAnnotationDef;
+ elements: HTMLElement[];
+}
+
+// Annotation state
+const annotationMap = new Map();
+const formFieldValues = new Map();
+
// DOM Elements
const mainEl = document.querySelector(".main") as HTMLElement;
const loadingEl = document.getElementById("loading")!;
@@ -80,6 +178,10 @@ const searchCloseBtn = document.getElementById(
"search-close-btn",
) as HTMLButtonElement;
const highlightLayerEl = document.getElementById("highlight-layer")!;
+const annotationLayerEl = document.getElementById("annotation-layer")!;
+const downloadBtn = document.getElementById(
+ "download-btn",
+) as HTMLButtonElement;
// Search state
interface SearchMatch {
@@ -194,6 +296,46 @@ function performSearch(query: string) {
goToPage(match.pageNum);
}
}
+
+ // Update model context with search results
+ updatePageContext();
+}
+
+/**
+ * Silent search: populate matches and report via model context
+ * without opening the search bar or rendering highlights.
+ */
+function performSilentSearch(query: string) {
+ allMatches = [];
+ currentMatchIndex = -1;
+ searchQuery = query;
+
+ if (!query) {
+ updatePageContext();
+ 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;
+ }
+ }
+
+ if (allMatches.length > 0) {
+ const idx = allMatches.findIndex((m) => m.pageNum >= currentPage);
+ currentMatchIndex = idx >= 0 ? idx : 0;
+ }
+
+ log.info(`Silent search "${query}": ${allMatches.length} matches`);
+ updatePageContext();
}
function renderHighlights() {
@@ -511,6 +653,50 @@ function findSelectionInText(
return undefined;
}
+/**
+ * Format search results with excerpts for model context.
+ * Limits to first 20 matches to avoid overwhelming the context.
+ */
+function formatSearchResults(): string {
+ const MAX_RESULTS = 20;
+ const EXCERPT_RADIUS = 40; // characters around the match
+
+ const lines: string[] = [];
+ const totalMatchCount = allMatches.length;
+ const currentIdx = currentMatchIndex >= 0 ? currentMatchIndex : -1;
+
+ lines.push(
+ `\nSearch: "${searchQuery}" (${totalMatchCount} match${totalMatchCount !== 1 ? "es" : ""} across ${new Set(allMatches.map((m) => m.pageNum)).size} page${new Set(allMatches.map((m) => m.pageNum)).size !== 1 ? "s" : ""})`,
+ );
+
+ const displayed = allMatches.slice(0, MAX_RESULTS);
+ for (let i = 0; i < displayed.length; i++) {
+ const match = displayed[i];
+ const pageText = pageTextCache.get(match.pageNum) || "";
+ const start = Math.max(0, match.index - EXCERPT_RADIUS);
+ const end = Math.min(
+ pageText.length,
+ match.index + match.length + EXCERPT_RADIUS,
+ );
+ const before = pageText.slice(start, match.index).replace(/\n/g, " ");
+ const matched = pageText.slice(match.index, match.index + match.length);
+ const after = pageText
+ .slice(match.index + match.length, end)
+ .replace(/\n/g, " ");
+ const prefix = start > 0 ? "..." : "";
+ const suffix = end < pageText.length ? "..." : "";
+ const current = i === currentIdx ? " (current)" : "";
+ lines.push(
+ ` [${i}] p.${match.pageNum}, offset ${match.index}${current}: ${prefix}${before}«${matched}»${after}${suffix}`,
+ );
+ }
+ if (totalMatchCount > MAX_RESULTS) {
+ lines.push(` ... and ${totalMatchCount - MAX_RESULTS} more matches`);
+ }
+
+ return lines.join("\n");
+}
+
// Extract text from current page and update model context
async function updatePageContext() {
if (!pdfDocument) return;
@@ -555,7 +741,27 @@ async function updatePageContext() {
`Current Page: ${currentPage}/${totalPages}`,
].join(" | ");
- const contextText = `${header}\n\nPage content:\n${content}`;
+ // Include search status if active
+ let searchSection = "";
+ if (searchOpen && searchQuery && allMatches.length > 0) {
+ searchSection = formatSearchResults();
+ } else if (searchOpen && searchQuery) {
+ searchSection = `\nSearch: "${searchQuery}" (no matches found)`;
+ }
+
+ // Include annotation summary if any exist
+ let annotationSection = "";
+ if (annotationMap.size > 0) {
+ const onThisPage = [...annotationMap.values()].filter(
+ (t) => t.def.page === currentPage,
+ ).length;
+ annotationSection = `\nAnnotations: ${onThisPage} on this page, ${annotationMap.size} total`;
+ if (formFieldValues.size > 0) {
+ annotationSection += ` | ${formFieldValues.size} form field(s) filled`;
+ }
+ }
+
+ const contextText = `${header}${searchSection}${annotationSection}\n\nPage content:\n${content}`;
// Build content array with text and optional screenshot
const contentBlocks: ContentBlock[] = [{ type: "text", text: contextText }];
@@ -603,6 +809,855 @@ async function updatePageContext() {
}
}
+// =============================================================================
+// Annotation Rendering
+// =============================================================================
+
+/**
+ * Convert PDF coordinates (bottom-left origin) to screen coordinates
+ * relative to the page wrapper. PDF.js viewport handles rotation and scale.
+ */
+function pdfRectToScreen(
+ rect: Rect,
+ viewport: { width: number; height: number; scale: number },
+): { left: number; top: number; width: number; height: number } {
+ const s = viewport.scale;
+ // PDF origin is bottom-left, screen origin is top-left
+ const left = rect.x * s;
+ const top = viewport.height - (rect.y + rect.height) * s;
+ const width = rect.width * s;
+ const height = rect.height * s;
+ return { left, top, width, height };
+}
+
+function pdfPointToScreen(
+ x: number,
+ y: number,
+ viewport: { width: number; height: number; scale: number },
+): { left: number; top: number } {
+ const s = viewport.scale;
+ return { left: x * s, top: viewport.height - y * s };
+}
+
+function renderAnnotationsForPage(pageNum: number): void {
+ // Clear existing annotation elements
+ annotationLayerEl.innerHTML = "";
+
+ // Remove tracked element refs for all annotations
+ for (const tracked of annotationMap.values()) {
+ tracked.elements = [];
+ }
+
+ if (!pdfDocument) return;
+
+ // Get viewport for coordinate conversion
+ const vp = {
+ width: parseFloat(annotationLayerEl.style.width) || 0,
+ height: parseFloat(annotationLayerEl.style.height) || 0,
+ scale,
+ };
+ if (vp.width === 0 || vp.height === 0) return;
+
+ for (const tracked of annotationMap.values()) {
+ const def = tracked.def;
+ if (def.page !== pageNum) continue;
+
+ const elements = renderAnnotation(def, vp);
+ tracked.elements = elements;
+ for (const el of elements) {
+ annotationLayerEl.appendChild(el);
+ }
+ }
+}
+
+function renderAnnotation(
+ def: PdfAnnotationDef,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement[] {
+ switch (def.type) {
+ case "highlight":
+ return renderRectsAnnotation(
+ def.rects,
+ "annotation-highlight",
+ viewport,
+ def.color ? { background: def.color } : {},
+ );
+ case "underline":
+ return renderRectsAnnotation(
+ def.rects,
+ "annotation-underline",
+ viewport,
+ def.color ? { borderBottomColor: def.color } : {},
+ );
+ case "strikethrough":
+ return renderRectsAnnotation(
+ def.rects,
+ "annotation-strikethrough",
+ viewport,
+ {},
+ def.color,
+ );
+ case "note":
+ return [renderNoteAnnotation(def, viewport)];
+ case "rectangle":
+ return [renderRectangleAnnotation(def, viewport)];
+ case "freetext":
+ return [renderFreetextAnnotation(def, viewport)];
+ case "stamp":
+ return [renderStampAnnotation(def, viewport)];
+ }
+}
+
+function renderRectsAnnotation(
+ rects: Rect[],
+ className: string,
+ viewport: { width: number; height: number; scale: number },
+ extraStyles: Record,
+ strikeColor?: string,
+): HTMLElement[] {
+ return rects.map((rect) => {
+ const screen = pdfRectToScreen(rect, viewport);
+ const el = document.createElement("div");
+ el.className = className;
+ el.style.left = `${screen.left}px`;
+ el.style.top = `${screen.top}px`;
+ el.style.width = `${screen.width}px`;
+ el.style.height = `${screen.height}px`;
+ for (const [k, v] of Object.entries(extraStyles)) {
+ (el.style as unknown as Record)[k] = v;
+ }
+ if (strikeColor) {
+ // Set color for the ::after pseudo-element via CSS custom property
+ el.style.setProperty("--strike-color", strikeColor);
+ el.querySelector("::after"); // no-op, style via CSS instead
+ // Actually use inline style on a child element for the line
+ const line = document.createElement("div");
+ line.style.position = "absolute";
+ line.style.left = "0";
+ line.style.right = "0";
+ line.style.top = "50%";
+ line.style.borderTop = `2px solid ${strikeColor}`;
+ el.appendChild(line);
+ }
+ return el;
+ });
+}
+
+function renderNoteAnnotation(
+ def: NoteAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const pos = pdfPointToScreen(def.x, def.y, viewport);
+ const el = document.createElement("div");
+ el.className = "annotation-note";
+ el.style.left = `${pos.left}px`;
+ el.style.top = `${pos.top - 20}px`; // offset up so note icon is at the point
+ el.setAttribute("data-icon", "\uD83D\uDCDD"); // memo emoji
+ if (def.color) el.style.color = def.color;
+
+ const tooltip = document.createElement("div");
+ tooltip.className = "annotation-tooltip";
+ tooltip.textContent = def.content;
+ el.appendChild(tooltip);
+
+ return el;
+}
+
+function renderRectangleAnnotation(
+ def: RectangleAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const screen = pdfRectToScreen(
+ { x: def.x, y: def.y, width: def.width, height: def.height },
+ viewport,
+ );
+ const el = document.createElement("div");
+ el.className = "annotation-rectangle";
+ el.style.left = `${screen.left}px`;
+ el.style.top = `${screen.top}px`;
+ el.style.width = `${screen.width}px`;
+ el.style.height = `${screen.height}px`;
+ if (def.color) el.style.borderColor = def.color;
+ if (def.fillColor) el.style.backgroundColor = def.fillColor;
+ return el;
+}
+
+function renderFreetextAnnotation(
+ def: FreetextAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const pos = pdfPointToScreen(def.x, def.y, viewport);
+ const el = document.createElement("div");
+ el.className = "annotation-freetext";
+ el.style.left = `${pos.left}px`;
+ el.style.top = `${pos.top}px`;
+ el.style.fontSize = `${(def.fontSize || 12) * viewport.scale}px`;
+ if (def.color) el.style.color = def.color;
+ el.textContent = def.content;
+ return el;
+}
+
+function renderStampAnnotation(
+ def: StampAnnotation,
+ viewport: { width: number; height: number; scale: number },
+): HTMLElement {
+ const pos = pdfPointToScreen(def.x, def.y, viewport);
+ const el = document.createElement("div");
+ el.className = "annotation-stamp";
+ el.style.left = `${pos.left}px`;
+ el.style.top = `${pos.top}px`;
+ el.style.fontSize = `${24 * viewport.scale}px`;
+ if (def.color) el.style.color = def.color;
+ if (def.rotation) {
+ el.style.transform = `rotate(${-def.rotation}deg)`;
+ el.style.transformOrigin = "left bottom";
+ }
+ el.textContent = def.label;
+ return el;
+}
+
+// =============================================================================
+// Annotation CRUD
+// =============================================================================
+
+function addAnnotation(def: PdfAnnotationDef): void {
+ // Remove existing if same id
+ removeAnnotation(def.id);
+ annotationMap.set(def.id, { def, elements: [] });
+ // Re-render if on current page
+ if (def.page === currentPage) {
+ renderAnnotationsForPage(currentPage);
+ }
+}
+
+function updateAnnotation(
+ update: Partial & { id: string; type: string },
+): void {
+ const tracked = annotationMap.get(update.id);
+ if (!tracked) return;
+
+ // Merge partial update into existing def
+ const merged = { ...tracked.def, ...update } as PdfAnnotationDef;
+ tracked.def = merged;
+
+ // Re-render if on current page
+ if (merged.page === currentPage) {
+ renderAnnotationsForPage(currentPage);
+ }
+}
+
+function removeAnnotation(id: string): void {
+ const tracked = annotationMap.get(id);
+ if (!tracked) return;
+ for (const el of tracked.elements) el.remove();
+ annotationMap.delete(id);
+}
+
+// =============================================================================
+// highlight_text Command
+// =============================================================================
+
+function handleHighlightText(cmd: {
+ id: string;
+ query: string;
+ page?: number;
+ color?: string;
+ content?: string;
+}): void {
+ const pagesToSearch: number[] = [];
+ if (cmd.page) {
+ pagesToSearch.push(cmd.page);
+ } else {
+ // Search all pages that have cached text
+ for (const [pageNum, text] of pageTextCache) {
+ if (text.toLowerCase().includes(cmd.query.toLowerCase())) {
+ pagesToSearch.push(pageNum);
+ }
+ }
+ }
+
+ let annotationIndex = 0;
+ for (const pageNum of pagesToSearch) {
+ // Find text positions using the text layer DOM if on current page,
+ // otherwise create approximate rects from text cache positions
+ const rects = findTextRects(cmd.query, pageNum);
+ if (rects.length > 0) {
+ const id =
+ pagesToSearch.length > 1
+ ? `${cmd.id}_p${pageNum}_${annotationIndex++}`
+ : cmd.id;
+ addAnnotation({
+ type: "highlight",
+ id,
+ page: pageNum,
+ rects,
+ color: cmd.color,
+ content: cmd.content,
+ });
+ }
+ }
+}
+
+/**
+ * Find text in a page and return PDF-coordinate rects.
+ * Uses the TextLayer DOM when the page is currently rendered,
+ * otherwise falls back to approximate character-based positioning.
+ */
+function findTextRects(query: string, pageNum: number): Rect[] {
+ if (pageNum !== currentPage) {
+ // For non-current pages, create approximate rects from page dimensions
+ // The text will be properly positioned when the user navigates to that page
+ return findTextRectsFromCache(query, pageNum);
+ }
+
+ // Use text layer DOM for current page
+ const spans = Array.from(
+ textLayerEl.querySelectorAll("span"),
+ ) as HTMLElement[];
+ if (spans.length === 0) return findTextRectsFromCache(query, pageNum);
+
+ const lowerQuery = query.toLowerCase();
+ const rects: Rect[] = [];
+ const wrapperEl = textLayerEl.parentElement!;
+ const wrapperRect = wrapperEl.getBoundingClientRect();
+
+ for (const span of spans) {
+ const text = span.textContent || "";
+ if (text.length === 0) continue;
+ const lowerText = text.toLowerCase();
+
+ let pos = 0;
+ while (true) {
+ const idx = lowerText.indexOf(lowerQuery, pos);
+ if (idx === -1) break;
+ pos = idx + 1;
+
+ const textNode = span.firstChild;
+ if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue;
+
+ try {
+ const range = document.createRange();
+ range.setStart(textNode, idx);
+ range.setEnd(textNode, Math.min(idx + lowerQuery.length, text.length));
+ const clientRects = range.getClientRects();
+
+ for (let ri = 0; ri < clientRects.length; ri++) {
+ const r = clientRects[ri];
+ // Convert screen coords back to PDF coords
+ const screenLeft = r.left - wrapperRect.left;
+ const screenTop = r.top - wrapperRect.top;
+ const pdfX = screenLeft / scale;
+ const pdfHeight = r.height / scale;
+ const pdfWidth = r.width / scale;
+ const pageHeight = parseFloat(annotationLayerEl.style.height) / scale;
+ const pdfY = pageHeight - (screenTop + r.height) / scale;
+ rects.push({
+ x: pdfX,
+ y: pdfY,
+ width: pdfWidth,
+ height: pdfHeight,
+ });
+ }
+ } catch {
+ // Range API errors with stale nodes
+ }
+ }
+ }
+
+ return rects;
+}
+
+function findTextRectsFromCache(query: string, pageNum: number): Rect[] {
+ const text = pageTextCache.get(pageNum);
+ if (!text) return [];
+ const lowerText = text.toLowerCase();
+ const lowerQuery = query.toLowerCase();
+ const idx = lowerText.indexOf(lowerQuery);
+ if (idx === -1) return [];
+
+ // Approximate: place a highlight rect in the middle of the page
+ // This will be re-computed accurately when the user visits the page
+ return [{ x: 72, y: 400, width: 200, height: 14 }];
+}
+
+// =============================================================================
+// get_pages — Offscreen rendering for model analysis
+// =============================================================================
+
+const MAX_GET_PAGES = 20;
+const SCREENSHOT_MAX_DIM = 768; // Max pixel dimension for screenshots
+
+/**
+ * Expand intervals into a sorted deduplicated list of page numbers,
+ * clamped to [1, totalPages].
+ */
+function expandIntervals(
+ intervals: Array<{ start?: number; end?: number }>,
+): number[] {
+ const pages = new Set();
+ for (const iv of intervals) {
+ const s = Math.max(1, iv.start ?? 1);
+ const e = Math.min(totalPages, iv.end ?? totalPages);
+ for (let p = s; p <= e; p++) pages.add(p);
+ }
+ return [...pages].sort((a, b) => a - b);
+}
+
+/**
+ * Render a single page to an offscreen canvas and return base64 PNG.
+ * Does not affect the visible canvas or text layer.
+ */
+async function renderPageOffscreen(pageNum: number): Promise {
+ if (!pdfDocument) throw new Error("No PDF loaded");
+ const page = await pdfDocument.getPage(pageNum);
+ const baseViewport = page.getViewport({ scale: 1.0 });
+
+ // Scale down to fit within SCREENSHOT_MAX_DIM
+ const maxDim = Math.max(baseViewport.width, baseViewport.height);
+ const renderScale =
+ maxDim > SCREENSHOT_MAX_DIM ? SCREENSHOT_MAX_DIM / maxDim : 1.0;
+ const viewport = page.getViewport({ scale: renderScale });
+
+ const canvas = document.createElement("canvas");
+ const dpr = 1; // No retina scaling for model screenshots
+ canvas.width = viewport.width * dpr;
+ canvas.height = viewport.height * dpr;
+ const ctx = canvas.getContext("2d")!;
+ ctx.scale(dpr, dpr);
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await (page.render as any)({ canvasContext: ctx, viewport }).promise;
+
+ // Extract base64 (strip data URL prefix)
+ const dataUrl = canvas.toDataURL("image/png");
+ return dataUrl.split(",")[1];
+}
+
+async function handleGetPages(cmd: {
+ requestId: string;
+ intervals: Array<{ start?: number; end?: number }>;
+ getText: boolean;
+ getScreenshots: boolean;
+}): Promise {
+ const allPages = expandIntervals(cmd.intervals);
+ const pages = allPages.slice(0, MAX_GET_PAGES);
+
+ log.info(
+ `get_pages: ${pages.length} pages (${pages[0]}..${pages[pages.length - 1]}), text=${cmd.getText}, screenshots=${cmd.getScreenshots}`,
+ );
+
+ const results: Array<{
+ page: number;
+ text?: string;
+ image?: string;
+ }> = [];
+
+ for (const pageNum of pages) {
+ const entry: { page: number; text?: string; image?: string } = {
+ page: pageNum,
+ };
+
+ if (cmd.getText) {
+ // Use cached text if available, otherwise extract on the fly
+ let text = pageTextCache.get(pageNum);
+ if (text == null && pdfDocument) {
+ try {
+ const pg = await pdfDocument.getPage(pageNum);
+ const tc = await pg.getTextContent();
+ text = (tc.items as Array<{ str?: string }>)
+ .map((item) => item.str || "")
+ .join(" ");
+ pageTextCache.set(pageNum, text);
+ } catch (err) {
+ log.error(
+ `get_pages: text extraction failed for page ${pageNum}:`,
+ err,
+ );
+ text = "";
+ }
+ }
+ entry.text = text ?? "";
+ }
+
+ if (cmd.getScreenshots) {
+ try {
+ entry.image = await renderPageOffscreen(pageNum);
+ } catch (err) {
+ log.error(`get_pages: screenshot failed for page ${pageNum}:`, err);
+ }
+ }
+
+ results.push(entry);
+ }
+
+ // Submit results back to server
+ try {
+ await app.callServerTool({
+ name: "submit_page_data",
+ arguments: { requestId: cmd.requestId, pages: results },
+ });
+ log.info(
+ `get_pages: submitted ${results.length} page(s) for ${cmd.requestId}`,
+ );
+ } catch (err) {
+ log.error("get_pages: failed to submit results:", err);
+ }
+}
+
+// =============================================================================
+// Annotation Persistence
+// =============================================================================
+
+/** Storage key for annotations — uses toolInfo.id (available early) with viewUUID fallback */
+function annotationStorageKey(): string | null {
+ const toolId = app.getHostContext()?.toolInfo?.id;
+ if (toolId) return `pdf-annot:${toolId}`;
+ if (viewUUID) return `${viewUUID}:annotations`;
+ return null;
+}
+
+function persistAnnotations(): void {
+ const key = annotationStorageKey();
+ if (!key) return;
+ try {
+ const data: PdfAnnotationDef[] = [];
+ for (const tracked of annotationMap.values()) {
+ data.push(tracked.def);
+ }
+ const formData: Record = {};
+ for (const [k, v] of formFieldValues) {
+ formData[k] = v;
+ }
+ localStorage.setItem(
+ key,
+ JSON.stringify({ annotations: data, formFields: formData }),
+ );
+ } catch {
+ // localStorage may be full or unavailable
+ }
+}
+
+function restoreAnnotations(): void {
+ const key = annotationStorageKey();
+ if (!key) return;
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return;
+ const parsed = JSON.parse(raw) as {
+ annotations?: PdfAnnotationDef[];
+ formFields?: Record;
+ };
+ if (parsed.annotations) {
+ for (const def of parsed.annotations) {
+ annotationMap.set(def.id, { def, elements: [] });
+ }
+ }
+ if (parsed.formFields) {
+ for (const [k, v] of Object.entries(parsed.formFields)) {
+ formFieldValues.set(k, v);
+ }
+ }
+ log.info(
+ `Restored ${annotationMap.size} annotations, ${formFieldValues.size} form fields`,
+ );
+ } catch {
+ // Parse error or unavailable
+ }
+}
+
+// =============================================================================
+// PDF Download with Annotations
+// =============================================================================
+
+function cssColorToRgb(
+ color: string,
+): { r: number; g: number; b: number } | null {
+ // Parse hex colors
+ const hex = color.match(/^#([0-9a-f]{3,8})$/i);
+ if (hex) {
+ let h = hex[1];
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
+ return {
+ r: parseInt(h.slice(0, 2), 16) / 255,
+ g: parseInt(h.slice(2, 4), 16) / 255,
+ b: parseInt(h.slice(4, 6), 16) / 255,
+ };
+ }
+ // Parse rgb/rgba
+ const rgbMatch = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
+ if (rgbMatch) {
+ return {
+ r: parseInt(rgbMatch[1]) / 255,
+ g: parseInt(rgbMatch[2]) / 255,
+ b: parseInt(rgbMatch[3]) / 255,
+ };
+ }
+ return null;
+}
+
+async function downloadAnnotatedPdf(): Promise {
+ if (!pdfDocument) return;
+ downloadBtn.disabled = true;
+ downloadBtn.title = "Preparing download...";
+
+ try {
+ // Fetch full PDF bytes
+ const totalBytes =
+ parseInt(canvasEl.dataset.totalBytes || "0", 10) ||
+ (await fetchRange(pdfUrl, 0, 1)).totalBytes;
+
+ const { bytes: fullBytes } = await fetchRange(pdfUrl, 0, totalBytes);
+
+ // Load with pdf-lib
+ const pdfDoc = await PDFDocument.load(fullBytes, {
+ ignoreEncryption: true,
+ });
+ const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
+ const boldFont = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
+
+ const pages = pdfDoc.getPages();
+
+ // Embed annotations
+ for (const tracked of annotationMap.values()) {
+ const def = tracked.def;
+ const pageIdx = def.page - 1;
+ if (pageIdx < 0 || pageIdx >= pages.length) continue;
+ const page = pages[pageIdx];
+
+ switch (def.type) {
+ case "highlight": {
+ const c = cssColorToRgb(def.color || "#ffff00") || {
+ r: 1,
+ g: 1,
+ b: 0,
+ };
+ for (const rect of def.rects) {
+ page.drawRectangle({
+ x: rect.x,
+ y: rect.y,
+ width: rect.width,
+ height: rect.height,
+ color: rgb(c.r, c.g, c.b),
+ opacity: 0.35,
+ });
+ }
+ break;
+ }
+ case "underline": {
+ const c = cssColorToRgb(def.color || "#ff0000") || {
+ r: 1,
+ g: 0,
+ b: 0,
+ };
+ for (const rect of def.rects) {
+ page.drawLine({
+ start: { x: rect.x, y: rect.y },
+ end: { x: rect.x + rect.width, y: rect.y },
+ thickness: 1.5,
+ color: rgb(c.r, c.g, c.b),
+ });
+ }
+ break;
+ }
+ case "strikethrough": {
+ const c = cssColorToRgb(def.color || "#ff0000") || {
+ r: 1,
+ g: 0,
+ b: 0,
+ };
+ for (const rect of def.rects) {
+ const midY = rect.y + rect.height / 2;
+ page.drawLine({
+ start: { x: rect.x, y: midY },
+ end: { x: rect.x + rect.width, y: midY },
+ thickness: 1.5,
+ color: rgb(c.r, c.g, c.b),
+ });
+ }
+ break;
+ }
+ case "note": {
+ const c = cssColorToRgb(def.color || "#ff9900") || {
+ r: 1,
+ g: 0.6,
+ b: 0,
+ };
+ // Draw a small note indicator and the content text
+ page.drawSquare({
+ x: def.x,
+ y: def.y - 10,
+ size: 10,
+ color: rgb(c.r, c.g, c.b),
+ opacity: 0.8,
+ });
+ if (def.content) {
+ page.drawText(def.content, {
+ x: def.x + 14,
+ y: def.y - 10,
+ size: 9,
+ font,
+ color: rgb(c.r, c.g, c.b),
+ });
+ }
+ break;
+ }
+ case "rectangle": {
+ const borderColor = cssColorToRgb(def.color || "#0066cc") || {
+ r: 0,
+ g: 0.4,
+ b: 0.8,
+ };
+ page.drawRectangle({
+ x: def.x,
+ y: def.y,
+ width: def.width,
+ height: def.height,
+ borderColor: rgb(borderColor.r, borderColor.g, borderColor.b),
+ borderWidth: 2,
+ color: def.fillColor
+ ? (() => {
+ const fc = cssColorToRgb(def.fillColor);
+ return fc ? rgb(fc.r, fc.g, fc.b) : undefined;
+ })()
+ : undefined,
+ opacity: def.fillColor ? 0.3 : undefined,
+ });
+ break;
+ }
+ case "freetext": {
+ const c = cssColorToRgb(def.color || "#000000") || {
+ r: 0,
+ g: 0,
+ b: 0,
+ };
+ page.drawText(def.content, {
+ x: def.x,
+ y: def.y,
+ size: def.fontSize || 12,
+ font,
+ color: rgb(c.r, c.g, c.b),
+ });
+ break;
+ }
+ case "stamp": {
+ const c = cssColorToRgb(def.color || "#cc0000") || {
+ r: 0.8,
+ g: 0,
+ b: 0,
+ };
+ const stampColor = rgb(c.r, c.g, c.b);
+ const fontSize = 24;
+ const textWidth = boldFont.widthOfTextAtSize(def.label, fontSize);
+ const padding = 8;
+ const rectW = textWidth + padding * 2;
+ const rectH = fontSize + padding * 2;
+ const rotation = def.rotation ? degrees(def.rotation) : undefined;
+
+ page.drawRectangle({
+ x: def.x,
+ y: def.y - rectH,
+ width: rectW,
+ height: rectH,
+ borderColor: stampColor,
+ borderWidth: 3,
+ opacity: 0.6,
+ rotate: rotation,
+ });
+ page.drawText(def.label, {
+ x: def.x + padding,
+ y: def.y - fontSize - padding + 4,
+ size: fontSize,
+ font: boldFont,
+ color: stampColor,
+ opacity: 0.6,
+ rotate: rotation,
+ });
+ break;
+ }
+ }
+ }
+
+ // Apply form fills
+ if (formFieldValues.size > 0) {
+ try {
+ const form = pdfDoc.getForm();
+ for (const [name, value] of formFieldValues) {
+ try {
+ if (typeof value === "boolean") {
+ const checkbox = form.getCheckBox(name);
+ if (value) checkbox.check();
+ else checkbox.uncheck();
+ } else {
+ const textField = form.getTextField(name);
+ textField.setText(value);
+ }
+ } catch {
+ // Field not found or wrong type — skip
+ }
+ }
+ } catch {
+ // Form not available — skip
+ }
+ }
+
+ const pdfBytes = await pdfDoc.save();
+
+ // Use app.downloadFile if host supports it, otherwise fall back to tag
+ const hasAnnotations = annotationMap.size > 0;
+ const baseName = (pdfTitle || "document").replace(/\.pdf$/i, "");
+ const fileName = hasAnnotations
+ ? `${baseName}_annotated.pdf`
+ : `${baseName}.pdf`;
+
+ // Convert to base64
+ const base64 = uint8ArrayToBase64(pdfBytes);
+
+ if (app.getHostCapabilities()?.downloadFile) {
+ const { isError } = await app.downloadFile({
+ contents: [
+ {
+ type: "resource",
+ resource: {
+ uri: `file:///${fileName}`,
+ mimeType: "application/pdf",
+ blob: base64,
+ },
+ },
+ ],
+ });
+ if (isError) {
+ log.info("Download was cancelled or denied by host");
+ }
+ } else {
+ // Fallback: create blob URL and trigger download
+ const blob = new Blob([pdfBytes.buffer as ArrayBuffer], {
+ type: "application/pdf",
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }
+ } catch (err) {
+ log.error("Download error:", err);
+ } finally {
+ downloadBtn.disabled = false;
+ downloadBtn.title = "Download PDF";
+ }
+}
+
+function uint8ArrayToBase64(bytes: Uint8Array): string {
+ let binary = "";
+ for (let i = 0; i < bytes.length; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+}
+
// Render state - prevents concurrent renders
let isRendering = false;
let pendingPage: number | null = null;
@@ -698,15 +1753,20 @@ async function renderPage() {
pageTextCache.set(pageToRender, items.join(""));
}
- // Size highlight layer to match canvas
+ // Size overlay layers to match canvas
highlightLayerEl.style.width = `${viewport.width}px`;
highlightLayerEl.style.height = `${viewport.height}px`;
+ annotationLayerEl.style.width = `${viewport.width}px`;
+ annotationLayerEl.style.height = `${viewport.height}px`;
// Re-render search highlights if search is active
if (searchOpen && searchQuery) {
renderHighlights();
}
+ // Re-render annotations for current page
+ renderAnnotationsForPage(pageToRender);
+
updateControls();
updatePageContext();
@@ -833,6 +1893,7 @@ searchCloseBtn.addEventListener("click", closeSearch);
searchPrevBtn.addEventListener("click", goToPrevMatch);
searchNextBtn.addEventListener("click", goToNextMatch);
fullscreenBtn.addEventListener("click", toggleFullscreen);
+downloadBtn.addEventListener("click", downloadAnnotatedPdf);
// Search input events
searchInputEl.addEventListener("input", () => {
@@ -1281,9 +2342,17 @@ app.ontoolresult = async (result: CallToolResult) => {
loadingIndicatorEl.style.display = "none";
showViewer();
+ downloadBtn.style.display = "";
+ // Restore any persisted annotations
+ restoreAnnotations();
renderPage();
// Start background preloading of all pages for text extraction
startPreloading();
+
+ // Start polling for commands now that we have viewUUID
+ if (viewUUID) {
+ startPolling();
+ }
} catch (err) {
log.error("Error loading PDF:", err);
showError(err instanceof Error ? err.message : String(err));
@@ -1295,6 +2364,155 @@ app.onerror = (err: unknown) => {
showError(err instanceof Error ? err.message : String(err));
};
+// =============================================================================
+// Command Queue Polling
+// =============================================================================
+
+type PdfCommand =
+ | { type: "navigate"; page: number }
+ | { type: "search"; query: string }
+ | { type: "find"; query: string }
+ | { type: "search_navigate"; matchIndex: number }
+ | { type: "zoom"; scale: number }
+ | { type: "add_annotations"; annotations: PdfAnnotationDef[] }
+ | {
+ type: "update_annotations";
+ annotations: Array<
+ Partial & { id: string; type: string }
+ >;
+ }
+ | { type: "remove_annotations"; ids: string[] }
+ | {
+ type: "highlight_text";
+ id: string;
+ query: string;
+ page?: number;
+ color?: string;
+ content?: string;
+ }
+ | {
+ type: "fill_form";
+ fields: Array<{ name: string; value: string | boolean }>;
+ }
+ | {
+ type: "get_pages";
+ requestId: string;
+ intervals: Array<{ start?: number; end?: number }>;
+ getText: boolean;
+ getScreenshots: boolean;
+ };
+
+/**
+ * Process a batch of commands from the server queue
+ */
+function processCommands(commands: PdfCommand[]): void {
+ if (commands.length === 0) return;
+
+ for (const cmd of commands) {
+ log.info("Processing command:", cmd.type, cmd);
+ switch (cmd.type) {
+ case "navigate":
+ if (cmd.page >= 1 && cmd.page <= totalPages) {
+ goToPage(cmd.page);
+ }
+ break;
+ case "search":
+ openSearch();
+ searchInputEl.value = cmd.query;
+ performSearch(cmd.query);
+ break;
+ case "find":
+ performSilentSearch(cmd.query);
+ break;
+ case "search_navigate":
+ if (
+ allMatches.length > 0 &&
+ cmd.matchIndex >= 0 &&
+ cmd.matchIndex < allMatches.length
+ ) {
+ currentMatchIndex = cmd.matchIndex;
+ const match = allMatches[cmd.matchIndex];
+ if (match.pageNum !== currentPage) {
+ goToPage(match.pageNum);
+ }
+ renderHighlights();
+ updateSearchUI();
+ updatePageContext();
+ }
+ break;
+ case "zoom":
+ if (cmd.scale >= 0.5 && cmd.scale <= 3.0) {
+ scale = cmd.scale;
+ renderPage();
+ }
+ break;
+ case "add_annotations":
+ for (const def of cmd.annotations) {
+ addAnnotation(def);
+ }
+ break;
+ case "update_annotations":
+ for (const update of cmd.annotations) {
+ updateAnnotation(update);
+ }
+ break;
+ case "remove_annotations":
+ for (const id of cmd.ids) {
+ removeAnnotation(id);
+ }
+ // Re-render annotation layer since elements were removed
+ renderAnnotationsForPage(currentPage);
+ break;
+ case "highlight_text":
+ handleHighlightText(cmd);
+ break;
+ case "fill_form":
+ for (const field of cmd.fields) {
+ formFieldValues.set(field.name, field.value);
+ }
+ break;
+ case "get_pages":
+ // Handle async — don't block other commands
+ handleGetPages(cmd);
+ break;
+ }
+ }
+
+ // Persist after processing batch
+ persistAnnotations();
+}
+
+let pollTimer: ReturnType | null = null;
+
+function startPolling(): void {
+ if (pollTimer) return;
+ pollTimer = setInterval(async () => {
+ if (!viewUUID) return;
+ try {
+ const result = await app.callServerTool({
+ name: "poll_pdf_commands",
+ arguments: { viewUUID },
+ });
+ const commands =
+ (result.structuredContent as { commands?: PdfCommand[] })?.commands ||
+ [];
+ if (commands.length > 0) {
+ log.info(`Received ${commands.length} command(s)`);
+ processCommands(commands);
+ }
+ } catch (err) {
+ log.error("Poll error:", err);
+ }
+ }, 300);
+}
+
+function stopPolling(): void {
+ if (pollTimer) {
+ clearInterval(pollTimer);
+ pollTimer = null;
+ }
+}
+
function handleHostContextChanged(ctx: McpUiHostContext) {
log.info("Host context changed:", ctx);
@@ -1336,6 +2554,12 @@ function handleHostContextChanged(ctx: McpUiHostContext) {
}
}
+app.onteardown = async () => {
+ log.info("App is being torn down");
+ stopPolling();
+ return {};
+};
+
app.onhostcontextchanged = handleHostContextChanged;
// Connect to host
@@ -1345,4 +2569,6 @@ app.connect().then(() => {
if (ctx) {
handleHostContextChanged(ctx);
}
+ // Restore annotations early using toolInfo.id (available before tool result)
+ restoreAnnotations();
});
diff --git a/examples/scenario-modeler-server/grid-cell.png b/examples/scenario-modeler-server/grid-cell.png
index d04569cf..410548e7 100644
Binary files a/examples/scenario-modeler-server/grid-cell.png and b/examples/scenario-modeler-server/grid-cell.png differ
diff --git a/examples/scenario-modeler-server/screenshot.png b/examples/scenario-modeler-server/screenshot.png
index 0f4cf849..1868cdba 100644
Binary files a/examples/scenario-modeler-server/screenshot.png and b/examples/scenario-modeler-server/screenshot.png differ
diff --git a/examples/shadertoy-server/grid-cell.png b/examples/shadertoy-server/grid-cell.png
index a2c2d9de..80bdc7cf 100644
Binary files a/examples/shadertoy-server/grid-cell.png and b/examples/shadertoy-server/grid-cell.png differ
diff --git a/examples/shadertoy-server/screenshot.png b/examples/shadertoy-server/screenshot.png
index dee834a4..6ac3e0b6 100644
Binary files a/examples/shadertoy-server/screenshot.png and b/examples/shadertoy-server/screenshot.png differ
diff --git a/examples/sheet-music-server/grid-cell.png b/examples/sheet-music-server/grid-cell.png
index b76374bb..79ac092d 100644
Binary files a/examples/sheet-music-server/grid-cell.png and b/examples/sheet-music-server/grid-cell.png differ
diff --git a/examples/sheet-music-server/screenshot.png b/examples/sheet-music-server/screenshot.png
index 046f1279..46a3a8c5 100644
Binary files a/examples/sheet-music-server/screenshot.png and b/examples/sheet-music-server/screenshot.png differ
diff --git a/examples/system-monitor-server/grid-cell.png b/examples/system-monitor-server/grid-cell.png
index 4bb898ec..56d55f71 100644
Binary files a/examples/system-monitor-server/grid-cell.png and b/examples/system-monitor-server/grid-cell.png differ
diff --git a/examples/system-monitor-server/screenshot.png b/examples/system-monitor-server/screenshot.png
index 61b6f60f..c83b0b54 100644
Binary files a/examples/system-monitor-server/screenshot.png and b/examples/system-monitor-server/screenshot.png differ
diff --git a/examples/threejs-server/grid-cell.png b/examples/threejs-server/grid-cell.png
index d8e28587..d0b1a6b1 100644
Binary files a/examples/threejs-server/grid-cell.png and b/examples/threejs-server/grid-cell.png differ
diff --git a/examples/threejs-server/screenshot.png b/examples/threejs-server/screenshot.png
index 0dff9121..45a4a56b 100644
Binary files a/examples/threejs-server/screenshot.png and b/examples/threejs-server/screenshot.png differ
diff --git a/examples/transcript-server/grid-cell.png b/examples/transcript-server/grid-cell.png
index 60ea7ea2..699522ee 100644
Binary files a/examples/transcript-server/grid-cell.png and b/examples/transcript-server/grid-cell.png differ
diff --git a/examples/transcript-server/screenshot.png b/examples/transcript-server/screenshot.png
index 0236c556..9d39c8ff 100644
Binary files a/examples/transcript-server/screenshot.png and b/examples/transcript-server/screenshot.png differ
diff --git a/examples/wiki-explorer-server/grid-cell.png b/examples/wiki-explorer-server/grid-cell.png
index 390f6447..e6543453 100644
Binary files a/examples/wiki-explorer-server/grid-cell.png and b/examples/wiki-explorer-server/grid-cell.png differ
diff --git a/examples/wiki-explorer-server/screenshot.png b/examples/wiki-explorer-server/screenshot.png
index cbf9c71d..cb06d116 100644
Binary files a/examples/wiki-explorer-server/screenshot.png and b/examples/wiki-explorer-server/screenshot.png differ
diff --git a/package-lock.json b/package-lock.json
index 0dcf0240..026d1d2a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -655,6 +655,7 @@
"@modelcontextprotocol/sdk": "^1.24.0",
"cors": "^2.8.5",
"express": "^5.1.0",
+ "pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.0.0",
"zod": "^4.1.13"
},
@@ -3157,6 +3158,24 @@
"win32"
]
},
+ "node_modules/@pdf-lib/standard-fonts": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
+ "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^1.0.6"
+ }
+ },
+ "node_modules/@pdf-lib/upng": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
+ "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^1.0.10"
+ }
+ },
"node_modules/@playwright/test": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
@@ -7156,6 +7175,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -7250,6 +7275,24 @@
"node": ">= 14.16"
}
},
+ "node_modules/pdf-lib": {
+ "version": "1.17.1",
+ "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
+ "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
+ "license": "MIT",
+ "dependencies": {
+ "@pdf-lib/standard-fonts": "^1.0.0",
+ "@pdf-lib/upng": "^1.0.1",
+ "pako": "^1.0.11",
+ "tslib": "^1.11.1"
+ }
+ },
+ "node_modules/pdf-lib/node_modules/tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "license": "0BSD"
+ },
"node_modules/pdfjs-dist": {
"version": "5.4.530",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz",
diff --git a/tests/e2e/pdf-annotations-api.spec.ts b/tests/e2e/pdf-annotations-api.spec.ts
new file mode 100644
index 00000000..5e632370
--- /dev/null
+++ b/tests/e2e/pdf-annotations-api.spec.ts
@@ -0,0 +1,333 @@
+/**
+ * PDF Annotation — Claude API prompt discovery tests
+ *
+ * Tests that Claude can discover and use PDF annotation capabilities
+ * when given the display_pdf result and interact tool descriptions.
+ *
+ * These tests call the Anthropic API directly and are DISABLED by default.
+ * To enable:
+ * ANTHROPIC_API_KEY=sk-ant-... npx playwright test tests/e2e/pdf-annotations-api.spec.ts
+ */
+
+import { test, expect } from "@playwright/test";
+
+const API_KEY = process.env.ANTHROPIC_API_KEY;
+
+// Skip all tests if no API key
+test.skip(!API_KEY, "Set ANTHROPIC_API_KEY to run API prompt tests");
+test.setTimeout(60000);
+
+// Tool definitions matching the PDF server's registered tools
+const TOOLS = [
+ {
+ name: "display_pdf",
+ description:
+ "Display a PDF in an interactive viewer. Returns a viewUUID for subsequent interact calls.",
+ input_schema: {
+ type: "object" as const,
+ properties: {
+ url: {
+ type: "string",
+ description: "URL or path to the PDF file",
+ default: "https://arxiv.org/pdf/1706.03762",
+ },
+ page: {
+ type: "number",
+ description: "Initial page to display (1-indexed)",
+ minimum: 1,
+ },
+ },
+ },
+ },
+ {
+ name: "interact",
+ description: `Interact with a PDF viewer: annotate, navigate, search, extract pages, fill forms.
+
+**ANNOTATION** — You can add visual annotations to any page. Use add_annotations with an array of annotation objects.
+Each annotation needs: id (unique string), type, page (1-indexed).
+Coordinates use PDF points (72 dpi), bottom-left origin.
+
+Annotation types:
+• highlight: rects:[{x,y,width,height}], color?, content? — semi-transparent overlay on text regions
+• underline: rects:[{x,y,width,height}], color? — underline below text
+• strikethrough: rects:[{x,y,width,height}], color? — line through text
+• note: x, y, content, color? — sticky note icon with tooltip
+• rectangle: x, y, width, height, color?, fillColor? — outlined/filled box
+• freetext: x, y, content, fontSize?, color? — arbitrary text label
+• stamp: x, y, label (APPROVED|DRAFT|CONFIDENTIAL|FINAL|VOID|REJECTED), color?, rotation? — stamp overlay
+
+Example — add a highlight and a stamp on page 1:
+\`\`\`json
+{"action":"add_annotations","viewUUID":"…","annotations":[
+ {"id":"h1","type":"highlight","page":1,"rects":[{"x":72,"y":700,"width":200,"height":12}]},
+ {"id":"s1","type":"stamp","page":1,"x":300,"y":500,"label":"APPROVED","color":"green","rotation":-15}
+]}
+\`\`\`
+
+**HIGHLIGHT TEXT** — highlight_text: auto-find and highlight text by query. Requires \`query\`. Optional: page, color, content.
+
+**ANNOTATION MANAGEMENT**:
+• update_annotations: partial update (id+type required). • remove_annotations: remove by ids.
+
+**NAVIGATION & SEARCH**:
+• navigate: go to page (requires \`page\`)
+• search: highlight matches in UI (requires \`query\`). Results in model context.
+• find: silent search, no UI change (requires \`query\`). Results in model context.
+• search_navigate: jump to match (requires \`matchIndex\`)
+• zoom: set scale 0.5–3.0 (requires \`scale\`)
+
+**PAGE EXTRACTION** — get_pages: extract text/screenshots from page ranges without navigating. \`intervals\` = [{start?,end?}], e.g. [{}] for all. \`getText\` (default true), \`getScreenshots\` (default false). Max 20 pages.
+
+**FORMS** — fill_form: fill fields with \`fields\` array of {name, value}.`,
+ input_schema: {
+ type: "object" as const,
+ required: ["viewUUID", "action"],
+ properties: {
+ viewUUID: {
+ type: "string",
+ description:
+ "The viewUUID of the PDF viewer (from display_pdf result)",
+ },
+ action: {
+ type: "string",
+ enum: [
+ "navigate",
+ "search",
+ "find",
+ "search_navigate",
+ "zoom",
+ "add_annotations",
+ "update_annotations",
+ "remove_annotations",
+ "highlight_text",
+ "fill_form",
+ "get_pages",
+ ],
+ description: "Action to perform",
+ },
+ page: {
+ type: "number",
+ minimum: 1,
+ description: "Page number (for navigate, highlight_text)",
+ },
+ query: {
+ type: "string",
+ description: "Search text (for search / find / highlight_text)",
+ },
+ matchIndex: {
+ type: "number",
+ minimum: 0,
+ description: "Match index (for search_navigate)",
+ },
+ scale: {
+ type: "number",
+ minimum: 0.5,
+ maximum: 3.0,
+ description: "Zoom scale (for zoom)",
+ },
+ annotations: {
+ type: "array",
+ description:
+ "Annotation objects for add_annotations or update_annotations",
+ items: { type: "object" },
+ },
+ ids: {
+ type: "array",
+ items: { type: "string" },
+ description: "Annotation IDs (for remove_annotations)",
+ },
+ color: {
+ type: "string",
+ description: "Color override (for highlight_text)",
+ },
+ content: {
+ type: "string",
+ description: "Tooltip/note content (for highlight_text)",
+ },
+ fields: {
+ type: "array",
+ items: { type: "object" },
+ description: "Form fields (for fill_form)",
+ },
+ intervals: {
+ type: "array",
+ items: { type: "object" },
+ description: "Page ranges for get_pages",
+ },
+ getText: {
+ type: "boolean",
+ description: "Include text (for get_pages, default true)",
+ },
+ getScreenshots: {
+ type: "boolean",
+ description: "Include screenshots (for get_pages, default false)",
+ },
+ },
+ },
+ },
+];
+
+// Simulated display_pdf result the model would see after calling display_pdf
+const DISPLAY_PDF_RESULT_TEXT = `Displaying PDF (viewUUID: abc-123-def): https://arxiv.org/pdf/1706.03762.
+
+Use the \`interact\` tool with this viewUUID. Available actions:
+- navigate: go to a page
+- search / find: search text (search highlights in UI, find is silent)
+- search_navigate: jump to a search match by index
+- zoom: set zoom level (0.5–3.0)
+- add_annotations: add highlights, underlines, strikethroughs, notes, rectangles, freetext, stamps (APPROVED/DRAFT/CONFIDENTIAL/FINAL/VOID/REJECTED)
+- update_annotations: partially update existing annotations
+- remove_annotations: remove annotations by ID
+- highlight_text: find text by query and highlight it automatically
+- fill_form: fill PDF form fields
+- get_pages: extract text and/or screenshots from page ranges without navigating`;
+
+/**
+ * Conversation history simulating: user asked to display a PDF, model called
+ * display_pdf, and the tool returned the result above.
+ */
+const AFTER_DISPLAY_PDF = [
+ {
+ role: "user" as const,
+ content: "Show me the Attention Is All You Need paper",
+ },
+ {
+ role: "assistant" as const,
+ content: [
+ {
+ type: "tool_use" as const,
+ id: "toolu_display_1",
+ name: "display_pdf",
+ input: { url: "https://arxiv.org/pdf/1706.03762" },
+ },
+ ],
+ },
+ {
+ role: "user" as const,
+ content: [
+ {
+ type: "tool_result" as const,
+ tool_use_id: "toolu_display_1",
+ content: DISPLAY_PDF_RESULT_TEXT,
+ },
+ ],
+ },
+];
+
+/** Call the Anthropic Messages API and return parsed response. */
+async function callClaude(
+ messages: Array<{ role: string; content: unknown }>,
+): Promise<{
+ toolUses: Array<{ name: string; input: Record }>;
+ textBlocks: Array<{ text: string }>;
+}> {
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-api-key": API_KEY!,
+ "anthropic-version": "2023-06-01",
+ },
+ body: JSON.stringify({
+ model: "claude-sonnet-4-20250514",
+ max_tokens: 1024,
+ tools: TOOLS,
+ messages,
+ }),
+ });
+
+ expect(response.ok, `API error ${response.status}`).toBe(true);
+ const result = await response.json();
+
+ return {
+ toolUses: result.content.filter(
+ (c: { type: string }) => c.type === "tool_use",
+ ),
+ textBlocks: result.content.filter(
+ (c: { type: string }) => c.type === "text",
+ ),
+ };
+}
+
+/** Check if any tool call uses an annotation-related interact action. */
+function usesAnnotationAction(
+ toolUses: Array<{ name: string; input: Record }>,
+): boolean {
+ return toolUses.some(
+ (tu) =>
+ tu.name === "interact" &&
+ ["add_annotations", "highlight_text", "update_annotations"].includes(
+ tu.input.action as string,
+ ),
+ );
+}
+
+/** Check if any tool call uses the interact tool. */
+function usesInteract(
+ toolUses: Array<{ name: string; input: Record }>,
+): boolean {
+ return toolUses.some((tu) => tu.name === "interact");
+}
+
+/** Check if text mentions annotation capabilities. */
+function mentionsAnnotations(textBlocks: Array<{ text: string }>): boolean {
+ const text = textBlocks
+ .map((t) => t.text)
+ .join(" ")
+ .toLowerCase();
+ return (
+ text.includes("annotati") ||
+ text.includes("highlight") ||
+ text.includes("stamp") ||
+ text.includes("add_annotations") ||
+ text.includes("mark up")
+ );
+}
+
+test.describe("PDF Annotation — API prompt discovery", () => {
+ test("model uses highlight_text when asked to highlight the title", async () => {
+ const { toolUses } = await callClaude([
+ ...AFTER_DISPLAY_PDF,
+ {
+ role: "user",
+ content:
+ "Please highlight the title and add an APPROVED stamp on the first page.",
+ },
+ ]);
+
+ expect(toolUses.length).toBeGreaterThan(0);
+ expect(usesAnnotationAction(toolUses)).toBe(true);
+ });
+
+ test("model discovers annotation capabilities when asked 'can you annotate?'", async () => {
+ const { toolUses, textBlocks } = await callClaude([
+ ...AFTER_DISPLAY_PDF,
+ {
+ role: "user",
+ content: "Can you annotate this PDF? Mark important sections for me.",
+ },
+ ]);
+
+ // Model should either use annotations directly or acknowledge it can
+ const discovers =
+ usesAnnotationAction(toolUses) ||
+ usesInteract(toolUses) ||
+ mentionsAnnotations(textBlocks);
+ expect(discovers).toBe(true);
+ });
+
+ test("model uses add_annotations or get_pages when asked to add notes", async () => {
+ const { toolUses } = await callClaude([
+ ...AFTER_DISPLAY_PDF,
+ {
+ role: "user",
+ content:
+ "Add a note on page 1 saying 'Key contribution' at position (200, 500), and highlight the abstract.",
+ },
+ ]);
+
+ expect(toolUses.length).toBeGreaterThan(0);
+ // Model should use interact (either to annotate directly or to read first)
+ expect(usesInteract(toolUses)).toBe(true);
+ });
+});
diff --git a/tests/e2e/pdf-annotations.spec.ts b/tests/e2e/pdf-annotations.spec.ts
new file mode 100644
index 00000000..77c566e7
--- /dev/null
+++ b/tests/e2e/pdf-annotations.spec.ts
@@ -0,0 +1,311 @@
+import { test, expect, type Page } from "@playwright/test";
+
+// Increase timeout for these tests — PDF loading from arxiv can be slow
+test.setTimeout(120000);
+
+/**
+ * PDF Annotation E2E Tests
+ *
+ * Tests the annotation capabilities of the PDF server through the basic-host UI.
+ * Verifies that annotations can be added, rendered, and interacted with.
+ */
+
+/** Wait for the MCP App to load inside nested iframes. */
+async function waitForAppLoad(page: Page) {
+ const outerFrame = page.frameLocator("iframe").first();
+ await expect(outerFrame.locator("iframe")).toBeVisible({ timeout: 30000 });
+}
+
+/** Get the app frame locator (nested: sandbox > app) */
+function getAppFrame(page: Page) {
+ return page.frameLocator("iframe").first().frameLocator("iframe").first();
+}
+
+/** Load the PDF server and call display_pdf with the default PDF. */
+async function loadPdfServer(page: Page) {
+ await page.goto("/?theme=hide");
+ await expect(page.locator("select").first()).toBeEnabled({ timeout: 30000 });
+ await page.locator("select").first().selectOption({ label: "PDF Server" });
+ await page.click('button:has-text("Call Tool")');
+ await waitForAppLoad(page);
+}
+
+/**
+ * Extract the viewUUID from the display_pdf result panel.
+ * The tool result is displayed as JSON in a collapsible panel.
+ */
+async function extractViewUUID(page: Page): Promise {
+ // Wait for the Tool Result panel to appear — it contains "📤 Tool Result"
+ const resultPanel = page.locator('text="📤 Tool Result"').first();
+ await expect(resultPanel).toBeVisible({ timeout: 30000 });
+
+ // The result preview shows the first 100 chars including "viewUUID: ..."
+ // Click to expand the result panel to see the full JSON
+ await resultPanel.click();
+
+ // Wait for the expanded result content to appear
+ const resultContent = page.locator("pre").last();
+ await expect(resultContent).toBeVisible({ timeout: 5000 });
+
+ const resultText = (await resultContent.textContent()) ?? "";
+
+ // Extract viewUUID from the JSON result
+ // The text content includes: "Displaying PDF (viewUUID: ): ..."
+ const match = resultText.match(/viewUUID["\s:]+([a-f0-9-]{36})/);
+ if (!match) {
+ throw new Error(
+ `Could not extract viewUUID from result: ${resultText.slice(0, 200)}`,
+ );
+ }
+ return match[1];
+}
+
+/**
+ * Call the interact tool with the given input JSON.
+ * Selects the interact tool from the dropdown, fills the input, and clicks Call Tool.
+ */
+async function callInteract(page: Page, input: Record) {
+ // Select "interact" in the tool dropdown (second select on the page)
+ const toolSelect = page.locator("select").nth(1);
+ await toolSelect.selectOption("interact");
+
+ // Fill the input textarea with the JSON
+ const inputTextarea = page.locator("textarea");
+ await inputTextarea.fill(JSON.stringify(input));
+
+ // Click "Call Tool"
+ await page.click('button:has-text("Call Tool")');
+}
+
+/** Wait for the PDF canvas to render (ensures the page is ready for annotations). */
+async function waitForPdfCanvas(page: Page) {
+ const appFrame = getAppFrame(page);
+ await expect(appFrame.locator("canvas").first()).toBeVisible({
+ timeout: 30000,
+ });
+ // Wait a bit for fonts and text layer to stabilize
+ await page.waitForTimeout(2000);
+}
+
+test.describe("PDF Server - Annotations", () => {
+ test("display_pdf result mentions annotation capabilities", async ({
+ page,
+ }) => {
+ await loadPdfServer(page);
+
+ // Wait for result to appear
+ const resultPanel = page.locator('text="📤 Tool Result"').first();
+ await expect(resultPanel).toBeVisible({ timeout: 30000 });
+
+ // Expand the result panel
+ await resultPanel.click();
+ const resultContent = page.locator("pre").last();
+ await expect(resultContent).toBeVisible({ timeout: 5000 });
+ const resultText = (await resultContent.textContent()) ?? "";
+
+ // Verify the result text enumerates interact actions including annotations
+ expect(resultText).toContain("add_annotations");
+ expect(resultText).toContain("highlight_text");
+ expect(resultText).toContain("navigate");
+ expect(resultText).toContain("get_pages");
+ expect(resultText).toContain("stamps");
+ });
+
+ test("interact tool is available in tool dropdown", async ({ page }) => {
+ await loadPdfServer(page);
+
+ // Verify the interact tool is available in the tool dropdown
+ const toolSelect = page.locator("select").nth(1);
+ const options = await toolSelect.locator("option").allTextContents();
+ expect(options).toContain("interact");
+ });
+
+ test("add_annotations renders highlight on the page", async ({ page }) => {
+ await loadPdfServer(page);
+ await waitForPdfCanvas(page);
+
+ const viewUUID = await extractViewUUID(page);
+
+ // Add a highlight annotation on page 1
+ await callInteract(page, {
+ viewUUID,
+ action: "add_annotations",
+ annotations: [
+ {
+ id: "test-highlight-1",
+ type: "highlight",
+ page: 1,
+ rects: [{ x: 72, y: 700, width: 300, height: 14 }],
+ color: "rgba(255, 255, 0, 0.4)",
+ },
+ ],
+ });
+
+ // Wait for the interact result
+ await page.waitForTimeout(1000);
+
+ // Verify the annotation appears in the annotation layer inside the app frame
+ const appFrame = getAppFrame(page);
+ const annotationLayer = appFrame.locator("#annotation-layer");
+ await expect(annotationLayer).toBeVisible({ timeout: 5000 });
+
+ // Check that a highlight annotation element was rendered
+ const highlightEl = appFrame.locator(".annotation-highlight");
+ await expect(highlightEl.first()).toBeVisible({ timeout: 5000 });
+ });
+
+ test("add_annotations renders multiple annotation types", async ({
+ page,
+ }) => {
+ await loadPdfServer(page);
+ await waitForPdfCanvas(page);
+
+ const viewUUID = await extractViewUUID(page);
+
+ // Add multiple annotation types at once
+ await callInteract(page, {
+ viewUUID,
+ action: "add_annotations",
+ annotations: [
+ {
+ id: "test-highlight",
+ type: "highlight",
+ page: 1,
+ rects: [{ x: 72, y: 700, width: 300, height: 14 }],
+ color: "rgba(255, 255, 0, 0.4)",
+ },
+ {
+ id: "test-note",
+ type: "note",
+ page: 1,
+ x: 400,
+ y: 600,
+ content: "Important finding!",
+ color: "#ffeb3b",
+ },
+ {
+ id: "test-stamp",
+ type: "stamp",
+ page: 1,
+ x: 300,
+ y: 400,
+ label: "APPROVED",
+ color: "#4caf50",
+ rotation: -15,
+ },
+ {
+ id: "test-freetext",
+ type: "freetext",
+ page: 1,
+ x: 100,
+ y: 300,
+ content: "See section 3.2",
+ fontSize: 14,
+ color: "#1976d2",
+ },
+ {
+ id: "test-rect",
+ type: "rectangle",
+ page: 1,
+ x: 50,
+ y: 200,
+ width: 500,
+ height: 100,
+ color: "#f44336",
+ },
+ ],
+ });
+
+ await page.waitForTimeout(1500);
+
+ const appFrame = getAppFrame(page);
+
+ // Verify each annotation type is rendered
+ await expect(appFrame.locator(".annotation-highlight").first()).toBeVisible(
+ {
+ timeout: 5000,
+ },
+ );
+ await expect(appFrame.locator(".annotation-note").first()).toBeVisible({
+ timeout: 5000,
+ });
+ await expect(appFrame.locator(".annotation-stamp").first()).toBeVisible({
+ timeout: 5000,
+ });
+ await expect(appFrame.locator(".annotation-freetext").first()).toBeVisible({
+ timeout: 5000,
+ });
+ await expect(appFrame.locator(".annotation-rectangle").first()).toBeVisible(
+ { timeout: 5000 },
+ );
+ });
+
+ test("remove_annotations removes annotation from DOM", async ({ page }) => {
+ await loadPdfServer(page);
+ await waitForPdfCanvas(page);
+
+ const viewUUID = await extractViewUUID(page);
+
+ // Add an annotation
+ await callInteract(page, {
+ viewUUID,
+ action: "add_annotations",
+ annotations: [
+ {
+ id: "to-remove",
+ type: "highlight",
+ page: 1,
+ rects: [{ x: 72, y: 700, width: 300, height: 14 }],
+ },
+ ],
+ });
+
+ await page.waitForTimeout(1000);
+
+ const appFrame = getAppFrame(page);
+ await expect(appFrame.locator(".annotation-highlight").first()).toBeVisible(
+ {
+ timeout: 5000,
+ },
+ );
+
+ // Remove the annotation
+ await callInteract(page, {
+ viewUUID,
+ action: "remove_annotations",
+ ids: ["to-remove"],
+ });
+
+ await page.waitForTimeout(1000);
+
+ // Verify the annotation is gone
+ await expect(appFrame.locator(".annotation-highlight")).toHaveCount(0, {
+ timeout: 5000,
+ });
+ });
+
+ test("highlight_text finds and highlights text", async ({ page }) => {
+ await loadPdfServer(page);
+ await waitForPdfCanvas(page);
+
+ const viewUUID = await extractViewUUID(page);
+
+ // Use highlight_text to find and highlight "Attention" in the PDF
+ await callInteract(page, {
+ viewUUID,
+ action: "highlight_text",
+ query: "Attention",
+ color: "rgba(0, 200, 255, 0.4)",
+ });
+
+ await page.waitForTimeout(2000);
+
+ const appFrame = getAppFrame(page);
+ // highlight_text creates highlight annotations, so we should see at least one
+ await expect(appFrame.locator(".annotation-highlight").first()).toBeVisible(
+ {
+ timeout: 10000,
+ },
+ );
+ });
+});