silpo-py-mcp

Typed Python client for the official Silpo MCP server
(https://mcp.silpo.ua/mcp).
Built on FastMCP 3.4.7 for the Silpo AI Factory
hackathon. One library, two modes:
- Real server — Streamable HTTP transport with OAuth 2.1 + PKCE, encrypted
on-disk token storage, typed methods mirroring the documented
silpo_*tools. - In-memory mock — a FastMCP server that implements the same 42 tools with realistic fixtures, so you can develop and test without a Silpo account.
Requires Python 3.12+.
Install
pip install silpo-py-mcp
# or with uv
uv add silpo-py-mcp
# local development
uv sync
Quick start (mock — no auth needed)
import asyncio
from silpo_py_mcp import SilpoClient
async def main() -> None:
async with SilpoClient.for_mock() as client:
result = await client.get_products(
"bran-1",
"DeliveryHome",
"2026-09-06T10:00:00+03:00",
"2026-09-06T11:00:00+03:00",
category="Молочні продукти",
)
for product in result.items:
print(product.title, product.price)
cart = await client.get_cart()
await client.add_cart_products(
cart.cart_id,
[
{
"productId": product.product_id,
"companyId": product.company_id,
"branchId": product.branch_id,
"quantity": 2,
}
for product in result.items
],
)
full = await client.get_cart_by_id(cart.cart_id)
print("Total:", full.totals.total_price)
asyncio.run(main())
Quick start (real server)
The first connection opens a browser for login at auth.silpo.ua
(phone + OTP or password). Tokens are encrypted and stored on disk, and the
client refreshes them automatically.
import asyncio
from silpo_py_mcp import SilpoClient
async def main() -> None:
async with SilpoClient.for_real_server() as client:
tools = await client.list_tools()
print(f"Connected. {len(tools)} tools available.")
branches = await client.call_tool("silpo_list_branches", {"limit": 1})
print("Branch:", branches["branches"][0]["address"])
asyncio.run(main())
Note on typed methods vs the real server. The typed methods and the mock mirror the live
tools/listschemas and response shapes (verified Sep 2026; re-verified Sep 7 2026; reconciled with server release-1.110.0 on Sep 10 2026; reconciled with server release-1.110.1 on Sep 11 2026; re-verified Sep 14 2026 — still 40 tools, no renames, no required-set changes; reconciled with server release-1.111.0/1.111.1 on Sep 25 2026 — still 40 tools, no renames; reconciled with server release-1.111.2 on Sep 28 2026 — still 40 tools, no renames; reconciled with server release-1.111.3 on Sep 28 2026 — still 40 tools, no renames, no required-set changes, with the cart/payment/category changes below; reconciled with server release-1.111.4 on Sep 30 2026 — still 40 tools, no renames, no required-set changes, with the promotions/time-slots/cart-description fixes below; reconciled with server release-1.111.5 on Sep 30 2026 — still 40 tools, no renames, no required-set changes, with the gateway coupon below; reconciled with server release-1.112.1 on Oct 20 2026 — 42 tools now: the cart-product tools were split andget_product_setsmoved to its v2 endpoint, see the release-1.112.1 section below). Context arguments such asbranchId/deliveryType/timeslotStart/timeslotEndare required where the live schema requires them — since 1.110.1 this includessilpo_get_similar_products— cart tools takeshoppingCartId, and since release-1.112.1 the cart-product tools are split:silpo_add_cart_productstakesproducts([{productId, companyId, branchId, quantity, comment?}]) for products that are not in the cart yet, whilesilpo_update_cart_productstakesproducts([{id, quantity, comment?}]) for products that are in it —idis the cart line-item id (cart.shipments[].products[].id), not the product id, andquantityis the new total, not a delta.silpo_remove_cart_productstakesproducts([{id}]) — the same line-item id. The oldsilpo_add_or_update_cart_productsis deprecated on the server (every call fails with a pointer to the two replacements); the client keeps a compatibility shim that routes to them.silpo_add_or_update_certificatestakescertificatesToAdd/certificatesToRemoveas[{barcode, pincode?}]objects (plain barcode strings are converted automatically). Product results carrydisplayPrice(fromPrice/toPricefilter by it, not byprice);get_productsrequires at least one ofcategory/mustHavePromotion/promotionCode/set(release-1.111.0: missing filter raisesValueErrorclient-side with the accepted-filter list;timeslotStart/timeslotEndare not validated server-side — a bogus slot silently returns the full catalog, so pass a real slot fromget_time_slots);get_time_slotssendsdeliveryTypes(singulardeliveryTypeaccepted server-side as an alias since 1.111.0; millisecond timestamps stripped since 1.111.1) and each slot carriesserviceFee(SelfPickup "Сервісний збір"); since release-1.111.2 the server normalizes timeslot bounds (Z,+00:00and naive stamps are all read as UTC, date-only values are rejected) and restrictsdeliveryTypesto the 9-valueTimeSlotDeliveryTypeenum —get_time_slotsvalidates the types, thelimitbounds (1..100) and the timestamp format client-side instead of letting the server answer-32602/400; release-1.111.4 makes the date normalizer more lenient (whitespace, lowercasez, comma fractions,+HHMM/+HHoffsets and a space separator are read as UTC) and adds atotalto the slots response ({success, summary, slots, total}— the client returns the list and ignores the count);get_promotionsfor an unknown branch returns[]instead of the pre-1.111.4500;find_addresssurfaceswarning/houseNumberMatchedfor unmatched house numbers (release-1.111.1 — check before relying on coordinates);get_available_delivery_typescoordinates are only validated for home-delivery types (SelfPickup/NovaPoshtaalways returned);get_product_detailscarriesdisplayPrice/image/specialPrices/externalProductIdplushasOfferAtBranch(release-1.110.0:price/displayPrice/stock/availableare the requested branch's real offer — checkhas_offer_at_branchfirst,Falsemeans no real offer at that branch);get_cart_by_idmapsserviceFeefrom the top level orcalculation(release-1.111.1); batch empty entries are skipped (meta.droppedCount→BatchProductResult.dropped_count) andqueries[].totalFoundsurfaces asBatchProductResult.total_found(truncated_queriesflags queries whose real match count exceeded the returned products). Coupon eligibility comes fromcanBeAppliedToOrderonsilpo_get_coupon_details— never infer it fromactive/statealone; since release-1.111.5 a non-nullsuccessorThresholdmarks a gateway coupon (CouponDetail.is_gateway) that unlocks a different promo once the threshold is spent — presentsuccessor_threshold.descriptionand readprogressas tracking toward that threshold. The mock cart tools (silpo_get_shopping_cart_by_id,silpo_clear_shopping_cart, ...) accept onlyshoppingCartId— the legacycartIdalias was removed in 0.3.1 to match the live schema.call_toolalways passes arguments through verbatim for one-off calls. Responses come back JSON-like (nested FastMCPRootdataclasses are unwrapped automatically).
release-1.111.3 (client 0.8.0) — breaking
| Change | Detail |
|---|---|
silpo_get_category dropped children |
The upstream API never returns child categories for this endpoint. CategoryDetail.subcategories is removed. Use get_categories_tree to discover children. The response now carries the ancestor path, a priceRange and a visible flag (False = no products at this branch) — exposed as detail.path, detail.price_range and detail.is_visible. |
| Cart payment methods | get_cart_by_id now parses calculation.payment into CartPayment: payment.available_types lists every usable method (with payment.loan/loan_config for BNPL) and payment.unavailable_types the blocked ones. cart.payment_type is only the selected method and stays "Unknown" until the guest picks one at checkout — do not read it as the list of options. |
| Cart delivery discount | calculation.delivery is exposed as cart.delivery with sub_total/sub_discount (so sub_total - sub_discount == total, plus discount_percent) and mirrored on cart.totals.delivery_sub_total/delivery_discount. The reason for a discount is not reported — order-total tiers and premium-subscription perks can each cause it. |
| Amount to show the guest | calculation.total is the total before discounts; cart.total_to_pay (calculation.totalAfterDiscounts) is what the guest actually pays. Always present the latter. |
update_shopping_cart schema |
address must carry an addressType (CartAddressType: house/flat/office/point/self-pickup/nova-poshta) and each shipments entry a companyId + branchId pair; deliveryType is narrowed to the 8-value UpdateCartDeliveryType. update_shopping_cart validates all of this client-side and raises ValueError naming the offending argument. Copy both objects from the cart via cart.update_payloads (address/shipments/timeslot/missing_fields) instead of building them. clear_bonus=True sends the explicit bonusRequested: null that removes bonus payment. |
get_time_slots / product docs clarified |
Slot times are UTC; available is authoritative (a type can show pricing while every slot is unavailable — filter on TimeSlot.is_bookable); minOrderCost is reported only by this tool; serviceFee is a preview that calculation.serviceFee.total supersedes once a cart exists. For weighted=True products step and cart quantity are always kilograms, whatever display_ratio shows. |
release-1.111.4 (client 0.8.1)
| Change | Detail |
|---|---|
get-promotions 500 fix |
An unknown branchId returns [] instead of the previous 500 error — the mock mirrors it and get_promotions documents the empty-means-no-promotions contract. |
get-time-slots date normalizer |
More lenient: surrounding whitespace, lowercase z, comma fractions, +HHMM/+HH offsets and a space separator are all accepted and read as UTC (silpo_py_mcp.slot_time, shared by the client and the mock). Date-only/unparseable values still fail fast (ValueError client-side, 400 on the wire). |
get-time-slots total response |
The live response now carries total alongside slots; get_time_slots unwraps slots/timeSlots/deliveryTimeSlots and ignores the count (it always equals len(slots)). |
add-or-update-cart-products description |
Clarified tool description (full line shape, replace-vs-add, kilograms for weighted products, get_cart_by_id afterwards) — behavior unchanged; client and mock docstrings carry it. |
release-1.111.5 (client 0.8.2)
| Change | Detail |
|---|---|
get-coupon-details gateway coupons |
The response now carries successorThreshold (promoId/amount/description/rewardText, null for ordinary coupons). A non-null value means the coupon unlocks a different promo once the threshold is spent (e.g. Yezzz!: spend ₴2000 → безкоштовний зв'язок) — exposed as CouponDetail.successor_threshold with the is_gateway helper; progress tracks toward that threshold. The mock returns the live gateway shape for fixture id 600971427. |
release-1.112.1 (client 0.9.0) — breaking
| Change | Detail |
|---|---|
| Cart-product tools split | silpo_add_or_update_cart_products is deprecated — the server now answers every call with an error pointing to its replacements. Use add_cart_products for products that are not in the cart yet ({productId, companyId, branchId, quantity, comment?}) and update_cart_products for products that already are ({id, quantity, comment?}). The old typed method still works as a deprecated shim: it reads the cart, routes every line to the right tool and emulates the old addQuantity semantics (it raises a DeprecationWarning). |
remove_cart_products takes line-item ids |
Breaking: the server now expects the cart line-item id (cart.shipments[].products[].id), not the product id — the old shape is rejected by the schema (-32602) and an unknown id is dropped silently. remove_cart_products(cart_id, line_ids) sends products: [{"id": …}]; remove_cart_products_by_product(cart_id, product_id) resolves the product id to the line id for you. |
| Update quantities are absolute | update_cart_products has no "add N more" mode: quantity is the new total. Read the current quantity from the cart and send current + N to increase it. |
| Cart lines are first-class | SilpoCart.lines normalizes the cart lines of both transports (CartLine), with line.id (the line-item id), line.product_id, line.quantity; cart.line_ids, cart.line_for(product_id) and cart.line_by_id(line_id) are the helpers. |
get_product_sets moved to v2 |
The tool now takes only branchId — deliveryType is gone from the live schema. get_product_sets(branch_id) no longer sends it; the deprecated delivery_type keyword is accepted but ignored (with a DeprecationWarning). |
find_products_batch runs on the new search |
The tool schema is unchanged, but the results now come from the new search version and carry the same fields as get_products (id/name/slug/price/displayPrice/stock/available/weighted/step/displayRatio/companyId/branchId/externalProductId) — product_id/company_id/branch_id can be fed straight into add_cart_products. |
Smoke test against the real server
examples/real_smoke.py verifies the live contract and runs a read-only
battery of calls:
uv run examples/real_smoke.py
On the first run a browser opens for login at auth.silpo.ua; afterwards the
encrypted token in ~/.silpo_py_mcp is reused. The script checks:
- live
tools/listmatches the 42 documented tools and prints every live signature (arg names/types), - a read-only battery of
call_toolcalls built from the live schemas (branches, address, delivery types, time slots, categories tree, promotions, products, profile, favorites, loyalty, coupons, orders, promos, certificates).
Failures are reported per check without aborting — server-side schema bugs and
drift between the real server and the mock show up as ✗ lines. Exits
non-zero if the tool-name contract is violated or a battery call fails.
Known server-side quirks (re-verified live, Sep 2026 — mitigated in examples/real_smoke.py)
| Tool | Symptom | Mitigation |
|---|---|---|
silpo_get_products |
400 Bad Request on plain limit without filter (pre-1.111.0; now a clear message listing category/mustHavePromotion/promotionCode/set) |
smoke uses category or set: klatsniznyzhky; typed get_products raises ValueError before the request |
silpo_get_products |
bogus timeslotStart/timeslotEnd silently returns the full catalog (documented 1.111.0, not validated) |
always pass a real slot from get_time_slots |
silpo_get_time_slots |
date-only start/end ("2026-09-02") answers with a bare 400 Bad Request (release-1.111.2 normalization) |
typed get_time_slots raises ValueError before the request; always pass a full timestamp |
silpo_update_shopping_cart |
a hand-built address without addressType (or a shipment without companyId/branchId) is rejected with -32602 (release-1.111.3) |
copy both from the cart response — cart.update_payloads; the typed method also raises ValueError naming the offending argument |
silpo_get_my_favorites |
Cannot read properties of null (reading 'id') — a corrupted favorites entry on the server side. The typed get_favorites() raises SilpoToolExecutionError; it is not a client/model drift. |
smoke treats it as skipped; until Silpo fixes it, wrap get_favorites() in try/except SilpoToolExecutionError or use call_tool("silpo_get_my_favorites", ...) and handle the failure |
silpo_get_product_details |
slug: null chain failure |
resolved once get_products returns real slugs |
Previously reported quirks that no longer reproduce (re-verified live, Sep 2026):
silpo_get_category no longer triggers the fastmcp id rejection (it validates
cleanly), silpo_get_time_slots accepts deliveryTypes: ["B2B"] again
(release-1.111.2 put it back in the enum — it returns an empty slot list), and
silpo_get_my_certificates — although still intermittently returning
HTTP 500 — now responds with a normal certificates envelope (unwrapped by the
client) that validates cleanly when it does respond.
Configuration
Configuration is read from environment variables (prefix SILPO_) or a .env
file. Key settings:
| Variable | Default | Description |
|---|---|---|
SILPO_MCP_URL |
https://mcp.silpo.ua/mcp |
Server endpoint |
SILPO_OAUTH_STORAGE_DIR |
~/.silpo_py_mcp |
Encrypted token store location |
SILPO_OAUTH_ENCRYPTION_KEY |
auto-generated | Fernet key (base64) |
SILPO_OAUTH_CLIENT_NAME |
silpo-py-mcp |
Client name for OAuth registration |
SILPO_OAUTH_TOKEN_ENDPOINT_AUTH_METHOD |
none |
DCR auth method: none (public client + PKCE, default), client_secret_post, client_secret_basic |
SILPO_OAUTH_CALLBACK_TIMEOUT |
300.0 |
Seconds to wait for the browser callback |
SILPO_DEFAULT_REQUEST_TIMEOUT |
30.0 |
Per-request timeout |
SILPO_MAX_RATE_LIMIT_RETRIES |
3 |
Retries on HTTP 429 |
Programmatic overrides are supported via SilpoSettings(...) or
SilpoClient.from_fastmcp(client, mcp_url=...).
Schema-driven by design
The exact tool schemas (arguments, JSON Schema) are only known from
tools/list after authentication, per the official docs.
SilpoClient therefore exposes:
list_tools()— the live schemas from the server.call_tool(name, arguments)— pass-through calls with typed error mapping.- Typed convenience methods — wrappers over the live tool schemas
(
get_products,get_cart_by_id,add_cart_products, ...).
If Silpo renames or reshapes tools, only the affected convenience method needs
updating; call_tool keeps working.
Working with the cart
Since release-1.112.1 the cart-product tools are split, so adding a product and changing its quantity are different calls — and both address the cart line-item, not the product:
cart = await client.get_cart_by_id(cart_id)
# add a product that is not in the cart yet
await client.add_cart_products(
cart_id,
[{"productId": product.product_id, "companyId": product.company_id, "branchId": product.branch_id, "quantity": 2}],
)
# change the quantity of a product that is already in it — the quantity is
# the new total, so read the current one first
cart = await client.get_cart_by_id(cart_id)
line = cart.line_for(product.product_id)
assert line is not None and line.id
await client.update_cart_products(cart_id, [{"id": line.id, "quantity": line.quantity + 1}])
# remove a line by its line-item id (or by product id, via the helper)
await client.remove_cart_products(cart_id, [line.id])
await client.remove_cart_products_by_product(cart_id, product.product_id)
success: true only means the request was accepted — a quantity above the
stock surfaces later in the cart validations, and an unknown id is dropped
silently, so always re-read the cart with get_cart_by_id to verify.
Since release-1.111.3 the update tool wants the cart's own address and
shipments copied back verbatim, so read them off cart.update_payloads
rather than rebuilding them. The cart also separates what the guest may pay
with from what they will pay:
cart = await client.get_cart_by_id(cart_id)
# what the guest can actually pay with (not cart.payment_type, which is
# "Unknown" until a method is chosen at checkout)
print(cart.available_payment_types) # ['Cashdesk', 'Card', 'Masterpass', ...]
print(cart.payment.unavailable_types) # ['BNPL'] — below loan_config.min_total
# the amount to show the guest: total is *before* discounts
print(cart.total_to_pay, cart.totals.total_price)
# why the delivery was cheaper than its list price (the cause is not reported)
print(cart.delivery.sub_total, cart.delivery.sub_discount, cart.delivery.total)
# hand the address/shipments/timeslot straight back to an update
payloads = cart.update_payloads
assert payloads.is_sendable, payloads.missing_fields
await client.update_shopping_cart(
cart_id,
cart.delivery_type,
payloads.timeslot,
payloads.address,
payloads.shipments,
)
Error handling
silpo_py_mcp.exceptions maps Silpo's documented error responses:
| Server response | Raised |
|---|---|
401 invalid_token |
SilpoAuthError |
403 |
SilpoForbiddenError |
429 (rate limit) |
SilpoRateLimitError |
-32601 method not found |
SilpoToolNotFoundError |
| Other tool failures | SilpoToolExecutionError |
| Schema mismatch / bad response | SilpoValidationError |
| Connection / protocol failures | SilpoConnectionError |
Development
uv sync # install deps
uv run pytest # run tests (all against the in-memory mock)
uv run ruff format . # format
uv run ruff check . # lint
uv run pyrefly check # type check (strict)
uv run pre-commit install # install git hooks (format/lint/type/tests)
Project layout
src/silpo_py_mcp/
├── client.py # SilpoClient — typed methods + error mapping
├── mock_server.py # SilpoMockServer — in-memory FastMCP server (42 tools)
├── auth.py # OAuth 2.1 + PKCE helper, encrypted token storage
├── config.py # pydantic-settings configuration
├── exceptions.py # typed exceptions
└── models/ # Pydantic models (product, cart, branch, category, order)
License
MIT
Metadata
Release files for silpo-py-mcp 0.9.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| silpo_py_mcp-0.9.0.tar.gz | 63.0 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| silpo_py_mcp-0.9.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 132.8 kB
Release files / silpo_py_mcp-0.9.0.tar.gz
| Download URL | silpo_py_mcp-0.9.0.tar.gz |
|---|---|
| Size | 63.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ad5ffb306690cf8706c54e276d51a8bc16fbf2ac590f30fb610ebce02ac3a182
|
|
BLAKE2b-256 checksum How to use checksums |
aa4f26e6acdf737cb08f9557f44e4c4ce7eee2e464e489cbe9181fe318b94c90
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / silpo_py_mcp-0.9.0-py3-none-any.whl
| Download URL | silpo_py_mcp-0.9.0-py3-none-any.whl |
|---|---|
| Size | 69.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a18d2fd2a2f7816e043d78fef44606219c1b0b77d1d256889b62163fc154cc57
|
|
BLAKE2b-256 checksum How to use checksums |
73171c68cd3d9f663ec77ad0ee6872d02c8b45cc61ccfcaab294fd6e1d151c56
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for silpo-py-mcp 0.9.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.