Adding a new UCP capability
This page walks you through the exact steps to add a new UCP capability
2. Adding a new UCP capability
This page walks you through the exact steps to add a new UCP capability to Shopware — either a root capability or an extension that augments an existing one — as a plugin or as a core PR.
We use a worked example throughout: a fictional wishlist capability
com.example.shopping.wishlist that exposes "get wishlist", "add to
wishlist", and "remove from wishlist" as UCP operations.
2.1 Pick a name
UCP uses reverse-domain namespaces. Pick a name that:
- Starts with a reverse-domain prefix you own (
com.yourcompany,org.yourorg, …). Thedev.ucp.*namespace is reserved for the UCP governing body. - Has a
{service}.{capability}suffix (shopping.wishlist,common.identity_linking, …). - Stays the same forever — once published, your name is part of the ecosystem.
| Good | Bad |
|---|---|
com.example.shopping.wishlist | wishlist (no namespace) |
org.openretail.shopping.loyalty | dev.ucp.shopping.wishlist (reserved namespace) |
com.acme.payments.installments | acme.installments (missing TLD) |
The simulator's negotiation step rejects spec URLs that don't match the namespace authority, so picking the wrong namespace is detected immediately.
2.2 Implement the capability class
Create a class implementing the UcpCapability interface (or extending
AbstractUcpCapability for the common boilerplate):
<?php declare(strict_types=1);
namespace Example\WishlistPlugin\Ucp;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Ucp\Capability\AbstractUcpCapability;
#[Package('framework')]
final class WishlistCapability extends AbstractUcpCapability
{
public const NAME = 'com.example.shopping.wishlist';
public function getName(): string
{
return self::NAME;
}
public function getSpecUrl(): string
{
return 'https://example.com/ucp/specifications/wishlist/' . $this->getVersion();
}
public function getSchemaUrl(): string
{
return 'https://example.com/ucp/schemas/wishlist.json';
}
public function getExtends(): string|array|null
{
// Root capability — extends nothing. Return a string or array
// (e.g. ['dev.ucp.shopping.checkout', 'dev.ucp.shopping.cart'])
// when implementing an extension.
return null;
}
public function getProfileConfig(): ?array
{
// Optional per-business config object published with this
// capability in /.well-known/ucp. Return null if not used.
return null;
}
}Note:
AbstractUcpCapability::getVersion()reads the UCP protocol version from the per-Sales-Channel config so your capability auto-version-tracks the shop. Override it only if your capability has independent versioning (com.example.*namespace can do this).
2.3 Register the capability service
In your plugin's Resources/config/services.xml:
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services">
<services>
<service id="Example\WishlistPlugin\Ucp\WishlistCapability">
<tag name="ucp.capability" capability="com.example.shopping.wishlist"/>
</service>
</services>
</container>That's the entire registration. The UcpCapabilityCompilerPass picks up
every tagged service at compile time and adds it to the
CapabilityRegistry. Your capability now appears in:
/.well-known/ucpfor any Sales Channel where the admin has enabled it- The capability toggle list in the admin UI
- The negotiation intersection algorithm
2.4 Implement the wire operations
A capability declaration on its own doesn't do anything — agents need to be able to call something. You implement that as one or more controllers under your capability's namespace.
REST controller
<?php declare(strict_types=1);
namespace Example\WishlistPlugin\Ucp\Controller;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Routing\Annotation\RouteScope;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
#[Package('framework')]
#[Route(defaults: ['_routeScope' => ['ucp']])]
final class WishlistController
{
public function __construct(
private readonly WishlistService $wishlist,
) {
}
#[Route(
path: '/ucp/v1/wishlist',
name: 'ucp.wishlist.get',
methods: ['GET'],
)]
public function get(Request $request, SalesChannelContext $context): JsonResponse
{
$wishlist = $this->wishlist->load($context);
return new JsonResponse([
'ucp' => [
'version' => $context->getContext()->getVersionId(),
'capabilities' => [
'com.example.shopping.wishlist' => [['version' => '2026-01-23']],
],
],
'id' => $wishlist->getId(),
'items' => $wishlist->toUcpArray(),
]);
}
// …add / remove omitted for brevity
}The _routeScope = ['ucp'] annotation routes the request through the
UcpAgentRequestResolver middleware, which validates the UCP-Agent
header, fetches the platform profile, and ensures negotiation
completes before your controller runs.
MCP tool (optional, recommended for MCP transport)
For MCP support, add a tool class:
<?php declare(strict_types=1);
namespace Example\WishlistPlugin\Ucp\Mcp;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Ucp\Transport\Mcp\McpTool;
use Shopware\Core\Framework\Ucp\Transport\Mcp\McpToolDescriptor;
#[Package('framework')]
final class GetWishlistTool implements McpTool
{
public function descriptor(): McpToolDescriptor
{
return new McpToolDescriptor(
name: 'get_wishlist',
description: 'Return the current wishlist for the authenticated user.',
inputSchema: ['type' => 'object'],
outputSchema: ['type' => 'object', 'properties' => [/* … */]],
);
}
public function call(array $args, SalesChannelContext $context): array
{
// Same logic as REST controller, returns an array
}
}Tag it ucp.mcp_tool with tool_name and capability attributes:
<service id="Example\WishlistPlugin\Ucp\Mcp\GetWishlistTool">
<tag name="ucp.mcp_tool"
tool_name="get_wishlist"
capability="com.example.shopping.wishlist"/>
</service>2.5 Define the schema (if extending)
If your capability extends an existing one and modifies its shared
structures (e.g. a discount extension that adds a discounts object
to the cart and checkout objects), you publish a schema document
declaring the additions.
Schema requirements per spec:
- One
$defsentry per parent declared inextends. - Each
$defsentry uses the parent's full capability name as the key (e.g.dev.ucp.shopping.checkout), not the short name. - Use
allOfto compose with the base schema.
Example fragment:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/ucp/schemas/wishlist.json",
"name": "com.example.shopping.wishlist",
"title": "Wishlist",
"$defs": {
"dev.ucp.shopping.cart": {
"title": "Cart with wishlist support",
"allOf": [
{ "$ref": "cart.json" },
{
"type": "object",
"properties": {
"wishlist_added": { "type": "array", "items": { "$ref": "#/$defs/wishlist_item" } }
}
}
]
},
"wishlist_item": { … }
}
}2.6 Add the toggle to the admin UI
This is automatic — the admin module reads the
CapabilityRegistry via the Admin-API endpoint
GET /api/_admin/ucp/capabilities and renders one toggle per
registered capability. Your capability appears in the Capabilities
card alongside the built-in ones.
To customise the human-readable label, add a snippet to your plugin's snippet files:
// administration/src/module/sw-settings-ucp/snippet/en-GB.json
{
"sw-settings-ucp": {
"capabilities": {
"com.example.shopping.wishlist": "Wishlist (extension)"
}
}
}2.7 Test your capability with the simulator
The simulator (simulator/) supports any UCP-compliant business out of
the box, so a custom capability with a custom name shows up
automatically in negotiation as business-only — the simulator won't
exercise it unless the simulator's platform profile also advertises it.
To add it to the simulator's platform profile so it actually gets
negotiated, edit simulator/src/ucp/discovery.ts and add an entry to
PLATFORM_PROFILE.ucp.capabilities:
'com.example.shopping.wishlist': [
{ version: '2026-01-23',
spec: 'https://example.com/ucp/specifications/wishlist/2026-01-23',
schema: 'https://example.com/ucp/schemas/wishlist.json' },
],Restart the simulator. Now your capability will appear in the negotiation intersection card when the simulator runs against your Shopware shop.
You can also add a custom scenario to exercise it:
// simulator/src/agent/scenarios.ts
{
id: 'extension.wishlist',
title: 'Extension: wishlist',
category: 'extensions',
description: 'Adds a product to the wishlist and re-fetches it.',
requiresCapabilities: ['com.example.shopping.wishlist'],
params: [{ id: 'productId', label: 'Product ID', type: 'string', default: '' }],
async run(agent, params) {
await agent.connect()
// call your custom REST endpoint via the agent or directly via fetch
// …
},
},The simulator will then show your scenario in the picker and run it end-to-end against your Shopware shop.
2.8 Checklist
When opening a PR or releasing your plugin:
- Name uses a reverse-domain you own
-
UcpCapabilityimplementation with stableNAMEconst - DI tag
ucp.capabilitywithcapability=attribute - At least one transport binding (REST controller and/or MCP tool)
- Schema document published at the spec URL declared in
getSchemaUrl()(404 here will fail negotiation) - Spec document published at the URL declared in
getSpecUrl() - Snippet for the human-readable toggle label
- Integration test against the simulator's compliance suite