pdfops-mcp 0.2.1 → 0.3.0

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"
package/dist/index.js CHANGED
@@ -1,87 +1,90 @@
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';
15
20
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
16
21
  import { z } from 'zod';
17
22
  import { PdfOps, PdfOpsError } from 'pdfops-sdk';
23
+ import { describeSource, pdfResult, resolveSource } from './source.js';
24
+ // Version comes from package.json so the string MCP clients display can no
25
+ // longer drift from the published one (0.2.0 shipped reporting 0.1.1).
26
+ const { version } = createRequire(import.meta.url)('../package.json');
18
27
  const client = new PdfOps({
19
28
  apiKey: process.env.PDFOPS_API_KEY,
20
29
  baseUrl: process.env.PDFOPS_BASE_URL,
21
30
  clientTag: 'mcp',
22
31
  });
23
- const server = new McpServer({
24
- name: 'pdfops',
25
- version: '0.2.1', // keep in sync with package.json
26
- });
32
+ const server = new McpServer({ name: 'pdfops', version });
33
+ 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.';
34
+ 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
35
  const errText = (e) => e instanceof PdfOpsError
28
36
  ? `PDFops API error ${e.status} (${e.code}): ${e.message}` +
29
37
  (e.code === 'rate_limited'
30
38
  ? ' — get a free API key (250/mo) at https://pdfops.dev/pricing and set PDFOPS_API_KEY'
31
39
  : '')
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 }) => {
40
+ : e instanceof Error
41
+ ? e.message
42
+ : String(e);
43
+ const fail = (e) => ({ content: [{ type: 'text', text: errText(e) }], isError: true });
44
+ const emit = async (bytes, name, summary, output_path) => {
45
+ if (output_path)
46
+ await writeFile(output_path, bytes);
47
+ return pdfResult(bytes, name, summary, output_path);
48
+ };
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 }) => {
34
50
  try {
35
- const result = await client.inspect(await readFile(pdf_path));
51
+ const result = await client.inspect(await resolveSource(pdf_path));
36
52
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
37
53
  }
38
54
  catch (e) {
39
- return { content: [{ type: 'text', text: errText(e) }], isError: true };
55
+ return fail(e);
40
56
  }
41
57
  });
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'),
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}`),
44
60
  fields: z
45
61
  .record(z.string())
46
62
  .describe('Field name → string value (from pdf_inspect\'s fillTemplate)'),
47
- output_path: z.string().describe('Absolute path to write the filled PDF'),
63
+ output_path: z.string().optional().describe(OUTPUT_DOC),
48
64
  flatten: z
49
65
  .boolean()
50
66
  .optional()
51
67
  .describe('Bake values into page content and drop the AcroForm so fields are no longer editable'),
52
68
  }, async ({ pdf_path, fields, output_path, flatten }) => {
53
69
  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
- };
70
+ const bytes = await client.fillForm(await resolveSource(pdf_path), fields, { flatten });
71
+ return await emit(bytes, 'filled.pdf', `Filled ${describeSource(pdf_path)}`, output_path);
61
72
  }
62
73
  catch (e) {
63
- return { content: [{ type: 'text', text: errText(e) }], isError: true };
74
+ return fail(e);
64
75
  }
65
76
  });
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'),
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),
72
80
  }, async ({ pdf_paths, output_path }) => {
73
81
  try {
74
- const inputs = await Promise.all(pdf_paths.map((p) => readFile(p)));
82
+ const inputs = await Promise.all(pdf_paths.map((p) => resolveSource(p)));
75
83
  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
- };
84
+ return await emit(bytes, 'merged.pdf', `Merged ${pdf_paths.length} PDFs`, output_path);
82
85
  }
83
86
  catch (e) {
84
- return { content: [{ type: 'text', text: errText(e) }], isError: true };
87
+ return fail(e);
85
88
  }
86
89
  });
87
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.', {
@@ -114,19 +117,15 @@ server.tool('pdf_invoice', 'Generate a complete, professionally laid-out invoice
114
117
  notes: z.string().max(1000).optional(),
115
118
  })
116
119
  .describe('Invoice data'),
117
- output_path: z.string().describe('Absolute path to write the invoice PDF'),
120
+ output_path: z.string().optional().describe(OUTPUT_DOC),
118
121
  }, async ({ invoice, output_path }) => {
119
122
  try {
120
123
  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
- };
124
+ const name = invoice.invoice_number ? `invoice-${invoice.invoice_number}.pdf` : 'invoice.pdf';
125
+ return await emit(bytes, name, 'Invoice', output_path);
127
126
  }
128
127
  catch (e) {
129
- return { content: [{ type: 'text', text: errText(e) }], isError: true };
128
+ return fail(e);
130
129
  }
131
130
  });
132
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 () => {
@@ -135,7 +134,7 @@ server.tool('pdfops_usage', 'Check the current PDFops API quota for the configur
135
134
  return { content: [{ type: 'text', text: JSON.stringify(usage, null, 2) }] };
136
135
  }
137
136
  catch (e) {
138
- return { content: [{ type: 'text', text: errText(e) }], isError: true };
137
+ return fail(e);
139
138
  }
140
139
  });
141
140
  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.0",
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",