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.
- package/README.md +28 -0
- package/package.json +1 -1
- 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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
1037
|
-
? Math.round(((now -
|
|
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":
|
|
1583
|
+
"@id": subgraphIri(r.id),
|
|
1405
1584
|
id: r.id,
|
|
1406
1585
|
name: r.display_name,
|
|
1407
1586
|
network: r.network,
|