Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mcp_server/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: "Papers With Code MCP tools for searching and reading AI/ML papers,
compatibility: "Requires an MCP client connected to https://paperswithcode.co/mcp with the Papers With Code tools available."
---

Generated for `pwc-mcp v0.2.1` and stock-client MCP protocol `2025-11-25`.
Generated for `pwc-mcp v0.2.2` and stock-client MCP protocol `2025-11-25`.

The tools query the public [Papers With Code](https://paperswithcode.co) catalog
anonymously and are read-only. Every tool runs the matching `pwc` CLI research
Expand Down
2 changes: 1 addition & 1 deletion mcp_server/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pwc-mcp"
version = "0.2.1"
version = "0.2.2"
description = "Read-only Papers With Code MCP server"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion mcp_server/src/pwc_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Read-only Papers With Code MCP server."""

__version__ = "0.2.1"
__version__ = "0.2.2"
40 changes: 39 additions & 1 deletion mcp_server/src/pwc_mcp/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,10 @@ def _text(self, path: str, *, ttl: int) -> str:

@staticmethod
def _rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
values = payload.get("results") or payload.get("items")
values = payload.get("results")
if values is None:
values = payload.get("items")
# An empty list is a valid (empty) result, not a missing list.
if not isinstance(values, list):
raise ResponseError("API response did not contain a result list")
return [item for item in values if isinstance(item, dict)]
Expand Down Expand Up @@ -255,8 +258,17 @@ def _resolve_paper(self, reference: str) -> str:
"www.paperswithcode.co",
}:
slug_from_url = candidate.casefold()
elif urlparse(candidate).scheme in {"http", "https"}:
# A DOI, publisher, or venue URL is never an exact title; searching
# it would page through empty results before failing anyway.
raise ResponseError(
f"Paper URL not supported: {candidate}; only arXiv, Hugging Face, "
"and Papers With Code URLs resolve"
)
candidate = ARXIV_VERSION.sub("", candidate)
if PAPER_ID.fullmatch(candidate):
if candidate.isdigit():
return self._canonical_paper_id(candidate)
return candidate
query = candidate.replace("-", " ") if slug_from_url else candidate
target = " ".join(candidate.split()).casefold()
Expand Down Expand Up @@ -293,6 +305,30 @@ def _resolve_paper(self, reference: str) -> str:
raise ResponseError(f"Paper title is ambiguous: {candidate}; {choices}")
raise ResponseError(f"Paper title not found: {candidate}")

def _canonical_paper_id(self, catalog_id: str) -> str:
"""Prefer the arXiv ID for a numeric catalog ID so every route accepts it.

The Markdown read route stores arXiv papers under their arXiv ID and
only external papers under the numeric ID; list tools hand out numeric
IDs for both, so a bare numeric reference cannot tell them apart.
"""
path = f"papers/{quote(catalog_id, safe='')}"
record = self._json(path, ttl=cache_ttl(path))
arxiv_id = record.get("arxiv_id")
if isinstance(arxiv_id, str) and arxiv_id.strip():
return ARXIV_VERSION.sub("", arxiv_id.strip())
return catalog_id

def _paper_exists(self, reference: str) -> bool:
path = f"papers/{quote(reference, safe='.')}"
try:
self._json(path, ttl=cache_ttl(path))
except HTTPStatusError as error:
if error.status == 404:
return False
raise
return True

def resolve_paper(self, paper: str) -> str:
return self._resolve_paper(paper)

Expand Down Expand Up @@ -326,6 +362,8 @@ def read_paper_chunk(
raise PaperVersionMismatchError(
"Paper Markdown changed; restart reading from the beginning"
) from error
if error.status == 404 and not self._paper_exists(reference):
raise ResponseError(f"Paper not found: {reference}") from error
raise

returned_version = response.headers.get("x-pwc-content-version", "")
Expand Down
66 changes: 56 additions & 10 deletions mcp_server/src/pwc_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from __future__ import annotations

import logging
import os
import time
from collections.abc import Mapping
Expand Down Expand Up @@ -57,6 +58,7 @@
paper_summary,
)

logger = logging.getLogger(__name__)
READ_ONLY = ToolAnnotations(
read_only_hint=True,
destructive_hint=False,
Expand All @@ -77,24 +79,46 @@
"Area not found",
"Paper title not found",
"Paper title is ambiguous",
"Paper not found",
"Paper URL not supported",
"Paper reference cannot be empty",
"Too many results to resolve paper title",
"Papers API did not confirm",
)
GENERIC_ERROR = "the Papers With Code catalog request failed"
# Upstream validation failures the caller can correct; the detail is the
# public API's own message, bounded and stripped of control characters.
INVALID_ARGUMENT_STATUSES = frozenset({400, 422})
MAX_UPSTREAM_DETAIL_CHARS = 200


def _upstream_detail(error: HTTPStatusError) -> str:
detail = "".join(
ch if ch.isprintable() else " " for ch in str(error.detail or "")
).strip()
return detail[:MAX_UPSTREAM_DETAIL_CHARS] or "the catalog rejected the request"


def catalog_error_message(error: Exception) -> str:
"""Return the CLI's own lookup message when it is actionable, else a generic one."""
if isinstance(error, HTTPStatusError) and error.status == 404:
return "not_found: the requested catalog record does not exist"
if isinstance(error, TransportError) and "timeout" in str(error).casefold():
return "upstream_timeout: the Papers With Code catalog timed out"
if isinstance(error, HTTPStatusError):
if error.status == 404:
return "not_found: the requested catalog record does not exist"
if error.status in INVALID_ARGUMENT_STATUSES:
return f"invalid_argument: {_upstream_detail(error)}"
if error.status == 429:
return "rate_limited: the Papers With Code catalog is rate limiting; retry later"
if isinstance(error, TransportError):
message = str(error).casefold()
if "timeout" in message or "timed out" in message:
return "upstream_timeout: the Papers With Code catalog timed out"
if isinstance(error, ResponseError) and not isinstance(error, TransportError):
message = str(error)
if message.startswith(CLIENT_FACING_ERRORS):
code = "ambiguous" if "ambiguous" in message.casefold() else "not_found"
return f"{code}: {message}"
# Only the exception class is logged: never the reference, query, or body.
logger.warning("pwc-mcp generic catalog error type=%s", type(error).__name__)
return f"upstream_error: {GENERIC_ERROR}"


Expand Down Expand Up @@ -493,7 +517,7 @@ def read_paper(paper: Reference, cursor: str | None = None) -> PaperReadResult:
except (ResponseError, TransportError) as error:
raise ToolError(catalog_error_message(error)) from error
if chunk.paper != canonical or chunk.source != source:
raise ToolError("the Papers With Code catalog request failed")
raise ToolError("paper changed; restart reading from the beginning")
next_cursor = None
if chunk.next_offset is not None:
next_cursor = codec.encode(
Expand Down Expand Up @@ -792,7 +816,7 @@ def get_benchmark(
sort_metric: SortMetric | None = None,
pareto: ParetoObjectives | None = None,
) -> BenchmarkResult:
"""Get one exact benchmark and its leaderboard (`pwc benchmark --name`). Use max_parameters (for example "4B") to keep models at or below a size, sort_metric to rank by a metric, and minimum_metrics, maximum_metrics, require_metrics, or pareto to select rows; matched_count reports how many rows passed before limit."""
"""Get one exact benchmark and its leaderboard (`pwc benchmark --name`). Use max_parameters (for example "4B") to keep models at or below a size, sort_metric to rank by a metric, and minimum_metrics, maximum_metrics, require_metrics, or pareto to select rows; matched_count reports how many rows passed before limit. Metric names are matched case-insensitively and through common aliases (AP/mAP, top1/Accuracy, AUROC/AUC); an unknown metric error lists the leaderboard's actual metric names."""
data = run(
"get_benchmark",
benchmark=benchmark,
Expand Down Expand Up @@ -843,8 +867,24 @@ def list_benchmarks(
page: Page = 1,
limit: Limit | None = None,
) -> BenchmarkPage:
"""List benchmarks for a task ranked by trend, filter them, or group them by area and task (`pwc benchmark list`). Follow with get_benchmark on the most relevant leaderboard."""
"""List benchmarks for a task ranked by trend, filter them, or group them by area and task (`pwc benchmark list`). order_by=trending needs task (without it the list is ordered by name); area groups a whole area and is dropped when combined with task, search, or ordering filters. Follow with get_benchmark on the most relevant leaderboard."""
notes: list[str] = []
if order_by == "trending" and not task:
order_by = None
notes.append("order_by=trending needs task; ordered by name instead")
filtered = bool(
task
or search
or include_descendants
or is_open is not None
or order_by is not None
or order_direction != "asc"
)
if area is not None and filtered:
area = None
notes.append("area cannot be combined with filters; returned the filtered list")
grouped = group_by_area or area is not None
# Grouped listings are not paginated; the CLI rejects page/limit there.
data = run(
"list_benchmarks",
search=search,
Expand All @@ -857,19 +897,25 @@ def list_benchmarks(
benchmarks_per_task=benchmarks_per_task,
order_by=order_by,
order_direction=order_direction,
page=page,
limit=limit if limit is not None or grouped else MAX_ROWS,
page=1 if grouped else page,
limit=None if grouped else (limit if limit is not None else MAX_ROWS),
)
if not isinstance(data, dict):
raise TypeError("benchmark listing did not contain a result document")
rows = _dicts(data.get("results"))
if grouped:
rows = _grouped_benchmarks(rows)
return BenchmarkPage(
result = BenchmarkPage(
items=[benchmark_summary(item) for item in rows],
next_page=_next_page(data),
data=data,
)
summary = f"Found {len(result.items)} benchmarks."
if result.next_page:
summary += f" Next page: {result.next_page}."
for note in notes:
summary += f" Note: {note}."
return _tool_result(result, summary)

@server.prompt(name="find_papers", title="Find papers")
def find_papers_prompt(topic: str) -> str:
Expand Down
2 changes: 1 addition & 1 deletion mcp_server/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def test_health_and_browser_origin_policy_are_explicit():
assert health.json() == {
"status": "ok",
"service": "pwc-mcp",
"version": "0.2.1",
"version": "0.2.2",
"protocol": "2025-11-25",
}
assert rejected.status_code == 403
Expand Down
80 changes: 80 additions & 0 deletions mcp_server/tests/test_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,3 +280,83 @@ def test_catalog_query_reports_cli_usage_errors_without_upstream_calls():
with pytest.raises(UsageError, match="not a read-only"):
catalog.query(("skills", "add"), {})
assert transport.calls == []


def test_catalog_canonicalises_numeric_ids_of_arxiv_papers():
transport = StubTransport(
{
"papers/86122": {"id": "86122", "arxiv_id": "2505.18132v1"},
"papers/2505.18132": {"id": "86122", "title": "BiggerGait"},
"papers/4242": {"id": "4242", "arxiv_id": None, "source": "external"},
}
)
catalog = CatalogClient(transport=transport)

# List tools hand out numeric IDs for arXiv papers too; the Markdown route
# only knows arXiv papers by arXiv ID, so numeric references canonicalise.
assert catalog.resolve_paper("86122") == "2505.18132"
assert catalog.query(("paper", "info"), {"paper": "86122"}) == {
"id": "86122",
"title": "BiggerGait",
}
# External papers have no arXiv ID and keep their numeric reference.
assert catalog.resolve_paper("4242") == "4242"
assert transport.calls[0] == ("papers/86122", {})


def test_catalog_rejects_unsupported_urls_without_searching():
transport = StubTransport({})
catalog = CatalogClient(transport=transport)

for url in (
"https://doi.org/10.1007/s10479-024-06277-x",
"https://aclanthology.org/2026.acl-long.200/",
):
with pytest.raises(ResponseError, match="Paper URL not supported"):
catalog.resolve_paper(url)
assert transport.calls == []


def test_catalog_distinguishes_missing_papers_from_missing_markdown():
from pwc_cli.transport import HTTPStatusError

class MissingTransport:
def __init__(self, paper_exists):
self.paper_exists = paper_exists

def get(self, path, params=None):
if path.endswith("/read"):
raise HTTPStatusError(404, "Paper Markdown was not found")
if self.paper_exists:
return Response(b'{"id": "1"}', {})
raise HTTPStatusError(404, "No paper found")

with pytest.raises(ResponseError, match="Paper not found: 2308.10195"):
CatalogClient(transport=MissingTransport(False)).read_paper_chunk(
"2308.10195", resolved=True
)
with pytest.raises(HTTPStatusError) as missing_markdown:
CatalogClient(transport=MissingTransport(True)).read_paper_chunk(
"2505.18132", resolved=True
)
assert missing_markdown.value.status == 404


def test_catalog_reports_titles_missing_from_an_empty_search_as_not_found():
# The public API answers an unknown title with an empty result list; that
# must surface as "title not found", not as a malformed response.
transport = StubTransport(
{
"papers/search": {
"next_page": None,
"previous_page": None,
"results": [],
"applied_filters": {},
}
}
)
catalog = CatalogClient(transport=transport)

with pytest.raises(ResponseError, match="Paper title not found: Dropout"):
catalog.resolve_paper("Dropout: A Simple Way to Prevent Overfitting")
assert len(transport.calls) == 1
Loading
Loading