Protocol reference
The MCP surface: transport, sessions, errors and versioning.
Everything a client author needs to connect without reading the source. The tool surface itself is on Tools (generated from the registry); quotas and rate limits are on Limits; the response envelope is on Reading a response.
Principals and what each sees
initialize returns the same shape for every door; kind decides the tool
list.
| Kind | Door | Hidden tools | Tools listed |
|---|---|---|---|
person |
/mcp |
clans_context, elixir_identify, elixir_my_identities |
44 |
agent |
/a/<public_id>/mcp |
elixir_my_players, clans_my_view |
45 |
integration |
/i/<public_id>/mcp |
elixir_my_players, clans_my_view, clans_context, elixir_nickname, elixir_timeline, elixir_track_player, elixir_track_clan, elixir_identify, elixir_my_identities |
38 |
The counts are generated from the registry at build time. Hiding is
enforced: calling a hidden tool answers JSON-RPC -32601 with data.kind
and a hint. Do not rely on a cached tools/list from another kind of
connection.
Tools are declared in group order, then by title, so a client that keeps
server order shows the domains: Account, Players, Battles, Cards, Clans, War, Game, Live, Timeline, Service, Help.
The group rides each tool's title
(Players · Player profile); names never carry it, so regrouping breaks
nothing. One page per group is under Tools.
initialize
{ "protocolVersion": "2025-06-18",
"capabilities": { "tools": { "listChanged": true },
"resources": { "subscribe": false, "listChanged": false },
"prompts": { "listChanged": false } },
"serverInfo": { "name": "elixir-mcp", "title": "Elixir MCP - Clash Royale history, recorded",
"version": "11.18.6+tools.3f1c9a2b7d4e", "websiteUrl": "https://elixir.poapkings.com/" },
"instructions": "…",
"_meta": { "elixir.poapkings.com/principal": { "kind": "person",
"subject": { "type": "player", "tag": "#PYLQ88", "name": "Birch Lantern" },
"clan": { "tag": "#QRP2GV", "name": "Tidewater" } } } }
instructions is prose tuned for the model and changes without notice;
never parse it. It is written to arrive whole in 2,048 characters, the most
some clients (Claude Code) hand the model, and in order of value: who you
are (your primary and your clan, or the clan an agent acts for; the other
players and clans you track by name while they fit, otherwise counted, with
elixir_my_players for the list), the one-line rules that change an answer,
where to start, how to file feedback, and a pointer to the
argument conventions below, which it does not
restate. The same facts ride in _meta as data; see
Reading a response.
Resources and prompts are declared beside tools because a
stateless server cannot push notifications/tools/list_changed and clients
list resources lazily at read time, so the documentation stays reachable when
a cached tools/list is stale:
| Method | Returns |
|---|---|
resources/list |
elixir://docs (the index), elixir://docs/<slug> for every page, elixir://examples, elixir://examples/<slug>, elixir://changelog, elixir://updates, elixir://cards |
resources/templates/list |
elixir://docs/{slug}, elixir://docs/{slug}#{section} (one H2 section), elixir://examples/{slug} |
resources/read |
Markdown for pages, sections and examples; JSON for the indexes, the changelog, the updates and the card catalog. An unknown URI is JSON-RPC -32002 |
prompts/list, prompts/get |
the ten examples as prompts, each a user message carrying the example's question and the tools it uses |
Reading a resource spends no daily quota; the hourly rate limit still applies.
Versioning and the cache-buster
serverInfo.version is <contract_version>+tools.<fingerprint>, where the
fingerprint is the first 12 hex characters of the SHA-256 of the declarations
your kind of connection receives. The server is stateless and never sends
notifications/tools/list_changed even though it declares listChanged;
compare serverInfo.version on every initialize and re-read tools/list
when it differs. elixir_changelog({ since: "0.35.0" }) lists what moved,
newest first, with tools_added and breaking where relevant. Pages contain
up to 20 releases. Pass next_offset as offset, keeping since unchanged,
until next_offset is null; total counts all releases matching since.
Omit since to page through the complete history, or read the complete JSON
resource at elixir://changelog.
The MCP contract is versioned over what an agent reasons from, not the code.
A new capability (a tool, an argument, a field) is a minor. A correction is a
patch, including one that removes a field that proved unreliable: an agent
reads the current declaration, output schema and notes, so a missing field
simply withdraws a claim. A major marks a shift in the domain model, one that
changes what a task means. There is no deprecation window; a renamed or
removed field goes in the release that replaces it. Every response carries
meta.contract_version. The JSON API at /api/v1 is for programs and keeps
ordinary semver of its own (Integrations).
Errors
Three layers, each with a closed set.
HTTP: 401, 403, 405 and 429 as on Connecting; 400 for malformed JSON, a batch or an
unsupported MCP-Protocol-Version; 500
never carries internals.
JSON-RPC (error.code):
| Code | When | HTTP |
|---|---|---|
-32600 |
not a JSON-RPC 2.0 object, a batch, or an unsupported MCP-Protocol-Version |
400 |
-32601 |
unknown method, or a tool hidden from this principal kind | 200 |
-32602 |
unknown tool name, or unknown prompt | 200 |
-32002 |
unknown resource URI | 200 |
-32029 |
daily tool-call quota reached; message names the cap and "It resets at midnight UTC." | 200 |
-32003 |
insufficient scope | 403 |
Tool result: a failed call is a normal tools/call result with
isError: true whose content[0].text is JSON, and no structuredContent
(which is held to the tool's outputSchema; an error envelope is not that
shape):
{ "error": { "code": "not_recorded", "class": "subject", "message": "…", "hint": "…" },
"meta": { "as_of": "…", "request_id": "…", "disclaimer": "…", "contract_version": "11.18.6" } }
| Code | Class | Meaning |
|---|---|---|
invalid_tag |
input |
input failed tag normalisation; hint states the rule |
not_entitled |
subject |
the caller lacks entitlement to the subject (clan tools, slots, identity binding) |
not_recorded |
subject |
the subject is valid but nothing has been recorded for it; on a person's door with no clan_tag, also the primary player's clan when it is not recorded (the default never slides to an alt's clan; the hint names the clan and the tracking call) |
not_found |
subject |
unknown to the record and to the live API; an unknown docs page, example |
no_subject |
subject |
nothing to answer about: no primary player on the account, a primary player in no clan when clan_tag is omitted, an on_behalf_of nobody has mapped, an agent with no recorded clan. The hint names the one call that fixes it (elixir_track_player, elixir_identify, or pass the tag). With display_name beside an unmapped on_behalf_of, error.candidates[] lists the clan members whose whole name matches (player_tag, name, clan_tag, role; case and spacing ignored, never a partial match), so one candidate is one elixir_identify call and zero or several is a question |
quota_exceeded |
budget |
a per-account slot or live-fetch cap; the daily call quota uses -32029 instead |
live_unavailable |
server |
the live lane is not configured, or the fresh payload was refused at admission |
live_pending |
retry |
live: true found no read inside the API's cache window and queued one (or, with the shared budget spent until the next tick, queued nothing and charged nothing); nothing is recorded for the subject yet, so there is no answer to give now. error.retry_after_s (an integer, seconds) says when to call again |
bad_request |
input |
structurally invalid input other than tags: unknown enum, inverted window, over-max limit, bad cursor, unknown timezone |
result_too_large |
input |
the request was fine and the result exceeded the delivery cap; the hint names the narrowing arguments. Also what live_fetch answers for a battle-log path, before spending the lane |
query_timeout |
retry |
an analytical read exceeded its cancellable query budget; no analytical result is returned. Retry the named call after a few seconds or narrow its from/to window; report meta.request_id if it persists. |
internal |
server |
the arguments were accepted and the server failed. Retrying the same call once is reasonable; if it fails again, report meta.request_id with elixir_send_feedback |
Every code is one branch: the message is for a person, the hint names one
executable next step (a tool and its arguments), and an agent should never
have to read the message to know which case it is in. Check the error body,
not only the transport flag; meta.request_id identifies the call for a
report.
class is the one word a consumer branches on without
knowing the code list: retry is not a failure of the call (call again:
live_pending after retry_after_s, query_timeout after a few seconds or
with a narrower window); input means the call is wrong as sent; subject
means the call is fine and there is nothing to answer about; server means
the server failed and meta.request_id is what to report; budget is a
quota. Every code has exactly one class (ERROR_CLASS in the contracts
package), so live_pending is never a failed call. Transport-level refusals (the rate limit, a
database that will not connect) carry the same envelope and class.
The heavy MCP reads battles_trends, cards_card, clans_standings and
war_history each have a query budget of at most 18 seconds per call,
shortened if Lambda has less time remaining. It covers the whole aggregation,
not a fresh allowance for each query. PostgreSQL cancels the work before the
25-second Lambda ceiling so the tool can return query_timeout, a retry hint
and meta.request_id, and audit the failed call. This is not a latency promise:
a long history read can still exhaust the budget under load. Smaller
limit values trim output, not the population scanned; narrow from/to to
reduce work.
The response cap
A result over 48,000 characters of compact JSON is not delivered sliced.
The body is replaced by a result_too_large error, "Result is N
characters; the cap is 48000.", with a hint naming the tool's narrowing
arguments and, where the tool has it and the call was not already compact,
verbosity: "compact" (or, for a tool without any, asking
you to report the request_id). The original request_id is preserved and
isError is set. The size of a page depends on what its rows hold (a full
battles_query page carries both sides' decks), so it cannot be predicted
up front; when the call had a limit, the hint also says how large the page
was and which limit would have fit the same arguments, so one retry
replaces a guessing loop. battles_query refuses limit above 25 unless
verbosity: "compact" for this reason, and live_fetch refuses a
/players/{tag}/battlelog path with the same code before spending a live
fetch, pointing at battles_query({ live: true }).
Argument conventions
The same conventions hold on every tool; the initialize instructions state
them once and the per-argument descriptions are one line each. The shorter
version, with the tool map, is Choosing a tool.
What omitting a subject argument means depends on the tool's family, and
the first sentence of every description says which. Segment tools are the
exception: segment is required, to identify the player or clan being read, and is never an implicit default.
| Family | Subject argument | Omission rule |
|---|---|---|
Player tools (players_*, battles_query, battles_performance, battles_decks, battles_cards, battles_opponents, elixir_coverage) |
player_tag |
the caller: a person's primary player, or whoever on_behalf_of maps to on an agent connection |
Clan tools (clans_*, war_current, war_history, war_rivals) |
clan_tag |
a person's primary player's current clan (refused not_recorded when that clan is not recorded, no_subject when the primary is in no clan; never an alt's, a friend's or another tracked clan), an agent's clan |
Segment tools (battles_trends, cards_card, badges_rarity, badges_holders) |
segment |
cannot be omitted; pass "mine", or an object naming a player or clan |
game_clock, cards_catalog, the Help tools |
none | no subject at all |
No default is ever looked up first, and there is no "no default" guess: a
player tool with nothing to answer about is no_subject with the fixing call
in its hint.
- Tags.
#plus 3 to 12 characters from0289PYLQGRJCUV. Input is trimmed and upper-cased, a missing#is added, and the letter O folds to zero. Anything else isinvalid_tag.*_tagis one tag,*_tagsan array. on_behalf_of(≤200 chars, opaque) selects the end user on an agent connection; ignored on a personal one, where the caller is always the primary player. A personal connection'stools/listleaves it anddisplay_nameout, and drops either if a client still sends it. The tools that build the map,elixir_identifyandelixir_my_identities, are agent-only. An emptyplayer_tagis refused as a caller bug, never treated as "default". See Agents.- Validation is strict: arguments are checked against the
published
inputSchemabefore the handler runs. An unknown enum value, an inverted date window, or alimitabove the declared maximum isbad_request, never clamped or emptied. - Windows are
fromandtoon every windowed tool: an ISO instant, orYYYY-MM-DDresolved in the account's timezone, where a date-onlytocovers that whole day. The battle and aggregate reads takefrominclusive andtoexclusive; the daily series count whole game days, both ends included (an instant is floored to its game day, and the response says so);elixir_timelineselects over (from,to] (below).daysandweeks(andseasonsonwar_history) are sugar forfrom, andseasonbounds one season on every windowed tool. The per-tool defaults are on Time and clocks. timezoneon any windowed tool is an IANA zone for that call alone: it resolves the date-only bounds and every local label (battle_time_localis ISO 8601 with its offset). Default: the account's timezone. An unknown zone isbad_request.appliedis the one echo block, on every response except seven (elixir_my_players,elixir_my_identities,elixir_coverage,elixir_collectors,elixir_data_insights,elixir_exampleswithout anexample, andelixir_docswithout apage):window(from,to,sourceofargument|default|unbounded|season|fixed(andpointeronelixir_timeline),timezone, and on the season-grained toolsseason,crossesandseason_age_days),limit,sort,mode,min_battles,segment,verbosity, as used. There are nofilters_applied,window_*orlimit_appliedkeys.verbosity: full | compactis the one size control, accepted on every tool. Wherecompactchanges the answer the description says what it drops; a tool with one size says so in a note and answers in full. There is no other flag for size.notes[]anddocsride every response: one-sentence caveats to repeat, and apage#sectionpointer into this documentation (elixir_docs({ page, section })orelixir://docs/<page>#<section>). Formulas live in the docs, never in a note.- Timestamps in responses are ISO 8601 UTC with a trailing
Z;meta.timezone_appliednames the display zone when local labels were used. Timeline items carryat. - Null is unknown, never zero: a source never polled has
observed_at: nullandfreshness_seconds: null; a member with no race row hasnullwar decks, not0; a destroyed tower is0and unreported tower data isnull. - Cursors.
battles_queryreturnsnext_cursor(nullmeans the end). Treat it as opaque: pass it back unchanged, never parse or construct one; a forged or stale cursor isbad_request.elixir_timelineuses instants:from/to, andnext_cursoris the window end you just read. live: trueonplayers_profile,clans_roster,war_current,battles_queryasks for a read no older than the API's cache. Asynchronous: fresh if in hand, otherwise queued and answered now from the record withlive_status: { state: "pending", retry_after_s }; call again after that. A new live read spends one request of the fleet's one global budget; when that budget is spent until the next scheduler tick nothing is queued or charged,live_statusstayspendingwithretry_after_sthe seconds to that tick, and the note says the budget had no room. Awar_currentread in the minute or two after a season roll, before the new race is matched, answerslive_status: { state: "matchmaking", fetched_at, retry_after_s }: no race yet, never an error. The tools are annotatedopenWorldHint.elixir_timelinewindows work differently: over (from, to] at the millisecond it serves, by when an item happened (occurred) or when the record learned it (observed), so a cut instant passed astoreaches the item at the cut.
Identifiers the record uses
deck_hash: SHA-256 hex ofsort(cards.map(c => id + ":" + (evolutionLevel ?? 0))).join(",") + "|" + (towerTroopId ?? 0). Card form (evolutionLevel1 = Evolution, 2 = Hero, absent or 0 = base) is part of identity; card levels and star levels are not.battle_id: SHA-256 of the canonical battle time, the sorted participant tags, and the battle class; the same battle seen from two logs is one row.request_id: a UUID minted before the tool runs and stamped intometa; the same id appears under Settings › Usage and limits › Requests.
Feedback and the changelog, over the wire
Feedback is one record for all of Elixir: an item filed here, over the
JSON API (POST /api/v1/feedback), from Settings, Ladder, Clan,
the docs or an email's footer lands in the same queue, is answered there,
and is listed by elixir_my_feedback wherever it was filed.
elixir_send_feedback({ message, category?, context?, request_id?, request_ids?, refs?, follows_id? }):message1 to 8000 chars;categoryone ofgeneral(default),bug,data_quality,feature,praise,judgment(Elixir judged someone wrongly),other.request_idis themeta.request_idof the call the feedback is about. Every response carries one, and passing it attaches that exact request, its arguments and its answer to the report, so the maintainer sees what you saw.request_ids(up to 20) carries every call a turn made when there were several; the first becomesrequest_idwhen that was omitted.refs(up to 20, with the calls) points at anything else it is about:{ kind, ref }with kindemail(a send id from a mail's footer),feedback(one of your ownfeedback_ids this item belongs with, such as another facet of one review; it is a link, not a reply),playerorclan(a tag), orclan_action,award,policy(Clan's ids). Only your own calls, emails and items attach; a pointer that is malformed or not yours is dropped and named innotes, and the report is kept.follows_idnames one of your own items this one follows, so the thread reads as one: a reply to an answer, the answer to aneeds_infoquestion, or a correction to something you filed (a filed item is not withdrawn; a correction that follows it says what was wrong, and the answer covers both).contextstays free text for naming a tool or a question. Filing counts as one call against the daily quota, like any tool call.- Agent connections only: an agent relaying a person's feedback also
passes
on_behalf_of(the person on its surface, e.g.discord:1234); it is kept with the report, and the answer still comes to the agent. A personal connection is its person's own, so its schema has noon_behalf_of: what it files is already that person's. elixir_my_feedback({ feedback_id?, limit?, offset?, status?, since?, area?, verbosity? }):statusone ofnew,seen,planned,needs_info,done,declined;areaone ofmcp,api,console,ladder,clan,mail,docs,recorder(where it was filed). Returns each item'sarea,refsandfollows_idwhen it has them,response,responded_at,shipped_in,related_tools. Pages are bounded by delivered size as well aslimit, so follownext_offsetwith the same filters until it isnull;totalcounts the filtered ledger. Only replies delivered on that page are marked seen, andmeta.feedback_responses_pendingstays raised while another response remains unread.verbosity: "compact"is the listing: each item'sfeedback_id,created_at,area,category,title(the message's first line),status,follows_id,related_ids,has_response,responded_atandshipped_in, with no bodies, so a 50-item page fits and nothing is marked seen.feedback_idreads one item whole, withfollowed_by(your items that reply to it) andrelated_by(your items that name it as related), and marks its answer seen.needs_infomeans the answer is a question for you: reply withfollows_id, and the reply is answered in its turn.elixir_changelog({ since?, limit?, offset? }): entries{ version, date, summary, tools_added?, breaking? }, newest first.limitis 1 to 20 (default 20);offsetdefaults to zero. Follownext_offsetwith the samesinceto reach every entry;nullmeans the end.totalis the number matching the exclusivesincefilter.
Machine-readable surfaces
Over the connection itself, three read-only Help tools serve this
documentation from the same sources the site renders: elixir_docs (the
index; one page by slug; one H2 section with page + section; or a word
search with query), elixir_examples (the ten worked examples with the
tools each calls) and elixir_updates (What's new, newest first, since a
date). The same text is reachable as resources at elixir://docs,
elixir://docs/<slug>, elixir://docs/<slug>#<section>, elixir://examples,
elixir://examples/<slug>, elixir://changelog, elixir://updates and
elixir://cards, and the examples as prompts. Over plain HTTPS:
| URL | Contents |
|---|---|
/llms.txt |
this site in one page: docs index, tool list, endpoint |
/llms-full.txt |
every documentation page and the full tool reference in one fetch |
/tools.json |
the registry as JSON: name, group, description, arguments, required scope |
/docs/integration-api.json |
the REST API's OpenAPI 3.1 document |
/updates |
every shipped change, contract versions included - the same list elixir_changelog returns, with the releases around it |
/feed.xml |
RSS 2.0 of What's new: the newest 50 updates and contract versions, each item's id its own /updates/<slug> page |
/feed.json |
the same items as JSON Feed 1.1 |
/api/public/stats, /api/public/status |
live counts of what the recorder holds and recording health, no authentication, cached at the edge for the time each states in Cache-Control; they name no player or clan, except that each collector is credited to its operator's primary player |