diff --git a/.gitignore b/.gitignore index e10ddfd..bfcdfef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.venv/ +.env +.uv/ + +# Logs / caches +.pytest_cache/ +.mypy_cache/ +ruff_cache/ + +# macOS +.DS_Store + +# local locks / envs +**/uv.lock +**/.venv/ +**/__pycache__/ +**/*.egg-info/ + +# local locks / envs +**/uv.lock +**/.venv/ +**/__pycache__/ +**/*.egg-info/ **/__pycache__ .pytest_cache .ruff_cache diff --git a/adapters/shopify/python/README.md b/adapters/shopify/python/README.md new file mode 100644 index 0000000..6044579 --- /dev/null +++ b/adapters/shopify/python/README.md @@ -0,0 +1,319 @@ +# UCP Shopify Adapter (Mock) + MCP Bridge + +This sample provides: + +1. **UCP-like REST adapter (mock)** that exposes a minimal shopping API compatible with UCP-style flows: + - Discovery profile: `GET /.well-known/ucp` + - Catalog: `GET /products`, `GET /products/{id}` + - Checkout: `POST /checkout-sessions`, `GET /checkout-sessions/{id}`, `PUT /checkout-sessions/{id}`, `POST /checkout-sessions/{id}/complete` + - Orders: `GET /orders/{id}` + - Testing: `POST /testing/simulate-shipping/{id}` *(requires `Simulation-Secret`)* + +2. **MCP server (bridge)** that exposes the adapter endpoints as MCP tools for agent clients (e.g. Claude Desktop). + See [`mcp/README.md`](./mcp/README.md). + +> This is a **mock** "Shopify adapter": it does **not** call Shopify yet. +> It uses an in-memory catalog and in-memory checkout/order storage so the full flow can be tested without tokens or a Shopify account. + +--- + +## Layout + +``` +samples/adapters/shopify/python/ +├── README.md +├── pyproject.toml +├── app/ +│ ├── main.py +│ ├── config.py +│ ├── shopify_client.py +│ ├── ucp_mappers.py +│ ├── storage.py +│ ├── models.py +│ └── routes/ +│ ├── discovery.py +│ ├── catalog.py +│ ├── checkout.py +│ ├── orders.py +│ └── testing.py +└── mcp/ + ├── README.md + ├── pyproject.toml + └── ucp_mcp_server.py +``` + +--- + +## Prerequisites + +- Python 3.10+ +- macOS/Linux shell (examples use `uuidgen`) + +--- + +## Install & Run (REST Adapter) + +From repo root: + +```bash +cd samples/adapters/shopify/python +python -m venv .venv +source .venv/bin/activate +pip install -e . +ucp-shopify-adapter +``` + +Adapter runs at: +- http://127.0.0.1:8183 + +--- + +## Quick Checks + +### Discovery profile + +```bash +curl -s http://127.0.0.1:8183/.well-known/ucp | python -m json.tool +``` + +### List products + +```bash +curl -s http://127.0.0.1:8183/products | python -m json.tool +``` + +### Get product + +```bash +curl -s http://127.0.0.1:8183/products/bouquet_roses | python -m json.tool +``` + +--- + +## End-to-End REST Flow + +### 1) Create a checkout + +```bash +REQ_ID=$(uuidgen) +IDEMP=$(uuidgen) + +curl -s -X POST http://127.0.0.1:8183/checkout-sessions \ + -H "Content-Type: application/json" \ + -H "UCP-Agent: profile=https://agent.example/profile" \ + -H "request-signature: test" \ + -H "request-id: $REQ_ID" \ + -H "idempotency-key: $IDEMP" \ + -d '{ + "buyer": {"full_name":"John Doe","email":"john.doe@example.com"}, + "currency":"USD", + "line_items":[{"product_id":"bouquet_roses","quantity":1}], + "discount_codes":["10OFF"] + }' | tee /tmp/checkout_create.json | python -m json.tool +``` + +Extract the IDs: + +```bash +CHECKOUT_ID=$(python -c 'import json;print(json.load(open("/tmp/checkout_create.json"))["id"])') +LINE_ITEM_ID=$(python -c 'import json;d=json.load(open("/tmp/checkout_create.json"));print(d["line_items"][0]["id"])') +echo "CHECKOUT_ID=$CHECKOUT_ID" +echo "LINE_ITEM_ID=$LINE_ITEM_ID" +``` + +--- + +### 2) Set shipping destination + choose shipping option + +This sample exposes 2 options once a destination is set: +- `std-ship` (free) +- `exp-ship-intl` (+2500 cents) + +Create update payload: + +```bash +cat > /tmp/checkout_update.json < 409 conflict +REQ_ID=$(uuidgen) +curl -s -X POST http://127.0.0.1:8183/checkout-sessions \ + -H "Content-Type: application/json" \ + -H "UCP-Agent: profile=https://agent.example/profile" \ + -H "request-signature: test" \ + -H "request-id: $REQ_ID" \ + -H "idempotency-key: $IDEMP" \ + -d '{"currency":"USD","line_items":[{"product_id":"bouquet_roses","quantity":2}]}' | python -m json.tool +``` + +--- + +## Configuration + +Environment variables (optional): +- `SERVER_BASE_URL` (default `http://localhost:8183`) +- `SIMULATION_SECRET` (default `letmein`) + +Example: + +```bash +export SIMULATION_SECRET="letmein" +export SERVER_BASE_URL="http://127.0.0.1:8183" +ucp-shopify-adapter +``` + +--- + +## MCP Bridge + +To expose this adapter via MCP tools for Claude Desktop, see: +- [mcp/README.md](./mcp/README.md) + +--- + +## Notes / Limitations + +- Storage is in-memory. Restarting the server clears checkouts and orders. +- Payment is mocked. Only the token `success_token` is treated as "authorized." +- This is a "Shopify adapter" shape only. A real implementation would replace `MockShopifyClient` with Shopify Storefront API calls and map Shopify cart/checkout objects into these UCP-ish models. \ No newline at end of file diff --git a/adapters/shopify/python/app/config.py b/adapters/shopify/python/app/config.py new file mode 100644 index 0000000..059d415 --- /dev/null +++ b/adapters/shopify/python/app/config.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel +import os + + +class Settings(BaseModel): + ucp_version: str = "2026-01-11" + server_base_url: str = os.getenv("SERVER_BASE_URL", "http://localhost:8183") + simulation_secret: str = os.getenv("SIMULATION_SECRET", "letmein") + + +settings = Settings() \ No newline at end of file diff --git a/adapters/shopify/python/app/main.py b/adapters/shopify/python/app/main.py new file mode 100644 index 0000000..701d224 --- /dev/null +++ b/adapters/shopify/python/app/main.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import uvicorn +from fastapi import FastAPI + +from .routes.discovery import router as discovery_router +from .routes.catalog import router as catalog_router +from .routes.checkout import router as checkout_router +from .routes.orders import router as orders_router +from .routes.testing import router as testing_router + + +app = FastAPI(title="UCP Shopify Adapter (Mock)", version="0.1.0") + +app.include_router(discovery_router) +app.include_router(catalog_router) +app.include_router(checkout_router) +app.include_router(orders_router) +app.include_router(testing_router) + +@app.get("/health") +async def health() -> dict: + return {"status": "ok"} + +def run() -> None: + uvicorn.run("app.main:app", host="127.0.0.1", port=8183, reload=True) + + +if __name__ == "__main__": + run() \ No newline at end of file diff --git a/adapters/shopify/python/app/models.py b/adapters/shopify/python/app/models.py new file mode 100644 index 0000000..6fd82cb --- /dev/null +++ b/adapters/shopify/python/app/models.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Optional +from pydantic import BaseModel, Field + + +# ------------------------- +# Common API error shape +# ------------------------- +class ApiError(BaseModel): + detail: str + code: str + + +# ------------------------- +# Catalog (Mock Shopify) +# ------------------------- +class Product(BaseModel): + id: str + title: str + price: int # cents + currency: str = "USD" + image_url: Optional[str] = None + + +# ------------------------- +# Checkout models (UCP-ish) +# ------------------------- +class Buyer(BaseModel): + full_name: Optional[str] = None + email: Optional[str] = None + phone_number: Optional[str] = None + + +class LineItemCreate(BaseModel): + product_id: str + quantity: int = Field(ge=1) + + +class LineItem(BaseModel): + id: str + item: Dict[str, Any] + quantity: int + totals: List[Dict[str, Any]] + parent_id: Optional[str] = None + + +class ShippingDestination(BaseModel): + id: Optional[str] = None + street_address: Optional[str] = None + extended_address: Optional[str] = None + address_locality: Optional[str] = None + address_region: Optional[str] = None + address_country: Optional[str] = None + postal_code: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + full_name: Optional[str] = None + phone_number: Optional[str] = None + + +class FulfillmentOption(BaseModel): + id: str + title: str + description: Optional[str] = None + carrier: Optional[str] = None + earliest_fulfillment_time: Optional[str] = None + latest_fulfillment_time: Optional[str] = None + totals: List[Dict[str, Any]] + + +class FulfillmentGroup(BaseModel): + id: Optional[str] = None + line_item_ids: Optional[List[str]] = None + options: Optional[List[FulfillmentOption]] = None + selected_option_id: Optional[str] = None + + +class FulfillmentMethod(BaseModel): + id: Optional[str] = None + type: Literal["shipping", "pickup"] + line_item_ids: Optional[List[str]] = None + destinations: Optional[List[ShippingDestination]] = None + selected_destination_id: Optional[str] = None + groups: Optional[List[FulfillmentGroup]] = None + + +class Fulfillment(BaseModel): + methods: Optional[List[FulfillmentMethod]] = None + available_methods: Optional[Any] = None + + +class Discounts(BaseModel): + codes: List[str] = [] + applied: List[Dict[str, Any]] = [] + + +class Checkout(BaseModel): + ucp: Dict[str, Any] + id: str + line_items: List[LineItem] + buyer: Optional[Buyer] = None + status: str + currency: str + totals: List[Dict[str, Any]] + discounts: Optional[Discounts] = None + fulfillment: Optional[Fulfillment] = None + payment: Dict[str, Any] = Field(default_factory=lambda: {"handlers": [], "selected_instrument_id": None, "instruments": []}) + order: Optional[Dict[str, Any]] = None + messages: Optional[Any] = None + links: List[Any] = [] + expires_at: Optional[str] = None + continue_url: Optional[str] = None + ap2: Optional[Any] = None + platform: Optional[Any] = None + + +class CreateCheckoutRequest(BaseModel): + buyer: Optional[Buyer] = None + currency: str = "USD" + line_items: List[LineItemCreate] + discount_codes: List[str] = [] + + +class UpdateCheckoutRequest(BaseModel): + id: str + # allow partial updates; only fulfillment used in this sample + fulfillment: Optional[Fulfillment] = None + + +# ------------------------- +# Payment (minimal) +# ------------------------- +class TokenCredential(BaseModel): + type: Literal["token"] + token: str + + +class PaymentInstrument(BaseModel): + id: str + handler_id: str + type: Literal["card"] + brand: str + last_digits: str + credential: Optional[TokenCredential] = None + + +class CompleteCheckoutRequest(BaseModel): + payment_data: PaymentInstrument + risk_signals: Dict[str, Any] = Field(default_factory=dict) + + +# ------------------------- +# Order models (UCP-ish) +# ------------------------- +class OrderLineItem(BaseModel): + id: str + item: Dict[str, Any] + quantity: Dict[str, int] # {"total": int, "fulfilled": int} + totals: List[Dict[str, Any]] + status: str + parent_id: Optional[str] = None + + +class FulfillmentEvent(BaseModel): + id: str + type: str + timestamp: str + + +class OrderFulfillment(BaseModel): + expectations: List[Dict[str, Any]] = [] + events: List[FulfillmentEvent] = [] + + +class Order(BaseModel): + ucp: Dict[str, Any] + id: str + checkout_id: str + permalink_url: str + line_items: List[OrderLineItem] + fulfillment: OrderFulfillment + adjustments: Optional[Any] = None + totals: List[Dict[str, Any]] \ No newline at end of file diff --git a/adapters/shopify/python/app/routes/catalog.py b/adapters/shopify/python/app/routes/catalog.py new file mode 100644 index 0000000..5689a73 --- /dev/null +++ b/adapters/shopify/python/app/routes/catalog.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException +from ..shopify_client import MockShopifyClient +from ..ucp_mappers import product_to_ucp_item + +router = APIRouter() +shopify = MockShopifyClient() + + +@router.get("/products") +async def list_products(): + return {"products": [product_to_ucp_item(p) for p in shopify.list_products()]} + + +@router.get("/products/{product_id}") +async def get_product(product_id: str): + p = shopify.get_product(product_id) + if not p: + raise HTTPException(status_code=404, detail="Product not found") + return {"product": product_to_ucp_item(p)} \ No newline at end of file diff --git a/adapters/shopify/python/app/routes/checkout.py b/adapters/shopify/python/app/routes/checkout.py new file mode 100644 index 0000000..64f033f --- /dev/null +++ b/adapters/shopify/python/app/routes/checkout.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +import datetime +import uuid +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Body, Header, HTTPException, Path +from ..config import settings +from ..models import ( + ApiError, + Checkout, + CompleteCheckoutRequest, + CreateCheckoutRequest, + FulfillmentOption, +) +from ..shopify_client import MockShopifyClient +from ..storage import store +from ..ucp_mappers import apply_discounts, compute_totals, product_to_ucp_item + +router = APIRouter() +shopify = MockShopifyClient() + + +def _require_header(name: str, value: Optional[str]) -> str: + if not value: + raise HTTPException(status_code=400, detail={"detail": f"Missing header: {name}", "code": "INVALID_REQUEST"}) + return value + + +def _checkout_ucp_envelope() -> Dict[str, Any]: + return { + "ucp": { + "version": settings.ucp_version, + "capabilities": [{"name": "dev.ucp.shopping.checkout", "version": settings.ucp_version, "spec": None, "schema": None, "extends": None, "config": None}], + } + } + + +def _build_fulfillment_defaults(line_item_ids: list[str]) -> Dict[str, Any]: + # We generate fulfillment groups/options after a destination is set (or on update) + return {"methods": [], "available_methods": None} + + +def _recompute_checkout(checkout: Dict[str, Any]) -> Dict[str, Any]: + subtotal = sum(li["item"]["price"] * li["quantity"] for li in checkout["line_items"]) + discount_amount, discounts_obj = apply_discounts(checkout.get("discounts", {}).get("codes", []), subtotal) + + fulfillment_amount = 0 + # If fulfillment selected option is exp-ship-intl => +2500 + try: + methods = (checkout.get("fulfillment") or {}).get("methods") or [] + if methods: + groups = (methods[0].get("groups") or []) + if groups and groups[0].get("selected_option_id") == "exp-ship-intl": + fulfillment_amount = 2500 + except Exception: + pass + + checkout["totals"] = compute_totals(subtotal=subtotal, fulfillment=fulfillment_amount, discount=discount_amount) + checkout["discounts"] = discounts_obj + return checkout + + +def _ensure_groups_options(method: Dict[str, Any]) -> None: + """ + If destinations exist, ensure groups/options exist. + """ + if not method.get("groups"): + method["groups"] = [] + + # single group for all line items in method + group = method["groups"][0] if method["groups"] else None + if not group: + group = { + "id": f"group_{uuid.uuid4()}", + "line_item_ids": method.get("line_item_ids") or [], + "options": [ + { + "id": "std-ship", + "title": "Standard Shipping (Free)", + "description": None, + "carrier": None, + "earliest_fulfillment_time": None, + "latest_fulfillment_time": None, + "totals": [ + {"type": "subtotal", "display_text": None, "amount": 0}, + {"type": "total", "display_text": None, "amount": 0}, + ], + }, + { + "id": "exp-ship-intl", + "title": "International Express", + "description": None, + "carrier": None, + "earliest_fulfillment_time": None, + "latest_fulfillment_time": None, + "totals": [ + {"type": "subtotal", "display_text": None, "amount": 2500}, + {"type": "total", "display_text": None, "amount": 2500}, + ], + }, + ], + "selected_option_id": group.get("selected_option_id") if group else None, + } + method["groups"] = [group] + else: + # make sure options exist + if not group.get("options"): + group["options"] = [ + { + "id": "std-ship", + "title": "Standard Shipping (Free)", + "description": None, + "carrier": None, + "earliest_fulfillment_time": None, + "latest_fulfillment_time": None, + "totals": [ + {"type": "subtotal", "display_text": None, "amount": 0}, + {"type": "total", "display_text": None, "amount": 0}, + ], + }, + { + "id": "exp-ship-intl", + "title": "International Express", + "description": None, + "carrier": None, + "earliest_fulfillment_time": None, + "latest_fulfillment_time": None, + "totals": [ + {"type": "subtotal", "display_text": None, "amount": 2500}, + {"type": "total", "display_text": None, "amount": 2500}, + ], + }, + ] + + +@router.post("/checkout-sessions") +async def create_checkout( + req: CreateCheckoutRequest = Body(...), + ucp_agent: Optional[str] = Header(None, alias="UCP-Agent"), + request_signature: Optional[str] = Header(None, alias="request-signature"), + request_id: Optional[str] = Header(None, alias="request-id"), + idempotency_key: Optional[str] = Header(None, alias="idempotency-key"), +): + _require_header("UCP-Agent", ucp_agent) + _require_header("request-signature", request_signature) + _require_header("request-id", request_id) + _require_header("idempotency-key", idempotency_key) + + payload = req.model_dump(mode="json") + try: + existing, req_hash = store.idempotency_check_or_raise(idempotency_key, payload) + if existing is not None: + return existing + except ValueError: + raise HTTPException(status_code=409, detail={"detail": "Idempotency key reused with different parameters", "code": "IDEMPOTENCY_CONFLICT"}) + + checkout_id = store.new_id() + line_items = [] + for li in req.line_items: + p = shopify.get_product(li.product_id) + if not p: + raise HTTPException(status_code=400, detail={"detail": f"Unknown product_id: {li.product_id}", "code": "INVALID_REQUEST"}) + line_item_id = store.new_id() + item = product_to_ucp_item(p) + line_items.append( + { + "id": line_item_id, + "item": item, + "quantity": li.quantity, + "totals": [ + {"type": "subtotal", "display_text": None, "amount": item["price"] * li.quantity}, + {"type": "total", "display_text": None, "amount": item["price"] * li.quantity}, + ], + "parent_id": None, + } + ) + + checkout = { + **_checkout_ucp_envelope(), + "id": checkout_id, + "line_items": line_items, + "buyer": req.buyer.model_dump(mode="json") if req.buyer else None, + "status": "ready_for_complete", + "currency": req.currency, + "totals": [], + "messages": None, + "links": [], + "expires_at": None, + "continue_url": None, + "payment": {"handlers": [], "selected_instrument_id": None, "instruments": []}, + "order": None, + "ap2": None, + "discounts": {"codes": req.discount_codes, "applied": []}, + "fulfillment": None, + "platform": None, + } + + checkout["fulfillment"] = _build_fulfillment_defaults([li["id"] for li in line_items]) + checkout = _recompute_checkout(checkout) + + store.checkouts[checkout_id] = checkout + store.put_idempotency(idempotency_key, req_hash, checkout) + return checkout + + +@router.get("/checkout-sessions/{id}") +async def get_checkout( + checkout_id: str = Path(..., alias="id"), + ucp_agent: Optional[str] = Header(None, alias="UCP-Agent"), + request_signature: Optional[str] = Header(None, alias="request-signature"), + request_id: Optional[str] = Header(None, alias="request-id"), +): + _require_header("UCP-Agent", ucp_agent) + _require_header("request-signature", request_signature) + _require_header("request-id", request_id) + + checkout = store.checkouts.get(checkout_id) + if not checkout: + raise HTTPException(status_code=404, detail={"detail": "Checkout not found", "code": "NOT_FOUND"}) + return checkout + + +@router.put("/checkout-sessions/{id}") +async def update_checkout( + checkout_id: str = Path(..., alias="id"), + body: Dict[str, Any] = Body(...), + ucp_agent: Optional[str] = Header(None, alias="UCP-Agent"), + request_signature: Optional[str] = Header(None, alias="request-signature"), + request_id: Optional[str] = Header(None, alias="request-id"), + idempotency_key: Optional[str] = Header(None, alias="idempotency-key"), +): + _require_header("UCP-Agent", ucp_agent) + _require_header("request-signature", request_signature) + _require_header("request-id", request_id) + _require_header("idempotency-key", idempotency_key) + + try: + existing, req_hash = store.idempotency_check_or_raise(idempotency_key, body) + if existing is not None: + return existing + except ValueError: + raise HTTPException(status_code=409, detail={"detail": "Idempotency key reused with different parameters", "code": "IDEMPOTENCY_CONFLICT"}) + + checkout = store.checkouts.get(checkout_id) + if not checkout: + raise HTTPException(status_code=404, detail={"detail": "Checkout not found", "code": "NOT_FOUND"}) + + # Partial update: only fulfillment supported here + if "fulfillment" in body and body["fulfillment"] is not None: + checkout["fulfillment"] = body["fulfillment"] + + # Ensure groups/options if destinations exist + methods = (checkout["fulfillment"] or {}).get("methods") or [] + if methods: + method = methods[0] + if method.get("destinations"): + _ensure_groups_options(method) + checkout["fulfillment"]["methods"] = [method] + + checkout = _recompute_checkout(checkout) + store.checkouts[checkout_id] = checkout + store.put_idempotency(idempotency_key, req_hash, checkout) + return checkout + + +@router.post("/checkout-sessions/{id}/complete") +async def complete_checkout( + checkout_id: str = Path(..., alias="id"), + req: CompleteCheckoutRequest = Body(...), + ucp_agent: Optional[str] = Header(None, alias="UCP-Agent"), + request_signature: Optional[str] = Header(None, alias="request-signature"), + request_id: Optional[str] = Header(None, alias="request-id"), + idempotency_key: Optional[str] = Header(None, alias="idempotency-key"), +): + _require_header("UCP-Agent", ucp_agent) + _require_header("request-signature", request_signature) + _require_header("request-id", request_id) + _require_header("idempotency-key", idempotency_key) + + payload = req.model_dump(mode="json") + try: + existing, req_hash = store.idempotency_check_or_raise(idempotency_key, payload) + if existing is not None: + return existing + except ValueError: + raise HTTPException(status_code=409, detail={"detail": "Idempotency key reused with different parameters", "code": "IDEMPOTENCY_CONFLICT"}) + + checkout = store.checkouts.get(checkout_id) + if not checkout: + raise HTTPException(status_code=404, detail={"detail": "Checkout not found", "code": "NOT_FOUND"}) + + # Validate fulfillment chosen + methods = (checkout.get("fulfillment") or {}).get("methods") or [] + if not methods: + raise HTTPException(status_code=400, detail={"detail": "Fulfillment address and option must be selected before completion.", "code": "INVALID_REQUEST"}) + method = methods[0] + if not method.get("selected_destination_id"): + raise HTTPException(status_code=400, detail={"detail": "Fulfillment address and option must be selected before completion.", "code": "INVALID_REQUEST"}) + groups = method.get("groups") or [] + if not groups or not groups[0].get("selected_option_id"): + raise HTTPException(status_code=400, detail={"detail": "Fulfillment address and option must be selected before completion.", "code": "INVALID_REQUEST"}) + + # Validate payment credential + cred = req.payment_data.credential + if not cred or cred.type != "token": + raise HTTPException(status_code=400, detail={"detail": "Missing credentials in instrument", "code": "INVALID_REQUEST"}) + if cred.token != "success_token": + raise HTTPException(status_code=402, detail={"detail": "Payment authorization failed", "code": "PAYMENT_FAILED"}) + + # Create order + order_id = store.new_id() + permalink_url = f"{settings.server_base_url}/orders/{order_id}" + + # Derive fulfillment expectation from selected option + selected_option = groups[0]["selected_option_id"] + option_title = "Standard Shipping (Free)" if selected_option == "std-ship" else "International Express" + + destination = None + for d in (method.get("destinations") or []): + if d.get("id") == method.get("selected_destination_id"): + destination = d + break + + expectations = [ + { + "id": f"exp_{uuid.uuid4()}", + "line_items": [{"id": li["id"], "quantity": li["quantity"]} for li in checkout["line_items"]], + "method_type": method.get("type"), + "destination": destination or {}, + "description": option_title, + "fulfillable_on": None, + } + ] + + order = { + **_checkout_ucp_envelope(), + "id": order_id, + "checkout_id": checkout_id, + "permalink_url": permalink_url, + "line_items": [ + { + "id": li["id"], + "item": li["item"], + "quantity": {"total": li["quantity"], "fulfilled": 0}, + "totals": li["totals"], + "status": "processing", + "parent_id": None, + } + for li in checkout["line_items"] + ], + "fulfillment": {"expectations": expectations, "events": []}, + "adjustments": None, + "totals": checkout["totals"], + } + + store.orders[order_id] = order + + checkout["status"] = "completed" + checkout["order"] = {"id": order_id, "permalink_url": permalink_url} + store.checkouts[checkout_id] = checkout + + store.put_idempotency(idempotency_key, req_hash, checkout) + return checkout \ No newline at end of file diff --git a/adapters/shopify/python/app/routes/discovery.py b/adapters/shopify/python/app/routes/discovery.py new file mode 100644 index 0000000..88a6e23 --- /dev/null +++ b/adapters/shopify/python/app/routes/discovery.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from fastapi import APIRouter +from ..config import settings + +router = APIRouter() + + +@router.get("/.well-known/ucp") +async def discovery_profile() -> dict: + return { + "ucp": { + "version": settings.ucp_version, + "services": { + "dev.ucp.shopping": { + "version": settings.ucp_version, + "spec": "https://ucp.dev/specs/shopping", + "rest": { + "schema": None, + "endpoint": f"{settings.server_base_url}/", + }, + "mcp": None, + "a2a": None, + } + }, + } + } \ No newline at end of file diff --git a/adapters/shopify/python/app/routes/orders.py b/adapters/shopify/python/app/routes/orders.py new file mode 100644 index 0000000..d80a647 --- /dev/null +++ b/adapters/shopify/python/app/routes/orders.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from fastapi import APIRouter, Header, HTTPException, Path +from typing import Optional +from ..storage import store + + +router = APIRouter() + + +def _require_header(name: str, value: Optional[str]) -> str: + if not value: + raise HTTPException(status_code=400, detail={"detail": f"Missing header: {name}", "code": "INVALID_REQUEST"}) + return value + + +@router.get("/orders/{id}") +async def get_order( + order_id: str = Path(..., alias="id"), + ucp_agent: Optional[str] = Header(None, alias="UCP-Agent"), + request_signature: Optional[str] = Header(None, alias="request-signature"), + request_id: Optional[str] = Header(None, alias="request-id"), +): + _require_header("UCP-Agent", ucp_agent) + _require_header("request-signature", request_signature) + _require_header("request-id", request_id) + + order = store.orders.get(order_id) + if not order: + raise HTTPException(status_code=404, detail={"detail": "Order not found", "code": "NOT_FOUND"}) + return order \ No newline at end of file diff --git a/adapters/shopify/python/app/routes/testing.py b/adapters/shopify/python/app/routes/testing.py new file mode 100644 index 0000000..74f7f05 --- /dev/null +++ b/adapters/shopify/python/app/routes/testing.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import datetime +import uuid +from typing import Optional + +from fastapi import APIRouter, Header, HTTPException, Path +from ..config import settings +from ..storage import store + +router = APIRouter() + + +def _require_header(name: str, value: Optional[str]) -> str: + if not value: + raise HTTPException(status_code=400, detail={"detail": f"Missing header: {name}", "code": "INVALID_REQUEST"}) + return value + + +@router.post("/testing/simulate-shipping/{id}") +async def simulate_shipping( + order_id: str = Path(..., alias="id"), + simulation_secret: Optional[str] = Header(None, alias="Simulation-Secret"), + ucp_agent: Optional[str] = Header(None, alias="UCP-Agent"), + request_signature: Optional[str] = Header(None, alias="request-signature"), + request_id: Optional[str] = Header(None, alias="request-id"), +): + _require_header("UCP-Agent", ucp_agent) + _require_header("request-signature", request_signature) + _require_header("request-id", request_id) + + if simulation_secret != settings.simulation_secret: + raise HTTPException(status_code=403, detail={"detail": "Invalid Simulation-Secret", "code": "FORBIDDEN"}) + + order = store.orders.get(order_id) + if not order: + raise HTTPException(status_code=404, detail={"detail": "Order not found", "code": "NOT_FOUND"}) + + if "fulfillment" not in order or order["fulfillment"] is None: + order["fulfillment"] = {"expectations": [], "events": []} + if "events" not in order["fulfillment"] or order["fulfillment"]["events"] is None: + order["fulfillment"]["events"] = [] + + order["fulfillment"]["events"].append( + { + "id": f"evt_{uuid.uuid4()}", + "type": "shipped", + "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), + } + ) + + store.orders[order_id] = order + return {"status": "shipped"} \ No newline at end of file diff --git a/adapters/shopify/python/app/shopify_client.py b/adapters/shopify/python/app/shopify_client.py new file mode 100644 index 0000000..e4a857e --- /dev/null +++ b/adapters/shopify/python/app/shopify_client.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import List, Optional + +from .models import Product +class MockShopifyClient: + """ + Mock catalog source. Later you'll replace this with a real Storefront API client. + """ + + def __post_init__(self) -> None: + pass + + def list_products(self) -> List[Product]: + return [ + Product(id="bouquet_roses", title="Bouquet of Red Roses", price=3500, currency="USD"), + Product(id="tulips_yellow", title="Yellow Tulips", price=2800, currency="USD"), + Product(id="orchid_white", title="White Orchid", price=4200, currency="USD"), + ] + + def get_product(self, product_id: str) -> Optional[Product]: + for p in self.list_products(): + if p.id == product_id: + return p + return None \ No newline at end of file diff --git a/adapters/shopify/python/app/storage.py b/adapters/shopify/python/app/storage.py new file mode 100644 index 0000000..2f59240 --- /dev/null +++ b/adapters/shopify/python/app/storage.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import hashlib +import json +import time +import uuid +from dataclasses import dataclass +from typing import Any, Dict, Optional, Tuple + + +def _stable_hash(payload: Any) -> str: + raw = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +@dataclass +class IdempotencyRecord: + request_hash: str + response_body: Any + created_at: float + + +class InMemoryStore: + def __init__(self) -> None: + self.checkouts: Dict[str, Dict[str, Any]] = {} + self.orders: Dict[str, Dict[str, Any]] = {} + self.idempotency: Dict[str, IdempotencyRecord] = {} + + def new_id(self, prefix: Optional[str] = None) -> str: + if prefix: + return f"{prefix}_{uuid.uuid4()}" + return str(uuid.uuid4()) + + def get_idempotency(self, key: str) -> Optional[IdempotencyRecord]: + return self.idempotency.get(key) + + def put_idempotency(self, key: str, request_hash: str, response_body: Any) -> None: + self.idempotency[key] = IdempotencyRecord( + request_hash=request_hash, + response_body=response_body, + created_at=time.time(), + ) + + def idempotency_check_or_raise( + self, key: str, payload: Any + ) -> Tuple[Optional[Any], str]: + """ + Returns (existing_response, request_hash). + If key exists but payload hash differs => raise ValueError("IDEMPOTENCY_CONFLICT") + """ + request_hash = _stable_hash(payload) + rec = self.get_idempotency(key) + if rec: + if rec.request_hash != request_hash: + raise ValueError("IDEMPOTENCY_CONFLICT") + return rec.response_body, request_hash + return None, request_hash + + +store = InMemoryStore() \ No newline at end of file diff --git a/adapters/shopify/python/app/ucp_mappers.py b/adapters/shopify/python/app/ucp_mappers.py new file mode 100644 index 0000000..125b0ff --- /dev/null +++ b/adapters/shopify/python/app/ucp_mappers.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Tuple + +from .models import Product + + +def product_to_ucp_item(p: Product) -> Dict[str, Any]: + return { + "id": p.id, + "title": p.title, + "price": p.price, + "image_url": p.image_url, + } + + +def compute_totals(subtotal: int, fulfillment: int, discount: int) -> List[Dict[str, Any]]: + total = max(0, subtotal + fulfillment - discount) + out: List[Dict[str, Any]] = [ + {"type": "subtotal", "display_text": None, "amount": subtotal}, + ] + if fulfillment: + out.append({"type": "fulfillment", "display_text": None, "amount": fulfillment}) + if discount: + out.append({"type": "discount", "display_text": None, "amount": discount}) + out.append({"type": "total", "display_text": None, "amount": total}) + return out + + +def apply_discounts(discount_codes: List[str], subtotal: int) -> Tuple[int, Dict[str, Any]]: + """ + Very simple rule: + - If "10OFF" is present => 10% off subtotal. + """ + applied = [] + discount_amount = 0 + + if "10OFF" in discount_codes: + discount_amount = int(round(subtotal * 0.10)) + applied.append( + { + "code": "10OFF", + "title": "10% Off", + "amount": discount_amount, + "automatic": False, + "method": None, + "priority": None, + "allocations": [{"path": "$.totals[?(@.type=='subtotal')]", "amount": discount_amount}], + } + ) + + return discount_amount, {"codes": discount_codes, "applied": applied} \ No newline at end of file diff --git a/adapters/shopify/python/mcp/README.md b/adapters/shopify/python/mcp/README.md new file mode 100644 index 0000000..0d962c2 --- /dev/null +++ b/adapters/shopify/python/mcp/README.md @@ -0,0 +1,151 @@ +# MCP Server for UCP Shopify Adapter (Mock) + +This MCP server exposes the mock Shopify adapter (`http://127.0.0.1:8183`) as **MCP tools** so agent clients (Claude Desktop, etc.) can perform an end-to-end shopping flow: browse → checkout → set shipping → complete → fetch order → simulate shipping. + +This MCP server is a bridge: it calls the REST adapter under the hood. + +--- + +## Prerequisites + +1. The REST adapter must be running: + +```bash +cd samples/adapters/shopify/python +source .venv/bin/activate +ucp-shopify-adapter +``` + +Default base URL: `http://127.0.0.1:8183` + +--- + +## Install & Run (MCP Server) + +From repo root: + +```bash +cd samples/adapters/shopify/python/mcp +python -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +Run the MCP server: + +```bash +ADAPTER_BASE_URL=http://127.0.0.1:8183 ucp-shopify-adapter-mcp +``` + +If `ADAPTER_BASE_URL` is not set, the MCP server should default to `http://127.0.0.1:8183` (keep it explicit for consistency in demos). + +--- + +## Claude Desktop Configuration + +Add an entry to Claude Desktop config (path varies by OS). On macOS the folder is commonly: + +``` +~/Library/Application Support/Claude/ +``` + +In `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "ucp-shopify-adapter": { + "command": "bash", + "args": ["-lc", "ADAPTER_BASE_URL=http://127.0.0.1:8183 ucp-shopify-adapter-mcp"], + "cwd": "/Users/bilalahmad/ucp_lab/samples/adapters/shopify/python/mcp" + } + } +} +``` + +- `cwd` must be set to your local path of the mcp directory. +- The command uses `bash -lc` so env vars resolve reliably. + +**Restart Claude Desktop after editing the config.** + +--- + +## Available Tools (Expected) + +The tool names below match the end-to-end test that succeeded in Claude Desktop: + +**Product browsing** +- `discovery_profile` +- `list_products` +- `get_product` + +**Checkout & purchase** +- `create_checkout` +- `set_shipping` +- `complete_checkout` + +**Order management** +- `get_order` + +**Testing** +- `simulate_shipping` (requires the correct simulation secret) + +--- + +## Suggested Claude Test Prompt + +Use something like this in Claude: + +1. List products +2. Create a checkout with the cheapest item and discount `10OFF` +3. Set shipping to: + - 10 Downing St, London, SW1A 2AA, GB + - choose `std-ship` +4. Complete checkout with token `success_token` +5. Get order +6. Simulate shipping with `letmein` +7. Get order again and confirm `fulfillment.events` includes a shipped event + +--- + +## Expected Behaviors / Guardrails + +### Simulation secret + +- Correct secret (default `letmein`) → success +- Wrong secret → 403 FORBIDDEN + +### Idempotency + +The REST adapter enforces idempotency: +- Same `idempotency-key` + same payload → same response +- Same `idempotency-key` + different payload → 409 IDEMPOTENCY_CONFLICT + +The MCP tools should surface these failures transparently. + +--- + +## Troubleshooting + +### "Tools show up but calls fail" + +- Confirm the adapter is reachable: + +```bash +curl -s http://127.0.0.1:8183/.well-known/ucp | python -m json.tool +``` + +- Confirm `ADAPTER_BASE_URL` points to the running adapter. + +### Port already in use + +- Adapter uses 8183. Stop the process or change the port in the adapter and update `ADAPTER_BASE_URL`. + +--- + +## Security Note + +This sample includes a testing endpoint: +- `POST /testing/simulate-shipping/{id}` + +It is protected by `Simulation-Secret` and is intended for local testing only. \ No newline at end of file diff --git a/adapters/shopify/python/mcp/pyproject.toml b/adapters/shopify/python/mcp/pyproject.toml new file mode 100644 index 0000000..b841de4 --- /dev/null +++ b/adapters/shopify/python/mcp/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "ucp-shopify-adapter-mcp" +version = "0.1.0" +description = "MCP server for the UCP Shopify Adapter (mock)" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "mcp[cli]>=1.0.0", + "httpx>=0.27", +] + +[project.scripts] +ucp-shopify-adapter-mcp = "ucp_mcp_server:run" \ No newline at end of file diff --git a/adapters/shopify/python/mcp/ucp_mcp_server.py b/adapters/shopify/python/mcp/ucp_mcp_server.py new file mode 100644 index 0000000..4cf7537 --- /dev/null +++ b/adapters/shopify/python/mcp/ucp_mcp_server.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import os +import uuid +from typing import Any, Dict, Optional + +import httpx +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("ucp_shopify_adapter_mock") + +ADAPTER_BASE_URL = os.getenv("ADAPTER_BASE_URL", "http://127.0.0.1:8183").rstrip("/") +DEFAULT_UCP_AGENT = os.getenv("UCP_AGENT", "profile=https://agent.example/profile") +DEFAULT_SIGNATURE = os.getenv("REQUEST_SIGNATURE", "test") + + +def _headers(idempotency_key: Optional[str] = None, simulation_secret: Optional[str] = None) -> Dict[str, str]: + h = { + "UCP-Agent": DEFAULT_UCP_AGENT, + "request-signature": DEFAULT_SIGNATURE, + "request-id": str(uuid.uuid4()), + } + if idempotency_key: + h["idempotency-key"] = idempotency_key + if simulation_secret: + h["Simulation-Secret"] = simulation_secret + return h + + +async def _request(method: str, path: str, *, json: Any = None, headers: Optional[Dict[str, str]] = None) -> Any: + url = f"{ADAPTER_BASE_URL}{path}" + async with httpx.AsyncClient(timeout=30) as client: + r = await client.request(method, url, json=json, headers=headers) + try: + data = r.json() + except Exception: + data = r.text + return {"status_code": r.status_code, "body": data} + + +@mcp.tool() +async def discovery_profile() -> Any: + """Fetch the discovery profile from the adapter.""" + return await _request("GET", "/.well-known/ucp", headers=_headers()) + + +@mcp.tool() +async def list_products() -> Any: + """List products.""" + return await _request("GET", "/products", headers=_headers()) + + +@mcp.tool() +async def get_product(product_id: str) -> Any: + """Get a product.""" + return await _request("GET", f"/products/{product_id}", headers=_headers()) + + +@mcp.tool() +async def create_checkout( + product_id: str, + quantity: int = 1, + currency: str = "USD", + discount_code: Optional[str] = None, + full_name: str = "John Doe", + email: str = "john.doe@example.com", + idempotency_key: Optional[str] = None, +) -> Any: + """Create a checkout session.""" + payload: Dict[str, Any] = { + "buyer": {"full_name": full_name, "email": email}, + "currency": currency, + "line_items": [{"product_id": product_id, "quantity": quantity}], + "discount_codes": ([discount_code] if discount_code else []), + } + idem = idempotency_key or str(uuid.uuid4()) + return await _request("POST", "/checkout-sessions", json=payload, headers=_headers(idem)) + + +@mcp.tool() +async def set_shipping( + checkout_id: str, + line_item_id: str, + selected_option_id: str = "std-ship", + full_name: str = "John Doe", + street_address: str = "10 Downing St", + address_locality: str = "London", + address_region: str = "London", + address_country: str = "GB", + postal_code: str = "SW1A 2AA", + phone_number: str = "+447000000000", + idempotency_key: Optional[str] = None, +) -> Any: + """Set shipping destination + select option.""" + payload: Dict[str, Any] = { + "id": checkout_id, + "fulfillment": { + "methods": [ + { + "type": "shipping", + "line_item_ids": [line_item_id], + "destinations": [ + { + "id": "dest_1", + "street_address": street_address, + "address_locality": address_locality, + "address_region": address_region, + "address_country": address_country, + "postal_code": postal_code, + "full_name": full_name, + "phone_number": phone_number, + } + ], + "selected_destination_id": "dest_1", + "groups": [{"selected_option_id": selected_option_id}], + } + ] + }, + } + idem = idempotency_key or str(uuid.uuid4()) + return await _request("PUT", f"/checkout-sessions/{checkout_id}", json=payload, headers=_headers(idem)) + + +@mcp.tool() +async def complete_checkout( + checkout_id: str, + token: str = "success_token", + idempotency_key: Optional[str] = None, +) -> Any: + """Complete checkout using mock payment token.""" + payload: Dict[str, Any] = { + "payment_data": { + "id": "pi_test_1", + "handler_id": "mock_payment_handler", + "type": "card", + "brand": "VISA", + "last_digits": "1111", + "credential": {"type": "token", "token": token}, + }, + "risk_signals": {}, + } + idem = idempotency_key or str(uuid.uuid4()) + return await _request("POST", f"/checkout-sessions/{checkout_id}/complete", json=payload, headers=_headers(idem)) + + +@mcp.tool() +async def get_order(order_id: str) -> Any: + """Fetch an order.""" + return await _request("GET", f"/orders/{order_id}", headers=_headers()) + + +@mcp.tool() +async def simulate_shipping(order_id: str, simulation_secret: str = "letmein") -> Any: + """Append a shipped event (requires Simulation-Secret).""" + return await _request( + "POST", + f"/testing/simulate-shipping/{order_id}", + json={}, + headers=_headers(simulation_secret=simulation_secret), + ) + + +def run() -> None: + mcp.run(transport="stdio") + + +if __name__ == "__main__": + run() \ No newline at end of file diff --git a/adapters/shopify/python/pyproject.toml b/adapters/shopify/python/pyproject.toml new file mode 100644 index 0000000..377b7f2 --- /dev/null +++ b/adapters/shopify/python/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "ucp-shopify-adapter" +version = "0.1.0" +description = "UCP Shopify adapter (mock) - discovery + shopping endpoints scaffold" +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.27", +] + +[project.scripts] +ucp-shopify-adapter = "app.main:run" + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["app", "app.routes"] diff --git a/rest/python/server/README.md b/rest/python/server/README.md index f6e7c73..7ec1225 100644 --- a/rest/python/server/README.md +++ b/rest/python/server/README.md @@ -104,10 +104,18 @@ uv run simple_happy_path_client.py \ The server exposes an additional endpoint for simulation and testing purposes: -* `POST /testing/simulate-shipping/{id}`: Triggers a simulated "order shipped" - event for the specified order ID. This updates the order status and sends a - webhook notification if configured. This endpoint requires the - `Simulation-Secret` header to match the configured `--simulation_secret`. +* `POST /testing/simulate-shipping/{id}`: Simulates an "order shipped" action for + the specified **order ID** by appending a `shipped` event to + `order.fulfillment.events` and returning `{"status":"shipped"}`. + If a webhook is configured, the server also POSTs a notification with + `event_type: "order_shipped"`. + + **Note:** This endpoint does **not** update `line_items[].status` or + `line_items[].quantity.fulfilled`; it only records a fulfillment event. + Repeated calls append additional `shipped` events. + + This endpoint requires the `Simulation-Secret` header to match the configured + `--simulation_secret`. ## Discovery