overleaf-forge 2.9.1 → 2.11.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,14 @@ 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 buildQueues = new Map();
16
24
 
17
25
  const __filename = fileURLToPath(import.meta.url);
18
26
  const __dirname = path.dirname(__filename);
@@ -85,10 +93,20 @@ const CONFIG_PATH = path.join(DATA_HOME, 'projects.json');
85
93
  const CONTEXTS_DIR = path.join(DATA_HOME, 'contexts');
86
94
  const DEFAULT_REPO_DIR = path.join(DATA_HOME, 'repos');
87
95
  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');
96
+ // Guidelines resolve personal-first:
97
+ // 1. <dataHome>/writing-guidelines.local.md (gitignored; never ships)
98
+ // 2. <dataHome>/writing-guidelines.md (user copy, when dataHome is not the package)
99
+ // 3. <packageDir>/writing-guidelines.md (bundled generic default)
100
+ // The .local name is what keeps a personal copy distinct when dataHome IS the
101
+ // package dir (a local clone with projects.json), where 2 and 3 are one file.
102
+ // Resolved per call so creating or deleting the local file needs no restart.
103
+ export function resolveGuidelinesPath({ dataHome, packageDir, exists }) {
104
+ const local = path.join(dataHome, 'writing-guidelines.local.md');
105
+ if (exists(local)) return local;
106
+ const user = path.join(dataHome, 'writing-guidelines.md');
107
+ if (exists(user)) return user;
108
+ return path.join(packageDir, 'writing-guidelines.md');
109
+ }
92
110
 
93
111
  // Where scaffold templates (main.tex skeleton, context-scaffold.md) are read.
94
112
  // Precedence: settings.templatesDir → $OVERLEAF_MCP_TEMPLATES → ~/.overleaf-mcp/
@@ -197,7 +215,8 @@ const SETTING_HELP = {
197
215
  ssaSubdir: 'What subfolder name should new SSAs go under inside each course folder? (e.g. "MY SSAs")',
198
216
  templatesDir: 'Use your own scaffold templates? Give the directory holding main.tex / context-scaffold.md (blank keeps the bundled examples).',
199
217
  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).',
218
+ autoPush: 'Should edit tools push to Overleaf immediately (true), or commit locally until publish_changes sends a verified batch (false, the default)?',
219
+ gitToken: 'Overleaf git token (prefer the OVERLEAF_GIT_TOKEN env var; set here only if you must store it in projects.json).',
201
220
  };
202
221
  const SETTING_KEYS = Object.keys(SETTING_HELP);
203
222
  const SETTING_PATHY = new Set(['repoDir', 'academicRoot', 'templatesDir']);
@@ -218,6 +237,14 @@ export function mergeSettings(current, args, homeDir) {
218
237
  return { settings, provided };
219
238
  }
220
239
 
240
+ // Whether a mutating tool pushes to Overleaf. An explicit per-call `push` wins,
241
+ // then settings.autoPush; the default is false, so edits stay local commits
242
+ // until publish_changes verifies and sends them in one deliberate step.
243
+ export function resolvePush(settings, args) {
244
+ if (typeof args?.push === 'boolean') return args.push;
245
+ return settings?.autoPush === true;
246
+ }
247
+
221
248
  // Default project resolution:
222
249
  // 1. explicit projectName argument
223
250
  // 2. project whose `cwd` is a prefix of SESSION_CWD (longest match wins)
@@ -321,23 +348,71 @@ class OverleafGitClient {
321
348
  }
322
349
  // Repair older clones that embedded the token in the remote URL.
323
350
  await this._git(['-C', this.repoPath, 'remote', 'set-url', 'origin', this.gitUrl]).catch(() => {});
351
+ const { stdout } = await this._git(['-C', this.repoPath, 'pull', '--ff-only'], { auth: true });
352
+ return stdout;
353
+ }
354
+
355
+ async requireLocal() {
356
+ if (!(await this._hasRepo())) throw Object.assign(new Error('Local clone missing; call sync_project explicitly.'), { code: 'LOCAL_CLONE_MISSING' });
357
+ }
358
+
359
+ // Mutations that push absorb remote edits first; local-only mutations stay
360
+ // off the network entirely, like reads.
361
+ async _prepareMutation(push) {
362
+ if (push) await this.cloneOrPull();
363
+ else await this.requireLocal();
364
+ await this._git(['-C', this.repoPath, 'config', 'user.email', 'claude@anthropic.com']);
365
+ await this._git(['-C', this.repoPath, 'config', 'user.name', 'Claude']);
366
+ return this._head();
367
+ }
368
+
369
+ async _head() {
370
+ const { stdout } = await this._git(['-C', this.repoPath, 'rev-parse', 'HEAD']);
371
+ return stdout.trim();
372
+ }
373
+
374
+ // HEAD plus the number of local commits not yet on the remote-tracking branch.
375
+ // unpublished is null when there is no tracking ref to compare against.
376
+ async pendingState() {
377
+ const head = await this._head();
378
+ const branch = await this._currentBranch();
379
+ let unpublished = null;
324
380
  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}`;
381
+ const { stdout } = await this._git(['-C', this.repoPath, 'rev-list', '--count', `origin/${branch}..HEAD`]);
382
+ unpublished = Number(stdout.trim());
383
+ } catch { /* no tracking ref */ }
384
+ return { head, unpublished };
385
+ }
386
+
387
+ // Finish a mutation whose commit is already made. push:false keeps it local
388
+ // for publish_changes. A refused push rolls back to preHead, this operation's
389
+ // own starting point: only its commit is undone, and earlier unpublished
390
+ // commits survive. (Resetting to origin/<branch> would silently drop them.)
391
+ async _finishMutation(preHead, push, { merge = false, refusal }) {
392
+ if (!push) return { pushed: false, committed: true, ...(await this.pendingState()) };
393
+ if (merge) return this._pushWithMerge(preHead);
394
+ try {
395
+ await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
396
+ } catch (e) {
397
+ await this._git(['-C', this.repoPath, 'reset', '--hard', preHead]).catch(() => {});
398
+ throw new Error(`${refusal} (${(e.stderr || e.message || '').slice(0, 120)})`);
399
+ }
400
+ return { pushed: true };
401
+ }
402
+
403
+ // git commit that reports "nothing to commit" as a value instead of throwing.
404
+ async _commit(message) {
405
+ try {
406
+ await this._git(['-C', this.repoPath, 'commit', '-m', message]);
407
+ return true;
408
+ } catch (e) {
409
+ if (/nothing to commit/i.test((e.stdout || '') + (e.stderr || ''))) return false;
410
+ throw e;
336
411
  }
337
412
  }
338
413
 
339
414
  async listFiles(extension = '.tex') {
340
- await this.cloneOrPull();
415
+ await this.requireLocal();
341
416
  const out = [];
342
417
  const walk = async (dir) => {
343
418
  const entries = await readdir(dir, { withFileTypes: true });
@@ -355,14 +430,16 @@ class OverleafGitClient {
355
430
  }
356
431
 
357
432
  async readFile(filePath) {
358
- await this.cloneOrPull();
359
- const fullPath = path.join(this.repoPath, filePath);
433
+ await this.requireLocal();
434
+ const root = realpathSync(this.repoPath);
435
+ const fullPath = realpathSync(path.resolve(root, filePath));
436
+ if (!fullPath.startsWith(root + path.sep)) throw Object.assign(new Error('File must be inside the project.'), { code: 'INVALID_INPUT' });
360
437
  return await readFile(fullPath, 'utf-8');
361
438
  }
362
439
 
363
440
  // 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();
441
+ async getBlobSha(filePath) {
442
+ await this.requireLocal();
366
443
  try {
367
444
  const { stdout } = await this._git(['-C', this.repoPath, 'rev-parse', `HEAD:${filePath}`]);
368
445
  return stdout.trim();
@@ -387,63 +464,167 @@ class OverleafGitClient {
387
464
  // Run latexmk from the repo root (so the project's .latexmkrc -- shell-escape,
388
465
  // the python@3.13 PATH fix for minted, $pdf_mode -- applies, and refs/citations/
389
466
  // 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();
467
+ async _runLatexmk(filePath, engine = 'lualatex', { clean = false, controlled = false } = {}) {
468
+ const full = path.resolve(this.repoPath, filePath);
469
+ 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.');
470
+ if (!(await this._hasRepo())) throw new Error('Local clone missing; call sync_project explicitly before building.');
392
471
  const engineFlag = { pdflatex: '-pdf', xelatex: '-xelatex', lualatex: '-lualatex' }[engine];
393
472
  if (!engineFlag) {
394
473
  throw new Error(`Invalid engine "${engine}". Choose from: pdflatex, xelatex, lualatex`);
395
474
  }
396
475
  const texbin = '/Library/TeX/texbin';
397
476
  const env = { ...process.env, PATH: `${texbin}:${process.env.PATH || ''}` };
398
- const args = [engineFlag, '-interaction=nonstopmode', '-halt-on-error'];
477
+ const args = [engineFlag, '-interaction=nonstopmode', '-halt-on-error', '-recorder'];
478
+ // -norc must precede all other options so no user or project Perl config runs.
479
+ if (controlled) args.unshift('-norc', '-no-shell-escape');
399
480
  if (clean) args.push('-gg');
400
481
  args.push(filePath);
482
+ let commandFailed = false;
401
483
  const { stdout, stderr } = await execFile(
402
484
  path.join(texbin, 'latexmk'), args,
403
485
  { cwd: this.repoPath, timeout: 180000, maxBuffer: 20 * 1024 * 1024, env }
404
- ).catch(e => ({ stdout: e.stdout || '', stderr: e.stderr || e.message }));
486
+ ).catch(e => { commandFailed = true; return { stdout: e.stdout || '', stderr: e.stderr || e.message }; });
405
487
  const pdfPath = path.join(this.repoPath, filePath.replace(/\.tex$/, '.pdf'));
406
488
  let pdfExists = false;
407
489
  try { await access(pdfPath); pdfExists = true; } catch { /* no pdf */ }
408
- return { stdout, stderr, log: `${stdout}\n${stderr}`, pdfPath: pdfExists ? pdfPath : null };
490
+ return { stdout, stderr, commandFailed, log: `${stdout}\n${stderr}`, pdfPath: !commandFailed && pdfExists ? pdfPath : null };
491
+ }
492
+
493
+ async compileFile(filePath, engine = 'lualatex', options = {}) {
494
+ return this.verifyBuild(filePath, engine, { ...options, force: true, clean: false });
495
+ }
496
+
497
+ // Explicit sync. Fetches, then acts on the ahead/behind relation:
498
+ // behind only -> fast-forward
499
+ // ahead only / equal -> nothing to do (ahead = unpublished local commits)
500
+ // diverged -> report both sides and change nothing, unless a
501
+ // strategy is chosen:
502
+ // 'rebase' replays local commits onto the remote; a conflict aborts back
503
+ // to the untouched pre-sync state.
504
+ // 'reset' discards local work to match the remote. Requires confirm to
505
+ // equal the reported local HEAD (proof the report was read), and
506
+ // tags the old HEAD and any uncommitted edits as mcp-backup/*
507
+ // first, so nothing becomes unrecoverable.
508
+ async syncProject({ strategy, confirm } = {}) {
509
+ if (!(await this._hasRepo())) {
510
+ await this.cloneOrPull();
511
+ return { state: 'cloned', ...(await this.pendingState()) };
512
+ }
513
+ if (strategy !== undefined && strategy !== 'rebase' && strategy !== 'reset') {
514
+ throw new Error(`Unknown strategy "${strategy}". Use "rebase" or "reset".`);
515
+ }
516
+ const g = (args, opts) => this._git(['-C', this.repoPath, ...args], opts);
517
+ await g(['remote', 'set-url', 'origin', this.gitUrl]).catch(() => {});
518
+ await g(['fetch', 'origin'], { auth: true });
519
+ // rebase and stash create write commits, which need a committer identity.
520
+ await g(['config', 'user.email', 'claude@anthropic.com']);
521
+ await g(['config', 'user.name', 'Claude']);
522
+ const branch = await this._currentBranch();
523
+ const upstream = `origin/${branch}`;
524
+ const count = async range => Number((await g(['rev-list', '--count', range])).stdout.trim());
525
+ const ahead = await count(`${upstream}..HEAD`);
526
+ const behind = await count(`HEAD..${upstream}`);
527
+ const head = await this._head();
528
+ const dirty = (await g(['status', '--porcelain=v1', '--untracked-files=no'])).stdout.split('\n').filter(Boolean).map(l => l.slice(3));
529
+
530
+ if (strategy === 'reset') {
531
+ if (confirm !== head) {
532
+ throw new Error(`reset discards local work; pass confirm: "${head}" (the current local HEAD) after reviewing the sync_project report.`);
533
+ }
534
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
535
+ const backups = [`mcp-backup/${stamp}`];
536
+ await g(['tag', backups[0], head]);
537
+ if (dirty.length) {
538
+ // stash create snapshots uncommitted tracked edits as a commit without
539
+ // touching the working tree; the tag keeps it reachable.
540
+ const wip = (await g(['stash', 'create', `mcp-backup ${stamp}`])).stdout.trim();
541
+ if (wip) { backups.push(`mcp-backup/${stamp}-wip`); await g(['tag', backups[1], wip]); }
542
+ }
543
+ await g(['reset', '--hard', upstream]);
544
+ return { state: 'reset', discardedCommits: ahead, discardedFiles: dirty, backups, ...(await this.pendingState()) };
545
+ }
546
+
547
+ if (behind === 0) return { state: ahead ? 'ahead' : 'up-to-date', ahead, behind, head, dirtyFiles: dirty };
548
+ if (ahead === 0) {
549
+ await g(['merge', '--ff-only', upstream]);
550
+ return { state: 'fast-forwarded', ahead, behind, ...(await this.pendingState()) };
551
+ }
552
+
553
+ const log = async range => (await g(['log', '--format=%h %s', '--name-only', range])).stdout.trim();
554
+ const report = {
555
+ state: 'diverged', ahead, behind, head, upstream: (await g(['rev-parse', upstream])).stdout.trim(),
556
+ localCommits: await log(`${upstream}..HEAD`), remoteCommits: await log(`HEAD..${upstream}`), dirtyFiles: dirty,
557
+ options: 'strategy:"rebase" replays local commits onto Overleaf (aborts cleanly on conflict); strategy:"reset" with confirm:<head> discards local work after tagging a backup.',
558
+ };
559
+ if (strategy !== 'rebase') return report;
560
+ if (dirty.length) throw new Error(`rebase needs a clean working tree; uncommitted edits in: ${dirty.join(', ')}. Commit them or choose reset.`);
561
+ try {
562
+ await g(['rebase', upstream]);
563
+ } catch {
564
+ const conflicts = (await g(['diff', '--name-only', '--diff-filter=U']).catch(() => ({ stdout: '' }))).stdout.trim().split('\n').filter(Boolean);
565
+ await g(['rebase', '--abort']).catch(() => {});
566
+ return { ...report, state: 'rebase-conflict', conflicts, note: 'Rebase aborted; local state is unchanged.' };
567
+ }
568
+ return { state: 'rebased', ...(await this.pendingState()) };
409
569
  }
410
570
 
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) };
571
+ // options.lint (true = every .tex file, or an array of paths) adds the voice
572
+ // linter to the gate: findings fail it like an undefined reference does.
573
+ // Lint runs outside the build cache, so a reused build verdict is never
574
+ // mutated and lint always sees the current files.
575
+ async verifyBuild(filePath, engine = 'lualatex', options = {}) {
576
+ const key = path.resolve(this.repoPath);
577
+ const previous = buildQueues.get(key) || Promise.resolve();
578
+ const task = previous.catch(() => {}).then(() => this._verifyLocal(filePath, engine, options));
579
+ buildQueues.set(key, task);
580
+ let verdict;
581
+ try { verdict = await task; } finally { if (buildQueues.get(key) === task) buildQueues.delete(key); }
582
+ if (!options.lint) return verdict;
583
+ const files = Array.isArray(options.lint) ? options.lint : await this.listFiles('.tex');
584
+ const lint = await this.lintFiles(files, options.lintCommand);
585
+ return { ...verdict, lint, pass: verdict.pass && lint.clean };
417
586
  }
418
587
 
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'));
588
+ async lintFiles(files, command) {
589
+ const results = [];
590
+ for (const file of files) results.push({ file, ...(await this.voiceLint(file, { command })) });
591
+ return { clean: results.every(r => r.clean), results };
592
+ }
593
+
594
+ async _verifyLocal(filePath, engine, options = {}) {
595
+ const { force = false, clean = true } = options;
596
+ const config = controlledBuildOptions(options);
597
+ const key = JSON.stringify([this.repoPath,filePath,engine,config]);
598
+ const fingerprint = () => buildFingerprint(this.repoPath,filePath,engine,config).catch(()=>null);
599
+ const sources = () => buildFingerprint(this.repoPath,filePath,engine,{...config,sourcesOnly:true});
600
+ const cached = verifiedBuilds.get(key);
601
+ const before = await sources();
602
+ if (!force && cached && cached.fingerprint === await fingerprint()) return { ...cached.verdict, reused: true };
603
+ verifiedBuilds.delete(key);
604
+ const { log: runLog, pdfPath, commandFailed } = await this._runLatexmk(filePath,engine,{clean,controlled:config.controlled});
605
+ const logPath = path.join(this.repoPath,filePath.replace(/\.tex$/,'.log'));
429
606
  let finalLog = runLog;
430
- try { finalLog = await readFile(logFile, 'utf-8'); } catch { /* keep runLog */ }
607
+ try { finalLog = await readFile(logPath,'utf8'); } catch { /* failed before log creation */ }
431
608
  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.
609
+ if (commandFailed) verdict.errors.push('latexmk command failed; any previous PDF is not a successful build.');
434
610
  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);
611
+ verdict.pass = verdict.pdfProduced && !verdict.errors.length && !verdict.undefinedRefs.length && !verdict.undefinedCitations.length;
612
+ verdict.logPath = logPath;
613
+ verdict.pdfPath = pdfPath;
614
+ verdict.tail = (commandFailed ? runLog : finalLog).slice(-2500);
615
+ verdict.reused = false;
616
+ const after = await sources();
617
+ if (before && before !== after) { verdict.pass = false; verdict.errors.push('Project inputs changed during the build; verify the latest source again.'); }
618
+ const print = before && before === after ? await fingerprint() : null;
619
+ verdict.cacheEligible = Boolean(print);
620
+ if (verdict.pass && print) verifiedBuilds.set(key,{ fingerprint:print,verdict });
440
621
  return verdict;
441
622
  }
442
623
 
443
624
  // Read-only grep across tracked files. Regex by default; fixed -> -F; ignoreCase -> -i.
444
625
  async searchText({ query, fixed = false, ignoreCase = false, extension } = {}) {
445
626
  if (!query) throw new Error('search_text needs a query.');
446
- await this.cloneOrPull();
627
+ await this.requireLocal();
447
628
  const args = ['-C', this.repoPath, 'grep', '-n', '--no-color', fixed ? '-F' : '-E'];
448
629
  if (ignoreCase) args.push('-i');
449
630
  args.push('-e', query);
@@ -458,34 +639,28 @@ class OverleafGitClient {
458
639
  }
459
640
  }
460
641
 
461
- // Append a BibTeX entry to refs.bib (reject a duplicate key), commit, push.
462
- async addCitation({ entry, commitMessage } = {}) {
642
+ // Append a BibTeX entry to refs.bib (reject a duplicate key), commit, and
643
+ // push when push is true.
644
+ async addCitation({ entry, commitMessage, push = true } = {}) {
463
645
  if (!entry || !entry.trim()) throw new Error('add_citation needs a BibTeX entry.');
464
646
  const m = entry.match(/@\w+\s*\{\s*([^,\s]+)/);
465
647
  if (!m) throw new Error('Could not find a BibTeX key in the entry (expected @type{key, ...}).');
466
648
  const key = m[1];
467
- await this.cloneOrPull();
649
+ const preHead = await this._prepareMutation(push);
468
650
  const bibPath = path.join(this.repoPath, 'refs.bib');
469
651
  let current = '';
470
652
  try { current = await readFile(bibPath, 'utf-8'); } catch { /* missing -> create */ }
471
653
  const dup = new RegExp(`@\\w+\\s*\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*,`);
472
654
  if (dup.test(current)) throw new Error(`citation key "${key}" already in refs.bib.`);
473
655
  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
656
  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 };
657
+ if (!(await this._commit(commitMessage || `Add citation ${key}`))) return { pushed: false, reason: 'nothing to commit', key };
658
+ return { ...(await this._finishMutation(preHead, push, { merge: true })), key };
484
659
  }
485
660
 
486
661
  // Read-only: cited keys (across .tex) vs defined keys (refs.bib).
487
662
  async citeLint() {
488
- await this.cloneOrPull();
663
+ await this.requireLocal();
489
664
  const texFiles = await this.listFiles('.tex');
490
665
  const citeRe = /\\(?:cite|autocite|parencite|citep|citet|textcite|footcite|nocite)\*?(?:\[[^\]]*\])*\{([^}]+)\}/g;
491
666
  const cited = new Set();
@@ -519,7 +694,7 @@ class OverleafGitClient {
519
694
 
520
695
  // Local rollback point: a lightweight tag mcp-snap/<label> at HEAD (not pushed).
521
696
  async checkpoint(label) {
522
- await this.cloneOrPull();
697
+ await this.requireLocal();
523
698
  const name = `mcp-snap/${(label && label.trim()) || `snap-${Date.now()}`}`;
524
699
  let exists = false;
525
700
  try { await this._git(['-C', this.repoPath, 'rev-parse', '--verify', '--quiet', `refs/tags/${name}`]); exists = true; } catch { exists = false; }
@@ -529,10 +704,12 @@ class OverleafGitClient {
529
704
  return { label: name, head: stdout.trim() };
530
705
  }
531
706
 
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();
707
+ // Forward-restore the snapshot's tree as a new commit on top of HEAD, pushed
708
+ // when push is true. No history rewrite, no force-push (the new commit
709
+ // descends from HEAD). The fast-forward refuses rather than overwriting
710
+ // uncommitted local edits to files the restore would change.
711
+ async restore(label, { push = true } = {}) {
712
+ const preHead = await this._prepareMutation(push);
536
713
  const name = String(label || '').startsWith('mcp-snap/') ? label : `mcp-snap/${label}`;
537
714
  let tree;
538
715
  try { ({ stdout: tree } = await this._git(['-C', this.repoPath, 'rev-parse', `${name}^{tree}`])); }
@@ -541,19 +718,10 @@ class OverleafGitClient {
541
718
  throw new Error(`snapshot "${name}" not found. Available: ${tags || '(none)'}`);
542
719
  }
543
720
  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
721
  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 };
722
+ await this._git(['-C', this.repoPath, 'merge', '--ff-only', commit.trim()]);
723
+ const res = await this._finishMutation(preHead, push, { refusal: 'restore: Overleaf moved during the rollback; refused. Run sync_project, then retry.' });
724
+ return { ...res, label: name, restoredTo: tree };
557
725
  }
558
726
 
559
727
  // Run the configured voice linter on a file; advisory, read-only.
@@ -605,9 +773,11 @@ class OverleafGitClient {
605
773
  }
606
774
 
607
775
  // 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() {
776
+ // let git 3-way merge it: clean merge -> push; real conflict -> abort, roll
777
+ // back to preHead (the state before this operation's commit), and throw, so
778
+ // nothing half-applied is left behind and earlier local commits survive.
779
+ async _pushWithMerge(preHead) {
780
+ if (!preHead) throw new Error('_pushWithMerge needs the pre-operation HEAD to roll back to.');
611
781
  try {
612
782
  await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
613
783
  return { pushed: true, merged: false };
@@ -618,15 +788,15 @@ class OverleafGitClient {
618
788
  await this._git(['-C', this.repoPath, 'merge', '--no-edit', `origin/${branch}`]);
619
789
  } catch (mergeErr) {
620
790
  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.');
791
+ await this._git(['-C', this.repoPath, 'reset', '--hard', preHead]);
792
+ 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
793
  e.cause = mergeErr;
624
794
  throw e;
625
795
  }
626
796
  try {
627
797
  await this._git(['-C', this.repoPath, 'push', 'origin', 'HEAD'], { auth: true });
628
798
  } catch (e2) {
629
- await this._git(['-C', this.repoPath, 'reset', '--hard', `origin/${branch}`]).catch(() => {});
799
+ await this._git(['-C', this.repoPath, 'reset', '--hard', preHead]).catch(() => {});
630
800
  const e = new Error('conflict: Overleaf moved again while merging; reset clean — re-read the file and retry.');
631
801
  e.cause = e2;
632
802
  throw e;
@@ -639,10 +809,10 @@ class OverleafGitClient {
639
809
  // files: require either a matching baseSha (proves freshness) or overwrite:true
640
810
  // (a deliberate clobber). A stale baseSha is refused, never merged.
641
811
  async writeFile(filePath, content, opts = {}) {
642
- const { baseSha, overwrite = false, commitMessage } = opts;
643
- await this.cloneOrPull();
812
+ const { baseSha, overwrite = false, commitMessage, push = true } = opts;
813
+ const preHead = await this._prepareMutation(push);
644
814
  const fullPath = path.join(this.repoPath, filePath);
645
- const current = await this.getBlobSha(filePath, { pull: false }); // null if new
815
+ const current = await this.getBlobSha(filePath); // null if new
646
816
 
647
817
  if (current !== null) {
648
818
  if (baseSha != null) {
@@ -656,28 +826,10 @@ class OverleafGitClient {
656
826
 
657
827
  await mkdir(path.dirname(fullPath), { recursive: true });
658
828
  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
829
  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
- }
830
+ if (!(await this._commit(commitMessage || `Update ${filePath} via Claude`))) return { pushed: false, reason: 'nothing to commit' };
670
831
  // 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 };
832
+ return this._finishMutation(preHead, push, { refusal: `${filePath}: Overleaf moved while writing; refused to overwrite. Run sync_project, re-read and retry.` });
681
833
  }
682
834
 
683
835
  // Upload binary file(s) from local disk into the clone and push. Single mode:
@@ -686,7 +838,7 @@ class OverleafGitClient {
686
838
  // Binary never 3-way-merges, so a push race refuses + resets like writeFile.
687
839
  // baseSha freshness applies in single mode only; existing files in batch mode
688
840
  // require overwrite:true.
689
- async uploadFile({ srcPath, destPath, files, baseSha, overwrite = false, commitMessage } = {}) {
841
+ async uploadFile({ srcPath, destPath, files, baseSha, overwrite = false, commitMessage, push = true } = {}) {
690
842
  let pairs;
691
843
  const batch = Array.isArray(files);
692
844
  if (batch) {
@@ -699,7 +851,7 @@ class OverleafGitClient {
699
851
  throw new Error('upload_file needs srcPath+destPath (single) or files:[{srcPath,destPath}] (batch).');
700
852
  }
701
853
 
702
- await this.cloneOrPull();
854
+ const preHead = await this._prepareMutation(push);
703
855
  const repoAbs = path.resolve(this.repoPath);
704
856
  const resolved = [];
705
857
  for (const { src, dest } of pairs) {
@@ -711,7 +863,7 @@ class OverleafGitClient {
711
863
  if (destAbs === repoAbs || rel.startsWith('..') || path.isAbsolute(rel) || rel.split(path.sep)[0] === '.git') {
712
864
  throw new Error(`destPath escapes the project or is not allowed: ${dest}`);
713
865
  }
714
- const current = await this.getBlobSha(rel, { pull: false });
866
+ const current = await this.getBlobSha(rel);
715
867
  if (current !== null) {
716
868
  if (!batch && baseSha != null) {
717
869
  if (baseSha !== current) {
@@ -729,34 +881,21 @@ class OverleafGitClient {
729
881
  await copyFile(src, destAbs);
730
882
  }
731
883
 
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;
884
+ const rels = resolved.map(r => r.rel);
885
+ await this._git(['-C', this.repoPath, 'add', '--', ...rels]);
886
+ if (!(await this._commit(commitMessage || `Upload ${resolved.length} file(s) via Claude`))) {
887
+ return { pushed: false, reason: 'nothing to commit (identical to repo)', files: rels };
742
888
  }
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) };
889
+ const res = await this._finishMutation(preHead, push, { refusal: 'Overleaf moved while uploading; refused. Run sync_project and retry.' });
890
+ return { ...res, files: rels };
753
891
  }
754
892
 
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();
893
+ // Anchored, conflict-safe edit. When pushing, pulls first (absorbing
894
+ // non-overlapping Overleaf edits); local-only edits never touch the network.
895
+ // A missing anchor means the region changed (overlap) or the string was
896
+ // wrong -> refuse, nothing written.
897
+ async editFile(filePath, oldString, newString, replaceAll = false, commitMessage, { push = true } = {}) {
898
+ const preHead = await this._prepareMutation(push);
760
899
  const fullPath = path.join(this.repoPath, filePath);
761
900
  let content;
762
901
  try { content = await readFile(fullPath, 'utf-8'); }
@@ -772,39 +911,39 @@ class OverleafGitClient {
772
911
  }
773
912
  const updated = replaceAll ? parts.join(newString) : content.replace(oldString, () => newString);
774
913
  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
914
  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();
915
+ if (!(await this._commit(commitMessage || `Edit ${filePath} via Claude`))) return { pushed: false, reason: 'no change (new === old)' };
916
+ return this._finishMutation(preHead, push, { merge: true });
788
917
  }
789
918
 
919
+ // Same slicing as the bundle (sectionText): runs to the next heading of the
920
+ // same or higher level, covers \paragraph, and refuses an ambiguous title
921
+ // rather than silently returning the first match.
790
922
  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);
923
+ return sectionText(await this.readFile(filePath), sectionTitle);
803
924
  }
804
925
  }
805
926
 
806
927
  export { OverleafGitClient };
807
928
 
929
+ // settings.voiceLinter / $OVERLEAF_VOICE_LINTER override the bundled example
930
+ // linter, which ships with the package so voice_lint works out of the box. The
931
+ // example implements generic prose checks; point the setting at your own
932
+ // command to enforce a house style.
933
+ function voiceLinterCommand(config) {
934
+ return config.settings?.voiceLinter
935
+ || process.env.OVERLEAF_VOICE_LINTER
936
+ || `node ${path.join(PACKAGE_DIR, 'examples', 'voice-lint.mjs')}`;
937
+ }
938
+
939
+ // What a mutating tool reports: pushed, or committed locally with the count of
940
+ // commits still waiting for publish_changes.
941
+ function mutationTail(res, what) {
942
+ 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.`;
945
+ }
946
+
808
947
  async function getClient(projectName) {
809
948
  const config = await loadConfig();
810
949
  const key = pickProjectKey(config, projectName);
@@ -973,12 +1112,21 @@ const server = new Server(
973
1112
 
974
1113
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
975
1114
  tools: [
1115
+ ...[
1116
+ ['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' } } }, []],
1117
+ ['change_report', 'Compact local file and section changes against an in-process baseline. Omit baselineVersion to establish one.', { baselineVersion: { type: 'string' } }, []],
1118
+ ['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
+ ['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
+ ['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']],
1122
+ ].map(([name, description, properties, required]) => ({ name, description, inputSchema: { type: 'object', properties: { projectName: { type: 'string' }, ...properties }, required } })),
976
1123
  {
977
1124
  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.',
1125
+ description: 'Read current writing and project context. Supply previousVersion to receive a compact unchanged response when content and project identity match.',
979
1126
  inputSchema: {
980
1127
  type: 'object',
981
1128
  properties: {
1129
+ previousVersion: { type: 'string', description: 'Version returned by the previous context read.' },
982
1130
  projectName: { type: 'string', description: 'Project key. Omit to auto-detect from current working directory.' },
983
1131
  },
984
1132
  },
@@ -1019,7 +1167,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1019
1167
  },
1020
1168
  {
1021
1169
  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.',
1170
+ 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
1171
  inputSchema: {
1024
1172
  type: 'object',
1025
1173
  properties: {
@@ -1028,6 +1176,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1028
1176
  ssaSubdir: { type: 'string', description: 'Subfolder name created under each course folder for new SSAs, e.g. "MY SSAs".' },
1029
1177
  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
1178
  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.' },
1179
+ autoPush: { type: 'boolean', description: 'true: edit tools push immediately. false (default): they commit locally and publish_changes sends the verified batch.' },
1031
1180
  gitToken: { type: 'string', description: 'Overleaf git token. Prefer the OVERLEAF_GIT_TOKEN env var; set here only to store it in projects.json.' },
1032
1181
  },
1033
1182
  },
@@ -1107,38 +1256,42 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1107
1256
  required: ['filePath'],
1108
1257
  },
1109
1258
  },
1259
+ {
1260
+ 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.',
1262
+ inputSchema: { type: 'object', properties: {
1263
+ strategy: { type: 'string', enum: ['rebase', 'reset'], description: 'Only for a diverged clone. Omit to get the report first.' },
1264
+ confirm: { type: 'string', description: 'For strategy "reset": the full local head SHA from the report.' },
1265
+ projectName: { type: 'string' },
1266
+ } },
1267
+ },
1110
1268
  {
1111
1269
  name: 'get_section_content',
1112
- description: 'Get the body of a single section by title.',
1270
+ 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
1271
  inputSchema: {
1114
1272
  type: 'object',
1115
1273
  properties: {
1116
1274
  filePath: { type: 'string' },
1117
1275
  sectionTitle: { type: 'string' },
1276
+ bundle: { type: 'boolean', default: false, description: 'Include referenced blocks, bibliography entries and assets.' },
1277
+ maxChars: { type: 'integer', minimum: 2000, maximum: 64000, description: 'bundle only: response budget (default 16000).' },
1118
1278
  projectName: { type: 'string' },
1119
1279
  },
1120
1280
  required: ['filePath', 'sectionTitle'],
1121
1281
  },
1122
1282
  },
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
1283
  {
1137
1284
  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.',
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.',
1139
1286
  inputSchema: {
1140
1287
  type: 'object',
1141
1288
  properties: {
1289
+ clean: { type: 'boolean', default: true, description: 'false = quick incremental rebuild that always recompiles (intermediate checks); true = from-scratch final gate.' },
1290
+ 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' } }] },
1291
+ controlled: { type: 'boolean', default: false, description: 'Ignore all latexmk rc files and disable shell escape. Opt in only when the project supports this mode.' },
1292
+ externalInputs: { type: 'array', items: { type: 'string' }, description: 'Absolute paths of additional build inputs to hash.' },
1293
+ verbose: { type: 'boolean', default: false, description: 'Include a bounded log tail; full log remains on disk.' },
1294
+ force: { type: 'boolean', default: false, description: 'Ignore a cached verification and rebuild.' },
1142
1295
  filePath: { type: 'string', description: 'The entrypoint, usually main.tex.' },
1143
1296
  engine: { type: 'string', description: 'pdflatex | xelatex | lualatex (default lualatex).' },
1144
1297
  projectName: { type: 'string' },
@@ -1148,7 +1301,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1148
1301
  },
1149
1302
  {
1150
1303
  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.',
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.',
1152
1305
  inputSchema: {
1153
1306
  type: 'object',
1154
1307
  properties: {
@@ -1157,6 +1310,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1157
1310
  newString: { type: 'string', description: 'Replacement text.' },
1158
1311
  replaceAll: { type: 'boolean', description: 'Replace every occurrence (default false; otherwise oldString must be unique).' },
1159
1312
  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.' },
1160
1314
  projectName: { type: 'string' },
1161
1315
  },
1162
1316
  required: ['filePath', 'oldString', 'newString'],
@@ -1164,7 +1318,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1164
1318
  },
1165
1319
  {
1166
1320
  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.',
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.',
1168
1322
  inputSchema: {
1169
1323
  type: 'object',
1170
1324
  properties: {
@@ -1173,6 +1327,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1173
1327
  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
1328
  overwrite: { type: 'boolean', description: 'Force-overwrite an existing file without a baseSha (deliberate full replacement). Ignored for new files.' },
1175
1329
  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.' },
1176
1331
  projectName: { type: 'string' },
1177
1332
  },
1178
1333
  required: ['filePath', 'content'],
@@ -1180,7 +1335,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1180
1335
  },
1181
1336
  {
1182
1337
  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.',
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.',
1184
1339
  inputSchema: {
1185
1340
  type: 'object',
1186
1341
  properties: {
@@ -1194,6 +1349,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1194
1349
  baseSha: { type: 'string', description: 'Single-file mode only: baseSha from read_file; a stale value is refused. Ignored in batch.' },
1195
1350
  overwrite: { type: 'boolean', description: 'Replace existing dest file(s). Required to overwrite in batch mode.' },
1196
1351
  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.' },
1197
1353
  projectName: { type: 'string' },
1198
1354
  },
1199
1355
  },
@@ -1215,12 +1371,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1215
1371
  },
1216
1372
  {
1217
1373
  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.',
1374
+ 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
1375
  inputSchema: {
1220
1376
  type: 'object',
1221
1377
  properties: {
1222
1378
  entry: { type: 'string', description: 'A complete BibTeX entry, e.g. @article{key, title={...}, ...}.' },
1223
1379
  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.' },
1224
1381
  projectName: { type: 'string' },
1225
1382
  },
1226
1383
  required: ['entry'],
@@ -1238,12 +1395,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1238
1395
  },
1239
1396
  {
1240
1397
  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'] },
1398
+ 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'] },
1243
1400
  },
1244
1401
  {
1245
1402
  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.',
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.',
1247
1404
  inputSchema: {
1248
1405
  type: 'object',
1249
1406
  properties: { filePath: { type: 'string' }, projectName: { type: 'string' } },
@@ -1261,7 +1418,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1261
1418
  ],
1262
1419
  }));
1263
1420
 
1264
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
1421
+ server.setRequestHandler(CallToolRequestSchema, async (request) => observeTool(request.params.name, async () => {
1265
1422
  try {
1266
1423
  const { name, arguments: args } = request.params;
1267
1424
 
@@ -1501,7 +1658,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1501
1658
  const key = pickProjectKey(config, args.projectName);
1502
1659
  const project = config.projects[key];
1503
1660
  let guidelines = '';
1504
- try { guidelines = await readFile(GUIDELINES_PATH, 'utf-8'); }
1661
+ const guidelinesPath = resolveGuidelinesPath({ dataHome: DATA_HOME, packageDir: PACKAGE_DIR, exists: existsSync });
1662
+ try { guidelines = await readFile(guidelinesPath, 'utf-8'); }
1505
1663
  catch { guidelines = '(writing-guidelines.md missing from OverleafMCP folder)'; }
1506
1664
  const ctx = await readContext(key, project);
1507
1665
  const text = [
@@ -1522,7 +1680,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1522
1680
  ``,
1523
1681
  ctx.body,
1524
1682
  ].join('\n');
1525
- return { content: [{ type: 'text', text }] };
1683
+ const v = versionedContext(key,text,args.previousVersion);
1684
+ return { content: [{ type: 'text', text: `Context version: ${v.version}\n${v.text}` }], structuredContent: { version:v.version, unchanged:v.unchanged, projectName:key } };
1526
1685
  }
1527
1686
 
1528
1687
  case 'list_files': {
@@ -1536,7 +1695,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1536
1695
  const content = await client.readFile(args.filePath);
1537
1696
  const baseSha = await client.getBlobSha(args.filePath, { pull: false });
1538
1697
  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 }] };
1698
+ const { stdout } = await client._git(['-C', client.repoPath, 'rev-parse', 'HEAD']);
1699
+ return { content: [{ type: 'text', text: header + content }], structuredContent: { baseSha, baseRevision: stdout.trim(), contentHash: createHash('sha256').update(content).digest('hex') } };
1540
1700
  }
1541
1701
 
1542
1702
  case 'get_sections': {
@@ -1547,65 +1707,109 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1547
1707
 
1548
1708
  case 'get_section_content': {
1549
1709
  const { client } = await getClient(args.projectName);
1710
+ if (args.bundle) {
1711
+ await client.requireLocal();
1712
+ const result = await sectionBundle(client.repoPath, args.filePath, args.sectionTitle, args.maxChars);
1713
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
1714
+ }
1550
1715
  const content = await client.getSectionContent(args.filePath, args.sectionTitle);
1551
1716
  return { content: [{ type: 'text', text: content }] };
1552
1717
  }
1553
1718
 
1554
- case 'compile_file': {
1719
+ case 'sync_project': {
1555
1720
  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() }] };
1721
+ const result = await client.syncProject({ strategy: args.strategy, confirm: args.confirm });
1722
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result };
1723
+ }
1724
+ case 'usage_stats': {
1725
+ const result = usageStats(args);
1726
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
1727
+ }
1728
+ case 'dependency_index':
1729
+ case 'change_report':
1730
+ case 'render_pages': {
1731
+ const { client } = await getClient(args.projectName);
1732
+ await client.requireLocal();
1733
+ const result = name === 'dependency_index' ? await dependencyIndex(client.repoPath, args)
1734
+ : name === 'change_report' ? await changeReport(client.repoPath, args.baselineVersion)
1735
+ : await renderPages(client.repoPath, args);
1736
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
1737
+ }
1738
+ case 'apply_changes':
1739
+ case 'publish_changes': {
1740
+ const config = await loadConfig();
1741
+ const { client } = await getClient(args.projectName);
1742
+ await client.requireLocal();
1743
+ const verify = async root => {
1744
+ const candidate = new OverleafGitClient(client.projectId, client.gitToken, root);
1745
+ return candidate.verifyBuild(args.filePath, args.engine || 'lualatex', {
1746
+ controlled: args.controlled === true,
1747
+ externalInputs: args.externalInputs,
1748
+ lint: args.lint,
1749
+ lintCommand: voiceLinterCommand(config),
1750
+ });
1751
+ };
1752
+ const result = name === 'apply_changes' ? await applyChanges(client.repoPath, args, verify)
1753
+ : await publishChanges(client.repoPath, args, verify, async (root, revision, branch) => {
1754
+ try {
1755
+ await client._git(['-C', root, 'push', 'origin', `${revision}:refs/heads/${branch}`], { auth: true });
1756
+ } catch (e) {
1757
+ // A rejected push means Overleaf moved since the last sync; name
1758
+ // the recovery instead of surfacing raw git plumbing.
1759
+ if (/rejected|fetch first|non-fast-forward/i.test(e.stderr || '')) {
1760
+ 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.');
1761
+ }
1762
+ throw e;
1763
+ }
1764
+ });
1765
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
1564
1766
  }
1565
-
1566
1767
  case 'verify_build': {
1768
+ const config = await loadConfig();
1567
1769
  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') }] };
1770
+ const opts = { ...args, lintCommand: voiceLinterCommand(config) };
1771
+ const v = args.clean === false
1772
+ ? await client.compileFile(args.filePath, args.engine || 'lualatex', opts)
1773
+ : 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));
1777
+ 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
+ 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
+ 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
1781
  }
1583
1782
 
1584
1783
  case 'edit_file': {
1784
+ const config = await loadConfig();
1585
1785
  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}).`;
1786
+ const res = await client.editFile(args.filePath, args.oldString, args.newString, args.replaceAll || false, args.commitMessage, { push: resolvePush(config.settings, args) });
1787
+ const tail = res.reason
1788
+ ? `No change applied to ${args.filePath} (${res.reason}).`
1789
+ : mutationTail(res, `Edited ${args.filePath}`);
1590
1790
  return { content: [{ type: 'text', text: tail }] };
1591
1791
  }
1592
1792
 
1593
1793
  case 'write_file': {
1794
+ const config = await loadConfig();
1594
1795
  const { client } = await getClient(args.projectName);
1595
1796
  const res = await client.writeFile(args.filePath, args.content, {
1596
1797
  baseSha: args.baseSha,
1597
1798
  overwrite: args.overwrite,
1598
1799
  commitMessage: args.commitMessage,
1800
+ push: resolvePush(config.settings, args),
1599
1801
  });
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}).`;
1802
+ const tail = res.reason
1803
+ ? `No change detected for ${args.filePath} (${res.reason}).`
1804
+ : mutationTail(res, `Wrote ${args.filePath}`);
1603
1805
  return { content: [{ type: 'text', text: tail }] };
1604
1806
  }
1605
1807
 
1606
1808
  case 'upload_file': {
1809
+ const config = await loadConfig();
1607
1810
  const { client } = await getClient(args.projectName);
1608
1811
  const res = await client.uploadFile({
1812
+ push: resolvePush(config.settings, args),
1609
1813
  srcPath: args.srcPath,
1610
1814
  destPath: args.destPath,
1611
1815
  files: args.files,
@@ -1613,9 +1817,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1613
1817
  overwrite: args.overwrite,
1614
1818
  commitMessage: args.commitMessage,
1615
1819
  });
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}).`;
1820
+ const tail = res.reason
1821
+ ? `No upload performed (${res.reason}).`
1822
+ : `${mutationTail(res, `Uploaded ${res.files.length} file(s): ${res.files.join(', ')}`)} Reference each figure with \\includegraphics{...} via edit_file.`;
1619
1823
  return { content: [{ type: 'text', text: tail }] };
1620
1824
  }
1621
1825
 
@@ -1628,9 +1832,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1628
1832
  }
1629
1833
 
1630
1834
  case 'add_citation': {
1835
+ const config = await loadConfig();
1631
1836
  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}).` }] };
1837
+ const res = await client.addCitation({ entry: args.entry, commitMessage: args.commitMessage, push: resolvePush(config.settings, args) });
1838
+ return { content: [{ type: 'text', text: res.reason ? `No change for "${res.key}" (${res.reason}).` : mutationTail(res, `Added citation "${res.key}" to refs.bib`) }] };
1634
1839
  }
1635
1840
 
1636
1841
  case 'cite_lint': {
@@ -1649,22 +1854,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1649
1854
  }
1650
1855
 
1651
1856
  case 'restore': {
1857
+ const config = await loadConfig();
1652
1858
  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.` }] };
1859
+ const r = await client.restore(args.label, { push: resolvePush(config.settings, args) });
1860
+ return { content: [{ type: 'text', text: `${mutationTail(r, `Restored "${r.label}" as a forward commit`)} Run verify_build to confirm.` }] };
1655
1861
  }
1656
1862
 
1657
1863
  case 'voice_lint': {
1658
1864
  const config = await loadConfig();
1659
1865
  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 });
1866
+ const r = await client.voiceLint(args.filePath, { command: voiceLinterCommand(config) });
1668
1867
  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
1868
  }
1670
1869
 
@@ -1693,14 +1892,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1693
1892
  throw new Error(`Unknown tool: ${name}`);
1694
1893
  }
1695
1894
  } 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
- };
1895
+ return toolError(error);
1702
1896
  }
1703
- });
1897
+ })());
1704
1898
 
1705
1899
  async function main() {
1706
1900
  const transport = new StdioServerTransport();