跳转到内容
getnextpdf.com

通过 MCP 驱动代理文档会话

这是一次针对 NextPDF Connect Model Context Protocol (MCP) 服务器的完整代理会话,逐条消息呈现:initializetools/list、六次构建一页项目简报的 tools/call 调用,以及为最终文件写入把关的人工介入(HITL)往返。下面每一条 JSON-RPC 消息都是从一个运行中的 bin/nextpdf-mcp 进程逐字捕获的(仅核心层工具),随后仅做了两处脱敏:一次性确认令牌显示为 confirm_<single-use-hex>,本机的系统临时目录被缩短为 C:\Temp。标识符、schema、位置和字节数都与服务器实际发送的完全一致。

Terminal window
composer require nextpdf/server

在你的 MCP 宿主中绑定 stdio 传输层——以 Claude Desktop 为例(宿主会从其自身目录启动该命令,因此请使用绝对路径;与 REST 传输层不同,stdio 传输层无需 API key):

{
"mcpServers": {
"nextpdf": {
"command": "php",
"args": ["/absolute/path/to/your/project/vendor/bin/nextpdf-mcp"]
}
}
}

服务器在 stdin/stdout 上使用以换行分隔的 JSON-RPC 2.0 通信,并将协议输出与诊断信息严格分离:启动和审计日志行发往 stderr,绝不发往 stdout。

MCP 文档会话是有状态的。create_pdf 会在服务器的内存存储中打开一个文档,并返回一个 document_id;此后的每次调用都以该标识符为目标。内容工具(set_fontadd_textadd_table)会以“谨慎”风险等级立即执行,并带有审计日志;preview_layout 是“安全”读取;而带有 file_pathoutput_pdf 属于“需要核准”——它不会在首次调用时执行。相反,服务器会返回一个带有一次性令牌的挑战码,代理将挑战码转交给人工,只有携带 _confirmation_token 的重新调用才会执行写入。留在存储中的文档会在配置的存活时间(默认 30 分钟)后过期。

同样的工具调用也通过 REST 和 gRPC 驱动工具引擎——这些传输层共享同一个执行器——因此除 stdio 分帧外,这里的一切都同样适用。要了解同一引擎在 HTTP 层面的表现,请参见 通过 REST 端到端渲染一张发票

工具在本会话中的作用风险等级
create_pdf打开文档,获取 document_id谨慎
set_font选择标题字体,然后是正文字体谨慎
add_text标题行,然后是引言段落谨慎
add_table负责人/截止日期清单表格谨慎
preview_layout在输出前读取布局状态安全
output_pdf(file 模式)写入 PDF——受闸门管控需要核准

这里捕获的部署共注册了 20 个工具(13 个 Core、6 个 Pro、1 个 Enterprise——数量见下方 initialize 响应);本会话仅使用核心工具,因此在纯开源安装环境下也能原样运行。正式记录的目录是你自己服务器的 tools/list 响应,风险阶梯则定义于 HITL 风险等级参考

客户端打开会话并声明其协议版本:

{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {
"name": "planning-agent",
"version": "1.0.0"
}
}
}

服务器确认协议版本并声明其能力,包括各层级的工具数量以及已启用 HITL 闸门:

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

客户端以一条通知进行确认(通知不携带 id,也不会收到响应):

{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
}

完整响应会列出全部 20 个已注册工具及其完整的输入 schema。这里将其精简为开启和收尾本会话的两个工具——省略的 18 个条目具有相同的结构:

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

注意 output_pdf 的 schema:file_path 是可选的,并且注解中带有 openWorldHint: true——该工具可以触及会话之外的世界,这正是 file 模式受闸门管控的原因。

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

每个工具结果都会在一条消息中出现两次:一个人类可读的 content 文本块,以及机器可读的 structuredContent。请读取 structuredContent.document_id,并将它贯穿到之后的每一次调用。

设置 16 磅粗体字体,然后放置标题:

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

为引言文本切回常规 11 磅字体;width: 0 会选择全宽多单元格(multi-cell)布局:

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

每次内容调用都会返回更新后的光标 position,因此代理始终知道下一个元素落在哪里。

preview_layout 是一个“安全”的只读调用——行为良好的代理会在请求人工核准写入之前,先检查自己构建出来的内容:

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

代理请求 output_pdf 将完成的简报写入磁盘,并保持文档存活(destroy: false),以防人工拒绝时需要回退到 base64 输出:

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

文件被写入。由于 file 模式属于“需要核准”,服务器转而返回一个确认挑战码:

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

代理将挑战码文本转交给人工。核准后,它会带着相同的参数外加 _confirmation_token 再次调用 output_pdf

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

令牌被消耗,写入得以执行,结果会报告已写入的文件:

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

本会话写入了 kickoff-brief.pdf(3,612 字节,一页,与 structuredContent.file_sizepage_count 相符)。针对该文件捕获的 qpdf --check 输出如下:

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

用 qpdf 自己的话说,这是一次结构检查——而非一致性判定。

  • **重新调用必须重复相同的参数。**确认令牌绑定于工具名称,加上其签发时所针对的那组参数的规范化摘要。若重新调用时改动了任何内容——哪怕只是翻转 destroy——都不会消耗该令牌;服务器会转而返回一个全新的挑战码。请原样重复参数,并只额外添加 _confirmation_token
  • **令牌是一次性的,并会过期。**挑战码会说明过期时间(300 秒)。过期或被消耗后,下一次受闸门管控的调用会获得一个新的挑战码;请转交这个新的挑战码。
  • **文件输出会落在一个白名单目录内。**对于位于其配置的临时目录之外的 file_path,服务器会以 Output path rejected by security policy 拒绝。默认的白名单根目录是系统临时目录下的 nextpdf-mcp;运维人员可通过 nextpdf-mcp.yaml 中的 temp_dir 设置来更改它。
  • **base64 模式不受闸门管控。**不带 file_pathoutput_pdf 会以“审查”等级将 PDF 作为 base64 返回,不产生任何文件系统副作用——关于该边界的深入说明,请参见 要求对文件输出进行人工审批
  • **挑战码是一个结果,而非错误。**挑战码消息带有 isError: false;待核准状态是一次工作流暂停。不要循环重试,也绝不要伪造令牌。
  • **通知不会得到回复。**在 notifications/initialized 之后,不要阻塞等待响应行。

本会话端到端都在内存中进行:在捕获的这次运行中,内容调用在毫秒级返回,而挂钟时间主要由人工核准的往返所主导——而这正是闸门的意义所在。文档存储默认会为每个会话保留 30 分钟的空闲存活时间(最多 50 个文档),因此缓慢的核准不会丢失已构建的文档——但被弃置的文档会被回收。

  • **将确认令牌视为一次性机密。**把挑战码文本转交给人工;不要记录该令牌,也不要将其持久化。本页正是出于这个原因才对捕获到的令牌做了脱敏处理。
  • **审计轨迹位于 stderr 上。**每一次“谨慎”及以上等级的执行都会通过 PSR-3 记入审计日志(工具、风险、参数、结果),并对敏感参数做脱敏处理。诊断信息绝不会混入协议流。
  • **路径白名单是文件系统边界。**请将 temp_dir 指向一个专用于 Connect 输出的目录;不要将其放宽到通用位置。
  • 风险等级只能向上调整。nextpdf-mcp.yaml 中的运维覆盖可以提升某个工具的风险等级,但绝不能将 output_pdf 降到“需要核准”以下。

本教程不作任何规范性标准声明。它记录的是 MCP stdio 传输层(JSON-RPC 2.0,协议版本 2025-06-18,即捕获的 initialize 交互中协商出的版本)以及服务器的风险与确认契约。上文的 qpdf --check 步骤仅确认写入文件的结构完整性;对某项标准的一致性由独立的验证器判定,而非由生产软件自行断言。