使用 AP2 和 UCP 保护智能体商务

1. 概览

在此 Codelab 中,您将运行一个 ADK 代理,该代理使用两种开源商务协议在多个影院商家处预订电影票:

  • UCP(通用商务协议):一种标准,用于让智能体发现商家、搜索目录和管理结账流程。
  • AP2(代理付款协议):一种协议,用于使用经过加密签名的授权书实现安全、可验证的付款授权。

演示版应用 CineAgent 连接到两个具有不同功能(选座、特殊格式和支付方式)的模拟影院商家,并编排从搜索到付款的完整预订流程。

学习内容

  • UCP 如何通过 /.well-known/ucp 资料发现商家
  • ADK 代理如何使用 UCP 搜索目录和创建结账
  • AP2 授权(CartMandate、PaymentMandate)如何保障交易安全
  • 端到端 UCP 和 AP2 协议如何保障智能体电子商务的安全

所需条件

  • 启用了结算功能的 Google Cloud 项目
  • 网络浏览器,例如 Chrome
  • Python 3.11+

此 Codelab 适用于对 Python 和 Google Cloud 有一定了解的中级开发者。完成本 Codelab 大约需要 15 分钟。

本 Codelab 中创建的资源费用应低于 5 美元。

2. 了解 UCP 和 AP2 协议

在深入了解如何构建代理之前,我们先来了解一下使这种安全的代理式商务成为可能的两种协议。

通用商务协议 (UCP)

UCP 可标准化 AI 智能体与商家的互动方式。它通过引入标准化资源模型,解决了代理必须为每个商店学习自定义 API 的问题。

工作原理:

  1. 发现:每个符合 UCP 要求的商家都会在标准位置 /.well-known/ucp 公开其资料。示例:Everlane 的 UCP 端点。当代理读取此配置文件时,它会查找:
    • 功能:商家支持的独立核心功能,例如目录搜索或结账。
    • 服务:用于交换数据的较低级别通信层。示例:REST API、MCP(Model Context Protocol)、A2A(Agent2Agent Protocol)。
    • 扩展程序:如果商家需要特殊行为,可以在此配置文件中定义自定义扩展程序
  2. 操作:发现服务后,代理会使用提供的服务端点来执行操作。在此 Codelab 中,我们将 Model Context Protocol (MCP) 用作服务传输。代理会向此端点发出 JSON-RPC 2.0 调用,以调用发现的功能:搜索商品、创建结账会话和完成购买交易。

UCP 工作流程

Agent Payments Protocol (AP2)

AP2 标准化了代理代表用户授权付款的方式。它解决了代理处理敏感支付凭据的安全问题。

工作原理:

  1. 购物车指令:当代理使用 UCP 协议创建结账时,商家会返回 CartMandate。这是一个 JSON 对象,其中包含购物车详细信息和商家的加密签名。它可作为价格锁定保证。商家在发布此授权后无法更改价格。
  2. 付款授权书:验证购物车的内容后,用户(或代表用户的代理)会创建 PaymentMandate 以授权付款。此 PaymentMandate 引用了 CartMandate,并包含用户的加密签名(或授权令牌)。
  3. 双重签名验证:商家会收到这两份授权书。它们会验证 CartMandate 上的自有签名和 PaymentMandate 上的用户签名。如果两者均有效,则继续执行交易。

这种“双重锁定”系统可确保商家不会过度收费,代理商也不会在未经授权的情况下消费。在生产环境中,这些授权使用 SD-JWT(选择性披露 JWT)来保护用户隐私。

AP2 工作流程

3. 设置您的环境

Google Cloud 项目设置

创建 Google Cloud 项目

  1. Google Cloud 控制台的项目选择器页面上,选择或创建一个 Google Cloud 项目
  2. 确保您的 Cloud 项目已启用结算功能。了解如何检查项目是否已启用结算功能

启动 Cloud Shell

Cloud Shell 是在 Google Cloud 中运行的命令行环境,预加载了必要的工具。

  1. 点击 Google Cloud 控制台顶部的激活 Cloud Shell
  2. 连接到 Cloud Shell 后,验证您的身份验证:
    gcloud auth list
    
  3. 确认您的项目已配置:
    gcloud config get project
    
  4. 如果项目未按预期设置,请进行设置:
    export PROJECT_ID=<YOUR_PROJECT_ID>
    gcloud config set project $PROJECT_ID
    

使用 Gemini 模型

在 Cloud Shell 环境中,复制并粘贴以下命令。这样一来,您就可以访问 Cine Agent 将使用的 Gemini 模型。

export GOOGLE_CLOUD_PROJECT=$PROJECT_ID
export GOOGLE_CLOUD_LOCATION=global
export GOOGLE_GENAI_USE_VERTEXAI=True

设置目录结构

复制并粘贴以下命令,为代理创建一个新目录:

mkdir -p agent_payments
cd agent_payments

安装依赖项

Google Cloud Shell 预先安装了 uv,用于管理环境和依赖项。

  1. agent_payments 文件夹的根目录下创建一个 pyproject.toml 文件,并向其中添加以下内容。此文件定义了项目元数据和依赖项。
[project]
name = "agent-payments-demo"
version = "0.1.0"
description = "CineAgent booking agent using UCP and AP2"
requires-python = ">=3.11"
dependencies = [
    "google-adk>=1.29.0",
    "google-genai>=1.27.0",
    "fastapi>=0.115.0",
    "uvicorn>=0.34.0",
    "httpx>=0.28.0",
    "ap2 @ git+https://github.com/google-agentic-commerce/AP2.git@main",
    "ucp-sdk @ git+https://github.com/Universal-Commerce-Protocol/python-sdk.git@main",
]
  1. 运行以下命令以创建虚拟环境并安装所有依赖项:
uv sync
  1. 激活由 uv 创建的虚拟环境:
source .venv/bin/activate

4. 定义代理

在编写任何工具逻辑之前,我们先在一个名为 agent.py 的文件中定义代理本身。此代理将充当电影预订流程的编排器。

创建 agent.py

"""CineAgent — movie ticket booking agent using UCP and AP2."""

from google.adk.agents import Agent
from google.adk.tools import FunctionTool
from agent_payments.tools import (
    discover_theaters,
    search_movies,
    get_movie_detail,
    create_checkout,
    complete_purchase,
)

root_agent = Agent(
    model="gemini-3.1-pro-preview",
    name="cineagent",
    description="Movie ticket booking agent using UCP and AP2.",
    instruction="""You are CineAgent, a movie ticket booking assistant.

You help users find and book movie tickets across multiple theaters
using UCP (Universal Commerce Protocol) and AP2 (Agent Payments).

**Your tools:**
- discover_theaters: Find theaters and what they support
- search_movies: Search movies across all theaters
- get_movie_detail: Get showtimes at a specific theater
- create_checkout: Start a checkout session
- complete_purchase: Finalize with AP2 mandate signing

**Rules:**
- Always call discover_theaters first if you haven't yet
- Keep responses concise — summarize and suggest next steps
- Prices from tools are in cents (1500 = $15.00)
- Never invent data — only state what tools return
""",
    tools=[
        discover_theaters,
        search_movies,
        get_movie_detail,
        create_checkout,
        FunctionTool(complete_purchase, require_confirmation=True),
    ],
)

代码说明

我们来详细了解一下此代理定义中发生了什么:

  • model="gemini-3.1-pro-preview":我们正在使用最新的 Gemini Pro 预览版模型,以进行复杂的推理和工具使用。
  • instruction:用于指导代理行为的提示。它明确告知代理使用 UCP 和 AP2,列出了可用的工具,并设置了“绝不编造数据”和“价格以美分为单位”等关键规则。
  • tools:这是一个 Python 函数列表(我们将在下一步中构建),智能体可以根据用户的请求选择调用这些函数。
  • require_confirmation:您可以使用 FunctionTool(my_function,require_confirmation=True) 封装任何工具。触发后,智能体会暂停并等待用户简单地回答“是”或“否”以表示批准,然后才会执行工具。在此示例中,在执行 complete_purchase 工具之前,代理会暂停,等待人工确认。

工具列表

代理定义声明了我们需要构建的内容。每种工具都映射到 UCP 或 AP2 协议中的特定操作:

工具

作用

协议操作

discover_theaters

查找商家及其支持情况

查询 /.well-known/ucp

search_movies

搜索不同商家的目录

JSON-RPC 到 MCP 端点

get_movie_detail

获取特定商家的放映时间

JSON-RPC 到 MCP 端点

create_checkout

启动结账会话

JSON-RPC 到 MCP 端点

complete_purchase

授权付款并完成订单

签署 AP2 授权书并将其发送给 MCP

Gemini 模型会根据对话内容决定何时调用每种工具。接下来,我们需要在 tools.py 中实现每种工具的功能。

5. 构建智能体工具:发现和浏览

现在,我们来实现智能体将用于浏览和发现电影的工具。每个工具都封装了一个 UCP 操作。

创建一个名为 tools.py 的新文件,然后复制并粘贴以下代码:

"""Agent tools — each one wraps a UCP or AP2 operation."""

import asyncio
import json

from .ucp import UCPClient
from .ap2 import AP2Handler

# Initialize clients directly
_merchant_urls = ["http://localhost:8081", "http://localhost:8082"]
_ucp = UCPClient()
_ap2 = AP2Handler()

了解设置

为了让此 Codelab 专注于构建智能体,我们使用了两个辅助类 UCPClientAP2Handler,我们将在后面的步骤中介绍这两个类。

  • 什么是退出页横幅广告?:这些是我们为此 Codelab 手动编写的辅助类,用于模拟与模拟商家的互动。由于官方 UCP 和 AP2 SDK 尚未推出,我们使用这些辅助程序来弥合差距。在生产环境中,您可以使用正式版 SDK(如有)。
  • 目前,请将它们视为辅助对象
    • _ucp.discover(url):提取商家的个人资料。
    • _ucp.mcp_call(url, method, params):向商家的 MCP 端点发送 JSON-RPC 2.0 请求。

探索影院

此工具是 UCP 流程中的第一步。它会查找有哪些商家以及他们支持哪些功能。

添加到 tools.py

async def discover_theaters() -> str:
    """Discover available theater merchants and their capabilities via UCP."""
    theaters = []
    for url in _merchant_urls:
        info = await _ucp.discover(url)
        theaters.append(
            {
                "url": url,
                "name": info["name"],
                "capabilities": info["capabilities"],
                "payment_handlers": info["payment_handlers"],
            }
        )
    return json.dumps(theaters, indent=2)

作用

  1. 它会遍历服务器提供的商家网址列表。在此 Codelab 中,我们将在后面的部分中设置两个模拟商家。
  2. 对于每个网址,它都会调用 _ucp.discover(url),从而命中 /.well-known/ucp 端点。
  3. 它会将名称、功能和付款处理程序收集到摘要列表中。
  4. 它会以 JSON 字符串的形式返回该列表,供代理读取。

搜索电影

此工具会搜索所有已发现的商家,并合并搜索结果。这一点至关重要,因为同一部电影可能会在多个影院上映,但格式(IMAX、Dolby)和价格各不相同。

添加到 tools.py

async def search_movies(query: str = "") -> str:
    """Search for movies across all theaters. Use '' to browse all."""
    all_movies = {}
    for url, merchant in _ucp.merchants.items():
        result = await _ucp.mcp_call(url, "search_catalog", {"query": query})
        for product in result.get("products", []):
            mid = product["id"]
            if mid not in all_movies:
                all_movies[mid] = {
                    "id": mid,
                    "title": product["title"],
                    "categories": product.get("categories", []),
                    "theaters": {},
                }
            showtimes = []
            for v in product.get("variants", []):
                opts = {
                    o["name"]: o["value"]
                    for o in v.get("selected_options", [])
                }
                showtimes.append(
                    {
                        "id": v["id"],
                        "format": opts.get("format", "Standard"),
                        "time": opts.get("time", ""),
                        "price": v.get("price", {}),
                        "seats": v.get("availability", {}).get(
                            "seats_available", 0
                        ),
                    }
                )
            all_movies[mid]["theaters"][url] = {
                "name": merchant["name"],
                "showtimes": showtimes,
            }
    return json.dumps(list(all_movies.values()), indent=2)

作用

  1. 它会遍历所有已发现的影院。
  2. 它会向商家的 MCP 端点发送搜索目录 JSON-RPC 请求 (_ucp.mcp_call(url, "search_catalog", {"query": query}))。
  3. 然后,它会进行一些清理工作,以解析结果,找到电影及其“变体”(表示具体的放映时间和格式)。按 ID 对电影进行分组,以便用户不会看到重复的电影条目。

获取电影详情

此工具可获取特定影院中特定电影的完整目录详细信息。

添加到 tools.py

async def get_movie_detail(movie_id: str, merchant_url: str) -> str:
    """Get detailed showtimes for a movie at a specific theater."""
    result = await _ucp.mcp_call(
        merchant_url, "lookup_catalog", {"product_id": movie_id}
    )
    return json.dumps(result, indent=2)

作用

  • 它会针对商家的 MCP 端点调用 lookup_catalog 方法,并传递特定的 movie_id。这样一来,您就可以获得该影院的详细信息,例如放映时间和座位空闲情况。

6. 构建智能体工具:结账和付款

这些工具可处理结账和购买流程。这时,AP2 协议便可发挥作用,确保交易安全。

创建结账

此工具会在特定影院针对特定放映时间开始结账会话。

添加到 tools.py

async def create_checkout(
    merchant_url: str, showtime_id: str, quantity: int = 1
) -> str:
    """Create a checkout session for tickets at a theater."""
    result = await _ucp.mcp_call(merchant_url, "create_checkout", {
        "checkout": {
            "line_items": [
                {"item": {"id": showtime_id}, "quantity": quantity}
            ],
            "context": {"country": "US", "currency": "USD"},
        }
    })
    return json.dumps(result, indent=2)

作用

  1. 它会调用商家 MCP 端点上的 create_checkout 方法。
  2. 它会传递用户请求的 showtime_idquantity
  3. 商家会返回包含 AP2 CartMandate 的 JSON 对象。

注意:如果您检查响应数据,会发现 ap2.cart_mandate 包含 merchant_authorization 字段。这是商家锁定报价的加密签名。他们以后无法更改!

完成购买

此工具会执行以下三项操作:

  1. 我们从结账流程中获取 CartMandate 并对其进行验证(模拟)
  2. 创建并签署 PaymentMandate (模拟)
  3. 将已签名的授权书发送给商家,以完成购买交易。

添加到 tools.py

async def complete_purchase(
    checkout_id: str, merchant_url: str, payment_method: str = "card"
) -> str:
    """Complete purchase with AP2 payment authorization."""
    # 1. Get the CartMandate from the checkout
    checkout = await _ucp.mcp_call(
        merchant_url, "get_checkout", {"checkout": {"id": checkout_id}}
    )
    cart_mandate = _ap2.process_cart_mandate(checkout)
    if not cart_mandate:
        return {"error": "No cart mandate — checkout may have expired"}

    # 2-3. Create and sign the PaymentMandate
    # In production, this call would trigger a user prompt (biometric or device auth)
    # via the AP2 Wallet SDK. In this demo, it just computes a mock SHA-256 hash.
    payment_mandate = _ap2.create_payment_mandate(cart_mandate, payment_method)

    # 4. Send both mandates to complete the purchase
    result = await _ucp.mcp_call(merchant_url, "complete_checkout", {
        "checkout": {
            "id": checkout_id,
            "payment": {
                "instruments": [{
                    "handler_id": f"card_{merchant_url.split(':')[-1]}",
                    "type": "card",
                }],
            },
            "ap2": {"payment_mandate": payment_mandate},
        }
    })
    return json.dumps(result, indent=2)

为什么需要两份授权书?CartMandate 可确保商家在报价后无法更改价格。PaymentMandate 可确保代理在未经用户同意的情况下无法向用户扣款。流程如下:

Merchant locks price -> User authorizes charge -> Merchant verifies both -> Order completes

检查点:完整 tools.py

完整的 tools.py 现在应包含 5 个工具函数,以及针对 UCP 和 AP2 客户端的模块级初始化。验证它是否如下所示:

"""Agent tools — each one wraps a UCP or AP2 operation."""

import asyncio
import json

from ucp import UCPClient
from ap2 import AP2Handler

# Initialize clients directly
_merchant_urls = ["http://localhost:8081", "http://localhost:8082"]
_ucp = UCPClient()
_ap2 = AP2Handler()


async def discover_theaters() -> str:
    """Discover available theater merchants and their capabilities via UCP."""
    theaters = []
    for url in _merchant_urls:
        info = await _ucp.discover(url)
        theaters.append(
            {
                "url": url,
                "name": info["name"],
                "capabilities": info["capabilities"],
                "payment_handlers": info["payment_handlers"],
            }
        )
    return json.dumps(theaters, indent=2)


async def search_movies(query: str = "") -> str:
    """Search for movies across all theaters. Use '' to browse all."""
    all_movies = {}
    for url, merchant in _ucp.merchants.items():
        result = await _ucp.mcp_call(url, "search_catalog", {"query": query})
        for product in result.get("products", []):
            mid = product["id"]
            if mid not in all_movies:
                all_movies[mid] = {
                    "id": mid,
                    "title": product["title"],
                    "categories": product.get("categories", []),
                    "theaters": {},
                }
            showtimes = []
            for v in product.get("variants", []):
                opts = {
                    o["name"]: o["value"]
                    for o in v.get("selected_options", [])
                }
                showtimes.append(
                    {
                        "id": v["id"],
                        "format": opts.get("format", "Standard"),
                        "time": opts.get("time", ""),
                        "price": v.get("price", {}),
                        "seats": v.get("availability", {}).get(
                            "seats_available", 0
                        ),
                    }
                )
            all_movies[mid]["theaters"][url] = {
                "name": merchant["name"],
                "showtimes": showtimes,
            }
    return json.dumps(list(all_movies.values()), indent=2)


async def get_movie_detail(movie_id: str, merchant_url: str) -> str:
    """Get detailed showtimes for a movie at a specific theater."""
    result = await _ucp.mcp_call(
        merchant_url, "lookup_catalog", {"product_id": movie_id}
    )
    return json.dumps(result, indent=2)


async def create_checkout(
    merchant_url: str, showtime_id: str, quantity: int = 1
) -> str:
    """Create a checkout session for tickets at a theater."""
    result = await _ucp.mcp_call(merchant_url, "create_checkout", {
        "checkout": {
            "line_items": [
                {"item": {"id": showtime_id}, "quantity": quantity}
            ],
            "context": {"country": "US", "currency": "USD"},
        }
    })
    return json.dumps(result, indent=2)


async def complete_purchase(
    checkout_id: str, merchant_url: str, payment_method: str = "card"
) -> str:
    """Complete purchase with AP2 payment authorization."""
    # 1. Get the CartMandate from the checkout
    checkout = await _ucp.mcp_call(
        merchant_url, "get_checkout", {"checkout": {"id": checkout_id}}
    )
    cart_mandate = _ap2.process_cart_mandate(checkout)
    if not cart_mandate:
        return {"error": "No cart mandate — checkout may have expired"}

    # 2-3. Create and sign the PaymentMandate
    # In production, this call would trigger a user prompt (biometric or device auth)
    # via the AP2 Wallet SDK. In this demo, it just computes a mock SHA-256 hash.
    payment_mandate = _ap2.create_payment_mandate(cart_mandate, payment_method)

    # 4. Send both mandates to complete the purchase
    result = await _ucp.mcp_call(merchant_url, "complete_checkout", {
        "checkout": {
            "id": checkout_id,
            "payment": {
                "instruments": [{
                    "handler_id": f"card_{merchant_url.split(':')[-1]}",
                    "type": "card",
                }],
            },
            "ap2": {"payment_mandate": payment_mandate},
        }
    })
    return json.dumps(result, indent=2)

7. 使其可运行

对于真实的 UCP 商家,您只需将智能体指向其网址即可。在此 Codelab 中,我们需要以下两项内容才能在本地进行测试:

  1. 模拟商家 - 模拟 UCP 端点的本地服务器,以便您进行测试
  2. 协议辅助程序 - UCP 和 AP2 的精简 HTTP 封装容器(在正式版中,官方 SDK 会替换这些封装容器)

注意:您无需仔细阅读此代码。这些文件模拟了真实的基础设施和 SDK 将提供的功能。按原样复制。

协议帮助程序

UCP 和 AP2 尚无客户端 SDK,这两个文件负责处理 HTTP 管道。

创建 ucp.py

"""UCP client — discovers merchants and calls their MCP tools."""

import uuid
import httpx


class UCPClient:
    def __init__(self):
        self.client = httpx.AsyncClient(timeout=30)
        self.merchants = {}  # url -> merchant info dict

    async def discover(self, merchant_url: str) -> dict:
        """Fetch a merchant's UCP profile from /.well-known/ucp."""
        resp = await self.client.get(f"{merchant_url}/.well-known/ucp")
        resp.raise_for_status()
        profile = resp.json()
        ucp = profile["ucp"]
        info = {
            "name": merchant_url.split("//")[-1],
            "mcp_endpoint": ucp["services"]["dev.ucp.shopping"][0]["endpoint"],
            "capabilities": list(ucp.get("capabilities", {}).keys()),
            "payment_handlers": list(ucp.get("payment_handlers", {}).keys()),
        }
        self.merchants[merchant_url] = info
        return info

    async def mcp_call(
        self, merchant_url: str, tool_name: str, arguments: dict
    ) -> dict:
        """Call a merchant's MCP tool via JSON-RPC 2.0."""
        merchant = self.merchants[merchant_url]
        resp = await self.client.post(
            merchant["mcp_endpoint"],
            json={
                "jsonrpc": "2.0",
                "id": uuid.uuid4().hex,
                "method": "tools/call",
                "params": {"name": tool_name, "arguments": arguments},
            },
        )
        resp.raise_for_status()
        data = resp.json()
        if "error" in data:
            raise Exception(f"MCP error: {data['error']}")
        return data.get("result", {})

    async def close(self):
        await self.client.aclose()

创建 ap2.py

"""AP2 mandate handler — creates and signs payment mandates."""

import uuid
import hashlib


class AP2Handler:
    def process_cart_mandate(self, checkout_response: dict) -> dict | None:
        """Extract the merchant-signed CartMandate from a checkout response.

        The CartMandate is the merchant's cryptographic price guarantee —
        it locks the total so it can't change between checkout and payment.
        """
        return checkout_response.get("ap2", {}).get("cart_mandate")

    def create_payment_mandate(
        self, cart_mandate: dict, payment_method: str = "card"
    ) -> dict:
        """Create and sign a PaymentMandate authorizing payment.

        References the merchant's CartMandate and adds user authorization.
        Together they form a two-party agreement: merchant guarantees price,
        user authorizes charge.
        """
        contents = cart_mandate["contents"]
        mandate_id = uuid.uuid4().hex

        return {
            "mandate_id": mandate_id,
            "cart_reference": contents["id"],
            "merchant": contents["merchant_name"],
            "total": contents["total"],
            "payment_method": payment_method,
            "user_authorization": self._sign(mandate_id, contents["id"]),
        }

    def _sign(self, mandate_id: str, checkout_id: str) -> str:
        """Sign the mandate. Production uses real crypto (sd-jwt-vc)."""
        payload = f"{mandate_id}:{checkout_id}"
        return hashlib.sha256(payload.encode()).hexdigest()

模拟商家

创建 merchants.py

"""Mock UCP merchant servers — two theaters with different capabilities."""

import uuid
import time
import multiprocessing
from datetime import datetime, timezone, timedelta

import uvicorn
from fastapi import FastAPI

# ── Theater data ────────────────────────────────────────────

THEATERS = {
    8081: {
        "name": "Meridian Cinemas",
        "movies": [
            {
                "id": "opp",
                "title": "Oppenheimer",
                "categories": ["Drama", "History"],
                "showtimes": [
                    {"id": "st_opp_7pm_imax", "format": "IMAX", "time": "7:00 PM", "price": 2200, "seats": 45},
                    {"id": "st_opp_930pm", "format": "Standard", "time": "9:30 PM", "price": 1500, "seats": 80},
                ],
            },
            {
                "id": "dune3",
                "title": "Dune: Part Three",
                "categories": ["Sci-Fi", "Action"],
                "showtimes": [
                    {"id": "st_dune_8pm_imax", "format": "IMAX", "time": "8:00 PM", "price": 2200, "seats": 30},
                ],
            },
        ],
        "discounts": {},
    },
    8082: {
        "name": "StarLight Theaters",
        "movies": [
            {
                "id": "opp",
                "title": "Oppenheimer",
                "categories": ["Drama", "History"],
                "showtimes": [
                    {"id": "st_opp_6pm_atmos", "format": "Dolby Atmos", "time": "6:00 PM", "price": 1800, "seats": 60},
                ],
            },
            {
                "id": "spider",
                "title": "Spider-Verse",
                "categories": ["Animation", "Action"],
                "showtimes": [
                    {"id": "st_spider_4pm", "format": "Standard", "time": "4:00 PM", "price": 1200, "seats": 100},
                ],
            },
        ],
        "discounts": {},
    },
}


def create_app(port):
    theater = THEATERS[port]
    app = FastAPI()
    sessions = {}

    # ── UCP Discovery endpoint ──────────────────────────────
    @app.get("/.well-known/ucp")
    def discovery():
        caps = {
            "dev.ucp.shopping.catalog.search": [{"version": "2026-01-15"}],
            "dev.ucp.shopping.catalog.lookup": [{"version": "2026-01-15"}],
            "dev.ucp.shopping.checkout": [{"version": "2026-01-15"}],
            "dev.ucp.shopping.ap2_mandate": [{"version": "2026-01-15"}],
        }

        return {
            "ucp": {
                "version": "2026-01-15",
                "services": {
                    "dev.ucp.shopping": [
                        {"version": "2026-01-15", "transport": "mcp",
                         "endpoint": f"http://localhost:{port}/mcp"}
                    ]
                },
                "capabilities": caps,
                "payment_handlers": {
                    "com.example.card": [
                        {"id": f"card_{port}", "version": "2026-01-15",
                         "available_instruments": [{"type": "card"}], "config": {}}
                    ]
                },
            }
        }

    # ── MCP JSON-RPC endpoint ───────────────────────────────
    @app.post("/mcp")
    def mcp(body: dict):
        tool = body["params"]["name"]
        args = body["params"].get("arguments", {})
        rid = body.get("id", "1")

        if tool == "search_catalog":
            q = args.get("query", "").lower()
            hits = [m for m in theater["movies"]
                    if not q or q in m["title"].lower()
                    or any(q in c.lower() for c in m["categories"])]
            return _ok(rid, {"products": [_product(m) for m in hits]})

        if tool == "lookup_catalog":
            mid = args.get("product_id") or (args.get("ids", [None])[0])
            movie = next((m for m in theater["movies"] if m["id"] == mid), None)
            if not movie:
                return _err(rid, "Not found")
            return _ok(rid, {"products": [_product(movie)]})

        if tool == "create_checkout":
            co = args.get("checkout", {})
            sid = f"chk_{uuid.uuid4().hex[:12]}"
            items, subtotal = [], 0
            for li in co.get("line_items", []):
                st = _find_showtime(li["item"]["id"])
                if not st:
                    continue
                mv = _find_movie(li["item"]["id"])
                qty = li.get("quantity", 1)
                amt = st["price"] * qty
                subtotal += amt
                items.append({
                    "id": f"li_{uuid.uuid4().hex[:8]}",
                    "item": {
                        "id": st["id"],
                        "title": f"{mv['title']}{st['format']} {st['time']}",
                        "price": st["price"],
                    },
                    "quantity": qty,
                    "totals": [{"type": "subtotal", "amount": amt}],
                })
            tax = int(subtotal * 0.08)
            total = subtotal + tax
            session = {
                "id": sid,
                "status": "ready_for_complete",
                "currency": "USD",
                "line_items": items,
                "totals": [
                    {"type": "subtotal", "display_text": "Subtotal", "amount": subtotal},
                    {"type": "tax", "display_text": "Tax", "amount": tax},
                    {"type": "total", "display_text": "Total", "amount": total},
                ],
                "metadata": {"theater_name": theater["name"]},
                "ap2": {
                    "cart_mandate": {
                        "contents": {
                            "id": sid,
                            "merchant_name": theater["name"],
                            "total": {
                                "label": "Total",
                                "amount": {"currency": "USD", "value": total / 100},
                            },
                            "cart_expiry": (
                                datetime.now(timezone.utc) + timedelta(minutes=10)
                            ).isoformat(),
                        },
                        "merchant_authorization": f"mock_merchant_sig_{sid}",
                    }
                },
            }
            sessions[sid] = session
            return _ok(rid, session)

        if tool == "get_checkout":
            sid = args.get("checkout", {}).get("id") or args.get("id")
            return _ok(rid, sessions.get(sid, {"error": "not_found"}))



        if tool == "complete_checkout":
            co = args.get("checkout", {})
            sid = co.get("id")
            session = sessions.get(sid)
            if not session:
                return _err(rid, "Not found")
            session["status"] = "completed"
            session["order"] = {
                "id": f"ord_{uuid.uuid4().hex[:8]}",
                "created_at": datetime.now(timezone.utc).isoformat(),
                "tickets": [
                    {
                        "movie": li["item"]["title"],
                        "quantity": li["quantity"],
                        "ticket_code": uuid.uuid4().hex[:8].upper(),
                    }
                    for li in session["line_items"]
                ],
            }
            session["ap2"]["payment_mandate_verified"] = True
            return _ok(rid, session)

        return _err(rid, f"Unknown tool: {tool}")

    def _ok(rid, result):
        return {"jsonrpc": "2.0", "id": rid, "result": result}

    def _err(rid, msg):
        return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32000, "message": msg}}

    def _product(movie):
        return {
            "id": movie["id"],
            "title": movie["title"],
            "categories": movie["categories"],
            "variants": [
                {
                    "id": st["id"],
                    "selected_options": [
                        {"name": "format", "value": st["format"]},
                        {"name": "time", "value": st["time"]},
                    ],
                    "price": {"amount": st["price"], "currency": "USD"},
                    "availability": {"available": True, "seats_available": st["seats"]},
                }
                for st in movie["showtimes"]
            ],
        }

    def _find_showtime(sid):
        return next(
            (st for m in theater["movies"] for st in m["showtimes"] if st["id"] == sid),
            None,
        )

    def _find_movie(sid):
        return next(
            (m for m in theater["movies"] for st in m["showtimes"] if st["id"] == sid),
            None,
        )

    return app


def _run(port):
    uvicorn.run(create_app(port), host="0.0.0.0", port=port, log_level="warning")


if __name__ == "__main__":
    for port in THEATERS:
        multiprocessing.Process(target=_run, args=(port,), daemon=True).start()
    print("Merchants running: Meridian (:8081), StarLight (:8082)")
    print("Press Ctrl+C to stop")
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        pass

这会创建两个 FastAPI 服务器,每个服务器都有两个端点:

  • GET /.well-known/ucp - UCP 发现。返回商家的功能、MCP 端点网址和可接受的付款方式。
  • POST /mcp - MCP(Model Context Protocol)操作。处理目录搜索、结账、折扣和付款的 JSON-RPC 2.0 调用。

新终端中启动商家,它们需要保持运行状态:

cd agent_payments
source .venv/bin/activate
python merchants.py

您应该会看到:

Merchants running: Meridian (:8081), StarLight (:8082)

返回第一个终端,验证 UCP 发现:

curl -s http://localhost:8081/.well-known/ucp | python -m json.tool

您应该会看到商家的功能、MCP 端点网址和付款处理程序。

8. 使用 ADK Web 运行智能体

我们来使用 ADK CLI 的内置 Web 界面!这样一来,您就可以在浏览器中使用聊天界面,并自动处理工具确认提示。

您的项目现在应如下所示:

agent_payments/
├── merchants.py      # Mock UCP merchants
├── ucp.py            # UCP client helper
├── ap2.py            # AP2 mandate handler
├── tools.py          # Agent tools
├── agent.py          # Agent definition
└── pyproject.toml

试用一下

在当前终端中,前往父级目录(agent_payments 上一级的文件夹),然后启动 ADK Web 界面:

cd ../
adk web --allow_origins '*'

您应该会看到输出,表明服务器正在运行,并提供一个网址(通常为 http://localhost:8000 或类似网址)。

与客服人员对话

  1. 在浏览器中打开 adk web 提供的网址。
  2. 您将看到一个聊天界面。
  3. 不妨尝试问:“有哪些电影正在上映?”
  4. 代理会在后台发现影院并搜索目录,然后通过 UCP 汇总来自两个商家的结果。
  5. 要求预订电影票:“预订 2 张晚上 7 点的《奥本海默》电影票”
  6. 当代理尝试调用 complete_purchase 时,请注意 ADK Web 界面如何弹出确认对话框或卡片!
  7. 如需授权交易,请在对话中回复以下 JSON 字符串:{"confirmed": true}
  8. 客服人员将完成购买交易,并向您返回包含门票代码的订单确认信息!

幕后花絮:

  1. create_checkout → 商家返回了 AP2 CartMandate(已签名的价格锁定)
  2. complete_purchase → 创建了 PaymentMandate,对其进行了签名(模拟 SHA-256),并将这两个授权发送给了商家
  3. 商家验证了两个签名 → 已签发票证(在模拟中)

9. 清理

为避免本地服务器持续运行,请清理资源:

  1. 在运行 adk web 的终端中,按 Ctrl+C 停止代理服务器。
  2. 在运行 python merchants.py 的终端中,按 Ctrl+C 停止模拟商家。
  3. 在两个终端中运行以下命令,停用虚拟环境:
deactivate
  1. (可选)如果您为此 Codelab 创建了一个新的 Google Cloud 项目,并想将其删除,请运行以下命令:
gcloud projects delete $GOOGLE_CLOUD_PROJECT

10. 恭喜!🎉

您构建了一个 ADK 代理,该代理使用 UCP 和 AP2 发现商家、浏览目录并完成购买交易。

要点回顾

在此 Codelab 中,您构建了一个可处理安全商务流程的 ADK 代理。下面简要介绍了您构建的内容以及应用的关键概念:

您构建的内容

  • 5 个代理工具,封装了 UCP 和 AP2 操作 - 发现、目录搜索、结账、付款。
  • AP2 授权书签署 - CartMandate(商家价格锁定)+ PaymentMandate(用户授权)。
  • 多商家搜索 - 一个代理查询多个影院,合并结果。

主要概念

协议

作用

运作方式

UCP 探索

代理查找商家及其功能

/.well-known/ucp → 功能、MCP 端点、支付方式

UCP MCP

智能体浏览目录、创建结账

对商家的 MCP 端点的 JSON-RPC 2.0 调用

AP2 CartMandate

商家锁定报价

由商家签名,包含总金额和到期日期

AP2 PaymentMandate

用户授权扣款

由用户签名,引用了 CartMandate

生产环境中有何不同?

此 Codelab 使用模拟对象。正式版:

  • UCP 发现针对注册表进行解析,而不是针对硬编码的 localhost 网址
  • MCP 端点由真实商家托管 - 采用相同的 JSON-RPC 2.0 协议,提供真实的广告资源
  • AP2 强制执行使用 sd-jwt-vc 进行签名,而不是使用 SHA-256 哈希值
  • 付款授权使用 AP2 Wallet SDK,并会显示用户同意提示
  • 前端以丰富的界面(商品网格、结账摘要、确认卡片)呈现工具结果

后续步骤

  • 探索 AP2 协议规范并构建自己的支持付款的智能体
  • 为商家实现 UCP 以启用智能体商务
  • 将 AP2 与 A2A 搭配使用,实现多智能体商业工作流
  • 了解加密货币付款的 AP2 x402 扩展程序

参考文档