mtga-mcp
# mtga-mcp
A local [MCP](https://modelcontextprotocol.io) server for querying your **Magic: The
Gathering Arena** collection in natural language. It ingests MTGA's own card catalog, your
owned cards from the game log, and [Scryfall](https://scryfall.com) card data into a local
SQLite database, then exposes query tools that any MCP client (Claude Desktop, Claude Code)
can call.
Nothing leaves your machine except the one-time Scryfall bulk-data download. There is no
model bundled here — your MCP client supplies the LLM.
## How it works
| Source | What it provides | Location (macOS) |
| --- | --- | --- |
| MTGA card catalog | Every card: name, set, collector #, rarity, colors | `~/Library/Application Support/com.wizards.mtga/Downloads/Raw/Raw_CardDatabase_*.mtga` |
| Player.log | Cards you own + wildcard/currency balances | `~/Library/Logs/Wizards Of The Coast/MTGA/Player.log` |
| Scryfall bulk | Oracle text, mana cost, prices, legalities, images | downloaded from scryfall.com |
The join key is MTGA's `GrpId`, which equals Scryfall's `arena_id`. Everything lands in
`~/.local/share/mtga-mcp/mtga.db` (override with `MTGA_MCP_DATA_DIR`).
> **Platforms.** Defaults target a **native macOS** MTGA install. On **Linux/NixOS running
> MTGA via Heroic (Wine/Proton)** the files live inside the game's Wine prefix — point the
> tool at them with environment variables (no code changes):
>
> | Env var | What |
> | --- | --- |
> | `MTGA_MCP_PLAYER_LOG` | full path to `Player.log` (prev log is inferred as a sibling) |
> | `MTGA_MCP_RAW_DIR` | dir holding `Raw_CardDatabase_*.mtga` |
> | `MTGA_MCP_UTC_LOG_DIR` | dir of rotating `UTC_Log*.log` files |
> | `MTGA_MCP_DATA_DIR` | where our own DB/caches live (default `~/.local/share/mtga-mcp`) |
>
> Under Heroic these are typically at
> `<prefix>/drive_c/users/<user>/AppData/LocalLow/Wizards Of The Coast/MTGA/…`
> (`Player.log`, and `Downloads/Raw` for the card DB).
## Setup
```bash
uv sync
```
With [direnv](https://direnv.net/) (`direnv allow` once), `cd`-ing into the repo puts
`mtga-mcp`/`bo`/`bo1`/`bo3` on `PATH`. For a menu over the common commands (deck wizard,
bulk-import, capture, ...) instead of remembering flags, run `./launch`.
### Step 0 — enable MTGA Detailed Logs (required for owned counts)
MTGA only writes your collection to `Player.log` when detailed logging is on:
1. In MTGA: **Settings → Account → check "Detailed Logs (Plugin Support)"**.
2. Restart MTGA and open your **Collection** screen once.
The card catalog and Scryfall data work without this; only *owned quantities* need it.
### Import data
```bash
uv run mtga-mcp import # runs all three steps
# or selectively:
uv run mtga-mcp import --catalog # ~19.7k cards from MTGA
uv run mtga-mcp import --collection # your owned cards + wildcards
uv run mtga-mcp import --scryfall # enrich (downloads a ~77MB bulk file, cached)
```
Re-run `import --collection` whenever your collection changes; re-run `--scryfall`
occasionally for new sets/prices (it only re-downloads when Scryfall has newer data).
#### Full collection (recommended): memory-scanner export
Modern MTGA clients no longer log the full owned-card list — `import --collection` only
recovers wildcards plus *new* card grants captured going forward, never cards you already
owned. To get the **complete** collection, `export-collection` reads it straight out of the
running client's memory. (The scanner is adapted from the MIT-licensed
[MTGA-collection-exporter](https://github.com/NthPhantom10/MTGA-collection-exporter); see
`LICENSES/mtga-collection-exporter-MIT.txt`.)
With MTGA running and the Collection screen opened once, and the card catalog already imported
(`uv run mtga-mcp import --catalog`):
```bash
uv run mtga-mcp export-collection # interactive: sudo password + confirm anchor cards
```
The command **self-elevates**: on macOS, if it isn't already root it re-execs itself under
`sudo` up front (before anchors are chosen), passing your data-dir/DB path through so writes
land in your home, not root's, and hands the DB back to you afterward. `scripts/export-collection.sh`
remains as an equivalent wrapper (it also runs `uv sync` first).
It's interactive by design:
- **sudo** — the macOS scan uses `task_for_pid`, which needs root, so you'll be prompted for
your password. The target MTGA process must not use the hardened runtime (the Heroic/native
build is fine).
- **anchor cards** — the scanner locates your collection in memory by searching for the exact
`(card, quantity)` of a few cards you own. It proposes ~5 rares/mythics pulled from your
last-imported collection and lets you confirm or adjust them (`e2` to edit a quantity, `a` to
add, `d2` to drop, Enter to accept). The quantities must match what you own **right now**, so
edit any that changed since your last export. On a first-ever run (empty collection) you add a
few cards manually. Run it in a real terminal; don't pipe its stdin.
This **replaces** the `collection` table with an authoritative, point-in-time snapshot (exact
per-printing counts). Re-run it whenever you want to refresh.
A legacy `mtga_collection.json` produced by the upstream exporter can still be loaded with
`mtga-mcp import-collection <file>`.
**Maintaining the scanner.** `src/mtga_mcp/memory_export.py` is *derived* from
MTGA-collection-exporter. A pristine copy of the upstream source we ported from lives at
`third_party/mtga-collection-exporter/mtg.py` (reference only — not imported). To pull upstream
fixes, run `scripts/update-exporter-reference.sh` to diff newer upstream against that baseline,
port the relevant hunks into `memory_export.py`, then re-run with `--accept` to advance the
pinned commit. See `third_party/mtga-collection-exporter/UPSTREAM.md`.
## Use it from an MCP client
### Claude Code
```bash
claude mcp add mtga -- uv --directory /Users/scott/git/mtga-mcp run mtga-mcp serve
```
### Claude Desktop
Add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"mtga": {
"command": "uv",
"args": ["--directory", "/Users/scott/git/mtga-mcp", "run", "mtga-mcp", "serve"]
}
}
}
```
Then ask things like:
- "How complete is my collection? Show the per-rarity breakdown and my wildcards."
- "Which rares am I missing from FDN?"
- "List blue instants I own that are Standard-legal."
### Optional companion: Bo1 replay coaching
A separate private repo, [`mtga-replay-coach`](https://github.com/fkadriver/mtga-replay-coach)
(not public — see its `NOTICE.md`), wraps this project's server with ranked Bo1 Standard
replay-coaching tools (pivotal moments, loss causes, opponent cards seen, saved coach
analyses). It depends on `mtga-mcp` as an editable path dependency and re-exposes every tool
in this README alongside its own, so one MCP server (`mtga-coach`) covers both collection
queries and game coaching:
```bash
cd ~/git/mtga-replay-coach && uv sync
claude mcp add mtga-coach -- uv --directory ~/git/mtga-replay-coach run mtga-coach
```
This is a process-boundary integration only — the coaching code is third-party with unclear
licensing and is never vendored into this repo. See `docs/latitude-setup.md` here for a full
worked example (env vars, scheduled capture, Syncthing) of running both together.
## Tools exposed
| Tool | Purpose |
| --- | --- |
| `search_cards` | Filter by name / colors / rarity / set / type; optional `owned_only` |
| `owned_cards` | Cards you own (count ≥ 1), same filters |
| `missing_from_set` | Cards in a set you haven't got a playset (4) of, with a `needed` count |
| `collection_summary` | Distinct owned, total copies, per-rarity breakdown, wildcards |
| `query_sql` | Ad-hoc **read-only** `SELECT` over the database |
| `import_deck` | Import a decklist from pasted text or an Archidekt/Moxfield URL |
| `list_decks` | List stored decks |
| `deck_gap` | Cards + wildcards needed to complete a deck |
| `craft_priority` | Which cards to craft to unlock the most decks |
| `best_buildable_deck` | Best Bo1/Bo3 deck you could build, buildability + win rate |
| `delete_deck` | Remove a stored deck |
| `wildcard_history` | Recent history of wildcard/currency balances (from scheduled capture) |
Database tables: `cards`, `collection(grp_id, count)`, `wildcards(kind, count)`, `meta`,
`decks`, `deck_cards`, `inventory_raw`, `inventory_history`.
## Deck buildability
Import meta decks (or your own brews), then ask what you're missing and what to build.
```bash
# Import from pasted Arena/MTGO text (tag it with meta info for ranking):
pbpaste | uv run mtga-mcp deck import --paste --name "Mono-Red" --format Standard \
--best-of 1 --win-rate 0.58
uv run mtga-mcp deck import --file list.txt --name "Dimir Midrange" --best-of 3 --tier 1
# Import from a deck host with a public API:
uv run mtga-mcp deck import --url https://archidekt.com/decks/1234567
uv run mtga-mcp deck import --url https://www.moxfield.com/decks/AbCdEf
uv run mtga-mcp deck gap "Mono-Red" # cards + wildcards you still need
uv run mtga-mcp deck best --best-of 1 # best deck you can build right now
uv run mtga-mcp deck craft-priority # what to craft to unlock the most decks
```
The **north-star** query — *"given my cards and the current meta, what's the best Bo1/Bo3
deck I could build?"* — is `deck best`, which scores each deck as
`buildability + strength_bonus`. `buildability = 1 / (1 + wildcards_needed)`, so an
already-buildable deck scores at least 1.0 and always outranks one that isn't, regardless of
strength. `strength_bonus` *averages* whichever of `win_rate` / `meta_share` / `tier` / your
own tracked **local win rate** the deck actually has (each rescaled onto a comparable 0..1
scale first — `meta_share` relative to the strongest `meta_share` in the same format/best-of
pool, `tier` via a fixed weight table) — averaged, not summed, so a deck with several known
signals doesn't automatically outrank one with a single strong signal. A deck with none of
them gets 0 (no bonus, not a penalty for data nobody tracked). Set any of them with
`--win-rate`/`--meta-share`/`--tier` on `deck import`, pull `win_rate` automatically via
`deck bulk-import --source aetherhub`, `meta_share` via `--source aetherhub-meta`, or your
*local win rate* by just playing games with `mtga-mcp capture` running (see below).
Decks containing cards not legal in their format (rotated or banned, per the Scryfall
legalities) are excluded by default — a "best deck" you can't actually play isn't an answer.
Pass `--include-illegal` to keep them, flagged with `format_legal` and the offending
`illegal_cards`. Formats without legality data (Alchemy/Timeless) are never excluded on those
grounds.
Your own decks (`deck import-mine`, source `player_log`) are already fully owned and often
carry a tracked `local_win_rate`, so they tend to float straight to the top — not useful when
you're trying to explore a *different* deck/meta. The interactive wizard (`bo`/`bo1`/`bo3`)
excludes them by default (its prompt defaults to yes — answer `n` to include them); `deck
best` needs an explicit `--exclude-mine` (default off there). They're untouched in the
database either way.
Pulling the same archetype from multiple sources means seeing it 5 times in a row in `deck
best` isn't an answer either. Decks within 2 mainboard cards of each other (same format,
basics/sideboard ignored) are treated as the same deck: `deck best`/`bo1`/`bo3` show only the
single best-scoring one, with a `variants` count of how many it's standing in for. Rows are
never merged or deleted — `deck list` still shows every one. `deck bulk-import` and `deck
import-mine` link new near-duplicates automatically as they're imported; run `deck dedupe`
once to backfill decks you already had before this existed.
### A note on meta-deck sources
Most of the big meta sites (MTGGoldfish, Untapped, mtgdecks) actively block automated
access — Cloudflare, robots `ai-train=no` (mtgdecks.net's robots.txt specifically disallows
`anthropic-ai`/`ClaudeBot` and even a literal `MTGMeta-Scraper` entry), blocked bot
user-agents, and blocked export endpoints. So this tool imports decklists from **pasted
text** and **deck-host public APIs** (Archidekt, Moxfield) instead. A best-effort MTGGoldfish
scraper exists behind an explicit `--allow-scrape` flag but is brittle and may fail; pasting
the Arena export (one click in your browser) is the reliable path.
Three sources support **bulk** import — no pasting one deck at a time — via `deck bulk-import
--source moxfield|aetherhub|aetherhub-meta`:
```bash
uv run mtga-mcp deck bulk-import --source moxfield --format Standard --count 25 --sort views
uv run mtga-mcp deck bulk-import --source aetherhub --format Standard --best-of 1 --count 25
uv run mtga-mcp deck bulk-import --source aetherhub-meta --format Standard --best-of 1
```
**Moxfield** (`search-sfw`, a public JSON API) ranks by `views`/`likes`/`updated`/`created`,
but carries no win-rate data. **AetherHub** (`robots.txt` places no restriction at all,
unlike the sites above) has no JSON API but its metagame listings are plain
server-rendered HTML with a no-auth plain-text export per deck, and natively splits Bo1
(`--best-of 1`) from Bo3/Traditional (`--best-of 3`) — Moxfield doesn't track that
distinction. It also shows each deck's own win rate right on the listing, which gets pulled
in as `win_rate` and feeds `deck best`'s score directly. **`aetherhub-meta`** is a third mode
on the same site: instead of many individual decks, it pulls one representative decklist per
archetype from AetherHub's metagame *overview* page, tagged with that archetype's real
`meta_share` (its own "% of Metagame" figure), which feeds `deck best`'s score too (rescaled
relative to the strongest `meta_share` in the pool before being averaged in alongside
`win_rate`/`tier` — see above). It has no win-rate data of its own, though.
### Importing your own decks
```bash
uv run mtga-mcp deck import-mine # every deck you've ever built in Arena
uv run mtga-mcp deck import-mine --filter "demon" # just the ones matching a name
```
The client periodically logs its full deck cache to `Player.log` in two matching pieces:
`DeckSummaries` (name + format per deck) and `DecksInternal` (each deck's actual card list,
keyed by MTGA's own card id — our `cards.grp_id`). `deck import-mine` joins the two, so every
deck resolves by an exact id lookup rather than name-matching, and needs no fetch of any
kind. Already-imported decks (by a synthetic `source_url`) are skipped on repeat runs.
This is also what makes **local win rate** (see above) actually land on a deck: once a deck
you've played is imported this way under its real Arena name, `mtga-mcp capture`'s tracked
match results join onto it automatically.
Either way, already-imported decks (matched by source URL) are skipped on repeat runs, so
it's safe to re-run periodically to pick up newly posted decks.
## Scheduled capture (wildcards + future card deltas)
Modern MTGA clients don't log your full owned-card collection — only `InventoryInfo`
(wildcards/currency, plus a `Changes` delta array that's populated on real acquisition events:
pack opens, precon grants, bundle/voucher redemptions). `Player.log` also rotates, so those
payloads are ephemeral. `mtga-mcp capture` archives every distinct `InventoryInfo` payload
into `inventory_raw` (deduped by content hash — `SeqId` resets every MTGA session, so it's not
a safe dedup key on its own), records a wildcard/currency snapshot in `inventory_history`, and
applies any `Changes[].GrantedCards` entries to the `collection` table. Running it on a
schedule accumulates a timeline and **owned-card deltas** as packs are opened / new sets
release.
**Caveat:** `collection` counts built this way only reflect deltas captured *since capture
started running* — there's no full historical collection to reconcile against, since modern
clients don't log one. A card you owned before capture ran (and haven't been granted again
since) won't show up.
`capture` also archives your own completed matches into `match_history`: win/loss plus which
deck you queued with, parsed off `matchGameRoomStateChangedEvent` (deduped by `matchId`). The
deck name comes from the `EventSetDeckV3` request that queued the match, joined against
stored decks by name (case-insensitive) — so your tracked win rate only feeds `deck best`'s
ranking (as `local_win_rate`, see above) once you've `deck import`ed that exact deck under
the same name you gave it in Arena.
```bash
uv run mtga-mcp capture # skips instantly if the logs haven't changed since last run
uv run mtga-mcp history # recent wildcard/currency snapshots
```
`capture` stat-checks the logs and **skips all parsing when they're unchanged** (i.e. when
you're not playing), so a frequent schedule costs almost nothing at idle.
### Run it automatically
**macOS (launchd):** edit `packaging/com.mtga-mcp.capture.plist` (replace `__REPO__` and
`__LOG__`), copy to `~/Library/LaunchAgents/com.mtga-mcp.capture.plist`, then:
```bash
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.mtga-mcp.capture.plist
# unload with: launchctl bootout gui/$(id -u)/com.mtga-mcp.capture
```
**Linux/NixOS (systemd user timer):** run `.venv/bin/mtga-mcp capture` from a `*.service` on a
15-minute `*.timer`, with the `MTGA_MCP_*` path vars (above) set in the unit's `Environment=`.
If a path contains spaces (common under Wine prefixes, e.g. `.../Magic The Gathering
Arena/...`), quote the **whole** `VAR=value` assignment — `Environment="VAR=/path with
spaces"` — not just the value, or systemd word-splits it into several broken assignments. See
[`docs/latitude-setup.md`](docs/latitude-setup.md) for a full worked example, including doing
this declaratively via home-manager's `systemd.user.services`/`timers`.
## Sharing findings across machines (Syncthing)
To use one database across several machines (e.g. macOS + a Linux/NixOS laptop), keep the DB
in a synced folder and leave the big regenerable Scryfall cache local:
```bash
export MTGA_MCP_DB_PATH=~/Documents/mtga-mcp/mtga.db # inside your Syncthing folder
# DATA_DIR (Scryfall cache, ~74 MB) stays local and is NOT synced.
```
Set `MTGA_MCP_DB_PATH` everywhere the tool runs: your shell profile, the capture LaunchAgent
(`EnvironmentVariables`), and the MCP client config (`env` block of the server entry). Then
append `packaging/syncthing.stignore` to the `.stignore` at the root of the synced folder so
SQLite's transient `-journal/-wal/-shm` sidecars aren't synced.
This is safe because **MTGA runs on only one device at a time**, so the two machines never
write the DB concurrently; the ~11 MB `mtga.db` (decks, wildcards, inventory history) syncs as
a single clean file.
## Development
```bash
uv run pytest
```
## Not yet implemented (ideas)
Auto-refreshing meta snapshots, live log-watching, deck similarity/clustering, non-English
card names.
TDQS
Scored across 11 tools
Most tools have distinct purposes, but search_cards with owned_only=true overlaps with owned_cards. Otherwise each tool targets a clear, separate function (searching, collection summary, deck import, gap analysis, etc.).
Names are readable and exclusively snake_case, but conventions vary: some are verb_noun (search_cards, import_deck, list_decks, delete_deck) while others are noun or adjective phrases (owned_cards, collection_summary, deck_gap, best_buildable_deck). No consistent pattern.
11 tools is well-scoped for the MTGA collection and deck analysis domain. Each tool fills a distinct role without unnecessary bloat, and the count feels appropriate for the server's purpose.
The set covers card search, collection overview, deck import/list/delete, and deck-building assistance, but lacks a tool to view a stored deck's full card list. This is a notable gap for analysis and verification workflows, though query_sql can partially address collection data if needed.