Search by

magenxcommerce / module-ai-mcp

magenxcommerce

Serves a Model Context Protocol (MCP) endpoint from Magento so an AI agent such as Claude Code can inspect and manage the backend: products with their images, links, variants, bundle options, downloadable links, attachments and custom options, categories, orders with their addresses, invoices, shipm

Package info

github.com/magenxcommerce/module-ai-mcp

Type:magento2-module

pkg:composer/magenxcommerce/module-ai-mcp

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.13.0 2026-10-19 10:24 UTC

This package is auto-updated.

Last update: 2026-10-24 19:20:28 UTC


README

Built with AI assistance. This module is built with significant use of Claude Code, under active human review and guidance. It is open source and in active development. AI assistance does not lower the engineering bar: every change must meet the same correctness, security, documentation and maintainability requirements as any other contribution.

No warranty. We are not responsible for any incompatibility. By installing this software, you accept responsibility for its security, upgrades, patches and monitoring.

Serves a Model Context Protocol (MCP) endpoint from Magento, so an AI agent such as Claude Code can inspect and manage the backend directly: products with their images, linked products and variants, categories, orders, returns and support tickets, invoices, shipments and credit memos, customer accounts and their addresses, stock levels, tier prices, cart and catalog price rules, CMS pages and blocks, product attributes and attribute sets, store configuration, caches and indexers.

This is a management surface, not a storefront feature. It is disabled by default, requires a Magento Integration access token — or, with OAuth switched on, an admin's own sign-in — and authorizes every individual tool against that integration's or admin's ACL role.

Why this lives in Magento

Stock Magento REST already covers most admin CRUD, so an MCP server could have been written outside Magento against /rest/V1/.... Three things stopped that:

  • core_config_data has no stock write endpoint. Store configuration — the "settings" half of the requirement — is only reachable from PHP.
  • Cache and indexer operations have no REST surface either.
  • Management authority belongs next to the thing being managed. Putting admin write powers behind the public headless storefront would turn any storefront vulnerability into a catalog-destruction vulnerability.

Endpoint

POST /magenx-mcp/server
Authorization: Bearer <integration access token | OAuth access token>
Content-Type: application/json

One JSON-RPC 2.0 request per POST, answered with one JSON response — MCP's streamable HTTP transport in its single-response form. No SSE stream is opened. Notifications are answered with a bare 202. Batched requests are rejected; MCP dropped JSON-RPC batching in protocol revision 2025-06-18, which is the revision this server advertises.

GET and every other method return 405. While the module is disabled the route returns 404, so a store that has not opted in advertises nothing.

What tools/list advertises

Each tool carries name, description and inputSchema, plus a title for a client's tool picker and MCP's annotations: readOnlyHint (the inverse of whether the tool writes), openWorldHint (false for every tool that acts on this one store alone; true only for security_status and upgrade_compatibility, which also ask public advisory services), and, for write tools only, destructiveHint and idempotentHint. The last two are defined by MCP only when readOnlyHint is false, so a read tool advertises neither.

A write tool assumes the worst of itself unless it says otherwise: destructiveHint starts true and idempotentHint false. Every set_/update_ tool overrides the second — they write the fields named to the values given, so a repeat lands in the same place — and the tools that only ever add override the first.

Four write tools keep destructiveHint: true despite being named create_ or add_, and the exceptions are the point of the hint. create_invoice, create_shipment and create_credit_memo move money and goods and cannot be taken back; add_product_media assigns image roles, and giving a role to one image takes it from whichever image held it. Every delete keeps the default too.

Forty-three read tools also carry an outputSchema, describing the total_count/page/page_size/items envelope every paged list returns. Unlike the annotations this is a promise rather than a hint: MCP obliges the structuredContent of every successful call to validate against whatever is advertised, so a schema that is approximately right is worse than none — it turns a response a client would have accepted into one it rejects.

Three consequences follow, and each is why something is missing from the wire:

  • No write tool advertises one. Every write has two successful shapes, not one: an unconfirmed call is answered with the confirm preview — {preview, tool, arguments, message} — through the same result helper as an applied call, and that is the normal response rather than the exception. No single schema describes both. Errors are unaffected; a tool error carries isError and no structured content, which the spec does not validate.
  • The envelope forbids nothing. Seventeen of the forty-three return the four keys and one of their own — search_admin_activity reports whether admin logging is even switched on, the lookup lists report which entity they listed — so the schema states the four as required and stops there. A floor that stays true beats a ceiling that is wrong the first time a tool grows.
  • items holds untyped objects. Its elements come from twenty-five projectors, several of which add keys inside a condition: a product's detail alone has four independently optional ones. Per-entity item schemas are worth having and have to follow the projectors a domain at a time.

Three read tools that look like they should qualify do not, and the omissions are deliberate. search_low_stock and search_media_gallery_assets report no total_count at all — the first counts what it returned, the second cannot know the total — so neither can promise a key it does not send. list_attribute_sets returns one attribute set in full, with no items and no paging, the moment attribute_set_id is passed; the envelope would be a promise it breaks on half its calls.

These are presentation hints, not a boundary. A client is free to ignore them; what actually decides whether a call changes anything is the ACL check, the write switch and the confirm gate described under The four guards. A tool contributed by another module that implements only Api/ToolInterface.php is advertised without them rather than being given hints or promises this server guessed on its behalf — the annotations live on Api/ToolAnnotationsInterface.php and the output schema on Api/StructuredToolInterface.php, both opt-in for exactly that reason.

Why a controller and not a webapi.xml route

MCP fixes the wire format. The webapi framework's typed (de)serialization cannot reproduce a JSON-RPC envelope — it cannot echo an id back with its original JSON type, and it cannot answer a notification with an empty 202. The authorization webapi would have provided is reimplemented faithfully in Model/Auth/Authenticator.php, against the same oauth_token records and the same ACL role, so nothing is lost but the serializer.

Tools

Tool Writes ACL resource
list_stores (endpoint grant only)
search_products Magento_Catalog::products
get_product Magento_Catalog::products
create_product ✓ Magento_Catalog::products
update_product ✓ Magento_Catalog::products
list_product_attributes Magento_Catalog::attributes_attributes
get_category_tree Magento_Catalog::categories
create_category ✓ Magento_Catalog::categories
update_category ✓ Magento_Catalog::categories
assign_product_to_category ✓ Magento_Catalog::categories
get_category Magento_Catalog::categories
list_category_products Magento_Catalog::categories
list_cms_blocks Magento_Cms::block
update_cms_block ✓ Magento_Cms::block
search_orders Magento_Sales::actions_view
get_order Magento_Sales::actions_view
list_order_comments Magento_Sales::actions_view
add_order_comment ✓ Magento_Sales::comment
hold_order ✓ Magento_Sales::hold
unhold_order ✓ Magento_Sales::unhold
cancel_order ✓ Magento_Sales::cancel
search_invoices Magento_Sales::sales_invoice
create_invoice ✓ Magento_Sales::invoice
search_shipments Magento_Sales::shipment
create_shipment ✓ Magento_Sales::ship
search_credit_memos Magento_Sales::sales_creditmemo
create_credit_memo ✓ Magento_Sales::creditmemo
get_invoice Magento_Sales::sales_invoice
capture_invoice ✓ Magento_Sales::capture
void_invoice ✓ Magento_Sales::capture
get_shipment Magento_Sales::shipment
add_shipment_track ✓ Magento_Sales::ship
delete_shipment_track ✓ Magento_Sales::ship
get_credit_memo Magento_Sales::sales_creditmemo
send_order_email ✓ Magento_Sales::email
update_order_address ✓ Magento_Sales::actions_edit
search_customers Magento_Customer::manage
get_customer Magento_Customer::manage
list_customer_groups Magento_Customer::group
create_customer_group ✓ Magento_Customer::group
update_customer_group ✓ Magento_Customer::group
delete_customer_group ✓ Magento_Customer::group
create_customer ✓ Magento_Customer::manage
update_customer ✓ Magento_Customer::manage
delete_customer ✓ Magento_Customer::delete
create_customer_address ✓ Magento_Customer::manage
update_customer_address ✓ Magento_Customer::manage
delete_customer_address ✓ Magento_Customer::manage
initiate_password_reset ✓ Magento_Customer::reset_password
invalidate_customer_tokens ✓ Magento_Customer::invalidate_tokens
set_newsletter_subscription ✓ Magento_Newsletter::subscriber
search_admin_users Magento_User::acl_users
get_stock Magento_CatalogInventory::cataloginventory
update_stock ✓ Magento_CatalogInventory::cataloginventory
search_low_stock Magento_CatalogInventory::cataloginventory
get_tier_prices Magento_Catalog::products
set_tier_prices ✓ Magento_Catalog::products
delete_tier_prices ✓ Magento_Catalog::products
get_contextual_price Magento_Catalog::products
search_cart_price_rules Magento_SalesRule::quote
get_cart_price_rule Magento_SalesRule::quote
update_cart_price_rule ✓ Magento_SalesRule::quote
create_cart_price_rule ✓ Magento_SalesRule::quote
delete_cart_price_rule ✓ Magento_SalesRule::quote
generate_coupons ✓ Magento_SalesRule::quote
search_coupons Magento_SalesRule::quote
delete_coupons ✓ Magento_SalesRule::quote
search_catalog_price_rules Magento_CatalogRule::promo_catalog
get_catalog_price_rule Magento_CatalogRule::promo_catalog
update_catalog_price_rule ✓ Magento_CatalogRule::promo_catalog
create_catalog_price_rule ✓ Magento_CatalogRule::promo_catalog
delete_catalog_price_rule ✓ Magento_CatalogRule::promo_catalog
apply_catalog_price_rules ✓ Magento_CatalogRule::promo_catalog
search_cms_pages Magento_Cms::page
get_cms_page Magento_Cms::page
create_cms_page ✓ Magento_Cms::save
update_cms_page ✓ Magento_Cms::save
update_cms_page_design ✓ Magento_Cms::save_design
delete_cms_page ✓ Magento_Cms::page_delete
create_cms_block ✓ Magento_Cms::block
delete_cms_block ✓ Magento_Cms::block
get_cms_block Magento_Cms::block
get_product_attribute Magento_Catalog::attributes_attributes
create_product_attribute ✓ Magento_Catalog::attributes_attributes
update_product_attribute ✓ Magento_Catalog::attributes_attributes
delete_product_attribute ✓ Magento_Catalog::attributes_attributes
add_product_attribute_option ✓ Magento_Catalog::attributes_attributes
delete_product_attribute_option ✓ Magento_Catalog::attributes_attributes
list_attribute_sets Magento_Catalog::sets
assign_product_attribute_to_set ✓ Magento_Catalog::sets
unassign_product_attribute_from_set ✓ Magento_Catalog::sets
search_url_rewrites Magento_UrlRewrite::urlrewrite
list_widget_instances Magento_Widget::widget_instance
get_widget_instance Magento_Widget::widget_instance
list_email_templates Magento_Email::template
get_email_template Magento_Email::template
update_email_template ✓ Magento_Email::template
get_design_config Magento_Theme::design_config
update_design_config ✓ Magento_Theme::design_config
search_media_gallery_assets Magento_Cms::media_gallery
upload_media_gallery_asset ✓ Magento_Cms::media_gallery
delete_media_gallery_asset ✓ Magento_Cms::media_gallery
list_product_media Magento_Catalog::products
add_product_media ✓ Magento_Catalog::products
update_product_media ✓ Magento_Catalog::products
delete_product_media ✓ Magento_Catalog::products
list_product_attachments Magento_Catalog::products
save_product_attachment ✓ Magento_Catalog::products
delete_product_attachment ✓ Magento_Catalog::products
list_product_link_types Magento_Catalog::products
get_product_links Magento_Catalog::products
set_product_links ✓ Magento_Catalog::products
delete_product_link ✓ Magento_Catalog::products
list_configurable_children Magento_Catalog::products
add_configurable_child ✓ Magento_Catalog::products
remove_configurable_child ✓ Magento_Catalog::products
list_bundle_options Magento_Catalog::products
save_bundle_option ✓ Magento_Catalog::products
delete_bundle_option ✓ Magento_Catalog::products
add_bundle_selection ✓ Magento_Catalog::products
remove_bundle_selection ✓ Magento_Catalog::products
list_downloadable_links Magento_Catalog::products
save_downloadable_link ✓ Magento_Catalog::products
delete_downloadable_link ✓ Magento_Catalog::products
list_downloadable_samples Magento_Catalog::products
save_downloadable_sample ✓ Magento_Catalog::products
delete_downloadable_sample ✓ Magento_Catalog::products
list_product_options Magento_Catalog::products
save_product_option ✓ Magento_Catalog::products
delete_product_option ✓ Magento_Catalog::products
assign_product_to_website ✓ Magento_Catalog::products
delete_product ✓ Magento_Catalog::products
delete_category ✓ Magento_Catalog::categories
move_category ✓ Magento_Catalog::categories
search_rma Magenx_Rma::rma_manage
get_rma Magenx_Rma::rma_manage
update_rma ✓ Magenx_Rma::rma_manage
delete_rma ✓ Magenx_Rma::rma_manage
list_rma_comments Magenx_Rma::rma_manage
add_rma_comment ✓ Magenx_Rma::rma_manage
list_rma_items Magenx_Rma::rma_manage
update_rma_item ✓ Magenx_Rma::rma_manage
list_rma_statuses Magenx_Rma::rma_status
save_rma_status ✓ Magenx_Rma::rma_status
delete_rma_status ✓ Magenx_Rma::rma_status
list_rma_reasons Magenx_Rma::rma_reason
save_rma_reason ✓ Magenx_Rma::rma_reason
delete_rma_reason ✓ Magenx_Rma::rma_reason
list_rma_resolution_types Magenx_Rma::rma_resolution_type
save_rma_resolution_type ✓ Magenx_Rma::rma_resolution_type
delete_rma_resolution_type ✓ Magenx_Rma::rma_resolution_type
list_rma_item_conditions Magenx_Rma::rma_item_condition
save_rma_item_condition ✓ Magenx_Rma::rma_item_condition
delete_rma_item_condition ✓ Magenx_Rma::rma_item_condition
search_helpdesk_tickets Magenx_Helpdesk::ticket
get_helpdesk_ticket Magenx_Helpdesk::ticket
create_helpdesk_ticket ✓ Magenx_Helpdesk::ticket
list_helpdesk_ticket_messages Magenx_Helpdesk::ticket
reply_to_helpdesk_ticket ✓ Magenx_Helpdesk::ticket
add_helpdesk_internal_note ✓ Magenx_Helpdesk::ticket
set_helpdesk_ticket_status ✓ Magenx_Helpdesk::ticket
set_helpdesk_ticket_priority ✓ Magenx_Helpdesk::ticket
assign_helpdesk_ticket ✓ Magenx_Helpdesk::ticket
reopen_helpdesk_ticket ✓ Magenx_Helpdesk::ticket
list_helpdesk_departments Magenx_Helpdesk::department
list_helpdesk_statuses Magenx_Helpdesk::status
list_helpdesk_priorities Magenx_Helpdesk::priority
list_helpdesk_custom_fields Magenx_Helpdesk::field
list_helpdesk_spam_patterns Magenx_Helpdesk::spam
list_helpdesk_gateways Magenx_Helpdesk::gateway
get_config Magento_Config::config
set_config ✓ Magento_Config::config
flush_cache ✓ Magento_Backend::cache
cache_status Magento_Backend::cache
set_cache_state ✓ Magento_Backend::cache
indexer_status Magento_Indexer::index
invalidate_indexers ✓ Magento_Indexer::index
cron_status Magenx_AiMcp::ops
list_modules Magenx_AiMcp::ops
read_audit_log Magenx_AiMcp::ops
list_logs Magenx_AiMcp::logs
read_log Magenx_AiMcp::logs
read_error_report Magenx_AiMcp::logs
save_helpdesk_priority ✓ Magenx_Helpdesk::priority
delete_helpdesk_priority ✓ Magenx_Helpdesk::priority
save_helpdesk_department ✓ Magenx_Helpdesk::department
delete_helpdesk_department ✓ Magenx_Helpdesk::department
save_helpdesk_custom_field ✓ Magenx_Helpdesk::field
delete_helpdesk_custom_field ✓ Magenx_Helpdesk::field
save_helpdesk_spam_pattern ✓ Magenx_Helpdesk::spam
delete_helpdesk_spam_pattern ✓ Magenx_Helpdesk::spam
set_helpdesk_ticket_fields ✓ Magenx_Helpdesk::ticket
set_helpdesk_ticket_department ✓ Magenx_Helpdesk::ticket
list_helpdesk_attachments Magenx_Helpdesk::ticket
delete_helpdesk_ticket ✓ Magenx_Helpdesk::ticket
create_rma ✓ Magenx_Rma::rma_manage
list_rma_attachments Magenx_Rma::rma_manage
delete_rma_attachment ✓ Magenx_Rma::rma_manage
platform_status Magenx_Platform::platform
security_status Magenx_Platform::platform
upgrade_compatibility Magenx_Platform::platform
search_admin_activity Magenx_AdminActivity::activity
get_admin_activity Magenx_AdminActivity::activity
sales_summary Magenx_AiMcp::reports
sales_by_period Magenx_AiMcp::reports
top_products Magenx_AiMcp::reports
order_status_breakdown Magenx_AiMcp::reports
customer_summary Magenx_AiMcp::reports
sales_by_country Magenx_AiMcp::reports
category_sales Magenx_AiMcp::reports
coupon_performance Magenx_AiMcp::reports
buyer_segments Magenx_AiMcp::reports
abandoned_carts Magenx_AiMcp::reports
inventory_risk Magenx_AiMcp::reports
search_terms Magento_Search::search
search_reviews Magento_Review::reviews_all
get_review Magento_Review::reviews_all
set_review_status ✓ Magento_Review::reviews_all
search_blog_posts Magenx_Blog::post
get_blog_post Magenx_Blog::post
create_blog_post ✓ Magenx_Blog::post
update_blog_post ✓ Magenx_Blog::post
delete_blog_post ✓ Magenx_Blog::post
list_blog_categories Magenx_Blog::category
save_blog_category ✓ Magenx_Blog::category
delete_blog_category ✓ Magenx_Blog
list_blog_tags Magenx_Blog::tag
save_blog_tag ✓ Magenx_Blog::tag
delete_blog_tag ✓ Magenx_Blog
search_gdpr_requests Magenx_Gdpr::requests
get_gdpr_request Magenx_Gdpr::requests
approve_gdpr_request ✓ Magenx_Gdpr::requests
deny_gdpr_request ✓ Magenx_Gdpr::requests
search_consent_log Magenx_Gdpr::consent_log
list_gdpr_cookie_groups Magenx_Gdpr::cookie_groups
list_gdpr_cookies Magenx_Gdpr::cookies
save_gdpr_cookie ✓ Magenx_Gdpr::cookies
delete_gdpr_cookie ✓ Magenx_Gdpr::cookies
search_feeds Magenx_ProductFeed::feed
get_feed Magenx_ProductFeed::feed
get_feed_history Magenx_ProductFeed::feed
list_feed_deliveries Magenx_ProductFeed::feed
create_feed ✓ Magenx_ProductFeed::feed
update_feed ✓ Magenx_ProductFeed::feed
delete_feed ✓ Magenx_ProductFeed::feed
generate_feed ✓ Magenx_ProductFeed::generate
search_quick_search_promotions Magenx_QuickSearchGraphQl::promotion
get_quick_search_promotion Magenx_QuickSearchGraphQl::promotion
create_quick_search_promotion ✓ Magenx_QuickSearchGraphQl::promotion
update_quick_search_promotion ✓ Magenx_QuickSearchGraphQl::promotion
delete_quick_search_promotion ✓ Magenx_QuickSearchGraphQl::promotion
search_carts Magento_Cart::cart
get_cart Magento_Cart::cart
list_cart_payment_methods Magento_Cart::cart
estimate_cart_shipping Magento_Cart::cart
create_cart ✓ Magento_Cart::manage
add_cart_item ✓ Magento_Cart::manage
update_cart_item ✓ Magento_Cart::manage
remove_cart_item ✓ Magento_Cart::manage
set_cart_delivery ✓ Magento_Cart::manage
set_cart_payment_method ✓ Magento_Cart::manage
place_order ✓ Magento_Sales::create

A tool the caller may not use is not listed, so an agent never plans around a capability it does not have.

Sales tools and what they cost to get wrong

Every sales resource above is one of Magento's own, so an integration role is the only thing deciding how far an agent reaches into orders. They are worth ticking deliberately, because five of these tools cannot be undone:

  • create_credit_memo with invoice_id and refund_online sends a refund to the payment gateway and real money leaves the merchant account. Without refund_online it records the credit memo offline and moves nothing. Magento has no operation that reverses a credit memo either way, so Magento_Sales::creditmemo is the resource to leave unticked on any integration that should not be able to refund.
  • create_invoice and create_shipment create documents that cannot be deleted. create_invoice with capture: true also captures payment through the gateway.
  • capture_invoice captures an invoice that was created without capturing, so real money leaves the customer's account. Nothing reverses a capture: undoing one means create_credit_memo, which is the other irreversible tool on this list. It sits behind Magento_Sales::capture, the same resource Magento's own capture and void buttons check — which is the resource to leave unticked on an integration that should be able to invoice but not take money.
  • void_invoice tells the gateway to release the authorization instead. The invoice document survives, canceled, because Magento never deletes one, and billing that order again means creating a second invoice.
  • cancel_order releases reserved stock and cannot be undone.

The confirm: true gate applies to all of them, but it is a single argument an agent supplies itself — it protects against a half-formed call, not against a confident wrong one. ACL is the boundary that holds.

get_order reports qty_invoiceable, qty_shippable and qty_refundable per line, derived from the five counters Magento actually stores. Those are the quantities the three creation tools accept; an agent should read them rather than assume the ordered quantity is still available.

get_invoice, get_shipment and get_credit_memo are the same split applied to the documents an order produces: the search_* tools return the row, these return the lines, totals and comments. Each takes the document's own entity_id or its own increment_id — which is not the order's — and an increment id that matches more than one document is an error naming the candidates rather than an arbitrary pick, exactly as get_order handles it. An invoice's state_label is worth reading first: it is what decides whether capture_invoice and void_invoice will accept it, and both refuse an invoice that is not open rather than letting the gateway produce a generic save failure after the attempt.

A tracking number is a separate write from the shipment. create_shipment does not require one, and a shipment without one leaves the customer with a dispatch e-mail and nothing to follow. add_shipment_track fills that in, and refuses a carrier_code this store has not configured with tracking enabled — Magento would otherwise store it happily and render a "Track this shipment" link that goes nowhere. The error names the codes the store does accept; custom is always one of them, and then title is required because it is the only thing naming the carrier to the customer. Neither adding nor deleting a track sends mail, so correcting a number already e-mailed out usually means adding the right one rather than only removing the wrong one.

update_order_address reaches the address through the order, by address_type, rather than by an address id the caller supplies. Magento's address repository saves whatever parent_id the object it is handed carries, so an id from another order would move that address rather than fail — the trap update_rma_item guards against in the same way. Only the fields passed are changed, it does not move a shipment or label that already exists, it does not recalculate shipping or tax, and the customer is told nothing. A virtual order has no shipping address and says so rather than falling back to the billing one.

send_order_email reports whether the mail actually went. Magento declines quietly when order e-mails are switched off for the store or the order cannot be notified, so the result carries sent: false in that case rather than an error.

There is deliberately no tool that sets an order's status directly. add_order_comment already takes a status, and Magento restricts it to the statuses belonging to the order's current state; a free setter would bypass that and leave an order in a status its state does not allow.

Customer tools handle personal data

Everything the customer tools return is personal data: names, email addresses, dates of birth, tax ids, postal addresses, phone numbers. Two things follow.

Reads are audited only if you ask. The audit log in var/log/magenx_ai_mcp.log always records every attempted write with the calling integration. Reads are recorded only when magenx_ai_mcp/security/audit_reads says so:

  • off (the default) — no reads, as before this setting existed.
  • personal_data — reads of tools that return people: customers, orders and their invoices, shipments and credit memos, carts, returns, help desk tickets, GDPR requests and the consent log, admin users and admin activity, and customer_summary. These tools carry Api/PersonalDataToolInterface, and a unit test fails if a new read in those domains forgets it.
  • all — every successful read.

A recorded read names the tool, the integration, the arguments, the total_count and the identifiers of the records returned (an order's increment id, otherwise an entity id) — never the records themselves. A copy of every address an agent looked at would make the audit file the most sensitive file on the server, and the ids already answer "whose data did it see".

Either way, grant Magento_Customer::manage only to integrations that need it, and treat the endpoint as a system with access to the customer base rather than a read-only convenience.

Three tools are visible to the customer or irreversible:

  • delete_customer permanently removes the account and its addresses. Past orders survive and keep the personal data captured on them, so this alone does not satisfy an erasure request. It sits behind Magento_Customer::delete, a separate grant from the one that allows editing customers.
  • create_customer sends Magento's account-creation email, and initiate_password_reset sends a password email. Both reach the customer's inbox the moment the tool succeeds.
  • set_newsletter_subscription can subscribe someone. Consent is the store's to obtain, not an agent's to assume; unsubscribing is always safe.

There is no tool that sets a password directly. Magento's own contract for that requires the customer's current password, and writing a chosen password into an account would mean an agent holding a credential the customer is supposed to trust — initiate_password_reset leaves the new password between the store and its customer. invalidate_customer_tokens revokes API tokens without touching the password, which is the pairing for a compromised account.

update_customer deliberately does not touch addresses. Magento treats the address list on a saved customer as authoritative and deletes any address missing from it, so addresses are only ever changed through the three dedicated address tools.

Customer groups are a separate grant and a separate hazard. The three group tools sit behind Magento_Customer::group, not ::manage, matching list_customer_groups and Magento's own split — deciding how a class of customers is priced and taxed is a different permission from editing one person's record.

tax_class_id is required on save rather than defaulted. A group carries the tax class everyone in it is taxed under, and getting it wrong is not an error anybody sees: it is a quietly wrong rate on every order those customers place. update_customer_group loads the group and mutates it for the same reason the price rule tools do — a rename rebuilt from its arguments would save with a null tax class.

delete_customer_group looks like a one-row delete and is not. Magento does not orphan the customers in a deleted group; it reassigns every one of them to the default group, a full customer save each, and their prices and tax change with them. The confirm preview an agent sees before all that would say nothing but a group id. So the members are counted first and any at all refuse the delete, naming the number: moving them is a decision for whoever is asking, made deliberately with update_customer, not a side effect of tidying up a group. An empty group deletes normally, and Magento's own refusals — "NOT LOGGED IN" and any group configured as a default — are passed through with their reasons intact.

Stock and pricing: three things that are not obvious

Stock is the legacy single-stock API. These tools use CatalogInventory's StockRegistryInterface, which is the aggregate quantity and the default source. Multi-Source Inventory ships as a separate package set and is not a dependency here, so on a multi-source installation get_stock and update_stock read and write the legacy view that MSI keeps in sync — not per-source quantities. Managing individual sources still needs the admin.

Thresholds are stored as a value plus an inheritance flag. Magento keeps min_qty, backorders, notify_stock_qty and the rest alongside a use_config_* flag saying to ignore the stored value in favour of the store default. Writing a threshold without clearing that flag stores a number Magento never reads — a change that reports success and does nothing. So get_stock reports each setting as {value, uses_config_default}, and update_stock clears the flag whenever you set one; pass the field as null to go back to inheriting.

get_contextual_price answers from the price index, which is what the storefront reads, so it gives the price a shopper in that customer group sees — including a stale one when the index is behind, which is why it also reports the price indexer's state. Beside the indexed regular and final price it reports today's catalog rule price (today in the store view's timezone, which is how catalogrule_product_price is dated), the tier price that applies at the given quantity, and the resulting unit price. The index table is resolved for the website and group, so a price indexer in dimension mode is read correctly. Tier prices are read keyed on entity_id, Magento Open Source's layout.

Nothing here reindexes. A stock change, a tier price or an activated catalog rule reaches the storefront only after the relevant indexer runs. The affected tools return reindex_required: true; invalidate_indexers is what marks the indexers to rerun.

Price rules: what these tools will not do

No rule tool writes conditions. That is deliberate, and it is why both update tools load the existing rule and mutate it rather than building a new one: Magento's sales rule repository converts the whole data object back into the rule on save, so a rule rebuilt from only the fields being changed would be saved with no conditions — a cart discount that suddenly applies to every cart, or a catalog rule that re-prices the entire catalogue. A rule whose conditions need changing has to be edited in the admin.

Everything else about a rule follows from that, in both directions:

  • Creating. create_cart_price_rule and create_catalog_price_rule always create the rule inactive, and is_active is not in their schemas at all. A rule created here cannot have conditions, so it must not have a way to go live either. The name, dates, discount, websites and customer groups are all set here; somebody then adds the conditions in the admin and enables it there. The result says so rather than reporting a plain success.
  • Activating. update_cart_price_rule and update_catalog_price_rule refuse is_active: true on a rule that restricts nothing, naming what to do about it. That guard matters more than the create tools do, because it covers the path that already shipped — and detecting "no conditions" is not the obvious check: Magento hands back a root combine node whether or not anything was put inside it, so an unconditioned cart rule answers getCondition() with an object. The test is a node with no children, and it lives in one place both tools share.
  • Deactivating is never refused, whatever the rule looks like. It is the way back out of a rule that should not have gone live.
  • Already live and unconditioned is left alone. A rule in that state is somebody's deliberate store-wide promotion, and renaming or re-dating it is allowed; only switching one on is not.

delete_cart_price_rule takes the rule's coupon codes with it, so any code a customer already holds stops working — deactivating stops a campaign just as completely and can be undone. delete_catalog_price_rule removes the rule but not its prices: those stay in the index until the catalog rule indexer runs, and apply_catalog_price_rules is what schedules that. Like invalidate_indexers, it marks the indexer invalid rather than re-pricing inline — which is what the admin's own "Apply Rules" button does, because re-applying rules across a real catalogue outlasts an HTTP request.

get_cart_price_rule does return both condition trees, read-only and depth-limited, because they are the usual answer to "why is this rule not applying". A catalog rule's condition is only an opaque serialized blob in Magento's API, so get_catalog_price_rule reports just whether it has one — a catalog rule with no condition applies to every product.

generate_coupons refuses a rule that is not set to auto-generate coupons rather than failing obscurely, and delete_coupons is all-or-nothing: Magento's mass delete reports a bad code only as "Some coupons are invalid", so the codes are checked first and a missing one is named before anything is deleted. set_tier_prices and delete_tier_prices guard the matching trap — the tier price storage reports rejected rows by returning them instead of throwing, so an empty return is the only thing that means the prices were applied.

Content and attributes: Magento's own ACL split is followed

CMS pages are the one place Magento splits permissions three ways, and the tools match it rather than collapsing it:

Tool Resource Why separate
search_cms_pages, get_cms_page Magento_Cms::page Reading
create_cms_page, update_cms_page Magento_Cms::save Copy and metadata
update_cms_page_design Magento_Cms::save_design Theme and layout XML
delete_cms_page Magento_Cms::page_delete Irreversible

A tool declares exactly one ACL resource, so folding the design fields into update_cms_page would let an integration granted only "Save Page" inject layout XML. PageContentArguments therefore neither offers nor writes a design field, and there is a unit test asserting that for each of the six. The one exception is page_layout, which Magento itself saves under "Save Page".

Blocks have only one resource, Magento_Cms::block, so all four block tools carry it.

Attributes change the catalogue's shape

create_product_attribute adds a field to every product in the sets it is later assigned to, and an attribute is useless until assign_product_attribute_to_set puts it in one — so the create tool says so in its own result. Neither the attribute code nor the input type can be changed afterwards; Magento treats both as fixed, and update_product_attribute does not offer them.

delete_product_attribute refuses Magento's system attributes even though the service contract would allow it. Deleting name, price or status does not fail loudly — it drops the column those values live in, taking every product's value with it, and nothing restores them. The admin does not offer that button either. Unassigning an attribute from its sets is the reversible way to stop using one.

add_product_attribute_option returns the new option id, which is what update_product needs — that attribute takes option ids, not labels. Magento rejects a label that already exists on the attribute, so the tool cannot create a duplicate; within a single create_product_attribute call it does not, so that tool checks for repeated labels itself.

Content the CMS tools do not cover

CMS pages and blocks are the content an agent edits; these are the four places that decide where it appears and how it looks.

Blocks now resolve through one locator. get_cms_block, update_cms_block and delete_cms_block share BlockLocator, so all three refuse an ambiguous identifier the same way and name the block_ids rather than acting on an arbitrary one. get_cms_block is the block-side counterpart of get_cms_page: list_cms_blocks finds an identifier, this reads the one block in full.

Widgets and e-mail templates have no Api/ layer. Both go through Magento's own models and collections — the same seam the admin grids use — which is a concrete-class dependency rather than an @api one, exactly as the help desk section below describes for TicketManager. A refactor upstream can break these in a way a service contract would not. The widget tools are read-only on purpose: a widget instance stores a layout handle and a serialised page-group blob, which is closer to update_cms_page_design's layout XML than to editing content, and Magento gives it no separate grant to sit behind. Widgets are created and placed in the admin; list_widget_instances is usually the answer to "why is this block appearing on that page", because a widget places it rather than the page content referencing it.

update_email_template edits what customers receive, on the next mail the store sends — there is no draft. Two things follow. Only templates that already exist as records can be edited: Magento's defaults ship as files and have no row until someone creates an override from one in the admin, so a built-in code is an error saying that rather than a template with no content. And a template body is Magento template markup, not HTML — its {{...}} directives are executed at render time, which makes it closer to code than to copy, and the tools say so.

Design configuration goes through its own repository, not through set_config. Design values are validated and cached as a set, and a row written around that is a value the storefront may not pick up — so update_design_config loads the scope's own value objects and mutates them, and refuses a path that scope does not carry rather than accepting a write Magento would drop. It is a separate ACL resource from Magento_Config::config because Magento separates them, so an integration can be allowed to change the theme without being allowed to change payment settings. The scope rules in Caveats apply here too: omitting both codes writes the default that everything inherits.

Uploading into the media gallery

upload_media_gallery_asset is the second tool in the module that takes real bytes, and the riskier of the two: add_product_media hands its file to Magento's gallery service, while this one writes into pub/media, which the web server hands out directly. A file there is a public URL the moment it exists.

Model/MediaPathPolicy.php is what stands between a caller-supplied name and that URL, and it is hard-coded for the same reason ConfigPathPolicy is — the failure it prevents is not a trade-off a store should be able to opt into through a text field:

  • The destination is confined to the gallery root. A path that climbs out with .., starts at /, hides a Windows separator, or carries a null byte is refused rather than normalised into something plausible.
  • The extension has to match the mime type, and the mime type has to match the bytes. hero.phtml holding a real JPEG is not a media file with the wrong name; it is code in a directory the web server serves.
  • Nothing is ever overwritten. A name already in use is an error, because replacing a file silently changes every page already pointing at it.

search_media_gallery_assets requires at least one filter, for the same reason search_url_rewrites does — an unfiltered lookup returns every asset in the store.

delete_media_gallery_asset runs the same path guard in the opposite direction, which is why the check is a public method on the policy rather than a second copy of it: a delete is handed a path that already starts at the gallery root, and needs no mime type, so resolve() is no use to it while the part that refuses .., a leading /, a backslash and a null byte is exactly what it wants. Paths outside the gallery root are refused and pointed at delete_product_media, which also keeps the product's own gallery consistent.

The other half of that tool is the in-use check. Deleting an asset removes the file and leaves every reference to it in place, so the storefront shows a broken image and nothing anywhere reports an error — Magento's own admin warns about this and then does it. This refuses instead, and names the entity type, id and field of everything still pointing at the file, which turns a silent breakage into a list of things to fix first. It is the one tool needing magento/module-media-content-api; without that mapping the check cannot be made at all.

URL rewrites are read-only

search_url_rewrites answers why a URL 404s or redirects. Creating and editing rewrites is deliberately not exposed: UrlPersistInterface is the mechanism Magento uses to regenerate its own entity rewrites, and a rewrite written through it is liable to be replaced the next time the product or category is saved. Custom rewrites belong in the admin. The lookup requires at least one filter, because an unfiltered one returns every rewrite in the store.

Catalog depth: replace, not merge

Four of Magento's catalog operations replace where they read like they add, and all four are wrapped accordingly.

update_product_media loads the entry it is changing. Magento's gallery update assigns the entry it is handed straight into the existing list ($entries[$key] = $entry), so an entry built from only the fields being changed would blank the rest — including the file that points at the image on disk. The tool fetches the entry, mutates what you passed, and sends that back. Assigning a role also moves it: a role belongs to one image at a time, so giving image to one entry takes it from whichever held it.

set_product_links replaces the whole list for the type. Any existing link of that type you do not list is removed. Because "set these links" reads like "add these links", the tool takes an explicit mode: replace (Magento's own behaviour, the default) or append, which reads the current links and carries them through. delete_product_link is the simpler way to drop one.

save_bundle_option replaces the option's selections, for the same reason and with the same escape: handing Magento a product_links list makes that list the whole set, so any selection left out stops being offered. It takes the same mode — replace by default, append to carry the existing selections through — and omitting product_links entirely is how a title or position is changed without touching what the option offers. add_bundle_selection and remove_bundle_selection are the one-at-a-time forms.

save_product_option replaces a select option's values. A choice left out of values is removed from the option; customers who already picked it keep it only on orders already placed. The option's type cannot be changed afterwards either — Magento would accept it and leave the stored values behind an input that no longer reads them — so the tool refuses and says to delete and recreate.

Tools that need real bytes take base64, with no URL or file-path form, so in practice the file comes from a person: add_product_media, and the downloadable link and sample tools. All of them check the payload decodes and is within 4 MB before Magento is asked to store it, because Magento's own rejection arrives as a generic save failure after the upload attempt; add_product_media additionally checks the bytes are genuinely an image of the declared mime type.

Product types beyond simple and configurable

create_product and update_product reach any product's own fields, but a bundle, a downloadable and a product with custom options each keep the thing that makes them sellable somewhere update_product does not go. These tools are those three places.

A downloadable link is a file the store sells. save_downloadable_link with link_type: "file" puts bytes on disk that Magento then serves to anyone who bought the product — so it is worth being sure the store is entitled to distribute what is being uploaded. save_downloadable_sample is the sharper edge: a sample is downloadable by anyone, without buying anything, so the file being sold must never be uploaded there. A link carries its own optional sample through the sample_* arguments, which is a different thing from the product-level samples save_downloadable_sample manages; both are visible in the respective list tools, and neither list ever returns the bytes.

The type is checked before anything else. Both sets of services accept any sku and then fail somewhere inside the type model, or quietly do nothing — a bundle option on a simple product, a downloadable link on a configurable. A product listing does not put the type in front of an agent, so it is checked once and the error says what the product actually is.

Custom options are not attributes. A custom option belongs to one product and exists only there; an attribute is catalogue-wide and goes through create_product_attribute and assign_product_attribute_to_set. An agent reaching for save_product_option to add a field to every product is reaching for the wrong tool, and each tool's description says so.

These tools add magento/module-bundle and magento/module-downloadable to the module's requirements. Both are Magento core, but both can be disabled on an installation that does not sell those product types, and the tool classes reference their Api\* interfaces — so setup:di:compile fails where they are absent, the same coupling the RMA section below describes. The same escape applies: move these files into a bridge module requiring both, contributing to the registry's tools argument, and nothing in this module changes.

Reading a category

get_category_tree is the map; get_category is the category itself — the fields update_category writes, resolved at a store_code the way get_product resolves a product's. list_category_products is the inverse of assign_product_to_category, and reports direct assignments only: a product sitting in a child category is not listed, even where an anchor parent shows it on the storefront. It returns sku and position rather than whole products, because Magento's contract has no paged form and answers with every assignment the category has.

What the deletes take with them

  • delete_product also removes that product's images, tier prices, links and category assignments, and drops it as a variant of any configurable. Past orders keep their own copy of the line.
  • delete_category deletes the whole subtree, and the result names how many categories went. Products are not deleted but lose the assignment. A store's root category is refused.
  • move_category takes the subtree and the products with it, and changes the URL of everything beneath — old links stop resolving without a redirect.
  • remove_configurable_child does not delete the simple product; it becomes standalone. Removing the last variant leaves the configurable unbuyable.

For each of these, the reversible alternative is in the tool's own description: disable rather than delete, unassign from a website rather than remove.

Returns (RMA) — the one non-stock domain, and a hard dependency

These twenty tools cover magenxcommerce/module-rma, which is not a Magento module. That makes it the only part of this server that depends on something outside Magento core, and the dependency is hard: the tool classes reference Magenx\Rma\Api\*, so setup:di:compile fails if the RMA module is absent. composer.json therefore requires it.

If that coupling is unwanted, the clean alternative is a bridge module — Magenx_AiMcpRma, requiring both, contributing its tools to the registry's tools argument the same way. Nothing in this module needs changing for that; the files move as they are.

The RMA module ships its own granular ACL, and the tools follow it exactly: rma_manage for the requests, and a separate resource per lookup table (rma_status, rma_reason, rma_resolution_type, rma_item_condition), so an agent can be allowed to work returns without being able to redefine the workflow.

update_rma emails the customer. RMARepository::save() compares the stored status against the one being saved and dispatches rma_status_change_after when they differ; the module's own observer turns that into a status-change e-mail. Setting the status to the value it already has changes nothing and sends nothing, and the result reports customer_notified either way.

add_rma_comment requires visible_to_customer, with no default. Staff notes and customer replies live in the same table, separated only by that flag, so defaulting it either way would silently publish an internal note or bury a reply. Adding a comment sends no e-mail on its own.

update_rma_item checks the line belongs to the return. The module's item service sets rma_id from its argument, so handing it a line from another return would move that line rather than fail.

The lookup tools are create-or-update: omit entity_id to create, pass it to change. That mirrors the RMA module's own admin save. Deleting a lookup row is not blocked while returns still reference it — those are plain integer columns with no constraint — so the delete tools say so and point at deactivating instead.

Help desk — built on TicketManager, not a service contract

magenxcommerce/module-helpdesk has no Api/ layer. Writes therefore go through Model\TicketManager, which is the module's documented single write path and the seam its own admin controller and mailbox gateway already use; reads go through its collections, the way the admin grid does. That is a concrete-class dependency rather than an @api one, and the module carries no @api annotations at all — so a refactor there can break these tools in a way a service contract would not. Adding an Api/ layer to helpdesk would fix that and change nothing about the tool surface: the tool names, schemas and ACL resources are the contract callers see, and only the bodies would move.

Replies and internal notes are separate tools, on purpose. The module keeps both in one table, told apart only by type, and its own code warns that letting an unrecognised type through would downgrade a staff-only note into something the customer is e-mailed. Making that a choice of tool rather than a flag means the mistake is not available: reply_to_helpdesk_ticket is always public and e-mails by default, add_helpdesk_internal_note is never either. An internal note also deliberately does not move the ticket's last-reply markers, so an unanswered ticket still reads as unanswered.

What sends mail. create_helpdesk_ticket sends the module's new-ticket notification and cannot suppress it. reply_to_helpdesk_ticket e-mails the customer unless notify is false. assign_helpdesk_ticket e-mails the assignee, not the customer, and only when the assignment actually moves. Status, priority and reopen send nothing.

A status change is not just a label. The module reads the store's configured archive and lock status codes, so moving a ticket can file it into Archive and stop the customer replying; moving off an archiving status brings it back to the inbox. A ticket in Spam stays there. set_helpdesk_ticket_status reports the folder and lock state it ended up in.

Searches default to the inbox, because a search that silently included Spam would report resolved noise as open work. Pass folder for archive, spam, or any.

The configuration tables are read-only. Statuses are referenced by code from store configuration, which is what decides which of them archive and which lock a ticket — so creating or renaming one through a tool could change what closing a ticket does. That belongs in the admin, next to the settings it interacts with.

list_helpdesk_gateways never returns a password, only whether one is set. Gateway rows hold live IMAP credentials; a read tool that handed them back would turn listing mailboxes into credential exfiltration, and nothing an agent does needs the password that last_error does not serve better. Creating or editing a gateway is not exposed at all — a tool that could set host and login could point the store's mail intake at someone else's mailbox.

Add a tool from another module by contributing to the tools argument of Magenx\AiMcp\Model\Tool\ToolRegistry in di.xml and implementing Magenx\AiMcp\Api\ToolInterface. Every guard below applies to it automatically.

Two conventions go with that, both load-bearing and neither enforced at runtime. Register the class as a \Proxy — Acme\Foo\Model\Tool\DoThing\Proxy — and make the item's key the tool's own getName():

<item name="do_thing" xsi:type="object">Acme\Foo\Model\Tool\DoThing\Proxy</item>

The object manager builds an array argument eagerly, so an unproxied tool is constructed on every request that reaches this server — along with every repository, factory and collection factory it asks for — whatever that request actually called. With 243 tools registered, that is the difference between building one tool per tools/call and building all of them. The key carries the name because the registry has to file a tool without asking it anything: asking is what wakes the proxy, and doing it in a loop is the eager construction the proxies exist to avoid. A key that disagrees with getName() puts the tool in the registry under a name it does not answer to, which nothing throws over — the tools in this module are held to both by Test/Unit/Model/Tool/RegistrationConventionsTest.php.

Erasure is confirmed by name, not by id

approve_gdpr_request is the most irreversible tool here. Approving a data-subject request does not mark a row and move on: it overwrites the customer's name, email, addresses, telephone, order history and newsletter subscription with placeholders, permanently.

The write switch and the confirm gate apply as they do everywhere, but the preview they produce names a request_id — not a person — so confirming it tells an agent nothing about whose data is about to go. The tool therefore also requires customer_email, and refuses unless it matches the request's own customer. An agent that reached for the wrong id cannot get past it; one that reached for the right id has had to look at who it belongs to. get_gdpr_request reports the address to pass.

The order of operations is the module's own, copied rather than reinvented: the request is marked resolved before the anonymization runs, so a failure between the two leaves the data destroyed and the request visibly closed rather than destroyed and still pending — which would invite a second call with no record that the first had already happened.

deny_gdpr_request destroys nothing and needs no such check, but it does require a reason: a denial is the part of this process anyone auditing it will ask about.

Cookie groups are read-only, and that is a property of the data model rather than a policy: the consent log records decisions in four fixed columns matching the four seeded groups, so a fifth group would appear in the banner with nowhere to store what a visitor answered about it. The cookies inside those groups are writable, because that registry is what a cookie policy is generated from and it drifts every time a script is added.

Attachments are listed, never served

list_helpdesk_attachments and list_rma_attachments report what a customer attached — name, size, type, and which message or comment it arrived on — and never the file itself. The row is a pointer to a file under the media directory, so a tool that returned its bytes by id would be a file-read primitive bounded by nothing but which ids an agent can guess, aimed at the most personal content in either module. What an agent needs in order to decide what to do next is whether the receipt is there, and the admin is one click away for the file.

delete_rma_attachment exists for the case nobody plans: a customer attaches something that should not be on the record at all — a bank statement, a passport photo — and it has to come off. Leaving personal data in place because the only way to remove it is the admin is the worse failure.

Product attachments: written beside the product, not through it

list_product_attachments, save_product_attachment and delete_product_attachment cover magenxcommerce/module-product-attachments (1.1 or later), which keeps one row per attachment in magenx_product_attachment — an uploaded file under pub/media/<media directory>/<sku>/, or an external link. Like the RMA module, it is a hard dependency.

update_product cannot do this job. The product form's grid is synced by an observer on product save that acts only when the grid's own field is in the posted data, and no API save carries it; a custom_attributes entry under that name is dropped by Magento because it is not an EAV attribute. So these tools write through the module's AttachmentRepository, the same seam the observer uses, and store an upload where the form's uploader would.

  • An upload never overwrites. A name already taken in the product's folder gets _1, _2… — an earlier row, and every order email already sent with it, points at the old file. For the same reason an upload's bytes cannot be swapped through save_product_attachment; delete the row and add again.
  • The module's own limits apply. Its allowed extensions and maximum file size, under 4 MB decoded for the JSON-RPC body, and only http(s) links. Writes are refused while the module is disabled.
  • The product's cached pages are dropped. A row in its own table changes no product, so each write fires clean_cache_by_tags for cat_p_<id> itself; otherwise the storefront keeps serving the attachment list it cached.
  • An attachment id is honoured only with its own sku, so a mistyped id cannot reach another product's file.

Admin users: enough to assign work, and no more

assign_helpdesk_ticket refuses an admin_user_id that does not exist, which left assignment as the one workflow an agent could not complete on its own: the id had to come from outside the conversation. search_admin_users is the lookup that closes that — help desk tickets assign to core admin users, so it serves Magento-proper assignment and the help desk alike, and there is no separate help desk agent table behind it.

It is a whitelist projection, not a filtered row. admin_user also holds the password hash, the password-reset token and the lockout counters; the tool returns the id, username, name, email, active flag and last login, and nothing that could be replayed as a credential. It sits behind Magento's own Magento_User::acl_users — the "All Users" resource — so an integration that should not enumerate staff simply does not get it, and the tool is then neither listed nor callable.

Admin identities are personal data too, and the note about customer reads applies unchanged here: reads are audited only under audit_reads, so grant the resource to the integrations that need it rather than by default.

There is deliberately no tool that creates, edits or disables an admin user, and none that touches roles. An endpoint that could mint an admin account, or widen the role its own token authorizes against, would make every other guard in this module advisory.

Diagnostics, and the one resource this module defines

cache_status is the missing half of flush_cache and the cache-side counterpart of indexer_status. A change that "is not showing up" is nearly always one of three states it reports: a cache type that is invalidated and holding stale content, one that is enabled and simply not cleaned yet, or one that was switched off entirely — which is a performance problem rather than a staleness one, and invisible from the outside.

set_cache_state is how a type is switched, and it counts as a write for the reason flush_cache does: no data changes, but a live site does. The configuration cache cannot be disabled through this server. Magento runs with any other type off, slowly; with config off it re-reads and re-merges every configuration file on every request, which on a live store is an outage — and re-enabling it through this same endpoint would be the first thing to time out. bin/magento cache:disable is still there for someone who means it.

Three tools have no stock Magento resource that fits, because they describe the installation rather than the store, so they sit behind Magenx_AiMcp::ops, one of the three resources this module defines for tools of its own. It is declared under Magenx_AiMcp::server in etc/acl.xml, so ticking the server in a role's tree offers them together, and leaving it unticked means an integration can use the endpoint without reading any of them:

  • cron_status says whether cron is running at all. Several tools here hand work to cron rather than doing it — invalidate_indexers marks indexers for a rebuild only cron performs — so a store whose cron has stopped is a store where those tools report success and nothing changes. An empty schedule usually means cron has never run, not that every job succeeded.
  • list_modules reports what is installed and what is switched on. A disabled module is installed and inert, which from the outside looks exactly like a tool that silently does nothing.
  • read_audit_log reads this server's own log, and only that file: the name is fixed and there is no path argument, because a log tool that can be pointed elsewhere is a tool for reading any file the web server can.

Logs: read by name, tail only, masked

list_logs, read_log and read_error_report read Magento's own logs in var/log and the error reports in var/report, behind Magenx_AiMcp::logs — a grant of its own, not part of ops. Logs hold whatever any installed module chose to write: customer e-mail addresses in exception messages, request payloads from payment modules, now and then the header that authenticated a failed API call. Reading them is a decision about an integration, not a side effect of letting it check cron.

  • No path argument. read_log takes a file name, and honours it only if it is one list_logs produced: a top-level *.log file in var/log, or a rotated *.log.N sibling. What is read is the listing's own entry, never the request's string, so ../app/etc/env.php has nothing to match and is refused as an unknown log. Subdirectories and other extensions are not listed. read_error_report takes a report id, which must be digits and nothing else.
  • Not the audit log. magenx_ai_mcp.log is left out of the listing; read_audit_log reads it behind ops, so logs cannot be used to see which integration changed what.
  • Tail only, bounded everywhere. At most the last 1 MB of a file is read off disk, at most 500 entries are returned (50 by default), and one answer is capped at 200,000 characters, dropping the oldest entries first.
  • Entries, not lines. An exception's stack trace is written on the lines after its record; counting lines would let one exception crowd out everything, and filtering lines would split a trace from its message. So the tail is grouped into entries at each [YYYY-MM-DD timestamp, contains filters whole entries, and each trace keeps its first trace_frames frames (5 by default, 15 for a report, at most 50) with a count of the rest. Lines longer than 1,000 characters are clipped.
  • Masked, best effort. Every line returned passes through Model/Tool/Log/LogRedactor.php: e-mail addresses, Bearer/Basic credentials, the value of any password/secret/token/api_key-like pair (in key=value, query-string and JSON form), digit runs that pass the Luhn check as card numbers, long opaque tokens containing digits, and the host octet of IPv4 addresses. contains matches the masked text, so it cannot be used to confirm an address is in the log. What no pattern recognises — names, street addresses, phone numbers in free text — can remain, which is why this is a grant to give narrowly rather than a filter to rely on.
  • Reports in the stock layout. Reports written with MAGE_ERROR_REPORT_DIR_NESTING_LEVEL above zero live in subdirectories and are not found.

Reports: SQL where it has to be, and why

sales_summary, sales_by_period, top_products, order_status_breakdown, customer_summary, sales_by_country, category_sales, coupon_performance, buyer_segments, abandoned_carts and inventory_risk sit behind Magenx_AiMcp::reports, a grant of their own. "What did we take last month" and "show me this customer's order" are different permissions — which is why Magento's own reports live under a separate tree — so these do not borrow Magento_Sales::actions_view like the order tools.

Almost every other tool here reads through a repository or a collection — the exception is get_contextual_price, which reads the price index the same way. These compute SUM and GROUP BY directly, because the alternative is fetching orders and adding them up in PHP, which is the "pull rows into a model's context" this server exists to avoid and is capped at 100 rows a page anyway. Magento's aggregate tables were the other option and are deliberately not used: a nightly cron fills them, so they lag a day, and on a store that has never refreshed statistics they are empty — which would report zero revenue for a store with orders, confidently.

Four things these tools are careful about, because each produces a plausible wrong number rather than an error:

  • Canceled orders are excluded from the totals and reported as their own figure. Counting them overstates revenue; dropping them silently hides a cancellation spike.
  • Money is read from the base_* columns and grouped by base currency. Every other projector in this module reports the order currency, so a multi-currency store gets one totals block per currency — adding 100 EUR to 100 USD would produce 200 of nothing.
  • Only top-level order lines are counted for product figures. A configurable product writes both itself and its simple variant to sales_order_item, so counting every row doubles every configurable sold.
  • Ordered, invoiced and refunded are all reported, with a net. Each answers a different question, and picking one to call "revenue" answers the other two wrongly.

Periods are read in the store's timezone, not UTC — the one place this server departs from the convention every other date argument follows. A daily figure computed in UTC disagrees with the admin's own by however many hours the store is offset, and "yesterday" means the merchant's yesterday. Every result therefore reports both the local range and the UTC range it actually queried, so a follow-up search_orders call can be made to cover the same orders. Period totals are exact across a daylight-saving change; individual buckets near one can be an hour out at the edge, which is the same approximation Magento's own reports make.

Named and rolling periods. Besides today, yesterday, last_7_days, last_30_days, this_month, last_month, this_year and last_year, every report takes this_quarter, last_quarter, mtd, qtd, ytd and a rolling last_<n>_days|weeks|months|years — last_18_months is the eighteen months ending today. Rolling rather than whole calendar months because that is what last_30_days already meant; for whole months use last_month or date_from/date_to. A month counted back from the 31st clamps to the shorter month rather than overflowing into the next one.

compare_to on sales_summary and sales_by_period adds a second period: previous_period (the same length immediately before — and a range of whole months shifts by whole months, so February is compared with January, not with the 28 days before it) or previous_year (the same dates a year earlier, month ends kept as month ends). sales_summary reports the change for each figure, absolute and in percent; the percentage is null when the earlier figure was zero rather than an invented 100%. sales_by_period returns the earlier period's own buckets rather than pairing them day by day, since a month back from a 31-day month does not have 31 days to pair with.

refund_basis on the same two tools decides how refunds are dated. order_date, the default, is what these tools always did: refunds made at any time against orders placed in the period. refund_date counts credit memos issued in the period instead, whatever order they belong to — the money that left in that period — read from sales_creditmemo with canceled credit memos left out. Ordered and invoiced stay by order date either way, so under refund_date the net is "invoiced on this period's orders, less everything refunded in it", which is what a cash view wants and is labelled as such.

sales_by_country is the sales_summary block per country, by billing (default) or shipping address. The address is joined leftwards, so an order without that address type — a virtual order has no shipping address — is reported under a null country rather than dropped, and the countries add up to sales_summary.

category_sales has to choose how a product in several categories is counted, and makes the caller choose too. split (the default) divides each line between the product's categories, so the categories add up to exactly what was sold — line_totals is that figure, less sales of products in no category. full counts the line in every category, which answers "how much did anything in Shoes sell" but adds up to more than was sold, and the result says sums_exceed_total: true so nobody adds it up by accident. Root categories are left out of both. parent_category_id reports only that category's descendants, and the split is then between those alone. A category's average order value is its revenue over the orders containing it. Membership is today's: Magento keeps no history of which category a product was in.

coupon_performance groups by coupon code and reaches the rule through the coupon table, not applied_rule_ids, which also lists automatic rules. Beside the codes it reports the baseline — orders and average order value with and without a coupon — because a coupon's average order value means nothing on its own. Discounts are negative, as Magento stores them.

buyer_segments reports new versus returning, which customer_summary does not. "New" needs each buyer's first order ever, so it reads every buyer's history up to the end of the period — still one query: an inner GROUP BY per buyer yields first order, last order and the period's orders and revenue, and an outer one counts buyers into segments. No row reaches PHP but the totals. A buyer is a lower-cased e-mail address, so a guest checkout and a later account are one person; inactive_buyers counts known buyers whose last order is more than inactive_days before the period end. On a large store this is the heaviest query here: it scans sales_order up to the period end.

abandoned_carts reads quote rather than orders: active, with items, last changed at least inactivity_hours ago and no more than max_age_days ago. It returns count, subtotal and items per base currency, how many carts have an e-mail address to follow up on, and the most valuable carts with that address — which makes it the one report carrying personal data. It is marked so, and is recorded by the personal-data read audit.

inventory_risk divides quantity on hand by units sold per day over the last velocity_days, and lists what runs out within threshold_days, soonest first. Units are counted on simple, virtual and downloadable lines — a configurable sale counts against the variant that left the shelf — less any canceled quantity. Quantity is the legacy cataloginventory_stock_item figure that get_stock reads, which under MSI is the default source only. A row inheriting "manage stock" from configuration is assumed managed, Magento's default.

Security patches and upgrades: the Platform module's two tabs, as data

security_status and upgrade_compatibility return the Platform module's Security and Compatibility tabs unrendered. Every finding is made there, by Magenx\Platform\Model\Security\SecurityReport and CompatibilityReport, so an agent and an admin reading the tab cannot disagree. Both sit behind Magenx_Platform::platform, like platform_status, and both advertise openWorldHint, because they ask public services about this store's release and locked packages. See the Platform module's README for what they read and from where.

In short: security_status reports the installed release, the Adobe bulletins open against it (magento.watch), whether this installation records the isolated patch for each — composer-patches 1.x/2.x records, composer.json declarations, m2-hotfixes/ — in the order they go on, and Packagist advisories against composer.lock, as composer audit would. no_evidence means no record was found, not that the patch is absent: a patch applied by hand leaves none.

upgrade_compatibility checks the recommended release, the newest patch on this line and the newest release, or one target_version. PHP is a blocker only when the release's own composer metapackage excludes it; the services are warnings against the versions Adobe tested; and every third-party package in composer.lock whose requirements exclude what the release pins is a blocker, because composer would refuse the upgrade. include_outdated adds the direct dependencies that have a newer stable release, at one lookup per package on a cold cache.

Search terms and reviews

search_terms reads Magento's own search log, search_query, which keeps one row per term and store with a running popularity count and the result count of the latest search. By default it lists only terms whose latest search found nothing — the gaps in the catalogue or its synonyms. The log keeps no history, so there is no period: popularity is since the term was first searched.

search_reviews, get_review and set_review_status cover the moderation queue: list pending reviews with their rating votes, read one, approve or reject it. Status is reported by name (approved, pending, not_approved) as well as Magento's id. After a status change the product's rating summary is re-aggregated straight away, as the admin does. Reviews carry the reviewer's nickname and customer id, so the two reads are marked personal data. All three sit behind Magento_Review::reviews_all.

Beneath and beside Magento

Two companion modules answer the questions the tools above structurally cannot, and each keeps its own ACL resource rather than borrowing Magenx_AiMcp::ops — an integration that should read infrastructure health has no business reading who edited what.

platform_status (Magenx_Platform::platform) reports live health for MariaDB, Redis, RabbitMQ, OpenSearch, PHP-FPM and Nginx. Everything else here describes Magento; this describes what Magento is standing on, which is where the cause usually is when cache_status, indexer_status and cron_status all look fine and the store is still slow. Probing is real work against live backends, so the enabled-collector list is a gate rather than a default: a code the store has not enabled is refused, naming the ones it has. A backend that cannot be reached comes back as one unavailable entry with its reason, never as a failed call. Repeated calls inside the module's cache TTL are answered from its snapshot, which matters more here than in the admin — an agent in a loop can call a tool far faster than anyone can click Refresh.

search_admin_activity and get_admin_activity (Magenx_AdminActivity::activity) are the other half of read_audit_log. That tool sees only writes made through this server; these read what admin users did in the admin, which is where nearly every change to a store actually comes from, with the before and after value of every field. Two things they are careful about: an unknown action_type or status is refused rather than filtered on, because an empty page reads as "nobody did this" rather than "you asked for something that cannot exist"; and every result carries logging_enabled, because an empty page from a store with logging switched off looks identical to one where nothing happened. Values are clipped at 2 KB and say so where they are — a clipped value that reads as complete is worse than none, when the question is what a field changed from.

Product feeds: one slice per call, and it publishes outward

The feed tools wrap Magenx_ProductFeed, which defines catalogue exports, renders them from a template language into XML, CSV, TSV or JSONL, publishes them under pub/media and delivers them by public URL, FTP/SFTP, the Google Merchant API or a push catalog API such as Meta's.

Three things about generate_feed are not what a reader would assume, and each would otherwise be found out the hard way.

It is one slice, not one feed. The export runs until the store's configured execution budget is spent, writes its cursor and stops; cron continues from there. On any real catalogue the first call therefore reports that it generated part of the feed — and that is a success. The module is explicit about it: a tick that used its budget has completed false and failed false, which is a normal result and not an error. The tool says so in words as well as in the flag, because "completed: false" beside no error message is exactly the shape an agent reads as a failure. Nothing is published or delivered until a run finishes, so a store whose cron has stopped is a store where this never completes — the same trap invalidate_indexers carries, and cron_status is still the tool that answers it.

A completed run publishes outward immediately. FeedManager::process() is not only a generate: when a run finishes, the feed is delivered to every active destination, which may be an FTP drop, Google Merchant Center or Meta. That is not routed around, because push destinations are prepared before the run and fed records as products are exported — generating without delivering would mean going around the module's single entry point, and a feed Google is meant to receive would silently never arrive. So generate_feed calls what the admin button, the CLI and cron all call, names the destinations a completed run will reach in its result and in its confirm preview, and sits behind Magenx_ProductFeed::generate rather than ::feed. Defining a feed and publishing the catalogue outward are different permissions, and the companion module already says so by declaring two resources.

Feeds are off by default. The module ships disabled, and process() answers a disabled store with the same "skipped" result it uses for "a run is already in progress" — distinguishable only by an English message. Rather than match on that prose, generate_feed checks the setting itself first and refuses with the config path to switch on, so a skip that does arrive can only mean the export lock is held.

What the write tools will not do. Conditions are not writable, the same call made for price rules: a feed's filter is Magento Rule machinery that needs the admin's nested post array. Unlike a price rule this is not a hazard — a feed with no conditions exports the whole catalogue, which is what most feeds are for — and update_feed loads and mutates rather than rebuilding, so a curated feed keeps its filter through an edit that does not mention it. The store view is settable only on create: a feed publishes under its store's own directory and its URL secret is never rotated, so moving one would leave the old file being served at the old address indefinitely while the row pointed elsewhere. update_feed and delete_feed both refuse while a generation is in progress, because the export writes its cursor and counts straight to the row and saving a feed loaded before the run would put the pre-run values back on top of them.

One refusal the admin does not make. A feed carrying both a template and a field map renders from the template and ignores the map entirely — deliberately, upstream, and with no error. Through an API that means writing columns, getting a clean save, generating, and finding none of them in the file. So the write tools check the resulting row and refuse, naming both ways out. get_feed reports render_mode for the same reason: which of the two is actually live is worth more than the two fields and a reader left to work it out.

delete_feed leaves the published file. Deleting cascades the delivery and history rows but the file stays where it was, because a marketplace may still be fetching that URL and removing it would turn a deletion here into a broken feed on somebody else's platform. This is the opposite conclusion from delete_media_gallery_asset, which refuses precisely because references would break, and the difference is who holds the reference: a CMS page is inside this store and can be fixed, a Google Merchant Center fetch schedule is not. So the result names the file that is still being served — an agent that reads "deleted" as "it has stopped being published" would be wrong for as long as the marketplace keeps fetching.

Destinations are read-only. list_feed_deliveries reports where a feed goes and how each destination's last attempt went, and withholds every setting whose key ends in password, token, secret or key. That suffix rule is the module's own, chosen there so a new deliverer's api_password is covered without anyone remembering to add it; copying the rule rather than a list of key names means this cannot drift into printing a credential. Withheld keys are reported by name, because knowing a password is configured at all is what distinguishes a broken destination from one nobody ever set up.

Quick search promotions: a saved row is not a shown one

The promotion tools wrap Magenx_QuickSearchGraphQl's sponsored items — the products, categories and brands its quickSearchSuggestions query puts in the storefront's search dropdown. They sit behind the module's one grant, Magenx_QuickSearchGraphQl::promotion, which guards the admin grid and every action on it.

The admin's checks are repeated, and tightened. The module validates in its admin controller, not its model, so the tools carry the checks themselves: a known type, a product SKU that exists, a numeric category or brand id, and active_to not before active_from. They also refuse three things the admin lets through, because each saves cleanly and then shows nothing: a category or brand id that does not exist, a store view that does not exist, and an image that is a URL or a path to no file. The storefront loads images only through its own /media proxy, so image is a path under pub/media — the one upload_media_gallery_asset returns. Dates are YYYY-MM-DD; the admin parses them in the admin user's locale, and there is no locale here. Keywords are an array, and a keyword containing a comma is refused rather than split, because the column is comma-separated.

Whether it shows is answered per store view. get_quick_search_promotion and the result of every create and update carry a storefront entry for each store view the promotion applies to — every store view, for store_id 0 — saying whether the dropdown shows it today and, if not, the first reason why. The reasons are checked in the order the resolver applies them, by calling the module's own Config, PromotionProvider and TargetLoader, so the answer cannot drift from the storefront: promotions switched off, the row disabled, today outside its dates in that store's timezone, past the Maximum Items cap, or a target the storefront drops (a disabled, invisible or out-of-stock product, an inactive category, a deleted brand option). The cap is applied to the pool before targets resolve, so a promotion past it is reported as cut by the cap even when its target is fine. Keyword matching is not checked; it happens in the browser. A disabled or out-of-stock target is reported, not refused — scheduling a promotion ahead of a launch is ordinary.

Writes go through the module's repository, so the model's cache tag is cleaned and the cached pool is rebuilt on the next dropdown open; nothing needs flushing. delete_quick_search_promotion returns the deleted row, which is the only undo there is.

The module's settings are not tools. Switching promotions or popular terms on and off, and their counts, is get_config / set_config on magenx_quick_search/promotions/{enabled,max_items} and magenx_popular_search/general/{enabled,count} — the popular-terms fields keep the paths of the module they replaced. Add those to magenx_ai_mcp/security/allowed_config_paths for set_config to write them.

Carts and orders: the one place this server commits money

Until now the server could act on orders that existed — invoice, ship, refund, hold, cancel — and could not create one. Eleven tools in Model/Tool/Quote/ build a cart and turn it into an order, and place_order is the most irreversible call the server has.

place_order sits behind Magento_Sales::create, not the Magento_Cart::manage the other write tools take. Assembling a basket and committing a customer to buy it are different permissions, so an integration can be allowed to price up a cart without being allowed to charge for one. Reads take Magento_Cart::cart; all three are Magento's own resources, from Magento_Quote/etc/acl.xml and Magento_Sales/etc/acl.xml.

Only offline payment methods work here, and that is not a limitation to route around. Check/money order, bank transfer, cash on delivery, purchase order and the zero-total method can be driven to a placed order. An online gateway cannot: it finishes at a payment page the customer has to be in front of, and selecting one does not fail cleanly — it fails somewhere inside the payment integration during placeOrder, by which point the quote may be half converted. So set_cart_payment_method intersects what the cart actually offers with the five offline codes and refuses everything else before setting anything. The two refusals read differently on purpose: an online method is refused for a reason no configuration will change, an offline one the cart does not offer is refused for a reason an operator can fix. list_cart_payment_methods still lists every method the store offers, marking which are usable, so a short list never reads as "this store takes no payments".

create_cart requires a store view. CartManagementInterface::createEmptyCart() takes no store at all — Magento resolves one from ambient scope, which in a headless request is whatever the controller happens to be in rather than a storefront anybody chose. The store decides the cart's currency, prices, tax and which shipping and payment methods exist, so an unspecified one means an order priced in the wrong currency that looks exactly like an order priced in the right one. The admin scope is refused outright: it is not a storefront and has no prices at all.

Addresses and the shipping method are one call. set_cart_delivery takes the billing address, the shipping address and the carrier/method together, because that is the only order Magento supports — a shipping rate depends on the destination, so the method cannot be chosen before the address is known. Call estimate_cart_shipping first to get a valid carrier_code/method_code pair. A cart of only virtual or downloadable products ships nothing and takes a different path entirely: billing address alone, no carrier, and passing shipping details to one is refused rather than ignored.

Prices are the store's. add_cart_item and update_cart_item take a sku and a quantity and no price, although Magento's cart item would accept one. An agent naming its own price is a discount with no rule behind it, no record of who authorised it and nothing in the catalogue to reconcile the order against — and it would be invisible on the finished order, which shows a price without saying where it came from. Discounts belong to cart price rules, which this server already reads and writes.

A placed cart is still a cart. Placing an order deactivates the quote rather than deleting it, so a placed cart still loads, still lists its items and still answers every getter — it simply ignores anything written to it. Every write tool here refuses an inactive cart and names the order it became, because otherwise an agent would run add-item, set-delivery and set-payment, see success at each step, and change nothing. The same distinction matters when reading: in search_carts, is_active: false with a reserved_order_id means bought, and is_active: true with a stale updated_at means abandoned. Reading those the wrong way round turns "we lost these sales" into "we made them".

search_carts is also the read side of abandoned checkout, which nothing here could see before. It requires at least one filter, for the reason search_url_rewrites does, and returns no money: a cart carries none, and totals are a service call per cart that a page of them cannot afford. get_cart reads one in full.

The four guards

  1. The module switch. magenx_ai_mcp/general/enabled is off by default; the route 404s.
  2. ACL. The integration — or, for an OAuth connection, the admin who approved it — must hold Magenx_AiMcp::server to reach the endpoint, plus the tool's own resource to call it. The endpoint grant is checked in Model/Protocol/Server.php before any method is dispatched, so a token whose role lacks it cannot even complete initialize, and gets 403. Every tool's resource is one of Magento's own except Magenx_AiMcp::ops, which this module declares for the three diagnostics Magento has no resource for — see below.
  3. The write switch. magenx_ai_mcp/security/allow_writes is off by default. While off the endpoint is strictly read-only, whatever the ACL says.
  4. The confirm gate. A write tool called without "confirm": true returns a preview of exactly what it would do and changes nothing.

An OAuth connection adds a fifth, narrower than the write switch: the admin grants write access per connection, and one approved without it is read-only whatever the role and the switch allow.

Configuration writes have two more gates: a hard-coded denylist of secret-bearing paths (*password*, *secret*, *token*, admin/*, oauth/*, crypt/*, and this module's own settings) that no allowlist can open, and the store's magenx_ai_mcp/security/allowed_config_paths glob allowlist, which is empty by default and therefore denies every write until an operator names the paths they want managed. get_config redacts secret values rather than returning them.

Every confirmed write also runs inside Magenx_AdminActivity's external-actor scope, so the entities it changes appear in System → Admin Activity Log beside admin-panel edits, with field-level before and after values, source = mcp, the connector (OAuth client or integration name) and the role (the admin's role at the time, or Integration "<name>"). An OAuth connection or admin token is recorded as that admin; an integration as integration:<name>. Only entity types the activity log tracks are recorded, exactly as for the admin panel, and nothing is recorded while that module's logging is switched off. search_admin_activity filters on source, connector and role_name.

Every attempted write is logged to var/log/magenx_ai_mcp.log with the calling integration, the arguments and the verdict, and successful reads too when audit_reads asks for them. read_audit_log reads the end of that file back, which is worth a deliberate decision rather than a reflex: the log cannot be erased or altered through this server, but an integration holding Magenx_AiMcp::ops can see which integration changed what. Grant it narrowly. That is the same argument as the read-audit note in the customer section, pointing the other way.

Configuration

Stores → Configuration → Magenx → AI MCP Server

Path Default Meaning
magenx_ai_mcp/general/enabled 0 Serve the endpoint at all
magenx_ai_mcp/security/allow_writes 0 Allow tools that change data
magenx_ai_mcp/security/allowed_ips (empty) Optional source-address allowlist; plain addresses and CIDR ranges. See the proxy caveat below
magenx_ai_mcp/security/allowed_origins (empty) Browser origins permitted to call the endpoint. Requests with no Origin header are unaffected; empty therefore means "no browser"
magenx_ai_mcp/security/allowed_config_paths (empty) Glob patterns set_config may write; empty denies all
magenx_ai_mcp/security/audit_reads off Also record successful reads: off, personal_data or all. Records ids, never contents
magenx_ai_mcp/security/rate_limit 120 Tool calls per caller per minute; 0 disables. A caller is one integration, or one admin across all their OAuth connections
magenx_ai_mcp/security/write_rate_limit 30 Confirmed writes per caller per minute, on top of the above; 0 disables
magenx_ai_mcp/tools/enabled_domains (empty) Tool domains to offer; empty means all
magenx_ai_mcp/tools/disabled_tools (empty) Individual tool names to withhold
magenx_ai_mcp/oauth/enabled 0 Accept OAuth sign-ins — see Connect claude.ai with OAuth
magenx_ai_mcp/oauth/allow_dynamic_registration 0 Let anyone register a client (RFC 7591). Off: only clients created under AI MCP Connections can connect
magenx_ai_mcp/oauth/allowed_redirect_uris claude.ai's callback Callbacks a client may be created or registered with, matched exactly
magenx_ai_mcp/oauth/allow_loopback_redirects 0 Also accept http://localhost / 127.0.0.1 / [::1] callbacks on any port
magenx_ai_mcp/oauth/access_token_lifetime 60 Minutes, 5–1440
magenx_ai_mcp/oauth/refresh_token_lifetime 30 Days a connection lasts from approval, 1–90; never extended by use
magenx_ai_mcp/oauth/public_base_url (empty) Origin clients use, when not the default store's secure base URL

Narrowing the toolset is about context, not permission

Every other setting above is a boundary. These two are not: they decide what a client is shown, and ACL plus the write switch still decide what it may actually do. The reason to use them is that the full tool list is a large part of a client's context window, and an agent given only the domains its task needs plans better inside it.

Both are empty by default and empty means "everything" — the one place this module is not default-deny. It has to be: an empty domain list that meant "no domains" would silently empty tools/list on every installation the moment this shipped.

Domains are derived from the directory each tool lives in — sales, catalog, cms, rma, helpdesk, ops and so on — rather than from a list kept by hand, so a tool added tomorrow lands in a domain without anyone maintaining anything. A tool contributed by another module that does not sit under Model/Tool/ is grouped under its vendor and module instead, so a whole third-party module can be switched off in one tick.

Both filters are applied before a tool is built rather than after. Every tool is registered as a lazy \Proxy, so narrowing the toolset makes the request cheaper as well as the context smaller: the tools in a domain you switched off are never constructed, and neither are their repositories and collection factories.

disabled_tools is applied after the domain selection, so a name listed there is withheld even when its domain is enabled. Saving a name that no tool answers to is refused, with the nearest real name offered: a denylist whose typo is accepted and ignored leaves you believing a tool is off when it is on, which is the wrong direction to fail in.

A withheld tool is neither listed nor callable, and calling one is answered Unknown tool — exactly what a tool the integration's ACL denies gets, so an agent learns nothing about a surface it cannot reach and does not keep trying. Because that answer says nothing, the refusal is written to var/log/magenx_ai_mcp.log instead, which is where to look when the question is "why can't the agent see this tool". If the settings ever leave tools/list completely empty, that is logged too — a handshake that succeeds and then offers nothing otherwise reads as a broken server with no error anywhere to explain it.

Narrowing per connection, from the URL

The two settings above apply to every integration. A client can narrow further for itself, with no admin change, by putting the slice it wants in the endpoint URL:

https://your-magento-host/magenx-mcp/server?profile=analytics
https://your-magento-host/magenx-mcp/server?domains=sales,report,store
https://your-magento-host/magenx-mcp/server?tools=search_orders,get_order
  • profile is a named domain set: analytics (reports, sales, customers, carts, promotions, stock, search terms, reviews), catalog (products and everything hanging off them, plus reviews and search terms), content (CMS, widgets, e-mail, design, media, blog), support (orders, customers, carts, returns, help desk, GDPR, admin users, reviews) and ops (caches, indexers, cron, logs, config, platform, security patches and upgrades, admin activity). Each also includes store, because most tasks start with list_stores.
  • domains is a comma-separated list of domain codes — the same codes as the Enabled Tool Domains setting.
  • tools is a comma-separated list of tool names.

They combine by intersection, and they only ever narrow: the URL is applied on top of the store's own toolset settings, ACL and the write switch, never instead of them, so nothing a client puts in its URL reaches a tool those would withhold. A tool outside the slice is not listed and is answered Unknown tool.

A name that matches nothing is refused with -32602 rather than ignored — a typo silently dropped would leave the client with either far fewer tools than intended or no narrowing at all. An unknown domain is answered with the list of real ones; an unknown tool name is not, because the full list includes tools the caller's role may not reach.

This is the cheapest way to point several agents at one store: register the same endpoint twice in .mcp.json under different names and profiles, and each pays only for the tools its task needs. The initialize handshake names the areas the connection offers, so the first thing an agent reads already matches what tools/list will hold.

allowed_ips measures REMOTE_ADDR, not X-Forwarded-For

Magento's RemoteAddress is constructed with no alternative headers and no trusted proxies, so it reads REMOTE_ADDR and never consults X-Forwarded-For. Behind the nginx reverse proxy this stack uses, that is nginx's own address unless the real_ip module is rewriting it:

set_real_ip_from 10.0.0.0/8;     # your proxy / load balancer
real_ip_header   X-Forwarded-For;
real_ip_recursive on;

Without that, filling in allowed_ips locks the endpoint out entirely — it fails closed, so this is safe rather than dangerous, but it looks like the token has stopped working. Refusals are logged with the address that was actually observed:

grep 'source address refused' var/log/magenx_ai_mcp.log

allowed_origins is the DNS-rebinding guard

The MCP transport requires servers to validate the Origin header. Only browsers send one — curl, Claude Code and every ordinary MCP client send none and are unaffected by this setting. A request that does carry an Origin is refused with 403 unless it is listed, so the empty default means no browser may call the endpoint. Leave it empty unless you are building a browser client.

Install

bin/magento module:enable Magenx_AiMcp
bin/magento setup:upgrade
bin/magento cache:flush

Then, in admin:

  1. System → Extensions → Integrations → Add New Integration. Give it a name and leave Callback URL and Identity Link URL empty — those drive Magento's OAuth 1.0a token exchange, for an integration hosted by a third party that provisions itself. Filling in a Callback URL changes what Activate does: Magento tries to POST the credentials to it and activation fails if nothing valid answers. A manually activated integration needs neither.
  2. On the API tab grant at least AI MCP Server, plus the resources for the tools you want: Catalog → Products / Categories, Content → Blocks, Stores → Settings → Configuration, System → Cache Management, System → Index Management.
  3. Save, then Activate. The popup shows four values — the one to copy is the Access Token, not the Consumer Key or the Access Token Secret. It is what goes in Authorization: Bearer <token>.
  4. Stores → Configuration → Magenx → AI MCP Server: enable the endpoint. Leave writes off until you have watched it read.

Use an Integration token, not POST /V1/integration/admin/token. Admin-user tokens expire (4 hours by default) and the server will start refusing them mid-session; integration tokens do not expire.

Connect Claude Code

From Claude Code on the web — use the repo's .mcp.json

This is the path for cloud sessions at claude.ai/code, and it needs no CLI. A repository's .mcp.json is part of the clone, so it carries into a web session; claude mcp add does not, because it writes to your local user config rather than the repo.

The file is already committed at the repo root:

{
  "mcpServers": {
    "magento": {
      "type": "http",
      "url": "${MAGENTO_MCP_URL:-https://www.magenxcommerce.com/magenx-mcp/server}",
      "headers": { "Authorization": "Bearer ${MAGENTO_MCP_TOKEN}" }
    }
  }
}

url and headers both support ${VAR} / ${VAR:-default} expansion, so the token never enters the repository. Supply it as an environment variable:

  1. In the web UI, click the cloud icon showing the environment's name, hover the environment, and click the settings icon.

  2. Under Environment variables, add one KEY=value per line, unquoted — quotes are stored as part of the value:

    MAGENTO_MCP_TOKEN=<access token>
    MAGENTO_MCP_URL=https://your-magento-host/magenx-mcp/server
    
  3. Start a new session. Environment variables and MCP servers are both applied when the session container starts, so an already-running session will not pick them up.

Two things to know. Cloud environments have no dedicated secrets store — variables live in the environment configuration and are visible to anyone who can edit it, so scope the Integration's ACL to what the agent actually needs and rotate the token (Reauthorize) when you are done. And if a variable is missing with no default, the config still loads: Claude Code warns and passes the literal ${VAR} text through, which reaches Magento as a malformed token and returns 401 — so a 401 here usually means the variable is unset, not that the token is wrong.

From the Claude Code CLI

claude mcp add --transport http magento \
  https://your-magento-host/magenx-mcp/server \
  --header "Authorization: Bearer <access token>"

This writes to your local user config, which makes the server available across all your projects — but it does not travel to cloud sessions. Use .mcp.json for anything the team or a web session should share.

From claude.ai "Custom connectors" — with OAuth

Turn OAuth on, create a client under System → Extensions → AI MCP Connections, and enter its ID and secret in the connector's advanced settings. See the next section.

Do not put a token in the connector URL (?token=…). URLs are recorded in nginx access logs, proxies and browser history, and the MCP authorization spec prohibits access tokens in the query string.

Verify

Verify with /mcp inside Claude Code, or from a shell:

curl -sS https://your-magento-host/magenx-mcp/server \
  -H "Authorization: Bearer <access token>" \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools[].name'

Connect claude.ai with OAuth

With OAuth on (Stores → Configuration → Magenx → AI MCP Server → OAuth), the server is also an OAuth 2.1 authorization server, and claude.ai, Claude Desktop and Claude mobile connect as a person rather than as an integration:

  1. System → Extensions → AI MCP Connections → Create Client. The page shows the client ID and secret once; the secret is stored only as a hash.
  2. Settings → Connectors → Add custom connector, URL https://your-host/magenx-mcp/server. Under Advanced settings → Use your own OAuth client, enter that client ID and secret.
  3. Claude opens a browser tab on your store. You sign in to the Magento admin as usual — password and two-factor — and land on Connect AI Client.
  4. It shows the client, the host the code goes to, the tool areas to tick, and a box for write access. Approve, and the tab returns to Claude, connected.

The connection then acts as you: your admin role is the ACL every tool is checked against, the audit log names you (admin:alice via Claude), and it ends when you are deactivated, locked out or deleted. Integration tokens keep working beside it, unchanged.

One client can serve every admin who connects through the same AI service; each approval is still its own connection, with its own admin. Deleting the client ends all of them.

Client policy

Each client created on AI MCP Connections has a Policy with three settings, plus Disable and Rotate Secret buttons:

  • Who may approve it. Tick admins to limit approval to them. A phishing link then works only if it reaches one of those admins, and an admin with weaker credentials cannot approve the client at all. Ticking admins also ends the connections of every admin who is not ticked. With nobody ticked, any admin whose role includes Magenx_AiMcp::oauth_grant may approve, which is how clients worked before this setting existed.

  • Tool areas it may use. This is the most any connection through the client can get. On the consent page the approving admin ticks areas within it, and the connection keeps those for its whole life, refreshes included. At each call the connection gets the areas it was approved with that the client still allows, so narrowing the client's areas also narrows its live connections. Like every other toolset setting, this only removes tools. The admin's role, mcp:write and the store's write switch still apply. With every area ticked, there is no limit, and areas added by later releases are included.

  • When an admin approves it again. This applies when the same admin approves the client a second time:

    • Add (the default) keeps both connections.
    • Replace revokes the admin's earlier connections when the new code is redeemed. If the new code is never redeemed, the earlier connection stays.
    • Refuse blocks the new approval while the admin has a live connection. The check runs on the consent page and again when the code is redeemed, so two approvals made at the same time cannot both connect.

    Other admins' connections are never affected.

Disable refuses the client everywhere, with the same invalid_client error an unknown client gets, so a caller cannot tell that its ID still exists. Its connections are kept, and Enable restores them. Nobody has to sign in again. Rotate Secret shows a new secret once, and the old one stops working at once. Connections are kept, but the AI service cannot renew their tokens until the new secret is entered in its connector.

These settings apply to clients an administrator created. A dynamically registered client has none of them, and a new registration would create another such client anyway.

Dynamic client registration is off by default

MCP clients can register themselves (RFC 7591), and the connector dialog then needs only the URL. Allow Dynamic Client Registration turns that on. Leave it off. Every callback a client may use is claude.ai's, where anyone has an account, so open registration lets anyone:

  • learn your admin URL: register a client, start a flow, read the redirect;
  • phish a connection: send an admin the consent link from their Claude account, which, approved, connects their Claude to the admin's role.

With registration off, /magenx-mcp/oauth/register answers 404 and is left out of the metadata, and the authorize endpoint refuses every client an administrator did not create, before it parks anything or redirects anywhere. Such a client is always confidential, so even an approval someone talked an admin into cannot be redeemed without the secret, which only the service the admin configured holds. Keep the client ID private as well: it is not a credential, but whoever has it can start a flow and so learn the admin path.

Upgrading from 1.10 or earlier, where registration was always open: a claude.ai connector that registered itself keeps working until its connection expires (30 days from approval by default) or is revoked. To reconnect, create a client and enter it in the connector, or turn registration back on.

Connector key (optional)

AI MCP Connections → Generate Key creates a second secret. Add it to the connector as the request header x-api-key (claude.ai: Advanced settings → Request headers). From then on every MCP call made with an OAuth token must carry it, or is refused with 403: a leaked access token is useless on its own, and so is an approval someone talked an admin into from their own Claude account, even with dynamic registration on.

What it does not cover: claude.ai sends the header only alongside the bearer token, not on discovery, registration or the token endpoint, so those stay as reachable as before. The refusal is a 403 rather than a 401 on purpose, so the client does not loop through the OAuth flow, which cannot supply the key. An expired token is still a 401 and still refreshes. Integration tokens are not affected. Replacing the key refuses every connection until its connector has the new one; removing it stops requiring one. Only the hash is stored, under magenx_ai_mcp/oauth/connector_key_hash.

Who talks to what

Claude's servers ── /.well-known/oauth-protected-resource/magenx-mcp/server ─┐
(Anthropic egress)  /.well-known/oauth-authorization-server/magenx-mcp       │
                    POST /magenx-mcp/oauth/register   (RFC 7591, if open)    ├─ public, Magento frontend
                    POST /magenx-mcp/oauth/token      (code + PKCE, refresh) │
                    POST /magenx-mcp/oauth/revoke     (RFC 7009)             │
                    POST /magenx-mcp/server           (Bearer mxa_…)         ┘

Your browser ────── GET  /magenx-mcp/oauth/authorize  → parks the request, 302 ┐
                    GET  /<admin>/magenx_aimcp/oauth/consent  (login + 2FA)    ├─ only you
                    POST /<admin>/magenx_aimcp/oauth/decide   (form key)       ┘

Nothing proxies Claude to the admin. Claude's servers only ever reach the public endpoints. The admin is reached only by your own browser, so it can stay behind the IP restriction it already has.

What protects it

  • Two-factor applies. The grant is made on an ordinary admin page, after Magento's login and Magento_TwoFactorAuth. Both finish by redirecting to the admin start-up page, so while a request is pending in your browser (a 10-minute, HttpOnly magenx_mcp_oauth cookie) the start-up page is the consent page. That is the plugin in Plugin/Backend/StartupPageUrl.php.
  • Only clients you created connect. Registration is closed by default, so a flow can only start with a client an administrator created, and that client must present its secret to redeem a code. See Dynamic client registration is off by default.
  • Optionally, a connector key. With one generated, an OAuth token is refused without the x-api-key header. See Connector key.
  • Callbacks are allowlisted. A client, created or registered, only ever gets a redirect URI on allowed_redirect_uris. The default is claude.ai's callback. A code can never be delivered anywhere you did not list. Errors before the callback is verified are shown, never redirected.
  • PKCE S256 is mandatory, plain is refused, codes live 120 seconds and are single-use. A replayed code, presented with its verifier, revokes everything it issued.
  • Short tokens, a fixed end. Access tokens last an hour. Refresh tokens rotate on every use, and a spent one presented again revokes the whole connection. A connection ends 30 days after approval however often it is used, and the admin signs in, with 2FA, again.
  • Tokens are bound to this server's resource URL (RFC 8707), to their client, and to an active, unlocked admin. Only SHA-256 hashes are stored.
  • A password change or a 2FA reset ends them. Saving an admin with a new password, or resetting one of their two-factor providers, revokes every connection that admin holds.
  • Throttled. Registration and authorization are the two calls that need no credential, and each stores a row. Each source address is limited to 20 registrations an hour and 30 authorization attempts in 10 minutes, and an hourly cron removes abandoned requests and registrations that never connected.
  • Two ACL resources of its own. Magenx_AiMcp::oauth_grant lets an admin approve connections at all. Magenx_AiMcp::oauth_manage opens System → Extensions → AI MCP Connections, which lists every live connection with a Revoke button.
  • Write is opt-in per connection. Ticking the box grants mcp:write. That still needs your role, the store's write switch and confirm: true.
  • Per-client approvers, tool areas and re-approval. See Client policy.

Every registration, approval, denial, replay and revocation is written to var/log/magenx_ai_mcp.log.

What it does not protect against

  • Consent phishing and admin path disclosure, once registration is open. Anyone can then start a flow from their own Claude account: the redirect shows them the admin path, and the consent link they send you would, approved, connect their Claude to your admin role. The consent page warns about it. With registration closed, as by default, neither is possible for anyone who does not hold a client's secret. Keep the admin IP-restricted as well, as this stack's nginx already does: that is defense in depth, not the fix.
  • A stolen admin account is not made worse, and not made better. Whoever can sign in as an admin, with the second factor, can approve a connection, just as they could already create an Integration.

Check a caller from the command line

bin/magento magenx:mcp:check --admin=alice
bin/magento magenx:mcp:check --integration="Reporting"

Runs the endpoint's own code for one caller, in-process: the ACL lookup against the store's real roles, the endpoint grant, initialize and tools/list. It prints how many tools that caller is offered and how many of them write, the write switch, the rate limits and the OAuth URLs, and exits non-zero with the reason when the caller could not use the endpoint: no AI MCP Server grant, an inactive admin or integration, or a role that reaches no tool at all. Run it after changing a role, or as a deploy check.

Limits and expiry

  • Rate limits. Tool calls are counted per caller in one-minute windows, 120 by default, with confirmed writes counted again against 30. A call over the limit gets an error the agent can read and wait out; the first refusal in each minute goes to the audit log.
  • Integration tokens never expire. The Platform module's Integrations tab shows each integration's token age, amber from 90 days and red from 180, and platform_status with collector=integrations returns the same. Reauthorize the integration to issue a new token.
  • A deactivated integration is cut off. Its token stays in oauth_token, but the endpoint refuses it from the moment the integration is inactive, as Magento's REST API does.

Route it at nginx — required, and it fails silently

In a headless deployment nginx sends everything it does not recognise to the Next.js storefront, so out of the box POST /magenx-mcp/server never reaches Magento at all. The storefront has no locale middleware, so it treats the path as locale-less and answers 307 → /en/magenx-mcp/server. An MCP client sees a redirect rather than a protocol error and reports the server as broken, with nothing in the Magento logs to explain it.

Add a location block beside your existing location /graphql, in the same server block and pointing at the same Magento upstream:

location /magenx-mcp/ {
    # Optional edge allowlist. Drop this pair if you connect from Claude Code on
    # the web, which egresses through a proxy with no stable address.
    allow 160.79.104.0/21;   # Anthropic published egress range
    deny  all;

    # Same upstream and fastcgi wiring as your existing `location /graphql`.
    try_files $uri $uri/ /index.php$is_args$args;
}

Keep it byte-for-byte consistent with the fastcgi_pass / include lines your /graphql block already uses — the point is only that the path reaches Magento rather than falling through to the storefront.

With OAuth on, the two discovery documents must reach Magento too. They live under /.well-known/, which a headless storefront usually claims. Put them ahead of any location ^~ /.well-known/ block:

location ^~ /.well-known/oauth-protected-resource   { try_files $uri /index.php$is_args$args; }
location ^~ /.well-known/oauth-authorization-server { try_files $uri /index.php$is_args$args; }

The /magenx-mcp/ block above already covers /magenx-mcp/oauth/*. Its allow list now has to admit two kinds of caller. Claude's servers (160.79.104.0/21) call metadata, register, token and the endpoint. The admins' own browsers call /magenx-mcp/oauth/authorize, so list them the way the admin location does.

curl -sS https://your-host/.well-known/oauth-protected-resource/magenx-mcp/server | jq .

should name https://your-host/magenx-mcp under authorization_servers. If the issuer shows the wrong host, set Public Base URL.

Check it took effect:

curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' \
  -X POST https://your-host/magenx-mcp/server
  • 404 with no redirect target — reaching Magento with the module disabled. Correct; enable the module.
  • 307 to /en/… — still hitting the storefront; the block is not in effect.

Restrict the route at the edge too, unless you connect from Claude Code on the web (which egresses through a proxy with no stable address — there the token is the boundary). The allowed_ips setting performs the same check inside Magento provided nginx is rewriting REMOTE_ADDR (see the caveat under Configuration); use both when the agent runs from a fixed address.

Caveats

  • invalidate_indexers does not reindex. A full reindex cannot finish inside an HTTP request, so the tool marks indexers invalid and cron rebuilds them. Run bin/magento indexer:reindex for an immediate rebuild, and cron_status to check cron is running at all before waiting on it.
  • set_config refreshes Magento's config cache but not a headless storefront's. In this stack the Next.js app caches store config under its own ISR tag; purge it through the existing /api/revalidate path.
  • search_products runs in the default scope, which is the right basis for management — it sees every product regardless of store-view overrides. Read a specific store view's values with get_product and store_code.
  • Scope mistakes are the easiest way to go wrong with this server. Omitting store_code writes the default value that all store views inherit; passing it writes an override for that view only. list_stores exists so the agent can check before it writes. Passing null for a field writes an empty override, it does not restore inheritance — clearing an override still means "Use Default Value" in the admin.
  • update_cms_block refuses an ambiguous identifier. Magento allows several blocks to share one identifier across store views. Rather than editing an arbitrary one, the tool errors and lists the matching block_ids; pass block_id to choose.
  • get_config needs at least a section and a group. A bare section (or /) is refused: it would return the entire merged configuration, and redaction only catches values whose path names them as secret.