precis-mcp 0.2.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,33 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ id-token: write # trusted publishing (OIDC)
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v4
17
+ with:
18
+ version: "latest"
19
+ - run: uv venv
20
+ - run: uv pip install -e ".[dev]"
21
+ - run: uv run --no-sync pytest
22
+
23
+ publish:
24
+ needs: test
25
+ runs-on: ubuntu-latest
26
+ environment: pypi
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: astral-sh/setup-uv@v4
30
+ with:
31
+ version: "latest"
32
+ - run: uv build
33
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,35 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ *.egg
7
+ dist/
8
+ build/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+
14
+ # Testing
15
+ .pytest_cache/
16
+ .coverage
17
+ htmlcov/
18
+
19
+ # IDE
20
+ .idea/
21
+ .vscode/
22
+ *.swp
23
+ *.swo
24
+ *~
25
+
26
+ # OS
27
+ .DS_Store
28
+ Thumbs.db
29
+
30
+ # UV
31
+ uv.lock
32
+
33
+ # Package-specific
34
+ *.docx.tmp
35
+ *.tex.tmp
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ All notable changes to **precis-mcp** will be documented in this file.
4
+
5
+ Format follows [Keep a Changelog](https://keepachangelog.com/).
6
+
7
+ ## [0.1.0] — 2026-03-11
8
+
9
+ ### Added
10
+
11
+ - Initial release.
@@ -0,0 +1,14 @@
1
+ Copyright (c) 2026 Reto Stamm and Acatome Contributors
2
+
3
+ This program is free software: you can redistribute it and/or modify
4
+ it under the terms of the GNU General Public License as published by
5
+ the Free Software Foundation, either version 3 of the License, or
6
+ (at your option) any later version.
7
+
8
+ This program is distributed in the hope that it will be useful,
9
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ GNU General Public License for more details.
12
+
13
+ You should have received a copy of the GNU General Public License
14
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: precis-mcp
3
+ Version: 0.2.0
4
+ Summary: MCP server for compressed structured context on .docx and .tex documents
5
+ Project-URL: Homepage, https://github.com/acatome/precis-mcp
6
+ Project-URL: Repository, https://github.com/acatome/precis-mcp
7
+ Project-URL: Issues, https://github.com/acatome/precis-mcp/issues
8
+ Author-email: Reto Stamm <reto@retostamm.com>
9
+ License-Expression: GPL-3.0-or-later
10
+ License-File: LICENSE
11
+ Keywords: document,docx,keyword-extraction,latex,llm,mcp,precis
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Text Processing :: Markup
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: lxml>=5.0
21
+ Requires-Dist: mcp[cli]>=1.0
22
+ Requires-Dist: platformdirs>=4.0
23
+ Requires-Dist: precis-summary>=0.1.0
24
+ Requires-Dist: python-docx>=1.1
25
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
26
+ Provides-Extra: dev
27
+ Requires-Dist: black>=24.0; extra == 'dev'
28
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0; extra == 'dev'
30
+ Requires-Dist: ruff>=0.5; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # precis-mcp
34
+
35
+ A [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that gives LLMs compressed, structured context for `.docx` and `.tex` documents. It maintains a heading tree with RAKE keyword summaries so the LLM can navigate and edit documents without flooding context.
36
+
37
+ ## Features
38
+
39
+ - **5 tools** — `activate`, `toc`, `get`, `put`, `move`
40
+ - **Dual addressing** — 5-char content-hash slugs + positional heading paths
41
+ - **RAKE keyword extraction** — stateless, zero-dependency precis generation (<5ms)
42
+ - **DOCX citations** — `[@key]` round-trip with styled hyperlinks and bibliography entries
43
+ - **Track changes** — `put()` writes Word revision markup by default
44
+ - **LaTeX support** — `\input`/`\include` resolution, `\label{}` aliases, `.bib` parsing
45
+ - **Atomic I/O** — every call reads fresh from disk, no stale state
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ uv pip install -e ".[dev]"
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ Run as an MCP server:
56
+
57
+ ```bash
58
+ precis
59
+ ```
60
+
61
+ Or use with an MCP client:
62
+
63
+ ```python
64
+ from precis.tools import Session, activate, toc, get, put
65
+
66
+ session = Session()
67
+ await activate(session, "paper.docx")
68
+ await toc(session)
69
+ await get(session, id="KR8M2")
70
+ await put(session, id="KR8M2", text="Updated text.", mode="replace")
71
+ ```
72
+
73
+ ## Configuration
74
+
75
+ `~/.config/precis/precis.toml`:
76
+
77
+ ```toml
78
+ [precis]
79
+ author = "precis" # track-changes author name (DOCX only)
80
+ ```
81
+
82
+ ## Testing
83
+
84
+ ```bash
85
+ uv run python -m pytest tests/ -v
86
+ ```
87
+
88
+ ## License
89
+
90
+ GPL-3.0-or-later — see [LICENSE](LICENSE).
@@ -0,0 +1,58 @@
1
+ # precis-mcp
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that gives LLMs compressed, structured context for `.docx` and `.tex` documents. It maintains a heading tree with RAKE keyword summaries so the LLM can navigate and edit documents without flooding context.
4
+
5
+ ## Features
6
+
7
+ - **5 tools** — `activate`, `toc`, `get`, `put`, `move`
8
+ - **Dual addressing** — 5-char content-hash slugs + positional heading paths
9
+ - **RAKE keyword extraction** — stateless, zero-dependency precis generation (<5ms)
10
+ - **DOCX citations** — `[@key]` round-trip with styled hyperlinks and bibliography entries
11
+ - **Track changes** — `put()` writes Word revision markup by default
12
+ - **LaTeX support** — `\input`/`\include` resolution, `\label{}` aliases, `.bib` parsing
13
+ - **Atomic I/O** — every call reads fresh from disk, no stale state
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ uv pip install -e ".[dev]"
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ Run as an MCP server:
24
+
25
+ ```bash
26
+ precis
27
+ ```
28
+
29
+ Or use with an MCP client:
30
+
31
+ ```python
32
+ from precis.tools import Session, activate, toc, get, put
33
+
34
+ session = Session()
35
+ await activate(session, "paper.docx")
36
+ await toc(session)
37
+ await get(session, id="KR8M2")
38
+ await put(session, id="KR8M2", text="Updated text.", mode="replace")
39
+ ```
40
+
41
+ ## Configuration
42
+
43
+ `~/.config/precis/precis.toml`:
44
+
45
+ ```toml
46
+ [precis]
47
+ author = "precis" # track-changes author name (DOCX only)
48
+ ```
49
+
50
+ ## Testing
51
+
52
+ ```bash
53
+ uv run python -m pytest tests/ -v
54
+ ```
55
+
56
+ ## License
57
+
58
+ GPL-3.0-or-later — see [LICENSE](LICENSE).
@@ -0,0 +1,76 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "precis-mcp"
7
+ version = "0.2.0"
8
+ description = "MCP server for compressed structured context on .docx and .tex documents"
9
+ requires-python = ">=3.11"
10
+ license = "GPL-3.0-or-later"
11
+ authors = [{name = "Reto Stamm", email = "reto@retostamm.com"}]
12
+ readme = "README.md"
13
+ keywords = ["mcp", "docx", "latex", "llm", "document", "precis", "keyword-extraction"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Text Processing :: Markup",
22
+ ]
23
+
24
+ dependencies = [
25
+ "mcp[cli]>=1.0",
26
+ "python-docx>=1.1",
27
+ "lxml>=5.0",
28
+ "platformdirs>=4.0",
29
+ "precis-summary>=0.1.0",
30
+ "tomli>=2.0; python_version < '3.11'",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/acatome/precis-mcp"
35
+ Repository = "https://github.com/acatome/precis-mcp"
36
+ Issues = "https://github.com/acatome/precis-mcp/issues"
37
+
38
+ [project.optional-dependencies]
39
+ dev = [
40
+ "pytest>=8.0",
41
+ "pytest-asyncio>=0.24",
42
+ "black>=24.0",
43
+ "ruff>=0.5",
44
+ ]
45
+
46
+ [project.scripts]
47
+ precis = "precis.server:main"
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/precis"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+ asyncio_mode = "auto"
55
+
56
+ [tool.black]
57
+ line-length = 88
58
+
59
+ [tool.ruff]
60
+ line-length = 88
61
+
62
+ [tool.bumpversion]
63
+ current_version = "0.2.0"
64
+ commit = true
65
+ tag = true
66
+ tag_name = "v{new_version}"
67
+
68
+ [[tool.bumpversion.files]]
69
+ filename = "pyproject.toml"
70
+ search = 'version = "{current_version}"'
71
+ replace = 'version = "{new_version}"'
72
+
73
+ [[tool.bumpversion.files]]
74
+ filename = "src/precis/__init__.py"
75
+ search = '__version__ = "{current_version}"'
76
+ replace = '__version__ = "{new_version}"'
@@ -0,0 +1,3 @@
1
+ """Precis MCP — compressed structured context for .docx and .tex documents."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,176 @@
1
+ """Citation system — style engine, bookmark/link helpers, DOCX style creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Markdown citation patterns
11
+ # ---------------------------------------------------------------------------
12
+
13
+ # Inline citation: [@key]
14
+ CITE_RE = re.compile(r"\[@([^\]\s]+)\]")
15
+
16
+ # Orphaned citation (lost bookmark, style survived): [@?:displayed text]
17
+ ORPHAN_CITE_RE = re.compile(r"\[@\?:([^\]]+)\]")
18
+
19
+ # Bibliography definition: [@key]: full reference text
20
+ BIB_DEF_RE = re.compile(r"^\[@([^\]\s]+)\]:\s*(.+)$")
21
+
22
+ ORPHAN_PREFIX = "?:"
23
+
24
+
25
+ def is_orphan_key(key: str) -> bool:
26
+ """True if the key is an orphaned citation (lost bookmark, style survived)."""
27
+ return key.startswith(ORPHAN_PREFIX)
28
+
29
+
30
+ def orphan_text(key: str) -> str:
31
+ """Extract the displayed text from an orphan key."""
32
+ return key[len(ORPHAN_PREFIX) :]
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Bookmark naming (for bibliography definitions only)
37
+ # ---------------------------------------------------------------------------
38
+
39
+ REF_BOOKMARK_RE = re.compile(r"^ref_(.+)$")
40
+
41
+
42
+ def ref_bookmark_name(key: str) -> str:
43
+ """Generate bookmark name for a bibliography entry."""
44
+ return f"ref_{key}"
45
+
46
+
47
+ def parse_ref_bookmark(name: str) -> str | None:
48
+ """Parse ref_key → key or None."""
49
+ m = REF_BOOKMARK_RE.match(name)
50
+ if m:
51
+ return m.group(1)
52
+ return None
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Citation styles
57
+ # ---------------------------------------------------------------------------
58
+
59
+ STYLES = ("author-year", "numbered", "superscript")
60
+
61
+
62
+ @dataclass
63
+ class BibEntry:
64
+ """A bibliography entry with parsed metadata for rendering."""
65
+
66
+ key: str
67
+ text: str # full formatted reference
68
+ author_short: str = "" # "Smith" or "Smith & Jones" or "Smith et al."
69
+ year: str = ""
70
+
71
+ def render_author_year(self) -> str:
72
+ if self.author_short and self.year:
73
+ return f"({self.author_short} {self.year})"
74
+ return f"[@{self.key}]"
75
+
76
+
77
+ @dataclass
78
+ class CitationIndex:
79
+ """Tracks citation order and bibliography for a document."""
80
+
81
+ style: str = "author-year"
82
+ entries: dict[str, BibEntry] = field(default_factory=dict)
83
+ cite_order: list[str] = field(default_factory=list)
84
+
85
+ def register_bib(self, key: str, text: str) -> None:
86
+ """Register a bibliography entry."""
87
+ author_short, year = _parse_author_year(text)
88
+ self.entries[key] = BibEntry(
89
+ key=key, text=text, author_short=author_short, year=year
90
+ )
91
+
92
+ def cite(self, key: str) -> int:
93
+ """Record a citation occurrence. Returns the 1-based citation number."""
94
+ if key not in self.cite_order:
95
+ self.cite_order.append(key)
96
+ return self.cite_order.index(key) + 1
97
+
98
+ def render_inline(self, key: str) -> tuple[str, bool]:
99
+ """Render an inline citation. Returns (text, is_superscript)."""
100
+ num = self.cite(key)
101
+ if self.style == "numbered":
102
+ return f"[{num}]", False
103
+ elif self.style == "superscript":
104
+ return str(num), True
105
+ else: # author-year
106
+ entry = self.entries.get(key)
107
+ if entry:
108
+ return entry.render_author_year(), False
109
+ return f"[@{key}]", False
110
+
111
+ def to_dict(self) -> dict[str, Any]:
112
+ return {
113
+ "style": self.style,
114
+ "entries": {
115
+ k: {"text": e.text, "author_short": e.author_short, "year": e.year}
116
+ for k, e in self.entries.items()
117
+ },
118
+ "cite_order": self.cite_order,
119
+ }
120
+
121
+ @classmethod
122
+ def from_dict(cls, data: dict[str, Any]) -> CitationIndex:
123
+ idx = cls(style=data.get("style", "author-year"))
124
+ idx.cite_order = data.get("cite_order", [])
125
+ for k, v in data.get("entries", {}).items():
126
+ idx.entries[k] = BibEntry(
127
+ key=k,
128
+ text=v.get("text", ""),
129
+ author_short=v.get("author_short", ""),
130
+ year=v.get("year", ""),
131
+ )
132
+ return idx
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Author/year extraction heuristic
137
+ # ---------------------------------------------------------------------------
138
+
139
+ # Matches "(2020)" or ", 2020" or "(2020)." at the end-ish of a reference
140
+ _YEAR_RE = re.compile(r"\b((?:19|20)\d{2})\b")
141
+ # First word(s) before a comma — typically the author surname
142
+ _AUTHOR_RE = re.compile(r"^([A-Z][a-z]+(?:\s*(?:&|and)\s*[A-Z][a-z]+)?)")
143
+
144
+
145
+ def _parse_author_year(text: str) -> tuple[str, str]:
146
+ """Best-effort extraction of (author_short, year) from a reference string.
147
+
148
+ Returns ("", "") if nothing found.
149
+ """
150
+ year = ""
151
+ author = ""
152
+
153
+ ym = _YEAR_RE.search(text)
154
+ if ym:
155
+ year = ym.group(1)
156
+
157
+ am = _AUTHOR_RE.match(text.strip())
158
+ if am:
159
+ author = am.group(1)
160
+ # Check for et al. — if there's more text after the matched authors
161
+ # before the year, assume et al.
162
+ rest = text.strip()[am.end() :].lstrip(",").strip()
163
+ if rest and not rest[0].isdigit() and "&" not in am.group(1):
164
+ # More authors follow — use et al.
165
+ if "," in rest.split("(")[0] if "(" in rest else rest:
166
+ author = f"{author} et al."
167
+
168
+ return author, year
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # DOCX style names
173
+ # ---------------------------------------------------------------------------
174
+
175
+ CITATION_REF_STYLE = "CitationRef"
176
+ BIB_ENTRY_STYLE = "BibEntry"
@@ -0,0 +1,39 @@
1
+ """Configuration from ~/.config/precis/precis.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ if sys.version_info >= (3, 11):
10
+ import tomllib
11
+ else:
12
+ import tomli as tomllib
13
+
14
+
15
+ DEFAULT_CONFIG_DIR = Path.home() / ".config" / "precis"
16
+ DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "precis.toml"
17
+
18
+
19
+ @dataclass
20
+ class PrecisConfig:
21
+ """Precis config — just track-changes author name."""
22
+
23
+ author: str = "precis"
24
+
25
+ @classmethod
26
+ def load(cls, path: Path | None = None) -> PrecisConfig:
27
+ """Load config from TOML file, falling back to defaults."""
28
+ path = path or DEFAULT_CONFIG_FILE
29
+ if not path.exists():
30
+ return cls()
31
+ try:
32
+ with open(path, "rb") as f:
33
+ data = tomllib.load(f)
34
+ section = data.get("precis", {})
35
+ return cls(
36
+ author=section.get("author", cls.author),
37
+ )
38
+ except Exception:
39
+ return cls()
@@ -0,0 +1,114 @@
1
+ """Markdown ↔ DOCX run formatting conversion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class FormattedRun:
11
+ """A text run with formatting properties."""
12
+
13
+ text: str
14
+ bold: bool = False
15
+ italic: bool = False
16
+ superscript: bool = False
17
+ subscript: bool = False
18
+ strike: bool = False
19
+ url: str = "" # non-empty = hyperlink
20
+ cite_key: str = "" # non-empty = citation reference [@key]
21
+
22
+
23
+ def runs_to_markdown(runs: list[FormattedRun]) -> str:
24
+ """Convert formatted runs to Markdown text."""
25
+ parts = []
26
+ for r in runs:
27
+ t = r.text
28
+ if r.cite_key:
29
+ t = f"[@{r.cite_key}]"
30
+ elif r.url:
31
+ t = f"[{t}]({r.url})"
32
+ if r.superscript:
33
+ t = f"<sup>{t}</sup>"
34
+ if r.subscript:
35
+ t = f"<sub>{t}</sub>"
36
+ if r.bold and r.italic:
37
+ t = f"***{t}***"
38
+ elif r.bold:
39
+ t = f"**{t}**"
40
+ elif r.italic:
41
+ t = f"*{t}*"
42
+ if r.strike:
43
+ t = f"~~{t}~~"
44
+ parts.append(t)
45
+ return "".join(parts)
46
+
47
+
48
+ # Patterns for parsing Markdown back to runs
49
+ _PATTERNS = [
50
+ # Bold+italic
51
+ (re.compile(r"\*\*\*(.+?)\*\*\*"), {"bold": True, "italic": True}),
52
+ # Bold
53
+ (re.compile(r"\*\*(.+?)\*\*"), {"bold": True}),
54
+ # Italic
55
+ (re.compile(r"\*(.+?)\*"), {"italic": True}),
56
+ # Strikethrough
57
+ (re.compile(r"~~(.+?)~~"), {"strike": True}),
58
+ # Superscript
59
+ (re.compile(r"<sup>(.+?)</sup>"), {"superscript": True}),
60
+ # Subscript
61
+ (re.compile(r"<sub>(.+?)</sub>"), {"subscript": True}),
62
+ # Hyperlink
63
+ (re.compile(r"\[(.+?)\]\((.+?)\)"), {"url": True}),
64
+ # Citation: [@key]
65
+ (re.compile(r"\[@([^\]\s]+)\]"), {"cite_key": True}),
66
+ ]
67
+
68
+
69
+ def markdown_to_runs(text: str) -> list[FormattedRun]:
70
+ """Parse Markdown text into FormattedRun objects.
71
+
72
+ Handles bold, italic, superscript, subscript, strikethrough, and hyperlinks.
73
+ Unrecognized text becomes plain runs.
74
+ """
75
+ if not text:
76
+ return []
77
+
78
+ # Use a token-based approach: find all formatting spans, sort by position
79
+ spans: list[tuple[int, int, FormattedRun]] = []
80
+
81
+ for pattern, props in _PATTERNS:
82
+ for m in pattern.finditer(text):
83
+ if "url" in props:
84
+ run = FormattedRun(text=m.group(1), url=m.group(2))
85
+ elif "cite_key" in props:
86
+ key = m.group(1)
87
+ run = FormattedRun(text=f"[@{key}]", cite_key=key)
88
+ else:
89
+ run = FormattedRun(text=m.group(1), **props)
90
+ spans.append((m.start(), m.end(), run))
91
+
92
+ if not spans:
93
+ return [FormattedRun(text=text)]
94
+
95
+ # Sort by start position, resolve overlaps (first match wins)
96
+ spans.sort(key=lambda s: s[0])
97
+ result: list[FormattedRun] = []
98
+ pos = 0
99
+
100
+ for start, end, run in spans:
101
+ if start < pos:
102
+ continue # overlapping span, skip
103
+ if start > pos:
104
+ plain = text[pos:start]
105
+ if plain:
106
+ result.append(FormattedRun(text=plain))
107
+ result.append(run)
108
+ pos = end
109
+
110
+ # Trailing plain text
111
+ if pos < len(text):
112
+ result.append(FormattedRun(text=text[pos:]))
113
+
114
+ return result