Shopware added MCP capabilities in version 6.7, which for now are experimental. But I got intrigued, what we can actually do with it. So I took one of my plugins and thought about where AI could actually help. The plugin I chose is a category sorting plugin, which allows you to sort products by hand. What if I want to order them by bestsellers? Or by best margin? This would involve a lot of manual work, first finding the bestsellers, then sorting them in the category. So I thought, what if we could use AI to help with that?
Registering our tool
Extending Shopware’s MCP capabilities is really straightforward. You create a class which sets a few attributes, simple things like name and title. Also, you set a description, which is really not the description, but the prompt the agent uses to decide when and how to use this capability. The necessary data input is derived from the docblock and the arguments. You also set a list of required rights and other necessary tools. In this case we need Shopware’s entity search to produce a list of products ordered by whatever criteria the user asks for. And for rights, we need access to actually write the category sorting. Also, we extend McpToolResponse. Our tool is executed with the __invoke method and the given UUIDs. In it, we first do some sanity checks, like validating the ids and privilege checks. When this is done, we create the actual order data. For this we call a function in our plugin which creates the order for a category. If this succeeds, we return information about what was changed, or if it failed, what was attempted and what went wrong.
You can find that class and the corresponding mcp.xml to load it below this article. The xml is loaded in the plugin’s build method. You can find how to generally extend Shopware’s MCP capabilities here.
Let the client do its job
And now for the exciting part: does it actually work?

To test it, I wrote a small Python script. With it, we authenticate to our test store and initialize our tool set. After this we load the tool list and convert it to our LLM’s (for this I used OpenAI with gpt-5.5) tool format. When this is done, we send our store’s instructions as a system prompt, a tool list, and our user prompt to the LLM. This starts the tool loop, the LLM asks for a certain tool to be executed, which we do, and return the result, followed by the next tool request. This ends when the LLM returns a final answer. We give this command: “Sort the category MCP Test by stock, highest first”.

In the image we see what actually happens. I added some debug info, so this really becomes self-explanatory. First, the LLM uses Shopware’s entity search tool to look up the category’s data, including id. After this, it fetches the products in the category, sorted by stock. With the data now complete, it tries to use our own tool. But strangely, it produces an error. The reason is, that a product number, instead of id, was passed. But the LLM is smart enough and tries again, this time with the correct product id. To avoid this in the future the tool description should be more precise.
And sure enough, the order now is based on the products stock.

With this article I wanted to show how to extend Shopware’s MCP capabilities with custom tools. For now, I wont put this into the plugin’s official release. Things like data rollback and edge cases would have to be assessed. But never the less, this hints at how to create real value for your plugins with AI. It maybe is not yet completely there, but close on the horizon
Here is the MCP tool registration class:
<?php declare(strict_types=1);
namespace Kplngi\ProductOrder\Mcp;
use Kplngi\ProductOrder\Position\ProductCategoryPositionService;
use Kplngi\ProductOrder\Position\Service\CategorySorted;
use Kplngi\ProductOrder\Position\Service\PositionMoveService;
use Mcp\Capability\Attribute\McpTool;
use Shopware\Core\Framework\Context;
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;
use Shopware\Core\Framework\Uuid\Uuid;
#[McpTool(
name: 'kplngi-category-order-set',
title: 'Set Category Product Order',
description: 'Set the manual product order of a category as shown in the storefront listing. '
. 'Pass productIds already sorted in the order they should be displayed: the first id is shown first. '
. 'You may pass only the products that should lead; every other product of the category keeps its current relative order below them. '
. 'Use main product ids (not variant ids); variants follow their parent automatically. '
. 'Find the categoryId and the product ids with shopware-entity-search first, e.g. products filtered by categoriesRo.id and sorted by the value the user asked for. '
. 'This writes immediately and enables manual ordering for the category if it was not enabled yet. '
. 'Returns the new top 10 so you can confirm the result to the user.'
)]
#[McpToolDependsOn('shopware-entity-search')]
#[McpToolRequires('kplngi_productcategoryposition:update')]
#[McpToolRequires('kplngi_productcategoryposition:create')]
class CategoryOrderSetTool extends McpToolResponse
{
public function __construct(
private readonly McpContextProvider $contextProvider,
private readonly CategorySorted $categorySorted,
private readonly ProductCategoryPositionService $positionService,
private readonly PositionMoveService $positionMoveService,
private readonly EntityRepository $categoryRepository,
private readonly EntityRepository $productRepository,
) {
}
/**
* @param string $categoryId id of the category to sort
* @param string[] $productIds main product ids in display order, first one is shown first
*/
public function __invoke(string $categoryId, array $productIds): string
{
$context = $this->contextProvider->getContext();
$missingPrivilege = $this->requirePrivilege(
$context,
'kplngi_productcategoryposition:update',
'kplngi_productcategoryposition:create',
);
if ($missingPrivilege !== null) {
return $missingPrivilege;
}
// Uuid::isValid() only accepts lowercase hex.
$categoryId = strtolower($categoryId);
$productIds = array_values(array_unique(array_map(static fn(mixed $id) => \is_string($id) ? strtolower($id) : $id, $productIds)));
if (!Uuid::isValid($categoryId)) {
return $this->error(\sprintf('"%s" is not a valid category id.', $categoryId));
}
if ($productIds === []) {
return $this->error('productIds must contain at least one product id.');
}
$invalidIds = array_values(array_filter($productIds, static fn(mixed $id) => !\is_string($id) || !Uuid::isValid($id)));
if ($invalidIds !== []) {
return $this->error('These product ids are not valid ids: ' . json_encode($invalidIds));
}
$category = $this->categoryRepository->search(new Criteria([$categoryId]), $context)->first();
if ($category === null) {
return $this->error(\sprintf('Category "%s" not found.', $categoryId));
}
$initialized = false;
if (!$this->categorySorted->isCustomOrderListing($context, $categoryId)) {
$this->positionService->initializeProductCategoryPositions($categoryId, $context);
$initialized = true;
}
$notInCategory = $this->positionMoveService->reorder($categoryId, $productIds, $context);
if ($notInCategory !== []) {
return $this->error(
($initialized ? 'Manual ordering was enabled for this category, but the order was not changed. ' : 'Nothing was changed. ')
. 'These products are not main products of this category (not assigned, or variant ids): '
. implode(', ', $notInCategory)
);
}
return $this->success([
'categoryId' => $categoryId,
'categoryName' => $category->getTranslation('name'),
'manualOrderEnabled' => $initialized ? 'enabled now' : 'was already enabled',
'productsPlacedOnTop' => \count($productIds),
'newTop10' => $this->describeProducts(\array_slice($productIds, 0, 10), $context),
]);
}
/**
* @param list<string> $productIds
*
* @return list<array{position: int, productId: string, productNumber: ?string, name: ?string}>
*/
private function describeProducts(array $productIds, Context $context): array
{
$products = $this->productRepository->search(new Criteria($productIds), $context)->getEntities();
$result = [];
foreach ($productIds as $index => $productId) {
$product = $products->get($productId);
$result[] = [
'position' => $index + 1,
'productId' => $productId,
'productNumber' => $product?->getProductNumber(),
'name' => $product?->getTranslation('name'),
];
}
return $result;
}
}
And the corresponding mcp.xml:
<?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="Kplngi\ProductOrder\Mcp\CategoryOrderSetTool">
<argument type="service" id="Shopware\Core\Framework\Mcp\Context\McpContextProvider"/>
<argument type="service" id="Kplngi\ProductOrder\Position\Service\CategorySorted"/>
<argument type="service" id="Kplngi\ProductOrder\Position\ProductCategoryPositionService"/>
<argument type="service" id="Kplngi\ProductOrder\Position\Service\PositionMoveService"/>
<argument type="service" id="category.repository"/>
<argument type="service" id="product.repository"/>
<tag name="shopware.mcp.tool"/>
</service>
</services>
</container>