Roadmap¶
The planned path from 0.1.3 to 1.0.0. The stages through v0.4.0, and the compile and
multi-file stages (v0.6.0 and v0.9.0), come from a real end-to-end session against
overleaf-web-mcp@0.1.2 (authenticating, listing 127 projects, inspecting a tree, overwriting
three text documents, uploading a figure, deleting 20 stale entities one at a time, compiling, and
round-tripping files to verify uploads), cross-checked against Overleaf's open-source web service
(overleaf/overleaf, services/web/app/src/router.mjs and its controllers), so every endpoint they
name is confirmed to exist rather than assumed. The shared-core, CLI, and tracked-change review
stages (v0.5.0, v0.7.0, v0.8.0) come from a review of this repository's code against the same
upstream source; any endpoint, payload, status, or OT form not yet confirmed there is marked
(verify in code), and confirming it is that stage's first task. MCP stays supported but stops
being the only interface: through one engine, the operations are reachable from a TypeScript
library from v0.5.0 and from a command line from v0.7.0 (see Interfaces beyond MCP).
How to use this file (for coding agents and people). Work one stage at a time, top to bottom. Each stage has a motivation, the design, a Tasks checklist, and Acceptance criteria that must be mechanically checkable before the stage counts as done. Before starting any stage, run the Stage 0 verification below; the code moves on between releases and some items are marked (verify in code). Before starting v0.5.0 or anything after it, read Interfaces beyond MCP: it fixes where new code goes and which behaviour may not differ between the MCP server, the CLI, and the library. A stage that tightens a safety default announces it one minor version ahead and enforces it in the next: v0.5.0 announces, v0.6.0 enforces. Update this file as tasks land; do not leave it stale.
Each stage assumes the previous one shipped. Tool names follow the existing snake_case convention; from v0.7.0, CLI commands and SDK methods are derived from them.
Conventions¶
- Tool names are snake_case
verb_noun(list_projects,manage_entity). Parameters are camelCase. From v0.7.0 a tool name is also a CLI and SDK name (call read_file,client.readFile). - From v0.5.0 every operation is defined once, in
src/contracts/: input schema, output schema, and effects. The MCP server, the CLI, and the library are adapters oversrc/service/; an adapter parses, renders, and maps errors, and never shapes arguments, applies policy, or retries. - Every tool ships with MCP tool annotations (
readOnlyHint,destructiveHint,idempotentHint), declared next to its effects in the contract entry from v0.5.0 on, and checked against them.readOnlyHintmeans the call changes nothing on Overleaf and writes nothing locally. From v0.2.0 on, every new tool that returns structured data also declares anoutputSchemaand returnsstructuredContent, mirrored as a JSON text block for older clients; v0.7.0 back-fills the rest, because the CLI and SDK return the same validated objects. - Bulk and sync tools are compositions of existing primitives (
upload_file,manage_entity,read_file/write_file), never parallel reimplementations. - Destructive actions use confirm-by-value (
confirmPath,confirmName,confirmCount,confirmDeleteCount, and from v0.5.0overwritefor a replacement), and fail withCONFIRMATION_MISMATCHwhen the value is wrong. Arevisionor aplanTokencounts as expected-state confirmation. A mutation that applies with no expected state at all must be requested by a named parameter (unplanned,uncheckedDocumentReplace), never by omission. MCP elicitation is an optional second layer when the client supports it; confirm-by-value stays mandatory as the fallback, and no CLI flag supplies a confirm value on the caller's behalf. - Failures are typed error codes on
McpError, never raw exceptions, through every interface. The class keeps its name; a neutral alias may be exported fromoverleaf-web-mcp/core. New codes are introduced per stage, listed inERROR_CODESfrom v0.5.0, and collected in the v1.0.0 taxonomy table. Errordetailsare allowlisted: status codes, short identifiers, counts, and values the caller supplied, never response bodies or upstream free text. - Ambiguous writes are never retried automatically, by any interface. Bounded backoff is only for clearly transient transport failures on reads.
- A tightened safety default is announced one minor version ahead (a CHANGELOG
Deprecatedentry naming the enforcing version, and adeprecationsentry in the affected result), then enforced in the next minor underChanged. New interfaces start strict, with one exception:overleaf-web-mcp/corein v0.5.0 exposes the same service as the MCP server during the announce minor, and v0.7.0 documents which parts of it are stable.
Stages¶
| Stage | Theme | New tools | Effort | The thing it fixes |
|---|---|---|---|---|
| v0.1.3 | Documentation, metadata, small additive fixes | 0 | shipped | Behaviour that had to be reverse-engineered |
| v0.2.0 | Project lifecycle | 5 | shipped | No way to create, rename, trash, or configure a project |
| v0.3.0 | Session keepalive | 0 | shipped | A saved session that dies after five idle days and presents as a dead server |
| v0.4.0 | Bulk and sync | 3, then 2 | shipped | 24 one-at-a-time calls to sync one folder |
| v0.5.0 | Shared core and safety | 0 | shipped | Operations reachable only through MCP; safety that depends on the caller picking the safe option |
| v0.6.0 | Compile and build ergonomics | 2 | shipped | Success inferred from counters; no PDF or log access. Also enforces the safety defaults v0.5.0 announced |
| v0.7.0 | CLI, SDK, and Skills | 0 | L | Every Overleaf operation needs an MCP client |
| v0.8.0 | Tracked-change review: accept and reject | 2 | L | Tracked changes can be written but not listed, accepted, or rejected |
| v0.9.0 | Multi-file documents | 1 | M | Section tools stop at \input boundaries |
| v1.0.0 | Hardening and compatibility | 0 | M | Failure modes that are not yet legible; no way to check an install before relying on it |
Effort is a relative size (S, M, L) of the work still open in a stage, not a date.
v0.1.3 shipped on 1 September 2026, followed the same day by v0.1.4, a documentation release: a human-first README, the documentation site at https://mhmdaskari.github.io/overleaf-web-mcp/, and usage instructions sent to MCP clients at connect time. v0.2.0 shipped on 10 September 2026 with the five project lifecycle tools, filtered list_projects, and the RATE_LIMITED and CONFIRMATION_MISMATCH error codes, followed the same day by v0.2.1, a documentation patch. v0.3.0 shipped on 14 September 2026 with the keepalive command, sessionExpiresAt on auth_status, and a cookie-jar fix that persists the session deadline, followed on 22 September by v0.3.1 (proxy support) and v0.3.2 (documentation). v0.4.0 shipped on 27 September 2026 with plan_sync, sync_directory, and delete_entities, ignore rules, progress notifications, and the REMOTE_DRIFT and PATH_OUTSIDE_ROOT error codes. v0.4.1 shipped on 6 October 2026, the stage's point release, with batch_upload and download_project_zip, the move to the v2 MCP SDK, and the documentation corrections planned for v0.5.0. v0.5.0 shipped on 10 October 2026 with the operation registry under src/contracts/, createOverleafService, the overleaf-web-mcp/core export, the access policy and POLICY_DENIED, path-safe ids, sanitized error details, and the announced plan and replacement defaults that v0.6.0 enforces, followed on 12 October by v0.5.1, the MCP Registry listing. v0.6.0 shipped on 19 October 2026 with a parsed compile summary and builtCleanly, buildRef, get_compile_log, download_compile_output, the compile output resources, five compile error codes, and the plan and replacement defaults enforced. See the changelog for what landed.
The MCP SDK (v0.4.1): the server moved from @modelcontextprotocol/sdk 1.29 to @modelcontextprotocol/server 2.3, the v2 SDK, and serve answers both protocol eras over stdio. Protocol 2026-07-28 removes the initialize handshake (each request carries its protocol version and client capabilities in _meta) and adds server/discover, which servers must implement and clients may call, and whose result carries the server instructions; clients on the 2025-era versions still receive them in the initialize result. A 2026-07-28 client that never calls server/discover never sees the instructions, so tool descriptions must stay self-sufficient. The existing tools' names, inputs, and result shapes did not change.
After v1.0.0 the server would register 34 tools (31 today, after the two the v0.4.1 point release added, batch_upload and download_project_zip, and the two v0.6.0 added, download_compile_output and get_compile_log): two in v0.8.0 (list_tracked_changes, manage_tracked_changes), and one in v0.9.0 (get_full_document). Every tool description costs the MCP client context on every turn, so the lifecycle and tracked-change review stages deliberately reuse the manage_entity action-enum pattern instead of adding one tool per verb, and checks that need no agent (doctor, capabilities) are CLI commands, not tools.
What already works well (keep these patterns)¶
manage_entity'sconfirmPath === pathrequirement on delete. It caught nothing dangerous in the session, but it is the right shape for a destructive action, and later stages reuse it (confirmNameon project trash/delete, a delete-count confirmation on mirror sync).upload_fileoverwrites by path. This is what made replacingmain.texwholesale possible without aread_file→ revision →write_fileround trip, and a text document replaced this way keeps itsentity_id. A replaced binary may not: Overleaf's source (ProjectEntityMongoUpdateHandler.replaceFileWithNew) gives it a new id, as it does a document replaced by a binary or the reverse, so callers look ids up again after a replacement (read in the source while building 0.4.1, not yet confirmed live). Since v0.6.0 (announced in v0.5.0) a replacement must be asked for (overwrite, oruncheckedDocumentReplacefor a document).get_sectionsis honest about its own limits. Its description states outright that it never follows\input/\include. Keep this practice when multi-file support lands in v0.9.0.write_filerequiring arevisionfrom a priorread_fileis the right default against blind clobbers of text a human might be editing concurrently.- the error model already exists.
McpErrorcarries a typedcode, aretryableflag, and structureddetails. v1.0.0 should extend this, not replace it. The CLI and SDK (v0.7.0) report failures with the same class and codes. - writes are never retried automatically. The README documents that a timed-out write is observed, never re-submitted. Every bulk tool below must inherit that invariant, and the CLI and SDK add no retry of their own.
- all project mutations run through one per-project FIFO. Bulk tools are compositions over that queue; they reduce tool calls and context, not wall-clock time. Say so in their descriptions. The queue is per process: two CLI processes working on one project are not serialized against each other, and revision checks and plan tokens are what protect them.
- the adapter sits on one runtime interface.
src/mcp/tools.tsreached Overleaf only through theOverleafToolRuntimeinterface it declared; outcome classification lives below it, and the adapter has no retry. v0.5.0 moved that interface out of the MCP folder, asOverleafServiceRuntimeinsrc/contracts/service.ts, and the argument shaping intocreateOverleafService, rather than adding a layer. - tokens are self-contained. Revision tokens (
createRevisioninsrc/core/revision.ts) and plan tokens carry everything a later call needs, so read-then-write and plan-then-apply work across processes with no daemon or state file; v0.6.0'sbuildReffollows suit.
Interfaces beyond MCP¶
Decision: keep MCP, with one engine behind several interfaces. What this project offers is Overleaf operations that stay correct under concurrent editing: revision-checked writes verified against a fresh join, tracked writes that never fall back, timed-out writes observed and never replayed, and syncs that stop when either side drifted from the plan. None of that depends on MCP; access does. parseCliCommand (src/cli-command.ts) accepts only serve, login, keepalive, and help, and the package's one entry loads the MCP SDK and exposes the engine only as the undocumented OverleafRuntime, whose domain classes skip the tool handlers' argument shaping. A CI step or a shell-driven coding agent needs an MCP client to reach anything, and a TypeScript program has no supported API. MCP itself is still developing (protocol 2026-07-28 changed how clients connect), so the plan adds interfaces rather than swapping one.
Two boundaries. The caller-facing one (MCP, the CLI, the library, Skills, perhaps HTTP) decides how an operation is invoked, confirmed, and rendered, and is replaceable. The Overleaf-facing one, private REST routes and Socket.IO 0.9 OT through a saved web session (the private API catalogue), carries the maintenance risk, which only boundary validation, sanitized fixtures, doctor (v1.0.0), and a future official-API backend reduce.
Architecture. Each layer calls only the one below it.
- Adapters.
src/mcp/;src/cli/(v0.7.0);src/sdk.ts, published asoverleaf-web-mcp/corefrom v0.5.0 with a client facade from v0.7.0; Skills (v0.7.0), which call only the public CLI; HTTP only if a trigger below fires. - Contracts and service (v0.5.0).
src/contracts/holds operation schemas, effects, error codes, and capability types.src/service/holds one function per operation with its argument shaping, and checks effects against the access policy insrc/core/policy.tsbefore any work starts. - Domain engine.
src/overleaf/,src/protocol/,src/http/,src/auth/. The domain APIs check every effect they perform against the same policy, so a direct call through the exported runtime is covered. Plan and apply stay inSyncApi(src/overleaf/sync.ts), with the per-project queue and every verification rule. - Backends. One today, the web session. An official-API or Git backend would advertise its own capabilities (v1.0.0) and never be substituted silently; a Git backend cannot make tracked changes or revision-checked live edits, and Overleaf's Git integration is paid.
Rules the shared layer keeps for every interface.
- Operation ids are the tool names; schemas are versioned with the package.
- Effects come from one enum:
overleaf-read,overleaf-write,overleaf-delete,project-lifecycle,compile,local-read,local-write,unchecked-replace. Comment writes and, from v0.8.0, tracked-change decisions areoverleaf-write. - Outcomes use the existing
details.outcomevaluesnot_appliedandunknown; a write that applied resolves, withrecoveredAfterTimeoutwhen observed after a timeout. New failure kinds go indetails.kind. - Progress, cancellation, and diagnostics go through a neutral
OperationContext { onProgress?, signal?, onDiagnostic? }; cancelling never aborts a submitted write. - Large outputs (logs, PDFs, zips) go to caller-named local paths under the local-write policy; a remote service would return scoped artifact references.
- One policy path for every public mutation, exported runtime methods included.
- No promise of cross-process serialization; revision checks and plan tokens protect separate processes.
- No code-execution surface: nothing runs caller-supplied code with access to the saved session.
Packaging. One package. overleaf-web-mcp/core is a subpath export from v0.5.0; the root stays the MCP entry with every current export, so no ./mcp subpath is needed. Split packages only on demand. The binary stays overleaf-web-mcp; a neutral alias is a later, separate decision, and no name for it has been checked.
What would change the plan. Conditions, not stages.
| If | Then | Not before |
|---|---|---|
| MCP stays in wide use by the clients served | Keep the adapter first-class; follow protocol revisions and the v1.0.0 compatibility matrix | now |
| Coding agents favour shell commands and Skills | Make the CLI their first install path; keep MCP | v0.7.0 |
| Applications call the library directly | Freeze its contract under SemVer; consider a separate core package |
v0.7.0 |
| A remote integration needs HTTP | A scoped HTTP service with an OpenAPI description generated from src/contracts/, its own authentication, artifact references, and the session never exposed |
a named consumer |
| Browser-native tool interfaces (WebMCP) become practical on Overleaf's pages | An adapter experiment outside the stages | its status re-checked |
| Another tool protocol gains adoption | A thin adapter over src/service/ |
a named client |
| Overleaf changes a private endpoint | Repair the web-session backend, its validation, and fixtures | any time |
| An official Overleaf API covers these operations | A new backend; the web session stays until it covers revision checks and tracked edits | it exists |
| Someone needs Git-based sync | An optional backend with its own capabilities, chosen explicitly | a recorded need |
| The product becomes an agent other agents delegate to | Consider an agent-to-agent (A2A) interface | not planned |
How the change is judged. v0.7.0 adds an opt-in comparison on disposable projects that runs four tasks through MCP, the CLI, and the library, and records per task:
- revise one section: success, bytes an agent reads, round trips, conflict outcome;
- sync figures and sources: skips, upstream requests and bytes, drift, partial failure;
- diagnose a failed build: errors located by file and line, output size, artifacts, unneeded recompiles;
- resolve a review task: anchoring, action scope, accidental mutations (expected zero); tracked changes join in v0.8.0.
Start-up and Overleaf time are reported separately, cold and warm, never asserted; when an agent runs the tasks, model latency is reported apart from both. Through every interface, tests keep proving that a stale revision fails without overwriting, a timed-out write is never replayed, a tracked write never falls back, a required plan is enforced in the service, preflight drift changes nothing, a mid-sync failure reports what completed, a failed upload prevents every delete, one policy refuses the same call everywhere, local-root checks cover every file operation, and logs never contain cookies, document content, review message bodies, or raw upstream responses.
Stage 0 — Verify the baseline (do this first, every time)¶
The code moves on between releases. Confirm the following before touching anything, and record what you find:
- [ ]
npm view overleaf-web-mcp versionandgit tagagree withpackage.jsonandSERVER_VERSIONinsrc/version.ts. Current release:0.6.0. - [ ]
TOOL_NAMESinsrc/mcp/tools.tslists the 31 registered tools:auth_status,list_projects,create_project,clone_project,import_project_zip,manage_project,update_project_settings,get_project_tree,read_file,write_file,create_file,manage_entity,upload_file,batch_upload,download_file,download_project_zip,plan_sync,sync_directory,delete_entities,get_sections,get_section_content,write_section,compile_project,stop_compile,get_compile_log,download_compile_output,list_comments,reply_to_comment,add_comment,set_comment_status,monitor_project_history. The README badge anddocs/tools.mdmust agree (test/mcp/tools.test.tsenforces it). Since v0.5.0, every name is an entry in the operation registry,OPERATIONSinsrc/contracts/operations.ts, andtools/listmatchestest/__snapshots__/list-tools.json. - [ ] The MCP SDK is v2:
@modelcontextprotocol/server2.x independencies,@modelcontextprotocol/client2.x indevDependencies(tests only),npm ls @modelcontextprotocol/sdkprints(empty), andnpm ls zod @modelcontextprotocol/coreshows one copy of each. Record the protocol versions answered (when this was written, 2024-10-07 through 2025-11-25 with aninitializehandshake, each covered by the protocol-era tests, and 2026-07-28 without one) and thatdescribe('protocol eras')intest/server.test.tsstill findsSERVER_INSTRUCTIONSin theinitializeandserver/discoverresults. - [ ]
SERVER_INSTRUCTIONSstays under the 450-word bound intest/server.test.ts(444 words at 0.4.0, 445 at 0.4.1, 392 at 0.5.0, which brought it under 400 once so v0.6.0 and v0.8.0 can spend the headroom, 417 at 0.6.0); after that, a stage that adds guidance trims elsewhere. - [ ] The limits and their environment variables still hold:
OVERLEAF_MAX_DOC_LENGTH(fallback 2,097,152 UTF-16 code units →DOC_TOO_LARGE) andOVERLEAF_MAX_UPDATE_CHARS(7,340,032 serialized characters →UPDATE_TOO_LARGE). - [ ] (verify live)
upload_fileto an existing path replaces it and reportsreplaced: true. Record theentity_idbefore and after: a replaced text document keeps it, and a replaced binary may get a new one, which Overleaf's source (replaceFileWithNew) says but no live run has confirmed. With track changes on for the account, record whether replacing a document shows up as tracked changes (upsertDoc→DocumentUpdaterHandler.setDocumentpasses the uploader's setting) while the result still sayswriteMode: "untracked". Check it withoverwrite: true(oruncheckedDocumentReplace: truefor a document), which v0.6.0 requires. - [ ]
get_project_treehashequalsgit hash-object <file>for at least one binary entity. - [ ] The gated live tests (
RUN_OVERLEAF_LIVE_TESTS=1,test/live/) and the CI workflows under.github/workflows(ci.yml,publish.yml,docs.yml,download-count.yml) are as described indocs/development.md. From v0.7.0, every commandrenderHelpprints appears indocs/cli.md.
v0.1.3 — Documentation, metadata, and small additive fixes (shipped)¶
Motivation: a meaningful fraction of the session went into reverse-engineering behaviour that should have been documented, most expensively the hash format (nine false "differs" out of nine real matches from comparing against plain sha1sum). Two of the items below are one-line code changes with outsized payoff; ship them even if the documentation pass takes longer.
- Document the hash field format precisely. Comparing
get_project_tree'shashagainst plainsha1sumproduced 9 false "differs" out of 9 real matches. The actual format is a git blob hash:sha1("blob " + byteLength + "\0" + content), i.e. exactlygit hash-object <file>. This is confirmed in Overleaf'sFileHashManager.mjs. State the formula wherever ahashfield appears.
hash exists only on binary file entities (Overleaf's fileRefs). src/overleaf/tree.ts copies it for fileRefs and for nothing else, and Overleaf does not store a content hash for doc entities in the tree at all. So the hash workflow covers figures, PDFs, and other binaries; .tex, .bib, and .bst documents can only be compared by reading their content. Document this alongside the formula, because it changes the design of plan_sync in v0.4.0.
-
Document
upload_file's overwrite semantics, and fix its annotation. The current description ("Upload a local binary file into an Overleaf project folder") undersells what it does. Overleaf's upload handler (FileSystemImportManager.addEntity→upsertDoc/upsertFile) replaces an existing entity at the same path in place; otherwise it creates one. Document: -
Type is decided by Overleaf, not by the caller.
FileTypeManagerclassifies by extension list and valid UTF-8; text files larger than three times the document limit are stored as binaryfileentities. a same-named entity of the other type (uploading text where a binary already exists, or vice versa) is expected to fail with Overleaf'sduplicate_file_nameerror rather than replace; map that toINVALID_ARGUMENTwith a clear message. - Doc replacement is blind and untracked. Upserting a
docgoes through the document updater with no revision check and never as tracked changes. A collaborator's concurrent edit is overwritten. This is the trade-off againstwrite_file. - the tool is registered with
destructiveHint: false, which is wrong for an in-place overwrite. SetdestructiveHint: true. - the remote name is always
basename(localPath); there is no way to uploadfig_v3.pngasfigures/fig.png. Add an optionaldestinationName. - normalise the response. Today it passes Overleaf's raw
{ success, entity_id, entity_type }through underupload. Return{ entityId, entityType, path, replaced, hash }, wherereplacedcomes from a tree lookup before the upload andhashis the git blob hash computed locally from the bytes sent, so callers can verify againstget_project_treelater without a round trip.
Corrected in 0.4.1, after a closer read of Overleaf's source: a replacement is in place only for a text document replacing a text document; a replaced binary, or a change of type, may get a new entity and id (seen in the source, not yet live). Uploading over the other type replaces it; duplicate_file_name comes from a folder of the same name. And replacing a document is recorded as tracked changes when track changes is on for the uploading user, so "never tracked" holds only when it is off.
-
Document that
upload_fileworks for text documents, not just binaries.main.tex,ref.bib, andelsarticle-num.bst(alldocentities) were replaced successfully vialocalPath. Fold this into item 2's "type is decided by Overleaf" paragraph and keep the concrete example. -
State a practical size guideline for
write_file'scontentvsupload_file'slocalPath. The session self-imposed "115 KB is too large for a tool parameter" with no number to go on.
the server-side limits are already in the code and are far away: DOC_TOO_LARGE at the advertised ol-maxDocLength (fallback 2,097,152 UTF-16 code units) and UPDATE_TOO_LARGE at 7,340,032 serialised characters. A 115 KB file is roughly 5% of the document limit. The real ceiling is the MCP client's tool-argument budget and the token cost of echoing a whole file through the model. Document that plainly.
accept localPath as an alternative to content on write_file (mutually exclusive, same revision check, same writeMode). That gives the one combination neither tool offers today: a revision-checked, optionally tracked, whole-file replacement from disk. It is a parameter addition, not a new tool, so it fits this stage.
- Add a decision table to the README. Three rows instead of two once item 4 lands:
| Need | Tool | Revision check | Tracked changes | Content source |
|---|---|---|---|---|
| Small edit, or concurrent humans possible | write_file with content |
yes | optional | inline |
| Replace a large text file safely | write_file with localPath |
yes | optional | disk |
| Replace a binary, or push text when nobody else is editing | upload_file |
no | never | disk |
-
Include a worked example of the hash-comparison workflow (loop local files →
git hash-object→ compare toget_project_treehash→ upload only what differs), with the caveat from item 1 that it applies to binaries only.plan_syncformalises this in v0.4.0; until then, document the manual version. -
expose the project's root document now. The
joinProjectpayload the server already receives declaresrootDoc_id(seeJoinProjectDatainsrc/protocol/project-connection.ts); Overleaf also sendscompiler,imageName, andspellCheckLanguagein the same payload. The server discards all of them. SurfacerootDocPath,compiler, andimageNameinget_project_tree's result, and makecompile_project.rootFilePathoptional, defaulting to the project's root doc and failing withINVALID_ARGUMENTonly when neither is set. In the session the real manuscript lived in0_main.texwhile Overleaf's root was a 13-line stubmain.tex; one field in the tree response would have shown that on the first call. Also rewordcompile_project's description, which currently leaks the internal parameter namerootDoc_id. -
download_fileoverwrites the local path silently (writeFile(localPath, bytes)). Either document it or addoverwritedefaulting tofalse. -
repository hygiene.
publish.ymlruns check/lint/test only when a release is published; nothing runs on pushes or pull requests. Add aci.ymlthat runs the same three steps plusnpm pack --dry-run. Commit this file asROADMAP.md, start aCHANGELOG.md, and turn each numbered item here into a GitHub issue under a milestone per stage (the repository currently has zero issues, so contributors have nothing to pick up). The "19 tools" badge and sentence in the README will drift with each stage; assert the count intest/mcp/tools.test.tsor generate it.
Tasks¶
- [x] Hash format documented everywhere a
hashfield appears, with thegit hash-objectone-liner. - [x]
upload_fileoverwrite semantics,destructiveHint: true,destinationName, normalised result. - [x] Size thresholds (
DOC_TOO_LARGE,UPDATE_TOO_LARGE) and their env vars documented next towrite_file/upload_file. - [x] README decision table (
write_filevsupload_file). - [x] Worked example: manual hash-diff loop, labelled as the manual precursor to
plan_sync. - [x]
rootDocPath,compiler,imageNameinget_project_tree;compile_project.rootFilePathoptional. - [x]
download_fileoverwriteparameter. - [x]
ci.yml,CHANGELOG.md, tool-count assertion. - [x] MCP tool annotations on all existing tools.
- [x] Ecosystem section. Correct the README's positioning:
@netique/overleaf-mcpis also a web-session/OT client with tracked changes; git-bridge MCP servers require a paid Overleaf plan and bypass tracked changes entirely. (Carried into v0.2.0 and shipped there.)
Acceptance¶
- A reader using only the README predicts a file's tree
hashwithgit hash-objectand gets a byte-identical match. - The README states, without reading tool descriptions, which tool overwrites with and without a revision check.
- The README names the exact thresholds for
DOC_TOO_LARGEandUPDATE_TOO_LARGE. tools/listshows annotations on every tool.get_project_treeshows which file Overleaf compiles by default; pull requests run the test suite.
v0.2.0 — Project lifecycle (shipped)¶
Motivation: the hard blocker of the session. Every one of the 19 tools then registered took an existing projectId; there was no way to create a project through the MCP at all. A human had to create a blank project in the web UI and paste back its URL before anything else could happen. There was also no rename, no trash, no search over 127 projects, and no way to persist the root document (the real manuscript lived in 0_main.tex while Overleaf's root was the 13-line stub main.tex).
the endpoints exist and are confirmed in Overleaf's router. All are ordinary browser-facing routes protected by the same session cookie and CSRF token the server already uses. They are catalogued, with every other route the server uses, in docs/private-api.md.
| Operation | Route | Body / result |
|---|---|---|
| Create | POST /project/new |
{ projectName, template }; template: "example" seeds the example project, anything else creates the basic project with Overleaf's stub main.tex. Returns { project_id }. |
| Clone | POST /Project/:id/clone |
{ projectName } → { project_id } |
| Import zip | POST /project/new/upload |
multipart qqfile + name → { project_id }; rate-limited server-side |
| Rename | POST /project/:id/rename |
{ newProjectName } |
| Settings | POST /project/:id/settings |
any of rootDocId, compiler, imageName, spellCheckLanguage |
| Trash / restore | POST / DELETE /project/:id/trash |
recoverable, matches the web UI |
| Archive / unarchive | POST / DELETE /Project/:id/archive |
|
| Permanent delete | DELETE /Project/:id |
the web UI only offers this from the Trashed view |
| List | POST /api/project |
{ totalSize, projects: [{ name, lastUpdated, archived, trashed, accessLevel, owner_ref, … }] }; this is what the current project dashboard calls, GET /user/projects (used before) is the legacy list |
Tools¶
create_project({ name: string, template?: "blank" | "example" })
→ { projectId, name, url, rootDocPath? }
// "blank" still contains Overleaf's stub main.tex as the root. Callers who import their own
// root should follow up with update_project_settings or delete the stub with manage_entity.
// annotations: destructiveHint false, idempotentHint false
clone_project({ sourceProjectId: string, name: string })
→ { projectId, name, url }
// Starting from a lab or journal template project is the most common way real projects begin.
import_project_zip({ localZipPath: string, name?: string })
→ { projectId, name, url }
// One call from "folder on disk" to "project on Overleaf"; v0.4.0's sync covers later updates.
// Overleaf rate-limits this endpoint: HTTP 429 surfaces as RATE_LIMITED with retryAfterMs.
manage_project({ projectId: string,
action: "rename" | "trash" | "restore" | "archive" | "unarchive" | "delete",
newName?: string, confirmName?: string })
→ { action, projectId, name }
// trash, archive, and delete require confirmName to equal the current project name exactly,
// else CONFIRMATION_MISMATCH and nothing changes. delete is permanent and succeeds only when
// the project is already trashed; trash is the normal path and is reversible in the UI.
// annotations: destructiveHint true. Use elicitation when the client advertises it (v1.0.0).
update_project_settings({ projectId: string, rootFilePath?: string,
compiler?: "pdflatex" | "latex" | "xelatex" | "lualatex",
imageName?: string, spellCheckLanguage?: string })
→ { projectId, rootDocPath?, compiler?, imageName?, spellCheckLanguage? }
// Persists in the project's own settings, so the web UI's Recompile uses it too.
// rootFilePath must resolve to a doc entity, else NOT_FOUND / INVALID_ARGUMENT.
// annotations: idempotentHint true
Design decisions, and why the first draft changed:
manage_projectinstead ofrename_project+delete_project. Mirrorsmanage_entity's action enum so six verbs cost one tool description. The first draft proposeddelete_project({ mode: "trash" | "delete" })as the destructive primitive. Overleaf's own model is trash first, permanent delete only from the trash. Follow it:deletesucceeds only when the project is already trashed, and the description points callers attrashas the normal path. Trash is recoverable, which matters when the caller is an agent, and it is what makes v1.0.0's automated smoke test safe to run.update_project_settingsinstead ofset_root_document.rootFilePathis resolved through the tree and must be adoc; the change persists in the project's own settings.compilerand the TeX LiveimageNameare exactly the two settings an agent needs when a compile fails on a font or engine mismatch; they ride on the same endpoint for free.- filter
list_projectsinstead of addingsearch_projects. Switch toPOST /api/project, addquery(case-insensitive substring on name),includeArchivedandincludeTrashed(defaultfalse),limit(default 50, max 200) andsort: "lastUpdated" | "name"(defaultlastUpdated, newest first), and return{ projects, totalMatched, totalProjects }withlastUpdated,archived, andtrashedper project. Fewer tools, and it matches what@netique/overleaf-mcpandoverleaf-syncalready do (olclilists without search).auth_statuscounts projects through the same endpoint'stotalSize. CONFIRMATION_MISMATCHis a new error code for every confirm-by-value failure, includingmanage_entity'sconfirmPath, which previously returnedINVALID_ARGUMENT. The first draft'sENTITY_NOT_FOUNDandNOT_A_DOCare covered by the existingNOT_FOUNDandINVALID_ARGUMENTthatresolveProjectPathalready throws; no duplicate codes.RATE_LIMITEDwas pulled forward from v1.0.0 because zip import and project creation are the first endpoints Overleaf throttles in practice.
Tasks¶
- [x] Wrap the private endpoints for create / clone / import / rename / trash / restore / archive / unarchive / delete / settings. Record each endpoint and payload shape in
docs/private-api.mdso v1.0.0'sAPI_SHAPE_CHANGEDhas something to check against. - [x] Implement the five tools with annotations and
outputSchema+structuredContent. - [x]
query/includeArchived/includeTrashed/limit/sortonlist_projects;outputSchemaon its new result shape. - [x]
CONFIRMATION_MISMATCHandRATE_LIMITED(details.retryAfterMsfromRetry-After). - [x]
spellCheckLanguagesurfaced alongsidecompilerandimageNameinget_project_tree. - [x] Extend the gated live test: create → set root → compile → trash (never permanent delete in the automated path).
- [x] Documentation: "from nothing to a compiled PDF" walkthrough using only MCP tools; README ecosystem correction carried from v0.1.3.
Acceptance¶
- From an account with zero projects, an agent reaches a compiled project using only MCP tools, with no web-UI step, and never has to guess which file is the root.
- After
update_project_settings({ rootFilePath }), reopening the project in the web UI shows the chosen file as root and "Recompile" builds it. list_projects({ query: "Thesis" })returns only matching projects; the default call returns at most 50 and hides archived and trashed projects.manage_projectwith a wrongconfirmNamereturnsCONFIRMATION_MISMATCHand changes nothing;deleteon a project that is not trashed returnsINVALID_ARGUMENTand changes nothing.
v0.3.0 — Session keepalive (shipped)¶
Motivation: on 10 September 2026 the server failed to start in Claude Code with nothing more than "Connection closed". The saved session had expired: serve bootstraps GET /project before it answers initialize, so an expired session exits with AUTH_EXPIRED on stderr and the client reports a dead process, not an error a person can read. Checked the same day against www.overleaf.com: overleaf_session2 is issued with Max-Age=432000, five days (Overleaf's default cookieSessionLength, which is also how long the server keeps the session), and every response re-issues the cookie with a fresh five-day expiry (express-session's rolling option). The HTTP client already merges refreshed Set-Cookie headers into the jar through CookieStore.mergeSetCookies, so a session used at least once every five days never expires, and one left alone for five days is gone. Nothing client-side can lengthen that: editing the expiry in cookies.txt only keeps sending a cookie the server has already forgotten. The problem is the gap between two uses, and a scheduled request closes it.
overleaf-web-mcp keepalive
# stdout, exit 0: { "refreshed": true, "baseUrl": "...", "sessionExpiresAt": "<ISO 8601>", "userId"?: "..." }
# stderr, exit 1: the normalized McpError JSON (AUTH_EXPIRED when the session is already dead)
Design decisions:
- A CLI subcommand, not an MCP tool. A tool runs only while a client has the server open, which is exactly when the session is already being refreshed.
keepaliveruns the existing startup bootstrap (OverleafRuntime.create:GET /project, CSRF check, cookie merge), reports, and exits. It reuses themain().catcherror path insrc/cli.ts, soAUTH_EXPIREDlands on stderr as JSON and the exit code is non-zero for a scheduler to alert on. On that path Overleaf's redirect hands out an anonymous session that replaces the dead cookie in the jar; that is harmless, the old one was already rejected, but it must never be reported asrefreshed: true. sessionExpiresAtis read from the jar after the merge, never from a cookie value. Report the earliest finite expiry among the cookies the jar would send withGET /project; the session cookie isoverleaf_session2onwww.overleaf.comandoverleaf.sidon Community Edition, so do not hardcode a name. Add the same field toauth_statusso an agent can tell the user how long the session has left. That is a result-shape change:outputSchemaper the Conventions, and a CHANGELOG entry.- Scheduling belongs to the operating system. Document a daily
launchdagent (macOS),cronentry (Linux), and Task Scheduler task (Windows) indocs/configuration.md. The examples must use the absolute path to a Node 20+ binary: schedulers do not source login shells, and the maintainer's own machine has a v16 defaultnode. Any interval under five days works; daily leaves four days of slack for a laptop that was asleep. A keepalive cannot resurrect a session that has already lapsed, so the documentation says plainly that a machine that is off for more than five days still needslogin. - Concurrency with a running server is already safe.
mergeSetCookiesreloads the jar under the advisory lock before writing, so a keepalive that fires while a client has the server open cannot clobber a refresh in either direction. Say so in the docs rather than adding a mutex. - Fix the Netscape serializer while here.
cookieToNetscapeinsrc/http/cookies.tsderives the include-subdomains column from a leading dot that tough-cookie has already stripped, so it always writesFALSE.parseNetscapeCookiesignores that column and tough-cookie treats any cookie with adomainas a domain cookie, which is why the server itself works, but curl and every other Netscape reader treat the cookie as host-only and will not send it towww.overleaf.com. Derive the column fromcookie.hostOnlyinstead. Implementation found a second defect in the same function: the expires column came fromcookie.expires, which tough-cookie leaves asInfinityfor aMax-Agecookie, so the session cookie was written with expiry0and its five-day deadline was discarded on every save; the column now comes fromexpiryTime(), which folds inMax-Age, andsessionExpiresAtreads the same method. The parser deliberately keeps ignoring the include-subdomains column: every jar written before 0.3.0 recordedFALSEfor the session cookie, and honouring it would load that cookie as host-only and stop sending it towww.overleaf.com, breaking existing installs. Those jars load exactly as before and are rewritten correctly on the first refreshed response.
This stage was split out of the bulk-sync work, now v0.4.0, so the fix could ship first: it is CLI-only with no tool surface, and it removes the one failure that presents as a dead server instead of a typed error.
Tasks¶
- [x]
keepaliveinsrc/cli-command.tsandsrc/cli.ts: bootstrap throughOverleafRuntime.create, print{ refreshed, baseUrl, sessionExpiresAt, userId? }, exit 1 with the normalized error onAUTH_EXPIRED;renderHelplists it. - [x]
sessionExpiresAtonauth_status, withoutputSchema, from the earliest finite expiry among the cookies sent withGET /project. - [x]
cookieToNetscapeinclude-subdomains column fromhostOnly; round-trip test intest/http/cookies.test.ts: aSet-CookiewithDomain=.overleaf.testserializes withTRUEand loads back as a cookie thatgetCookieStringsends towww.overleaf.test. - [x] Command tests with a fake fetcher: a 200 carrying a rolled
Set-CookieprintssessionExpiresAtequal to the new expiry and the jar on disk carries it; a redirect to/loginexits 1 withAUTH_EXPIREDJSON on stderr and nothing on stdout; a 200 without the CSRF meta tag isAUTH_EXPIREDas well. - [x] Documentation: a "Keeping the session alive" section in
docs/configuration.md(the five-day rolling behaviour, scheduler examples with an absolute Node path); adocs/install.mdtroubleshooting entry that a client reporting only "Connection closed" or "server exited" at startup usually means an expired session; the README's "Sign in once" step qualified as once, plus again after any five idle days unless a keepalive is scheduled.
Acceptance¶
- With a valid session and a daily keepalive scheduled, fourteen days without any MCP use end with no
loginrequired, and each run'ssessionExpiresAtlies about five days after that run. keepaliveagainst a dead session exits 1 withAUTH_EXPIREDon stderr and prints nothing on stdout; against a live session it exits 0 andauth_statusreports the samesessionExpiresAt.curl -b <jar> https://www.overleaf.com/projectreturns 200 with a jar written by this release, where a jar written by 0.2.1 redirects to/login.
v0.4.0 — Bulk and sync operations (shipped)¶
Motivation: almost everything after authentication in the session was one-file-at-a-time: 3 text overwrites, 1 binary upload, and 20 individual manage_entity delete calls, each needing its own confirmPath. Before that, a hand-rolled diff over 9 local figures established that none of them needed re-uploading. That comparison is generically useful and should not be reinvented per caller.
design constraints the first draft did not account for.
- Two comparison paths. Binary files compare by the git blob hash already in the tree, at zero cost. Documents have no remote hash (v0.1.3 item 1), so
plan_syncmustread_fileeach remote doc and compare LF-normalised content against the local file with the same normalisation. ReportcomparedBy: "hash" | "content"per entry. Each doc comparison is one document join through the per-project FIFO; that is fine for tens of documents and should be stated in the description. - Documents sync through revision-checked writes, not uploads.
sync_directoryshould replace a changeddocwithwrite_filesemantics (usinglocalPathfrom v0.1.3 item 4), passing the revisionplan_syncobserved, and upload only binaries. A collaborator's edit between plan and sync then yields a per-fileREVISION_CONFLICTinstead of a silently lost edit, andwriteMode: "tracked"becomes available for projects in review mode. New documents go through the upload path, where Overleaf classifies them as docs by extension. - Ignore rules. Ship a default ignore list (
.git/,.DS_Store,__MACOSX/, hidden files,*.aux,*.log,*.bbl,*.blg,*.out,*.toc,*.synctex.gz,*.fdb_latexmk,*.fls) plus anignore: string[]of gitignore-style globs, merged with any.olignorein the folder foroverleaf-syncusers. Overleaf enforces a 150-character name limit and its own reserved names (FileTypeManager.shouldIgnore); map itsinvalid_filenametoINVALID_ARGUMENT. - Folder deletes are recursive.
DELETE /project/:id/folder/:idremoves a subtree in one request. Mirror mode should collapseremoteOnlyentries to their highest remote-only ancestor, present that collapsed list, and countconfirmDeleteCountagainst it. State which count the caller is confirming. - Partial failure is the normal case. Return per-file outcomes
{ path, action: "uploaded" | "written" | "created" | "deleted" | "skipped" | "failed", entityId?, error? }and continue past individual failures unlessstopOnErroris set. The existingPARTIAL_CLEANUPcode shows the pattern. - Bound the response.
identicalon a real project can be hundreds of paths. Return counts plus the first N, withverboseto get everything.
Design decisions made while building the first release, and why the draft above changed:
- The plan token is stateless and covers both sides. It is a small base64url object with three truncated SHA-256 digests: of the arguments that decide the scope (project, resolved folder, destination, ignore list), of every project entity in scope (path, type, id, and binary hash or document version and content hash), and of every included local file's blob hash and folder. Nothing is stored between calls, so a token survives a server restart, and it stays short enough to pass through a model's context. It covers identical entries too: a collaborator's edit to a file the plan called identical would otherwise be overwritten by the sync without anyone having seen it. A local change that alters the plan is also
REMOTE_DRIFT, withdetails.changed: "local", rather than a separate code: in both cases the plan the user approved is stale. - The token a sync returns describes what that sync left: what it changed as the tree shows it afterwards, everything else as planned. That makes a partial run resumable while a change someone made during the pause still stops it. The first draft's alternative, re-planning from scratch, would silently absorb that change into the new token.
- No
onConflict. With a token, any moved revision is alreadyREMOTE_DRIFT; during the sync, a moved revision fails that file withREVISION_CONFLICT. Anoverwriteoption could only mean discarding a collaborator's edit, which the acceptance criteria rule out. destinationFolderPath(default"", the project root) was added, so./figurescan be synced against the project'sfigures/without mirroring the whole project.- A
conflictslist. A local file where the project has a folder, a local folder where it has a file, and non-UTF-8 text where it has a document cannot be applied without a person deciding.plan_synclists them;sync_directoryreports them as failures, so they also block deletes. - Ignore rules protect the project side. A remote entity an ignore rule matches is never compared or deleted, the way rsync treats excluded files, so mirror mode cannot remove the
.latexmkrcit was told to ignore. A remote-only folder that holds such an entity is not collapsed; its other contents are listed individually. Patterns are applied in the order defaults,.olignore,ignore, so!patternre-includes, using theignorepackage, and match case-insensitively. - Tracked mode covers new text files. A new file created by upload is never tracked content, so with
writeMode: "tracked"new files Overleaf treats as text are created the waycreate_filedoes, as an empty document followed by a tracked write; binaries are uploaded as usual. (An upload that replaces a document can be tracked, when track changes is on for the account, as 0.4.1 found in Overleaf's source;sync_directorynever replaces a document by upload, so this does not change it.) - Uploads are confirmed before deletes. After the upload phase the tree is read back; an upload that is missing or whose hash differs from the local file counts as a failure, which withholds every delete. Each delete re-checks the entity's id, and for a folder its contents, against the plan first.
- Empty folders. A remote folder with no local counterpart is deleted whole, which is how required semantic 4's "remove folders left empty" is met. A remote folder whose local counterpart exists is kept even when the sync empties it, because mirror mode reproduces the local side, and only folders needed to hold uploaded files are created, so an empty local folder is not reproduced.
modeis required. There is no default that could delete.- Walk limits. More than 2,000 included files (Overleaf's own per-project limit) or 20,000 scanned entries fail fast with
INVALID_ARGUMENT, so a mistakenlocalFolderPathdoes not hash a home folder. - Only
identicalandignoredare bounded (25 unlessverbose).toUpload,remoteOnly, andconflictsare what the user is approving, so they are always complete.
Design decisions made while building the point release (0.4.1):
batch_uploadkeepsonConflict, defaulting to"overwrite". Unlikesync_directoryit has no plan and no revision to protect, so its default matchesupload_file, which a batch replaces call for call;"skip"is the conservative choice. Replacing a document this way is a blind write with no revision check, recorded as tracked changes only when track changes is already on for the account, and the description says to preferwrite_fileorsync_directoryfor documents a collaborator may edit. The default joins the v0.5.0 transition withupload_file's.destinationPathis a full path, file name included, so one call can sendfig_v3.pngtofigures/fig.pngand files to several folders.- Everything that can be checked locally is checked first. Invalid, duplicate, or nested destinations and missing or non-file local paths fail the whole call before any request. Problems only the project tree shows (a folder at the destination, or a file where a parent folder should be) fail that one file, in both modes, because a fresh tree is read once and a batch should not stop for one bad path.
- A
skippedlist. In skip mode an existing document or file is reported underskipped, which does not make the callpartial, and the check is repeated inside the upload's queue job right before the request (anifExistsoption onEntitiesApi.uploadFile, defaulting toreplace, soupload_fileis unchanged). A path a collaborator filled in between is skipped, not replaced. - Folder creation is shared with
sync_directory. Missing folders are created parents first, each once; a folder that failed is not tried again, and every file under it fails with the folder's code and a message naming it. - A
verifiedflag. After any attempt the tree is read back once, as sync's verification does: an upload that is missing, or a binary whose hash differs from the local file's (computed before the upload), moves tofailedwithREMOTE_ERROR. If the read-back itself fails,verifiedisfalseand the entries stand as reported. - Timed-out uploads are classified, never resent. A binary now in the tree with the local hash becomes
completedwithrecoveredAfterTimeout: true, whether this upload put the bytes there or they were already identical, since the requested outcome holds either way; a path absent before and after, or the same entity with the same hash as before, staysTIMEOUT, with a message that it had not landed when read back but could still land late; anything else, a document included since documents carry no hash, isOUTCOME_UNKNOWN, with a message to checkget_project_treebefore uploading again. download_project_zipusesCONFIRMATION_MISMATCHfor an existinglocalPathwithoutoverwrite: true, checked before any request, as new tools should;download_file'sINVALID_ARGUMENTstays the one recorded exception. A missing parent folder isNOT_FOUND, a folder atlocalPathINVALID_ARGUMENT.- Streaming and atomic replace. The archive is streamed through a streaming GET on the same HTTP client path (authentication, status mapping, and the timeout all apply) to a temporary file in the target folder, created before the request so an unwritable folder fails without spending one of Overleaf's downloads, and never buffered whole. It must start with a zip signature, else
PROTOCOL_UNSUPPORTED, with only a MIME-shapedcontentTypeindetails, and end with a zip end-of-central-directory record whose comment length accounts for the bytes after it, elseREMOTE_ERROR, retryable, with nothing written. Withoverwriteit is renamed over the target, so a failed download never destroys the existing file; without it, it is hard-linked into place so a file that appeared meanwhile is not replaced, falling back to an exclusive copy where links are unsupported. The temporary file is removed on every failure path. timeoutMs(1 second to 15 minutes, default 5 minutes) bounds the whole transfer, because a large project's archive can take longer than a default request timeout. No project socket and no queue: it is one HTTP request.- Annotations.
batch_uploadis{ destructiveHint: true, idempotentHint: false }, since it composesupload_fileand never retries;download_project_zipis{ readOnlyHint: false, destructiveHint: true, idempotentHint: false }, because it writes locally.
Checked against Overleaf's source before 0.4.1 shipped (overleaf/overleaf, services/web). The read corrected earlier assumptions, and the code and documentation follow it. These are read from the source, not yet confirmed live; Stage 0 records them.
- Replaced binaries may get a new entity id. Only a text document replacing a text document keeps its id. A binary replacing a binary goes through
ProjectEntityMongoUpdateHandler.replaceFileWithNew, and a document replaced by a binary or the reverse is a new entity too.upload_file's description now says a replaced binary "may get a new one", and nothing promises a kept id for binaries. - Replacing a document by upload can be tracked.
upsertDochands the new text toDocumentUpdaterHandler.setDocument, which diffs it and records tracked changes when track changes is on for the uploading user. The descriptions and connect-time instructions no longer say "never tracked". New text files created by upload are still untracked content, sosync_directory's tracked mode is unaffected.upload_file's result still reportswriteMode: "untracked"; changing the result shape is left to v0.5.0. duplicate_file_namemeans a folder is in the way. Overleaf replaces a document with a binary, or the reverse, without complaint; the code comes from a folder of the same name. The error message was corrected.- The zip route. Its rate limiter (
zip-download) allows 10 requests a minute per project and user and answers 429 with noRetry-After, sodetails.retryAfterMsis normally absent and the description says to wait a minute. The route has no login check of its own, so an expired session is a 403,PERMISSION_DENIED, notAUTH_EXPIRED; the description says so. The archive is built while it is sent, chunked with noContent-Length, so a transfer cut short still ends with HTTP 200: hence the end-record check. A file Overleaf fails to read is left out of an otherwise well-formed archive and only logged upstream, which the documentation states rather than hides. - Upload rate limits.
file-uploadallows 500 uploads per 15 minutes per project and user, the same asbatch_upload's cap, and folder creation 60 a minute. Since every later upload would be refused too,batch_uploadstops at the firstRATE_LIMITED, even withoutstopOnError, and lists the rest inremainingwith nothing more sent. - Timeout classification, simplified. The read-back now settles a timed-out upload in one of three ways, as recorded above: the local bytes at the path (recovered), the path unchanged (
TIMEOUT), or anything else (OUTCOME_UNKNOWN).
Tools¶
// Shipped in 0.4.0.
plan_sync({ projectId: string, localFolderPath: string, destinationFolderPath?: string,
ignore?: string[], verbose?: boolean })
→ {
planToken: string, // opaque; digests of the scope, the project side, and the local side
localFolderPath, destinationFolderPath,
toUpload: [{ localPath, destinationPath, reason: "new" | "changed",
comparedBy?: "hash" | "content", remoteType?: "doc" | "file" }],
identical: { count, paths }, // first 25 unless verbose
remoteOnly:[{ destinationPath, entityId, type: "doc" | "file" | "folder", contains? }], // collapsed
conflicts: [{ localPath, destinationPath, reason, message }],
ignored: { count, entries: [{ localPath, matchedPattern? , reason? }] }
}
// Side-effect free. annotations: readOnlyHint true
sync_directory({
projectId: string,
localFolderPath: string,
mode: "additive" | "mirror", // required
destinationFolderPath?: string,
planToken?: string, // if given, REMOTE_DRIFT when either side no longer matches it
confirmDeleteCount?: number, // REQUIRED in mirror mode; must equal remoteOnly.length, else CONFIRMATION_MISMATCH
ignore?: string[],
writeMode?: "untracked" | "tracked",
stopOnError?: boolean
})
→ {
status: "complete" | "partial",
mode,
completed: [{ destinationPath, action, entityId? }],
failed: [{ destinationPath, action, errorCode, message }],
remaining: [{ destinationPath, action }],
identicalCount: number,
planToken?: string // re-run with this to resume; absent if the result could not be re-read
}
// action: "create_folder" | "upload" | "write" | "create" | "delete"
// annotations: destructiveHint true, idempotentHint true
delete_entities({ projectId: string, paths: string[], confirmCount: number, stopOnError?: boolean })
→ { status, completed: [{ path, type, entityId }], failed: [{ path, errorCode, message }], remaining: [{ path }] }
// Composes manage_entity's delete; collapses the session's 20 calls into one.
// annotations: destructiveHint true
// Shipped in 0.4.1.
batch_upload({ projectId: string,
files: [{ localPath: string, destinationPath: string }], // 1..500; destinationPath includes the file name
onConflict?: "skip" | "overwrite", // default "overwrite", matching upload_file
stopOnError?: boolean })
→ {
status: "complete" | "partial", // skipped entries do not make it partial
onConflict,
completed: [{ destinationPath, action: "create_folder" | "upload", entityId?,
entityType?: "doc" | "file", replaced?, recoveredAfterTimeout?: true }],
skipped: [{ destinationPath, localPath, entityType }], // onConflict "skip" only
failed: [{ destinationPath, action, errorCode, message }],
remaining: [{ destinationPath, action }], // after stopOnError, or the first RATE_LIMITED
verified: boolean // the tree was read back and every completed entry confirmed
}
// annotations: destructiveHint true, idempotentHint false
download_project_zip({ projectId: string, localPath: string, overwrite?: boolean,
timeoutMs?: number }) // 1,000..900,000, default 300,000
→ { projectId, localPath, bytes, replaced }
// GET /Project/:id/download/zip. The reverse direction of sync; recommended as the backup
// step before any mirror sync. annotations: readOnlyHint false, destructiveHint true,
// idempotentHint false
Required semantics (write these into the tool descriptions)¶
- Order: all uploads and writes first, then deletes. Never delete if any upload failed.
- Partial failure: continue past individual failures (or stop at the first with
stopOnError), returnstatus: "partial"withcompleted/failed/remaining; the call is resumable by re-running with the sameplanToken. - Concurrency: if
planTokenis supplied and any entity intoUpload/remoteOnlyhas a different livehashor revision than the snapshot →REMOTE_DRIFT, nothing changed. This, plus per-docREVISION_CONFLICT, is the only protection for human co-editors, becauseupload_filehas no revision check; say so in the docs. - Folders: auto-create missing parent folders (via
manage_entity); in mirror mode, remove folders left empty. - Paths:
localFolderPathis resolved on the server's filesystem; reject..segments and symlinks that escape the folder (PATH_OUTSIDE_ROOT). - Hashing: git blob hash over raw bytes; stream files larger than 8 MB rather than buffering.
Keep manage_entity and upload_file exactly as they are underneath; every tool here is a composition of existing primitives.
Scope of the releases¶
The first release of this stage, 0.4.0, shipped plan_sync, sync_directory, and delete_entities: the two-call workflow the acceptance criteria name, plus the batched delete that collapses the session's 20 calls into one. batch_upload and download_project_zip followed in the 0.4.1 point release, once the planner and its ignore rules had had real-world use.
Tasks¶
- [x] Streaming
gitBlobHashhelper plus unit tests againstgit hash-objectfixtures (text, binary, empty file). - [x] Ignore-pattern matcher (reuse a gitignore-compatible library; support
.olignore). - [x]
plan_sync,sync_directory, anddelete_entitiescomposed from existing primitives. - [x]
batch_uploadanddownload_project_zip, in the 0.4.1 point release. - [x] Fault-injection tests: fail upload N of M; assert no deletes ran and
remainingis correct; assert resume completes. - [x] Drift test: mutate a remote doc between
plan_syncandsync_directory; assertREMOTE_DRIFT. - [x] Progress notifications (
notifications/progress) per file when the client supplies a progress token.
Acceptance¶
- The reference cleanup (upload 4 changed, delete 20 stale, leave 9 identical figures untouched) is 2 calls (
plan_sync→sync_directory) and the 9 identical files are never re-uploaded. - An induced mid-sync failure never deletes and is resumable with the same
planToken. - A concurrent web-UI edit between plan and sync yields
REMOTE_DRIFT(or one file'sREVISION_CONFLICT) with zero changes applied and never lost text. mirrorwithout a correctconfirmDeleteCountreturnsCONFIRMATION_MISMATCH.
v0.5.0 — Shared core and safety (shipped)¶
Motivation: two problems with one root. First, every operation, as a tool defines it, is reachable only through the MCP adapter: OverleafToolRuntime is declared in src/mcp/tools.ts, the argument shaping for manage_project, write_file, manage_entity, and list_comments lives in its handlers, and the exported OverleafRuntime reaches the domain classes but not that shaping. A CLI or library built on that would copy the shaping, and copies are where safety rules drift apart. Second, safety holds only when the caller picks the safe option, and error details carry what AGENTS.md forbids:
sync_directorychecks drift only when given aplanToken; without one, mirror mode countsconfirmDeleteCountagainst a delete set nobody reviewed, while the README,docs/index.md, and thedocs/safety.mdheading stated the guarantee unconditionally until 0.4.1.upload_filereplaces whatever is at the path, documents included, with no revision check and no confirm value, and so doesbatch_upload(0.4.1) with its defaultonConflict: "overwrite".- Local paths are read and written wherever the process can reach, and
download_filewrites locally while annotatedreadOnlyHint: true. - Ids reach request paths unencoded, and
new URL(path, base)inOverleafHttpClient.requesthonours..and?, so a craftedprojectIdorthreadIdcan retarget a POST that carries the CSRF token. COMPILE_FAILEDcarries the whole compile response indetails.result, the protocol layer stringifies upstream socket payloads into messages, and a non-JSON 200 body is quoted in aREMOTE_ERROR.
This stage fixes both before any new interface exists, and adds no MCP tool.
What changes for existing callers, and when. These change an agent's usual call, so they follow the one-minor rule even before 1.0; this table changes the shipped behaviour the v0.4.0 section records.
| Behaviour | v0.5.0 (announce) | v0.6.0 (enforce) |
|---|---|---|
sync_directory without planToken |
Runs; result has planned: false and a deprecations entry |
CONFIRMATION_MISMATCH, details.missing: "planToken"; additive may pass unplanned: true instead |
upload_file onto an existing binary, overwrite omitted |
Replaces, with a deprecations entry; an explicit overwrite: false refuses now |
CONFIRMATION_MISMATCH, nothing sent |
upload_file onto an existing document |
Replaces, with a deprecations entry |
INVALID_ARGUMENT naming write_file with localPath, unless uncheckedDocumentReplace: true |
COMPILE_FAILED details.result |
Reduced to { status }, deprecated |
Removed |
There is no compatibility switch: overleaf-web-mcp/core follows the table, and the SDK facade arrives in v0.7.0, after enforcement, so it starts strict.
Design decisions:
- Move first, with no public change.
src/contracts/takes the schemas verbatim fromsrc/mcp/tools.ts, plus effects, each entry's annotations (checked against its effects),ERROR_CODES,OperationContext, andOverleafService(today'sOverleafToolRuntime);createOverleafService(runtime)insrc/service/operations.tstakes the shaping, so each MCP handler becomes one call. overleaf-web-mcp/coreis a subpath of this package with entrysrc/sdk.ts(src/core/stays the internal utilities folder). It never imports@modelcontextprotocol/*, does not exportProjectConnection, and keepsconnectionFactorya test seam.- Expected state by default. Single-entity writes use a revision, an
expectedHash, or entity ids, and a mutation with none must be asked for by name. AplanTokenrecords whatplan_syncobserved, not that a person reviewed it; confirm-by-value and elicitation (v1.0.0) are the approval layer. uncheckedDocumentReplacekeeps a recorded workflow. The v0.1.3 decision table recommendsupload_fileto "push text when nobody else is editing", and the checked route throughread_filecosts the whole document in the model's context.overwritehas no default until v0.6.0, so an omitted value (deprecated) differs from an explicitfalse(refused).download_file'sINVALID_ARGUMENTfor the same case stays, as the one recorded exception.expectedHashis a preflight, not a compare-and-swap: the upload route takes onlyqqfileandname, so a replacement landing between check and upload is lost, and the docs say so.upload_file'swriteMode: "untracked"is inaccurate when track changes is on. Since 0.4.1 the description says a replaced document is recorded as tracked changes when track changes is on for the account, but the result still reportswriteMode: "untracked". Resolve it with theoutputSchemathis stage givesupload_file: report what Overleaf will record (from the account's track-changes state observed on join,"untracked"for a binary or a new file), or drop the field. Either is a result-shape change listed in the changelog, andbatch_upload's entries follow the same rule if they gain the field.- The access policy is not a sandbox.
src/core/policy.tsdecides allowed projects, local read and write roots, and allowed effects. With every new variable unset, only three refusals are new: local writes to the cookie jar or browser profile, a.olignorethat resolves outside its folder, and ids that are not path-safe. It cannot close the gap between the realpath check and the open or contain a malicious local process; it stops an agent, possibly steered by text it read in a project, from reaching outside what it was given. Projects created in a process join its allowlist in memory, so a later CLI process (v0.7.0) does not see them.
sync_directory({ ...existing, unplanned?: boolean })
→ { ...existing, planned: boolean,
deprecations?: [{ parameter: string, message: string, enforcedIn: string }] }
// unplanned: additive only, never deletes; INVALID_ARGUMENT with a planToken or mode "mirror"
upload_file({ ...existing, overwrite?: boolean, // no default until v0.6.0, then false
expectedHash?: string, // 40 hex characters, from get_project_tree
uncheckedDocumentReplace?: boolean }) // blind; tracked only if the account has it on; effect-gated
→ { ...existing, deprecations?: [...] } // gains an outputSchema; still destructiveHint true
download_file // annotations become { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
New error code POLICY_DENIED; new variables OVERLEAF_ALLOWED_PROJECTS, OVERLEAF_LOCAL_READ_ROOTS, OVERLEAF_LOCAL_WRITE_ROOTS, OVERLEAF_ALLOWED_EFFECTS.
Tasks¶
- [x] Documentation corrections (shipped in 0.4.1). Make the sync guarantee conditional on a
planTokenin the README,docs/index.md, anddocs/safety.md, and qualify the README's "each one confirms by value", false forupload_file,stop_compile, and additivesync_directory.docs/safety.mdsays aplanTokenis not human approval and that a per-itemfailed[]entry leaves earliercompleted[]items applied. - [x] AGENTS.md. Add the one-minor rule. Amend the confirm-by-value rule:
stop_compileis the one destructive tool without a confirm value, arevisionorplanTokencounts as expected-state confirmation, andunplannedanduncheckedDocumentReplaceare the only named unchecked opt-ins. Rescope "stdout is reserved for MCP protocol frames" toserve, sincehelp,login, andkeepalivealready print to stdout. - [x] Contracts and service as designed, with the
listTools()golden snapshot intest/server.test.ts;McpErrorCodeis derived fromERROR_CODES; no write path receives a request's abort signal. - [x] Lint boundary.
no-restricted-importsforbidding**/mcp/**,**/server.js, and@modelcontextprotocol/*, type imports included, outsidesrc/mcp/,src/server.ts,src/cli.ts, andsrc/index.ts;no-console; and noprocess.stdoutorprocess.stderroutsidesrc/cli.tsandsrc/server.ts(whoserunStdioServerdefaults the stdio transport toprocess.stdinandprocess.stdout). Onlysrc/cli.tswritesOperationContext.onDiagnosticnotices ({ tool, code }) to stderr. - [x]
./coreexport.src/sdk.tsexportsOverleafRuntime,readConfig,McpError,ERROR_CODES,createOverleafService, and the contract types;package.jsonexportsgains"./core"; a CI step after Build imports it by package self-reference. - [x] Announce phase. The new fields, with upload checks inside the queue job before
postForm. Sync's#applyUploadpassesoverwrite: falsefor new paths andoverwrite: truewithexpectedHashfor changed binaries, so a refusal becomes a per-itemREMOTE_DRIFT, which already withholds deletes.batch_upload, which shipped in 0.4.1 withonConflictdefaulting to"overwrite", joins the transition through the sameuploadFilechecks, per file. - [x] Tests. Every operation with a destructive effect has one of
confirmPath,confirmName,confirmCount,confirmDeleteCount,overwrite,onConflict(forbatch_upload),revision, orplanToken, with an allowlist of exactly['stop_compile']. Intest/overleaf/sync.test.ts, tokenless call sites plan first or passunplanned, andFakeProjectcounts reads and mutations. - [x] Access policy.
assertProject,resolveLocalRead,resolveLocalWrite, andassertEffect, with roots resolved byrealpathinOverleafRuntime.create. Reads:resolveTextContent,EntitiesApi.uploadFileandbatch_upload's local checks,ProjectsApi.importProjectZip, thescanLocalFolderroot, and.olignore(refused outside the folder, since its patterns are echoed back). Writes:EntitiesApi.downloadFile,ProjectsApi.downloadProjectZip(0.4.1), and laterdownload_compile_output. Refusals arePATH_OUTSIDE_ROOTwithdetails.kind. Projects are checked inProjectConnectionCache.withConnectionand every HTTP-only path, andlist_projectsshows only allowed ones. - [x] Path-safe ids. Reject
/,\,.,?,#, and%inprojectId,sourceProjectId, andthreadId(check Community Edition id formats before narrowing to 24 hex);encodeURIComponentat every interpolation insrc/overleaf/;OverleafHttpClient.requestasserts the resolved origin equalsbaseUrl's, and mutating requests useredirect: "manual"if a cross-origin redirect would keepx-csrf-token. - [x] Allowlisted error details.
COMPILE_FAILEDdetails become{ status, rootFilePath, result: { status } }, withstatusechoed only when it matches^[a-z][a-z0-9-]{0,63}$, else"unrecognized"; known upstream socket messages, which contain spaces, map to identifiers, and a raw socketErrorinopenProjectConnectionno longer supplies the message; the upload body'serrormust pass the short-code check;create_folder'screatedbecomes{ _id, name }; JSON parse failures becomePROTOCOL_UNSUPPORTEDwith{ path, contentType }. - [x] Instructions are delivered on both protocol eras (
initializeandserver/discover), tested in memory (done with the SDK move). - [x] Instructions, counts, docs.
SERVER_INSTRUCTIONSto at most 400 words once;test/mcp/tools.test.tsalso checks every "All N tools" in the README (the tool-list sentence and the Documentation table row) and the comparison "Tools" cell; a "Local effect" column in the tool tables, a policy section indocs/configuration.md, and "The access policy is not a sandbox" indocs/safety.md, which with theupload_filedescription also saysexpectedHashis a preflight, not a compare-and-swap. - [x] Upload
writeMode. Resolveupload_file'swriteMode: "untracked", inaccurate since 0.4.1 found that replacing a document is tracked when track changes is on for the account, as designed above, with a test for each state. - [x] CHANGELOG 0.5.0:
Added,Deprecated(each announced default, naming 0.6.0), andChanged(annotations, path-safe ids,.olignorerefusal, sanitized details, thecreate_folderresult).
Design decisions made while building 0.5.0, and why the draft above changed:
OverleafServiceis keyed by operation.createOverleafService(runtime)returns one function per tool name, taking the tool's input and resolving with its result; the interface the runtime implements, today'sOverleafToolRuntime, isOverleafServiceRuntime. Naming bothOverleafServicewould have given the factory and its argument the same name.- The service takes input as the interface parsed it. The MCP SDK validates against the contract's schema before the handler runs, and
test/mcp/tools.test.tspins the exact arguments each handler forwards, so the service shapes arguments without parsing again; a library caller is held to the input type. The CLI (v0.7.0) parses with the same schemas. - Effects are checked where they are known. A contract lists every effect an operation can have, but which ones a call has depends on its input (
manage_entitydeletes only withdelete) or on what Overleaf holds (an upload replaces a document only if one is there, seen inside the queue job). So the domain APIs check each effect they actually perform, before any request, and the service checks the project first, before any local read. A direct call through the exported runtime is covered either way. - An allowlist hides the rest. With
OVERLEAF_ALLOWED_PROJECTSset,list_projectsreportstotalProjects, andauth_statusprojectCount, over the allowed projects only, so nothing outside it is listed or counted. Roots must be absolute, and an empty variable counts as unset. expectedHashalone confirms a binary replacement, as arevisionor aplanTokenconfirms by expected state;overwrite: trueconfirms by value. An explicitoverwrite: falserefuses even with a matching hash. A document has no hash, soexpectedHashfor a path that holds one isREMOTE_DRIFT.batch_uploadgainsuncheckedDocumentReplace, andonConflictloses its schema default. v0.6.0 applies the document rule per file, so callers need the opt-in before then; and an omittedonConflictcan only be announced as deprecated if it differs from an explicit"overwrite". A deprecation is listed once per parameter per call, and only when a replacement relied on the default.writeModereports, it is not dropped.upload_file's result saystrackedexactly when a document replaced a document while the account's track-changes state, observed on join, is on.- Diagnostics are
{ tool, code }. The service reports a failed call with its error code and a deprecated default withDEPRECATED;servewrites them to stderr as{"diagnostic":…}.OperationContext.signalis defined, and nothing yet passes it on, which keeps every write path free of it. - Only mutations stop following redirects. fetch drops
cookieandauthorizationon a cross-origin redirect but keepsx-csrf-token, so a request that changes something usesredirect: "manual"(a redirect to/loginis stillAUTH_EXPIRED), while reads keep following, since a binary download may be redirected to storage on another host. - Socket messages map through a short table. A known message becomes an identifier in
details.reason; anything else isunrecognized, even when it looks like an identifier, so no upstream text is echoed. - The saved session is never read either. A review of the branch found that
write_fileorupload_filewithlocalPathset to the cookie jar would put the cookies into a project, and a sync of a folder holding it would upload it. So the session files are refused for local reads as well as writes, and a folder that holds them cannot be synced: a fourth refusal with every variable unset, beyond the three the draft named. - Paths are judged the way the file system resolves them. Normalizing
link/../xas text names a different file than the one the file system opens, which let a..through a symbolic link reach the cookie jar past the check. The policy resolves a path component by component, links before the..after them and a dangling link by where it would create a file, and the caller opens exactly the path that was checked. overwrite: falserefuses anything; an omittedoverwriteconcerns binaries. From v0.6.0 an omittedoverwriterefuses replacing a binary, anduncheckedDocumentReplacealone governs a document, so a document replacement that passes it keeps working.- The snapshot came first.
test/__snapshots__/list-tools.jsonwas committed on the 0.4.1 surface before the move, so the move's commit showstools/listunchanged and every later change to it shows up in review.
Acceptance¶
- After the move the committed
listTools()snapshot is unchanged, andtest/mcp/tools.test.tsandtest/server.test.tspass with only import-path changes. grep -rnE "from '(\.\./)*\.?/?mcp/|@modelcontextprotocol" srcmatches onlysrc/mcp/,src/server.ts, andsrc/cli.ts, and a lint fixture importing./mcp/tools.jsfromsrc/runtime.tsfails.- With every
@modelcontextprotocol/*module andsrc/mcp/tools.jsmocked to throw on load,src/sdk.jsperformsread_filethrough injected fakes. The builtoverleaf-web-mcp/coreexposescreateOverleafServiceand aMcpErrorstrictly equal to the root's, notProjectConnection, and the root still exports every 0.4.1 name. - Tokenless
sync_directoryresolves withplanned === falseand adeprecationsentry{ parameter: "planToken", enforcedIn: "0.6.0" };unplanned: truewith aplanTokenormode: "mirror"rejects withINVALID_ARGUMENTbefore any read. upload_fileonto an existing binary:overwrite: falsegivesCONFIRMATION_MISMATCHand a wrongexpectedHashgivesREMOTE_DRIFT, each with 0postFormcalls; the matching hash givesreplaced: true; an omittedoverwriteresolves with adeprecationsentry.download_fileannotations equal the block above; noreadOnlyHint: trueoperation has an effect other thanoverleaf-readorlocal-read; the destructive-tools test passes;grep -n "one minor" AGENTS.mdmatches;grep -nE "plan you reviewed|what you reviewed" README.md docs/*.md | grep -v planTokenprints nothing.- Policy refusals make zero fetches:
write_filewithlocalPath,upload_file,import_project_zip, andplan_syncoutsideOVERLEAF_LOCAL_READ_ROOTS, or through a symbolic link that leaves it, reject withPATH_OUTSIDE_ROOTwith 0 fetcher and connection-factory calls afterOverleafRuntime.create();download_fileonto the cookie jar is refused with no roots set. - With
OVERLEAF_ALLOWED_PROJECTS=a, every operation takingprojectIdorsourceProjectIdrejectsbwithPOLICY_DENIEDand 0 fetches, a project created in the same process is accepted, andlist_projectshides everything else. WithOVERLEAF_ALLOWED_EFFECTSexcludingoverleaf-write, a directruntime.documents.writeFilerejects withPOLICY_DENIEDand 0submitUpdatecalls. stop_compilewithprojectId: "../x?"andreply_to_commentwiththreadId: "../x?"reject withINVALID_ARGUMENTbefore any fetch;OverleafHttpClient.request("POST", "https://other.example/x")throws before fetching.- No serialized error or result contains the sentinel or
<!DOCTYPEfor anotUpdateErrorquoting document text,connectionRejected{ message: "SENTINEL" }, an upload body{ success: false, error: "Free <b>SENTINEL</b> text" }, an HTML 200 body ongetJson, or a compile status"SENTINEL free text"(which yieldsdetails.status === "unrecognized"). - The protocol-era tests pass after the trim, a one-time check finds
SERVER_INSTRUCTIONSat 400 words or fewer, and the tool-count test fails when any README count disagrees withTOOL_NAMES.length.
v0.6.0 — Compile and build ergonomics (shipped)¶
Motivation: compile_project returns a large JSON blob of build-artifact URLs plus a stats object. The session concluded success from stats["latexmk-errors"] === 0, never fetched output.log, and had no tool to do so. There is also no way to pull the compiled PDF to a local path; download_file is for project source entities only. This closes the README comparison gap "Reads the compile log and errors". It was v0.5.0 in earlier versions of this file; it now goes through the v0.5.0 contracts, so the CLI and SDK get both tools for free, and it enforces the safety defaults v0.5.0 announced.
what the code does today, and why the failure path matters more than the success path. src/overleaf/compile.ts throws COMPILE_FAILED for every non-success status and, until v0.5.0, buried the whole response, including the output.log URL, under details.result. So on the most common failure, a LaTeX error, the caller receives an error object with the evidence hidden inside it. On success it spreads the raw, unvalidated response into the result. Overleaf's web client distinguishes these non-success statuses: failure, timedout, terminated, too-recently-compiled, rate-limited, autocompile-backoff, compile-in-progress, project-too-large, validation-problems, clsi-maintenance, clsi-unavailable. It may also handle stopped-on-first-error, exited, and unavailable (clsi-unavailable being only a UI key), put the build id only on outputFiles[].build, and keep output for timedout and terminated builds (verify in code: local-compile-context.tsx, CompileController.mjs). Output files are served at /project/:id/build/:buildId/output/:file, adding clsiserverid as a query parameter when the response includes one; the server builds that path from the build id and a validated file name and never follows a url from the response.
Changes to compile_project¶
compile_project(...) // existing params, plus stopOnFirstError?: boolean, draft?: boolean
→ {
...existing fields (outputFiles, stats, buildId, clsiServerId, rootFilePath), validated,
buildRef?: string, // opaque; pass to get_compile_log / download_compile_output
summary: {
status: "success" | "failure" | "stopped-on-first-error", // Overleaf's own status
builtCleanly: boolean, // === pdfProduced && errorCount === 0
errorCount: number, // total; the lists below hold the first 20
errors: [{ file?: string, line?: number, message: string }],
warnings: [{ file?: string, line?: number, message: string }],
undefinedReferences: string[], undefinedCitations: string[], missingFiles: string[],
pageCount?: number, pdfSizeBytes?: number
}
}
// IMPORTANT: Overleaf reports status "success" under nonstopmode even when LaTeX errors
// occurred. builtCleanly is the field callers should assert on.
- Return a parsed summary on success and on
failurealike. Throw only when there is no build output:too-recently-compiled,rate-limited,autocompile-backoff,compile-in-progress, and HTTP 429 →COMPILE_RATE_LIMITED(retryable, withretryAfterMs);clsi-maintenanceandunavailable→COMPILE_UNAVAILABLE(retryable);timedout→COMPILE_TIMEOUT; the rest,validation-problemsincluded, →COMPILE_FAILED. - Error details are exactly
{ status, rootFilePath, buildId?, buildRef? };details.resultis gone. A build id is recorded before the throw, so a timed-out build's log stays reachable. ATIMEOUTon the compile POST isretryable: false, because the compile may still be running. - Validate the response at the boundary:
statusrequired,outputFilesdefaulting to[]with each entry reduced to{ path, type, build? }(nourl),statsreduced to finite numbers. Anything else malformed isPROTOCOL_UNSUPPORTEDwith{ endpoint: "compile", expectedKeys, receivedKeys }. - A stateless
buildRef, base64url likeplanToken, carries the project id, build id, and routing values, so a CLI call in a new process can fetch an earlier compile's output; within one process the tools default to the latest build. - licence constraint on the log parser. Overleaf's
latex-log-parser.tsandbib-log-parser.tsare AGPL-3.0 and this package is MIT, so write an independent, pure parser for the patterns that matter (!errors withl.<n>, undefined references and citations, missing files,Output written on output.pdf (N pages, M bytes),.blg"I didn't find a database entry"), tested against fixture logs. A message is the!or warning line cut to 240 characters, without the source excerpt afterl.<n>, which quotes the document. - expose
stopOnFirstErroranddraft, which Overleaf's compile endpoint already accepts: the first gives a short log with the one error that matters, the second speeds up text-only iteration.
New tools¶
download_compile_output({ projectId: string, localPath: string, file?: string, buildRef?: string, overwrite?: boolean })
→ { localPath, file, sizeBytes, buildId }
// file defaults to "output.pdf" and accepts any output path (output.log, output.blg,
// output.synctex.gz). Without buildRef, the latest build known to this process, else NO_BUILD;
// an evicted build is BUILD_NOT_FOUND. Writes through the local-write policy; an existing
// localPath without overwrite: true is CONFIRMATION_MISMATCH, nothing written.
// annotations: as download_file (readOnlyHint false, destructiveHint true, idempotentHint false)
get_compile_log({ projectId: string, buildRef?: string, kind?: "latex" | "bibtex",
format?: "raw" | "errors-only", tail?: number })
→ { buildId, log: string, truncated: boolean, errorLines: [{ line: number, text: string }] }
// Reads at most 1 MB and returns a bounded tail. annotations: readOnlyHint true
Tasks¶
- [x] Log parser with fixture tests under
test/fixtures/compile/(clean, errors under nonstopmode, missing bib, 500 errors). - [x] Response schema, status mapping, error details,
buildRef, a bounded body reader, and the parser wired intocompile_project, all as contract entries withoutputSchema. - [x] Both new tools;
TOOL_NAMES, the README counts, the tool table, the comparison row,docs/tools.md, anddocs/private-api.md(output route,stopOnFirstErroranddraft, "passed through" removed). - [x] The five new codes in
ERROR_CODESanddocs/safety.md; the Compiling paragraph ofsrc/mcp/instructions.ts. - [x] Read-only MCP resources
overleaf://project/{id}/output/output.logand.../output.pdf; the live test assertssummary.builtCleanly === trueand PDF magic bytes%PDF-. - [x] CHANGELOG 0.6.0:
Added(both tools,summary,buildRef,stopOnFirstError,draft, the output resources, the five codes);Changed(failurereturns a summary, compile error details reduced to four keys,outputFilesentries withouturl, a compile-POSTTIMEOUTnot retryable).
Safety defaults flip (announced in v0.5.0)
- [x]
sync_directory: aplanTokenpresence check before any read; mirror without a token, or additive with neither a token norunplanned: true, fails withCONFIRMATION_MISMATCHanddetails.missing: "planToken". - [x]
upload_file:overwritedefaults tofalse; an existing document withoutuncheckedDocumentReplacefails withINVALID_ARGUMENTnamingwrite_filewithlocalPath;batch_upload, shipped in 0.4.1 withonConflictdefaulting to"overwrite", flips with it: no default that replaces, and the same document rule per file. - [x] Wording in
src/mcp/instructions.ts, thesync_directorydescription ("required in mirror mode"),docs/tools.md, AGENTS.md, anddocs/safety.md; CHANGELOG 0.6.0Changednames each flipped default.
How the design settled (recorded while building 0.6.0). Read against overleaf/overleaf
(CompileController.mjs, CompileManager.mjs, ClsiManager.mjs, local-compile-context.tsx,
output-files.ts, and libraries/validation-tools):
- The compile response has no top-level build id; it is
outputFiles[].build, validated as Overleaf validates it (^[0-9a-f]+-[0-9a-f]+$), withclsiServerIdas^[a-z0-9-]+$. The statuses are those listed above plusexited;clsi-unavailableis only a UI key, and is mapped toCOMPILE_UNAVAILABLEanyway.too-recently-compiledcomes from a one-secondCOMPILE_DELAY, so it carriesretryAfterMs: 1000; aCOMPILE_RATE_LIMITEDaddsretryAfterMsto the four detail keys when it is known, and nothing else does. - The compile is posted with
?file_line_errors=true(-file-line-error), so errors name their file; the parser also reads the classic!form, undoes TeX's 79-column wrapping before cutting a message, and reads BibTeX and Biber.blglogs. - The summary adds
pdfProduced,logRead, andwarningCountto the planned fields, andbuiltCleanlyalso needslogRead, so a log that could not be read is never reported clean. Every list, references, citations, and missing files included, holds at most 20 entries. statskeeps every key that is a short identifier with a finite number. An output file the server would refuse to request (absolute,.., control characters) is left out ofoutputFilesrather than failing the call; an entry without a stringpathisPROTOCOL_UNSUPPORTED.buildRefalso lists the build's output paths (up to 100), so a file the build did not produce isNOT_FOUNDwith no request, and only a 404 for a listed file isBUILD_NOT_FOUND.get_compile_logreads the first 1 MB, as Overleaf's editor does, returns the lasttaillines (default 200, at most 2,000, and at most 100,000 characters), and lists the first 100errorLines. The PDF resource stops at 16 MB.batch_upload's new default is an explicitonConflict: "fail": a taken destination refuses the whole call before any upload, withdetails.missing: "onConflict"and the paths, rather than skipping silently.upload_file's refusals carrydetails.missingtoo. Thedeprecationsfield is gone from the three results; the mechanism (src/contracts/deprecations.ts, theDEPRECATEDdiagnostic) stays for the next announcement.
Acceptance¶
- A project with a deliberate
\undefinedcommand yields Overleafstatus: "success"butbuiltCleanly: falseanderrorCount ≥ 1; the test must exercise exactly this case. - A clean project yields
builtCleanly: true,errorCount: 0, correctpageCount. download_compile_outputwrites a file starting with%PDF-in one call, no separate log fetch needed.- A failed compile returns structured
{ file, line, message }errors instead of an opaqueCOMPILE_FAILED. - A table-driven test maps every listed status to its code and
retryable; HTTP 429 givesCOMPILE_RATE_LIMITED; an empty 200 body givesPROTOCOL_UNSUPPORTED. - Sentinels in
timings,outputFiles[].url,pdfDownloadDomain, an unknown key, and a non-numericstatsvalue never reach the serialized error or result, and error details have no keys beyond the four above. - A
timedoutresponse with buildBrejects withCOMPILE_TIMEOUT,details.buildId === "B", and abuildRefwith which a second runtime instance requests build B's log. - The 500-error fixture gives 20
errorsanderrorCount === 500, with no message over 240 characters or matching/^l\.\d+ /. download_compile_outputrequests only paths under/project/<projectId>/, never a fixture URL on another origin, andfile: "../x"isINVALID_ARGUMENTwith zero requests.TOOL_NAMESgrows by exactly two, andgrep -n "details.result" docs/safety.md docs/tools.md src/mcp/instructions.tsprints nothing.- Tokenless mirror rejects with
details.missing: "planToken"and no read or mutation;unplanned: trueresolves withplanned === falseand no delete;upload_fileonto an existing document withoutuncheckedDocumentReplace, or onto an existing binary withoutoverwrite, fails with 0postFormcalls.
v0.7.0 — CLI, SDK, and Skills¶
Motivation: the first complete Overleaf workflow that needs no MCP client. The command line reaches no operation today, and a TypeScript program has only the ./core runtime. Since v0.5.0 every operation is one service function with one contract, so a CLI and an SDK are thin adapters, and multi-process use needs no new state, because revision tokens, plan tokens, and buildRef are self-contained. keepalive already shows the output convention (main in src/cli.ts). One gap remains: 17 tools at 0.4.0 return only a JSON text block (handler() in src/mcp/tools.ts); upload_file and compile_project gain an outputSchema in v0.5.0 and v0.6.0, and the other 15 gain one here.
Design decisions:
- One generic command, plus aliases.
call <tool_name>reaches every operation; aliases only build its input. Input is validated with the operation's contract, so defaults equal MCP's. An unknown key is refused (INVALID_ARGUMENT), because a misspelledwriteModewould otherwise write untracked; whether MCP strips unknown keys depends on the SDK (verify against the v2 SDK), anddocs/cli.mdstates any difference. - Output framing. An operation command prints exactly one JSON value on stdout, the tool's
structuredContent. On failure stdout stays empty and stderr gets one line of{ code, message, retryable, details? }, which may contain local paths the caller supplied. Progress goes to stderr as JSON lines only with--progress.serve,login,keepalive, and an unknown command keep their output and exit 1 on any failure. - Exit codes say what is safe to do next. The HTTP client marks every timeout
retryable: true, uploads and deletes included, so the code depends on whether the operation is read-only and ondetails.outcome; a wrapper that re-runs on "retry later" must never resubmit a write that may have applied. - Noninteractive and strict. No browser, no terminal input, no confirm value supplied, no
--yesor--force, no retry, and the defaults v0.6.0 enforced. - Each invocation is a session. Every run bootstraps
GET /projectand may open a project socket, so the account can appear online and a shell loop multiplies requests; neither the per-project queue nor the in-memory allowlist reaches across processes.docs/cli.mdpoints loops atsync_directoryanddelete_entities. - Build references cross processes explicitly.
compile logandcompile downloadrequire--build-ref, since a new process has no latest build;compile run --downloaddoes both in one process. - No document text in argv, which process listings and shell history show: content goes through
--content-file,--stdin, or--request <file>. - The SDK is a client facade over the same service.
createOverleafClient(options?)returns one camelCase method per operation plusclose(), generated from the registry; each returns the contract output as a plain object and throwsMcpErrorwith the same codes. - Output contracts for every tool. Private pass-through fields (
list_commentsthreads, thethreadofreply_to_commentandadd_comment) stay loose. Read-only operations validate their result at run time; mutating ones are only typed, because an error after a write applied invites a resubmission. - Skills come after the CLI: revise a manuscript, handle reviewer comments, sync figures and sources, each a folder with a
SKILL.md(verify the current Agent Skills format). They call only public CLI commands, duplicate no authentication or OT logic, and treat manuscript text and reviewer comments as data, never instructions.
Commands¶
overleaf-web-mcp call <tool_name> (--request <file> | --request - | --json '<object>') [--progress]
overleaf-web-mcp schema <tool_name> (--input | --output)
overleaf-web-mcp capabilities [projectId]
overleaf-web-mcp projects list [--query <q>] [--limit <n>]
overleaf-web-mcp tree <projectId>
overleaf-web-mcp files read <projectId> <path>
overleaf-web-mcp files write <projectId> <path> --revision <r> (--content-file <f> | --stdin) [--tracked]
overleaf-web-mcp files download <projectId> <path> <localPath> [--overwrite]
overleaf-web-mcp sync plan <projectId> <localFolder> [--destination <path>] [--ignore <glob>]... [--verbose]
overleaf-web-mcp sync apply <projectId> <localFolder> --mode additive|mirror (--plan-token <t> | --unplanned)
[--confirm-delete-count <n>] [--tracked] [--stop-on-error] [--progress]
overleaf-web-mcp compile run <projectId> [--root <path>] [--download <localPath>] [--overwrite]
overleaf-web-mcp compile log <projectId> --build-ref <r> [--tail <n>]
overleaf-web-mcp compile download <projectId> <localPath> --build-ref <r> [--file <f>] [--overwrite]
overleaf-web-mcp comments list <projectId> [--status open|resolved|all] [--file <path>]
Every other operation is reached through call. Exit codes apply to operation commands only:
| Exit | Meaning | Codes |
|---|---|---|
| 0 | Complete | |
| 1 | Anything else | |
| 2 | Fix the call | usage errors, INVALID_ARGUMENT, CONFIRMATION_MISMATCH, PATH_OUTSIDE_ROOT |
| 3 | Not allowed or not available; do not retry | AUTH_EXPIRED, PERMISSION_DENIED, POLICY_DENIED |
| 4 | Read or plan again, then decide | REVISION_CONFLICT, REMOTE_DRIFT, unless details.outcome is unknown; TIMEOUT with details.outcome: "not_applied" from an operation that is not read-only |
| 5 | Outcome unknown; read before acting, never re-run blindly | OUTCOME_UNKNOWN; any error with details.outcome: "unknown"; TIMEOUT from an operation that is not read-only, unless details.outcome is not_applied (exit 4) |
| 6 | status: "partial"; the result is still on stdout |
|
| 7 | Safe to retry later | RATE_LIMITED, COMPILE_RATE_LIMITED, COMPILE_UNAVAILABLE, TIMEOUT from a read-only operation |
Tasks¶
- [ ] Output contracts for the 15 remaining tools, with golden snapshots of their results taken first through the real domain classes over an injected fetcher and connection factory. Every tool registers through
structured()with a byte-identical text block, andhandler()goes.errorCodeSchemabecomesz.enum(ERROR_CODES); the narrowercreateMcpServerparameter type is listed underChanged. - [ ] CLI.
src/cli/parse.tsonnode:utilparseArgs, delegating anything else to the untouchedparseCliCommand;call,schema,capabilities(effects and annotations from the contract entry, error and exit codes; with aprojectId, project-level fields, elseunknown), the aliases,src/cli/exit-codes.ts, andruntime.close()infinally. - [ ] Docs. AGENTS.md and
docs/safety.md: every command butserveandhelpprints exactly one JSON result.docs/cli.mdin themkdocs.ymlnav: commands, request format, exit codes (never re-run on 5), no cross-process serialization or allowlist, presence and request volume, and the Skills. README: a "Without an MCP client" section and a lead sentence naming MCP, the CLI, and the SDK;docs/install.md: a CLI-only path. - [ ] SDK.
createOverleafClientinsrc/sdk.ts;docs/sdk.mdin the nav names the stable surface (createOverleafClient,createOverleafService, the contract types,McpError,ERROR_CODES,readConfig,OverleafRuntime.createandclose), marks runtime fields andconnectionFactoryinternal, and shows a minimal program. - [ ] Skills under
skills/, using only commandsrenderHelplists, andskillsinpackage.jsonfiles; a test parses every command line in them and rejects any mention of@modelcontextprotocol,src/, or the cookie jar. - [ ] Comparison.
test/live/compare-interfaces.test.ts, gated byRUN_OVERLEAF_LIVE_TESTS=1andRUN_OVERLEAF_LIVE_COMPARE_TESTS=1on a throwaway project it creates and trashes, runs the four tasks in Interfaces beyond MCP (review on comment threads) through a spawnedserve,node dist/cli.js, and the SDK, and writes a JSON report of those metrics plus round trips, upstream requests and bytes, bytes an agent would read, and start-up versus Overleaf time, for a cold and a warm run of each task. - [ ] Live CLI test (
RUN_OVERLEAF_LIVE_TESTS=1andRUN_OVERLEAF_LIVE_CLI_TESTS=1):node dist/cli.jsalone, fromcall create_projectthroughfiles write,sync apply --plan-token, andcompile runto a trash of that throwaway project. - [ ] CHANGELOG 0.7.0:
Added(commands, exit codes,createOverleafClient, Skills, 15 output schemas);Changed(errorCodeenum,createMcpServerparameter type).
Acceptance¶
test/cli-command.test.tsis unchanged and passes;overleaf-web-mcp unknownexits 1; the keepalive dead-session test still passes.- For every
TOOL_NAMESentry,schema <name> --inputprints a JSON Schema with"type": "object", andcall <name> --request <fixture>reaches the fake service with arguments deep-equal to those the MCP handler passes. - A table-driven test over every error code: success writes one JSON value to stdout and exits 0; an injected
McpErrorleaves stdout empty, writes exactly one stderr line parsing to{ code, message, retryable, details? }, and exits with the table's code. TIMEOUTfromwrite_file,upload_file, orcreate_projectexits 5;TIMEOUTfromread_fileexits 7;TIMEOUTwithdetails.outcome: "not_applied"fromwrite_fileexits 4;REVISION_CONFLICTwithdetails.outcome: "unknown"exits 5;status: "partial"exits 6 with the result on stdout.files writewith a stale revision against the realDocumentsApiexits 4 with zerosubmitUpdatecalls;manage_entitydelete withoutconfirmPathand mirrorsync applywithout--confirm-delete-countexit 2 with zero DELETE requests; an unknown request key exits 2 and never calls the service.captureBrowserSessionis never called by an operation command; stderr is empty after a successfulsync applywithout--progress; and across every CLI test stderr never contains a seeded cookie-jar value or a comment-body sentinel.- Parity: for every tool, MCP
structuredContent, the MCP text block, CLI stdout, and the SDK return value are deep-equal, and an injectedMcpErrorgives an identical{ code, message, retryable, details }through all three. A realPATH_OUTSIDE_ROOT(download_fileoutsideOVERLEAF_LOCAL_WRITE_ROOTS) and a realPOLICY_DENIED(OVERLEAF_ALLOWED_PROJECTS) give the same error through all three with 0 fetches. - Every tool has an
outputSchemaitsstructuredContentvalidates against over the in-memory transport,grep -n "handler(async" src/mcp/tools.tsprints nothing, and the 15 golden result snapshots are unchanged. createOverleafClientover injected fakes has a method perTOOL_NAMESentry, performsreadFile,writeFilewith the returned revision, andclose(), and throwsMcpErrorwithREVISION_CONFLICTon a stale revision.capabilitieslists every tool, reportsdownload_filewith alocal-writeeffect, and without aprojectIdreports project-level fields asunknown; everyrenderHelpcommand appears indocs/cli.md.npm pack --dry-runlists the threeSKILL.mdfiles, and the comparison, with its flags set, reports every task and interface with the same outcome through all three.
v0.8.0 — Tracked-change review: accept and reject¶
Motivation: tracked changes can be written (writeMode: "tracked") but not listed, accepted, or rejected, the second gap in the README comparison ("Accepts or rejects tracked changes"), which a web-session peer already covers. history-ot trackedChanges are read only to strip tracked deletions from visible content (historyVisibleContent in src/protocol/ot.ts), and ShareJS ranges.changes is never read. The same blind spot weakens tracked writes: DocumentsApi.writeFile verifies only the visible content hash, so a tracked insert that Overleaf recorded untracked passes verification. The range parsers this stage needs close that gap too.
Verify first. No captured frame here holds a non-empty change (the fixtures under test/fixtures/protocol show "changes":[] and "trackedChanges":[]), and no accept or reject route is in docs/private-api.md. Confirm each item in overleaf/overleaf at the commit the release targets, record it in docs/private-api.md, and gate anything unconfirmed with REVIEW_UNSUPPORTED rather than guessing (details.kind: "document_protocol" for a document protocol with no confirmed mechanism). All ten are (verify in code):
- The accept route, believed to be
POST /project/:project_id/doc/:doc_id/changes/acceptwith{ change_ids }: authorization, version bump, and history-ot support. - How the editor rejects: a reject route, or an untracked inverse OT operation (for ShareJS, an insert with an undo flag that cancels a delete change).
- The ShareJS change shape
{ id, op: { p, i } | { p, d }, metadata: { user_id, ts } }. - Whether
joinDocwithencodeRanges: trueencodes range text as it encodes lines. - The raw history-ot
trackedChangesform (seeHistorySnapshotinsrc/protocol/ot.ts): no ids, adjacent-range merging, and theTextOperationforms that clear tracking or delete. - Whether
GET /project/:id/rangesreturnschanges, history-ot documents included. project.features.trackChangesinjoinProject, and the privilege levelsowner,readAndWrite,review,readOnly.- Whether
meta.tcon a tracked ShareJS update is the author or an id seed; this server's tracked writes set it to the user id. - Whether
applyOtUpdateforces tracking for thereviewprivilege. - The real-time service's
otUpdateErrormessages (the too-large case in particular) and which clients receive the event.
Tools¶
list_tracked_changes({ projectId: string, filePath?: string, type?: "insert" | "delete",
authorId?: string, excerptChars?: number /* 0..500, default 120 */,
limit?: number /* 1..500, default 100 */ })
→ { documents: [{ filePath, revision, protocol: "sharejs" | "history-ot", totalChanges,
changes: [{ changeId, type, start: { line, column }, end: { line, column },
length, excerpt?, excerptTruncated, authorId, timestamp }] }],
totalMatched, truncated, trackChangesActive, indexUnavailable?: true }
// positions 1-based in UTF-16 units. annotations: readOnlyHint true
manage_tracked_changes({ projectId: string, filePath: string, revision: string,
action: "accept" | "reject",
changeIds: string[], // 1..500, unique, all in this document
confirmCount?: number }) // must equal changeIds.length
→ { action, filePath, changeIds, revision, protocol, remainingChangeCount,
trackChangesActive, recoveredAfterTimeout?: true }
// accept insert keeps the text; accept delete removes it; reject insert removes the text;
// reject delete restores it. A tracked replacement is a delete plus an insert; pass both ids.
// annotations: destructiveHint true, idempotentHint true (a repeat fails and changes nothing)
CLI: tracked-changes list <projectId> [--file <path>] and tracked-changes accept|reject <projectId> <path> --revision <r> --change <id>... [--confirm-count <n>]; the SDK methods come from the registry.
Design decisions:
- Two tools. Listing is read-only and cannot share a tool with a destructive action; decisions reuse the
manage_action-enum pattern, one document per call, because revisions, the accept route, and OT are per document. confirmCountis optional in the schema and checked byTrackedChangesApi, assync_directorychecksconfirmDeleteCount, so a missing value isCONFIRMATION_MISMATCH, not a schema error.- Listing never scans every document. Without
filePathit reads the/rangesindexlist_commentsalready uses and joins only documents with changes, or returnsindexUnavailable: true.filePathis authoritative, since the index may omit history-ot documents (item 6). - Checks before anything is sent:
confirmCount; access level (ownerorreadAndWrite, elsePERMISSION_DENIED);features.trackChanges === false(REVIEW_UNSUPPORTED,details.kind: "feature_disabled"); revision; every id live (NOT_FOUND); no overlap with a change left out (INVALID_ARGUMENT). Then submit once and verify against a fresh join. - Mechanisms, all (verify in code) per the list above. The expected state is computed locally and canonicalized before comparing; size limits are checked first. Reject operations carry no tracking metadata, so they are not tracked writes that fell back.
| Document | accept insert | accept delete | reject insert | reject delete |
|---|---|---|---|---|
| ShareJS | REST accept (1) | REST accept (1) | untracked OT delete of the text | untracked OT insert with undo (2) |
| history-ot | OT retain with tracking: { type: "none" } (5) |
OT delete (5) | OT delete | OT retain with tracking: { type: "none" } |
- History-ot ids are synthesized (
ht_plus 22 base64url characters of a SHA-256 over document, type, position, length, user, and timestamp), valid only with the listed revision. - Timeouts are observed, never resent, through one recovery helper extracted from
DocumentsApi.writeFileand the comment writes: target state seen,recoveredAfterTimeout: true; original state live through the window,TIMEOUTwith"not_applied"; a third state,REVISION_CONFLICTwith"unknown"; nothing seen,OUTCOME_UNKNOWN. - The same parsers check tracked writes. Every inserted span must sit in a new tracked insert by the current user, every deleted span in a new tracked delete. Otherwise the write fails with
PROTOCOL_UNSUPPORTED,retryable: false, anddetails.kind: "applied_untracked", with nodetails.outcome, because it applied; it is never reverted, since a revert is another write. otUpdateErroris classified, not echoed:update is too largeisUPDATE_TOO_LARGEwith"not_applied"; anything else goes through observation, since Overleaf broadcasts it to every client on the document (verify in code: item 10).- Independent code: the upstream range libraries and the peer server are AGPL-3.0, this package MIT.
- Plan and edition. Review may need a paid plan on
www.overleaf.com(verify live) and Server Pro when self-hosted (verify against the current Community Edition); the free-plan positioning does not extend to it. - Known limits, stated in the descriptions: the revision ignores ranges, so per-id checks catch a concurrent accept; after a history-ot decision removes text, re-list; a decision landing after the recovery window is reported
not_applied, as withwrite_file.
Tasks¶
- [ ] Confirm items 1–10 into
docs/private-api.md, and capture sanitized fixturestest/fixtures/protocol/sharejs-tracked-changes.jsonandhistory-ot-tracked-changes.jsonfrom a disposable project (an insert, a delete, a replacement pair, non-ASCII text; placeholder ids, users, and timestamps), plus a hygiene test overtest/fixtures/protocolthat fails on 24-hex ids, email addresses, and session cookie names. - [ ]
src/protocol/tracked-changes.ts(pure parsers, ids, locations, excerpts, overlap detection, decision planner) with exact-operation tests; the shared recovery helper, with documents and comments moved onto it. - [ ]
TrackedChangesApiinsrc/overleaf/tracked-changes.ts, inside the per-project queue, readingfeatures.trackChangesandpermissionsLevelfromProjectConnection; contract entries (overleaf-read,overleaf-write) registered afterset_comment_status;REVIEW_UNSUPPORTEDinERROR_CODES. - [ ] CLI aliases and
docs/cli.md;src/cli/exit-codes.tsmapsREVIEW_UNSUPPORTEDto 3 andapplied_untrackedto 4. - [ ] The fidelity check in
DocumentsApi.writeFile, skipping no-op writes, with an optionalkindonSyncFailure(src/overleaf/sync.ts); theotUpdateErrormapping inProjectConnection.submitUpdate. - [ ] Tracked-change decisions join the review task in
test/live/compare-interfaces.test.ts. - [ ] A "Tracked changes" paragraph in
src/mcp/instructions.tswithin the 450-word bound: list first, confirm with the user, pass revision, ids, and a matchingconfirmCount, re-list afterREVISION_CONFLICTorNOT_FOUND. - [ ] README (the counts, two Review rows, the comparison row, the plan caveat),
docs/tools.md,docs/safety.md,docs/internals.md,docs/using.md; an opt-in live test behindRUN_OVERLEAF_LIVE_TRACKED_REVIEW_TESTS=1; CHANGELOG 0.8.0AddedandChanged.
Acceptance¶
list_tracked_changesis annotated exactly{ readOnlyHint: true }andmanage_tracked_changesexactly{ destructiveHint: true, idempotentHint: true }, both have anoutputSchema, andTOOL_NAMESgrows by exactly two over the stage base (33 if earlier stages shipped as planned).confirmCountmissing or not equal tochangeIds.lengthgivesCONFIRMATION_MISMATCHwith zerojoinDocument,submitUpdate, andpostJsoncalls; a stale revision, an absent id (details.missingChangeIds), arevieworreadOnlyaccess level, andfeatures.trackChanges === falseeach return their code beforesubmitUpdateorpostJson.- A ShareJS accept posts once to the confirmed route with exactly the given ids, and a 404 gives
REVIEW_UNSUPPORTEDwithdetails.kind: "route_unavailable"; other decisions submit exactly the fixture-derived operation with no tracking metadata; a fresh join that misses the expected state givesREVISION_CONFLICTwith"unknown". - After a timeout, with one submission, each of the four recovery outcomes above is produced for both the REST and OT mechanisms.
- Without
filePath, a/ranges404 returnsindexUnavailable: truewith no join; excerpts never exceedexcerptCharsor split a surrogate pair; no key namedemailappears. - Both new fixtures parse, and a hygiene test finds no 24-hex string, no
@, and no cookie name undertest/fixtures/protocol. The confirmed too-largeotUpdateErrormessage givesUPDATE_TOO_LARGEwith"not_applied"after onesubmitUpdate. - A tracked write whose rejoin shows the text but no new tracked ranges for the user rejects with
details.kind: "applied_untracked"after onesubmitUpdate, and a trackedsync_directorystep reports thatkind;test/overleaf/documents.test.tsandcomments.test.tspass unmodified after the helper extraction. tracked-changes acceptwithout--confirm-countexits 2 withCONFIRMATION_MISMATCH;REVIEW_UNSUPPORTEDexits 3;applied_untrackedexits 4.- Live, on a disposable project: a tracked insert is listed with
authorIdequal toauth_status'suserId; reject restores the original text; accept on a second file keeps it and empties the listing. - Every route and OT form the code submits is listed in
docs/private-api.mdwithout a (verify in code) mark.
v0.9.0 — Multi-file document support¶
Motivation: get_sections / get_section_content / write_section are explicitly single-file. The project in the session was, until recently, split across 0_main.tex plus eight sec: *.tex files stitched together with \input. Plenty of real Overleaf projects stay organised this way permanently, and section tools that stop at \input boundaries can only partially help with them.
This was v0.6.0 in earlier versions of this file. The design is unchanged, except that get_full_document and the followIncludes parameters are contract entries (v0.5.0), so the CLI and SDK (v0.7.0) expose them without adapter code.
resolution rules to decide up front (document them). \input{x} tries x then x.tex; \include{x} always means x.tex, implies a page break, and interacts with \includeonly (respect it, or at least flag it in the result). Cover \subfile{} and \import{dir}{file} / \subimport, which many multi-file projects use instead. Paths resolve relative to the project root (Overleaf semantics), then relative to the including file. Skip directives inside % comments (the section parser already does this; reuse it). Detect cycles (INCLUDE_CYCLE, listing the chain) and cap depth (INCLUDE_DEPTH_EXCEEDED). A missing target is reported under unresolved, not thrown. A target that is a binary file rather than a doc is skipped and reported.
Tools¶
get_full_document({ projectId: string, rootFilePath?: string, maxDepth?: number /* default 10 */ })
→ {
text: string, // flattened, latexpand-style
sourceMap: [{ flattenedStart, flattenedEnd, filePath, docId, originalStart, originalEnd, revision }],
files: string[], // every file visited, in order
unresolved: [{ file: string, line: number, target: string }]
}
// rootFilePath defaults to the project root doc. Each source-map entry carries the per-file
// revision so an edit decided on in the flattened view can be routed back through write_file
// or write_section with the correct revision for that file.
// annotations: readOnlyHint true
- Extend
get_sections/get_section_content/write_sectionwithfollowIncludes?: boolean(defaultfalse), built onget_full_document. Every section then carriesfilePath, andsectionIdencodes the file sowrite_sectionstays single-file underneath. Writes are always applied per file through the normal revision-checkedwrite_filepath using the source map; never write a flattened blob back. A section whose heading and body straddle a file boundary is reported withspansFiles: trueand refused bywrite_sectionwith a clearINVALID_ARGUMENTrather than partially written.
Keep the honesty pattern: update the "never follows \input" sentence to say exactly what is and is not followed; do not delete it.
Tasks¶
- [ ]
\input/\include/\subfile/\importscanner with comment stripping and cycle detection; unit tests including nested and cyclic fixtures. - [ ] Source-map builder and a
flatRange → (file, fileRange)lookup helper. - [ ]
followIncludesplumbing in the three section tools; write-back viaread_file→write_fileper touched file. - [ ] Keep
get_sections' "does not follow\inputby default" wording; it is still true by default. - [ ] Contract entries for
get_full_document(overleaf-read) andfollowIncludeson the three section operations;INCLUDE_CYCLEandINCLUDE_DEPTH_EXCEEDEDinERROR_CODES;TOOL_NAMES, the README counts, the tool table, anddocs/tools.mdupdated.
Acceptance¶
- A project split across
\inputs is section-browsed and section-edited withfollowIncludes: trueexactly like a single-file project. - An edit chosen in the flattened view lands in the correct
(file, lineRange)and goes through a revision check (test: concurrently bump the target file's revision →REVISION_CONFLICT, nothing written). - A deliberate
\inputcycle returnsINCLUDE_CYCLE; a missing target appears inunresolved, not as an exception. overleaf-web-mcp schema get_full_document --outputprints a JSON Schema whose properties includesourceMap, andoverleaf-web-mcp call get_full_documentprints the same object the tool returns asstructuredContent.
v1.0.0 — Hardening and compatibility¶
Motivation: this wraps undocumented private endpoints, as the README says. At 1.0 the highest-leverage investment is making failure modes legible and the protocol surface complete, not adding tools. A SemVer commitment also needs a read-only check of an install and an Overleaf instance (doctor, backend capabilities) and evidence from real clients on each protocol version answered (a compatibility matrix). And openProjectConnection turns every connectionRejected into AUTH_EXPIRED, which agents are told to stop on, even for rejections that do not mean an invalid session (verify in code).
Error taxonomy (complete list; each is a typed code on the error, through every interface)¶
| Code | Introduced | Meaning |
|---|---|---|
AUTH_EXPIRED |
0.1.0 | No saved session, or Overleaf no longer accepts it |
PERMISSION_DENIED |
0.1.0 | HTTP 403, which on the zip download (v0.4.1) can also be an expired session; from v0.8.0 also an access level that cannot decide tracked changes |
NOT_FOUND |
0.1.0 | Project, path, resource, or (v0.8.0) tracked change does not exist (covers the first draft's ENTITY_NOT_FOUND) |
REVISION_CONFLICT |
0.1.0 | write_file / write_section revision no longer matches, or a verified result differs from the intent (details.outcome: "unknown") |
PROTOCOL_UNSUPPORTED |
0.1.0 | Collaboration protocol or response shape this release does not understand; from v0.4.1 a project download that is not a zip archive; from v0.5.0 a body that is not JSON; from v0.8.0 a tracked write that landed untracked (details.kind: "applied_untracked") |
DOC_TOO_LARGE |
0.1.0 | Document would exceed OVERLEAF_MAX_DOC_LENGTH |
UPDATE_TOO_LARGE |
0.1.0 | Single update exceeds OVERLEAF_MAX_UPDATE_CHARS, or HTTP 413, or (v0.8.0) the matching socket error |
TIMEOUT, OUTCOME_UNKNOWN |
0.1.0 | Timed-out request; timed-out write whose outcome could not be observed |
COMPILE_FAILED |
0.1.0 | Compile finished without a usable build; from v0.6.0 failure returns a summary instead, and details are { status, rootFilePath, buildId?, buildRef? } only |
PARTIAL_CLEANUP |
0.1.0 | A multi-step operation could not undo every step |
INVALID_ARGUMENT |
0.1.0 | Malformed call, wrong entity type (covers the first draft's NOT_A_DOC), rejected name; from v0.5.0 an id that is not path-safe |
REMOTE_ERROR |
0.1.0 | Anything else; from v0.4.1 also a project download cut short before its zip end record (retryable) |
CONFIRMATION_MISMATCH |
v0.2.0 | confirmPath / confirmName / confirmCount / confirmDeleteCount wrong; from v0.4.1 an existing localPath without overwrite: true on download_project_zip; from v0.5.0 an explicit overwrite: false on an existing binary; from v0.6.0 an omitted overwrite there, a missing required planToken (details.missing), or an existing localPath without overwrite: true on download_compile_output; from v0.8.0 a missing confirmCount on manage_tracked_changes |
RATE_LIMITED |
v0.2.0 | HTTP 429; details.retryAfterMs when Overleaf said how long |
REMOTE_DRIFT |
v0.4.0 | The project or the local folder differs from the planToken snapshot, or an entity changed just before its delete or, from v0.5.0, its replacement (expectedHash) |
PATH_OUTSIDE_ROOT |
v0.4.0 | A symbolic link in localFolderPath resolves outside it; from v0.5.0 any local path outside the allowed roots (details.kind) |
POLICY_DENIED |
v0.5.0 | The access policy does not allow this project or effect; nothing was sent |
COMPILE_RATE_LIMITED, COMPILE_TIMEOUT |
v0.6.0 | Compile throttled by Overleaf; compile timed out |
COMPILE_UNAVAILABLE |
v0.6.0 | Overleaf's compile service is unavailable or in maintenance; retryable |
NO_BUILD, BUILD_NOT_FOUND |
v0.6.0 | No compile known to this process / CLSI output evicted |
REVIEW_UNSUPPORTED |
v0.8.0 | Tracked-change review is not available (details.kind: feature_disabled, route_unavailable, document_protocol); nothing changed |
INCLUDE_CYCLE, INCLUDE_DEPTH_EXCEEDED |
v0.9.0 | \input graph problems |
API_SHAPE_CHANGED |
v1.0.0 | Response no longer matches the docs/private-api.md schema (the first draft called this UNSUPPORTED_API_CHANGE); subject to the decision task below |
TRANSIENT_FAILURE |
v1.0.0 | Network or 5xx after bounded backoff on a read was exhausted, or Overleaf asked the socket to retry |
Tasks¶
- [ ] Validate every private response at the boundary with zod (compile since v0.6.0, tracked-change ranges since v0.8.0, the rest here), returning the shape-error code with details limited to
{ kind, endpoint, expectedKeys, receivedKeys }. One route table,src/overleaf/routes.ts, is tested to equaldocs/private-api.mdand to send no body key a route does not accept. - [ ] Decide the shape-error code. Prefer
PROTOCOL_UNSUPPORTEDwithdetails.kind(response_shape,socket_protocol,protocol_version,applied_untracked) and dropAPI_SHAPE_CHANGED:docs/private-api.mdalready promisesPROTOCOL_UNSUPPORTEDfor an unexpected shape, and no caller breaks. A separate code is announced one minor ahead. - [ ] Classify socket rejections in
openProjectConnectionby upstreamcode, then message: invalid session →AUTH_EXPIRED; retry →TRANSIENT_FAILURE; not authorized →PERMISSION_DENIED; project not found →NOT_FOUND; too many requests →RATE_LIMITED; elseREMOTE_ERROR, retryable for a disconnect beforejoinProjectResponse(verify in code: the real-time service's codes and messages). InOverleafRuntime.create, a page withol-user_idbut nool-csrfTokenisPROTOCOL_UNSUPPORTED, notAUTH_EXPIRED. - [ ] retry with backoff for reads only. Bounded exponential backoff (max 3 attempts, jitter) on
retryable: truefailures of GET requests and document joins, never on OT submissions, uploads, deletes, or state-changing POSTs, so the "never re-submitted automatically" guarantee survives. Centralise request pacing (configurable minimum interval). Exhausted backoff →TRANSIENT_FAILURE, exit 7 insrc/cli/exit-codes.tsanddocs/cli.md(API_SHAPE_CHANGED, if kept, exit 1). - [ ] Sanitized fixtures under
test/fixtures/protocol(connectionRejectedandotUpdateErrorvariants, a nestedjoinProject) andtest/fixtures/rest(compile responses per status, the project list, uploads), fed through the real parsers; the v0.8.0 hygiene test and the AGENTS.md fixture rule extend to all oftest/fixtures. - [ ]
overleaf-web-mcp doctor [projectId](CLI only): read-only checks of the bootstrap meta and/api/project, plus, with aprojectId, the handshake,joinProjectand itsprotocolVersion, and ajoinDoc/leaveDocof the root document. It prints{ checks: [{ id, status, code? }] }without names or paths, exits 1 on anyunsupported, never mutates, and its docs say a project check shows the account online. - [ ] Backend capabilities as a
backendkey on the v0.7.0capabilitiesreport (kind: "web-session",liveRevisions,trackedWrites,review: { list, decide },comments,compile,history); an operation the backend cannot perform fails before any request. - [ ] MCP compatibility matrix. In-memory tests cover both protocol eras, and
describe('stdio lifecycle')intest/server.test.tsalready drivesrunStdioServerover piped streams with a fake runtime, checking that stdout carries only JSON-RPC and that the server exits once stdin closes, for a 2025-11-25 client only. Extend it so a 2025-11-25initializeand a 2026-07-28server/discovereach returnSERVER_INSTRUCTIONSover those streams, and list indocs/install.mdeach MCP client checked by hand, with version, negotiated protocol, and date. - [ ] do not add cursor pagination to
list_projects./api/projectreturns every project; v0.2.0'squery,limit,sort, andtotalMatchedare the reasoned answer to the first draft'scursor. - [ ] MCP elicitation for
manage_projecttrash/delete, mirrorsync_directory, andmanage_tracked_changeswhen supported; confirm-by-value stays mandatory. On 2026-07-28, read_meta["io.modelcontextprotocol/clientCapabilities"]per request (verify the v2 SDK accessor). - [ ] Resources (
overleaf://project/{id}/tree,overleaf://project/{id}/file/{path}, plus the v0.6.0 output resources) and progress forcompile_projectandget_full_document. - [x] Back-fill
outputSchemaon every tool: moved to v0.7.0, where the CLI and SDK need it; the acceptance below still checks it. - [ ] automated smoke test against Community Edition, never
www.overleaf.com(the README's Terms-of-Service caution): on a pinned Docker image,doctor <projectId>, thencreate_project → update_project_settings → plan_sync → sync_directory (with its planToken) → compile_project → download_compile_output → manage_project(trash)through the MCP server and throughnode dist/cli.js(sync plan, thensync apply --plan-token), with equal results. Comments and tracked changes may be Server Pro features (verify against the current Community Edition); their live tests stay manual. - [ ] README: security model (cookie-jar permissions, stdout framing, no content logging, the limits of the access policy), ToS posture, and "what breaks when Overleaf changes".
- [ ] schema stability commitment. SemVer covers tool names, input schemas, result shapes, and error codes, plus the CLI's commands, flags, request format, output framing, and exit codes, the documented
overleaf-web-mcp/coresurface, and the capability report. Deprecations are announced one minor ahead with both names registered, andCHANGELOG.mdrecords every change. - [ ] release checks. Still to add to the publish workflow: the
npm pack --dry-runfile-list check from v0.1.3.
Acceptance¶
- Every error surfaced through any interface carries one of the codes above; a mutated fixture (dropped key, wrong type, HTML or empty body) for
joinProjectand every REST route produces the decided shape-error code, never a stack trace. list_projectsover a 127-project account answers "the ten most recently updated" and "anything containing Thesis" in one call each; no uncapped arrays remain.- CI runs the full smoke path green against Community Edition on every pull request.
tools/listshows annotations and anoutputSchemaon every tool.- Socket rejections map as listed (a retry is retryable, never
AUTH_EXPIRED; an early disconnect is neverOUTCOME_UNKNOWN); a bootstrap page withol-user_idand nool-csrfTokenyieldsPROTOCOL_UNSUPPORTED, and a redirect to/loginstill yieldsAUTH_EXPIRED. doctorwith fakes exits 0 when all checks pass and 1 with checkjoinProject.protocolVersionunsupportedforprotocolVersion3, sends noapplyOtUpdate, and skips socket checks without aprojectId.- The sanitization and route-table tests pass, and every file under
test/fixturesis referenced by a test. - The stdio lifecycle test also serves a 2026-07-28 client, both eras receive
SERVER_INSTRUCTIONSover stdio, anddocs/install.mdlists at least one client checked by hand, with its version and date. - With a fake connection whose
features.trackChangesisfalse,capabilitiesreportsreview.decide: false, andmanage_tracked_changesfails withREVIEW_UNSUPPORTEDbefore any request. - An injected
TRANSIENT_FAILUREexits 7 from an operation command.
Explicitly out of scope (for now)¶
- A dedicated review of the comment and history tools (
add_comment,list_comments,reply_to_comment,set_comment_status,monitor_project_history): not exercised in the reference session; worth its own pass from someone who has used the review workflow end to end. Listing, accepting, and rejecting tracked changes is not part of this exclusion; it is v0.8.0. - Collaborator and sharing management, git-bridge parity, version diff and restore, billing, chat, background history watching, backward history pagination, label mutation, and editing or deleting individual comment messages. The "Current exclusions" list in
docs/development.mdstands. - An HTTP service with an OpenAPI description, a Git backend, a WebMCP adapter, and an agent-to-agent (A2A) interface: triggers in Interfaces beyond MCP, not stages.
- Any surface that runs caller-supplied code with access to the saved session; serializing CLI invocations across processes; renaming the package, the repository, or the binary.
Sequencing rationale¶
- v0.1.3 first because it was zero-risk and unblocked correct use of everything that existed. Two of its items were one-line changes with the best payoff-to-effort ratio in this document: surfacing
rootDocPathinget_project_treeand correctingupload_file'sdestructiveHint. - v0.2.0 before v0.4.0 even though sync produced more individual tool calls: the lifecycle gap was a hard stop that pulled a human into the loop mid-task, while the sync friction was merely tedious. Fix what blocks autonomous use before what is merely inefficient. The trash-first design is not only safer for agents; it is what lets v1.0.0's smoke test create and dispose of projects without a permanent delete anywhere in the automated path.
- Session keepalive shipped alone as v0.3.0, ahead of sync rather than with it or after hardening: it is a CLI-only change with no tool surface, it removes the one failure that presents as a dead server instead of a typed error, and bulk sync driven by a scheduler or a long-running agent is exactly the workload that outlives a five-day session.
- v0.5.0, shared core and safety (L: contracts M, policy M), before any new interface. A CLI or library built on today's MCP handlers would copy their shaping and inherit the optional-plan and blind-upload defaults. Moving first means every new interface starts strict, and the registry exists before v0.6.0 writes
compile_project's output schema into it. - v0.6.0, compile (M), before the CLI. It closes a comparison gap cheaply, v0.7.0 exposes its tools for free, and it enforces the v0.5.0 defaults exactly one minor later.
- v0.7.0, CLI, SDK, and Skills (L: CLI M, SDK S to M, Skills S). The first workflow with no MCP client needs both the contracts and the strict defaults; Skills only call the CLI's public commands.
- v0.8.0, tracked-change review (L), after the CLI. Its operations are defined once in the contracts, its upstream verification can take time without blocking anything, and it lands before v1.0.0 so
REVIEW_UNSUPPORTEDand the range shapes join the taxonomy and boundary validation. - v0.9.0, multi-file (M), last before 1.0.
\inputflattening is the most novel item and is self-contained, so it can slip without hurting the core zero-to-PDF story. - v1.0.0, hardening and compatibility (M), last, because
doctor, backend capabilities, and the compatibility matrix describe every interface. - Conditional interfaces are not stages. An HTTP service (L), a Git backend, WebMCP, or A2A each needs a real consumer first; built speculatively, it adds a surface to keep safe without reducing the upstream risk.
- Annotations and
outputSchemaare not deferred to 1.0. Every new tool ships with them, v0.7.0 back-fills the rest, and v1.0.0 adds only the cross-cutting protocol features (elicitation, resources, progress).
Competitive position this roadmap targets¶
| Capability | v0.5.0 (today) | After roadmap | olcli |
@netique/overleaf-mcp |
Git-bridge MCPs |
|---|---|---|---|---|---|
| Works on a free plan | ✅ | ✅ | ✅ | ✅ | ❌ (paid) |
| Create / clone / import / rename / trash project, set root | ✅ | ✅ | create and rename only | ❌ | partial |
Filtered list_projects |
✅ | ✅ | ❌ (lists, no search) | ✅ | ❌ |
| Dry-run diff + safe mirror sync + ignore rules | ✅ | ✅ (plan required since v0.6.0) | folder diff; rest re-check | ❌ | via git |
| Typed compile summary + PDF/log download | ✅ v0.6.0 | ✅ v0.6.0 | compiles, no log; PDF re-check | summary + log, no PDF | varies |
| Full workflow without an MCP client | ❌ | ✅ v0.7.0 (CLI and library) | ✅ (CLI) | re-check | via git |
| Tracked changes as suggestions | ✅ | ✅ | ❌ | ✅ | ❌ (bypassed) |
| Accept or reject tracked changes | ❌ | ✅ v0.8.0 | ❌ | ✅ | ❌ |
\input/\include flattening with source map |
❌ | ✅ v0.9.0 | re-check | ❌ | ❌ |
| Comments + version history | ✅ | ✅ | comments only | comments only | git log |
The differentiator: autonomous zero-to-compiled-PDF on a free account, tracked-change- and revision-safe, with safe bulk sync, review decisions, and multi-file awareness, from an MCP client, a shell, or a TypeScript program. No existing tool covers that combination. A command line alone is not the difference, since olcli already has one; the same revision, plan, and policy checks behind every interface are. Review decisions may need a paid plan on www.overleaf.com (verify live); the zero-to-PDF workflow does not. Re-check competitors before each release; if a peer ships sync or PDF download first, lean harder on sync safety and \input flattening.