podpdf-mcp 1.0.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 +47 -0
- package/package.json +29 -0
- package/src/client.js +98 -0
- package/src/index.js +15 -0
- package/src/server.js +189 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# podpdf-mcp
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server for [PodPDF](https://podpdf.com). It lets Claude, Cursor, VS Code and any other MCP client turn HTML, Markdown, URLs and saved templates into PDFs.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
1. Get an API key at [app.podpdf.com](https://app.podpdf.com) → Settings → API Keys. PodPDF charges $0.01 per PDF, and an account needs a plan or a credit pack before calls succeed.
|
|
8
|
+
2. Add the server to your client. Most clients take this config:
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"mcpServers": {
|
|
13
|
+
"podpdf": {
|
|
14
|
+
"command": "npx",
|
|
15
|
+
"args": ["-y", "podpdf-mcp"],
|
|
16
|
+
"env": { "PODPDF_API_KEY": "pk_..." }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
With Claude Code:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
claude mcp add podpdf --env PODPDF_API_KEY=pk_... -- npx -y podpdf-mcp
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Tools
|
|
29
|
+
|
|
30
|
+
| Tool | What it does |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `generate_pdf` | HTML, Markdown or a public HTTPS URL to PDF, up to 25 pages. Options: `format`, `landscape`, `margin`, `printBackground`, `scale`, `pageRanges`. |
|
|
33
|
+
| `render_template` | Fill a saved template (`template_id`) with `data` and render it. |
|
|
34
|
+
| `start_long_job` | Queue HTML or Markdown up to 100 pages. `wait: true` polls until it's done. |
|
|
35
|
+
| `get_job` | Status and details of a job. |
|
|
36
|
+
| `get_download_link` | A fresh 1-hour download link for a job's stored output. |
|
|
37
|
+
|
|
38
|
+
`generate_pdf`, `render_template` and `get_download_link` take an optional `save_path` to write the PDF to disk. Otherwise they return a download link that is valid for one hour.
|
|
39
|
+
|
|
40
|
+
## Environment
|
|
41
|
+
|
|
42
|
+
| Variable | Required | Default |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| `PODPDF_API_KEY` | yes | — |
|
|
45
|
+
| `PODPDF_API_URL` | no | `https://api.podpdf.com` |
|
|
46
|
+
|
|
47
|
+
API docs: [apidocs.podpdf.com](https://apidocs.podpdf.com). Support: podpdfapp@gmail.com.
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "podpdf-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for PodPDF: turn HTML, Markdown, URLs and templates into PDFs from any MCP client.",
|
|
5
|
+
"mcpName": "io.github.mohzameer/podpdf",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"podpdf-mcp": "src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node src/index.js",
|
|
16
|
+
"test": "node --test"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["mcp", "model-context-protocol", "pdf", "html-to-pdf", "markdown-to-pdf", "podpdf"],
|
|
19
|
+
"homepage": "https://podpdf.com",
|
|
20
|
+
"author": "PodPDF <podpdfapp@gmail.com>",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
27
|
+
"zod": "^3.25.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Thin client for the slice of the PodPDF API the MCP tools use. See ../../SPEC.md.
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_BASE_URL = 'https://api.podpdf.com';
|
|
4
|
+
|
|
5
|
+
export class PodPdfError extends Error {
|
|
6
|
+
constructor(status, code, message, details) {
|
|
7
|
+
super(`PodPDF ${status}${code ? ` ${code}` : ''}: ${message}`);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.details = details;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createClient({ apiKey, baseUrl = DEFAULT_BASE_URL, fetchImpl = globalThis.fetch } = {}) {
|
|
15
|
+
const root = baseUrl.replace(/\/+$/, '');
|
|
16
|
+
|
|
17
|
+
async function request(method, path, body) {
|
|
18
|
+
if (!apiKey) {
|
|
19
|
+
throw new PodPdfError(401, 'MISSING_API_KEY', 'Set PODPDF_API_KEY to your key from https://app.podpdf.com (Settings → API Keys).');
|
|
20
|
+
}
|
|
21
|
+
const res = await fetchImpl(`${root}${path}`, {
|
|
22
|
+
method,
|
|
23
|
+
headers: {
|
|
24
|
+
'X-API-Key': apiKey,
|
|
25
|
+
Accept: 'application/json',
|
|
26
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
27
|
+
'User-Agent': 'podpdf-mcp',
|
|
28
|
+
},
|
|
29
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
30
|
+
});
|
|
31
|
+
const text = await res.text();
|
|
32
|
+
let json = null;
|
|
33
|
+
try {
|
|
34
|
+
json = text ? JSON.parse(text) : null;
|
|
35
|
+
} catch {
|
|
36
|
+
// Non-JSON body; reported below if the status is an error.
|
|
37
|
+
}
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const err = json?.error ?? {};
|
|
40
|
+
throw new PodPdfError(res.status, err.code, err.message || text || res.statusText, err.details);
|
|
41
|
+
}
|
|
42
|
+
return json;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function download(url) {
|
|
46
|
+
const res = await fetchImpl(url);
|
|
47
|
+
if (!res.ok) throw new PodPdfError(res.status, 'DOWNLOAD_FAILED', `Could not download ${url}`);
|
|
48
|
+
return Buffer.from(await res.arrayBuffer());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
me: () => request('GET', '/me'),
|
|
53
|
+
quickjob: (body) => request('POST', '/quickjob', { ...body, store: true }),
|
|
54
|
+
renderTemplate: (templateId, body) =>
|
|
55
|
+
request('POST', `/templates/${encodeURIComponent(templateId)}/render`, { ...body, store: true }),
|
|
56
|
+
longjob: (body) => request('POST', '/longjob', body),
|
|
57
|
+
getJob: (jobId) => request('GET', `/jobs/${encodeURIComponent(jobId)}`),
|
|
58
|
+
getDownloadLink: (jobId) => request('GET', `/jobs/${encodeURIComponent(jobId)}/download`),
|
|
59
|
+
download,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Expands dotted keys into nested objects, so `customer.name` becomes
|
|
65
|
+
* `{ customer: { name: … } }` — the shape the render API expects. A plain key
|
|
66
|
+
* wins over a dotted one.
|
|
67
|
+
*/
|
|
68
|
+
export function expandDottedKeys(data) {
|
|
69
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return data;
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const [key, value] of Object.entries(data)) {
|
|
72
|
+
if (!key.includes('.')) continue;
|
|
73
|
+
const parts = key.split('.');
|
|
74
|
+
const last = parts.pop();
|
|
75
|
+
let target = out;
|
|
76
|
+
for (const part of parts) {
|
|
77
|
+
if (!target[part] || typeof target[part] !== 'object' || Array.isArray(target[part])) target[part] = {};
|
|
78
|
+
target = target[part];
|
|
79
|
+
}
|
|
80
|
+
target[last] = value;
|
|
81
|
+
}
|
|
82
|
+
for (const [key, value] of Object.entries(data)) {
|
|
83
|
+
if (!key.includes('.')) out[key] = value;
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Builds `options` from the flat fields tools accept, leaving out anything unset.
|
|
89
|
+
export function buildOptions({ format, landscape, margin, printBackground, scale, pageRanges } = {}) {
|
|
90
|
+
const options = {};
|
|
91
|
+
if (format) options.format = format;
|
|
92
|
+
if (landscape !== undefined) options.landscape = landscape;
|
|
93
|
+
if (margin) options.margin = { top: margin, right: margin, bottom: margin, left: margin };
|
|
94
|
+
if (printBackground !== undefined) options.printBackground = printBackground;
|
|
95
|
+
if (scale !== undefined) options.scale = scale;
|
|
96
|
+
if (pageRanges) options.pageRanges = pageRanges;
|
|
97
|
+
return Object.keys(options).length ? options : undefined;
|
|
98
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { createClient } from './client.js';
|
|
4
|
+
import { createServer } from './server.js';
|
|
5
|
+
|
|
6
|
+
const client = createClient({
|
|
7
|
+
apiKey: process.env.PODPDF_API_KEY,
|
|
8
|
+
baseUrl: process.env.PODPDF_API_URL || undefined,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
if (!process.env.PODPDF_API_KEY) {
|
|
12
|
+
console.error('podpdf-mcp: PODPDF_API_KEY is not set; tool calls will fail until it is.');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
await createServer(client).connect(new StdioServerTransport());
|
package/src/server.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { buildOptions, expandDottedKeys, PodPdfError } from './client.js';
|
|
6
|
+
|
|
7
|
+
export const VERSION = '1.0.0';
|
|
8
|
+
|
|
9
|
+
const DOCS = 'https://apidocs.podpdf.com';
|
|
10
|
+
const TERMINAL = new Set(['completed', 'partial_failed', 'failed', 'timeout']);
|
|
11
|
+
|
|
12
|
+
const optionFields = {
|
|
13
|
+
format: z.enum(['A4', 'Letter', 'Legal', 'A3', 'A5', 'Tabloid']).optional().describe('Paper size (default A4)'),
|
|
14
|
+
landscape: z.boolean().optional().describe('Landscape orientation'),
|
|
15
|
+
margin: z.string().optional().describe('Margin for all four sides, e.g. "20mm" or "0.5in"'),
|
|
16
|
+
printBackground: z.boolean().optional().describe('Print background colours and images (default true)'),
|
|
17
|
+
scale: z.number().min(0.1).max(2).optional().describe('Render scale, 0.1 to 2 (default 1)'),
|
|
18
|
+
pageRanges: z.string().optional().describe('Pages to keep, e.g. "1-3, 5"'),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const savePath = z
|
|
22
|
+
.string()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe('Optional local file path to save the PDF to, e.g. "./invoice.pdf". The download link is returned either way.');
|
|
25
|
+
|
|
26
|
+
function ok(data, extra) {
|
|
27
|
+
const text = [extra, JSON.stringify(data, null, 2)].filter(Boolean).join('\n\n');
|
|
28
|
+
return { content: [{ type: 'text', text }], structuredContent: data };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fail(err) {
|
|
32
|
+
const message =
|
|
33
|
+
err instanceof PodPdfError
|
|
34
|
+
? [err.message, hint(err), err.details ? `Details: ${JSON.stringify(err.details)}` : null].filter(Boolean).join('\n')
|
|
35
|
+
: String(err?.message ?? err);
|
|
36
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hint(err) {
|
|
40
|
+
switch (err.code || err.status) {
|
|
41
|
+
case 'QUICKJOB_TIMEOUT':
|
|
42
|
+
return 'The document took over 30 seconds. Use start_long_job instead.';
|
|
43
|
+
case 'UPGRADE_REQUIRED':
|
|
44
|
+
return 'The account has no paid plan yet. Buy credits or subscribe at https://app.podpdf.com.';
|
|
45
|
+
case 'INSUFFICIENT_CREDITS':
|
|
46
|
+
return 'Out of credits. Top up at https://app.podpdf.com.';
|
|
47
|
+
case 401:
|
|
48
|
+
return 'Check PODPDF_API_KEY.';
|
|
49
|
+
default:
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function maybeSave(client, result, file) {
|
|
55
|
+
if (!file || !result?.download_url) return null;
|
|
56
|
+
const target = path.resolve(file);
|
|
57
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
58
|
+
await writeFile(target, await client.download(result.download_url));
|
|
59
|
+
return `Saved PDF to ${target}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function contentFor(input) {
|
|
63
|
+
const { input_type, content } = input;
|
|
64
|
+
if (input_type === 'url') return { url: content };
|
|
65
|
+
return { [input_type]: content };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createServer(client, { pollIntervalMs = 5000, maxWaitMs = 180000 } = {}) {
|
|
69
|
+
const server = new McpServer({ name: 'podpdf', version: VERSION });
|
|
70
|
+
|
|
71
|
+
server.registerTool(
|
|
72
|
+
'generate_pdf',
|
|
73
|
+
{
|
|
74
|
+
title: 'Generate PDF',
|
|
75
|
+
description:
|
|
76
|
+
`Convert HTML, Markdown or a public HTTPS URL to a PDF (up to 25 pages, 30 seconds). Returns a download link valid for 1 hour. Costs one PodPDF credit. Docs: ${DOCS}/api-reference/quickjob`,
|
|
77
|
+
inputSchema: {
|
|
78
|
+
input_type: z.enum(['html', 'markdown', 'url']).describe('What `content` is'),
|
|
79
|
+
content: z.string().min(1).describe('The HTML, the Markdown, or the https:// URL to convert'),
|
|
80
|
+
...optionFields,
|
|
81
|
+
save_path: savePath,
|
|
82
|
+
},
|
|
83
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
84
|
+
},
|
|
85
|
+
async ({ input_type, content, save_path, ...opts }) => {
|
|
86
|
+
try {
|
|
87
|
+
const result = await client.quickjob({ input_type, ...contentFor({ input_type, content }), options: buildOptions(opts) });
|
|
88
|
+
return ok(result, await maybeSave(client, result, save_path));
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return fail(err);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
server.registerTool(
|
|
96
|
+
'render_template',
|
|
97
|
+
{
|
|
98
|
+
title: 'Render Template',
|
|
99
|
+
description:
|
|
100
|
+
`Fill a saved PodPDF template with data and render it to PDF. Template IDs are shown in the dashboard (Templates → Use via API). Docs: ${DOCS}/api-reference/templates`,
|
|
101
|
+
inputSchema: {
|
|
102
|
+
template_id: z.string().min(1).describe('Template ID from the PodPDF dashboard'),
|
|
103
|
+
data: z.record(z.any()).describe("Values for the template's fields; nested objects and arrays of objects for list fields. A dotted key such as \"customer.name\" is also accepted."),
|
|
104
|
+
filename: z.string().optional().describe('File name for the PDF'),
|
|
105
|
+
save_path: savePath,
|
|
106
|
+
},
|
|
107
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
108
|
+
},
|
|
109
|
+
async ({ template_id, data, filename, save_path }) => {
|
|
110
|
+
try {
|
|
111
|
+
const result = await client.renderTemplate(template_id, { data: expandDottedKeys(data), ...(filename ? { filename } : {}) });
|
|
112
|
+
return ok(result, await maybeSave(client, result, save_path));
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return fail(err);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
server.registerTool(
|
|
120
|
+
'start_long_job',
|
|
121
|
+
{
|
|
122
|
+
title: 'Start Long Job',
|
|
123
|
+
description:
|
|
124
|
+
`Queue a large HTML or Markdown document (up to 100 pages) for asynchronous rendering. Returns a job_id; set wait to true to poll until it finishes and return the download link. Docs: ${DOCS}/api-reference/longjob`,
|
|
125
|
+
inputSchema: {
|
|
126
|
+
input_type: z.enum(['html', 'markdown']).describe('What `content` is'),
|
|
127
|
+
content: z.string().min(1).describe('The HTML or Markdown to render'),
|
|
128
|
+
...optionFields,
|
|
129
|
+
wait: z.boolean().optional().describe('Wait up to 3 minutes for the job to finish (default false)'),
|
|
130
|
+
},
|
|
131
|
+
annotations: { readOnlyHint: false, openWorldHint: true },
|
|
132
|
+
},
|
|
133
|
+
async ({ input_type, content, wait, ...opts }) => {
|
|
134
|
+
try {
|
|
135
|
+
const queued = await client.longjob({ input_type, [input_type]: content, options: buildOptions(opts) });
|
|
136
|
+
if (!wait) return ok(queued, 'Job queued. Check it with get_job, then fetch the PDF with get_download_link.');
|
|
137
|
+
const deadline = Date.now() + maxWaitMs;
|
|
138
|
+
let job = queued;
|
|
139
|
+
while (!TERMINAL.has(job.status) && Date.now() < deadline) {
|
|
140
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
141
|
+
job = await client.getJob(queued.job_id);
|
|
142
|
+
}
|
|
143
|
+
if (job.status !== 'completed') {
|
|
144
|
+
return ok(job, TERMINAL.has(job.status) ? `Job ended with status ${job.status}.` : 'Still running; check again with get_job.');
|
|
145
|
+
}
|
|
146
|
+
return ok({ ...job, ...(await client.getDownloadLink(queued.job_id)) });
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return fail(err);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
server.registerTool(
|
|
154
|
+
'get_job',
|
|
155
|
+
{
|
|
156
|
+
title: 'Get Job',
|
|
157
|
+
description: `Get the status and details of a PodPDF job (queued, processing, completed, partial_failed, failed, timeout). Docs: ${DOCS}/api-reference/jobs`,
|
|
158
|
+
inputSchema: { job_id: z.string().min(1).describe('Job ID') },
|
|
159
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
160
|
+
},
|
|
161
|
+
async ({ job_id }) => {
|
|
162
|
+
try {
|
|
163
|
+
return ok(await client.getJob(job_id));
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return fail(err);
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
server.registerTool(
|
|
171
|
+
'get_download_link',
|
|
172
|
+
{
|
|
173
|
+
title: 'Get Download Link',
|
|
174
|
+
description: `Get a fresh 1-hour download link for a finished job's stored output (long, bulk, merge/split, or stored quick and template jobs). Docs: ${DOCS}/api-reference/jobs`,
|
|
175
|
+
inputSchema: { job_id: z.string().min(1).describe('Job ID'), save_path: savePath },
|
|
176
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
177
|
+
},
|
|
178
|
+
async ({ job_id, save_path }) => {
|
|
179
|
+
try {
|
|
180
|
+
const result = await client.getDownloadLink(job_id);
|
|
181
|
+
return ok(result, await maybeSave(client, result, save_path));
|
|
182
|
+
} catch (err) {
|
|
183
|
+
return fail(err);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
return server;
|
|
189
|
+
}
|