The Theme Dev MCP server registers 33 tools: 18 that read and 15 that write. Every name on this page is the wire name — the exact string an agent puts in a tools/call, not a display label. Call them verbatim.
The whole set fits in a single tools/list page, so there is no cursor to follow.
What your token sees
The 15 write tools check the token for themes:write at registration time. A token holding only themes:read gets a tools/list of 18, not 33 — the write tools are absent rather than present-and-failing. That is deliberate: an agent that cannot see a tool will not plan around it and then hit a wall halfway through.
screenshot-theme is the one tool whose grouping may surprise you. It renders and uploads an image, but it is not write-gated: a read-only token can call it. Screenshotting is how an agent checks its own work, so a read-only session can still run the full inspect-diff-report loop without ever being able to change a file.
See Which ability connects, which ability gates for the full picture.
Conventions across every tool
themeaccepts either identifier. The numeric row id or thetheme_idUUID both resolve. Get them fromlist-themes.- Everything is scoped to the token’s store, before anything else runs. A theme belonging to another store is reported as not found, and there is no argument that widens the scope.
- Paths are directory-scoped, with extensions:
sections/hero.liquid,templates/index.json,assets/base.css,snippets/header-drawer.liquid. - Errors come back as MCP error results, not exceptions. They are written to be actionable — most name the tool you should have called first.
The two refusals on file writes
The file-write tools refuse two categories of change unless you opt in explicitly. Neither refusal is a permission problem, and retrying the same call with the flag set is the intended resolution — once you have established that it is the right call to make.
allow_active — writes to the active theme are blocked. All four file-write tools (update-theme-file, create-theme-file, delete-theme-file, restore-file-version) carry this flag. The active theme is the live storefront, so the safe pattern is duplicate-theme and work on the copy. Pass allow_active: true only when the user has explicitly asked to edit the live theme. set-collection-filters carries the same flag for the same reason: it writes a theme setting, and on the active theme a filter change is live on the next page load.
allow_chrome — writes to global page chrome are blocked. That means sections/header-group.liquid, sections/footer-group.liquid, and any section those groups render. Only the three tools that can change an existing file — update-theme-file, delete-theme-file and restore-file-version — take this flag; create-theme-file has no allow_chrome argument, because creating a file that does not yet exist cannot overwrite chrome that does. Chrome appears on every page, so the flag exists to separate two jobs the tool cannot tell apart:
- Recreating a whole theme or homepage from a mockup: the mockup’s header and footer are the site’s header and footer. Retry with
allow_chrome: true— shipping a custom body under stock chrome is a half-finished theme. - Building one secondary page: the chrome is not yours to change. If that page’s mockup shows different chrome, ask the user. Secondary-page mockups usually just abbreviate it, and matching them would strip the difference from the entire storefront.
The refusal text states both branches, because the correct answer depends on intent rather than on the file.
Saves are validated, versioned, and critiqued
Every write through the save pipeline does three things:
- Validates. Liquid, JSON and
{% schema %}are parsed. A failure returns the parser error and nothing is written — fix and retry. - Versions. The previous content is snapshotted first, so
list-file-versionsplusrestore-file-versionis always available as an undo. - Warns. A save can succeed and return advisory warnings. These are the silent-failure detectors: a
<header>in a body section that is not wired intoheader-group, a<nav>with nolink_listsetting, several page regions crammed into one section file, hand-drawn SVG artwork standing in for a photo, a hardcodedfont-family, a hand-rolled page container that overrides the merchant’s Page width, an overlay written as an empty<div>, a custom header that cannot produce a hamburger or whose search icon reaches nothing, and a template using a plugin section whose plugin is not active.
Warnings do not block the write. Read them anyway — every one of them describes something that will look fine in a screenshot and be broken in production.
One warning is not a criticism but a report: if you send null for a setting the merchant has already chosen in the visual editor, the save keeps their value and tells you which ones it kept. read-theme-file returns the file, where an image_picker reads "image": null; the merchant’s actual choice lives in the theme’s settings and you cannot see it. A null you write back means “not specified”, never “clear it” — and sending "" to force it through deletes a photo you were never shown.
The tools
List themes
list-themesthemes:read{
"themes": [
{
"theme_id": "9f1c0b2e-7d44-4a1e-9a52-3b8c6d0f1a77",
"name": "Arsenal",
"active": true,
"template_source": "arsenal",
"base_version": "1.4.0",
"is_customized": true,
"dev_mode": false,
"screenshot_url": "https://cdn.firearmcart.com/theme-screenshots/412.jpg",
"updated_at": "2026-02-11T18:04:22+00:00"
},
{
"theme_id": "2ad7f6c1-55b0-4f3d-8e19-c0a4b7e2d904",
"name": "Arsenal (working copy)",
"active": false,
"template_source": "arsenal",
"base_version": "1.4.0",
"is_customized": false,
"dev_mode": false,
"screenshot_url": null,
"updated_at": "2026-02-14T09:31:07+00:00"
}
]
}- Takes no arguments — the store is the token itself, so there is nothing to scope by.
- Rows are ordered active-first, then by name, so themes[0] is the live theme whenever the team has one.
- theme_id is the identifier every later tool's `theme` argument takes, and it is stable: it survives an export/import round trip.
- template_source is the theme-store slug the theme was created from (it matches list-theme-store.slug), and base_version is the catalogue version it was copied at — neither changes when the merchant edits files.
- screenshot_url is the library thumbnail written by a background job (GenerateThemeScreenshot), so it is frequently null on a freshly duplicated theme and can lag the current files. It is unrelated to the URLs screenshot-theme returns; do not use it to check your own edits.
- Never build against the active theme. Duplicate it first (duplicate-theme) or pick an inactive copy — file-write tools refuse an active theme unless allow_active=true.
List theme store catalog
list-theme-storethemes:read{
"store": [
{
"slug": "arsenal",
"name": "Arsenal",
"version": "1.4.0",
"price_cents": 0,
"is_free": true,
"purchased": false,
"cover_photo": "https://cdn.firearmcart.com/themes/arsenal/cover.jpg",
"in_library": true
},
{
"slug": "tactix",
"name": "Tactix",
"version": "2.0.1",
"price_cents": 18000,
"is_free": false,
"purchased": true,
"cover_photo": "https://cdn.firearmcart.com/themes/tactix/cover.jpg",
"in_library": false
}
]
}- Takes no arguments.
- The catalog is the `config('themes')` file, not a database table. Only entries flagged active are returned, and a slug with several active versions yields one row per version — so slug alone is not a unique key here.
- price_cents is an integer in cents and is 0 for free themes. Nothing on this server can purchase a theme; a paid theme the team has not bought (purchased: false) must be bought in the dashboard first.
- in_library is derived by matching the slug against the template_source of themes already in the library, so it is true for any copy — including one the merchant has since renamed or heavily customized.
List theme files
list-theme-filesthemes:readArguments
themestringrequired- The theme to inspect — the theme_id returned by list-themes. Omitting it fails with “Missing theme argument. Call list-themes first.”
typestringoptional- Return only one group. Omit for every group at once. One of: layout, template, section, snippet, block, asset.
{
"theme": "418",
"type": "section"
}{
"files": {
"section": [
{
"path": "sections/header.liquid",
"is_customized": true,
"user_created": false,
"size": 18422
},
{
"path": "sections/hero-banner.liquid",
"is_customized": false,
"user_created": true,
"size": 3140
}
],
"asset": [
{
"path": "assets/base.css",
"file_size": 96114,
"mime_type": "text/css",
"editable": true
},
{
"path": "assets/logo-mark.png",
"file_size": 12880,
"mime_type": "image/png",
"editable": false
}
]
}
}- files is an object keyed by type, not a flat array, and a key is simply absent when that type has no files. Iterate the keys rather than indexing them.
- Template rows and asset rows have different shapes: templates carry is_customized / user_created / size, assets carry file_size / mime_type / editable.
- user_created: true means the file has no base-theme original (original_content IS NULL). Those are exactly the files delete-theme-file will accept — base theme files cannot be deleted, only overwritten.
- editable: false marks a binary asset (png, jpg, webp, fonts). read-theme-file refuses it and no write tool can produce one, because every tool on this server is text-only. Reference it by URL instead.
- Only active assets are listed. Assets superseded by a re-upload stay in the database but never appear here.
- Plugin sections (fc-product-reviews, fc-events-list, fc-reload-options, …) are NOT theme files — they live in the application, so they never appear in this listing however active the plugin is. Discover them with list-plugins or get-section-schema.
- Themes are resolved inside the authenticated team only. A theme id belonging to another team is indistinguishable from a non-existent one: both return “Theme not found for this team. Call list-themes first.”
Read a theme file
read-theme-filethemes:readArguments
themestringrequired- The theme to inspect — the theme_id returned by list-themes. Omitting it fails with “Missing theme argument. Call list-themes first.”
pathstringrequired- Theme path exactly as list-theme-files reports it, e.g. sections/hero.liquid, templates/index.json, assets/base.css.
versionintegeroptional- Read a historical version instead of live content. Pass a version_number from list-file-versions (minimum 1).
{
"theme": "418",
"path": "sections/hero-banner.liquid",
"version": 3
}# sections/hero-banner.liquid @ v3
<div class="section section--{{ section.settings.section_width }}">
<h2>{{ section.settings.heading }}</h2>
</div>
{% schema %}
{ "name": "Hero banner", "settings": [] }
{% endschema %}- The first line is a header — `# {path} @ {version}` — followed by a blank line, then the raw file. Strip those two lines before treating the payload as Liquid or JSON.
- The version label reads `v12 (current)` when the file has version history and plain `current` when it has none. Requesting an explicit version labels it `v3` with no suffix.
- Binary assets are refused with “Binary asset cannot be inlined. Use public_url: …”, which includes the URL. Readable assets are css, js, json and svg, plus *.css.liquid and *.js.liquid.
- A missing path errors with “File not found: {path}” and a bad version with “Version {n} not found for {path}.” Neither is a partial success — nothing is returned alongside the error.
- This returns the FILE, not the merchant's settings. An image_picker reads "image": null here even when the merchant has chosen a photo, because their choice lives in the theme's settings — read those with get-theme-settings, and never write that null back as "" or "none" to “clear” it.
- Themes are resolved inside the authenticated team only. A theme id belonging to another team is indistinguishable from a non-existent one: both return “Theme not found for this team. Call list-themes first.”
Search theme files
grep-themethemes:readArguments
themestringrequired- The theme to inspect — the theme_id returned by list-themes. Omitting it fails with “Missing theme argument. Call list-themes first.”
querystringrequired- The string to find, or a PCRE pattern when regex is true. Must be at least one character.
regexbooleanoptional- Treat query as a regular expression rather than a literal substring. Defaults to false.
case_sensitivebooleanoptional- Match case exactly. Both literal and regex searches are case-insensitive by default. Defaults to false.
typestringoptional- Restrict the search to one template type. Assets are governed by include_assets, not by this filter. One of: layout, template, section, snippet, block.
include_assetsbooleanoptional- Also search css/js/json/svg assets. Slower — each one is fetched from object storage. Defaults to false.
max_resultsintegeroptional- Stop after this many matches (1–200). Defaults to 50.
{
"theme": "418",
"query": "render 'header-drawer'",
"include_assets": false,
"max_results": 20
}{
"matches": [
{
"path": "sections/header.liquid",
"line": 142,
"text": " {% render 'header-drawer', linklist: section.settings.menu %}"
}
],
"count": 1,
"truncated": false
}- Matching is line-based and line numbers are 1-indexed, so a pattern spanning two lines never matches.
- The regex delimiter is `~` and the tool escapes any `~` in your pattern before compiling with the `u` (and, unless case_sensitive, `i`) modifier. An uncompilable pattern returns “Invalid regex pattern.” rather than falling back to a literal search.
- max_results caps the whole run, not each file — templates are scanned first and the cap can be reached before assets are touched. truncated: true means matches were dropped, so narrow the query rather than paging.
- include_assets only reaches text assets; binaries are skipped, and any asset that fails to download is skipped silently rather than erroring the call.
- The type filter has no `asset` value on purpose. Passing an unlisted type is a validation error, not an empty result.
- Themes are resolved inside the authenticated team only. A theme id belonging to another team is indistinguishable from a non-existent one: both return “Theme not found for this team. Call list-themes first.”
Get section schema
get-section-schemathemes:readArguments
themestringrequired- The theme to inspect — the theme_id returned by list-themes. Omitting it fails with “Missing theme argument. Call list-themes first.”
sectionstringoptional- A section name (hero-banner), a filename (hero-banner.liquid), a full path (sections/hero-banner.liquid) or a plugin section type (fc-product-reviews). Omit for the summary of all sections.
{
"theme": "418",
"section": "sections/hero-banner.liquid"
}{
"path": "sections/hero-banner.liquid",
"schema": {
"name": "Hero banner",
"settings": [
{ "type": "image_picker", "id": "image", "label": "Background" },
{ "type": "text", "id": "heading", "label": "Heading", "default": "Shop rifles" }
],
"blocks": [{ "type": "cta", "name": "Button", "settings": [] }],
"presets": [{ "name": "Hero banner" }]
},
"has_presets": true,
"picker_excluded": false,
"picker_visible": true
}- Three different response shapes. With a theme section: path + schema + has_presets + picker_excluded + picker_visible. With a plugin section: type, plugin_slug, plugin_active, template_only, block_types, schema and a ready-made `usage` snippet. With `section` omitted: a `sections` summary array (path, name, preset_count, block_types, picker_visible) plus a `plugin_sections` array.
- Plugin names are resolved BEFORE theme files, so a theme file named fc-*.liquid can never be read through this tool.
- picker_visible is the merchant-facing fact: it is true only when the section has at least one preset AND is not picker-excluded. Excluded are header, footer, header-group, footer-group, _blocks, any name starting with `_`, and anything prefixed main-, password-, predictive-search or section-rendering. A section with no preset exists but can never be added from the Visual Editor.
- A plugin section whose plugin is inactive comes back with plugin_active: false and a `warning` telling you not to build the feature — its drops are empty and its endpoints reject. Confirm activation with list-plugins first.
- template_only plugin sections (fc-reload-options is the usual one) are absent from the editor's picker entirely, so wiring them into a template JSON is the only way a merchant gets them.
- An unknown section errors with “Section not found: {ref}”. Only files of type `section` resolve — passing a snippet or template path is a not-found, not a wrong-type error.
- The summary form parses every section's schema on the fly, so it is the more expensive of the two calls on a large theme.
Get theme settings
get-theme-settingsthemes:readArguments
themestringrequired- The theme to inspect — the theme_id returned by list-themes. Omitting it fails with “Missing theme argument. Call list-themes first.”
scopestringrequired- Which slice to read: `schema` for the settings_schema definition, `values` for the merchant's saved global values, `page_template` for one template's effective section JSON. One of: schema, values, page_template.
templatestringoptional- The template name for scope=page_template, e.g. index, product, page.about. A .json or .liquid suffix is stripped. Required for that scope, ignored otherwise.
{
"theme": "418",
"scope": "page_template",
"template": "index"
}{
"template": "index",
"source": "themes.settings",
"data": {
"sections": {
"hero": {
"type": "hero-banner",
"settings": { "heading": "Shop rifles", "image": null }
}
},
"order": ["hero"]
}
}- scope=page_template answers the question read-theme-file cannot: `source` is “themes.settings” when the merchant has edited the page in the Visual Editor and “theme_templates” when it is still the file on disk. The settings copy WINS at render, so templates/index.json can look nothing like the live page.
- scope=values returns global values only — every key prefixed `templates.` or `pages.` is stripped out, because those are per-page overrides. Read one of those with scope=page_template.
- scope=values includes collection_filters, the list of specification groups the theme shows as collection-page filters (an empty list means every group shows). It is the one setting with a write tool: use list-collection-filters to judge the groups and set-collection-filters to change it, rather than reading this raw list.
- scope=values is also where merchant-chosen images live. A file read shows "image": null while the value here is set; that mismatch is expected, not a bug to correct.
- The store logo is NOT a theme setting: it comes from the team's Settings › General and is force-applied at render, so a section-level logo picker never shows anything. Headers must emit the global settings.logo.
- Errors are scope-specific: “No settings_schema found for this theme.”, “template is required when scope=page_template.”, “Page template not found: {name}”.
- A JSON template's leading comment header is stripped before parsing. If the body still fails to decode, `data` comes back as the raw string instead of an object.
List file versions
list-file-versionsthemes:readArguments
themestringrequired- The theme to inspect — the theme_id returned by list-themes. Omitting it fails with “Missing theme argument. Call list-themes first.”
pathstringrequired- Theme path of the file whose history you want, e.g. sections/hero-banner.liquid.
{
"theme": "418",
"path": "sections/hero-banner.liquid"
}{
"path": "sections/hero-banner.liquid",
"versions": [
{
"version_number": 4,
"label": "MCP update",
"user": null,
"created_at": "2026-02-14T09:31:07+00:00",
"content_hash": "5f2b…a91c",
"is_current": true
},
{
"version_number": 3,
"label": "MCP update",
"user": "Dana Reyes",
"created_at": "2026-02-13T22:12:44+00:00",
"content_hash": "c07d…8e33",
"is_current": false
}
]
}- Ordered by version_number descending, so versions[0] is the most recent save.
- is_current is a sha256 comparison against live content, not a pointer. If an edit was reverted by hand, two versions can both report is_current: true — and a file saved outside the version pipeline can leave every version false.
- user is null when the save came from a team API token (which is every MCP write), because a token has no user behind it. The label is what identifies the writer.
- This returns metadata only. Fetch the content of a version with read-theme-file and its `version` argument.
- A missing path errors with “File not found: {path}”. A file that has never been saved through the editor or MCP returns an empty versions array — there is no synthetic version 1 for the base theme content.
- Themes are resolved inside the authenticated team only. A theme id belonging to another team is indistinguishable from a non-existent one: both return “Theme not found for this team. Call list-themes first.”
List available fonts
list-fontsthemes:read{
"fonts": [
{
"family": "Inter",
"weights": [
{ "handle": "inter_n4", "weight": 400, "label": "Regular" },
{ "handle": "inter_n7", "weight": 700, "label": "Bold" }
]
},
{
"family": "Playfair Display",
"weights": [{ "handle": "playfair_display_n7", "weight": 700, "label": "Bold" }]
}
],
"handle_format": "{family_lowercased_with_underscores}_{style}{weight}: `n` = normal, `i` = italic, digit x 100 = weight. Inter 400 is `inter_n4`; Playfair Display 700 is `playfair_display_n7`.",
"rules": {
"no_hosted_font_library": "…",
"never_hardcode_a_family": "…",
"mockup_typefaces": "…",
"usage": "…"
}
}- Takes no arguments. The catalogue is global (App\Support\GoogleFonts, 25 families) and identical for every team and theme.
- The failure mode this tool exists to prevent is silent: an unknown family 400s at Google, a 400 yields an EMPTY @font-face rule, and the browser falls back with no error logged anywhere. The page just renders in the wrong typeface.
- Never write a literal font-family into a section's CSS. Typography is a merchant setting: use the theme's tokens (var(--font-heading--family)) or declare your own {"type":"font_picker","id":"heading_font","default":"inter_n7"} and render it with the font_face / font_url filters. Saves are warned when a hardcoded family is detected.
- The handle format is fixed, but the family spellings are canonical and case-sensitive — PT Sans, DM Sans and JetBrains Mono cannot be reconstructed by title-casing their handles, which is why the handle string from this response is the only safe value to use.
- A mockup's typeface is often not in the list. Pick the closest family and tell the user what you substituted rather than hardcoding the original name.
List media
list-mediathemes:readArguments
searchstringoptional- Case-insensitive substring filter on the media name or filename. Max 255 characters.
limitintegeroptional- Max results, 1–200. Defaults to 50.
offsetintegeroptional- Skip this many results. Defaults to 0.
{
"search": "hero",
"limit": 20
}{
"media": [
{
"name": "optics-hero",
"file_name": "optics-hero.jpg",
"url": "https://cdn.firearmcart.com/media/9812/optics-hero.jpg",
"mime_type": "image/jpeg",
"size": 418322
}
],
"total": 64,
"offset": 0,
"limit": 50,
"returned": 1,
"usage": "Use `url` verbatim: as an image_picker value in templates/*.json, or in Liquid via {{ \"<url>\" | image_url: width: 1200 }}. Uploading new images is not possible over MCP — anything not listed here must be uploaded by the merchant in Files."
}- Use `url` verbatim — as an `image_picker` value in `templates/*.json`, or in Liquid via `{{ "<url>" | image_url: width: 1200 }}`. Never invent an asset filename or hotlink a third-party URL.
- Only the merchant's `web_assets` collection is listed. Product and collection photography is not here; reach it through the catalog image drops instead.
- `returned` can be lower than `limit` even mid-list: an item whose CDN URL cannot be resolved is dropped from `media` but still counted in `total`. Page with `offset` on `total`, and treat a short page as normal.
- There is no upload tool. Anything the mockup needs that is not listed here must be uploaded by the merchant in Files — report the gap rather than drawing the photo as SVG.
List collection filters
list-collection-filtersthemes:readArguments
themestringrequired- theme_id from list-themes. The allowlist is stored per theme, so a library copy and the live theme each carry their own.
searchstringoptional- Only return groups whose name contains this text, case-insensitively. Applied before limit and offset.
limitintegeroptional- Maximum groups to return (1–400). Defaults to 100.
offsetintegeroptional- Skip this many groups, to page past limit. Defaults to 0.
{
"theme": "9f1c0b2e-7d44-4a1e-9a52-3b8c6d0f1a77"
}{
"theme": {
"theme_id": "9f1c0b2e-7d44-4a1e-9a52-3b8c6d0f1a77",
"name": "Arsenal",
"active": true
},
"allowlist": {
"filters": [],
"state": "No allowlist: every group with renders: true is shown on collection pages.",
"not_in_catalog": []
},
"always_shown": [
"Availability",
"Price",
"Sale",
"Product type (when a page spans 2+ product types)",
"In Store (when the catalog uses that spec)"
],
"catalog": {
"storefront_products": 18412,
"groups_total": 212,
"groups_shown_now": 164,
"groups_matching": 212,
"offset": 0,
"returned": 100
},
"groups": [
{
"name": "Manufacturer",
"products": 18412,
"values": 150,
"values_capped": true,
"renders": true,
"shown_now": true,
"top_values": [
{ "value": "Hornady", "count": 612 },
{ "value": "Federal", "count": 540 },
{ "value": "Magpul", "count": 377 },
{ "value": "SIG SAUER", "count": 301 }
],
"top_product_types": [
{ "type": "ammunition", "products": 2210 },
{ "type": "rifles", "products": 1984 },
{ "type": "handguns", "products": 1102 }
]
},
{
"name": "Caliber / Gauge",
"products": 7630,
"values": 118,
"values_capped": false,
"renders": true,
"shown_now": true,
"top_values": [
{ "value": "9mm Luger", "count": 1204 },
{ "value": "5.56 NATO", "count": 688 }
],
"top_product_types": [
{ "type": "ammunition", "products": 2140 },
{ "type": "rifles", "products": 1911 },
{ "type": "handguns", "products": 1086 }
]
},
{
"name": "FFL Required",
"products": 3322,
"values": 1,
"values_capped": false,
"renders": false,
"shown_now": false,
"top_values": [{ "value": "Yes", "count": 3322 }],
"top_product_types": [{ "type": "rifles", "products": 1984 }]
}
],
"fields": { "products": "…", "values": "…", "values_capped": "…", "renders": "…", "shown_now": "…", "top_values": "…", "top_product_types": "…" },
"how_to_choose": ["…"]
}- Group names are exactly what set-collection-filters accepts. Every caliber-type spec — Caliber, Gauge, Caliber/Gauge, Chambering, Cartridge — is merged into the single “Caliber / Gauge” group, just as the storefront merges them into one filter.
- Groups are sorted by products, most widely carried first, then by name. Category-specific groups (Tube Diameter, Blade Length) therefore sit well below feed plumbing that every product carries: page through with offset, or use search, before concluding a useful group is missing.
- values is the number of checkboxes the filter renders, read from the same cached aggregation the storefront uses. Calibers are merged into canonical cartridges, but every other spelling counts separately — 1x and 1X are two values because they render as two checkboxes.
- The aggregation keeps at most 150 values per group, so values_capped: true means “at least 150”. For most groups that marks free text rather than a filter; a brand group on a large catalog is the normal exception.
- renders: false means the group has fewer than 2 values. It never appears on the storefront, allowlisted or not — flags such as FFL Required are the usual case. shown_now applies the current allowlist on top of that.
- On the storefront, groups other than Manufacturer and Caliber / Gauge show at most their 50 most common values.
- Counts cover storefront-visible products across the whole catalog: active, not deleted, and not event listings. They are not per collection. A group only appears on a collection page whose products carry it, which is what top_product_types lets you predict.
- allowlist.not_in_catalog lists saved names that no longer match any group — the spec was renamed, or the products carrying it were removed. Those entries render nothing.
- Base filters (always_shown) are not part of the allowlist. Nothing on this server can hide them.
- The per-type breakdown is a catalog-wide aggregation that takes a few seconds on a catalog of tens of thousands of products. It is cached for an hour and evicted whenever products change, so a repeat call is fast.
- The response carries a how_to_choose checklist written for the agent: what shoppers narrow by per category, how to spot identifiers, free text and feed plumbing, and why to read top_values before choosing.
- Themes are resolved inside the authenticated team only. A theme id belonging to another team is indistinguishable from a non-existent one: both return “Theme not found for this team. Call list-themes first.”
List pages
list-pagesthemes:readArguments
themestringrequired- `theme_id` from `list-themes`. Required because the answer depends on which templates that theme actually has.
{
"theme": "b8d2f0c4-7a91-4f3e-9c65-1d0e2a4b7f88"
}{
"pages": [
{
"slug": "about",
"title": "About Us",
"status": "published",
"url": "/pages/about",
"type": "page.about",
"renders_with": "templates/page.about.json",
"uses_shared_template": false,
"template_to_create": null
},
{
"slug": "contact",
"title": "Contact",
"status": "published",
"url": "/pages/contact",
"type": "page.contact",
"renders_with": "templates/page.json",
"uses_shared_template": true,
"template_to_create": "templates/page.contact.json"
}
],
"rules": "… five guidance strings on template selection and page chrome, abbreviated …"
}- A page's template is chosen by its `type` column, NOT its slug. `/pages/{slug}` renders `templates/{type}.json` when that template exists and silently falls back to the shared `templates/page.json` when it does not — which is why `renders_with` is the field to trust, not `type`.
- `template_to_create` is the whole point of the tool: it names the file to pass to `create-theme-file` for a page that currently borrows the shared template. Creating `templates/page.about.json` while the page's `type` is something else has no effect at all.
- `templates/page.json` is the fallback for EVERY page without its own template. Restyling one page by editing it changes contact, FAQ and the rest.
- The `rules` block returned by this tool still claims “MCP cannot edit a page's `type`”. That is stale — `update-page` sets exactly that. Follow the tool, not the note.
List policies
list-policiesthemes:readArguments
policystringoptional- Slug of a single policy. Returns its full `content` instead of a preview. Omit to list them all.
{}{
"policies": [
{
"name": "Privacy Policy",
"slug": "privacy-policy",
"url": "/policies/privacy-policy",
"published": true,
"content_length": 8421,
"preview": "We collect only the information needed to fulfil your order and to meet our record…"
},
{
"name": "Shipping Policy",
"slug": "shipping-policy",
"url": "/policies/shipping-policy",
"published": false,
"content_length": 2140,
"preview": "All firearms ship to a licensed FFL dealer of your choosing…"
}
],
"rules": "… four guidance strings: shop.policies, the footer block, 404-on-unpublish, slug stability …"
}- Two shapes, one tool. Listing returns `content_length` plus a 160-character `preview` of the tag-stripped body; naming a `policy` swaps both for the full `content`.
- Policies are store data, like menus and pages. Themes read them through the `shop.policies` drop (`title`, `url`, `handle`, `body`) and the stock footer already lists them via its `footer-policy-list` block — never hardcode policy text or links into a section.
- `published: false` is a draft, not a soft-hide: the policy disappears from `shop.policies` AND `/policies/{slug}` returns 404.
- A named policy that does not exist is refused with “Policy '…' not found for this team. Call list-policies with no arguments to see them all.”
List plugins and their Liquid surface
list-pluginsthemes:readArguments
liquid_onlybooleanoptional- Keep to the plugins that expose theme surface. Pass false to also list the team's other active plugins, which have nothing to build against. Defaults to true.
{
"liquid_only": true
}{
"plugins": [
{
"slug": "product-reviews",
"name": "Product Reviews",
"active": true,
"sections": [
{ "type": "fc-product-reviews", "name": "Product Reviews", "template_only": false }
],
"drops": [
"product.metafields.reviews.rating.value.rating (float) + .scale_max",
"product.rating / product.rating_full (pre-rendered star HTML; empty string when no reviews)"
],
"endpoints": ["POST /api/reviews (plain <form>, NOT {% form %}); rejects when the plugin is inactive"],
"guide": "plugin-liquid → Product Reviews",
"note": null
},
{
"slug": "events",
"name": "Events & Classes",
"active": false,
"sections": [{ "type": "fc-events-list", "name": "Events", "template_only": false }],
"drops": ["events (global; up to 24 upcoming — EMPTY ARRAY when the plugin is inactive)"],
"endpoints": ["POST /events/{handle}/register (plain <form>)"],
"guide": "plugin-liquid → Events",
"note": "NOT ACTIVE for this team. Do not build this feature — its data drops return empty and its form endpoints reject. Tell the user to activate 'events' in the plugin marketplace first."
}
],
"active_slugs": ["product-reviews", "klaviyo"],
"how_to_use": { "sections": "…", "template_only": "…", "inactive": "…", "recipes": "…" }
}- Four plugins currently open Liquid surface: product-reviews, events, reload (Subscribe & Save) and klaviyo (back in stock). All four are always returned — read the `active` flag, do not infer activation from presence.
- An inactive plugin carries a populated `note`; an active one has note: null. active_slugs is the flat list of everything active on the team, including plugins with no theme surface.
- liquid_only: false appends the team's other active plugins as bare rows (slug, active, sections, note) with no drops/endpoints/guide keys — do not expect a uniform row shape across that response.
- Plugin sections are not theme files: they are invisible to list-theme-files and must never be created or edited. Reference one by `type` from a page template, exactly like a theme section, and read its settings with get-section-schema.
- There is no Liquid-visible activation flag (no shop.plugins drop), which is the reason this tool exists. The closest in-theme proxy is events.size > 0, and only for the events plugin.
- Building a feature whose plugin is inactive ships a widget with no data — the section still renders, its drops are empty and its endpoints reject. Read the `plugin-liquid` resource for the per-feature Liquid recipes.
List blogs
list-blogsthemes:read{}{
"blogs": [
{
"title": "News",
"handle": "news",
"url": "/blogs/news",
"is_active": true,
"articles": 12,
"published_articles": 9,
"liquid": "blogs['news']"
}
],
"liquid": "… four notes: the handle-keyed global, blog/article templates, the double-resolve trap, no global articles map …",
"note": null
}- `blogs` is a map keyed by handle: `{% assign blog = blogs['news'] %}`, then `blog.title` / `blog.url` / `blog.articles` / `blog.articles_count`.
- There is no global `articles` map. Articles are reachable only nested under a blog (`blogs['news'].articles`) or as the page-level `article` drop.
- The double-resolve trap: a `blog` section setting is already resolved to a blog OBJECT at render, so `blogs[section.settings.blog]` yields nothing. Use `section.settings.blog` directly, or index `blogs` with a literal handle string.
- `/blogs/{handle}` renders `templates/blog.json` (with `blog.articles` paginated) and `/blogs/{handle}/{article}` renders `templates/article.json`.
- A team with no blogs gets an empty list plus a `note` saying so. `create-blog` makes one, and `create-article` auto-creates a “News” blog when the team has none.
List articles
list-articlesthemes:readArguments
blogstringoptional- Blog handle. Omit for every blog in the team.
statusstringoptional- Return only articles in this state. One of: draft, published, scheduled.
limitintegeroptional- Max results, 1–100. Defaults to 50.
{
"blog": "news",
"status": "published",
"limit": 20
}{
"articles": [
{
"blog": "news",
"title": "Choosing your first optic",
"handle": "choosing-your-first-optic",
"url": "/blogs/news/choosing-your-first-optic",
"status": "published",
"published_at": "2026-07-28T14:02:11+00:00",
"excerpt": "Magnification, reticle and eye relief, without the jargon.",
"featured_image": "https://cdn.firearmcart.com/media/9812/optics-hero.jpg",
"author_name": "Dana Whitfield",
"live": true
}
],
"note": "Only `published` articles render on the storefront. `scheduled` flips to published by cron once its date passes."
}- There is no way to read an article's body over MCP. The server's own advertised description tells clients to “use read-article”, but no such tool is registered — `excerpt` is all you get.
- Only `published` articles render on the storefront; `live` restates that per row. A `scheduled` article flips to published by cron once its date passes.
- Ordering is `published_at DESC, id DESC`, so drafts and scheduled posts (no publish date) sort to the top.
- An unknown blog handle is refused with “Blog '…' not found for this team. Call list-blogs.” A team with no blogs at all returns an empty list and a `note` instead of an error.
Screenshot a theme
screenshot-themethemes:readArguments
themestringrequired- theme_id from list-themes.
templatestringoptional- Page template name, with or without its extension — index, product, collection, page, page.contact, blog, article, cart, search, 404, … Validated against this theme's own template rows; an unknown name returns the valid list. Defaults to "index".
pagestringoptional- Page slug (e.g. about-us) for a page template. REQUIRED to see real content on the shared `page` template — without it that template previews as an empty stub. Slugs come from list-pages.
productstringoptional- Product slug or id for the product template. Without it the preview picks a RANDOM product, or placeholder data on an empty catalog.
collectionstringoptional- Collection slug or id for the collection template. Without it the preview picks a random collection.
blogstringoptional- Blog handle for the blog or article template. Defaults to the first blog. Handles come from list-blogs.
articlestringoptional- Article handle for the article template. Defaults to the newest article. Handles come from list-articles.
viewportsarray<string>optional- Viewports to capture, at most three. desktop is 1440×900, tablet 768×1024 at 2× DPR, mobile 390×844 at 3× DPR. Duplicates are collapsed. One of: desktop, tablet, mobile. Defaults to ["desktop"].
full_pagebooleanoptional- Capture the whole scrollable page. Set false for just the above-the-fold viewport — faster, but you cannot diff the rest of the page against a mockup. Defaults to true.
delay_msintegeroptional- Extra wait after network idle, in milliseconds. Accepted range 0–15000. Defaults to 2000.
{
"theme": "c40b9e18-77a5-4f36-8d21-2a9f0be4c153",
"template": "index",
"viewports": ["desktop", "mobile"],
"full_page": true
}[image/jpeg, image/jpeg, then a text block:]
{
"screenshots": [
{
"viewport": "desktop",
"width": 1440,
"height": 900,
"actual_width": 1440,
"actual_height": 6820,
"url": "https://cdn.firearmcart.com/mcp-previews/c40b9e18…/index-desktop-1770000000.jpg"
},
{
"viewport": "mobile",
"width": 390,
"height": 844,
"actual_width": 1170,
"actual_height": 28440,
"url": "https://cdn.firearmcart.com/mcp-previews/c40b9e18…/index-mobile-1770000000.jpg",
"inline_omitted": true,
"inline_omitted_reason": "Full-page image exceeds 1.5MB so it could not be returned inline — you cannot see it. Retry this viewport with full_page: false to get an inline above-the-fold capture, or open the url."
}
],
"preview_url": "https://firearmcart.com/themes/preview/c40b9e18…?screenshot=true",
"note": "Screenshot CDN URLs expire after ~7 days. Dimensions include logical viewport and actual image size."
}- **The only tool in this group a `themes:read` token can call.** It does not check write ability and is always registered — but note it is annotated idempotent rather than read-only, unlike the other read tools on this server, because each call re-renders and uploads a fresh capture.
- **The inline JPEG is the point.** MCP hands the image to the model as base64, which is how it sees the render. A bare preview URL is invisible to a model, so the mockup-diff loop depends on the inline image landing.
- An image over 1.5 MB is dropped from the inline payload and the entry says so — retry that viewport with `full_page: false` for an above-the-fold capture you can actually see. Mobile is most at risk, since it is captured at 3× DPR.
- Subjects are team-scoped and a bad handle **fails loudly** (`Page 'x' not found for this team. See list-pages.`) rather than silently falling back to a random one, which would have you reviewing the wrong page and thinking it passed.
- A subject argument that does not apply to the chosen template is an error, not a no-op: ``product`` on a `page` template returns *`product` does not apply to the 'page' template.*
- Two soft warnings ride along in the response instead of failing: the shared `page` template rendered with no `page` slug previews as an EMPTY stub (which reads as a broken theme but is not), and a `product` template with no `product` uses a random one.
- `page.{slug}`-style templates derive their subject from the suffix, so they need no argument.
- If every requested viewport fails, the error still carries `preview_url` plus a note that screenshot rendering is broken on the host, not necessarily the theme — keep editing and open the URL in a browser rather than hunting for a Liquid error. A partial failure returns the captures that worked plus a `failures` list.
- Captures land in `mcp-previews/{theme_uuid}/` on the CDN; URLs expire after roughly 7 days and old shots are pruned, so do not treat them as durable links.
- Library (inactive) themes preview by UUID without being published, which is what makes the duplicate-then-screenshot loop safe.
Update theme file
update-theme-filethemes:writemutatesArguments
themestringrequired- theme_id from list-themes.
pathstringrequired- Theme path to an existing file, e.g. templates/index.json. Unknown paths return `File not found: {path}`.
contentstringrequired- Full replacement content — this is not a patch. The key must be present; an empty string is accepted for most files but is refused on a JSON page template.
allow_activebooleanoptional- Set true ONLY if the user explicitly asked to edit the live (active) theme. Otherwise duplicate-theme and edit the copy. Defaults to false.
allow_chromebooleanoptional- Allow writing GLOBAL chrome (header-group/footer-group and the sections they render — these appear on EVERY page). True when recreating a whole theme or homepage from a mockup, or when the user asked to change the site-wide header/footer. Building ONE secondary page whose mockup shows different chrome: do not set it, ask the user first. Defaults to false.
{
"theme": "8f3c1a72-4d90-4e51-9b2e-6c0a55d1e7bb",
"path": "templates/index.json",
"content": "{\n \"sections\": {\n \"hero\": { \"type\": \"hero-banner\", \"settings\": { \"heading\": \"Shop rifles\" } },\n \"featured\": { \"type\": \"featured-collection\", \"settings\": { \"collection\": \"new-arrivals\" } }\n },\n \"order\": [\"hero\", \"featured\"]\n}"
}{
"saved": true,
"path": "templates/index.json",
"version_number": 7,
"warnings": [
"Kept 2 setting(s) you sent as null, because the merchant has set them in the visual editor: hero.image, featured.background_image. A null in a theme file means \"not specified\", not \"clear it\" …"
],
"note": "Use restore-file-version to revert if needed."
}- Saves are validated before anything is written. Liquid goes through the theme renderer, JSON page templates are decoded after the leading `/* … */` comment header is stripped, and a section or block carrying a `{% schema %}` must have schema JSON that parses. A failure returns *Validation failed, file NOT saved: {parser error}* and the file is untouched — fix and retry.
- Every successful save records a version (label `MCP save`) and returns its `version_number`. `restore-file-version` takes you back.
- Writes to the ACTIVE (published) theme are refused unless `allow_active: true`.
- **Global chrome is gated, not forbidden.** The guard only fires for `sections/*` paths that `header-group` or `footer-group` actually renders. The refusal states both branches and makes you choose: recreating a whole theme or homepage — retry the same call with `allow_chrome: true`, do NOT respond by skipping the header/footer; building one secondary page — stop and ask the user, because this changes every page to match one page's mockup.
- Only text assets are updatable: `css`, `js`, `json`, `svg`, plus `.css.liquid` / `.js.liquid`. Anything else returns *Only text assets (css/js/json/.css.liquid/.js.liquid) can be updated via MCP.*
- A JSON page template's identity is inferred from its **content**, because `template_name` carries no extension. Saving Liquid (or nothing) into `templates/x.json` would silently retype it to `templates/x.liquid` and stop the theme rendering its sections, so that save is refused outright.
- **A `null` you send never erases a merchant's setting.** `read-theme-file` returns the FILE, where an `image_picker` reads `"image": null`, while the merchant's picked value lives in the theme settings. The merge treats `null` as "not specified" and keeps what they set, then tells you which keys it kept. Do not try to force the null through with `""` or `"none"` — you would be deleting a photo you cannot see.
- `warnings` are non-blocking and worth reading: duplicate header/footer, a section that fights `header-group`/`footer-group`, hardcoded nav with no `link_list` setting, a whole page crammed into one section, hand-drawn SVG artwork, a missing mobile hamburger, dead search, a footer that dropped `powered_by_link`, a hardcoded page container, or a font family outside the Google catalogue.
- After a successful save the tool recompiles affected CSS/JS bundles, folds JSON page templates into the theme's live settings, and invalidates theme and preview caches. Failures in those post-save steps surface as `warnings`, not as a failed save.
- Registered only for tokens that hold `themes:write`.
Create theme file
create-theme-filethemes:writemutatesArguments
themestringrequired- theme_id from list-themes.
pathstringrequired- Theme path, e.g. sections/hero-banner.liquid. The first segment must be one of layout, layouts, templates, sections, snippets, blocks or assets.
contentstringoptional- Initial file content. Omit it to create an empty file; when present it is validated before the row is created. Defaults to "".
allow_activebooleanoptional- Set true ONLY if the user explicitly asked to edit the live (active) theme. Otherwise duplicate-theme and edit the copy. Defaults to false.
{
"theme": "8f3c1a72-4d90-4e51-9b2e-6c0a55d1e7bb",
"path": "sections/hero-banner.liquid",
"content": "<section class=\"section section--{{ section.settings.section_width }}\">\n <h1>{{ section.settings.heading }}</h1>\n</section>\n\n{% schema %}\n{\n \"name\": \"Hero banner\",\n \"settings\": [\n { \"type\": \"text\", \"id\": \"heading\", \"label\": \"Heading\", \"default\": \"Shop rifles\" },\n { \"type\": \"select\", \"id\": \"section_width\", \"label\": \"Width\", \"options\": [{ \"value\": \"page-width\", \"label\": \"Page\" }, { \"value\": \"full-width\", \"label\": \"Full\" }], \"default\": \"page-width\" }\n ],\n \"presets\": [{ \"name\": \"Hero banner\" }]\n}\n{% endschema %}"
}{
"created": true,
"path": "sections/hero-banner.liquid"
}- Only the whitelisted top-level directories resolve: `layout/` (`layouts/` is accepted as an alias), `templates/`, `sections/`, `snippets/`, `blocks/`, `assets/`. Anything else — or a path containing `..` — returns *Invalid path. Use theme paths like sections/hero.liquid or templates/index.json.*
- Nesting is allowed only under `templates/` (e.g. `templates/customers/account.json`). A nested section, snippet, block or layout is refused, and `assets/` may never be nested.
- Each path segment must match `[a-zA-Z0-9_-]` plus periods. Templates must end `.liquid` or `.json`; assets must end `.css`, `.css.liquid`, `.js`, `.js.liquid`, `.json` or `.svg`.
- **Raster images cannot be created.** `content` is a string, so `assets/hero.png` is rejected before any write: *Binary assets (png/jpg/webp/fonts) must be uploaded through the theme import or media library, not the file editor.* SVG is the one image format authorable here — and only for icons; name them `icon-*.svg` or the save warns.
- `assets/*.css.liquid` and `assets/*.js.liquid` are stored with `asset_type` css and js respectively, so they compile like their plain counterparts.
- A file that already exists in that category is refused (*A file with this name already exists in this category.* / *An asset with this name already exists.*) — use update-theme-file instead.
- Content is validated before the row is inserted, and if the save fails afterwards the row is deleted, so a failed create never leaves an orphan you must clean up before retrying.
- The first version snapshot is labelled `Baseline` and holds the content **as created**, not the empty row it was inserted as — so restoring to version 1 returns the file to what you first wrote rather than blanking it.
- Writes to the ACTIVE (published) theme are refused unless `allow_active: true`; the error tells you to call duplicate-theme and edit the copy.
- **No `allow_chrome` argument.** Unlike update / delete / restore, this tool does not run the global-chrome gate — creating `sections/header-group.liquid` on a theme that lacks one is not speed-bumped. THEME_MCP.md claims all four file-write tools gate chrome; only three do.
- `warnings` is omitted from the response when empty (the payload is `array_filter`ed). When present it carries the same non-blocking structural warnings as update-theme-file.
- Registered only for tokens that hold `themes:write`. A read-only token does not see this tool in `tools/list` at all.
Delete theme file
delete-theme-filethemes:writemutatesArguments
themestringrequired- theme_id from list-themes.
pathstringrequired- Theme path to an existing file. Unknown paths return `File not found: {path}`.
allow_activebooleanoptional- Set true ONLY if the user explicitly asked to edit the live (active) theme. Otherwise duplicate-theme and edit the copy. Defaults to false.
allow_chromebooleanoptional- Allow deleting GLOBAL chrome (header-group/footer-group and the sections they render — these appear on EVERY page). True only when rebuilding the site-wide header/footer; false when working on one page. Defaults to false.
{
"theme": "8f3c1a72-4d90-4e51-9b2e-6c0a55d1e7bb",
"path": "sections/hero-banner-old.liquid"
}{
"deleted": true,
"path": "sections/hero-banner-old.liquid"
}- The only tool on this server annotated destructive, so an MCP client may prompt before running it.
- **Base theme files cannot be deleted.** A template that still carries `original_content` returns *Cannot delete base theme files. You can only delete files you created (original_content is null).*, and an asset with `is_base_theme` returns the asset equivalent. Only files you or the merchant created are removable.
- Deletion also removes the file's entire version history, so there is nothing left to restore-file-version afterwards. Prefer emptying or superseding a file you are unsure about.
- Writes to the ACTIVE (published) theme are refused unless `allow_active: true`, and the same global-chrome gate as update-theme-file applies to `sections/*` paths a group renders.
- For assets the Spaces object is deleted first, but a storage failure does not abort the delete — the row and its versions still go, and the response is still `deleted: true`.
- Theme caches are invalidated after the delete; a failure there is swallowed rather than reported.
- Registered only for tokens that hold `themes:write`.
Restore a file version
restore-file-versionthemes:writemutatesArguments
themestringrequired- theme_id from list-themes.
pathstringrequired- Theme path to an existing file. Unknown paths return `File not found: {path}`.
version_numberintegerrequired- Version to restore, minimum 1. Get the list from list-file-versions; a number the file does not have returns `Version {n} not found.`
allow_activebooleanoptional- Set true ONLY if the user explicitly asked to edit the live (active) theme. Otherwise duplicate-theme and edit the copy. Defaults to false.
allow_chromebooleanoptional- Allow restoring GLOBAL chrome (header-group/footer-group and the sections they render — these appear on EVERY page). True when rolling back a site-wide header/footer you are rebuilding; false when working on one page. Defaults to false.
{
"theme": "8f3c1a72-4d90-4e51-9b2e-6c0a55d1e7bb",
"path": "sections/hero-banner.liquid",
"version_number": 3
}{
"restored": true,
"path": "sections/hero-banner.liquid",
"restored_from_version": 3,
"version_number": 9,
"warnings": []
}- There is no preview or confirmation step — the restore commits on the call.
- It runs through the same save pipeline as update-theme-file (label `MCP restore`), so the current content is snapshotted as a **new** version first. The restore is therefore itself undoable: the response returns both `restored_from_version` and the new `version_number`.
- Validation still applies. If the old content no longer parses against the current theme, the response is *Restore validation failed, file NOT changed: {parser error}* and nothing moves.
- Version 1 is the `Baseline` — for a file created through create-theme-file that is the content as first written, not an empty file.
- Writes to the ACTIVE (published) theme are refused unless `allow_active: true`, and the same global-chrome gate as update-theme-file applies to `sections/*` paths a group renders.
- `warnings` behaves as on a normal save: restoring old content can re-introduce a structural warning that was fixed since.
- Registered only for tokens that hold `themes:write`.
Create page
create-pagethemes:writemutatesArguments
titlestringrequired- Page title. 3–255 characters.
slugstringoptional- URL slug, slugified before use. Defaults to a slug of the title.
templatestringoptional- Template that renders this page, e.g. `page.about`. Use `page` for the shared default layout. Defaults to page.{slug}.
contentstringoptional- Page body (HTML). Defaults to "" (empty).
statusstringoptional- Whether the page is live on the storefront. One of: published, draft. Defaults to published.
seo_titlestringoptional- SEO title. Max 255 characters.
seo_descriptionstringoptional- SEO meta description. Max 500 characters.
{
"title": "About Us",
"slug": "about",
"template": "page.about",
"content": "<p>Family owned since 1998.</p>",
"status": "published"
}{
"created": true,
"page": {
"slug": "about",
"title": "About Us",
"status": "published",
"url": "/pages/about",
"template": "page.about"
},
"next_step": "Now create templates/page.about.json with create-theme-file. Until it exists, this page renders with the shared templates/page.json."
}- Creating the page is only half the job. The response's `next_step` names the template file to create with `create-theme-file`; until it exists the page renders with the shared `templates/page.json`.
- Slugs are unique per team. An existing slug is refused with “A page with slug '…' already exists. Use update-page to change it.”
- A title that slugifies to nothing (and no explicit `slug`) is refused with “Could not derive a slug from the title. Pass an explicit slug.”
- Store data, not theme content: there is no `allow_active` guard here. The write hits the live storefront immediately, even when you are building inside an inactive draft theme.
- Requires `themes:write`. A token without it never sees this tool in `tools/list` (`shouldRegister()`), and a direct call is refused with “This token lacks themes:write.”
Update page
update-pagethemes:writemutatesArguments
slugstringrequired- Slug of the page to update, from `list-pages`. Slugified before lookup.
themestringoptional- Theme id or UUID. Optional, but pass it: it is the only way the tool can check the target template exists and warn you when it does not.
templatestringoptional- Template that renders this page, e.g. `page.about`. Use `page` for the shared default.
titlestringoptional- New title. 3–255 characters.
contentstringoptional- New page body (HTML). Replaces the existing body.
statusstringoptional- Whether the page is live on the storefront. One of: published, draft.
seo_titlestringoptional- SEO title. Max 255 characters.
seo_descriptionstringoptional- SEO meta description. Max 500 characters.
{
"slug": "contact",
"theme": "b8d2f0c4-7a91-4f3e-9c65-1d0e2a4b7f88",
"template": "page.contact"
}{
"updated": true,
"page": {
"slug": "contact",
"title": "Contact",
"status": "published",
"url": "/pages/contact",
"template": "page.contact"
},
"warnings": [
"templates/page.contact.json does not exist in this theme yet, so the page still renders with the shared templates/page.json. Create it with create-theme-file."
]
}- This is the only way to move a page off the shared `page` template. Writing `templates/page.{slug}.json` alone does nothing — the template is picked by the page's `type`.
- Pass `theme` to get the guardrail. With it, a template that does not exist in that theme yet returns a `warnings` entry rather than letting you believe the new layout is live; without it, the retarget is applied blind.
- A call that carries no updatable field is refused with “Nothing to update. Pass at least one of: template, title, content, status, seo_title, seo_description.”
- An unknown slug is refused with “Page '…' not found for this team. Call list-pages.”
- Not versioned. `list-file-versions` and `restore-file-version` only cover theme files, so this overwrite has no undo.
- Requires `themes:write`. A token without it never sees this tool in `tools/list` (`shouldRegister()`), and a direct call is refused with “This token lacks themes:write.”
Create policy
create-policythemes:writemutatesArguments
namestringrequired- Policy name, e.g. “Privacy Policy”. Becomes the slug and the link text in the footer. 3–255 characters.
contentstringrequired- Policy body (HTML).
slugstringoptional- URL slug override, slugified before use. Defaults to a slug of the name, de-duplicated within the team.
publishedbooleanoptional- `true` = live at /policies/{slug} and listed in `shop.policies`. `false` = draft: the URL 404s and it is hidden from the footer. Defaults to true.
{
"name": "Shipping Policy",
"content": "<h2>Shipping</h2><p>All firearms ship to a licensed FFL dealer of your choosing.</p>",
"published": true
}{
"created": true,
"policy": {
"name": "Shipping Policy",
"slug": "shipping-policy",
"url": "/policies/shipping-policy",
"published": true
},
"note": "Themes reach this through the `shop.policies` drop, and the stock footer already lists it via its `footer-policy-list` block. Do not add a hardcoded link to it in a section."
}- The slug is set once, at creation, and never regenerated on update. Pick it deliberately — a later rename keeps the original URL.
- An explicit `slug` already taken by this team is refused with “A policy with slug '…' already exists for this team. Use update-policy to change it.” An auto-derived slug is de-duplicated instead, gaining a `-2` suffix.
- An explicit `slug` that slugifies to nothing is refused with “The slug given does not reduce to anything usable. Omit it to derive one from the name.”
- Store data, not theme content: there is no `allow_active` guard here. The write hits the live storefront immediately, even when you are building inside an inactive draft theme.
- Requires `themes:write`. A token without it never sees this tool in `tools/list` (`shouldRegister()`), and a direct call is refused with “This token lacks themes:write.”
Update policy
update-policythemes:writemutatesArguments
policystringrequired- Slug of the policy to update, from `list-policies`.
namestringoptional- New name. Does NOT change the slug — the URL stays as it was. 3–255 characters.
contentstringoptional- New body (HTML). Replaces the existing body outright.
publishedbooleanoptional- `true` = live at /policies/{slug} and listed in `shop.policies`. `false` = draft: the URL 404s and it is hidden from the footer.
{
"policy": "privacy-policy",
"name": "Privacy Notice"
}{
"updated": true,
"policy": {
"name": "Privacy Notice",
"slug": "privacy-policy",
"url": "/policies/privacy-policy",
"published": true
},
"warnings": [
"Renamed, but the slug is unchanged by design: this policy still lives at /policies/privacy-policy. Existing links keep working."
]
}- Read the `warnings` array. A rename returns one stating the URL did not move, and unpublishing returns one stating that `/policies/{slug}` now 404s and the policy has left `shop.policies`, so the footer no longer links it.
- `content` replaces the body outright — there is no patch or append mode. Fetch the current text with `list-policies` first if you mean to edit rather than replace.
- A call that carries no updatable field is refused with “Nothing to update. Pass at least one of: name, content, published.”
- An unknown slug is refused with “Policy '…' not found for this team. Call list-policies.”
- Not versioned. `list-file-versions` and `restore-file-version` only cover theme files, so this overwrite has no undo.
- Requires `themes:write`. A token without it never sees this tool in `tools/list` (`shouldRegister()`), and a direct call is refused with “This token lacks themes:write.”
Create blog
create-blogthemes:writemutatesArguments
titlestringrequired- Blog title, e.g. “News”. 2–255 characters.
handlestringoptional- URL handle, max 100 characters. Defaults to a slug of the title; de-duplicated within the team.
{
"title": "News",
"handle": "news"
}{
"created": true,
"blog": {
"title": "News",
"handle": "news",
"url": "/blogs/news"
},
"liquid": "blogs['news']",
"next_step": "Add articles with create-article. The theme renders /blogs/{handle} via templates/blog.json and each post via templates/article.json."
}- Handles are de-duplicated inside the team, including one you pass explicitly — a collision quietly becomes `news-2` rather than erroring, so read the `handle` in the response instead of assuming the one you asked for.
- A title that slugifies to nothing is refused with “Could not derive a handle from that title. Pass an explicit `handle`.”
- Blogs are created active. There is no tool to rename, deactivate or delete one — that is dashboard-only.
- Store data, not theme content: there is no `allow_active` guard here. The write hits the live storefront immediately, even when you are building inside an inactive draft theme.
- Requires `themes:write`. A token without it never sees this tool in `tools/list` (`shouldRegister()`), and a direct call is refused with “This token lacks themes:write.”
Create article
create-articlethemes:writemutatesArguments
titlestringrequired- Article title. Max 255 characters.
contentstringrequired- Article body (HTML). Required — an article cannot be created without it.
blogstringoptional- Blog handle. Defaults to the team's only blog, or creates a “News” blog when the team has none.
handlestringoptional- URL handle, max 100 characters. Defaults to a slug of the title; de-duplicated within the blog.
excerptstringoptional- Short summary. Max 500 characters.
statusstringoptional- Publication state. Only `published` renders on the storefront. One of: draft, published, scheduled. Defaults to draft.
scheduled_atstringoptional- ISO-8601 datetime (UTC). Required, and must be in the future, when `status` is `scheduled`.
author_namestringoptional- Byline. Max 255 characters.
tagsarray<string>optional- Tag NAMES, not ids. Each name is resolved to a team-scoped tag, created if missing.
featured_imagestringoptional- Image URL taken verbatim from `list-media`. Must be one of the team's own uploads.
featured_image_altstringoptional- Alt text for the featured image. Max 255 characters.
meta_titlestringoptional- SEO title. Max 70 characters.
meta_descriptionstringoptional- SEO description. Max 160 characters.
{
"blog": "news",
"title": "Range bag essentials",
"content": "<p>Six things worth carrying, and two that are not.</p>",
"excerpt": "Six things worth carrying, and two that are not.",
"status": "published",
"author_name": "Dana Whitfield",
"tags": ["gear", "beginners"],
"featured_image": "https://cdn.firearmcart.com/media/9812/optics-hero.jpg"
}{
"created": true,
"article": {
"blog": "news",
"title": "Range bag essentials",
"handle": "range-bag-essentials",
"url": "/blogs/news/range-bag-essentials",
"status": "published",
"published_at": "2026-08-11T09:15:00+00:00"
},
"live": true,
"note": null
}- Omitting `blog` is only safe when the team has exactly one. With several, the call is refused with “This team has several blogs (…). Pass `blog` to say which one.” With none, a “News” blog is created silently, mirroring the dashboard.
- The default status is `draft`, so a bare create publishes nothing. The response says so in `note`: “This article is NOT on the storefront yet — only status=published renders.”
- `status: "scheduled"` requires `scheduled_at`, and it must parse as ISO-8601 and be in the future — a past date is refused with “`scheduled_at` must be in the future. To publish now, use status “published”.” A cron flips scheduled articles to published once the date passes.
- Publishing always stamps a date. `published_at` is nullable but the blog listing orders by `published_at DESC`, and the database sorts NULLs first — an undated published article would pin itself to the top of the blog forever.
- `tags` takes tag NAMES, not ids. Missing tags are created, and every tag is resolved inside the authenticated team (the dashboard syncs raw ids without an ownership check; this path deliberately does not).
- `featured_image` must be a URL returned verbatim by `list-media`. Any other URL is refused — the column is a plain string, so an unchecked value would let an article hotlink anything. MCP cannot upload images.
- A title that slugifies to nothing is refused with “Could not derive a handle from that title. Pass an explicit `handle`.”
- Store data, not theme content: there is no `allow_active` guard here. The write hits the live storefront immediately, even when you are building inside an inactive draft theme.
- Requires `themes:write`. A token without it never sees this tool in `tools/list` (`shouldRegister()`), and a direct call is refused with “This token lacks themes:write.”
Update article
update-articlethemes:writemutatesArguments
articlestringrequired- Article handle, from `list-articles`.
blogstringoptional- Blog handle. Required only when the same article handle exists in more than one of the team's blogs.
titlestringoptional- New title. Does NOT change the handle. Max 255 characters.
contentstringoptional- New body (HTML). Replaces the existing body.
excerptstringoptional- Short summary. Max 500 characters.
statusstringoptional- Publication state. Only `published` renders on the storefront. One of: draft, published, scheduled.
scheduled_atstringoptional- ISO-8601 datetime (UTC). Required, and must be in the future, when `status` is `scheduled`.
author_namestringoptional- Byline. Max 255 characters.
tagsarray<string>optional- Tag NAMES. Replaces the existing set outright — an empty array clears every tag.
featured_imagestringoptional- Image URL taken verbatim from `list-media`.
featured_image_altstringoptional- Alt text. Max 255 characters.
meta_titlestringoptional- SEO title. Max 70 characters.
meta_descriptionstringoptional- SEO description. Max 160 characters.
{
"article": "range-bag-essentials",
"blog": "news",
"status": "published"
}{
"updated": true,
"article": {
"blog": "news",
"title": "Range bag essentials",
"handle": "range-bag-essentials",
"url": "/blogs/news/range-bag-essentials",
"status": "published",
"published_at": "2026-08-11T09:15:00+00:00"
},
"live": true
}- Re-publishing preserves the original `published_at` rather than stamping a new one, so toggling an old post back to published does not jump it to the top of the blog.
- An article whose stored `published_at` is in the future cannot be published directly — the call is refused and tells you to use `status: "scheduled"` instead.
- The article is looked up across every blog in the team. A handle that exists in several is refused with “Several blogs have an article '…' (…). Pass `blog` to disambiguate.”
- Changing `title` never changes the handle, so the URL is stable. There is no tool to re-handle or delete an article.
- `status: "scheduled"` requires `scheduled_at`, and it must parse as ISO-8601 and be in the future — a past date is refused with “`scheduled_at` must be in the future. To publish now, use status “published”.” A cron flips scheduled articles to published once the date passes.
- Publishing always stamps a date. `published_at` is nullable but the blog listing orders by `published_at DESC`, and the database sorts NULLs first — an undated published article would pin itself to the top of the blog forever.
- `tags` takes tag NAMES, not ids. Missing tags are created, and every tag is resolved inside the authenticated team (the dashboard syncs raw ids without an ownership check; this path deliberately does not).
- `featured_image` must be a URL returned verbatim by `list-media`. Any other URL is refused — the column is a plain string, so an unchecked value would let an article hotlink anything. MCP cannot upload images.
- A call that carries no updatable field (and no `tags`) is refused with “Nothing to update. Pass at least one field.”
- Not versioned. `list-file-versions` and `restore-file-version` only cover theme files, so this overwrite has no undo.
- Requires `themes:write`. A token without it never sees this tool in `tools/list` (`shouldRegister()`), and a direct call is refused with “This token lacks themes:write.”
Set collection filters
set-collection-filtersthemes:writemutatesArguments
themestringrequired- theme_id from list-themes. The allowlist is stored per theme, so a library copy and the live theme each carry their own.
filtersarray<string>optional- Group names from list-collection-filters. Replaces the whole allowlist. Pass this or clear, not both.
clearbooleanoptional- Drop the allowlist so every group with 2+ values renders again. Pass this or filters, not both. Defaults to false.
allow_activebooleanoptional- Set true ONLY if the user explicitly asked to change the filters on the live (active) theme. Defaults to false.
{
"theme": "9f1c0b2e-7d44-4a1e-9a52-3b8c6d0f1a77",
"filters": ["Manufacturer", "caliber", "Action", "Capacity", "Barrel Length", "Finish", "Bullet Type", "Rounds Per Box"],
"allow_active": true
}{
"saved": true,
"theme": {
"theme_id": "9f1c0b2e-7d44-4a1e-9a52-3b8c6d0f1a77",
"name": "Arsenal",
"active": true
},
"filters": ["Manufacturer", "Caliber / Gauge", "Action", "Capacity", "Barrel Length", "Finish", "Bullet Type", "Rounds Per Box"],
"previous_filters": [],
"warnings": [],
"note": "Collection pages now show only these groups, plus Availability, Price, Sale, Product type and In Store. Storefront caches were cleared. To undo, call set-collection-filters with previous_filters, or clear: true if previous_filters is empty."
}- Requires `themes:write`. A token without it never sees this tool in `tools/list`, and a direct call is refused with “This token lacks themes:write.”
- It REPLACES the list. There is no add or remove: send the complete set every time, or you will silently drop the groups you left out.
- Names match case-insensitively and are stored as the catalog spells them, so “manufacturer” saves as “Manufacturer”. When a catalog carries several capitalizations of one name, every one of them is selected. Any caliber-type spelling — Caliber, Gauge, Caliber/Gauge — selects the merged “Caliber / Gauge” filter.
- One unknown name fails the whole call, and the valid names beside it are not saved either: “Nothing saved. Not a specification group in this catalog: 'Brand' (did you mean: Brand Fit, Sub Brand?). Call list-collection-filters (with search) for the exact names.”
- Exactly one of filters or clear is required. Both, or neither, is refused with “Pass either filters (one or more group names from list-collection-filters) or clear: true, not both and not neither.”
- The active theme is refused unless allow_active: true, with the same message the file-write tools use. Unlike a page or policy write, this is a theme setting, so working on an inactive copy and publishing it later is a real option.
- Not versioned — list-file-versions and restore-file-version do not cover settings. previous_filters in the response is the undo: send it back, or clear: true when it is empty.
- A group with fewer than 2 values is saved but reported in warnings, because it never renders.
- Re-sending the same set, in any order, returns saved: false and writes nothing. Order is never significant: the storefront lists filter groups alphabetically.
- The save goes through the same path as a Visual Editor Save: the theme's settings are updated, its compiled assets and page and section caches are invalidated, and a cache warm with a Cloudflare purge is queued. The purge is asynchronous, so edge-cached pages can show the old filters for a short window.
- This writes the same value as the Visual Editor's Theme settings → Collection Filters picker. A merchant editing that picker afterwards will see your selection, and can overwrite it.
- Themes are resolved inside the authenticated team only. A theme id belonging to another team is indistinguishable from a non-existent one: both return “Theme not found for this team. Call list-themes first.”
Add theme from store
add-theme-from-storethemes:writemutatesArguments
slugstringrequired- The catalog slug, exactly as list-theme-store returns it in each entry's slug field (for example arsenal, horizon, tactix). This is the store slug, not a theme name and not a theme_id.
{
"slug": "arsenal"
}{
"added": true,
"theme_id": "6f5a1c2e-9d47-4b0a-8f31-2c7e5b90ad14",
"name": "Arsenal",
"active": false,
"template_source": "arsenal",
"note": "Theme added as an inactive library copy. Use publish-theme when ready to make it live."
}- Only catalog entries flagged active are installable. An unknown slug, or one whose catalog entry is inactive, is refused with "Theme store slug not found or inactive: {slug}". Call list-theme-store first — it lists only active entries, so anything it returns is installable.
- Paid themes cannot be bought over MCP. If the entry is not free and the team has no matching purchase record, the tool refuses with "This is a paid theme the team has not purchased. Purchases must be made in the dashboard at /store/themes/store." list-theme-store tells you this in advance through its is_free and purchased flags.
- Nothing dedupes by catalog slug. Calling this twice with the same slug produces two separate library themes with the same template_source, both named the same. list-theme-store's in_library flag is the only signal that you already have one; check it before installing.
- The new theme is always inactive — this tool never touches the live storefront. Call publish-theme when the merchant is ready.
- Expect this call to take tens of seconds. Asset upload, CSS/JS compilation and .css.liquid / .js.liquid processing all happen inline before the response is written, so a short MCP client timeout can fire while the install is still running and succeeding. Re-issuing after a timeout adds a second copy (see above) — call list-themes to check before retrying.
- On a purchased PAID theme the created theme is named "{Slug} Theme" (for example "Reformation Theme") with base_version 1.0.0, not the catalog's name and version. The install re-reads the catalog without the paid flag, so the metadata lookup comes back empty and the fallback names are used. Rename it in the dashboard, or via the Themes index, if it matters.
- Any failure returns "Failed to add theme: {message}" and the exception is reported to error tracking. The database work is transactional and rolls back, but files already uploaded to object storage are not removed, so a failed install can leave orphaned asset objects behind.
- A cache warm is queued for the new theme on the default queue connection. Nothing about the storefront changes, since the theme is not active.
Duplicate theme
duplicate-themethemes:writemutatesArguments
themestringrequired- The source theme's theme_id, from list-themes.
namestringoptional- Name for the copy. Trimmed, and limited to 255 characters — a longer value is a validation error. Defaults to "{source name} (Copy)".
{
"theme": "6f5a1c2e-9d47-4b0a-8f31-2c7e5b90ad14",
"name": "Arsenal — homepage rebuild"
}{
"duplicated": true,
"source": {
"theme_id": "6f5a1c2e-9d47-4b0a-8f31-2c7e5b90ad14",
"name": "Arsenal",
"active": true
},
"theme": {
"theme_id": "b3d90f77-1a54-42c8-9e6b-70f1c8a4d221",
"name": "Arsenal — homepage rebuild",
"active": false,
"template_source": "arsenal",
"base_version": "15.4.1"
},
"note": "Inactive library copy created. Edit this theme freely; publish-theme when ready to make it the live storefront."
}- Duplicating the ACTIVE published theme is allowed, and is the intended way to get a workspace. The active-theme refusal applies to the file-write tools, not to this one: duplicate first, edit the copy, then publish it.
- The copy is always created inactive, whatever the source's state.
- theme is resolved against the authenticated team only. An id belonging to another team reads as missing and returns "Theme not found for this team. Call list-themes first."; an empty value returns "Missing theme argument. Call list-themes first." Values are matched against theme_id.
- A blank or whitespace-only name is treated as absent and falls back to "{source name} (Copy)". Names are not unique — duplicating twice without a name yields two themes with identical names, distinguishable only by id.
- Asset copying is best-effort. If an asset's file is missing from object storage, the copy's asset row keeps the ORIGINAL storage_path and public_url instead of an isolated one, so that asset is then shared with the source theme. The failure is reported to error tracking, not returned to you.
- Media re-homing runs after the transaction commits and is also best-effort: if it fails, the duplicate still succeeds and the response says nothing about it.
- Carried over: template_source, base_version, is_customized and the theme settings blob. Reset: active, and all dev-mode state.
- Not idempotent. Every call creates another theme, so a retry after a client timeout leaves you with two copies — check list-themes before retrying.
- A cache warm is queued for the copy, so its preview URL is usable shortly after the call returns.
Publish theme
publish-themethemes:writemutatesArguments
themestringrequired- The theme_id of the theme to publish, from list-themes.
{
"theme": "b3d90f77-1a54-42c8-9e6b-70f1c8a4d221"
}{
"published": true,
"already_active": false,
"theme": {
"theme_id": "b3d90f77-1a54-42c8-9e6b-70f1c8a4d221",
"name": "Arsenal — homepage rebuild",
"active": true
},
"previous_theme": {
"theme_id": "6f5a1c2e-9d47-4b0a-8f31-2c7e5b90ad14",
"name": "Arsenal",
"active": false
},
"note": "Theme is now the live storefront. The previous active theme (if any) was deactivated automatically."
}- Publishing a theme that is already active is a no-op: the response carries already_active: true, previous_theme: null and "Theme was already the published storefront; no changes made." No caches are cleared and no warm is queued, so this is not a way to force a cache refresh.
- previous_theme is the theme this call deactivated, captured before the switch. It is null when the team had no active theme, and when the target was already active.
- There is no unpublish tool. The only way to change the live storefront is to publish a different theme; you cannot leave a team with no active theme over MCP.
- Nothing validates the theme before it goes live. Publish does not render a page, check for missing snippets or run the save-time Liquid checks — a theme that has never been screenshotted can be published broken. Run screenshot-theme at desktop, tablet and mobile first.
- Cache clearing happens after the transaction commits, and covers the team's UUID storefront plus every active custom domain. The Cloudflare purge rides on a queued cache-warm job (redis-long connection, theme-cache queue), so it is asynchronous: for a short window after this returns, edge-cached HTML from the old theme can still be served.
- theme is resolved against the authenticated team only, exactly as in duplicate-theme, and returns the same "Theme not found for this team." and "Missing theme argument." errors.
- If the publish itself throws, the tool returns "Failed to publish theme: {message}" and reports the exception. The activation is transactional, so a failure leaves the previously active theme live.

