Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions examples/pdf-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,16 +188,29 @@ When roots are ignored the server logs:

## Tools

| 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, save to file |
| `read_pdf_bytes` | App only | Stream PDF data in chunks |
| `save_pdf` | App only | Save annotated PDF back to local file |
| 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, save/export |
| `read_pdf_bytes` | App only | Stream PDF data in chunks |
| `save_pdf` | App only | Save annotated PDF back to local file |

¹ stdio only by default; in HTTP mode requires `--enable-interact` — see [Deployment](#deployment).

### App tools

The viewer also registers tools on the view itself (via `app.registerTool`), for hosts that list and call view tools directly. These mirror the `interact` actions (navigate, search, zoom, annotations, form fill, `get_text`, `get_screenshot`, …) and include `get_current_pdf`.

### Exporting the current document

`get_current_pdf` is the viewer's "get output file" tool: it returns the current document — with annotations and form fills applied — as an embedded PDF resource in the tool result, so the model can retrieve the file whenever it needs it without a save to disk (as opposed to `ui/download-file`, which saves to the user's disk without the model seeing the bytes).

It is available on both surfaces:

- **App tool** (view-registered): the view builds the bytes and returns them directly in the `tools/call` response.
- **`interact` action** (server-mediated, for stdio hosts such as Claude Desktop): the server enqueues a `get_current_pdf` command, the viewer builds the bytes and posts them back through the app-only `submit_current_pdf` tool, and the blocking `interact` call returns the same text + embedded resource.

## 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:
Expand Down
5 changes: 4 additions & 1 deletion examples/pdf-server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ export async function startStreamableHTTPServer(
): Promise<void> {
const port = parseInt(process.env.PORT ?? "3001", 10);

const app = createMcpExpressApp({ host: "0.0.0.0" });
// Raise the JSON body limit (express default: 100kb): the viewer submits
// whole-document base64 through submit_current_pdf / submit_save_data and
// page screenshots through submit_page_data.
const app = createMcpExpressApp({ host: "0.0.0.0", jsonLimit: "64mb" });
app.use(cors());

app.all("/mcp", async (req: Request, res: Response) => {
Expand Down
96 changes: 96 additions & 0 deletions examples/pdf-server/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1675,6 +1675,102 @@ describe("interact tool", () => {
});
});

describe("get_current_pdf action", () => {
it("full roundtrip: enqueue → poll → submit → embedded resource returned", async () => {
const { server, client } = await connect();
const uuid = "getpdf-roundtrip";
const pdfBytes = "%PDF-1.4\nfake-current-contents\n%%EOF";

// interact blocks in waitForCurrentPdf until submit_current_pdf resolves it
const interactPromise = client.callTool({
name: "interact",
arguments: { viewUUID: uuid, action: "get_current_pdf" },
});

// Viewer polls → receives the get_current_pdf command with a requestId
const cmds = await poll(client, uuid);
expect(cmds).toHaveLength(1);
expect(cmds[0].type).toBe("get_current_pdf");
const requestId = cmds[0].requestId as string;
expect(typeof requestId).toBe("string");

// Viewer submits bytes + metadata
const submit = await client.callTool({
name: "submit_current_pdf",
arguments: {
requestId,
data: Buffer.from(pdfBytes).toString("base64"),
fileName: "report - edited.pdf",
hasEdits: true,
},
});
expect(submit.isError).toBeFalsy();

// interact unblocks with a text summary + the embedded resource
const r = await interactPromise;
expect(r.isError).toBeFalsy();
const content = r.content as Array<{
type: string;
text?: string;
resource?: { uri: string; mimeType: string; blob: string };
}>;
expect(content[0].type).toBe("text");
expect(content[0].text).toContain("report - edited.pdf");
expect(content[0].text).toContain(`${pdfBytes.length} bytes`);
expect(content[0].text).toContain("includes in-viewer edits");
const resource = content.find((c) => c.type === "resource");
expect(resource).toBeTruthy();
expect(resource!.resource!.mimeType).toBe("application/pdf");
expect(resource!.resource!.uri).toBe(
`file:///${encodeURIComponent("report - edited.pdf")}`,
);
expect(Buffer.from(resource!.resource!.blob, "base64").toString()).toBe(
pdfBytes,
);

await client.close();
await server.close();
});

it("viewer-reported error propagates as isError", async () => {
const { server, client } = await connect();
const uuid = "getpdf-error";

const interactPromise = client.callTool({
name: "interact",
arguments: { viewUUID: uuid, action: "get_current_pdf" },
});

const cmds = await poll(client, uuid);
expect(cmds).toHaveLength(1);
const requestId = cmds[0].requestId as string;

await client.callTool({
name: "submit_current_pdf",
arguments: { requestId, error: "no document loaded" },
});

const r = await interactPromise;
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("no document loaded");

await client.close();
await server.close();
});

it("submit_current_pdf with unknown requestId returns isError", async () => {
const { server, client } = await connect();
const r = await client.callTool({
name: "submit_current_pdf",
arguments: { requestId: "never-created", data: "AAAA" },
});
expect(r.isError).toBe(true);
expect(firstText(r)).toContain("No pending request");
await client.close();
await server.close();
});
});

describe("viewer liveness", () => {
// get_screenshot/get_text fail fast when the iframe never polled, instead
// of waiting 45s for a viewer that isn't there. Reproduces the case where
Expand Down
164 changes: 158 additions & 6 deletions examples/pdf-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,43 @@ function waitForViewerState(
});
}

/** What the viewer submits for a get_current_pdf request. */
interface CurrentPdfPayload {
data: string; // base64 PDF bytes
fileName: string;
hasEdits: boolean;
}

const pendingCurrentPdfRequests = new Map<
string,
(v: CurrentPdfPayload | Error) => void
>();

/**
* Wait for the viewer to build the current PDF bytes and submit them.
* Same timeout/abort semantics as waitForSaveData.
*/
function waitForCurrentPdf(
requestId: string,
signal?: AbortSignal,
): Promise<CurrentPdfPayload> {
return new Promise<CurrentPdfPayload>((resolve, reject) => {
const settle = (v: CurrentPdfPayload | Error) => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
pendingCurrentPdfRequests.delete(requestId);
v instanceof Error ? reject(v) : resolve(v);
};
const onAbort = () => settle(new Error("interact request cancelled"));
const timer = setTimeout(
() => settle(new Error("Timeout waiting for PDF bytes from viewer")),
GET_PAGES_TIMEOUT_MS,
);
signal?.addEventListener("abort", onAbort);
pendingCurrentPdfRequests.set(requestId, settle);
});
}

interface QueueEntry {
commands: PdfCommand[];
/** Timestamp of the most recent enqueue or dequeue */
Expand Down Expand Up @@ -1473,6 +1510,7 @@ Returns a viewUUID in structuredContent. Pass it to \`interact\`:
- navigate, search, find, search_navigate, zoom
- get_text, get_screenshot, get_viewer_state (extract content / read selection & current page)
- save_as (write annotated PDF to disk)
- get_current_pdf (return the current PDF, edits applied, as an embedded resource)

Accepts local files (use list_pdfs), client MCP root directories, or any HTTPS URL.
Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before display.`,
Expand Down Expand Up @@ -1760,6 +1798,7 @@ URL: ${normalized}`,
"get_screenshot",
"get_viewer_state",
"save_as",
"get_current_pdf",
])
.describe("Action to perform"),
page: z
Expand Down Expand Up @@ -1829,7 +1868,11 @@ URL: ${normalized}`,
type InteractCommand = z.infer<typeof InteractCommandSchema>;
type ContentPart =
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string };
| { type: "image"; data: string; mimeType: string }
| {
type: "resource";
resource: { uri: string; mimeType: string; blob: string };
};

/**
* Resolve an image annotation: fetch imageUrl → imageData if needed,
Expand Down Expand Up @@ -2347,6 +2390,42 @@ URL: ${normalized}`,
);
}
}
case "get_current_pdf": {
const requestId = randomUUID();
enqueueCommand(uuid, { type: "get_current_pdf", requestId });
let payload: CurrentPdfPayload;
try {
await ensureViewerIsPolling(uuid);
payload = await waitForCurrentPdf(requestId, signal);
} catch (err) {
return {
content: [
{
type: "text",
text: `get_current_pdf failed: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
};
}
const byteLength = Buffer.from(payload.data, "base64").length;
return {
content: [
{
type: "text",
text: `${payload.fileName} (application/pdf, ${byteLength} bytes${payload.hasEdits ? ", includes in-viewer edits" : ""})`,
},
{
type: "resource",
resource: {
uri: `file:///${encodeURIComponent(payload.fileName)}`,
mimeType: "application/pdf",
blob: payload.data,
},
},
],
};
}
case "get_viewer_state": {
const requestId = randomUUID();
enqueueCommand(uuid, { type: "get_viewer_state", requestId });
Expand Down Expand Up @@ -2428,7 +2507,9 @@ Example — add a signature image and a stamp, then screenshot to verify:

**FORMS** — fill_form: fill fields with \`fields\` array of {name, value}.

**SAVE** — save_as: write the annotated PDF (annotations + form values) to a file. Pass \`path\` (absolute path or file://) for a new location, or omit \`path\` to overwrite the original. Set \`overwrite: true\` to replace an existing file (always required when omitting \`path\`).`,
**SAVE** — save_as: write the annotated PDF (annotations + form values) to a file. Pass \`path\` (absolute path or file://) for a new location, or omit \`path\` to overwrite the original. Set \`overwrite: true\` to replace an existing file (always required when omitting \`path\`).

**EXPORT** — get_current_pdf: return the current document (annotations + form values applied) as an embedded PDF resource. No file is written. The result embeds the whole document as base64, so prefer a single call once editing is done.`,
inputSchema: z.object({
viewUUID: z
.string()
Expand All @@ -2452,6 +2533,7 @@ Example — add a signature image and a stamp, then screenshot to verify:
"get_screenshot",
"get_viewer_state",
"save_as",
"get_current_pdf",
])
.optional()
.describe(
Expand Down Expand Up @@ -2633,10 +2715,23 @@ Example — add a signature image and a stamp, then screenshot to verify:
if (result.content.length === 1) {
allContent.push(result.content[0]);
} else {
const texts = result.content
.map((c) => (c.type === "text" ? c.text : null))
.filter((t) => t != null);
allContent.push({ type: "text", text: texts.join("\n") });
const resourcePart = result.content.find(
(c) => c.type === "resource",
);
if (resourcePart && commandList.length === 1) {
// Single command: no 1:1 slot contract — keep the summary
// text and the embedded resource (get_current_pdf) as-is.
allContent.push(...(result.content as ContentPart[]));
} else if (resourcePart) {
// Batch: preserve the embedded resource as the slot's value —
// the accompanying text block is only a summary.
allContent.push(resourcePart as ContentPart);
} else {
const texts = result.content
.map((c) => (c.type === "text" ? c.text : null))
.filter((t) => t != null);
allContent.push({ type: "text", text: texts.join("\n") });
}
}
}

Expand Down Expand Up @@ -2734,6 +2829,63 @@ Example — add a signature image and a stamp, then screenshot to verify:
},
);

// Tool: submit_current_pdf (app-only) - Viewer submits current PDF bytes
registerAppTool(
server,
"submit_current_pdf",
{
title: "Submit Current PDF",
description:
"Submit current PDF bytes for a get_current_pdf request (used by viewer). The model should NOT call this tool directly.",
inputSchema: z.object({
requestId: z
.string()
.describe("The request ID from the get_current_pdf command"),
data: z.string().optional().describe("Base64-encoded PDF bytes"),
fileName: z
.string()
.optional()
.describe("Suggested file name for the document"),
hasEdits: z
.boolean()
.optional()
.describe("Whether the bytes include in-viewer edits"),
error: z
.string()
.optional()
.describe("Error message if the viewer failed to build bytes"),
}),
_meta: { ui: { visibility: ["app"] } },
},
async ({
requestId,
data,
fileName,
hasEdits,
error,
}): Promise<CallToolResult> => {
const settle = pendingCurrentPdfRequests.get(requestId);
if (!settle) {
return {
content: [
{ type: "text", text: `No pending request for ${requestId}` },
],
isError: true,
};
}
if (error || !data) {
settle(new Error(error || "Viewer returned no data"));
} else {
settle({
data,
fileName: fileName || "document.pdf",
hasEdits: hasEdits ?? false,
});
}
return { content: [{ type: "text", text: "Submitted" }] };
},
);

// Tool: submit_viewer_state (app-only) - Viewer reports its live state
registerAppTool(
server,
Expand Down
1 change: 1 addition & 0 deletions examples/pdf-server/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,6 @@ export type PdfCommand =
getScreenshots: boolean;
}
| { type: "save_as"; requestId: string }
| { type: "get_current_pdf"; requestId: string }
| { type: "get_viewer_state"; requestId: string }
| { type: "file_changed"; mtimeMs: number };
Loading
Loading