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 +47 -0
- package/data/registry.db +0 -0
- package/package.json +1 -1
- package/src/index.js +349 -35
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.
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
588
|
+
.filter(([, kws]) => hitsGoal(kws))
|
|
439
589
|
.map(([d]) => d);
|
|
440
590
|
const ptypes = Object.entries(typeMap)
|
|
441
|
-
.filter(([, kws]) => kws
|
|
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
|
-
|
|
456
|
-
|
|
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
|
-
|
|
460
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
904
|
-
? Math.round(((now -
|
|
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":
|
|
1583
|
+
"@id": subgraphIri(r.id),
|
|
1270
1584
|
id: r.id,
|
|
1271
1585
|
name: r.display_name,
|
|
1272
1586
|
network: r.network,
|