bykaranteli-mcp 0.30.7 → 0.32.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/CHANGELOG.md +38 -0
- package/README.md +51 -15
- package/dist/provenance.d.ts +22 -0
- package/dist/provenance.js +127 -0
- package/dist/tools.js +503 -34
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to the `bykaranteli-mcp` package. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Dates are UTC and come from the git tag of each release (the version commit where no tag exists). The tool count is the number of tools the server registers in that release.
|
|
4
4
|
|
|
5
|
+
## [0.32.0] - 2026-10-06
|
|
6
|
+
|
|
7
|
+
67 tools.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- `get_etf_flows`: `asset` accepts `XRP` next to `BTC`, `ETH` and `SOL` and returns the US spot XRP ETF daily rows (net inflow, total net assets, cumulative inflow since launch, value traded, in USD). The description gives each asset's first day (BTC and ETH since 2025-05-20, SOL since 2025-10-28, XRP since 2026-09-08) instead of one history length for all.
|
|
12
|
+
|
|
13
|
+
## [0.31.0] - 2026-09-30
|
|
14
|
+
|
|
15
|
+
67 tools.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `get_options_chain`: the option chain hour by hour from ByKaranteli's own capture of every listed venue: open interest and mark IV per expiry and strike, the change over 1h and 24h, the front expiry's ATM IV path.
|
|
20
|
+
- `get_hl_positions`: the liquidation price map of the largest Hyperliquid accounts per coin, with a comparison against the LiqMap model and an hourly archive.
|
|
21
|
+
- `get_venue_share`: each counted exchange's share of the recorded liquidations and of perpetual open interest over 1, 7 or 30 days.
|
|
22
|
+
- `get_tradfi_gaps`: stock, index and commodity perpetuals against the cash close over weekends and nights, checkpoint by checkpoint, with the realised open gap.
|
|
23
|
+
- Account tools for Hyperliquid address tracking (Terminal and above): `list_tracked_addresses`, `add_tracked_address`, `remove_tracked_address`.
|
|
24
|
+
- OAuth 2.1 sign-in on the hosted endpoint: the client registers itself, the user approves once, the token runs on the account's own key; the key path stays.
|
|
25
|
+
|
|
26
|
+
- `get_series`: the recorded series of one metric for one perpetual (price candles, volume, perp and spot CVD, open interest, funding, liquidations, long/short ratios, RSI, Coinbase premium, ETF flows, borrow rates, Hyperliquid whale net), by period, window (`from`, `to`) or newest bars, on another venue for price, OI, funding and borrow; `/api/series`, metric list at `/api/series/metrics`.
|
|
27
|
+
- Account tools that act on your own account with your key: `list_alert_recipes`, `create_alert_recipe`, `delete_alert_recipe`, `list_watchlists`, `add_watchlist_symbol`, `remove_watchlist_symbol`. The four that change something are annotated `readOnlyHint: false` (the two that remove, `destructiveHint: true`), count on the key's plan, are rate limited per key and recorded on the account; the plan's recipe limit comes back as the tool answer.
|
|
28
|
+
- `parse_alert_text`: a sentence such as "BTC funding above 0.05%" or "tell me when ETH drops 5% in a day" becomes the recipe it describes, with a confidence, a summary and what the text left open; rule based, saves nothing, never invents a threshold.
|
|
29
|
+
- A `provenance` block on every market answer: source page, API path, the answer's own timestamp, fetch time, and when the answer carries them the venues, `full`, `sampled` or `mixed` coverage, stale feeds and first recorded day, plus the proof page.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- The README plan table leaves prices to <https://bykaranteli.com/pricing>, and the x402 catalog is included in the monthly fair use of Builder and above (each call counts as 10 requests) instead of a separate monthly allowance.
|
|
34
|
+
|
|
35
|
+
## [0.30.8] - 2026-09-30
|
|
36
|
+
|
|
37
|
+
52 tools.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- `get_market_profile`: the daily TPO Market Profile of a Binance USDT-M perpetual from ByKaranteli's own minute bars (point of control, 70% value area, first-hour initial balance, volume point of control, naked points of control of the last 60 recorded days, the latest day's profile per bucket); `/api/public/market-profile`.
|
|
42
|
+
|
|
5
43
|
## [0.30.7] - 2026-09-30
|
|
6
44
|
|
|
7
45
|
51 tools.
|
package/README.md
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
MCP (Model Context Protocol) server for **live crypto derivatives data**: funding rates, cross-exchange funding arbitrage, open interest pressure, liquidations, options, ETF flows, Fear & Greed and BTC dominance.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
67 read-only and account tools over the public JSON API of [bykaranteli.com](https://bykaranteli.com/developers): the market data tools only read, and the alert and watchlist tools act on your own account through your key ([Account tools](#account-tools-alerts-and-watchlist)). Since 2026-09-10 the API asks programs for an account key: a **free key** comes with any verified account at <https://bykaranteli.com/dashboard/api> (30 requests a minute and 15,000 a month, public depth), and the Builder, Business and Scale plans raise the rate and the monthly fair use and unlock member depth (LiqMap on seven timeframes, 5-minute series, the x402 catalog included in the monthly fair use). Set it as `BYKARANTELI_API_KEY`. Data covers Binance USDT-M perpetuals; funding arbitrage, liquidations, order book depth, options, positioning and insurance funds add the other venues each board lists on bykaranteli.com/coverage.
|
|
6
6
|
|
|
7
7
|
## Hosted endpoint (no install)
|
|
8
8
|
|
|
9
9
|
Paste `https://mcp.bykaranteli.com` as a custom connector in any MCP-capable
|
|
10
|
-
assistant.
|
|
10
|
+
assistant. The same tools, nothing to install. Every tool call needs your
|
|
11
11
|
account key (free at <https://bykaranteli.com/dashboard/api>); connecting and
|
|
12
12
|
listing the tools work without one. The key travels one of three ways:
|
|
13
13
|
|
|
@@ -18,6 +18,13 @@ listing the tools work without one. The key travels one of three ways:
|
|
|
18
18
|
send headers (ChatGPT). Treat that address like a password and revoke the key
|
|
19
19
|
if it leaks.
|
|
20
20
|
|
|
21
|
+
Or sign in with OAuth 2.1 where the client offers it (claude.ai custom
|
|
22
|
+
connectors: Authentication, Sign in; ChatGPT connectors: OAuth): the client
|
|
23
|
+
registers itself (RFC 7591, PKCE S256), you approve it once on bykaranteli.com
|
|
24
|
+
and it runs on a key of your own account, which the Data and API page lists
|
|
25
|
+
under connected apps. Discovery:
|
|
26
|
+
<https://mcp.bykaranteli.com/.well-known/oauth-protected-resource>.
|
|
27
|
+
|
|
21
28
|
A free account covers the API and the hosted MCP on one monthly counter;
|
|
22
29
|
Builder and above bring higher rates and member depth.
|
|
23
30
|
|
|
@@ -66,7 +73,7 @@ Requires Node.js 18 or newer.
|
|
|
66
73
|
| `get_liquidation_leaderboard` | "What was the biggest liquidation today?", "When do liquidations cluster, Asia or US hours?" |
|
|
67
74
|
| `get_insurance_funds` | "How big is the Binance insurance fund?", "Did any exchange insurance fund shrink this week?" |
|
|
68
75
|
| `get_tokenized_stocks` | "How much tokenized Tesla exists onchain?", "Which tokenized stock trades furthest from the real share?", "Which price valued this wrapper's supply?" (`price_source`, `totals.priced_by`) |
|
|
69
|
-
| `get_etf_flows` | "Did the Bitcoin ETFs buy or sell yesterday?", "Cumulative ETH ETF inflow?" |
|
|
76
|
+
| `get_etf_flows` | "Did the Bitcoin ETFs buy or sell yesterday?", "Cumulative ETH ETF inflow?", "XRP ETF flows this week?" |
|
|
70
77
|
| `get_cot_positioning` | "Are hedge funds long or short Bitcoin?", "What did the COT report show?" |
|
|
71
78
|
| `get_options_snapshot` | "Where are the BTC option walls?", "What is DVOL / the zero-gamma level?" |
|
|
72
79
|
| `get_coinbase_premium` | "Are US investors buying Bitcoin?", "What does the basis trade pay?" |
|
|
@@ -108,15 +115,43 @@ Requires Node.js 18 or newer.
|
|
|
108
115
|
| `get_metric_context` | "Is today's funding extreme historically?", "Where does this reading sit in its distribution?" |
|
|
109
116
|
| `get_quantum_exposure` | "How much Bitcoin is quantum-vulnerable?", "What is the P2PK exposure?" |
|
|
110
117
|
| `get_liqmap` | "Where are the BTC liquidation clusters?", "Where would leveraged longs get liquidated?" |
|
|
118
|
+
| `get_market_profile` | "Where is the BTC point of control today?", "What was yesterday's value area?", "Which naked POCs are still untested?" |
|
|
119
|
+
| `get_options_chain` | "How did BTC open interest move by strike today?", "Where is ETH ATM IV hour by hour?", "Which expiry gained the most open interest in 24h?" |
|
|
120
|
+
| `get_hl_positions` | "Where do Hyperliquid whales get liquidated on BTC?", "How much tracked notional sits below price?", "Does the model agree with the tracked positions?" |
|
|
121
|
+
| `get_venue_share` | "Which exchange had the most liquidations this week?", "How is perp open interest split by venue?" |
|
|
122
|
+
| `get_tradfi_gaps` | "What did the TSLA perp do over the weekend?", "How big was the Monday open gap on NVDA?", "How far apart were the venues overnight?" |
|
|
123
|
+
| `get_series` | "BTC open interest by hour for the last week?", "ETH funding on OKX since 1 September?", "Daily Coinbase premium this month?" (metric list, units and finest periods: <https://bykaranteli.com/api/series/metrics>) |
|
|
124
|
+
|
|
125
|
+
All market answers are JSON and carry a `generatedAt` timestamp, a `source` URL to the human-readable page and a `provenance` block: `source_page`, `api_path`, the answer's own `generated_at`, `fetched_at`, and when the answer carries them the `venues` behind the number, `coverage` (`full`, `sampled` or `mixed`), `stale_venues` and `recorded_since`, plus the `proof` page (<https://bykaranteli.com/proof>). A field the answer does not carry is left out, never filled in. Symbols accept both `BTCUSDT` and bare `BTC`.
|
|
126
|
+
|
|
127
|
+
## Account tools (alerts and watchlist)
|
|
111
128
|
|
|
112
|
-
|
|
129
|
+
These tools read and change your own ByKaranteli account with the same key: the alert recipes that notify you on Telegram, email, push or webhook, and your watchlists. They never trade, move funds or touch another account.
|
|
130
|
+
|
|
131
|
+
| Tool | What it does |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `parse_alert_text` | Turns "BTC funding above 0.05%" or "tell me when ETH drops 5% in a day" into the exact recipe, with a confidence and what the text left open. Rule based; saves nothing, never invents a threshold. |
|
|
134
|
+
| `list_alert_recipes` | Lists your alert recipes with their conditions, scope, channels and when each last fired. |
|
|
135
|
+
| `create_alert_recipe` | Saves an alert recipe (writes to your account). |
|
|
136
|
+
| `delete_alert_recipe` | Deletes one of your alert recipes by id (writes to your account). |
|
|
137
|
+
| `list_watchlists` | Lists your watchlists and the symbols on each. |
|
|
138
|
+
| `add_watchlist_symbol` | Adds a symbol to your default list or the list you name (writes to your account). |
|
|
139
|
+
| `remove_watchlist_symbol` | Removes a symbol from a list (writes to your account). |
|
|
140
|
+
| `list_tracked_addresses` | Lists the Hyperliquid addresses you follow, with their last positions (reads your account). |
|
|
141
|
+
| `add_tracked_address` | Follows a Hyperliquid address for position alerts (writes to your account; Terminal and above). |
|
|
142
|
+
| `remove_tracked_address` | Stops following an address (writes to your account). |
|
|
143
|
+
|
|
144
|
+
- The write tools carry `readOnlyHint: false` (and `destructiveHint: true` for the two that remove something), so clients that ask before a change will ask.
|
|
145
|
+
- How many recipes an account keeps follows its plan; past it, `create_alert_recipe` answers with the limit instead of saving.
|
|
146
|
+
- Every call counts on the key's plan like any other request; writes are also rate limited per key, and each write is recorded on the account with the key that made it.
|
|
147
|
+
- Recipes and watchlist changes show up at once on <https://bykaranteli.com/dashboard/alerts> and <https://bykaranteli.com/dashboard/watchlist>.
|
|
113
148
|
|
|
114
149
|
## Configuration
|
|
115
150
|
|
|
116
151
|
| Env var | Default | Purpose |
|
|
117
152
|
|---|---|---|
|
|
118
153
|
| `BYKARANTELI_API_KEY` | none | Account key (`bk_...`) sent as `Authorization: Bearer`. Free at <https://bykaranteli.com/dashboard/api>; required for programs from 2026-09-10 |
|
|
119
|
-
| `BYKARANTELI_BASE_URL` | `https://bykaranteli.com` | Override the API host (testing only) |
|
|
154
|
+
| `BYKARANTELI_BASE_URL` | `https://bykaranteli.com` | Override the API host (testing only). The key is sent to whatever host this names, so point it only at a server you trust. |
|
|
120
155
|
|
|
121
156
|
## Data notes
|
|
122
157
|
|
|
@@ -140,13 +175,15 @@ Server: MIT. Data: personal and research use with attribution "ByKaranteli (byka
|
|
|
140
175
|
|
|
141
176
|
## Plans and paid depth
|
|
142
177
|
|
|
143
|
-
| Plan |
|
|
144
|
-
|
|
145
|
-
| Free API |
|
|
146
|
-
| Terminal |
|
|
147
|
-
| Builder |
|
|
148
|
-
| Business |
|
|
149
|
-
| Scale |
|
|
178
|
+
| Plan | Rate | Monthly | Depth |
|
|
179
|
+
|---|---|---|---|
|
|
180
|
+
| Free API | 30 / min | 15,000 | public pages |
|
|
181
|
+
| Terminal | 60 / min | 150,000 | public pages |
|
|
182
|
+
| Builder | 300 / min | 1,000,000 fair use | member depth, x402 catalog included, each call counts as 10 requests of the monthly fair use |
|
|
183
|
+
| Business | 1,200 / min | 3,000,000 fair use | member depth, commercial licence, x402 catalog included, each call counts as 10 requests of the monthly fair use, monthly bulk |
|
|
184
|
+
| Scale | 3,000 / min | 10,000,000 fair use | member depth, derived redistribution, x402 catalog included, each call counts as 10 requests of the monthly fair use, daily raw |
|
|
185
|
+
|
|
186
|
+
Prices: https://bykaranteli.com/pricing
|
|
150
187
|
|
|
151
188
|
A Free or Terminal key past its monthly figure gets 429 until
|
|
152
189
|
the month resets. A paid key past its fair use is never stopped: it answers at
|
|
@@ -157,6 +194,5 @@ Full table: <https://bykaranteli.com/developers#tiers>. For recorded history and
|
|
|
157
194
|
raw records beyond the live snapshots, bykaranteli.com also exposes pay-per-call
|
|
158
195
|
x402 endpoints for anonymous agents (USDC on Solana or Base, priced per call,
|
|
159
196
|
no account): <https://bykaranteli.com/developers#x402> · machine catalog:
|
|
160
|
-
<https://bykaranteli.com/api/x402>. Builder keys call those
|
|
161
|
-
|
|
162
|
-
allowance a key pays per call like an anonymous agent or moves up a plan.
|
|
197
|
+
<https://bykaranteli.com/api/x402>. Builder, Business and Scale keys call those
|
|
198
|
+
routes unpaid; each call counts as 10 requests of the monthly fair use.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const PROOF_URL = "https://bykaranteli.com/proof";
|
|
2
|
+
export type ProvenanceCoverage = "full" | "sampled" | "mixed";
|
|
3
|
+
export type Provenance = {
|
|
4
|
+
source_page: string;
|
|
5
|
+
api_path: string;
|
|
6
|
+
/** The answer's own timestamp (generatedAt, generated_at, as_of); null when the route does not stamp one. */
|
|
7
|
+
generated_at: string | null;
|
|
8
|
+
fetched_at: string;
|
|
9
|
+
venues?: string[];
|
|
10
|
+
/** full: every counted feed is a complete record; sampled: every one is sampled; mixed: both. */
|
|
11
|
+
coverage?: ProvenanceCoverage;
|
|
12
|
+
stale_venues?: string[];
|
|
13
|
+
recorded_since?: string;
|
|
14
|
+
proof: string;
|
|
15
|
+
};
|
|
16
|
+
/** The site page a route's numbers are shown on. */
|
|
17
|
+
export declare function pageForApiPath(path: string): string;
|
|
18
|
+
export declare function buildProvenance(data: unknown, at: {
|
|
19
|
+
apiPath: string;
|
|
20
|
+
sourcePage: string;
|
|
21
|
+
fetchedAt: string;
|
|
22
|
+
}): Provenance;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/* Provenance of one tool answer (0.31.0): where it came from, when, from which venues, whether the record behind it
|
|
2
|
+
* is complete or sampled, and where the proof lives. Read only from fields the API answer already carries
|
|
3
|
+
* (generatedAt or as_of, venues, sources, coverage kinds, stale feeds, recorded_since or history_since); a field the
|
|
4
|
+
* answer does not carry is left out, never filled in.
|
|
5
|
+
*
|
|
6
|
+
* The same code runs in the hosted bridge as web/src/lib/mcp-provenance.ts; the web test mcp-provenance.test.ts runs
|
|
7
|
+
* both copies on the same answers and fails when they drift. No imports, so both sides can load it as is. */
|
|
8
|
+
export const PROOF_URL = "https://bykaranteli.com/proof";
|
|
9
|
+
/* API stems whose public page has a different name (2026-09-04 audit: stripping /api/public gave nine tools a source
|
|
10
|
+
* that 404s on the site). */
|
|
11
|
+
const PAGE_FOR_STEM = {
|
|
12
|
+
"/borrow-rates": "/borrow",
|
|
13
|
+
"/oi": "/oi-leaderboard",
|
|
14
|
+
"/venues/markets": "/venues",
|
|
15
|
+
"/venues/lead-lag": "/venues",
|
|
16
|
+
"/venues/profile": "/venues",
|
|
17
|
+
"/venues/oi-history": "/venues",
|
|
18
|
+
"/options/surface": "/options",
|
|
19
|
+
"/context": "/methodology",
|
|
20
|
+
"/leverage-tiers": "/leverage",
|
|
21
|
+
"/datasets/etf-flows": "/etf",
|
|
22
|
+
"/datasets/liquidations-daily": "/liquidations",
|
|
23
|
+
"/jupiter": "/jupiter-perps",
|
|
24
|
+
"/market-profile": "/symbols",
|
|
25
|
+
"/liquidation-leaderboard": "/liquidations",
|
|
26
|
+
};
|
|
27
|
+
/** The site page a route's numbers are shown on. */
|
|
28
|
+
export function pageForApiPath(path) {
|
|
29
|
+
const clean = path.split("?")[0] || "/";
|
|
30
|
+
if (clean === "/api/v1/proof" || clean.startsWith("/api/v1/proof/"))
|
|
31
|
+
return "/proof";
|
|
32
|
+
if (clean === "/api/series" || clean.startsWith("/api/series/"))
|
|
33
|
+
return "/chart";
|
|
34
|
+
const stem = clean.replace(/^\/api\/(?:v1\/)?public/, "").replace(/\.(?:json|csv)$/, "") || "/";
|
|
35
|
+
return PAGE_FOR_STEM[stem] ?? stem;
|
|
36
|
+
}
|
|
37
|
+
const isRec = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
38
|
+
const str = (v) => (typeof v === "string" && v.trim() ? v : undefined);
|
|
39
|
+
/* Coverage kinds of the liquidation registry. "delayed" is a complete record that arrives late, so it reads as full;
|
|
40
|
+
* "shadow" and "none" are feeds we hold but do not count, so they are not a source of the number. */
|
|
41
|
+
const COUNTED_KINDS = new Set(["full", "sampled", "delayed"]);
|
|
42
|
+
const UNCOUNTED_KINDS = new Set(["shadow", "none"]);
|
|
43
|
+
const VENUE_TOKEN = /^[A-Za-z0-9][A-Za-z0-9 ._()-]{0,39}$/;
|
|
44
|
+
const MAX_VENUES = 100;
|
|
45
|
+
function venueId(item) {
|
|
46
|
+
if (typeof item === "string")
|
|
47
|
+
return VENUE_TOKEN.test(item) ? item : undefined;
|
|
48
|
+
if (!isRec(item))
|
|
49
|
+
return undefined;
|
|
50
|
+
const id = str(item.exchange) ?? str(item.venue_id) ?? str(item.id) ?? str(item.key) ?? str(item.venue);
|
|
51
|
+
return id && VENUE_TOKEN.test(id) ? id : undefined;
|
|
52
|
+
}
|
|
53
|
+
export function buildProvenance(data, at) {
|
|
54
|
+
const d = isRec(data) ? data : {};
|
|
55
|
+
const meta = isRec(d.meta) ? d.meta : {};
|
|
56
|
+
const coverageRec = isRec(d.coverage) ? d.coverage : {};
|
|
57
|
+
const estimate = isRec(d.market_estimate) ? d.market_estimate : {};
|
|
58
|
+
const venues = [];
|
|
59
|
+
const kinds = new Set();
|
|
60
|
+
const stale = [];
|
|
61
|
+
let staleKnown = false;
|
|
62
|
+
const addVenue = (id) => {
|
|
63
|
+
if (!venues.includes(id) && venues.length < MAX_VENUES)
|
|
64
|
+
venues.push(id);
|
|
65
|
+
};
|
|
66
|
+
const addStale = (id) => {
|
|
67
|
+
if (!stale.includes(id))
|
|
68
|
+
stale.push(id);
|
|
69
|
+
};
|
|
70
|
+
const addList = (list) => {
|
|
71
|
+
if (!Array.isArray(list))
|
|
72
|
+
return;
|
|
73
|
+
for (const item of list.slice(0, 1000)) {
|
|
74
|
+
const kind = isRec(item) ? str(item.kind) ?? str(item.coverage) : undefined;
|
|
75
|
+
if (kind && UNCOUNTED_KINDS.has(kind))
|
|
76
|
+
continue;
|
|
77
|
+
const id = venueId(item);
|
|
78
|
+
if (!id)
|
|
79
|
+
continue;
|
|
80
|
+
addVenue(id);
|
|
81
|
+
if (kind && COUNTED_KINDS.has(kind))
|
|
82
|
+
kinds.add(kind === "delayed" ? "full" : kind);
|
|
83
|
+
if (isRec(item) && typeof item.stale === "boolean") {
|
|
84
|
+
staleKnown = true;
|
|
85
|
+
if (item.stale)
|
|
86
|
+
addStale(id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const addStaleList = (list) => {
|
|
91
|
+
if (!Array.isArray(list))
|
|
92
|
+
return;
|
|
93
|
+
staleKnown = true;
|
|
94
|
+
for (const item of list) {
|
|
95
|
+
const id = venueId(item);
|
|
96
|
+
if (id)
|
|
97
|
+
addStale(id);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const single = str(d.venue);
|
|
101
|
+
if (single && VENUE_TOKEN.test(single))
|
|
102
|
+
addVenue(single);
|
|
103
|
+
addList(d.venues);
|
|
104
|
+
addList(d.by_exchange);
|
|
105
|
+
addList(d.coverage);
|
|
106
|
+
addList(coverageRec.counted);
|
|
107
|
+
addList(d.sources);
|
|
108
|
+
addList(meta.venues);
|
|
109
|
+
addStaleList(d.stale_venues);
|
|
110
|
+
addStaleList(coverageRec.stale);
|
|
111
|
+
addStaleList(estimate.stale_venues);
|
|
112
|
+
const coverageWord = str(d.coverage);
|
|
113
|
+
if (coverageWord && COUNTED_KINDS.has(coverageWord))
|
|
114
|
+
kinds.add(coverageWord === "delayed" ? "full" : coverageWord);
|
|
115
|
+
const since = str(d.recorded_since) ?? str(d.history_since) ?? str(meta.recorded_since) ?? str(meta.history_since);
|
|
116
|
+
return {
|
|
117
|
+
source_page: at.sourcePage,
|
|
118
|
+
api_path: at.apiPath,
|
|
119
|
+
generated_at: str(d.generatedAt) ?? str(d.generated_at) ?? str(d.as_of) ?? str(d.asOf) ?? str(meta.generatedAt) ?? str(meta.generated_at) ?? str(meta.as_of) ?? null,
|
|
120
|
+
fetched_at: at.fetchedAt,
|
|
121
|
+
...(venues.length > 0 ? { venues } : {}),
|
|
122
|
+
...(kinds.size > 0 ? { coverage: kinds.size > 1 ? "mixed" : kinds.values().next().value } : {}),
|
|
123
|
+
...(staleKnown ? { stale_venues: stale } : {}),
|
|
124
|
+
...(since ? { recorded_since: since } : {}),
|
|
125
|
+
proof: PROOF_URL,
|
|
126
|
+
};
|
|
127
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* instance, so all 20 tools have exactly one definition.
|
|
6
6
|
*/
|
|
7
7
|
import { z } from "zod/v4";
|
|
8
|
+
import { buildProvenance, pageForApiPath } from "./provenance.js";
|
|
8
9
|
export function registerByKaranteliTools(server, options) {
|
|
9
10
|
const BASE_URL = (options?.baseUrl ?? process.env.BYKARANTELI_BASE_URL ?? "https://bykaranteli.com").replace(/\/+$/, "");
|
|
10
11
|
const PUBLIC_URL = (options?.publicUrl ?? process.env.BYKARANTELI_PUBLIC_URL ?? "https://bykaranteli.com").replace(/\/+$/, "");
|
|
@@ -12,9 +13,11 @@ export function registerByKaranteliTools(server, options) {
|
|
|
12
13
|
const TIMEOUT_MS = 15_000;
|
|
13
14
|
const STATIC_KEY = (options?.apiKey ?? process.env.BYKARANTELI_API_KEY ?? "").trim();
|
|
14
15
|
const authorizationFor = () => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
/* A host that forwards its callers' keys (authorizationProvider) never falls back to a static key: on the
|
|
17
|
+
* hosted endpoint that fallback would have let every keyless caller act as whatever key the process held
|
|
18
|
+
* (security review 2026-09-30). The static key is for a local stdio server with one owner. */
|
|
19
|
+
if (options?.authorizationProvider)
|
|
20
|
+
return options.authorizationProvider() || undefined;
|
|
18
21
|
return STATIC_KEY ? `Bearer ${STATIC_KEY}` : undefined;
|
|
19
22
|
};
|
|
20
23
|
// Read-only GET tools over an open-world public API, all of them.
|
|
@@ -48,26 +51,14 @@ export function registerByKaranteliTools(server, options) {
|
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
/* Every answer carries where it came from and when (README promise; audit
|
|
51
|
-
* P3 #416): routes that already emit these keep their own values.
|
|
52
|
-
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"/venues/lead-lag": "/venues",
|
|
60
|
-
"/venues/profile": "/venues",
|
|
61
|
-
"/venues/oi-history": "/venues",
|
|
62
|
-
"/options/surface": "/options",
|
|
63
|
-
"/context": "/methodology",
|
|
64
|
-
"/leverage-tiers": "/leverage",
|
|
65
|
-
"/datasets/etf-flows": "/etf",
|
|
66
|
-
"/datasets/liquidations-daily": "/liquidations",
|
|
67
|
-
};
|
|
68
|
-
function pageForApiPath(path) {
|
|
69
|
-
const stem = path.replace(/^\/api\/(?:v1\/)?public/, "").split("?")[0] || "/";
|
|
70
|
-
return PAGE_FOR_STEM[stem] ?? stem;
|
|
54
|
+
* P3 #416): routes that already emit these keep their own values. Since
|
|
55
|
+
* 0.31.0 a provenance block rides along too (src/provenance.ts): source page,
|
|
56
|
+
* API path, the answer's own timestamp, fetch time, venues, full or sampled
|
|
57
|
+
* coverage, stale feeds, first recorded day and the proof page, each only
|
|
58
|
+
* when the answer carries it. The page map (stems whose page has another
|
|
59
|
+
* name) moved there as well. */
|
|
60
|
+
function provenanceFor(data, path, page) {
|
|
61
|
+
return buildProvenance(data, { apiPath: path, sourcePage: `${PUBLIC_URL}${page ?? pageForApiPath(path)}`, fetchedAt: new Date().toISOString() });
|
|
71
62
|
}
|
|
72
63
|
function withProvenance(data, path) {
|
|
73
64
|
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
@@ -77,6 +68,7 @@ export function registerByKaranteliTools(server, options) {
|
|
|
77
68
|
...record,
|
|
78
69
|
generatedAt: typeof record.generatedAt === "string" ? record.generatedAt : new Date().toISOString(),
|
|
79
70
|
source: typeof record.source === "string" ? record.source : `${PUBLIC_URL}${pageForApiPath(path)}`,
|
|
71
|
+
provenance: provenanceFor(record, path),
|
|
80
72
|
};
|
|
81
73
|
}
|
|
82
74
|
/* Compact JSON (pretty-printing cost ~35% of the budget) and a hard ceiling:
|
|
@@ -149,6 +141,7 @@ export function registerByKaranteliTools(server, options) {
|
|
|
149
141
|
}
|
|
150
142
|
: undefined,
|
|
151
143
|
source: `${PUBLIC_URL}/indices`,
|
|
144
|
+
provenance: provenanceFor(d, "/api/public/indices", "/indices"),
|
|
152
145
|
});
|
|
153
146
|
}
|
|
154
147
|
catch (err) {
|
|
@@ -241,6 +234,7 @@ export function registerByKaranteliTools(server, options) {
|
|
|
241
234
|
? `rows is capped at ${MAX_ROWS} of ${filtered.length} matching rows (largest first). Use summary.totals and summary.by_date for complete figures, or pass a symbol filter. ` + baseNote
|
|
242
235
|
: baseNote,
|
|
243
236
|
source: `${PUBLIC_URL}/liquidations`,
|
|
237
|
+
provenance: provenanceFor(d, "/api/v1/public/datasets/liquidations-daily.json", "/liquidations"),
|
|
244
238
|
});
|
|
245
239
|
}
|
|
246
240
|
catch (err) {
|
|
@@ -248,10 +242,10 @@ export function registerByKaranteliTools(server, options) {
|
|
|
248
242
|
}
|
|
249
243
|
});
|
|
250
244
|
server.registerTool("get_etf_flows", {
|
|
251
|
-
title: "US spot Bitcoin and
|
|
252
|
-
description: "Call this when the user asks about Bitcoin, Ethereum or
|
|
245
|
+
title: "US spot Bitcoin, Ethereum, Solana and XRP ETF daily flows",
|
|
246
|
+
description: "Call this when the user asks about Bitcoin, Ethereum, Solana or XRP spot ETF flows: daily net inflows or outflows, cumulative flow since launch, or total net assets of the US spot ETFs (IBIT, FBTC, ETHA, XRPC and the rest). Returns one row per finalized US trading day and asset with net inflow, total net assets, cumulative inflow since launch and value traded, all in USD. History by asset: BTC and ETH since 2025-05-20, SOL since 2025-10-28, XRP since 2026-09-08.",
|
|
253
247
|
inputSchema: {
|
|
254
|
-
asset: z.enum(["BTC", "ETH", "SOL"]).optional().describe("Filter to one asset (BTC, ETH
|
|
248
|
+
asset: z.enum(["BTC", "ETH", "SOL", "XRP"]).optional().describe("Filter to one asset (BTC, ETH, SOL or XRP). Omit for all."),
|
|
255
249
|
days: z.number().int().min(1).max(400).optional().describe("How many most recent trading days to return (default 10)."),
|
|
256
250
|
},
|
|
257
251
|
annotations: READ_ONLY,
|
|
@@ -279,6 +273,7 @@ export function registerByKaranteliTools(server, options) {
|
|
|
279
273
|
truncated: dates.length < new Set(filtered.map((r) => String(r.date))).size,
|
|
280
274
|
note: "Finalized US trading days only; a positive net_inflow_usd means the funds bought more of the asset than they sold that day. window_net_inflow_usd sums the returned window per asset.",
|
|
281
275
|
source: `${PUBLIC_URL}/etf`,
|
|
276
|
+
provenance: provenanceFor(d, "/api/v1/public/datasets/etf-flows.json", "/etf"),
|
|
282
277
|
});
|
|
283
278
|
}
|
|
284
279
|
catch (err) {
|
|
@@ -307,9 +302,9 @@ export function registerByKaranteliTools(server, options) {
|
|
|
307
302
|
note: `${want} is not in the heatmap set. Tracked symbols: ${d.rows.map((r) => r.symbol).join(", ")}`,
|
|
308
303
|
});
|
|
309
304
|
}
|
|
310
|
-
return ok({ generatedAt: d.generatedAt, row, source: `${PUBLIC_URL}/heatmap
|
|
305
|
+
return ok({ generatedAt: d.generatedAt, row, source: `${PUBLIC_URL}/heatmap`, provenance: provenanceFor(d, "/api/public/heatmap") });
|
|
311
306
|
}
|
|
312
|
-
return ok({ ...d, source: `${PUBLIC_URL}/heatmap
|
|
307
|
+
return ok({ ...d, source: `${PUBLIC_URL}/heatmap`, provenance: provenanceFor(d, "/api/public/heatmap") });
|
|
313
308
|
}
|
|
314
309
|
catch (err) {
|
|
315
310
|
return fail(err);
|
|
@@ -323,7 +318,7 @@ export function registerByKaranteliTools(server, options) {
|
|
|
323
318
|
}, async () => {
|
|
324
319
|
try {
|
|
325
320
|
const d = (await fetchJson("/api/public/funding-arb"));
|
|
326
|
-
return ok({ ...d, source: `${PUBLIC_URL}/funding-arb
|
|
321
|
+
return ok({ ...d, source: `${PUBLIC_URL}/funding-arb`, provenance: provenanceFor(d, "/api/public/funding-arb") });
|
|
327
322
|
}
|
|
328
323
|
catch (err) {
|
|
329
324
|
return fail(err);
|
|
@@ -369,13 +364,14 @@ export function registerByKaranteliTools(server, options) {
|
|
|
369
364
|
note: `${want} is not in the tracked pressure universe right now.`,
|
|
370
365
|
});
|
|
371
366
|
}
|
|
372
|
-
return ok({ generatedAt: d.generatedAt, item: slim(item), source: `${PUBLIC_URL}/pressure
|
|
367
|
+
return ok({ generatedAt: d.generatedAt, item: slim(item), source: `${PUBLIC_URL}/pressure`, provenance: provenanceFor(d, "/api/public/pressure") });
|
|
373
368
|
}
|
|
374
369
|
return ok({
|
|
375
370
|
generatedAt: d.generatedAt,
|
|
376
371
|
items: d.items.slice(0, limit ?? 20).map(slim),
|
|
377
372
|
totalTracked: d.items.length,
|
|
378
373
|
source: `${PUBLIC_URL}/pressure`,
|
|
374
|
+
provenance: provenanceFor(d, "/api/public/pressure"),
|
|
379
375
|
});
|
|
380
376
|
}
|
|
381
377
|
catch (err) {
|
|
@@ -390,7 +386,7 @@ export function registerByKaranteliTools(server, options) {
|
|
|
390
386
|
}, async () => {
|
|
391
387
|
try {
|
|
392
388
|
const d = (await fetchJson("/api/public/top-movers"));
|
|
393
|
-
return ok({ ...d, source: `${PUBLIC_URL}/top-movers
|
|
389
|
+
return ok({ ...d, source: `${PUBLIC_URL}/top-movers`, provenance: provenanceFor(d, "/api/public/top-movers") });
|
|
394
390
|
}
|
|
395
391
|
catch (err) {
|
|
396
392
|
return fail(err);
|
|
@@ -867,7 +863,7 @@ export function registerByKaranteliTools(server, options) {
|
|
|
867
863
|
server.registerTool("get_macro_liquidity", {
|
|
868
864
|
title: "Macro liquidity: Fed funds, 10y, balance sheet, RRP, stablecoin supply",
|
|
869
865
|
description: "Call this when the user asks about macro liquidity, the Fed balance sheet, reverse repo, rates or stablecoin supply in relation to crypto. Returns the recorded daily series and latest values.",
|
|
870
|
-
inputSchema: { days: z.number().int().min(
|
|
866
|
+
inputSchema: { days: z.number().int().min(30).max(4000).optional().describe("Window in days, 30-4000 (default 365); the route clamps to that range.") },
|
|
871
867
|
annotations: READ_ONLY,
|
|
872
868
|
}, async (args) => {
|
|
873
869
|
try {
|
|
@@ -881,7 +877,7 @@ export function registerByKaranteliTools(server, options) {
|
|
|
881
877
|
server.registerTool("get_network_health", {
|
|
882
878
|
title: "Bitcoin network health from our own node",
|
|
883
879
|
description: "Call this when the user asks about Bitcoin hashrate, difficulty or block fees (our node runs blocksonly, so there is no mempool series). Returns the recorded daily series and latest values measured on ByKaranteli's own node.",
|
|
884
|
-
inputSchema: { days: z.number().int().min(
|
|
880
|
+
inputSchema: { days: z.number().int().min(30).max(4000).optional().describe("Window in days, 30-4000 (default 365); the route clamps to that range.") },
|
|
885
881
|
annotations: READ_ONLY,
|
|
886
882
|
}, async (args) => {
|
|
887
883
|
try {
|
|
@@ -915,7 +911,8 @@ export function registerByKaranteliTools(server, options) {
|
|
|
915
911
|
const sym = raw.endsWith("USDT") ? raw : `${raw}USDT`;
|
|
916
912
|
const path = `/api/liqmap/public?symbol=${encodeURIComponent(sym)}&timeframe=${encodeURIComponent(timeframe ?? "24h")}`;
|
|
917
913
|
const data = await fetchJson(path);
|
|
918
|
-
|
|
914
|
+
const page = `/liqmap/${sym.replace(/USDT$/, "").toLowerCase()}`;
|
|
915
|
+
return ok({ ...data, source: `${PUBLIC_URL}${page}`, provenance: provenanceFor(data, path, page) });
|
|
919
916
|
}
|
|
920
917
|
catch (err) {
|
|
921
918
|
return fail(err);
|
|
@@ -1115,6 +1112,122 @@ export function registerByKaranteliTools(server, options) {
|
|
|
1115
1112
|
return fail(err);
|
|
1116
1113
|
}
|
|
1117
1114
|
});
|
|
1115
|
+
server.registerTool("get_market_profile", {
|
|
1116
|
+
title: "Market Profile: daily TPO profile of a perpetual, point of control, value area, initial balance, naked POCs",
|
|
1117
|
+
description: "Call this when the user asks about a perpetual's Market Profile, TPO profile, point of control (POC), value area (VAH, VAL), initial balance or naked (untested) points of control. Returns one row per closed UTC day from ByKaranteli's own one-minute bars of the Binance USDT-M perpetual (30-minute TPO periods, buckets of 0.05% of the day's open, 70% value area, first-hour initial balance, volume point of control), the naked points of control of the last 60 recorded days and the latest day's profile per price bucket. Symbols without recorded minute bars return no_profile.",
|
|
1118
|
+
inputSchema: {
|
|
1119
|
+
symbol: z.string().trim().toUpperCase().regex(/^[A-Z0-9]{2,20}$/).optional().describe("Ticker such as BTC or BTCUSDT (default BTC)"),
|
|
1120
|
+
days: z.number().int().min(1).max(30).optional().describe("Closed UTC days, newest first, 1..30 (default 7)"),
|
|
1121
|
+
},
|
|
1122
|
+
annotations: READ_ONLY,
|
|
1123
|
+
}, async ({ symbol, days }) => {
|
|
1124
|
+
try {
|
|
1125
|
+
const q = new URLSearchParams();
|
|
1126
|
+
q.set("symbol", symbol ?? "BTC");
|
|
1127
|
+
if (days)
|
|
1128
|
+
q.set("days", String(days));
|
|
1129
|
+
const path = `/api/public/market-profile?${q.toString()}`;
|
|
1130
|
+
return ok(withProvenance(await fetchJson(path), path));
|
|
1131
|
+
}
|
|
1132
|
+
catch (err) {
|
|
1133
|
+
return fail(err);
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
server.registerTool("get_options_chain", {
|
|
1137
|
+
title: "Options chain hour by hour: open interest and IV per expiry and strike, the change over 1h and 24h, the ATM IV path",
|
|
1138
|
+
description: "Call this when the user asks how the BTC or ETH option chain moved today or over the last day: open interest and mark IV per expiry and strike from ByKaranteli's own hourly capture of every listed venue, the change over the last hour and the last 24 hours, and the front expiry's ATM IV hour by hour. Anonymous depth lists the largest strikes; a key with member depth lists every strike. Recorded from 2026-09-30, so the first days carry a short history.",
|
|
1139
|
+
inputSchema: {
|
|
1140
|
+
currency: z.enum(["BTC", "ETH"]).optional().describe("BTC or ETH (default BTC)"),
|
|
1141
|
+
venue: z.enum(["all", "deribit", "bybit", "binance", "okx", "delta"]).optional().describe("One venue or all (default all)"),
|
|
1142
|
+
expiry: z.string().trim().toUpperCase().regex(/^\d{1,2}[A-Z]{3}\d{2}$/).optional().describe("Expiry as DDMONYY, e.g. 31OCT26 (default: every expiry)"),
|
|
1143
|
+
strikes: z.enum(["top", "all"]).optional().describe("top (largest strikes) or all (member depth)"),
|
|
1144
|
+
},
|
|
1145
|
+
annotations: READ_ONLY,
|
|
1146
|
+
}, async ({ currency, venue, expiry, strikes }) => {
|
|
1147
|
+
try {
|
|
1148
|
+
const q = new URLSearchParams();
|
|
1149
|
+
if (currency !== undefined)
|
|
1150
|
+
q.set("currency", String(currency));
|
|
1151
|
+
if (venue !== undefined)
|
|
1152
|
+
q.set("venue", String(venue));
|
|
1153
|
+
if (expiry !== undefined)
|
|
1154
|
+
q.set("expiry", String(expiry));
|
|
1155
|
+
if (strikes !== undefined)
|
|
1156
|
+
q.set("strikes", String(strikes));
|
|
1157
|
+
const qs = q.toString();
|
|
1158
|
+
const path = `/api/public/options/chain${qs ? `?${qs}` : ""}`;
|
|
1159
|
+
return ok(withProvenance(await fetchJson(path), path));
|
|
1160
|
+
}
|
|
1161
|
+
catch (err) {
|
|
1162
|
+
return fail(err);
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
1165
|
+
server.registerTool("get_hl_positions", {
|
|
1166
|
+
title: "Hyperliquid tracked positions: the liquidation price map of the largest accounts per coin, against the LiqMap model",
|
|
1167
|
+
description: "Call this when the user asks where Hyperliquid whales would be liquidated, how much tracked notional sits at each price, how the largest accounts lean on a coin, or how their liquidation prices compare with the LiqMap model. The universe is the largest accounts by equity on Hyperliquid's public leaderboard, scanned every five minutes; the levels are their own liquidation prices bucketed around the mark. hours returns the hourly archive of level totals.",
|
|
1168
|
+
inputSchema: {
|
|
1169
|
+
coin: z.string().trim().toUpperCase().regex(/^[A-Z0-9]{1,24}$/).optional().describe("Coin as Hyperliquid names it, e.g. BTC, ETH, SOL (default BTC)"),
|
|
1170
|
+
hours: z.number().int().min(1).max(720).optional().describe("Hours of archive to return, 1..720 (default 24)"),
|
|
1171
|
+
},
|
|
1172
|
+
annotations: READ_ONLY,
|
|
1173
|
+
}, async ({ coin, hours }) => {
|
|
1174
|
+
try {
|
|
1175
|
+
const q = new URLSearchParams();
|
|
1176
|
+
if (coin !== undefined)
|
|
1177
|
+
q.set("coin", String(coin));
|
|
1178
|
+
if (hours !== undefined)
|
|
1179
|
+
q.set("hours", String(hours));
|
|
1180
|
+
const qs = q.toString();
|
|
1181
|
+
const path = `/api/public/hyperliquid-positions${qs ? `?${qs}` : ""}`;
|
|
1182
|
+
return ok(withProvenance(await fetchJson(path), path));
|
|
1183
|
+
}
|
|
1184
|
+
catch (err) {
|
|
1185
|
+
return fail(err);
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
server.registerTool("get_venue_share", {
|
|
1189
|
+
title: "Venue share: each counted exchange's share of the recorded liquidations and of perpetual open interest",
|
|
1190
|
+
description: "Call this when the user asks which exchange sees the most liquidations, how that share moved this week or this month, or how perpetual open interest splits between venues. Shares over one, seven or thirty days from ByKaranteli's own hourly liquidation record (counted venues only, each with the day its record started) and the hourly open interest record.",
|
|
1191
|
+
inputSchema: {
|
|
1192
|
+
days: z.number().int().optional().describe("Window in days: 1, 7 or 30 (default 7)"),
|
|
1193
|
+
},
|
|
1194
|
+
annotations: READ_ONLY,
|
|
1195
|
+
}, async ({ days }) => {
|
|
1196
|
+
try {
|
|
1197
|
+
const q = new URLSearchParams();
|
|
1198
|
+
if (days !== undefined)
|
|
1199
|
+
q.set("days", String(days));
|
|
1200
|
+
const qs = q.toString();
|
|
1201
|
+
const path = `/api/public/venue-share${qs ? `?${qs}` : ""}`;
|
|
1202
|
+
return ok(withProvenance(await fetchJson(path), path));
|
|
1203
|
+
}
|
|
1204
|
+
catch (err) {
|
|
1205
|
+
return fail(err);
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
server.registerTool("get_tradfi_gaps", {
|
|
1209
|
+
title: "Weekend and overnight gaps: stock, index and commodity perpetuals against the cash close, checkpoint by checkpoint",
|
|
1210
|
+
description: "Call this when the user asks what a stock, index or commodity perpetual did over the weekend or overnight while the cash market was closed, how far it sat from the last cash close at each checkpoint, how the venues disagreed, and what gap the next open then realised. From ByKaranteli's own ten-minute venue record; window weekend or night; one symbol or the whole board.",
|
|
1211
|
+
inputSchema: {
|
|
1212
|
+
window: z.enum(["weekend", "night"]).optional().describe("weekend (Friday close to Monday open) or night (cash close to next open); default weekend"),
|
|
1213
|
+
symbol: z.string().trim().toUpperCase().regex(/^[A-Z0-9.]{1,24}$/).optional().describe("One underlying, e.g. TSLA or XAU (default: the whole board)"),
|
|
1214
|
+
},
|
|
1215
|
+
annotations: READ_ONLY,
|
|
1216
|
+
}, async ({ window, symbol }) => {
|
|
1217
|
+
try {
|
|
1218
|
+
const q = new URLSearchParams();
|
|
1219
|
+
if (window !== undefined)
|
|
1220
|
+
q.set("window", String(window));
|
|
1221
|
+
if (symbol !== undefined)
|
|
1222
|
+
q.set("symbol", String(symbol));
|
|
1223
|
+
const qs = q.toString();
|
|
1224
|
+
const path = `/api/public/tradfi-gaps${qs ? `?${qs}` : ""}`;
|
|
1225
|
+
return ok(withProvenance(await fetchJson(path), path));
|
|
1226
|
+
}
|
|
1227
|
+
catch (err) {
|
|
1228
|
+
return fail(err);
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1118
1231
|
server.registerTool("get_solana_perps", {
|
|
1119
1232
|
title: "Solana Perps board: open interest, 24h volume and hourly rates across six Solana perpetual venues (Jupiter, Pacifica, Phoenix, GM Trade, Velocity, Bullet)",
|
|
1120
1233
|
description: "Call this when the user asks about perpetuals on Solana as a whole, which Solana perp DEX has the most open interest or volume, a market on Pacifica, Phoenix, GM Trade (GMX on Solana), Velocity (the Drift relaunch) or Bullet (funding, open interest, 24h volume, mark), or how Jupiter compares with the order-book venues. Returns the board read every 10 minutes: per-venue totals (one-sided open interest, both sides on pool venues, 24h volume, market count, median hourly rate, as_of), every market of every venue largest first with instrument type (perpetual, equity, index, commodity, fx), and optional hourly history of one market on any venue but Jupiter (7 days free, 30 with member depth; Jupiter history is get_jupiter_perps).",
|
|
@@ -1180,4 +1293,360 @@ export function registerByKaranteliTools(server, options) {
|
|
|
1180
1293
|
return fail(err);
|
|
1181
1294
|
}
|
|
1182
1295
|
});
|
|
1296
|
+
/* API contract values the account and series tools use (0.31.0). They mirror the web registries (series metrics,
|
|
1297
|
+
* recipe fields, operators, channels and limits); the web test mcp-extra-tools.test.ts fails when they drift, and
|
|
1298
|
+
* GET /api/series/metrics lists the metrics with their units and floors at runtime. */
|
|
1299
|
+
const SERIES_METRIC_KEYS = ["price", "volume", "cvd_perp", "cvd_spot", "oi", "funding", "liquidations", "long_short", "top_traders", "rsi", "premium", "etf_flow", "borrow", "whale_net"];
|
|
1300
|
+
const SERIES_METRICS = Object.fromEntries(SERIES_METRIC_KEYS.map((k) => [k, true]));
|
|
1301
|
+
const SERIES_PERIODS = ["5m", "15m", "1h", "4h", "1d"];
|
|
1302
|
+
const PERIOD_MS = { "5m": 300_000, "15m": 900_000, "1h": 3_600_000, "4h": 14_400_000, "1d": 86_400_000 };
|
|
1303
|
+
const SERIES_MEMBER_BAR_LIMIT = 5000;
|
|
1304
|
+
const RECIPE_FIELD_KEYS = ["mark_price", "pressure_score", "funding_rate_pct", "oi24h_pct", "basis_pct", "price_change_24h_pct", "liq_cluster_distance_pct", "usdt_peg_min_usd", "usdc_peg_min_usd", "liq_1h_usd", "rsi_4h", "ls_ratio_global", "book_imbalance_2pct_pct", "withdrawal_paused_venues", "max_leverage_min", "borrow_apr_pct", "hl_whale_long_share_pct"];
|
|
1305
|
+
const RECIPE_OPS = [">=", ">", "<=", "<", "=="];
|
|
1306
|
+
const RECIPE_CHANNELS = ["telegram", "email", "push", "webhook"];
|
|
1307
|
+
const RECIPE_LIMITS = { MAX_CONDITIONS: 6, MAX_SYMBOLS: 20, NAME_MAX: 64 };
|
|
1308
|
+
const ALERT_TEXT_MAX_CHARS = 500;
|
|
1309
|
+
/* GET a market route with provenance, and extra fields the tool adds (get_series' requested window). */
|
|
1310
|
+
async function tool(path, extra) {
|
|
1311
|
+
try {
|
|
1312
|
+
const data = withProvenance(await fetchJson(path), path);
|
|
1313
|
+
return ok(extra && data && typeof data === "object" && !Array.isArray(data) ? { ...data, ...extra } : data);
|
|
1314
|
+
}
|
|
1315
|
+
catch (err) {
|
|
1316
|
+
return fail(err);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
const ACCOUNT_READ = { readOnlyHint: true, openWorldHint: false };
|
|
1320
|
+
const ACCOUNT_WRITE = { readOnlyHint: false, openWorldHint: false, destructiveHint: false, idempotentHint: false };
|
|
1321
|
+
const ACCOUNT_WRITE_IDEMPOTENT = { readOnlyHint: false, openWorldHint: false, destructiveHint: false, idempotentHint: true };
|
|
1322
|
+
const ACCOUNT_DELETE = { readOnlyHint: false, openWorldHint: false, destructiveHint: true, idempotentHint: true };
|
|
1323
|
+
const errorResult = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
1324
|
+
const okResult = (data) => ({ content: [{ type: "text", text: JSON.stringify(data) }] });
|
|
1325
|
+
const KEY_NEEDED = "This tool reads or changes your own account, so it needs your account key (x-api-key: bk_... or Authorization: Bearer bk_...). Free key: https://bykaranteli.com/dashboard/api";
|
|
1326
|
+
/* One call to a member route with the caller's key. The route's own error (limit reached, unknown id, rate) is passed
|
|
1327
|
+
* through as the tool error so the model can say what happened. */
|
|
1328
|
+
async function account(method, path, body) {
|
|
1329
|
+
const authorization = authorizationFor();
|
|
1330
|
+
if (!authorization)
|
|
1331
|
+
return { ok: false, result: errorResult(KEY_NEEDED) };
|
|
1332
|
+
try {
|
|
1333
|
+
const res = await fetch(`${BASE_URL}${path}`, {
|
|
1334
|
+
method,
|
|
1335
|
+
headers: {
|
|
1336
|
+
accept: "application/json",
|
|
1337
|
+
"user-agent": USER_AGENT,
|
|
1338
|
+
authorization,
|
|
1339
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
1340
|
+
},
|
|
1341
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1342
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
1343
|
+
});
|
|
1344
|
+
const text = await res.text();
|
|
1345
|
+
let data = {};
|
|
1346
|
+
try {
|
|
1347
|
+
const parsed = JSON.parse(text);
|
|
1348
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
1349
|
+
data = parsed;
|
|
1350
|
+
}
|
|
1351
|
+
catch {
|
|
1352
|
+
// non-JSON body: the status line below still says what happened
|
|
1353
|
+
}
|
|
1354
|
+
if (!res.ok) {
|
|
1355
|
+
const reason = typeof data.message === "string" ? data.message : typeof data.error === "string" ? data.error : text.replace(/\s+/g, " ").slice(0, 300);
|
|
1356
|
+
return { ok: false, result: errorResult(`${method} ${path.split("?")[0]} returned HTTP ${res.status}: ${reason}`) };
|
|
1357
|
+
}
|
|
1358
|
+
return { ok: true, data };
|
|
1359
|
+
}
|
|
1360
|
+
catch (err) {
|
|
1361
|
+
return { ok: false, result: errorResult(`Failed: ${err instanceof Error ? err.message : String(err)}`) };
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1365
|
+
function seriesSymbol(raw) {
|
|
1366
|
+
const s = (typeof raw === "string" && raw.trim() ? raw : "BTCUSDT").trim().toUpperCase().replace(/[\s/_-]+/g, "");
|
|
1367
|
+
const expanded = s.endsWith("USDT") ? s : `${s}USDT`;
|
|
1368
|
+
return /^[A-Z0-9]{5,20}$/.test(expanded) ? expanded : null;
|
|
1369
|
+
}
|
|
1370
|
+
/* The list a symbol goes to: the one named, else "default", else the only list the account has. */
|
|
1371
|
+
async function resolveWatchlist(watchlistId) {
|
|
1372
|
+
const lists = await account("GET", "/api/member/watchlists");
|
|
1373
|
+
if (!lists.ok)
|
|
1374
|
+
return lists;
|
|
1375
|
+
const rows = (Array.isArray(lists.data.watchlists) ? lists.data.watchlists : []);
|
|
1376
|
+
if (typeof watchlistId === "string" && watchlistId.trim()) {
|
|
1377
|
+
const hit = rows.find((w) => w.id === watchlistId.trim());
|
|
1378
|
+
return hit ? { ok: true, list: hit } : { ok: false, result: errorResult(`No watchlist with id ${watchlistId.trim()} on this account. Lists: ${rows.map((w) => `${w.name} (${w.id})`).join(", ") || "none"}.`) };
|
|
1379
|
+
}
|
|
1380
|
+
const pick = rows.find((w) => w.name === "default") ?? (rows.length === 1 ? rows[0] : undefined);
|
|
1381
|
+
if (pick)
|
|
1382
|
+
return { ok: true, list: pick };
|
|
1383
|
+
return {
|
|
1384
|
+
ok: false,
|
|
1385
|
+
result: errorResult(rows.length === 0
|
|
1386
|
+
? "This account has no watchlist yet. Open https://bykaranteli.com/dashboard/watchlist once to create the default list, then call again."
|
|
1387
|
+
: `Pass watchlist_id: this account has ${rows.length} lists: ${rows.map((w) => `${w.name} (${w.id})`).join(", ")}.`),
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
/* ---- 0.31.0: recorded series, alert text, alert recipes, watchlists ---- */
|
|
1391
|
+
server.registerTool("get_series", {
|
|
1392
|
+
title: "Recorded series: bars or points of one metric for one perpetual (price, OI, funding, CVD, liquidations, long/short, RSI and more)",
|
|
1393
|
+
description: `Call this when the user wants the history of one recorded metric for one perpetual as a time series: price candles, volume, perp or spot CVD, open interest, funding, liquidations, long/short ratios, RSI, the Coinbase premium, US spot ETF flows, borrow rates or Hyperliquid whale net flow, for charting, backtesting or "what did X do over the last N days". Returns the /api/series answer (points as [t, v], or [t, o, h, l, c, v] for price, with unit, kind, source and source_kind, the bars served and whether member depth applied) plus provenance. metric is one of: ${SERIES_METRIC_KEYS.join(", ")} (unit, finest period and venue support of each: https://bykaranteli.com/api/series/metrics). Public depth serves fewer bars and no 5m bars; a Builder key and above get member depth; the bar limits are in the same list. Pass from and to (ISO) for a window, or limit for the newest bars.`,
|
|
1394
|
+
inputSchema: {
|
|
1395
|
+
metric: z.string().describe("string, metric key, e.g. price, oi, funding, liquidations (list: /api/series/metrics)"),
|
|
1396
|
+
symbol: z.string().optional().describe("string, optional Binance USDT-M perp or coin, e.g. BTCUSDT or BTC (default BTCUSDT)"),
|
|
1397
|
+
period: z.enum(SERIES_PERIODS).optional().describe(`string, optional bar period: ${SERIES_PERIODS.join(" | ")} (default 1h; each metric has a finest period)`),
|
|
1398
|
+
venue: z.string().optional().describe("string, optional venue id for price, oi, funding or borrow, e.g. okx (default binance)"),
|
|
1399
|
+
from: z.string().optional().describe("string, optional ISO start, e.g. 2026-09-01T00:00:00Z"),
|
|
1400
|
+
to: z.string().optional().describe("string, optional ISO end (default now)"),
|
|
1401
|
+
limit: z.number().optional().describe(`number, optional newest bars, 10..${SERIES_MEMBER_BAR_LIMIT}; the key's depth caps it`),
|
|
1402
|
+
},
|
|
1403
|
+
annotations: READ_ONLY,
|
|
1404
|
+
}, async (args = {}) => {
|
|
1405
|
+
const metric = typeof args.metric === "string" ? args.metric.trim().toLowerCase() : "";
|
|
1406
|
+
if (!Object.hasOwn(SERIES_METRICS, metric))
|
|
1407
|
+
return errorResult(`Invalid input: metric must be one of ${SERIES_METRIC_KEYS.join(", ")}.`);
|
|
1408
|
+
const symbol = seriesSymbol(args.symbol);
|
|
1409
|
+
if (!symbol)
|
|
1410
|
+
return errorResult("Invalid input: symbol looks like BTCUSDT or BTC.");
|
|
1411
|
+
const period = (typeof args.period === "string" && SERIES_PERIODS.includes(args.period) ? args.period : "1h");
|
|
1412
|
+
const to = typeof args.to === "string" && args.to.trim() ? new Date(args.to) : null;
|
|
1413
|
+
const from = typeof args.from === "string" && args.from.trim() ? new Date(args.from) : null;
|
|
1414
|
+
if ((to && Number.isNaN(to.getTime())) || (from && Number.isNaN(from.getTime())))
|
|
1415
|
+
return errorResult("Invalid input: from and to are ISO dates, e.g. 2026-09-01T00:00:00Z.");
|
|
1416
|
+
const end = to ?? new Date();
|
|
1417
|
+
if (from && from.getTime() >= end.getTime())
|
|
1418
|
+
return errorResult("Invalid input: from must be before to.");
|
|
1419
|
+
let limit = typeof args.limit === "number" && Number.isFinite(args.limit) && args.limit > 0 ? Math.floor(args.limit) : undefined;
|
|
1420
|
+
const bars = from ? Math.ceil((end.getTime() - from.getTime()) / PERIOD_MS[period]) : undefined;
|
|
1421
|
+
if (bars !== undefined)
|
|
1422
|
+
limit = Math.min(limit ?? bars, bars);
|
|
1423
|
+
if (limit !== undefined)
|
|
1424
|
+
limit = Math.max(10, Math.min(SERIES_MEMBER_BAR_LIMIT, limit));
|
|
1425
|
+
const q = new URLSearchParams({ metric, symbol, period });
|
|
1426
|
+
if (limit !== undefined)
|
|
1427
|
+
q.set("limit", String(limit));
|
|
1428
|
+
if (to)
|
|
1429
|
+
q.set("to", to.toISOString());
|
|
1430
|
+
if (typeof args.venue === "string" && /^[a-z0-9]{2,20}$/i.test(args.venue.trim()))
|
|
1431
|
+
q.set("venue", args.venue.trim().toLowerCase());
|
|
1432
|
+
/* The route answers the newest `limit` bars up to `to`; `requested` lets the caller compare with the bars served. */
|
|
1433
|
+
return tool(`/api/series?${q.toString()}`, from ? { requested: { from: from.toISOString(), to: end.toISOString(), bars } } : undefined);
|
|
1434
|
+
});
|
|
1435
|
+
server.registerTool("parse_alert_text", {
|
|
1436
|
+
title: "Alert text to an alert recipe: turn a sentence like \"BTC funding above 0.05%\" into the recipe it describes",
|
|
1437
|
+
description: `Call this when the user describes an alert in words ("tell me when ETH drops 5% in a day", "BTC funding above 5 bps", "liquidations over $20M in an hour") and you want the exact recipe before creating it. Rule based, nothing is saved: returns recipe (name, scope, symbols, conditions of field, op, value, cooldownHours, channels) or null, confidence 0..1, a one-line summary to confirm with the user, and unresolved (what the text left open or what was assumed; timing words are not part of a recipe). A threshold the text does not state is never invented. English and tickers; at most ${ALERT_TEXT_MAX_CHARS} characters. Pass the returned conditions, scope, symbols and channels to create_alert_recipe once the user agrees.`,
|
|
1438
|
+
inputSchema: {
|
|
1439
|
+
text: z.string().describe("string, the alert in plain English, e.g. \"SOL funding below -0.01% or OI up 10%\""),
|
|
1440
|
+
},
|
|
1441
|
+
annotations: ACCOUNT_READ,
|
|
1442
|
+
}, async (args = {}) => {
|
|
1443
|
+
const text = typeof args.text === "string" ? args.text.trim() : "";
|
|
1444
|
+
if (!text)
|
|
1445
|
+
return errorResult("Invalid input: text is required, e.g. \"BTC funding above 0.05%\".");
|
|
1446
|
+
if (text.length > ALERT_TEXT_MAX_CHARS)
|
|
1447
|
+
return errorResult(`Invalid input: at most ${ALERT_TEXT_MAX_CHARS} characters.`);
|
|
1448
|
+
const r = await account("POST", "/api/member/alert-recipes/parse", { text });
|
|
1449
|
+
return r.ok ? okResult(r.data) : r.result;
|
|
1450
|
+
});
|
|
1451
|
+
server.registerTool("list_alert_recipes", {
|
|
1452
|
+
title: "Your alert recipes: every alert on your account with its conditions, scope, channels and when it last fired",
|
|
1453
|
+
description: "Call this when the user asks which alerts they have, whether an alert fired, or before deleting or changing one. Returns the account's alert recipes (id, name, scope, symbols, conditions, cooldown in hours, enabled, last fired, fire count, channels). Needs the account key; reads nothing but this account.",
|
|
1454
|
+
inputSchema: {},
|
|
1455
|
+
annotations: ACCOUNT_READ,
|
|
1456
|
+
}, async () => {
|
|
1457
|
+
const r = await account("GET", "/api/member/alert-recipes");
|
|
1458
|
+
return r.ok ? okResult({ ...r.data, page: "https://bykaranteli.com/dashboard/alerts" }) : r.result;
|
|
1459
|
+
});
|
|
1460
|
+
server.registerTool("create_alert_recipe", {
|
|
1461
|
+
title: "Create an alert recipe (writes to your account)",
|
|
1462
|
+
description: `Call this when the user asks to be alerted when a metric crosses a threshold and has agreed to the exact condition (parse_alert_text turns their words into one). Saves an alert recipe on the account: it fires when every condition holds, delivers by Telegram first with email as fallback unless channels says otherwise, and waits cooldown_hours before it fires again for the same symbol. field is one of: ${RECIPE_FIELD_KEYS.join(", ")}; op is one of ${RECIPE_OPS.join(" ")}; percent fields take percent values (0.05 means 0.05%). Pass field, op and threshold for one condition, or conditions for up to ${RECIPE_LIMITS.MAX_CONDITIONS}. scope: watchlist (default), any, or symbols with symbols. The plan sets how many recipes an account keeps; past it the tool answers with the limit. Writes are rate limited per key and each one is recorded on the account.`,
|
|
1463
|
+
inputSchema: {
|
|
1464
|
+
field: z.string().optional().describe("string, one condition's metric, e.g. funding_rate_pct"),
|
|
1465
|
+
op: z.enum(RECIPE_OPS).optional().describe(`string, one condition's comparison: ${RECIPE_OPS.join(" | ")}`),
|
|
1466
|
+
threshold: z.number().optional().describe("number, one condition's threshold, e.g. 0.05"),
|
|
1467
|
+
conditions: z.array(z.object({ field: z.string(), op: z.enum(RECIPE_OPS), value: z.number() })).optional().describe(`array, optional up to ${RECIPE_LIMITS.MAX_CONDITIONS} conditions {field, op, value}, all must hold (instead of field, op, threshold)`),
|
|
1468
|
+
scope: z.enum(["watchlist", "any", "symbols"]).optional().describe("string, optional: watchlist (default), any, symbols"),
|
|
1469
|
+
symbols: z.array(z.string()).optional().describe(`array, optional up to ${RECIPE_LIMITS.MAX_SYMBOLS} symbols, e.g. [\"BTCUSDT\", \"ETH\"]; implies scope symbols`),
|
|
1470
|
+
channels: z.array(z.enum(RECIPE_CHANNELS)).optional().describe(`array, optional delivery channels: ${RECIPE_CHANNELS.join(" | ")}; empty means Telegram first, email as fallback`),
|
|
1471
|
+
cooldown_hours: z.number().optional().describe("number, optional 1..168 hours before it fires again (default 24)"),
|
|
1472
|
+
name: z.string().optional().describe("string, optional name; default describes the condition"),
|
|
1473
|
+
},
|
|
1474
|
+
annotations: ACCOUNT_WRITE,
|
|
1475
|
+
}, async (args = {}) => {
|
|
1476
|
+
const rawConds = Array.isArray(args.conditions) && args.conditions.length > 0
|
|
1477
|
+
? args.conditions
|
|
1478
|
+
: args.field !== undefined || args.threshold !== undefined
|
|
1479
|
+
? [{ field: args.field, op: args.op, value: args.threshold }]
|
|
1480
|
+
: [];
|
|
1481
|
+
if (rawConds.length === 0)
|
|
1482
|
+
return errorResult("Invalid input: pass field, op and threshold, or conditions.");
|
|
1483
|
+
if (rawConds.length > RECIPE_LIMITS.MAX_CONDITIONS)
|
|
1484
|
+
return errorResult(`Invalid input: at most ${RECIPE_LIMITS.MAX_CONDITIONS} conditions.`);
|
|
1485
|
+
const conditions = [];
|
|
1486
|
+
for (const c of rawConds) {
|
|
1487
|
+
const field = typeof c?.field === "string" ? c.field.trim() : "";
|
|
1488
|
+
const op = typeof c?.op === "string" ? c.op.trim() : "";
|
|
1489
|
+
const value = typeof c?.value === "number" ? c.value : Number.NaN;
|
|
1490
|
+
if (!RECIPE_FIELD_KEYS.includes(field))
|
|
1491
|
+
return errorResult(`Invalid input: field "${field}" is not one of ${RECIPE_FIELD_KEYS.join(", ")}.`);
|
|
1492
|
+
if (!RECIPE_OPS.includes(op))
|
|
1493
|
+
return errorResult(`Invalid input: op must be one of ${RECIPE_OPS.join(" ")}.`);
|
|
1494
|
+
if (!Number.isFinite(value))
|
|
1495
|
+
return errorResult(`Invalid input: the threshold of ${field} must be a number.`);
|
|
1496
|
+
conditions.push({ field, op, value });
|
|
1497
|
+
}
|
|
1498
|
+
const symbolsIn = Array.isArray(args.symbols) ? args.symbols : [];
|
|
1499
|
+
if (symbolsIn.length > RECIPE_LIMITS.MAX_SYMBOLS)
|
|
1500
|
+
return errorResult(`Invalid input: at most ${RECIPE_LIMITS.MAX_SYMBOLS} symbols.`);
|
|
1501
|
+
const symbols = [];
|
|
1502
|
+
for (const raw of symbolsIn) {
|
|
1503
|
+
const sym = seriesSymbol(raw);
|
|
1504
|
+
if (!sym || typeof raw !== "string")
|
|
1505
|
+
return errorResult(`Invalid input: "${String(raw)}" is not a symbol like BTCUSDT or BTC.`);
|
|
1506
|
+
if (!symbols.includes(sym))
|
|
1507
|
+
symbols.push(sym);
|
|
1508
|
+
}
|
|
1509
|
+
const scope = args.scope === "any" || args.scope === "watchlist" || args.scope === "symbols" ? args.scope : symbols.length > 0 ? "symbols" : "watchlist";
|
|
1510
|
+
if (scope === "symbols" && symbols.length === 0)
|
|
1511
|
+
return errorResult("Invalid input: scope symbols needs symbols.");
|
|
1512
|
+
const channels = Array.isArray(args.channels) ? args.channels.filter((c) => typeof c === "string") : [];
|
|
1513
|
+
if (channels.some((c) => !RECIPE_CHANNELS.includes(c)))
|
|
1514
|
+
return errorResult(`Invalid input: channels are ${RECIPE_CHANNELS.join(", ")}.`);
|
|
1515
|
+
const cooldownHours = typeof args.cooldown_hours === "number" && Number.isFinite(args.cooldown_hours) ? Math.max(1, Math.min(168, Math.round(args.cooldown_hours))) : 24;
|
|
1516
|
+
const fallbackName = `${scope === "symbols" ? `${symbols.slice(0, 3).map((x) => x.replace(/USDT$/, "")).join(", ")} · ` : ""}${conditions.map((c) => `${c.field} ${c.op} ${c.value}`).join(" and ")}`;
|
|
1517
|
+
const name = (typeof args.name === "string" && args.name.trim() ? args.name.trim() : fallbackName).slice(0, RECIPE_LIMITS.NAME_MAX);
|
|
1518
|
+
const body = { name, scope, symbols: scope === "symbols" ? symbols : [], conditions, cooldownHours, channels: [...new Set(channels)], enabled: true };
|
|
1519
|
+
const r = await account("POST", "/api/member/alert-recipes", body);
|
|
1520
|
+
return r.ok ? okResult({ ok: true, id: r.data.id, recipe: body, page: "https://bykaranteli.com/dashboard/alerts" }) : r.result;
|
|
1521
|
+
});
|
|
1522
|
+
server.registerTool("delete_alert_recipe", {
|
|
1523
|
+
title: "Delete one of your alert recipes (writes to your account)",
|
|
1524
|
+
description: "Call this when the user asks to remove an alert. Deletes the alert recipe with this id from the account (list_alert_recipes shows the ids); an id that is not one of the account's recipes changes nothing and says so. Writes are rate limited per key and each one is recorded on the account.",
|
|
1525
|
+
inputSchema: {
|
|
1526
|
+
id: z.string().describe("string, the recipe id from list_alert_recipes"),
|
|
1527
|
+
},
|
|
1528
|
+
annotations: ACCOUNT_DELETE,
|
|
1529
|
+
}, async (args = {}) => {
|
|
1530
|
+
const id = typeof args.id === "string" ? args.id.trim() : "";
|
|
1531
|
+
if (!UUID_RE.test(id))
|
|
1532
|
+
return errorResult("Invalid input: id is a recipe id from list_alert_recipes.");
|
|
1533
|
+
const r = await account("DELETE", `/api/member/alert-recipes/${id}`);
|
|
1534
|
+
if (!r.ok)
|
|
1535
|
+
return r.result;
|
|
1536
|
+
if (r.data.deleted === 0)
|
|
1537
|
+
return errorResult(`No alert recipe with id ${id} on this account; list_alert_recipes shows the ids.`);
|
|
1538
|
+
return okResult({ ok: true, id, deleted: true });
|
|
1539
|
+
});
|
|
1540
|
+
server.registerTool("list_watchlists", {
|
|
1541
|
+
title: "Your watchlists and the symbols on each",
|
|
1542
|
+
description: "Call this when the user asks what is on their watchlist, which lists they have, or before adding or removing a symbol. Returns the account's watchlists (id, name, symbol count) and, unless include_symbols is false, the symbols on each list in order. Needs the account key; reads nothing but this account.",
|
|
1543
|
+
inputSchema: {
|
|
1544
|
+
include_symbols: z.boolean().optional().describe("boolean, optional: include each list's symbols (default true)"),
|
|
1545
|
+
},
|
|
1546
|
+
annotations: ACCOUNT_READ,
|
|
1547
|
+
}, async (args = {}) => {
|
|
1548
|
+
const lists = await account("GET", "/api/member/watchlists");
|
|
1549
|
+
if (!lists.ok)
|
|
1550
|
+
return lists.result;
|
|
1551
|
+
const rows = (Array.isArray(lists.data.watchlists) ? lists.data.watchlists : []);
|
|
1552
|
+
if (args.include_symbols === false)
|
|
1553
|
+
return okResult({ watchlists: rows, page: "https://bykaranteli.com/dashboard/watchlist" });
|
|
1554
|
+
const out = [];
|
|
1555
|
+
for (const w of rows.slice(0, 20)) {
|
|
1556
|
+
const items = await account("GET", `/api/member/watchlists/${encodeURIComponent(w.id)}/items`);
|
|
1557
|
+
if (!items.ok)
|
|
1558
|
+
return items.result;
|
|
1559
|
+
out.push({ ...w, symbols: Array.isArray(items.data.symbols) ? items.data.symbols : [] });
|
|
1560
|
+
}
|
|
1561
|
+
return okResult({ watchlists: out, page: "https://bykaranteli.com/dashboard/watchlist" });
|
|
1562
|
+
});
|
|
1563
|
+
server.registerTool("add_watchlist_symbol", {
|
|
1564
|
+
title: "Add a symbol to your watchlist (writes to your account)",
|
|
1565
|
+
description: "Call this when the user asks to watch, follow or add a coin to their watchlist. Adds the symbol to the list named by watchlist_id, else to the default list (or the only list the account has); a symbol already on the list stays once. Returns the list's symbols after the change. Writes are rate limited per key and each one is recorded on the account.",
|
|
1566
|
+
inputSchema: {
|
|
1567
|
+
symbol: z.string().describe("string, a Binance USDT-M perp or coin, e.g. SOLUSDT or SOL"),
|
|
1568
|
+
watchlist_id: z.string().optional().describe("string, optional list id from list_watchlists (default: the default list)"),
|
|
1569
|
+
},
|
|
1570
|
+
annotations: ACCOUNT_WRITE_IDEMPOTENT,
|
|
1571
|
+
}, async (args = {}) => {
|
|
1572
|
+
const symbol = typeof args.symbol === "string" ? args.symbol.trim() : "";
|
|
1573
|
+
if (!symbol || symbol.length > 30)
|
|
1574
|
+
return errorResult("Invalid input: symbol looks like SOLUSDT or SOL.");
|
|
1575
|
+
const list = await resolveWatchlist(args.watchlist_id);
|
|
1576
|
+
if (!list.ok)
|
|
1577
|
+
return list.result;
|
|
1578
|
+
const r = await account("POST", `/api/member/watchlists/${encodeURIComponent(list.list.id)}/items`, { symbol });
|
|
1579
|
+
if (!r.ok)
|
|
1580
|
+
return r.result;
|
|
1581
|
+
return okResult({ ...r.data, watchlist: { id: list.list.id, name: list.list.name }, note: r.data.created === false ? "Already on this list." : undefined });
|
|
1582
|
+
});
|
|
1583
|
+
server.registerTool("remove_watchlist_symbol", {
|
|
1584
|
+
title: "Remove a symbol from your watchlist (writes to your account)",
|
|
1585
|
+
description: "Call this when the user asks to stop watching a coin or remove it from their watchlist. Removes the symbol from the list named by watchlist_id, else from the default list (or the only list the account has); a symbol that is not on the list changes nothing. Returns the list's symbols after the change. Writes are rate limited per key and each one is recorded on the account.",
|
|
1586
|
+
inputSchema: {
|
|
1587
|
+
symbol: z.string().describe("string, the symbol to remove, e.g. SOLUSDT or SOL"),
|
|
1588
|
+
watchlist_id: z.string().optional().describe("string, optional list id from list_watchlists (default: the default list)"),
|
|
1589
|
+
},
|
|
1590
|
+
annotations: ACCOUNT_DELETE,
|
|
1591
|
+
}, async (args = {}) => {
|
|
1592
|
+
const symbol = typeof args.symbol === "string" ? args.symbol.trim() : "";
|
|
1593
|
+
if (!symbol || symbol.length > 30)
|
|
1594
|
+
return errorResult("Invalid input: symbol looks like SOLUSDT or SOL.");
|
|
1595
|
+
const list = await resolveWatchlist(args.watchlist_id);
|
|
1596
|
+
if (!list.ok)
|
|
1597
|
+
return list.result;
|
|
1598
|
+
const r = await account("DELETE", `/api/member/watchlists/${encodeURIComponent(list.list.id)}/items?symbol=${encodeURIComponent(symbol)}`);
|
|
1599
|
+
if (!r.ok)
|
|
1600
|
+
return r.result;
|
|
1601
|
+
return okResult({ ...r.data, watchlist: { id: list.list.id, name: list.list.name }, note: r.data.deleted === false ? "Was not on this list." : undefined });
|
|
1602
|
+
});
|
|
1603
|
+
/* Hyperliquid address tracking (Terminal feature, 2026-09-30): the member routes /api/member/hl-addresses take the
|
|
1604
|
+
* account key like the alert and watchlist routes. */
|
|
1605
|
+
const HL_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
1606
|
+
const TRACKED_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1607
|
+
server.registerTool("list_tracked_addresses", {
|
|
1608
|
+
title: "List the Hyperliquid addresses you follow (reads your account)",
|
|
1609
|
+
description: "Call this when the user asks which Hyperliquid addresses they follow, or what those addresses hold right now. Returns every followed address with its label, the positions last seen on it and the plan's address limit; following addresses is part of Terminal and the plans above it.",
|
|
1610
|
+
inputSchema: {},
|
|
1611
|
+
annotations: ACCOUNT_READ,
|
|
1612
|
+
}, async () => {
|
|
1613
|
+
const r = await account("GET", "/api/member/hl-addresses");
|
|
1614
|
+
if (!r.ok)
|
|
1615
|
+
return r.result;
|
|
1616
|
+
return okResult(r.data);
|
|
1617
|
+
});
|
|
1618
|
+
server.registerTool("add_tracked_address", {
|
|
1619
|
+
title: "Follow a Hyperliquid address for position alerts (writes to your account)",
|
|
1620
|
+
description: "Call this when the user asks to follow, track or get alerts for a Hyperliquid address (0x followed by 40 hex characters). Every later position change of the address (opened, closed, increased, reduced, flipped) reaches the user's alert channels; the first sight is the baseline and sends nothing. Counted against the plan's address limit; the route answers terminal_required or limit_reached when it cannot add.",
|
|
1621
|
+
inputSchema: {
|
|
1622
|
+
address: z.string().describe("string, 0x followed by 40 hex characters"),
|
|
1623
|
+
label: z.string().optional().describe("string, optional name for the address, up to 40 characters"),
|
|
1624
|
+
},
|
|
1625
|
+
annotations: ACCOUNT_WRITE_IDEMPOTENT,
|
|
1626
|
+
}, async (args = {}) => {
|
|
1627
|
+
const address = typeof args.address === "string" ? args.address.trim().toLowerCase() : "";
|
|
1628
|
+
if (!HL_ADDRESS_RE.test(address))
|
|
1629
|
+
return errorResult("Invalid input: address is 0x followed by 40 hex characters.");
|
|
1630
|
+
const label = typeof args.label === "string" ? args.label.trim().slice(0, 40) : undefined;
|
|
1631
|
+
const r = await account("POST", "/api/member/hl-addresses", { address, ...(label ? { label } : {}) });
|
|
1632
|
+
if (!r.ok)
|
|
1633
|
+
return r.result;
|
|
1634
|
+
return okResult(r.data);
|
|
1635
|
+
});
|
|
1636
|
+
server.registerTool("remove_tracked_address", {
|
|
1637
|
+
title: "Stop following a Hyperliquid address (writes to your account)",
|
|
1638
|
+
description: "Call this when the user asks to stop following or tracking a Hyperliquid address. Takes the id from list_tracked_addresses; the address's alerts stop, its recorded events stay.",
|
|
1639
|
+
inputSchema: {
|
|
1640
|
+
id: z.string().describe("string, the id from list_tracked_addresses"),
|
|
1641
|
+
},
|
|
1642
|
+
annotations: ACCOUNT_DELETE,
|
|
1643
|
+
}, async (args = {}) => {
|
|
1644
|
+
const id = typeof args.id === "string" ? args.id.trim() : "";
|
|
1645
|
+
if (!TRACKED_ID_RE.test(id))
|
|
1646
|
+
return errorResult("Invalid input: id is the value list_tracked_addresses returns.");
|
|
1647
|
+
const r = await account("DELETE", `/api/member/hl-addresses/${encodeURIComponent(id)}`);
|
|
1648
|
+
if (!r.ok)
|
|
1649
|
+
return r.result;
|
|
1650
|
+
return okResult(r.data);
|
|
1651
|
+
});
|
|
1183
1652
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bykaranteli-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server for live crypto derivatives data from bykaranteli.com: funding rates, funding arbitrage, open interest pressure, liquidations, options, ETF flows, Fear & Greed and BTC dominance.
|
|
3
|
+
"version": "0.32.0",
|
|
4
|
+
"description": "MCP server for live crypto derivatives data from bykaranteli.com: funding rates, funding arbitrage, open interest pressure, liquidations, options, ETF flows, Fear & Greed and BTC dominance. 67 read-only and account tools (the alert and watchlist tools act on your own account), free account key, paid depth via plans or x402.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"bykaranteli-mcp": "dist/index.js"
|