Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Prototype PwC MCP Space from pwc-cli@6a547659
Browse files- Dockerfile +23 -0
- README.md +19 -5
- mcp_server/Dockerfile +23 -0
- mcp_server/README.md +139 -0
- mcp_server/SKILL.md +134 -0
- mcp_server/SPEC.md +119 -0
- mcp_server/pyproject.toml +30 -0
- mcp_server/space/README.md +19 -0
- mcp_server/src/pwc_mcp/__init__.py +3 -0
- mcp_server/src/pwc_mcp/app.py +579 -0
- mcp_server/src/pwc_mcp/catalog.py +564 -0
- mcp_server/src/pwc_mcp/cursors.py +189 -0
- mcp_server/src/pwc_mcp/models.py +585 -0
- mcp_server/src/pwc_mcp/server.py +1256 -0
- mcp_server/tests/conftest.py +9 -0
- mcp_server/tests/test_app.py +379 -0
- mcp_server/tests/test_catalog.py +575 -0
- mcp_server/tests/test_cursors.py +73 -0
- mcp_server/tests/test_parity.py +106 -0
- mcp_server/tests/test_server.py +1176 -0
- mcp_server/tests/test_skill.py +41 -0
- mcp_server/uv.lock +0 -0
- standalone_cli/README.md +179 -0
- standalone_cli/SKILL.md +166 -0
- standalone_cli/entrypoint.py +3 -0
- standalone_cli/install.py +226 -0
- standalone_cli/pyproject.toml +18 -0
- standalone_cli/scripts/check_contract.py +89 -0
- standalone_cli/src/pwc_cli/__init__.py +4 -0
- standalone_cli/src/pwc_cli/__main__.py +3 -0
- standalone_cli/src/pwc_cli/cli.py +0 -0
- standalone_cli/src/pwc_cli/edit_credentials.py +66 -0
- standalone_cli/src/pwc_cli/edit_transport.py +72 -0
- standalone_cli/src/pwc_cli/edits.py +154 -0
- standalone_cli/src/pwc_cli/queries.py +179 -0
- standalone_cli/src/pwc_cli/skills.py +294 -0
- standalone_cli/src/pwc_cli/transport.py +100 -0
- standalone_cli/tests/test_cli.py +0 -0
- standalone_cli/tests/test_edits.py +124 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"]
|