Admin MCP
Pyle's Admin MCP server gives staff-facing AI clients a stable HTTP surface for catalog search, pricing, stock inspection, customer and order lookup, material-list comparisons, reusable order, customer, and fulfillment request comments, and focused admin order writes.
The server is intentionally close to the admin panel. Reads use the same search and lookup paths that staff already use, and writes go through the existing domain actions so validation, permissions, events, and audit behavior stay aligned with the application.
The framework only registers the server when PYLE_MCP_ENABLED=true. When enabled, the MCP endpoint is exposed at /mcp/admin.
Mcp::oauthRoutes();
Mcp::web('/mcp/admin', AdminServer::class)
->middleware([
AuthenticateMcpOauthUser::class,
RestrictAuthenticatedUsersToStaff::class,
]);This keeps Laravel's standard OAuth discovery and dynamic registration endpoints in place while still limiting MCP usage to authenticated staff.
Authentication
The primary MCP endpoint, /mcp/admin, uses Passport-backed OAuth and is limited to staff users. Once PYLE_MCP_ENABLED=true, the application exposes the standard discovery endpoints automatically:
/.well-known/oauth-protected-resource/mcp/admin/.well-known/oauth-authorization-server/mcp/admin
These discovery endpoints remain public metadata so OAuth clients can discover how to authenticate before a user is signed in.
Only staff users may complete the MCP OAuth authorization flow. If a non-staff user reaches /oauth/authorize with the mcp:use scope, the request is denied before a token can be issued.
Dynamic OAuth client registration at /oauth/register is intentionally stateless so CLI clients can register without a web session. Staff restrictions are enforced at authorization and MCP resource access time.
Before getting started, make sure the Passport tables and keys exist:
php artisan migrate --path=vendor/laravel/passport/database/migrations
php artisan passport:keys --forceApplications using the framework are only responsible for operational setup, such as Passport keys and environment overrides like MCP_REDIRECT_DOMAINS and MCP_CUSTOM_SCHEMES.
Enable the server in the application environment before trying to connect:
PYLE_MCP_ENABLED=trueInstalling In Codex
Use the real OAuth-protected MCP endpoint in both local and production environments:
codex mcp add admin-mcp --url http://your-pyle-app.test/mcp/admin
codex mcp login admin-mcp --scopes mcp:useOf course, you may also point Codex at a production deployment:
codex mcp add admin-mcp --url https://your-pyle-app.example.com/mcp/admin
codex mcp login admin-mcp --scopes mcp:useOnce you have added the server, Codex will use the MCP discovery endpoints automatically.
TIP
The default MCP_REDIRECT_DOMAINS value allows loopback redirects such as http://localhost, http://127.0.0.1, and http://[::1], plus the ChatGPT callback domains https://chat.openai.com and https://chatgpt.com. Add any other callback domains explicitly in each deployment that needs them.
Production Checklist
To get started in production, make sure the following pieces are in place:
APP_URLpoints to your public application URL.- Passport keys exist on the server.
- HTTPS is enabled for the public MCP endpoint.
- Staff users can sign in through the normal web guard.
MCP_REDIRECT_DOMAINSincludes any additional OAuth callback domains you would like to allow.MCP_CUSTOM_SCHEMESincludes any custom client schemes you would like to allow.
Available Tools
The current public tool surface is:
analytics.metrics.list
analytics.daily.get
catalog.search
catalog.attributes
catalog.facets
catalog.compare-pricing
catalog.compare-cost
products.pricing
products.stock
products.specifications
stock.search
inventory-items.get
inventory-items.stock-history
locations.search
users.search
tax-rates.search
transfer-locations.search
transfer-locations.get
fulfillment-requests.search
fulfillment-requests.get
customers.search
customers.get
customers.addresses.list
orders.search
orders.get
orders.items.list
orders.items.add
orders.items.update
orders.items.remove
orders.shipping-lines.list
orders.shipping-lines.create
orders.shipping-lines.update
orders.shipping-lines.delete
orders.shipping-lines.assign-to-items
orders.create
orders.set-customer
orders.set-shipping-address
orders.set-billing-address
orders.assign-owner
orders.email.update
orders.reference-number.update
orders.project-number.update
orders.notes.update
orders.hold-date.update
orders.discount-lines.create
orders.discount-lines.update
orders.discount-lines.delete
orders.items.discount-lines.create
orders.items.discount-lines.update
orders.items.discount-lines.delete
orders.items.discount-lines.create-for-all-items
orders.tax-settings.update
orders.tax-rates.add
orders.tax-rates.remove
comments.list
comments.create
comments.update
comments.delete
material-lists.availability
material-lists.pricing
material-lists.compareThe server returns the current tool list in a normal tools/list request. Internally, the default discovery page size is large enough for the current admin surface, but clients should still support MCP pagination for future tool growth.
Available Prompts
The server also publishes one MCP guidance prompt:
catalog.pricing-workflowAdmin Visibility
The Admin MCP follows the same staff visibility rules as the admin panel. When admin restrictions are enabled, customer, order, fulfillment, and inventory item reads are scoped to the customers and inventory locations the staff user may access.
This means a client can safely use precise ids, such as order_id, fulfillment_request_id, or inventory_item_id, without bypassing admin restrictions:
{
"name": "inventory-items.get",
"arguments": {
"inventory_item_id": 9944
}
}If the authenticated staff user cannot access the matching location or parent order context, the MCP will not return that record.
Money Values
All public money values returned by the Admin MCP use major-unit decimals.
When a product has a measure-normalized price, the main MCP price fields are canonicalized to that normalized basis. In practice, this means current_price may already represent $0.50 per square foot rather than the raw package price.
The same rule applies to staff-only stock cost fields. When a stocked product has a pricing measure, products.stock may already return cost as something like $3.95 per square foot rather than the raw package cost.
Use these fields together:
current_pricecurrent_price_presentableprice_basisprice_unitunit_measureunit_base_measureunit_measure_unit
For stock cost, use these fields together:
costcost_presentablecost_basiscost_unitunit_measureunit_base_measureunit_measure_unit
Fields such as comparison_price, comparison_cost, cost, line_total, sell_subtotal, cost_subtotal, and gross_margin also use major-unit decimals. The matching _decimal fields are still returned for compatibility and contain the same numeric value.
{
"current_price": 0.5,
"price_basis": "measure",
"price_unit": "sqft",
"unit_measure": 500.0,
"unit_base_measure": 1.0,
"unit_measure_unit": "sqft",
"cost": 3.95,
"cost_basis": "measure",
"cost_unit": "sqft",
"line_total": 22.0
}NOTE
The MCP contract exposes decimals consistently, even though the underlying application may still use integer cents internally for offer selection, sorting, and totals.
Analytics
The analytics tools expose registered daily aggregate metrics. Start with analytics.metrics.list to discover the public metric keys, source keys, definitions, sensitivity level, and supported date range:
{
"name": "analytics.metrics.list",
"arguments": {
"scope": "daily_aggregates",
"include_definitions": true
}
}The framework registers analytics.metrics.list and analytics.daily.get as default Admin MCP tools when the Admin MCP server is enabled.
You may register daily aggregate metrics in your application's config/pyle.php file. Each metric must provide a query_class; the framework uses that class internally for cache writes and persisted row lookup, but MCP responses only expose public metadata:
'analytics' => [
'daily' => [
'metrics' => [
'daily_total_sales' => [
'query_class' => App\Analytics\DailyTotalSales::class,
'source_key' => 'daily_total_sales',
'label' => 'Daily Total Sales',
'sensitivity' => 'basic_analytics',
'definitions' => [
'source' => 'analytics_aggregates',
'date_field' => 'analytics_aggregates.datetime',
'formula' => 'Total sales by day.',
'exclusions' => [],
'tooltip' => 'Daily sales from persisted aggregate rows.',
],
],
],
],
],Once you have a metric key, you may read the persisted daily series with analytics.daily.get:
{
"name": "analytics.daily.get",
"arguments": {
"metric_key": "daily_total_sales",
"from": "2026-06-01",
"to": "2026-06-14",
"timezone": "America/Toronto"
}
}The daily reader only returns existing analytics_aggregates rows. Dates are bucketed in the requested timezone; if omitted, the application timezone is used. The reader queries a one-day buffer around the requested range, converts stored analytics_aggregates.datetime values into the requested timezone, and then keys each row by local date.
If a requested date is missing, the response includes limits.series_buckets, meta.missing_count, and a missing_daily_aggregate_rows warning. Missing series points are marked as missing instead of generating or backfilling aggregate data.
Daily aggregate metrics currently share the framework-supported range: 5 years at daily grain. The metric registry powers discovery, reads, and the daily cache writer, while the framework owns the range and grain so app metadata cannot accidentally advertise a different retention window.
Catalog Search
You may use catalog.search to discover products by free text, exact SKU, or structured filters.
This tool is for discovery only. It does not return authoritative pricing, and you should never infer a price from unit_measure.
When the request only contains free text, the tool follows the predictive search path and returns sectioned results for:
productsmanufacturerscollectionscategories
For example, you may issue a broad catalog query like this:
{
"name": "catalog.search",
"arguments": {
"search": "Schluter thermostat"
}
}If you provide structured filters, the tool switches to the faceted fast-search path and returns only products:
{
"name": "catalog.search",
"arguments": {
"product_manufacturer_id": 10234,
"unit_measure_unit": "sqft",
"limit": 10
}
}Of course, you may also perform exact SKU lookups:
{
"name": "catalog.search",
"arguments": {
"sku": "NRENJAD5X5",
"limit": 1
}
}All catalog search flows through the Meilisearch-backed fast-search path. This keeps MCP product discovery aligned with storefront behavior, including typo tolerance and broad-query performance.
Each product summary may include unit_measure, unit_base_measure, and unit_measure_unit, which describe how much one sell unit contains. It may also include available_quantity and available_quantity_total, which are global catalog aggregates rather than branch-specific stock.
Catalog Attributes
You may use catalog.attributes to discover which attribute facet keys are actually available in the current catalog scope before building exact spec filters.
This is the preferred way to avoid guessing keys such as thickness, width, or nominal size:
{
"name": "catalog.attributes",
"arguments": {
"search": "engineered hardwood",
"search_limit": 25
}
}Each returned attribute includes:
attribute_codeattribute_namefacet_keysproduct_count
For example, a thickness attribute may expose facet keys like:
attributes.product_thickness_mm.enattributes.product_thickness_mm.fr
Catalog Facets
You may use catalog.facets to inspect valid filter values for a search scope before building structured filters.
This is the safest way to explore facet-driven workflows such as thickness filtering:
{
"name": "catalog.facets",
"arguments": {
"search": "click vinyl",
"facet_keys": [
"unit_measure_unit",
"stock_status",
"attributes.product_thickness_mm.en"
]
}
}The response returns:
facets, with valid values and counts for each requested keyfacets[*].status, which isok,no_options, orunknownfacets[*].message, which explains empty or invalid keyssupported_filter_shapes, which documents the MCP facet key patternsmeta, so the caller can tell how large the current catalog scope is
If you request an unknown attribute key, the MCP now says so explicitly instead of returning a silent empty options list. In that case, use catalog.attributes first.
Catalog Pricing Comparison
You may use catalog.compare-pricing to rank matching products by the cheapest eligible price in a given pricing context. The tool accepts either explicit product_ids or catalog search criteria, but not both.
At least one pricing input is required:
customer_idziplocation_id
To get started, you may compare catalog matches directly:
{
"name": "catalog.compare-pricing",
"arguments": {
"search": "engineered oak",
"unit_measure_unit": "sqft",
"zip": "H4N1N3",
"compare_by": "auto",
"search_limit": 25,
"limit": 5
}
}If you already know the products you would like to compare, you may pass explicit IDs instead:
{
"name": "catalog.compare-pricing",
"arguments": {
"product_ids": [39205, 39206, 39207],
"customer_id": 1540,
"compare_by": "measure"
}
}When compare_by is set to auto, the tool will rank by measure-aware pricing when every priced result shares the same pricing unit. Otherwise, it falls back to unit pricing. If you explicitly set compare_by to measure, all priced matches must expose the same measure unit or the request will be rejected.
The response only includes priced, comparable items. If no eligible priced offers exist for the selected context, the response returns an empty items array with meta.status = "no_priced_results".
When the tool falls back to unit comparison, the embedded offer payload is also aligned to unit pricing so the nested money fields do not conflict with comparison_price.
Always inspect:
meta.comparison_modemeta.statusmeta.is_exhaustive
If the tool starts from a catalog search, meta.is_exhaustive = false means only the initial search_limit candidate set was compared.
If you provide a zip, it must resolve to a supported pricing region. Otherwise, the request is rejected instead of silently falling back.
Catalog Cost Comparison
You may use catalog.compare-cost to rank matching products by the lowest authoritative stock cost in a branch or nearest-location context. The tool accepts either explicit product_ids or catalog search criteria, but not both.
At least one stock-cost context input is required:
location_idzip
To get started, you may compare catalog matches directly:
{
"name": "catalog.compare-cost",
"arguments": {
"search": "engineered oak",
"unit_measure_unit": "sqft",
"location_id": 10058,
"compare_by": "auto",
"search_limit": 25,
"limit": 5
}
}If you already know the products you would like to compare, you may pass explicit IDs instead:
{
"name": "catalog.compare-cost",
"arguments": {
"product_ids": [39205, 39206, 39207],
"zip": "H4N1N3",
"compare_by": "measure"
}
}When compare_by is set to auto, the tool ranks by measure-normalized cost when every stocked result shares the same pricing unit. Otherwise, it falls back to unit cost. If you explicitly set compare_by to measure, all costed matches must expose the same measure unit or the request will be rejected.
The response only includes stocked, comparable items. If no eligible stock cost rows exist for the selected context, the response returns an empty items array with meta.status = "no_cost_results".
When the tool falls back to unit comparison, the embedded inventory_item payload is also aligned to unit cost so the nested money fields do not conflict with comparison_cost.
Always inspect:
meta.comparison_modemeta.statusmeta.is_exhaustive
If the tool starts from a catalog search, meta.is_exhaustive = false means only the initial search_limit candidate set was compared.
If you provide a zip, it must resolve to a supported location region. Otherwise, the request is rejected instead of silently falling back.
Pricing And Stock
You may use products.pricing to retrieve eligible offers for one product in one pricing context. For cross-product cheapest questions, use catalog.compare-pricing instead.
At least one narrowing input is required:
customer_idziplocation_id
{
"name": "products.pricing",
"arguments": {
"product_id": 39205,
"location_id": 10058
}
}The response includes eligible offers as well as effective_location and effective_location_resource_uri, so the fulfillment context remains explicit even when the offer is not tied to a single location row.
Each offer also includes default_product_inventory_item_id and product_inventory_item_ids. These ids are the inventory item ids accepted by orders.items.add for that offer. When a default inventory item is set on the offer, the list only contains that default id, matching the order item validation rules.
If you provide a zip, it must resolve to a supported pricing region.
If the product has a measure-normalized price, the offer payload exposes that normalized value as the main price:
current_pricecurrent_price_presentableprice_basisprice_unitunit_measureunit_base_measureunit_measure_unit
You may use products.stock to inspect stock rows for one product across locations:
{
"name": "products.stock",
"arguments": {
"product_id": 39205,
"location_id": 10058
}
}This tool includes staff-only inventory cost data. Those cost values are returned as decimals on the MCP surface.
If you provide a zip, it must resolve to a supported location region.
The response now contains two stock views:
items, which are the raw inventory rowslocations, which are per-location summaries for branch-level workflows
When multiple raw rows exist for the same location, locations is the preferred branch-level view. Each location summary includes:
inventory_item_idsinventory_row_countavailable_totalavailable_maxavailability_is_ambiguousbest_costbest_cost_basisbest_cost_unit
This makes duplicate same-location rows explainable without forcing the caller to guess which raw row is authoritative.
When the product has a pricing measure, the public stock cost fields are canonicalized to that measure basis. Use these fields together:
costcost_presentablecost_basiscost_unitunit_measureunit_base_measureunit_measure_unit
If cost_basis = "measure" and cost_unit = "sqft", then cost already represents cost per square foot rather than raw cost per box.
If a postal code looks partial, the MCP returns a format hint. For example, a partial Canadian postal code will tell the caller to use a full value such as J1N0T7.
If the client cannot read MCP resources directly, use products.specifications to retrieve canonical spec payloads by product_id:
{
"name": "products.specifications",
"arguments": {
"product_id": 39205
}
}Sometimes, you may wish to inspect stock across many products and locations without exposing cost. In that case, you may use stock.search:
{
"name": "stock.search",
"arguments": {
"product_id": 39205,
"limit": 100
}
}Locations
The locations.search tool resolves vendor, inventory, and pickup locations by name, code, city, postal code, or vendor location code:
{
"name": "locations.search",
"arguments": {
"search": "Saint-Laurent"
}
}The code and location_code fields both map to vendor_location_code.
These locations are not limited to company-owned branches. Depending on your data, they may represent vendor, warehouse, or pickup locations.
Inventory Item Diagnostics
Use inventory-items.get when a staff workflow needs the exact ProductInventoryItem behind a stock row, order item, or cart item:
{
"name": "inventory-items.get",
"arguments": {
"inventory_item_id": 9944
}
}The payload includes current availability, net availability, cost context, the inventory location, compact vendor context, and item-level last-seen fields:
last_seen_atcatalog_item_last_seen_atcatalog_inventory_last_seen_at
These fields describe the selected inventory item context. They are not vendor-level feed sync timestamps.
Inventory item diagnostics stay at the Pyle ProductInventoryItem level. They do not expose product vendor catalog inventory rows by default, and they are scoped to inventory locations visible to the authenticated staff user.
When you need the item timeline, call inventory-items.stock-history:
{
"name": "inventory-items.stock-history",
"arguments": {
"inventory_item_id": 9944,
"limit": 20
}
}The newest stock changes are returned first. The same detail is available as resources at admin://inventory-items/{id} and admin://inventory-items/{id}/stock-history; use the tool when you need pagination beyond the bounded resource payload.
Transfer Locations
Transfer locations are separate from inventory locations. Use transfer-locations.search to find transfer and pickup destinations by name or address context:
{
"name": "transfer-locations.search",
"arguments": {
"query": "Montreal"
}
}When you already know the transfer location id, call transfer-locations.get or read admin://transfer-locations/{id} for the canonical payload with address, pickup note, opening hours, and contact context.
Customers
You may use customers.search to find customer records by admin customer id, the same indexed admin search surface, owner, account manager, or account flags:
{
"name": "customers.search",
"arguments": {
"query": "Acme Flooring",
"limit": 10
}
}Search responses are bounded summaries with resource_uri, addresses_resource_uri, and comments_resource_uri links. They include latest_order and latest_confirmed_order summaries when available so agents can disambiguate similar customers before writing. When the caller already knows the customer id, use customers.get or read admin://customers/{id} for the canonical customer payload.
Use customers.addresses.list or admin://customers/{id}/addresses before selecting existing customer addresses for an order workflow. The address resource returns the first bounded page plus total, returned, and has_more; switch to the paginated tool when has_more is true.
{
"name": "customers.addresses.list",
"arguments": {
"customer_id": 1540
}
}Orders
You may use orders.search for confirmed orders, draft carts, and quotes. In the Admin MCP, carts are still orders. Use cart: true when the caller needs storefront-cart state instead of a separate carts.* namespace.
Order reads work for confirmed orders, draft carts, and quotes. Existing-order writes are intentionally draft-only: if an order has processed_at set, the MCP rejects order field writes and order-owned child mutations before calling the admin action. This includes items, addresses, discounts, taxes, shipping lines, and owner assignment. Comments remain available through comments.*.
{
"name": "orders.search",
"arguments": {
"customer_id": 1540,
"cart": true,
"limit": 10
}
}The canonical order id is the numeric admin order id. The search tool accepts filters such as order_id, customer_id, email, statuses, owner, date windows, and search-only cart_token, but response payloads do not expose cart_token. Date-only processed_to and updated_to values are treated as inclusive calendar days.
When the caller already knows the order id, use orders.get or read admin://orders/{id} for the canonical order payload:
{
"name": "orders.get",
"arguments": {
"order_id": 90210
}
}The canonical order payload returns bounded items and shipping_lines slices. If items_has_more or shipping_lines_has_more is true, call orders.items.list or orders.shipping-lines.list with the same order_id and a page number.
Creating Orders
You may create an admin order, quote, or draft cart with orders.create. The tool always requires an existing customer, since customer context drives currency, default addresses, pricing, and later item behavior.
{
"name": "orders.create",
"arguments": {
"customer_id": 1540,
"cart": true,
"reference_number": "PO-2026-1844"
}
}If you need to work with an older order record that has no customer, you may use orders.set-customer. This tool only works while the order is a draft and currently customerless. Once a customer is set, MCP clients cannot change it.
{
"name": "orders.set-customer",
"arguments": {
"order_id": 90210,
"customer_id": 1540
}
}Selecting Addresses
Use the customer's address book before selecting order addresses. customers.addresses.list returns the address ids that may be passed to orders.set-shipping-address and orders.set-billing-address.
{
"name": "orders.set-shipping-address",
"arguments": {
"order_id": 90210,
"customer_address_id": 33012
}
}The selected address must belong to the order customer, and the order must still be a draft.
Managing Items
The item write tools mirror the admin order item actions. They use orders.* naming because carts and quotes are still order states. Item writes are blocked once the parent order is confirmed.
Before adding an item, resolve the product through catalog.search, then call products.pricing in the customer, ZIP, or location context. The returned offer includes product_inventory_item_ids that are valid for that offer; pass the selected offer id and one of those inventory item ids to orders.items.add.
{
"name": "orders.items.add",
"arguments": {
"order_id": 90210,
"product_offer_id": 8812,
"product_inventory_item_id": 9944,
"quantity": 3
}
}An added item must use a product offer and inventory item pair that the admin flow can actually expose. This prevents the requested inventory item from being silently replaced by an offer's default inventory item.
You may update only the narrow item fields currently exposed to MCP: quantity, note, and special_order.
{
"name": "orders.items.update",
"arguments": {
"order_id": 90210,
"order_item_id": 7155,
"quantity": 0,
"note": "Customer may confirm this line later."
}
}Use orders.items.remove to remove an item when the admin deletion rules allow it:
{
"name": "orders.items.remove",
"arguments": {
"order_id": 90210,
"order_item_id": 7155
}
}All item writes return the changed item when applicable plus a refreshed canonical order payload.
Updating Order Fields
The field write surface is narrow on purpose. Each tool updates one draft-order field group instead of accepting a generic order patch. This keeps tool names clear for agents and leaves financial, discount, tax, shipping-line, cart-flag, and owner workflows for their dedicated tools.
orders.email.update
orders.reference-number.update
orders.project-number.update
orders.notes.update
orders.hold-date.updateFor example, you may update an order reference number like this:
{
"name": "orders.reference-number.update",
"arguments": {
"order_id": 90210,
"reference_number": "PO-2026-1844"
}
}Each order field update returns the updated canonical order payload. Blank reference and project numbers are normalized to null, matching the admin dialog.
All current order field updates go through the existing order update action, so admin validation, permissions, logging, and events stay in force.
If a user asks for a "project note", use orders.notes.update only when it is clear whether the note belongs in note or internal_note; otherwise ask for clarification before writing.
Assigning Owners
You may use orders.assign-owner to assign or clear the owner on a draft order, draft cart, or quote. The tool uses the same order update action as the admin panel:
Use users.search first when the request names a person instead of providing owned_by_user_id:
{
"name": "users.search",
"arguments": {
"search": "Sarah",
"staff_only": true
}
}By default, users.search only returns staff users. If you would like to inspect non-staff users for a read-only workflow, you may pass staff_only: false.
{
"name": "orders.assign-owner",
"arguments": {
"order_id": 90210,
"owned_by_user_id": 42
}
}Assigning an owner to a draft storefront cart may set cart to false, which moves the same Order record into admin draft or quote mode.
Discounts, Taxes, And Shipping Lines
Financial order writes are explicit draft-order tools instead of generic metadata updates. This keeps each agent action close to the admin panel operation it performs:
orders.discount-lines.create
orders.discount-lines.update
orders.discount-lines.delete
orders.items.discount-lines.create
orders.items.discount-lines.update
orders.items.discount-lines.delete
orders.items.discount-lines.create-for-all-items
orders.tax-settings.update
orders.tax-rates.add
orders.tax-rates.remove
orders.shipping-lines.create
orders.shipping-lines.update
orders.shipping-lines.delete
orders.shipping-lines.assign-to-itemsDiscount values use the same admin conventions: value_type is percentage or fixed_amount, and fixed amounts are sent as major-unit decimals. For example, you may create an order-level fixed discount like this:
{
"name": "orders.discount-lines.create",
"arguments": {
"order_id": 90210,
"description": "Manual adjustment",
"value_type": "fixed_amount",
"value": 25
}
}You may update tax settings separately from tax-rate assignment:
Use tax-rates.search to map a human tax-rate name to a numeric tax_rate_id. For order custom tax-rate assignment, search active custom rates:
{
"name": "tax-rates.search",
"arguments": {
"search": "special municipal",
"active": true,
"custom": true
}
}{
"name": "orders.tax-rates.add",
"arguments": {
"order_id": 90210,
"tax_rate_id": 14
}
}The orders.tax-rates.add tool only accepts active custom tax rates. If the order is not already using custom tax rates, the tool enables custom_tax_rates before attaching the selected rate.
Shipping-line amount is also sent as a major-unit decimal. You may create a custom shipping line and assign it to eligible order items in one call:
{
"name": "orders.shipping-lines.create",
"arguments": {
"order_id": 90210,
"title": "Custom delivery",
"amount": 19.95,
"custom": true,
"apply_to_all_items": true
}
}Shipping services are scoped to the staff user's admin-visible inventory locations. Invoiced shipping lines and invoiced order items cannot be reassigned. When selected_shipping_options is provided, pass shipping option ids that belong to the selected shipping_service_id. When a shipping line is assigned to items, the response includes assigned_order_items_count, candidate_order_items_count, eligible_order_items_count, and skipped_invoiced_order_items_count. These counts are returned for both explicit order_item_ids and all-item or location-scoped assignment.
The bulk item-discount tool creates or updates item discount lines across every order item and fails as one transaction if any item is not editable. The shipping-line assignment tool assigns one custom shipping line to eligible, non-invoiced items, optionally limited by product_inventory_location_id.
Configuring Writes
Blanket MCP write access is disabled by default. Enable every write tool explicitly when the deployment is ready to allow all staff MCP mutations:
MCP_WRITES_ENABLED=trueWhen blanket writes are disabled, Framework evaluates the scoped write_tools policy in config/mcp.php. Framework allows comment creation and updates by default, while comment deletion and order mutations remain disabled:
'write_tools' => [
'default' => [
'comments.create' => true,
'comments.update' => true,
'comments.delete' => false,
],
'local' => [],
],Consuming applications may recursively override individual default entries and add a map matching the current Laravel environment. The environment map overlays default; an absent environment map uses default unchanged:
'write_tools' => [
'default' => [
'comments.update' => false,
],
'local' => [
'orders.*' => true,
'orders.shipping-lines.delete' => false,
],
],Policy keys are case-sensitive canonical MCP tool names. A controlled trailing namespace wildcard such as orders.* or orders.items.* is also supported. Exact names win over wildcards; otherwise the matching wildcard with the longest prefix wins. Bare *, mid-string globs, class strings, regexes, and non-boolean permission values grant nothing.
MCP_WRITES_ENABLED=true permits every mutation regardless of scoped false entries. When it is false, only a scoped policy value resolving strictly to true permits the mutation. All other mutation calls return a validation error before making changes. Disabled write tools remain visible in discovery.
When writes are enabled, confirmed orders remain read-only through orders.* write tools and order-owned commerce mutations. Existing-order writes require processed_at to be null; the admin/domain action then applies any more specific validation for that draft order. Reusable comments.* writes remain available on confirmed orders.
Current write tools accept an optional idempotency_key for future replay-safe clients. The key is validated today, but replay protection is not enforced yet, so the write tools are not advertised as idempotent.
Comments
You may list, create, update, and delete comments on orders, customers, and fulfillment requests. The MCP comment surface is allowlisted to those parent types and does not expose raw polymorphic class names.
Comment collections are also available as resources:
admin://orders/90210/comments
admin://customers/1540/comments
admin://fulfillment-requests/7812/commentsOrder, customer, and fulfillment request payloads include comments_resource_uri so clients can discover the right comment collection without building URIs by hand.
{
"name": "comments.create",
"arguments": {
"parent_resource_uri": "admin://orders/90210",
"body": "Follow up with the customer before confirming delivery."
}
}You may also address comments by parent type and id:
{
"name": "comments.list",
"arguments": {
"parent_type": "customer",
"parent_id": 1540
}
}If a request includes both parent_resource_uri and parent_type / parent_id, both references must point to the same parent. This avoids accidental writes to the wrong order, customer, or fulfillment request.
Comment collection payloads include items, total, returned, has_more, parent_resource_uri, and list_tool. Use comments.list when has_more is true.
Comment writes use the existing comment actions, so authorship, mentions, notifications, permissions, and delete/update restrictions stay aligned with the admin panel. Order, customer, and fulfillment request comments remain available through the same comment surface; confirmed orders do not block comment writes.
Fulfillment Requests
Fulfillment request access is read-only in this MCP slice. Use fulfillment-requests.search for operational lookups by order, customer, inventory location, transfer location, carrier, state, or date filters:
{
"name": "fulfillment-requests.search",
"arguments": {
"order_id": 90210,
"open": true
}
}Use fulfillment-requests.get or admin://fulfillment-requests/{id} for the canonical payload:
{
"name": "fulfillment-requests.get",
"arguments": {
"fulfillment_request_id": 7812
}
}The canonical detail payload includes the parent order link, pickup details, destination shipping snapshot, transfer locations, fulfillment items, quote file/reference fields, and a comments_resource_uri when present. It also includes a required pending_scheduling boolean that matches membership in the Admin Pending Scheduling tab, including scheduled transfer locations and the same stock-transit and backorder exclusions. Fulfillment request search summaries do not include this detail-only field.
Fulfillment request operational writes remain out of scope for this MCP version, but reusable comments are available through comments.*.
Date-only updated_to filters are treated as inclusive calendar days, matching orders.search. Canonical fulfillment request payloads also return bounded items and transfer_locations slices with count, returned, and has_more fields so clients know when a larger collection exists.
Material List Workflows
Pyle includes three material-list tools for sales workflows that need more than a single product lookup.
Availability
Sometimes, you may wish to know which candidate locations can fulfill an entire material list. In that case, you may use material-lists.availability.
Each line must include exactly one of sku or product_id, plus a required quantity:
{
"name": "material-lists.availability",
"arguments": {
"items": [
{ "sku": "DH512M", "quantity": 2 },
{ "product_id": 39205, "quantity": 1 }
],
"zip": "H4N1N3",
"limit": 5
}
}The response includes resolved lines, ranked candidate locations, complete status, missing_items, and per-line stock at each location. If pricing context is available, the response will also include a subtotal.
If you want to constrain the workflow to a known set of branches, provide location_ids.
Pricing
You may use material-lists.pricing to price an entire material list in one request. At least one pricing input is required:
customer_idziplocation_id
{
"name": "material-lists.pricing",
"arguments": {
"items": [
{ "sku": "DH512M", "quantity": 2 },
{ "product_id": 39205, "quantity": 1 }
],
"location_id": 10058
}
}Line totals and subtotals are returned as decimals, which keeps material-list responses aligned with the single-product pricing tools.
Compare
Sometimes, you may wish to answer a broader sales question in a single request. The material-lists.compare tool is designed for that workflow.
It may tell you:
- which locations can fulfill the material list
- which location is nearest
- which location is cheapest on sell subtotal
- which location is cheapest on inventory cost
{
"name": "material-lists.compare",
"arguments": {
"items": [
{ "sku": "DH512M", "quantity": 2 },
{ "sku": "DHEHK240204", "quantity": 1 },
{ "sku": "DHERT104/BW", "quantity": 1 }
],
"zip": "H4N1N3",
"vendor_ids": [12],
"prefer_vendor_scope": true,
"sort_by": "cost_subtotal",
"limit": 5
}
}Per location, the response includes:
completedistance_kmvendor_preferredsell_subtotalcost_subtotalgross_margingross_margin_percentage- line-by-line stock, pricing, and cost data
material-lists.compare preloads inventory rows once and resolves offer candidates once per unique product and quantity before ranking locations. This keeps larger comparisons faster while preserving the current location-specific offer behavior.
Sell totals, cost totals, subtotals, and margins are all returned as decimals.
Resources
The server also exposes canonical read-only resources:
admin://products/{id}
admin://products/{id}/specifications
admin://locations/{id}
admin://inventory-items/{id}
admin://inventory-items/{id}/stock-history
admin://transfer-locations/{id}
admin://customers/{id}
admin://customers/{id}/addresses
admin://orders/{id}
admin://fulfillment-requests/{id}
admin://orders/{id}/comments
admin://customers/{id}/comments
admin://manufacturers/{id}
admin://collections/{id}
admin://categories/{id}You may read these resources when the caller already knows which entity it needs and would like a canonical payload instead of a search result. Resource reads are still staff-scoped, so direct resource URIs do not bypass admin visibility rules.
If the MCP client does not expose resource reads cleanly, prefer the products.specifications tool for admin://products/{id}/specifications.
For example, a product specifications read looks like this:
{
"uri": "admin://products/39205/specifications"
}Extending The Admin MCP
Applications may extend the Admin MCP surface without forking framework resources. The extension API covers two independent surfaces: the primitive registry for tools, resources, and prompts, and the payload extension registry for adding new fields to existing resource payloads.
Registering Custom Primitives
The Admin MCP reads the mcp.admin.add.tools, mcp.admin.add.resources, and mcp.admin.add.prompts config keys at boot. Publish the mcp.php config file and populate those arrays with your own concrete classes:
// config/mcp.php
'admin' => [
'add' => [
'tools' => [
App\Mcp\Tools\AccountTierSearchTool::class,
],
'resources' => [
App\Mcp\Resources\AccountTierResource::class,
],
'prompts' => [],
],
],You may also register primitives at runtime from a service provider, which is useful when the set of extensions is computed conditionally:
use CBOX\Framework\Mcp\Support\AdminMcpPrimitiveRegistry;
$this->app->booted(function () {
app(AdminMcpPrimitiveRegistry::class)
->registerTool(AccountTierSearchTool::class)
->registerResource(AccountTierResource::class);
});The registry validates each class at build time. Concrete primitives only, no duplicates on the same tool name or resource URI template.
Replacing An Existing Primitive
When you need to swap a framework primitive for an application-specific subclass, use the replace config keys. The replacement must extend the original class:
'admin' => [
'replace' => [
'tools' => [
\CBOX\Framework\Mcp\Tools\Customers\SearchCustomersTool::class
=> App\Mcp\Tools\SearchCustomersWithTierTool::class,
],
'resources' => [],
'prompts' => [],
],
],The same operation is available at runtime:
app(AdminMcpPrimitiveRegistry::class)
->replaceTool(SearchCustomersTool::class, SearchCustomersWithTierTool::class);Removing A Primitive
To remove a default primitive entirely, add its class to the remove config keys:
'admin' => [
'remove' => [
'tools' => [
\CBOX\Framework\Mcp\Tools\Comments\DeleteCommentTool::class,
],
'resources' => [],
'prompts' => [],
],
],Removing a class that is not registered throws an InvalidArgumentException at boot, so missing removals are caught before any request reaches the server.
Extending Resource Payloads
Resource payloads can be extended with extra fields without touching the framework resource class. The extension is keyed by a payload key and applies to one or more payload surfaces.
Surfaces follow a {resource}.{variant} format. The framework currently provides summary and detail variants for customer, order, and product resources, which map to the payloads returned by their respective resource classes.
Register extensions in mcp.admin.payload_extensions. You may scope an extension to a specific resource group to prevent it from accidentally applying to the wrong surface:
'admin' => [
'payload_extensions' => [
'customer' => [
App\Mcp\Payloads\CustomerAccountTierExtension::class,
],
'order' => [
App\Mcp\Payloads\OrderAccountTierExtension::class,
],
],
],You may also list extension classes at the top level when one class handles its own surface routing:
'admin' => [
'payload_extensions' => [
App\Mcp\Payloads\AccountTierPayloadExtension::class,
],
],The McpPayloadExtension Contract
A payload extension must implement CBOX\Framework\Mcp\Support\Contracts\McpPayloadExtension. The contract requires:
key(): string— The payload key under which the extension fragment appears.surfaces(): array<int, string>— The surfaces this extension contributes to, e.g.['customer.summary', 'customer.detail'].eagerLoads(string $surface): array<int, string>— Any Eloquent relationships that must be loaded beforepayload()runs.payload(Model $model, string $surface): ?array— The fragment to merge into the resource payload, ornullto skip.schema(JsonSchema $schema, string $surface): mixed— The JSON Schema definition for this fragment.
A minimal implementation looks like this:
use CBOX\Framework\Mcp\Support\Contracts\McpPayloadExtension;
use CBOX\Framework\Models\Customer;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Database\Eloquent\Model;
class CustomerAccountTierExtension implements McpPayloadExtension
{
public function key(): string
{
return 'account_tier';
}
public function surfaces(): array
{
return ['customer.summary', 'customer.detail'];
}
public function eagerLoads(string $surface): array
{
return [];
}
public function payload(Model $model, string $surface): ?array
{
if (!$model instanceof Customer) {
return null;
}
return [
'tier' => $model->account_tier,
'tier_label' => $model->account_tier_label,
];
}
public function schema(JsonSchema $schema, string $surface): mixed
{
return $schema->object([
'tier' => $schema->string()->nullable(),
'tier_label' => $schema->string()->nullable(),
])->nullable();
}
}Using FluentMcpPayloadExtension
For extensions that use typed model attributes or reference other resources, FluentMcpPayloadExtension provides a declarative alternative to the raw contract. Extend it and implement define():
use CBOX\Framework\Mcp\Support\FluentMcpPayloadExtension;
use CBOX\Framework\Mcp\Support\McpPayloadExtensionDefinition;
use CBOX\Framework\Mcp\Support\McpPayloadFields;
use CBOX\Framework\Models\Customer;
class CustomerAccountTierExtension extends FluentMcpPayloadExtension
{
public function define(McpPayloadExtensionDefinition $extension): void
{
$extension
->key('account_tier')
->forResource('customer')
->forModel(Customer::class)
->withEagerLoads('accountTier')
->addToSummary(McpPayloadFields::make()
->string('tier', 'accountTier.slug', nullable: true)
->string('tier_label', 'accountTier.label', nullable: true));
}
}McpPayloadExtensionDefinition methods:
| Method | Description |
|---|---|
key(string $key) | Sets the top-level payload key for this extension fragment. |
forResource(string $resource) | Scopes surfaces to a resource prefix, e.g. customer. |
forModel(string $modelClass) | Guard: payload() only runs when the model is an instance of this class. |
withEagerLoads(...) | Relationships to load before the payload runs. |
addToSummary(McpPayloadFields|callable $builder) | Defines the fields for the {resource}.summary surface. |
addToDetail(McpPayloadFields|callable $builder) | Defines the fields for the {resource}.detail surface. |
An extension can target summary only, detail only, or both. Surfaces not added are simply not registered.
McpPayloadFields
McpPayloadFields::make() returns a fluent field builder. Each field takes a key and a resolver. Resolvers may be:
- A model data path — passed to
data_get($model, $path, $default). - A callable — receives the model and returns the raw value.
- A literal value — returned as-is for every model.
Available field types:
| Method | Returns |
|---|---|
string($key, $resolver, nullable: true) | Scalar or enum cast to string. |
integer($key, $resolver, nullable: true) | Integer or whole-number string. |
number($key, $resolver, nullable: true) | Integer or float. |
boolean($key, $resolver, nullable: false) | Boolean. |
resource($key, $resourceClass, $resolver) | Compact resource reference {id, resource_uri}. |
resourceSummary($key, $resourceClass, $resolver) | Full resource summary payload. |
resourceDetail($key, $resourceClass, $resolver) | Full resource detail payload. |
McpPayloadFields::make()
->string('tier', 'accountTier.slug', nullable: true)
->integer('tier_id', 'accountTier.id', nullable: true)
->boolean('is_preferred', fn (Customer $c): bool => $c->isPreferred(), nullable: false)
->resource('account_tier', AccountTierResource::class, 'accountTier.id');Extension Guardrails
The extension registry enforces several rules at resolution time:
- A payload key may not duplicate a native key already present on the resource. Attempting to register
id,resource_uri, or any other reserved key throws anInvalidArgumentException. - The same key cannot appear twice for the same surface. Duplicate registrations throw before any payload is built.
- When an extension is registered under a resource group (e.g.
'customer' => [...]), each surface it declares must begin with that resource prefix. A mismatch throws at resolution time.
Example Workflows
Find A Thermostat In Stock At Saint-Laurent
You may answer a question like "Find a Schluter wireless programmable thermostat in stock at Saint-Laurent" using this sequence:
catalog.search { "search": "Schluter wireless programmable thermostat" }locations.search { "search": "Saint-Laurent" }products.stock { "product_id": 39205, "location_id": 10058 }
Check An Exact SKU At A Location
You may answer a question like "Do we have NRENJAD5X5 in stock at MSI Mississauga?" like this:
catalog.search { "sku": "NRENJAD5X5", "limit": 1 }products.stock { "product_id": 13455, "location_id": 10123 }
Compare Products By Square Foot
If you would like to answer a question like "Which engineered oak product is cheapest per square foot?", you may use catalog.compare-pricing directly:
{
"name": "catalog.compare-pricing",
"arguments": {
"search": "engineered oak",
"unit_measure_unit": "sqft",
"zip": "H4N1N3",
"compare_by": "auto",
"limit": 3
}
}Discover Thickness Facets Before Comparing
If you would like to narrow a flooring search by thickness before comparing prices, inspect the current facet values first:
catalog.search { "search": "click vinyl" }catalog.attributes { "search": "click vinyl" }catalog.facets { "search": "click vinyl", "facet_keys": ["attributes.product_thickness_mm.en"] }catalog.compare-pricing { "search": "click vinyl", "filters": { "attributes.product_thickness_mm.en": ["5.2"] }, "zip": "H4N1N3" }
Compare A Heated Floor Material List
If you would like to know which nearby location can fulfill an entire heated-floor package and which one is cheapest, you may use material-lists.compare directly:
{
"name": "material-lists.compare",
"arguments": {
"items": [
{ "sku": "DH512M", "quantity": 2 },
{ "sku": "DHEHK240204", "quantity": 1 },
{ "sku": "DHERT104/BW", "quantity": 1 },
{ "sku": "SETA50W", "quantity": 6 }
],
"zip": "H4N1N3",
"sort_by": "cost_subtotal",
"limit": 3
}
}Notes
products.stockincludes staff-only inventory cost data.products.stock.locationsis the preferred branch-level stock summary when duplicate raw rows exist for the same location.stock.searchintentionally excludes cost fields.- Public MCP money fields use major-unit decimals consistently across pricing, cost, subtotal, total, and margin payloads.
catalog.searchis discovery only; usecatalog.attributesfor spec-key discovery,catalog.compare-pricing,catalog.compare-cost, orproducts.pricingfor price and cost questions.- Search results include
resource_urivalues so callers may pivot from a search result to a canonical resource read. - MCP writes are intentionally narrow in the current release: reusable order/customer/fulfillment request comments, customer-required order creation, address selection, order item add/update/remove, explicit order field updates, owner assignment, discount-line writes, tax setting/custom tax-rate writes, and shipping-line writes.
- Order/cart flag tools are deferred until an app-level flag contract registers concrete order flag classes.
- Fulfillment request access is read-only, and canonical fulfillment payloads use bounded child collections.