pdfops-mcp 0.2.1 → 0.3.1

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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  MCP server that gives AI agents deterministic PDF tools, backed by the [PDFops API](https://pdfops.dev): **inspect** AcroForm fields, **fill** forms, **merge** PDFs, and **generate invoices** — no Chromium, no native deps, nothing to host.
4
4
 
5
- Tools operate on local file paths, so PDF bytes never transit the model context: your agent says *"fill /tmp/form.pdf and save to /tmp/out.pdf"* and gets a one-line confirmation back.
5
+ Beside a local agent, tools operate on file paths, so PDF bytes never transit the model context: your agent says *"fill /tmp/form.pdf and save to /tmp/out.pdf"* and gets a one-line confirmation back. Every PDF input also accepts an `https://` URL or a `data:application/pdf;base64,…` URI, and every output can be returned inline instead of written — which is what makes the server work on hosted runtimes (see below).
6
6
 
7
7
  ## Install
8
8
 
@@ -38,6 +38,15 @@ claude mcp add pdfops -- npx -y pdfops-mcp
38
38
  | `pdf_invoice` | Structured data → complete invoice PDF. Deterministic: same input, byte-identical output. |
39
39
  | `pdfops_usage` | Quota check for the configured key. |
40
40
 
41
+ ## Running remotely (Smithery, Glama hosted, cloud IDE gateways)
42
+
43
+ A hosted MCP runtime executes this server on a machine where your agent's file paths do not exist. Nothing changes in the config — pass sources the server can reach and skip `output_path`:
44
+
45
+ - **Inputs** (`pdf_path`, `pdf_paths`): an `https://` URL the server can fetch (≤50 MB), or a `data:application/pdf;base64,…` URI for small files.
46
+ - **Outputs**: omit `output_path` and `pdf_fill` / `pdf_merge` / `pdf_invoice` return the PDF inline as an embedded `application/pdf` resource (`pdfops://filled.pdf`, …) that the client saves. With `output_path` set, the file is written where the *server* runs.
47
+
48
+ Locally, absolute paths keep working exactly as before and remain the recommended form — bytes stay off the model context.
49
+
41
50
  ## Example agent flow
42
51
 
43
52
  > "Fill the W-9 template at ~/docs/w9.pdf for Ada Lovelace and merge it with ~/docs/cover.pdf"
@@ -50,4 +59,8 @@ claude mcp add pdfops -- npx -y pdfops-mcp
50
59
 
51
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
52
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
+
53
66
  MIT © PDFops
package/dist/index.js CHANGED
@@ -1,141 +1,167 @@
1
1
  #!/usr/bin/env node
2
2
  // pdfops-mcp — MCP server exposing the PDFops API as agent tools.
3
3
  //
4
- // Design: tools take/return FILE PATHS, not base64 blobs. This server
5
- // runs locally (npx pdfops-mcp) beside the agent, so the filesystem is
6
- // the natural interface — an agent says "fill /tmp/form.pdf and save
7
- // to /tmp/out.pdf" and the PDF bytes never transit the model context.
4
+ // Design: tools take PDF *sources* and return files or inline PDFs.
5
+ // Beside a local agent (npx pdfops-mcp) the filesystem is the natural
6
+ // interface — "fill /tmp/form.pdf and save to /tmp/out.pdf" — and the PDF
7
+ // bytes never transit the model context. On a hosted runtime (Smithery,
8
+ // Glama hosted, cloud IDE gateways) the agent's paths do not exist on this
9
+ // machine, so every source also accepts an https:// URL or a
10
+ // data:application/pdf;base64 URI, and omitting output_path returns the
11
+ // result inline as an application/pdf resource. See src/source.ts.
8
12
  //
9
13
  // Env:
10
14
  // PDFOPS_API_KEY optional — free key from https://pdfops.dev/pricing
11
15
  // (250 req/mo; keyless works at 100 req/IP/mo)
12
16
  // PDFOPS_BASE_URL optional — API origin override (testing)
13
- import { readFile, writeFile } from 'node:fs/promises';
17
+ import { writeFile } from 'node:fs/promises';
18
+ import { createRequire } from 'node:module';
14
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.
15
22
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
16
23
  import { z } from 'zod';
17
24
  import { PdfOps, PdfOpsError } from 'pdfops-sdk';
25
+ import { describeSource, pdfResult, resolveSource } from './source.js';
26
+ // Version comes from package.json so the string MCP clients display can no
27
+ // longer drift from the published one (0.2.0 shipped reporting 0.1.1).
28
+ const { version } = createRequire(import.meta.url)('../package.json');
18
29
  const client = new PdfOps({
19
30
  apiKey: process.env.PDFOPS_API_KEY,
20
31
  baseUrl: process.env.PDFOPS_BASE_URL,
21
32
  clientTag: 'mcp',
22
33
  });
23
- const server = new McpServer({
24
- name: 'pdfops',
25
- version: '0.2.1', // keep in sync with package.json
26
- });
34
+ const server = new McpServer({ name: 'pdfops', version });
35
+ 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
+ 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.';
27
37
  const errText = (e) => e instanceof PdfOpsError
28
38
  ? `PDFops API error ${e.status} (${e.code}): ${e.message}` +
29
39
  (e.code === 'rate_limited'
30
40
  ? ' — get a free API key (250/mo) at https://pdfops.dev/pricing and set PDFOPS_API_KEY'
31
41
  : '')
32
- : String(e);
33
- 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('Absolute path to the PDF to inspect') }, async ({ pdf_path }) => {
42
+ : e instanceof Error
43
+ ? e.message
44
+ : String(e);
45
+ const fail = (e) => ({ content: [{ type: 'text', text: errText(e) }], isError: true });
46
+ const emit = async (bytes, name, summary, output_path) => {
47
+ if (output_path)
48
+ await writeFile(output_path, bytes);
49
+ return pdfResult(bytes, name, summary, output_path);
50
+ };
51
+ server.registerTool('pdf_inspect', {
52
+ 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.',
54
+ inputSchema: { pdf_path: z.string().describe(SOURCE_DOC) },
55
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
56
+ }, async ({ pdf_path }) => {
34
57
  try {
35
- const result = await client.inspect(await readFile(pdf_path));
58
+ const result = await client.inspect(await resolveSource(pdf_path));
36
59
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
37
60
  }
38
61
  catch (e) {
39
- return { content: [{ type: 'text', text: errText(e) }], isError: true };
62
+ return fail(e);
40
63
  }
41
64
  });
42
- server.tool('pdf_fill', 'Fill AcroForm form fields in a PDF and save 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).', {
43
- pdf_path: z.string().describe('Absolute path to the template PDF'),
44
- fields: z
45
- .record(z.string())
46
- .describe('Field name → string value (from pdf_inspect\'s fillTemplate)'),
47
- output_path: z.string().describe('Absolute path to write the filled PDF'),
48
- flatten: z
49
- .boolean()
50
- .optional()
51
- .describe('Bake values into page content and drop the AcroForm so fields are no longer editable'),
65
+ server.registerTool('pdf_fill', {
66
+ 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).',
68
+ inputSchema: {
69
+ pdf_path: z.string().describe(`Template ${SOURCE_DOC}`),
70
+ fields: z
71
+ .record(z.string())
72
+ .describe('Field name → string value (from pdf_inspect\'s fillTemplate)'),
73
+ output_path: z.string().optional().describe(OUTPUT_DOC),
74
+ flatten: z
75
+ .boolean()
76
+ .optional()
77
+ .describe('Bake values into page content and drop the AcroForm so fields are no longer editable'),
78
+ },
79
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
52
80
  }, async ({ pdf_path, fields, output_path, flatten }) => {
53
81
  try {
54
- const bytes = await client.fillForm(await readFile(pdf_path), fields, { flatten });
55
- await writeFile(output_path, bytes);
56
- return {
57
- content: [
58
- { type: 'text', text: `Filled PDF written to ${output_path} (${bytes.byteLength} bytes)` },
59
- ],
60
- };
82
+ const bytes = await client.fillForm(await resolveSource(pdf_path), fields, { flatten });
83
+ return await emit(bytes, 'filled.pdf', `Filled ${describeSource(pdf_path)}`, output_path);
61
84
  }
62
85
  catch (e) {
63
- return { content: [{ type: 'text', text: errText(e) }], isError: true };
86
+ return fail(e);
64
87
  }
65
88
  });
66
- server.tool('pdf_merge', 'Merge two or more PDFs into one, in the order given, and save the result.', {
67
- pdf_paths: z
68
- .array(z.string())
69
- .min(2)
70
- .describe('Absolute paths of the PDFs to merge, in order'),
71
- output_path: z.string().describe('Absolute path to write the merged PDF'),
89
+ server.registerTool('pdf_merge', {
90
+ title: 'Merge PDFs',
91
+ description: 'Merge two or more PDFs into one, in the order given, and save or return the result.',
92
+ inputSchema: {
93
+ pdf_paths: z.array(z.string()).min(2).describe(`In order, each a ${SOURCE_DOC}`),
94
+ output_path: z.string().optional().describe(OUTPUT_DOC),
95
+ },
96
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
72
97
  }, async ({ pdf_paths, output_path }) => {
73
98
  try {
74
- const inputs = await Promise.all(pdf_paths.map((p) => readFile(p)));
99
+ const inputs = await Promise.all(pdf_paths.map((p) => resolveSource(p)));
75
100
  const bytes = await client.merge(inputs);
76
- await writeFile(output_path, bytes);
77
- return {
78
- content: [
79
- { type: 'text', text: `Merged ${pdf_paths.length} PDFs into ${output_path} (${bytes.byteLength} bytes)` },
80
- ],
81
- };
101
+ return await emit(bytes, 'merged.pdf', `Merged ${pdf_paths.length} PDFs`, output_path);
82
102
  }
83
103
  catch (e) {
84
- return { content: [{ type: 'text', text: errText(e) }], isError: true };
104
+ return fail(e);
85
105
  }
86
106
  });
87
- 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.', {
88
- invoice: z
89
- .object({
90
- from: z.union([
91
- z.string(),
92
- z.object({ name: z.string(), lines: z.array(z.string()).optional() }),
93
- ]),
94
- to: z.union([
95
- z.string(),
96
- z.object({ name: z.string(), lines: z.array(z.string()).optional() }),
97
- ]),
98
- items: z
99
- .array(z.object({
100
- description: z.string(),
101
- quantity: z.number().positive().optional(),
102
- unit_price: z.number().nonnegative(),
103
- }))
104
- .min(1)
105
- .max(100),
106
- invoice_number: z.string().optional(),
107
- date: z
108
- .string()
109
- .optional()
110
- .describe('Shown on the invoice; also pins metadata for determinism'),
111
- due: z.string().optional(),
112
- currency: z.string().regex(/^[A-Z]{3}$/).optional(),
113
- tax_rate: z.number().min(0).max(100).optional(),
114
- notes: z.string().max(1000).optional(),
115
- })
116
- .describe('Invoice data'),
117
- output_path: z.string().describe('Absolute path to write the invoice PDF'),
107
+ server.registerTool('pdf_invoice', {
108
+ 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.',
110
+ inputSchema: {
111
+ invoice: z
112
+ .object({
113
+ from: z.union([
114
+ z.string(),
115
+ z.object({ name: z.string(), lines: z.array(z.string()).optional() }),
116
+ ]),
117
+ to: z.union([
118
+ z.string(),
119
+ z.object({ name: z.string(), lines: z.array(z.string()).optional() }),
120
+ ]),
121
+ items: z
122
+ .array(z.object({
123
+ description: z.string(),
124
+ quantity: z.number().positive().optional(),
125
+ unit_price: z.number().nonnegative(),
126
+ }))
127
+ .min(1)
128
+ .max(100),
129
+ invoice_number: z.string().optional(),
130
+ date: z
131
+ .string()
132
+ .optional()
133
+ .describe('Shown on the invoice; also pins metadata for determinism'),
134
+ due: z.string().optional(),
135
+ currency: z.string().regex(/^[A-Z]{3}$/).optional(),
136
+ tax_rate: z.number().min(0).max(100).optional(),
137
+ notes: z.string().max(1000).optional(),
138
+ })
139
+ .describe('Invoice data'),
140
+ output_path: z.string().optional().describe(OUTPUT_DOC),
141
+ },
142
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
118
143
  }, async ({ invoice, output_path }) => {
119
144
  try {
120
145
  const bytes = await client.invoice(invoice);
121
- await writeFile(output_path, bytes);
122
- return {
123
- content: [
124
- { type: 'text', text: `Invoice written to ${output_path} (${bytes.byteLength} bytes)` },
125
- ],
126
- };
146
+ const name = invoice.invoice_number ? `invoice-${invoice.invoice_number}.pdf` : 'invoice.pdf';
147
+ return await emit(bytes, name, 'Invoice', output_path);
127
148
  }
128
149
  catch (e) {
129
- return { content: [{ type: 'text', text: errText(e) }], isError: true };
150
+ return fail(e);
130
151
  }
131
152
  });
132
- 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 () => {
153
+ server.registerTool('pdfops_usage', {
154
+ 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.',
156
+ inputSchema: {},
157
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
158
+ }, async () => {
133
159
  try {
134
160
  const usage = await client.usage();
135
161
  return { content: [{ type: 'text', text: JSON.stringify(usage, null, 2) }] };
136
162
  }
137
163
  catch (e) {
138
- return { content: [{ type: 'text', text: errText(e) }], isError: true };
164
+ return fail(e);
139
165
  }
140
166
  });
141
167
  const transport = new StdioServerTransport();
package/dist/source.js ADDED
@@ -0,0 +1,108 @@
1
+ // PDF input/output plumbing shared by every tool.
2
+ //
3
+ // The server was designed around local file paths (bytes never transit the
4
+ // model context). That is still the right default beside a local agent — but
5
+ // every hosted MCP runtime (Smithery, Glama hosted, cloud IDE gateways) runs
6
+ // this process on a machine where the agent's paths do not exist, so inspect/
7
+ // fill/merge were broken by construction there (TASK-102). A "source" string is
8
+ // now one of three things, told apart by prefix, with no schema change:
9
+ //
10
+ // /abs/path/file.pdf read from disk (local default)
11
+ // https://host/file.pdf fetched (hosted default)
12
+ // data:application/pdf;base64,JVBERi0x... decoded inline
13
+ //
14
+ // Outputs mirror it: with `output_path` the PDF is written to disk; without it
15
+ // the PDF comes back as an embedded application/pdf resource the client saves.
16
+ import { readFile } from 'node:fs/promises';
17
+ /** Upper bound on a fetched or inline PDF. The API rejects larger bodies anyway. */
18
+ export const MAX_INPUT_BYTES = 50 * 1024 * 1024;
19
+ export const classifySource = (s) => {
20
+ if (/^data:/i.test(s))
21
+ return 'data';
22
+ if (/^https?:\/\//i.test(s))
23
+ return 'url';
24
+ return 'path';
25
+ };
26
+ /** Short, safe label for tool output — never echoes inline payloads. */
27
+ export const describeSource = (s) => {
28
+ switch (classifySource(s)) {
29
+ case 'data':
30
+ return 'inline data URI';
31
+ case 'url':
32
+ try {
33
+ return new URL(s).host + new URL(s).pathname;
34
+ }
35
+ catch {
36
+ return 'url';
37
+ }
38
+ default:
39
+ return s;
40
+ }
41
+ };
42
+ const decodeDataUri = (s) => {
43
+ const m = /^data:([^;,]*)((?:;[^;,]*)*),(.*)$/is.exec(s);
44
+ if (!m)
45
+ throw new Error('Malformed data: URI');
46
+ const params = m[2].toLowerCase();
47
+ if (!params.includes(';base64')) {
48
+ throw new Error('data: URI must be base64-encoded (data:application/pdf;base64,...)');
49
+ }
50
+ const bytes = Buffer.from(m[3].replace(/\s+/g, ''), 'base64');
51
+ if (bytes.byteLength === 0)
52
+ throw new Error('data: URI decoded to zero bytes');
53
+ return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
54
+ };
55
+ const fetchUrl = async (url, fetchImpl) => {
56
+ const res = await fetchImpl(url, {
57
+ redirect: 'follow',
58
+ signal: AbortSignal.timeout(30_000),
59
+ headers: { accept: 'application/pdf,*/*' },
60
+ });
61
+ if (!res.ok)
62
+ throw new Error(`Fetching ${describeSource(url)} failed: HTTP ${res.status}`);
63
+ const declared = Number(res.headers.get('content-length') ?? 0);
64
+ if (declared > MAX_INPUT_BYTES) {
65
+ throw new Error(`${describeSource(url)} is ${declared} bytes; limit is ${MAX_INPUT_BYTES}`);
66
+ }
67
+ const buf = new Uint8Array(await res.arrayBuffer());
68
+ if (buf.byteLength > MAX_INPUT_BYTES) {
69
+ throw new Error(`${describeSource(url)} is ${buf.byteLength} bytes; limit is ${MAX_INPUT_BYTES}`);
70
+ }
71
+ if (buf.byteLength === 0)
72
+ throw new Error(`${describeSource(url)} returned an empty body`);
73
+ return buf;
74
+ };
75
+ /** Resolve a source string (path | https URL | data: URI) to PDF bytes. */
76
+ export const resolveSource = async (source, fetchImpl = fetch) => {
77
+ switch (classifySource(source)) {
78
+ case 'data':
79
+ return decodeDataUri(source);
80
+ case 'url':
81
+ return fetchUrl(source, fetchImpl);
82
+ default: {
83
+ const buf = await readFile(source);
84
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
85
+ }
86
+ }
87
+ };
88
+ /**
89
+ * Tool result for a produced PDF. With `wroteTo` (the caller already wrote the
90
+ * file) it is a one-line confirmation; without it the bytes travel back inline
91
+ * as an embedded application/pdf resource, which is what a hosted server must
92
+ * do since it has no filesystem the agent can reach.
93
+ */
94
+ export const pdfResult = (bytes, name, summary, wroteTo) => {
95
+ if (wroteTo) {
96
+ return { content: [{ type: 'text', text: `${summary} written to ${wroteTo} (${bytes.byteLength} bytes)` }] };
97
+ }
98
+ const uri = `pdfops://${name}`;
99
+ return {
100
+ content: [
101
+ {
102
+ type: 'text',
103
+ text: `${summary} (${bytes.byteLength} bytes) returned inline as ${uri} — no output_path was given. Save the attached application/pdf resource; pass output_path to write to disk instead.`,
104
+ },
105
+ { type: 'resource', resource: { uri, mimeType: 'application/pdf', blob: Buffer.from(bytes).toString('base64') } },
106
+ ],
107
+ };
108
+ };
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pdfops-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "mcpName": "dev.pdfops/pdfops-mcp",
5
- "description": "MCP server for the PDFops API \u2014 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.",
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": [
7
7
  "mcp",
8
8
  "model-context-protocol",