overleaf-mcp-integration 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ """Installable Overleaf MCP server package."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.1.0"
overleaf_mcp/client.py ADDED
@@ -0,0 +1,384 @@
1
+ import base64
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+ import threading
6
+ import time
7
+ from collections.abc import Iterator
8
+ from contextlib import contextmanager, suppress
9
+
10
+ import git
11
+ from filelock import FileLock, Timeout
12
+
13
+ from .config import config
14
+ from .constants import IDENTITY, MAX_READ_BYTES, MAX_READ_LINES, MAX_WRITE_BYTES
15
+
16
+ GIT_NET_TIMEOUT = 60
17
+ GIT_CLONE_TIMEOUT = 600
18
+ PROJECT_LOCK_TIMEOUT = 120
19
+
20
+
21
+ class OverleafClient:
22
+ def __init__(
23
+ self,
24
+ repo_dir: str | os.PathLike[str] | None = None,
25
+ token: str | None = None,
26
+ default_project_id: str | None = None,
27
+ remote_base: str = "https://git.overleaf.com",
28
+ sync_ttl: float = 5.0,
29
+ ):
30
+ if shutil.which("git") is None:
31
+ raise RuntimeError("Git executable not found; install Git before starting the server.")
32
+ self.repo_dir = os.path.abspath(os.fspath(repo_dir or config.OVERLEAF_REPO_DIR))
33
+ self._token = token if token is not None else config.OVERLEAF_TOKEN.get_secret_value()
34
+ self._default_project_id = (
35
+ default_project_id if default_project_id is not None else config.PROJECT_ID
36
+ )
37
+ self._remote_base = remote_base.rstrip("/")
38
+ self._sync_ttl = sync_ttl
39
+ if not self._token and self._remote_base.startswith("http"):
40
+ raise ValueError("OVERLEAF_TOKEN is required")
41
+ repo_dir_created = not os.path.exists(self.repo_dir)
42
+ os.makedirs(self.repo_dir, mode=0o700, exist_ok=True)
43
+ if repo_dir_created:
44
+ os.chmod(self.repo_dir, 0o700)
45
+
46
+ self._project_locks: dict[str, threading.Lock] = {}
47
+ self._locks_lock = threading.Lock()
48
+ self._last_sync: dict[str, float] = {}
49
+
50
+ def _get_thread_lock(self, project_id: str) -> threading.Lock:
51
+ with self._locks_lock:
52
+ return self._project_locks.setdefault(project_id, threading.Lock())
53
+
54
+ def _resolve_project_id(self, project_id: str | None) -> str:
55
+ resolved = self._default_project_id if project_id is None else project_id
56
+ if not resolved:
57
+ raise ValueError("project_id is required")
58
+ if not config.PROJECT_ID_RE.fullmatch(resolved):
59
+ raise ValueError("Invalid project_id (expected 24 hex characters).")
60
+ configured_projects = {
61
+ item.strip() for item in config.OVERLEAF_ALLOWED_PROJECTS.split(",") if item.strip()
62
+ }
63
+ allowed_projects = configured_projects or {self._default_project_id}
64
+ if resolved not in allowed_projects:
65
+ raise ValueError(f"Project '{resolved}' is not in the allowed project list.")
66
+ return resolved
67
+
68
+ def _project_path(self, project_id: str) -> str:
69
+ return os.path.join(self.repo_dir, project_id)
70
+
71
+ @contextmanager
72
+ def _project_lock(self, project_id: str) -> Iterator[None]:
73
+ with self._get_thread_lock(project_id):
74
+ try:
75
+ with FileLock(
76
+ f"{self._project_path(project_id)}.lock", timeout=PROJECT_LOCK_TIMEOUT
77
+ ):
78
+ yield
79
+ except Timeout:
80
+ raise RuntimeError(
81
+ "Another process is using this project; retry shortly."
82
+ ) from None
83
+
84
+ def _remote_url(self, project_id: str) -> str:
85
+ return f"{self._remote_base}/{project_id}"
86
+
87
+ @staticmethod
88
+ def _branch(repo: git.Repo) -> str:
89
+ try:
90
+ branch = repo.active_branch.name
91
+ except TypeError:
92
+ try:
93
+ ref = str(repo.git.symbolic_ref("--short", "refs/remotes/origin/HEAD"))
94
+ branch = ref.removeprefix("origin/")
95
+ except git.GitCommandError:
96
+ branch = "master"
97
+ try:
98
+ repo.git.rev_parse("--verify", f"refs/remotes/origin/{branch}")
99
+ except git.GitCommandError:
100
+ raise RuntimeError(f"Remote branch 'origin/{branch}' was not found.") from None
101
+ return branch
102
+
103
+ def _git_env(self) -> dict[str, str]:
104
+ entries = [("core.hooksPath", os.devnull)]
105
+ if self._token:
106
+ cred = base64.b64encode(f"git:{self._token}".encode()).decode()
107
+ entries.append(("http.extraHeader", f"Authorization: Basic {cred}"))
108
+ env = {
109
+ "GIT_TERMINAL_PROMPT": "0",
110
+ "GIT_HTTP_LOW_SPEED_LIMIT": "1000",
111
+ "GIT_HTTP_LOW_SPEED_TIME": "30",
112
+ "GIT_CONFIG_GLOBAL": os.devnull,
113
+ "GIT_CONFIG_NOSYSTEM": "1",
114
+ "GIT_AUTHOR_NAME": IDENTITY[0],
115
+ "GIT_AUTHOR_EMAIL": IDENTITY[1],
116
+ "GIT_COMMITTER_NAME": IDENTITY[0],
117
+ "GIT_COMMITTER_EMAIL": IDENTITY[1],
118
+ "GIT_CONFIG_COUNT": str(len(entries)),
119
+ }
120
+ for i, (key, value) in enumerate(entries):
121
+ env[f"GIT_CONFIG_KEY_{i}"] = key
122
+ env[f"GIT_CONFIG_VALUE_{i}"] = value
123
+ return env
124
+
125
+ def scrub(self, text: str) -> str:
126
+ if self._token:
127
+ text = text.replace(self._token, "***")
128
+ cred = base64.b64encode(f"git:{self._token}".encode()).decode()
129
+ text = text.replace(cred, "***")
130
+ return text
131
+
132
+ @contextmanager
133
+ def _scrubbed(self) -> Iterator[None]:
134
+ try:
135
+ yield
136
+ except git.GitCommandError as e:
137
+ raise RuntimeError(self.scrub(str(e))) from None
138
+
139
+ @staticmethod
140
+ def _safe_path(project_path: str, file_path: str) -> tuple[str, str]:
141
+ if not file_path or "\x00" in file_path:
142
+ raise ValueError("Invalid file path.")
143
+ root = os.path.realpath(project_path)
144
+ full = os.path.realpath(os.path.join(root, file_path))
145
+ try:
146
+ inside = os.path.commonpath([root, full]) == root
147
+ except ValueError:
148
+ inside = False
149
+ if not inside or full == root:
150
+ raise ValueError(f"Path '{file_path}' is outside the project directory.")
151
+ rel = os.path.relpath(full, root)
152
+ if any(part.lower() == ".git" for part in rel.split(os.sep)):
153
+ raise ValueError("Access to the .git directory is not allowed.")
154
+ return full, rel
155
+
156
+ @staticmethod
157
+ def _atomic_write(full_path: str, data: bytes) -> None:
158
+ directory = os.path.dirname(full_path)
159
+ os.makedirs(directory, exist_ok=True)
160
+ fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-")
161
+ try:
162
+ with os.fdopen(fd, "wb") as f:
163
+ f.write(data)
164
+ if os.path.exists(full_path):
165
+ shutil.copymode(full_path, tmp)
166
+ os.replace(tmp, full_path)
167
+ except BaseException:
168
+ if os.path.exists(tmp):
169
+ os.remove(tmp)
170
+ raise
171
+
172
+ def _sync_unlocked(self, project_id: str, force: bool) -> str:
173
+ path = self._project_path(project_id)
174
+ env = self._git_env()
175
+
176
+ if not os.path.isdir(os.path.join(path, ".git")):
177
+ try:
178
+ with self._scrubbed():
179
+ git.Repo.clone_from(
180
+ self._remote_url(project_id),
181
+ path,
182
+ env=env,
183
+ kill_after_timeout=GIT_CLONE_TIMEOUT,
184
+ )
185
+ repo = git.Repo(path)
186
+ with self._scrubbed(), repo.git.custom_environment(**env):
187
+ repo.git.remote("set-url", "origin", self._remote_url(project_id))
188
+ repo.git.config(
189
+ "--replace-all",
190
+ "remote.origin.fetch",
191
+ "+refs/heads/*:refs/remotes/origin/*",
192
+ )
193
+ with suppress(git.GitCommandError):
194
+ repo.git.remote("set-head", "origin", "--auto")
195
+ except BaseException:
196
+ self._remove_cache(path)
197
+ raise
198
+ else:
199
+ age = time.monotonic() - self._last_sync.get(project_id, float("-inf"))
200
+ if not force and age < self._sync_ttl:
201
+ return path
202
+ try:
203
+ repo = git.Repo(path)
204
+ except (git.InvalidGitRepositoryError, git.NoSuchPathError):
205
+ self._remove_cache(path)
206
+ return self._sync_unlocked(project_id, force=True)
207
+ try:
208
+ with self._scrubbed(), repo.git.custom_environment(**env):
209
+ repo.git.fetch("origin", kill_after_timeout=GIT_NET_TIMEOUT)
210
+ branch = self._branch(repo)
211
+ repo.git.reset("--hard", f"origin/{branch}")
212
+ repo.git.clean("-fd")
213
+ except RuntimeError as exc:
214
+ if str(exc).startswith("Remote branch"):
215
+ raise
216
+ self._remove_cache(path)
217
+ return self._sync_unlocked(project_id, force=True)
218
+
219
+ self._last_sync[project_id] = time.monotonic()
220
+ return path
221
+
222
+ @staticmethod
223
+ def _remove_cache(path: str) -> None:
224
+ if os.path.isdir(path):
225
+ shutil.rmtree(path, ignore_errors=True)
226
+ elif os.path.exists(path):
227
+ os.remove(path)
228
+
229
+ def ensure_repo(self, project_id: str | None = None) -> str:
230
+ pid = self._resolve_project_id(project_id)
231
+ with self._project_lock(pid):
232
+ return self._sync_unlocked(pid, force=True)
233
+
234
+ def list_files(self, project_id: str | None = None) -> list[str]:
235
+ pid = self._resolve_project_id(project_id)
236
+ with self._project_lock(pid):
237
+ root = self._sync_unlocked(pid, force=False)
238
+ files = []
239
+ for current, dirs, filenames in os.walk(root):
240
+ dirs[:] = [d for d in dirs if d != ".git" and not d.startswith(".tmp-")]
241
+ for name in filenames:
242
+ if name != ".git" and not name.startswith(".tmp-"):
243
+ rel = os.path.relpath(os.path.join(current, name), root)
244
+ files.append(rel.replace(os.sep, "/"))
245
+ return sorted(files)
246
+
247
+ def read_file(
248
+ self,
249
+ file_path: str,
250
+ project_id: str | None = None,
251
+ offset: int = 0,
252
+ limit: int | None = None,
253
+ ) -> str:
254
+ if offset < 0:
255
+ raise ValueError("offset must be non-negative.")
256
+ if limit is not None and not 0 < limit <= MAX_READ_LINES:
257
+ raise ValueError(f"limit must be between 1 and {MAX_READ_LINES}.")
258
+ pid = self._resolve_project_id(project_id)
259
+ with self._project_lock(pid):
260
+ root = self._sync_unlocked(pid, force=False)
261
+ full, rel = self._safe_path(root, file_path)
262
+ if not os.path.isfile(full):
263
+ raise FileNotFoundError(f"File '{file_path}' not found.")
264
+ try:
265
+ with open(full, encoding="utf-8", newline="") as f:
266
+ chunks: list[str] = []
267
+ total_bytes = 0
268
+ for _ in range(offset):
269
+ skipped = f.readline(MAX_READ_BYTES + 1)
270
+ if not skipped:
271
+ return ""
272
+ if len(skipped.encode("utf-8")) > MAX_READ_BYTES:
273
+ raise ValueError(
274
+ f"Content exceeds {MAX_READ_BYTES} bytes; "
275
+ "use offset/limit to read in chunks."
276
+ )
277
+ lines_read = 0
278
+ while limit is None or lines_read < limit:
279
+ line = f.readline(MAX_READ_BYTES + 1)
280
+ if not line:
281
+ break
282
+ total_bytes += len(line.encode("utf-8"))
283
+ if total_bytes > MAX_READ_BYTES:
284
+ raise ValueError(
285
+ f"Content exceeds {MAX_READ_BYTES} bytes; "
286
+ "use offset/limit to read in chunks."
287
+ )
288
+ chunks.append(line)
289
+ lines_read += 1
290
+ return "".join(chunks)
291
+ except UnicodeDecodeError:
292
+ raise ValueError(f"'{rel}' is not a UTF-8 text file.") from None
293
+
294
+ def _write_synced_file(self, pid: str, root: str, full: str, git_rel: str, data: bytes) -> str:
295
+ self._atomic_write(full, data)
296
+ repo = git.Repo(root)
297
+ with self._scrubbed(), repo.git.custom_environment(**self._git_env()):
298
+ if not repo.git.status("--porcelain", "--", git_rel).strip():
299
+ return f"No changes: '{git_rel}' already has this content."
300
+
301
+ repo.git.add("--", git_rel)
302
+ repo.git.commit("-m", f"Update {git_rel} via Overleaf MCP")
303
+ branch = self._branch(repo)
304
+ try:
305
+ repo.git.push(
306
+ "origin",
307
+ f"HEAD:refs/heads/{branch}",
308
+ kill_after_timeout=GIT_NET_TIMEOUT,
309
+ )
310
+ except git.GitCommandError as e:
311
+ msg = self.scrub(str(e))
312
+ self._last_sync.pop(pid, None)
313
+ try:
314
+ repo.git.fetch("origin", kill_after_timeout=GIT_NET_TIMEOUT)
315
+ ahead = int(repo.git.rev_list("--count", f"origin/{branch}..HEAD"))
316
+ behind = int(repo.git.rev_list("--count", f"HEAD..origin/{branch}"))
317
+ except (git.GitCommandError, ValueError):
318
+ raise RuntimeError(
319
+ "Push result unknown; re-read the file before retrying. " + msg
320
+ ) from None
321
+
322
+ if ahead == 0:
323
+ return f"Pushed '{git_rel}' (confirmed after error)."
324
+ repo.git.reset("--hard", f"origin/{branch}")
325
+ if behind:
326
+ raise RuntimeError(
327
+ "Push rejected: the remote changed. Re-read and retry. " + msg
328
+ ) from None
329
+ raise RuntimeError("Push failed; your change was discarded. " + msg) from None
330
+ sha = repo.head.commit.hexsha[:8]
331
+ return f"Updated '{git_rel}' and pushed to Overleaf ({sha})"
332
+
333
+ def write_file(self, file_path: str, content: str, project_id: str | None = None) -> str:
334
+ pid = self._resolve_project_id(project_id)
335
+ data = content.encode("utf-8")
336
+ if len(data) > MAX_WRITE_BYTES:
337
+ raise ValueError(f"Content is larger than {MAX_WRITE_BYTES} bytes.")
338
+
339
+ with self._project_lock(pid):
340
+ root = self._sync_unlocked(pid, force=True)
341
+ full, rel = self._safe_path(root, file_path)
342
+ git_rel = rel.replace(os.sep, "/")
343
+ if os.path.isfile(full):
344
+ try:
345
+ with open(full, "rb") as existing:
346
+ existing_text = existing.read().decode("utf-8")
347
+ if "\x00" in existing_text:
348
+ raise ValueError(f"'{git_rel}' is not a UTF-8 text file.")
349
+ except UnicodeDecodeError:
350
+ raise ValueError(f"'{git_rel}' is not a UTF-8 text file.") from None
351
+ return self._write_synced_file(pid, root, full, git_rel, data)
352
+
353
+ def edit_file(
354
+ self,
355
+ file_path: str,
356
+ old_text: str,
357
+ new_text: str,
358
+ project_id: str | None = None,
359
+ ) -> str:
360
+ """Replace exactly one occurrence of old_text and push the complete file."""
361
+ if not old_text:
362
+ raise ValueError("old_text must not be empty.")
363
+ pid = self._resolve_project_id(project_id)
364
+ with self._project_lock(pid):
365
+ root = self._sync_unlocked(pid, force=True)
366
+ full, rel = self._safe_path(root, file_path)
367
+ if not os.path.isfile(full):
368
+ raise FileNotFoundError(f"File '{file_path}' not found.")
369
+ try:
370
+ with open(full, encoding="utf-8", newline="") as existing:
371
+ current = existing.read()
372
+ except UnicodeDecodeError:
373
+ raise ValueError(f"'{rel}' is not a UTF-8 text file.") from None
374
+ matches = current.count(old_text)
375
+ if matches == 0:
376
+ raise ValueError(
377
+ "old_text was not found in the file; check whitespace and indentation."
378
+ )
379
+ if matches > 1:
380
+ raise ValueError(f"old_text matched {matches} times; it must match exactly once.")
381
+ data = current.replace(old_text, new_text, 1).encode("utf-8")
382
+ if len(data) > MAX_WRITE_BYTES:
383
+ raise ValueError(f"Content is larger than {MAX_WRITE_BYTES} bytes.")
384
+ return self._write_synced_file(pid, root, full, rel.replace(os.sep, "/"), data)
overleaf_mcp/config.py ADDED
@@ -0,0 +1,27 @@
1
+ import os
2
+ import re
3
+ from pathlib import Path
4
+ from typing import ClassVar
5
+
6
+ from pydantic import Field, SecretStr
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+ _PACKAGE_ENV_FILE = Path(__file__).resolve().parent.parent / ".env"
10
+ _ENV_FILE = (
11
+ Path(os.environ["OVERLEAF_ENV_FILE"])
12
+ if os.environ.get("OVERLEAF_ENV_FILE")
13
+ else (_PACKAGE_ENV_FILE if _PACKAGE_ENV_FILE.exists() else None)
14
+ )
15
+
16
+
17
+ class Config(BaseSettings):
18
+ model_config = SettingsConfigDict(env_file=_ENV_FILE, extra="ignore")
19
+
20
+ OVERLEAF_TOKEN: SecretStr = Field(...)
21
+ PROJECT_ID: str = Field(...)
22
+ OVERLEAF_ALLOWED_PROJECTS: str = ""
23
+ OVERLEAF_REPO_DIR: Path = Path.home() / ".cache" / "overleaf-mcp"
24
+ PROJECT_ID_RE: ClassVar[re.Pattern[str]] = re.compile(r"^[0-9a-f]{24}$")
25
+
26
+
27
+ config = Config()
@@ -0,0 +1,4 @@
1
+ MAX_READ_BYTES = 100_000
2
+ MAX_READ_LINES = 10_000
3
+ MAX_WRITE_BYTES = 2_000_000
4
+ IDENTITY = ("Overleaf MCP", "overleaf-mcp@localhost")
overleaf_mcp/py.typed ADDED
File without changes
overleaf_mcp/server.py ADDED
@@ -0,0 +1,115 @@
1
+ import asyncio
2
+ import json
3
+ import sys
4
+ from collections.abc import Callable
5
+ from functools import lru_cache
6
+ from typing import TYPE_CHECKING, Annotated
7
+
8
+ from mcp.server.fastmcp import FastMCP
9
+ from mcp.types import ToolAnnotations
10
+ from pydantic import Field, ValidationError
11
+
12
+ from .constants import MAX_READ_LINES
13
+
14
+ if TYPE_CHECKING:
15
+ from .client import OverleafClient
16
+
17
+ mcp = FastMCP("overleaf-mcp-server")
18
+
19
+
20
+ @lru_cache(maxsize=1)
21
+ def get_client() -> "OverleafClient":
22
+ from .client import OverleafClient
23
+
24
+ return OverleafClient()
25
+
26
+
27
+ READ_ONLY = ToolAnnotations(readOnlyHint=True, openWorldHint=True)
28
+ WRITE = ToolAnnotations(
29
+ readOnlyHint=False,
30
+ destructiveHint=True,
31
+ idempotentHint=True,
32
+ openWorldHint=True,
33
+ )
34
+ EDIT = ToolAnnotations(readOnlyHint=False, destructiveHint=True, openWorldHint=True)
35
+ SYNC = ToolAnnotations(
36
+ readOnlyHint=False,
37
+ destructiveHint=False,
38
+ idempotentHint=True,
39
+ openWorldHint=True,
40
+ )
41
+
42
+
43
+ async def _run[**P, T](fn: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
44
+ try:
45
+ return await asyncio.to_thread(fn, *args, **kwargs)
46
+ except RuntimeError as e:
47
+ text = str(e)
48
+ except Exception as e:
49
+ text = f"{type(e).__name__}: {e}"
50
+ raise RuntimeError(get_client().scrub(text)) from None
51
+
52
+
53
+ @mcp.tool(annotations=READ_ONLY)
54
+ async def list_files(project_id: str | None = None) -> str:
55
+ """Synchronize and list visible files in an Overleaf project."""
56
+ files = await _run(get_client().list_files, project_id)
57
+ return json.dumps(files, indent=2)
58
+
59
+
60
+ @mcp.tool(annotations=READ_ONLY)
61
+ async def read_file(
62
+ file_path: str,
63
+ project_id: str | None = None,
64
+ offset: Annotated[int, Field(ge=0)] = 0,
65
+ limit: Annotated[int | None, Field(ge=1, le=MAX_READ_LINES)] = None,
66
+ ) -> str:
67
+ """Read a UTF-8 text file, optionally selecting a bounded range of lines."""
68
+ return await _run(get_client().read_file, file_path, project_id, offset, limit)
69
+
70
+
71
+ @mcp.tool(annotations=WRITE)
72
+ async def write_file(file_path: str, content: str, project_id: str | None = None) -> str:
73
+ """Overwrite the complete UTF-8 text file, commit the change, and push it to Overleaf."""
74
+ return await _run(get_client().write_file, file_path, content, project_id)
75
+
76
+
77
+ @mcp.tool(annotations=EDIT)
78
+ async def edit_file(
79
+ file_path: str,
80
+ old_text: str,
81
+ new_text: str,
82
+ project_id: str | None = None,
83
+ ) -> str:
84
+ """Replace exactly one matching text range in a UTF-8 file and push the result."""
85
+ return await _run(get_client().edit_file, file_path, old_text, new_text, project_id)
86
+
87
+
88
+ @mcp.tool(annotations=SYNC)
89
+ async def sync_project(project_id: str | None = None) -> str:
90
+ """Refresh the local project cache from Overleaf without changing the remote."""
91
+ await _run(get_client().ensure_repo, project_id)
92
+ return "Synchronized Overleaf project"
93
+
94
+
95
+ def main() -> None:
96
+ """Run the Overleaf MCP server over stdio."""
97
+ try:
98
+ get_client()
99
+ except ValidationError as error:
100
+ missing = [
101
+ ".".join(str(part) for part in detail["loc"])
102
+ for detail in error.errors()
103
+ if detail["type"] == "missing"
104
+ ]
105
+ message = "Missing configuration: " + ", ".join(missing or ["invalid values"])
106
+ print(message, file=sys.stderr)
107
+ sys.exit(1)
108
+ except Exception as error:
109
+ print(f"Failed to initialize Overleaf client: {type(error).__name__}", file=sys.stderr)
110
+ sys.exit(1)
111
+ mcp.run()
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.5
2
+ Name: overleaf-mcp-integration
3
+ Version: 0.1.0
4
+ Summary: MCP server for Overleaf projects via Git sync
5
+ Project-URL: Homepage, https://github.com/younesbensafia/overleaf-mcp-server
6
+ Project-URL: Repository, https://github.com/younesbensafia/overleaf-mcp-server
7
+ Project-URL: Issues, https://github.com/younesbensafia/overleaf-mcp-server/issues
8
+ Author: Younes Bensafia
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: git,latex,mcp,overleaf,sharelatex
12
+ Requires-Python: >=3.13
13
+ Requires-Dist: filelock>=3.16.0
14
+ Requires-Dist: gitpython>=3.1.46
15
+ Requires-Dist: mcp<2.0.0,>=1.26.0
16
+ Requires-Dist: pydantic-settings>=2.10.0
17
+ Requires-Dist: pydantic>=2.11.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Overleaf MCP Server
21
+ <!-- mcp-name: io.github.YounesBensafia/overleaf-mcp-server -->
22
+
23
+ An MCP server focused only on Overleaf projects (via Overleaf Git sync).
24
+
25
+ ## What This Server Does
26
+
27
+ - Connects MCP-compatible clients to your Overleaf project through Git sync.
28
+ - Exposes file-level tools to list, read, write, and sync project content.
29
+ - Keeps workflow simple: pull latest files, edit, then push back to Overleaf.
30
+
31
+ ## Architecture
32
+
33
+ ```mermaid
34
+ flowchart LR
35
+ C[MCP Client\nClaude Desktop / other MCP host] -->|Tool Call| S[Overleaf MCP Server]
36
+ S -->|Git Sync| O[Overleaf Git Remote]
37
+ S -->|Read / Write| L[Local Repo Mirror]
38
+ L -->|Commit + Push| O
39
+ O -->|Pull / Fetch| L
40
+ S -->|Tool Result| C
41
+ ```
42
+
43
+ ## Tool Workflow
44
+
45
+ ```mermaid
46
+ sequenceDiagram
47
+ participant Client as MCP Client
48
+ participant Server as Overleaf MCP Server
49
+ participant Local as Local Mirror
50
+ participant Overleaf as Overleaf Git
51
+
52
+ Client->>Server: list_files / read_file
53
+ Server->>Local: Ensure local clone
54
+ Server->>Overleaf: git pull
55
+ Overleaf-->>Server: latest content
56
+ Server-->>Client: file list / file content
57
+
58
+ Client->>Server: write_file(path, content)
59
+ Server->>Local: update file
60
+ Server->>Local: git commit
61
+ Server->>Overleaf: git push
62
+ Server-->>Client: success + metadata
63
+ ```
64
+
65
+ ## Requirements
66
+
67
+ - Python 3.13+
68
+ - `uv` package manager
69
+ - An Overleaf plan with **Git integration** (individual, group, or institution license). Check if your institution provides free access at [Overleaf for Institutions](https://www.overleaf.com/for/institutions-using-overleaf) - use your institutional email. If your institution is not listed, [upgrade your plan](https://www.overleaf.com/user/subscription).
70
+
71
+ ## Git Setup
72
+
73
+ 1. **Enable Git** - open your project on Overleaf → **Menu** → enable **Git** under Integrations.
74
+ 2. **Copy project ID** - from the browser URL (e.g. `https://www.overleaf.com/project/69a4f7cc4eaf13bd56de5b04` → `69a4f7cc4eaf13bd56de5b04`).
75
+ 3. **Generate a Git token** - **Account Settings** → **Git integration authentication tokens** → **Generate new token**.
76
+ 4. **Configure `.env`** - copy `.env.example` to `.env` and fill in:
77
+
78
+ ```env
79
+ OVERLEAF_TOKEN=your_git_token
80
+ PROJECT_ID=your_project_id
81
+ ```
82
+
83
+ > `project_id` can also be passed per tool call, but `PROJECT_ID` is still required
84
+ > by the server configuration.
85
+
86
+ The Overleaf Git token is account-wide credential material. Store it only in
87
+ the MCP client's environment or a protected `.env` file, and rotate it if it
88
+ is exposed.
89
+
90
+ ## Safety Notes
91
+
92
+ Files in an Overleaf project are untrusted input. A `.tex` file or project
93
+ configuration can contain instructions aimed at the model; treat file contents
94
+ as data, not as commands. Keep `OVERLEAF_ALLOWED_PROJECTS` restricted to the
95
+ projects the server should be able to access:
96
+
97
+ ```env
98
+ OVERLEAF_ALLOWED_PROJECTS=project_id_a,project_id_b
99
+ ```
100
+
101
+ The default allowlist contains only `PROJECT_ID`.
102
+
103
+ ## Quick Start
104
+
105
+ ```bash
106
+ git clone https://github.com/younesbensafia/overleaf-mcp-server.git
107
+ cd overleaf-mcp-server
108
+ uv sync
109
+ cp .env.example .env # then edit with your token/project id
110
+ uv run overleaf-mcp
111
+ ```
112
+
113
+ Without a checkout, run the Git version directly with:
114
+
115
+ ```bash
116
+ uvx --from git+https://github.com/younesbensafia/overleaf-mcp-server overleaf-mcp
117
+ ```
118
+
119
+ Plain `uvx overleaf-mcp` is appropriate only after the project is published to
120
+ PyPI.
121
+
122
+ The server listens on stdio - connect your MCP client (Claude Desktop, etc.) to it.
123
+
124
+ For a large project, call `sync_project` first. The initial Git clone can take
125
+ longer than an MCP client's individual tool request timeout.
126
+
127
+ ## Available Tools
128
+
129
+ | Tool | Description |
130
+ |------|-------------|
131
+ | `list_files` | Pull and list files from Overleaf project |
132
+ | `read_file` | Read file content |
133
+ | `write_file` | Overwrite a complete text file, commit, and push to Overleaf |
134
+ | `edit_file` | Replace one exact text match, commit, and push to Overleaf |
135
+ | `sync_project` | Force a pull/sync from Overleaf |
136
+
137
+ `read_file` accepts optional zero-based `offset` and bounded `limit` line
138
+ parameters for reading large documents in chunks.
139
+
140
+ ## Claude Desktop Setup
141
+
142
+ Add to `~/.config/Claude/claude_desktop_config.json`:
143
+
144
+ ```json
145
+ {
146
+ "mcpServers": {
147
+ "overleaf": {
148
+ "command": "uv",
149
+ "args": ["--directory", "/path/to/overleaf-mcp-server", "run", "overleaf-mcp"],
150
+ "env": {
151
+ "OVERLEAF_TOKEN": "your_git_token",
152
+ "PROJECT_ID": "your_project_id"
153
+ }
154
+ }
155
+ }
156
+ }
157
+ ```
158
+
159
+ ## Troubleshooting
160
+
161
+ - **403 Forbidden on git operations:**
162
+ - Your plan doesn't include Git integration - follow the [Git Setup](#git-setup) section.
163
+ - Or the Git token is wrong - regenerate it at **Account Settings** → **Git integration authentication tokens**.
164
+ - **Wrong project content:**
165
+ - Set the correct `PROJECT_ID` in `.env`.
166
+ - Or pass `project_id` explicitly in tool calls.
167
+ - **Sync conflicts:**
168
+ - Run `sync_project` before `write_file` if the remote changed.
169
+ - **Server not starting:**
170
+ - Ensure dependencies are installed with `uv sync`.
171
+ - Verify Python 3.13+ is available.
172
+
173
+ ## License
174
+
175
+ MIT - See [LICENSE](LICENSE)
@@ -0,0 +1,11 @@
1
+ overleaf_mcp/__init__.py,sha256=EiOxZeok9fzVftcs2zW6X3PlwPe9FhX-91_h38lGg_Q,96
2
+ overleaf_mcp/client.py,sha256=TZOko5oInQnKodqZrSBfPu8B_u70v77EpCjfIw6Shmg,16671
3
+ overleaf_mcp/config.py,sha256=eDuGiQmqDYwIOnHwB5OonxaowzZXiM_ielLt5OUv7Fs,802
4
+ overleaf_mcp/constants.py,sha256=uFKqxMk0gZD_FYX4hFARLQt9DknQwWtlLCNVuvpUqSA,131
5
+ overleaf_mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ overleaf_mcp/server.py,sha256=e_aC8t0Bbi4U2-MJs4JdeYl-vGvaFiWZu3jYeWCmh0Q,3502
7
+ overleaf_mcp_integration-0.1.0.dist-info/METADATA,sha256=jEqLKDjqfkMM0rEq3ipZaNtu8WvXJf3f1iRBFHkyY5o,5800
8
+ overleaf_mcp_integration-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ overleaf_mcp_integration-0.1.0.dist-info/entry_points.txt,sha256=Ipu52AKgkhFbIfteHiOfsGvPltk8KIc7AkyX1pEKKHc,58
10
+ overleaf_mcp_integration-0.1.0.dist-info/licenses/LICENSE,sha256=mhfylnkk-rj_wws_EMCOUMn14k6iHUlvpKQ_cNX2j18,1072
11
+ overleaf_mcp_integration-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ overleaf-mcp = overleaf_mcp.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Younes Bensafia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.