From bc55212f8fa56fd8ef79637c7f500dece6dc4546 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Thu, 23 Jul 2026 11:37:09 +0000 Subject: [PATCH 1/3] 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/3] 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/3] 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, + } + ], + }, )