mcp
Start an MCP (Model Context Protocol) server that lets AI agents (Claude Desktop, Claude Code, and other MCP clients) manage your decks, collections, and wanted lists.
The server runs the same operations as the admin site, in-process, through the same route handlers. Editing through MCP behaves exactly like editing in the browser: same changelog, same content-hash conflict detection, same optional git auto-commit. It does not open the admin HTTP server or need an admin login. It is a local, trusted process you launch yourself.
ritual mcp [options]Options
Section titled “Options”| Option | Description | Default |
|---|---|---|
--transport <type> |
Transport to use: stdio or http |
stdio |
-p, --port <number> |
Port for the HTTP transport | 8765 |
--host <address> |
Host to bind for the HTTP transport | 127.0.0.1 |
--token <secret> |
Require this bearer token on the HTTP transport | |
--allow-unauthenticated |
Serve the HTTP transport without a bearer token on a non-loopback host | |
--sell-mode |
Answer the sell/buylist tools for this run even when site.sellMode is off (both transports) |
Notes:
--tokencan also come from theRITUAL_MCP_TOKENenvironment variable (the flag wins). That keeps the secret out of the process list.- The global
--base-dir <path>option, or theRITUAL_BASE_DIRenvironment variable (handy in a client’senvblock), selects the Ritual workspace. The directory must exist, or the server exits2before starting. - A malformed
ritual.config.jsonaborts the server with exit1before the transport opens. - The global
--cache-server <host:port>option also applies.
Transports
Section titled “Transports”stdio (default)
Section titled “stdio (default)”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, because 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. One server instance is kept for the life of the connection. The per-request stateless model below applies to HTTP only.
HTTP (Streamable HTTP)
Section titled “HTTP (Streamable HTTP)”ritual mcp --transport http --port 8765 --token "$MCP_TOKEN"Serves the MCP Streamable HTTP transport at http://<host>:<port>/mcp for remote clients. It binds to 127.0.0.1 by default. --port must be 1–65535; an invalid value exits with code 2. If you expose it beyond localhost, set a token (--token or RITUAL_MCP_TOKEN). Every request must then send Authorization: Bearer <token>. There is no other authentication layer.
Ritual implements MCP revision 2026-07-28, whose Streamable HTTP transport is stateless: 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. 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 answer 405. Ritual uses neither feature.
Without a token, the command refuses to bind a non-loopback --host (exit code 2) unless you pass --allow-unauthenticated. An unauthenticated endpoint exposed beyond the local machine lets 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) do nothing under stdio. Passing them there prints a warning on stderr.
Ctrl+C (SIGINT) or SIGTERM stops the listener and drops active connections, so the port is released and the process exits on its own. A failed teardown is reported on stderr. ritual admin --mcp does the same for both of its listeners.
Errors
Section titled “Errors”| Response | When |
|---|---|
401 |
Missing or wrong bearer token. The JSON-RPC body carries implementation-defined code -32010. |
403 |
On a loopback bind only: a request whose Host or Origin header is not local (DNS-rebinding protection, checked before auth). An exposed host skips this and relies on the token. |
404 |
A path other than /mcp. The body carries implementation-defined code -32011. |
405 |
GET/DELETE on /mcp — the 2025-era session operations, which stateless serving does not have. |
415 |
A POST whose Content-Type is not application/json. |
413 |
A request body over about 32 MiB, refused before it reaches the protocol layer (no JSON-RPC body). Tool arguments are also held to each route’s own size limit: an apply_changes batch carries at most 50,000 changes, and an oversized argument is a tool error. |
On 2026-07-28 responses, the catalog surfaces (tools/list, resources/templates/list, server/discover) advertise a one-hour private cache hint. resources/list and 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.
Embedding in a running admin server
Section titled “Embedding in a running admin server”ritual admin --mcp serves the same MCP endpoint inside a running web admin instead of a standalone process. One process exposes both the web admin and an MCP endpoint (on --mcp-port, default 8765), sharing 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. The token is independent of the browser admin login.
Results and errors
Section titled “Results and errors”The tool-result contract is transport-independent. It holds over stdio, over Streamable HTTP, and on either protocol era.
Structured results
Section titled “Structured results”Every tool declares an outputSchema and answers with structuredContent. Read structuredContent, not content[0].text. A successful result carries an empty content array, so the same JSON is never sent twice. Only a failure carries a text block, holding the message described below. The one other shape a call can answer with is an input_required result: sync_collection returns one when it needs an ambiguous-removal decision and the client can be asked.
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 what it means. This page describes the tools; the schemas describe their replies. The one exception is the failure payload: isError results are exempt from output-schema validation, so no schema carries it and it is documented below instead.
Tool errors
Section titled “Tool errors”A failed tool call is not a JSON-RPC error. It comes back as a normal result with isError: true, 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."}| Field | Meaning |
|---|---|
code |
conflict | cancelled | invalid-request | internal. cancelled is the caller’s own cancellation reaching back up. |
conflict |
Present (and true) only on code: "conflict" — a lost optimistic-concurrency race. |
recovery |
The next concrete action, when there is one. |
unmatched |
Changes 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. Every error thrown inside a tool call is converted into the isError result above; the numeric code only drives the one automatic retry. The -32010 / -32011 codes in the HTTP Errors table are different: the HTTP wrapper emits them before the request reaches the protocol layer, so they are wire-visible and never appear inside a tool result.
One error is re-raised rather than structured: a URL-elicitation request is a protocol handshake the client must answer, not a tool failure.
Source offer
Section titled “Source offer”The server instructions end with one line naming the running Ritual version and its license (AGPL-3.0-only) and linking to the source of exactly that version, as the license asks of every network surface. See License and Attribution.
Language: English by contract
Section titled “Language: English by contract”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 stay English. They are model-facing prose mixed with flags, file paths, and snake_case tool names; translating them would break the identifiers a client matches on.
A client that renders for a human gets the sentence unrendered instead of translated. Results produced by the shared admin handlers carry two optional fields beside message:
| Field | Meaning |
|---|---|
messageKey |
The 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. |
messageParams |
The values that key interpolates. Absent when the message takes none. |
Match on messageKey (or, for a failure, on code) rather than on the prose; 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. Params may nest: a param may itself be a message (messageKey, messageParams), a list (list, join), a duration (elapsedMs, or durationMs for an age or a time-out) or a card language (language). They do in the import_csv, import_deck and import_change_bundle results (a count with its plural noun, and each entry of import_csv’s and import_deck‘s warnings/advisories, which are message triples rather than bare strings; each import_csv failure carries reasonKey/reasonParams beside its English reason), in the sync tools’ per-item reasonParams and in their run-level report.errorMessages[].messageParams (a collection push refused by its card-cache check nests the cache’s reason). Each failed list of import_change_bundle carries errorKey and errorParams beside error. The isError payload carries no key, since code is already its discriminator.
Data payloads are never localized either. See what never gets translated.
Progress notifications
Section titled “Progress notifications”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 supplied a progressToken (the SDK client does this 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. Scales differ by tool: the cache refresh reports 0–100, the two syncs report one notification per deck/list plus a terminal n/n, and build_site reports total: 3 (0 start, 1 building, 2 publishing, 3 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 once a notification precedes the result.
One client-side setting matters. 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 this; it is a client option.
All four also honour cancellation (a client’s notifications/cancelled, which the SDK client sends when the signal passed to callTool aborts), each at the point its partial state is recoverable:
build_sitekills the child build. The build publishes atomically, so the live site is untouched:dist/still holds the previous site, and the nextbuild_siteis accepted immediately. The cancelled call answers with a tool error saying the site build was cancelled.refresh_cachestops the download and writes nothing. The previous card cache stands, the cache lock is released, and the call answers with a tool error saying the refresh was cancelled.sync_decksandsync_collectionstop between items. The deck or list in flight finishes (nothing is half-pushed or half-written), and every item the run never reached is reportedskippedwith theoutcomecancelled(its Englishreasonreadscancelled before it started). The result is the ordinary report withreport.cancelled: true(and, for the collection, nolastSyncedrecorded), because the items already synced are real. A client that cancels should still read it. A cancelled push has already sent whatever it pushed before stopping.
Every tool that addresses a list takes the same two fields: listType (deck | collection | wanted) and slug (the markdown file basename without .md).
Read (read-only)
Section titled “Read (read-only)”| Tool | Description |
|---|---|
list_lists |
Every list as { listType, slug, name }, optionally filtered by listType. |
get_sync_status |
What an Archidekt sync can cover: target: "decks", target: "collection", or omit target for both. |
get_list |
Read one list, whole or narrowed by section / nameContains / limit / offset. See the result shape. |
search_scryfall |
Run a live Scryfall query and return card summaries (name, printing, mana cost, type line, oracle text, prices). warm: true also caches the results locally. |
autocomplete_card |
Match every whitespace-separated term against the local cache’s card names (in tre → “In the Trenches”). |
find_cards |
Find where a card physically lives across your lists, one result per copy. includeLists adds the full roster. |
get_card_details |
Everything 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_price |
A card’s printings and per-currency prices (an unknown card name is an error). |
get_price_report |
Price one list, one list type, or every list; optional source picks the store. |
get_sell_report |
Match cards against the locally cached Card Kingdom buylist: what CK is buying, the cash quote per Near Mint copy, and their quantity caps. |
get_sell_cart |
The cards CK is buying, rendered as their sell-cart CSV import format, over the same scope and filters as get_sell_report. |
get_buylist_quotes |
The buyer’s current offer for specific printings, keyed by set:collectorNumber:finish. |
get_history |
A list’s change history: each set’s prose lines, typed events, preserved trailing text, and the defaultEvents a rewrite-with-defaults would write. |
get_config, get_cache_status |
Configuration (as config, plus session overrides) and the state of the local Scryfall card cache. |
diff_lists |
Compare two lists by card name or exact printing — the diff command as a tool. |
export_cards |
Render a CSV, JSON, plain-text, or Markdown export of lists and/or card picks; write: true writes a file instead. |
Details the table leaves out:
get_sync_status:target: "decks"returns the linked decks with each deck’slastSynced(whatdeck-sync statusprints).target: "collection"returns the coverable lists, the default pull target, the CSV threshold, and when the account last synced. Both carry the login snapshot, whoseloginRequiredis whatritual login statusreports.get_sell_report: scope withlistType(default: collections) orlists, and narrow to cards carrying one oftags(applied before matching, so CK’s buy caps go to those cards: tag a hand-picked batch withapply_changesadd-tag, thenget_sell_cartwith the tag builds its cart); filter withsets/minPrice/minRatio(the offer as a fraction of the TCGplayer market price) /minOwned(total copies owned). Each entry carries thecardIds(&N) of its lines for follow-up edits, theirtags, the card’sedhrecRank,ownedCopies(collection/deckcounts by name across every collection and deck), andtcgplayerPrice/offerRatiobeside CK’s quote. Errors when the card cache is empty (refresh_cache), no feed is downloaded (refresh_buylist), or sell mode is off.get_sell_cart: no header row; CK’s own listing titles with variant note; quantities capped at their buy limits.warningsflags their 500-title/5,000-card upload caps and etched foils the format cannot express. Needs sell mode.get_buylist_quotes: prices an arbitrary set of cards without building a whole sell report; printings with no product are absent from the result. Cache-backed, so runrefresh_buylistfirst. Needs sell mode.get_history:eventscome from the entry’sritual-changesblock (empty for a legacy entry). Hand-written text preserved after a set is in itstrailingarray; echo it back onrewrite_historyor it is deleted. When the categories sidecar could not be read,defaultEventsnames no categories andcategoryWarningssays why (absent otherwise).get_config: the config includesdefaultLanguage(the card language) anduiLocale(the interface language), two different settings.export_cards: takes filters (name, set, finish, conditions, labels, tags), saved or built-inpresets, and column selection forcsv/json. The columns includescryfallIdandedhrecRank, both read from the local Scryfall cache only (never a Scryfall fetch): an uncached card exports an empty cell plus a warning, and an unranked card an emptyedhrecRankcell without one. They also includecategories(comma-joined, primary first) andprimaryCategory; both are dropped by themdformat and by everytextdialect exceptmoxfield, which writes a card’s categories as#tagtokens. Adialect(ritual,archidekt,arena,moxfield) spellscsv/jsonvalues and picks thetextdecklist form;arena/moxfieldomit maybeboard and token cards and name them inwarnings, andmoxfieldalso names the categories it could not write as tags.
The get_list result
Section titled “The get_list result”The result is discriminated by view ("cards" | "summary") and listType:
- A deck’s cards view carries
deck+frontMatter. A flat list’s carriesentries+sectionOrderplus the list’s front-matterdescription(a deck’s is insidedeck).view: "summary"carriescountsonly. - Every arm carries
warnings: lines the file’s parser could not read. It is always present and empty for a clean file, so a list holding an unreadable line is never mistaken for a shorter list. - A cards view on a label-carrying list reports the list-level
labelsdefault, each card’s own signedlabelsoverride, which adjusts it (see Card labels), and each card’seffectiveLabels: the labels in force, resolved by the server, so an agent never applies the rule itself. - Each card carries its own
tags(canonical, without the#) when it has any; absent when none. - The list’s cover image override is reported as
image, absent when the built-in cover rule applies. - When any returned card has custom art, a
customArtrecord of raw references keyed by&Nid is present. - The list’s categories are reported as
categorieswhen the list has any, and each returned card carries its own resolvedcategories.
Network vs local
Section titled “Network vs local”Three tools find cards, and their names say where the data comes from:
search_scryfallalways queries the live Scryfall API with Scryfall’s own query syntax. One page per call: walk a large result set withpagewhilehasMoreis true.limitcaps the cards returned (max 175; default 20 withwarm: true, otherwise the whole page). Withwarm: trueit also 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, so it still carriesreadOnlyHint.find_cardssearches your own lists and never touches the network. Each result is one physical copy (a deck line with quantity 3 yields three), carryinglistType,listSlug,name, printing,cardId, andcopyIndex— exactly the fieldsmove_selected_cardsandremove_selected_cardsaddress entries by. Filters intersect:namematches every whitespace-separated term in any order;listType/slug/setmatch exactly.includeLists: trueadds the full list roster (the move destinations). It is off by default, and the roster is unaffected by the filters.warningsnames any list file that could not be fully read, so an empty result is never silently wrong.autocomplete_cardreads the local card cache, also with no network.
The single-card lookups read the local cache first but can reach Scryfall: get_card_details and get_card_printings fall back to one Scryfall fetch when the cache holds no printings for the name, and get_card_price fetches (and updates the cache) when the name is uncached or its cached copy is more than a day old.
Reading part of a list
Section titled “Reading part of a list”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. No card data, which makes it the cheapest call and the right first one on a large collection.sectionmatches a markdown## Sectionheading exactly (case-sensitive). A section that does not exist yields no entries rather than an error.nameContainsmatches every whitespace-separated term, in any order, likeautocomplete_card.limitandoffsetpage through the matches in thecardsview.view: "summary"ignores them; its counts always describe the whole filtered set.totalCountis always present: the number of entries that matched beforelimit/offset, or the list’s whole line count when nothing was filtered. It tells 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.
Cards and prices
Section titled “Cards and prices”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 is returned with it, so a client never sees a printing with half its languages missing. Pass includePrices: true when you want each printing’s price block; get_card_price is usually the better answer for a price question. The result’s languages array lists 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 is complete (complete / printingsComplete). It is false when the local 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, so a full refresh_cache is needed — see bulk selection). Check empty before pricing (an empty cache is what get_price_report errors on) and priceStale before trusting the numbers; refresh_cache fixes both.
diff_lists takes two sides (a and b, each { listType?, name }; names resolve like CLI list arguments, with listType disambiguating a name) plus an optional by (name, the default, or printing). It 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 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 source picks the store, and the store sets the currency (there is no currency input): tcgplayer (Scryfall USD), cardmarket (Scryfall EUR), cardhoarder (Scryfall MTGO tix), or cardkingdom (Card Kingdom NM retail from the cached buylist feed; errors with the refresh advice when no feed is downloaded). Without it, the configured defaultPriceSource applies. Every result names its source and currency. The result is discriminated by mode: "list" carries list + cards, "summary" carries lists + typeTotals + totals. Every totals object carries a quantity-weighted estimatedCount: deck and wanted copies priced at a representative printing (no pinned printing) or with no price. Any non-zero value makes total an estimate; collections always report 0. Prices come strictly from the local card cache; an empty cache is an error (check get_cache_status, 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 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, though it is not flagged destructive either, since the writer never replaces a file.
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 instead, 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 while the sell tools answer anyway. Without such a flag 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.
| Tool | Description |
|---|---|
create_list |
Create a new, empty list. format (from the fixed set of deck formats) applies to decks only. |
import_deck |
Import a deck from a URL or pasted decklist text (Ritual’s own format and MTG Arena/MTGO exports). |
import_csv |
Import CSV text into a new or existing list (create/overwrite/append) with a column-mapping spec. |
import_change_bundle |
Apply a change bundle exported from the site editor to the underlying lists. |
set_list_metadata |
Write a list’s front matter. Only the fields you send are touched; null clears one. See set_list_metadata. |
add_card |
Add a card to any list; quantity adds that many copies in one save. The list must already exist (use create_list). |
set_card_art |
Set or clear one card’s custom art, addressed by cardId. See Custom art. |
remove_card |
Remove a card from any list; quantity (decks only) removes that many copies. Flat lists remove one entry at a time. |
set_card_printing |
Set a card’s printing (and optionally condition / language) in place. |
apply_changes |
Apply an ordered batch of changes to one list atomically (one save, one changelog block). The only route to language, note, label, section, commander and category edits. |
move_selected_cards |
Move a batch of identity-addressed cards between lists atomically. |
remove_selected_cards |
Remove a batch of identity-addressed cards across lists atomically. |
Details the table leaves out:
create_listis 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_deck: a URL import must statesyncPrintings(truekeeps the exact printings the source lists,falseimports bare card names). The CLI asks the user this interactively, so ask the user when their intent is unclear. A URL import from Archidekt or Moxfield also writes each card’s categories. Text lines the parser cannot read are skipped and reported inwarnings(empty for URL imports); a non-empty array means part of the pasted text was not imported.advisoriesreports lines that were imported but looked off (e.g. a card name still holding a printing token). Each entry of both is a message: Englishmessage, plusmessageKey/messageParamswhen it has a catalog entry; a warning whose card was imported without its labels haslabelsDropped: true. A name/ID conflict withoutoverwrite, or a deck name with no characters usable in a file name, fails withcode: "invalid-request".import_csv: acategoriescell (headercategoryorcategories) holds comma-separated categories (Ramp, Artifacts, first is primary) written to the list’s categories sidecar. On a deck, a value that names a board (Sideboard,Commander,Tokens, …) sets the row’s section instead, and a value the category grammar refuses goes towarningsrather than failing the row. 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.hasHeaderdefaults to true, andwarningsnames the skipped row (and whether it looked like a header).warningsentries are messages (EnglishmessageplusmessageKey/messageParams), and eachfailuresrow’s EnglishreasoncarriesreasonKey/reasonParamsbeside it.formatapplies to decks only. Rows that fail validation do not fail the call: the result always carriescardCount,failures, andfailedCount. A refusal (bad column spec, unknown list type, append to a missing list) is a tool error as usual.import_change_bundle: a version-3 bundle carries per-listlists[].changesplus a top-level normalizedmovesarray. Each move is applied on its destination list, whose save also takes the copy out of the source and writes both changelogs. A move carryingpinsCardIdpins a name-only line there instead of adding a copy; one carryingreplacementadds that printing back to the source; one carryingtagsor anotelands them on the destination line, and one carryinglabels(the card’s labels as its source states them, signed) lands them by the move rule: a proxy stays a proxy and a real card real, a stated label keeps its state, and an unstated one takes the destination’s default, as far as the destination type accepts. Answers{ message, messageKey, messageParams, lists, failedCount }; each list’sdroppedNotesreports notes its saves discarded (a moved card merging onto a deck line that holds a different note keeps the line’s note). A list that could not be resolved, loaded, or saved is reported in its ownlists[].errorrather than failing the call: the failing batch applied nothing and that list’s later batches are skipped, while earlier batches stay applied and are counted.add_card:conditionis rejected for wanted lists; collections requireset+collectorNumbertogether.labels(a signed label override for the new card, such as["no-proxy"]for a real card in aproxy-default deck) is accepted for whatever the list type carries;tagsgives the new card its tags on any list type.languagerecords a non-English copy; omitted, the configureddefaultLanguageapplies.section(also onapply_changesaddandset-section) resolves to the deck’s own section likeadd-card --section: case-insensitively, or by role, so an alias likeMainorDeckis the first main-deck section. A copy of the same printing merges only with a line in that section. A name that resolves to an existing section never creates a new one. Unlikeritual add-card, this never creates a missing list.remove_card:finish,condition, andlanguagenarrow the match to entries with that value (language: "en"matches bare lines).set_card_printing: omittingset/collectorNumberclears a deck or wanted-list card’s printing; on a collection that is rejected.conditiontakes a grade orNONEto clear a recorded grade (also accepted onapply_changes’set-printingaction);NMis the unrecorded default, so setting it leaves the line ungraded.languagesets the card’s language alongside the printing; omitted, it is left alone.
Card edits read the list’s content hash and post the changes to its save route, which replays them over the file, in one call, so you never supply a content hash; conflict detection is internal. 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 (see Tool errors), since two losses mean a live concurrent editor.
The four single-list edit tools (add_card, remove_card, set_card_printing, apply_changes) answer with { applied, message, listType, slug, effects, unmatched }:
effectsremoves the post-writeget_listround trip. It lists every entry the save created, dropped, or changed as{ action, cardId, name, section?, quantity, printing?, previousCardId? }, with the&NcardIdthe save allocated. Ids are assigned at write time, so the caller cannot know them beforehand.previousCardIdappears only on anupdatedeffect whose line was renumbered because another entry claimed 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.unmatchedis 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. This differs from the CLI, where a --card-id paired with a card name that names a different entry is a usage error. 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 that does not apply (its target does not exist, or one of the refusals below) 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 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.
A write to a list whose file holds a line the parser cannot read (a malformed card line, prose, comments, or any other text the list grammar does not model) is refused outright, because the save would drop that line and recycle its &N. The error names the file and each unreadable line, and nothing is written; these are 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.
Card-name validation
Section titled “Card-name validation”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. That 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, ...?). - If the cache is empty, the write is refused with a message naming both remedies (
refresh_cachehere,ritual cache preload-allon the CLI). Checkget_cache_statusfirst if unsure.
import_deck, import_csv, and import_change_bundle are excluded: they carry bulk content whose per-row failures their own engines already report.
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 is rejected. Decks and wanted lists accept a name-only card.
A finish belongs to a printing on every list type. apply_changes’ set-finish action rejects foil or etched against an entry that has no printing, reporting it as an unmatched change (needs-printing) so the whole batch is refused. Set the printing first; a set-printing earlier in the same batch satisfies it. nonfoil always applies, since it clears a finish token rather than asserting one.
The same check covers set_card_printing and apply_changes’ set-printing, which write a printing and a finish together: clearing the printing (omitting set/collectorNumber on a deck or wanted list) while passing finish: "foil" is rejected. Clearing both the printing and the finish together is fine.
add is exempt: a name-only entry created with a finish records “any printing, in foil”, a wanted-list specificity rather than an edit to an existing 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.
Card language
Section titled “Card language”Entries carry a language field only when the copy is not English; an absent value always means en, mirroring the card lines, where the [ja]-style token is omitted on English lines (see Card Language). The vocabulary 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_cardtakes an optionallanguage; omitted, the configureddefaultLanguageapplies (adds never prompt).set_card_printingtakes one to set it alongside the printing; omitted leaves it alone.apply_changes’set-languageaction changes it on its own;enclears the line’s token.remove_card(andapply_changes’removeaction) takes an optionallanguageto 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". get_buylist_quotes printings take an optional language for the same reason (a non-English copy gets no quote).
Card labels are per list type
Section titled “Card labels are per list type”The label vocabulary is shared, but each list type carries a different part of it: 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.
A card’s own labels are signed and adjust its list’s default (get_list reports both): "trade" adds a label, "no-sale" takes an inherited one away, so ["trade", "no-sale"] under a sale default means “for trade, not for sale”. Effective labels are the positives plus the default minus the negated labels, and a positive also displaces an inherited label it cannot combine with (["keep"] under a sale default is just keep). A label may not be both set and negated (["proxy", "no-proxy"] is refused by the input schema), and a list default is positive only. get_list reports each card’s resolved effectiveLabels, and export_cards reports and filters by effective labels.
Every tool that pairs a listType with labels (add_card, set_list_metadata, and apply_changes’ add / set-label actions) enforces this in its input schema, so a sale (or no-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: it passes on a deck and a collection and is refused on a wanted list, which has no override to clear. The CLI flags, the admin save routes, and change-bundle imports make the same decision. A move’s labels are the exception: they describe the copy as its source list states it (a bundle move’s labels, or what move_selected_cards reads off the source line), so they are landed by the move rule rather than refused.
A card labeled proxy is not a real copy, and every reporting tool agrees: get_price_report prices it at 0 with unpricedReason: "proxy" and leaves it out of unpricedCount (it still counts as a card), export_cards’ filters.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. Its absence there is the rule, not a failed lookup. A card with custom art is treated the same way (no price, no quotes, no sale), reported as unpricedReason: "custom-art", which wins when a card is both.
Card tags
Section titled “Card tags”Tags are the owner’s own free-form vocabulary, carried by every list type and never resolved against a list default: a card’s tags are exactly the ones on its line. Over MCP a tag is always its canonical value: plain text in the owner’s own casing, trimmed and single-spaced (Ramp, Card Draw), without the # the card line writes, and never containing #, ,, &, *, double quotes, brackets, braces or parentheses. Anything not canonical (#Ramp, Ramp, a,b) is refused by the input schema before anything is loaded.
get_listreports each card’stagsin canonical (sorted) order.add_cardandapply_changes’addtaketagsfor a new card.apply_changes’add-tag/remove-tagput one tag on or take one off an existing card, one event per tag, so tagging then untagging the same card in one batch cancels out.- A cross-list move carries a card’s tags and note to the destination line:
move_selected_cardstakes them from the source line, and a bundle move carries them in its owntagsandnotefields. Nothing is filtered out of either, since every list type carries both, with one exception: a copy that merges onto a deck line already holding a different note keeps that line’s note, and the result’sdroppedNotesreports it. The card’s labels travel too, as its source states them (a bundle move’slabels): a proxy stays a proxy and a real card real wherever both lists carryproxy, asale/trade/keepstate the source stated is kept, and a label it never stated takes the destination’s default. The destination keeps only the labels its type accepts (proxyinto a deck, everything into a collection, nothing into a wanted list) and writes the smallest override that says so. export_cards’filters.tagsselects cards carrying any of the given tags on every list type (wanted lists included), matched exactly. A card with no tags never matches, andnoneis an ordinary tag rather than a sentinel.
Tags are not labels: keep as a tag carries none of the keep label’s meaning, and a deck’s front-matter tags (written by set_list_metadata) describe the deck, not a card.
Card categories
Section titled “Card categories”Categories are the card’s role in this one list, what Archidekt calls a category and Moxfield a tag. They are the third kind of thing you can say about a card: a label is an instruction to the app (proxy, keep), a tag is a property of the physical copy and travels with a cross-list move, and a category belongs to the card name in this list and never follows a move.
- Keyed by card name, never
&N. One assignment covers every line of that name in the list, whatever its printing, section or quantity; matching folds case and whitespace. - Ordered, first is primary, the one the site groups by.
- A name is plain text in the owner’s own casing (
Ramp,Card Draw,Tokens & Extras) and can never contain a comma or a control character, and must be canonical (no surrounding or doubled spaces). Anything else (Ramp,a,b) is refused by the input schema before anything is loaded.
get_list’s cards view reports them twice: the list’s own categories ({ "order": [...], "cards": { "Sol Ring": ["Ramp", "Artifacts"] } }) beside the entries, and each returned card’s own resolved categories, primary first. Absent means none at both levels, never an empty array. Read the per-card field rather than joining cards yourself, since the name fold is Ritual’s. categories always describes the whole list, never just the returned page; each card’s own categories covers only the cards in the body.
apply_changes writes them, with three actions:
| Action | Fields | What it does |
|---|---|---|
set-categories |
cardName, categories |
Replaces that name’s whole ordered list (first = primary). [] clears. No cardId — the sidecar does not address lines. |
rename-category |
category, newCategory |
Rewrites every card using the name, and the vocabulary’s order. |
set-category-order |
order |
Sets the vocabulary’s display order. An empty array clears the declared order; the next write re-derives one from the categories the cards use, listing the configured defaultCategories first. |
There is no “remove a category” action. Removing one is two list-level actions: clear it off its cards with set-categories, then send a set-category-order without it.
A set-categories naming a card this list does not hold is applied and counted, then pruned when the save writes the sidecar (categories go with the last copy of a name), so it reads as a successful no-op. Check the list first. A save that pruned anything reports the dropped names in prunedCategories, as do move_selected_cards and remove_selected_cards.
A cards view also carries categoryWarnings when the categories sidecar cannot be read or records categories for names the list no longer holds. Like artWarnings it is not part of warnings, and it blocks nothing. See the sidecar’s format.
Custom art
Section titled “Custom art”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 as art (null clears). 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). A url is used verbatim, never downloaded and never extension-checked.
Custom art carries the same pricing rule as the proxy label: get_price_report prices the card 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, because a line’s &N is allocated inside the write. 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:
add_card/apply_changes→effects: [{ action: "added", cardId: 7, name: "Sol Ring", … }]set_card_artwithcardId: 7and the reference.
The admin editors’ add-card dialog does the same internally, and the CLI is the same shape: add-card then set-card --art.
Art follows the card, so no follow-up set_card_art is needed to keep it in place:
- A mutation that removes a card (
remove_card, aremoveinapply_changes,remove_selected_cards) drops its art. - A cross-list move (
move_selected_cards, or a move applied byimport_change_bundle) re-files the entry under the destination line’s new&N. - A sync that pulls removals in (
sync_decks,sync_collection) drops the entries of the cards it removed. - A
removefollowed by anaddof the same card in oneapply_changesbatch still drops the art, even when the new line reuses the old&N. Callset_card_artafterwards if the new copy should have it. - Two things keep their art: a deck line that still has copies left after a removal, and a moved copy that merges onto a line the destination already had (that line’s art is left alone, whichever direction the copy travels).
See Art follows the card. A list’s cover image is filed under an &N the same way and reconciled by the same mutations, so a set_list_metadata cover never needs re-setting after a card change.
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 not part of warnings, which means unreadable card lines and causes a mutation to refuse the list. Bad custom art blocks nothing.
A mutation result (add_card, remove_card, set_card_printing, apply_changes) reports the same sidecar channels for the save it just did: artWarnings for art sidecars it could not re-file, categoryWarnings for a categories sidecar it could not read or write, and prunedCategories for card names whose category assignments it dropped. move_selected_cards and remove_selected_cards report prunedCategories too. All are absent when there is nothing to say.
apply_changes
Section titled “apply_changes”apply_changes accepts fifteen change actions: add, remove, set-finish, set-printing, set-language, set-note, set-label, add-tag, remove-tag, set-commander, unset-commander, set-section, set-categories, rename-category, and set-category-order. It is the only way to reach every action after set-printing:
set-languagerequireslanguage;enclears the token.set-notesets or clears a card note.set-labeltakes the new signed labels (["trade", "no-sale"],["no-proxy"]), replacing the card’s own; an empty array clears the override so the list default applies. Labels the list type cannot carry are refused by the input schema; aset-labelthat otherwise cannot apply fails at apply time.add-tag/remove-tageach take one canonicaltag, on any list type.set-sectionmoves a card to a section.set-commander/unset-commanderapply to decks only.set-categoriestargets a card by name (nocardId);rename-categoryandset-category-ordertarget the list and are the only changes here that name no card.
An add may also carry labels and tags for the fresh card; add and set-printing may carry language; remove may match on language. Change id and timestamp are stamped by the server and are not part of the input. Cross-list moves are rejected; use move_selected_cards. Section-structural events are not agent-facing.
apply_changes carries destructiveHint: true because a batch can remove cards in bulk. The hint reflects the tool’s worst-case capability, not what any particular batch does.
set_list_metadata
Section titled “set_list_metadata”set_list_metadata writes deck front matter (tags, format, source link) and, on a deck or a collection, labels, the default card labels every entry inherits, adjusted by its own signed labels (null clears them; a default is positive only). All three list types take:
description, the blurb the built site prints above the cards (nullor""clears it).image, the list’s cover image: a single-key mapping —{"card": N}(the&Nof a line in that same list),{"file": "rel/path"}(relative to the art directory) or{"url": "https://…"}— withnullrestoring the built-in cover rule. There is no scalar spelling. The three modes fail differently: acardid the list does not carry is rejected outright, afilewith nothing behind it is accepted and only warned about at build time, and aurlis never validated.
description and image are the only fields a wanted list accepts (use rename_list to change its display name); a labels key on a wanted body is refused by name.
Setting sourceId together with an archidekt.com sourceUrl makes a deck sync-linked, so sync_decks operates on it. Once merged over what the file already carries, the two must name the same Archidekt deck, or the call is rejected (a sync addresses the deck by sourceId while every surface shows sourceUrl).
The call answers { slug, frontMatter }; the route’s contentHash is dropped, since an agent never supplies one. No changelog entry is recorded, because the changelog is card-level. This is the same write the CLI’s deck-sync link performs, so the deck’s card lines and prose survive byte for byte.
Destructive
Section titled “Destructive”These are flagged with the MCP destructiveHint so clients can gate or confirm them:
| Tool | Description |
|---|---|
rename_list |
Rename a list (changes its slug); the result carries newFilePath and oldFilePath. |
delete_list |
Delete a list and every sidecar it has, moving the files into the workspace’s .trash/. Requires a confirmName matching the list’s display name; the result carries deletedFiles and trashDir. |
rewrite_history |
Replace a list’s entire change log. |
update_config |
Merge a partial configuration. |
build_site |
Rebuild the public static site. Runs in a child process and publishes atomically, so an interrupted build never leaves a broken site. Returns { message, outDir, durationMs }. |
sync_decks |
Sync decks with Archidekt in either direction. See sync_decks. |
sync_collection |
Sync collection lists with Archidekt in either direction. See sync_collection. |
refresh_cache |
Refresh the Scryfall card cache (bulk download + oracle/art tags). A failed download or ingest is a tool error. Does not touch the buylist; that is refresh_buylist. |
refresh_buylist |
Download the Card Kingdom pricelist feed (~70 MB) when the cached copy is stale (older than a day) or missing; force: true redownloads regardless. Needs sell mode. |
Details the table leaves out:
rename_listis refused when the new name resolves to another list of the same type; re-spelling the list’s own name (capitalization, punctuation) is allowed.rewrite_history: echo back sets you did not author exactly asget_historyreturned them, including each set’seventsarray (one typed event per change line, in the same order; empty only for a legacy set that had none) and itstrailingarray of preserved hand-written lines, or that text is deleted. A set with noevents, or with a count that does not match itslines, is refused. Trailing lines must not start with-or##.update_config:defaultPriceSourcetakes one store name (tcgplayer,cardkingdom,cardmarket,cardhoarder); a currency code is rejected, and""clears it (the firstpriceSourcesentry then applies).defaultLanguagetakes canonical Scryfall codes only; a non-envalue switches cache downloads to the much largerall_cardsbulk (see Default language).uiLocaleis the unrelated interface language (a BCP-47 tag); setting it never changes this surface’s English prose.priceSourcestakes store names (tcgplayer,cardmarket,cardkingdom,cardhoarder; the sites offer only these stores’ currencies;[]hides all site prices). Enablingcardkingdommakes builds and servers download the ~70 MB Card Kingdom feed, as sell mode does.defaultCategoriesis the global category vocabulary new lists suggest and order by (see Card categories).updateCheck(boolean) turns the CLI’s update notice on or off for the workspace. Themanagedblock is Ritual bookkeeping: it is accepted so a config read byget_configcan be sent back whole, but ignored; workspace migration is theritual migrateCLI command, not an MCP tool.build_site,sync_decks,sync_collection, andrefresh_cachereport progress and honour cancellation. The syncs cancel between items and reportreport.cancelled; a cancelledrefresh_cachewrites nothing and the previous cache stands.sync_collectionasks the client (elicitation) which list loses an ambiguous removal when it can, and takesremovalAssignmentsotherwise.refresh_buylist: the sell tools read strictly from this cache. A failed download with a stale cache degrades:refreshed: falseplus the failure inwarnings.
sync_decks
Section titled “sync_decks”sync_decks takes:
direction(pull|push).- An optional
decksarray (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. - Optional
dryRun/ignoreUnreadableLines/force/syncPrintings/syncCategoriesflags.
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.
Both directions diff per board and always apply board moves: a card on a different board on each side is moved, keeping the local line’s ID or the Archidekt entry. only never drops a move, and each deck’s report entry that moved cards carries boardMoves.
Under syncCategories, a pull makes the local categories file hold Archidekt’s categories for every card and a push makes each Archidekt card hold the local ones, beside the board category of the board it ends up on. A push from a deck with no local categories leaves Archidekt’s alone. Each deck’s report entry carries categoriesChanged.
Each deck’s report entry that had card changes carries changes: every change the run applied (or would apply on a dry run), narrowed by only. It lists what went through: a push leaves out what its plan could not send, or did not need to. When the push itself failed (nothing was sent), it lists what the push attempted, and reason says so. Entries are discriminated by kind: added/removed (name, quantity, board), quantity (name, board, from, to), move (name, quantity, from, to boards), printing (name, board, from, to printings) and categories (name, categories, primary first). The first four carry an optional printing (set lowercase, collectorNumber, finish) on a run syncing printings. These are the cards the CLI lists under --verbose.
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 and outcome (changed, unchanged, dry-run or cancelled; branch on it, not on the prose). reason is English, with reasonKey/reasonParams beside it for a client that renders it in its own language.
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, since 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; it stands in for the CLI’s --yes prompt.
sync_collection
Section titled “sync_collection”sync_collection is the collection counterpart. 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:
direction(pull|push).- An optional
listsarray (slugs or names scoping the local side; omit to compare every collection list). - The same
onlyfilter. - An optional
into: the list a pull adds new cards to, created if missing. Defaults to thecollectionSync.pullTargetconfig key. - An optional
removalPriorityarray orremovalAssignmentsarray (one or the other; see below). - An optional
csvflag. - The same
dryRun/ignoreUnreadableLinesflags.
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: send a push’s new cards to Archidekt as one CSV import, with rows built from the local Scryfall cache, instead of 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; 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 the rows are keyed by Scryfall ids from the local cache, an empty or day-old cache is refreshed automatically before the upload is built (the CLI’s --refresh auto). Quantity changes and removals never go through 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).
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. removalAssignments is the other way to decide: an explicit [{ key, choices: [{ list, copies }] }] per ambiguous removal, naming the lists that lose copies and how many each gives up (key is the removal’s key as report.ambiguous reports it). The two cannot be combined. Either one is validated against the ambiguity the run actually finds (a list holding no copies, or counts that do not add up), and a decision that does not cover a removal fails the run and writes nothing at all, naming the cards in report.errors and setting report.unresolvedAmbiguity.
A run that meets an ambiguous removal with neither field set asks the user when it can. If the client declared the elicitation capability, the call returns an input_required result carrying one form per ambiguous removal (a bounded integer per list holding copies: “how many copies should each list give up?”), and the client’s answers rerun the sync with them as removalAssignments. The SDK client fulfils the round trip on its own; a 2025-era client is served through the SDK’s legacy shim as an elicitation/create request. A declined form leaves the ambiguity unresolved: the run reports unresolvedAmbiguity: true, and nothing is written. A client that declared no elicitation capability is never asked; it gets the same unresolved report, and the agent should ask the user which lists may lose cards and rerun with one of the two fields rather than guess.
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). It refuses a collection list with unreadable lines for the same reason as sync_decks: 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 strategy placed them or the run failed on them.unresolvedAmbiguity: true when the run stopped on them, having written nothing.lists[].changes, present when a list had any: every card behind its counts, which are its totals. Each entry has akind(added, withvia: "csv"when the CSV import created it, orremoved),name,quantityin copies, andprinting(setlowercase,collectorNumber,finish,condition, andlanguagewhen not English). There is one entry per card, printing and kind, narrowed byonly. The schema’spendingkind is the CLI’s--csv-fileand never appears here. It lists what went through, or would on a dry run: a card whose write failed is left out, and the list’sreasonsays what failed. On a push a card held in several lists is one key, listed under each with the key’s whole quantity. A list a cancellation skipped may still carry changes an earlier list’s turn made for it. These are the cards the CLI lists under--verbose.unlisted: a push’s deletions of records for cards no in-scope list holds any more, present only when there is at least one.totals.skipped: whatonlyleft out.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 produced: a pull adds nothing and a push removes nothing.
report.failedCount and each list’s status/reason carry per-list failures; each list’s outcome says what came of it, as for sync_decks. report.errors is English, and report.errorMessages holds the same failures as messages with their keys.
Other destructive hints
Section titled “Other destructive hints”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.
Workspace maintenance is not exposed either: ritual migrate, which applies workspace migrations and refreshes generated files, has no tool. Agents run it through the CLI (the ritual-site skill covers it), previewing with --check or --dry-run and asking before applying. ritual mcp also skips the pending-updates check other commands run, since its stdout is the JSON-RPC channel; check a workspace with ritual migrate --check. It never prints the update notice either. Updating Ritual itself is likewise CLI-only: ritual self-update replaces the running binary and has no tool, so agents check with ritual self-update --check and ask the user before installing.
Resources
Section titled “Resources”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), with the same view and listType discriminants and the same warnings. 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 keeps 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 builds one server per request and tears it down with the response, so a notification there would have nowhere to go. HTTP clients should re-list resources after a list-lifecycle call.
Card cache
Section titled “Card cache”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, because 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 its state and refresh_cache to warm it. A cold cache also makes card-name validation on writes and get_price_report fail, so check it first.
The same applies to the Card Kingdom buylist. admin and serve --api redownload a day-old feed when they start; the MCP server 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). refresh_cache does not include the buylist, even though the CLI’s cache preload-all does.
Sell tools need sell mode
Section titled “Sell tools need sell mode”The four buylist tools (get_sell_report, get_sell_cart, get_buylist_quotes, and refresh_buylist) reuse the admin’s sell routes. Those are enabled by sell mode or the cardkingdom entry of priceSources (whose retail prices come from the same feed). Both are off by default. 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 (same key), or by starting the server with --sell-mode:
ritual mcp --sell-moderitual admin --mcp --sell-mode does the same for the embedded endpoint. Only these four tools are gated.
Client configuration
Section titled “Client configuration”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:
claude mcp add ritual -- ritual mcp --base-dir /path/to/your/ritual/workspaceSee also
Section titled “See also”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.
Ritual is unofficial Fan Content permitted under the Fan Content Policy. Not approved or endorsed by Wizards of the Coast. Portions of the materials used are property of Wizards of the Coast. ©Wizards of the Coast LLC.
Card data, images and symbols come from Scryfall, which is not affiliated with this project. Ritual is free software under the AGPL-3.0. License and attribution.