pdf-server: annotations, interact tool, page extraction & prompt engineering#506
Open
pdf-server: annotations, interact tool, page extraction & prompt engineering#506
Conversation
@modelcontextprotocol/ext-apps
@modelcontextprotocol/server-basic-preact
@modelcontextprotocol/server-basic-react
@modelcontextprotocol/server-basic-solid
@modelcontextprotocol/server-basic-svelte
@modelcontextprotocol/server-basic-vanillajs
@modelcontextprotocol/server-basic-vue
@modelcontextprotocol/server-budget-allocator
@modelcontextprotocol/server-cohort-heatmap
@modelcontextprotocol/server-customer-segmentation
@modelcontextprotocol/server-debug
@modelcontextprotocol/server-map
@modelcontextprotocol/server-pdf
@modelcontextprotocol/server-scenario-modeler
@modelcontextprotocol/server-shadertoy
@modelcontextprotocol/server-sheet-music
@modelcontextprotocol/server-system-monitor
@modelcontextprotocol/server-threejs
@modelcontextprotocol/server-transcript
@modelcontextprotocol/server-video-resource
@modelcontextprotocol/server-wiki-explorer
commit: |
Add PDF annotation system with 7 annotation types (highlight, underline, strikethrough, note, rectangle, freetext, stamp), text-based highlighting, form filling, and annotated PDF download using pdf-lib. - Server: annotation Zod schemas, extended interact tool with add/update/remove annotations, highlight_text, and fill_form actions - Client: annotation layer rendering with PDF coordinate conversion, persistence via localStorage (using toolInfo.id key), pdf-lib-based download with embedded annotations and form fills, uses app.downloadFile() SDK with <a> fallback - Model context includes annotation summary Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New tool `get_pages` lets the model get text and/or screenshots from arbitrary page ranges without navigating the visible viewer. - Server: `get_pages` tool with interval-based page ranges (optional start/end, open ranges supported), `getText`/`getScreenshots` flags, request-response bridge via `submit_page_data` app-only tool - Client: offscreen rendering (hidden canvas, no visual interference), text from cache or on-demand extraction, screenshots scaled to 768px max dimension, results submitted back to server - Max 20 pages per request, 60s timeout Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fold get_pages into the interact tool to minimize tools requiring approval. Now accessed via `interact(action: "get_pages", ...)`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add concrete per-type schema docs with field names in tool description - Add JSON example showing add_annotations with highlight + stamp - Replace opaque z.record(z.string(), z.unknown()) with typed union of all annotation schemas (full + partial forms) so the model sees exact field names and types - Remove redundant manual safeParse since Zod inputSchema validates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- display_pdf result text now explicitly lists annotation capabilities (highlights, stamps, notes, etc.) instead of vague "navigate, search, zoom, etc." - Restructured interact tool description: annotations promoted to top, with clear type reference, JSON example, and bold section headers - Added pdf-annotations.spec.ts with 6 E2E tests covering: - Result text mentions annotation capabilities - interact tool available in dropdown - add_annotations renders highlight - Multiple annotation types render (highlight, note, stamp, freetext, rectangle) - remove_annotations removes from DOM - highlight_text finds and highlights text Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests that Claude can discover and use PDF annotation capabilities by calling the Anthropic Messages API with the tool schemas and simulated display_pdf result. Disabled by default — skipped unless ANTHROPIC_API_KEY is set: ANTHROPIC_API_KEY=sk-... npx playwright test tests/e2e/pdf-annotations-api.spec.ts 3 scenarios tested: - Model uses highlight_text when asked to highlight the title - Model discovers annotation capabilities when asked "can you annotate?" - Model uses interact (add_annotations or get_pages) when asked to add notes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…e to README - Example prompts for annotations, navigation, page extraction, stamps, forms - Documents how to run E2E tests and API prompt discovery tests - Updated tools table to include interact tool - Updated key patterns table with annotations, command queue, file download - Added pdf-lib to dependencies list Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The typed Zod union (14 anyOf variants: 7 full + 7 partial annotation types) produced a 5,817-char JSON schema for the annotations field alone. This bloated the interact tool schema to 7,802 chars, which may cause the model to struggle with or skip the tool. Replace with z.record(z.string(), z.any()) — annotation types are already fully documented in the tool description. Schema drops to 2,239 chars (71% reduction), annotations field to 254 chars (96% reduction). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The display_pdf result text now lists every action by name (navigate, search, find, search_navigate, zoom, add_annotations, update_annotations, remove_annotations, highlight_text, fill_form, get_pages) so the model knows exactly what commands are available without needing to inspect the interact tool schema. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The model was passing "pdf-viewer" instead of the actual UUID, causing get_pages to timeout (commands queued under wrong key, client never picks them up). - Add activeViewUUIDs set tracking UUIDs issued by display_pdf - Validate viewUUID at the top of interact handler with clear error - Add "IMPORTANT: viewUUID must be the exact UUID returned by display_pdf" to the interact tool description Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Raise annotation-layer z-index above text-layer so note annotations receive hover/click events (was z-index: 1, now 3; text-layer is 2) - Replace memo emoji (data-icon attr) with CSS mask SVG document icon that respects currentColor for consistent cross-platform rendering
Right-side panel (250px) shows all annotations grouped by page with expand/collapse cards. Clicking a card navigates to the page and pulses the annotation; clicking a note icon on the PDF highlights its card in the panel. Panel auto-shows on first annotation, remembers user toggle preference via localStorage, and shows a badge count when collapsed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Server now holds poll_pdf_commands requests open (up to 30s) until commands arrive, waking waiters via enqueueCommand. Client loops sequentially instead of using setInterval, with 2s backoff on errors. Reduces idle RPC traffic from ~3 calls/sec to ~2 calls/min. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Render PDF.js AnnotationLayer with renderForms:true so form fields appear as interactive HTML inputs. Build a fieldName→annotationID map via getFieldObjects() to bridge fill_form (which uses field names) with annotationStorage (which uses annotation IDs). Sync user input back to formFieldValues for persistence and PDF download. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…lling When enabled and the client supports elicitation, extracts form fields from the PDF via pdf-lib and prompts the user to fill them before the viewer loads. Elicited values are returned in content/structuredContent and enqueued as a fill_form command for the viewer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…d extraction Use pdfjs-dist's getDocument() + getFieldObjects() instead of pdf-lib's PDFDocument.load() + getForm().getFields() in extractFormSchema(). This removes the pdf-lib import from the server bundle (pdf-lib is still used client-side for PDF modification in downloadAnnotatedPdf). Uses the legacy pdfjs-dist build to avoid DOMMatrix dependency in Node.js. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Set --scale-factor/--total-scale-factor CSS variables on the form layer so AnnotationLayer font-size rules resolve correctly instead of falling back to browser defaults. Also update live DOM elements directly in fill_form handler so values appear immediately without waiting for a full re-render. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
After rendering the annotation layer, shrink select[size] font to fit within the PDF rect height. The default AnnotationLayer CSS uses a fixed 9px * scale-factor which overflows when many options share a small rect. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Show a delete button on each annotation card that appears on hover. Clicking it removes the annotation from the PDF and persists the change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Skip keyboard navigation shortcuts (space, arrows, +/-) when any input, textarea, or select element is focused, not just the search/page inputs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Form field values now appear in the sidebar under a "Form Fields" group, with trash icons to clear individual values. The badge count and auto-show logic include form fields. The panel open/close now calls requestFitToContent with width to avoid overflow in inline layout mode. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Explicitly state that Y=0 is the bottom edge and Y=792 is the top for US Letter, with concrete guidance on typical values for top/bottom placement to prevent models from using top-down screen coordinates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract form field names during display_pdf and cache per viewer UUID. fill_form now soft-fails on unknown field names (applies valid ones, reports skipped ones). Both display_pdf and fill_form results include the list of valid field names so the model can self-correct. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
In inline mode, replace the 250px side panel with a slim bottom strip that shows one annotation/form-field at a time with prev/next navigation. The side panel is still used in fullscreen mode. - Strip shows item swatch, label, preview + counter (N of M · Page P) - Click item to navigate to its page; delete single or clear all - requestFitToContent includes strip height in size calculation - fieldNameToPage map built during init for form field page context - Display mode toggle switches between strip and panel automatically
…ases Add explicit guidance on when to use the tool: filling out forms (tax forms, applications), annotating PDFs, and interactive review. This helps models route user requests like 'help me fill out this form' to the display_pdf tool.
…rop, and PDF export Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…spect ratio, add handle tooltips Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use non-standard /Name to prevent Preview.app from substituting built-in stamp graphics, align padding with CSS (4pt/12pt), add ExtGState for 0.6 opacity, and fix text baseline positioning. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Page accordion click navigates to that page
- Form fields appear in their page's accordion section (before annotations)
- Selecting annotation or focusing form field auto-expands page accordion
- Image resize preserves aspect ratio by default (Shift for free resize)
- Add aspect field ("preserve"|"ignore") to ImageAnnotation type
- Fix selection UI regression: remove overflow:hidden that clipped handles
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…descriptions - Add signing/signature keywords to display_pdf description for discoverability - Add image annotation example (signature placement) to interact tool description - Clarify that imageUrl supports file paths and HTTP URLs with auto-fetch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…mageUrl accepts file paths Models tend to encode images as base64 themselves instead of passing file paths. Make the schema descriptions and tool docs explicit: prefer imageUrl with a file path, no data: URIs, don't encode images yourself. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Models were encoding images as base64 themselves instead of passing file paths. Remove imageData from the model-facing Zod schema entirely — only imageUrl (file path or HTTPS URL) is accepted. The server fetches and embeds the image automatically. imageData remains as an internal field for client-side rendering and drag-drop. Also mention drag-and-drop as an alternative in the tool description. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ges) - Request clipboardWrite permission in resource metadata - Ctrl/Cmd+C: copy selected annotations as JSON to clipboard - Ctrl/Cmd+X: cut (copy + delete) selected annotations - Ctrl/Cmd+V / paste event: paste annotations from clipboard JSON, or paste images from clipboard (e.g. screenshots, copied images) - Refactor drag-drop image creation into shared addImageFromFile() - Pasted annotations get new IDs and slight offset to avoid overlap - Pasted images are centered on the current page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a `disableInteract` boolean to `createServer()` options that skips registering the `interact`, `poll_pdf_commands`, and `submit_page_data` tools (all relying on an in-memory command queue). Adjusts `display_pdf` description and schema accordingly when disabled. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rename disableInteract → enableInteract (default false) so existing library consumers get read-only mode without code changes. The CLI entry point passes enableInteract: true only when running with --stdio. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Form widgets: PDF.js writes inline background-color (from the annotation /BC entry, typically 'transparent'), defeating our CSS. Force opaque with !important. Fixes double-text in listboxes and text inputs at any zoom. - Poll spam: view was calling poll_pdf_commands in a tight loop when interact is disabled (HTTP mode) — server returns isError, which doesn't throw, so the loop never backed off. Server now signals interactEnabled via _meta; view only polls when true, and stops entirely on isError results. - Silence Helvetica / inset-border warnings: set verbosity=ERRORS for server-side getDocument (only used for form introspection, no rendering, so font fallback warnings are noise).
Resolve pdfjs-dist/standard_fonts/ at runtime via createRequire. Works from both source (bun main.ts) and bundled dist/ since pdfjs-dist is --external. NodeStandardFontDataFactory reads via fs.readFile, so a filesystem path with trailing separator is what's expected. Keep verbosity=ERRORS as backstop for unrelated warnings (e.g. 'Unimplemented border style: inset').
- Single CDN URL pinned to the bundled pdfjs-dist version, used in both server (Node) and viewer (browser) - Node: default NodeStandardFontDataFactory uses fs.readFile which can't fetch URLs — pass a minimal FetchStandardFontDataFactory - Browser: DOMStandardFontDataFactory already fetches, just pass the URL - Add https://unpkg.com to CSP connectDomains (pdf.js loads font binaries via fetch(), maps to connect-src)
Without this, an invalid totalBytes flows to PDFDataRangeTransport.length → worker ChunkedStream sized at 0 → opaque 'Bad end offset: N' error. The guard turns that into an actionable message (rebuild hint).
…from history The display_pdf result (including totalBytes) is baked into conversation history. If the user saves the annotated PDF (grows the file) then reloads the conversation, the host replays the stale size. pdf.js initializes its ChunkedStream at that stale length, then the first live read_pdf_bytes returns the current (larger) file — every chunk fails the 'end === bytes.length' check with 'Bad end offset: N'. Fix: drop the fileSizeBytes param and always probe via a live 1-byte read_pdf_bytes call before setting up the range transport.
Server:
- fs.watch on local files when interact is enabled; pushes file_changed
via the poll_pdf_commands channel. Re-attaches on rename to survive
atomic writes (vim/vscode). Debounced 150ms. Skips unchanged mtime.
- save_pdf returns {filePath, mtimeMs} so the saving viewer can
recognise its own write's echo. Other viewers on the same file still
get notified (their content is genuinely stale).
- stopFileWatch in pruneStaleQueues TTL sweep.
Viewer:
- In-app confirm dialog (no window.confirm — would block the iframe).
- Save button: hidden until first edit, enabled while dirty, disabled
(but still visible) after save, re-enabled on next edit.
- savePdf() asks for confirmation, records lastSavedMtime.
- loadGeneration counter invalidates stale fetchChunk results and
aborts the preloader when the PDF is reloaded underneath them.
- reloadPdf() clears all per-document state (byte cache, annotations,
form fields, undo/redo, text caches, field maps, localStorage diff)
and loads fresh.
- file_changed handler: suppresses own-save echo via saveInProgress +
mtime match; auto-reloads when clean; prompts Keep/Discard when dirty.
Covers: external write → file_changed enqueued via poll_pdf_commands; debounced rapid writes; stopFileWatch prevents further events; save_pdf returns mtimeMs; watcher survives atomic rename (vim/vscode pattern).
… prompt - .confirm-btn:hover's background cascaded to primary buttons too (same specificity, primary has both classes) — turning the blue button light grey on hover. Re-assert background in primary:hover. - Save confirm: extract just the filename from pdfUrl (strip file:// prefix, take basename).
Use host-provided CSS variables (--color-*, --font-*, --border-radius-*, --shadow-*) from applyHostStyleVariables, with local fallbacks. - Larger border-radius (--border-radius-xl), softer shadow (--shadow-lg) - Larger bold title (--font-heading-md-size, --font-weight-bold) - Primary button uses inverse colors (dark bg + light text) like the host's downloadFile dialog, not blue - New .confirm-detail box: monospace bordered rect for filename - Button order: Cancel first, primary last (native convention) - Escape resolves to first non-primary button
…button persistAnnotations() was unconditionally setDirty(true). It already computes the diff vs baseline — use isDiffEmpty(diff) to decide instead. Now undoing all the way back to the original state marks the viewer clean again (save button disables, title loses asterisk).
getFieldObjects() returns the PDF's stored form values but we were only reading field IDs/pages from it. After saving a filled form and reopening, the panel showed nothing and there was no way to see what was filled. - New pdfBaselineFormValues map, populated in buildFieldNameMap() from each field's .value (skipping empty/Off/button values). Seeds formFieldValues so the panel shows PDF-stored values on open. - computeDiff takes an optional baselineFormFields param and only includes values that differ — opening a filled PDF doesn't mark dirty, editing a field does, reverting to the PDF's value marks clean again. - importFieldValue() normalises radio-group value lookup (parent entry has value=undefined, children have the real export value), checkbox→ true, listbox array→joined string.
Reset-button regression: buildFieldNameMap() now seeds formFieldValues from the PDF's stored values, but syncFormValuesToStorage() was pushing them back into annotationStorage in our normalised repr (checkbox→true, radio→export string). pdf.js's native repr may differ, and overwriting it breaks the form's Reset button. Fix: skip syncing values that match baseline — the PDF's own values are already in storage natively. Panel buttons: - Reset: revert to what's in the PDF file. Restores baseline annotations and form values, clears undo/redo. Empty diff → clean. Disabled when not dirty. - Clear all: removes EVERYTHING including PDF-native items. Non-empty diff (baseline items are 'removed') → dirty; saving writes stripped PDF. Disabled when nothing to clear.
Clear all regression: annotationStorage.remove(id) only drops our override — the widget reverts to the PDF's stored /V (the baseline value). To actually clear, push each field's defaultValue (/DV) via setValue instead, which is what the PDF's native Reset button does. Save button visibility: server now checks fs.access(path, W_OK) and reports via _meta.writable. Viewer gates save on that instead of just 'is this a local path'. Hides the button for read-only mounts and Claude Desktop's uploads directory. Removed dead isLocalFileUrl().
Root cause: getFieldObjects() returns field-dictionary refs (the /T tree, e.g. '86R'), but annotationStorage is keyed by WIDGET annotation refs (what page.getAnnotations() returns, e.g. '9R'). These differ when a PDF's field and its widget /Kids are separate objects. fieldNameToIds was built from getFieldObjects().id, so EVERY storage write was keyed wrong and silently no-op'd: - syncFormValuesToStorage: wrote to dead keys - fill_form: setValue + querySelector([data-element-id=...]) both missed - individual field delete: remove() on wrong key - clearAllItems: setValue to wrong keys Fix: build fieldNameToIds from page.getAnnotations() which gives the correct widget IDs. Keep cachedFieldObjects only for type/value/ defaultValue metadata (matched by name, not id) and for passing to AnnotationLayer.render() where pdf.js uses it internally. Also extract clearFieldInStorage() helper — pushes defaultValue for a single field, used by both individual delete and Clear all.
pdf.js _bindResetFormAction iterates fieldObjects using each entry's .id
to (a) key annotationStorage and (b) querySelector([data-element-id=...]).
Both expect WIDGET annotation IDs. fieldObjects contains field-dict IDs.
Works only when field and widget share a PDF object — which pdf-lib's
save breaks (it splits merged objects on write).
This is a latent pdf.js bug, not a regression from our changes — it was
broken the moment the user first saved via save_pdf.
Fix: rebuild cachedFieldObjects with widget IDs (from fieldNameToIds)
before passing to AnnotationLayer.render({fieldObjects}). Skip parent
entries with no concrete id (radio group /T tree root). Preserve
type/defaultValue/exportValues which Reset needs.
Combo reset: pdf.js's resetform handler sets all option.selected = (option.value === defaultFieldValue); when defaultFieldValue is null nothing matches. Chrome then synchronously normalises the non-multiple <select> by auto-selecting option[0] — so by the time our fix-listener ran, selectedIndex was 0 and the state-check failed. Now: capture whether a real default exists before the event; if reset didn't land on it, force-prepend a hidden blank and select it explicitly. (Was latent — before the ID fix, resetform never dispatched on selects.) Writability scope: only mark writable if the file is explicitly in allowedLocalFiles (CLI arg = opt-in) OR strictly under an allowed root directory (isAncestorDir already excludes the root itself via rel !== ''), AND fs.access W_OK passes. A root passed by the client is a boundary, not a target. CI: drop the post-rename second-write assertion — fs.watch re-attach semantics differ between kqueue and inotify, inherently racy in CI. Only assert the rename itself is detected.
Reset-all bug: clearUserFormStorage skipped any field whose name was
in pdfBaselineFormValues — but if the user had EDITED a baseline field,
the edit sits in storage under that name. Skipping it left the widget
showing the stale edit while the panel showed the restored baseline.
Fix: remove ALL storage overrides on reset — every field reverts to the
PDF's /V, which IS baseline. Removed the now-dead helper.
Panel: cleared baseline fields now stay visible instead of vanishing.
State is derived per-field by comparing formFieldValues to
pdfBaselineFormValues (no new data structures — both maps already
exist, the comparison was just never rendered):
- unchanged: current === baseline → solid swatch, trash clears
- modified: baseline exists, current differs → solid swatch, revert
- cleared: baseline exists, current empty/absent → outlined cross
swatch, struck-out label/value, revert restores
- added: no baseline → solid swatch, trash removes
Panel iterates union(formFieldValues, pdfBaselineFormValues) so cleared
items don't disappear. sidebarItemCount uses the same union so the
toolbar button stays visible as long as there are baseline items OR
edits. Per-item revert: formFieldValues.set(name, baseline) +
storage.remove(id) → widget reverts to /V.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds full annotation, interaction, and page extraction capabilities to the PDF server:
navigate,search,find,search_navigate,zoomadd_annotations(7 types: highlight, underline, strikethrough, note, rectangle, freetext, stamp),update_annotations,remove_annotationshighlight_text— auto-find and highlight text by queryget_pages— batch text and/or screenshot extraction from page ranges without visual navigation (offscreen rendering)fill_form— fill PDF form fieldspdf-lib(client-side) +app.downloadFile()SDK supporttoolInfo.idNew dependency
pdf-lib(^1.17.1) — client-side PDF modification for annotated downloadFiles changed
examples/pdf-server/server.tsexamples/pdf-server/src/mcp-app.tsexamples/pdf-server/mcp-app.htmlexamples/pdf-server/src/mcp-app.cssexamples/pdf-server/README.mdtests/e2e/pdf-annotations.spec.tstests/e2e/pdf-annotations-api.spec.tsANTHROPIC_API_KEY)Test plan
npx playwright test tests/e2e/pdf-annotations.spec.ts— 6 tests pass (annotation CRUD, highlight_text)npx playwright test -g "PDF Server"— existing screenshot tests passANTHROPIC_API_KEY=... npx playwright test tests/e2e/pdf-annotations-api.spec.ts— 3/3 pass (model discovers annotations)npm run --workspace examples/pdf-server build— compiles cleanly🤖 Generated with Claude Code