diff --git a/CHANGELOG.md b/CHANGELOG.md index 94ffd0f1..aad5f82d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CHANGELOG_de-DE.md b/CHANGELOG_de-DE.md index e4a27921..ba8fbd7c 100644 --- a/CHANGELOG_de-DE.md +++ b/CHANGELOG_de-DE.md @@ -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. diff --git a/docs/ucp-sdk-integration-backlog.md b/docs/ucp-sdk-integration-backlog.md index f40f62a0..dd2ecf7f 100644 --- a/docs/ucp-sdk-integration-backlog.md +++ b/docs/ucp-sdk-integration-backlog.md @@ -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 | diff --git a/src/Compatibility/ShopwareVersionDetector.php b/src/Compatibility/ShopwareVersionDetector.php index 50c20612..f402fe1f 100644 --- a/src/Compatibility/ShopwareVersionDetector.php +++ b/src/Compatibility/ShopwareVersionDetector.php @@ -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', '>=')) { diff --git a/src/SwagAgenticCommerce.php b/src/SwagAgenticCommerce.php index 2925af25..5bda477c 100644 --- a/src/SwagAgenticCommerce.php +++ b/src/SwagAgenticCommerce.php @@ -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; @@ -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( diff --git a/src/Ucp/DependencyInjection/AdvertiseUcpToolsWithoutToolsetPinningPass.php b/src/Ucp/DependencyInjection/AdvertiseUcpToolsWithoutToolsetPinningPass.php new file mode 100644 index 00000000..7168de94 --- /dev/null +++ b/src/Ucp/DependencyInjection/AdvertiseUcpToolsWithoutToolsetPinningPass.php @@ -0,0 +1,92 @@ +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; + } +} diff --git a/src/Ucp/Mcp/Api/UcpMcpProxyController.php b/src/Ucp/Mcp/Api/UcpMcpProxyController.php index 49f3cc36..8954c391 100644 --- a/src/Ucp/Mcp/Api/UcpMcpProxyController.php +++ b/src/Ucp/Mcp/Api/UcpMcpProxyController.php @@ -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; @@ -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(), @@ -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 */ diff --git a/src/Ucp/Mcp/Tool/UcpCartCancelTool.php b/src/Ucp/Mcp/Tool/UcpCartCancelTool.php index b4955409..76a107fc 100644 --- a/src/Ucp/Mcp/Tool/UcpCartCancelTool.php +++ b/src/Ucp/Mcp/Tool/UcpCartCancelTool.php @@ -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 diff --git a/src/Ucp/Mcp/Tool/UcpCartCreateTool.php b/src/Ucp/Mcp/Tool/UcpCartCreateTool.php index 70e0422c..dbb5a09a 100644 --- a/src/Ucp/Mcp/Tool/UcpCartCreateTool.php +++ b/src/Ucp/Mcp/Tool/UcpCartCreateTool.php @@ -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; @@ -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 { diff --git a/src/Ucp/Mcp/Tool/UcpCartGetTool.php b/src/Ucp/Mcp/Tool/UcpCartGetTool.php index 73fbcc2e..4a5303f1 100644 --- a/src/Ucp/Mcp/Tool/UcpCartGetTool.php +++ b/src/Ucp/Mcp/Tool/UcpCartGetTool.php @@ -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 diff --git a/src/Ucp/Mcp/Tool/UcpCartUpdateTool.php b/src/Ucp/Mcp/Tool/UcpCartUpdateTool.php index c8f5f76e..90885388 100644 --- a/src/Ucp/Mcp/Tool/UcpCartUpdateTool.php +++ b/src/Ucp/Mcp/Tool/UcpCartUpdateTool.php @@ -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; @@ -18,7 +19,7 @@ * @internal */ #[McpTool(name: 'update_cart', title: 'UCP Cart Update', description: 'Set or change the quantity of a line item, or add, remove, or replace the line items, in an existing cart through the shared UCP cart capability. Use this, NOT get_cart, for any change to the cart contents or line-item quantities. The payload parameter is a JSON object matching the UCP cart.update request: it carries the complete "line_items" array; the cart id travels as the id parameter and is not repeated in the payload. line_items replaces the cart contents rather than patching them, so always resend every line you want to keep. 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 UcpCartUpdateTool { diff --git a/src/Ucp/Mcp/Tool/UcpCatalogLookupTool.php b/src/Ucp/Mcp/Tool/UcpCatalogLookupTool.php index 426b91a5..89655a47 100644 --- a/src/Ucp/Mcp/Tool/UcpCatalogLookupTool.php +++ b/src/Ucp/Mcp/Tool/UcpCatalogLookupTool.php @@ -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: 'lookup_catalog', title: 'UCP Catalog Lookup', description: 'Load products by id from the current Store API sales-channel catalog through the shared UCP catalog capability. The ids parameter is a string, NOT an array: pass a JSON array string such as ["id-a","id-b"], or a single id, or a comma-separated list of ids.')] -#[McpToolGroup('discovery')] +#[McpToolGroup(UcpMcpToolset::NAME)] /** @internal */ #[Package('inventory')] final class UcpCatalogLookupTool diff --git a/src/Ucp/Mcp/Tool/UcpCatalogSearchTool.php b/src/Ucp/Mcp/Tool/UcpCatalogSearchTool.php index a8eeba50..846124e1 100644 --- a/src/Ucp/Mcp/Tool/UcpCatalogSearchTool.php +++ b/src/Ucp/Mcp/Tool/UcpCatalogSearchTool.php @@ -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: 'search_catalog', title: 'UCP Catalog Search', description: 'Search the current Store API sales-channel catalog through the same UCP catalog capability used by REST, A2A, and embedded flows.')] -#[McpToolGroup('discovery')] +#[McpToolGroup(UcpMcpToolset::NAME)] /** @internal */ #[Package('inventory')] final class UcpCatalogSearchTool diff --git a/src/Ucp/Mcp/Tool/UcpCheckoutCancelTool.php b/src/Ucp/Mcp/Tool/UcpCheckoutCancelTool.php index 956b5e86..91b68d59 100644 --- a/src/Ucp/Mcp/Tool/UcpCheckoutCancelTool.php +++ b/src/Ucp/Mcp/Tool/UcpCheckoutCancelTool.php @@ -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_checkout', title: 'UCP Checkout Cancel', description: 'Cancel a checkout session through the shared UCP checkout 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 UcpCheckoutCancelTool diff --git a/src/Ucp/Mcp/Tool/UcpCheckoutCompleteTool.php b/src/Ucp/Mcp/Tool/UcpCheckoutCompleteTool.php index befd4670..06df6856 100644 --- a/src/Ucp/Mcp/Tool/UcpCheckoutCompleteTool.php +++ b/src/Ucp/Mcp/Tool/UcpCheckoutCompleteTool.php @@ -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\Service\ProtocolValidatorInterface; use Ucp\Sdk\Symfony\Operation\ShoppingOperationExecutor; @@ -19,7 +20,7 @@ * @internal */ #[McpTool(name: 'complete_checkout', title: 'UCP Checkout Complete', description: 'Complete a checkout session through the shared UCP checkout capability. This places the order and takes payment. With dryRun=true (the default) nothing is placed: the current checkout is read back and reported together with anything that would block a commit. Set dryRun=false only once the buyer has confirmed the purchase. The payload parameter is a JSON object matching the UCP checkout.complete request; UCP requires a payment object here. Omit it to charge the sales channel default (invoice/offline) method, which needs nothing from the buyer.')] -#[McpToolGroup('discovery')] +#[McpToolGroup(UcpMcpToolset::NAME)] #[Package('checkout')] final class UcpCheckoutCompleteTool { diff --git a/src/Ucp/Mcp/Tool/UcpCheckoutCreateTool.php b/src/Ucp/Mcp/Tool/UcpCheckoutCreateTool.php index 643d10d5..c81566fa 100644 --- a/src/Ucp/Mcp/Tool/UcpCheckoutCreateTool.php +++ b/src/Ucp/Mcp/Tool/UcpCheckoutCreateTool.php @@ -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; @@ -18,7 +19,7 @@ * @internal */ #[McpTool(name: 'create_checkout', title: 'UCP Checkout Create', description: 'Create a checkout session through the shared UCP checkout capability. The payload parameter is a JSON object matching the UCP checkout.create request. "line_items" is always required, even when empty. To convert an existing cart into a checkout send "cart_id" together with "line_items": [] and the cart is reused as-is; send line_items to start from scratch instead. "discounts": {"codes": [...]}, "fulfillment" and "buyer_consent" are also accepted even though the published request schema omits them. 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 UcpCheckoutCreateTool { diff --git a/src/Ucp/Mcp/Tool/UcpCheckoutGetTool.php b/src/Ucp/Mcp/Tool/UcpCheckoutGetTool.php index 1de9c24c..9ff88b9a 100644 --- a/src/Ucp/Mcp/Tool/UcpCheckoutGetTool.php +++ b/src/Ucp/Mcp/Tool/UcpCheckoutGetTool.php @@ -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_checkout', title: 'UCP Checkout Get', description: 'Load a checkout session by id through the shared UCP checkout capability.')] -#[McpToolGroup('discovery')] +#[McpToolGroup(UcpMcpToolset::NAME)] /** @internal */ #[Package('checkout')] final class UcpCheckoutGetTool diff --git a/src/Ucp/Mcp/Tool/UcpCheckoutUpdateTool.php b/src/Ucp/Mcp/Tool/UcpCheckoutUpdateTool.php index ffccdc10..8ee96133 100644 --- a/src/Ucp/Mcp/Tool/UcpCheckoutUpdateTool.php +++ b/src/Ucp/Mcp/Tool/UcpCheckoutUpdateTool.php @@ -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; @@ -18,7 +19,7 @@ * @internal */ #[McpTool(name: 'update_checkout', title: 'UCP Checkout Update', description: 'Update a checkout session through the shared UCP checkout capability. The payload parameter is a JSON object matching the UCP checkout.update request. line_items is required and replaces the checkout contents rather than patching them, so resend every line you want to keep even when you only mean to change the buyer or the fulfillment address. 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 UcpCheckoutUpdateTool { diff --git a/src/Ucp/Mcp/Tool/UcpDiscountApplyTool.php b/src/Ucp/Mcp/Tool/UcpDiscountApplyTool.php index 96725f2f..563c3f1a 100644 --- a/src/Ucp/Mcp/Tool/UcpDiscountApplyTool.php +++ b/src/Ucp/Mcp/Tool/UcpDiscountApplyTool.php @@ -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: 'apply_discount', title: 'UCP Discount Apply', description: 'Apply a discount code to a cart through the shared UCP discount capability. Always use dryRun=true (the default) to check whether the code would be accepted without persisting it, then set dryRun=false to commit.')] -#[McpToolGroup('discovery')] +#[McpToolGroup(UcpMcpToolset::NAME)] /** @internal */ #[Package('checkout')] final class UcpDiscountApplyTool diff --git a/src/Ucp/Mcp/Tool/UcpOrderGetTool.php b/src/Ucp/Mcp/Tool/UcpOrderGetTool.php index 2e3fea95..8edec947 100644 --- a/src/Ucp/Mcp/Tool/UcpOrderGetTool.php +++ b/src/Ucp/Mcp/Tool/UcpOrderGetTool.php @@ -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_order', title: 'UCP Order Get', description: 'Load an order by id through the shared UCP order capability.')] -#[McpToolGroup('discovery')] +#[McpToolGroup(UcpMcpToolset::NAME)] /** @internal */ #[Package('after-sales')] final class UcpOrderGetTool diff --git a/src/Ucp/Mcp/UcpMcpToolset.php b/src/Ucp/Mcp/UcpMcpToolset.php new file mode 100644 index 00000000..4288d161 --- /dev/null +++ b/src/Ucp/Mcp/UcpMcpToolset.php @@ -0,0 +1,34 @@ +requireShopware(self::PROGRESSIVE_DISCLOSURE_VERSION); + $this->configureUcpRuntime(); + static::getContainer()->get(UcpConfigService::class)->saveConfig(['enabledTransports' => ['rest', 'mcp']], $this->ucpSalesChannelId); + + $tools = $this->listTools('/ucp/mcp', []); + + self::assertContains('search_catalog', $tools); + self::assertContains('create_cart', $tools); + self::assertContains('get_cart', $tools); + } + + #[Test] + public function testAPlainStoreApiSessionOnlyAdvertisesTheDiscoveryTools(): void + { + $this->requireShopware(self::CONNECT_TIME_TOOLSETS_VERSION); + $this->configureUcpRuntime(); + $accessKey = static::getContainer()->get(Connection::class)->fetchOne( + 'SELECT access_key FROM sales_channel WHERE id = UNHEX(:id)', + ['id' => $this->ucpSalesChannelId], + ); + self::assertIsString($accessKey); + + $tools = $this->listTools('/store-api/_mcp', ['HTTP_SW_ACCESS_KEY' => $accessKey]); + + self::assertNotContains('search_catalog', $tools, 'The UCP tools must not be on every Store API connection.'); + self::assertContains('shopware-toolsets-list', $tools); + } + + #[Test] + public function testTheProductionProbeFindsConnectTimeToolsets(): void + { + $this->requireShopware(self::CONNECT_TIME_TOOLSETS_VERSION); + + // Gated on the version, not on the probe: a probe broken by a core rename must fail here + // rather than silently re-enable the fallback pass on every Store API connection. + self::assertTrue(UcpMcpToolset::coreSupportsConnectTimeToolsets(), 'Core supports connect-time toolsets, but UcpMcpToolset no longer detects them.'); + } + + private function requireShopware(string $minimumVersion): void + { + $detector = static::getContainer()->get(ShopwareVersionDetector::class); + if (!$detector->supportsStoreApiMcp() || !$detector->isAtLeast($minimumVersion)) { + self::markTestSkipped(\sprintf('Needs the Store API MCP endpoint on Shopware %s or newer.', $minimumVersion)); + } + } + + /** + * Opens a fresh MCP session on $path and returns the names of the tools on its first page. + * + * @param array $server + * + * @return list + */ + private function listTools(string $path, array $server): array + { + $server += ['HTTP_ACCEPT' => self::ACCEPT]; + + $initialize = $this->ucpRequest('POST', $path, [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-06-18', + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'ucp-mcp-discovery-test', 'version' => '1.0'], + ], + ], $server); + self::assertSame(Response::HTTP_OK, $initialize->getStatusCode(), (string) $initialize->getContent()); + + $sessionId = $initialize->headers->get('mcp-session-id'); + self::assertIsString($sessionId, 'initialize must return an MCP session id.'); + $server['HTTP_MCP_SESSION_ID'] = $sessionId; + + $this->ucpRequest('POST', $path, ['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], $server); + + $list = $this->ucpRequest('POST', $path, ['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list', 'params' => new \stdClass()], $server); + self::assertSame(Response::HTTP_OK, $list->getStatusCode(), (string) $list->getContent()); + + $message = $this->lastJsonRpcMessage((string) $list->getContent()); + self::assertIsArray($message['result']['tools'] ?? null, (string) $list->getContent()); + + return array_values(array_map(static fn (array $tool): string => $tool['name'], $message['result']['tools'])); + } + + /** + * The answer arrives as a single JSON object, or as SSE `data:` frames when notifications ride along. + * + * @return array + */ + private function lastJsonRpcMessage(string $body): array + { + $decoded = json_decode($body, true); + if (\is_array($decoded)) { + return $decoded; + } + + $message = []; + foreach (explode("\n", $body) as $line) { + if (str_starts_with($line, 'data:')) { + $frame = json_decode(trim(substr($line, 5)), true); + if (\is_array($frame) && isset($frame['result'])) { + $message = $frame; + } + } + } + + return $message; + } +} diff --git a/tests/Unit/AdvertiseUcpToolsWithoutToolsetPinningPassTest.php b/tests/Unit/AdvertiseUcpToolsWithoutToolsetPinningPassTest.php new file mode 100644 index 00000000..eedfde49 --- /dev/null +++ b/tests/Unit/AdvertiseUcpToolsWithoutToolsetPinningPassTest.php @@ -0,0 +1,76 @@ +container(['shopware-tool-search']); + + (new AdvertiseUcpToolsWithoutToolsetPinningPass(coreSupportsConnectTimeToolsets: false))->process($container); + + self::assertSame( + ['shopware-tool-search', 'get_cart', 'search_catalog'], + $container->getParameter(AdvertiseUcpToolsWithoutToolsetPinningPass::ADVERTISED_TOOLS_PARAMETER), + ); + } + + public function testLeavesTheDefaultSurfaceAloneWhenCoreCanPinToolsets(): void + { + $container = $this->container(['shopware-tool-search']); + + (new AdvertiseUcpToolsWithoutToolsetPinningPass(coreSupportsConnectTimeToolsets: true))->process($container); + + self::assertSame(['shopware-tool-search'], $container->getParameter(AdvertiseUcpToolsWithoutToolsetPinningPass::ADVERTISED_TOOLS_PARAMETER)); + } + + public function testIgnoresToolsOutsideTheUcpToolsetAndUnknownClasses(): void + { + $container = new ContainerBuilder(); + $container->setParameter(AdvertiseUcpToolsWithoutToolsetPinningPass::ADVERTISED_TOOLS_PARAMETER, []); + $container->setDefinition('other.tool', (new Definition(\stdClass::class))->addTag('shopware.store_api_mcp.tool')); + $container->setDefinition('missing.tool', (new Definition('Missing\\ToolClass'))->addTag('shopware.store_api_mcp.tool')); + + (new AdvertiseUcpToolsWithoutToolsetPinningPass(coreSupportsConnectTimeToolsets: false))->process($container); + + self::assertSame([], $container->getParameter(AdvertiseUcpToolsWithoutToolsetPinningPass::ADVERTISED_TOOLS_PARAMETER)); + } + + public function testDoesNothingWithoutTheStoreApiMcpServer(): void + { + $container = new ContainerBuilder(); + $container->setDefinition(UcpCartGetTool::class, (new Definition(UcpCartGetTool::class))->addTag('shopware.store_api_mcp.tool')); + + (new AdvertiseUcpToolsWithoutToolsetPinningPass(coreSupportsConnectTimeToolsets: false))->process($container); + + self::assertFalse($container->hasParameter(AdvertiseUcpToolsWithoutToolsetPinningPass::ADVERTISED_TOOLS_PARAMETER)); + } + + /** + * @param list $advertised + */ + private function container(array $advertised): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->setParameter(AdvertiseUcpToolsWithoutToolsetPinningPass::ADVERTISED_TOOLS_PARAMETER, $advertised); + $container->setDefinition(UcpCartGetTool::class, (new Definition(UcpCartGetTool::class))->addTag('shopware.store_api_mcp.tool')); + $container->setDefinition(UcpCatalogSearchTool::class, (new Definition(UcpCatalogSearchTool::class))->addTag('shopware.store_api_mcp.tool')); + // Listed twice must not advertise twice. + $container->setDefinition('alias.cart.get', (new Definition(UcpCartGetTool::class))->addTag('shopware.store_api_mcp.tool')); + + return $container; + } +} diff --git a/tests/Unit/ShopwareVersionDetectorTest.php b/tests/Unit/ShopwareVersionDetectorTest.php index f73b2fc3..be17e120 100644 --- a/tests/Unit/ShopwareVersionDetectorTest.php +++ b/tests/Unit/ShopwareVersionDetectorTest.php @@ -60,4 +60,13 @@ public function testItNeedsTheSystemConfigXsdCompatPatchOnlyOnSixFive(): void // Unknown/unresolvable version must fall through to core's real schema. self::assertFalse((new ShopwareVersionDetector(versionOverride: '0.0.0.0'))->needsSystemConfigXsdCompatPatch()); } + + public function testItComparesTheRuntimeVersionIgnoringPatchSuffixes(): void + { + self::assertTrue((new ShopwareVersionDetector(versionOverride: '6.7.15.0'))->isAtLeast('6.7.15.0')); + self::assertTrue((new ShopwareVersionDetector(versionOverride: '6.7.9999999-dev'))->isAtLeast('6.7.15.0')); + self::assertFalse((new ShopwareVersionDetector(versionOverride: '6.7.14.2'))->isAtLeast('6.7.15.0')); + self::assertFalse((new ShopwareVersionDetector(versionOverride: '6.7.15.0-rc1'))->isAtLeast('6.7.15.1')); + self::assertFalse((new ShopwareVersionDetector(versionOverride: '0.0.0.0'))->isAtLeast('6.5.0.0')); + } } diff --git a/tests/Unit/UcpMcpProxyControllerTest.php b/tests/Unit/UcpMcpProxyControllerTest.php index f2f09759..0d2b9bc4 100644 --- a/tests/Unit/UcpMcpProxyControllerTest.php +++ b/tests/Unit/UcpMcpProxyControllerTest.php @@ -22,6 +22,7 @@ use Swag\AgenticCommerce\Ucp\Config\UcpConfigService; use Swag\AgenticCommerce\Ucp\Http\SymfonyRequestContextFactory; use Swag\AgenticCommerce\Ucp\Mcp\Api\UcpMcpProxyController; +use Swag\AgenticCommerce\Ucp\Mcp\UcpMcpToolset; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolver; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -337,6 +338,63 @@ public function testItProxiesWithoutForwardingBrowserCookies(): void self::assertSame($context, $request->attributes->get(SymfonyRequestContextFactory::REQUEST_CONTEXT_ATTRIBUTE)); } + #[Test] + public function testItPinsTheUcpToolsetOnTheMainRequest(): void + { + $requestContextFactory = $this->createStub(HttpRequestContextFactoryInterface::class); + $requestContextFactory->method('create')->willReturn(new RequestContext('shop.example')); + + $kernel = $this->createMock(HttpKernelInterface::class); + $kernel->expects(self::once()) + ->method('handle') + ->with(self::callback(static function (Request $request): bool { + // The sub-request keeps the client's query string; core reads the pin from the main request. + self::assertSame('toolsets=order', $request->getQueryString()); + + return true; + }), HttpKernelInterface::SUB_REQUEST) + ->willReturn(new Response('proxied')); + + $controller = $this->controller( + $this->knownDomains(), + $this->knownSalesChannels('store-api-access-key'), + new UcpConfig(active: true, enabledTransports: ['mcp']), + $kernel, + $requestContextFactory, + ); + + $request = Request::create('https://shop.example/ucp/mcp?toolsets=order', Request::METHOD_POST, content: '{"jsonrpc":"2.0","method":"tools/list","id":1}'); + + $controller->proxy($request); + + self::assertSame('order,'.UcpMcpToolset::NAME, $request->query->get(UcpMcpToolset::QUERY_PARAMETER)); + self::assertSame('https://shop.example/ucp/mcp?toolsets=order', $request->getUri(), 'The signed URI must stay as the client sent it.'); + } + + #[Test] + public function testItDoesNotPinTheUcpToolsetTwice(): void + { + $requestContextFactory = $this->createStub(HttpRequestContextFactoryInterface::class); + $requestContextFactory->method('create')->willReturn(new RequestContext('shop.example')); + + $kernel = $this->createStub(HttpKernelInterface::class); + $kernel->method('handle')->willReturn(new Response('proxied')); + + $controller = $this->controller( + $this->knownDomains(), + $this->knownSalesChannels('store-api-access-key'), + new UcpConfig(active: true, enabledTransports: ['mcp']), + $kernel, + $requestContextFactory, + ); + + $request = Request::create('https://shop.example/ucp/mcp?toolsets=ucp,%20,order', Request::METHOD_POST, content: '{}'); + + $controller->proxy($request); + + self::assertSame('ucp,order', $request->query->get(UcpMcpToolset::QUERY_PARAMETER)); + } + #[Test] public function testItDoesNotBypassGatingForOptionsRequests(): void { diff --git a/tests/Unit/UcpMcpToolNamesTest.php b/tests/Unit/UcpMcpToolNamesTest.php index 739be220..0b6bf7fc 100644 --- a/tests/Unit/UcpMcpToolNamesTest.php +++ b/tests/Unit/UcpMcpToolNamesTest.php @@ -8,11 +8,13 @@ use Mcp\Capability\Attribute\McpTool; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; +use Swag\AgenticCommerce\Ucp\Mcp\UcpMcpToolset; use Swag\AgenticCommerce\Ucp\UcpProtocol; /** * Every UCP MCP tool must carry the name the specification's OpenRPC document gives it, and - * must be advertised on a fresh MCP session. + * must belong to the UCP toolset that `/ucp/mcp` pins, so it is advertised on a fresh `/ucp/mcp` + * session (see UcpMcpProxyController and UcpMcpDiscoveryFlowTest). * * Both halves were wrong at once and invisible the same way. The tools were named * `shopware-ucp-*`, so a spec-following agent that looked for `create_cart` found nothing; @@ -58,7 +60,7 @@ public function testEveryToolIsNamedAsTheSpecificationNamesIt(): void } } - public function testEveryToolIsAdvertisedOnAFreshSession(): void + public function testEveryToolIsInTheUcpToolset(): void { foreach ($this->toolClasses() as $class) { $groups = []; @@ -70,7 +72,7 @@ public function testEveryToolIsAdvertisedOnAFreshSession(): void } } - self::assertSame(['discovery'], $groups, \sprintf('%s must sit in the always-advertised "discovery" group, or a spec-following agent never sees it.', $class)); + self::assertSame([UcpMcpToolset::NAME], $groups, \sprintf('%s must sit in the "%s" toolset, which /ucp/mcp pins at connect time (#254).', $class, UcpMcpToolset::NAME)); } }