Roadmap¶
The planned path from 0.1.3 to 1.0.0. Every item below comes 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 named here is confirmed to exist rather than assumed.
Each stage assumes the previous one shipped. Tool names follow the existing snake_case convention.
| Stage | Theme | New tools | The thing it fixes |
|---|---|---|---|
| v0.1.3 | Documentation, metadata, small additive fixes | 0 | Behaviour that had to be reverse-engineered |
| v0.2.0 | Project lifecycle | 5 | No way to create, rename, trash, or configure a project |
| v0.3.0 | Bulk and sync | 5 | 24 one-at-a-time calls to sync one folder |
| v0.4.0 | Compile and build ergonomics | 2 | Success inferred from counters; no PDF or log access |
| v0.5.0 | Multi-file documents | 1 | Section tools stop at \input boundaries |
| v1.0.0 | Hardening | 0 | Failure modes that are not yet legible |
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. See the changelog for what landed.
After v1.0.0 the server would register 32 tools (19 today). Every tool description costs the MCP
client context on every turn, so the lifecycle stage below deliberately reuses the
manage_entity action-enum pattern instead of adding one tool per verb.
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 in place by path, keeping the sameentity_idacross re-uploads. This is what made replacingmain.texwholesale possible without aread_file→ revision →write_fileround trip.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.5.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(13 codes today), aretryableflag, and structureddetails. v1.0.0 should extend this, not replace it. - 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.
- 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.
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. 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.3.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. -
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.3.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.
Acceptance: a fresh reader of the README can predict hash values and overwrite behaviour without probing empirically; get_project_tree shows which file Overleaf compiles by default; pull requests run the test suite.
v0.2.0 — Project lifecycle¶
Motivation: the hard blocker of the session. Every one of the 19 tools takes an existing projectId; there is 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.
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.
| 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 today) is the legacy list |
New tools:
create_project({ name, template: "blank" | "example" })→{ projectId, url, rootDocPath }. note in the description that "blank" still contains Overleaf's stubmain.tex, which is exactly the placeholder root the session tripped over; callers who import their own root should follow up withupdate_project_settingsor delete the stub.clone_project({ sourceProjectId, name })→{ projectId, url }. Starting from a lab or journal template project is the most common way real Overleaf projects begin.import_project_zip({ localZipPath, name? })→{ projectId, url }. One call from "folder on disk" to "project on Overleaf"; v0.3.0's sync covers subsequent updates. Surface the server-side rate limit asRATE_LIMITED(v1.0.0) rather than a raw HTTP 429.manage_project({ projectId, action: "rename" | "trash" | "restore" | "archive" | "unarchive" | "delete", newName?, confirmName? }), mirroringmanage_entity's action enum so five verbs cost one tool description.trash,archive, anddeleterequireconfirmNameto equal the current project name exactly. the first draft proposeddelete_projectas 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_settings({ projectId, rootFilePath?, compiler?, imageName?, spellCheckLanguage? })instead ofset_root_document.rootFilePathis resolved through the tree and must be adoc; the change persists in the project's own settings, so the web UI's Recompile targets the right file too.compiler(pdflatex|latex|xelatex|lualatex) and 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) andsort: "lastUpdated" | "name"(defaultlastUpdated, newest first), and returnlastUpdated,archived,trashed, andtotalMatched. With 127 projects, "the ten most recently updated" or "anything containing CHEERSafe" is what every caller actually wants, and neither is possible today.auth_statusalso lists every project just to count them; the new endpoint'stotalSizemakes that cheap.
Acceptance: a fresh Overleaf account with zero projects can go from nothing to a compiled project using only these tools plus v0.1 tools, with no web UI step, and never has to guess which file is the root.
v0.3.0 — Bulk and sync operations¶
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/,*.aux,*.log,*.bbl,*.blg,*.out,*.toc,*.synctex.gz,*.fdb_latexmk,*.fls) plus anignore: string[]of globs. 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.
New tools:
plan_sync({ projectId, localFolderPath, ignore? })→{ toUpload: [{ path, reason: "new" | "changed", comparedBy }], identical: { count, paths? }, remoteOnly: [...collapsed...], planToken }. Side-effect-free.planTokencarries the per-doc revisionssync_directoryneeds.sync_directory({ projectId, localFolderPath, mode: "additive" | "mirror", planToken?, confirmDeleteCount?, ignore?, writeMode?, stopOnError? }). InmirrormodeconfirmDeleteCountmust equal the collapsedremoteOnlycount or nothing is deleted. Without aplanTokenthe tool plans internally first.batch_upload({ projectId, files: [{ localPath, destinationPath }] })for callers with a known file list and no directory semantics.destinationPathincludes the file name, which needsdestinationNamefrom v0.1.3.delete_entities({ projectId, paths: [...], confirmCount }). Composesmanage_entity's delete; would have collapsed the session's 20 calls into one even without a local mirror.download_project_zip({ projectId, localPath }), viaGET /Project/:id/download/zip. The reverse direction of sync: it replaces per-file round-trip verification with one call, and its description should recommend it as the backup step before anymirrorsync.
Keep manage_entity and upload_file exactly as they are underneath; every tool here is a composition of existing primitives.
Acceptance: re-running the session's cleanup (upload 4 changed files, delete 20 stale ones, leave 9 identical figures untouched) is two calls, plan_sync then sync_directory, instead of 24; and a human edit made during the sync surfaces as one file's REVISION_CONFLICT, never as lost text.
v0.4.0 — Compile and build ergonomics¶
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.
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 buries 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. 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. Output files are fetched from the url each outputFiles entry already carries (/project/:id/build/:buildId/output/:file), adding clsiserverid as a query parameter when the response includes one.
Changes:
- Return a parsed summary on success and on
failurealike. When a build produced a log, return rather than throw:{ status, buildId, errors: [{ file, line, message }], warnings, undefinedReferences, undefinedCitations, missingFiles, pageCount, pdfBytes, outputFiles }. Throw only when there is no build output, and map the statuses:too-recently-compiled,rate-limited,autocompile-backoff,compile-in-progress→COMPILE_RATE_LIMITED(retryable, withretryAfterMs);timedout→COMPILE_TIMEOUT;validation-problems→COMPILE_FAILEDcarryingvalidationProblems; the rest →COMPILE_FAILEDwith the status. - licence constraint on the log parser. Overleaf's own
latex-log-parser.tsandbib-log-parser.tsare AGPL-3.0; this package is MIT. Do not vendor them. Write an independent parser for the handful of patterns that matter (!error lines withl.<n>,LaTeX Warning: Reference … undefined,Citation … undefined,File … not found,Output written on output.pdf (N pages, and the.blg"I didn't find a database entry" lines), keep it pure, and unit-test it against fixture logs in the same style astest/fixtures. download_compile_output({ projectId, file, localPath, buildId? })instead of a PDF-only tool;filedefaults tooutput.pdfand accepts any path inoutputFiles(output.log,output.blg,output.synctex.gz).get_compile_log({ projectId, buildId?, kind: "latex" | "bibtex", tail? })for when the summary is not enough. Logs run to hundreds of KB; default to a bounded tail and document the bound.- expose
stopOnFirstErroranddraft. Both are accepted by Overleaf's compile endpoint today alongside thecheckandincrementalCompilesEnabledfields the server already sends.stopOnFirstErrorgives agents a short log with the one error that matters;draftspeeds up text-only iteration.
Acceptance: verifying a compile is errorCount === 0 on a typed field plus one call to pull the PDF; a failed compile returns structured { file, line, message } errors instead of an opaque COMPILE_FAILED.
v0.5.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.
resolution rules to decide up front. \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. Skip directives inside % comments (the section parser already does this; reuse it). Detect cycles and cap depth. A missing target is reported under unresolved, not thrown. A target that is a binary file rather than a doc is skipped and reported.
New tools:
get_full_document({ projectId, rootFilePath? })→ the flattened text plus a source map. each map entry must carry{ flattenedStart, flattenedEnd, filePath, docId, originalStart, originalEnd, revision }, including the per-filerevision, so an edit decided on in the flattened view can be routed back throughwrite_fileorwrite_sectionwith the correct revision for that file.rootFilePathdefaults to the project root doc from v0.1.3 item 7.- Extend
get_sections/get_section_contentwithfollowIncludes: true, built onget_full_document. every section then carriesfilePath, andsectionIdencodes the file sowrite_sectionstays single-file underneath. 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.
Acceptance: a project split across \inputs can be section-browsed and section-edited with the same tools as a single-file project, opt-in via one flag, and no write ever lands in the wrong file.
v1.0.0 — Hardening¶
Motivation: this wraps undocumented private endpoints, as the README says. At 1.0 the highest-leverage investment is making failure modes legible, not adding surface area.
- retry with backoff for reads only. Retry
retryable: truefailures on GET requests and document joins, never on OT submissions, uploads, or deletes, so the README's "never re-submitted automatically" guarantee survives and the bulk tools inherit it. map HTTP 429, whichsrc/http/client.tscurrently reports as a genericREMOTE_ERRORwithstatus: 429, to aRATE_LIMITEDcode carryingretryAfterMsfrom theRetry-Afterheader. Overleaf rate-limits uploads and compiles per endpoint, so v0.3.0's bulk tools will meet this in practice. API_SHAPE_CHANGEDinstead of a raw parse exception. Today a moved private API surfaces as aREMOTE_ERRORfromasMcpErroror as a bareTypeError. Validate responses at the boundary with zod, already a dependency, for the project list, thejoinProjectpayload, the upload response, and the compile response. On mismatch, returnAPI_SHAPE_CHANGEDwith{ endpoint, expectedKeys, receivedKeys }and nothing else, honouring the README's rule against logging response bodies.- do not add cursor pagination to
list_projects. Overleaf has no server-side pagination;/api/projectreturns every project and the dashboard paginates in the browser. A cursor would be theatre. v0.2.0'squery,limit,sort, andtotalMatchedare the right fix; keep them. - automated smoke test against Community Edition, not
www.overleaf.com. A CI job that logs into the public service conflicts with the README's own Terms-of-Service caution and needs a long-lived session cookie stored as a CI secret. Instead runcreate_project → sync_directory → compile_project → download_compile_output → manage_project(trash)against Overleaf Community Edition in a Docker service container on a pinned image tag. That also pins the private-API version the suite is tested against. Comments and tracked changes are Server Pro features, so the existing env-gated live tests for those stay manual and opt-in. - schema stability commitment. 1.0 means tool names, input schemas, result shapes, and error codes are governed by SemVer, deprecations are announced one minor version ahead with both names registered during the overlap, and
CHANGELOG.mdrecords every change to any of them. MCP clients cannot discover this on their own; write it down. - release checks. The publish workflow's tag-equals-version check is good. Add the
npm pack --dry-runfile-list check from v0.1.3 and fail the release if the README's tool count disagrees withTOOL_NAMES.
Deliberately not scoped here (not exercised in the session, so there is no direct evidence of friction): add_comment, list_comments, reply_to_comment, set_comment_status, monitor_project_history. Worth a dedicated review pass once the above ships, from someone who has used the review workflow end to end. The README's "Current exclusions" list (Git workflows, collaborator administration, billing, chat, background history watching, history pagination and restoration, label mutation, editing or deleting individual comment messages) stands.
Sequencing rationale¶
v0.2.0 (lifecycle) is ordered before v0.3.0 (sync) even though the sync friction produced more individual tool calls, because 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.
two items in v0.1.3 are one-line changes with the best payoff-to-effort ratio in this document: surfacing rootDocPath in get_project_tree (item 7) and correcting upload_file's destructiveHint (item 2). Ship those first, even ahead of the documentation pass. And v0.2.0's 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.