Drive an agent document session over MCP
At a glance
Section titled “At a glance”This is one complete agent session against the NextPDF Connect Model
Context Protocol (MCP) server, message by message: initialize,
tools/list, six tools/call invocations that build a one-page project
brief, and the human-in-the-loop (HITL) round trip that gates the final
file write. Every JSON-RPC message below was captured verbatim from a
live bin/nextpdf-mcp process (core-tier tools only), then sanitized in
exactly two ways: the single-use confirmation token is shown as
confirm_<single-use-hex>, and the machine’s system temporary directory
is shortened to C:\Temp. Identifiers, schemas, positions, and byte
counts are exactly what the server sent.
Install
Section titled “Install”composer require nextpdf/serverBind the stdio transport in your MCP host — for Claude Desktop (hosts launch the command from their own directory, so use an absolute path; the stdio transport needs no API key, unlike the REST transport):
{ "mcpServers": { "nextpdf": { "command": "php", "args": ["/absolute/path/to/your/project/vendor/bin/nextpdf-mcp"] } }}The server speaks newline-delimited JSON-RPC 2.0 on stdin/stdout and keeps protocol output strictly separate from diagnostics: startup and audit lines go to stderr, never stdout.
Conceptual overview
Section titled “Conceptual overview”An MCP document session is stateful. create_pdf opens a document in the
server’s in-memory store and returns a document_id; every later call
targets that identifier. Content tools (set_font, add_text,
add_table) execute immediately at the Caution risk level with audit
logging; preview_layout is a Safe read; and output_pdf with a
file_path is Approval Required — it does not run on the first call.
Instead the server returns a challenge with a single-use token, the agent
relays the challenge to the human, and only a re-call carrying
_confirmation_token executes the write. Documents left in the store
expire after the configured time to live (30 minutes by default).
The same tool calls drive the tool engine over REST and gRPC — the transports share one executor — so everything here except the stdio framing carries over. See Render an invoice end to end over REST for the same engine on the HTTP surface.
API surface
Section titled “API surface”| Tool | Role in this session | Risk level |
|---|---|---|
create_pdf | Open the document, get document_id | Caution |
set_font | Select heading, then body face | Caution |
add_text | Title line, then intro paragraph | Caution |
add_table | Owner/due-date checklist table | Caution |
preview_layout | Read layout state before output | Safe |
output_pdf (file mode) | Write the PDF — gated | Approval Required |
The deployment captured here registered 20 tools (13 core, 6 Pro, 1
Enterprise — the counts appear in the initialize response below); this
session uses core tools only, so it runs unchanged on an open-source-only
install. The catalog of record is your own server’s tools/list reply,
and the risk ladder is defined in the
HITL risk tiers reference.
The session, message by message
Section titled “The session, message by message”1. Initialize the connection
Section titled “1. Initialize the connection”The client opens the session and states its protocol version:
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "planning-agent", "version": "1.0.0" } }}The server confirms the protocol version and declares its capabilities, including per-tier tool counts and that HITL gating is enabled:
{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-06-18", "capabilities": { "tools": { "listChanged": false }, "nextpdf": { "tiers": { "core": 13, "pro": 6, "enterprise": 1 }, "tool_count": 20, "risk_model_version": 1, "hitl_enabled": true } }, "serverInfo": { "name": "NextPDF Connect", "version": "1.0.0" } }}The client acknowledges with a notification (notifications carry no id
and receive no response):
{ "jsonrpc": "2.0", "method": "notifications/initialized"}2. Discover the tools
Section titled “2. Discover the tools”{ "jsonrpc": "2.0", "id": 2, "method": "tools/list"}The full reply lists all 20 registered tools with their complete input schemas. It is shown here shortened to the two tools that open and close this session — the elided 18 entries have the same shape:
{ "jsonrpc": "2.0", "id": 2, "result": { "tools": [ { "name": "create_pdf", "description": "Create a new PDF document and return a document_id for subsequent operations", "inputSchema": { "type": "object", "properties": { "page_size": { "type": "string", "description": "Page size name (e.g. \"A4\", \"Letter\", \"Legal\", \"A3\")", "default": "A4" }, "orientation": { "type": "string", "enum": [ "portrait", "landscape" ], "description": "Page orientation", "default": "portrait" }, "title": { "type": "string", "description": "Document title metadata" }, "author": { "type": "string", "description": "Document author metadata" } }, "required": [] }, "annotations": { "destructiveHint": false, "idempotentHint": false } }, { "name": "output_pdf", "description": "Finalize the PDF and output to file or return as base64", "inputSchema": { "type": "object", "properties": { "document_id": { "type": "string", "description": "The document_id returned by create_pdf" }, "file_path": { "type": "string", "description": "Absolute file path to save the PDF. If omitted, returns base64-encoded PDF data." }, "destroy": { "type": "boolean", "description": "Whether to remove the document from the store after output", "default": true } }, "required": [ "document_id" ] }, "annotations": { "destructiveHint": false, "openWorldHint": true } } ] }}Note output_pdf’s schema: file_path is optional, and the annotations
carry openWorldHint: true — the tool can touch the world outside the
session, which is exactly why file mode is gated.
3. Open the document
Section titled “3. Open the document”{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "create_pdf", "arguments": { "page_size": "A4", "orientation": "portrait", "title": "Project kickoff brief", "author": "Planning agent" } }}{ "jsonrpc": "2.0", "id": 3, "result": { "content": [ { "type": "text", "text": "{\"document_id\":\"doc_3b9f435efa0f32d1da7a131d\",\"page_count\":1,\"page_size\":\"A4\",\"orientation\":\"portrait\"}" } ], "structuredContent": { "document_id": "doc_3b9f435efa0f32d1da7a131d", "page_count": 1, "page_size": "A4", "orientation": "portrait" } }}Every tool result arrives twice in one message: a human-readable content
text block, and machine-readable structuredContent. Read
structuredContent.document_id and thread it through every following
call.
4. Add the heading
Section titled “4. Add the heading”Set a bold 16-point face, then place the title:
{ "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "set_font", "arguments": { "document_id": "doc_3b9f435efa0f32d1da7a131d", "family": "helvetica", "style": "B", "size": 16 } }}{ "jsonrpc": "2.0", "id": 4, "result": { "content": [ { "type": "text", "text": "Font set to helvetica B 16pt on document doc_3b9f435efa0f32d1da7a131d." } ] }}{ "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": { "name": "add_text", "arguments": { "document_id": "doc_3b9f435efa0f32d1da7a131d", "text": "Project kickoff brief" } }}{ "jsonrpc": "2.0", "id": 5, "result": { "content": [ { "type": "text", "text": "{\"document_id\":\"doc_3b9f435efa0f32d1da7a131d\",\"position\":{\"x\":10,\"y\":16,\"page\":0}}" } ], "structuredContent": { "document_id": "doc_3b9f435efa0f32d1da7a131d", "position": { "x": 10, "y": 16, "page": 0 } } }}5. Add the body paragraph
Section titled “5. Add the body paragraph”Back to a regular 11-point face for the intro text; width: 0 selects
full-width multi-cell layout:
{ "jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": { "name": "set_font", "arguments": { "document_id": "doc_3b9f435efa0f32d1da7a131d", "family": "helvetica", "style": "", "size": 11 } }}{ "jsonrpc": "2.0", "id": 6, "result": { "content": [ { "type": "text", "text": "Font set to helvetica 11pt on document doc_3b9f435efa0f32d1da7a131d." } ] }}{ "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": { "name": "add_text", "arguments": { "document_id": "doc_3b9f435efa0f32d1da7a131d", "text": "Prepared by the planning agent for the 14 July kickoff. Scope, owners, and the first-week checklist are tabled below.", "width": 0, "line_height": 6 } }}{ "jsonrpc": "2.0", "id": 7, "result": { "content": [ { "type": "text", "text": "{\"document_id\":\"doc_3b9f435efa0f32d1da7a131d\",\"position\":{\"x\":10,\"y\":29.75,\"page\":0}}" } ], "structuredContent": { "document_id": "doc_3b9f435efa0f32d1da7a131d", "position": { "x": 10, "y": 29.75, "page": 0 } } }}6. Add the checklist table
Section titled “6. Add the checklist table”{ "jsonrpc": "2.0", "id": 8, "method": "tools/call", "params": { "name": "add_table", "arguments": { "document_id": "doc_3b9f435efa0f32d1da7a131d", "html": "<table><tr><th>Work item</th><th>Owner</th><th>Due</th></tr><tr><td>Repository bootstrap</td><td>Devon</td><td>2026-07-15</td></tr><tr><td>CI pipeline</td><td>Ana</td><td>2026-07-17</td></tr><tr><td>Staging deploy</td><td>Priya</td><td>2026-07-21</td></tr></table>" } }}{ "jsonrpc": "2.0", "id": 8, "result": { "content": [ { "type": "text", "text": "{\"document_id\":\"doc_3b9f435efa0f32d1da7a131d\",\"position\":{\"x\":10,\"y\":84.75,\"page\":0}}" } ], "structuredContent": { "document_id": "doc_3b9f435efa0f32d1da7a131d", "position": { "x": 10, "y": 84.75, "page": 0 } } }}Each content call returns the updated cursor position, so the agent
always knows where the next element lands.
7. Preview before asking for approval
Section titled “7. Preview before asking for approval”preview_layout is a Safe, read-only call — a well-behaved agent checks
what it built before asking a human to approve a write:
{ "jsonrpc": "2.0", "id": 9, "method": "tools/call", "params": { "name": "preview_layout", "arguments": { "document_id": "doc_3b9f435efa0f32d1da7a131d" } }}{ "jsonrpc": "2.0", "id": 9, "result": { "content": [ { "type": "text", "text": "{\"document_id\":\"doc_3b9f435efa0f32d1da7a131d\",\"total_pages\":1,\"current_page\":0,\"page_dimensions\":{\"width\":595.276,\"height\":841.89},\"margins\":{\"top\":10,\"right\":10,\"bottom\":10,\"left\":10},\"cursor_position\":{\"x\":10,\"y\":84.75}}" } ], "structuredContent": { "document_id": "doc_3b9f435efa0f32d1da7a131d", "total_pages": 1, "current_page": 0, "page_dimensions": { "width": 595.276, "height": 841.89 }, "margins": { "top": 10, "right": 10, "bottom": 10, "left": 10 }, "cursor_position": { "x": 10, "y": 84.75 } } }}8. Request the file write — the gate answers first
Section titled “8. Request the file write — the gate answers first”The agent asks output_pdf to write the finished brief to disk, keeping
the document alive (destroy: false) in case the human rejects and it
needs to fall back to base64 output:
{ "jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": { "name": "output_pdf", "arguments": { "document_id": "doc_3b9f435efa0f32d1da7a131d", "file_path": "C:\\Temp\\nextpdf-mcp\\kickoff-brief.pdf", "destroy": false } }}The file is not written. Because file mode is Approval Required, the server answers with a confirmation challenge instead:
{ "jsonrpc": "2.0", "id": 10, "result": { "content": [ { "type": "text", "text": "⚠️ CONFIRMATION REQUIRED\n\nOperation: output_pdf\nDescription: Finalize the PDF and output to file or return as base64\n\nTo proceed, call output_pdf again with parameter _confirmation_token: \"confirm_<single-use-hex>\"\nExpires in 300 seconds." } ], "isError": false }}9. Human approves — re-call with the token
Section titled “9. Human approves — re-call with the token”The agent relays the challenge text to the human. On approval, it calls
output_pdf again with the same arguments plus _confirmation_token:
{ "jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": { "name": "output_pdf", "arguments": { "document_id": "doc_3b9f435efa0f32d1da7a131d", "file_path": "C:\\Temp\\nextpdf-mcp\\kickoff-brief.pdf", "destroy": false, "_confirmation_token": "confirm_<single-use-hex>" } }}The token is consumed, the write executes, and the result reports the written file:
{ "jsonrpc": "2.0", "id": 11, "result": { "content": [ { "type": "text", "text": "{\"document_id\":\"doc_3b9f435efa0f32d1da7a131d\",\"file_path\":\"C:\\\\Temp\\\\nextpdf-mcp\\\\kickoff-brief.pdf\",\"file_size\":3612,\"page_count\":1,\"destroyed\":false}" } ], "structuredContent": { "document_id": "doc_3b9f435efa0f32d1da7a131d", "file_path": "C:\\Temp\\nextpdf-mcp\\kickoff-brief.pdf", "file_size": 3612, "page_count": 1, "destroyed": false } }}Verify the written file
Section titled “Verify the written file”The session wrote kickoff-brief.pdf (3,612 bytes, one page, matching
structuredContent.file_size and page_count). Captured qpdf --check
output for that exact file:
checking kickoff-brief.pdfPDF Version: 2.0File is not encryptedFile is not linearizedNo syntax or stream encoding errors found; the file may still containerrors that qpdf cannot detectThat is a structural check, in qpdf’s own words — not a conformance determination.
Edge cases & gotchas
Section titled “Edge cases & gotchas”- The re-call must repeat the same arguments. The confirmation token
is bound to the tool name plus a canonical digest of the arguments it
was issued for. Re-calling with anything changed — even flipping
destroy— does not consume the token; the server answers with a fresh challenge instead. Repeat the arguments exactly and add only_confirmation_token. - The token is single-use and expires. The challenge states the expiry (300 seconds). After expiry or consumption, the next gated call gets a new challenge; relay the new one.
- File output lands inside an allow-listed directory. The server
rejects a
file_pathoutside its configured temporary directory withOutput path rejected by security policy. The default allow-list root isnextpdf-mcpunder the system temporary directory; operators change it with thetemp_dirsetting innextpdf-mcp.yaml. - base64 mode is not gated.
output_pdfwithoutfile_pathreturns the PDF as base64 at the Review level, with no filesystem side effect — see Require human approval for file output for that boundary in depth. - A challenge is a result, not an error. The challenge message
arrives with
isError: false; a pending approval is a workflow pause. Do not retry in a loop, and never fabricate a token. - Notifications get no reply. After
notifications/initialized, do not block waiting for a response line.
Performance
Section titled “Performance”The session is in-memory end to end: content calls returned in milliseconds on the captured run, and wall time is dominated by the human approval round trip, which is the point of the gate. The document store holds a session for 30 minutes of idle time by default (50 documents maximum), so a slow approval does not lose the built document — but an abandoned one is reclaimed.
Security notes
Section titled “Security notes”- Treat the confirmation token as a one-time secret. Relay the challenge text to the human; do not log the token or persist it. This page redacts the captured token for exactly that reason.
- The audit trail is on stderr. Every Caution-or-above execution is audit-logged (tool, risk, arguments, outcome) via PSR-3, with sensitive parameters redacted. Diagnostics never mix into the protocol stream.
- The path allow-list is the filesystem boundary. Point
temp_dirat a directory dedicated to Connect output; do not widen it to a general-purpose location. - Risk levels only ratchet up. An operator override in
nextpdf-mcp.yamlcan raise a tool’s risk level but can never loweroutput_pdfbelow Approval Required.
Conformance
Section titled “Conformance”This recipe makes no normative standards claim. It documents the MCP
stdio transport (JSON-RPC 2.0, protocol version 2025-06-18 as
negotiated in the captured initialize exchange) and the server’s risk
and confirmation contract. The qpdf --check step above confirms
structural integrity of the written file only; conformance to a standard
is determined by an independent validator, not asserted by the producing
software.
See also
Section titled “See also”- Require human approval for file output — the confirmation gate in depth, including the rejection path.
- Render an invoice end to end over REST — the same tool engine over HTTP, with the captured wire transcript.
- Generate your first PDF — the smallest Connect session.
- Connect recipe conventions — the contract every Connect recipe follows.
- HITL risk tiers — the canonical risk ladder and policy resolution.
- Tool catalog — the tool catalog of record.