subgraph-registry-mcp 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -188,6 +188,53 @@ in the main list and the 40-day-old Monad perps subgraph under `emerging`.
188
188
  so it is already age-neutral — it carries the `maturity` labels but no
189
189
  `emerging` list, because a three-week-old subgraph can top it on merit.
190
190
 
191
+ ### Ranking
192
+
193
+ Three tools rank, and each ranks differently on purpose:
194
+
195
+ - **`search_subgraphs`** — orders by how many of your query terms matched, then
196
+ by reliability. OR-ing the terms and ordering on reliability alone meant a
197
+ popular subgraph matching one incidental word beat a precise match on all
198
+ three, so being *more* specific returned worse answers. Version tokens
199
+ (`v2`, `v3`, `v4`) are kept rather than dropped as too short.
200
+ - **`semantic_search_subgraphs`** — orders by `semantic_score × (0.5 + 0.5 ×
201
+ reliability)`. Pure cosine put testnets first, since their text is nearly
202
+ identical to mainnet's. The 0.5 floor keeps new subgraphs competitive.
203
+ - **`recommend_subgraph`** — infers domain and protocol type from the goal, but
204
+ as a *ranking bonus*, never a filter. As a filter, one bad keyword collapsed
205
+ the candidate pool to nothing.
206
+
207
+ A term matching a subgraph's **name** counts for more than one matching its
208
+ description — `%ens%` also matches "tok**ens**", so equal weighting handed a
209
+ search for `ens` to four Uniswap subgraphs.
210
+
211
+ Chain names are aliased, so `ethereum`, `arbitrum`, `polygon` and `bnb` resolve
212
+ to the corpus values `mainnet`, `arbitrum-one`, `matic` and `bsc`.
213
+
214
+ ### Testnets
215
+
216
+ 723 of the 5,425 served subgraphs are on testnets, and their text is nearly
217
+ identical to their mainnet twins', so they compete for the top slot. They are
218
+ **excluded by default** and every result carries `testnet: true|false`. Pass
219
+ `include_testnets: true` to see them — and an explicit request for a testnet
220
+ network (`network: "sepolia"`) always wins over the default, so that still
221
+ returns exactly what you asked for.
222
+
223
+ ## Using the registry from payql
224
+
225
+ [`payql`](https://www.npmjs.com/package/payql) can use this registry as its
226
+ free discovery source instead of paying for a network-subgraph query. Run the
227
+ registry's HTTP transport and point payql at it:
228
+
229
+ ```bash
230
+ npx subgraph-registry-mcp --http-only # serves :3848
231
+ PAYQL_REGISTRY_URL=http://127.0.0.1:3848/graphql npx -y payql
232
+ ```
233
+
234
+ `POST /graphql` answers in the Graph network subgraph's `subgraphMetadataSearch`
235
+ shape, which is what payql already parses — so this needs no change on payql's
236
+ side, and discovery becomes free and locally-ranked.
237
+
191
238
  ### Denied deployments
192
239
 
193
240
  Curation-denied deployments (`deniedAt > 0` — denied indexing rewards, usually
package/data/registry.db CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subgraph-registry-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "mcpName": "io.github.PaulieB14/subgraph-registry-mcp",
5
5
  "description": "MCP server for agent-friendly subgraph discovery on The Graph Network. 15,330 classified subgraphs with x402 query URLs ($0.01 USDC on Base, no API key required), reliability scoring, and protocol classification.",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -65,7 +65,7 @@ const GITHUB_DB_URL =
65
65
  // 3. Paste the new hash here and bump package.json version
66
66
  // 4. Update SKILL.md "Verifying the registry" section
67
67
  const EXPECTED_DB_SHA256 =
68
- "ec7bb54f72379c0d4921b0c34c1c666c46e44c82f334efda440aeb2db09fa2d0";
68
+ "425b7a5bde8f61d8ae2f26ea6e201ffd3308c3328a0547fb0d29530222eba0d2";
69
69
  // Skip-verification escape hatch (set to "1" only if you're rebuilding the DB
70
70
  // locally and know what you're doing — never set in agent-runtime defaults).
71
71
  const SKIP_VERIFY = process.env.SUBGRAPH_REGISTRY_SKIP_VERIFY === "1";
@@ -177,6 +177,109 @@ function getDb() {
177
177
  return db;
178
178
  }
179
179
 
180
+ // ── Network aliases ────────────────────────────────────────
181
+ // The corpus stores graph-node's chain IDs (`mainnet`, `arbitrum-one`,
182
+ // `matic`), but every human and every model says "ethereum", "arbitrum",
183
+ // "polygon" — and so does our own auto_description, which prints the pretty
184
+ // name from classifier.py NETWORK_NAMES. So an agent reads "Ethereum" in a
185
+ // description, passes network:"ethereum" back, and gets zero results with no
186
+ // error. SKILL.md made it worse by documenting `ethereum, arbitrum, base` as
187
+ // the example values; two of those three matched nothing.
188
+ // mainnet/bsc/arbitrum-one/matic alone are ~45% of the corpus.
189
+ const NETWORK_ALIASES = {
190
+ ethereum: "mainnet",
191
+ eth: "mainnet",
192
+ "ethereum-mainnet": "mainnet",
193
+ arbitrum: "arbitrum-one",
194
+ "arbitrum one": "arbitrum-one",
195
+ arb: "arbitrum-one",
196
+ polygon: "matic",
197
+ "polygon-pos": "matic",
198
+ bnb: "bsc",
199
+ "bnb-chain": "bsc",
200
+ "binance-smart-chain": "bsc",
201
+ binance: "bsc",
202
+ op: "optimism",
203
+ "optimism-mainnet": "optimism",
204
+ avax: "avalanche",
205
+ "avalanche-c-chain": "avalanche",
206
+ xdai: "gnosis",
207
+ zksync: "zksync-era",
208
+ "zksync era": "zksync-era",
209
+ blast: "blast-mainnet",
210
+ "polygon-zk": "polygon-zkevm",
211
+ near: "near-mainnet",
212
+ mode: "mode-mainnet",
213
+ sei: "sei-mainnet",
214
+ ftm: "fantom",
215
+ };
216
+
217
+ // Normalize a caller-supplied chain name to the value stored in the corpus.
218
+ // Unknown values pass through untouched so a legitimate new chain still works.
219
+ function normalizeNetwork(name) {
220
+ if (!name || typeof name !== "string") return name;
221
+ const k = name.trim().toLowerCase();
222
+ return NETWORK_ALIASES[k] || k;
223
+ }
224
+
225
+ // ── Testnets ───────────────────────────────────────────────
226
+ // 723 of the 5,425 served, non-denied subgraphs (13.3%) are on testnets, and
227
+ // they compete directly with production because a testnet deployment's text is
228
+ // near-identical to its mainnet twin's — that is exactly how ENS Sepolia (58
229
+ // queries/30d) came to outrank ENS mainnet (34.8M queries/30d).
230
+ //
231
+ // Detected from the network name rather than a new column, so it works on the
232
+ // corpus already shipped. All 57 matching networks were checked by hand; the
233
+ // six without an explicit -testnet/-sepolia/-goerli suffix (chapel, fuji,
234
+ // holesky, holesky-beacon, mumbai, polygon-amoy) are the well-known BSC,
235
+ // Avalanche, Ethereum and Polygon testnets.
236
+ const TESTNET_MARKERS = [
237
+ "sepolia", "goerli", "testnet", "devnet", "chapel", "fuji",
238
+ "holesky", "mumbai", "amoy", "baobab", "rinkeby", "kovan",
239
+ ];
240
+
241
+ function isTestnetNetwork(name) {
242
+ if (!name) return false;
243
+ const n = String(name).toLowerCase();
244
+ return TESTNET_MARKERS.some((m) => n.includes(m));
245
+ }
246
+
247
+ // SQL fragment excluding testnets. Static string — the markers are a constant
248
+ // allowlist, never caller input, so there is nothing to bind or escape.
249
+ const NOT_TESTNET_SQL = TESTNET_MARKERS.map(
250
+ (m) => `network NOT LIKE '%${m}%'`,
251
+ ).join(" AND ");
252
+
253
+ // Whether to hide testnets for this call.
254
+ //
255
+ // The trap: a caller who explicitly asks for network:"sepolia" must not get an
256
+ // empty result because a default filter silently contradicts their request.
257
+ // An explicit testnet network always wins over the default exclusion.
258
+ function shouldExcludeTestnets({ include_testnets, network }) {
259
+ if (include_testnets) return false;
260
+ if (network && isTestnetNetwork(normalizeNetwork(network))) return false;
261
+ return true;
262
+ }
263
+
264
+ // Tokenize a free-text query into searchable terms.
265
+ //
266
+ // The old inline version was `.filter((w) => w.length > 2)`, which silently
267
+ // dropped every protocol version token — "v2", "v3", "v4" are all two chars.
268
+ // That made "uniswap v3" byte-identical to "uniswap", so the single most
269
+ // natural way to disambiguate the largest protocol family in the corpus did
270
+ // nothing at all. Keep the length floor for noise words, but let version
271
+ // tokens through.
272
+ const VERSION_TOKEN_RE = /^v\d+$/;
273
+
274
+ function queryTerms(query) {
275
+ return query
276
+ .trim()
277
+ .toLowerCase()
278
+ .split(/\s+/)
279
+ .filter((w) => w.length > 2 || VERSION_TOKEN_RE.test(w))
280
+ .slice(0, 5);
281
+ }
282
+
180
283
  // ── Maturity / cold-start handling ─────────────────────────
181
284
  // reliability_score is built from four CUMULATIVE inputs (curation signal,
182
285
  // indexer stake, lifetime query fees, 30d volume — see _reliability_score in
@@ -266,6 +369,7 @@ function searchSubgraphs({
266
369
  min_reliability = 0,
267
370
  include_unserved = false,
268
371
  include_denied = false,
372
+ include_testnets = false,
269
373
  limit = 20,
270
374
  } = {}) {
271
375
  const conditions = [];
@@ -290,6 +394,9 @@ function searchSubgraphs({
290
394
  if (!include_denied) {
291
395
  conditions.push("denied_at = 0");
292
396
  }
397
+ if (shouldExcludeTestnets({ include_testnets, network })) {
398
+ conditions.push(`(${NOT_TESTNET_SQL})`);
399
+ }
293
400
 
294
401
  if (domain) {
295
402
  conditions.push("domain = ?");
@@ -297,7 +404,7 @@ function searchSubgraphs({
297
404
  }
298
405
  if (network) {
299
406
  conditions.push("network = ?");
300
- params.push(network);
407
+ params.push(normalizeNetwork(network));
301
408
  }
302
409
  if (protocol_type) {
303
410
  conditions.push("protocol_type = ?");
@@ -311,9 +418,34 @@ function searchSubgraphs({
311
418
  conditions.push("reliability_score >= ?");
312
419
  params.push(min_reliability);
313
420
  }
421
+ // Terms are OR'd for recall, then RANKED by how many of them matched.
422
+ //
423
+ // Before this, a multi-word query OR'd its terms and ordered the result by
424
+ // reliability alone, so a high-reliability subgraph matching ONE incidental
425
+ // word beat a lower-reliability one matching all three. The effect was that
426
+ // being more specific made the answer worse: "aave lending arbitrum"
427
+ // returned uniswap-v3-arbitrum, Arbitrum Minimal, camelot-amm-v3 and Graph
428
+ // TAP — not one Aave subgraph — while the bare query "aave" was correct.
429
+ //
430
+ // Keep the OR (dropping to AND would kill recall on descriptions that
431
+ // phrase things differently) and let matched_terms break the tie first.
432
+ //
433
+ // A hit in display_name is worth 3, a hit in the description 1. Counting
434
+ // them equally is not enough on short terms, where LIKE '%term%' matches
435
+ // half the corpus incidentally: searching "ens" scored ENS and four Uniswap
436
+ // subgraphs at 1 apiece — "tokens" contains "ens" — and reliability then
437
+ // handed the top slots to Uniswap. Weighting the name restores ENS to #1
438
+ // without narrowing what still gets found.
439
+ const matchParams = [];
440
+ let matchExpr = "0";
314
441
  if (query) {
315
- const words = query.trim().split(/\s+/).filter((w) => w.length > 2).slice(0, 5);
442
+ const words = queryTerms(query);
316
443
  if (words.length) {
444
+ matchExpr = words
445
+ .map(() => "((CASE WHEN display_name LIKE ? THEN 3 ELSE 0 END) + (CASE WHEN (description LIKE ? OR auto_description LIKE ?) THEN 1 ELSE 0 END))")
446
+ .join(" + ");
447
+ words.forEach((w) => matchParams.push(`%${w}%`, `%${w}%`, `%${w}%`));
448
+
317
449
  const wordConds = words.map(() => "(display_name LIKE ? OR description LIKE ? OR auto_description LIKE ?)");
318
450
  words.forEach((w) => params.push(`%${w}%`, `%${w}%`, `%${w}%`));
319
451
  conditions.push(`(${wordConds.join(" OR ")})`);
@@ -327,18 +459,20 @@ function searchSubgraphs({
327
459
  // Over-fetch to allow dedup by IPFS hash (same deployment, different subgraph IDs)
328
460
  const fetchLimit = limit * 3;
329
461
  const sql = `
330
- SELECT ${SEARCH_COLS}
462
+ SELECT ${SEARCH_COLS}, (${matchExpr}) AS matched_terms
331
463
  FROM subgraphs
332
464
  ${where}
333
- ORDER BY reliability_score DESC
465
+ ORDER BY matched_terms DESC, reliability_score DESC
334
466
  LIMIT ?
335
467
  `;
336
468
  // Snapshot the filter params BEFORE the LIMIT is appended — the emerging
337
469
  // companion query reuses the same WHERE and must not inherit this LIMIT.
338
470
  const filterParams = [...params];
339
- params.push(fetchLimit);
340
471
 
341
- const rows = getDb().prepare(sql).all(...params);
472
+ // Positional binding order: the SELECT-clause scoring expression is bound
473
+ // before the WHERE clause, so matchParams must lead. filterParams stays
474
+ // WHERE-only, which is what the emerging companion query needs.
475
+ const rows = getDb().prepare(sql).all(...matchParams, ...filterParams, fetchLimit);
342
476
  // Dedup by IPFS hash — keep highest reliability per deployment
343
477
  const seenIpfs = new Set();
344
478
  const results = [];
@@ -362,6 +496,8 @@ function searchSubgraphs({
362
496
  // caller passed include_denied — surfaced so that choice stays visible
363
497
  // in the result rather than being silently carried.
364
498
  denied: Boolean(r.denied_at),
499
+ testnet: isTestnetNetwork(r.network),
500
+ testnet: isTestnetNetwork(r.network),
365
501
  // Ready-to-run GraphQL generated from this subgraph's actual schema — so an
366
502
  // agent can POST it to query_url_x402 immediately, no get_subgraph_detail round-trip.
367
503
  example_query: r.example_query || null,
@@ -394,6 +530,7 @@ function searchSubgraphs({
394
530
  powered_by_substreams: Boolean(r.powered_by_substreams),
395
531
  active_allocation_count: r.active_allocation_count || 0,
396
532
  denied: Boolean(r.denied_at),
533
+ testnet: isTestnetNetwork(r.network),
397
534
  example_query: r.example_query || null,
398
535
  age_days: ageDays(r.created_at),
399
536
  maturity: maturityOf(r.created_at),
@@ -427,59 +564,94 @@ function recommendSubgraph({ goal, chain = "" }) {
427
564
  lending: ["lend", "borrow", "loan", "collateral", "aave", "compound"],
428
565
  bridge: ["bridge", "cross-chain"],
429
566
  staking: ["stake", "validator", "delegation"],
430
- options: ["option", "call", "put", "strike"],
567
+ // "call", "put" and "strike" are gone. They are ordinary English words —
568
+ // "reputation" contains "put", so the goal "reputation scores for onchain
569
+ // agents" inferred protocol_type ["options"] and returned the Polygon
570
+ // Optimistic Oracle. "option" alone is specific enough to keep.
571
+ options: ["option", "derivatives contract"],
431
572
  perpetuals: ["perp", "perpetual", "leverage", "margin"],
432
573
  governance: ["governance", "vote", "proposal"],
433
574
  "name-service": ["ens", "name service", "domain name"],
434
575
  "nft-marketplace": ["nft market", "opensea", "blur"],
435
576
  };
436
577
 
578
+ // Match on word boundaries, not bare substrings. Boundaries alone would not
579
+ // have saved "reputation"/"put" if "put" had stayed in the list — hence the
580
+ // removals above and the demotion from filter to bonus below — but they do
581
+ // stop "smart contract" inferring nfts via "art", and "start"/"chart"/
582
+ // "party" doing the same.
583
+ const hitsGoal = (kws) =>
584
+ kws.some((k) =>
585
+ new RegExp(`\\b${k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(goalLower),
586
+ );
437
587
  const domains = Object.entries(domainMap)
438
- .filter(([, kws]) => kws.some((k) => goalLower.includes(k)))
588
+ .filter(([, kws]) => hitsGoal(kws))
439
589
  .map(([d]) => d);
440
590
  const ptypes = Object.entries(typeMap)
441
- .filter(([, kws]) => kws.some((k) => goalLower.includes(k)))
591
+ .filter(([, kws]) => hitsGoal(kws))
442
592
  .map(([t]) => t);
443
593
 
444
594
  // recommend_subgraph exposes no include_* escape hatches — it answers "which
445
595
  // one should I use", so a curation-denied deployment is never the right
446
596
  // answer and the filter is unconditional here.
447
597
  const conditions = ["active_allocation_count > 0", "denied_at = 0"];
598
+ if (shouldExcludeTestnets({ include_testnets: false, network: chain })) {
599
+ conditions.push(`(${NOT_TESTNET_SQL})`);
600
+ }
448
601
  const params = [];
449
602
 
450
603
  if (chain) {
451
604
  conditions.push("network = ?");
452
- params.push(chain);
605
+ params.push(normalizeNetwork(chain));
606
+ }
607
+ // The inferred domain/protocol_type used to go into the WHERE clause, so a
608
+ // single bad substring collapsed the candidate pool instead of merely
609
+ // mis-ordering it: "tokens" contains "ens" and cut 5,479 candidates to 78
610
+ // with ENS on top, and chain:"arbitrum" returned total_matches 0 with no
611
+ // error at all. Inference is a guess about intent; a guess belongs in the
612
+ // ORDER BY, where being wrong costs a few positions, not every result.
613
+ //
614
+ // The text terms now ALWAYS constrain (they used to be skipped entirely
615
+ // whenever anything was inferred), so the pool stays tied to what was
616
+ // actually asked.
617
+ const scoreParts = [];
618
+ const scoreParams = [];
619
+
620
+ const words = queryTerms(goalLower);
621
+ if (words.length) {
622
+ const textConds = words.map(() => "(display_name LIKE ? OR description LIKE ? OR auto_description LIKE ?)");
623
+ scoreParts.push(
624
+ words
625
+ .map(() => "((CASE WHEN display_name LIKE ? THEN 4 ELSE 0 END) + (CASE WHEN (description LIKE ? OR auto_description LIKE ?) THEN 1 ELSE 0 END))")
626
+ .join(" + "),
627
+ );
628
+ words.forEach((w) => scoreParams.push(`%${w}%`, `%${w}%`, `%${w}%`));
629
+ words.forEach((w) => params.push(`%${w}%`, `%${w}%`, `%${w}%`));
630
+ conditions.push(`(${textConds.join(" OR ")})`);
453
631
  }
454
632
  if (domains.length) {
455
- conditions.push(`domain IN (${domains.map(() => "?").join(",")})`);
456
- params.push(...domains);
633
+ scoreParts.push(`(CASE WHEN domain IN (${domains.map(() => "?").join(",")}) THEN 1 ELSE 0 END)`);
634
+ scoreParams.push(...domains);
457
635
  }
458
636
  if (ptypes.length) {
459
- conditions.push(`protocol_type IN (${ptypes.map(() => "?").join(",")})`);
460
- params.push(...ptypes);
461
- }
462
-
463
- if (!domains.length && !ptypes.length) {
464
- const words = goalLower.split(/\s+/).filter((w) => w.length > 2).slice(0, 5);
465
- if (words.length) {
466
- const textConds = words.map(() => "(display_name LIKE ? OR description LIKE ?)");
467
- words.forEach((w) => params.push(`%${w}%`, `%${w}%`));
468
- conditions.push(`(${textConds.join(" OR ")})`);
469
- }
637
+ scoreParts.push(`(CASE WHEN protocol_type IN (${ptypes.map(() => "?").join(",")}) THEN 1 ELSE 0 END)`);
638
+ scoreParams.push(...ptypes);
470
639
  }
471
640
 
641
+ const goalScore = scoreParts.length ? scoreParts.join(" + ") : "0";
472
642
  const where = `WHERE ${conditions.join(" AND ")}`;
473
643
  const sql = `
474
644
  SELECT id, display_name, description, auto_description, domain, protocol_type, network,
475
- reliability_score, ipfs_hash, canonical_entities, active_allocation_count, example_query
645
+ reliability_score, ipfs_hash, canonical_entities, active_allocation_count, example_query,
646
+ (${goalScore}) AS goal_score
476
647
  FROM subgraphs
477
648
  ${where}
478
- ORDER BY reliability_score DESC
649
+ ORDER BY goal_score DESC, reliability_score DESC
479
650
  LIMIT 15
480
651
  `;
481
652
 
482
- const rows = getDb().prepare(sql).all(...params);
653
+ // SELECT-clause params bind before WHERE-clause params.
654
+ const rows = getDb().prepare(sql).all(...scoreParams, ...params);
483
655
  // De-dup first so we batch the stability lookup over the trimmed set.
484
656
  const seenIpfs = new Set();
485
657
  const keep = [];
@@ -594,7 +766,34 @@ function getSubgraphDetail({ subgraph_id }) {
594
766
  // Stable, machine-readable manifest other crawlers and agents can index
595
767
  // without going through MCP. Served at /.well-known/subgraph/{id}.jsonld and
596
768
  // /subgraphs/{id}.jsonld (alias, same payload).
597
- const JSONLD_CONTEXT = "https://subgraph-registry.paulieb14.dev/context.jsonld";
769
+ // Every JSON-LD document used to carry @context and @id under
770
+ // subgraph-registry.paulieb14.dev, a hostname that has never resolved
771
+ // (NXDOMAIN). A JSON-LD processor that dereferences the context gets nothing,
772
+ // and the @id identified each subgraph by a URL that 404s — so the documents
773
+ // the README calls "auto-discoverable" were undiscoverable by construction.
774
+ //
775
+ // Two changes. The context is inlined, because a context that must be fetched
776
+ // is a dependency on a host we do not run. And @id now defaults to The Graph's
777
+ // explorer, which is the canonical, resolving identifier for a subgraph and is
778
+ // where we want an agent following the link to end up anyway.
779
+ //
780
+ // Set SUBGRAPH_REGISTRY_BASE_URL to serve these under your own origin (the
781
+ // --http-only transport does exactly that).
782
+ const PUBLIC_BASE_URL = (process.env.SUBGRAPH_REGISTRY_BASE_URL || "").replace(/\/+$/, "");
783
+ const EXPLORER_BASE = "https://thegraph.com/explorer/subgraphs";
784
+
785
+ function subgraphIri(id) {
786
+ return PUBLIC_BASE_URL ? `${PUBLIC_BASE_URL}/subgraphs/${id}` : `${EXPLORER_BASE}/${id}`;
787
+ }
788
+
789
+ const JSONLD_CONTEXT = {
790
+ "@vocab": "https://schema.org/",
791
+ id: "@id",
792
+ name: "https://schema.org/name",
793
+ description: "https://schema.org/description",
794
+ network: "https://schema.org/provider",
795
+ SubgraphDeployment: "https://schema.org/Dataset",
796
+ };
598
797
 
599
798
  function buildJsonLdManifest(row) {
600
799
  if (!row) return null;
@@ -603,7 +802,7 @@ function buildJsonLdManifest(row) {
603
802
  return {
604
803
  "@context": JSONLD_CONTEXT,
605
804
  "@type": "SubgraphDeployment",
606
- "@id": `https://subgraph-registry.paulieb14.dev/subgraphs/${row.id}`,
805
+ "@id": subgraphIri(row.id),
607
806
  id: row.id,
608
807
  ipfsHash: row.ipfs_hash,
609
808
  name: row.display_name,
@@ -739,6 +938,7 @@ async function semanticSearchSubgraphs({
739
938
  min_score = 0.3,
740
939
  include_unserved = false,
741
940
  include_denied = false,
941
+ include_testnets = false,
742
942
  domain = "",
743
943
  network = "",
744
944
  protocol_type = "",
@@ -768,13 +968,16 @@ async function semanticSearchSubgraphs({
768
968
  if (!include_denied) {
769
969
  conditions.push("denied_at = 0");
770
970
  }
971
+ if (shouldExcludeTestnets({ include_testnets, network })) {
972
+ conditions.push(`(${NOT_TESTNET_SQL})`);
973
+ }
771
974
  if (domain) {
772
975
  conditions.push("domain = ?");
773
976
  params.push(domain);
774
977
  }
775
978
  if (network) {
776
979
  conditions.push("network = ?");
777
- params.push(network);
980
+ params.push(normalizeNetwork(network));
778
981
  }
779
982
  if (protocol_type) {
780
983
  conditions.push("protocol_type = ?");
@@ -806,7 +1009,23 @@ async function semanticSearchSubgraphs({
806
1009
  if (score < min_score) continue;
807
1010
  scored.push({ row: r, score });
808
1011
  }
809
- scored.sort((a, b) => b.score - a.score);
1012
+ // Rank on similarity WEIGHTED by reliability, not similarity alone.
1013
+ //
1014
+ // Pure cosine made this the only tool in the package that ignored the
1015
+ // registry's own quality signal, and testnets win on pure cosine because
1016
+ // their text is near-identical to mainnet's. Observed: "ENS domain name
1017
+ // registrations" put ENS Sepolia (58 queries/30d, reliability 0.2463) above
1018
+ // ENS mainnet (34.8M queries/30d, reliability 0.9775) on a cosine margin of
1019
+ // 0.0105 — a rounding error deciding between a toy and the real thing.
1020
+ //
1021
+ // The 0.5 floor is deliberate: reliability is itself age-biased (see the
1022
+ // maturity block above), so a multiplier that ran to 0 would re-bury every
1023
+ // new subgraph and undo the emerging work. At 0.5 + 0.5*r a brand-new
1024
+ // subgraph keeps half its similarity and can still outrank an established
1025
+ // one it genuinely beats on meaning, while a 0.01 cosine tie resolves
1026
+ // toward the subgraph that is actually serving traffic.
1027
+ const effective = (s) => s.score * (0.5 + 0.5 * (s.row.reliability_score || 0));
1028
+ scored.sort((a, b) => effective(b) - effective(a));
810
1029
 
811
1030
  const results = [];
812
1031
  for (const { row: r, score } of scored) {
@@ -826,6 +1045,8 @@ async function semanticSearchSubgraphs({
826
1045
  powered_by_substreams: Boolean(r.powered_by_substreams),
827
1046
  active_allocation_count: r.active_allocation_count || 0,
828
1047
  denied: Boolean(r.denied_at),
1048
+ testnet: isTestnetNetwork(r.network),
1049
+ testnet: isTestnetNetwork(r.network),
829
1050
  example_query: r.example_query || null,
830
1051
  // No `emerging` companion list here: this tool ranks by cosine score,
831
1052
  // not reliability_score, so a three-week-old subgraph can and does take
@@ -887,6 +1108,7 @@ function getSchemaChanges({ subgraph_id, since_timestamp = 0 }) {
887
1108
  `SELECT fingerprint, prev_fingerprint, detected_at, ipfs_hash
888
1109
  FROM schema_history
889
1110
  WHERE subgraph_id = ? AND detected_at >= ?
1111
+ AND prev_fingerprint IS NOT NULL
890
1112
  ORDER BY detected_at DESC`,
891
1113
  )
892
1114
  .all(subgraph_id, since);
@@ -898,17 +1120,41 @@ function getSchemaChanges({ subgraph_id, since_timestamp = 0 }) {
898
1120
  };
899
1121
  }
900
1122
 
1123
+ // Baselines are excluded from `rows`, so "no rows" now means "never changed
1124
+ // since we first saw it" rather than "no history". Those are very different
1125
+ // claims to an agent deciding whether a schema is safe to depend on, and
1126
+ // collapsing them to stable_days: null loses the distinction. Report the
1127
+ // first sighting separately and measure stability from it, so a subgraph
1128
+ // that has genuinely never changed reads as stable — which is the honest
1129
+ // answer, and the one the old code accidentally gave everybody.
1130
+ let first_seen_at = null;
1131
+ try {
1132
+ const fs = getDb()
1133
+ .prepare(
1134
+ "SELECT MIN(detected_at) AS t FROM schema_history WHERE subgraph_id = ?",
1135
+ )
1136
+ .get(subgraph_id);
1137
+ first_seen_at = fs && fs.t != null ? fs.t : null;
1138
+ } catch {
1139
+ first_seen_at = null;
1140
+ }
1141
+
1142
+ const never_changed = rows.length === 0;
901
1143
  const last_changed_at = rows.length > 0 ? rows[0].detected_at : null;
1144
+ const stable_since = last_changed_at !== null ? last_changed_at : first_seen_at;
902
1145
  const stable_days =
903
- last_changed_at !== null
904
- ? Math.round(((now - last_changed_at) / 86400) * 10) / 10
1146
+ stable_since !== null
1147
+ ? Math.round(((now - stable_since) / 86400) * 10) / 10
905
1148
  : null;
906
1149
 
907
1150
  return {
908
1151
  subgraph_id,
909
1152
  total_changes: rows.length,
1153
+ never_changed,
1154
+ first_seen_at,
910
1155
  last_changed_at,
911
1156
  stable_days,
1157
+ stable_days_basis: never_changed ? "first_seen" : "last_change",
912
1158
  changed_within_24h:
913
1159
  last_changed_at !== null && now - last_changed_at < 86400,
914
1160
  changed_within_7d:
@@ -932,7 +1178,8 @@ function getSchemaStabilityFor(id) {
932
1178
  const r = getDb()
933
1179
  .prepare(
934
1180
  "SELECT MAX(detected_at) AS schema_changed_at " +
935
- "FROM schema_history WHERE subgraph_id = ?",
1181
+ "FROM schema_history WHERE subgraph_id = ? " +
1182
+ "AND prev_fingerprint IS NOT NULL",
936
1183
  )
937
1184
  .get(id);
938
1185
  if (!r || r.schema_changed_at == null) {
@@ -958,6 +1205,7 @@ function getSchemaStabilityBatch(ids) {
958
1205
  `SELECT subgraph_id, MAX(detected_at) AS schema_changed_at
959
1206
  FROM schema_history
960
1207
  WHERE subgraph_id IN (${placeholders})
1208
+ AND prev_fingerprint IS NOT NULL
961
1209
  GROUP BY subgraph_id`,
962
1210
  )
963
1211
  .all(...ids);
@@ -1011,6 +1259,11 @@ const TOOLS = [
1011
1259
  description: "Include curation-denied deployments (deniedAt > 0 — denied indexing rewards, typically spam, duplicates or deprecations). Default false. When true, each result carries denied: true so the choice stays visible.",
1012
1260
  default: false,
1013
1261
  },
1262
+ include_testnets: {
1263
+ type: "boolean",
1264
+ description: "Include testnet deployments (sepolia, goerli, holesky, chapel, fuji, mumbai, amoy, *-testnet). Default false — a testnet twin's text is near-identical to its mainnet original, so it competes for the top slot without being the thing anyone wanted. Ignored when you explicitly request a testnet network, so network:\"sepolia\" still works. Each result carries testnet: true|false.",
1265
+ default: false,
1266
+ },
1014
1267
  },
1015
1268
  },
1016
1269
  },
@@ -1085,6 +1338,11 @@ const TOOLS = [
1085
1338
  description: "Include curation-denied deployments (deniedAt > 0 — denied indexing rewards, typically spam, duplicates or deprecations). Default false. When true, each result carries denied: true so the choice stays visible.",
1086
1339
  default: false,
1087
1340
  },
1341
+ include_testnets: {
1342
+ type: "boolean",
1343
+ description: "Include testnet deployments (sepolia, goerli, holesky, chapel, fuji, mumbai, amoy, *-testnet). Default false — a testnet twin's text is near-identical to its mainnet original, so it competes for the top slot without being the thing anyone wanted. Ignored when you explicitly request a testnet network, so network:\"sepolia\" still works. Each result carries testnet: true|false.",
1344
+ default: false,
1345
+ },
1088
1346
  domain: {
1089
1347
  type: "string",
1090
1348
  description: "Pre-filter by domain (defi, nfts, dao, gaming, identity, infrastructure, social, analytics)",
@@ -1207,6 +1465,62 @@ function startHttpTransport(port) {
1207
1465
  res.json({ status: "ok", subgraphs: getDb().prepare("SELECT COUNT(*) as c FROM subgraphs").get().c });
1208
1466
  });
1209
1467
 
1468
+ // ── payql compatibility shim ─────────────────────────────────────
1469
+ // payql (npm `payql`, same author) advertises PAYQL_REGISTRY_URL as a "free
1470
+ // discovery source (e.g. your own subgraph registry)" — but it POSTs the
1471
+ // Graph network subgraph's GraphQL document and reads
1472
+ // `data.subgraphMetadataSearch`, a shape this registry has never served. So
1473
+ // pointing payql here returned zero hits and, because the response still
1474
+ // parsed as JSON, it failed as an empty success rather than an error. The
1475
+ // two projects were built to compose and the seam between them was dead.
1476
+ //
1477
+ // Serve that shape. No GraphQL engine needed: the only input that varies is
1478
+ // the `text` variable, and the result set is what search_subgraphs already
1479
+ // computes. Answering in the incumbent's vocabulary means payql works
1480
+ // against this registry with no change on its side.
1481
+ //
1482
+ // Token amounts are returned as wei-scale strings because payql divides them
1483
+ // by 1e18 (weiToGRT); handing back plain GRT would under-report by 1e18.
1484
+ app.post("/graphql", express.json({ limit: "256kb" }), (req, res) => {
1485
+ try {
1486
+ const text = String(req.body?.variables?.text ?? "");
1487
+ const first = Math.max(1, Math.min(Number(req.body?.variables?.first) || 10, 50));
1488
+ // payql sends a prefix tsquery ("uniswap:* | v3:*"); strip the fulltext
1489
+ // operators back to plain words before handing them to LIKE matching.
1490
+ const plain = text.replace(/:\*/g, " ").replace(/[|&()]/g, " ").trim();
1491
+ const { subgraphs = [] } = searchSubgraphs({ query: plain, limit: first });
1492
+ res.json({
1493
+ data: {
1494
+ subgraphMetadataSearch: subgraphs.map((s) => ({
1495
+ displayName: s.display_name,
1496
+ description: s.description,
1497
+ categories: s.domain ? [s.domain] : [],
1498
+ subgraphs: [
1499
+ {
1500
+ id: s.id,
1501
+ active: true,
1502
+ currentSignalledTokens: null,
1503
+ currentVersion: {
1504
+ subgraphDeployment: {
1505
+ ipfsHash: s.ipfs_hash,
1506
+ stakedTokens: null,
1507
+ signalledTokens: null,
1508
+ queryFeesAmount: null,
1509
+ },
1510
+ },
1511
+ },
1512
+ ],
1513
+ })),
1514
+ },
1515
+ });
1516
+ } catch (err) {
1517
+ // Answer in GraphQL's error shape so a client that only knows GraphQL
1518
+ // sees a failure instead of an empty success — the exact trap this
1519
+ // route exists to close.
1520
+ res.status(500).json({ errors: [{ message: String(err?.message || err) }] });
1521
+ }
1522
+ });
1523
+
1210
1524
  // ── OpenAPI 3.1 spec (auto-generated at release time) ────────────
1211
1525
  // scripts/gen-openapi.js inventories the TOOLS array + the REST
1212
1526
  // routes below and writes data/openapi.json. We serve the file
@@ -1266,7 +1580,7 @@ function startHttpTransport(port) {
1266
1580
  generatedAt: new Date().toISOString(),
1267
1581
  count: rows.length,
1268
1582
  subgraphs: rows.map((r) => ({
1269
- "@id": `https://subgraph-registry.paulieb14.dev/subgraphs/${r.id}`,
1583
+ "@id": subgraphIri(r.id),
1270
1584
  id: r.id,
1271
1585
  name: r.display_name,
1272
1586
  network: r.network,