Skip to content

Tools

All 33 tools the Theme Dev MCP server exposes — arguments, guardrails, and which ability each one needs

Updated View as Markdown

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

  • theme accepts either identifier. The numeric row id or the theme_id UUID both resolve. Get them from list-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:

  1. Validates. Liquid, JSON and {% schema %} are parsed. A failure returns the parser error and nothing is written — fix and retry.
  2. Versions. The previous content is snapshotted first, so list-file-versions plus restore-file-version is always available as an undo.
  3. 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 into header-group, a <nav> with no link_list setting, several page regions crammed into one section file, hand-drawn SVG artwork standing in for a photo, a hardcoded font-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
Lists every theme in the authenticated team's library. The row with active=true is the published storefront; the rest are inactive library copies you can safely edit. This is the entry point for the whole server — every other theme tool needs an id from here.
Takes no arguments.
Resultjson
{
  "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"
    }
  ]
}
Caveats
  • 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
Lists the active themes in the FirearmCart theme store, each annotated with whether this team has purchased it and whether a copy is already in their library. Use it to find the slug that add-theme-from-store installs.
Takes no arguments.
Resultjson
{
  "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
    }
  ]
}
Caveats
  • 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:read
Lists a theme's files as theme paths, grouped by type (layout/, templates/, sections/, snippets/, blocks/, assets/). Run it before writing anything: it is the only way to confirm a snippet or asset you want to {% render %} actually exists.

Arguments

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.
Example argumentsjson
{
  "theme": "418",
  "type": "section"
}
Resultjson
{
  "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
      }
    ]
  }
}
Caveats
  • 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:read
Returns the contents of one theme file addressed by its theme path, optionally at a historical version number. The response is plain text, not JSON.

Arguments

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).
Example argumentsjson
{
  "theme": "418",
  "path": "sections/hero-banner.liquid",
  "version": 3
}
Resulttext
# 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 %}
Caveats
  • 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:read
Searches template contents — and optionally text assets — for a string or regex, returning path, line number and the matching line. The fastest way to find which section renders a snippet or defines a CSS class.

Arguments

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.
Example argumentsjson
{
  "theme": "418",
  "query": "render 'header-drawer'",
  "include_assets": false,
  "max_results": 20
}
Resultjson
{
  "matches": [
    {
      "path": "sections/header.liquid",
      "line": 142,
      "text": "      {% render 'header-drawer', linklist: section.settings.menu %}"
    }
  ],
  "count": 1,
  "truncated": false
}
Caveats
  • 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:read
Returns a section's parsed {% schema %} — its settings, blocks and presets — or, with `section` omitted, a summary of every section in the theme. It also covers plugin-provided sections (fc-*), which are not theme files but can be referenced by type from a page template.

Arguments

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.
Example argumentsjson
{
  "theme": "418",
  "section": "sections/hero-banner.liquid"
}
Resultjson
{
  "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
}
Caveats
  • 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:read
Reads a theme's configuration in one of three scopes: the settings_schema definition, the merchant's saved global values, or the effective JSON for one page template. Theme settings are read-only over MCP, with one exception: the collection filter allowlist, which set-collection-filters writes.

Arguments

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.
Example argumentsjson
{
  "theme": "418",
  "scope": "page_template",
  "template": "index"
}
Resultjson
{
  "template": "index",
  "source": "themes.settings",
  "data": {
    "sections": {
      "hero": {
        "type": "hero-banner",
        "settings": { "heading": "Shop rifles", "image": null }
      }
    },
    "order": ["hero"]
  }
}
Caveats
  • 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:read
Lists the version history of one theme file, newest first, with who saved it and whether each version still matches the live content. Pair it with restore-file-version to undo a change that made things worse.

Arguments

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.
Example argumentsjson
{
  "theme": "418",
  "path": "sections/hero-banner.liquid"
}
Resultjson
{
  "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
    }
  ]
}
Caveats
  • 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
Lists every web font a theme can load, with the exact font_picker handle for each family and weight. FirearmCart hosts no font library of its own — fonts come from Google Fonts and only these families resolve. Call it before choosing a typeface from a mockup.
Takes no arguments.
Resultjson
{
  "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": "…"
  }
}
Caveats
  • 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:read
List images the merchant has uploaded to their media library, with CDN URLs usable directly in Liquid or as image_picker values in templates/*.json. MCP cannot upload images, so this is the primary source of real photography when recreating a mockup.

Arguments

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.
Example argumentsjson
{
  "search": "hero",
  "limit": 20
}
Resultjson
{
  "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."
}
Caveats
  • 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 menus

list-menusthemes:read
List the merchant's navigation menus with their handles and nested items. Storefront navigation is merchant-managed data, not theme content — bind a `link_list` setting to a handle instead of hardcoding links in Liquid.

Arguments

handlestringoptional
Return only this menu handle. Omit for every menu.
Example argumentsjson
{
  "handle": "main-menu"
}
Resultjson
{
  "menus": [
    {
      "handle": "main-menu",
      "name": "Main menu",
      "is_active": true,
      "links": [
        { "title": "Shop", "url": "/collections/all" },
        {
          "title": "Firearms",
          "url": "/collections/firearms",
          "links": [
            { "title": "Handguns", "url": "/collections/handguns" },
            { "title": "Rifles", "url": "/collections/rifles" }
          ]
        }
      ]
    }
  ],
  "aliases": {
    "main-menu": "Always resolves to the ACTIVE menu. Use this as the default for a header link_list setting.",
    "main": "Alias of main-menu."
  },
  "usage": "… how to declare a link_list setting and loop section.settings.menu.links …"
}
Caveats
  • `main-menu` (and its alias `main`) always resolves to the ACTIVE menu, which makes it the right `default` for a header `link_list` setting.
  • Declare `{"type": "link_list", "id": "menu", "label": "Menu", "default": "main-menu"}` in the section schema, then loop `section.settings.menu.links` — each link has `title`, `url`, and `links` when it has children.
  • The stored value is a handle string that the render layer swaps for a menu object, so `linklists[section.settings.menu]` double-resolves and yields nothing. Use the setting directly.
  • Read-only over MCP. Menus live in team settings under `navigation_menus` and are edited in Navigation settings — there is no create or update tool.
  • Never copy nav labels out of a mockup screenshot. The merchant owns this list.

List collection filters

list-collection-filtersthemes:read
Lists every specification group the store's catalog can show as a filter on collection pages, with the evidence for choosing which belong in the theme's collection filter allowlist: how many products carry each group, how many checkbox values it renders, its most common values, and the product types it concentrates in. Pair it with set-collection-filters.

Arguments

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.
Example argumentsjson
{
  "theme": "9f1c0b2e-7d44-4a1e-9a52-3b8c6d0f1a77"
}
Resultjson
{
  "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": ["…"]
}
Caveats
  • 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:read
List the merchant's content pages (about, contact, …) and which template actually renders each one. Call it before editing a secondary page: a page gets its own look from templates/page.{slug}.json, never from editing the shared page.json or the header and footer.

Arguments

themestringrequired
`theme_id` from `list-themes`. Required because the answer depends on which templates that theme actually has.
Example argumentsjson
{
  "theme": "b8d2f0c4-7a91-4f3e-9c65-1d0e2a4b7f88"
}
Resultjson
{
  "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 …"
}
Caveats
  • 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:read
List the store policies (privacy, terms, refund, shipping, …) that feed the `shop.policies` drop. Bodies can be long, so this returns a preview by default — pass `policy` to get one policy in full.

Arguments

policystringoptional
Slug of a single policy. Returns its full `content` instead of a preview. Omit to list them all.
Example argumentsjson
{}
Resultjson
{
  "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 …"
}
Caveats
  • 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:read
Lists the plugins that open theme/Liquid surface — their sections, drops and form endpoints — and whether each is active for this team. Plugin features (reviews, events, subscriptions, back-in-stock) are only buildable when the plugin is ACTIVE, so call this before implementing any of them.

Arguments

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.
Example argumentsjson
{
  "liquid_only": true
}
Resultjson
{
  "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": "…" }
}
Caveats
  • 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
List the merchant's storefront blogs with handles and article counts. In Liquid the global `blogs` map is keyed by HANDLE, so these handles are exactly what a theme references. Use `list-articles` for a blog's posts.
Takes no arguments.
Example argumentsjson
{}
Resultjson
{
  "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
}
Caveats
  • `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:read
List blog articles (posts) for the team, newest first. Article bodies are omitted — only the excerpt and metadata come back.

Arguments

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.
Example argumentsjson
{
  "blog": "news",
  "status": "published",
  "limit": 20
}
Resultjson
{
  "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."
}
Caveats
  • 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:read
Screenshot a theme preview template at one or more viewports (desktop/tablet/mobile). Captures the FULL scrollable page by default. Runs synchronously (multi-viewport is slow). Returns inline JPEG images plus CDN URLs that expire after ~7 days. Prefer only the viewports needed per iteration.

Arguments

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.
Example argumentsjson
{
  "theme": "c40b9e18-77a5-4f36-8d21-2a9f0be4c153",
  "template": "index",
  "viewports": ["desktop", "mobile"],
  "full_page": true
}
Resulttext
[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."
}
Caveats
  • **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:writemutates
Update an existing theme file. Content is validated (Liquid/JSON/schema), versioned, and cache-invalidated. Use restore-file-version to revert. Refuses the active (published) theme unless allow_active=true.

Arguments

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.
Example argumentsjson
{
  "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}"
}
Resultjson
{
  "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."
}
Caveats
  • 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:writemutates
Create a new theme file at a theme path. Directory whitelist is enforced server-side. Optional content is validated on create. Refuses the active (published) theme unless allow_active=true.

Arguments

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.
Example argumentsjson
{
  "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 %}"
}
Resultjson
{
  "created": true,
  "path": "sections/hero-banner.liquid"
}
Caveats
  • 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:writemutates
Delete a user-created theme file. Base theme files (original_content set / is_base_theme assets) cannot be deleted. Refuses the active (published) theme unless allow_active=true.

Arguments

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.
Example argumentsjson
{
  "theme": "8f3c1a72-4d90-4e51-9b2e-6c0a55d1e7bb",
  "path": "sections/hero-banner-old.liquid"
}
Resultjson
{
  "deleted": true,
  "path": "sections/hero-banner-old.liquid"
}
Caveats
  • 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:writemutates
Restore a file to a previous version_number. Commits immediately (snapshots current content first). Validation still applies. Refuses the active (published) theme unless allow_active=true.

Arguments

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.
Example argumentsjson
{
  "theme": "8f3c1a72-4d90-4e51-9b2e-6c0a55d1e7bb",
  "path": "sections/hero-banner.liquid",
  "version_number": 3
}
Resultjson
{
  "restored": true,
  "path": "sections/hero-banner.liquid",
  "restored_from_version": 3,
  "version_number": 9,
  "warnings": []
}
Caveats
  • 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:writemutates
Create a content page (about, FAQ, …) served at /pages/{slug}. The page's `template` names the theme template that renders it, so create that template too or the page falls back to the shared page.json.

Arguments

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.
Example argumentsjson
{
  "title": "About Us",
  "slug": "about",
  "template": "page.about",
  "content": "<p>Family owned since 1998.</p>",
  "status": "published"
}
Resultjson
{
  "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."
}
Caveats
  • 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:writemutates
Update a content page — most importantly, point it at a different template. A page renders with templates/{template}.json, so a page stuck on the shared `page` template needs its template changed here before a dedicated layout takes effect.

Arguments

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.
Example argumentsjson
{
  "slug": "contact",
  "theme": "b8d2f0c4-7a91-4f3e-9c65-1d0e2a4b7f88",
  "template": "page.contact"
}
Resultjson
{
  "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."
  ]
}
Caveats
  • 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:writemutates
Create a store policy (privacy, terms, refund, shipping, …) served at /policies/{slug} and exposed to themes as `shop.policies`. The slug is derived from the name and is permanent — renaming later will not change it.

Arguments

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.
Example argumentsjson
{
  "name": "Shipping Policy",
  "content": "<h2>Shipping</h2><p>All firearms ship to a licensed FFL dealer of your choosing.</p>",
  "published": true
}
Resultjson
{
  "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."
}
Caveats
  • 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:writemutates
Update a store policy: rewrite its body, rename it, or publish and unpublish it. The slug never changes, so /policies/{slug} keeps working after a rename.

Arguments

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.
Example argumentsjson
{
  "policy": "privacy-policy",
  "name": "Privacy Notice"
}
Resultjson
{
  "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."
  ]
}
Caveats
  • 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:writemutates
Create a storefront blog — a container for articles — at /blogs/{handle}. Most stores need only one, so check `list-blogs` first. In Liquid it is reachable as blogs['{handle}'].

Arguments

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.
Example argumentsjson
{
  "title": "News",
  "handle": "news"
}
Resultjson
{
  "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."
}
Caveats
  • 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:writemutates
Create a blog article (post). `content` is HTML and is required. Only status=published renders on the storefront; scheduled needs a future `scheduled_at`.

Arguments

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.
Example argumentsjson
{
  "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"
}
Resultjson
{
  "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
}
Caveats
  • 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:writemutates
Update a blog article: edit its body, or publish, unpublish and schedule it. Publishing always stamps a date, because an undated published article would pin itself to the top of the blog.

Arguments

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.
Example argumentsjson
{
  "article": "range-bag-essentials",
  "blog": "news",
  "status": "published"
}
Resultjson
{
  "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
}
Caveats
  • 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:writemutates
Sets which specification groups render as filters on collection pages, replacing the theme's current allowlist, or clears it so every group renders again. Names are validated against the catalog before anything is written, and the change is live on the next page load.

Arguments

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.
Example argumentsjson
{
  "theme": "9f1c0b2e-7d44-4a1e-9a52-3b8c6d0f1a77",
  "filters": ["Manufacturer", "caliber", "Action", "Capacity", "Barrel Length", "Finish", "Bullet Type", "Rounds Per Box"],
  "allow_active": true
}
Resultjson
{
  "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."
}
Caveats
  • 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:writemutates
Installs a theme from the FirearmCart theme store catalog into the team's library as a new inactive copy. Paid themes must already have been purchased in the dashboard. The call is synchronous and slow: it copies every template, uploads the base theme's assets to object storage, and compiles CSS and JS before returning.

Arguments

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.
Example argumentsjson
{
  "slug": "arsenal"
}
Resultjson
{
  "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."
}
Caveats
  • 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:writemutates
Clones a theme into a new inactive library copy — the safe workspace every editing session should start from. Copies templates, theme settings rows, locales and assets, isolating each asset file into the copy's own storage folder so edits can never reach the original. Never publishes.

Arguments

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)".
Example argumentsjson
{
  "theme": "6f5a1c2e-9d47-4b0a-8f31-2c7e5b90ad14",
  "name": "Arsenal — homepage rebuild"
}
Resultjson
{
  "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."
}
Caveats
  • 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:writemutates
Makes a library theme the live storefront. A team can have only one published theme, so the previously active one is deactivated in the same transaction. Storefront caches are cleared and a cache warm with a Cloudflare purge is queued. Identical to Themes → Publish in the dashboard.

Arguments

themestringrequired
The theme_id of the theme to publish, from list-themes.
Example argumentsjson
{
  "theme": "b3d90f77-1a54-42c8-9e6b-70f1c8a4d221"
}
Resultjson
{
  "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."
}
Caveats
  • 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.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close