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.
- overleaf_mcp/__init__.py +4 -0
- overleaf_mcp/client.py +384 -0
- overleaf_mcp/config.py +27 -0
- overleaf_mcp/constants.py +4 -0
- overleaf_mcp/py.typed +0 -0
- overleaf_mcp/server.py +115 -0
- overleaf_mcp_integration-0.1.0.dist-info/METADATA +175 -0
- overleaf_mcp_integration-0.1.0.dist-info/RECORD +11 -0
- overleaf_mcp_integration-0.1.0.dist-info/WHEEL +4 -0
- overleaf_mcp_integration-0.1.0.dist-info/entry_points.txt +2 -0
- overleaf_mcp_integration-0.1.0.dist-info/licenses/LICENSE +21 -0
overleaf_mcp/__init__.py
ADDED
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()
|
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,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.
|