Skip to main content
Glama

LeafMCP

A remote MCP server that lets AI assistants read and edit your Overleaf projects.

Ask Gemini, Claude, or ChatGPT to tighten a paragraph, fix a broken \cite, or update a table — and watch it happen in your actual Overleaf project, not in a chat window you then copy from.

License: MIT Node MCP CI


Table of contents


Related MCP server: Unofficial Overleaf MCP Server

Why this exists

Overleaf has no public API for editing project files, and the AI assistants people actually pay for do not expose an API at all. Three facts shaped this project:

1. Overleaf cannot be embedded in another page. www.overleaf.com sends X-Frame-Options: SAMEORIGIN, so a single-page app with "real Overleaf on the left, chat on the right" is not possible. LeafMCP takes the opposite approach: Overleaf stays open in your browser as usual, and the assistant edits the same project through the server. Reload and the change is there.

2. A Gemini subscription is not API access. Google AI Pro / Ultra are subscriptions to the Gemini app; they do not include quota for generativelanguage.googleapis.com, which is billed separately through AI Studio. "Use my subscription" therefore means letting the web app call tools, which is exactly what MCP is for — rather than holding an API key ourselves.

3. OAuth is the only thing all three clients agree on. ChatGPT documents no way to send a static API key to a custom MCP server; Gemini's custom-app flow is OAuth-only with no token fallback; Claude supports static headers but only in a limited beta. So this server ships a real OAuth 2.1 authorization server (dynamic client registration, PKCE, refresh rotation, RFC 9728 / RFC 8414 discovery). It is not optional — without it the server simply does not connect.

Features

  • 26 LaTeX-aware tools — read, search, edit, rename, compile, undo, change sets, and review comments.

  • Edits land in Overleaf within seconds. Each accepted change is published immediately, so your browser shows it without a manual sync.

  • Atomic revisions. begin_change_set groups a multi-file change so it lands all at once, or not at all.

  • Every edit is versioned. Each change is a git commit in a local mirror; undo_last_edit reverses recent edits with a real git revert and republishes.

  • Fresh reads. A file is re-fetched from Overleaf before it is read or edited, so an edit fails loudly instead of silently overwriting something you changed in the browser.

  • Compile-and-check loop. compile publishes pending edits first, then returns a distilled error/warning summary with line numbers — not a 4,000-line LaTeX log.

  • Review comments are first-class: list them, act on them, reply, resolve.

  • One server, several assistants. No per-client forks or special cases.

  • Read-only mode, per-connection scopes, and OAuth the operator approves by hand.

Architecture

┌────────────────┐   ┌────────────────┐   ┌────────────────┐
│ Gemini web app │   │ Claude /       │   │ Cursor /       │
│ (your plan)    │   │ ChatGPT        │   │ Claude Desktop │
└───────┬────────┘   └───────┬────────┘   └───────┬────────┘
        │  MCP over HTTPS + OAuth 2.1             │ stdio
        └────────────────────┼────────────────────┘
                             ▼
                 ┌───────────────────────┐
                 │   LeafMCP server      │  ← runs on your server + domain
                 │  · 26 LaTeX tools     │
                 │  · OAuth 2.1 AS       │
                 │  · git-versioned      │
                 │    project mirrors    │
                 └───────────┬───────────┘
                             │ Overleaf web session
                             ▼
                 ┌───────────────────────┐
                 │       Overleaf        │
                 └───────────────────────┘

Requests arrive from the vendors' clouds, not from your browser, so the server must be reachable over public HTTPS. localhost will not work for the remote clients (it works fine for the stdio transport).

Quick start

Sign in to Overleaf, open any project, then DevTools → Application → Cookies → copy the value of overleaf_session2.

On self-hosted Overleaf / ShareLaTeX the cookie is usually sharelatex.sid; set OVERLEAF_COOKIE_NAME accordingly.

This value is a full-account credential. It is held in process memory only, never written to disk, and scrubbed from every log line and tool result.

2. Configure

git clone https://github.com/CherryLemon/leafmcp.git
cd leafmcp
cp .env.example .env

Fill in at minimum OVERLEAF_SESSION, PUBLIC_URL, MCP_AUTH_MODE, and MCP_OAUTH_PASSWORD.

openssl rand -base64 24   # generate the OAuth approval passphrase

3. Build and check

npm install
npm run build

node dist/index.js --check              # validate config, list every problem, start nothing
node dist/index.js --verify             # exercise every Overleaf call this server makes
node dist/index.js --verify --compile   # ... and include a LaTeX build
node dist/index.js                      # start the server

Debug in this order. Run --verify before connecting anything: from inside a chat window a bad cookie, an incompatible upstream and a broken MCP setup all look the same ("something went wrong"). The verifier does not write — no upload, no comment — and reports each capability separately, so a failure names the call that broke:

  ok    sign in and list projects — 12 project(s)
  ok    project metadata — 4 top-level entr(ies)
  ok    download the project archive — 812 KB
  ok    read the archive — 37 file(s), e.g. main.tex
  ok    download a single file (main.tex) — 9134 bytes
  ok    list review comments — 2 thread(s)

For a first run, use a project you can afford to lose, and set OLMCP_READ_ONLY=true. Nothing here has been exercised against a live account, so treat the first session as a test of the bridge itself rather than a real editing session.

4. Run the tests

npm test

Deployment

Clients connect from their vendors' clouds, so the server needs a public HTTPS address.

cp .env.example .env
# PUBLIC_URL in .env must match MCP_DOMAIN exactly
MCP_DOMAIN=mcp.example.com docker compose -f docker/docker-compose.yml up -d

Caddy obtains and renews the certificate automatically. docker/Caddyfile disables response buffering — a compile takes tens of seconds, and a buffered response looks like a hung server.

DNS requirement: the hostname needs an IPv4 A record. Claude rejects hosts that only have AAAA records.

Without Docker

Requires Node 22+ and git on PATH:

npm ci && npm run build
PUBLIC_URL=https://mcp.example.com node dist/index.js

Put Nginx or Caddy in front for TLS.

On your own machine, with Cloudflare Tunnel

This is the right shape for a home server or a laptop-as-a-server: cloudflared dials out to Cloudflare, so you need no public IP, no port forwarding, and no inbound firewall rule — and it keeps working when your ISP changes your address or puts you behind CGNAT. Cloudflare terminates TLS, so there is no Caddy in this arrangement.

CF_TUNNEL_TOKEN=<token> docker compose -f docker/docker-compose.cloudflare.yml up -d

The header of docker/docker-compose.cloudflare.yml has the dashboard steps, including the one detail that is easy to get wrong: the tunnel's public hostname must point at overleaf-mcp:8080 (the compose service name), not localhost:8080.

Four Cloudflare settings decide whether this works:

Setting

Value

Why

MCP_JSON_RESPONSES

false

Cloudflare returns error 524 when the origin sends nothing for 125 seconds, and a JSON-mode tool call sends nothing until it finishes — a slow LaTeX compile can trip it. SSE mode starts responding immediately and then streams. Note this is the opposite of the default, which is chosen for a same-host reverse proxy.

Bot Fight Mode

off

The assistants call from cloud IPs with non-browser user agents, so bot protection 403s them — and the client only reports "not a valid MCP server".

Cloudflare Access

do not enable

It requires an interactive login, which server-to-server clients cannot perform. LeafMCP runs its own OAuth; the consent page is opened in your browser, so Access would work for that one step and fail for everything else — a confusing half-broken state.

SSL/TLS mode

Full

Flexible is insecure and a common source of subtle breakage.

Also add a cache-bypass rule for the hostname. POST /mcp is not cached by default, but a stale .well-known/oauth-protected-resource would break OAuth discovery.

Be realistic about uptime: with this arrangement the server is available only while your machine is awake and online. A laptop that sleeps looks like a flaky assistant, and the error messages clients produce are not specific about why.

Connecting clients

The MCP endpoint is always:

https://your-domain/mcp

Gemini web app

  1. Open gemini.google.com in a desktop browser.

  2. Settings → Connected Apps → Custom apps → Add a custom app.

  3. Enter https://your-domain/mcp.

  4. LeafMCP supports dynamic client registration, so you can skip Advanced features → Show more (that is only needed for servers without DCR).

  5. Click Next and approve the OAuth consent screen by entering MCP_OAUTH_PASSWORD.

  6. Mention the app with @appname in a prompt.

Claude

  1. claude.ai → Customize → Connectors → Add → Custom → Web.

  2. Enter https://your-domain/mcp.

  3. Approve the OAuth consent screen.

A free account can add one custom connector. Claude's callback (https://claude.ai/api/mcp/auth_callback) is already allowlisted.

ChatGPT

  1. chatgpt.com/plugins → "+" → Add custom MCP server (older path: Settings → Connectors → Advanced → Developer mode).

  2. Enter https://your-domain/mcp.

  3. Approve the OAuth consent screen.

ChatGPT's callback (https://chatgpt.com/connector_platform_oauth_redirect) is already allowlisted.

Local clients (Cursor, Claude Desktop, MCP Inspector)

Use the stdio transport; no public address needed:

{
  "mcpServers": {
    "overleaf": {
      "command": "node",
      "args": ["/absolute/path/to/leafmcp/dist/index.js", "--stdio"],
      "env": {
        "OVERLEAF_SESSION": "your cookie value",
        "OLMCP_DATA_DIR": "/absolute/path/to/leafmcp/data"
      }
    }
  }
}

Verifying the connection

curl https://your-domain/healthz
curl https://your-domain/.well-known/oauth-protected-resource

Tools

26 tools. Read tools carry readOnlyHint, mutating ones carry destructiveHint, and clients use those hints to decide when to ask for confirmation.

Projects

Tool

Description

list_projects

List every project in the account.

use_project

Select the working project by id, exact name, or a unique substring.

project_overview

File tree, plus the section structure of the main document and any unpublished changes.

Files

Tool

Description

list_files

List project files with sizes.

read_file

Read a file with line numbers; supports start_line / end_line.

read_files

Read several files in one call.

search_project

Search all text files by substring or regular expression.

file_outline

Sections, labels, citations, includes and environments of one .tex file.

edit_file

Preferred. Replace an exact snippet; ambiguous matches are refused, and expected_hash refuses the edit if the file moved on since it was read.

write_file

Write a whole file, creating it if needed.

delete_file

Delete a file (requires explicit confirm=true).

rename_file

Rename or move a file.

pending_changes

List local edits not yet sent to Overleaf.

Build and history

Tool

Description

compile

Compile on Overleaf; returns distilled errors/warnings and the PDF link.

publish_changes

Push pending local edits to Overleaf.

pull_project

Re-download the project from Overleaf.

edit_history

Recent changes, each labelled with its operation id.

undo_operation

Reverse one specific operation, leaving everything after it in place.

undo_last_edit

Reverse the last N edits and republish.

begin_change_set

Open a change set: the edits that follow are published together, or not at all.

commit_change_set

Publish the open change set in one go. A failure leaves it open to retry.

abandon_change_set

Discard the open change set and return the mirror to the state it opened from.

Review comments

Tool

Description

list_comments

List comment threads with file and line.

add_comment

Add a comment, optionally anchored to a line.

reply_to_comment

Reply within a thread.

resolve_comment

Mark a thread resolved.

Design notes

  • Mirror plus per-file sync state. Each project has a locally cached, git-versioned mirror. What Overleaf holds is recorded per file, as a content hash, rather than as one project-wide ref: a single ref cannot describe a state where one file was refreshed while another still has unpublished edits, and the version that tried made the pending file look published. Hashing also keeps git out of the decision, so a failed git add can no longer produce a confident "nothing to publish".

  • One writer per project. Every mutating tool runs under that project's lock, so the whole refresh → read → write → commit → publish sequence is atomic with respect to other clients. Without it, two edits arriving together both read the same base and the second discards the first. Different projects stay fully concurrent. This serialises within one process; two server instances must not share a data directory.

  • A partial publish is an error, not a footnote. If any file fails to reach Overleaf, the tool result is flagged as an error, because a warning inside an otherwise successful response is easy for a model to summarise away as "done" — and the user then believes their paper was updated.

  • Edits can be pinned to a version. read_file reports a sha256: prefix and edit_file accepts it as expected_hash, so an edit written against text that has since changed is refused rather than applied. Overleaf offers no atomic conditional write, so this cannot close the race between the check and the upload — but it turns the common case, "the user edited this after the model read it", into a conflict someone has to look at.

  • Change sets make a revision atomic. For work that spans several files, or that should not be visible half-finished, begin_change_set suspends publishing: the edits accumulate locally and reach Overleaf together on commit_change_set. Without it a five-file revision arrives one file at a time, and anyone reading the paper in between sees a state that never existed as a whole. abandon_change_set discards the lot, and nothing was ever published, so there is nothing to undo remotely.

  • Every mutating call is an operation with an id. Results and edit_history report it, and undo_operation reverses exactly that one, leaving later work alone. It reverts rather than resets, so the original edit stays in history and a mistaken undo is itself undoable. When a later edit has rewritten the same lines, the reversal is refused with the conflict reported — guessing which version was meant is how a tool destroys work it was asked to protect.

  • Undo is real. Every tool edit is a commit, and undo_last_edit uses git revert, so an undo is itself undoable. It deliberately refuses to reverse a reload from Overleaf — that would push a stale copy over whatever you changed in the browser.

  • Freshness before use. A file is re-fetched from Overleaf before being read or edited (whenever nothing is unpublished locally). If the text no longer matches, the edit fails loudly. Costs one request per read; disable with OLMCP_FRESH_READS=false.

  • Publish before compile. Compilation runs on Overleaf's servers, so pending edits are pushed first — otherwise "does it still compile?" would answer a question about the old file.

  • Log distillation. A thesis log runs to thousands of lines of which about twenty matter, so only errors (with l.NN line references), undefined references, and warning counts survive.

  • Path safety. Every model-supplied path is validated against .., absolute paths and drive letters, and zip entries that would escape the project root are skipped.

Configuration

All configuration is environment variables; see .env.example for the annotated list. The essentials:

Variable

Default

Purpose

OVERLEAF_SESSION

—

Required. The overleaf_session2 cookie value.

PUBLIC_URL

http://localhost:8080

Exact public HTTPS origin. OAuth metadata is built from it.

MCP_AUTH_MODE

bearer

oauth, bearer, or none. Use oauth for the web clients.

MCP_OAUTH_PASSWORD

—

Required for oauth. The passphrase you type on the consent screen.

MCP_BEARER_TOKENS

—

Required for bearer. Comma-separated static tokens.

OVERLEAF_BASE_URL

https://www.overleaf.com

Point at a self-hosted instance.

OVERLEAF_EMAIL / OVERLEAF_PASSWORD

—

Self-hosted only: silent re-login when the cookie expires.

OLMCP_AUTO_PUSH

true

Publish each accepted edit immediately.

OLMCP_READ_ONLY

false

Refuse every mutating tool.

OLMCP_FRESH_READS

true

Re-fetch a file from Overleaf before reading or editing it.

OLMCP_DATA_DIR

./data

Where project mirrors and OAuth state live.

TRUST_PROXY

1

Number of reverse proxies in front. Set to 0 if directly exposed.

LOG_LEVEL

info

debug, info, warn, error. Logs go to stderr.

Security model

This server is equivalent to remote write access to your Overleaf account. Treat it that way.

  • Credentials. The session cookie lives in memory only. Every log line and every tool result passes through redaction, including the configured cookie value itself.

  • Approval passphrase. MCP_OAUTH_PASSWORD is the only thing standing between a stranger and your papers. The server refuses to start in oauth mode without it.

  • Callback allowlist. Dynamic client registration only accepts known vendor callbacks and loopback addresses. Without that, the server would be an open redirector handing out authorization codes.

  • Tokens. Opaque and server-side, so revocation is immediate. Refresh tokens rotate, and replaying a rotated token revokes its whole chain.

  • Least privilege. OLMCP_READ_ONLY=true disables every mutating tool, and OAuth scopes (overleaf.read, overleaf.write) let you hand out a read-only connection.

  • Rate limiting. Registration, token, and consent endpoints each have their own budget.

  • Destructive operations. delete_file requires explicit confirmation, a push refuses to empty a project outright, and every edit is reversible.

Suggested rollout: start with OLMCP_READ_ONLY=true and let the assistant read for a while before you grant write access.

Security review

This codebase has been through an adversarial review with live reproduction. Every finding below is fixed and covered by a regression test (npm test, the Regression sections):

Severity

Issue

Fix

Critical

.git was reachable through the file tools. That is a command-execution primitive rather than mere repository corruption: git runs programs named by core.fsmonitor, core.hooksPath and filter.*.clean, and git add -A triggers them — confirmed against git 2.47, where all three vectors executed a payload. A prompt injection reaching one write call would have become code execution on the host.

Git's directory now lives outside the work tree (verified: the work tree contains no .git at all), the path layer rejects every .git* name, archives with such entries are skipped, and the git child runs with a minimal environment, no global config, and hooks and fsmonitor disabled.

Critical

Under MCP_AUTH_MODE=bearer (the default) the OAuth routes were still mounted, and that mode does not require MCP_OAUTH_PASSWORD — so an empty passphrase matched an empty passphrase, letting anyone self-register a client, approve it, and mint a token with full account access.

An empty passphrase never authorizes. OAuth routes are mounted only in oauth mode, and tokens issued earlier stop working outside it.

High

pull_project wiped the mirror before writing, so a failure part-way through left a partial tree; the next push read the missing files as deletions and removed them from Overleaf. The rollback added in the first fix was itself wrong — it deleted work-tree entries that had not been backed up yet, destroying the files it existed to protect.

Extract to a temporary directory and swap atomically. The rollback removes only what was installed and restores from the backup's actual contents. A push also refuses to delete every file at once.

High

compile auto-publishes pending edits but was the one write path with no read-only check, so a read-only connection could upload and delete files through it.

Read-only check added.

High

Publishing continued into the deletion phase after an upload failed. A rename is an upload plus a delete, so this left the project with neither the new file nor the old one — silent data loss from an operation the user read as a rename.

Any upload failure cancels the deletion phase; the change stays pending and the outcome reports that deletions were held back.

High

The consent screen was unthrottled, so the approval passphrase could be brute-forced.

Rate limited.

Medium

changes() returned an empty list on a git error (fail-open), silently skipping pushes and disabling the guard that protects unpublished edits.

Now throws (fail-closed).

Medium

undo_last_edit would reverse a reload from Overleaf, pushing a stale copy over browser edits and reporting success.

Only edit commits are undoable; reloads and refreshes are refused explicitly.

Medium

Refresh tokens could be replayed concurrently (both requests minted new tokens), and family revocation never actually matched — so a replayed token was merely refused while the stolen successor kept full access, contradicting what the README claimed.

Tokens carry a grant id; the claim is atomic; a replayed token revokes every token in its grant, access tokens included, so revocation is immediate. The regression test asserts the successor dies, not just that the old token is refused.

Medium

trust proxy: true trusted every proxy, so X-Forwarded-For could be spoofed to evade rate limiting.

Configurable hop count (default 1); true is unreachable.

Medium

A failed write permanently poisoned the OAuth store's write chain, breaking all later writes.

The chain stays settled; a single failure no longer propagates.

Medium

search_project compiled a model-supplied regex and ran it per line on the event loop, so catastrophic backtracking hung the whole server.

Nested-quantifier patterns rejected, plus a per-line cap and a time budget.

Medium

edit_file passed replacement text through String.replace, which expands $$, $& and $1. Writing $$E = mc^2$$ produced $E = mc^2$ — display math silently destroyed by the one tool whose job is editing LaTeX. The two replacement branches also disagreed.

A function replacer makes the text literal in both branches.

Medium

The ReDoS guard was a heuristic for nested quantifiers, and `(a

aa)+$` walked past it. Measured: 36 characters took ~6 seconds and 40 never finished, against a 4,000-character line cap — so one line of one file could wedge every request.

Medium

Dynamic client registration is unauthenticated by design, and nothing bounded it: client_name had no length limit, there was no cap on the number of clients, and the endpoint accepted the global 4 MB body. Each stored client is rewritten in full on every token operation, so this was an anonymous way to make the server do unbounded work.

Per-field length limits, a cap on registered clients, and a 16 KB body limit for the OAuth routes (the MCP route keeps 4 MB because a write_file call carries a file).

Medium

Project import had no limits. pull holds the whole archive in memory, so a project with a hundred thousand entries, one enormous entry, or a deeply nested path was enough to exhaust the process.

Entry count, per-entry and total extracted size, and path depth are all bounded; the declared size is checked before decompressing and the real size after, since a crafted archive can understate it.

Medium

The consent screen could be framed and cached. It is the one page where a click decides whether a stranger gets write access to someone's papers.

CSP with frame-ancestors 'none' and form-action 'self', plus X-Frame-Options: DENY, Cache-Control: no-store, a referrer policy and nosniff. There is no script on the page at all.

Low

The Docker build used npm install while CI used npm ci, so an image could contain dependency versions no lockfile ever pinned.

Both stages use npm ci.

High

Sync state was a single project-wide commit ref, so refreshing one file from Overleaf advanced the baseline past every file — another file's unpublished edit then looked already published and was never uploaded. Reproduced: an unpublished a.tex disappeared from pending_changes when b.tex was refreshed.

Sync state is now a per-file manifest of content hashes. Updating one entry cannot speak for another file, and the regression test asserts the pending file survives.

High

Change detection asked git, which fails open: git add's exit code was ignored, so a failed staging (a full disk, an unreadable file) could leave the index untouched and git diff --cached then reported no changes — the server would announce "already synced" about edits it had never sent, and the same empty list disabled the guard protecting unpublished work.

Detection hashes the files directly. Git is no longer in the trust path for deciding what to publish.

High

Read-only tools were outside the project lock, yet reading initialises the mirror, pulls it and refreshes files — all mutations. A read could race an edit and advance sync state on its behalf.

Every tool that can name a project now takes that project's lock. compile scopes its own critical section so a minute-long remote build does not block other clients.

Medium

Revocation deleted token rows but recorded no status, so a refresh already in flight when the grant was revoked could still write fresh, valid successors. Deleting records is a snapshot, not a state.

Grants have a durable revoked marker; minting consults it inside the same synchronous block as the insert, so a late write is refused rather than resurrecting the grant.

Medium

Upgrading from the old in-tree .git layout had no migration: the mirror looked uninitialised, so the next pull replaced the work tree — discarding unpublished edits — and the old configuration, which had lived inside the assistant-writable tree, would have been trusted.

Migration moves the git directory out, replaces the old config rather than reading it, clears hooks, and rebuilds the manifest from the old watermark so unpublished files stay unpublished. A work tree with files but no sync record now refuses to be pulled over.

Medium

The rate limiter kept one bucket per client address with no ceiling, and cleaned up by scanning the whole table once it grew past 1,000 keys — so the defence became the attack. The obvious fix, evicting the oldest bucket, is worse: an attacker rotating addresses evicts the record of their own abuse and starts over.

The table is capped and expired entries are dropped from the front (O(1) amortised, since keys are inserted in expiry order). Once full, unknown addresses share one overflow budget rather than displacing existing ones.

Medium

A full client table blocked every new registration until an operator edited a JSON file by hand; registrations accumulated with no reclamation.

Registrations that hold no live token and have no authorization in progress are reclaimed to make room. A client mid-consent is never reclaimed, and a reclaimable one simply registers again.

Medium

The isolated search process read files itself, so it inherited none of the read tool's limits — one large asset could be pulled into memory per search, and searches could be multiplied into as many processes as there were requests.

Per-file and total byte budgets inside the worker, plus a ceiling on concurrently running searches.

Medium

delete_file on a directory removed everything inside it, published the deletions, and reported success — the caller had named a folder, not its contents.

A directory is refused on the first attempt and its contents are listed; recursive=true is required to proceed, and the result states how many files went.

Medium

Only a target's final path segment was checked for being a symlink, so a linked directory let a write land outside the project while the path still looked contained.

The resolved ancestor chain is checked against the resolved project root, for writes, moves and deletions. The remaining check-to-open window is documented rather than claimed closed.

Medium

Credential redaction registered only the literal secret, so a value that travelled percent-encoded appeared in logs in a form the pattern did not match.

Secrets are registered with their decoded variant as well, one level deep — repeated decoding of attacker-influenced text is its own problem.

Low

Extra callback patterns were matched as regular expressions against a URL, so an unescaped . in a hostname quietly widened what was accepted, and an unanchored pattern matched a URL that merely contained the callback as a parameter.

Exact URLs are the preferred configuration (MCP_OAUTH_REDIRECT_URLS); patterns are validated at startup and the server refuses to run unanchored or unescaped ones, with --check explaining why.

Low

Four OAuth settings (MCP_OAUTH_CLIENT_ID, _CLIENT_SECRET, _AUTHORIZE_URL, _TOKEN_URL) were parsed but never read, implying a pre-registration mode that does not exist.

Removed. This server is the authorization server, so those settings had no meaning to implement.

Low

Session re-authentication matched a bare login substring, so an unrelated error mentioning it could tear down a working session; and an old expired call cleared whichever session was current, discarding one another call had just rebuilt.

Matching is phrase-based against the client's actual wording, and only the session that failed is discarded.

Low

Dot files were hidden from listings but readable by name, and an extensionless one like .latexmkrc was classified as binary and refused.

Only git's own names are hidden; a leading dot is no longer treated as an extension. Listing and reading now agree.

Low

OAuthStore initialised from a shared constant with a shallow copy, so every instance pointed at the same nested clients, tokens and revoked objects. One store mutating another's state went unnoticed because production runs a single instance.

A factory function returns genuinely independent state. Found by a test that seeded two stores.

Low

use_project had no read-only check and writes global state, so a read-only connection could retarget another client's edits.

Read-only check added.

Low

MCP_AUTH_MODE=none granted anonymous write access.

Read-only by default; requires OLMCP_ALLOW_ANONYMOUS_WRITE=true to opt in.

Low

Static tokens were compared in non-constant time; redaction missed custom cookie names; successful tool output bypassed redaction.

Constant-time comparison, runtime-registered secrets, output redacted too.

The same review confirmed these were already correct: authorization codes are single-use and expiry-checked; PKCE is S256-only and bound to both client_id and redirect_uri; the redirect allowlist is enforced at registration and both authorization steps (12 hostile URIs rejected, no open redirect); path traversal (absolute, drive letters, UNC, ..) is rejected; zip-slip entries are skipped rather than extracted.

Troubleshooting

Symptom

Cause and fix

Client says "not a valid MCP server"

Clients POST initialize straight to the registered URL and never use legacy SSE. Make sure you entered https://domain/mcp (not /sse) and that /healthz is publicly reachable.

Tool calls fail after roughly two minutes (Cloudflare error 524)

The origin sent nothing for 125 seconds. Set MCP_JSON_RESPONSES=false so responses stream; a JSON-mode call stays silent until the tool finishes.

"Not a valid MCP server" only when behind Cloudflare

Bot Fight Mode is rejecting the assistants, which call from cloud IPs with non-browser user agents. Turn it off for the zone, or add a WAF skip rule for the hostname.

Connection times out

Requests come from the vendors' clouds. Check for an IPv4 A record, a valid certificate, and no firewall rule limited to your own IP. Anthropic's egress range is 160.79.104.0/21.

Consent page will not open, or authorization fails

PUBLIC_URL must match the address clients use exactly. The resource in the discovery metadata must equal the MCP URL precisely; clients validate it strictly.

Tools fail with a session error

overleaf_session2 changes when you log out or the session expires. Copy it again. Self-hosted instances can set OVERLEAF_EMAIL / OVERLEAF_PASSWORD for automatic re-login.

Not sure which part is broken

Run node dist/index.js --verify. It reports each Overleaf call separately and writes nothing, so the failing line names the problem.

Changes do not appear in Overleaf

Check pending_changes. If OLMCP_AUTO_PUSH=false, you need an explicit publish_changes. Failed files are listed with reasons in the tool result.

Compile fails but the local file looks fine

compile publishes first and reports an error rather than compiling stale content. Check pending_changes.

Set LOG_LEVEL=debug to see per-call timings and failure reasons.

Development

npm install
npm run typecheck      # types only
npm run build          # compile to dist/
npm test               # build, then the full end-to-end suite
npm start              # run the built server

For a development loop, run these in two terminals:

npm run build:watch    # tsc --watch
npm run dev            # builds once, then restarts on every rebuild

Everything runs from dist/. Node's built-in type stripping does not rewrite the ./x.js import specifiers that tsc requires in ESM output, so running src/ directly needs an extra resolve hook or a runner such as tsx — this project stays on plain tsc output rather than adding a dependency for it. The one exception is the search worker (src/tools/search-worker.ts), which imports nothing but Node built-ins so it can also be launched from source.

Layout

src/
  index.ts              Entry point: validate config, choose the http or stdio transport
  bootstrap.ts          Redirects a dependency's config dir; must be imported first
  config.ts             All configuration and its validation
  auth/                 OAuth 2.1 authorization server
    provider.ts           Authorization codes, PKCE, DCR, token issue and verify
    store.ts              Persistence for clients, codes and tokens
    routes.ts             Discovery metadata, consent screen, token endpoints
    middleware.ts         Bearer enforcement and the 401 challenge
  overleaf/
    session.ts            Authenticated client with re-auth on expiry
    registry.ts           Project resolution, active project, workspace lifecycle
    workspace.ts          The git mirror: pull, change detection, push, undo
    latex.ts              LaTeX structure and summaries
    latexlog.ts           Compile-log distillation
    journal.ts            Operations and change sets, for undo by id
  tools/                26 tool handlers, decoupled from the MCP framework
    search.ts             Spawns and supervises the isolated search
    search-worker.ts      Runs the pattern somewhere it can be killed
  transport/http.ts     Express app: MCP endpoint, OAuth, health check
test/smoke.mjs          End-to-end suite (273 checks)
docker/                 Dockerfile, compose, Caddyfile

Tool handlers are plain (ctx, args) => Promise<string> functions bound to the MCP SDK in one place (tools/index.ts), so the entire tool surface is testable without speaking JSON-RPC.

The suite needs no Overleaf account: the Overleaf side is a stub client that records calls, which is what makes the dangerous logic — push, undo, refresh, mass deletion — verifiable locally and repeatably.

Limitations

  • Binary files cannot be read or written through the tools; text sources (tex, bib, cls, sty, …) can.

  • Concurrent edits to the same file are handled conservatively. A file is re-fetched before use, so an edit based on stale text fails instead of overwriting. But if someone else changed a different file, a push still applies the local mirror's view of it. In collaborative projects, pull_project first.

  • Compilation uses Overleaf's compiler; there is no local LaTeX toolchain.

  • Tool paths are text-only and size-limited (OLMCP_MAX_FILE_BYTES, default 2 MB).

Acknowledgements

The Overleaf side is built on @aloth/olcli (MIT), which talks to Overleaf's web session interface rather than the Git integration. That means LeafMCP works on Overleaf free plans as well as self-hosted instances — Git integration is not required.

License

MIT © 2026 CherryLemon

Related MCP Connectors

Related MCP Servers