Configuring Crafty via the MCP server

Use this when an agent should author and configure content in a Crafty tenant: sites, pages/blocks, collections/entries, forms, media, and publishing.

Server URL

The MCP endpoint is per-tenant and lives at the /mcp path on the tenant's own domain:

https://<your-tenant-domain>/mcp

The tenant is identified by the request host, so each tenant has its own endpoint. Hitting the wrong host fails to resolve the tenant. Every request must be authenticated (Bearer token).

Authentication

Two families, both carrying a Bearer token on every request:

Personal Access Token (PAT) — simplest

Generate a long-lived Personal Access Token from your Crafty profile, choosing the scopes you need, then paste it into the editor config as an Authorization: Bearer … header. Best for CLIs and local setups. Regenerate and update the config if it stops working.

OAuth 2.0 — via an MCP client

Create an "MCP client" in your Crafty profile and pick a grant type:

  • Authorization Code — standard browser-redirect flow for tools that open a browser (Claude.ai, Claude Code, Codex, Cursor, VS Code, ChatGPT). Select the redirect URI matching your tool. Most of these register themselves (see below), so a manual client is only needed when the tool asks for a client_id.
  • Device Code (RFC 8628) — for clients that can't open a browser and support this grant; the client shows a code that you approve in the browser.

When you create the client manually in your profile you are shown the client_id and a one-time client_secret — copy it immediately (PATs likewise show the token once). Clients that auto-register through discovery (e.g. Claude.ai, Claude Code, Codex, Cursor, ChatGPT) are registered as public clients and receive no client secret.

OAuth clients that support MCP auto-discovery (Claude.ai, Claude Code, Codex, Cursor, ChatGPT) can find everything from the server URL alone: discovery is published at /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server, with authorization_endpoint /oauth/authorize, token_endpoint /oauth/token, dynamic registration_endpoint /oauth/register, PKCE (S256), and grants authorization_code + refresh_token. Registered redirect URIs must be https, a custom app scheme, or http on a loopback address (so local CLI callbacks work). These OAuth endpoints live at the tenant root, not under /mcp.

Clients that implement the MCP 2026-07-28 authorization spec can skip registration and use a Client ID Metadata Document (CIMD): the client_id is an https URL serving the client's metadata as JSON (client_id equal to that URL, redirect_uris, optional client_name). Crafty advertises client_id_metadata_document_supported: true, fetches the document on /oauth/authorize (public addresses only, no redirects, 5 KB max, cached per its Cache-Control max-age), and requires the redirect_uri to be listed in it. Only public clients (token_endpoint_auth_method: "none") are accepted this way. Dynamic registration keeps working for clients that still use it.

Scopes

When you create a token (PAT or OAuth client) you grant one or more scopes. Scopes are enforced per tool: a tool is only advertised when your token carries its required scope, and invoking one without the scope fails with an MCP error (Insufficient OAuth scope). Grant the scopes the agent needs:

Scope Unlocks Tools
crafty:read Read sites, pages, block types, collections, entries, media, and form definitions all list_* / get_* except form submissions
crafty:write Create, update and delete sites, pages, collections, entries, media, and forms; configure the visual editor, llms.txt, and the API cache all create_* / update_* / delete_*, upload_media, configure_visual_editor, configure_llms, flush_api_cache, set_api_cache_status
crafty:publish Publish, unpublish and schedule pages, entries and forms publish_page, unpublish_page, publish_entry, unpublish_entry, schedule_publication, publish_form, unpublish_form
crafty:submissions Read form submissions (may contain personal data) list_form_submissions, get_form_submission

Request all four for full authoring plus submission access. Scopes are also advertised in OAuth discovery; the /mcp endpoint additionally requires a valid authenticated token on every request.

Connecting each client

Replace https://<tenant>/mcp with your tenant URL and YOUR_PAT with a Personal Access Token.

Claude.ai (Authorization Code): go to Customize → Connectors → + → Add custom connector, enter the server URL, click Connect and approve in the browser (on Team/Enterprise an Owner adds it first under Organization settings → Connectors). It auto-registers; to use your own client instead, create an Authorization Code MCP client with redirect URI https://claude.ai/api/mcp/auth_callback and paste its client_id/client_secret in the connector's Advanced settings.

Claude Code (CLI):

# Option A — PAT
claude mcp add --transport http crafty https://<tenant>/mcp \
  --header "Authorization: Bearer YOUR_PAT"

# Option B — OAuth in the browser (auto-registers)
claude mcp add --transport http crafty https://<tenant>/mcp
# then, inside Claude Code: /mcp → crafty → Authenticate

Cursor (Authorization Code): add to ~/.cursor/mcp.json and authorize when prompted (it auto-registers; for your own client use redirect URI cursor://anysphere.cursor-deeplink/mcp/auth_callback):

{ "mcpServers": { "crafty": { "url": "https://<tenant>/mcp" } } }

VS Code (GitHub Copilot, PAT): add to .vscode/mcp.json, then open Copilot Chat → Agent mode and select the Crafty tools. (OAuth alternative: Authorization Code client with redirect URI vscode://github.copilot-chat/mcp/auth_callback.)

{ "servers": { "crafty": { "type": "http", "url": "https://<tenant>/mcp",
  "headers": { "Authorization": "Bearer YOUR_PAT" } } } }

Codex CLI (writes ~/.codex/config.toml):

# Option A — PAT, read from an environment variable
export CRAFTY_MCP_TOKEN="YOUR_PAT"
codex mcp add crafty --url https://<tenant>/mcp --bearer-token-env-var CRAFTY_MCP_TOKEN

# Option B — OAuth in the browser (auto-registers)
codex mcp add crafty --url https://<tenant>/mcp
codex mcp login crafty

ChatGPT (Authorization Code; Plus, Pro, Business, Enterprise, Education, web): turn on Developer mode in Settings → Apps & Connectors → Advanced settings, click Create, paste the server URL, choose OAuth, and approve in the Crafty login. It auto-registers.

Tool catalog

All tools return a JSON text response. IDs are integers; slugs are strings. Pagination defaults to 25 per page (per_page max 100 where supported).

Read (crafty:read)

Tool Required Optional Returns
list_sites — — all sites (id, name, slug, domain, is_active)
get_site site_id — one site
list_pages site_id status (draft/published) paginated pages, ordered by title
get_page page_id — page with its blocks (incl. block types)
list_block_types — — block types and their schemas (not filtered by site)
get_block_type site_id, and block_type_id or slug — one block type of the site with its full schema (field_definitions)
list_collections — site_id active collections (with their id, used by update_collection / delete_collection)
list_entries collection_slug site_id, status, q, filter, per_page paginated entries (newest id first), each with status and published_at; filter works as in the public API (see below)
get_entry entry_id — one entry with its collection
list_media site_id collection, per_page paginated media for the site; each item has alt (null when missing)
list_forms — site_id, status, per_page paginated forms, ordered by name
get_form form_id — one form with its field definitions
list_redirects site_id environment (default production) the environment's redirects { source, destination, status_code }
get_assistant_settings site_id environment (default production) the site assistant's settings, status (mode assistant/whatsapp/off, blocked_reason server/plan/disabled/quota, this month's usage), answers_count and the allowed options
list_assistant_answers site_id environment (default production) the assistant's private fixed answers { id, question, answer, author, updated_at }, newest first
list_webhooks site_id environment (default production) content webhooks (admins only) with their events and last deliveries; upsert_webhook / delete_webhook manage them (crafty:write)

Filtering entries by field value

list_entries (MCP) and GET /api/v1/collections/{slug}/entries (public API, crafty.entries.list(slug, { filter }) in the SDK) take the same filter, keyed by field name. A bare value means eq:

{ "precio": { "gte": 150000, "lte": 300000 }, "tipo": "apartamento", "sector": { "in": ["piantini", "naco"] }, "amenidades": { "contains": "piscina" } }

In a URL: ?filter[precio][gte]=150000&filter[tipo]=apartamento&filter[sector][in]=piantini,naco.

Field type Operators
number, decimal eq, gt, gte, lt, lte (numeric)
datetime eq, gt, gte, lt, lte (ISO 8601 text, e.g. 2026-12-01 or 2026-12-01T10:00)
boolean eq (true/false/1/0; false also matches entries without the field)
string, url, select, reference eq, in (reference by id)
multiple select, multiReference, stringList contains
text, longText, repeater, json not filterable (use q for text)

An unknown field, a non-filterable type or a disallowed operator is a 422 (an error from the MCP tool). At most 10 conditions per request and 50 values per in. Filters combine with q, sorting, pagination, locale and preview; the public API still returns only published entries. When designing a collection that visitors will filter (a catalog, a directory), give the filterable attributes their own typed fields (number for price, select for type) rather than burying them in text.

Submissions (crafty:submissions)

Form submissions may contain personal data, so they require their own scope.

Tool Required Optional Returns
list_form_submissions form_slug site_id, per_page paginated submissions, newest first
get_form_submission form_slug, submission_id site_id one submission with its data

Write (crafty:write)

Tool Required Optional Notes
flush_api_cache site_id resource flushes cached public API responses for the site; resource = pages, entries, collections, media, or all (default all)
set_api_cache_status site_id, enabled — enables (true) or disables (false) the public API response cache for the site; per-site override, preserves other cache config (TTLs, bypass rules)
create_site name slug slug auto-generated from name; returns the site (with a generated api_token)
create_block_type site_id, name, fields slug, description fields: [{ key, type, label?, required?, model?, collection? }]; types string, text, longText, url, number (integer), decimal (any number; optional min, max, precision = max decimal places), boolean, datetime, select (options: ["a", { value: "b", label: "B" }], multiple?: true for an array of values), reference, multiReference, stringList, repeater. number also takes optional min/max. A repeater takes fields: [...] (subtypes string, text, longText, url, number, decimal, boolean, select, reference, stringList; no nested repeater) and optional min/max item counts. url fields accept http(s) URLs, mailto:, tel:, #anchors and site-relative paths starting with / (mailto: allows one recipient, tel: needs at least 3 digits, IDN hosts are converted to punycode when validated). Images and files: reference / multiReference with model: "Media" (value: media id(s)); entries of a collection: collection: "<slug>". Slug auto-generated from name, unique per site
update_block_type site_id, block_type_id name, slug, description, fields, force fields replaces all fields. Removing or retyping a field (or repeater subfield) that existing blocks fill is rejected unless force: true; block data is never rewritten
delete_block_type site_id, block_type_id, confirm: true — refused while blocks use the type
create_page site_id, title slug, blocks starts as draft; blocks: [{ block_type_id, data }] created in order; data is validated against the block type (errors like blocks.2.data.items.1.title), undeclared keys are dropped and listed in warnings
update_page page_id title, slug, blocks if blocks is supplied, ALL existing blocks are replaced (send each block with its id to keep it); same validation and warnings as create_page
create_collection site_id, name slug, description, fields slug auto-generated from name; fields: [{ key, type, label, required?, model?, repository?, collection?, options?, multiple?, min?, max?, precision?, fields? }]; types string, text, longText, url, number (integer), decimal, boolean, datetime, select, json, reference, multiReference, stringList, repeater — decimal, select and repeater take the same settings as in create_block_type; invalid settings (e.g. a select without options or with duplicate values) are rejected. Images: { key: "cover", type: "reference", model: "Media", repository: "library" }, galleries: multiReference; avoid json for content editors maintain
update_collection collection_id name, description, is_active, fields fields is a full replacement list in display order; each field { key, type, label?, required?, model?, repository?, collection?, relation?, slug_source?, title_source?, options?, multiple?, min?, max?, precision?, fields? } (media reference: { key: "image", type: "reference", model: "Media", repository: "library" })
delete_collection collection_id, confirm: true — permanently deletes the collection and all its entries
create_entry collection_slug, site_id, slug data, status status = draft (default, hidden from the public API until publish_entry) or published; data = field key/value pairs, validated against the collection schema (number integer, decimal within min/max/precision, select one of its option values or an array of distinct values when multiple, repeater items against their subfields; invalid data is rejected as data.<field>: <message>; undeclared keys are dropped and listed in warnings)
update_entry entry_id data, slug snapshots the prior data into a new revision; data is validated like create_entry (required fields are not enforced, only provided ones are checked) and rejected data creates no revision
bulk_upsert_entries site_id, collection_slug, entries dry_run, publish up to 100 { slug, external_id?, data } per call; matches an existing entry by external_id first, then by slug, else creates it; data is validated like create_entry and replaces the stored data on update (with a revision); new entries are drafts unless publish: true (needs crafty:publish); items fail individually; dry_run reports created / updated / failed per item without saving
upload_media url, site_id collection_name (default library), file_name, sha256, alt alt = alt text for images (max 500 chars); http(s) URL or base64 data URI (max 256 KB decoded); sha256 rejects and discards the upload on mismatch; the response returns the stored sha256, visibility and a warning when the file is private
import_media_from_urls site_id, urls collection_name (default library) up to 50 public http(s) URLs per call, with the same SSRF, size and type checks as upload_media; a URL already imported to the site (or repeated in the batch) is not downloaded again; per URL: status imported / existing / duplicate / failed, media_id, original_url
request_media_upload site_id, file_name, sha256 collection_name (default library), size, alt alt is stored with the file once uploaded; returns a 15-minute single-use signed URL and a ready curl -T command for uploading a local binary file without sending its bytes through the model; max 25 MB; verified against sha256
manage_media_collection site_id, key name, description, is_public creates or updates a media repository; is_public: true gives permanent public URLs (existing files are moved in the background); new repositories are private
update_media site_id, media_id, alt — sets the alt text (max 500 chars) of one media file; null or "" clears it
delete_media site_id, media_id, confirm: true — permanently deletes one media file (ids from list_media)
create_form site_id, name, slug description, fields, captcha fields: [{ key, type, label, required? }]; starts as draft; protected by a captcha by default when Crafty has one configured (captcha: false to opt out), and the frontend must then render it with crafty.forms.captcha()
update_form form_id name, slug, description, fields, notification_emails only supplied fields change; fields/notification_emails replace
configure_visual_editor site_id environment (default production), frontend_url, routes (page_pattern, home_slug, collections, fixed), render_mode (ssr/hybrid/static/unknown), upload_media_collection visual editor settings of one environment; only sent keys change, null resets to the fallback; with only site_id it just reads; returns the effective settings plus llms_warnings (the llms.txt generator's warnings, since routes affect it too)
configure_llms site_id environment (default production), title, summary, language, excluded_collections (routed collections to drop), included_collections (routeless collections to opt in), collection_languages admins only; same read/partial-update semantics as configure_visual_editor; only sent keys change, null resets to the fallback; with only site_id it just reads; returns the effective settings, the configuration warnings, and the first 40 lines of the generated llms.txt

| configure_assistant | site_id and at least one setting | environment (default production), enabled, whatsapp_enabled, whatsapp_number, whatsapp_label, contact_form, hours (weekdays/saturday/sunday → { open, close } or null), timezone, greeting, suggestions (max 3), tone (friendly/formal/technical), guidelines, privacy_url, appearance (name, color, font, corners, position, avatar_on_launcher) | only sent keys change, null resets to the default; the avatar is uploaded in the panel; returns the effective settings and status | | configure_store | site_id | environment (default production), enabled, currency, order_prefix, prices_include_tax, taxes, default_tax, delivery (enabled, fee, free_from, tax, zone), pickup (enabled, address), payment_methods (cash_on_delivery, pay_at_pickup, transfer), transfer_accounts, notify_emails, customer_emails, return_url, color, logo_media_id, font, corners, checkout_fields, checkout_analytics, captcha | admins only; enabled: true needs the store in the plan and creates (or adopts) the productos collection and the checkout URL; only sent keys change; with only site_id it just reads; returns the settings, open, the products summary and checkout_url. See online-store.md | | list_orders | site_id | environment, status, payment_status, search, limit, page | needs the orders permission (read) | | get_order | site_id, number | environment | the order with its lines (ids for update_order), fields, metadata, payment_url, next_statuses and history | | create_order | site_id, customer, fulfillment, payment_method, items | environment, address, fields, metadata, coupon, locale, expected_total | orders write permission; source mcp; returns the order and its payment_url | | update_order | site_id, number | environment, edit keys (items, fulfillment, payment_method, customer, address, fields, metadata, coupon, coupon_below_minimum), notify, status, paid, payment_reference, internal_note | edits only unpaid open orders (metadata always); old lines keep their price; fires order.updated | | list_coupons | site_id | environment, status, search | coupons with status, uses, orders, sales, discounted; needs the coupons permission (read) | | get_coupon | site_id, code | environment | one coupon's rules (products as slugs) and results | | upsert_coupon | site_id, code | environment, name, type, value, max_discount, min_subtotal, applies_to, products, max_uses, max_uses_per_email, starts_at, ends_at, is_active | creates or changes by code; a used coupon keeps its code and type; needs coupons write | | delete_coupon | site_id, code | environment | deletes an unused coupon, pauses a used one | | configure_bookings | site_id | environment (default production), enabled, timezone, hours (merged by weekday), closed_dates, min_notice_minutes, max_days_ahead, slot_interval, cancel_notice_minutes, auto_confirm, reminder_hours, payment_methods (pay_at_venue, transfer), address, phone, checkout_fields | admins only; enabled: true needs bookings in the plan and creates (or adopts) the servicios and profesionales collections; only sent keys change; with only site_id it just reads; returns the settings, open, the service and professional slugs and checkout_url. See online-bookings.md | | get_booking_availability | site_id | environment, service, professional, from, to | without service: the bookable services and professionals; with it: free slots per local day (up to 31 days), each with the professionals free | | list_bookings | site_id | environment, from, to, status, professional, search, metadata, limit, page | needs the bookings permission (read) | | get_booking | site_id, number | environment | the booking with next_statuses, internal_note, customer_url and events | | create_booking | site_id, service, start, customer | environment, professional, payment_method, fields, metadata, outside_hours, internal_note, locale | bookings write permission; source mcp; a taken slot answers SLOT_UNAVAILABLE | | update_booking | site_id, number | environment, start, professional, outside_hours, status, paid, payment_reference, internal_note, metadata | moves, changes the status, marks paid; returns the booking and applied | | set_professional_schedule | site_id, professional | environment, mode (shop/own), hours | without mode it reads; own hours always fall inside the shop's | | manage_booking_blocks | site_id, action (list/create/delete) | environment, professional, repeat (once/weekly), from_date, to_date, all_day, start_time, end_time, weekdays, reason, id (to delete) | blocks time for one professional or the whole shop; create returns how many bookings already fall inside | | upsert_assistant_answer | site_id, question, answer | environment (default production), answer_id | adds a fixed answer, or edits the one with answer_id; the assistant uses it from the next visitor message | | delete_assistant_answer | site_id, answer_id | environment (default production) | deletes one fixed answer | | upsert_redirects | site_id, redirects | environment (default production), dry_run | up to 500 { source, destination, status_code? } per call, matched by source; source is a path starting with / (no spaces, ? or #; Cloudflare Pages * splats and :placeholders allowed), destination a path or absolute http(s) URL, status_code 301 (default) / 302 / 307 / 308; invalid items are listed in failed and skipped. Crafty hosting writes them to _redirects on the next deploy (after the site's own rules; limit 2000 static + 100 dynamic); custom frontends read GET /api/v1/redirects | | delete_redirect | site_id, source | environment (default production) | deletes one redirect |

Assistant tools follow the panel's Asistente section: admins always, editors with read access can read and editors with write access can change.

Redirect tools follow the Pages permission: reading needs access to pages, changing needs write access.

Publish (crafty:publish)

Tool Required Effect
publish_page page_id status → published, sets published_at
unpublish_page page_id status → draft, clears published_at
publish_entry entry_id status → published, sets published_at
unpublish_entry entry_id status → draft, clears published_at; the entry disappears from the public API and from references
schedule_publication type (page/entry), id, and publish_at and/or unpublish_at sets when a draft publishes itself and/or when a published item returns to draft; ISO 8601 dates (no offset → the user's regional timezone), null clears one, unpublish_at must be after publish_at; applied every minute
publish_form form_id status → published (now accepts public submissions)
unpublish_form form_id status → draft (stops accepting submissions)

Canonical workflows

Model content with block types (preferred)

  1. list_sites → choose a site_id (or create_site).
  2. For each section of the design, create_block_type (or get_block_type if it exists). Use a repeater for lists of items, stringList for lists of plain strings, and reference / multiReference with model: "Media" for images (upload them first); only use a collection for large independent record sets.
  3. create_page in the default locale with blocks: [{ block_type_id, data }, …]; fix any blocks.N.data.… errors and check warnings. For other languages, configure_locales once and then upsert_translation per page (blocks by id from get_page), or translate_content to let AI translate it once AI translation is enabled for the workspace and the site (configure_translation).
  4. publish_page.
  5. To change a schema later, update_block_type with the FULL field list (use force: true only if you accept orphaning data that blocks already hold).

Build and publish a page

  1. list_sites → choose a site_id (or create_site).
  2. get_block_type / list_block_types → note each id and its data schema.
  3. create_page with site_id, title, and blocks: [{ block_type_id, data }, …].
  4. publish_page with the returned page_id.

Structured content

  1. list_collections (optionally site_id) → pick a collection_slug, or create_collection with its fields (update_collection changes them later).
  2. create_entry with collection_slug, site_id, slug, and data.
  3. update_entry to edit (each edit stores a revision).

Forms

  1. create_form with site_id, name, slug, and fields.
  2. publish_form so it accepts public submissions.
  3. list_form_submissions / get_form_submission to read what visitors sent.

Media

  1. Need permanent public URLs? manage_media_collection with site_id, key, is_public: true first (otherwise files get expiring signed URLs).
  2. From a URL or a tiny file: upload_media with url, site_id and alt (optionally collection_name, sha256). From a local file: compute shasum -a 256 <file>, call request_media_upload (with alt), then run the returned curl -T command.
  3. list_media to confirm and retrieve URLs; give every content image without alt one with update_media.

Migrate an existing site

Follow migrate-to-crafty.md: crawl → classify → model → import_media_from_urls → bulk_upsert_entries (drafts, dry_run first) → upsert_redirects → human review → publish.

Visual editor

  1. Prepare the frontend (see visual-editor.md).
  2. configure_visual_editor with site_id, frontend_url, routes, and render_mode.
  3. Ask the user to open Editar sitio in the admin to confirm click-to-edit works.

API response cache management

The public API has a tag-based response cache (enabled per environment). Per-site overrides take priority over the global setting.

  • Disable cache for a site before bulk edits, then re-enable when done:
    1. set_api_cache_status with site_id and enabled: false.
    2. Make all your content changes (update_entry, update_page, etc.).
    3. set_api_cache_status with enabled: true.
    4. flush_api_cache with site_id (and optionally resource) to clear stale entries.
  • Flush only a specific resource type after targeted changes:
    • flush_api_cache with site_id and resource: "entries" after updating entries.
    • flush_api_cache with site_id and resource: "pages" after updating pages.
  • flush_api_cache with no resource (or resource: "all") clears all cached responses for the site at once.

Troubleshooting

  • 401 / authentication error — the Bearer token is missing, invalid, or expired; reconnect or regenerate it. Every /mcp request must carry a valid token.
  • A tool is missing, or fails with Insufficient OAuth scope — the token lacks that tool's scope. Tools are only advertised when the token carries the matching scope (crafty:write to create/edit, crafty:publish to publish, crafty:submissions to read form submissions). Re-issue the token with the scopes you need.
  • Tenant not found — wrong host; use the exact tenant domain. OAuth token endpoints are at the tenant root (/oauth/...), not under /mcp.
  • OAuth fails with a manually created client — its type must match the flow (Authorization Code or Device Code), its redirect URI must be exactly the one the tool uses, and client_id/client_secret must match. Tools that auto-register need no manual client.
  • Token/secret lost — client_secret and PATs are shown only once at creation; regenerate if lost.