docshelf-mcp 0.2.0__py3-none-any.whl
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.
- docshelf_mcp/__init__.py +20 -0
- docshelf_mcp/__main__.py +6 -0
- docshelf_mcp/config.py +28 -0
- docshelf_mcp/core/__init__.py +6 -0
- docshelf_mcp/core/converter.py +88 -0
- docshelf_mcp/core/indexer.py +267 -0
- docshelf_mcp/core/shelf.py +330 -0
- docshelf_mcp/core/slugify.py +48 -0
- docshelf_mcp/core/splitter.py +160 -0
- docshelf_mcp/server.py +195 -0
- docshelf_mcp/tools.py +345 -0
- docshelf_mcp-0.2.0.dist-info/METADATA +331 -0
- docshelf_mcp-0.2.0.dist-info/RECORD +16 -0
- docshelf_mcp-0.2.0.dist-info/WHEEL +4 -0
- docshelf_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- docshelf_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
docshelf_mcp/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""docshelf-mcp — MCP server for managing AI-friendly document collections.
|
|
2
|
+
|
|
3
|
+
A document collection (`shelf`) is a git-tracked folder that:
|
|
4
|
+
|
|
5
|
+
* Holds source documents (PDF or Markdown) organized into categories.
|
|
6
|
+
* Auto-converts PDFs to Markdown and splits oversized files by H2 heading.
|
|
7
|
+
* Generates an `INDEX.md` navigation page with raw GitHub URLs so an LLM
|
|
8
|
+
can fetch only the section it needs instead of swallowing the whole file.
|
|
9
|
+
|
|
10
|
+
Use `docshelf_mcp.server:main` (or the `docshelf-mcp` console script) to
|
|
11
|
+
launch the MCP server. Use `docshelf_mcp.core.shelf:Shelf` for direct
|
|
12
|
+
library access.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from docshelf_mcp.core.shelf import Shelf
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
__all__ = ["Shelf", "__version__"]
|
docshelf_mcp/__main__.py
ADDED
docshelf_mcp/config.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Server-level configuration (env vars).
|
|
2
|
+
|
|
3
|
+
Right now the only knob is ``DOCSHELF_ROOT`` — the default shelf directory
|
|
4
|
+
used when an MCP tool is called without an explicit ``shelf_path``. Override
|
|
5
|
+
it via env to point at your shelf:
|
|
6
|
+
|
|
7
|
+
.. code-block:: shell
|
|
8
|
+
|
|
9
|
+
export DOCSHELF_ROOT="$HOME/Documents/my-docs"
|
|
10
|
+
|
|
11
|
+
If unset, tools fall back to ``$PWD``, which is the right behaviour when the
|
|
12
|
+
server is started from a shelf directory.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
__all__ = ["default_shelf_root"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_shelf_root() -> Path:
|
|
24
|
+
"""Resolve the default shelf root for tool calls without an explicit path."""
|
|
25
|
+
env = os.environ.get("DOCSHELF_ROOT", "").strip()
|
|
26
|
+
if env:
|
|
27
|
+
return Path(env).expanduser().resolve()
|
|
28
|
+
return Path.cwd().resolve()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""PDF → Markdown conversion.
|
|
2
|
+
|
|
3
|
+
Two engines:
|
|
4
|
+
|
|
5
|
+
* ``"fast"`` — `pymupdf4llm <https://pypi.org/project/pymupdf4llm/>`__.
|
|
6
|
+
Pure-Python, no GPU, ~1 second per 100 pages. Good enough for most
|
|
7
|
+
technical manuals. **Default.**
|
|
8
|
+
* ``"high"`` — `marker-pdf <https://pypi.org/project/marker-pdf/>`__.
|
|
9
|
+
Higher fidelity (tables, equations), but heavy (PyTorch). Optional —
|
|
10
|
+
only imported if requested.
|
|
11
|
+
|
|
12
|
+
Both code paths are deferred imports: importing ``docshelf_mcp`` doesn't
|
|
13
|
+
pull in PyMuPDF or marker until you actually call :func:`pdf_to_markdown`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
__all__ = ["pdf_to_markdown", "Quality"]
|
|
22
|
+
|
|
23
|
+
Quality = Literal["fast", "high"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConversionError(RuntimeError):
|
|
27
|
+
"""Raised when PDF conversion fails or a required engine is missing."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _convert_fast(pdf_path: Path) -> str:
|
|
31
|
+
try:
|
|
32
|
+
import pymupdf4llm # type: ignore[import-not-found]
|
|
33
|
+
except ImportError as exc:
|
|
34
|
+
raise ConversionError(
|
|
35
|
+
"pymupdf4llm is required for quality='fast' but is not installed. "
|
|
36
|
+
"Install it with: pip install pymupdf4llm"
|
|
37
|
+
) from exc
|
|
38
|
+
|
|
39
|
+
return pymupdf4llm.to_markdown(str(pdf_path))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _convert_high(pdf_path: Path) -> str:
|
|
43
|
+
try:
|
|
44
|
+
from marker.converters.pdf import PdfConverter # type: ignore[import-not-found]
|
|
45
|
+
from marker.models import create_model_dict # type: ignore[import-not-found]
|
|
46
|
+
from marker.output import text_from_rendered # type: ignore[import-not-found]
|
|
47
|
+
except ImportError as exc:
|
|
48
|
+
raise ConversionError(
|
|
49
|
+
"marker-pdf is required for quality='high' but is not installed. "
|
|
50
|
+
"Install it with: pip install 'docshelf-mcp[high-quality]' or "
|
|
51
|
+
"pip install marker-pdf. Note: marker-pdf pulls in PyTorch (~2 GB)."
|
|
52
|
+
) from exc
|
|
53
|
+
|
|
54
|
+
converter = PdfConverter(artifact_dict=create_model_dict())
|
|
55
|
+
rendered = converter(str(pdf_path))
|
|
56
|
+
text, _, _ = text_from_rendered(rendered)
|
|
57
|
+
return text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def pdf_to_markdown(pdf_path: Path | str, quality: Quality = "fast") -> str:
|
|
61
|
+
"""Convert a PDF file to Markdown.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
pdf_path: Path to the source PDF.
|
|
65
|
+
quality: ``"fast"`` (default, pymupdf4llm) or ``"high"`` (marker-pdf).
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The extracted Markdown text. NOT cleaned —
|
|
69
|
+
run :func:`docshelf_mcp.core.splitter.clean_markdown` to remove
|
|
70
|
+
PDF-extraction artefacts.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
FileNotFoundError: If ``pdf_path`` doesn't exist.
|
|
74
|
+
ConversionError: If the requested engine is missing or fails.
|
|
75
|
+
"""
|
|
76
|
+
pdf_path = Path(pdf_path).expanduser().resolve()
|
|
77
|
+
if not pdf_path.exists():
|
|
78
|
+
raise FileNotFoundError(f"PDF not found: {pdf_path}")
|
|
79
|
+
if pdf_path.suffix.lower() != ".pdf":
|
|
80
|
+
raise ConversionError(
|
|
81
|
+
f"Expected a .pdf file, got {pdf_path.suffix or '<no extension>'}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if quality == "fast":
|
|
85
|
+
return _convert_fast(pdf_path)
|
|
86
|
+
if quality == "high":
|
|
87
|
+
return _convert_high(pdf_path)
|
|
88
|
+
raise ConversionError(f"Unknown quality preset: {quality!r}")
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Generate ``INDEX.md`` — the navigation page for a shelf.
|
|
2
|
+
|
|
3
|
+
The index is the *one* file you attach to your AI chat / Claude project.
|
|
4
|
+
For every document in the shelf, it lists:
|
|
5
|
+
|
|
6
|
+
* The category bucket (e.g. ``## Motherboards``).
|
|
7
|
+
* A human title + short description.
|
|
8
|
+
* A `raw.githubusercontent.com` URL the model can fetch on demand.
|
|
9
|
+
* For split documents: every section as its own link.
|
|
10
|
+
|
|
11
|
+
The index is **regenerated** every time the shelf changes — the source of
|
|
12
|
+
truth is the on-disk shelf state, not the previous index.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"DocumentEntry",
|
|
24
|
+
"build_index",
|
|
25
|
+
"scan_shelf",
|
|
26
|
+
"raw_github_url",
|
|
27
|
+
"DEFAULT_PREAMBLE",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class DocumentEntry:
|
|
33
|
+
"""One document in a shelf."""
|
|
34
|
+
|
|
35
|
+
category: str
|
|
36
|
+
title: str
|
|
37
|
+
description: str
|
|
38
|
+
#: Path relative to the shelf root, e.g. ``"docs/motherboards/asus-x870.md"``.
|
|
39
|
+
relative_path: str
|
|
40
|
+
size_bytes: int
|
|
41
|
+
#: Section files (relative paths) if the document was split. Empty list
|
|
42
|
+
#: means the document is a single self-contained file.
|
|
43
|
+
section_paths: list[str] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
DEFAULT_PREAMBLE = (
|
|
47
|
+
"This is the navigation index for the document shelf. "
|
|
48
|
+
"Each entry below links to a Markdown file on GitHub (raw URL) — "
|
|
49
|
+
"an AI assistant can fetch only the section it needs instead of "
|
|
50
|
+
"loading the entire collection. Large documents are split by chapter; "
|
|
51
|
+
"follow the section links for those."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def raw_github_url(remote: str, branch: str, relative_path: str) -> str:
|
|
56
|
+
"""Build a ``raw.githubusercontent.com`` URL.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
remote: GitHub URL of the form ``https://github.com/owner/repo`` or
|
|
60
|
+
``git@github.com:owner/repo.git``.
|
|
61
|
+
branch: Branch name (e.g. ``"main"``).
|
|
62
|
+
relative_path: Path relative to the repo root, forward-slashed.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Empty string if ``remote`` cannot be parsed (so the index still renders).
|
|
66
|
+
"""
|
|
67
|
+
owner_repo = _parse_owner_repo(remote)
|
|
68
|
+
if owner_repo is None:
|
|
69
|
+
return ""
|
|
70
|
+
owner, repo = owner_repo
|
|
71
|
+
return f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{relative_path}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_GITHUB_HTTPS = re.compile(r"github\.com[:/](?P<owner>[^/]+)/(?P<repo>[^/.]+)")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_owner_repo(remote: str) -> tuple[str, str] | None:
|
|
78
|
+
m = _GITHUB_HTTPS.search(remote)
|
|
79
|
+
if not m:
|
|
80
|
+
return None
|
|
81
|
+
repo = m.group("repo")
|
|
82
|
+
if repo.endswith(".git"):
|
|
83
|
+
repo = repo[:-4]
|
|
84
|
+
return m.group("owner"), repo
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def scan_shelf(shelf_root: Path) -> list[DocumentEntry]:
|
|
88
|
+
"""Walk ``shelf_root/docs/`` and produce a :class:`DocumentEntry` list.
|
|
89
|
+
|
|
90
|
+
Conventions:
|
|
91
|
+
|
|
92
|
+
* ``docs/<category>/<doc>.md`` — a regular single-file document.
|
|
93
|
+
* ``docs/<category>/<doc>/<NNN>-<slug>.md`` — a split document (the
|
|
94
|
+
sibling directory of ``<doc>.md`` if both exist).
|
|
95
|
+
* ``docs/<category>/.meta.json`` — optional override file with the
|
|
96
|
+
shape ``{"<doc>.md": {"title": "...", "description": "..."}}``.
|
|
97
|
+
"""
|
|
98
|
+
docs_root = shelf_root / "docs"
|
|
99
|
+
entries: list[DocumentEntry] = []
|
|
100
|
+
if not docs_root.exists():
|
|
101
|
+
return entries
|
|
102
|
+
|
|
103
|
+
for category_dir in sorted(p for p in docs_root.iterdir() if p.is_dir()):
|
|
104
|
+
meta = _load_meta(category_dir)
|
|
105
|
+
for md_file in sorted(category_dir.glob("*.md")):
|
|
106
|
+
name = md_file.name
|
|
107
|
+
override = meta.get(name, {})
|
|
108
|
+
title = override.get("title") or _title_from_filename(md_file.stem)
|
|
109
|
+
description = override.get("description", "")
|
|
110
|
+
|
|
111
|
+
# Look for a sibling directory of the same stem — split sections.
|
|
112
|
+
split_dir = category_dir / md_file.stem
|
|
113
|
+
section_paths: list[str] = []
|
|
114
|
+
if split_dir.is_dir():
|
|
115
|
+
section_paths = sorted(
|
|
116
|
+
str(p.relative_to(shelf_root).as_posix())
|
|
117
|
+
for p in split_dir.glob("*.md")
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
entries.append(
|
|
121
|
+
DocumentEntry(
|
|
122
|
+
category=category_dir.name,
|
|
123
|
+
title=title,
|
|
124
|
+
description=description,
|
|
125
|
+
relative_path=str(md_file.relative_to(shelf_root).as_posix()),
|
|
126
|
+
size_bytes=md_file.stat().st_size,
|
|
127
|
+
section_paths=section_paths,
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
return entries
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _load_meta(category_dir: Path) -> dict[str, dict]:
|
|
134
|
+
meta_path = category_dir / ".meta.json"
|
|
135
|
+
if not meta_path.is_file():
|
|
136
|
+
return {}
|
|
137
|
+
try:
|
|
138
|
+
return json.loads(meta_path.read_text(encoding="utf-8"))
|
|
139
|
+
except (OSError, json.JSONDecodeError):
|
|
140
|
+
return {}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _title_from_filename(stem: str) -> str:
|
|
144
|
+
"""Best-effort: ``asus-x870-plus_ug`` → ``Asus X870 Plus Ug``."""
|
|
145
|
+
parts = re.split(r"[-_]+", stem)
|
|
146
|
+
return " ".join(p.capitalize() if p.islower() else p for p in parts if p)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _pretty_section(section_relpath: str) -> str:
|
|
150
|
+
"""``docs/foo/bar/003-power-supplies.md`` → ``Power supplies``."""
|
|
151
|
+
stem = Path(section_relpath).stem
|
|
152
|
+
stem = re.sub(r"^\d+-", "", stem)
|
|
153
|
+
text = stem.replace("-", " ").replace("_", " ").strip()
|
|
154
|
+
return text.capitalize() if text else stem
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_index(
|
|
158
|
+
shelf_name: str,
|
|
159
|
+
entries: list[DocumentEntry],
|
|
160
|
+
*,
|
|
161
|
+
remote: str = "",
|
|
162
|
+
branch: str = "main",
|
|
163
|
+
preamble: str = DEFAULT_PREAMBLE,
|
|
164
|
+
category_order: list[str] | None = None,
|
|
165
|
+
) -> str:
|
|
166
|
+
"""Render the INDEX.md text.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
shelf_name: Human-readable shelf name, used as the H1.
|
|
170
|
+
entries: From :func:`scan_shelf`.
|
|
171
|
+
remote: GitHub remote URL. Empty string disables raw URL links.
|
|
172
|
+
branch: Branch for raw URLs.
|
|
173
|
+
preamble: Intro paragraph below the H1.
|
|
174
|
+
category_order: Optional pinned order for the first N categories.
|
|
175
|
+
Unknown categories follow in alphabetical order.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The full Markdown text of ``INDEX.md``.
|
|
179
|
+
"""
|
|
180
|
+
lines: list[str] = []
|
|
181
|
+
lines.append(f"# {shelf_name}")
|
|
182
|
+
lines.append("")
|
|
183
|
+
if remote:
|
|
184
|
+
lines.append(f"Repo: <{remote}>")
|
|
185
|
+
lines.append("")
|
|
186
|
+
if preamble:
|
|
187
|
+
lines.append(preamble)
|
|
188
|
+
lines.append("")
|
|
189
|
+
|
|
190
|
+
by_cat: dict[str, list[DocumentEntry]] = {}
|
|
191
|
+
for e in entries:
|
|
192
|
+
by_cat.setdefault(e.category, []).append(e)
|
|
193
|
+
|
|
194
|
+
seen: set[str] = set()
|
|
195
|
+
ordered: list[str] = []
|
|
196
|
+
for cat in category_order or []:
|
|
197
|
+
if cat in by_cat:
|
|
198
|
+
ordered.append(cat)
|
|
199
|
+
seen.add(cat)
|
|
200
|
+
for cat in sorted(by_cat.keys()):
|
|
201
|
+
if cat not in seen:
|
|
202
|
+
ordered.append(cat)
|
|
203
|
+
|
|
204
|
+
if not ordered:
|
|
205
|
+
lines.append("_No documents yet. Use the `add_document` tool to start your shelf._")
|
|
206
|
+
lines.append("")
|
|
207
|
+
return "\n".join(lines)
|
|
208
|
+
|
|
209
|
+
for cat in ordered:
|
|
210
|
+
lines.append(f"## {_humanize_category(cat)}")
|
|
211
|
+
lines.append("")
|
|
212
|
+
for entry in sorted(by_cat[cat], key=lambda e: e.title.lower()):
|
|
213
|
+
_render_entry(lines, entry, remote, branch)
|
|
214
|
+
lines.append("")
|
|
215
|
+
|
|
216
|
+
lines.append("---")
|
|
217
|
+
lines.append("")
|
|
218
|
+
lines.append(
|
|
219
|
+
"*Auto-generated by [docshelf-mcp]"
|
|
220
|
+
"(https://github.com/ignatenkofi/docshelf-mcp). "
|
|
221
|
+
"Edit the source files in `docs/` and call `rebuild_index` to regenerate.*"
|
|
222
|
+
)
|
|
223
|
+
lines.append("")
|
|
224
|
+
return "\n".join(lines)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _humanize_category(cat: str) -> str:
|
|
228
|
+
return cat.replace("-", " ").replace("_", " ").strip().title()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _render_entry(
|
|
232
|
+
lines: list[str],
|
|
233
|
+
entry: DocumentEntry,
|
|
234
|
+
remote: str,
|
|
235
|
+
branch: str,
|
|
236
|
+
) -> None:
|
|
237
|
+
url = raw_github_url(remote, branch, entry.relative_path) if remote else ""
|
|
238
|
+
label = f"`{Path(entry.relative_path).name}`"
|
|
239
|
+
if url:
|
|
240
|
+
full_link = f"[{label}]({url})"
|
|
241
|
+
else:
|
|
242
|
+
full_link = label
|
|
243
|
+
|
|
244
|
+
desc = entry.description.strip()
|
|
245
|
+
desc_suffix = f" — {desc}" if desc else ""
|
|
246
|
+
|
|
247
|
+
if entry.section_paths:
|
|
248
|
+
lines.append(f"### {entry.title}{desc_suffix}")
|
|
249
|
+
lines.append("")
|
|
250
|
+
size_kb = entry.size_bytes // 1024
|
|
251
|
+
lines.append(
|
|
252
|
+
f"Full document: {full_link} "
|
|
253
|
+
f"(~{size_kb} KB — prefer pulling individual sections below)."
|
|
254
|
+
)
|
|
255
|
+
lines.append("")
|
|
256
|
+
for sect in entry.section_paths:
|
|
257
|
+
sect_label = _pretty_section(sect)
|
|
258
|
+
if remote:
|
|
259
|
+
sect_url = raw_github_url(remote, branch, sect)
|
|
260
|
+
lines.append(f"- [{sect_label}]({sect_url})")
|
|
261
|
+
else:
|
|
262
|
+
lines.append(f"- {sect_label} (`{sect}`)")
|
|
263
|
+
lines.append("")
|
|
264
|
+
lines.append(f" *(sections: {len(entry.section_paths)})*")
|
|
265
|
+
lines.append("")
|
|
266
|
+
else:
|
|
267
|
+
lines.append(f"- **{entry.title}**{desc_suffix} — {full_link}")
|