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.
@@ -13,6 +13,15 @@ import { execFile as execFileCallback } from 'child_process';
13
13
  import path from 'path';
14
14
  import { fileURLToPath } from 'url';
15
15
  import os from 'os';
16
+ import { createHash } from 'node:crypto';
17
+ import { dependencyIndex, changeReport } from './dependency-index.js';
18
+ import { renderPages } from './render-cache.js';
19
+ import { applyChanges, publishChanges } from './transactions.js';
20
+ import { observeTool, usageStats, toolError } from './runtime-observability.js';
21
+ import { versionedContext, buildFingerprint, sectionBundle, sectionText, controlledBuildOptions } from './efficiency.js';
22
+ const verifiedBuilds = new Map();
23
+ const recentPasses = new Map();
24
+ const buildQueues = new Map();
16
25
 
17
26
  const __filename = fileURLToPath(import.meta.url);
18
27
  const __dirname = path.dirname(__filename);
@@ -85,10 +94,20 @@ const CONFIG_PATH = path.join(DATA_HOME, 'projects.json');
85
94
  const CONTEXTS_DIR = path.join(DATA_HOME, 'contexts');
86
95
  const DEFAULT_REPO_DIR = path.join(DATA_HOME, 'repos');
87
96
  const BUNDLED_TEMPLATES_DIR = path.join(PACKAGE_DIR, 'templates');
88
- // A user copy in the data home overrides the bundled default writing-guidelines.
89
- const GUIDELINES_PATH = existsSync(path.join(DATA_HOME, 'writing-guidelines.md'))
90
- ? path.join(DATA_HOME, 'writing-guidelines.md')
91
- : path.join(PACKAGE_DIR, 'writing-guidelines.md');
97
+ // Guidelines resolve personal-first:
98
+ // 1. <dataHome>/writing-guidelines.local.md (gitignored; never ships)
99
+ // 2. <dataHome>/writing-guidelines.md (user copy, when dataHome is not the package)
100
+ // 3. <packageDir>/writing-guidelines.md (bundled generic default)
101
+ // The .local name is what keeps a personal copy distinct when dataHome IS the
102
+ // package dir (a local clone with projects.json), where 2 and 3 are one file.
103
+ // Resolved per call so creating or deleting the local file needs no restart.
104
+ export function resolveGuidelinesPath({ dataHome, packageDir, exists }) {
105
+ const local = path.join(dataHome, 'writing-guidelines.local.md');
106
+ if (exists(local)) return local;
107
+ const user = path.join(dataHome, 'writing-guidelines.md');
108
+ if (exists(user)) return user;
109
+ return path.join(packageDir, 'writing-guidelines.md');
110
+ }
92
111
 
93
112
  // Where scaffold templates (main.tex skeleton, context-scaffold.md) are read.
94
113
  // Precedence: settings.templatesDir → $OVERLEAF_MCP_TEMPLATES → ~/.overleaf-mcp/
@@ -197,7 +216,8 @@ const SETTING_HELP = {
197
216
  ssaSubdir: 'What subfolder name should new SSAs go under inside each course folder? (e.g. "MY SSAs")',
198
217
  templatesDir: 'Use your own scaffold templates? Give the directory holding main.tex / context-scaffold.md (blank keeps the bundled examples).',
199
218
  voiceLinter: 'Use your own prose linter for voice_lint? Give the command (takes a file path, exits non-zero on findings; blank keeps the bundled example).',
200
- gitToken: 'Overleaf git token (prefer the OVERLEAF_GIT_TOKEN env var; set here only if you must store it in projects.json).',
219
+ autoPush: 'Should edit tools push to Overleaf immediately (true), or commit locally until publish_changes sends a verified batch (false, the default)?',
220
+ gitToken: 'Overleaf git token (prefer the OVERLEAF_GIT_TOKEN env var; set here only if you must store it in projects.json).',
201
221
  };
202
222
  const SETTING_KEYS = Object.keys(SETTING_HELP);
203
223
  const SETTING_PATHY = new Set(['repoDir', 'academicRoot', 'templatesDir']);
@@ -218,6 +238,14 @@ export function mergeSettings(current, args, homeDir) {
218
238
  return { settings, provided };
219
239
  }
220
240
 
241
+ // Whether a mutating tool pushes to Overleaf. An explicit per-call `push` wins,
242
+ // then settings.autoPush; the default is false, so edits stay local commits
243
+ // until publish_changes verifies and sends them in one deliberate step.
244
+ export function resolvePush(settings, args) {
245
+ if (typeof args?.push === 'boolean') return args.push;
246
+ return settings?.autoPush === true;
247
+ }
248
+
221
249
  // Default project resolution:
222
250
  // 1. explicit projectName argument
223
251
  // 2. project whose `cwd` is a prefix of SESSION_CWD (longest match wins)
@@ -321,23 +349,71 @@ class OverleafGitClient {
321
349
  }
322
350
  // Repair older clones that embedded the token in the remote URL.
323
351
  await this._git(['-C', this.repoPath, 'remote', 'set-url', 'origin', this.gitUrl]).catch(() => {});
352
+ const { stdout } = await this._git(['-C', this.repoPath, 'pull', '--ff-only'], { auth: true });
353
+ return stdout;
354
+ }
355
+
356
+ async requireLocal() {
357
+ if (!(await this._hasRepo())) throw Object.assign(new Error('Local clone missing; call sync_project explicitly.'), { code: 'LOCAL_CLONE_MISSING' });
358
+ }
359
+
360
+ // Mutations that push absorb remote edits first; local-only mutations stay
361
+ // off the network entirely, like reads.
362
+ async _prepareMutation(push) {
363
+ if (push) await this.cloneOrPull();
364
+ else await this.requireLocal();
365
+ await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
366
+ await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
367
+ return this._head();
368
+ }
369
+
370
+ async _head() {
371
+ const { stdout } = await this._git(['-C', this.repoPath, 'rev-parse', 'HEAD']);
372
+ return stdout.trim();
373
+ }
374
+
375
+ // HEAD plus the number of local commits not yet on the remote-tracking branch.
376
+ // unpublished is null when there is no tracking ref to compare against.
377
+ async pendingState() {
378
+ const head = await this._head();
379
+ const branch = await this._currentBranch();
380
+ let unpublished = null;
324
381
  try {
325
- const { stdout } = await this._git(['-C', this.repoPath, 'pull', '--ff-only'], { auth: true });
326
- return stdout;
327
- } catch {
328
- // Pull failed (diverged, or leftover changes from a prior failed write).
329
- // Overleaf is the source of truth, so fetch and hard-reset to the remote
330
- // tip rather than cascading into a clone-into-nonempty-dir error.
331
- await this._git(['-C', this.repoPath, 'fetch', 'origin'], { auth: true });
332
- const { stdout: br } = await this._git(['-C', this.repoPath, 'rev-parse', '--abbrev-ref', 'HEAD']);
333
- const branch = (br || '').trim() || 'master';
334
- await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]);
335
- return `recovered: hard-reset to origin/${branch}`;
382
+ const { stdout } = await this._git(['-C', this.repoPath, 'rev-list', '--count', `origin/${branch}..HEAD`]);
383
+ unpublished = Number(stdout.trim());
384
+ } catch { /* no tracking ref */ }
385
+ return { head, unpublished };
386
+ }
387
+
388
+ // Finish a mutation whose commit is already made. push:false keeps it local
389
+ // for publish_changes. A refused push rolls back to preHead, this operation's
390
+ // own starting point: only its commit is undone, and earlier unpublished
391
+ // commits survive. (Resetting to origin/<branch> would silently drop them.)
392
+ async _finishMutation(preHead, push, { merge = false, refusal }) {
393
+ if (!push) return { pushed: false, committed: true, ...(await this.pendingState()) };
394
+ if (merge) return this._pushWithMerge(preHead);
395
+ try {
396
+ await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
397
+ } catch (e) {
398
+ await this._git(['-C', this.repoPath, 'reset', '--hard', preHead]).catch(() => {});
399
+ throw new Error(`${refusal} (${(e.stderr || e.message || '').slice(0, 120)})`);
400
+ }
401
+ return { pushed: true };
402
+ }
403
+
404
+ // git commit that reports "nothing to commit" as a value instead of throwing.
405
+ async _commit(message) {
406
+ try {
407
+ await this._git(['-C', this.repoPath, 'commit', '-m', message]);
408
+ return true;
409
+ } catch (e) {
410
+ if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) return false;
411
+ throw e;
336
412
  }
337
413
  }
338
414
 
339
415
  async listFiles(extension = '.tex') {
340
- await this.cloneOrPull();
416
+ await this.requireLocal();
341
417
  const out = [];
342
418
  const walk = async (dir) => {
343
419
  const entries = await readdir(dir, { withFileTypes: true });
@@ -355,14 +431,16 @@ class OverleafGitClient {
355
431
  }
356
432
 
357
433
  async readFile(filePath) {
358
- await this.cloneOrPull();
359
- const fullPath = path.join(this.repoPath, filePath);
434
+ await this.requireLocal();
435
+ const root = realpathSync(this.repoPath);
436
+ const fullPath = realpathSync(path.resolve(root, filePath));
437
+ if (!fullPath.startsWith(root + path.sep)) throw Object.assign(new Error('File must be inside the project.'), { code: 'INVALID_INPUT' });
360
438
  return await readFile(fullPath, 'utf-8');
361
439
  }
362
440
 
363
441
  // git blob SHA of a file at the current tip; null if the file isn't tracked.
364
- async getBlobSha(filePath, { pull = true } = {}) {
365
- if (pull) await this.cloneOrPull();
442
+ async getBlobSha(filePath) {
443
+ await this.requireLocal();
366
444
  try {
367
445
  const { stdout } = await this._git(['-C', this.repoPath, 'rev-parse', `HEAD:${filePath}`]);
368
446
  return stdout.trim();
@@ -387,63 +465,179 @@ class OverleafGitClient {
387
465
  // Run latexmk from the repo root (so the project's .latexmkrc -- shell-escape,
388
466
  // the python@3.13 PATH fix for minted, $pdf_mode -- applies, and refs/citations/
389
467
  // reruns resolve). clean:true adds -gg to force a complete from-scratch rebuild.
390
- async _runLatexmk(filePath, engine = 'lualatex', { clean = false } = {}) {
391
- await this.cloneOrPull();
468
+ async _runLatexmk(filePath, engine = 'lualatex', { clean = false, controlled = false } = {}) {
469
+ const full = path.resolve(this.repoPath, filePath);
470
+ if (!full.startsWith(path.resolve(this.repoPath) + path.sep) || !filePath.endsWith('.tex') || filePath.startsWith('-')) throw new Error('Build entrypoint must be a .tex path inside the project.');
471
+ if (!(await this._hasRepo())) throw new Error('Local clone missing; call sync_project explicitly before building.');
392
472
  const engineFlag = { pdflatex: '-pdf', xelatex: '-xelatex', lualatex: '-lualatex' }[engine];
393
473
  if (!engineFlag) {
394
474
  throw new Error(`Invalid engine "${engine}". Choose from: pdflatex, xelatex, lualatex`);
395
475
  }
396
476
  const texbin = '/Library/TeX/texbin';
397
477
  const env = { ...process.env, PATH: `${texbin}:${process.env.PATH || ''}` };
398
- const args = [engineFlag, '-interaction=nonstopmode', '-halt-on-error'];
478
+ const args = [engineFlag, '-interaction=nonstopmode', '-halt-on-error', '-recorder'];
479
+ // -norc must precede all other options so no user or project Perl config runs.
480
+ if (controlled) args.unshift('-norc', '-no-shell-escape');
399
481
  if (clean) args.push('-gg');
400
482
  args.push(filePath);
483
+ let commandFailed = false;
401
484
  const { stdout, stderr } = await execFile(
402
485
  path.join(texbin, 'latexmk'), args,
403
486
  { cwd: this.repoPath, timeout: 180000, maxBuffer: 20 * 1024 * 1024, env }
404
- ).catch(e => ({ stdout: e.stdout || '', stderr: e.stderr || e.message }));
487
+ ).catch(e => { commandFailed = true; return { stdout: e.stdout || '', stderr: e.stderr || e.message }; });
405
488
  const pdfPath = path.join(this.repoPath, filePath.replace(/\.tex$/, '.pdf'));
406
489
  let pdfExists = false;
407
490
  try { await access(pdfPath); pdfExists = true; } catch { /* no pdf */ }
408
- return { stdout, stderr, log: `${stdout}\n${stderr}`, pdfPath: pdfExists ? pdfPath : null };
491
+ return { stdout, stderr, commandFailed, log: `${stdout}\n${stderr}`, pdfPath: !commandFailed && pdfExists ? pdfPath : null };
492
+ }
493
+
494
+ async compileFile(filePath, engine = 'lualatex', options = {}) {
495
+ return this.verifyBuild(filePath, engine, { ...options, force: true, clean: false });
496
+ }
497
+
498
+ // Explicit sync. Fetches, then acts on the ahead/behind relation:
499
+ // behind only -> fast-forward
500
+ // ahead only / equal -> nothing to do (ahead = unpublished local commits)
501
+ // diverged -> report both sides and change nothing, unless a
502
+ // strategy is chosen:
503
+ // 'rebase' replays local commits onto the remote; a conflict aborts back
504
+ // to the untouched pre-sync state.
505
+ // 'reset' discards local work to match the remote. Requires confirm to
506
+ // equal the reported local HEAD (proof the report was read), and
507
+ // tags the old HEAD and any uncommitted edits as mcp-backup/*
508
+ // first, so nothing becomes unrecoverable.
509
+ async syncProject({ strategy, confirm } = {}) {
510
+ if (!(await this._hasRepo())) {
511
+ await this.cloneOrPull();
512
+ return { state: 'cloned', ...(await this.pendingState()) };
513
+ }
514
+ if (strategy !== undefined && strategy !== 'rebase' && strategy !== 'reset') {
515
+ throw new Error(`Unknown strategy "${strategy}". Use "rebase" or "reset".`);
516
+ }
517
+ const g = (args, opts) => this._git(['-C', this.repoPath, ...args], opts);
518
+ await g(['remote', 'set-url', 'origin', this.gitUrl]).catch(() => {});
519
+ await g(['fetch', 'origin'], { auth: true });
520
+ // rebase and stash create write commits, which need a committer identity.
521
+ await g(['config', 'user.email', 'claude@anthropic.com']);
522
+ await g(['config', 'user.name', 'Claude']);
523
+ const branch = await this._currentBranch();
524
+ const upstream = `origin/${branch}`;
525
+ const count = async range => Number((await g(['rev-list', '--count', range])).stdout.trim());
526
+ const ahead = await count(`${upstream}..HEAD`);
527
+ const behind = await count(`HEAD..${upstream}`);
528
+ const head = await this._head();
529
+ const dirty = (await g(['status', '--porcelain=v1', '--untracked-files=no'])).stdout.split('\n').filter(Boolean).map(l => l.slice(3));
530
+
531
+ if (strategy === 'reset') {
532
+ if (confirm !== head) {
533
+ throw new Error(`reset discards local work; pass confirm: "${head}" (the current local HEAD) after reviewing the sync_project report.`);
534
+ }
535
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
536
+ const backups = [`mcp-backup/${stamp}`];
537
+ await g(['tag', backups[0], head]);
538
+ if (dirty.length) {
539
+ // stash create snapshots uncommitted tracked edits as a commit without
540
+ // touching the working tree; the tag keeps it reachable.
541
+ const wip = (await g(['stash', 'create', `mcp-backup ${stamp}`])).stdout.trim();
542
+ if (wip) { backups.push(`mcp-backup/${stamp}-wip`); await g(['tag', backups[1], wip]); }
543
+ }
544
+ await g(['reset', '--hard', upstream]);
545
+ return { state: 'reset', discardedCommits: ahead, discardedFiles: dirty, backups, ...(await this.pendingState()) };
546
+ }
547
+
548
+ if (behind === 0) return { state: ahead ? 'ahead' : 'up-to-date', ahead, behind, head, dirtyFiles: dirty };
549
+ if (ahead === 0) {
550
+ await g(['merge', '--ff-only', upstream]);
551
+ return { state: 'fast-forwarded', ahead, behind, ...(await this.pendingState()) };
552
+ }
553
+
554
+ const log = async range => (await g(['log', '--format=%h %s', '--name-only', range])).stdout.trim();
555
+ const report = {
556
+ state: 'diverged', ahead, behind, head, upstream: (await g(['rev-parse', upstream])).stdout.trim(),
557
+ localCommits: await log(`${upstream}..HEAD`), remoteCommits: await log(`HEAD..${upstream}`), dirtyFiles: dirty,
558
+ options: 'strategy:"rebase" replays local commits onto Overleaf (aborts cleanly on conflict); strategy:"reset" with confirm:<head> discards local work after tagging a backup.',
559
+ };
560
+ if (strategy !== 'rebase') return report;
561
+ if (dirty.length) throw new Error(`rebase needs a clean working tree; uncommitted edits in: ${dirty.join(', ')}. Commit them or choose reset.`);
562
+ try {
563
+ await g(['rebase', upstream]);
564
+ } catch {
565
+ const conflicts = (await g(['diff', '--name-only', '--diff-filter=U']).catch(() => ({ stdout: '' }))).stdout.trim().split('\n').filter(Boolean);
566
+ await g(['rebase', '--abort']).catch(() => {});
567
+ return { ...report, state: 'rebase-conflict', conflicts, note: 'Rebase aborted; local state is unchanged.' };
568
+ }
569
+ return { state: 'rebased', ...(await this.pendingState()) };
409
570
  }
410
571
 
411
- async compileFile(filePath, engine = 'lualatex') {
412
- const { log, pdfPath } = await this._runLatexmk(filePath, engine, { clean: false });
413
- const errors = (log.match(/^!.*$/gm) || []).slice(0, 20);
414
- const undefinedRefs = (log.match(/^(?:LaTeX|Package)[^\n]*Warning:[^\n]*(?:undefined|multiply)[^\n]*/gmi) || []);
415
- const overfull = (log.match(/^(?:Overfull|Underfull)[^\n]*$/gm) || []).slice(0, 20);
416
- return { pdfPath, errors, undefinedRefs, overfull, tail: log.slice(-2500) };
572
+ // options.lint (true = every .tex file, or an array of paths) adds the voice
573
+ // linter to the gate: findings fail it like an undefined reference does.
574
+ // Lint runs outside the build cache, so a reused build verdict is never
575
+ // mutated and lint always sees the current files.
576
+ async verifyBuild(filePath, engine = 'lualatex', options = {}) {
577
+ const key = path.resolve(this.repoPath);
578
+ const previous = buildQueues.get(key) || Promise.resolve();
579
+ const task = previous.catch(() => {}).then(() => this._verifyLocal(filePath, engine, options));
580
+ buildQueues.set(key, task);
581
+ let verdict;
582
+ try { verdict = await task; } finally { if (buildQueues.get(key) === task) buildQueues.delete(key); }
583
+ if (!options.lint) return verdict;
584
+ const files = Array.isArray(options.lint) ? options.lint : await this.listFiles('.tex');
585
+ const lint = await this.lintFiles(files, options.lintCommand);
586
+ return { ...verdict, lint, pass: verdict.pass && lint.clean };
417
587
  }
418
588
 
419
- // Clean-from-scratch build + structured PASS/FAIL verdict on the "done" bar.
420
- async verifyBuild(filePath, engine = 'lualatex') {
421
- const { log: runLog, pdfPath } = await this._runLatexmk(filePath, engine, { clean: true });
422
- // Classify the FINAL-pass log (e.g. main.log), NOT latexmk's concatenated
423
- // multi-pass stdout: pass 1 (before the .aux exists) flags every \ref/\cite
424
- // undefined, and those transient warnings would be false positives. main.log
425
- // is the last engine run's output -- the true end state; a genuinely undefined
426
- // ref persists there, a resolved one does not. Fall back to the run log if the
427
- // .log file is missing (a catastrophic failure that produced no .log).
428
- const logFile = path.join(this.repoPath, filePath.replace(/\.tex$/, '.log'));
589
+ async lintFiles(files, command) {
590
+ const results = [];
591
+ for (const file of files) results.push({ file, ...(await this.voiceLint(file, { command })) });
592
+ return { clean: results.every(r => r.clean), results };
593
+ }
594
+
595
+ async _verifyLocal(filePath, engine, options = {}) {
596
+ const { force = false, clean = true } = options;
597
+ const config = controlledBuildOptions(options);
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]);
600
+ const fingerprint = () => buildFingerprint(this.repoPath,filePath,engine,config).catch(()=>null);
601
+ const sources = () => buildFingerprint(this.repoPath,filePath,engine,{...config,sourcesOnly:true});
602
+ const cached = verifiedBuilds.get(key);
603
+ const before = await sources();
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 };
614
+ verifiedBuilds.delete(key);
615
+ const { log: runLog, pdfPath, commandFailed } = await this._runLatexmk(filePath,engine,{clean,controlled:config.controlled});
616
+ const logPath = path.join(this.repoPath,filePath.replace(/\.tex$/,'.log'));
429
617
  let finalLog = runLog;
430
- try { finalLog = await readFile(logFile, 'utf-8'); } catch { /* keep runLog */ }
618
+ try { finalLog = await readFile(logPath,'utf8'); } catch { /* failed before log creation */ }
431
619
  const verdict = classifyBuildLog(finalLog);
432
- // Confirm the PDF against the real file, not just the log, so a parser miss
433
- // can't yield a false PASS; then recompute the verdict.
620
+ if (commandFailed) verdict.errors.push('latexmk command failed; any previous PDF is not a successful build.');
434
621
  verdict.pdfProduced = pdfPath !== null;
435
- verdict.pass = verdict.pdfProduced
436
- && verdict.errors.length === 0
437
- && verdict.undefinedRefs.length === 0
438
- && verdict.undefinedCitations.length === 0;
439
- verdict.tail = finalLog.slice(-2500);
622
+ verdict.pass = verdict.pdfProduced && !verdict.errors.length && !verdict.undefinedRefs.length && !verdict.undefinedCitations.length;
623
+ verdict.logPath = logPath;
624
+ verdict.pdfPath = pdfPath;
625
+ verdict.tail = (commandFailed ? runLog : finalLog).slice(-2500);
626
+ verdict.reused = false;
627
+ const after = await sources();
628
+ if (before && before !== after) { verdict.pass = false; verdict.errors.push('Project inputs changed during the build; verify the latest source again.'); }
629
+ const print = before && before === after ? await fingerprint() : null;
630
+ verdict.cacheEligible = Boolean(print);
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);
440
634
  return verdict;
441
635
  }
442
636
 
443
637
  // Read-only grep across tracked files. Regex by default; fixed -> -F; ignoreCase -> -i.
444
638
  async searchText({ query, fixed = false, ignoreCase = false, extension } = {}) {
445
639
  if (!query) throw new Error('search_text needs a query.');
446
- await this.cloneOrPull();
640
+ await this.requireLocal();
447
641
  const args = ['-C', this.repoPath, 'grep', '-n', '--no-color', fixed ? '-F' : '-E'];
448
642
  if (ignoreCase) args.push('-i');
449
643
  args.push('-e', query);
@@ -458,34 +652,28 @@ class OverleafGitClient {
458
652
  }
459
653
  }
460
654
 
461
- // Append a BibTeX entry to refs.bib (reject a duplicate key), commit, push.
462
- async addCitation({ entry, commitMessage } = {}) {
655
+ // Append a BibTeX entry to refs.bib (reject a duplicate key), commit, and
656
+ // push when push is true.
657
+ async addCitation({ entry, commitMessage, push = true } = {}) {
463
658
  if (!entry || !entry.trim()) throw new Error('add_citation needs a BibTeX entry.');
464
659
  const m = entry.match(/@\w+\s*\{\s*([^,\s]+)/);
465
660
  if (!m) throw new Error('Could not find a BibTeX key in the entry (expected @type{key, ...}).');
466
661
  const key = m[1];
467
- await this.cloneOrPull();
662
+ const preHead = await this._prepareMutation(push);
468
663
  const bibPath = path.join(this.repoPath, 'refs.bib');
469
664
  let current = '';
470
665
  try { current = await readFile(bibPath, 'utf-8'); } catch { /* missing -> create */ }
471
666
  const dup = new RegExp(`@\\w+\\s*\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*,`);
472
667
  if (dup.test(current)) throw new Error(`citation key "${key}" already in refs.bib.`);
473
668
  await writeFile(bibPath, current.replace(/\s*$/, '') + '\n\n' + entry.trim() + '\n', 'utf-8');
474
- await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
475
- await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
476
669
  await this._git(['-C', this.repoPath, 'add', '--', 'refs.bib']);
477
- try {
478
- await this._git(['-C', this.repoPath, 'commit', '-m', commitMessage || `Add citation ${key}`]);
479
- } catch (e) {
480
- if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) return { pushed: false, reason: 'nothing to commit', key };
481
- throw e;
482
- }
483
- return { ...(await this._pushWithMerge()), key };
670
+ if (!(await this._commit(commitMessage || `Add citation ${key}`))) return { pushed: false, reason: 'nothing to commit', key };
671
+ return { ...(await this._finishMutation(preHead, push, { merge: true })), key };
484
672
  }
485
673
 
486
674
  // Read-only: cited keys (across .tex) vs defined keys (refs.bib).
487
675
  async citeLint() {
488
- await this.cloneOrPull();
676
+ await this.requireLocal();
489
677
  const texFiles = await this.listFiles('.tex');
490
678
  const citeRe = /\\(?:cite|autocite|parencite|citep|citet|textcite|footcite|nocite)\*?(?:\[[^\]]*\])*\{([^}]+)\}/g;
491
679
  const cited = new Set();
@@ -519,7 +707,7 @@ class OverleafGitClient {
519
707
 
520
708
  // Local rollback point: a lightweight tag mcp-snap/<label> at HEAD (not pushed).
521
709
  async checkpoint(label) {
522
- await this.cloneOrPull();
710
+ await this.requireLocal();
523
711
  const name = `mcp-snap/${(label && label.trim()) || `snap-${Date.now()}`}`;
524
712
  let exists = false;
525
713
  try { await this._git(['-C', this.repoPath, 'rev-parse', '--verify', '--quiet', `refs/tags/${name}`]); exists = true; } catch { exists = false; }
@@ -529,10 +717,12 @@ class OverleafGitClient {
529
717
  return { label: name, head: stdout.trim() };
530
718
  }
531
719
 
532
- // Forward-restore the snapshot's tree as a new commit on top of HEAD, then push.
533
- // No history rewrite, no force-push (the new commit descends from HEAD).
534
- async restore(label) {
535
- await this.cloneOrPull();
720
+ // Forward-restore the snapshot's tree as a new commit on top of HEAD, pushed
721
+ // when push is true. No history rewrite, no force-push (the new commit
722
+ // descends from HEAD). The fast-forward refuses rather than overwriting
723
+ // uncommitted local edits to files the restore would change.
724
+ async restore(label, { push = true } = {}) {
725
+ const preHead = await this._prepareMutation(push);
536
726
  const name = String(label || '').startsWith('mcp-snap/') ? label : `mcp-snap/${label}`;
537
727
  let tree;
538
728
  try { ({ stdout: tree } = await this._git(['-C', this.repoPath, 'rev-parse', `${name}^{tree}`])); }
@@ -541,19 +731,10 @@ class OverleafGitClient {
541
731
  throw new Error(`snapshot "${name}" not found. Available: ${tags || '(none)'}`);
542
732
  }
543
733
  tree = tree.trim();
544
- await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
545
- await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
546
734
  const { stdout: commit } = await this._git(['-C', this.repoPath, 'commit-tree', tree, '-p', 'HEAD', '-m', `restore: ${name}`]);
547
- await this._git(['-C', this.repoPath, 'reset', '--hard', commit.trim()]);
548
- try {
549
- await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
550
- } catch (e) {
551
- const branch = await this._currentBranch();
552
- await this._git(['-C', this.repoPath, 'fetch', 'origin'], { auth: true }).catch(() => {});
553
- await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
554
- throw new Error(`restore: Overleaf moved during the rollback; refused. Re-run after re-pulling. (${(e.stderr || e.message || '').slice(0, 120)})`);
555
- }
556
- return { pushed: true, label: name, restoredTo: tree };
735
+ await this._git(['-C', this.repoPath, 'merge', '--ff-only', commit.trim()]);
736
+ const res = await this._finishMutation(preHead, push, { refusal: 'restore: Overleaf moved during the rollback; refused. Run sync_project, then retry.' });
737
+ return { ...res, label: name, restoredTo: tree };
557
738
  }
558
739
 
559
740
  // Run the configured voice linter on a file; advisory, read-only.
@@ -605,9 +786,11 @@ class OverleafGitClient {
605
786
  }
606
787
 
607
788
  // Push origin HEAD. If the remote moved during the op (non-fast-forward),
608
- // let git 3-way merge it: clean merge -> push; real conflict -> abort, reset
609
- // to the remote tip, and throw (so nothing half-applied is left behind).
610
- async _pushWithMerge() {
789
+ // let git 3-way merge it: clean merge -> push; real conflict -> abort, roll
790
+ // back to preHead (the state before this operation's commit), and throw, so
791
+ // nothing half-applied is left behind and earlier local commits survive.
792
+ async _pushWithMerge(preHead) {
793
+ if (!preHead) throw new Error('_pushWithMerge needs the pre-operation HEAD to roll back to.');
611
794
  try {
612
795
  await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
613
796
  return { pushed: true, merged: false };
@@ -618,15 +801,15 @@ class OverleafGitClient {
618
801
  await this._git(['-C', this.repoPath, 'merge', '--no-edit', `origin/${branch}`]);
619
802
  } catch (mergeErr) {
620
803
  await this._git(['-C', this.repoPath, 'merge', '--abort']).catch(() => {});
621
- await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]);
622
- const e = new Error('conflict: the file changed on Overleaf in a way that overlaps this edit. Re-read the file and retry.');
804
+ await this._git(['-C', this.repoPath, 'reset', '--hard', preHead]);
805
+ const e = new Error('conflict: the file changed on Overleaf in a way that overlaps this edit. Run sync_project, re-read the file and retry.');
623
806
  e.cause = mergeErr;
624
807
  throw e;
625
808
  }
626
809
  try {
627
810
  await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
628
811
  } catch (e2) {
629
- await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
812
+ await this._git(['-C', this.repoPath, 'reset', '--hard', preHead]).catch(() => {});
630
813
  const e = new Error('conflict: Overleaf moved again while merging; reset clean — re-read the file and retry.');
631
814
  e.cause = e2;
632
815
  throw e;
@@ -639,10 +822,10 @@ class OverleafGitClient {
639
822
  // files: require either a matching baseSha (proves freshness) or overwrite:true
640
823
  // (a deliberate clobber). A stale baseSha is refused, never merged.
641
824
  async writeFile(filePath, content, opts = {}) {
642
- const { baseSha, overwrite = false, commitMessage } = opts;
643
- await this.cloneOrPull();
825
+ const { baseSha, overwrite = false, commitMessage, push = true } = opts;
826
+ const preHead = await this._prepareMutation(push);
644
827
  const fullPath = path.join(this.repoPath, filePath);
645
- const current = await this.getBlobSha(filePath, { pull: false }); // null if new
828
+ const current = await this.getBlobSha(filePath); // null if new
646
829
 
647
830
  if (current !== null) {
648
831
  if (baseSha != null) {
@@ -656,28 +839,10 @@ class OverleafGitClient {
656
839
 
657
840
  await mkdir(path.dirname(fullPath), { recursive: true });
658
841
  await writeFile(fullPath, content, 'utf-8');
659
- await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
660
- await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
661
842
  await this._git(['-C', this.repoPath, 'add', '--', filePath]);
662
- try {
663
- await this._git(['-C', this.repoPath, 'commit', '-m', commitMessage || `Update ${filePath} via Claude`]);
664
- } catch (e) {
665
- if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) {
666
- return { pushed: false, reason: 'nothing to commit' };
667
- }
668
- throw e;
669
- }
843
+ if (!(await this._commit(commitMessage || `Update ${filePath} via Claude`))) return { pushed: false, reason: 'nothing to commit' };
670
844
  // write_file refuses on a push race rather than merging (conflict-refuse policy).
671
- try {
672
- await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
673
- } catch (e) {
674
- const branch = await this._currentBranch();
675
- await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
676
- await this._git(['-C', this.repoPath, 'fetch', 'origin'], { auth: true }).catch(() => {});
677
- await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
678
- throw new Error(`${filePath}: Overleaf moved while writing; refused to overwrite. Re-read and retry. (${(e.stderr || e.message || '').slice(0, 120)})`);
679
- }
680
- return { pushed: true };
845
+ return this._finishMutation(preHead, push, { refusal: `${filePath}: Overleaf moved while writing; refused to overwrite. Run sync_project, re-read and retry.` });
681
846
  }
682
847
 
683
848
  // Upload binary file(s) from local disk into the clone and push. Single mode:
@@ -686,7 +851,7 @@ class OverleafGitClient {
686
851
  // Binary never 3-way-merges, so a push race refuses + resets like writeFile.
687
852
  // baseSha freshness applies in single mode only; existing files in batch mode
688
853
  // require overwrite:true.
689
- async uploadFile({ srcPath, destPath, files, baseSha, overwrite = false, commitMessage } = {}) {
854
+ async uploadFile({ srcPath, destPath, files, baseSha, overwrite = false, commitMessage, push = true } = {}) {
690
855
  let pairs;
691
856
  const batch = Array.isArray(files);
692
857
  if (batch) {
@@ -699,7 +864,7 @@ class OverleafGitClient {
699
864
  throw new Error('upload_file needs srcPath+destPath (single) or files:[{srcPath,destPath}] (batch).');
700
865
  }
701
866
 
702
- await this.cloneOrPull();
867
+ const preHead = await this._prepareMutation(push);
703
868
  const repoAbs = path.resolve(this.repoPath);
704
869
  const resolved = [];
705
870
  for (const { src, dest } of pairs) {
@@ -711,7 +876,7 @@ class OverleafGitClient {
711
876
  if (destAbs === repoAbs || rel.startsWith('..') || path.isAbsolute(rel) || rel.split(path.sep)[0] === '.git') {
712
877
  throw new Error(`destPath escapes the project or is not allowed: ${dest}`);
713
878
  }
714
- const current = await this.getBlobSha(rel, { pull: false });
879
+ const current = await this.getBlobSha(rel);
715
880
  if (current !== null) {
716
881
  if (!batch && baseSha != null) {
717
882
  if (baseSha !== current) {
@@ -729,34 +894,21 @@ class OverleafGitClient {
729
894
  await copyFile(src, destAbs);
730
895
  }
731
896
 
732
- await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
733
- await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
734
- await this._git(['-C', this.repoPath, 'add', '--', ...resolved.map(r => r.rel)]);
735
- try {
736
- await this._git(['-C', this.repoPath, 'commit', '-m', commitMessage || `Upload ${resolved.length} file(s) via Claude`]);
737
- } catch (e) {
738
- if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) {
739
- return { pushed: false, reason: 'nothing to commit (identical to repo)', files: resolved.map(r => r.rel) };
740
- }
741
- throw e;
897
+ const rels = resolved.map(r => r.rel);
898
+ await this._git(['-C', this.repoPath, 'add', '--', ...rels]);
899
+ if (!(await this._commit(commitMessage || `Upload ${resolved.length} file(s) via Claude`))) {
900
+ return { pushed: false, reason: 'nothing to commit (identical to repo)', files: rels };
742
901
  }
743
- try {
744
- await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
745
- } catch (e) {
746
- const branch = await this._currentBranch();
747
- await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
748
- await this._git(['-C', this.repoPath, 'fetch', 'origin'], { auth: true }).catch(() => {});
749
- await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
750
- throw new Error(`Overleaf moved while uploading; refused. Re-read and retry. (${(e.stderr || e.message || '').slice(0, 120)})`);
751
- }
752
- return { pushed: true, files: resolved.map(r => r.rel) };
902
+ const res = await this._finishMutation(preHead, push, { refusal: 'Overleaf moved while uploading; refused. Run sync_project and retry.' });
903
+ return { ...res, files: rels };
753
904
  }
754
905
 
755
- // Anchored, conflict-safe edit. Pulls first (absorbing non-overlapping Overleaf
756
- // edits), then replaces oldString. A missing anchor means the user changed that
757
- // region (overlap) or the string was wrong -> refuse, nothing written.
758
- async editFile(filePath, oldString, newString, replaceAll = false, commitMessage) {
759
- await this.cloneOrPull();
906
+ // Anchored, conflict-safe edit. When pushing, pulls first (absorbing
907
+ // non-overlapping Overleaf edits); local-only edits never touch the network.
908
+ // A missing anchor means the region changed (overlap) or the string was
909
+ // wrong -> refuse, nothing written.
910
+ async editFile(filePath, oldString, newString, replaceAll = false, commitMessage, { push = true } = {}) {
911
+ const preHead = await this._prepareMutation(push);
760
912
  const fullPath = path.join(this.repoPath, filePath);
761
913
  let content;
762
914
  try { content = await readFile(fullPath, 'utf-8'); }
@@ -772,39 +924,41 @@ class OverleafGitClient {
772
924
  }
773
925
  const updated = replaceAll ? parts.join(newString) : content.replace(oldString, () => newString);
774
926
  await writeFile(fullPath, updated, 'utf-8');
775
-
776
- await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
777
- await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
778
927
  await this._git(['-C', this.repoPath, 'add', '--', filePath]);
779
- try {
780
- await this._git(['-C', this.repoPath, 'commit', '-m', commitMessage || `Edit ${filePath} via Claude`]);
781
- } catch (e) {
782
- if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) {
783
- return { pushed: false, reason: 'no change (new === old)' };
784
- }
785
- throw e;
786
- }
787
- return await this._pushWithMerge();
928
+ if (!(await this._commit(commitMessage || `Edit ${filePath} via Claude`))) return { pushed: false, reason: 'no change (new === old)' };
929
+ return this._finishMutation(preHead, push, { merge: true });
788
930
  }
789
931
 
932
+ // Same slicing as the bundle (sectionText): runs to the next heading of the
933
+ // same or higher level, covers \paragraph, and refuses an ambiguous title
934
+ // rather than silently returning the first match.
790
935
  async getSectionContent(filePath, sectionTitle) {
791
- const content = await this.readFile(filePath);
792
- const sections = await this.getSections(filePath);
793
- const target = sections.find(s => s.title === sectionTitle);
794
- if (!target) {
795
- throw new Error(`Section "${sectionTitle}" not found`);
796
- }
797
- // The body runs until the next heading of the SAME or HIGHER level, so a
798
- // \section keeps its \subsections instead of being cut at the first one.
799
- const rank = { section: 1, subsection: 2, subsubsection: 3 };
800
- const next = sections.find(s => s.index > target.index && rank[s.type] <= rank[target.type]);
801
- const endIdx = next ? next.index : content.length;
802
- return content.substring(target.index, endIdx);
936
+ return sectionText(await this.readFile(filePath), sectionTitle);
803
937
  }
804
938
  }
805
939
 
806
940
  export { OverleafGitClient };
807
941
 
942
+ // settings.voiceLinter / $OVERLEAF_VOICE_LINTER override the bundled example
943
+ // linter, which ships with the package so voice_lint works out of the box. The
944
+ // example implements generic prose checks; point the setting at your own
945
+ // command to enforce a house style.
946
+ function voiceLinterCommand(config) {
947
+ return config.settings?.voiceLinter
948
+ || process.env.OVERLEAF_VOICE_LINTER
949
+ || `node ${path.join(PACKAGE_DIR, 'examples', 'voice-lint.mjs')}`;
950
+ }
951
+
952
+ // What a mutating tool reports: pushed, or committed locally with the count of
953
+ // commits still waiting for publish_changes.
954
+ function mutationTail(res, what) {
955
+ if (res.pushed) return `${what} and pushed to Overleaf${res.merged ? ' (auto-merged a concurrent Overleaf change)' : ''}.`;
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}.`;
960
+ }
961
+
808
962
  async function getClient(projectName) {
809
963
  const config = await loadConfig();
810
964
  const key = pickProjectKey(config, projectName);
@@ -973,12 +1127,21 @@ const server = new Server(
973
1127
 
974
1128
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
975
1129
  tools: [
1130
+ ...[
1131
+ ['dependency_index', 'Local static TeX dependencies and affected sections. Dynamic macros are reported as unresolved.', { changedFiles: { type: 'array', items: { type: 'string' } }, changedSymbols: { type: 'array', items: { type: 'string' } } }, []],
1132
+ ['change_report', 'Compact local file and section changes against an in-process baseline. Omit baselineVersion to establish one.', { baselineVersion: { type: 'string' } }, []],
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']],
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' } }, []],
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']],
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']],
1137
+ ].map(([name, description, properties, required]) => ({ name, description, inputSchema: { type: 'object', properties: { projectName: { type: 'string' }, ...properties }, required } })),
976
1138
  {
977
1139
  name: 'get_context',
978
- description: 'Read writing guidelines + per-project context. Always call this at the start of any writing or editing session, and re-read whenever instructions feel forgotten. Both the guidelines and the project context md are re-read from disk on every call, so external edits take effect immediately without restarting.',
1140
+ description: 'Read current writing and project context. Supply previousVersion to receive a compact unchanged response when content and project identity match.',
979
1141
  inputSchema: {
980
1142
  type: 'object',
981
1143
  properties: {
1144
+ previousVersion: { type: 'string', description: 'Version returned by the previous context read.' },
982
1145
  projectName: { type: 'string', description: 'Project key. Omit to auto-detect from current working directory.' },
983
1146
  },
984
1147
  },
@@ -1019,7 +1182,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1019
1182
  },
1020
1183
  {
1021
1184
  name: 'configure',
1022
- description: 'Set up or update overleaf-forge\'s global settings: the scaffold templates directory, the voice_lint command, and the recurring-work settings bootstrap_ssa uses (academic root, SSA subdir, default clone dir). FIRST call this with NO arguments to see the current settings and which are unset, with a question for each; ask the user those questions in turn; THEN call it again with their answers to write them to projects.json. Omit a field to leave it unchanged; pass an empty string to clear it back to the bundled default. The git token is redacted in all output. Intended right after install to configure the server conversationally.',
1185
+ description: 'Set up or update overleaf-forge\'s global settings: the scaffold templates directory, the voice_lint command, the edit push policy (autoPush), and the recurring-work settings bootstrap_ssa uses (academic root, SSA subdir, default clone dir). FIRST call this with NO arguments to see the current settings and which are unset, with a question for each; ask the user those questions in turn; THEN call it again with their answers to write them to projects.json. Omit a field to leave it unchanged; pass an empty string to clear it back to the bundled default. The git token is redacted in all output. Intended right after install to configure the server conversationally.',
1023
1186
  inputSchema: {
1024
1187
  type: 'object',
1025
1188
  properties: {
@@ -1028,6 +1191,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1028
1191
  ssaSubdir: { type: 'string', description: 'Subfolder name created under each course folder for new SSAs, e.g. "MY SSAs".' },
1029
1192
  templatesDir: { type: 'string', description: 'Directory of your scaffold templates (main.tex, context-scaffold.md), overriding the bundled examples. ~ expanded. Empty string reverts to bundled.' },
1030
1193
  voiceLinter: { type: 'string', description: 'Prose-linter command for voice_lint (takes a file path, exits non-zero on findings), overriding the bundled example. Empty string reverts to bundled.' },
1194
+ autoPush: { type: 'boolean', description: 'true: edit tools push immediately. false (default): they commit locally and publish_changes sends the verified batch.' },
1031
1195
  gitToken: { type: 'string', description: 'Overleaf git token. Prefer the OVERLEAF_GIT_TOKEN env var; set here only to store it in projects.json.' },
1032
1196
  },
1033
1197
  },
@@ -1107,38 +1271,42 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1107
1271
  required: ['filePath'],
1108
1272
  },
1109
1273
  },
1274
+ {
1275
+ name: 'sync_project',
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.',
1277
+ inputSchema: { type: 'object', properties: {
1278
+ strategy: { type: 'string', enum: ['rebase', 'reset'], description: 'Only for a diverged clone. Omit to get the report first.' },
1279
+ confirm: { type: 'string', description: 'For strategy "reset": the full local head SHA from the report.' },
1280
+ projectName: { type: 'string' },
1281
+ } },
1282
+ },
1110
1283
  {
1111
1284
  name: 'get_section_content',
1112
- description: 'Get the body of a single section by title.',
1285
+ description: 'Read one section (\\section to \\paragraph) by exact title from the local clone; no pull. The title must be unique in the file. bundle:true also returns the equation/figure blocks it references, matching bibliography entries and asset paths as JSON, reporting unresolved references and truncation (not a recursive TeX parser).',
1113
1286
  inputSchema: {
1114
1287
  type: 'object',
1115
1288
  properties: {
1116
1289
  filePath: { type: 'string' },
1117
1290
  sectionTitle: { type: 'string' },
1291
+ bundle: { type: 'boolean', default: false, description: 'Include referenced blocks, bibliography entries and assets.' },
1292
+ maxChars: { type: 'integer', minimum: 2000, maximum: 64000, description: 'bundle only: response budget (default 16000).' },
1118
1293
  projectName: { type: 'string' },
1119
1294
  },
1120
1295
  required: ['filePath', 'sectionTitle'],
1121
1296
  },
1122
1297
  },
1123
- {
1124
- name: 'compile_file',
1125
- description: 'Compile a .tex file locally with LuaLaTeX (default), XeLaTeX, or pdfLaTeX. Pulls before compiling. ALWAYS run this after write_file before declaring work done — silent build breakage is the most common failure mode.',
1126
- inputSchema: {
1127
- type: 'object',
1128
- properties: {
1129
- filePath: { type: 'string' },
1130
- engine: { type: 'string', description: 'pdflatex | xelatex | lualatex (default lualatex)' },
1131
- projectName: { type: 'string' },
1132
- },
1133
- required: ['filePath'],
1134
- },
1135
- },
1136
1298
  {
1137
1299
  name: 'verify_build',
1138
- description: 'Compile the entrypoint FROM SCRATCH (clean aux) 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. Reports page count; overfull/underfull boxes are warnings, not failures. Use as the final gate before declaring a writing task done.',
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.',
1139
1301
  inputSchema: {
1140
1302
  type: 'object',
1141
1303
  properties: {
1304
+ clean: { type: 'boolean', default: true, description: 'false = quick incremental rebuild that always recompiles (intermediate checks); true = from-scratch final gate.' },
1305
+ lint: { description: 'Also run the voice linter as part of the gate: true for every .tex file, or an array of paths. Findings fail the verdict.', anyOf: [{ type: 'boolean' }, { type: 'array', items: { type: 'string' } }] },
1306
+ controlled: { type: 'boolean', default: false, description: 'Ignore all latexmk rc files and disable shell escape. Opt in only when the project supports this mode.' },
1307
+ externalInputs: { type: 'array', items: { type: 'string' }, description: 'Absolute paths of additional build inputs to hash.' },
1308
+ verbose: { type: 'boolean', default: false, description: 'Include a bounded log tail; full log remains on disk.' },
1309
+ force: { type: 'boolean', default: false, description: 'Ignore a cached verification and rebuild.' },
1142
1310
  filePath: { type: 'string', description: 'The entrypoint, usually main.tex.' },
1143
1311
  engine: { type: 'string', description: 'pdflatex | xelatex | lualatex (default lualatex).' },
1144
1312
  projectName: { type: 'string' },
@@ -1148,7 +1316,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1148
1316
  },
1149
1317
  {
1150
1318
  name: 'edit_file',
1151
- description: 'Surgical, conflict-safe edit: replace oldString with newString in a file, then commit and push. 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). Non-overlapping concurrent edits auto-merge. oldString must match exactly once unless replaceAll is true. After editing, call compile_file to verify the build.',
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.',
1152
1320
  inputSchema: {
1153
1321
  type: 'object',
1154
1322
  properties: {
@@ -1157,6 +1325,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1157
1325
  newString: { type: 'string', description: 'Replacement text.' },
1158
1326
  replaceAll: { type: 'boolean', description: 'Replace every occurrence (default false; otherwise oldString must be unique).' },
1159
1327
  commitMessage: { type: 'string' },
1328
+ push: { type: 'boolean', description: 'Push now. Default settings.autoPush; false keeps a local commit for publish_changes.' },
1160
1329
  projectName: { type: 'string' },
1161
1330
  },
1162
1331
  required: ['filePath', 'oldString', 'newString'],
@@ -1164,7 +1333,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1164
1333
  },
1165
1334
  {
1166
1335
  name: 'write_file',
1167
- description: 'Create a new file, or overwrite an existing one wholesale, then 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 writing, call compile_file to verify the build.',
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.',
1168
1337
  inputSchema: {
1169
1338
  type: 'object',
1170
1339
  properties: {
@@ -1173,6 +1342,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1173
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.' },
1174
1343
  overwrite: { type: 'boolean', description: 'Force-overwrite an existing file without a baseSha (deliberate full replacement). Ignored for new files.' },
1175
1344
  commitMessage: { type: 'string' },
1345
+ push: { type: 'boolean', description: 'Push now. Default settings.autoPush; false keeps a local commit for publish_changes.' },
1176
1346
  projectName: { type: 'string' },
1177
1347
  },
1178
1348
  required: ['filePath', 'content'],
@@ -1180,7 +1350,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1180
1350
  },
1181
1351
  {
1182
1352
  name: 'upload_file',
1183
- description: 'Upload a binary file (PNG/PDF figure, etc.) from a local disk path INTO the Overleaf project and 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 compile_file.',
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.',
1184
1354
  inputSchema: {
1185
1355
  type: 'object',
1186
1356
  properties: {
@@ -1194,6 +1364,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1194
1364
  baseSha: { type: 'string', description: 'Single-file mode only: baseSha from read_file; a stale value is refused. Ignored in batch.' },
1195
1365
  overwrite: { type: 'boolean', description: 'Replace existing dest file(s). Required to overwrite in batch mode.' },
1196
1366
  commitMessage: { type: 'string' },
1367
+ push: { type: 'boolean', description: 'Push now. Default settings.autoPush; false keeps a local commit for publish_changes.' },
1197
1368
  projectName: { type: 'string' },
1198
1369
  },
1199
1370
  },
@@ -1215,12 +1386,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1215
1386
  },
1216
1387
  {
1217
1388
  name: 'add_citation',
1218
- description: 'Append a BibTeX entry (raw @type{key, ...} string) to refs.bib and push. Refuses if the key already exists. Creates refs.bib if absent.',
1389
+ description: 'Append a BibTeX entry (raw @type{key, ...} string) to refs.bib and commit (local by default; see push). Refuses if the key already exists. Creates refs.bib if absent.',
1219
1390
  inputSchema: {
1220
1391
  type: 'object',
1221
1392
  properties: {
1222
1393
  entry: { type: 'string', description: 'A complete BibTeX entry, e.g. @article{key, title={...}, ...}.' },
1223
1394
  commitMessage: { type: 'string' },
1395
+ push: { type: 'boolean', description: 'Push now. Default settings.autoPush; false keeps a local commit for publish_changes.' },
1224
1396
  projectName: { type: 'string' },
1225
1397
  },
1226
1398
  required: ['entry'],
@@ -1238,12 +1410,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1238
1410
  },
1239
1411
  {
1240
1412
  name: 'restore',
1241
- description: 'Roll back to a checkpoint: re-applies the snapshot\'s file tree as a NEW commit on top of history and pushes (no force-push, no history rewrite). Overleaf reflects the rollback; intervening commits are preserved.',
1242
- inputSchema: { type: 'object', properties: { label: { type: 'string' }, projectName: { type: 'string' } }, required: ['label'] },
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.',
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'] },
1243
1415
  },
1244
1416
  {
1245
1417
  name: 'voice_lint',
1246
- 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: reports output, never blocks. 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.',
1247
1419
  inputSchema: {
1248
1420
  type: 'object',
1249
1421
  properties: { filePath: { type: 'string' }, projectName: { type: 'string' } },
@@ -1261,7 +1433,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1261
1433
  ],
1262
1434
  }));
1263
1435
 
1264
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
1436
+ server.setRequestHandler(CallToolRequestSchema, async (request) => observeTool(request.params.name, async () => {
1265
1437
  try {
1266
1438
  const { name, arguments: args } = request.params;
1267
1439
 
@@ -1501,7 +1673,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1501
1673
  const key = pickProjectKey(config, args.projectName);
1502
1674
  const project = config.projects[key];
1503
1675
  let guidelines = '';
1504
- try { guidelines = await readFile(GUIDELINES_PATH, 'utf-8'); }
1676
+ const guidelinesPath = resolveGuidelinesPath({ dataHome: DATA_HOME, packageDir: PACKAGE_DIR, exists: existsSync });
1677
+ try { guidelines = await readFile(guidelinesPath, 'utf-8'); }
1505
1678
  catch { guidelines = '(writing-guidelines.md missing from OverleafMCP folder)'; }
1506
1679
  const ctx = await readContext(key, project);
1507
1680
  const text = [
@@ -1522,7 +1695,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1522
1695
  ``,
1523
1696
  ctx.body,
1524
1697
  ].join('\n');
1525
- return { content: [{ type: 'text', text }] };
1698
+ const v = versionedContext(key,text,args.previousVersion);
1699
+ return { content: [{ type: 'text', text: `Context version: ${v.version}\n${v.text}` }], structuredContent: { version:v.version, unchanged:v.unchanged, projectName:key } };
1526
1700
  }
1527
1701
 
1528
1702
  case 'list_files': {
@@ -1536,7 +1710,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1536
1710
  const content = await client.readFile(args.filePath);
1537
1711
  const baseSha = await client.getBlobSha(args.filePath, { pull: false });
1538
1712
  const header = `<!-- overleaf-mcp baseSha: ${baseSha || 'none'} (pass as baseSha to write_file to guard against clobbering Overleaf edits) -->\n`;
1539
- return { content: [{ type: 'text', text: header + content }] };
1713
+ const { stdout } = await client._git(['-C', client.repoPath, 'rev-parse', 'HEAD']);
1714
+ return { content: [{ type: 'text', text: header + content }], structuredContent: { baseSha, baseRevision: stdout.trim(), contentHash: createHash('sha256').update(content).digest('hex') } };
1540
1715
  }
1541
1716
 
1542
1717
  case 'get_sections': {
@@ -1547,65 +1722,114 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1547
1722
 
1548
1723
  case 'get_section_content': {
1549
1724
  const { client } = await getClient(args.projectName);
1725
+ if (args.bundle) {
1726
+ await client.requireLocal();
1727
+ const result = await sectionBundle(client.repoPath, args.filePath, args.sectionTitle, args.maxChars);
1728
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
1729
+ }
1550
1730
  const content = await client.getSectionContent(args.filePath, args.sectionTitle);
1551
1731
  return { content: [{ type: 'text', text: content }] };
1552
1732
  }
1553
1733
 
1554
- case 'compile_file': {
1734
+ case 'sync_project': {
1555
1735
  const { client } = await getClient(args.projectName);
1556
- const r = await client.compileFile(args.filePath, args.engine || 'lualatex');
1557
- const status = r.pdfPath ? `✓ PDF written to ${r.pdfPath}` : '✗ Compilation failed — no PDF produced';
1558
- const parts = [status];
1559
- if (r.errors.length) parts.push(`\n--- Errors (${r.errors.length}) ---\n${r.errors.join('\n')}`);
1560
- if (r.undefinedRefs.length) parts.push(`\n--- Undefined refs/citations (${r.undefinedRefs.length}) ---\n${r.undefinedRefs.join('\n')}`);
1561
- if (r.overfull.length) parts.push(`\n--- Overfull/Underfull (${r.overfull.length}) ---\n${r.overfull.join('\n')}`);
1562
- parts.push(`\n--- Log tail ---\n${r.tail}`);
1563
- return { content: [{ type: 'text', text: parts.join('\n').trim() }] };
1736
+ const result = await client.syncProject({ strategy: args.strategy, confirm: args.confirm });
1737
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result };
1738
+ }
1739
+ case 'usage_stats': {
1740
+ const result = usageStats(args);
1741
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
1742
+ }
1743
+ case 'dependency_index':
1744
+ case 'change_report':
1745
+ case 'render_pages': {
1746
+ const { client } = await getClient(args.projectName);
1747
+ await client.requireLocal();
1748
+ const result = name === 'dependency_index' ? await dependencyIndex(client.repoPath, args)
1749
+ : name === 'change_report' ? await changeReport(client.repoPath, args.baselineVersion)
1750
+ : await renderPages(client.repoPath, args);
1751
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
1752
+ }
1753
+ case 'apply_changes':
1754
+ case 'publish_changes': {
1755
+ const config = await loadConfig();
1756
+ const { client } = await getClient(args.projectName);
1757
+ await client.requireLocal();
1758
+ const verify = async root => {
1759
+ const candidate = new OverleafGitClient(client.projectId, client.gitToken, root);
1760
+ return candidate.verifyBuild(args.filePath, args.engine || 'lualatex', {
1761
+ controlled: args.controlled === true,
1762
+ externalInputs: args.externalInputs,
1763
+ lint: args.lint,
1764
+ lintCommand: voiceLinterCommand(config),
1765
+ reuseRecentPass: name === 'publish_changes' && args.force !== true,
1766
+ });
1767
+ };
1768
+ const result = name === 'apply_changes' ? await applyChanges(client.repoPath, args, verify)
1769
+ : await publishChanges(client.repoPath, args, verify, async (root, revision, branch) => {
1770
+ try {
1771
+ await client._git(['-C', root, 'push', 'origin', `${revision}:refs/heads/${branch}`], { auth: true });
1772
+ } catch (e) {
1773
+ // A rejected push means Overleaf moved since the last sync; name
1774
+ // the recovery instead of surfacing raw git plumbing.
1775
+ if (/rejected|fetch first|non-fast-forward/i.test(e.stderr || '')) {
1776
+ throw new Error('Overleaf has commits this clone lacks, so the push was refused and nothing was published. Run sync_project to see both sides, resolve with strategy "rebase", verify again, then publish the new HEAD.');
1777
+ }
1778
+ throw e;
1779
+ }
1780
+ });
1781
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
1564
1782
  }
1565
-
1566
1783
  case 'verify_build': {
1784
+ const config = await loadConfig();
1567
1785
  const { client } = await getClient(args.projectName);
1568
- const v = await client.verifyBuild(args.filePath, args.engine || 'lualatex');
1569
- if (v.pass) {
1570
- const warn = (v.overfullCount || v.underfullCount)
1571
- ? ` (note: ${v.overfullCount} overfull / ${v.underfullCount} underfull boxes)` : '';
1572
- return { content: [{ type: 'text', text: `✓ PASS ${v.pageCount} pages${warn}` }] };
1573
- }
1574
- const parts = ['✗ FAIL'];
1575
- if (!v.pdfProduced) parts.push('- no PDF produced');
1576
- if (v.errors.length) parts.push(`- ${v.errors.length} error(s):\n${v.errors.slice(0, 20).join('\n')}`);
1577
- if (v.undefinedRefs.length) parts.push(`- ${v.undefinedRefs.length} undefined reference(s):\n${v.undefinedRefs.slice(0, 20).join('\n')}`);
1578
- if (v.undefinedCitations.length) parts.push(`- ${v.undefinedCitations.length} undefined citation(s):\n${v.undefinedCitations.slice(0, 20).join('\n')}`);
1579
- if (v.overfullCount || v.underfullCount) parts.push(`- (warnings) ${v.overfullCount} overfull / ${v.underfullCount} underfull`);
1580
- parts.push(`\n--- log tail ---\n${v.tail}`);
1581
- return { content: [{ type: 'text', text: parts.join('\n') }] };
1786
+ const opts = { ...args, lintCommand: voiceLinterCommand(config) };
1787
+ const v = args.clean === false
1788
+ ? await client.compileFile(args.filePath, args.engine || 'lualatex', opts)
1789
+ : await client.verifyBuild(args.filePath, args.engine || 'lualatex', opts);
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)];
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')}`);
1799
+ if (args.verbose) parts.push(v.tail);
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 } };
1582
1801
  }
1583
1802
 
1584
1803
  case 'edit_file': {
1804
+ const config = await loadConfig();
1585
1805
  const { client } = await getClient(args.projectName);
1586
- const res = await client.editFile(args.filePath, args.oldString, args.newString, args.replaceAll || false, args.commitMessage);
1587
- const tail = res.pushed
1588
- ? `Edited ${args.filePath}${res.merged ? ' (auto-merged a concurrent Overleaf change)' : ''}. NEXT STEP: call compile_file on the project main .tex to verify the build.`
1589
- : `No change applied to ${args.filePath} (${res.reason}).`;
1806
+ const res = await client.editFile(args.filePath, args.oldString, args.newString, args.replaceAll || false, args.commitMessage, { push: resolvePush(config.settings, args) });
1807
+ const tail = res.reason
1808
+ ? `No change applied to ${args.filePath} (${res.reason}).`
1809
+ : mutationTail(res, `Edited ${args.filePath}`);
1590
1810
  return { content: [{ type: 'text', text: tail }] };
1591
1811
  }
1592
1812
 
1593
1813
  case 'write_file': {
1814
+ const config = await loadConfig();
1594
1815
  const { client } = await getClient(args.projectName);
1595
1816
  const res = await client.writeFile(args.filePath, args.content, {
1596
1817
  baseSha: args.baseSha,
1597
1818
  overwrite: args.overwrite,
1598
1819
  commitMessage: args.commitMessage,
1820
+ push: resolvePush(config.settings, args),
1599
1821
  });
1600
- const tail = res.pushed
1601
- ? `Wrote ${args.filePath}. NEXT STEP: call compile_file on the project main .tex to verify the build.`
1602
- : `No change detected for ${args.filePath} (${res.reason}).`;
1822
+ const tail = res.reason
1823
+ ? `No change detected for ${args.filePath} (${res.reason}).`
1824
+ : mutationTail(res, `Wrote ${args.filePath}`);
1603
1825
  return { content: [{ type: 'text', text: tail }] };
1604
1826
  }
1605
1827
 
1606
1828
  case 'upload_file': {
1829
+ const config = await loadConfig();
1607
1830
  const { client } = await getClient(args.projectName);
1608
1831
  const res = await client.uploadFile({
1832
+ push: resolvePush(config.settings, args),
1609
1833
  srcPath: args.srcPath,
1610
1834
  destPath: args.destPath,
1611
1835
  files: args.files,
@@ -1613,9 +1837,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1613
1837
  overwrite: args.overwrite,
1614
1838
  commitMessage: args.commitMessage,
1615
1839
  });
1616
- const tail = res.pushed
1617
- ? `Uploaded ${res.files.length} file(s): ${res.files.join(', ')}. NEXT: reference each figure with \\includegraphics{...} via edit_file, then compile_file.`
1618
- : `No upload performed (${res.reason}).`;
1840
+ const tail = res.reason
1841
+ ? `No upload performed (${res.reason}).`
1842
+ : `${mutationTail(res, `Uploaded ${res.files.length} file(s): ${res.files.join(', ')}`)} Reference each figure with \\includegraphics{...} via edit_file.`;
1619
1843
  return { content: [{ type: 'text', text: tail }] };
1620
1844
  }
1621
1845
 
@@ -1628,9 +1852,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1628
1852
  }
1629
1853
 
1630
1854
  case 'add_citation': {
1855
+ const config = await loadConfig();
1631
1856
  const { client } = await getClient(args.projectName);
1632
- const res = await client.addCitation({ entry: args.entry, commitMessage: args.commitMessage });
1633
- return { content: [{ type: 'text', text: res.pushed ? `Added citation "${res.key}" to refs.bib and pushed.` : `No change for "${res.key}" (${res.reason}).` }] };
1857
+ const res = await client.addCitation({ entry: args.entry, commitMessage: args.commitMessage, push: resolvePush(config.settings, args) });
1858
+ return { content: [{ type: 'text', text: res.reason ? `No change for "${res.key}" (${res.reason}).` : mutationTail(res, `Added citation "${res.key}" to refs.bib`) }] };
1634
1859
  }
1635
1860
 
1636
1861
  case 'cite_lint': {
@@ -1649,22 +1874,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1649
1874
  }
1650
1875
 
1651
1876
  case 'restore': {
1877
+ const config = await loadConfig();
1652
1878
  const { client } = await getClient(args.projectName);
1653
- const r = await client.restore(args.label);
1654
- return { content: [{ type: 'text', text: `Restored "${r.label}" and pushed (forward commit). Run compile_file/verify_build to confirm.` }] };
1879
+ const r = await client.restore(args.label, { push: resolvePush(config.settings, args) });
1880
+ return { content: [{ type: 'text', text: `${mutationTail(r, `Restored "${r.label}" as a forward commit`)} Run verify_build to confirm.` }] };
1655
1881
  }
1656
1882
 
1657
1883
  case 'voice_lint': {
1658
1884
  const config = await loadConfig();
1659
1885
  const { client } = await getClient(args.projectName);
1660
- // settings.voiceLinter / $OVERLEAF_VOICE_LINTER override the bundled
1661
- // example linter, which ships with the package so voice_lint works out
1662
- // of the box. The example implements generic prose checks; point the
1663
- // setting at your own command to enforce a house style.
1664
- const command = config.settings?.voiceLinter
1665
- || process.env.OVERLEAF_VOICE_LINTER
1666
- || `node ${path.join(PACKAGE_DIR, 'examples', 'voice-lint.mjs')}`;
1667
- const r = await client.voiceLint(args.filePath, { command });
1886
+ const r = await client.voiceLint(args.filePath, { command: voiceLinterCommand(config) });
1668
1887
  return { content: [{ type: 'text', text: r.clean ? `✓ voice OK — ${args.filePath}${r.findings ? `\n${r.findings}` : ''}` : `voice findings in ${args.filePath}:\n${r.findings}` }] };
1669
1888
  }
1670
1889
 
@@ -1693,14 +1912,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1693
1912
  throw new Error(`Unknown tool: ${name}`);
1694
1913
  }
1695
1914
  } catch (error) {
1696
- // Defense in depth: scrub any tokenized URL that might surface in an error.
1697
- const msg = String(error?.message ?? error).replace(/git:[^@\s/]+@/g, 'git:***@');
1698
- return {
1699
- content: [{ type: 'text', text: `Error: ${msg}` }],
1700
- isError: true,
1701
- };
1915
+ return toolError(error);
1702
1916
  }
1703
- });
1917
+ })());
1704
1918
 
1705
1919
  async function main() {
1706
1920
  const transport = new StdioServerTransport();