blizzard-wow-mcp
# blizzard-wow-mcp
[](https://github.com/hjoliveira/wow-armory-mcp2/actions/workflows/ci.yml)
An MCP server exposing World of Warcraft character data from the [Blizzard API](https://develop.battle.net/documentation/world-of-warcraft).
It targets the MCP 2026-07-28 specification via the Python SDK v2 (`mcp>=2`). Everything it does is read-only: it authenticates with the OAuth2 client-credentials flow, so it can reach any public character profile, but none of the `/profile/user/*` endpoints — those require the authorization-code flow with an end user in the loop.
## Requirements
- Python 3.14+
- [uv](https://docs.astral.sh/uv/)
- Blizzard API credentials — create a client at [develop.battle.net/access/clients](https://develop.battle.net/access/clients)
## Setup
```bash
uv sync
```
Then set your credentials:
```bash
export BLIZZARD_CLIENT_ID=...
export BLIZZARD_CLIENT_SECRET=...
```
| Variable | Required | Default | Notes |
| --- | --- | --- | --- |
| `BLIZZARD_CLIENT_ID` | yes | — | Server refuses to start without it |
| `BLIZZARD_CLIENT_SECRET` | yes | — | Server refuses to start without it |
| `BLIZZARD_REGION` | no | `eu` | One of `us`, `eu`, `apac`. Realms differ between regions |
| `BLIZZARD_LOCALE` | no | `en_GB` | Any locale the region supports, e.g. `en_US` |
## Running
```bash
uv run server.py # stdio (default)
uv run server.py --http # stateless streamable HTTP on :8000/mcp
```
The package also installs a `blizzard-wow-mcp` console script that does the same thing.
### Claude Desktop / MCP client config
```json
{
"mcpServers": {
"wow": {
"command": "uv",
"args": ["run", "--directory", "/path/to/wow-armory-mcp2", "server.py"],
"env": {
"BLIZZARD_CLIENT_ID": "...",
"BLIZZARD_CLIENT_SECRET": "...",
"BLIZZARD_REGION": "eu"
}
}
}
}
```
## Verifying it works
In `--http` mode the server is stateless and returns plain JSON, so curl is enough to smoke-test it — no session ID and no `initialized` notification needed.
Start it in one shell:
```bash
BLIZZARD_CLIENT_ID=... BLIZZARD_CLIENT_SECRET=... uv run server.py --http
```
Check it is up and negotiating:
```bash
curl -s -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
```
List the tools — the quickest single check that the server is wired up:
```bash
curl -s -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
```
Call a tool, which exercises the credentials and the round-trip to Blizzard. `wow_find_realm` is the cheapest one — it needs no character name:
```bash
curl -s -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"wow_find_realm","arguments":{"query":"twisting"}}}'
```
A few gotchas:
- The `Accept` header must list **both** `application/json` and `text/event-stream`. That is a streamable-HTTP requirement, not optional.
- If your shell has `HTTPS_PROXY` or `ALL_PROXY` set, add `--noproxy '*'` or curl will try to proxy localhost and hang.
- A tool call that comes back with `"isError": true` and a 403 usually means bad or unset credentials, or that outbound access to `oauth.battle.net` is blocked.
## Tools
| Tool | Purpose |
| --- | --- |
| `wow_character_summary` | Level, class, spec, race, faction, guild, item level, achievement points, last login, Armory URL |
| `wow_character_equipment` | Every equipped item with slot, name, item ID, item level, quality, enchantments, stats, sockets and tier set |
| `wow_character_mythic_keystone` | Mythic+ season rating and best runs, sorted by rating. Defaults to the character's most recent season |
| `wow_item_search` | Find items by name, filtered by quality and item level, to get the ID `wow_item` needs |
| `wow_item` | An item's own record by ID: stats, weapon damage, effects, binding, durability and vendor prices |
| `wow_character_resource` | Escape hatch for any other character sub-resource — raid progression, talents, professions, PvP, reputations, collections, and so on |
| `wow_find_realm` | Resolve a realm name to the slug the other tools need |
All tools are annotated read-only, idempotent and non-destructive.
Everything except `wow_character_resource` and `wow_find_realm` returns structured Pydantic models rather than raw Blizzard JSON. `wow_character_resource` returns the raw payload with Blizzard's self-referential `key`/`href` link objects stripped, which typically removes 30–50% of the tokens without losing anything a model would use. Its `resource` argument is constrained to a fixed list: `achievements`, `appearance`, `collections/mounts`, `collections/pets`, `collections/toys`, `encounters/dungeons`, `encounters/raids`, `hunter-pets`, `professions`, `pvp-summary`, `quests/completed`, `reputations`, `specializations`, `statistics`, `titles`.
### Item stats: base vs. worn
There are two sources of item stats, and they answer different questions.
`wow_item` reads `/data/wow/item/{id}`, whose `preview_item` block carries the item's **base** form — Blizzard renders it at the item's default bonus list. The endpoint takes no bonus-list or context parameter, so for gear that scales (most modern raid and Mythic+ drops, which ship at many item levels under one item ID) the stats it reports will not match any particular character's copy.
`wow_character_equipment` reports what a character is **actually wearing**, rendered with that instance's real bonus list — so its `stats`, `sockets` and `item_level` are authoritative for that character. Each equipped item also carries an `item_id`, which is what you feed to `wow_item`.
Neither can resolve arbitrary bonus IDs — an auction listing's `bonus_lists` cannot be turned into stats through any public endpoint.
### Searching for items
`wow_item_search` is the only way into the item data by name; Blizzard exposes no endpoint that enumerates items. Matching is token-based rather than substring, so `Thunderfury` matches and `fury` does not. Results are sorted by item level descending and carry classification only — feed an `id` to `wow_item` for the stat block.
The search API filters on a **locale-qualified** field name (`name.en_GB`, not `name`), which makes `BLIZZARD_LOCALE` load-bearing in a way it is not for any other endpoint. A locale the region does not serve yields either an empty result set or a 400; both are reported with a message naming the locale. If you change `BLIZZARD_REGION`, change `BLIZZARD_LOCALE` to match.
The filter encoding — locale-qualified fields, `[min,max]` ranges, `_page` / `_pageSize` — is taken from Blizzard's documentation rather than from a captured response, and lives entirely in `_item_search_params`. If a live call disagrees, that one function is the only thing to change.
## Notes on names and realms
Realm names are slugified automatically — `Kil'jaeden` becomes `kiljaeden`, `Área 52` becomes `area-52` — and character names are lowercased and percent-encoded, so you can pass them as a player would write them.
If a lookup 404s, the likely causes are: the character does not exist on that realm, the realm slug is wrong, the character is below level 10, or it has not logged in since the last expansion. Call `wow_find_realm` to confirm the slug before retrying, and check `BLIZZARD_REGION`.
Blizzard's quota is 100 requests/second and 36,000/hour per client. The server caches the access token for its lifetime (refreshing 60s before expiry) and retries once on a 401, so ordinary use stays well inside that.
## Development
Linting is configured for [ruff](https://docs.astral.sh/ruff/) (line length 100, targeting py314):
```bash
uvx ruff check .
uvx ruff format .
```
### Tests
```bash
uv sync # installs the dev group
uv run pytest
```
The suite is offline — every Blizzard call is served by an `httpx.MockTransport`, so no
credentials and no network access are needed:
- `tests/test_helpers.py` — realm slugging, character normalisation, `slim()`, timestamp
conversion, and the localised-name fallback search results need.
- `tests/test_client.py` — token caching and refresh, the single 401 retry, extra query
parameters merging without clobbering the namespace, and the mapping from HTTP status
codes to readable `BlizzardError` messages.
- `tests/test_tools.py` — all seven tools, including field mapping, missing optional fields,
equipment stat/socket/set parsing, item detail from `preview_item`, item search filter
encoding and locale handling, Mythic+ season defaulting and run ordering, and realm-search
matching.
### CI
`.github/workflows/ci.yml` runs ruff and the test suite on Python 3.14 for every pull request,
plus weekly on Mondays at 06:00 UTC and on demand via workflow dispatch.
Pull request runs use `uv sync --locked`, so they install exactly what `uv.lock` pins and fail if
the lock has drifted from `pyproject.toml`. The weekly run uses `--upgrade` instead, resolving
dependencies fresh so it surfaces upstream releases that break the server — which a run pinned to
the lockfile would never catch.
## License
[MIT](LICENSE)
TDQS
Scored across 5 tools
Each tool targets a distinct aspect of WoW character data: summary, equipment, Mythic+ details, generic sub-resources, and realm resolution. The generic resource tool is explicitly for sub-resources without dedicated tools, so no overlap exists.
All tool names use lowercase snake_case and the 'wow_' prefix, with four of five sharing 'wow_character_*'. The exception is 'wow_find_realm', which uses a verb and different resource prefix, creating a minor but noticeable pattern deviation.
Five tools is a well-scoped count for a character-information server. It covers core workflows without overwhelming users, and the generic resource tool prevents unnecessary tool proliferation.
The dedicated tools handle the most common character lookups, while the generic resource tool provides access to many additional sub-resources (raids, talents, professions, etc.). This effectively covers the domain of WoW character data without significant gaps.