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.
@@ -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__"]
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m docshelf_mcp``."""
2
+
3
+ from docshelf_mcp.server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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,6 @@
1
+ """Core building blocks: PDF conversion, splitting, indexing, shelf state."""
2
+
3
+ from docshelf_mcp.core.shelf import Shelf
4
+ from docshelf_mcp.core.slugify import slugify
5
+
6
+ __all__ = ["Shelf", "slugify"]
@@ -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}")