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.
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"
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()
if __name__ == "__main__":
mcp.run()
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.
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.
veruscite_me — credits / accountveruscite_submit — upload from a local absolute pathveruscite_status / veruscite_results — one documentveruscite_jobs — active jobs onlyveruscite_list_documents — list all your papers (filename, status, when uploaded, citation counts)