@senpi/runtime 3.0.146 → 3.0.149

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.
Files changed (88) hide show
  1. package/dist/cli/guide-commands.d.ts.map +1 -1
  2. package/dist/cli/guide-commands.js +55 -0
  3. package/dist/cli/guide-commands.js.map +1 -1
  4. package/dist/cli/quant-command.d.ts +128 -0
  5. package/dist/cli/quant-command.d.ts.map +1 -0
  6. package/dist/cli/quant-command.js +345 -0
  7. package/dist/cli/quant-command.js.map +1 -0
  8. package/dist/cli/senpi-commands.d.ts +11 -0
  9. package/dist/cli/senpi-commands.d.ts.map +1 -1
  10. package/dist/cli/senpi-commands.js +49 -4
  11. package/dist/cli/senpi-commands.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +40 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/quant/engine/quantdesk/__init__.py +19 -0
  16. package/dist/quant/engine/quantdesk/__main__.py +281 -0
  17. package/dist/quant/engine/quantdesk/_version.py +24 -0
  18. package/dist/quant/engine/quantdesk/benchmark.py +90 -0
  19. package/dist/quant/engine/quantdesk/book.py +197 -0
  20. package/dist/quant/engine/quantdesk/deep.py +197 -0
  21. package/dist/quant/engine/quantdesk/desk.py +878 -0
  22. package/dist/quant/engine/quantdesk/dsl.py +224 -0
  23. package/dist/quant/engine/quantdesk/followups.py +117 -0
  24. package/dist/quant/engine/quantdesk/hl_api.py +473 -0
  25. package/dist/quant/engine/quantdesk/market.py +290 -0
  26. package/dist/quant/engine/quantdesk/mcp_client.py +158 -0
  27. package/dist/quant/engine/quantdesk/metrics.py +409 -0
  28. package/dist/quant/engine/quantdesk/opportunities.py +88 -0
  29. package/dist/quant/engine/quantdesk/render.py +787 -0
  30. package/dist/quant/engine/quantdesk/roundtrips.py +124 -0
  31. package/dist/quant/engine/quantdesk/score.py +817 -0
  32. package/dist/quant/engine/quantdesk/senpi_history.py +160 -0
  33. package/dist/quant/engine/quantdesk/smart_money.py +354 -0
  34. package/dist/quant/engine/quantdesk/strategy_read.py +292 -0
  35. package/dist/quant/engine/quantdesk/taxonomy.py +55 -0
  36. package/dist/quant/engine/quantdesk/timing.py +164 -0
  37. package/dist/quant/engine/quantdesk/voice.py +42 -0
  38. package/dist/quant/engine-runner.d.ts +133 -0
  39. package/dist/quant/engine-runner.d.ts.map +1 -0
  40. package/dist/quant/engine-runner.js +306 -0
  41. package/dist/quant/engine-runner.js.map +1 -0
  42. package/dist/quant/job.d.ts +125 -0
  43. package/dist/quant/job.d.ts.map +1 -0
  44. package/dist/quant/job.js +184 -0
  45. package/dist/quant/job.js.map +1 -0
  46. package/dist/quant/register.d.ts +53 -0
  47. package/dist/quant/register.d.ts.map +1 -0
  48. package/dist/quant/register.js +253 -0
  49. package/dist/quant/register.js.map +1 -0
  50. package/dist/quant/registry.d.ts +105 -0
  51. package/dist/quant/registry.d.ts.map +1 -0
  52. package/dist/quant/registry.js +248 -0
  53. package/dist/quant/registry.js.map +1 -0
  54. package/dist/quant/types.d.ts +216 -0
  55. package/dist/quant/types.d.ts.map +1 -0
  56. package/dist/quant/types.js +121 -0
  57. package/dist/quant/types.js.map +1 -0
  58. package/dist/skills-manager/manifest.js +1 -1
  59. package/dist/skills-manager/skills-manifest.json +73 -16
  60. package/dist/widgets/index.d.ts +8 -1
  61. package/dist/widgets/index.d.ts.map +1 -1
  62. package/dist/widgets/index.js +1 -0
  63. package/dist/widgets/index.js.map +1 -1
  64. package/dist/widgets/registry.d.ts +8 -0
  65. package/dist/widgets/registry.d.ts.map +1 -1
  66. package/dist/widgets/registry.js +26 -1
  67. package/dist/widgets/registry.js.map +1 -1
  68. package/dist/widgets/tool.d.ts +14 -4
  69. package/dist/widgets/tool.d.ts.map +1 -1
  70. package/dist/widgets/tool.js +36 -23
  71. package/dist/widgets/tool.js.map +1 -1
  72. package/dist/widgets/turn-cap.d.ts +33 -0
  73. package/dist/widgets/turn-cap.d.ts.map +1 -0
  74. package/dist/widgets/turn-cap.js +119 -0
  75. package/dist/widgets/turn-cap.js.map +1 -0
  76. package/dist/widgets/types.d.ts +33 -5
  77. package/dist/widgets/types.d.ts.map +1 -1
  78. package/dist/widgets/types.js +4 -3
  79. package/dist/widgets/types.js.map +1 -1
  80. package/dist/widgets/widgets/fund-user-wallet.d.ts.map +1 -1
  81. package/dist/widgets/widgets/fund-user-wallet.js +1 -0
  82. package/dist/widgets/widgets/fund-user-wallet.js.map +1 -1
  83. package/dist/widgets/widgets/quant-desk-recommendations.d.ts +52 -0
  84. package/dist/widgets/widgets/quant-desk-recommendations.d.ts.map +1 -0
  85. package/dist/widgets/widgets/quant-desk-recommendations.js +278 -0
  86. package/dist/widgets/widgets/quant-desk-recommendations.js.map +1 -0
  87. package/openclaw.plugin.json +12 -2
  88. package/package.json +4 -3
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env python3
2
+ """Market fit from public data: per-coin trend, realized volatility, funding and open interest, and whether
3
+ each open position sits with or against them. Funding is quoted per 8h in basis points (Hyperliquid pays
4
+ hourly; hourly rate × 8 × 10,000)."""
5
+ # Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
6
+ import math
7
+ import statistics
8
+
9
+ H = 3_600_000.0
10
+
11
+
12
+ def _f(x, d=0.0):
13
+ try:
14
+ return float(x)
15
+ except (TypeError, ValueError):
16
+ return d
17
+
18
+
19
+ def coin_regime(coin, candles, ctx):
20
+ """Trend from hourly candles: 7-day and 30-day change and the close vs its 20-day mean; volatility as the
21
+ 24h realized vol against the 30-day. Returns None when the candle series is missing."""
22
+ c = candles.get(coin)
23
+ if not c:
24
+ return None
25
+ rows = c[1]
26
+ if len(rows) < 48:
27
+ return None
28
+ close = [r[4] for r in rows]
29
+ now = close[-1]
30
+ def chg(hours):
31
+ return (now / close[-hours - 1] - 1) if len(close) > hours else None
32
+ # 480 hourly candles IS the 20-day mean. Below that it took whatever it had — as few as 48
33
+ # candles, two days — and called it the 20-day mean, then fed it to a +/-2% trend threshold that
34
+ # a 2-day mean can barely clear. That is a new listing: the case where a trend call is least
35
+ # reliable and most likely to be read as one. Abstain. (@danielmbirochi, #718, round 2.)
36
+ sma20 = statistics.mean(close[-480:]) if len(close) >= 480 else None
37
+ rets = [math.log(close[i] / close[i - 1]) for i in range(1, len(close)) if close[i - 1] > 0]
38
+ vol24 = statistics.pstdev(rets[-24:]) * math.sqrt(24) if len(rets) >= 24 else None
39
+ vol30 = statistics.pstdev(rets[-720:]) * math.sqrt(24) if len(rets) >= 720 else None
40
+ c7, c30 = chg(168), chg(720)
41
+ vs_sma = (now / sma20 - 1) if sma20 else None
42
+ if vs_sma is None:
43
+ trend = "UNKNOWN" # too little tape to confirm a direction; not the same as ranging
44
+ elif c7 is not None and c7 > 0.05 and vs_sma > 0.02:
45
+ trend = "UP"
46
+ elif c7 is not None and c7 < -0.05 and vs_sma < -0.02:
47
+ trend = "DOWN"
48
+ else:
49
+ trend = "RANGING"
50
+ fr = _f((ctx or {}).get("funding"))
51
+ return dict(coin=coin, trend=trend, change_7d=c7, change_30d=c30, vs_20d_mean=vs_sma, vol_24h=vol24, vol_30d=vol30,
52
+ vol_ratio=(vol24 / vol30) if (vol24 and vol30) else None, funding_bp_8h=fr * 8 * 1e4,
53
+ open_interest_usd=_f((ctx or {}).get("openInterest")) * _f((ctx or {}).get("markPx")), day_volume=_f((ctx or {}).get("dayNtlVlm")))
54
+
55
+
56
+ def fit(position, regime):
57
+ """WITH / AGAINST / NEUTRAL: a long fits an up-trend, a short a down-trend; ranging is neutral. Funding
58
+ is reported beside it (a long paying positive funding is a cost, not a misfit)."""
59
+ if not regime or regime["trend"] == "UNKNOWN":
60
+ return "UNKNOWN"
61
+ if regime["trend"] == "RANGING":
62
+ return "NEUTRAL — ranging"
63
+ return "WITH THE MARKET" if (position["side"] == "LONG") == (regime["trend"] == "UP") else "AGAINST THE MARKET"
64
+
65
+
66
+ def book_fit(book, candles, ctxs):
67
+ ctx_by = {u["name"]: c for u, c in zip(ctxs[0]["universe"], ctxs[1])}
68
+ rows, regimes = [], {}
69
+ for p in book["positions"]:
70
+ r = coin_regime(p["coin"], candles, ctx_by.get(p["coin"]))
71
+ regimes[p["coin"]] = r
72
+ rows.append(dict(coin=p["coin"], side=p["side"], leverage=p["leverage"], trend=r["trend"] if r else None,
73
+ funding_bp_8h=r["funding_bp_8h"] if r else None, open_interest_usd=r["open_interest_usd"] if r else None,
74
+ notional=abs(_f(p.get("notional"))), funding_per_day=p["funding_per_day"], fit=fit(p, r)))
75
+ btc = coin_regime("BTC", candles, ctx_by.get("BTC"))
76
+ # Weight the headline by NOTIONAL, not by coin. A plain median counts a $19 dust position and a
77
+ # $20M one equally, so a book with sixteen xyz names at 0 bp and its real size in HYPE (+10) and
78
+ # XMR (+9) read "FUNDING NEAR FLAT" while the same desk said it collects $20,629/day two lines
79
+ # below. Size is what decides whether funding is a real part of the return.
80
+ pairs = [(r["funding_bp_8h"], r["notional"]) for r in rows
81
+ if r["funding_bp_8h"] is not None and r["notional"] > 0]
82
+ if pairs:
83
+ tot = sum(w for _, w in pairs)
84
+ med_f = sum(f * w for f, w in pairs) / tot if tot > 0 else None
85
+ else:
86
+ fundings = [r["funding_bp_8h"] for r in rows if r["funding_bp_8h"] is not None]
87
+ med_f = statistics.median(fundings) if fundings else None
88
+ # Say the annualised rate, and never let the word contradict it. bp/8h is three periods a day, so
89
+ # 3 bp/8h — the old floor for "POSITIVE" — is already 33%/yr, and everything below it read
90
+ # "NEAR FLAT". On the real book that printed FUNDING NEAR FLAT over a weighted 2.70 bp/8h =
91
+ # 29.6%/yr, beside the same desk's own "$20,629/day" (25.8%/yr of account value). The two agree;
92
+ # only the label was wrong.
93
+ ann = (med_f * 3 * 365 / 100.0) if med_f is not None else None
94
+ if med_f is None:
95
+ fhead = "FUNDING UNKNOWN"
96
+ elif med_f >= 10:
97
+ fhead = "HIGH POSITIVE FUNDING"
98
+ elif med_f >= 3:
99
+ fhead = "POSITIVE FUNDING"
100
+ elif med_f >= 1:
101
+ fhead = "MILDLY POSITIVE FUNDING"
102
+ elif med_f <= -10:
103
+ fhead = "DEEPLY NEGATIVE FUNDING"
104
+ elif med_f <= -3:
105
+ fhead = "NEGATIVE FUNDING"
106
+ elif med_f <= -1:
107
+ fhead = "MILDLY NEGATIVE FUNDING"
108
+ else:
109
+ fhead = "FUNDING NEAR FLAT"
110
+ if ann is not None and abs(ann) >= 5:
111
+ # the RATE on notional, not a share of equity. The risk dimension quotes the same funding as
112
+ # a share of EQUITY, which leverage makes a different number: on 0xccd2…c8a3 this read
113
+ # "+49%/yr on the book you hold" beside "186% of equity a year" — both true, 4x apart.
114
+ fhead += f" ({ann:+.0f}%/yr on notional at today's rates)"
115
+ headline = f"{fhead} · BTC {btc['trend'] if btc else 'UNKNOWN'}"
116
+ net = book["net_exposure"]
117
+ stance = "net long" if net > 0 else ("net short" if net < 0 else "flat")
118
+ fpd = book["funding_per_day"]
119
+ return dict(headline=headline, median_funding_bp_8h=med_f, btc=btc, stance=stance, funding_per_day=fpd, rows=rows, regimes=regimes,
120
+ with_market=sum(1 for r in rows if r["fit"].startswith("WITH")), against=sum(1 for r in rows if r["fit"].startswith("AGAINST")))
121
+
122
+
123
+ # ================================================================ v2: the market the trader is in
124
+ import collections # noqa: E402
125
+
126
+ import taxonomy # noqa: E402
127
+
128
+ DAY_MS = 86_400_000
129
+
130
+
131
+ def _field(d, *names, default=None):
132
+ if isinstance(d, dict):
133
+ for n in names:
134
+ if n in d and d[n] is not None:
135
+ return d[n]
136
+ return default
137
+
138
+
139
+ def _ok(resp):
140
+ if isinstance(resp, dict):
141
+ if resp.get("success") is False:
142
+ return None
143
+ return resp.get("data", resp)
144
+ return resp
145
+
146
+
147
+ def breadth(ctx_main, ctx_xyz=None):
148
+ """Today's tape from the live contexts: 24h change per asset (mark vs previous day), grouped by asset
149
+ class; risk_on / risk_off / mixed by senpi-market-pulse's rule (groups moving ≥ 0.5% either way)."""
150
+ majors, large = taxonomy.crypto_tiers(ctx_main)
151
+ rows = []
152
+ for ctxs in (ctx_main, ctx_xyz):
153
+ if not ctxs:
154
+ continue
155
+ for u, c in zip(ctxs[0]["universe"], ctxs[1]):
156
+ try:
157
+ mark, prev = float(c["markPx"]), float(c["prevDayPx"])
158
+ if not mark or not prev:
159
+ continue
160
+ rows.append(dict(coin=u["name"], cls=taxonomy.classify(u["name"], majors, large), change_pct=100 * (mark / prev - 1),
161
+ funding_bp_8h=_f(c.get("funding")) * 8 * 1e4, oi_usd=_f(c.get("openInterest")) * mark, volume=_f(c.get("dayNtlVlm"))))
162
+ except (TypeError, ValueError, KeyError):
163
+ continue
164
+ groups = {}
165
+ for cls in sorted({r["cls"] for r in rows}):
166
+ rs = [r for r in rows if r["cls"] == cls and r["volume"] > 0]
167
+ if not rs:
168
+ continue
169
+ chg = [r["change_pct"] for r in rs]
170
+ groups[cls] = dict(label=taxonomy.label(cls), n=len(rs), avg_change_pct=statistics.mean(chg), median_change_pct=statistics.median(chg),
171
+ up=sum(1 for x in chg if x > 0), down=sum(1 for x in chg if x < 0), median_funding_bp_8h=statistics.median(r["funding_bp_8h"] for r in rs),
172
+ oi_usd=sum(r["oi_usd"] for r in rs))
173
+ avgs = [g["avg_change_pct"] for g in groups.values()]
174
+ down = sum(1 for x in avgs if x < -0.5); up = sum(1 for x in avgs if x > 0.5)
175
+ day = "risk_off" if (down >= up * 2 and down >= 3) else ("risk_on" if (up >= down * 2 and up >= 3) else "mixed")
176
+ main_rows = [r for r in rows if not r["coin"].startswith("xyz:") and r["volume"] > 0]
177
+ share_up = (sum(1 for r in main_rows if r["change_pct"] > 0) / len(main_rows)) if main_rows else None
178
+ by = {r["coin"]: r for r in rows}
179
+ return dict(day=day, share_up=share_up, groups=groups, btc_change_pct=by.get("BTC", {}).get("change_pct"), eth_change_pct=by.get("ETH", {}).get("change_pct"),
180
+ memes_vs_majors=(groups.get("memes", {}).get("avg_change_pct", 0) - groups.get("majors", {}).get("avg_change_pct", 0)) if ("memes" in groups and "majors" in groups) else None,
181
+ assets=by, majors=sorted(majors), large=sorted(large))
182
+
183
+
184
+ def daily_regimes(daily, basket):
185
+ """One label per UTC day over the window from daily candles of a basket: risk_on when BTC is up more
186
+ than 1% or ≥ 65% of the basket closed up; risk_off symmetric; else mixed."""
187
+ closes = {c: {r[0]: r[4] for r in rows} for c, rows in (daily or {}).items() if rows and c in basket}
188
+ btc = closes.get("BTC") or {}
189
+ days = sorted(set().union(*(set(v) for v in closes.values())) if closes else set())
190
+ out = {}
191
+ prev = {}
192
+ for d in days:
193
+ ups = downs = 0
194
+ for c, s in closes.items():
195
+ if d in s and c in prev and prev[c]:
196
+ ups += s[d] > prev[c]; downs += s[d] < prev[c]
197
+ if d in s:
198
+ prev[c] = s[d]
199
+ n = ups + downs
200
+ bchg = (btc[d] / btc[prev_d] - 1) if (d in btc and (prev_d := _prev_key(btc, d)) is not None) else None
201
+ share = (ups / n) if n else None
202
+ if bchg is not None and (bchg > 0.01 or (share is not None and share >= 0.65)):
203
+ lab = "risk_on"
204
+ elif bchg is not None and (bchg < -0.01 or (share is not None and share <= 0.35)):
205
+ lab = "risk_off"
206
+ else:
207
+ lab = "mixed"
208
+ out[d] = dict(label=lab, btc_change=bchg, share_up=share)
209
+ return out
210
+
211
+
212
+ def _prev_key(series, d):
213
+ ks = [k for k in series if k < d]
214
+ return max(ks) if ks else None
215
+
216
+
217
+ def regime_performance(closed, regimes):
218
+ """The trader's own record split by the regime of the day each trade was opened."""
219
+ if not regimes:
220
+ return None
221
+ days = sorted(regimes)
222
+ import bisect
223
+ def label_for(t):
224
+ i = bisect.bisect_right(days, t) - 1
225
+ return regimes[days[i]]["label"] if i >= 0 and t - days[i] < 2 * DAY_MS else None
226
+ g = collections.defaultdict(list)
227
+ for e in closed:
228
+ lab = label_for(e["open_time"])
229
+ if lab:
230
+ g[(lab, "ALL")].append(e); g[(lab, e["direction"])].append(e)
231
+ out = {}
232
+ for (lab, side), eps in g.items():
233
+ w = sum(e["realized"] for e in eps if e["realized"] > 0); l = -sum(e["realized"] for e in eps if e["realized"] <= 0)
234
+ out[f"{lab}/{side}"] = dict(regime=lab, side=side, trades=len(eps), wins=sum(1 for e in eps if e["win"]), realized=sum(e["realized"] for e in eps),
235
+ pf=(w / l) if l else (float("inf") if w else None))
236
+ counts = collections.Counter(v["label"] for v in regimes.values())
237
+ return dict(cells=out, days=dict(counts))
238
+
239
+
240
+ def funding_regime(resp):
241
+ d = _ok(resp)
242
+ if isinstance(d, str):
243
+ return dict(regime=d)
244
+ if not isinstance(d, dict):
245
+ return None
246
+ return dict(regime=_field(d, "regime", "funding_regime", "label", "state"), avg_annualized_pct=_field(d, "avg_annualized_funding", "average_annualized_funding_pct", "avg_annualized_pct"),
247
+ extreme_count=_field(d, "extreme_count", "extreme_funding_count", "num_extreme"), duration_hours=_field(d, "regime_duration_hours", "duration_hours", "duration"),
248
+ long_funding_count=_field(d, "long_funding_count", "longs_pay_count"), short_funding_count=_field(d, "short_funding_count", "shorts_pay_count"))
249
+
250
+
251
+ def attention(markets_resp, momentum_resp, book):
252
+ """Where the top traders' gains sit right now (leaderboard_get_markets) and who is entering momentum
253
+ in what (momentum events, last 4h) — against the trader's own book."""
254
+ out = dict(markets=[], overlap=[], momentum=[], with_momentum=[], against_momentum=[])
255
+ m = _ok(markets_resp) or {}
256
+ for _ in range(3):
257
+ if isinstance(m, dict):
258
+ m = _field(m, "markets", "data", "results", default=None) or next((v for v in m.values() if isinstance(v, list)), [])
259
+ rows = m if isinstance(m, list) else []
260
+ dom = [r for r in rows or [] if isinstance(r, dict) and r.get("is_dominant_direction")]
261
+ dom.sort(key=lambda r: -_f(r.get("pct_of_top_traders_gain")))
262
+ mine = {p["coin"]: p["side"] for p in book["positions"]}
263
+ for r in dom[:8]:
264
+ coin = (("xyz:" + r["token"]) if r.get("dex") == "xyz" else r.get("token")) if r.get("token") else None
265
+ row = dict(coin=coin, direction=(r.get("direction") or "").upper(), share_of_gains=_f(r.get("pct_of_top_traders_gain")), traders=int(_f(r.get("trader_count"))),
266
+ change_4h=_field(r, "token_price_change_pct_4h"), contribution_change_1h=_field(r, "contribution_pct_change_1h"))
267
+ out["markets"].append(row)
268
+ if coin in mine:
269
+ out["overlap"].append(dict(coin=coin, you=mine[coin], top=row["direction"], read="WITH" if mine[coin] == row["direction"] else "AGAINST"))
270
+ ev = _ok(momentum_resp) or []
271
+ for _ in range(3): # unwrap {data:{events:[...]}} however deep it comes
272
+ if isinstance(ev, dict):
273
+ ev = _field(ev, "events", "data", "results", "items", default=None) or next((v for v in ev.values() if isinstance(v, list)), [])
274
+ if not isinstance(ev, list):
275
+ ev = []
276
+ seen = collections.Counter()
277
+ for e in ev[:50]:
278
+ if not isinstance(e, dict):
279
+ continue
280
+ for p in e.get("top_positions") or []:
281
+ if not isinstance(p, dict):
282
+ continue
283
+ coin = _field(p, "token", "coin", "asset", "market"); side = str(_field(p, "direction", "side", default="")).upper()
284
+ if coin and side:
285
+ seen[(coin, side)] += 1
286
+ for (coin, side), n in seen.most_common(8):
287
+ out["momentum"].append(dict(coin=coin, direction=side, events=n))
288
+ if coin in mine:
289
+ (out["with_momentum"] if mine[coin] == side else out["against_momentum"]).append(coin)
290
+ return out
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env python3
2
+ """Self-contained streamable-HTTP MCP client (stdlib only).
3
+
4
+ Ported from senpi_runtime_helpers.SenpiClient so the discovery skill has ZERO cross-skill
5
+ dependency — it ships its own market-data transport. Read-only tool calls only.
6
+
7
+ client = MCPClient() # reads SENPI_AUTH_TOKEN / SENPI_MCP_URL from env
8
+ data = client.mcp_call("market_get_asset_data", asset="BTC") # -> unwrapped {success,data,...}
9
+
10
+ Returns the same unwrapped JSON `mcporter`/SenpiClient returns. JSON-RPC errors and tool-level
11
+ failures (result.isError) raise MCPError so callers log a warning instead of silently reading None.
12
+ """
13
+ # Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
14
+ import http.client
15
+ import itertools
16
+ import json
17
+ import os
18
+ from urllib.parse import urlsplit
19
+
20
+ MCP_URL = os.environ.get("SENPI_MCP_URL", "https://mcp.prod.senpi.ai/mcp")
21
+ AUTH = os.environ.get("SENPI_AUTH_TOKEN", "")
22
+ _PROTOCOL = "2025-03-26"
23
+
24
+
25
+ class MCPError(Exception):
26
+ pass
27
+
28
+
29
+ def _post(url, body, headers, timeout):
30
+ """POST a JSON body on a fresh connection; return (status, raw_bytes, content_type, session_id)."""
31
+ parts = urlsplit(url)
32
+ scheme = parts.scheme
33
+ host = parts.hostname or ""
34
+ port = parts.port or (443 if scheme == "https" else 80)
35
+ path = (parts.path or "/") + (("?" + parts.query) if parts.query else "")
36
+ data = json.dumps(body).encode("utf-8")
37
+ hdrs = {
38
+ "Host": parts.netloc,
39
+ "Content-Type": "application/json",
40
+ "Accept": "application/json, text/event-stream",
41
+ "Content-Length": str(len(data)),
42
+ }
43
+ hdrs.update(headers)
44
+ cls = http.client.HTTPSConnection if scheme == "https" else http.client.HTTPConnection
45
+ conn = cls(host, port, timeout=timeout)
46
+ try:
47
+ conn.request("POST", path, body=data, headers=hdrs)
48
+ resp = conn.getresponse()
49
+ raw = resp.read()
50
+ return resp.status, raw, (resp.getheader("Content-Type") or ""), resp.getheader("Mcp-Session-Id")
51
+ finally:
52
+ try:
53
+ conn.close()
54
+ except Exception: # noqa
55
+ pass
56
+
57
+
58
+ def _parse(raw, content_type):
59
+ """Streamable-HTTP returns a single JSON doc OR an SSE stream; return the first JSON-RPC payload."""
60
+ text = raw.decode("utf-8", errors="replace")
61
+ if "text/event-stream" in (content_type or "").lower():
62
+ for line in text.splitlines():
63
+ line = line.strip()
64
+ if line.startswith("data:"):
65
+ payload = line[5:].strip()
66
+ if payload and payload != "[DONE]":
67
+ try:
68
+ obj = json.loads(payload)
69
+ if isinstance(obj, dict):
70
+ return obj
71
+ except json.JSONDecodeError:
72
+ continue
73
+ return {}
74
+ if not text:
75
+ return {}
76
+ try:
77
+ obj = json.loads(text)
78
+ return obj if isinstance(obj, dict) else {"result": obj}
79
+ except json.JSONDecodeError:
80
+ return {}
81
+
82
+
83
+ def _unwrap(rpc):
84
+ """tools/call JSON-RPC -> the inner JSON document (content[0].text), like mcporter.
85
+
86
+ Raises MCPError on a JSON-RPC error or a tool-level failure (result.isError) — silence
87
+ here is how a missing/renamed tool masquerades as 'no data'."""
88
+ if not isinstance(rpc, dict):
89
+ return None
90
+ if rpc.get("error"):
91
+ err = rpc["error"]
92
+ raise MCPError(f"JSON-RPC error {err.get('code')}: {err.get('message')}"
93
+ if isinstance(err, dict) else str(err))
94
+ result = rpc.get("result")
95
+ if not isinstance(result, dict):
96
+ return result
97
+ content = result.get("content")
98
+ text = None
99
+ if isinstance(content, list) and content:
100
+ first = content[0]
101
+ if isinstance(first, dict) and "text" in first:
102
+ text = first["text"]
103
+ if result.get("isError"):
104
+ raise MCPError(f"tool error: {str(text)[:300]}")
105
+ if text is not None:
106
+ try:
107
+ doc = json.loads(text)
108
+ except (json.JSONDecodeError, TypeError):
109
+ return result
110
+ # the senpi server reports tool failures app-level: HTTP 200 + {"success": false, "error": {...}}
111
+ if isinstance(doc, dict) and doc.get("success") is False:
112
+ err = doc.get("error") or {}
113
+ raise MCPError(f"tool failed: {err.get('code')}: {err.get('message')}"
114
+ if isinstance(err, dict) else f"tool failed: {err}")
115
+ return doc
116
+ return result
117
+
118
+
119
+ class MCPClient:
120
+ """Lazy `initialize` handshake (streamable-http), then `tools/call`. One per process."""
121
+
122
+ def __init__(self, url=None, token=None):
123
+ self.url = url or MCP_URL
124
+ self.token = token if token is not None else AUTH
125
+ self._sid = None
126
+ self._initialized = False
127
+ self._id = itertools.count(1)
128
+
129
+ def _headers(self, sid=None):
130
+ h = {"Authorization": f"Bearer {self.token}"} if self.token else {}
131
+ sid = sid or self._sid
132
+ if sid:
133
+ h["Mcp-Session-Id"] = sid
134
+ return h
135
+
136
+ def _initialize(self, timeout):
137
+ if self._initialized:
138
+ return
139
+ body = {"jsonrpc": "2.0", "id": next(self._id), "method": "initialize",
140
+ "params": {"protocolVersion": _PROTOCOL, "capabilities": {},
141
+ "clientInfo": {"name": "quant-desk", "version": "2.0.0"}}}
142
+ status, _raw, _ct, sid = _post(self.url, body, self._headers(), timeout)
143
+ if status >= 400:
144
+ raise MCPError(f"initialize HTTP {status}")
145
+ self._sid = sid
146
+ # streamable-http requires notifications/initialized after init
147
+ note = {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}
148
+ _post(self.url, note, self._headers(sid), timeout)
149
+ self._initialized = True
150
+
151
+ def mcp_call(self, tool, timeout=12, **arguments):
152
+ self._initialize(timeout)
153
+ body = {"jsonrpc": "2.0", "id": next(self._id), "method": "tools/call",
154
+ "params": {"name": tool, "arguments": arguments}}
155
+ status, raw, ct, _sid = _post(self.url, body, self._headers(), timeout)
156
+ if status >= 400:
157
+ raise MCPError(f"{tool} HTTP {status}")
158
+ return _unwrap(_parse(raw, ct))