darukade-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.
Files changed (30) hide show
  1. darukade_mcp-0.1.0/.github/ISSUE_TEMPLATE/bug_report.yml +42 -0
  2. darukade_mcp-0.1.0/.github/ISSUE_TEMPLATE/config.yml +5 -0
  3. darukade_mcp-0.1.0/.github/dependabot.yml +10 -0
  4. darukade_mcp-0.1.0/.github/workflows/ci.yml +22 -0
  5. darukade_mcp-0.1.0/.github/workflows/publish.yml +51 -0
  6. darukade_mcp-0.1.0/.gitignore +6 -0
  7. darukade_mcp-0.1.0/LICENSE +21 -0
  8. darukade_mcp-0.1.0/PKG-INFO +87 -0
  9. darukade_mcp-0.1.0/README.md +60 -0
  10. darukade_mcp-0.1.0/pyproject.toml +55 -0
  11. darukade_mcp-0.1.0/server.json +32 -0
  12. darukade_mcp-0.1.0/src/darukade_mcp/__init__.py +5 -0
  13. darukade_mcp-0.1.0/src/darukade_mcp/http.py +253 -0
  14. darukade_mcp-0.1.0/src/darukade_mcp/registry.py +16 -0
  15. darukade_mcp-0.1.0/src/darukade_mcp/server.py +91 -0
  16. darukade_mcp-0.1.0/src/darukade_mcp/shop.py +725 -0
  17. darukade_mcp-0.1.0/tests/conftest.py +82 -0
  18. darukade_mcp-0.1.0/tests/fixtures/article.json +124 -0
  19. darukade_mcp-0.1.0/tests/fixtures/articles_list.json +112 -0
  20. darukade_mcp-0.1.0/tests/fixtures/brand_page.json +163 -0
  21. darukade_mcp-0.1.0/tests/fixtures/filters.json +423 -0
  22. darukade_mcp-0.1.0/tests/fixtures/home.json +891 -0
  23. darukade_mcp-0.1.0/tests/fixtures/list_cheapest.json +1 -0
  24. darukade_mcp-0.1.0/tests/fixtures/list_search.json +1 -0
  25. darukade_mcp-0.1.0/tests/fixtures/product_supplement.json +498 -0
  26. darukade_mcp-0.1.0/tests/fixtures/product_variants.json +1 -0
  27. darukade_mcp-0.1.0/tests/fixtures/related.json +1 -0
  28. darukade_mcp-0.1.0/tests/test_darukade.py +270 -0
  29. darukade_mcp-0.1.0/tests/test_live_darukade.py +70 -0
  30. darukade_mcp-0.1.0/uv.lock +1362 -0
@@ -0,0 +1,42 @@
1
+ name: Bug report
2
+ description: A tool failed or returned wrong data
3
+ labels: [bug]
4
+ body:
5
+ - type: input
6
+ id: tool
7
+ attributes:
8
+ label: Tool
9
+ description: Which tool, e.g. bl_search
10
+ validations:
11
+ required: true
12
+ - type: textarea
13
+ id: input
14
+ attributes:
15
+ label: Arguments and result
16
+ description: The arguments the agent sent and the error or wrong output. Remove anything personal.
17
+ render: json
18
+ validations:
19
+ required: true
20
+ - type: textarea
21
+ id: expected
22
+ attributes:
23
+ label: What you expected
24
+ description: e.g. what darukade.com shows for the same query
25
+ - type: input
26
+ id: client
27
+ attributes:
28
+ label: MCP client and OS
29
+ placeholder: Claude Desktop 1.x on Windows 11
30
+ - type: dropdown
31
+ id: network
32
+ attributes:
33
+ label: Network
34
+ options:
35
+ - Direct connection
36
+ - VPN or proxy
37
+ - DARUKADE_MCP_PROXY set
38
+ - type: input
39
+ id: version
40
+ attributes:
41
+ label: darukade-mcp version
42
+ placeholder: "0.1.0"
@@ -0,0 +1,5 @@
1
+ blank_issues_enabled: true
2
+ contact_links:
3
+ - name: Question or idea
4
+ url: https://github.com/sepehr071/darukade-mcp/discussions
5
+ about: Ask how to use it or suggest a new tool
@@ -0,0 +1,10 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: github-actions
4
+ directory: /
5
+ schedule:
6
+ interval: monthly
7
+ - package-ecosystem: uv
8
+ directory: /
9
+ schedule:
10
+ interval: monthly
@@ -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/darukade-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,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ .pytest_cache/
6
+ .ruff_cache/
@@ -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,87 @@
1
+ Metadata-Version: 2.5
2
+ Name: darukade-mcp
3
+ Version: 0.1.0
4
+ Summary: Unofficial read-only MCP server for Darukade (darukade.com), an Iranian online pharmacy: search products, prices, ingredients, usage, reviews.
5
+ Project-URL: Homepage, https://github.com/sepehr071/darukade-mcp
6
+ Project-URL: Issues, https://github.com/sepehr071/darukade-mcp/issues
7
+ Project-URL: Website, https://mcpfarsi.ir/
8
+ Author-email: Sepehr Radmard <sepehrr80@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: claude,cosmetics,darukade,darukade-com,iran,mcp,model-context-protocol,online-pharmacy,persian,pharmacy,prices,skin-care,supplements,vitamins
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: curl-cffi>=0.16.3
23
+ Requires-Dist: httpx-curl-cffi>=0.1.5
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: mcp<3,>=2.2
26
+ Description-Content-Type: text/markdown
27
+
28
+ <!-- mcp-name: io.github.sepehr071/darukade-mcp -->
29
+
30
+ # darukade-mcp: Darukade (داروکده) online pharmacy MCP server for AI agents
31
+
32
+ **darukade-mcp is an unofficial, read-only MCP server for Darukade (darukade.com), an Iranian online pharmacy.**<br>
33
+ Its 12 tools search the product catalog with filters (category, brand, form, price, stock, discount), find the cheapest products,
34
+ read a product's price per variant, ingredients, dosage and warnings, compare products, list user questions with the pharmacists'
35
+ answers, suggest alternatives, show the home page offers, and search and read the Darukade health magazine.<br>
36
+ No API key or account is needed. It works in Claude, Cursor, Copilot and any other MCP client.
37
+
38
+ [![PyPI](https://img.shields.io/pypi/v/darukade-mcp?color=2563eb)](https://pypi.org/project/darukade-mcp/)
39
+ [![License: MIT](https://img.shields.io/badge/license-MIT-16a34a)](https://github.com/sepehr071/darukade-mcp/blob/main/LICENSE)
40
+
41
+ ## For AI agents
42
+
43
+ - **What:** unofficial MCP server for darukade.com: supplements, vitamins, cosmetics and skin care, baby, medical equipment.
44
+ - **Install:** `uvx darukade-mcp` or `claude mcp add darukade -- uvx darukade-mcp`. Transport: stdio. Needs [uv](https://docs.astral.sh/uv/getting-started/installation/).
45
+ - **Auth:** none. Optional `DARUKADE_MCP_PROXY` (HTTP proxy URL, or a comma-separated list); system proxy settings are ignored.
46
+ - **Access:** read-only. It cannot log in, order or upload prescriptions, so no confirmation is needed before any tool.
47
+ - **Tools:** 12, prefixed `dr_`. Typical: `dr_categories` / `dr_brands` / `dr_forms` -> `dr_search` / `dr_cheapest` -> `dr_product` / `dr_compare` -> `dr_reviews` / `dr_related`; or `dr_articles` -> `dr_article`.
48
+ - **Output:** compact JSON; prices in Toman after discount; Persian text as the shop has it. Not medical advice.
49
+
50
+ ## Quick start
51
+
52
+ ```bash
53
+ claude mcp add darukade -- uvx darukade-mcp
54
+ ```
55
+
56
+ Claude Desktop / Cursor: add `{"mcpServers": {"darukade": {"command": "uvx", "args": ["darukade-mcp"]}}}` to the MCP config.
57
+
58
+ ## Tools
59
+
60
+ | Tool | What it does |
61
+ |---|---|
62
+ | `dr_search` | Search the catalog by words, category, brand, form, price range, in stock, discounted, gift, with sort and paging |
63
+ | `dr_cheapest` | List the lowest-priced products of a category, brand or search, cheapest first |
64
+ | `dr_product` | One product: price per variant, stock, brand, ingredients with amounts, dosage, warnings, properties, pharmacy FAQ, magazine articles |
65
+ | `dr_compare` | 2-5 products side by side: price, stock, ingredient amounts, dosage, cheapest, common ingredients |
66
+ | `dr_reviews` | User comments and questions with the pharmacy's answers, plus the rating summary |
67
+ | `dr_related` | Alternatives, similar, same-brand and complementary products with prices |
68
+ | `dr_categories` | Category ids (about 60 top-level, 475 sub-categories) for the `category` filter |
69
+ | `dr_brands` | Find a brand by name and get its id for the `brand` filter, or read a brand's profile (about text, categories) |
70
+ | `dr_forms` | Product form ids (capsule, syrup, cream, spray ...) for the `form` filter |
71
+ | `dr_home` | The home page's golden offers, newest, best-selling and most-loved products |
72
+ | `dr_articles` | Search the health magazine (about 2,900 articles; category 14 is drug information) |
73
+ | `dr_article` | Read one magazine article as text with its author, sources and recommended products |
74
+
75
+ Example: "cheapest sunscreen for all skin types" -> `dr_categories("ضد آفتاب انواع پوست")` -> `dr_cheapest(category=[1379])`.
76
+ The site cannot sort text searches, so a text query with a cheapest/discount sort orders up to 288 matches itself.
77
+
78
+ ## Development
79
+
80
+ ```bash
81
+ uv sync
82
+ uv run ruff check . && uv run ruff format --check .
83
+ uv run python -m pytest -q
84
+ uv run python -m pytest -m live -q # calls the real site
85
+ ```
86
+
87
+ Unofficial; not affiliated with Darukade. MIT licensed. Author: Sepehr Radmard (سپهر رادمرد), github.com/sepehr071.
@@ -0,0 +1,60 @@
1
+ <!-- mcp-name: io.github.sepehr071/darukade-mcp -->
2
+
3
+ # darukade-mcp: Darukade (داروکده) online pharmacy MCP server for AI agents
4
+
5
+ **darukade-mcp is an unofficial, read-only MCP server for Darukade (darukade.com), an Iranian online pharmacy.**<br>
6
+ Its 12 tools search the product catalog with filters (category, brand, form, price, stock, discount), find the cheapest products,
7
+ read a product's price per variant, ingredients, dosage and warnings, compare products, list user questions with the pharmacists'
8
+ answers, suggest alternatives, show the home page offers, and search and read the Darukade health magazine.<br>
9
+ No API key or account is needed. It works in Claude, Cursor, Copilot and any other MCP client.
10
+
11
+ [![PyPI](https://img.shields.io/pypi/v/darukade-mcp?color=2563eb)](https://pypi.org/project/darukade-mcp/)
12
+ [![License: MIT](https://img.shields.io/badge/license-MIT-16a34a)](https://github.com/sepehr071/darukade-mcp/blob/main/LICENSE)
13
+
14
+ ## For AI agents
15
+
16
+ - **What:** unofficial MCP server for darukade.com: supplements, vitamins, cosmetics and skin care, baby, medical equipment.
17
+ - **Install:** `uvx darukade-mcp` or `claude mcp add darukade -- uvx darukade-mcp`. Transport: stdio. Needs [uv](https://docs.astral.sh/uv/getting-started/installation/).
18
+ - **Auth:** none. Optional `DARUKADE_MCP_PROXY` (HTTP proxy URL, or a comma-separated list); system proxy settings are ignored.
19
+ - **Access:** read-only. It cannot log in, order or upload prescriptions, so no confirmation is needed before any tool.
20
+ - **Tools:** 12, prefixed `dr_`. Typical: `dr_categories` / `dr_brands` / `dr_forms` -> `dr_search` / `dr_cheapest` -> `dr_product` / `dr_compare` -> `dr_reviews` / `dr_related`; or `dr_articles` -> `dr_article`.
21
+ - **Output:** compact JSON; prices in Toman after discount; Persian text as the shop has it. Not medical advice.
22
+
23
+ ## Quick start
24
+
25
+ ```bash
26
+ claude mcp add darukade -- uvx darukade-mcp
27
+ ```
28
+
29
+ Claude Desktop / Cursor: add `{"mcpServers": {"darukade": {"command": "uvx", "args": ["darukade-mcp"]}}}` to the MCP config.
30
+
31
+ ## Tools
32
+
33
+ | Tool | What it does |
34
+ |---|---|
35
+ | `dr_search` | Search the catalog by words, category, brand, form, price range, in stock, discounted, gift, with sort and paging |
36
+ | `dr_cheapest` | List the lowest-priced products of a category, brand or search, cheapest first |
37
+ | `dr_product` | One product: price per variant, stock, brand, ingredients with amounts, dosage, warnings, properties, pharmacy FAQ, magazine articles |
38
+ | `dr_compare` | 2-5 products side by side: price, stock, ingredient amounts, dosage, cheapest, common ingredients |
39
+ | `dr_reviews` | User comments and questions with the pharmacy's answers, plus the rating summary |
40
+ | `dr_related` | Alternatives, similar, same-brand and complementary products with prices |
41
+ | `dr_categories` | Category ids (about 60 top-level, 475 sub-categories) for the `category` filter |
42
+ | `dr_brands` | Find a brand by name and get its id for the `brand` filter, or read a brand's profile (about text, categories) |
43
+ | `dr_forms` | Product form ids (capsule, syrup, cream, spray ...) for the `form` filter |
44
+ | `dr_home` | The home page's golden offers, newest, best-selling and most-loved products |
45
+ | `dr_articles` | Search the health magazine (about 2,900 articles; category 14 is drug information) |
46
+ | `dr_article` | Read one magazine article as text with its author, sources and recommended products |
47
+
48
+ Example: "cheapest sunscreen for all skin types" -> `dr_categories("ضد آفتاب انواع پوست")` -> `dr_cheapest(category=[1379])`.
49
+ The site cannot sort text searches, so a text query with a cheapest/discount sort orders up to 288 matches itself.
50
+
51
+ ## Development
52
+
53
+ ```bash
54
+ uv sync
55
+ uv run ruff check . && uv run ruff format --check .
56
+ uv run python -m pytest -q
57
+ uv run python -m pytest -m live -q # calls the real site
58
+ ```
59
+
60
+ Unofficial; not affiliated with Darukade. MIT licensed. Author: Sepehr Radmard (سپهر رادمرد), github.com/sepehr071.
@@ -0,0 +1,55 @@
1
+ [project]
2
+ name = "darukade-mcp"
3
+ version = "0.1.0"
4
+ description = "Unofficial read-only MCP server for Darukade (darukade.com), an Iranian online pharmacy: search products, prices, ingredients, usage, reviews."
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", "darukade", "darukade-com", "iran", "pharmacy", "online-pharmacy", "supplements", "vitamins", "cosmetics", "skin-care", "prices", "claude", "persian"]
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/darukade-mcp"
31
+ Issues = "https://github.com/sepehr071/darukade-mcp/issues"
32
+ Website = "https://mcpfarsi.ir/"
33
+
34
+ [project.scripts]
35
+ darukade-mcp = "darukade_mcp.server:main"
36
+
37
+ [dependency-groups]
38
+ dev = ["pytest>=8", "ruff>=0.6"]
39
+
40
+ [build-system]
41
+ requires = ["hatchling"]
42
+ build-backend = "hatchling.build"
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+ addopts = "-m 'not live'"
47
+ markers = ["live: calls the real Darukade site (run with -m live)"]
48
+
49
+ [tool.ruff]
50
+ line-length = 120
51
+ target-version = "py310"
52
+
53
+ [tool.ruff.lint]
54
+ select = ["E", "F", "I", "B", "UP"]
55
+ 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/darukade-mcp",
4
+ "title": "Darukade Pharmacy",
5
+ "description": "Read-only Darukade (darukade.com) pharmacy: products, prices, ingredients, usage, reviews.",
6
+ "repository": {
7
+ "url": "https://github.com/sepehr071/darukade-mcp",
8
+ "source": "github"
9
+ },
10
+ "websiteUrl": "https://github.com/sepehr071/darukade-mcp",
11
+ "version": "0.1.0",
12
+ "packages": [
13
+ {
14
+ "registryType": "pypi",
15
+ "registryBaseUrl": "https://pypi.org",
16
+ "identifier": "darukade-mcp",
17
+ "version": "0.1.0",
18
+ "runtimeHint": "uvx",
19
+ "transport": {
20
+ "type": "stdio"
21
+ },
22
+ "environmentVariables": [
23
+ {
24
+ "name": "DARUKADE_MCP_PROXY",
25
+ "description": "Optional HTTP proxy URL for the Darukade requests; 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,5 @@
1
+ """Unofficial MCP server for Darukade (darukade.com), an Iranian online pharmacy: product search, prices, ingredients."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("darukade-mcp")
@@ -0,0 +1,253 @@
1
+ """Shared async HTTP client for darukade.com (Next.js pages with __NEXT_DATA__, plus two small JSON endpoints).
2
+
3
+ Every tool goes through `fetch_page` / `fetch_json`, which cap concurrency, cache answers briefly and turn HTTP
4
+ failures into `ToolError` messages the model can act on.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import copy
11
+ import json as json_lib
12
+ import os
13
+ import random
14
+ import re
15
+ import time
16
+ from typing import Any
17
+
18
+ import httpx
19
+ from curl_cffi.const import CurlOpt
20
+ from httpx_curl_cffi import AsyncCurlTransport
21
+ from mcp.server.mcpserver.exceptions import ToolError
22
+
23
+ SITE = "https://darukade.com"
24
+ # Only this host is ever called; it doubles as the allowlist.
25
+ HOSTS = {"darukade.com"}
26
+
27
+ HEADERS = {
28
+ # User-Agent, sec-ch-ua and TLS come from the impersonated Chrome profile (see _get_client).
29
+ "Accept-Language": "fa-IR,fa;q=0.9,en;q=0.8",
30
+ }
31
+ PAGE_HEADERS = {
32
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
33
+ "sec-fetch-dest": "document",
34
+ "sec-fetch-mode": "navigate",
35
+ "sec-fetch-site": "same-origin",
36
+ "Referer": f"{SITE}/",
37
+ }
38
+ JSON_HEADERS = {
39
+ # The site's own fetch() calls (checked in Chrome 2026-10-31): same-origin, Accept */*.
40
+ "Accept": "*/*",
41
+ "sec-fetch-dest": "empty",
42
+ "sec-fetch-mode": "cors",
43
+ "sec-fetch-site": "same-origin",
44
+ "priority": "u=1, i",
45
+ "Referer": f"{SITE}/",
46
+ }
47
+
48
+ MAX_CONCURRENCY = 3
49
+ JITTER = (0.1, 0.4) # seconds before each uncached call
50
+ MAX_RETRIES = 2
51
+ MAX_RETRY_WAIT = 10 # cap for Retry-After and backoff, seconds
52
+ CACHE_TTL = 120 # seconds; prices and stock change slowly
53
+ CACHE_MAX = 64
54
+ RETRY_DELAY = 0.5 # seconds, doubled per retry
55
+ MAX_BODY = 5_000_000 # bytes; larger responses are refused
56
+
57
+ _transport: httpx.AsyncBaseTransport | None = None
58
+ _client: httpx.AsyncClient | None = None
59
+ _limit: asyncio.Semaphore | None = None
60
+ _proxy_index = 0
61
+ _retired: set[asyncio.Task] = set()
62
+ _cache: dict[tuple, tuple[float, Any]] = {}
63
+ _NEXT_DATA = re.compile(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.S)
64
+
65
+
66
+ class _XhrTransport(AsyncCurlTransport):
67
+ """The Chrome profile sends navigation-only headers; a None value makes curl drop them on XHR calls."""
68
+
69
+ def _create_request_params(self, req: httpx.Request) -> dict[str, Any]:
70
+ # The parent drops "accept: */*" as an httpx default; curl would then send Chrome's page Accept instead.
71
+ accept = req.headers.get("accept")
72
+ p = super()._create_request_params(req)
73
+ extra = [(b"cache-control", None)] # Chrome sends max-age=0 only on a reload
74
+ if req.headers.get("sec-fetch-mode") != "navigate":
75
+ extra += [(b"upgrade-insecure-requests", None), (b"sec-fetch-user", None)]
76
+ p["headers"] = [*p["headers"], *extra, *([(b"accept", b"*/*")] if accept == "*/*" else [])]
77
+ return p
78
+
79
+
80
+ class ApiError(ToolError):
81
+ """A failed upstream call. `status` is the HTTP status, or None for network errors."""
82
+
83
+ def __init__(self, message: str, status: int | None = None):
84
+ super().__init__(message)
85
+ self.status = status
86
+
87
+
88
+ def set_transport(transport: httpx.AsyncBaseTransport | None) -> None:
89
+ """Swap the transport (tests use httpx.MockTransport). Drops the current client."""
90
+ global _transport, _client, _limit, _proxy_index
91
+ _transport, _client, _limit, _proxy_index = transport, None, None, 0
92
+ _cache.clear()
93
+
94
+
95
+ def _proxies() -> list[str]:
96
+ """DARUKADE_MCP_PROXY: one proxy URL, or a comma-separated list used one at a time."""
97
+ return [p.strip() for p in os.environ.get("DARUKADE_MCP_PROXY", "").split(",") if p.strip()]
98
+
99
+
100
+ def _next_proxy(client: httpx.AsyncClient) -> None:
101
+ """Sticky pool: move to the next proxy after a block or connect error; the next call rebuilds the client.
102
+
103
+ Only the client that failed may rotate. The old client is closed late because other requests may still use it.
104
+ """
105
+ global _client, _limit, _proxy_index
106
+ if len(_proxies()) < 2 or client is not _client:
107
+ return
108
+ _client, _limit, _proxy_index = None, None, _proxy_index + 1
109
+ t = asyncio.create_task(_close_later(client))
110
+ _retired.add(t)
111
+ t.add_done_callback(_retired.discard)
112
+
113
+
114
+ async def _close_later(client: httpx.AsyncClient) -> None:
115
+ await asyncio.sleep(30) # longer than the 20 s request timeout
116
+ await client.aclose()
117
+
118
+
119
+ def _get_client() -> tuple[httpx.AsyncClient, asyncio.Semaphore]:
120
+ global _client, _limit
121
+ if _client is None:
122
+ # System proxy settings are ignored; the proxy only comes from DARUKADE_MCP_PROXY. curl_cffi presents
123
+ # Chrome's TLS/HTTP2 fingerprint and default headers. The proxy goes to the transport.
124
+ proxies = _proxies()
125
+ _client = httpx.AsyncClient(
126
+ transport=_transport
127
+ or _XhrTransport(
128
+ impersonate="chrome",
129
+ default_headers=True,
130
+ curl_options={
131
+ CurlOpt.NOPROXY: "*"
132
+ }, # httpx-curl-cffi drops its own NOPROXY unless this dict is non-empty
133
+ proxy=proxies[_proxy_index % len(proxies)] if proxies else None,
134
+ max_connections=2 * MAX_CONCURRENCY,
135
+ ),
136
+ headers=HEADERS,
137
+ timeout=httpx.Timeout(20, connect=5),
138
+ limits=httpx.Limits(max_connections=2 * MAX_CONCURRENCY),
139
+ follow_redirects=False,
140
+ trust_env=False,
141
+ )
142
+ _limit = asyncio.Semaphore(MAX_CONCURRENCY)
143
+ assert _limit is not None
144
+ return _client, _limit
145
+
146
+
147
+ async def fetch_page(path: str, params: dict[str, Any] | None = None) -> dict:
148
+ """GET a site page and return its Next.js `pageProps` (the server-rendered data)."""
149
+ html = await _fetch(path, params, PAGE_HEADERS)
150
+ m = _NEXT_DATA.search(html)
151
+ if not m:
152
+ raise ApiError("darukade.com returned a page without data (layout changed or blocked).")
153
+ try:
154
+ props = json_lib.loads(m.group(1))["props"]["pageProps"]
155
+ except (ValueError, KeyError) as e:
156
+ raise ApiError("darukade.com page data could not be read.") from e
157
+ return props
158
+
159
+
160
+ async def fetch_json(path: str, params: dict[str, Any] | None = None) -> Any:
161
+ """GET one of the site's /api/ JSON endpoints."""
162
+ text = await _fetch(path, params, JSON_HEADERS)
163
+ try:
164
+ return json_lib.loads(text)
165
+ except ValueError as e:
166
+ raise ApiError("darukade.com returned a non-JSON response.") from e
167
+
168
+
169
+ async def _fetch(path: str, params: dict[str, Any] | None, headers: dict[str, str]) -> str:
170
+ """Cached GET with retries; returns the response body text. Retried on timeout, network error, 5xx or 429."""
171
+ query = dict(params or {})
172
+ url = SITE + path
173
+ host = httpx.URL(url).host
174
+ if host not in HOSTS:
175
+ raise ApiError(f"Refusing to call unexpected host {host}.")
176
+ key = (url, tuple(sorted(query.items())), headers["Accept"])
177
+ if (hit := _cache.get(key)) and time.monotonic() - hit[0] < CACHE_TTL:
178
+ return copy.copy(hit[1])
179
+ for attempt in range(MAX_RETRIES + 1):
180
+ last = attempt == MAX_RETRIES
181
+ wait = RETRY_DELAY * 2**attempt * random.uniform(1, 1.5)
182
+ try:
183
+ r, content = await _request(url, query, headers, host)
184
+ except ApiError as e:
185
+ if last or e.status is not None:
186
+ raise
187
+ await asyncio.sleep(min(wait, MAX_RETRY_WAIT))
188
+ continue
189
+ if (r.status_code == 429 or r.status_code >= 500) and not last:
190
+ await asyncio.sleep(min(_retry_after(r) or wait, MAX_RETRY_WAIT))
191
+ continue
192
+ break
193
+ if r.status_code >= 400:
194
+ raise ApiError(_status_message(r, host), r.status_code)
195
+ text = content.decode("utf-8", "replace")
196
+ if len(_cache) >= CACHE_MAX:
197
+ _cache.pop(next(iter(_cache)))
198
+ _cache[key] = (time.monotonic(), text)
199
+ return text
200
+
201
+
202
+ async def _send(client, limit, url, query, headers, host) -> tuple[httpx.Response, bytes]:
203
+ try:
204
+ async with limit:
205
+ await asyncio.sleep(random.uniform(*JITTER))
206
+ async with client.stream("GET", url, params=query, headers=headers) as r:
207
+ return r, await _read_capped(r, host)
208
+ except httpx.TimeoutException as e:
209
+ raise ApiError(f"{host} did not answer in time. Try again in a moment.") from e
210
+ except httpx.RequestError as e:
211
+ if isinstance(e, httpx.ConnectError | httpx.ProxyError):
212
+ _next_proxy(client)
213
+ raise ApiError(
214
+ f"Could not reach {host} ({type(e).__name__}). Check the internet connection "
215
+ "(or the DARUKADE_MCP_PROXY setting if you use one)."
216
+ ) from e
217
+
218
+
219
+ async def _request(url, query, headers, host) -> tuple[httpx.Response, bytes]:
220
+ client, limit = _get_client()
221
+ r, content = await _send(client, limit, url, query, headers, host)
222
+ if r.status_code in (403, 429):
223
+ _next_proxy(client) # blocked: try the next proxy on the following call
224
+ return r, content
225
+
226
+
227
+ async def _read_capped(r: httpx.Response, host: str) -> bytes:
228
+ """Read the body, refusing anything over MAX_BODY so one huge answer cannot flood memory or context."""
229
+ buf = bytearray()
230
+ async for chunk in r.aiter_bytes():
231
+ buf += chunk
232
+ if len(buf) > MAX_BODY:
233
+ raise ApiError(f"{host} sent a response over {MAX_BODY // 1_000_000} MB; refused.", r.status_code)
234
+ return bytes(buf)
235
+
236
+
237
+ def _retry_after(r: httpx.Response) -> float | None:
238
+ """Seconds from a numeric Retry-After on a 429/503, else None (HTTP-date form is ignored)."""
239
+ v = r.headers.get("retry-after", "")
240
+ return float(v) if r.status_code in (429, 503) and v.replace(".", "", 1).isdigit() else None
241
+
242
+
243
+ def _status_message(r: httpx.Response, host: str) -> str:
244
+ code = r.status_code
245
+ if code == 403:
246
+ return f"{host} blocked the request (HTTP 403). Try another network or set DARUKADE_MCP_PROXY."
247
+ if code == 404:
248
+ return f"{host} found nothing at that address (HTTP 404). Check the id or filters, or the page number."
249
+ if code == 429:
250
+ return f"{host} is rate limiting requests (HTTP 429). Wait a minute before retrying."
251
+ if code >= 500:
252
+ return f"{host} had a server error (HTTP {code}). Try again later."
253
+ return f"{host} rejected the request (HTTP {code})."