Skip to main content
Glama
palm8-dev

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.