@pipeworx/mcp-kalshi 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pipeworx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # mcp-kalshi
2
+
3
+ Kalshi MCP — US-regulated prediction-market data (no auth on public reads).
4
+
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 883+ live data sources.
6
+
7
+ ## Tools
8
+
9
+ | Tool | Description |
10
+ |------|-------------|
11
+
12
+ ## Quick Start
13
+
14
+ Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
15
+
16
+ ```json
17
+ {
18
+ "mcpServers": {
19
+ "kalshi": {
20
+ "url": "https://gateway.pipeworx.io/kalshi/mcp"
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ Or connect to the full Pipeworx gateway for access to all 883+ data sources:
27
+
28
+ ```json
29
+ {
30
+ "mcpServers": {
31
+ "pipeworx": {
32
+ "url": "https://gateway.pipeworx.io/mcp"
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## Using with ask_pipeworx
39
+
40
+ Instead of calling tools directly, you can ask questions in plain English:
41
+
42
+ ```
43
+ ask_pipeworx({ question: "your question about Kalshi data" })
44
+ ```
45
+
46
+ The gateway picks the right tool and fills the arguments automatically.
47
+
48
+ ## More
49
+
50
+ - [All tools and guides](https://github.com/pipeworx-io/examples)
51
+ - [pipeworx.io](https://pipeworx.io)
52
+
53
+ ## License
54
+
55
+ MIT
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@pipeworx/mcp-kalshi",
3
+ "version": "0.1.0",
4
+ "description": "Kalshi MCP — US-regulated prediction-market data (no auth on public reads).",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "kalshi"],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/pipeworx-io/mcp-kalshi"
13
+ },
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^5.7.0"
19
+ }
20
+ }
package/server.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.pipeworx-io/kalshi",
4
+ "title": "Kalshi",
5
+ "description": "Kalshi MCP — US-regulated prediction-market data (no auth on public reads).",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://pipeworx.io/packs/kalshi",
8
+ "repository": {
9
+ "url": "https://github.com/pipeworx-io/mcp-kalshi",
10
+ "source": "github"
11
+ },
12
+ "remotes": [
13
+ {
14
+ "type": "streamable-http",
15
+ "url": "https://gateway.pipeworx.io/kalshi/mcp"
16
+ }
17
+ ]
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,569 @@
1
+ interface McpToolDefinition {
2
+ name: string;
3
+ description: string;
4
+ inputSchema: {
5
+ type: 'object';
6
+ properties: Record<string, unknown>;
7
+ required?: string[];
8
+ };
9
+ }
10
+
11
+ interface McpToolExport {
12
+ tools: McpToolDefinition[];
13
+ callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
14
+ meter?: { credits: number };
15
+ cost?: Record<string, unknown>;
16
+ provider?: string;
17
+ }
18
+
19
+ /**
20
+ * Kalshi MCP — US-regulated prediction-market data (no auth on public reads).
21
+ *
22
+ * Coverage: every open Kalshi market — politics, economics, Fed rates,
23
+ * climate, sports, science, weather. Each Kalshi EVENT (e.g. "Fed funds
24
+ * rate after Oct 2026 meeting?") groups multiple MARKETS (one per
25
+ * outcome bucket), much like Polymarket's event → markets structure.
26
+ *
27
+ * All tools prefixed with `kalshi_` to dodge collisions with the
28
+ * polymarket pack (which has similarly-named tools).
29
+ *
30
+ * Cross-market arb angle: when both Kalshi and Polymarket list the same
31
+ * resolving event, their YES prices can disagree by several pp because
32
+ * the two venues have different participant pools. Agents can use this
33
+ * pack alongside `polymarket_*` to compute the spread.
34
+ *
35
+ * Docs: https://trading-api.readme.io/reference/getting-started
36
+ */
37
+
38
+
39
+ const BASE = 'https://api.elections.kalshi.com/trade-api/v2';
40
+ const UA = 'pipeworx-mcp-kalshi/1.0 (+https://pipeworx.io)';
41
+
42
+ const tools: McpToolExport['tools'] = [
43
+ {
44
+ name: 'kalshi_markets',
45
+ description:
46
+ 'List/search Kalshi markets. Optional filters: status (open|closed|settled), event_ticker (group by event), series_ticker (group by series like KXFED for Fed rate). Returns ticker, title, yes_ask/no_ask (in cents 1–99), volume, open_interest. Use this to discover markets; use kalshi_market for full detail.',
47
+ inputSchema: {
48
+ type: 'object',
49
+ properties: {
50
+ status: { type: 'string', description: 'open | closed | settled (default open)' },
51
+ event_ticker: { type: 'string', description: 'Filter to one event (e.g. "KXFED-26OCT")' },
52
+ series_ticker: { type: 'string', description: 'Filter to one series (e.g. "KXFED" for Fed funds rate)' },
53
+ limit: { type: 'number', description: '1-1000 (default 100)' },
54
+ cursor: { type: 'string', description: 'Pagination cursor from previous response' },
55
+ },
56
+ },
57
+ },
58
+ {
59
+ name: 'kalshi_market',
60
+ description:
61
+ 'AUTHORITATIVE detail for one Kalshi market by ticker (e.g. "KXFED-26OCT-T3.50"). Returns the rules text (so you know exactly what the market settles on — critical before quoting odds), yes_ask + no_ask prices in cents, last_price, volume, open_interest, expiration date, settlement criteria. Use after kalshi_events / kalshi_event to drill into a specific market, or when you already have a Kalshi ticker. For depth-of-book use kalshi_orderbook.',
62
+ inputSchema: {
63
+ type: 'object',
64
+ properties: {
65
+ ticker: { type: 'string', description: 'Kalshi market ticker, e.g. "KXFED-26OCT-T3.50"' },
66
+ },
67
+ required: ['ticker'],
68
+ },
69
+ },
70
+ {
71
+ name: 'kalshi_events',
72
+ description:
73
+ 'List/browse Kalshi events (event = a question with one-or-more child markets, e.g. "Fed funds rate after Oct 2026 meeting?" with 11 markets, one per rate bucket). Filter by status (open / settled), series_ticker (KXFED, KXBTC, KXCPI, etc.), or category. Use this as a discovery tool — to find what events Kalshi has open for a given topic family. For a specific event\'s child markets see kalshi_event; for one specific market see kalshi_market.',
74
+ inputSchema: {
75
+ type: 'object',
76
+ properties: {
77
+ status: { type: 'string', description: 'open | closed | settled (default open)' },
78
+ series_ticker: { type: 'string', description: 'e.g. "KXFED"' },
79
+ limit: { type: 'number', description: '1-200 (default 100)' },
80
+ cursor: { type: 'string', description: 'Pagination cursor' },
81
+ },
82
+ },
83
+ },
84
+ {
85
+ name: 'kalshi_event',
86
+ description:
87
+ 'AUTHORITATIVE odds from Kalshi — the only CFTC-regulated US prediction-market exchange (US persons CAN legally trade here, unlike Polymarket). Returns one event with ALL its child markets nested: event title + each market\'s ticker, subtitle, yes_ask price, volume. Use when you need the full partition for an outright bet ("Fed funds in June 2026: each rate level is one market"). Pass include_orderbook=true to fetch live top-of-book for each market (slower but populates yes_bid/yes_ask/no_bid/no_ask + implied_yes_prob — required for most macro events since the nested response leaves prices null on the public unauth API). For cross-venue spreads vs Polymarket, see polymarket_kalshi_spread.',
88
+ inputSchema: {
89
+ type: 'object',
90
+ properties: {
91
+ event_ticker: { type: 'string', description: 'Kalshi event ticker, e.g. "KXFED-26OCT"' },
92
+ include_orderbook: { type: 'boolean', description: 'Default false. When true, fetches per-market orderbook in parallel and patches in best bid/ask + recomputed implied_yes_prob. Adds ~1s for events with ~10 markets. Required when the nested response returns null prices (common for macro events on the unauth API).' },
93
+ },
94
+ required: ['event_ticker'],
95
+ },
96
+ },
97
+ {
98
+ name: 'kalshi_series',
99
+ description:
100
+ 'List Kalshi series (a series groups related events over time — e.g. "KXFED" series has one event per FOMC meeting). Useful to find the canonical handle for recurring questions.',
101
+ inputSchema: {
102
+ type: 'object',
103
+ properties: {
104
+ category: { type: 'string', description: 'Politics | Economics | Climate | Sports | Science | World' },
105
+ limit: { type: 'number', description: '1-1000 (default 200)' },
106
+ },
107
+ },
108
+ },
109
+ {
110
+ name: 'kalshi_orderbook',
111
+ description:
112
+ 'Current YES/NO orderbook (bids + asks with size, in cents) for a market ticker. Use to see live liquidity depth before judging whether an edge is tradable. Returns sorted price/quantity levels.',
113
+ inputSchema: {
114
+ type: 'object',
115
+ properties: {
116
+ ticker: { type: 'string', description: 'Kalshi market ticker' },
117
+ depth: { type: 'number', description: 'Levels to return per side (default 5, max 100)' },
118
+ },
119
+ required: ['ticker'],
120
+ },
121
+ },
122
+ {
123
+ name: 'kalshi_trades',
124
+ description:
125
+ 'Recent executed trades for a market ticker. Returns most-recent N trades with price (cents), size, side, timestamp. Useful for sanity-checking what the market is actually paying vs the resting orderbook.',
126
+ inputSchema: {
127
+ type: 'object',
128
+ properties: {
129
+ ticker: { type: 'string', description: 'Kalshi market ticker' },
130
+ limit: { type: 'number', description: '1-1000 (default 50)' },
131
+ },
132
+ required: ['ticker'],
133
+ },
134
+ },
135
+ {
136
+ name: 'kalshi_price_history',
137
+ description:
138
+ 'Historical price/probability time-series (candlesticks) for a Kalshi market — how the YES odds moved over time. Pass a market ticker (e.g. "KXFEDDECISION-28JAN-H26"). Returns OHLC candles: YES price (open/high/low/close/mean as probability 0-1), best bid/ask, volume, and open interest per interval. Use for "how has this market moved", trend/momentum, or charting a prediction over time. The Kalshi analogue of polymarket_price_history. Pick interval "1h" or "1d" (default) and lookback_days.',
139
+ inputSchema: {
140
+ type: 'object',
141
+ properties: {
142
+ ticker: { type: 'string', description: 'Kalshi market ticker (e.g. "KXFEDDECISION-28JAN-H26"). The series is derived from the ticker automatically.' },
143
+ interval: { type: 'string', description: '"1h" (hourly) | "1d" (daily, default) | "1m" (per-minute). Coarser intervals cover longer history.' },
144
+ lookback_days: { type: 'number', description: 'How many days back to fetch (1-365, default 30).' },
145
+ },
146
+ required: ['ticker'],
147
+ },
148
+ },
149
+ {
150
+ name: 'kalshi_exchange_status',
151
+ description:
152
+ 'Exchange-level status: is the trading floor open, are deposits/withdrawals enabled, any scheduled maintenance. Cheap check before a batch script.',
153
+ inputSchema: { type: 'object', properties: {} },
154
+ },
155
+ {
156
+ name: 'kalshi_macro',
157
+ description:
158
+ 'Friendly-name shortcut for the most-asked Kalshi macro series: "Fed" (FOMC rate buckets), "BTC" (Bitcoin price ranges), "ETH" (Ethereum), "CPI" (monthly inflation), "GDP" (quarterly growth), "SP500" (S&P 500 EOY close), "Recession" (NBER recession calls). Returns the soonest-expiring open event for that series with all child markets + implied probabilities, so agents can ask about macro odds without knowing Kalshi\'s ticker scheme.',
159
+ inputSchema: {
160
+ type: 'object',
161
+ properties: {
162
+ topic: { type: 'string', description: 'Fed | BTC | ETH | CPI | GDP | SP500 | Recession' },
163
+ },
164
+ required: ['topic'],
165
+ },
166
+ },
167
+ ];
168
+
169
+ // Friendly-name → Kalshi series-ticker map. Mirrors what NEXUS exposes
170
+ // as `get_kalshi_prediction_odds` so an agent that knows "Fed" doesn't
171
+ // need to know that the canonical Kalshi handle is "KXFED".
172
+ const MACRO_SERIES: Record<string, string> = {
173
+ fed: 'KXFED', // Fed funds rate after each FOMC meeting
174
+ btc: 'KXBTC', // Bitcoin price range (weekly)
175
+ bitcoin: 'KXBTC',
176
+ eth: 'KXETHY', // ETH price EOY
177
+ ethereum: 'KXETHY',
178
+ cpi: 'KXCPI', // CPI inflation (monthly)
179
+ gdp: 'KXGDP', // GDP growth (quarterly)
180
+ sp500: 'KXSP500', // S&P 500 EOY close
181
+ recession: 'KXRECSS', // NBER recession calls
182
+ };
183
+
184
+ async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
185
+ switch (name) {
186
+ case 'kalshi_markets': {
187
+ const params = new URLSearchParams();
188
+ params.set('status', String(args.status ?? 'open'));
189
+ params.set('limit', String(Math.min(1000, Math.max(1, (args.limit as number) ?? 100))));
190
+ if (args.event_ticker) params.set('event_ticker', String(args.event_ticker));
191
+ if (args.series_ticker) params.set('series_ticker', String(args.series_ticker));
192
+ if (args.cursor) params.set('cursor', String(args.cursor));
193
+ const data = (await kalshiGet(`/markets?${params}`)) as { markets?: KalshiMarket[]; cursor?: string };
194
+ return {
195
+ count: data.markets?.length ?? 0,
196
+ cursor: data.cursor ?? null,
197
+ markets: (data.markets ?? []).map((m) => formatMarket(m)),
198
+ };
199
+ }
200
+ case 'kalshi_market': {
201
+ const ticker = reqStr(args, 'ticker', '"KXFED-26OCT-T3.50"');
202
+ const data = (await kalshiGet(`/markets/${encodeURIComponent(ticker)}`)) as { market?: KalshiMarket };
203
+ if (!data.market) return { found: false, ticker };
204
+ return { found: true, market: formatMarket(data.market, /* full */ true) };
205
+ }
206
+ case 'kalshi_events': {
207
+ const params = new URLSearchParams();
208
+ params.set('status', String(args.status ?? 'open'));
209
+ params.set('limit', String(Math.min(200, Math.max(1, (args.limit as number) ?? 100))));
210
+ if (args.series_ticker) params.set('series_ticker', String(args.series_ticker));
211
+ if (args.cursor) params.set('cursor', String(args.cursor));
212
+ const data = (await kalshiGet(`/events?${params}`)) as { events?: KalshiEvent[]; cursor?: string };
213
+ return {
214
+ count: data.events?.length ?? 0,
215
+ cursor: data.cursor ?? null,
216
+ events: (data.events ?? []).map(formatEvent),
217
+ };
218
+ }
219
+ case 'kalshi_event': {
220
+ const et = reqStr(args, 'event_ticker', '"KXFED-26OCT"');
221
+ const includeOrderbook = args.include_orderbook === true;
222
+ const data = (await kalshiGet(
223
+ `/events/${encodeURIComponent(et)}?with_nested_markets=true`,
224
+ )) as { event?: KalshiEvent & { markets?: KalshiMarket[] } };
225
+ if (!data.event) return { found: false, event_ticker: et };
226
+ let markets = data.event.markets ?? [];
227
+ if (includeOrderbook && markets.length > 0) {
228
+ // Augment each market with top-of-book derived from the live orderbook
229
+ // endpoint. Kalshi's nested-markets response leaves prices null for
230
+ // many macro events under the unauth API; the orderbook endpoint
231
+ // does expose depth. Fetch in parallel — N small calls, capped at
232
+ // 30 markets per event to bound latency.
233
+ markets = await Promise.all(markets.slice(0, 30).map(enrichMarketWithOrderbook));
234
+ }
235
+ return {
236
+ found: true,
237
+ event: formatEvent(data.event),
238
+ markets: markets.map((m) => formatMarket(m)),
239
+ };
240
+ }
241
+ case 'kalshi_series': {
242
+ const params = new URLSearchParams();
243
+ params.set('limit', String(Math.min(1000, Math.max(1, (args.limit as number) ?? 200))));
244
+ if (args.category) params.set('category', String(args.category));
245
+ const data = (await kalshiGet(`/series?${params}`)) as { series?: Array<Record<string, unknown>> };
246
+ return {
247
+ count: data.series?.length ?? 0,
248
+ series: (data.series ?? []).map((s) => ({
249
+ ticker: s.ticker,
250
+ title: s.title,
251
+ category: s.category,
252
+ frequency: s.frequency,
253
+ })),
254
+ };
255
+ }
256
+ case 'kalshi_orderbook': {
257
+ const ticker = reqStr(args, 'ticker', '"KXFED-26OCT-T3.50"');
258
+ const depth = Math.min(100, Math.max(1, (args.depth as number) ?? 5));
259
+ const data = (await kalshiGet(
260
+ `/markets/${encodeURIComponent(ticker)}/orderbook?depth=${depth}`,
261
+ )) as { orderbook?: { yes?: number[][]; no?: number[][] } };
262
+ const ob = data.orderbook ?? {};
263
+ return {
264
+ ticker,
265
+ yes_bids: (ob.yes ?? []).map(([price, qty]) => ({ price_cents: price, quantity: qty })),
266
+ no_bids: (ob.no ?? []).map(([price, qty]) => ({ price_cents: price, quantity: qty })),
267
+ note: 'Kalshi quotes in cents (1-99). yes_bid X means someone will pay X cents for a YES contract that pays $1 on YES resolution. Implied YES probability = price_cents / 100.',
268
+ };
269
+ }
270
+ case 'kalshi_trades': {
271
+ const ticker = reqStr(args, 'ticker', '"KXFED-26OCT-T3.50"');
272
+ const limit = Math.min(1000, Math.max(1, (args.limit as number) ?? 50));
273
+ const data = (await kalshiGet(
274
+ `/markets/trades?ticker=${encodeURIComponent(ticker)}&limit=${limit}`,
275
+ )) as { trades?: Array<Record<string, unknown>>; cursor?: string };
276
+ return {
277
+ ticker,
278
+ count: data.trades?.length ?? 0,
279
+ cursor: data.cursor ?? null,
280
+ trades: (data.trades ?? []).map((t) => ({
281
+ trade_id: t.trade_id,
282
+ ticker: t.ticker,
283
+ yes_price_cents: t.yes_price,
284
+ no_price_cents: t.no_price,
285
+ count: t.count,
286
+ taker_side: t.taker_side,
287
+ created_time: t.created_time,
288
+ })),
289
+ };
290
+ }
291
+ case 'kalshi_price_history': {
292
+ const ticker = reqStr(args, 'ticker', '"KXFEDDECISION-28JAN-H26"');
293
+ const interval = String(args.interval ?? '1d').toLowerCase();
294
+ const PERIOD: Record<string, number> = { '1m': 1, '1min': 1, '1h': 60, '1hr': 60, '1d': 1440, '1day': 1440 };
295
+ const period = PERIOD[interval] ?? 1440;
296
+ const lookback = Math.min(365, Math.max(1, (args.lookback_days as number) ?? 30));
297
+ // Series ticker is the first dash-segment of the market ticker
298
+ // (KXFEDDECISION-28JAN-H26 → KXFEDDECISION). Required in the candlesticks path.
299
+ const series = ticker.split('-')[0];
300
+ const end = Math.floor(Date.now() / 1000);
301
+ const start = end - lookback * 86_400;
302
+ const data = (await kalshiGet(
303
+ `/series/${encodeURIComponent(series)}/markets/${encodeURIComponent(ticker)}/candlesticks?start_ts=${start}&end_ts=${end}&period_interval=${period}`,
304
+ )) as { error?: string; message?: string; candlesticks?: Array<Record<string, unknown>> };
305
+ if (data.error) return data;
306
+
307
+ const num = (v: unknown): number | null => {
308
+ const n = typeof v === 'string' ? parseFloat(v) : typeof v === 'number' ? v : NaN;
309
+ return Number.isFinite(n) ? n : null;
310
+ };
311
+ const dollars = (obj: unknown, field: string): number | null =>
312
+ num((obj as Record<string, unknown> | undefined)?.[field]);
313
+
314
+ const candles = (data.candlesticks ?? []).map((c) => {
315
+ const price = c.price as Record<string, unknown> | undefined;
316
+ return {
317
+ timestamp: c.end_period_ts ? new Date(Number(c.end_period_ts) * 1000).toISOString() : null,
318
+ unix: c.end_period_ts ?? null,
319
+ yes_open: dollars(price, 'open_dollars'),
320
+ yes_high: dollars(price, 'high_dollars'),
321
+ yes_low: dollars(price, 'low_dollars'),
322
+ yes_close: dollars(price, 'close_dollars'),
323
+ yes_mean: dollars(price, 'mean_dollars'),
324
+ yes_bid_close: dollars(c.yes_bid, 'close_dollars'),
325
+ yes_ask_close: dollars(c.yes_ask, 'close_dollars'),
326
+ volume: num(c.volume_fp),
327
+ open_interest: num(c.open_interest_fp),
328
+ };
329
+ });
330
+
331
+ return {
332
+ ticker,
333
+ series,
334
+ interval,
335
+ period_minutes: period,
336
+ lookback_days: lookback,
337
+ point_count: candles.length,
338
+ coverage: candles.length === 0
339
+ ? 'no candles in window — market may be newly opened, illiquid, or have no trades in the lookback period (not a tool limit)'
340
+ : `${candles.length} candle(s)`,
341
+ candles,
342
+ };
343
+ }
344
+ case 'kalshi_exchange_status': {
345
+ const data = (await kalshiGet('/exchange/status')) as Record<string, unknown>;
346
+ return data;
347
+ }
348
+ case 'kalshi_macro': {
349
+ const topic = reqStr(args, 'topic', '"Fed"').toLowerCase();
350
+ const series = MACRO_SERIES[topic];
351
+ if (!series) {
352
+ return {
353
+ error: 'unknown_topic',
354
+ topic,
355
+ known_topics: Object.keys(MACRO_SERIES),
356
+ message: `Unknown topic "${topic}". Use one of: ${Object.keys(MACRO_SERIES).join(', ')}. For arbitrary series, use kalshi_events with series_ticker.`,
357
+ };
358
+ }
359
+ // Find the soonest-expiring open event for this series.
360
+ const events = (await kalshiGet(`/events?series_ticker=${series}&status=open&limit=50`)) as { events?: KalshiEvent[] };
361
+ const open = events.events ?? [];
362
+ if (open.length === 0) {
363
+ return { topic, series_ticker: series, found: false, message: `No open events for series ${series}.` };
364
+ }
365
+ // Sort by strike_date ascending (soonest first); fall back to first.
366
+ open.sort((a, b) => (a.strike_date ?? 'z').localeCompare(b.strike_date ?? 'z'));
367
+ const ev = open[0];
368
+ // Pull that event's nested markets so the caller gets actionable prices in one call.
369
+ const detail = (await kalshiGet(
370
+ `/events/${encodeURIComponent(ev.event_ticker ?? '')}?with_nested_markets=true`,
371
+ )) as { event?: KalshiEvent & { markets?: KalshiMarket[] } };
372
+ const markets = (detail.event?.markets ?? []).map((m) => formatMarket(m));
373
+ return {
374
+ topic,
375
+ series_ticker: series,
376
+ event: formatEvent(detail.event ?? ev),
377
+ markets,
378
+ other_open_events: open.slice(1, 6).map(formatEvent),
379
+ note: 'Returns the soonest-expiring open event for this series. Use other_open_events to drill into later periods, or kalshi_event with their event_ticker for full detail.',
380
+ };
381
+ }
382
+ default:
383
+ throw new Error(`Unknown tool: ${name}`);
384
+ }
385
+ }
386
+
387
+ // ── Raw types from Kalshi ────────────────────────────────────────────
388
+
389
+ interface KalshiMarket {
390
+ ticker?: string;
391
+ event_ticker?: string;
392
+ title?: string;
393
+ subtitle?: string;
394
+ yes_sub_title?: string;
395
+ no_sub_title?: string;
396
+ status?: string;
397
+ yes_ask?: number;
398
+ yes_bid?: number;
399
+ no_ask?: number;
400
+ no_bid?: number;
401
+ last_price?: number;
402
+ previous_yes_ask?: number;
403
+ previous_yes_bid?: number;
404
+ volume?: number;
405
+ volume_24h?: number;
406
+ liquidity?: number;
407
+ open_interest?: number;
408
+ expiration_time?: string;
409
+ close_time?: string;
410
+ open_time?: string;
411
+ rules_primary?: string;
412
+ rules_secondary?: string;
413
+ category?: string;
414
+ }
415
+
416
+ interface KalshiEvent {
417
+ event_ticker?: string;
418
+ series_ticker?: string;
419
+ title?: string;
420
+ sub_title?: string;
421
+ category?: string;
422
+ mutually_exclusive?: boolean;
423
+ strike_date?: string;
424
+ strike_period?: string;
425
+ }
426
+
427
+ // ── Formatters ───────────────────────────────────────────────────────
428
+
429
+ // Trim a market down to the fields agents actually use; include rules text
430
+ // only on the full single-market lookup so list responses stay small.
431
+ function formatMarket(m: KalshiMarket, full = false): Record<string, unknown> {
432
+ // Prefer the mid of bid/ask (real two-sided market). When only one side
433
+ // is quoted, fall back to that side. When the orderbook is empty, use
434
+ // last_price (the most recent trade). When even that's missing, infer
435
+ // yes_prob from no_ask (1 - no/100). Empty orderbook is common on
436
+ // forward-dated event markets (e.g., KXCPI-26NOV has 7 buckets with
437
+ // no asks yet), and dropping them silently breaks downstream tools
438
+ // like polymarket_kalshi_spread that filter on yes_prob != null.
439
+ const impliedYesProb = computeImpliedYesProb(m);
440
+ const base: Record<string, unknown> = {
441
+ ticker: m.ticker ?? null,
442
+ event_ticker: m.event_ticker ?? null,
443
+ title: m.title ?? null,
444
+ subtitle: m.subtitle ?? m.yes_sub_title ?? null,
445
+ status: m.status ?? null,
446
+ // Prices in cents on the wire; surface the implied probability too.
447
+ yes_ask_cents: m.yes_ask ?? null,
448
+ yes_bid_cents: m.yes_bid ?? null,
449
+ no_ask_cents: m.no_ask ?? null,
450
+ last_price_cents: m.last_price ?? null,
451
+ implied_yes_prob: impliedYesProb,
452
+ implied_yes_prob_source: impliedYesProbSource(m),
453
+ volume: m.volume ?? null,
454
+ volume_24h: m.volume_24h ?? null,
455
+ open_interest: m.open_interest ?? null,
456
+ close_time: m.close_time ?? m.expiration_time ?? null,
457
+ category: m.category ?? null,
458
+ };
459
+ if (full) {
460
+ base.rules_primary = m.rules_primary ?? null;
461
+ base.rules_secondary = m.rules_secondary ?? null;
462
+ }
463
+ return base;
464
+ }
465
+
466
+ function computeImpliedYesProb(m: KalshiMarket): number | null {
467
+ // Mid-price when both sides quoted
468
+ if (typeof m.yes_ask === 'number' && typeof m.yes_bid === 'number') {
469
+ return +(((m.yes_ask + m.yes_bid) / 2) / 100).toFixed(4);
470
+ }
471
+ if (typeof m.yes_ask === 'number') return +(m.yes_ask / 100).toFixed(4);
472
+ if (typeof m.yes_bid === 'number') return +(m.yes_bid / 100).toFixed(4);
473
+ if (typeof m.last_price === 'number') return +(m.last_price / 100).toFixed(4);
474
+ // Infer from no_ask: if NO is offered at X cents, YES is implied at (100-X)
475
+ if (typeof m.no_ask === 'number') return +((100 - m.no_ask) / 100).toFixed(4);
476
+ return null;
477
+ }
478
+
479
+ function impliedYesProbSource(m: KalshiMarket): string | null {
480
+ if (typeof m.yes_ask === 'number' && typeof m.yes_bid === 'number') return 'mid';
481
+ if (typeof m.yes_ask === 'number') return 'yes_ask';
482
+ if (typeof m.yes_bid === 'number') return 'yes_bid';
483
+ if (typeof m.last_price === 'number') return 'last_price';
484
+ if (typeof m.no_ask === 'number') return 'inferred_from_no_ask';
485
+ return null;
486
+ }
487
+
488
+ // Patch a market with top-of-book derived from the /orderbook endpoint.
489
+ // Why this exists: Kalshi's /events/...?with_nested_markets=true endpoint
490
+ // returns null prices for most macro events on the unauth API right now,
491
+ // even though the /orderbook endpoint exposes a full depth ladder. The
492
+ // orderbook returns yes_dollars / no_dollars as [price_string, size_string]
493
+ // arrays sorted from low → high price. Best YES bid = highest yes_dollars
494
+ // price (most aggressive buyer of YES). Best YES ask = 1 − best NO bid
495
+ // (someone bidding 99¢ for NO implicitly offers YES at 1¢). Returns a
496
+ // shallow-merged copy of the input market with yes_ask/yes_bid/no_ask/
497
+ // no_bid populated where derivable; original fields preserved when set.
498
+ async function enrichMarketWithOrderbook(m: KalshiMarket): Promise<KalshiMarket> {
499
+ if (!m.ticker) return m;
500
+ // Don't re-fetch if we already have a top-of-book.
501
+ if (typeof m.yes_ask === 'number' || typeof m.yes_bid === 'number') return m;
502
+ try {
503
+ const ob = await kalshiGet(`/markets/${encodeURIComponent(m.ticker)}/orderbook`) as {
504
+ orderbook_fp?: { yes_dollars?: Array<[string, string]>; no_dollars?: Array<[string, string]> };
505
+ };
506
+ const yesLadder = ob.orderbook_fp?.yes_dollars ?? [];
507
+ const noLadder = ob.orderbook_fp?.no_dollars ?? [];
508
+ // yes_dollars sorted low→high; best YES bid is the top of book = last entry.
509
+ const bestYesBid = yesLadder.length > 0 ? parseFloat(yesLadder[yesLadder.length - 1][0]) : null;
510
+ const bestNoBid = noLadder.length > 0 ? parseFloat(noLadder[noLadder.length - 1][0]) : null;
511
+ // YES ask = 1 - best NO bid; YES bid = best YES bid (direct).
512
+ const yesBidCents = bestYesBid != null ? Math.round(bestYesBid * 100) : null;
513
+ const yesAskCents = bestNoBid != null ? Math.round((1 - bestNoBid) * 100) : null;
514
+ const noBidCents = bestNoBid != null ? Math.round(bestNoBid * 100) : null;
515
+ const noAskCents = bestYesBid != null ? Math.round((1 - bestYesBid) * 100) : null;
516
+ return {
517
+ ...m,
518
+ yes_bid: m.yes_bid ?? yesBidCents ?? undefined,
519
+ yes_ask: m.yes_ask ?? yesAskCents ?? undefined,
520
+ no_bid: m.no_bid ?? noBidCents ?? undefined,
521
+ no_ask: m.no_ask ?? noAskCents ?? undefined,
522
+ } as KalshiMarket;
523
+ } catch {
524
+ // Orderbook fetch failed — return the original market unchanged.
525
+ return m;
526
+ }
527
+ }
528
+
529
+ function formatEvent(e: KalshiEvent): Record<string, unknown> {
530
+ return {
531
+ event_ticker: e.event_ticker ?? null,
532
+ series_ticker: e.series_ticker ?? null,
533
+ title: e.title ?? null,
534
+ sub_title: e.sub_title ?? null,
535
+ category: e.category ?? null,
536
+ mutually_exclusive: e.mutually_exclusive ?? null,
537
+ strike_date: e.strike_date ?? null,
538
+ strike_period: e.strike_period ?? null,
539
+ };
540
+ }
541
+
542
+ // ── Helpers ──────────────────────────────────────────────────────────
543
+
544
+ async function kalshiGet(path: string): Promise<unknown> {
545
+ const res = await fetch(`${BASE}${path}`, {
546
+ headers: { Accept: 'application/json', 'User-Agent': UA },
547
+ });
548
+ if (res.status === 404) {
549
+ return { error: 'not_found', message: 'Kalshi: ticker not found.' };
550
+ }
551
+ if (res.status === 429) {
552
+ throw new Error('Kalshi rate limit (HTTP 429). Try again in a minute.');
553
+ }
554
+ if (!res.ok) {
555
+ const t = await res.text();
556
+ throw new Error(`Kalshi: ${res.status} ${t.slice(0, 200)}`);
557
+ }
558
+ return res.json();
559
+ }
560
+
561
+ function reqStr(args: Record<string, unknown>, key: string, example: string): string {
562
+ const v = args[key];
563
+ if (typeof v !== 'string' || !v.trim()) {
564
+ throw new Error(`Required argument "${key}" is missing. Pass a string like ${example}.`);
565
+ }
566
+ return v.trim();
567
+ }
568
+
569
+ export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true
12
+ },
13
+ "include": ["src"]
14
+ }