pdfops-mcp 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +28 -0
  2. package/dist/index.js +64 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -10,6 +10,8 @@ Beside a local agent, tools operate on file paths, so PDF bytes never transit th
10
10
 
11
11
  ```bash
12
12
  claude mcp add pdfops -- npx -y pdfops-mcp
13
+ # with a key:
14
+ claude mcp add pdfops -e PDFOPS_API_KEY=pdfops_live_… -- npx -y pdfops-mcp
13
15
  ```
14
16
 
15
17
  **Claude Desktop** (`claude_desktop_config.json`) / **Cursor** (`.cursor/mcp.json`)
@@ -26,6 +28,23 @@ claude mcp add pdfops -- npx -y pdfops-mcp
26
28
  }
27
29
  ```
28
30
 
31
+ **VS Code** (`.vscode/mcp.json`, note the `servers` key and `type`)
32
+
33
+ ```json
34
+ {
35
+ "servers": {
36
+ "pdfops": {
37
+ "type": "stdio",
38
+ "command": "npx",
39
+ "args": ["-y", "pdfops-mcp"],
40
+ "env": { "PDFOPS_API_KEY": "pdfops_live_…" }
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ Any other stdio client (Windsurf, Cline, Zed, your own agent): command `npx`, args `-y pdfops-mcp`. Per-client walkthrough and a real tool-call transcript: [pdfops.dev/mcp](https://pdfops.dev/mcp).
47
+
29
48
  `PDFOPS_API_KEY` is optional — without it you get the keyless trial (100 requests/IP/month). A free key (250/month, no card) takes one field at [pdfops.dev/pricing](https://pdfops.dev/pricing).
30
49
 
31
50
  ## Tools
@@ -47,6 +66,15 @@ A hosted MCP runtime executes this server on a machine where your agent's file p
47
66
 
48
67
  Locally, absolute paths keep working exactly as before and remain the recommended form — bytes stay off the model context.
49
68
 
69
+ ## Example prompts
70
+
71
+ - *"What fields does ~/forms/fw9.pdf have?"* → `pdf_inspect`
72
+ - *"Fill ~/forms/fw9.pdf for Ada Lovelace, 12 Analytical Way, London; save it flattened as ~/out/w9-ada.pdf"* → `pdf_inspect`, then `pdf_fill` with `flatten: true`
73
+ - *"Fill the onboarding form once per row of contractors.csv into ~/out/"* → one `pdf_inspect`, then `pdf_fill` per row
74
+ - *"Merge ~/out/w9-ada.pdf, ~/docs/nda.pdf and ~/docs/cover.pdf into one packet, cover first"* → `pdf_merge`
75
+ - *"Invoice Globex for 3 days of consulting at $650, 8.5% tax, due in 30 days"* → `pdf_invoice`
76
+ - *"How many PDFops requests do I have left this month?"* → `pdfops_usage`
77
+
50
78
  ## Example agent flow
51
79
 
52
80
  > "Fill the W-9 template at ~/docs/w9.pdf for Ada Lovelace and merge it with ~/docs/cover.pdf"
package/dist/index.js CHANGED
@@ -26,12 +26,25 @@ import { describeSource, pdfResult, resolveSource } from './source.js';
26
26
  // Version comes from package.json so the string MCP clients display can no
27
27
  // longer drift from the published one (0.2.0 shipped reporting 0.1.1).
28
28
  const { version } = createRequire(import.meta.url)('../package.json');
29
+ const server = new McpServer({ name: 'pdfops', version });
30
+ // Source attribution: every API call carries this server's version and the
31
+ // MCP host app ("claude-ai/0.1.0", "cursor-vscode/1.0.0", …) taken from the
32
+ // initialize handshake's clientInfo. Added via a fetch wrapper rather than
33
+ // SDK options because clientInfo only exists after connect, and so this works
34
+ // against the already-published pdfops-sdk 0.4.0.
29
35
  const client = new PdfOps({
30
36
  apiKey: process.env.PDFOPS_API_KEY,
31
37
  baseUrl: process.env.PDFOPS_BASE_URL,
32
38
  clientTag: 'mcp',
39
+ fetch: (input, init) => {
40
+ const headers = new Headers(init?.headers);
41
+ headers.set('X-Pdfops-Client-Version', version);
42
+ const host = server.server.getClientVersion();
43
+ if (host)
44
+ headers.set('X-Pdfops-Client-Host', `${host.name}/${host.version}`);
45
+ return fetch(input, { ...init, headers });
46
+ },
33
47
  });
34
- const server = new McpServer({ name: 'pdfops', version });
35
48
  const SOURCE_DOC = 'PDF source: an absolute file path, an https:// URL, or a data:application/pdf;base64,… URI. Use a URL or data URI when this server runs remotely (Smithery, hosted gateways) where local paths do not exist.';
36
49
  const OUTPUT_DOC = 'Absolute path to write the result. Omit when running remotely: the PDF is then returned inline as an application/pdf resource for the client to save.';
37
50
  const errText = (e) => e instanceof PdfOpsError
@@ -43,20 +56,51 @@ const errText = (e) => e instanceof PdfOpsError
43
56
  ? e.message
44
57
  : String(e);
45
58
  const fail = (e) => ({ content: [{ type: 'text', text: errText(e) }], isError: true });
59
+ // Declared as outputSchema on every PDF-emitting tool so an agent knows what it
60
+ // gets back without calling first. Both delivery modes are represented: a path
61
+ // when output_path was given, a pdfops:// resource uri when it was not.
62
+ const PDF_OUTPUT = {
63
+ bytes: z.number().int().describe('Size of the produced PDF in bytes'),
64
+ output_path: z.string().optional().describe('Absolute path written, when output_path was supplied'),
65
+ resource_uri: z.string().optional().describe('pdfops:// uri of the inline application/pdf resource, when output_path was omitted'),
66
+ };
46
67
  const emit = async (bytes, name, summary, output_path) => {
47
68
  if (output_path)
48
69
  await writeFile(output_path, bytes);
49
- return pdfResult(bytes, name, summary, output_path);
70
+ const base = pdfResult(bytes, name, summary, output_path);
71
+ return {
72
+ ...base,
73
+ structuredContent: output_path
74
+ ? { bytes: bytes.byteLength, output_path }
75
+ : { bytes: bytes.byteLength, resource_uri: `pdfops://${name}` },
76
+ };
50
77
  };
51
78
  server.registerTool('pdf_inspect', {
52
79
  title: 'Inspect PDF form fields',
53
- description: 'List a PDF\'s AcroForm form fields — names, types, options, current values, per-field maxLength where declared — plus a paste-ready fillTemplate object for pdf_fill and a hasXFA flag (hybrid AcroForm/XFA inputs lose their XFA layer when filled). A PDF with no form returns count 0. Call this FIRST when filling an unfamiliar PDF: you cannot fill fields whose names you do not know, and values longer than a field\'s maxLength are rejected.',
80
+ description: 'List a PDF\'s AcroForm form fields — names, types, options, current values, per-field maxLength where declared — plus a paste-ready fillTemplate object for pdf_fill and a hasXFA flag (hybrid AcroForm/XFA inputs lose their XFA layer when filled). A PDF with no fillable form returns count 0. Read-only: the PDF is fetched or read but never modified. Call this FIRST when filling an unfamiliar PDF, since pdf_fill rejects unknown field names and over-length values.',
54
81
  inputSchema: { pdf_path: z.string().describe(SOURCE_DOC) },
82
+ outputSchema: {
83
+ count: z.number().int().describe('Number of form fields found; 0 when the PDF has no fillable form'),
84
+ hasXFA: z.boolean().describe('True for hybrid AcroForm/XFA documents, whose XFA layer is dropped when filled'),
85
+ fields: z
86
+ .array(z.object({
87
+ name: z.string(),
88
+ type: z.string().describe('text, checkbox, radio, optionlist, or unsupported'),
89
+ readOnly: z.boolean(),
90
+ value: z.string().optional(),
91
+ options: z.array(z.string()).optional().describe('Permitted values for radio/dropdown/optionlist fields'),
92
+ checked: z.boolean().optional(),
93
+ maxLength: z.number().int().optional().describe('Longer values are rejected by pdf_fill'),
94
+ raw: z.unknown().optional(),
95
+ }))
96
+ .describe('One entry per form field, in document order'),
97
+ fillTemplate: z.record(z.string()).describe('Field name to empty-or-current value, ready to edit and pass to pdf_fill'),
98
+ },
55
99
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
56
100
  }, async ({ pdf_path }) => {
57
101
  try {
58
102
  const result = await client.inspect(await resolveSource(pdf_path));
59
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
103
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: { ...result } };
60
104
  }
61
105
  catch (e) {
62
106
  return fail(e);
@@ -64,7 +108,7 @@ server.registerTool('pdf_inspect', {
64
108
  });
65
109
  server.registerTool('pdf_fill', {
66
110
  title: 'Fill PDF form',
67
- description: 'Fill AcroForm form fields in a PDF and save or return the result. Field names must exist in the PDF (use pdf_inspect first). All values are strings; checkboxes take "true"/"false"; dropdown/radio/optionlist values must be one of the field\'s options; text values must respect the field\'s maxLength from pdf_inspect. Encrypted PDFs are rejected with decrypt advice (common for government blanks with an empty user password).',
111
+ description: 'Fill AcroForm form fields in a PDF and save or return the result. Field names must exist in the PDF (use pdf_inspect first). All values are strings; checkboxes take "true"/"false"; dropdown/radio/optionlist values must be one of the field\'s options; text values must respect the field\'s maxLength from pdf_inspect. The source PDF is never modified. Returns the filled PDF written to output_path, or inline as an application/pdf resource when output_path is omitted; an existing file at output_path is overwritten. Encrypted PDFs are rejected with decrypt advice (common for government blanks with an empty user password), and a rejected fill writes no file at all.',
68
112
  inputSchema: {
69
113
  pdf_path: z.string().describe(`Template ${SOURCE_DOC}`),
70
114
  fields: z
@@ -76,6 +120,7 @@ server.registerTool('pdf_fill', {
76
120
  .optional()
77
121
  .describe('Bake values into page content and drop the AcroForm so fields are no longer editable'),
78
122
  },
123
+ outputSchema: PDF_OUTPUT,
79
124
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
80
125
  }, async ({ pdf_path, fields, output_path, flatten }) => {
81
126
  try {
@@ -88,11 +133,12 @@ server.registerTool('pdf_fill', {
88
133
  });
89
134
  server.registerTool('pdf_merge', {
90
135
  title: 'Merge PDFs',
91
- description: 'Merge two or more PDFs into one, in the order given, and save or return the result.',
136
+ description: 'Merge two or more PDFs into one, in the order given, and save or return the result. Source files are read only and never modified; an existing file at output_path is overwritten. Returns the merged PDF written to output_path, or inline as an application/pdf resource when output_path is omitted. This tool only concatenates whole documents: it does not reorder, rotate or delete pages within them, and it does not fill forms — use pdf_fill for a fillable form and pdf_invoice to build a document from data. An unreadable or rejected input fails with an error and writes no file, so a partial merge is never left behind.',
92
137
  inputSchema: {
93
138
  pdf_paths: z.array(z.string()).min(2).describe(`In order, each a ${SOURCE_DOC}`),
94
139
  output_path: z.string().optional().describe(OUTPUT_DOC),
95
140
  },
141
+ outputSchema: PDF_OUTPUT,
96
142
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
97
143
  }, async ({ pdf_paths, output_path }) => {
98
144
  try {
@@ -106,7 +152,7 @@ server.registerTool('pdf_merge', {
106
152
  });
107
153
  server.registerTool('pdf_invoice', {
108
154
  title: 'Generate invoice PDF',
109
- description: 'Generate a complete, professionally laid-out invoice PDF from structured data — no template needed. Deterministic: the same input produces byte-identical output (safe to re-run). Note: without a paid PDFops key the output carries a small "Generated with pdfops.dev" footer line.',
155
+ description: 'Generate a complete, professionally laid-out invoice PDF from structured data — no template needed. Deterministic: the same input produces byte-identical output (safe to re-run). Note: without a paid PDFops key the output carries a small "Generated with pdfops.dev" footer line. Returns the invoice written to output_path, or inline as an application/pdf resource when output_path is omitted; an existing file at output_path is overwritten. Use this when you have invoice DATA and no document; if you already have an invoice PDF or a fillable template to populate, use pdf_fill instead.',
110
156
  inputSchema: {
111
157
  invoice: z
112
158
  .object({
@@ -139,6 +185,7 @@ server.registerTool('pdf_invoice', {
139
185
  .describe('Invoice data'),
140
186
  output_path: z.string().optional().describe(OUTPUT_DOC),
141
187
  },
188
+ outputSchema: PDF_OUTPUT,
142
189
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
143
190
  }, async ({ invoice, output_path }) => {
144
191
  try {
@@ -152,13 +199,21 @@ server.registerTool('pdf_invoice', {
152
199
  });
153
200
  server.registerTool('pdfops_usage', {
154
201
  title: 'Check PDFops quota',
155
- description: 'Check the current PDFops API quota for the configured key: tier, limit, used, remaining, reset date. Requires PDFOPS_API_KEY.',
202
+ description: 'Check the current PDFops API quota for the configured key: tier, limit, used, remaining, the billing period, and the reset timestamp. Read-only and safe to call before a batch to confirm there is headroom. Requires PDFOPS_API_KEY.',
156
203
  inputSchema: {},
204
+ outputSchema: {
205
+ tier: z.string().describe('Plan name, e.g. free'),
206
+ limit: z.number().int().describe('Calls allowed in the current period'),
207
+ used: z.number().int(),
208
+ remaining: z.number().int(),
209
+ period: z.string().describe('Billing period the counts belong to, YYYY-MM'),
210
+ resets_at: z.string().describe('ISO 8601 timestamp when used resets to 0'),
211
+ },
157
212
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
158
213
  }, async () => {
159
214
  try {
160
215
  const usage = await client.usage();
161
- return { content: [{ type: 'text', text: JSON.stringify(usage, null, 2) }] };
216
+ return { content: [{ type: 'text', text: JSON.stringify(usage, null, 2) }], structuredContent: { ...usage } };
162
217
  }
163
218
  catch (e) {
164
219
  return fail(e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pdfops-mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "mcpName": "dev.pdfops/pdfops-mcp",
5
5
  "description": "MCP server for the PDFops API — give AI agents deterministic PDF tools: inspect AcroForm fields, fill forms, merge PDFs, and generate invoices. Works with Claude Code, Claude Desktop, Cursor, and any MCP client.",
6
6
  "keywords": [