linxiv_mcp
MCP server exposing linXiv tools to Claude and other MCP clients.
1"""MCP server exposing linXiv tools to Claude and other MCP clients.""" 2 3from __future__ import annotations 4 5import dataclasses 6import datetime 7import json 8from pathlib import Path 9from typing import Any, Literal, Optional 10 11from mcp.server.fastmcp import FastMCP # pyright: ignore[reportMissingImports] 12 13import service.author as svc_author 14import service.paper as svc_paper 15import service.tag as svc_tag 16import service.project as svc_project 17import service.note as svc_note 18import service.files as svc_files 19import service.export_import as svc_ei 20import user_settings 21from config import init_data_dir 22from service.author import Author 23from service.paper import Paper, Papers 24from service.tag import Tag, TagIn 25from service.project import Project as _SvcProjectFilter, Projects as _SvcProjects, ProjectIn, UNSET 26from service.note import Note as _SvcNote, Notes as _SvcNotes, NoteIn, NoteUpdateIn 27from service.models.project import Status as _SvcStatus 28from storage.notes import ensure_notes_db as _ensure_notes_db 29from storage.projects import ensure_projects_db 30from formats.bibtex import BibTeXFormat 31from formats.markdown import ObsidianFormat 32from sources.arxiv_source import ArxivSource 33from sources.base import PaperMetadata 34from sources.crossref_source import CrossRefSource 35from sources.doi_resolve import resolve_doi as _resolve_doi 36from sources.openalex_source import OpenAlexSource 37 38 39mcp = FastMCP("linxiv") 40 41_SOURCES = { 42 "arxiv": ArxivSource, 43 "crossref": CrossRefSource, 44 "openalex": OpenAlexSource, 45} 46 47init_data_dir() 48svc_paper.init_db() 49ensure_projects_db() 50_ensure_notes_db() 51 52 53# --------------------------------------------------------------------------- 54# Serialization helpers 55# --------------------------------------------------------------------------- 56 57def _asdict_json(obj) -> dict: 58 """dataclasses.asdict with datetime/date values rendered as ISO strings.""" 59 return dataclasses.asdict(obj, dict_factory=lambda items: { 60 k: (v.isoformat() if hasattr(v, "isoformat") else v) 61 for k, v in items 62 }) 63 64 65def _resolve_source(source: str): 66 cls = _SOURCES.get(source) 67 if cls is None: 68 raise ValueError(f"Unknown source {source!r}. Use 'arxiv', 'crossref', or 'openalex'.") 69 return cls 70 71 72def _project_paper_count(project_id: int) -> int: 73 """Re-read the project and count its (active) members. Raises ValueError 74 if the project vanished (e.g. hard-deleted) since the caller's write.""" 75 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 76 if details is None: 77 raise ValueError(f"Project {project_id} not found.") 78 return len(details.source_fks) 79 80 81# ── Paper tools ─────────────────────────────────────────────────────────────── 82 83@mcp.tool() 84def search_papers(query: str, source: str = "arxiv", max_results: int = 10) -> list[dict]: 85 """Search for academic papers by keyword. 86 87 Args: 88 query: Search query string (e.g. "transformer attention mechanism"). 89 source: Data source — "arxiv", "crossref", or "openalex". 90 max_results: Maximum number of results to return (default 10). 91 """ 92 return [r.model_dump(mode="json") for r in _resolve_source(source)().search(query, max_results=max_results)] 93 94 95@mcp.tool() 96def fetch_paper(paper_id: str, source: str = "arxiv") -> dict: 97 """Fetch full metadata for a paper by ID and save it to the local database. 98 99 Args: 100 paper_id: arXiv style (e.g. "2204.12985"), CrossRef DOI, or OpenAlex ID (e.g. "W3123456789"). 101 source: Data source — "arxiv", "crossref", or "openalex". 102 """ 103 meta = _resolve_source(source)().fetch_by_id(paper_id) 104 svc_paper.save_paper_metadata(meta) 105 return meta.model_dump(mode="json") 106 107 108@mcp.tool() 109def list_papers(limit: Optional[int] = None, offset: int = 0, category: Optional[str] = None) -> list[dict]: 110 """List papers stored in the local database. 111 112 Args: 113 limit: Maximum number of papers to return (default: all). 114 offset: Number of papers to skip for pagination. 115 category: Filter by arXiv primary category (e.g. "cs.LG"). 116 """ 117 papers = svc_paper.list_paper_details(limit=limit, offset=offset, category=category) 118 return [p.to_dict() for p in papers] 119 120 121@mcp.tool() 122def get_paper(paper_id: str) -> Optional[dict]: 123 """Get full metadata for a single paper from the local database. 124 125 Args: 126 paper_id: The paper ID (e.g. "arxiv:2204.12985" or "2204.12985"). 127 """ 128 paper = svc_paper.get(Paper(source_id=paper_id)) 129 return paper.to_dict() if paper else None 130 131 132@mcp.tool() 133def delete_paper(paper_id: str) -> dict: 134 """Soft-delete a paper from the local database. 135 136 The paper is moved to trash and can be restored. Use paper_id in the 137 format returned by list_papers or get_paper (e.g. "arxiv:2204.12985"). 138 139 Args: 140 paper_id: The paper source ID to delete. 141 """ 142 if svc_paper.get(Paper(source_id=paper_id)) is None: 143 raise ValueError(f"Paper {paper_id!r} not found in database.") 144 svc_paper.delete(Paper(source_id=paper_id)) 145 return {"deleted": paper_id} 146 147 148@mcp.tool() 149def get_paper_versions(paper_id: str) -> Optional[dict]: 150 """Get all stored versions of a paper. 151 152 Args: 153 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 154 """ 155 all_ver = svc_paper.get_all(Paper(source_id=paper_id)) 156 if all_ver is None: 157 return None 158 return _asdict_json(all_ver) 159 160 161@mcp.tool() 162def search_full_text(query: str, limit: int = 20) -> list[dict]: 163 """Full-text search over downloaded TeX source content. 164 165 Only papers whose TeX source has been downloaded will appear. 166 167 Args: 168 query: SQLite FTS5 query string. 169 limit: Maximum number of results (default 20). 170 """ 171 try: 172 return [p.to_dict() for p in svc_paper.search_full_text_details(query, limit=limit)] 173 except Exception as exc: 174 print(f"[mcp] search_full_text error for query {query!r}: {exc}") 175 return [] 176 177 178# ── Tag tools ───────────────────────────────────────────────────────────────── 179 180@mcp.tool() 181def list_all_tags() -> list[str]: 182 """List all tags in the database.""" 183 return svc_tag.list_all_tags() 184 185 186@mcp.tool() 187def get_paper_tags(paper_id: str) -> dict: 188 """Get all tags applied to a specific paper. 189 190 Args: 191 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 192 """ 193 tags = svc_tag.get_paper_tags(paper_id) 194 return {"paper_id": paper_id, "tags": tags} 195 196 197@mcp.tool() 198def add_tags_to_paper(paper_id: str, tags: list[str]) -> dict: 199 """Add one or more tags to a paper. 200 201 Args: 202 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 203 tags: List of tag labels to add. 204 """ 205 paper = svc_paper.get(Paper(source_id=paper_id)) 206 if paper is None: 207 raise ValueError(f"Paper {paper_id!r} not found in database.") 208 updated = svc_tag.add_paper_tags(paper_id, tags) 209 return {"paper_id": paper_id, "tags": updated} 210 211 212@mcp.tool() 213def remove_tags_from_paper(paper_id: str, tags: list[str]) -> dict: 214 """Remove one or more tags from a paper. 215 216 Args: 217 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 218 tags: List of tag labels to remove. 219 """ 220 paper = svc_paper.get(Paper(source_id=paper_id)) 221 if paper is None: 222 raise ValueError(f"Paper {paper_id!r} not found in database.") 223 updated = svc_tag.remove_paper_tags(paper_id, tags) 224 return {"paper_id": paper_id, "tags": updated} 225 226 227@mcp.tool() 228def create_tag(label: str) -> dict: 229 """Create a new tag (or return its ID if it already exists). 230 231 Args: 232 label: Tag label text. 233 """ 234 tag_id = svc_tag.upsert(TagIn(label=label)) 235 if tag_id is None or tag_id < 0: 236 raise RuntimeError(f"Failed to create or locate tag {label!r}.") 237 return {"tag_id": tag_id, "label": label} 238 239 240@mcp.tool() 241def delete_tag(tag_id: int) -> dict: 242 """Delete a tag by its ID. 243 244 Args: 245 tag_id: Numeric tag ID (from create_tag or list_all_tags). 246 """ 247 if svc_tag.get(Tag(tag_id=tag_id)) is None: 248 raise ValueError(f"Tag {tag_id} not found.") 249 svc_tag.delete(Tag(tag_id=tag_id)) 250 return {"deleted": tag_id} 251 252 253# ── Project tools ───────────────────────────────────────────────────────────── 254 255@mcp.tool() 256def list_projects(status: Optional[str] = None) -> list[dict]: 257 """List research projects. 258 259 Args: 260 status: Filter by status — "active", "archived", or "deleted". 261 Defaults to all non-deleted projects. 262 """ 263 if status is not None: 264 try: 265 status_enum = _SvcStatus(status) 266 except ValueError: 267 raise ValueError(f"Invalid status {status!r}. Use 'active', 'archived', or 'deleted'.") 268 projects = svc_project.get_many(_SvcProjects(status=status_enum)) 269 else: 270 all_projects = svc_project.get_many(_SvcProjects()) 271 projects = [p for p in all_projects if p.status != _SvcStatus.DELETED] 272 return [p.to_dict() for p in projects] 273 274 275@mcp.tool() 276def get_project(project_id: int) -> Optional[dict]: 277 """Get full details for a project. 278 279 Args: 280 project_id: Numeric project ID. 281 """ 282 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 283 return details.to_dict() if details else None 284 285 286@mcp.tool() 287def create_project(name: str, description: str = "") -> dict: 288 """Create a new research project. 289 290 Args: 291 name: Project name. 292 description: Optional description. 293 """ 294 fk = svc_project.create(ProjectIn(name=name, description=description)) 295 details = svc_project.get(_SvcProjectFilter(project_fk=fk)) 296 return details.to_dict() if details else {"id": fk, "name": name} 297 298 299@mcp.tool() 300def update_project( 301 project_id: int, 302 name: Optional[str] = None, 303 description: Optional[str] = None, 304 color: Optional[str] = None, 305 tags: Optional[list[str]] = None, 306 status: Optional[str] = None, 307) -> dict: 308 """Update a project's name, description, color, tags, or lifecycle status. 309 310 Args: 311 project_id: Numeric project ID. 312 name: New name (omit to leave unchanged). 313 description: New description (omit to leave unchanged). 314 color: New hex color, e.g. "#4f86f7" (omit to leave unchanged). 315 tags: Replacement project tag list (omit to leave unchanged; [] clears all). 316 status: New lifecycle status — "active", "archived", or "deleted". 317 """ 318 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 319 if details is None: 320 raise ValueError(f"Project {project_id} not found.") 321 color_arg: Any = UNSET 322 if color is not None: 323 color_arg = svc_project.color_from_hex(color) 324 status_enum = None 325 if status is not None: 326 try: 327 status_enum = _SvcStatus(status) 328 except ValueError: 329 raise ValueError(f"Invalid status {status!r}. Use 'active', 'archived', or 'deleted'.") 330 svc_project.update( 331 project_id, 332 name=name, 333 description=description, 334 color=color_arg, 335 project_tags=tags, 336 status=status_enum, 337 ) 338 updated = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 339 return updated.to_dict() if updated else {} 340 341 342@mcp.tool() 343def delete_project(project_id: int) -> dict: 344 """Soft-delete a project (moves it to trash). 345 346 Args: 347 project_id: Numeric project ID. 348 """ 349 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 350 if details is None: 351 raise ValueError(f"Project {project_id} not found.") 352 svc_project.delete(_SvcProjectFilter(project_fk=project_id)) 353 return {"deleted": project_id} 354 355 356@mcp.tool() 357def add_paper_to_project(project_id: int, paper_id: str) -> dict: 358 """Add a paper to an existing project. 359 360 Args: 361 project_id: Numeric project ID. 362 paper_id: Paper ID to add (e.g. "arxiv:2204.12985"). 363 """ 364 try: 365 failed = svc_project.add_papers(project_id, [paper_id]) 366 except svc_project.ProjectNotFoundError as e: 367 raise ValueError(f"Project {project_id} not found.") from e 368 if failed: 369 raise ValueError(f"Paper {paper_id!r} not found in database.") 370 return {"project_id": project_id, "paper_id": paper_id, 371 "paper_count": _project_paper_count(project_id)} 372 373 374@mcp.tool() 375def remove_paper_from_project(project_id: int, paper_id: str) -> dict: 376 """Remove a paper from a project. 377 378 Args: 379 project_id: Numeric project ID. 380 paper_id: Paper ID to remove. 381 """ 382 try: 383 failed = svc_project.remove_papers(project_id, [paper_id]) 384 except svc_project.ProjectNotFoundError as e: 385 raise ValueError(f"Project {project_id} not found.") from e 386 if failed: 387 raise ValueError(f"Paper {paper_id!r} not found in database.") 388 return {"project_id": project_id, "paper_id": paper_id, 389 "paper_count": _project_paper_count(project_id)} 390 391 392@mcp.tool() 393def export_project(project_id: int, dest: str, include_pdfs: bool = False) -> dict: 394 """Export a project to a .lxproj archive file. 395 396 Args: 397 project_id: Numeric project ID. 398 dest: Destination file path (.lxproj extension added automatically if absent). 399 include_pdfs: Include bundled PDFs in the archive (default False). 400 """ 401 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 402 if details is None: 403 raise ValueError(f"Project {project_id} not found.") 404 out = svc_ei.export_project(project_id, Path(dest), include_pdfs=include_pdfs) 405 return {"path": str(out), "project_id": project_id} 406 407 408@mcp.tool() 409def import_project( 410 zip_path: str, 411 on_conflict: Literal["merge", "overwrite"] = "merge", 412 preview: bool = False, 413) -> dict: 414 """Import a project from a .lxproj archive file. 415 416 Args: 417 zip_path: Path to the .lxproj archive. 418 on_conflict: How to handle papers that already exist — "merge" or "overwrite". 419 preview: If True, return a summary without modifying the database. 420 """ 421 path = Path(zip_path) 422 if preview: 423 result = svc_ei.preview_import(path) 424 return dataclasses.asdict(result) 425 fk = svc_ei.commit_import(path, on_conflict=on_conflict) 426 return {"project_id": fk} 427 428 429# ── Note tools ──────────────────────────────────────────────────────────────── 430 431@mcp.tool() 432def create_note( 433 paper_id: str, 434 content: str, 435 title: str = "", 436 project_id: Optional[int] = None, 437) -> dict: 438 """Create a note attached to a paper, optionally scoped to a project. 439 440 The paper must be in the local database (run fetch_paper first). 441 442 Args: 443 paper_id: Paper ID the note is attached to. 444 content: Body text of the note. 445 title: Optional note title. 446 project_id: Associate the note with a specific project. 447 """ 448 root = svc_paper.get_paper_root(paper_id) 449 if root is None: 450 raise ValueError(f"Paper {paper_id!r} not found. Run fetch_paper first.") 451 source_fk = int(root["SOURCE_FK"]) 452 note_id = svc_note.create(NoteIn(source_fk=source_fk, project_fk=project_id, title=title, content=content)) 453 created = svc_note.get(_SvcNote(note_id=note_id)) 454 return created.to_dict() if created else {"id": note_id, "source_fk": source_fk, "project_id": project_id, "title": title} 455 456 457@mcp.tool() 458def get_note(note_id: int) -> Optional[dict]: 459 """Get a single note by its ID. 460 461 Args: 462 note_id: Numeric note ID. 463 """ 464 details = svc_note.get(_SvcNote(note_id=note_id)) 465 return details.to_dict() if details else None 466 467 468@mcp.tool() 469def list_notes( 470 paper_id: Optional[str] = None, 471 project_id: Optional[int] = None, 472) -> list[dict]: 473 """List notes, optionally filtered by paper or project. 474 475 Omit both arguments to return all notes. 476 477 Args: 478 paper_id: Filter by paper source ID (e.g. "arxiv:2204.12985"). 479 project_id: Filter by project ID. 480 """ 481 if paper_id is None and project_id is None: 482 notes = svc_note.list_all() 483 else: 484 source_fk: Optional[int] = None 485 if paper_id is not None: 486 root = svc_paper.get_paper_root(paper_id) 487 if root is None: 488 raise ValueError(f"Paper {paper_id!r} not found in database.") 489 source_fk = int(root["SOURCE_FK"]) 490 # all_projects=True returns every note for the paper regardless of project scope; 491 # when project_id is also given, project_fk narrows the results as expected. 492 notes = svc_note.get_many(_SvcNotes( 493 source_fk=source_fk, 494 project_fk=project_id, 495 all_projects=paper_id is not None and project_id is None, 496 )) 497 return [n.to_dict() for n in notes] 498 499 500@mcp.tool() 501def update_note( 502 note_id: int, 503 title: Optional[str] = None, 504 content: Optional[str] = None, 505) -> dict: 506 """Update a note's title and/or content. 507 508 At least one of title or content must be provided. 509 510 Args: 511 note_id: Numeric note ID. 512 title: New title (omit to leave unchanged). 513 content: New content (omit to leave unchanged). 514 """ 515 ok = svc_note.update(NoteUpdateIn(note_id=note_id, title=title, content=content)) 516 if not ok: 517 raise ValueError(f"Note {note_id} not found.") 518 updated = svc_note.get(_SvcNote(note_id=note_id)) 519 return updated.to_dict() if updated else {} 520 521 522@mcp.tool() 523def delete_note(note_id: int) -> dict: 524 """Delete a note by its ID. 525 526 Args: 527 note_id: Numeric note ID. 528 """ 529 details = svc_note.get(_SvcNote(note_id=note_id)) 530 if details is None: 531 raise ValueError(f"Note {note_id} not found.") 532 svc_note.delete(_SvcNote(note_id=note_id)) 533 return {"deleted": note_id} 534 535 536@mcp.tool() 537def get_notes_for_paper(paper_id: str, project_id: Optional[int] = None) -> list[dict]: 538 """Retrieve notes attached to a paper. 539 540 Args: 541 paper_id: Paper ID to look up notes for. 542 project_id: Scope to a specific project (None returns all notes for the paper). 543 """ 544 root = svc_paper.get_paper_root(paper_id) 545 if root is None: 546 raise ValueError(f"Paper {paper_id!r} not found in database.") 547 return [n.to_dict() for n in svc_note.get_many(_SvcNotes( 548 source_fk=int(root["SOURCE_FK"]), 549 project_fk=project_id, 550 all_projects=project_id is None, 551 ))] 552 553 554@mcp.tool() 555def get_notes_for_project(project_id: int) -> list[dict]: 556 """Retrieve all notes scoped to a project, across all its papers. 557 558 Args: 559 project_id: Numeric project ID. 560 """ 561 return [n.to_dict() for n in svc_note.get_many(_SvcNotes(project_fk=project_id))] 562 563 564# ── PDF tools ───────────────────────────────────────────────────────────────── 565 566@mcp.tool() 567def get_pdf_path(paper_id: str, version: Optional[int] = None) -> dict: 568 """Get the local filesystem path for a paper's PDF, if downloaded. 569 570 Args: 571 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 572 version: Specific version number (defaults to latest). 573 """ 574 paper = svc_paper.get(Paper(source_id=paper_id, version=version)) 575 if paper is None: 576 raise ValueError(f"Paper {paper_id!r} not found in database.") 577 ver = paper.version 578 path = svc_files.pdf_path(paper.source_id, ver, paper.pdf_path) 579 return {"paper_id": paper_id, "version": ver, "path": path} 580 581 582@mcp.tool() 583def download_pdf(paper_id: str, url: str, version: Optional[int] = None) -> dict: 584 """Download a PDF for a paper and save it to the managed PDF directory. 585 586 Args: 587 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 588 url: Direct URL to the PDF file. 589 version: Specific version number (defaults to latest). 590 """ 591 paper = svc_paper.get(Paper(source_id=paper_id, version=version)) 592 if paper is None: 593 raise ValueError(f"Paper {paper_id!r} not found in database.") 594 ver = paper.version 595 path = svc_files.download_pdf(paper.source_id, ver, url) 596 svc_paper.mark_pdf_saved(paper.source_id, path, ver) 597 return {"paper_id": paper_id, "version": ver, "path": path} 598 599 600@mcp.tool() 601def get_pdf_storage() -> dict: 602 """Report total PDF storage usage for all managed PDFs. 603 604 Returns storage in megabytes and the path to the PDF directory. 605 """ 606 mb = svc_files.pdf_storage_mb() 607 return {"storage_mb": round(mb, 3), "pdf_dir": svc_files.managed_pdf_dir()} 608 609 610# ── Paper management tools ────────────────────────────────────────────────────── 611 612@mcp.tool() 613def repair_paper( 614 paper_id: str, 615 title: str, 616 authors: list[str], 617 published: str, 618 summary: str = "", 619 category: Optional[str] = None, 620 doi: Optional[str] = None, 621 url: Optional[str] = None, 622 tags: Optional[list[str]] = None, 623) -> dict: 624 """Overwrite a paper's metadata in-place to fix a bad import (wrong title, authors, etc.). 625 626 Keyed by the stable paper root, so the correction survives a source_id rename. 627 628 Args: 629 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 630 title: Corrected title. 631 authors: Corrected list of author names. 632 published: Publication date in YYYY-MM-DD format. 633 summary: Abstract / summary text. 634 category: Primary category (e.g. "cs.LG"). 635 doi: DOI string. 636 url: Canonical URL. 637 tags: Replacement tag list. 638 """ 639 root = svc_paper.get_paper_root(paper_id) 640 if root is None: 641 raise ValueError(f"Paper {paper_id!r} not found in database.") 642 source_fk = int(root["SOURCE_FK"]) 643 existing = svc_paper.get(Paper(source_id=paper_id)) 644 version = existing.version if existing is not None else 1 645 try: 646 published_date = datetime.date.fromisoformat(published) 647 except ValueError: 648 raise ValueError(f"Invalid date {published!r}; use YYYY-MM-DD.") 649 meta = PaperMetadata( 650 source_id=paper_id, 651 version=version, 652 title=title, 653 authors=authors, 654 published=published_date, 655 summary=summary or "", 656 category=category, 657 doi=doi, 658 url=url, 659 tags=tags or None, 660 source=None, 661 ) 662 svc_paper.repair_paper(source_fk, meta) 663 return {"repaired": paper_id} 664 665 666@mcp.tool() 667def restore_paper(paper_id: str) -> dict: 668 """Restore a soft-deleted (trashed) paper back into the library. 669 670 Args: 671 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 672 """ 673 if not svc_paper.is_paper_deleted(paper_id): 674 raise ValueError(f"Paper {paper_id!r} not found in trash.") 675 pdf_path, project_fks = svc_paper.restore(Paper(source_id=paper_id)) 676 return {"restored": paper_id, "pdf_path": pdf_path, "project_fks": project_fks} 677 678 679@mcp.tool() 680def hard_delete_paper(paper_id: str) -> dict: 681 """Permanently delete a paper and all its data. This is irreversible. 682 683 Works regardless of whether the paper is in the trash. For a trash-only 684 guard, use trash_hard_delete_paper instead. 685 686 Args: 687 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 688 """ 689 root = svc_paper.get_paper_root(paper_id) 690 if root is None: 691 raise ValueError(f"Paper {paper_id!r} not found.") 692 svc_paper.hard_delete(Paper(source_id=paper_id)) 693 return {"hard_deleted": paper_id} 694 695 696@mcp.tool() 697def remove_paper_from_all_projects(paper_id: str) -> dict: 698 """Remove a paper from every project it currently belongs to. 699 700 Args: 701 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 702 """ 703 removed = svc_project.remove_paper_from_all_projects_by_id(paper_id) 704 if removed is None: 705 raise ValueError(f"Paper {paper_id!r} not found.") 706 return {"paper_id": paper_id, "removed_from_projects": removed} 707 708 709# ── Trash tools ───────────────────────────────────────────────────────────────── 710 711@mcp.tool() 712def list_trash() -> dict: 713 """List all soft-deleted papers and projects currently in the trash.""" 714 papers = svc_paper.list_deleted() 715 projects = svc_project.list_deleted() 716 return { 717 "papers": [_asdict_json(p) for p in papers], 718 "projects": [p.to_dict() for p in projects], 719 } 720 721 722@mcp.tool() 723def trash_hard_delete_paper(paper_id: str) -> dict: 724 """Permanently delete a trashed paper. Only works if the paper is in the trash. 725 726 Use this for safe permanent deletion of items already soft-deleted; for an 727 unconditional purge use hard_delete_paper. 728 729 Args: 730 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 731 """ 732 if not svc_paper.is_paper_deleted(paper_id): 733 raise ValueError(f"Paper {paper_id!r} not found in trash.") 734 root = svc_paper.get_paper_root(paper_id) 735 if root is None: 736 raise ValueError(f"Paper {paper_id!r} not found.") 737 svc_paper.hard_delete(Paper(source_id=paper_id)) 738 return {"hard_deleted": paper_id} 739 740 741@mcp.tool() 742def restore_project_from_trash(project_id: int) -> dict: 743 """Restore a project from the trash. Only works if the project is soft-deleted. 744 745 Args: 746 project_id: Numeric project ID. 747 """ 748 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 749 if details is None: 750 raise ValueError(f"Project {project_id} not found.") 751 if details.status != _SvcStatus.DELETED: 752 raise ValueError(f"Project {project_id} is not in trash.") 753 svc_project.restore(_SvcProjectFilter(project_fk=project_id)) 754 return {"restored_project_id": project_id} 755 756 757@mcp.tool() 758def hard_delete_project_from_trash(project_id: int) -> dict: 759 """Permanently delete a trashed project. Only works if the project is soft-deleted. 760 761 Args: 762 project_id: Numeric project ID. 763 """ 764 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 765 if details is None: 766 raise ValueError(f"Project {project_id} not found.") 767 if details.status != _SvcStatus.DELETED: 768 raise ValueError(f"Project {project_id} is not in trash.") 769 svc_project.hard_delete(_SvcProjectFilter(project_fk=project_id)) 770 return {"hard_deleted_project_id": project_id} 771 772 773# ── Project lifecycle tools ───────────────────────────────────────────────────── 774 775@mcp.tool() 776def archive_project(project_id: int) -> dict: 777 """Archive a project (read-only, still visible). Use restore_project to reactivate. 778 779 Args: 780 project_id: Numeric project ID. 781 """ 782 if svc_project.get(_SvcProjectFilter(project_fk=project_id)) is None: 783 raise ValueError(f"Project {project_id} not found.") 784 svc_project.archive(_SvcProjectFilter(project_fk=project_id)) 785 return {"archived_project_id": project_id} 786 787 788@mcp.tool() 789def restore_project(project_id: int) -> dict: 790 """Restore an archived or soft-deleted project back to active status. 791 792 Args: 793 project_id: Numeric project ID. 794 """ 795 if svc_project.get(_SvcProjectFilter(project_fk=project_id)) is None: 796 raise ValueError(f"Project {project_id} not found.") 797 svc_project.restore(_SvcProjectFilter(project_fk=project_id)) 798 return {"restored_project_id": project_id} 799 800 801@mcp.tool() 802def hard_delete_project(project_id: int) -> dict: 803 """Permanently delete a project. This is irreversible. Papers themselves are kept. 804 805 Works regardless of the project's status. For a trash-only guard, use 806 hard_delete_project_from_trash instead. 807 808 Args: 809 project_id: Numeric project ID. 810 """ 811 if svc_project.get(_SvcProjectFilter(project_fk=project_id)) is None: 812 raise ValueError(f"Project {project_id} not found.") 813 svc_project.hard_delete(_SvcProjectFilter(project_fk=project_id)) 814 return {"hard_deleted_project_id": project_id} 815 816 817# ── Project export tools ──────────────────────────────────────────────────────── 818 819@mcp.tool() 820def export_project_bibtex(project_id: int, dest: str) -> dict: 821 """Export a project's papers to a BibTeX (.bib) file. 822 823 Args: 824 project_id: Numeric project ID. 825 dest: Output file path (.bib added automatically if no extension is given). 826 """ 827 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 828 if details is None: 829 raise ValueError(f"Project {project_id} not found.") 830 papers = svc_paper.get_many(Papers(source_fks=details.source_fks)) if details.source_fks else [] 831 bibtex_str = BibTeXFormat().export_papers([dataclasses.asdict(p) for p in papers]) 832 out = Path(dest) 833 if not out.suffix: 834 out = out.with_suffix(".bib") 835 out.write_text(bibtex_str, encoding="utf-8") 836 return {"path": str(out), "project_id": project_id} 837 838 839@mcp.tool() 840def export_project_obsidian(project_id: int, dest: str) -> dict: 841 """Export a project's papers as Obsidian-style markdown notes. 842 843 Args: 844 project_id: Numeric project ID. 845 dest: Output file path (.md added automatically if no extension is given). 846 """ 847 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 848 if details is None: 849 raise ValueError(f"Project {project_id} not found.") 850 papers = svc_paper.get_many(Papers(source_fks=details.source_fks)) if details.source_fks else [] 851 md_str = ObsidianFormat().export_papers([dataclasses.asdict(p) for p in papers]) 852 out = Path(dest) 853 if not out.suffix: 854 out = out.with_suffix(".md") 855 out.write_text(md_str, encoding="utf-8") 856 return {"path": str(out), "project_id": project_id} 857 858 859# ── Project tag tools ─────────────────────────────────────────────────────────── 860 861@mcp.tool() 862def add_tags_to_project(project_id: int, tags: list[str]) -> dict: 863 """Add one or more tags to a project. 864 865 Args: 866 project_id: Numeric project ID. 867 tags: List of tag labels to add. 868 """ 869 if svc_project.get(_SvcProjectFilter(project_fk=project_id)) is None: 870 raise ValueError(f"Project {project_id} not found.") 871 updated = svc_tag.add_project_tags(project_id, tags) 872 return {"project_id": project_id, "tags": updated} 873 874 875@mcp.tool() 876def remove_tags_from_project(project_id: int, tags: list[str]) -> dict: 877 """Remove one or more tags from a project. 878 879 Args: 880 project_id: Numeric project ID. 881 tags: List of tag labels to remove. 882 """ 883 if svc_project.get(_SvcProjectFilter(project_fk=project_id)) is None: 884 raise ValueError(f"Project {project_id} not found.") 885 updated = svc_tag.remove_project_tags(project_id, tags) 886 return {"project_id": project_id, "tags": updated} 887 888 889@mcp.tool() 890def get_project_tags(project_id: int) -> dict: 891 """Get all tags applied to a project. 892 893 Args: 894 project_id: Numeric project ID. 895 """ 896 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 897 if details is None: 898 raise ValueError(f"Project {project_id} not found.") 899 return {"project_id": project_id, "tags": details.project_tags} 900 901 902# ── DOI tools ─────────────────────────────────────────────────────────────────── 903 904@mcp.tool() 905def resolve_doi(doi: str) -> dict: 906 """Resolve a DOI to paper metadata without saving it to the library. 907 908 Args: 909 doi: DOI string (e.g. "10.1038/nature12373"). 910 """ 911 meta = _resolve_doi(doi) 912 return meta.model_dump(mode="json") 913 914 915@mcp.tool() 916def save_doi(doi: str) -> dict: 917 """Resolve a DOI and save the resulting paper to the local library. 918 919 Args: 920 doi: DOI string (e.g. "10.1038/nature12373"). 921 """ 922 meta = _resolve_doi(doi) 923 source_id, ver = svc_paper.save_paper_metadata(meta) 924 return {"source_id": source_id, "version": ver, "title": meta.title} 925 926 927# ── Author tools ──────────────────────────────────────────────────────────────── 928 929@mcp.tool() 930def list_authors() -> list[dict]: 931 """List all authors in the library with their paper counts.""" 932 return [a.to_dict() for a in svc_author.list_with_paper_count()] 933 934 935@mcp.tool() 936def get_author(author_id: int) -> dict: 937 """Get an author's details together with a preview of their papers. 938 939 Args: 940 author_id: Numeric author ID. 941 """ 942 author = svc_author.get(Author(author_id=author_id)) 943 if author is None: 944 raise ValueError(f"Author {author_id} not found.") 945 previews = svc_author.get_paper_previews(author_id) 946 result = author.to_dict() 947 result["papers"] = [p.to_dict() for p in previews] 948 return result 949 950 951@mcp.tool() 952def update_author( 953 author_id: int, 954 full_name: Optional[str] = None, 955 first_name: Optional[str] = None, 956 last_name: Optional[str] = None, 957 orcid: Optional[str] = None, 958) -> dict: 959 """Update an author's fields. At least one field must be provided. 960 961 Args: 962 author_id: Numeric author ID. 963 full_name: New full name. 964 first_name: New first name. 965 last_name: New last name. 966 orcid: New ORCID identifier. 967 """ 968 if svc_author.get(Author(author_id=author_id)) is None: 969 raise ValueError(f"Author {author_id} not found.") 970 if full_name is None and first_name is None and last_name is None and orcid is None: 971 raise ValueError("At least one of full_name, first_name, last_name, or orcid must be provided.") 972 svc_author.update_fields( 973 author_id=author_id, 974 full_name=full_name, 975 first_name=first_name, 976 last_name=last_name, 977 orcid=orcid, 978 ) 979 return {"updated_author_id": author_id} 980 981 982@mcp.tool() 983def delete_author(author_id: int) -> dict: 984 """Delete an author. Blocked if the author is still linked to any papers. 985 986 Args: 987 author_id: Numeric author ID. 988 """ 989 link_count = svc_author.count_paper_links(author_id) 990 if link_count > 0: 991 raise ValueError(f"Author {author_id} is linked to {link_count} paper(s); unlink first.") 992 svc_author.delete_author(author_id) 993 return {"deleted_author_id": author_id} 994 995 996# ── Import tools ──────────────────────────────────────────────────────────────── 997 998@mcp.tool() 999def import_bibtex(file: str, project_id: Optional[int] = None) -> dict: 1000 """Bulk-import papers from a BibTeX (.bib) file into the library. 1001 1002 Args: 1003 file: Path to the .bib file on disk. 1004 project_id: Optionally link all imported papers to this project. 1005 """ 1006 if project_id is not None: 1007 # Guard before parsing/saving so a missing or deleted project fails 1008 # the tool call before the library is mutated. 1009 try: 1010 svc_project.ensure_membership_writable(project_id) 1011 except svc_project.ProjectNotFoundError as e: 1012 raise ValueError(f"Project {project_id} not found.") from e 1013 metas = BibTeXFormat().import_file(file) 1014 results = svc_paper.save_papers_metadata(metas) 1015 if project_id is not None and results: 1016 try: 1017 svc_project.link_imported(project_id, [s for s, _ in results]) 1018 except (svc_project.ProjectNotFoundError, svc_project.ProjectDeletedError) as e: 1019 # Project went away between the guard above and the link; the 1020 # message says the papers stayed imported. 1021 raise ValueError( 1022 f"{len(results)} paper(s) were imported but could not be linked: {e}" 1023 ) from e 1024 return {"imported": len(results), "papers": [{"source_id": s, "version": v} for s, v in results]} 1025 1026 1027@mcp.tool() 1028def import_pdf(file: str, project_id: Optional[int] = None) -> dict: 1029 """Import a local PDF file, extracting paper metadata from its contents. 1030 1031 Args: 1032 file: Path to the PDF file on disk. 1033 project_id: Optionally link the imported paper to this project. 1034 """ 1035 if project_id is not None: 1036 # import_pdf re-applies the same guards; this converts the missing- 1037 # project case to the MCP-conventional ValueError before reading the file. 1038 try: 1039 svc_project.ensure_membership_writable(project_id) 1040 except svc_project.ProjectNotFoundError as e: 1041 raise ValueError(f"Project {project_id} not found.") from e 1042 content = Path(file).read_bytes() 1043 try: 1044 result = svc_paper.import_pdf(content, project_id) 1045 except svc_project.ProjectNotFoundError as e: 1046 # Project hard-deleted between the guard above and import_pdf's own 1047 # pre-import guard — nothing was imported. 1048 raise ValueError(f"Project {project_id} not found.") from e 1049 except svc_paper.PaperLinkError as e: 1050 # Project went away after the import; the paper stays imported and 1051 # the message says so. 1052 raise ValueError(str(e)) from e 1053 return _asdict_json(result) 1054 1055 1056# ── System tools ──────────────────────────────────────────────────────────────── 1057 1058@mcp.tool() 1059def get_stats() -> dict: 1060 """Report library statistics: paper, tag, category, and downloaded-PDF counts.""" 1061 papers = svc_paper.list_paper_details(latest_only=True) 1062 categories = svc_paper.get_categories() 1063 all_tags = svc_tag.list_all_tags() 1064 pdf_count = sum(1 for p in papers if p.has_pdf) 1065 return { 1066 "paper_count": len(papers), 1067 "tag_count": len(all_tags), 1068 "category_count": len(categories), 1069 "pdf_count": pdf_count, 1070 } 1071 1072 1073@mcp.tool() 1074def list_categories() -> list[str]: 1075 """List all distinct paper categories present in the library.""" 1076 return svc_paper.get_categories() 1077 1078 1079@mcp.tool() 1080def get_settings() -> dict: 1081 """Get all current user settings.""" 1082 return user_settings.all_settings() 1083 1084 1085@mcp.tool() 1086def update_setting(key: str, value: str) -> dict: 1087 """Update a single user setting. 1088 1089 The value is parsed as JSON when it is valid JSON (so "true", "42", or 1090 '["a","b"]' become the corresponding types); otherwise it is stored as a string. 1091 1092 Args: 1093 key: Setting key. 1094 value: New value (JSON-parsed when valid JSON, else stored as a string). 1095 """ 1096 try: 1097 parsed: Any = json.loads(value) 1098 except json.JSONDecodeError: 1099 parsed = value 1100 user_settings.set(key, parsed) 1101 return {key: parsed} 1102 1103 1104def main() -> None: 1105 mcp.run() 1106 1107 1108if __name__ == "__main__": 1109 main()
84@mcp.tool() 85def search_papers(query: str, source: str = "arxiv", max_results: int = 10) -> list[dict]: 86 """Search for academic papers by keyword. 87 88 Args: 89 query: Search query string (e.g. "transformer attention mechanism"). 90 source: Data source — "arxiv", "crossref", or "openalex". 91 max_results: Maximum number of results to return (default 10). 92 """ 93 return [r.model_dump(mode="json") for r in _resolve_source(source)().search(query, max_results=max_results)]
Search for academic papers by keyword.
Args: query: Search query string (e.g. "transformer attention mechanism"). source: Data source — "arxiv", "crossref", or "openalex". max_results: Maximum number of results to return (default 10).
96@mcp.tool() 97def fetch_paper(paper_id: str, source: str = "arxiv") -> dict: 98 """Fetch full metadata for a paper by ID and save it to the local database. 99 100 Args: 101 paper_id: arXiv style (e.g. "2204.12985"), CrossRef DOI, or OpenAlex ID (e.g. "W3123456789"). 102 source: Data source — "arxiv", "crossref", or "openalex". 103 """ 104 meta = _resolve_source(source)().fetch_by_id(paper_id) 105 svc_paper.save_paper_metadata(meta) 106 return meta.model_dump(mode="json")
Fetch full metadata for a paper by ID and save it to the local database.
Args: paper_id: arXiv style (e.g. "2204.12985"), CrossRef DOI, or OpenAlex ID (e.g. "W3123456789"). source: Data source — "arxiv", "crossref", or "openalex".
109@mcp.tool() 110def list_papers(limit: Optional[int] = None, offset: int = 0, category: Optional[str] = None) -> list[dict]: 111 """List papers stored in the local database. 112 113 Args: 114 limit: Maximum number of papers to return (default: all). 115 offset: Number of papers to skip for pagination. 116 category: Filter by arXiv primary category (e.g. "cs.LG"). 117 """ 118 papers = svc_paper.list_paper_details(limit=limit, offset=offset, category=category) 119 return [p.to_dict() for p in papers]
List papers stored in the local database.
Args: limit: Maximum number of papers to return (default: all). offset: Number of papers to skip for pagination. category: Filter by arXiv primary category (e.g. "cs.LG").
122@mcp.tool() 123def get_paper(paper_id: str) -> Optional[dict]: 124 """Get full metadata for a single paper from the local database. 125 126 Args: 127 paper_id: The paper ID (e.g. "arxiv:2204.12985" or "2204.12985"). 128 """ 129 paper = svc_paper.get(Paper(source_id=paper_id)) 130 return paper.to_dict() if paper else None
Get full metadata for a single paper from the local database.
Args: paper_id: The paper ID (e.g. "arxiv:2204.12985" or "2204.12985").
133@mcp.tool() 134def delete_paper(paper_id: str) -> dict: 135 """Soft-delete a paper from the local database. 136 137 The paper is moved to trash and can be restored. Use paper_id in the 138 format returned by list_papers or get_paper (e.g. "arxiv:2204.12985"). 139 140 Args: 141 paper_id: The paper source ID to delete. 142 """ 143 if svc_paper.get(Paper(source_id=paper_id)) is None: 144 raise ValueError(f"Paper {paper_id!r} not found in database.") 145 svc_paper.delete(Paper(source_id=paper_id)) 146 return {"deleted": paper_id}
Soft-delete a paper from the local database.
The paper is moved to trash and can be restored. Use paper_id in the format returned by list_papers or get_paper (e.g. "arxiv:2204.12985").
Args: paper_id: The paper source ID to delete.
149@mcp.tool() 150def get_paper_versions(paper_id: str) -> Optional[dict]: 151 """Get all stored versions of a paper. 152 153 Args: 154 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 155 """ 156 all_ver = svc_paper.get_all(Paper(source_id=paper_id)) 157 if all_ver is None: 158 return None 159 return _asdict_json(all_ver)
Get all stored versions of a paper.
Args: paper_id: The paper source ID (e.g. "arxiv:2204.12985").
162@mcp.tool() 163def search_full_text(query: str, limit: int = 20) -> list[dict]: 164 """Full-text search over downloaded TeX source content. 165 166 Only papers whose TeX source has been downloaded will appear. 167 168 Args: 169 query: SQLite FTS5 query string. 170 limit: Maximum number of results (default 20). 171 """ 172 try: 173 return [p.to_dict() for p in svc_paper.search_full_text_details(query, limit=limit)] 174 except Exception as exc: 175 print(f"[mcp] search_full_text error for query {query!r}: {exc}") 176 return []
Full-text search over downloaded TeX source content.
Only papers whose TeX source has been downloaded will appear.
Args: query: SQLite FTS5 query string. limit: Maximum number of results (default 20).
228@mcp.tool() 229def create_tag(label: str) -> dict: 230 """Create a new tag (or return its ID if it already exists). 231 232 Args: 233 label: Tag label text. 234 """ 235 tag_id = svc_tag.upsert(TagIn(label=label)) 236 if tag_id is None or tag_id < 0: 237 raise RuntimeError(f"Failed to create or locate tag {label!r}.") 238 return {"tag_id": tag_id, "label": label}
Create a new tag (or return its ID if it already exists).
Args: label: Tag label text.
241@mcp.tool() 242def delete_tag(tag_id: int) -> dict: 243 """Delete a tag by its ID. 244 245 Args: 246 tag_id: Numeric tag ID (from create_tag or list_all_tags). 247 """ 248 if svc_tag.get(Tag(tag_id=tag_id)) is None: 249 raise ValueError(f"Tag {tag_id} not found.") 250 svc_tag.delete(Tag(tag_id=tag_id)) 251 return {"deleted": tag_id}
Delete a tag by its ID.
Args: tag_id: Numeric tag ID (from create_tag or list_all_tags).
256@mcp.tool() 257def list_projects(status: Optional[str] = None) -> list[dict]: 258 """List research projects. 259 260 Args: 261 status: Filter by status — "active", "archived", or "deleted". 262 Defaults to all non-deleted projects. 263 """ 264 if status is not None: 265 try: 266 status_enum = _SvcStatus(status) 267 except ValueError: 268 raise ValueError(f"Invalid status {status!r}. Use 'active', 'archived', or 'deleted'.") 269 projects = svc_project.get_many(_SvcProjects(status=status_enum)) 270 else: 271 all_projects = svc_project.get_many(_SvcProjects()) 272 projects = [p for p in all_projects if p.status != _SvcStatus.DELETED] 273 return [p.to_dict() for p in projects]
List research projects.
Args: status: Filter by status — "active", "archived", or "deleted". Defaults to all non-deleted projects.
276@mcp.tool() 277def get_project(project_id: int) -> Optional[dict]: 278 """Get full details for a project. 279 280 Args: 281 project_id: Numeric project ID. 282 """ 283 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 284 return details.to_dict() if details else None
Get full details for a project.
Args: project_id: Numeric project ID.
287@mcp.tool() 288def create_project(name: str, description: str = "") -> dict: 289 """Create a new research project. 290 291 Args: 292 name: Project name. 293 description: Optional description. 294 """ 295 fk = svc_project.create(ProjectIn(name=name, description=description)) 296 details = svc_project.get(_SvcProjectFilter(project_fk=fk)) 297 return details.to_dict() if details else {"id": fk, "name": name}
Create a new research project.
Args: name: Project name. description: Optional description.
300@mcp.tool() 301def update_project( 302 project_id: int, 303 name: Optional[str] = None, 304 description: Optional[str] = None, 305 color: Optional[str] = None, 306 tags: Optional[list[str]] = None, 307 status: Optional[str] = None, 308) -> dict: 309 """Update a project's name, description, color, tags, or lifecycle status. 310 311 Args: 312 project_id: Numeric project ID. 313 name: New name (omit to leave unchanged). 314 description: New description (omit to leave unchanged). 315 color: New hex color, e.g. "#4f86f7" (omit to leave unchanged). 316 tags: Replacement project tag list (omit to leave unchanged; [] clears all). 317 status: New lifecycle status — "active", "archived", or "deleted". 318 """ 319 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 320 if details is None: 321 raise ValueError(f"Project {project_id} not found.") 322 color_arg: Any = UNSET 323 if color is not None: 324 color_arg = svc_project.color_from_hex(color) 325 status_enum = None 326 if status is not None: 327 try: 328 status_enum = _SvcStatus(status) 329 except ValueError: 330 raise ValueError(f"Invalid status {status!r}. Use 'active', 'archived', or 'deleted'.") 331 svc_project.update( 332 project_id, 333 name=name, 334 description=description, 335 color=color_arg, 336 project_tags=tags, 337 status=status_enum, 338 ) 339 updated = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 340 return updated.to_dict() if updated else {}
Update a project's name, description, color, tags, or lifecycle status.
Args: project_id: Numeric project ID. name: New name (omit to leave unchanged). description: New description (omit to leave unchanged). color: New hex color, e.g. "#4f86f7" (omit to leave unchanged). tags: Replacement project tag list (omit to leave unchanged; [] clears all). status: New lifecycle status — "active", "archived", or "deleted".
343@mcp.tool() 344def delete_project(project_id: int) -> dict: 345 """Soft-delete a project (moves it to trash). 346 347 Args: 348 project_id: Numeric project ID. 349 """ 350 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 351 if details is None: 352 raise ValueError(f"Project {project_id} not found.") 353 svc_project.delete(_SvcProjectFilter(project_fk=project_id)) 354 return {"deleted": project_id}
Soft-delete a project (moves it to trash).
Args: project_id: Numeric project ID.
357@mcp.tool() 358def add_paper_to_project(project_id: int, paper_id: str) -> dict: 359 """Add a paper to an existing project. 360 361 Args: 362 project_id: Numeric project ID. 363 paper_id: Paper ID to add (e.g. "arxiv:2204.12985"). 364 """ 365 try: 366 failed = svc_project.add_papers(project_id, [paper_id]) 367 except svc_project.ProjectNotFoundError as e: 368 raise ValueError(f"Project {project_id} not found.") from e 369 if failed: 370 raise ValueError(f"Paper {paper_id!r} not found in database.") 371 return {"project_id": project_id, "paper_id": paper_id, 372 "paper_count": _project_paper_count(project_id)}
Add a paper to an existing project.
Args: project_id: Numeric project ID. paper_id: Paper ID to add (e.g. "arxiv:2204.12985").
375@mcp.tool() 376def remove_paper_from_project(project_id: int, paper_id: str) -> dict: 377 """Remove a paper from a project. 378 379 Args: 380 project_id: Numeric project ID. 381 paper_id: Paper ID to remove. 382 """ 383 try: 384 failed = svc_project.remove_papers(project_id, [paper_id]) 385 except svc_project.ProjectNotFoundError as e: 386 raise ValueError(f"Project {project_id} not found.") from e 387 if failed: 388 raise ValueError(f"Paper {paper_id!r} not found in database.") 389 return {"project_id": project_id, "paper_id": paper_id, 390 "paper_count": _project_paper_count(project_id)}
Remove a paper from a project.
Args: project_id: Numeric project ID. paper_id: Paper ID to remove.
393@mcp.tool() 394def export_project(project_id: int, dest: str, include_pdfs: bool = False) -> dict: 395 """Export a project to a .lxproj archive file. 396 397 Args: 398 project_id: Numeric project ID. 399 dest: Destination file path (.lxproj extension added automatically if absent). 400 include_pdfs: Include bundled PDFs in the archive (default False). 401 """ 402 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 403 if details is None: 404 raise ValueError(f"Project {project_id} not found.") 405 out = svc_ei.export_project(project_id, Path(dest), include_pdfs=include_pdfs) 406 return {"path": str(out), "project_id": project_id}
Export a project to a .lxproj archive file.
Args: project_id: Numeric project ID. dest: Destination file path (.lxproj extension added automatically if absent). include_pdfs: Include bundled PDFs in the archive (default False).
409@mcp.tool() 410def import_project( 411 zip_path: str, 412 on_conflict: Literal["merge", "overwrite"] = "merge", 413 preview: bool = False, 414) -> dict: 415 """Import a project from a .lxproj archive file. 416 417 Args: 418 zip_path: Path to the .lxproj archive. 419 on_conflict: How to handle papers that already exist — "merge" or "overwrite". 420 preview: If True, return a summary without modifying the database. 421 """ 422 path = Path(zip_path) 423 if preview: 424 result = svc_ei.preview_import(path) 425 return dataclasses.asdict(result) 426 fk = svc_ei.commit_import(path, on_conflict=on_conflict) 427 return {"project_id": fk}
Import a project from a .lxproj archive file.
Args: zip_path: Path to the .lxproj archive. on_conflict: How to handle papers that already exist — "merge" or "overwrite". preview: If True, return a summary without modifying the database.
432@mcp.tool() 433def create_note( 434 paper_id: str, 435 content: str, 436 title: str = "", 437 project_id: Optional[int] = None, 438) -> dict: 439 """Create a note attached to a paper, optionally scoped to a project. 440 441 The paper must be in the local database (run fetch_paper first). 442 443 Args: 444 paper_id: Paper ID the note is attached to. 445 content: Body text of the note. 446 title: Optional note title. 447 project_id: Associate the note with a specific project. 448 """ 449 root = svc_paper.get_paper_root(paper_id) 450 if root is None: 451 raise ValueError(f"Paper {paper_id!r} not found. Run fetch_paper first.") 452 source_fk = int(root["SOURCE_FK"]) 453 note_id = svc_note.create(NoteIn(source_fk=source_fk, project_fk=project_id, title=title, content=content)) 454 created = svc_note.get(_SvcNote(note_id=note_id)) 455 return created.to_dict() if created else {"id": note_id, "source_fk": source_fk, "project_id": project_id, "title": title}
Create a note attached to a paper, optionally scoped to a project.
The paper must be in the local database (run fetch_paper first).
Args: paper_id: Paper ID the note is attached to. content: Body text of the note. title: Optional note title. project_id: Associate the note with a specific project.
458@mcp.tool() 459def get_note(note_id: int) -> Optional[dict]: 460 """Get a single note by its ID. 461 462 Args: 463 note_id: Numeric note ID. 464 """ 465 details = svc_note.get(_SvcNote(note_id=note_id)) 466 return details.to_dict() if details else None
Get a single note by its ID.
Args: note_id: Numeric note ID.
469@mcp.tool() 470def list_notes( 471 paper_id: Optional[str] = None, 472 project_id: Optional[int] = None, 473) -> list[dict]: 474 """List notes, optionally filtered by paper or project. 475 476 Omit both arguments to return all notes. 477 478 Args: 479 paper_id: Filter by paper source ID (e.g. "arxiv:2204.12985"). 480 project_id: Filter by project ID. 481 """ 482 if paper_id is None and project_id is None: 483 notes = svc_note.list_all() 484 else: 485 source_fk: Optional[int] = None 486 if paper_id is not None: 487 root = svc_paper.get_paper_root(paper_id) 488 if root is None: 489 raise ValueError(f"Paper {paper_id!r} not found in database.") 490 source_fk = int(root["SOURCE_FK"]) 491 # all_projects=True returns every note for the paper regardless of project scope; 492 # when project_id is also given, project_fk narrows the results as expected. 493 notes = svc_note.get_many(_SvcNotes( 494 source_fk=source_fk, 495 project_fk=project_id, 496 all_projects=paper_id is not None and project_id is None, 497 )) 498 return [n.to_dict() for n in notes]
List notes, optionally filtered by paper or project.
Omit both arguments to return all notes.
Args: paper_id: Filter by paper source ID (e.g. "arxiv:2204.12985"). project_id: Filter by project ID.
501@mcp.tool() 502def update_note( 503 note_id: int, 504 title: Optional[str] = None, 505 content: Optional[str] = None, 506) -> dict: 507 """Update a note's title and/or content. 508 509 At least one of title or content must be provided. 510 511 Args: 512 note_id: Numeric note ID. 513 title: New title (omit to leave unchanged). 514 content: New content (omit to leave unchanged). 515 """ 516 ok = svc_note.update(NoteUpdateIn(note_id=note_id, title=title, content=content)) 517 if not ok: 518 raise ValueError(f"Note {note_id} not found.") 519 updated = svc_note.get(_SvcNote(note_id=note_id)) 520 return updated.to_dict() if updated else {}
Update a note's title and/or content.
At least one of title or content must be provided.
Args: note_id: Numeric note ID. title: New title (omit to leave unchanged). content: New content (omit to leave unchanged).
523@mcp.tool() 524def delete_note(note_id: int) -> dict: 525 """Delete a note by its ID. 526 527 Args: 528 note_id: Numeric note ID. 529 """ 530 details = svc_note.get(_SvcNote(note_id=note_id)) 531 if details is None: 532 raise ValueError(f"Note {note_id} not found.") 533 svc_note.delete(_SvcNote(note_id=note_id)) 534 return {"deleted": note_id}
Delete a note by its ID.
Args: note_id: Numeric note ID.
537@mcp.tool() 538def get_notes_for_paper(paper_id: str, project_id: Optional[int] = None) -> list[dict]: 539 """Retrieve notes attached to a paper. 540 541 Args: 542 paper_id: Paper ID to look up notes for. 543 project_id: Scope to a specific project (None returns all notes for the paper). 544 """ 545 root = svc_paper.get_paper_root(paper_id) 546 if root is None: 547 raise ValueError(f"Paper {paper_id!r} not found in database.") 548 return [n.to_dict() for n in svc_note.get_many(_SvcNotes( 549 source_fk=int(root["SOURCE_FK"]), 550 project_fk=project_id, 551 all_projects=project_id is None, 552 ))]
Retrieve notes attached to a paper.
Args: paper_id: Paper ID to look up notes for. project_id: Scope to a specific project (None returns all notes for the paper).
555@mcp.tool() 556def get_notes_for_project(project_id: int) -> list[dict]: 557 """Retrieve all notes scoped to a project, across all its papers. 558 559 Args: 560 project_id: Numeric project ID. 561 """ 562 return [n.to_dict() for n in svc_note.get_many(_SvcNotes(project_fk=project_id))]
Retrieve all notes scoped to a project, across all its papers.
Args: project_id: Numeric project ID.
567@mcp.tool() 568def get_pdf_path(paper_id: str, version: Optional[int] = None) -> dict: 569 """Get the local filesystem path for a paper's PDF, if downloaded. 570 571 Args: 572 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 573 version: Specific version number (defaults to latest). 574 """ 575 paper = svc_paper.get(Paper(source_id=paper_id, version=version)) 576 if paper is None: 577 raise ValueError(f"Paper {paper_id!r} not found in database.") 578 ver = paper.version 579 path = svc_files.pdf_path(paper.source_id, ver, paper.pdf_path) 580 return {"paper_id": paper_id, "version": ver, "path": path}
Get the local filesystem path for a paper's PDF, if downloaded.
Args: paper_id: The paper source ID (e.g. "arxiv:2204.12985"). version: Specific version number (defaults to latest).
583@mcp.tool() 584def download_pdf(paper_id: str, url: str, version: Optional[int] = None) -> dict: 585 """Download a PDF for a paper and save it to the managed PDF directory. 586 587 Args: 588 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 589 url: Direct URL to the PDF file. 590 version: Specific version number (defaults to latest). 591 """ 592 paper = svc_paper.get(Paper(source_id=paper_id, version=version)) 593 if paper is None: 594 raise ValueError(f"Paper {paper_id!r} not found in database.") 595 ver = paper.version 596 path = svc_files.download_pdf(paper.source_id, ver, url) 597 svc_paper.mark_pdf_saved(paper.source_id, path, ver) 598 return {"paper_id": paper_id, "version": ver, "path": path}
Download a PDF for a paper and save it to the managed PDF directory.
Args: paper_id: The paper source ID (e.g. "arxiv:2204.12985"). url: Direct URL to the PDF file. version: Specific version number (defaults to latest).
601@mcp.tool() 602def get_pdf_storage() -> dict: 603 """Report total PDF storage usage for all managed PDFs. 604 605 Returns storage in megabytes and the path to the PDF directory. 606 """ 607 mb = svc_files.pdf_storage_mb() 608 return {"storage_mb": round(mb, 3), "pdf_dir": svc_files.managed_pdf_dir()}
Report total PDF storage usage for all managed PDFs.
Returns storage in megabytes and the path to the PDF directory.
613@mcp.tool() 614def repair_paper( 615 paper_id: str, 616 title: str, 617 authors: list[str], 618 published: str, 619 summary: str = "", 620 category: Optional[str] = None, 621 doi: Optional[str] = None, 622 url: Optional[str] = None, 623 tags: Optional[list[str]] = None, 624) -> dict: 625 """Overwrite a paper's metadata in-place to fix a bad import (wrong title, authors, etc.). 626 627 Keyed by the stable paper root, so the correction survives a source_id rename. 628 629 Args: 630 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 631 title: Corrected title. 632 authors: Corrected list of author names. 633 published: Publication date in YYYY-MM-DD format. 634 summary: Abstract / summary text. 635 category: Primary category (e.g. "cs.LG"). 636 doi: DOI string. 637 url: Canonical URL. 638 tags: Replacement tag list. 639 """ 640 root = svc_paper.get_paper_root(paper_id) 641 if root is None: 642 raise ValueError(f"Paper {paper_id!r} not found in database.") 643 source_fk = int(root["SOURCE_FK"]) 644 existing = svc_paper.get(Paper(source_id=paper_id)) 645 version = existing.version if existing is not None else 1 646 try: 647 published_date = datetime.date.fromisoformat(published) 648 except ValueError: 649 raise ValueError(f"Invalid date {published!r}; use YYYY-MM-DD.") 650 meta = PaperMetadata( 651 source_id=paper_id, 652 version=version, 653 title=title, 654 authors=authors, 655 published=published_date, 656 summary=summary or "", 657 category=category, 658 doi=doi, 659 url=url, 660 tags=tags or None, 661 source=None, 662 ) 663 svc_paper.repair_paper(source_fk, meta) 664 return {"repaired": paper_id}
Overwrite a paper's metadata in-place to fix a bad import (wrong title, authors, etc.).
Keyed by the stable paper root, so the correction survives a source_id rename.
Args: paper_id: The paper source ID (e.g. "arxiv:2204.12985"). title: Corrected title. authors: Corrected list of author names. published: Publication date in YYYY-MM-DD format. summary: Abstract / summary text. category: Primary category (e.g. "cs.LG"). doi: DOI string. url: Canonical URL. tags: Replacement tag list.
667@mcp.tool() 668def restore_paper(paper_id: str) -> dict: 669 """Restore a soft-deleted (trashed) paper back into the library. 670 671 Args: 672 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 673 """ 674 if not svc_paper.is_paper_deleted(paper_id): 675 raise ValueError(f"Paper {paper_id!r} not found in trash.") 676 pdf_path, project_fks = svc_paper.restore(Paper(source_id=paper_id)) 677 return {"restored": paper_id, "pdf_path": pdf_path, "project_fks": project_fks}
Restore a soft-deleted (trashed) paper back into the library.
Args: paper_id: The paper source ID (e.g. "arxiv:2204.12985").
680@mcp.tool() 681def hard_delete_paper(paper_id: str) -> dict: 682 """Permanently delete a paper and all its data. This is irreversible. 683 684 Works regardless of whether the paper is in the trash. For a trash-only 685 guard, use trash_hard_delete_paper instead. 686 687 Args: 688 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 689 """ 690 root = svc_paper.get_paper_root(paper_id) 691 if root is None: 692 raise ValueError(f"Paper {paper_id!r} not found.") 693 svc_paper.hard_delete(Paper(source_id=paper_id)) 694 return {"hard_deleted": paper_id}
Permanently delete a paper and all its data. This is irreversible.
Works regardless of whether the paper is in the trash. For a trash-only guard, use trash_hard_delete_paper instead.
Args: paper_id: The paper source ID (e.g. "arxiv:2204.12985").
697@mcp.tool() 698def remove_paper_from_all_projects(paper_id: str) -> dict: 699 """Remove a paper from every project it currently belongs to. 700 701 Args: 702 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 703 """ 704 removed = svc_project.remove_paper_from_all_projects_by_id(paper_id) 705 if removed is None: 706 raise ValueError(f"Paper {paper_id!r} not found.") 707 return {"paper_id": paper_id, "removed_from_projects": removed}
Remove a paper from every project it currently belongs to.
Args: paper_id: The paper source ID (e.g. "arxiv:2204.12985").
712@mcp.tool() 713def list_trash() -> dict: 714 """List all soft-deleted papers and projects currently in the trash.""" 715 papers = svc_paper.list_deleted() 716 projects = svc_project.list_deleted() 717 return { 718 "papers": [_asdict_json(p) for p in papers], 719 "projects": [p.to_dict() for p in projects], 720 }
List all soft-deleted papers and projects currently in the trash.
723@mcp.tool() 724def trash_hard_delete_paper(paper_id: str) -> dict: 725 """Permanently delete a trashed paper. Only works if the paper is in the trash. 726 727 Use this for safe permanent deletion of items already soft-deleted; for an 728 unconditional purge use hard_delete_paper. 729 730 Args: 731 paper_id: The paper source ID (e.g. "arxiv:2204.12985"). 732 """ 733 if not svc_paper.is_paper_deleted(paper_id): 734 raise ValueError(f"Paper {paper_id!r} not found in trash.") 735 root = svc_paper.get_paper_root(paper_id) 736 if root is None: 737 raise ValueError(f"Paper {paper_id!r} not found.") 738 svc_paper.hard_delete(Paper(source_id=paper_id)) 739 return {"hard_deleted": paper_id}
Permanently delete a trashed paper. Only works if the paper is in the trash.
Use this for safe permanent deletion of items already soft-deleted; for an unconditional purge use hard_delete_paper.
Args: paper_id: The paper source ID (e.g. "arxiv:2204.12985").
742@mcp.tool() 743def restore_project_from_trash(project_id: int) -> dict: 744 """Restore a project from the trash. Only works if the project is soft-deleted. 745 746 Args: 747 project_id: Numeric project ID. 748 """ 749 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 750 if details is None: 751 raise ValueError(f"Project {project_id} not found.") 752 if details.status != _SvcStatus.DELETED: 753 raise ValueError(f"Project {project_id} is not in trash.") 754 svc_project.restore(_SvcProjectFilter(project_fk=project_id)) 755 return {"restored_project_id": project_id}
Restore a project from the trash. Only works if the project is soft-deleted.
Args: project_id: Numeric project ID.
758@mcp.tool() 759def hard_delete_project_from_trash(project_id: int) -> dict: 760 """Permanently delete a trashed project. Only works if the project is soft-deleted. 761 762 Args: 763 project_id: Numeric project ID. 764 """ 765 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 766 if details is None: 767 raise ValueError(f"Project {project_id} not found.") 768 if details.status != _SvcStatus.DELETED: 769 raise ValueError(f"Project {project_id} is not in trash.") 770 svc_project.hard_delete(_SvcProjectFilter(project_fk=project_id)) 771 return {"hard_deleted_project_id": project_id}
Permanently delete a trashed project. Only works if the project is soft-deleted.
Args: project_id: Numeric project ID.
776@mcp.tool() 777def archive_project(project_id: int) -> dict: 778 """Archive a project (read-only, still visible). Use restore_project to reactivate. 779 780 Args: 781 project_id: Numeric project ID. 782 """ 783 if svc_project.get(_SvcProjectFilter(project_fk=project_id)) is None: 784 raise ValueError(f"Project {project_id} not found.") 785 svc_project.archive(_SvcProjectFilter(project_fk=project_id)) 786 return {"archived_project_id": project_id}
Archive a project (read-only, still visible). Use restore_project to reactivate.
Args: project_id: Numeric project ID.
789@mcp.tool() 790def restore_project(project_id: int) -> dict: 791 """Restore an archived or soft-deleted project back to active status. 792 793 Args: 794 project_id: Numeric project ID. 795 """ 796 if svc_project.get(_SvcProjectFilter(project_fk=project_id)) is None: 797 raise ValueError(f"Project {project_id} not found.") 798 svc_project.restore(_SvcProjectFilter(project_fk=project_id)) 799 return {"restored_project_id": project_id}
Restore an archived or soft-deleted project back to active status.
Args: project_id: Numeric project ID.
802@mcp.tool() 803def hard_delete_project(project_id: int) -> dict: 804 """Permanently delete a project. This is irreversible. Papers themselves are kept. 805 806 Works regardless of the project's status. For a trash-only guard, use 807 hard_delete_project_from_trash instead. 808 809 Args: 810 project_id: Numeric project ID. 811 """ 812 if svc_project.get(_SvcProjectFilter(project_fk=project_id)) is None: 813 raise ValueError(f"Project {project_id} not found.") 814 svc_project.hard_delete(_SvcProjectFilter(project_fk=project_id)) 815 return {"hard_deleted_project_id": project_id}
Permanently delete a project. This is irreversible. Papers themselves are kept.
Works regardless of the project's status. For a trash-only guard, use hard_delete_project_from_trash instead.
Args: project_id: Numeric project ID.
820@mcp.tool() 821def export_project_bibtex(project_id: int, dest: str) -> dict: 822 """Export a project's papers to a BibTeX (.bib) file. 823 824 Args: 825 project_id: Numeric project ID. 826 dest: Output file path (.bib added automatically if no extension is given). 827 """ 828 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 829 if details is None: 830 raise ValueError(f"Project {project_id} not found.") 831 papers = svc_paper.get_many(Papers(source_fks=details.source_fks)) if details.source_fks else [] 832 bibtex_str = BibTeXFormat().export_papers([dataclasses.asdict(p) for p in papers]) 833 out = Path(dest) 834 if not out.suffix: 835 out = out.with_suffix(".bib") 836 out.write_text(bibtex_str, encoding="utf-8") 837 return {"path": str(out), "project_id": project_id}
Export a project's papers to a BibTeX (.bib) file.
Args: project_id: Numeric project ID. dest: Output file path (.bib added automatically if no extension is given).
840@mcp.tool() 841def export_project_obsidian(project_id: int, dest: str) -> dict: 842 """Export a project's papers as Obsidian-style markdown notes. 843 844 Args: 845 project_id: Numeric project ID. 846 dest: Output file path (.md added automatically if no extension is given). 847 """ 848 details = svc_project.get(_SvcProjectFilter(project_fk=project_id)) 849 if details is None: 850 raise ValueError(f"Project {project_id} not found.") 851 papers = svc_paper.get_many(Papers(source_fks=details.source_fks)) if details.source_fks else [] 852 md_str = ObsidianFormat().export_papers([dataclasses.asdict(p) for p in papers]) 853 out = Path(dest) 854 if not out.suffix: 855 out = out.with_suffix(".md") 856 out.write_text(md_str, encoding="utf-8") 857 return {"path": str(out), "project_id": project_id}
Export a project's papers as Obsidian-style markdown notes.
Args: project_id: Numeric project ID. dest: Output file path (.md added automatically if no extension is given).
905@mcp.tool() 906def resolve_doi(doi: str) -> dict: 907 """Resolve a DOI to paper metadata without saving it to the library. 908 909 Args: 910 doi: DOI string (e.g. "10.1038/nature12373"). 911 """ 912 meta = _resolve_doi(doi) 913 return meta.model_dump(mode="json")
Resolve a DOI to paper metadata without saving it to the library.
Args: doi: DOI string (e.g. "10.1038/nature12373").
916@mcp.tool() 917def save_doi(doi: str) -> dict: 918 """Resolve a DOI and save the resulting paper to the local library. 919 920 Args: 921 doi: DOI string (e.g. "10.1038/nature12373"). 922 """ 923 meta = _resolve_doi(doi) 924 source_id, ver = svc_paper.save_paper_metadata(meta) 925 return {"source_id": source_id, "version": ver, "title": meta.title}
Resolve a DOI and save the resulting paper to the local library.
Args: doi: DOI string (e.g. "10.1038/nature12373").
999@mcp.tool() 1000def import_bibtex(file: str, project_id: Optional[int] = None) -> dict: 1001 """Bulk-import papers from a BibTeX (.bib) file into the library. 1002 1003 Args: 1004 file: Path to the .bib file on disk. 1005 project_id: Optionally link all imported papers to this project. 1006 """ 1007 if project_id is not None: 1008 # Guard before parsing/saving so a missing or deleted project fails 1009 # the tool call before the library is mutated. 1010 try: 1011 svc_project.ensure_membership_writable(project_id) 1012 except svc_project.ProjectNotFoundError as e: 1013 raise ValueError(f"Project {project_id} not found.") from e 1014 metas = BibTeXFormat().import_file(file) 1015 results = svc_paper.save_papers_metadata(metas) 1016 if project_id is not None and results: 1017 try: 1018 svc_project.link_imported(project_id, [s for s, _ in results]) 1019 except (svc_project.ProjectNotFoundError, svc_project.ProjectDeletedError) as e: 1020 # Project went away between the guard above and the link; the 1021 # message says the papers stayed imported. 1022 raise ValueError( 1023 f"{len(results)} paper(s) were imported but could not be linked: {e}" 1024 ) from e 1025 return {"imported": len(results), "papers": [{"source_id": s, "version": v} for s, v in results]}
Bulk-import papers from a BibTeX (.bib) file into the library.
Args: file: Path to the .bib file on disk. project_id: Optionally link all imported papers to this project.
1028@mcp.tool() 1029def import_pdf(file: str, project_id: Optional[int] = None) -> dict: 1030 """Import a local PDF file, extracting paper metadata from its contents. 1031 1032 Args: 1033 file: Path to the PDF file on disk. 1034 project_id: Optionally link the imported paper to this project. 1035 """ 1036 if project_id is not None: 1037 # import_pdf re-applies the same guards; this converts the missing- 1038 # project case to the MCP-conventional ValueError before reading the file. 1039 try: 1040 svc_project.ensure_membership_writable(project_id) 1041 except svc_project.ProjectNotFoundError as e: 1042 raise ValueError(f"Project {project_id} not found.") from e 1043 content = Path(file).read_bytes() 1044 try: 1045 result = svc_paper.import_pdf(content, project_id) 1046 except svc_project.ProjectNotFoundError as e: 1047 # Project hard-deleted between the guard above and import_pdf's own 1048 # pre-import guard — nothing was imported. 1049 raise ValueError(f"Project {project_id} not found.") from e 1050 except svc_paper.PaperLinkError as e: 1051 # Project went away after the import; the paper stays imported and 1052 # the message says so. 1053 raise ValueError(str(e)) from e 1054 return _asdict_json(result)
Import a local PDF file, extracting paper metadata from its contents.
Args: file: Path to the PDF file on disk. project_id: Optionally link the imported paper to this project.
1059@mcp.tool() 1060def get_stats() -> dict: 1061 """Report library statistics: paper, tag, category, and downloaded-PDF counts.""" 1062 papers = svc_paper.list_paper_details(latest_only=True) 1063 categories = svc_paper.get_categories() 1064 all_tags = svc_tag.list_all_tags() 1065 pdf_count = sum(1 for p in papers if p.has_pdf) 1066 return { 1067 "paper_count": len(papers), 1068 "tag_count": len(all_tags), 1069 "category_count": len(categories), 1070 "pdf_count": pdf_count, 1071 }
Report library statistics: paper, tag, category, and downloaded-PDF counts.
1074@mcp.tool() 1075def list_categories() -> list[str]: 1076 """List all distinct paper categories present in the library.""" 1077 return svc_paper.get_categories()
List all distinct paper categories present in the library.
1080@mcp.tool() 1081def get_settings() -> dict: 1082 """Get all current user settings.""" 1083 return user_settings.all_settings()
Get all current user settings.
1086@mcp.tool() 1087def update_setting(key: str, value: str) -> dict: 1088 """Update a single user setting. 1089 1090 The value is parsed as JSON when it is valid JSON (so "true", "42", or 1091 '["a","b"]' become the corresponding types); otherwise it is stored as a string. 1092 1093 Args: 1094 key: Setting key. 1095 value: New value (JSON-parsed when valid JSON, else stored as a string). 1096 """ 1097 try: 1098 parsed: Any = json.loads(value) 1099 except json.JSONDecodeError: 1100 parsed = value 1101 user_settings.set(key, parsed) 1102 return {key: parsed}
Update a single user setting.
The value is parsed as JSON when it is valid JSON (so "true", "42", or '["a","b"]' become the corresponding types); otherwise it is stored as a string.
Args: key: Setting key. value: New value (JSON-parsed when valid JSON, else stored as a string).