"""
Product search and management tools for Kroger MCP server
"""
from typing import Dict, List, Any, Optional, Literal
from pydantic import Field
from fastmcp import Context, Image
import requests
from io import BytesIO
from .shared import (
get_client_credentials_client,
get_preferred_location_id,
format_currency
)
def register_tools(mcp):
"""Register product-related tools with the FastMCP server"""
@mcp.tool()
async def get_product_images(
product_id: str,
perspective: str = "front",
location_id: Optional[str] = None,
ctx: Context = None
) -> Image:
"""
Get an image for a specific product from the requested perspective.
Use get_product_details first to see what perspectives are available (typically "front", "back", "left", "right").
Args:
product_id: The unique product identifier
perspective: The image perspective to retrieve (default: "front")
location_id: Store location ID (uses preferred if not provided)
Returns:
The product image from the requested perspective
"""
# Use preferred location if none provided
if not location_id:
location_id = get_preferred_location_id()
if not location_id:
return {
"success": False,
"error": "No location_id provided and no preferred location set. Use set_preferred_location first."
}
if ctx:
await ctx.info(f"Fetching images for product {product_id} at location {location_id}")
client = get_client_credentials_client()
try:
# Get product details to extract image URLs
product_details = client.product.get_product(
product_id=product_id,
location_id=location_id
)
if not product_details or "data" not in product_details:
return {
"success": False,
"message": f"Product {product_id} not found"
}
product = product_details["data"]
# Check if images are available
if "images" not in product or not product["images"]:
return {
"success": False,
"message": f"No images available for product {product_id}"
}
# Find the requested perspective image
perspective_image = None
available_perspectives = []
for img_data in product["images"]:
img_perspective = img_data.get("perspective", "unknown")
available_perspectives.append(img_perspective)
# Skip if not the requested perspective
if img_perspective != perspective:
continue
if not img_data.get("sizes"):
continue
# Find the best image size (prefer large, fallback to xlarge or other available)
img_url = None
size_preference = ["large", "xlarge", "medium", "small", "thumbnail"]
# Create a map of available sizes for quick lookup
available_sizes = {size.get("size"): size.get("url") for size in img_data.get("sizes", []) if size.get("size") and size.get("url")}
# Select best size based on preference order
for size in size_preference:
if size in available_sizes:
img_url = available_sizes[size]
break
if img_url:
try:
if ctx:
await ctx.info(f"Downloading {perspective} image from {img_url}")
# Download image
response = requests.get(img_url)
response.raise_for_status()
# Create Image object
perspective_image = Image(
data=response.content,
format="jpeg" # Kroger images are typically JPEG
)
break
except Exception as e:
if ctx:
await ctx.warning(f"Failed to download {perspective} image: {str(e)}")
# If the requested perspective wasn't found
if not perspective_image:
available_str = ", ".join(available_perspectives) if available_perspectives else "none"
return {
"success": False,
"message": f"No image found for perspective '{perspective}'. Available perspectives: {available_str}"
}
return perspective_image
except Exception as e:
if ctx:
await ctx.error(f"Error getting product images: {str(e)}")
return {
"success": False,
"error": str(e)
}
@mcp.tool()
async def search_products(
search_term: str,
location_id: Optional[str] = None,
limit: int = Field(default=10, ge=1, le=50, description="Number of results to return (1-50)"),
fulfillment: Optional[Literal["csp", "delivery", "pickup"]] = None,
brand: Optional[str] = None,
ctx: Context = None
) -> Dict[str, Any]:
"""
Search for products at a Kroger store.
Args:
search_term: Product search term (e.g., "milk", "bread", "organic apples")
location_id: Store location ID (uses preferred location if not provided)
limit: Number of results to return (1-50)
fulfillment: Filter by fulfillment method (csp=curbside pickup, delivery, pickup)
brand: Filter by brand name
Returns:
Dictionary containing product search results
"""
# Use preferred location if none provided
if not location_id:
location_id = get_preferred_location_id()
if not location_id:
return {
"success": False,
"error": "No location_id provided and no preferred location set. Use set_preferred_location first."
}
if ctx:
await ctx.info(f"Searching for '{search_term}' at location {location_id}")
client = get_client_credentials_client()
try:
products = client.product.search_products(
term=search_term,
location_id=location_id,
limit=limit,
fulfillment=fulfillment,
brand=brand
)
if not products or "data" not in products or not products["data"]:
return {
"success": False,
"message": f"No products found matching '{search_term}'",
"data": []
}
# Format product data
formatted_products = []
for product in products["data"]:
formatted_product = {
"product_id": product.get("productId"),
"upc": product.get("upc"),
"description": product.get("description"),
"brand": product.get("brand"),
"categories": product.get("categories", []),
"country_origin": product.get("countryOrigin"),
"temperature": product.get("temperature", {})
}
# Add item information (size, price, etc.)
if "items" in product and product["items"]:
item = product["items"][0]
formatted_product["item"] = {
"size": item.get("size"),
"sold_by": item.get("soldBy"),
"inventory": item.get("inventory", {}),
"fulfillment": item.get("fulfillment", {})
}
# Add pricing information
if "price" in item:
price = item["price"]
formatted_product["pricing"] = {
"regular_price": price.get("regular"),
"sale_price": price.get("promo"),
"regular_per_unit": price.get("regularPerUnitEstimate"),
"formatted_regular": format_currency(price.get("regular")),
"formatted_sale": format_currency(price.get("promo")),
"on_sale": price.get("promo") is not None and price.get("promo") < price.get("regular", float('inf'))
}
# Add aisle information
if "aisleLocations" in product:
formatted_product["aisle_locations"] = [
{
"description": aisle.get("description"),
"number": aisle.get("number"),
"side": aisle.get("side"),
"shelf_number": aisle.get("shelfNumber")
}
for aisle in product["aisleLocations"]
]
# Add image information
if "images" in product and product["images"]:
formatted_product["images"] = [
{
"perspective": img.get("perspective"),
"url": img["sizes"][0].get("url") if img.get("sizes") else None,
"size": img["sizes"][0].get("size") if img.get("sizes") else None
}
for img in product["images"]
if img.get("sizes")
]
formatted_products.append(formatted_product)
if ctx:
await ctx.info(f"Found {len(formatted_products)} products")
return {
"success": True,
"search_params": {
"search_term": search_term,
"location_id": location_id,
"limit": limit,
"fulfillment": fulfillment,
"brand": brand
},
"count": len(formatted_products),
"data": formatted_products
}
except Exception as e:
if ctx:
await ctx.error(f"Error searching products: {str(e)}")
return {
"success": False,
"error": str(e),
"data": []
}
@mcp.tool()
async def get_product_details(
product_id: str,
location_id: Optional[str] = None,
ctx: Context = None
) -> Dict[str, Any]:
"""
Get detailed information about a specific product.
Args:
product_id: The unique product identifier
location_id: Store location ID for pricing/availability (uses preferred if not provided)
Returns:
Dictionary containing detailed product information
"""
# Use preferred location if none provided
if not location_id:
location_id = get_preferred_location_id()
if not location_id:
return {
"success": False,
"error": "No location_id provided and no preferred location set. Use set_preferred_location first."
}
if ctx:
await ctx.info(f"Getting details for product {product_id} at location {location_id}")
client = get_client_credentials_client()
try:
product_details = client.product.get_product(
product_id=product_id,
location_id=location_id
)
if not product_details or "data" not in product_details:
return {
"success": False,
"message": f"Product {product_id} not found"
}
product = product_details["data"]
# Format the detailed product information
result = {
"success": True,
"product_id": product.get("productId"),
"upc": product.get("upc"),
"description": product.get("description"),
"brand": product.get("brand"),
"categories": product.get("categories", []),
"country_origin": product.get("countryOrigin"),
"temperature": product.get("temperature", {}),
"location_id": location_id
}
# Add detailed item information
if "items" in product and product["items"]:
item = product["items"][0]
result["item_details"] = {
"size": item.get("size"),
"sold_by": item.get("soldBy"),
"inventory": item.get("inventory", {}),
"fulfillment": item.get("fulfillment", {})
}
# Add detailed pricing
if "price" in item:
price = item["price"]
result["pricing"] = {
"regular_price": price.get("regular"),
"sale_price": price.get("promo"),
"regular_per_unit": price.get("regularPerUnitEstimate"),
"formatted_regular": format_currency(price.get("regular")),
"formatted_sale": format_currency(price.get("promo")),
"on_sale": price.get("promo") is not None and price.get("promo") < price.get("regular", float('inf')),
"savings": price.get("regular", 0) - price.get("promo", price.get("regular", 0)) if price.get("promo") else 0
}
# Add aisle locations
if "aisleLocations" in product:
result["aisle_locations"] = [
{
"description": aisle.get("description"),
"aisle_number": aisle.get("number"),
"side": aisle.get("side"),
"shelf_number": aisle.get("shelfNumber")
}
for aisle in product["aisleLocations"]
]
# Add images
if "images" in product and product["images"]:
result["images"] = [
{
"perspective": img.get("perspective"),
"sizes": [
{
"size": size.get("size"),
"url": size.get("url")
}
for size in img.get("sizes", [])
]
}
for img in product["images"]
]
return result
except Exception as e:
if ctx:
await ctx.error(f"Error getting product details: {str(e)}")
return {
"success": False,
"error": str(e)
}
@mcp.tool()
async def search_products_by_id(
product_id: str,
location_id: Optional[str] = None,
ctx: Context = None
) -> Dict[str, Any]:
"""
Search for products by their specific product ID.
Args:
product_id: The product ID to search for
location_id: Store location ID (uses preferred location if not provided)
Returns:
Dictionary containing matching products
"""
# Use preferred location if none provided
if not location_id:
location_id = get_preferred_location_id()
if not location_id:
return {
"success": False,
"error": "No location_id provided and no preferred location set. Use set_preferred_location first."
}
if ctx:
await ctx.info(f"Searching for products with ID '{product_id}' at location {location_id}")
client = get_client_credentials_client()
try:
products = client.product.search_products(
product_id=product_id,
location_id=location_id
)
if not products or "data" not in products or not products["data"]:
return {
"success": False,
"message": f"No products found with ID '{product_id}'",
"data": []
}
# Format product data (similar to search_products but simpler)
formatted_products = []
for product in products["data"]:
formatted_product = {
"product_id": product.get("productId"),
"upc": product.get("upc"),
"description": product.get("description"),
"brand": product.get("brand"),
"categories": product.get("categories", [])
}
# Add basic pricing if available
if "items" in product and product["items"] and "price" in product["items"][0]:
price = product["items"][0]["price"]
formatted_product["pricing"] = {
"regular_price": price.get("regular"),
"sale_price": price.get("promo"),
"formatted_regular": format_currency(price.get("regular")),
"formatted_sale": format_currency(price.get("promo"))
}
formatted_products.append(formatted_product)
if ctx:
await ctx.info(f"Found {len(formatted_products)} products with ID '{product_id}'")
return {
"success": True,
"search_params": {
"product_id": product_id,
"location_id": location_id
},
"count": len(formatted_products),
"data": formatted_products
}
except Exception as e:
if ctx:
await ctx.error(f"Error searching products by ID: {str(e)}")
return {
"success": False,
"error": str(e),
"data": []
}