Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# next version

- Show the UCP tools only to UCP agents instead of on every Store API MCP connection. 1.3.0 put them into the group Shopware reserves for its own discovery tools, so every client connecting to `/store-api/_mcp` saw the thirteen UCP tools next to Shopware's instruction that no tools are listed until a toolset is enabled -- and models followed that instruction, enabling toolsets for tools they already had. The tools now form their own `ucp` toolset, and `/ucp/mcp` selects it at connect time, so a UCP agent still finds them on its first tool listing while a plain `/store-api/_mcp` connection lists only Shopware's discovery tools. On Shopware versions before `6.7.15.0`, which cannot select a toolset at connect time, the tools stay listed on every connection as before.
- Link the UCP settings to their documentation. The Exposure sub-tab of the Agentic Commerce tab now carries a link below the capability and transport checkboxes that opens the UCP section of the user documentation in a new tab, in the language of the administration. Until now the tab explained each option only in a one-line tooltip and gave no way to read on.
- Let an extension register its own UCP OAuth scope. The supported scopes were a private class constant, so a plugin that adds a UCP capability of its own had no way to make its scope grantable: the token request threw `Unsupported OAuth scope`, and a consent flow that swallowed the error burned its one-time handle and told the buyer the authorization link had expired. Tag a scope provider with `swag_agentic_commerce.ucp.oauth_scope_provider` and its scope is advertised in `scopes_supported` on `/.well-known/oauth-authorization-server` and accepted in an authorization request. A request that omits the scope still gets only the three built-in ones; an extension scope has to be asked for by name. An unregistered scope is still rejected -- now with the supported set named in the message, which is what made this hard to diagnose.
- Require Shopware `6.5.8` or newer. `6.5.0.0` through `6.5.7.4` were listed as compatible but could never install: those versions ship Symfony 6.3, while both the extension's own routes and the UCP SDK need Symfony 6.4. Installation therefore ended in a Composer error about `symfony/config` that a merchant cannot act on. Such a shop now sees the extension as incompatible; updating to `6.5.8.x` fixes that and stays inside the same minor.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG_de-DE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# next version

- Die UCP-Tools sind jetzt nur noch für UCP-Agenten sichtbar und nicht mehr bei jeder MCP-Verbindung zur Store API. In 1.3.0 lagen sie in der Gruppe, die Shopware für seine eigenen Discovery-Tools vorsieht. Jeder Client, der sich mit `/store-api/_mcp` verband, bekam deshalb alle dreizehn UCP-Tools angezeigt, obwohl Shopware ihm gleichzeitig mitteilt, dass Tools erst nach dem Aktivieren eines Toolsets erscheinen. Die Modelle haben sich daran gehalten und Toolsets für Tools aktiviert, die sie längst hatten. Jetzt bilden die Tools ein eigenes Toolset `ucp`, das `/ucp/mcp` direkt beim Verbindungsaufbau auswählt: Ein UCP-Agent sieht sie weiterhin sofort, eine normale Verbindung zu `/store-api/_mcp` zeigt nur noch die Discovery-Tools von Shopware. Unter Shopware-Versionen vor `6.7.15.0`, in denen sich beim Verbindungsaufbau kein Toolset auswählen lässt, bleibt alles wie bisher.
- Die UCP-Einstellungen verweisen jetzt auf ihre Dokumentation. Im Unterreiter „Bereitstellung“ des Tabs „Agentic Commerce“ steht unterhalb der Checkboxen für Funktionen und Transportwege ein Link, der den UCP-Abschnitt der Benutzerdokumentation in der Sprache der Administration in einem neuen Tab öffnet. Bisher erklärte der Tab jede Option nur in einem einzeiligen Tooltip und bot keine Möglichkeit, weiterzulesen.
- Erweiterungen können jetzt eigene UCP-OAuth-Scopes registrieren. Bisher war die Liste der zulässigen Scopes fest im Code hinterlegt, sodass ein Plugin mit eigener UCP-Capability seinen Scope nicht freischalten konnte: Die Token-Anfrage schlug mit `Unsupported OAuth scope` fehl. Weil das Plugin diesen Fehler still abfing, der Autorisierungslink aber schon eingelöst war, sah der Käufer nur die Meldung, der Link sei abgelaufen. Jetzt genügt ein Scope-Provider mit dem Service-Tag `swag_agentic_commerce.ucp.oauth_scope_provider`: Sein Scope wird in `scopes_supported` unter `/.well-known/oauth-authorization-server` veröffentlicht und in Autorisierungsanfragen akzeptiert. Ein Client, der keinen Scope angibt, erhält wie bisher nur die drei eingebauten Scopes; den Scope einer Erweiterung muss er ausdrücklich anfordern. Nicht registrierte Scopes werden weiterhin abgelehnt, die Fehlermeldung führt jetzt aber alle unterstützten Scopes auf -- genau diese Angabe fehlte bei der Fehlersuche.
- Die Erweiterung setzt jetzt Shopware `6.5.8` oder neuer voraus. `6.5.0.0` bis `6.5.7.4` wurden als kompatibel angezeigt, ließen sich aber nie installieren: Diese Versionen enthalten Symfony 6.3, während sowohl die Routen der Erweiterung als auch das UCP-SDK Symfony 6.4 benötigen. Die Installation brach deshalb mit einer Composer-Fehlermeldung zu `symfony/config` ab, mit der ein Händler nichts anfangen kann. In betroffenen Shops wird die Erweiterung jetzt als nicht kompatibel angezeigt; ein Update auf `6.5.8.x` behebt das und bleibt innerhalb derselben Minor-Version.
Expand Down
2 changes: 1 addition & 1 deletion docs/ucp-sdk-integration-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ failures** before the fixes. The same set on both:
| Finding | Ours? | What happened |
| ------------------------------------------------------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `cart.get` with an unknown id answered HTTP 200 with a fabricated empty cart | **yes** | The cart id is a Shopware context token; `ShopwareCartGateway` resolved a context for any token and created a cart on demand. Fixed: `cart.create` registers its token in the `sales_channel_api_context` payload the checkout session already uses, and get/update/discount/cancel answer `not_found` for any other token. The same defect the SDK example app had (ucp-php-sdk#173) |
| MCP `tools/list` advertised only `shopware-tool-search`, `shopware-toolset-enable`, `shopware-toolsets-list` | **yes** | Two halves: the tools were named `shopware-ucp-*` where the pinned `mcp.openrpc.json` names them `create_cart`, `search_catalog`, `complete_checkout` and so on, and they sat in a toolset an agent had to enable first. Fixed: spec names, and `#[McpToolGroup('discovery')]` puts them on a fresh session's list. Guarded by a test that reads the pinned OpenRPC document |
| MCP `tools/list` advertised only `shopware-tool-search`, `shopware-toolset-enable`, `shopware-toolsets-list` | **yes** | Two halves: the tools were named `shopware-ucp-*` where the pinned `mcp.openrpc.json` names them `create_cart`, `search_catalog`, `complete_checkout` and so on, and they sat in a toolset an agent had to enable first. Fixed: spec names, and `#[McpToolGroup('discovery')]` puts them on a fresh session's list. Later replaced (#254): the tools form a `ucp` toolset that `/ucp/mcp` pins with `?toolsets=`, so they stay off plain `/store-api/_mcp` connections. Guarded by a test that reads the pinned OpenRPC document |
| "Empty catalog" on `catalog.search` | **no** | The agent declares an umbrella `dev.ucp.shopping.catalog` that no release defines (agent#5), so negotiation excludes both catalog operations and the store answers a `capabilities_incompatible` envelope with HTTP 200, which the agent reads as an empty product list. Reproduced by hand: with the two real ids in the agent profile, an empty query lists 20 products. While chasing it, a real latent defect surfaced and is fixed too: `ShopwareCatalogGateway` handed an empty term to Shopware's search route, which matches nothing, where the spec's optional free-text `query` asks for a listing |
| Profile not over HTTPS | no | loopback artefact |

Expand Down
5 changes: 5 additions & 0 deletions src/Compatibility/ShopwareVersionDetector.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ public function currentVersion(): string
return '0.0.0.0';
}

public function isAtLeast(string $version): bool
{
return version_compare($this->normalizeVersion($this->currentVersion()), $this->normalizeVersion($version), '>=');
}

public function supportsStoreApiMcp(): bool
{
if (!version_compare($this->normalizeVersion($this->currentVersion()), '6.7.0.0', '>=')) {
Expand Down
6 changes: 6 additions & 0 deletions src/SwagAgenticCommerce.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Swag\AgenticCommerce\DependencyInjection\AgenticCommerceCoexistenceCompilerPass;
use Swag\AgenticCommerce\DependencyInjection\TestAgentProfileFetcherCompilerPass;
use Swag\AgenticCommerce\Exception\SdkNotAvailableException;
use Swag\AgenticCommerce\Ucp\DependencyInjection\AdvertiseUcpToolsWithoutToolsetPinningPass;
use Swag\AgenticCommerce\Ucp\DependencyInjection\ReplaceSdkSigningKeyCommandsPass;
use Swag\AgenticCommerce\Ucp\DependencyInjection\ReplaceSdkUrlSafetyValidatorPass;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
Expand Down Expand Up @@ -82,6 +83,11 @@ public function build(ContainerBuilder $container): void
// configured remote profile hosts are actually fetchable.
$container->addCompilerPass(new ReplaceSdkUrlSafetyValidatorPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 1000);

// Keeps the UCP MCP tools on the first tools/list on Shopware releases without connect-time
// toolset pinning. Must run after core's McpToolDiscoveryCompilerPass (priority 0 on 6.7.14,
// 20 from 6.7.15), which resets the list it extends.
$container->addCompilerPass(new AdvertiseUcpToolsWithoutToolsetPinningPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -10);

// In the test environment, swap the SDK's HTTP agent-profile fetcher for a fixed,
// test-supplied one so the functional suite can negotiate the UCP handshake offline.
$container->addCompilerPass(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

namespace Swag\AgenticCommerce\Ucp\DependencyInjection;

use Swag\AgenticCommerce\Ucp\Mcp\UcpMcpToolset;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* Keeps the UCP tools on the first `tools/list` on Shopware releases that cannot pin a toolset at
* connect time.
*
* The UCP tools sit in their own toolset ({@see UcpMcpToolset}), and `/ucp/mcp` pins it with
* `?toolsets=ucp`. Releases before 6.7.15.0 have progressive disclosure on the Store API endpoint
* but no connect-time pinning, so there a UCP agent would see only the discovery meta-tools. On
* those releases this pass adds the UCP tools to the endpoint's default surface, which is what the
* plugin did before. From 6.7.15.0 on it does nothing.
*
* Must run after core's McpToolDiscoveryCompilerPass, which resets the parameter before writing it,
* so it is registered below that pass's priority on every release line.
*
* @internal
*/
class AdvertiseUcpToolsWithoutToolsetPinningPass implements CompilerPassInterface
{
public const ADVERTISED_TOOLS_PARAMETER = 'shopware.store_api_mcp.advertised_tools';

private const STORE_API_TOOL_TAG = 'shopware.store_api_mcp.tool';

private const TOOL_ATTRIBUTE = 'Mcp\\Capability\\Attribute\\McpTool';

private const GROUP_ATTRIBUTE = 'Shopware\\Core\\Framework\\Mcp\\Attribute\\McpToolGroup';

public function __construct(private readonly ?bool $coreSupportsConnectTimeToolsets = null)
{
}

public function process(ContainerBuilder $container): void
{
if (($this->coreSupportsConnectTimeToolsets ?? UcpMcpToolset::coreSupportsConnectTimeToolsets())
|| !$container->hasParameter(self::ADVERTISED_TOOLS_PARAMETER)) {
return;
}

$advertised = $container->getParameter(self::ADVERTISED_TOOLS_PARAMETER);
if (!\is_array($advertised)) {
return;
}

foreach (array_keys($container->findTaggedServiceIds(self::STORE_API_TOOL_TAG)) as $serviceId) {
$class = $container->getDefinition($serviceId)->getClass() ?? $serviceId;
$name = $this->ucpToolName($class);

if (null !== $name) {
$advertised[] = $name;
}
}

$container->setParameter(self::ADVERTISED_TOOLS_PARAMETER, array_values(array_unique($advertised)));
}

/**
* The tool name when the class is a tool in the UCP toolset. Attribute arguments are read
* without instantiating them, because the attribute classes only exist on newer releases.
*/
private function ucpToolName(string $class): ?string
{
if (!class_exists($class)) {
return null;
}

$reflection = new \ReflectionClass($class);
$group = null;
foreach ($reflection->getAttributes(self::GROUP_ATTRIBUTE) as $attribute) {
$group = $attribute->getArguments()[0] ?? $attribute->getArguments()['group'] ?? null;
}

if (UcpMcpToolset::NAME !== $group) {
return null;
}

foreach ($reflection->getAttributes(self::TOOL_ATTRIBUTE) as $attribute) {
$name = $attribute->getArguments()['name'] ?? $attribute->getArguments()[0] ?? null;

return \is_string($name) ? $name : null;
}

return null;
}
}
22 changes: 22 additions & 0 deletions src/Ucp/Mcp/Api/UcpMcpProxyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Swag\AgenticCommerce\Compatibility\ShopwareVersionDetector;
use Swag\AgenticCommerce\Ucp\Config\UcpConfigService;
use Swag\AgenticCommerce\Ucp\Http\SymfonyRequestContextFactory;
use Swag\AgenticCommerce\Ucp\Mcp\UcpMcpToolset;
use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolver;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
Expand Down Expand Up @@ -106,6 +107,8 @@ public function proxy(Request $request): Response

private function dispatchToStoreApiMcp(Request $request, string $accessKey, ?RequestContext $context = null): Response
{
$this->pinUcpToolset($request);

$subRequest = Request::create(
'/store-api/_mcp'.('' !== $request->getQueryString() ? '?'.$request->getQueryString() : ''),
$request->getMethod(),
Expand All @@ -130,6 +133,25 @@ private function dispatchToStoreApiMcp(Request $request, string $accessKey, ?Req
return $this->httpKernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST);
}

/**
* Advertises the UCP tools on the first `tools/list` of a `/ucp/mcp` connection, without putting
* them on every Store API connection. Core reads the pinned toolsets from the main request's
* query (it deliberately ignores sub-requests), so the pin goes on this request, merged with any
* toolsets the client asked for. It runs after the signature check, and only the query bag
* changes, so the signed URI stays as the client sent it.
*/
private function pinUcpToolset(Request $request): void
{
$requested = $request->query->all()[UcpMcpToolset::QUERY_PARAMETER] ?? '';
$toolsets = \is_string($requested) ? array_filter(array_map(trim(...), explode(',', $requested)), static fn (string $name): bool => '' !== $name) : [];

if (!\in_array(UcpMcpToolset::NAME, $toolsets, true)) {
$toolsets[] = UcpMcpToolset::NAME;
}

$request->query->set(UcpMcpToolset::QUERY_PARAMETER, implode(',', array_unique($toolsets)));
}

/**
* @return array{salesChannelId: string, accessKey: string}|null
*/
Expand Down
3 changes: 2 additions & 1 deletion src/Ucp/Mcp/Tool/UcpCartCancelTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
use Mcp\Capability\Attribute\McpTool;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Mcp\Attribute\McpToolGroup;
use Swag\AgenticCommerce\Ucp\Mcp\UcpMcpToolset;
use Ucp\Sdk\Model\RequestContext;
use Ucp\Sdk\Symfony\Operation\ShoppingOperationExecutor;
use Ucp\Sdk\Symfony\Operation\ShoppingOperationRequest;

#[McpTool(name: 'cancel_cart', title: 'UCP Cart Cancel', description: 'Cancel a cart through the shared UCP cart capability. Always use dryRun=true (the default) to validate the request without persisting it, then set dryRun=false to commit.')]
#[McpToolGroup('discovery')]
#[McpToolGroup(UcpMcpToolset::NAME)]
/** @internal */
#[Package('checkout')]
final class UcpCartCancelTool
Expand Down
3 changes: 2 additions & 1 deletion src/Ucp/Mcp/Tool/UcpCartCreateTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Mcp\Capability\Attribute\Schema;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Mcp\Attribute\McpToolGroup;
use Swag\AgenticCommerce\Ucp\Mcp\UcpMcpToolset;
use Ucp\Sdk\Model\RequestContext;
use Ucp\Sdk\Symfony\Operation\ShoppingOperationExecutor;
use Ucp\Sdk\Symfony\Operation\ShoppingOperationRequest;
Expand All @@ -18,7 +19,7 @@
* @internal
*/
#[McpTool(name: 'create_cart', title: 'UCP Cart Create', description: 'Create a cart through the shared UCP cart capability. The payload parameter is a JSON object matching the UCP cart.create request. Always use dryRun=true (the default) to validate the request without persisting it, then set dryRun=false to commit.')]
#[McpToolGroup('discovery')]
#[McpToolGroup(UcpMcpToolset::NAME)]
#[Package('checkout')]
final class UcpCartCreateTool
{
Expand Down
3 changes: 2 additions & 1 deletion src/Ucp/Mcp/Tool/UcpCartGetTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
use Mcp\Capability\Attribute\McpTool;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Mcp\Attribute\McpToolGroup;
use Swag\AgenticCommerce\Ucp\Mcp\UcpMcpToolset;
use Ucp\Sdk\Symfony\Operation\ShoppingOperationExecutor;
use Ucp\Sdk\Symfony\Operation\ShoppingOperationRequest;

#[McpTool(name: 'get_cart', title: 'UCP Cart Get', description: 'Load a cart by id through the shared UCP cart capability.')]
#[McpToolGroup('discovery')]
#[McpToolGroup(UcpMcpToolset::NAME)]
/** @internal */
#[Package('checkout')]
final class UcpCartGetTool
Expand Down
Loading
Loading