pdfops-mcp 0.2.0 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PDFops (pdfops.dev)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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,133 +1,141 @@
1
1
  #!/usr/bin/env node
2
- #!/usr/bin/env node
3
- import { readFile, writeFile } from "node:fs/promises";
4
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
- import { z } from "zod";
7
- import { PdfOps, PdfOpsError } from "pdfops-sdk";
2
+ // pdfops-mcp — MCP server exposing the PDFops API as agent tools.
3
+ //
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.
12
+ //
13
+ // Env:
14
+ // PDFOPS_API_KEY optional — free key from https://pdfops.dev/pricing
15
+ // (250 req/mo; keyless works at 100 req/IP/mo)
16
+ // PDFOPS_BASE_URL optional — API origin override (testing)
17
+ import { writeFile } from 'node:fs/promises';
18
+ import { createRequire } from 'node:module';
19
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
20
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
21
+ import { z } from 'zod';
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');
8
27
  const client = new PdfOps({
9
- apiKey: process.env.PDFOPS_API_KEY,
10
- baseUrl: process.env.PDFOPS_BASE_URL,
11
- clientTag: "mcp"
12
- });
13
- const server = new McpServer({
14
- name: "pdfops",
15
- version: "0.1.1"
28
+ apiKey: process.env.PDFOPS_API_KEY,
29
+ baseUrl: process.env.PDFOPS_BASE_URL,
30
+ clientTag: 'mcp',
16
31
  });
17
- const errText = (e) => e instanceof PdfOpsError ? `PDFops API error ${e.status} (${e.code}): ${e.message}` + (e.code === "rate_limited" ? " \u2014 get a free API key (250/mo) at https://pdfops.dev/pricing and set PDFOPS_API_KEY" : "") : String(e);
18
- server.tool(
19
- "pdf_inspect",
20
- "List a PDF's AcroForm form fields \u2014 names, types, options, current values, per-field maxLength where declared \u2014 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.",
21
- { pdf_path: z.string().describe("Absolute path to the PDF to inspect") },
22
- async ({ pdf_path }) => {
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.';
35
+ const errText = (e) => e instanceof PdfOpsError
36
+ ? `PDFops API error ${e.status} (${e.code}): ${e.message}` +
37
+ (e.code === 'rate_limited'
38
+ ? ' — get a free API key (250/mo) at https://pdfops.dev/pricing and set PDFOPS_API_KEY'
39
+ : '')
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 }) => {
23
50
  try {
24
- const result = await client.inspect(await readFile(pdf_path));
25
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
26
- } catch (e) {
27
- return { content: [{ type: "text", text: errText(e) }], isError: true };
51
+ const result = await client.inspect(await resolveSource(pdf_path));
52
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
53
+ }
54
+ catch (e) {
55
+ return fail(e);
28
56
  }
29
- }
30
- );
31
- server.tool(
32
- "pdf_fill",
33
- `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).`,
34
- {
35
- pdf_path: z.string().describe("Absolute path to the template PDF"),
36
- fields: z.record(z.string()).describe("Field name \u2192 string value (from pdf_inspect's fillTemplate)"),
37
- output_path: z.string().describe("Absolute path to write the filled PDF"),
38
- flatten: z.boolean().optional().describe("Bake values into page content and drop the AcroForm so fields are no longer editable")
39
- },
40
- async ({ pdf_path, fields, output_path, flatten }) => {
57
+ });
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}`),
60
+ fields: z
61
+ .record(z.string())
62
+ .describe('Field name → string value (from pdf_inspect\'s fillTemplate)'),
63
+ output_path: z.string().optional().describe(OUTPUT_DOC),
64
+ flatten: z
65
+ .boolean()
66
+ .optional()
67
+ .describe('Bake values into page content and drop the AcroForm so fields are no longer editable'),
68
+ }, async ({ pdf_path, fields, output_path, flatten }) => {
41
69
  try {
42
- const bytes = await client.fillForm(await readFile(pdf_path), fields, { flatten });
43
- await writeFile(output_path, bytes);
44
- return {
45
- content: [
46
- { type: "text", text: `Filled PDF written to ${output_path} (${bytes.byteLength} bytes)` }
47
- ]
48
- };
49
- } catch (e) {
50
- return { content: [{ type: "text", text: errText(e) }], isError: true };
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);
72
+ }
73
+ catch (e) {
74
+ return fail(e);
51
75
  }
52
- }
53
- );
54
- server.tool(
55
- "pdf_merge",
56
- "Merge two or more PDFs into one, in the order given, and save the result.",
57
- {
58
- pdf_paths: z.array(z.string()).min(2).describe("Absolute paths of the PDFs to merge, in order"),
59
- output_path: z.string().describe("Absolute path to write the merged PDF")
60
- },
61
- async ({ pdf_paths, output_path }) => {
76
+ });
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),
80
+ }, async ({ pdf_paths, output_path }) => {
62
81
  try {
63
- const inputs = await Promise.all(pdf_paths.map((p) => readFile(p)));
64
- const bytes = await client.merge(inputs);
65
- await writeFile(output_path, bytes);
66
- return {
67
- content: [
68
- { type: "text", text: `Merged ${pdf_paths.length} PDFs into ${output_path} (${bytes.byteLength} bytes)` }
69
- ]
70
- };
71
- } catch (e) {
72
- return { content: [{ type: "text", text: errText(e) }], isError: true };
82
+ const inputs = await Promise.all(pdf_paths.map((p) => resolveSource(p)));
83
+ const bytes = await client.merge(inputs);
84
+ return await emit(bytes, 'merged.pdf', `Merged ${pdf_paths.length} PDFs`, output_path);
73
85
  }
74
- }
75
- );
76
- server.tool(
77
- "pdf_invoice",
78
- 'Generate a complete, professionally laid-out invoice PDF from structured data \u2014 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.',
79
- {
80
- invoice: z.object({
81
- from: z.union([
82
- z.string(),
83
- z.object({ name: z.string(), lines: z.array(z.string()).optional() })
84
- ]),
85
- to: z.union([
86
- z.string(),
87
- z.object({ name: z.string(), lines: z.array(z.string()).optional() })
88
- ]),
89
- items: z.array(
90
- z.object({
91
- description: z.string(),
92
- quantity: z.number().positive().optional(),
93
- unit_price: z.number().nonnegative()
94
- })
95
- ).min(1).max(100),
96
- invoice_number: z.string().optional(),
97
- date: z.string().optional().describe("Shown on the invoice; also pins metadata for determinism"),
98
- due: z.string().optional(),
99
- currency: z.string().regex(/^[A-Z]{3}$/).optional(),
100
- tax_rate: z.number().min(0).max(100).optional(),
101
- notes: z.string().max(1e3).optional()
102
- }).describe("Invoice data"),
103
- output_path: z.string().describe("Absolute path to write the invoice PDF")
104
- },
105
- async ({ invoice, output_path }) => {
86
+ catch (e) {
87
+ return fail(e);
88
+ }
89
+ });
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.', {
91
+ invoice: z
92
+ .object({
93
+ from: z.union([
94
+ z.string(),
95
+ z.object({ name: z.string(), lines: z.array(z.string()).optional() }),
96
+ ]),
97
+ to: z.union([
98
+ z.string(),
99
+ z.object({ name: z.string(), lines: z.array(z.string()).optional() }),
100
+ ]),
101
+ items: z
102
+ .array(z.object({
103
+ description: z.string(),
104
+ quantity: z.number().positive().optional(),
105
+ unit_price: z.number().nonnegative(),
106
+ }))
107
+ .min(1)
108
+ .max(100),
109
+ invoice_number: z.string().optional(),
110
+ date: z
111
+ .string()
112
+ .optional()
113
+ .describe('Shown on the invoice; also pins metadata for determinism'),
114
+ due: z.string().optional(),
115
+ currency: z.string().regex(/^[A-Z]{3}$/).optional(),
116
+ tax_rate: z.number().min(0).max(100).optional(),
117
+ notes: z.string().max(1000).optional(),
118
+ })
119
+ .describe('Invoice data'),
120
+ output_path: z.string().optional().describe(OUTPUT_DOC),
121
+ }, async ({ invoice, output_path }) => {
106
122
  try {
107
- const bytes = await client.invoice(invoice);
108
- await writeFile(output_path, bytes);
109
- return {
110
- content: [
111
- { type: "text", text: `Invoice written to ${output_path} (${bytes.byteLength} bytes)` }
112
- ]
113
- };
114
- } catch (e) {
115
- return { content: [{ type: "text", text: errText(e) }], isError: true };
123
+ const bytes = await client.invoice(invoice);
124
+ const name = invoice.invoice_number ? `invoice-${invoice.invoice_number}.pdf` : 'invoice.pdf';
125
+ return await emit(bytes, name, 'Invoice', output_path);
126
+ }
127
+ catch (e) {
128
+ return fail(e);
116
129
  }
117
- }
118
- );
119
- server.tool(
120
- "pdfops_usage",
121
- "Check the current PDFops API quota for the configured key: tier, limit, used, remaining, reset date. Requires PDFOPS_API_KEY.",
122
- {},
123
- async () => {
130
+ });
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 () => {
124
132
  try {
125
- const usage = await client.usage();
126
- return { content: [{ type: "text", text: JSON.stringify(usage, null, 2) }] };
127
- } catch (e) {
128
- return { content: [{ type: "text", text: errText(e) }], isError: true };
133
+ const usage = await client.usage();
134
+ return { content: [{ type: 'text', text: JSON.stringify(usage, null, 2) }] };
135
+ }
136
+ catch (e) {
137
+ return fail(e);
129
138
  }
130
- }
131
- );
139
+ });
132
140
  const transport = new StdioServerTransport();
133
141
  await server.connect(transport);
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.0",
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",
@@ -20,6 +20,10 @@
20
20
  "author": "PDFops <hello@pdfops.dev> (https://pdfops.dev)",
21
21
  "license": "MIT",
22
22
  "type": "module",
23
+ "scripts": {
24
+ "build": "rm -rf dist && tsc",
25
+ "prepare": "npm run build"
26
+ },
23
27
  "bin": {
24
28
  "pdfops-mcp": "./dist/index.js"
25
29
  },
@@ -28,7 +32,7 @@
28
32
  "README.md"
29
33
  ],
30
34
  "engines": {
31
- "node": ">=18"
35
+ "node": ">=20"
32
36
  },
33
37
  "dependencies": {
34
38
  "@modelcontextprotocol/sdk": "^1.0.0",
@@ -38,5 +42,9 @@
38
42
  "repository": {
39
43
  "type": "git",
40
44
  "url": "git+https://github.com/pdfops/pdfops-mcp.git"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.10.0",
48
+ "typescript": "^5.6.0"
41
49
  }
42
- }
50
+ }