overleaf-forge 2.11.0 → 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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/overleaf-forge.svg)](https://www.npmjs.com/package/overleaf-forge) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](#license) ![Node](https://img.shields.io/badge/node-%E2%89%A518-43853d.svg)
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 publishes verified batches to Overleaf; nothing depends on scraping the web UI.
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; 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.
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
- Rough token cost per operation on such a chapter, and the cut each tool buys:
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) | ~0.15K (`edit_file`) | ~99% |
22
- | A dozen edits (one revision pass) | ~170K | ~3K | ~98% |
23
- | One compile check | ~1.5K (raw `latexmk` log) | ~0.02K (`verify_build` verdict) | ~99% |
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
- Figures are order-of-magnitude, for a chapter this size; the absolute numbers scale with file size, the percentages roughly hold.
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 into context, then the file back out as the write payload. `edit_file` sends only the old and new strings and returns a one-line confirmation, on the order of 100 tokens. Across a dozen edits to a single chapter that is the difference between roughly 170K tokens and 3K.
29
- - **A one-line build verdict instead of a raw log.** `verify_build` returns `✓ PASS 24 pages` rather than the `latexmk` output. A raw log runs to hundreds or thousands of tokens per compile, and a multi-pass log buries the true final state under transient undefined-reference warnings from early passes (the exact trap `verify_build` classifies away by reading the final log). Over a session of repeated compiles that is a few thousand tokens against a few dozen.
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 the full 13K.
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
- For an iterative edit, build, and review loop on a large document the tool traffic runs about an order of magnitude lighter than a read-and-rewrite-the-whole-file approach. The gain is workflow-dependent: a single full-file rewrite is a wash, since `write_file` moves the same bytes either way. It is the repeated, surgical work that compounds, which is exactly the shape of writing and revising a paper.
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
- - **Surgical, conflict-safe edits**: `edit_file` replaces an exact anchor and refuses if the region changed on Overleaf; non-overlapping concurrent edits auto-merge via git.
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
- - **Binary / figure upload**: push PNG/PDF figures from disk (single or a whole set in one commit), byte-exact, path-confined to the project.
39
- - **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.
40
- - **Citation tooling**: append BibTeX entries with duplicate-key protection; lint for undefined and unused citations.
41
- - **Section-aware reading**: list sections and pull a single section's body by title.
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; `restore` it as a forward commit (no force-push, no history rewrite).
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 `verify_build`; the rest works without it). The default engine is LuaLaTeX; `latexmk` is expected at `/Library/TeX/texbin` (MacTeX) or otherwise on `PATH`.
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; pin `overleaf-forge@2.7.1` instead to freeze a version. For multiple projects, per-project contexts, or the SSA bootstrap, see [Configuration](#configuration).
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; only the file location differs. Put the `npx` block above inside each.
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.7.1`), 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`.
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 # enter your npm 2FA one-time code when prompted
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; the server loads its `.js` once at startup.)
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; 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.
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,19 +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` → `✓ PASS 12 pages`
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 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.
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
- - **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.
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.
234
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.
235
- - **`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.
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.
236
253
  - **`upload_file`** uses the same gate for binaries, never merges, and confines every destination path inside the project clone.
237
- - **`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.
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.
238
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.
239
256
 
240
257
  ## Bootstrap for recurring structured documents
@@ -245,7 +262,7 @@ Edits never silently overwrite a concurrent Overleaf change.
245
262
 
246
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.
247
264
 
248
- **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.
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.
249
266
 
250
267
  ## Tools
251
268
 
@@ -288,6 +305,16 @@ By the built-in convention this parses the name, locates the parent course folde
288
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. |
289
306
  | `sync_project` | Fetch and reconcile with Overleaf: fast-forward, report divergence, or resolve it with `rebase` / confirmed `reset` (see Conflict safety). |
290
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). |
291
318
 
292
319
  **Citations, snapshots, voice**
293
320
 
@@ -299,9 +326,28 @@ By the built-in convention this parses the name, locates the parent course folde
299
326
  | `restore` | Roll back to a checkpoint via a forward commit (no force, no history rewrite). |
300
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. |
301
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.
347
+
302
348
  ## How it works
303
349
 
304
- 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.
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.
305
351
 
306
352
  ## Testing & development
307
353
 
@@ -311,33 +357,19 @@ node --test # run the full suite
311
357
  node --check overleaf-mcp-server.js
312
358
  ```
313
359
 
314
- 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.
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.
315
361
 
316
362
  ## Security
317
363
 
318
- - `projects.json` is gitignored; never commit a real token.
319
- - 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.
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.
320
366
  - All subprocess calls use `execFile` (no shell), so paths, commit messages, and section titles cannot inject shell commands.
321
367
  - `upload_file` destinations are resolved and confined inside the project clone (no `..` escape, no absolute paths, not the `.git` directory).
322
368
 
323
369
  ## Origin & credits
324
370
 
325
- 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.
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.
326
372
 
327
373
  ## License
328
374
 
329
375
  MIT. See [LICENSE](LICENSE) if present, or treat this as MIT per the upstream project.
330
-
331
- ## Efficient local reading and builds
332
-
333
- - `get_context({projectName, previousVersion})` returns a context version and omits unchanged content. The version covers project identity and the rendered guidance/context.
334
- - `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.
335
- - All read, search, lint, dependency, render and build tools are local-only. Use `sync_project` explicitly before reading when needed. It fast-forwards only; divergence is reported, never auto-resolved.
336
- - Build output is compact by default. `verbose: true` includes a bounded log tail; the full log remains at the returned path.
337
- - `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 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.
338
- - `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.
339
- - `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.
340
- - `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.
341
- - `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.
342
-
343
- After updating the server, reconnect the MCP client so it reloads the process and tool schemas. Package publication is separate from installing these local changes.
package/efficiency.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import os from 'node:os';
4
5
 
5
6
  const hash = value => createHash('sha256').update(value).digest('hex');
6
7
  export function versionedContext(key, text, previousVersion) {
@@ -23,6 +24,16 @@ export function controlledBuildOptions(options = {}) {
23
24
  return { sourcesOnly: options.sourcesOnly === true, controlled, externalInputs: [...new Set(externalInputs)] };
24
25
  }
25
26
 
27
+ // The rc files latexmk reads besides the project's own (latexmk(1), CONFIGURATION FILES).
28
+ export function latexmkUserRcFiles(env = process.env, home = os.homedir()) {
29
+ const xdg = env.XDG_CONFIG_HOME || path.join(home, '.config');
30
+ return [
31
+ env.LATEXMKRCSYS,
32
+ path.join(xdg, 'latexmk', 'latexmkrc'),
33
+ path.join(home, '.latexmkrc'),
34
+ ].filter(Boolean);
35
+ }
36
+
26
37
  export async function buildFingerprint(root, entry, engine, options = {}) {
27
38
  const { sourcesOnly, controlled, externalInputs } = controlledBuildOptions(options);
28
39
  const records = [];
@@ -45,6 +56,18 @@ export async function buildFingerprint(root, entry, engine, options = {}) {
45
56
  }
46
57
  };
47
58
  await visit(root);
59
+ // latexmk also executes user and system rc files outside the project. They
60
+ // are arbitrary Perl like a project .latexmkrc, so they get the same rule:
61
+ // their content enters the key, and outside controlled mode (-norc) their
62
+ // presence makes the full cache ineligible.
63
+ if (!controlled) {
64
+ for (const rc of latexmkUserRcFiles()) {
65
+ const bytes = await readFile(rc).catch(() => null);
66
+ if (bytes === null) continue;
67
+ records.push([rc, hash(bytes)]);
68
+ unsafe = true;
69
+ }
70
+ }
48
71
  for (const input of externalInputs) {
49
72
  const stat = await lstat(input).catch(() => null);
50
73
  if (!stat || !stat.isFile() || stat.isSymbolicLink()) throw new Error(`external input must be a regular non-symlink file: ${input}`);
@@ -20,6 +20,7 @@ import { applyChanges, publishChanges } from './transactions.js';
20
20
  import { observeTool, usageStats, toolError } from './runtime-observability.js';
21
21
  import { versionedContext, buildFingerprint, sectionBundle, sectionText, controlledBuildOptions } from './efficiency.js';
22
22
  const verifiedBuilds = new Map();
23
+ const recentPasses = new Map();
23
24
  const buildQueues = new Map();
24
25
 
25
26
  const __filename = fileURLToPath(import.meta.url);
@@ -594,12 +595,22 @@ class OverleafGitClient {
594
595
  async _verifyLocal(filePath, engine, options = {}) {
595
596
  const { force = false, clean = true } = options;
596
597
  const config = controlledBuildOptions(options);
597
- const key = JSON.stringify([this.repoPath,filePath,engine,config]);
598
+ // Resolved so the gate (client.repoPath) and publish (path.resolve(root)) share a key.
599
+ const key = JSON.stringify([path.resolve(this.repoPath),filePath,engine,config]);
598
600
  const fingerprint = () => buildFingerprint(this.repoPath,filePath,engine,config).catch(()=>null);
599
601
  const sources = () => buildFingerprint(this.repoPath,filePath,engine,{...config,sourcesOnly:true});
600
602
  const cached = verifiedBuilds.get(key);
601
603
  const before = await sources();
602
604
  if (!force && cached && cached.fingerprint === await fingerprint()) return { ...cached.verdict, reused: true };
605
+ // publish_changes re-verifies a commit the final gate usually just passed.
606
+ // Projects with executable config (rc files, minted, shell escape) never
607
+ // qualify for the full cache above, so without this every publish would
608
+ // rebuild from scratch. Reuse is allowed only when every project file is
609
+ // byte-identical to the state right after that PASS (the sources hash
610
+ // covers tracked and untracked files, the environment and the day) within
611
+ // this process. Not covered: a TeX installation change in between.
612
+ const recent = recentPasses.get(key);
613
+ if (options.reuseRecentPass && !force && recent && recent.sources === before) return { ...recent.verdict, reused: true };
603
614
  verifiedBuilds.delete(key);
604
615
  const { log: runLog, pdfPath, commandFailed } = await this._runLatexmk(filePath,engine,{clean,controlled:config.controlled});
605
616
  const logPath = path.join(this.repoPath,filePath.replace(/\.tex$/,'.log'));
@@ -618,6 +629,8 @@ class OverleafGitClient {
618
629
  const print = before && before === after ? await fingerprint() : null;
619
630
  verdict.cacheEligible = Boolean(print);
620
631
  if (verdict.pass && print) verifiedBuilds.set(key,{ fingerprint:print,verdict });
632
+ if (verdict.pass && before === after) recentPasses.set(key, { sources: after, verdict });
633
+ else recentPasses.delete(key);
621
634
  return verdict;
622
635
  }
623
636
 
@@ -940,8 +953,10 @@ function voiceLinterCommand(config) {
940
953
  // commits still waiting for publish_changes.
941
954
  function mutationTail(res, what) {
942
955
  if (res.pushed) return `${what} and pushed to Overleaf${res.merged ? ' (auto-merged a concurrent Overleaf change)' : ''}.`;
943
- const n = res.unpublished == null ? 'unknown' : res.unpublished;
944
- return `${what} and committed locally (HEAD ${res.head.slice(0, 12)}; ${n} unpublished commit(s)). NEXT: finish the batch, verify_build, then publish_changes with revision ${res.head} once publishing is authorized.`;
956
+ // The full hash is what publish_changes takes as revision; the workflow
957
+ // itself lives in the tool descriptions, not in every reply.
958
+ const n = res.unpublished == null ? '' : `, ${res.unpublished} unpublished`;
959
+ return `${what}; committed locally at ${res.head}${n}.`;
945
960
  }
946
961
 
947
962
  async function getClient(projectName) {
@@ -1118,7 +1133,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1118
1133
  ['render_pages', 'Render explicitly selected PDF pages to cached local PNG paths. Pages are one-based; no sync or build.', { filePath: { type: 'string' }, pages: { type: 'array', items: { type: 'integer', minimum: 1 }, minItems: 1, maxItems: 20 }, dpi: { type: 'integer', minimum: 36, maximum: 300 } }, ['filePath', 'pages']],
1119
1134
  ['usage_stats', 'In-process tool counts, durations, response bytes and cache hits. Stores no document content. Bytes are not billed tokens.', { reset: { type: 'boolean' } }, []],
1120
1135
  ['apply_changes', 'Verify a UTF-8 multi-file batch in an isolated worktree, then commit it locally. Requires clean tracked source, HEAD baseRevision and SHA-256 baseHash per file (null for new files). No push.', { baseRevision: { type: 'string' }, changes: { type: 'array', minItems: 1, maxItems: 100, items: { type: 'object', properties: { filePath: { type: 'string' }, baseHash: { type: ['string', 'null'] }, content: { type: 'string' } }, required: ['filePath', 'baseHash', 'content'] } }, filePath: { type: 'string' }, engine: { type: 'string' }, controlled: { type: 'boolean' }, externalInputs: { type: 'array', items: { type: 'string' } }, lint: { anyOf: [{ type: 'boolean' }, { type: 'array', items: { type: 'string' } }] } }, ['baseRevision', 'changes', 'filePath']],
1121
- ['publish_changes', 'Verify the clean local HEAD and push it once, publishing every unpublished local commit (from apply_changes or local-mode edit tools) together. revision must equal HEAD. No pull, retry, merge or reset; if Overleaf moved, run sync_project first. Requires publishing authorization.', { revision: { type: 'string' }, filePath: { type: 'string' }, engine: { type: 'string' }, controlled: { type: 'boolean' }, externalInputs: { type: 'array', items: { type: 'string' } }, lint: { anyOf: [{ type: 'boolean' }, { type: 'array', items: { type: 'string' } }] } }, ['revision', 'filePath']],
1136
+ ['publish_changes', 'Verify the clean local HEAD and push every unpublished commit once. revision must equal HEAD. Reuses this session\'s verify_build PASS when no project file changed since; force rebuilds. No pull, merge or retry: if Overleaf moved, sync_project first. Needs publishing authorization.', { revision: { type: 'string' }, force: { type: 'boolean' }, filePath: { type: 'string' }, engine: { type: 'string' }, controlled: { type: 'boolean' }, externalInputs: { type: 'array', items: { type: 'string' } }, lint: { anyOf: [{ type: 'boolean' }, { type: 'array', items: { type: 'string' } }] } }, ['revision', 'filePath']],
1122
1137
  ].map(([name, description, properties, required]) => ({ name, description, inputSchema: { type: 'object', properties: { projectName: { type: 'string' }, ...properties }, required } })),
1123
1138
  {
1124
1139
  name: 'get_context',
@@ -1258,7 +1273,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1258
1273
  },
1259
1274
  {
1260
1275
  name: 'sync_project',
1261
- description: 'Fetch Overleaf and reconcile the local clone. Fast-forwards when only behind; reports unpublished local commits when ahead. On divergence it changes nothing and returns both sides, unless strategy is given: "rebase" replays local commits onto Overleaf (aborts cleanly on conflict); "reset" discards local work to match Overleaf, requires confirm set to the reported local head, and tags mcp-backup/* first. Clones when no local copy exists. Builds and reads never sync.',
1276
+ description: 'Fetch Overleaf. Fast-forwards when behind; reports unpublished commits when ahead. On divergence it changes nothing and reports both sides unless strategy is "rebase" (aborts cleanly on conflict) or "reset" (needs confirm = the reported head; tags mcp-backup/* first). Clones a missing project.',
1262
1277
  inputSchema: { type: 'object', properties: {
1263
1278
  strategy: { type: 'string', enum: ['rebase', 'reset'], description: 'Only for a diverged clone. Omit to get the report first.' },
1264
1279
  confirm: { type: 'string', description: 'For strategy "reset": the full local head SHA from the report.' },
@@ -1282,7 +1297,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1282
1297
  },
1283
1298
  {
1284
1299
  name: 'verify_build',
1285
- description: 'Build the local entrypoint and return a PASS/FAIL verdict on the done-bar: PASS only if a PDF is produced with zero LaTeX errors, zero undefined references and zero undefined citations (and, with lint, zero voice-linter findings). Reports page count; overfull/underfull boxes are warnings. Default is the final gate: a clean from-scratch build, reusing an unchanged eligible PASS unless force is true. clean:false is a quick incremental rebuild for intermediate layout checks. Local only; no pull.',
1300
+ description: 'Build the entrypoint and return PASS/FAIL. PASS needs a PDF, zero LaTeX errors and zero undefined references and citations (and zero findings with lint); box warnings do not fail. Default: clean from-scratch final gate, reusing an unchanged eligible PASS unless force. clean:false: quick incremental rebuild for intermediate checks. Local only.',
1286
1301
  inputSchema: {
1287
1302
  type: 'object',
1288
1303
  properties: {
@@ -1301,7 +1316,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1301
1316
  },
1302
1317
  {
1303
1318
  name: 'edit_file',
1304
- description: 'Surgical, conflict-safe edit: replace oldString with newString in a file and commit. Commits locally by default (push:false unless settings.autoPush); publish_changes sends the verified batch. PREFER this over write_file for edits to existing files — it is far cheaper than a full rewrite and it cannot silently clobber a concurrent Overleaf edit (a missing oldString means the region changed; the edit refuses). When pushing, non-overlapping concurrent Overleaf edits auto-merge. oldString must match exactly once unless replaceAll is true. After the edit batch, use verify_build as the single final gate.',
1319
+ description: 'Anchored edit: replace oldString (must match once unless replaceAll) with newString and commit. A missing anchor means the region changed, so the edit refuses instead of clobbering it. Prefer over write_file for existing files. Commits locally unless push.',
1305
1320
  inputSchema: {
1306
1321
  type: 'object',
1307
1322
  properties: {
@@ -1310,7 +1325,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1310
1325
  newString: { type: 'string', description: 'Replacement text.' },
1311
1326
  replaceAll: { type: 'boolean', description: 'Replace every occurrence (default false; otherwise oldString must be unique).' },
1312
1327
  commitMessage: { type: 'string' },
1313
- push: { type: 'boolean', description: 'Push to Overleaf now. Defaults to settings.autoPush (false unless configured): the change stays a local commit until publish_changes sends the verified batch.' },
1328
+ push: { type: 'boolean', description: 'Push now. Default settings.autoPush; false keeps a local commit for publish_changes.' },
1314
1329
  projectName: { type: 'string' },
1315
1330
  },
1316
1331
  required: ['filePath', 'oldString', 'newString'],
@@ -1318,7 +1333,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1318
1333
  },
1319
1334
  {
1320
1335
  name: 'write_file',
1321
- description: 'Create a new file, or overwrite an existing one wholesale, and commit (local by default; see push). For edits to existing files prefer edit_file. Overwriting an existing file requires either baseSha (from read_file, so a stale write is refused) or overwrite:true. After the edit batch, use verify_build as the single final gate.',
1336
+ description: 'Create a file or replace one wholesale, and commit. Replacing needs baseSha from read_file (a stale one is refused) or overwrite:true. Prefer edit_file for changes. Commits locally unless push.',
1322
1337
  inputSchema: {
1323
1338
  type: 'object',
1324
1339
  properties: {
@@ -1327,7 +1342,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1327
1342
  baseSha: { type: 'string', description: 'The baseSha from read_file for this file. Required to overwrite an existing file safely; if Overleaf moved since, the write is refused.' },
1328
1343
  overwrite: { type: 'boolean', description: 'Force-overwrite an existing file without a baseSha (deliberate full replacement). Ignored for new files.' },
1329
1344
  commitMessage: { type: 'string' },
1330
- push: { type: 'boolean', description: 'Push to Overleaf now. Defaults to settings.autoPush (false unless configured): the change stays a local commit until publish_changes sends the verified batch.' },
1345
+ push: { type: 'boolean', description: 'Push now. Default settings.autoPush; false keeps a local commit for publish_changes.' },
1331
1346
  projectName: { type: 'string' },
1332
1347
  },
1333
1348
  required: ['filePath', 'content'],
@@ -1335,7 +1350,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1335
1350
  },
1336
1351
  {
1337
1352
  name: 'upload_file',
1338
- description: 'Upload a binary file (PNG/PDF figure, etc.) from a local disk path INTO the Overleaf project and commit (local by default; see push). write_file/edit_file are UTF-8 only — use this for binaries. Single: srcPath + destPath. Batch (one commit for a figure set): files: [{srcPath, destPath}, ...]. Existing dest files need baseSha (single mode, from read_file) or overwrite:true. After uploading, reference each figure with \\includegraphics{...} via edit_file, then verify_build.',
1353
+ description: 'Copy binary file(s) such as figures from a local path into the project and commit. Single: srcPath + destPath; batch: files[] in one commit. Existing destinations need baseSha (single) or overwrite:true. Commits locally unless push.',
1339
1354
  inputSchema: {
1340
1355
  type: 'object',
1341
1356
  properties: {
@@ -1349,7 +1364,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1349
1364
  baseSha: { type: 'string', description: 'Single-file mode only: baseSha from read_file; a stale value is refused. Ignored in batch.' },
1350
1365
  overwrite: { type: 'boolean', description: 'Replace existing dest file(s). Required to overwrite in batch mode.' },
1351
1366
  commitMessage: { type: 'string' },
1352
- push: { type: 'boolean', description: 'Push to Overleaf now. Defaults to settings.autoPush (false unless configured): the change stays a local commit until publish_changes sends the verified batch.' },
1367
+ push: { type: 'boolean', description: 'Push now. Default settings.autoPush; false keeps a local commit for publish_changes.' },
1353
1368
  projectName: { type: 'string' },
1354
1369
  },
1355
1370
  },
@@ -1377,7 +1392,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1377
1392
  properties: {
1378
1393
  entry: { type: 'string', description: 'A complete BibTeX entry, e.g. @article{key, title={...}, ...}.' },
1379
1394
  commitMessage: { type: 'string' },
1380
- push: { type: 'boolean', description: 'Push to Overleaf now. Defaults to settings.autoPush (false unless configured): the change stays a local commit until publish_changes sends the verified batch.' },
1395
+ push: { type: 'boolean', description: 'Push now. Default settings.autoPush; false keeps a local commit for publish_changes.' },
1381
1396
  projectName: { type: 'string' },
1382
1397
  },
1383
1398
  required: ['entry'],
@@ -1396,11 +1411,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1396
1411
  {
1397
1412
  name: 'restore',
1398
1413
  description: 'Roll back to a checkpoint: re-applies the snapshot\'s file tree as a NEW commit on top of history (no force-push, no history rewrite); intervening commits are preserved. Local by default; see push.',
1399
- inputSchema: { type: 'object', properties: { label: { type: 'string' }, push: { type: 'boolean', description: 'Push to Overleaf now. Defaults to settings.autoPush (false unless configured): the change stays a local commit until publish_changes sends the verified batch.' }, projectName: { type: 'string' } }, required: ['label'] },
1414
+ inputSchema: { type: 'object', properties: { label: { type: 'string' }, push: { type: 'boolean', description: 'Push now. Default settings.autoPush; false keeps a local commit for publish_changes.' }, projectName: { type: 'string' } }, required: ['label'] },
1400
1415
  },
1401
1416
  {
1402
1417
  name: 'voice_lint',
1403
- description: 'Lint a .tex file for prose issues. Runs a bundled generic example linter by default; override with settings.voiceLinter in projects.json or the OVERLEAF_VOICE_LINTER env var (a command that takes a file path and exits non-zero on findings). Lints the LOCAL working copy as-is and never pulls, so it reflects on-disk state including edits not yet pushed; if the project has not been cloned locally yet it errors rather than fetching. Read-only and advisory on its own; verify_build with lint makes findings fail the final gate. Useful after editing prose via edit_file/write_file, which bypass any local editor hooks.',
1418
+ description: 'Run the prose linter (settings.voiceLinter, else the bundled example) on a local .tex file; never pulls. Advisory on its own; verify_build with lint makes findings fail the gate.',
1404
1419
  inputSchema: {
1405
1420
  type: 'object',
1406
1421
  properties: { filePath: { type: 'string' }, projectName: { type: 'string' } },
@@ -1747,6 +1762,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => observeTool(r
1747
1762
  externalInputs: args.externalInputs,
1748
1763
  lint: args.lint,
1749
1764
  lintCommand: voiceLinterCommand(config),
1765
+ reuseRecentPass: name === 'publish_changes' && args.force !== true,
1750
1766
  });
1751
1767
  };
1752
1768
  const result = name === 'apply_changes' ? await applyChanges(client.repoPath, args, verify)
@@ -1771,12 +1787,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => observeTool(r
1771
1787
  const v = args.clean === false
1772
1788
  ? await client.compileFile(args.filePath, args.engine || 'lualatex', opts)
1773
1789
  : await client.verifyBuild(args.filePath, args.engine || 'lualatex', opts);
1774
- const parts = [`${v.pass ? 'PASS' : 'FAIL'}: ${v.pageCount ?? '?'} pages; ${v.errors.length} errors; ${v.undefinedRefs.length} undefined references; ${v.undefinedCitations.length} undefined citations; ${v.overfullCount} overfull / ${v.underfullCount} underfull boxes.`,
1775
- `Reused verification: ${v.reused}. Full log: ${v.logPath}`];
1776
- if (!v.pass) parts.push(...v.errors.slice(0,5),...v.undefinedRefs.slice(0,5),...v.undefinedCitations.slice(0,5));
1790
+ // A PASS already means zero errors and zero undefined references and
1791
+ // citations, so only box warnings and reuse are worth reporting. A FAIL
1792
+ // carries the counts, the first offenders and where the full log is.
1793
+ const boxes = v.overfullCount || v.underfullCount ? `; ${v.overfullCount} overfull / ${v.underfullCount} underfull boxes` : '';
1794
+ const parts = v.pass
1795
+ ? [`PASS: ${v.pageCount ?? '?'} pages${boxes}${v.reused ? ' (reused unchanged verification)' : ''}.`]
1796
+ : [`FAIL: ${v.pageCount ?? '?'} pages; ${v.errors.length} errors; ${v.undefinedRefs.length} undefined references; ${v.undefinedCitations.length} undefined citations${boxes}. Log: ${path.relative(client.repoPath, v.logPath)}`,
1797
+ ...v.errors.slice(0,5),...v.undefinedRefs.slice(0,5),...v.undefinedCitations.slice(0,5)];
1777
1798
  if (v.lint) parts.push(v.lint.clean ? `Voice lint: clean (${v.lint.results.length} file(s)).` : `Voice lint findings:\n${v.lint.results.filter(r => !r.clean).map(r => `${r.file}:\n${r.findings}`).join('\n')}`);
1778
1799
  if (args.verbose) parts.push(v.tail);
1779
- if (!v.cacheEligible && !v.reused) parts.push('Cache not retained: dependency closure unavailable, executable configuration, or changing inputs.');
1780
1800
  return { content:[{type:'text',text:parts.join('\n')}], structuredContent: { pass: v.pass, pageCount: v.pageCount, reused: v.reused, cacheEligible: v.cacheEligible, errors: v.errors.slice(0,5), logPath: v.logPath, lintClean: v.lint ? v.lint.clean : null } };
1781
1801
  }
1782
1802
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overleaf-forge",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "description": "MCP server to read, edit, compile, and verify Overleaf/LaTeX projects over git: conflict-safe edits, figure upload, clean-build verification, citation and voice linting.",
5
5
  "type": "module",
6
6
  "main": "overleaf-mcp-server.js",