Skip to content

MCP (Model Context Protocol) v26.9+

Maho speaks the Model Context Protocol, so AI assistants and agents can read and operate on your store the same way they use any other MCP server.

Endpoint: POST /api/mcp Transport: streamable HTTP Authentication: the same JWT bearer token as REST

There is nothing to configure per tool. The tool catalogue is derived from the same resource metadata that drives REST and GraphQL, so every resource, including those added by third-party modules, becomes a set of tools the moment it is installed. A default install exposes around 200 of them across catalog, inventory, sales, customers, content, tax and system.

Same gates as REST

A tool call runs through the identical provider/processor pipeline as the matching REST request, which means it inherits the operation's security: expression, the caller's role permissions, admin ACL, write logging and rate limiting. A call is refused exactly when the same REST request would be. There is no separate permission surface to audit.

Enabling it

MCP is off by default, like every other protocol, and its two supporting packages are not part of a standard install.

  1. Install the packages:

    composer require symfony/mcp-bundle nyholm/psr7
    

    They ship in Maho's suggest list rather than as hard dependencies, so nothing pulls them in for you. Without both, the admin toggle below stays inert (the config field warns you when they are missing).

  2. Go to System → Configuration → Services → API → API Protocols.

  3. Set MCP (Model Context Protocol) to Yes.
  4. Create a token for the agent, see Authentication. A client_credentials service account scoped to just the resources the agent needs is the right choice here, not an admin token.

If you serve Maho with nginx, Caddy, or anything other than the bundled Apache config, add /api/mcp to your API location block, see Web Server Configuration. The bundled public/.htaccess already routes it.

Remote clients need the host allowlist

The MCP SDK ships DNS-rebinding protection that, left at its default, only answers requests whose Host is localhost. Maho populates the allowlist from your store's base URLs automatically, but it does so when the API container is compiled. After changing a base URL or adding a store, clear var/cache/api_platform or remote calls will fail with an opaque transport error. The same already applies to the CORS allowlist, see Cache invalidation.

Connecting a client

The handshake is a normal JSON-RPC initialize call. The response carries a Mcp-Session-Id header that every subsequent message must echo back. The server accepts every protocol version from 2024-11-05 onwards and advertises the newest one it knows in the initialize response, so the version it replies with may be later than the one you sent; that is normal version negotiation, not an error.

# 1. Handshake, note the Mcp-Session-Id response header
curl -i -X POST https://your-domain.com/api/mcp \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2025-06-18' \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-06-18",
      "capabilities": {},
      "clientInfo": {"name": "my-agent", "version": "1.0"}
    }
  }'

# 2. List the tools this token can call
curl -X POST https://your-domain.com/api/mcp \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2025-06-18' \
  -H 'Mcp-Session-Id: THE_SESSION_ID' \
  -d '{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}'

# 3. Call one
curl -X POST https://your-domain.com/api/mcp \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2025-06-18' \
  -H 'Mcp-Session-Id: THE_SESSION_ID' \
  -d '{
    "jsonrpc": "2.0",
    "id": 3,
    "method": "tools/call",
    "params": {"name": "catalog_products_get", "arguments": {"id": 394}}
  }'

Most MCP clients take a remote server URL plus custom headers. The shape varies by client, but the two things you always need are the URL and the bearer token:

{
  "mcpServers": {
    "maho": {
      "type": "http",
      "url": "https://your-domain.com/api/mcp",
      "headers": {
        "Authorization": "Bearer YOUR_TOKEN"
      }
    }
  }
}

Static tokens only

Maho's MCP endpoint authenticates with the same static bearer token as the rest of the v2 API. It does not implement MCP's OAuth 2.1 / protected-resource-metadata discovery flow, so it works with clients that let you set a custom header, and not with clients that insist on driving an OAuth dance. GET /api/mcp returns 405; the endpoint is POST (plus DELETE to end a session and OPTIONS for preflight).

"Static" does not mean eternal: the token is a JWT with a lifetime (one hour by default), so one pasted into a client config stops working when it expires. For a long-lived agent, raise the token lifetime or have the agent re-issue via client_credentials, see Authentication.

Server instructions

initialize returns an instructions string that orients the model before it touches anything. It is generated from your store's own configuration and states the store name, the currency prices are quoted in, that IDs are Maho entity IDs rather than SKUs or increment IDs, that store views are selected by store code, and that list tools are paginated.

Tool names

Names are derived from the resource's section, the static segments of its URI, and the operation verb:

catalog_products_list
catalog_products_get
catalog_products_create
catalog_products_update
catalog_products_delete
sales_orders_list
customers_carts_items_gift_message_update

The verbs are list, get, create, update and delete. Path variables are dropped, so a collection and its item differ only by list versus get.

These names are the protocol's wire identity: an agent's saved prompts and a client's tool allow-lists both key off them, so they are treated as stable. A resource whose section, URI or shortName changes will change its tool names, exactly as it would change its REST paths.

A representative slice of the catalogue:

Section Examples
Catalog catalog_products_*, catalog_categories_*, catalog_product_attributes_*, catalog_attribute_sets_*
Inventory catalog_inventory_update, catalog_inventory_bulk_update
Sales sales_orders_*, sales_orders_invoices_list, sales_orders_credit_memos_*, sales_orders_shipments_*, sales_coupons_*
Customers customers_customers_*, customers_addresses_*, customers_customer_groups_*, customers_carts_*
Content content_cms_pages_*, content_cms_blocks_*, content_blog_posts_*
Tax tax_tax_rates_*, tax_tax_rules_*, tax_tax_classes_*
System system_stores_*, system_countries_*, core_store_config_get

Call tools/list against your own install for the authoritative list; it reflects the modules you actually have.

Sub-resources get tools too, so an agent can work at the same granularity REST offers rather than round-tripping the whole entity: catalog_products_tier_prices_*, catalog_products_media_*, catalog_products_custom_options_*, catalog_products_links_related_* (plus cross_sell and up_sell), sales_orders_comments_create.

What a tool looks like

Each tool advertises a right-sized input schema rather than the whole resource:

  • item read or delete takes just the identifier
  • list takes page and itemsPerPage, plus whatever filters the resource declares
  • create or update takes the resource body, with identifiers merged in
{
  "name": "catalog_products_get",
  "title": "Get Product",
  "description": "Get a product by ID",
  "inputSchema": {
    "type": "object",
    "properties": {
      "id": {
        "type": ["string", "integer"],
        "description": "Identifies the Product to act on."
      }
    },
    "required": ["id"]
  },
  "annotations": {
    "readOnlyHint": true,
    "destructiveHint": false,
    "idempotentHint": true,
    "openWorldHint": false
  }
}

The annotation hints let clients decide when to ask the user for confirmation. Reads are marked readOnlyHint, deletes are the only operations marked destructiveHint, and creates are the only ones marked non-idempotent.

Results come back as JSON text plus structuredContent, in the same JSON-LD shape REST returns, so a list tool includes totalItems alongside member:

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "isError": false,
    "content": [{"type": "text", "text": "{\"@context\":\"/api/contexts/Product\",\"@type\":\"Collection\",\"totalItems\":85,\"member\":[…]}"}],
    "structuredContent": {"@type": "Collection", "totalItems": 85, "member": []}
  }
}

Pagination and filtering

tools/list is cursor-paginated. Follow nextCursor until it is absent; a client that reads only the first page will see a fraction of the catalogue. Pages can also come back shorter than nominal, because permission filtering runs after paging, so treat only a missing nextCursor as the end, never a short page.

List tools paginate too. Pass page and itemsPerPage as arguments; they reach the resource exactly as the equivalent REST query parameters would.

Filters work the same way, and a list tool advertises the ones its resource declares:

Tool Filters
catalog_products_list search, sku, barcode, categoryId, priceMin, priceMax, sortBy, sortDir, attributeFilters, createdFrom, createdTo, updatedSince
sales_orders_list status, state, storeId, customerId, email, emailLike, incrementId, createdFrom, createdTo, updatedSince
customers_customers_list search, email, telephone
content_cms_pages_list, content_cms_blocks_list identifier, search, createdFrom, createdTo, updatedSince
content_blog_posts_list urlKey, search, categoryId, createdFrom, createdTo, updatedSince
catalog_categories_list search, urlKey, parentId, includeInMenu
tax_tax_rates_list search, taxCountryId
sales_coupons_list code, is_active

Date ranges

createdFrom, createdTo and updatedSince take a UTC date (2026-07-29) or datetime (2026-07-29 14:30:00). A bare date on createdTo covers the whole day, so a single day is createdFrom and createdTo set to the same value:

{"name": "sales_orders_list", "arguments": {"createdFrom": "2026-07-29", "createdTo": "2026-07-29"}}

updatedSince is the one to poll on for synchronisation, since it catches edits to older records that a created range would miss. On orders it is also accepted under its original REST name since, which still works but is no longer advertised: one filter with two names is a trap for an agent reading the schema as the contract.

The advertised set is derived from the resource's canonical GraphQL collection query, which is the only machine-readable declaration of what a collection filters on. Two consequences are worth knowing.

A tool that advertises no filter may still accept one. Providers read filters from an ad-hoc array, so a resource that filters without declaring the arguments still honours them, it just cannot tell an agent they exist. If you maintain a resource and want its filters discoverable, declare them as args: (or extraArgs:) on its collection_query; the same declaration serves GraphQL clients.

An unrecognised argument is ignored, not rejected. A filter name an agent invents is silently dropped and the call returns the unfiltered set, which reads as a filtered answer. Treat the advertised schema as the whole contract rather than guessing names.

Where a resource exposes both a top-level collection and a scoped variant, /orders and /customers/me/orders, only the top-level one carries the declared filters; the scoped variant reads a different set in its provider.

What is deliberately not filterable

Storefront-facing collections hard-code their own visibility rules, so there is no filter to see past them: catalog_products_list is limited to enabled, catalog-visible products (a text search goes through the search index, so it matches search-visible products, a slightly different set), and content_cms_pages_list, catalog_categories_list and content_blog_posts_list to active records, with blog posts scheduled in the future hidden too. A disabled product is not reachable through the product list on any token, by design. Sub-resource lists (catalog_products_media_list, sales_orders_invoices_list, customer addresses) take the parent id and return a handful of rows, so they carry no filters beyond it. Customer date and group filtering is not available yet: that provider searches through hand-built SQL that needs porting to the collection API first.

Tool visibility

tools/list is filtered to what the calling token can actually use, so an agent is not shown a catalogue it will be refused from. An unauthenticated caller sees only the public tools, which are not all reads: operations open to guests over REST, like guest-cart writes, are public here too. A service account sees the resources its role grants.

This is a usability measure, not the security boundary. Enforcement happens on the call itself, and a tool that is hidden is also refused. Filtering is deliberately optimistic: where the verdict cannot be reached without loading the entity, for instance a customer-scoped resource that compares the loaded record against the token, the tool stays listed.

Denials

A refused call comes back as a JSON-RPC error with the reason in the message:

{"jsonrpc": "2.0", "id": 3, "error": {"code": -32603, "message": "Your admin role does not grant access to \"sales/order\"."}}
{"jsonrpc": "2.0", "id": 3, "error": {"code": -32603, "message": "Authentication required: send a Maho API bearer token with the MCP request."}}

Keeping something out of the catalogue

Derivation is opt-out, on the principle that a resource worth exposing over REST is usually worth exposing to an agent. Useful exceptions are upload endpoints, auth handshakes, operations that return a raw file, and anything whose only sensible caller is a human in a browser. There are three levels.

A whole resource, using Maho's attribute. mahoMcp: false leaves the REST surface untouched. A hypothetical third-party resource:

#[Maho\Config\ApiResource(
    mahoMcp: false,
    shortName: 'SupplierFeed',
    operations: [ /* … */ ],
)]
class SupplierFeed { }

A whole resource, using API Platform's attribute. There is no mahoMcp to set, so declare mcp: explicitly. An empty array means no tools; a populated one means you are declaring them yourself and derivation stays out of the way:

#[ApiResource(
    mcp: [],
    shortName: 'ContactForm',
    operations: [ /* … */ ],
)]
class ContactForm { }

A single operation. Set the maho_mcp extra property to false:

new Get(
    uriTemplate: '/products/{productId}/custom-options/{id}/file',
    extraProperties: ['maho_mcp' => false],
)

Core currently uses the last two: the token handshake and the contact form declare mcp: [], and the custom-option file download opts out per-operation. mahoMcp: false is there for module authors; no core resource needs it yet. See Extending & Deployment.

Not the same as the Intelligence MCP server

Maho ships a second, unrelated MCP server in the Maho_Intelligence module. That one is developer-facing: it runs over stdio and exposes config paths, events, class aliases and database introspection so an AI coding assistant can understand how an install is wired. The endpoint documented here is merchant-facing: it runs over HTTP and exposes store data and operations. They share no transport, no tools and no configuration.