subgraph-registry-mcp 0.9.7 → 0.9.9

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/data/openapi.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "info": {
4
4
  "title": "Subgraph Registry",
5
5
  "description": "Agent-friendly subgraph discovery on The Graph Network. 15,330 classified subgraphs with semantic search, reliability scoring, 30-day query volume and schema-evolution tracking. Discovery only: returns subgraph ids and starter queries, which you run with a Graph Studio API key (Authorization: Bearer) or over x402 ($0.01 USDC on Base, no key).",
6
- "version": "0.9.7",
6
+ "version": "0.9.9",
7
7
  "license": {
8
8
  "name": "MIT"
9
9
  },
package/openapi.yaml CHANGED
@@ -5,7 +5,7 @@ openapi: "3.1.0"
5
5
  info:
6
6
  title: "Subgraph Registry"
7
7
  description: "Agent-friendly subgraph discovery on The Graph Network. 15,330 classified subgraphs with semantic search, reliability scoring, 30-day query volume and schema-evolution tracking. Discovery only: returns subgraph ids and starter queries, which you run with a Graph Studio API key (Authorization: Bearer) or over x402 ($0.01 USDC on Base, no key)."
8
- version: "0.9.7"
8
+ version: "0.9.9"
9
9
  license:
10
10
  name: "MIT"
11
11
  contact:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "subgraph-registry-mcp",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
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 reliability scoring, 30-day query volume, semantic search and protocol classification. Discovery only — returns subgraph ids and ready-to-run queries for you to run with a Graph Studio key or over x402.",
6
6
  "type": "module",
@@ -20,7 +20,8 @@
20
20
  "start:http-only": "node src/index.js --http-only",
21
21
  "test": "node --test test/",
22
22
  "sync:server": "node scripts/sync-server-json.js",
23
- "version": "node scripts/sync-server-json.js && node scripts/gen-openapi.js && git add server.json openapi.yaml data/openapi.json"
23
+ "version": "node scripts/sync-server-json.js && node scripts/gen-openapi.js && git add server.json openapi.yaml data/openapi.json",
24
+ "smoke": "node scripts/smoke-bin.mjs"
24
25
  },
25
26
  "keywords": [
26
27
  "mcp",
package/src/index.js CHANGED
@@ -24,7 +24,7 @@ import Database from "better-sqlite3";
24
24
  import express from "express";
25
25
  import { fileURLToPath, pathToFileURL } from "url";
26
26
  import { basename, dirname, join } from "path";
27
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
27
+ import { existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "fs";
28
28
  import { get as httpsGet } from "https";
29
29
  import { createHash } from "crypto";
30
30
 
@@ -349,6 +349,40 @@ function queryTerms(query) {
349
349
  .slice(0, 5);
350
350
  }
351
351
 
352
+ // Re-rank candidates on WORD boundaries, which SQL LIKE cannot express.
353
+ //
354
+ // The SQL score treats any substring of a display name as a name hit, so
355
+ // "scores" scored scoresquare-base at full name weight and "for" scored
356
+ // forsage-x2-prod, both beating genuine description matches. A term that
357
+ // appears as a whole word is a real signal; a term that is merely a prefix of
358
+ // a longer word is not.
359
+ //
360
+ // Shared by search_subgraphs and recommend_subgraph. It lived only in
361
+ // recommend, which is why `search_subgraphs("reputation scores for onchain
362
+ // agents")` still returned scoresquare-base at #1 while recommend did not —
363
+ // two tools disagreeing because a fix was applied to one of them.
364
+ function boundaryRerank(rows, words) {
365
+ if (!words.length) return rows;
366
+ const res = words.map(
367
+ (w) => new RegExp("(^|[^a-z0-9])" + w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "([^a-z0-9]|$)", "i"),
368
+ );
369
+ const score = (r) => {
370
+ const name = r.display_name || "";
371
+ const text = `${r.description || ""} ${r.auto_description || ""}`;
372
+ let out = 0;
373
+ res.forEach((re, i) => {
374
+ if (re.test(name)) out += 6;
375
+ else if (name.toLowerCase().includes(words[i])) out += 1;
376
+ if (re.test(text)) out += 2;
377
+ });
378
+ return out;
379
+ };
380
+ return rows
381
+ .map((r) => ({ r, s: score(r) }))
382
+ .sort((a, b) => b.s - a.s || (b.r.reliability_score || 0) - (a.r.reliability_score || 0))
383
+ .map((x) => x.r);
384
+ }
385
+
352
386
  // ── Maturity / cold-start handling ─────────────────────────
353
387
  // reliability_score is built from four CUMULATIVE inputs (curation signal,
354
388
  // indexer stake, lifetime query fees, 30d volume — see _reliability_score in
@@ -542,7 +576,10 @@ function searchSubgraphs({
542
576
  // Positional binding order: the SELECT-clause scoring expression is bound
543
577
  // before the WHERE clause, so matchParams must lead. filterParams stays
544
578
  // WHERE-only, which is what the emerging companion query needs.
545
- const rows = getDb().prepare(sql).all(...matchParams, ...filterParams, fetchLimit);
579
+ const rows = boundaryRerank(
580
+ getDb().prepare(sql).all(...matchParams, ...filterParams, fetchLimit),
581
+ query ? queryTerms(query) : [],
582
+ );
546
583
  // Dedup by IPFS hash — keep highest reliability per deployment
547
584
  const seenIpfs = new Set();
548
585
  const results = [];
@@ -721,15 +758,27 @@ function recommendSubgraph({ goal, chain = "" }) {
721
758
  if (canonical !== w || KNOWN_NETWORKS.has(canonical)) chainWords.push(canonical);
722
759
  else words.push(w);
723
760
  }
724
- // An explicit `chain` argument always wins over one inferred from prose.
725
- if (!chain && chainWords.length === 1) {
761
+ // A chain word NEVER becomes a scoring term. The first version of this fell
762
+ // back to `words.push(...chainWords)` whenever it could not use them as a
763
+ // filter — and because chainWords hold the CANONICAL form, "on ethereum"
764
+ // re-entered scoring as the token "mainnet" and scored +6 against any
765
+ // display name containing it. Passing chain:"ethereum" with a goal ending
766
+ // "on ethereum" therefore ranked Clearpool staking mainnet (vol 2) over Lido
767
+ // (4,692,414), Mainnet Voting V2 over Snapshot, seer-outcome-tokens-mainnet
768
+ // (vol 1) over ENS (34,835,842), and dropped EigenLayer out of the top 5
769
+ // entirely. It made the explicit chain argument actively worse than omitting
770
+ // it, which is the opposite of what an argument is for.
771
+ //
772
+ // Chain words are routing information. They filter, or they are discarded.
773
+ const explicitChain = chain ? normalizeNetwork(chain) : null;
774
+ if (!explicitChain && chainWords.length === 1) {
726
775
  conditions.push("network = ?");
727
776
  params.push(chainWords[0]);
728
- inferredChain = chainWords[0];
729
- } else if (chainWords.length) {
730
- // Ambiguous or already-specified: keep them as weak text signal only.
731
- words.push(...chainWords);
732
777
  }
778
+ // Report the chain actually in effect, whether it came from the argument or
779
+ // the prose — a caller passing chain used to get inferred_chain: null, which
780
+ // read as "your chain was ignored".
781
+ inferredChain = explicitChain || (chainWords.length === 1 ? chainWords[0] : null);
733
782
 
734
783
  if (words.length) {
735
784
  const textConds = words.map(() => "(display_name LIKE ? OR description LIKE ? OR auto_description LIKE ?)");
@@ -767,37 +816,8 @@ function recommendSubgraph({ goal, chain = "" }) {
767
816
  // SELECT-clause params bind before WHERE-clause params.
768
817
  let rows = getDb().prepare(sql).all(...scoreParams, ...params);
769
818
 
770
- // Re-rank on WORD boundaries, which SQL LIKE cannot express.
771
- //
772
- // The SQL score treats any substring of the display name as a name hit, so
773
- // "scores" scored scoresquare-base at 4 and "for" scored forsage-x2-prod at
774
- // 4, both beating agent0's two genuine description matches at 1 apiece. A
775
- // term that appears as a whole word in the name is a real signal; a term that
776
- // merely happens to be a prefix of a longer word is not, and was outranking
777
- // it 4 to 1.
778
- //
779
- // Done here rather than in SQL because expressing "\bterm\b" in LIKE needs
780
- // half a dozen OR-ed patterns per word for space, hyphen and underscore
781
- // delimiters. The SQL score stays as the recall net (LIMIT 60); this decides
782
- // the order of what it caught.
783
- if (words.length) {
784
- const bounded = words.map((w) => new RegExp("(^|[^a-z0-9])" + w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "([^a-z0-9]|$)", "i"));
785
- const rescore = (r) => {
786
- const name = r.display_name || "";
787
- const text = `${r.description || ""} ${r.auto_description || ""}`;
788
- let score = 0;
789
- bounded.forEach((re, i) => {
790
- if (re.test(name)) score += 6; // whole word in the name
791
- else if (name.toLowerCase().includes(words[i])) score += 1; // incidental substring
792
- if (re.test(text)) score += 2; // whole word in the description
793
- });
794
- return score;
795
- };
796
- rows = rows
797
- .map((r) => ({ r, s: rescore(r) }))
798
- .sort((a, b) => b.s - a.s || (b.r.reliability_score || 0) - (a.r.reliability_score || 0))
799
- .map((x) => x.r);
800
- }
819
+ rows = boundaryRerank(rows, words);
820
+
801
821
  // De-dup first so we batch the stability lookup over the trimmed set.
802
822
  const seenIpfs = new Set();
803
823
  const keep = [];
@@ -1856,10 +1876,37 @@ export { TOOLS };
1856
1876
  // the MCP server or open the SQLite file. Use pathToFileURL so the
1857
1877
  // comparison works on Windows where process.argv[1] uses backslashes
1858
1878
  // while import.meta.url is forward-slashed.
1879
+ // Whether this file was RUN, as opposed to imported (scripts/gen-openapi.js
1880
+ // imports it for TOOLS[] and must not spawn a server).
1881
+ //
1882
+ // This has to compare real paths. npm installs a bin as a symlink —
1883
+ // node_modules/.bin/subgraph-registry-mcp -> ../subgraph-registry-mcp/src/index.js
1884
+ // — and Node sets import.meta.url to the RESOLVED target while argv[1] keeps
1885
+ // the symlink. So the old comparison was:
1886
+ //
1887
+ // pathToFileURL(argv[1]) = file:///…/node_modules/.bin/subgraph-registry-mcp
1888
+ // import.meta.url = file:///…/subgraph-registry-mcp/src/index.js
1889
+ // basename(argv[1]) = "subgraph-registry-mcp" (not "index.js")
1890
+ //
1891
+ // Both branches false, so main() never ran: the process exited 0, instantly,
1892
+ // silently, and every MCP host reported "Connection closed" with 0 tools. It
1893
+ // worked in local testing only because `node src/index.js` satisfies the
1894
+ // basename fallback — which is exactly why no test caught it. The package has
1895
+ // never once started over npx.
1896
+ //
1897
+ // realpathSync collapses the symlink on both sides, so npx, `npm i -g`, a
1898
+ // direct path and a symlinked directory all agree. Note the URL comparison was
1899
+ // already unreliable on its own: on macOS a /var path resolves to /private/var,
1900
+ // so even a direct run failed that branch and survived on the basename check.
1859
1901
  const _isMain =
1860
1902
  process.argv[1] !== undefined &&
1861
- (pathToFileURL(process.argv[1]).href === import.meta.url ||
1862
- basename(process.argv[1]) === "index.js");
1903
+ (() => {
1904
+ try {
1905
+ return realpathSync(process.argv[1]) === realpathSync(__filename);
1906
+ } catch {
1907
+ return false;
1908
+ }
1909
+ })();
1863
1910
 
1864
1911
  if (_isMain) {
1865
1912
  main().catch((err) => {