Claude Desktop (local MCP)

Claude Desktop can call tools on your machine via MCP. The setup below uses uv so you do not need a global Python venv — uv installs dependencies when the server starts.

veruscite_submit reads a file from the computer where uv / Claude Desktop runs. It cannot see Claude chat upload sandbox paths such as /mnt/user-data/uploads/…. Put the file on a real disk location (e.g. Downloads) and pass that absolute path.

1. Install uv

Skip if you already have uv on your PATH.

# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

2. Save veruscite_mcp.py

Anywhere on disk is fine (for example C:\Users\You\veruscite_mcp.py or /home/you/veruscite_mcp.py).

"""Local MCP server for VerusCite. Set VERUSCITE_API_KEY in the environment."""
from __future__ import annotations

import os
import re
from pathlib import Path

import httpx
from mcp.server.fastmcp import FastMCP

API_ROOT = "https://veruscite-data.com"
KEY = os.environ["VERUSCITE_API_KEY"]
HEADERS = {"Authorization": f"Bearer {KEY}"}
mcp = FastMCP("veruscite")


def _client() -> httpx.Client:
    return httpx.Client(base_url=API_ROOT, headers=HEADERS, timeout=120.0)


def _resolve_local_file(file_path: str) -> Path | None:
    """Find a real local file from a path Claude may have mangled.

    Claude Desktop on Windows sometimes turns /mnt/... into C:\\mnt\\...
    We try a few normalizations; none of them invent paths that are not on disk.
    """
    raw = (file_path or "").strip().strip('"').strip("'")
    if not raw:
        return None

    candidates: list[str] = [raw]

    # C:\mnt\user-data\...  ->  /mnt/user-data/...
    m = re.match(r"^[A-Za-z]:[\\/](.+)$", raw)
    if m:
        unixish = "/" + m.group(1).replace("\\", "/")
        candidates.append(unixish)

    # /mnt/user-data/... already unix
    if "\\" in raw and not re.match(r"^[A-Za-z]:", raw):
        candidates.append(raw.replace("\\", "/"))

    seen: set[str] = set()
    for c in candidates:
        if c in seen:
            continue
        seen.add(c)
        try:
            p = Path(c).expanduser()
            # resolve() can fail on broken Windows→Unix hybrids; try both ways
            for try_p in (p, p.resolve(strict=False)):
                if try_p.is_file():
                    return try_p
        except OSError:
            continue
    return None


@mcp.tool()
def veruscite_me() -> dict:
    """Return account email and credit balance."""
    with _client() as c:
        r = c.get("/api/v1/me")
        r.raise_for_status()
        return r.json()


@mcp.tool()
def veruscite_submit(file_path: str) -> dict:
    """Upload a file from this computer for citation verification.

    file_path must be a real absolute path on the machine running this MCP
    server (where Claude Desktop / uv run). Examples:
      Windows: C:\\Users\\You\\Downloads\\paper.pdf
      WSL/Linux: /home/you/Documents/paper.pdf

    Do NOT use Claude chat sandbox paths like /mnt/user-data/uploads/... —
    those are not visible to this tool. Copy the file to Downloads (or similar)
    and pass that path instead.
    """
    path = _resolve_local_file(file_path)
    if path is None:
        return {
            "error": "file_not_found",
            "message": (
                "No readable file at that path on the computer running the "
                "VerusCite MCP server. Use a real local absolute path "
                "(e.g. C:\\\\Users\\\\You\\\\Downloads\\\\paper.pdf), not a "
                "Claude chat upload path like /mnt/user-data/uploads/..."
            ),
            "tried": file_path,
        }
    with path.open("rb") as f, _client() as c:
        r = c.post("/api/v1/documents", files={"file": (path.name, f)})
        r.raise_for_status()
        return r.json()


@mcp.tool()
def veruscite_status(document_id: int) -> dict:
    """Poll job status for a document id until finished is true."""
    with _client() as c:
        r = c.get(f"/api/v1/documents/{document_id}")
        r.raise_for_status()
        return r.json()


@mcp.tool()
def veruscite_results(document_id: int) -> dict:
    """Fetch citation verification results for a document."""
    with _client() as c:
        r = c.get(f"/api/v1/documents/{document_id}/results")
        r.raise_for_status()
        return r.json()


@mcp.tool()
def veruscite_jobs() -> dict:
    """List active (pending / in-process) jobs only."""
    with _client() as c:
        r = c.get("/api/v1/jobs")
        r.raise_for_status()
        return r.json()


@mcp.tool()
def veruscite_list_documents(limit: int = 50, status: str | None = None) -> dict:
    """List your documents (filenames, status, upload time, citation counts).

    Optional status filter: pending, in-process, completed, or failed.
    """
    params: dict = {"limit": min(max(limit, 1), 200)}
    if status:
        params["status"] = status
    with _client() as c:
        r = c.get("/api/v1/documents", params=params)
        r.raise_for_status()
        return r.json()


@mcp.tool()
def veruscite_update_document(
    document_id: int,
    filename: str | None = None,
    notes: str | None = None,
) -> dict:
    """Update a document's display name and/or owner notes.

    filename: display title only (does not rename the file on disk).
    notes: owner notes on the document; pass "" to clear.
    Provide at least one of filename or notes.
    """
    body: dict = {}
    if filename is not None:
        body["filename"] = filename
    if notes is not None:
        body["notes"] = notes
    if not body:
        return {
            "error": "missing_fields",
            "message": "Provide filename and/or notes to update.",
        }
    with _client() as c:
        r = c.patch(f"/api/v1/documents/{document_id}", json=body)
        r.raise_for_status()
        return r.json()


@mcp.tool()
def veruscite_edit_citation(
    document_id: int,
    citation_id: int,
    fields: dict,
) -> dict:
    """Edit fields on one citation (partial update).

    citation_id must be the stable id from veruscite_results (citations[].id),
    not the list index. fields is a dict of keys to change, for example:
      {"title": "Corrected title", "year": 2024, "status": "minor_error",
       "notes": "Fixed OCR"}
    Common keys: status, citation_type, raw_text, title, authors, year, venue,
    volume, issue, pages, doi, isbn, book_title, publisher, editors, url,
    date_accessed, matched_title, matched_authors, matched_year, matched_venue,
    matched_doi, source_url, verification_method, notes.
    """
    if not isinstance(fields, dict) or not fields:
        return {
            "error": "missing_fields",
            "message": "fields must be a non-empty object of citation keys to change.",
        }
    with _client() as c:
        r = c.post(
            f"/api/v1/documents/{document_id}/citations/{citation_id}",
            json={"action": "edit", "fields": fields},
        )
        r.raise_for_status()
        return r.json()


@mcp.tool()
def veruscite_delete_citation(document_id: int, citation_id: int) -> dict:
    """Permanently delete one citation from a document.

    citation_id must be the stable id from veruscite_results (citations[].id).
    """
    with _client() as c:
        r = c.post(
            f"/api/v1/documents/{document_id}/citations/{citation_id}",
            json={"action": "delete"},
        )
        r.raise_for_status()
        return r.json()


if __name__ == "__main__":
    mcp.run()

3. Claude Desktop config

macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
Linux: ~/.config/Claude/claude_desktop_config.json

{
  "mcpServers": {
    "veruscite": {
      "command": "uv",
      "args": [
        "run",
        "--with", "mcp",
        "--with", "httpx",
        "...path...\\veruscite_mcp.py"
      ],
      "env": {
        "VERUSCITE_API_KEY": "vc_your_key_here"
      }
    }
  }
}

Put the full absolute path to your script in place of ...path...\veruscite_mcp.py (Linux/macOS style: /home/you/veruscite_mcp.py). On Windows, if Claude cannot find uv, set command to the full path of uv.exe. Restart Claude Desktop after saving.

4. How to ask Claude

Tell Claude a path that exists on your PC, for example:

Use VerusCite to submit C:\Users\You\Downloads\references.bib
and poll until finished.

If you only attached the file in the Claude chat, first save/copy it to Downloads (or another real folder), then give Claude that path.

After a run finishes, you can also ask Claude to edit results:

Rename document 178 to references.bib and set notes to "Smoke test".
Then open its results, fix citation id 7161 title to "Corrected title",
and delete citation id 7162 if it is a duplicate.

5. Tools

  • veruscite_me — credits / account
  • veruscite_submit — upload from a local absolute path
  • veruscite_status / veruscite_results — one document
  • veruscite_jobs — active jobs only
  • veruscite_list_documents — list all your papers (filename, status, when uploaded, citation counts)
  • veruscite_update_document — rename display filename and/or set owner notes
  • veruscite_edit_citation — partial field edit on one citation (by stable id from results)
  • veruscite_delete_citation — delete one citation row

After you change veruscite_mcp.py, restart Claude Desktop so it reloads the tools.