Skip to content

fix(ucp): always emit absolute order.permalink_url so completed checkouts pass SDK response validation - #151

Closed
Robin Schulte (relativvv) wants to merge 18 commits into
fix/ucp-payment-state-and-handler-discoveryfrom
fix/ucp-order-permalink
Closed

fix(ucp): always emit absolute order.permalink_url so completed checkouts pass SDK response validation#151
Robin Schulte (relativvv) wants to merge 18 commits into
fix/ucp-payment-state-and-handler-discoveryfrom
fix/ucp-order-permalink

Conversation

@relativvv

Copy link
Copy Markdown
Contributor

Problem

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, OrderConfirmation::toArray() array_filters the null away and the field is omitted. The SDK then validates the shop's own response (ShoppingOperationExecutorDefaultProtocolValidator::validateResponse) against the checkout.*.response schema (a 2-branch oneOf), which fails — surfacing as the opaque $ must match exactly one allowed schema on GET and complete, for every client, once a checkout carries an order.

Reproduced with the SDK's own GeneratedSchemaValidator: an order without permalink_url → INVALID; with it → VALID.

Fix

  • New 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).
  • Thread an explicit orderPermalinkUrl through CheckoutCompleter and ShopwareCheckoutAdapter into ShopwareDataMapper::toCompletedCheckout; OrderConfirmation uses orderPermalinkUrl ?? continueUrl.
  • Unit tests for the builder + mapper (permalink present / continue-url fallback).

Notes

Draft pending review.

…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
* 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.
@BrocksiNet

Copy link
Copy Markdown
Contributor

Resolved the conflict with main and pushed a merge commit — history untouched, nothing dropped.

What conflicted: tests/Unit/ShopwareDataMapperTest.php. Both sides had inserted new tests after the same #[Test] anchor, so git couldn't tell whether to interleave or stack them. Kept both — your two permalink tests and main's promotion/cart-message block. The only edit is a closing brace and a re-opened attribute so the two sets sit side by side.

After the merge: composer test 546 green (9 skipped), composer cs clean.

One local wrinkle, not yours: the test bootstrap's teardown reports Removal failed, shopware/agentic-commerce is still present on a lane whose root composer.json requires the plugin. The suite runs and passes; that line is the uninstall step, not a test failure.

This is the third instance of one defect class

Worth naming, because the pattern is more useful than the three fixes:

# mapped response why the schema rejected it
#155 promotion line with a negative unit price emitted as a per-line subtotal, which types/total.json constrains to minimum: 0
#159 cart message type: cart_error, and types/message.json pins type to a const of error/warning/info
this completed checkout's order permalink_url omitted when continueUrlTemplate is unset, and it is a required absolute URI

Every one surfaces as the same opaque $ must match exactly one allowed schema from the shop validating its own response, and every one was invisible until an agent hit the exact path.

The durable fix is a fixture, not a fourth patch. #159 added UcpResponseSchemaTest, which runs the real GeneratedSchemaValidator against real mapped responses across all seven cart and checkout operations — but its fixture is a cart. Your defect lives on the completed-checkout path, where an order exists, and that fixture never builds one. So #159's test would not have caught this, and the next mapping drift on that path will also go unnoticed.

Could you add a completed-checkout case to UcpResponseSchemaTest here — toCompletedCheckout() through assertValidates('checkout.complete.response', …) — rather than only the two unit assertions on toArray()? Your unit tests prove the permalink is populated; a schema case proves the whole response is conformant, which is the thing that actually broke. That closes the class instead of the instance. Happy to add it myself if you'd rather.

Base branch

You're stacked on #130, and #130's Part B (reporting a placed-but-unpaid order as complete_in_progress) looks superseded: the spec says completed means "Order placed successfully" and complete_in_progress means "Business is processing the Complete Checkout request", and Lukas Rump (@lukasrump)'s #152 already implements the spec-shaped alternative (requires_escalation + continue_url, order not placed). Details on #130.

If Part B comes out, this will want rebasing onto main — it only needs Part A's toCompletedCheckout signature, and I don't think it needs even that. Flagging so the base doesn't quietly carry a contested change into main with your fix.

@BrocksiNet

Copy link
Copy Markdown
Contributor

Closing in favour of #165, which is this same fix — your commit, you as author — rebased onto current main.

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 main, all of which are already on main. Rebased, the same change is 131 / 7 across 9 files.

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 continueUrlTemplate cleared — the condition CI runs under, and the only reason this never reproduced on a dev lane — the fix takes the MCP evals store suite from 30 passed / 3 failed / 2 skipped to 34 / 1, and order.get passes for the first time. Numbers and the measured URLs are in #165.

One question in there worth your eye: order.get's permalink is {baseUri}/account/order/{orderId}, and Shopware's guest order page resolves a deep-link code rather than an order id — so that link may not open for a guest. Untested either way; the completion permalink points at the UCP endpoint and is unaffected.

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 $ must match exactly one allowed schema.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants