bykaranteli-mcp 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
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
+ ## [1.1.0] - 2026-10-24
6
+
7
+ 68 tools.
8
+
9
+ ### Added
10
+
11
+ - `detail` on thirteen large tools (get_liqmap, get_turkey_premium, get_correlations, get_hl_whales, get_options_flow, get_funding_arbitrage, get_desk_notes, get_positioning, get_coverage, get_new_listings, get_venue_markets, get_tokenized_stocks, get_factor_board): `"compact"` answers a shorter page than the default one (fewer rows, numbers rounded to six significant digits, long free text cut, the LiqMap heatmap grid and axes left out, each pair of the correlation matrix once for BTC and ETH), `"full"` the whole route answer (still under the 100,000 character ceiling of every answer), omitted the default first page. Every cut is named in `trimmed` as before. get_liqmap and get_coverage keep their boolean form, where `true` means full. Measured against the live routes on 24 Oct 2026: every compact answer under 25,000 characters.
12
+ - `compact` entry (`bykaranteli-mcp/compact`): `compactValue`, `compactToolText` and the per-tool `COMPACT_RULES`, the code the compact pages use, exported for hosts that run the tools in process and cap an answer's size themselves (the AI desk on bykaranteli.com).
13
+ - `headersProvider` in the register options: extra request headers read per outbound fetch, for a host that marks member depth for the caller it answers for. Never a credential.
14
+
5
15
  ## [1.0.1] - 2026-10-23
6
16
 
7
17
  68 tools.
package/README.md CHANGED
@@ -129,6 +129,8 @@ All market answers are JSON and carry a `generatedAt` timestamp, a `source` URL
129
129
 
130
130
  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.
131
131
 
132
+ Thirteen large tools (get_liqmap, get_turkey_premium, get_correlations, get_hl_whales, get_options_flow, get_funding_arbitrage, get_desk_notes, get_positioning, get_coverage, get_new_listings, get_venue_markets, get_tokenized_stocks, get_factor_board) also take `detail`: `"compact"` answers a shorter page than the default one (fewer rows, numbers rounded to six significant digits, long free text cut, grids left out; every cut named in `trimmed`), `"full"` the whole route answer. get_liqmap and get_coverage keep their boolean form, where `true` means full. Since 1.1.0.
133
+
132
134
  ## Account tools (alerts and watchlist)
133
135
 
134
136
  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.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Compact answers (1.1.0, 2026-10-24). Two callers:
3
+ * - the `detail: "compact"` value of thirteen large tools (tools.ts): a shorter page than the default one, for a
4
+ * model that only needs the headline figures;
5
+ * - the AI desk on bykaranteli.com, which runs the same tools in process and caps every tool answer at a fixed
6
+ * number of characters before the model reads it (compactToolText).
7
+ * Nothing here recomputes a figure: lists are cut in the route's own order, numbers are rounded to a few significant
8
+ * digits, long free text is shortened, and every cut is named in `trimmed` the way the default pages name theirs.
9
+ */
10
+ export type CompactCut = {
11
+ field: string;
12
+ returned: number;
13
+ total: number;
14
+ more: string;
15
+ };
16
+ export type CompactRule = {
17
+ /** Items kept per list (default 10; Infinity leaves every list as the tool cut it). The thirteen tool rules leave
18
+ * lists alone: each tool cuts its own lists in its handler, where it knows which end of a series is the newest. */
19
+ maxItems?: number;
20
+ /** Per-key item caps by the list's own key, e.g. { exchanges: 3 }; wins over maxItems for that key. */
21
+ fields?: Readonly<Record<string, number>>;
22
+ /** Top-level keys left out entirely (heatmap grids, label axes). */
23
+ drop?: readonly string[];
24
+ /** Keys whose strings are never shortened and whose lists are never cut (the quotable text of a desk note, the
25
+ * routes' own field definitions in `note`). */
26
+ keepKeys?: readonly string[];
27
+ /** Longest string kept (default 300 characters); longer ones are cut with a marker. */
28
+ maxString?: number;
29
+ /** Significant digits kept on non-integer numbers (default 6). */
30
+ significant?: number;
31
+ /** The `more` text of every cut (default: the full answer parameter). */
32
+ more?: string;
33
+ };
34
+ /** Walks one answer; returns the shortened copy and the cuts it made. */
35
+ export declare function compactValue(value: unknown, rule?: CompactRule): {
36
+ value: unknown;
37
+ cuts: CompactCut[];
38
+ };
39
+ /**
40
+ * One tool answer (its JSON text) under a character cap. The caps shrink until the text fits: first the rule's own
41
+ * item caps, then halves of them down to two items, then the largest top-level lists are left out. The answer is
42
+ * never cut mid-JSON; a model always reads a complete document with its `trimmed` notes. Non-JSON text (an error
43
+ * line) is cut at the cap with a marker.
44
+ */
45
+ export declare function compactToolText(text: string, cap: number, rule?: CompactRule): {
46
+ text: string;
47
+ cuts: CompactCut[];
48
+ capped: boolean;
49
+ chars: number;
50
+ };
51
+ export declare const COMPACT_RULES: {
52
+ readonly get_liqmap: {
53
+ readonly maxItems: number;
54
+ readonly drop: readonly ["heatmap_cells", "x_labels", "y_labels", "band_prices", "price_path", "candles"];
55
+ readonly more: "pass detail \"full\" for every level and print";
56
+ };
57
+ readonly get_turkey_premium: {
58
+ readonly maxItems: number;
59
+ readonly maxString: 300;
60
+ };
61
+ readonly get_correlations: {
62
+ readonly maxItems: number;
63
+ };
64
+ readonly get_hl_whales: {
65
+ readonly maxItems: number;
66
+ };
67
+ readonly get_options_flow: {
68
+ readonly maxItems: number;
69
+ readonly maxString: 300;
70
+ };
71
+ readonly get_funding_arbitrage: {
72
+ readonly maxItems: number;
73
+ };
74
+ readonly get_desk_notes: {
75
+ readonly maxItems: number;
76
+ readonly keepKeys: readonly ["text", "headline", "x_text", "history_line"];
77
+ };
78
+ readonly get_positioning: {
79
+ readonly maxItems: number;
80
+ };
81
+ readonly get_coverage: {
82
+ readonly maxItems: number;
83
+ readonly maxString: 200;
84
+ readonly keepKeys: readonly ["note_en"];
85
+ readonly drop: readonly ["matrix"];
86
+ readonly more: "omit detail for the venue by product matrix";
87
+ };
88
+ readonly get_new_listings: {
89
+ readonly maxItems: number;
90
+ };
91
+ readonly get_venue_markets: {
92
+ readonly maxItems: number;
93
+ };
94
+ readonly get_tokenized_stocks: {
95
+ readonly maxItems: number;
96
+ readonly maxString: 300;
97
+ };
98
+ readonly get_factor_board: {
99
+ readonly maxItems: number;
100
+ readonly maxString: 200;
101
+ readonly keepKeys: readonly ["interpretationEn", "todayText", "bandEn"];
102
+ };
103
+ };
104
+ export type CompactToolName = keyof typeof COMPACT_RULES;
105
+ export declare const COMPACT_TOOL_NAMES: readonly CompactToolName[];
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Compact answers (1.1.0, 2026-10-24). Two callers:
3
+ * - the `detail: "compact"` value of thirteen large tools (tools.ts): a shorter page than the default one, for a
4
+ * model that only needs the headline figures;
5
+ * - the AI desk on bykaranteli.com, which runs the same tools in process and caps every tool answer at a fixed
6
+ * number of characters before the model reads it (compactToolText).
7
+ * Nothing here recomputes a figure: lists are cut in the route's own order, numbers are rounded to a few significant
8
+ * digits, long free text is shortened, and every cut is named in `trimmed` the way the default pages name theirs.
9
+ */
10
+ const DEFAULT_MORE = "narrow the call: a symbol, fewer days or a smaller limit";
11
+ /** Keys never shortened: provenance and page links are what the reader follows. */
12
+ const KEEP_KEYS = new Set(["provenance", "source", "generatedAt", "generated_at", "as_of", "asOf", "trimmed", "license", "page", "links", "url", "destination_url", "image_url", "note"]);
13
+ /** The first `max` UTF-16 units without a dangling high surrogate at the end. */
14
+ function cutString(v, max) {
15
+ let end = max;
16
+ const unit = v.charCodeAt(end - 1);
17
+ if (end > 0 && unit >= 0xd800 && unit <= 0xdbff)
18
+ end -= 1;
19
+ return v.slice(0, end);
20
+ }
21
+ const isRec = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
22
+ function roundNumber(n, significant) {
23
+ if (!Number.isFinite(n) || Number.isInteger(n))
24
+ return n;
25
+ const r = Number(n.toPrecision(significant));
26
+ return Number.isFinite(r) ? r : n;
27
+ }
28
+ /** Walks one answer; returns the shortened copy and the cuts it made. */
29
+ export function compactValue(value, rule = {}) {
30
+ const maxItems = rule.maxItems ?? 10;
31
+ const keep = new Set([...KEEP_KEYS, ...(rule.keepKeys ?? [])]);
32
+ const maxString = rule.maxString ?? 300;
33
+ const significant = rule.significant ?? 6;
34
+ const more = rule.more ?? DEFAULT_MORE;
35
+ const cuts = [];
36
+ const noted = new Set();
37
+ const note = (field, returned, total) => {
38
+ if (noted.has(field)) {
39
+ const prev = cuts.find((c) => c.field === field);
40
+ if (prev) {
41
+ prev.returned += returned;
42
+ prev.total += total;
43
+ }
44
+ return;
45
+ }
46
+ noted.add(field);
47
+ cuts.push({ field, returned, total, more });
48
+ };
49
+ const walk = (v, path, key, depth) => {
50
+ if (typeof v === "number")
51
+ return roundNumber(v, significant);
52
+ if (typeof v === "string") {
53
+ if (v.length > maxString && !keep.has(key)) {
54
+ note(`${path} (characters)`, maxString, v.length);
55
+ return `${cutString(v, maxString)}…`;
56
+ }
57
+ return v;
58
+ }
59
+ if (Array.isArray(v)) {
60
+ const cap = keep.has(key) ? Number.POSITIVE_INFINITY : rule.fields?.[key] ?? maxItems;
61
+ const kept = v.length > cap ? v.slice(0, cap) : v;
62
+ if (v.length > cap)
63
+ note(`${path}[]`, cap, v.length);
64
+ return kept.map((item) => walk(item, `${path}[]`, key, depth + 1));
65
+ }
66
+ if (isRec(v)) {
67
+ const out = {};
68
+ for (const [k, x] of Object.entries(v)) {
69
+ if (depth === 0 && rule.drop?.includes(k)) {
70
+ note(k, 0, Array.isArray(x) ? x.length : 1);
71
+ continue;
72
+ }
73
+ if (keep.has(k)) {
74
+ out[k] = x;
75
+ continue;
76
+ }
77
+ out[k] = walk(x, path ? `${path}.${k}` : k, k, depth + 1);
78
+ }
79
+ return out;
80
+ }
81
+ return v;
82
+ };
83
+ const out = walk(value, "", "", 0);
84
+ if (isRec(out) && cuts.length > 0) {
85
+ const prior = Array.isArray(out.trimmed) ? out.trimmed : [];
86
+ return { value: { ...out, trimmed: [...prior, ...cuts] }, cuts };
87
+ }
88
+ return { value: out, cuts };
89
+ }
90
+ /**
91
+ * One tool answer (its JSON text) under a character cap. The caps shrink until the text fits: first the rule's own
92
+ * item caps, then halves of them down to two items, then the largest top-level lists are left out. The answer is
93
+ * never cut mid-JSON; a model always reads a complete document with its `trimmed` notes. Non-JSON text (an error
94
+ * line) is cut at the cap with a marker.
95
+ */
96
+ export function compactToolText(text, cap, rule = {}) {
97
+ if (text.length <= cap)
98
+ return { text, cuts: [], capped: false, chars: text.length };
99
+ let parsed;
100
+ try {
101
+ parsed = JSON.parse(text);
102
+ }
103
+ catch {
104
+ const cut = `${cutString(text, Math.max(0, cap - 40))} [cut at ${cap} characters]`;
105
+ return { text: cut, cuts: [], capped: true, chars: cut.length };
106
+ }
107
+ let maxItems = Number.isFinite(rule.maxItems ?? 10) ? (rule.maxItems ?? 10) : 10;
108
+ let fields = rule.fields;
109
+ for (let round = 0; round < 8; round += 1) {
110
+ const { value, cuts } = compactValue(parsed, { ...rule, maxItems, fields });
111
+ const out = JSON.stringify(value);
112
+ if (out.length <= cap)
113
+ return { text: out, cuts, capped: true, chars: out.length };
114
+ if (maxItems <= 2)
115
+ break;
116
+ maxItems = Math.max(2, Math.floor(maxItems / 2));
117
+ fields = fields ? Object.fromEntries(Object.entries(fields).map(([k, n]) => [k, Math.max(2, Math.floor(n / 2))])) : undefined;
118
+ }
119
+ /* Still over: leave out the top-level lists one by one, the one that costs the most at the final caps first. */
120
+ if (isRec(parsed)) {
121
+ const rec = { ...parsed };
122
+ const lists = Object.entries(rec)
123
+ .filter(([k, v]) => Array.isArray(v) && !KEEP_KEYS.has(k) && !(rule.keepKeys ?? []).includes(k))
124
+ .map(([k, v]) => [k, JSON.stringify(v.slice(0, fields?.[k] ?? maxItems)).length])
125
+ .sort((a, b) => b[1] - a[1]);
126
+ const dropped = [];
127
+ for (const [k] of lists) {
128
+ dropped.push(k);
129
+ const { value, cuts } = compactValue(rec, { ...rule, maxItems, fields, drop: [...(rule.drop ?? []), ...dropped] });
130
+ const out = JSON.stringify(value);
131
+ if (out.length <= cap)
132
+ return { text: out, cuts, capped: true, chars: out.length };
133
+ }
134
+ }
135
+ const fallback = JSON.stringify({ truncated: true, chars: text.length, limit: cap, note: "Answer too large for the desk even in compact form. Narrow the call: one symbol, fewer days or a smaller limit." });
136
+ return { text: fallback, cuts: [], capped: true, chars: fallback.length };
137
+ }
138
+ /** The compact rules of the thirteen large tools (measured 24 Oct 2026, olcum/mcp_arac_sayimi.md). Each handler cuts
139
+ * its own lists for detail "compact" (it knows which end of a series is the newest and writes one `trimmed` entry
140
+ * with consistent counts); these rules only round figures, shorten free text and leave grids out, so every list
141
+ * stays as the handler cut it (maxItems Infinity). A key here must be a tool that declares `detail`
142
+ * (test/compact.test.mjs checks the two sets agree). */
143
+ const NO_LIST_CUT = Number.POSITIVE_INFINITY;
144
+ export const COMPACT_RULES = {
145
+ get_liqmap: { maxItems: NO_LIST_CUT, drop: ["heatmap_cells", "x_labels", "y_labels", "band_prices", "price_path", "candles"], more: 'pass detail "full" for every level and print' },
146
+ get_turkey_premium: { maxItems: NO_LIST_CUT, maxString: 300 },
147
+ get_correlations: { maxItems: NO_LIST_CUT },
148
+ get_hl_whales: { maxItems: NO_LIST_CUT },
149
+ get_options_flow: { maxItems: NO_LIST_CUT, maxString: 300 },
150
+ get_funding_arbitrage: { maxItems: NO_LIST_CUT },
151
+ get_desk_notes: { maxItems: NO_LIST_CUT, keepKeys: ["text", "headline", "x_text", "history_line"] },
152
+ get_positioning: { maxItems: NO_LIST_CUT },
153
+ get_coverage: { maxItems: NO_LIST_CUT, maxString: 200, keepKeys: ["note_en"], drop: ["matrix"], more: "omit detail for the venue by product matrix" },
154
+ get_new_listings: { maxItems: NO_LIST_CUT },
155
+ get_venue_markets: { maxItems: NO_LIST_CUT },
156
+ get_tokenized_stocks: { maxItems: NO_LIST_CUT, maxString: 300 },
157
+ get_factor_board: { maxItems: NO_LIST_CUT, maxString: 200, keepKeys: ["interpretationEn", "todayText", "bandEn"] },
158
+ };
159
+ export const COMPACT_TOOL_NAMES = Object.keys(COMPACT_RULES);
package/dist/tools.d.ts CHANGED
@@ -24,5 +24,8 @@ export type RegisterOptions = {
24
24
  /** Per-call Authorization value, for hosts that forward their caller's key
25
25
  * (the hosted endpoint). Takes precedence over apiKey when it returns one. */
26
26
  authorizationProvider?: () => string | undefined;
27
+ /** Extra request headers for every outbound fetch, read per call (1.1.0): the hosted AI desk marks the member depth
28
+ * of the signed-in member it answers for. Never a credential; those go through apiKey or authorizationProvider. */
29
+ headersProvider?: () => Record<string, string> | undefined;
27
30
  };
28
31
  export declare function registerByKaranteliTools(target: McpServer, options?: RegisterOptions): void;
package/dist/tools.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * instance, so every tool has exactly one definition.
6
6
  */
7
7
  import { z } from "zod/v4";
8
+ import { COMPACT_RULES, compactValue } from "./compact.js";
8
9
  import { buildProvenance, pageForApiPath } from "./provenance.js";
9
10
  export function registerByKaranteliTools(target, options) {
10
11
  const withTitle = (def) => def.title && !(def.annotations && "title" in def.annotations) ? { ...def, annotations: { ...(def.annotations ?? {}), title: def.title } } : def;
@@ -16,6 +17,18 @@ export function registerByKaranteliTools(target, options) {
16
17
  const USER_AGENT = `bykaranteli-mcp/${options?.version ?? "dev"} (+https://github.com/bykarantelicom/bykaranteli-mcp)`;
17
18
  const TIMEOUT_MS = 15_000;
18
19
  const STATIC_KEY = (options?.apiKey ?? process.env.BYKARANTELI_API_KEY ?? "").trim();
20
+ /* headersProvider output, lower-cased, without anything that could carry a credential or change the exchange:
21
+ * the fixed headers and the forwarded authorization always win (package review 24 Oct 2026). */
22
+ const BLOCKED_EXTRA = new Set(["authorization", "proxy-authorization", "cookie", "x-api-key", "accept", "user-agent", "host", "content-type", "content-length"]);
23
+ const extraHeaders = () => {
24
+ const out = {};
25
+ for (const [k, v] of Object.entries(options?.headersProvider?.() ?? {})) {
26
+ const key = k.toLowerCase();
27
+ if (!BLOCKED_EXTRA.has(key) && typeof v === "string")
28
+ out[key] = v;
29
+ }
30
+ return out;
31
+ };
19
32
  const authorizationFor = () => {
20
33
  /* A host that forwards its callers' keys (authorizationProvider) never falls back to a static key: on the
21
34
  * hosted endpoint that fallback would have let every keyless caller act as whatever key the process held
@@ -39,6 +52,7 @@ export function registerByKaranteliTools(target, options) {
39
52
  headers: {
40
53
  accept: "application/json",
41
54
  "user-agent": USER_AGENT,
55
+ ...extraHeaders(),
42
56
  ...(authorization ? { authorization } : {}),
43
57
  },
44
58
  });
@@ -92,7 +106,7 @@ export function registerByKaranteliTools(target, options) {
92
106
  truncated: true,
93
107
  chars: text.length,
94
108
  limit: MAX_RESULT_CHARS,
95
- note: "Result too large for one context window. Narrow the call: fewer hours or days, a single symbol, or include_points=false, then call again.",
109
+ note: "Result too large for one context window. Narrow the call: a single symbol, fewer hours or days, a smaller limit, or detail \"compact\" where the tool offers it, then call again.",
96
110
  }),
97
111
  }],
98
112
  };
@@ -133,6 +147,17 @@ export function registerByKaranteliTools(target, options) {
133
147
  function compact(data, path, cut, rowsField) {
134
148
  return trimmedAnswer(withProvenance(data, path), cut, rowsField);
135
149
  }
150
+ const DETAIL = z
151
+ .union([z.boolean(), z.enum(["compact", "full"])])
152
+ .optional()
153
+ .describe('Optional. "compact": a shorter page than the default (fewer rows, newest points kept, rounded figures, long text cut, grids left out; every cut named in trimmed). "full" or true: the whole route answer, still under the 100,000 character ceiling (the largest boards answer the ceiling note then: narrow them with symbol, limit or days instead). Omitted or false: the default first page.');
154
+ const wantsFull = (d) => d === true || d === "full";
155
+ const wantsCompact = (d) => d === "compact";
156
+ /** ok(page), or ok(the compact page) when the caller asked for detail \"compact\": rounding, text and grid rules of
157
+ * src/compact.ts; the lists were cut by the handler. */
158
+ function okDetail(name, detail, page) {
159
+ return ok(wantsCompact(detail) ? compactValue(page, COMPACT_RULES[name]).value : page);
160
+ }
136
161
  /** The newest `days` distinct dates of rows carrying a `date` field, in the route's order. */
137
162
  function newestDates(rows, days) {
138
163
  const dates = [...new Set(rows.map((r) => (isRec(r) ? String(r.date) : "")))].filter(Boolean).sort().reverse();
@@ -361,25 +386,29 @@ export function registerByKaranteliTools(target, options) {
361
386
  });
362
387
  server.registerTool("get_funding_arbitrage", {
363
388
  title: "Cross-exchange funding arbitrage opportunities",
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.",
389
+ 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. Pass detail \"compact\" for a shorter page or \"full\" for the whole answer.",
365
390
  inputSchema: {
366
391
  symbol: z.string().optional().describe("Optional. One perp, e.g. BTCUSDT or BTC: its row with every venue leg."),
392
+ detail: DETAIL,
367
393
  },
368
394
  annotations: READ_ONLY,
369
- }, async ({ symbol }) => {
395
+ }, async ({ symbol, detail }) => {
370
396
  try {
371
397
  const d = (await fetchJson("/api/public/funding-arb"));
372
398
  const full = { ...d, source: `${PUBLIC_URL}/funding-arb`, provenance: provenanceFor(d, "/api/public/funding-arb") };
373
399
  const rows = list(d.rows).filter(isRec);
374
400
  if (symbol) {
401
+ /* A filtered call keeps every leg of that perp at every detail level. */
375
402
  const want = normalizeSymbol(symbol);
376
403
  const row = rows.find((r) => r.symbol === want);
377
404
  if (!row)
378
405
  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] });
406
+ return okDetail("get_funding_arbitrage", detail, { ...full, rows: [row] });
380
407
  }
381
- const LEGS = 5;
382
- return ok(trimmedAnswer(full, (rec, t) => {
408
+ if (wantsFull(detail))
409
+ return ok(full);
410
+ const LEGS = wantsCompact(detail) ? 3 : 5;
411
+ return okDetail("get_funding_arbitrage", detail, trimmedAnswer(full, (rec, t) => {
383
412
  let kept = 0;
384
413
  let total = 0;
385
414
  rec.rows = rows.map((r) => {
@@ -530,12 +559,32 @@ export function registerByKaranteliTools(target, options) {
530
559
  });
531
560
  server.registerTool("get_options_flow", {
532
561
  title: "Options tape: biggest prints and premium flow (BTC + ETH)",
533
- description: "Call this when the user asks what big options players are buying, about block trades, or whether call or put premium dominates today. Returns 24h call vs put premium bought (net of the sold legs of the same multi-leg block or combo, so a spread counts its net premium), the block-trade share, the multi-leg structure count, and the largest prints of the last 48 hours with strikes, premium, IV, structure id and venue (Deribit or OKX). Updated every 15 minutes.",
534
- inputSchema: {},
562
+ description: "Call this when the user asks what big options players are buying, about block trades, or whether call or put premium dominates today. Returns 24h call vs put premium bought (net of the sold legs of the same multi-leg block or combo, so a spread counts its net premium), the block-trade share, the multi-leg structure count, and the largest prints of the last 48 hours with strikes, premium, IV, structure id and venue (Deribit or OKX). Updated every 15 minutes. Pass detail \"compact\" for a shorter page or \"full\" for the whole answer.",
563
+ inputSchema: { detail: DETAIL },
535
564
  annotations: READ_ONLY,
536
- }, async () => {
565
+ }, async ({ detail }) => {
537
566
  try {
538
- return ok(withProvenance(await fetchJson("/api/public/options-flow"), "/api/public/options-flow"));
567
+ const path = "/api/public/options-flow";
568
+ const data = await fetchJson(path);
569
+ if (!wantsCompact(detail))
570
+ return ok(withProvenance(data, path));
571
+ /* compact: the first 8 of each per-asset list (the route orders them largest first), the 48h top prints whole. */
572
+ return okDetail("get_options_flow", detail, compact(data, path, (rec, t) => {
573
+ if (!isRec(rec.assets))
574
+ return;
575
+ const assets = {};
576
+ for (const [asset, value] of Object.entries(rec.assets)) {
577
+ if (!isRec(value)) {
578
+ assets[asset] = value;
579
+ continue;
580
+ }
581
+ const out = { ...value };
582
+ for (const key of ["by_expiry", "by_strike", "by_venue", "structures"])
583
+ out[key] = t.head(value[key], 8, `assets.${asset}.${key}`, "omit detail for the default page");
584
+ assets[asset] = out;
585
+ }
586
+ rec.assets = assets;
587
+ }));
539
588
  }
540
589
  catch (err) {
541
590
  return fail(err);
@@ -632,7 +681,7 @@ export function registerByKaranteliTools(target, options) {
632
681
  });
633
682
  server.registerTool("get_tokenized_stocks", {
634
683
  title: "Tokenized stocks onchain: supply, premium to the real share, DEX pools and exchange pairs",
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.",
684
+ 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. Pass detail \"compact\" for a shorter page or \"full\" for the whole answer.",
636
685
  inputSchema: {
637
686
  underlying: z.string().regex(/^[A-Za-z0-9.]{1,16}$/).optional().describe("Stock ticker, e.g. TSLA; omit for the whole board."),
638
687
  /* Issuer ids mirror TOKENIZED_ISSUER_IDS in web/src/lib/tokenized-equities/meta.ts (the hosted tool reads that list
@@ -643,9 +692,10 @@ export function registerByKaranteliTools(target, options) {
643
692
  .describe("One issuer: xstocks, robinhood, coinbase, bstocks, ondo, backpack, gstocks, reality, superstate, figure, dinari or securitize; omit for every issuer."),
644
693
  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)."),
645
694
  top: z.number().int().min(1).max(200).optional().describe("Rows by supply (default 10, max 200)."),
695
+ detail: DETAIL,
646
696
  },
647
697
  annotations: READ_ONLY,
648
- }, async ({ underlying, issuer, chain, top }) => {
698
+ }, async ({ underlying, issuer, chain, top, detail }) => {
649
699
  try {
650
700
  const params = new URLSearchParams();
651
701
  if (underlying)
@@ -655,13 +705,13 @@ export function registerByKaranteliTools(target, options) {
655
705
  if (chain)
656
706
  params.set("chain", chain);
657
707
  /* 0.35.0: 10 rows by default (20 full rows were 113k characters). */
658
- params.set("top", String(top ?? 10));
708
+ params.set("top", String(top ?? (wantsFull(detail) ? 200 : wantsCompact(detail) ? 6 : 10)));
659
709
  const path = `/api/public/tokenized-stocks?${params.toString()}`;
660
710
  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) => {
711
+ if (underlying || wantsFull(detail))
712
+ return okDetail("get_tokenized_stocks", detail, withProvenance(data, path));
713
+ const N = wantsCompact(detail) ? 2 : 3;
714
+ return okDetail("get_tokenized_stocks", detail, compact(data, path, (rec, t) => {
665
715
  const kept = { wrappers: 0, cex: 0 };
666
716
  const total = { wrappers: 0, cex: 0 };
667
717
  rec.rows = list(rec.rows).map((r) => {
@@ -771,16 +821,17 @@ export function registerByKaranteliTools(target, options) {
771
821
  });
772
822
  server.registerTool("get_factor_board", {
773
823
  title: "Factor board: what followed days like today across recorded metrics",
774
- description: "Call this when the user asks which indicators currently sit in an unusual band, whether a metric's current level historically preceded BTC moves, or for a cross-metric conditional overview. Returns every recorded metric in its historical band with the median 7-day BTC move that followed versus the base rate, with an n >= 30 gate; distributions, not forecasts. Without limit the first 40 rows in the board's own order (rows_total counts every row, trimmed names the cut); pass limit up to 400 for the whole board.",
824
+ description: "Call this when the user asks which indicators currently sit in an unusual band, whether a metric's current level historically preceded BTC moves, or for a cross-metric conditional overview. Returns every recorded metric in its historical band with the median 7-day BTC move that followed versus the base rate, with an n >= 30 gate; distributions, not forecasts. Without limit the first 40 rows in the board's own order (rows_total counts every row, trimmed names the cut); pass limit up to 400 for the whole board. Pass detail \"compact\" for a shorter page or \"full\" for the whole answer.",
775
825
  inputSchema: {
776
826
  limit: z.coerce.number().int().min(1).max(400).optional().describe("Optional. Rows to return, 1..400 (default 40)"),
827
+ detail: DETAIL,
777
828
  },
778
829
  annotations: READ_ONLY,
779
- }, async ({ limit }) => {
830
+ }, async ({ limit, detail }) => {
780
831
  try {
781
832
  /* 0.36.1: 79 rows of about 600 characters answered 46,000 characters without arguments; a first page of 40. */
782
- const n = limit ?? 40;
783
- return ok(compact(await fetchJson("/api/public/factors"), "/api/public/factors", (rec, t) => {
833
+ const n = wantsFull(detail) ? 400 : limit ?? (wantsCompact(detail) ? 15 : 40);
834
+ return okDetail("get_factor_board", detail, compact(await fetchJson("/api/public/factors"), "/api/public/factors", (rec, t) => {
784
835
  rec.rows = t.head(rec.rows, n, "rows", "pass limit up to 400");
785
836
  }, "rows"));
786
837
  }
@@ -969,11 +1020,12 @@ export function registerByKaranteliTools(target, options) {
969
1020
  });
970
1021
  server.registerTool("get_venue_markets", {
971
1022
  title: "Exchange coverage: OI, volume, funding and pegs across every exchange we snapshot",
972
- 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.",
1023
+ 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. Pass detail \"compact\" for a shorter page or \"full\" for the whole answer.",
973
1024
  inputSchema: {
974
1025
  symbol: z.string().optional().describe("string, optional base asset, e.g. BTC"),
975
1026
  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"),
976
1027
  limit: z.coerce.number().int().min(1).max(250).optional().describe("Optional. Venue market rows without symbol, largest open interest first (default 30)."),
1028
+ detail: DETAIL,
977
1029
  },
978
1030
  annotations: READ_ONLY,
979
1031
  }, async (args) => {
@@ -982,15 +1034,34 @@ export function registerByKaranteliTools(target, options) {
982
1034
  ? `/api/public/venues/oi-history?days=${Math.min(90, Math.floor(args.history_days))}`
983
1035
  : args.symbol ? `/api/public/venues/markets?symbol=${encodeURIComponent(args.symbol.toUpperCase())}` : "/api/public/venues/markets";
984
1036
  const data = await fetchJson(path);
985
- if (args.history_days || args.symbol)
1037
+ const c = wantsCompact(args.detail);
1038
+ if (wantsFull(args.detail))
1039
+ return ok(withProvenance(data, path));
1040
+ if (args.history_days) {
1041
+ if (!c)
1042
+ return ok(withProvenance(data, path));
1043
+ /* compact: the hourly history runs oldest first, so the newest 24 points are its tail. */
1044
+ return okDetail("get_venue_markets", args.detail, compact(data, path, (rec, t) => {
1045
+ const points = list(rec.points);
1046
+ if (points.length > 24) {
1047
+ rec.points = points.slice(-24);
1048
+ t.note("points", 24, points.length, "omit detail for every point of the window");
1049
+ }
1050
+ }));
1051
+ }
1052
+ if (args.symbol && !c)
986
1053
  return ok(withProvenance(data, path));
987
1054
  /* The route lists rows by venue; the board's own ranking is open interest, then 24h volume for the rows
988
1055
  * without open interest (spot). */
989
- const n = Number(args.limit ?? 30);
1056
+ const n = Number(args.limit ?? (c ? 12 : 30));
990
1057
  const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : -1);
991
- return ok(compact(data, path, (rec, t) => {
1058
+ return okDetail("get_venue_markets", args.detail, compact(data, path, (rec, t) => {
992
1059
  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));
993
- rec.rows = t.head(ranked, n, "rows", "pass symbol for one coin's rows on every venue, or limit up to 250");
1060
+ rec.rows = t.head(ranked, n, "rows", args.symbol ? "omit detail for every venue of this coin" : "pass symbol for one coin's rows on every venue, or limit up to 250");
1061
+ if (c && !args.symbol) {
1062
+ rec.symbols = t.head(rec.symbols, 12, "symbols", "omit detail for every symbol");
1063
+ rec.coverage = t.head(rec.coverage, 15, "coverage", "omit detail for every feed");
1064
+ }
994
1065
  }, "rows"));
995
1066
  }
996
1067
  catch (err) {
@@ -1055,12 +1126,31 @@ export function registerByKaranteliTools(target, options) {
1055
1126
  });
1056
1127
  server.registerTool("get_correlations", {
1057
1128
  title: "Crypto correlation matrix",
1058
- description: "Call this when the user asks how correlated two coins are, for decorrelated pairs, or how tightly alts track BTC. Returns the 30-day rolling Pearson correlation matrix of daily returns across the top perpetuals.",
1059
- inputSchema: {},
1129
+ description: "Call this when the user asks how correlated two coins are, for decorrelated pairs, or how tightly alts track BTC. Returns the 30-day rolling Pearson correlation matrix of daily returns across the top perpetuals. Pass detail \"compact\" for a shorter page or \"full\" for the whole answer.",
1130
+ inputSchema: { detail: DETAIL },
1060
1131
  annotations: READ_ONLY,
1061
- }, async () => {
1132
+ }, async ({ detail }) => {
1062
1133
  try {
1063
- return ok(withProvenance(await fetchJson("/api/public/correlations"), "/api/public/correlations"));
1134
+ const path = "/api/public/correlations";
1135
+ const data = withProvenance(await fetchJson(path), path);
1136
+ if (!wantsCompact(detail) || !isRec(data))
1137
+ return ok(data);
1138
+ /* compact: the pairs that involve BTC or ETH, each pair once (the whole matrix is 400 cells). */
1139
+ const cells = list(data.cells).filter(isRec);
1140
+ const majors = new Set(["BTCUSDT", "ETHUSDT"]);
1141
+ const seen = new Set();
1142
+ const kept = cells.filter((c) => {
1143
+ const a = String(c.symbolA);
1144
+ const b = String(c.symbolB);
1145
+ if (a === b || (!majors.has(a) && !majors.has(b)))
1146
+ return false;
1147
+ const key = [a, b].sort().join("|");
1148
+ if (seen.has(key))
1149
+ return false;
1150
+ seen.add(key);
1151
+ return true;
1152
+ });
1153
+ return okDetail("get_correlations", detail, { ...data, cells: kept, trimmed: [{ field: "cells", returned: kept.length, total: cells.length, more: "omit detail for the whole matrix" }] });
1064
1154
  }
1065
1155
  catch (err) {
1066
1156
  return fail(err);
@@ -1068,13 +1158,23 @@ export function registerByKaranteliTools(target, options) {
1068
1158
  });
1069
1159
  server.registerTool("get_new_listings", {
1070
1160
  title: "New and delisted perpetual contracts",
1071
- description: "Call this when the user asks what new perpetuals were listed, which exchange listed a coin first, or about delistings. Returns listings and delistings across every exchange the hourly scan covers.",
1072
- inputSchema: { days: z.number().int().min(1).max(30).optional().describe("Window in days, 1-30 (default 30). Longer listing history is the listings dataset at bykaranteli.com/data.") },
1161
+ description: "Call this when the user asks what new perpetuals were listed, which exchange listed a coin first, or about delistings. Returns listings and delistings across every exchange the hourly scan covers. Pass detail \"compact\" for a shorter page or \"full\" for the whole answer.",
1162
+ inputSchema: {
1163
+ days: z.number().int().min(1).max(30).optional().describe("Window in days, 1-30 (default 30). Longer listing history is the listings dataset at bykaranteli.com/data."),
1164
+ detail: DETAIL,
1165
+ },
1073
1166
  annotations: READ_ONLY,
1074
1167
  }, async (args) => {
1075
1168
  try {
1076
1169
  const path = `/api/public/listings?days=${args.days && args.days > 0 ? Math.floor(args.days) : 30}`;
1077
- return ok(withProvenance(await fetchJson(path), path));
1170
+ const data = await fetchJson(path);
1171
+ if (!wantsCompact(args.detail))
1172
+ return ok(withProvenance(data, path));
1173
+ /* compact: the newest 15 listings and 10 delistings (the route lists newest first). */
1174
+ return okDetail("get_new_listings", args.detail, compact(data, path, (rec, t) => {
1175
+ rec.listed = t.head(rec.listed, 15, "listed", "omit detail for the whole window");
1176
+ rec.delisted = t.head(rec.delisted, 10, "delisted", "omit detail for the whole window");
1177
+ }));
1078
1178
  }
1079
1179
  catch (err) {
1080
1180
  return fail(err);
@@ -1162,7 +1262,7 @@ export function registerByKaranteliTools(target, options) {
1162
1262
  });
1163
1263
  server.registerTool("get_liqmap", {
1164
1264
  title: "LiqMap: estimated liquidation clusters with real prints overlaid",
1165
- 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.",
1265
+ 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 largest real prints; pass detail \"full\" for all of them. Pass detail \"compact\" for a shorter page or \"full\" for the whole answer.",
1166
1266
  inputSchema: {
1167
1267
  symbol: z
1168
1268
  .string()
@@ -1175,7 +1275,7 @@ export function registerByKaranteliTools(target, options) {
1175
1275
  .enum(["1h", "4h", "12h", "24h", "3d", "1w", "30d"])
1176
1276
  .optional()
1177
1277
  .describe("Model window. Default 24h, the only one on the Free plan; Builder and higher plans get every window."),
1178
- detail: z.boolean().optional().describe("Optional. true: every real level and every real print (default false: the 30 nearest levels, the 30 newest prints)."),
1278
+ detail: DETAIL,
1179
1279
  },
1180
1280
  annotations: READ_ONLY,
1181
1281
  }, async ({ symbol, timeframe, detail }) => {
@@ -1186,21 +1286,22 @@ export function registerByKaranteliTools(target, options) {
1186
1286
  const data = await fetchJson(path);
1187
1287
  const page = `/liqmap/${sym.replace(/USDT$/, "").toLowerCase()}`;
1188
1288
  const full = { ...data, source: `${PUBLIC_URL}${page}`, provenance: provenanceFor(data, path, page) };
1189
- if (detail)
1289
+ if (wantsFull(detail))
1190
1290
  return ok(full);
1191
- const N = 30;
1192
- return ok(trimmedAnswer(full, (rec, t) => {
1291
+ const N = wantsCompact(detail) ? 12 : 30;
1292
+ const P = wantsCompact(detail) ? 10 : 30;
1293
+ return okDetail("get_liqmap", detail, trimmedAnswer(full, (rec, t) => {
1193
1294
  if (isRec(rec.real_levels) && Array.isArray(rec.real_levels.levels) && rec.real_levels.levels.length > N) {
1194
1295
  /* The levels nearest the price, kept in the route's price order. */
1195
1296
  const levels = rec.real_levels.levels;
1196
1297
  const dist = (l) => (isRec(l) && typeof l.distance_pct === "number" ? Math.abs(l.distance_pct) : Infinity);
1197
1298
  const nearest = new Set([...levels].sort((a, b) => dist(a) - dist(b)).slice(0, N));
1198
1299
  rec.real_levels = { ...rec.real_levels, levels: levels.filter((l) => nearest.has(l)) };
1199
- t.note("real_levels.levels", N, levels.length, "pass detail=true for every level");
1300
+ t.note("real_levels.levels", N, levels.length, 'pass detail \"full\" for every level');
1200
1301
  }
1201
1302
  if (isRec(rec.real_liquidations)) {
1202
- /* Newest print first. */
1203
- rec.real_liquidations = { ...rec.real_liquidations, points: t.head(rec.real_liquidations.points, N, "real_liquidations.points", "pass detail=true for every print") };
1303
+ /* Largest print first (the route orders prints by size). */
1304
+ rec.real_liquidations = { ...rec.real_liquidations, points: t.head(rec.real_liquidations.points, P, "real_liquidations.points", 'pass detail \"full\" for every print') };
1204
1305
  }
1205
1306
  }));
1206
1307
  }
@@ -1307,14 +1408,15 @@ export function registerByKaranteliTools(target, options) {
1307
1408
  });
1308
1409
  server.registerTool("get_hl_whales", {
1309
1410
  title: "Hyperliquid whale tracker: top-300 accounts, long vs short, positions, changes",
1310
- 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.",
1411
+ 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. Pass detail \"compact\" for a shorter page or \"full\" for the whole answer.",
1311
1412
  inputSchema: {
1312
1413
  coin: z.string().trim().toUpperCase().regex(/^[A-Z0-9]{1,24}$/).optional().describe("One coin, e.g. BTC"),
1313
1414
  events: z.boolean().optional().describe("Include the last 200 position change events"),
1314
1415
  limit: z.coerce.number().int().min(1).max(200).optional().describe("Optional. Coins and positions without coin, largest first (default 30)."),
1416
+ detail: DETAIL,
1315
1417
  },
1316
1418
  annotations: READ_ONLY,
1317
- }, async ({ coin, events, limit }) => {
1419
+ }, async ({ coin, events, limit, detail }) => {
1318
1420
  try {
1319
1421
  const params = new URLSearchParams();
1320
1422
  if (coin)
@@ -1324,11 +1426,12 @@ export function registerByKaranteliTools(target, options) {
1324
1426
  const qs = params.toString();
1325
1427
  const path = `/api/public/hyperliquid-whales${qs ? `?${qs}` : ""}`;
1326
1428
  const data = await fetchJson(path);
1327
- if (coin)
1328
- return ok(withProvenance(data, path));
1329
- return ok(compact(data, path, (rec, t) => {
1330
- rec.by_coin = t.head(rec.by_coin, limit ?? 30, "by_coin", "pass coin, or limit up to 200");
1331
- rec.positions = t.head(rec.positions, limit ?? 30, "positions", "pass coin, or limit up to 200");
1429
+ if (coin || wantsFull(detail))
1430
+ return okDetail("get_hl_whales", detail, withProvenance(data, path));
1431
+ return okDetail("get_hl_whales", detail, compact(data, path, (rec, t) => {
1432
+ const n = limit ?? (wantsCompact(detail) ? 10 : 30);
1433
+ rec.by_coin = t.head(rec.by_coin, n, "by_coin", "pass coin, or limit up to 200");
1434
+ rec.positions = t.head(rec.positions, n, "positions", "pass coin, or limit up to 200");
1332
1435
  }, "positions"));
1333
1436
  }
1334
1437
  catch (err) {
@@ -1337,23 +1440,25 @@ export function registerByKaranteliTools(target, options) {
1337
1440
  });
1338
1441
  server.registerTool("get_positioning", {
1339
1442
  title: "Positioning: long/short ratios, taker buy/sell and CVD across exchanges",
1340
- 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.",
1443
+ 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. Pass detail \"compact\" for a shorter page or \"full\" for the whole answer.",
1341
1444
  inputSchema: {
1342
1445
  symbol: z.string().trim().toUpperCase().regex(/^[A-Z0-9]{2,24}$/).optional().describe("One Binance symbol, e.g. BTCUSDT"),
1343
1446
  limit: z.coerce.number().int().min(1).max(30).optional().describe("Optional. Perps without symbol, most traded first (default 10, up to 30)."),
1344
1447
  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."),
1448
+ detail: DETAIL,
1345
1449
  },
1346
1450
  annotations: READ_ONLY,
1347
- }, async ({ symbol, limit, cvd_points }) => {
1451
+ }, async ({ symbol, limit, cvd_points, detail }) => {
1348
1452
  try {
1349
1453
  const path = `/api/public/positioning${symbol ? `?symbol=${encodeURIComponent(symbol)}` : ""}`;
1350
1454
  const data = await fetchJson(path);
1351
- const points = cvd_points ?? (symbol ? undefined : 24);
1352
- if (symbol && points === undefined)
1455
+ const c = wantsCompact(detail);
1456
+ const points = cvd_points ?? (symbol ? (c ? 24 : undefined) : c ? 12 : 24);
1457
+ if (wantsFull(detail) || (symbol && points === undefined))
1353
1458
  return ok(withProvenance(data, path));
1354
- return ok(compact(data, path, (rec, t) => {
1459
+ return okDetail("get_positioning", detail, compact(data, path, (rec, t) => {
1355
1460
  if (!symbol)
1356
- rec.rows = t.head(rec.rows, limit ?? 10, "rows", "pass symbol, or limit up to 30");
1461
+ rec.rows = t.head(rec.rows, limit ?? (c ? 6 : 10), "rows", "pass symbol, or limit up to 30");
1357
1462
  if (points !== undefined && isRec(rec.cvd)) {
1358
1463
  const kept = {};
1359
1464
  const total = {};
@@ -1385,16 +1490,17 @@ export function registerByKaranteliTools(target, options) {
1385
1490
  });
1386
1491
  server.registerTool("get_desk_notes", {
1387
1492
  title: "Desk notes: short dated observations the ByKaranteli desk wrote from its own recorded derivatives data",
1388
- description: "Call this when the user asks what happened in crypto derivatives today or on a date, what the desk observed, what moved in funding, open interest, liquidations, options, ETF flows or exchange premiums, or wants a dated, quotable sentence with its number and time. Returns the desk's notes newest first: each is one observation written by the desk writer (an AI model) from the platform's own recorded data and checked against it before publishing, with its publish time in UTC, the card's data time, the desk's 0 to 10 news value, the board it read from, the coins it names and its permanent URL. Information about the data, never advice. Default the newest 25 notes of the last 24 hours (count says how many came back; limit up to 100 for more); pass day for one UTC day, since for a window up to 7 days back (50 notes unless limit says otherwise), type for one card type (funding-arb, liqmap-btc, event-liq-burst, ...), lang for zh, ru or ko text.",
1493
+ description: "Call this when the user asks what happened in crypto derivatives today or on a date, what the desk observed, what moved in funding, open interest, liquidations, options, ETF flows or exchange premiums, or wants a dated, quotable sentence with its number and time. Returns the desk's notes newest first: each is one observation written by the desk writer (an AI model) from the platform's own recorded data and checked against it before publishing, with its publish time in UTC, the card's data time, the desk's 0 to 10 news value, the board it read from, the coins it names and its permanent URL. Information about the data, never advice. Default the newest 25 notes of the last 24 hours (count says how many came back; limit up to 100 for more); pass day for one UTC day, since for a window up to 7 days back (50 notes unless limit says otherwise), type for one card type (funding-arb, liqmap-btc, event-liq-burst, ...), lang for zh, ru or ko text. Pass detail \"compact\" for a shorter page or \"full\" for the whole answer.",
1389
1494
  inputSchema: {
1390
1495
  day: z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("One UTC day, YYYY-MM-DD"),
1391
1496
  since: z.string().trim().max(40).optional().describe("ISO time; notes published at or after it, at most 7 days back (ignored with day)"),
1392
1497
  type: z.string().trim().toLowerCase().regex(/^[a-z0-9][a-z0-9-]{0,60}$/).optional().describe("One card type, e.g. funding-arb, liqmap-btc, event-liq-burst"),
1393
1498
  lang: z.enum(["en", "zh", "ru", "ko"]).optional().describe("Text language (default en); a note without that translation comes back in English"),
1394
1499
  limit: z.number().int().min(1).max(100).optional().describe("Notes to return, 1..100 (default 25 for the last 24 hours, 50 with day or since)"),
1500
+ detail: DETAIL,
1395
1501
  },
1396
1502
  annotations: READ_ONLY,
1397
- }, async ({ day, since, type, lang, limit }) => {
1503
+ }, async ({ day, since, type, lang, limit, detail }) => {
1398
1504
  try {
1399
1505
  const q = new URLSearchParams();
1400
1506
  if (day)
@@ -1408,11 +1514,13 @@ export function registerByKaranteliTools(target, options) {
1408
1514
  /* 0.36.1: a day of forty notes answered 47,000 characters without arguments; the first page is 25. */
1409
1515
  if (limit)
1410
1516
  q.set("limit", String(limit));
1411
- else if (!day && !since)
1517
+ else if (wantsCompact(detail))
1518
+ q.set("limit", "10");
1519
+ else if (!day && !since && !wantsFull(detail))
1412
1520
  q.set("limit", "25");
1413
1521
  const qs = q.toString();
1414
1522
  const path = `/api/public/desk${qs ? `?${qs}` : ""}`;
1415
- return ok(withProvenance(await fetchJson(path), path));
1523
+ return okDetail("get_desk_notes", detail, withProvenance(await fetchJson(path), path));
1416
1524
  }
1417
1525
  catch (err) {
1418
1526
  return fail(err);
@@ -1420,14 +1528,15 @@ export function registerByKaranteliTools(target, options) {
1420
1528
  });
1421
1529
  server.registerTool("get_turkey_premium", {
1422
1530
  title: "Turkey Premium Index: what lira buyers pay above the world price, with TRY reference prices from Turkish venues",
1423
- 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.",
1531
+ 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. Pass detail \"compact\" for a shorter page or \"full\" for the whole answer.",
1424
1532
  inputSchema: {
1425
1533
  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"),
1426
1534
  history_days: z.number().int().min(1).max(30).optional().describe("Include 15-minute index history for the pair, 1..30 days"),
1427
1535
  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)."),
1536
+ detail: DETAIL,
1428
1537
  },
1429
1538
  annotations: READ_ONLY,
1430
- }, async ({ pair, history_days, daily_days }) => {
1539
+ }, async ({ pair, history_days, daily_days, detail }) => {
1431
1540
  try {
1432
1541
  const q = new URLSearchParams();
1433
1542
  if (pair)
@@ -1437,15 +1546,35 @@ export function registerByKaranteliTools(target, options) {
1437
1546
  const qs = q.toString();
1438
1547
  const path = `/api/public/turkey-premium${qs ? `?${qs}` : ""}`;
1439
1548
  const data = await fetchJson(path);
1440
- const wantDays = daily_days ?? (pair ? undefined : 7);
1441
- if (wantDays === undefined)
1549
+ const c = wantsCompact(detail);
1550
+ const wantDays = daily_days ?? (pair ? (c ? 3 : undefined) : c ? 3 : 7);
1551
+ if (wantsFull(detail))
1442
1552
  return ok(withProvenance(data, path));
1443
- return ok(compact(data, path, (rec, t) => {
1444
- const daily = list(rec.daily);
1445
- const keep = newestDates(daily, wantDays);
1446
- const kept = daily.filter((r) => isRec(r) && keep.has(String(r.date)));
1447
- t.note("daily", kept.length, daily.length, "pass pair, or daily_days up to 30");
1448
- rec.daily = kept;
1553
+ if (wantDays === undefined && !c)
1554
+ return ok(withProvenance(data, path));
1555
+ return okDetail("get_turkey_premium", detail, compact(data, path, (rec, t) => {
1556
+ if (wantDays !== undefined) {
1557
+ const daily = list(rec.daily);
1558
+ const keep = newestDates(daily, wantDays);
1559
+ const kept = daily.filter((r) => isRec(r) && keep.has(String(r.date)));
1560
+ t.note("daily", kept.length, daily.length, "pass pair, or daily_days up to 30");
1561
+ rec.daily = kept;
1562
+ }
1563
+ if (c) {
1564
+ /* The 15-minute history (history.points) runs oldest first: the newest 24 points are its tail. */
1565
+ if (isRec(rec.history) && Array.isArray(rec.history.points) && rec.history.points.length > 24) {
1566
+ const points = rec.history.points;
1567
+ rec.history = { ...rec.history, points: points.slice(-24) };
1568
+ t.note("history.points", 24, points.length, 'pass detail \"full\" for every point');
1569
+ }
1570
+ if (isRec(rec.pairs)) {
1571
+ const pairs = {};
1572
+ for (const [key, value] of Object.entries(rec.pairs)) {
1573
+ pairs[key] = isRec(value) ? { ...value, venues: t.head(value.venues, 5, `pairs.${key}.venues`, 'pass detail \"full\" for every venue') } : value;
1574
+ }
1575
+ rec.pairs = pairs;
1576
+ }
1577
+ }
1449
1578
  }));
1450
1579
  }
1451
1580
  catch (err) {
@@ -1667,20 +1796,25 @@ export function registerByKaranteliTools(target, options) {
1667
1796
  });
1668
1797
  server.registerTool("get_coverage", {
1669
1798
  title: "Coverage registry: which venues and data types we collect, how, and how fresh",
1670
- 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.",
1799
+ 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 \"full\" for the full registry. Pass detail \"compact\" for a shorter page or \"full\" for the whole answer.",
1671
1800
  inputSchema: {
1672
- 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)."),
1801
+ detail: DETAIL,
1673
1802
  },
1674
1803
  annotations: READ_ONLY,
1675
1804
  }, async ({ detail }) => {
1676
1805
  try {
1677
1806
  const path = "/api/public/coverage";
1678
1807
  const data = await fetchJson(path);
1679
- if (detail)
1808
+ if (wantsFull(detail))
1680
1809
  return ok(withProvenance(data, path));
1681
- const more = "pass detail=true for the full registry";
1682
- return ok(compact(data, path, (rec, t) => {
1683
- rec.snapshots = t.head(rec.snapshots, 30, "snapshots", more);
1810
+ const more = 'pass detail \"full\" for the full registry';
1811
+ return okDetail("get_coverage", detail, compact(data, path, (rec, t) => {
1812
+ const c = wantsCompact(detail);
1813
+ rec.snapshots = t.head(rec.snapshots, c ? 8 : 30, "snapshots", more);
1814
+ if (c) {
1815
+ rec.orderbook_depth = t.head(rec.orderbook_depth, 8, "orderbook_depth", more);
1816
+ rec.funding_arbitrage = t.head(rec.funding_arbitrage, 10, "funding_arbitrage", more);
1817
+ }
1684
1818
  const liq = list(rec.liquidations);
1685
1819
  let translated = 0;
1686
1820
  rec.liquidations = liq.map((r) => {
@@ -1772,6 +1906,7 @@ export function registerByKaranteliTools(target, options) {
1772
1906
  headers: {
1773
1907
  accept: "application/json",
1774
1908
  "user-agent": USER_AGENT,
1909
+ ...extraHeaders(),
1775
1910
  authorization,
1776
1911
  ...(body === undefined ? {} : { "content-type": "application/json" }),
1777
1912
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bykaranteli-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
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. 68 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": {
@@ -17,7 +17,9 @@
17
17
  "build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
18
18
  "prepublishOnly": "npm run build",
19
19
  "start": "node dist/index.js",
20
- "prepare": "npm run build"
20
+ "prepare": "npm run build",
21
+ "test": "node --test test/*.test.mjs",
22
+ "pretest": "npm run -s build"
21
23
  },
22
24
  "engines": {
23
25
  "node": ">=20"
@@ -63,6 +65,10 @@
63
65
  "./tools": {
64
66
  "types": "./dist/tools.d.ts",
65
67
  "default": "./dist/tools.js"
68
+ },
69
+ "./compact": {
70
+ "types": "./dist/compact.d.ts",
71
+ "default": "./dist/compact.js"
66
72
  }
67
73
  }
68
74
  }