Extending the MCP Server via Plugin
Shopware plugins and Symfony bundles can add custom tools, prompts, and resources to the MCP server. This guide covers the plugin path: in-process PHP with full DAL access and the Shopware plugin lifecycle.
Use a plugin when:
- Your tool needs deep access to DAL repositories, services, or the Symfony container
- You want to ship via the Shopware Marketplace
- Your capability is tightly coupled to Shopware's plugin lifecycle (install, activate, deactivate)
For remote/webhook-based capabilities, see Extending via App. For a side-by-side comparison of all three extension types, see Extending the MCP Server.
Naming convention
All capability names must only contain a-zA-Z0-9_- (no dots). Use a hyphen-separated vendor prefix to avoid conflicts:
- Core:
shopware-{name}(reserved; do not use in extensions) - Plugin / Bundle:
{vendor-name}-{tool-name}(e.g.,swag-erp-sync-orders) - App:
{app-name}-{tool-name}(auto-prefixed)
This convention applies uniformly to tools, prompts, and resources.
Plugin structure
custom/plugins/SwagMyPlugin/
├── composer.json
└── src/
├── SwagMyPlugin.php # Plugin class
├── Mcp/
│ └── Tool/
│ └── MyTool.php # MCP tool class
└── Resources/
└── config/
└── services.xml # Service registrationStep 1: Create the tool class
The #[McpTool] attribute must be on the class, not on __invoke(). Extend McpToolResponse to get consistent response envelopes and built-in helpers.
<?php declare(strict_types=1);
namespace Swag\MyPlugin\Mcp\Tool;
use Mcp\Capability\Attribute\McpTool;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\Mcp\Attribute\McpToolDependsOn;
use Shopware\Core\Framework\Mcp\Attribute\McpToolRequires;
use Shopware\Core\Framework\Mcp\Context\McpContextProvider;
use Shopware\Core\Framework\Mcp\Tool\McpToolResponse;
#[McpTool(name: 'swag-my-plugin-orders', title: 'Order List', description: 'List recent orders for a given customer email.')]
#[McpToolDependsOn('shopware-entity-schema')]
#[McpToolRequires('order:read')]
class MyTool extends McpToolResponse
{
public function __construct(
private readonly EntityRepository $orderRepository,
private readonly McpContextProvider $contextProvider,
) {
}
public function __invoke(string $email, int $limit = 10): string
{
$context = $this->contextProvider->getContext();
if ($error = $this->requirePrivilege($context, 'order:read')) {
return $error;
}
$criteria = new Criteria();
$criteria->addFilter(/* ... */);
$criteria->setLimit($limit);
$orders = $this->orderRepository->search($criteria, $context);
return $this->success(
$orders->map(fn($o) => ['id' => $o->getId(), 'orderNumber' => $o->getOrderNumber()]),
['total' => $orders->getTotal()]
);
}
}Key rules:
#[McpTool]goes on the class, not on__invoke(). The MCP compiler passes read class-level attributes; method-level attributes are silently ignored.titleis optional. When set, MCP clients (Claude Desktop, Cursor, etc.) display it in their tool list instead of the machine-readablename. Omit it if you have no better label to offer.- Names must only contain
a-zA-Z0-9_-. - Parameter types on
__invoke()are mapped to JSON schema. Supported:string,int,float,bool. Default values make parameters optional. - Obtain the request context via
McpContextProvider::getContext()injected through the constructor. Do not add aContextparameter to__invoke(). The MCP SDK does not inject it there. requirePrivilege()returns an error string on failure; check its return value withif ($error = $this->requirePrivilege(...)) { return $error; }.#[McpToolRequires]is declarative only; without this call there is no runtime enforcement.- Never use
Context::createDefaultContext()inside a tool. It bypasses the integration's ACL. UseMcpContextProvider::getContext()instead. - Return a
stringfrom__invoke(). The MCP SDK automatically wraps the return value into the protocol response. - Extend
McpToolResponseto use$this->success()and$this->error()helpers.
Step 2: Declare dependencies and privileges
Tool dependencies
When your tool only makes sense after the agent has used another tool, declare that relationship with #[McpToolDependsOn]:
#[McpToolDependsOn('shopware-entity-schema')] // agent should know the schema first
#[McpToolDependsOn('shopware-entity-search')] // and be able to searchThe attribute is repeatable. When an operator enables your tool in the Admin UI, all declared dependencies (and their transitive dependencies) are automatically added to the integration's allowlist.
Only declare a dependency when it is genuinely required; unnecessary dependencies inflate every integration's allowlist.
Required privileges
Declare the ACL privileges your tool needs with #[McpToolRequires] so operators can configure roles correctly:
// Static privilege
#[McpToolRequires('order:read')]
// Dynamic privilege (entity name comes from a runtime parameter)
#[McpToolRequires(entityParam: 'entity', operations: ['read', 'update'])]The attribute is declarative only: it populates the Admin UI coverage warnings and bin/console debug:mcp output. You still must call $this->requirePrivilege($context, 'order:read') inside __invoke() for actual runtime enforcement.
Step 3: Register the service
In src/Resources/config/services.xml, tag the service with shopware.mcp.tool:
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services
http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="Swag\MyPlugin\Mcp\Tool\MyTool">
<argument type="service" id="order.repository"/>
<argument type="service" id="Shopware\Core\Framework\Mcp\Context\McpContextProvider"/>
<tag name="shopware.mcp.tool"/>
</service>
</services>
</container>Plugin tools use shopware.mcp.tool (not mcp.tool). The MCP compiler passes remap this tag to mcp.tool at compile time and registers the tool with the MCP server builder. You do not need a shopware.feature flag tag; the MCP feature flag gates the server endpoint itself, and once it is enabled, all registered tools are available.
Available tags
| Shopware tag | Purpose |
|---|---|
shopware.mcp.tool | Register a tool |
shopware.mcp.prompt | Register a prompt |
shopware.mcp.resource | Register a resource |
Step 4: Install and verify
bin/console plugin:refresh
bin/console plugin:install --activate SwagMyPlugin
bin/console cache:clearVerify the tool is registered:
bin/console debug:mcpIf the tool appears here, it is available in the live HTTP endpoint. If it does not appear, check:
- Plugin is installed and active
- Service has
<tag name="shopware.mcp.tool"/> #[McpTool]is on the class, not on__invoke()
Adding prompts
Follow the same pattern with #[McpPrompt] and shopware.mcp.prompt:
use Mcp\Capability\Attribute\McpPrompt;
#[McpPrompt(name: 'swag-my-plugin-context', title: 'My Plugin Context', description: 'Context for using the My Plugin MCP tools.')]
class MyPluginContextPrompt
{
public function __invoke(): array
{
return [
['role' => 'user', 'content' => 'You are working with the My Plugin Shopware extension...'],
];
}
}Adding resources
Follow the same pattern with #[McpResource] and shopware.mcp.resource:
use Mcp\Capability\Attribute\McpResource;
#[McpResource(uri: 'swag-my-plugin://config', name: 'swag-my-plugin-config', description: 'Current configuration values.')]
class MyPluginConfigResource
{
public function __invoke(): array
{
return [
'uri' => 'swag-my-plugin://config',
'mimeType' => 'application/json',
'text' => json_encode(['key' => 'value']),
];
}
}Extending via Symfony bundle
Symfony bundles (not Shopware plugins) follow the same shopware.mcp.tool tag mechanism. The key differences:
- The bundle class extends
Symfony\Component\HttpKernel\Bundle\Bundle, notShopware\Core\Framework\Plugin - No Shopware install/activate lifecycle; the bundle is always active when registered in
config/bundles.php - Load services unconditionally in the bundle's
build()method — no feature flag gate is required, because the MCP feature flag gates the HTTP endpoint, not the service registration:
public function build(ContainerBuilder $container): void
{
$loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/Resources/config'));
$loader->load('services.xml');
}If you only want to register the bundle itself when the MCP feature is active, gate the entry in config/bundles.php instead — see Optional bundles.
Common pitfalls
Dots in capability names
Names must only contain a-zA-Z0-9_-. Dots are not allowed:
// Wrong
#[McpTool(name: 'swag-my-plugin.orders', description: '...')]
// Correct
#[McpTool(name: 'swag-my-plugin-orders', description: '...')]Attribute on the wrong level
#[McpTool] must be in the class. Placing it on __invoke() silently drops the tool:
// Wrong (tool is silently skipped)
class MyTool extends McpToolResponse
{
#[McpTool(name: 'swag-my-plugin-orders', description: '...')]
public function __invoke(): string { ... }
}
// Correct
#[McpTool(name: 'swag-my-plugin-orders', description: '...')]
class MyTool extends McpToolResponse
{
public function __invoke(): string { ... }
}Unhandled exceptions
Unhandled exceptions in __invoke() produce a generic MCP error (-32603). Catch known exceptions and return $this->error($message) instead:
public function __invoke(string $entity): string
{
try {
return $this->success(['result' => $data]);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}For write tools, use $this->executeWithDryRun(), which catches exceptions automatically and returns structured error responses.
Further reading
- MCP Concepts: tools, resources, and prompts explained
- Best Practices: design principles for MCP tools
- Configuration: allowlist, ACL, and CLI debugging
- SwagMcpAdminUsers: example plugin registering tools, prompts, and resources for admin user management