Skip to main content
Glama
Liaxum

piltover-archive

by Liaxum
README.md
# PiltoverArchives-mcp

An MCP server for the [Piltover Archive](https://piltoverarchive.com/) API — a
community card database, deck builder and proxy generator for the Riftbound TCG.

It exposes card search, card lookup, set listing and decklists as MCP tools, so
an assistant can answer questions about Riftbound cards it otherwise knows
nothing about.

## Why this exists

Riftbound is recent enough that its card data is not in any language model's
training data. Ask an assistant what `Vi, Destructive` costs and you get a guess.
This server closes that gap by giving it the real database.

## Install

```bash
git clone https://github.com/Liaxum/PiltoverArchives-mcp.git
cd PiltoverArchives-mcp
npm install
npm run build
```

### Claude Code

```bash
claude mcp add piltover-archive -- node /absolute/path/to/PiltoverArchives-mcp/dist/index.js
```

### Claude Desktop

Add to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "piltover-archive": {
      "command": "node",
      "args": ["/absolute/path/to/PiltoverArchives-mcp/dist/index.js"]
    }
  }
}
```

**No credentials are needed.** See [Authentication](#authentication) — supplying
an API key currently makes things worse, not better.

## Tools

| Tool | What it does |
| --- | --- |
| `piltover_search_cards` | Search cards by text, colour, type, set, rarity, tag, and energy/power/might ranges |
| `piltover_get_cards` | Resolve known cards in one request by collector number, exact name, or UUID |
| `piltover_list_sets` | List every set with its prefix and release date |
| `piltover_search_decks` | Search public decklists by name, description or legend |
| `piltover_get_deck` | Fetch one decklist, **resolving card UUIDs into real card names** |

All tools are read-only and take a `response_format` of `markdown` (default) or
`json`.

`piltover_get_deck` is the one that earns its keep: the API stores deck contents
as bare UUIDs with no names attached, so the tool batch-resolves every one of
them and renders a readable list.

```
## Main deck (39)
- 3x Charm [OGN-043] — Spell, 1 energy, Calm
- 3x B.F. Sword [SFD-161] — Gear, 4 energy, Order
```

### Configuration

| Variable | Default | Purpose |
| --- | --- | --- |
| `PILTOVER_API_BASE_URL` | `https://piltoverarchive.com/api/external/v1` | Override the API base |
| `PILTOVER_API_KEY` | *(unset)* | Optional bearer token. Leave unset — see below |

## Development

```bash
npm run build     # compile TypeScript
npm run inspect   # run against the MCP Inspector
```

---

# API reference

The rest of this document records what was learned about the upstream API. It
is kept because none of it is officially documented anywhere.

## How this was determined

Piltover Archive publishes **no API documentation**. There is no `docs.` or
`developers.` host, no OpenAPI/Swagger document, no developer section on the
site, and nothing indexed by search engines.

Everything below was reverse-engineered from the production Next.js client
bundles (`https://piltoverarchive.com/_next/static/chunks/*.js`, notably the
API client in module `491560`) and then **verified with live requests**. Where
something was inferred but not confirmed against the running API, it is marked
as such.

Because none of this is a published contract, it may change without notice.
Treat it as observed behaviour, not a stable interface.

---

## Base URL

The API is not on a separate host. It is a route inside the main Next.js app:

```
https://piltoverarchive.com/api/external/v1
```

The client bundle reads a server-side `EXTERNAL_API_URL` environment variable
and falls back to the relative path `/api/external`, which suggests the backend
may also be reachable directly on another origin. That origin is not public;
`api.piltoverarchive.com` does not resolve.

Infrastructure observed from response headers: Cloudflare in front of Railway
(`x-railway-edge: iad1`), with [Clerk](https://clerk.com) for user
authentication (`x-clerk-auth-status`).

## Authentication

Bearer tokens only:

```
Authorization: Bearer <token>
```

An `x-api-key` header is **not** read — sending the key that way is treated as
no credential at all.

Two kinds of bearer token exist. The web app sends a **Clerk session token** for
signed-in users. Programmatic clients are expected to send an **API key** with
an `ak_` prefix.

### Important: the read endpoints are public

`/v1/cards`, `/v1/decks` and `/v1/sets` return `200` with **no credentials at
all**. A card-data MCP server needs no authentication to be useful.

### The `ak_` API key and partner status

An `ak_`-prefixed API key was tested against this API. The results are worth
recording, because the failure mode is misleading:

| Request | Response |
| --- | --- |
| Deliberately invalid `ak_…` token | `401 INVALID_API_KEY` — "Invalid API key" |
| The real `ak_…` key | `401 PARTNER_REQUIRED` — "Partner identification required" |
| The real key on `/v1/partners` | `403 ADMIN_REQUIRED` — "Admin access required" |

The key is **valid and recognised** — it produces a different error than a
forged one, and it passes the authentication gate on `/v1/partners` only to
fail the *authorization* gate. What it lacks is an association with a partner
record on the server side.

The practical consequence is that sending the key is currently **worse than
sending nothing**:

| Endpoint | No auth | With the `ak_` key |
| --- | --- | --- |
| `GET /v1/cards` | **200** | **401** `PARTNER_REQUIRED` |
| `GET /v1/decks` | **200** | **401** `PARTNER_REQUIRED` |
| `GET /v1/sets` | **200** | **401** `PARTNER_REQUIRED` |
| `GET /v1/notifications` | 401 | 401 `PARTNER_REQUIRED` |
| `GET /v1/collection/stats` | 401 | 401 `PARTNER_REQUIRED` |

Authenticating downgrades otherwise-public endpoints to `401` and unlocks
nothing.

This is **not** a missing request header. `X-Partner-Id`, `X-Partner` and
`X-Partner-Key` were each tried alongside the bearer token and all returned the
identical `PARTNER_REQUIRED`. Partner association has to be granted server-side.
The app has an internal `/admin/partners` route, a `checkIsPartner` server
action and a `usePartnerStatus` hook, and exposes "Partner" as an account tier
alongside admin and supporter — so this is an account flag the Piltover Archive
maintainers set, reachable through their
[help channels](https://piltoverarchive.com/help) (email or Discord).

**Recommendation for this server:** build against the public endpoints and do
not send a key by default. Put the `Authorization` header behind an opt-in
setting so it can be switched on if and when partner access is granted.

## Response envelope

List endpoints return:

```json
{
  "data": [ ... ],
  "pagination": {
    "total": 1238, "page": 1, "limit": 1,
    "totalPages": 1238, "hasNext": true, "hasPrevious": false
  },
  "meta": { "filters": { "colors": [ { "id": "…", "name": "Body", "count": 223 } ] } }
}
```

`meta.filters` carries facet counts for the current result set, which is handy
for exposing filter options without a second request.

Errors are JSON: `{"error": "CODE", "message": "…"}`. Some responses also carry
a `requestId`. Validation failures use a different shape:
`{"type":"validation","on":"params","found":{…}}` with status `422`.

Observed error codes: `INVALID_API_KEY`, `AUTH_REQUIRED`, `PARTNER_REQUIRED`,
`ADMIN_REQUIRED`, `INVALID_REQUEST`, `PREMIUM_REQUIRED`, `NOT_FOUND`.

## Endpoints

### Cards

```
GET  /v1/cards               # search — public
POST /v1/cards/batch         # bulk lookup — public
```

`/v1/cards/batch` accepts at least one of `variantNumbers`, `names` or `ids`:

```bash
curl -X POST https://piltoverarchive.com/api/external/v1/cards/batch \
  -H 'Content-Type: application/json' \
  -d '{"variantNumbers":["ARC-001"]}'
```

Search parameters, taken from the client's own parameter normaliser
(`urlToCardSearchParams`) and confirmed working:

| Kind | Parameters | Notes |
| --- | --- | --- |
| Free text | `q`, `name`, `description`, `artist`, `flavor` | trimmed, truncated to 200 chars |
| Identity | `id` | truncated to 64 chars |
| Multi-value | `colors`, `sets`, `types`, `supertypes`, `variants`, `excludeVariants`, `rarities`, `tags`, `artworks`, `releaseDateAfter` | comma-separated, truncated to 500 chars |
| Ranges | `energyMin`/`energyMax`, `powerMin`/`powerMax`, `mightMin`/`mightMax`, `qtyMin`/`qtyMax` | non-negative integers |
| Sorting | `sortBy`, or `sort` + `dir` (`asc`\|`desc`) | `sortBy` takes precedence |
| Paging | `page` (≥1), `limit` (≥1, **capped at 100**) | |
| Convenience | `new=true` | client-side sugar that sets `releaseDateAfter` to a "new card" cutoff |

Example — `?q=Vi&limit=2&sort=name&dir=asc` returns 56 matches; `?energyMin=5`
returns 313.

A card variant looks like:

```json
{
  "id": "a60d2063-…", "variantNumber": "ARC-001",
  "rarity": "Showcase", "variantType": "Promo", "foilMode": "foil_only",
  "imageUrl": "https://piltoverarchive.b-cdn.net/…webp",
  "artist": "Fortiche Production", "releaseDate": "2025-10-14",
  "variantLabel": "Arcane Box Promo",
  "showInLibrary": true, "isCollectible": true,
  "cardmarketId": 858987, "tcgplayerId": 678053,
  "cardmarketPrice": 94.35, "tcgplayerPrice": 97.16,
  "set":  { "id": "…", "name": "Arcane Box Set", "prefix": "ARC", "releaseDate": "2025-12-31" },
  "card": {
    "id": "…", "name": "Vi, Destructive",
    "types": ["Unit"], "type": "Unit", "super": "Champion",
    "description": "[GANKING] …", "energy": 2, "might": 3, "power": 1,
    "tags": ["Vi", "Piltover"], "maxCopies": null, "banEffectiveDate": null,
    "colors": [ { "id": "…", "name": "Fury", "hexCode": "#CB222D", "imageUrl": "…" } ]
  }
}
```

Note the card/variant split: a `card` is the game object, and the top level is a
printing of it. Market prices come from Cardmarket and TCGplayer.

### Sets

```
GET /v1/sets                 # public
```

Returns `{ id, name, prefix, releaseDate, imageUrl }`, e.g.
`Origins | Nexus Night` (`OGN-NN`).

### Decks

```
GET    /v1/decks             # list — public
GET    /v1/decks/{uuid}      # detail
PATCH  /v1/decks/{uuid}
DELETE /v1/decks/{uuid}
GET    /v1/decks/{uuid}/likes
POST   /v1/decks/{uuid}/like
DELETE /v1/decks/{uuid}/like
```

`{uuid}` must be a UUID — `/v1/decks/me` returns `422`.

Deck list parameters differ from card search in two ways worth knowing:

- The request parameter is `limit`, but the response reports it back as
  `pageSize`. Sending `pageSize` is ignored and you silently get the default 20.
- `q` searches deck name, description **and** legend name, so a champion name
  finds decks led by that champion even when the title never mentions it.
- `sort` + `dir` order the results; `sortBy` is ignored here (unlike card search).
- `total` appears to cap at 10000.

Deck contents are stored as bare identifiers — `{cardId, variantId, quantity}` —
with no card names. Sections are `champions`, `battlefields`, `runes`,
`maindeck`, `sideboard` and `bench`. To render a decklist you must collect the
ids and resolve them through `POST /v1/cards/batch` with `ids`.

### Deck export

All `POST`, all taking a `deckCode` plus an `options` object. `image` and
`proxies` return a binary blob rather than JSON; `text` returns `{ "text": … }`.

```
POST /v1/decks/export/image           # deck image (PNG)
POST /v1/decks/export/proxies         # printable proxy PDF
POST /v1/decks/export/registration    # tournament registration sheet
POST /v1/decks/export/tts             # Tabletop Simulator
POST /v1/decks/export/text            # plain text list
```

Export options seen in the client include `expandCards`, `showQR`,
`showWatermark`, `sortBy` (as `{field, order}` pairs) and per-section toggles
(`legend`, `battlefields`, `runes`). Some option combinations return
`PREMIUM_REQUIRED`. Only the image export passes a bearer token.

### Authenticated / restricted

Reachable but gated; not usable without partner or admin status:

```
GET    /v1/notifications                       # AUTH_REQUIRED
GET    /v1/collection/stats                    # AUTH_REQUIRED
GET    /v1/collection/export
GET    /v1/collection/sets/{id}/details
GET    /v1/collection/users/{id}/ownership
PATCH  /v1/collection/{id}
PATCH  /v1/collection/binders/{id}
DELETE /v1/collection/{id}
DELETE /v1/collection/binders/{id}
GET    /v1/partners/{id}
GET    /v1/partners                            # ADMIN_REQUIRED
PATCH  /v1/admin/decks/{id}/featured           # admin only
```

`/v1/users` and `/v1/news` return a bare `403 {"error":"Forbidden"}`.
`/v1/tournaments` returns `NOT_FOUND`, despite tournaments existing in the
product — that data is presumably served another way.

## Implementation status

The tools listed at the top of this document are implemented and verified
against the live API. All of them use unauthenticated endpoints.

Collection and notification tools are **not** implemented: they require partner
access that the current key does not have. They can be added once Piltover
Archive grants it, without changing anything already built.

## Caveats

- None of this is a published API. It can change at any time, without notice.
- Be a good citizen: the endpoints are public but not advertised as such. Cache
  responses, respect the `limit` cap of 100, and do not hammer the service.
- Card data and images are Riot Games intellectual property, served by a
  community project. This repository is unaffiliated with both.

TDQS

A4.6/5.0

Scored across 5 tools

Disambiguation5/5

Each tool has a clearly distinct purpose: search vs. identity lookup for cards, list sets, search vs. fetch for decks. The descriptions even include explicit 'Don't use when' guidance steering between the card-search and card-get pair, which is the only plausible overlap.

Naming Consistency5/5

All five tools follow a strict piltover_<verb>_<noun> snake_case pattern (search_cards, get_cards, list_sets, search_decks, get_deck). Search/get/list verbs are used consistently and distinguish the operations predictably.

Tool Count5/5

Five tools is well-scoped for a read-only card and deck archive: two for cards, one for sets, two for decks. No bloat, and no single tool is doing unrelated work.

Completeness4/5

The surface covers the core lifecycle for a read-only archive: card search, identity resolution, set enumeration, deck search, and full deck retrieval, with resolution of deck UUIDs built in. Minor gaps remain (e.g. no explicit rulings/price-history or set-contents browsing beyond the search filter), but these are workable via piltover_search_cards.

Maintenance

ActivityMaintained
ResponsivenessNo issues