fix(ucp): always emit absolute order.permalink_url so completed checkouts pass SDK response validation - #151
Conversation
…th-variants-correctly fix: mark parent listings with variants correctly
…outs pass SDK response validation
The UCP checkout/order response schema requires order.permalink_url to be a
non-null absolute URI (format: uri). ShopwareDataMapper passed the nullable
continue URL as the permalink, so on sales channels without a continueUrlTemplate
the field was omitted and the shop's OWN checkout response failed the SDK
response validator with an opaque '$ must match exactly one allowed schema'
error on GET and complete — blocking every UCP checkout that reaches an order.
Add OrderPermalinkBuilder ({baseUri}/ucp/v1/orders/{id}) and thread an explicit
order permalink through CheckoutCompleter and ShopwareCheckoutAdapter into
ShopwareDataMapper::toCompletedCheckout. Points at the UCP order endpoint so the
link stays machine-resolvable for headless/agent sales channels.
Stacked on #130 (shares the toCompletedCheckout signature). Rebase onto main
after #130 merges.
…el-allowlist fix: limit UCP configuration to transactional channels
This reverts commit f92e5d3.
fix: cross version inconsistencies
* fix: surface MCP tool errors and clarify catalog-lookup ids
Every UCP MCP tool rethrew its failure, and the MCP server turns any
exception into a generic "Error while executing tool" JSON-RPC error. The
message an agent needs to correct its call — including SDK validation
violations such as the missing UCP-Agent header — was dropped on the
floor, so a failing tool call was indistinguishable from any other.
Return failures in band instead, via UcpMcpToolContext::failure(), as
{"success":false,"error":{...}} mirroring the existing success envelope.
This is what the MCP spec recommends for tool-execution errors and it
does not depend on the mcp/sdk version ceiling, so ToolCallException is
no longer the seam to restore later. Only UcpException subclasses are
surfaced verbatim; anything else is reported generically so internals do
not leak to an unauthenticated MCP client.
catalog-lookup's ids is a string parameter carrying a list, which an
agent reliably gets wrong — the tool description never said so, unlike
the write tools' payload. Say it explicitly, and accept the shapes agents
actually send (JSON array string, object wrapping the list, bare id,
comma-separated list).
Both decoders also degraded silently: decodeObject returned [] for a
non-object payload and decodeStringList returned [] for anything that was
not a JSON array. They now raise a ValidationException naming the
offending parameter, which the in-band error makes visible.
* feat: add dryRun to the mutating UCP MCP tools
Closes #153 on the plugin side.
An eval harness classifies a tool mechanically: one whose inputSchema
declares dryRun is called with dryRun:true forced on, anything else that
mutates is never called and gets graded on tool name alone. No UCP tool
declared dryRun, so the whole Store suite was selection-only — it could
not tell a correctly named call with nonsense arguments from one that ran.
All eight mutating tools now take dryRun: bool = true, matching the core
admin write tools. Seven run the real operation inside a DBAL transaction
that is always rolled back, the same mechanism as Shopware's
McpToolResponse::executeWithDryRun. The response carries the flag either
way — "dryRun": true|false is always present on a mutating tool, so an
agent can never be in doubt about whether it committed.
checkout-complete cannot use that mechanism. CheckoutCompleter
synchronously POSTs an order.created webhook through the SDK's
DefaultOrderWebhookDispatcher, and no database rollback recalls an HTTP
request, so a rolled-back preview would tell the merchant about an order
that never existed — on the one tool that can take money. It previews
instead: it reads the checkout back through the same checkout.get path
shopware-ucp-checkout-get uses and reports the blockers its status
implies. Blocker derivation is split into UcpCheckoutCompletionPreview
because the tool depends on the final ShoppingOperationExecutor and
cannot be constructed with a mock.
A dry run runs deliberately before IdempotencyService::claim(). Claiming
on a preview would make the following real call replay the rolled-back
preview response instead of committing. Validation a commit would perform
still applies, so a preview fails on the same input a commit would.
docs/mcp-dry-run.md records what the rollback does not cover: Redis cart
storage (the parameter does not exist on 6.5, so reading it would break
the container there), anything a capability sends outbound, and flows —
core sets Context::SKIP_TRIGGER_FLOW, which the UCP capabilities expose no
seam for. UcpMcpToolDryRunContractTest pins the classification so a new
tool or a dropped parameter fails a test instead of silently downgrading
the suite.
Two acceptance criteria are not in this repository: shopware-store-api-context
is core's StoreApiContextTool, and the catalogue snapshot plus the
UNSAFE -> DRY_RUNNABLE move in toolclass.py belong to shopware-mcp-evals.
* fix: run the checkout-complete preview through executeMutating
The dry-run branch returned a preview before reaching executeMutating(), so
the idempotencyRequired check at the top of that method never ran for
checkout-complete. With idempotencyRequired and no Idempotency-Key header,
every other tool's dry run failed with `$.headers.idempotency-key is
required` while the one tool that can take money previewed happily —
contradicting both the comment on that check and docs/mcp-dry-run.md.
executeMutating() now takes an optional $preview callback and uses it in
place of previewMutation() for a tool whose effects a rollback cannot undo,
still before claim() so a preview does not consume the key. The tool loses
its own branch, so the check cannot be skipped by previewing differently.
The preview also stops resolving the request context a second time and uses
the one executeMutating() already checked.
…ma-valid (#155) * fix: make UCP checkout-complete callable over MCP The tool sent an empty payload to checkout.complete, but UCP marks payment as required for that operation: checkout.json annotates it ucp_request: {create: "optional", update: "optional", complete: "required"} and the generated checkout.complete.request.json derives `required: ["payment"]` from it. Every commit therefore failed schema validation with `$.payment is required`, and the tool declared only `id` and `dryRun`, so an agent had nowhere to put one. No UCP order could be placed over MCP at all. Add a payload parameter, matching the other mutating tools, and default payment to an empty instrument list when the agent does not mention it. Nothing reads the instrument yet -- CheckoutAdapterInterface::completeCheckout() takes only an id and a context, so completion charges the sales channel default (invoice/offline) method -- so requiring the agent to invent one would be a gate with no payoff. An explicit payment is passed through untouched, so it starts meaning something as soon as the SDK threads it into the adapter. The defaulting lives in its own class because the tool depends on the final ShoppingOperationExecutor and cannot be constructed with a mock, the same reason UcpCheckoutCompletionPreview was split out. * fix: report Shopware promotions as an items_discount total Applying a discount code adds a Shopware promotion line item with a negative unit price. The mapper forwarded every line item regardless of type, and the SDK's LineItem::toArray() emits a per-line `{"type": "subtotal"}` entry, which types/total.json constrains to `minimum: 0`. The response then failed its own schema inside ShoppingOperationExecutor::response(), so the agent saw a server error even though the request was valid and the discount had been applied. discount.apply is where an agent notices, but any response carrying a promotion was affected: cart.get, cart.update, checkout.get and checkout.update all run the same mapping. Report these lines the way the spec models them instead: exclude them from line_items and emit a negative `items_discount` total, which is one of the two types total.json permits a negative amount. Shopware's positionPrice is already net of the promotion, so the discount is added back out of the subtotal and reported separately -- subtotal + items_discount returns to positionPrice, and subtotal stays non-negative as the schema requires. The entry is omitted when nothing was discounted, since zero would violate `exclusiveMaximum: 0`. The predicate keys off a negative unit price rather than the promotion type, so credits and custom negative lines are handled too. The embedded page needs no change: its totals block already renders any type generically. UcpResponseSchemaTest runs the real GeneratedSchemaValidator against the real schemas, so the mapping and the spec cannot drift apart again unnoticed. * docs: spell out the UCP MCP payload shapes agents cannot guess Three request shapes are impossible to infer from the tool schemas, and each one fails in a way that reads like the agent's mistake: - cart.update requires the cart id inside the payload as well as on the tool argument. The SDK validates the raw payload instead of merging the resource id first as it does for cart.get and cart.cancel, so omitting it returns `$.id is required` even though the id was supplied. - checkout.create accepts cart_id for cart-to-checkout conversion, plus discounts.codes, fulfillment and buyer_consent. The SDK maps all four, but the generated request schema lists none of them. line_items stays required even when converting, so cart_id alone returns `$.line_items is required`. - line_items is a full replacement on both update tools. An agent told to add a shipping address that sends only fulfillment empties the cart. Record the remaining gaps in the parity plan too, so the entry that claims the MCP matrix is fully routed no longer reads as complete when payment is validated but discarded and discounts.applied[] is not emitted at all.
* fix: allow ucp-php-sdk releases after 0.0.2 `^0.0.2` cannot install 0.0.3. Composer's caret pins the last specified digit, so on a 0.0.x package it means `>=0.0.2 <0.0.3` — this plugin has been locked to a single SDK patch release, and every future one is excluded by construction rather than by choice. That already bites. ucp-php-sdk 0.0.3 carries the two fixes this plugin's own UCP flows depend on being available: - #108 types agent-profile fetch failures as UcpException, so a transport failure, non-200, oversized response or undecodable body answers 424 with a diagnosable message instead of an opaque 500. - #107 adds CheckoutCompleteRequest and the opt-in payment-aware checkout interfaces, and publishes the capability extension fields the SDK already reads on checkout.create/update. None of it is reachable while the constraint excludes the release. The SDK hit this same bug in its own bundle -> core requirement and fixed it in 0.0.3, changelog verbatim: "Composer's caret pins the patch on 0.0.x, so the old constraint excluded every future release of core, including this one". `ucp-php-sdk/symfony-bundle` 0.0.3 now requires core as `>=0.0.2 <0.1.0`; this is the same change one level up. Range rather than a floor bump: nothing here references a 0.0.3-only symbol — checked for CheckoutCompleteRequest, the PaymentAware interfaces and AgentProfileException — so 0.0.2 stays installable and this only stops excluding what comes after it. `<0.1.0` keeps the guard against 0.1.x, where a pre-1.0 minor is where breaking changes are actually expected. Found from shopware-mcp-evals, whose CI has to patch this constraint at install time to test the plugin against the current SDK at all. * docs: describe the SDK constraint as the range it now is The release guidance still said the bundle is pinned with a caret, is locked to that exact patch, cannot upgrade automatically, and that any install resolves the older published tag. Widening the constraint made all four false, so the documented procedure was built on dependency semantics the repository no longer has. Both the README section and its AGENTS.md summary now describe the range, and spell out the two consequences it has: a new 0.0.x release reaches merchants with no plugin change, which is what makes the moving-main CI signal load-bearing; and the range only permits a newer tag rather than guaranteeing one, so depending on a new symbol still means raising the lower bound and keeping the forced path-repo versions at or above it. * fix(ucp): accept the empty payload object every MCP tool defaults to `decodeObject('{}')` throws. Every UCP MCP tool declares `string $payload = '{}'`, so omitting the payload — which the tool descriptions explicitly invite, "Omit it to charge the sales channel default (invoice/offline) method, which needs nothing from the buyer" — fails validation with: $.payload must be a JSON object, array given The cause is a PHP detail rather than a logic slip. `json_decode('{}', true)` yields `[]`, and `array_is_list([])` is `true`, so the guard if (!is_array($decoded) || array_is_list($decoded)) cannot tell an empty JSON object from a JSON array. The default value therefore fails its own check, and after #155 gave checkout-complete a payload parameter, `shopware-ucp-checkout-complete` became uncallable without one. Fixed by discriminating on the raw string, which is the only thing that still distinguishes the two once json_decode has flattened them: a JSON object literal starts with `{`. Every existing rejection is preserved — `["a"]` and `[]` start with `[`, `"a"` is not an array — and `''` keeps returning `[]` through the earlier guard. All five tools carried the broken default: cart-create, cart-update, checkout-create, checkout-update and checkout-complete. The first four happen to be called with a payload in practice, which is why only checkout-complete surfaced it. Not touched, because their callers may reasonably want an empty object rejected: the same `!is_array || array_is_list` pattern appears in UcpConfig::decode (line 133) and JsonlRowParser (line 44). Worth a separate look rather than a blanket change. Found by shopware/shopware-mcp-evals: its UCP buyer journey calls checkout-complete without a payload, so the suite failed on this within a run of #155 merging. Verified against PHP 8.5 across '{}', ' {} ', a populated object, '[]', '["a"]', '"a"', '' and ' ' — the empty object and only the empty object changes verdict. Unit deps are not installed in this checkout, so the plugin's phpunit suite has not been run here; the added test pins the case.
The branch conflicted with main in tests/Unit/ShopwareDataMapperTest.php: both sides had inserted new tests after the same `#[Test]` anchor. Resolved by keeping both — this branch's two permalink tests and main's promotion/cart-message block. Nothing was dropped; the only edit is a closing brace and a re-opened attribute so the two sets sit next to each other. Merged rather than rebased, so the original history is untouched. 546 tests green afterwards.
|
Resolved the conflict with What conflicted: After the merge: One local wrinkle, not yours: the test bootstrap's teardown reports This is the third instance of one defect classWorth naming, because the pattern is more useful than the three fixes:
Every one surfaces as the same opaque The durable fix is a fixture, not a fourth patch. #159 added Could you add a completed-checkout case to Base branchYou're stacked on #130, and #130's Part B (reporting a placed-but-unpaid order as If Part B comes out, this will want rebasing onto |
|
Closing in favour of #165, which is this same fix — your commit, you as author — rebased onto current Nothing about the fix was in question. It was the diff: this PR reports 2,079 additions / 261 deletions across 49 files, because the branch carries #147, #148, #149, #156, #154, #155 and #157 plus a merge of Two editorial calls I had to make, both flagged as questions for you in #165's description:
All four of your permalink test additions are intact; two I had to re-seat because the conflict boundary had spliced them into the middle of a helper method. Also verified it, which this PR's own diff had made hard: with One question in there worth your eye: Thanks — the diagnosis in your description is what made the CI failure legible, and it also produced ucp-php-sdk#114, which makes the SDK name the branch and the field instead of only saying |
Problem
The UCP checkout/order response schema requires
order.permalink_urlto be a non-null absolute URI (format: uri).ShopwareDataMapperpassed the nullable continue URL as the permalink, so on sales channels without acontinueUrlTemplate,OrderConfirmation::toArray()array_filters the null away and the field is omitted. The SDK then validates the shop's own response (ShoppingOperationExecutor→DefaultProtocolValidator::validateResponse) against thecheckout.*.responseschema (a 2-branchoneOf), which fails — surfacing as the opaque$ must match exactly one allowed schemaon GET and complete, for every client, once a checkout carries an order.Reproduced with the SDK's own
GeneratedSchemaValidator: anorderwithoutpermalink_url→ INVALID; with it → VALID.Fix
OrderPermalinkBuilder→{baseUri}/ucp/v1/orders/{id}(mirrors the existing order-view permalink logic; points at the UCP order endpoint so it stays machine-resolvable on headless/agent sales channels with no human storefront order page).orderPermalinkUrlthroughCheckoutCompleterandShopwareCheckoutAdapterintoShopwareDataMapper::toCompletedCheckout;OrderConfirmationusesorderPermalinkUrl ?? continueUrl.Notes
toCompletedCheckoutsignature). Rebase ontomainonce fix: never report unpaid orders as completed; advertise delegated payment handlers #130 merges.completeCheckout→x402returned → USDC settled on Base mainnet → orderpaid.Draft pending review.