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:
0
mcp_servers/__init__.py
Normal file
0
mcp_servers/__init__.py
Normal file
29
mcp_servers/aftersale_mcp/Dockerfile
Normal file
29
mcp_servers/aftersale_mcp/Dockerfile
Normal 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"]
|
||||
0
mcp_servers/aftersale_mcp/__init__.py
Normal file
0
mcp_servers/aftersale_mcp/__init__.py
Normal file
15
mcp_servers/aftersale_mcp/requirements.txt
Normal file
15
mcp_servers/aftersale_mcp/requirements.txt
Normal 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
|
||||
299
mcp_servers/aftersale_mcp/server.py
Normal file
299
mcp_servers/aftersale_mcp/server.py
Normal 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)
|
||||
0
mcp_servers/aftersale_mcp/tools/__init__.py
Normal file
0
mcp_servers/aftersale_mcp/tools/__init__.py
Normal file
29
mcp_servers/order_mcp/Dockerfile
Normal file
29
mcp_servers/order_mcp/Dockerfile
Normal 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"]
|
||||
0
mcp_servers/order_mcp/__init__.py
Normal file
0
mcp_servers/order_mcp/__init__.py
Normal file
15
mcp_servers/order_mcp/requirements.txt
Normal file
15
mcp_servers/order_mcp/requirements.txt
Normal 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
|
||||
284
mcp_servers/order_mcp/server.py
Normal file
284
mcp_servers/order_mcp/server.py
Normal 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)
|
||||
0
mcp_servers/order_mcp/tools/__init__.py
Normal file
0
mcp_servers/order_mcp/tools/__init__.py
Normal file
29
mcp_servers/product_mcp/Dockerfile
Normal file
29
mcp_servers/product_mcp/Dockerfile
Normal 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"]
|
||||
0
mcp_servers/product_mcp/__init__.py
Normal file
0
mcp_servers/product_mcp/__init__.py
Normal file
15
mcp_servers/product_mcp/requirements.txt
Normal file
15
mcp_servers/product_mcp/requirements.txt
Normal 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
|
||||
317
mcp_servers/product_mcp/server.py
Normal file
317
mcp_servers/product_mcp/server.py
Normal 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)
|
||||
0
mcp_servers/product_mcp/tools/__init__.py
Normal file
0
mcp_servers/product_mcp/tools/__init__.py
Normal file
0
mcp_servers/shared/__init__.py
Normal file
0
mcp_servers/shared/__init__.py
Normal file
87
mcp_servers/shared/hyperf_client.py
Normal file
87
mcp_servers/shared/hyperf_client.py
Normal 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)
|
||||
128
mcp_servers/shared/strapi_client.py
Normal file
128
mcp_servers/shared/strapi_client.py
Normal 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", {})
|
||||
}
|
||||
29
mcp_servers/strapi_mcp/Dockerfile
Normal file
29
mcp_servers/strapi_mcp/Dockerfile
Normal 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"]
|
||||
0
mcp_servers/strapi_mcp/__init__.py
Normal file
0
mcp_servers/strapi_mcp/__init__.py
Normal file
404
mcp_servers/strapi_mcp/http_routes.py
Normal file
404
mcp_servers/strapi_mcp/http_routes.py
Normal 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
|
||||
}
|
||||
19
mcp_servers/strapi_mcp/requirements.txt
Normal file
19
mcp_servers/strapi_mcp/requirements.txt
Normal 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
|
||||
269
mcp_servers/strapi_mcp/server.py
Normal file
269
mcp_servers/strapi_mcp/server.py
Normal 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)
|
||||
0
mcp_servers/strapi_mcp/tools/__init__.py
Normal file
0
mcp_servers/strapi_mcp/tools/__init__.py
Normal file
Reference in New Issue
Block a user