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.
Version requirements
This guide describes Shopware 6.7.14.0 and later. Earlier 6.7 versions need MCP_SERVER=1 in the environment and have no tool groups or toolsets, so skip Step 2 there. See Configuration for details.
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.php # 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\McpToolGroup;
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.')]
#[McpToolGroup('swag-my-plugin')]
#[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 reads 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: Assign a tool group
For in-process extensions, including plugins and Symfony bundles, assign every tool to a focused group with #[McpToolGroup]:
#[McpToolGroup('swag-my-plugin')]Groups organize tools in the Administration allowlist and debug:mcp. Each group also becomes a toolset that an MCP client can enable for its current session. Use a stable, hyphenated name that keeps related tools together without loading unrelated schemas. A tool belongs to exactly one group, so the attribute cannot be repeated. The toolset title is generated from the group name — swag-my-plugin becomes "Swag my plugin tools".
The attribute is optional. Without it, Shopware uses the longest hyphen-separated name prefix that the tool shares with another tool that also has no explicit group. For example, swag-my-plugin-orders and swag-my-plugin-products form the group swag-my-plugin. A tool without a longer shared prefix falls back to its first name segment, such as swag. Prefixes are compared segment by segment and case-insensitively, so swagfoo-orders and swag-orders share nothing.
Because the inferred group depends on which other tools are registered without a group, declare the group explicitly when you need a stable toolset name or want a different grouping. An explicit group also takes precedence over the group Shopware derives for app tools.
The special discovery group places an allowed tool in the initial session surface. Such a tool is always advertised and never appears in shopware-toolsets-list, so it can be neither enabled nor disabled. Use it only for an intentional discovery meta-tool; regular domain tools should remain deferred.
Step 3: 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 4: Register the service
In src/Resources/config/services.php, tag the service with shopware.mcp.tool:
<?php declare(strict_types=1);
use Shopware\Core\Framework\Mcp\Context\McpContextProvider;
use Swag\MyPlugin\Mcp\Tool\MyTool;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
return static function (ContainerConfigurator $configurator): void {
$services = $configurator->services();
$services->set(MyTool::class)
->args([
service('order.repository'),
service(McpContextProvider::class),
])
->tag('shopware.mcp.tool');
};Plugin tools use shopware.mcp.tool (not mcp.tool). The MCP compiler remaps 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.
Available tags
| Shopware tag | Purpose |
|---|---|
shopware.mcp.tool | Register a tool |
shopware.mcp.prompt | Register a prompt |
shopware.mcp.resource | Register a resource |
Step 5: 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 registered in the live HTTP endpoint and can be discovered through its toolset. If it does not appear, check:
- Plugin is installed and active
- Service is tagged with
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:
public function build(ContainerBuilder $container): void
{
$loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/Resources/config'));
$loader->load('services.php');
}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