Skip to content
getnextpdf.com

Drive an agent document session over MCP

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.

Terminal window
composer require nextpdf/server

Bind 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.

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.

ToolRole in this sessionRisk level
create_pdfOpen the document, get document_idCaution
set_fontSelect heading, then body faceCaution
add_textTitle line, then intro paragraphCaution
add_tableOwner/due-date checklist tableCaution
preview_layoutRead layout state before outputSafe
output_pdf (file mode)Write the PDF — gatedApproval 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 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"
}
{
"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.

{
"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.

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
}
}
}
}

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
}
}
}
}
{
"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.

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
}
}
}

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.pdf
PDF Version: 2.0
File is not encrypted
File is not linearized
No syntax or stream encoding errors found; the file may still contain
errors that qpdf cannot detect

That is a structural check, in qpdf’s own words — not a conformance determination.

  • 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_path outside its configured temporary directory with Output path rejected by security policy. The default allow-list root is nextpdf-mcp under the system temporary directory; operators change it with the temp_dir setting in nextpdf-mcp.yaml.
  • base64 mode is not gated. output_pdf without file_path returns 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.

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.

  • 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_dir at 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.yaml can raise a tool’s risk level but can never lower output_pdf below Approval Required.

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.