Skip to content

mcp

Start an MCP (Model Context Protocol) server that exposes Ritual’s deck, collection, and wanted-list management to AI agents (Claude Desktop, Claude Code, and other MCP clients).

The MCP server runs the same operations as the web admin interface in-process — it reuses the admin route handlers directly, so editing through MCP behaves identically to editing in the browser (the same changelog, content-hash conflict detection, and optional git auto-commit). It does not open the admin HTTP server or require an admin login; it is a local, trusted process you launch yourself.

Terminal window
ritual mcp [options]
OptionDescriptionDefault
--transport <type>Transport to use: stdio or httpstdio
-p, --port <number>Port for the HTTP transport8765
--host <address>Host to bind for the HTTP transport127.0.0.1
--token <secret>Require this bearer token on the HTTP transport
--allow-unauthenticatedServe the HTTP transport without a bearer token on a non-loopback host
--sell-modeAnswer the sell/buylist tools for this run even when site.sellMode is off (both transports)

--token may also be supplied via the RITUAL_MCP_TOKEN environment variable (the flag takes precedence); this keeps the secret out of the process list. The global --base-dir <path> option selects which Ritual workspace (decks/collections/wanted dirs) the server operates on — the RITUAL_BASE_DIR environment variable does the same, which is handy in an MCP client’s env block, and the directory must already exist or the server exits 2 before starting. Likewise, a malformed ritual.config.json aborts the server with exit 1 before the transport opens. --cache-server <host:port> is also honoured.

The standard transport for local MCP clients: the client launches ritual mcp and exchanges JSON-RPC over stdin/stdout. There is no network exposure and no authentication — the client already controls the process.

Over stdio the protocol era is chosen by the connection’s opening exchange (a 2025-era initialize is served on a compatibility path; a 2026-07-28 client is served statelessly), and one server instance is pinned for the life of the connection — the per-request stateless model described below applies to HTTP only.

Terminal window
ritual mcp --transport http --port 8765 --token "$MCP_TOKEN"

Serves the MCP Streamable HTTP transport at http://<host>:<port>/mcp for remote/networked clients. It binds to 127.0.0.1 by default. --port is validated at parse time (1–65535); an invalid value exits with code 2. If you expose it beyond localhost, set a token (--token or RITUAL_MCP_TOKEN) so every request must send Authorization: Bearer <token> — there is no other authentication layer.

Ritual implements MCP revision 2026-07-28, whose Streamable HTTP transport is stateless: there is no initialize handshake and no Mcp-Session-Id header. Every POST to /mcp is served on its own, carrying the protocol version and client capabilities in the request itself. Clients speaking the older 2025-era protocol (including current Claude Desktop / Claude Code releases) are still served — Ritual answers their initialize handshake on a compatibility path. On that path the standalone GET /mcp SSE stream and DELETE /mcp session teardown are not available and answer 405; Ritual uses neither feature.

Without a token, the command refuses to bind a non-loopback --host (exit code 2) unless you explicitly pass --allow-unauthenticated — an unauthenticated MCP endpoint exposed beyond the local machine would let anyone on the network edit your lists. Tokenless binds to a loopback host (127.0.0.1, localhost, ::1) are allowed and print a one-line notice on stderr.

The HTTP-only flags (--port, --host, --token, --allow-unauthenticated) have no effect under the default stdio transport; passing them there prints a warning on stderr and they are ignored.

Ctrl+C (SIGINT) or SIGTERM stops the listener and drops active connections, so the port is released and the process exits on its own rather than being killed with a bound socket behind it. A teardown that fails is reported on stderr instead of being swallowed. ritual admin --mcp does the same for both of its listeners.

ResponseWhen
401Missing or wrong bearer token. The JSON-RPC body carries implementation-defined code -32010.
403On a loopback bind only: a request whose Host or Origin header is not local (DNS-rebinding protection, checked before auth). A deliberately exposed host skips this check and is guarded by the bearer token instead.
404A path other than /mcp. The body carries implementation-defined code -32011.
405GET/DELETE on /mcp — the 2025-era session operations, which stateless serving does not have.
415A POST whose Content-Type is not application/json.

On 2026-07-28 responses, the catalog surfaces (tools/list, resources/templates/list, server/discover) advertise a one-hour private cache hint; list enumerations and reads (resources/list, resources/read) are marked never-cacheable, since their contents change with every edit. Ritual declares no tool-list-changed notifications and no resource subscriptions; see Resources for the transport-dependent resources.listChanged.

Instead of a standalone process, you can serve the same MCP endpoint inside a running web admin with ritual admin --mcp. That runs one process exposing both the web admin and an MCP endpoint (on --mcp-port, default 8765), sharing the same config, cache, and data. It uses the same bearer-token auth as this command: a token (--mcp-token or RITUAL_MCP_TOKEN) is required there (since the admin binds 0.0.0.0 by default) and is independent of the browser admin login.

Both halves of the tool-result contract are transport-independent — they hold identically over stdio, over Streamable HTTP, and on either protocol era.

Every tool declares an outputSchema and answers with structuredContent. Read structuredContent, not content[0].text — a successful result carries an empty content array on purpose, so the same JSON is never put on the wire twice. Only a failure carries a text block, and it holds the message below.

A tool’s outputSchema, as returned by tools/list, is the authoritative field-level documentation of its response: every field, its type, whether it is always present, and a description of what it means. This page describes the tools; the schemas describe their replies, and they are what the client validates against. The one exception is the failure payload — isError results are exempt from output-schema validation, so no schema carries it and it is documented in prose below instead.

A failed tool call is not a JSON-RPC error. It comes back as a normal result with isError: true, carrying a one-line text block (the message) and a structured payload:

{
"error": true,
"code": "conflict",
"message": "Deck has been modified since you loaded it. Please reload.",
"conflict": true,
"recovery": "Re-read the list with get_list, then re-apply your change."
}
FieldMeaning
codeconflict | invalid-request | internal.
conflictPresent (and true) only on code: "conflict" — a lost optimistic-concurrency race.
recoveryThe next concrete action, when there is one.
unmatchedChanges that did not apply, when an all-or-nothing batch was rejected whole.

Ritual’s internal conflict code -32012 is never visible to a client: Ritual itself catches every error thrown inside a tool call and converts it into the isError result above, so the numeric code is spent before the SDK — let alone a client — ever sees it. It survives only as the internal signal that drives the one automatic retry. The -32010 / -32011 codes in the HTTP Errors table are different — they are emitted by the HTTP wrapper before the request reaches the protocol layer, so they are wire-visible, and correspondingly never appear inside a tool result.

(One error is deliberately re-raised rather than structured: a URL-elicitation request is a protocol handshake the client must answer, not a tool failure.)

Ritual’s CLI output and its two web UIs follow the configured UI locale (--locale, RITUAL_LOCALE, or the uiLocale config key). This surface does not. Tool names, titles, descriptions, parameter documentation, output-schema descriptions, the server instructions, and the message of every result — success or failure — stay English no matter what that setting says. They are model-facing prose densely interleaved with flags, file paths, and snake_case tool names, so a translated copy would help no one and would break the very identifiers a client matches on.

A client that does render for a human gets the sentence unrendered instead of translated. Results produced by the shared admin handlers carry two optional fields beside message:

FieldMeaning
messageKeyThe catalog key message was rendered from — stable across locales and across rewordings of the English text. Absent when a handler has no key for that sentence.
messageParamsThe values that key interpolates. Absent when the message takes none.

Match on messageKey (or, for a failure, on code) rather than on the prose: both are locale-invariant, and message is the field most likely to be reworded. The two sync tools carry the same triple per clause in summary.clauses, so a run’s one-line outcome can be re-joined and re-pluralized in another language instead of being re-parsed out of English. The isError payload carries no key — code is already its discriminator.

Data payloads are never localized either — see what never gets translated for the full contract.

The four long-running tools — refresh_cache, sync_decks, sync_collection, and build_site — emit notifications/progress during the call, but only when the client asked for them by supplying a progressToken (which the SDK client does automatically when you pass onprogress to callTool). Without a token nothing is emitted.

Each notification carries progress, total, and a human-readable message; progress strictly increases across a run. The scales differ by tool: the cache refresh reports 0–100, and the two syncs report one notification per deck/list plus a terminal n/n. build_site reports three structural steps (start → building → publishing → done).

The result itself is unchanged and still blocking: the tool returns its ordinary structured result when the work finishes. Over Streamable HTTP the response upgrades to an SSE stream automatically as soon as a notification precedes the result, so no client configuration is needed for the frames to arrive.

One client-side setting does matter: the SDK’s default request timeout is 60 seconds and does not reset on progress. A client driving a long call should pass resetTimeoutOnProgress: true (or a larger timeout) alongside onprogress. Ritual cannot set it — it is a client option.

build_site also honours cancellation: aborting the call kills the child build, and because the build publishes atomically the live site is left untouched. The cancelled call answers with a tool error saying the site build was cancelled; dist/ still holds the previous site, byte for byte, and the next build_site is accepted immediately. The syncs and the cache refresh deliberately run to completion — an aborted sync would leave remote Archidekt records already mutated, and an aborted refresh holds the cache lock.

Every tool that addresses a list takes the same two fields: listType (deck | collection | wanted) and slug (the markdown file basename without .md).

ToolDescription
list_listsEvery list as { listType, slug, name }, optionally filtered by listType.
get_sync_statusWhat an Archidekt sync can cover. target: "decks" returns the linked decks (with each deck’s lastSynced — the same view the CLI’s deck-sync status prints); target: "collection" returns the coverable lists, the default pull target, the CSV threshold, and when the account last synced. Omit target for both halves. Both carry the login snapshot, whose loginRequired is what ritual login status reports.
get_listRead one list. The result is discriminated by view ("cards" | "summary") and listType: a deck’s cards view carries deck + frontMatter, a flat list’s carries entries + sectionOrder, and view: "summary" carries counts only. A cards view on a label-carrying list also reports the list-level labels default with each card’s own labels override, and — when any returned card has custom art — a customArt record of raw references keyed by &N id. Every arm also carries warnings — lines the file’s parser could not read, always present and empty for a clean file, so a list holding an unreadable line is never mistaken for a shorter list. section / nameContains / limit / offset narrow the result.
search_scryfallRun a live Scryfall query and return card summaries (name, printing, mana cost, type line, oracle text, prices). warm: true also caches the results locally and promotes a whole-name match.
autocomplete_cardMatch every whitespace-separated term against the local cache’s card names (in tre → “In the Trenches”).
find_cardsFind where a card physically lives across your lists — one result per copy, carrying the fields the move/remove tools address entries by. includeLists adds the full roster as { listType, slug, name }.
get_card_detailsEverything the local cache knows about one card: oracle text, type line, colors, keywords, legalities, Scryfall Tagger tags, faces, printing count.
get_card_printings, get_card_priceA card’s printings and per-currency prices (an unknown card name is an error).
get_price_reportPrice one list (listType + slug), one list type (listType alone), or every list (no arguments). Optional source picks the store (tcgplayer / cardmarket / cardkingdom — Card Kingdom NM retail from the cached feed). The result is discriminated by mode: "list" carries list + cards, "summary" carries lists + typeTotals + totals.
get_sell_reportMatch cards against the locally cached Card Kingdom buylist: what CK is buying, the cash quote per Near Mint copy, and their quantity caps. Scope with listType (default: collections) or lists; filter with sets / minPrice. Errors when the card cache is empty (refresh_cache), no feed has been downloaded (refresh_buylist), or sell mode is off.
get_sell_cartThe cards CK is buying, rendered as their sell-cart CSV import format (no header row; CK’s own listing titles, variant note included; quantities capped at their buy limits) over the same scope and filters as get_sell_report. warnings flags their 500-title/5,000-card upload caps and etched foils the format cannot express. Needs sell mode.
get_buylist_quotesThe buyer’s current offer for specific printings, keyed by set:collectorNumber:finish. Prices an arbitrary set of cards (a trade, a selection) without building a whole sell report; printings with no product are absent from the result. Cache-backed — run refresh_buylist first. Needs sell mode.
get_historyA list’s change history. A set followed by preserved hand-written text carries it in a trailing array — echo it back on rewrite_history or that text is deleted.
get_config, get_cache_statusConfiguration (including defaultLanguage, the card language, and uiLocale, the interface language — two different settings), and the state of the local Scryfall card cache. get_config reports the stored config as config and, when this server was started with a session flag such as --sell-mode, what it is actually running with as overrides.
diff_listsCompare two lists by card name or exact printing — the diff command as a tool.
export_cardsRender a CSV, JSON, plain-text, or Markdown export of lists and/or card picks, with filters (and, for csv/json, column selection, a value dialect, and saved or built-in presets). write: true writes a file instead.

Three tools find cards, and their names say where the data comes from:

  • search_scryfall always queries the live Scryfall API, using Scryfall’s own query syntax. One page per call — walk a large result set with page while hasMore is true; limit caps the cards returned (max 175, defaulting to 20 when warm: true and to the whole page otherwise). With warm: true it additionally writes results into the local card cache under names the cache does not already hold (never overwriting one), and moves a card whose whole name the query spells out ahead of Scryfall’s popularity order. It writes to the cache only, never to your lists, which is why it still carries readOnlyHint.
  • find_cards searches your own lists — the cross-list physical-card index — and never touches the network. Each result is one physical copy (a deck line with quantity 3 yields three), carrying listType, listSlug, name, printing, cardId, and copyIndex: exactly the fields move_selected_cards and remove_selected_cards address entries by. Filters intersect (name matches every whitespace-separated term in any order; listType/slug/set match exactly), and includeLists: true adds the full list roster (the move destinations) — off by default, since it does not depend on the filters. warnings names any list file that could not be fully read, so an empty result is never silently wrong.
  • autocomplete_card reads the local card cache, also with no network.

get_list defaults to the whole list. To read less:

  • view: "summary" returns { slug, listType, counts, warnings } — total lines, total copies, a per-section breakdown, and the parser warnings every arm carries. No card data at all, which makes it the cheapest call on a list you have not seen, and the right first one on a large collection.
  • section matches a markdown ## Section heading exactly (case-sensitively); a section that does not exist yields no entries rather than an error.
  • nameContains matches every whitespace-separated term, in any order, the way autocomplete_card matches.
  • limit and offset page through the matches in the default (cards) view. view: "summary" ignores them: its counts always describe the whole filtered set, which is what you page against.
  • totalCount is always present — the number of entries that matched before limit/offset applied, or the list’s whole line count when nothing was filtered — so you can tell a full page from the end of the list.

These are route parameters, not a client-side trim: a summary or filtered read returns before the server loads any Scryfall card data, printings, prices, or the mana-symbol map.

get_card_printings returns the newest 20 printings by default, as identity only — set, collector number, rarity, release date, finishes, and (when not English) the object’s lang. Pass limit for more or fewer (limit and totalPrintings count distinct printings — with a non-English cache every language object of an included printing rides along, so a client never sees a printing with half its languages missing), and includePrices: true when you actually want each printing’s price block; get_card_price is usually the better answer for a price question. The result’s languages array summarizes every language the card exists in (en first; ["en"] for an English-only cache).

Both get_card_printings and get_card_details report whether the printing list can be trusted as complete (complete / printingsComplete). It is false when the local card cache holds no printing list for the name — the one printing shown then came from a single Scryfall lookup, so a printingCount of 1 says nothing about the card. Run refresh_cache (or ritual cache preload-all) before concluding a card has only one printing.

get_cache_status reports whether the local card cache is empty, how many cards it holds, when it was last refreshed, its price age and whether prices are priceStale, whether Scryfall Tagger tags are present, where the cache is served from, and its language provenance: defaultLanguage (the configured code), cardBulkType (default_cards English-only or all_cards every-language — null before any recorded ingest), and bulkTypeStale (true when the cache’s bulk disagrees with what defaultLanguage demands, meaning a full refresh_cache is needed — see bulk selection). Check empty and priceStale before pricing: a stale or empty cache is exactly what get_price_report errors on, and refresh_cache is the fix.

diff_lists takes two sides (a and b, each { listType?, name } — names resolve like CLI list arguments, with listType pinning an ambiguous name) plus an optional by (name, the default, or printing) and returns { a, b, by, matches, onlyInA, onlyInB, warnings } with quantities summed across all sections. Each side comes back as { listType, slug, name } — the same vocabulary list_lists and find_cards’ roster use, so a diff side can be handed straight to any tool that names a list. See diff for the identity rules (nonfoil folding, the no-printing bucket).

get_price_report takes both listType and slug (one list’s summary plus its priced card entries), listType alone (per-list totals across every list of that type, like the CLI’s price --deck --summary), or neither (per-list totals across every list) — a slug without a listType is a validation error. The optional currency (usd | eur | tix) defaults to the configured defaultCurrency; the optional source picks the store instead — tcgplayer (Scryfall USD), cardmarket (Scryfall EUR), or cardkingdom (Card Kingdom NM retail from the cached buylist feed; errors with the refresh advice when no feed is downloaded). A source implies its currency, so passing a currency that disagrees is a validation error, and a cardkingdom result carries source: "cardkingdom" beside currency: "usd". The result is discriminated by mode: "list" carries list + cards, "summary" carries lists + typeTotals + totals. Prices come strictly from the local card cache; an empty cache is an error (check get_cache_status first, then run refresh_cache).

export_cards returns { mode: "content", format, entryCount, warnings, content } by default — the rendered export inline, with nothing written to disk. With write: true it instead writes a server-named file under exports/ in the base dir and returns { mode: "file", format, entryCount, warnings, path, bytes }; an existing file is never overwritten. Because of that write mode it carries no readOnlyHint (it is not flagged destructive either — the writer never replaces a file), even though it is registered with the read tools.

Stored config vs what this server runs with

Section titled “Stored config vs what this server runs with”

get_config answers two questions, because they can disagree. config is the stored configuration — ritual.config.json merged over the built-in defaults, the same payload config list --output json prints. overrides is what this running server is operating with in place of it, keyed by the config path each override displaces:

{
"config": { "site": {} },
"overrides": { "site.sellMode": true }
}

That is a server started with --sell-mode: the flag is a session setting that writes nothing, so config.site.sellMode stays as stored (usually unset) while the sell tools answer anyway. Without it, the key is absent entirely — no overrides means the two answers agree, and reading config alone is enough.

update_config never carries the field: it echoes back what it persisted, and an override is neither persisted nor changed by a write.

ToolDescription
create_listCreate a new, empty list. format (from the fixed set of deck formats) applies to decks only. Refused (409) when a list of that type already resolves under that name — folding ignores case, accents, hyphens/underscores, apostrophes, and filename-illegal punctuation.
import_deckImport a deck from a URL or pasted decklist text (Ritual’s own format and MTG Arena/MTGO exports). A URL import must state syncPrintings (true keeps the exact printings the source lists, false imports bare card names) — the CLI asks the user interactively, and this field is that decision, so ask the user when their intent is unclear. Text lines the parser cannot read are skipped and reported in the result’s warnings array (empty for URL imports) — a non-empty array means part of the pasted text was not imported. advisories reports lines that were imported but looked off (e.g. a card name still holding a printing token).
import_csvImport CSV text into a new or existing list (create/overwrite/append) with a column-mapping spec. In a deck, rows of the same card and printing merge into one line in every mode; collections and wanted lists keep one line per copy. hasHeader defaults to true and the result’s warnings name the row that skipped (and whether it looked like a header), so a headerless export does not silently lose its first card. format applies to decks only. Rows that fail validation do not fail the call: the result always carries cardCount, failures, and failedCount, so a partially-failed import still succeeds and reports which rows were dropped. A refusal (bad column spec, unknown list type, append to a missing list) is a tool error as usual.
import_change_bundleApply a change bundle exported from the site editor to the underlying lists. Answers with { message, lists, failedCount }; a list that could not be loaded or saved is reported in its own lists[].error rather than failing the call.
set_list_metadataWrite a list’s front matter — a deck’s description, tags, format, sourceId, sourceUrl, or a deck’s or collection’s default labels (a deck’s being proxy alone). Only the fields you send are touched; null clears one. Answers with { slug, frontMatter } — the route’s contentHash is dropped, since an agent never supplies one.
add_cardAdd a card to any list; quantity adds that many copies in one save. condition is rejected for wanted lists; collections require set + collectorNumber together, and labels (a label override for the new card) is accepted for whatever the list type carries. language records a non-English copy — omitted, the configured defaultLanguage applies. The list must already exist — unlike ritual add-card, which creates a missing collection or wanted list, list creation here is its own tool (create_list).
set_card_artSet or clear one card’s custom art — the image the site and editors show instead of the printing’s scan. Addressed by cardId (get_list reports it); art is { file } under the configured artDir, { url }, or null to clear. A file must already exist there — Ritual references images, it never uploads them. The write goes straight to <list>.art.json: no card-line change, no changelog entry, and no interaction with a pending batch.
remove_cardRemove a card from any list; quantity (decks only) removes that many copies. Flat lists remove one entry a time. finish, condition, and language narrow the match to entries with that value (language: "en" matches bare lines).
set_card_printingSet a card’s printing in place. It can omit set/collectorNumber to clear a deck or wanted-list card’s printing, but not a collection’s — that’s rejected. condition takes a grade or NONE to clear a recorded grade (also accepted on apply_changesset-printing action); NM is the unrecorded default, so setting it leaves the line ungraded. language sets the card’s language alongside the printing; omitted, it is left alone.
apply_changesApply an ordered batch of card-level changes to one list atomically (one save, one changelog block). The only route to language, note, label, section, and commander edits.
move_selected_cardsMove a batch of identity-addressed cards between lists atomically.
remove_selected_cardsRemove a batch of identity-addressed cards across lists atomically.

Card edits load the list, apply the change, and save in a single call, so you never supply a content hash — conflict detection is handled internally. A concurrent web-UI edit is retried once automatically (the retry re-reads the list, so your changes land on the content that won); a second conflict in a row surfaces as a structured conflict error result (see Tool errors), since two losses mean a live concurrent editor and retrying further would overwrite work you never saw.

The four single-list edit tools (add_card, remove_card, set_card_printing, apply_changes) answer with { applied, message, listType, slug, effects, unmatched }. effects removes the post-write get_list round trip: it lists every entry the save created, dropped, or changed as { action, cardId, name, section?, quantity, printing?, previousCardId? }, with the &N cardId the save allocated — which the caller could not know beforehand, since ids are assigned at write time. previousCardId appears only on an updated effect whose line was renumbered, because another entry arrived claiming the same &N (a cross-list move carrying its source id, a replayed change bundle): without it, a card you have had all along would read as newly added. unmatched is always empty on a returning call (a miss fails the whole batch and surfaces as a structured error carrying the same list). The cross-list tools move_selected_cards and remove_selected_cards keep their own { moved | removed, requested, skipped, warnings } vocabulary and do not carry effects.

Card targeting is exact and case-sensitive on cardName, with cardId (the &N id shown by get_list) taking priority. That id priority is deliberate and differs from the CLI, where a --card-id paired with a card name that names a different entry is a usage error: the CLI guards a human (or script) holding an &N that was recycled after a removal, while an MCP client is expected to read cardId and name from the same get_list snapshot. If you cannot be sure your snapshot is current, send cardName alone. For the single-list edit tools (add_card, remove_card, set_card_printing, apply_changes), a change whose target does not exist fails the whole call: nothing is saved, no changelog entry is written, and the error names each change that did not apply. In an apply_changes batch this is atomic — one miss rejects the batch — while a later change may still target a card an earlier change in the same batch added. The cross-list batch tools (move_selected_cards, remove_selected_cards) and import_change_bundle use the same exact targeting but skip and report unresolvable items instead of failing, as documented below.

A write to a list whose file holds a line the parser cannot read — a malformed card line, but also prose, comments, or any other text the list grammar does not model — is refused outright: the save would re-serialize the list without that line and recycle its &N. The error names the file and each unreadable line, and nothing is written — the same lines get_list reports in warnings. Fix the file, then retry (the line-preserving CLI one-shots set-card/remove-card/note can edit such a file without touching those lines).

Every write that carries a free-text card name — add_card, remove_card, set_card_printing, apply_changes, move_selected_cards, remove_selected_cards — has its names checked against the local Scryfall card cache before anything is written:

  • A name already present in the list being edited is accepted with no lookup at all. That is what keeps a custom, proxied, or unreleased card that already lives in a file removable and editable.
  • Any other name must be one the cache knows. An unknown one is rejected with up to three of the closest cached spellings ('Lightning Bolz' is not a card name the local cache knows. Did you mean: Lightning Bolt, ...?), which is far more actionable than the “nothing matched” the edit would otherwise have produced.
  • If the cache is empty, the write is refused with a message naming both remedies (the refresh_cache tool here, ritual cache preload-all on the CLI). Check get_cache_status first if you are unsure.

import_deck, import_csv, and import_change_bundle are excluded: they carry bulk content whose per-row failures their own engines already report, and rejecting a whole import over one bad row would be worse than reporting it.

Collections track a specific physical printing per entry: add_card, apply_changes’s add and set-printing actions, and set_card_printing all require set + collectorNumber together when the target list is a collection — omitting either one is rejected rather than written as a printing-less (or cleared) entry. Decks and wanted lists accept a name-only card.

move_selected_cards and remove_selected_cards address each card by identity: source listType + slug + cardName, plus cardId (the persistent &N id — required to match whenever the entry has one; get_list shows it) and copyIndex (0-based, for deck lines with quantity above 1). Each move_selected_cards item names its destination with toListType + toSlug, may override the printing on arrival (set, collectorNumber, finish, condition, language), and may pick a destination deck section with toSection (deck destinations only). Unresolvable items are skipped and counted in the response; notes a destination cannot keep are reported as droppedNotes.

Entries carry a language field only when the copy is not English: an absent value always means en, mirroring the card lines themselves, where the [ja]-style token is omitted on English lines (see Card Language). The vocabulary everywhere is the 17 Scryfall codes (en es fr de it pt ja ko ru zhs zht he la grc ar sa ph) — canonical codes only, no aliases. add_card takes an optional language (omitted, the configured defaultLanguage stamps the new card — adds never prompt); set_card_printing takes one to set it alongside the printing (omitted leaves it alone); and apply_changesset-language action changes it on its own, where en clears the line’s token. remove_card (and apply_changesremove action) takes an optional language to match only entries in that language — "en" matches bare lines. Non-English copies are never quoted by the sell tools — Card Kingdom’s feed is English-only, so they report noMatchReason: "non-english", and get_buylist_quotes printings take an optional language for the same reason (a non-English copy gets no quote).

The label vocabulary is shared, but which of it a list carries is not: a collection takes all of it (sale/trade combine; keep and proxy each stand alone), a deck takes proxy alone, and a wanted list carries none. Every tool that pairs a listType with labelsadd_card, set_list_metadata, and apply_changesadd / set-label actions — enforces this in its input schema, so a sale on a deck is refused before anything is loaded, with a message naming the offending labels and the ones that type supports. An empty array is a clear, so it passes on a deck and a collection and is refused on a wanted list, which has no override to clear — the same decision the CLI flags, the admin save routes, and change-bundle imports make, from the same shared rule.

A card labeled proxy is not a real copy, and every reporting tool says so consistently: get_price_report prices it at 0 with unpricedReason: "proxy" and leaves it out of unpricedCount (it still counts as a card), export_cardsfilters.labels selects it on a deck as readily as on a collection, and get_sell_report, get_sell_cart, and get_buylist_quotes never see it at all. Its absence there is the rule, not a failed lookup. A card given custom art is treated identically — one rule, custom art or proxy ⇒ no price, no quotes, no sale — reported as unpricedReason: "custom-art", which wins when a card is both.

get_list’s cards view reports a list’s custom art as customArt — a record of raw references ({ "file": … } / { "url": … }) keyed by the card’s &N id, present only when some card in the body has any. set_card_art takes those same references back. Art is list metadata: it lives in <list>.art.json, records no changelog entry, and neither needs nor disturbs a pending batch of card changes. Ritual only ever references images — a file must already exist under the configured artDir and carry an image extension (.avif, .gif, .jpeg, .jpg, .png, .webp — the ones the art route serves), and a url is used verbatim, never downloaded and never extension-checked.

Custom art carries the same pricing rule as the proxy label: a card with art of its own is not the printing a price is quoted for, so get_price_report prices it at 0 with unpricedReason: "custom-art" (not counted in unpricedCount), and get_sell_report, get_sell_cart, and get_buylist_quotes never see it.

Art at add time is a two-step. No tool takes an art reference as part of an add — add_card and an add in apply_changes write card lines, and a line’s &N is allocated inside that write. So add the card first, read the id off the mutation’s effects (following previousCardId where a line was renumbered), then aim set_card_art at it:

  1. add_card / apply_changeseffects: [{ action: "added", cardId: 7, name: "Sol Ring", … }]
  2. set_card_art with cardId: 7 and the reference.

This is deliberate rather than a gap: art is list metadata written to a different file, and folding it into the add would make one call write two files with two failure modes. The admin editors’ add-card dialog does exactly this internally, holding the reference until the save answers. The CLI is the same shape — add-card then set-card --art.

Art follows the card, so no tool needs a follow-up set_card_art to keep it in place: a mutation that removes a card (remove_card, a remove in apply_changes, remove_selected_cards) drops its art, a cross-list move (move_selected_cards, a move-from in apply_changes) re-files the entry under the destination line’s new &N, and a sync that pulls removals in (sync_decks, sync_collection) drops the entries of the cards it removed. Two things keep their art instead: a deck line that still has copies left after a removal, and a moved copy that merges onto a line the destination already had. See Art follows the card.

A cards view also carries artWarnings when something is wrong with that sidecar — it cannot be read, or it holds art for cards the list no longer has. It is deliberately not part of warnings: that channel means unreadable card lines, and a mutation refuses a list that has any. Bad custom art blocks nothing.

apply_changes accepts ten card-level change actions: add, remove, set-finish, set-printing, set-language, set-note, set-label, set-commander, unset-commander, and set-section. It is the only way to reach the last six — changing a card’s language on its own (set-language requires language; en clears the token), setting or clearing a card note, setting or clearing a card’s label override (set-label takes the new labels; an empty array clears the override so the list default applies), moving a card to a section, and setting or clearing a deck commander have no tool of their own. The commander actions apply to decks only, and set-label only where the list type carries the labels you send — a schema refusal for a label the type cannot express, and a not-applicable failure at apply time elsewhere. An add may also carry labels to label a fresh card, add and set-printing may carry language, and remove may match on one. Change id and timestamp are stamped by the server and are not part of the input. Cross-list moves and section-structural events are rejected — use move_selected_cards for the former.

apply_changes carries destructiveHint: true because a batch can remove cards in bulk. The note, label, section, and commander actions are themselves additive; the hint reflects the tool’s worst-case capability, not what any particular batch does.

set_list_metadata writes deck front matter (description, tags, format, source link) and — on a deck or a collection — labels, the default card labels every entry without its own override inherits (null clears them). Wanted lists carry no front matter, so use rename_list to change their display name. Setting sourceId together with an archidekt.com sourceUrl is what makes a deck sync-linked, and therefore what sync_decks then operates on — the two must name the same Archidekt deck once merged over what the file already carries, or the call is rejected (a sync addresses the deck by sourceId while every surface shows sourceUrl). No changelog entry is recorded: the changelog is card-level, and metadata is not a card change. This is the same write the CLI’s deck-sync link performs — both go through one front-matter writer, so the deck’s card lines and prose survive byte for byte either way.

These are flagged with the MCP destructiveHint so clients can gate or confirm them:

ToolDescription
rename_listRename a list (changes its slug); the result carries newFilePath and oldFilePath. Refused when the new name resolves to another list of the same type; re-spelling the list’s own name (capitalization, punctuation) is allowed.
delete_listDelete a list and every sidecar it has. Requires a confirmName matching the list’s display name; the result carries deletedFiles.
rewrite_historyReplace a list’s entire change log. Echo back sets you did not author exactly as get_history returned them — including each set’s trailing array of preserved hand-written lines, or that text is deleted. Trailing lines must not start with - or ## .
update_configMerge a partial configuration. defaultLanguage takes canonical Scryfall codes only, and a non-en value switches cache downloads to the much larger all_cards bulk — see Default language. uiLocale is the unrelated interface language (a BCP-47 tag); setting it never changes this surface’s own English prose. priceSources takes store names (tcgplayer, cardmarket, cardkingdom; [] hides all site prices) — enabling cardkingdom makes builds and servers download the ~70 MB Card Kingdom feed like sell mode does.
build_siteRebuild the public static site. Runs asynchronously in a child process and publishes atomically, so an interrupted build never leaves a broken site. Reports progress and honours cancellation. Returns { message, outDir, durationMs } — where it published and how long the build took.
sync_decksSync decks with Archidekt in either direction.
sync_collectionSync collection lists with Archidekt in either direction.
refresh_cacheRefresh the Scryfall card cache (bulk download + oracle/art tags). A failed download or ingest is now reported as a tool error rather than a silent success. Does not touch the buylist — that is refresh_buylist.
refresh_buylistDownload the Card Kingdom pricelist feed (~70 MB) when the cached copy is stale (older than a day) or missing; force: true redownloads regardless. The sell tools read strictly from this cache. A failed download with a stale cache degrades: refreshed: false plus the failure in warnings. Needs sell mode.

sync_decks takes a direction (pull | push), an optional decks array (slugs or names; omit to sync every Archidekt-linked deck), an optional only (additions | removals, applying just one side of each deck’s diff relative to the sync destination), and optional dryRun / ignoreUnreadableLines / force / syncPrintings flags. Under syncPrintings, a card held at several printings at once is reconciled printing by printing (copies added, removed, or re-pinned), a local line naming no printing pushes nothing, and a stated finish the printing does not offer on Archidekt fails that deck; each deck’s report entry carries printingsChanged. Without the flag, printings are left alone and a deck whose two sides disagree about them carries printingsUnaligned. It needs an Archidekt login stored by ritual login archidekt or the admin site — check get_sync_status’s decks.archidekt.loginRequired first. A run that completes reports success even when individual decks failed; read report.failedCount and each deck’s status/reason.

A push whose remote deck changed since that deck’s recorded sourceUpdatedAt fails with Remote deck changed since last sync (…) — pull first, or pass --force to overwrite remote changes. rather than reverting those remote edits; force: true overwrites them deliberately, and a dryRun reports the same refusal without needing it. Pulling that deck first also clears it — a pull records the baseline even when it finds no card changes. See Divergence Guard. Only decks that pushed cleanly get fresh stamps, so get_sync_status never reports a sync that failed.

A deck whose file holds lines the parser cannot read fails with N unreadable lines would be dropped by a sync, because syncing rewrites the file and would delete them. ignoreUnreadableLines: true accepts that loss — confirm with the user before setting it, since it is the tool’s stand-in for the CLI’s --yes prompt.

sync_collection is the collection counterpart, and the shape of the problem differs: an Archidekt account has one collection while Ritual has many collection lists, so a run compares the union of the lists in scope against the whole remote collection (there is no per-file link — the connection is the signed-in account). It takes a direction (pull | push), an optional lists array (slugs or names scoping the local side; omit to compare every collection list), the same only filter, an optional into (the list a pull adds new cards to, created if missing — defaults to the collectionSync.pullTarget config key), an optional removalPriority array, an optional csv flag, and the same dryRun / ignoreUnreadableLines flags. Naming a subset of lists declares that those lists are what the remote collection mirrors, so cards living only in unnamed lists read as absent — pair a subset run with only: "additions" when they are not the whole story.

csv is the tool’s form of the CLI’s --csv, and means the same thing: send a push’s new cards to Archidekt as one CSV import, with the rows built from the local Scryfall cache, instead of resolving and creating them one at a time. Creating a printing costs a search plus a create, both paced, so a push adding more than 25 new printings without csv: true fails before writing anything remote rather than spending that many requests — there is nobody to prompt over MCP. Set it for any large push; a dryRun never needs it (it reports the upload it would make). report.csv then says what the import did — its status, rows, chunks, the rows Archidekt refused (failures), and uncached additions whose printing the cache does not hold and which were added one at a time. Because those rows are keyed by the Scryfall ids the local cache holds, an empty or day-old cache is refreshed automatically before the upload is built — the CLI’s --refresh auto, since there is nobody to ask here either. Quantity changes and removals never ride the CSV, and a pull ignores the field. Writing the CSV to a file instead of pushing it is CLI-only: the tool has no csvFile field (an unknown field is stripped before dispatch, never honored), since a server does not write files a caller names.

removalPriority is the tool’s form of the CLI’s --removal-priority: collection list names in priority order, the only lists an ambiguous removal may take copies from. A removal is ambiguous when only some of a printing’s copies are going and they live in several lists — taking every copy, or copies held in a single list, never is. There is nobody to prompt over MCP, so a run that meets an ambiguous removal without a priority — or with one that cannot cover it — fails and writes nothing at all, naming the cards in report.errors. Ask the user which binders may lose cards rather than guessing at a priority.

It needs the same Archidekt login (get_sync_status’s collection.archidekt.loginRequired reports it; a login stored before the account id was recorded must be renewed), and it refuses a collection list with unreadable lines for the same reason — a pull rewrites the file, and a push treats the file as the truth, so those cards would be deleted from Archidekt. Its report adds ambiguous (every ambiguous removal, with the lists holding copies and how many each holds — reported whether a removalPriority placed them or the run failed on them), totals.skipped (what only left out), and localIncomplete — true when a list in scope did not make it into the comparison (an unresolvable name, an unreadable file, or one refused for unreadable lines). The local side is then short of cards it really holds, so the run withholds the changes that shortfall would have manufactured: a pull adds nothing (it would duplicate the missing list’s cards into the target) and a push removes nothing (it would delete them from Archidekt). report.failedCount and each list’s status/reason carry per-list failures.

import_deck, import_csv, import_change_bundle, and apply_changes also carry destructiveHint, even though they are registered with the write tools: the imports can overwrite an existing list of the same name, and an imported or applied change batch can remove cards. Their default, non-overwrite modes are otherwise safe.

The authentication endpoints (setup, login, TOTP, Archidekt) and the login audit log (GET /api/audit-log) are intentionally not exposed: they describe who used the admin server, which is not an agent’s concern.

Every list is also a readable resource at ritual://{type}/{slug} (e.g. ritual://deck/my-deck), listed via the MCP resources API. A read returns the same projected JSON as the get_list tool — the list’s contents without the heavy editor payload (card data, printings, prices), carrying the same view and listType discriminants — including warnings, the lines the parser could not read. The URI template offers completions for both {type} and {slug}, and a {type} already chosen narrows the slugs offered.

resources.listChanged is advertised on stdio only. Stdio pins one server instance for the life of a connection, so a notifications/resources/list_changed sent after create_list, import_deck, import_csv, rename_list, or delete_list has a client to reach. The HTTP transport is stateless — it builds one server per request and tears it down with the response — so a notification there would have nowhere to go, and claiming the capability would be a promise Ritual cannot keep. HTTP clients should re-list resources after a list-lifecycle call.

Like ritual admin, server startup runs the standard card-ID backfill, persisting any missing &N card IDs into the list files before the first request is served.

Unlike ritual admin, the MCP server does not prompt to refresh the Scryfall cache on startup (stdin is reserved for the protocol). It uses whatever cache exists; on a cache miss, card lookups fall back to live Scryfall requests. Call get_cache_status to see what state it is in, and the refresh_cache tool to warm it explicitly. A cold cache is also what makes card-name validation on writes and get_price_report fail, so it is worth checking first.

The same applies to the Card Kingdom buylist: admin and serve --api redownload a day-old feed when they start, and the MCP server deliberately does not — get_sell_report, get_sell_cart, and get_buylist_quotes read whatever feed is cached, and refresh_buylist is the only thing in an MCP session that downloads one (its result reports the feed’s age and product count). Note that refresh_cache does not include the buylist, even though the CLI’s cache preload-all does.

The four buylist tools — get_sell_report, get_sell_cart, get_buylist_quotes, and refresh_buylist — reuse the admin’s sell routes, which are gated on sell mode or the cardkingdom entry of priceSources (whose retail prices ride on the same feed). Both are off by default, and with neither on all four fail with a Not found tool error. That is a configuration decision rather than a missing feed, so refresh_buylist will not fix it. Enable it with ritual config set site.sellMode true, by ticking Offer sell mode on the admin’s Settings page (it writes the same key), or by starting the server with --sell-mode:

Terminal window
ritual mcp --sell-mode

ritual admin --mcp --sell-mode does the same for the embedded endpoint. The rest of the tool surface is unaffected — only these four are gated.

Most MCP clients accept a server entry like the following (stdio):

{
"mcpServers": {
"ritual": {
"command": "ritual",
"args": ["mcp", "--base-dir", "/path/to/your/ritual/workspace"]
}
}
}

For Claude Code, register it with:

Terminal window
claude mcp add ritual -- ritual mcp --base-dir /path/to/your/ritual/workspace
  • skills — teach a coding agent to drive Ritual via the CLI instead of MCP tool calls.
  • admin — the browser-based equivalent, and the HTTP API the MCP tools mirror.
  • Admin API Endpoints — the underlying request/response shapes.
  • Localization — what follows the UI locale, and what (this surface included) never does.