sbor-mcp 1.0.1 → 1.1.1

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 +40 -9
  2. package/package.json +10 -3
  3. package/server.mjs +177 -80
package/README.md CHANGED
@@ -1,7 +1,19 @@
1
1
  # SBOR MCP server
2
2
 
3
- Call [SBOR](https://sbor.xyz), the benchmark lending rate for Stacks, as a tool
4
- from Claude or any MCP client.
3
+ Check a lending rate before your agent borrows.
4
+
5
+ Bitcoin DeFi has no benchmark lending rate. [SBOR](https://sbor.xyz) starts with
6
+ Stacks, where the market is: one borrow and one supply rate per currency, read
7
+ from lending contract state and published daily. This server exposes it as tools
8
+ for Claude, Cowork, Cursor or any MCP client.
9
+
10
+ ## Quick check
11
+
12
+ ```bash
13
+ npx -y sbor-mcp
14
+ ```
15
+
16
+ It starts and waits silently. That is success. Ctrl+C to exit.
5
17
 
6
18
  ## Install
7
19
 
@@ -10,7 +22,7 @@ from Claude or any MCP client.
10
22
  "mcpServers": {
11
23
  "sbor": {
12
24
  "command": "npx",
13
- "args": ["-y", "@sbor/mcp"]
25
+ "args": ["-y", "sbor-mcp"]
14
26
  }
15
27
  }
16
28
  }
@@ -24,15 +36,34 @@ Claude Desktop: add that to `claude_desktop_config.json` and restart.
24
36
  |---|---|
25
37
  | `get_rate` | What does capital cost on Stacks right now |
26
38
  | `compare_rate` | Is this offer above or below the market, and by how much |
27
- | `list_markets` | Which venues make up the rate, with utilisation and depth |
39
+ | `list_markets` | Which venues make up the rate, with utilization and depth |
28
40
  | `get_history` | How has the rate moved |
29
- | `compare_chains` | How does Stacks compare with Aave on Ethereum |
41
+ | `compare_chains` | How Stacks compares with Ethereum, Base, Hyperliquid, Solana and SOFR |
30
42
  | `get_methodology` | How the number is built, and what it excludes |
31
43
 
32
- ## Behaviour worth knowing
44
+ ## Recommended use
45
+
46
+ **Use SBOR to stop, never to start.** Before borrowing, call `compare_rate`. If
47
+ the offer is more than 50 basis points above the benchmark, stop and ask a
48
+ human. A wrong reading under that rule costs a pause, not a trade.
49
+
50
+ ## Behavior worth knowing
51
+
52
+ **It refuses rather than guesses.** `compare_rate` returns an error, not a
53
+ verdict, when the data is more than 48 hours old, when the fixing timestamp
54
+ cannot be read, when the index is not published, or when a rate is missing. An
55
+ error means there is no trustworthy answer, not that the rate is bad. Fall back
56
+ to your own logic.
57
+
58
+ **It checks units.** Rates are percentages: 4.2 means 4.2%. Anything outside 0
59
+ to 100 is rejected. Below 0.5 is answered, because rates that low genuinely
60
+ occur, but with a warning that you may have passed a fraction.
61
+
62
+ **It never averages across a methodology change.** `get_history` gives a mean
63
+ per methodology version when a window spans more than one.
33
64
 
34
- **It never guesses.** If SBOR is unreachable the tool says so and tells you to
35
- fall back to your own logic rather than substituting an estimate.
65
+ **It never guesses when SBOR is down.** If SBOR is unreachable or slower than 10
66
+ seconds, the tool says so rather than substituting an estimate.
36
67
 
37
68
  **It reports omissions.** When a market cannot be read, SBOR omits the index
38
69
  rather than publishing a figure that is not real. The tool explains that instead
@@ -44,7 +75,7 @@ venue, not a market average, and `get_rate` says so.
44
75
  **It reads the same public endpoints as everyone else.** No key, no state, no
45
76
  writes, no telemetry. Set `SBOR_BASE` to point at a different host.
46
77
 
47
- ## Licence
78
+ ## License
48
79
 
49
80
  MIT. The published fixing is free to read. See
50
81
  [llms.txt](https://sbor.xyz/llms.txt) for the full integration policy.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sbor-mcp",
3
- "version": "1.0.1",
4
- "description": "MCP server for SBOR, the benchmark lending rate for Stacks",
3
+ "version": "1.1.1",
4
+ "description": "Check a lending rate before your agent borrows. The benchmark rate for Stacks, read from contract state. Free, no key, no rate limit.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "sbor-mcp": "./server.mjs"
@@ -11,15 +11,22 @@
11
11
  "homepage": "https://sbor.xyz",
12
12
  "repository": {
13
13
  "type": "git",
14
- "url": "https://github.com/sborxyz/sbor"
14
+ "url": "git+https://github.com/sborxyz/sbor.git"
15
15
  },
16
16
  "keywords": [
17
17
  "mcp",
18
+ "mcp-server",
18
19
  "stacks",
19
20
  "bitcoin",
21
+ "bitcoin-defi",
22
+ "btcfi",
20
23
  "defi",
24
+ "lending",
21
25
  "interest-rates",
22
26
  "benchmark",
27
+ "ai-agents",
28
+ "agents",
29
+ "sbtc",
23
30
  "sbor"
24
31
  ],
25
32
  "dependencies": {
package/server.mjs CHANGED
@@ -2,44 +2,70 @@
2
2
  /**
3
3
  * SBOR MCP server.
4
4
  *
5
- * Exposes the Stacks Bitcoin Offered Rate as tools any MCP client can call:
6
- * Claude, an agent framework, or anything else that speaks the protocol.
5
+ * Exposes SBOR, the benchmark lending rate for Stacks, as tools any MCP client
6
+ * can call: Claude, an agent framework, or anything else that speaks the
7
+ * protocol.
7
8
  *
8
9
  * Reads the same public endpoints as everyone else. No key, no state, no
9
- * writes. If SBOR is unreachable the tools say so rather than guessing.
10
+ * writes. If SBOR cannot give a trustworthy answer, the tools say so rather
11
+ * than guessing. That matters most in compare_rate, which an agent may consult
12
+ * before borrowing: a wrong verdict there is worse than no verdict.
10
13
  *
11
- * Run: npx -y @sbor/mcp
14
+ * Run: npx -y sbor-mcp
12
15
  * Or: node mcp/server.mjs
13
16
  */
14
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
19
  import { z } from "zod";
17
20
 
21
+ const VERSION = "1.1.1";
18
22
  const BASE = process.env.SBOR_BASE || "https://sbor.xyz";
19
- const UA = "sbor-mcp/1.0";
23
+ const UA = `sbor-mcp/${VERSION}`;
24
+ const TIMEOUT_MS = 10_000;
25
+ const STALE_AFTER_HOURS = 48;
26
+ const INDICES = ["SBOR-USD", "SBOR-BTC", "SBOR-STX"];
20
27
 
21
28
  /* Small cache so an agent asking three questions in a row makes one request. */
22
29
  const cache = new Map();
23
30
  async function getJson(path, ttlMs = 60_000){
24
31
  const hit = cache.get(path);
25
32
  if (hit && Date.now() - hit.at < ttlMs) return hit.data;
26
- const r = await fetch(`${BASE}${path}`, { headers: { accept:"application/json", "user-agent":UA } });
33
+ const r = await fetch(`${BASE}${path}`, {
34
+ headers: { accept: "application/json", "user-agent": UA },
35
+ signal: AbortSignal.timeout(TIMEOUT_MS)
36
+ });
27
37
  if (!r.ok) throw new Error(`${path} responded ${r.status}`);
28
38
  const data = await r.json();
29
39
  cache.set(path, { at: Date.now(), data });
30
40
  return data;
31
41
  }
32
42
 
33
- const text = t => ({ content: [{ type:"text", text: t }] });
34
- const fail = e => ({ isError: true, content: [{ type:"text",
35
- text: `SBOR is unreachable or returned something unexpected: ${e.message}. `
36
- + `Do not substitute an estimate. Fall back to your own logic or try again.` }] });
43
+ const text = t => ({ content: [{ type: "text", text: t }] });
44
+ const refuse = t => ({ isError: true, content: [{ type: "text", text: t }] });
45
+ const fail = e => refuse(
46
+ (e?.name === "TimeoutError"
47
+ ? `SBOR did not respond within ${TIMEOUT_MS / 1000} seconds.`
48
+ : `SBOR is unreachable or returned something unexpected: ${e?.message}.`)
49
+ + ` Do not substitute an estimate. Fall back to your own logic or try again.`);
37
50
 
38
- const pct = n => (typeof n === "number" ? n.toFixed(2) + "%" : "not published");
39
- const usd = n => n >= 1e9 ? "$" + (n/1e9).toFixed(2) + "B"
40
- : "$" + (n/1e6).toFixed(1) + "M";
51
+ const pct = n => (typeof n === "number" && Number.isFinite(n) ? n.toFixed(2) + "%" : "not published");
52
+ const usd = n => !Number.isFinite(n) ? "n/a"
53
+ : n >= 1e9 ? "$" + (n / 1e9).toFixed(2) + "B" : "$" + (n / 1e6).toFixed(1) + "M";
41
54
 
42
- const server = new McpServer({ name: "sbor", version: "1.0.0" });
55
+ /* Age of a fixing in hours, or null when the timestamp cannot be parsed. An
56
+ unparseable timestamp must read as unusable, never as fresh. */
57
+ function ageHours(iso){
58
+ const t = Date.parse(iso);
59
+ return Number.isFinite(t) ? (Date.now() - t) / 36e5 : null;
60
+ }
61
+ function freshness(d){
62
+ const a = ageHours(d.fixing);
63
+ if (a === null) return `Fixing timestamp "${d.fixing}" could not be read. Treat this data as unusable.`;
64
+ if (a > STALE_AFTER_HOURS) return `STALE: the last fixing is ${a.toFixed(1)} hours old. Rates may have moved. Do not act on it.`;
65
+ return `Fixing ${d.fixing}, ${a.toFixed(1)} hours old, methodology ${d.methodologyVersion}.`;
66
+ }
67
+
68
+ const server = new McpServer({ name: "sbor", version: VERSION });
43
69
 
44
70
  /* ------------------------------------------------------------------ */
45
71
  /* 1. the current fixing */
@@ -51,38 +77,38 @@ server.registerTool("get_rate", {
51
77
  "lending contract state. Use this to judge whether a lending offer is good: " +
52
78
  "borrowing above the SBOR borrow rate means paying more than the market, " +
53
79
  "supplying below the supply rate means earning less. Returns every currency " +
54
- "index unless one is named.",
80
+ "index unless one is named. Always read the freshness line first.",
55
81
  inputSchema: {
56
- index: z.enum(["SBOR-USD","SBOR-BTC","SBOR-STX"]).optional()
57
- .describe("Currency index. Omit for all of them.")
82
+ index: z.enum(INDICES).optional().describe("Currency index. Omit for all of them.")
58
83
  }
59
84
  }, async ({ index }) => {
60
85
  try {
61
86
  const d = await getJson("/api/v1/latest.json");
62
- const wanted = index ? { [index]: d.indices[index] } : d.indices;
63
87
  if (index && !d.indices[index])
64
88
  return text(`${index} is not published in the current fixing. `
65
89
  + `When a market cannot be read, SBOR omits the index rather than publishing `
66
- + `a figure that is not real. Published today: ${Object.keys(d.indices).join(", ")}.`);
90
+ + `a figure that is not real. Treat it as unknown, never as zero. `
91
+ + `Published today: ${Object.keys(d.indices).join(", ")}.`);
67
92
 
93
+ const wanted = index ? { [index]: d.indices[index] } : d.indices;
68
94
  const lines = Object.entries(wanted).map(([label, ix]) => {
69
- const conc = `${ix.venues.length} ${ix.venues.length === 1 ? "venue" : "venues"}`
70
- + `, largest ${(ix.largestConstituentWeight*100).toFixed(0)}% of depth`;
71
- const allIn = ix.allInSupplyDiffers
72
- ? `, ${pct(ix.allInSupply)} all in with protocol yield` : "";
95
+ const n = ix.venues.length;
96
+ const conc = `${n} ${n === 1 ? "venue, so a reading of that venue rather than a market average" : "venues"}`
97
+ + `, largest ${(ix.largestConstituentWeight * 100).toFixed(0)}% of depth`;
98
+ const allIn = ix.allInSupplyDiffers ? `, ${pct(ix.allInSupply)} all in with protocol yield` : "";
73
99
  return `${label}: borrow ${pct(ix.borrow)}, supply ${pct(ix.supply)}${allIn} (${conc})`;
74
100
  });
75
101
 
76
- const omitted = ["SBOR-USD","SBOR-BTC","SBOR-STX"].filter(l => !d.indices[l]);
102
+ const omitted = INDICES.filter(l => !d.indices[l]);
77
103
  return text([
78
- `SBOR fixing ${d.fixing}, methodology ${d.methodologyVersion}`,
104
+ freshness(d),
79
105
  ...lines,
80
106
  omitted.length ? `Not published: ${omitted.join(", ")}. A market that cannot be read is omitted, not estimated.` : "",
81
- d.poxReference ? `PoX staking yield ${pct(d.poxReference.apy)}, a staking yield and not a lending rate.` : "",
107
+ d.poxReference ? `Proof of Transfer staking yield ${pct(d.poxReference.apy)}. A staking yield, not a lending rate. Never add it to one.` : "",
82
108
  `Basis: ${d.basis}`,
83
109
  `Source: ${BASE}/api/v1/latest.json`
84
110
  ].filter(Boolean).join("\n"));
85
- } catch(e){ return fail(e); }
111
+ } catch (e) { return fail(e); }
86
112
  });
87
113
 
88
114
  /* ------------------------------------------------------------------ */
@@ -92,24 +118,46 @@ server.registerTool("compare_rate", {
92
118
  title: "Compare a rate against the SBOR benchmark",
93
119
  description:
94
120
  "Given a rate you have been offered, say whether it is above or below the " +
95
- "market for that currency, and by how much. This is the main reason SBOR exists.",
121
+ "market for that currency, and by how much. This is the main reason SBOR exists. " +
122
+ "Recommended use: if a borrow offer is more than 50 basis points above the " +
123
+ "benchmark, stop and ask a human. Use SBOR to stop, never to start. " +
124
+ "This tool refuses rather than guesses: an error result means there is no " +
125
+ "trustworthy answer, not that the rate is bad.",
96
126
  inputSchema: {
97
- rate: z.number().describe("The rate offered, as a percentage. 4.2 means 4.2%."),
98
- side: z.enum(["borrow","supply"]).describe("Whether you would be borrowing or supplying."),
99
- index: z.enum(["SBOR-USD","SBOR-BTC","SBOR-STX"]).describe("Which currency.")
127
+ rate: z.number().gt(0).lte(100)
128
+ .describe("The rate offered, as a percentage between 0 and 100. 4.2 means 4.2%, not 0.042."),
129
+ side: z.enum(["borrow", "supply"]).describe("Whether you would be borrowing or supplying."),
130
+ index: z.enum(INDICES).describe("Which currency.")
100
131
  }
101
132
  }, async ({ rate, side, index }) => {
102
133
  try {
134
+ if (!Number.isFinite(rate) || rate <= 0 || rate > 100)
135
+ return refuse(`The rate must be a percentage between 0 and 100, got ${rate}. 4.2% is 4.2, not 0.042.`);
136
+
103
137
  const d = await getJson("/api/v1/latest.json");
138
+
139
+ /* A verdict an agent may act on must not rest on data SBOR cannot stand behind. */
140
+ const a = ageHours(d.fixing);
141
+ if (a === null)
142
+ return refuse(`The fixing timestamp "${d.fixing}" could not be read, so the age of the data is unknown. No verdict. Fall back to your own logic.`);
143
+ if (a > STALE_AFTER_HOURS)
144
+ return refuse(`The last fixing is ${a.toFixed(1)} hours old, past the ${STALE_AFTER_HOURS} hour limit. No verdict on stale data. Fall back to your own logic.`);
145
+
104
146
  const ix = d.indices[index];
105
- if (!ix) return text(`${index} is not published in the current fixing, so there is no benchmark to compare against today.`);
147
+ if (!ix)
148
+ return refuse(`${index} is not published in the current fixing, so there is no benchmark to compare against. `
149
+ + `Treat this as unknown, not as zero. Published today: ${Object.keys(d.indices).join(", ")}.`);
150
+
106
151
  const bench = ix[side];
107
- if (typeof bench !== "number") return text(`${index} has no published ${side} rate today.`);
152
+ if (typeof bench !== "number" || !Number.isFinite(bench))
153
+ return refuse(`${index} has no usable ${side} rate in the current fixing. Treat this as unknown, not as zero.`);
108
154
 
109
155
  const diff = rate - bench;
156
+ if (!Number.isFinite(diff)) return refuse(`Could not compute a difference. No verdict.`);
110
157
  const bps = Math.round(Math.abs(diff) * 100);
158
+
111
159
  let verdict;
112
- if (Math.abs(diff) < 0.01) verdict = "at the market";
160
+ if (bps < 1) verdict = "at the market";
113
161
  else if (side === "borrow") verdict = diff > 0
114
162
  ? `above the market. You would be paying ${bps} basis points more than the benchmark`
115
163
  : `below the market. You would be paying ${bps} basis points less than the benchmark`;
@@ -117,18 +165,30 @@ server.registerTool("compare_rate", {
117
165
  ? `above the market. You would be earning ${bps} basis points more than the benchmark`
118
166
  : `below the market. You would be earning ${bps} basis points less than the benchmark`;
119
167
 
120
- const cheapest = [...ix.markets].sort((a,b) =>
121
- side === "borrow" ? a.borrow - b.borrow : b.supply - a.supply)[0];
168
+ /* A fraction passed as a percentage cannot be caught with certainty,
169
+ because rates this low genuinely occur on Stacks. So it is answered, and
170
+ flagged loudly enough that it cannot be missed. */
171
+ const unitsWarning = rate < 0.5
172
+ ? `CHECK UNITS FIRST: this was read as ${rate}%, not ${(rate * 100).toFixed(1)}%. Rates this low do occur, so it has been answered as given. If you meant ${(rate * 100).toFixed(1)}%, ask again with ${(rate * 100).toFixed(1)}.`
173
+ : "";
174
+
175
+ const best = [...ix.markets]
176
+ .filter(m => typeof m[side] === "number" && Number.isFinite(m[side]))
177
+ .sort((p, q) => side === "borrow" ? p[side] - q[side] : q[side] - p[side])[0];
178
+
179
+ const stop = side === "borrow" && diff * 100 > 50
180
+ ? `This is more than 50 basis points above the benchmark. Recommended: stop and ask a human before borrowing.`
181
+ : "";
122
182
 
123
183
  return text([
184
+ unitsWarning,
124
185
  `${index} ${side} benchmark is ${pct(bench)}. Your ${rate.toFixed(2)}% is ${verdict}.`,
125
- `Best constituent today: ${cheapest.venue} ${cheapest.asset} at ${pct(cheapest[side])}, `
126
- + `utilisation ${pct(cheapest.utilization)}.`,
127
- ix.venues.length === 1
128
- ? `Note: this index covers one venue, so it is a reading of that venue rather than a market average.`
129
- : ""
186
+ stop,
187
+ best ? `Best constituent today: ${best.venue} ${best.asset} at ${pct(best[side])}, utilization ${pct(best.utilization)}.` : "",
188
+ ix.venues.length === 1 ? `Note: this index covers one venue, so it is a reading of that venue rather than a market average.` : "",
189
+ freshness(d)
130
190
  ].filter(Boolean).join("\n"));
131
- } catch(e){ return fail(e); }
191
+ } catch (e) { return fail(e); }
132
192
  });
133
193
 
134
194
  /* ------------------------------------------------------------------ */
@@ -138,10 +198,9 @@ server.registerTool("list_markets", {
138
198
  title: "List the lending markets behind a rate",
139
199
  description:
140
200
  "Every venue and asset in an index, with its borrow rate, supply rate, " +
141
- "utilisation, depth and weight. Utilisation explains why a rate sits where it does.",
201
+ "utilization, depth and weight. Utilization explains why a rate sits where it does.",
142
202
  inputSchema: {
143
- index: z.enum(["SBOR-USD","SBOR-BTC","SBOR-STX"]).optional()
144
- .describe("Currency index. Omit for all of them.")
203
+ index: z.enum(INDICES).optional().describe("Currency index. Omit for all of them.")
145
204
  }
146
205
  }, async ({ index }) => {
147
206
  try {
@@ -149,16 +208,16 @@ server.registerTool("list_markets", {
149
208
  const entries = index
150
209
  ? (d.indices[index] ? [[index, d.indices[index]]] : [])
151
210
  : Object.entries(d.indices);
152
- if (!entries.length) return text(`${index} is not published in the current fixing.`);
211
+ if (!entries.length) return text(`${index} is not published in the current fixing. Treat it as unknown, not as zero.`);
153
212
 
154
213
  const out = entries.map(([label, ix]) =>
155
214
  `${label}\n` + ix.markets.map(m =>
156
215
  ` ${m.venue} ${m.asset}: borrow ${pct(m.borrow)}, supply ${pct(m.supply)}, `
157
- + `utilisation ${pct(m.utilization)}, depth ${usd(m.depthUsd)}, weight ${(m.weight*100).toFixed(1)}%`
158
- + (m.protocolYield ? `, plus ${pct(m.protocolYield)} protocol yield from the asset itself` : "")
216
+ + `utilization ${pct(m.utilization)}, depth ${usd(m.depthUsd)}, weight ${(m.weight * 100).toFixed(1)}%`
217
+ + (m.protocolYield ? `, plus ${pct(m.protocolYield)} protocol yield from holding the asset, not from the loan` : "")
159
218
  ).join("\n")).join("\n\n");
160
- return text(out + `\n\nSource: ${BASE}/api/v1/latest.json`);
161
- } catch(e){ return fail(e); }
219
+ return text([freshness(d), "", out, "", `Source: ${BASE}/api/v1/latest.json`].join("\n"));
220
+ } catch (e) { return fail(e); }
162
221
  });
163
222
 
164
223
  /* ------------------------------------------------------------------ */
@@ -168,58 +227,93 @@ server.registerTool("get_history", {
168
227
  title: "Get the SBOR history",
169
228
  description:
170
229
  "Daily fixings since the index began. Use this to see whether a rate is " +
171
- "unusual, or how the cost of capital has moved.",
230
+ "unusual, or how the cost of capital has moved. Means are given per " +
231
+ "methodology version, never across a change in how the number is built.",
172
232
  inputSchema: {
173
- index: z.enum(["SBOR-USD","SBOR-BTC","SBOR-STX"]).describe("Which currency."),
174
- days: z.number().int().min(1).max(365).optional().describe("How many days back. Default 30.")
233
+ index: z.enum(INDICES).describe("Which currency."),
234
+ days: z.number().int().min(1).max(200).optional().describe("How many days back, up to 200. Default 30.")
175
235
  }
176
236
  }, async ({ index, days = 30 }) => {
177
237
  try {
178
238
  const h = await getJson("/api/v1/history.json", 300_000);
179
- const cutoff = new Date(Date.now() - days*864e5).toISOString().slice(0,10);
180
- const rows = h.filter(r => r.date >= cutoff && r[index]);
239
+ const cutoff = new Date(Date.now() - days * 864e5).toISOString().slice(0, 10);
240
+ const rows = h.filter(r => r.date >= cutoff && r[index]).sort((p, q) => p.date.localeCompare(q.date));
181
241
  if (!rows.length) return text(`No ${index} fixings in the last ${days} days.`);
182
242
 
183
243
  const lines = rows.map(r => {
184
244
  const e = r[index];
185
245
  if (e.withdrawn) return `${r.date}: withdrawn. ${e.reason}`;
186
- return `${r.date}: borrow ${pct(e.borrow)}, supply ${pct(e.supply)}`;
246
+ return `${r.date}: borrow ${pct(e.borrow)}, supply ${pct(e.supply)}${r.methodologyVersion ? ` (v${r.methodologyVersion})` : " (version not recorded)"}`;
187
247
  });
188
- const withdrawn = rows.filter(r => r[index].withdrawn).length;
189
- const valid = rows.filter(r => !r[index].withdrawn && typeof r[index].borrow === "number");
190
- const avg = valid.length
191
- ? (valid.reduce((a,r)=>a+r[index].borrow,0)/valid.length).toFixed(2) : null;
248
+
249
+ const valid = rows.filter(r => !r[index].withdrawn && Number.isFinite(r[index].borrow));
250
+ const withdrawn = rows.length - rows.filter(r => !r[index].withdrawn).length;
251
+
252
+ /* A mean across a methodology change averages two different definitions of
253
+ the same number, so it is given per version instead. */
254
+ const byVersion = {};
255
+ for (const r of valid) {
256
+ const v = r.methodologyVersion ?? "not recorded";
257
+ (byVersion[v] ||= []).push(r[index].borrow);
258
+ }
259
+ const versions = Object.keys(byVersion);
260
+ const means = versions.map(v => {
261
+ const xs = byVersion[v];
262
+ return ` v${v}: ${(xs.reduce((a, b) => a + b, 0) / xs.length).toFixed(2)}% over ${xs.length} fixing${xs.length > 1 ? "s" : ""}`;
263
+ });
264
+
192
265
  return text([
193
266
  `${index}, last ${days} days, ${rows.length} fixings`,
194
267
  ...lines,
195
- withdrawn ? `\n${withdrawn} fixing${withdrawn>1?"s":""} in this window ${withdrawn>1?"were":"was"} withdrawn and ${withdrawn>1?"are":"is"} excluded from the mean. Withdrawn fixings stay in the record rather than being deleted.` : "",
196
- avg ? `\nSimple mean borrow over the period: ${avg}%. For a compounded term average use get_rate, which carries termAverages once a full window exists.` : ""
268
+ withdrawn ? `\n${withdrawn} withdrawn fixing${withdrawn > 1 ? "s are" : " is"} kept in the record and excluded from every mean.` : "",
269
+ versions.length > 1
270
+ ? `\nThis window spans ${versions.length} methodology versions, so there is no single mean. Mean borrow by version:\n${means.join("\n")}`
271
+ : valid.length ? `\nSimple mean borrow over the period: ${means[0].split(": ")[1]}` : "",
272
+ `\nFor a compounded term average use get_rate, which carries termAverages once a full window exists.`
197
273
  ].filter(Boolean).join("\n"));
198
- } catch(e){ return fail(e); }
274
+ } catch (e) { return fail(e); }
199
275
  });
200
276
 
201
277
  /* ------------------------------------------------------------------ */
202
278
  /* 5. how does Stacks compare */
203
279
  /* ------------------------------------------------------------------ */
204
280
  server.registerTool("compare_chains", {
205
- title: "Compare Stacks rates against the largest lending market elsewhere",
281
+ title: "Compare Stacks rates against the same markets on other chains",
206
282
  description:
207
- "Reference rates from Aave V3 on Ethereum for the same asset classes, " +
208
- "published beside the Stacks indices. Context only: these are never " +
209
- "constituents of an SBOR index.",
210
- inputSchema: {}
211
- }, async () => {
283
+ "The same asset classes on the largest lending markets on Ethereum, Base, " +
284
+ "Hyperliquid and Solana, plus SOFR, the US repo rate. Context only: none of " +
285
+ "these is ever a constituent of an SBOR index, and the other chains come from " +
286
+ "DefiLlama rather than contract state, so small differences are expected.",
287
+ inputSchema: {
288
+ index: z.enum(INDICES).optional().describe("Only markets comparable to this index. Omit for all.")
289
+ }
290
+ }, async ({ index }) => {
212
291
  try {
213
292
  const d = await getJson("/api/v1/latest.json");
214
- if (!d.externalReference) return text("No external reference in the current fixing.");
215
- const ext = d.externalReference.markets.map(m =>
216
- `${m.venue} ${m.asset}: borrow ${pct(m.borrow)}, supply ${pct(m.supply)}, `
217
- + `utilisation ${pct(m.utilization)}, depth ${usd(m.depthUsd)} (compare with ${m.comparableTo})`
218
- ).join("\n");
219
- const stacks = Object.entries(d.indices).map(([l,ix]) =>
220
- `${l}: borrow ${pct(ix.borrow)}, supply ${pct(ix.supply)}`).join("\n");
221
- return text(`Stacks\n${stacks}\n\nElsewhere\n${ext}\n\n${d.externalReference.note}`);
222
- } catch(e){ return fail(e); }
293
+ const all = d.externalReference?.markets ?? [];
294
+ const ext = (index ? all.filter(m => m.comparableTo === index) : all).map(m =>
295
+ ` ${m.venue} ${m.asset}: borrow ${pct(m.borrow)}, supply ${pct(m.supply)}, `
296
+ + `utilization ${pct(m.utilization)}, depth ${usd(m.depthUsd)}`
297
+ );
298
+ const stacks = Object.entries(d.indices)
299
+ .filter(([l]) => !index || l === index)
300
+ .map(([l, ix]) => ` ${l}: borrow ${pct(ix.borrow)}, supply ${pct(ix.supply)}`);
301
+
302
+ const s = d.context?.sofr;
303
+ const sofr = s && (!index || index === "SBOR-USD")
304
+ ? `SOFR, the overnight US repo rate secured by US government debt: ${pct(s.rate)} for ${s.effectiveDate}.`
305
+ + (Number.isFinite(s.average30day) ? ` Averages: 30 day ${pct(s.average30day)}, 90 day ${pct(s.average90day)}, 180 day ${pct(s.average180day)}.` : "")
306
+ + ` A cheaper rate on Stacks reflects lower utilization, not lower risk.`
307
+ : "";
308
+
309
+ return text([
310
+ freshness(d), "",
311
+ "Stacks", ...stacks, "",
312
+ ext.length ? "Elsewhere" : "", ...ext, ext.length ? "" : "",
313
+ sofr,
314
+ d.externalReference?.note ?? ""
315
+ ].filter((x, i, arr) => x !== "" || (arr[i - 1] !== "" && i > 0)).join("\n").trim());
316
+ } catch (e) { return fail(e); }
223
317
  });
224
318
 
225
319
  /* ------------------------------------------------------------------ */
@@ -234,10 +328,13 @@ server.registerTool("get_methodology", {
234
328
  inputSchema: {}
235
329
  }, async () => {
236
330
  try {
237
- const r = await fetch(`${BASE}/llms.txt`, { headers:{ "user-agent":UA } });
331
+ const r = await fetch(`${BASE}/llms.txt`, {
332
+ headers: { "user-agent": UA },
333
+ signal: AbortSignal.timeout(TIMEOUT_MS)
334
+ });
238
335
  if (!r.ok) throw new Error(`llms.txt responded ${r.status}`);
239
336
  return text(await r.text());
240
- } catch(e){ return fail(e); }
337
+ } catch (e) { return fail(e); }
241
338
  });
242
339
 
243
340
  await server.connect(new StdioServerTransport());