feat: 初始化 B2B AI Shopping Assistant 项目

- 配置 Docker Compose 多服务编排
- 实现 Chatwoot + Agent 集成
- 配置 Strapi MCP 知识库
- 支持 7 种语言的 FAQ 系统
- 实现 LangGraph AI 工作流

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
wl
2026-01-14 19:25:22 +08:00
commit 3ad6eee0d9
59 changed files with 8078 additions and 0 deletions

0
mcp_servers/__init__.py Normal file
View File

View File

@@ -0,0 +1,29 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Note: shared modules are mounted via docker-compose volumes
# Expose port
EXPOSE 8003
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8003/health || exit 1
# Run the application
CMD ["python", "server.py"]

View File

View File

@@ -0,0 +1,15 @@
# FastMCP Framework
fastmcp>=0.1.0
# HTTP Client
httpx>=0.26.0
# Data Validation
pydantic>=2.5.0
pydantic-settings>=2.1.0
# Environment & Config
python-dotenv>=1.0.0
# Logging
structlog>=24.1.0

View File

@@ -0,0 +1,299 @@
"""
Aftersale MCP Server - Returns, exchanges, and complaints
"""
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(
"Aftersale 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 apply_return(
order_id: str,
user_id: str,
items: List[dict],
description: str,
images: Optional[List[str]] = None
) -> dict:
"""Apply for a return
Args:
order_id: Order ID
user_id: User identifier
items: List of items to return, each with:
- item_id: Order item ID
- quantity: Quantity to return
- reason: Return reason (quality_issue, wrong_item, not_as_described, etc.)
description: Detailed description of the issue
images: Optional list of image URLs showing the issue
Returns:
Return application result with aftersale ID
"""
payload = {
"order_id": order_id,
"user_id": user_id,
"items": items,
"description": description
}
if images:
payload["images"] = images
try:
result = await hyperf.post("/aftersales/return", json=payload)
return {
"success": True,
"aftersale_id": result.get("aftersale_id"),
"status": result.get("status"),
"estimated_refund": result.get("estimated_refund"),
"process_steps": result.get("process_steps", []),
"message": "Return application submitted successfully"
}
except Exception as e:
return {
"success": False,
"error": str(e)
}
@mcp.tool()
async def apply_exchange(
order_id: str,
user_id: str,
items: List[dict],
description: str
) -> dict:
"""Apply for an exchange
Args:
order_id: Order ID
user_id: User identifier
items: List of items to exchange, each with:
- item_id: Order item ID
- reason: Exchange reason
- new_specs: Optional new specifications (size, color, etc.)
description: Detailed description of the issue
Returns:
Exchange application result with aftersale ID
"""
try:
result = await hyperf.post(
"/aftersales/exchange",
json={
"order_id": order_id,
"user_id": user_id,
"items": items,
"description": description
}
)
return {
"success": True,
"aftersale_id": result.get("aftersale_id"),
"status": result.get("status"),
"process_steps": result.get("process_steps", []),
"message": "Exchange application submitted successfully"
}
except Exception as e:
return {
"success": False,
"error": str(e)
}
@mcp.tool()
async def create_complaint(
user_id: str,
complaint_type: str,
title: str,
description: str,
related_order_id: Optional[str] = None,
attachments: Optional[List[str]] = None
) -> dict:
"""Create a complaint
Args:
user_id: User identifier
complaint_type: Type of complaint:
- product_quality: Product quality issues
- service: Service attitude or process issues
- logistics: Shipping/delivery issues
- pricing: Pricing or billing issues
- other: Other complaints
title: Brief complaint title
description: Detailed description
related_order_id: Related order ID (optional)
attachments: Optional list of attachment URLs
Returns:
Complaint creation result
"""
payload = {
"user_id": user_id,
"type": complaint_type,
"title": title,
"description": description
}
if related_order_id:
payload["related_order_id"] = related_order_id
if attachments:
payload["attachments"] = attachments
try:
result = await hyperf.post("/aftersales/complaint", json=payload)
return {
"success": True,
"complaint_id": result.get("complaint_id"),
"status": result.get("status"),
"assigned_to": result.get("assigned_to"),
"expected_response_time": result.get("expected_response_time"),
"message": "Complaint submitted successfully"
}
except Exception as e:
return {
"success": False,
"error": str(e)
}
@mcp.tool()
async def create_ticket(
user_id: str,
category: str,
priority: str,
title: str,
description: str
) -> dict:
"""Create a support ticket
Args:
user_id: User identifier
category: Ticket category (technical_support, account, payment, other)
priority: Priority level (low, medium, high, urgent)
title: Ticket title
description: Detailed description
Returns:
Ticket creation result
"""
try:
result = await hyperf.post(
"/aftersales/ticket",
json={
"user_id": user_id,
"category": category,
"priority": priority,
"title": title,
"description": description
}
)
return {
"success": True,
"ticket_id": result.get("ticket_id"),
"status": result.get("status"),
"assigned_team": result.get("assigned_team"),
"message": "Support ticket created successfully"
}
except Exception as e:
return {
"success": False,
"error": str(e)
}
@mcp.tool()
async def query_aftersale_status(
user_id: str,
aftersale_id: Optional[str] = None
) -> dict:
"""Query aftersale records and status
Args:
user_id: User identifier
aftersale_id: Specific aftersale ID (optional, queries all if not provided)
Returns:
List of aftersale records with progress
"""
params = {"user_id": user_id}
if aftersale_id:
params["aftersale_id"] = aftersale_id
try:
result = await hyperf.get("/aftersales/query", params=params)
return {
"success": True,
"records": result.get("records", [])
}
except Exception as e:
return {
"success": False,
"error": str(e),
"records": []
}
# Health check endpoint
@mcp.tool()
async def health_check() -> dict:
"""Check server health status"""
return {
"status": "healthy",
"service": "aftersale_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=8003)

View File

@@ -0,0 +1,29 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Note: shared modules are mounted via docker-compose volumes
# Expose port
EXPOSE 8002
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8002/health || exit 1
# Run the application
CMD ["python", "server.py"]

View File

View File

@@ -0,0 +1,15 @@
# FastMCP Framework
fastmcp>=0.1.0
# HTTP Client
httpx>=0.26.0
# Data Validation
pydantic>=2.5.0
pydantic-settings>=2.1.0
# Environment & Config
python-dotenv>=1.0.0
# Logging
structlog>=24.1.0

View File

@@ -0,0 +1,284 @@
"""
Order MCP Server - Order management tools
"""
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(
"Order Management"
)
# 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 query_order(
user_id: str,
account_id: str,
order_id: Optional[str] = None,
status: Optional[str] = None,
date_start: Optional[str] = None,
date_end: Optional[str] = None,
page: int = 1,
page_size: int = 10
) -> dict:
"""Query orders for a user
Args:
user_id: User identifier
account_id: B2B account identifier
order_id: Specific order ID to query (optional)
status: Order status filter (pending, paid, shipped, delivered, cancelled)
date_start: Start date filter (YYYY-MM-DD)
date_end: End date filter (YYYY-MM-DD)
page: Page number (default: 1)
page_size: Items per page (default: 10)
Returns:
List of orders with details
"""
payload = {
"user_id": user_id,
"account_id": account_id,
"page": page,
"page_size": page_size
}
if order_id:
payload["order_id"] = order_id
if status:
payload["status"] = status
if date_start or date_end:
payload["date_range"] = {}
if date_start:
payload["date_range"]["start"] = date_start
if date_end:
payload["date_range"]["end"] = date_end
try:
result = await hyperf.post("/orders/query", json=payload)
return {
"success": True,
"orders": result.get("orders", []),
"pagination": result.get("pagination", {})
}
except Exception as e:
return {
"success": False,
"error": str(e),
"orders": []
}
@mcp.tool()
async def track_logistics(
order_id: str,
tracking_number: Optional[str] = None
) -> dict:
"""Track order logistics/shipping status
Args:
order_id: Order ID
tracking_number: Tracking number (optional, will be fetched from order if not provided)
Returns:
Logistics tracking information with timeline
"""
try:
params = {}
if tracking_number:
params["tracking_number"] = tracking_number
result = await hyperf.get(f"/orders/{order_id}/logistics", params=params)
return {
"success": True,
"order_id": order_id,
"tracking_number": result.get("tracking_number"),
"courier": result.get("courier"),
"status": result.get("status"),
"estimated_delivery": result.get("estimated_delivery"),
"timeline": result.get("timeline", [])
}
except Exception as e:
return {
"success": False,
"error": str(e),
"order_id": order_id
}
@mcp.tool()
async def modify_order(
order_id: str,
user_id: str,
modifications: dict
) -> dict:
"""Modify an existing order
Args:
order_id: Order ID to modify
user_id: User ID for permission verification
modifications: Changes to apply. Can include:
- shipping_address: {province, city, district, detail, contact, phone}
- items: [{product_id, quantity}] to update quantities
- notes: Order notes/instructions
Returns:
Modified order details and any price changes
"""
try:
result = await hyperf.put(
f"/orders/{order_id}/modify",
json={
"user_id": user_id,
"modifications": modifications
}
)
return {
"success": True,
"order_id": order_id,
"order": result.get("order", {}),
"price_diff": result.get("price_diff", 0),
"message": result.get("message", "Order modified successfully")
}
except Exception as e:
return {
"success": False,
"error": str(e),
"order_id": order_id
}
@mcp.tool()
async def cancel_order(
order_id: str,
user_id: str,
reason: str
) -> dict:
"""Cancel an order
Args:
order_id: Order ID to cancel
user_id: User ID for permission verification
reason: Cancellation reason
Returns:
Cancellation result with refund information
"""
try:
result = await hyperf.post(
f"/orders/{order_id}/cancel",
json={
"user_id": user_id,
"reason": reason
}
)
return {
"success": True,
"order_id": order_id,
"status": "cancelled",
"refund_info": result.get("refund_info", {}),
"message": result.get("message", "Order cancelled successfully")
}
except Exception as e:
return {
"success": False,
"error": str(e),
"order_id": order_id
}
@mcp.tool()
async def get_invoice(
order_id: str,
invoice_type: str = "normal"
) -> dict:
"""Get invoice for an order
Args:
order_id: Order ID
invoice_type: Invoice type ('normal' for regular invoice, 'vat' for VAT invoice)
Returns:
Invoice information and download URL
"""
try:
result = await hyperf.get(
f"/orders/{order_id}/invoice",
params={"type": invoice_type}
)
return {
"success": True,
"order_id": order_id,
"invoice_number": result.get("invoice_number"),
"invoice_type": invoice_type,
"amount": result.get("amount"),
"tax": result.get("tax"),
"invoice_url": result.get("invoice_url"),
"issued_at": result.get("issued_at")
}
except Exception as e:
return {
"success": False,
"error": str(e),
"order_id": order_id
}
# Health check endpoint
@mcp.tool()
async def health_check() -> dict:
"""Check server health status"""
return {
"status": "healthy",
"service": "order_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=8002)

View File

View File

@@ -0,0 +1,29 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Note: shared modules are mounted via docker-compose volumes
# Expose port
EXPOSE 8004
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8004/health || exit 1
# Run the application
CMD ["python", "server.py"]

View File

View File

@@ -0,0 +1,15 @@
# FastMCP Framework
fastmcp>=0.1.0
# HTTP Client
httpx>=0.26.0
# Data Validation
pydantic>=2.5.0
pydantic-settings>=2.1.0
# Environment & Config
python-dotenv>=1.0.0
# Logging
structlog>=24.1.0

View File

@@ -0,0 +1,317 @@
"""
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": []
}
# 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)

View File

View File

@@ -0,0 +1,87 @@
"""
Hyperf API Client for MCP Servers
"""
from typing import Any, Optional
import httpx
from pydantic_settings import BaseSettings
from pydantic import ConfigDict
class HyperfSettings(BaseSettings):
"""Hyperf configuration"""
hyperf_api_url: str
hyperf_api_token: str
model_config = ConfigDict(env_file=".env")
settings = HyperfSettings()
class HyperfClient:
"""Async client for Hyperf PHP API"""
def __init__(
self,
api_url: Optional[str] = None,
api_token: Optional[str] = None
):
self.api_url = (api_url or settings.hyperf_api_url).rstrip("/")
self.api_token = api_token or settings.hyperf_api_token
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(
base_url=f"{self.api_url}/api/v1",
headers={
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
"Accept": "application/json"
},
timeout=30.0
)
return self._client
async def close(self):
if self._client:
await self._client.aclose()
self._client = None
async def request(
self,
method: str,
endpoint: str,
params: Optional[dict[str, Any]] = None,
json: Optional[dict[str, Any]] = None
) -> dict[str, Any]:
"""Make API request and handle response"""
client = await self._get_client()
response = await client.request(
method=method,
url=endpoint,
params=params,
json=json
)
response.raise_for_status()
data = response.json()
# Check for API-level errors
if data.get("code", 0) != 0:
raise Exception(f"API Error [{data.get('code')}]: {data.get('message')}")
return data.get("data", data)
async def get(self, endpoint: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
return await self.request("GET", endpoint, params=params)
async def post(self, endpoint: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]:
return await self.request("POST", endpoint, json=json)
async def put(self, endpoint: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]:
return await self.request("PUT", endpoint, json=json)
async def delete(self, endpoint: str) -> dict[str, Any]:
return await self.request("DELETE", endpoint)

View File

@@ -0,0 +1,128 @@
"""
Strapi API Client for MCP Server
"""
from typing import Any, Optional
import httpx
from pydantic_settings import BaseSettings
from pydantic import ConfigDict
class StrapiSettings(BaseSettings):
"""Strapi configuration"""
strapi_api_url: str
strapi_api_token: str
model_config = ConfigDict(env_file=".env")
settings = StrapiSettings()
class StrapiClient:
"""Async client for Strapi CMS API"""
def __init__(
self,
api_url: Optional[str] = None,
api_token: Optional[str] = None
):
self.api_url = (api_url or settings.strapi_api_url).rstrip("/")
self.api_token = api_token or settings.strapi_api_token
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
headers = {"Content-Type": "application/json"}
# Only add Authorization header if token is provided
if self.api_token and self.api_token.strip():
headers["Authorization"] = f"Bearer {self.api_token}"
self._client = httpx.AsyncClient(
base_url=f"{self.api_url}/api",
headers=headers,
timeout=30.0
)
return self._client
async def close(self):
if self._client:
await self._client.aclose()
self._client = None
async def get(
self,
endpoint: str,
params: Optional[dict[str, Any]] = None
) -> dict[str, Any]:
"""GET request to Strapi API"""
client = await self._get_client()
response = await client.get(endpoint, params=params)
response.raise_for_status()
return response.json()
async def query_collection(
self,
collection: str,
filters: Optional[dict[str, Any]] = None,
sort: Optional[list[str]] = None,
pagination: Optional[dict[str, int]] = None,
locale: str = "zh-CN"
) -> dict[str, Any]:
"""Query a Strapi collection with filters
Args:
collection: Collection name (e.g., 'faqs', 'company-infos')
filters: Strapi filter object
sort: Sort fields (e.g., ['priority:desc'])
pagination: Pagination params {page, pageSize} or {limit}
locale: Locale for i18n content
"""
params = {"locale": locale}
# Add filters
if filters:
for key, value in filters.items():
params[f"filters{key}"] = value
# Add sort
if sort:
for i, s in enumerate(sort):
params[f"sort[{i}]"] = s
# Add pagination
if pagination:
for key, value in pagination.items():
params[f"pagination[{key}]"] = value
return await self.get(f"/{collection}", params=params)
@staticmethod
def flatten_response(data: dict[str, Any]) -> list[dict[str, Any]]:
"""Flatten Strapi response structure
Converts Strapi's {data: [{id, attributes: {...}}]} format
to simple [{id, ...attributes}] format.
"""
items = data.get("data", [])
result = []
for item in items:
flattened = {"id": item.get("id")}
attributes = item.get("attributes", {})
flattened.update(attributes)
result.append(flattened)
return result
@staticmethod
def flatten_single(data: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Flatten a single Strapi item response"""
item = data.get("data")
if not item:
return None
return {
"id": item.get("id"),
**item.get("attributes", {})
}

View File

@@ -0,0 +1,29 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Note: shared modules are mounted via docker-compose volumes
# Expose port
EXPOSE 8001
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8001/health || exit 1
# Run the application
CMD ["python", "server.py"]

View File

View File

@@ -0,0 +1,404 @@
"""
HTTP Routes for Strapi MCP Server
Provides direct HTTP access to knowledge base functions
"""
from typing import Optional, List
import httpx
from fastapi import Request
from starlette.responses import JSONResponse
from pydantic_settings import BaseSettings
from pydantic import ConfigDict
class Settings(BaseSettings):
"""Server configuration"""
strapi_api_url: str
strapi_api_token: str = ""
log_level: str = "INFO"
model_config = ConfigDict(env_file=".env")
settings = Settings()
# ============ FAQ Categories ============
FAQ_CATEGORIES = {
"register": "faq-register",
"order": "faq-order",
"pre-order": "faq-pre-order",
"payment": "faq-payment",
"shipment": "faq-shipment",
"return": "faq-return",
"other": "faq-other-question",
}
# ============ Company Info ============
async def get_company_info_http(section: str = "contact", locale: str = "en"):
"""Get company information - HTTP wrapper
Args:
section: Section identifier (e.g., "contact")
locale: Language locale (default: en)
Supported: en, nl, de, es, fr, it, tr
"""
try:
# Map section names to API endpoints
section_map = {
"contact": "info-contact",
"about": "info-about",
"service": "info-service",
}
endpoint = section_map.get(section, f"info-{section}")
# Build query parameters
headers = {"Content-Type": "application/json"}
if settings.strapi_api_token and settings.strapi_api_token.strip():
headers["Authorization"] = f"Bearer {settings.strapi_api_token}"
# Request with populate=deep to get all related data
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{settings.strapi_api_url}/api/{endpoint}",
params={"populate": "deep", "locale": locale},
headers=headers
)
response.raise_for_status()
data = response.json()
if not data.get("data"):
return {
"success": False,
"error": f"Section '{section}' not found",
"data": None
}
item = data["data"]
# Extract relevant information
result_data = {
"id": item.get("id"),
"title": item.get("title"),
"description": item.get("description"),
"section": section,
"locale": locale
}
# Add profile information if available
if item.get("yehwang_profile"):
profile = item["yehwang_profile"]
result_data["profile"] = {
"title": profile.get("title"),
"content": profile.get("content")
}
return {
"success": True,
"data": result_data
}
except Exception as e:
return {
"success": False,
"error": str(e),
"data": None
}
# ============ FAQ Query ============
async def query_faq_http(
category: str = "other",
locale: str = "en",
limit: int = 10
):
"""Get FAQ by category - HTTP wrapper
Args:
category: FAQ category (register, order, pre-order, payment, shipment, return, other)
locale: Language locale (default: en)
limit: Maximum results to return
"""
try:
# Map category to endpoint
endpoint = FAQ_CATEGORIES.get(category, f"faq-{category}")
headers = {"Content-Type": "application/json"}
if settings.strapi_api_token and settings.strapi_api_token.strip():
headers["Authorization"] = f"Bearer {settings.strapi_api_token}"
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{settings.strapi_api_url}/api/{endpoint}",
params={"populate": "deep", "locale": locale},
headers=headers
)
response.raise_for_status()
data = response.json()
# Check if data exists
if not data.get("data"):
return {
"success": True,
"count": 0,
"category": category,
"locale": locale,
"results": []
}
# Handle different response formats
item_data = data["data"]
# If it's a single object with nested items
faq_list = []
if isinstance(item_data, dict):
# Check for content array (Yehwang format)
if item_data.get("content"):
faq_list = item_data["content"]
# Check for nested FAQ array
elif item_data.get("faqs"):
faq_list = item_data["faqs"]
# Check for questions array
elif item_data.get("questions"):
faq_list = item_data["questions"]
# The item itself might be the FAQ
elif item_data.get("question") and item_data.get("answer"):
faq_list = [item_data]
else:
# Return the whole data as one FAQ
faq_list = [item_data]
elif isinstance(item_data, list):
faq_list = item_data
# Format results
results = []
for item in faq_list[:limit]:
faq_item = {
"id": item.get("id"),
"category": category,
"locale": locale
}
# Yehwang format: title and content
if item.get("title"):
faq_item["question"] = item.get("title")
if item.get("content"):
faq_item["answer"] = item.get("content")
# Also support other field names
if item.get("question"):
faq_item["question"] = item.get("question")
if item.get("answer"):
faq_item["answer"] = item.get("answer")
if item.get("description"):
faq_item["description"] = item.get("description")
# Add any other fields
for key, value in item.items():
if key not in faq_item and key not in ["id", "createdAt", "updatedAt", "publishedAt", "locale"]:
faq_item[key] = value
if "question" in faq_item or "answer" in faq_item:
results.append(faq_item)
return {
"success": True,
"count": len(results),
"category": category,
"locale": locale,
"results": results
}
except Exception as e:
return {
"success": False,
"error": str(e),
"category": category,
"results": []
}
async def search_faq_http(
query: str,
locale: str = "en",
limit: int = 5
):
"""Search FAQ across all categories - HTTP wrapper
Args:
query: Search keywords
locale: Language locale (default: en)
limit: Maximum results per category
"""
try:
all_results = []
# Search all categories in parallel
async with httpx.AsyncClient(timeout=30.0) as client:
tasks = []
for category_name, endpoint in FAQ_CATEGORIES.items():
headers = {"Content-Type": "application/json"}
if settings.strapi_api_token and settings.strapi_api_token.strip():
headers["Authorization"] = f"Bearer {settings.strapi_api_token}"
task = client.get(
f"{settings.strapi_api_url}/api/{endpoint}",
params={"populate": "deep", "locale": locale},
headers=headers
)
tasks.append((category_name, task))
# Execute all requests
for category_name, task in tasks:
try:
response = await task
if response.status_code == 200:
data = response.json()
if data.get("data"):
item_data = data["data"]
# Extract FAQ list
faq_list = []
if isinstance(item_data, dict):
# Yehwang format: content array
if item_data.get("content"):
faq_list = item_data["content"]
elif item_data.get("faqs"):
faq_list = item_data["faqs"]
elif item_data.get("questions"):
faq_list = item_data["questions"]
elif item_data.get("question") and item_data.get("answer"):
faq_list = [item_data]
elif isinstance(item_data, list):
faq_list = item_data
# Filter by query and add to results
for item in faq_list:
# Search in title/content (Yehwang format) or question/answer
item_text = (
str(item.get("title", "")) +
str(item.get("content", "")) +
str(item.get("question", "")) +
str(item.get("answer", "")) +
str(item.get("description", ""))
)
if query.lower() in item_text.lower():
result_item = {
"id": item.get("id"),
"category": category_name,
"locale": locale
}
# Use title as question (Yehwang format)
if item.get("title"):
result_item["question"] = item.get("title")
if item.get("content"):
result_item["answer"] = item.get("content")
# Also support other field names
if item.get("question"):
result_item["question"] = item.get("question")
if item.get("answer"):
result_item["answer"] = item.get("answer")
if item.get("description"):
result_item["description"] = item.get("description")
all_results.append(result_item)
except Exception as e:
# Continue with next category on error
pass
return {
"success": True,
"count": len(all_results),
"query": query,
"locale": locale,
"results": all_results[:limit]
}
except Exception as e:
return {
"success": False,
"error": str(e),
"results": []
}
async def search_knowledge_base_http(query: str, locale: str = "en", limit: int = 10):
"""Search knowledge base - HTTP wrapper
Args:
query: Search keywords
locale: Language locale
limit: Maximum results
"""
# Search FAQ across all categories
return await search_faq_http(query, locale, limit)
async def get_policy_http(policy_type: str, locale: str = "en"):
"""Get policy document - HTTP wrapper
Args:
policy_type: Type of policy (return_policy, privacy_policy, etc.)
locale: Language locale
"""
try:
# Map policy types to endpoints
policy_map = {
"return_policy": "policy-return",
"privacy_policy": "policy-privacy",
"terms_of_service": "policy-terms",
"shipping_policy": "policy-shipping",
"payment_policy": "policy-payment",
}
endpoint = policy_map.get(policy_type, f"policy-{policy_type}")
headers = {"Content-Type": "application/json"}
if settings.strapi_api_token and settings.strapi_api_token.strip():
headers["Authorization"] = f"Bearer {settings.strapi_api_token}"
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{settings.strapi_api_url}/api/{endpoint}",
params={"populate": "deep", "locale": locale},
headers=headers
)
response.raise_for_status()
data = response.json()
if not data.get("data"):
return {
"success": False,
"error": f"Policy '{policy_type}' not found",
"data": None
}
item = data["data"]
return {
"success": True,
"data": {
"id": item.get("id"),
"type": policy_type,
"title": item.get("title"),
"content": item.get("content"),
"summary": item.get("summary"),
"locale": locale
}
}
except Exception as e:
return {
"success": False,
"error": str(e),
"data": None
}

View File

@@ -0,0 +1,19 @@
# FastMCP Framework
fastmcp>=0.1.0
# HTTP Server
fastapi>=0.100.0
uvicorn>=0.23.0
# HTTP Client
httpx>=0.26.0
# Data Validation
pydantic>=2.5.0
pydantic-settings>=2.1.0
# Environment & Config
python-dotenv>=1.0.0
# Logging
structlog>=24.1.0

View File

@@ -0,0 +1,269 @@
"""
Strapi MCP Server - FAQ and Knowledge Base
"""
import sys
import os
from typing import Optional
# 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 fastapi import Request
from starlette.responses import JSONResponse
import uvicorn
from pydantic import ConfigDict
class Settings(BaseSettings):
"""Server configuration"""
strapi_api_url: str
strapi_api_token: str
log_level: str = "INFO"
model_config = ConfigDict(env_file=".env")
settings = Settings()
# Create MCP server
mcp = FastMCP(
"Strapi Knowledge Base"
)
# Strapi client for this server
from shared.strapi_client import StrapiClient
strapi = StrapiClient(settings.strapi_api_url, settings.strapi_api_token)
@mcp.tool()
async def query_faq(
category: str = "other",
locale: str = "en",
limit: int = 10
) -> dict:
"""Get FAQ by category
Args:
category: FAQ category
Available: register, order, pre-order, payment, shipment, return, other
locale: Language locale (default: en)
Available: en, nl, de, es, fr, it, tr
limit: Maximum results to return (default: 10)
Returns:
List of FAQ items with questions and answers for the specified category
"""
from http_routes import query_faq_http
return await query_faq_http(category, locale, limit)
@mcp.tool()
async def get_company_info(
section: str,
locale: str = "en"
) -> dict:
"""Get company information by section
Args:
section: Information section (about_us, contact, service_hours, locations, etc.)
locale: Language locale (default: zh-CN)
Returns:
Company information for the requested section
"""
try:
response = await strapi.query_collection(
"company-infos",
filters={"[section][$eq]": section},
locale=locale
)
results = strapi.flatten_response(response)
if not results:
return {
"success": False,
"error": f"Section '{section}' not found",
"data": None
}
item = results[0]
return {
"success": True,
"data": {
"section": item.get("section"),
"title": item.get("title"),
"content": item.get("content"),
"metadata": item.get("metadata", {})
}
}
except Exception as e:
return {
"success": False,
"error": str(e),
"data": None
}
@mcp.tool()
async def get_policy(
policy_type: str,
locale: str = "en"
) -> dict:
"""Get policy document
Args:
policy_type: Type of policy (return_policy, privacy_policy, terms_of_service,
shipping_policy, payment_policy, etc.)
locale: Language locale (default: zh-CN)
Returns:
Policy document with content and metadata
"""
try:
response = await strapi.query_collection(
"policies",
filters={"[type][$eq]": policy_type},
locale=locale
)
results = strapi.flatten_response(response)
if not results:
return {
"success": False,
"error": f"Policy '{policy_type}' not found",
"data": None
}
item = results[0]
return {
"success": True,
"data": {
"type": item.get("type"),
"title": item.get("title"),
"content": item.get("content"),
"summary": item.get("summary"),
"version": item.get("version"),
"effective_date": item.get("effective_date"),
"last_updated": item.get("updatedAt")
}
}
except Exception as e:
return {
"success": False,
"error": str(e),
"data": None
}
@mcp.tool()
async def search_knowledge_base(
query: str,
locale: str = "en",
limit: int = 10
) -> dict:
"""Search knowledge base documents across all FAQ categories
Args:
query: Search keywords
locale: Language locale (default: en)
Available: en, nl, de, es, fr, it, tr
limit: Maximum results to return (default: 10)
Returns:
List of matching FAQ documents from all categories
"""
from http_routes import search_knowledge_base_http
return await search_knowledge_base_http(query, locale, limit)
# Health check endpoint
@mcp.tool()
async def health_check() -> dict:
"""Check server health status"""
return {
"status": "healthy",
"service": "strapi_mcp",
"version": "1.0.0"
}
if __name__ == "__main__":
# Create FastAPI app from MCP
mcp_app = mcp.http_app()
# Add health endpoint
async def health_check(request):
return JSONResponse({"status": "healthy"})
# Import HTTP routes
from http_routes import (
get_company_info_http,
query_faq_http,
get_policy_http,
search_knowledge_base_http
)
# Direct function references for HTTP endpoints
async def call_query_faq(request):
"""HTTP endpoint for query_faq"""
try:
data = await request.json()
result = await query_faq_http(**data)
return JSONResponse(result)
except Exception as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
async def call_get_company_info(request):
"""HTTP endpoint for get_company_info"""
try:
data = await request.json()
result = await get_company_info_http(**data)
return JSONResponse(result)
except Exception as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
async def call_get_policy(request):
"""HTTP endpoint for get_policy"""
try:
data = await request.json()
result = await get_policy_http(**data)
return JSONResponse(result)
except Exception as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
async def call_search_knowledge_base(request):
"""HTTP endpoint for search_knowledge_base"""
try:
data = await request.json()
result = await search_knowledge_base_http(**data)
return JSONResponse(result)
except Exception as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
# Add routes using the correct method
from fastapi import FastAPI
# Create a wrapper FastAPI app with custom routes first
app = FastAPI()
# Add custom routes BEFORE mounting mcp_app
app.add_route("/health", health_check, methods=["GET"])
app.add_route("/tools/query_faq", call_query_faq, methods=["POST"])
app.add_route("/tools/get_company_info", call_get_company_info, methods=["POST"])
app.add_route("/tools/get_policy", call_get_policy, methods=["POST"])
app.add_route("/tools/search_knowledge_base", call_search_knowledge_base, methods=["POST"])
# Mount MCP app at root (will catch all other routes)
app.mount("/", mcp_app)
uvicorn.run(app, host="0.0.0.0", port=8001)

View File