tapsifood-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tapsifood_mcp-0.1.0/.github/workflows/ci.yml +22 -0
- tapsifood_mcp-0.1.0/.github/workflows/publish.yml +51 -0
- tapsifood_mcp-0.1.0/.gitignore +6 -0
- tapsifood_mcp-0.1.0/LICENSE +21 -0
- tapsifood_mcp-0.1.0/PKG-INFO +96 -0
- tapsifood_mcp-0.1.0/README.md +70 -0
- tapsifood_mcp-0.1.0/pyproject.toml +54 -0
- tapsifood_mcp-0.1.0/server.json +32 -0
- tapsifood_mcp-0.1.0/src/tapsifood_mcp/__init__.py +5 -0
- tapsifood_mcp-0.1.0/src/tapsifood_mcp/discover.py +297 -0
- tapsifood_mcp-0.1.0/src/tapsifood_mcp/http.py +250 -0
- tapsifood_mcp-0.1.0/src/tapsifood_mcp/location.py +95 -0
- tapsifood_mcp-0.1.0/src/tapsifood_mcp/registry.py +21 -0
- tapsifood_mcp-0.1.0/src/tapsifood_mcp/server.py +89 -0
- tapsifood_mcp-0.1.0/src/tapsifood_mcp/vendor.py +189 -0
- tapsifood_mcp-0.1.0/tests/conftest.py +72 -0
- tapsifood_mcp-0.1.0/tests/fixtures/foodshots.json +648 -0
- tapsifood_mcp-0.1.0/tests/fixtures/geocode_forward.json +64 -0
- tapsifood_mcp-0.1.0/tests/fixtures/geocode_reverse.json +29 -0
- tapsifood_mcp-0.1.0/tests/fixtures/groceries.json +343 -0
- tapsifood_mcp-0.1.0/tests/fixtures/product_search.json +195 -0
- tapsifood_mcp-0.1.0/tests/fixtures/restaurants.json +595 -0
- tapsifood_mcp-0.1.0/tests/fixtures/taptime.json +390 -0
- tapsifood_mcp-0.1.0/tests/fixtures/vendor_menu.json +452 -0
- tapsifood_mcp-0.1.0/tests/fixtures/vendor_profile.json +158 -0
- tapsifood_mcp-0.1.0/tests/fixtures/vendor_search.json +203 -0
- tapsifood_mcp-0.1.0/tests/test_http.py +53 -0
- tapsifood_mcp-0.1.0/tests/test_live.py +91 -0
- tapsifood_mcp-0.1.0/tests/test_tools.py +284 -0
- tapsifood_mcp-0.1.0/uv.lock +1247 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python: ["3.10", "3.11", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v7
|
|
16
|
+
- uses: astral-sh/setup-uv@v10.2.0
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python }}
|
|
19
|
+
- run: uv sync --locked
|
|
20
|
+
- run: uv run ruff check .
|
|
21
|
+
- run: uv run ruff format --check .
|
|
22
|
+
- run: uv run pytest -q
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
# Tag a release (git tag v0.1.0 && git push --tags) after bumping the version in
|
|
4
|
+
# pyproject.toml and server.json.
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
tags: ["v*"]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
pypi:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
environment: pypi
|
|
13
|
+
permissions:
|
|
14
|
+
id-token: write # PyPI trusted publishing
|
|
15
|
+
contents: write # GitHub Release
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v7
|
|
18
|
+
- uses: astral-sh/setup-uv@v10.2.0
|
|
19
|
+
- name: Check versions match the tag
|
|
20
|
+
run: |
|
|
21
|
+
TAG=${GITHUB_REF_NAME#v}
|
|
22
|
+
grep -q "^version = \"$TAG\"" pyproject.toml
|
|
23
|
+
test "$(jq -r .version server.json)" = "$TAG"
|
|
24
|
+
test "$(jq -r '.packages[0].version' server.json)" = "$TAG"
|
|
25
|
+
- run: uv sync --locked && uv run pytest -q
|
|
26
|
+
- run: uv build
|
|
27
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
28
|
+
- run: gh release create "$GITHUB_REF_NAME" dist/* --generate-notes --title "$GITHUB_REF_NAME"
|
|
29
|
+
env:
|
|
30
|
+
GH_TOKEN: ${{ github.token }}
|
|
31
|
+
|
|
32
|
+
mcp-registry:
|
|
33
|
+
needs: pypi
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
permissions:
|
|
36
|
+
id-token: write # MCP Registry login via GitHub OIDC
|
|
37
|
+
contents: read
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v7
|
|
40
|
+
- name: Wait for PyPI to serve the new version
|
|
41
|
+
run: |
|
|
42
|
+
for i in $(seq 1 30); do
|
|
43
|
+
curl -sf "https://pypi.org/pypi/tapsifood-mcp/${GITHUB_REF_NAME#v}/json" >/dev/null && exit 0
|
|
44
|
+
sleep 10
|
|
45
|
+
done
|
|
46
|
+
exit 1
|
|
47
|
+
- name: Install mcp-publisher
|
|
48
|
+
run: |
|
|
49
|
+
curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.8.1/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
|
|
50
|
+
- run: ./mcp-publisher login github-oidc
|
|
51
|
+
- run: ./mcp-publisher publish
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sepehr Radmard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tapsifood-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial read-only MCP server for Tapsi Food: search dishes and restaurants, read menus and reviews, find flash deals.
|
|
5
|
+
Project-URL: Homepage, https://github.com/sepehr071/tapsifood-mcp
|
|
6
|
+
Project-URL: Issues, https://github.com/sepehr071/tapsifood-mcp/issues
|
|
7
|
+
Author-email: Sepehr Radmard <sepehrr80@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: food-delivery,iran,mcp,model-context-protocol,tapsi,tapsifood
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: curl-cffi>=0.16.3
|
|
22
|
+
Requires-Dist: httpx-curl-cffi>=0.1.5
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Requires-Dist: mcp<3,>=2.2
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
<!-- mcp-name: io.github.sepehr071/tapsifood-mcp -->
|
|
28
|
+
|
|
29
|
+
# tapsifood-mcp: Tapsi Food MCP server for AI agents
|
|
30
|
+
|
|
31
|
+
**Let your AI agent shop around on Tapsi Food (تپسیفود).**
|
|
32
|
+
An unofficial, read-only MCP server for Tapsi's food and grocery delivery in Iran: search dishes and restaurants near an
|
|
33
|
+
address, read live menus and reviews, check delivery fees and minimum orders, and catch the Tap Time flash deals.
|
|
34
|
+
|
|
35
|
+
## For AI agents
|
|
36
|
+
|
|
37
|
+
- **What:** unofficial MCP server for Tapsi Food (tapsi.food) in Iran. Queries in Persian match best; prices in Toman.
|
|
38
|
+
- **Install:** `uvx tapsifood-mcp` or `claude mcp add tapsifood -- uvx tapsifood-mcp`. Transport: stdio. Needs [uv](https://docs.astral.sh/uv/getting-started/installation/) (Python 3.10+).
|
|
39
|
+
- **Auth:** none. Tapsi Food hangs connections from non-Iranian IPs: run it from Iran without a VPN, or set `TAPSIFOOD_MCP_PROXY` to an HTTP proxy that exits in Iran.
|
|
40
|
+
- **Access:** read-only. No login, basket, orders or payment, so no confirmation is needed before calling any tool.
|
|
41
|
+
- **Tools:** 11, all `tf_*`. Most need `lat` and `long` (not `tf_reviews`, `tf_find_location`, `tf_list_cities`).
|
|
42
|
+
Typical sequence: `tf_find_location` -> `tf_search_dishes` -> `tf_vendor_info` (-> `tf_menu`, `tf_reviews`).
|
|
43
|
+
- **Coverage:** Tehran, Mashhad, Shiraz. Elsewhere the API answers 404 or `OutOfCoverage`.
|
|
44
|
+
- **Output:** compact JSON. Prices in Toman, ratings 0-5. Null and empty fields are omitted (a missing `rating` = not rated); menu descriptions
|
|
45
|
+
are cut to 100 characters and review comments to 250 (`full_text: true` for the whole text, which lifts the size cap).
|
|
46
|
+
Replies stay under about 15 KB at the maximum `limit` (`tf_menu` 25, `tf_reviews` 20, `tf_search_dishes` 40).
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
claude mcp add tapsifood -- uvx tapsifood-mcp
|
|
52
|
+
# behind a proxy that exits in Iran:
|
|
53
|
+
claude mcp add tapsifood -e TAPSIFOOD_MCP_PROXY=http://127.0.0.1:8080 -- uvx tapsifood-mcp
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Tools
|
|
57
|
+
|
|
58
|
+
| Tool | What it does |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `tf_find_location` | Turn a place name or address into latitude/longitude. |
|
|
61
|
+
| `tf_reverse_geocode` | Describe coordinates as an address with province and city. |
|
|
62
|
+
| `tf_list_cities` | List the cities Tapsi Food shows, with a center point and whether it delivers there. |
|
|
63
|
+
| `tf_search_dishes` | Search dishes by name near a point; sort by relevance, cheapest, biggest discount, rating or distance. |
|
|
64
|
+
| `tf_search_vendors` | Search restaurants and stores by name or cuisine near a point. |
|
|
65
|
+
| `tf_restaurants` | List restaurants or grocery stores with rating, delivery fee, minimum order, ETA; filter by cuisine, discount, courier. |
|
|
66
|
+
| `tf_deals` | List the Tap Time flash deals (limited-stock discounted dishes). |
|
|
67
|
+
| `tf_discounted_vendors` | List the vendors in the Food Shots discount campaign. |
|
|
68
|
+
| `tf_menu` | Read a vendor's live menu with price, discount and stock. |
|
|
69
|
+
| `tf_vendor_info` | Get a vendor's delivery fee to a point, minimum order, packaging, ETA, status, hours, address and rating spread. |
|
|
70
|
+
| `tf_reviews` | Read customer reviews of a vendor with per-dish ratings. |
|
|
71
|
+
|
|
72
|
+
## Notes
|
|
73
|
+
|
|
74
|
+
- Prices in the search index can lag the menu: confirm with `tf_menu`. Menu and profile minimums sometimes differ; `tf_vendor_info` returns both (`min_order`, `min_order_profile`).
|
|
75
|
+
- Dish and vendor search list only restaurants open now (the site does the same), so at night `total` can be 0; `tf_restaurants` and
|
|
76
|
+
`tf_discounted_vendors` also list closed vendors, flagged `open: false`. `tf_search_dishes` `top_rated` / `cheapest` / `biggest_discount`
|
|
77
|
+
sort the first 100 matches, not the whole result set.
|
|
78
|
+
- `eta_max` is the site's "up to N minutes" (its dialog shows roughly 20 to N); the API sends no lower bound. `tf_vendor_info.open_now`
|
|
79
|
+
follows the menu status (the live state the site shows); the profile's own status is just the vendor's on/off switch and is not returned.
|
|
80
|
+
- `tf_reviews`: `total` counts every review including rating-only ones; only reviews with a comment are listed, so a page can hold fewer
|
|
81
|
+
than `limit` (keep paging while `has_more`). The site's tab badge shows just the first page's count. Writer names are left out on purpose.
|
|
82
|
+
- Coupons are not exposed by the site's anonymous API.
|
|
83
|
+
- Page 1 of the browse tools also carries filter and banner widgets, so it lists fewer vendors than `page_size`.
|
|
84
|
+
- API map this server is built on: [`tapsifood/`](https://github.com/sepehr071/api-mcp) (endpoints, samples, quirks).
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uv sync
|
|
90
|
+
uv run ruff check . && uv run ruff format --check .
|
|
91
|
+
uv run python -m pytest -q # offline unit tests
|
|
92
|
+
uv run python -m pytest -m live -q # real API; needs an Iranian IP
|
|
93
|
+
uv build
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Unofficial: not affiliated with Tapsi. MIT licensed.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<!-- mcp-name: io.github.sepehr071/tapsifood-mcp -->
|
|
2
|
+
|
|
3
|
+
# tapsifood-mcp: Tapsi Food MCP server for AI agents
|
|
4
|
+
|
|
5
|
+
**Let your AI agent shop around on Tapsi Food (تپسیفود).**
|
|
6
|
+
An unofficial, read-only MCP server for Tapsi's food and grocery delivery in Iran: search dishes and restaurants near an
|
|
7
|
+
address, read live menus and reviews, check delivery fees and minimum orders, and catch the Tap Time flash deals.
|
|
8
|
+
|
|
9
|
+
## For AI agents
|
|
10
|
+
|
|
11
|
+
- **What:** unofficial MCP server for Tapsi Food (tapsi.food) in Iran. Queries in Persian match best; prices in Toman.
|
|
12
|
+
- **Install:** `uvx tapsifood-mcp` or `claude mcp add tapsifood -- uvx tapsifood-mcp`. Transport: stdio. Needs [uv](https://docs.astral.sh/uv/getting-started/installation/) (Python 3.10+).
|
|
13
|
+
- **Auth:** none. Tapsi Food hangs connections from non-Iranian IPs: run it from Iran without a VPN, or set `TAPSIFOOD_MCP_PROXY` to an HTTP proxy that exits in Iran.
|
|
14
|
+
- **Access:** read-only. No login, basket, orders or payment, so no confirmation is needed before calling any tool.
|
|
15
|
+
- **Tools:** 11, all `tf_*`. Most need `lat` and `long` (not `tf_reviews`, `tf_find_location`, `tf_list_cities`).
|
|
16
|
+
Typical sequence: `tf_find_location` -> `tf_search_dishes` -> `tf_vendor_info` (-> `tf_menu`, `tf_reviews`).
|
|
17
|
+
- **Coverage:** Tehran, Mashhad, Shiraz. Elsewhere the API answers 404 or `OutOfCoverage`.
|
|
18
|
+
- **Output:** compact JSON. Prices in Toman, ratings 0-5. Null and empty fields are omitted (a missing `rating` = not rated); menu descriptions
|
|
19
|
+
are cut to 100 characters and review comments to 250 (`full_text: true` for the whole text, which lifts the size cap).
|
|
20
|
+
Replies stay under about 15 KB at the maximum `limit` (`tf_menu` 25, `tf_reviews` 20, `tf_search_dishes` 40).
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
claude mcp add tapsifood -- uvx tapsifood-mcp
|
|
26
|
+
# behind a proxy that exits in Iran:
|
|
27
|
+
claude mcp add tapsifood -e TAPSIFOOD_MCP_PROXY=http://127.0.0.1:8080 -- uvx tapsifood-mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Tools
|
|
31
|
+
|
|
32
|
+
| Tool | What it does |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `tf_find_location` | Turn a place name or address into latitude/longitude. |
|
|
35
|
+
| `tf_reverse_geocode` | Describe coordinates as an address with province and city. |
|
|
36
|
+
| `tf_list_cities` | List the cities Tapsi Food shows, with a center point and whether it delivers there. |
|
|
37
|
+
| `tf_search_dishes` | Search dishes by name near a point; sort by relevance, cheapest, biggest discount, rating or distance. |
|
|
38
|
+
| `tf_search_vendors` | Search restaurants and stores by name or cuisine near a point. |
|
|
39
|
+
| `tf_restaurants` | List restaurants or grocery stores with rating, delivery fee, minimum order, ETA; filter by cuisine, discount, courier. |
|
|
40
|
+
| `tf_deals` | List the Tap Time flash deals (limited-stock discounted dishes). |
|
|
41
|
+
| `tf_discounted_vendors` | List the vendors in the Food Shots discount campaign. |
|
|
42
|
+
| `tf_menu` | Read a vendor's live menu with price, discount and stock. |
|
|
43
|
+
| `tf_vendor_info` | Get a vendor's delivery fee to a point, minimum order, packaging, ETA, status, hours, address and rating spread. |
|
|
44
|
+
| `tf_reviews` | Read customer reviews of a vendor with per-dish ratings. |
|
|
45
|
+
|
|
46
|
+
## Notes
|
|
47
|
+
|
|
48
|
+
- Prices in the search index can lag the menu: confirm with `tf_menu`. Menu and profile minimums sometimes differ; `tf_vendor_info` returns both (`min_order`, `min_order_profile`).
|
|
49
|
+
- Dish and vendor search list only restaurants open now (the site does the same), so at night `total` can be 0; `tf_restaurants` and
|
|
50
|
+
`tf_discounted_vendors` also list closed vendors, flagged `open: false`. `tf_search_dishes` `top_rated` / `cheapest` / `biggest_discount`
|
|
51
|
+
sort the first 100 matches, not the whole result set.
|
|
52
|
+
- `eta_max` is the site's "up to N minutes" (its dialog shows roughly 20 to N); the API sends no lower bound. `tf_vendor_info.open_now`
|
|
53
|
+
follows the menu status (the live state the site shows); the profile's own status is just the vendor's on/off switch and is not returned.
|
|
54
|
+
- `tf_reviews`: `total` counts every review including rating-only ones; only reviews with a comment are listed, so a page can hold fewer
|
|
55
|
+
than `limit` (keep paging while `has_more`). The site's tab badge shows just the first page's count. Writer names are left out on purpose.
|
|
56
|
+
- Coupons are not exposed by the site's anonymous API.
|
|
57
|
+
- Page 1 of the browse tools also carries filter and banner widgets, so it lists fewer vendors than `page_size`.
|
|
58
|
+
- API map this server is built on: [`tapsifood/`](https://github.com/sepehr071/api-mcp) (endpoints, samples, quirks).
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv sync
|
|
64
|
+
uv run ruff check . && uv run ruff format --check .
|
|
65
|
+
uv run python -m pytest -q # offline unit tests
|
|
66
|
+
uv run python -m pytest -m live -q # real API; needs an Iranian IP
|
|
67
|
+
uv build
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Unofficial: not affiliated with Tapsi. MIT licensed.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tapsifood-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Unofficial read-only MCP server for Tapsi Food: search dishes and restaurants, read menus and reviews, find flash deals."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Sepehr Radmard", email = "sepehrr80@gmail.com" }]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
keywords = ["mcp", "model-context-protocol", "tapsi", "tapsifood", "iran", "food-delivery"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"mcp>=2.2,<3",
|
|
24
|
+
"httpx>=0.27",
|
|
25
|
+
"httpx-curl-cffi>=0.1.5",
|
|
26
|
+
"curl-cffi>=0.16.3",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/sepehr071/tapsifood-mcp"
|
|
31
|
+
Issues = "https://github.com/sepehr071/tapsifood-mcp/issues"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
tapsifood-mcp = "tapsifood_mcp.server:main"
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
addopts = "-m 'not live'"
|
|
46
|
+
markers = ["live: calls the real Tapsi Food API (run with -m live)"]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 120
|
|
50
|
+
target-version = "py310"
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
54
|
+
ignore = ["E501"] # long Field(description=...) strings read better on one line
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.sepehr071/tapsifood-mcp",
|
|
4
|
+
"title": "Tapsi Food",
|
|
5
|
+
"description": "Unofficial read-only Tapsi Food: search dishes and restaurants, menus, reviews, flash deals.",
|
|
6
|
+
"repository": {
|
|
7
|
+
"url": "https://github.com/sepehr071/tapsifood-mcp",
|
|
8
|
+
"source": "github"
|
|
9
|
+
},
|
|
10
|
+
"websiteUrl": "https://github.com/sepehr071/tapsifood-mcp",
|
|
11
|
+
"version": "0.1.0",
|
|
12
|
+
"packages": [
|
|
13
|
+
{
|
|
14
|
+
"registryType": "pypi",
|
|
15
|
+
"registryBaseUrl": "https://pypi.org",
|
|
16
|
+
"identifier": "tapsifood-mcp",
|
|
17
|
+
"version": "0.1.0",
|
|
18
|
+
"runtimeHint": "uvx",
|
|
19
|
+
"transport": {
|
|
20
|
+
"type": "stdio"
|
|
21
|
+
},
|
|
22
|
+
"environmentVariables": [
|
|
23
|
+
{
|
|
24
|
+
"name": "TAPSIFOOD_MCP_PROXY",
|
|
25
|
+
"description": "Optional HTTP proxy URL. Tapsi Food does not answer non-Iranian exit IPs (connections hang); system proxy settings are ignored. Also accepts a comma-separated list: one is used until it gets blocked or fails to connect, then the next.",
|
|
26
|
+
"isRequired": false,
|
|
27
|
+
"isSecret": false
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Search and browse tools: dishes, vendors, restaurant and grocery lists, flash deals, discounted vendors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from .http import fetch
|
|
10
|
+
from .location import Lat, Long
|
|
11
|
+
from .registry import tool
|
|
12
|
+
|
|
13
|
+
Query = Annotated[str, Field(min_length=2, description="Dish or restaurant word, e.g. 'پیتزا'.")]
|
|
14
|
+
Page = Annotated[int, Field(ge=1, le=50, description="Page number, from 1.")]
|
|
15
|
+
|
|
16
|
+
FEED = "/v1/api/search-discovery/page-content/"
|
|
17
|
+
SEARCH = "/v1/api/search-discovery/Search/"
|
|
18
|
+
DELIVERY = {"tapsi": "OfoodDelivery", "vendor": "OwnDelivery"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _stars(v: float | None) -> float | None:
|
|
22
|
+
return round(v, 1) if v else None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _dish(d: dict[str, Any]) -> dict[str, Any]:
|
|
26
|
+
p, v = d["productInfo"], d.get("vendorInfo") or {}
|
|
27
|
+
return {
|
|
28
|
+
"id": p["id"],
|
|
29
|
+
"product_id": str(p["headerId"]) if p.get("headerId") is not None else None,
|
|
30
|
+
"name": p["name"].strip(),
|
|
31
|
+
"price": p["price"],
|
|
32
|
+
"discount_pct": p.get("discountRatio") or 0,
|
|
33
|
+
"final_price": p["priceAfterDiscount"],
|
|
34
|
+
"rating": _stars(p.get("rate")),
|
|
35
|
+
"vendor_code": v.get("vendorCode"),
|
|
36
|
+
"vendor": (v.get("title") or "").strip(),
|
|
37
|
+
"vendor_rating": _stars(v.get("rate")),
|
|
38
|
+
"delivery_fee": v.get("deliveryPrice"),
|
|
39
|
+
"eta_max": v.get("eta"),
|
|
40
|
+
"open": v.get("vendorStatus"),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _vendor(code: str, title: str | None, v: dict[str, Any]) -> dict[str, Any]:
|
|
45
|
+
"""One vendor card; search rows and feed rows name the same facts differently."""
|
|
46
|
+
badges = [b["name"] for b in v.get("badges") or [] if b.get("name")]
|
|
47
|
+
return {
|
|
48
|
+
"code": code,
|
|
49
|
+
"title": (title or "").strip(),
|
|
50
|
+
"rating": _stars(v.get("rate")),
|
|
51
|
+
"ratings_count": v.get("ratesCount"),
|
|
52
|
+
"open": v["isOpen"] if "isOpen" in v else v.get("vendorStatus") == "Open",
|
|
53
|
+
"delivery_fee": v.get("deliveryPrice"),
|
|
54
|
+
"delivery_by": "vendor" if v.get("deliveryType") == "OwnDelivery" else "tapsi",
|
|
55
|
+
"min_order": int(v["minimumBasket"]) if v.get("minimumBasket") else None,
|
|
56
|
+
"eta_max": v.get("eta"),
|
|
57
|
+
"discount_pct": int(v["discount"]) if v.get("discount") else None,
|
|
58
|
+
"badges": badges,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@tool("Search dishes")
|
|
63
|
+
async def tf_search_dishes(
|
|
64
|
+
query: Query,
|
|
65
|
+
lat: Lat,
|
|
66
|
+
long: Long,
|
|
67
|
+
sort: Annotated[
|
|
68
|
+
Literal["relevance", "cheapest", "biggest_discount", "top_rated", "nearest"],
|
|
69
|
+
Field(
|
|
70
|
+
description="Order of the results; cheapest / biggest_discount / top_rated sort the first 100 matches "
|
|
71
|
+
"(top_rated = dish rating, then restaurant rating; unrated last). nearest is Tapsi's own order."
|
|
72
|
+
),
|
|
73
|
+
] = "relevance",
|
|
74
|
+
discounted_only: Annotated[bool, Field(description="Only dishes with a discount.")] = False,
|
|
75
|
+
page: Page = 1,
|
|
76
|
+
limit: Annotated[int, Field(ge=1, le=40, description="Max dishes (40 keeps the reply under 15 KB).")] = 10,
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""Search dishes by name near a delivery point, with price, discount and restaurant.
|
|
79
|
+
|
|
80
|
+
Use for "find X near me" or "cheapest / discounted X". Prices are the search index's and can lag
|
|
81
|
+
the menu: confirm with tf_menu before recommending. Then call tf_vendor_info for fee and minimum order.
|
|
82
|
+
Tapsi's search lists only restaurants open now: at night total can be 0 (the site shows the same);
|
|
83
|
+
tf_restaurants / tf_discounted_vendors also list closed vendors.
|
|
84
|
+
"""
|
|
85
|
+
pooled = sort in ("cheapest", "biggest_discount", "top_rated") # the API ignores ratingdesc for dishes
|
|
86
|
+
filters = ["HasDiscount"] if discounted_only else []
|
|
87
|
+
if sort == "nearest":
|
|
88
|
+
filters.append("distance")
|
|
89
|
+
body = await fetch(
|
|
90
|
+
SEARCH + "product-search",
|
|
91
|
+
{
|
|
92
|
+
"latitude": lat,
|
|
93
|
+
"longitude": long,
|
|
94
|
+
"query": query,
|
|
95
|
+
"pageNumber": 1 if pooled else page,
|
|
96
|
+
"pageSize": 100 if pooled else limit,
|
|
97
|
+
"filtersAndSorts": filters or None,
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
dishes = [_dish(d) for d in body.get("data") or []]
|
|
101
|
+
if sort == "cheapest":
|
|
102
|
+
dishes.sort(key=lambda d: d["final_price"])
|
|
103
|
+
elif sort == "biggest_discount":
|
|
104
|
+
dishes.sort(key=lambda d: -d["discount_pct"])
|
|
105
|
+
elif sort == "top_rated":
|
|
106
|
+
dishes.sort(key=lambda d: (-(d["rating"] or 0), -(d["vendor_rating"] or 0)))
|
|
107
|
+
if pooled: # the pool is page 1 of 100 hits; slice the requested page out of it
|
|
108
|
+
dishes = dishes[(page - 1) * limit :]
|
|
109
|
+
return {"total": body["meta"]["totalItems"], "dishes": dishes[:limit]}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@tool("Search vendors")
|
|
113
|
+
async def tf_search_vendors(
|
|
114
|
+
query: Query,
|
|
115
|
+
lat: Lat,
|
|
116
|
+
long: Long,
|
|
117
|
+
page: Page = 1,
|
|
118
|
+
limit: Annotated[int, Field(ge=1, le=50, description="Max vendors.")] = 10,
|
|
119
|
+
) -> dict[str, Any]:
|
|
120
|
+
"""Search restaurants and stores by name or cuisine near a delivery point.
|
|
121
|
+
|
|
122
|
+
Use to find a vendor code by name. No dish prices: use tf_menu or tf_search_dishes.
|
|
123
|
+
Tapsi's search lists only restaurants open now (total 0 at night, as on the site); tf_restaurants also lists closed ones.
|
|
124
|
+
"""
|
|
125
|
+
body = await fetch(
|
|
126
|
+
SEARCH + "vendor-search",
|
|
127
|
+
{"latitude": lat, "longitude": long, "query": query, "pageNumber": page, "pageSize": limit},
|
|
128
|
+
)
|
|
129
|
+
return {
|
|
130
|
+
"total": body["meta"]["totalItems"],
|
|
131
|
+
"vendors": [_vendor(r["vendorCode"], (i := r["vendorInfo"])["title"], i) for r in body.get("data") or []],
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _feed(slug: str, lat: float, long: float, filters: list[str], page: int, size: int) -> dict[str, Any]:
|
|
136
|
+
"""One page of a CMS feed. Page 1 also carries filter and banner widgets, so it holds fewer rows."""
|
|
137
|
+
body = await fetch(
|
|
138
|
+
FEED + slug,
|
|
139
|
+
{
|
|
140
|
+
"latitude": lat,
|
|
141
|
+
"longitude": long,
|
|
142
|
+
"bypassCache": "false",
|
|
143
|
+
"pageNumber": page,
|
|
144
|
+
"pageSize": size,
|
|
145
|
+
"filtersAndSorts": filters or None,
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
rows, chips = [], []
|
|
149
|
+
for c in body.get("content") or []:
|
|
150
|
+
if c["type"] in (1, 2) and c.get("defaultFeedContent"):
|
|
151
|
+
rows.append(c)
|
|
152
|
+
elif (c.get("component") or {}).get("type") == 1039: # category chips, only on page 1
|
|
153
|
+
chips = [
|
|
154
|
+
{"title": i["payload"]["title"], "filter": i["filterValue"]}
|
|
155
|
+
for i in c["component"]["items"]
|
|
156
|
+
if i.get("filterValue")
|
|
157
|
+
]
|
|
158
|
+
meta = body["meta"]
|
|
159
|
+
total = rows[0].get("feedMeta", {}).get("totalItems") if rows else 0
|
|
160
|
+
return {
|
|
161
|
+
"rows": [r["defaultFeedContent"] for r in rows],
|
|
162
|
+
"chips": chips,
|
|
163
|
+
"total": total if total is not None else meta["totalItems"],
|
|
164
|
+
"has_more": meta["pageNumber"] * meta["pageSize"] < meta["totalItems"],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _filters(sort: str | None, discounted_only: bool, delivery: str, new_only: bool, category: str | None) -> list[str]:
|
|
169
|
+
out = [category] if category else []
|
|
170
|
+
out += [sort] if sort else []
|
|
171
|
+
out += ["HasDiscount"] if discounted_only else []
|
|
172
|
+
out += [DELIVERY[delivery]] if delivery in DELIVERY else []
|
|
173
|
+
out += ["NewVendor"] if new_only else []
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@tool("List restaurants")
|
|
178
|
+
async def tf_restaurants(
|
|
179
|
+
lat: Lat,
|
|
180
|
+
long: Long,
|
|
181
|
+
kind: Annotated[
|
|
182
|
+
Literal["restaurants", "groceries"], Field(description="restaurants, or grocery stores (خواربار).")
|
|
183
|
+
] = "restaurants",
|
|
184
|
+
category: Annotated[
|
|
185
|
+
str | None,
|
|
186
|
+
Field(description="Cuisine chip filter from the 'categories' of a page-1 result, e.g. '1-82' (kebab)."),
|
|
187
|
+
] = None,
|
|
188
|
+
sort: Annotated[
|
|
189
|
+
Literal["default", "top_rated", "popular", "nearest"], Field(description="Order of the results.")
|
|
190
|
+
] = "default",
|
|
191
|
+
discounted_only: Annotated[bool, Field(description="Only vendors with a discount.")] = False,
|
|
192
|
+
delivery: Annotated[
|
|
193
|
+
Literal["any", "tapsi", "vendor"], Field(description="Courier: Tapsi's own, or the vendor's.")
|
|
194
|
+
] = "any",
|
|
195
|
+
new_only: Annotated[bool, Field(description="Only new vendors.")] = False,
|
|
196
|
+
page: Page = 1,
|
|
197
|
+
page_size: Annotated[
|
|
198
|
+
int,
|
|
199
|
+
Field(
|
|
200
|
+
ge=5, le=50, description="Feed items per page; page 1 spends some on widgets, so it lists fewer vendors."
|
|
201
|
+
),
|
|
202
|
+
] = 20,
|
|
203
|
+
) -> dict[str, Any]:
|
|
204
|
+
"""List restaurants (or grocery stores) delivering to a point, with rating, delivery fee, minimum order and ETA.
|
|
205
|
+
|
|
206
|
+
Use to browse or rank vendors. Page 1 also returns 'categories' (cuisine filters for the category param).
|
|
207
|
+
No dish prices: use tf_menu. Returns 404 outside the served cities (tf_list_cities).
|
|
208
|
+
"""
|
|
209
|
+
sort_key = {"top_rated": "ratingdesc", "popular": "mostpopular", "nearest": "distance"}.get(sort)
|
|
210
|
+
slug = "business-line-15" if kind == "restaurants" else "business-line-7"
|
|
211
|
+
f = await _feed(slug, lat, long, _filters(sort_key, discounted_only, delivery, new_only, category), page, page_size)
|
|
212
|
+
return {
|
|
213
|
+
"total": f["total"],
|
|
214
|
+
"has_more": f["has_more"],
|
|
215
|
+
"categories": f["chips"],
|
|
216
|
+
"vendors": [_vendor(r["vendorCode"], r["title"], r) for r in f["rows"]],
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@tool("Flash deals")
|
|
221
|
+
async def tf_deals(
|
|
222
|
+
lat: Lat,
|
|
223
|
+
long: Long,
|
|
224
|
+
sort: Annotated[
|
|
225
|
+
Literal["default", "most_discounted", "most_purchased", "cheapest", "top_rated"],
|
|
226
|
+
Field(description="Order of the deals."),
|
|
227
|
+
] = "most_discounted",
|
|
228
|
+
category: Annotated[str | None, Field(description="Cuisine chip filter from 'categories', e.g. '1-82'.")] = None,
|
|
229
|
+
page: Page = 1,
|
|
230
|
+
page_size: Annotated[
|
|
231
|
+
int,
|
|
232
|
+
Field(ge=5, le=50, description="Feed items per page; page 1 spends some on widgets, so it lists fewer deals."),
|
|
233
|
+
] = 20,
|
|
234
|
+
) -> dict[str, Any]:
|
|
235
|
+
"""List the Tap Time flash deals (تپ تایم): discounted dishes with a limited stock, near a point.
|
|
236
|
+
|
|
237
|
+
Use for "today's best deals". Not the same as Snappfood's FoodParty. Check stock_left, it runs out.
|
|
238
|
+
Then tf_menu / tf_vendor_info for the restaurant.
|
|
239
|
+
"""
|
|
240
|
+
sort_key = {
|
|
241
|
+
"most_discounted": "mostdiscounted",
|
|
242
|
+
"most_purchased": "mostpurchased",
|
|
243
|
+
"cheapest": "cheapest",
|
|
244
|
+
"top_rated": "ratingdesc",
|
|
245
|
+
}.get(sort)
|
|
246
|
+
f = await _feed("تپ-تایم", lat, long, _filters(sort_key, False, "any", False, category), page, page_size)
|
|
247
|
+
return {
|
|
248
|
+
"total": f["total"],
|
|
249
|
+
"has_more": f["has_more"],
|
|
250
|
+
"categories": f["chips"],
|
|
251
|
+
"deals": [
|
|
252
|
+
{
|
|
253
|
+
"id": r["productVariationId"],
|
|
254
|
+
"product_id": r.get("headerId"),
|
|
255
|
+
"name": r["productName"].strip(),
|
|
256
|
+
"price": r["price"],
|
|
257
|
+
"discount_pct": int(r.get("discountRatio") or 0),
|
|
258
|
+
"final_price": r["priceAfterDiscount"],
|
|
259
|
+
"stock_left": r.get("discountStock"),
|
|
260
|
+
"rating": _stars(r.get("productRate")),
|
|
261
|
+
"vendor_code": r["vendorCode"],
|
|
262
|
+
"vendor": (r.get("vendorName") or "").strip(),
|
|
263
|
+
"delivery_fee": r.get("deliveryPrice"),
|
|
264
|
+
"open": r.get("vendorStatus") == "Open",
|
|
265
|
+
}
|
|
266
|
+
for r in f["rows"]
|
|
267
|
+
],
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@tool("Discounted vendors")
|
|
272
|
+
async def tf_discounted_vendors(
|
|
273
|
+
lat: Lat,
|
|
274
|
+
long: Long,
|
|
275
|
+
sort: Annotated[
|
|
276
|
+
Literal["default", "top_rated", "popular", "nearest"], Field(description="Order of the results.")
|
|
277
|
+
] = "default",
|
|
278
|
+
page: Page = 1,
|
|
279
|
+
page_size: Annotated[
|
|
280
|
+
int,
|
|
281
|
+
Field(
|
|
282
|
+
ge=5, le=50, description="Feed items per page; page 1 spends some on widgets, so it lists fewer vendors."
|
|
283
|
+
),
|
|
284
|
+
] = 20,
|
|
285
|
+
) -> dict[str, Any]:
|
|
286
|
+
"""List the vendors in Tapsi Food's 'Food Shots' (فودشات) discount campaign near a point.
|
|
287
|
+
|
|
288
|
+
Use for "which restaurants have the biggest discount". discount_pct is the vendor-wide percentage.
|
|
289
|
+
The campaign is curated, so not every discounted vendor is in it (tf_restaurants with discounted_only is wider).
|
|
290
|
+
"""
|
|
291
|
+
sort_key = {"top_rated": "ratingdesc", "popular": "mostpopular", "nearest": "distance"}.get(sort)
|
|
292
|
+
f = await _feed("فودشات", lat, long, _filters(sort_key, False, "any", False, None), page, page_size)
|
|
293
|
+
return {
|
|
294
|
+
"total": f["total"],
|
|
295
|
+
"has_more": f["has_more"],
|
|
296
|
+
"vendors": [_vendor(r["vendorCode"], r["title"], r) for r in f["rows"]],
|
|
297
|
+
}
|