From 0a3e6ef7dd66420e9b76a3232116b1a0aa35f696 Mon Sep 17 00:00:00 2001 From: BrocksiNet Date: Fri, 25 Sep 2026 14:24:04 +0200 Subject: [PATCH 1/4] fix(mcp): pin the UCP toolset on /ucp/mcp instead of using core's discovery group The UCP tools sat in core's reserved discovery group, so every plain /store-api/_mcp connection advertised them next to core's guidance that nothing is advertised before a toolset is enabled. They now form a ucp toolset that the /ucp/mcp proxy pins with ?toolsets= on the main request, after signature verification. On Shopware releases without connect-time toolset selection, a compiler pass keeps them on the default surface as before. --- CHANGELOG.md | 1 + CHANGELOG_de-DE.md | 1 + docs/ucp-sdk-integration-backlog.md | 2 +- src/SwagAgenticCommerce.php | 5 + ...rtiseUcpToolsWithoutToolsetPinningPass.php | 91 +++++++++++++ src/Ucp/Mcp/Api/UcpMcpProxyController.php | 22 +++ src/Ucp/Mcp/Tool/UcpCartCancelTool.php | 3 +- src/Ucp/Mcp/Tool/UcpCartCreateTool.php | 3 +- src/Ucp/Mcp/Tool/UcpCartGetTool.php | 3 +- src/Ucp/Mcp/Tool/UcpCartUpdateTool.php | 3 +- src/Ucp/Mcp/Tool/UcpCatalogLookupTool.php | 3 +- src/Ucp/Mcp/Tool/UcpCatalogSearchTool.php | 3 +- src/Ucp/Mcp/Tool/UcpCheckoutCancelTool.php | 3 +- src/Ucp/Mcp/Tool/UcpCheckoutCompleteTool.php | 3 +- src/Ucp/Mcp/Tool/UcpCheckoutCreateTool.php | 3 +- src/Ucp/Mcp/Tool/UcpCheckoutGetTool.php | 3 +- src/Ucp/Mcp/Tool/UcpCheckoutUpdateTool.php | 3 +- src/Ucp/Mcp/Tool/UcpDiscountApplyTool.php | 3 +- src/Ucp/Mcp/Tool/UcpOrderGetTool.php | 3 +- src/Ucp/Mcp/UcpMcpToolset.php | 34 +++++ .../Ucp/UcpMcpDiscoveryFlowTest.php | 126 ++++++++++++++++++ ...eUcpToolsWithoutToolsetPinningPassTest.php | 76 +++++++++++ tests/Unit/UcpMcpProxyControllerTest.php | 58 ++++++++ tests/Unit/UcpMcpToolNamesTest.php | 10 +- 24 files changed, 448 insertions(+), 17 deletions(-) create mode 100644 src/Ucp/DependencyInjection/AdvertiseUcpToolsWithoutToolsetPinningPass.php create mode 100644 src/Ucp/Mcp/UcpMcpToolset.php create mode 100644 tests/Functional/Ucp/UcpMcpDiscoveryFlowTest.php create mode 100644 tests/Unit/AdvertiseUcpToolsWithoutToolsetPinningPassTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index aa9f5341..5ff8f351 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. - 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. - Let Shopware install the UCP SDK instead of shipping it inside the archive. 1.3.0 put the SDK in the plugin's own `vendor/` and loaded the autoloader Composer had generated for it, because Shopware does not load a plugin's `vendor/autoload.php` by itself. Every shop that installed it then carried two separate package registries: FroshTools reported `2 autoloaders registered`, and a question as ordinary as which version of a package is installed could be answered from the plugin's copy instead of the shop's. The extension now carries no dependencies at all. It has Shopware run `composer require` on install and update instead, which is what that mechanism is there for on a zip-installed extension: the extension itself resolves from the `custom/plugins/*` path repository every Shopware project declares, and the SDK version it names comes from Packagist into the shop's own `vendor/`. Nothing changes for a shop that installs through Composer. What is new is that installing has to reach Packagist -- without it the install stops with a Composer error, rather than leaving an extension that cannot run. diff --git a/CHANGELOG_de-DE.md b/CHANGELOG_de-DE.md index df2ed938..721ef9e2 100644 --- a/CHANGELOG_de-DE.md +++ b/CHANGELOG_de-DE.md @@ -1,5 +1,6 @@ # next version +- Die UCP-Tools werden jetzt nur UCP-Agenten angezeigt statt jeder Store-API-MCP-Verbindung. 1.3.0 hat sie in die Gruppe gelegt, die Shopware für seine eigenen Discovery-Tools reserviert. Dadurch sah jeder Client, der sich mit `/store-api/_mcp` verbindet, die dreizehn UCP-Tools neben Shopwares Hinweis, dass erst nach dem Aktivieren eines Toolsets Tools aufgelistet werden -- und Modelle folgten diesem Hinweis und aktivierten Toolsets für Tools, die sie bereits hatten. Die Tools bilden jetzt ein eigenes Toolset `ucp`, das `/ucp/mcp` beim Verbindungsaufbau auswählt. Ein UCP-Agent findet sie also weiterhin in seiner ersten Tool-Liste, während eine einfache `/store-api/_mcp`-Verbindung nur Shopwares Discovery-Tools auflistet. Auf Shopware-Versionen vor `6.7.15.0`, die beim Verbindungsaufbau kein Toolset auswählen können, bleiben die Tools wie bisher in jeder Verbindung aufgelistet. - 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. - Das UCP SDK wird nicht mehr mitgeliefert, sondern von Shopware installiert. 1.3.0 legte es in das plugin-eigene `vendor/` und lud den Autoloader, den Composer dafür erzeugt hatte -- denn das `vendor/autoload.php` einer Erweiterung lädt Shopware nicht von sich aus. Damit führte jeder Shop zwei getrennte Paketverzeichnisse: FroshTools meldete `2 autoloaders registered`, und die Frage, welche Version eines Pakets installiert ist, konnte aus der Kopie der Erweiterung beantwortet werden statt aus der des Shops. Die Erweiterung bringt jetzt keine Abhängigkeiten mehr mit, sondern lässt Shopware bei Installation und Update `composer require` ausführen -- dafür ist dieser Weg bei Erweiterungen aus einer ZIP-Datei gedacht. Die Erweiterung selbst wird dabei über das Pfad-Repository `custom/plugins/*` aufgelöst, das jedes Shopware-Projekt mitbringt, das festgelegte SDK kommt von Packagist in das `vendor/` des Shops. Für Shops, die über Composer installieren, ändert sich nichts. Neu ist, dass die Installation Packagist erreichen muss: Ohne Internetzugang bricht sie mit einem Composer-Fehler ab, statt eine unvollständige Erweiterung zu hinterlassen. diff --git a/docs/ucp-sdk-integration-backlog.md b/docs/ucp-sdk-integration-backlog.md index f40f62a0..2d4856cd 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/SwagAgenticCommerce.php b/src/SwagAgenticCommerce.php index 2925af25..ac1f9ffc 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,10 @@ 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. Runs after core's MCP discovery pass (priority 20). + $container->addCompilerPass(new AdvertiseUcpToolsWithoutToolsetPinningPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); + // 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..4e124613 --- /dev/null +++ b/src/Ucp/DependencyInjection/AdvertiseUcpToolsWithoutToolsetPinningPass.php @@ -0,0 +1,91 @@ +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 @@ +get(ShopwareVersionDetector::class)->supportsStoreApiMcp()) { + self::markTestSkipped('Needs the Store API MCP endpoint with connect-time toolset selection (Shopware 6.7.15.0+).'); + } + } + + #[Test] + public function testUcpToolsAreListedOnAFreshUcpMcpSession(): void + { + $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->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); + } + + /** + * 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/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..b7d076ca 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,9 @@ 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)); + // Not core's reserved "discovery" group: that would put the tool on every Store API + // connection. /ucp/mcp pins this toolset instead (shopware/agentic-commerce#254). + self::assertSame([UcpMcpToolset::NAME], $groups, \sprintf('%s must sit in the "%s" toolset, which /ucp/mcp pins at connect time.', $class, UcpMcpToolset::NAME)); } } From 7ee27e8828b7dc8b3f17c88182a1de68ee2e4289 Mon Sep 17 00:00:00 2001 From: BrocksiNet Date: Fri, 25 Sep 2026 14:35:22 +0200 Subject: [PATCH 2/4] docs(changelog): rewrite the German entry for the UCP toolset in natural German --- CHANGELOG_de-DE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG_de-DE.md b/CHANGELOG_de-DE.md index 721ef9e2..7e974525 100644 --- a/CHANGELOG_de-DE.md +++ b/CHANGELOG_de-DE.md @@ -1,6 +1,6 @@ # next version -- Die UCP-Tools werden jetzt nur UCP-Agenten angezeigt statt jeder Store-API-MCP-Verbindung. 1.3.0 hat sie in die Gruppe gelegt, die Shopware für seine eigenen Discovery-Tools reserviert. Dadurch sah jeder Client, der sich mit `/store-api/_mcp` verbindet, die dreizehn UCP-Tools neben Shopwares Hinweis, dass erst nach dem Aktivieren eines Toolsets Tools aufgelistet werden -- und Modelle folgten diesem Hinweis und aktivierten Toolsets für Tools, die sie bereits hatten. Die Tools bilden jetzt ein eigenes Toolset `ucp`, das `/ucp/mcp` beim Verbindungsaufbau auswählt. Ein UCP-Agent findet sie also weiterhin in seiner ersten Tool-Liste, während eine einfache `/store-api/_mcp`-Verbindung nur Shopwares Discovery-Tools auflistet. Auf Shopware-Versionen vor `6.7.15.0`, die beim Verbindungsaufbau kein Toolset auswählen können, bleiben die Tools wie bisher in jeder Verbindung aufgelistet. +- 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. - 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. - Das UCP SDK wird nicht mehr mitgeliefert, sondern von Shopware installiert. 1.3.0 legte es in das plugin-eigene `vendor/` und lud den Autoloader, den Composer dafür erzeugt hatte -- denn das `vendor/autoload.php` einer Erweiterung lädt Shopware nicht von sich aus. Damit führte jeder Shop zwei getrennte Paketverzeichnisse: FroshTools meldete `2 autoloaders registered`, und die Frage, welche Version eines Pakets installiert ist, konnte aus der Kopie der Erweiterung beantwortet werden statt aus der des Shops. Die Erweiterung bringt jetzt keine Abhängigkeiten mehr mit, sondern lässt Shopware bei Installation und Update `composer require` ausführen -- dafür ist dieser Weg bei Erweiterungen aus einer ZIP-Datei gedacht. Die Erweiterung selbst wird dabei über das Pfad-Repository `custom/plugins/*` aufgelöst, das jedes Shopware-Projekt mitbringt, das festgelegte SDK kommt von Packagist in das `vendor/` des Shops. Für Shops, die über Composer installieren, ändert sich nichts. Neu ist, dass die Installation Packagist erreichen muss: Ohne Internetzugang bricht sie mit einem Composer-Fehler ab, statt eine unvollständige Erweiterung zu hinterlassen. From deefb79255510e6ca6cb6e2b1877b8512f0b5086 Mon Sep 17 00:00:00 2001 From: BrocksiNet Date: Mon, 28 Sep 2026 09:07:41 +0200 Subject: [PATCH 3/4] docs: re-align the backlog table after the #254 note --- docs/ucp-sdk-integration-backlog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ucp-sdk-integration-backlog.md b/docs/ucp-sdk-integration-backlog.md index 2d4856cd..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. 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 | +| 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 | From c597e23b05128a1aae71130f17ec3badbae56e68 Mon Sep 17 00:00:00 2001 From: BrocksiNet Date: Tue, 29 Sep 2026 16:04:54 +0200 Subject: [PATCH 4/4] fix(mcp): order the fallback pass after core's and gate the discovery test on the version Core registers McpToolDiscoveryCompilerPass at priority 0 on 6.7.14.x and at 20 only from 6.7.15.x. The fallback pass sat at 0 too, so on the one line where it acts, running after core depended on bundle insertion order. Core's pass resets the advertised list before rebuilding it, so running first would silently drop the UCP tools. Register it at -10 instead; the list reaches the handler through a parameter placeholder, which is resolved later in the optimisation phase. UcpMcpDiscoveryFlowTest skipped on the same class probe production uses, so a core rename of the @internal/@experimental resolver would have turned the fix off and the test off with it. The tests now gate on the Shopware version: the /ucp/mcp listing from 6.7.14.0, where the fallback pass carries it, the plain Store API check from 6.7.15.0, plus an assertion that the probe still detects connect-time toolsets there. Drop the test comment that restated the assertion message. --- src/Compatibility/ShopwareVersionDetector.php | 5 +++ src/SwagAgenticCommerce.php | 5 +-- ...rtiseUcpToolsWithoutToolsetPinningPass.php | 3 +- .../Ucp/UcpMcpDiscoveryFlowTest.php | 31 +++++++++++++++---- tests/Unit/ShopwareVersionDetectorTest.php | 9 ++++++ tests/Unit/UcpMcpToolNamesTest.php | 4 +-- 6 files changed, 45 insertions(+), 12 deletions(-) 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 ac1f9ffc..5bda477c 100644 --- a/src/SwagAgenticCommerce.php +++ b/src/SwagAgenticCommerce.php @@ -84,8 +84,9 @@ public function build(ContainerBuilder $container): void $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. Runs after core's MCP discovery pass (priority 20). - $container->addCompilerPass(new AdvertiseUcpToolsWithoutToolsetPinningPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); + // 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. diff --git a/src/Ucp/DependencyInjection/AdvertiseUcpToolsWithoutToolsetPinningPass.php b/src/Ucp/DependencyInjection/AdvertiseUcpToolsWithoutToolsetPinningPass.php index 4e124613..7168de94 100644 --- a/src/Ucp/DependencyInjection/AdvertiseUcpToolsWithoutToolsetPinningPass.php +++ b/src/Ucp/DependencyInjection/AdvertiseUcpToolsWithoutToolsetPinningPass.php @@ -18,7 +18,8 @@ * 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. * - * Runs after core's McpToolDiscoveryCompilerPass (priority 20), which writes the parameter. + * 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 */ diff --git a/tests/Functional/Ucp/UcpMcpDiscoveryFlowTest.php b/tests/Functional/Ucp/UcpMcpDiscoveryFlowTest.php index 113079d4..18e45fa1 100644 --- a/tests/Functional/Ucp/UcpMcpDiscoveryFlowTest.php +++ b/tests/Functional/Ucp/UcpMcpDiscoveryFlowTest.php @@ -25,16 +25,16 @@ final class UcpMcpDiscoveryFlowTest extends TestCase private const ACCEPT = 'application/json, text/event-stream'; - protected function setUp(): void - { - if (!UcpMcpToolset::coreSupportsConnectTimeToolsets() || !static::getContainer()->get(ShopwareVersionDetector::class)->supportsStoreApiMcp()) { - self::markTestSkipped('Needs the Store API MCP endpoint with connect-time toolset selection (Shopware 6.7.15.0+).'); - } - } + /** Progressive disclosure on the Store API endpoint; below this the UCP tools were never deferred. */ + private const PROGRESSIVE_DISCLOSURE_VERSION = '6.7.14.0'; + + /** Connect-time toolset pinning; below this the UCP tools reach /ucp/mcp through the fallback pass. */ + private const CONNECT_TIME_TOOLSETS_VERSION = '6.7.15.0'; #[Test] public function testUcpToolsAreListedOnAFreshUcpMcpSession(): void { + $this->requireShopware(self::PROGRESSIVE_DISCLOSURE_VERSION); $this->configureUcpRuntime(); static::getContainer()->get(UcpConfigService::class)->saveConfig(['enabledTransports' => ['rest', 'mcp']], $this->ucpSalesChannelId); @@ -48,6 +48,7 @@ public function testUcpToolsAreListedOnAFreshUcpMcpSession(): void #[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)', @@ -61,6 +62,24 @@ public function testAPlainStoreApiSessionOnlyAdvertisesTheDiscoveryTools(): void 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. * 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/UcpMcpToolNamesTest.php b/tests/Unit/UcpMcpToolNamesTest.php index b7d076ca..0b6bf7fc 100644 --- a/tests/Unit/UcpMcpToolNamesTest.php +++ b/tests/Unit/UcpMcpToolNamesTest.php @@ -72,9 +72,7 @@ public function testEveryToolIsInTheUcpToolset(): void } } - // Not core's reserved "discovery" group: that would put the tool on every Store API - // connection. /ucp/mcp pins this toolset instead (shopware/agentic-commerce#254). - self::assertSame([UcpMcpToolset::NAME], $groups, \sprintf('%s must sit in the "%s" toolset, which /ucp/mcp pins at connect time.', $class, UcpMcpToolset::NAME)); + self::assertSame([UcpMcpToolset::NAME], $groups, \sprintf('%s must sit in the "%s" toolset, which /ucp/mcp pins at connect time (#254).', $class, UcpMcpToolset::NAME)); } }