Developer guide

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, …). The dev.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.
GoodBad
com.example.shopping.wishlistwishlist (no namespace)
org.openretail.shopping.loyaltydev.ucp.shopping.wishlist (reserved namespace)
com.acme.payments.installmentsacme.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
<?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
<?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/ucp for 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
<?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.

For MCP support, add a tool class:

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

xml
<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 $defs entry per parent declared in extends.
  • Each $defs entry uses the parent's full capability name as the key (e.g. dev.ucp.shopping.checkout), not the short name.
  • Use allOf to compose with the base schema.

Example fragment:

json
{
  "$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:

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

ts
'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:

ts
// 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
  • UcpCapability implementation with stable NAME const
  • DI tag ucp.capability with capability= 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

→ Next: Integrating a payment plugin with UCP