Admin MCP
Pyle includes a read-only Admin MCP server for staff and sales workflows. It gives AI clients a stable HTTP surface for catalog search, pricing, stock inspection, and material-list comparisons while continuing to rely on the same product, inventory, and offer rules used by the application itself.
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 already allows loopback redirects such as http://localhost, http://127.0.0.1, and http://[::1], which covers local OAuth callbacks for clients such as Codex and Claude Code. It also includes ChatGPT callback domains such as https://chat.openai.com and https://chatgpt.com.
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:
catalog.search
catalog.attributes
catalog.facets
catalog.compare-pricing
catalog.compare-cost
products.pricing
products.stock
products.specifications
stock.search
locations.search
material-lists.availability
material-lists.pricing
material-lists.compareAvailable Prompts
The server also publishes one MCP guidance prompt:
catalog.pricing-workflowMoney 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.
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.
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.
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://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.
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"
}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. - The MCP is read-only. Querying and sales-assist workflows are the focus of the first release.