Repository navigation
Expand file tree
/
Copy pathapp.py
More file actions
2952 lines (2607 loc) · 135 KB
/
Copy pathapp.py
File metadata and controls
2952 lines (2607 loc) · 135 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""HVTracker web service.
Serves the pre-generated static site from the volume, exposes a dynamic JSON
API and live SVG badges sourced from data.json, accepts agent submissions /
corrections into Postgres, and runs the 4-hourly refresh in-process.
"""
from __future__ import annotations
import json
import os
import re
import sys
import threading
import hashlib
import time
from collections import deque
from contextlib import asynccontextmanager
from datetime import datetime, timezone, timedelta
from html import escape
from apscheduler.schedulers.background import BackgroundScheduler
from dotenv import load_dotenv
from fastapi import FastAPI, Form, Request
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
import alerts
import db
import mcp_server
import mcp_trust
import usage
import verify_log
# Load local secrets/config from a gitignored .env (OAuth client ids/secrets,
# HVT_SESSION_SECRET, HVT_DEV_AUTH). No-op in production where env vars are set
# directly. Must run before `import auth` (~line 206), which reads these at load.
load_dotenv()
# ---- anti-spam helpers -----------------------------------------------------
_RATE_WINDOW = 600 # 10 minutes
_RATE_LIMIT = 5
_rate_log: dict[str, deque] = {}
_rate_lock = threading.Lock()
def _client_ip(request: Request) -> str:
return (
request.headers.get("cf-connecting-ip")
or request.headers.get("x-forwarded-for", "").split(",")[0].strip()
or (request.client.host if request.client else "unknown")
)
def _is_rate_limited(request: Request) -> bool:
ip = _client_ip(request)
now = time.monotonic()
with _rate_lock:
q = _rate_log.setdefault(ip, deque())
while q and q[0] < now - _RATE_WINDOW:
q.popleft()
if len(q) >= _RATE_LIMIT:
return True
q.append(now)
return False
# Open-lookup (P2): a more generous, read-only limit than the form limiter,
# plus a short cache so repeat checks of the same repo don't re-hit GitHub.
_OLOOKUP_WINDOW = 600
_OLOOKUP_LIMIT = 20
_olookup_log: dict[str, deque] = {}
_olookup_cache: dict[str, tuple] = {}
_OLOOKUP_TTL = 86400 # cache a repo's verdict for the day; a daily job can refresh it
def _is_open_lookup_limited(request: Request) -> bool:
ip = _client_ip(request)
now = time.monotonic()
with _rate_lock:
q = _olookup_log.setdefault(ip, deque())
while q and q[0] < now - _OLOOKUP_WINDOW:
q.popleft()
if len(q) >= _OLOOKUP_LIMIT:
return True
q.append(now)
return False
def _olookup_cache_get(repo: str):
hit = _olookup_cache.get(repo)
if hit and time.monotonic() - hit[0] < _OLOOKUP_TTL:
return hit[1]
return None
def _olookup_cache_put(repo: str, verdict: dict) -> None:
_olookup_cache[repo] = (time.monotonic(), verdict)
# MCP endpoint limiter: the trust tools are read-only in-memory lookups with no
# external calls, so this is deliberately generous — it only exists to stop a
# flood that bypasses the CDN and hits the origin directly. A real multi-check
# agent session stays well under it.
_MCP_WINDOW = 60
_MCP_LIMIT = 60
_mcp_log: dict[str, deque] = {}
def _is_mcp_rate_limited(request: Request) -> bool:
ip = _client_ip(request)
now = time.monotonic()
with _rate_lock:
q = _mcp_log.setdefault(ip, deque())
while q and q[0] < now - _MCP_WINDOW:
q.popleft()
if len(q) >= _MCP_LIMIT:
return True
q.append(now)
return False
def _mcp_enabled() -> bool:
"""Budget kill switch for the MCP endpoint.
Set MCP_ENABLED=0 (Railway variable) to pause only the /mcp server — the
website, badges, and the /api/v1/mcp/verify HTTP API keep serving. Use this
if MCP traffic ever pushes usage toward the workspace spend cap.
"""
return os.environ.get("MCP_ENABLED", "1").strip().lower() not in {"0", "false", "off", "no"}
_EMAIL_RE = re.compile(r".+@.+\..+")
_FIELD_LIMITS = {
"repo": 200,
"name": 120,
"email": 254,
"contact": 254,
"message": 4000,
"notes": 4000,
}
_DEFAULT_LIMIT = 500
def _check_field_lengths(**fields: str) -> str | None:
for name, value in fields.items():
limit = _FIELD_LIMITS.get(name, _DEFAULT_LIMIT)
if len(value) > limit:
return f"{name} exceeds the {limit}-character limit."
return None
HONEYPOT_HTML = '<div style="position:absolute;left:-9999px;top:-9999px" aria-hidden="true"><label>Leave blank<input name="website" autocomplete="off" tabindex="-1"></label></div>'
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
OUTPUT_DIR = os.environ.get("OUTPUT_DIR", BASE_DIR)
def _asset_ver(name: str) -> str:
"""Short content hash for cache-busting unhashed static JS (auth.js etc.)."""
try:
with open(os.path.join(BASE_DIR, name), "rb") as f:
return hashlib.sha256(f.read()).hexdigest()[:8]
except OSError:
return "0"
_AUTH_JS_VER = _asset_ver("auth.js")
os.makedirs(OUTPUT_DIR, exist_ok=True) # volume subdir may not exist on first boot
DATA_PATH = os.path.join(OUTPUT_DIR, "data.json")
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
COMPARE_TOOL_PATH = os.path.join(BASE_DIR, "compare", "index.html")
VERIFY_TOOL_PATH = os.path.join(BASE_DIR, "verify", "index.html")
LIVE_PAGE_PATH = os.path.join(BASE_DIR, "live", "index.html")
SCAN_TOOL_PATH = os.path.join(BASE_DIR, "scan", "index.html")
RENDER_FINGERPRINT_PATH = os.path.join(OUTPUT_DIR, ".render_fingerprint")
verify_log.init(OUTPUT_DIR) # public "recently checked" feed (transparency)
usage.init(OUTPUT_DIR) # machine-channel usage rollup behind /live/
REFRESH_STATUS_PATH = os.path.join(OUTPUT_DIR, ".refresh_status.json")
MAX_DATA_AGE = timedelta(hours=int(os.environ.get("MAX_DATA_AGE_HOURS", "6")))
# OSSF scores are baked into the image from the `data` branch at build time
# (see Dockerfile). Re-pull that file at runtime so live refreshes apply the
# latest daily scan instead of the deploy-time snapshot, which otherwise ages
# out to the unreliable deps.dev fallback after 48h.
SCORECARD_CACHE_URL = os.environ.get(
"SCORECARD_CACHE_URL",
"https://raw.githubusercontent.com/YugantM/hvtracker/data/scorecard-cache.json",
)
SCORECARD_CACHE_PATH = os.path.join(BASE_DIR, "scorecard-cache.json")
# The /registry/ feed's snapshot of official MCP registry entries, refreshed
# daily on the `data` branch by .github/workflows/registry-snapshot.yml.
REGISTRY_SNAPSHOT_URL = os.environ.get(
"REGISTRY_SNAPSHOT_URL",
"https://raw.githubusercontent.com/YugantM/hvtracker/data/mcp-registry-snapshot.json",
)
REGISTRY_SNAPSHOT_PATH = os.path.join(BASE_DIR, "mcp-registry-snapshot.json")
@asynccontextmanager
async def _lifespan(_app):
# Run the MCP Streamable-HTTP session manager (required by the SDK even in
# stateless mode) for the lifetime of the app, then the existing startup work.
_install_mcp_route()
async with mcp_server.mcp.session_manager.run():
startup()
try:
yield
finally:
shutdown()
app = FastAPI(title="HVTracker", docs_url="/api/docs", openapi_url="/api/openapi.json",
lifespan=_lifespan)
# Accounts: GitHub/Google OAuth + watchlist/claim/notifications. Registered
# before the catch-all StaticFiles mount so its /auth/* and /api/* routes win.
import auth as _auth # noqa: E402
app.include_router(_auth.router)
_scheduler = None
_scheduler_error: str | None = None
_refresh_lock = threading.Lock()
# ---- cache headers -------------------------------------------------------
#
# StaticFiles ships responses with no Cache-Control, so Cloudflare returns
# `cf-cache-status: DYNAMIC` and every visit hits Railway directly. Add
# sensible defaults: HTML pages get 5 min browser / 15 min CDN cache (with
# stale-while-revalidate so re-fetches feel instant), JSON data endpoints
# get longer s-maxage since they're consumed by API users. Routes that
# already set Cache-Control (e.g. badge SVGs at app.py:172) win — we only
# fill in the gaps.
_HTML_CACHE = "public, max-age=300, s-maxage=900, stale-while-revalidate=86400"
_JSON_CACHE = "public, max-age=600, s-maxage=1800, stale-while-revalidate=86400"
# Static images/fonts (OG share cards, logos, favicons). Without a header these
# ship `cf-cache-status: BYPASS` and every social unfurl / crawler fetch of a
# ~50-60KB card hits Railway. Per-agent OG cards regenerate when a score moves,
# so a 1-day edge TTL (stale-while-revalidate serves instantly, revalidates in
# the background) keeps them fresh enough while cutting origin egress to ~zero.
_ASSET_CACHE = "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800"
_CSP = (
"default-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"img-src 'self' data:"
)
_CANONICAL_HOST = "hvtracker.net"
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
_DYNAMIC_SLASH_PATHS = {
"/alerts",
"/badges",
"/blog",
"/changes",
"/changelog",
"/compare",
"/verify",
"/correct",
"/data",
"/data-api",
"/ecosystem",
"/movers",
"/methodology",
"/org",
"/roadmap",
"/spec",
"/sponsor",
"/submit",
"/use-cases",
}
_HEALTHCHECK_PATHS = {"/healthz", "/healthz/"}
# Retired sections that still attract crawlers (GSC "Not found (404)" cleanup).
# 301 preserves link equity and fires before the static mount, so stale
# generated pages left on the prod volume can never shadow these.
_RETIRED_REDIRECTS = {
"/score-lab/": "/methodology/#runtime-calibration",
"/spec/runtime-trust/v0.1/": "/spec/runtime-trust/v0.3/",
"/spec/runtime-trust/v0.2/": "/spec/runtime-trust/v0.3/",
"/org/i-am-bee/": "/org/",
"/use-cases/recently-active/": "/use-cases/",
# Duplicate listings merged into the original via previous_repos.
"/agents/headroomlabs-headroom/": "/agents/headroom/",
"/agents/zylon-ai-privategpt/": "/agents/privategpt/",
"/agents/notfair-plugin/": "/agents/notfair/",
}
# Agents hard-deleted from agents.json (never in data/retired.json, which only
# carries `legacy` rows the renderer delisted).
_HARD_RETIRED_AGENT_SLUGS = frozenset({"bee-agent-framework"})
_retired_cache = {"mtime": None, "slugs": _HARD_RETIRED_AGENT_SLUGS}
def _retired_slugs() -> frozenset:
"""Slugs whose /agents/<slug>/ page is gone for good → 410 (cached by mtime)."""
path = os.path.join(OUTPUT_DIR, "data", "retired.json")
try:
mtime = os.path.getmtime(path)
except OSError:
return _HARD_RETIRED_AGENT_SLUGS
if _retired_cache["mtime"] != mtime:
try:
with open(path, encoding="utf-8") as f:
slugs = json.load(f).get("agents", [])
except (OSError, json.JSONDecodeError, AttributeError):
return _HARD_RETIRED_AGENT_SLUGS
_retired_cache["slugs"] = frozenset(slugs) | _HARD_RETIRED_AGENT_SLUGS
_retired_cache["mtime"] = mtime
return _retired_cache["slugs"]
def _retired_response(path: str) -> Response | None:
"""301 for retired sections, 410 Gone for retired agent/compare pages."""
lookup = path if path.endswith("/") else f"{path}/"
target = _RETIRED_REDIRECTS.get(lookup)
if target is not None:
return RedirectResponse(target, status_code=301)
parts = [p for p in lookup.split("/") if p]
if len(parts) != 2:
return None
gone = False
if parts[0] == "agents":
gone = parts[1] in _retired_slugs()
elif parts[0] == "compare" and "-vs-" in parts[1]:
gone = any(s in _retired_slugs() for s in parts[1].split("-vs-"))
if gone:
return HTMLResponse(
"<h1>410 Gone</h1><p>This agent has been retired from the HVTracker "
'registry. See the <a href="/">current leaderboard</a>.</p>',
status_code=410,
)
return None
def _external_scheme(request: Request) -> str:
cf_visitor = request.headers.get("cf-visitor")
if cf_visitor:
try:
scheme = json.loads(cf_visitor).get("scheme")
if scheme in {"http", "https"}:
return scheme
except (json.JSONDecodeError, TypeError, AttributeError):
pass
forwarded = request.headers.get("x-forwarded-proto", "")
if forwarded:
scheme = forwarded.split(",", 1)[0].strip().lower()
if scheme in {"http", "https"}:
return scheme
return request.url.scheme
def _external_host(request: Request) -> str:
forwarded_host = request.headers.get("x-forwarded-host")
raw_host = forwarded_host.split(",", 1)[0].strip() if forwarded_host else request.headers.get("host", "")
return raw_host.split(":", 1)[0].lower()
def _path_needs_trailing_slash(path: str) -> bool:
if path in ("", "/") or path.endswith("/"):
return False
if path.startswith("/spec/"):
parts = [part for part in path.split("/") if part]
if len(parts) >= 3:
return True
if "." in path.rsplit("/", 1)[-1]:
return False
if path in _DYNAMIC_SLASH_PATHS or path.startswith("/track/"):
return True
candidate = os.path.join(OUTPUT_DIR, path.lstrip("/"))
return os.path.isdir(candidate) and os.path.isfile(os.path.join(candidate, "index.html"))
def _canonical_redirect_target(request: Request) -> str | None:
scheme = _external_scheme(request)
host = _external_host(request)
orig_path = request.url.path or "/"
# Collapse repeated slashes: /agents/composio// currently 200s (StaticFiles
# resolves the doubled path) and Google indexes it as a duplicate of the
# single-slash canonical. Redirect to the collapsed path so each page has
# exactly one crawlable URL. Query string is untouched. The "already
# canonical" guards below compare the target against orig_path (not the
# collapsed path), or a doubled-slash URL would match itself and never
# redirect.
path = re.sub(r"/{2,}", "/", orig_path) if "//" in orig_path else orig_path
target_path = f"{path}/" if _path_needs_trailing_slash(path) else path
if host in _LOCAL_HOSTS:
if target_path == orig_path:
return None
query = request.url.query
suffix = f"?{query}" if query else ""
return f"{request.url.scheme}://{request.headers.get('host', host)}{target_path}{suffix}"
if scheme == "https" and host == _CANONICAL_HOST and target_path == orig_path:
return None
query = request.url.query
suffix = f"?{query}" if query else ""
return f"https://{_CANONICAL_HOST}{target_path}{suffix}"
# ---- machine-surface usage counters (master plan 1.2) ---------------------
# In-memory since last process start — enough to establish the API/MCP usage
# baseline KPI without any storage. Exposed in /healthz as machine_usage.
_usage_counters = {"api_v1": 0, "mcp": 0, "data_json": 0, "agent_records": 0, "exports": 0, "registry": 0}
_USAGE_SINCE = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# /mcp requests by protocol era: 2026-07-28 clients name the method in an
# Mcp-Method header; 2025-era clients send none ("legacy"). Shows how fast
# clients move to the new revision. Unknown header values fold into "other"
# so arbitrary input can't grow the dict.
_MCP_METHODS = frozenset({
"server/discover", "tools/list", "tools/call", "resources/list",
"resources/read", "prompts/list", "subscriptions/listen", "ping",
})
_mcp_method_counters: dict[str, int] = {}
# What the "other" bucket actually holds: the first 20 distinct unknown method
# names (truncated), so a flood of junk headers can't grow it without bound.
_MCP_OTHER_LIMIT = 20
_mcp_other_methods: dict[str, int] = {}
# Badge SVG fetches per agent slug. READMEs embed badges via GitHub's camo
# proxy, which caches the SVG and strips referrers — so GA never sees this
# reach at all, and each fetch here represents MANY actual README views
# (camo re-fetches only when its cached copy expires). Counted only for
# badges that resolve to a real agent, so scanner noise can't inflate it.
_badge_counters: dict[str, int] = {}
def _count_badge(slug: str) -> None:
_badge_counters[slug] = _badge_counters.get(slug, 0) + 1
# The /live/ page polls /api/v1/usage, and the badge/status widgets poll their
# own endpoints. Counting those would let our own site inflate the "machine
# consumer" numbers it is reporting — the same self-counting mistake the
# verify feed made. Excluded from machine_usage by path.
# The homepage's own deferred board rows are a page asset fetched by people
# searching the leaderboard, not machine use of the data surface.
_USAGE_EXCLUDED_PATHS = frozenset({"/api/v1/usage", "/data/board-rest.json", "/data/search-index.json"})
# Daily snapshots are the registry's irreplaceable asset: one 4MB file per day
# holding every row with all 62 fields, including trust_breakdown and
# scorecard_checks — the scoring internals, not just the published scores. They
# were never deliberately published; the catch-all StaticFiles mount over
# OUTPUT_DIR simply exposed them, and the date-based filenames make the whole
# corpus enumerable with a loop. The site itself reads these from DISK, never
# over HTTP, so refusing them here costs nothing.
#
# The curated public history surface stays open and unaffected:
# GET /api/v1/agents/<slug>/history (90-day window, whitelisted fields).
_PRIVATE_SNAPSHOT_PREFIXES = ("/output/history/", "/output/history")
# The generator's API-response cache lives on the volume beside the site
# (see _refresh_cache_dir); it is never meant to be served.
_PRIVATE_CACHE_PREFIX = "/.cache"
_PUBLIC_SNAPSHOT_RE = re.compile(r"^/data/history/(\d{4}-\d{2}-\d{2})\.json$")
def _is_expired_public_snapshot(path: str) -> bool:
"""/data/history/<date>.json copies older than the public 90-day window.
The same window as /api/v1/agents/<slug>/history and the open-core plan
(extended history is the reserved future tier); the files stay on disk,
and the quarterly CC BY export remains the path to the full series."""
m = _PUBLIC_SNAPSHOT_RE.match(path)
if not m:
return False
cutoff = (datetime.now(timezone.utc).date() - timedelta(days=_HISTORY_PUBLIC_DAYS - 1)).isoformat()
return m.group(1) < cutoff
def _is_private_snapshot_path(path: str) -> bool:
"""True for raw daily-snapshot and cache paths, which must not be served publicly."""
return (path.startswith(_PRIVATE_SNAPSHOT_PREFIXES[0]) or path == _PRIVATE_SNAPSHOT_PREFIXES[1]
or path == _PRIVATE_CACHE_PREFIX or path.startswith(_PRIVATE_CACHE_PREFIX + "/"))
def _count_machine_usage(path: str, mcp_method: str | None = None) -> None:
if path in _USAGE_EXCLUDED_PATHS:
return
channel = None
if path.startswith("/api/v1/"):
channel = "api_v1"
elif path == "/mcp" or path.startswith("/mcp/"):
channel = "mcp"
elif path.startswith("/data/exports/"):
channel = "exports"
elif path.startswith("/registry/") and "/v0.1/" in path:
channel = "registry"
elif path.startswith("/data/agents/") and path.endswith(".json"):
# Per-agent records carry the signed trust_credential, so their
# fetches are the closest measure of anyone verifying a record.
channel = "agent_records"
elif path.startswith("/data/") and path.endswith(".json"):
channel = "data_json"
if channel is None:
return
_usage_counters[channel] += 1
usage.bump(channel) # durable rollup behind /live/
if channel == "mcp":
key = mcp_method if mcp_method in _MCP_METHODS else ("other" if mcp_method else "legacy")
_mcp_method_counters[key] = _mcp_method_counters.get(key, 0) + 1
if key == "other":
name = mcp_method[:40]
if name in _mcp_other_methods or len(_mcp_other_methods) < _MCP_OTHER_LIMIT:
_mcp_other_methods[name] = _mcp_other_methods.get(name, 0) + 1
@app.middleware("http")
async def _cache_headers(request, call_next):
path = request.url.path
_count_machine_usage(path, request.headers.get("mcp-method"))
if path not in _HEALTHCHECK_PATHS:
redirect_target = _canonical_redirect_target(request)
if redirect_target is not None:
status_code = 301 if request.method in {"GET", "HEAD"} else 308
return RedirectResponse(redirect_target, status_code=status_code)
retired = _retired_response(path)
if retired is not None:
return retired
if _is_private_snapshot_path(path) or _is_expired_public_snapshot(path):
# 404, not 403: don't confirm that a given date's snapshot exists.
return Response("Not Found", status_code=404, media_type="text/plain")
if path == "/mcp" and request.method == "GET":
# A GET opens the optional server-to-client stream, which this
# stateless JSON server doesn't offer (the spec allows 405). Under MCP
# SDK v2 the request would otherwise hang open until the client gives up.
return Response(status_code=405, headers={"Allow": "POST"})
if path == "/mcp" and request.method == "POST":
if not _mcp_enabled():
return JSONResponse(
{"jsonrpc": "2.0", "id": None, "error": {"code": -32000,
"message": "HVTracker MCP is temporarily paused to stay within budget. "
"The website and the HTTP API at /api/v1/mcp/verify remain available."}},
status_code=503,
headers={"Retry-After": "3600"},
)
if _is_mcp_rate_limited(request):
return JSONResponse(
{"jsonrpc": "2.0", "id": None, "error": {"code": -32029,
"message": "Rate limit exceeded — too many MCP requests from your IP. Retry in a minute."}},
status_code=429,
headers={"Retry-After": "60"},
)
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
response.headers["Content-Security-Policy-Report-Only"] = _CSP
if _external_scheme(request) == "https":
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
if not path.startswith("/badge/"):
response.headers["X-Frame-Options"] = "SAMEORIGIN"
if response.status_code != 200 or "cache-control" in {k.lower() for k in response.headers}:
return response
# User-specific / auth / API responses must never be cached (or shared by a
# CDN): /account shows the signed-in user's data, /login varies by auth state.
if (path in {"/account", "/account/", "/login", "/login/"}
or path.startswith("/api/") or path.startswith("/auth/")):
response.headers["Cache-Control"] = "no-store"
return response
if path.startswith("/data/") and path.endswith(".json"):
response.headers["Cache-Control"] = _JSON_CACHE
elif path in ("/sitemap.xml", "/feed.json") or path.endswith("/feed.xml"):
# Feeds + sitemap regenerate at most once per render; without a header
# Cloudflare's cache rule marks them BYPASS and every crawler poll hits
# the origin.
response.headers["Cache-Control"] = _JSON_CACHE
elif path.endswith("/") or path.endswith(".html"):
response.headers["Cache-Control"] = _HTML_CACHE
elif path in ("",) or path == "/":
response.headers["Cache-Control"] = _HTML_CACHE
elif path.endswith((".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg",
".ico", ".woff2", ".woff", ".ttf")):
# OG cards, logos, favicons, fonts — without this they're BYPASS.
response.headers["Cache-Control"] = _ASSET_CACHE
return response
# Compress on the way out. Cloudflare compresses for visitors, so the site
# always *looked* fine, but nothing compressed the origin->edge hop and Railway
# bills that hop uncompressed: the homepage left the container at 6.4MB instead
# of 0.54MB, and with /data/latest.json (9.0MB raw) the two accounted for ~94%
# of a ~98GB/month egress bill. StaticFiles never compresses on its own.
# Registered LAST so it is the outermost middleware (add_middleware inserts at
# index 0 and the stack is built inside-out), which puts it around both
# _cache_headers and the StaticFiles mount.
# compresslevel=6, not the library default of 9: measured on the real homepage,
# 9 costs 2.1x the CPU of 6 to gain 6.6% (30.6ms/538KB vs 65.3ms/503KB).
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=6)
# ---- data.json access (mtime-cached) -------------------------------------
_cache: dict = {"mtime": None, "data": None}
def load_data() -> dict:
"""Return the current leaderboard from the volume's data.json (cached by mtime)."""
try:
mtime = os.path.getmtime(DATA_PATH)
except OSError:
return {"agents": [], "total": 0, "updated": None}
if _cache["mtime"] != mtime:
for _ in range(3):
try:
with open(DATA_PATH, encoding="utf-8") as f:
_cache["data"] = json.load(f)
_cache["mtime"] = mtime
break
except json.JSONDecodeError:
if _cache["data"] is not None:
return _cache["data"]
time.sleep(0.05)
else:
return {"agents": [], "total": 0, "updated": None}
return _cache["data"]
def _catalog_agent_count() -> int:
try:
with open(os.path.join(BASE_DIR, "agents.json"), encoding="utf-8") as f:
return len(json.load(f))
except (OSError, json.JSONDecodeError, TypeError):
return 0
def _source_render_count() -> int:
catalog_count = 0
try:
with open(os.path.join(BASE_DIR, "agents.json"), encoding="utf-8") as f:
agents = json.load(f)
catalog_count = sum(
1 for agent in agents
if agent.get("status") != "legacy" and agent.get("listing_status") != "legacy"
)
except (OSError, json.JSONDecodeError, TypeError):
pass
path = os.path.join(BASE_DIR, "data", "render_state.json")
try:
with open(path, encoding="utf-8") as f:
payload = json.load(f)
return max(catalog_count, len(payload.get("rows") or []))
except (OSError, json.JSONDecodeError, TypeError):
return catalog_count
def _normalize_github_repo(value: str | None) -> str | None:
if not value:
return None
text = value.strip()
if not text or text.lower().startswith("pkg:"):
return None # a package URL is never a repo ("pkg:npm/x" has two parts)
text = text.replace("git@github.com:", "https://github.com/")
text = text.replace("ssh://git@github.com/", "https://github.com/")
text = text.replace("git+https://github.com/", "https://github.com/")
text = text.replace("git://github.com/", "https://github.com/")
if "github.com/" in text.lower():
m = re.search(r"github\.com[:/]+([^/\s]+)/([^/\s?#]+)", text, re.IGNORECASE)
if not m:
return None
owner, repo = m.group(1), m.group(2)
else:
parts = [part for part in text.strip("/").split("/") if part]
if len(parts) != 2:
return None
owner, repo = parts
repo = repo.removesuffix(".git")
return f"{owner.lower()}/{repo.lower()}" if owner and repo else None
def _tracked_repo_lookup() -> dict[str, dict]:
return {
(agent.get("repo") or "").lower(): agent
for agent in db.load_agents()
if agent.get("repo")
}
def load_manual_candidates() -> list[dict]:
path = os.path.join(BASE_DIR, "docs", "import-candidates.json")
try:
with open(path, encoding="utf-8") as f:
payload = json.load(f)
except (OSError, json.JSONDecodeError, TypeError):
return []
if not isinstance(payload, list):
return []
tracked = _tracked_repo_lookup()
items = []
for entry in payload:
if isinstance(entry, str):
source_repo = entry
item = {"repo": entry}
elif isinstance(entry, dict):
source_repo = entry.get("repo") or entry.get("url") or entry.get("github")
item = dict(entry)
else:
continue
normalized_repo = _normalize_github_repo(source_repo)
if not normalized_repo:
continue
tracked_agent = tracked.get(normalized_repo)
item["repo"] = normalized_repo
item["url"] = f"https://github.com/{normalized_repo}"
item["tracked"] = tracked_agent is not None
item["tracked_name"] = tracked_agent.get("name") if tracked_agent else None
items.append(item)
return items
_HEADER_ENV = None
SITE_HEADER_MARKER = "<!--#site-header-->"
def _site_header_html(updated: str) -> str:
"""The site header, rendered from templates/_site_header.html.j2: the same
partial every generated page includes, so marketing/login pages and the
hand-written tools (/scan/, /verify/, /live/, /compare/) can't drift from
it (they each carried a hand-copied header with stale links before)."""
global _HEADER_ENV
if _HEADER_ENV is None:
from jinja2 import Environment, FileSystemLoader
_HEADER_ENV = Environment(loader=FileSystemLoader(os.path.join(BASE_DIR, "templates")),
autoescape=True)
return _HEADER_ENV.get_template("_site_header.html.j2").render(
updated=updated, auth_js_hash=_AUTH_JS_VER)
def _with_site_header(html: str) -> str:
"""Fill a hand-written page's <!--#site-header--> slot."""
return html.replace(SITE_HEADER_MARKER, _site_header_html(""), 1)
def _runtime_git_sha() -> str | None:
for key in (
"RAILWAY_GIT_COMMIT_SHA",
"GIT_COMMIT_SHA",
"GITHUB_SHA",
"SOURCE_COMMIT",
"COMMIT_SHA",
):
value = (os.environ.get(key) or "").strip()
if value:
return value
return None
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_updated_timestamp(value: str | None) -> datetime | None:
if not value:
return None
text = value.strip()
for fmt in ("%Y-%m-%d %H:%M UTC", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z"):
try:
parsed = datetime.strptime(text, fmt)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
except ValueError:
continue
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
except ValueError:
return None
def _read_refresh_status() -> dict:
try:
with open(REFRESH_STATUS_PATH, encoding="utf-8") as f:
payload = json.load(f)
return payload if isinstance(payload, dict) else {}
except (OSError, json.JSONDecodeError, TypeError):
return {}
def _write_refresh_status(status: dict) -> None:
with open(REFRESH_STATUS_PATH, "w", encoding="utf-8") as f:
json.dump(status, f, indent=2, sort_keys=True)
def _mark_refresh_status(**updates) -> dict:
status = _read_refresh_status()
status.update(updates)
_write_refresh_status(status)
return status
def find_agent(repo: str) -> dict | None:
repo = repo.lower()
for a in load_data().get("agents", []):
if a["repo"].lower() == repo:
return a
return None
def find_agent_by_slug(slug: str) -> dict | None:
slug = slug.lower()
for a in load_data().get("agents", []):
if (a.get("slug") or "").lower() == slug:
return a
return None
_identifiers_cache: dict = {"mtime": None, "purl": {}, "registry": {}}
def _identifier_slugs(identifier: str) -> list[str]:
"""Listing slugs that data/identifiers.json (written each render) ties to a
package URL (pkg:npm/..., pkg:pypi/..., pkg:cargo/...) or an MCP registry
name. Registry names match case-insensitively."""
path = os.path.join(OUTPUT_DIR, "data", "identifiers.json")
try:
mtime = os.path.getmtime(path)
if _identifiers_cache["mtime"] != mtime:
with open(path, encoding="utf-8") as f:
ids = json.load(f).get("identifiers") or {}
_identifiers_cache.update(
mtime=mtime, purl=ids.get("purl") or {},
registry={k.lower(): v for k, v in (ids.get("mcp-registry") or {}).items()})
except (OSError, ValueError):
return []
purl = mcp_trust.parse_purl(identifier)
if purl:
return _identifiers_cache["purl"].get(purl, [])
return _identifiers_cache["registry"].get(identifier.lower(), [])
def find_agent_by_key(key: str) -> dict | None:
"""A tracked agent by exact slug, then exact display name, then a package
URL or MCP registry name from the identifier index, then npm/PyPI package
id. Slugs and names are unique; package ids are not across
ecosystems (npm `fastmcp` is punkpeye/fastmcp, PyPI `fastmcp` is
PrefectHQ/fastmcp, slug `fastmcp`). A single first-match pass in board
order returned whichever ranked higher, so "fastmcp" resolved to the
TypeScript port. An indexed identifier resolves only when it names one
listing; with several there is nothing to choose by."""
raw = (key or "").strip()
key = raw.lower()
if not key:
return None
agents = load_data().get("agents", [])
for field in ("slug", "name"):
for a in agents:
if (a.get(field) or "").strip().lower() == key:
return a
slugs = _identifier_slugs(raw)
if len(slugs) == 1:
indexed = find_agent_by_slug(slugs[0])
if indexed is not None:
return indexed
for a in agents:
if key in ((a.get("npm_package") or "").lower(), (a.get("pypi_package") or "").lower()):
return a
return None
def _resolve_registry_agent(identifier: str) -> dict | None:
"""Resolve an identifier to a tracked agent: GitHub repo/URL first, then
find_agent_by_key (slug, display name, npm/pypi package). Shared by
/api/v1/mcp/verify, scan_stack and the MCP server so all agree on what a
string maps to."""
identifier = (identifier or "").strip()
if not identifier:
return None
repo = _normalize_github_repo(identifier)
agent = find_agent(repo) if repo else None
if agent is None:
agent = find_agent_by_key(identifier)
return agent
_SCAN_MAX_ITEMS = 60
def _parse_scan_input(text: str) -> list[str]:
"""Extract candidate identifiers from pasted requirements.txt, package.json,
an MCP client config, or a plain (newline/comma) list. Best-effort and
tolerant — unknown shapes fall back to line parsing."""
text = (text or "").strip()
if not text:
return []
ids: list[str] = []
try:
obj = json.loads(text)
except (json.JSONDecodeError, ValueError):
obj = None
if isinstance(obj, dict):
for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
dep = obj.get(key)
if isinstance(dep, dict):
ids.extend(dep.keys())
servers = obj.get("mcpServers")
if isinstance(servers, dict):
for name, cfg in servers.items():
ids.append(name)
if isinstance(cfg, dict) and isinstance(cfg.get("url"), str):
ids.append(cfg["url"])
if not ids:
# requirements.txt / plain list: strip version specifiers, extras, markers, comments.
for raw in re.split(r"[\n,]", text):
line = raw.strip()
if not line or line.startswith("#") or line.startswith("-"):
continue
token = re.split(r"[<>=!~;\[ @]", line, maxsplit=1)[0].strip()
if token:
ids.append(token)
seen, out = set(), []
for i in ids:
i = i.strip()
k = i.lower()
if i and k not in seen:
seen.add(k)
out.append(i)
return out[:_SCAN_MAX_ITEMS]
# ---- JSON API ------------------------------------------------------------
@app.api_route(
"/.well-known/mcp/server-card.json",
methods=["GET", "HEAD"],
include_in_schema=False,
)
def smithery_mcp_server_card():
return JSONResponse(mcp_server.server_card(), headers={"Cache-Control": _JSON_CACHE})
@app.api_route("/healthz", methods=["GET", "HEAD"])
@app.api_route("/healthz/", methods=["GET", "HEAD"])
def healthz():
d = load_data()
source_fingerprint = _compute_render_fingerprint()
stored_fingerprint = _read_render_fingerprint()
refresh_status = _read_refresh_status()
updated_at = _parse_updated_timestamp(d.get("updated"))
data_age_seconds = None
data_fresh = None
if updated_at is not None:
data_age_seconds = max(0, int((datetime.now(timezone.utc) - updated_at).total_seconds()))
data_fresh = data_age_seconds <= int(MAX_DATA_AGE.total_seconds())
return {
"status": "ok",
"agents": d.get("total", 0),
"catalog_agents": _catalog_agent_count(),
"source_render_agents": _source_render_count(),
"updated": d.get("updated"),
"max_data_age_seconds": int(MAX_DATA_AGE.total_seconds()),
"data_age_seconds": data_age_seconds,
"data_fresh": data_fresh,
"git_sha": _runtime_git_sha(),
"source_render_fingerprint": source_fingerprint,
"stored_render_fingerprint": stored_fingerprint,
"render_in_sync": bool(stored_fingerprint and stored_fingerprint == source_fingerprint),
"refresh_in_progress": bool(refresh_status.get("in_progress")),
"last_refresh_started_at": refresh_status.get("last_started_at"),
"last_refresh_completed_at": refresh_status.get("last_completed_at"),
"last_refresh_succeeded": refresh_status.get("last_succeeded"),
"last_refresh_mode": refresh_status.get("last_mode"),
"last_refresh_error": refresh_status.get("last_error"),
"scheduler_running": bool(_scheduler is not None and _scheduler.running),
"scheduler_error": _scheduler_error,
"scheduled_jobs": _scheduled_jobs(),
"process_rss_mb": _process_rss_mb(),
"api_cache_entries": _api_cache_entries(),
"machine_usage": {"since": _USAGE_SINCE, **_usage_counters,
"mcp_by_method": dict(_mcp_method_counters),
"mcp_other_methods": dict(_mcp_other_methods)},
"badge_fetches": {
"since": _USAGE_SINCE,
"total": sum(_badge_counters.values()),
"top": dict(sorted(_badge_counters.items(), key=lambda kv: -kv[1])[:20]),
},
}
@app.api_route("/api/agents", methods=["GET", "HEAD"])
def api_agents(q: str = "", category: str = "", sort: str = "rank",
order: str = "asc", limit: int = 50, offset: int = 0):
"""Search/filter/sort/paginate the leaderboard."""
agents = load_data().get("agents", [])
if q:
ql = q.lower()
agents = [a for a in agents
if ql in a["name"].lower() or ql in a["repo"].lower()
or ql in (a.get("description") or "").lower()]
if category:
cl = category.lower()