subgraph-registry-mcp 0.9.1 → 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.
Files changed (3) hide show
  1. package/README.md +28 -0
  2. package/package.json +1 -1
  3. package/src/index.js +186 -7
package/README.md CHANGED
@@ -204,9 +204,37 @@ Three tools rank, and each ranks differently on purpose:
204
204
  as a *ranking bonus*, never a filter. As a filter, one bad keyword collapsed
205
205
  the candidate pool to nothing.
206
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
+
207
211
  Chain names are aliased, so `ethereum`, `arbitrum`, `polygon` and `bnb` resolve
208
212
  to the corpus values `mainnet`, `arbitrum-one`, `matic` and `bsc`.
209
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
+
210
238
  ### Denied deployments
211
239
 
212
240
  Curation-denied deployments (`deniedAt > 0` — denied indexing rewards, usually
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subgraph-registry-mcp",
3
- "version": "0.9.1",
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
@@ -222,6 +222,45 @@ function normalizeNetwork(name) {
222
222
  return NETWORK_ALIASES[k] || k;
223
223
  }
224
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
+
225
264
  // Tokenize a free-text query into searchable terms.
226
265
  //
227
266
  // The old inline version was `.filter((w) => w.length > 2)`, which silently
@@ -330,6 +369,7 @@ function searchSubgraphs({
330
369
  min_reliability = 0,
331
370
  include_unserved = false,
332
371
  include_denied = false,
372
+ include_testnets = false,
333
373
  limit = 20,
334
374
  } = {}) {
335
375
  const conditions = [];
@@ -354,6 +394,9 @@ function searchSubgraphs({
354
394
  if (!include_denied) {
355
395
  conditions.push("denied_at = 0");
356
396
  }
397
+ if (shouldExcludeTestnets({ include_testnets, network })) {
398
+ conditions.push(`(${NOT_TESTNET_SQL})`);
399
+ }
357
400
 
358
401
  if (domain) {
359
402
  conditions.push("domain = ?");
@@ -386,13 +429,20 @@ function searchSubgraphs({
386
429
  //
387
430
  // Keep the OR (dropping to AND would kill recall on descriptions that
388
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.
389
439
  const matchParams = [];
390
440
  let matchExpr = "0";
391
441
  if (query) {
392
442
  const words = queryTerms(query);
393
443
  if (words.length) {
394
444
  matchExpr = words
395
- .map(() => "(CASE WHEN (display_name LIKE ? OR description LIKE ? OR auto_description LIKE ?) THEN 1 ELSE 0 END)")
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))")
396
446
  .join(" + ");
397
447
  words.forEach((w) => matchParams.push(`%${w}%`, `%${w}%`, `%${w}%`));
398
448
 
@@ -446,6 +496,8 @@ function searchSubgraphs({
446
496
  // caller passed include_denied — surfaced so that choice stays visible
447
497
  // in the result rather than being silently carried.
448
498
  denied: Boolean(r.denied_at),
499
+ testnet: isTestnetNetwork(r.network),
500
+ testnet: isTestnetNetwork(r.network),
449
501
  // Ready-to-run GraphQL generated from this subgraph's actual schema — so an
450
502
  // agent can POST it to query_url_x402 immediately, no get_subgraph_detail round-trip.
451
503
  example_query: r.example_query || null,
@@ -478,6 +530,7 @@ function searchSubgraphs({
478
530
  powered_by_substreams: Boolean(r.powered_by_substreams),
479
531
  active_allocation_count: r.active_allocation_count || 0,
480
532
  denied: Boolean(r.denied_at),
533
+ testnet: isTestnetNetwork(r.network),
481
534
  example_query: r.example_query || null,
482
535
  age_days: ageDays(r.created_at),
483
536
  maturity: maturityOf(r.created_at),
@@ -542,6 +595,9 @@ function recommendSubgraph({ goal, chain = "" }) {
542
595
  // one should I use", so a curation-denied deployment is never the right
543
596
  // answer and the filter is unconditional here.
544
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
+ }
545
601
  const params = [];
546
602
 
547
603
  if (chain) {
@@ -566,7 +622,7 @@ function recommendSubgraph({ goal, chain = "" }) {
566
622
  const textConds = words.map(() => "(display_name LIKE ? OR description LIKE ? OR auto_description LIKE ?)");
567
623
  scoreParts.push(
568
624
  words
569
- .map(() => "(CASE WHEN (display_name LIKE ? OR description LIKE ? OR auto_description LIKE ?) THEN 2 ELSE 0 END)")
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))")
570
626
  .join(" + "),
571
627
  );
572
628
  words.forEach((w) => scoreParams.push(`%${w}%`, `%${w}%`, `%${w}%`));
@@ -710,7 +766,34 @@ function getSubgraphDetail({ subgraph_id }) {
710
766
  // Stable, machine-readable manifest other crawlers and agents can index
711
767
  // without going through MCP. Served at /.well-known/subgraph/{id}.jsonld and
712
768
  // /subgraphs/{id}.jsonld (alias, same payload).
713
- 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
+ };
714
797
 
715
798
  function buildJsonLdManifest(row) {
716
799
  if (!row) return null;
@@ -719,7 +802,7 @@ function buildJsonLdManifest(row) {
719
802
  return {
720
803
  "@context": JSONLD_CONTEXT,
721
804
  "@type": "SubgraphDeployment",
722
- "@id": `https://subgraph-registry.paulieb14.dev/subgraphs/${row.id}`,
805
+ "@id": subgraphIri(row.id),
723
806
  id: row.id,
724
807
  ipfsHash: row.ipfs_hash,
725
808
  name: row.display_name,
@@ -855,6 +938,7 @@ async function semanticSearchSubgraphs({
855
938
  min_score = 0.3,
856
939
  include_unserved = false,
857
940
  include_denied = false,
941
+ include_testnets = false,
858
942
  domain = "",
859
943
  network = "",
860
944
  protocol_type = "",
@@ -884,6 +968,9 @@ async function semanticSearchSubgraphs({
884
968
  if (!include_denied) {
885
969
  conditions.push("denied_at = 0");
886
970
  }
971
+ if (shouldExcludeTestnets({ include_testnets, network })) {
972
+ conditions.push(`(${NOT_TESTNET_SQL})`);
973
+ }
887
974
  if (domain) {
888
975
  conditions.push("domain = ?");
889
976
  params.push(domain);
@@ -958,6 +1045,8 @@ async function semanticSearchSubgraphs({
958
1045
  powered_by_substreams: Boolean(r.powered_by_substreams),
959
1046
  active_allocation_count: r.active_allocation_count || 0,
960
1047
  denied: Boolean(r.denied_at),
1048
+ testnet: isTestnetNetwork(r.network),
1049
+ testnet: isTestnetNetwork(r.network),
961
1050
  example_query: r.example_query || null,
962
1051
  // No `emerging` companion list here: this tool ranks by cosine score,
963
1052
  // not reliability_score, so a three-week-old subgraph can and does take
@@ -1031,17 +1120,41 @@ function getSchemaChanges({ subgraph_id, since_timestamp = 0 }) {
1031
1120
  };
1032
1121
  }
1033
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;
1034
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;
1035
1145
  const stable_days =
1036
- last_changed_at !== null
1037
- ? Math.round(((now - last_changed_at) / 86400) * 10) / 10
1146
+ stable_since !== null
1147
+ ? Math.round(((now - stable_since) / 86400) * 10) / 10
1038
1148
  : null;
1039
1149
 
1040
1150
  return {
1041
1151
  subgraph_id,
1042
1152
  total_changes: rows.length,
1153
+ never_changed,
1154
+ first_seen_at,
1043
1155
  last_changed_at,
1044
1156
  stable_days,
1157
+ stable_days_basis: never_changed ? "first_seen" : "last_change",
1045
1158
  changed_within_24h:
1046
1159
  last_changed_at !== null && now - last_changed_at < 86400,
1047
1160
  changed_within_7d:
@@ -1146,6 +1259,11 @@ const TOOLS = [
1146
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.",
1147
1260
  default: false,
1148
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
+ },
1149
1267
  },
1150
1268
  },
1151
1269
  },
@@ -1220,6 +1338,11 @@ const TOOLS = [
1220
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.",
1221
1339
  default: false,
1222
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
+ },
1223
1346
  domain: {
1224
1347
  type: "string",
1225
1348
  description: "Pre-filter by domain (defi, nfts, dao, gaming, identity, infrastructure, social, analytics)",
@@ -1342,6 +1465,62 @@ function startHttpTransport(port) {
1342
1465
  res.json({ status: "ok", subgraphs: getDb().prepare("SELECT COUNT(*) as c FROM subgraphs").get().c });
1343
1466
  });
1344
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
+
1345
1524
  // ── OpenAPI 3.1 spec (auto-generated at release time) ────────────
1346
1525
  // scripts/gen-openapi.js inventories the TOOLS array + the REST
1347
1526
  // routes below and writes data/openapi.json. We serve the file
@@ -1401,7 +1580,7 @@ function startHttpTransport(port) {
1401
1580
  generatedAt: new Date().toISOString(),
1402
1581
  count: rows.length,
1403
1582
  subgraphs: rows.map((r) => ({
1404
- "@id": `https://subgraph-registry.paulieb14.dev/subgraphs/${r.id}`,
1583
+ "@id": subgraphIri(r.id),
1405
1584
  id: r.id,
1406
1585
  name: r.display_name,
1407
1586
  network: r.network,