magenxcommerce / module-ai-mcp
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
Requires
- php: ~8.3.0||~8.4.0||~8.5.0
- magento/framework: *
- magento/module-authorization: *
- magento/module-backend: *
- magento/module-bundle: *
- magento/module-catalog: *
- magento/module-catalog-inventory: *
- magento/module-catalog-rule: *
- magento/module-checkout: *
- magento/module-cms: *
- magento/module-config: *
- magento/module-configurable-product: *
- magento/module-cron: *
- magento/module-customer: *
- magento/module-downloadable: *
- magento/module-eav: *
- magento/module-email: *
- magento/module-indexer: *
- magento/module-integration: *
- magento/module-media-content-api: *
- magento/module-media-gallery-api: *
- magento/module-newsletter: *
- magento/module-offline-payments: *
- magento/module-payment: *
- magento/module-quote: *
- magento/module-review: *
- magento/module-sales: *
- magento/module-sales-rule: *
- magento/module-search: *
- magento/module-shipping: *
- magento/module-store: *
- magento/module-theme: *
- magento/module-url-rewrite: *
- magento/module-user: *
- magento/module-widget: *
- magenxcommerce/module-admin-activity: *
- magenxcommerce/module-blog: *
- magenxcommerce/module-gdpr: *
- magenxcommerce/module-helpdesk: *
- magenxcommerce/module-platform: *
- magenxcommerce/module-product-attachments: ^1.1
- magenxcommerce/module-product-feed: *
- magenxcommerce/module-quick-search-graph-ql: *
- magenxcommerce/module-rma: *
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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_datahas 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 carriesisErrorand 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_activityreports whether admin logging is even switched on, the lookup lists report whichentitythey 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. itemsholds 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_memowithinvoice_idandrefund_onlinesends a refund to the payment gateway and real money leaves the merchant account. Withoutrefund_onlineit records the credit memo offline and moves nothing. Magento has no operation that reverses a credit memo either way, soMagento_Sales::creditmemois the resource to leave unticked on any integration that should not be able to refund.create_invoiceandcreate_shipmentcreate documents that cannot be deleted.create_invoicewithcapture: truealso captures payment through the gateway.capture_invoicecaptures an invoice that was created without capturing, so real money leaves the customer's account. Nothing reverses a capture: undoing one meanscreate_credit_memo, which is the other irreversible tool on this list. It sits behindMagento_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_invoicetells 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_orderreleases 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, andcustomer_summary. These tools carryApi/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_customerpermanently 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 behindMagento_Customer::delete, a separate grant from the one that allows editing customers.create_customersends Magento's account-creation email, andinitiate_password_resetsends a password email. Both reach the customer's inbox the moment the tool succeeds.set_newsletter_subscriptioncan 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_ruleandcreate_catalog_price_rulealways create the rule inactive, andis_activeis 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_ruleandupdate_catalog_price_rulerefuseis_active: trueon 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 answersgetCondition()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.phtmlholding 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_productalso 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_categorydeletes 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_categorytakes the subtree and the products with it, and changes the URL of everything beneath — old links stop resolving without a redirect.remove_configurable_childdoes 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 throughsave_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_tagsforcat_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_statussays whether cron is running at all. Several tools here hand work to cron rather than doing it —invalidate_indexersmarks 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_modulesreports 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_logreads 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_logtakes a file name, and honours it only if it is onelist_logsproduced: a top-level*.logfile invar/log, or a rotated*.log.Nsibling. What is read is the listing's own entry, never the request's string, so../app/etc/env.phphas nothing to match and is refused as an unknown log. Subdirectories and other extensions are not listed.read_error_reporttakes a report id, which must be digits and nothing else. - Not the audit log.
magenx_ai_mcp.logis left out of the listing;read_audit_logreads it behindops, sologscannot 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-DDtimestamp,containsfilters whole entries, and each trace keeps its firsttrace_framesframes (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/Basiccredentials, the value of anypassword/secret/token/api_key-like pair (inkey=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.containsmatches 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_LEVELabove 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
- The module switch.
magenx_ai_mcp/general/enabledis off by default; the route 404s. - ACL. The integration — or, for an OAuth connection, the admin who
approved it — must hold
Magenx_AiMcp::serverto reach the endpoint, plus the tool's own resource to call it. The endpoint grant is checked inModel/Protocol/Server.phpbefore any method is dispatched, so a token whose role lacks it cannot even completeinitialize, and gets403. Every tool's resource is one of Magento's own exceptMagenx_AiMcp::ops, which this module declares for the three diagnostics Magento has no resource for — see below. - The write switch.
magenx_ai_mcp/security/allow_writesis off by default. While off the endpoint is strictly read-only, whatever the ACL says. - The confirm gate. A write tool called without
"confirm": truereturns 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
profileis 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) andops(caches, indexers, cron, logs, config, platform, security patches and upgrades, admin activity). Each also includesstore, because most tasks start withlist_stores.domainsis a comma-separated list of domain codes — the same codes as the Enabled Tool Domains setting.toolsis 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:
- 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.
- 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. - 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>. - 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:
-
In the web UI, click the cloud icon showing the environment's name, hover the environment, and click the settings icon.
-
Under Environment variables, add one
KEY=valueper 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 -
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:
- System → Extensions → AI MCP Connections → Create Client. The page shows the client ID and secret once; the secret is stored only as a hash.
- 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. - 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.
- 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_grantmay 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:writeand 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, HttpOnlymagenx_mcp_oauthcookie) the start-up page is the consent page. That is the plugin inPlugin/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-keyheader. 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,
plainis 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_grantlets an admin approve connections at all.Magenx_AiMcp::oauth_manageopens 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 andconfirm: 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_statuswithcollector=integrationsreturns 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
404with no redirect target — reaching Magento with the module disabled. Correct; enable the module.307to/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_indexersdoes not reindex. A full reindex cannot finish inside an HTTP request, so the tool marks indexers invalid and cron rebuilds them. Runbin/magento indexer:reindexfor an immediate rebuild, andcron_statusto check cron is running at all before waiting on it.set_configrefreshes 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/revalidatepath.search_productsruns 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 withget_productandstore_code.- Scope mistakes are the easiest way to go wrong with this server. Omitting
store_codewrites the default value that all store views inherit; passing it writes an override for that view only.list_storesexists so the agent can check before it writes. Passingnullfor a field writes an empty override, it does not restore inheritance — clearing an override still means "Use Default Value" in the admin. update_cms_blockrefuses 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 matchingblock_ids; passblock_idto choose.get_configneeds 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.