bykaranteli-mcp 0.34.6 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
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.35.1] - 2026-10-11
6
+
7
+ 67 tools.
8
+
9
+ ### Changed
10
+
11
+ - Every tool carries `annotations.title`, the same text as its top-level `title`: directory portals (the Claude Connectors Directory among them) read the display name there. The read-only, destructive, idempotent and open-world hints are unchanged.
12
+ - Descriptions name plans, not keys: depth and limits follow the account's plan whether the client signed in with OAuth or sends an account key (`get_liqmap`, `get_series`, `get_options_chain`, `get_solana_perps`, the account tools).
13
+ - `get_series` returns the newest 300 bars when neither `from` nor `limit` is given (Builder and higher plans received 1,000 by default, 64,700 characters for price); `limit` and `from`/`to` still return up to 5,000.
14
+
15
+ ## [0.35.0] - 2026-10-11
16
+
17
+ 67 tools.
18
+
19
+ ### Changed
20
+
21
+ - Short default answers. Twenty market tools answered between 48,000 and 234,000 characters on a call without arguments, and `get_venue_markets` answered nothing but the ceiling note. A call without the detail parameters now returns a first page: every shortened list is named in `trimmed` (`field`, `returned`, `total` and `more`, the parameter that returns the rest), and a shortened main list carries `rows_total` and `rows_returned`. Items are kept or left out as the route sent them; nothing is recomputed, field names stay, and `provenance` still describes the full route answer. Filtered calls (one symbol, coin, asset, pair, underlying or venue) keep their full detail.
22
+ - The result ceiling is 100,000 characters (was 240,000), under the 25,000 tokens Claude Code allows one tool result by default. `get_macro_liquidity` and `get_network_health` with a long `days` window and no `series` answer, when every series would pass it, with the series keys and the latest values instead of the generic ceiling note.
23
+ - Defaults per tool: `get_tradfi_gaps` 15 underlyings in the summary and the latest window; `get_borrow_rates` the last 24 hours of each series; `get_positioning` the 10 most traded perps and 24 points per CVD series; `get_macro_liquidity` and `get_network_health` the newest 7 points of each series; `get_tradfi_board`, `get_rsi_heatmap`, `get_solana_perps`, `get_hl_whales`, `get_settlements`, `get_withdrawal_status` and `get_leverage_tiers` 30 rows per list (`get_leverage_tiers` also the first 5 tiers of each ladder); `get_venue_markets` the 30 venue markets with the largest open interest; `get_liquidations` 30 rows without a symbol (summary still covers every matching row); `get_tokenized_stocks` 10 stocks (was 20) with their first 3 wrappers and 3 exchange pairs; `get_coverage` the first 30 snapshot feeds, English notes only and no matrix cells; `get_coinbase_premium` and `get_turkey_premium` the last 7 days of daily rows; `get_funding_arbitrage` the first 5 venue legs per row; `get_liqmap` the 30 real levels nearest the price and the 30 newest real prints; `get_lead_lag` the last 7 daily rows per pair.
24
+
25
+ ### Added
26
+
27
+ - Parameters that return the detail: `limit` on `get_liquidations`, `get_tradfi_gaps`, `get_positioning`, `get_tradfi_board`, `get_leverage_tiers`, `get_rsi_heatmap`, `get_settlements`, `get_hl_whales`, `get_solana_perps`, `get_withdrawal_status` and `get_venue_markets`; `asset` and `hours` on `get_borrow_rates`; `cvd_points` on `get_positioning`; `series` on `get_macro_liquidity` and `get_network_health` (one key over a long window); `order` (asc with `sort`, most oversold first) on `get_rsi_heatmap`; `base` on `get_settlements`; `symbol` on `get_funding_arbitrage`; `days` on `get_coinbase_premium` and `get_lead_lag`; `daily_days` on `get_turkey_premium`; `detail` on `get_coverage` and `get_liqmap`.
28
+
5
29
  ## [0.34.6] - 2026-10-09
6
30
 
7
31
  67 tools. README only; the server code is the same as 0.34.3.
package/README.md CHANGED
@@ -124,6 +124,8 @@ Requires Node.js 18 or newer.
124
124
 
125
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
126
 
127
+ Answers are sized for a model's context: a call without arguments returns a short first page (for example 30 rows, or the newest points of a series), never a whole board. Every shortened list is named in `trimmed` with `field`, `returned`, `total` and `more`, the parameter that returns the rest (`symbol`, `coin`, `asset`, `limit`, `days`, `detail` and the like); a filtered call keeps its full detail. One answer is capped at 100,000 characters; above that the tool says how to narrow the call.
128
+
127
129
  ## Account tools (alerts and watchlist)
128
130
 
129
131
  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.
package/dist/tools.d.ts CHANGED
@@ -25,4 +25,4 @@ export type RegisterOptions = {
25
25
  * (the hosted endpoint). Takes precedence over apiKey when it returns one. */
26
26
  authorizationProvider?: () => string | undefined;
27
27
  };
28
- export declare function registerByKaranteliTools(server: McpServer, options?: RegisterOptions): void;
28
+ export declare function registerByKaranteliTools(target: McpServer, options?: RegisterOptions): void;
package/dist/tools.js CHANGED
@@ -6,7 +6,11 @@
6
6
  */
7
7
  import { z } from "zod/v4";
8
8
  import { buildProvenance, pageForApiPath } from "./provenance.js";
9
- export function registerByKaranteliTools(server, options) {
9
+ export function registerByKaranteliTools(target, options) {
10
+ const withTitle = (def) => def.title && !(def.annotations && "title" in def.annotations) ? { ...def, annotations: { ...(def.annotations ?? {}), title: def.title } } : def;
11
+ const server = {
12
+ registerTool: (name, def, handler) => target.registerTool.call(target, name, withTitle(def), handler),
13
+ };
10
14
  const BASE_URL = (options?.baseUrl ?? process.env.BYKARANTELI_BASE_URL ?? "https://bykaranteli.com").replace(/\/+$/, "");
11
15
  const PUBLIC_URL = (options?.publicUrl ?? process.env.BYKARANTELI_PUBLIC_URL ?? "https://bykaranteli.com").replace(/\/+$/, "");
12
16
  const USER_AGENT = `bykaranteli-mcp/${options?.version ?? "dev"} (+https://github.com/bykarantelicom/bykaranteli-mcp)`;
@@ -76,7 +80,7 @@ export function registerByKaranteliTools(server, options) {
76
80
  * context window, with no signal that anything was cut (audit 2026-09-04
77
81
  * #41). Above the ceiling the tool answers with guidance instead of a
78
82
  * payload the caller could not use anyway. */
79
- const MAX_RESULT_CHARS = 240_000;
83
+ const MAX_RESULT_CHARS = 100_000;
80
84
  function ok(data) {
81
85
  const text = JSON.stringify(data);
82
86
  if (text.length <= MAX_RESULT_CHARS)
@@ -93,6 +97,47 @@ export function registerByKaranteliTools(server, options) {
93
97
  }],
94
98
  };
95
99
  }
100
+ const isRec = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
101
+ const list = (v) => (Array.isArray(v) ? v : []);
102
+ class Trim {
103
+ cuts = [];
104
+ /** The first n items in the route's own order; anything that is not a longer list passes through. */
105
+ head(value, n, field, more) {
106
+ if (!Array.isArray(value) || value.length <= n)
107
+ return value;
108
+ this.cuts.push({ field, returned: n, total: value.length, more });
109
+ return value.slice(0, n);
110
+ }
111
+ /** One entry for a cut spread over several lists (a field inside every row). */
112
+ note(field, returned, total, more) {
113
+ if (returned < total)
114
+ this.cuts.push({ field, returned, total, more });
115
+ }
116
+ }
117
+ /* The answer with provenance already attached, shortened by `cut`; rows_total and rows_returned describe the main
118
+ * list when it is named (a route that already counts its rows keeps its own rows_total). */
119
+ function trimmedAnswer(full, cut, rowsField) {
120
+ if (!isRec(full))
121
+ return full;
122
+ const rec = { ...full };
123
+ const total = rowsField && Array.isArray(rec[rowsField]) ? rec[rowsField].length : undefined;
124
+ const t = new Trim();
125
+ cut(rec, t);
126
+ if (rowsField && total !== undefined && Array.isArray(rec[rowsField])) {
127
+ if (rec.rows_total === undefined)
128
+ rec.rows_total = total;
129
+ rec.rows_returned = rec[rowsField].length;
130
+ }
131
+ return t.cuts.length > 0 ? { ...rec, trimmed: t.cuts } : rec;
132
+ }
133
+ function compact(data, path, cut, rowsField) {
134
+ return trimmedAnswer(withProvenance(data, path), cut, rowsField);
135
+ }
136
+ /** The newest `days` distinct dates of rows carrying a `date` field, in the route's order. */
137
+ function newestDates(rows, days) {
138
+ const dates = [...new Set(rows.map((r) => (isRec(r) ? String(r.date) : "")))].filter(Boolean).sort().reverse();
139
+ return new Set(dates.slice(0, days));
140
+ }
96
141
  function fail(err) {
97
142
  const message = err instanceof Error ? err.message : String(err);
98
143
  const permanent = /returned HTTP 4\d\d/.test(message);
@@ -150,7 +195,7 @@ export function registerByKaranteliTools(server, options) {
150
195
  });
151
196
  server.registerTool("get_liquidations", {
152
197
  title: "Crypto liquidations: daily long/short totals per symbol and exchange",
153
- description: "Call this when the user asks how much was liquidated in crypto futures, whether longs or shorts got flushed, or for liquidation history. Returns daily long and short liquidation totals in USD per symbol and exchange, recorded from ByKaranteli's own stream collectors on every liquidation venue it counts, listed on bykaranteli.com/coverage (recorded events, a floor, not estimates). One row per finalized UTC day, symbol and exchange; history begins 2026-07-30 and grows daily.",
198
+ description: "Call this when the user asks how much was liquidated in crypto futures, whether longs or shorts got flushed, or for liquidation history. Returns daily long and short liquidation totals in USD per symbol and exchange, recorded from ByKaranteli's own stream collectors on every liquidation venue it counts, listed on bykaranteli.com/coverage (recorded events, a floor, not estimates). One row per finalized UTC day, symbol and exchange; history begins 2026-07-30 and grows daily. Without symbol, rows holds the first 30 rows (newest day, largest first) while summary covers every matching row; pass symbol or limit (up to 400) for more rows.",
154
199
  inputSchema: {
155
200
  symbol: z
156
201
  .string()
@@ -160,9 +205,10 @@ export function registerByKaranteliTools(server, options) {
160
205
  .optional()
161
206
  .describe("Optional symbol filter like BTCUSDT or ETHUSDT. Omit for all symbols."),
162
207
  days: z.number().int().min(1).max(90).optional().describe("How many most recent days to return (default 7)."),
208
+ limit: z.coerce.number().int().min(1).max(400).optional().describe("Optional. Rows to return, newest day first and largest first within a day (default 30 without symbol, 400 with one)."),
163
209
  },
164
210
  annotations: READ_ONLY,
165
- }, async ({ symbol, days }) => {
211
+ }, async ({ symbol, days, limit }) => {
166
212
  try {
167
213
  const d = (await fetchJson("/api/v1/public/datasets/liquidations-daily.json"));
168
214
  const rows = Array.isArray(d.rows) ? d.rows : [];
@@ -199,7 +245,8 @@ export function registerByKaranteliTools(server, options) {
199
245
  day.rows += 1;
200
246
  byDate.set(date, day);
201
247
  }
202
- const MAX_ROWS = 400;
248
+ /* 0.35.0: 30 rows without a symbol (the 400-row page was 78k characters); a symbol keeps the old 400. */
249
+ const MAX_ROWS = limit ?? (wantSymbol ? 400 : 30);
203
250
  /* Largest rows first within the cap, newest day first. */
204
251
  const ordered = [...filtered].sort((a, b) => {
205
252
  const dd = String(b.date).localeCompare(String(a.date));
@@ -227,14 +274,16 @@ export function registerByKaranteliTools(server, options) {
227
274
  })),
228
275
  },
229
276
  rows: ordered.slice(0, MAX_ROWS),
277
+ rows_total: filtered.length,
230
278
  rows_returned: Math.min(ordered.length, MAX_ROWS),
231
279
  rows_matched: filtered.length,
232
280
  truncated,
233
281
  note: truncated
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
282
+ ? `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 or limit up to 400. ` + baseNote
235
283
  : baseNote,
236
284
  source: `${PUBLIC_URL}/liquidations`,
237
285
  provenance: provenanceFor(d, `/api/v1/public/datasets/liquidations-daily.json${wantSymbol ? `?symbol=${wantSymbol}` : ""}`, "/liquidations"),
286
+ ...(truncated ? { trimmed: [{ field: "rows", returned: MAX_ROWS, total: filtered.length, more: "pass symbol, or limit up to 400" }] } : {}),
238
287
  });
239
288
  }
240
289
  catch (err) {
@@ -312,13 +361,35 @@ export function registerByKaranteliTools(server, options) {
312
361
  });
313
362
  server.registerTool("get_funding_arbitrage", {
314
363
  title: "Cross-exchange funding arbitrage opportunities",
315
- description: "Call this when the user asks about funding arbitrage, funding rate differences between exchanges, or delta-neutral carry trades. Compares funding across every venue on the board, from Binance, OKX and Bybit to Hyperliquid, dYdX and the smaller perp venues fed by the venue snapshot, for 12 major perps and returns the best long/short venue per symbol with gross and net annualized APR (net of taker fees and weekly rebalance cost).",
316
- inputSchema: {},
364
+ description: "Call this when the user asks about funding arbitrage, funding rate differences between exchanges, or delta-neutral carry trades. Compares funding across every venue on the board, from Binance, OKX and Bybit to Hyperliquid, dYdX and the smaller perp venues fed by the venue snapshot, for 12 major perps and returns the best long/short venue per symbol with gross and net annualized APR (net of taker fees and weekly rebalance cost). Each row lists its first 5 venue legs; pass symbol for every leg of one perp.",
365
+ inputSchema: {
366
+ symbol: z.string().optional().describe("Optional. One perp, e.g. BTCUSDT or BTC: its row with every venue leg."),
367
+ },
317
368
  annotations: READ_ONLY,
318
- }, async () => {
369
+ }, async ({ symbol }) => {
319
370
  try {
320
371
  const d = (await fetchJson("/api/public/funding-arb"));
321
- return ok({ ...d, source: `${PUBLIC_URL}/funding-arb`, provenance: provenanceFor(d, "/api/public/funding-arb") });
372
+ const full = { ...d, source: `${PUBLIC_URL}/funding-arb`, provenance: provenanceFor(d, "/api/public/funding-arb") };
373
+ const rows = list(d.rows).filter(isRec);
374
+ if (symbol) {
375
+ const want = normalizeSymbol(symbol);
376
+ const row = rows.find((r) => r.symbol === want);
377
+ if (!row)
378
+ return ok({ generatedAt: d.generatedAt, note: `${want} is not on the funding arbitrage board. Symbols: ${rows.map((r) => String(r.symbol)).join(", ")}` });
379
+ return ok({ ...full, rows: [row] });
380
+ }
381
+ const LEGS = 5;
382
+ return ok(trimmedAnswer(full, (rec, t) => {
383
+ let kept = 0;
384
+ let total = 0;
385
+ rec.rows = rows.map((r) => {
386
+ const legs = list(r.exchanges);
387
+ total += legs.length;
388
+ kept += Math.min(legs.length, LEGS);
389
+ return legs.length > LEGS ? { ...r, exchanges: legs.slice(0, LEGS) } : r;
390
+ });
391
+ t.note("rows[].exchanges", kept, total, "pass symbol for every venue leg of one perp");
392
+ }));
322
393
  }
323
394
  catch (err) {
324
395
  return fail(err);
@@ -423,12 +494,22 @@ export function registerByKaranteliTools(server, options) {
423
494
  });
424
495
  server.registerTool("get_coinbase_premium", {
425
496
  title: "Coinbase Premium (US demand gauge) + carry yield",
426
- description: "Call this when the user asks whether US investors are buying or selling Bitcoin or Ethereum, about the Coinbase Premium, or what the cash-and-carry basis trade pays. Returns the latest daily premium in percent, 7-day average, same-sign streak, the last 30 days, and annualized quarterly carry yields. History since 2017; positive premium = US buying pressure.",
427
- inputSchema: {},
497
+ description: "Call this when the user asks whether US investors are buying or selling Bitcoin or Ethereum, about the Coinbase Premium, or what the cash-and-carry basis trade pays. Returns the latest daily premium in percent, 7-day average, same-sign streak, the last 30 days, and annualized quarterly carry yields. History since 2017; positive premium = US buying pressure. The kimchi and Turkey premium daily rows cover the last 7 days; pass days (up to 90) for more.",
498
+ inputSchema: {
499
+ days: z.coerce.number().int().min(1).max(90).optional().describe("Optional. Days of kimchi and Turkey premium daily rows, newest first (default 7, up to 90)."),
500
+ },
428
501
  annotations: READ_ONLY,
429
- }, async () => {
502
+ }, async ({ days }) => {
430
503
  try {
431
- return ok(withProvenance(await fetchJson("/api/public/premium"), "/api/public/premium"));
504
+ return ok(compact(await fetchJson("/api/public/premium"), "/api/public/premium", (rec, t) => {
505
+ if (!isRec(rec.kimchi))
506
+ return;
507
+ const daily = list(rec.kimchi.daily);
508
+ const keep = newestDates(daily, days ?? 7);
509
+ const kept = daily.filter((r) => isRec(r) && keep.has(String(r.date)));
510
+ t.note("kimchi.daily", kept.length, daily.length, "pass days up to 90");
511
+ rec.kimchi = { ...rec.kimchi, daily: kept };
512
+ }));
432
513
  }
433
514
  catch (err) {
434
515
  return fail(err);
@@ -551,7 +632,7 @@ export function registerByKaranteliTools(server, options) {
551
632
  });
552
633
  server.registerTool("get_tokenized_stocks", {
553
634
  title: "Tokenized stocks onchain: supply, premium to the real share, DEX pools and exchange pairs",
554
- description: "Call this when the user asks about tokenized stocks or stock tokens (xStocks, Ondo, Robinhood stock tokens, Coinbase tokenized stocks on Base, Binance bStocks, Backpack, Gate gStocks, Reality, Superstate, Figure, Dinari dShares, Securitize): how much of a stock exists onchain, which issuer or chain holds the most, whether a wrapper trades above or below the real share, which DEX pools or exchanges trade it. Returns the board ByKaranteli refreshes every 10 minutes: per underlying the reference share price and its session, each wrapper (issuer, chain, price, premium_pct against a fresh reference, DEX liquidity and 24h volume from admitted pools, supply in shares and dollars, holders, status), tokenized spot pairs on the exchanges the board lists, the perpetual futures cross, and totals (supply by issuer and chain, DEX volume, median premium). Each wrapper's price_source names the price that valued its supply dollars (reference: the share's reference price; issuer_oracle: the issuer's own price for the token; wrapper: its own pool or exchange price; null: no dollars), and totals.priced_by counts the counted wrappers per level, unpriced included. Wrappers come from issuer sources only, never from a name search.",
635
+ description: "Call this when the user asks about tokenized stocks or stock tokens (xStocks, Ondo, Robinhood stock tokens, Coinbase tokenized stocks on Base, Binance bStocks, Backpack, Gate gStocks, Reality, Superstate, Figure, Dinari dShares, Securitize): how much of a stock exists onchain, which issuer or chain holds the most, whether a wrapper trades above or below the real share, which DEX pools or exchanges trade it. Returns the board ByKaranteli refreshes every 10 minutes: per underlying the reference share price and its session, each wrapper (issuer, chain, price, premium_pct against a fresh reference, DEX liquidity and 24h volume from admitted pools, supply in shares and dollars, holders, status), tokenized spot pairs on the exchanges the board lists, the perpetual futures cross, and totals (supply by issuer and chain, DEX volume, median premium). Each wrapper's price_source names the price that valued its supply dollars (reference: the share's reference price; issuer_oracle: the issuer's own price for the token; wrapper: its own pool or exchange price; null: no dollars), and totals.priced_by counts the counted wrappers per level, unpriced included. Wrappers come from issuer sources only, never from a name search. Without underlying, each stock lists its first 3 wrappers and 3 exchange pairs; pass underlying for all of them.",
555
636
  inputSchema: {
556
637
  underlying: z.string().regex(/^[A-Za-z0-9.]{1,16}$/).optional().describe("Stock ticker, e.g. TSLA; omit for the whole board."),
557
638
  /* Issuer ids mirror TOKENIZED_ISSUER_IDS in web/src/lib/tokenized-equities/meta.ts (the hosted tool reads that list
@@ -561,7 +642,7 @@ export function registerByKaranteliTools(server, options) {
561
642
  .optional()
562
643
  .describe("One issuer: xstocks, robinhood, coinbase, bstocks, ondo, backpack, gstocks, reality, superstate, figure, dinari or securitize; omit for every issuer."),
563
644
  chain: z.string().regex(/^[a-z]{2,12}$/).optional().describe("One chain, e.g. solana, base, bnb, robinhood, ethereum, ton, all (xStocks circulating) or cex (exchange pairs)."),
564
- top: z.number().int().min(1).max(200).optional().describe("Rows by supply (default 20, max 200)."),
645
+ top: z.number().int().min(1).max(200).optional().describe("Rows by supply (default 10, max 200)."),
565
646
  },
566
647
  annotations: READ_ONLY,
567
648
  }, async ({ underlying, issuer, chain, top }) => {
@@ -573,9 +654,32 @@ export function registerByKaranteliTools(server, options) {
573
654
  params.set("issuer", issuer);
574
655
  if (chain)
575
656
  params.set("chain", chain);
576
- params.set("top", String(top ?? 20));
657
+ /* 0.35.0: 10 rows by default (20 full rows were 113k characters). */
658
+ params.set("top", String(top ?? 10));
577
659
  const path = `/api/public/tokenized-stocks?${params.toString()}`;
578
- return ok(withProvenance(await fetchJson(path), path));
660
+ const data = await fetchJson(path);
661
+ if (underlying)
662
+ return ok(withProvenance(data, path));
663
+ const N = 3;
664
+ return ok(compact(data, path, (rec, t) => {
665
+ const kept = { wrappers: 0, cex: 0 };
666
+ const total = { wrappers: 0, cex: 0 };
667
+ rec.rows = list(rec.rows).map((r) => {
668
+ if (!isRec(r))
669
+ return r;
670
+ const out = { ...r };
671
+ for (const key of ["wrappers", "cex"]) {
672
+ const items = list(r[key]);
673
+ total[key] += items.length;
674
+ kept[key] += Math.min(items.length, N);
675
+ if (items.length > N)
676
+ out[key] = items.slice(0, N);
677
+ }
678
+ return out;
679
+ });
680
+ t.note("rows[].wrappers", kept.wrappers, total.wrappers, "pass underlying for every wrapper of one stock");
681
+ t.note("rows[].cex", kept.cex, total.cex, "pass underlying for every exchange pair of one stock");
682
+ }, "rows"));
579
683
  }
580
684
  catch (err) {
581
685
  return fail(err);
@@ -680,12 +784,42 @@ export function registerByKaranteliTools(server, options) {
680
784
  });
681
785
  server.registerTool("get_borrow_rates", {
682
786
  title: "Margin borrow rates per venue: the cost of leverage, hourly",
683
- description: "Call this when the user asks what it costs to borrow USDT, USDC, BTC, ETH or a major alt on an exchange, which venue has the cheapest borrow, whether stablecoin borrow cost is spiking, or what the carry of a basis trade is on a venue (funding minus borrow). Returns the latest annualised rate per venue and asset, 30 days of hourly series for the stablecoins and majors, and the carry table. Recorded hourly by ByKaranteli (Binance and OKX today).",
684
- inputSchema: {},
787
+ description: "Call this when the user asks what it costs to borrow USDT, USDC, BTC, ETH or a major alt on an exchange, which venue has the cheapest borrow, whether stablecoin borrow cost is spiking, or what the carry of a basis trade is on a venue (funding minus borrow). Returns the latest annualised rate per venue and asset, 30 days of hourly series for the stablecoins and majors, and the carry table. Recorded hourly by ByKaranteli (Binance and OKX today). The series hold the last 24 hours unless hours (up to 720) or asset is passed.",
788
+ inputSchema: {
789
+ asset: z.string().trim().toUpperCase().regex(/^[A-Z0-9]{2,12}$/).optional().describe("Optional. One asset, e.g. USDT: its rates, carry and full 30-day series."),
790
+ hours: z.coerce.number().int().min(1).max(720).optional().describe("Optional. Hours of hourly series to return (default 24, or the full 720 with asset)."),
791
+ },
685
792
  annotations: READ_ONLY,
686
- }, async () => {
793
+ }, async ({ asset, hours }) => {
687
794
  try {
688
- return ok(withProvenance(await fetchJson("/api/public/borrow-rates"), "/api/public/borrow-rates"));
795
+ const path = "/api/public/borrow-rates";
796
+ return ok(compact(await fetchJson(path), path, (rec, t) => {
797
+ const byAsset = (r) => !asset || (isRec(r) && String(r.asset).toUpperCase() === asset);
798
+ if (asset) {
799
+ rec.latest = list(rec.latest).filter(byAsset);
800
+ rec.carry = list(rec.carry).filter(byAsset);
801
+ if (list(rec.latest).length === 0)
802
+ rec.note = `${asset} has no recorded borrow rate; omit asset for every recorded one.`;
803
+ }
804
+ const series = list(rec.series).filter(byAsset).filter(isRec);
805
+ /* Hourly points [ts_ms, value], oldest first; the window ends at the newest point of any series. */
806
+ let newest = 0;
807
+ for (const s of series)
808
+ for (const p of list(s.points))
809
+ if (Array.isArray(p) && Number(p[0]) > newest)
810
+ newest = Number(p[0]);
811
+ const cutoff = newest - (hours ?? (asset ? 720 : 24)) * 3_600_000;
812
+ let kept = 0;
813
+ let total = 0;
814
+ rec.series = series.map((s) => {
815
+ const points = list(s.points);
816
+ const inWindow = points.filter((p) => Array.isArray(p) && Number(p[0]) > cutoff);
817
+ total += points.length;
818
+ kept += inWindow.length;
819
+ return { ...s, points: inWindow };
820
+ });
821
+ t.note("series[].points", kept, total, "pass hours up to 720, or asset for one asset's full series");
822
+ }));
689
823
  }
690
824
  catch (err) {
691
825
  return fail(err);
@@ -706,12 +840,26 @@ export function registerByKaranteliTools(server, options) {
706
840
  });
707
841
  server.registerTool("get_settlements", {
708
842
  title: "Expiry calendar and settlement prices across venues",
709
- description: "Call this when the user asks what futures or options expire soon, when the next quarterly expiry is on an exchange, how many contracts settle this week, or at what price a dated future settled. Returns the next 60 days of dated future and option expiries grouped by date, venue and underlying from 54 venues' market lists, plus the settlement prices recorded as dated futures deliver.",
710
- inputSchema: {},
843
+ description: "Call this when the user asks what futures or options expire soon, when the next quarterly expiry is on an exchange, how many contracts settle this week, or at what price a dated future settled. Returns the next 60 days of dated future and option expiries grouped by date, venue and underlying from 54 venues' market lists, plus the settlement prices recorded as dated futures deliver. Without base, the first 30 expiry rows and the 30 newest settlement prices; pass base for one asset's full lists, or limit (up to 300).",
844
+ inputSchema: {
845
+ base: z.string().trim().toUpperCase().regex(/^[A-Z0-9]{1,16}$/).optional().describe("Optional. One underlying, e.g. BTC: every expiry row and settlement price of that asset."),
846
+ limit: z.coerce.number().int().min(1).max(300).optional().describe("Optional. Rows per list without base (default 30): expiries soonest first, settlements newest first."),
847
+ },
711
848
  annotations: READ_ONLY,
712
- }, async () => {
849
+ }, async ({ base, limit }) => {
713
850
  try {
714
- return ok(withProvenance(await fetchJson("/api/public/settlements"), "/api/public/settlements"));
851
+ const path = "/api/public/settlements";
852
+ return ok(compact(await fetchJson(path), path, (rec, t) => {
853
+ if (base) {
854
+ const byBase = (r) => isRec(r) && String(r.base).toUpperCase() === base;
855
+ rec.upcoming = list(rec.upcoming).filter(byBase);
856
+ rec.recorded = list(rec.recorded).filter(byBase);
857
+ return;
858
+ }
859
+ const n = limit ?? 30;
860
+ rec.upcoming = t.head(rec.upcoming, n, "upcoming", "pass base for one asset, or limit up to 300");
861
+ rec.recorded = t.head(rec.recorded, n, "recorded", "pass base for one asset, or limit up to 300");
862
+ }, base ? undefined : "upcoming"));
715
863
  }
716
864
  catch (err) {
717
865
  return fail(err);
@@ -732,10 +880,11 @@ export function registerByKaranteliTools(server, options) {
732
880
  });
733
881
  server.registerTool("get_withdrawal_status", {
734
882
  title: "Withdrawal status and network fees: which exchanges paused withdrawals, per asset and network",
735
- description: "Call this when the user asks whether an exchange has paused withdrawals or deposits, which networks are open for an asset, what the withdrawal fee or minimum is on each venue, or which venue is cheapest to withdraw from. Without arguments returns the overview (withdrawals paused right now, ranked, plus recent suspension and resumption events). Pass asset (e.g. USDT) for every venue and network of that asset, and venue (e.g. kucoin) to narrow. Recorded daily by ByKaranteli from 20+ venues' public currency lists.",
883
+ description: "Call this when the user asks whether an exchange has paused withdrawals or deposits, which networks are open for an asset, what the withdrawal fee or minimum is on each venue, or which venue is cheapest to withdraw from. Without arguments returns the overview (withdrawals paused right now, ranked, plus recent suspension and resumption events). Pass asset (e.g. USDT) for every venue and network of that asset, and venue (e.g. kucoin) to narrow. Recorded daily by ByKaranteli from 20+ venues' public currency lists. The overview lists the first 30 paused networks and the 30 newest events; pass asset, or limit (up to 400) for more.",
736
884
  inputSchema: {
737
885
  asset: z.string().optional().describe("string, optional asset code, e.g. USDT"),
738
886
  venue: z.string().optional().describe("string, optional venue id, e.g. kucoin"),
887
+ limit: z.coerce.number().int().min(1).max(400).optional().describe("Optional. Rows of the overview lists (paused networks, events) without asset (default 30)."),
739
888
  },
740
889
  annotations: READ_ONLY,
741
890
  }, async (args) => {
@@ -746,7 +895,15 @@ export function registerByKaranteliTools(server, options) {
746
895
  if (args.venue)
747
896
  qs.set("venue", String(args.venue).toLowerCase());
748
897
  const q = qs.toString();
749
- return ok(withProvenance(await fetchJson(`/api/public/withdrawals${q ? `?${q}` : ""}`), `/api/public/withdrawals${q ? `?${q}` : ""}`));
898
+ const path = `/api/public/withdrawals${q ? `?${q}` : ""}`;
899
+ const data = await fetchJson(path);
900
+ if (args.asset)
901
+ return ok(withProvenance(data, path));
902
+ const n = args.limit ?? 30;
903
+ return ok(compact(data, path, (rec, t) => {
904
+ rec.suspended_rows = t.head(rec.suspended_rows, n, "suspended_rows", "pass asset for every venue and network of one asset, or limit up to 400");
905
+ rec.events = t.head(rec.events, n, "events", "pass limit up to 400");
906
+ }, "suspended_rows"));
750
907
  }
751
908
  catch (err) {
752
909
  return fail(err);
@@ -754,10 +911,11 @@ export function registerByKaranteliTools(server, options) {
754
911
  });
755
912
  server.registerTool("get_leverage_tiers", {
756
913
  title: "Leverage tiers: max leverage and maintenance margin per perpetual on every venue",
757
- description: "Call this when the user asks how much leverage an exchange allows on a coin, what the maintenance margin or risk limit ladder is, which venue offers the highest leverage for a symbol, or whether an exchange recently cut leverage. Returns the current ladder per venue (tier, notional floor and cap, max leverage, maintenance margin rate) recorded daily by ByKaranteli, plus a change log. Pass symbol for one base asset (e.g. SOL) and venue for one exchange (bybit, okx, gate, htx, bitget, mexc).",
914
+ description: "Call this when the user asks how much leverage an exchange allows on a coin, what the maintenance margin or risk limit ladder is, which venue offers the highest leverage for a symbol, or whether an exchange recently cut leverage. Returns the current ladder per venue (tier, notional floor and cap, max leverage, maintenance margin rate) recorded daily by ByKaranteli, plus a change log. Pass symbol for one base asset (e.g. SOL) and venue for one exchange (bybit, okx, gate, htx, bitget, mexc). Without symbol, the first 30 rows of each list and the first 5 tiers of each ladder; pass symbol for every tier, or limit (up to 300).",
758
915
  inputSchema: {
759
916
  symbol: z.string().optional().describe("string, optional base asset, e.g. BTC"),
760
917
  venue: z.string().optional().describe("string, optional venue id, e.g. bybit"),
918
+ limit: z.coerce.number().int().min(1).max(300).optional().describe("Optional. Rows per list without symbol (default 30)."),
761
919
  },
762
920
  annotations: READ_ONLY,
763
921
  }, async (args) => {
@@ -768,7 +926,36 @@ export function registerByKaranteliTools(server, options) {
768
926
  if (args.venue)
769
927
  qs.set("venue", String(args.venue).toLowerCase());
770
928
  const q = qs.toString();
771
- return ok(withProvenance(await fetchJson(`/api/public/leverage-tiers${q ? `?${q}` : ""}`), `/api/public/leverage-tiers${q ? `?${q}` : ""}`));
929
+ const path = `/api/public/leverage-tiers${q ? `?${q}` : ""}`;
930
+ const data = await fetchJson(path);
931
+ if (args.symbol)
932
+ return ok(withProvenance(data, path));
933
+ const n = args.limit ?? 30;
934
+ const more = "pass symbol for every tier of one base asset, or limit up to 300";
935
+ return ok(compact(data, path, (rec, t) => {
936
+ /* venue alone: one flat list of every tier of every contract on that venue (2,000 rows on Bybit). */
937
+ rec.rows = t.head(rec.rows, n, "rows", more);
938
+ rec.compare = t.head(rec.compare, n, "compare", more);
939
+ rec.changes = t.head(rec.changes, n, "changes", "pass limit up to 300");
940
+ if (isRec(rec.ladders)) {
941
+ const TIERS = 5;
942
+ let kept = 0;
943
+ let total = 0;
944
+ const ladders = {};
945
+ for (const [base, venues] of Object.entries(rec.ladders)) {
946
+ ladders[base] = list(venues).map((v) => {
947
+ if (!isRec(v))
948
+ return v;
949
+ const rows = list(v.rows);
950
+ total += rows.length;
951
+ kept += Math.min(rows.length, TIERS);
952
+ return rows.length > TIERS ? { ...v, rows: rows.slice(0, TIERS) } : v;
953
+ });
954
+ }
955
+ rec.ladders = ladders;
956
+ t.note("ladders.*[].rows", kept, total, "pass symbol for every tier of one base asset");
957
+ }
958
+ }, isRec(data) && Array.isArray(data.rows) ? "rows" : "compare"));
772
959
  }
773
960
  catch (err) {
774
961
  return fail(err);
@@ -776,10 +963,11 @@ export function registerByKaranteliTools(server, options) {
776
963
  });
777
964
  server.registerTool("get_venue_markets", {
778
965
  title: "Exchange coverage: OI, volume, funding and pegs across every exchange we snapshot",
779
- description: "Call this when the user asks about total open interest across exchanges, which venues hold the most OI, DEX versus CEX share, funding dispersion between venues, or stablecoin pegs. Returns the latest 10-minute snapshot aggregates across every perpetual and spot feed we poll (the coverage field lists them); pass symbol for one coin's per-venue rows.",
966
+ description: "Call this when the user asks about total open interest across exchanges, which venues hold the most OI, DEX versus CEX share, funding dispersion between venues, or stablecoin pegs. Returns the latest 10-minute snapshot aggregates across every perpetual and spot feed we poll (the coverage field lists them); pass symbol for one coin's per-venue rows. Without symbol, rows holds the 30 venue markets with the largest open interest; pass symbol, or limit (up to 250) for more.",
780
967
  inputSchema: {
781
968
  symbol: z.string().optional().describe("string, optional base asset, e.g. BTC"),
782
969
  history_days: z.number().int().min(1).max(90).optional().describe("Return the hourly multi-venue open interest history (total, DEX share, OI-weighted funding) for this many days instead of the snapshot"),
970
+ limit: z.coerce.number().int().min(1).max(250).optional().describe("Optional. Venue market rows without symbol, largest open interest first (default 30)."),
783
971
  },
784
972
  annotations: READ_ONLY,
785
973
  }, async (args) => {
@@ -787,7 +975,17 @@ export function registerByKaranteliTools(server, options) {
787
975
  const path = args.history_days
788
976
  ? `/api/public/venues/oi-history?days=${Math.min(90, Math.floor(args.history_days))}`
789
977
  : args.symbol ? `/api/public/venues/markets?symbol=${encodeURIComponent(args.symbol.toUpperCase())}` : "/api/public/venues/markets";
790
- return ok(withProvenance(await fetchJson(path), path));
978
+ const data = await fetchJson(path);
979
+ if (args.history_days || args.symbol)
980
+ return ok(withProvenance(data, path));
981
+ /* The route lists rows by venue; the board's own ranking is open interest, then 24h volume for the rows
982
+ * without open interest (spot). */
983
+ const n = Number(args.limit ?? 30);
984
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : -1);
985
+ return ok(compact(data, path, (rec, t) => {
986
+ const ranked = list(rec.rows).filter(isRec).sort((a, b) => num(b.open_interest_usd) - num(a.open_interest_usd) || num(b.volume_24h_usd) - num(a.volume_24h_usd));
987
+ rec.rows = t.head(ranked, n, "rows", "pass symbol for one coin's rows on every venue, or limit up to 250");
988
+ }, "rows"));
791
989
  }
792
990
  catch (err) {
793
991
  return fail(err);
@@ -795,12 +993,28 @@ export function registerByKaranteliTools(server, options) {
795
993
  });
796
994
  server.registerTool("get_lead_lag", {
797
995
  title: "Venue lead-lag: who moves first (Coinbase, Kraken, Binance)",
798
- description: "Call this when the user asks which exchange leads price discovery or whether spot or perp moves first. Returns per-pair daily cross-correlations of one-minute returns at lags -3..+3 and the lead asymmetry, with the share of days each venue led.",
799
- inputSchema: {},
996
+ description: "Call this when the user asks which exchange leads price discovery or whether spot or perp moves first. Returns per-pair daily cross-correlations of one-minute returns at lags -3..+3 and the lead asymmetry, with the share of days each venue led. Each pair's daily rows cover the last 7 days (its summary covers the whole window); pass days (up to 30) for more.",
997
+ inputSchema: {
998
+ days: z.coerce.number().int().min(1).max(30).optional().describe("Optional. Daily rows per pair, newest kept (default 7, up to 30)."),
999
+ },
800
1000
  annotations: READ_ONLY,
801
- }, async () => {
1001
+ }, async ({ days }) => {
802
1002
  try {
803
- return ok(withProvenance(await fetchJson("/api/public/venues/lead-lag"), "/api/public/venues/lead-lag"));
1003
+ const path = "/api/public/venues/lead-lag";
1004
+ return ok(compact(await fetchJson(path), path, (rec, t) => {
1005
+ const n = days ?? 7;
1006
+ let kept = 0;
1007
+ let total = 0;
1008
+ rec.pairs = list(rec.pairs).map((p) => {
1009
+ if (!isRec(p) || !Array.isArray(p.days))
1010
+ return p;
1011
+ /* Oldest day first, so the newest n are the tail. */
1012
+ total += p.days.length;
1013
+ kept += Math.min(p.days.length, n);
1014
+ return p.days.length > n ? { ...p, days: p.days.slice(-n) } : p;
1015
+ });
1016
+ t.note("pairs[].days", kept, total, "pass days up to 30");
1017
+ }));
804
1018
  }
805
1019
  catch (err) {
806
1020
  return fail(err);
@@ -860,15 +1074,65 @@ export function registerByKaranteliTools(server, options) {
860
1074
  return fail(err);
861
1075
  }
862
1076
  });
1077
+ /* Daily series boards (macro, network): a call without days reads the route's shortest window (30 days) and keeps the
1078
+ * newest 7 points of each series; a call with days gets the whole window it asked for. */
1079
+ async function dailySeries(route, days, key) {
1080
+ const wanted = days && days > 0 ? Math.floor(days) : undefined;
1081
+ const path = `${route}?days=${wanted ?? 30}`;
1082
+ const data = await fetchJson(path);
1083
+ /* series narrows the answer to one key, so a long window of one series fits where every series would not. */
1084
+ const narrowed = key && isRec(data) && isRec(data.series)
1085
+ ? (() => {
1086
+ if (!Object.hasOwn(data.series, key))
1087
+ throw new InputError(`series must be one of ${Object.keys(data.series).join(", ")}.`);
1088
+ return { ...data, series: { [key]: data.series[key] } };
1089
+ })()
1090
+ : data;
1091
+ if (wanted) {
1092
+ const answer = withProvenance(narrowed, path);
1093
+ /* Every series over a long window can pass the ceiling (macro, 365 days: 146k characters); name the keys that
1094
+ * fit instead of the generic ceiling note. */
1095
+ if (!key && isRec(data) && isRec(data.series) && JSON.stringify(answer).length > MAX_RESULT_CHARS) {
1096
+ const keys = Object.keys(data.series);
1097
+ return ok({
1098
+ truncated: true,
1099
+ limit: MAX_RESULT_CHARS,
1100
+ note: `Every series over ${wanted} days does not fit one answer. Pass series (one of series_keys) for one key over this window, or fewer days for all of them.`,
1101
+ series_keys: keys,
1102
+ ...(isRec(data.latest) ? { latest: data.latest } : {}),
1103
+ });
1104
+ }
1105
+ return ok(answer);
1106
+ }
1107
+ return ok(compact(narrowed, path, (rec, t) => {
1108
+ if (!isRec(rec.series))
1109
+ return;
1110
+ const POINTS = 7;
1111
+ let kept = 0;
1112
+ let total = 0;
1113
+ const series = {};
1114
+ for (const [key, points] of Object.entries(rec.series)) {
1115
+ const all = list(points);
1116
+ total += all.length;
1117
+ kept += Math.min(all.length, POINTS);
1118
+ /* Oldest date first, so the newest points are the tail. */
1119
+ series[key] = all.length > POINTS ? all.slice(-POINTS) : points;
1120
+ }
1121
+ rec.series = series;
1122
+ t.note("series.*", kept, total, "pass days (30 to 4000) for the daily series, and series for one key over a long window");
1123
+ }));
1124
+ }
863
1125
  server.registerTool("get_macro_liquidity", {
864
1126
  title: "Macro liquidity: Fed funds, 10y, balance sheet, RRP, stablecoin supply",
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.",
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.") },
1127
+ 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. Without days, each series holds its newest 7 points; pass days for the daily series, and series for one key over a long window.",
1128
+ inputSchema: {
1129
+ days: z.number().int().min(30).max(4000).optional().describe("Window in days, 30-4000; the route clamps to that range. Omit for the newest 7 points of each series."),
1130
+ series: z.string().trim().regex(/^[a-z0-9_]{2,60}$/).optional().describe("Optional. One series key from the answer's series field, e.g. fred_walcl_busd."),
1131
+ },
867
1132
  annotations: READ_ONLY,
868
1133
  }, async (args) => {
869
1134
  try {
870
- const path = `/api/public/macro?days=${args.days && args.days > 0 ? Math.floor(args.days) : 365}`;
871
- return ok(withProvenance(await fetchJson(path), path));
1135
+ return await dailySeries("/api/public/macro", args.days, args.series);
872
1136
  }
873
1137
  catch (err) {
874
1138
  return fail(err);
@@ -876,13 +1140,15 @@ export function registerByKaranteliTools(server, options) {
876
1140
  });
877
1141
  server.registerTool("get_network_health", {
878
1142
  title: "Bitcoin network health from our own node",
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.",
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.") },
1143
+ 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. Without days, each series holds its newest 7 points; pass days for the daily series, and series for one key over a long window.",
1144
+ inputSchema: {
1145
+ days: z.number().int().min(30).max(4000).optional().describe("Window in days, 30-4000; the route clamps to that range. Omit for the newest 7 points of each series."),
1146
+ series: z.string().trim().regex(/^[a-z0-9_]{2,60}$/).optional().describe("Optional. One series key from the answer's series field, e.g. btc_hashrate_ehs."),
1147
+ },
881
1148
  annotations: READ_ONLY,
882
1149
  }, async (args) => {
883
1150
  try {
884
- const path = `/api/public/network?days=${args.days && args.days > 0 ? Math.floor(args.days) : 365}`;
885
- return ok(withProvenance(await fetchJson(path), path));
1151
+ return await dailySeries("/api/public/network", args.days, args.series);
886
1152
  }
887
1153
  catch (err) {
888
1154
  return fail(err);
@@ -890,7 +1156,7 @@ export function registerByKaranteliTools(server, options) {
890
1156
  });
891
1157
  server.registerTool("get_liqmap", {
892
1158
  title: "LiqMap: estimated liquidation clusters with real prints overlaid",
893
- description: "Call this when the user asks where liquidation clusters or liquidity pools sit for a perpetual, where leveraged longs/shorts would get liquidated, or for a liquidation heatmap reading. Returns the LiqMap snapshot for one symbol: modeled liquidation levels by price, zone aggregates and real liquidation prints from every liquidation venue ByKaranteli counts (listed on bykaranteli.com/coverage). Without an account key (or on the Free plan) the 24h view; with a Builder or higher key (BYKARANTELI_API_KEY) every timeframe from 1h to 30d.",
1159
+ description: "Call this when the user asks where liquidation clusters or liquidity pools sit for a perpetual, where leveraged longs/shorts would get liquidated, or for a liquidation heatmap reading. Returns the LiqMap snapshot for one symbol: modeled liquidation levels by price, zone aggregates and real liquidation prints from every liquidation venue ByKaranteli counts (listed on bykaranteli.com/coverage). On the Free plan the 24h view; on Builder and higher plans every timeframe from 1h to 30d. By default the 30 real levels nearest the price and the 30 newest real prints; pass detail=true for all of them.",
894
1160
  inputSchema: {
895
1161
  symbol: z
896
1162
  .string()
@@ -902,17 +1168,35 @@ export function registerByKaranteliTools(server, options) {
902
1168
  timeframe: z
903
1169
  .enum(["1h", "4h", "12h", "24h", "3d", "1w", "30d"])
904
1170
  .optional()
905
- .describe("Model window. Default 24h, the only one served without a Builder or higher key."),
1171
+ .describe("Model window. Default 24h, the only one on the Free plan; Builder and higher plans get every window."),
1172
+ detail: z.boolean().optional().describe("Optional. true: every real level and every real print (default false: the 30 nearest levels, the 30 newest prints)."),
906
1173
  },
907
1174
  annotations: READ_ONLY,
908
- }, async ({ symbol, timeframe }) => {
1175
+ }, async ({ symbol, timeframe, detail }) => {
909
1176
  try {
910
1177
  const raw = symbol ?? "BTCUSDT";
911
1178
  const sym = raw.endsWith("USDT") ? raw : `${raw}USDT`;
912
1179
  const path = `/api/liqmap/public?symbol=${encodeURIComponent(sym)}&timeframe=${encodeURIComponent(timeframe ?? "24h")}`;
913
1180
  const data = await fetchJson(path);
914
1181
  const page = `/liqmap/${sym.replace(/USDT$/, "").toLowerCase()}`;
915
- return ok({ ...data, source: `${PUBLIC_URL}${page}`, provenance: provenanceFor(data, path, page) });
1182
+ const full = { ...data, source: `${PUBLIC_URL}${page}`, provenance: provenanceFor(data, path, page) };
1183
+ if (detail)
1184
+ return ok(full);
1185
+ const N = 30;
1186
+ return ok(trimmedAnswer(full, (rec, t) => {
1187
+ if (isRec(rec.real_levels) && Array.isArray(rec.real_levels.levels) && rec.real_levels.levels.length > N) {
1188
+ /* The levels nearest the price, kept in the route's price order. */
1189
+ const levels = rec.real_levels.levels;
1190
+ const dist = (l) => (isRec(l) && typeof l.distance_pct === "number" ? Math.abs(l.distance_pct) : Infinity);
1191
+ const nearest = new Set([...levels].sort((a, b) => dist(a) - dist(b)).slice(0, N));
1192
+ rec.real_levels = { ...rec.real_levels, levels: levels.filter((l) => nearest.has(l)) };
1193
+ t.note("real_levels.levels", N, levels.length, "pass detail=true for every level");
1194
+ }
1195
+ if (isRec(rec.real_liquidations)) {
1196
+ /* Newest print first. */
1197
+ rec.real_liquidations = { ...rec.real_liquidations, points: t.head(rec.real_liquidations.points, N, "real_liquidations.points", "pass detail=true for every print") };
1198
+ }
1199
+ }));
916
1200
  }
917
1201
  catch (err) {
918
1202
  return fail(err);
@@ -920,7 +1204,7 @@ export function registerByKaranteliTools(server, options) {
920
1204
  });
921
1205
  server.registerTool("get_tradfi_board", {
922
1206
  title: "TradFi perpetuals: stock, index and commodity perps on Binance",
923
- description: "Call this when the user asks about stock perpetuals (TSLA, NVDA, AAPL, gold, S&P 500...), tokenized-equity perps, TradFi perp funding rates, open interest, liquidations, which exchanges list a stock perp, or whether the equity session is open. Returns Binance's TradFi perpetual board: per contract mark, index, basis, funding, 24h change and volume, open interest, 24h recorded liquidations, other venues listing the same underlying, and the trading-session state per market. Filter by market (EQUITY, HK_EQUITY, KR_EQUITY, CN_EQUITY, COMMODITY, INDEX, PREMARKET) or one symbol.",
1207
+ description: "Call this when the user asks about stock perpetuals (TSLA, NVDA, AAPL, gold, S&P 500...), tokenized-equity perps, TradFi perp funding rates, open interest, liquidations, which exchanges list a stock perp, or whether the equity session is open. Returns Binance's TradFi perpetual board: per contract mark, index, basis, funding, 24h change and volume, open interest, 24h recorded liquidations, other venues listing the same underlying, and the trading-session state per market. Filter by market (EQUITY, HK_EQUITY, KR_EQUITY, CN_EQUITY, COMMODITY, INDEX, PREMARKET) or one symbol. Without symbol, the first 30 contracts by open interest; pass symbol, or limit (up to 150) for more.",
924
1208
  inputSchema: {
925
1209
  market: z
926
1210
  .string()
@@ -936,9 +1220,10 @@ export function registerByKaranteliTools(server, options) {
936
1220
  .regex(/^[A-Z0-9]{2,24}$/)
937
1221
  .optional()
938
1222
  .describe("One Binance TradFi symbol, e.g. TSLAUSDT"),
1223
+ limit: z.coerce.number().int().min(1).max(150).optional().describe("Optional. Contracts without symbol, largest open interest first (default 30)."),
939
1224
  },
940
1225
  annotations: READ_ONLY,
941
- }, async ({ market, symbol }) => {
1226
+ }, async ({ market, symbol, limit }) => {
942
1227
  try {
943
1228
  const params = new URLSearchParams();
944
1229
  if (market)
@@ -947,7 +1232,12 @@ export function registerByKaranteliTools(server, options) {
947
1232
  params.set("symbol", symbol);
948
1233
  const qs = params.toString();
949
1234
  const path = `/api/public/tradfi${qs ? `?${qs}` : ""}`;
950
- return ok(withProvenance(await fetchJson(path), path));
1235
+ const data = await fetchJson(path);
1236
+ if (symbol)
1237
+ return ok(withProvenance(data, path));
1238
+ return ok(compact(data, path, (rec, t) => {
1239
+ rec.rows = t.head(rec.rows, limit ?? 30, "rows", "pass symbol, market, or limit up to 150");
1240
+ }, "rows"));
951
1241
  }
952
1242
  catch (err) {
953
1243
  return fail(err);
@@ -955,14 +1245,16 @@ export function registerByKaranteliTools(server, options) {
955
1245
  });
956
1246
  server.registerTool("get_rsi_heatmap", {
957
1247
  title: "RSI heatmap: Wilder RSI(14) on eight timeframes for the top-400 crypto perps and every TradFi perp",
958
- description: "Call this when the user asks which coins are overbought or oversold, for a crypto RSI heatmap, multi-timeframe RSI, or one contract's RSI on 15m, 1h, 4h, 12h, 1d, 3d, 1w or 1M. Returns the live board for the top-400 Binance crypto perps by volume plus every TradFi perp, with overbought/oversold counts per interval. Filter by symbol or kind (crypto|tradfi), sort by an interval.",
1248
+ description: "Call this when the user asks which coins are overbought or oversold, for a crypto RSI heatmap, multi-timeframe RSI, or one contract's RSI on 15m, 1h, 4h, 12h, 1d, 3d, 1w or 1M. Returns the live board for the top-400 Binance crypto perps by volume plus every TradFi perp, with overbought/oversold counts per interval. Filter by symbol or kind (crypto|tradfi), sort by an interval. Without symbol, the first 30 rows (by volume, or by the sort interval); pass limit (up to 400) for more, or order asc with sort for the most oversold first.",
959
1249
  inputSchema: {
960
1250
  symbol: z.string().trim().toUpperCase().regex(/^[A-Z0-9]{2,24}$/).optional().describe("One Binance symbol, e.g. BTCUSDT"),
961
1251
  kind: z.enum(["crypto", "tradfi"]).optional().describe("crypto | tradfi"),
962
1252
  sort: z.enum(["15m", "1h", "4h", "12h", "1d", "3d", "1w", "1M"]).optional().describe("Interval to sort by, descending"),
1253
+ order: z.enum(["desc", "asc"]).optional().describe("Optional. With sort: desc (default, most overbought first) or asc (most oversold first)."),
1254
+ limit: z.coerce.number().int().min(1).max(400).optional().describe("Optional. Rows without symbol (default 30)."),
963
1255
  },
964
1256
  annotations: READ_ONLY,
965
- }, async ({ symbol, kind, sort }) => {
1257
+ }, async ({ symbol, kind, sort, order, limit }) => {
966
1258
  try {
967
1259
  const params = new URLSearchParams();
968
1260
  if (symbol)
@@ -973,7 +1265,18 @@ export function registerByKaranteliTools(server, options) {
973
1265
  params.set("sort", sort);
974
1266
  const qs = params.toString();
975
1267
  const path = `/api/public/rsi${qs ? `?${qs}` : ""}`;
976
- return ok(withProvenance(await fetchJson(path), path));
1268
+ const data = await fetchJson(path);
1269
+ if (symbol)
1270
+ return ok(withProvenance(data, path));
1271
+ return ok(compact(data, path, (rec, t) => {
1272
+ let rows = list(rec.rows);
1273
+ if (sort && order === "asc") {
1274
+ /* The route sorts descending; reversed, rows without a reading on that interval stay last. */
1275
+ const has = (r) => isRec(r) && isRec(r.rsi) && typeof r.rsi[sort] === "number";
1276
+ rows = [...rows.filter(has).reverse(), ...rows.filter((r) => !has(r))];
1277
+ }
1278
+ rec.rows = t.head(rows, limit ?? 30, "rows", "pass symbol, kind, or limit up to 400 (with sort and order asc for the lowest readings)");
1279
+ }, "rows"));
977
1280
  }
978
1281
  catch (err) {
979
1282
  return fail(err);
@@ -998,13 +1301,14 @@ export function registerByKaranteliTools(server, options) {
998
1301
  });
999
1302
  server.registerTool("get_hl_whales", {
1000
1303
  title: "Hyperliquid whale tracker: top-300 accounts, long vs short, positions, changes",
1001
- description: "Call this when the user asks what Hyperliquid whales are doing, whether the biggest Hyperliquid accounts are net long or short a coin, for the largest open positions with liquidation prices, or what large accounts just opened, closed or flipped. Returns the live board of the 300 largest accounts by equity (scanned every 5 minutes, addresses only) and with events the last 200 position changes.",
1304
+ description: "Call this when the user asks what Hyperliquid whales are doing, whether the biggest Hyperliquid accounts are net long or short a coin, for the largest open positions with liquidation prices, or what large accounts just opened, closed or flipped. Returns the live board of the 300 largest accounts by equity (scanned every 5 minutes, addresses only) and with events the last 200 position changes. Without coin, the 30 largest coins and positions; pass coin, or limit (up to 200) for more.",
1002
1305
  inputSchema: {
1003
1306
  coin: z.string().trim().toUpperCase().regex(/^[A-Z0-9]{1,24}$/).optional().describe("One coin, e.g. BTC"),
1004
1307
  events: z.boolean().optional().describe("Include the last 200 position change events"),
1308
+ limit: z.coerce.number().int().min(1).max(200).optional().describe("Optional. Coins and positions without coin, largest first (default 30)."),
1005
1309
  },
1006
1310
  annotations: READ_ONLY,
1007
- }, async ({ coin, events }) => {
1311
+ }, async ({ coin, events, limit }) => {
1008
1312
  try {
1009
1313
  const params = new URLSearchParams();
1010
1314
  if (coin)
@@ -1013,7 +1317,13 @@ export function registerByKaranteliTools(server, options) {
1013
1317
  params.set("events", "1");
1014
1318
  const qs = params.toString();
1015
1319
  const path = `/api/public/hyperliquid-whales${qs ? `?${qs}` : ""}`;
1016
- return ok(withProvenance(await fetchJson(path), path));
1320
+ const data = await fetchJson(path);
1321
+ if (coin)
1322
+ return ok(withProvenance(data, path));
1323
+ return ok(compact(data, path, (rec, t) => {
1324
+ rec.by_coin = t.head(rec.by_coin, limit ?? 30, "by_coin", "pass coin, or limit up to 200");
1325
+ rec.positions = t.head(rec.positions, limit ?? 30, "positions", "pass coin, or limit up to 200");
1326
+ }, "positions"));
1017
1327
  }
1018
1328
  catch (err) {
1019
1329
  return fail(err);
@@ -1021,15 +1331,47 @@ export function registerByKaranteliTools(server, options) {
1021
1331
  });
1022
1332
  server.registerTool("get_positioning", {
1023
1333
  title: "Positioning: long/short ratios, taker buy/sell and CVD across exchanges",
1024
- description: "Call this when the user asks about the long/short ratio, whether retail or top traders are net long or short, the taker buy/sell ratio, or CVD (cumulative volume delta) for a perpetual. Returns exchange-published statistics for the 30 most traded Binance USDT perps on every perpetual venue the positioning board records (Binance global and top-trader ratios, Bybit share long, OKX ratios and taker volume, Gate account and top-trader ratios, HTX elite ratios, Bitget account and position ratios) and CVD series for BTC, ETH and SOL; refreshed every 15 minutes.",
1334
+ description: "Call this when the user asks about the long/short ratio, whether retail or top traders are net long or short, the taker buy/sell ratio, or CVD (cumulative volume delta) for a perpetual. Returns exchange-published statistics for the 30 most traded Binance USDT perps on every perpetual venue the positioning board records (Binance global and top-trader ratios, Bybit share long, OKX ratios and taker volume, Gate account and top-trader ratios, HTX elite ratios, Bitget account and position ratios) and CVD series for BTC, ETH and SOL; refreshed every 15 minutes. Without symbol, the 10 most traded perps and the newest 24 points of each CVD series; pass symbol, limit or cvd_points for more.",
1025
1335
  inputSchema: {
1026
1336
  symbol: z.string().trim().toUpperCase().regex(/^[A-Z0-9]{2,24}$/).optional().describe("One Binance symbol, e.g. BTCUSDT"),
1337
+ limit: z.coerce.number().int().min(1).max(30).optional().describe("Optional. Perps without symbol, most traded first (default 10, up to 30)."),
1338
+ cvd_points: z.coerce.number().int().min(1).max(288).optional().describe("Optional. Newest points per CVD series (1h and 5m); default 24 without symbol, every point with one."),
1027
1339
  },
1028
1340
  annotations: READ_ONLY,
1029
- }, async ({ symbol }) => {
1341
+ }, async ({ symbol, limit, cvd_points }) => {
1030
1342
  try {
1031
1343
  const path = `/api/public/positioning${symbol ? `?symbol=${encodeURIComponent(symbol)}` : ""}`;
1032
- return ok(withProvenance(await fetchJson(path), path));
1344
+ const data = await fetchJson(path);
1345
+ const points = cvd_points ?? (symbol ? undefined : 24);
1346
+ if (symbol && points === undefined)
1347
+ return ok(withProvenance(data, path));
1348
+ return ok(compact(data, path, (rec, t) => {
1349
+ if (!symbol)
1350
+ rec.rows = t.head(rec.rows, limit ?? 10, "rows", "pass symbol, or limit up to 30");
1351
+ if (points !== undefined && isRec(rec.cvd)) {
1352
+ const kept = {};
1353
+ const total = {};
1354
+ const cvd = {};
1355
+ for (const [sym, intervals] of Object.entries(rec.cvd)) {
1356
+ if (!isRec(intervals)) {
1357
+ cvd[sym] = intervals;
1358
+ continue;
1359
+ }
1360
+ const out = {};
1361
+ for (const [interval, series] of Object.entries(intervals)) {
1362
+ const all = list(series);
1363
+ total[interval] = (total[interval] ?? 0) + all.length;
1364
+ kept[interval] = (kept[interval] ?? 0) + Math.min(all.length, points);
1365
+ /* Oldest point first, so the newest are the tail. */
1366
+ out[interval] = all.length > points ? all.slice(-points) : series;
1367
+ }
1368
+ cvd[sym] = out;
1369
+ }
1370
+ rec.cvd = cvd;
1371
+ for (const interval of Object.keys(total))
1372
+ t.note(`cvd.*.${interval}`, kept[interval], total[interval], "pass symbol for its full CVD, or cvd_points up to 288");
1373
+ }
1374
+ }, symbol ? undefined : "rows"));
1033
1375
  }
1034
1376
  catch (err) {
1035
1377
  return fail(err);
@@ -1037,13 +1379,14 @@ export function registerByKaranteliTools(server, options) {
1037
1379
  });
1038
1380
  server.registerTool("get_turkey_premium", {
1039
1381
  title: "Turkey Premium Index: what lira buyers pay above the world price, with TRY reference prices from Turkish venues",
1040
- description: "Call this when the user asks about Bitcoin, Ether or USDT prices in Turkish lira, the Turkey premium, the USDT/TRY rate or dollar premium in Turkey, or which Turkish exchanges (BtcTurk, Bitlo, CoinTR, OKX TR, Binance TR, Bybit TR, Bitexen; KuCoin TR contributes the USDT pairs only) trade above or below the global price. Returns the live board: the Turkey Premium Index (what a lira buyer pays for bitcoin against the global dollar price at the official exchange rate, in bps) with its dollar leg and crypto leg, a 0-100 score (50 = world price) and regime, 24h and 7d averages and the same-sign streak; then five reference prices (median of eligible order books), per-venue book status, spread, depth and each venue's implied premium. Pass pair and history_days for 15-minute history of one pair.",
1382
+ description: "Call this when the user asks about Bitcoin, Ether or USDT prices in Turkish lira, the Turkey premium, the USDT/TRY rate or dollar premium in Turkey, or which Turkish exchanges (BtcTurk, Bitlo, CoinTR, OKX TR, Binance TR, Bybit TR, Bitexen; KuCoin TR contributes the USDT pairs only) trade above or below the global price. Returns the live board: the Turkey Premium Index (what a lira buyer pays for bitcoin against the global dollar price at the official exchange rate, in bps) with its dollar leg and crypto leg, a 0-100 score (50 = world price) and regime, 24h and 7d averages and the same-sign streak; then five reference prices (median of eligible order books), per-venue book status, spread, depth and each venue's implied premium. Pass pair and history_days for 15-minute history of one pair. Without pair, the daily rows cover the last 7 days; pass pair, or daily_days (up to 30) for more.",
1041
1383
  inputSchema: {
1042
1384
  pair: z.string().trim().toUpperCase().regex(/^(BTC-TRY|ETH-TRY|USDT-TRY|BTC-USDT|ETH-USDT)$/).optional().describe("Pair: BTC-TRY, ETH-TRY, USDT-TRY, BTC-USDT or ETH-USDT"),
1043
1385
  history_days: z.number().int().min(1).max(30).optional().describe("Include 15-minute index history for the pair, 1..30 days"),
1386
+ daily_days: z.coerce.number().int().min(1).max(30).optional().describe("Optional. Days of daily rows, newest first (default 7 without pair, every recorded day with one)."),
1044
1387
  },
1045
1388
  annotations: READ_ONLY,
1046
- }, async ({ pair, history_days }) => {
1389
+ }, async ({ pair, history_days, daily_days }) => {
1047
1390
  try {
1048
1391
  const q = new URLSearchParams();
1049
1392
  if (pair)
@@ -1052,7 +1395,17 @@ export function registerByKaranteliTools(server, options) {
1052
1395
  q.set("days", String(history_days));
1053
1396
  const qs = q.toString();
1054
1397
  const path = `/api/public/turkey-premium${qs ? `?${qs}` : ""}`;
1055
- return ok(withProvenance(await fetchJson(path), path));
1398
+ const data = await fetchJson(path);
1399
+ const wantDays = daily_days ?? (pair ? undefined : 7);
1400
+ if (wantDays === undefined)
1401
+ return ok(withProvenance(data, path));
1402
+ return ok(compact(data, path, (rec, t) => {
1403
+ const daily = list(rec.daily);
1404
+ const keep = newestDates(daily, wantDays);
1405
+ const kept = daily.filter((r) => isRec(r) && keep.has(String(r.date)));
1406
+ t.note("daily", kept.length, daily.length, "pass pair, or daily_days up to 30");
1407
+ rec.daily = kept;
1408
+ }));
1056
1409
  }
1057
1410
  catch (err) {
1058
1411
  return fail(err);
@@ -1135,12 +1488,12 @@ export function registerByKaranteliTools(server, options) {
1135
1488
  });
1136
1489
  server.registerTool("get_options_chain", {
1137
1490
  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.",
1491
+ 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. The Free plan lists the largest strikes; Builder and higher plans list every strike. Recorded from 2026-09-30, so the first days carry a short history.",
1139
1492
  inputSchema: {
1140
1493
  currency: z.enum(["BTC", "ETH"]).optional().describe("BTC or ETH (default BTC)"),
1141
1494
  venue: z.enum(["all", "deribit", "bybit", "binance", "okx", "delta"]).optional().describe("One venue or all (default all)"),
1142
1495
  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)"),
1496
+ strikes: z.enum(["top", "all"]).optional().describe("top (largest strikes) or all (Builder and higher plans)"),
1144
1497
  },
1145
1498
  annotations: READ_ONLY,
1146
1499
  }, async ({ currency, venue, expiry, strikes }) => {
@@ -1207,13 +1560,14 @@ export function registerByKaranteliTools(server, options) {
1207
1560
  });
1208
1561
  server.registerTool("get_tradfi_gaps", {
1209
1562
  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.",
1563
+ 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. Without symbol, the first 15 underlyings of the summary and of the latest window; pass symbol, or limit (up to 60) for more.",
1211
1564
  inputSchema: {
1212
1565
  window: z.enum(["weekend", "night"]).optional().describe("weekend (Friday close to Monday open) or night (cash close to next open); default weekend"),
1213
1566
  symbol: z.string().trim().toUpperCase().regex(/^[A-Z0-9.]{1,24}$/).optional().describe("One underlying, e.g. TSLA or XAU (default: the whole board)"),
1567
+ limit: z.coerce.number().int().min(1).max(60).optional().describe("Optional. Underlyings without symbol, in the board's order, for the summary and the latest window (default 15, up to 60)."),
1214
1568
  },
1215
1569
  annotations: READ_ONLY,
1216
- }, async ({ window, symbol }) => {
1570
+ }, async ({ window, symbol, limit }) => {
1217
1571
  try {
1218
1572
  const q = new URLSearchParams();
1219
1573
  if (window !== undefined)
@@ -1222,7 +1576,15 @@ export function registerByKaranteliTools(server, options) {
1222
1576
  q.set("symbol", String(symbol));
1223
1577
  const qs = q.toString();
1224
1578
  const path = `/api/public/tradfi-gaps${qs ? `?${qs}` : ""}`;
1225
- return ok(withProvenance(await fetchJson(path), path));
1579
+ const data = await fetchJson(path);
1580
+ if (symbol !== undefined)
1581
+ return ok(withProvenance(data, path));
1582
+ const n = limit ?? 15;
1583
+ return ok(compact(data, path, (rec, t) => {
1584
+ rec.summary = t.head(rec.summary, n, "summary", "pass symbol for every window of one underlying, or limit up to 60");
1585
+ if (isRec(rec.latest))
1586
+ rec.latest = { ...rec.latest, symbols: t.head(rec.latest.symbols, n, "latest.symbols", "pass symbol for every window of one underlying, or limit up to 60") };
1587
+ }, "summary"));
1226
1588
  }
1227
1589
  catch (err) {
1228
1590
  return fail(err);
@@ -1230,16 +1592,17 @@ export function registerByKaranteliTools(server, options) {
1230
1592
  });
1231
1593
  server.registerTool("get_solana_perps", {
1232
1594
  title: "Solana Perps board: open interest, 24h volume and hourly rates across six Solana perpetual venues (Jupiter, Pacifica, Phoenix, GM Trade, Velocity, Bullet)",
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).",
1595
+ 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 on the Free plan, 30 on Builder and higher plans; Jupiter history is get_jupiter_perps). Without venue, the 30 largest markets; pass venue, or limit (up to 300) for more.",
1234
1596
  inputSchema: {
1235
1597
  venue: z.string().trim().toLowerCase().regex(/^(jupiter|pacifica|phoenix|gmtrade|velocity|bullet)$/).optional().describe("Venue filter: jupiter, pacifica, phoenix, gmtrade, velocity or bullet"),
1236
1598
  /* Passed as typed: Pacifica and Phoenix list mixed-case markets (kBONK, kPEPE, kSHIB) and the route matches the
1237
1599
  symbol case-insensitively, so upper-casing here would only change what the caller sees echoed back. */
1238
1600
  symbol: z.string().trim().regex(/^[A-Za-z0-9_.-]{1,24}$/).optional().describe("Market symbol on the chosen venue (Pacifica when venue is not given) for hourly history, as the board lists it, e.g. SOL, SOL-PERP, SOL-USD, kBONK"),
1239
1601
  history_days: z.number().int().min(1).max(30).optional().describe("Hourly history for the symbol, 1..30 days"),
1602
+ limit: z.coerce.number().int().min(1).max(300).optional().describe("Optional. Markets without venue, largest first (default 30)."),
1240
1603
  },
1241
1604
  annotations: READ_ONLY,
1242
- }, async ({ venue, symbol, history_days }) => {
1605
+ }, async ({ venue, symbol, history_days, limit }) => {
1243
1606
  try {
1244
1607
  const q = new URLSearchParams();
1245
1608
  if (venue)
@@ -1250,7 +1613,12 @@ export function registerByKaranteliTools(server, options) {
1250
1613
  q.set("days", String(history_days));
1251
1614
  const qs = q.toString();
1252
1615
  const path = `/api/public/solana-perps${qs ? `?${qs}` : ""}`;
1253
- return ok(withProvenance(await fetchJson(path), path));
1616
+ const data = await fetchJson(path);
1617
+ if (venue)
1618
+ return ok(withProvenance(data, path));
1619
+ return ok(compact(data, path, (rec, t) => {
1620
+ rec.markets = t.head(rec.markets, limit ?? 30, "markets", "pass venue for every market of one venue, or limit up to 300");
1621
+ }, "markets"));
1254
1622
  }
1255
1623
  catch (err) {
1256
1624
  return fail(err);
@@ -1258,13 +1626,38 @@ export function registerByKaranteliTools(server, options) {
1258
1626
  });
1259
1627
  server.registerTool("get_coverage", {
1260
1628
  title: "Coverage registry: which venues and data types we collect, how, and how fresh",
1261
- description: "Call this when the user asks which exchanges sit behind a ByKaranteli number, whether a feed is complete or sampled, since when a venue is collected, or how fresh the data is. Returns the live coverage registry: liquidation feeds per venue with kind and last record, snapshot feeds per venue and market, funding arbitrage legs, positioning sources, whale tape, spot minutes and the Hyperliquid whale scan with freshness. snapshots[].country is the jurisdiction only when the venue states one; it is null for most venues, so do not read null as unknown risk.",
1262
- inputSchema: {},
1629
+ description: "Call this when the user asks which exchanges sit behind a ByKaranteli number, whether a feed is complete or sampled, since when a venue is collected, or how fresh the data is. Returns the live coverage registry: liquidation feeds per venue with kind and last record, snapshot feeds per venue and market, funding arbitrage legs, positioning sources, whale tape, spot minutes and the Hyperliquid whale scan with freshness. snapshots[].country is the jurisdiction only when the venue states one; it is null for most venues, so do not read null as unknown risk. By default the first 30 snapshot feeds, English notes only and no per-venue matrix cells; pass detail=true for the full registry.",
1630
+ inputSchema: {
1631
+ detail: z.boolean().optional().describe("Optional. true: every snapshot feed, the zh, ru and ko notes and the venue by product matrix cells (default false)."),
1632
+ },
1263
1633
  annotations: READ_ONLY,
1264
- }, async () => {
1634
+ }, async ({ detail }) => {
1265
1635
  try {
1266
1636
  const path = "/api/public/coverage";
1267
- return ok(withProvenance(await fetchJson(path), path));
1637
+ const data = await fetchJson(path);
1638
+ if (detail)
1639
+ return ok(withProvenance(data, path));
1640
+ const more = "pass detail=true for the full registry";
1641
+ return ok(compact(data, path, (rec, t) => {
1642
+ rec.snapshots = t.head(rec.snapshots, 30, "snapshots", more);
1643
+ const liq = list(rec.liquidations);
1644
+ let translated = 0;
1645
+ rec.liquidations = liq.map((r) => {
1646
+ if (!isRec(r))
1647
+ return r;
1648
+ /* note_en stays; the zh, ru and ko notes translate it for the site's other locales. */
1649
+ const { note_zh, note_ru, note_ko, ...rest } = r;
1650
+ if (note_zh !== undefined || note_ru !== undefined || note_ko !== undefined)
1651
+ translated += 1;
1652
+ return rest;
1653
+ });
1654
+ t.note("liquidations[].note_zh, note_ru, note_ko", 0, translated, more);
1655
+ if (isRec(rec.matrix) && isRec(rec.matrix.cells)) {
1656
+ const products = Object.keys(rec.matrix.cells).length;
1657
+ rec.matrix = { ...rec.matrix, cells: {} };
1658
+ t.note("matrix.cells", 0, products, `${more}; matrix.products already lists each product's venues`);
1659
+ }
1660
+ }));
1268
1661
  }
1269
1662
  catch (err) {
1270
1663
  return fail(err);
@@ -1390,7 +1783,7 @@ export function registerByKaranteliTools(server, options) {
1390
1783
  /* ---- 0.31.0: recorded series, alert text, alert recipes, watchlists ---- */
1391
1784
  server.registerTool("get_series", {
1392
1785
  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 (one venue, or OI-weighted across every venue as funding_agg), 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 bars under 15 minutes (1m, 3m, 5m, 10m); a Builder key and above get member depth; the bar limits are in the same list. The answer names the venue it was read from (venue) and every venue a market-wide series sums (venues). Pass from and to (ISO) for a window, or limit for the newest bars.`,
1786
+ 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 (one venue, or OI-weighted across every venue as funding_agg), 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). The Free plan gets public depth: fewer bars and no bars under 15 minutes (1m, 3m, 5m, 10m); Builder and higher plans get member depth; the bar limits are in the same list. The answer names the venue it was read from (venue) and every venue a market-wide series sums (venues). Without from or limit, the newest 300 bars; pass from and to (ISO) for a window, or limit for more of the newest bars.`,
1394
1787
  inputSchema: {
1395
1788
  metric: z.string().describe("string, metric key, e.g. price, oi, funding, liquidations (list: /api/series/metrics)"),
1396
1789
  symbol: z.string().optional().describe("string, optional Binance USDT-M perp or coin, e.g. BTCUSDT or BTC (default BTCUSDT)"),
@@ -1398,7 +1791,7 @@ export function registerByKaranteliTools(server, options) {
1398
1791
  venue: z.string().optional().describe("string, optional venue id for price, oi, funding or borrow, e.g. okx (default binance)"),
1399
1792
  from: z.string().optional().describe("string, optional ISO start, e.g. 2026-09-01T00:00:00Z"),
1400
1793
  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`),
1794
+ limit: z.number().optional().describe(`number, optional newest bars, 10..${SERIES_MEMBER_BAR_LIMIT}; the plan's depth caps it (default 300 without from)`),
1402
1795
  },
1403
1796
  annotations: READ_ONLY,
1404
1797
  }, async (args = {}) => {
@@ -1420,6 +1813,9 @@ export function registerByKaranteliTools(server, options) {
1420
1813
  const bars = from ? Math.ceil((end.getTime() - from.getTime()) / PERIOD_MS[period]) : undefined;
1421
1814
  if (bars !== undefined)
1422
1815
  limit = Math.min(limit ?? bars, bars);
1816
+ /* 0.35.1: the newest 300 bars when neither from nor limit is given (the route gives members 1000 by default). */
1817
+ if (limit === undefined)
1818
+ limit = 300;
1423
1819
  if (limit !== undefined)
1424
1820
  limit = Math.max(10, Math.min(SERIES_MEMBER_BAR_LIMIT, limit));
1425
1821
  const q = new URLSearchParams({ metric, symbol, period });
@@ -1450,7 +1846,7 @@ export function registerByKaranteliTools(server, options) {
1450
1846
  });
1451
1847
  server.registerTool("list_alert_recipes", {
1452
1848
  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.",
1849
+ 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 a signed-in account (OAuth sign-in or account key); reads nothing but this account.",
1454
1850
  inputSchema: {},
1455
1851
  annotations: ACCOUNT_READ,
1456
1852
  }, async () => {
@@ -1459,7 +1855,7 @@ export function registerByKaranteliTools(server, options) {
1459
1855
  });
1460
1856
  server.registerTool("create_alert_recipe", {
1461
1857
  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.`,
1858
+ 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 connection (sign-in or key) and each one is recorded on the account.`,
1463
1859
  inputSchema: {
1464
1860
  field: z.string().optional().describe("string, one condition's metric, e.g. funding_rate_pct"),
1465
1861
  op: z.enum(RECIPE_OPS).optional().describe(`string, one condition's comparison: ${RECIPE_OPS.join(" | ")}`),
@@ -1521,7 +1917,7 @@ export function registerByKaranteliTools(server, options) {
1521
1917
  });
1522
1918
  server.registerTool("delete_alert_recipe", {
1523
1919
  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.",
1920
+ 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 connection (sign-in or key) and each one is recorded on the account.",
1525
1921
  inputSchema: {
1526
1922
  id: z.string().describe("string, the recipe id from list_alert_recipes"),
1527
1923
  },
@@ -1539,7 +1935,7 @@ export function registerByKaranteliTools(server, options) {
1539
1935
  });
1540
1936
  server.registerTool("list_watchlists", {
1541
1937
  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.",
1938
+ 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 a signed-in account (OAuth sign-in or account key); reads nothing but this account.",
1543
1939
  inputSchema: {
1544
1940
  include_symbols: z.boolean().optional().describe("boolean, optional: include each list's symbols (default true)"),
1545
1941
  },
@@ -1562,7 +1958,7 @@ export function registerByKaranteliTools(server, options) {
1562
1958
  });
1563
1959
  server.registerTool("add_watchlist_symbol", {
1564
1960
  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.",
1961
+ 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 connection (sign-in or key) and each one is recorded on the account.",
1566
1962
  inputSchema: {
1567
1963
  symbol: z.string().describe("string, a Binance USDT-M perp or coin, e.g. SOLUSDT or SOL"),
1568
1964
  watchlist_id: z.string().optional().describe("string, optional list id from list_watchlists (default: the default list)"),
@@ -1582,7 +1978,7 @@ export function registerByKaranteliTools(server, options) {
1582
1978
  });
1583
1979
  server.registerTool("remove_watchlist_symbol", {
1584
1980
  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.",
1981
+ 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 connection (sign-in or key) and each one is recorded on the account.",
1586
1982
  inputSchema: {
1587
1983
  symbol: z.string().describe("string, the symbol to remove, e.g. SOLUSDT or SOL"),
1588
1984
  watchlist_id: z.string().optional().describe("string, optional list id from list_watchlists (default: the default list)"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bykaranteli-mcp",
3
- "version": "0.34.6",
3
+ "version": "0.35.1",
4
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": {