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()
mcp = <mcp.server.fastmcp.server.FastMCP object>
@mcp.tool()
def search_papers(query: str, source: str = 'arxiv', max_results: int = 10) -> list[dict]:
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).

@mcp.tool()
def fetch_paper(paper_id: str, source: str = 'arxiv') -> dict:
 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".

@mcp.tool()
def list_papers( limit: int | None = None, offset: int = 0, category: str | None = None) -> list[dict]:
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").

@mcp.tool()
def get_paper(paper_id: str) -> dict | None:
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").

@mcp.tool()
def delete_paper(paper_id: str) -> dict:
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.

@mcp.tool()
def get_paper_versions(paper_id: str) -> dict | None:
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").

@mcp.tool()
def search_full_text(query: str, limit: int = 20) -> list[dict]:
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).

@mcp.tool()
def list_all_tags() -> list[str]:
181@mcp.tool()
182def list_all_tags() -> list[str]:
183    """List all tags in the database."""
184    return svc_tag.list_all_tags()

List all tags in the database.

@mcp.tool()
def get_paper_tags(paper_id: str) -> dict:
187@mcp.tool()
188def get_paper_tags(paper_id: str) -> dict:
189    """Get all tags applied to a specific paper.
190
191    Args:
192        paper_id: The paper source ID (e.g. "arxiv:2204.12985").
193    """
194    tags = svc_tag.get_paper_tags(paper_id)
195    return {"paper_id": paper_id, "tags": tags}

Get all tags applied to a specific paper.

Args: paper_id: The paper source ID (e.g. "arxiv:2204.12985").

@mcp.tool()
def add_tags_to_paper(paper_id: str, tags: list[str]) -> dict:
198@mcp.tool()
199def add_tags_to_paper(paper_id: str, tags: list[str]) -> dict:
200    """Add one or more tags to a paper.
201
202    Args:
203        paper_id: The paper source ID (e.g. "arxiv:2204.12985").
204        tags: List of tag labels to add.
205    """
206    paper = svc_paper.get(Paper(source_id=paper_id))
207    if paper is None:
208        raise ValueError(f"Paper {paper_id!r} not found in database.")
209    updated = svc_tag.add_paper_tags(paper_id, tags)
210    return {"paper_id": paper_id, "tags": updated}

Add one or more tags to a paper.

Args: paper_id: The paper source ID (e.g. "arxiv:2204.12985"). tags: List of tag labels to add.

@mcp.tool()
def remove_tags_from_paper(paper_id: str, tags: list[str]) -> dict:
213@mcp.tool()
214def remove_tags_from_paper(paper_id: str, tags: list[str]) -> dict:
215    """Remove one or more tags from a paper.
216
217    Args:
218        paper_id: The paper source ID (e.g. "arxiv:2204.12985").
219        tags: List of tag labels to remove.
220    """
221    paper = svc_paper.get(Paper(source_id=paper_id))
222    if paper is None:
223        raise ValueError(f"Paper {paper_id!r} not found in database.")
224    updated = svc_tag.remove_paper_tags(paper_id, tags)
225    return {"paper_id": paper_id, "tags": updated}

Remove one or more tags from a paper.

Args: paper_id: The paper source ID (e.g. "arxiv:2204.12985"). tags: List of tag labels to remove.

@mcp.tool()
def create_tag(label: str) -> dict:
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.

@mcp.tool()
def delete_tag(tag_id: int) -> dict:
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).

@mcp.tool()
def list_projects(status: str | None = None) -> list[dict]:
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.

@mcp.tool()
def get_project(project_id: int) -> dict | None:
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.

@mcp.tool()
def create_project(name: str, description: str = '') -> dict:
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.

@mcp.tool()
def update_project( project_id: int, name: str | None = None, description: str | None = None, color: str | None = None, tags: list[str] | None = None, status: str | None = None) -> dict:
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".

@mcp.tool()
def delete_project(project_id: int) -> dict:
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.

@mcp.tool()
def add_paper_to_project(project_id: int, paper_id: str) -> dict:
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").

@mcp.tool()
def remove_paper_from_project(project_id: int, paper_id: str) -> dict:
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.

@mcp.tool()
def export_project(project_id: int, dest: str, include_pdfs: bool = False) -> dict:
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).

@mcp.tool()
def import_project( zip_path: str, on_conflict: Literal['merge', 'overwrite'] = 'merge', preview: bool = False) -> dict:
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.

@mcp.tool()
def create_note( paper_id: str, content: str, title: str = '', project_id: int | None = None) -> dict:
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.

@mcp.tool()
def get_note(note_id: int) -> dict | None:
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.

@mcp.tool()
def list_notes(paper_id: str | None = None, project_id: int | None = None) -> list[dict]:
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.

@mcp.tool()
def update_note( note_id: int, title: str | None = None, content: str | None = None) -> dict:
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).

@mcp.tool()
def delete_note(note_id: int) -> dict:
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.

@mcp.tool()
def get_notes_for_paper(paper_id: str, project_id: int | None = None) -> list[dict]:
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).

@mcp.tool()
def get_notes_for_project(project_id: int) -> list[dict]:
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.

@mcp.tool()
def get_pdf_path(paper_id: str, version: int | None = None) -> dict:
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).

@mcp.tool()
def download_pdf(paper_id: str, url: str, version: int | None = None) -> dict:
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).

@mcp.tool()
def get_pdf_storage() -> dict:
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.

@mcp.tool()
def repair_paper( paper_id: str, title: str, authors: list[str], published: str, summary: str = '', category: str | None = None, doi: str | None = None, url: str | None = None, tags: list[str] | None = None) -> dict:
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.

@mcp.tool()
def restore_paper(paper_id: str) -> dict:
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").

@mcp.tool()
def hard_delete_paper(paper_id: str) -> dict:
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").

@mcp.tool()
def remove_paper_from_all_projects(paper_id: str) -> dict:
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").

@mcp.tool()
def list_trash() -> dict:
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.

@mcp.tool()
def trash_hard_delete_paper(paper_id: str) -> dict:
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").

@mcp.tool()
def restore_project_from_trash(project_id: int) -> dict:
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.

@mcp.tool()
def hard_delete_project_from_trash(project_id: int) -> dict:
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.

@mcp.tool()
def archive_project(project_id: int) -> dict:
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.

@mcp.tool()
def restore_project(project_id: int) -> dict:
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.

@mcp.tool()
def hard_delete_project(project_id: int) -> dict:
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.

@mcp.tool()
def export_project_bibtex(project_id: int, dest: str) -> dict:
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).

@mcp.tool()
def export_project_obsidian(project_id: int, dest: str) -> dict:
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).

@mcp.tool()
def add_tags_to_project(project_id: int, tags: list[str]) -> dict:
862@mcp.tool()
863def add_tags_to_project(project_id: int, tags: list[str]) -> dict:
864    """Add one or more tags to a project.
865
866    Args:
867        project_id: Numeric project ID.
868        tags: List of tag labels to add.
869    """
870    if svc_project.get(_SvcProjectFilter(project_fk=project_id)) is None:
871        raise ValueError(f"Project {project_id} not found.")
872    updated = svc_tag.add_project_tags(project_id, tags)
873    return {"project_id": project_id, "tags": updated}

Add one or more tags to a project.

Args: project_id: Numeric project ID. tags: List of tag labels to add.

@mcp.tool()
def remove_tags_from_project(project_id: int, tags: list[str]) -> dict:
876@mcp.tool()
877def remove_tags_from_project(project_id: int, tags: list[str]) -> dict:
878    """Remove one or more tags from a project.
879
880    Args:
881        project_id: Numeric project ID.
882        tags: List of tag labels to remove.
883    """
884    if svc_project.get(_SvcProjectFilter(project_fk=project_id)) is None:
885        raise ValueError(f"Project {project_id} not found.")
886    updated = svc_tag.remove_project_tags(project_id, tags)
887    return {"project_id": project_id, "tags": updated}

Remove one or more tags from a project.

Args: project_id: Numeric project ID. tags: List of tag labels to remove.

@mcp.tool()
def get_project_tags(project_id: int) -> dict:
890@mcp.tool()
891def get_project_tags(project_id: int) -> dict:
892    """Get all tags applied to a project.
893
894    Args:
895        project_id: Numeric project ID.
896    """
897    details = svc_project.get(_SvcProjectFilter(project_fk=project_id))
898    if details is None:
899        raise ValueError(f"Project {project_id} not found.")
900    return {"project_id": project_id, "tags": details.project_tags}

Get all tags applied to a project.

Args: project_id: Numeric project ID.

@mcp.tool()
def resolve_doi(doi: str) -> dict:
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").

@mcp.tool()
def save_doi(doi: str) -> dict:
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").

@mcp.tool()
def list_authors() -> list[dict]:
930@mcp.tool()
931def list_authors() -> list[dict]:
932    """List all authors in the library with their paper counts."""
933    return [a.to_dict() for a in svc_author.list_with_paper_count()]

List all authors in the library with their paper counts.

@mcp.tool()
def get_author(author_id: int) -> dict:
936@mcp.tool()
937def get_author(author_id: int) -> dict:
938    """Get an author's details together with a preview of their papers.
939
940    Args:
941        author_id: Numeric author ID.
942    """
943    author = svc_author.get(Author(author_id=author_id))
944    if author is None:
945        raise ValueError(f"Author {author_id} not found.")
946    previews = svc_author.get_paper_previews(author_id)
947    result = author.to_dict()
948    result["papers"] = [p.to_dict() for p in previews]
949    return result

Get an author's details together with a preview of their papers.

Args: author_id: Numeric author ID.

@mcp.tool()
def update_author( author_id: int, full_name: str | None = None, first_name: str | None = None, last_name: str | None = None, orcid: str | None = None) -> dict:
952@mcp.tool()
953def update_author(
954    author_id: int,
955    full_name: Optional[str] = None,
956    first_name: Optional[str] = None,
957    last_name: Optional[str] = None,
958    orcid: Optional[str] = None,
959) -> dict:
960    """Update an author's fields. At least one field must be provided.
961
962    Args:
963        author_id: Numeric author ID.
964        full_name: New full name.
965        first_name: New first name.
966        last_name: New last name.
967        orcid: New ORCID identifier.
968    """
969    if svc_author.get(Author(author_id=author_id)) is None:
970        raise ValueError(f"Author {author_id} not found.")
971    if full_name is None and first_name is None and last_name is None and orcid is None:
972        raise ValueError("At least one of full_name, first_name, last_name, or orcid must be provided.")
973    svc_author.update_fields(
974        author_id=author_id,
975        full_name=full_name,
976        first_name=first_name,
977        last_name=last_name,
978        orcid=orcid,
979    )
980    return {"updated_author_id": author_id}

Update an author's fields. At least one field must be provided.

Args: author_id: Numeric author ID. full_name: New full name. first_name: New first name. last_name: New last name. orcid: New ORCID identifier.

@mcp.tool()
def delete_author(author_id: int) -> dict:
983@mcp.tool()
984def delete_author(author_id: int) -> dict:
985    """Delete an author. Blocked if the author is still linked to any papers.
986
987    Args:
988        author_id: Numeric author ID.
989    """
990    link_count = svc_author.count_paper_links(author_id)
991    if link_count > 0:
992        raise ValueError(f"Author {author_id} is linked to {link_count} paper(s); unlink first.")
993    svc_author.delete_author(author_id)
994    return {"deleted_author_id": author_id}

Delete an author. Blocked if the author is still linked to any papers.

Args: author_id: Numeric author ID.

@mcp.tool()
def import_bibtex(file: str, project_id: int | None = None) -> dict:
 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.

@mcp.tool()
def import_pdf(file: str, project_id: int | None = None) -> dict:
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.

@mcp.tool()
def get_stats() -> dict:
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.

@mcp.tool()
def list_categories() -> list[str]:
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.

@mcp.tool()
def get_settings() -> dict:
1080@mcp.tool()
1081def get_settings() -> dict:
1082    """Get all current user settings."""
1083    return user_settings.all_settings()

Get all current user settings.

@mcp.tool()
def update_setting(key: str, value: str) -> dict:
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).

def main() -> None:
1105def main() -> None:
1106    mcp.run()