sbor-mcp 1.0.0 → 1.1.0
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 +40 -9
- package/package.json +10 -3
- package/server.mjs +177 -80
package/README.md
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
# SBOR MCP server
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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", "
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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
|
|
35
|
-
|
|
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
|
-
##
|
|
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.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.0",
|
|
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
|
|
6
|
-
* Claude, an agent framework, or anything else that speaks the
|
|
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
|
|
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
|
|
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.0";
|
|
18
22
|
const BASE = process.env.SBOR_BASE || "https://sbor.xyz";
|
|
19
|
-
const UA =
|
|
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}`, {
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 =
|
|
102
|
+
const omitted = INDICES.filter(l => !d.indices[l]);
|
|
77
103
|
return text([
|
|
78
|
-
|
|
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 ? `
|
|
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().
|
|
98
|
-
|
|
99
|
-
|
|
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)
|
|
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"
|
|
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 (
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
ix.venues.length === 1
|
|
128
|
-
|
|
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
|
-
"
|
|
201
|
+
"utilization, depth and weight. Utilization explains why a rate sits where it does.",
|
|
142
202
|
inputSchema: {
|
|
143
|
-
index: z.enum(
|
|
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
|
-
+ `
|
|
158
|
-
+ (m.protocolYield ? `, plus ${pct(m.protocolYield)} protocol yield from the asset
|
|
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
|
|
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(
|
|
174
|
-
days: z.number().int().min(1).max(
|
|
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
|
-
|
|
189
|
-
const valid = rows.filter(r => !r[index].withdrawn &&
|
|
190
|
-
const
|
|
191
|
-
|
|
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":
|
|
196
|
-
|
|
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
|
|
281
|
+
title: "Compare Stacks rates against the same markets on other chains",
|
|
206
282
|
description:
|
|
207
|
-
"
|
|
208
|
-
"
|
|
209
|
-
"
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
const ext =
|
|
216
|
-
|
|
217
|
-
+ `
|
|
218
|
-
)
|
|
219
|
-
const stacks = Object.entries(d.indices)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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`, {
|
|
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());
|