# magicmarkets-cli
A command-line interface for the [Magic Markets](https://magicmarkets.com) v2 API — stream live sports prices, quote selections, place and manage orders, and inspect your position. The same API is also available as MCP tools, over stdio (a client launches `magicmarkets mcp` as a subprocess) or the streamable HTTP transport on localhost.
Single static binary, authenticated with one API key. No request signing, no private keys.
```bash
magicmarkets markets --sport fb # find events with live prices
magicmarkets offers fb 2026-06-15,1001,2002 # list priced bet types
magicmarkets betslip create fb 2026-06-15,1001,2002 for,h --wait 5s
magicmarkets order place --betslip bs-123 --price 2.10 --stake 50
```
- **[Setup](#setup)** — install, authenticate, first commands
- **[Using it](#using-it)** — the bet flow, command reference, MCP, prices, errors
- **[Development](#development)** — layout, code generation, conventions, contributing
---
# Setup
## 1. Install
From a clone:
```bash
git clone https://github.com/magicmarkets/magicmarkets-cli
cd magicmarkets-cli
make install # installs `magicmarkets` into your Go bin directory
```
`make where` prints exactly where that landed. If `magicmarkets` is not found afterwards, that directory is not on your `PATH`:
```bash
export PATH="$PATH:$(go env GOPATH)/bin" # add to ~/.zshrc or ~/.bashrc
```
Prefer not to install? `make build` produces `./build/magicmarkets` and leaves your `PATH` alone.
go install directly
Note the `/cmd/magicmarkets` path — installing the module root would build a binary called `magicmarkets-cli`:
```bash
go install ./cmd/magicmarkets
```
The Go module path is `magicmarkets-cli`, not a GitHub URL, so `go install github.com/…/magicmarkets-cli@latest` will not work. Cloning and `make install` is the supported path.
## 2. Add your API key
Create a key at magicmarkets.com under **Settings → API**. It is shown **once at creation**, so store it immediately.
Put it in `~/.magicmarkets/.env` so it works from any directory:
```bash
mkdir -p ~/.magicmarkets
echo 'MAGICMARKETS_API_KEY=your-key-here' > ~/.magicmarkets/.env
chmod 600 ~/.magicmarkets/.env
```
An env var or a project-local `.env` works too — `cp env.example .env` and fill it in.
## 3. Verify
```bash
$ magicmarkets status
version v1.0.0
api url https://magicmarkets.com/v2
ws url wss://magicmarkets.com/v2/stream
lang en
api key ***********1234
env files [/Users/you/.magicmarkets/.env]
authenticated yes
```
Always check this before opening a stream — the WebSocket rejects a bad key at the handshake without a useful error.
That's the whole setup. Everything below is optional.
## Try it
```bash
magicmarkets balance # money position
magicmarkets xrates # exchange rates to USDT
magicmarkets markets --sport fb --limit 5 # events that currently have prices
magicmarkets offers fb --depth 2 # priced bet types on one event
magicmarkets orders --open # your open orders
magicmarkets ticks 2.345 # where a price lands on the tick schedule
magicmarkets api endpoints # every endpoint (no key, no network)
```
Add `--json` to any command to pipe it into `jq`.
## Configuration
Resolved in this order, first match winning:
| Priority | Source |
|---|---|
| 1 | Real environment variables |
| 2 | `./.env` |
| 3 | `~/.magicmarkets/.env` |
| 4 | `~/.env` |
| 5 | Built-in defaults |
| Variable | Default | Purpose |
|---|---|---|
| `MAGICMARKETS_API_KEY` | — | API key (`X-Api-Key`). Required unless `MAGICMARKETS_ACCESS_TOKEN` is set |
| `MAGICMARKETS_ACCESS_TOKEN` | — | OAuth Bearer token for CLI/stdio. HTTP MCP still takes the token per request |
| `MAGICMARKETS_OAUTH_ISSUER` | `https://magicmarkets.com/api/auth` | Upstream AS that `mcp --http` proxies `/authorize` and `/token` to, and that `POST /oauth2/firebase-token` is resolved against for a Bearer credential — see [Authentication](#authentication) |
| `MAGICMARKETS_SESSION_GROUP_ID` | — | Environment-specific id from Magic Markets, used to build the `session` value an OAuth Bearer credential resolves to. No safe default — required wherever a Bearer credential is expected |
| `MAGICMARKETS_FIREBASE_WEB_API_KEY` | — | Firebase Web API key for the Magic Markets Firebase project, used to exchange the Firebase custom token from `/oauth2/firebase-token` for a real Firebase ID token — see [Authentication](#authentication). No safe default — required wherever a Bearer credential is expected |
| `MAGICMARKETS_OAUTH_CLIENT_ID` | — | Pre-registered client allowlisting *this host's own* `/mcp/callback` |
| `MAGICMARKETS_OAUTH_PROXY_SECRET` | — | Seals OAuth proxy state; every replica must share it |
| `MAGICMARKETS_MCP_PUBLIC_URL` | — | Public base of `mcp --http` (this host is the MCP Authorization Server) |
| `MAGICMARKETS_API_URL` | `https://magicmarkets.com/v2` | REST base, including `/v2` |
| `MAGICMARKETS_BASIC_AUTH` | — | Base64 `user:pass` sent as an additional `Authorization: Basic` header on every `/v2/*` REST call — the token for an infra-level wall some non-production environments put in front of the v2 API. Production has no such wall, so this is unset there. Never sent to `/oauth2/firebase-token`, Firebase's `signInWithCustomToken`, or the WebSocket stream |
| `MAGICMARKETS_WS_URL` | derived from `MAGICMARKETS_API_URL` | Stream endpoint |
| `MAGICMARKETS_LANG` | `en` | Event name language: `en`, `ko`, `zh-hans` |
| `MAGICMARKETS_TIMEOUT` | `30s` | Per-request timeout |
| `MAGICMARKETS_ALLOW_TRADING` | unset (off) | Lets [`magicmarkets mcp`](#mcp-over-stdio) place bets. No effect on the CLI. |
Global flags: `--json`, `--verbose`/`-v`, `--api-key`, `--api-url`, `--ws-url`.
`--api-url` re-derives the stream endpoint from it (matching `MAGICMARKETS_WS_URL`'s
own derivation), unless `MAGICMARKETS_WS_URL` or `--ws-url` pins it explicitly —
so `--api-url https://staging...` doesn't leave `stream` reading production
prices while every other command reaches staging.
---
# Using it
## The two-step bet flow
Placing a bet always takes two steps:
1. **A betslip** registers interest in one selection and receives a live quote. It costs nothing and commits nothing.
2. **An order** commits a stake against that quote.
Betslips are short-lived and carry **no price when created** — the quote arrives asynchronously, over the WebSocket as a `pmm` message or by polling. Hence `--wait`:
```bash
# 1. find an event that has prices
$ magicmarkets markets --sport fb --limit 5
SPORT EVENT ID EVENT COMPETITION STATUS START
----- -------- ----- ----------- ------ -----
fb 2026-06-15,1001,2002 Arsenal v Chelsea England Premier League pre_event 2026-06-15 16:00:00
# 2. read a bet_type straight off the feed
$ magicmarkets offers fb 2026-06-15,1001,2002 --depth 2
BET TYPE MARKET IR PRICES (stake @ price) TOTAL
-------- ------ -- ---------------------- -----
for,h 1x2 - 150.00@2.10 80.00@2.08 230.00
for,ah,h,-4 ah - 200.00@1.95 120.00@1.94 320.00
# 3. quote it, waiting for the price to land
$ magicmarkets betslip create fb 2026-06-15,1001,2002 for,h --wait 5s
betslip id bs-abc123
bet type for,h
description Home
expires 2026-06-15 15:42:10 (28s)
total available 230.00 USDT
Prices:
PRICE MIN MAX
----- --- ---
2.10 5.00 150.00
2.08 - 80.00
# 4. commit a stake (asks for confirmation)
$ magicmarkets order place --betslip bs-abc123 --price 2.10 --stake 50
```
**Never construct a `bet_type` by hand.** Copy it verbatim from `magicmarkets offers` or the stream — it encodes the market, handicap, outcome and direction in one string.
## Command reference
### Account
| Command | Purpose |
|---|---|
| `magicmarkets status` | Show config and verify the key |
| `magicmarkets balance` | Balance, open stake, smart credit, available |
| `magicmarkets xrates` | Exchange rates to USDT |
| `magicmarkets position` | Aggregate P&L, with `--grid` for the payoff matrix |
### Discovery
| Command | Purpose |
|---|---|
| `magicmarkets markets` | Events that currently have prices |
| `magicmarkets offers ` | Priced bet types on an event |
| `magicmarkets stream` | Tail the live price and account feed |
The v2 REST API has **no event-listing endpoint** — discovery happens over the WebSocket. These commands connect, read the snapshot, and disconnect, so they take a few seconds.
```bash
magicmarkets markets --sport fb,tennis --limit 20
magicmarkets markets --search arsenal
magicmarkets markets --in-play
magicmarkets offers fb 2026-06-15,1001,2002 --market ah --depth 3
magicmarkets stream --register fb:2026-06-15,1001,2002
magicmarkets stream --type order,bet # only order activity
```
### Trading
| Command | Purpose |
|---|---|
| `magicmarkets betslip create [sport] [event] [bet-type]` | Quote a selection |
| `magicmarkets betslip get ` | Show a betslip and its prices |
| `magicmarkets betslip list` | Open betslip IDs (`--expand` for full detail) |
| `magicmarkets betslip refresh ` | Extend expiry |
| `magicmarkets order place` | Place an order against a betslip |
| `magicmarkets orders` / `magicmarkets order list` | List orders |
| `magicmarkets order get ` | Show one order and its bets |
| `magicmarkets order tracked ` | Look an order up by idempotency key |
| `magicmarkets order updates` | Orders changed in a time window |
| `magicmarkets order close ` | Cancel one order |
| `magicmarkets order close-many ...` | Cancel up to 500 orders |
| `magicmarkets order close-all` | Cancel every open order |
Lay bets and parlays:
```bash
# lay (against) a selection
magicmarkets betslip create --lay fb 2026-06-15,1001,2002 for,over,2.5
# a 2-leg accumulator
magicmarkets betslip create \
--leg fb:2026-06-15,1001,2002:for,h \
--leg fb:2026-06-16,1003,2004:for,over,2.5 --wait 5s
```
### Risk
| Command | Purpose |
|---|---|
| `magicmarkets heartbeat run` | Create a heartbeat and keep it alive in the foreground |
| `magicmarkets heartbeat create/list/get/refresh/cancel` | Manage heartbeats directly |
A heartbeat is a dead-man's switch: if it is not refreshed before it expires, **every open order is closed automatically**. Run one alongside an automated strategy so a crash cannot leave orders live.
```bash
$ magicmarkets heartbeat run --timeout 60
heartbeat hb-xyz created, expires 2026-06-15 15:43:10; refreshing every 20s
press Ctrl-C to cancel it and leave orders open
```
On Ctrl-C the heartbeat is cancelled cleanly, leaving orders open. If the process dies, the switch fires.
### MCP
| Command | Purpose |
|---|---|
| `magicmarkets mcp` | MCP tools over stdio by default, or `--http` for the streamable HTTP transport on localhost. See [MCP](#mcp) |
### Reference
| Command | Purpose |
|---|---|
| `magicmarkets bet-type ` | Validate a bet type, show its payoff grid |
| `magicmarkets ticks ` | Snap a price onto the tick schedule |
| `magicmarkets api endpoints` | List every endpoint |
| `magicmarkets api show [method]` | Endpoint detail: parameters, body, responses |
| `magicmarkets api schema [name]` | Component schemas |
| `magicmarkets api curl ` | Generate a runnable curl command |
| `magicmarkets api search ` | Search endpoints and schemas |
| `magicmarkets api spec` | Print the embedded OpenAPI spec |
The `magicmarkets api` commands need **no API key and no network** — the OpenAPI 3.1 spec is compiled into the binary.
## Safe-operation checklist
Worth internalising before running anything that spends money:
- **Pass `--request-uuid` on every order.** It makes placement idempotent: a retry after a timeout cannot create a second order, and the order stays retrievable for six hours. Without it, a timeout leaves you unsure whether a bet was placed.
- **Run a heartbeat when automating.** Without one, a crashed strategy leaves orders live in the market.
- **Verify the key over REST before opening a stream.** The WebSocket fails the handshake with no useful error.
- **Take `bet_type` from the feed, never by hand.** Asian handicap lines are 4× the real line, so a hand-built string is easy to get silently wrong.
- **Check the snapped price.** `magicmarkets order place` shows it in the confirmation; that is the price the order actually runs with, not what you typed.
## Prices and the tick schedule
Every price lies on a fixed tick schedule whose step widens as the price grows:
| Decimal price | Tick |
|---|---|
| 1.01 – 2 | 0.01 |
| 2 – 3 | 0.02 |
| 3 – 4 | 0.05 |
| 4 – 6 | 0.10 |
| 6 – 10 | 0.20 |
| 10 – 20 | 0.50 |
| 20 – 30 | 1 |
| 30 – 50 | 2 |
| 50 – 100 | 5 |
| 100 – 1000 | 10 |
An off-tick order price is rounded so it never tightens your limit: **down** for back (`for`) orders, **up** for lay (`against`) orders. `magicmarkets order place` snaps the price itself and shows the result in the confirmation.
```bash
$ magicmarkets ticks 2.345
snapped price 2.34 # back: rounded down
$ magicmarkets ticks 2.345 --lay
snapped price 2.36 # lay: rounded up
```
Prices quoted from the feed are already on the schedule and are never re-rounded.
## Bet type grammar
`bet_type` is a comma-separated string beginning with the direction: `for` to back, `against` to lay. Handicaps always refer to the **home team**.
| Example | Meaning |
|---|---|
| `for,h` / `for,d` / `for,a` | Home / draw / away win |
| `for,dnb,h` | Home win, void if draw |
| `for,dc,h,d` | Double chance: home or draw |
| `for,over,2.5` / `for,under,2.5` | Over/under 2.5 goals |
| `for,ah,h,-4` | Asian handicap, home **-1.0** |
| `for,ahover,7` | Asian total over **1.75** |
| `for,cs,2,1` | Correct score 2–1 |
| `for,score,both,yes` | Both teams to score |
| `for,win,` | Runner to win an outright |
| `for,top,3,` | Runner to finish top 3 |
**Asian handicap lines are integers equal to 4× the real line** — `-4` is -1.0, `2` is +0.5, `7` is +1.75. This keeps 0.25-step lines integer-only on the wire.
Validate any candidate string:
```bash
$ magicmarkets bet-type fb for,ah,h,-4
description Home -1.0 (Asian)
valid yes
```
The full grammar — tennis periods, time-period tokens, every market — is in [`docs/api-reference.md`](docs/api-reference.md).
## JSON output
Every command takes `--json`:
```bash
magicmarkets orders --open --json | jq -r '.[] | "\(.order_id) \(.status)"'
magicmarkets markets --sport fb --json | jq -r '.[].event_id'
magicmarkets stream --type order --json # one JSON object per line
```
**Stakes are two-element tuples, not objects** — `--json` mirrors the API wire
format exactly, so index them rather than reaching for a field name:
```bash
$ magicmarkets balance --json
{
"balance": ["USDT", 10000.5],
"open_stake": ["USDT", 152.55],
"smart_credit": null
}
$ magicmarkets balance --json | jq '.balance[1]' # amount
10000.5
$ magicmarkets balance --json | jq -r '.balance[0]' # currency
USDT
```
The same applies to every money field: `want_stake`, `stake`, `profit_loss`, `total`, and the `min`/`max` inside a price level.
## MCP
`magicmarkets mcp` serves the API as MCP tools, so an LLM agent can read prices and manage orders. By default it speaks stdio: an MCP client (Claude Code, Cursor, and the like) launches it as a subprocess and they exchange JSON-RPC on stdin/stdout. Pass `--http` to instead serve the MCP [streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) on localhost — see [Serving over localhost HTTP](#serving-over-localhost-http) below.
### stdio
Register the command with a client:
```bash
claude mcp add magicmarkets -e MAGICMARKETS_API_KEY=your-key -- magicmarkets mcp
```
Or in a client's MCP config — `mcpServers` is the client's name for a stdio subprocess, not a network service:
```json
{
"mcpServers": {
"magicmarkets": {
"command": "magicmarkets",
"args": ["mcp"],
"env": { "MAGICMARKETS_API_KEY": "your-key" }
}
}
}
```
MCP support is developer-mode for now — expect some friction wiring a stdio server into a given client, and expect that to keep improving. For client-specific setup (where the config file lives, restart behavior, log locations), see that client's own docs rather than this README:
- [Claude Code: Connect to tools via MCP](https://docs.claude.com/en/docs/claude-code/mcp)
- [Model Context Protocol: Connect to local (stdio) servers](https://modelcontextprotocol.io/docs/develop/connect-local-servers) — covers Claude Desktop and other MCP clients generically
Across clients, the most common snag is the `command` field: a client often spawns the subprocess with a minimal PATH, not your shell's, so a bare `"command": "magicmarkets"` can fail to resolve even though the same command works from a terminal. If that happens, swap in the absolute path instead:
```bash
which magicmarkets # or: make where
```
### Serving over localhost HTTP
Pass `--http` to serve the streamable HTTP transport instead of stdio — useful when a client connects over the network, or you want one long-running server shared by several clients instead of a subprocess per client:
```bash
magicmarkets mcp --http --addr 127.0.0.1:8383
```
`--addr` defaults to `127.0.0.1:8383` — loopback-only, so nothing outside the machine can reach it regardless. `--http` has no TLS of its own — put it behind a reverse proxy if you expose it beyond loopback.
**The server does not use `MAGICMARKETS_API_KEY`.** Each request must send the caller's credential as `X-Api-Key` **or** `Authorization: Bearer`. An API key is forwarded to the Magic Markets REST API and `/v2/stream` unchanged. A Bearer token is *not* — it's resolved first, via `POST {MAGICMARKETS_OAUTH_ISSUER}/oauth2/firebase-token`, to the credential those actually require; see [Authentication](#authentication) for the full flow and its known limitations. Stdio still takes `MAGICMARKETS_API_KEY` or `MAGICMARKETS_ACCESS_TOKEN` from the environment.
Point a client at the URL instead of a command:
```json
{
"mcpServers": {
"magicmarkets": {
"url": "http://127.0.0.1:8383/mcp",
"headers": { "X-Api-Key": "your-key" }
}
}
}
```
The same URL accepts an OAuth access token:
```json
{
"mcpServers": {
"magicmarkets": {
"url": "http://127.0.0.1:8383/mcp",
"headers": { "Authorization": "Bearer your-token" }
}
}
}
```
Remote MCP hosts that speak OAuth can skip the headers map. Unauthenticated `/mcp` replies include `WWW-Authenticate` pointing at protected-resource metadata that names **this MCP host** as the Authorization Server (so Claude's Dynamic Client Registration POSTs `/register` here, not `https://magicmarkets.com/register`). This process runs its own PKCE login against `https://magicmarkets.com/api/auth` — it never forwards a downstream client's redirect_uri upstream, since the real Magic Markets AS only allowlists *this host's own* callback (`{public-url}/mcp/callback`), never Claude's or Cursor's. Set:
- `--public-url https://magicmarkets-mcp.dev-eu.kubershmuber.com` on the hosted deploy
- `MAGICMARKETS_OAUTH_CLIENT_ID` to a client on `MAGICMARKETS_OAUTH_ISSUER` whose redirect_uris allowlist includes that host's `/mcp/callback`
- `MAGICMARKETS_OAUTH_PROXY_SECRET` on every replica of a multi-replica deployment — the proxy keeps no server-side session state (login state and one-time codes are sealed, self-contained tokens instead), so replicas that don't share this secret can't decode each other's in-flight logins
### Enabling trading
**Trading is off by default.** A fresh registration is read-only, so an agent asking to place a bet will find no `place_order` tool at all. Enable it with `MAGICMARKETS_ALLOW_TRADING=1`, **replace** the existing registration, and **restart your client**:
```bash
claude mcp remove magicmarkets
claude mcp add magicmarkets -e MAGICMARKETS_API_KEY=your-key -e MAGICMARKETS_ALLOW_TRADING=1 -- magicmarkets mcp
```
Restarting matters: a client reads the subprocess's tool list once at startup, so an already-running session keeps the read-only list even after you re-register.
Editing a client's MCP config file directly, set `MAGICMARKETS_ALLOW_TRADING` in `env`:
```json
{
"mcpServers": {
"magicmarkets": {
"command": "magicmarkets",
"args": ["mcp"],
"env": {
"MAGICMARKETS_API_KEY": "your-key",
"MAGICMARKETS_ALLOW_TRADING": "1"
}
}
}
}
```
Confirm which mode you are in without involving a client:
```bash
$ MAGICMARKETS_ALLOW_TRADING=1 magicmarkets mcp --print-tools
mode: trading ENABLED (via MAGICMARKETS_ALLOW_TRADING) — this process can place real bets
TOOL
----
close_all_orders
close_order
create_betslip
place_order
...
```
Run it without the env var to see the read-only list (11 tools vs 19). `magicmarkets mcp` also logs the mode to stderr on every start, which appears in your client's MCP logs (stderr is the only place those lines can go — stdout is the JSON-RPC stream).
### What each mode exposes
| Always available | Requires `MAGICMARKETS_ALLOW_TRADING` |
|---|---|
| `get_balance`, `get_exchange_rates`, `get_position` | `create_betslip` |
| `list_events`, `list_event_offers` | `place_order` |
| `list_orders`, `get_order` | `close_order`, `close_all_orders` |
| `list_betslips`, `get_betslip` | `create_heartbeat`, `refresh_heartbeat`, `cancel_heartbeat`, `list_heartbeats` |
| `validate_bet_type`, `snap_price` | |
Enable trading only if the agent should be able to bet real money. The money-spending tools carry MCP destructive hints so clients prompt before calling them.
Note this gate applies to **`magicmarkets mcp` only**. The CLI's own `magicmarkets order place` is always available — it has its own confirmation prompt instead.
## Errors
Errors carry a stable machine-readable `code` to branch on:
| HTTP | Code | Meaning |
|---|---|---|
| 400 | `validation_error` | Body or query failed validation; per-field reasons are printed |
| 400 | `order_closed` | Order exists but is already closed or settled |
| 401 | `auth_error` | Key missing, malformed or rejected |
| 403 | `forbidden` | Key valid but action not allowed |
| 404 | `not_found` | Resource unknown or invisible to this key |
| 409 | `order_already_created` | A `request_uuid` was reused; the existing order ID is reported |
| 409 | `limit_reached` | A per-customer cap was hit |
| 429 | `throttled` | Rate limited; retried automatically, honouring `Retry-After` |
| 500 | `server_error` | Internal error; quote the support token when reporting |
Throttled requests are retried automatically (twice by default) because a 429 means the request was rejected outright, so nothing was created. No other status is retried.
### Idempotency
```bash
uuid=$(uuidgen)
magicmarkets order place --betslip bs-123 --price 2.10 --stake 50 --request-uuid "$uuid"
magicmarkets order tracked "$uuid" # recover after a timeout, safely
```
If a reused UUID is detected, `magicmarkets` fetches and shows the original order instead of failing.
### Rate limits
Per account, sliding window: 100 req/s burst and 1200 req/min sustained overall, with dedicated budgets of 10 req/s for betslip creation and 5 req/s for order placement.
## Troubleshooting
**`no API key configured`** — set `MAGICMARKETS_API_KEY`. `magicmarkets status` shows which `.env` files were read.
**`auth_error` (401)** — no key was sent at all. Check the variable name.
**`session_not_found` (404) on every call** — the key was sent but is not recognised. Regenerate it under Settings → API.
**`stream handshake failed`** — the WebSocket rejects a bad key at the HTTP handshake. Run `magicmarkets status` first; REST gives a clearer error.
**`magicmarkets markets` returns nothing** — the snapshot only contains events that *currently have live prices*, not the full fixture list. Try without `--sport`, or raise `--timeout`.
**Betslip has no prices** — quotes arrive asynchronously. Use `--wait 5s`. If it still has none, no source is quoting that selection.
**`updated_at_to must be at least 60 seconds in the past`** — `magicmarkets order updates` windows must end ≥60s ago and span ≤70 minutes.
**An agent says it cannot bet / needs `MAGICMARKETS_ALLOW_TRADING`** — the `magicmarkets mcp` subprocess is running read-only, so the betting tools are not registered. See [Enabling trading](#enabling-trading): re-register with `MAGICMARKETS_ALLOW_TRADING=1` and restart the client. Check the current mode with `magicmarkets mcp --print-tools`.
---
# Development
## Layout
```
cmd/magicmarkets/main.go entry point — signal handling, version
internal/config/ .env + environment resolution
internal/magicmarkets/ API client — no CLI or MCP dependencies
client.go transport, envelope, 429 retry
errors.go typed APIError per error code
types.go wire types (Stake is a [ccy, amount] tuple)
ticks.go tick schedule and price snapping
betslips.go orders.go account.go heartbeats.go
stream.go WebSocket client
internal/cli/ cobra command tree, table/JSON rendering
internal/mcpserver/ MCP tools over stdio or localhost HTTP, same client
internal/spec/ embedded openapi.json + reference commands
internal/magicmarketsapi/ generated models + the contract test guarding drift
tools/prepspec/ adapts the spec for oapi-codegen
docs/api-reference.md full API reference (vendored)
```
`internal/magicmarkets` has no dependency on the CLI or MCP layers, so it is usable as a plain Go client library.
## Everyday commands
```bash
make test # go test ./...
make lint # go vet ./...
make fmt # gofmt -w
make build # ./build/magicmarkets
make install # install `magicmarkets` into your Go bin directory
make where # print where make install puts the binary
make generate # regenerate internal/magicmarketsapi from the vendored spec
make update-spec # refresh the vendored spec + docs, then regenerate
```
Tests need no API key and no network. Keep it that way.
The main package lives in `cmd/magicmarkets/`, not the module root, so the binary is
named `magicmarkets`. Building the root would name it after the module path —
`magicmarkets-cli` — which is not what the docs or `magicmarkets --help` tell you to run. Keep
new build targets pointed at `$(PKG)`.
`make build` and `make install` stamp `main.version` from `git describe`, so
`magicmarkets --version` reports something traceable. Override with
`make build VERSION=v1.2.3`.
## Code generation
Models in `internal/magicmarketsapi` are generated from the vendored OpenAPI spec with [oapi-codegen](https://github.com/oapi-codegen/oapi-codegen).
**Setup: none.** oapi-codegen is pinned by the `tool` directive in `go.mod`, so `make generate` works on a fresh clone. The generated file is checked in, so `git clone && go build` never requires codegen.
```bash
make generate # regenerate from internal/spec/openapi.json
make update-spec # pull the latest spec from the API, then regenerate
```
The canonical spec comes from [magicmarkets.com/magic-api/docs](https://magicmarkets.com/magic-api/docs) — `make update-spec` fetches `/magic-api/v2/openapi.json` plus the Markdown reference. Run `git diff` afterwards to see exactly what the API changed.
### These generated types are a contract reference, not what the CLI uses
The client in `internal/magicmarkets` keeps hand-written types, because generated code cannot express three things this API needs:
- **Stake tuples.** `["USDT", 115.38]` is an OpenAPI 3.1 tuple; oapi-codegen cannot generate one at all.
- **The bet-status union.** A bet's status is either a bare string or an object. `magicmarkets.BetStatus` unmarshals both; a generated union type pushes that branch onto every caller.
- **Non-pointer access.** The spec marks almost nothing `required`, so every generated field is a pointer. Threading nil checks through the CLI for fields the API always sends would be noise.
### What keeps the two honest
`internal/magicmarketsapi/contract_test.go` compares the JSON field names of every hand-written type against its generated counterpart, in both directions, and fails on any difference. An upstream field added, removed or renamed breaks `go test` after `make generate` instead of being discovered at runtime.
It earned its keep on the first run: it caught `bet_bar_values` missing from `Order`, which was silently dropping a field from `magicmarkets order get --json`.
If it fails, the spec and the client have diverged. **Fix the client**, or record the exception in that pair's `specOnly` / `handOnly` map *with a reason*. Do not delete the pair to make it pass.
### Two wrinkles handled by `tools/prepspec`
It adapts the spec before codegen without touching the vendored file:
- **`number` → `float64`.** oapi-codegen maps a formatless OpenAPI `number` to `float32` (~7 significant digits), not enough for prices and stakes. prepspec adds `format: double`. This includes the nullable `["number", "null"]` form, which covers precisely the achieved-price fields. A test asserts no generated money field is ever `float32`.
- **`StakeTuple` flattened** to an untyped array, since oapi-codegen fails outright on a 3.1 tuple. `magicmarkets.Stake` is the real typed equivalent.
Do not edit `internal/magicmarketsapi/types.gen.go` by hand.
## Conventions and invariants
Things this codebase relies on. Breaking one should be deliberate.
**Never place a real order to test a change.** `magicmarkets order place`, `magicmarkets order close*`, and the MCP `place_order` / `close_*` tools spend real money. Read-only commands (`status`, `balance`, `xrates`, `markets`, `offers`, `orders`, `position`) and the offline `magicmarkets api` commands are safe to exercise. For write paths, use a local stub server.
**Check the spec before inferring a shape.** `magicmarkets api show orders POST` beats guessing. Several endpoints break the common `{status, data}` pattern, and each break was a bug caught only by reading the spec:
- `GET /v2/heartbeats/` wraps data under a `heartbeats` key; every other list endpoint returns a flat array.
- `POST /v2/orders/{id}/close/` always returns `data: null`. Re-read the order for its final state.
- `POST /v2/betslips/{id}/refresh/` has no documented response body, so `RefreshBetslip` re-reads the betslip instead of decoding the reply.
**Keep the layering.** `internal/magicmarkets` must not import `internal/cli` or `internal/mcpserver`.
**Money is `float64`, and prices go through `SnapPrice`.** Never introduce `float32` on a price or stake path.
**New MCP tools that spend money go behind `AllowTrading`** and carry a destructive hint. The gate is tested; do not weaken it.
**Money-touching code needs a test.** `ticks.go` and the MCP trading gate both have tests asserting safety properties — a snap never tightens the bettor's limit, and trading tools are unreachable without `MAGICMARKETS_ALLOW_TRADING`. Extend those rather than working around them.
**Every command supports `--json`** and renders a table otherwise. Data goes to stdout; warnings and prompts go to stderr, so piping stays clean.
**Branch on error codes, not strings.** Use `magicmarkets.HasCode(err, magicmarkets.CodeOrderClosed)`.
## Authentication
This repo targets the **public v2 API**: `https://magicmarkets.com/v2`. A caller presents one of two credentials — `X-Api-Key`, or `Authorization: Bearer` with a token from `https://magicmarkets.com/api/auth` — but only the API key is what actually goes out on the wire. A Bearer token is not accepted by the v2 API or `/v2/stream` as-is; it must first be resolved, in two hops, to the `magic-metadata-jwt`/`session` pair those endpoints require:
1. `POST {issuer}/oauth2/firebase-token` (`Authorization: Bearer `) mints a **Firebase custom token** carrying the player's real entitlements.
2. A Firebase custom token is not itself a valid ID token — Magic Markets' own OAuth integration guide for MCP server implementers is explicit that a custom token must be redeemed for a Firebase **ID token** before it's usable. This process does that redemption itself, calling Google's Identity Toolkit REST API directly — `POST https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=` — and uses the returned `idToken` as `magic-metadata-jwt`.
`GET {issuer}/me` is *not* used for any of this: it's guarded by Firebase ID-token verification, which the self-signed OAuth access token never satisfies. `internal/magicmarkets.MeResolver` (`internal/magicmarkets/meauth.go`) does both hops and caches the result; both `internal/mcpserver` and the CLI/stdio path go through the same client, so both get it automatically. `magicmarkets mcp --http` advertises OAuth protected-resource metadata so MCP hosts (Claude, Cursor, ...) can obtain a Bearer token in the first place.
**Why `MAGICMARKETS_FIREBASE_WEB_API_KEY` exists:** hop 2 above is a call to *Firebase's* Identity Toolkit API, not to any Magic Markets endpoint, and Firebase requires a **Web API key** — scoped to the Magic Markets Firebase project — to identify which project's custom token is being redeemed. It is not a secret minted per-caller; it's the same project-level key any Firebase web client already embeds client-side. It has no safe default because it's project- and environment-specific (STG and prod are different Firebase projects), so — like `MAGICMARKETS_SESSION_GROUP_ID` — it must be set explicitly wherever an OAuth Bearer caller is expected, or every Bearer-authenticated call fails.
```mermaid
sequenceDiagram
participant Caller
participant Resolver as MeResolver (cache)
participant Issuer as Magic Markets AS
participant Firebase as Firebase Identity Toolkit
participant API as v2 API / stream
Note over Caller,API: An X-Api-Key credential skips all of this, forwarded unchanged.
Caller->>Resolver: Authorization Bearer access token
alt cache hit, MeCacheTTL is 1 minute
Resolver->>Resolver: reuse cached magic-metadata-jwt and session
else cache miss
Resolver->>Issuer: POST /oauth2/firebase-token
Authorization Bearer access token
Issuer-->>Resolver: firebase_token, a Firebase custom token
not yet valid as magic-metadata-jwt
Resolver->>Firebase: POST accounts:signInWithCustomToken
key is MAGICMARKETS_FIREBASE_WEB_API_KEY, token is firebase_token
Firebase-->>Resolver: idToken
Resolver->>Resolver: cache magic-metadata-jwt as idToken
session as m, group id, uuid joined by dashes
uuid read from the access token's own sub claim
end
Resolver-->>Caller: magic-metadata-jwt, session
Caller->>API: REST headers magic-metadata-jwt and session
stream query params jwt and token
```
### Known limitations
- **This process redeems a Firebase custom token for an ID token itself, instead of that being Magic Markets' problem.** `POST /oauth2/firebase-token` could just as easily call `signInWithCustomToken` server-side and hand back a ready-to-use ID token — sparing every MCP server implementer (not just this one) from needing `MAGICMARKETS_FIREBASE_WEB_API_KEY`, a direct dependency on Google's Identity Toolkit endpoint, and knowledge of the custom-token/ID-token distinction at all. This is a client-side workaround for a gap in the Authorization Server's contract, not the intended end state — revisit once/if `/oauth2/firebase-token` returns an ID token (or the v2 API accepts a custom token directly).
- **The cache is in-process, not shared.** `MeResolver`'s cache is a per-replica LRU (via `hashicorp/golang-lru`'s expirable variant), not the sealed, replica-independent state `internal/mcpserver/oauth.go` uses for OAuth proxy state. On a hosted, multi-replica deployment, a request routed to a different pod than a prior one just pays for one extra exchange (now two calls: `/oauth2/firebase-token` and `signInWithCustomToken`) on a cache miss — it does not fail, unlike an un-shared `MAGICMARKETS_OAUTH_PROXY_SECRET` would. This is a deliberate workaround, not the end state: a shared cache (or a documented, longer-lived credential from Magic Markets) would remove the per-replica cold-start cost entirely.
- **The TTL and cache size are fixed consts**, not environment variables (`magicmarkets.MeCacheTTL` = 1 minute; a size of 4096 distinct access tokens) — see `internal/magicmarkets/meauth.go`. This keeps the workaround simple while the `/me` contract itself is still firming up; revisit once it's worth tuning.
- **`MAGICMARKETS_SESSION_GROUP_ID` and `MAGICMARKETS_FIREBASE_WEB_API_KEY` have no safe default** and differ per environment — both must be set explicitly wherever an OAuth Bearer caller is expected, or every Bearer-authenticated call fails.
- **No proactive token refresh.** Resolution is retried on every cache miss, but nothing refreshes an access token before it expires — an expired token surfaces as a failed exchange (and thus a failed tool call), the same as any other invalid credential.
## Making a change
See [CONTRIBUTING.md](CONTRIBUTING.md) for the branch → code → check → PR
workflow, including what to do if your change touches the vendored OpenAPI
spec.
---
## License
MIT — see [LICENSE](LICENSE).
Maintained by [Magic Markets](https://magicmarkets.com).