filetopdf-mcp 0.1.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 ADDED
@@ -0,0 +1,84 @@
1
+ # FileToPDF MCP server
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server for
4
+ [FileToPDF](https://filetopdf.dev). It lets any MCP client — Claude Desktop, Cursor,
5
+ Cline, VS Code, or your own agent — convert **files, HTML, and Markdown to PDF**
6
+ through natural language. **Bring your own API key** (free trial key on the home page).
7
+
8
+ ## Tools
9
+
10
+ | Tool | What it does |
11
+ |------|--------------|
12
+ | `get_account` | Check the API key and show plan + remaining credits. Free, no credits used. |
13
+ | `convert_file` | Convert a file from a **public URL** (DOCX, XLSX, PPTX, images, HTML, MD, PDF…) to PDF. |
14
+ | `convert_html` | Render a raw **HTML** string (with optional CSS + layout options) to PDF. |
15
+ | `convert_markdown` | Render a raw **Markdown** string (with optional CSS + layout options) to PDF. |
16
+
17
+ Each convert tool returns a metadata summary **and** the PDF as an embedded
18
+ `application/pdf` resource (base64), so capable clients can save the file. Set the
19
+ `FILETOPDF_OUTPUT_DIR` env var, or pass `save_path`, to also write the PDF to disk.
20
+
21
+ Conversion options (`landscape`, `paperWidth/Height`, margins, `scale`, `pdfa`,
22
+ passwords, …) are available on **Pro, Scale, and the free trial**; on Starter/Basic
23
+ they return an upgrade error. Each successful conversion costs **1 credit**; errors
24
+ are free.
25
+
26
+ ## Quick start (local / stdio)
27
+
28
+ ```bash
29
+ npx filetopdf-mcp
30
+ ```
31
+
32
+ Add to **Claude Desktop** (`claude_desktop_config.json`) or **Cursor**:
33
+
34
+ ```json
35
+ {
36
+ "mcpServers": {
37
+ "filetopdf": {
38
+ "command": "npx",
39
+ "args": ["-y", "filetopdf-mcp"],
40
+ "env": { "FILETOPDF_API_KEY": "sk_live_xxxxxxxxxxxxxxxxxxxxxxxx" }
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ Get a key free at <https://filetopdf.dev> (instant trial key on the home page, 10 free
47
+ conversions) or from the dashboard.
48
+
49
+ ## Hosted / remote (Streamable HTTP)
50
+
51
+ ```bash
52
+ npm run build && npm run start:http # listens on $PORT (default 8080) at /mcp
53
+ ```
54
+
55
+ The key is read from the `x-api-key` header, an `Authorization: Bearer` header, or an
56
+ `?apiKey=` query parameter — so one endpoint serves every user with their own key.
57
+ A `Dockerfile` is included for container hosting (Smithery, Fly, Render, Cloud Run).
58
+
59
+ ## Develop & test
60
+
61
+ ```bash
62
+ npm install
63
+ npm run build # tsc -> dist/
64
+ npm test # spawns the built stdio server, runs a live conversion
65
+ ```
66
+
67
+ `npm test` reads `FILETOPDF_API_KEY`, or `API_KEY=` from a gitignored `.env`.
68
+
69
+ ## Publishing & listing
70
+
71
+ This server is built to be **publicly discoverable** on MCP marketplaces:
72
+
73
+ 1. **npm** — `npm publish` makes `npx filetopdf-mcp` work (prerequisite for most
74
+ directories).
75
+ 2. **GitHub** — push a public repo (`filetopdf/filetopdf-mcp`); Glama and others
76
+ auto-index it.
77
+ 3. **Smithery** — connect the repo at <https://smithery.ai/new>; it builds the
78
+ `Dockerfile` and hosts the HTTP server using `smithery.yaml`.
79
+ 4. **Official MCP Registry** — publish `server.json` with the `mcp-publisher` CLI
80
+ (namespace `dev.filetopdf/*` is verified via a DNS TXT record on filetopdf.dev).
81
+
82
+ ## License
83
+
84
+ MIT
package/dist/api.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ export declare const BASE_URL: string;
2
+ export declare const OPTION_KEYS: readonly ["landscape", "nativePageRanges", "pdfa", "pdfua", "password", "userPassword", "ownerPassword", "paperWidth", "paperHeight", "marginTop", "marginBottom", "marginLeft", "marginRight", "scale", "printBackground", "preferCssPageSize"];
3
+ export type RawOptions = Record<string, unknown>;
4
+ export declare function stringifyOptions(input: RawOptions): Record<string, string>;
5
+ export interface AccountData {
6
+ workspace_id: string;
7
+ plan: string;
8
+ credits_remaining: number;
9
+ subscription_status: string;
10
+ }
11
+ export interface PdfResult {
12
+ pdfBase64: string;
13
+ filename: string;
14
+ pages: number | null;
15
+ sizeBytes: number;
16
+ creditsUsed: number | null;
17
+ creditsRemaining: number | null;
18
+ }
19
+ /** A FileToPDF API error carrying the stable machine code and any extra fields. */
20
+ export declare class FileToPdfError extends Error {
21
+ code: string;
22
+ httpStatus: number;
23
+ parameter?: string;
24
+ upgradeUrl?: string;
25
+ constructor(message: string, opts: {
26
+ code: string;
27
+ httpStatus: number;
28
+ parameter?: string;
29
+ upgradeUrl?: string;
30
+ });
31
+ }
32
+ /** GET /account — free, never rate-limited. Use it as the connection/credits check. */
33
+ export declare function getAccount(apiKey: string): Promise<AccountData>;
34
+ export declare function convertFile(apiKey: string, url: string, options?: RawOptions): Promise<PdfResult>;
35
+ export declare function convertHtml(apiKey: string, html: string, css: string | undefined, options?: RawOptions): Promise<PdfResult>;
36
+ export declare function convertMarkdown(apiKey: string, markdown: string, css: string | undefined, options?: RawOptions): Promise<PdfResult>;
package/dist/api.js ADDED
@@ -0,0 +1,150 @@
1
+ // FileToPDF API client — a thin, dependency-free wrapper around the three
2
+ // conversion endpoints plus the free /account check. Shared by every MCP tool.
3
+ //
4
+ // API contract (see integrations/openapi/openapi.yaml for the source of truth):
5
+ // POST /file { url, ...options } -> PDF
6
+ // POST /html { html, css?, ...options } -> PDF
7
+ // POST /markdown { markdown, css?, ...options } -> PDF
8
+ // GET /account -> { status, data:{ plan, credits_remaining, ... } }
9
+ // Auth: header `x-api-key: sk_live_...`. Errors never cost credits.
10
+ export const BASE_URL = process.env.FILETOPDF_BASE_URL || "https://api.filetopdf.dev";
11
+ // Layout/render options forwarded to the API. The backend's form handling expects
12
+ // STRINGS, so booleans become "true"/"false" and numbers their string form.
13
+ // IMPORTANT: only send options the user actually set — any option on a Starter/Basic
14
+ // plan returns 402 upgrade_required, so `false` booleans and empty values are DROPPED.
15
+ export const OPTION_KEYS = [
16
+ "landscape",
17
+ "nativePageRanges",
18
+ "pdfa",
19
+ "pdfua",
20
+ "password",
21
+ "userPassword",
22
+ "ownerPassword",
23
+ "paperWidth",
24
+ "paperHeight",
25
+ "marginTop",
26
+ "marginBottom",
27
+ "marginLeft",
28
+ "marginRight",
29
+ "scale",
30
+ "printBackground",
31
+ "preferCssPageSize",
32
+ ];
33
+ export function stringifyOptions(input) {
34
+ const out = {};
35
+ for (const key of OPTION_KEYS) {
36
+ let v = input[key];
37
+ if (v === undefined || v === null || v === "")
38
+ continue;
39
+ if (typeof v === "boolean") {
40
+ if (!v)
41
+ continue; // drop false — never send an option the user didn't enable
42
+ v = "true";
43
+ }
44
+ out[key] = String(v);
45
+ }
46
+ return out;
47
+ }
48
+ /** A FileToPDF API error carrying the stable machine code and any extra fields. */
49
+ export class FileToPdfError extends Error {
50
+ code;
51
+ httpStatus;
52
+ parameter;
53
+ upgradeUrl;
54
+ constructor(message, opts) {
55
+ super(message);
56
+ this.name = "FileToPdfError";
57
+ this.code = opts.code;
58
+ this.httpStatus = opts.httpStatus;
59
+ this.parameter = opts.parameter;
60
+ this.upgradeUrl = opts.upgradeUrl;
61
+ }
62
+ }
63
+ function authHeaders(apiKey) {
64
+ if (!apiKey) {
65
+ throw new FileToPdfError("No FileToPDF API key configured. Set FILETOPDF_API_KEY (stdio) or send an x-api-key header (HTTP). Get a free key at https://filetopdf.dev.", { code: "missing_api_key", httpStatus: 401 });
66
+ }
67
+ return { "x-api-key": apiKey };
68
+ }
69
+ /** Turn a non-OK response into a friendly FileToPdfError, reading the {error:{...}} envelope. */
70
+ async function toError(res) {
71
+ let code = `http_${res.status}`;
72
+ let message = `Request failed (HTTP ${res.status}).`;
73
+ let parameter;
74
+ let upgradeUrl;
75
+ try {
76
+ const body = (await res.json());
77
+ if (body?.error) {
78
+ code = body.error.code || code;
79
+ message = body.error.message || message;
80
+ parameter = body.error.parameter;
81
+ upgradeUrl = body.error.upgrade_url;
82
+ }
83
+ }
84
+ catch {
85
+ /* non-JSON error body — keep the generic message */
86
+ }
87
+ // Friendlier guidance for the common plan-gating cases.
88
+ if (code === "upgrade_required" && parameter) {
89
+ message = `The "${parameter}" option requires the Pro or Scale plan (or the free trial). ${message}`;
90
+ }
91
+ else if (code === "payment_required") {
92
+ message = `Out of FileToPDF credits. ${message}${upgradeUrl ? ` Upgrade at ${upgradeUrl}.` : ""}`;
93
+ }
94
+ else if (code === "forbidden_url") {
95
+ message = `That URL was rejected (private/internal addresses are not allowed). ${message}`;
96
+ }
97
+ else if (code === "concurrency_limit") {
98
+ message = `Concurrency limit reached for your plan. ${message}`;
99
+ }
100
+ return new FileToPdfError(message, { code, httpStatus: res.status, parameter, upgradeUrl });
101
+ }
102
+ /** GET /account — free, never rate-limited. Use it as the connection/credits check. */
103
+ export async function getAccount(apiKey) {
104
+ const res = await fetch(`${BASE_URL}/account`, {
105
+ headers: { ...authHeaders(apiKey), Accept: "application/json" },
106
+ });
107
+ if (!res.ok)
108
+ throw await toError(res);
109
+ const body = (await res.json());
110
+ if (body?.status !== "success")
111
+ throw await toError(res);
112
+ return body.data;
113
+ }
114
+ /** Internal: POST a JSON body to a conversion endpoint and parse the base64 envelope. */
115
+ async function convert(apiKey, path, body) {
116
+ const res = await fetch(`${BASE_URL}${path}`, {
117
+ method: "POST",
118
+ headers: {
119
+ ...authHeaders(apiKey),
120
+ "Content-Type": "application/json",
121
+ // Ask for the JSON envelope so we get base64 + metadata in one shot — easiest
122
+ // form to hand back to an MCP client as an embedded resource.
123
+ Accept: "application/json",
124
+ },
125
+ body: JSON.stringify(body),
126
+ });
127
+ if (!res.ok)
128
+ throw await toError(res);
129
+ const out = (await res.json());
130
+ if (out?.status !== "success" || !out?.data?.pdf)
131
+ throw await toError(res);
132
+ const d = out.data;
133
+ return {
134
+ pdfBase64: d.pdf,
135
+ filename: d.filename || "converted.pdf",
136
+ pages: typeof d.pages === "number" ? d.pages : null,
137
+ sizeBytes: typeof d.size_bytes === "number" ? d.size_bytes : 0,
138
+ creditsUsed: typeof d.credits_used === "number" ? d.credits_used : null,
139
+ creditsRemaining: typeof d.credits_remaining === "number" ? d.credits_remaining : null,
140
+ };
141
+ }
142
+ export function convertFile(apiKey, url, options = {}) {
143
+ return convert(apiKey, "/file", { url, ...stringifyOptions(options) });
144
+ }
145
+ export function convertHtml(apiKey, html, css, options = {}) {
146
+ return convert(apiKey, "/html", { html, ...(css ? { css } : {}), ...stringifyOptions(options) });
147
+ }
148
+ export function convertMarkdown(apiKey, markdown, css, options = {}) {
149
+ return convert(apiKey, "/markdown", { markdown, ...(css ? { css } : {}), ...stringifyOptions(options) });
150
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/http.js ADDED
@@ -0,0 +1,101 @@
1
+ // Streamable HTTP entrypoint — the form used for hosted/remote deployment
2
+ // (Smithery, your own infra). Runs stateless: each request spins up a fresh server
3
+ // bound to the API key carried on that request, so one endpoint serves every user
4
+ // with their own key (BYO-key).
5
+ //
6
+ // The FileToPDF API key is read, in order, from:
7
+ // 1. the `x-api-key` request header
8
+ // 2. an `Authorization: Bearer <key>` header
9
+ // 3. an `apiKey` / `api_key` query-string parameter (how Smithery passes config)
10
+ //
11
+ // Listens on PORT (default 8080) at the path /mcp.
12
+ import { createServer as createHttpServer } from "node:http";
13
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
14
+ import { createServer } from "./server.js";
15
+ const PORT = Number(process.env.PORT) || 8080;
16
+ const MCP_PATH = "/mcp";
17
+ function resolveApiKey(req, query) {
18
+ const headerKey = req.headers["x-api-key"];
19
+ if (typeof headerKey === "string" && headerKey)
20
+ return headerKey;
21
+ const auth = req.headers["authorization"];
22
+ if (typeof auth === "string" && auth.toLowerCase().startsWith("bearer ")) {
23
+ return auth.slice(7).trim();
24
+ }
25
+ return query.get("apiKey") || query.get("api_key") || undefined;
26
+ }
27
+ function readBody(req) {
28
+ return new Promise((resolve, reject) => {
29
+ const chunks = [];
30
+ req.on("data", (c) => chunks.push(Buffer.from(c)));
31
+ req.on("end", () => {
32
+ const raw = Buffer.concat(chunks).toString("utf8");
33
+ if (!raw)
34
+ return resolve(undefined);
35
+ try {
36
+ resolve(JSON.parse(raw));
37
+ }
38
+ catch {
39
+ reject(new Error("Invalid JSON body"));
40
+ }
41
+ });
42
+ req.on("error", reject);
43
+ });
44
+ }
45
+ function setCors(res) {
46
+ res.setHeader("Access-Control-Allow-Origin", "*");
47
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
48
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, x-api-key, Authorization, Mcp-Session-Id");
49
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
50
+ }
51
+ const httpServer = createHttpServer(async (req, res) => {
52
+ setCors(res);
53
+ if (req.method === "OPTIONS") {
54
+ res.writeHead(204).end();
55
+ return;
56
+ }
57
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
58
+ // Simple health check for hosting platforms.
59
+ if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/health")) {
60
+ res.writeHead(200, { "Content-Type": "application/json" });
61
+ res.end(JSON.stringify({ status: "ok", server: "filetopdf-mcp" }));
62
+ return;
63
+ }
64
+ if (url.pathname !== MCP_PATH) {
65
+ res.writeHead(404, { "Content-Type": "application/json" });
66
+ res.end(JSON.stringify({ error: "Not found. The MCP endpoint is at /mcp." }));
67
+ return;
68
+ }
69
+ // Stateless: one transport + server per request, no session persistence.
70
+ if (req.method === "POST") {
71
+ try {
72
+ const body = await readBody(req);
73
+ const apiKey = resolveApiKey(req, url.searchParams);
74
+ const server = createServer(() => apiKey);
75
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
76
+ res.on("close", () => {
77
+ transport.close();
78
+ server.close();
79
+ });
80
+ await server.connect(transport);
81
+ await transport.handleRequest(req, res, body);
82
+ }
83
+ catch (err) {
84
+ if (!res.headersSent) {
85
+ res.writeHead(400, { "Content-Type": "application/json" });
86
+ }
87
+ res.end(JSON.stringify({
88
+ jsonrpc: "2.0",
89
+ error: { code: -32000, message: err.message },
90
+ id: null,
91
+ }));
92
+ }
93
+ return;
94
+ }
95
+ // GET/DELETE aren't used in stateless mode.
96
+ res.writeHead(405, { "Content-Type": "application/json", Allow: "POST, OPTIONS" });
97
+ res.end(JSON.stringify({ error: "Method not allowed. Use POST for MCP requests." }));
98
+ });
99
+ httpServer.listen(PORT, () => {
100
+ console.error(`filetopdf-mcp (Streamable HTTP) listening on http://0.0.0.0:${PORT}${MCP_PATH}`);
101
+ });
@@ -0,0 +1,8 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /** How the active transport resolves the caller's FileToPDF API key. */
3
+ export type ApiKeyResolver = () => string | undefined;
4
+ /**
5
+ * Create a fully-configured FileToPDF MCP server. The transport supplies a resolver
6
+ * so each tool call reads the right API key (from env for stdio, from headers for HTTP).
7
+ */
8
+ export declare function createServer(getApiKey: ApiKeyResolver): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,195 @@
1
+ // FileToPDF MCP server — registers the four tools shared by every transport.
2
+ //
3
+ // Design mirrors the shipped FileToPDF connectors (Make / n8n / Zapier / Apify):
4
+ // * All actions, zero triggers (the API is pure request/response).
5
+ // * Clean three-converter split: convert_file (URL) / convert_html / convert_markdown.
6
+ // * A free get_account tool that doubles as the connection + credits check.
7
+ // * Layout options forwarded as STRINGS, false/empty dropped (see api.ts).
8
+ //
9
+ // Each convert tool returns BOTH a human-readable text summary AND an embedded
10
+ // `application/pdf` resource (base64 blob) so capable MCP clients can save the file.
11
+ // If FILETOPDF_OUTPUT_DIR is set (or a `save_path` is given) the PDF is also written
12
+ // to disk — handy for local stdio use.
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { z } from "zod";
15
+ import { promises as fs } from "node:fs";
16
+ import path from "node:path";
17
+ import { convertFile, convertHtml, convertMarkdown, getAccount, FileToPdfError, } from "./api.js";
18
+ // --- Shared option shapes (kept as strings to match the API's form handling) ---
19
+ // Chromium render options — apply to HTML and Markdown. Available on Pro/Scale/trial.
20
+ const chromiumOptions = {
21
+ landscape: z.boolean().optional().describe("Render in landscape orientation. (Pro/Scale/trial only.)"),
22
+ paperWidth: z.string().optional().describe('Paper width in inches, or with a unit e.g. "210mm".'),
23
+ paperHeight: z.string().optional().describe("Paper height in inches."),
24
+ marginTop: z.string().optional().describe("Top margin in inches."),
25
+ marginBottom: z.string().optional().describe("Bottom margin in inches."),
26
+ marginLeft: z.string().optional().describe("Left margin in inches."),
27
+ marginRight: z.string().optional().describe("Right margin in inches."),
28
+ scale: z.string().optional().describe("Render scale, 0.1–2.0."),
29
+ printBackground: z.boolean().optional().describe("Print background graphics/colors."),
30
+ preferCssPageSize: z.boolean().optional().describe("Use the page size declared in CSS @page rules."),
31
+ nativePageRanges: z.string().optional().describe('Page ranges to include, e.g. "1-3,5".'),
32
+ pdfa: z.string().optional().describe('PDF/A conformance level, e.g. "PDF/A-2b". (Pro/Scale/trial only.)'),
33
+ pdfua: z.boolean().optional().describe("Produce an accessible PDF/UA document."),
34
+ userPassword: z.string().optional().describe("Password required to open the resulting PDF."),
35
+ ownerPassword: z.string().optional().describe("Owner password controlling permissions on the resulting PDF."),
36
+ save_path: z.string().optional().describe("Optional absolute path to also write the PDF to on disk."),
37
+ };
38
+ // LibreOffice/document options — apply to convert_file (no paper size / margins / scale).
39
+ const fileOptions = {
40
+ landscape: z.boolean().optional().describe("Render in landscape orientation. (Pro/Scale/trial only.)"),
41
+ nativePageRanges: z.string().optional().describe('Page ranges to include, e.g. "1-3".'),
42
+ password: z.string().optional().describe("Password to open a protected source document."),
43
+ pdfa: z.string().optional().describe('PDF/A conformance level, e.g. "PDF/A-2b". (Pro/Scale/trial only.)'),
44
+ pdfua: z.boolean().optional().describe("Produce an accessible PDF/UA document."),
45
+ userPassword: z.string().optional().describe("Password required to open the resulting PDF."),
46
+ ownerPassword: z.string().optional().describe("Owner password controlling permissions on the resulting PDF."),
47
+ save_path: z.string().optional().describe("Optional absolute path to also write the PDF to on disk."),
48
+ };
49
+ /** Build the standard tool result for a successful conversion. */
50
+ async function pdfToolResult(result, savePath) {
51
+ let savedNote = "";
52
+ // Resolve a disk destination from the explicit save_path or FILETOPDF_OUTPUT_DIR.
53
+ let dest = savePath;
54
+ if (!dest && process.env.FILETOPDF_OUTPUT_DIR) {
55
+ dest = path.join(process.env.FILETOPDF_OUTPUT_DIR, result.filename);
56
+ }
57
+ if (dest) {
58
+ try {
59
+ await fs.mkdir(path.dirname(dest), { recursive: true });
60
+ await fs.writeFile(dest, Buffer.from(result.pdfBase64, "base64"));
61
+ savedNote = `\nSaved to: ${dest}`;
62
+ }
63
+ catch (e) {
64
+ savedNote = `\n(Could not write to "${dest}": ${e.message})`;
65
+ }
66
+ }
67
+ const summary = `✅ Converted to PDF.\n` +
68
+ `• File: ${result.filename}\n` +
69
+ `• Pages: ${result.pages ?? "?"}\n` +
70
+ `• Size: ${result.sizeBytes} bytes\n` +
71
+ `• Credits used: ${result.creditsUsed ?? "?"} (remaining: ${result.creditsRemaining ?? "?"})` +
72
+ savedNote;
73
+ return {
74
+ content: [
75
+ { type: "text", text: summary },
76
+ {
77
+ type: "resource",
78
+ resource: {
79
+ uri: `filetopdf://output/${result.filename}`,
80
+ mimeType: "application/pdf",
81
+ blob: result.pdfBase64,
82
+ },
83
+ },
84
+ ],
85
+ };
86
+ }
87
+ /** Turn any thrown error into an MCP tool error result (never throws). */
88
+ function errorResult(err) {
89
+ const message = err instanceof FileToPdfError
90
+ ? `FileToPDF error [${err.code}]: ${err.message}`
91
+ : `Unexpected error: ${err.message}`;
92
+ return { isError: true, content: [{ type: "text", text: message }] };
93
+ }
94
+ /** Split a validated args object into {known fields, leftover options}. */
95
+ function takeOptions(args, drop) {
96
+ const opts = {};
97
+ for (const [k, v] of Object.entries(args)) {
98
+ if (!drop.includes(k))
99
+ opts[k] = v;
100
+ }
101
+ return opts;
102
+ }
103
+ /**
104
+ * Create a fully-configured FileToPDF MCP server. The transport supplies a resolver
105
+ * so each tool call reads the right API key (from env for stdio, from headers for HTTP).
106
+ */
107
+ export function createServer(getApiKey) {
108
+ const server = new McpServer({
109
+ name: "filetopdf",
110
+ version: "0.1.0",
111
+ });
112
+ const requireKey = () => {
113
+ const key = getApiKey();
114
+ if (!key) {
115
+ throw new FileToPdfError("No FileToPDF API key. Set FILETOPDF_API_KEY (stdio) or send an x-api-key header (HTTP). Get a free key at https://filetopdf.dev.", { code: "missing_api_key", httpStatus: 401 });
116
+ }
117
+ return key;
118
+ };
119
+ server.registerTool("get_account", {
120
+ title: "Get FileToPDF account status",
121
+ description: "Check the FileToPDF API key and return the plan, remaining conversion credits, and subscription status. Free — costs no credits. Use this to verify the connection works.",
122
+ inputSchema: {},
123
+ annotations: { readOnlyHint: true, openWorldHint: true },
124
+ }, async () => {
125
+ try {
126
+ const acct = await getAccount(requireKey());
127
+ const text = `FileToPDF · plan: ${acct.plan} · credits remaining: ${acct.credits_remaining} · ` +
128
+ `subscription: ${acct.subscription_status} · workspace: ${acct.workspace_id}`;
129
+ return { content: [{ type: "text", text }] };
130
+ }
131
+ catch (err) {
132
+ return errorResult(err);
133
+ }
134
+ });
135
+ server.registerTool("convert_file", {
136
+ title: "Convert a file (by URL) to PDF",
137
+ description: "Convert a file fetched from a public URL into a PDF. Auto-detects the engine from the extension: Office docs (DOCX, XLSX, PPTX, ODT, RTF, TXT, CSV…), images (PNG, JPG, WebP…), HTML, Markdown, or an existing PDF (passthrough). Costs 1 credit on success. Returns the PDF as an embedded resource.",
138
+ inputSchema: {
139
+ url: z.string().url().describe("Public http(s) URL of the file to download and convert."),
140
+ ...fileOptions,
141
+ },
142
+ annotations: { openWorldHint: true },
143
+ }, async (args) => {
144
+ try {
145
+ const { url, save_path } = args;
146
+ const options = takeOptions(args, ["url", "save_path"]);
147
+ const result = await convertFile(requireKey(), url, options);
148
+ return await pdfToolResult(result, save_path);
149
+ }
150
+ catch (err) {
151
+ return errorResult(err);
152
+ }
153
+ });
154
+ server.registerTool("convert_html", {
155
+ title: "Convert HTML to PDF",
156
+ description: "Render a raw HTML string to a PDF using Chromium, with optional CSS and layout options. Costs 1 credit on success. Returns the PDF as an embedded resource.",
157
+ inputSchema: {
158
+ html: z.string().describe("The HTML markup to render."),
159
+ css: z.string().optional().describe("Optional CSS, injected into the document <head>."),
160
+ ...chromiumOptions,
161
+ },
162
+ annotations: { openWorldHint: true },
163
+ }, async (args) => {
164
+ try {
165
+ const { html, css, save_path } = args;
166
+ const options = takeOptions(args, ["html", "css", "save_path"]);
167
+ const result = await convertHtml(requireKey(), html, css, options);
168
+ return await pdfToolResult(result, save_path);
169
+ }
170
+ catch (err) {
171
+ return errorResult(err);
172
+ }
173
+ });
174
+ server.registerTool("convert_markdown", {
175
+ title: "Convert Markdown to PDF",
176
+ description: "Render a raw Markdown string to a PDF, with optional CSS and layout options. A sensible default stylesheet is applied when no CSS is given. Costs 1 credit on success. Returns the PDF as an embedded resource.",
177
+ inputSchema: {
178
+ markdown: z.string().describe("The Markdown content to render."),
179
+ css: z.string().optional().describe("Optional CSS to style the rendered Markdown (overrides the default stylesheet)."),
180
+ ...chromiumOptions,
181
+ },
182
+ annotations: { openWorldHint: true },
183
+ }, async (args) => {
184
+ try {
185
+ const { markdown, css, save_path } = args;
186
+ const options = takeOptions(args, ["markdown", "css", "save_path"]);
187
+ const result = await convertMarkdown(requireKey(), markdown, css, options);
188
+ return await pdfToolResult(result, save_path);
189
+ }
190
+ catch (err) {
191
+ return errorResult(err);
192
+ }
193
+ });
194
+ return server;
195
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/stdio.js ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ // stdio entrypoint — the form most MCP clients (Claude Desktop, Cursor, Cline) and
3
+ // directories (Glama, mcp.so) consume via `npx filetopdf-mcp`. The FileToPDF API key
4
+ // comes from the FILETOPDF_API_KEY environment variable.
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { createServer } from "./server.js";
7
+ async function main() {
8
+ const server = createServer(() => process.env.FILETOPDF_API_KEY);
9
+ const transport = new StdioServerTransport();
10
+ await server.connect(transport);
11
+ // Never write to stdout — it's the protocol channel. Logs go to stderr.
12
+ console.error("filetopdf-mcp running on stdio");
13
+ }
14
+ main().catch((err) => {
15
+ console.error("Fatal:", err);
16
+ process.exit(1);
17
+ });
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "filetopdf-mcp",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Model Context Protocol (MCP) server for FileToPDF — convert files, HTML, and Markdown to PDF from any MCP client (Claude, Cursor, etc.). Bring your own API key.",
6
+ "keywords": [
7
+ "mcp",
8
+ "modelcontextprotocol",
9
+ "filetopdf",
10
+ "pdf",
11
+ "html-to-pdf",
12
+ "markdown-to-pdf",
13
+ "document-conversion",
14
+ "claude",
15
+ "cursor"
16
+ ],
17
+ "homepage": "https://filetopdf.dev",
18
+ "license": "MIT",
19
+ "author": "FileToPDF <support@filetopdf.dev>",
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "bin": {
24
+ "filetopdf-mcp": "dist/stdio.js"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsc",
32
+ "start": "node dist/stdio.js",
33
+ "start:http": "node dist/http.js",
34
+ "prepare": "npm run build",
35
+ "test": "node test/live-test.mjs"
36
+ },
37
+ "dependencies": {
38
+ "@modelcontextprotocol/sdk": "^1.12.0",
39
+ "zod": "^3.23.8"
40
+ },
41
+ "devDependencies": {
42
+ "typescript": "^5.5.4",
43
+ "@types/node": "^20.14.0"
44
+ }
45
+ }