Appearance
MCP Server
The module OOCS_LynxMcp runs a Model Context Protocol server, so AI assistants such as Claude or ChatGPT can read and build PageBuilder layouts. Tools call Magento code directly, not REST or GraphQL. What an assistant may do is decided by the Magento admin role of the person who connected it.
This page covers running the server, connecting assistants and adding your own tools. For what merchants can do with it, see Build with AI.
Running the server
bash
php -d opcache.enable_cli=1 bin/magento lynx:mcp:serve --host=127.0.0.1 --port=7361| Option | Default | Purpose |
|---|---|---|
--host | 127.0.0.1 | Listening address. Keep it local and put nginx in front. |
--port | 7361 | Listening port. |
--max-children | 8 | Requests handled at the same time. When all are busy, new connections wait in the socket backlog; they are not refused. |
--timeout | 120 | Seconds a request may run before its worker is killed and the event is logged. |
Requirements: the PHP extensions pcntl and posix (the command stops at once if one is missing).
The server is built on Workerman. The master process boots Magento once, then forks workers from that booted state. Each worker serves exactly one connection and exits, and the master forks a fresh one to replace it. A request therefore never sees state left by a previous one, a slow tool only occupies its own worker, and starting a worker takes a few milliseconds. The master closes its database connection before forking, so each worker opens its own; cache connections such as Redis or Valkey are re-created after the fork as well.
Enable OPcache for the CLI (-d opcache.enable_cli=1). The first worker compiles the code into shared memory and every later worker reuses it, which makes requests several times faster and workers much smaller.
Restart after changes
Configuration, DI and code are loaded once when the server starts. Restart it after deploying code, installing a module, changing Stores › Configuration › Lynx › MCP Server, or editing a tool.
The server speaks Streamable HTTP without sessions: one JSON response per POST /mcp, no server-sent events, no Mcp-Session-Id. Several server processes can therefore run behind one upstream. Each request is limited to 16 KB of headers and 1 MB of body.
bin/magento lynx:mcp:tools lists the registered tools with their ACL resource and whether they change data. Add --details for the full description and input schema, or --json for everything as JSON.
systemd
ini
[Unit]
Description=Lynx MCP server
After=network.target mysql.service
[Service]
User=www-data
WorkingDirectory=/var/www/magento
ExecStart=/usr/bin/php -d opcache.enable_cli=1 bin/magento lynx:mcp:serve --host=127.0.0.1 --port=7361
Restart=always
KillSignal=SIGTERM
TimeoutStopSec=150
[Install]
WantedBy=multi-user.targetOn SIGTERM, workers finish the request they are serving and exit; a worker still busy after --timeout seconds is killed. Keep TimeoutStopSec above --timeout. Workerman writes its PID and status files to var/lynx_mcp/ and its log to var/log/lynx_mcp_server.log. Tool errors go to the Magento logs in var/log/.
nginx
Serve the MCP server on its own subdomain. OAuth metadata must be found under /.well-known/ of the same origin, which is simplest when the server owns the whole host.
nginx
server {
listen 443 ssl http2;
server_name mcp.example.com;
# ssl_certificate ...;
# ssl_certificate_key ...;
location / {
proxy_pass http://127.0.0.1:7361;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_read_timeout 130s;
proxy_request_buffering on;
}
}Set proxy_read_timeout above --timeout. The server's own log shows nginx as the client; real client addresses are in the nginx access log. The server does not rate-limit requests, so use limit_req in nginx if you need it.
Configuration
Stores › Configuration › Lynx › MCP Server, default scope only (there is one server process).
| Field | Default | Purpose |
|---|---|---|
| Public URL | http://127.0.0.1:7361 | The address clients reach, such as https://mcp.example.com. Used as the OAuth issuer, the resource tokens are issued for, and in every metadata URL. Leave it empty to turn OAuth off; then only integration and admin tokens work. |
| Allowed Origins | empty | One origin per line. A request carrying an Origin header that is not listed (and is not the Public URL's origin) is refused with 403, which protects against DNS rebinding. Desktop and command-line clients send no Origin and are not affected. |
| Accept Integration and Admin Tokens | Yes | Accept Magento integration access tokens and admin tokens as Bearer tokens. Integration tokens also need Stores › Configuration › Services › OAuth › Allow OAuth Access Tokens to be used as standalone Bearer tokens. |
| Dynamic Client Registration | Yes | Open /register for clients that register themselves. |
| Access Token Lifetime (seconds) | 3600 | |
| Refresh Token Lifetime (days) | 30 | Counted from the last refresh. |
The Public URL is never derived from the Host header: clients store tokens per issuer, and the resource a token is checked against must not be chosen by the client.
Connecting an assistant
Give the assistant the endpoint https://mcp.example.com/mcp. For example, in Claude Code:
bash
claude mcp add --transport http shop https://mcp.example.com/mcpIn Claude or ChatGPT, add it as a custom connector with the same URL. For scripts and CI, skip OAuth and pass a static token:
bash
claude mcp add --transport http shop https://mcp.example.com/mcp \
--header "Authorization: Bearer <integration access token>"OAuth 2.1 with a pairing code
The authorization page has to be published in public metadata, so it cannot be an admin page without revealing the admin path. Lynx uses a pairing code instead:
- The client calls
/mcp, gets401with aWWW-Authenticateheader pointing to/.well-known/oauth-protected-resource/mcp, and reads the server metadata. - The client identifies itself (see Clients) and opens the browser at
/authorizewith a PKCE challenge. - The page shows an 8-character code, valid for 10 minutes.
- An admin opens Lynx › MCP › Authorize Client and enters the code. The review screen shows the client, its redirect URI and the tools the admin's role allows, with tools that change data marked. The admin approves or denies. After 5 wrong codes, the admin session is locked out for 15 minutes.
- The browser page receives the redirect once, and the client exchanges the code at
/token(PKCE S256 required, code valid for 5 minutes) for an access token and a refresh token.
Redirect URIs must use https, or http to a loopback address (any port).
Tokens are opaque and stored only as SHA-256 hashes. Magento's REST API does not accept them, so a leaked token exposes only the MCP tools of the role. Refreshing rotates the refresh token; if an old refresh token or code is used again, every token from that approval is revoked.
Clients
Lynx › MCP › Clients lists the clients the server knows. A client comes from one of three sources:
| Source | How it is created | Notes |
|---|---|---|
| Dynamic registration | The client calls POST /register itself (RFC 7591). | Public clients only. The name is not verified. Removed by cron once it has no tokens and has not been used for the refresh token lifetime. Turn off with Dynamic Client Registration. |
| Client ID metadata document | The client's client_id is an https URL serving its metadata as JSON. | Fetched over https from public addresses only, without redirects, up to 64 KB; the client_id inside must equal the URL. Cached for its max-age, between 5 minutes and 24 hours. The review screen shows the domain. |
| Manual | An admin uses Add Client. | Public, or confidential with a secret shown once and stored hashed. Never removed by cron. |
Deleting a client deletes its pending requests and tokens.
Connections and revocation
- Lynx › MCP › Connections lists every approved connection. Revoking one deletes all tokens from that approval at once; the client must ask for a new pairing code.
- Clients can revoke their own tokens with
POST /revoke(RFC 7009). - The cron job
lynx_mcp_oauth_cleanupruns daily at 03:00 and removes expired requests, expired tokens and unused dynamically registered clients.
Admin permissions for the MCP screens
| ACL resource | Allows |
|---|---|
OOCS_LynxMcp::authorize | Entering pairing codes. Without it, the admin cannot connect any client. |
OOCS_LynxMcp::clients | Managing clients. |
OOCS_LynxMcp::connections | Viewing and revoking every admin's connections. |
OOCS_LynxMcp::config | The configuration section. |
The first three sit under Lynx › MCP in the role resource tree; the last under Stores › Settings › Configuration.
Permissions model
Access is decided by the Magento ACL only. Every Bearer token is resolved to an admin role:
- an OAuth token acts with the role of the admin who approved it; that admin must still be active and not locked,
- an admin token acts with that admin's role,
- an integration token acts with the integration's resources.
Each tool has its own ACL resource under Lynx › MCP › Tools. tools/list returns only the tools the role holds, and calling any other tool fails as if it did not exist. The role is checked on every request, so granting or removing a resource, or locking the admin, takes effect on the next call without reconnecting. OAuth scopes are not used: a scope sent by the client is ignored, and every client an admin approves can call every tool of that admin's role. A role with All resources gets every tool, including tools from modules installed later.
Store scope
Each request starts in the admin store (All Store Views). Tools that work on one store view take a store argument: a store view code, or a domain or URL the store view serves (the longest matching base URL wins; if several store views share a base URL, use the code).
Built-in tools
| Tool | Changes data | What it does |
|---|---|---|
pagebuilder_read_designs | Lists designs: id, name, description, readonly, parents and the store views running each, including whether that store view uses a Lynx theme. store returns only the design that store view runs. | |
pagebuilder_read_pages | Lists the pages and shared zones (header, footer, backstage) of a design, each with its source (own, inherited:<id> or none). include_empty adds pages without a layout. | |
pagebuilder_read_components | Lists the components a page can be built from. names returns full setting and slot definitions; page or entity (cms_page, category) filters to what that page accepts. | |
pagebuilder_read_layout | Reads the saved nodes of a design's page or zone with the design's theme settings, or the own layout of a cms_page or category with its per-store-view status. | |
pagebuilder_create_design | Yes | Creates a design (name, description, parents). Assigning it to a store view stays an admin task in the configuration. |
pagebuilder_create_preview_url | Returns a signed storefront URL, valid for 2 hours, that opens a page (or a cms_page or category) as a design renders it. Previews render in the browser, so the assistant needs browser access to see them. | |
pagebuilder_create_share_preview_url | Yes | Creates a password-protected share link for a design, valid for 3, 24, 72 or 168 hours. The password is returned once. |
pagebuilder_save_layout | Yes | Replaces the layout of a design's page or zone with nodes ([{name, props, slots}], validated against component definitions) and/or merges theme settings. With cms_page or category, saves that entity's own layout and/or its Render with Lynx switch (enabled). |
catalog_search_products | Finds products by name or SKU: id, SKU, name, type, status, URL key and image path. | |
catalog_search_categories | Finds categories by name, with whether each renders with Lynx and has its own layout. | |
cms_search_pages | Finds CMS pages by title or identifier, with their store views, Lynx switch and layout status. | |
media_upload_image | Yes | Stores a JPEG or PNG image under pub/media/wysiwyg/ from an https url (public host, up to 10 MB) or base64 data (within the 1 MB request limit). An image over 1024 KB or 2560 px is refused with its size so the assistant can ask the user; called again with resize: true, it is shrunk to fit 2560 px with Magento's image adapter. An existing file is never replaced: a taken name gets a suffix. Returns the path for image settings. |
Run bin/magento lynx:mcp:tools --details for each tool's full description and input schema.
CMS page and category layouts
A CMS page or a category can keep its own layout, which replaces the content zone of its storefront page where Render with Lynx is on. The tools reach these layouts through the pool OOCS\LynxPageBuilder\Model\LayoutSources, which maps a type to an OOCS\LynxPageBuilder\Api\LayoutSourceInterface implementation:
| Type | Class | Scope |
|---|---|---|
cms_page | OOCS\LynxCms\Model\PageLayout | One layout per page; store only resolves an identifier. |
category | OOCS\LynxCatalog\Model\CategoryLayout | Layout and switch per store view; without store, the default all store views fall back to. |
A layout shows only in store views that run a Lynx theme, carry the entity (assigned and active) and have the switch on. pagebuilder_read_layout reports all three per store view.
Extending
Add tools and routes from your own module. Declare a dependency on OOCS_LynxMcp in etc/module.xml:
xml
<module name="Vendor_Module">
<sequence>
<module name="OOCS_LynxMcp"/>
</sequence>
</module>The server runs in the global area and loads only global DI. Register everything in etc/di.xml, never in etc/adminhtml/di.xml or another area file: area files are not loaded by the server, and an area declaration of the same array argument would replace the whole list. The same applies to any pool your tool reads.
Adding a tool
A tool is a class implementing OOCS\LynxMcp\Api\ToolInterface:
| Method | Returns |
|---|---|
getDescription() | What the tool does, written for the model deciding whether to call it. |
getInputSchema() | JSON Schema of the arguments, as an array with type: object at the root. |
getAclResource() | The ACL resource the caller's role must hold. |
isReadOnly() | false when the tool changes data. Sent to clients as readOnlyHint and marked on the approval screen. |
execute(array $arguments) | The result, an array with string keys. It is returned both as structuredContent and as JSON text. |
Conventions:
- Put the class in
Tool/<Object>/<VerbObject>.php, for exampleTool/Blocks/ReadBlock.php. - Name the tool
<group>_<verb>_<object>, such ascms_read_block. Names must match^[a-zA-Z0-9_-]{1,64}$and be unique: a later module registering the same name replaces the tool. - Name the ACL resource
<Module>::tool_<tool name>and place it underOOCS_LynxMcp::tools. - The server does not validate arguments against the schema. Check them in
execute(). - Throw
Magento\Framework\Exception\LocalizedExceptionfor errors the assistant should read; its message is returned as a tool error. Any other exception is logged and reported as "The tool failed". - The request starts in the admin store. Take a
storeargument when the tool works per store view. - A request running longer than
--timeoutis killed. Wrap writes in a database transaction.
Example: a tool that reads a CMS block.
Tool/Blocks/ReadBlock.php
php
<?php
declare(strict_types=1);
namespace Vendor\Module\Tool\Blocks;
use Magento\Cms\Api\GetBlockByIdentifierInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Store\Model\Store;
use Magento\Store\Model\StoreManagerInterface;
use OOCS\LynxMcp\Api\ToolInterface;
class ReadBlock implements ToolInterface
{
/**
* @param GetBlockByIdentifierInterface $getBlockByIdentifier
* @param StoreManagerInterface $storeManager
*/
public function __construct(
private readonly GetBlockByIdentifierInterface $getBlockByIdentifier,
private readonly StoreManagerInterface $storeManager,
) {
}
public function getDescription(): string
{
return 'Read a CMS block by identifier: id, title, whether it is active, and its HTML content.';
}
public function getInputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'identifier' => ['type' => 'string', 'description' => 'Block identifier'],
'store' => ['type' => 'string', 'description' => 'Store view code; default all store views'],
],
'required' => ['identifier'],
];
}
public function getAclResource(): string
{
return 'Vendor_Module::tool_cms_read_block';
}
public function isReadOnly(): bool
{
return true;
}
public function execute(array $arguments): array
{
$identifier = trim((string) ($arguments['identifier'] ?? ''));
if ($identifier === '') {
throw new LocalizedException(__('identifier must not be empty.'));
}
try {
$storeId = isset($arguments['store'])
? (int) $this->storeManager->getStore((string) $arguments['store'])->getId()
: Store::DEFAULT_STORE_ID;
$block = $this->getBlockByIdentifier->execute($identifier, $storeId);
} catch (NoSuchEntityException) {
throw new LocalizedException(__('No block or store view matches.'));
}
return [
'id' => (int) $block->getId(),
'title' => (string) $block->getTitle(),
'is_active' => (bool) $block->isActive(),
'content' => (string) $block->getContent(),
];
}
}etc/acl.xml
xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd">
<acl>
<resources>
<resource id="Magento_Backend::admin">
<resource id="OOCS_LynxPageBuilder::manage">
<resource id="OOCS_LynxMcp::mcp">
<resource id="OOCS_LynxMcp::tools">
<resource id="Vendor_Module::tool_cms_read_block" title="Read CMS Block" translate="title"/>
</resource>
</resource>
</resource>
</resource>
</resources>
</acl>
</config>etc/di.xml
xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="OOCS\LynxMcp\Model\ToolList">
<arguments>
<argument name="tools" xsi:type="array">
<item name="cms_read_block" xsi:type="object">Vendor\Module\Tool\Blocks\ReadBlock\Proxy</item>
</argument>
</arguments>
</type>
</config>The item name is the tool name. Register the class through its Proxy, so listing tools does not build every tool's dependencies. ToolList checks names and classes when the server starts and stops with an error if one is invalid.
Then:
- Run
bin/magento setup:upgrade(andsetup:di:compilein production). - Grant the new resource to the admin roles that should use it (System › Permissions › User Roles › Role Resources). Roles with All have it already.
- Restart the MCP server.
- Check it with
bin/magento lynx:mcp:tools. Connected assistants see the tool the next time they list tools.
Adding an HTTP route
A route is a class implementing OOCS\LynxMcp\Api\RouteInterface:
getMethods()returns the HTTP methods it answers; other methods get405with anAllowheader.handle(array $request)receivesmethod,path,query,headers(lower-case names) andbody, and returns[status, headers, body]. An array body is sent as JSON.
Routes are not authenticated by the server. A route that exposes data must check the Authorization header itself.
php
<?php
declare(strict_types=1);
namespace Vendor\Module\Service\Route;
use OOCS\LynxMcp\Api\RouteInterface;
class Health implements RouteInterface
{
public function getMethods(): array
{
return ['GET'];
}
public function handle(array $request): array
{
return [200, [], ['status' => 'ok']];
}
}Register it in etc/di.xml. The item name is the path, matched exactly (no parameters) and starting with /:
xml
<type name="OOCS\LynxMcp\Model\RouteList">
<arguments>
<argument name="routes" xsi:type="array">
<item name="/health" xsi:type="object">Vendor\Module\Service\Route\Health</item>
</argument>
</arguments>
</type>Built-in paths are /mcp, /.well-known/oauth-protected-resource, /.well-known/oauth-protected-resource/mcp, /.well-known/oauth-authorization-server, /authorize, /authorize/status, /token, /revoke and /register. Registering one of these names replaces the built-in route.
Providing a layout source
The cms_page and category arguments of the layout tools read and write through the LayoutSources pool. A module that stores CMS page or category layouts differently can register its own implementation of OOCS\LynxPageBuilder\Api\LayoutSourceInterface under the same type:
| Method | Purpose |
|---|---|
getPagePath() | The full action name the entity renders under, such as cms_page_view. Used to filter components. |
resolveId($key, $storeId) | The entity id from an id or another key (a CMS page identifier). |
getLabel($id, $storeId) | Its title in the store view. |
getNodes($id, $storeId) / saveNodes($id, $storeId, $nodes) | Read and store the layout; an empty list removes it. |
isEnabled() / setEnabled() | The Render with Lynx switch for the store view. |
isAvailable($id, $storeId) | Whether the store view shows the entity at all (assigned and active). |
getPreviewRoute($id, $storeId) | The storefront route a visitor reaches it by, used for preview URLs. |
isStoreScoped() | Whether the layout and switch differ by store view. |
All methods taking an id throw NoSuchEntityException when the entity does not exist.
xml
<!-- etc/di.xml -->
<type name="OOCS\LynxPageBuilder\Model\LayoutSources">
<arguments>
<argument name="sources" xsi:type="array">
<item name="cms_page" xsi:type="object">Vendor\Module\Model\PageLayout</item>
</argument>
</arguments>
</type>Make your module load after the module that registers the type you replace (OOCS_LynxCms for cms_page, OOCS_LynxCatalog for category). The built-in tools accept only the cms_page and category types. A source registered under another type can be read from your own tools with LayoutSources::get('<type>').
Limits
- Arguments are not validated against the input schema; each tool checks its own.
- The server sends no CORS headers. MCP clients running in a browser must go through their own proxy.
- Access cannot be narrowed per client: every client an admin approves can use every tool of that admin's role. Create a dedicated admin with a restricted role for assistants that should do less.