ext-apps/examples/pdf-server/src/commands.ts
Olivier Chafik 4fc95131bf
feat(pdf-server): get_viewer_state interact action (#590)
* feat(pdf-server): get_viewer_state interact action

New interact action that returns a JSON snapshot of the live viewer:
{currentPage, pageCount, zoom, displayMode, selectedAnnotationIds,
 selection: {text, contextBefore, contextAfter, boundingRect} | null}.

The viewer already pushes selection passively via setModelContext as
<pdf-selection> tags, but not all hosts surface model-context. This gives
the model an explicit pull.

selection.boundingRect is a single bbox in PDF points (top-left origin,
y-down) so it can be fed straight back into add_annotations. selection is
null when nothing is selected or the selection is outside the text-layer.

Wiring: new PdfCommand variant -> processCommands case ->
handleGetViewerState -> submit_viewer_state (new app-only tool, mirrors
submit_save_data) -> waitForViewerState -> text content block.

Also fills a gap in the display_pdf description: it listed interact
actions but was missing save_as; added that and get_viewer_state.

e2e: two tests covering selection:null and a programmatic text-layer
selection.

* test(pdf-server): fix get_viewer_state e2e race + assertions

readLastToolResult clicked .last() before the interact result panel
existed (callInteract doesn't block), so it expanded the display_pdf
panel instead. Wait for the expected panel count first.

Also: basic-host renders the full CallToolResult JSON, with the state
double-escaped inside content[0].text. Parse instead of regex-matching.

playwright.config.ts: honor PW_CHANNEL env to use system Chrome locally
when the bundled chromium_headless_shell is broken.
2026-04-02 08:47:48 +01:00

70 lines
2.2 KiB
TypeScript

/**
* PdfCommand — the wire protocol between server and viewer.
*
* The server enqueues these via the `interact` tool; the viewer polls
* `poll_pdf_commands` and receives them as `structuredContent.commands`.
*
* This file is the single source of truth for the command shape. Both
* `server.ts` (which enqueues) and `mcp-app.ts` (which consumes) import
* from here — a new command variant must be added exactly once.
*
* Uses `import type` so neither side pulls pdf-lib into its bundle.
*/
import type { PdfAnnotationDef } from "./pdf-annotations.js";
/** Single form field assignment, as sent by `fill_form`. */
export interface FormFieldFill {
name: string;
value: string | boolean;
}
/**
* Partial annotation update: `id` + `type` pin the target, everything
* else is optional. Server validates shape; viewer merges into existing.
*/
export type PdfAnnotationPatch = Partial<PdfAnnotationDef> & {
id: string;
type: PdfAnnotationDef["type"];
};
/** Page range for text/screenshot extraction. Omitted bound = open-ended. */
export interface PageInterval {
start?: number;
end?: number;
}
/**
* Commands the server can send to the viewer via the poll queue.
* Adding a variant here means adding a `case` in both the server's
* `interact` handler (to enqueue) and the viewer's `processCommands`
* (to execute).
*/
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: PdfAnnotationDef[] }
| { type: "update_annotations"; annotations: PdfAnnotationPatch[] }
| { type: "remove_annotations"; ids: string[] }
| {
type: "highlight_text";
id: string;
query: string;
page?: number;
color?: string;
content?: string;
}
| { type: "fill_form"; fields: FormFieldFill[] }
| {
type: "get_pages";
requestId: string;
intervals: PageInterval[];
getText: boolean;
getScreenshots: boolean;
}
| { type: "save_as"; requestId: string }
| { type: "get_viewer_state"; requestId: string }
| { type: "file_changed"; mtimeMs: number };