@noedgeai-org/doc2x-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 +67 -0
- package/dist/config.js +94 -0
- package/dist/doc2x/client.js +121 -0
- package/dist/doc2x/convert.js +95 -0
- package/dist/doc2x/download.js +55 -0
- package/dist/doc2x/image.js +146 -0
- package/dist/doc2x/materialize.js +19 -0
- package/dist/doc2x/pdf.js +89 -0
- package/dist/errors.js +15 -0
- package/dist/index.js +29 -0
- package/dist/mcp/registerTools.js +307 -0
- package/dist/mcp/results.js +13 -0
- package/dist/utils.js +21 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Doc2x MCP Server
|
|
2
|
+
|
|
3
|
+
本项目提供一个基于 stdio 的 MCP Server,把 Doc2x v2 的 PDF/图片接口封装成语义化 tools。
|
|
4
|
+
|
|
5
|
+
## 1) 运行环境
|
|
6
|
+
|
|
7
|
+
- Node.js >= 18
|
|
8
|
+
|
|
9
|
+
## 2) 配置
|
|
10
|
+
|
|
11
|
+
通过环境变量配置:
|
|
12
|
+
|
|
13
|
+
- `DOC2X_API_KEY`:必填(形如 `sk-xxx`)
|
|
14
|
+
- `DOC2X_BASE_URL`:可选,默认 `https://v2.doc2x.noedgeai.com`
|
|
15
|
+
- `DOC2X_HTTP_TIMEOUT_MS`:可选,默认 `60000`
|
|
16
|
+
- `DOC2X_POLL_INTERVAL_MS`:可选,默认 `2000`
|
|
17
|
+
- `DOC2X_MAX_WAIT_MS`:可选,默认 `600000`
|
|
18
|
+
- `DOC2X_DOWNLOAD_URL_ALLOWLIST`:可选,默认 `".amazonaws.com.cn,.aliyuncs.com,.noedgeai.com"`;设为 `*` 可允许任意 host(不推荐)
|
|
19
|
+
|
|
20
|
+
## 3) 启动
|
|
21
|
+
|
|
22
|
+
### 方式 A:通过 npx
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"command": "npx",
|
|
27
|
+
"args": ["-y", "@noedgeai/doc2x-mcp"],
|
|
28
|
+
"env": {
|
|
29
|
+
"DOC2X_API_KEY": "sk-xxx",
|
|
30
|
+
"DOC2X_BASE_URL": "https://v2.doc2x.noedgeai.com"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### 方式 B:本地源码运行
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
cd doc2x-mcp
|
|
39
|
+
npm install
|
|
40
|
+
npm run build
|
|
41
|
+
DOC2X_API_KEY=sk-xxx npm start
|
|
42
|
+
```
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"command": "node",
|
|
46
|
+
"args": ["<ABS_PATH>/doc2x-mcp/dist/index.js"],
|
|
47
|
+
"env": {
|
|
48
|
+
"DOC2X_API_KEY": "sk-xxx",
|
|
49
|
+
"DOC2X_BASE_URL": "https://v2.doc2x.noedgeai.com"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
## 4) Tools
|
|
54
|
+
|
|
55
|
+
- `doc2x_parse_pdf_submit`
|
|
56
|
+
- `doc2x_parse_pdf_status`
|
|
57
|
+
- `doc2x_parse_pdf_wait_text`
|
|
58
|
+
- `doc2x_convert_export_submit`
|
|
59
|
+
- `doc2x_convert_export_result`
|
|
60
|
+
- `doc2x_convert_export_wait`
|
|
61
|
+
- `doc2x_download_url_to_file`
|
|
62
|
+
- `doc2x_parse_image_layout_sync`
|
|
63
|
+
- `doc2x_parse_image_layout_submit`
|
|
64
|
+
- `doc2x_parse_image_layout_status`
|
|
65
|
+
- `doc2x_parse_image_layout_wait_text`
|
|
66
|
+
- `doc2x_materialize_convert_zip`
|
|
67
|
+
- `doc2x_debug_config`
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const INLINE_DOC2X_API_KEY = "";
|
|
2
|
+
function parseDoc2xApiKey(raw) {
|
|
3
|
+
const v = String(raw).trim();
|
|
4
|
+
if (!v)
|
|
5
|
+
return "";
|
|
6
|
+
// Common misconfig: Alma/Codex passes literal "${DOC2X_API_KEY}" without expansion.
|
|
7
|
+
if (v.includes("${") && v.includes("}"))
|
|
8
|
+
return "";
|
|
9
|
+
const bearerPrefix = /^bearer\s+/i;
|
|
10
|
+
if (bearerPrefix.test(v))
|
|
11
|
+
return v.replace(bearerPrefix, "").trim();
|
|
12
|
+
return v;
|
|
13
|
+
}
|
|
14
|
+
function resolveApiKey() {
|
|
15
|
+
const inline = parseDoc2xApiKey(INLINE_DOC2X_API_KEY);
|
|
16
|
+
if (inline)
|
|
17
|
+
return { apiKey: inline, source: "inline" };
|
|
18
|
+
const env = parseDoc2xApiKey(process.env.DOC2X_API_KEY || "");
|
|
19
|
+
if (env)
|
|
20
|
+
return { apiKey: env, source: "env" };
|
|
21
|
+
return { apiKey: "", source: "missing" };
|
|
22
|
+
}
|
|
23
|
+
function getEnvInt(name, def) {
|
|
24
|
+
const raw = process.env[name];
|
|
25
|
+
if (raw == null || String(raw).trim() === "")
|
|
26
|
+
return def;
|
|
27
|
+
const n = Number(raw);
|
|
28
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
29
|
+
throw new Error(`Invalid env ${name}: ${raw}`);
|
|
30
|
+
return Math.floor(n);
|
|
31
|
+
}
|
|
32
|
+
function parsePositiveDurationMs(raw, defaultUnit) {
|
|
33
|
+
const v = String(raw).trim().toLowerCase();
|
|
34
|
+
if (!v)
|
|
35
|
+
throw new Error("empty duration");
|
|
36
|
+
const m = v.match(/^(\d+(?:\.\d+)?)(ms|s|m)?$/);
|
|
37
|
+
if (!m)
|
|
38
|
+
throw new Error(`invalid duration: ${raw}`);
|
|
39
|
+
const num = Number(m[1]);
|
|
40
|
+
if (!Number.isFinite(num) || num <= 0)
|
|
41
|
+
throw new Error(`invalid duration: ${raw}`);
|
|
42
|
+
const unit = m[2] ?? defaultUnit;
|
|
43
|
+
const ms = unit === "ms" ? num : unit === "s" ? num * 1000 : num * 60_000;
|
|
44
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
45
|
+
throw new Error(`invalid duration: ${raw}`);
|
|
46
|
+
return Math.floor(ms);
|
|
47
|
+
}
|
|
48
|
+
function resolveHttpTimeoutMs() {
|
|
49
|
+
const msRaw = process.env.DOC2X_HTTP_TIMEOUT_MS;
|
|
50
|
+
if (msRaw != null && String(msRaw).trim() !== "")
|
|
51
|
+
return parsePositiveDurationMs(msRaw, "ms");
|
|
52
|
+
const raw = process.env.DOC2X_HTTP_TIMEOUT;
|
|
53
|
+
if (raw != null && String(raw).trim() !== "")
|
|
54
|
+
return parsePositiveDurationMs(raw, "s");
|
|
55
|
+
return 60_000;
|
|
56
|
+
}
|
|
57
|
+
export const RESOLVED_KEY = resolveApiKey();
|
|
58
|
+
export const CONFIG = Object.freeze({
|
|
59
|
+
baseUrl: (process.env.DOC2X_BASE_URL || "https://v2.doc2x.noedgeai.com").replace(/\/+$/, ""),
|
|
60
|
+
apiKey: RESOLVED_KEY.apiKey,
|
|
61
|
+
httpTimeoutMs: resolveHttpTimeoutMs(),
|
|
62
|
+
pollIntervalMs: getEnvInt("DOC2X_POLL_INTERVAL_MS", 2_000),
|
|
63
|
+
maxWaitMs: getEnvInt("DOC2X_MAX_WAIT_MS", 600_000)
|
|
64
|
+
});
|
|
65
|
+
const DEFAULT_DOWNLOAD_HOST_SUFFIX_ALLOWLIST = Object.freeze([".amazonaws.com.cn", ".aliyuncs.com", ".noedgeai.com"]);
|
|
66
|
+
export function parseDownloadUrlAllowlist() {
|
|
67
|
+
const raw = String(process.env.DOC2X_DOWNLOAD_URL_ALLOWLIST || "").trim();
|
|
68
|
+
if (!raw)
|
|
69
|
+
return [...DEFAULT_DOWNLOAD_HOST_SUFFIX_ALLOWLIST];
|
|
70
|
+
if (raw === "*")
|
|
71
|
+
return ["*"];
|
|
72
|
+
return raw
|
|
73
|
+
.split(",")
|
|
74
|
+
.map((s) => s.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
}
|
|
77
|
+
export function isHostAllowedByAllowlist(hostname, allowlist) {
|
|
78
|
+
const host = hostname.toLowerCase();
|
|
79
|
+
for (const rule of allowlist) {
|
|
80
|
+
const r = rule.toLowerCase();
|
|
81
|
+
if (r === "*")
|
|
82
|
+
return true;
|
|
83
|
+
if (r.startsWith(".")) {
|
|
84
|
+
if (host.endsWith(r))
|
|
85
|
+
return true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (host === r)
|
|
89
|
+
return true;
|
|
90
|
+
if (host.endsWith("." + r))
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import { CONFIG } from "../config.js";
|
|
4
|
+
import { ToolError } from "../errors.js";
|
|
5
|
+
import { jitteredBackoffMs, sleep } from "../utils.js";
|
|
6
|
+
async function fetchJson(url, init, timeoutMs) {
|
|
7
|
+
const ctrl = new AbortController();
|
|
8
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
|
11
|
+
const text = await res.text();
|
|
12
|
+
let json = null;
|
|
13
|
+
try {
|
|
14
|
+
json = text ? JSON.parse(text) : null;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
json = null;
|
|
18
|
+
}
|
|
19
|
+
return { res, text, json };
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
clearTimeout(t);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function normalizeUrl(u) {
|
|
26
|
+
return String(u).replace(/\\u0026/g, "&");
|
|
27
|
+
}
|
|
28
|
+
export function isRetryableDoc2xBusinessCode(code) {
|
|
29
|
+
switch (code) {
|
|
30
|
+
case "parse_error":
|
|
31
|
+
case "parse_create_task_error":
|
|
32
|
+
case "parse_task_limit_exceeded":
|
|
33
|
+
case "parse_concurrency_limit":
|
|
34
|
+
case "parse_status_not_found":
|
|
35
|
+
return true;
|
|
36
|
+
default:
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function doc2xHeaders(extra) {
|
|
41
|
+
if (!CONFIG.apiKey) {
|
|
42
|
+
throw new ToolError({
|
|
43
|
+
code: "missing_api_key",
|
|
44
|
+
message: "Doc2x API key is not configured (set INLINE_DOC2X_API_KEY in src/config.ts or provide DOC2X_API_KEY env).",
|
|
45
|
+
retryable: false
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
Authorization: `Bearer ${CONFIG.apiKey}`,
|
|
50
|
+
...(extra || {})
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export async function doc2xRequestJson(method, pathname, opts) {
|
|
54
|
+
const url = new URL(CONFIG.baseUrl + pathname);
|
|
55
|
+
if (opts?.query) {
|
|
56
|
+
for (const [k, v] of Object.entries(opts.query))
|
|
57
|
+
url.searchParams.set(k, v);
|
|
58
|
+
}
|
|
59
|
+
const init = { method, headers: doc2xHeaders() };
|
|
60
|
+
if (opts?.body != null) {
|
|
61
|
+
init.headers = doc2xHeaders({ "Content-Type": "application/json" });
|
|
62
|
+
init.body = JSON.stringify(opts.body);
|
|
63
|
+
}
|
|
64
|
+
let attempt = 0;
|
|
65
|
+
while (true) {
|
|
66
|
+
const { res, json, text } = await fetchJson(url.toString(), init, CONFIG.httpTimeoutMs);
|
|
67
|
+
if (res.status === 429) {
|
|
68
|
+
await sleep(jitteredBackoffMs(attempt++));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
const snippet = text ? text.slice(0, 300) : "";
|
|
73
|
+
throw new ToolError({
|
|
74
|
+
code: `http_${res.status}`,
|
|
75
|
+
message: `Doc2x HTTP error: ${res.status} ${res.statusText}${snippet ? `; body=${JSON.stringify(snippet)}` : ""}`,
|
|
76
|
+
retryable: res.status >= 500 || res.status === 408 || res.status === 429
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (!json) {
|
|
80
|
+
throw new ToolError({ code: "invalid_json", message: `Doc2x returned non-JSON: ${text.slice(0, 200)}`, retryable: false });
|
|
81
|
+
}
|
|
82
|
+
if (json.code !== "success") {
|
|
83
|
+
const code = String(json.code || "doc2x_error");
|
|
84
|
+
throw new ToolError({
|
|
85
|
+
code,
|
|
86
|
+
message: String(json.msg || "Doc2x error"),
|
|
87
|
+
retryable: isRetryableDoc2xBusinessCode(code)
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return json.data;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function putToSignedUrl(signedUrl, filePath) {
|
|
94
|
+
const stat = await fsp.stat(filePath);
|
|
95
|
+
const body = fs.createReadStream(filePath);
|
|
96
|
+
const ctrl = new AbortController();
|
|
97
|
+
const t = setTimeout(() => ctrl.abort(), CONFIG.httpTimeoutMs);
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(signedUrl, {
|
|
100
|
+
method: "PUT",
|
|
101
|
+
body: body,
|
|
102
|
+
duplex: "half",
|
|
103
|
+
headers: {
|
|
104
|
+
"Content-Type": "application/pdf",
|
|
105
|
+
"Content-Length": String(stat.size)
|
|
106
|
+
},
|
|
107
|
+
signal: ctrl.signal
|
|
108
|
+
});
|
|
109
|
+
if (!res.ok) {
|
|
110
|
+
const txt = await res.text().catch(() => "");
|
|
111
|
+
throw new ToolError({
|
|
112
|
+
code: `put_failed_${res.status}`,
|
|
113
|
+
message: `PUT to signed url failed: ${res.status} ${res.statusText} ${txt.slice(0, 200)}`,
|
|
114
|
+
retryable: res.status >= 500 || res.status === 408
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
clearTimeout(t);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { CONFIG } from "../config.js";
|
|
3
|
+
import { ToolError } from "../errors.js";
|
|
4
|
+
import { jitteredBackoffMs, sleep } from "../utils.js";
|
|
5
|
+
import { doc2xRequestJson, normalizeUrl } from "./client.js";
|
|
6
|
+
function normalizeExportFilename(filename, to, mode) {
|
|
7
|
+
const v = String(filename).trim();
|
|
8
|
+
if (!v)
|
|
9
|
+
return v;
|
|
10
|
+
const base = path.basename(v);
|
|
11
|
+
if (mode === "raw")
|
|
12
|
+
return base;
|
|
13
|
+
if (to === "md")
|
|
14
|
+
return base.replace(/\.md$/i, "");
|
|
15
|
+
if (to === "tex")
|
|
16
|
+
return base.replace(/\.tex$/i, "");
|
|
17
|
+
if (to === "docx")
|
|
18
|
+
return base.replace(/\.docx$/i, "");
|
|
19
|
+
return base;
|
|
20
|
+
}
|
|
21
|
+
function exportUrlLooksLike(url, to) {
|
|
22
|
+
const u = String(url || "").toLowerCase();
|
|
23
|
+
if (!u)
|
|
24
|
+
return false;
|
|
25
|
+
if (to === "docx")
|
|
26
|
+
return u.includes("convert_docx");
|
|
27
|
+
return u.includes(`convert_${to}_`);
|
|
28
|
+
}
|
|
29
|
+
export async function convertExportSubmit(args) {
|
|
30
|
+
const body = {
|
|
31
|
+
uid: args.uid,
|
|
32
|
+
to: args.to,
|
|
33
|
+
formula_mode: args.formula_mode
|
|
34
|
+
};
|
|
35
|
+
if (args.merge_cross_page_forms != null)
|
|
36
|
+
body.merge_cross_page_forms = args.merge_cross_page_forms;
|
|
37
|
+
if (args.filename != null)
|
|
38
|
+
body.filename = normalizeExportFilename(args.filename, args.to, args.filename_mode ?? "auto");
|
|
39
|
+
const data = await doc2xRequestJson("POST", "/api/v2/convert/parse", { body });
|
|
40
|
+
return { uid: args.uid, status: String(data.status), url: String(data.url || "") };
|
|
41
|
+
}
|
|
42
|
+
export async function convertExportResult(uid) {
|
|
43
|
+
const data = await doc2xRequestJson("GET", "/api/v2/convert/parse/result", { query: { uid } });
|
|
44
|
+
return { uid, status: String(data.status), url: data.url ? normalizeUrl(String(data.url)) : "" };
|
|
45
|
+
}
|
|
46
|
+
export async function convertExportWaitByUid(args) {
|
|
47
|
+
const pollInterval = args.poll_interval_ms ?? CONFIG.pollIntervalMs;
|
|
48
|
+
const maxWait = args.max_wait_ms ?? CONFIG.maxWaitMs;
|
|
49
|
+
const start = Date.now();
|
|
50
|
+
let attempt = 0;
|
|
51
|
+
while (true) {
|
|
52
|
+
if (Date.now() - start > maxWait) {
|
|
53
|
+
throw new ToolError({
|
|
54
|
+
code: "timeout",
|
|
55
|
+
message: `wait timeout after ${maxWait}ms (hint: exports for the same uid should be run sequentially, not in parallel)`,
|
|
56
|
+
retryable: true,
|
|
57
|
+
uid: args.uid
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
let st;
|
|
61
|
+
try {
|
|
62
|
+
st = await convertExportResult(args.uid);
|
|
63
|
+
attempt = 0;
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
if (e instanceof ToolError && e.retryable) {
|
|
67
|
+
await sleep(jitteredBackoffMs(attempt++));
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
if (st.status === "success") {
|
|
73
|
+
if (st.url && exportUrlLooksLike(st.url, args.to))
|
|
74
|
+
return st;
|
|
75
|
+
await sleep(pollInterval);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (st.status === "failed")
|
|
79
|
+
throw new ToolError({ code: "convert_failed", message: "convert failed", retryable: true, uid: args.uid });
|
|
80
|
+
await sleep(pollInterval);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export async function convertExportWait(args) {
|
|
84
|
+
const pollInterval = args.poll_interval_ms ?? CONFIG.pollIntervalMs;
|
|
85
|
+
const maxWait = args.max_wait_ms ?? CONFIG.maxWaitMs;
|
|
86
|
+
await convertExportSubmit({
|
|
87
|
+
uid: args.uid,
|
|
88
|
+
to: args.to,
|
|
89
|
+
formula_mode: args.formula_mode,
|
|
90
|
+
filename: args.filename,
|
|
91
|
+
filename_mode: args.filename_mode,
|
|
92
|
+
merge_cross_page_forms: args.merge_cross_page_forms
|
|
93
|
+
});
|
|
94
|
+
return convertExportWaitByUid({ uid: args.uid, to: args.to, poll_interval_ms: pollInterval, max_wait_ms: maxWait });
|
|
95
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { CONFIG, isHostAllowedByAllowlist, parseDownloadUrlAllowlist } from "../config.js";
|
|
6
|
+
import { ToolError } from "../errors.js";
|
|
7
|
+
import { normalizeUrl } from "./client.js";
|
|
8
|
+
export async function downloadUrlToFile(args) {
|
|
9
|
+
const outPath = path.resolve(args.output_path);
|
|
10
|
+
await fsp.mkdir(path.dirname(outPath), { recursive: true });
|
|
11
|
+
const normalizedUrl = normalizeUrl(args.url);
|
|
12
|
+
let parsed;
|
|
13
|
+
try {
|
|
14
|
+
parsed = new URL(normalizedUrl);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new ToolError({ code: "invalid_url", message: "download failed: invalid url", retryable: false });
|
|
18
|
+
}
|
|
19
|
+
if (parsed.protocol !== "https:") {
|
|
20
|
+
throw new ToolError({ code: "unsafe_url", message: `download blocked: only https URLs are allowed (${parsed.protocol})`, retryable: false });
|
|
21
|
+
}
|
|
22
|
+
const allowlist = parseDownloadUrlAllowlist();
|
|
23
|
+
if (!isHostAllowedByAllowlist(parsed.hostname, allowlist)) {
|
|
24
|
+
throw new ToolError({
|
|
25
|
+
code: "unsafe_url",
|
|
26
|
+
message: `download blocked: host not allowed (${parsed.hostname}); set DOC2X_DOWNLOAD_URL_ALLOWLIST=\"*\" to allow any host, or provide a comma-separated allowlist`,
|
|
27
|
+
retryable: false
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const ctrl = new AbortController();
|
|
31
|
+
const t = setTimeout(() => ctrl.abort(), CONFIG.httpTimeoutMs);
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(normalizedUrl, { method: "GET", signal: ctrl.signal });
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
throw new ToolError({
|
|
36
|
+
code: `http_${res.status}`,
|
|
37
|
+
message: `download failed: ${res.status} ${res.statusText}`,
|
|
38
|
+
retryable: res.status >= 500 || res.status === 408 || res.status === 429
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (!res.body)
|
|
42
|
+
throw new ToolError({ code: "empty_body", message: "download failed: empty body", retryable: true });
|
|
43
|
+
const file = fs.createWriteStream(outPath);
|
|
44
|
+
await new Promise((resolve, reject) => {
|
|
45
|
+
file.on("error", reject);
|
|
46
|
+
file.on("finish", resolve);
|
|
47
|
+
Readable.fromWeb(res.body).on("error", reject).pipe(file);
|
|
48
|
+
});
|
|
49
|
+
const stat = await fsp.stat(outPath);
|
|
50
|
+
return { output_path: outPath, bytes_written: stat.size };
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
clearTimeout(t);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CONFIG } from "../config.js";
|
|
4
|
+
import { ToolError } from "../errors.js";
|
|
5
|
+
import { jitteredBackoffMs, sleep } from "../utils.js";
|
|
6
|
+
import { doc2xHeaders, doc2xRequestJson, isRetryableDoc2xBusinessCode } from "./client.js";
|
|
7
|
+
async function readFileChecked(filePath, maxBytes) {
|
|
8
|
+
const p = path.resolve(filePath);
|
|
9
|
+
const st = await fsp.stat(p);
|
|
10
|
+
if (st.size > maxBytes)
|
|
11
|
+
throw new ToolError({ code: "file_too_large", message: `file too large: ${st.size} bytes`, retryable: false });
|
|
12
|
+
return await fsp.readFile(p);
|
|
13
|
+
}
|
|
14
|
+
export async function parseImageLayoutSync(imagePath) {
|
|
15
|
+
const buf = await readFileChecked(imagePath, 7 * 1024 * 1024);
|
|
16
|
+
const url = CONFIG.baseUrl + "/api/v2/parse/img/layout";
|
|
17
|
+
let attempt = 0;
|
|
18
|
+
while (true) {
|
|
19
|
+
const ctrl = new AbortController();
|
|
20
|
+
const t = setTimeout(() => ctrl.abort(), CONFIG.httpTimeoutMs);
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(url, { method: "POST", headers: doc2xHeaders(), body: new Uint8Array(buf), signal: ctrl.signal });
|
|
23
|
+
if (res.status === 429) {
|
|
24
|
+
await sleep(jitteredBackoffMs(attempt++));
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const text = await res.text();
|
|
28
|
+
let json = null;
|
|
29
|
+
try {
|
|
30
|
+
json = text ? JSON.parse(text) : null;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
json = null;
|
|
34
|
+
}
|
|
35
|
+
if (!res.ok)
|
|
36
|
+
throw new ToolError({ code: `http_${res.status}`, message: `Doc2x HTTP error: ${res.status}`, retryable: res.status >= 500 || res.status === 429 });
|
|
37
|
+
if (!json || json.code !== "success") {
|
|
38
|
+
const code = String(json?.code || "doc2x_error");
|
|
39
|
+
const retryable = isRetryableDoc2xBusinessCode(code);
|
|
40
|
+
if (retryable) {
|
|
41
|
+
await sleep(jitteredBackoffMs(attempt++));
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
throw new ToolError({ code, message: String(json?.msg || "Doc2x error"), retryable });
|
|
45
|
+
}
|
|
46
|
+
return { uid: String(json.data.uid), result: json.data.result, convert_zip: json.data.convert_zip ?? null };
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
clearTimeout(t);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function parseImageLayoutSubmit(imagePath) {
|
|
54
|
+
const buf = await readFileChecked(imagePath, 7 * 1024 * 1024);
|
|
55
|
+
const url = CONFIG.baseUrl + "/api/v2/async/parse/img/layout";
|
|
56
|
+
let attempt = 0;
|
|
57
|
+
while (true) {
|
|
58
|
+
const ctrl = new AbortController();
|
|
59
|
+
const t = setTimeout(() => ctrl.abort(), CONFIG.httpTimeoutMs);
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(url, { method: "POST", headers: doc2xHeaders(), body: new Uint8Array(buf), signal: ctrl.signal });
|
|
62
|
+
if (res.status === 429) {
|
|
63
|
+
await sleep(jitteredBackoffMs(attempt++));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const text = await res.text();
|
|
67
|
+
let json = null;
|
|
68
|
+
try {
|
|
69
|
+
json = text ? JSON.parse(text) : null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
json = null;
|
|
73
|
+
}
|
|
74
|
+
if (!res.ok)
|
|
75
|
+
throw new ToolError({ code: `http_${res.status}`, message: `Doc2x HTTP error: ${res.status}`, retryable: res.status >= 500 || res.status === 429 });
|
|
76
|
+
if (!json || json.code !== "success") {
|
|
77
|
+
const code = String(json?.code || "doc2x_error");
|
|
78
|
+
const retryable = isRetryableDoc2xBusinessCode(code);
|
|
79
|
+
if (retryable) {
|
|
80
|
+
await sleep(jitteredBackoffMs(attempt++));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
throw new ToolError({ code, message: String(json?.msg || "Doc2x error"), retryable });
|
|
84
|
+
}
|
|
85
|
+
return { uid: String(json.data.uid) };
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
clearTimeout(t);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export async function parseImageLayoutStatus(uid) {
|
|
93
|
+
const data = await doc2xRequestJson("GET", "/api/v2/parse/img/layout/status", { query: { uid } });
|
|
94
|
+
return { uid, status: String(data.status), result: data.result ?? null, convert_zip: data.convert_zip ?? null };
|
|
95
|
+
}
|
|
96
|
+
export async function parseImageLayoutWaitTextByUid(args) {
|
|
97
|
+
const pollInterval = args.poll_interval_ms ?? CONFIG.pollIntervalMs;
|
|
98
|
+
const maxWait = args.max_wait_ms ?? Math.min(CONFIG.maxWaitMs, 300_000);
|
|
99
|
+
const uid = String(args.uid || "").trim();
|
|
100
|
+
if (!uid)
|
|
101
|
+
throw new ToolError({ code: "invalid_argument", message: "uid is required", retryable: false });
|
|
102
|
+
const start = Date.now();
|
|
103
|
+
let attempt = 0;
|
|
104
|
+
while (true) {
|
|
105
|
+
if (Date.now() - start > maxWait)
|
|
106
|
+
throw new ToolError({ code: "timeout", message: `wait timeout after ${maxWait}ms`, retryable: true, uid });
|
|
107
|
+
let st;
|
|
108
|
+
try {
|
|
109
|
+
st = await parseImageLayoutStatus(uid);
|
|
110
|
+
attempt = 0;
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
if (e instanceof ToolError && e.retryable) {
|
|
114
|
+
await sleep(jitteredBackoffMs(attempt++));
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
throw e;
|
|
118
|
+
}
|
|
119
|
+
if (st.status === "success") {
|
|
120
|
+
const md = String(st?.result?.pages?.[0]?.md || "");
|
|
121
|
+
return { uid, status: "success", text: md };
|
|
122
|
+
}
|
|
123
|
+
if (st.status === "failed")
|
|
124
|
+
throw new ToolError({ code: "parse_failed", message: "parse failed", retryable: true, uid });
|
|
125
|
+
await sleep(pollInterval);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export async function parseImageLayoutWaitText(args) {
|
|
129
|
+
const uid = String(args.uid || "").trim();
|
|
130
|
+
if (uid) {
|
|
131
|
+
return parseImageLayoutWaitTextByUid({ uid, poll_interval_ms: args.poll_interval_ms, max_wait_ms: args.max_wait_ms });
|
|
132
|
+
}
|
|
133
|
+
const imagePath = String(args.image_path || "").trim();
|
|
134
|
+
if (!imagePath)
|
|
135
|
+
throw new ToolError({ code: "invalid_argument", message: "Either uid or image_path is required", retryable: false });
|
|
136
|
+
const pollInterval = args.poll_interval_ms ?? CONFIG.pollIntervalMs;
|
|
137
|
+
const maxWait = args.max_wait_ms ?? Math.min(CONFIG.maxWaitMs, 300_000);
|
|
138
|
+
const useAsyncMode = args.async !== false;
|
|
139
|
+
if (!useAsyncMode) {
|
|
140
|
+
const data = await parseImageLayoutSync(imagePath);
|
|
141
|
+
const md = String(data?.result?.pages?.[0]?.md || "");
|
|
142
|
+
return { uid: data.uid, status: "success", text: md };
|
|
143
|
+
}
|
|
144
|
+
const { uid: newUid } = await parseImageLayoutSubmit(imagePath);
|
|
145
|
+
return parseImageLayoutWaitTextByUid({ uid: newUid, poll_interval_ms: pollInterval, max_wait_ms: maxWait });
|
|
146
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
function spawnUnzip(zipPath, outputDir) {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
const child = spawn("unzip", ["-o", zipPath, "-d", outputDir], { stdio: "ignore" });
|
|
7
|
+
child.on("error", () => resolve(false));
|
|
8
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export async function materializeConvertZip(args) {
|
|
12
|
+
const outDir = path.resolve(args.output_dir);
|
|
13
|
+
await fsp.mkdir(outDir, { recursive: true });
|
|
14
|
+
const buf = Buffer.from(args.convert_zip_base64, "base64");
|
|
15
|
+
const zipPath = path.join(outDir, "assets.zip");
|
|
16
|
+
await fsp.writeFile(zipPath, buf);
|
|
17
|
+
const extracted = await spawnUnzip(zipPath, outDir);
|
|
18
|
+
return { output_dir: outDir, zip_path: zipPath, extracted };
|
|
19
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CONFIG } from "../config.js";
|
|
4
|
+
import { ToolError } from "../errors.js";
|
|
5
|
+
import { jitteredBackoffMs, sleep } from "../utils.js";
|
|
6
|
+
import { doc2xRequestJson, putToSignedUrl } from "./client.js";
|
|
7
|
+
function mergePagesToText(result, joinWith) {
|
|
8
|
+
const pages = Array.isArray(result?.pages) ? result.pages.slice() : [];
|
|
9
|
+
pages.sort((a, b) => (a.page_idx ?? 0) - (b.page_idx ?? 0));
|
|
10
|
+
return pages.map((p) => String(p.md || "")).join(joinWith);
|
|
11
|
+
}
|
|
12
|
+
async function preuploadPdfWithRetry() {
|
|
13
|
+
let attempt = 0;
|
|
14
|
+
while (true) {
|
|
15
|
+
try {
|
|
16
|
+
const data = await doc2xRequestJson("POST", "/api/v2/parse/preupload");
|
|
17
|
+
return { uid: String(data.uid), url: String(data.url) };
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
if (e instanceof ToolError && e.retryable) {
|
|
21
|
+
await sleep(jitteredBackoffMs(attempt++));
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
throw e;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function parsePdfSubmit(pdfPath) {
|
|
29
|
+
const p = path.resolve(pdfPath);
|
|
30
|
+
if (!p.toLowerCase().endsWith(".pdf"))
|
|
31
|
+
throw new ToolError({ code: "invalid_argument", message: "pdf_path must end with .pdf", retryable: false });
|
|
32
|
+
await fsp.access(p);
|
|
33
|
+
let data = await preuploadPdfWithRetry();
|
|
34
|
+
try {
|
|
35
|
+
await putToSignedUrl(String(data.url), p);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
data = await preuploadPdfWithRetry();
|
|
39
|
+
await putToSignedUrl(String(data.url), p);
|
|
40
|
+
}
|
|
41
|
+
return { uid: String(data.uid) };
|
|
42
|
+
}
|
|
43
|
+
export async function parsePdfStatus(uid) {
|
|
44
|
+
const data = await doc2xRequestJson("GET", "/api/v2/parse/status", { query: { uid } });
|
|
45
|
+
return { uid, status: String(data.status), progress: Number(data.progress ?? 0), detail: String(data.detail || ""), result: data.result ?? null };
|
|
46
|
+
}
|
|
47
|
+
export async function parsePdfWaitTextByUid(args) {
|
|
48
|
+
const pollInterval = args.poll_interval_ms ?? CONFIG.pollIntervalMs;
|
|
49
|
+
const maxWait = args.max_wait_ms ?? CONFIG.maxWaitMs;
|
|
50
|
+
const joinWith = args.join_with ?? "\n\n---\n\n";
|
|
51
|
+
const uid = String(args.uid || "").trim();
|
|
52
|
+
if (!uid)
|
|
53
|
+
throw new ToolError({ code: "invalid_argument", message: "uid is required", retryable: false });
|
|
54
|
+
const start = Date.now();
|
|
55
|
+
let attempt = 0;
|
|
56
|
+
while (true) {
|
|
57
|
+
if (Date.now() - start > maxWait)
|
|
58
|
+
throw new ToolError({ code: "timeout", message: `wait timeout after ${maxWait}ms`, retryable: true, uid });
|
|
59
|
+
let st;
|
|
60
|
+
try {
|
|
61
|
+
st = await parsePdfStatus(uid);
|
|
62
|
+
attempt = 0;
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
if (e instanceof ToolError && e.retryable) {
|
|
66
|
+
await sleep(jitteredBackoffMs(attempt++));
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
throw e;
|
|
70
|
+
}
|
|
71
|
+
if (st.status === "success")
|
|
72
|
+
return { uid, status: "success", text: mergePagesToText(st.result, joinWith) };
|
|
73
|
+
if (st.status === "failed")
|
|
74
|
+
throw new ToolError({ code: "parse_failed", message: st.detail || "parse failed", retryable: true, uid });
|
|
75
|
+
await sleep(pollInterval);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function parsePdfWaitText(args) {
|
|
79
|
+
const uid = String(args.uid || "").trim();
|
|
80
|
+
if (uid) {
|
|
81
|
+
return parsePdfWaitTextByUid({ uid, poll_interval_ms: args.poll_interval_ms, max_wait_ms: args.max_wait_ms, join_with: args.join_with });
|
|
82
|
+
}
|
|
83
|
+
const pdfPath = String(args.pdf_path || "").trim();
|
|
84
|
+
if (!pdfPath) {
|
|
85
|
+
throw new ToolError({ code: "invalid_argument", message: "Either uid or pdf_path is required", retryable: false });
|
|
86
|
+
}
|
|
87
|
+
const { uid: newUid } = await parsePdfSubmit(pdfPath);
|
|
88
|
+
return parsePdfWaitTextByUid({ uid: newUid, poll_interval_ms: args.poll_interval_ms, max_wait_ms: args.max_wait_ms, join_with: args.join_with });
|
|
89
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export class ToolError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
retryable;
|
|
4
|
+
uid;
|
|
5
|
+
constructor(args) {
|
|
6
|
+
super(args.message);
|
|
7
|
+
this.name = "ToolError";
|
|
8
|
+
this.code = args.code;
|
|
9
|
+
this.retryable = args.retryable;
|
|
10
|
+
this.uid = args.uid;
|
|
11
|
+
}
|
|
12
|
+
toPayload() {
|
|
13
|
+
return { error: { code: this.code, message: this.message, retryable: this.retryable, uid: this.uid } };
|
|
14
|
+
}
|
|
15
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { registerTools } from "./mcp/registerTools.js";
|
|
9
|
+
function readPackageVersion() {
|
|
10
|
+
try {
|
|
11
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const pkgPath = path.resolve(here, "../package.json");
|
|
13
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
14
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return "0.0.0";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const server = new McpServer({ name: "doc2x-mcp", version: readPackageVersion() });
|
|
21
|
+
registerTools(server);
|
|
22
|
+
async function main() {
|
|
23
|
+
const transport = new StdioServerTransport();
|
|
24
|
+
await server.connect(transport);
|
|
25
|
+
}
|
|
26
|
+
main().catch((e) => {
|
|
27
|
+
process.stderr.write(JSON.stringify({ ts: new Date().toISOString(), err: String(e?.stack || e) }) + os.EOL);
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { CONFIG, RESOLVED_KEY, parseDownloadUrlAllowlist } from "../config.js";
|
|
5
|
+
import { convertExportResult, convertExportSubmit, convertExportWaitByUid } from "../doc2x/convert.js";
|
|
6
|
+
import { downloadUrlToFile } from "../doc2x/download.js";
|
|
7
|
+
import { parseImageLayoutStatus, parseImageLayoutSubmit, parseImageLayoutSync, parseImageLayoutWaitTextByUid } from "../doc2x/image.js";
|
|
8
|
+
import { materializeConvertZip } from "../doc2x/materialize.js";
|
|
9
|
+
import { parsePdfStatus, parsePdfSubmit, parsePdfWaitTextByUid } from "../doc2x/pdf.js";
|
|
10
|
+
import { ToolError } from "../errors.js";
|
|
11
|
+
import { asErrorResult, asJsonResult, asTextResult } from "./results.js";
|
|
12
|
+
async function fileSig(p) {
|
|
13
|
+
const absPath = path.resolve(p);
|
|
14
|
+
const st = await fsp.stat(absPath);
|
|
15
|
+
return { absPath, size: st.size, mtimeMs: st.mtimeMs };
|
|
16
|
+
}
|
|
17
|
+
function sameSig(a, b) {
|
|
18
|
+
return a.absPath === b.absPath && a.size === b.size && a.mtimeMs === b.mtimeMs;
|
|
19
|
+
}
|
|
20
|
+
export function registerTools(server) {
|
|
21
|
+
const pdfUidCache = new Map();
|
|
22
|
+
const imageUidCache = new Map();
|
|
23
|
+
const convertSubmitCache = new Set();
|
|
24
|
+
server.registerTool("doc2x_parse_pdf_submit", {
|
|
25
|
+
description: "Create a Doc2x PDF parse task for a local file and return {uid}. After this, call doc2x_parse_pdf_wait_text (with uid) or doc2x_parse_pdf_status.",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
pdf_path: z
|
|
28
|
+
.string()
|
|
29
|
+
.min(1)
|
|
30
|
+
.describe("Absolute path to a local PDF file. Use an absolute path (relative paths are resolved from the MCP server process cwd, which may be '/'). Must end with '.pdf'.")
|
|
31
|
+
}
|
|
32
|
+
}, async ({ pdf_path }) => {
|
|
33
|
+
try {
|
|
34
|
+
const sig = await fileSig(pdf_path);
|
|
35
|
+
const res = await parsePdfSubmit(pdf_path);
|
|
36
|
+
pdfUidCache.set(sig.absPath, { sig, uid: res.uid });
|
|
37
|
+
return asJsonResult(res);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
return asErrorResult(e);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
server.registerTool("doc2x_parse_pdf_status", {
|
|
44
|
+
description: "Get status/result for an existing Doc2x PDF parse task by uid.",
|
|
45
|
+
inputSchema: { uid: z.string().min(1).describe("Doc2x parse task uid returned by doc2x_parse_pdf_submit.") }
|
|
46
|
+
}, async ({ uid }) => {
|
|
47
|
+
try {
|
|
48
|
+
return asJsonResult(await parsePdfStatus(uid));
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
return asErrorResult(e);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
server.registerTool("doc2x_parse_pdf_wait_text", {
|
|
55
|
+
description: "Wait for a PDF parse task until success and return merged text. Prefer passing uid (no re-submit). If only pdf_path is provided, it will (a) reuse an in-process cached uid if available, otherwise (b) submit a new task then wait.",
|
|
56
|
+
inputSchema: {
|
|
57
|
+
uid: z.string().min(1).optional().describe("Doc2x parse task uid returned by doc2x_parse_pdf_submit."),
|
|
58
|
+
pdf_path: z
|
|
59
|
+
.string()
|
|
60
|
+
.min(1)
|
|
61
|
+
.optional()
|
|
62
|
+
.describe("Absolute path to a local PDF file. If uid is not provided, this tool will reuse cached uid (if any) or submit a new task."),
|
|
63
|
+
poll_interval_ms: z.number().int().positive().optional(),
|
|
64
|
+
max_wait_ms: z.number().int().positive().optional(),
|
|
65
|
+
join_with: z.string().optional()
|
|
66
|
+
}
|
|
67
|
+
}, async (args) => {
|
|
68
|
+
try {
|
|
69
|
+
const uid = String(args.uid || "").trim();
|
|
70
|
+
if (uid) {
|
|
71
|
+
const out = await parsePdfWaitTextByUid({
|
|
72
|
+
uid,
|
|
73
|
+
poll_interval_ms: args.poll_interval_ms,
|
|
74
|
+
max_wait_ms: args.max_wait_ms,
|
|
75
|
+
join_with: args.join_with
|
|
76
|
+
});
|
|
77
|
+
return asTextResult(out.text);
|
|
78
|
+
}
|
|
79
|
+
const pdfPath = String(args.pdf_path || "").trim();
|
|
80
|
+
if (!pdfPath)
|
|
81
|
+
throw new ToolError({ code: "invalid_argument", message: "Either uid or pdf_path is required.", retryable: false });
|
|
82
|
+
const sig = await fileSig(pdfPath);
|
|
83
|
+
const cached = pdfUidCache.get(sig.absPath);
|
|
84
|
+
const resolvedUid = cached && sameSig(cached.sig, sig) ? cached.uid : "";
|
|
85
|
+
const finalUid = resolvedUid || (await parsePdfSubmit(pdfPath)).uid;
|
|
86
|
+
pdfUidCache.set(sig.absPath, { sig, uid: finalUid });
|
|
87
|
+
const out = await parsePdfWaitTextByUid({
|
|
88
|
+
uid: finalUid,
|
|
89
|
+
poll_interval_ms: args.poll_interval_ms,
|
|
90
|
+
max_wait_ms: args.max_wait_ms,
|
|
91
|
+
join_with: args.join_with
|
|
92
|
+
});
|
|
93
|
+
return asTextResult(out.text);
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
return asErrorResult(e);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
server.registerTool("doc2x_convert_export_submit", {
|
|
100
|
+
description: "Start an export (convert) job for a parsed PDF uid. After this, poll with doc2x_convert_export_wait or doc2x_convert_export_result. Do NOT call doc2x_convert_export_submit twice for the same uid+format in parallel.",
|
|
101
|
+
inputSchema: {
|
|
102
|
+
uid: z.string().min(1).describe("Doc2x parse task uid returned by doc2x_parse_pdf_submit."),
|
|
103
|
+
to: z.enum(["md", "tex", "docx"]),
|
|
104
|
+
formula_mode: z.enum(["normal", "dollar"]),
|
|
105
|
+
filename: z
|
|
106
|
+
.string()
|
|
107
|
+
.describe("Optional output filename (for md/tex only). Tip: pass a basename WITHOUT extension to avoid getting 'name.md.md' / 'name.tex.tex'.")
|
|
108
|
+
.optional(),
|
|
109
|
+
filename_mode: z
|
|
110
|
+
.enum(["auto", "raw"])
|
|
111
|
+
.describe("How to treat filename. 'auto' strips common extensions for the target format; 'raw' passes basename as-is.")
|
|
112
|
+
.optional(),
|
|
113
|
+
merge_cross_page_forms: z.boolean().optional()
|
|
114
|
+
}
|
|
115
|
+
}, async (args) => {
|
|
116
|
+
try {
|
|
117
|
+
const key = JSON.stringify({
|
|
118
|
+
uid: args.uid,
|
|
119
|
+
to: args.to,
|
|
120
|
+
formula_mode: args.formula_mode,
|
|
121
|
+
filename: args.filename ?? null,
|
|
122
|
+
filename_mode: args.filename_mode ?? null,
|
|
123
|
+
merge_cross_page_forms: args.merge_cross_page_forms ?? null
|
|
124
|
+
});
|
|
125
|
+
const res = await convertExportSubmit(args);
|
|
126
|
+
convertSubmitCache.add(key);
|
|
127
|
+
return asJsonResult(res);
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
return asErrorResult(e);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
server.registerTool("doc2x_convert_export_result", {
|
|
134
|
+
description: "Get the latest export (convert) result for a parsed PDF uid (may contain an escaped URL).",
|
|
135
|
+
inputSchema: { uid: z.string().min(1).describe("Doc2x parse task uid returned by doc2x_parse_pdf_submit.") }
|
|
136
|
+
}, async ({ uid }) => {
|
|
137
|
+
try {
|
|
138
|
+
return asJsonResult(await convertExportResult(uid));
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
return asErrorResult(e);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
server.registerTool("doc2x_convert_export_wait", {
|
|
145
|
+
description: "Wait for an export job to finish. Prefer calling doc2x_convert_export_submit first, then wait with uid+to. For backward compatibility, if formula_mode is provided and this job was not submitted in-process, this tool will submit once then wait.",
|
|
146
|
+
inputSchema: {
|
|
147
|
+
uid: z.string().min(1).describe("Doc2x parse task uid returned by doc2x_parse_pdf_submit."),
|
|
148
|
+
to: z.enum(["md", "tex", "docx"]).describe("Expected target format. Used to verify the result URL."),
|
|
149
|
+
formula_mode: z.enum(["normal", "dollar"]).optional(),
|
|
150
|
+
filename: z.string().optional(),
|
|
151
|
+
filename_mode: z.enum(["auto", "raw"]).optional(),
|
|
152
|
+
merge_cross_page_forms: z.boolean().optional(),
|
|
153
|
+
poll_interval_ms: z.number().int().positive().optional(),
|
|
154
|
+
max_wait_ms: z.number().int().positive().optional()
|
|
155
|
+
}
|
|
156
|
+
}, async (args) => {
|
|
157
|
+
try {
|
|
158
|
+
if (args.formula_mode) {
|
|
159
|
+
const key = JSON.stringify({
|
|
160
|
+
uid: args.uid,
|
|
161
|
+
to: args.to,
|
|
162
|
+
formula_mode: args.formula_mode,
|
|
163
|
+
filename: args.filename ?? null,
|
|
164
|
+
filename_mode: args.filename_mode ?? null,
|
|
165
|
+
merge_cross_page_forms: args.merge_cross_page_forms ?? null
|
|
166
|
+
});
|
|
167
|
+
if (!convertSubmitCache.has(key)) {
|
|
168
|
+
await convertExportSubmit({
|
|
169
|
+
uid: args.uid,
|
|
170
|
+
to: args.to,
|
|
171
|
+
formula_mode: args.formula_mode,
|
|
172
|
+
filename: args.filename,
|
|
173
|
+
filename_mode: args.filename_mode,
|
|
174
|
+
merge_cross_page_forms: args.merge_cross_page_forms
|
|
175
|
+
});
|
|
176
|
+
convertSubmitCache.add(key);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return asJsonResult(await convertExportWaitByUid(args));
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
return asErrorResult(e);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
server.registerTool("doc2x_download_url_to_file", {
|
|
186
|
+
description: "Download a Doc2x-provided URL (e.g. from doc2x_convert_export_result) to a local file path.",
|
|
187
|
+
inputSchema: {
|
|
188
|
+
url: z.string().min(1).describe("A URL returned by doc2x_convert_export_result (may contain escaped '\\u0026')."),
|
|
189
|
+
output_path: z.string().min(1).describe("Absolute path for the output file. The file will be overwritten if it exists.")
|
|
190
|
+
}
|
|
191
|
+
}, async (args) => {
|
|
192
|
+
try {
|
|
193
|
+
return asJsonResult(await downloadUrlToFile(args));
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
return asErrorResult(e);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
server.registerTool("doc2x_parse_image_layout_sync", {
|
|
200
|
+
description: "Parse an image layout synchronously and return the raw Doc2x result JSON (including convert_zip when present).",
|
|
201
|
+
inputSchema: {
|
|
202
|
+
image_path: z
|
|
203
|
+
.string()
|
|
204
|
+
.min(1)
|
|
205
|
+
.describe("Absolute path to a local image file (png/jpg). Use an absolute path (relative paths are resolved from the MCP server process cwd, which may be '/').")
|
|
206
|
+
}
|
|
207
|
+
}, async ({ image_path }) => {
|
|
208
|
+
try {
|
|
209
|
+
return asJsonResult(await parseImageLayoutSync(image_path));
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
return asErrorResult(e);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
server.registerTool("doc2x_parse_image_layout_submit", {
|
|
216
|
+
description: "Create an async image-layout parse task and return {uid}. After this, call doc2x_parse_image_layout_wait_text (with uid) or doc2x_parse_image_layout_status.",
|
|
217
|
+
inputSchema: {
|
|
218
|
+
image_path: z
|
|
219
|
+
.string()
|
|
220
|
+
.min(1)
|
|
221
|
+
.describe("Absolute path to a local image file (png/jpg). Use an absolute path (relative paths are resolved from the MCP server process cwd, which may be '/').")
|
|
222
|
+
}
|
|
223
|
+
}, async ({ image_path }) => {
|
|
224
|
+
try {
|
|
225
|
+
const sig = await fileSig(image_path);
|
|
226
|
+
const res = await parseImageLayoutSubmit(image_path);
|
|
227
|
+
imageUidCache.set(sig.absPath, { sig, uid: res.uid });
|
|
228
|
+
return asJsonResult(res);
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
return asErrorResult(e);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
server.registerTool("doc2x_parse_image_layout_status", {
|
|
235
|
+
description: "Get status/result for an existing async image-layout parse task by uid.",
|
|
236
|
+
inputSchema: { uid: z.string().min(1).describe("Doc2x image-layout parse task uid returned by doc2x_parse_image_layout_submit.") }
|
|
237
|
+
}, async ({ uid }) => {
|
|
238
|
+
try {
|
|
239
|
+
return asJsonResult(await parseImageLayoutStatus(uid));
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
return asErrorResult(e);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
server.registerTool("doc2x_parse_image_layout_wait_text", {
|
|
246
|
+
description: "Wait for an image-layout parse task until success, returning first page markdown. Prefer passing uid (no re-submit). If only image_path is provided, it will (a) reuse an in-process cached uid if available, otherwise (b) submit a new async task then wait.",
|
|
247
|
+
inputSchema: {
|
|
248
|
+
uid: z.string().min(1).optional().describe("Doc2x image-layout parse task uid returned by doc2x_parse_image_layout_submit."),
|
|
249
|
+
image_path: z
|
|
250
|
+
.string()
|
|
251
|
+
.min(1)
|
|
252
|
+
.optional()
|
|
253
|
+
.describe("Absolute path to a local image file (png/jpg). Used to reuse cached uid or submit a new async task."),
|
|
254
|
+
poll_interval_ms: z.number().int().positive().optional(),
|
|
255
|
+
max_wait_ms: z.number().int().positive().optional()
|
|
256
|
+
}
|
|
257
|
+
}, async (args) => {
|
|
258
|
+
try {
|
|
259
|
+
const uid = String(args.uid || "").trim();
|
|
260
|
+
if (uid) {
|
|
261
|
+
const out = await parseImageLayoutWaitTextByUid({ uid, poll_interval_ms: args.poll_interval_ms, max_wait_ms: args.max_wait_ms });
|
|
262
|
+
return asTextResult(out.text);
|
|
263
|
+
}
|
|
264
|
+
const imagePath = String(args.image_path || "").trim();
|
|
265
|
+
if (!imagePath)
|
|
266
|
+
throw new ToolError({ code: "invalid_argument", message: "Either uid or image_path is required.", retryable: false });
|
|
267
|
+
const sig = await fileSig(imagePath);
|
|
268
|
+
const cached = imageUidCache.get(sig.absPath);
|
|
269
|
+
const resolvedUid = cached && sameSig(cached.sig, sig) ? cached.uid : "";
|
|
270
|
+
const finalUid = resolvedUid || (await parseImageLayoutSubmit(imagePath)).uid;
|
|
271
|
+
imageUidCache.set(sig.absPath, { sig, uid: finalUid });
|
|
272
|
+
const out = await parseImageLayoutWaitTextByUid({ uid: finalUid, poll_interval_ms: args.poll_interval_ms, max_wait_ms: args.max_wait_ms });
|
|
273
|
+
return asTextResult(out.text);
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
return asErrorResult(e);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
server.registerTool("doc2x_materialize_convert_zip", {
|
|
280
|
+
description: "Materialize convert_zip (base64) into output_dir. Best-effort: tries system unzip first; otherwise writes the zip file.",
|
|
281
|
+
inputSchema: { convert_zip_base64: z.string().min(1), output_dir: z.string().min(1) }
|
|
282
|
+
}, async (args) => {
|
|
283
|
+
try {
|
|
284
|
+
return asJsonResult(await materializeConvertZip({ convert_zip_base64: args.convert_zip_base64, output_dir: args.output_dir }));
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
return asErrorResult(e);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
server.registerTool("doc2x_debug_config", { description: "Debug helper: return resolved config and API key source for troubleshooting.", inputSchema: {} }, async () => {
|
|
291
|
+
try {
|
|
292
|
+
return asJsonResult({
|
|
293
|
+
baseUrl: CONFIG.baseUrl,
|
|
294
|
+
apiKeySource: RESOLVED_KEY.source,
|
|
295
|
+
apiKeyLen: CONFIG.apiKey.length,
|
|
296
|
+
apiKeyPrefix: CONFIG.apiKey ? CONFIG.apiKey.slice(0, 6) : "",
|
|
297
|
+
pollIntervalMs: CONFIG.pollIntervalMs,
|
|
298
|
+
httpTimeoutMs: CONFIG.httpTimeoutMs,
|
|
299
|
+
maxWaitMs: CONFIG.maxWaitMs,
|
|
300
|
+
downloadUrlAllowlist: parseDownloadUrlAllowlist()
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
return asErrorResult(e);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ToolError } from "../errors.js";
|
|
2
|
+
export function asTextResult(text) {
|
|
3
|
+
return { content: [{ type: "text", text }] };
|
|
4
|
+
}
|
|
5
|
+
export function asJsonResult(obj) {
|
|
6
|
+
return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] };
|
|
7
|
+
}
|
|
8
|
+
export function asErrorResult(e) {
|
|
9
|
+
const payload = e instanceof ToolError
|
|
10
|
+
? e.toPayload()
|
|
11
|
+
: { error: { code: "internal_error", message: String(e?.message || e), retryable: false } };
|
|
12
|
+
return { isError: true, content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
13
|
+
}
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function sleep(ms, signal) {
|
|
2
|
+
return new Promise((resolve, reject) => {
|
|
3
|
+
const t = setTimeout(resolve, ms);
|
|
4
|
+
if (!signal)
|
|
5
|
+
return;
|
|
6
|
+
if (signal.aborted) {
|
|
7
|
+
clearTimeout(t);
|
|
8
|
+
reject(new Error("aborted"));
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
signal.addEventListener("abort", () => {
|
|
12
|
+
clearTimeout(t);
|
|
13
|
+
reject(new Error("aborted"));
|
|
14
|
+
}, { once: true });
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export function jitteredBackoffMs(attempt) {
|
|
18
|
+
const base = Math.min(30_000, 1_000 * Math.pow(2, Math.max(0, attempt)));
|
|
19
|
+
const jitter = Math.floor(Math.random() * 250);
|
|
20
|
+
return base + jitter;
|
|
21
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@noedgeai-org/doc2x-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Doc2x MCP server (stdio, MCP SDK).",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"bin": {
|
|
12
|
+
"doc2x-mcp": "dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"./dist",
|
|
16
|
+
"./README.md",
|
|
17
|
+
"./package.json"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json",
|
|
21
|
+
"start": "node dist/index.js",
|
|
22
|
+
"prepublishOnly": "npm run build"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@modelcontextprotocol/sdk": "latest",
|
|
26
|
+
"zod": "latest"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "latest",
|
|
30
|
+
"typescript": "latest"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/NoEdgeAI/doc2x-mcp.git"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"doc2x"
|
|
41
|
+
],
|
|
42
|
+
"author": "hsn",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/NoEdgeAI/doc2x-mcp/issues"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/NoEdgeAI/doc2x-mcp#readme"
|
|
47
|
+
}
|