Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions app/routers/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ async def _handle_get_deals(args: dict[str, Any]) -> CallToolResult:
params = {"min_discount_pct": min_discount_pct, "limit": limit}
if args.get("category"):
params["category"] = args["category"]
if args.get("country_code"):
params["country_code"] = args["country_code"]

try:
data = await _api_get("/v1/deals", params)
Expand Down Expand Up @@ -280,16 +282,52 @@ async def _handle_get_deals(args: dict[str, Any]) -> CallToolResult:


async def _api_get(path: str, params: dict[str, Any] | None = None) -> Any:
"""Call the internal catalog API with retry and separated timeouts.

The backend DB queries carry a SET LOCAL statement_timeout of 8s
(see categories.py, deals.py). We set a generous read timeout here
so that transient contention / slow queries get a chance to complete
rather than being prematurely killed by the HTTP client.
"""
import asyncio
import os

import httpx
from app.config import get_settings

settings = get_settings()
API_BASE_URL = settings.app_base_url or "http://localhost:8000"
api_base = getattr(settings, "app_base_url", None) or os.environ.get("BUYWHERE_API_URL", "http://localhost:8000")
api_timeout = float(getattr(settings, "mcp_api_timeout", None) or os.environ.get("BUYWHERE_API_TIMEOUT", "25.0"))

headers = {"Accept": "application/json"}
async with httpx.AsyncClient(base_url=API_BASE_URL, headers=headers, timeout=10.0) as client:
resp = await client.get(path, params=params or {})
resp.raise_for_status()
return resp.json()
last_exc: Exception | None = None

for attempt in range(3):
try:
timeout = httpx.Timeout(
connect=5.0 + attempt * 2.0,
read=api_timeout,
write=10.0,
pool=5.0,
)
async with httpx.AsyncClient(
base_url=api_base, headers=headers, timeout=timeout
) as client:
resp = await client.get(path, params=params or {})
resp.raise_for_status()
return resp.json()
except (httpx.TimeoutException, httpx.TransportError) as exc:
last_exc = exc
logger.warning(
"MCP _api_get %s attempt %d/%d failed: %s",
path, attempt + 1, 3, exc,
)
if attempt < 2:
await asyncio.sleep(0.5 * (attempt + 1))

raise httpx.RequestError(
f"MCP _api_get {path} failed after 3 attempts"
) from last_exc


def _fmt_price(price: Any, currency: str = "SGD") -> str:
Expand Down
56 changes: 52 additions & 4 deletions mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
if not API_KEY:
logger.warning("BUYWHERE_API_KEY is not set — requests may be rejected")

# Timeout for upstream API calls. Index-accelerated queries should complete
# well within this window once the 810 migration (GIN indexes) has been applied.
API_TIMEOUT_SECONDS = float(os.environ.get("BUYWHERE_API_TIMEOUT", "25.0"))

mcp = FastMCP(
"buywhere",
host="0.0.0.0",
Expand Down Expand Up @@ -141,13 +145,16 @@ async def find_best_price(product_name: str, category: str | None = None) -> Tex
@mcp.tool()
async def get_deals(
category: str | None = None,
country_code: str | None = None,
min_discount_pct: float = 10,
limit: int = 10,
) -> TextContent:
"""Find products with significant price drops compared to their original price."""
params: dict[str, Any] = {"min_discount_pct": min_discount_pct, "limit": min(limit, 50)}
if category:
params["category"] = category
if country_code:
params["country_code"] = country_code.upper()

try:
data = await _api_get("/v1/deals", params)
Expand All @@ -172,14 +179,55 @@ async def get_deals(
return TextContent(type="text", text="\n".join(lines))


@mcp.tool()
async def list_categories(country_code: str | None = None, limit: int = 50) -> TextContent:
"""List available BuyWhere product categories with product counts."""
params: dict[str, Any] = {"limit": min(limit, 100)}
if country_code:
params["country_code"] = country_code.upper()

try:
data = await _api_get("/v1/categories", params)
except Exception as exc:
logger.exception("list_categories API error")
return TextContent(type="text", text=f"Categories fetch failed: {exc}")

categories = data.get("categories", []) if isinstance(data, dict) else []
if not categories:
return TextContent(type="text", text="No categories found.")

lines = [f"Found {len(categories)} categor(y/ies):\n"]
for i, category in enumerate(categories, 1):
name = category.get("name", "Unknown")
count = category.get("count", 0)
lines.append(f"{i}. **{name}** ({count} products)")
return TextContent(type="text", text="\n".join(lines))


async def _api_get(path: str, params: dict[str, Any] | None = None) -> Any:
headers: dict[str, str] = {"Accept": "application/json"}
if API_KEY:
headers["Authorization"] = f"Bearer {API_KEY}"
async with httpx.AsyncClient(base_url=API_BASE_URL, headers=headers, timeout=10.0) as client:
resp = await client.get(path, params=params or {})
resp.raise_for_status()
return resp.json()

last_exc = None
for attempt in range(2):
try:
timeout = httpx.Timeout(
connect=5.0 + attempt * 5.0,
read=API_TIMEOUT_SECONDS,
write=10.0,
pool=5.0,
)
async with httpx.AsyncClient(base_url=API_BASE_URL, headers=headers, timeout=timeout) as client:
resp = await client.get(path, params=params or {})
resp.raise_for_status()
return resp.json()
except (httpx.TimeoutException, httpx.TransportError) as exc:
last_exc = exc
logger.warning("API call %s attempt %d failed: %s", path, attempt + 1, exc)
await asyncio.sleep(0.5 * (attempt + 1))

raise httpx.RequestError(f"API call failed after 2 attempts: {last_exc}") from last_exc


def _fmt_price(price: Any, currency: str = "SGD") -> str:
Expand Down