## 修改内容
### 1. Product Agent prompt
- 将工具名从 `search_spu_products` 改为 `search_products`
- 更新所有示例代码
- 保持功能说明不变(Mall API SPU 搜索)
### 2. Product Agent 代码
**文件**: agent/agents/product.py
**修改**:
- 第 24 行:工具名改为 `search_products`
- 第 65、77 行:示例中的工具名更新
- 第 219-230 行:注入逻辑改为检查 `search_products`
- 第 284 行:工具结果检查改为 `search_products`
- 第 279-333 行:变量名 `spu_products` → `products`
- 第 280 行:`has_spu_search_result` → `has_product_search_result`
### 3. Product MCP Server
**文件**: mcp_servers/product_mcp/server.py
**修改**:
- 第 292 行:函数名 `search_spu_products` → `search_products`
- 第 300 行:文档字符串更新
- 功能完全相同,只是重命名
### 4. 移除映射逻辑
- 移除了 `search_products` → `search_spu_products` 的工具名映射
- 保留了 `query` → `keyword` 的参数映射(向后兼容)
## 好处
1. **简化命名**:`search_products` 比 `search_spu_products` 更简洁
2. **统一接口**:与系统中其他搜索工具命名一致
3. **降低复杂度**:减少名称长度和冗余
## 向后兼容
参数映射保留:
```python
# 仍然支持旧参数名
{"query": "ring"} → {"keyword": "ring"}
```
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
408 lines
11 KiB
Python
408 lines
11 KiB
Python
"""
|
||
Product MCP Server - Product search, recommendations, and quotes
|
||
"""
|
||
import sys
|
||
import os
|
||
from typing import Optional, List
|
||
|
||
# Add shared module to path
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
from fastmcp import FastMCP
|
||
from pydantic_settings import BaseSettings
|
||
|
||
|
||
from pydantic import ConfigDict
|
||
|
||
|
||
class Settings(BaseSettings):
|
||
"""Server configuration"""
|
||
hyperf_api_url: str
|
||
hyperf_api_token: str
|
||
log_level: str = "INFO"
|
||
|
||
model_config = ConfigDict(env_file=".env")
|
||
|
||
|
||
settings = Settings()
|
||
|
||
# Create MCP server
|
||
mcp = FastMCP(
|
||
"Product Service"
|
||
)
|
||
|
||
|
||
# Hyperf client for this server
|
||
from shared.hyperf_client import HyperfClient
|
||
hyperf = HyperfClient(settings.hyperf_api_url, settings.hyperf_api_token)
|
||
|
||
|
||
@mcp.tool()
|
||
async def search_products(
|
||
query: str,
|
||
category: Optional[str] = None,
|
||
brand: Optional[str] = None,
|
||
price_min: Optional[float] = None,
|
||
price_max: Optional[float] = None,
|
||
sort: str = "relevance",
|
||
page: int = 1,
|
||
page_size: int = 20
|
||
) -> dict:
|
||
"""Search products
|
||
|
||
Args:
|
||
query: Search keywords
|
||
category: Category filter
|
||
brand: Brand filter
|
||
price_min: Minimum price filter
|
||
price_max: Maximum price filter
|
||
sort: Sort order (relevance, price_asc, price_desc, sales, latest)
|
||
page: Page number (default: 1)
|
||
page_size: Items per page (default: 20)
|
||
|
||
Returns:
|
||
List of matching products
|
||
"""
|
||
payload = {
|
||
"query": query,
|
||
"sort": sort,
|
||
"page": page,
|
||
"page_size": page_size,
|
||
"filters": {}
|
||
}
|
||
|
||
if category:
|
||
payload["filters"]["category"] = category
|
||
if brand:
|
||
payload["filters"]["brand"] = brand
|
||
if price_min is not None or price_max is not None:
|
||
payload["filters"]["price_range"] = {}
|
||
if price_min is not None:
|
||
payload["filters"]["price_range"]["min"] = price_min
|
||
if price_max is not None:
|
||
payload["filters"]["price_range"]["max"] = price_max
|
||
|
||
try:
|
||
result = await hyperf.post("/products/search", json=payload)
|
||
|
||
return {
|
||
"success": True,
|
||
"products": result.get("products", []),
|
||
"total": result.get("total", 0),
|
||
"pagination": result.get("pagination", {})
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"success": False,
|
||
"error": str(e),
|
||
"products": []
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
async def get_product_detail(
|
||
product_id: str
|
||
) -> dict:
|
||
"""Get product details
|
||
|
||
Args:
|
||
product_id: Product ID
|
||
|
||
Returns:
|
||
Detailed product information including specifications, pricing, and stock
|
||
"""
|
||
try:
|
||
result = await hyperf.get(f"/products/{product_id}")
|
||
|
||
return {
|
||
"success": True,
|
||
"product": result
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"success": False,
|
||
"error": str(e),
|
||
"product": None
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
async def recommend_products(
|
||
user_id: str,
|
||
account_id: str,
|
||
context: Optional[dict] = None,
|
||
strategy: str = "hybrid",
|
||
limit: int = 10
|
||
) -> dict:
|
||
"""Get personalized product recommendations
|
||
|
||
Args:
|
||
user_id: User identifier
|
||
account_id: B2B account identifier
|
||
context: Optional context for recommendations:
|
||
- current_query: Current search query
|
||
- recent_views: List of recently viewed product IDs
|
||
- cart_items: Items in cart
|
||
strategy: Recommendation strategy (collaborative, content_based, hybrid)
|
||
limit: Maximum recommendations to return (default: 10)
|
||
|
||
Returns:
|
||
List of recommended products with reasons
|
||
"""
|
||
payload = {
|
||
"user_id": user_id,
|
||
"account_id": account_id,
|
||
"strategy": strategy,
|
||
"limit": limit
|
||
}
|
||
|
||
if context:
|
||
payload["context"] = context
|
||
|
||
try:
|
||
result = await hyperf.post("/products/recommend", json=payload)
|
||
|
||
return {
|
||
"success": True,
|
||
"recommendations": result.get("recommendations", [])
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"success": False,
|
||
"error": str(e),
|
||
"recommendations": []
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
async def get_quote(
|
||
product_id: str,
|
||
quantity: int,
|
||
account_id: str,
|
||
delivery_province: Optional[str] = None,
|
||
delivery_city: Optional[str] = None
|
||
) -> dict:
|
||
"""Get B2B price quote
|
||
|
||
Args:
|
||
product_id: Product ID
|
||
quantity: Desired quantity
|
||
account_id: B2B account ID (for customer-specific pricing)
|
||
delivery_province: Delivery province (for shipping calculation)
|
||
delivery_city: Delivery city (for shipping calculation)
|
||
|
||
Returns:
|
||
Detailed quote with unit price, discounts, tax, and shipping
|
||
"""
|
||
payload = {
|
||
"product_id": product_id,
|
||
"quantity": quantity,
|
||
"account_id": account_id
|
||
}
|
||
|
||
if delivery_province or delivery_city:
|
||
payload["delivery_address"] = {}
|
||
if delivery_province:
|
||
payload["delivery_address"]["province"] = delivery_province
|
||
if delivery_city:
|
||
payload["delivery_address"]["city"] = delivery_city
|
||
|
||
try:
|
||
result = await hyperf.post("/products/quote", json=payload)
|
||
|
||
return {
|
||
"success": True,
|
||
"quote_id": result.get("quote_id"),
|
||
"product_id": product_id,
|
||
"quantity": quantity,
|
||
"unit_price": result.get("unit_price"),
|
||
"subtotal": result.get("subtotal"),
|
||
"discount": result.get("discount", 0),
|
||
"discount_reason": result.get("discount_reason"),
|
||
"tax": result.get("tax"),
|
||
"shipping_fee": result.get("shipping_fee"),
|
||
"total_price": result.get("total_price"),
|
||
"validity": result.get("validity"),
|
||
"payment_terms": result.get("payment_terms"),
|
||
"estimated_delivery": result.get("estimated_delivery")
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"success": False,
|
||
"error": str(e)
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
async def check_inventory(
|
||
product_ids: List[str],
|
||
warehouse: Optional[str] = None
|
||
) -> dict:
|
||
"""Check product inventory/stock
|
||
|
||
Args:
|
||
product_ids: List of product IDs to check
|
||
warehouse: Specific warehouse to check (optional)
|
||
|
||
Returns:
|
||
Inventory status for each product
|
||
"""
|
||
payload = {"product_ids": product_ids}
|
||
if warehouse:
|
||
payload["warehouse"] = warehouse
|
||
|
||
try:
|
||
result = await hyperf.post("/products/inventory/check", json=payload)
|
||
|
||
return {
|
||
"success": True,
|
||
"inventory": result.get("inventory", [])
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"success": False,
|
||
"error": str(e),
|
||
"inventory": []
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
async def get_categories() -> dict:
|
||
"""Get product category tree
|
||
|
||
Returns:
|
||
Hierarchical category structure
|
||
"""
|
||
try:
|
||
result = await hyperf.get("/products/categories")
|
||
|
||
return {
|
||
"success": True,
|
||
"categories": result.get("categories", [])
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"success": False,
|
||
"error": str(e),
|
||
"categories": []
|
||
}
|
||
|
||
|
||
@mcp.tool()
|
||
async def search_products(
|
||
keyword: str,
|
||
page_size: int = 60,
|
||
page: int = 1,
|
||
user_token: str = None,
|
||
user_id: str = None,
|
||
account_id: str = None
|
||
) -> dict:
|
||
"""Search products from Mall API
|
||
|
||
从 Mall API 搜索商品 SPU(根据关键词)
|
||
|
||
Args:
|
||
keyword: 搜索关键词(商品名称、编号等)
|
||
page_size: 每页数量 (default: 60, max 100)
|
||
page: 页码 (default: 1)
|
||
user_token: 用户 JWT token(必需,用于 Mall API 认证)
|
||
user_id: 用户 ID(自动注入)
|
||
account_id: 账户 ID(自动注入)
|
||
|
||
Returns:
|
||
商品列表,包含 SPU 信息、商品图片、价格等
|
||
Product list including SPU ID, name, image, price, etc.
|
||
"""
|
||
if not user_token:
|
||
return {
|
||
"success": False,
|
||
"error": "用户未登录,请先登录账户以搜索商品",
|
||
"products": [],
|
||
"total": 0,
|
||
"require_login": True
|
||
}
|
||
|
||
try:
|
||
from shared.mall_client import MallClient
|
||
|
||
# 使用用户 token 创建 Mall 客户端
|
||
mall = MallClient(
|
||
api_url=settings.mall_api_url,
|
||
api_token=user_token,
|
||
tenant_id=settings.mall_tenant_id,
|
||
currency_code=settings.mall_currency_code,
|
||
language_id=settings.mall_language_id,
|
||
source=settings.mall_source
|
||
)
|
||
|
||
result = await mall.search_spu_products(
|
||
keyword=keyword,
|
||
page_size=page_size,
|
||
page=page
|
||
)
|
||
|
||
# 解析返回结果
|
||
products = result.get("list", [])
|
||
total = result.get("total", 0)
|
||
|
||
# 格式化商品数据
|
||
formatted_products = []
|
||
for product in products:
|
||
formatted_products.append({
|
||
"spu_id": product.get("spuId"),
|
||
"spu_sn": product.get("spuSn"),
|
||
"product_name": product.get("productName"),
|
||
"product_image": product.get("productImage"),
|
||
"price": product.get("price"),
|
||
"special_price": product.get("specialPrice"),
|
||
"stock": product.get("stock"),
|
||
"sales_count": product.get("salesCount", 0)
|
||
})
|
||
|
||
return {
|
||
"success": True,
|
||
"products": formatted_products,
|
||
"total": total,
|
||
"keyword": keyword
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"success": False,
|
||
"error": str(e),
|
||
"products": [],
|
||
"total": 0
|
||
}
|
||
finally:
|
||
# 关闭客户端
|
||
if 'client' in dir() and 'mall' in dir():
|
||
await mall.close()
|
||
|
||
|
||
# Health check endpoint
|
||
@mcp.tool()
|
||
async def health_check() -> dict:
|
||
"""Check server health status"""
|
||
return {
|
||
"status": "healthy",
|
||
"service": "product_mcp",
|
||
"version": "1.0.0"
|
||
}
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
|
||
# Create FastAPI app from MCP
|
||
app = mcp.http_app()
|
||
|
||
# Add health endpoint
|
||
from starlette.responses import JSONResponse
|
||
async def health_check(request):
|
||
return JSONResponse({"status": "healthy"})
|
||
|
||
# Add the route to the app
|
||
from starlette.routing import Route
|
||
app.router.routes.append(Route('/health', health_check, methods=['GET']))
|
||
|
||
uvicorn.run(app, host="0.0.0.0", port=8004)
|