# Kapruka MCP

> Free public MCP server for Kapruka.com — Sri Lanka's largest e-commerce platform.

- name: com.kapruka/kapruka-mcp
- version: 0.9.0
- connection class: unknown (unknown)
- trust: 79/100
- quality: 80/100 (good)
- owner: not claimed
- descriptor: https://api.protogrid.dev/v1/servers/com.kapruka%2Fkapruka-mcp
- tools: https://api.protogrid.dev/v1/servers/com.kapruka%2Fkapruka-mcp/tools

**Not classified yet.** The remote endpoints have not been probed successfully, or the listing has nothing to connect to.

## Connection (mcpServers)

```json
{
  "mcpServers": {
    "kapruka-mcp": {
      "type": "http",
      "url": "https://mcp.kapruka.com/mcp"
    }
  }
}
```


## Trust

- hygiene: 100
- liveness: 30
- freshness: 100
- provenance: 100
- drivers: +repository +namespace-matches-repo +dns-namespace +website -unreachable(1 failures) +updated-1d-ago
- Derived from observable signals (official registry feed and our own credential-free probes); no code audit performed.

## Quality 80/100 (good)

### protocol: 75 (weight 25)

- warn `protocol.modern`: newest supported version is 2025-11-25; 2026-07-28 not supported, older versions are deprecated until 2027-07-28
- n/a `protocol.stateless`: only applies to 2026-07-28 servers
- pass `protocol.transport`: streamable HTTP
- n/a `protocol.list_ttl`: only applies to 2026-07-28 servers

### authorization: n/a (weight 15)

- n/a `auth.prm`, `auth.as_metadata`, `auth.cimd`: no remote uses OAuth
- n/a `auth.secret_in_url`: no templated URL

### tool hygiene: 86 (weight 30)

- pass `tools.descriptions`: every tool has a description
- warn `tools.description_length`: 8 tools have descriptions over 1,024 characters, which crowds the context
- pass `tools.schemas`: every tool has an object input schema
- pass `tools.annotations`: 10 of 10 tools declare readOnlyHint or destructiveHint
- pass `tools.directory_hints`: every tool declares readOnlyHint, destructiveHint, idempotentHint and openWorldHint
- warn `tools.token_cost`: about 11,691 tokens to load every tool
- pass `tools.api_dump`: tools are not a one-to-one API dump

### stability: 75 (weight 15)

- warn `stability.changes`: 15 tool changes in 30 days
- pass `stability.rug_pull`: no tool changed its meaning under the same name

### dependencies: n/a (weight 15)

- n/a `deps.known_vulns`, `deps.mcp_sdk_version`, `deps.resolvable`: no npm or PyPI package

- tools: 10, about 11,691 tokens to load them all
- tool set hash: dd1c4ffe5dfe904c0588f3bc3b949b09
- tools last changed: 2026-10-20T08:39:58.017Z
- badge: [![protogrid quality](https://protogrid.dev/badge/com.kapruka/kapruka-mcp.svg)](https://protogrid.dev/servers/com.kapruka/kapruka-mcp)
- Checks run on what our credential-free, read-only probes observe; tools are never called and no code audit is performed.

### Tool changes

- 2026-10-20 `kapruka_gift_country_products` added
  - List EVERY gift in stock in one country's store (international gifts), grouped by type. The stores are small (tens of products), so this returns the whole store: read it and choose for the customer — there is no keyword search for these products. Prices are exactly what checkout charges for the item (no website discounts apply to these stores); delivery is extra — one fee per order (zone fee in Australia / the UAE), shown at the top. Use the `id` with kapruka_get_product for full detail and with kapruka_create_order (delivery.type "international_gift"). Args: params: country, type (optional filter), currency ('LKR' default or 'USD'), response_format. Returns: str. JSON: {"country", "currency", "count", "delivery": {country row from kapruka_gift_countries}, "products": [{"id", "name", "type", "summary", "price": {"amount","currency"}, "image_url", "url"}]}
- 2026-10-20 `kapruka_gift_countries` added
  - List the countries Kapruka delivers gifts IN (international gift stores). Use when the RECIPIENT is outside Sri Lanka — e.g. "send flowers to my sister in Dubai", "a cake for my son in Melbourne". These gifts are delivered by Kapruka's partner in that country, not shipped from Sri Lanka. They are NOT in kapruka_search_products; list a store with kapruka_gift_country_products. For each country: how many gifts are in stock now (0 = nothing can be ordered there right now — say so), the delivery fee (one fee per order, whatever the number of items), and for Australia and the UAE the zones they deliver to — delivery is ONLY to those zones, each with its own fee. The earliest delivery date is 3 days from today (Sri Lanka date). Args: params: currency ('LKR' default or 'USD'), response_format. Returns: str. JSON: {"currency", "earliest_date", "countries": [{"country", "products_in_stock", "delivery_fee": {"amount","currency"}, "zone_required": bool, "zones": [{"name","delivery_fee"}], "earliest_date", "store_url"}]}
- 2026-10-20 `kapruka_check_delivery` changed (description, inputSchema)
  - before: Check whether Kapruka can deliver to a given city on a given date, and the delivery fee. Returns whether the requested date is available (if not, the next available date plus reason) and the delivery fee the checkout will charge. THE FEE DEPENDS ON THE CART AND THE CURRENCY — the city's base rate is not what checkout charges. Sri Lankan customers (LKR) pay the city rate capped by the cart value (25% of the item value, at least LKR 300; some remote cities 50%), so a small cart to a far city pays far less than the rate. Overseas customers (USD) pay a fixed USD fee per city, whatever the cart. Pass `currency` and the `cart` you are about to order and the answer gives the EXACT fee kapruka_create_order will charge ("Delivery fee for this cart"). Without a cart, an LKR answer gives only the MOST the fee can be ("up to"). One shipment per order: the fee covers the whole cart. Pass `product_id` whenever the customer has named a product: the answer then also checks that ITEM's delivery scope (restaurant food, hotel cakes and liquor only reach selected cities, typically the Colombo area). With a product_id, `available` is true only if the date is open AND the item is deliverable to that city. When `item_deliverable` is false, offer the customer one of the returned `deliverable_cities` or an island-wide alternative — do not attempt kapruka_create_order with the same city, it will be rejected. An unknown product_id is silently ignored (no item fields in the result), so check `item_deliverable` is present before relying on it. product_id also selects the SAME-DAY rule the checkout applies to that item: vendor-delivered items (restaurant food) can go today until late afternoon; ordinary items move to the next date; ordinary same-day is only possible early in the morning near Colombo. So a check without product_id can differ from one with it — always pass it once an item is chosen. When `available` is false, offer `next_available_date`. Do not interpret the `reason` text (it is the website's wording and may say slots are full when the real cause is the cutoff). ECONOMY DELIVERY (cheaper courier, a few days instead of a chosen date) is worked out from the `cart` (and `gift_message`, which you should pass when there is one). When the answer shows an economy option, offer it to the customer as the cheaper choice, quoting its fee, promise wording and "expected by" date as given; if they take it, call kapruka_create_order with `delivery.type: "economy"` (the order is dated for you). When the answer says the order goes by economy delivery, that is the ONLY option for these items: tell the customer and order with `delivery.type: "economy"`. When economy is not shown, do not mention it. Perishable codes (CAKE*, FLOWER*, COMBO*) additionally get a freshness warning when the chosen delivery date is more than 1 day out. Args: params (CheckDeliveryInput): - city (str): Canonical city name (e.g. 'Colombo 03', 'Galle') - delivery_date (Optional[str]): YYYY-MM-DD; defaults to today (LK time) - product_id (Optional[str]): Check the city against this item's delivery scope - currency (Optional[str]): LKR (default) or USD/GBP/AUD/EUR (charged in USD) - cart (Optional[list]): [{product_id, quantity, icing_text?}] for the exact fee - other_items_total (Optional[float]): LKR value of items not in `cart` (custom cake quote) - gift_message (Optional[str]): the order's gift message, for the economy rule - response_format (str): 'markdown' (default) or 'json' Returns: str: Delivery feasibility + fee in the requested format. JSON schema: { "city": str, "now": str, # ISO timestamp, Sri Lanka time "checked_date": str,
  - after: Check whether Kapruka can deliver to a given city on a given date, and the delivery fee. Returns whether the requested date is available (if not, the next available date plus reason) and the delivery fee the checkout will charge. THE FEE DEPENDS ON THE CART AND THE CURRENCY — the city's base rate is not what checkout charges. Sri Lankan customers (LKR) pay the city rate capped by the cart value (25% of the item value, at least LKR 300; some remote cities 50%), so a small cart to a far city pays far less than the rate. Overseas customers (USD) pay a fixed USD fee per city, whatever the cart. Pass `currency` and the `cart` you are about to order and the answer gives the EXACT fee kapruka_create_order will charge ("Delivery fee for this cart"). Without a cart, an LKR answer gives only the MOST the fee can be ("up to"). One shipment per order: the fee covers the whole cart. Pass `product_id` whenever the customer has named a product: the answer then also checks that ITEM's delivery scope (restaurant food, hotel cakes and liquor only reach selected cities, typically the Colombo area). With a product_id, `available` is true only if the date is open AND the item is deliverable to that city. When `item_deliverable` is false, offer the customer one of the returned `deliverable_cities` or an island-wide alternative — do not attempt kapruka_create_order with the same city, it will be rejected. An unknown product_id is silently ignored (no item fields in the result), so check `item_deliverable` is present before relying on it. product_id also selects the SAME-DAY rule the checkout applies to that item: vendor-delivered items (restaurant food) can go today until late afternoon; ordinary items move to the next date; ordinary same-day is only possible early in the morning near Colombo. So a check without product_id can differ from one with it — always pass it once an item is chosen. When `available` is false, offer `next_available_date`. Do not interpret the `reason` text (it is the website's wording and may say slots are full when the real cause is the cutoff). ECONOMY DELIVERY (cheaper courier, a few days instead of a chosen date) is worked out from the `cart` (and `gift_message`, which you should pass when there is one). When the answer shows an economy option, offer it to the customer as the cheaper choice, quoting its fee, promise wording and "expected by" date as given; if they take it, call kapruka_create_order with `delivery.type: "economy"` (the order is dated for you). When the answer says the order goes by economy delivery, that is the ONLY option for these items: tell the customer and order with `delivery.type: "economy"`. When economy is not shown, do not mention it. Perishable codes (CAKE*, FLOWER*, COMBO*) additionally get a freshness warning when the chosen delivery date is more than 1 day out. INTERNATIONAL GIFTS (store products from kapruka_gift_country_products): pass product_id or cart (no city needed) and, for Australia / the UAE, `zone`. The answer is that country's delivery: available or not, the fee for the whole order, and the earliest date (3 days ahead; used when delivery_date is omitted). Args: params (CheckDeliveryInput): - city (str): Canonical city name (e.g. 'Colombo 03', 'Galle'); not needed for international gift store products - zone (Optional[str]): international gifts in Australia / the UAE - delivery_date (Optional[str]): YYYY-MM-DD; defaults to today (LK time) - product_id (Optional[str]): Check the city against this item's delivery scope - currency (Optional[str]): LKR (default) or USD/GBP/AUD/EUR (charged in USD) - cart (Optional[list]): [{product_id, quantity, icing_text?}] for the exact fee - other_items_total (Optiona
- 2026-10-20 `kapruka_create_order` changed (inputSchema)
- 2026-10-20 `kapruka_search_products` changed (description)
  - before: Search for products on Kapruka.com by keyword, with optional category filter and pagination. Returns a ranked list of matching products with prices, stock status, images, and URLs, plus the category facets available for the query (facets.categories) — the names that work in `category` to narrow the same search. Supports cursor-based pagination — pass next_cursor from one response into the next call. Pagination is capped at 3 pages per query to discourage catalog enumeration; for broader discovery, refine the query or narrow with a facet instead. Queries must be at least 3 characters and contain specific terms — pure stopword queries (e.g. "the", "a an") are rejected. Relevance: the API matches ANY single query word, so extra words add loosely related results rather than narrowing. Search the thing the customer wants (the head noun) and check the product names actually contain it before presenting them. Prices and min_price/max_price are both in `currency` — pass the customer's own budget in their own currency; do not convert it. A row's `price` is the discounted price checkout charges (the bounds filter on it); `compare_at_price` is set when on sale. Search results carry NO delivery-scope information. Food, hotel cakes and liquor are delivered only to selected cities — never infer deliverability from a search hit. Before quoting delivery on a specific item, call kapruka_get_product (read `delivery.island_wide`) or kapruka_check_delivery with `product_id`. Args: params (SearchProductsInput): - q (str): Search query (e.g. 'birthday cake', 'roses', 'tea gift'). Min 3 chars. - category (Optional[str]): A facet name from a previous search's facets.categories - limit (int): Results per page, 1–50 (default 10) - cursor (Optional[str]): Pagination cursor from previous response - currency (str): LKR (default), USD, GBP, AUD, EUR - min_price (Optional[float]): Min price (inclusive) in `currency` - max_price (Optional[float]): Max price (inclusive) in `currency` - in_stock_only (bool): Restrict to in-stock items (default false) - sort (str): 'relevance' | 'price_asc' | 'price_desc' | 'newest' | 'bestseller' - include_stubs (bool): Deprecated, no effect - response_format (str): 'markdown' (default) or 'json' Returns: str: Search results in the requested format. JSON schema: { "results": [ { "id": str, "type": "product", "name": str, "summary": str, "price": {"amount": float | null, "currency": str}, "compare_at_price": {"amount": float, "currency": str} | null, "in_stock": bool, "stock_level": str, "image_url": str | null, "category": {"id": str, "name": str, "slug": str}, "rating": null, "ships_internationally": bool, "url": str } ], "next_cursor": str | null, # null after page 3 even if upstream has more "total_estimate": int, # index hits for the query words (any-word match) "applied_filters": {"q": str, "category": str, "currency": str, ...}, "facets": {"categories": [{"name": str, "count": int}]}, "category_filter_dropped": str, # only when `category` was not a facet here "valid_categories": [str] # ...and the facet names that are } Error: "Error: <message>" or "No products found for '<query>'" on failure.
  - after: Search for products on Kapruka.com by keyword, with optional category filter and pagination. Returns a ranked list of matching products with prices, stock status, images, and URLs, plus the category facets available for the query (facets.categories) — the names that work in `category` to narrow the same search. Supports cursor-based pagination — pass next_cursor from one response into the next call. Pagination is capped at 3 pages per query to discourage catalog enumeration; for broader discovery, refine the query or narrow with a facet instead. Queries must be at least 3 characters and contain specific terms — pure stopword queries (e.g. "the", "a an") are rejected. Relevance: the API matches ANY single query word, so extra words add loosely related results rather than narrowing. Search the thing the customer wants (the head noun) and check the product names actually contain it before presenting them. Prices and min_price/max_price are both in `currency` — pass the customer's own budget in their own currency; do not convert it. Gifts for recipients OUTSIDE Sri Lanka are not in this search: use kapruka_gift_countries / kapruka_gift_country_products. A row's `price` is the discounted price checkout charges (the bounds filter on it); `compare_at_price` is set when on sale. Search results carry NO delivery-scope information. Food, hotel cakes and liquor are delivered only to selected cities — never infer deliverability from a search hit. Before quoting delivery on a specific item, call kapruka_get_product (read `delivery.island_wide`) or kapruka_check_delivery with `product_id`. Args: params (SearchProductsInput): - q (str): Search query (e.g. 'birthday cake', 'roses', 'tea gift'). Min 3 chars. - category (Optional[str]): A facet name from a previous search's facets.categories - limit (int): Results per page, 1–50 (default 10) - cursor (Optional[str]): Pagination cursor from previous response - currency (str): LKR (default), USD, GBP, AUD, EUR - min_price (Optional[float]): Min price (inclusive) in `currency` - max_price (Optional[float]): Max price (inclusive) in `currency` - in_stock_only (bool): Restrict to in-stock items (default false) - sort (str): 'relevance' | 'price_asc' | 'price_desc' | 'newest' | 'bestseller' - include_stubs (bool): Deprecated, no effect - response_format (str): 'markdown' (default) or 'json' Returns: str: Search results in the requested format. JSON schema: { "results": [ { "id": str, "type": "product", "name": str, "summary": str, "price": {"amount": float | null, "currency": str}, "compare_at_price": {"amount": float, "currency": str} | null, "in_stock": bool, "stock_level": str, "image_url": str | null, "category": {"id": str, "name": str, "slug": str}, "rating": null, "ships_internationally": bool, "url": str } ], "next_cursor": str | null, # null after page 3 even if upstream has more "total_estimate": int, # index hits for the query words (any-word match) "applied_filters": {"q": str, "category": str, "currency": str, ...}, "facets": {"categories": [{"name": str, "count": int}]}, "category_filter_dropped": str, # only when `category` was not a facet here "valid_categories": [str] # ...and the facet names that are } Error: "Error: <message>" or "No products found for '<query>'" on failure.
- 2026-10-20 `kapruka_get_product` changed (description)
  - before: Fetch full details for a single Kapruka product by its product ID. Returns name, description, price (with optional currency conversion), stock status, images, variants, shipping info, delivery scope, and a direct product URL. `economy_delivery` ("optional" | "only" | "only_5to7" | null) says whether the item can go by the cheaper economy courier; kapruka_check_delivery with the cart says whether an order is offered it. Discounts: `price` is what checkout charges, with any website discount already applied; `compare_at_price` is the pre-discount price when one applies (null otherwise). Each variant carries its own pair. Quote `price`. Delivery scope: most gifts ship island-wide, but restaurant food, hotel cakes and liquor only reach a limited city set (typically the Colombo area). The `delivery` object is the authority — search results do NOT carry it. When `delivery.island_wide` is false, tell the customer up front that the item is delivered only to selected cities, and confirm their city with kapruka_check_delivery(city, product_id) before promising anything. If `deliverable_city_count` exceeds the returned list, the list is truncated — say "and more", don't treat it as complete. Note: Some IDs starting with 'CATSYM' are category landing pages, not purchasable products — this tool will flag those clearly. Args: params (GetProductInput): - product_id (str): Kapruka product ID (e.g. 'cakeXX000000') - currency (str): Price currency — LKR (default), USD, GBP, AUD, EUR - type (Optional[str]): Optional type hint (e.g. 'specialgifts') - response_format (str): 'markdown' (default) or 'json' Returns: str: Product details in the requested format. JSON schema: { "id": str, "name": str, "description": str, "summary": str, "price": {"amount": float, "currency": str}, "compare_at_price": {"amount": float, "currency": str} | null, "in_stock": bool, "stock_level": str, # "low" | "medium" | "high" "category": {"id": str, "name": str, "slug": str, "path": str}, "variants": [{"id": str, "name": str, "sku": str, "price": {...}, "in_stock": bool, "stock_level": str, "attributes": {...}}], "images": [str], # list of full-resolution image URLs "attributes": {"type": str, "subtype": str, "weight": str, "vendor": str}, "shipping": {"ships_from": str, "ships_internationally": bool, "restricted_countries": [str]}, "delivery": { "island_wide": bool, "deliverable_city_count": int, # only when island_wide=false; TRUE total "deliverable_cities": [str] # only when island_wide=false; capped at 60 }, "rating": null, "url": str } Error: "Error: <message>" on failure.
  - after: Fetch full details for a single Kapruka product by its product ID. Returns name, description, price (with optional currency conversion), stock status, images, variants, shipping info, delivery scope, and a direct product URL. `economy_delivery` ("optional" | "only" | "only_5to7" | null) says whether the item can go by the cheaper economy courier; kapruka_check_delivery with the cart says whether an order is offered it. International gift store products carry `international_gift` {"country", "orderable", "store_url"}: they are delivered IN that country by Kapruka's partner (see kapruka_gift_countries), not in Sri Lanka. Discounts: `price` is what checkout charges, with any website discount already applied; `compare_at_price` is the pre-discount price when one applies (null otherwise). Each variant carries its own pair. Quote `price`. Delivery scope: most gifts ship island-wide, but restaurant food, hotel cakes and liquor only reach a limited city set (typically the Colombo area). The `delivery` object is the authority — search results do NOT carry it. When `delivery.island_wide` is false, tell the customer up front that the item is delivered only to selected cities, and confirm their city with kapruka_check_delivery(city, product_id) before promising anything. If `deliverable_city_count` exceeds the returned list, the list is truncated — say "and more", don't treat it as complete. Note: Some IDs starting with 'CATSYM' are category landing pages, not purchasable products — this tool will flag those clearly. Args: params (GetProductInput): - product_id (str): Kapruka product ID (e.g. 'cakeXX000000') - currency (str): Price currency — LKR (default), USD, GBP, AUD, EUR - type (Optional[str]): Optional type hint (e.g. 'specialgifts') - response_format (str): 'markdown' (default) or 'json' Returns: str: Product details in the requested format. JSON schema: { "id": str, "name": str, "description": str, "summary": str, "price": {"amount": float, "currency": str}, "compare_at_price": {"amount": float, "currency": str} | null, "in_stock": bool, "stock_level": str, # "low" | "medium" | "high" "category": {"id": str, "name": str, "slug": str, "path": str}, "variants": [{"id": str, "name": str, "sku": str, "price": {...}, "in_stock": bool, "stock_level": str, "attributes": {...}}], "images": [str], # list of full-resolution image URLs "attributes": {"type": str, "subtype": str, "weight": str, "vendor": str}, "shipping": {"ships_from": str, "ships_internationally": bool, "restricted_countries": [str]}, "delivery": { "island_wide": bool, "deliverable_city_count": int, # only when island_wide=false; TRUE total "deliverable_cities": [str] # only when island_wide=false; capped at 60 }, "rating": null, "url": str } Error: "Error: <message>" on failure.
- 2026-10-12 `kapruka_check_delivery` changed (description, inputSchema)
  - before: Check whether Kapruka can deliver to a given city on a given date, and the delivery fee. Returns whether the requested date is available (if not, the next available date plus reason) and the delivery fee the checkout will charge. THE FEE DEPENDS ON THE CART AND THE CURRENCY — the city's base rate is not what checkout charges. Sri Lankan customers (LKR) pay the city rate capped by the cart value (25% of the item value, at least LKR 300; some remote cities 50%), so a small cart to a far city pays far less than the rate. Overseas customers (USD) pay a fixed USD fee per city, whatever the cart. Pass `currency` and the `cart` you are about to order and the answer gives the EXACT fee kapruka_create_order will charge ("Delivery fee for this cart"). Without a cart, an LKR answer gives only the MOST the fee can be ("up to"). One shipment per order: the fee covers the whole cart. Pass `product_id` whenever the customer has named a product: the answer then also checks that ITEM's delivery scope (restaurant food, hotel cakes and liquor only reach selected cities, typically the Colombo area). With a product_id, `available` is true only if the date is open AND the item is deliverable to that city. When `item_deliverable` is false, offer the customer one of the returned `deliverable_cities` or an island-wide alternative — do not attempt kapruka_create_order with the same city, it will be rejected. An unknown product_id is silently ignored (no item fields in the result), so check `item_deliverable` is present before relying on it. product_id also selects the SAME-DAY rule the checkout applies to that item: vendor-delivered items (restaurant food) can go today until late afternoon; ordinary items move to the next date; ordinary same-day is only possible early in the morning near Colombo. So a check without product_id can differ from one with it — always pass it once an item is chosen. When `available` is false, offer `next_available_date`. Do not interpret the `reason` text (it is the website's wording and may say slots are full when the real cause is the cutoff). Perishable codes (CAKE*, FLOWER*, COMBO*) additionally get a freshness warning when the chosen delivery date is more than 1 day out. Args: params (CheckDeliveryInput): - city (str): Canonical city name (e.g. 'Colombo 03', 'Galle') - delivery_date (Optional[str]): YYYY-MM-DD; defaults to today (LK time) - product_id (Optional[str]): Check the city against this item's delivery scope - currency (Optional[str]): LKR (default) or USD/GBP/AUD/EUR (charged in USD) - cart (Optional[list]): [{product_id, quantity, icing_text?}] for the exact fee - other_items_total (Optional[float]): LKR value of items not in `cart` (custom cake quote) - response_format (str): 'markdown' (default) or 'json' Returns: str: Delivery feasibility + fee in the requested format. JSON schema: { "city": str, "now": str, # ISO timestamp, Sri Lanka time "checked_date": str, # YYYY-MM-DD "available": bool, # date open AND (if product_id) item deliverable "delivery_fee": number, # what checkout charges (see fee_basis) "fee_currency": "LKR" | "USD", "fee_basis": "cart" | "max" | "fixed", # exact for the cart | LKR without a cart: the most it can be | USD: same for any cart "fee_items_value": number, # LKR item value the fee was computed from (fee_basis=cart) "fee_cart_error": str, # the cart could not be priced (fee falls back to max) "rate": number, # the city's BASE rate (LKR) — not the checkout fee "currency": "LKR", "reason": str | null, # date-block
  - after: Check whether Kapruka can deliver to a given city on a given date, and the delivery fee. Returns whether the requested date is available (if not, the next available date plus reason) and the delivery fee the checkout will charge. THE FEE DEPENDS ON THE CART AND THE CURRENCY — the city's base rate is not what checkout charges. Sri Lankan customers (LKR) pay the city rate capped by the cart value (25% of the item value, at least LKR 300; some remote cities 50%), so a small cart to a far city pays far less than the rate. Overseas customers (USD) pay a fixed USD fee per city, whatever the cart. Pass `currency` and the `cart` you are about to order and the answer gives the EXACT fee kapruka_create_order will charge ("Delivery fee for this cart"). Without a cart, an LKR answer gives only the MOST the fee can be ("up to"). One shipment per order: the fee covers the whole cart. Pass `product_id` whenever the customer has named a product: the answer then also checks that ITEM's delivery scope (restaurant food, hotel cakes and liquor only reach selected cities, typically the Colombo area). With a product_id, `available` is true only if the date is open AND the item is deliverable to that city. When `item_deliverable` is false, offer the customer one of the returned `deliverable_cities` or an island-wide alternative — do not attempt kapruka_create_order with the same city, it will be rejected. An unknown product_id is silently ignored (no item fields in the result), so check `item_deliverable` is present before relying on it. product_id also selects the SAME-DAY rule the checkout applies to that item: vendor-delivered items (restaurant food) can go today until late afternoon; ordinary items move to the next date; ordinary same-day is only possible early in the morning near Colombo. So a check without product_id can differ from one with it — always pass it once an item is chosen. When `available` is false, offer `next_available_date`. Do not interpret the `reason` text (it is the website's wording and may say slots are full when the real cause is the cutoff). ECONOMY DELIVERY (cheaper courier, a few days instead of a chosen date) is worked out from the `cart` (and `gift_message`, which you should pass when there is one). When the answer shows an economy option, offer it to the customer as the cheaper choice, quoting its fee, promise wording and "expected by" date as given; if they take it, call kapruka_create_order with `delivery.type: "economy"` (the order is dated for you). When the answer says the order goes by economy delivery, that is the ONLY option for these items: tell the customer and order with `delivery.type: "economy"`. When economy is not shown, do not mention it. Perishable codes (CAKE*, FLOWER*, COMBO*) additionally get a freshness warning when the chosen delivery date is more than 1 day out. Args: params (CheckDeliveryInput): - city (str): Canonical city name (e.g. 'Colombo 03', 'Galle') - delivery_date (Optional[str]): YYYY-MM-DD; defaults to today (LK time) - product_id (Optional[str]): Check the city against this item's delivery scope - currency (Optional[str]): LKR (default) or USD/GBP/AUD/EUR (charged in USD) - cart (Optional[list]): [{product_id, quantity, icing_text?}] for the exact fee - other_items_total (Optional[float]): LKR value of items not in `cart` (custom cake quote) - gift_message (Optional[str]): the order's gift message, for the economy rule - response_format (str): 'markdown' (default) or 'json' Returns: str: Delivery feasibility + fee in the requested format. JSON schema: { "city": str, "now": str, # ISO timestamp, Sri Lanka time "checked_date": str,
- 2026-10-12 `kapruka_create_order` changed (description, inputSchema)
  - before: Create a guest-checkout order on Kapruka and return a click-to-pay link. Builds a Kapruka order from the supplied cart + recipient + delivery + sender, then returns a checkout URL the customer opens in a browser to complete payment. No Kapruka account is required. Prices are locked for the lifetime of the link (60 minutes) — the customer pays exactly the quoted grand total even if the catalog price changes meanwhile. Free public tier limits: 30 orders per hour per client IP. Cart up to 30 items, quantity up to 99 per item. A fresh idempotency key is generated per call so retries on transient errors return the same checkout URL rather than duplicates. Args: params (CreateOrderInput): - cart (list[CartItem]): 1–30 lines. Catalogue line: product_id, quantity (default 1), optional icing_text (cakes only). Custom cake line: custom_cake_request_id + phone — orders the cake Kapruka staff quoted via kapruka_custom_cake_status (status must be 'quoted'; quantity is always 1; never send a price). Lines can be mixed in one order — one delivery fee covers everything; the whole cart must be deliverable to delivery.city. Only place a custom cake order after the customer clearly accepted the quoted total. - recipient (Recipient): name + phone (E.164 +9477… or local 077…) - delivery (Delivery): address, city (must be Kapruka-deliverable — use kapruka_list_delivery_cities), location_type (house/apartment/office/other, default house), date (YYYY-MM-DD, today-or-future Asia/Colombo), optional instructions - sender (Sender): name + anonymous flag - gift_message (Optional[str]): Up to 300 chars - currency (str): LKR (default), USD, GBP, AUD, EUR - response_format (str): 'markdown' (default) or 'json' Returns: str: Order confirmation with checkout URL. JSON schema: { "checkout_url": str, # Open in browser to pay (no login required) "order_ref": str, # e.g. "ORD-20260520-7823" "order_id": str, # id used by the bank-deposit flow "summary": { "items_total": number, "delivery_fee": number, "addons_total": number, "grand_total": number, # items_total + delivery_fee + addons_total "currency": str }, "expires_at": str # ISO 8601 — link stops working after this } Error: "Error (<code>): <message>" on failure. Common codes: empty_cart, missing_field, past_delivery_date, product_not_found, product_out_of_stock, city_not_deliverable (city not in the network at all), date_not_deliverable, city_not_deliverable_for_item. Custom cake lines add: request_not_found (404 — wrong id/phone or staff removed it), quote_not_ready (409 — staff haven't priced it; check kapruka_custom_cake_status later), quote_expired (410 — submit a new kapruka_custom_cake_request). summary.items_total may differ from the quoted cake total by a few rupees (USD round-trip) — quote the summary numbers when asking for payment. city_not_deliverable_for_item (HTTP 422): at least one cart item (food / hotel cake / liquor) cannot reach delivery.city. NOTHING is created — the API never places a partial order and neither should you. The error text names every blocking item and lists the cities the whole cart CAN go to. Tell the customer which item blocks the order and offer to (a) change the city to one of those, or (b) remove/replace that item. Never retry with the same city. Avoid this entirely by calling kapruka_check_delivery with `product_id` for each limited item before ordering.
  - after: Create a guest-checkout order on Kapruka and return a click-to-pay link. Builds a Kapruka order from the supplied cart + recipient + delivery + sender, then returns a checkout URL the customer opens in a browser to complete payment. No Kapruka account is required. Prices are locked for the lifetime of the link (60 minutes) — the customer pays exactly the quoted grand total even if the catalog price changes meanwhile. Free public tier limits: 30 orders per hour per client IP. Cart up to 30 items, quantity up to 99 per item. A fresh idempotency key is generated per call so retries on transient errors return the same checkout URL rather than duplicates. Args: params (CreateOrderInput): - cart (list[CartItem]): 1–30 lines. Catalogue line: product_id, quantity (default 1), optional icing_text (cakes only). Custom cake line: custom_cake_request_id + phone — orders the cake Kapruka staff quoted via kapruka_custom_cake_status (status must be 'quoted'; quantity is always 1; never send a price). Lines can be mixed in one order — one delivery fee covers everything; the whole cart must be deliverable to delivery.city. Only place a custom cake order after the customer clearly accepted the quoted total. - recipient (Recipient): name + phone (E.164 +9477… or local 077…) - delivery (Delivery): address, city (must be Kapruka-deliverable — use kapruka_list_delivery_cities), location_type (house/apartment/office/other, default house), date (YYYY-MM-DD, today-or-future Asia/Colombo), optional instructions, type ('standard' default | 'economy') - sender (Sender): name + anonymous flag - gift_message (Optional[str]): Up to 300 chars - currency (str): LKR (default), USD, GBP, AUD, EUR - response_format (str): 'markdown' (default) or 'json' Returns: str: Order confirmation with checkout URL. JSON schema: { "checkout_url": str, # Open in browser to pay (no login required) "order_ref": str, # e.g. "ORD-20260520-7823" "order_id": str, # id used by the bank-deposit flow "summary": { "items_total": number, "delivery_fee": number, "addons_total": number, "grand_total": number, # items_total + delivery_fee + addons_total "currency": str, "delivery_type": "standard" | "economy", "delivery_date": str, # the date checkout will use — may differ from the one sent "delivery_promise": str # economy only: quote as given }, "expires_at": str # ISO 8601 — link stops working after this } Error: "Error (<code>): <message>" on failure. Common codes: empty_cart, missing_field, past_delivery_date, product_not_found, product_out_of_stock, city_not_deliverable (city not in the network at all), date_not_deliverable, city_not_deliverable_for_item. Custom cake lines add: request_not_found (404 — wrong id/phone or staff removed it), quote_not_ready (409 — staff haven't priced it; check kapruka_custom_cake_status later), quote_expired (410 — submit a new kapruka_custom_cake_request). summary.items_total may differ from the quoted cake total by a few rupees (USD round-trip) — quote the summary numbers when asking for payment. economy_not_available (HTTP 422): economy was requested but this order cannot go economy. Nothing is created. Tell the customer it goes by standard delivery (fee from kapruka_check_delivery) and, once they agree, resend with delivery.type 'standard' and their date. Economy-only items always go economy, even when 'standard' is sent; read summary.delivery_type a
- 2026-10-12 `kapruka_get_product` changed (description)
  - before: Fetch full details for a single Kapruka product by its product ID. Returns name, description, price (with optional currency conversion), stock status, images, variants, shipping info, delivery scope, and a direct product URL. Discounts: `price` is what checkout charges, with any website discount already applied; `compare_at_price` is the pre-discount price when one applies (null otherwise). Each variant carries its own pair. Quote `price`. Delivery scope: most gifts ship island-wide, but restaurant food, hotel cakes and liquor only reach a limited city set (typically the Colombo area). The `delivery` object is the authority — search results do NOT carry it. When `delivery.island_wide` is false, tell the customer up front that the item is delivered only to selected cities, and confirm their city with kapruka_check_delivery(city, product_id) before promising anything. If `deliverable_city_count` exceeds the returned list, the list is truncated — say "and more", don't treat it as complete. Note: Some IDs starting with 'CATSYM' are category landing pages, not purchasable products — this tool will flag those clearly. Args: params (GetProductInput): - product_id (str): Kapruka product ID (e.g. 'cakeXX000000') - currency (str): Price currency — LKR (default), USD, GBP, AUD, EUR - type (Optional[str]): Optional type hint (e.g. 'specialgifts') - response_format (str): 'markdown' (default) or 'json' Returns: str: Product details in the requested format. JSON schema: { "id": str, "name": str, "description": str, "summary": str, "price": {"amount": float, "currency": str}, "compare_at_price": {"amount": float, "currency": str} | null, "in_stock": bool, "stock_level": str, # "low" | "medium" | "high" "category": {"id": str, "name": str, "slug": str, "path": str}, "variants": [{"id": str, "name": str, "sku": str, "price": {...}, "in_stock": bool, "stock_level": str, "attributes": {...}}], "images": [str], # list of full-resolution image URLs "attributes": {"type": str, "subtype": str, "weight": str, "vendor": str}, "shipping": {"ships_from": str, "ships_internationally": bool, "restricted_countries": [str]}, "delivery": { "island_wide": bool, "deliverable_city_count": int, # only when island_wide=false; TRUE total "deliverable_cities": [str] # only when island_wide=false; capped at 60 }, "rating": null, "url": str } Error: "Error: <message>" on failure.
  - after: Fetch full details for a single Kapruka product by its product ID. Returns name, description, price (with optional currency conversion), stock status, images, variants, shipping info, delivery scope, and a direct product URL. `economy_delivery` ("optional" | "only" | "only_5to7" | null) says whether the item can go by the cheaper economy courier; kapruka_check_delivery with the cart says whether an order is offered it. Discounts: `price` is what checkout charges, with any website discount already applied; `compare_at_price` is the pre-discount price when one applies (null otherwise). Each variant carries its own pair. Quote `price`. Delivery scope: most gifts ship island-wide, but restaurant food, hotel cakes and liquor only reach a limited city set (typically the Colombo area). The `delivery` object is the authority — search results do NOT carry it. When `delivery.island_wide` is false, tell the customer up front that the item is delivered only to selected cities, and confirm their city with kapruka_check_delivery(city, product_id) before promising anything. If `deliverable_city_count` exceeds the returned list, the list is truncated — say "and more", don't treat it as complete. Note: Some IDs starting with 'CATSYM' are category landing pages, not purchasable products — this tool will flag those clearly. Args: params (GetProductInput): - product_id (str): Kapruka product ID (e.g. 'cakeXX000000') - currency (str): Price currency — LKR (default), USD, GBP, AUD, EUR - type (Optional[str]): Optional type hint (e.g. 'specialgifts') - response_format (str): 'markdown' (default) or 'json' Returns: str: Product details in the requested format. JSON schema: { "id": str, "name": str, "description": str, "summary": str, "price": {"amount": float, "currency": str}, "compare_at_price": {"amount": float, "currency": str} | null, "in_stock": bool, "stock_level": str, # "low" | "medium" | "high" "category": {"id": str, "name": str, "slug": str, "path": str}, "variants": [{"id": str, "name": str, "sku": str, "price": {...}, "in_stock": bool, "stock_level": str, "attributes": {...}}], "images": [str], # list of full-resolution image URLs "attributes": {"type": str, "subtype": str, "weight": str, "vendor": str}, "shipping": {"ships_from": str, "ships_internationally": bool, "restricted_countries": [str]}, "delivery": { "island_wide": bool, "deliverable_city_count": int, # only when island_wide=false; TRUE total "deliverable_cities": [str] # only when island_wide=false; capped at 60 }, "rating": null, "url": str } Error: "Error: <message>" on failure.
- 2026-10-05 `kapruka_search_products` changed (description)
  - before: Search for products on Kapruka.com by keyword, with optional category filter and pagination. Returns a ranked list of matching products with prices, stock status, images, and URLs, plus the category facets available for the query (facets.categories) — the names that work in `category` to narrow the same search. Supports cursor-based pagination — pass next_cursor from one response into the next call. Pagination is capped at 3 pages per query to discourage catalog enumeration; for broader discovery, refine the query or narrow with a facet instead. Queries must be at least 3 characters and contain specific terms — pure stopword queries (e.g. "the", "a an") are rejected. Relevance: the API matches ANY single query word, so extra words add loosely related results rather than narrowing. Search the thing the customer wants (the head noun) and check the product names actually contain it before presenting them. Prices and min_price/max_price are both in `currency` — pass the customer's own budget in their own currency; do not convert it. Search results carry NO delivery-scope information. Food, hotel cakes and liquor are delivered only to selected cities — never infer deliverability from a search hit. Before quoting delivery on a specific item, call kapruka_get_product (read `delivery.island_wide`) or kapruka_check_delivery with `product_id`. Args: params (SearchProductsInput): - q (str): Search query (e.g. 'birthday cake', 'roses', 'tea gift'). Min 3 chars. - category (Optional[str]): A facet name from a previous search's facets.categories - limit (int): Results per page, 1–50 (default 10) - cursor (Optional[str]): Pagination cursor from previous response - currency (str): LKR (default), USD, GBP, AUD, EUR - min_price (Optional[float]): Min price (inclusive) in `currency` - max_price (Optional[float]): Max price (inclusive) in `currency` - in_stock_only (bool): Restrict to in-stock items (default false) - sort (str): 'relevance' | 'price_asc' | 'price_desc' | 'newest' | 'bestseller' - include_stubs (bool): Deprecated, no effect - response_format (str): 'markdown' (default) or 'json' Returns: str: Search results in the requested format. JSON schema: { "results": [ { "id": str, "type": "product", "name": str, "summary": str, "price": {"amount": float | null, "currency": str}, "compare_at_price": {"amount": float, "currency": str} | null, "in_stock": bool, "stock_level": str, "image_url": str | null, "category": {"id": str, "name": str, "slug": str}, "rating": null, "ships_internationally": bool, "url": str } ], "next_cursor": str | null, # null after page 3 even if upstream has more "total_estimate": int, # index hits for the query words (any-word match) "applied_filters": {"q": str, "category": str, "currency": str, ...}, "facets": {"categories": [{"name": str, "count": int}]}, "category_filter_dropped": str, # only when `category` was not a facet here "valid_categories": [str] # ...and the facet names that are } Error: "Error: <message>" or "No products found for '<query>'" on failure.
  - after: Search for products on Kapruka.com by keyword, with optional category filter and pagination. Returns a ranked list of matching products with prices, stock status, images, and URLs, plus the category facets available for the query (facets.categories) — the names that work in `category` to narrow the same search. Supports cursor-based pagination — pass next_cursor from one response into the next call. Pagination is capped at 3 pages per query to discourage catalog enumeration; for broader discovery, refine the query or narrow with a facet instead. Queries must be at least 3 characters and contain specific terms — pure stopword queries (e.g. "the", "a an") are rejected. Relevance: the API matches ANY single query word, so extra words add loosely related results rather than narrowing. Search the thing the customer wants (the head noun) and check the product names actually contain it before presenting them. Prices and min_price/max_price are both in `currency` — pass the customer's own budget in their own currency; do not convert it. A row's `price` is the discounted price checkout charges (the bounds filter on it); `compare_at_price` is set when on sale. Search results carry NO delivery-scope information. Food, hotel cakes and liquor are delivered only to selected cities — never infer deliverability from a search hit. Before quoting delivery on a specific item, call kapruka_get_product (read `delivery.island_wide`) or kapruka_check_delivery with `product_id`. Args: params (SearchProductsInput): - q (str): Search query (e.g. 'birthday cake', 'roses', 'tea gift'). Min 3 chars. - category (Optional[str]): A facet name from a previous search's facets.categories - limit (int): Results per page, 1–50 (default 10) - cursor (Optional[str]): Pagination cursor from previous response - currency (str): LKR (default), USD, GBP, AUD, EUR - min_price (Optional[float]): Min price (inclusive) in `currency` - max_price (Optional[float]): Max price (inclusive) in `currency` - in_stock_only (bool): Restrict to in-stock items (default false) - sort (str): 'relevance' | 'price_asc' | 'price_desc' | 'newest' | 'bestseller' - include_stubs (bool): Deprecated, no effect - response_format (str): 'markdown' (default) or 'json' Returns: str: Search results in the requested format. JSON schema: { "results": [ { "id": str, "type": "product", "name": str, "summary": str, "price": {"amount": float | null, "currency": str}, "compare_at_price": {"amount": float, "currency": str} | null, "in_stock": bool, "stock_level": str, "image_url": str | null, "category": {"id": str, "name": str, "slug": str}, "rating": null, "ships_internationally": bool, "url": str } ], "next_cursor": str | null, # null after page 3 even if upstream has more "total_estimate": int, # index hits for the query words (any-word match) "applied_filters": {"q": str, "category": str, "currency": str, ...}, "facets": {"categories": [{"name": str, "count": int}]}, "category_filter_dropped": str, # only when `category` was not a facet here "valid_categories": [str] # ...and the facet names that are } Error: "Error: <message>" or "No products found for '<query>'" on failure.

Full history: https://api.protogrid.dev/v1/servers/com.kapruka%2Fkapruka-mcp/changes

## Remotes

- https://mcp.kapruka.com/mcp (streamable-http, auth none, reachable false, uptime30d 0.9916667)

## Packages

_none_

## Tools (10)

- `kapruka_check_delivery`: Check whether Kapruka can deliver to a given city on a given date, and the delivery fee.

    Returns whether the requested date is available (if not, the next available
    date plus reason) and the delivery fee the checkout will charge.

    THE FEE DEPENDS ON THE CART AND THE CURRENCY — the city's base rate is not
    what checkout charges. Sri Lankan customers (LKR) pay the city rate capped
    by the cart value (25% of the item value, at least LKR 300; some remote
    cities 50%), so a small cart to a far city pays far less than the rate.
    Overseas customers (USD) pay a fixed USD fee per city, whatever the cart.
    Pass `currency` and the `cart` you are about to order and the answer gives
    the EXACT fee kapruka_create_order will charge ("Delivery fee for this
    cart"). Without a cart, an LKR answer gives only the MOST the fee can be
    ("up to"). One shipment per order: the fee covers the whole cart.

    Pass `product_id` whenever the customer has named a product: the answer then
    also checks that ITEM's delivery scope (restaurant food, hotel cakes and
    liquor only reach selected cities, typically the Colombo area). With a
    product_id, `available` is true only if the date is open AND the item is
    deliverable to that city. When `item_deliverable` is false, offer the
    customer one of the returned `deliverable_cities` or an island-wide
    alternative — do not attempt kapruka_create_order with the same city, it
    will be rejected. An unknown product_id is silently ignored (no item fields
    in the result), so check `item_deliverable` is present before relying on it.

    product_id also selects the SAME-DAY rule the checkout applies to that item:
    vendor-delivered items (restaurant food) can go today until late afternoon;
    ordinary items move to the next date; ordinary same-day is only possible early
    in the morning near Colombo. So a check without product_id can differ from one
    with it — always pass it once an item is chosen. When `available` is false,
    offer `next_available_date`. Do not interpret the `reason` text (it is the
    website's wording and may say slots are full when the real cause is the cutoff).

    ECONOMY DELIVERY (cheaper courier, a few days instead of a chosen date) is
    worked out from the `cart` (and `gift_message`, which you should pass when
    there is one). When the answer shows an economy option, offer it to the
    customer as the cheaper choice, quoting its fee, promise wording and
    "expected by" date as given; if they take it, call kapruka_create_order with
    `delivery.type: "economy"` (the order is dated for you). When the answer says
    the order goes by economy delivery, that is the ONLY option for these items:
    tell the customer and order with `delivery.type: "economy"`. When economy is
    not shown, do not mention it.

    Perishable codes (CAKE*, FLOWER*, COMBO*) additionally get a freshness
    warning when the chosen delivery date is more than 1 day out.

    INTERNATIONAL GIFTS (store products from kapruka_gift_country_products): pass
    product_id or cart (no city needed) and, for Australia / the UAE, `zone`. The
    answer is that country's delivery: available or not, the fee for the whole order,
    and the earliest date (3 days ahead; used when delivery_date is omitted).

    Args:
        params (CheckDeliveryInput):
            - city (str): Canonical city name (e.g. 'Colombo 03', 'Galle'); not needed for
              international gift store products
            - zone (Optional[str]): international gifts in Australia / the UAE
            - delivery_date (Optional[str]): YYYY-MM-DD; defaults to today (LK time)
            - product_id (Optional[str]): Check the city against this item's delivery scope
            - currency (Optional[str]): LKR (default) or USD/GBP/AUD/EUR (charged in USD)
            - cart (Optional[list]): [{product_id, quantity, icing_text?}] for the exact fee
            - other_items_total (Optional[float]): LKR value of items not in `cart` (custom cake quote)
            - gift_message (Optional[str]): the order's gift message, for the economy rule
            - response_format (str): 'markdown' (default) or 'json'

    Returns:
        str: Delivery feasibility + fee in the requested format.

        JSON schema:
        {
          "city": str,
          "now": str,                       # ISO timestamp, Sri Lanka time
          "checked_date": str,              # YYYY-MM-DD
          "available": bool,                # date open AND (if product_id) item deliverable
          "delivery_fee": number,           # what checkout charges (see fee_basis)
          "fee_currency": "LKR" | "USD",
          "fee_basis": "cart" | "max" | "fixed",  # exact for the cart | LKR without a cart: the most it can be | USD: same for any cart
          "fee_items_value": number,        # LKR item value the fee was computed from (fee_basis=cart)
          "fee_cart_error": str,            # the cart could not be priced (fee falls back to max)
          "rate": number,                   # the city's BASE rate (LKR) — not the checkout fee
          "currency": "LKR",
          "reason": str | null,             # date-block message, else "This item is not delivered to <City>."
          "next_available_date": str|null,  # only for date blocks
          "item_deliverable": bool,         # only when product_id resolved to a real product
          "deliverable_cities": [str],      # only when item_deliverable=false (capped at 60)
          "perishable_warning": str | null, # populated when product_id is perishable
          "economy": {                      # only with `cart`
            "available": bool,              # false: say nothing about economy
            "forced": bool,                 # true: economy is the ONLY option
            "fee": number, "currency": "LKR",
            "promise": str,                 # quote as given
            "delivery_date": str,           # "expected by" date; create_order uses it
            "saving": number | null         # vs the standard fee
          }
        }
    
- `kapruka_create_order`: Create a guest-checkout order on Kapruka and return a click-to-pay link.

    Builds a Kapruka order from the supplied cart + recipient + delivery + sender,
    then returns a checkout URL the customer opens in a browser to complete payment.
    No Kapruka account is required. Prices are locked for the lifetime of the link
    (60 minutes) — the customer pays exactly the quoted grand total even if the
    catalog price changes meanwhile.

    Free public tier limits: 30 orders per hour per client IP. Cart up to 30 items,
    quantity up to 99 per item. A fresh idempotency key is generated per call so
    retries on transient errors return the same checkout URL rather than duplicates.

    Args:
        params (CreateOrderInput):
            - cart (list[CartItem]): 1–30 lines. Catalogue line: product_id, quantity (default 1), optional icing_text (cakes only).
              Custom cake line: custom_cake_request_id + phone — orders the cake Kapruka staff quoted via
              kapruka_custom_cake_status (status must be 'quoted'; quantity is always 1; never send a price).
              Lines can be mixed in one order — one delivery fee covers everything; the whole cart must be
              deliverable to delivery.city. Only place a custom cake order after the customer clearly
              accepted the quoted total.
            - recipient (Recipient): name + phone (E.164 +9477… or local 077…)
            - delivery (Delivery): address, city (must be Kapruka-deliverable — use kapruka_list_delivery_cities), location_type (house/apartment/office/other, default house), date (YYYY-MM-DD, today-or-future Asia/Colombo), optional instructions, type ('standard' default | 'economy')
            - sender (Sender): name + anonymous flag
            - gift_message (Optional[str]): Up to 300 chars
            - currency (str): LKR (default), USD, GBP, AUD, EUR
            - response_format (str): 'markdown' (default) or 'json'

    Returns:
        str: Order confirmation with checkout URL.

        JSON schema:
        {
          "checkout_url": str,           # Open in browser to pay (no login required)
          "order_ref": str,              # e.g. "ORD-20260520-7823"
          "order_id": str,               # id used by the bank-deposit flow
          "summary": {
            "items_total":   number,
            "delivery_fee":  number,
            "addons_total":  number,
            "grand_total":   number,     # items_total + delivery_fee + addons_total
            "currency":      str,
            "delivery_type": "standard" | "economy",
            "delivery_date": str,        # the date checkout will use — may differ from the one sent
            "delivery_promise": str      # economy only: quote as given
          },
          "expires_at": str              # ISO 8601 — link stops working after this
        }

        Error: "Error (<code>): <message>" on failure. Common codes:
          empty_cart, missing_field, past_delivery_date, product_not_found,
          product_out_of_stock, city_not_deliverable (city not in the network at
          all), date_not_deliverable, city_not_deliverable_for_item.

        Custom cake lines add: request_not_found (404 — wrong id/phone or staff
        removed it), quote_not_ready (409 — staff haven't priced it; check
        kapruka_custom_cake_status later), quote_expired (410 — submit a new
        kapruka_custom_cake_request). summary.items_total may differ from the
        quoted cake total by a few rupees (USD round-trip) — quote the summary
        numbers when asking for payment.

        economy_not_available (HTTP 422): economy was requested but this order
        cannot go economy. Nothing is created. Tell the customer it goes by
        standard delivery (fee from kapruka_check_delivery) and, once they agree,
        resend with delivery.type 'standard' and their date.

        Economy-only items always go economy, even when 'standard' is sent; read
        summary.delivery_type and summary.delivery_date and tell the customer the
        date checkout will use.

        city_not_deliverable_for_item (HTTP 422): at least one cart item (food /
        hotel cake / liquor) cannot reach delivery.city. NOTHING is created — the
        API never places a partial order and neither should you. The error text
        names every blocking item and lists the cities the whole cart CAN go to.
        Tell the customer which item blocks the order and offer to (a) change the
        city to one of those, or (b) remove/replace that item. Never retry with
        the same city. Avoid this entirely by calling kapruka_check_delivery with
        `product_id` for each limited item before ordering.
    
- `kapruka_get_product`: Fetch full details for a single Kapruka product by its product ID.

    Returns name, description, price (with optional currency conversion), stock status,
    images, variants, shipping info, delivery scope, and a direct product URL.

    `economy_delivery` ("optional" | "only" | "only_5to7" | null) says whether the
    item can go by the cheaper economy courier; kapruka_check_delivery with the
    cart says whether an order is offered it.

    International gift store products carry `international_gift` {"country", "orderable",
    "store_url"}: they are delivered IN that country by Kapruka's partner (see
    kapruka_gift_countries), not in Sri Lanka.

    Discounts: `price` is what checkout charges, with any website discount already
    applied; `compare_at_price` is the pre-discount price when one applies (null
    otherwise). Each variant carries its own pair. Quote `price`.

    Delivery scope: most gifts ship island-wide, but restaurant food, hotel cakes
    and liquor only reach a limited city set (typically the Colombo area). The
    `delivery` object is the authority — search results do NOT carry it. When
    `delivery.island_wide` is false, tell the customer up front that the item is
    delivered only to selected cities, and confirm their city with
    kapruka_check_delivery(city, product_id) before promising anything. If
    `deliverable_city_count` exceeds the returned list, the list is truncated —
    say "and more", don't treat it as complete.

    Note: Some IDs starting with 'CATSYM' are category landing pages, not purchasable
    products — this tool will flag those clearly.

    Args:
        params (GetProductInput):
            - product_id (str): Kapruka product ID (e.g. 'cakeXX000000')
            - currency (str): Price currency — LKR (default), USD, GBP, AUD, EUR
            - type (Optional[str]): Optional type hint (e.g. 'specialgifts')
            - response_format (str): 'markdown' (default) or 'json'

    Returns:
        str: Product details in the requested format.

        JSON schema:
        {
          "id": str,
          "name": str,
          "description": str,
          "summary": str,
          "price": {"amount": float, "currency": str},
          "compare_at_price": {"amount": float, "currency": str} | null,
          "in_stock": bool,
          "stock_level": str,           # "low" | "medium" | "high"
          "category": {"id": str, "name": str, "slug": str, "path": str},
          "variants": [{"id": str, "name": str, "sku": str, "price": {...},
                        "in_stock": bool, "stock_level": str, "attributes": {...}}],
          "images": [str],              # list of full-resolution image URLs
          "attributes": {"type": str, "subtype": str, "weight": str, "vendor": str},
          "shipping": {"ships_from": str, "ships_internationally": bool, "restricted_countries": [str]},
          "delivery": {
            "island_wide": bool,
            "deliverable_city_count": int,   # only when island_wide=false; TRUE total
            "deliverable_cities": [str]      # only when island_wide=false; capped at 60
          },
          "rating": null,
          "url": str
        }

        Error: "Error: <message>" on failure.
    
- `kapruka_gift_countries`: List the countries Kapruka delivers gifts IN (international gift stores).

    Use when the RECIPIENT is outside Sri Lanka — e.g. "send flowers to my sister in
    Dubai", "a cake for my son in Melbourne". These gifts are delivered by Kapruka's
    partner in that country, not shipped from Sri Lanka. They are NOT in
    kapruka_search_products; list a store with kapruka_gift_country_products.

    For each country: how many gifts are in stock now (0 = nothing can be ordered
    there right now — say so), the delivery fee (one fee per order, whatever the
    number of items), and for Australia and the UAE the zones they deliver to —
    delivery is ONLY to those zones, each with its own fee. The earliest delivery
    date is 3 days from today (Sri Lanka date).

    Args:
        params: currency ('LKR' default or 'USD'), response_format.

    Returns:
        str. JSON: {"currency", "earliest_date", "countries": [{"country", "products_in_stock",
        "delivery_fee": {"amount","currency"}, "zone_required": bool,
        "zones": [{"name","delivery_fee"}], "earliest_date", "store_url"}]}
    
- `kapruka_gift_country_products`: List EVERY gift in stock in one country's store (international gifts), grouped by type.

    The stores are small (tens of products), so this returns the whole store: read it
    and choose for the customer — there is no keyword search for these products.
    Prices are exactly what checkout charges for the item (no website discounts apply
    to these stores); delivery is extra — one fee per order (zone fee in Australia /
    the UAE), shown at the top. Use the `id` with kapruka_get_product for full detail
    and with kapruka_create_order (delivery.type "international_gift").

    Args:
        params: country, type (optional filter), currency ('LKR' default or 'USD'),
            response_format.

    Returns:
        str. JSON: {"country", "currency", "count", "delivery": {country row from
        kapruka_gift_countries}, "products": [{"id", "name", "type", "summary",
        "price": {"amount","currency"}, "image_url", "url"}]}
    
- `kapruka_list_categories`: List top-level Kapruka product categories by name with browse URLs.

    Returns the site's NAVIGATION categories plus the public Kapruka.com URL for each
    category landing page — useful for sending a customer to a category to browse.

    These names are NOT search filters. kapruka_search_products filters on search
    facets, a different vocabulary (e.g. navigation 'Electronic' vs facet 'Electronics',
    'Cakes' vs 'Kapruka Cakes'); every search response lists its valid facet names
    under facets.categories. Use those for `category`, never these. Internal IDs and
    product counts are not exposed. Results are cached for 30 minutes server-side.

    Args:
        params (ListCategoriesInput):
            - depth (int): Sub-category levels to include, 1 or 2 (default 1)
            - response_format (str): 'markdown' (default) or 'json'

    Returns:
        str: Category tree in the requested format.

        JSON schema:
        {
          "categories": [
            {
              "name": str,
              "url": str,                  # kapruka.com category landing page
              "children": [{"name": str, "url": str, "children": [...]}]
            }
          ]
        }

        Error: "Error: <message>" on failure.
    
- `kapruka_list_delivery_cities`: List or search Sri Lankan cities Kapruka delivers to.

    Use the `query` param to filter (e.g. "colombo" → all Colombo zones,
    "anur" → Anuradhapura). Without a query you get the first 25 cities
    alphabetically, which is rarely what an agent needs — pass a query.

    Returns canonical city names (use these as the `city` argument to
    kapruka_check_delivery) plus any common aliases / vernacular spellings.

    Args:
        params (ListDeliveryCitiesInput):
            - query (Optional[str]): Partial match filter
            - limit (int): Max results, 1–50 (default 25)
            - response_format (str): 'markdown' (default) or 'json'

    Returns:
        str: Cities list in the requested format.

        JSON schema:
        {
          "cities": [{"name": str, "aliases": [str]}],
          "total_matched": int,
          "showing": int
        }
    
- `kapruka_render_options_card`: Render 1-4 products as ONE shareable JPEG "menu" card and return its URL.

    The card shows each product's photo with a big numbered badge (the ref you
    assign), and its name + price printed under the photo. Built for chat
    commerce (WhatsApp): send the image, tell the customer "reply 1, 2 or 3",
    and they pick without opening links. No AI is involved — the image is
    server-composited from the live catalog data, so prices match what the
    product tools return.

    Ref numbering contract: refs are yours to assign — use sequential numbers
    per conversation and NEVER reuse one (if the first card was 1-3, the next
    card starts at 4). A number must keep meaning the same product for the whole
    conversation.

    Args:
        params (RenderOptionsCardInput):
            - items (list[CardProduct]): 1-4 of {product_id, ref}
            - currency (str): LKR (default), USD, GBP, AUD, EUR
            - courtesy ({currency, per_usd}, optional): home-currency figure printed
              under each USD price ("≈ JPY 2,544"); the caller's rate, echoed back
            - footer_note (str, optional): appended to the footer reply hint

    Returns:
        str: JSON:
        {
          "card_url": str,              # public JPEG URL — send this as the image
          "items": [{"ref": int, "product_id": str, "name": str,
                      "price": {"amount": float, "currency": str},
                      "price_note": str | null, "url": str}],
          "courtesy": {"currency": str, "per_usd": float} | null,  # what was printed, or null
          "unavailable": [str]          # product_ids that failed to load (omitted from card)
        }

        Error: "Error: <message>" when no product could be loaded.
    
- `kapruka_search_products`: Search for products on Kapruka.com by keyword, with optional category filter and pagination.

    Returns a ranked list of matching products with prices, stock status, images, and URLs,
    plus the category facets available for the query (facets.categories) — the names that
    work in `category` to narrow the same search.
    Supports cursor-based pagination — pass next_cursor from one response into the next call.
    Pagination is capped at 3 pages per query to discourage catalog enumeration; for broader
    discovery, refine the query or narrow with a facet instead.

    Queries must be at least 3 characters and contain specific terms — pure stopword queries
    (e.g. "the", "a an") are rejected.

    Relevance: the API matches ANY single query word, so extra words add loosely related
    results rather than narrowing. Search the thing the customer wants (the head noun) and
    check the product names actually contain it before presenting them.

    Prices and min_price/max_price are both in `currency` — pass the customer's own budget
    in their own currency; do not convert it. Gifts for recipients OUTSIDE Sri Lanka are not
    in this search: use kapruka_gift_countries / kapruka_gift_country_products. A row's `price` is the discounted price
    checkout charges (the bounds filter on it); `compare_at_price` is set when on sale.

    Search results carry NO delivery-scope information. Food, hotel cakes and liquor
    are delivered only to selected cities — never infer deliverability from a search
    hit. Before quoting delivery on a specific item, call kapruka_get_product (read
    `delivery.island_wide`) or kapruka_check_delivery with `product_id`.

    Args:
        params (SearchProductsInput):
            - q (str): Search query (e.g. 'birthday cake', 'roses', 'tea gift'). Min 3 chars.
            - category (Optional[str]): A facet name from a previous search's facets.categories
            - limit (int): Results per page, 1–50 (default 10)
            - cursor (Optional[str]): Pagination cursor from previous response
            - currency (str): LKR (default), USD, GBP, AUD, EUR
            - min_price (Optional[float]): Min price (inclusive) in `currency`
            - max_price (Optional[float]): Max price (inclusive) in `currency`
            - in_stock_only (bool): Restrict to in-stock items (default false)
            - sort (str): 'relevance' | 'price_asc' | 'price_desc' | 'newest' | 'bestseller'
            - include_stubs (bool): Deprecated, no effect
            - response_format (str): 'markdown' (default) or 'json'

    Returns:
        str: Search results in the requested format.

        JSON schema:
        {
          "results": [
            {
              "id": str,
              "type": "product",
              "name": str,
              "summary": str,
              "price": {"amount": float | null, "currency": str},
              "compare_at_price": {"amount": float, "currency": str} | null,
              "in_stock": bool,
              "stock_level": str,
              "image_url": str | null,
              "category": {"id": str, "name": str, "slug": str},
              "rating": null,
              "ships_internationally": bool,
              "url": str
            }
          ],
          "next_cursor": str | null,     # null after page 3 even if upstream has more
          "total_estimate": int,         # index hits for the query words (any-word match)
          "applied_filters": {"q": str, "category": str, "currency": str, ...},
          "facets": {"categories": [{"name": str, "count": int}]},
          "category_filter_dropped": str,   # only when `category` was not a facet here
          "valid_categories": [str]         # ...and the facet names that are
        }

        Error: "Error: <message>" or "No products found for '<query>'" on failure.
    
- `kapruka_track_order`: Look up status and delivery progress for a Kapruka order by order number.

    Returns current status (received / confirmed / out-for-delivery / delivered /
    cancelled), the recipient and delivery details on file, a timestamped progress
    timeline, the cart contents, and flags for whether a delivery photo or video is
    available. Use this after a customer has placed and paid for an order and reads
    back the order number from their confirmation email or the order complete page.

    The order number is NOT the `order_ref` returned by kapruka_create_order
    (which is the pre-payment checkout reference). Once the customer completes
    payment in the browser, Kapruka emails them a separate order number — that
    is what this tool expects.

    Args:
        params (TrackOrderInput):
            - order_number (str): Kapruka order number (e.g. 'VIMP34456CB2')
            - response_format (str): 'markdown' (default) or 'json'

    Returns:
        str: Order tracking details in the requested format.

        JSON schema:
        {
          "order_number": str,
          "pnref": str,                 # internal payment reference (numeric; not the same as order_number)
          "status": str,                # received | confirmed | shipped | delivered | cancelled | ...
          "status_display": str,        # human label
          "order_date": str,            # human-formatted, Asia/Colombo
          "delivery_date": str,         # human-formatted
          "shipped_date": str | null,
          "amount": str,                # LKR string (e.g. "15500.00")
          "payment_method": str,
          "comments": str | null,
          "recipient": {"name": str, "phone": str, "address": str, "city": str},
          "greeting_message": str | null,
          "special_instructions": str | null,
          "progress": [{"step": str, "timestamp": str}],
          "live_tracking_available": bool,
          "has_delivery_video": bool,
          "has_delivery_photo": bool,
          "items": [{"product_id": str, "name": str, "quantity": int, "selling_price": float}]
        }

        Error: "Error: <message>" on failure (e.g. order not found).
    
