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 +10 -1
- package/dist/index.js +46 -47
- package/dist/source.js +108 -0
- package/package.json +2 -2
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
|
-
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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 {
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
:
|
|
33
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
55
|
-
await
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
82
|
+
const inputs = await Promise.all(pdf_paths.map((p) => resolveSource(p)));
|
|
75
83
|
const bytes = await client.merge(inputs);
|
|
76
|
-
await
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"mcpName": "dev.pdfops/pdfops-mcp",
|
|
5
|
-
"description": "MCP server for the PDFops API
|
|
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",
|