pdfops-mcp 0.3.0 → 0.3.2

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 +4 -0
  2. package/dist/index.js +118 -49
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -59,4 +59,8 @@ Locally, absolute paths keep working exactly as before and remain the recommende
59
59
 
60
60
  API docs: [pdfops.dev/docs](https://pdfops.dev/docs) · OpenAPI: [pdfops.dev/openapi.json](https://pdfops.dev/openapi.json) · Typed client: [`pdfops-sdk`](https://www.npmjs.com/package/pdfops-sdk) · Questions: hello@pdfops.dev
61
61
 
62
+ ## Privacy Policy
63
+
64
+ This server runs on your machine and sends only what a tool call needs to the PDFops API (`https://pdfops.dev`): the PDF bytes you point it at, the field values you supply, and your API key if you set one. PDFops processes the request in memory and returns the result; it does not store your documents. Anonymous usage is metered per IP and per client tag (`mcp`) for quota and attribution only. Nothing is shared with third parties. The full policy, including retention and contact details, is at <https://pdfops.dev/privacy>. Questions: hello@pdfops.dev.
65
+
62
66
  MIT © PDFops
package/dist/index.js CHANGED
@@ -17,6 +17,8 @@
17
17
  import { writeFile } from 'node:fs/promises';
18
18
  import { createRequire } from 'node:module';
19
19
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
20
+ // Tool annotations (title + readOnlyHint/destructiveHint) are mandatory for the
21
+ // Claude Connectors Directory and help every client show what a tool does.
20
22
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
21
23
  import { z } from 'zod';
22
24
  import { PdfOps, PdfOpsError } from 'pdfops-sdk';
@@ -41,30 +43,72 @@ const errText = (e) => e instanceof PdfOpsError
41
43
  ? e.message
42
44
  : String(e);
43
45
  const fail = (e) => ({ content: [{ type: 'text', text: errText(e) }], isError: true });
46
+ // Declared as outputSchema on every PDF-emitting tool so an agent knows what it
47
+ // gets back without calling first. Both delivery modes are represented: a path
48
+ // when output_path was given, a pdfops:// resource uri when it was not.
49
+ const PDF_OUTPUT = {
50
+ bytes: z.number().int().describe('Size of the produced PDF in bytes'),
51
+ output_path: z.string().optional().describe('Absolute path written, when output_path was supplied'),
52
+ resource_uri: z.string().optional().describe('pdfops:// uri of the inline application/pdf resource, when output_path was omitted'),
53
+ };
44
54
  const emit = async (bytes, name, summary, output_path) => {
45
55
  if (output_path)
46
56
  await writeFile(output_path, bytes);
47
- return pdfResult(bytes, name, summary, output_path);
57
+ const base = pdfResult(bytes, name, summary, output_path);
58
+ return {
59
+ ...base,
60
+ structuredContent: output_path
61
+ ? { bytes: bytes.byteLength, output_path }
62
+ : { bytes: bytes.byteLength, resource_uri: `pdfops://${name}` },
63
+ };
48
64
  };
49
- server.tool('pdf_inspect', '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.', { pdf_path: z.string().describe(SOURCE_DOC) }, async ({ pdf_path }) => {
65
+ server.registerTool('pdf_inspect', {
66
+ title: 'Inspect PDF form fields',
67
+ 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.',
68
+ inputSchema: { pdf_path: z.string().describe(SOURCE_DOC) },
69
+ outputSchema: {
70
+ count: z.number().int().describe('Number of form fields found; 0 when the PDF has no fillable form'),
71
+ hasXFA: z.boolean().describe('True for hybrid AcroForm/XFA documents, whose XFA layer is dropped when filled'),
72
+ fields: z
73
+ .array(z.object({
74
+ name: z.string(),
75
+ type: z.string().describe('text, checkbox, radio, optionlist, or unsupported'),
76
+ readOnly: z.boolean(),
77
+ value: z.string().optional(),
78
+ options: z.array(z.string()).optional().describe('Permitted values for radio/dropdown/optionlist fields'),
79
+ checked: z.boolean().optional(),
80
+ maxLength: z.number().int().optional().describe('Longer values are rejected by pdf_fill'),
81
+ raw: z.unknown().optional(),
82
+ }))
83
+ .describe('One entry per form field, in document order'),
84
+ fillTemplate: z.record(z.string()).describe('Field name to empty-or-current value, ready to edit and pass to pdf_fill'),
85
+ },
86
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
87
+ }, async ({ pdf_path }) => {
50
88
  try {
51
89
  const result = await client.inspect(await resolveSource(pdf_path));
52
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
90
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: { ...result } };
53
91
  }
54
92
  catch (e) {
55
93
  return fail(e);
56
94
  }
57
95
  });
58
- server.tool('pdf_fill', '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).', {
59
- pdf_path: z.string().describe(`Template ${SOURCE_DOC}`),
60
- fields: z
61
- .record(z.string())
62
- .describe('Field name → string value (from pdf_inspect\'s fillTemplate)'),
63
- output_path: z.string().optional().describe(OUTPUT_DOC),
64
- flatten: z
65
- .boolean()
66
- .optional()
67
- .describe('Bake values into page content and drop the AcroForm so fields are no longer editable'),
96
+ server.registerTool('pdf_fill', {
97
+ title: 'Fill PDF form',
98
+ 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.',
99
+ inputSchema: {
100
+ pdf_path: z.string().describe(`Template ${SOURCE_DOC}`),
101
+ fields: z
102
+ .record(z.string())
103
+ .describe('Field name → string value (from pdf_inspect\'s fillTemplate)'),
104
+ output_path: z.string().optional().describe(OUTPUT_DOC),
105
+ flatten: z
106
+ .boolean()
107
+ .optional()
108
+ .describe('Bake values into page content and drop the AcroForm so fields are no longer editable'),
109
+ },
110
+ outputSchema: PDF_OUTPUT,
111
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
68
112
  }, async ({ pdf_path, fields, output_path, flatten }) => {
69
113
  try {
70
114
  const bytes = await client.fillForm(await resolveSource(pdf_path), fields, { flatten });
@@ -74,9 +118,15 @@ server.tool('pdf_fill', 'Fill AcroForm form fields in a PDF and save or return t
74
118
  return fail(e);
75
119
  }
76
120
  });
77
- server.tool('pdf_merge', 'Merge two or more PDFs into one, in the order given, and save or return the result.', {
78
- pdf_paths: z.array(z.string()).min(2).describe(`In order, each a ${SOURCE_DOC}`),
79
- output_path: z.string().optional().describe(OUTPUT_DOC),
121
+ server.registerTool('pdf_merge', {
122
+ title: 'Merge PDFs',
123
+ 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.',
124
+ inputSchema: {
125
+ pdf_paths: z.array(z.string()).min(2).describe(`In order, each a ${SOURCE_DOC}`),
126
+ output_path: z.string().optional().describe(OUTPUT_DOC),
127
+ },
128
+ outputSchema: PDF_OUTPUT,
129
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
80
130
  }, async ({ pdf_paths, output_path }) => {
81
131
  try {
82
132
  const inputs = await Promise.all(pdf_paths.map((p) => resolveSource(p)));
@@ -87,37 +137,43 @@ server.tool('pdf_merge', 'Merge two or more PDFs into one, in the order given, a
87
137
  return fail(e);
88
138
  }
89
139
  });
90
- server.tool('pdf_invoice', '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.', {
91
- invoice: z
92
- .object({
93
- from: z.union([
94
- z.string(),
95
- z.object({ name: z.string(), lines: z.array(z.string()).optional() }),
96
- ]),
97
- to: z.union([
98
- z.string(),
99
- z.object({ name: z.string(), lines: z.array(z.string()).optional() }),
100
- ]),
101
- items: z
102
- .array(z.object({
103
- description: z.string(),
104
- quantity: z.number().positive().optional(),
105
- unit_price: z.number().nonnegative(),
106
- }))
107
- .min(1)
108
- .max(100),
109
- invoice_number: z.string().optional(),
110
- date: z
111
- .string()
112
- .optional()
113
- .describe('Shown on the invoice; also pins metadata for determinism'),
114
- due: z.string().optional(),
115
- currency: z.string().regex(/^[A-Z]{3}$/).optional(),
116
- tax_rate: z.number().min(0).max(100).optional(),
117
- notes: z.string().max(1000).optional(),
118
- })
119
- .describe('Invoice data'),
120
- output_path: z.string().optional().describe(OUTPUT_DOC),
140
+ server.registerTool('pdf_invoice', {
141
+ title: 'Generate invoice PDF',
142
+ 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.',
143
+ inputSchema: {
144
+ invoice: z
145
+ .object({
146
+ from: z.union([
147
+ z.string(),
148
+ z.object({ name: z.string(), lines: z.array(z.string()).optional() }),
149
+ ]),
150
+ to: z.union([
151
+ z.string(),
152
+ z.object({ name: z.string(), lines: z.array(z.string()).optional() }),
153
+ ]),
154
+ items: z
155
+ .array(z.object({
156
+ description: z.string(),
157
+ quantity: z.number().positive().optional(),
158
+ unit_price: z.number().nonnegative(),
159
+ }))
160
+ .min(1)
161
+ .max(100),
162
+ invoice_number: z.string().optional(),
163
+ date: z
164
+ .string()
165
+ .optional()
166
+ .describe('Shown on the invoice; also pins metadata for determinism'),
167
+ due: z.string().optional(),
168
+ currency: z.string().regex(/^[A-Z]{3}$/).optional(),
169
+ tax_rate: z.number().min(0).max(100).optional(),
170
+ notes: z.string().max(1000).optional(),
171
+ })
172
+ .describe('Invoice data'),
173
+ output_path: z.string().optional().describe(OUTPUT_DOC),
174
+ },
175
+ outputSchema: PDF_OUTPUT,
176
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
121
177
  }, async ({ invoice, output_path }) => {
122
178
  try {
123
179
  const bytes = await client.invoice(invoice);
@@ -128,10 +184,23 @@ server.tool('pdf_invoice', 'Generate a complete, professionally laid-out invoice
128
184
  return fail(e);
129
185
  }
130
186
  });
131
- server.tool('pdfops_usage', 'Check the current PDFops API quota for the configured key: tier, limit, used, remaining, reset date. Requires PDFOPS_API_KEY.', {}, async () => {
187
+ server.registerTool('pdfops_usage', {
188
+ title: 'Check PDFops quota',
189
+ 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.',
190
+ inputSchema: {},
191
+ outputSchema: {
192
+ tier: z.string().describe('Plan name, e.g. free'),
193
+ limit: z.number().int().describe('Calls allowed in the current period'),
194
+ used: z.number().int(),
195
+ remaining: z.number().int(),
196
+ period: z.string().describe('Billing period the counts belong to, YYYY-MM'),
197
+ resets_at: z.string().describe('ISO 8601 timestamp when used resets to 0'),
198
+ },
199
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
200
+ }, async () => {
132
201
  try {
133
202
  const usage = await client.usage();
134
- return { content: [{ type: 'text', text: JSON.stringify(usage, null, 2) }] };
203
+ return { content: [{ type: 'text', text: JSON.stringify(usage, null, 2) }], structuredContent: { ...usage } };
135
204
  }
136
205
  catch (e) {
137
206
  return fail(e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pdfops-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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": [