Typleaf MCP Server
๏ปฟ# ๐ฟ Typleaf MCP Server
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for **[Typst](https://typst.app)** projects on **[Typleaf Pro](https://github.com/superzeldalink/typleaf-pro)** โ a self-hostable Overleaf fork with Typst support.
**28 tools** covering full CRUD, document structure analysis, git history & diff, compilation, PDF download, PDF-layout perception, and citation verification.
Typleaf compiles both Typst and LaTeX, so this server handles **both**. The right parser is chosen from the file extension โ you never have to say which.
```
You: "What kind of project is 6a6dfc57bbb3aac01ed9a71d?"
AI: [project_info] Typst, root main.typ, 36 .typ files, bibliography refs.yml
You: "Read sections/03-method.typ"
AI: [read_file] Here's the content: โฆ
You: "Tighten the Background section"
AI: [update_section] โ Edited and pushed
You: "Compile it and tell me how many pages"
AI: [get_page_count] 61 pages (format=typst, source=pdf)
You: "Which page does each heading start on?"
AI: [section_page_map] p.1 = Introduction โฆ p.8 == Parameters โฆ
```
> This is a fork of [`overleaf-mcp-plus`](https://pypi.org/project/overleaf-mcp-plus/) by [rangehow](https://github.com/rangehow/overleaf-mcp), retargeted at self-hosted Typleaf. See [What changed from the upstream](#-what-changed-from-the-upstream).
---
## ๐ Setup
### 1. Install
```bash
pip install "typleaf-mcp[compile]"
```
Or run without installing:
```bash
uvx --from "typleaf-mcp[compile]" typleaf-mcp
```
### 2. Get your credentials
| Variable | Required for | Where to get it |
|---|---|---|
| `TYPLEAF_BASE_URL` | **everything** | Your instance's URL, e.g. `https://typleaf.example.com` |
| `TYPLEAF_SESSION` | **everything else** โ read, write, compile, PDF | DevTools โ Application โ Cookies โ *your instance* โ `overleaf.sid` โ Value |
| `TYPLEAF_GIT_TOKEN` | *optional* โ commit history only | `<base>/user/settings` โ Git Integration โ Create Token |
> **You do not need a git token.** Typleaf's git bridge is an optional module, and many deployments don't run it โ on those, no token exists to set. This server reads and writes with the session cookie alone (see [Backends](#-backends)). Only `list_history`, `get_diff` and `sync_project` need git, and they explain the gap rather than failing cryptically.
> **Cookie name:** Overleaf ships two. Community Edition โ which Typleaf is built on โ sets `overleaf.sid`; the hosted service sets `overleaf_session2`. Guessing wrong looks exactly like an expired session, so this server sends **both** names with your value unless you pin one via `TYPLEAF_SESSION_COOKIE`. Just copy whichever your instance shows.
> **`TYPLEAF_BASE_URL` has no default, on purpose.** This server sends your session cookie and git token to whatever host it is pointed at. Defaulting to `www.overleaf.com` โ as the upstream does, correctly, for a single-host service โ would mean an unconfigured install leaks a private instance's credentials to a third party. So it errors instead. The `OVERLEAF_*` spellings of all three are accepted as fallbacks, so an existing `overleaf-mcp` config only needs its base URL changed.
The session cookie is `HttpOnly`: you must copy it from the DevTools **Cookies** panel, not the JS console.
Git tools additionally need the **git-bridge module enabled** on your deployment. Without it, clones 404 even though every project is visible in the web UI โ the error message says so.
### 3. Register the server
```json
{
"mcpServers": {
"typleaf": {
"command": "typleaf-mcp",
"env": {
"TYPLEAF_BASE_URL": "https://typleaf.example.com",
"TYPLEAF_SESSION": "s%3A...",
"TYPLEAF_GIT_TOKEN": "olp_..."
}
}
}
}
```
---
## ๐ Backends
The server picks its backend from what your deployment actually offers:
| | **web** (cookie only) | **git** (bridge enabled) |
|---|---|---|
| Selected when | no `TYPLEAF_GIT_TOKEN` | token is set |
| Read / write files | โ
| โ
|
| Commit messages | โ โ edits land as ordinary project changes | โ
|
| `list_history` / `get_diff` / `sync_project` | โ | โ
|
`status_summary` reports which one is live.
**How the web backend writes.** There is no HTTP endpoint that sets a document's content โ `setDocument` exists but sits behind service-to-service auth, and the editor itself writes over WebSocket using operational transform. But `POST /Project/<id>/upload` is cookie-authenticated and *upserts*: uploading a name that already exists replaces that entity in place, keeping its id, and a text file comes back as an editable `doc` rather than a binary attachment.
The catch is that upload needs a `folder_id`, and no HTTP endpoint exposes folder ids โ `/entities` gives paths only, `/metadata` gives doc ids only, and creating a folder that exists returns `400 file already exists`. They arrive only in the real-time service's `joinProject` payload, over **socket.io 0.9** โ a protocol no maintained Python client speaks. So `realtime.py` implements the four frame types needed to read the tree, and writes go over plain HTTP afterwards. That sidesteps implementing operational transform, which is the genuinely hard part of talking to Overleaf's editor.
**Editing an existing document goes through operational transform, not upload.** An edit is sent as the *operation* โ `{"p": 12, "d": "Original"}, {"p": 12, "i": "EDITED"}` โ down the same WebSocket the editor uses, so a collaborator with the file open sees it appear in place. Verified with a second client watching: it receives the op live. A one-word change in a 2,430-character file transmits **7 characters**, so cursors, selections and the track-changes attribution of untouched text all survive.
Upload is still used where there is no document to edit yet โ new files and binary assets.
One wrinkle worth documenting, since it looks alarming in logs: this server's socket.io 0.9 stack sends its ack in a frame that sets the MASK bit, which RFC 6455 forbids a server to do, and strict clients close the connection rather than read it โ *after* the edit has already applied. So the write is confirmed by **reading the document back**, not by the ack. That's a stronger check anyway: it tests the result rather than the transport.
**Multi-file changes are all-or-nothing.** Use `write_files` rather than looping over `rewrite_file`. Every edit is validated and resolved *before* anything is written, so the common failures โ missing file, search string matching twice or not at all, duplicate path, bad name โ touch nothing at all. If a write still fails, the files already written are restored.
It's a compensating transaction, not a real one (Typleaf has no multi-document transaction and no cookie-reachable version restore), so two limits are reported rather than hidden: rollback can itself fail, in which case the result names exactly which files are in which state; and intermediate writes were real, so a collaborator watching may have seen a state that was later undone.
## ๐ฏ Typst specifics
### The compiler setting is what makes a project Typst
Typleaf's CLSI generates a Typst sync map only when the project's `compiler` is `typst` **and** the root document ends in `.typ`. A project full of `.typ` files that is still on the default `pdflatex` fails to compile with TeX errors that never mention Typst, and every PDF-position tool silently returns nothing.
```
You: "Make a new Typst paper"
AI: [create_project name="Paper" compiler="typst"] โ
```
or on an existing project: `set_compiler(project_id, "typst")`.
### Headings
`get_sections`, `update_section` and `section_page_map` recognise `=`-markup headings at column 0 โ **exactly** the rule Typleaf's own editor outline uses, so the sections this server reports are the ones you see in the IDE's outline pane. Raw blocks, line comments and (nested) block comments are skipped, so a `= ` inside a code sample is not mistaken for a section.
`#heading(level: n)[โฆ]` calls are found by `project_info` but are deliberately **not** editable via `update_section` โ rewriting the body of a programmatically generated heading is not a text-span operation.
### Compile logs
Typst writes no log file; it reports everything on stderr, which CLSI captures into `output.log`. `download_log` parses those diagnostics into a compact list with `file:line:column`:
```
Typst compile log โ 1 error(s), 1 warning(s)
โ expected comma (sections/05-parameters.typ:13:74)
โ unknown font family: calibri
```
Pass `raw=true` for the unparsed text.
### PDF-position tools, and their honest limits
`locate_in_pdf` and `section_page_map` work for Typst, backed by Typleaf's `TypstSyncManager` rather than SyncTeX. Four differences are real and are reported rather than hidden:
- **Coarser.** Typleaf injects zero-width `#metadata` markers into the sources after compiling and asks `typst query` where they landed. Anchors are per source *line*, but only where a marker was safe to place โ a lookup resolves to the enclosing block. Content in `#let`/`#show` template bodies gets no anchor at all, and a `#for` body gets one anchor for the whole loop.
- **Slower.** `output.typst-sync.json` is listed among the compile outputs but the web tier will not proxy it (verified: it 404s while `output.pdf` from the same build serves fine), so there is nothing to parse offline. A LaTeX project resolves every heading from one `output.synctex.gz` parse; a Typst project costs **one request per heading**. `section_page_map` is therefore capped at 150 headings by default (`TYPLEAF_MAX_SECTIONS`) and says so when the cap bites.
- **Headings are queried via their body prose, not their own line.** A document with `#outline()` renders every heading twice, and Typleaf's sync map keeps whichever copy fits the file's document-order trajectory โ which fails for the *first* heading in each file, since there is no prior trajectory. Measured on a real 104-page document, `= Functions` resolved to page 2 (the contents) against a true page of 12. Prose is never duplicated into an outline, so that is what gets queried. Any residual backwards jump is flagged `!` in the output rather than presented as fact.
- **No text-area figures.** `text_area_fill_pct` / `text_area_remaining_pt` come from the LaTeX `geometry` package's log dump. Typst reports no page geometry, so those fields are absent for Typst rather than guessed. Physical-page fullness (from the PDF MediaBox) is still reported.
`section_page_map` follows `#include` through the whole document by default โ a Typst root is usually a thin index whose own heading count is zero. Pass `file` to map a single file instead.
Page counts for Typst are measured by parsing the PDF, since typst prints no "Output written on โฆ (N pages)" line. And a Typst compile that produces no diagnostics writes **no log at all**, which `download_log` reports as success rather than as a missing file.
### Bibliographies
Typst reads BibTeX **and** its native [Hayagriva](https://github.com/typst/hayagriva) YAML. `verify_citations` handles both: `.yml` entries are translated to the minimal BibTeX the verifier reads (title, DOI, arXiv id, author, year, journal โ the fields a verdict actually depends on).
Discovery differs from the LaTeX side for a reason: `.yml` is an ambiguous extension, and a bare scan would feed a GitHub Actions workflow to the verifier. So for Typst the tool reads the `#bibliography(โฆ)` call out of the source โ walking the `#include` graph, since a `#bibliography` in a `back-matter.typ` is a common layout โ and falls back to a `.bib` scan only if none is declared. The report says which route it took.
For Typst projects it also lists **cited-but-undefined keys**, which render as broken `?` references. LaTeX shouts about these at compile time; Typst's warning is easy to miss. Label cross-references (`@fig-plot` pointing at `<fig-plot>`) are excluded project-wide, so a well-labelled document does not report its own labels as missing citations.
---
## ๐ Tools (29)
### Orientation
| Tool | Description |
|---|---|
| `project_info` | Format (Typst/LaTeX), compile root, file counts, declared bibliography. Cheap โ no compile. **Call this first.** |
| `list_projects` | All projects on the instance |
| `status_summary` | Format, file counts, heading structure of the root |
### Read
| Tool | Description |
|---|---|
| `list_files` | List files, optionally filtered by extension |
| `read_file` | Read a file's contents |
| `search_files` | Regex search across every text file โ one request regardless of project size |
| `get_sections` | Heading structure with hierarchy and previews |
| `get_section_content` | One section's full text by title |
| `verify_citations` | Verify DOIs/arXiv ids against CrossRef & arXiv; BibTeX + Hayagriva |
### Write
| Tool | Description |
|---|---|
| `create_project` | New project, optionally with `compiler="typst"` |
| `create_file` | New file; auto-creates parent folders |
| `edit_file` | Surgical exact search-and-replace (like `sed`) |
| `rewrite_file` | Replace a file's entire contents |
| `update_section` | Replace a section's body, preserving its heading |
| `write_files` | Apply a coordinated change across several files, all-or-nothing |
| `upload_file` | Upload a local binary (images, PDFs) |
| `delete_file` | Delete a file |
| `set_compiler` | Switch the project's compiler (`typst`, `pdflatex`, โฆ) |
### History
| Tool | Description |
|---|---|
| `list_history` | Commit log, filterable by file and date |
| `get_diff` | Diff between refs or the working tree |
| `sync_project` | Pull the latest changes |
### Compile & output
| Tool | Description |
|---|---|
| `compile_project` | Trigger a compile; returns status + output files |
| `download_pdf` | Save the compiled PDF locally |
| `download_log` | Compile log โ parsed Typst diagnostics, or the raw TeX log |
| `download_source_zip` | Save the project source as a `.zip` |
| `download_source` | Extract the project source into a directory |
### Layout
| Tool | Description |
|---|---|
| `get_page_count` | Total pages in the compiled PDF |
| `locate_in_pdf` | Where a source line lands: page + rectangles |
| `section_page_map` | Every heading โ its page, plus last-page fullness |
All writes commit and push immediately. Every tool is annotated with MCP safety hints (`readOnlyHint` / `destructiveHint` / `idempotentHint`), and a startup check refuses to run if any tool is left unclassified.
---
## ๐ What changed from the upstream
`overleaf-mcp-plus` targets hosted overleaf.com and LaTeX. The retarget touched five areas:
**New โ Typst support**
- `typst.py` โ heading, include/import, bibliography and citation parsing, matching Typleaf's own editor rules
- `document.py` โ extension-based dispatch so every tool serves both formats from one code path
- `typst_log.py` โ parser for `typst compile` diagnostics
- `hayagriva.py` โ `.yml` bibliography โ BibTeX for the verifier
- `layout.py` โ a second backend using Typleaf's Typst sync map, with the SyncTeX path untouched for LaTeX
- New tools: `project_info`, `set_compiler`; `create_project` gained a `compiler` argument
**Self-hosting**
- `TYPLEAF_BASE_URL` is required, with no default (see above)
- The git bridge is on the instance's own origin at `/git/<id>`, not a separate `git.overleaf.com` host
- Clone failures name the git-bridge module as a possible cause, with the token redacted from the URL
- The local-copy sidecar records which *instance* a checkout came from, not just the project id
**Writes that an open editor survives**
Upstream replaces a file over HTTP. That works, but any collaborator with the
file open gets *"this file has gone out of sync"* and loses their place. This
fork edits through the real-time channel instead โ a minimal ShareJS diff, so
the change streams into open editors the way a human's typing does.
- `ot.py` โ minimal insert/delete components, emitted in reverse document order so every offset stays valid, and replayed locally before transmission
- `realtime.py` / `polling.py` โ socket.io 0.9 clients (WebSocket and xhr-polling). Typleaf's git bridge is an optional module, so on an instance without it the real-time service is the only place folder and document ids exist
- `transaction.py` โ multi-file edits validated up front and rolled back on partial failure, so a broken compile is not the way you learn a write failed
- `search_files` โ regex search server-side, instead of listing and reading candidates one at a time
The awkward one is an encoding bug in Typleaf's real-time service: it serves
document lines decoded as Latin-1 while its own ShareJS offsets are over the
correctly-decoded string. Diff against the text as delivered and every offset
past the first non-ASCII character is wrong โ inserts validate nothing and land
silently in the wrong place, deletes cannot match and the server drops the
connection. Pure-ASCII files are byte-identical either way and work perfectly,
which makes it look like a size or permissions fault. `ot.decode_doc_text`
undoes the rendering; it is idempotent, so it stays correct if the instance is
ever fixed to serve UTF-8.
**Bug fixes carried into the fork**
- `download_log` was unreachable upstream: a bad merge left its return statements orphaned inside `section_page_map`, so the tool fell through to `Unknown tool: download_log`
- The credential guard now reports a missing base URL rather than only the missing cookie
**Kept as-is** โ git client, LaTeX parser, SyncTeX engine, thread-safe per-project locking, MCP SDK v2 handler registration, tool safety annotations.
---
## ๐งช Development
```bash
git clone <this repo> && cd typleaf-mcp
python -m venv .venv && .venv/bin/pip install -e ".[compile]" pytest
.venv/bin/python -m pytest tests -q
```
391 tests, no external network. `tests/conftest.py` points the package at a `.invalid` host, so any test that escapes its stubs fails with a DNS error instead of reaching a live server.
`tests/test_integration_fake_instance.py` runs a stand-in Typleaf on a real socket and asserts on the *requests it receives* โ that the session cookie is sent, that writes carry the CSRF token, that per-build output URLs carry `?clsiserverid`, that `sync/code` gets a plain project path, and that no `.synctex.gz` is ever requested for a Typst project. Those are the wire details a mocked `httpx` cannot cover, and where this package's bugs historically live. It is the slow part of the suite (~18s) because each call opens a fresh connection.
`tests/test_verify_citations.py` skips unless `tofu-search` is installed; the discovery and reporting logic this fork adds is covered separately in `tests/test_verify_discovery.py`, which has no such dependency.
---
## ๐ License
MIT. Not affiliated with Overleaf, Inc., Digital Science, or the Typst project. Typleaf Pro is an independent community project.
TDQS
Scored across 29 tools
Most tools target a distinct action/resource combination, and the descriptions clearly separate edit_file, rewrite_file, update_section, and write_files. status_summary, project_info, and get_sections have some overlap in reporting headings or project overview, but the descriptions provide enough guidance to choose correctly.
The tool set predominantly follows a clear verb_noun pattern such as list_files, create_file, delete_file, and download_pdf. A few noun-style names like status_summary, project_info, and section_page_map are minor deviations that do not obscure the overall pattern.
With 29 tools, the surface exceeds the 25+ threshold and feels heavy for an agent to navigate. Many tools are individually justified, but there are several closely related variants around editing, downloading, and PDF mapping that increase selection cost.
The core document lifecycle is well covered: project creation, file CRUD, editing, compilation, PDF/log retrieval, page mapping, citation verification, and git operations. Minor gaps such as deleting or renaming projects and moving files are workaround-able and not central to the primary compile-edit workflow.