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 ); } } 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/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" } ] }, 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, + } + ], + }, )