The Stable module exposes a set of HTTP tool endpoints for interacting with an OpenCart store through two controller groups:
- Frontend endpoints for storefront operations
- Backend endpoints for admin-side catalog, customer, and order data
The module lets an external agent query and manipulate store data over plain HTTP, using JSON-RPC 2.0 shaped responses. Each tool is exposed through a dedicated controller action and returns either a structured result or an error.
This is not an MCP server. The endpoints are ordinary HTTP handlers, and the tool catalogue in index() is a convenience descriptor, not a conformant MCP tools/list. An agent calls these endpoints with a generic HTTP tool; nothing registers them as MCP tools. Keep this distinction in mind when writing agent-facing documentation — describing them as "MCP tools" leads an agent to look for them in its tool list, where they do not exist.
The module supports the following high-level areas:
- Product and category browsing
- Customer lookup and profile access
- Order and order status retrieval
- Shopping cart management
- Checkout and payment/shipping information
- Country and zone lookup
catalog/controller/extension/module/stable.php runs on the content_top_before event and only for a logged-in customer. admin/controller/extension/module/stable.php does the equivalent for the admin panel.
On each page load it:
- Looks up (or creates) the chat record for the customer in
stable_chat. - Refreshes the stored
session_idwith the current storefront session viaeditChat(). - Requests a Ranch embed token.
- Sets four short-lived cookies and enqueues the widget script.
| Cookie | Contents |
|---|---|
stable_api_url |
Base URL of this store's Stable endpoint group (frontend or backend) |
stable_chat_id |
Chat identifier for this customer |
stable_agent_id |
Ranch agent id from module settings |
stable_token |
Ranch embed token |
view/javascript/stable/stable.js reads the cookies and passes two values to the agent through the SDK's data-prompt attribute:
Stable API URL: <base url>
Chat ID: <chat id>
These labels are the agent's only source for both values, so they must match the placeholders used in the agent-facing documentation (<STABLE_API_URL>, Chat ID) exactly.
Note that the cookies are set with a 60-second lifetime while the Ranch token is issued for a day. They are read once on window load; a widget re-initialisation later than that reads null.
Every endpoint is POST with a JSON body. There are no GET endpoints and no other HTTP methods; request-method validation was removed from the handlers.
POST <base url>/<tool>
Content-Type: application/json
{"chat_id": "...", ...tool arguments...}
Arguments are read by the shared getRequestData() helper, which is deliberately tolerant. It merges, in descending priority:
- The JSON body (
php://input) - Form-encoded POST fields
- Query-string parameters
OpenCart's own route parameter is stripped. A caller that puts arguments in the query string still works, but the JSON body is the documented form.
Success — HTTP 200:
{ "jsonrpc": "2.0", "result": { ... } }Failure — HTTP 400:
{
"jsonrpc": "2.0",
"error": "Chat ID required! Product ID required!",
"errors": ["Chat ID required!", "Product ID required!"]
}resultanderrorare mutually exclusive.- All validation problems are collected and reported in one response, rather than one per call.
erroris every problem joined into a single string;errorsis the same set as an array.- Failures return HTTP status 400, not 200. Success stays 200.
All tool endpoints share the same pattern:
- A request arrives with a
chat_idand relevant arguments. - Required input is validated; every missing field is collected.
- The chat record is resolved, and the caller is checked against the configured tool permissions.
- The storefront (or admin) session is restored from the chat record.
- The relevant model layer is called to retrieve or modify data.
- The result is returned in JSON-RPC shape, with HTTP 400 on failure.
The module also records chat actions and request logs for traceability.
Write operations — cart changes and order creation — need the customer's real session, not the one the API request arrives on. refreshStartup($chat) rebuilds it:
- starts the PHP session stored in
stable_chat.session_id - re-creates
customer,currency,tax,weight,lengthandcartin the registry against that session
This makes two things load-bearing:
stable_chat.session_idmust stay current. The bootstrap controller refreshes it on every page load. If that update is skipped, the stored id eventually points at an expired session:Cart\Customerthen finds no logged-in customer,getCurrentCustomerfails withCurrent customer not found!, and cart writes silently land in a guest-scoped cart that the customer never sees on the storefront.- Cart operations can appear to succeed without a logged-in customer.
Cart\Cartis scoped bycustomer_id+session_id, so withcustomer_id = 0an add still returns a consistent cart. A successful cart response is therefore not by itself proof that the session is healthy.
Every tool is gated by one of five permission groups, configured per store under setting.side.<side>.tool.<group>.status:
| Side | Groups |
|---|---|
| Frontend | product, customer, cart, checkout |
| Backend | product, customer, order |
The product group covers the manufacturer tools too — getManufacturer and
getManufacturers are catalog lookups and are gated with the categories and products.
getCountries and getZonesByCountryId are ungated on both sides. A disabled group makes its tools return You do not have permission to use this tool!.
Every plural tool takes filters plus sort, order and page, and applies all three
server-side. The agent is not expected to fetch broadly and re-order the result itself.
- Page size is 20 for every search tool. Keep it modest: a generic HTTP tool on the agent side typically truncates large bodies, and a truncated response still arrives with status 200, so the agent cannot tell it was cut.
orderacceptsASC/DESC. Default isASCeverywhere exceptgetOrders, which defaults toDESCso the newest order is the first element of page 1.sortaccepts a fixed key set per tool, mapped in the model's$sort_data. An unrecognised key silently falls back to the default — the handler does not reject it.
| Tool | sort keys |
|---|---|
getCategories |
name, sort_order (backend also status) |
getManufacturers |
name, sort_order |
getProducts |
name, model, price, quantity, sort_order, date_added, manufacturer, rating (backend also status) |
getCustomers (backend) |
name, email, customer_group, status, date_added |
getOrders (backend) |
order_id, customer_name, order_status, total, date_added |
manufacturer and rating sort on SELECT-list aliases, which is legal in ORDER BY but
not in WHERE — the same reason the price filter cannot reuse the special / discount
aliases (see below).
getProducts on the frontend filters and sorts by the price the customer actually sees,
not the stored base price. getPriceExpression() and getDisplayPriceExpression() in the
frontend model build one SQL expression applying, in order:
- active special, else quantity-1 discount, else
p.price— viaCOALESCE, with the subqueries repeated inline because SELECT aliases are invisible toWHERE; - tax for the product's
tax_class_id, asvalue * k + c. Both coefficients are read off the liveCart\Taxobject with two probes (calculate(0)andcalculate(100)) rather than re-implementing the rate rules —Tax::calculate()is linear in value, so this is exact and can never drift from what the display code produces; - the session currency rate.
The same expression backs getProducts, getTotalProducts and the price sort, so the
returned rows, productCount and the ordering can never disagree. Because of that, the
controller passes price_min / price_max through verbatim — converting them again
would apply the currency rate twice.
The backend deliberately filters on the raw p.price: no tax, no conversion. Admin-side
answers are about stored data, not shopfront presentation.
getCurrentCustomerOrder/getCurrentCustomerOrders(frontend) only return orders withorder_status_id > 0, scoped to the current customer and store, ordered newest first. This tool takes nosort/order.getOrders(backend) defaults toorder_status_id > 0as well, butorder_status_id: 0is a valid filter value and returns the unconfirmed ("missing", abandoned) orders. Both the handler and the model guard with!== ''rather than!empty(), precisely so that a zero survives.getOrderStatusesdoes not list0— status 0 is the absence of a status, and it has no row in theorder_statustable, so such orders come back withorder_statusset tonull.
The backend controller is intended for admin-side operations and exposes read-only tools for catalog, customer, and order data. No backend tool creates or modifies a record.
-
getCategory- Returns detailed information about a single category.
- Requires:
chat_id,category_id
-
getCategories- Returns one level of the category tree.
- Requires:
chat_id - Optional:
name,parent_category_id,status,sort,order,page
-
getManufacturer- Returns a single manufacturer:
manufacturer_id,name,image,sort_order. - Requires:
chat_id,manufacturer_id
- Returns a single manufacturer:
-
getManufacturers- Searches manufacturers, so a brand name can be resolved to an id for
getProducts. - Requires:
chat_id - Optional:
name,sort,order,page
- Searches manufacturers, so a brand name can be resolved to an id for
-
getProduct- Returns detailed information about a single product.
- Requires:
chat_id,product_id
-
getProducts- Searches products with filters, sorting and paging.
- Requires:
chat_id - Optional:
name,model,category_id,manufacturer_id,price_min,price_max,quantity_min,quantity_max,status,date_added_from,date_added_to,sort,order,page statushas no default: omit it and both enabled and disabled products are returned.taganddescriptionare no longer searchable — the filters were removed from the models.
-
getCustomer- Returns details for a specific customer.
- Requires:
chat_id,customer_id
-
getCustomers- Searches customers using name, email, customer group, or pagination.
- Requires:
chat_id - Optional:
name,email,customer_group_id,status,date_added_from,date_added_to,sort,order,page
-
getCustomerGroups- Returns the list of available customer groups.
- Requires:
chat_id
-
getOrder- Returns details of a single order.
- Requires:
chat_id,order_id
-
getOrders- Searches orders by customer name, order status, date range, or pagination.
- Defaults to newest first; pass
sort/orderto change that, andorder_status_id: 0for unconfirmed orders. - Requires:
chat_id - Optional:
customer_name,order_status_id,total_min,total_max,date_added_from,date_added_to,sort,order,page
-
getOrderStatuses- Returns the list of order statuses.
- Requires:
chat_id
-
getCountries- Returns available countries.
- Requires:
chat_id
-
getZonesByCountryId- Returns zones for a specific country.
- Requires:
chat_id,country_id
The frontend controller is intended for storefront interactions and exposes tools that support customer-facing shopping scenarios.
-
getCategory- Returns information about a category in the storefront catalog.
- Requires:
chat_id,category_id
-
getCategories- Returns one level of the storefront category tree. Only active categories in this store.
- Requires:
chat_id - Optional:
name,parent_category_id,sort,order,page
-
getManufacturer- Returns a single manufacturer:
manufacturer_id,name,image,sort_order. - Requires:
chat_id,manufacturer_id
- Returns a single manufacturer:
-
getManufacturers- Searches manufacturers, so a brand the customer names can be resolved to an id.
- Requires:
chat_id - Optional:
name,sort,order,page
-
getProduct- Returns information about a product from the storefront catalog.
- Requires:
chat_id,product_id
-
getProducts- Searches products with filters, sorting and paging. Only active, in-store products.
- Requires:
chat_id - Optional:
name,model,category_id,manufacturer_id,price_min,price_max,quantity_min,quantity_max,date_added_from,date_added_to,sort,order,page price_min/price_maxare interpreted as the customer-visible price — tax applied, session currency — see Effective price. Pass them through unconverted.- No
statusfilter here: the storefront query is hard-wired top.status = '1'. taganddescriptionare no longer searchable — the filters were removed from the models.
-
getCurrentCustomer- Returns information about the current customer session.
- Requires:
chat_id - Fails with
Current customer not found!when the restored session has no logged-in customer — see Session handling.
-
getCurrentCustomerOrder- Returns a specific order belonging to the current customer.
- Requires:
chat_id,order_id
-
getCurrentCustomerOrders- Returns the current customer's order history, newest first.
- Requires:
chat_id - Optional:
page
Each cart tool returns the full updated cart ({"products": [...]}), so the response itself is the confirmation that the change took effect.
-
addCartProduct- Adds a product to the customer cart.
- Requires:
chat_id,product_id - Optional:
quantity(default1),option,recurring_id - Required product options are validated: a missing one fails with
<Option name> required!. A product with recurring profiles needs a validrecurring_id.
-
editCartProduct- Updates the quantity of a product already in the cart.
- Requires:
chat_id,cart_id,quantity - Keys off the cart line (
cart_id), notproduct_id.
-
deleteCartProduct- Removes a product from the cart.
- Requires:
chat_id,cart_id
-
getCartProducts- Returns the contents of the cart.
- Requires:
chat_id
-
createOrder- Creates a new order from the current cart and customer data, then clears the cart.
- Unconditionally requires:
chat_id,payment_method_code - Auto-filled when omitted from the customer's profile and default address:
firstname,lastname,email,address_1,city,postcode,country_id,zone_id. They only fail if still missing afterwards. shipping_method_codeis required only when the cart contains a shippable product (cart->hasShipping()); omit it for digital-only carts.- Payment-specific fields are declared per method in the config registry (see Payment methods), not hard-coded.
index()generates theirpropertiesand oneallOf/if/thenbranch per method from that registry, and the handler validates against the same data. All such fields are declared as strings: as numbers,"01"becomes1and"045"becomes45, and long card numbers lose precision. - Method codes must be passed through verbatim from
getShippingMethods/getPaymentMethods, never constructed. - Pre-flight checks: the cart must not be empty, items must be in stock, and quantities must meet each product's
minimum. - The handler copies the decoded body into
$this->request->postso the payment extension (which reads from there) receives the payment fields.
-
getShippingMethods- Returns shipping methods available for a given country and zone.
- Requires:
chat_id,country_id,zone_id
-
getPaymentMethods- Returns payment methods available for a given country and zone, each annotated with what the module can do with it.
- Requires:
chat_id,country_id,zone_id - Each entry carries
code,title,flow, and eitherrequired_fields+optional_fieldsor, forflow: unsupported, areason.
-
getCountries- Returns available countries for checkout and address entry.
- Requires:
chat_id
-
getZonesByCountryId- Returns zones for a specific country during checkout.
- Requires:
chat_id,country_id
OpenCart ships 54 payment extensions and they are not interchangeable. What a method needs is visible in its controller's public methods:
| Class | Signature | Can the module complete it? |
|---|---|---|
| Offline | index, confirm |
Yes — confirm() sets the order status server-side |
| Direct API | index, send |
Yes, once the extra fields are collected |
| API + 3-D Secure | send plus acsReturn / threeDSecureCallback / tds |
No — the challenge needs the customer's browser |
| Redirect / IPN | index, callback / notify / ipn |
No — the customer must be sent to the gateway |
| JS SDK | express, nonce, createOrder … |
No — needs a script running in the browser |
Only the first two classes can be driven headlessly, so the practical ceiling is roughly a dozen methods, not all 54. That is a property of the payment flows, not a limitation to be engineered away.
Supported methods are declared in system/config/stable.php under payment_method. Nothing
about a specific gateway lives in the controller.
'cod' => array(
'code' => 'cod',
'flow' => 'confirm',
'field' => array()
),
'authorizenet_aim' => array(
'code' => 'authorizenet_aim',
'flow' => 'send',
'field' => array(
'cc_number' => array(
'code' => 'cc_number', 'type' => 'string', 'required' => true,
'name' => 'Card Number', 'description' => '...'
),
// ...
)
),flowis the controller method to call —confirmorsend. The dispatcher is generic:load->controller('extension/payment/' . $code . '/' . $flow).fielddeclares every field the method understands.required => truefields are validated bycreateOrderand land in that method'sallOf/thenbranch;required => falsefields are declared inpropertiesand advertised asoptional_fields, but never enforced.- A method absent from the registry is reported as
flow: unsupportedbygetPaymentMethods, andcreateOrderrefuses it with HTTP 400 before creating anything.
Currently registered: cod, bank_transfer, cheque, free_checkout (offline);
authorizenet_aim, sagepay_us, web_payment_software, perpetual_payments,
firstdata_remote (send).
Field sets genuinely differ between these — web_payment_software never reads a cardholder
name, firstdata_remote calls it cc_name and reads no expiry date at all,
perpetual_payments wants an optional card start date. Nothing may assume a fixed "card
field set".
Field descriptions are written into createOrder's inputSchema keyed by field code, so
a field shared between methods keeps only the last description declared. Descriptions for
the same code must therefore stay identical and must never name one method — which method
requires what is expressed by the generated allOf branches, not by the prose.
Response shapes are inconsistent across extensions: cod / bank_transfer / cheque set
json['redirect'], authorizenet_aim sets redirect or error, some gateways use
json['success'], and free_checkout outputs nothing at all on success. So "empty
output" means success for one method and failure for the rest — the response cannot decide it.
createOrder therefore ignores the response shape and checks the database:
json['error']present → surface the gateway's own message. It is the only place the real reason (a decline, a bad card) exists.- Otherwise re-read the order:
order_status_id > 0means confirmed, because every extension callsaddOrderHistory()on success and that is what lifts the order off status 0. - Neither → the order was created but never confirmed. Report failure and leave the cart intact.
The cart is cleared only inside the verified-success branch. A row left at status 0 is a
missing order — the same thing the backend's order_status_id: 0 filter finds.
- The tool catalogue is declared in the
index()action; the actual logic lives in dedicated methods such asgetProduct,getProducts,createOrder, and the cart actions. - Descriptors carry
endpointandrequestMethodalongside the argument schema. These are extensions for the module's own client and are not part of the MCP contract. - All requests need a valid
chat_idand the appropriate permission group. - Every request and response is written to the module log, which is the primary tool for diagnosing agent behaviour: it shows the arguments that actually arrived and the exact error returned.