From bc55212f8fa56fd8ef79637c7f500dece6dc4546 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Thu, 23 Jul 2026 11:37:09 +0000 Subject: [PATCH 1/5] fix: trim unsupported transports from Python server discovery profile Only REST transport is currently supported by the Python server. Removing MCP, A2A, and Embedded transports from the discovery profile to prevent clients from attempting to use them. Closes #133 TAG=agy CONV=4aee53ae-545a-4ca3-8b91-fee3b95e9ef7 --- .../server/routes/discovery_profile.json | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/rest/python/server/routes/discovery_profile.json b/rest/python/server/routes/discovery_profile.json index 2bceb459..b359718d 100644 --- a/rest/python/server/routes/discovery_profile.json +++ b/rest/python/server/routes/discovery_profile.json @@ -9,25 +9,6 @@ "transport": "rest", "endpoint": "{{ENDPOINT}}", "schema": "https://ucp.dev/2026-04-08/services/shopping/openapi.json" - }, - { - "version": "2026-04-08", - "spec": "https://ucp.dev/2026-04-08/specification/overview", - "transport": "mcp", - "endpoint": "{{ENDPOINT}}/mcp", - "schema": "https://ucp.dev/2026-04-08/services/shopping/openrpc.json" - }, - { - "version": "2026-04-08", - "spec": "https://ucp.dev/2026-04-08/specification/overview", - "transport": "a2a", - "endpoint": "{{ENDPOINT}}/.well-known/agent-card.json" - }, - { - "version": "2026-04-08", - "spec": "https://ucp.dev/2026-04-08/specification/overview", - "transport": "embedded", - "schema": "https://ucp.dev/2026-04-08/services/shopping/embedded.json" } ] }, From b931a975765b95f0dfea9a62269c0f878c79ba7d Mon Sep 17 00:00:00 2001 From: damaz91 Date: Fri, 24 Jul 2026 07:32:55 +0000 Subject: [PATCH 2/5] fix(rest/nodejs): return 422 for unsupported UCP version Align Node.js version negotiation error response with the spec and Python implementation by returning HTTP 422 and a structured UcpErrorDetail. TAG=agy CONV=430e7e91-3e21-4a9d-802a-80c71390c95c --- rest/nodejs/src/index.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/rest/nodejs/src/index.ts b/rest/nodejs/src/index.ts index 321ceaff..8b9c8bcf 100644 --- a/rest/nodejs/src/index.ts +++ b/rest/nodejs/src/index.ts @@ -63,8 +63,21 @@ app.use(async (c: Context, next: () => Promise) => { // Ideally we'd parse and check compatibility. if (clientVersion > serverVersion) { return c.json( - { error: `Unsupported UCP version: ${clientVersion}` }, - 400 + { + ucp: { + version: serverVersion, + status: "error", + }, + messages: [ + { + type: "error", + code: "VERSION_UNSUPPORTED", + content: `Version ${clientVersion} is not supported. This merchant implements version ${serverVersion}.`, + severity: "unrecoverable", + }, + ], + }, + 422 ); } } From 579b8aa6b92858e6a4518273b1f1fece3fbe0465 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Fri, 24 Jul 2026 14:19:51 +0000 Subject: [PATCH 3/5] fix(rest/python): align error response format with UCP spec Align reference Python server error responses with error_response.json and message_error.json schemas. - Use 'messages' wrapper instead of 'errors'. - Use standard 'severity' enums (unrecoverable, requires_buyer_input). - Raise UcpError/UcpVersionError in dependencies and format in central handler. - Update integration tests to match new structure. TAG=agy CONV=66cef4ea-c19e-4693-8c23-b155a5e0cddc --- rest/python/server/dependencies.py | 35 +++------- rest/python/server/exceptions.py | 93 ++++++++++++++++++-------- rest/python/server/integration_test.py | 46 ++++++++----- rest/python/server/server.py | 15 ++++- 4 files changed, 115 insertions(+), 74 deletions(-) diff --git a/rest/python/server/dependencies.py b/rest/python/server/dependencies.py index 09ffce6c..54d6ff9b 100644 --- a/rest/python/server/dependencies.py +++ b/rest/python/server/dependencies.py @@ -28,8 +28,7 @@ import config import db -from exceptions import UcpErrorDetail -from exceptions import UcpErrorDetailItem +from exceptions import UcpError from exceptions import UcpVersionError from fastapi import Depends from fastapi import Header @@ -73,10 +72,7 @@ async def validate_ucp_headers(ucp_agent: str): try: server_date = parse_ucp_version(server_version) except UcpVersionError as exc: - raise HTTPException( - status_code=500, - detail=exc.to_detail().model_dump(), - ) from exc + raise UcpError("Server version configuration is invalid") from exc # Default to server version if UCP-Agent omits version=. agent_version = server_version @@ -92,29 +88,16 @@ async def validate_ucp_headers(ucp_agent: str): if match: # Group 1 is quoted value, Group 2 is unquoted value agent_version = (match.group(1) or match.group(2)).strip() - try: - agent_date = parse_ucp_version(agent_version) - except UcpVersionError as exc: - raise HTTPException( - status_code=400, - detail=exc.to_detail().model_dump(), - ) from exc + agent_date = parse_ucp_version(agent_version) if agent_date > server_date: - raise HTTPException( + raise UcpVersionError( + message=( + f"Version {agent_version} is not supported. This merchant" + f" implements version {server_version}." + ), + code="VERSION_UNSUPPORTED", status_code=422, - detail=UcpErrorDetail( - errors=[ - UcpErrorDetailItem( - code="VERSION_UNSUPPORTED", - message=( - f"Version {agent_version} is not supported. This merchant" - f" implements version {server_version}." - ), - severity="critical", - ) - ] - ).model_dump(), ) diff --git a/rest/python/server/exceptions.py b/rest/python/server/exceptions.py index 9e6100b1..62910321 100644 --- a/rest/python/server/exceptions.py +++ b/rest/python/server/exceptions.py @@ -17,31 +17,37 @@ from pydantic import BaseModel -class UcpErrorDetailItem(BaseModel): - """Details of a single error.""" +class UcpMessageError(BaseModel): + """Details of a single error, matching message_error.json schema.""" + type: str = "error" code: str - message: str - severity: str = "critical" + content: str + severity: str -class UcpErrorDetail(BaseModel): - """Top-level error response payload.""" +class UcpErrorResponse(BaseModel): + """Top-level error response payload, matching error_response.json schema.""" - status: str = "error" - errors: list[UcpErrorDetailItem] + ucp: dict # Will contain {"version": ..., "status": "error"} + messages: list[UcpMessageError] class UcpError(Exception): """Base class for all UCP exceptions.""" def __init__( - self, message: str, code: str = "INTERNAL_ERROR", status_code: int = 500 + self, + message: str, + code: str = "INTERNAL_ERROR", + status_code: int = 500, + severity: str = "unrecoverable", ): """Initialize UcpError.""" self.message = message self.code = code self.status_code = status_code + self.severity = severity super().__init__(self.message) @@ -50,7 +56,12 @@ class ResourceNotFoundError(UcpError): def __init__(self, message: str): """Initialize ResourceNotFoundError.""" - super().__init__(message, code="RESOURCE_NOT_FOUND", status_code=404) + super().__init__( + message, + code="RESOURCE_NOT_FOUND", + status_code=404, + severity="unrecoverable", + ) class IdempotencyConflictError(UcpError): @@ -58,7 +69,12 @@ class IdempotencyConflictError(UcpError): def __init__(self, message: str): """Initialize IdempotencyConflictError.""" - super().__init__(message, code="IDEMPOTENCY_CONFLICT", status_code=409) + super().__init__( + message, + code="IDEMPOTENCY_CONFLICT", + status_code=409, + severity="unrecoverable", + ) class CheckoutNotModifiableError(UcpError): @@ -66,7 +82,12 @@ class CheckoutNotModifiableError(UcpError): def __init__(self, message: str): """Initialize CheckoutNotModifiableError.""" - super().__init__(message, code="CHECKOUT_NOT_MODIFIABLE", status_code=409) + super().__init__( + message, + code="CHECKOUT_NOT_MODIFIABLE", + status_code=409, + severity="unrecoverable", + ) class OutOfStockError(UcpError): @@ -74,17 +95,30 @@ class OutOfStockError(UcpError): def __init__(self, message: str, status_code: int = 400): """Initialize OutOfStockError.""" - super().__init__(message, code="OUT_OF_STOCK", status_code=status_code) + super().__init__( + message, + code="OUT_OF_STOCK", + status_code=status_code, + severity="unrecoverable", + ) class PaymentFailedError(UcpError): """Raised when payment processing fails.""" def __init__( - self, message: str, code: str = "PAYMENT_FAILED", status_code: int = 402 + self, + message: str, + code: str = "PAYMENT_FAILED", + status_code: int = 402, ): """Initialize PaymentFailedError.""" - super().__init__(message, code=code, status_code=status_code) + super().__init__( + message, + code=code, + status_code=status_code, + severity="requires_buyer_input", + ) class InvalidRequestError(UcpError): @@ -92,24 +126,25 @@ class InvalidRequestError(UcpError): def __init__(self, message: str): """Initialize InvalidRequestError.""" - super().__init__(message, code="INVALID_REQUEST", status_code=400) + super().__init__( + message, + code="INVALID_REQUEST", + status_code=400, + severity="unrecoverable", + ) class UcpVersionError(UcpError): """Raised when a UCP version string is invalid or unsupported.""" - def __init__(self, message: str, code: str = "VERSION_INVALID_FORMAT"): + def __init__( + self, + message: str, + code: str = "VERSION_INVALID_FORMAT", + status_code: int = 400, + severity: str = "unrecoverable", + ): """Initialize UcpVersionError.""" - super().__init__(message, code=code, status_code=400) - - def to_detail(self) -> UcpErrorDetail: - """Return an error payload matching UCP REST error shape.""" - return UcpErrorDetail( - errors=[ - UcpErrorDetailItem( - code=self.code, - message=self.message, - severity="critical", - ) - ] + super().__init__( + message, code=code, status_code=status_code, severity=severity ) diff --git a/rest/python/server/integration_test.py b/rest/python/server/integration_test.py index 6250da72..55218f22 100644 --- a/rest/python/server/integration_test.py +++ b/rest/python/server/integration_test.py @@ -340,7 +340,9 @@ async def verify_inventory() -> int | None: json=payload.model_dump(mode="json", exclude_none=True), ) self.assertEqual(response.status_code, 400) - self.assertIn("Insufficient stock", response.json()["detail"]) + data = response.json() + self.assertEqual(data["ucp"]["status"], "error") + self.assertIn("Insufficient stock", data["messages"][0]["content"]) def test_double_complete_checkout(self) -> None: """Test that completing a checkout twice is idempotent.""" @@ -372,8 +374,10 @@ def test_double_complete_checkout(self) -> None: json=payment_payload, ) self.assertEqual(response.status_code, 409) + data = response.json() + self.assertEqual(data["ucp"]["status"], "error") self.assertEqual( - response.json()["detail"], + data["messages"][0]["content"], "Cannot complete checkout in state 'completed'", ) @@ -561,7 +565,9 @@ def test_cancel_checkout(self) -> None: ), ) self.assertEqual(response.status_code, 409) - self.assertIn("Cannot cancel checkout", response.json()["detail"]) + data = response.json() + self.assertEqual(data["ucp"]["status"], "error") + self.assertIn("Cannot cancel checkout", data["messages"][0]["content"]) # 4. Create another checkout and complete it, then try to cancel payload = self._create_checkout_payload( @@ -595,7 +601,9 @@ def test_cancel_checkout(self) -> None: ), ) self.assertEqual(response.status_code, 409) - self.assertIn("Cannot cancel checkout", response.json()["detail"]) + data = response.json() + self.assertEqual(data["ucp"]["status"], "error") + self.assertIn("Cannot cancel checkout", data["messages"][0]["content"]) def _notify_and_capture( self, checkout: UnifiedCheckout, event_type: str @@ -737,14 +745,15 @@ def test_version_invalid_format(self) -> None: ) self.assertEqual(response.status_code, 400) - # Verify the error structure matches UcpErrorDetail + # Verify the error structure matches UcpErrorResponse data = response.json() - self.assertIn("detail", data) - detail = data["detail"] - self.assertEqual(detail["status"], "error") - self.assertEqual(len(detail["errors"]), 1) - self.assertEqual(detail["errors"][0]["code"], "VERSION_INVALID_FORMAT") - self.assertEqual(detail["errors"][0]["severity"], "critical") + self.assertNotIn("detail", data) + self.assertEqual(data["ucp"]["status"], "error") + self.assertEqual(data["ucp"]["version"], app.version) + self.assertEqual(len(data["messages"]), 1) + self.assertEqual(data["messages"][0]["type"], "error") + self.assertEqual(data["messages"][0]["code"], "VERSION_INVALID_FORMAT") + self.assertEqual(data["messages"][0]["severity"], "unrecoverable") def test_version_unsupported(self) -> None: """Tests that UCP-Agent with unsupported (newer) version is rejected.""" @@ -764,14 +773,15 @@ def test_version_unsupported(self) -> None: ) self.assertEqual(response.status_code, 422) - # Verify the error structure matches UcpErrorDetail + # Verify the error structure matches UcpErrorResponse data = response.json() - self.assertIn("detail", data) - detail = data["detail"] - self.assertEqual(detail["status"], "error") - self.assertEqual(len(detail["errors"]), 1) - self.assertEqual(detail["errors"][0]["code"], "VERSION_UNSUPPORTED") - self.assertEqual(detail["errors"][0]["severity"], "critical") + self.assertNotIn("detail", data) + self.assertEqual(data["ucp"]["status"], "error") + self.assertEqual(data["ucp"]["version"], app.version) + self.assertEqual(len(data["messages"]), 1) + self.assertEqual(data["messages"][0]["type"], "error") + self.assertEqual(data["messages"][0]["code"], "VERSION_UNSUPPORTED") + self.assertEqual(data["messages"][0]["severity"], "unrecoverable") if __name__ == "__main__": diff --git a/rest/python/server/server.py b/rest/python/server/server.py index fd707b38..69d290f4 100644 --- a/rest/python/server/server.py +++ b/rest/python/server/server.py @@ -49,7 +49,20 @@ async def ucp_exception_handler(request: Request, exc: UcpError): del request # Unused. return JSONResponse( status_code=exc.status_code, - content={"detail": exc.message, "code": exc.code}, + content={ + "ucp": { + "version": config.get_server_version(), + "status": "error", + }, + "messages": [ + { + "type": "error", + "code": exc.code, + "content": exc.message, + "severity": exc.severity, + } + ], + }, ) From 25eaaed41d2e3cdc18f5fe02951ac25e8c5aaac6 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Mon, 27 Jul 2026 07:36:27 +0000 Subject: [PATCH 4/5] feat: implement spec-mandated webhook headers for Node.js and Python samples Implement `Webhook-Id` and `Webhook-Timestamp` headers for the `orderEvent` webhook in both Node.js and Python sample servers, as required by the UCP specification. - Generate a unique UUID for `Webhook-Id` for each event. - Generate the current Unix timestamp (seconds since epoch) for `Webhook-Timestamp`. - Include these headers in the POST request to the webhook URL. - Update integration tests to assert these headers are present and valid. --- rest/nodejs/src/api/checkout.ts | 2 ++ rest/nodejs/test/webhook.test.ts | 23 +++++++++++++++++++ rest/python/server/integration_test.py | 22 ++++++++++++++++++ .../server/services/checkout_service.py | 8 ++++++- 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/rest/nodejs/src/api/checkout.ts b/rest/nodejs/src/api/checkout.ts index 2e453fcf..2eca3986 100644 --- a/rest/nodejs/src/api/checkout.ts +++ b/rest/nodejs/src/api/checkout.ts @@ -153,6 +153,8 @@ export class CheckoutService { headers: { "Content-Type": "application/json", "X-Event-Type": eventType, + "Webhook-Id": uuidv4(), + "Webhook-Timestamp": Math.floor(Date.now() / 1000).toString(), }, body: JSON.stringify(orderData), }); diff --git a/rest/nodejs/test/webhook.test.ts b/rest/nodejs/test/webhook.test.ts index 3dfad0da..e5d62a76 100644 --- a/rest/nodejs/test/webhook.test.ts +++ b/rest/nodejs/test/webhook.test.ts @@ -118,6 +118,29 @@ test("webhook delivers the bare order object as the body", async () => { "event type must be carried in the X-Event-Type header" ); + assert.ok( + delivered.headers["Webhook-Id"], + "Webhook-Id header must be present" + ); + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + assert.ok( + uuidRegex.test(delivered.headers["Webhook-Id"]), + "Webhook-Id must be a valid UUID" + ); + + assert.ok( + delivered.headers["Webhook-Timestamp"], + "Webhook-Timestamp header must be present" + ); + const timestamp = parseInt(delivered.headers["Webhook-Timestamp"], 10); + assert.ok(!isNaN(timestamp), "Webhook-Timestamp must be a number"); + const now = Math.floor(Date.now() / 1000); + assert.ok( + Math.abs(now - timestamp) < 5, + `Webhook-Timestamp (${timestamp}) should be close to now (${now})` + ); + const body = delivered.body as Record; // The body IS the order: its own id, and every required field present. assert.equal( diff --git a/rest/python/server/integration_test.py b/rest/python/server/integration_test.py index 55218f22..1b9f3cd9 100644 --- a/rest/python/server/integration_test.py +++ b/rest/python/server/integration_test.py @@ -16,6 +16,7 @@ import asyncio from collections.abc import AsyncGenerator +import datetime import json from pathlib import Path import shutil @@ -684,6 +685,27 @@ def test_webhook_delivers_the_bare_order_as_body(self) -> None: # The event type travels in the header, not the body. self.assertEqual(delivered["headers"].get("X-Event-Type"), "order_placed") + self.assertIn("Webhook-Id", delivered["headers"]) + uuid_str = delivered["headers"]["Webhook-Id"] + try: + uuid.UUID(uuid_str) + except ValueError: + self.fail(f"Webhook-Id {uuid_str} is not a valid UUID") + + self.assertIn("Webhook-Timestamp", delivered["headers"]) + timestamp_str = delivered["headers"]["Webhook-Timestamp"] + try: + timestamp = int(timestamp_str) + except ValueError: + self.fail(f"Webhook-Timestamp {timestamp_str} is not a valid integer") + + now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + self.assertLess( + abs(now - timestamp), + 5, + f"Webhook-Timestamp ({timestamp}) should be close to now ({now})", + ) + body = delivered["json"] # The body IS an order: it validates and carries every required field. Order.model_validate(body) diff --git a/rest/python/server/services/checkout_service.py b/rest/python/server/services/checkout_service.py index f2ed1c53..c3f445ea 100644 --- a/rest/python/server/services/checkout_service.py +++ b/rest/python/server/services/checkout_service.py @@ -839,7 +839,13 @@ async def _notify_webhook(self, checkout: Checkout, event_type: str) -> None: await client.post( webhook_url, json=order_data, - headers={"X-Event-Type": event_type}, + headers={ + "X-Event-Type": event_type, + "Webhook-Id": str(uuid.uuid4()), + "Webhook-Timestamp": str( + int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + ), + }, timeout=5.0, ) except Exception as e: # pylint: disable=broad-exception-caught From 88d4a6c41cdca8b8983a9adb830674fac4d0f473 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Mon, 27 Jul 2026 09:05:35 +0000 Subject: [PATCH 5/5] chore: change default status code for UcpVersionError to 422 --- rest/python/server/exceptions.py | 2 +- rest/python/server/integration_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rest/python/server/exceptions.py b/rest/python/server/exceptions.py index b8c4915a..9d499f9e 100644 --- a/rest/python/server/exceptions.py +++ b/rest/python/server/exceptions.py @@ -143,7 +143,7 @@ def __init__( self, message: str, code: str = "VERSION_INVALID_FORMAT", - status_code: int = 400, + status_code: int = 422, severity: ErrorSeverity = ErrorSeverity.UNRECOVERABLE, ): """Initialize UcpVersionError.""" diff --git a/rest/python/server/integration_test.py b/rest/python/server/integration_test.py index 61cd9ae7..9181d045 100644 --- a/rest/python/server/integration_test.py +++ b/rest/python/server/integration_test.py @@ -771,7 +771,7 @@ def test_version_invalid_format(self) -> None: headers=headers, json=payload.model_dump(mode="json", exclude_none=True), ) - self.assertEqual(response.status_code, 400) + self.assertEqual(response.status_code, 422) # Verify the error structure matches UcpErrorResponse data = response.json()