bykaranteli-mcp 0.30.6 → 0.31.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 ADDED
@@ -0,0 +1,169 @@
1
+ # Changelog
2
+
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
+
5
+ ## [0.31.0] - 2026-09-30
6
+
7
+ 67 tools.
8
+
9
+ ### Added
10
+
11
+ - `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.
12
+ - `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.
13
+ - `get_venue_share`: each counted exchange's share of the recorded liquidations and of perpetual open interest over 1, 7 or 30 days.
14
+ - `get_tradfi_gaps`: stock, index and commodity perpetuals against the cash close over weekends and nights, checkpoint by checkpoint, with the realised open gap.
15
+ - Account tools for Hyperliquid address tracking (Terminal and above): `list_tracked_addresses`, `add_tracked_address`, `remove_tracked_address`.
16
+ - 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.
17
+
18
+ - `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`.
19
+ - 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.
20
+ - `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.
21
+ - 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.
22
+
23
+ ### Changed
24
+
25
+ - 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.
26
+
27
+ ## [0.30.8] - 2026-09-30
28
+
29
+ 52 tools.
30
+
31
+ ### Added
32
+
33
+ - `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`.
34
+
35
+ ## [0.30.7] - 2026-09-30
36
+
37
+ 51 tools.
38
+
39
+ ### Changed
40
+
41
+ - `get_options_snapshot` describes the per-expiry rows the API now returns (`expiries`: open interest by side, the max pain strike, the one-sigma implied move the ATM IV prices).
42
+
43
+ ### Added
44
+
45
+ - This changelog ships inside the npm package, and the README links to it.
46
+
47
+ ## [0.30.6] - 2026-09-30
48
+
49
+ 51 tools.
50
+
51
+ ### Fixed
52
+
53
+ - Source links of the IV surface, metric context, leverage tiers and open interest history tools point at pages that exist, and the options snapshot and options flow descriptions name the wall side, the single ATM strike and how multi-leg trades are netted.
54
+
55
+ ## [0.30.5] - 2026-09-28
56
+
57
+ 51 tools.
58
+
59
+ ### Fixed
60
+
61
+ - `get_tokenized_stocks` accepts all twelve issuers, `get_solana_perps` passes the symbol as typed so mixed-case markets (kBONK, kPEPE, kSHIB) reach their history, and `get_turkey_premium` says KuCoin TR contributes the USDT pairs only.
62
+
63
+ ## [0.30.4] - 2026-09-25
64
+
65
+ 51 tools.
66
+
67
+ ### Changed
68
+
69
+ - `get_solana_perps` covers six venues (Jupiter, Pacifica, Phoenix, GM Trade, Velocity, Bullet) with one-sided open interest and history on every venue but Jupiter.
70
+
71
+ ## [0.30.3] - 2026-09-25
72
+
73
+ 51 tools.
74
+
75
+ ### Added
76
+
77
+ - `get_solana_perps`: the Solana Perps board with venues summed, every market and Pacifica hourly history.
78
+
79
+ ## [0.30.2] - 2026-09-25
80
+
81
+ 50 tools.
82
+
83
+ ### Changed
84
+
85
+ - The hosted endpoint needs an account key; the README, the registry description and the remote header say so.
86
+
87
+ ## [0.30.1] - 2026-09-24
88
+
89
+ 50 tools.
90
+
91
+ ### Changed
92
+
93
+ - `get_tokenized_stocks` describes `price_source` per wrapper and `totals.priced_by`.
94
+
95
+ ## [0.30.0] - 2026-09-23
96
+
97
+ 50 tools.
98
+
99
+ ### Added
100
+
101
+ - `get_tokenized_stocks`: tokenized stock wrappers onchain and on exchanges.
102
+
103
+ ## [0.29.0] - 2026-09-23
104
+
105
+ 49 tools.
106
+
107
+ ### Added
108
+
109
+ - `get_insurance_funds` (exchange insurance funds on Binance, Bybit, OKX and Gate) and a `venue` argument on `get_options_snapshot` (all, deribit, bybit, binance, okx, delta); `get_orderbook_depth` reports held-out books and venue wording no longer carries hand-written lists.
110
+
111
+ ## [0.28.3] - 2026-09-20
112
+
113
+ 48 tools.
114
+
115
+ ### Added
116
+
117
+ - `get_liquidation_leaderboard`: the largest single liquidations over 24 hours, 7 days and 30 days, with a 30-day session heatmap.
118
+
119
+ ## [0.28.2] - 2026-09-20
120
+
121
+ 47 tools.
122
+
123
+ ### Changed
124
+
125
+ - `get_positioning` names six venues (Gate, HTX and Bitget joined on 2026-09-20).
126
+
127
+ ## [0.28.1] - 2026-09-20
128
+
129
+ 47 tools.
130
+
131
+ ### Fixed
132
+
133
+ - `get_pressure_scores` keeps the `byk_proof` reference in its slim output.
134
+
135
+ ## [0.28.0] - 2026-09-18
136
+
137
+ 47 tools.
138
+
139
+ ### Added
140
+
141
+ - `get_data_proof`: onchain proofs of the BYK Data Layer on Solana and Base.
142
+
143
+ ## [0.27.2] - 2026-09-17
144
+
145
+ 46 tools.
146
+
147
+ ### Changed
148
+
149
+ - The README lists `get_turkey_premium`, every description states the same tool count, and the `server.json` and Cursor plugin versions caught up with the package.
150
+
151
+ ## [0.27.1] - 2026-09-17
152
+
153
+ 46 tools. No git tag; the date is the version commit.
154
+
155
+ ### Changed
156
+
157
+ - `get_turkey_premium` describes the Turkey Premium Index, its score and its regime.
158
+
159
+ ## [0.27.0] - 2026-09-17
160
+
161
+ 46 tools. No git tag; the date is the version commit.
162
+
163
+ ### Added
164
+
165
+ - `get_turkey_premium`: TRY reference prices from eight Turkish venues and the premium in basis points.
166
+
167
+ ## Earlier versions
168
+
169
+ Release notes for 0.26.3 and earlier are not recorded in this file; the git history and tags of this repository carry them.
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
- 51 read-only tools over the public JSON API of [bykaranteli.com](https://bykaranteli.com/developers). 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, a monthly x402 catalog allowance). 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.
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. Same 51 tools, nothing to install. Every tool call needs your
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
 
@@ -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`.
111
126
 
112
- All responses are JSON and carry a `generatedAt` timestamp plus a `source` URL to the human-readable page. Symbols accept both `BTCUSDT` and bare `BTC`.
127
+ ## Account tools (alerts and watchlist)
128
+
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
 
@@ -132,19 +167,23 @@ npm run build
132
167
  node dist/index.js # speaks MCP over stdio
133
168
  ```
134
169
 
170
+ Release notes per version: [CHANGELOG.md](https://github.com/bykarantelicom/bykaranteli-mcp/blob/main/CHANGELOG.md).
171
+
135
172
  ## License
136
173
 
137
174
  Server: MIT. Data: personal and research use with attribution "ByKaranteli (bykaranteli.com)"; commercial use with the Business plan. Licence text: <https://bykaranteli.com/data#license>.
138
175
 
139
176
  ## Plans and paid depth
140
177
 
141
- | Plan | Price | Rate | Monthly | Depth |
142
- |---|---|---|---|---|
143
- | Free API | $0 | 30 / min | 15,000 | public pages |
144
- | Terminal | $29 / mo | 60 / min | 150,000 | public pages |
145
- | Builder | $49 / mo | 300 / min | 1,000,000 fair use | member depth, 1,000 x402 catalog calls a month |
146
- | Business | $149 / mo | 1,200 / min | 3,000,000 fair use | member depth, commercial licence, 10,000 x402 catalog calls a month, monthly bulk |
147
- | Scale | $399 / mo | 3,000 / min | 10,000,000 fair use | member depth, derived redistribution, x402 catalog with no ceiling, daily raw |
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
148
187
 
149
188
  A Free or Terminal key past its monthly figure gets 429 until
150
189
  the month resets. A paid key past its fair use is never stopped: it answers at
@@ -155,6 +194,5 @@ Full table: <https://bykaranteli.com/developers#tiers>. For recorded history and
155
194
  raw records beyond the live snapshots, bykaranteli.com also exposes pay-per-call
156
195
  x402 endpoints for anonymous agents (USDC on Solana or Base, priced per call,
157
196
  no account): <https://bykaranteli.com/developers#x402> · machine catalog:
158
- <https://bykaranteli.com/api/x402>. Builder keys call those routes unpaid up to
159
- 1,000 calls a month, Business up to 10,000, Scale with no ceiling; past the
160
- 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
- const forwarded = options?.authorizationProvider?.();
16
- if (forwarded)
17
- return forwarded;
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
- /* API stems whose public page has a different name. Stripping /api/public off
53
- * the route gave nine tools a `source` that 404s on the site (2026-09-04
54
- * audit: /borrow-rates, /oi, /recent, /venues/markets ...). */
55
- const PAGE_FOR_STEM = {
56
- "/borrow-rates": "/borrow",
57
- "/oi": "/oi-leaderboard",
58
- "/venues/markets": "/venues",
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) {
@@ -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);
@@ -411,7 +407,7 @@ export function registerByKaranteliTools(server, options) {
411
407
  });
412
408
  server.registerTool("get_options_snapshot", {
413
409
  title: "Options walls, gamma exposure and DVOL (BTC + ETH)",
414
- description: "Call this when the user asks where the big options bets sit, about call/put walls, gamma exposure (GEX), the zero-gamma level, implied volatility (DVOL) or the IV term structure for Bitcoin or Ethereum, across options venues or on one venue. Daily snapshot of the listed option chains of every options venue we record, summed by default or one venue with venue: the call wall (largest call open interest above spot) and put wall (largest put open interest below spot), the largest bars on the whole axis, top strikes by open interest, put/call ratio, dealer hedging map, ATM IV by expiry (calls and puts interpolated at the money, iv_source names the venue whose quotes price the chain) and each venue's open interest (venues_included). DVOL is Deribit's index whatever the venue.",
410
+ description: "Call this when the user asks where the big options bets sit, about call/put walls, gamma exposure (GEX), the zero-gamma level, implied volatility (DVOL) or the IV term structure for Bitcoin or Ethereum, across options venues or on one venue. Daily snapshot of the listed option chains of every options venue we record, summed by default or one venue with venue: the call wall (largest call open interest above spot) and put wall (largest put open interest below spot), the largest bars on the whole axis, top strikes by open interest, put/call ratio, dealer hedging map, ATM IV by expiry (calls and puts interpolated at the money, iv_source names the venue whose quotes price the chain), each venue's open interest (venues_included), and per expiry the open interest by side, the max pain strike (where the open contracts as a group pay out the least at settlement, not a price target) and the one-sigma implied move the ATM IV prices (expiries). DVOL is Deribit's index whatever the venue.",
415
411
  inputSchema: {
416
412
  venue: z.enum(["all", "deribit", "bybit", "binance", "okx", "delta"]).optional().describe("all (default) sums every options venue; or one venue id: deribit, bybit, binance, okx or delta (Delta Exchange India)."),
417
413
  },
@@ -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(1).max(730).optional().describe("Window in days, 1-730 (default 365).") },
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(1).max(730).optional().describe("Window in days, 1-730 (default 365).") },
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
- return ok({ ...data, source: `${PUBLIC_URL}/liqmap/${sym.replace(/USDT$/, "").toLowerCase()}` });
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.30.6",
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. 51 read-only tools, free account key, paid depth via plans or x402.",
3
+ "version": "0.31.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"
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "dist",
12
12
  "README.md",
13
+ "CHANGELOG.md",
13
14
  "LICENSE"
14
15
  ],
15
16
  "scripts": {