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
3 changes: 2 additions & 1 deletion mcp_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ the caller controls hybrid (default), keyword, or semantic mode. `read_paper`
fetches at most one 64 KiB catalog chunk per call and returns a signed, one-hour
continuation cursor when more Markdown remains. Continuations stay pinned to
the resolved paper and content version, so a changed paper fails with an
explicit restart response.
explicit restart response; any reference that resolves to the same paper (the
numeric catalog ID after starting from the arXiv ID, say) may carry the cursor.

## Resources

Expand Down
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.3` and stock-client MCP protocol `2025-11-25`.
Generated for `pwc-mcp v0.2.4` 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
6 changes: 4 additions & 2 deletions mcp_server/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ Responses use stable, MCP-specific versioned structured outputs with a text
fallback. `read_paper` performs one upstream read of at most 64 KiB per call and
returns a signed opaque continuation cursor when more Markdown remains. The
cursor binds the original reference, canonical paper, content version, byte
offset, chunk limit, key identifier, and fixed one-hour expiry. The current and
previous signing keys support rotation without accepting unsigned state.
offset, chunk limit, key identifier, and fixed one-hour expiry. A continuation
supplied with a different reference is honoured only when that reference
resolves to the cursor's canonical paper. The current and previous signing keys
support rotation without accepting unsigned state.

Paper references accept arXiv IDs, numeric PwC external IDs, arXiv/Hugging
Face/Papers With Code URLs, and exact titles. Ambiguous exact titles fail rather
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.3"
version = "0.2.4"
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.3"
__version__ = "0.2.4"
41 changes: 30 additions & 11 deletions mcp_server/src/pwc_mcp/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,34 @@ def _resolve_paper(self, reference: str) -> str:
return candidate
query = candidate.replace("-", " ") if slug_from_url else candidate
target = " ".join(candidate.split()).casefold()
# The keyword search treats a quoted query as one phrase, so an exact
# title comes back in a single small page even when its words are
# common ("Attention Is All You Need" has over a thousand keyword
# hits). The plain query remains the fallback for titles the phrase
# parser cannot express.
phrase = '"' + " ".join(query.replace('"', " ").split()) + '"'
exact, exhausted = self._exact_title_matches(phrase, target, slug_from_url)
if not exact:
exact, exhausted = self._exact_title_matches(query, target, slug_from_url)
if len(exact) == 1:
return next(iter(exact))
if exact:
choices = "; ".join(
f"{item.get('title')} ({paper})" for paper, item in exact.items()
)
raise ResponseError(f"Paper title is ambiguous: {candidate}; {choices}")
if not exhausted:
raise ResponseError("Too many results to resolve paper title safely")
raise ResponseError(f"Paper title not found: {candidate}")

def _exact_title_matches(
self, query: str, target: str, slug_from_url: str | None
) -> tuple[dict[str, dict[str, Any]], bool]:
"""Collect papers whose title (or slug) equals the target.

Returns the matches and whether the search was read to its end within
the page budget; an unexhausted search with no match is inconclusive.
"""
exact: dict[str, dict[str, Any]] = {}
page = 1
while page <= 10:
Expand All @@ -292,18 +320,9 @@ def _resolve_paper(self, reference: str) -> str:
exact.setdefault(paper, item)
next_page = payload.get("next_page")
if not isinstance(next_page, int) or next_page <= page:
break
return exact, True
page = next_page
else:
raise ResponseError("Too many results to resolve paper title safely")
if len(exact) == 1:
return next(iter(exact))
if exact:
choices = "; ".join(
f"{item.get('title')} ({paper})" for paper, item in exact.items()
)
raise ResponseError(f"Paper title is ambiguous: {candidate}; {choices}")
raise ResponseError(f"Paper title not found: {candidate}")
return exact, False

def _canonical_paper_id(self, catalog_id: str) -> str:
"""Prefer the arXiv ID for a numeric catalog ID so every route accepts it.
Expand Down
20 changes: 19 additions & 1 deletion mcp_server/src/pwc_mcp/cursors.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@
_PAPER_ID = re.compile(r"(?:\d{4}\.\d{4,5}|\d{1,20})")


class CursorReferenceMismatch(ValueError):
"""A valid cursor whose paper reference differs from the one supplied.

The caller may still honour it when the new reference resolves to the
cursor's paper (an agent that started from an arXiv ID and continues with
the numeric catalog ID, say).
"""

def __init__(self, state: CursorState) -> None:
super().__init__(
"invalid continuation cursor: it belongs to another paper reference"
)
self.state = state


@dataclass(frozen=True)
class CursorState:
reference: str
Expand Down Expand Up @@ -129,15 +144,18 @@ def decode(self, token: str, *, reference: str) -> CursorState:
"kid",
"exp",
}
or state.reference != reference.strip()
or not self._valid_state(state)
):
raise ValueError
if state.expires_at <= int(self._now()):
raise TimeoutError
if state.reference != reference.strip():
raise CursorReferenceMismatch(state)
return state
except TimeoutError as error:
raise ValueError("expired continuation cursor") from error
except CursorReferenceMismatch:
raise
except (
ValueError,
TypeError,
Expand Down
23 changes: 20 additions & 3 deletions mcp_server/src/pwc_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
CURSOR_LIFETIME_SECONDS,
MAX_CHUNK_BYTES,
CursorCodec,
CursorReferenceMismatch,
CursorState,
)
from pwc_mcp.models import (
Expand Down Expand Up @@ -475,12 +476,28 @@ def get_paper_evaluations(

@server.tool(annotations=READ_ONLY, structured_output=True)
def read_paper(paper: Reference, cursor: str | None = None) -> PaperReadResult:
"""Read stored paper Markdown, continuing oversized documents with a cursor (`pwc paper read`)."""
"""Read stored paper Markdown, continuing oversized documents with a cursor (`pwc paper read`). Pass the next_cursor value from the previous result together with the same paper (any reference to that paper works); omit cursor to start from the beginning."""
reference = paper.strip()
try:
state = codec.decode(cursor, reference=reference) if cursor else None
except CursorReferenceMismatch as error:
# The agent continued with another spelling of the same paper, for
# example the numeric catalog ID after starting from the arXiv ID.
try:
canonical = catalog.resolve_paper(reference)
except (ResponseError, TransportError) as inner:
raise ToolError(catalog_error_message(inner)) from inner
if canonical != error.state.paper:
raise ToolError(
"continuation cursor belongs to a different paper; omit cursor "
"to start reading this paper from the beginning"
) from error
state = error.state
except ValueError as error:
raise ToolError(str(error)) from error
raise ToolError(
f"{error}; pass the next_cursor value from the previous read_paper "
"result, or omit cursor to start from the beginning"
) from error
if state is None:
try:
canonical = catalog.resolve_paper(reference)
Expand Down Expand Up @@ -818,7 +835,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. 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."""
"""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, through common aliases (AP/mAP, top1/Accuracy, AUROC/AUC, Pass@1/Pass Rate), and by the one leaderboard metric containing the request or an alias of it (Normalized Score for "D4RL Normalized Score", GenEval Score for "Overall"); an unknown metric error lists the leaderboard's actual metric names."""
data = run(
"get_benchmark",
benchmark=benchmark,
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.3",
"version": "0.2.4",
"protocol": "2025-11-25",
}
assert rejected.status_code == 403
Expand Down
66 changes: 65 additions & 1 deletion mcp_server/tests/test_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,68 @@ def test_catalog_reports_titles_missing_from_an_empty_search_as_not_found():

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
# One phrase query, then one plain keyword query as the fallback.
assert [params["q"] for _path, params in transport.calls] == [
'"Dropout: A Simple Way to Prevent Overfitting"',
"Dropout: A Simple Way to Prevent Overfitting",
]


EXACT_TITLE_ROW = {"id": "755", "arxiv_id": "1706.03762", "title": "Attention Is All You Need"}


class TitleSearchTransport:
"""Keyword search whose plain results never end; the phrase query is small."""

def __init__(self, *, phrase_results, plain_has_exact):
self.phrase_results = phrase_results
self.plain_has_exact = plain_has_exact
self.calls = []

def get(self, path, params=None):
params = dict(params or {})
self.calls.append((path, params))
assert path == "papers/search"
page = params["page"]
if params["q"].startswith('"'):
body = {"results": self.phrase_results, "next_page": None}
else:
rows = [
{"id": str(page * 100 + i), "arxiv_id": f"2{page:03d}.{i:05d}", "title": f"Attention {i}"}
for i in range(100)
]
if self.plain_has_exact and page == 1:
rows[0] = EXACT_TITLE_ROW
body = {"results": rows, "next_page": page + 1}
return Response(json.dumps(body).encode(), {"content-type": "application/json"})


def test_catalog_resolves_common_word_titles_through_one_phrase_query():
# "Attention Is All You Need" has over a thousand keyword hits; paging
# through them used to end in "Too many results" although the exact title
# was the first result.
transport = TitleSearchTransport(
phrase_results=[
EXACT_TITLE_ROW,
{"id": "9", "arxiv_id": "2010.13154", "title": "Attention is All You Need in Speech Separation"},
],
plain_has_exact=True,
)
catalog = CatalogClient(transport=transport)

assert catalog.resolve_paper("Attention Is All You Need") == "1706.03762"
assert [params["q"] for _path, params in transport.calls] == ['"Attention Is All You Need"']


def test_catalog_keeps_exact_matches_found_before_the_page_budget_runs_out():
transport = TitleSearchTransport(phrase_results=[], plain_has_exact=True)
catalog = CatalogClient(transport=transport)

assert catalog.resolve_paper("Attention Is All You Need") == "1706.03762"
assert len(transport.calls) == 11

inconclusive = CatalogClient(
transport=TitleSearchTransport(phrase_results=[], plain_has_exact=False)
)
with pytest.raises(ResponseError, match="Too many results"):
inconclusive.resolve_paper("Attention Is All You Need")
16 changes: 15 additions & 1 deletion mcp_server/tests/test_cursors.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import pytest
from pwc_mcp.cursors import CursorCodec, CursorState
from pwc_mcp.cursors import CursorCodec, CursorReferenceMismatch, CursorState


def _state(*, expires_at: int = 4600) -> CursorState:
Expand Down Expand Up @@ -57,3 +57,17 @@ def test_cursor_rejects_oversized_or_invalid_state():
codec.encode(_state(expires_at=1000))
with pytest.raises(ValueError, match="cursor secret"):
CursorCodec("")


def test_cursor_for_another_reference_reports_the_mismatch_with_its_state():
codec = CursorCodec("current-secret", now=lambda: 1000)
token = codec.encode(_state())

with pytest.raises(CursorReferenceMismatch, match="invalid continuation cursor") as caught:
codec.decode(token, reference="755")
assert caught.value.state == _state()

# Tampering and expiry still win over the reference check.
expired = CursorCodec("current-secret", now=lambda: 5000)
with pytest.raises(ValueError, match="expired continuation cursor"):
expired.decode(token, reference="755")
40 changes: 39 additions & 1 deletion mcp_server/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,10 +482,48 @@ def test_read_paper_rejects_invalid_continuation_as_an_expected_error():

assert result.is_error is True
assert result.content[0].text == (
"Error executing tool read_paper: invalid continuation cursor"
"Error executing tool read_paper: invalid continuation cursor; pass the "
"next_cursor value from the previous read_paper result, or omit cursor to "
"start from the beginning"
)


class AliasedStubCatalog(StubCatalog):
"""Resolves the numeric catalog ID and the arXiv ID to the same paper."""

def resolve_paper(self, paper: str):
self.resolve_calls += 1
return {"1706.03762": "1706.03762", "755": "1706.03762", "1810.04805": "1810.04805"}[paper]


def test_read_paper_continues_with_another_reference_to_the_same_paper():
# Agents start from the arXiv ID and continue with the numeric ID that
# list tools hand out; the cursor must follow the paper, not the spelling.
catalog = AliasedStubCatalog()

async def exercise():
async with Client(build_server(catalog, read_chunk_bytes=5)) as client:
first = await client.call_tool("read_paper", {"paper": "1706.03762"})
cursor = first.structured_content["next_cursor"]
same = await client.call_tool("read_paper", {"paper": "755", "cursor": cursor})
other = await client.call_tool("read_paper", {"paper": "1810.04805", "cursor": cursor})
return first, same, other

first, same, other = asyncio.run(exercise())

assert first.structured_content["markdown"] == "abcde"
assert same.is_error is False
assert same.structured_content["paper"] == "755"
assert same.structured_content["markdown"] == "fgh"
assert same.structured_content["next_cursor"] is None
assert other.is_error is True
assert other.content[0].text == (
"Error executing tool read_paper: continuation cursor belongs to a different "
"paper; omit cursor to start reading this paper from the beginning"
)
assert catalog.read_calls == [(0, None, 5), (5, "a" * 64, 5)]


def test_paper_listing_related_work_and_lineage_are_composable():
catalog = StubCatalog()
listed, recent, trending, related, lineage = _call(
Expand Down
4 changes: 2 additions & 2 deletions mcp_server/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion standalone_cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: pwc-cli
description: "Papers With Code CLI (`pwc`) for searching and reading AI/ML papers, discovering recent and trending research, finding related work and paper lineage, browsing tasks, methods, conferences, organizations, frameworks, and benchmark leaderboards, and submitting authenticated paper edits through the public Papers With Code catalog. Use whenever the user asks to find papers, survey literature, compare research, inspect an arXiv paper, explore AI/ML taxonomy or conferences, discover benchmarks or state-of-the-art models, or mentions Papers With Code, `pwc`, or `pwc-cli`."
---

Generated with `pwc v0.4.2`. Run `pwc skills add --force` to regenerate.
Generated with `pwc v0.4.3`. Run `pwc skills add --force` to regenerate.

Research commands query the public [Papers With Code](https://paperswithcode.co) catalog anonymously.
Paper editing requires explicit browser authorization through `pwc auth login --paper PAPER`.
Expand Down
2 changes: 1 addition & 1 deletion standalone_cli/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pwc-cli"
version = "0.4.2"
version = "0.4.3"
description = "Papers With Code research and paper-editing CLI"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion standalone_cli/src/pwc_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Standalone Papers With Code client."""

__version__ = "0.4.2"
__version__ = "0.4.3"
API_CONTRACT_VERSION = "v1"
Loading
Loading