conv2pdf-mcp 0.0.1 → 1.0.1
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 +21 -0
- package/README.md +100 -2
- package/bin/conv2pdf-mcp.js +27 -0
- package/package.json +38 -6
- package/src/api.js +110 -0
- package/src/files.js +66 -0
- package/src/index.js +29 -0
- package/src/messages.js +119 -0
- package/src/protocol.js +206 -0
- package/src/tools.js +349 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Iris Digital
|
|
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
|
@@ -1,5 +1,103 @@
|
|
|
1
1
|
# conv2pdf-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Official [Model Context Protocol](https://modelcontextprotocol.io) server of the [conv2pdf API](https://conv2pdf.com/en/api/). Your AI assistant converts Word, Excel, PowerPoint, Pages, images and e-books to PDF, converts PDFs to Word or to images, and merges, compresses, protects, rotates or numbers the PDFs that are on your computer. Processing runs on OVHcloud servers in France: no transfer outside the EU, no US service in the chain.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[Installation](#installation) · [Tools](#tools) · [How it behaves](#how-it-behaves) · [Development](#development)
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
1. Create an account on [conv2pdf.com](https://conv2pdf.com/en/api/). The Dev plan includes 300 free conversions, valid for 12 months; see [pricing](https://conv2pdf.com/en/api/pricing/) for the paid plans.
|
|
10
|
+
2. Create an API key, which starts with `cpdf_live_`, in the **Developer** tab of your dashboard. conv2pdf shows it only once, at creation.
|
|
11
|
+
3. Add the server to your MCP client, with the key in the `CONV2PDF_API_KEY` environment variable. It needs [Node.js](https://nodejs.org) 22 or later.
|
|
12
|
+
|
|
13
|
+
In Claude Code:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
claude mcp add --env CONV2PDF_API_KEY=cpdf_live_... --transport stdio conv2pdf -- npx -y conv2pdf-mcp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
On Windows, put `cmd /c` before `npx`.
|
|
20
|
+
|
|
21
|
+
In Claude Desktop, Cursor and the other clients configured with a JSON file:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"mcpServers": {
|
|
26
|
+
"conv2pdf": {
|
|
27
|
+
"command": "npx",
|
|
28
|
+
"args": ["-y", "conv2pdf-mcp"],
|
|
29
|
+
"env": { "CONV2PDF_API_KEY": "cpdf_live_..." }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then ask for what you need: "convert report.docx to PDF", "merge these three PDFs", "compress scan.pdf and protect it with a password".
|
|
36
|
+
|
|
37
|
+
## Tools
|
|
38
|
+
|
|
39
|
+
| Tool | What it does |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `convert_to_pdf` | Word, Excel, PowerPoint, OpenDocument, Apple Pages, RTF, TXT, CSV, PNG, JPG, WebP, GIF, TIFF, HEIC or EPUB file to PDF, chosen from the extension |
|
|
42
|
+
| `convert_heic_to_jpg` | iPhone HEIC or HEIF photo to JPG |
|
|
43
|
+
| `convert_pdf_to_word` | PDF with real text to an editable DOCX document |
|
|
44
|
+
| `convert_pdf_to_images` | One PNG or JPG per page, in a ZIP file |
|
|
45
|
+
| `merge_pdfs` | Combine several PDFs into one, in the order given |
|
|
46
|
+
| `extract_pdf_pages` | Keep the pages you list, such as `1-5,7,10-12`, in a new PDF |
|
|
47
|
+
| `rotate_pdf` | Turn every page by 90, 180 or 270 degrees |
|
|
48
|
+
| `add_page_numbers` | Number the pages, as "3 / 12" or "3", bottom center, left or right |
|
|
49
|
+
| `add_watermark` | Stamp a text of up to 50 characters on every page |
|
|
50
|
+
| `compress_pdf` | Reduce the size, keeping images at 72, 150 or 300 DPI |
|
|
51
|
+
| `protect_pdf` | Set a password, and optionally forbid printing or copying |
|
|
52
|
+
| `unlock_pdf` | Remove a password you know, or the printing and copying restrictions |
|
|
53
|
+
| `get_quota` | Plan, usage and limits of the API key |
|
|
54
|
+
|
|
55
|
+
## How it behaves
|
|
56
|
+
|
|
57
|
+
- **Files stay on your computer, except for the conversion.** A tool takes the path of a file, sends the file to conv2pdf and writes the result on your disk. conv2pdf checks the content of the file, not only its name.
|
|
58
|
+
- **Nothing is overwritten.** The result goes next to the input file: `report.docx` gives `report.pdf`, and a PDF tool adds a suffix, such as `report-compressed.pdf`. When that name is taken, the server writes `report (2).pdf`. The assistant can choose the place with `output_path`; if a file is already there, the tool refuses before converting anything.
|
|
59
|
+
- **Deleted from the server.** Once the result is on your disk, the server deletes the job from conv2pdf. Should that fail, conv2pdf deletes the files after one hour.
|
|
60
|
+
- **Quota.** Each conversion counts as one in the quota of your plan, including one that conv2pdf refuses after reading the file, such as an unreadable or scanned PDF, or one with too many pages. A file refused on arrival (wrong type, too large, password missing), a refused setting (a page range that does not exist, a password that is too short) and a failure on the side of conv2pdf count for nothing, and `get_quota` is free. Files can weigh up to 10 MB on the Dev plan and 200 MB on paid plans.
|
|
61
|
+
- **Rate limit.** The API accepts 20 conversions per minute per API key. When it asks to slow down, or when its queue is full, the server waits for the delay the API gives and tries again, twice at most and only for delays of 30 seconds or less; otherwise the assistant gets the delay to wait.
|
|
62
|
+
- **Protected PDFs.** The tools that read a PDF, except `merge_pdfs`, open one that asks for a password when the assistant passes it as `password`. The result has no password. For a merge, unlock the protected PDFs first.
|
|
63
|
+
- **Pages and Numbers.** A Pages document keeps its `.pages` extension: only the name tells it from a Numbers spreadsheet, which conv2pdf does not convert, like Keynote presentations.
|
|
64
|
+
- **Errors.** A refusal comes back to the assistant as a sentence it can act on: the wrong password, the page range that does not exist, the plan limit, the key to set. A cancelled call stops the transfer and leaves no file behind.
|
|
65
|
+
- **Without a key.** The server starts and lists its tools; a call explains how to set `CONV2PDF_API_KEY`.
|
|
66
|
+
|
|
67
|
+
Every request carries `User-Agent: conv2pdf-mcp/<version>`. Mention it when you contact support: it tells your calls apart in the API's logs.
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
The server has no dependency: Node.js runs the sources as they are. It talks to its client over stdio and serves both eras of the protocol from the same process: revision 2026-07-28, where each request carries its protocol version, and the earlier revisions (2025-11-25 down to 2024-11-05), which start with an `initialize` handshake.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npm test
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
runs the tests against a fake conv2pdf API on a local port: no key and no network are needed.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npm run check-tools
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
compares the extensions `convert_to_pdf` routes on with what the live API accepts (`GET /v1/tools`).
|
|
84
|
+
|
|
85
|
+
### Releasing
|
|
86
|
+
|
|
87
|
+
1. Set the version in `package.json` and `server.json`, and date its section in `CHANGELOG.md`.
|
|
88
|
+
2. Run `npm test` and `npm run check-tools`.
|
|
89
|
+
3. Commit, then push a tag named after the version: the **Publish** workflow publishes the package to npm through Trusted Publishing, with a provenance statement.
|
|
90
|
+
4. Publish the entry of the [MCP Registry](https://registry.modelcontextprotocol.io): `mcp-publisher login http --domain conv2pdf.com`, then `mcp-publisher publish`. The name `com.conv2pdf/conv2pdf` is proved by the public key served at `https://conv2pdf.com/.well-known/mcp-registry-auth`.
|
|
91
|
+
|
|
92
|
+
## Resources
|
|
93
|
+
|
|
94
|
+
- [conv2pdf API documentation](https://conv2pdf.com/en/api/docs/)
|
|
95
|
+
- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/2026-07-28)
|
|
96
|
+
|
|
97
|
+
## Support
|
|
98
|
+
|
|
99
|
+
Open an [issue](https://github.com/jamalofski/conv2pdf-mcp/issues) or write to contact@conv2pdf.com.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main, version } from '../src/index.js';
|
|
3
|
+
|
|
4
|
+
const HELP = `conv2pdf-mcp ${version}
|
|
5
|
+
MCP server of the conv2pdf API. An MCP client starts it and talks to it over stdio:
|
|
6
|
+
|
|
7
|
+
{
|
|
8
|
+
"mcpServers": {
|
|
9
|
+
"conv2pdf": {
|
|
10
|
+
"command": "npx",
|
|
11
|
+
"args": ["-y", "conv2pdf-mcp"],
|
|
12
|
+
"env": { "CONV2PDF_API_KEY": "cpdf_live_..." }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
Documentation: https://github.com/jamalofski/conv2pdf-mcp
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
const argument = process.argv[2];
|
|
21
|
+
if (argument === '--version' || argument === '-v') {
|
|
22
|
+
process.stdout.write(`${version}\n`);
|
|
23
|
+
} else if (argument === '--help' || argument === '-h') {
|
|
24
|
+
process.stdout.write(HELP);
|
|
25
|
+
} else {
|
|
26
|
+
await main();
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conv2pdf-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Official MCP server of the conv2pdf API: an AI assistant converts Office documents, images and e-books to PDF, and merges, splits, compresses, protects or converts the PDFs of your computer. Hosted in France.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://conv2pdf.com/en/api/",
|
|
7
|
-
"
|
|
7
|
+
"mcpName": "com.conv2pdf/conv2pdf",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"conv2pdf-mcp": "bin/conv2pdf-mcp.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"mcp",
|
|
21
|
+
"mcp-server",
|
|
22
|
+
"model-context-protocol",
|
|
23
|
+
"conv2pdf",
|
|
24
|
+
"pdf",
|
|
25
|
+
"pdf conversion",
|
|
26
|
+
"word to pdf",
|
|
27
|
+
"docx to pdf",
|
|
28
|
+
"pdf to word",
|
|
29
|
+
"merge pdf",
|
|
30
|
+
"compress pdf"
|
|
31
|
+
],
|
|
32
|
+
"author": {
|
|
33
|
+
"name": "Jamal Tantaoui",
|
|
34
|
+
"email": "contact@conv2pdf.com"
|
|
35
|
+
},
|
|
8
36
|
"repository": {
|
|
9
37
|
"type": "git",
|
|
10
38
|
"url": "git+https://github.com/jamalofski/conv2pdf-mcp.git"
|
|
11
39
|
},
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/jamalofski/conv2pdf-mcp/issues"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"test": "node --test test/*.test.js",
|
|
45
|
+
"check-tools": "node scripts/check-tools.mjs"
|
|
46
|
+
}
|
|
15
47
|
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The conv2pdf API, as the tools need it: convert files, download the result, read the quota.
|
|
2
|
+
import { openAsBlob } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_BASE_URL = 'https://api.conv2pdf.com/v1';
|
|
6
|
+
|
|
7
|
+
// conv2pdf gives a conversion 90 seconds; the rest of the time is the transfer of a file
|
|
8
|
+
// of up to 200 MB, both ways, on the user's connection. A backstop: the MCP client has
|
|
9
|
+
// its own timeout and cancels the request first.
|
|
10
|
+
const REQUEST_TIMEOUT_MS = 5 * 60_000;
|
|
11
|
+
|
|
12
|
+
// The API answers 429 `rate_limited` and 503 `server_busy` with a Retry-After header:
|
|
13
|
+
// no conversion was counted, so both are safe to retry. A tool call must not hang for
|
|
14
|
+
// minutes though: beyond these bounds the error goes back to the model, with the delay.
|
|
15
|
+
const MAX_RETRIES = 2;
|
|
16
|
+
const MAX_WAIT_SECONDS = 30;
|
|
17
|
+
const DEFAULT_RETRY_AFTER_SECONDS = 30;
|
|
18
|
+
|
|
19
|
+
/** A response of the API that is not a success: HTTP status, JSON body, seconds to wait. */
|
|
20
|
+
export class ApiError extends Error {
|
|
21
|
+
constructor(status, body, retryAfter) {
|
|
22
|
+
super(`conv2pdf answered HTTP ${status}`);
|
|
23
|
+
this.status = status;
|
|
24
|
+
this.body = body;
|
|
25
|
+
this.code = typeof body.error === 'string' ? body.error : undefined;
|
|
26
|
+
this.retryAfter = retryAfter;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
31
|
+
const timer = setTimeout(resolve, ms);
|
|
32
|
+
signal?.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason); }, { once: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
async function jsonOf(response) {
|
|
36
|
+
try {
|
|
37
|
+
const body = await response.json();
|
|
38
|
+
return body !== null && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
39
|
+
} catch {
|
|
40
|
+
// Not JSON: an HTML error page from a proxy, for example.
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function retryAfterSeconds(response, body) {
|
|
46
|
+
const seconds = Number(response.headers.get('retry-after') ?? body.retry_after);
|
|
47
|
+
return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds) : DEFAULT_RETRY_AFTER_SECONDS;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* apiKey: the conv2pdf API key
|
|
52
|
+
* baseUrl: the API root, changed only by the tests
|
|
53
|
+
* userAgent: `conv2pdf-mcp/<version>`
|
|
54
|
+
*/
|
|
55
|
+
export function createClient({ apiKey, baseUrl = DEFAULT_BASE_URL, userAgent, wait = sleep }) {
|
|
56
|
+
// `makeBody` builds the body again for each attempt: a sent body cannot be sent twice.
|
|
57
|
+
async function request(method, pathname, { makeBody, signal } = {}) {
|
|
58
|
+
for (let attempt = 0; ; attempt++) {
|
|
59
|
+
const response = await fetch(`${baseUrl}${pathname}`, {
|
|
60
|
+
method,
|
|
61
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'User-Agent': userAgent },
|
|
62
|
+
body: makeBody ? await makeBody() : undefined,
|
|
63
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) : AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
64
|
+
});
|
|
65
|
+
if (response.ok) return response;
|
|
66
|
+
const body = await jsonOf(response);
|
|
67
|
+
const retryAfter = retryAfterSeconds(response, body);
|
|
68
|
+
const retryable =
|
|
69
|
+
(response.status === 429 && body.error === 'rate_limited') ||
|
|
70
|
+
(response.status === 503 && body.error === 'server_busy');
|
|
71
|
+
if (!retryable || attempt >= MAX_RETRIES || retryAfter > MAX_WAIT_SECONDS) {
|
|
72
|
+
throw new ApiError(response.status, body, retryable ? retryAfter : undefined);
|
|
73
|
+
}
|
|
74
|
+
await wait(retryAfter * 1000, signal);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
/**
|
|
80
|
+
* Sends the files to a tool. Resolves to the job of the API (`job_id`, `size_bytes`,
|
|
81
|
+
* `quota`) and to the response that streams the result.
|
|
82
|
+
*/
|
|
83
|
+
async convert(tool, files, fields, { signal } = {}) {
|
|
84
|
+
const makeBody = async () => {
|
|
85
|
+
const form = new FormData();
|
|
86
|
+
// File-backed blobs: a 200 MB file is streamed, never held in memory.
|
|
87
|
+
for (const file of files) form.append('file', await openAsBlob(file), path.basename(file));
|
|
88
|
+
for (const [name, value] of Object.entries(fields)) form.append(name, String(value));
|
|
89
|
+
return form;
|
|
90
|
+
};
|
|
91
|
+
const converted = await request('POST', `/convert/${tool}`, { makeBody, signal });
|
|
92
|
+
const job = await jsonOf(converted);
|
|
93
|
+
const download = await request('GET', `/download/${encodeURIComponent(job.job_id)}`, { signal });
|
|
94
|
+
return { job, download };
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/** Deletes the files of a job from conv2pdf. Best effort: they expire after one hour anyway. */
|
|
98
|
+
async deleteJob(jobId) {
|
|
99
|
+
try {
|
|
100
|
+
await request('DELETE', `/job/${encodeURIComponent(jobId)}`);
|
|
101
|
+
} catch {
|
|
102
|
+
// Nothing to report: the result is already on disk.
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
async quota({ signal } = {}) {
|
|
107
|
+
return jsonOf(await request('GET', '/quota', { signal }));
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
package/src/files.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The paths the model gives: where the input is read and where the result is written.
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
/** A problem with a path or an argument: its message becomes the tool error. */
|
|
7
|
+
export class InputError extends Error {}
|
|
8
|
+
|
|
9
|
+
/** An absolute path for what the model wrote: `~` is the home folder, the rest is relative to the working directory. */
|
|
10
|
+
export function resolvePath(given, argument) {
|
|
11
|
+
if (typeof given !== 'string' || given.trim() === '') {
|
|
12
|
+
throw new InputError(`"${argument}" must be the path of a file.`);
|
|
13
|
+
}
|
|
14
|
+
const inHome = given === '~' || given.startsWith('~/') || given.startsWith('~\\');
|
|
15
|
+
return path.resolve(inHome ? path.join(os.homedir(), given.slice(1)) : given);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The absolute path of an existing input file. */
|
|
19
|
+
export async function inputFile(given, argument) {
|
|
20
|
+
const file = resolvePath(given, argument);
|
|
21
|
+
let stats;
|
|
22
|
+
try {
|
|
23
|
+
stats = await fs.stat(file);
|
|
24
|
+
} catch {
|
|
25
|
+
throw new InputError(`File not found: ${file}`);
|
|
26
|
+
}
|
|
27
|
+
if (!stats.isFile()) throw new InputError(`Not a file: ${file}`);
|
|
28
|
+
return file;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates the output file, empty, before the conversion starts, and returns its path and
|
|
33
|
+
* its handle. Created exclusively: an existing file is never overwritten, and a refusal
|
|
34
|
+
* comes before any conversion is counted.
|
|
35
|
+
* requested: the `output_path` of the model, if any
|
|
36
|
+
* input: the input file the default name derives from
|
|
37
|
+
* suffix, extension: `report.pdf` + `-compressed` + `.pdf` gives `report-compressed.pdf`
|
|
38
|
+
*/
|
|
39
|
+
export async function reserveOutput({ requested, input, suffix, extension }) {
|
|
40
|
+
if (requested !== undefined) {
|
|
41
|
+
const target = resolvePath(requested, 'output_path');
|
|
42
|
+
try {
|
|
43
|
+
return { file: target, handle: await fs.open(target, 'wx') };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error.code === 'EEXIST') throw new InputError(`${target} already exists: give another "output_path". Nothing was converted.`);
|
|
46
|
+
if (error.code === 'ENOENT') throw new InputError(`The folder ${path.dirname(target)} does not exist. Nothing was converted.`);
|
|
47
|
+
throw new InputError(`Cannot write ${target}: ${error.message}. Nothing was converted.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const { dir, name } = path.parse(input);
|
|
51
|
+
for (let n = 1; ; n++) {
|
|
52
|
+
const target = path.join(dir, `${name}${suffix}${n === 1 ? '' : ` (${n})`}${extension}`);
|
|
53
|
+
try {
|
|
54
|
+
return { file: target, handle: await fs.open(target, 'wx') };
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error.code === 'EEXIST') continue;
|
|
57
|
+
throw new InputError(`Cannot write ${target}: ${error.message}. Give an "output_path" in a folder that can be written. Nothing was converted.`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Removes the reserved file when no result came to fill it. */
|
|
63
|
+
export async function discardOutput({ file, handle }) {
|
|
64
|
+
await handle.close().catch(() => {});
|
|
65
|
+
await fs.unlink(file).catch(() => {});
|
|
66
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_BASE_URL, createClient } from './api.js';
|
|
4
|
+
import { createServer, serveStdio } from './protocol.js';
|
|
5
|
+
import { INSTRUCTIONS, createTools } from './tools.js';
|
|
6
|
+
|
|
7
|
+
export const { version } = createRequire(import.meta.url)('../package.json');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runs the MCP server on the standard streams until the client closes the input.
|
|
11
|
+
* CONV2PDF_API_KEY: the API key, required to call a tool
|
|
12
|
+
* CONV2PDF_API_URL: another root for the API, for the tests
|
|
13
|
+
*/
|
|
14
|
+
export function main({ env = process.env, input = process.stdin, output = process.stdout } = {}) {
|
|
15
|
+
const apiKey = (env.CONV2PDF_API_KEY ?? '').trim();
|
|
16
|
+
const api = createClient({
|
|
17
|
+
apiKey,
|
|
18
|
+
baseUrl: env.CONV2PDF_API_URL || DEFAULT_BASE_URL,
|
|
19
|
+
userAgent: `conv2pdf-mcp/${version}`,
|
|
20
|
+
});
|
|
21
|
+
const { tools, callTool } = createTools({ api, hasKey: apiKey !== '' });
|
|
22
|
+
const server = createServer({
|
|
23
|
+
info: { name: 'conv2pdf', title: 'conv2pdf', version, websiteUrl: 'https://conv2pdf.com/en/api/' },
|
|
24
|
+
instructions: INSTRUCTIONS,
|
|
25
|
+
tools,
|
|
26
|
+
callTool,
|
|
27
|
+
});
|
|
28
|
+
return serveStdio(server, { input, output });
|
|
29
|
+
}
|
package/src/messages.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// What a refusal of the conv2pdf API means, said to the model: it reads this text, so it
|
|
2
|
+
// must tell what to change or what to report to the user.
|
|
3
|
+
|
|
4
|
+
const PRICING_URL = 'https://conv2pdf.com/en/api/pricing/';
|
|
5
|
+
export const KEYS_URL = 'https://conv2pdf.com/en/dashboard/#developer';
|
|
6
|
+
|
|
7
|
+
export const NO_KEY =
|
|
8
|
+
'No conv2pdf API key. Set the CONV2PDF_API_KEY environment variable in the configuration of this MCP server. ' +
|
|
9
|
+
`Keys are created in the Developer tab of the conv2pdf dashboard: ${KEYS_URL}`;
|
|
10
|
+
|
|
11
|
+
// A table entry for a value from the API, never a property every object inherits.
|
|
12
|
+
const own = (table, key) => (typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : undefined);
|
|
13
|
+
|
|
14
|
+
const megabytes = (bytes) => `${Math.round(bytes / (1024 * 1024))} MB`;
|
|
15
|
+
|
|
16
|
+
const OFFICE_ELSEWHERE = 'This file is an office document: use convert_to_pdf, with the extension of its real format.';
|
|
17
|
+
const IMAGE_ELSEWHERE = 'This file is an image: use convert_to_pdf, with the extension of its real format.';
|
|
18
|
+
const WEB_PAGE = 'This file contains a web page, not the expected document.';
|
|
19
|
+
|
|
20
|
+
// What conv2pdf found in a file that does not match the tool (`detected_type`).
|
|
21
|
+
const CONTENT_HINTS = {
|
|
22
|
+
pdf: 'This file is already a PDF.',
|
|
23
|
+
epub: 'This file is an EPUB e-book: give it the .epub extension and use convert_to_pdf.',
|
|
24
|
+
pages: OFFICE_ELSEWHERE,
|
|
25
|
+
docx: OFFICE_ELSEWHERE,
|
|
26
|
+
xlsx: OFFICE_ELSEWHERE,
|
|
27
|
+
pptx: OFFICE_ELSEWHERE,
|
|
28
|
+
odt: OFFICE_ELSEWHERE,
|
|
29
|
+
ods: OFFICE_ELSEWHERE,
|
|
30
|
+
odp: OFFICE_ELSEWHERE,
|
|
31
|
+
odg: OFFICE_ELSEWHERE,
|
|
32
|
+
png: IMAGE_ELSEWHERE,
|
|
33
|
+
jpeg: IMAGE_ELSEWHERE,
|
|
34
|
+
gif: IMAGE_ELSEWHERE,
|
|
35
|
+
webp: IMAGE_ELSEWHERE,
|
|
36
|
+
tiff: IMAGE_ELSEWHERE,
|
|
37
|
+
heic: 'This file is a HEIC photo: give it the .heic extension and use convert_to_pdf or convert_heic_to_jpg.',
|
|
38
|
+
html: WEB_PAGE,
|
|
39
|
+
xml: WEB_PAGE,
|
|
40
|
+
svg: WEB_PAGE,
|
|
41
|
+
text: 'This file contains plain text, not the expected document.',
|
|
42
|
+
image_corrupt: 'This image is damaged or incomplete.',
|
|
43
|
+
zip_corrupt: 'This file is damaged or incomplete.',
|
|
44
|
+
msg: 'This file is an Outlook message (.msg), not a document.',
|
|
45
|
+
iwork_numbers:
|
|
46
|
+
'This file is a Numbers spreadsheet, which conv2pdf does not convert. In Numbers, choose File > Export To > PDF.',
|
|
47
|
+
iwork_keynote:
|
|
48
|
+
'This file is a Keynote presentation, which conv2pdf does not convert. In Keynote, choose File > Export To > PDF.',
|
|
49
|
+
iwork: 'conv2pdf converts Apple Pages documents only, named with their .pages extension.',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// One entry per code of the `error` field, as text or built from the response body.
|
|
53
|
+
const MESSAGES = {
|
|
54
|
+
invalid_api_key: `conv2pdf did not accept the API key. Check CONV2PDF_API_KEY in the configuration of this MCP server. Keys are created in the Developer tab of the conv2pdf dashboard: ${KEYS_URL}`,
|
|
55
|
+
account_not_provisioned: `This API key has no conv2pdf API plan. Plans: ${PRICING_URL}`,
|
|
56
|
+
quota_exceeded: `The conv2pdf quota of this API key is used up. Paid plans renew it every month, the Dev trial does not. Plans: ${PRICING_URL}`,
|
|
57
|
+
credits_expired: `The conv2pdf trial credits have expired. Plans: ${PRICING_URL}`,
|
|
58
|
+
rate_limited: (body, retryAfter) => `The conv2pdf rate limit is reached. Try again in ${retryAfter} seconds.`,
|
|
59
|
+
server_busy: (body, retryAfter) => `The conv2pdf conversion queue is full. Try again in ${retryAfter} seconds.`,
|
|
60
|
+
file_too_large: (body) =>
|
|
61
|
+
`The file is larger than ${Number.isInteger(body.max_bytes) ? `the ${megabytes(body.max_bytes)} per file` : 'what'} this conv2pdf plan allows. Plans: ${PRICING_URL}`,
|
|
62
|
+
payload_too_large: 'The request is larger than conv2pdf accepts.',
|
|
63
|
+
plan_limit_files: `This conv2pdf plan does not merge that many files at once: 2 on the Dev plan, up to 20 on paid plans. Plans: ${PRICING_URL}`,
|
|
64
|
+
not_enough_files: 'Merging needs at least 2 PDFs.',
|
|
65
|
+
too_many_files: 'conv2pdf merges up to 20 PDFs at a time.',
|
|
66
|
+
unsupported_content: (body) =>
|
|
67
|
+
own(CONTENT_HINTS, body.detected_type) ||
|
|
68
|
+
'The content of the file does not match this tool: conv2pdf checks what the file contains, not only its name.',
|
|
69
|
+
unsupported_format: 'conv2pdf cannot read this image format. Use a PNG, JPG, WebP, GIF or TIFF image.',
|
|
70
|
+
empty_file: 'The file is empty.',
|
|
71
|
+
source_unreadable: 'conv2pdf could not read this file: it may be damaged or incomplete.',
|
|
72
|
+
field_required: (body) => `The "${typeof body.field === 'string' ? body.field : 'required'}" field is empty.`,
|
|
73
|
+
invalid_page_range: (body) =>
|
|
74
|
+
`The pages${typeof body.range === 'string' ? ` "${body.range}"` : ''} are not valid${Number.isInteger(body.total_pages) ? ` for this PDF of ${body.total_pages} page${body.total_pages === 1 ? '' : 's'}` : ''}. Use page numbers and ranges separated by commas, such as 1-5,7,10-12.`,
|
|
75
|
+
invalid_rotation: 'The rotation must be 90, 180 or 270 degrees.',
|
|
76
|
+
invalid_format: 'This format is not accepted by the tool.',
|
|
77
|
+
invalid_quality: 'The quality must be low, medium or high.',
|
|
78
|
+
password_protected: 'The file is protected with a password. For a PDF, remove it first with unlock_pdf. A DRM-protected e-book or a locked iWork document cannot be converted.',
|
|
79
|
+
needs_password: 'The PDF asks for a password to open: pass it as "password".',
|
|
80
|
+
wrong_password: 'The password does not open this PDF.',
|
|
81
|
+
password_too_short: (body) => `The password must have at least ${Number.isInteger(body.min) ? body.min : 6} characters.`,
|
|
82
|
+
password_too_long: (body) => `The password must have at most ${Number.isInteger(body.max) ? body.max : 64} characters.`,
|
|
83
|
+
pdf_already_protected: 'The PDF is already protected with a password. Remove it first with unlock_pdf.',
|
|
84
|
+
pdf_not_protected: 'The PDF has no password or restriction to remove.',
|
|
85
|
+
pdf_scanned_needs_ocr: 'This PDF is a scan without text, so it cannot be converted to Word.',
|
|
86
|
+
pdf_too_many_pages: 'The PDF has too many pages for this tool.',
|
|
87
|
+
too_many_pages: (body) =>
|
|
88
|
+
Number.isInteger(body.max_pages)
|
|
89
|
+
? `The document has more than the ${body.max_pages.toLocaleString('en-US')} pages this tool accepts.`
|
|
90
|
+
: 'The document has too many pages for this tool.',
|
|
91
|
+
unsupported_characters: 'The watermark text has no character conv2pdf can print: use Latin letters, digits and punctuation.',
|
|
92
|
+
epub_blank_output:
|
|
93
|
+
'This e-book cannot be converted: its pages are full-screen images (comics, picture books, manga), and their layout does not survive the conversion to PDF.',
|
|
94
|
+
epub_too_long: 'The e-book is longer than the 1,500 pages conv2pdf converts at once.',
|
|
95
|
+
output_too_large: 'The result is too large to be delivered.',
|
|
96
|
+
// The API refunds the quota on these two, and on a refused setting (page range, rotation,
|
|
97
|
+
// password length). A refusal that follows the reading of the file (unreadable or scanned
|
|
98
|
+
// PDF, too many pages) stays counted.
|
|
99
|
+
conversion_failed: 'conv2pdf could not process this file. The failure is on its side and is not counted against the quota.',
|
|
100
|
+
conversion_timeout: 'The conversion took too long and was stopped. It is not counted against the quota.',
|
|
101
|
+
file_expired: 'The converted file is no longer available on conv2pdf.',
|
|
102
|
+
job_deleted: 'The conversion was deleted on conv2pdf before it could be downloaded.',
|
|
103
|
+
};
|
|
104
|
+
MESSAGES.missing_bearer_token = MESSAGES.invalid_api_key;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The text of a tool error for an ApiError. A tool words a code itself through `overrides`,
|
|
108
|
+
* when the general text would point the model at the wrong fix.
|
|
109
|
+
*/
|
|
110
|
+
export function apiErrorText(error, overrides = {}) {
|
|
111
|
+
const { status, body, code, retryAfter } = error;
|
|
112
|
+
const message = own(overrides, code) ?? own(MESSAGES, code);
|
|
113
|
+
if (message) return typeof message === 'function' ? message(body, retryAfter) : message;
|
|
114
|
+
if (status === 401) return MESSAGES.invalid_api_key;
|
|
115
|
+
if (status >= 500) {
|
|
116
|
+
return 'conv2pdf is temporarily unavailable: try again in a few minutes.';
|
|
117
|
+
}
|
|
118
|
+
return code ? `conv2pdf refused the request (${code}).` : `conv2pdf returned HTTP ${status}.`;
|
|
119
|
+
}
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// The Model Context Protocol over stdio, for a server that only offers tools.
|
|
2
|
+
//
|
|
3
|
+
// No dependency and no state: one JSON-RPC message per line in, one per line out. Both
|
|
4
|
+
// protocol eras are served by the same process, chosen request by request as the
|
|
5
|
+
// specification asks of a "dual-era" server:
|
|
6
|
+
// - 2026-07-28: no handshake, each request carries its protocol version and the client
|
|
7
|
+
// capabilities in `_meta`, and `server/discover` describes the server;
|
|
8
|
+
// - 2025-11-25 and earlier: `initialize`, then plain requests.
|
|
9
|
+
// Specification: https://modelcontextprotocol.io/specification/2026-07-28
|
|
10
|
+
|
|
11
|
+
export const MODERN_VERSIONS = ['2026-07-28'];
|
|
12
|
+
export const LEGACY_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
13
|
+
export const SUPPORTED_VERSIONS = [...MODERN_VERSIONS, ...LEGACY_VERSIONS];
|
|
14
|
+
|
|
15
|
+
const META_VERSION = 'io.modelcontextprotocol/protocolVersion';
|
|
16
|
+
const META_CLIENT_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities';
|
|
17
|
+
const META_SERVER_INFO = 'io.modelcontextprotocol/serverInfo';
|
|
18
|
+
|
|
19
|
+
// JSON-RPC codes, then the one the MCP specification reserves (2026-07-28).
|
|
20
|
+
const PARSE_ERROR = -32700;
|
|
21
|
+
const INVALID_REQUEST = -32600;
|
|
22
|
+
const METHOD_NOT_FOUND = -32601;
|
|
23
|
+
const INVALID_PARAMS = -32602;
|
|
24
|
+
const INTERNAL_ERROR = -32603;
|
|
25
|
+
const UNSUPPORTED_PROTOCOL_VERSION = -32022;
|
|
26
|
+
|
|
27
|
+
// The tool list only changes with a new version of the package: a client may keep it
|
|
28
|
+
// for an hour, and it is the same for everyone.
|
|
29
|
+
const CACHE_HINTS = { ttlMs: 60 * 60 * 1000, cacheScope: 'public' };
|
|
30
|
+
|
|
31
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
32
|
+
|
|
33
|
+
const rpcError = (id, code, message, data) => ({
|
|
34
|
+
jsonrpc: '2.0',
|
|
35
|
+
id: id === undefined ? null : id,
|
|
36
|
+
error: data === undefined ? { code, message } : { code, message, data },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A tools-only MCP server.
|
|
41
|
+
* info: { name, title, version, websiteUrl }
|
|
42
|
+
* instructions: guidance for the model
|
|
43
|
+
* tools: [{ name, title, description, inputSchema, annotations }]
|
|
44
|
+
* callTool(name, args, { signal }): the tool result, { content, structuredContent?, isError? }
|
|
45
|
+
* Returns { handle(message), close() }: `handle` resolves to the response to write, or to
|
|
46
|
+
* undefined when there is none (a notification, a cancelled request).
|
|
47
|
+
*/
|
|
48
|
+
export function createServer({ info, instructions, tools, callTool }) {
|
|
49
|
+
const names = new Set(tools.map((tool) => tool.name));
|
|
50
|
+
// The requests being processed, to abort one when the client cancels it.
|
|
51
|
+
const running = new Map();
|
|
52
|
+
|
|
53
|
+
async function runTool(id, params) {
|
|
54
|
+
const name = isObject(params) ? params.name : undefined;
|
|
55
|
+
if (!names.has(name)) return { error: [INVALID_PARAMS, `Unknown tool: ${String(name).slice(0, 100)}`] };
|
|
56
|
+
const args = params.arguments === undefined ? {} : params.arguments;
|
|
57
|
+
if (!isObject(args)) return { error: [INVALID_PARAMS, 'Invalid params: "arguments" must be an object'] };
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
running.set(id, controller);
|
|
60
|
+
try {
|
|
61
|
+
const result = await callTool(name, args, { signal: controller.signal });
|
|
62
|
+
return controller.signal.aborted ? { cancelled: true } : { result };
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (controller.signal.aborted) return { cancelled: true };
|
|
65
|
+
throw error;
|
|
66
|
+
} finally {
|
|
67
|
+
running.delete(id);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function handle(message) {
|
|
72
|
+
if (!isObject(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
|
|
73
|
+
return rpcError(undefined, INVALID_REQUEST, 'Invalid request: expected one JSON-RPC 2.0 request or notification');
|
|
74
|
+
}
|
|
75
|
+
const { id, method, params } = message;
|
|
76
|
+
if (id === undefined) {
|
|
77
|
+
// On stdio a client cancels a request with a notification; the others need no action.
|
|
78
|
+
if (method === 'notifications/cancelled' && isObject(params)) running.get(params.requestId)?.abort();
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
if (typeof id !== 'string' && typeof id !== 'number') {
|
|
82
|
+
return rpcError(undefined, INVALID_REQUEST, 'Invalid request: "id" must be a string or a number');
|
|
83
|
+
}
|
|
84
|
+
if (params !== undefined && !isObject(params)) {
|
|
85
|
+
return rpcError(id, INVALID_REQUEST, 'Invalid request: "params" must be an object');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
// The body decides the era: a request that carries its version in `_meta` belongs
|
|
90
|
+
// to 2026-07-28, any other to the handshake era.
|
|
91
|
+
const meta = isObject(params) && isObject(params._meta) ? params._meta : {};
|
|
92
|
+
const claimed = meta[META_VERSION];
|
|
93
|
+
|
|
94
|
+
if (claimed !== undefined) {
|
|
95
|
+
if (typeof claimed !== 'string') {
|
|
96
|
+
return rpcError(id, INVALID_PARAMS, `Invalid params: _meta field ${META_VERSION} must be a string`);
|
|
97
|
+
}
|
|
98
|
+
if (!MODERN_VERSIONS.includes(claimed)) {
|
|
99
|
+
return rpcError(id, UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', {
|
|
100
|
+
supported: SUPPORTED_VERSIONS,
|
|
101
|
+
requested: claimed,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (!isObject(meta[META_CLIENT_CAPABILITIES])) {
|
|
105
|
+
return rpcError(id, INVALID_PARAMS, `Invalid params: missing required _meta field ${META_CLIENT_CAPABILITIES}`);
|
|
106
|
+
}
|
|
107
|
+
const complete = (result, cacheable) => ({
|
|
108
|
+
jsonrpc: '2.0',
|
|
109
|
+
id,
|
|
110
|
+
result: {
|
|
111
|
+
resultType: 'complete',
|
|
112
|
+
...result,
|
|
113
|
+
...(cacheable ? CACHE_HINTS : {}),
|
|
114
|
+
_meta: { [META_SERVER_INFO]: info },
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
if (method === 'server/discover') {
|
|
118
|
+
return complete({ supportedVersions: SUPPORTED_VERSIONS, capabilities: { tools: {} }, instructions }, true);
|
|
119
|
+
}
|
|
120
|
+
if (method === 'tools/list') return complete({ tools }, true);
|
|
121
|
+
if (method === 'tools/call') {
|
|
122
|
+
const outcome = await runTool(id, params);
|
|
123
|
+
if (outcome.cancelled) return undefined;
|
|
124
|
+
return outcome.error ? rpcError(id, ...outcome.error) : complete(outcome.result, false);
|
|
125
|
+
}
|
|
126
|
+
return rpcError(id, METHOD_NOT_FOUND, `Method not found: ${method.slice(0, 100)}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (method === 'initialize') {
|
|
130
|
+
const requested = isObject(params) ? params.protocolVersion : undefined;
|
|
131
|
+
return {
|
|
132
|
+
jsonrpc: '2.0',
|
|
133
|
+
id,
|
|
134
|
+
result: {
|
|
135
|
+
// The requested version when we speak it, else our latest of this era: the
|
|
136
|
+
// client decides whether it can do with it.
|
|
137
|
+
protocolVersion: LEGACY_VERSIONS.includes(requested) ? requested : LEGACY_VERSIONS[0],
|
|
138
|
+
capabilities: { tools: {} },
|
|
139
|
+
serverInfo: info,
|
|
140
|
+
instructions,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (method === 'ping') return { jsonrpc: '2.0', id, result: {} };
|
|
145
|
+
if (method === 'tools/list') return { jsonrpc: '2.0', id, result: { tools } };
|
|
146
|
+
if (method === 'tools/call') {
|
|
147
|
+
const outcome = await runTool(id, params);
|
|
148
|
+
if (outcome.cancelled) return undefined;
|
|
149
|
+
return outcome.error ? rpcError(id, ...outcome.error) : { jsonrpc: '2.0', id, result: outcome.result };
|
|
150
|
+
}
|
|
151
|
+
return rpcError(id, METHOD_NOT_FOUND, `Method not found: ${method.slice(0, 100)}`);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return rpcError(id, INTERNAL_ERROR, `Internal error: ${error && error.message ? error.message : 'unknown'}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Stops the requests still running: the client is gone, nobody reads their result.
|
|
158
|
+
function close() {
|
|
159
|
+
for (const controller of running.values()) controller.abort();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { handle, close };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Connects a server to a pair of streams: newline-delimited JSON-RPC, nothing else on
|
|
167
|
+
* the output. Resolves when the input ends and the last response is written.
|
|
168
|
+
*/
|
|
169
|
+
export function serveStdio(server, { input = process.stdin, output = process.stdout } = {}) {
|
|
170
|
+
return new Promise((resolve) => {
|
|
171
|
+
const pending = new Set();
|
|
172
|
+
let buffer = '';
|
|
173
|
+
let ended = false;
|
|
174
|
+
const write = (response) => output.write(`${JSON.stringify(response)}\n`);
|
|
175
|
+
const settle = () => { if (ended && pending.size === 0) resolve(); };
|
|
176
|
+
|
|
177
|
+
const dispatch = (line) => {
|
|
178
|
+
let message;
|
|
179
|
+
try {
|
|
180
|
+
message = JSON.parse(line);
|
|
181
|
+
} catch {
|
|
182
|
+
write(rpcError(undefined, PARSE_ERROR, 'Parse error: the line is not valid JSON'));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const task = server.handle(message).then((response) => { if (response) write(response); });
|
|
186
|
+
pending.add(task);
|
|
187
|
+
task.finally(() => { pending.delete(task); settle(); });
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
input.setEncoding('utf8');
|
|
191
|
+
input.on('data', (chunk) => {
|
|
192
|
+
buffer += chunk;
|
|
193
|
+
for (let end = buffer.indexOf('\n'); end !== -1; end = buffer.indexOf('\n')) {
|
|
194
|
+
const line = buffer.slice(0, end).trim();
|
|
195
|
+
buffer = buffer.slice(end + 1);
|
|
196
|
+
if (line) dispatch(line);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
// A closed input is the client's way to stop the server.
|
|
200
|
+
input.on('end', () => {
|
|
201
|
+
ended = true;
|
|
202
|
+
server.close();
|
|
203
|
+
settle();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// The tools of the server: what the model reads, and what a call does.
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
|
|
7
|
+
import { ApiError } from './api.js';
|
|
8
|
+
import { InputError, discardOutput, inputFile, reserveOutput } from './files.js';
|
|
9
|
+
import { NO_KEY, apiErrorText } from './messages.js';
|
|
10
|
+
|
|
11
|
+
export const INSTRUCTIONS =
|
|
12
|
+
'conv2pdf converts and edits files that are on this computer: Office documents, images and e-books to PDF, ' +
|
|
13
|
+
'PDF to Word or to images, and merging, extracting pages, rotating, numbering, watermarking, compressing, ' +
|
|
14
|
+
'protecting or unlocking PDFs. Give a tool the path of the file: the result is written next to it, or at ' +
|
|
15
|
+
'output_path, and its path is returned. The files are uploaded to conv2pdf, hosted in France, which deletes ' +
|
|
16
|
+
'them once the result is downloaded. Each conversion uses one unit of the quota of the API key, including one ' +
|
|
17
|
+
'that conv2pdf refuses after reading the file (an unreadable or scanned PDF, too many pages); a refused setting, ' +
|
|
18
|
+
'such as a page range that does not exist, and a failure on the side of conv2pdf are not counted. get_quota ' +
|
|
19
|
+
'tells what is left.';
|
|
20
|
+
|
|
21
|
+
// convert_to_pdf picks the conv2pdf tool from the extension of the file. These lists are
|
|
22
|
+
// the `accepted_exts` of GET /v1/tools: `npm run check-tools` compares them.
|
|
23
|
+
export const TO_PDF_TOOLS = {
|
|
24
|
+
'office-to-pdf': [
|
|
25
|
+
'.doc', '.docx', '.docm', '.odt', '.rtf', '.txt', '.xls', '.xlsx', '.xlsm', '.ods', '.csv', '.ppt', '.pptx',
|
|
26
|
+
'.pptm', '.odp', '.odg', '.sxw', '.sxc', '.sxi', '.sxd', '.pages', '.fodt', '.fods', '.fodp', '.fodg',
|
|
27
|
+
],
|
|
28
|
+
'image-to-pdf': ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.tiff', '.tif'],
|
|
29
|
+
'heic-to-pdf': ['.heic', '.heif'],
|
|
30
|
+
'epub-to-pdf': ['.epub'],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function toPdfTool(input) {
|
|
34
|
+
const extension = path.extname(input).toLowerCase();
|
|
35
|
+
for (const [tool, extensions] of Object.entries(TO_PDF_TOOLS)) {
|
|
36
|
+
if (extensions.includes(extension)) return tool;
|
|
37
|
+
}
|
|
38
|
+
throw new InputError(
|
|
39
|
+
`convert_to_pdf cannot convert ${extension ? `a ${extension} file` : 'a file without extension'}. ` +
|
|
40
|
+
`It accepts: ${Object.values(TO_PDF_TOOLS).flat().join(' ')}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const SENT = 'The file is uploaded to conv2pdf and the call counts as one conversion.';
|
|
45
|
+
|
|
46
|
+
const INPUT_PATH = {
|
|
47
|
+
type: 'string',
|
|
48
|
+
description: 'Path of the file on this computer: absolute, or relative to the working directory of the server.',
|
|
49
|
+
};
|
|
50
|
+
const OUTPUT_PATH = {
|
|
51
|
+
type: 'string',
|
|
52
|
+
description:
|
|
53
|
+
'Where to write the result. Optional: by default next to the input file, under a name that is not taken. An existing file is never overwritten.',
|
|
54
|
+
};
|
|
55
|
+
const PDF_PASSWORD = {
|
|
56
|
+
type: 'string',
|
|
57
|
+
description: 'Password of the PDF, only if it asks for one to open. The result has no password.',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// One entry per tool that converts files:
|
|
61
|
+
// api: the conv2pdf tool, or a function of the input file that names it
|
|
62
|
+
// properties, required: the arguments besides input_path and output_path
|
|
63
|
+
// fields: the form fields sent with the file, from the arguments
|
|
64
|
+
// suffix, extension: the default name of the result
|
|
65
|
+
// messages: the API error codes this tool words itself
|
|
66
|
+
const FILE_TOOLS = [
|
|
67
|
+
{
|
|
68
|
+
name: 'convert_to_pdf',
|
|
69
|
+
title: 'Convert to PDF',
|
|
70
|
+
description:
|
|
71
|
+
'Converts a document, an image or an e-book to PDF: Word, Excel, PowerPoint, OpenDocument, Apple Pages, RTF, TXT, CSV, PNG, JPG, WebP, GIF, TIFF, HEIC and EPUB. The converter is chosen from the extension of the file.',
|
|
72
|
+
api: toPdfTool,
|
|
73
|
+
extension: '.pdf',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'convert_heic_to_jpg',
|
|
77
|
+
title: 'Convert HEIC to JPG',
|
|
78
|
+
description: 'Converts an iPhone HEIC or HEIF photo to a JPG image.',
|
|
79
|
+
api: 'heic-to-jpg',
|
|
80
|
+
extension: '.jpg',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'convert_pdf_to_word',
|
|
84
|
+
title: 'Convert PDF to Word',
|
|
85
|
+
description:
|
|
86
|
+
'Converts a PDF that contains real text to an editable Word document (DOCX). A scanned PDF, made of images without text, is refused.',
|
|
87
|
+
api: 'pdf-to-word',
|
|
88
|
+
properties: { password: PDF_PASSWORD },
|
|
89
|
+
fields: (args) => ({ password: args.password }),
|
|
90
|
+
extension: '.docx',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'convert_pdf_to_images',
|
|
94
|
+
title: 'Convert PDF to Images',
|
|
95
|
+
description:
|
|
96
|
+
'Renders each page of a PDF as an image at 150 DPI. The result is one ZIP archive with an image per page. Up to 100 pages.',
|
|
97
|
+
api: 'pdf-to-image',
|
|
98
|
+
properties: {
|
|
99
|
+
format: { type: 'string', enum: ['png', 'jpg'], description: 'Format of the images. Default: png.' },
|
|
100
|
+
password: PDF_PASSWORD,
|
|
101
|
+
},
|
|
102
|
+
fields: (args) => ({ format: args.format, password: args.password }),
|
|
103
|
+
extension: '.zip',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'merge_pdfs',
|
|
107
|
+
title: 'Merge PDFs',
|
|
108
|
+
description:
|
|
109
|
+
'Combines several PDFs into one, in the order given. 2 files on the Dev plan, up to 20 on paid plans. The result is written next to the first file.',
|
|
110
|
+
api: 'merge-pdf',
|
|
111
|
+
multiple: true,
|
|
112
|
+
suffix: '-merged',
|
|
113
|
+
messages: {
|
|
114
|
+
needs_password: 'One of the PDFs is protected with a password: remove it first with unlock_pdf.',
|
|
115
|
+
password_protected: 'One of the PDFs is protected with a password: remove it first with unlock_pdf.',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'extract_pdf_pages',
|
|
120
|
+
title: 'Extract Pages From PDF',
|
|
121
|
+
description: 'Keeps some pages of a PDF in a new PDF. The pages keep the order they have in the document.',
|
|
122
|
+
api: 'split-pdf',
|
|
123
|
+
properties: {
|
|
124
|
+
pages: {
|
|
125
|
+
type: 'string',
|
|
126
|
+
description: 'Pages to keep: numbers and ranges separated by commas, such as 1-5,7,10-12.',
|
|
127
|
+
},
|
|
128
|
+
password: PDF_PASSWORD,
|
|
129
|
+
},
|
|
130
|
+
required: ['pages'],
|
|
131
|
+
fields: (args) => ({ ranges: args.pages, password: args.password }),
|
|
132
|
+
suffix: '-pages',
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'rotate_pdf',
|
|
136
|
+
title: 'Rotate PDF',
|
|
137
|
+
description: 'Rotates every page of a PDF clockwise.',
|
|
138
|
+
api: 'rotate-pdf',
|
|
139
|
+
properties: {
|
|
140
|
+
rotation: { type: 'integer', enum: [90, 180, 270], description: 'Rotation in degrees, clockwise.' },
|
|
141
|
+
password: PDF_PASSWORD,
|
|
142
|
+
},
|
|
143
|
+
required: ['rotation'],
|
|
144
|
+
fields: (args) => ({ rotation: args.rotation, password: args.password }),
|
|
145
|
+
suffix: '-rotated',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: 'add_page_numbers',
|
|
149
|
+
title: 'Add Page Numbers to PDF',
|
|
150
|
+
description: 'Prints the page number at the bottom of every page of a PDF.',
|
|
151
|
+
api: 'page-numbers-pdf',
|
|
152
|
+
properties: {
|
|
153
|
+
position: {
|
|
154
|
+
type: 'string',
|
|
155
|
+
enum: ['bottom-center', 'bottom-left', 'bottom-right'],
|
|
156
|
+
description: 'Where the number goes. Default: bottom-center.',
|
|
157
|
+
},
|
|
158
|
+
format: {
|
|
159
|
+
type: 'string',
|
|
160
|
+
enum: ['full', 'simple'],
|
|
161
|
+
description: 'full prints the page and the total, as "3 / 12"; simple prints "3". Default: full.',
|
|
162
|
+
},
|
|
163
|
+
password: PDF_PASSWORD,
|
|
164
|
+
},
|
|
165
|
+
fields: (args) => ({ position: args.position, format: args.format, password: args.password }),
|
|
166
|
+
suffix: '-numbered',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'add_watermark',
|
|
170
|
+
title: 'Add Watermark to PDF',
|
|
171
|
+
description: 'Stamps a text diagonally across every page of a PDF.',
|
|
172
|
+
api: 'watermark-pdf',
|
|
173
|
+
properties: {
|
|
174
|
+
text: {
|
|
175
|
+
type: 'string',
|
|
176
|
+
description: 'Text of the watermark, such as CONFIDENTIAL: 50 characters at most, Latin letters, digits and punctuation.',
|
|
177
|
+
},
|
|
178
|
+
password: PDF_PASSWORD,
|
|
179
|
+
},
|
|
180
|
+
required: ['text'],
|
|
181
|
+
fields: (args) => ({ text: args.text, password: args.password }),
|
|
182
|
+
suffix: '-watermarked',
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: 'compress_pdf',
|
|
186
|
+
title: 'Compress PDF',
|
|
187
|
+
description: 'Reduces the size of a PDF by lowering the resolution of its images.',
|
|
188
|
+
api: 'compress-pdf',
|
|
189
|
+
properties: {
|
|
190
|
+
quality: {
|
|
191
|
+
type: 'string',
|
|
192
|
+
enum: ['low', 'medium', 'high'],
|
|
193
|
+
description: 'Resolution kept for the images: low is 72 DPI and gives the smallest file, medium 150 DPI, high 300 DPI. Default: medium.',
|
|
194
|
+
},
|
|
195
|
+
password: PDF_PASSWORD,
|
|
196
|
+
},
|
|
197
|
+
fields: (args) => ({ quality: args.quality, password: args.password }),
|
|
198
|
+
suffix: '-compressed',
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: 'protect_pdf',
|
|
202
|
+
title: 'Protect PDF With Password',
|
|
203
|
+
description: 'Encrypts a PDF (AES-256) so that it asks for a password to open, and optionally forbids printing or copying.',
|
|
204
|
+
api: 'protect-pdf',
|
|
205
|
+
properties: {
|
|
206
|
+
password: { type: 'string', description: 'Password needed to open the PDF, 6 to 64 characters.' },
|
|
207
|
+
prevent_print: { type: 'boolean', description: 'Forbid printing. Default: false.' },
|
|
208
|
+
prevent_copy: { type: 'boolean', description: 'Forbid copying text. Default: false.' },
|
|
209
|
+
},
|
|
210
|
+
required: ['password'],
|
|
211
|
+
// The API reads any value of these two fields as a yes: they are sent only when true.
|
|
212
|
+
fields: (args) => ({
|
|
213
|
+
password: args.password,
|
|
214
|
+
prevent_print: args.prevent_print === true ? 'on' : undefined,
|
|
215
|
+
prevent_copy: args.prevent_copy === true ? 'on' : undefined,
|
|
216
|
+
}),
|
|
217
|
+
suffix: '-protected',
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: 'unlock_pdf',
|
|
221
|
+
title: 'Unlock PDF',
|
|
222
|
+
description:
|
|
223
|
+
'Removes the password and the restrictions of a PDF whose password is known. It does not find a forgotten password.',
|
|
224
|
+
api: 'unlock-pdf',
|
|
225
|
+
properties: {
|
|
226
|
+
password: {
|
|
227
|
+
type: 'string',
|
|
228
|
+
description:
|
|
229
|
+
'Current password of the PDF. Leave it out for a PDF that opens without a password but restricts printing or copying.',
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
fields: (args) => ({ password: args.password }),
|
|
233
|
+
suffix: '-unlocked',
|
|
234
|
+
messages: { needs_password: 'This PDF needs its current password to be unlocked: pass it as "password".' },
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
function definition(spec) {
|
|
239
|
+
const input = spec.multiple
|
|
240
|
+
? {
|
|
241
|
+
input_paths: {
|
|
242
|
+
type: 'array',
|
|
243
|
+
items: { type: 'string' },
|
|
244
|
+
minItems: 2,
|
|
245
|
+
maxItems: 20,
|
|
246
|
+
description: 'Paths of the PDFs on this computer, in the order they must appear in the result.',
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
: { input_path: INPUT_PATH };
|
|
250
|
+
return {
|
|
251
|
+
name: spec.name,
|
|
252
|
+
title: spec.title,
|
|
253
|
+
description: `${spec.description} ${SENT}`,
|
|
254
|
+
inputSchema: {
|
|
255
|
+
type: 'object',
|
|
256
|
+
properties: { ...input, ...spec.properties, output_path: OUTPUT_PATH },
|
|
257
|
+
required: [spec.multiple ? 'input_paths' : 'input_path', ...(spec.required ?? [])],
|
|
258
|
+
additionalProperties: false,
|
|
259
|
+
},
|
|
260
|
+
// Each call writes a new file and never replaces one; the input leaves this computer.
|
|
261
|
+
annotations: { title: spec.title, readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const GET_QUOTA = {
|
|
266
|
+
name: 'get_quota',
|
|
267
|
+
title: 'Get Quota',
|
|
268
|
+
description:
|
|
269
|
+
'Returns the plan of the conv2pdf API key and how many conversions it has used and may still use. Reading the quota is free.',
|
|
270
|
+
inputSchema: { type: 'object', additionalProperties: false },
|
|
271
|
+
annotations: { title: 'Get Quota', readOnlyHint: true, openWorldHint: false },
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const toolResult = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data });
|
|
275
|
+
// A tool error is a result, not a protocol error: it is the form the model reads, so the
|
|
276
|
+
// only one it can act on.
|
|
277
|
+
const toolError = (text) => ({ content: [{ type: 'text', text }], isError: true });
|
|
278
|
+
|
|
279
|
+
async function inputFiles(spec, args) {
|
|
280
|
+
if (!spec.multiple) return [await inputFile(args.input_path, 'input_path')];
|
|
281
|
+
if (!Array.isArray(args.input_paths) || args.input_paths.length < 2) {
|
|
282
|
+
throw new InputError('"input_paths" must list at least 2 PDFs.');
|
|
283
|
+
}
|
|
284
|
+
const files = [];
|
|
285
|
+
for (const given of args.input_paths) files.push(await inputFile(given, 'input_paths'));
|
|
286
|
+
return files;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function convertFile(spec, args, api, signal) {
|
|
290
|
+
for (const name of spec.required ?? []) {
|
|
291
|
+
if (args[name] === undefined || args[name] === '') throw new InputError(`"${name}" is required.`);
|
|
292
|
+
}
|
|
293
|
+
const inputs = await inputFiles(spec, args);
|
|
294
|
+
const tool = typeof spec.api === 'function' ? spec.api(inputs[0]) : spec.api;
|
|
295
|
+
const fields = Object.fromEntries(
|
|
296
|
+
Object.entries(spec.fields ? spec.fields(args) : {}).filter(([, value]) => value !== undefined && value !== ''),
|
|
297
|
+
);
|
|
298
|
+
const output = await reserveOutput({
|
|
299
|
+
requested: args.output_path,
|
|
300
|
+
input: inputs[0],
|
|
301
|
+
suffix: spec.suffix ?? '',
|
|
302
|
+
extension: spec.extension ?? '.pdf',
|
|
303
|
+
});
|
|
304
|
+
let job;
|
|
305
|
+
try {
|
|
306
|
+
const converted = await api.convert(tool, inputs, fields, { signal });
|
|
307
|
+
job = converted.job;
|
|
308
|
+
await pipeline(Readable.fromWeb(converted.download.body), output.handle.createWriteStream(), { signal });
|
|
309
|
+
} catch (error) {
|
|
310
|
+
await discardOutput(output);
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
await api.deleteJob(job.job_id);
|
|
314
|
+
const { size } = await fs.stat(output.file);
|
|
315
|
+
return { output_path: output.file, size_bytes: size, quota: job.quota };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* api: the conv2pdf client (api.js)
|
|
320
|
+
* hasKey: whether an API key is configured
|
|
321
|
+
* Returns the tool definitions and the function that runs a call.
|
|
322
|
+
*/
|
|
323
|
+
export function createTools({ api, hasKey }) {
|
|
324
|
+
const specs = new Map(FILE_TOOLS.map((spec) => [spec.name, spec]));
|
|
325
|
+
const tools = [...FILE_TOOLS.map(definition), GET_QUOTA];
|
|
326
|
+
|
|
327
|
+
async function callTool(name, args, { signal }) {
|
|
328
|
+
if (!hasKey) return toolError(NO_KEY);
|
|
329
|
+
const spec = specs.get(name);
|
|
330
|
+
try {
|
|
331
|
+
if (!spec) return toolResult(await api.quota({ signal }));
|
|
332
|
+
return toolResult(await convertFile(spec, args, api, signal));
|
|
333
|
+
} catch (error) {
|
|
334
|
+
if (error instanceof InputError) return toolError(error.message);
|
|
335
|
+
if (error instanceof ApiError) return toolError(apiErrorText(error, spec?.messages));
|
|
336
|
+
if (error.name === 'TimeoutError') return toolError('conv2pdf did not answer in time. Try again in a moment.');
|
|
337
|
+
if (error instanceof TypeError && error.message === 'fetch failed') {
|
|
338
|
+
return toolError('Could not reach conv2pdf. Check the network connection and try again.');
|
|
339
|
+
}
|
|
340
|
+
if (typeof error.code === 'string' && error.syscall) {
|
|
341
|
+
return toolError(`Could not write the result: ${error.message}`);
|
|
342
|
+
}
|
|
343
|
+
// A call cancelled by the client ends here too: the protocol layer sends nothing back.
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { tools, callTool };
|
|
349
|
+
}
|