doc-toolkit-mcp 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 B2B增长工坊
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.
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.5
2
+ Name: doc-toolkit-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for local document work: convert to Markdown, OCR scanned files, chunk for retrieval, and split or merge PDFs
5
+ Project-URL: Homepage, https://open.workbuddy.cn
6
+ Author: B2B增长工坊
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: markdown,mcp,ocr,pdf,rag
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: jieba>=0.42
15
+ Requires-Dist: mcp>=1.2.0
16
+ Requires-Dist: openpyxl>=3.1
17
+ Requires-Dist: pymupdf>=1.24
18
+ Requires-Dist: pypdf>=4.0
19
+ Requires-Dist: python-docx>=1.1
20
+ Requires-Dist: python-pptx>=0.6.23
21
+ Provides-Extra: ocr
22
+ Requires-Dist: rapidocr-onnxruntime>=1.3; extra == 'ocr'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # doc-toolkit-mcp
26
+
27
+ MCP server for local document work. **Everything runs on your machine** — no upload, no API key, no account.
28
+
29
+ | Tool | What it does |
30
+ |---|---|
31
+ | `convert_to_markdown` | PDF / DOCX / PPTX / XLSX → Markdown, tables preserved |
32
+ | `ocr_extract` | Images and scanned PDFs → text (optional OCR extra) |
33
+ | `split_chunks` | Split a document into retrieval-sized chunks |
34
+ | `pdf_merge` | Merge several PDFs in order |
35
+ | `pdf_split` | Split by page count, or extract a page range |
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ uvx doc-toolkit-mcp
41
+ ```
42
+
43
+ With OCR support:
44
+
45
+ ```bash
46
+ uvx --with rapidocr-onnxruntime doc-toolkit-mcp
47
+ ```
48
+
49
+ ## MCP client config
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "doc-toolkit": {
55
+ "command": "uvx",
56
+ "args": ["doc-toolkit-mcp"]
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ ## Behaviour notes
63
+
64
+ - `convert_to_markdown` reads the text layer only; scanned PDFs come back empty and should go through `ocr_extract`.
65
+ - `ocr_extract` checks each PDF page for a text layer first and only OCRs pages that lack one.
66
+ - `split_chunks` keeps the nearest heading with every chunk so retrieved passages carry context.
67
+ - All paths are absolute paths on the machine running the client.
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,47 @@
1
+ # doc-toolkit-mcp
2
+
3
+ MCP server for local document work. **Everything runs on your machine** — no upload, no API key, no account.
4
+
5
+ | Tool | What it does |
6
+ |---|---|
7
+ | `convert_to_markdown` | PDF / DOCX / PPTX / XLSX → Markdown, tables preserved |
8
+ | `ocr_extract` | Images and scanned PDFs → text (optional OCR extra) |
9
+ | `split_chunks` | Split a document into retrieval-sized chunks |
10
+ | `pdf_merge` | Merge several PDFs in order |
11
+ | `pdf_split` | Split by page count, or extract a page range |
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ uvx doc-toolkit-mcp
17
+ ```
18
+
19
+ With OCR support:
20
+
21
+ ```bash
22
+ uvx --with rapidocr-onnxruntime doc-toolkit-mcp
23
+ ```
24
+
25
+ ## MCP client config
26
+
27
+ ```json
28
+ {
29
+ "mcpServers": {
30
+ "doc-toolkit": {
31
+ "command": "uvx",
32
+ "args": ["doc-toolkit-mcp"]
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## Behaviour notes
39
+
40
+ - `convert_to_markdown` reads the text layer only; scanned PDFs come back empty and should go through `ocr_extract`.
41
+ - `ocr_extract` checks each PDF page for a text layer first and only OCRs pages that lack one.
42
+ - `split_chunks` keeps the nearest heading with every chunk so retrieved passages carry context.
43
+ - All paths are absolute paths on the machine running the client.
44
+
45
+ ## License
46
+
47
+ MIT
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "doc-toolkit-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for local document work: convert to Markdown, OCR scanned files, chunk for retrieval, and split or merge PDFs"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "B2B增长工坊" }]
13
+ keywords = ["mcp", "pdf", "ocr", "markdown", "rag"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = [
20
+ "mcp>=1.2.0",
21
+ "pymupdf>=1.24",
22
+ "python-docx>=1.1",
23
+ "python-pptx>=0.6.23",
24
+ "openpyxl>=3.1",
25
+ "pypdf>=4.0",
26
+ "jieba>=0.42",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ ocr = ["rapidocr-onnxruntime>=1.3"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://open.workbuddy.cn"
34
+
35
+ [project.scripts]
36
+ doc-toolkit-mcp = "doc_toolkit_mcp.server:main"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/doc_toolkit_mcp"]
@@ -0,0 +1,3 @@
1
+ """Local document toolkit MCP server."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,346 @@
1
+ """Local document toolkit MCP server.
2
+
3
+ Everything runs on the user's machine — no upload, no API key.
4
+
5
+ Tools:
6
+ - ``convert_to_markdown`` PDF / DOCX / PPTX / XLSX → Markdown
7
+ - ``ocr_extract`` images and scanned PDFs → text (needs rapidocr-onnxruntime)
8
+ - ``split_chunks`` split a document into retrieval-sized chunks
9
+ - ``pdf_merge`` merge several PDFs
10
+ - ``pdf_split`` split a PDF by page count or page range
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from pathlib import Path
16
+
17
+ try: # mcp SDK v1
18
+ from mcp.server.fastmcp import FastMCP
19
+ except ImportError: # mcp SDK v2 renamed FastMCP to MCPServer; decorator API unchanged
20
+ from mcp.server.mcpserver import MCPServer as FastMCP
21
+
22
+ mcp = FastMCP("doc-toolkit")
23
+
24
+ TEXT_EXT = {".md", ".txt", ".csv"}
25
+
26
+
27
+ # ── extraction ───────────────────────────────────────────────
28
+
29
+ def _fmt_table(rows: list[list[str]]) -> str:
30
+ if not rows:
31
+ return ""
32
+ width = max(len(r) for r in rows)
33
+ rows = [r + [""] * (width - len(r)) for r in rows]
34
+ lines = ["| " + " | ".join(rows[0]) + " |", "|" + "|".join(["---"] * width) + "|"]
35
+ lines += ["| " + " | ".join(r) + " |" for r in rows[1:]]
36
+ return "\n".join(lines)
37
+
38
+
39
+ def _from_pdf(path: Path) -> str:
40
+ import fitz
41
+
42
+ parts = []
43
+ with fitz.open(path) as doc:
44
+ for i, page in enumerate(doc, 1):
45
+ text = page.get_text("text").strip()
46
+ if text:
47
+ parts.append(f"\n\n<!-- page {i} -->\n\n{text}")
48
+ return "".join(parts)
49
+
50
+
51
+ def _from_docx(path: Path) -> str:
52
+ from docx import Document
53
+
54
+ doc = Document(str(path))
55
+ out = []
56
+ for p in doc.paragraphs:
57
+ t = p.text.strip()
58
+ if not t:
59
+ continue
60
+ style = (p.style.name or "").lower()
61
+ if style.startswith("heading"):
62
+ lvl = "".join(ch for ch in style if ch.isdigit()) or "1"
63
+ out.append("#" * min(int(lvl), 6) + " " + t)
64
+ elif style.startswith("title"):
65
+ out.append("# " + t)
66
+ else:
67
+ out.append(t)
68
+ for ti, table in enumerate(doc.tables, 1):
69
+ rows = [[c.text.strip().replace("\n", " ") for c in r.cells] for r in table.rows]
70
+ if rows:
71
+ out.append(f"**表 {ti}**\n\n" + _fmt_table(rows))
72
+ return "\n\n".join(out)
73
+
74
+
75
+ def _from_pptx(path: Path) -> str:
76
+ from pptx import Presentation
77
+
78
+ prs = Presentation(str(path))
79
+ out = []
80
+ for i, slide in enumerate(prs.slides, 1):
81
+ out.append(f"\n## Slide {i}\n")
82
+ for shape in slide.shapes:
83
+ if shape.has_text_frame and shape.text_frame.text.strip():
84
+ out.append(shape.text_frame.text.strip())
85
+ if getattr(shape, "has_table", False):
86
+ rows = [[c.text.strip() for c in r.cells] for r in shape.table.rows]
87
+ if rows:
88
+ out.append(_fmt_table(rows))
89
+ return "\n\n".join(out)
90
+
91
+
92
+ def _from_xlsx(path: Path) -> str:
93
+ from openpyxl import load_workbook
94
+
95
+ wb = load_workbook(str(path), read_only=True, data_only=True)
96
+ out = []
97
+ for ws in wb.worksheets:
98
+ out.append(f"\n## {ws.title}\n")
99
+ rows = []
100
+ for row in ws.iter_rows(values_only=True):
101
+ if any(v not in (None, "") for v in row):
102
+ rows.append([" " if v is None else str(v).strip() for v in row])
103
+ if rows:
104
+ out.append(_fmt_table(rows))
105
+ wb.close()
106
+ return "\n\n".join(out)
107
+
108
+
109
+ def _extract(path: Path) -> str:
110
+ ext = path.suffix.lower()
111
+ if ext == ".pdf":
112
+ return _from_pdf(path)
113
+ if ext == ".docx":
114
+ return _from_docx(path)
115
+ if ext == ".pptx":
116
+ return _from_pptx(path)
117
+ if ext in (".xlsx", ".xlsm"):
118
+ return _from_xlsx(path)
119
+ if ext in TEXT_EXT:
120
+ return path.read_text(encoding="utf-8", errors="replace")
121
+ raise ValueError(f"unsupported format: {ext}")
122
+
123
+
124
+ # ── chunking ─────────────────────────────────────────────────
125
+
126
+ def _token_len(text: str) -> int:
127
+ try:
128
+ import logging
129
+
130
+ import jieba
131
+
132
+ # jieba 首次加载会把 DEBUG 日志打到 stderr,污染 MCP 输出
133
+ jieba.setLogLevel(logging.WARNING)
134
+ return len([w for w in jieba.lcut(text) if w.strip()])
135
+ except Exception:
136
+ return len(text)
137
+
138
+
139
+ def _chunk(md: str, size: int, overlap: int):
140
+ sections, head, buf = [], "", []
141
+ for line in md.split("\n"):
142
+ m = re.match(r"^(#{1,6})\s+(.*)$", line)
143
+ if m:
144
+ if buf and "".join(buf).strip():
145
+ sections.append((head, "\n".join(buf).strip()))
146
+ head, buf = line.strip(), []
147
+ else:
148
+ buf.append(line)
149
+ if buf and "".join(buf).strip():
150
+ sections.append((head, "\n".join(buf).strip()))
151
+
152
+ chunks = []
153
+ for heading, body in sections:
154
+ cur = ""
155
+ for para in [p.strip() for p in re.split(r"\n\s*\n", body) if p.strip()]:
156
+ for sent in re.split(r"(?<=[。!?.!?])\s+", para):
157
+ cand = (cur + " " + sent).strip() if cur else sent
158
+ if _token_len(cand) > size and cur:
159
+ chunks.append({"heading": heading, "text": cur})
160
+ words = cur.split()
161
+ cur = (" ".join(words[-overlap:]) + " " + sent) if overlap else sent
162
+ else:
163
+ cur = cand
164
+ if cur.strip():
165
+ chunks.append({"heading": heading, "text": cur.strip()})
166
+ return [c for c in chunks if c["text"]]
167
+
168
+
169
+ # ── tools ────────────────────────────────────────────────────
170
+
171
+ @mcp.tool()
172
+ def convert_to_markdown(path: str) -> str:
173
+ """Convert a local PDF, DOCX, PPTX or XLSX file to Markdown (tables preserved).
174
+
175
+ Args:
176
+ path: Absolute path to the file on the user's machine.
177
+ """
178
+ p = Path(path).expanduser()
179
+ if not p.exists():
180
+ return f"错误:文件不存在 {p}"
181
+ try:
182
+ md = _extract(p)
183
+ except Exception as exc:
184
+ return f"错误:转换失败({exc})"
185
+ if not md.strip():
186
+ return "转换结果为空。如果是扫描版 PDF,请改用 ocr_extract。"
187
+ return md
188
+
189
+
190
+ @mcp.tool()
191
+ def ocr_extract(path: str, dpi: int = 200, max_pages: int = 50) -> str:
192
+ """OCR an image or scanned PDF into text. Requires the rapidocr-onnxruntime package.
193
+
194
+ Pages that already carry a text layer are read directly instead of being OCR'd.
195
+
196
+ Args:
197
+ path: Absolute path to an image or PDF.
198
+ dpi: Render DPI for PDF pages (200 is a good default, 300 for fuzzy scans).
199
+ max_pages: Safety cap on how many pages to process.
200
+ """
201
+ p = Path(path).expanduser()
202
+ if not p.exists():
203
+ return f"错误:文件不存在 {p}"
204
+ try:
205
+ from rapidocr_onnxruntime import RapidOCR
206
+ except ImportError:
207
+ return "错误:未安装 OCR 依赖。请先执行 pip install rapidocr-onnxruntime"
208
+
209
+ engine = RapidOCR()
210
+ ext = p.suffix.lower()
211
+
212
+ if ext == ".pdf":
213
+ import fitz
214
+
215
+ doc = fitz.open(str(p))
216
+ limit = min(len(doc), max_pages) if max_pages > 0 else len(doc)
217
+ parts = []
218
+ for i in range(limit):
219
+ page = doc[i]
220
+ text = page.get_text("text").strip()
221
+ if len(text) > 20:
222
+ parts.append(f"\n<!-- page {i + 1} · text layer -->\n\n{text}")
223
+ continue
224
+ pix = page.get_pixmap(dpi=dpi)
225
+ tmp = Path(str(p) + f".p{i + 1}.png")
226
+ pix.save(str(tmp))
227
+ try:
228
+ result, _ = engine(str(tmp))
229
+ finally:
230
+ tmp.unlink(missing_ok=True)
231
+ body = "\n".join(line[1] for line in (result or []))
232
+ parts.append(f"\n<!-- page {i + 1} · ocr -->\n\n{body}")
233
+ doc.close()
234
+ out = "".join(parts).strip()
235
+ else:
236
+ result, _ = engine(str(p))
237
+ out = "\n".join(line[1] for line in (result or []))
238
+
239
+ return out or "没有识别到文字。"
240
+
241
+
242
+ @mcp.tool()
243
+ def split_chunks(path: str, size: int = 800, overlap: int = 100) -> str:
244
+ """Split a document into retrieval-sized chunks and return them inline.
245
+
246
+ Args:
247
+ path: Absolute path to a document.
248
+ size: Target tokens per chunk.
249
+ overlap: Tokens of overlap between consecutive chunks.
250
+ """
251
+ p = Path(path).expanduser()
252
+ if not p.exists():
253
+ return f"错误:文件不存在 {p}"
254
+ try:
255
+ md = p.read_text(encoding="utf-8", errors="replace") if p.suffix.lower() in TEXT_EXT else _extract(p)
256
+ except Exception as exc:
257
+ return f"错误:读取失败({exc})"
258
+ chunks = _chunk(md, max(100, size), max(0, overlap))
259
+ if not chunks:
260
+ return "没有可切分的内容。"
261
+ out = [f"共 {len(chunks)} 块:"]
262
+ for i, c in enumerate(chunks, 1):
263
+ out.append(f"\n--- 块 {i}{' · ' + c['heading'] if c['heading'] else ''} ---\n{c['text']}")
264
+ return "\n".join(out)
265
+
266
+
267
+ @mcp.tool()
268
+ def pdf_merge(inputs: list[str], output: str) -> str:
269
+ """Merge several PDFs into one, in the order given.
270
+
271
+ Args:
272
+ inputs: Absolute paths of the PDFs to merge.
273
+ output: Where to write the merged PDF.
274
+ """
275
+ from pypdf import PdfReader, PdfWriter
276
+
277
+ writer, total = PdfWriter(), 0
278
+ for item in inputs:
279
+ p = Path(item).expanduser()
280
+ if not p.exists():
281
+ return f"错误:文件不存在 {p}"
282
+ reader = PdfReader(str(p))
283
+ for page in reader.pages:
284
+ writer.add_page(page)
285
+ total += len(reader.pages)
286
+ out = Path(output).expanduser()
287
+ out.parent.mkdir(parents=True, exist_ok=True)
288
+ with open(out, "wb") as fh:
289
+ writer.write(fh)
290
+ return f"已合并 {len(inputs)} 个文件共 {total} 页 → {out}"
291
+
292
+
293
+ @mcp.tool()
294
+ def pdf_split(path: str, output_dir: str, every: int = 1, page_range: str = "") -> str:
295
+ """Split a PDF by page count, or extract a page range.
296
+
297
+ Args:
298
+ path: Absolute path to the PDF.
299
+ output_dir: Directory to write the parts into.
300
+ every: When page_range is empty, put this many pages in each output file.
301
+ page_range: Optional "2-5" or "7" to extract just those pages.
302
+ """
303
+ from pypdf import PdfReader, PdfWriter
304
+
305
+ src = Path(path).expanduser()
306
+ if not src.exists():
307
+ return f"错误:文件不存在 {src}"
308
+ reader = PdfReader(str(src))
309
+ n = len(reader.pages)
310
+ outdir = Path(output_dir).expanduser()
311
+ outdir.mkdir(parents=True, exist_ok=True)
312
+
313
+ if page_range:
314
+ rng = page_range.replace(" ", "")
315
+ lo, _, hi = rng.partition("-")
316
+ lo = int(lo)
317
+ hi = int(hi) if hi else lo
318
+ if lo < 1 or hi > n or lo > hi:
319
+ return f"错误:页码范围不合法(文档共 {n} 页)"
320
+ w = PdfWriter()
321
+ for i in range(lo - 1, hi):
322
+ w.add_page(reader.pages[i])
323
+ dst = outdir / f"{src.stem}_p{lo}-{hi}.pdf"
324
+ with open(dst, "wb") as fh:
325
+ w.write(fh)
326
+ return f"已提取第 {lo}-{hi} 页 → {dst}"
327
+
328
+ step = max(1, every)
329
+ made = 0
330
+ for start in range(0, n, step):
331
+ w = PdfWriter()
332
+ for i in range(start, min(start + step, n)):
333
+ w.add_page(reader.pages[i])
334
+ dst = outdir / f"{src.stem}_p{start + 1}-{min(start + step, n)}.pdf"
335
+ with open(dst, "wb") as fh:
336
+ w.write(fh)
337
+ made += 1
338
+ return f"已拆成 {made} 个文件 → {outdir}"
339
+
340
+
341
+ def main() -> None:
342
+ mcp.run()
343
+
344
+
345
+ if __name__ == "__main__":
346
+ main()
@@ -0,0 +1,45 @@
1
+ """Smoke tests — no network, no OCR model required."""
2
+ import os
3
+ import tempfile
4
+
5
+ from doc_toolkit_mcp import server
6
+
7
+
8
+ def test_missing_file_is_reported():
9
+ out = server.convert_to_markdown("definitely-not-here.pdf")
10
+ assert "文件不存在" in out
11
+
12
+
13
+ def test_chunk_respects_headings():
14
+ md = "## A\n\n第一段内容。\n\n第二段内容。\n\n## B\n\n第三段。"
15
+ chunks = server._chunk(md, 50, 0)
16
+ assert any(c["heading"] == "## A" for c in chunks)
17
+ assert any(c["heading"] == "## B" for c in chunks)
18
+
19
+
20
+ def test_table_rendering():
21
+ out = server._fmt_table([["a", "b"], ["1", "2"]])
22
+ assert out.splitlines()[0] == "| a | b |"
23
+ assert "| 1 | 2 |" in out
24
+
25
+
26
+ def test_pdf_merge_and_split(tmp_path):
27
+ import fitz
28
+
29
+ paths = []
30
+ for name, pages in (("a.pdf", 2), ("b.pdf", 1)):
31
+ doc = fitz.open()
32
+ for _ in range(pages):
33
+ doc.new_page()
34
+ p = tmp_path / name
35
+ doc.save(str(p))
36
+ doc.close()
37
+ paths.append(str(p))
38
+
39
+ merged = tmp_path / "merged.pdf"
40
+ msg = server.pdf_merge(paths, str(merged))
41
+ assert merged.exists() and "3 页" in msg
42
+
43
+ outdir = tmp_path / "parts"
44
+ msg = server.pdf_split(str(merged), str(outdir), every=2)
45
+ assert len(list(outdir.glob("*.pdf"))) == 2