nielsr HF Staff commited on
Commit
3ecc591
·
verified ·
1 Parent(s): ee0b555

Deploy pwc-cli main (c8a789ed6c1151a2bb9fdd65eb49197eb81daf7a)

Browse files
README.md CHANGED
@@ -17,8 +17,3 @@ leaderboards through Model Context Protocol.
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.
 
17
  - MCP endpoint: `/mcp`
18
  - Health endpoint: `/health`
19
  - Source: <https://github.com/huggingface/pwc-cli>
 
 
 
 
 
mcp_server/README.md CHANGED
@@ -106,6 +106,7 @@ unfiltered leaderboard and a `metric_note` naming the available metrics.
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
@@ -115,6 +116,15 @@ 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
 
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
+ | `PWC_MCP_TRUSTED_PROXY_CIDRS` | Comma-separated networks of a non-loopback reverse proxy whose `X-Forwarded-For` is trusted; empty by default |
110
  | `LOG_LEVEL` | Content-free operational log level |
111
 
112
  Native clients may omit `Origin`. Browser requests must match the configured
 
116
  without `X-Forwarded-For`, so proxied public traffic cannot use it. The server does not log queries, paper references, request bodies,
117
  raw IP addresses, or authorization headers.
118
 
119
+ Behind a reverse proxy that does not run on the same host, such as the Hugging
120
+ Face Spaces ingress, set `PWC_MCP_TRUSTED_PROXY_CIDRS` to the proxy's networks.
121
+ A request whose direct peer falls in one of them is limited by the right-most
122
+ `X-Forwarded-For` hop outside those networks; hops further left are
123
+ client-supplied and ignored. Such requests can never name an identity with
124
+ `X-PwC-MCP-Client`. Loopback peers keep the behaviour above. List only
125
+ networks that hold nothing but the proxy: any peer inside them can choose its
126
+ own rate-limit identity.
127
+
128
  The hosted defaults allow 60 total requests and 10 semantic or hybrid searches per minute
129
  per client IP, with at most four concurrent requests per IP and 128 globally.
130
  Tool inputs cap list results at 25, catalog calls time out after 25 seconds, and
mcp_server/SPEC.md CHANGED
@@ -108,7 +108,9 @@ openness, and update time. Metric direction is explicit when known.
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
 
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`, optionally trust the
112
+ right-most untrusted `X-Forwarded-For` hop from peers in
113
+ `PWC_MCP_TRUSTED_PROXY_CIDRS` (never their `X-PwC-MCP-Client`), and expose cached catalog readiness without making `/health`
114
  wait on an upstream call.
115
 
116
  ## Release
mcp_server/space/README.md DELETED
@@ -1,19 +0,0 @@
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/app.py CHANGED
@@ -185,7 +185,42 @@ 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:
@@ -199,6 +234,14 @@ def _client_address(scope: Scope, headers: Headers, trust_proxy_headers: bool) -
199
  identity = headers.get(CLIENT_IDENTITY_HEADER, "")
200
  if CLIENT_IDENTITY.fullmatch(identity):
201
  return f"client:{identity}"
 
 
 
 
 
 
 
 
202
  return direct_address
203
 
204
 
@@ -310,6 +353,7 @@ class RateLimitMiddleware:
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
@@ -318,6 +362,7 @@ class RateLimitMiddleware:
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)
@@ -373,7 +418,9 @@ class RateLimitMiddleware:
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
@@ -482,6 +529,7 @@ def create_app(
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)
@@ -495,6 +543,9 @@ def create_app(
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
  [
@@ -544,6 +595,7 @@ def create_app(
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)
 
185
  CLIENT_IDENTITY = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
186
 
187
 
188
+ # Hosts that put the server behind a non-loopback reverse proxy, such as the
189
+ # Hugging Face Spaces ingress, list the proxy's networks in
190
+ # PWC_MCP_TRUSTED_PROXY_CIDRS. A request from such a peer is attributed to the
191
+ # right-most X-Forwarded-For hop outside those networks: hops to its left are
192
+ # client-supplied and can be forged. A proxied request never names its own
193
+ # identity through CLIENT_IDENTITY_HEADER.
194
+ IPNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network
195
+
196
+
197
+ def _trusted_proxy_networks(value: str) -> tuple[IPNetwork, ...]:
198
+ try:
199
+ return tuple(
200
+ ipaddress.ip_network(item.strip())
201
+ for item in value.split(",")
202
+ if item.strip()
203
+ )
204
+ except ValueError as error:
205
+ raise ValueError(
206
+ f"PWC_MCP_TRUSTED_PROXY_CIDRS must list CIDR networks: {error}"
207
+ ) from None
208
+
209
+
210
+ def _in_networks(address: str, networks: tuple[IPNetwork, ...]) -> bool:
211
+ try:
212
+ ip = ipaddress.ip_address(address)
213
+ except ValueError:
214
+ return False
215
+ return any(ip in network for network in networks)
216
+
217
+
218
+ def _client_address(
219
+ scope: Scope,
220
+ headers: Headers,
221
+ trust_proxy_headers: bool,
222
+ trusted_proxies: tuple[IPNetwork, ...] = (),
223
+ ) -> str:
224
  client = scope.get("client")
225
  direct_address = str(client[0]) if client else "unknown"
226
  try:
 
234
  identity = headers.get(CLIENT_IDENTITY_HEADER, "")
235
  if CLIENT_IDENTITY.fullmatch(identity):
236
  return f"client:{identity}"
237
+ elif trust_proxy_headers and _in_networks(direct_address, trusted_proxies):
238
+ hops = ",".join(headers.getlist("x-forwarded-for")).split(",")
239
+ for hop in reversed([hop.strip() for hop in hops if hop.strip()]):
240
+ if not _in_networks(hop, trusted_proxies):
241
+ try:
242
+ return str(ipaddress.ip_address(hop))
243
+ except ValueError:
244
+ break
245
  return direct_address
246
 
247
 
 
353
  concurrency_limit: int,
354
  global_concurrency_limit: int,
355
  trust_proxy_headers: bool,
356
+ trusted_proxies: tuple[IPNetwork, ...] = (),
357
  identity_limit: int = 10_000,
358
  ):
359
  self.app = app
 
362
  self.concurrency_limit = concurrency_limit
363
  self.global_concurrency_limit = global_concurrency_limit
364
  self.trust_proxy_headers = trust_proxy_headers
365
+ self.trusted_proxies = trusted_proxies
366
  self.identity_limit = identity_limit
367
  self.key = secrets.token_bytes(32)
368
  self.requests: dict[str, deque[float]] = defaultdict(deque)
 
418
  tool, semantic = _tool_and_semantic(headers, body)
419
  scope.setdefault("state", {})["pwc_tool_name"] = tool
420
  identity = self._identity(
421
+ _client_address(
422
+ scope, headers, self.trust_proxy_headers, self.trusted_proxies
423
+ )
424
  )
425
  now = time.monotonic()
426
  limited = False
 
529
  concurrency_limit: int | None = None,
530
  global_concurrency_limit: int | None = None,
531
  trust_proxy_headers: bool = True,
532
+ trusted_proxy_cidrs: str | None = None,
533
  ) -> ASGIApp:
534
  if request_limit is None:
535
  request_limit = _int_env("PWC_MCP_REQUEST_LIMIT", DEFAULT_REQUEST_LIMIT)
 
543
  global_concurrency_limit = _int_env(
544
  "PWC_MCP_GLOBAL_CONCURRENCY_LIMIT", DEFAULT_GLOBAL_CONCURRENCY_LIMIT
545
  )
546
+ if trusted_proxy_cidrs is None:
547
+ trusted_proxy_cidrs = os.environ.get("PWC_MCP_TRUSTED_PROXY_CIDRS", "")
548
+ trusted_proxies = _trusted_proxy_networks(trusted_proxy_cidrs)
549
  hosts = allowed_hosts or _csv_env(
550
  "PWC_MCP_ALLOWED_HOSTS",
551
  [
 
595
  concurrency_limit=concurrency_limit,
596
  global_concurrency_limit=global_concurrency_limit,
597
  trust_proxy_headers=trust_proxy_headers,
598
+ trusted_proxies=trusted_proxies,
599
  )
600
  wrapped = MCPMethodMiddleware(wrapped)
601
  wrapped = ResponseSizeLimitMiddleware(wrapped)
mcp_server/tests/test_app.py CHANGED
@@ -5,7 +5,9 @@ import logging
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
@@ -256,6 +258,109 @@ def test_loopback_first_party_clients_may_name_their_rate_limit_identity():
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))
 
5
  from pwc_mcp.app import (
6
  MAX_RESPONSE_BODY_SIZE,
7
  ResponseSizeLimitMiddleware,
8
+ RateLimitMiddleware,
9
  _client_address,
10
+ _trusted_proxy_networks,
11
  create_app,
12
  )
13
  from starlette.applications import Starlette
 
258
  assert _client_address({"client": ("127.0.0.1", 1)}, long, True) == "127.0.0.1"
259
 
260
 
261
+ HF_PROXIES = _trusted_proxy_networks("10.0.0.0/8, fd00::/8")
262
+
263
+
264
+ def test_trusted_proxy_peer_uses_the_rightmost_untrusted_forwarded_hop():
265
+ def address(peer: str, forwarded: str | None, **extra: str) -> str:
266
+ values = dict(extra)
267
+ if forwarded is not None:
268
+ values["x-forwarded-for"] = forwarded
269
+ return _client_address(
270
+ {"client": (peer, 1)}, Headers(values), True, HF_PROXIES
271
+ )
272
+
273
+ assert address("10.1.2.3", "203.0.113.9") == "203.0.113.9"
274
+ # Hops left of the proxy's own entry are client-supplied and ignored.
275
+ assert address("10.1.2.3", "198.51.100.1, 203.0.113.9") == "203.0.113.9"
276
+ # Trusted intermediate proxies are skipped from the right.
277
+ assert address("10.1.2.3", "203.0.113.9, 10.9.9.9") == "203.0.113.9"
278
+ assert address("fd00::5", "2001:db8::7, fd00::6") == "2001:db8::7"
279
+ # Repeated headers are read as one list in arrival order.
280
+ repeated = Headers(
281
+ raw=[(b"x-forwarded-for", b"198.51.100.1"), (b"x-forwarded-for", b"203.0.113.9")]
282
+ )
283
+ assert (
284
+ _client_address({"client": ("10.1.2.3", 1)}, repeated, True, HF_PROXIES)
285
+ == "203.0.113.9"
286
+ )
287
+ # Missing, fully trusted, or malformed chains fall back to the proxy peer.
288
+ assert address("10.1.2.3", None) == "10.1.2.3"
289
+ assert address("10.1.2.3", "10.4.4.4, 10.5.5.5") == "10.1.2.3"
290
+ assert address("10.1.2.3", "203.0.113.9, not-an-ip") == "10.1.2.3"
291
+ # Untrusted peers and disabled trust ignore the header entirely.
292
+ assert address("192.0.2.4", "203.0.113.9") == "192.0.2.4"
293
+ assert (
294
+ _client_address(
295
+ {"client": ("10.1.2.3", 1)},
296
+ Headers({"x-forwarded-for": "203.0.113.9"}),
297
+ False,
298
+ HF_PROXIES,
299
+ )
300
+ == "10.1.2.3"
301
+ )
302
+
303
+
304
+ def test_proxied_hops_never_name_their_own_identity():
305
+ tagged = {"x-pwc-mcp-client": "chat-0123abcd"}
306
+ assert (
307
+ _client_address({"client": ("10.1.2.3", 1)}, Headers(tagged), True, HF_PROXIES)
308
+ == "10.1.2.3"
309
+ )
310
+ forwarded = Headers({**tagged, "x-forwarded-for": "203.0.113.9"})
311
+ assert (
312
+ _client_address({"client": ("10.1.2.3", 1)}, forwarded, True, HF_PROXIES)
313
+ == "203.0.113.9"
314
+ )
315
+ # Loopback behaviour is unchanged when proxies are configured.
316
+ assert (
317
+ _client_address({"client": ("127.0.0.1", 1)}, Headers(tagged), True, HF_PROXIES)
318
+ == "client:chat-0123abcd"
319
+ )
320
+
321
+
322
+ def test_trusted_proxy_cidrs_are_opt_in_and_validated(monkeypatch):
323
+ def middleware(app):
324
+ while not isinstance(app, RateLimitMiddleware):
325
+ app = app.app
326
+ return app
327
+
328
+ monkeypatch.delenv("PWC_MCP_TRUSTED_PROXY_CIDRS", raising=False)
329
+ assert middleware(create_app(StubCatalog())).trusted_proxies == ()
330
+ monkeypatch.setenv("PWC_MCP_TRUSTED_PROXY_CIDRS", " 10.0.0.0/8 ,fd00::/8,")
331
+ assert [str(n) for n in middleware(create_app(StubCatalog())).trusted_proxies] == [
332
+ "10.0.0.0/8",
333
+ "fd00::/8",
334
+ ]
335
+ for invalid in ("10.0.0.1/8", "not-a-cidr", "10.0.0.0/33"):
336
+ monkeypatch.setenv("PWC_MCP_TRUSTED_PROXY_CIDRS", invalid)
337
+ try:
338
+ create_app(StubCatalog())
339
+ except ValueError as error:
340
+ assert "PWC_MCP_TRUSTED_PROXY_CIDRS" in str(error)
341
+ continue
342
+ raise AssertionError(invalid)
343
+
344
+
345
+ def test_clients_behind_a_trusted_proxy_get_separate_rate_limits():
346
+ app = create_app(
347
+ StubCatalog(),
348
+ allowed_hosts=["testserver"],
349
+ request_limit=1,
350
+ trusted_proxy_cidrs="10.0.0.0/8",
351
+ )
352
+ with TestClient(app, client=("10.1.2.3", 50000)) as client:
353
+ first = client.post("/mcp", json={}, headers={"x-forwarded-for": "203.0.113.9"})
354
+ other = client.post("/mcp", json={}, headers={"x-forwarded-for": "203.0.113.10"})
355
+ repeat = client.post(
356
+ "/mcp", json={}, headers={"x-forwarded-for": "1.2.3.4, 203.0.113.9"}
357
+ )
358
+
359
+ assert first.status_code != 429
360
+ assert other.status_code != 429
361
+ assert repeat.status_code == 429
362
+
363
+
364
  def test_serialized_mcp_response_limit_fails_closed():
365
  async def oversized(_request):
366
  return Response(b"x" * (MAX_RESPONSE_BODY_SIZE + 1))