sbor-mcp 1.0.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 +50 -0
- package/package.json +32 -0
- package/server.mjs +243 -0
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# SBOR MCP server
|
|
2
|
+
|
|
3
|
+
Call [SBOR](https://sbor.xyz), the benchmark lending rate for Stacks, as a tool
|
|
4
|
+
from Claude or any MCP client.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```json
|
|
9
|
+
{
|
|
10
|
+
"mcpServers": {
|
|
11
|
+
"sbor": {
|
|
12
|
+
"command": "npx",
|
|
13
|
+
"args": ["-y", "@sbor/mcp"]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Claude Desktop: add that to `claude_desktop_config.json` and restart.
|
|
20
|
+
|
|
21
|
+
## Tools
|
|
22
|
+
|
|
23
|
+
| Tool | What it answers |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `get_rate` | What does capital cost on Stacks right now |
|
|
26
|
+
| `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 |
|
|
28
|
+
| `get_history` | How has the rate moved |
|
|
29
|
+
| `compare_chains` | How does Stacks compare with Aave on Ethereum |
|
|
30
|
+
| `get_methodology` | How the number is built, and what it excludes |
|
|
31
|
+
|
|
32
|
+
## Behaviour worth knowing
|
|
33
|
+
|
|
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.
|
|
36
|
+
|
|
37
|
+
**It reports omissions.** When a market cannot be read, SBOR omits the index
|
|
38
|
+
rather than publishing a figure that is not real. The tool explains that instead
|
|
39
|
+
of returning nothing.
|
|
40
|
+
|
|
41
|
+
**It surfaces concentration.** An index covering one venue is a reading of that
|
|
42
|
+
venue, not a market average, and `get_rate` says so.
|
|
43
|
+
|
|
44
|
+
**It reads the same public endpoints as everyone else.** No key, no state, no
|
|
45
|
+
writes, no telemetry. Set `SBOR_BASE` to point at a different host.
|
|
46
|
+
|
|
47
|
+
## Licence
|
|
48
|
+
|
|
49
|
+
MIT. The published fixing is free to read. See
|
|
50
|
+
[llms.txt](https://sbor.xyz/llms.txt) for the full integration policy.
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sbor-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for SBOR, the benchmark lending rate for Stacks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sbor-mcp": "./server.mjs"
|
|
8
|
+
},
|
|
9
|
+
"main": "./server.mjs",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"homepage": "https://sbor.xyz",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/sborxyz/sbor"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"stacks",
|
|
19
|
+
"bitcoin",
|
|
20
|
+
"defi",
|
|
21
|
+
"interest-rates",
|
|
22
|
+
"benchmark",
|
|
23
|
+
"sbor"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
27
|
+
"zod": "^3.23.8"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SBOR MCP server.
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* 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
|
+
*
|
|
11
|
+
* Run: npx -y @sbor/mcp
|
|
12
|
+
* Or: node mcp/server.mjs
|
|
13
|
+
*/
|
|
14
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
|
|
18
|
+
const BASE = process.env.SBOR_BASE || "https://sbor.xyz";
|
|
19
|
+
const UA = "sbor-mcp/1.0";
|
|
20
|
+
|
|
21
|
+
/* Small cache so an agent asking three questions in a row makes one request. */
|
|
22
|
+
const cache = new Map();
|
|
23
|
+
async function getJson(path, ttlMs = 60_000){
|
|
24
|
+
const hit = cache.get(path);
|
|
25
|
+
if (hit && Date.now() - hit.at < ttlMs) return hit.data;
|
|
26
|
+
const r = await fetch(`${BASE}${path}`, { headers: { accept:"application/json", "user-agent":UA } });
|
|
27
|
+
if (!r.ok) throw new Error(`${path} responded ${r.status}`);
|
|
28
|
+
const data = await r.json();
|
|
29
|
+
cache.set(path, { at: Date.now(), data });
|
|
30
|
+
return data;
|
|
31
|
+
}
|
|
32
|
+
|
|
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.` }] });
|
|
37
|
+
|
|
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";
|
|
41
|
+
|
|
42
|
+
const server = new McpServer({ name: "sbor", version: "1.0.0" });
|
|
43
|
+
|
|
44
|
+
/* ------------------------------------------------------------------ */
|
|
45
|
+
/* 1. the current fixing */
|
|
46
|
+
/* ------------------------------------------------------------------ */
|
|
47
|
+
server.registerTool("get_rate", {
|
|
48
|
+
title: "Get the current SBOR fixing",
|
|
49
|
+
description:
|
|
50
|
+
"The benchmark borrow and supply rate for lending on Stacks, read from " +
|
|
51
|
+
"lending contract state. Use this to judge whether a lending offer is good: " +
|
|
52
|
+
"borrowing above the SBOR borrow rate means paying more than the market, " +
|
|
53
|
+
"supplying below the supply rate means earning less. Returns every currency " +
|
|
54
|
+
"index unless one is named.",
|
|
55
|
+
inputSchema: {
|
|
56
|
+
index: z.enum(["SBOR-USD","SBOR-BTC","SBOR-STX"]).optional()
|
|
57
|
+
.describe("Currency index. Omit for all of them.")
|
|
58
|
+
}
|
|
59
|
+
}, async ({ index }) => {
|
|
60
|
+
try {
|
|
61
|
+
const d = await getJson("/api/v1/latest.json");
|
|
62
|
+
const wanted = index ? { [index]: d.indices[index] } : d.indices;
|
|
63
|
+
if (index && !d.indices[index])
|
|
64
|
+
return text(`${index} is not published in the current fixing. `
|
|
65
|
+
+ `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(", ")}.`);
|
|
67
|
+
|
|
68
|
+
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` : "";
|
|
73
|
+
return `${label}: borrow ${pct(ix.borrow)}, supply ${pct(ix.supply)}${allIn} (${conc})`;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const omitted = ["SBOR-USD","SBOR-BTC","SBOR-STX"].filter(l => !d.indices[l]);
|
|
77
|
+
return text([
|
|
78
|
+
`SBOR fixing ${d.fixing}, methodology ${d.methodologyVersion}`,
|
|
79
|
+
...lines,
|
|
80
|
+
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.` : "",
|
|
82
|
+
`Basis: ${d.basis}`,
|
|
83
|
+
`Source: ${BASE}/api/v1/latest.json`
|
|
84
|
+
].filter(Boolean).join("\n"));
|
|
85
|
+
} catch(e){ return fail(e); }
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/* ------------------------------------------------------------------ */
|
|
89
|
+
/* 2. is this offer any good */
|
|
90
|
+
/* ------------------------------------------------------------------ */
|
|
91
|
+
server.registerTool("compare_rate", {
|
|
92
|
+
title: "Compare a rate against the SBOR benchmark",
|
|
93
|
+
description:
|
|
94
|
+
"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.",
|
|
96
|
+
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.")
|
|
100
|
+
}
|
|
101
|
+
}, async ({ rate, side, index }) => {
|
|
102
|
+
try {
|
|
103
|
+
const d = await getJson("/api/v1/latest.json");
|
|
104
|
+
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.`);
|
|
106
|
+
const bench = ix[side];
|
|
107
|
+
if (typeof bench !== "number") return text(`${index} has no published ${side} rate today.`);
|
|
108
|
+
|
|
109
|
+
const diff = rate - bench;
|
|
110
|
+
const bps = Math.round(Math.abs(diff) * 100);
|
|
111
|
+
let verdict;
|
|
112
|
+
if (Math.abs(diff) < 0.01) verdict = "at the market";
|
|
113
|
+
else if (side === "borrow") verdict = diff > 0
|
|
114
|
+
? `above the market. You would be paying ${bps} basis points more than the benchmark`
|
|
115
|
+
: `below the market. You would be paying ${bps} basis points less than the benchmark`;
|
|
116
|
+
else verdict = diff > 0
|
|
117
|
+
? `above the market. You would be earning ${bps} basis points more than the benchmark`
|
|
118
|
+
: `below the market. You would be earning ${bps} basis points less than the benchmark`;
|
|
119
|
+
|
|
120
|
+
const cheapest = [...ix.markets].sort((a,b) =>
|
|
121
|
+
side === "borrow" ? a.borrow - b.borrow : b.supply - a.supply)[0];
|
|
122
|
+
|
|
123
|
+
return text([
|
|
124
|
+
`${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
|
+
: ""
|
|
130
|
+
].filter(Boolean).join("\n"));
|
|
131
|
+
} catch(e){ return fail(e); }
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/* ------------------------------------------------------------------ */
|
|
135
|
+
/* 3. venue by venue */
|
|
136
|
+
/* ------------------------------------------------------------------ */
|
|
137
|
+
server.registerTool("list_markets", {
|
|
138
|
+
title: "List the lending markets behind a rate",
|
|
139
|
+
description:
|
|
140
|
+
"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.",
|
|
142
|
+
inputSchema: {
|
|
143
|
+
index: z.enum(["SBOR-USD","SBOR-BTC","SBOR-STX"]).optional()
|
|
144
|
+
.describe("Currency index. Omit for all of them.")
|
|
145
|
+
}
|
|
146
|
+
}, async ({ index }) => {
|
|
147
|
+
try {
|
|
148
|
+
const d = await getJson("/api/v1/latest.json");
|
|
149
|
+
const entries = index
|
|
150
|
+
? (d.indices[index] ? [[index, d.indices[index]]] : [])
|
|
151
|
+
: Object.entries(d.indices);
|
|
152
|
+
if (!entries.length) return text(`${index} is not published in the current fixing.`);
|
|
153
|
+
|
|
154
|
+
const out = entries.map(([label, ix]) =>
|
|
155
|
+
`${label}\n` + ix.markets.map(m =>
|
|
156
|
+
` ${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` : "")
|
|
159
|
+
).join("\n")).join("\n\n");
|
|
160
|
+
return text(out + `\n\nSource: ${BASE}/api/v1/latest.json`);
|
|
161
|
+
} catch(e){ return fail(e); }
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/* ------------------------------------------------------------------ */
|
|
165
|
+
/* 4. history */
|
|
166
|
+
/* ------------------------------------------------------------------ */
|
|
167
|
+
server.registerTool("get_history", {
|
|
168
|
+
title: "Get the SBOR history",
|
|
169
|
+
description:
|
|
170
|
+
"Daily fixings since the index began. Use this to see whether a rate is " +
|
|
171
|
+
"unusual, or how the cost of capital has moved.",
|
|
172
|
+
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.")
|
|
175
|
+
}
|
|
176
|
+
}, async ({ index, days = 30 }) => {
|
|
177
|
+
try {
|
|
178
|
+
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]);
|
|
181
|
+
if (!rows.length) return text(`No ${index} fixings in the last ${days} days.`);
|
|
182
|
+
|
|
183
|
+
const lines = rows.map(r => {
|
|
184
|
+
const e = r[index];
|
|
185
|
+
if (e.withdrawn) return `${r.date}: withdrawn. ${e.reason}`;
|
|
186
|
+
return `${r.date}: borrow ${pct(e.borrow)}, supply ${pct(e.supply)}`;
|
|
187
|
+
});
|
|
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;
|
|
192
|
+
return text([
|
|
193
|
+
`${index}, last ${days} days, ${rows.length} fixings`,
|
|
194
|
+
...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.` : ""
|
|
197
|
+
].filter(Boolean).join("\n"));
|
|
198
|
+
} catch(e){ return fail(e); }
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/* ------------------------------------------------------------------ */
|
|
202
|
+
/* 5. how does Stacks compare */
|
|
203
|
+
/* ------------------------------------------------------------------ */
|
|
204
|
+
server.registerTool("compare_chains", {
|
|
205
|
+
title: "Compare Stacks rates against the largest lending market elsewhere",
|
|
206
|
+
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 () => {
|
|
212
|
+
try {
|
|
213
|
+
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); }
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/* ------------------------------------------------------------------ */
|
|
226
|
+
/* 6. how the number is made */
|
|
227
|
+
/* ------------------------------------------------------------------ */
|
|
228
|
+
server.registerTool("get_methodology", {
|
|
229
|
+
title: "How SBOR is calculated",
|
|
230
|
+
description:
|
|
231
|
+
"The full methodology and integration policy: how the fixing is built, " +
|
|
232
|
+
"what is excluded and why, and what SBOR will and will not do. Read this " +
|
|
233
|
+
"before quoting a rate in anything that matters.",
|
|
234
|
+
inputSchema: {}
|
|
235
|
+
}, async () => {
|
|
236
|
+
try {
|
|
237
|
+
const r = await fetch(`${BASE}/llms.txt`, { headers:{ "user-agent":UA } });
|
|
238
|
+
if (!r.ok) throw new Error(`llms.txt responded ${r.status}`);
|
|
239
|
+
return text(await r.text());
|
|
240
|
+
} catch(e){ return fail(e); }
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await server.connect(new StdioServerTransport());
|