ponisha-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 (34) hide show
  1. ponisha_mcp-0.1.0/.github/workflows/ci.yml +22 -0
  2. ponisha_mcp-0.1.0/.github/workflows/publish.yml +51 -0
  3. ponisha_mcp-0.1.0/.gitignore +6 -0
  4. ponisha_mcp-0.1.0/LICENSE +21 -0
  5. ponisha_mcp-0.1.0/PKG-INFO +92 -0
  6. ponisha_mcp-0.1.0/README.md +65 -0
  7. ponisha_mcp-0.1.0/pyproject.toml +55 -0
  8. ponisha_mcp-0.1.0/server.json +32 -0
  9. ponisha_mcp-0.1.0/src/ponisha_mcp/__init__.py +5 -0
  10. ponisha_mcp-0.1.0/src/ponisha_mcp/http.py +236 -0
  11. ponisha_mcp-0.1.0/src/ponisha_mcp/ponisha.py +868 -0
  12. ponisha_mcp-0.1.0/src/ponisha_mcp/registry.py +16 -0
  13. ponisha_mcp-0.1.0/src/ponisha_mcp/server.py +93 -0
  14. ponisha_mcp-0.1.0/tests/conftest.py +78 -0
  15. ponisha_mcp-0.1.0/tests/fixtures/bids.json +187 -0
  16. ponisha_mcp-0.1.0/tests/fixtures/catalog.json +369 -0
  17. ponisha_mcp-0.1.0/tests/fixtures/catalogs.json +722 -0
  18. ponisha_mcp-0.1.0/tests/fixtures/categories.json +102 -0
  19. ponisha_mcp-0.1.0/tests/fixtures/comments.json +81 -0
  20. ponisha_mcp-0.1.0/tests/fixtures/feedback.json +19 -0
  21. ponisha_mcp-0.1.0/tests/fixtures/freelancers.json +136 -0
  22. ponisha_mcp-0.1.0/tests/fixtures/portfolio_list.json +99 -0
  23. ponisha_mcp-0.1.0/tests/fixtures/portfolios.json +90 -0
  24. ponisha_mcp-0.1.0/tests/fixtures/profile.json +58 -0
  25. ponisha_mcp-0.1.0/tests/fixtures/profile2.json +127 -0
  26. ponisha_mcp-0.1.0/tests/fixtures/project.json +95 -0
  27. ponisha_mcp-0.1.0/tests/fixtures/projects.json +286 -0
  28. ponisha_mcp-0.1.0/tests/fixtures/resume.json +24 -0
  29. ponisha_mcp-0.1.0/tests/fixtures/reviews_by_skill.json +96 -0
  30. ponisha_mcp-0.1.0/tests/fixtures/skills.json +22 -0
  31. ponisha_mcp-0.1.0/tests/fixtures/user_projects.json +165 -0
  32. ponisha_mcp-0.1.0/tests/test_live_ponisha.py +56 -0
  33. ponisha_mcp-0.1.0/tests/test_ponisha.py +197 -0
  34. ponisha_mcp-0.1.0/uv.lock +1362 -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/ponisha-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,92 @@
1
+ Metadata-Version: 2.5
2
+ Name: ponisha-mcp
3
+ Version: 0.1.0
4
+ Summary: Unofficial read-only MCP server for Ponisha (ponisha.ir), the Iranian freelance marketplace: projects, bids, freelancers, skill demand.
5
+ Project-URL: Homepage, https://github.com/sepehr071/ponisha-mcp
6
+ Project-URL: Issues, https://github.com/sepehr071/ponisha-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: bids,claude,freelance,freelancer,iran,marketplace,mcp,model-context-protocol,persian,ponisha,ponisha-ir,projects
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/ponisha-mcp -->
29
+
30
+ # ponisha-mcp: Ponisha (پونیشا) freelance marketplace MCP server for AI agents
31
+
32
+ **ponisha-mcp is an unofficial, read-only MCP server that gives AI agents the public data of Ponisha (ponisha.ir), Iran's freelance marketplace.**<br>
33
+ Its 14 tools search projects (text, skills, category, budget, sort), show a project with the employer's track record, list the offers made on it, search freelancers, read profiles, reviews and portfolios, browse fixed-price service packages, and summarize demand, budgets and trends for a skill.<br>
34
+ No API key or Ponisha account is needed. It works in Claude, Cursor, Copilot and any other MCP client.
35
+
36
+ [For AI agents](#for-ai-agents) · [Quick start](#quick-start) · [Tools](#tools) · [FAQ](#faq) · [فارسی](#فارسی)
37
+
38
+ ---
39
+
40
+ ## For AI agents
41
+
42
+ - **What:** unofficial MCP server (model context protocol) for ponisha.ir: project search, project details, bids, freelancer search, public profiles, skills and categories, skill market summary.
43
+ - **Install:** `uvx ponisha-mcp` or `claude mcp add ponisha -- uvx ponisha-mcp`. Transport: stdio. Needs [uv](https://docs.astral.sh/uv/getting-started/installation/) (Python 3.10+).
44
+ - **Auth:** none. Optional `PONISHA_MCP_PROXY` (HTTP proxy URL, or a comma-separated list); system proxy settings are ignored.
45
+ - **Access:** read-only. It cannot log in, bid, post projects or message anyone, so no confirmation is needed before calling any tool.
46
+ - **Tools:** 14, all prefixed `pn_`. Typical sequences: `pn_skills` -> `pn_search_projects` -> `pn_project` -> `pn_bids`; `pn_search_freelancers` -> `pn_profile`; `pn_skills` -> `pn_market` / `pn_trend`; `pn_profile` -> `pn_reviews` + `pn_portfolios`; `pn_services` -> `pn_service`.
47
+ - **Use when:** the user wants freelance projects to bid on in Iran, what a kind of work pays, how crowded a project is, whether an employer is reliable, or who to hire for a skill.
48
+ - **Do not use when:** the user wants to apply, message or post (read-only), or needs private contact data.
49
+ - **Output:** compact JSON. Money is Toman. Times are Tehran time. Text is Persian user content.
50
+
51
+ ## Quick start
52
+
53
+ ```bash
54
+ claude mcp add ponisha -- uvx ponisha-mcp
55
+ ```
56
+
57
+ Any MCP client: command `uvx`, args `["ponisha-mcp"]`.
58
+
59
+ ## Tools
60
+
61
+ | Tool | What it does |
62
+ |---|---|
63
+ | `pn_search_projects` | Projects by text, skills, category, open/all, budget range, sort. |
64
+ | `pn_project` | One project: brief, budget, deadline, employer track record, optional bid price sample. |
65
+ | `pn_bids` | Offers on a project: price, days, pitch, bidder level and rating (paged). |
66
+ | `pn_search_freelancers` | Freelancers by text, skills, level, portfolio, ranked by score. |
67
+ | `pn_profile` | Public profile of a freelancer or employer. |
68
+ | `pn_skills` | Skill name -> slug/id (about 700 skills). |
69
+ | `pn_categories` | Top-level categories, or the sub-categories of one. |
70
+ | `pn_market` | Open projects, budget quartiles, competition and related skills for one skill. |
71
+ | `pn_trend` | Posting rate, median budget and bids for a skill sampled across its whole history. |
72
+ | `pn_reviews` | Reviews of a user with score breakdown, or recent reviews for a skill. |
73
+ | `pn_portfolios` | Search about 500k portfolio items, or list one user's. |
74
+ | `pn_services` | Fixed-price service packages by category, price, rating. |
75
+ | `pn_service` | One package: deliverables, requirements, paid add-ons. |
76
+ | `pn_user_projects` | An employer's or freelancer's finished/open projects. |
77
+
78
+ ## FAQ
79
+
80
+ **Is it official?** No. It reads the same public API the ponisha.ir web app uses for anonymous visitors.
81
+
82
+ **Why are bids slow?** Ponisha serves the bids list slowly (seconds, up to 30 s on projects with 100+ bids). Keep `limit` small.
83
+
84
+ **Why does the budget filter only look at one page?** The site has no budget filter. The tool scans up to 50 projects per page and keeps those whose range fits.
85
+
86
+ ## فارسی
87
+
88
+ **ponisha-mcp** یک سرور MCP غیررسمی و فقط‌خواندنی برای پونیشا (ponisha.ir) است. با آن می‌توانید پروژه‌ها را با کلمه، مهارت، دسته و بودجه جستجو کنید، پیشنهادهای ثبت‌شده و سابقه کارفرما را ببینید، فریلنسرها و پروفایل‌های عمومی را جستجو کنید و وضعیت بازار یک مهارت را بسنجید. بدون کلید API و حساب کاربری. نصب: `uvx ponisha-mcp`.
89
+
90
+ مبلغ‌ها به تومان و زمان‌ها به وقت تهران (میلادی) هستند. این ابزار ثبت پیشنهاد، پیام یا ثبت پروژه انجام نمی‌دهد.
91
+
92
+ Author: Sepehr Radmard (سپهر رادمرد) · github.com/sepehr071 · linkedin.com/in/sepehr-radmard
@@ -0,0 +1,65 @@
1
+ <!-- mcp-name: io.github.sepehr071/ponisha-mcp -->
2
+
3
+ # ponisha-mcp: Ponisha (پونیشا) freelance marketplace MCP server for AI agents
4
+
5
+ **ponisha-mcp is an unofficial, read-only MCP server that gives AI agents the public data of Ponisha (ponisha.ir), Iran's freelance marketplace.**<br>
6
+ Its 14 tools search projects (text, skills, category, budget, sort), show a project with the employer's track record, list the offers made on it, search freelancers, read profiles, reviews and portfolios, browse fixed-price service packages, and summarize demand, budgets and trends for a skill.<br>
7
+ No API key or Ponisha account is needed. It works in Claude, Cursor, Copilot and any other MCP client.
8
+
9
+ [For AI agents](#for-ai-agents) · [Quick start](#quick-start) · [Tools](#tools) · [FAQ](#faq) · [فارسی](#فارسی)
10
+
11
+ ---
12
+
13
+ ## For AI agents
14
+
15
+ - **What:** unofficial MCP server (model context protocol) for ponisha.ir: project search, project details, bids, freelancer search, public profiles, skills and categories, skill market summary.
16
+ - **Install:** `uvx ponisha-mcp` or `claude mcp add ponisha -- uvx ponisha-mcp`. Transport: stdio. Needs [uv](https://docs.astral.sh/uv/getting-started/installation/) (Python 3.10+).
17
+ - **Auth:** none. Optional `PONISHA_MCP_PROXY` (HTTP proxy URL, or a comma-separated list); system proxy settings are ignored.
18
+ - **Access:** read-only. It cannot log in, bid, post projects or message anyone, so no confirmation is needed before calling any tool.
19
+ - **Tools:** 14, all prefixed `pn_`. Typical sequences: `pn_skills` -> `pn_search_projects` -> `pn_project` -> `pn_bids`; `pn_search_freelancers` -> `pn_profile`; `pn_skills` -> `pn_market` / `pn_trend`; `pn_profile` -> `pn_reviews` + `pn_portfolios`; `pn_services` -> `pn_service`.
20
+ - **Use when:** the user wants freelance projects to bid on in Iran, what a kind of work pays, how crowded a project is, whether an employer is reliable, or who to hire for a skill.
21
+ - **Do not use when:** the user wants to apply, message or post (read-only), or needs private contact data.
22
+ - **Output:** compact JSON. Money is Toman. Times are Tehran time. Text is Persian user content.
23
+
24
+ ## Quick start
25
+
26
+ ```bash
27
+ claude mcp add ponisha -- uvx ponisha-mcp
28
+ ```
29
+
30
+ Any MCP client: command `uvx`, args `["ponisha-mcp"]`.
31
+
32
+ ## Tools
33
+
34
+ | Tool | What it does |
35
+ |---|---|
36
+ | `pn_search_projects` | Projects by text, skills, category, open/all, budget range, sort. |
37
+ | `pn_project` | One project: brief, budget, deadline, employer track record, optional bid price sample. |
38
+ | `pn_bids` | Offers on a project: price, days, pitch, bidder level and rating (paged). |
39
+ | `pn_search_freelancers` | Freelancers by text, skills, level, portfolio, ranked by score. |
40
+ | `pn_profile` | Public profile of a freelancer or employer. |
41
+ | `pn_skills` | Skill name -> slug/id (about 700 skills). |
42
+ | `pn_categories` | Top-level categories, or the sub-categories of one. |
43
+ | `pn_market` | Open projects, budget quartiles, competition and related skills for one skill. |
44
+ | `pn_trend` | Posting rate, median budget and bids for a skill sampled across its whole history. |
45
+ | `pn_reviews` | Reviews of a user with score breakdown, or recent reviews for a skill. |
46
+ | `pn_portfolios` | Search about 500k portfolio items, or list one user's. |
47
+ | `pn_services` | Fixed-price service packages by category, price, rating. |
48
+ | `pn_service` | One package: deliverables, requirements, paid add-ons. |
49
+ | `pn_user_projects` | An employer's or freelancer's finished/open projects. |
50
+
51
+ ## FAQ
52
+
53
+ **Is it official?** No. It reads the same public API the ponisha.ir web app uses for anonymous visitors.
54
+
55
+ **Why are bids slow?** Ponisha serves the bids list slowly (seconds, up to 30 s on projects with 100+ bids). Keep `limit` small.
56
+
57
+ **Why does the budget filter only look at one page?** The site has no budget filter. The tool scans up to 50 projects per page and keeps those whose range fits.
58
+
59
+ ## فارسی
60
+
61
+ **ponisha-mcp** یک سرور MCP غیررسمی و فقط‌خواندنی برای پونیشا (ponisha.ir) است. با آن می‌توانید پروژه‌ها را با کلمه، مهارت، دسته و بودجه جستجو کنید، پیشنهادهای ثبت‌شده و سابقه کارفرما را ببینید، فریلنسرها و پروفایل‌های عمومی را جستجو کنید و وضعیت بازار یک مهارت را بسنجید. بدون کلید API و حساب کاربری. نصب: `uvx ponisha-mcp`.
62
+
63
+ مبلغ‌ها به تومان و زمان‌ها به وقت تهران (میلادی) هستند. این ابزار ثبت پیشنهاد، پیام یا ثبت پروژه انجام نمی‌دهد.
64
+
65
+ Author: Sepehr Radmard (سپهر رادمرد) · github.com/sepehr071 · linkedin.com/in/sepehr-radmard
@@ -0,0 +1,55 @@
1
+ [project]
2
+ name = "ponisha-mcp"
3
+ version = "0.1.0"
4
+ description = "Unofficial read-only MCP server for Ponisha (ponisha.ir), the Iranian freelance marketplace: projects, bids, freelancers, skill demand."
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", "ponisha", "ponisha-ir", "freelance", "freelancer", "projects", "iran", "bids", "marketplace", "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/ponisha-mcp"
31
+ Issues = "https://github.com/sepehr071/ponisha-mcp/issues"
32
+ Website = "https://mcpfarsi.ir/"
33
+
34
+ [project.scripts]
35
+ ponisha-mcp = "ponisha_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 Ponisha APIs (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/ponisha-mcp",
4
+ "title": "Ponisha Freelance",
5
+ "description": "Read-only Ponisha (ponisha.ir) freelance market: projects, bids, freelancers, skill demand.",
6
+ "repository": {
7
+ "url": "https://github.com/sepehr071/ponisha-mcp",
8
+ "source": "github"
9
+ },
10
+ "websiteUrl": "https://github.com/sepehr071/ponisha-mcp",
11
+ "version": "0.1.0",
12
+ "packages": [
13
+ {
14
+ "registryType": "pypi",
15
+ "registryBaseUrl": "https://pypi.org",
16
+ "identifier": "ponisha-mcp",
17
+ "version": "0.1.0",
18
+ "runtimeHint": "uvx",
19
+ "transport": {
20
+ "type": "stdio"
21
+ },
22
+ "environmentVariables": [
23
+ {
24
+ "name": "PONISHA_MCP_PROXY",
25
+ "description": "Optional HTTP proxy URL for the Ponisha 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 Ponisha (ponisha.ir), the Iranian freelance marketplace: projects, bids, freelancers."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("ponisha-mcp")
@@ -0,0 +1,236 @@
1
+ """Shared async HTTP client for the public APIs behind ponisha.ir (project/freelancer search, project and profile data).
2
+
3
+ Every tool goes through `fetch`, which caps concurrency, caches answers briefly and turns HTTP failures
4
+ 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 time
15
+ from typing import Any
16
+
17
+ import httpx
18
+ from curl_cffi.const import CurlOpt
19
+ from httpx_curl_cffi import AsyncCurlTransport
20
+ from mcp.server.mcpserver.exceptions import ToolError
21
+
22
+ API = "https://api.ponisha.ir/api/v1"
23
+ SEARCH = "https://search.ponisha.ir/v1"
24
+ SITE = "https://ponisha.ir"
25
+
26
+ HEADERS = {
27
+ # User-Agent, sec-ch-ua and TLS come from the impersonated Chrome profile (see _get_client).
28
+ "Accept-Language": "fa-IR,fa;q=0.9,en;q=0.8",
29
+ "sec-fetch-dest": "empty",
30
+ "sec-fetch-mode": "cors",
31
+ "priority": "u=1, i",
32
+ }
33
+
34
+ MAX_CONCURRENCY = 3
35
+ JITTER = (0.1, 0.4) # seconds before each uncached call
36
+ MAX_RETRIES = 1
37
+ MAX_RETRY_WAIT = 10 # cap for Retry-After and backoff, seconds
38
+ CACHE_TTL = 120 # seconds
39
+ CACHE_MAX = 128
40
+ RETRY_DELAY = 0.5 # seconds, doubled per retry
41
+ MAX_BODY = 5_000_000 # bytes; larger responses are refused
42
+ AXIOS = "application/json, text/plain, */*"
43
+ # The web app (ponisha.ir) calls these API hosts cross-site with axios (checked in Chrome 2026-10-31).
44
+ # Doubles as the host allowlist.
45
+ HOSTS = {"api.ponisha.ir", "search.ponisha.ir"}
46
+
47
+ _transport: httpx.AsyncBaseTransport | None = None
48
+ _client: httpx.AsyncClient | None = None
49
+ _limit: asyncio.Semaphore | None = None
50
+ _proxy_index = 0
51
+ _retired: set[asyncio.Task] = set()
52
+ _cache: dict[tuple, tuple[float, Any]] = {}
53
+
54
+
55
+ class _XhrTransport(AsyncCurlTransport):
56
+ """The Chrome profile sends navigation-only headers; a None value makes curl drop them on XHR calls."""
57
+
58
+ def _create_request_params(self, req: httpx.Request) -> dict[str, Any]:
59
+ # The parent drops "accept: */*" as an httpx default; curl would then send Chrome's page Accept instead.
60
+ accept = req.headers.get("accept")
61
+ p = super()._create_request_params(req)
62
+ extra = [(b"cache-control", None)] # Chrome sends max-age=0 only on a reload
63
+ if req.headers.get("sec-fetch-mode") != "navigate":
64
+ extra += [(b"upgrade-insecure-requests", None), (b"sec-fetch-user", None)]
65
+ p["headers"] = [*p["headers"], *extra, *([(b"accept", b"*/*")] if accept == "*/*" else [])]
66
+ return p
67
+
68
+
69
+ class ApiError(ToolError):
70
+ """A failed upstream call. `status` is the HTTP status, or None for network errors."""
71
+
72
+ def __init__(self, message: str, status: int | None = None):
73
+ super().__init__(message)
74
+ self.status = status
75
+
76
+
77
+ def set_transport(transport: httpx.AsyncBaseTransport | None) -> None:
78
+ """Swap the transport (tests use httpx.MockTransport). Drops the current client."""
79
+ global _transport, _client, _limit, _proxy_index
80
+ _transport, _client, _limit, _proxy_index = transport, None, None, 0
81
+ _cache.clear()
82
+
83
+
84
+ def _proxies() -> list[str]:
85
+ """PONISHA_MCP_PROXY: one proxy URL, or a comma-separated list used one at a time."""
86
+ return [p.strip() for p in os.environ.get("PONISHA_MCP_PROXY", "").split(",") if p.strip()]
87
+
88
+
89
+ def _next_proxy(client: httpx.AsyncClient) -> None:
90
+ """Sticky pool: move to the next proxy after a block or connect error; the next call rebuilds the client.
91
+
92
+ Only the client that failed may rotate. The old client is closed late because other requests may still use it.
93
+ """
94
+ global _client, _limit, _proxy_index
95
+ if len(_proxies()) < 2 or client is not _client:
96
+ return
97
+ _client, _limit, _proxy_index = None, None, _proxy_index + 1
98
+ t = asyncio.create_task(_close_later(client))
99
+ _retired.add(t)
100
+ t.add_done_callback(_retired.discard)
101
+
102
+
103
+ async def _close_later(client: httpx.AsyncClient) -> None:
104
+ await asyncio.sleep(30) # longer than the 60 s request timeout
105
+ await client.aclose()
106
+
107
+
108
+ def _get_client() -> tuple[httpx.AsyncClient, asyncio.Semaphore]:
109
+ global _client, _limit
110
+ if _client is None:
111
+ # System proxy settings are ignored; the proxy only comes from PONISHA_MCP_PROXY. curl_cffi presents
112
+ # Chrome's TLS/HTTP2 fingerprint and default headers. The proxy goes to the transport.
113
+ proxies = _proxies()
114
+ _client = httpx.AsyncClient(
115
+ transport=_transport
116
+ or _XhrTransport(
117
+ impersonate="chrome",
118
+ default_headers=True,
119
+ curl_options={
120
+ CurlOpt.NOPROXY: "*"
121
+ }, # httpx-curl-cffi drops its own NOPROXY unless this dict is non-empty
122
+ proxy=proxies[_proxy_index % len(proxies)] if proxies else None,
123
+ max_connections=2 * MAX_CONCURRENCY,
124
+ ),
125
+ headers=HEADERS,
126
+ timeout=httpx.Timeout(60, connect=5),
127
+ limits=httpx.Limits(max_connections=2 * MAX_CONCURRENCY),
128
+ follow_redirects=False,
129
+ trust_env=False,
130
+ )
131
+ _limit = asyncio.Semaphore(MAX_CONCURRENCY)
132
+ assert _limit is not None
133
+ return _client, _limit
134
+
135
+
136
+ async def fetch(url: str, params: dict[str, Any] | None = None, body: dict[str, Any] | None = None) -> Any:
137
+ """GET (or POST `body` as JSON, the search host's form) an endpoint the way ponisha.ir does; return parsed JSON.
138
+
139
+ Retried up to MAX_RETRIES times on timeout, network error, 5xx or 429, with jittered exponential backoff or
140
+ after Retry-After when sent. 403 is never retried.
141
+ """
142
+ query = dict(params or {})
143
+ host = httpx.URL(url).host
144
+ if host not in HOSTS:
145
+ raise ApiError(f"Refusing to call unexpected host {host}.")
146
+ key = (url, tuple(sorted(query.items())), json_lib.dumps(body, sort_keys=True))
147
+ if (hit := _cache.get(key)) and time.monotonic() - hit[0] < CACHE_TTL:
148
+ return copy.deepcopy(hit[1])
149
+ # axios also sends Content-Type: application/json on GET (seen in Chrome on api.ponisha.ir).
150
+ headers = {
151
+ "Origin": SITE,
152
+ "Referer": f"{SITE}/",
153
+ "Accept": AXIOS,
154
+ "Content-Type": "application/json",
155
+ "sec-fetch-site": "same-site",
156
+ }
157
+ for attempt in range(MAX_RETRIES + 1):
158
+ last = attempt == MAX_RETRIES
159
+ wait = RETRY_DELAY * 2**attempt * random.uniform(1, 1.5)
160
+ try:
161
+ r, content = await _request(url, query, body, headers, host)
162
+ except ApiError as e:
163
+ if last or e.status is not None:
164
+ raise
165
+ await asyncio.sleep(min(wait, MAX_RETRY_WAIT))
166
+ continue
167
+ if (r.status_code == 429 or r.status_code >= 500) and not last:
168
+ await asyncio.sleep(min(_retry_after(r) or wait, MAX_RETRY_WAIT))
169
+ continue
170
+ break
171
+
172
+ if r.status_code >= 400:
173
+ raise ApiError(_status_message(r, host), r.status_code)
174
+ try:
175
+ data = json_lib.loads(content)
176
+ except ValueError as e:
177
+ raise ApiError(f"{host} returned a non-JSON response (HTTP {r.status_code}).", r.status_code) from e
178
+ if len(_cache) >= CACHE_MAX:
179
+ _cache.pop(next(iter(_cache)))
180
+ _cache[key] = (time.monotonic(), copy.deepcopy(data))
181
+ return data
182
+
183
+
184
+ async def _send(client, limit, url, query, body, headers, host) -> tuple[httpx.Response, bytes]:
185
+ try:
186
+ async with limit:
187
+ await asyncio.sleep(random.uniform(*JITTER))
188
+ method = "POST" if body is not None else "GET"
189
+ async with client.stream(method, url, params=query, json=body, headers=headers) as r:
190
+ return r, await _read_capped(r, host)
191
+ except httpx.TimeoutException as e:
192
+ raise ApiError(f"{host} did not answer in time. Try again in a moment.") from e
193
+ except httpx.RequestError as e:
194
+ if isinstance(e, httpx.ConnectError | httpx.ProxyError):
195
+ _next_proxy(client)
196
+ raise ApiError(
197
+ f"Could not reach {host} ({type(e).__name__}). Check the internet connection "
198
+ "(or the PONISHA_MCP_PROXY setting if you use one)."
199
+ ) from e
200
+
201
+
202
+ async def _request(url, query, body, headers, host) -> tuple[httpx.Response, bytes]:
203
+ client, limit = _get_client()
204
+ r, content = await _send(client, limit, url, query, body, headers, host)
205
+ if r.status_code in (403, 429):
206
+ _next_proxy(client) # blocked: try the next proxy on the following call
207
+ return r, content
208
+
209
+
210
+ async def _read_capped(r: httpx.Response, host: str) -> bytes:
211
+ """Read the body, refusing anything over MAX_BODY so one huge answer cannot flood memory or context."""
212
+ buf = bytearray()
213
+ async for chunk in r.aiter_bytes():
214
+ buf += chunk
215
+ if len(buf) > MAX_BODY:
216
+ raise ApiError(f"{host} sent a response over {MAX_BODY // 1_000_000} MB; refused.", r.status_code)
217
+ return bytes(buf)
218
+
219
+
220
+ def _retry_after(r: httpx.Response) -> float | None:
221
+ """Seconds from a numeric Retry-After on a 429/503, else None (HTTP-date form is ignored)."""
222
+ v = r.headers.get("retry-after", "")
223
+ return float(v) if r.status_code in (429, 503) and v.replace(".", "", 1).isdigit() else None
224
+
225
+
226
+ def _status_message(r: httpx.Response, host: str) -> str:
227
+ code = r.status_code
228
+ if code == 403:
229
+ return f"{host} blocked the request (HTTP 403). Try another network or set PONISHA_MCP_PROXY."
230
+ if code == 404:
231
+ return f"{host} found nothing at that address (HTTP 404). Check the project id or username."
232
+ if code == 429:
233
+ return f"{host} is rate limiting requests (HTTP 429). Wait a minute before retrying."
234
+ if code >= 500:
235
+ return f"{host} had a server error (HTTP {code}). Try again later."
236
+ return f"{host} rejected the request (HTTP {code})."