| name | contributing |
| description | How to contribute to the OverleafMCP server — code style, the hard invariants (review-mode, cursor-anchored edits, the ~50ms latency budget), how to run the tests, and where things live. Load this before editing overleaf_mcp/, adding a tool, or touching the editor/ops layer. |
Contributing to OverleafMCP
An MCP server that drives an Overleaf project as a live collaborator. Read this
before changing overleaf_mcp/. Keep the four invariants below intact — they are
safety- and correctness-critical, not stylistic.
Hard invariants (do not weaken)
- Review mode is enforced in code, not by the account role. Every edit is a
tracked-change suggestion:
apply_op always attaches a meta.tc id and
never branches on permission level. Even if the bot account were promoted
to full editor, the MCP must still only suggest. Never add a "direct write"
path.
- Edit by the content-anchored cursor, never by raw line numbers. Placement
is
goto_text (content) / goto_file; the cursor is a character offset that
is OT-transformed on every incoming edit (_transform_offset), so it stays
pinned while a human types. Line-number-based mutation is a known bug source
and is banned in the edit tools. select_to_text must search FORWARD from
the cursor (_find_nth_from), not globally — a global search once resolved a
repeated anchor (\end{proof}) to an earlier occurrence and selected backward
over 27k chars. Keep the forward default; backward needs allow_backward.
- Reads are served from the live cache and must not move the cursor. Only
goto_file/goto_text and edits move the cursor. read_file/search/
list_files never do.
- Latency budget: interactive actions target ≤50ms (see below). Anything
that can be served from the in-memory subscription must not do a network
round-trip.
Tracked-change gotchas
- Overleaf merges adjacent same-author tracked ranges. So a change's live
length (from
list_changes) can grow after a neighbouring edit — undoing a
deletion re-inserts text next to an insertion, and the two fuse. Any op that
acts on a change by id (undo_change, revise_change) must not assume the
range is the length you created it at; undo_change takes expect_chars to
assert this. Don't reintroduce blind by-id deletion.
undo_change on a deletion re-inserts (reviewers can't reject), so it is not
truly "zero residual" — it leaves a content-neutral insert+delete pair. Prefer
fixing the selection up front over undo-chaining.
Latency tiers (the ~50ms budget)
Measured medians on a warm connection — keep new code in the right tier:
- Local / cache-served (<1ms, MUST stay here):
list_files, read_file
(any range), search, get_commands, goto_text, goto_file, whoami,
current_project (metadata is cached from list_projects; do not re-fetch
per call). A new read/inspect tool belongs here — serve it from
editor.live_doc / editor.entries, never a fresh joinDoc or HTTP call.
- Network round-trip (inherently >50ms, unavoidable): the edit tools wait for
an Overleaf
applyOtUpdate ack; list_projects hits /api/project (and can
429 if hammered — never call it in a hot path). These cannot be made 50ms; just
don't make them slower and don't call them from a local-tier tool.
- Compile / render (seconds / 100s of ms):
compile_project, get_logs,
get_page_count, screenshot. Out of scope for the budget.
- Known offender:
list_changes re-joins the doc every call
(doc_state(with_ranges=True)) because tracked-change ranges are not kept in
the live cache. Fixing it means maintaining ranges incrementally in the receive
thread — do that rather than adding more re-joins.
Code style
- Google Python style. Match the surrounding code's naming, docstring density,
and idioms. Public methods get a one-line summary +
Args: when non-obvious.
- No
from __future__ import ..., no unused imports. Python is ^3.11; use
native X | None, list[dict], etc.
- Ruff is the linter, line length 100 (
pyproject.toml). ruff check must be
clean before you commit.
- No dead code. Remove it rather than commenting it out.
- Docs track behavior. If you change a tool's behavior, update its docstring,
the server
INSTRUCTIONS, and the docs in the same change. A docstring that
contradicts the code is a bug (we have shipped that mistake — don't).
Running the tests
conda activate /misc/envs/overleaf
python -m pytest -q
ruff check overleaf_mcp/ tests/
- The suite is pure/offline — it stubs the editor and exercises the ops layer
(
tests/test_editor_ops.py), the task-block parser (tests/test_commands.py),
and search/slicing (tests/test_search_and_slicing.py). No Overleaf session
needed; it is the reference for how these units behave.
- When you touch the ops/cursor/diff code, add a case to
test_editor_ops.py
(there is a seeded _diff_to_ops round-trip fuzz — extend it or add targeted
cases). New tool logic that is pure should get a test here too.
- Live/integration checks against a real project (e.g. MIMIK) are done by hand
through the MCP tools; they are not part of the unit suite because they need a
session and mutate a document (as reviewable suggestions).
Where things live
overleaf_mcp/
config.py XDG config/state dirs, global + per-agent TOML, agent-id resolution
auth.py One-time headed login -> .auth/storage_state.json
session.py Cookie-backed httpx session + CSRF + auth-expiry detection
editor.py Socket.IO 0.9 client: live subscription, OT-anchored cursor,
tracked edits, and the pure helpers unit-tested in tests/
commands.py Parse %%bot / %%todo task blocks
client.py Projects, files, search, compile/logs/screenshot, editing + inbox
server.py FastMCP server (stdio): the @mcp.tool()s + INSTRUCTIONS
tests/ Offline unit suite
docs/ARCHITECTURE.md is the design/internals reference; the README's Tools
tables are the authoritative tool list (ARCHITECTURE points to them — don't
re-enumerate every tool in two places).
Adding a tool
- Add the backing method to
client.py (or editor.py for realtime ops),
serving from the live cache if it is a read (keep it in the local latency tier).
- Add the
@mcp.tool() wrapper in server.py with a clear docstring (agents
read these). Keep it a thin pass-through to the client method.
- If any pure logic is involved, add a unit test.
- Document it in the README Tools table (authoritative) and, if it changes a
category, the one-line ARCHITECTURE summary.
ruff check + pytest clean.
Commits
Commit on meaningful units. End commit messages with:
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>