Kalshi Read-Only MCP Server
by palm8-dev
README.md
# Kalshi Read-Only MCP Server (v0.1)
A tiny MCP server that lets an AI agent (Grok, Claude, anything that speaks MCP)
read **real Kalshi market data** instead of guessing prices from web search.
It exposes exactly three tools:
| Tool | What it does |
| --- | --- |
| `kalshi_search_markets` | Find markets by keyword, series, or event |
| `kalshi_get_market` | Full detail for one market ticker |
| `kalshi_get_orderbook` | Resting bid levels, best prices, depth totals |
**Read-only by design.** The server only sends `GET` requests to Kalshi's public
market-data endpoints. It has no Kalshi login, asks for no API keys, stores no
credentials, and cannot place, change, or cancel an order.
---
## 1. What you need
- Python 3.10 or newer (this project was built and tested on 3.14)
- An internet connection (it calls `https://api.elections.kalshi.com`)
- No Kalshi account
## 2. Install
From the project folder:
```bash
python -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements-dev.txt
```
`requirements.txt` holds just what the server needs to run (that is what a host
installs). `requirements-dev.txt` adds the test tools on top.
On macOS/Linux the interpreter is `.venv/bin/python` instead.
## 3. Run the tests
```bash
.venv\Scripts\python.exe -m pytest
```
The tests use a fake Kalshi API (no network, no rate limits), so they run in
about a second and always give the same answer.
## 4. Start the server
```bash
.venv\Scripts\python.exe server.py
```
You will see:
```
Kalshi read-only MCP server 0.1.0
MCP endpoint : http://127.0.0.1:8000/mcp
Health check : http://127.0.0.1:8000/healthz
```
- **MCP endpoint:** `http://127.0.0.1:8000/mcp` — this is the URL an MCP client connects to.
- **Health check:** `http://127.0.0.1:8000/healthz` — open it in a browser to confirm the server is alive.
Useful flags and environment variables:
| Option | Default | Meaning |
| --- | --- | --- |
| `--port 9000` | `8000` | Port to listen on (also `PORT` env var) |
| `--host 0.0.0.0` | `127.0.0.1` | Listen on all interfaces, not just this machine |
| `MCP_AUTH_TOKEN` | unset | If set, callers must send `Authorization: Bearer <token>` |
| `KALSHI_API_BASE` | Kalshi production | Point at a different Kalshi base URL |
Stop the server with `Ctrl+C`.
## 5. Give it a public HTTPS URL
Grok connects over the internet, so `127.0.0.1` is not reachable from it.
### Recommended: deploy to Render
This repo includes `render.yaml`, so Render can configure itself.
1. Push this project to a GitHub repository.
2. In Render: **New → Blueprint**, pick the repo, and confirm.
3. Render prompts for `MCP_AUTH_TOKEN`. Paste a long random string — or leave it
blank to run without auth. Generate one with:
`python -c "import secrets; print(secrets.token_urlsafe(32))"`
4. Wait for the first build, then open `https://<your-service>.onrender.com/healthz`
to confirm it is alive.
5. Your MCP URL is `https://<your-service>.onrender.com/mcp`.
**Know this about the free tier:** Render spins a free service down after 15
minutes with no traffic, and waking it takes roughly a minute. The first tool
call after an idle period can therefore time out inside Grok. Two ways around it:
- Point a free uptime monitor (UptimeRobot, cron-job.org) at `/healthz` every
10 minutes. That keeps the service awake and fits inside the 750 free instance
hours per month for a single service. `/healthz` needs no auth, so this works
even with a token set.
- Or upgrade to Render's paid Starter tier, which never sleeps.
### Temporary alternative: a tunnel from your own PC
Useful for a quick test; the server stays on your machine.
```bash
cloudflared tunnel --url http://localhost:8000 # winget install --id Cloudflare.cloudflared
ssh -R 80:localhost:8000 nokey@localhost.run # no install, uses built-in ssh
```
Both hand you a random HTTPS URL that **changes every restart**, and both stop
working when your PC sleeps. localhost.run also drops idle tunnels and rotates
its hostnames while connected.
### Protecting a public URL
Anything on the open internet gets scanned, and a permanent hostname gets found
far sooner than a random tunnel one. Set a token so only your agent can call it:
```bash
set MCP_AUTH_TOKEN=pick-a-long-random-string
.venv\Scripts\python.exe server.py
```
Callers then need the header `Authorization: Bearer pick-a-long-random-string`.
`/healthz` stays open so uptime checks and Render's own health probe keep working.
## 6. Connect it to Grok
1. Start the server and the tunnel (or deploy it).
2. In Grok, add a **Custom MCP connector**.
3. Paste the full MCP URL, including `/mcp`:
`https://your-public-host.example.com/mcp`
4. If you set `MCP_AUTH_TOKEN`, add the `Authorization: Bearer <token>` header
in the connector's auth settings.
5. Save. Grok should list three tools: `kalshi_search_markets`,
`kalshi_get_market`, `kalshi_get_orderbook`.
The server uses the **streamable HTTP** transport in stateless mode, which is
what remote MCP connectors expect, and it survives restarts and load balancers
because no session state is kept between requests.
---
## Tool reference
### `kalshi_search_markets`
| Parameter | Default | Notes |
| --- | --- | --- |
| `query` | `""` | Keywords; **all** words must appear |
| `series_ticker` | none | Restrict to one series, e.g. `KXEPLGAME` |
| `event_ticker` | none | Restrict to one fixture, e.g. `KXEPLGAME-26SEP18BRECFC` |
| `status` | `open` | `open`, `closed`, `settled`, `unopened`, `any` |
| `limit` | `20` | Max markets returned (1–100) |
| `max_pages` | `8` | Pages scanned per series (1–25) |
Kalshi has **no server-side text search**, so this server filters locally:
1. Your words are matched against the ~14,000 Kalshi *series* (competitions).
2. The best-matching series are searched for open events (fixtures).
3. Each event's markets are matched against your words, including the event
title — so `liverpool` finds the win, tie, spread, and totals markets for
"Ipswich Town vs Liverpool".
Every response carries a `coverage` block (`events_scanned`, `pages_fetched`,
`total_matches_found`, `scan_complete`). If `scan_complete` is `false`, more
data existed than the request budget allowed — narrow the search with
`series_ticker` for an exhaustive answer.
Handy soccer series tickers:
| Series | Competition |
| --- | --- |
| `KXEPLGAME` | English Premier League — match winner |
| `KXEPLSPREAD` / `KXEPLTOTAL` | EPL spread / total goals |
| `KXUCLGAME` | UEFA Champions League |
| `KXLALIGAGAME` | La Liga |
| `KXSERIEAGAME` | Serie A |
| `KXBUNDESLIGAGAME` | Bundesliga |
| `KXLIGUE1GAME` | Ligue 1 |
| `KXMLSGAME` | MLS |
### `kalshi_get_market`
Takes an exact `ticker`. Returns prices, volume, open interest, liquidity,
settlement rules, strike details, and timestamps, plus the parent event's title.
### `kalshi_get_orderbook`
Takes an exact `ticker` and optional `depth` (1–100, default 10). Returns YES and
NO bid levels sorted best-price-first, top-of-book prices, level counts, and
totals, plus a price snapshot of the market itself.
Kalshi returns an empty order book with HTTP 200 for tickers that do not exist,
so this tool checks the market first. `market_not_found` and
`is_empty: true` are therefore two different, honest answers.
---
## How to read the JSON
```jsonc
{
"ok": true,
"market": {
"ticker": "KXEPLGAME-26SEP04IPSLFC-LFC",
"prices": {
"yes_bid": { "dollars": 0.66, "cents": 66 },
"derived": { // computed here, NOT quoted by Kalshi
"yes_mid_dollars": 0.665,
"yes_mid_implied_probability_percent": 66.5
}
}
},
"retrieved_at": "2026-09-04T07:20:11Z",
"source": "https://api.elections.kalshi.com/trade-api/v2/markets/..."
}
```
Three rules the server follows so an agent can trust the numbers:
1. **Quoted vs computed are separated.** Anything under a `derived` key was
calculated by this server (mid price, implied ask from the opposite bid).
Everything else came from Kalshi as-is.
2. **Missing means `null`.** If Kalshi does not send a price or volume, the field
is `null`. The server never estimates, interpolates, or carries values over.
3. **Failure is explicit.** Errors return `"ok": false` with a machine-readable
`code` (`market_not_found`, `event_not_found`, `invalid_parameter`,
`rate_limited`, `timeout`, `network_error`, `http_error`), never a
plausible-looking empty result.
Prices are dollars per contract. A Kalshi contract pays **$1.00** if YES resolves
true, so `0.66` means a 66% implied probability. Cents are included alongside
every price because Kalshi's own UI quotes cents.
---
## Project layout
```
kalshi-mcp/
├─ server.py MCP server: tool definitions + web app
├─ tools.py The three tools and all JSON shaping
├─ kalshi_client.py HTTP GETs, timeouts, retries, error mapping
├─ tests/
│ ├─ conftest.py Fake Kalshi API used by every test
│ ├─ test_tools.py Tool behavior
│ └─ test_mcp_server.py Real MCP protocol round-trips
├─ requirements.txt
└─ README.md
```
## Troubleshooting
| Symptom | Cause / fix |
| --- | --- |
| `rate_limited` errors | Kalshi throttles public data. Wait a few seconds; the client already retries twice with back-off. |
| Search returns 0 results | Add `series_ticker`. A bare keyword search is best-effort — check the `coverage` block. |
| Grok cannot connect | Make sure the URL ends in `/mcp` and is HTTPS, and that both the server and the tunnel are running. Open `/healthz` in a browser first. |
| `401 unauthorized` | `MCP_AUTH_TOKEN` is set on the server; add the matching `Authorization: Bearer` header in Grok. |
| Search feels slow the first time | The series catalogue (~16 MB) is downloaded once and cached in memory for 15 minutes. |
## Limits of v0.1
- Read-only market data. No trading, no portfolio, no account data — by design.
- No WebSocket streaming; every call is a fresh REST read.
- Keyword search is best-effort and says so; series/event-scoped search is exhaustive.
- The series catalogue cache lives in memory, so a restart re-downloads it.
## Data source
Kalshi public Trade API v2 — `https://api.elections.kalshi.com/trade-api/v2`
(docs: <https://docs.kalshi.com>). Market data is Kalshi's; this server only
reformats it. Nothing here is trading advice.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues