Skip to content

Commit 94b3047

Browse files
committed
feat: 3 new tools + remote compile + bug fixes (26 tools, 173 tests)
Completeness: - latex_flatten — inline \input/\include/\subfile into one .tex (arXiv/export). - overleaf_diff — unified diff of the working copy vs HEAD or between refs. - latex_compile_plan — dry-run the compile routing decision. - latex_compile now falls back to remote compile (CLSI, else the experimental Overleaf session) when no local TeX engine is installed. Polish (adversarial pass): - refcheck now handles starred (\ref*) and cleveref (\cref/\crefrange/...) forms. - redact: scrub secrets embedded in string log args (URL userinfo, token/cookie query params) — defence in depth so a token can never leak via a raw log string. - dedup engine-selection (capabilities reuses the compiler's selectEngine) and the git clone path; +71 tests.
1 parent ce1f173 commit 94b3047

29 files changed

Lines changed: 1725 additions & 72 deletions

README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,17 @@ Other Overleaf MCP servers stop at one trick — read-only Git, or local-only co
7575
| Create a **new** project from generated LaTeX (no auth) |||| copy-paste |
7676
| Works for **free-tier** Overleaf users | ✅ (experimental) || local only | ✅ (you) |
7777
| Self-host free · Open-source (MIT) || varies | varies | n/a |
78-
| 23 agent-first tools, one `npx` || partial | partial | n/a |
78+
| 26 agent-first tools, one `npx` || partial | partial | n/a |
7979

8080
## What you can do
8181

8282
- **Ship a paper without leaving the chat.** *"Pull my thesis, fix the undefined references in chapter 3, recompile, and push."* — vibeTeX pulls over the Git bridge, edits the files, runs `latexmk`, and pushes the commit back to Overleaf.
8383
- **Compile and read the log.** `latex_compile` builds the PDF; `latex_log` returns the **parsed** errors and warnings (file, line, message) so your AI can actually fix the build — not guess.
8484
- **Catch problems before submission.** `latex_check_refs` finds undefined `\ref`/`\cite` and duplicate labels; `latex_lint` flags common LaTeX smells; `latex_outline` gives the document structure; `latex_word_count` counts real words (not markup).
8585
- **Draft, then hand off.** Generate fresh LaTeX and `overleaf_create_project` opens it as a **new** Overleaf project via the official "Open in Overleaf" flow — no token required.
86-
- **Inspect history and diffs.** `overleaf_history` and `overleaf_status` show what changed, so a review loop stays grounded in the real repository.
87-
- **Built for agents.** 23 consolidated, well-described `overleaf_*` / `latex_*` tools with structured output, namespaced and discoverable via `overleaf_whoami` and `latex_search_tools`.
86+
- **Inspect history and diffs.** `overleaf_history`, `overleaf_status`, and `overleaf_diff` show exactly what changed, so a review loop stays grounded in the real repository.
87+
- **Flatten for submission.** `latex_flatten` inlines every `\input`/`\include`/`\subfile` into one self-contained `.tex` — ready for arXiv or a single-file export.
88+
- **Built for agents.** 26 consolidated, well-described `overleaf_*` / `latex_*` tools with structured output, namespaced and discoverable via `overleaf_whoami` and `latex_search_tools`.
8889

8990
## The four capability tiers
9091

@@ -129,15 +130,15 @@ Hosting a shared/remote instance (OAuth, rate limits, metrics)? See [`.env.examp
129130

130131
## Tools reference
131132

132-
23 tools, namespaced `overleaf_*` (project + sync) and `latex_*` (compile + quality). Discover them at runtime with `latex_search_tools`.
133+
26 tools, namespaced `overleaf_*` (project + sync) and `latex_*` (compile + quality). Discover them at runtime with `latex_search_tools`.
133134

134135
**Discovery**`overleaf_whoami` · `latex_search_tools`
135136

136-
**Project sync (Git bridge / session)**`overleaf_list_projects` · `overleaf_pull` · `overleaf_status` · `overleaf_push` · `overleaf_history` · `overleaf_list_files` · `overleaf_read_file` · `overleaf_write_file` · `overleaf_edit_file` · `overleaf_delete_file` · `overleaf_create_project` · `overleaf_download_zip`
137+
**Project sync (Git bridge / session)**`overleaf_list_projects` · `overleaf_pull` · `overleaf_status` · `overleaf_push` · `overleaf_diff` · `overleaf_history` · `overleaf_list_files` · `overleaf_read_file` · `overleaf_write_file` · `overleaf_edit_file` · `overleaf_delete_file` · `overleaf_create_project` · `overleaf_download_zip`
137138

138-
**Compile**`latex_compile` · `latex_log` · `latex_get_pdf`
139+
**Compile (local / CLSI / session)**`latex_compile` · `latex_compile_plan` · `latex_log` · `latex_get_pdf`
139140

140-
**Quality**`latex_outline` · `latex_check_refs` · `latex_lint` · `latex_format` · `latex_word_count` · `latex_bib`
141+
**Quality**`latex_outline` · `latex_check_refs` · `latex_lint` · `latex_format` · `latex_word_count` · `latex_bib` · `latex_flatten`
141142

142143
## Documentation
143144

src/api/git-bridge.ts

Lines changed: 114 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,40 @@ export interface PushResult {
6868
committed: string[];
6969
}
7070

71+
/** Options for {@link GitBridgeClient.diff}. */
72+
export interface DiffOptions {
73+
/** Base ref (commit/branch/tag). With `to` set, diffs `from..to`; alone, diffs `from` vs worktree. */
74+
from?: string;
75+
/** Target ref. Requires `from`; diffs `from..to`. */
76+
to?: string;
77+
/** Scope the diff to a single project-relative path (file or directory). */
78+
path?: string;
79+
}
80+
81+
/** Per-file change summary in a {@link DiffResult}. */
82+
export interface DiffFile {
83+
path: string;
84+
/** Lines added (insertions) in this file; undefined for binary files. */
85+
added?: number;
86+
/** Lines removed (deletions) in this file; undefined for binary files. */
87+
removed?: number;
88+
/** True when git reports the file as binary (no line-level diff). */
89+
binary: boolean;
90+
}
91+
92+
/** Result of a `git diff` over the working copy. */
93+
export interface DiffResult {
94+
/** The unified-diff text (empty when there are no changes). */
95+
patch: string;
96+
/** Per-file insertion/deletion summary. */
97+
files: DiffFile[];
98+
/** Total insertions / deletions across all files. */
99+
added: number;
100+
removed: number;
101+
/** Human-readable description of what was compared (e.g. "working copy vs HEAD"). */
102+
range: string;
103+
}
104+
71105
/** A slice of file content read from the working copy. */
72106
export interface FileContent {
73107
path: string;
@@ -273,33 +307,35 @@ export class GitBridgeClient {
273307
}
274308
}
275309

310+
/**
311+
* Clone `project` into its workdir. LOCK-FREE: callers must already hold the per-project lock
312+
* (re-acquiring our Semaphore(1) would self-deadlock). Shared by ensureClone + pull.
313+
*/
314+
private async cloneInto(project: ResolvedProject): Promise<PullResult> {
315+
await mkdir(dirname(project.workdir), { recursive: true });
316+
// `git clone <url> <dir>` creates <dir> exactly.
317+
await this.git(['clone', project.gitUrl, project.workdir], { authed: true });
318+
const head = await this.headHash(project.workdir);
319+
this.logger.info(`Cloned Overleaf project ${project.id}${project.workdir} @ ${head}`);
320+
return { cloned: true, head, changed: [], workdir: project.workdir };
321+
}
322+
276323
/** Clone the project if absent, else leave the working copy untouched. Returns its workdir/head. */
277324
ensureClone(project: ResolvedProject): Promise<PullResult> {
278325
return this.lockFor(project.workdir).run(async () => {
279326
if (this.isCloned(project.workdir)) {
280327
const head = await this.headHash(project.workdir);
281328
return { cloned: false, head, changed: [], workdir: project.workdir };
282329
}
283-
await mkdir(dirname(project.workdir), { recursive: true });
284-
// Clone into the exact workdir. `git clone <url> <dir>` creates <dir>.
285-
await this.git(['clone', project.gitUrl, project.workdir], { authed: true });
286-
const head = await this.headHash(project.workdir);
287-
this.logger.info(`Cloned Overleaf project ${project.id}${project.workdir} @ ${head}`);
288-
return { cloned: true, head, changed: [], workdir: project.workdir };
330+
return this.cloneInto(project);
289331
});
290332
}
291333

292334
/** Fetch + fast-forward the working copy from Overleaf. */
293335
pull(project: ResolvedProject): Promise<PullResult> {
294336
return this.lockFor(project.workdir).run(async () => {
295337
if (!this.isCloned(project.workdir)) {
296-
// Defer to ensureClone's own locking-free internals: replicate inline to avoid a
297-
// nested lock acquisition (Semaphore(1) would deadlock on re-entry).
298-
await mkdir(dirname(project.workdir), { recursive: true });
299-
await this.git(['clone', project.gitUrl, project.workdir], { authed: true });
300-
const head = await this.headHash(project.workdir);
301-
this.logger.info(`Cloned Overleaf project ${project.id}${project.workdir} @ ${head}`);
302-
return { cloned: true, head, changed: [], workdir: project.workdir };
338+
return this.cloneInto(project);
303339
}
304340
const before = await this.headHash(project.workdir);
305341
await this.git(['pull', '--ff-only'], { cwd: project.workdir, authed: true });
@@ -385,6 +421,71 @@ export class GitBridgeClient {
385421
});
386422
}
387423

424+
/**
425+
* `git diff` over the working copy. By default diffs the working copy (including staged +
426+
* unstaged changes) against HEAD — i.e. exactly what overleaf_push would commit. Pass
427+
* `opts.from`/`opts.to` to diff between two refs (`from..to`), or `opts.from` alone to diff a
428+
* ref against the current worktree. Scope to one file/dir with `opts.path`. Returns the unified
429+
* patch plus a per-file insertion/deletion summary. Read-only; runs on the local clone only
430+
* (no token, no network), so it is safe to call freely.
431+
*/
432+
diff(project: ResolvedProject, opts: DiffOptions = {}): Promise<DiffResult> {
433+
return this.lockFor(project.workdir).run(async () => {
434+
if (!this.isCloned(project.workdir)) {
435+
throw new OverleafError({
436+
status: 400,
437+
message:
438+
'Cannot diff: project not pulled yet. Run overleaf_pull first to populate the working copy.',
439+
});
440+
}
441+
// Build the revision selector. `from..to` for two refs; `from` alone diffs that ref vs the
442+
// worktree; nothing → working copy (HEAD + index + worktree) vs HEAD. `--` separates a
443+
// path from refs so a filename can never be mistaken for a ref.
444+
const refArgs: string[] = [];
445+
let range: string;
446+
if (opts.from && opts.to) {
447+
refArgs.push(`${opts.from}..${opts.to}`);
448+
range = `${opts.from}..${opts.to}`;
449+
} else if (opts.from) {
450+
refArgs.push(opts.from);
451+
range = `${opts.from} vs working copy`;
452+
} else {
453+
// Compare HEAD against the working tree, INCLUDING untracked-but-staged changes. We add
454+
// -A-staged content into the diff by diffing HEAD (covers both staged and unstaged).
455+
refArgs.push('HEAD');
456+
range = 'working copy vs HEAD';
457+
}
458+
const pathArgs = opts.path ? ['--', opts.path] : [];
459+
460+
const { stdout: patch } = await this.git(['diff', ...refArgs, ...pathArgs], {
461+
cwd: project.workdir,
462+
});
463+
const { stdout: numstat } = await this.git(['diff', '--numstat', ...refArgs, ...pathArgs], {
464+
cwd: project.workdir,
465+
});
466+
467+
const files: DiffFile[] = [];
468+
let added = 0;
469+
let removed = 0;
470+
for (const line of numstat.split('\n')) {
471+
const trimmed = line.trim();
472+
if (!trimmed) continue;
473+
// numstat: `<added>\t<removed>\t<path>`; `-`/`-` marks a binary file.
474+
const [a = '', r = '', ...rest] = trimmed.split('\t');
475+
const path = rest.join('\t');
476+
if (!path) continue;
477+
const binary = a === '-' || r === '-';
478+
const addedN = binary ? undefined : Number(a) || 0;
479+
const removedN = binary ? undefined : Number(r) || 0;
480+
if (addedN) added += addedN;
481+
if (removedN) removed += removedN;
482+
files.push({ path, added: addedN, removed: removedN, binary });
483+
}
484+
485+
return { patch, files, added, removed, range };
486+
});
487+
}
488+
388489
/** Recent commit log (newest first), capped at `limit`. */
389490
log(project: ResolvedProject, limit?: number): Promise<CommitInfo[]> {
390491
return this.lockFor(project.workdir).run(async () => {

src/api/session-client.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,23 @@ export class SessionClient {
216216
};
217217
}
218218

219+
/**
220+
* Download one compile output artifact (the produced PDF or log) as raw bytes, authenticated
221+
* with the session cookie. Exposed (non-breaking) so the CompileService can persist remote
222+
* artifacts to disk and return a path rather than streaming bytes into chat. Best-effort —
223+
* part of the experimental, unofficial session tier.
224+
*/
225+
async fetchOutput(url: string): Promise<Buffer> {
226+
this.assertEnabled();
227+
const res = await this.deps.fetcher.fetch(url, {
228+
method: 'GET',
229+
headers: { cookie: this.cookieHeader() },
230+
redirect: 'manual',
231+
});
232+
if (!res.ok) await this.raise(res, `fetch compile output ${url}`);
233+
return Buffer.from(await res.arrayBuffer());
234+
}
235+
219236
/** Throw a clear error if the experimental session tier is not enabled / configured. */
220237
private assertEnabled(): void {
221238
if (!this.deps.config.experimentalSession || !this.deps.config.sessionCookie) {

src/auth/consent.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ export interface ConsentPageOptions {
3636
}
3737

3838
/** Minimal self-contained HTML consent/passcode page; posts to ./consent. */
39-
export function renderConsentPage({ authId, clientName, redirectHost, error }: ConsentPageOptions): string {
39+
export function renderConsentPage({
40+
authId,
41+
clientName,
42+
redirectHost,
43+
error,
44+
}: ConsentPageOptions): string {
4045
const name = esc(sanitizeDisplay(clientName));
4146
return `<!doctype html>
4247
<html lang="en"><head><meta charset="utf-8" />

src/auth/provider.ts

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto';
22
import type { Response } from 'express';
3-
import type { OAuthServerProvider, AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js';
3+
import type {
4+
OAuthServerProvider,
5+
AuthorizationParams,
6+
} from '@modelcontextprotocol/sdk/server/auth/provider.js';
47
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
58
import { redirectUriMatches } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize.js';
69
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
@@ -9,7 +12,10 @@ import type {
912
OAuthTokens,
1013
OAuthTokenRevocationRequest,
1114
} from '@modelcontextprotocol/sdk/shared/auth.js';
12-
import { InvalidGrantError, InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
15+
import {
16+
InvalidGrantError,
17+
InvalidTokenError,
18+
} from '@modelcontextprotocol/sdk/server/auth/errors.js';
1319
import { renderConsentPage } from './consent.js';
1420
import { MemoryStore, type OAuthStore, type StoredAccess, type StoredRefresh } from './store.js';
1521

@@ -79,7 +85,8 @@ export class VibetexOAuthProvider implements OAuthServerProvider {
7985

8086
constructor(private readonly opts: VibetexOAuthProviderOptions) {
8187
this.store = opts.store ?? new MemoryStore();
82-
if (opts.mode === 'passcode' && !opts.passcode) throw new Error('passcode mode requires a passcode');
88+
if (opts.mode === 'passcode' && !opts.passcode)
89+
throw new Error('passcode mode requires a passcode');
8390
}
8491

8592
readonly clientsStore: OAuthRegisteredClientsStore = {
@@ -99,7 +106,11 @@ export class VibetexOAuthProvider implements OAuthServerProvider {
99106
},
100107
};
101108

102-
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
109+
async authorize(
110+
client: OAuthClientInformationFull,
111+
params: AuthorizationParams,
112+
res: Response,
113+
): Promise<void> {
103114
this.sweep();
104115
const authId = randomUUID();
105116
this.pending.set(authId, {
@@ -126,18 +137,39 @@ export class VibetexOAuthProvider implements OAuthServerProvider {
126137
const pc = this.pending.get(authId);
127138
if (!pc || pc.expiresAt < Date.now()) {
128139
this.pending.delete(authId);
129-
this.sendConsent(res, 400, authId, 'this client', '', 'Session expired — please reconnect from the client.');
140+
this.sendConsent(
141+
res,
142+
400,
143+
authId,
144+
'this client',
145+
'',
146+
'Session expired — please reconnect from the client.',
147+
);
130148
return;
131149
}
132150
if (!timingSafeEqualStr(passcode, this.opts.passcode ?? '')) {
133151
pc.attempts += 1;
134152
this.opts.onEvent?.('auth_failed');
135153
if (pc.attempts >= MAX_CONSENT_ATTEMPTS) {
136154
this.pending.delete(authId);
137-
this.sendConsent(res, 429, authId, pc.clientName, hostOf(pc.redirectUri), 'Too many attempts — please reconnect from the client.');
155+
this.sendConsent(
156+
res,
157+
429,
158+
authId,
159+
pc.clientName,
160+
hostOf(pc.redirectUri),
161+
'Too many attempts — please reconnect from the client.',
162+
);
138163
return;
139164
}
140-
this.sendConsent(res, 401, authId, pc.clientName, hostOf(pc.redirectUri), 'Incorrect passcode. Please try again.');
165+
this.sendConsent(
166+
res,
167+
401,
168+
authId,
169+
pc.clientName,
170+
hostOf(pc.redirectUri),
171+
'Incorrect passcode. Please try again.',
172+
);
141173
return;
142174
}
143175
this.pending.delete(authId);
@@ -166,7 +198,10 @@ export class VibetexOAuthProvider implements OAuthServerProvider {
166198
res.redirect(302, target.href);
167199
}
168200

169-
async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
201+
async challengeForAuthorizationCode(
202+
client: OAuthClientInformationFull,
203+
authorizationCode: string,
204+
): Promise<string> {
170205
const c = this.codes.get(authorizationCode);
171206
if (!c || c.clientId !== client.client_id || c.expiresAt < Date.now()) {
172207
throw new InvalidGrantError('Invalid or expired authorization code');
@@ -224,7 +259,10 @@ export class VibetexOAuthProvider implements OAuthServerProvider {
224259
};
225260
}
226261

227-
async revokeToken(_client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void> {
262+
async revokeToken(
263+
_client: OAuthClientInformationFull,
264+
request: OAuthTokenRevocationRequest,
265+
): Promise<void> {
228266
this.store.deleteAccess(request.token);
229267
this.store.deleteRefresh(request.token);
230268
void this.store.flush();
@@ -268,7 +306,9 @@ export class VibetexOAuthProvider implements OAuthServerProvider {
268306
): void {
269307
res.setHeader('Content-Type', 'text/html; charset=utf-8');
270308
res.setHeader('Cache-Control', 'no-store');
271-
res.status(status).send(renderConsentPage({ authId, clientName, redirectHost: hostOf(redirectUri), error }));
309+
res
310+
.status(status)
311+
.send(renderConsentPage({ authId, clientName, redirectHost: hostOf(redirectUri), error }));
272312
}
273313

274314
/** Drop expired pending consents and codes; sweep expired tokens in the store. */

0 commit comments

Comments
 (0)