tex-mcp-web 0.8.2__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.
- tex_mcp_web/__init__.py +3 -0
- tex_mcp_web/agent/init.md +104 -0
- tex_mcp_web/cli.py +361 -0
- tex_mcp_web/comment_drafts.py +123 -0
- tex_mcp_web/comments.py +2281 -0
- tex_mcp_web/compiler.py +534 -0
- tex_mcp_web/completion.py +158 -0
- tex_mcp_web/config.py +366 -0
- tex_mcp_web/imaging.py +411 -0
- tex_mcp_web/mcp_client.py +249 -0
- tex_mcp_web/mcp_server.py +1375 -0
- tex_mcp_web/overleaf.py +477 -0
- tex_mcp_web/overleaf_git.py +384 -0
- tex_mcp_web/overleaf_live.py +565 -0
- tex_mcp_web/overleaf_ot.py +377 -0
- tex_mcp_web/overleaf_sync.py +1519 -0
- tex_mcp_web/page_token.py +62 -0
- tex_mcp_web/plugin_monitor.py +79 -0
- tex_mcp_web/server.py +3297 -0
- tex_mcp_web/static/ace/LICENSE +24 -0
- tex_mcp_web/static/ace/ace.js +23 -0
- tex_mcp_web/static/ace/ext-language_tools.js +8 -0
- tex_mcp_web/static/ace/ext-searchbox.js +8 -0
- tex_mcp_web/static/ace/keybinding-vim.js +8 -0
- tex_mcp_web/static/ace/mode-bibtex.js +8 -0
- tex_mcp_web/static/ace/mode-latex.js +8 -0
- tex_mcp_web/static/ace/mode-text.js +8 -0
- tex_mcp_web/static/ace/theme-one_dark.js +8 -0
- tex_mcp_web/static/ace/theme-textmate.js +8 -0
- tex_mcp_web/static/embedpdf/LICENSE +21 -0
- tex_mcp_web/static/embedpdf/LICENSE.pdfium +196 -0
- tex_mcp_web/static/embedpdf/browser-BKLM0ThC-CkSOgtCM.js +1 -0
- tex_mcp_web/static/embedpdf/direct-engine-BA2WfEti.js +1 -0
- tex_mcp_web/static/embedpdf/embedpdf-7TNsu-EA.js +3 -0
- tex_mcp_web/static/embedpdf/embedpdf.js +1 -0
- tex_mcp_web/static/embedpdf/pdfium.wasm +0 -0
- tex_mcp_web/static/index.html +347 -0
- tex_mcp_web/static/style.css +1368 -0
- tex_mcp_web/static/viewer.js +5379 -0
- tex_mcp_web/structure.py +338 -0
- tex_mcp_web/synctex.py +414 -0
- tex_mcp_web/terms.py +485 -0
- tex_mcp_web/translate.py +291 -0
- tex_mcp_web/watcher.py +332 -0
- tex_mcp_web-0.8.2.dist-info/METADATA +244 -0
- tex_mcp_web-0.8.2.dist-info/RECORD +49 -0
- tex_mcp_web-0.8.2.dist-info/WHEEL +4 -0
- tex_mcp_web-0.8.2.dist-info/entry_points.txt +4 -0
- tex_mcp_web-0.8.2.dist-info/licenses/LICENSE +22 -0
tex_mcp_web/__init__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Set up a folder for tex review
|
|
2
|
+
|
|
3
|
+
The user said "do tex init", or `listen` found no config. Set up review for the current MCP
|
|
4
|
+
session. Read setup paths from `setup_info()`, not from the shell's current directory. Its
|
|
5
|
+
startup directory is where the config goes. Configuration discovery searches that directory
|
|
6
|
+
and its parents, never its subfolders. The discovered config and an existing binding may
|
|
7
|
+
differ. A successful binding is retained until the MCP process is restarted.
|
|
8
|
+
|
|
9
|
+
Resolve `dir` relative to the config's directory, and `main` relative to `dir`, or to the
|
|
10
|
+
config's directory when `dir` is unset. Init creates the config, not the paper. Confirm only
|
|
11
|
+
choices the user has not already authorized. For an explicitly requested setup for another
|
|
12
|
+
session, use that session's intended startup directory and report separately whether its
|
|
13
|
+
connection has been verified.
|
|
14
|
+
|
|
15
|
+
## 1. Inspect (write nothing)
|
|
16
|
+
|
|
17
|
+
- Call `setup_info()` before connecting or writing, and read its startup directory,
|
|
18
|
+
discovered config, and existing binding.
|
|
19
|
+
- Run `tex-mcp-web config` without arguments from the startup directory and inspect the
|
|
20
|
+
selected path and values. If the config serves the intended project, reuse it and edit only
|
|
21
|
+
the settings the request requires. If a parent config serves another project, leave it
|
|
22
|
+
unchanged and propose a new config in the startup directory. If a config already exists in
|
|
23
|
+
the startup directory, edit that file rather than replacing it with init. A missing main
|
|
24
|
+
file alone does not establish that a config belongs to another project.
|
|
25
|
+
- Main file: `grep -l '\\documentclass' *.tex` in the paper's folder. One hit is the proposal.
|
|
26
|
+
Several are asked. No hit in the startup directory: ask where the paper is, and that folder
|
|
27
|
+
becomes `dir`.
|
|
28
|
+
- Port: the first free one from 8765 (`ss -ltn`, or `lsof -iTCP -sTCP:LISTEN -P -n` on macOS), also skipping the port in a
|
|
29
|
+
`.html-mcp-web.yaml` in this folder, since both tools default to 8765.
|
|
30
|
+
- Compiler: `command -v latexmk`. Missing: tell the user, and do not install a TeX distribution.
|
|
31
|
+
- References: a folder the user names that holds the papers theirs builds on, converted to
|
|
32
|
+
Markdown. Unnamed: leave it unset.
|
|
33
|
+
- Guideline: `setup_info()` lists the user's guidelines folder, with its absolute path and
|
|
34
|
+
entries. Mark an entry without GUIDELINE.md, which cannot be chosen. None is the default.
|
|
35
|
+
- Page token: off is the default. Every account on a machine can reach the page's port, so on
|
|
36
|
+
a machine other people log in to, such as a lab server, the page token keeps them out.
|
|
37
|
+
|
|
38
|
+
## 2. Confirm
|
|
39
|
+
|
|
40
|
+
Show one summary, in the user's language, like:
|
|
41
|
+
|
|
42
|
+
config : <startup directory>/.tex-mcp-web.yaml
|
|
43
|
+
dir : paper2 (the paper's folder, relative to the config)
|
|
44
|
+
main : main.tex (in dir, the only file with \documentclass)
|
|
45
|
+
port : 8765
|
|
46
|
+
watch : *.tex *.bib -> 9 files (main.tex, sections/*.tex, refs.bib)
|
|
47
|
+
ignore : *_backup.tex
|
|
48
|
+
compiler : auto (latexmk)
|
|
49
|
+
references : ../related (the reference papers in Markdown, relative to the config)
|
|
50
|
+
guideline : my-field (writing rules the agent reads before editing)
|
|
51
|
+
page token : off (on: the page opens only from its link, for a machine others log in to)
|
|
52
|
+
|
|
53
|
+
Leave out `dir` when the paper is in the startup directory, `references` when the user named
|
|
54
|
+
no folder, and `guideline` when the user has none. Under guideline, list the user's
|
|
55
|
+
guidelines and none, each with one line on what it is for, read from its first lines, with
|
|
56
|
+
none as the default. Under page token, ask whether other people can log in to this machine:
|
|
57
|
+
on, the page opens only from a link with a token only this account can read, once per
|
|
58
|
+
browser, after which its plain address works as a bookmark there. Off, the plain address
|
|
59
|
+
opens it for anyone on the machine, as it does on a computer of one's own. Ask once. A choice
|
|
60
|
+
the user has already approved is not asked again.
|
|
61
|
+
|
|
62
|
+
## 3. Write
|
|
63
|
+
|
|
64
|
+
Run init and subsequent config commands with the MCP startup directory as their working
|
|
65
|
+
directory, with the command that `listen()`'s missing-config error names. Changing the shell's
|
|
66
|
+
working directory does not retarget this MCP process.
|
|
67
|
+
|
|
68
|
+
tex-mcp-web --port <port> init --main <file>
|
|
69
|
+
tex-mcp-web config <dir|compiler|watch|ignore|references|guideline|page_token> <value> # only for changed fields
|
|
70
|
+
|
|
71
|
+
Continue only if init succeeds and the expected local config exists. Before changing any
|
|
72
|
+
setting, run `tex-mcp-web config` without arguments and confirm that it selects the intended
|
|
73
|
+
file. Stop if it selects another file. Inspect the resulting values after the changes.
|
|
74
|
+
|
|
75
|
+
In this setup, use `init --force` only after confirming that the existing config is in a
|
|
76
|
+
parent directory and creating a local config is authorized. It creates the local config
|
|
77
|
+
without changing the parent. Do not use it to replace an existing local config.
|
|
78
|
+
|
|
79
|
+
## 4. Verify
|
|
80
|
+
|
|
81
|
+
Before reporting completion, confirm that configuration discovery selects the intended file,
|
|
82
|
+
`dir` resolves to the intended paper directory, and `main` resolves to an existing paper file.
|
|
83
|
+
|
|
84
|
+
Then check the current connection without a build. Call `guide()`, which connects this session
|
|
85
|
+
to its project, read every file in its read list in full, and then call `setup_info()`. Its
|
|
86
|
+
`bound_config_path` must be the intended config. A review server is shared only by the same config file, so this is the config the
|
|
87
|
+
review page serves. If it is bound to another config, restart the MCP server process through
|
|
88
|
+
the client and check again. A successful init or CLI config check alone does not establish
|
|
89
|
+
this.
|
|
90
|
+
|
|
91
|
+
Report setup as complete only after both the files and the current connection have been
|
|
92
|
+
verified. Otherwise report what was prepared and what remains unverified.
|
|
93
|
+
|
|
94
|
+
## 5. Tell the user
|
|
95
|
+
|
|
96
|
+
Report setup as ready only after the current MCP connection passes the check. If restarting
|
|
97
|
+
the MCP process remains necessary, say that the files are prepared but this session is not
|
|
98
|
+
ready yet, and state the remaining action. Saying "do tex listen" starts the review page. With
|
|
99
|
+
the page token on, give the user the link `tex-mcp-web link` prints in the paper's folder, to
|
|
100
|
+
open once in each browser.
|
|
101
|
+
To follow an Overleaf project, the user presses Connect Overleaf in the page's top bar (in
|
|
102
|
+
the ⋯ menu on a phone) and follows what it asks. Tell them so in one line.
|
|
103
|
+
With the plugin, Claude Code started in the folder later listens on its own. Start listening
|
|
104
|
+
only when the user asks for it.
|
tex_mcp_web/cli.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""tex-mcp-web command-line interface.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
serve run the viewer server on its own (default if no subcommand)
|
|
5
|
+
link print the page's address, with its token when page_token is on
|
|
6
|
+
init scaffold .tex-mcp-web.yaml
|
|
7
|
+
config get/set values in .tex-mcp-web.yaml
|
|
8
|
+
compile one-shot compile, print structured errors
|
|
9
|
+
goto scroll a running viewer
|
|
10
|
+
mcp run the MCP server (stdio transport) and serve the viewer
|
|
11
|
+
|
|
12
|
+
Comment management lives in the browser (for humans) and the MCP tools
|
|
13
|
+
(for the agent). The CLI deliberately does not expose comment commands
|
|
14
|
+
because nobody types ``tex-mcp-web comment add ...`` from a shell.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .config import DEFAULT_PORT, create_config, find_config, load_config
|
|
27
|
+
from . import __version__
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Logging
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _configure_logging(verbose: bool) -> None:
|
|
36
|
+
logging.basicConfig(
|
|
37
|
+
level=logging.DEBUG if verbose else logging.INFO,
|
|
38
|
+
format="%(asctime)s %(levelname)-7s %(name)s — %(message)s",
|
|
39
|
+
datefmt="%H:%M:%S",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# serve
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_serve(args: argparse.Namespace) -> int:
|
|
49
|
+
from .server import run as run_server
|
|
50
|
+
|
|
51
|
+
cfg = load_config(main_file=getattr(args, "main", None))
|
|
52
|
+
port = args.port or cfg.port
|
|
53
|
+
print(
|
|
54
|
+
f"tex-mcp-web v{__version__} serving {cfg.main} at http://127.0.0.1:{port}"
|
|
55
|
+
+ (" (open it from the link `tex-mcp-web link` prints)" if cfg.page_token else ""),
|
|
56
|
+
file=sys.stderr,
|
|
57
|
+
)
|
|
58
|
+
run_server(cfg, port=port)
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# link
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_link(args: argparse.Namespace) -> int:
|
|
68
|
+
"""The page's address, to open or bookmark: with page_token on, the link with the token,
|
|
69
|
+
which a browser opens once and then keeps."""
|
|
70
|
+
from .page_token import link
|
|
71
|
+
|
|
72
|
+
cfg = load_config()
|
|
73
|
+
print(link(args.port or cfg.port, cfg.page_token))
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# init
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
83
|
+
existing = find_config()
|
|
84
|
+
if existing and not args.force:
|
|
85
|
+
# init writes here. A config found higher up is left alone by --force.
|
|
86
|
+
if existing.parent.resolve() == Path.cwd().resolve():
|
|
87
|
+
print(f"Config already exists here: {existing}", file=sys.stderr)
|
|
88
|
+
print("--force overwrites it.", file=sys.stderr)
|
|
89
|
+
else:
|
|
90
|
+
print(f"Config found in a parent folder: {existing}", file=sys.stderr)
|
|
91
|
+
print("--force writes a new config here, which takes precedence for this folder. "
|
|
92
|
+
"The parent is not changed.", file=sys.stderr)
|
|
93
|
+
return 1
|
|
94
|
+
path = create_config(main=args.main, port=args.port or DEFAULT_PORT)
|
|
95
|
+
print(f"Wrote {path}", file=sys.stderr)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# config
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_CONFIG_KEYS = ("main", "dir", "port", "compiler", "watch", "ignore", "references", "guideline", "page_token")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_config(args: argparse.Namespace) -> int:
|
|
108
|
+
import yaml
|
|
109
|
+
|
|
110
|
+
from .config import DEFAULT_CONFIG_NAME
|
|
111
|
+
|
|
112
|
+
path = find_config()
|
|
113
|
+
if path is None:
|
|
114
|
+
print(f"No {DEFAULT_CONFIG_NAME} found. `tex-mcp-web init` writes one here, "
|
|
115
|
+
"and `tex-mcp-web config dir <folder>` then names the paper's folder anywhere.", file=sys.stderr)
|
|
116
|
+
return 1
|
|
117
|
+
with open(path) as f:
|
|
118
|
+
data = yaml.safe_load(f) or {}
|
|
119
|
+
|
|
120
|
+
if args.key is None:
|
|
121
|
+
print(f"# {path}")
|
|
122
|
+
print(yaml.dump(data, default_flow_style=False, sort_keys=False), end="")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
if args.key not in _CONFIG_KEYS:
|
|
126
|
+
print(f"unknown key {args.key!r}; one of: {', '.join(_CONFIG_KEYS)}", file=sys.stderr)
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
if args.value is None:
|
|
130
|
+
if args.key not in data:
|
|
131
|
+
print(f"{args.key} is not set in {path}", file=sys.stderr)
|
|
132
|
+
return 1
|
|
133
|
+
v = data[args.key]
|
|
134
|
+
print(",".join(v) if isinstance(v, list) else v)
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
value: object = args.value
|
|
138
|
+
if args.key == "port":
|
|
139
|
+
value = int(args.value)
|
|
140
|
+
elif args.key == "page_token":
|
|
141
|
+
if args.value not in ("true", "false"):
|
|
142
|
+
print("page_token is true or false", file=sys.stderr)
|
|
143
|
+
return 1
|
|
144
|
+
value = args.value == "true"
|
|
145
|
+
elif args.key in ("watch", "ignore"):
|
|
146
|
+
value = [p.strip() for p in args.value.split(",") if p.strip()]
|
|
147
|
+
elif args.key == "guideline":
|
|
148
|
+
from .config import Config, get_guideline_files
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
get_guideline_files(Config.from_dict({"main": "main.tex", "guideline": args.value}))
|
|
152
|
+
except (OSError, ValueError) as error:
|
|
153
|
+
print(f"{error}. setup_info() lists the guidelines to choose from.", file=sys.stderr)
|
|
154
|
+
return 1
|
|
155
|
+
elif args.key == "compiler":
|
|
156
|
+
from .compiler import ALLOWED_COMPILERS
|
|
157
|
+
|
|
158
|
+
if args.value not in ALLOWED_COMPILERS:
|
|
159
|
+
print(
|
|
160
|
+
f"unknown compiler {args.value!r}; one of: {', '.join(sorted(ALLOWED_COMPILERS))}",
|
|
161
|
+
file=sys.stderr,
|
|
162
|
+
)
|
|
163
|
+
return 1
|
|
164
|
+
data[args.key] = value
|
|
165
|
+
with open(path, "w") as f:
|
|
166
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
167
|
+
print(f"{args.key} = {value} ({path})", file=sys.stderr)
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# compile (one-shot)
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cmd_compile(args: argparse.Namespace) -> int:
|
|
177
|
+
from .compiler import compile_tex
|
|
178
|
+
from .config import get_main_file, get_watch_dir
|
|
179
|
+
|
|
180
|
+
cfg = load_config(main_file=args.main)
|
|
181
|
+
main = get_main_file(cfg)
|
|
182
|
+
watch_dir = get_watch_dir(cfg)
|
|
183
|
+
|
|
184
|
+
result = asyncio.run(compile_tex(main, compiler=cfg.compiler, work_dir=watch_dir))
|
|
185
|
+
|
|
186
|
+
if args.json:
|
|
187
|
+
print(json.dumps(_compile_result_dict(result), indent=2))
|
|
188
|
+
else:
|
|
189
|
+
if result.success:
|
|
190
|
+
print(f"compile succeeded in {result.duration_seconds:.2f}s")
|
|
191
|
+
else:
|
|
192
|
+
print(f"compile FAILED ({len(result.errors)} errors)", file=sys.stderr)
|
|
193
|
+
for err in result.errors:
|
|
194
|
+
loc = f"{err.file}:{err.line}" if err.line else err.file
|
|
195
|
+
print(f" ERROR {loc} {err.message}", file=sys.stderr)
|
|
196
|
+
if err.context:
|
|
197
|
+
for line in err.context:
|
|
198
|
+
print(f" {line}", file=sys.stderr)
|
|
199
|
+
for w in result.warnings:
|
|
200
|
+
loc = f"{w.file}:{w.line}" if w.line else w.file
|
|
201
|
+
print(f" warn {loc} {w.message}", file=sys.stderr)
|
|
202
|
+
return 0 if result.success else 1
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _compile_result_dict(result) -> dict:
|
|
206
|
+
import dataclasses
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
"success": result.success,
|
|
210
|
+
"errors": [dataclasses.asdict(e) for e in result.errors],
|
|
211
|
+
"warnings": [dataclasses.asdict(w) for w in result.warnings],
|
|
212
|
+
"output_file": str(result.output_file) if result.output_file else None,
|
|
213
|
+
"duration_seconds": result.duration_seconds,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# goto (requires running daemon)
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cmd_goto(args: argparse.Namespace) -> int:
|
|
223
|
+
import urllib.request
|
|
224
|
+
|
|
225
|
+
from .mcp_server import parse_goto_target
|
|
226
|
+
|
|
227
|
+
cfg = load_config()
|
|
228
|
+
port = args.port or cfg.port
|
|
229
|
+
body = parse_goto_target(args.target, default_file=cfg.main)
|
|
230
|
+
|
|
231
|
+
from .page_token import headers
|
|
232
|
+
|
|
233
|
+
req = urllib.request.Request(
|
|
234
|
+
f"http://127.0.0.1:{port}/goto",
|
|
235
|
+
data=json.dumps(body).encode(),
|
|
236
|
+
headers={"Content-Type": "application/json", **headers()},
|
|
237
|
+
method="POST",
|
|
238
|
+
)
|
|
239
|
+
try:
|
|
240
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
241
|
+
print(resp.read().decode())
|
|
242
|
+
return 0
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
print(f"could not reach daemon at {port}: {exc}", file=sys.stderr)
|
|
245
|
+
return 2
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# mcp
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def cmd_mcp(args: argparse.Namespace) -> int:
|
|
254
|
+
if args.check:
|
|
255
|
+
from .mcp_server import check
|
|
256
|
+
|
|
257
|
+
tools = check(Path.cwd())
|
|
258
|
+
print(f"tex-mcp: {len(tools)} tools ({', '.join(tools)})")
|
|
259
|
+
return 0
|
|
260
|
+
from .mcp_server import main as mcp_main
|
|
261
|
+
|
|
262
|
+
mcp_main()
|
|
263
|
+
return 0
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
# Argument parsing
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
272
|
+
parser = argparse.ArgumentParser(
|
|
273
|
+
prog="tex-mcp-web",
|
|
274
|
+
description=(
|
|
275
|
+
"Live PDF preview + review-style commenting for LaTeX papers, "
|
|
276
|
+
"designed for a coding agent as the author. Run with no arguments to "
|
|
277
|
+
"start the server."
|
|
278
|
+
),
|
|
279
|
+
)
|
|
280
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="DEBUG logging")
|
|
281
|
+
parser.add_argument("--port", type=int, help="HTTP port (default: from config)")
|
|
282
|
+
|
|
283
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
284
|
+
|
|
285
|
+
p = sub.add_parser("serve", help="run the viewer server on its own (default)")
|
|
286
|
+
p.add_argument("--main", help="main .tex file (overrides config)")
|
|
287
|
+
p.set_defaults(func=cmd_serve)
|
|
288
|
+
|
|
289
|
+
p = sub.add_parser("link", help="print the page's address, with its token when page_token is on")
|
|
290
|
+
p.set_defaults(func=cmd_link)
|
|
291
|
+
|
|
292
|
+
p = sub.add_parser("init", help="scaffold .tex-mcp-web.yaml")
|
|
293
|
+
p.add_argument("--main", default="paper.tex")
|
|
294
|
+
p.add_argument("--force", action="store_true")
|
|
295
|
+
p.set_defaults(func=cmd_init)
|
|
296
|
+
|
|
297
|
+
p = sub.add_parser("config", help="get/set values in .tex-mcp-web.yaml")
|
|
298
|
+
p.add_argument("key", nargs="?", help=f"one of: {', '.join(_CONFIG_KEYS)}")
|
|
299
|
+
p.add_argument("value", nargs="?", help="new value (lists comma-separated); omit to print")
|
|
300
|
+
p.set_defaults(func=cmd_config)
|
|
301
|
+
|
|
302
|
+
p = sub.add_parser("compile", help="one-shot compile")
|
|
303
|
+
p.add_argument("--main", help="main .tex file (overrides config)")
|
|
304
|
+
p.add_argument("--json", action="store_true", help="JSON output")
|
|
305
|
+
p.set_defaults(func=cmd_compile)
|
|
306
|
+
|
|
307
|
+
p = sub.add_parser("goto", help="scroll a running viewer")
|
|
308
|
+
p.add_argument("target", help="section title, line number, file:line, or pN")
|
|
309
|
+
p.set_defaults(func=cmd_goto)
|
|
310
|
+
|
|
311
|
+
p = sub.add_parser("mcp", help="run the MCP server (stdio); serves the viewer too")
|
|
312
|
+
p.add_argument("--check", action="store_true",
|
|
313
|
+
help="build the server and list its tools instead of serving")
|
|
314
|
+
p.set_defaults(func=cmd_mcp)
|
|
315
|
+
|
|
316
|
+
return parser
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def main(argv: list[str] | None = None) -> int:
|
|
320
|
+
parser = build_parser()
|
|
321
|
+
args = parser.parse_args(argv)
|
|
322
|
+
_configure_logging(getattr(args, "verbose", False))
|
|
323
|
+
|
|
324
|
+
if not getattr(args, "func", None):
|
|
325
|
+
args.main = None
|
|
326
|
+
return cmd_serve(args)
|
|
327
|
+
return args.func(args)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _split_root_args(argv: list[str]) -> tuple[list[str], list[str]]:
|
|
331
|
+
# Root-parser flags must precede the subcommand in argparse.
|
|
332
|
+
root: list[str] = []
|
|
333
|
+
rest: list[str] = []
|
|
334
|
+
i = 0
|
|
335
|
+
while i < len(argv):
|
|
336
|
+
a = argv[i]
|
|
337
|
+
if a in ("-v", "--verbose") or a.startswith("--port="):
|
|
338
|
+
root.append(a)
|
|
339
|
+
elif a == "--port":
|
|
340
|
+
root.extend(argv[i : i + 2])
|
|
341
|
+
i += 1
|
|
342
|
+
else:
|
|
343
|
+
rest.append(a)
|
|
344
|
+
i += 1
|
|
345
|
+
return root, rest
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def main_mcp(argv: list[str] | None = None) -> int:
|
|
349
|
+
"""`tex-mcp` entry point: MCP stdio server."""
|
|
350
|
+
root, rest = _split_root_args(sys.argv[1:] if argv is None else argv)
|
|
351
|
+
return main([*root, "mcp", *rest])
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def main_serve(argv: list[str] | None = None) -> int:
|
|
355
|
+
"""`tex-web` entry point: web server."""
|
|
356
|
+
root, rest = _split_root_args(sys.argv[1:] if argv is None else argv)
|
|
357
|
+
return main([*root, "serve", *rest])
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
if __name__ == "__main__":
|
|
361
|
+
sys.exit(main())
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Server-owned, file-backed comment replies and rewrites. Only the Reply and Edit
|
|
2
|
+
blocks are editable."""
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import stat
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@contextlib.contextmanager
|
|
15
|
+
def _directory(root: Path, create: bool = False):
|
|
16
|
+
# Walk directory descriptors, never following a symlink, including ancestors.
|
|
17
|
+
root = root.absolute()
|
|
18
|
+
if ".." in root.parts:
|
|
19
|
+
raise ValueError("invalid draft directory")
|
|
20
|
+
fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
|
|
21
|
+
try:
|
|
22
|
+
for part in root.parts[1:]:
|
|
23
|
+
if create:
|
|
24
|
+
try:
|
|
25
|
+
os.mkdir(part, mode=0o700, dir_fd=fd)
|
|
26
|
+
except FileExistsError:
|
|
27
|
+
pass
|
|
28
|
+
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
|
|
29
|
+
os.close(fd)
|
|
30
|
+
fd = child
|
|
31
|
+
yield fd
|
|
32
|
+
finally:
|
|
33
|
+
os.close(fd)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read(fd: int, name: str) -> str:
|
|
37
|
+
source = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=fd)
|
|
38
|
+
with os.fdopen(source, "r", encoding="utf-8", newline="") as stream:
|
|
39
|
+
info = os.fstat(stream.fileno())
|
|
40
|
+
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
|
41
|
+
raise ValueError("draft must be a regular, unlinked file")
|
|
42
|
+
return stream.read()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def export(root: Path, comments: list[dict]) -> dict:
|
|
46
|
+
"""Write the draft. Each comment gets an Edit block per entry the agent wrote,
|
|
47
|
+
holding its current text, and one empty Reply block; everything else is fixed."""
|
|
48
|
+
ids = [comment["id"] for comment in comments]
|
|
49
|
+
if not ids or len(ids) != len(set(ids)):
|
|
50
|
+
raise ValueError("comment_ids must be nonempty and unique")
|
|
51
|
+
key = uuid.uuid4().hex
|
|
52
|
+
segments = ["# Comment replies\n\nEdit only Reply and Edit blocks; keep their markers. "
|
|
53
|
+
"A Reply block left empty adds nothing; an Edit block left as it is changes nothing.\n"]
|
|
54
|
+
slots: list[list] = [] # ["reply", comment_id] or ["edit", comment_id, entry_id, original]
|
|
55
|
+
for comment in comments:
|
|
56
|
+
# The heading carries what the reader needs to answer: which thread, where it
|
|
57
|
+
# sits, and its state. Anchors, selectors and stamps are the store's business
|
|
58
|
+
# and the snapshot beside this file already holds what the reply is checked
|
|
59
|
+
# against, so none of them is written out for someone to read past.
|
|
60
|
+
source = comment["resolved_source"] if "resolved_source" in comment else None
|
|
61
|
+
where = (f" {source['file']}:{source['line_start']}-{source['line_end']}"
|
|
62
|
+
if source is not None else "")
|
|
63
|
+
segments[-1] += f"\n## {comment['id']}{where} [{comment['status']}]\n"
|
|
64
|
+
for entry in comment["thread"]:
|
|
65
|
+
# A human entry is there to be read; an agent's own entry is there to be
|
|
66
|
+
# rewritten, and its Edit block already holds the text, so neither is
|
|
67
|
+
# written twice.
|
|
68
|
+
if entry["author"] != "agent":
|
|
69
|
+
segments[-1] += f"\n{entry['author']} {entry['text']}\n"
|
|
70
|
+
continue
|
|
71
|
+
segments[-1] += f"\n### Edit {entry['id']}\n<!-- edit:{key}:{entry['id']} -->\n"
|
|
72
|
+
segments.append(f"\n<!-- /edit:{key}:{entry['id']} -->\n")
|
|
73
|
+
slots.append(["edit", comment["id"], entry["id"], entry["text"]])
|
|
74
|
+
segments[-1] += f"\n### Reply\n<!-- reply:{key}:{comment['id']} -->\n"
|
|
75
|
+
segments.append(f"\n<!-- /reply:{key}:{comment['id']} -->\n")
|
|
76
|
+
slots.append(["reply", comment["id"]])
|
|
77
|
+
# Between an opening marker line and its closing marker sits the block's content:
|
|
78
|
+
# the entry's current text for an Edit block, nothing yet for a Reply block.
|
|
79
|
+
body = ""
|
|
80
|
+
for index, segment in enumerate(segments):
|
|
81
|
+
body += segment
|
|
82
|
+
if index < len(slots) and slots[index][0] == "edit":
|
|
83
|
+
body += slots[index][3]
|
|
84
|
+
snapshot = {"ids": ids, "updated": [c["updated"] for c in comments], "segments": segments, "slots": slots}
|
|
85
|
+
root = root.absolute()
|
|
86
|
+
with _directory(root, create=True) as fd:
|
|
87
|
+
for suffix, text in ((".snapshot", json.dumps(snapshot, ensure_ascii=False)), (".md", body)):
|
|
88
|
+
target = os.open(key + suffix, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=fd)
|
|
89
|
+
with os.fdopen(target, "w", encoding="utf-8", newline="") as stream:
|
|
90
|
+
stream.write(text)
|
|
91
|
+
return {"path": str(root / (key + ".md")), "sha256": hashlib.sha256(body.encode("utf-8")).hexdigest(), "comment_ids": ids}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load(root: Path, path: str) -> tuple[dict[str, str], list[tuple[str, str, str]], dict[str, str]]:
|
|
95
|
+
"""Read a filled draft back: (replies by comment id, entry rewrites as (comment id,
|
|
96
|
+
entry id, text), expected updated stamps by comment id). Only changed blocks count."""
|
|
97
|
+
candidate = Path(path)
|
|
98
|
+
if ".." in candidate.parts or candidate.parent != root.absolute() or not re.fullmatch(r"[0-9a-f]{32}\.md", candidate.name):
|
|
99
|
+
raise ValueError("draft must be a server-created draft path")
|
|
100
|
+
try:
|
|
101
|
+
with _directory(root) as fd:
|
|
102
|
+
snapshot = json.loads(_read(fd, candidate.stem + ".snapshot"))
|
|
103
|
+
body = _read(fd, candidate.name)
|
|
104
|
+
# Exact immutable segments protect IDs, snapshot timestamps and thread text.
|
|
105
|
+
match = re.fullmatch("(.*?)".join(re.escape(s) for s in snapshot["segments"]), body, re.DOTALL)
|
|
106
|
+
if match is None or len(match.groups()) != len(snapshot["slots"]):
|
|
107
|
+
raise ValueError("malformed draft: edit only Reply and Edit blocks")
|
|
108
|
+
replies: dict[str, str] = {}
|
|
109
|
+
entry_edits: list[tuple[str, str, str]] = []
|
|
110
|
+
for slot, value in zip(snapshot["slots"], match.groups()):
|
|
111
|
+
if slot[0] == "reply":
|
|
112
|
+
if value.strip():
|
|
113
|
+
replies[slot[1]] = value.strip()
|
|
114
|
+
elif value.strip() != slot[3].strip():
|
|
115
|
+
if not value.strip():
|
|
116
|
+
raise ValueError(f"the Edit block for {slot[2]} must not be emptied")
|
|
117
|
+
entry_edits.append((slot[1], slot[2], value.strip()))
|
|
118
|
+
if not replies and not entry_edits:
|
|
119
|
+
raise ValueError("the draft holds no reply and no changed Edit block")
|
|
120
|
+
expected = dict(zip(snapshot["ids"], snapshot["updated"]))
|
|
121
|
+
return replies, entry_edits, expected
|
|
122
|
+
except (OSError, UnicodeError, KeyError, TypeError, IndexError, json.JSONDecodeError) as error:
|
|
123
|
+
raise ValueError("invalid or unregistered draft") from error
|