diff --git a/docs/specification/checkout-mcp.md b/docs/specification/checkout-mcp.md index 9477c0cd8..e3b48daec 100644 --- a/docs/specification/checkout-mcp.md +++ b/docs/specification/checkout-mcp.md @@ -139,7 +139,9 @@ Maps to the [Create Checkout](checkout.md#create-checkout) operation. #### Input Schema * `checkout` ([Checkout](checkout.md#create-checkout)): **Required**. Contains - the initial checkout session data and optional extensions. + the initial checkout session data and negotiated extension inputs. See + [Create Checkout](checkout.md#create-checkout) for the extension input + summary. * Extensions (Optional): * `dev.ucp.shopping.buyer_consent`: [Buyer Consent](buyer-consent.md) * `dev.ucp.shopping.fulfillment`: [Fulfillment](fulfillment.md) diff --git a/docs/specification/checkout.md b/docs/specification/checkout.md index b7bc86421..563bd88b1 100644 --- a/docs/specification/checkout.md +++ b/docs/specification/checkout.md @@ -1058,7 +1058,20 @@ should accept an additional `cart_id` field for cart-to-checkout conversion. See [Cart → Cart-to-Checkout Conversion](cart.md#cart-to-checkout-conversion) for the field contract. -{{ method_fields('create_checkout', 'rest.openapi.json', 'checkout') }} +When extensions are negotiated, Create Checkout can also accept extension-owned +inputs. The generated table below includes extension fields that add new +top-level inputs; extensions that specialize an existing field are summarized +here: + +| Extension | Input | Create Checkout behavior | +| --- | --- | --- | +| [Discount](discount.md) | `discounts` | Optional discount codes or identifiers to evaluate with the checkout. | +| [Fulfillment](fulfillment.md) | `fulfillment` | Optional fulfillment method and selection inputs. The Business response remains authoritative for generated destinations, groups, options, costs, timing, and availability. | +| [Buyer Consent](buyer-consent.md) | `buyer` | Optional consent details alongside the base buyer information. | +| [Payment Terms](payment-terms.md) | `payment` | Optional selected payment-term inputs when payment terms are available. | +| [Split Payments](split-payments.md) | `payment.instruments[]` | Optional allocation-aware payment instruments. | + +{{ method_fields('create_checkout', 'rest.openapi.json', 'checkout', extensions=['discount', 'fulfillment']) }} ### Get Checkout diff --git a/docs/specification/fulfillment.md b/docs/specification/fulfillment.md index d49f602a5..64805932d 100644 --- a/docs/specification/fulfillment.md +++ b/docs/specification/fulfillment.md @@ -38,6 +38,12 @@ On Checkout, the `fulfillment` field contains: * `groups[]` — business-generated packages, each with selectable `options[]` * `available_methods[]` — inventory availability per item (optional) +Checkout Create and Update requests **MAY** include `fulfillment` when this +extension is active. Request payloads carry buyer-provided method selections +and selected destination or option identifiers; response payloads remain +authoritative for IDs, groups, options, costs, timing, and availability +generated by the Business. + **Mental model:** * `methods[0]` Shipping diff --git a/main.py b/main.py index a656fb509..4ddd2376d 100644 --- a/main.py +++ b/main.py @@ -235,6 +235,24 @@ def _resolve_with_ucp_schema(schema_path, direction, operation): """Resolve a schema using ucp-schema CLI (delegates to module-level fn).""" return _resolve_schema(schema_path, direction, operation, bundle=False) + def _operation_from_context(context): + """Return the ucp-schema operation name for a render context.""" + if not context: + return "read" + + io_type = context.get("io_type") + op_id = context.get("operation_id", "").lower() + + if io_type == "request": + if "create" in op_id: + return "create" + if "update" in op_id or "patch" in op_id: + return "update" + if "complete" in op_id: + return "complete" + + return "read" + def _load_json_file(entity_name): """Try loading a JSON file from the configured directories.""" for schemas_dir in schemas_dirs: @@ -246,6 +264,61 @@ def _load_json_file(entity_name): continue return None + def _extension_target_properties(entity_name, target="checkout", context=None): + """Return the schema fragment an extension contributes to a target schema.""" + full_path = SHOPPING_SCHEMAS_DIR / (entity_name + ".json") + + def _inherit_local_ref_descriptions(schema_fragment, root): + for details in schema_fragment.get("properties", {}).values(): + if not isinstance(details, dict) or details.get("description"): + continue + ref = details.get("$ref") + if not ref or not ref.startswith("#/"): + continue + resolved = _resolve_json_pointer(ref, root) + if isinstance(resolved, dict) and resolved.get("description"): + details["description"] = resolved["description"] + return schema_fragment + + try: + if context: + data = _resolve_schema( + full_path, + context.get("io_type", "response"), + _operation_from_context(context), + bundle=False, + ) + else: + with full_path.open(encoding="utf-8") as f: + data = json.load(f) + + defs = data.get("$defs", {}) + + target_def = None + for key, schema_def in defs.items(): + if (key == target or key.endswith("." + target)) and ( + isinstance(schema_def, dict) and "allOf" in schema_def + ): + target_def = schema_def + break + + if target_def: + for item in target_def["allOf"]: + if "properties" in item: + return _inherit_local_ref_descriptions(item, data) + + for schema_def in defs.values(): + if isinstance(schema_def, dict) and "allOf" in schema_def: + for item in schema_def["allOf"]: + if "properties" in item: + return _inherit_local_ref_descriptions(item, data) + + return None + except (FileNotFoundError, json.JSONDecodeError) as e: + raise RuntimeError( + f"Error loading extension '{entity_name}': {e}{get_error_context()}" + ) from e + def _load_schema_variant(entity_name, context): """Load and resolve a schema for a specific operation. @@ -265,7 +338,6 @@ def _load_schema_variant(entity_name, context): return _load_json_file(entity_name) io_type = context.get("io_type") - op_id = context.get("operation_id", "").lower() # Find the schema file schema_path = None @@ -280,17 +352,7 @@ def _load_schema_variant(entity_name, context): # Determine direction and operation for ucp-schema direction = io_type # "request" or "response" - operation = "read" # default for responses - - if io_type == "request": - if "create" in op_id: - operation = "create" - elif "update" in op_id or "patch" in op_id: - operation = "update" - elif "complete" in op_id: - operation = "complete" - elif io_type == "response": - operation = "read" + operation = _operation_from_context(context) # Resolve using ucp-schema (no fallback - fail loudly if unavailable) resolved = _resolve_with_ucp_schema(schema_path, direction, operation) @@ -410,7 +472,9 @@ def create_link(ref_string, spec_file_name, context=None): fragment_text = ( fragment.replace("_", " ").replace(".", " ").replace("-", " ").title() ) - link_text = f"{base_text} {fragment_text}" + link_text = ( + f"{base_text} {fragment_text}" if base_text else fragment_text + ) else: link_text = ( raw_name.replace("_", " ").replace(".", " ").replace("-", " ").title() @@ -1337,51 +1401,25 @@ def extension_fields(entity_name, spec_file_name, target="checkout"): "checkout", "cart"). Defaults to "checkout". """ - # Construct full path based on new structure - full_path = SHOPPING_SCHEMAS_DIR / (entity_name + ".json") - try: - with full_path.open(encoding="utf-8") as f: - data = json.load(f) - - # Extension schemas have their composed type in $defs.checkout - # or $defs.order_line_item. - defs = data.get("$defs", {}) - - # Try to find the specific target first (e.g. dev.ucp.shopping.checkout) - # We look for a key that ends with target. - target_def = None - for key, schema_def in defs.items(): - if (key == target or key.endswith("." + target)) and ( - isinstance(schema_def, dict) and "allOf" in schema_def - ): - target_def = schema_def - break - - if target_def: - for item in target_def["allOf"]: - if "properties" in item: - return _render_table_from_schema(item, spec_file_name) - - # Fallback to dynamically finding the composed type (old behavior) - for schema_def in defs.values(): - if isinstance(schema_def, dict) and "allOf" in schema_def: - for item in schema_def["allOf"]: - if "properties" in item: - return _render_table_from_schema(item, spec_file_name) - - raise RuntimeError( - f"Could not find extension properties for target '{target}' " - f"in '{entity_name}'" - f"{get_error_context()}" - ) - except (FileNotFoundError, json.JSONDecodeError) as e: - raise RuntimeError( - f"Error loading extension '{entity_name}': {e}{get_error_context()}" - ) from e + properties = _extension_target_properties(entity_name, target) + if properties: + return _render_table_from_schema(properties, spec_file_name) + + raise RuntimeError( + f"Could not find extension properties for target '{target}' " + f"in '{entity_name}'" + f"{get_error_context()}" + ) # --- MACRO 3: For Transport Operations --- @env.macro - def method_fields(operation_id, file_name, spec_file_name, io_type=None): + def method_fields( + operation_id, + file_name, + spec_file_name, + io_type=None, + extensions=None, + ): """Extract Request/Response schemas for a specific OpenAPI operationId. Args: @@ -1392,6 +1430,8 @@ def method_fields(operation_id, file_name, spec_file_name, io_type=None): should be rendered (e.g., "checkout", "fulfillment"). io_type: Optional. Specifies whether to render 'request', 'response', or both (if None). + extensions: Optional. Extension schema names whose target fields should + be appended to the request table. """ full_path = OPENAPI_DIR / file_name @@ -1538,6 +1578,20 @@ def resolve_structure(schema, root): elif req_schema: combined_schema = req_schema + if extensions and combined_schema: + extension_schemas = [] + for extension_name in extensions: + extension_schema = _extension_target_properties( + extension_name, + "checkout", + req_context, + ) + if extension_schema and extension_schema.get("properties"): + extension_schemas.append(extension_schema) + + if extension_schemas: + combined_schema = {"allOf": [combined_schema, *extension_schemas]} + if combined_schema: output += "**Inputs**\n\n" output += ( diff --git a/source/services/shopping/mcp.openrpc.json b/source/services/shopping/mcp.openrpc.json index a32cebe34..3ea1d0f3c 100644 --- a/source/services/shopping/mcp.openrpc.json +++ b/source/services/shopping/mcp.openrpc.json @@ -84,6 +84,7 @@ { "name": "checkout", "required": true, + "description": "Initial checkout session data. The object may include negotiated extension fields according to operation-specific request rules.", "schema": {"$ref": "../../schemas/shopping/checkout.json"} } ], @@ -130,6 +131,7 @@ { "name": "checkout", "required": true, + "description": "Replacement checkout session data. The object may include negotiated extension fields according to operation-specific request rules.", "schema": {"$ref": "../../schemas/shopping/checkout.json"} } ], @@ -160,6 +162,7 @@ { "name": "checkout", "required": true, + "description": "Completion input. Use negotiated extension fields according to operation-specific request rules.", "schema": {"$ref": "../../schemas/shopping/checkout.json"} } ],