nielsr HF Staff commited on
Commit
ee0b555
·
verified ·
1 Parent(s): 0f53d2e

Prototype PwC MCP Space from pwc-cli@6a547659

Browse files
Files changed (40) hide show
  1. Dockerfile +23 -0
  2. README.md +19 -5
  3. mcp_server/Dockerfile +23 -0
  4. mcp_server/README.md +139 -0
  5. mcp_server/SKILL.md +134 -0
  6. mcp_server/SPEC.md +119 -0
  7. mcp_server/pyproject.toml +30 -0
  8. mcp_server/space/README.md +19 -0
  9. mcp_server/src/pwc_mcp/__init__.py +3 -0
  10. mcp_server/src/pwc_mcp/app.py +579 -0
  11. mcp_server/src/pwc_mcp/catalog.py +564 -0
  12. mcp_server/src/pwc_mcp/cursors.py +189 -0
  13. mcp_server/src/pwc_mcp/models.py +585 -0
  14. mcp_server/src/pwc_mcp/server.py +1256 -0
  15. mcp_server/tests/conftest.py +9 -0
  16. mcp_server/tests/test_app.py +379 -0
  17. mcp_server/tests/test_catalog.py +575 -0
  18. mcp_server/tests/test_cursors.py +73 -0
  19. mcp_server/tests/test_parity.py +106 -0
  20. mcp_server/tests/test_server.py +1176 -0
  21. mcp_server/tests/test_skill.py +41 -0
  22. mcp_server/uv.lock +0 -0
  23. standalone_cli/README.md +179 -0
  24. standalone_cli/SKILL.md +166 -0
  25. standalone_cli/entrypoint.py +3 -0
  26. standalone_cli/install.py +226 -0
  27. standalone_cli/pyproject.toml +18 -0
  28. standalone_cli/scripts/check_contract.py +89 -0
  29. standalone_cli/src/pwc_cli/__init__.py +4 -0
  30. standalone_cli/src/pwc_cli/__main__.py +3 -0
  31. standalone_cli/src/pwc_cli/cli.py +0 -0
  32. standalone_cli/src/pwc_cli/edit_credentials.py +66 -0
  33. standalone_cli/src/pwc_cli/edit_transport.py +72 -0
  34. standalone_cli/src/pwc_cli/edits.py +154 -0
  35. standalone_cli/src/pwc_cli/queries.py +179 -0
  36. standalone_cli/src/pwc_cli/skills.py +294 -0
  37. standalone_cli/src/pwc_cli/transport.py +100 -0
  38. standalone_cli/tests/test_cli.py +0 -0
  39. standalone_cli/tests/test_edits.py +124 -0
  40. standalone_cli/tests/test_queries.py +225 -0
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12.11-slim-bookworm
2
+
3
+ COPY --from=ghcr.io/astral-sh/uv:0.8.15 /uv /uvx /bin/
4
+
5
+ ENV PYTHONDONTWRITEBYTECODE=1 \
6
+ PYTHONUNBUFFERED=1 \
7
+ PWC_MCP_HOST=0.0.0.0 \
8
+ PORT=7860
9
+
10
+ RUN useradd --create-home --uid 1000 app
11
+ WORKDIR /app
12
+
13
+ COPY standalone_cli /src/standalone_cli
14
+ COPY mcp_server /src/mcp_server
15
+ RUN uv sync --project /src/mcp_server --frozen --no-dev --no-editable
16
+
17
+ USER app
18
+ ENV PATH="/src/mcp_server/.venv/bin:$PATH"
19
+ EXPOSE 7860
20
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
21
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/health', timeout=4)"
22
+
23
+ CMD ["pwc-mcp"]
README.md CHANGED
@@ -1,10 +1,24 @@
1
  ---
2
- title: Paperswithcode Mcp
3
- emoji: 🚀
4
- colorFrom: pink
5
  colorTo: blue
6
  sdk: docker
7
- pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Papers With Code MCP
3
+ emoji: 📚
4
+ colorFrom: yellow
5
  colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
+ suggested_hardware: cpu-upgrade
9
+ short_description: Read-only MCP access to the Papers With Code catalog
10
  ---
11
 
12
+ # Papers With Code MCP
13
+
14
+ Anonymous, read-only access to papers, tasks, methods, benchmarks, and
15
+ leaderboards through Model Context Protocol.
16
+
17
+ - MCP endpoint: `/mcp`
18
+ - Health endpoint: `/health`
19
+ - Source: <https://github.com/huggingface/pwc-cli>
20
+ - Built from `huggingface/pwc-cli@6a547659644bce05a246a0e20029699dbe2850e1`
21
+ (`pwc-mcp` 0.2.10, the release live at `https://paperswithcode.co/mcp`)
22
+
23
+ Prototype: catalog reads go to the public `https://paperswithcode.co/api/v1`
24
+ and share its anonymous rate limits.
mcp_server/Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12.11-slim-bookworm
2
+
3
+ COPY --from=ghcr.io/astral-sh/uv:0.8.15 /uv /uvx /bin/
4
+
5
+ ENV PYTHONDONTWRITEBYTECODE=1 \
6
+ PYTHONUNBUFFERED=1 \
7
+ PWC_MCP_HOST=0.0.0.0 \
8
+ PORT=7860
9
+
10
+ RUN useradd --create-home --uid 1000 app
11
+ WORKDIR /app
12
+
13
+ COPY standalone_cli /src/standalone_cli
14
+ COPY mcp_server /src/mcp_server
15
+ RUN uv sync --project /src/mcp_server --frozen --no-dev --no-editable
16
+
17
+ USER app
18
+ ENV PATH="/src/mcp_server/.venv/bin:$PATH"
19
+ EXPOSE 7860
20
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
21
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/health', timeout=4)"
22
+
23
+ CMD ["pwc-mcp"]
mcp_server/README.md ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pwc MCP server
2
+
3
+ Anonymous, read-only Model Context Protocol access to the public
4
+ [Papers With Code](https://paperswithcode.co) catalog.
5
+
6
+ The server uses stock-client MCP `2025-11-25` over Streamable HTTP and also
7
+ serves experimental `2026-07-28` discovery on the same `/mcp` endpoint. It
8
+ returns versioned structured output with compact Markdown fallbacks.
9
+
10
+ ## Run locally
11
+
12
+ ```bash
13
+ uv run --project mcp_server pwc-mcp
14
+ ```
15
+
16
+ The default listener is `127.0.0.1:7860`. Set `PWC_MCP_HOST`, `PORT`, or
17
+ `PWC_API_URL` to override the bind address, HTTP port, or compatible PwC API.
18
+
19
+ ```bash
20
+ curl http://127.0.0.1:7860/health
21
+ ```
22
+
23
+ ## Tools
24
+
25
+ Every read-only `pwc` CLI research command is one tool, and every research flag
26
+ of that command is a tool parameter, so results match `pwc ... --json`. Each
27
+ result carries `schema_version` and `data`, the complete CLI JSON payload, next
28
+ to typed projections such as `items` or `evaluations`.
29
+
30
+ | Tool | CLI command |
31
+ | --- | --- |
32
+ | `search_papers` | `pwc search` |
33
+ | `get_paper_info` | `pwc paper info` |
34
+ | `get_paper_evaluations` | `pwc paper evaluations` |
35
+ | `read_paper` | `pwc paper read` (64 KiB chunks with a continuation cursor) |
36
+ | `list_papers` | `pwc paper list` |
37
+ | `list_recent_papers` | `pwc paper recent` |
38
+ | `list_trending_papers` | `pwc paper trending` |
39
+ | `get_related_papers` | `pwc paper related` |
40
+ | `get_paper_lineage` | `pwc paper lineage list` |
41
+ | `get_task` | `pwc task --name` |
42
+ | `list_tasks` | `pwc task list` |
43
+ | `get_method` | `pwc method --name` |
44
+ | `list_methods` | `pwc method list` |
45
+ | `get_conference` | `pwc conference --name` |
46
+ | `list_conferences` | `pwc conference list` |
47
+ | `get_organization` | `pwc organization --name` |
48
+ | `list_organizations` | `pwc organization list` |
49
+ | `get_framework` | `pwc framework --name` |
50
+ | `list_frameworks` | `pwc framework list` |
51
+ | `get_benchmark` | `pwc benchmark --name` |
52
+ | `list_benchmarks` | `pwc benchmark list` |
53
+
54
+ Parameter names follow the CLI flags except for the established MCP names
55
+ `published_after`/`published_before` (`--start-date`/`--end-date`), `limit`
56
+ (`--page-size`), `authors` (`--author`), `order_direction` (`--order-dir`),
57
+ `minimum_evaluations` (`--min-eval-count`), and `include_evaluations`
58
+ (`--include-evals`). Terminal-only flags (`--json`,
59
+ `--implementation-coverage`, `--flat`) have no parameter because MCP output is
60
+ always structured. Hosted differences from the CLI: `limit` is capped at 25,
61
+ `get_paper_info` returns compact official-first resources, and `read_paper`
62
+ is chunked. `search_papers` defaults to `hybrid` mode like `pwc search`.
63
+ `tests/test_parity.py` fails when the CLI parser and the tool schemas drift.
64
+
65
+ All tools are annotated read-only and idempotent. Search is deterministic;
66
+ the caller controls hybrid (default), keyword, or semantic mode. `read_paper`
67
+ fetches at most one 64 KiB catalog chunk per call and returns a signed, one-hour
68
+ continuation cursor when more Markdown remains. Continuations stay pinned to
69
+ the resolved paper and content version, so a changed paper fails with an
70
+ explicit restart response; any reference that resolves to the same paper (the
71
+ numeric catalog ID after starting from the arXiv ID, say) may carry the cursor.
72
+ A placeholder cursor such as `"next"` with nothing saved reads from the
73
+ beginning.
74
+
75
+ Paper tools fall back to the public API's `public-metadata` endpoint for papers
76
+ the catalog does not hold: `get_paper_info` and `read_paper` return Hugging Face
77
+ Papers, arXiv, or Crossref (DOI) metadata and the abstract with
78
+ `in_catalog: false`, a `note`, and labelled `available_at` links to cite instead
79
+ of a Papers With Code page; `get_related_papers` returns a catalog search for the
80
+ public title with the same `note` and links. A reference the endpoint maps to a
81
+ catalog paper returns the catalog paper. Titles go to the sibling
82
+ `public-metadata/search` endpoint, which accepts only an exact arXiv or Crossref
83
+ title match, or the single arXiv paper sharing the "Name:" before its colon.
84
+ `get_benchmark` answers a metric the leaderboard does not report with the
85
+ unfiltered leaderboard and a `metric_note` naming the available metrics.
86
+
87
+ ## Resources
88
+
89
+ - `pwc://papers/{paper}`
90
+ - `pwc://papers/{paper}/markdown`
91
+ - `pwc://tasks/{task}`
92
+ - `pwc://benchmarks/{benchmark}`
93
+
94
+ ## Configuration
95
+
96
+ | Variable | Purpose |
97
+ | --- | --- |
98
+ | `PWC_API_URL` | Compatible catalog API; defaults to production PwC |
99
+ | `PWC_MCP_HOST` | Listener address; defaults to loopback (`127.0.0.1`) |
100
+ | `PWC_MCP_ALLOWED_HOSTS` | Comma-separated HTTP Host allowlist |
101
+ | `PWC_MCP_ALLOWED_ORIGINS` | Comma-separated browser Origin allowlist |
102
+ | `PWC_MCP_CURSOR_KEY_CURRENT` | Required secret used to sign continuation cursors |
103
+ | `PWC_MCP_CURSOR_KEY_PREVIOUS` | Optional previous secret accepted during key rotation |
104
+ | `PORT` | HTTP port; defaults to `7860` |
105
+ | `PWC_MCP_REQUEST_LIMIT` | Requests per client per minute; defaults to `60` |
106
+ | `PWC_MCP_SEMANTIC_LIMIT` | Semantic or hybrid searches per client per minute; defaults to `10` |
107
+ | `PWC_MCP_CONCURRENCY_LIMIT` | Concurrent requests per client; defaults to `4` |
108
+ | `PWC_MCP_GLOBAL_CONCURRENCY_LIMIT` | Concurrent requests across all clients; defaults to `128` and sizes the tool thread pool |
109
+ | `LOG_LEVEL` | Content-free operational log level |
110
+
111
+ Native clients may omit `Origin`. Browser requests must match the configured
112
+ allowlist. A first-party client on the same host may send
113
+ `X-PwC-MCP-Client: <token>` to name its rate-limit identity (for example one
114
+ hashed chat session); the header counts only on a direct loopback connection
115
+ without `X-Forwarded-For`, so proxied public traffic cannot use it. The server does not log queries, paper references, request bodies,
116
+ raw IP addresses, or authorization headers.
117
+
118
+ The hosted defaults allow 60 total requests and 10 semantic or hybrid searches per minute
119
+ per client IP, with at most four concurrent requests per IP and 128 globally.
120
+ Tool inputs cap list results at 25, catalog calls time out after 25 seconds, and
121
+ request, upstream, and serialized MCP response bodies are bounded to 2 MiB.
122
+ The global ceiling is 128 concurrent requests by default.
123
+ Markdown chunks use a bounded 256-entry/16 MiB in-memory cache.
124
+
125
+ `GET /.well-known/mcp` exposes connection metadata and `GET /docs` publishes
126
+ the live tool schema. A bare `GET /mcp` returns `405`; protocol requests use
127
+ `POST /mcp`. The server also exposes `find_papers`, `compare_leaderboard`, and
128
+ `survey_task` prompts.
129
+
130
+ ## Test
131
+
132
+ ```bash
133
+ uv run --project mcp_server pytest mcp_server/tests
134
+ ```
135
+
136
+ The canonical hosted service is deployed as an immutable VPS component and
137
+ published through `https://paperswithcode.co/mcp`. The
138
+ `huggingface/paperswithcode-mcp` Docker Space remains an optional fallback; its
139
+ container explicitly binds to `0.0.0.0`.
mcp_server/SKILL.md ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: pwc-mcp
3
+ description: "Papers With Code MCP tools for searching and reading AI/ML papers, discovering recent and trending research, finding related work and paper lineage, browsing tasks, methods, conferences, organizations, and frameworks, and inspecting benchmark leaderboards with model-size and metric filters 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, the PwC MCP server, or paperswithcode.co/mcp."
4
+ compatibility: "Requires an MCP client connected to https://paperswithcode.co/mcp with the Papers With Code tools available."
5
+ ---
6
+
7
+ Generated for `pwc-mcp v0.2.7` and stock-client MCP protocol `2025-11-25`.
8
+
9
+ The tools query the public [Papers With Code](https://paperswithcode.co) catalog
10
+ anonymously and are read-only. Every tool runs the matching `pwc` CLI research
11
+ command, and every research flag of that command is a tool parameter, so
12
+ results match `pwc ... --json`. If live tool discovery and this skill disagree,
13
+ the discovered tool schemas are authoritative.
14
+
15
+ Every result carries `schema_version` and `data`, the complete CLI JSON payload
16
+ for the call. Typed fields such as `items`, `paper`, `task`, `evaluations` are
17
+ projections of `data`; read `data` when a projection lacks a detail. Use
18
+ structured output directly for filtering, joining, or other schema-dependent
19
+ processing.
20
+
21
+ When the user identifies an author, prefer repeatable structured
22
+ `list_papers` calls with the `authors` argument. Add `search` for stated topic
23
+ terms and explicit `order_by: "date_published"` and `order_direction: "desc"`
24
+ for newest or recent work; the default order is `trending`. Author references
25
+ accept an exact normalized name, numeric ID, or `@HF_USERNAME`; multiple
26
+ authors use AND semantics.
27
+
28
+ Publication date ranges are inclusive: use `published_after: "YYYY-MM-DD"` and
29
+ `published_before: "YYYY-MM-DD"` with `search_papers` or `list_papers`; the
30
+ start date must not be later than the end date.
31
+
32
+ Paper discovery results always include official implementation status and the
33
+ total linked repository count. Use `has_official_implementation: true` with
34
+ `search_papers` or `list_papers` to require a catalog-linked official
35
+ repository; this filter fails closed if unconfirmed.
36
+
37
+ Use `get_benchmark` with `max_parameters` to keep models at or below an
38
+ inclusive parameter limit. It accepts values such as `"500M"`, `"1.5B"`, `"3B"`,
39
+ and raw integers. Models without one consistent parameter count are excluded
40
+ from constrained results. Combine `require_metrics`, `minimum_metrics`,
41
+ `maximum_metrics`, `sort_metric`, and `pareto` to select leaderboard rows by
42
+ metric; a metric the leaderboard does not report returns the unfiltered
43
+ leaderboard with a `metric_note` naming the available metrics.
44
+
45
+ `paper` accepts a modern or legacy arXiv ID, a numeric external-paper ID, an
46
+ arXiv, Hugging Face, or Papers With Code URL, an exact paper title, or the
47
+ short name before a title's colon ("MuseTalk"). Title matching is
48
+ case-insensitive but exact; ambiguous titles fail with their matching IDs. When
49
+ a paper is not indexed on Papers With Code, `get_paper_info`, `read_paper`, and
50
+ `get_related_papers` return public Hugging Face Papers, arXiv, or Crossref (for
51
+ DOIs and exact titles) metadata marked `in_catalog: false` with a `note` and `available_at`
52
+ links; say the paper is not indexed on Papers With Code and link to those
53
+ sources, never to a Papers With Code paper page. Task, method, conference, organization, framework, and benchmark
54
+ arguments take an exact name, slug, or ID.
55
+
56
+ ## Tools
57
+
58
+ - `search_papers({"query": QUERY, "mode": "hybrid"|"keyword"|"semantic", "page": PAGE, "limit": LIMIT, "published_after": START_DATE, "published_before": END_DATE, "has_official_implementation": BOOLEAN})` — search papers by title, topic, author, or arXiv ID (`pwc search`). Omit optional arguments when they are not needed.
59
+ - `get_paper_info({"paper": PAPER, "include_resources": BOOLEAN, "repo_limit": LIMIT, "include_evaluations": BOOLEAN})` — show compact paper metadata, official-first code, and the total repository count; opt into capped additional resources (`pwc paper info`).
60
+ - `get_paper_evaluations({"paper": PAPER, "page": PAGE, "limit": LIMIT})` — page through one paper's benchmark evaluations, including protocol, sources, openness, and task-scoped ranks (`pwc paper evaluations`).
61
+ - `read_paper({"paper": PAPER, "cursor": CURSOR})` — read one stored paper Markdown chunk (`pwc paper read`). If `truncated` is true, call `read_paper` again with the same `paper` and the returned `next_cursor`; repeat until `truncated` is false. Treat the cursor as opaque and use it within one hour; omit it (or pass `"next"` before any read) to start from the beginning.
62
+ - `list_papers({"search": SEARCH, "task": TASK, "method": METHOD, "conference": CONFERENCE, "framework": FRAMEWORK, "organization": ORGANIZATION, "authors": [AUTHOR], "published_after": START_DATE, "published_before": END_DATE, "all_versions": BOOLEAN, "order_by": "trending"|"date_published"|"citation_count", "order_direction": "asc"|"desc", "include_resources": BOOLEAN, "has_official_implementation": BOOLEAN, "page": PAGE, "limit": LIMIT})` — list and filter papers by exact catalog associations (`pwc paper list`). Omit optional arguments when they are not needed.
63
+ - `list_recent_papers({"limit": LIMIT})` — list the most recently added papers (`pwc paper recent`).
64
+ - `list_trending_papers({"limit": LIMIT, "max_age_days": DAYS, "min_velocity": VELOCITY})` — list trending papers by repository velocity (`pwc paper trending`).
65
+ - `get_related_papers({"paper": PAPER, "limit": LIMIT})` — list related papers (`pwc paper related`); `limit` is at most 20.
66
+ - `get_paper_lineage({"paper": PAPER})` — list explicit predecessors and successors (`pwc paper lineage list`).
67
+ - `get_task({"task": TASK})` — inspect one exact task, including its area, hierarchy, sister tasks, ranked benchmarks, common methods, recommended frameworks, and trending papers (`pwc task --name`).
68
+ - `list_tasks({"search": SEARCH, "area": AREA, "level": LEVEL, "visible_only": BOOLEAN, "group_by_area": BOOLEAN, "order_by": "name"|"created_at"|"level"|"paper_count", "order_direction": "asc"|"desc", "page": PAGE, "limit": LIMIT})` — search, list, and filter research tasks, or set `group_by_area: true` for the complete visible top-level taxonomy without pagination (`pwc task list`).
69
+ - `get_method({"method": METHOD})` — inspect one exact method with its area (`pwc method --name`).
70
+ - `list_methods({"search": SEARCH, "area": AREA, "introduced_year": YEAR, "order_by": "name"|"full_name"|"introduced_year"|"created_at"|"paper_count", "order_direction": "asc"|"desc", "page": PAGE, "limit": LIMIT})` — search, list, and filter research methods (`pwc method list`).
71
+ - `get_conference({"conference": CONFERENCE})` — inspect one exact conference (`pwc conference --name`).
72
+ - `list_conferences({"year": YEAR})` — list conferences with imported papers (`pwc conference list`).
73
+ - `get_organization({"organization": ORGANIZATION})` — inspect one exact research organization (`pwc organization --name`).
74
+ - `list_organizations({"featured_only": BOOLEAN})` — list research organizations (`pwc organization list`).
75
+ - `get_framework({"framework": FRAMEWORK})` — inspect one exact research framework (`pwc framework --name`).
76
+ - `list_frameworks({"domain": DOMAIN, "category": CATEGORY, "platform": PLATFORM})` — list research frameworks (`pwc framework list`).
77
+ - `get_benchmark({"benchmark": BENCHMARK, "page": PAGE, "limit": LIMIT, "is_open": BOOLEAN, "max_parameters": SIZE, "require_metrics": [METRIC], "minimum_metrics": {METRIC: VALUE}, "maximum_metrics": {METRIC: VALUE}, "sort_metric": "METRIC:asc|desc", "pareto": ["METRIC:higher", "METRIC:lower"]})` — inspect one paginated benchmark leaderboard with model-size, metric threshold, sort, and Pareto selection (`pwc benchmark --name`). `matched_count` reports rows that satisfied the filters before pagination.
78
+ - `list_benchmarks({"search": SEARCH, "task": TASK, "include_descendants": BOOLEAN, "minimum_evaluations": COUNT, "is_open": BOOLEAN, "group_by_area": BOOLEAN, "area": AREA, "benchmarks_per_task": COUNT, "order_by": "trending"|"name"|"full_name"|"created_at"|"paper_count", "order_direction": "asc"|"desc", "page": PAGE, "limit": LIMIT})` — list and filter benchmarks (`pwc benchmark list`). With `task`, results are ranked by trend unless `order_by` is set; `group_by_area` or `area` returns top benchmarks under each visible task.
79
+
80
+ All page numbers start at 1. `limit` is between 1 and 25 and defaults to the
81
+ CLI page size capped at 25. Follow `next_page` when the user asks for more
82
+ results than one response contains; do not infer that a missing item does not
83
+ exist until the relevant pages have been checked.
84
+
85
+ The MCP server does not expose the standalone CLI's paper editing,
86
+ authentication, skill installation, or version commands, and `pwc paper read`
87
+ is served as `read_paper` chunks rather than one document.
88
+ Do not invent equivalent tools. Use the separate `pwc` CLI only when it is
89
+ available and the user needs one of those capabilities.
90
+
91
+ ## Research workflow
92
+
93
+ 1. Use `list_benchmarks({"task": TASK})` to discover active benchmarks, then
94
+ `get_benchmark({"benchmark": NAME})` to inspect a leaderboard. Add
95
+ `max_parameters` when model size is part of the request and `sort_metric`
96
+ or `minimum_metrics` when a specific metric matters.
97
+ 2. Use `get_paper_info({"paper": PAPER})` to inspect promising results. Its
98
+ response includes official-first code; use `get_paper_evaluations` to page
99
+ through its benchmark results.
100
+ 3. Use exact `list_papers` `authors`, `task`, `method`, `conference`,
101
+ `framework`, and `organization` arguments for known identities or catalog
102
+ associations. Combine them to require every association; do not substitute
103
+ a keyword search. Add `search` for title or abstract topic terms.
104
+ 4. Use `search_papers` for broader discovery, then `read_paper` for primary
105
+ evidence. Follow every returned continuation cursor needed for the user's
106
+ question.
107
+ 5. Expand the literature with `get_related_papers` and use
108
+ `get_paper_lineage` when model or method ancestry matters. Use `get_task`,
109
+ `get_method`, `list_conferences`, `list_organizations`, and
110
+ `list_frameworks` to orient within the taxonomy.
111
+ 6. Preserve paper titles, identifiers, and URLs so claims remain traceable.
112
+
113
+ ## Output and limits
114
+
115
+ - Tool results use stable, versioned structured output with compact text
116
+ fallbacks. Prefer structured fields over parsing the text fallback.
117
+ - Search mode is deterministic: `hybrid` is the default, matching
118
+ `pwc search`; `keyword` uses lexical retrieval alone and `semantic` uses
119
+ dense retrieval alone. Both `hybrid` and `semantic` count toward the semantic
120
+ search rate limit, so pass `mode: "keyword"` for exact terminology or when
121
+ that limit is close.
122
+ - Catalog-filtered paper lists fail closed unless the server confirms every
123
+ requested filter; never treat results from an older server as filtered.
124
+ Parameter-filtered leaderboards fail closed unless the server confirms
125
+ parameter-filter support and every returned model satisfies the limit.
126
+ - `read_paper` returns at most one 64 KiB chunk per call. A continuation stays
127
+ pinned to the resolved paper and content version; if the paper changes, start
128
+ reading again from the beginning.
129
+ - The hosted service limits each client to 60 total requests and 10 semantic
130
+ or hybrid searches per minute, with at most four concurrent requests. Respect
131
+ retry metadata instead of trying to work around limits.
132
+ - The tools contain no authentication, catalog mutation, ingestion,
133
+ publication, image, embedding, CRON, or infrastructure-maintenance
134
+ operations.
mcp_server/SPEC.md ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Papers With Code MCP server specification
2
+
3
+ This document records the agreed beta scope for the hosted Papers With Code MCP
4
+ server.
5
+
6
+ ## Product and hosting
7
+
8
+ - Live in this repository as the separate `pwc-mcp` Python package and reuse the
9
+ CLI's public HTTP transport rather than invoking the CLI as a subprocess.
10
+ - Ship the canonical endpoint from the Papers With Code VPS at
11
+ `https://paperswithcode.co/mcp`. The public Docker Space
12
+ `huggingface/paperswithcode-mcp` remains an optional fallback without changing
13
+ the MCP contract.
14
+ - Serve anonymous, read-only requests. Search is deterministic and contains no
15
+ embedded language model.
16
+ - Use stateless Streamable HTTP at `/mcp`, advertising stock-client MCP
17
+ `2025-11-25` while supporting experimental `2026-07-28` discovery. Expose
18
+ `/health`, `/.well-known/mcp`, and generated `/docs` schema routes.
19
+
20
+ ## Public contract
21
+
22
+ Expose exactly one tool per read-only `pwc` CLI research command, with every
23
+ research flag of that command as a tool parameter (`tests/test_parity.py`
24
+ enforces this against the CLI parser):
25
+
26
+ - `search_papers` (`pwc search`)
27
+ - `get_paper_info` (`pwc paper info`)
28
+ - `get_paper_evaluations` (`pwc paper evaluations`)
29
+ - `read_paper` (`pwc paper read`)
30
+ - `list_papers` (`pwc paper list`)
31
+ - `list_recent_papers` (`pwc paper recent`)
32
+ - `list_trending_papers` (`pwc paper trending`)
33
+ - `get_related_papers` (`pwc paper related`)
34
+ - `get_paper_lineage` (`pwc paper lineage list`)
35
+ - `get_task` (`pwc task --name`)
36
+ - `list_tasks` (`pwc task list`)
37
+ - `get_method` (`pwc method --name`)
38
+ - `list_methods` (`pwc method list`)
39
+ - `get_conference` (`pwc conference --name`)
40
+ - `list_conferences` (`pwc conference list`)
41
+ - `get_organization` (`pwc organization --name`)
42
+ - `list_organizations` (`pwc organization list`)
43
+ - `get_framework` (`pwc framework --name`)
44
+ - `list_frameworks` (`pwc framework list`)
45
+ - `get_benchmark` (`pwc benchmark --name`)
46
+ - `list_benchmarks` (`pwc benchmark list`)
47
+
48
+ Tools run the CLI handlers in-process through the shared cached transport, so
49
+ validation, fail-closed filter confirmation, and the JSON payload are the
50
+ CLI's. Every result includes that payload as `data` beside typed projections.
51
+ Terminal-only flags have no parameter. The hosted service caps `limit` at 25,
52
+ returns compact official-first paper resources, and serves `read_paper` in
53
+ chunks. `search_papers` defaults to hybrid mode, the same as `pwc search`.
54
+
55
+ Expose these resource templates:
56
+
57
+ - `pwc://papers/{paper}`
58
+ - `pwc://papers/{paper}/markdown`
59
+ - `pwc://tasks/{task}`
60
+ - `pwc://benchmarks/{benchmark}`
61
+
62
+ Expose `find_papers`, `compare_leaderboard`, and `survey_task` prompts.
63
+
64
+ Responses use stable, MCP-specific versioned structured outputs with a text
65
+ fallback. `read_paper` performs one upstream read of at most 64 KiB per call and
66
+ returns a signed opaque continuation cursor when more Markdown remains. The
67
+ cursor binds the original reference, canonical paper, content version, byte
68
+ offset, chunk limit, key identifier, and fixed one-hour expiry. A continuation
69
+ supplied with a different reference is honoured only when that reference
70
+ resolves to the cursor's canonical paper. The current and previous signing keys
71
+ support rotation without accepting unsigned state.
72
+
73
+ Paper references accept arXiv IDs, numeric PwC external IDs, arXiv/Hugging
74
+ Face/Papers With Code URLs, exact titles, and the unique short name before a
75
+ title's colon. Ambiguous exact titles fail rather than selecting one result.
76
+ A reference the catalog does not hold falls back to the public API's
77
+ `papers/{reference}/public-metadata` endpoint (Hugging Face Papers, arXiv, or
78
+ Crossref metadata with labelled `available_at` links), cached for an hour
79
+ including misses, and marked `in_catalog: false`; full text is never fetched
80
+ from outside the catalog. A title the endpoint rejects goes to
81
+ `papers/public-metadata/search?title=`, which matches exact normalized titles on
82
+ arXiv and Crossref (or the single arXiv paper sharing a "Name:" prefix, noted
83
+ in the result). Misses say whether an identifier or a title was tried, or that
84
+ the lookup was unavailable. A reference either endpoint maps to a catalog paper
85
+ is retried with that paper's arXiv or catalog ID.
86
+
87
+ Paper evaluations and benchmark leaderboards paginate. Leaderboards merge equivalent model rows across
88
+ task scopes while retaining scoped ranks, protocol, split, shots, source,
89
+ openness, and update time. Metric direction is explicit when known.
90
+
91
+ ## Safety and operations
92
+
93
+ - Require a strict configurable browser Origin allowlist; native clients may
94
+ omit Origin. Never configure a wildcard Origin.
95
+ - Enforce, per IP, 60 total requests/minute, 10 semantic or hybrid searches/minute, and
96
+ four concurrent requests, plus a global ceiling of 128 concurrent requests.
97
+ All four limits are overridable through `PWC_MCP_*_LIMIT` environment
98
+ variables; the global ceiling also sizes the synchronous tool thread pool.
99
+ List tools return at most 25 rows.
100
+ - Set catalog timeouts to 25 seconds and bound HTTP request and upstream response
101
+ bodies and serialized MCP responses to 2 MiB.
102
+ - Log only tool name, status, latency, response bytes, protocol, and server
103
+ version. Never log queries, paper identifiers, raw IPs, authorization values,
104
+ or request/response bodies.
105
+ - Cache tool/resource catalogs for one hour, taxonomy data for ten minutes,
106
+ paper and benchmark data for five minutes, and search results for one minute.
107
+ Cache immutable, versioned Markdown chunks for one hour within both a
108
+ 256-entry and 16 MiB ceiling.
109
+ - Bind to loopback on the VPS, trust forwarded identity only from an exact
110
+ loopback peer, let a direct loopback client without `X-Forwarded-For` name
111
+ its own rate-limit identity with `X-PwC-MCP-Client`, and expose cached catalog readiness without making `/health`
112
+ wait on an upstream call.
113
+
114
+ ## Release
115
+
116
+ Immutable releases must test the CLI contract and MCP server and build the
117
+ Docker image. VPS deployment smoke-tests `/health`, the single `/mcp` endpoint,
118
+ and the catalog continuation contract before nginx publication. Only tags
119
+ matching `pwc-mcp-*` may additionally publish the fallback Space.
mcp_server/pyproject.toml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "pwc-mcp"
3
+ version = "0.2.10"
4
+ description = "Read-only Papers With Code MCP server"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = {text = "Apache-2.0"}
8
+ dependencies = [
9
+ "mcp>=2.1,<3",
10
+ "pydantic>=2.11,<3",
11
+ "pwc-cli>=0.4.1,<0.5",
12
+ "starlette>=0.47,<1",
13
+ "uvicorn>=0.35,<1",
14
+ ]
15
+
16
+ [project.scripts]
17
+ pwc-mcp = "pwc_mcp.app:main"
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/pwc_mcp"]
25
+
26
+ [tool.uv.sources]
27
+ pwc-cli = {path = "../standalone_cli", editable = true}
28
+
29
+ [dependency-groups]
30
+ dev = ["httpx>=0.28,<1", "pytest>=8"]
mcp_server/space/README.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Papers With Code MCP
3
+ emoji: 📚
4
+ colorFrom: yellow
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ suggested_hardware: cpu-upgrade
9
+ short_description: Read-only MCP access to the Papers With Code research catalog
10
+ ---
11
+
12
+ # Papers With Code MCP
13
+
14
+ Anonymous, read-only access to papers, tasks, methods, benchmarks, and
15
+ leaderboards through Model Context Protocol.
16
+
17
+ - MCP endpoint: `/mcp`
18
+ - Health endpoint: `/health`
19
+ - Source: <https://github.com/huggingface/pwc-cli>
mcp_server/src/pwc_mcp/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Read-only Papers With Code MCP server."""
2
+
3
+ __version__ = "0.2.10"
mcp_server/src/pwc_mcp/app.py ADDED
@@ -0,0 +1,579 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import ipaddress
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import secrets
11
+ import threading
12
+ import time
13
+ from collections import defaultdict, deque
14
+
15
+ import anyio.to_thread
16
+ import uvicorn
17
+ from mcp.server.transport_security import TransportSecuritySettings
18
+ from pwc_cli.transport import ResponseError, TransportError
19
+ from starlette.datastructures import Headers
20
+ from starlette.middleware.cors import CORSMiddleware
21
+ from starlette.requests import Request
22
+ from starlette.responses import JSONResponse
23
+ from starlette.routing import Route
24
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
25
+
26
+ from pwc_mcp import __version__
27
+ from pwc_mcp.catalog import CatalogClient
28
+ from pwc_mcp.server import DEFAULT_SEARCH_MODE, TOOL_COMMANDS, Catalog, build_server
29
+
30
+ LOGGER = logging.getLogger("pwc_mcp.requests")
31
+ # MCP SDK diagnostics can include peer-supplied tool names and resource URIs.
32
+ # OperationalTelemetryMiddleware is the server's sole request log surface.
33
+ logging.getLogger("mcp").setLevel(logging.CRITICAL + 1)
34
+ PROTOCOL_VERSION = "2025-11-25"
35
+ EXPERIMENTAL_PROTOCOL_VERSION = "2026-07-28"
36
+ MAX_REQUEST_BODY_SIZE = 2 * 1024 * 1024
37
+ MAX_RESPONSE_BODY_SIZE = 2 * 1024 * 1024
38
+ KNOWN_TOOLS = frozenset(TOOL_COMMANDS)
39
+ KNOWN_PROTOCOLS = {
40
+ PROTOCOL_VERSION,
41
+ EXPERIMENTAL_PROTOCOL_VERSION,
42
+ "2025-06-18",
43
+ "2025-03-26",
44
+ "2024-11-05",
45
+ }
46
+
47
+
48
+ async def well_known_mcp(_request: Request) -> JSONResponse:
49
+ return JSONResponse(
50
+ {
51
+ "name": "Papers With Code",
52
+ "description": "Anonymous read-only AI research catalog",
53
+ "transport": {"type": "streamable-http", "url": "/mcp"},
54
+ "protocol_version": PROTOCOL_VERSION,
55
+ "supported_protocol_versions": sorted(KNOWN_PROTOCOLS, reverse=True),
56
+ "documentation_url": "https://paperswithcode.co/mcp/schema",
57
+ "setup_url": "https://paperswithcode.co/mcp",
58
+ }
59
+ )
60
+
61
+
62
+ def _docs_schema(server) -> dict:
63
+ return {
64
+ "name": "Papers With Code MCP",
65
+ "version": __version__,
66
+ "protocol_version": PROTOCOL_VERSION,
67
+ "endpoint": "/mcp",
68
+ "tools": [
69
+ {
70
+ "name": tool.name,
71
+ "description": tool.description,
72
+ "inputSchema": tool.parameters,
73
+ "outputSchema": tool.output_schema,
74
+ }
75
+ for tool in server._tool_manager.list_tools()
76
+ ],
77
+ }
78
+
79
+
80
+ class MCPMethodMiddleware:
81
+ """Reject a bare GET instead of opening an unbounded SSE response."""
82
+
83
+ def __init__(self, app: ASGIApp):
84
+ self.app = app
85
+
86
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
87
+ if (
88
+ scope["type"] == "http"
89
+ and scope.get("path") == "/mcp"
90
+ and scope.get("method") == "GET"
91
+ ):
92
+ response = JSONResponse(
93
+ {"error": "method_not_allowed", "allowed": ["POST"]},
94
+ status_code=405,
95
+ headers={"Allow": "POST"},
96
+ )
97
+ await response(scope, receive, send)
98
+ return
99
+ await self.app(scope, receive, send)
100
+
101
+
102
+ # Hosted defaults. The first-party chat gateway names one identity per chat
103
+ # session (see _client_address), so per-client limits protect fairness while
104
+ # the global ceiling protects the process. Every value is overridable through
105
+ # the environment for other deployments.
106
+ DEFAULT_REQUEST_LIMIT = 60
107
+ DEFAULT_SEMANTIC_LIMIT = 10
108
+ DEFAULT_CONCURRENCY_LIMIT = 4
109
+ DEFAULT_GLOBAL_CONCURRENCY_LIMIT = 128
110
+ # anyio runs synchronous tool functions in a thread pool whose default size (40)
111
+ # would silently cap concurrency below the global ceiling.
112
+ MINIMUM_THREAD_TOKENS = 40
113
+
114
+
115
+ def _int_env(name: str, default: int, *, maximum: int = 10_000) -> int:
116
+ value = os.environ.get(name)
117
+ if value is None or not value.strip():
118
+ return default
119
+ if not value.strip().isdigit() or not 1 <= int(value) <= maximum:
120
+ raise ValueError(f"{name} must be an integer between 1 and {maximum}")
121
+ return int(value)
122
+
123
+
124
+ def thread_limiter_tokens(global_concurrency_limit: int) -> int:
125
+ return max(MINIMUM_THREAD_TOKENS, global_concurrency_limit)
126
+
127
+
128
+ def _csv_env(name: str, default: list[str]) -> list[str]:
129
+ value = os.environ.get(name)
130
+ if value is None:
131
+ return default
132
+ return [item.strip() for item in value.split(",") if item.strip()]
133
+
134
+
135
+ class CatalogReadiness:
136
+ def __init__(self, catalog: CatalogClient | None, *, initially_ready: bool = False):
137
+ self.catalog = catalog
138
+ self.ready = initially_ready
139
+ self.probing = False
140
+ self.next_probe_at = 0.0
141
+ self.lock = threading.Lock()
142
+
143
+ def schedule(self) -> None:
144
+ if self.catalog is None:
145
+ return
146
+ now = time.monotonic()
147
+ with self.lock:
148
+ if self.probing or now < self.next_probe_at:
149
+ return
150
+ self.probing = True
151
+ asyncio.create_task(asyncio.to_thread(self._probe))
152
+
153
+ def _probe(self) -> None:
154
+ try:
155
+ assert self.catalog is not None
156
+ ready = self.catalog.check_readiness()
157
+ except (ResponseError, TransportError):
158
+ ready = False
159
+ with self.lock:
160
+ self.ready = ready
161
+ self.probing = False
162
+ self.next_probe_at = time.monotonic() + (30 if ready else 5)
163
+
164
+
165
+ async def health(request: Request) -> JSONResponse:
166
+ readiness: CatalogReadiness = request.app.state.pwc_catalog_readiness
167
+ readiness.schedule()
168
+ ready = readiness.ready
169
+ return JSONResponse(
170
+ {
171
+ "status": "ok" if ready else "unavailable",
172
+ "service": "pwc-mcp",
173
+ "version": __version__,
174
+ "protocol": PROTOCOL_VERSION,
175
+ },
176
+ status_code=200 if ready else 503,
177
+ )
178
+
179
+
180
+ # A first-party client on the same host (the chat gateway) may name the
181
+ # rate-limit identity of a request, for example one hashed chat session. The
182
+ # header counts only on a direct loopback connection that carries no proxy
183
+ # header: nginx always adds X-Forwarded-For, so public traffic cannot use it.
184
+ CLIENT_IDENTITY_HEADER = "x-pwc-mcp-client"
185
+ CLIENT_IDENTITY = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
186
+
187
+
188
+ def _client_address(scope: Scope, headers: Headers, trust_proxy_headers: bool) -> str:
189
+ client = scope.get("client")
190
+ direct_address = str(client[0]) if client else "unknown"
191
+ try:
192
+ trusted_hop = ipaddress.ip_address(direct_address).is_loopback
193
+ except ValueError:
194
+ trusted_hop = False
195
+ if trust_proxy_headers and trusted_hop:
196
+ forwarded = headers.get("x-forwarded-for")
197
+ if forwarded:
198
+ return forwarded.split(",", 1)[0].strip()
199
+ identity = headers.get(CLIENT_IDENTITY_HEADER, "")
200
+ if CLIENT_IDENTITY.fullmatch(identity):
201
+ return f"client:{identity}"
202
+ return direct_address
203
+
204
+
205
+ class RequestBodyTooLarge(Exception):
206
+ pass
207
+
208
+
209
+ class ResponseSizeLimitMiddleware:
210
+ def __init__(self, app: ASGIApp):
211
+ self.app = app
212
+
213
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
214
+ if (
215
+ scope["type"] != "http"
216
+ or scope.get("path") != "/mcp"
217
+ or scope.get("method") != "POST"
218
+ ):
219
+ await self.app(scope, receive, send)
220
+ return
221
+ messages: list[Message] = []
222
+ size = 0
223
+ oversized = False
224
+
225
+ async def capture(message: Message) -> None:
226
+ nonlocal size, oversized
227
+ if message["type"] == "http.response.body":
228
+ size += len(message.get("body", b""))
229
+ oversized = oversized or size > MAX_RESPONSE_BODY_SIZE
230
+ if not oversized:
231
+ messages.append(message)
232
+
233
+ await self.app(scope, receive, capture)
234
+ if oversized:
235
+ response = JSONResponse({"error": "response_too_large"}, status_code=503)
236
+ await response(scope, receive, send)
237
+ return
238
+ for message in messages:
239
+ await send(message)
240
+
241
+
242
+ async def _body_and_replay(
243
+ receive: Receive, *, maximum_bytes: int
244
+ ) -> tuple[bytes, Receive]:
245
+ messages: list[Message] = []
246
+ chunks = []
247
+ size = 0
248
+ while True:
249
+ message = await receive()
250
+ messages.append(message)
251
+ if message["type"] != "http.request":
252
+ break
253
+ chunk = message.get("body", b"")
254
+ size += len(chunk)
255
+ if size > maximum_bytes:
256
+ raise RequestBodyTooLarge
257
+ chunks.append(chunk)
258
+ if not message.get("more_body", False):
259
+ break
260
+ pending = deque(messages)
261
+
262
+ async def replay() -> Message:
263
+ if pending:
264
+ return pending.popleft()
265
+ return await receive()
266
+
267
+ return b"".join(chunks), replay
268
+
269
+
270
+ def _tool_and_semantic(headers: Headers, body: bytes) -> tuple[str | None, bool]:
271
+ header_tool = headers.get("mcp-name")
272
+ body_tool = None
273
+ mode = DEFAULT_SEARCH_MODE
274
+ try:
275
+ payload = json.loads(body)
276
+ except (UnicodeDecodeError, json.JSONDecodeError):
277
+ tool = header_tool if header_tool in KNOWN_TOOLS else None
278
+ return tool, False
279
+ if isinstance(payload, dict):
280
+ params = payload.get("params")
281
+ if isinstance(params, dict):
282
+ body_tool = (
283
+ params.get("name") if isinstance(params.get("name"), str) else None
284
+ )
285
+ if isinstance(payload, dict):
286
+ params = payload.get("params")
287
+ arguments = params.get("arguments") if isinstance(params, dict) else None
288
+ if isinstance(arguments, dict):
289
+ mode = arguments.get("mode", DEFAULT_SEARCH_MODE)
290
+ tool = body_tool or header_tool
291
+ tool = tool if tool in KNOWN_TOOLS else None
292
+ # Hybrid retrieval also runs the dense embedding search upstream.
293
+ return tool, tool == "search_papers" and mode in {"semantic", "hybrid"}
294
+
295
+
296
+ def _protocol_label(headers: Headers) -> str:
297
+ value = headers.get("mcp-protocol-version")
298
+ if value is None:
299
+ return "legacy"
300
+ return value if value in KNOWN_PROTOCOLS else "unknown"
301
+
302
+
303
+ class RateLimitMiddleware:
304
+ def __init__(
305
+ self,
306
+ app: ASGIApp,
307
+ *,
308
+ request_limit: int,
309
+ semantic_limit: int,
310
+ concurrency_limit: int,
311
+ global_concurrency_limit: int,
312
+ trust_proxy_headers: bool,
313
+ identity_limit: int = 10_000,
314
+ ):
315
+ self.app = app
316
+ self.request_limit = request_limit
317
+ self.semantic_limit = semantic_limit
318
+ self.concurrency_limit = concurrency_limit
319
+ self.global_concurrency_limit = global_concurrency_limit
320
+ self.trust_proxy_headers = trust_proxy_headers
321
+ self.identity_limit = identity_limit
322
+ self.key = secrets.token_bytes(32)
323
+ self.requests: dict[str, deque[float]] = defaultdict(deque)
324
+ self.semantic_requests: dict[str, deque[float]] = defaultdict(deque)
325
+ self.active: dict[str, int] = defaultdict(int)
326
+ self.lock = asyncio.Lock()
327
+ self.next_cleanup_at = 0.0
328
+ self.global_active = 0
329
+
330
+ def _identity(self, address: str) -> str:
331
+ return hashlib.blake2b(
332
+ address.encode(), key=self.key, digest_size=16
333
+ ).hexdigest()
334
+
335
+ @staticmethod
336
+ def _trim(values: deque[float], now: float) -> None:
337
+ while values and values[0] <= now - 60:
338
+ values.popleft()
339
+
340
+ def _purge_stale_identities(self, now: float) -> None:
341
+ for identity in list(self.requests):
342
+ requests = self.requests[identity]
343
+ semantic_requests = self.semantic_requests.get(identity)
344
+ self._trim(requests, now)
345
+ if semantic_requests is not None:
346
+ self._trim(semantic_requests, now)
347
+ if not requests and not semantic_requests and not self.active.get(identity):
348
+ self.requests.pop(identity, None)
349
+ self.semantic_requests.pop(identity, None)
350
+
351
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
352
+ if scope["type"] == "lifespan":
353
+ anyio.to_thread.current_default_thread_limiter().total_tokens = (
354
+ thread_limiter_tokens(self.global_concurrency_limit)
355
+ )
356
+ if (
357
+ scope["type"] != "http"
358
+ or scope.get("path") != "/mcp"
359
+ or scope.get("method") != "POST"
360
+ ):
361
+ await self.app(scope, receive, send)
362
+ return
363
+
364
+ headers = Headers(scope=scope)
365
+ try:
366
+ body, replay = await _body_and_replay(
367
+ receive, maximum_bytes=MAX_REQUEST_BODY_SIZE
368
+ )
369
+ except RequestBodyTooLarge:
370
+ response = JSONResponse({"error": "request_too_large"}, status_code=413)
371
+ await response(scope, receive, send)
372
+ return
373
+ tool, semantic = _tool_and_semantic(headers, body)
374
+ scope.setdefault("state", {})["pwc_tool_name"] = tool
375
+ identity = self._identity(
376
+ _client_address(scope, headers, self.trust_proxy_headers)
377
+ )
378
+ now = time.monotonic()
379
+ limited = False
380
+ saturated = False
381
+ requests: deque[float] | None = None
382
+ semantic_requests: deque[float] | None = None
383
+ async with self.lock:
384
+ saturated = self.global_active >= self.global_concurrency_limit
385
+ if not saturated and (
386
+ identity not in self.requests
387
+ and len(self.requests) >= self.identity_limit
388
+ ):
389
+ if now >= self.next_cleanup_at:
390
+ self._purge_stale_identities(now)
391
+ self.next_cleanup_at = now + 5
392
+ limited = len(self.requests) >= self.identity_limit
393
+ if not limited and not saturated:
394
+ requests = self.requests[identity]
395
+ semantic_requests = self.semantic_requests[identity]
396
+ self._trim(requests, now)
397
+ self._trim(semantic_requests, now)
398
+ limited = (
399
+ len(requests) >= self.request_limit
400
+ or (semantic and len(semantic_requests) >= self.semantic_limit)
401
+ or self.active[identity] >= self.concurrency_limit
402
+ )
403
+ if not limited and not saturated:
404
+ assert requests is not None
405
+ assert semantic_requests is not None
406
+ requests.append(now)
407
+ if semantic:
408
+ semantic_requests.append(now)
409
+ self.active[identity] += 1
410
+ self.global_active += 1
411
+ if saturated:
412
+ response = JSONResponse(
413
+ {"error": "server_saturated"},
414
+ status_code=503,
415
+ headers={"Retry-After": "1"},
416
+ )
417
+ await response(scope, replay, send)
418
+ return
419
+ if limited:
420
+ response = JSONResponse(
421
+ {"error": "rate_limit_exceeded"},
422
+ status_code=429,
423
+ headers={"Retry-After": "60"},
424
+ )
425
+ await response(scope, replay, send)
426
+ return
427
+ try:
428
+ await self.app(scope, replay, send)
429
+ finally:
430
+ async with self.lock:
431
+ self.active[identity] -= 1
432
+ if self.active[identity] == 0:
433
+ self.active.pop(identity, None)
434
+ self.global_active -= 1
435
+
436
+
437
+ class OperationalTelemetryMiddleware:
438
+ def __init__(self, app: ASGIApp):
439
+ self.app = app
440
+
441
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
442
+ if scope["type"] != "http" or scope.get("path") != "/mcp":
443
+ await self.app(scope, receive, send)
444
+ return
445
+ started = time.monotonic()
446
+ status = 500
447
+ size = 0
448
+
449
+ async def observe(message: Message) -> None:
450
+ nonlocal status, size
451
+ if message["type"] == "http.response.start":
452
+ status = int(message["status"])
453
+ elif message["type"] == "http.response.body":
454
+ size += len(message.get("body", b""))
455
+ await send(message)
456
+
457
+ try:
458
+ await self.app(scope, receive, observe)
459
+ finally:
460
+ headers = Headers(scope=scope)
461
+ tool = scope.get("state", {}).get("pwc_tool_name")
462
+ if tool not in KNOWN_TOOLS:
463
+ tool = "unknown"
464
+ LOGGER.info(
465
+ "mcp_request tool=%s status=%s latency_ms=%s bytes=%s protocol=%s version=%s",
466
+ tool or "unknown",
467
+ status,
468
+ round((time.monotonic() - started) * 1000),
469
+ size,
470
+ _protocol_label(headers),
471
+ __version__,
472
+ )
473
+
474
+
475
+ def create_app(
476
+ catalog: Catalog | None = None,
477
+ *,
478
+ allowed_hosts: list[str] | None = None,
479
+ allowed_origins: list[str] | None = None,
480
+ request_limit: int | None = None,
481
+ semantic_limit: int | None = None,
482
+ concurrency_limit: int | None = None,
483
+ global_concurrency_limit: int | None = None,
484
+ trust_proxy_headers: bool = True,
485
+ ) -> ASGIApp:
486
+ if request_limit is None:
487
+ request_limit = _int_env("PWC_MCP_REQUEST_LIMIT", DEFAULT_REQUEST_LIMIT)
488
+ if semantic_limit is None:
489
+ semantic_limit = _int_env("PWC_MCP_SEMANTIC_LIMIT", DEFAULT_SEMANTIC_LIMIT)
490
+ if concurrency_limit is None:
491
+ concurrency_limit = _int_env(
492
+ "PWC_MCP_CONCURRENCY_LIMIT", DEFAULT_CONCURRENCY_LIMIT
493
+ )
494
+ if global_concurrency_limit is None:
495
+ global_concurrency_limit = _int_env(
496
+ "PWC_MCP_GLOBAL_CONCURRENCY_LIMIT", DEFAULT_GLOBAL_CONCURRENCY_LIMIT
497
+ )
498
+ hosts = allowed_hosts or _csv_env(
499
+ "PWC_MCP_ALLOWED_HOSTS",
500
+ [
501
+ "huggingface-paperswithcode-mcp.hf.space",
502
+ "localhost:*",
503
+ "127.0.0.1:*",
504
+ ],
505
+ )
506
+ origins = allowed_origins or _csv_env(
507
+ "PWC_MCP_ALLOWED_ORIGINS",
508
+ ["https://chatgpt.com", "https://claude.ai"],
509
+ )
510
+ if "*" in origins:
511
+ raise ValueError("PWC_MCP_ALLOWED_ORIGINS must not contain a wildcard")
512
+ catalog_client = CatalogClient() if catalog is None else None
513
+ resolved_catalog = catalog or catalog_client
514
+ assert resolved_catalog is not None
515
+ server = build_server(resolved_catalog)
516
+ app = server.streamable_http_app(
517
+ streamable_http_path="/mcp",
518
+ json_response=True,
519
+ stateless_http=True,
520
+ max_request_body_size=MAX_REQUEST_BODY_SIZE,
521
+ host="0.0.0.0",
522
+ transport_security=TransportSecuritySettings(
523
+ enable_dns_rebinding_protection=True,
524
+ allowed_hosts=hosts,
525
+ allowed_origins=origins,
526
+ ),
527
+ )
528
+ app.routes.insert(0, Route("/health", health, methods=["GET"]))
529
+ schema = _docs_schema(server)
530
+
531
+ async def docs(_request: Request) -> JSONResponse:
532
+ return JSONResponse(schema)
533
+
534
+ app.routes.insert(1, Route("/docs", docs, methods=["GET"]))
535
+ app.routes.insert(2, Route("/.well-known/mcp", well_known_mcp, methods=["GET"]))
536
+ app.state.pwc_catalog_readiness = CatalogReadiness(
537
+ catalog_client,
538
+ initially_ready=catalog is not None,
539
+ )
540
+ wrapped: ASGIApp = RateLimitMiddleware(
541
+ app,
542
+ request_limit=request_limit,
543
+ semantic_limit=semantic_limit,
544
+ concurrency_limit=concurrency_limit,
545
+ global_concurrency_limit=global_concurrency_limit,
546
+ trust_proxy_headers=trust_proxy_headers,
547
+ )
548
+ wrapped = MCPMethodMiddleware(wrapped)
549
+ wrapped = ResponseSizeLimitMiddleware(wrapped)
550
+ wrapped = OperationalTelemetryMiddleware(wrapped)
551
+ return CORSMiddleware(
552
+ wrapped,
553
+ allow_origins=origins,
554
+ allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
555
+ allow_headers=[
556
+ "authorization",
557
+ "content-type",
558
+ "mcp-protocol-version",
559
+ "mcp-method",
560
+ "mcp-name",
561
+ "mcp-session-id",
562
+ ],
563
+ expose_headers=["mcp-session-id", "retry-after"],
564
+ )
565
+
566
+
567
+ def main() -> None:
568
+ logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
569
+ uvicorn.run(
570
+ create_app(),
571
+ host=os.environ.get("PWC_MCP_HOST", "127.0.0.1"),
572
+ port=int(os.environ.get("PORT", "7860")),
573
+ proxy_headers=False,
574
+ access_log=False,
575
+ )
576
+
577
+
578
+ if __name__ == "__main__":
579
+ main()
mcp_server/src/pwc_mcp/catalog.py ADDED
@@ -0,0 +1,564 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import threading
5
+ from difflib import SequenceMatcher
6
+ import time
7
+ from collections import OrderedDict
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from typing import Any, Protocol
11
+ from urllib.parse import quote, urlparse
12
+
13
+ from pwc_cli import queries
14
+ from pwc_cli.transport import (
15
+ Client,
16
+ HTTPStatusError,
17
+ Response,
18
+ ResponseError,
19
+ TransportError,
20
+ )
21
+
22
+ PAPER_ID = re.compile(r"(?:\d{4}\.\d{4,5}|[a-z][a-z0-9.-]*/\d{7}|\d+)", re.IGNORECASE)
23
+ ARXIV_VERSION = re.compile(r"v\d+$", re.IGNORECASE)
24
+ CONTENT_VERSION = re.compile(r"[0-9a-f]{64}")
25
+ MARKDOWN_CHUNK_BYTES = 65_536
26
+ MARKDOWN_CACHE_ENTRIES = 256
27
+ MARKDOWN_CACHE_BYTES = 16 * 1024 * 1024
28
+ MARKDOWN_CACHE_SECONDS = 3600
29
+ PUBLIC_PAPER_CACHE_SECONDS = 3600
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class PublicPaperMiss:
34
+ """Why the public-metadata API returned no record.
35
+
36
+ ``identifier``: no public source knows the ID, DOI, or URL. ``title``: no
37
+ arXiv or Crossref paper has exactly that title. ``unsupported``: neither an
38
+ identifier nor a searchable title. ``unavailable``: the lookup failed.
39
+ """
40
+
41
+ reason: str
42
+ # Search and paper listings change quickly; taxonomy is stable; the rest sits
43
+ # between (SPEC.md: one minute, ten minutes, and five minutes respectively).
44
+ VOLATILE_PATHS = frozenset(
45
+ {"papers/", "papers/search", "papers/recent", "papers/trending"}
46
+ )
47
+ TAXONOMY_PREFIXES = (
48
+ "tasks/",
49
+ "methods/",
50
+ "areas/",
51
+ "conferences/",
52
+ "organizations/",
53
+ "frameworks/",
54
+ )
55
+
56
+
57
+ def cache_ttl(path: str) -> int:
58
+ if path in VOLATILE_PATHS:
59
+ return 60
60
+ if path.startswith(TAXONOMY_PREFIXES):
61
+ return 600
62
+ return 300
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class PaperMarkdownChunk:
67
+ paper: str
68
+ source: str
69
+ markdown: str
70
+ content_version: str
71
+ next_offset: int | None
72
+
73
+ @property
74
+ def truncated(self) -> bool:
75
+ return self.next_offset is not None
76
+
77
+
78
+ class PaperVersionMismatchError(ResponseError):
79
+ pass
80
+
81
+
82
+ class Transport(Protocol):
83
+ def get(
84
+ self, path: str, params: dict[str, object | None] | None = None
85
+ ) -> Response: ...
86
+
87
+
88
+ def _freeze(value: object) -> object:
89
+ if isinstance(value, dict):
90
+ return tuple(sorted((key, _freeze(item)) for key, item in value.items()))
91
+ if isinstance(value, (list, tuple)):
92
+ return tuple(_freeze(item) for item in value)
93
+ return value
94
+
95
+
96
+ def normalize_title(value: object) -> str:
97
+ return " ".join(re.findall(r"[a-z0-9]+", str(value or "").casefold()))
98
+
99
+
100
+ def short_name(title: object) -> str:
101
+ """The name before a title's colon: "MuseTalk" for "MuseTalk: Real-Time ..."."""
102
+ text = str(title or "")
103
+ return normalize_title(text.split(":", 1)[0]) if ":" in text else ""
104
+
105
+
106
+ # A near match tolerates a typo or variant spelling in a full title
107
+ # ("Heterogeneous" for BEIR's "Heterogenous"), never a short name.
108
+ NEAR_TITLE_MIN_CHARS = 24
109
+ NEAR_TITLE_MIN_RATIO = 0.95
110
+
111
+
112
+ def near_title(title: str, target: str) -> bool:
113
+ """Whether two normalized titles differ only by a small spelling change.
114
+
115
+ Numbers must agree, so a sequel or another year's edition never matches.
116
+ """
117
+ if len(target) < NEAR_TITLE_MIN_CHARS or title == target:
118
+ return False
119
+ if re.findall(r"\d+", title) != re.findall(r"\d+", target):
120
+ return False
121
+ return SequenceMatcher(None, title, target).ratio() >= NEAR_TITLE_MIN_RATIO
122
+
123
+ class _TTLCache:
124
+ def __init__(self, maximum: int = 256):
125
+ self.maximum = maximum
126
+ self._values: OrderedDict[object, tuple[float, object]] = OrderedDict()
127
+ self._lock = threading.Lock()
128
+
129
+ def get(self, key: object) -> object | None:
130
+ now = time.monotonic()
131
+ with self._lock:
132
+ cached = self._values.get(key)
133
+ if cached is None:
134
+ return None
135
+ expires_at, value = cached
136
+ if expires_at <= now:
137
+ del self._values[key]
138
+ return None
139
+ self._values.move_to_end(key)
140
+ return value
141
+
142
+ def put(self, key: object, value: object, ttl_seconds: int) -> None:
143
+ with self._lock:
144
+ self._values[key] = (time.monotonic() + ttl_seconds, value)
145
+ self._values.move_to_end(key)
146
+ while len(self._values) > self.maximum:
147
+ self._values.popitem(last=False)
148
+
149
+
150
+ class _MarkdownChunkCache:
151
+ def __init__(self):
152
+ self._values: OrderedDict[object, tuple[float, int, PaperMarkdownChunk]] = (
153
+ OrderedDict()
154
+ )
155
+ self._bytes = 0
156
+ self._lock = threading.Lock()
157
+
158
+ def get(self, key: object) -> PaperMarkdownChunk | None:
159
+ now = time.monotonic()
160
+ with self._lock:
161
+ cached = self._values.get(key)
162
+ if cached is None:
163
+ return None
164
+ expires_at, size, value = cached
165
+ if expires_at <= now:
166
+ self._bytes -= size
167
+ del self._values[key]
168
+ return None
169
+ self._values.move_to_end(key)
170
+ return value
171
+
172
+ def put(self, key: object, value: PaperMarkdownChunk) -> None:
173
+ size = len(value.markdown.encode("utf-8"))
174
+ if size > MARKDOWN_CACHE_BYTES:
175
+ return
176
+ with self._lock:
177
+ previous = self._values.pop(key, None)
178
+ if previous is not None:
179
+ self._bytes -= previous[1]
180
+ self._values[key] = (
181
+ time.monotonic() + MARKDOWN_CACHE_SECONDS,
182
+ size,
183
+ value,
184
+ )
185
+ self._bytes += size
186
+ while (
187
+ len(self._values) > MARKDOWN_CACHE_ENTRIES
188
+ or self._bytes > MARKDOWN_CACHE_BYTES
189
+ ):
190
+ _, (_, removed_size, _) = self._values.popitem(last=False)
191
+ self._bytes -= removed_size
192
+
193
+
194
+ class CatalogClient:
195
+ """Typed, cached catalog operations shared by every MCP tool."""
196
+
197
+ def __init__(self, transport: Transport | None = None):
198
+ self.transport = transport or Client(timeout=25)
199
+ self.cache = _TTLCache()
200
+ self.markdown_cache = _MarkdownChunkCache()
201
+
202
+ def _response(
203
+ self,
204
+ path: str,
205
+ params: Mapping[str, object | None] | None = None,
206
+ *,
207
+ ttl: int,
208
+ ) -> Response:
209
+ key = ("response", path, _freeze(dict(params or {})))
210
+ cached = self.cache.get(key)
211
+ if isinstance(cached, Response):
212
+ return cached
213
+ response = self.transport.get(path, dict(params or {}))
214
+ self.cache.put(key, response, ttl)
215
+ return response
216
+
217
+ def _json(
218
+ self,
219
+ path: str,
220
+ params: dict[str, object | None] | None = None,
221
+ *,
222
+ ttl: int,
223
+ ) -> dict[str, Any]:
224
+ payload = self._response(path, params, ttl=ttl).json()
225
+ if not isinstance(payload, dict):
226
+ raise ResponseError("API returned an unexpected response shape")
227
+ return payload
228
+
229
+ def check_readiness(self) -> bool:
230
+ response = self.transport.get("tasks/", {"page": 1, "page_size": 1})
231
+ payload = response.json()
232
+ if not isinstance(payload, dict):
233
+ return False
234
+ rows = payload.get("results")
235
+ if rows is None:
236
+ rows = payload.get("items")
237
+ return isinstance(rows, list)
238
+
239
+ def _text(self, path: str, *, ttl: int) -> str:
240
+ key = ("text", path)
241
+ cached = self.cache.get(key)
242
+ if isinstance(cached, str):
243
+ return cached
244
+ response = self.transport.get(path)
245
+ if response.headers.get("x-pwc-truncated") == "1":
246
+ raise ResponseError(
247
+ "Papers API returned incomplete Markdown; continuation is unavailable"
248
+ )
249
+ value = response.body.decode("utf-8", errors="replace")
250
+ self.cache.put(key, value, ttl)
251
+ return value
252
+
253
+ @staticmethod
254
+ def _rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
255
+ values = payload.get("results")
256
+ if values is None:
257
+ values = payload.get("items")
258
+ # An empty list is a valid (empty) result, not a missing list.
259
+ if not isinstance(values, list):
260
+ raise ResponseError("API response did not contain a result list")
261
+ return [item for item in values if isinstance(item, dict)]
262
+
263
+ @staticmethod
264
+ def _paper_from_url(reference: str) -> str | None:
265
+ parsed = urlparse(reference)
266
+ if parsed.scheme not in {"http", "https"}:
267
+ return None
268
+ parts = [part for part in parsed.path.split("/") if part]
269
+ candidate = None
270
+ if (
271
+ parsed.netloc.casefold() in {"arxiv.org", "www.arxiv.org"}
272
+ and len(parts) >= 2
273
+ ):
274
+ if parts[0] in {"abs", "pdf"}:
275
+ candidate = "/".join(parts[1:])
276
+ elif parsed.netloc.casefold() == "huggingface.co" and len(parts) >= 2:
277
+ if parts[0] == "papers":
278
+ candidate = parts[1]
279
+ elif (
280
+ parsed.netloc.casefold()
281
+ in {
282
+ "paperswithcode.co",
283
+ "www.paperswithcode.co",
284
+ }
285
+ and len(parts) >= 2
286
+ and parts[0] == "paper"
287
+ ):
288
+ candidate = parts[1]
289
+ if candidate is None:
290
+ return None
291
+ candidate = candidate.removesuffix(".pdf")
292
+ return ARXIV_VERSION.sub("", candidate)
293
+
294
+ def _resolve_paper(self, reference: str) -> str:
295
+ candidate = reference.strip()
296
+ if not candidate:
297
+ raise ResponseError("Paper reference cannot be empty")
298
+ from_url = self._paper_from_url(candidate)
299
+ slug_from_url = None
300
+ if from_url:
301
+ candidate = from_url
302
+ parsed = urlparse(reference)
303
+ if parsed.netloc.casefold() in {
304
+ "paperswithcode.co",
305
+ "www.paperswithcode.co",
306
+ }:
307
+ slug_from_url = candidate.casefold()
308
+ elif urlparse(candidate).scheme in {"http", "https"}:
309
+ # A DOI, publisher, or venue URL is never an exact title; searching
310
+ # it would page through empty results before failing anyway.
311
+ raise ResponseError(
312
+ f"Paper URL not supported: {candidate}; only arXiv, Hugging Face, "
313
+ "and Papers With Code URLs resolve"
314
+ )
315
+ candidate = ARXIV_VERSION.sub("", candidate)
316
+ if PAPER_ID.fullmatch(candidate):
317
+ if candidate.isdigit():
318
+ return self._canonical_paper_id(candidate)
319
+ return candidate
320
+ query = candidate.replace("-", " ") if slug_from_url else candidate
321
+ target = " ".join(candidate.split()).casefold()
322
+ # The keyword search treats a quoted query as one phrase, so an exact
323
+ # title comes back in a single small page even when its words are
324
+ # common ("Attention Is All You Need" has over a thousand keyword
325
+ # hits). The plain query remains the fallback for titles the phrase
326
+ # parser cannot express.
327
+ phrase = '"' + " ".join(query.replace('"', " ").split()) + '"'
328
+ exact, named, near, exhausted = self._exact_title_matches(
329
+ phrase, target, slug_from_url
330
+ )
331
+ if not exact:
332
+ exact, more_named, more_near, exhausted = self._exact_title_matches(
333
+ query, target, slug_from_url
334
+ )
335
+ named = named or more_named
336
+ near = near or more_near
337
+ if not exact and not slug_from_url:
338
+ # Agents name a paper by the short name before its title's colon
339
+ # ("MuseTalk" for "MuseTalk: Real-Time ..."), or misspell one word
340
+ # of a full title; a unique such match resolves.
341
+ exact = named or near
342
+ if len(exact) == 1:
343
+ return next(iter(exact))
344
+ if exact:
345
+ choices = "; ".join(
346
+ f"{item.get('title')} ({paper})" for paper, item in exact.items()
347
+ )
348
+ raise ResponseError(f"Paper title is ambiguous: {candidate}; {choices}")
349
+ if not exhausted:
350
+ raise ResponseError("Too many results to resolve paper title safely")
351
+ raise ResponseError(f"Paper title not found: {candidate}")
352
+
353
+ def _exact_title_matches(
354
+ self, query: str, target: str, slug_from_url: str | None
355
+ ) -> tuple[
356
+ dict[str, dict[str, Any]], dict[str, dict[str, Any]], dict[str, dict[str, Any]], bool
357
+ ]:
358
+ """Collect papers whose title (or slug) equals the target.
359
+
360
+ Returns the matches, the papers whose short name before a title colon
361
+ equals the target, the papers whose title is a near match (see
362
+ `near_title`), and whether the search was read to its end within the
363
+ page budget; an unexhausted search with no match is inconclusive.
364
+ """
365
+ exact: dict[str, dict[str, Any]] = {}
366
+ named: dict[str, dict[str, Any]] = {}
367
+ near: dict[str, dict[str, Any]] = {}
368
+ target_name = normalize_title(target)
369
+ page = 1
370
+ while page <= 10:
371
+ payload = self._json(
372
+ "papers/search",
373
+ {"q": query, "page": page, "page_size": 100, "mode": "keyword"},
374
+ ttl=60,
375
+ )
376
+ for item in self._rows(payload):
377
+ paper = str(item.get("arxiv_id") or item.get("id") or "")
378
+ title = " ".join(str(item.get("title") or "").split()).casefold()
379
+ item_slug = str(item.get("slug") or "").casefold()
380
+ if not item_slug:
381
+ item_slug = re.sub(r"[^a-z0-9]+", "-", title).strip("-")
382
+ if paper and (
383
+ title == target or (slug_from_url and item_slug == slug_from_url)
384
+ ):
385
+ exact.setdefault(paper, item)
386
+ elif paper and target_name and short_name(item.get("title")) == target_name:
387
+ named.setdefault(paper, item)
388
+ elif paper and near_title(normalize_title(title), target_name):
389
+ near.setdefault(paper, item)
390
+ next_page = payload.get("next_page")
391
+ if not isinstance(next_page, int) or next_page <= page:
392
+ return exact, named, near, True
393
+ page = next_page
394
+ return exact, named, near, False
395
+
396
+ def _canonical_paper_id(self, catalog_id: str) -> str:
397
+ """Prefer the arXiv ID for a numeric catalog ID so every route accepts it.
398
+
399
+ The Markdown read route stores arXiv papers under their arXiv ID and
400
+ only external papers under the numeric ID; list tools hand out numeric
401
+ IDs for both, so a bare numeric reference cannot tell them apart.
402
+ """
403
+ path = f"papers/{quote(catalog_id, safe='')}"
404
+ record = self._json(path, ttl=cache_ttl(path))
405
+ arxiv_id = record.get("arxiv_id")
406
+ if isinstance(arxiv_id, str) and arxiv_id.strip():
407
+ return ARXIV_VERSION.sub("", arxiv_id.strip())
408
+ return catalog_id
409
+
410
+ def _paper_exists(self, reference: str) -> bool:
411
+ path = f"papers/{quote(reference, safe='.')}"
412
+ try:
413
+ self._json(path, ttl=cache_ttl(path))
414
+ except HTTPStatusError as error:
415
+ if error.status == 404:
416
+ return False
417
+ raise
418
+ return True
419
+
420
+ def resolve_paper(self, paper: str) -> str:
421
+ return self._resolve_paper(paper)
422
+
423
+ def public_paper(self, reference: str) -> dict[str, Any] | PublicPaperMiss:
424
+ """The public-metadata API's record for a catalog miss, or why none.
425
+
426
+ The API resolves an arXiv ID or URL, Hugging Face paper URL, DOI, or
427
+ catalog ID against the catalog first, then Hugging Face Papers, arXiv,
428
+ or Crossref. It rejects anything else (400), which is then looked up
429
+ as an exact title on arXiv and Crossref; a record found that way
430
+ carries ``requested_title``. Lookups are best effort: a failure is an
431
+ ``unavailable`` miss, and only definitive misses are cached.
432
+ """
433
+ reference = reference.strip()
434
+ key = ("public-paper", reference)
435
+ cached = self.cache.get(key)
436
+ if isinstance(cached, (dict, PublicPaperMiss)):
437
+ return cached
438
+ # The route takes the reference as a path, slashes included (DOIs, URLs).
439
+ result = self._public_metadata(
440
+ f"papers/{quote(reference, safe='/:')}/public-metadata",
441
+ None,
442
+ PublicPaperMiss("identifier"),
443
+ )
444
+ if result == PublicPaperMiss("unsupported"):
445
+ result = self._public_metadata(
446
+ "papers/public-metadata/search",
447
+ {"title": reference},
448
+ PublicPaperMiss("title"),
449
+ )
450
+ if isinstance(result, dict):
451
+ result = {**result, "requested_title": reference}
452
+ if result != PublicPaperMiss("unavailable"):
453
+ self.cache.put(key, result, PUBLIC_PAPER_CACHE_SECONDS)
454
+ return result
455
+
456
+ def _public_metadata(
457
+ self, path: str, params: dict[str, Any] | None, not_found: PublicPaperMiss
458
+ ) -> dict[str, Any] | PublicPaperMiss:
459
+ try:
460
+ value = self.transport.get(path, params).json()
461
+ except HTTPStatusError as error:
462
+ if error.status == 404:
463
+ return not_found
464
+ # 429 and 503 are transient.
465
+ return PublicPaperMiss("unsupported" if error.status == 400 else "unavailable")
466
+ except (ResponseError, TransportError):
467
+ return PublicPaperMiss("unavailable")
468
+ if not isinstance(value, dict) or not value.get("title"):
469
+ return PublicPaperMiss("unavailable")
470
+ return value
471
+
472
+ def read_paper_chunk(
473
+ self,
474
+ paper: str,
475
+ *,
476
+ offset: int = 0,
477
+ content_version: str | None = None,
478
+ limit: int = MARKDOWN_CHUNK_BYTES,
479
+ resolved: bool = False,
480
+ ) -> PaperMarkdownChunk:
481
+ reference = paper if resolved else self._resolve_paper(paper)
482
+ source = "external" if reference.isdigit() else "arxiv"
483
+ cache_key = (reference, content_version, offset, limit)
484
+ if content_version is not None:
485
+ cached = self.markdown_cache.get(cache_key)
486
+ if cached is not None:
487
+ return cached
488
+ try:
489
+ response = self.transport.get(
490
+ f"research/papers/{quote(reference, safe='.')}/read",
491
+ {
492
+ "offset": offset,
493
+ "limit": limit,
494
+ "content_version": content_version,
495
+ },
496
+ )
497
+ except HTTPStatusError as error:
498
+ if error.status == 409:
499
+ raise PaperVersionMismatchError(
500
+ "Paper Markdown changed; restart reading from the beginning"
501
+ ) from error
502
+ if error.status == 404 and not self._paper_exists(reference):
503
+ raise ResponseError(f"Paper not found: {reference}") from error
504
+ raise
505
+
506
+ returned_version = response.headers.get("x-pwc-content-version", "")
507
+ truncated = response.headers.get("x-pwc-truncated")
508
+ next_text = response.headers.get("x-pwc-next-offset")
509
+ try:
510
+ markdown = response.body.decode("utf-8")
511
+ next_offset = int(next_text) if next_text is not None else None
512
+ except (UnicodeDecodeError, ValueError) as error:
513
+ raise ResponseError(
514
+ "Papers API returned an invalid Markdown chunk"
515
+ ) from error
516
+ if (
517
+ CONTENT_VERSION.fullmatch(returned_version) is None
518
+ or truncated not in {"0", "1"}
519
+ or (content_version is not None and returned_version != content_version)
520
+ or (truncated == "1" and next_offset is None)
521
+ or (truncated == "0" and next_offset is not None)
522
+ or (next_offset is not None and next_offset <= offset)
523
+ or (next_offset is not None and next_offset - offset != len(response.body))
524
+ ):
525
+ detail = (
526
+ "Papers API Markdown offset did not advance"
527
+ if next_offset is not None and next_offset <= offset
528
+ else "Papers API returned an invalid Markdown chunk"
529
+ )
530
+ raise ResponseError(detail)
531
+ result = PaperMarkdownChunk(
532
+ paper=reference,
533
+ source=source,
534
+ markdown=markdown,
535
+ content_version=returned_version,
536
+ next_offset=next_offset,
537
+ )
538
+ self.markdown_cache.put((reference, returned_version, offset, limit), result)
539
+ return result
540
+
541
+ def query(self, command: tuple[str, ...], options: Mapping[str, Any]) -> Any:
542
+ """Run one read-only ``pwc`` command in-process against the cached catalog.
543
+
544
+ Paper references are resolved first so URLs, legacy IDs, and exact
545
+ titles behave exactly as they do for ``read_paper``; the CLI then
546
+ receives the canonical identifier and applies its own validation,
547
+ fail-closed filter checks, and JSON payload shape.
548
+ """
549
+ resolved = dict(options)
550
+ if resolved.get("paper") is not None:
551
+ resolved["paper"] = self._resolve_paper(str(resolved["paper"]))
552
+ return queries.query(tuple(command), resolved, _CachedTransport(self))
553
+
554
+
555
+ class _CachedTransport:
556
+ """``pwc_cli.transport.Client`` stand-in that serves CLI handlers from the cache."""
557
+
558
+ def __init__(self, catalog: CatalogClient):
559
+ self.catalog = catalog
560
+
561
+ def get(
562
+ self, path: str, params: Mapping[str, object | None] | None = None
563
+ ) -> Response:
564
+ return self.catalog._response(path, params, ttl=cache_ttl(path))
mcp_server/src/pwc_mcp/cursors.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import re
9
+ import time
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+
13
+ CURSOR_SCHEMA_VERSION = 1
14
+ CURSOR_LIFETIME_SECONDS = 3600
15
+ MAX_CURSOR_BYTES = 2048
16
+ MAX_CHUNK_BYTES = 65_536
17
+ _CONTENT_VERSION = re.compile(r"[0-9a-f]{64}")
18
+ _PAPER_ID = re.compile(r"(?:\d{4}\.\d{4,5}|\d{1,20})")
19
+
20
+
21
+ class CursorReferenceMismatch(ValueError):
22
+ """A valid cursor whose paper reference differs from the one supplied.
23
+
24
+ The caller may still honour it when the new reference resolves to the
25
+ cursor's paper (an agent that started from an arXiv ID and continues with
26
+ the numeric catalog ID, say).
27
+ """
28
+
29
+ def __init__(self, state: CursorState) -> None:
30
+ super().__init__(
31
+ "invalid continuation cursor: it belongs to another paper reference"
32
+ )
33
+ self.state = state
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class CursorState:
38
+ reference: str
39
+ paper: str
40
+ source: str
41
+ content_version: str
42
+ offset: int
43
+ limit: int
44
+ expires_at: int
45
+
46
+
47
+ def _b64encode(value: bytes) -> str:
48
+ return base64.urlsafe_b64encode(value).decode().rstrip("=")
49
+
50
+
51
+ def _b64decode(value: str) -> bytes:
52
+ return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
53
+
54
+
55
+ def _key_id(secret: bytes) -> str:
56
+ return hashlib.sha256(secret).hexdigest()[:16]
57
+
58
+
59
+ class CursorCodec:
60
+ def __init__(
61
+ self,
62
+ current_secret: str,
63
+ *,
64
+ previous_secret: str | None = None,
65
+ now: Callable[[], float] = time.time,
66
+ ) -> None:
67
+ if not current_secret:
68
+ raise ValueError("current cursor secret is required")
69
+ current = current_secret.encode()
70
+ self._current_id = _key_id(current)
71
+ self._keys = {self._current_id: current}
72
+ if previous_secret:
73
+ previous = previous_secret.encode()
74
+ self._keys[_key_id(previous)] = previous
75
+ self._now = now
76
+
77
+ def encode(self, state: CursorState) -> str:
78
+ now = int(self._now())
79
+ if not self._valid_state(state) or not (
80
+ now < state.expires_at <= now + CURSOR_LIFETIME_SECONDS
81
+ ):
82
+ raise ValueError("invalid continuation cursor")
83
+ payload = {
84
+ "v": CURSOR_SCHEMA_VERSION,
85
+ "ref": state.reference,
86
+ "paper": state.paper,
87
+ "source": state.source,
88
+ "version": state.content_version,
89
+ "offset": state.offset,
90
+ "limit": state.limit,
91
+ "kid": self._current_id,
92
+ "exp": state.expires_at,
93
+ }
94
+ encoded = _b64encode(
95
+ json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
96
+ )
97
+ signature = hmac.digest(self._keys[self._current_id], encoded.encode(), "sha256")
98
+ token = f"{encoded}.{_b64encode(signature)}"
99
+ if len(token) > MAX_CURSOR_BYTES:
100
+ raise ValueError("invalid continuation cursor")
101
+ return token
102
+
103
+ def decode(self, token: str, *, reference: str) -> CursorState:
104
+ try:
105
+ if not token or len(token) > MAX_CURSOR_BYTES:
106
+ raise ValueError
107
+ encoded, signature_text = token.split(".", 1)
108
+ raw = _b64decode(encoded)
109
+ if len(raw) > MAX_CURSOR_BYTES:
110
+ raise ValueError
111
+ payload = json.loads(raw)
112
+ if not isinstance(payload, dict):
113
+ raise TypeError
114
+ key_id = payload.get("kid")
115
+ if not isinstance(key_id, str):
116
+ raise TypeError
117
+ secret = self._keys.get(key_id)
118
+ if secret is None:
119
+ raise ValueError
120
+ supplied = _b64decode(signature_text)
121
+ expected = hmac.digest(secret, encoded.encode(), "sha256")
122
+ if not hmac.compare_digest(supplied, expected):
123
+ raise ValueError
124
+ state = CursorState(
125
+ reference=payload["ref"],
126
+ paper=payload["paper"],
127
+ source=payload["source"],
128
+ content_version=payload["version"],
129
+ offset=payload["offset"],
130
+ limit=payload["limit"],
131
+ expires_at=payload["exp"],
132
+ )
133
+ if (
134
+ payload.get("v") != CURSOR_SCHEMA_VERSION
135
+ or set(payload)
136
+ != {
137
+ "v",
138
+ "ref",
139
+ "paper",
140
+ "source",
141
+ "version",
142
+ "offset",
143
+ "limit",
144
+ "kid",
145
+ "exp",
146
+ }
147
+ or not self._valid_state(state)
148
+ ):
149
+ raise ValueError
150
+ if state.expires_at <= int(self._now()):
151
+ raise TimeoutError
152
+ if state.reference != reference.strip():
153
+ raise CursorReferenceMismatch(state)
154
+ return state
155
+ except TimeoutError as error:
156
+ raise ValueError("expired continuation cursor") from error
157
+ except CursorReferenceMismatch:
158
+ raise
159
+ except (
160
+ ValueError,
161
+ TypeError,
162
+ KeyError,
163
+ json.JSONDecodeError,
164
+ UnicodeDecodeError,
165
+ binascii.Error,
166
+ ) as error:
167
+ raise ValueError("invalid continuation cursor") from error
168
+
169
+ @staticmethod
170
+ def _valid_state(state: CursorState) -> bool:
171
+ return (
172
+ isinstance(state.reference, str)
173
+ and 1 <= len(state.reference) <= 500
174
+ and state.reference == state.reference.strip()
175
+ and isinstance(state.paper, str)
176
+ and _PAPER_ID.fullmatch(state.paper) is not None
177
+ and state.source in {"arxiv", "external"}
178
+ and (state.source == "external") == state.paper.isdigit()
179
+ and isinstance(state.content_version, str)
180
+ and _CONTENT_VERSION.fullmatch(state.content_version) is not None
181
+ and isinstance(state.offset, int)
182
+ and not isinstance(state.offset, bool)
183
+ and 1 <= state.offset <= 2**63 - 1
184
+ and isinstance(state.limit, int)
185
+ and not isinstance(state.limit, bool)
186
+ and 1 <= state.limit <= MAX_CHUNK_BYTES
187
+ and isinstance(state.expires_at, int)
188
+ and not isinstance(state.expires_at, bool)
189
+ )
mcp_server/src/pwc_mcp/models.py ADDED
@@ -0,0 +1,585 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+
9
+ class OutputModel(BaseModel):
10
+ model_config = ConfigDict(extra="forbid")
11
+
12
+
13
+ def _absolute_url(value: object) -> str | None:
14
+ if not value:
15
+ return None
16
+ url = str(value)
17
+ return f"https://paperswithcode.co{url}" if url.startswith("/") else url
18
+
19
+
20
+ class QueryResult(OutputModel):
21
+ """The exact ``data`` document that ``pwc <command> --json`` prints."""
22
+
23
+ schema_version: Literal["v1"] = "v1"
24
+ data: Any = Field(
25
+ default=None,
26
+ description="Complete CLI JSON payload for this call; typed fields are projections of it.",
27
+ )
28
+
29
+
30
+ class PaperSummary(OutputModel):
31
+ id: str
32
+ arxiv_id: str | None = None
33
+ title: str
34
+ authors: list[str]
35
+ published: str | None = None
36
+ citation_count: int | None = None
37
+ url: str | None = None
38
+ has_official_implementation: bool = Field(
39
+ description=(
40
+ "True when the catalog marks at least one linked repository as the "
41
+ "paper's official implementation; this is not an independent code audit."
42
+ )
43
+ )
44
+ code_repository_count: int
45
+
46
+
47
+ class PaperLink(OutputModel):
48
+ label: str
49
+ url: str
50
+ kind: str | None = None
51
+
52
+
53
+ AVAILABLE_AT = (
54
+ "Where a paper outside the catalog can be read (abstract page, PDF, Hugging "
55
+ "Face Papers, DOI, code); link to these, not to a Papers With Code paper page."
56
+ )
57
+
58
+
59
+ class PaperPage(QueryResult):
60
+ items: list[PaperSummary]
61
+ next_page: int | None = None
62
+ note: str | None = Field(
63
+ default=None,
64
+ description="Set when the requested paper is not in the catalog and the items are a search fallback.",
65
+ )
66
+ available_at: list[PaperLink] | None = Field(default=None, description=AVAILABLE_AT)
67
+
68
+
69
+ class CatalogReference(OutputModel):
70
+ id: str
71
+ name: str
72
+ slug: str | None = None
73
+
74
+
75
+ class RepositoryReference(OutputModel):
76
+ url: str
77
+ is_official: bool
78
+
79
+
80
+ class XThreadReference(OutputModel):
81
+ url: str
82
+ author: str
83
+ handle: str
84
+
85
+
86
+ class PaperDetail(OutputModel):
87
+ id: str
88
+ arxiv_id: str | None = None
89
+ title: str
90
+ abstract: str | None = None
91
+ authors: list[str]
92
+ published: str | None = None
93
+ citation_count: int | None = None
94
+ url: str | None = None
95
+ pdf_url: str | None = None
96
+ code_repository_count: int
97
+ tasks: list[CatalogReference]
98
+ methods: list[CatalogReference]
99
+ repositories: list[RepositoryReference]
100
+ project_pages: list[str]
101
+ hf_models: list[str] = []
102
+ hf_datasets: list[str] = []
103
+ hf_spaces: list[str] = []
104
+ x_threads: list[XThreadReference] = []
105
+
106
+
107
+ class PaperEvaluation(OutputModel):
108
+ id: str
109
+ benchmark: str | None = None
110
+ task: str | None = None
111
+ model_name: str
112
+ harness: str | None = None
113
+ metrics: dict[str, float | int | str | None]
114
+ best_metric: str | None = None
115
+ best_rank: int | None = None
116
+ is_open: bool
117
+ num_parameters: int | None = None
118
+ source_url: str | None = None
119
+
120
+
121
+ class PaperInfoResult(QueryResult):
122
+ paper: PaperDetail
123
+ evaluation_count: int | None = None
124
+ evaluations: list[PaperEvaluation] | None = None
125
+ in_catalog: bool = Field(
126
+ default=True,
127
+ description="False when the paper is not indexed on Papers With Code; the metadata then comes from public Hugging Face Papers, arXiv, or Crossref records.",
128
+ )
129
+ note: str | None = None
130
+ available_at: list[PaperLink] | None = Field(default=None, description=AVAILABLE_AT)
131
+
132
+
133
+ class PaperReadResult(OutputModel):
134
+ schema_version: Literal["v1"] = "v1"
135
+ paper: str
136
+ markdown: str
137
+ truncated: bool
138
+ next_cursor: str | None = None
139
+ in_catalog: bool = Field(
140
+ default=True,
141
+ description="False when the paper is not indexed on Papers With Code; markdown then holds public metadata and the abstract, not the full text.",
142
+ )
143
+ available_at: list[PaperLink] | None = Field(default=None, description=AVAILABLE_AT)
144
+
145
+
146
+ class PaperReference(OutputModel):
147
+ id: str
148
+ reference: str | None = None
149
+ title: str
150
+
151
+
152
+ class PaperLineageResult(QueryResult):
153
+ paper: PaperReference
154
+ predecessors: list[PaperReference]
155
+ successors: list[PaperReference]
156
+ coverage: Literal["explicit_catalog_links_only"] = "explicit_catalog_links_only"
157
+ coverage_note: str = (
158
+ "Only explicit catalog relationships are returned; an empty list does not "
159
+ "prove that no predecessor or successor exists."
160
+ )
161
+
162
+
163
+ class AreaReference(OutputModel):
164
+ id: str
165
+ name: str
166
+
167
+
168
+ class BenchmarkSummary(OutputModel):
169
+ id: str
170
+ name: str
171
+ slug: str | None = None
172
+ full_name: str | None = None
173
+ description: str | None = None
174
+ split: str | None = None
175
+ hf_url: str | None = None
176
+ paper_count: int
177
+
178
+
179
+ class TaskDetail(OutputModel):
180
+ id: str
181
+ name: str
182
+ slug: str
183
+ description: str | None = None
184
+ paper_count: int
185
+ area: AreaReference | None = None
186
+ parents: list[CatalogReference]
187
+ children: list[CatalogReference]
188
+ benchmarks: list[BenchmarkSummary]
189
+
190
+
191
+ class TaskResult(QueryResult):
192
+ task: TaskDetail
193
+
194
+
195
+ class MethodDetail(OutputModel):
196
+ id: str
197
+ name: str
198
+ slug: str
199
+ full_name: str | None = None
200
+ description: str | None = None
201
+ introduced_year: int | None = None
202
+ source_paper_id: str | None = None
203
+ source_url: str | None = None
204
+ source_title: str | None = None
205
+ paper_count: int
206
+
207
+
208
+ class MethodResult(QueryResult):
209
+ method: MethodDetail
210
+
211
+
212
+ class BenchmarkPage(QueryResult):
213
+ items: list[BenchmarkSummary]
214
+ next_page: int | None = None
215
+
216
+
217
+ class EvaluationRankScope(OutputModel):
218
+ task_id: str | None = None
219
+ task_name: str | None = None
220
+ task_slug: str | None = None
221
+ rank: int | None = None
222
+
223
+
224
+ class Evaluation(OutputModel):
225
+ id: str
226
+ model_name: str
227
+ harness: str | None = None
228
+ metrics: dict[str, float | int | str | None]
229
+ best_metric: str | None = None
230
+ best_rank: int | None = None
231
+ task: str | None = None
232
+ paper_id: str | None = None
233
+ paper_title: str | None = None
234
+ paper_arxiv_id: str | None = None
235
+ paper_published: str | None = None
236
+ rank_scopes: list[EvaluationRankScope] = Field(default_factory=list)
237
+ is_open: bool | None = Field(
238
+ description=(
239
+ "Catalog openness flag for the evaluated implementation: true=open, "
240
+ "false=closed, null=not recorded."
241
+ )
242
+ )
243
+ num_parameters: int | None = None
244
+ split: str | None = None
245
+ shots: int | None = None
246
+ evaluation_protocol: str | None = None
247
+ source_url: str | None = None
248
+ code_url: str | None = None
249
+ hf_model_url: str | None = None
250
+ updated_at: str | None = None
251
+ uses_additional_data: bool | None = None
252
+
253
+
254
+ class BenchmarkResult(QueryResult):
255
+ benchmark: BenchmarkSummary
256
+ evaluation_count: int
257
+ matched_count: int | None = None
258
+ evaluations: list[Evaluation]
259
+ metric_directions: dict[str, Literal["higher", "lower", "unknown"]]
260
+ page: int
261
+ next_page: int | None = None
262
+ metric_note: str | None = Field(
263
+ default=None,
264
+ description="Set when a requested metric is not on this leaderboard; the rows are then unfiltered and the note lists the available metrics.",
265
+ )
266
+ ranking_note: str = (
267
+ "Ranks are scoped by task and are not necessarily comparable across "
268
+ "rank_scopes."
269
+ )
270
+
271
+
272
+ class EvaluationPage(QueryResult):
273
+ paper: str
274
+ evaluation_count: int
275
+ page: int
276
+ next_page: int | None = None
277
+ evaluations: list[Evaluation]
278
+
279
+
280
+ def _text(value: Any) -> str | None:
281
+ return str(value) if value not in (None, "") else None
282
+
283
+
284
+ def _int(value: Any) -> int | None:
285
+ if isinstance(value, bool) or value is None:
286
+ return None
287
+ try:
288
+ return int(value)
289
+ except (TypeError, ValueError):
290
+ return None
291
+
292
+
293
+ def _urls(values: Any) -> list[str]:
294
+ urls = []
295
+ for value in values or []:
296
+ url = value.get("url") if isinstance(value, dict) else value
297
+ if url:
298
+ urls.append(str(url))
299
+ return urls
300
+
301
+
302
+ def paper_summary(item: dict[str, Any]) -> PaperSummary:
303
+ return PaperSummary(
304
+ id=str(item.get("id") or ""),
305
+ arxiv_id=_text(item.get("arxiv_id")),
306
+ title=str(item.get("title") or "Untitled paper"),
307
+ authors=[str(author) for author in item.get("authors") or []],
308
+ published=_text(item.get("published") or item.get("date_published")),
309
+ citation_count=_int(item.get("citation_count")),
310
+ url=_text(item.get("url_abs") or item.get("source_url")),
311
+ has_official_implementation=item.get("has_official_implementation") is True,
312
+ code_repository_count=int(item.get("code_repository_count") or 0),
313
+ )
314
+
315
+
316
+ def catalog_reference(item: dict[str, Any]) -> CatalogReference:
317
+ return CatalogReference(
318
+ id=str(item.get("id") or ""),
319
+ name=str(item.get("name") or item.get("slug") or "Unknown"),
320
+ slug=_text(item.get("slug")),
321
+ )
322
+
323
+
324
+ def paper_detail(
325
+ item: dict[str, Any], *, include_resources: bool = False, repo_limit: int = 5
326
+ ) -> PaperDetail:
327
+ repositories = []
328
+ for repository in item.get("repositories") or []:
329
+ if isinstance(repository, dict) and repository.get("url"):
330
+ value = RepositoryReference(
331
+ url=str(repository["url"]),
332
+ is_official=repository.get("is_official") is True,
333
+ )
334
+ if include_resources or value.is_official:
335
+ repositories.append(value)
336
+ repositories.sort(key=lambda repository: not repository.is_official)
337
+ repositories = repositories[:repo_limit]
338
+ return PaperDetail(
339
+ id=str(item.get("id") or ""),
340
+ arxiv_id=_text(item.get("arxiv_id")),
341
+ title=str(item.get("title") or "Untitled paper"),
342
+ abstract=_text(item.get("abstract")),
343
+ authors=[str(author) for author in item.get("authors") or []],
344
+ published=_text(item.get("published")),
345
+ citation_count=_int(item.get("citation_count")),
346
+ url=_text(item.get("url_abs") or item.get("source_url")),
347
+ pdf_url=_text(item.get("url_pdf")),
348
+ code_repository_count=int(item.get("code_repository_count") or 0),
349
+ tasks=[
350
+ catalog_reference(task)
351
+ for task in item.get("tasks") or []
352
+ if isinstance(task, dict)
353
+ ],
354
+ methods=[
355
+ catalog_reference(method)
356
+ for method in item.get("methods") or []
357
+ if isinstance(method, dict)
358
+ ],
359
+ repositories=repositories,
360
+ project_pages=_urls(item.get("project_pages"))[:repo_limit]
361
+ if include_resources
362
+ else [],
363
+ hf_models=_urls(item.get("hf_models"))[:repo_limit]
364
+ if include_resources
365
+ else [],
366
+ hf_datasets=_urls(item.get("hf_datasets"))[:repo_limit]
367
+ if include_resources
368
+ else [],
369
+ hf_spaces=_urls(item.get("hf_spaces"))[:repo_limit]
370
+ if include_resources
371
+ else [],
372
+ x_threads=[
373
+ XThreadReference(
374
+ url=str(thread["url"]),
375
+ author=str(thread.get("author_name") or ""),
376
+ handle=str(thread.get("author_handle") or ""),
377
+ )
378
+ for thread in item.get("x_threads") or []
379
+ if isinstance(thread, dict) and thread.get("url")
380
+ ][:repo_limit]
381
+ if include_resources
382
+ else [],
383
+ )
384
+
385
+
386
+ def _metrics(item: dict[str, Any]) -> dict[str, float | int | str | None]:
387
+ metrics = item.get("metrics")
388
+ if not isinstance(metrics, dict):
389
+ return {}
390
+ return {str(key): value for key, value in metrics.items()}
391
+
392
+
393
+ def paper_evaluation(item: dict[str, Any]) -> PaperEvaluation:
394
+ return PaperEvaluation(
395
+ id=str(item.get("id") or ""),
396
+ benchmark=_text(item.get("dataset_name")),
397
+ task=_text(item.get("task_name")),
398
+ model_name=str(item.get("model_name") or "Unknown model"),
399
+ harness=_text(item.get("harness")),
400
+ metrics=_metrics(item),
401
+ best_metric=_text(item.get("best_metric")),
402
+ best_rank=_int(item.get("best_rank")),
403
+ is_open=item.get("is_open") is not False,
404
+ num_parameters=_int(item.get("num_parameters")),
405
+ source_url=_text(item.get("result_url") or item.get("source_url")),
406
+ )
407
+
408
+
409
+ def paper_reference(item: dict[str, Any]) -> PaperReference:
410
+ reference = item.get("reference") or item.get("arxiv_id")
411
+ return PaperReference(
412
+ id=str(item.get("id") or ""),
413
+ reference=str(reference) if reference else None,
414
+ title=str(item.get("title") or item.get("reference") or "Untitled paper"),
415
+ )
416
+
417
+
418
+ def benchmark_summary(item: dict[str, Any]) -> BenchmarkSummary:
419
+ count = (
420
+ item.get("paper_count")
421
+ if item.get("paper_count") is not None
422
+ else item.get("all_time_paper_count", item.get("evaluation_count"))
423
+ )
424
+ return BenchmarkSummary(
425
+ id=str(item.get("id") or ""),
426
+ name=str(item.get("name") or item.get("slug") or "Unknown benchmark"),
427
+ slug=_text(item.get("slug")),
428
+ full_name=_text(item.get("full_name")),
429
+ description=_text(item.get("description")),
430
+ split=_text(item.get("split_name") or item.get("split")),
431
+ hf_url=_text(item.get("hf_url")),
432
+ paper_count=_int(count) or 0,
433
+ )
434
+
435
+
436
+ def evaluation(item: dict[str, Any]) -> Evaluation:
437
+ return Evaluation(
438
+ id=str(item.get("id") or ""),
439
+ model_name=str(item.get("model_name") or "Unknown model"),
440
+ harness=_text(item.get("harness")),
441
+ metrics={name: _metric_value(value) for name, value in _metrics(item).items()},
442
+ best_metric=_text(item.get("best_metric")),
443
+ best_rank=_int(item.get("best_rank")),
444
+ task=_text(item.get("task_name")),
445
+ paper_id=_text(item.get("paper_id")),
446
+ paper_title=_text(item.get("paper_title")),
447
+ paper_arxiv_id=_text(item.get("paper_arxiv_id")),
448
+ paper_published=_text(item.get("paper_published_date")),
449
+ rank_scopes=_rank_scopes(item),
450
+ is_open=(
451
+ item.get("is_open") if isinstance(item.get("is_open"), bool) else None
452
+ ),
453
+ num_parameters=_int(item.get("num_parameters")),
454
+ split=_text(
455
+ item.get("split") or item.get("split_name") or item.get("evaluated_on")
456
+ ),
457
+ shots=_shot_count(item.get("methodology")),
458
+ evaluation_protocol=_text(item.get("methodology")),
459
+ source_url=_absolute_url(
460
+ item.get("result_url")
461
+ or item.get("source_url")
462
+ or item.get("external_source_url")
463
+ ),
464
+ code_url=_absolute_url(item.get("code_url")),
465
+ hf_model_url=_absolute_url(item.get("hf_model_url")),
466
+ updated_at=_text(item.get("updated_at")),
467
+ uses_additional_data=(
468
+ item.get("uses_additional_data")
469
+ if isinstance(item.get("uses_additional_data"), bool)
470
+ else None
471
+ ),
472
+ )
473
+
474
+
475
+ def _rank_scopes(item: dict[str, Any]) -> list[EvaluationRankScope]:
476
+ existing = item.get("rank_scopes")
477
+ if isinstance(existing, list):
478
+ return [
479
+ EvaluationRankScope.model_validate(scope)
480
+ for scope in existing
481
+ if isinstance(scope, dict)
482
+ ]
483
+ if not any(
484
+ item.get(field) is not None for field in ("task_id", "task_name", "best_rank")
485
+ ):
486
+ return []
487
+ return [
488
+ EvaluationRankScope(
489
+ task_id=_text(item.get("task_id")),
490
+ task_name=_text(item.get("task_name")),
491
+ task_slug=_text(item.get("task_slug")),
492
+ rank=_int(item.get("best_rank")),
493
+ )
494
+ ]
495
+
496
+
497
+ def _shot_count(methodology: object) -> int | None:
498
+ match = re.search(r"\b(\d+)\s*[- ]?shot\b", str(methodology), re.IGNORECASE)
499
+ return int(match.group(1)) if match else None
500
+
501
+
502
+ def _metric_value(value: Any) -> float | int | str | None:
503
+ if value is None or isinstance(value, (int, float)) and not isinstance(value, bool):
504
+ return value
505
+ if isinstance(value, str):
506
+ try:
507
+ return float(value)
508
+ except ValueError:
509
+ return value
510
+ return str(value)
511
+
512
+
513
+ def metric_directions(
514
+ evaluations: list[Evaluation],
515
+ ) -> dict[str, Literal["higher", "lower", "unknown"]]:
516
+ lower = {
517
+ "error",
518
+ "loss",
519
+ "perplexity",
520
+ "latency",
521
+ "runtime",
522
+ "wer",
523
+ "cer",
524
+ "eer",
525
+ "fid",
526
+ "mae",
527
+ "rmse",
528
+ }
529
+ higher = {
530
+ "accuracy",
531
+ "precision",
532
+ "recall",
533
+ "f1",
534
+ "bleu",
535
+ "rouge",
536
+ "map",
537
+ "auc",
538
+ "score",
539
+ "em",
540
+ }
541
+ result: dict[str, Literal["higher", "lower", "unknown"]] = {}
542
+ for name in sorted({key for row in evaluations for key in row.metrics}):
543
+ words = set(re.sub(r"[^a-z0-9]+", " ", name.casefold()).split())
544
+ if words & lower:
545
+ result[name] = "lower"
546
+ elif words & higher:
547
+ result[name] = "higher"
548
+ else:
549
+ result[name] = "unknown"
550
+ return result
551
+
552
+
553
+ def public_paper_detail(item: dict[str, Any], *, repo_limit: int = 5) -> PaperDetail:
554
+ """Project public metadata for a paper outside the catalog.
555
+
556
+ Linked repositories come from Hugging Face Papers or the abstract, not
557
+ catalog curation, so none is marked official.
558
+ """
559
+ repositories = [
560
+ RepositoryReference(url=url, is_official=False)
561
+ for url in _urls(item.get("repositories"))
562
+ ][:repo_limit]
563
+ return PaperDetail(
564
+ id=str(item.get("id") or ""),
565
+ arxiv_id=_text(item.get("arxiv_id")),
566
+ title=str(item.get("title") or "Untitled paper"),
567
+ abstract=_text(item.get("abstract")),
568
+ authors=[str(author) for author in item.get("authors") or []],
569
+ published=_text(item.get("published")),
570
+ url=_text(item.get("url")),
571
+ pdf_url=_text(item.get("pdf_url")),
572
+ code_repository_count=len(repositories),
573
+ tasks=[],
574
+ methods=[],
575
+ repositories=repositories,
576
+ project_pages=_urls(item.get("project_pages"))[:repo_limit],
577
+ )
578
+
579
+
580
+ def available_at(item: dict[str, Any]) -> list[PaperLink]:
581
+ return [
582
+ PaperLink(label=str(link["label"]), url=str(link["url"]), kind=_text(link.get("kind")))
583
+ for link in item.get("available_at") or []
584
+ if isinstance(link, dict) and link.get("label") and link.get("url")
585
+ ]
mcp_server/src/pwc_mcp/server.py ADDED
@@ -0,0 +1,1256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MCP tools that mirror every read-only ``pwc`` research command.
2
+
3
+ Each tool maps one CLI command and exposes each of its research flags as a
4
+ typed parameter, then runs the CLI handler in-process through the catalog.
5
+ ``TOOL_COMMANDS``, ``PARAMETER_NAMES``, ``ENTITY_PARAMETERS`` and
6
+ ``MCP_ONLY_PARAMETERS`` are the parity contract that ``tests/test_parity.py``
7
+ checks against the CLI parser.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ import time
15
+ from collections.abc import Mapping
16
+ from datetime import date
17
+ from functools import cache
18
+ from typing import Annotated, Any, Literal, Protocol, TypeVar
19
+
20
+ from mcp.server.caching import CacheHint
21
+ from mcp.server.mcpserver import MCPServer
22
+ from mcp.server.mcpserver.exceptions import ToolError
23
+ from mcp.types import CallToolResult, TextContent, ToolAnnotations
24
+ from pwc_cli import queries
25
+ from pwc_cli.cli import UsageError
26
+ from pwc_cli.transport import HTTPStatusError, ResponseError, TransportError
27
+ from pydantic import BaseModel, Field
28
+
29
+ from pwc_mcp import __version__
30
+ from pwc_mcp.catalog import (
31
+ PaperMarkdownChunk,
32
+ PaperVersionMismatchError,
33
+ PublicPaperMiss,
34
+ normalize_title,
35
+ )
36
+ from pwc_mcp.cursors import (
37
+ CURSOR_LIFETIME_SECONDS,
38
+ MAX_CHUNK_BYTES,
39
+ CursorCodec,
40
+ CursorReferenceMismatch,
41
+ CursorState,
42
+ )
43
+ from pwc_mcp.models import (
44
+ AreaReference,
45
+ BenchmarkPage,
46
+ BenchmarkResult,
47
+ EvaluationPage,
48
+ MethodDetail,
49
+ MethodResult,
50
+ PaperInfoResult,
51
+ PaperLineageResult,
52
+ PaperPage,
53
+ PaperReadResult,
54
+ QueryResult,
55
+ TaskDetail,
56
+ TaskResult,
57
+ available_at,
58
+ benchmark_summary,
59
+ catalog_reference,
60
+ evaluation,
61
+ metric_directions,
62
+ paper_detail,
63
+ paper_evaluation,
64
+ paper_reference,
65
+ paper_summary,
66
+ public_paper_detail,
67
+ )
68
+
69
+ logger = logging.getLogger(__name__)
70
+ READ_ONLY = ToolAnnotations(
71
+ read_only_hint=True,
72
+ destructive_hint=False,
73
+ idempotent_hint=True,
74
+ open_world_hint=True,
75
+ )
76
+ # Hosted ceiling on rows per response (SPEC.md); the CLI allows up to 100.
77
+ MAX_ROWS = 25
78
+ # Matches `pwc search --mode`; app.py counts omitted modes with this value.
79
+ DEFAULT_SEARCH_MODE = "hybrid"
80
+ # CLI lookup failures the caller can act on (an unknown or ambiguous name, an
81
+ # unconfirmed filter). Transport, HTTP, and response-shape failures stay generic.
82
+ CLIENT_FACING_ERRORS = (
83
+ "Task not found",
84
+ "Method not found",
85
+ "Benchmark not found",
86
+ "Conference not found",
87
+ "Organization not found",
88
+ "Framework not found",
89
+ "Area not found",
90
+ "Paper title not found",
91
+ "Paper title is ambiguous",
92
+ "Paper not found",
93
+ "Paper URL not supported",
94
+ "Paper reference cannot be empty",
95
+ "Too many results to resolve paper title",
96
+ "Papers API did not confirm",
97
+ )
98
+ GENERIC_ERROR = "the Papers With Code catalog request failed"
99
+ # Upstream validation failures the caller can correct; the detail is the
100
+ # public API's own message, bounded and stripped of control characters.
101
+ INVALID_ARGUMENT_STATUSES = frozenset({400, 422})
102
+ MAX_UPSTREAM_DETAIL_CHARS = 200
103
+ # Lookup failures meaning the paper itself is absent from the catalog; the
104
+ # paper tools then fall back to the public-metadata API (Hugging Face Papers,
105
+ # arXiv, or Crossref records with the places the paper is available).
106
+ MISSING_PAPER_ERRORS = (
107
+ "Paper not found",
108
+ "Paper title not found",
109
+ "Paper URL not supported",
110
+ )
111
+ # Placeholder cursors agents send instead of a next_cursor token ("next" with
112
+ # nothing saved); they mean "keep reading", which starts at the beginning.
113
+ PLACEHOLDER_CURSORS = frozenset(
114
+ {"next", "start", "begin", "beginning", "first", "0", "1", "none", "null"}
115
+ )
116
+ NOT_IN_CATALOG = "not indexed on Papers With Code"
117
+ METADATA_SOURCES = {
118
+ "huggingface": "Hugging Face Papers",
119
+ "arxiv": "arXiv",
120
+ "crossref": "Crossref",
121
+ }
122
+
123
+
124
+ class PaperNotInCatalog(ToolError):
125
+ """A paper tool's reference does not name a catalog paper."""
126
+
127
+
128
+ def _is_missing_paper(error: Exception) -> bool:
129
+ if isinstance(error, HTTPStatusError):
130
+ return error.status == 404
131
+ return (
132
+ isinstance(error, ResponseError)
133
+ and not isinstance(error, TransportError)
134
+ and str(error).startswith(MISSING_PAPER_ERRORS)
135
+ )
136
+
137
+
138
+ # Why a catalog miss has no public record, worded by what was actually tried.
139
+ PUBLIC_MISSES: dict[str, str] = {
140
+ "identifier": "no public Hugging Face Papers, arXiv, or Crossref record matched it",
141
+ "title": (
142
+ "no arXiv or Crossref paper has exactly this title; check the title or "
143
+ "retry with the paper's arXiv ID, DOI, or URL"
144
+ ),
145
+ "unsupported": (
146
+ "it was not looked up publicly, because that needs an arXiv ID, DOI, "
147
+ "arXiv or Hugging Face URL, or the full title"
148
+ ),
149
+ "unavailable": (
150
+ "the public Hugging Face Papers, arXiv, and Crossref lookup is "
151
+ "unavailable right now, so whether a public record exists is unknown"
152
+ ),
153
+ }
154
+
155
+
156
+ def _public_note(record: dict[str, Any]) -> str:
157
+ links = "; ".join(f"{link.label}: {link.url}" for link in available_at(record))
158
+ requested = record.get("requested_title")
159
+ renamed = (
160
+ f'No paper is titled exactly "{requested}"; this is the paper with the '
161
+ f'same name, titled "{record.get("title")}". '
162
+ if requested and normalize_title(requested) != normalize_title(record.get("title"))
163
+ else ""
164
+ )
165
+ return (
166
+ f"{renamed}This paper is {NOT_IN_CATALOG}, so there is no Papers With Code page, "
167
+ "catalog tasks, methods, evaluations, or curated code for it; metadata comes "
168
+ "from public "
169
+ f"{METADATA_SOURCES.get(str(record.get('metadata_source')), 'arXiv')} "
170
+ "records. "
171
+ + (
172
+ f"Link to where it is available instead: {links}."
173
+ if links
174
+ else "Do not link to a Papers With Code page for it."
175
+ )
176
+ )
177
+
178
+
179
+ def _public_markdown(record: dict[str, Any]) -> str:
180
+ lines = [
181
+ f"# {record.get('title') or 'Untitled paper'}",
182
+ "",
183
+ f"> {_public_note(record)} Full text is unavailable here.",
184
+ "",
185
+ ]
186
+ if record.get("authors"):
187
+ lines.append(f"**Authors:** {', '.join(record['authors'])}")
188
+ if record.get("published"):
189
+ lines.append(f"**Published:** {record['published']}")
190
+ links = available_at(record)
191
+ if links:
192
+ lines.extend(["", "## Available at", ""])
193
+ lines.extend(f"- [{link.label}]({link.url})" for link in links)
194
+ if record.get("abstract"):
195
+ lines.extend(["", "## Abstract", "", str(record["abstract"])])
196
+ return "\n".join(lines) + "\n"
197
+
198
+
199
+ def _upstream_detail(error: HTTPStatusError) -> str:
200
+ detail = "".join(
201
+ ch if ch.isprintable() else " " for ch in str(error.detail or "")
202
+ ).strip()
203
+ return detail[:MAX_UPSTREAM_DETAIL_CHARS] or "the catalog rejected the request"
204
+
205
+
206
+ def catalog_error_message(error: Exception) -> str:
207
+ """Return the CLI's own lookup message when it is actionable, else a generic one."""
208
+ if isinstance(error, HTTPStatusError):
209
+ if error.status == 404:
210
+ return "not_found: the requested catalog record does not exist"
211
+ if error.status in INVALID_ARGUMENT_STATUSES:
212
+ return f"invalid_argument: {_upstream_detail(error)}"
213
+ if error.status == 429:
214
+ return "rate_limited: the Papers With Code catalog is rate limiting; retry later"
215
+ if isinstance(error, TransportError):
216
+ message = str(error).casefold()
217
+ if "timeout" in message or "timed out" in message:
218
+ return "upstream_timeout: the Papers With Code catalog timed out"
219
+ if isinstance(error, ResponseError) and not isinstance(error, TransportError):
220
+ message = str(error)
221
+ if message.startswith(CLIENT_FACING_ERRORS):
222
+ code = "ambiguous" if "ambiguous" in message.casefold() else "not_found"
223
+ return f"{code}: {message}"
224
+ # Only the exception class is logged: never the reference, query, or body.
225
+ logger.warning("pwc-mcp generic catalog error type=%s", type(error).__name__)
226
+ return f"upstream_error: {GENERIC_ERROR}"
227
+
228
+
229
+ # One read-only CLI command per tool.
230
+ TOOL_COMMANDS: dict[str, tuple[str, ...]] = {
231
+ "search_papers": ("search",),
232
+ "get_paper_info": ("paper", "info"),
233
+ "get_paper_evaluations": ("paper", "evaluations"),
234
+ "read_paper": ("paper", "read"),
235
+ "list_papers": ("paper", "list"),
236
+ "list_recent_papers": ("paper", "recent"),
237
+ "list_trending_papers": ("paper", "trending"),
238
+ "get_related_papers": ("paper", "related"),
239
+ "get_paper_lineage": ("paper", "lineage", "list"),
240
+ "get_task": ("task",),
241
+ "list_tasks": ("task", "list"),
242
+ "get_method": ("method",),
243
+ "list_methods": ("method", "list"),
244
+ "get_conference": ("conference",),
245
+ "list_conferences": ("conference", "list"),
246
+ "get_organization": ("organization",),
247
+ "list_organizations": ("organization", "list"),
248
+ "get_framework": ("framework",),
249
+ "list_frameworks": ("framework", "list"),
250
+ "get_benchmark": ("benchmark",),
251
+ "list_benchmarks": ("benchmark", "list"),
252
+ }
253
+ # CLI destinations that keep their established MCP parameter name.
254
+ PARAMETER_NAMES: dict[str, str] = {
255
+ "start_date": "published_after",
256
+ "end_date": "published_before",
257
+ "page_size": "limit",
258
+ "author": "authors",
259
+ "order_dir": "order_direction",
260
+ "min_eval_count": "minimum_evaluations",
261
+ "include_evals": "include_evaluations",
262
+ }
263
+ # ``--name`` selects the tool's entity and is exposed under the entity's name.
264
+ ENTITY_PARAMETERS: dict[str, str] = {
265
+ "get_task": "task",
266
+ "get_method": "method",
267
+ "get_conference": "conference",
268
+ "get_organization": "organization",
269
+ "get_framework": "framework",
270
+ "get_benchmark": "benchmark",
271
+ }
272
+ # Parameters with no CLI flag; ``read_paper`` continues with a signed cursor.
273
+ MCP_ONLY_PARAMETERS: dict[str, frozenset[str]] = {
274
+ "read_paper": frozenset({"cursor"}),
275
+ "get_paper_info": frozenset({"repo_limit"}),
276
+ "get_task": frozenset({"benchmark_limit"}),
277
+ }
278
+ _CLI_DESTINATIONS = {mcp: cli for cli, mcp in PARAMETER_NAMES.items()}
279
+
280
+
281
+ @cache
282
+ def _cli_destinations(tool: str) -> frozenset[str]:
283
+ return frozenset(queries.query_options(TOOL_COMMANDS[tool]))
284
+
285
+
286
+ def cli_options(tool: str, **parameters: Any) -> dict[str, Any]:
287
+ """Translate MCP parameters into the CLI destinations of ``tool``.
288
+
289
+ A parameter keeps its name when the command has that flag (``--limit``),
290
+ and otherwise follows ``PARAMETER_NAMES`` (``limit`` -> ``--page-size``).
291
+ """
292
+ entity = ENTITY_PARAMETERS.get(tool)
293
+ destinations = _cli_destinations(tool)
294
+ options: dict[str, Any] = {}
295
+ for name, value in parameters.items():
296
+ if name in MCP_ONLY_PARAMETERS.get(tool, frozenset()):
297
+ continue
298
+ if name == entity:
299
+ destination = "name"
300
+ elif name in destinations:
301
+ destination = name
302
+ else:
303
+ destination = _CLI_DESTINATIONS.get(name, name)
304
+ options[destination] = value
305
+ return options
306
+
307
+
308
+ Page = Annotated[int, Field(ge=1, le=100, description="Result page, starting at 1.")]
309
+ Limit = Annotated[
310
+ int, Field(ge=1, le=MAX_ROWS, description="Rows per response, at most 25.")
311
+ ]
312
+ Reference = Annotated[
313
+ str,
314
+ Field(
315
+ min_length=1,
316
+ max_length=500,
317
+ description="arXiv ID, numeric PwC ID, arXiv/Hugging Face/PwC URL, or exact title.",
318
+ ),
319
+ ]
320
+ Query = Annotated[str, Field(min_length=1, max_length=500)]
321
+ Entity = Annotated[str, Field(min_length=1, max_length=500)]
322
+ IsoDate = Annotated[
323
+ str, Field(max_length=10, description="Inclusive publication date, YYYY-MM-DD.")
324
+ ]
325
+ AuthorList = Annotated[
326
+ list[str],
327
+ Field(
328
+ max_length=10,
329
+ description="Exact author names, numeric IDs, or @HF_USERNAME; every author must match.",
330
+ ),
331
+ ]
332
+ ResourceLimit = Annotated[int, Field(ge=1, le=10)]
333
+ Output = TypeVar("Output", bound=BaseModel)
334
+
335
+
336
+ def _tool_result(value: Output, markdown: str) -> CallToolResult:
337
+ return CallToolResult(
338
+ content=[TextContent(type="text", text=markdown)],
339
+ structured_content=value.model_dump(mode="json"),
340
+ )
341
+
342
+
343
+ def _structured_model(result: Any, model: type[Output]) -> Output:
344
+ if isinstance(result, CallToolResult):
345
+ return model.model_validate(result.structured_content)
346
+ return result
347
+
348
+
349
+ Area = Annotated[str, Field(description="Case-insensitive exact area name or area ID.")]
350
+ Direction = Literal["asc", "desc"]
351
+ ParameterSize = Annotated[
352
+ str,
353
+ Field(
354
+ max_length=32,
355
+ description="Inclusive model-size limit such as 500M, 1.5B, 3B, or a raw integer.",
356
+ ),
357
+ ]
358
+ MetricNames = Annotated[
359
+ list[str],
360
+ Field(
361
+ min_length=1, description="Metric names that every returned row must report."
362
+ ),
363
+ ]
364
+ MetricBounds = Annotated[
365
+ dict[str, float], Field(description="Metric name to numeric threshold.")
366
+ ]
367
+ SortMetric = Annotated[
368
+ str,
369
+ Field(
370
+ max_length=200,
371
+ description="METRIC or METRIC:asc|desc; default direction is desc.",
372
+ ),
373
+ ]
374
+ ParetoObjectives = Annotated[
375
+ list[str],
376
+ Field(
377
+ min_length=2,
378
+ description="Two or more METRIC:higher or METRIC:lower objectives; keeps the Pareto frontier.",
379
+ ),
380
+ ]
381
+
382
+
383
+ def _validate_date_range(start: str | None, end: str | None) -> None:
384
+ try:
385
+ start_date = date.fromisoformat(start) if start else None
386
+ end_date = date.fromisoformat(end) if end else None
387
+ except ValueError as error:
388
+ raise ToolError("publication dates must use YYYY-MM-DD") from error
389
+ if start_date and end_date and start_date > end_date:
390
+ raise ToolError("published_after must be on or before published_before")
391
+
392
+
393
+ def _dicts(values: Any) -> list[dict[str, Any]]:
394
+ return [value for value in values or [] if isinstance(value, dict)]
395
+
396
+
397
+ def _next_page(data: Any) -> int | None:
398
+ value = data.get("next_page") if isinstance(data, dict) else None
399
+ return int(value) if value is not None else None
400
+
401
+
402
+ def _paper_page(data: Any) -> PaperPage:
403
+ """Project a paper listing; recent/trending endpoints return a bare list."""
404
+ if isinstance(data, list):
405
+ rows: Any = data
406
+ elif isinstance(data, dict):
407
+ rows = data.get("results")
408
+ else:
409
+ raise TypeError("paper listing did not contain a result document")
410
+ return PaperPage(
411
+ items=[paper_summary(item) for item in _dicts(rows)],
412
+ next_page=_next_page(data),
413
+ data=data,
414
+ )
415
+
416
+
417
+ def _grouped_benchmarks(areas: list[dict[str, Any]]) -> list[dict[str, Any]]:
418
+ return [
419
+ benchmark
420
+ for area in areas
421
+ for task in _dicts(area.get("tasks"))
422
+ for benchmark in _dicts(task.get("benchmarks"))
423
+ ]
424
+
425
+
426
+ class Catalog(Protocol):
427
+ def resolve_paper(self, paper: str) -> str: ...
428
+
429
+ def read_paper_chunk(
430
+ self,
431
+ paper: str,
432
+ *,
433
+ offset: int = 0,
434
+ content_version: str | None = None,
435
+ limit: int = MAX_CHUNK_BYTES,
436
+ resolved: bool = False,
437
+ ) -> PaperMarkdownChunk: ...
438
+
439
+ def query(self, command: tuple[str, ...], options: Mapping[str, Any]) -> Any: ...
440
+
441
+ def public_paper(self, reference: str) -> dict[str, Any] | PublicPaperMiss: ...
442
+
443
+
444
+ def build_server(
445
+ catalog: Catalog,
446
+ *,
447
+ read_chunk_bytes: int = MAX_CHUNK_BYTES,
448
+ cursor_codec: CursorCodec | None = None,
449
+ ) -> MCPServer:
450
+ codec = cursor_codec or CursorCodec(
451
+ os.environ.get("PWC_MCP_CURSOR_KEY_CURRENT", ""),
452
+ previous_secret=os.environ.get("PWC_MCP_CURSOR_KEY_PREVIOUS") or None,
453
+ )
454
+ server = MCPServer(
455
+ "pwc",
456
+ title="Papers With Code",
457
+ description=(
458
+ "Read-only access to papers, tasks, methods, conferences, organizations, "
459
+ "frameworks, and benchmarks; every pwc CLI research command and flag."
460
+ ),
461
+ instructions=(
462
+ "Use search_papers for relevance and list tools for deterministic filters. "
463
+ "Pass slugs or numeric IDs returned by list tools to exact lookup tools. "
464
+ "Dates use YYYY-MM-DD. Structured data is in structuredContent; text is a "
465
+ "short Markdown summary. Official implementation means catalog-designated "
466
+ "official code, not an independent audit. Evaluation is_open describes the "
467
+ "cataloged implementation and may be null when unknown."
468
+ ),
469
+ version=__version__,
470
+ website_url="https://paperswithcode.co",
471
+ cache_hints={
472
+ "server/discover": CacheHint(ttl_ms=3_600_000, scope="public"),
473
+ "tools/list": CacheHint(ttl_ms=3_600_000, scope="public"),
474
+ "resources/templates/list": CacheHint(ttl_ms=3_600_000, scope="public"),
475
+ "resources/read": CacheHint(ttl_ms=300_000, scope="public"),
476
+ },
477
+ )
478
+
479
+ def run(tool: str, **parameters: Any) -> Any:
480
+ """Run the tool's CLI command; surface usage errors, hide upstream detail."""
481
+ try:
482
+ return catalog.query(TOOL_COMMANDS[tool], cli_options(tool, **parameters))
483
+ except UsageError as error:
484
+ raise ToolError(str(error)) from error
485
+ except (ResponseError, TransportError) as error:
486
+ if "paper" in parameters and _is_missing_paper(error):
487
+ raise PaperNotInCatalog(catalog_error_message(error)) from error
488
+ raise ToolError(catalog_error_message(error)) from error
489
+
490
+ def public_paper(reference: str, missing: ToolError) -> dict[str, Any]:
491
+ """Public metadata for a catalog miss, or the original error."""
492
+ record = catalog.public_paper(reference)
493
+ if isinstance(record, PublicPaperMiss):
494
+ raise ToolError(
495
+ f"{missing}; it is {NOT_IN_CATALOG} and {PUBLIC_MISSES[record.reason]}"
496
+ ) from missing
497
+ return record
498
+
499
+ def catalog_alternatives(reference: str, record: dict[str, Any]) -> list[str]:
500
+ """Catalog IDs the public-metadata API resolved a missed reference to.
501
+
502
+ An arXiv URL or versioned ID the catalog lookup missed may name a
503
+ catalog paper. The arXiv ID comes first: Markdown reads key arXiv papers
504
+ by it, not by the numeric catalog ID.
505
+ """
506
+ if record.get("in_catalog") is not True:
507
+ return []
508
+ candidates = (record.get("arxiv_id"), record.get("catalog_id"))
509
+ return [
510
+ str(value).strip()
511
+ for value in candidates
512
+ if value and str(value).strip() != reference.strip()
513
+ ]
514
+
515
+ def catalog_retry(
516
+ tool: str,
517
+ reference: str,
518
+ record: dict[str, Any],
519
+ missing: ToolError,
520
+ **parameters: Any,
521
+ ) -> Any:
522
+ """Retry the catalog with the catalog ID the public-metadata API found.
523
+
524
+ Returns None for a paper outside the catalog; a cataloged paper that
525
+ still cannot be read keeps the original error rather than being
526
+ presented as outside the catalog.
527
+ """
528
+ for alternative in catalog_alternatives(reference, record):
529
+ try:
530
+ return run(tool, paper=str(alternative), **parameters)
531
+ except ToolError:
532
+ continue
533
+ if record.get("in_catalog") is True:
534
+ raise missing
535
+ return None
536
+
537
+ @server.tool(annotations=READ_ONLY, structured_output=True)
538
+ def search_papers(
539
+ query: Query,
540
+ mode: Literal["hybrid", "keyword", "semantic"] = DEFAULT_SEARCH_MODE,
541
+ page: Page = 1,
542
+ limit: Limit = 10,
543
+ published_after: IsoDate | None = None,
544
+ published_before: IsoDate | None = None,
545
+ has_official_implementation: bool = False,
546
+ ) -> PaperPage:
547
+ """Search papers by title, topic, author, or arXiv ID (`pwc search`). Broad discovery only: for best, top, or state-of-the-art model questions start with get_task, list_benchmarks, and get_benchmark, which return leaderboard evidence that search cannot. Mode defaults to hybrid like `pwc search`; use keyword for exact terminology or to stay under the semantic search rate limit."""
548
+ _validate_date_range(published_after, published_before)
549
+ result = _paper_page(
550
+ run(
551
+ "search_papers",
552
+ query=query,
553
+ mode=mode,
554
+ page=page,
555
+ limit=limit,
556
+ published_after=published_after,
557
+ published_before=published_before,
558
+ has_official_implementation=has_official_implementation,
559
+ )
560
+ )
561
+ return _tool_result(
562
+ result,
563
+ f"Found {len(result.items)} papers."
564
+ + (f" Next page: {result.next_page}." if result.next_page else ""),
565
+ )
566
+
567
+ @server.tool(annotations=READ_ONLY, structured_output=True)
568
+ def get_paper_info(
569
+ paper: Reference,
570
+ include_resources: bool = False,
571
+ repo_limit: ResourceLimit = 5,
572
+ include_evaluations: bool = False,
573
+ ) -> PaperInfoResult:
574
+ """Get compact paper metadata and official-first code. Set include_resources for additional repositories, project pages, Hugging Face artifacts, and author X threads introducing the paper, repo_limit to cap each list, and include_evaluations only for bounded legacy compatibility (`pwc paper info`). A paper not indexed on Papers With Code returns public Hugging Face Papers, arXiv, or Crossref metadata with in_catalog=false, a note, and available_at links to cite instead of a Papers With Code page."""
575
+ options = {
576
+ # Fetch once so the compact default can retain official code while
577
+ # omitting hundreds of community repositories from the response.
578
+ "include_resources": True,
579
+ "include_evaluations": include_evaluations,
580
+ }
581
+ try:
582
+ data = run("get_paper_info", paper=paper, **options)
583
+ except PaperNotInCatalog as missing:
584
+ record = public_paper(paper, missing)
585
+ data = catalog_retry("get_paper_info", paper, record, missing, **options)
586
+ if data is None:
587
+ detail = public_paper_detail(record, repo_limit=repo_limit)
588
+ note = _public_note(record)
589
+ result = PaperInfoResult(
590
+ paper=detail,
591
+ in_catalog=False,
592
+ note=note,
593
+ available_at=available_at(record),
594
+ data=None,
595
+ )
596
+ return _tool_result(result, f"## {detail.title}\n\n{note}")
597
+ if not isinstance(data, dict):
598
+ raise TypeError("paper response did not contain a paper")
599
+ evaluations = data.get("evaluations")
600
+ result = PaperInfoResult(
601
+ paper=paper_detail(
602
+ data, include_resources=include_resources, repo_limit=repo_limit
603
+ ),
604
+ evaluation_count=(
605
+ int(evaluations.get("count") or 0)
606
+ if isinstance(evaluations, dict)
607
+ else None
608
+ ),
609
+ evaluations=(
610
+ [paper_evaluation(item) for item in _dicts(evaluations.get("results"))]
611
+ if isinstance(evaluations, dict)
612
+ else None
613
+ ),
614
+ data=None,
615
+ )
616
+ return _tool_result(
617
+ result,
618
+ f"## {result.paper.title}\n\n{result.paper.code_repository_count} code repositories; returned {len(result.paper.repositories)}.",
619
+ )
620
+
621
+ @server.tool(annotations=READ_ONLY, structured_output=True)
622
+ def get_paper_evaluations(
623
+ paper: Reference, page: Page = 1, limit: Limit = 20
624
+ ) -> EvaluationPage:
625
+ """Get one page of benchmark evaluations for a paper, including protocol, source, openness, and task-scoped ranks (`pwc paper evaluations`)."""
626
+ data = run("get_paper_evaluations", paper=paper, page=page, limit=limit)
627
+ rows = _dicts(data.get("results")) if isinstance(data, dict) else []
628
+ values = [evaluation(row) for row in rows]
629
+ result = EvaluationPage(
630
+ paper=paper,
631
+ evaluation_count=int(data.get("count") or len(values)),
632
+ page=page,
633
+ next_page=_next_page(data),
634
+ evaluations=values,
635
+ data=None,
636
+ )
637
+ return _tool_result(
638
+ result,
639
+ f"Found {result.evaluation_count} evaluation rows; returned {len(values)} on page {page}."
640
+ + (f" Next page: {result.next_page}." if result.next_page else ""),
641
+ )
642
+
643
+ @server.tool(annotations=READ_ONLY, structured_output=True)
644
+ def read_paper(paper: Reference, cursor: str | None = None) -> PaperReadResult:
645
+ """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. A paper not indexed on Papers With Code returns its public metadata and abstract as Markdown with in_catalog=false and available_at links to cite instead of a Papers With Code page."""
646
+ if cursor is not None and cursor.strip().casefold() in PLACEHOLDER_CURSORS:
647
+ # Not a token: nothing was saved to continue from.
648
+ cursor = None
649
+ return read(paper.strip(), cursor, follow_public=True)
650
+
651
+ def read(
652
+ reference: str, cursor: str | None, *, follow_public: bool
653
+ ) -> PaperReadResult:
654
+ try:
655
+ state = codec.decode(cursor, reference=reference) if cursor else None
656
+ except CursorReferenceMismatch as error:
657
+ # The agent continued with another spelling of the same paper, for
658
+ # example the numeric catalog ID after starting from the arXiv ID.
659
+ try:
660
+ canonical = catalog.resolve_paper(reference)
661
+ except (ResponseError, TransportError) as inner:
662
+ raise ToolError(catalog_error_message(inner)) from inner
663
+ if canonical != error.state.paper:
664
+ raise ToolError(
665
+ "continuation cursor belongs to a different paper; omit cursor "
666
+ "to start reading this paper from the beginning"
667
+ ) from error
668
+ state = error.state
669
+ except ValueError as error:
670
+ raise ToolError(
671
+ f"{error}; pass the next_cursor value from the previous read_paper "
672
+ "result, or omit cursor to start from the beginning"
673
+ ) from error
674
+ if state is None:
675
+ try:
676
+ canonical = catalog.resolve_paper(reference)
677
+ except (ResponseError, TransportError) as error:
678
+ if _is_missing_paper(error):
679
+ return public_read(reference, error, follow=follow_public)
680
+ raise ToolError(catalog_error_message(error)) from error
681
+ offset = 0
682
+ content_version = None
683
+ limit = read_chunk_bytes
684
+ expires_at = int(time.time()) + CURSOR_LIFETIME_SECONDS
685
+ source = "external" if canonical.isdigit() else "arxiv"
686
+ else:
687
+ canonical = state.paper
688
+ offset = state.offset
689
+ content_version = state.content_version
690
+ limit = state.limit
691
+ expires_at = state.expires_at
692
+ source = state.source
693
+ try:
694
+ chunk = catalog.read_paper_chunk(
695
+ canonical,
696
+ offset=offset,
697
+ content_version=content_version,
698
+ limit=limit,
699
+ resolved=True,
700
+ )
701
+ except PaperVersionMismatchError as error:
702
+ raise ToolError(
703
+ "paper changed; restart reading from the beginning"
704
+ ) from error
705
+ except HTTPStatusError as error:
706
+ if error.status == 404:
707
+ raise ToolError(
708
+ "no_markdown: no stored Markdown is available for this paper"
709
+ ) from error
710
+ raise ToolError(catalog_error_message(error)) from error
711
+ except (ResponseError, TransportError) as error:
712
+ if state is None and _is_missing_paper(error):
713
+ return public_read(reference, error, follow=follow_public)
714
+ raise ToolError(catalog_error_message(error)) from error
715
+ if chunk.paper != canonical or chunk.source != source:
716
+ raise ToolError("paper changed; restart reading from the beginning")
717
+ next_cursor = None
718
+ if chunk.next_offset is not None:
719
+ next_cursor = codec.encode(
720
+ CursorState(
721
+ reference=reference,
722
+ paper=canonical,
723
+ source=source,
724
+ content_version=chunk.content_version,
725
+ offset=chunk.next_offset,
726
+ limit=limit,
727
+ expires_at=expires_at,
728
+ )
729
+ )
730
+ return PaperReadResult(
731
+ paper=reference,
732
+ markdown=chunk.markdown,
733
+ truncated=chunk.truncated,
734
+ next_cursor=next_cursor,
735
+ )
736
+
737
+ def public_read(
738
+ reference: str, error: Exception, *, follow: bool
739
+ ) -> PaperReadResult:
740
+ missing = PaperNotInCatalog(catalog_error_message(error))
741
+ record = public_paper(reference, missing)
742
+ # The public-metadata API may resolve the reference to a catalog paper.
743
+ # The result names that paper's ID, so continuation cursors match it.
744
+ for alternative in catalog_alternatives(reference, record) if follow else []:
745
+ try:
746
+ result = read(alternative, None, follow_public=False)
747
+ except ToolError:
748
+ continue
749
+ if result.in_catalog:
750
+ return result
751
+ if record.get("in_catalog") is True:
752
+ raise missing
753
+ return PaperReadResult(
754
+ paper=reference,
755
+ markdown=_public_markdown(record),
756
+ truncated=False,
757
+ next_cursor=None,
758
+ in_catalog=False,
759
+ available_at=available_at(record),
760
+ )
761
+
762
+ @server.tool(annotations=READ_ONLY, structured_output=True)
763
+ def list_papers(
764
+ search: str | None = None,
765
+ task: str | None = None,
766
+ method: str | None = None,
767
+ conference: str | None = None,
768
+ framework: str | None = None,
769
+ organization: str | None = None,
770
+ authors: AuthorList | None = None,
771
+ published_after: IsoDate | None = None,
772
+ published_before: IsoDate | None = None,
773
+ all_versions: bool = False,
774
+ order_by: Literal["trending", "date_published", "citation_count"] = "trending",
775
+ order_direction: Direction = "desc",
776
+ include_resources: bool = False,
777
+ has_official_implementation: bool = False,
778
+ page: Page = 1,
779
+ limit: Limit = 20,
780
+ ) -> PaperPage:
781
+ """List and filter papers by exact catalog associations in a deterministic order (`pwc paper list`)."""
782
+ _validate_date_range(published_after, published_before)
783
+ return _paper_page(
784
+ run(
785
+ "list_papers",
786
+ search=search,
787
+ task=task,
788
+ method=method,
789
+ conference=conference,
790
+ framework=framework,
791
+ organization=organization,
792
+ authors=authors or None,
793
+ published_after=published_after,
794
+ published_before=published_before,
795
+ all_versions=all_versions,
796
+ order_by=order_by,
797
+ order_direction=order_direction,
798
+ include_resources=include_resources,
799
+ has_official_implementation=has_official_implementation,
800
+ page=page,
801
+ limit=limit,
802
+ )
803
+ )
804
+
805
+ @server.tool(annotations=READ_ONLY, structured_output=True)
806
+ def list_recent_papers(limit: Limit = 10) -> PaperPage:
807
+ """List the most recently added papers (`pwc paper recent`)."""
808
+ return _paper_page(run("list_recent_papers", limit=limit))
809
+
810
+ @server.tool(annotations=READ_ONLY, structured_output=True)
811
+ def list_trending_papers(
812
+ limit: Limit = 20,
813
+ max_age_days: Annotated[int, Field(ge=1, le=365)] = 180,
814
+ min_velocity: float | None = None,
815
+ ) -> PaperPage:
816
+ """List trending papers by repository velocity (`pwc paper trending`)."""
817
+ return _paper_page(
818
+ run(
819
+ "list_trending_papers",
820
+ limit=limit,
821
+ max_age_days=max_age_days,
822
+ min_velocity=min_velocity,
823
+ )
824
+ )
825
+
826
+ @server.tool(annotations=READ_ONLY, structured_output=True)
827
+ def get_related_papers(
828
+ paper: Reference, limit: Annotated[int, Field(ge=1, le=20)] = 4
829
+ ) -> PaperPage:
830
+ """Find catalog papers related to one paper (`pwc paper related`). A paper not indexed on Papers With Code falls back to a catalog search for its public title, with a note and available_at links for the paper itself."""
831
+ try:
832
+ return _paper_page(run("get_related_papers", paper=paper, limit=limit))
833
+ except PaperNotInCatalog as missing:
834
+ record = public_paper(paper, missing)
835
+ data = catalog_retry(
836
+ "get_related_papers", paper, record, missing, limit=limit
837
+ )
838
+ if data is not None:
839
+ return _paper_page(data)
840
+ result = _paper_page(
841
+ run(
842
+ "search_papers",
843
+ query=str(record.get("title") or paper)[:500],
844
+ mode=DEFAULT_SEARCH_MODE,
845
+ page=1,
846
+ limit=limit,
847
+ )
848
+ )
849
+ result.note = (
850
+ f"{record.get('title') or paper} is {NOT_IN_CATALOG}; these are catalog "
851
+ "search results for its title, not curated related papers. Link to the "
852
+ "paper itself through available_at, not a Papers With Code page."
853
+ )
854
+ result.available_at = available_at(record)
855
+ return _tool_result(result, result.note)
856
+
857
+ @server.tool(annotations=READ_ONLY, structured_output=True)
858
+ def get_paper_lineage(paper: Reference) -> PaperLineageResult:
859
+ """Get explicit predecessor and successor papers (`pwc paper lineage list`)."""
860
+ data = run("get_paper_lineage", paper=paper)
861
+ current = data.get("paper") if isinstance(data, dict) else None
862
+ if not isinstance(current, dict):
863
+ raise TypeError("lineage response did not contain a paper")
864
+ return PaperLineageResult(
865
+ paper=paper_reference(current),
866
+ predecessors=[
867
+ paper_reference(item) for item in _dicts(data.get("predecessors"))
868
+ ],
869
+ successors=[
870
+ paper_reference(item) for item in _dicts(data.get("successors"))
871
+ ],
872
+ data=data,
873
+ )
874
+
875
+ @server.tool(annotations=READ_ONLY, structured_output=True)
876
+ def get_task(task: Entity, benchmark_limit: ResourceLimit = 10) -> TaskResult:
877
+ """Get one exact task by name, slug, or ID with its hierarchy, ranked benchmarks, common methods, and trending papers (`pwc task --name`). Start here for any question about the best or state-of-the-art models for a task, then inspect a leaderboard with get_benchmark."""
878
+ data = run("get_task", task=task)
879
+ item = data.get("task") if isinstance(data, dict) else None
880
+ if not isinstance(item, dict):
881
+ raise TypeError("task response did not contain a task")
882
+ area_item = data.get("area")
883
+ result = TaskResult(
884
+ task=TaskDetail(
885
+ id=str(item.get("id") or ""),
886
+ name=str(item.get("name") or "Unknown task"),
887
+ slug=str(item.get("slug") or item.get("id") or ""),
888
+ description=(
889
+ str(item["description"]) if item.get("description") else None
890
+ ),
891
+ paper_count=int(
892
+ item.get("paper_count") or data.get("paper_count") or 0
893
+ ),
894
+ area=(
895
+ AreaReference(
896
+ id=str(area_item.get("id") or ""),
897
+ name=str(area_item.get("name") or "Unknown area"),
898
+ )
899
+ if isinstance(area_item, dict)
900
+ else None
901
+ ),
902
+ parents=[catalog_reference(v) for v in _dicts(data.get("parents"))],
903
+ children=[catalog_reference(v) for v in _dicts(data.get("children"))],
904
+ benchmarks=[
905
+ benchmark_summary(v) for v in _dicts(data.get("benchmarks"))
906
+ ][:benchmark_limit],
907
+ ),
908
+ data=None,
909
+ )
910
+ return _tool_result(
911
+ result,
912
+ f"## {result.task.name}\n\n{result.task.paper_count} papers; returned {len(result.task.benchmarks)} benchmarks.",
913
+ )
914
+
915
+ @server.tool(annotations=READ_ONLY, structured_output=True)
916
+ def list_tasks(
917
+ search: str | None = None,
918
+ area: Area | None = None,
919
+ level: int | None = None,
920
+ visible_only: bool = False,
921
+ group_by_area: bool = False,
922
+ order_by: Literal["name", "created_at", "level", "paper_count"] = "name",
923
+ order_direction: Direction = "asc",
924
+ page: Page = 1,
925
+ limit: Limit | None = None,
926
+ ) -> QueryResult:
927
+ """List and filter research tasks, or group the visible top-level taxonomy by area (`pwc task list`)."""
928
+ return QueryResult(
929
+ data=run(
930
+ "list_tasks",
931
+ search=search,
932
+ area=area,
933
+ level=level,
934
+ visible_only=visible_only,
935
+ group_by_area=group_by_area,
936
+ order_by=order_by,
937
+ order_direction=order_direction,
938
+ page=page,
939
+ limit=limit if limit is not None or group_by_area else MAX_ROWS,
940
+ )
941
+ )
942
+
943
+ @server.tool(annotations=READ_ONLY, structured_output=True)
944
+ def get_method(method: Entity) -> MethodResult:
945
+ """Get one method by name, full name, slug, ID, or curated keyword alias (`pwc method --name`)."""
946
+ data = run("get_method", method=method)
947
+ item = data.get("method") if isinstance(data, dict) else None
948
+ if not isinstance(item, dict):
949
+ raise TypeError("method response did not contain a method")
950
+ result = MethodResult(
951
+ method=MethodDetail(
952
+ id=str(item.get("id") or ""),
953
+ name=str(item.get("name") or "Unknown method"),
954
+ slug=str(item.get("slug") or item.get("id") or ""),
955
+ full_name=str(item["full_name"]) if item.get("full_name") else None,
956
+ description=(
957
+ str(item["description"]) if item.get("description") else None
958
+ ),
959
+ introduced_year=(
960
+ int(item["introduced_year"])
961
+ if item.get("introduced_year") is not None
962
+ else None
963
+ ),
964
+ source_paper_id=(
965
+ str(item["source_paper_id"])
966
+ if item.get("source_paper_id")
967
+ else None
968
+ ),
969
+ source_url=(
970
+ f"https://paperswithcode.co{item['source_url']}"
971
+ if str(item.get("source_url") or "").startswith("/")
972
+ else str(item["source_url"])
973
+ if item.get("source_url")
974
+ else None
975
+ ),
976
+ source_title=(
977
+ str(item["source_title"]) if item.get("source_title") else None
978
+ ),
979
+ paper_count=int(item.get("paper_count") or 0),
980
+ ),
981
+ data=None,
982
+ )
983
+ return _tool_result(
984
+ result, f"## {result.method.name}\n\n{result.method.paper_count} papers."
985
+ )
986
+
987
+ @server.tool(annotations=READ_ONLY, structured_output=True)
988
+ def list_methods(
989
+ search: str | None = None,
990
+ area: Area | None = None,
991
+ introduced_year: int | None = None,
992
+ order_by: Literal[
993
+ "name", "full_name", "introduced_year", "created_at", "paper_count"
994
+ ] = "name",
995
+ order_direction: Direction = "asc",
996
+ page: Page = 1,
997
+ limit: Limit = MAX_ROWS,
998
+ ) -> QueryResult:
999
+ """List and filter research methods (`pwc method list`)."""
1000
+ return QueryResult(
1001
+ data=run(
1002
+ "list_methods",
1003
+ search=search,
1004
+ area=area,
1005
+ introduced_year=introduced_year,
1006
+ order_by=order_by,
1007
+ order_direction=order_direction,
1008
+ page=page,
1009
+ limit=limit,
1010
+ )
1011
+ )
1012
+
1013
+ @server.tool(annotations=READ_ONLY, structured_output=True)
1014
+ def get_conference(conference: Entity) -> QueryResult:
1015
+ """Get one exact conference by name, slug, or ID (`pwc conference --name`)."""
1016
+ return QueryResult(data=run("get_conference", conference=conference))
1017
+
1018
+ @server.tool(annotations=READ_ONLY, structured_output=True)
1019
+ def list_conferences(year: int | None = None) -> QueryResult:
1020
+ """List conferences with imported papers (`pwc conference list`)."""
1021
+ return QueryResult(data=run("list_conferences", year=year))
1022
+
1023
+ @server.tool(annotations=READ_ONLY, structured_output=True)
1024
+ def get_organization(organization: Entity) -> QueryResult:
1025
+ """Get one exact research organization by name, slug, or ID (`pwc organization --name`)."""
1026
+ return QueryResult(data=run("get_organization", organization=organization))
1027
+
1028
+ @server.tool(annotations=READ_ONLY, structured_output=True)
1029
+ def list_organizations(featured_only: bool = False) -> QueryResult:
1030
+ """List research organizations (`pwc organization list`)."""
1031
+ return QueryResult(data=run("list_organizations", featured_only=featured_only))
1032
+
1033
+ @server.tool(annotations=READ_ONLY, structured_output=True)
1034
+ def get_framework(framework: Entity) -> QueryResult:
1035
+ """Get one exact research framework by name, slug, or ID (`pwc framework --name`)."""
1036
+ return QueryResult(data=run("get_framework", framework=framework))
1037
+
1038
+ @server.tool(annotations=READ_ONLY, structured_output=True)
1039
+ def list_frameworks(
1040
+ domain: str | None = None,
1041
+ category: str | None = None,
1042
+ platform: str | None = None,
1043
+ ) -> QueryResult:
1044
+ """List research frameworks by domain, category, or platform (`pwc framework list`)."""
1045
+ return QueryResult(
1046
+ data=run(
1047
+ "list_frameworks", domain=domain, category=category, platform=platform
1048
+ )
1049
+ )
1050
+
1051
+ @server.tool(annotations=READ_ONLY, structured_output=True)
1052
+ def get_benchmark(
1053
+ benchmark: Entity,
1054
+ page: Page = 1,
1055
+ limit: Limit = 20,
1056
+ is_open: bool | None = None,
1057
+ max_parameters: ParameterSize | None = None,
1058
+ require_metrics: MetricNames | None = None,
1059
+ minimum_metrics: MetricBounds | None = None,
1060
+ maximum_metrics: MetricBounds | None = None,
1061
+ sort_metric: SortMetric | None = None,
1062
+ pareto: ParetoObjectives | None = None,
1063
+ ) -> BenchmarkResult:
1064
+ """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"); a metric the leaderboard does not report returns the unfiltered leaderboard with metric_note listing its actual metric names."""
1065
+ options = {
1066
+ "benchmark": benchmark,
1067
+ "page": page,
1068
+ "limit": limit,
1069
+ "is_open": is_open,
1070
+ "max_parameters": max_parameters,
1071
+ }
1072
+ metric_note = None
1073
+ try:
1074
+ data = run(
1075
+ "get_benchmark",
1076
+ **options,
1077
+ require_metrics=require_metrics,
1078
+ minimum_metrics=minimum_metrics,
1079
+ maximum_metrics=maximum_metrics,
1080
+ sort_metric=sort_metric,
1081
+ pareto=pareto,
1082
+ )
1083
+ except ToolError as error:
1084
+ # A metric the leaderboard does not report ("overall", "pass@1")
1085
+ # still deserves the leaderboard, with its real metric names.
1086
+ if not str(error).startswith("unknown metric"):
1087
+ raise
1088
+ data = run("get_benchmark", **options)
1089
+ metric_note = (
1090
+ f"{error}; returned the leaderboard without metric filters or "
1091
+ "metric sorting"
1092
+ )
1093
+ item = data.get("benchmark") if isinstance(data, dict) else None
1094
+ if not isinstance(item, dict):
1095
+ raise TypeError("benchmark response did not contain a benchmark")
1096
+ matched = data.get("matched_count")
1097
+ evaluations = [evaluation(value) for value in _dicts(data.get("results"))]
1098
+ result = BenchmarkResult(
1099
+ benchmark=benchmark_summary(item),
1100
+ evaluation_count=int(data.get("count") or 0),
1101
+ matched_count=int(matched) if matched is not None else None,
1102
+ evaluations=evaluations,
1103
+ metric_directions=metric_directions(evaluations),
1104
+ page=page,
1105
+ next_page=_next_page(data),
1106
+ metric_note=metric_note,
1107
+ data=None,
1108
+ )
1109
+ return _tool_result(
1110
+ result,
1111
+ f"## {result.benchmark.name}\n\n{result.evaluation_count} evaluation rows; returned {len(result.evaluations)} models on page {page}. Ranks are task-scoped."
1112
+ + (f" Next page: {result.next_page}." if result.next_page else "")
1113
+ + (f" Note: {metric_note}." if metric_note else ""),
1114
+ )
1115
+
1116
+ @server.tool(annotations=READ_ONLY, structured_output=True)
1117
+ def list_benchmarks(
1118
+ search: str | None = None,
1119
+ task: str | None = None,
1120
+ include_descendants: bool = False,
1121
+ minimum_evaluations: int | None = None,
1122
+ is_open: bool | None = None,
1123
+ group_by_area: bool = False,
1124
+ area: Area | None = None,
1125
+ benchmarks_per_task: Annotated[int, Field(ge=1, le=10)] = 3,
1126
+ order_by: Literal["trending", "name", "full_name", "created_at", "paper_count"]
1127
+ | None = None,
1128
+ order_direction: Direction = "asc",
1129
+ page: Page = 1,
1130
+ limit: Limit | None = None,
1131
+ ) -> BenchmarkPage:
1132
+ """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."""
1133
+ notes: list[str] = []
1134
+ if order_by == "trending" and not task:
1135
+ order_by = None
1136
+ notes.append("order_by=trending needs task; ordered by name instead")
1137
+ filtered = bool(
1138
+ task
1139
+ or search
1140
+ or include_descendants
1141
+ or is_open is not None
1142
+ or order_by is not None
1143
+ or order_direction != "asc"
1144
+ )
1145
+ if area is not None and filtered:
1146
+ area = None
1147
+ notes.append("area cannot be combined with filters; returned the filtered list")
1148
+ grouped = group_by_area or area is not None
1149
+ if minimum_evaluations is not None and minimum_evaluations < 1:
1150
+ # The flat listing has no minimum by default and rejects 0 (it
1151
+ # needs at least 1); the grouped one accepts 0 but defaults to 2.
1152
+ minimum_evaluations = 0 if grouped else None
1153
+ # Grouped listings are not paginated; the CLI rejects page/limit there.
1154
+ data = run(
1155
+ "list_benchmarks",
1156
+ search=search,
1157
+ task=task,
1158
+ include_descendants=include_descendants,
1159
+ minimum_evaluations=minimum_evaluations,
1160
+ is_open=is_open,
1161
+ group_by_area=group_by_area,
1162
+ area=area,
1163
+ benchmarks_per_task=benchmarks_per_task,
1164
+ order_by=order_by,
1165
+ order_direction=order_direction,
1166
+ page=1 if grouped else page,
1167
+ limit=None if grouped else (limit if limit is not None else MAX_ROWS),
1168
+ )
1169
+ if not isinstance(data, dict):
1170
+ raise TypeError("benchmark listing did not contain a result document")
1171
+ rows = _dicts(data.get("results"))
1172
+ if grouped:
1173
+ rows = _grouped_benchmarks(rows)
1174
+ result = BenchmarkPage(
1175
+ items=[benchmark_summary(item) for item in rows],
1176
+ next_page=_next_page(data),
1177
+ data=data,
1178
+ )
1179
+ summary = f"Found {len(result.items)} benchmarks."
1180
+ if result.next_page:
1181
+ summary += f" Next page: {result.next_page}."
1182
+ for note in notes:
1183
+ summary += f" Note: {note}."
1184
+ return _tool_result(result, summary)
1185
+
1186
+ @server.prompt(name="find_papers", title="Find papers")
1187
+ def find_papers_prompt(topic: str) -> str:
1188
+ return (
1189
+ f"Find papers about {topic!r}. Use semantic search for concepts, then "
1190
+ "inspect the most relevant papers and cite their canonical URLs."
1191
+ )
1192
+
1193
+ @server.prompt(name="compare_leaderboard", title="Compare a leaderboard")
1194
+ def compare_leaderboard_prompt(benchmark: str) -> str:
1195
+ return (
1196
+ f"Inspect the {benchmark!r} benchmark. Explain metric direction and "
1197
+ "evaluation settings, and do not compare ranks from different task scopes."
1198
+ )
1199
+
1200
+ @server.prompt(name="survey_task", title="Survey a research task")
1201
+ def survey_task_prompt(task: str) -> str:
1202
+ return (
1203
+ f"Survey the {task!r} task. Resolve its slug, inspect high-coverage "
1204
+ "benchmarks, then summarize representative papers and methods."
1205
+ )
1206
+
1207
+ @server.resource(
1208
+ "pwc://papers/{paper}",
1209
+ name="paper-info",
1210
+ title="Paper information",
1211
+ description="Canonical Papers With Code paper metadata.",
1212
+ mime_type="application/json",
1213
+ )
1214
+ def paper_info_resource(paper: str) -> str:
1215
+ return _structured_model(
1216
+ get_paper_info(paper), PaperInfoResult
1217
+ ).model_dump_json()
1218
+
1219
+ @server.resource(
1220
+ "pwc://papers/{paper}/markdown",
1221
+ name="paper-markdown",
1222
+ title="Paper Markdown",
1223
+ description="Complete stored Markdown for a paper when it fits one response.",
1224
+ mime_type="text/markdown",
1225
+ )
1226
+ def paper_markdown_resource(paper: str) -> str:
1227
+ result = read_paper(paper)
1228
+ if result.truncated:
1229
+ raise ValueError(
1230
+ "paper is too large for one resource response; use read_paper with its continuation cursor"
1231
+ )
1232
+ return result.markdown
1233
+
1234
+ @server.resource(
1235
+ "pwc://tasks/{task}",
1236
+ name="task",
1237
+ title="Research task",
1238
+ description="Canonical Papers With Code task metadata and benchmarks.",
1239
+ mime_type="application/json",
1240
+ )
1241
+ def task_resource(task: str) -> str:
1242
+ return _structured_model(get_task(task), TaskResult).model_dump_json()
1243
+
1244
+ @server.resource(
1245
+ "pwc://benchmarks/{benchmark}",
1246
+ name="benchmark",
1247
+ title="Benchmark leaderboard",
1248
+ description="Canonical benchmark metadata and leading evaluations.",
1249
+ mime_type="application/json",
1250
+ )
1251
+ def benchmark_resource(benchmark: str) -> str:
1252
+ return _structured_model(
1253
+ get_benchmark(benchmark), BenchmarkResult
1254
+ ).model_dump_json()
1255
+
1256
+ return server
mcp_server/tests/conftest.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+
6
+ @pytest.fixture(autouse=True)
7
+ def _cursor_secrets(monkeypatch):
8
+ monkeypatch.setenv("PWC_MCP_CURSOR_KEY_CURRENT", "test-current-cursor-secret")
9
+ monkeypatch.delenv("PWC_MCP_CURSOR_KEY_PREVIOUS", raising=False)
mcp_server/tests/test_app.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from pwc_mcp.app import (
6
+ MAX_RESPONSE_BODY_SIZE,
7
+ ResponseSizeLimitMiddleware,
8
+ _client_address,
9
+ create_app,
10
+ )
11
+ from starlette.applications import Starlette
12
+ from starlette.datastructures import Headers
13
+ from starlette.responses import Response
14
+ from starlette.routing import Route
15
+ from starlette.testclient import TestClient
16
+ from test_server import StubCatalog
17
+
18
+
19
+ def test_health_and_browser_origin_policy_are_explicit():
20
+ app = create_app(
21
+ StubCatalog(),
22
+ allowed_hosts=["testserver"],
23
+ allowed_origins=["https://chatgpt.com"],
24
+ )
25
+
26
+ with TestClient(app) as client:
27
+ health = client.get("/health")
28
+ rejected = client.post(
29
+ "/mcp",
30
+ headers={"Origin": "https://attacker.example"},
31
+ json={},
32
+ )
33
+ preflight = client.options(
34
+ "/mcp",
35
+ headers={
36
+ "Origin": "https://chatgpt.com",
37
+ "Access-Control-Request-Method": "POST",
38
+ "Access-Control-Request-Headers": "content-type,mcp-protocol-version,mcp-method,mcp-name",
39
+ },
40
+ )
41
+
42
+ assert health.status_code == 200
43
+ assert health.json() == {
44
+ "status": "ok",
45
+ "service": "pwc-mcp",
46
+ "version": "0.2.10",
47
+ "protocol": "2025-11-25",
48
+ }
49
+ assert rejected.status_code == 403
50
+ assert preflight.status_code == 200
51
+ assert preflight.headers["access-control-allow-origin"] == "https://chatgpt.com"
52
+
53
+
54
+ def test_discovery_schema_and_bare_get_are_bounded():
55
+ app = create_app(StubCatalog(), allowed_hosts=["testserver"])
56
+
57
+ with TestClient(app) as client:
58
+ discovery = client.get("/.well-known/mcp")
59
+ schema = client.get("/docs")
60
+ bare_get = client.get("/mcp")
61
+
62
+ assert discovery.status_code == 200
63
+ assert discovery.json()["protocol_version"] == "2025-11-25"
64
+ assert discovery.json()["documentation_url"].endswith("/mcp/schema")
65
+ assert len(schema.json()["tools"]) == 21
66
+ assert bare_get.status_code == 405
67
+ assert bare_get.headers["allow"] == "POST"
68
+
69
+
70
+ def test_wildcard_browser_origin_is_rejected_at_startup():
71
+ try:
72
+ create_app(StubCatalog(), allowed_origins=["*"])
73
+ except ValueError as error:
74
+ assert "must not contain a wildcard" in str(error)
75
+ else:
76
+ raise AssertionError("wildcard Origin was accepted")
77
+
78
+
79
+ def test_rate_limit_is_content_free_and_returns_retry_metadata():
80
+ app = create_app(
81
+ StubCatalog(),
82
+ allowed_hosts=["testserver"],
83
+ request_limit=1,
84
+ )
85
+
86
+ with TestClient(app) as client:
87
+ first = client.post("/mcp", json={})
88
+ limited = client.post("/mcp", json={})
89
+
90
+ assert first.status_code != 429
91
+ assert limited.status_code == 429
92
+ assert limited.headers["retry-after"] == "60"
93
+ assert limited.json() == {"error": "rate_limit_exceeded"}
94
+
95
+
96
+ def test_global_saturation_returns_retry_metadata():
97
+ app = create_app(
98
+ StubCatalog(),
99
+ allowed_hosts=["testserver"],
100
+ global_concurrency_limit=0,
101
+ )
102
+
103
+ with TestClient(app) as client:
104
+ response = client.post("/mcp", json={})
105
+
106
+ assert response.status_code == 503
107
+ assert response.headers["retry-after"] == "1"
108
+ assert response.json() == {"error": "server_saturated"}
109
+
110
+
111
+ def test_semantic_limit_uses_body_tool_name_when_header_disagrees():
112
+ app = create_app(
113
+ StubCatalog(),
114
+ allowed_hosts=["testserver"],
115
+ request_limit=10,
116
+ semantic_limit=1,
117
+ )
118
+ request = {
119
+ "jsonrpc": "2.0",
120
+ "id": 1,
121
+ "method": "tools/call",
122
+ "params": {
123
+ "name": "search_papers",
124
+ "arguments": {"query": "attention", "mode": "semantic"},
125
+ },
126
+ }
127
+
128
+ with TestClient(app) as client:
129
+ first = client.post("/mcp", json=request, headers={"MCP-Name": "get_task"})
130
+ limited = client.post("/mcp", json=request, headers={"MCP-Name": "get_task"})
131
+
132
+ assert first.status_code != 429
133
+ assert limited.status_code == 429
134
+
135
+
136
+ def test_request_body_limit_is_enforced_before_protocol_parsing():
137
+ app = create_app(StubCatalog(), allowed_hosts=["testserver"])
138
+
139
+ with TestClient(app) as client:
140
+ response = client.post(
141
+ "/mcp",
142
+ content=b"x" * (2 * 1024 * 1024 + 1),
143
+ headers={"Content-Type": "application/json"},
144
+ )
145
+
146
+ assert response.status_code == 413
147
+ assert response.json() == {"error": "request_too_large"}
148
+
149
+
150
+ def test_operational_telemetry_normalizes_untrusted_labels(caplog):
151
+ secret = "private unreleased project heliotrope"
152
+ app = create_app(StubCatalog(), allowed_hosts=["testserver"])
153
+
154
+ with caplog.at_level(logging.INFO), TestClient(app) as client:
155
+ client.post(
156
+ "/mcp",
157
+ json={
158
+ "jsonrpc": "2.0",
159
+ "id": 1,
160
+ "method": "tools/call",
161
+ "params": {"name": secret, "arguments": {}},
162
+ },
163
+ headers={
164
+ "MCP-Name": secret,
165
+ "MCP-Protocol-Version": secret,
166
+ },
167
+ )
168
+
169
+ assert secret not in caplog.text
170
+ assert "tool=unknown" in caplog.text
171
+ assert "protocol=unknown" in caplog.text
172
+
173
+
174
+ def test_one_http_endpoint_serves_modern_and_legacy_protocol_eras():
175
+ app = create_app(StubCatalog(), allowed_hosts=["testserver"])
176
+ modern_body = {
177
+ "jsonrpc": "2.0",
178
+ "id": 1,
179
+ "method": "server/discover",
180
+ "params": {
181
+ "_meta": {
182
+ "io.modelcontextprotocol/protocolVersion": "2026-07-28",
183
+ "io.modelcontextprotocol/clientInfo": {"name": "test", "version": "1"},
184
+ "io.modelcontextprotocol/clientCapabilities": {},
185
+ }
186
+ },
187
+ }
188
+ legacy_body = {
189
+ "jsonrpc": "2.0",
190
+ "id": 2,
191
+ "method": "initialize",
192
+ "params": {
193
+ "protocolVersion": "2025-11-25",
194
+ "capabilities": {},
195
+ "clientInfo": {"name": "test", "version": "1"},
196
+ },
197
+ }
198
+
199
+ with TestClient(app) as client:
200
+ modern = client.post(
201
+ "/mcp",
202
+ headers={
203
+ "Accept": "application/json, text/event-stream",
204
+ "MCP-Protocol-Version": "2026-07-28",
205
+ "Mcp-Method": "server/discover",
206
+ },
207
+ json=modern_body,
208
+ )
209
+ legacy = client.post(
210
+ "/mcp",
211
+ headers={"Accept": "application/json, text/event-stream"},
212
+ json=legacy_body,
213
+ )
214
+
215
+ assert modern.status_code == 200
216
+ assert modern.json()["result"]["supportedVersions"] == ["2026-07-28"]
217
+ assert modern.json()["result"]["cacheScope"] == "public"
218
+ assert legacy.status_code == 200
219
+ assert legacy.json()["result"]["protocolVersion"] == "2025-11-25"
220
+
221
+
222
+ def test_proxy_identity_trusts_only_an_exact_loopback_peer():
223
+ headers = Headers({"x-forwarded-for": "203.0.113.9"})
224
+
225
+ assert (
226
+ _client_address({"client": ("127.0.0.1", 1234)}, headers, True) == "203.0.113.9"
227
+ )
228
+ assert _client_address({"client": ("::1", 1234)}, headers, True) == "203.0.113.9"
229
+ assert _client_address({"client": ("10.0.0.2", 1234)}, headers, True) == "10.0.0.2"
230
+ assert (
231
+ _client_address({"client": ("192.168.1.2", 1234)}, headers, True)
232
+ == "192.168.1.2"
233
+ )
234
+
235
+
236
+ def test_loopback_first_party_clients_may_name_their_rate_limit_identity():
237
+ tagged = Headers({"x-pwc-mcp-client": "chat-0123abcd"})
238
+ assert (
239
+ _client_address({"client": ("127.0.0.1", 1)}, tagged, True)
240
+ == "client:chat-0123abcd"
241
+ )
242
+ assert (
243
+ _client_address({"client": ("::1", 1)}, tagged, True) == "client:chat-0123abcd"
244
+ )
245
+ # Proxied traffic always carries X-Forwarded-For, which wins over the tag.
246
+ proxied = Headers(
247
+ {"x-pwc-mcp-client": "chat-0123abcd", "x-forwarded-for": "203.0.113.9"}
248
+ )
249
+ assert _client_address({"client": ("127.0.0.1", 1)}, proxied, True) == "203.0.113.9"
250
+ # Remote peers, disabled trust, and malformed tags fall back to the address.
251
+ assert _client_address({"client": ("10.0.0.2", 1)}, tagged, True) == "10.0.0.2"
252
+ assert _client_address({"client": ("127.0.0.1", 1)}, tagged, False) == "127.0.0.1"
253
+ bad = Headers({"x-pwc-mcp-client": "spaces are/not ok"})
254
+ assert _client_address({"client": ("127.0.0.1", 1)}, bad, True) == "127.0.0.1"
255
+ long = Headers({"x-pwc-mcp-client": "a" * 129})
256
+ assert _client_address({"client": ("127.0.0.1", 1)}, long, True) == "127.0.0.1"
257
+
258
+
259
+ def test_serialized_mcp_response_limit_fails_closed():
260
+ async def oversized(_request):
261
+ return Response(b"x" * (MAX_RESPONSE_BODY_SIZE + 1))
262
+
263
+ app = ResponseSizeLimitMiddleware(
264
+ Starlette(routes=[Route("/mcp", oversized, methods=["POST"])])
265
+ )
266
+ with TestClient(app) as client:
267
+ response = client.post("/mcp")
268
+
269
+ assert response.status_code == 503
270
+ assert response.json() == {"error": "response_too_large"}
271
+
272
+
273
+ def test_hybrid_search_counts_toward_the_semantic_limit():
274
+ app = create_app(
275
+ StubCatalog(),
276
+ allowed_hosts=["testserver"],
277
+ semantic_limit=1,
278
+ )
279
+ body = {
280
+ "jsonrpc": "2.0",
281
+ "id": 1,
282
+ "method": "tools/call",
283
+ "params": {
284
+ "name": "search_papers",
285
+ "arguments": {"query": "attention", "mode": "hybrid"},
286
+ },
287
+ }
288
+
289
+ with TestClient(app) as client:
290
+ first = client.post("/mcp", json=body)
291
+ limited = client.post("/mcp", json=body)
292
+
293
+ assert first.status_code != 429
294
+ assert limited.status_code == 429
295
+
296
+
297
+ def test_omitted_search_mode_defaults_to_hybrid_and_counts_toward_the_semantic_limit():
298
+ app = create_app(
299
+ StubCatalog(),
300
+ allowed_hosts=["testserver"],
301
+ semantic_limit=1,
302
+ )
303
+ body = {
304
+ "jsonrpc": "2.0",
305
+ "id": 1,
306
+ "method": "tools/call",
307
+ "params": {"name": "search_papers", "arguments": {"query": "attention"}},
308
+ }
309
+
310
+ with TestClient(app) as client:
311
+ first = client.post("/mcp", json=body)
312
+ limited = client.post("/mcp", json=body)
313
+
314
+ assert first.status_code != 429
315
+ assert limited.status_code == 429
316
+
317
+
318
+ def test_keyword_search_does_not_count_toward_the_semantic_limit():
319
+ app = create_app(
320
+ StubCatalog(),
321
+ allowed_hosts=["testserver"],
322
+ semantic_limit=1,
323
+ )
324
+ body = {
325
+ "jsonrpc": "2.0",
326
+ "id": 1,
327
+ "method": "tools/call",
328
+ "params": {
329
+ "name": "search_papers",
330
+ "arguments": {"query": "attention", "mode": "keyword"},
331
+ },
332
+ }
333
+
334
+ with TestClient(app) as client:
335
+ first = client.post("/mcp", json=body)
336
+ second = client.post("/mcp", json=body)
337
+
338
+ assert first.status_code != 429
339
+ assert second.status_code != 429
340
+
341
+
342
+ def test_rate_limits_are_env_configurable_with_hosted_defaults(monkeypatch):
343
+ from pwc_mcp.app import (
344
+ DEFAULT_GLOBAL_CONCURRENCY_LIMIT,
345
+ RateLimitMiddleware,
346
+ _int_env,
347
+ thread_limiter_tokens,
348
+ )
349
+
350
+ assert DEFAULT_GLOBAL_CONCURRENCY_LIMIT == 128
351
+ assert thread_limiter_tokens(8) == 40 and thread_limiter_tokens(256) == 256
352
+ monkeypatch.delenv("PWC_MCP_GLOBAL_CONCURRENCY_LIMIT", raising=False)
353
+ assert _int_env("PWC_MCP_GLOBAL_CONCURRENCY_LIMIT", 128) == 128
354
+ monkeypatch.setenv("PWC_MCP_GLOBAL_CONCURRENCY_LIMIT", "256")
355
+ assert _int_env("PWC_MCP_GLOBAL_CONCURRENCY_LIMIT", 128) == 256
356
+ for invalid in ("0", "abc", "-4", "10001"):
357
+ monkeypatch.setenv("PWC_MCP_GLOBAL_CONCURRENCY_LIMIT", invalid)
358
+ try:
359
+ _int_env("PWC_MCP_GLOBAL_CONCURRENCY_LIMIT", 128)
360
+ except ValueError:
361
+ continue
362
+ raise AssertionError(invalid)
363
+
364
+ monkeypatch.setenv("PWC_MCP_REQUEST_LIMIT", "7")
365
+ monkeypatch.setenv("PWC_MCP_SEMANTIC_LIMIT", "3")
366
+ monkeypatch.setenv("PWC_MCP_CONCURRENCY_LIMIT", "2")
367
+ monkeypatch.setenv("PWC_MCP_GLOBAL_CONCURRENCY_LIMIT", "64")
368
+ app = create_app(StubCatalog(), allowed_hosts=["testserver"])
369
+ layer = app
370
+ while not isinstance(layer, RateLimitMiddleware):
371
+ layer = layer.app
372
+ assert (
373
+ layer.request_limit,
374
+ layer.semantic_limit,
375
+ layer.concurrency_limit,
376
+ layer.global_concurrency_limit,
377
+ ) == (7, 3, 2, 64)
378
+ with TestClient(app) as client:
379
+ assert client.get("/health").status_code in {200, 503}
mcp_server/tests/test_catalog.py ADDED
@@ -0,0 +1,575 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import pytest
6
+ from pwc_cli.cli import UsageError
7
+ from pwc_cli.transport import Response, ResponseError
8
+ from pwc_mcp.catalog import (
9
+ CatalogClient,
10
+ PublicPaperMiss,
11
+ cache_ttl,
12
+ near_title,
13
+ normalize_title,
14
+ )
15
+
16
+
17
+ class StubTransport:
18
+ def __init__(self, routes):
19
+ self.routes = routes
20
+ self.calls = []
21
+
22
+ def get(self, path, params=None):
23
+ self.calls.append((path, params))
24
+ value = self.routes[path]
25
+ if isinstance(value, str):
26
+ return Response(value.encode(), {"content-type": "text/markdown"})
27
+ return Response(
28
+ json.dumps(value).encode(), {"content-type": "application/json"}
29
+ )
30
+
31
+
32
+ def test_catalog_normalizes_paper_urls_and_caches_identical_reads():
33
+ transport = StubTransport(
34
+ {
35
+ "papers/1706.03762": {"id": "755", "title": "Attention Is All You Need"},
36
+ }
37
+ )
38
+ catalog = CatalogClient(transport=transport)
39
+
40
+ first = catalog.query(
41
+ ("paper", "info"),
42
+ {"paper": "https://arxiv.org/pdf/1706.03762v7.pdf", "include_resources": True},
43
+ )
44
+ second = catalog.query(
45
+ ("paper", "info"),
46
+ {
47
+ "paper": "https://huggingface.co/papers/1706.03762",
48
+ "include_resources": True,
49
+ },
50
+ )
51
+
52
+ assert first == second
53
+ assert transport.calls == [("papers/1706.03762", {"include_resources": True})]
54
+
55
+
56
+ def test_catalog_reads_one_version_bound_markdown_chunk_per_call():
57
+ version = "a" * 64
58
+
59
+ class ChunkTransport:
60
+ def __init__(self):
61
+ self.calls = []
62
+
63
+ def get(self, path, params=None):
64
+ self.calls.append((path, params))
65
+ if params["offset"] == 0:
66
+ return Response(
67
+ "α first".encode(),
68
+ {
69
+ "x-pwc-truncated": "1",
70
+ "x-pwc-content-version": version,
71
+ "x-pwc-next-offset": "8",
72
+ },
73
+ )
74
+ return Response(
75
+ b" terminal",
76
+ {
77
+ "x-pwc-truncated": "0",
78
+ "x-pwc-content-version": version,
79
+ },
80
+ )
81
+
82
+ transport = ChunkTransport()
83
+ catalog = CatalogClient(transport=transport)
84
+ first = catalog.read_paper_chunk("1706.03762", limit=65_536)
85
+ second = catalog.read_paper_chunk(
86
+ "1706.03762",
87
+ offset=first.next_offset,
88
+ content_version=first.content_version,
89
+ limit=65_536,
90
+ resolved=True,
91
+ )
92
+
93
+ assert first.markdown == "α first"
94
+ assert first.next_offset == 8
95
+ assert second.markdown == " terminal"
96
+ assert second.next_offset is None
97
+ assert transport.calls == [
98
+ (
99
+ "research/papers/1706.03762/read",
100
+ {"offset": 0, "limit": 65_536, "content_version": None},
101
+ ),
102
+ (
103
+ "research/papers/1706.03762/read",
104
+ {"offset": 8, "limit": 65_536, "content_version": version},
105
+ ),
106
+ ]
107
+
108
+
109
+ def test_catalog_caches_versioned_chunks_and_rejects_nonadvancing_offsets():
110
+ version = "b" * 64
111
+
112
+ class ChunkTransport:
113
+ def __init__(self):
114
+ self.calls = 0
115
+
116
+ def get(self, _path, _params=None):
117
+ self.calls += 1
118
+ return Response(
119
+ b"data",
120
+ {
121
+ "x-pwc-truncated": "1",
122
+ "x-pwc-content-version": version,
123
+ "x-pwc-next-offset": "4",
124
+ },
125
+ )
126
+
127
+ transport = ChunkTransport()
128
+ catalog = CatalogClient(transport=transport)
129
+ with pytest.raises(ResponseError, match="did not advance"):
130
+ catalog.read_paper_chunk(
131
+ "1706.03762",
132
+ offset=4,
133
+ content_version=version,
134
+ resolved=True,
135
+ )
136
+ assert transport.calls == 1
137
+
138
+
139
+ def test_catalog_versioned_chunk_cache_avoids_repeated_upstream_reads():
140
+ version = "c" * 64
141
+
142
+ class ChunkTransport:
143
+ def __init__(self):
144
+ self.calls = 0
145
+
146
+ def get(self, _path, _params=None):
147
+ self.calls += 1
148
+ return Response(
149
+ b"done",
150
+ {
151
+ "x-pwc-truncated": "0",
152
+ "x-pwc-content-version": version,
153
+ },
154
+ )
155
+
156
+ transport = ChunkTransport()
157
+ catalog = CatalogClient(transport=transport)
158
+ first = catalog.read_paper_chunk(
159
+ "1706.03762",
160
+ offset=8,
161
+ content_version=version,
162
+ resolved=True,
163
+ )
164
+ second = catalog.read_paper_chunk(
165
+ "1706.03762",
166
+ offset=8,
167
+ content_version=version,
168
+ resolved=True,
169
+ )
170
+
171
+ assert first == second
172
+ assert transport.calls == 1
173
+
174
+
175
+ def test_catalog_resolves_exact_titles_and_rejects_ambiguous_titles():
176
+ transport = StubTransport(
177
+ {
178
+ "papers/search": {
179
+ "results": [
180
+ {"id": "1", "arxiv_id": "1111.11111", "title": "Same Title"},
181
+ {"id": "2", "arxiv_id": "2222.22222", "title": "Same Title"},
182
+ ]
183
+ }
184
+ }
185
+ )
186
+ catalog = CatalogClient(transport=transport)
187
+
188
+ with pytest.raises(ResponseError, match="ambiguous"):
189
+ catalog.query(("paper", "lineage", "list"), {"paper": "Same Title"})
190
+
191
+
192
+ def test_catalog_resolves_pwc_urls_and_dotted_legacy_arxiv_ids():
193
+ transport = StubTransport(
194
+ {
195
+ "papers/search": {
196
+ "results": [
197
+ {
198
+ "id": "755",
199
+ "arxiv_id": "1706.03762",
200
+ "title": "Attention Is All You Need",
201
+ }
202
+ ]
203
+ },
204
+ "papers/1706.03762": {"id": "755"},
205
+ "papers/math.GT%2F0309136": {"id": "900"},
206
+ }
207
+ )
208
+ catalog = CatalogClient(transport=transport)
209
+
210
+ assert catalog.query(
211
+ ("paper", "info"),
212
+ {"paper": "https://paperswithcode.co/paper/attention-is-all-you-need"},
213
+ ) == {"id": "755"}
214
+ assert catalog.query(("paper", "info"), {"paper": "math.GT/0309136"}) == {
215
+ "id": "900"
216
+ }
217
+
218
+
219
+ def test_catalog_checks_later_search_pages_for_ambiguous_exact_titles():
220
+ class PaginatedTransport:
221
+ def get(self, path, params=None):
222
+ assert path == "papers/search"
223
+ page = params["page"]
224
+ payload = {
225
+ "results": [
226
+ {
227
+ "id": str(page),
228
+ "arxiv_id": f"1111.1111{page}",
229
+ "title": "Same Title",
230
+ }
231
+ ],
232
+ "next_page": 2 if page == 1 else None,
233
+ }
234
+ return Response(json.dumps(payload).encode(), {})
235
+
236
+ catalog = CatalogClient(transport=PaginatedTransport())
237
+
238
+ with pytest.raises(ResponseError, match="ambiguous"):
239
+ catalog.query(("paper", "lineage", "list"), {"paper": "Same Title"})
240
+
241
+
242
+ def test_catalog_fails_closed_when_paper_filters_are_not_confirmed():
243
+ transport = StubTransport({"papers/": {"results": [], "applied_filters": {}}})
244
+ catalog = CatalogClient(transport=transport)
245
+
246
+ with pytest.raises(ResponseError, match="did not confirm"):
247
+ catalog.query(("paper", "list"), {"task": "image-classification"})
248
+
249
+
250
+ def test_catalog_query_runs_cli_commands_with_per_path_cache_lifetimes():
251
+ transport = StubTransport(
252
+ {
253
+ "datasets/": {"results": [{"id": "72", "name": "ImageNet-1k"}]},
254
+ "datasets/72/evaluations/": {
255
+ "count": 1,
256
+ "results": [{"id": "1", "model_name": "A", "metrics": {"top1": 80}}],
257
+ },
258
+ }
259
+ )
260
+ catalog = CatalogClient(transport=transport)
261
+
262
+ first = catalog.query(("benchmark",), {"name": "ImageNet-1k", "limit": 5})
263
+ second = catalog.query(("benchmark",), {"name": "imagenet-1k", "limit": 5})
264
+
265
+ assert first == second
266
+ assert first["results"][0]["model_name"] == "A"
267
+ assert first["count"] == 1
268
+ # The second lookup differs only in the search text; the identical
269
+ # evaluation request is served from the shared cache.
270
+ assert [path for path, _params in transport.calls] == [
271
+ "datasets/",
272
+ "datasets/72/evaluations/",
273
+ "datasets/",
274
+ ]
275
+ assert cache_ttl("papers/search") == 60
276
+ assert cache_ttl("tasks/1/page") == 600
277
+ assert cache_ttl("datasets/72/evaluations/") == 300
278
+
279
+
280
+ def test_catalog_query_reports_cli_usage_errors_without_upstream_calls():
281
+ transport = StubTransport({})
282
+ catalog = CatalogClient(transport=transport)
283
+
284
+ with pytest.raises(UsageError, match="--limit must be 1-100"):
285
+ catalog.query(("search",), {"query": "x", "limit": 0})
286
+ with pytest.raises(UsageError, match="not a read-only"):
287
+ catalog.query(("skills", "add"), {})
288
+ assert transport.calls == []
289
+
290
+
291
+ def test_catalog_canonicalises_numeric_ids_of_arxiv_papers():
292
+ transport = StubTransport(
293
+ {
294
+ "papers/86122": {"id": "86122", "arxiv_id": "2505.18132v1"},
295
+ "papers/2505.18132": {"id": "86122", "title": "BiggerGait"},
296
+ "papers/4242": {"id": "4242", "arxiv_id": None, "source": "external"},
297
+ }
298
+ )
299
+ catalog = CatalogClient(transport=transport)
300
+
301
+ # List tools hand out numeric IDs for arXiv papers too; the Markdown route
302
+ # only knows arXiv papers by arXiv ID, so numeric references canonicalise.
303
+ assert catalog.resolve_paper("86122") == "2505.18132"
304
+ assert catalog.query(("paper", "info"), {"paper": "86122"}) == {
305
+ "id": "86122",
306
+ "title": "BiggerGait",
307
+ }
308
+ # External papers have no arXiv ID and keep their numeric reference.
309
+ assert catalog.resolve_paper("4242") == "4242"
310
+ assert transport.calls[0] == ("papers/86122", {})
311
+
312
+
313
+ def test_catalog_rejects_unsupported_urls_without_searching():
314
+ transport = StubTransport({})
315
+ catalog = CatalogClient(transport=transport)
316
+
317
+ for url in (
318
+ "https://doi.org/10.1007/s10479-024-06277-x",
319
+ "https://aclanthology.org/2026.acl-long.200/",
320
+ ):
321
+ with pytest.raises(ResponseError, match="Paper URL not supported"):
322
+ catalog.resolve_paper(url)
323
+ assert transport.calls == []
324
+
325
+
326
+ def test_catalog_distinguishes_missing_papers_from_missing_markdown():
327
+ from pwc_cli.transport import HTTPStatusError
328
+
329
+ class MissingTransport:
330
+ def __init__(self, paper_exists):
331
+ self.paper_exists = paper_exists
332
+
333
+ def get(self, path, params=None):
334
+ if path.endswith("/read"):
335
+ raise HTTPStatusError(404, "Paper Markdown was not found")
336
+ if self.paper_exists:
337
+ return Response(b'{"id": "1"}', {})
338
+ raise HTTPStatusError(404, "No paper found")
339
+
340
+ with pytest.raises(ResponseError, match="Paper not found: 2308.10195"):
341
+ CatalogClient(transport=MissingTransport(False)).read_paper_chunk(
342
+ "2308.10195", resolved=True
343
+ )
344
+ with pytest.raises(HTTPStatusError) as missing_markdown:
345
+ CatalogClient(transport=MissingTransport(True)).read_paper_chunk(
346
+ "2505.18132", resolved=True
347
+ )
348
+ assert missing_markdown.value.status == 404
349
+
350
+
351
+ def test_catalog_reports_titles_missing_from_an_empty_search_as_not_found():
352
+ # The public API answers an unknown title with an empty result list; that
353
+ # must surface as "title not found", not as a malformed response.
354
+ transport = StubTransport(
355
+ {
356
+ "papers/search": {
357
+ "next_page": None,
358
+ "previous_page": None,
359
+ "results": [],
360
+ "applied_filters": {},
361
+ }
362
+ }
363
+ )
364
+ catalog = CatalogClient(transport=transport)
365
+
366
+ with pytest.raises(ResponseError, match="Paper title not found: Dropout"):
367
+ catalog.resolve_paper("Dropout: A Simple Way to Prevent Overfitting")
368
+ # One phrase query, then one plain keyword query as the fallback.
369
+ assert [params["q"] for _path, params in transport.calls] == [
370
+ '"Dropout: A Simple Way to Prevent Overfitting"',
371
+ "Dropout: A Simple Way to Prevent Overfitting",
372
+ ]
373
+
374
+
375
+ EXACT_TITLE_ROW = {"id": "755", "arxiv_id": "1706.03762", "title": "Attention Is All You Need"}
376
+
377
+
378
+ class TitleSearchTransport:
379
+ """Keyword search whose plain results never end; the phrase query is small."""
380
+
381
+ def __init__(self, *, phrase_results, plain_has_exact):
382
+ self.phrase_results = phrase_results
383
+ self.plain_has_exact = plain_has_exact
384
+ self.calls = []
385
+
386
+ def get(self, path, params=None):
387
+ params = dict(params or {})
388
+ self.calls.append((path, params))
389
+ assert path == "papers/search"
390
+ page = params["page"]
391
+ if params["q"].startswith('"'):
392
+ body = {"results": self.phrase_results, "next_page": None}
393
+ else:
394
+ rows = [
395
+ {"id": str(page * 100 + i), "arxiv_id": f"2{page:03d}.{i:05d}", "title": f"Attention {i}"}
396
+ for i in range(100)
397
+ ]
398
+ if self.plain_has_exact and page == 1:
399
+ rows[0] = EXACT_TITLE_ROW
400
+ body = {"results": rows, "next_page": page + 1}
401
+ return Response(json.dumps(body).encode(), {"content-type": "application/json"})
402
+
403
+
404
+ def test_catalog_resolves_common_word_titles_through_one_phrase_query():
405
+ # "Attention Is All You Need" has over a thousand keyword hits; paging
406
+ # through them used to end in "Too many results" although the exact title
407
+ # was the first result.
408
+ transport = TitleSearchTransport(
409
+ phrase_results=[
410
+ EXACT_TITLE_ROW,
411
+ {"id": "9", "arxiv_id": "2010.13154", "title": "Attention is All You Need in Speech Separation"},
412
+ ],
413
+ plain_has_exact=True,
414
+ )
415
+ catalog = CatalogClient(transport=transport)
416
+
417
+ assert catalog.resolve_paper("Attention Is All You Need") == "1706.03762"
418
+ assert [params["q"] for _path, params in transport.calls] == ['"Attention Is All You Need"']
419
+
420
+
421
+ def test_catalog_keeps_exact_matches_found_before_the_page_budget_runs_out():
422
+ transport = TitleSearchTransport(phrase_results=[], plain_has_exact=True)
423
+ catalog = CatalogClient(transport=transport)
424
+
425
+ assert catalog.resolve_paper("Attention Is All You Need") == "1706.03762"
426
+ assert len(transport.calls) == 11
427
+
428
+ inconclusive = CatalogClient(
429
+ transport=TitleSearchTransport(phrase_results=[], plain_has_exact=False)
430
+ )
431
+ with pytest.raises(ResponseError, match="Too many results"):
432
+ inconclusive.resolve_paper("Attention Is All You Need")
433
+
434
+
435
+ def test_catalog_resolves_a_unique_short_name_before_the_title_colon():
436
+ # Production chat: get_paper_info("MuseTalk") failed although the catalog
437
+ # holds "MuseTalk: Real-Time High-Fidelity Video Dubbing ..." (2410.10122).
438
+ musetalk = {
439
+ "id": "90",
440
+ "arxiv_id": "2410.10122",
441
+ "title": "MuseTalk: Real-Time High-Fidelity Video Dubbing via Spatio-Temporal Sampling",
442
+ }
443
+ citing = {"id": "91", "arxiv_id": "2501.00001", "title": "Beyond MuseTalk: Faster Dubbing"}
444
+ transport = TitleSearchTransport(phrase_results=[musetalk, citing], plain_has_exact=False)
445
+ catalog = CatalogClient(transport=transport)
446
+
447
+ assert catalog.resolve_paper("MuseTalk") == "2410.10122"
448
+ assert catalog.resolve_paper("musetalk") == "2410.10122"
449
+
450
+ twins = [musetalk, {**musetalk, "id": "92", "arxiv_id": "2502.00002"}]
451
+ ambiguous = CatalogClient(
452
+ transport=TitleSearchTransport(phrase_results=twins, plain_has_exact=False)
453
+ )
454
+ with pytest.raises(ResponseError, match="Paper title is ambiguous: MuseTalk"):
455
+ ambiguous.resolve_paper("MuseTalk")
456
+
457
+
458
+ def test_catalog_resolves_a_unique_near_match_of_a_full_title():
459
+ # Production chat: get_paper_info("BEIR: A Heterogeneous Benchmark ...")
460
+ # failed although the catalog holds the arXiv spelling "Heterogenous"
461
+ # (2104.08663), which keyword search returns first.
462
+ beir = {
463
+ "id": "5505",
464
+ "arxiv_id": "2104.08663",
465
+ "title": "BEIR: A Heterogenous Benchmark for Zero-shot Evaluation of Information Retrieval Models",
466
+ }
467
+ beir_nl = {
468
+ "id": "93",
469
+ "arxiv_id": "2412.08329",
470
+ "title": "BEIR-NL: Zero-shot Information Retrieval Benchmark for the Dutch Language",
471
+ }
472
+ catalog = CatalogClient(
473
+ transport=TitleSearchTransport(phrase_results=[beir, beir_nl], plain_has_exact=False)
474
+ )
475
+
476
+ assert catalog.resolve_paper(
477
+ "BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models"
478
+ ) == "2104.08663"
479
+
480
+ twins = [beir, {**beir, "id": "94", "arxiv_id": "2502.00003"}]
481
+ ambiguous = CatalogClient(
482
+ transport=TitleSearchTransport(phrase_results=twins, plain_has_exact=False)
483
+ )
484
+ with pytest.raises(ResponseError, match="Paper title is ambiguous"):
485
+ ambiguous.resolve_paper(
486
+ "BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models"
487
+ )
488
+
489
+
490
+ def test_near_title_rejects_short_names_and_other_editions():
491
+ assert near_title(
492
+ normalize_title("BEIR: A Heterogenous Benchmark for Zero-shot Evaluation"),
493
+ normalize_title("BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation"),
494
+ )
495
+ assert not near_title(normalize_title("MuseTalks"), normalize_title("MuseTalk"))
496
+ assert not near_title(
497
+ normalize_title("The Llama 3 Herd of Language Foundation Models"),
498
+ normalize_title("The Llama 4 Herd of Language Foundation Models"),
499
+ )
500
+ assert not near_title(
501
+ normalize_title("Deep Residual Learning for Image Recognition"),
502
+ normalize_title("Deep Residual Learning for Video Recognition"),
503
+ )
504
+
505
+
506
+ def test_catalog_reads_public_metadata_and_caches_lookups_including_misses():
507
+ from pwc_cli.transport import HTTPStatusError
508
+
509
+ class PublicTransport:
510
+ def __init__(self):
511
+ self.calls = []
512
+
513
+ def get(self, path, params=None):
514
+ self.calls.append((path, params))
515
+ if path == "papers/2610.18869/public-metadata":
516
+ return Response(
517
+ json.dumps({"in_catalog": False, "title": "Found"}).encode(), {}
518
+ )
519
+ if path == "papers/public-metadata/search":
520
+ title = params["title"]
521
+ if title == "Down Title":
522
+ raise HTTPStatusError(503, "unavailable")
523
+ if title == "BERT":
524
+ raise HTTPStatusError(400, "title too short")
525
+ if title == "Some Title":
526
+ return Response(
527
+ json.dumps({"in_catalog": False, "title": "Some title"}).encode(),
528
+ {},
529
+ )
530
+ raise HTTPStatusError(404, "no exact title")
531
+ if path.startswith("papers/Down/"):
532
+ raise HTTPStatusError(503, "unavailable")
533
+ if "%20" in path or path == "papers/BERT/public-metadata":
534
+ raise HTTPStatusError(400, "titles are not identifiers")
535
+ raise HTTPStatusError(404, "Paper not found")
536
+
537
+ transport = PublicTransport()
538
+ catalog = CatalogClient(transport=transport)
539
+
540
+ assert catalog.public_paper(" 2610.18869 ")["title"] == "Found"
541
+ assert catalog.public_paper("2610.18869")["title"] == "Found"
542
+ assert catalog.public_paper("10.1/x") == PublicPaperMiss("identifier")
543
+ assert catalog.public_paper("10.1/x") == PublicPaperMiss("identifier")
544
+ # Titles the identifier route rejects are searched as exact titles.
545
+ assert catalog.public_paper("Some Title") == {
546
+ "in_catalog": False,
547
+ "title": "Some title",
548
+ "requested_title": "Some Title",
549
+ }
550
+ assert catalog.public_paper("Some Title")["title"] == "Some title"
551
+ assert catalog.public_paper("Unknown Title") == PublicPaperMiss("title")
552
+ assert catalog.public_paper("Unknown Title") == PublicPaperMiss("title")
553
+ assert catalog.public_paper("BERT") == PublicPaperMiss("unsupported")
554
+ # Upstream failures are best effort and not cached as misses.
555
+ assert catalog.public_paper("Down") == PublicPaperMiss("unavailable")
556
+ assert catalog.public_paper("Down") == PublicPaperMiss("unavailable")
557
+ assert catalog.public_paper("Down Title") == PublicPaperMiss("unavailable")
558
+ assert catalog.public_paper("Down Title") == PublicPaperMiss("unavailable")
559
+ search = "papers/public-metadata/search"
560
+ assert transport.calls == [
561
+ ("papers/2610.18869/public-metadata", None),
562
+ ("papers/10.1/x/public-metadata", None),
563
+ ("papers/Some%20Title/public-metadata", None),
564
+ (search, {"title": "Some Title"}),
565
+ ("papers/Unknown%20Title/public-metadata", None),
566
+ (search, {"title": "Unknown Title"}),
567
+ ("papers/BERT/public-metadata", None),
568
+ (search, {"title": "BERT"}),
569
+ ("papers/Down/public-metadata", None),
570
+ ("papers/Down/public-metadata", None),
571
+ ("papers/Down%20Title/public-metadata", None),
572
+ (search, {"title": "Down Title"}),
573
+ ("papers/Down%20Title/public-metadata", None),
574
+ (search, {"title": "Down Title"}),
575
+ ]
mcp_server/tests/test_cursors.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+ from pwc_mcp.cursors import CursorCodec, CursorReferenceMismatch, CursorState
5
+
6
+
7
+ def _state(*, expires_at: int = 4600) -> CursorState:
8
+ return CursorState(
9
+ reference="Attention Is All You Need",
10
+ paper="1706.03762",
11
+ source="arxiv",
12
+ content_version="a" * 64,
13
+ offset=65536,
14
+ limit=65536,
15
+ expires_at=expires_at,
16
+ )
17
+
18
+
19
+ def test_cursor_round_trip_and_replay_are_stable():
20
+ codec = CursorCodec("current-secret", now=lambda: 1000)
21
+ token = codec.encode(_state())
22
+
23
+ assert codec.decode(token, reference="Attention Is All You Need") == _state()
24
+ assert codec.decode(token, reference="Attention Is All You Need") == _state()
25
+
26
+
27
+ def test_cursor_rejects_tampering_another_reference_and_expiry():
28
+ codec = CursorCodec("current-secret", now=lambda: 1000)
29
+ token = codec.encode(_state(expires_at=1001))
30
+
31
+ with pytest.raises(ValueError, match="invalid continuation cursor"):
32
+ codec.decode(token[:-1] + ("A" if token[-1] != "A" else "B"), reference="Attention Is All You Need")
33
+ with pytest.raises(ValueError, match="invalid continuation cursor"):
34
+ codec.decode(token, reference="Another paper")
35
+
36
+ expired = CursorCodec("current-secret", now=lambda: 1002)
37
+ with pytest.raises(ValueError, match="expired continuation cursor"):
38
+ expired.decode(token, reference="Attention Is All You Need")
39
+
40
+
41
+ def test_cursor_rotation_accepts_previous_key_without_extending_expiry():
42
+ old = CursorCodec("old-secret", now=lambda: 1000)
43
+ token = old.encode(_state(expires_at=1200))
44
+ rotated = CursorCodec(
45
+ "new-secret", previous_secret="old-secret", now=lambda: 1100
46
+ )
47
+
48
+ assert rotated.decode(token, reference="Attention Is All You Need").expires_at == 1200
49
+
50
+
51
+ def test_cursor_rejects_oversized_or_invalid_state():
52
+ codec = CursorCodec("current-secret", now=lambda: 1000)
53
+
54
+ with pytest.raises(ValueError, match="invalid continuation cursor"):
55
+ codec.decode("x" * 4096, reference="paper")
56
+ with pytest.raises(ValueError, match="invalid continuation cursor"):
57
+ codec.encode(_state(expires_at=1000))
58
+ with pytest.raises(ValueError, match="cursor secret"):
59
+ CursorCodec("")
60
+
61
+
62
+ def test_cursor_for_another_reference_reports_the_mismatch_with_its_state():
63
+ codec = CursorCodec("current-secret", now=lambda: 1000)
64
+ token = codec.encode(_state())
65
+
66
+ with pytest.raises(CursorReferenceMismatch, match="invalid continuation cursor") as caught:
67
+ codec.decode(token, reference="755")
68
+ assert caught.value.state == _state()
69
+
70
+ # Tampering and expiry still win over the reference check.
71
+ expired = CursorCodec("current-secret", now=lambda: 5000)
72
+ with pytest.raises(ValueError, match="expired continuation cursor"):
73
+ expired.decode(token, reference="755")
mcp_server/tests/test_parity.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Every read-only CLI command is a tool, and every research flag a parameter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+
8
+ from mcp.client import Client
9
+ from pwc_cli import queries
10
+ from pwc_mcp.server import (
11
+ ENTITY_PARAMETERS,
12
+ MCP_ONLY_PARAMETERS,
13
+ PARAMETER_NAMES,
14
+ TOOL_COMMANDS,
15
+ build_server,
16
+ cli_options,
17
+ )
18
+ from test_server import StubCatalog
19
+
20
+ # Deliberate default divergences from the CLI, documented in README.md: the
21
+ # hosted service caps rows per response at 25 (list_benchmarks and list_tasks
22
+ # leave the row count to the server instead of the CLI's 50).
23
+ DEFAULT_EXCEPTIONS = {
24
+ ("list_benchmarks", "limit"): None,
25
+ ("list_methods", "limit"): 25,
26
+ ("list_tasks", "limit"): None,
27
+ }
28
+ # argparse placeholders for "not given"; the MCP schema spells these as null.
29
+ UNSET_DEFAULTS = (None, argparse.SUPPRESS, (), [])
30
+
31
+
32
+ def _tools():
33
+ async def exercise():
34
+ async with Client(build_server(StubCatalog())) as client:
35
+ return {tool.name: tool for tool in (await client.list_tools()).tools}
36
+
37
+ return asyncio.run(exercise())
38
+
39
+
40
+ def _enum_values(schema, root):
41
+ if "$ref" in schema:
42
+ schema = root["$defs"][schema["$ref"].rsplit("/", 1)[1]]
43
+ values = list(schema.get("enum") or [])
44
+ for branch in schema.get("anyOf") or []:
45
+ values.extend(_enum_values(branch, root))
46
+ return values
47
+
48
+
49
+ def _mcp_name(tool: str, destination: str) -> str:
50
+ if destination == "name":
51
+ return ENTITY_PARAMETERS[tool]
52
+ return PARAMETER_NAMES.get(destination, destination)
53
+
54
+
55
+ def test_every_read_only_cli_command_has_exactly_one_tool():
56
+ assert sorted(TOOL_COMMANDS.values()) == sorted(queries.READ_ONLY_COMMANDS)
57
+ assert len(set(TOOL_COMMANDS.values())) == len(TOOL_COMMANDS)
58
+
59
+
60
+ def test_every_cli_research_flag_is_a_tool_parameter_and_vice_versa():
61
+ tools = _tools()
62
+
63
+ assert set(tools) == set(TOOL_COMMANDS)
64
+ for tool, command in TOOL_COMMANDS.items():
65
+ cli = queries.query_options(command)
66
+ schema = tools[tool].input_schema
67
+ parameters = set(schema["properties"]) - MCP_ONLY_PARAMETERS.get(
68
+ tool, frozenset()
69
+ )
70
+ mapped = cli_options(tool, **dict.fromkeys(parameters))
71
+
72
+ assert set(mapped) == set(cli), tool
73
+ for destination, action in cli.items():
74
+ parameter = _mcp_name(tool, destination)
75
+ property_schema = schema["properties"][parameter]
76
+ if isinstance(action, argparse._StoreTrueAction):
77
+ assert property_schema.get("type") == "boolean", (tool, parameter)
78
+ elif action.choices and set(action.choices) != {"true", "false"}:
79
+ assert set(action.choices) <= set(
80
+ _enum_values(property_schema, schema)
81
+ ), (
82
+ tool,
83
+ parameter,
84
+ )
85
+ if not action.option_strings or destination == "name":
86
+ assert parameter in schema["required"], (tool, parameter)
87
+ # Same arguments must mean the same query through `pwc` and the MCP
88
+ # tools, so every CLI default is the tool default unless documented.
89
+ if action.default not in UNSET_DEFAULTS:
90
+ expected = DEFAULT_EXCEPTIONS.get((tool, parameter), action.default)
91
+ assert property_schema.get("default") == expected, (tool, parameter)
92
+
93
+
94
+ def test_paper_references_and_entity_names_are_required_everywhere():
95
+ tools = _tools()
96
+
97
+ for tool, entity in ENTITY_PARAMETERS.items():
98
+ assert tools[tool].input_schema["required"] == [entity]
99
+ for tool in (
100
+ "get_paper_info",
101
+ "get_paper_evaluations",
102
+ "read_paper",
103
+ "get_related_papers",
104
+ "get_paper_lineage",
105
+ ):
106
+ assert tools[tool].input_schema["required"] == ["paper"]
mcp_server/tests/test_server.py ADDED
@@ -0,0 +1,1176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+
6
+ import pytest
7
+ from mcp.client import Client
8
+ from pwc_cli.cli import UsageError
9
+ from pwc_cli.transport import HTTPStatusError, ResponseError
10
+ from pwc_mcp.catalog import PaperMarkdownChunk, PublicPaperMiss
11
+ from pwc_mcp.models import paper_detail
12
+ from pwc_mcp.server import TOOL_COMMANDS, build_server
13
+
14
+ PAPER_ROW = {
15
+ "id": "755",
16
+ "arxiv_id": "1706.03762",
17
+ "title": "Attention Is All You Need",
18
+ "authors": ["Ashish Vaswani"],
19
+ "published": "2017-06-12",
20
+ "citation_count": 190_373,
21
+ "url_abs": "https://arxiv.org/abs/1706.03762v7",
22
+ "has_official_implementation": True,
23
+ "code_repository_count": 595,
24
+ }
25
+ PAPER_PAGE = {"next_page": 2, "results": [PAPER_ROW]}
26
+ PAPER_INFO = {
27
+ **PAPER_ROW,
28
+ "abstract": "A transformer architecture.",
29
+ "url_pdf": "https://arxiv.org/pdf/1706.03762v7.pdf",
30
+ "tasks": [
31
+ {"id": "6", "name": "Machine Translation", "slug": "machine-translation"}
32
+ ],
33
+ "methods": [{"id": "1", "name": "Transformer", "slug": "transformer"}],
34
+ "repositories": [
35
+ {"url": "https://github.com/tensorflow/tensor2tensor", "is_official": True}
36
+ ],
37
+ "project_pages": ["https://example.test/transformer"],
38
+ "hf_models": ["https://huggingface.co/google-t5/t5-base"],
39
+ "hf_datasets": [],
40
+ "hf_spaces": [],
41
+ }
42
+ PAPER_EVALUATION = {
43
+ "id": "40",
44
+ "dataset_name": "WMT2014 English-German",
45
+ "task_name": "Machine Translation",
46
+ "model_name": "Transformer Big",
47
+ "metrics": {"BLEU score": 28.4},
48
+ "best_metric": "BLEU score",
49
+ "best_rank": 3,
50
+ "is_open": True,
51
+ "num_parameters": 213_000_000,
52
+ "result_url": "https://example.test/result",
53
+ }
54
+ EVALUATION_ROW = {
55
+ "id": "10",
56
+ "model_name": "ExampleNet",
57
+ "harness": "timm",
58
+ "metrics": {"Accuracy": 90.1},
59
+ "best_metric": "Accuracy",
60
+ "best_rank": 1,
61
+ "task_name": "Image Classification",
62
+ "paper_id": "755",
63
+ "paper_title": "Attention Is All You Need",
64
+ "paper_arxiv_id": "1706.03762",
65
+ "paper_published_date": "2017-06-12",
66
+ "is_open": True,
67
+ "num_parameters": 1000,
68
+ }
69
+ BENCHMARK = {
70
+ "id": "72",
71
+ "name": "ImageNet-1k",
72
+ "slug": "imagenet-1k",
73
+ "paper_count": 124,
74
+ }
75
+ TASK = {
76
+ "id": "1",
77
+ "name": "Image Classification",
78
+ "slug": "image-classification",
79
+ "description": "Assign a class to an image.",
80
+ "paper_count": 2343,
81
+ }
82
+ METHOD = {
83
+ "id": "2",
84
+ "name": "Transformer",
85
+ "slug": "transformer",
86
+ "full_name": "Transformer",
87
+ "description": "Attention-based architecture.",
88
+ "introduced_year": 2017,
89
+ "source_paper_id": "755",
90
+ "source_url": "/paper/1706.03762",
91
+ "source_title": "Attention Is All You Need",
92
+ "paper_count": 13505,
93
+ }
94
+ CONFERENCE = {"slug": "cvpr-2025", "name": "CVPR 2025", "year": 2025, "paper_count": 3}
95
+ ORGANIZATION = {"id": "4", "slug": "nvidia", "name": "NVIDIA", "paper_count": 900}
96
+ FRAMEWORK = {"id": "7", "slug": "vllm", "name": "vLLM", "platforms": ["gpu"]}
97
+ GROUPED_BENCHMARKS = {
98
+ "results": [
99
+ {
100
+ "id": "1",
101
+ "name": "Vision",
102
+ "tasks": [
103
+ {
104
+ "slug": "image-classification",
105
+ "benchmarks": [
106
+ {
107
+ "id": "72",
108
+ "name": "ImageNet-1k",
109
+ "slug": "imagenet-1k",
110
+ "evaluation_count": 124,
111
+ }
112
+ ],
113
+ }
114
+ ],
115
+ }
116
+ ]
117
+ }
118
+
119
+ PAYLOADS = {
120
+ ("search",): PAPER_PAGE,
121
+ ("paper", "list"): PAPER_PAGE,
122
+ # The recent and trending endpoints return a bare list of papers.
123
+ ("paper", "recent"): [PAPER_ROW],
124
+ ("paper", "trending"): [PAPER_ROW],
125
+ ("paper", "related"): {"results": [PAPER_ROW]},
126
+ ("paper", "evaluations"): {
127
+ "count": 1,
128
+ "page": 1,
129
+ "next_page": 2,
130
+ "results": [EVALUATION_ROW],
131
+ },
132
+ ("paper", "lineage", "list"): {
133
+ "paper": {
134
+ "id": 755,
135
+ "reference": "1706.03762",
136
+ "title": "Attention Is All You Need",
137
+ },
138
+ "predecessors": [],
139
+ "successors": [{"id": 900, "reference": "2001.00001", "title": "A Follow-up"}],
140
+ },
141
+ ("task",): {
142
+ "task": TASK,
143
+ "area": {"id": "1", "name": "Vision"},
144
+ "parents": [],
145
+ "children": [],
146
+ "benchmarks": [BENCHMARK],
147
+ "common_methods": [METHOD],
148
+ "papers": [PAPER_ROW],
149
+ "paper_count": 2343,
150
+ },
151
+ ("task", "list"): {"count": 1, "results": [TASK]},
152
+ ("method",): {"method": METHOD, "area": {"id": "1", "name": "Vision"}},
153
+ ("method", "list"): {"count": 1, "results": [METHOD]},
154
+ ("conference",): CONFERENCE,
155
+ ("conference", "list"): {"count": 1, "results": [CONFERENCE]},
156
+ ("organization",): ORGANIZATION,
157
+ ("organization", "list"): {"count": 1, "results": [ORGANIZATION]},
158
+ ("framework",): FRAMEWORK,
159
+ ("framework", "list"): {"count": 1, "results": [FRAMEWORK]},
160
+ ("benchmark",): {
161
+ "benchmark": {"id": "72", "name": "ImageNet-1k", "slug": "imagenet-1k"},
162
+ "count": 1,
163
+ "matched_count": 1,
164
+ "page": 1,
165
+ "next_page": 2,
166
+ "results": [EVALUATION_ROW],
167
+ },
168
+ ("benchmark", "list"): {"next_page": None, "results": [BENCHMARK]},
169
+ }
170
+
171
+
172
+ class StubCatalog:
173
+ """Records the CLI command and options each tool requests."""
174
+
175
+ def __init__(self):
176
+ self.queries: list[tuple[tuple[str, ...], dict]] = []
177
+ self.resolve_calls = 0
178
+ self.read_calls = []
179
+
180
+ def resolve_paper(self, paper: str):
181
+ self.resolve_calls += 1
182
+ assert paper == "1706.03762"
183
+ return paper
184
+
185
+ def read_paper_chunk(
186
+ self,
187
+ paper: str,
188
+ *,
189
+ offset: int = 0,
190
+ content_version: str | None = None,
191
+ limit: int = 65_536,
192
+ resolved: bool = False,
193
+ ):
194
+ assert paper == "1706.03762"
195
+ assert resolved is True
196
+ version = "a" * 64
197
+ assert content_version in {None, version}
198
+ self.read_calls.append((offset, content_version, limit))
199
+ raw = b"abcdefgh"
200
+ markdown = raw[offset : offset + limit].decode()
201
+ next_offset = (
202
+ offset + len(markdown) if offset + len(markdown) < len(raw) else None
203
+ )
204
+ return PaperMarkdownChunk(
205
+ paper=paper,
206
+ source="arxiv",
207
+ markdown=markdown,
208
+ content_version=version,
209
+ next_offset=next_offset,
210
+ )
211
+
212
+ def query(self, command, options):
213
+ self.queries.append((tuple(command), dict(options)))
214
+ if command == ("paper", "info"):
215
+ payload = dict(PAPER_INFO)
216
+ if options.get("include_evals"):
217
+ payload["evaluations"] = {"count": 1, "results": [PAPER_EVALUATION]}
218
+ return payload
219
+ if command == ("benchmark", "list") and (
220
+ options.get("group_by_area") or options.get("area")
221
+ ):
222
+ return GROUPED_BENCHMARKS
223
+ return PAYLOADS[tuple(command)]
224
+
225
+ def public_paper(self, reference: str):
226
+ return PublicPaperMiss("identifier")
227
+
228
+ def options(self, command):
229
+ return next(options for called, options in self.queries if called == command)
230
+
231
+
232
+ def _call(catalog, requests, **server_options):
233
+ async def exercise():
234
+ async with Client(build_server(catalog, **server_options)) as client:
235
+ results = []
236
+ for name, arguments in requests:
237
+ results.append(await client.call_tool(name, arguments))
238
+ return results
239
+
240
+ return asyncio.run(exercise())
241
+
242
+
243
+ def test_search_papers_is_a_read_only_structured_tool():
244
+ catalog = StubCatalog()
245
+
246
+ async def exercise():
247
+ async with Client(build_server(catalog)) as client:
248
+ tools = {tool.name: tool for tool in (await client.list_tools()).tools}
249
+ result = await client.call_tool(
250
+ "search_papers",
251
+ {"query": "transformer", "mode": "keyword", "limit": 1},
252
+ )
253
+ return tools, result
254
+
255
+ tools, result = asyncio.run(exercise())
256
+
257
+ assert tools["search_papers"].annotations.read_only_hint is True
258
+ assert tools["search_papers"].input_schema["properties"]["limit"]["maximum"] == 25
259
+ assert tools["search_papers"].input_schema["properties"]["mode"]["enum"] == [
260
+ "hybrid",
261
+ "keyword",
262
+ "semantic",
263
+ ]
264
+ # Same default as `pwc search --mode`.
265
+ assert (
266
+ tools["search_papers"].input_schema["properties"]["mode"]["default"] == "hybrid"
267
+ )
268
+ assert result.is_error is False
269
+ assert result.structured_content == {
270
+ "schema_version": "v1",
271
+ "items": [
272
+ {
273
+ "id": "755",
274
+ "arxiv_id": "1706.03762",
275
+ "title": "Attention Is All You Need",
276
+ "authors": ["Ashish Vaswani"],
277
+ "published": "2017-06-12",
278
+ "citation_count": 190_373,
279
+ "url": "https://arxiv.org/abs/1706.03762v7",
280
+ "has_official_implementation": True,
281
+ "code_repository_count": 595,
282
+ }
283
+ ],
284
+ "next_page": 2,
285
+ "note": None,
286
+ "available_at": None,
287
+ "data": PAPER_PAGE,
288
+ }
289
+ assert catalog.queries == [
290
+ (
291
+ ("search",),
292
+ {
293
+ "query": "transformer",
294
+ "mode": "keyword",
295
+ "page": 1,
296
+ "limit": 1,
297
+ "start_date": None,
298
+ "end_date": None,
299
+ "has_official_implementation": False,
300
+ },
301
+ )
302
+ ]
303
+
304
+
305
+ def test_search_rejects_invalid_ranges_before_calling_the_catalog():
306
+ catalog = StubCatalog()
307
+ (result,) = _call(
308
+ catalog,
309
+ [
310
+ (
311
+ "search_papers",
312
+ {
313
+ "query": "transformer",
314
+ "published_after": "2026-08-31",
315
+ "published_before": "2026-08-01",
316
+ },
317
+ )
318
+ ],
319
+ )
320
+
321
+ assert result.is_error is True
322
+ assert result.content[0].text == (
323
+ "Error executing tool search_papers: "
324
+ "published_after must be on or before published_before"
325
+ )
326
+ assert catalog.queries == []
327
+
328
+
329
+ def test_catalog_failures_do_not_expose_or_log_user_queries(caplog):
330
+ secret_query = "private unreleased project heliotrope"
331
+
332
+ class FailingCatalog(StubCatalog):
333
+ def query(self, command, options):
334
+ raise ResponseError(f"API returned invalid JSON for {secret_query}")
335
+
336
+ with caplog.at_level(logging.INFO):
337
+ (result,) = _call(
338
+ FailingCatalog(), [("search_papers", {"query": secret_query})]
339
+ )
340
+
341
+ assert result.is_error is True
342
+ assert result.content[0].text == (
343
+ "Error executing tool search_papers: "
344
+ "upstream_error: the Papers With Code catalog request failed"
345
+ )
346
+ assert secret_query not in caplog.text
347
+
348
+
349
+ def test_lookup_failures_return_the_cli_hint_without_logging_it(caplog):
350
+ class MissingCatalog(StubCatalog):
351
+ def query(self, command, options):
352
+ raise ResponseError(
353
+ "Task not found: language-modelling; closest results: Language Modeling"
354
+ )
355
+
356
+ class TransportCatalog(StubCatalog):
357
+ def query(self, command, options):
358
+ raise HTTPStatusError(404, "not found: language-modelling")
359
+
360
+ with caplog.at_level(logging.INFO):
361
+ (missing,) = _call(
362
+ MissingCatalog(), [("get_task", {"task": "language-modelling"})]
363
+ )
364
+ (transport,) = _call(
365
+ TransportCatalog(), [("get_task", {"task": "language-modelling"})]
366
+ )
367
+
368
+ assert missing.content[0].text == (
369
+ "Error executing tool get_task: "
370
+ "not_found: Task not found: language-modelling; closest results: Language Modeling"
371
+ )
372
+ assert transport.content[0].text == (
373
+ "Error executing tool get_task: not_found: the requested catalog record does not exist"
374
+ )
375
+ assert "language-modelling" not in caplog.text
376
+
377
+
378
+ def test_cli_usage_errors_are_returned_verbatim():
379
+ class StrictCatalog(StubCatalog):
380
+ def query(self, command, options):
381
+ raise UsageError("unknown metric(s): latency; available metrics: Accuracy")
382
+
383
+ (result,) = _call(
384
+ StrictCatalog(),
385
+ [("get_benchmark", {"benchmark": "imagenet-1k", "sort_metric": "latency"})],
386
+ )
387
+
388
+ assert result.is_error is True
389
+ assert result.content[0].text == (
390
+ "Error executing tool get_benchmark: "
391
+ "unknown metric(s): latency; available metrics: Accuracy"
392
+ )
393
+
394
+
395
+ def test_paper_info_and_reading_use_stable_schemas_and_opaque_continuation():
396
+ catalog = StubCatalog()
397
+
398
+ async def exercise():
399
+ async with Client(build_server(catalog, read_chunk_bytes=5)) as client:
400
+ info = await client.call_tool("get_paper_info", {"paper": "1706.03762"})
401
+ evaluated = await client.call_tool(
402
+ "get_paper_info",
403
+ {"paper": "1706.03762", "include_evaluations": True},
404
+ )
405
+ first = await client.call_tool("read_paper", {"paper": "1706.03762"})
406
+ second = await client.call_tool(
407
+ "read_paper",
408
+ {
409
+ "paper": "1706.03762",
410
+ "cursor": first.structured_content["next_cursor"],
411
+ },
412
+ )
413
+ return info, evaluated, first, second
414
+
415
+ info, evaluated, first, second = asyncio.run(exercise())
416
+
417
+ assert catalog.queries[0] == (
418
+ ("paper", "info"),
419
+ {"paper": "1706.03762", "include_resources": True, "include_evals": False},
420
+ )
421
+ paper = info.structured_content["paper"]
422
+ assert paper["title"] == "Attention Is All You Need"
423
+ assert paper["tasks"] == [
424
+ {"id": "6", "name": "Machine Translation", "slug": "machine-translation"}
425
+ ]
426
+ assert paper["repositories"][0]["is_official"] is True
427
+ assert paper["hf_models"] == []
428
+ assert paper["code_repository_count"] == 595
429
+ assert info.structured_content["evaluations"] is None
430
+ assert info.structured_content["data"] is None
431
+
432
+ assert catalog.queries[1][1]["include_evals"] is True
433
+ assert evaluated.structured_content["evaluation_count"] == 1
434
+ assert evaluated.structured_content["evaluations"] == [
435
+ {
436
+ "id": "40",
437
+ "benchmark": "WMT2014 English-German",
438
+ "task": "Machine Translation",
439
+ "model_name": "Transformer Big",
440
+ "harness": None,
441
+ "metrics": {"BLEU score": 28.4},
442
+ "best_metric": "BLEU score",
443
+ "best_rank": 3,
444
+ "is_open": True,
445
+ "num_parameters": 213_000_000,
446
+ "source_url": "https://example.test/result",
447
+ }
448
+ ]
449
+
450
+ assert first.structured_content["markdown"] == "abcde"
451
+ assert first.structured_content["truncated"] is True
452
+ assert first.structured_content["next_cursor"]
453
+ assert second.structured_content == {
454
+ "schema_version": "v1",
455
+ "paper": "1706.03762",
456
+ "markdown": "fgh",
457
+ "truncated": False,
458
+ "next_cursor": None,
459
+ "in_catalog": True,
460
+ "available_at": None,
461
+ }
462
+ assert catalog.resolve_calls == 1
463
+ assert catalog.read_calls == [(0, None, 5), (5, "a" * 64, 5)]
464
+
465
+
466
+ def test_paper_evaluations_are_paginated_and_compact():
467
+ catalog = StubCatalog()
468
+ (result,) = _call(
469
+ catalog,
470
+ [("get_paper_evaluations", {"paper": "1706.03762", "limit": 5})],
471
+ )
472
+
473
+ assert catalog.options(("paper", "evaluations")) == {
474
+ "paper": "1706.03762",
475
+ "page": 1,
476
+ "page_size": 5,
477
+ }
478
+ assert result.structured_content["next_page"] == 2
479
+ assert result.structured_content["data"] is None
480
+ assert (
481
+ result.structured_content["evaluations"][0]["rank_scopes"][0]["task_name"]
482
+ == "Image Classification"
483
+ )
484
+
485
+
486
+ def test_read_paper_rejects_invalid_continuation_as_an_expected_error():
487
+ (result,) = _call(
488
+ StubCatalog(),
489
+ [("read_paper", {"paper": "1706.03762", "cursor": "%%%private%%%"})],
490
+ )
491
+
492
+ assert result.is_error is True
493
+ assert result.content[0].text == (
494
+ "Error executing tool read_paper: invalid continuation cursor; pass the "
495
+ "next_cursor value from the previous read_paper result, or omit cursor to "
496
+ "start from the beginning"
497
+ )
498
+
499
+
500
+ class AliasedStubCatalog(StubCatalog):
501
+ """Resolves the numeric catalog ID and the arXiv ID to the same paper."""
502
+
503
+ def resolve_paper(self, paper: str):
504
+ self.resolve_calls += 1
505
+ return {"1706.03762": "1706.03762", "755": "1706.03762", "1810.04805": "1810.04805"}[paper]
506
+
507
+
508
+ def test_read_paper_continues_with_another_reference_to_the_same_paper():
509
+ # Agents start from the arXiv ID and continue with the numeric ID that
510
+ # list tools hand out; the cursor must follow the paper, not the spelling.
511
+ catalog = AliasedStubCatalog()
512
+
513
+ async def exercise():
514
+ async with Client(build_server(catalog, read_chunk_bytes=5)) as client:
515
+ first = await client.call_tool("read_paper", {"paper": "1706.03762"})
516
+ cursor = first.structured_content["next_cursor"]
517
+ same = await client.call_tool("read_paper", {"paper": "755", "cursor": cursor})
518
+ other = await client.call_tool("read_paper", {"paper": "1810.04805", "cursor": cursor})
519
+ return first, same, other
520
+
521
+ first, same, other = asyncio.run(exercise())
522
+
523
+ assert first.structured_content["markdown"] == "abcde"
524
+ assert same.is_error is False
525
+ assert same.structured_content["paper"] == "755"
526
+ assert same.structured_content["markdown"] == "fgh"
527
+ assert same.structured_content["next_cursor"] is None
528
+ assert other.is_error is True
529
+ assert other.content[0].text == (
530
+ "Error executing tool read_paper: continuation cursor belongs to a different "
531
+ "paper; omit cursor to start reading this paper from the beginning"
532
+ )
533
+ assert catalog.read_calls == [(0, None, 5), (5, "a" * 64, 5)]
534
+
535
+
536
+ def test_paper_listing_related_work_and_lineage_are_composable():
537
+ catalog = StubCatalog()
538
+ listed, recent, trending, related, lineage = _call(
539
+ catalog,
540
+ [
541
+ (
542
+ "list_papers",
543
+ {
544
+ "task": "machine-translation",
545
+ "authors": ["Ashish Vaswani"],
546
+ "limit": 1,
547
+ },
548
+ ),
549
+ ("list_recent_papers", {"limit": 3}),
550
+ ("list_trending_papers", {"max_age_days": 30, "min_velocity": 1.5}),
551
+ ("get_related_papers", {"paper": "1706.03762", "limit": 2}),
552
+ ("get_paper_lineage", {"paper": "1706.03762"}),
553
+ ],
554
+ )
555
+
556
+ assert catalog.options(("paper", "list")) == {
557
+ "search": None,
558
+ "task": "machine-translation",
559
+ "method": None,
560
+ "conference": None,
561
+ "framework": None,
562
+ "organization": None,
563
+ "author": ["Ashish Vaswani"],
564
+ "start_date": None,
565
+ "end_date": None,
566
+ "all_versions": False,
567
+ "order_by": "trending",
568
+ "order_dir": "desc",
569
+ "include_resources": False,
570
+ "has_official_implementation": False,
571
+ "page": 1,
572
+ "page_size": 1,
573
+ }
574
+ assert catalog.options(("paper", "recent")) == {"limit": 3}
575
+ assert catalog.options(("paper", "trending")) == {
576
+ "limit": 20,
577
+ "max_age_days": 30,
578
+ "min_velocity": 1.5,
579
+ }
580
+ assert catalog.options(("paper", "related")) == {"paper": "1706.03762", "limit": 2}
581
+ assert catalog.options(("paper", "lineage", "list")) == {"paper": "1706.03762"}
582
+ assert listed.structured_content["items"][0]["arxiv_id"] == "1706.03762"
583
+ assert recent.structured_content["next_page"] is None
584
+ assert recent.structured_content["items"][0]["id"] == "755"
585
+ assert recent.structured_content["data"] == [PAPER_ROW]
586
+ assert trending.structured_content["items"][0]["id"] == "755"
587
+ assert related.structured_content["items"][0]["id"] == "755"
588
+ assert lineage.structured_content["paper"]["reference"] == "1706.03762"
589
+ assert lineage.structured_content["successors"] == [
590
+ {"id": "900", "reference": "2001.00001", "title": "A Follow-up"}
591
+ ]
592
+
593
+
594
+ def test_taxonomy_and_benchmark_tools_return_stable_catalog_entities():
595
+ catalog = StubCatalog()
596
+
597
+ async def exercise():
598
+ async with Client(build_server(catalog)) as client:
599
+ tool_names = {tool.name for tool in (await client.list_tools()).tools}
600
+ task = await client.call_tool("get_task", {"task": "image-classification"})
601
+ method = await client.call_tool("get_method", {"method": "transformer"})
602
+ benchmarks = await client.call_tool(
603
+ "list_benchmarks", {"task": "image-classification"}
604
+ )
605
+ benchmark = await client.call_tool(
606
+ "get_benchmark",
607
+ {
608
+ "benchmark": "imagenet-1k",
609
+ "limit": 5,
610
+ "is_open": True,
611
+ "max_parameters": "4B",
612
+ "minimum_metrics": {"Accuracy": 80},
613
+ "sort_metric": "Accuracy:desc",
614
+ },
615
+ )
616
+ return tool_names, task, method, benchmarks, benchmark
617
+
618
+ tool_names, task, method, benchmarks, benchmark = asyncio.run(exercise())
619
+
620
+ assert tool_names == set(TOOL_COMMANDS)
621
+ assert len(tool_names) == 21
622
+ assert catalog.options(("task",)) == {"name": "image-classification"}
623
+ assert catalog.options(("method",)) == {"name": "transformer"}
624
+ assert catalog.options(("benchmark", "list")) == {
625
+ "search": None,
626
+ "task": "image-classification",
627
+ "include_descendants": False,
628
+ "min_eval_count": None,
629
+ "is_open": None,
630
+ "group_by_area": False,
631
+ "area": None,
632
+ "benchmarks_per_task": 3,
633
+ "order_by": None,
634
+ "order_dir": "asc",
635
+ "page": 1,
636
+ "page_size": 25,
637
+ }
638
+ assert catalog.options(("benchmark",)) == {
639
+ "name": "imagenet-1k",
640
+ "page": 1,
641
+ "limit": 5,
642
+ "is_open": True,
643
+ "max_parameters": "4B",
644
+ "require_metrics": None,
645
+ "minimum_metrics": {"Accuracy": 80.0},
646
+ "maximum_metrics": None,
647
+ "sort_metric": "Accuracy:desc",
648
+ "pareto": None,
649
+ }
650
+ assert task.structured_content["task"]["area"] == {"id": "1", "name": "Vision"}
651
+ assert task.structured_content["task"]["benchmarks"][0]["slug"] == "imagenet-1k"
652
+ assert task.structured_content["data"] is None
653
+ assert method.structured_content["method"]["introduced_year"] == 2017
654
+ assert benchmarks.structured_content["items"][0]["slug"] == "imagenet-1k"
655
+ assert benchmark.structured_content["matched_count"] == 1
656
+ assert benchmark.structured_content["next_page"] == 2
657
+ evaluation = benchmark.structured_content["evaluations"][0]
658
+ assert evaluation["model_name"] == "ExampleNet"
659
+ assert evaluation["metrics"] == {"Accuracy": 90.1}
660
+ assert evaluation["rank_scopes"] == [
661
+ {
662
+ "task_id": None,
663
+ "task_name": "Image Classification",
664
+ "task_slug": None,
665
+ "rank": 1,
666
+ }
667
+ ]
668
+ assert benchmark.structured_content["metric_directions"] == {"Accuracy": "higher"}
669
+
670
+
671
+ def test_grouped_listings_omit_pagination_and_flatten_benchmarks():
672
+ catalog = StubCatalog()
673
+ tasks, grouped_tasks, benchmarks = _call(
674
+ catalog,
675
+ [
676
+ ("list_tasks", {"area": "Vision", "level": 1}),
677
+ ("list_tasks", {"group_by_area": True}),
678
+ ("list_benchmarks", {"area": "Vision", "benchmarks_per_task": 2}),
679
+ ],
680
+ )
681
+
682
+ assert catalog.queries[0] == (
683
+ ("task", "list"),
684
+ {
685
+ "search": None,
686
+ "area": "Vision",
687
+ "level": 1,
688
+ "visible_only": False,
689
+ "group_by_area": False,
690
+ "order_by": "name",
691
+ "order_dir": "asc",
692
+ "page": 1,
693
+ "page_size": 25,
694
+ },
695
+ )
696
+ assert catalog.queries[1][1]["group_by_area"] is True
697
+ assert catalog.queries[1][1]["page_size"] is None
698
+ assert catalog.queries[2][1]["page_size"] is None
699
+ assert catalog.queries[2][1]["benchmarks_per_task"] == 2
700
+ assert tasks.structured_content == {
701
+ "schema_version": "v1",
702
+ "data": {"count": 1, "results": [TASK]},
703
+ }
704
+ assert grouped_tasks.structured_content["data"] == {"count": 1, "results": [TASK]}
705
+ assert benchmarks.structured_content["items"] == [
706
+ {
707
+ "id": "72",
708
+ "name": "ImageNet-1k",
709
+ "slug": "imagenet-1k",
710
+ "full_name": None,
711
+ "description": None,
712
+ "split": None,
713
+ "hf_url": None,
714
+ "paper_count": 124,
715
+ }
716
+ ]
717
+ assert benchmarks.structured_content["data"] == GROUPED_BENCHMARKS
718
+
719
+
720
+ def test_new_catalog_tools_return_the_cli_json_document():
721
+ catalog = StubCatalog()
722
+ requests = [
723
+ ("list_methods", {"introduced_year": 2017, "order_by": "paper_count"}),
724
+ ("get_conference", {"conference": "CVPR 2025"}),
725
+ ("list_conferences", {"year": 2025}),
726
+ ("get_organization", {"organization": "NVIDIA"}),
727
+ ("list_organizations", {"featured_only": True}),
728
+ ("get_framework", {"framework": "vLLM"}),
729
+ ("list_frameworks", {"platform": "gpu"}),
730
+ ]
731
+ results = _call(catalog, requests)
732
+
733
+ for (name, _arguments), result in zip(requests, results):
734
+ assert result.is_error is False, name
735
+ assert result.structured_content == {
736
+ "schema_version": "v1",
737
+ "data": PAYLOADS[TOOL_COMMANDS[name]],
738
+ }
739
+ assert catalog.options(("method", "list")) == {
740
+ "search": None,
741
+ "area": None,
742
+ "introduced_year": 2017,
743
+ "order_by": "paper_count",
744
+ "order_dir": "asc",
745
+ "page": 1,
746
+ "page_size": 25,
747
+ }
748
+ assert catalog.options(("conference",)) == {"name": "CVPR 2025"}
749
+ assert catalog.options(("conference", "list")) == {"year": 2025}
750
+ assert catalog.options(("organization",)) == {"name": "NVIDIA"}
751
+ assert catalog.options(("organization", "list")) == {"featured_only": True}
752
+ assert catalog.options(("framework",)) == {"name": "vLLM"}
753
+ assert catalog.options(("framework", "list")) == {
754
+ "domain": None,
755
+ "category": None,
756
+ "platform": "gpu",
757
+ }
758
+
759
+
760
+ def test_resources_expose_canonical_papers_tasks_and_benchmarks():
761
+ async def exercise():
762
+ async with Client(build_server(StubCatalog())) as client:
763
+ templates = {
764
+ template.uri_template
765
+ for template in (
766
+ await client.list_resource_templates()
767
+ ).resource_templates
768
+ }
769
+ paper = await client.read_resource("pwc://papers/1706.03762")
770
+ markdown = await client.read_resource("pwc://papers/1706.03762/markdown")
771
+ task = await client.read_resource("pwc://tasks/image-classification")
772
+ benchmark = await client.read_resource("pwc://benchmarks/imagenet-1k")
773
+ return templates, paper, markdown, task, benchmark
774
+
775
+ templates, paper, markdown, task, benchmark = asyncio.run(exercise())
776
+
777
+ assert templates == {
778
+ "pwc://papers/{paper}",
779
+ "pwc://papers/{paper}/markdown",
780
+ "pwc://tasks/{task}",
781
+ "pwc://benchmarks/{benchmark}",
782
+ }
783
+ assert '"title":"Attention Is All You Need"' in paper.contents[0].text
784
+ assert markdown.contents[0].text == "abcdefgh"
785
+ assert '"slug":"image-classification"' in task.contents[0].text
786
+ assert '"slug":"imagenet-1k"' in benchmark.contents[0].text
787
+
788
+
789
+ def test_upstream_validation_rate_limit_and_timeout_errors_are_actionable(caplog):
790
+ from pwc_cli.transport import TransportError
791
+ from pwc_mcp.server import catalog_error_message
792
+
793
+ class ErrorCatalog(StubCatalog):
794
+ def __init__(self, error):
795
+ super().__init__()
796
+ self.error = error
797
+
798
+ def query(self, command, options):
799
+ raise self.error
800
+
801
+ cases = {
802
+ HTTPStatusError(422, '{"detail":"page_size must be <= 100"}\n\x00'): (
803
+ 'invalid_argument: {"detail":"page_size must be <= 100"}'
804
+ ),
805
+ HTTPStatusError(429, "slow down"): (
806
+ "rate_limited: the Papers With Code catalog is rate limiting; retry later"
807
+ ),
808
+ TransportError("API request timed out"): (
809
+ "upstream_timeout: the Papers With Code catalog timed out"
810
+ ),
811
+ ResponseError("Paper not found: 2308.10195"): (
812
+ "not_found: Paper not found: 2308.10195"
813
+ ),
814
+ ResponseError("Paper URL not supported: https://doi.org/x; only arXiv"): (
815
+ "not_found: Paper URL not supported: https://doi.org/x; only arXiv"
816
+ ),
817
+ }
818
+ for error, expected in cases.items():
819
+ (result,) = _call(ErrorCatalog(error), [("get_task", {"task": "x"})])
820
+ assert result.is_error is True
821
+ assert result.content[0].text == f"Error executing tool get_task: {expected}"
822
+
823
+ with caplog.at_level(logging.WARNING):
824
+ assert catalog_error_message(ResponseError("API returned invalid JSON")) == (
825
+ "upstream_error: the Papers With Code catalog request failed"
826
+ )
827
+ assert "pwc-mcp generic catalog error type=ResponseError" in caplog.text
828
+ assert "invalid JSON" not in caplog.text
829
+
830
+
831
+ def test_list_benchmarks_falls_back_instead_of_rejecting_argument_combinations():
832
+ catalog = StubCatalog()
833
+ trending_search, area_with_search, area_only = _call(
834
+ catalog,
835
+ [
836
+ (
837
+ "list_benchmarks",
838
+ {"search": "COCO", "order_by": "trending", "order_direction": "desc"},
839
+ ),
840
+ ("list_benchmarks", {"area": "Vision", "search": "HDR", "limit": 10}),
841
+ ("list_benchmarks", {"area": "Vision", "limit": 10}),
842
+ ],
843
+ )
844
+
845
+ # order_by=trending needs a task; the CLI would raise a usage error.
846
+ assert catalog.queries[0][1]["order_by"] is None
847
+ assert catalog.queries[0][1]["search"] == "COCO"
848
+ assert trending_search.is_error is False
849
+ assert "Note: order_by=trending needs task; ordered by name instead." in (
850
+ trending_search.content[0].text
851
+ )
852
+ assert trending_search.structured_content["items"][0]["slug"] == "imagenet-1k"
853
+
854
+ # area cannot be combined with flat filters; the filters win.
855
+ assert catalog.queries[1][1]["area"] is None
856
+ assert catalog.queries[1][1]["search"] == "HDR"
857
+ assert catalog.queries[1][1]["page_size"] == 10
858
+ assert "area cannot be combined with filters" in area_with_search.content[0].text
859
+
860
+ # A bare limit does not conflict with grouping: the grouped listing ignores it.
861
+ assert catalog.queries[2][1]["area"] == "Vision"
862
+ assert catalog.queries[2][1]["page_size"] is None
863
+ assert "Note:" not in area_only.content[0].text
864
+ assert area_only.content[0].text == "Found 1 benchmarks."
865
+
866
+
867
+ def test_read_paper_names_an_identity_mismatch_instead_of_a_generic_failure():
868
+ class DriftingCatalog(StubCatalog):
869
+ def read_paper_chunk(self, paper, **kwargs):
870
+ chunk = super().read_paper_chunk(paper, **kwargs)
871
+ return PaperMarkdownChunk(
872
+ paper="9999.99999",
873
+ source=chunk.source,
874
+ markdown=chunk.markdown,
875
+ content_version=chunk.content_version,
876
+ next_offset=chunk.next_offset,
877
+ )
878
+
879
+ (result,) = _call(DriftingCatalog(), [("read_paper", {"paper": "1706.03762"})])
880
+
881
+ assert result.is_error is True
882
+ assert result.content[0].text == (
883
+ "Error executing tool read_paper: paper changed; restart reading from the beginning"
884
+ )
885
+
886
+
887
+ def test_paper_detail_includes_x_threads_only_with_resources():
888
+ item = {
889
+ **PAPER_INFO,
890
+ "x_threads": [
891
+ {
892
+ "url": "https://x.com/author/status/123",
893
+ "author_name": "Ada Author",
894
+ "author_handle": "author",
895
+ "text": "Introducing our paper",
896
+ },
897
+ {"author_name": "No URL"},
898
+ ],
899
+ }
900
+
901
+ threads = paper_detail(item, include_resources=True, repo_limit=5).x_threads
902
+ assert [thread.model_dump() for thread in threads] == [
903
+ {
904
+ "url": "https://x.com/author/status/123",
905
+ "author": "Ada Author",
906
+ "handle": "author",
907
+ }
908
+ ]
909
+ assert paper_detail(item, include_resources=False, repo_limit=5).x_threads == []
910
+
911
+
912
+ PUBLIC_PAPER = {
913
+ "in_catalog": False,
914
+ "catalog_id": None,
915
+ "catalog_url": None,
916
+ "note": "Not indexed on Papers With Code.",
917
+ "id": "2309.06920",
918
+ "arxiv_id": "2309.06920",
919
+ "doi": None,
920
+ "title": "A Paper Outside The Catalog",
921
+ "authors": ["Ada Lovelace"],
922
+ "abstract": "An abstract.",
923
+ "published": "2023-09-13",
924
+ "url": "https://arxiv.org/abs/2309.06920",
925
+ "pdf_url": "https://arxiv.org/pdf/2309.06920",
926
+ "hf_url": "https://huggingface.co/papers/2309.06920",
927
+ "repositories": ["https://github.com/example/outside"],
928
+ "project_pages": [],
929
+ "metadata_source": "huggingface",
930
+ "available_at": [
931
+ {"label": "arXiv", "url": "https://arxiv.org/abs/2309.06920", "kind": "abstract"},
932
+ {"label": "PDF", "url": "https://arxiv.org/pdf/2309.06920", "kind": "pdf"},
933
+ {"label": "Code", "url": "https://github.com/example/outside", "kind": "code"},
934
+ ],
935
+ }
936
+
937
+
938
+ class OutsideCatalog(StubCatalog):
939
+ """Holds only 1706.03762; knows public metadata for other references."""
940
+
941
+ def __init__(self, public=None):
942
+ super().__init__()
943
+ self.public = {"2309.06920": PUBLIC_PAPER} if public is None else public
944
+ self.public_calls = []
945
+
946
+ def resolve_paper(self, paper):
947
+ self.resolve_calls += 1
948
+ if paper in {"1706.03762", "Attention Is All You Need"}:
949
+ return "1706.03762"
950
+ if paper[0].isdigit() and "/" not in paper:
951
+ return paper
952
+ raise ResponseError(f"Paper title not found: {paper}")
953
+
954
+ def read_paper_chunk(self, paper, **options):
955
+ if paper != "1706.03762":
956
+ raise ResponseError(f"Paper not found: {paper}")
957
+ return super().read_paper_chunk(paper, **options)
958
+
959
+ def query(self, command, options):
960
+ paper = options.get("paper")
961
+ if paper is not None and self.resolve_paper(paper) != "1706.03762":
962
+ self.queries.append((tuple(command), dict(options)))
963
+ raise HTTPStatusError(404, "Paper not found")
964
+ return super().query(command, options)
965
+
966
+ def public_paper(self, reference):
967
+ self.public_calls.append(reference)
968
+ return self.public.get(reference, PublicPaperMiss("identifier"))
969
+
970
+
971
+ def test_paper_tools_fall_back_to_public_metadata_outside_the_catalog():
972
+ catalog = OutsideCatalog()
973
+ info, read, related = _call(
974
+ catalog,
975
+ [
976
+ ("get_paper_info", {"paper": "2309.06920"}),
977
+ ("read_paper", {"paper": "2309.06920"}),
978
+ ("get_related_papers", {"paper": "2309.06920", "limit": 3}),
979
+ ],
980
+ )
981
+
982
+ assert info.is_error is False
983
+ assert info.structured_content["in_catalog"] is False
984
+ note = info.structured_content["note"]
985
+ assert "not indexed on Papers With Code" in note
986
+ assert "no Papers With Code page" in note
987
+ assert "public Hugging Face Papers records" in note
988
+ assert "arXiv: https://arxiv.org/abs/2309.06920" in note
989
+ assert "not indexed on Papers With Code" in info.content[0].text
990
+ assert info.structured_content["available_at"] == PUBLIC_PAPER["available_at"]
991
+ paper = info.structured_content["paper"]
992
+ assert paper["title"] == "A Paper Outside The Catalog"
993
+ assert paper["authors"] == ["Ada Lovelace"]
994
+ assert paper["abstract"] == "An abstract."
995
+ assert paper["published"] == "2023-09-13"
996
+ assert paper["url"] == "https://arxiv.org/abs/2309.06920"
997
+ assert paper["repositories"] == [
998
+ {"url": "https://github.com/example/outside", "is_official": False}
999
+ ]
1000
+ assert paper["tasks"] == [] and paper["methods"] == []
1001
+
1002
+ assert read.is_error is False
1003
+ assert read.structured_content["in_catalog"] is False
1004
+ assert read.structured_content["truncated"] is False
1005
+ assert read.structured_content["next_cursor"] is None
1006
+ assert read.structured_content["available_at"] == PUBLIC_PAPER["available_at"]
1007
+ markdown = read.structured_content["markdown"]
1008
+ assert markdown.startswith("# A Paper Outside The Catalog\n")
1009
+ assert "not indexed on Papers With Code" in markdown
1010
+ assert "## Available at\n\n- [arXiv](https://arxiv.org/abs/2309.06920)" in markdown
1011
+ assert "- [Code](https://github.com/example/outside)" in markdown
1012
+ assert markdown.endswith("An abstract.\n")
1013
+
1014
+ assert related.is_error is False
1015
+ assert "not indexed on Papers With Code" in related.structured_content["note"]
1016
+ assert related.structured_content["available_at"] == PUBLIC_PAPER["available_at"]
1017
+ assert catalog.options(("search",)) == {
1018
+ "query": "A Paper Outside The Catalog",
1019
+ "mode": "hybrid",
1020
+ "page": 1,
1021
+ "limit": 3,
1022
+ }
1023
+
1024
+
1025
+ def test_paper_misses_without_public_metadata_keep_an_explicit_not_found():
1026
+ (info,) = _call(OutsideCatalog(public={}), [("get_paper_info", {"paper": "2108.05103"})])
1027
+
1028
+ assert info.is_error is True
1029
+ assert info.content[0].text == (
1030
+ "Error executing tool get_paper_info: not_found: the requested catalog record "
1031
+ "does not exist; it is not indexed on Papers With Code and no public Hugging "
1032
+ "Face Papers, arXiv, or Crossref record matched it"
1033
+ )
1034
+
1035
+
1036
+ def test_a_reference_the_public_metadata_api_finds_in_the_catalog_returns_the_catalog_paper():
1037
+ doi = "10.48550/arXiv.1706.03762"
1038
+ record = {**PUBLIC_PAPER, "in_catalog": True, "catalog_id": "755",
1039
+ "id": doi, "arxiv_id": "1706.03762", "doi": doi,
1040
+ "title": "Attention Is All You Need",
1041
+ "metadata_source": "paperswithcode"}
1042
+ catalog = OutsideCatalog(public={doi: record})
1043
+ info, read = _call(
1044
+ catalog,
1045
+ [("get_paper_info", {"paper": doi}), ("read_paper", {"paper": doi})],
1046
+ )
1047
+
1048
+ assert info.structured_content["in_catalog"] is True
1049
+ assert info.structured_content["paper"]["arxiv_id"] == "1706.03762"
1050
+ assert read.structured_content["in_catalog"] is True
1051
+ assert read.structured_content["paper"] == "1706.03762"
1052
+ assert read.structured_content["markdown"] == "abcdefgh"
1053
+
1054
+
1055
+ def test_a_cataloged_record_that_cannot_be_read_keeps_the_original_error():
1056
+ # The API says the reference is cataloged, but the catalog ID still misses:
1057
+ # never present a cataloged paper as outside the catalog.
1058
+ doi = "10.5555/gone"
1059
+ record = {**PUBLIC_PAPER, "in_catalog": True, "arxiv_id": "2108.05103",
1060
+ "catalog_id": "99"}
1061
+ (info, read) = _call(
1062
+ OutsideCatalog(public={doi: record}),
1063
+ [("get_paper_info", {"paper": doi}), ("read_paper", {"paper": doi})],
1064
+ )
1065
+
1066
+ assert info.is_error is True and "not_found" in info.content[0].text
1067
+ assert read.is_error is True
1068
+
1069
+
1070
+ def test_read_paper_treats_a_placeholder_cursor_as_the_beginning():
1071
+ # Production chat sent cursor="next" before any read (28 failures a week,
1072
+ # then identical retries).
1073
+ catalog = StubCatalog()
1074
+ next_read, start_read = _call(
1075
+ catalog,
1076
+ [
1077
+ ("read_paper", {"paper": "1706.03762", "cursor": "next"}),
1078
+ ("read_paper", {"paper": "1706.03762", "cursor": " Start "}),
1079
+ ],
1080
+ )
1081
+
1082
+ assert next_read.is_error is False
1083
+ assert next_read.structured_content["markdown"] == "abcdefgh"
1084
+ assert start_read.is_error is False
1085
+ assert catalog.read_calls == [(0, None, 65_536), (0, None, 65_536)]
1086
+
1087
+
1088
+ def test_get_benchmark_returns_the_leaderboard_when_a_metric_is_unknown():
1089
+ class MetricCatalog(StubCatalog):
1090
+ def query(self, command, options):
1091
+ if options.get("sort_metric") or options.get("require_metrics"):
1092
+ self.queries.append((tuple(command), dict(options)))
1093
+ raise UsageError(
1094
+ "unknown metric(s): overall; available metrics: Accuracy, F1"
1095
+ )
1096
+ return super().query(command, options)
1097
+
1098
+ catalog = MetricCatalog()
1099
+ (result,) = _call(
1100
+ catalog,
1101
+ [("get_benchmark", {"benchmark": "imagenet-1k", "sort_metric": "overall"})],
1102
+ )
1103
+
1104
+ assert result.is_error is False
1105
+ assert result.structured_content["metric_note"] == (
1106
+ "unknown metric(s): overall; available metrics: Accuracy, F1; returned the "
1107
+ "leaderboard without metric filters or metric sorting"
1108
+ )
1109
+ assert result.structured_content["evaluations"][0]["model_name"] == "ExampleNet"
1110
+ assert "available metrics: Accuracy, F1" in result.content[0].text
1111
+ retried = catalog.queries[-1][1]
1112
+ assert "sort_metric" not in retried and "require_metrics" not in retried
1113
+
1114
+
1115
+ @pytest.mark.parametrize(
1116
+ ("reason", "wording"),
1117
+ [
1118
+ ("title", "no arXiv or Crossref paper has exactly this title"),
1119
+ ("unsupported", "it was not looked up publicly"),
1120
+ ("unavailable", "the public Hugging Face Papers, arXiv, and Crossref lookup is"),
1121
+ ],
1122
+ )
1123
+ def test_paper_misses_say_what_the_public_lookup_tried(reason, wording):
1124
+ title = "A Classic Paper Title"
1125
+ catalog = OutsideCatalog(public={title: PublicPaperMiss(reason)})
1126
+
1127
+ (info,) = _call(catalog, [("get_paper_info", {"paper": title})])
1128
+
1129
+ assert info.is_error is True
1130
+ text = info.content[0].text
1131
+ assert "it is not indexed on Papers With Code and " + wording in text
1132
+ assert "no public Hugging Face Papers" not in text
1133
+
1134
+
1135
+ def test_a_title_resolved_by_its_name_says_the_full_title_differs():
1136
+ requested = "GlobalTrack: A Simple and Universal Tracker with Global Features"
1137
+ record = {
1138
+ **PUBLIC_PAPER,
1139
+ "title": "GlobalTrack: A Simple and Strong Baseline for Long-term Tracking",
1140
+ "requested_title": requested,
1141
+ }
1142
+ exact = {**PUBLIC_PAPER, "title": "Same Title.", "requested_title": "same title"}
1143
+ catalog = OutsideCatalog(public={requested: record, "same title": exact})
1144
+
1145
+ renamed, same = _call(
1146
+ catalog,
1147
+ [
1148
+ ("get_paper_info", {"paper": requested}),
1149
+ ("get_paper_info", {"paper": "same title"}),
1150
+ ],
1151
+ )
1152
+
1153
+ assert renamed.structured_content["in_catalog"] is False
1154
+ assert renamed.structured_content["note"].startswith(
1155
+ f'No paper is titled exactly "{requested}"; this is the paper with the '
1156
+ 'same name, titled "GlobalTrack: A Simple and Strong Baseline'
1157
+ )
1158
+ assert same.structured_content["note"].startswith("This paper is not indexed")
1159
+
1160
+
1161
+ @pytest.mark.parametrize(
1162
+ ("arguments", "expected"),
1163
+ [
1164
+ ({"minimum_evaluations": 0}, None),
1165
+ ({"minimum_evaluations": -3}, None),
1166
+ ({"minimum_evaluations": 2}, 2),
1167
+ ({"minimum_evaluations": 0, "group_by_area": True}, 0),
1168
+ ],
1169
+ )
1170
+ def test_list_benchmarks_never_sends_a_minimum_the_api_rejects(arguments, expected):
1171
+ catalog = StubCatalog()
1172
+
1173
+ (result,) = _call(catalog, [("list_benchmarks", arguments)])
1174
+
1175
+ assert result.is_error is False
1176
+ assert catalog.queries[0][1]["min_eval_count"] == expected
mcp_server/tests/test_skill.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from pwc_mcp.server import TOOL_COMMANDS
7
+
8
+ SKILL = Path(__file__).parents[1] / "SKILL.md"
9
+ EXPECTED_TOOLS = set(TOOL_COMMANDS)
10
+
11
+
12
+ def test_mcp_skill_has_valid_agent_skills_frontmatter() -> None:
13
+ text = SKILL.read_text(encoding="utf-8")
14
+ assert text.startswith("---\n")
15
+ frontmatter = text.split("---\n", 2)[1]
16
+
17
+ name = re.search(r"^name: ([a-z0-9-]+)$", frontmatter, re.MULTILINE)
18
+ description = re.search(r'^description: "(.+)"$', frontmatter, re.MULTILINE)
19
+ compatibility = re.search(r'^compatibility: "(.+)"$', frontmatter, re.MULTILINE)
20
+
21
+ assert name is not None and name.group(1) == "pwc-mcp"
22
+ assert description is not None and 1 <= len(description.group(1)) <= 1024
23
+ assert compatibility is not None and len(compatibility.group(1)) <= 500
24
+
25
+
26
+ def test_mcp_skill_documents_every_public_tool_without_inventing_tools() -> None:
27
+ text = SKILL.read_text(encoding="utf-8")
28
+ tools_section = text.split("## Tools\n", 1)[1].split("## Research workflow", 1)[0]
29
+ documented = set(re.findall(r"^- `([a-z_]+)\(", tools_section, re.MULTILINE))
30
+
31
+ assert documented == EXPECTED_TOOLS
32
+ assert re.search(r"Do not invent equivalent\s+tools", tools_section)
33
+
34
+
35
+ def test_mcp_skill_explains_paper_continuation_contract() -> None:
36
+ text = SKILL.read_text(encoding="utf-8")
37
+
38
+ assert "next_cursor" in text
39
+ assert "Treat the cursor as opaque" in text
40
+ assert "same `paper`" in text
41
+ assert "within one hour" in text
mcp_server/uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
standalone_cli/README.md ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pwc CLI
2
+
3
+ A lightweight, read-only client for the public Papers With Code catalog API.
4
+ Public reads are anonymous: no login or token is required.
5
+
6
+ ```bash
7
+ uv tool install ./standalone_cli
8
+ pwc search "small vision language models" --limit 10
9
+ pwc paper info 2501.01234
10
+ pwc paper info 2501.01234 --include-resources
11
+ pwc paper info 2501.01234 --include-evals
12
+ pwc paper info "Attention Is All You Need"
13
+ pwc paper read 2501.01234
14
+ pwc task --name "scene-text-recognition"
15
+ pwc task list --area Vision
16
+ pwc task list --group-by-area
17
+ pwc method --name Transformer
18
+ pwc method list --area Audio
19
+ pwc conference --name "CVPR 2025"
20
+ pwc conference list --year 2025
21
+ pwc organization --name NVIDIA
22
+ pwc organization list --featured-only
23
+ pwc framework --name vLLM
24
+ pwc framework list --platform gpu
25
+ pwc benchmark list
26
+ pwc skills add
27
+ ```
28
+
29
+ Every `PAPER` argument accepts an arXiv ID, numeric external-paper ID, or quoted
30
+ exact title. Title matching is case-insensitive and rejects ambiguity.
31
+ When `pwc paper info` or `pwc paper read` names a paper Papers With Code has not
32
+ indexed, stderr shows "Not indexed on Papers With Code — available at:" with its
33
+ public links (arXiv, Hugging Face Papers, DOI, code) and metadata from the
34
+ `papers/{reference}/public-metadata` API. Stdout and the not-found exit status
35
+ are unchanged; an arXiv or Hugging Face URL the API maps to a catalog paper is
36
+ read from the catalog.
37
+
38
+ `pwc paper info PAPER --include-evals` fetches every evaluation associated with
39
+ the resolved paper. Human-readable output adds an Evaluations Markdown table;
40
+ `--json` adds an `evaluations` object containing `count` and `results`.
41
+ Normal paper-info output includes the names of tagged organizations when present.
42
+
43
+ Task lists and benchmark lists render grouped Markdown in a terminal. Use
44
+ `--flat` for paginated tables or `--group-by-area` to force Markdown when
45
+ piping. Task, search, pagination, ordering, and open-model filters take
46
+ precedence over `--group-by-area` and select task-scoped flat output. Grouped
47
+ benchmarks include their name, full name when distinct, slug, and ID.
48
+ Task-scoped benchmark lists also include the slug and ID alongside the best
49
+ model, paper, and code repository, using aligned columns even when output is
50
+ captured by an agent or another process. Inspect one with
51
+ `pwc benchmark --name IDENTIFIER`; either identifier is accepted. Benchmark
52
+ details use an aligned table in a terminal and Markdown when piped or captured.
53
+ Other list and search output uses aligned columns in a terminal and lossless
54
+ TSV when captured.
55
+
56
+ Paper discovery commands accept `--implementation-coverage` to add
57
+ `official_implementation` and `code_repositories` columns. JSON and
58
+ `pwc paper info` always include those fields. The boolean reports whether the
59
+ catalog links an official repository to any version of the paper; `false` does
60
+ not prove that no official code exists elsewhere. `pwc search` and
61
+ `pwc paper list` accept `--has-official-implementation` and fail closed unless
62
+ the API confirms the filter.
63
+
64
+ Task lists report each task's direct paper, benchmark, and evaluation counts in
65
+ both grouped and flat output.
66
+
67
+ `pwc task --name NAME` accepts an exact task name, slug, or ID and renders the
68
+ task-page details in CLI form: area, description, hierarchy, research trends,
69
+ recommended frameworks, sister tasks, subtasks, common methods, benchmarks,
70
+ and trending papers. Direct benchmarks follow the ranking returned by the
71
+ task benchmark-trends API; the CLI does not calculate or re-sort trend scores.
72
+ Add `--json` for the complete structured payload.
73
+
74
+ Methods, conferences, organizations, and frameworks share the same detail
75
+ shape as tasks: pass an exact name, slug, or ID to `--name`. Their `list`
76
+ subcommands retain entity-specific filters such as method area, conference
77
+ year, featured organizations, and framework domain, category, or platform.
78
+ `pwc paper list` accepts exact `--task`, `--method`, `--conference`,
79
+ `--framework`, and `--organization` filters. Repeat `--author` with exact
80
+ normalized names, numeric IDs, or `@HF_USERNAME` references to require every
81
+ listed co-author. Combine these filters to require every selected identity,
82
+ tag, or catalog association. The CLI fails closed if the selected API does not
83
+ confirm that it applied every requested catalog filter.
84
+ Publication date ranges use inclusive `--start-date YYYY-MM-DD` and
85
+ `--end-date YYYY-MM-DD` bounds; the start date cannot be later than the end date.
86
+
87
+ For example, find an author's papers on a stated topic in newest-first order:
88
+
89
+ ```bash
90
+ pwc paper list --author "Kaiming He" --search drift \
91
+ --order-by date_published --order-dir desc
92
+ ```
93
+
94
+ Leaderboard inspection supports numeric multi-metric selection:
95
+
96
+ ```bash
97
+ pwc benchmark --name coco-val2017 --require-metrics mAP,FPS
98
+ pwc benchmark --name coco-val2017 --min FPS=60 --sort mAP:desc
99
+ pwc benchmark --name coco-val2017 --pareto mAP:higher,FPS:higher
100
+ pwc benchmark --name wikitext-103 --is-open true --max-parameters 3B
101
+ ```
102
+
103
+ `--min` and `--max` are repeatable. Metric names are case-insensitive. Metric
104
+ selection scans the bounded leaderboard before applying `--limit`; it fails
105
+ explicitly rather than returning an incomplete selection if a leaderboard
106
+ exceeds the 1,000-row scan ceiling.
107
+
108
+ `--max-parameters SIZE` keeps models at or below an inclusive parameter limit.
109
+ SIZE accepts case-insensitive decimal suffixes such as `500M`, `1.5B`, and
110
+ `3B`, plus raw integers. Parameter-constrained results exclude models whose
111
+ parameter count is unknown or inconsistent across metric rows and fail closed
112
+ against APIs that cannot confirm support. Human-readable leaderboard output
113
+ includes a Parameters column; JSON retains the exact `num_parameters` integer.
114
+
115
+ `pipx install ./standalone_cli` is also supported. `PWC_API_URL` selects a
116
+ compatible versioned API origin; it defaults to
117
+ `https://paperswithcode.co/api/v1`. Run `pwc --help` for the complete read-only
118
+ command tree. The stable exit codes are 0 (success), 2 (usage), 3 (network or
119
+ server failure), and 4 (invalid response).
120
+
121
+ Install the version-matched Skill for coding agents with `pwc skills add`.
122
+ Add `--global` to use `~/.agents/skills`, `--claude` to also link it for Claude
123
+ Code, or `--dest PATH` for another harness.
124
+
125
+
126
+ ## Edit a paper with your agent
127
+
128
+ Requires a server with the paper-edit API enabled. Sign in using the browser
129
+ link and confirm the paper and code. This grants one hour of write access to
130
+ that paper; each batch publishes immediately under your HF username.
131
+
132
+ ```bash
133
+ pwc auth login --paper 123 # --no-browser for remote machines
134
+ pwc paper edit export 123 --output edits.json
135
+ # Add explicit operations to edits.json; current is reference material.
136
+ pwc paper edit preview 123 --file edits.json
137
+ pwc paper edit submit 123 --file edits.json
138
+ pwc auth status --paper 123
139
+ pwc auth logout --paper 123
140
+ ```
141
+
142
+ For example, add the following to the exported `operations` array, substituting
143
+ real existing task/dataset/metric identifiers. A metrics dictionary lets you
144
+ submit multiple scores without repeating model and benchmark details:
145
+
146
+ ```json
147
+ {
148
+ "section": "evaluations",
149
+ "payload": {
150
+ "task_id": 1,
151
+ "dataset_id": 2,
152
+ "model_name": "Example model",
153
+ "methodology": "Test split, zero-shot; Table 3",
154
+ "metrics": {"Accuracy": 90.5, "F1": 88.1},
155
+ "source_url": "https://arxiv.org/abs/2601.00001"
156
+ }
157
+ }
158
+ ```
159
+
160
+ Add `evaluation_id` alongside `section` to correct a row. Other supported
161
+ sections are tasks, methods, repositories, project_pages, hf_artifacts,
162
+ x_threads, and source_url. `x_threads` takes `{"x_threads": [post URLs]}` in
163
+ display order (at most 10); link only posts in which the paper's authors or
164
+ their organization introduce the paper. Section replacements must retain unrelated entries. Evaluation
165
+ updates preserve omitted fields. Existing benchmark/metric definitions are
166
+ required; evaluation deletion and rank overrides are excluded.
167
+
168
+ Batches contain at most 50 operations and 1 MiB. Invalid or stale batches write
169
+ nothing. Retry uncertain network results with the exact same document and
170
+ idempotency key. For a stale revision, export again and reconcile before retrying.
171
+ Accounts share a 20-distinct-paper daily limit (UTC) and 30-publication-per-minute
172
+ limit across the website and CLI; admins are exempt. `pwc skills add --force`
173
+ installs the detailed agent workflow.
174
+
175
+ Credentials are stored in mode-0600 files inside the mode-0700 directory
176
+ `~/.config/pwc/edit-credentials`, keyed by API base URL and paper. Never copy
177
+ these files into prompts or repositories. `PWC_API_URL` selects the target API;
178
+ editing requires HTTPS, except on loopback development servers, and refuses
179
+ redirects. Public read commands never load edit credentials.
standalone_cli/SKILL.md ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: pwc-cli
3
+ 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`."
4
+ ---
5
+
6
+ Generated with `pwc v0.4.6`. Run `pwc skills add --force` to regenerate.
7
+
8
+ Research commands query the public [Papers With Code](https://paperswithcode.co) catalog anonymously.
9
+ Paper editing requires explicit browser authorization through `pwc auth login --paper PAPER`.
10
+ Run `pwc --help` or a nested `--help` command when the live parser and this
11
+ skill disagree; the parser is authoritative.
12
+
13
+ Use compact output for reading and discovery. Add `--json` for programmatic
14
+ filtering, joining, or schema-dependent processing.
15
+
16
+ When the user identifies an author, prefer repeatable structured
17
+ `pwc paper list --author AUTHOR` filters. Add `--search TEXT` for stated topic
18
+ terms and explicit `--order-by date_published --order-dir desc` for newest or
19
+ recent work. Author references accept an exact normalized name, numeric ID, or
20
+ `@HF_USERNAME`; repeated authors use AND semantics.
21
+
22
+ Publication date ranges are inclusive: use `--start-date YYYY-MM-DD` and
23
+ `--end-date YYYY-MM-DD` with `pwc search` or `pwc paper list`; the start date
24
+ must not be later than `--end-date`. The combined flags are
25
+ `--start-date YYYY-MM-DD --end-date YYYY-MM-DD`.
26
+
27
+ Paper discovery commands accept `--implementation-coverage` to add official
28
+ implementation status and total linked repository count columns. JSON and
29
+ `pwc paper info` always include both fields. Use
30
+ `--has-official-implementation` with `pwc search` or `pwc paper list` to require
31
+ a catalog-linked official repository; these filters fail closed if unconfirmed.
32
+
33
+ Use `pwc benchmark --name NAME --max-parameters SIZE` to keep models at or
34
+ below an inclusive parameter limit. SIZE accepts values such as `500M`, `1.5B`,
35
+ `3B`, and raw integers. Models without one consistent parameter count are
36
+ excluded from constrained results.
37
+
38
+ `PAPER` accepts a modern or legacy arXiv ID, a numeric external-paper ID, or an
39
+ exact paper title. Quote titles containing spaces. Title matching is
40
+ case-insensitive but exact; ambiguous titles fail with their matching IDs.
41
+ When `pwc paper info` or `pwc paper read` names a paper Papers With Code has
42
+ not indexed, stderr shows "Not indexed on Papers With Code — available at:"
43
+ with public links and metadata before the usual not-found error; cite those
44
+ links, not a Papers With Code page.
45
+
46
+ ## Commands
47
+
48
+ - `pwc search QUERY [--limit LIMIT] [--page PAGE] [--mode hybrid|keyword|semantic] [--start-date START_DATE] [--end-date END_DATE] [--has-official-implementation] [--implementation-coverage] [--json]` — search papers.
49
+ - `pwc paper info PAPER [--include-resources] [--include-evals] [--json]` — show paper metadata including abstract.
50
+ - `pwc paper evaluations PAPER [--page PAGE] [--page-size PAGE_SIZE] [--json]` — list one paper's benchmark evaluations.
51
+ - `pwc paper read PAPER [--json]` — print stored paper Markdown.
52
+ - `pwc paper list [--page PAGE] [--page-size PAGE_SIZE] [--search SEARCH] [--start-date START_DATE] [--end-date END_DATE] [--task TASK] [--method METHOD] [--conference CONFERENCE] [--framework FRAMEWORK] [--organization ORGANIZATION] [--author AUTHOR] [--all-versions] [--order-by trending|date_published|citation_count] [--order-dir asc|desc] [--include-resources] [--has-official-implementation] [--implementation-coverage] [--json]` — list and filter papers.
53
+ - `pwc paper recent [--limit LIMIT] [--implementation-coverage] [--json]` — list recent papers.
54
+ - `pwc paper trending [--limit LIMIT] [--max-age-days MAX_AGE_DAYS] [--min-velocity MIN_VELOCITY] [--implementation-coverage] [--json]` — list trending papers.
55
+ - `pwc paper related PAPER [--limit LIMIT] [--implementation-coverage] [--json]` — list related papers.
56
+ - `pwc paper lineage list PAPER [--json]` — list predecessors and successors.
57
+ - `pwc paper edit export PAPER [--output OUTPUT]`.
58
+ - `pwc paper edit preview PAPER [--file FILE]`.
59
+ - `pwc paper edit submit PAPER [--file FILE]`.
60
+ - `pwc task [--name NAME] [--json]` — inspect or list research tasks.
61
+ - `pwc task list [--page PAGE] [--page-size PAGE_SIZE] [--search SEARCH] [--group-by-area] [--flat] [--area AREA] [--level LEVEL] [--visible-only] [--order-by name|created_at|level|paper_count] [--order-dir asc|desc] [--json]` — list and filter research tasks.
62
+ - `pwc method [--name NAME] [--json]` — inspect or list research methods.
63
+ - `pwc method list [--page PAGE] [--page-size PAGE_SIZE] [--search SEARCH] [--area AREA] [--introduced-year INTRODUCED_YEAR] [--order-by name|full_name|introduced_year|created_at|paper_count] [--order-dir asc|desc] [--json]` — list and filter research methods.
64
+ - `pwc conference [--name NAME] [--json]` — inspect or list conferences.
65
+ - `pwc conference list [--year YEAR] [--json]` — list conferences with imported papers.
66
+ - `pwc organization [--name NAME] [--json]` — inspect or list research organizations.
67
+ - `pwc organization list [--featured-only] [--json]` — list research organizations.
68
+ - `pwc framework [--name NAME] [--json]` — inspect or list research frameworks.
69
+ - `pwc framework list [--domain DOMAIN] [--category CATEGORY] [--platform PLATFORM] [--json]` — list research frameworks.
70
+ - `pwc benchmark [--name NAME] [--limit LIMIT] [--page PAGE] [--is-open true|false] [--max-parameters SIZE] [--require-metrics METRIC[,METRIC]] [--min METRIC=VALUE] [--max METRIC=VALUE] [--sort METRIC[:ASC|DESC]] [--pareto METRIC:HIGHER,METRIC:LOWER] [--json]` — inspect benchmarks.
71
+ - `pwc benchmark list [--page PAGE] [--page-size PAGE_SIZE] [--search SEARCH] [--task TASK] [--group-by-area] [--flat] [--area AREA] [--benchmarks-per-task BENCHMARKS_PER_TASK] [--include-descendants] [--min-eval-count MIN_EVAL_COUNT] [--is-open true|false] [--order-by trending|name|full_name|created_at|paper_count] [--order-dir asc|desc] [--json]` — list and filter benchmarks.
72
+ - `pwc skills add [--global] [--claude] [--dest DEST] [--force]` — install the version-matched pwc CLI Skill.
73
+ - `pwc version` — show CLI and API contract versions.
74
+ - `pwc auth login [--paper PAPER] [--no-browser]`.
75
+ - `pwc auth status [--paper PAPER]`.
76
+ - `pwc auth logout [--paper PAPER]`.
77
+
78
+ ## Research workflow
79
+
80
+ 1. Use `pwc benchmark list --task TASK` to discover active benchmarks, then
81
+ `pwc benchmark --name NAME` to inspect a leaderboard. Add
82
+ `--max-parameters SIZE` when model size is part of the request.
83
+ 2. Use `pwc paper info` to inspect promising results. Add
84
+ `--include-resources` when repositories, project pages, or Hugging Face
85
+ artifacts matter.
86
+ 3. Use exact `pwc paper list --author`, `--task`, `--method`, `--conference`,
87
+ `--framework`, and `--organization` filters for known identities or catalog
88
+ associations. Combine them to require every association; do not substitute
89
+ a keyword search. Add `--search` for title or abstract topic terms.
90
+ 4. Use `pwc search` for broader discovery, then `pwc paper read` for primary
91
+ evidence.
92
+ 5. Expand the literature with `pwc paper related` and use
93
+ `pwc paper lineage list` when model or method ancestry matters.
94
+ 6. Preserve paper titles, identifiers, and URLs so claims remain traceable.
95
+
96
+ ## Output and limits
97
+
98
+ - Interactive output is optimized for people. Benchmark lists remain aligned
99
+ when captured; other captured list output uses lossless TSV. Add `--json` for
100
+ structured agent or script consumption.
101
+ - Stable exit codes are `0` success, `2` invalid usage, `3` network/server
102
+ failure, and `4` invalid API response.
103
+ - `PWC_API_URL` may select another compatible v1 endpoint. The default is
104
+ `https://paperswithcode.co/api/v1`.
105
+ - Catalog-filtered paper lists fail closed unless the server confirms every
106
+ requested filter; never treat results from an older server as filtered.
107
+ - Parameter-filtered benchmark details fail closed unless the server confirms
108
+ parameter-filter support and every returned model satisfies the limit.
109
+
110
+ The research commands contain no authentication, catalog mutation, ingestion,
111
+ publication, image, embedding, CRON, or infrastructure-maintenance operations.
112
+
113
+
114
+ ## Editing one paper
115
+
116
+ Use this workflow only when the user requests edits. Research commands remain anonymous.
117
+
118
+ 1. Run `pwc auth login --paper PAPER`. Give the user the browser link and code;
119
+ they sign in with Hugging Face and authorize that paper for one hour. Use
120
+ `--no-browser` on a remote machine. Never extract, print, or copy credentials.
121
+ 2. Run `pwc paper edit export PAPER --output edits.json` (the output must not
122
+ already exist). It includes paper_id, version, idempotency_key, empty operations,
123
+ and current reference data. Change operations, not current. Preserve the
124
+ exported version and retry key. Documents may contain at most 50 operations
125
+ and 1 MiB of JSON.
126
+ 3. Add operations of the form `{"section":"tasks","payload":{"task_ids":[1,2]}}`.
127
+ A section operation explicitly replaces that section; preserve unrelated links
128
+ and tags. Omitted sections stay unchanged. Allowed sections and payload keys:
129
+ tasks/task_ids, methods/method_ids, repositories/repositories,
130
+ project_pages/project_pages, hf_artifacts/hf_models+hf_datasets+hf_spaces,
131
+ x_threads/x_threads, source_url/source_url (external papers only), and
132
+ evaluations. Repository/project-page entries contain url and is_official.
133
+ x_threads is a list of x.com or twitter.com post URLs in display order (at
134
+ most 10). Link only posts in which the paper's authors or their organization
135
+ introduce the paper, never outside accounts; the server captures each post's
136
+ preview and rejects deleted or protected posts.
137
+ 4. For evaluations, an operation without evaluation_id creates a row. Include
138
+ task_id, dataset_id, metrics (a dictionary of existing metric names to scores),
139
+ model_name, and the evaluation setup where available. One row can contain
140
+ multiple metrics. Add evaluation_id to correct an existing row on this paper;
141
+ omitted update fields stay unchanged. Use source_url and methodology to cite
142
+ precise evidence when available; source references remain optional. Existing
143
+ benchmark/task/metric definitions are required. Do not invent missing IDs or
144
+ create benchmarks. Report unsupported results to the user. No evaluation
145
+ deletion, paper identity changes, organization edits, or rank overrides.
146
+ 5. Run `pwc paper edit preview PAPER --file edits.json`, inspect the before/after
147
+ changes, then `pwc paper edit submit PAPER --file edits.json`. Publication is
148
+ immediate: no per-batch browser approval. Respect the user's requested scope.
149
+ A batch succeeds completely or makes no changes. The response includes status
150
+ published and a link to the paper's history.
151
+ 6. Retry an uncertain network result using the exact same document and retry key.
152
+ A 409 means changed data or a reused key with different edits. Fetch a fresh
153
+ export, preserve others' edits, and reconcile. Ask the user if the same score
154
+ has conflicting corrections. Once you intentionally revise a previously
155
+ submitted batch, use a fresh export/key. A 403 may mean expired authorization,
156
+ suspension, or insufficient scope: read the error; do not broaden access.
157
+ A 429 indicates the shared account limit: 20 distinct papers per UTC day and
158
+ 30 publications per minute. Do not work around account limits.
159
+ 7. `pwc auth status --paper PAPER` shows local expiry metadata (revocation is
160
+ checked by the server on use). `pwc auth logout --paper PAPER` revokes access.
161
+ If authorization expires, keep the prepared file and request browser login
162
+ again. Renewing authorization does not require discarding a valid edit document.
163
+
164
+ Credentials are stored in owner-only files under ~/.config/pwc/edit-credentials,
165
+ separately for each API base URL and paper. They never belong in a prompt, edit
166
+ file, repository, or command argument. The CLI does not follow edit redirects.
standalone_cli/entrypoint.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from pwc_cli.cli import main
2
+
3
+ raise SystemExit(main())
standalone_cli/install.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Install one checksum-verified prebuilt pwc CLI artifact."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import http.client
9
+ import json
10
+ import os
11
+ import platform
12
+ import re
13
+ import shlex
14
+ import shutil
15
+ import stat
16
+ import sys
17
+ import tempfile
18
+ import urllib.error
19
+ import urllib.request
20
+ from pathlib import Path
21
+
22
+ DEFAULT_RELEASE_ORIGIN = "https://github.com/huggingface/pwc-cli/releases/download"
23
+ DEFAULT_LATEST_RELEASE_URL = (
24
+ "https://api.github.com/repos/huggingface/pwc-cli/releases/latest"
25
+ )
26
+ VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
27
+
28
+
29
+ class NetworkError(Exception):
30
+ """A concise installer networking failure."""
31
+
32
+
33
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
34
+ parser = argparse.ArgumentParser()
35
+ parser.add_argument(
36
+ "--version",
37
+ help="install an explicit release version instead of the latest release",
38
+ )
39
+ parser.add_argument("--prefix", type=Path, default=Path.home() / ".local")
40
+ parser.add_argument(
41
+ "--release-origin",
42
+ default=DEFAULT_RELEASE_ORIGIN,
43
+ )
44
+ parser.add_argument(
45
+ "--latest-release-url",
46
+ default=DEFAULT_LATEST_RELEASE_URL,
47
+ help=argparse.SUPPRESS,
48
+ )
49
+ return parser.parse_args(argv)
50
+
51
+
52
+ def _open(url: str):
53
+ request = urllib.request.Request(
54
+ url,
55
+ headers={
56
+ "Accept": "application/vnd.github+json",
57
+ "User-Agent": "pwc-cli-installer",
58
+ },
59
+ )
60
+ try:
61
+ return urllib.request.urlopen(request, timeout=30)
62
+ except (urllib.error.URLError, TimeoutError) as error:
63
+ reason = str(getattr(error, "reason", error)).replace("\n", " ")
64
+ raise NetworkError(reason) from error
65
+
66
+
67
+ def _validate_version(version: str) -> str:
68
+ if not VERSION_RE.fullmatch(version):
69
+ raise SystemExit(f"invalid release version: {version}")
70
+ return version
71
+
72
+
73
+ def _read_response(response, maximum_bytes: int) -> bytes:
74
+ try:
75
+ contents = response.read(maximum_bytes + 1)
76
+ except (OSError, http.client.HTTPException) as error:
77
+ raise NetworkError(str(error)) from error
78
+ if len(contents) > maximum_bytes:
79
+ raise NetworkError("response was unexpectedly large")
80
+ return contents
81
+
82
+
83
+ def resolve_version(explicit_version: str | None, latest_release_url: str) -> str:
84
+ if explicit_version:
85
+ return _validate_version(explicit_version)
86
+ try:
87
+ with _open(latest_release_url) as response:
88
+ payload = json.loads(_read_response(response, 1024 * 1024))
89
+ tag = payload["tag_name"]
90
+ except NetworkError:
91
+ raise
92
+ except (KeyError, TypeError, ValueError) as error:
93
+ raise NetworkError("latest release response was invalid") from error
94
+ prefix = "pwc-cli-"
95
+ if not isinstance(tag, str) or not tag.startswith(prefix):
96
+ raise NetworkError("latest release tag was invalid")
97
+ try:
98
+ return _validate_version(tag.removeprefix(prefix))
99
+ except SystemExit as error:
100
+ raise NetworkError("latest release tag was invalid") from error
101
+
102
+
103
+ def _download(url: str, destination: Path) -> None:
104
+ with _open(url) as response, destination.open("wb") as output:
105
+ while True:
106
+ try:
107
+ chunk = response.read(1024 * 1024)
108
+ except (OSError, http.client.HTTPException) as error:
109
+ raise NetworkError(str(error)) from error
110
+ if not chunk:
111
+ break
112
+ output.write(chunk)
113
+
114
+
115
+ def _read_checksum(url: str) -> str:
116
+ with _open(url) as response:
117
+ contents = _read_response(response, 4096)
118
+ try:
119
+ checksum = contents.decode("ascii", errors="strict").split()[0]
120
+ except (IndexError, UnicodeDecodeError) as error:
121
+ raise SystemExit("artifact checksum file was invalid") from error
122
+ if len(checksum) != 64 or any(
123
+ character not in "0123456789abcdefABCDEF" for character in checksum
124
+ ):
125
+ raise SystemExit("artifact checksum file was invalid")
126
+ return checksum.lower()
127
+
128
+
129
+ def _sha256(path: Path) -> str:
130
+ digest = hashlib.sha256()
131
+ with path.open("rb") as artifact:
132
+ for chunk in iter(lambda: artifact.read(1024 * 1024), b""):
133
+ digest.update(chunk)
134
+ return digest.hexdigest()
135
+
136
+
137
+ def _install_atomically(artifact: Path, destination: Path) -> None:
138
+ destination.parent.mkdir(parents=True, exist_ok=True)
139
+ file_descriptor, temporary_name = tempfile.mkstemp(
140
+ prefix=".pwc-install-", dir=destination.parent
141
+ )
142
+ os.close(file_descriptor)
143
+ temporary = Path(temporary_name)
144
+ try:
145
+ shutil.copy2(artifact, temporary)
146
+ temporary.chmod(
147
+ temporary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
148
+ )
149
+ os.replace(temporary, destination)
150
+ finally:
151
+ temporary.unlink(missing_ok=True)
152
+
153
+
154
+ def report_path(bin_directory: Path, path_value: str | None = None) -> None:
155
+ path_value = os.environ.get("PATH", "") if path_value is None else path_value
156
+ expanded_bin = bin_directory.expanduser().resolve()
157
+ entries = {
158
+ Path(entry).expanduser().resolve()
159
+ for entry in path_value.split(os.pathsep)
160
+ if entry
161
+ }
162
+ display = (
163
+ "~/.local/bin"
164
+ if expanded_bin == (Path.home() / ".local/bin").resolve()
165
+ else str(bin_directory)
166
+ )
167
+ shell_path = (
168
+ '"$HOME/.local/bin:$PATH"'
169
+ if display == "~/.local/bin"
170
+ else f'{shlex.quote(str(expanded_bin))}:"$PATH"'
171
+ )
172
+ if expanded_bin in entries:
173
+ print(f"{display} is on PATH", flush=True)
174
+ return
175
+ print(f"warning: {display} is not on PATH", file=sys.stderr, flush=True)
176
+ print(
177
+ f"Add this to ~/.profile: export PATH={shell_path}",
178
+ file=sys.stderr,
179
+ flush=True,
180
+ )
181
+
182
+
183
+ def _print_network_error(error: NetworkError) -> None:
184
+ message = str(error).replace("\r", " ").replace("\n", " ")
185
+ print(f"pwc installer: network error: {message}", file=sys.stderr)
186
+
187
+
188
+ def main(argv: list[str] | None = None) -> int:
189
+ args = parse_args(argv)
190
+ try:
191
+ version = resolve_version(args.version, args.latest_release_url)
192
+ except NetworkError as error:
193
+ _print_network_error(error)
194
+ return 3
195
+
196
+ machine = platform.machine().lower()
197
+ system = platform.system().lower()
198
+ machine = {"amd64": "x86_64", "aarch64": "arm64"}.get(machine, machine)
199
+ supported = {
200
+ ("linux", "x86_64"),
201
+ ("darwin", "x86_64"),
202
+ ("darwin", "arm64"),
203
+ }
204
+ if (system, machine) not in supported:
205
+ raise SystemExit(f"unsupported platform: {system}-{machine}")
206
+ artifact = f"pwc-{version}-{system}-{machine}"
207
+ base = f"{args.release_origin.rstrip('/')}/pwc-cli-{version}"
208
+ try:
209
+ with tempfile.TemporaryDirectory(prefix="pwc-install-") as directory:
210
+ target = Path(directory) / artifact
211
+ _download(f"{base}/{artifact}", target)
212
+ checksum = _read_checksum(f"{base}/{artifact}.sha256")
213
+ if _sha256(target) != checksum:
214
+ raise SystemExit("artifact checksum verification failed")
215
+ destination = args.prefix / "bin" / "pwc"
216
+ _install_atomically(target, destination)
217
+ except NetworkError as error:
218
+ _print_network_error(error)
219
+ return 3
220
+ report_path(destination.parent)
221
+ os.execv(destination, [str(destination), "version"])
222
+ return 0
223
+
224
+
225
+ if __name__ == "__main__":
226
+ raise SystemExit(main())
standalone_cli/pyproject.toml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "pwc-cli"
3
+ version = "0.4.6"
4
+ description = "Papers With Code research and paper-editing CLI"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = {text = "Apache-2.0"}
8
+ dependencies = []
9
+
10
+ [project.scripts]
11
+ pwc = "pwc_cli.cli:main"
12
+
13
+ [build-system]
14
+ requires = ["hatchling"]
15
+ build-backend = "hatchling.build"
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/pwc_cli"]
standalone_cli/scripts/check_contract.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Fail CI when parser, sandbox guide, or public-skill inventory drifts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ REPOSITORY = Path(__file__).resolve().parents[2]
10
+ sys.path.insert(0, str(REPOSITORY / "standalone_cli" / "src"))
11
+
12
+ from pwc_cli.cli import build_parser # noqa: E402
13
+
14
+ EDIT_COMMANDS = ("auth login", "auth status", "auth logout", "paper edit export", "paper edit preview", "paper edit submit")
15
+
16
+ COMMANDS = (
17
+ "search",
18
+ "paper info",
19
+ "paper evaluations",
20
+ "paper read",
21
+ "paper list",
22
+ "paper recent",
23
+ "paper trending",
24
+ "paper related",
25
+ "paper lineage list",
26
+ "task",
27
+ "task list",
28
+ "method",
29
+ "method list",
30
+ "conference",
31
+ "conference list",
32
+ "organization",
33
+ "organization list",
34
+ "framework",
35
+ "framework list",
36
+ "benchmark",
37
+ "benchmark list",
38
+ "skills add",
39
+ "version",
40
+ )
41
+
42
+ RESEARCH_COMMANDS = tuple(command for command in COMMANDS if command != "skills add")
43
+
44
+
45
+ def parser_commands() -> set[str]:
46
+ parser = build_parser()
47
+ discovered: set[str] = set()
48
+
49
+ def visit(current, prefix: str = ""):
50
+ for action in current._actions:
51
+ choices = getattr(action, "choices", None)
52
+ if not isinstance(choices, dict):
53
+ continue
54
+ for name, child in choices.items():
55
+ command = f"{prefix} {name}".strip()
56
+ nested = any(
57
+ isinstance(getattr(item, "choices", None), dict)
58
+ for item in child._actions
59
+ )
60
+ if nested:
61
+ if "handler" in child._defaults:
62
+ discovered.add(command)
63
+ visit(child, command)
64
+ else:
65
+ discovered.add(command)
66
+
67
+ visit(parser)
68
+ return discovered
69
+
70
+
71
+ def main() -> int:
72
+ expected = set(COMMANDS) | set(EDIT_COMMANDS)
73
+ if parser_commands() != expected:
74
+ raise SystemExit("standalone parser command inventory drifted")
75
+ skill = (REPOSITORY / "standalone_cli" / "SKILL.md").read_text()
76
+ for command in EDIT_COMMANDS:
77
+ if f"pwc {command}" not in skill:
78
+ raise SystemExit(f"missing edit command: {command}")
79
+ for command in RESEARCH_COMMANDS:
80
+ if command == "version":
81
+ continue
82
+ invocation = f"pwc {command}"
83
+ if invocation not in skill:
84
+ raise SystemExit(f"missing read-only inventory entry: {invocation}")
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__":
89
+ raise SystemExit(main())
standalone_cli/src/pwc_cli/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """Standalone Papers With Code client."""
2
+
3
+ __version__ = "0.4.6"
4
+ API_CONTRACT_VERSION = "v1"
standalone_cli/src/pwc_cli/__main__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from pwc_cli.cli import main
2
+
3
+ raise SystemExit(main())
standalone_cli/src/pwc_cli/cli.py ADDED
The diff for this file is too large to render. See raw diff
 
standalone_cli/src/pwc_cli/edit_credentials.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Private per-origin, per-paper credential files. Secrets never enter edit documents."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import stat
8
+ import tempfile
9
+
10
+ from pwc_cli.transport import ResponseError
11
+
12
+
13
+ class CredentialStore:
14
+ def __init__(self, base_url: str):
15
+ self.base_url = base_url
16
+ self.root = Path.home() / ".config" / "pwc" / "edit-credentials"
17
+
18
+ def path(self, paper_id: int) -> Path:
19
+ key = hashlib.sha256(f"{self.base_url}:{paper_id}".encode()).hexdigest()
20
+ return self.root / f"{key}.json"
21
+
22
+ def save(self, paper_id: int, credential: dict) -> None:
23
+ self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
24
+ info = self.root.lstat()
25
+ if (
26
+ not stat.S_ISDIR(info.st_mode)
27
+ or info.st_uid != os.getuid()
28
+ or info.st_mode & 0o077
29
+ ):
30
+ raise ResponseError(
31
+ "Credential directory must be owned by you with mode 0700"
32
+ )
33
+ fd, name = tempfile.mkstemp(dir=self.root)
34
+ try:
35
+ with os.fdopen(fd, "w") as handle:
36
+ json.dump({**credential, "base_url": self.base_url}, handle)
37
+ os.replace(name, self.path(paper_id))
38
+ finally:
39
+ if os.path.exists(name):
40
+ os.unlink(name)
41
+
42
+ def load(self, paper_id: int) -> dict:
43
+ path = self.path(paper_id)
44
+ try:
45
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
46
+ except FileNotFoundError as error:
47
+ raise ResponseError(
48
+ "No authorization for this paper and API. Run pwc auth login --paper PAPER"
49
+ ) from error
50
+ with os.fdopen(fd) as handle:
51
+ info = os.fstat(handle.fileno())
52
+ if (
53
+ not stat.S_ISREG(info.st_mode)
54
+ or info.st_uid != os.getuid()
55
+ or info.st_mode & 0o077
56
+ ):
57
+ raise ResponseError(
58
+ "Credential file must be owned by you with mode 0600"
59
+ )
60
+ value = json.load(handle)
61
+ if value.get("base_url") != self.base_url or value.get("paper_id") != paper_id:
62
+ raise ResponseError("Credential origin or paper does not match")
63
+ return value
64
+
65
+ def remove(self, paper_id: int) -> None:
66
+ self.path(paper_id).unlink(missing_ok=True)
standalone_cli/src/pwc_cli/edit_transport.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Origin-bound edit transport. Never forwards a credential through a redirect."""
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.parse
6
+ import urllib.request
7
+
8
+ from pwc_cli.transport import (
9
+ HTTPStatusError,
10
+ MAX_RESPONSE_BYTES,
11
+ ResponseError,
12
+ TransportError,
13
+ )
14
+ from pwc_cli import __version__
15
+
16
+
17
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
18
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
19
+ return None
20
+
21
+
22
+ def edit_base_url(value: str) -> str:
23
+ parsed = urllib.parse.urlsplit(value)
24
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
25
+ raise ResponseError(
26
+ "Edit API URL must not contain credentials, query, or fragment"
27
+ )
28
+ if parsed.scheme != "https" and not (
29
+ parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"}
30
+ ):
31
+ raise ResponseError(
32
+ "Editing requires HTTPS (HTTP is allowed only on loopback for development)"
33
+ )
34
+ if not parsed.hostname:
35
+ raise ResponseError("Invalid edit API origin")
36
+ return value.rstrip("/")
37
+
38
+
39
+ class EditClient:
40
+ def __init__(self, base_url: str, token: str | None = None):
41
+ self.base_url = edit_base_url(base_url)
42
+ self.token = token
43
+
44
+ def request(self, method: str, path: str, payload=None):
45
+ if not path.startswith("/") or path.startswith("//") or ".." in path:
46
+ raise ResponseError("Invalid edit endpoint")
47
+ headers = {"Accept": "application/json", "User-Agent": f"pwc-cli/{__version__}"}
48
+ if self.token:
49
+ headers["Authorization"] = f"Bearer {self.token}"
50
+ data = None
51
+ if payload is not None:
52
+ data = json.dumps(payload, allow_nan=False).encode()
53
+ headers["Content-Type"] = "application/json"
54
+ request = urllib.request.Request(
55
+ self.base_url + path, method=method, data=data, headers=headers
56
+ )
57
+ try:
58
+ with urllib.request.build_opener(NoRedirect()).open(
59
+ request, timeout=60
60
+ ) as response:
61
+ body = response.read(MAX_RESPONSE_BYTES + 1)
62
+ if len(body) > MAX_RESPONSE_BYTES:
63
+ raise ResponseError("Edit response exceeded the byte limit")
64
+ return json.loads(body) if body else {}
65
+ except urllib.error.HTTPError as error:
66
+ raise HTTPStatusError(
67
+ error.code, error.read(4096).decode("utf-8", errors="replace")
68
+ ) from error
69
+ except urllib.error.URLError as error:
70
+ raise TransportError(f"Edit API request failed: {error.reason}") from error
71
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
72
+ raise ResponseError("Edit API returned invalid JSON") from error
standalone_cli/src/pwc_cli/edits.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authenticated paper edits for humans and coding agents."""
2
+
3
+ from datetime import datetime, timezone
4
+ import json
5
+ from pathlib import Path
6
+ import sys
7
+ import time
8
+ from urllib.parse import urlsplit
9
+ import webbrowser
10
+
11
+ from pwc_cli.edit_credentials import CredentialStore
12
+ from pwc_cli.edit_transport import EditClient, edit_base_url
13
+ from pwc_cli.transport import ResponseError
14
+
15
+
16
+ class EditUsageError(ValueError):
17
+ pass
18
+
19
+
20
+ def paper_id(reference, client):
21
+ from pwc_cli.cli import _resolve_paper
22
+
23
+ reference = _resolve_paper(reference, client)
24
+ if reference.isdigit():
25
+ return int(reference)
26
+ value = client.get(f"papers/{reference}").json().get("id")
27
+ if not value:
28
+ raise ResponseError("Paper response did not include its database ID")
29
+ return int(value)
30
+
31
+
32
+ def auth(args, client):
33
+ base = edit_base_url(client.base_url)
34
+ target = paper_id(args.paper, client)
35
+ store = CredentialStore(base)
36
+ if args.auth_command == "login":
37
+ api = EditClient(base)
38
+ start = api.request("POST", "/paper-edit-authorizations", {"paper_id": target})
39
+ origin = urlsplit(base)
40
+ path = start["verification_path"]
41
+ if not path.startswith("/authorize-paper-edit/") or "?" in path or "#" in path:
42
+ raise ResponseError("Invalid browser authorization path")
43
+ url = f"{origin.scheme}://{origin.netloc}{path}"
44
+ print(
45
+ f"Authorize paper {target} at {url}\nCheck that the browser shows code {start['user_code']}.\nAccess expires after one hour; edits publish immediately.",
46
+ file=sys.stderr,
47
+ )
48
+ if not args.no_browser:
49
+ webbrowser.open(url)
50
+ deadline = time.monotonic() + min(int(start["expires_in"]), 600)
51
+ while time.monotonic() < deadline:
52
+ result = api.request(
53
+ "POST",
54
+ "/paper-edit-authorizations/token",
55
+ {"device_code": start["device_code"]},
56
+ )
57
+ if result.get("status") == "authorized":
58
+ if result.get("paper_id") != target or not result.get("access_token"):
59
+ raise ResponseError(
60
+ "Authorization returned the wrong paper or no credential"
61
+ )
62
+ store.save(target, result)
63
+ print(
64
+ json.dumps(
65
+ {
66
+ "status": "authorized",
67
+ "paper_id": target,
68
+ "username": result["username"],
69
+ "expires_at": result["expires_at"],
70
+ }
71
+ )
72
+ )
73
+ return 0
74
+ if result.get("status") != "authorization_pending":
75
+ raise ResponseError("Unexpected authorization response")
76
+ time.sleep(max(1, min(int(start["interval"]), 10)))
77
+ raise EditUsageError("Browser authorization timed out. Run login again.")
78
+ saved = store.load(target)
79
+ if args.auth_command == "status":
80
+ print(
81
+ json.dumps(
82
+ {
83
+ "paper_id": target,
84
+ "api": base,
85
+ "username": saved["username"],
86
+ "expires_at": saved["expires_at"],
87
+ "expired": datetime.fromisoformat(saved["expires_at"])
88
+ <= datetime.now(timezone.utc),
89
+ }
90
+ )
91
+ )
92
+ else:
93
+ EditClient(base, saved["access_token"]).request("POST", "/paper-edit-logout")
94
+ store.remove(target)
95
+ print("Paper authorization revoked.")
96
+ return 0
97
+
98
+
99
+ def edit(args, client):
100
+ base = edit_base_url(client.base_url)
101
+ target = paper_id(args.paper, client)
102
+ saved = CredentialStore(base).load(target)
103
+ if datetime.fromisoformat(saved["expires_at"]) <= datetime.now(timezone.utc):
104
+ raise EditUsageError(
105
+ "Authorization expired. Run pwc auth login --paper PAPER again; keep your edit document."
106
+ )
107
+ api = EditClient(base, saved["access_token"])
108
+ if args.edit_command == "export":
109
+ document = api.request("GET", f"/papers/{target}/edit-document")
110
+ with Path(args.output).open("x") as handle:
111
+ json.dump(document, handle, indent=2)
112
+ handle.write("\n")
113
+ print(
114
+ f"Exported paper {target} to {args.output}. Add explicit operations; current is reference material only."
115
+ )
116
+ return 0
117
+ with Path(args.file).open("rb") as handle:
118
+ raw = handle.read(1_048_577)
119
+ if len(raw) > 1_048_576:
120
+ raise EditUsageError("Edit documents must be at most 1 MiB")
121
+ document = json.loads(raw)
122
+ if document.get("paper_id") != target:
123
+ raise EditUsageError("Document targets a different paper")
124
+ document.pop("current", None)
125
+ suffix = "/preview" if args.edit_command == "preview" else ""
126
+ result = api.request("POST", f"/papers/{target}/edits{suffix}", document)
127
+ print(json.dumps(result, indent=2))
128
+ return 0
129
+
130
+
131
+ def add_commands(commands, paper_commands):
132
+ parser = commands.add_parser("auth", help="authorize one-hour edits to one paper")
133
+ sub = parser.add_subparsers(dest="auth_command", required=True)
134
+ for name in ("login", "status", "logout"):
135
+ command = sub.add_parser(name)
136
+ command.add_argument("--paper", required=True)
137
+ if name == "login":
138
+ command.add_argument(
139
+ "--no-browser",
140
+ action="store_true",
141
+ help="print the authorization link without opening a browser",
142
+ )
143
+ command.set_defaults(handler=auth)
144
+ parser = paper_commands.add_parser(
145
+ "edit", help="export, preview, or publish an atomic paper edit"
146
+ )
147
+ sub = parser.add_subparsers(dest="edit_command", required=True)
148
+ for name in ("export", "preview", "submit"):
149
+ command = sub.add_parser(name)
150
+ command.add_argument("paper")
151
+ command.add_argument(
152
+ "--output" if name == "export" else "--file", required=True
153
+ )
154
+ command.set_defaults(handler=edit)
standalone_cli/src/pwc_cli/queries.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-process access to the CLI's read-only research queries.
2
+
3
+ Embedders such as the Papers With Code MCP server run the exact CLI handlers
4
+ through the CLI's own parser. Every flag, default, validation rule, and JSON
5
+ payload therefore stays identical to ``pwc ... --json`` without a subprocess,
6
+ stdout capture, or a second implementation of the query logic.
7
+
8
+ Each call owns its parser and result sink, so concurrent callers never share
9
+ state. Only read-only research commands are reachable; authentication, paper
10
+ editing, skill installation, and version display are refused.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ from collections.abc import Mapping
17
+ from datetime import date
18
+ from typing import Any
19
+
20
+ from pwc_cli.cli import Parser, UsageError, build_parser
21
+ from pwc_cli.transport import ResponseError
22
+
23
+ Command = tuple[str, ...]
24
+
25
+ READ_ONLY_COMMANDS: tuple[Command, ...] = (
26
+ ("search",),
27
+ ("paper", "info"),
28
+ ("paper", "evaluations"),
29
+ ("paper", "read"),
30
+ ("paper", "list"),
31
+ ("paper", "recent"),
32
+ ("paper", "trending"),
33
+ ("paper", "related"),
34
+ ("paper", "lineage", "list"),
35
+ ("task",),
36
+ ("task", "list"),
37
+ ("method",),
38
+ ("method", "list"),
39
+ ("conference",),
40
+ ("conference", "list"),
41
+ ("organization",),
42
+ ("organization", "list"),
43
+ ("framework",),
44
+ ("framework", "list"),
45
+ ("benchmark",),
46
+ ("benchmark", "list"),
47
+ )
48
+
49
+ # Flags that only shape terminal rendering. JSON output ignores them, so they
50
+ # have no in-process equivalent.
51
+ PRESENTATION_FLAGS = frozenset({"json", "implementation_coverage", "flat"})
52
+
53
+ _HIDDEN_ACTIONS = (
54
+ argparse._SubParsersAction,
55
+ argparse._HelpAction,
56
+ argparse._VersionAction,
57
+ )
58
+
59
+
60
+ class _StrictParser(Parser):
61
+ """Raise ``UsageError`` for invalid arguments instead of exiting."""
62
+
63
+ def error(self, message: str) -> None: # type: ignore[override]
64
+ raise UsageError(message)
65
+
66
+
67
+ def _command_label(command: Command) -> str:
68
+ return " ".join(("pwc", *command))
69
+
70
+
71
+ def command_parser(
72
+ command: Command, parser: argparse.ArgumentParser | None = None
73
+ ) -> argparse.ArgumentParser:
74
+ """Return the subparser that handles ``command``."""
75
+ current = parser or build_parser(parser_class=_StrictParser)
76
+ for name in command:
77
+ subparsers = next(
78
+ (
79
+ action
80
+ for action in current._actions
81
+ if isinstance(action, argparse._SubParsersAction)
82
+ ),
83
+ None,
84
+ )
85
+ if subparsers is None or name not in subparsers.choices:
86
+ raise KeyError(f"unknown command: {_command_label(command)}")
87
+ current = subparsers.choices[name]
88
+ return current
89
+
90
+
91
+ def query_options(command: Command) -> dict[str, argparse.Action]:
92
+ """Research options of one command keyed by destination.
93
+
94
+ Presentation-only flags, help, version, and nested command selectors are
95
+ excluded, leaving exactly the options an embedder must expose.
96
+ """
97
+ parser = command_parser(command)
98
+ return {
99
+ action.dest: action
100
+ for action in parser._actions
101
+ if not isinstance(action, _HIDDEN_ACTIONS)
102
+ and action.dest not in PRESENTATION_FLAGS
103
+ }
104
+
105
+
106
+ def _text(value: Any) -> str:
107
+ if isinstance(value, bool):
108
+ return "true" if value else "false"
109
+ if isinstance(value, date):
110
+ return value.isoformat()
111
+ return str(value)
112
+
113
+
114
+ def build_argv(command: Command, options: Mapping[str, Any]) -> list[str]:
115
+ """Translate structured options into the argv the CLI parser expects.
116
+
117
+ ``None`` leaves a flag at its CLI default. Booleans toggle ``store_true``
118
+ flags. Lists repeat ``append`` flags or join comma-separated values, and
119
+ mappings expand to repeated ``NAME=VALUE`` entries.
120
+ """
121
+ actions = query_options(command)
122
+ unknown = sorted(set(options) - set(actions))
123
+ if unknown:
124
+ raise UsageError(
125
+ f"unknown option(s) for {_command_label(command)}: {', '.join(unknown)}"
126
+ )
127
+ argv = list(command)
128
+ positionals: list[str] = []
129
+ for dest, action in actions.items():
130
+ value = options.get(dest)
131
+ if value is None:
132
+ continue
133
+ if not action.option_strings:
134
+ positionals.append(_text(value))
135
+ continue
136
+ flag = action.option_strings[0]
137
+ if isinstance(action, argparse._StoreTrueAction):
138
+ if value is True:
139
+ argv.append(flag)
140
+ elif value is not False:
141
+ raise UsageError(f"{flag} expects a boolean")
142
+ continue
143
+ if isinstance(value, Mapping):
144
+ value = [f"{name}={_text(item)}" for name, item in value.items()]
145
+ values = list(value) if isinstance(value, (list, tuple, set)) else [value]
146
+ if isinstance(action, argparse._AppendAction):
147
+ for item in values:
148
+ argv.extend((flag, _text(item)))
149
+ elif values:
150
+ argv.extend((flag, ",".join(_text(item) for item in values)))
151
+ if positionals:
152
+ argv.append("--")
153
+ argv.extend(positionals)
154
+ return argv
155
+
156
+
157
+ def query(command: Command, options: Mapping[str, Any], client: Any) -> Any:
158
+ """Run one read-only CLI command in-process and return its JSON ``data``.
159
+
160
+ ``client`` must provide ``get(path, params) -> Response`` like
161
+ :class:`pwc_cli.transport.Client`. Invalid options raise ``UsageError``;
162
+ catalog failures raise the CLI's ``TransportError`` or ``ResponseError``.
163
+ """
164
+ command = tuple(command)
165
+ if command not in READ_ONLY_COMMANDS:
166
+ raise UsageError(
167
+ f"{_command_label(command)} is not a read-only research command"
168
+ )
169
+ argv = build_argv(command, options)
170
+ args = build_parser(parser_class=_StrictParser).parse_args(argv)
171
+ sink: list[Any] = []
172
+ args.json = True
173
+ args.result_sink = sink
174
+ args.handler(args, client)
175
+ if len(sink) != 1:
176
+ raise ResponseError(
177
+ f"{_command_label(command)} did not produce one JSON result"
178
+ )
179
+ return sink[0]
standalone_cli/src/pwc_cli/skills.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate and install the pwc CLI skill for AI coding agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import shutil
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ from pwc_cli import __version__
12
+
13
+ SKILL_NAME = "pwc-cli"
14
+ CENTRAL_LOCAL = Path(".agents/skills")
15
+ CENTRAL_GLOBAL = Path("~/.agents/skills")
16
+ CLAUDE_LOCAL = Path(".claude/skills")
17
+ CLAUDE_GLOBAL = Path("~/.claude/skills")
18
+
19
+ _DESCRIPTION = (
20
+ "Papers With Code CLI (`pwc`) for searching and reading AI/ML papers, "
21
+ "discovering recent and trending research, finding related work and paper "
22
+ "lineage, browsing tasks, methods, conferences, organizations, frameworks, "
23
+ "and benchmark leaderboards, and submitting authenticated paper edits "
24
+ "through the public Papers With Code catalog. Use whenever the user asks to "
25
+ "find papers, survey literature, compare research, inspect an arXiv paper, "
26
+ "explore AI/ML taxonomy or conferences, discover benchmarks or "
27
+ "state-of-the-art models, or mentions Papers With Code, `pwc`, or `pwc-cli`."
28
+ )
29
+
30
+ _INTRODUCTION = """
31
+ Research commands query the public [Papers With Code](https://paperswithcode.co) catalog anonymously.
32
+ Paper editing requires explicit browser authorization through `pwc auth login --paper PAPER`.
33
+ Run `pwc --help` or a nested `--help` command when the live parser and this
34
+ skill disagree; the parser is authoritative.
35
+
36
+ Use compact output for reading and discovery. Add `--json` for programmatic
37
+ filtering, joining, or schema-dependent processing.
38
+
39
+ When the user identifies an author, prefer repeatable structured
40
+ `pwc paper list --author AUTHOR` filters. Add `--search TEXT` for stated topic
41
+ terms and explicit `--order-by date_published --order-dir desc` for newest or
42
+ recent work. Author references accept an exact normalized name, numeric ID, or
43
+ `@HF_USERNAME`; repeated authors use AND semantics.
44
+
45
+ Publication date ranges are inclusive: use `--start-date YYYY-MM-DD` and
46
+ `--end-date YYYY-MM-DD` with `pwc search` or `pwc paper list`; the start date
47
+ must not be later than `--end-date`. The combined flags are
48
+ `--start-date YYYY-MM-DD --end-date YYYY-MM-DD`.
49
+
50
+ Paper discovery commands accept `--implementation-coverage` to add official
51
+ implementation status and total linked repository count columns. JSON and
52
+ `pwc paper info` always include both fields. Use
53
+ `--has-official-implementation` with `pwc search` or `pwc paper list` to require
54
+ a catalog-linked official repository; these filters fail closed if unconfirmed.
55
+
56
+ Use `pwc benchmark --name NAME --max-parameters SIZE` to keep models at or
57
+ below an inclusive parameter limit. SIZE accepts values such as `500M`, `1.5B`,
58
+ `3B`, and raw integers. Models without one consistent parameter count are
59
+ excluded from constrained results.
60
+
61
+ `PAPER` accepts a modern or legacy arXiv ID, a numeric external-paper ID, or an
62
+ exact paper title. Quote titles containing spaces. Title matching is
63
+ case-insensitive but exact; ambiguous titles fail with their matching IDs.
64
+ When `pwc paper info` or `pwc paper read` names a paper Papers With Code has
65
+ not indexed, stderr shows "Not indexed on Papers With Code — available at:"
66
+ with public links and metadata before the usual not-found error; cite those
67
+ links, not a Papers With Code page.
68
+ """
69
+
70
+ _WORKFLOW = """
71
+ ## Research workflow
72
+
73
+ 1. Use `pwc benchmark list --task TASK` to discover active benchmarks, then
74
+ `pwc benchmark --name NAME` to inspect a leaderboard. Add
75
+ `--max-parameters SIZE` when model size is part of the request.
76
+ 2. Use `pwc paper info` to inspect promising results. Add
77
+ `--include-resources` when repositories, project pages, or Hugging Face
78
+ artifacts matter.
79
+ 3. Use exact `pwc paper list --author`, `--task`, `--method`, `--conference`,
80
+ `--framework`, and `--organization` filters for known identities or catalog
81
+ associations. Combine them to require every association; do not substitute
82
+ a keyword search. Add `--search` for title or abstract topic terms.
83
+ 4. Use `pwc search` for broader discovery, then `pwc paper read` for primary
84
+ evidence.
85
+ 5. Expand the literature with `pwc paper related` and use
86
+ `pwc paper lineage list` when model or method ancestry matters.
87
+ 6. Preserve paper titles, identifiers, and URLs so claims remain traceable.
88
+
89
+ ## Output and limits
90
+
91
+ - Interactive output is optimized for people. Benchmark lists remain aligned
92
+ when captured; other captured list output uses lossless TSV. Add `--json` for
93
+ structured agent or script consumption.
94
+ - Stable exit codes are `0` success, `2` invalid usage, `3` network/server
95
+ failure, and `4` invalid API response.
96
+ - `PWC_API_URL` may select another compatible v1 endpoint. The default is
97
+ `https://paperswithcode.co/api/v1`.
98
+ - Catalog-filtered paper lists fail closed unless the server confirms every
99
+ requested filter; never treat results from an older server as filtered.
100
+ - Parameter-filtered benchmark details fail closed unless the server confirms
101
+ parameter-filter support and every returned model satisfies the limit.
102
+
103
+ The research commands contain no authentication, catalog mutation, ingestion,
104
+ publication, image, embedding, CRON, or infrastructure-maintenance operations.
105
+ """
106
+
107
+
108
+ class SkillInstallError(Exception):
109
+ """An invalid or unsafe skill installation request."""
110
+
111
+
112
+ def _subcommand_help(action: argparse._SubParsersAction, name: str) -> str:
113
+ for choice in action._choices_actions:
114
+ if choice.dest == name:
115
+ return str(choice.help or "")
116
+ return ""
117
+
118
+
119
+ def _command_signature(parser: argparse.ArgumentParser) -> str:
120
+ parts: list[str] = []
121
+ for action in parser._actions:
122
+ if isinstance(action, argparse._SubParsersAction):
123
+ continue
124
+ if action.dest == "help":
125
+ continue
126
+ if not action.option_strings:
127
+ parts.append(str(action.metavar or action.dest).upper())
128
+ continue
129
+ option = next(
130
+ (item for item in action.option_strings if item.startswith("--")),
131
+ action.option_strings[0],
132
+ )
133
+ if action.nargs == 0:
134
+ parts.append(f"[{option}]")
135
+ continue
136
+ if action.choices:
137
+ value = "|".join(str(choice) for choice in action.choices)
138
+ else:
139
+ value = str(action.metavar or action.dest).upper()
140
+ parts.append(f"[{option} {value}]")
141
+ return " ".join(parts)
142
+
143
+
144
+ def _command_entries(
145
+ parser: argparse.ArgumentParser, prefix: tuple[str, ...] = ()
146
+ ) -> list[str]:
147
+ entries: list[str] = []
148
+ for action in parser._actions:
149
+ if not isinstance(action, argparse._SubParsersAction):
150
+ continue
151
+ for name, child in action.choices.items():
152
+ path = (*prefix, name)
153
+ if "handler" in child._defaults:
154
+ signature = _command_signature(child)
155
+ invocation = " ".join(("pwc", *path, signature)).rstrip()
156
+ help_text = _subcommand_help(action, name)
157
+ suffix = f" — {help_text.rstrip('.')}" if help_text else ""
158
+ entries.append(f"- `{invocation}`{suffix}.")
159
+ entries.extend(_command_entries(child, path))
160
+ return entries
161
+
162
+
163
+ def build_skill_md() -> str:
164
+ """Build a Skill matching the commands in the installed pwc version."""
165
+ from pwc_cli.cli import build_parser
166
+
167
+ commands = "\n".join(_command_entries(build_parser()))
168
+ return (
169
+ "---\n"
170
+ f"name: {SKILL_NAME}\n"
171
+ f'description: "{_DESCRIPTION}"\n'
172
+ "---\n\n"
173
+ f"Generated with `pwc v{__version__}`. Run "
174
+ "`pwc skills add --force` to regenerate.\n"
175
+ f"{_INTRODUCTION}\n"
176
+ "## Commands\n\n"
177
+ f"{commands}\n"
178
+ f"{_WORKFLOW}\n{_EDIT_WORKFLOW}"
179
+ )
180
+
181
+
182
+ def _remove_existing(path: Path, force: bool) -> None:
183
+ if not (path.exists() or path.is_symlink()):
184
+ return
185
+ if not force:
186
+ raise SkillInstallError(
187
+ f"skill already exists at {path}; rerun with --force to overwrite"
188
+ )
189
+ if path.is_dir() and not path.is_symlink():
190
+ shutil.rmtree(path)
191
+ else:
192
+ path.unlink()
193
+
194
+
195
+ def _install_to(skills_dir: Path, force: bool) -> Path:
196
+ skills_dir = skills_dir.expanduser().resolve()
197
+ skills_dir.mkdir(parents=True, exist_ok=True)
198
+ target = skills_dir / SKILL_NAME
199
+ _remove_existing(target, force)
200
+ temporary = Path(tempfile.mkdtemp(prefix=f".{SKILL_NAME}-", dir=skills_dir))
201
+ try:
202
+ (temporary / "SKILL.md").write_text(build_skill_md(), encoding="utf-8")
203
+ os.replace(temporary, target)
204
+ finally:
205
+ if temporary.exists():
206
+ shutil.rmtree(temporary)
207
+ return target
208
+
209
+
210
+ def _link_to(skills_dir: Path, installed: Path, force: bool) -> Path:
211
+ skills_dir = skills_dir.expanduser().resolve()
212
+ skills_dir.mkdir(parents=True, exist_ok=True)
213
+ link = skills_dir / SKILL_NAME
214
+ _remove_existing(link, force)
215
+ link.symlink_to(os.path.relpath(installed, skills_dir), target_is_directory=True)
216
+ return link
217
+
218
+
219
+ def skills_add(args: argparse.Namespace, _client: object) -> int:
220
+ """Install the generated pwc skill in central and optional harness paths."""
221
+ if args.dest is not None and (args.global_ or args.claude):
222
+ raise SkillInstallError("--dest cannot be combined with --global or --claude")
223
+
224
+ if args.dest is not None:
225
+ installed = _install_to(args.dest, args.force)
226
+ print(f"Installed '{SKILL_NAME}' to {installed}")
227
+ return 0
228
+
229
+ central_root = CENTRAL_GLOBAL if args.global_ else CENTRAL_LOCAL
230
+ installed = _install_to(central_root, args.force)
231
+ print(f"Installed '{SKILL_NAME}' to {installed}")
232
+
233
+ if args.claude:
234
+ claude_root = CLAUDE_GLOBAL if args.global_ else CLAUDE_LOCAL
235
+ link = _link_to(claude_root, installed, args.force)
236
+ print(f"Linked '{SKILL_NAME}' for Claude at {link}")
237
+ return 0
238
+
239
+
240
+ _EDIT_WORKFLOW = """
241
+ ## Editing one paper
242
+
243
+ Use this workflow only when the user requests edits. Research commands remain anonymous.
244
+
245
+ 1. Run `pwc auth login --paper PAPER`. Give the user the browser link and code;
246
+ they sign in with Hugging Face and authorize that paper for one hour. Use
247
+ `--no-browser` on a remote machine. Never extract, print, or copy credentials.
248
+ 2. Run `pwc paper edit export PAPER --output edits.json` (the output must not
249
+ already exist). It includes paper_id, version, idempotency_key, empty operations,
250
+ and current reference data. Change operations, not current. Preserve the
251
+ exported version and retry key. Documents may contain at most 50 operations
252
+ and 1 MiB of JSON.
253
+ 3. Add operations of the form `{"section":"tasks","payload":{"task_ids":[1,2]}}`.
254
+ A section operation explicitly replaces that section; preserve unrelated links
255
+ and tags. Omitted sections stay unchanged. Allowed sections and payload keys:
256
+ tasks/task_ids, methods/method_ids, repositories/repositories,
257
+ project_pages/project_pages, hf_artifacts/hf_models+hf_datasets+hf_spaces,
258
+ x_threads/x_threads, source_url/source_url (external papers only), and
259
+ evaluations. Repository/project-page entries contain url and is_official.
260
+ x_threads is a list of x.com or twitter.com post URLs in display order (at
261
+ most 10). Link only posts in which the paper's authors or their organization
262
+ introduce the paper, never outside accounts; the server captures each post's
263
+ preview and rejects deleted or protected posts.
264
+ 4. For evaluations, an operation without evaluation_id creates a row. Include
265
+ task_id, dataset_id, metrics (a dictionary of existing metric names to scores),
266
+ model_name, and the evaluation setup where available. One row can contain
267
+ multiple metrics. Add evaluation_id to correct an existing row on this paper;
268
+ omitted update fields stay unchanged. Use source_url and methodology to cite
269
+ precise evidence when available; source references remain optional. Existing
270
+ benchmark/task/metric definitions are required. Do not invent missing IDs or
271
+ create benchmarks. Report unsupported results to the user. No evaluation
272
+ deletion, paper identity changes, organization edits, or rank overrides.
273
+ 5. Run `pwc paper edit preview PAPER --file edits.json`, inspect the before/after
274
+ changes, then `pwc paper edit submit PAPER --file edits.json`. Publication is
275
+ immediate: no per-batch browser approval. Respect the user's requested scope.
276
+ A batch succeeds completely or makes no changes. The response includes status
277
+ published and a link to the paper's history.
278
+ 6. Retry an uncertain network result using the exact same document and retry key.
279
+ A 409 means changed data or a reused key with different edits. Fetch a fresh
280
+ export, preserve others' edits, and reconcile. Ask the user if the same score
281
+ has conflicting corrections. Once you intentionally revise a previously
282
+ submitted batch, use a fresh export/key. A 403 may mean expired authorization,
283
+ suspension, or insufficient scope: read the error; do not broaden access.
284
+ A 429 indicates the shared account limit: 20 distinct papers per UTC day and
285
+ 30 publications per minute. Do not work around account limits.
286
+ 7. `pwc auth status --paper PAPER` shows local expiry metadata (revocation is
287
+ checked by the server on use). `pwc auth logout --paper PAPER` revokes access.
288
+ If authorization expires, keep the prepared file and request browser login
289
+ again. Renewing authorization does not require discarding a valid edit document.
290
+
291
+ Credentials are stored in owner-only files under ~/.config/pwc/edit-credentials,
292
+ separately for each API base URL and paper. They never belong in a prompt, edit
293
+ file, repository, or command argument. The CLI does not follow edit redirects.
294
+ """
standalone_cli/src/pwc_cli/transport.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Credential-ready anonymous HTTP transport for the public catalog API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from pwc_cli import __version__
14
+
15
+ DEFAULT_API_URL = "https://paperswithcode.co/api/v1"
16
+ MAX_RESPONSE_BYTES = 2 * 1024 * 1024
17
+
18
+
19
+ class TransportError(RuntimeError):
20
+ pass
21
+
22
+
23
+ class HTTPStatusError(TransportError):
24
+ def __init__(self, status: int, detail: str):
25
+ super().__init__(f"API request failed ({status}): {detail}")
26
+ self.status = status
27
+ self.detail = detail
28
+
29
+
30
+ class ResponseError(RuntimeError):
31
+ pass
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Response:
36
+ body: bytes
37
+ headers: dict[str, str]
38
+
39
+ def json(self) -> Any:
40
+ try:
41
+ return json.loads(self.body)
42
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
43
+ raise ResponseError("API returned invalid JSON") from error
44
+
45
+
46
+ class Client:
47
+ def __init__(
48
+ self,
49
+ base_url: str | None = None,
50
+ credential: str | None = None,
51
+ timeout: float = 30,
52
+ ):
53
+ configured = base_url or os.environ.get("PWC_API_URL") or DEFAULT_API_URL
54
+ self.base_url = configured.rstrip("/")
55
+ self.credential = credential
56
+ self.timeout = timeout
57
+
58
+ def get(
59
+ self, path: str, params: dict[str, object | None] | None = None
60
+ ) -> Response:
61
+ query = urllib.parse.urlencode(
62
+ {
63
+ key: str(value).lower() if isinstance(value, bool) else value
64
+ for key, value in (params or {}).items()
65
+ if value is not None
66
+ },
67
+ doseq=True,
68
+ )
69
+ url = f"{self.base_url}/{path.lstrip('/')}"
70
+ if query:
71
+ url += f"?{query}"
72
+ headers = {
73
+ "Accept": "application/json, text/plain;q=0.9",
74
+ "User-Agent": f"pwc-cli/{__version__}",
75
+ }
76
+ if self.credential:
77
+ headers["Authorization"] = f"Bearer {self.credential}"
78
+ request = urllib.request.Request(url, headers=headers)
79
+ try:
80
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
81
+ length = response.headers.get("Content-Length")
82
+ if length and int(length) > MAX_RESPONSE_BYTES:
83
+ raise ResponseError("API response exceeded the client byte limit")
84
+ body = response.read(MAX_RESPONSE_BYTES + 1)
85
+ if len(body) > MAX_RESPONSE_BYTES:
86
+ raise ResponseError("API response exceeded the client byte limit")
87
+ return Response(
88
+ body,
89
+ {key.lower(): value for key, value in response.headers.items()},
90
+ )
91
+ except urllib.error.HTTPError as error:
92
+ detail = error.read(4096).decode("utf-8", errors="replace")
93
+ raise HTTPStatusError(error.code, detail) from error
94
+ except urllib.error.URLError as error:
95
+ raise TransportError(f"API request failed: {error.reason}") from error
96
+ except TimeoutError as error:
97
+ # Read timeouts surface as a bare TimeoutError, not a URLError.
98
+ raise TransportError("API request timed out") from error
99
+ except OSError as error:
100
+ raise TransportError(f"API request failed: {error}") from error
standalone_cli/tests/test_cli.py ADDED
The diff for this file is too large to render. See raw diff
 
standalone_cli/tests/test_edits.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import stat
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ import pytest
6
+
7
+ from pwc_cli.edit_credentials import CredentialStore
8
+ from pwc_cli.edit_transport import EditClient, NoRedirect, edit_base_url
9
+ from pwc_cli.edits import EditUsageError
10
+ from pwc_cli import edits
11
+ from pwc_cli.cli import build_parser
12
+ from pwc_cli.transport import Client, ResponseError
13
+
14
+
15
+ def test_credential_origin_paper_and_permissions(tmp_path, monkeypatch):
16
+ monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
17
+ store = CredentialStore("https://paperswithcode.co/api/v1")
18
+ store.save(1, {"paper_id": 1, "access_token": "secret"})
19
+ assert stat.S_IMODE(store.path(1).stat().st_mode) == 0o600
20
+ assert store.load(1)["access_token"] == "secret"
21
+ with pytest.raises(ResponseError):
22
+ store.load(2)
23
+ with pytest.raises(ResponseError):
24
+ CredentialStore("https://other.example/api/v1").load(1)
25
+ store.path(1).chmod(0o644)
26
+ with pytest.raises(ResponseError):
27
+ store.load(1)
28
+
29
+
30
+ def test_edit_transport_rejects_unsafe_origins_and_redirects():
31
+ for url in (
32
+ "http://example.org/api/v1",
33
+ "https://user:pass@example.org/api/v1",
34
+ "https://example.org/api?token=secret",
35
+ ):
36
+ with pytest.raises(ResponseError):
37
+ edit_base_url(url)
38
+ assert edit_base_url("http://127.0.0.1:8989/api/v1").startswith("http:")
39
+ assert (
40
+ NoRedirect().redirect_request(None, None, 302, "", {}, "https://other.example")
41
+ is None
42
+ )
43
+
44
+
45
+ def test_submit_omits_reference_material_and_keeps_retry_key(
46
+ tmp_path, monkeypatch, capsys
47
+ ):
48
+ monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
49
+ client = Client()
50
+ store = CredentialStore(client.base_url)
51
+ store.save(
52
+ 1,
53
+ {
54
+ "paper_id": 1,
55
+ "access_token": "top-secret",
56
+ "expires_at": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
57
+ },
58
+ )
59
+ document = {
60
+ "paper_id": 1,
61
+ "version": "a" * 64,
62
+ "idempotency_key": "stable-key",
63
+ "current": {"private_reference": True},
64
+ "operations": [{"section": "tasks", "payload": {"task_ids": [3]}}],
65
+ }
66
+ path = tmp_path / "edits.json"
67
+ path.write_text(json.dumps(document))
68
+ calls = []
69
+
70
+ def request(self, method, endpoint, payload=None):
71
+ calls.append((method, endpoint, payload))
72
+ return {"status": "published"}
73
+
74
+ monkeypatch.setattr(EditClient, "request", request)
75
+ args = build_parser().parse_args(
76
+ ["paper", "edit", "submit", "1", "--file", str(path)]
77
+ )
78
+ assert edits.edit(args, client) == 0
79
+ assert calls[0][2]["idempotency_key"] == "stable-key"
80
+ assert "current" not in calls[0][2]
81
+ assert "top-secret" not in capsys.readouterr().out
82
+ document["paper_id"] = 2
83
+ path.write_text(json.dumps(document))
84
+ with pytest.raises(EditUsageError):
85
+ edits.edit(args, client)
86
+ assert len(calls) == 1
87
+
88
+
89
+ def test_login_requires_browser_approval_and_never_prints_token(
90
+ tmp_path, monkeypatch, capsys
91
+ ):
92
+ monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
93
+ responses = iter(
94
+ [
95
+ {
96
+ "device_code": "device-secret",
97
+ "verification_path": "/authorize-paper-edit/abc",
98
+ "user_code": "ABC",
99
+ "expires_in": 600,
100
+ "interval": 5,
101
+ },
102
+ {"status": "authorization_pending"},
103
+ {
104
+ "status": "authorized",
105
+ "paper_id": 1,
106
+ "username": "alice",
107
+ "access_token": "access-secret",
108
+ "expires_at": (
109
+ datetime.now(timezone.utc) + timedelta(hours=1)
110
+ ).isoformat(),
111
+ },
112
+ ]
113
+ )
114
+ monkeypatch.setattr(EditClient, "request", lambda *a, **kw: next(responses))
115
+ monkeypatch.setattr(edits.time, "sleep", lambda _: None)
116
+ monkeypatch.setattr(
117
+ edits.webbrowser, "open", lambda _: pytest.fail("no-browser ignored")
118
+ )
119
+ args = build_parser().parse_args(["auth", "login", "--paper", "1", "--no-browser"])
120
+ assert edits.auth(args, Client()) == 0
121
+ output = capsys.readouterr()
122
+ assert "access-secret" not in output.out + output.err
123
+ assert "device-secret" not in output.out + output.err
124
+ assert CredentialStore(Client().base_url).load(1)["access_token"] == "access-secret"
standalone_cli/tests/test_queries.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The in-process query layer must mirror ``pwc ... --json`` exactly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import io
7
+ import json
8
+ from contextlib import redirect_stdout
9
+
10
+ import pytest
11
+ from pwc_cli import queries
12
+ from pwc_cli.cli import UsageError, build_parser, main
13
+ from pwc_cli.transport import Response
14
+
15
+ NON_RESEARCH_COMMANDS = {
16
+ ("skills", "add"),
17
+ ("version",),
18
+ ("auth", "login"),
19
+ ("auth", "status"),
20
+ ("auth", "logout"),
21
+ ("paper", "edit", "export"),
22
+ ("paper", "edit", "preview"),
23
+ ("paper", "edit", "submit"),
24
+ }
25
+
26
+ EVALUATION_ROWS = [
27
+ {
28
+ "id": "1",
29
+ "model_name": "Small",
30
+ "metrics": {"mAP": 50.0},
31
+ "best_rank": 2,
32
+ "num_parameters": 100_000_000,
33
+ "paper_id": "1",
34
+ "task_id": "t",
35
+ "dataset_id": "9",
36
+ },
37
+ {
38
+ "id": "2",
39
+ "model_name": "Smaller",
40
+ "metrics": {"mAP": 60.0},
41
+ "best_rank": 1,
42
+ "num_parameters": 50_000_000,
43
+ "paper_id": "2",
44
+ "task_id": "t",
45
+ "dataset_id": "9",
46
+ },
47
+ ]
48
+
49
+ BENCHMARK_ROUTES = {
50
+ "datasets/": {"results": [{"id": "9", "name": "COCO", "slug": "coco"}]},
51
+ "datasets/9/evaluations/": {"count": 2, "results": EVALUATION_ROWS},
52
+ "evaluations/": {
53
+ "count": 2,
54
+ "next_page": None,
55
+ "parameter_coverage_known": 2,
56
+ "parameter_coverage_total": 2,
57
+ "results": EVALUATION_ROWS,
58
+ },
59
+ }
60
+
61
+
62
+ class StubClient:
63
+ def __init__(self, routes):
64
+ self.routes = routes
65
+ self.calls = []
66
+
67
+ def get(self, path, params=None):
68
+ self.calls.append((path, dict(params or {})))
69
+ return Response(json.dumps(self.routes[path]).encode(), {})
70
+
71
+
72
+ def _leaf_commands(parser, prefix=()):
73
+ for action in parser._actions:
74
+ if not isinstance(action, argparse._SubParsersAction):
75
+ continue
76
+ for name, child in action.choices.items():
77
+ path = (*prefix, name)
78
+ if "handler" in child._defaults:
79
+ yield path
80
+ yield from _leaf_commands(child, path)
81
+
82
+
83
+ def test_read_only_commands_cover_every_research_command():
84
+ research = set(_leaf_commands(build_parser())) - NON_RESEARCH_COMMANDS
85
+
86
+ assert set(queries.READ_ONLY_COMMANDS) == research
87
+ for command in queries.READ_ONLY_COMMANDS:
88
+ assert "handler" in queries.command_parser(command)._defaults
89
+
90
+
91
+ def test_query_options_exclude_presentation_flags_and_selectors():
92
+ options = queries.query_options(("benchmark", "list"))
93
+
94
+ assert set(options) == {
95
+ "page",
96
+ "page_size",
97
+ "search",
98
+ "task",
99
+ "group_by_area",
100
+ "area",
101
+ "benchmarks_per_task",
102
+ "include_descendants",
103
+ "min_eval_count",
104
+ "is_open",
105
+ "order_by",
106
+ "order_dir",
107
+ }
108
+ assert queries.query_options(("search",))["query"].option_strings == []
109
+
110
+
111
+ def test_build_argv_translates_structured_options():
112
+ argv = queries.build_argv(
113
+ ("benchmark",),
114
+ {
115
+ "name": "COCO",
116
+ "limit": 5,
117
+ "is_open": True,
118
+ "require_metrics": ["mAP", "FPS"],
119
+ "minimum_metrics": {"mAP": 40},
120
+ "maximum_metrics": {"FPS": 10.5},
121
+ "pareto": ["mAP:higher", "FPS:lower"],
122
+ "max_parameters": None,
123
+ },
124
+ )
125
+
126
+ assert argv == [
127
+ "benchmark",
128
+ "--name",
129
+ "COCO",
130
+ "--limit",
131
+ "5",
132
+ "--is-open",
133
+ "true",
134
+ "--require-metrics",
135
+ "mAP,FPS",
136
+ "--min",
137
+ "mAP=40",
138
+ "--max",
139
+ "FPS=10.5",
140
+ "--pareto",
141
+ "mAP:higher,FPS:lower",
142
+ ]
143
+ assert queries.build_argv(
144
+ ("paper", "list"),
145
+ {"author": ["Ada", "@ada"], "all_versions": True, "include_resources": False},
146
+ ) == ["paper", "list", "--author", "Ada", "--author", "@ada", "--all-versions"]
147
+
148
+ argv = queries.build_argv(("search",), {"query": "-attention", "limit": 3})
149
+ assert argv == ["search", "--limit", "3", "--", "-attention"]
150
+ assert build_parser().parse_args(argv).query == "-attention"
151
+
152
+
153
+ def test_query_returns_exactly_the_cli_json_data(monkeypatch, capsys):
154
+ monkeypatch.setattr("pwc_cli.cli.Client", lambda: StubClient(BENCHMARK_ROUTES))
155
+ output = io.StringIO()
156
+ with redirect_stdout(output):
157
+ assert (
158
+ main(
159
+ [
160
+ "benchmark",
161
+ "--name",
162
+ "COCO",
163
+ "--max-parameters",
164
+ "500M",
165
+ "--sort",
166
+ "mAP",
167
+ "--limit",
168
+ "1",
169
+ "--json",
170
+ ]
171
+ )
172
+ == 0
173
+ )
174
+ printed = json.loads(output.getvalue())
175
+
176
+ client = StubClient(BENCHMARK_ROUTES)
177
+ data = queries.query(
178
+ ("benchmark",),
179
+ {"name": "COCO", "max_parameters": "500M", "sort_metric": "mAP", "limit": 1},
180
+ client,
181
+ )
182
+
183
+ assert {"schema_version": "v1", "data": data} == printed
184
+ assert data["matched_count"] == 2
185
+ assert [row["model_name"] for row in data["results"]] == ["Smaller"]
186
+ assert client.calls[1][1]["max_parameters_exclusive"] == 500_000_001
187
+ assert capsys.readouterr().out == ""
188
+
189
+
190
+ def test_query_raises_usage_errors_instead_of_exiting():
191
+ client = StubClient({})
192
+
193
+ with pytest.raises(UsageError, match="--limit must be 1-100"):
194
+ queries.query(("search",), {"query": "x", "limit": 500}, client)
195
+ with pytest.raises(UsageError, match="unknown option"):
196
+ queries.query(("search",), {"query": "x", "page_size": 5}, client)
197
+ with pytest.raises(UsageError, match="invalid"):
198
+ queries.query(("search",), {"query": "x", "start_date": "2026-13-01"}, client)
199
+ with pytest.raises(UsageError, match="cannot be combined"):
200
+ queries.query(("task", "list"), {"group_by_area": True, "page": 2}, client)
201
+ with pytest.raises(UsageError, match="unknown metric"):
202
+ queries.query(
203
+ ("benchmark",),
204
+ {"name": "COCO", "sort_metric": "Latency"},
205
+ StubClient(BENCHMARK_ROUTES),
206
+ )
207
+ assert client.calls == []
208
+
209
+
210
+ def test_query_refuses_commands_that_are_not_read_only():
211
+ for command in (("skills", "add"), ("version",), ("paper", "edit", "export")):
212
+ with pytest.raises(UsageError, match="not a read-only"):
213
+ queries.query(command, {}, StubClient({}))
214
+
215
+
216
+ def test_query_resolves_metric_aliases_against_the_leaderboard():
217
+ # Agents ask for "AP" on COCO leaderboards that report "mAP".
218
+ data = queries.query(
219
+ ("benchmark",),
220
+ {"name": "COCO", "sort_metric": "AP:asc"},
221
+ StubClient(BENCHMARK_ROUTES),
222
+ )
223
+
224
+ assert data["matched_count"] == 2
225
+ assert [row["model_name"] for row in data["results"]] == ["Small", "Smaller"]