overleaf-forge 2.9.1 → 2.12.0
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.
- package/README.md +91 -42
- package/dependency-index.js +189 -0
- package/efficiency.js +138 -0
- package/overleaf-mcp-server.js +457 -243
- package/package.json +7 -2
- package/render-cache.js +161 -0
- package/runtime-observability.js +109 -0
- package/transactions.js +219 -0
- package/writing-guidelines.md +24 -222
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/overleaf-forge) [](#license) 
|
|
4
4
|
|
|
5
|
-
A [Model Context Protocol](https://modelcontextprotocol.io) server that lets an AI assistant (Claude Code, Claude Desktop, or any MCP client) read, edit, compile, and verify an [Overleaf](https://www.overleaf.com) project over Overleaf's built-in **git** integration. The model edits a local clone with surgical, conflict-safe operations and
|
|
5
|
+
A [Model Context Protocol](https://modelcontextprotocol.io) server that lets an AI assistant (Claude Code, Claude Desktop, or any MCP client) read, edit, compile, and verify an [Overleaf](https://www.overleaf.com) project over Overleaf's built-in **git** integration. The model edits a local clone with surgical, conflict-safe operations and publishes verified batches to Overleaf. Nothing depends on scraping the web UI.
|
|
6
6
|
|
|
7
7
|
> **Acknowledgement.** Forked from [mjyoo2/OverleafMCP](https://github.com/mjyoo2/OverleafMCP), the original Overleaf MCP server. This fork adds conflict-safe editing, binary/figure upload, a clean-build PASS/FAIL gate, citation and voice linting, snapshots, a bootstrap for recurring structured documents, per-project contexts, and a hardened git layer (no-shell `execFile`, credential-helper auth, error redaction).
|
|
8
8
|
|
|
@@ -12,35 +12,49 @@ Editing a LaTeX project through an AI normally means one of two bad options: pas
|
|
|
12
12
|
|
|
13
13
|
## Token economy
|
|
14
14
|
|
|
15
|
-
Safety is one motivation
|
|
15
|
+
Safety is one motivation. Keeping a large document out of the model's context window is the other, and it drove most of the tool design. A 50 KB chapter is about 12K to 13K tokens, so the cost of a naive workflow is dominated by moving that whole file in and out.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Measured token cost on a real 48 KB chapter (a 30-page report with a bibliography and `minted` listings). The whole-file column is an estimate of the read-and-rewrite alternative. The overleaf-forge column is measured over MCP, at about four characters per token.
|
|
18
18
|
|
|
19
|
-
| Operation (on a ~50 KB chapter) | Whole-file workflow | overleaf-forge | Reduction |
|
|
19
|
+
| Operation (on a ~50 KB chapter) | Whole-file workflow (estimate) | overleaf-forge (measured) | Reduction |
|
|
20
20
|
| --- | --- | --- | --- |
|
|
21
|
-
| One surgical edit | ~25K (read + write the file) | ~
|
|
22
|
-
| A dozen edits (one revision pass) | ~170K | ~3K |
|
|
23
|
-
| One
|
|
21
|
+
| One surgical edit | ~25K (read + write the file) | ~25 tokens (`edit_file`) | >99% |
|
|
22
|
+
| A dozen edits (one revision pass) | ~170K | ~0.3K | >99% |
|
|
23
|
+
| One passing build check | ~1.5K to 2K (raw `latexmk` log) | ~4 tokens (`verify_build` PASS) | >99% |
|
|
24
24
|
| Locating a passage | ~13K (read the whole file) | ~2K (`get_section_content` / `search_text`) | ~85% |
|
|
25
|
+
| Re-reading unchanged guidance | full guidance again | ~25 tokens (`get_context` with `previousVersion`) | >99% |
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
The absolute numbers scale with file size. The percentages roughly hold.
|
|
27
28
|
|
|
28
|
-
- **Anchored edits instead of whole-file rewrites.** Changing one phrase by reading the whole file and writing it back costs roughly 25K tokens per edit: the file
|
|
29
|
-
- **A
|
|
30
|
-
- **Section and grep reads instead of the whole file.** `get_section_content` returns one section and `search_text` returns the matching lines, so locating something costs 1K to 3K tokens rather than
|
|
29
|
+
- **Anchored edits instead of whole-file rewrites.** Changing one phrase by reading the whole file and writing it back costs roughly 25K tokens per edit: the file enters the context, then leaves it again as the write payload. `edit_file` sends only the old and new strings and returns one line naming the new local commit.
|
|
30
|
+
- **A verdict instead of a log.** A passing `verify_build` returns `PASS: 30 pages.` A failing one returns the counts, the first offending lines and the log path. A raw multi-pass log costs hundreds to thousands of tokens and buries the true final state under transient undefined-reference warnings from early passes, which `verify_build` avoids by classifying the final log only.
|
|
31
|
+
- **Section and grep reads instead of the whole file.** `get_section_content` returns one section and `search_text` returns the matching lines, so locating something costs 1K to 3K tokens rather than 13K.
|
|
32
|
+
- **Fewer network round trips.** Reads, builds and edits work on the local clone. In the measured session below, the previous release made 29 git network operations and this one makes 1, the final push.
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
**A measured session.** Context read, section read, twelve anchored edits, an intermediate build, the final gate, a context re-read and a publish, on the chapter above:
|
|
35
|
+
|
|
36
|
+
| | 2.9.1 | 2.12 |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| Tool responses | ~13.9K tokens | ~5.5K tokens |
|
|
39
|
+
| Tool definitions (paid once per session) | ~3.9K tokens | ~5.0K tokens |
|
|
40
|
+
| Git network operations | 29 | 1 |
|
|
41
|
+
| Wall time against a local stand-in remote | 53 s | 53 s |
|
|
42
|
+
|
|
43
|
+
The remaining wall time is LaTeX. Against real Overleaf, every one of the 28 avoided network operations is an HTTPS round trip. Most of the saving in tool responses comes from the guidance: a shorter default guide, and a compact reply when it is re-read unchanged. A revision session that also loads a long personal style reference narrows the gap.
|
|
33
44
|
|
|
34
45
|
## Features
|
|
35
46
|
|
|
36
|
-
- **
|
|
47
|
+
- **Local-first, publish when verified**: edits commit to the local clone and stay off the network until `publish_changes` verifies the build and pushes the whole batch. `settings.autoPush` restores push-on-every-write.
|
|
48
|
+
- **Surgical, conflict-safe edits**: `edit_file` replaces an exact anchor and refuses if the region changed. When pushing, non-overlapping concurrent edits auto-merge via git.
|
|
37
49
|
- **No silent clobbering**: full-file `write_file` requires a freshness token (`baseSha`) or an explicit `overwrite` to replace an existing file.
|
|
38
|
-
- **
|
|
39
|
-
- **
|
|
40
|
-
- **
|
|
41
|
-
- **
|
|
50
|
+
- **Divergence recovery**: `sync_project` reports what differs between the clone and Overleaf without changing anything, then resolves it by rebase (aborts cleanly on conflict) or by a confirmed reset that tags a backup first.
|
|
51
|
+
- **Binary / figure upload**: add PNG/PDF figures from disk (single or a whole set in one commit), byte-exact, path-confined to the project.
|
|
52
|
+
- **Build verification**: `verify_build` compiles from scratch with `latexmk` and returns PASS/FAIL on the real "done" bar (a PDF, zero errors, zero undefined references/citations) with the page count. An unchanged successful build is reused instead of rebuilt. `clean: false` gives a quicker incremental rebuild for intermediate checks, and `lint` makes voice-linter findings fail the gate.
|
|
53
|
+
- **Citation tooling**: append BibTeX entries with duplicate-key protection, and lint for undefined and unused citations.
|
|
54
|
+
- **Section-aware reading**: list sections and pull a single section's body by title, optionally bundled with the equations, figures and bibliography entries it references.
|
|
55
|
+
- **Dependency tracking**: `dependency_index` and `change_report` name the sections affected by a changed label, value, citation or included file.
|
|
42
56
|
- **Project grep**: `search_text` over tracked files.
|
|
43
|
-
- **Snapshots**: `checkpoint` a rollback point before a risky edit
|
|
57
|
+
- **Snapshots**: `checkpoint` a rollback point before a risky edit, then `restore` it as a forward commit (no force-push, no history rewrite).
|
|
44
58
|
- **Recurring-document bootstrap**: one call to clone, register, and scaffold a new instance of a structured document (see [Bootstrap](#bootstrap-for-recurring-structured-documents)).
|
|
45
59
|
- **Per-project context**: durable notes and writing guidelines surfaced to the model at the start of a session.
|
|
46
60
|
- **Hardened git layer**: every subprocess runs through `execFile` (no shell), the token is supplied via an environment-backed credential helper and never appears in a command or an error, and tokenized URLs are redacted from any error returned.
|
|
@@ -49,7 +63,7 @@ For an iterative edit, build, and review loop on a large document the tool traff
|
|
|
49
63
|
|
|
50
64
|
- Node.js ≥ 18 (ESM).
|
|
51
65
|
- `git` on `PATH`.
|
|
52
|
-
- A LaTeX distribution with `latexmk` (only for `
|
|
66
|
+
- A LaTeX distribution with `latexmk` (only for `verify_build`; the rest works without it). The default engine is LuaLaTeX. `latexmk` is expected at `/Library/TeX/texbin` (MacTeX) or otherwise on `PATH`.
|
|
53
67
|
- An Overleaf account with **Git integration** enabled (a paid feature at time of writing).
|
|
54
68
|
|
|
55
69
|
## Install
|
|
@@ -76,7 +90,7 @@ For an iterative edit, build, and review loop on a large document the tool traff
|
|
|
76
90
|
|
|
77
91
|
3. Restart the client (or reload its MCP servers). That's it.
|
|
78
92
|
|
|
79
|
-
`npx` fetches and runs the published package on demand. The token and project id are the entire setup for a single project, with no config file (this is **env-only mode**). `@latest` means each client restart picks up the newest published version automatically
|
|
93
|
+
`npx` fetches and runs the published package on demand. The token and project id are the entire setup for a single project, with no config file (this is **env-only mode**). `@latest` means each client restart picks up the newest published version automatically. Pin `overleaf-forge@2.11.0` instead to freeze a version. For multiple projects, per-project contexts, or the SSA bootstrap, see [Configuration](#configuration).
|
|
80
94
|
|
|
81
95
|
An MCP server is not an app you launch yourself: the client starts it as a subprocess, so "installing" it just means making its command available to the client. The `npx` form above needs no install step. If you would rather have a real command on your `PATH`, install it globally:
|
|
82
96
|
|
|
@@ -98,7 +112,7 @@ Then use `"command": "node", "args": ["/absolute/path/to/overleaf-mcp-server.js"
|
|
|
98
112
|
|
|
99
113
|
### Wiring into specific clients
|
|
100
114
|
|
|
101
|
-
The `mcpServers` schema is identical across clients
|
|
115
|
+
The `mcpServers` schema is identical across clients: only the file location differs. Put the `npx` block above inside each.
|
|
102
116
|
|
|
103
117
|
| Client | Config file |
|
|
104
118
|
| --- | --- |
|
|
@@ -111,13 +125,15 @@ Restart the client (or reload its MCP servers) after editing, so it spawns the s
|
|
|
111
125
|
|
|
112
126
|
## Updating
|
|
113
127
|
|
|
114
|
-
**As a user.** With `overleaf-forge@latest` in your config (the recommended form), restart the client or reload its MCP servers and it fetches the newest published version. If you pinned a version (`overleaf-forge@2.
|
|
128
|
+
**As a user.** With `overleaf-forge@latest` in your config (the recommended form), restart the client or reload its MCP servers and it fetches the newest published version. If you pinned a version (`overleaf-forge@2.11.0`), change the number. If `npx` seems to keep running an old version, clear its cache with `npx clear-npx-cache` and restart. If you installed globally instead, update with `npm update -g overleaf-forge`.
|
|
129
|
+
|
|
130
|
+
**Upgrading from 2.9 or earlier: edits no longer push by default.** Since 2.11, `edit_file`, `write_file`, `upload_file`, `add_citation` and `restore` commit to the local clone and report the unpublished count: nothing reaches Overleaf until `publish_changes` runs. To keep the old push-on-every-write behaviour, set `autoPush: true` through `configure` (or pass `push: true` per call). Since 2.10, reads and builds no longer pull either, so start a session with `sync_project` when Overleaf may have moved. `compile_file` is now `verify_build({clean: false})`, and `get_section_bundle` is now `get_section_content({bundle: true})`.
|
|
115
131
|
|
|
116
132
|
**As the maintainer (publishing a new release).** From the repository:
|
|
117
133
|
|
|
118
134
|
```bash
|
|
119
135
|
npm version patch # or minor / major; bumps package.json and tags
|
|
120
|
-
npm publish #
|
|
136
|
+
npm publish # approve the npm 2FA prompt (browser or one-time code)
|
|
121
137
|
git push --follow-tags # push the commit and the version tag
|
|
122
138
|
```
|
|
123
139
|
|
|
@@ -176,11 +192,11 @@ That creates `~/.overleaf-mcp/projects.json` from the example and copies the edi
|
|
|
176
192
|
| `projects.<key>.cwd` | Directory you launch the client from for this project; used to auto-detect the active project. |
|
|
177
193
|
| `projects.<key>.localPath` | Explicit clone location (optional). |
|
|
178
194
|
|
|
179
|
-
`projects.json` is re-read on every call, so registering a project or rotating the token takes effect immediately, with no restart. (Code changes do need a restart
|
|
195
|
+
`projects.json` is re-read on every call, so registering a project or rotating the token takes effect immediately, with no restart. (Code changes do need a restart: the server loads its `.js` once at startup.)
|
|
180
196
|
|
|
181
197
|
### Where files live
|
|
182
198
|
|
|
183
|
-
User state (the `projects.json`, per-project `contexts/`, customised `templates/`, and the git clones) lives in the **data home**, resolved as: `$OVERLEAF_MCP_HOME` if set, else the package directory when it already holds a `projects.json` (so an existing local clone keeps working untouched), else `~/.overleaf-mcp`. Bundled, read-only defaults (the templates and the stock writing-guidelines) ship inside the package
|
|
199
|
+
User state (the `projects.json`, per-project `contexts/`, customised `templates/`, and the git clones) lives in the **data home**, resolved as: `$OVERLEAF_MCP_HOME` if set, else the package directory when it already holds a `projects.json` (so an existing local clone keeps working untouched), else `~/.overleaf-mcp`. Bundled, read-only defaults (the templates and the stock writing-guidelines) ship inside the package, and a copy you place in the data home overrides the bundled one. For personal writing rules, use `writing-guidelines.local.md` (gitignored, read first on every `get_context` call). It stays distinct from the bundled file even when the data home is the package directory.
|
|
184
200
|
|
|
185
201
|
### Getting Overleaf credentials
|
|
186
202
|
|
|
@@ -222,17 +238,20 @@ A typical editing session, in the model's words:
|
|
|
222
238
|
2. *"Show me the sections in `Chapters/ch2.tex`."* → `get_sections`
|
|
223
239
|
3. *"In `Chapters/ch2.tex`, change `\section{Intro}` to `\section{Introduction}`."* → `edit_file` (anchored, conflict-safe)
|
|
224
240
|
4. *"Add a figure: upload `~/plots/fig1.png` to `figures/fig1.png`."* → `upload_file`
|
|
225
|
-
5. *"Verify the build."* → `verify_build` →
|
|
241
|
+
5. *"Verify the build."* → `verify_build` → `PASS: 12 pages.`
|
|
242
|
+
6. *"Publish it."* → `publish_changes` (pushes every verified local commit to Overleaf at once)
|
|
226
243
|
|
|
227
|
-
Every write commits
|
|
244
|
+
Every write commits locally. `verify_build` is the gate before calling the work done, and `publish_changes` pushes the verified commits to Overleaf in one step. Set `settings.autoPush: true` (or pass `push: true`) to push on every write instead.
|
|
228
245
|
|
|
229
246
|
## Conflict safety
|
|
230
247
|
|
|
231
248
|
Edits never silently overwrite a concurrent Overleaf change.
|
|
232
249
|
|
|
233
|
-
-
|
|
234
|
-
- **`
|
|
250
|
+
- **Local by default.** Write tools commit to the local clone and stay off the network unless `push: true` or `settings.autoPush` is set. `publish_changes` pushes the accumulated commits after verification. When any push is refused, only that operation's own commit is rolled back: earlier unpublished commits are never discarded.
|
|
251
|
+
- **`edit_file`** replaces an exact anchor string. If the anchor is gone, the region changed since you read it, and the edit refuses rather than guessing. When pushing, it pulls first (so a non-overlapping browser edit is absorbed), and on the rare push race git performs a real 3-way merge and the edit refuses only on a true overlap.
|
|
252
|
+
- **`write_file`** (full-file create or overwrite) creates a new file freely. To overwrite an existing file it requires either the `baseSha` you got from `read_file` (a stale one is refused) or an explicit `overwrite: true`. It never merges a wholesale replacement: a push race refuses and rolls back its own commit.
|
|
235
253
|
- **`upload_file`** uses the same gate for binaries, never merges, and confines every destination path inside the project clone.
|
|
254
|
+
- **`sync_project`** fetches and fast-forwards when the clone is only behind. On divergence it changes nothing and reports the local and remote commits. `strategy: "rebase"` replays local work onto Overleaf (aborting cleanly on conflict), and `strategy: "reset"` discards local work only with `confirm` equal to the reported head, after tagging `mcp-backup/*` copies of the old head and any uncommitted edits.
|
|
236
255
|
- An explicit `projectName` that doesn't resolve is an **error**, never a silent fall-through to a different project, so a write cannot land in the wrong repo.
|
|
237
256
|
|
|
238
257
|
## Bootstrap for recurring structured documents
|
|
@@ -243,7 +262,7 @@ Edits never silently overwrite a concurrent Overleaf change.
|
|
|
243
262
|
|
|
244
263
|
By the built-in convention this parses the name, locates the parent course folder under `settings.academicRoot/Year <year>/Q*/<COURSE>*`, creates `<parent>/<ssaSubdir>/<name>/`, clones the Overleaf repo into an `overleaf/` subfolder, registers the project, and scaffolds a context file with a question template. Pass `cleanAfterClone: true` when the project was duplicated from a previous instance to wipe the body (chapters, appendices, bib, figures) while keeping the preamble.
|
|
245
264
|
|
|
246
|
-
**Adapting it.** The parsing and folder rules are specific to the SSA scheme
|
|
265
|
+
**Adapting it.** The parsing and folder rules are specific to the SSA scheme. To drive other recurring work, adjust `parseSsaName` / `findCourseFolder` and the `bootstrap_ssa` handler, or skip the bootstrap entirely and `register_project` each instance.
|
|
247
266
|
|
|
248
267
|
## Tools
|
|
249
268
|
|
|
@@ -267,7 +286,7 @@ By the built-in convention this parses the name, locates the parent course folde
|
|
|
267
286
|
| `read_file` | Read a file. The first line carries the file's `baseSha` (its git blob hash) for conflict-safe writes. |
|
|
268
287
|
| `list_files` | List files in the project, filtered by extension. |
|
|
269
288
|
| `get_sections` | List `\section` / `\subsection` / `\subsubsection` entries in a `.tex`. |
|
|
270
|
-
| `get_section_content` |
|
|
289
|
+
| `get_section_content` | One section's body by exact, unique title, `\section` down to `\paragraph` (level-aware: a section keeps its subsections). `bundle: true` adds the referenced equation/figure blocks, bibliography entries and assets. |
|
|
271
290
|
| `search_text` | Grep tracked files. Regex by default; `fixed` for a literal, `ignoreCase`, `extension` to scope. Returns `file:line:match`. |
|
|
272
291
|
| `status_summary` | File count, main file, section count. |
|
|
273
292
|
|
|
@@ -275,7 +294,7 @@ By the built-in convention this parses the name, locates the parent course folde
|
|
|
275
294
|
|
|
276
295
|
| Tool | Purpose |
|
|
277
296
|
| --- | --- |
|
|
278
|
-
| `edit_file` | Anchored `oldString` → `newString` edit + commit
|
|
297
|
+
| `edit_file` | Anchored `oldString` → `newString` edit + commit (push per `push` / `settings.autoPush`). Conflict-safe; auto-merges non-overlapping concurrent edits. Preferred for existing files. |
|
|
279
298
|
| `write_file` | Create a new file, or overwrite one wholesale. Existing-file overwrite needs `baseSha` or `overwrite: true`. |
|
|
280
299
|
| `upload_file` | Upload binary file(s) (figures) from a local path. Single or batch (one commit). Byte-exact, path-confined, same conflict gate. |
|
|
281
300
|
|
|
@@ -283,22 +302,52 @@ By the built-in convention this parses the name, locates the parent course folde
|
|
|
283
302
|
|
|
284
303
|
| Tool | Purpose |
|
|
285
304
|
| --- | --- |
|
|
286
|
-
| `
|
|
287
|
-
| `
|
|
305
|
+
| `verify_build` | Compile with `latexmk` from the repo root (project `.latexmkrc`, reruns and bibliography apply) + PASS/FAIL verdict: PASS only with a PDF and zero errors / undefined references / undefined citations. Reports page count. Default is the clean from-scratch done-bar gate; `clean: false` is a quick incremental rebuild; `lint` adds voice-linter findings to the gate. |
|
|
306
|
+
| `sync_project` | Fetch and reconcile with Overleaf: fast-forward, report divergence, or resolve it with `rebase` / confirmed `reset` (see Conflict safety). |
|
|
307
|
+
| `publish_changes` | Verify the clean local HEAD and push every unpublished commit once. |
|
|
308
|
+
| `apply_changes` | Verify a SHA-guarded multi-file batch in an isolated worktree, then commit it locally only if it passes. |
|
|
309
|
+
|
|
310
|
+
**Analysis & measurement**
|
|
311
|
+
|
|
312
|
+
| Tool | Purpose |
|
|
313
|
+
| --- | --- |
|
|
314
|
+
| `dependency_index` | Static map of includes, figures, labels, references, citations and declared values; names the sections a change affects. |
|
|
315
|
+
| `change_report` | Compact file and section changes against a retained baseline. |
|
|
316
|
+
| `render_pages` | Render chosen PDF pages to cached PNGs for visual checks. |
|
|
317
|
+
| `usage_stats` | In-process call counts, durations, response sizes and cache hits (no document text). |
|
|
288
318
|
|
|
289
319
|
**Citations, snapshots, voice**
|
|
290
320
|
|
|
291
321
|
| Tool | Purpose |
|
|
292
322
|
| --- | --- |
|
|
293
|
-
| `add_citation` | Append a BibTeX entry to `refs.bib` (refuses a duplicate key) +
|
|
323
|
+
| `add_citation` | Append a BibTeX entry to `refs.bib` (refuses a duplicate key) + commit. |
|
|
294
324
|
| `cite_lint` | Report undefined (`\cite` with no entry) and unused (entry never cited) citations. Read-only. |
|
|
295
325
|
| `checkpoint` | Mark a local rollback point (a `mcp-snap/<label>` tag) before a risky edit. |
|
|
296
|
-
| `restore` | Roll back to a checkpoint via a forward commit
|
|
297
|
-
| `voice_lint` | Run a prose linter on a `.tex` (the bundled `examples/voice-lint.mjs` by default; override via `settings.voiceLinter`). Lints the local working copy as-is, never pulls. Read-only
|
|
326
|
+
| `restore` | Roll back to a checkpoint via a forward commit (no force, no history rewrite). |
|
|
327
|
+
| `voice_lint` | Run a prose linter on a `.tex` (the bundled `examples/voice-lint.mjs` by default; override via `settings.voiceLinter`). Lints the local working copy as-is, never pulls. Read-only and advisory; `verify_build` with `lint` makes it gating. |
|
|
328
|
+
|
|
329
|
+
## Local reads, builds and batches
|
|
330
|
+
|
|
331
|
+
- `get_context({projectName, previousVersion})` returns a context version and omits unchanged content. The version covers project identity and the rendered guidance/context.
|
|
332
|
+
- `get_section_content({projectName, filePath, sectionTitle, bundle: true, maxChars})` reads locally without pulling and returns directly referenced equation/figure blocks, matching BibTeX entries and asset paths. It reports missing matches and truncation. Macro-generated references, recursive TeX expansion and parenthesized BibTeX entries require a focused follow-up read.
|
|
333
|
+
- All read, search, lint, dependency, render and build tools are local-only. Use `sync_project` explicitly before reading when needed. It fast-forwards on its own and resolves divergence only through an explicitly chosen strategy.
|
|
334
|
+
- Build output is compact by default. `verbose: true` includes a bounded log tail, and the full log stays at the returned path.
|
|
335
|
+
- `verify_build` can reuse a successful verification within the running server when project files, recorder inputs, tool binaries, environment and output artifacts are unchanged. `force: true` rebuilds. Missing recorder data, symlinks or executable build configuration (a project `.latexmkrc`, or the user's own `~/.latexmkrc`) conservatively disable reuse. The cache is in-memory and disappears on server restart. Custom build commands can have undeclared external dependencies, so projects with latexmkrc files or detected shell/Lua generation rebuild. `publish_changes` is the exception: it reuses a PASS from the same session when every project file is byte-identical since, so publishing does not repeat the final gate's full build.
|
|
336
|
+
- `controlled: true` runs `latexmk -norc -no-shell-escape` and permits caching in projects with a `.latexmkrc`, because the rc file cannot run. The mode still rejects shell escape, `minted` and Lua file generation. Add required absolute regular `externalInputs` so their content enters the cache key.
|
|
337
|
+
- `dependency_index` records static TeX includes, figures, labels, references, citations and declared values. `change_report` compares a later index to a retained baseline and names affected sections. Dynamic macros are reported as unresolved rather than guessed.
|
|
338
|
+
- `render_pages` caches a requested PDF page by PDF content hash, page, DPI and renderer version. `usage_stats` exposes aggregate in-process timing, response-size and cache-hit measurements without retaining request or document text.
|
|
339
|
+
- Write tools commit locally unless `push: true` or `settings.autoPush` is set, and report HEAD plus the unpublished count.
|
|
340
|
+
- `apply_changes` verifies an SHA-guarded UTF-8 multi-file candidate in an isolated worktree and fast-forwards one local commit only if verification passes. `publish_changes` separately re-verifies its exact clean revision, then pushes it once, carrying every unpublished local commit. Neither operation pulls, resets, retries or silently merges.
|
|
341
|
+
|
|
342
|
+
After updating the server, reconnect the MCP client so it reloads the process and tool schemas.
|
|
343
|
+
|
|
344
|
+
## Troubleshooting
|
|
345
|
+
|
|
346
|
+
**Every build fails with undefined citations on macOS 27.** TeX Live 2025 ships biber as a universal binary that unpacks itself with `lipo -extract_family`, which the `lipo` in macOS 27 rejects (`biber: extracting arm64 binary with lipo failed`). A user-level workaround: a small `biber` wrapper that extracts the native slice with `lipo -thin` and caches it, and a `~/.latexmkrc` line pointing latexmk at it (`$biber = "$ENV{HOME}/.local/bin/biber %O %S";`). A user rc file counts as executable build configuration, so non-controlled builds stop using the full build cache while it exists.
|
|
298
347
|
|
|
299
348
|
## How it works
|
|
300
349
|
|
|
301
|
-
Each project is a normal git clone of its Overleaf repo, kept under `repoDir` (or `localPath`).
|
|
350
|
+
Each project is a normal git clone of its Overleaf repo, kept under `repoDir` (or `localPath`). `sync_project` is the only synchronization command. Read, search, lint, dependency, render and build tools use the existing clone without pulling. This gives a task a stable source snapshot and prevents a read from replacing local edits. Git runs through `execFile` with argument arrays (no shell), so file paths, commit messages, and patterns can't inject commands. Authentication uses an inline git credential helper that reads the token from the process environment, so the token is never written into a remote URL, a command line, or an error message. The clone's `origin` stays token-free.
|
|
302
351
|
|
|
303
352
|
## Testing & development
|
|
304
353
|
|
|
@@ -308,18 +357,18 @@ node --test # run the full suite
|
|
|
308
357
|
node --check overleaf-mcp-server.js
|
|
309
358
|
```
|
|
310
359
|
|
|
311
|
-
Tests run the real client against a throwaway local bare repository that stands in for Overleaf, so the full edit/merge/conflict/upload/snapshot behaviour is exercised with no network and no real account. The `verify_build` log classifier is unit-tested on captured log strings
|
|
360
|
+
Tests run the real client against a throwaway local bare repository that stands in for Overleaf, so the full edit/merge/conflict/upload/snapshot behaviour is exercised with no network and no real account. The `verify_build` log classifier is unit-tested on captured log strings. A single integration test compiles a trivial document and auto-skips when `latexmk` isn't installed, so the suite is green on any machine.
|
|
312
361
|
|
|
313
362
|
## Security
|
|
314
363
|
|
|
315
|
-
- `projects.json` is gitignored
|
|
316
|
-
- The token is supplied to git through an environment-backed credential helper and never appears in a command string, a remote URL, or an error. Any tokenized URL that could surface in an error is redacted before it is returned. Rotate by updating `settings.gitToken
|
|
364
|
+
- `projects.json` is gitignored. Never commit a real token.
|
|
365
|
+
- The token is supplied to git through an environment-backed credential helper and never appears in a command string, a remote URL, or an error. Any tokenized URL that could surface in an error is redacted before it is returned. Rotate by updating `settings.gitToken`: it applies on the next call.
|
|
317
366
|
- All subprocess calls use `execFile` (no shell), so paths, commit messages, and section titles cannot inject shell commands.
|
|
318
367
|
- `upload_file` destinations are resolved and confined inside the project clone (no `..` escape, no absolute paths, not the `.git` directory).
|
|
319
368
|
|
|
320
369
|
## Origin & credits
|
|
321
370
|
|
|
322
|
-
Forked from [mjyoo2/OverleafMCP](https://github.com/mjyoo2/OverleafMCP). The original established the git-integration approach and the base read/write/compile tools
|
|
371
|
+
Forked from [mjyoo2/OverleafMCP](https://github.com/mjyoo2/OverleafMCP). The original established the git-integration approach and the base read/write/compile tools. This fork reworked the edit path for conflict safety, hardened the git layer, and added the verification, figure, citation, snapshot, voice, bootstrap, and context tooling.
|
|
323
372
|
|
|
324
373
|
## License
|
|
325
374
|
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, readFile, realpath, readdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const parseCache = new Map();
|
|
6
|
+
const snapshots = new Map();
|
|
7
|
+
const MAX_FILES = 1000;
|
|
8
|
+
const MAX_RESPONSE = 5000;
|
|
9
|
+
const MAX_SNAPSHOTS = 8;
|
|
10
|
+
|
|
11
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
12
|
+
const lineAt = (text, offset) => text.slice(0, offset).split('\n').length;
|
|
13
|
+
const rel = (root, file) => path.relative(root, file).split(path.sep).join('/');
|
|
14
|
+
|
|
15
|
+
function inside(root, candidate) {
|
|
16
|
+
const r = path.resolve(root);
|
|
17
|
+
const c = path.resolve(candidate);
|
|
18
|
+
return c === r || c.startsWith(`${r}${path.sep}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function withoutComments(source) {
|
|
22
|
+
return source.replace(/(^|[^\\])%[^\n]*/g, '$1');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sectionFor(sections, offset) {
|
|
26
|
+
let result = null;
|
|
27
|
+
for (const section of sections) {
|
|
28
|
+
if (section.offset <= offset) result = section;
|
|
29
|
+
else break;
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseFile(file, source, hash) {
|
|
35
|
+
const text = withoutComments(source);
|
|
36
|
+
const sections = [];
|
|
37
|
+
const sectionRe = /\\(part|chapter|section|subsection|subsubsection|paragraph|subparagraph)\*?\{((?:[^{}]|\{[^{}]*\})*)\}/g;
|
|
38
|
+
let match;
|
|
39
|
+
while ((match = sectionRe.exec(text))) sections.push({
|
|
40
|
+
id: `${file}#${match[2]}`,
|
|
41
|
+
title: match[2], type: match[1], line: lineAt(text, match.index), offset: match.index,
|
|
42
|
+
});
|
|
43
|
+
for (let i = 0; i < sections.length; i++) {
|
|
44
|
+
const nextOffset = i + 1 < sections.length ? sections[i + 1].offset : source.length;
|
|
45
|
+
sections[i].endLine = i + 1 < sections.length ? sections[i + 1].line - 1 : source.split('\n').length;
|
|
46
|
+
// Hash the section's own source span. This keeps an unchanged subsection
|
|
47
|
+
// stable when text in a sibling section shifts its line number.
|
|
48
|
+
sections[i].hash = digest(source.slice(sections[i].offset, nextOffset));
|
|
49
|
+
if (i > 0 && sections[i - 1].id === sections[i].id) sections[i].id += `:${i + 1}`;
|
|
50
|
+
}
|
|
51
|
+
const declarations = [];
|
|
52
|
+
const labels = [];
|
|
53
|
+
const refs = [];
|
|
54
|
+
const citations = [];
|
|
55
|
+
const includes = [];
|
|
56
|
+
const graphics = [];
|
|
57
|
+
const values = [];
|
|
58
|
+
if (/\.bib$/i.test(file)) {
|
|
59
|
+
const bibRe = /@\w+\s*\{\s*([^,\s]+)\s*,/g;
|
|
60
|
+
while ((match = bibRe.exec(text))) citations.push({ kind: 'bib', symbol: match[1], line: lineAt(text, match.index), section: null });
|
|
61
|
+
}
|
|
62
|
+
const declarationRe = /\\(?:newcommand|renewcommand|providecommand)\s*\{\\([^}]+)\}(?:\s*\[[^\]]*\])?\s*\{([^{}]*)\}/g;
|
|
63
|
+
while ((match = declarationRe.exec(text))) {
|
|
64
|
+
const symbol = `\\${match[1]}`;
|
|
65
|
+
const declaration = { symbol, value: match[2], line: lineAt(text, match.index), section: sectionFor(sections, match.index)?.id ?? null };
|
|
66
|
+
declarations.push(declaration); values.push(declaration);
|
|
67
|
+
}
|
|
68
|
+
const labelRe = /\\label\s*\{([^}]+)\}/g;
|
|
69
|
+
while ((match = labelRe.exec(text))) labels.push({ symbol: match[1], line: lineAt(text, match.index), section: sectionFor(sections, match.index)?.id ?? null });
|
|
70
|
+
const refRe = /\\(ref|pageref|autoref|cref|Cref)\s*\{([^}]+)\}/g;
|
|
71
|
+
while ((match = refRe.exec(text))) refs.push({ kind: match[1], symbol: match[2], line: lineAt(text, match.index), section: sectionFor(sections, match.index)?.id ?? null, dynamic: /\\|\$|#/.test(match[2]) });
|
|
72
|
+
const citeRe = /\\cite[a-zA-Z*]*\s*(?:\[[^\]]*\]\s*)?\{([^}]+)\}/g;
|
|
73
|
+
while ((match = citeRe.exec(text))) for (const symbol of match[1].split(',').map(x => x.trim()).filter(Boolean)) citations.push({ kind: 'cite', symbol, line: lineAt(text, match.index), section: sectionFor(sections, match.index)?.id ?? null });
|
|
74
|
+
const includeRe = /\\(input|include|includegraphics)\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}/g;
|
|
75
|
+
while ((match = includeRe.exec(text))) {
|
|
76
|
+
const item = { kind: match[1], target: match[2].trim(), line: lineAt(text, match.index), section: sectionFor(sections, match.index)?.id ?? null, dynamic: /\\|\$|#/.test(match[2]) };
|
|
77
|
+
(match[1] === 'includegraphics' ? graphics : includes).push(item);
|
|
78
|
+
}
|
|
79
|
+
return { file, hash, sections, declarations, labels, refs, citations, includes, graphics, values };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function filesUnder(root, notices) {
|
|
83
|
+
const result = [];
|
|
84
|
+
async function walk(dir) {
|
|
85
|
+
let entries;
|
|
86
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch (error) { notices.push(`cannot read ${rel(root, dir)}: ${error.message}`); return; }
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const full = path.join(dir, entry.name);
|
|
89
|
+
if (entry.isSymbolicLink()) { notices.push(`skipped symlink ${rel(root, full)}`); continue; }
|
|
90
|
+
if (entry.isDirectory()) { await walk(full); continue; }
|
|
91
|
+
if (entry.isFile() && /\.(?:tex|sty|cls|bib|bbx|cbx|ltx)$/i.test(entry.name)) result.push(full);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
await walk(root);
|
|
95
|
+
return result.sort();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveTarget(target, from, root, files) {
|
|
99
|
+
if (!target || /[\\$#]/.test(target)) return null;
|
|
100
|
+
const base = path.resolve(root, path.dirname(from), target);
|
|
101
|
+
const candidates = [base, ...(!path.extname(base) ? ['.tex', '.sty', '.cls', '.bib'].map(ext => `${base}${ext}`) : [])];
|
|
102
|
+
return candidates.find(candidate => files.has(rel(root, candidate))) ? rel(root, candidates.find(candidate => files.has(rel(root, candidate)))) : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function cap(list, limit, field, truncated) {
|
|
106
|
+
if (list.length <= limit) return list;
|
|
107
|
+
truncated[field] = true;
|
|
108
|
+
return list.slice(0, limit);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function dependencyIndex(root, { changedFiles = [], changedSymbols = [] } = {}) {
|
|
112
|
+
const rootReal = await realpath(root);
|
|
113
|
+
const notices = [];
|
|
114
|
+
const paths = await filesUnder(rootReal, notices);
|
|
115
|
+
const truncated = {};
|
|
116
|
+
const files = new Map();
|
|
117
|
+
const pathSet = new Set(paths.map(file => rel(rootReal, file)));
|
|
118
|
+
for (const file of paths.slice(0, MAX_FILES)) {
|
|
119
|
+
const relative = rel(rootReal, file);
|
|
120
|
+
const bytes = await readFile(file);
|
|
121
|
+
const hash = digest(bytes);
|
|
122
|
+
const key = `${rootReal}:${relative}`;
|
|
123
|
+
const old = parseCache.get(key);
|
|
124
|
+
const parsed = old?.hash === hash ? old : parseFile(relative, bytes.toString('utf8'), hash);
|
|
125
|
+
parseCache.set(key, parsed);
|
|
126
|
+
files.set(relative, parsed);
|
|
127
|
+
}
|
|
128
|
+
if (paths.length > MAX_FILES) truncated.files = true;
|
|
129
|
+
const labels = new Map();
|
|
130
|
+
const citations = new Map();
|
|
131
|
+
for (const file of files.values()) {
|
|
132
|
+
for (const label of file.labels) labels.set(label.symbol, { file: file.file, section: label.section, line: label.line });
|
|
133
|
+
for (const citation of file.citations) citations.set(citation.symbol, { file: file.file, section: citation.section, line: citation.line });
|
|
134
|
+
}
|
|
135
|
+
const edges = [];
|
|
136
|
+
const unresolved = [];
|
|
137
|
+
for (const file of files.values()) {
|
|
138
|
+
const add = (kind, item, target, symbol = null) => edges.push({ kind, from: { file: file.file, section: item.section, line: item.line }, to: target ? { file: target.file, section: target.section ?? null, line: target.line ?? null, symbol } : { symbol: target ?? symbol, unresolved: true } });
|
|
139
|
+
for (const item of file.refs) { const target = labels.get(item.symbol); add(item.kind, item, target, item.symbol); if (!target) unresolved.push({ kind: item.kind, file: file.file, section: item.section, line: item.line, symbol: item.symbol, dynamic: item.dynamic }); }
|
|
140
|
+
for (const item of file.citations) { const target = citations.get(item.symbol); add('cite', item, target, item.symbol); if (!target) unresolved.push({ kind: 'cite', file: file.file, section: item.section, line: item.line, symbol: item.symbol }); }
|
|
141
|
+
for (const item of [...file.includes, ...file.graphics]) { const targetFile = resolveTarget(item.target, file.file, rootReal, pathSet); add(item.kind, item, targetFile ? { file: targetFile } : item.target); if (!targetFile) unresolved.push({ kind: item.kind, file: file.file, section: item.section, line: item.line, symbol: item.target, dynamic: item.dynamic }); }
|
|
142
|
+
}
|
|
143
|
+
const changed = new Set();
|
|
144
|
+
for (const file of changedFiles) {
|
|
145
|
+
const candidate = path.resolve(rootReal, file);
|
|
146
|
+
if (!inside(rootReal, candidate)) throw new Error(`changed file must be inside root: ${file}`);
|
|
147
|
+
changed.add(rel(rootReal, candidate));
|
|
148
|
+
}
|
|
149
|
+
const affected = edges.filter(edge => changed.has(edge.to.file) || (edge.to.symbol && changedSymbols.includes(edge.to.symbol))).map(edge => edge.from);
|
|
150
|
+
const version = digest(JSON.stringify([...files].map(([file, value]) => [file, value.hash])));
|
|
151
|
+
return { version, files: cap([...files.values()].map(({ file, hash, sections, declarations }) => ({ file, hash, sections, symbols: declarations.map(item => item.symbol), definitions: declarations.slice(0, 200) })), MAX_FILES, 'files', truncated), edges: cap(edges, MAX_RESPONSE, 'edges', truncated), unresolved: cap(unresolved, MAX_RESPONSE, 'unresolved', truncated), affectedSections: cap([...new Map(affected.map(x => [x.file + '#' + x.section + ':' + x.line, x])).values()], MAX_RESPONSE, 'affectedSections', truncated), notices, truncated, truncatedFields: Object.keys(truncated) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function changeReport(root, baselineVersion) {
|
|
155
|
+
const index = await dependencyIndex(root);
|
|
156
|
+
const key = await realpath(root);
|
|
157
|
+
const previous = baselineVersion ? snapshots.get(key)?.get(baselineVersion) : null;
|
|
158
|
+
const firstCall = !baselineVersion;
|
|
159
|
+
const baselineFound = firstCall || Boolean(previous);
|
|
160
|
+
const oldFiles = previous?.files ?? [];
|
|
161
|
+
const oldMap = new Map(oldFiles.map(file => [file.file, file]));
|
|
162
|
+
const currentMap = new Map(index.files.map(file => [file.file, file]));
|
|
163
|
+
const allFileNames = new Set([...oldMap.keys(), ...currentMap.keys()]);
|
|
164
|
+
const changedFiles = [...allFileNames].filter(file => oldMap.get(file)?.hash !== currentMap.get(file)?.hash).sort();
|
|
165
|
+
const removedFiles = [...oldMap.keys()].filter(file => !currentMap.has(file)).sort();
|
|
166
|
+
const changedFileSet = new Set(changedFiles);
|
|
167
|
+
const oldEdges = previous?.edges ?? [];
|
|
168
|
+
const affectedEdges = firstCall ? [] : [...index.edges, ...oldEdges].filter(edge => changedFileSet.has(edge.to.file));
|
|
169
|
+
const affectedSections = [...new Map(affectedEdges.map(edge => [edge.from.file + '#' + edge.from.section + ':' + edge.from.line, edge.from])).values()];
|
|
170
|
+
const notices = [...index.notices];
|
|
171
|
+
if (baselineVersion && !previous) notices.push(`baseline version not found: ${baselineVersion}; full refresh required`);
|
|
172
|
+
const changedFileHashes = Object.fromEntries(index.files.filter(file => changedFiles.includes(file.file)).map(file => [file.file, file.hash]));
|
|
173
|
+
const changedSections = [];
|
|
174
|
+
if (!firstCall && previous) {
|
|
175
|
+
for (const file of allFileNames) {
|
|
176
|
+
const before = new Map((oldMap.get(file)?.sections ?? []).map(section => [section.id, section]));
|
|
177
|
+
const after = new Map((currentMap.get(file)?.sections ?? []).map(section => [section.id, section]));
|
|
178
|
+
for (const id of new Set([...before.keys(), ...after.keys()])) {
|
|
179
|
+
if (before.get(id)?.hash !== after.get(id)?.hash) changedSections.push({ file, section: id, beforeHash: before.get(id)?.hash ?? null, hash: after.get(id)?.hash ?? null, removed: !after.has(id) });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const report = { version: index.version, baselineVersion: baselineVersion ?? null, baselineFound, fullRefreshRequired: Boolean(baselineVersion && !previous), firstCall, changedFiles: firstCall || !previous ? [] : changedFiles, removedFiles: firstCall || !previous ? [] : removedFiles, changedFileHashes, changedSections: cap(changedSections, MAX_RESPONSE, 'changedSections', index.truncated), affectedSections: cap(affectedSections, MAX_RESPONSE, 'affectedSections', index.truncated), affectedRefs: cap(affectedEdges, MAX_RESPONSE, 'affectedRefs', index.truncated), suggestions: firstCall || !previous ? [] : affectedSections.map(section => ({ file: section.file, section: section.section, action: 'review references and recompile' })), unresolved: index.unresolved, notices, truncated: index.truncated, truncatedFields: Object.keys(index.truncated) };
|
|
184
|
+
if (!snapshots.has(key)) snapshots.set(key, new Map());
|
|
185
|
+
snapshots.get(key).set(index.version, index);
|
|
186
|
+
const retained = snapshots.get(key);
|
|
187
|
+
while (retained.size > MAX_SNAPSHOTS) retained.delete(retained.keys().next().value);
|
|
188
|
+
return report;
|
|
189
|
+
}
|