Skip to content

Extending the MCP Server via Plugin

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 ​

text
custom/plugins/SwagMyPlugin/
├── composer.json
└── src/
 ├── SwagMyPlugin.php              # Plugin class
 ├── Mcp/
 │   └── Tool/
 │       └── MyTool.php            # MCP tool class
 └── Resources/
 └── config/
 └── services.php          # Service registration

Step 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
<?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.
  • title is optional. When set, MCP clients (Claude Desktop, Cursor, etc.) display it in their tool list instead of the machine-readable name. 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 a Context parameter to __invoke(). The MCP SDK does not inject it there.
  • requirePrivilege() returns an error string on failure; check its return value with if ($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. Use McpContextProvider::getContext() instead.
  • Return a string from __invoke(). The MCP SDK automatically wraps the return value into the protocol response.
  • Extend McpToolResponse to 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]:

php
#[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]:

php
#[McpToolDependsOn('shopware-entity-schema')]   // agent should know the schema first
#[McpToolDependsOn('shopware-entity-search')]    // and be able to search

The 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:

php
// 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
<?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 tagPurpose
shopware.mcp.toolRegister a tool
shopware.mcp.promptRegister a prompt
shopware.mcp.resourceRegister a resource

Step 5: Install and verify ​

bash
bin/console plugin:refresh
bin/console plugin:install --activate SwagMyPlugin
bin/console cache:clear

Verify the tool is registered:

bash
bin/console debug:mcp

If 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:

php
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:

php
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, not Shopware\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:
php
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:

php
// 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:

php
// 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:

php
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 ​

Was this page helpful?
UnsatisfiedSatisfied
Be the first to vote!
0.0 / 5  (0 votes)