qavanin-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 (32) hide show
  1. qavanin_mcp-0.1.0/.github/workflows/ci.yml +22 -0
  2. qavanin_mcp-0.1.0/.github/workflows/publish.yml +51 -0
  3. qavanin_mcp-0.1.0/.gitignore +6 -0
  4. qavanin_mcp-0.1.0/LICENSE +21 -0
  5. qavanin_mcp-0.1.0/PKG-INFO +87 -0
  6. qavanin_mcp-0.1.0/README.md +61 -0
  7. qavanin_mcp-0.1.0/pyproject.toml +54 -0
  8. qavanin_mcp-0.1.0/server.json +32 -0
  9. qavanin_mcp-0.1.0/src/qavanin_mcp/__init__.py +5 -0
  10. qavanin_mcp-0.1.0/src/qavanin_mcp/http.py +212 -0
  11. qavanin_mcp-0.1.0/src/qavanin_mcp/law.py +293 -0
  12. qavanin_mcp-0.1.0/src/qavanin_mcp/registry.py +16 -0
  13. qavanin_mcp-0.1.0/src/qavanin_mcp/search.py +288 -0
  14. qavanin_mcp-0.1.0/src/qavanin_mcp/server.py +73 -0
  15. qavanin_mcp-0.1.0/src/qavanin_mcp/text.py +28 -0
  16. qavanin_mcp-0.1.0/tests/conftest.py +79 -0
  17. qavanin_mcp-0.1.0/tests/fixtures/arvan_challenge.html +49 -0
  18. qavanin_mcp-0.1.0/tests/fixtures/law_attribute_civil.html +281 -0
  19. qavanin_mcp-0.1.0/tests/fixtures/law_attribute_regulation.html +281 -0
  20. qavanin_mcp-0.1.0/tests/fixtures/law_relatedindex_excerpt.html +193 -0
  21. qavanin_mcp-0.1.0/tests/fixtures/law_sectionstatus.html +36 -0
  22. qavanin_mcp-0.1.0/tests/fixtures/law_statusindex.html +664 -0
  23. qavanin_mcp-0.1.0/tests/fixtures/law_treetext_circular.html +366 -0
  24. qavanin_mcp-0.1.0/tests/fixtures/law_treetext_civil_excerpt.html +351 -0
  25. qavanin_mcp-0.1.0/tests/fixtures/law_treetext_vote.html +362 -0
  26. qavanin_mcp-0.1.0/tests/fixtures/search_empty.html +53 -0
  27. qavanin_mcp-0.1.0/tests/fixtures/search_results.html +120 -0
  28. qavanin_mcp-0.1.0/tests/test_http.py +76 -0
  29. qavanin_mcp-0.1.0/tests/test_law.py +86 -0
  30. qavanin_mcp-0.1.0/tests/test_live.py +53 -0
  31. qavanin_mcp-0.1.0/tests/test_search.py +124 -0
  32. qavanin_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/qavanin-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: qavanin-mcp
3
+ Version: 0.1.0
4
+ Summary: Unofficial read-only MCP server for qavanin.ir: search Iranian laws and regulations, read their full text, amendments and status.
5
+ Project-URL: Homepage, https://github.com/sepehr071/qavanin-mcp
6
+ Project-URL: Issues, https://github.com/sepehr071/qavanin-mcp/issues
7
+ Author-email: Sepehr Radmard <sepehrr80@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: iran,law,legal,mcp,model-context-protocol,qavanin,regulations
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/qavanin-mcp -->
28
+
29
+ # qavanin-mcp: Iranian laws and regulations MCP server for AI agents ⚖️
30
+
31
+ **Unofficial, read-only MCP server for [qavanin.ir](https://qavanin.ir) (سامانه ملی قوانین و مقررات جمهوری اسلامی ایران), the national database of Iranian laws, regulations, court votes and legal opinions.**<br>
32
+ Search about 168,000 documents by title or full text, read a law or a single article, see how an article was amended, and follow the regulations and court votes that cite it, all from Claude, Cursor or Copilot.
33
+
34
+ [Quick start](#quick-start) · [For AI agents](#for-ai-agents) · [Tools](#tools) · [Notes](#notes) · [فارسی](#فارسی)
35
+
36
+ ---
37
+
38
+ ## For AI agents
39
+
40
+ - **What:** unofficial MCP server for qavanin.ir. Queries work in Persian (Arabic ي/ك and Persian ی/ک are equivalent); dates are Jalali `YYYY/MM/DD`.
41
+ - **Install:** `claude mcp add qavanin -- uvx qavanin-mcp` (stdio transport, needs [uv](https://docs.astral.sh/uv/getting-started/installation/) and Python 3.10+).
42
+ - **Auth:** none. Optional `QAVANIN_MCP_PROXY` sets an HTTP proxy for requests to qavanin.ir.
43
+ - **Access:** read-only. No login, subscription or purchase calls exist.
44
+ - **Tools:** 7, all prefixed `qv_`. Typical sequence: `qv_search` → `qv_law` (with `article`) → `qv_amendments` → `qv_article_history`.
45
+ - **Use when:** looking up what an Iranian law, regulation, court ruling or legal opinion says, when it was approved and published, whether it was amended or repealed, and what cites it.
46
+ - **Do not use when:** the user needs legal advice or a binding interpretation. The site is a reference database; always say which version and status you read.
47
+ - **Output:** compact JSON. Document ids (`id`, the site's `IDS`) are 20-digit strings: keep them as strings. Output uses Persian letters. Sections marked `repealed` or `amended` are not current text.
48
+ - **Machine-readable:** [`server.json`](server.json), and the catalog of all Iranian MCP servers at [awesome-iran-mcp](https://github.com/sepehr071/awesome-iran-mcp).
49
+
50
+ ## Quick start
51
+
52
+ ```bash
53
+ claude mcp add qavanin -- uvx qavanin-mcp
54
+ ```
55
+
56
+ ```json
57
+ { "mcpServers": { "qavanin": { "command": "uvx", "args": ["qavanin-mcp"] } } }
58
+ ```
59
+
60
+ Example: «ماده ۱۰ قانون مدنی چه می‌گوید؟» → `qv_search(query="قانون مدنی", kind="law")` → `qv_law(ids=..., article="10")`.
61
+
62
+ ## Tools
63
+
64
+ | Tool | What it does |
65
+ |---|---|
66
+ | `qv_search` | Search titles or full text, with filters for kind, approving body, executor, subject, type, status, dates and gazette numbers |
67
+ | `qv_lookup` | Find the ids the search filters take: approving bodies, executing bodies, subjects, document types, statuses, curated lists |
68
+ | `qv_law` | Read the text of a law as sections, one article with its notes, or the text as of a past date |
69
+ | `qv_law_info` | Metadata: type, approver, approval, notification and gazette numbers and dates, last status, executors |
70
+ | `qv_amendments` | Which laws amended this one, which it amends, and which of its articles were amended |
71
+ | `qv_article_history` | Every amendment of one article: type, date, amending law, replacing text |
72
+ | `qv_related` | Regulations, court votes and legal opinions related to a law |
73
+
74
+ ## Notes
75
+
76
+ - The site is slow: 1-15 s per call, the civil code about 15 s on first read (it is cached for 5 minutes, so paging through it is fast).
77
+ - The site sometimes ignores search filters and answers with the whole database; the server detects and retries that.
78
+ - Sorting by date has many ties, so paging a long list can repeat or skip a row: narrow the filters instead of paging deep.
79
+ - A bot-check cookie (ArvanCloud) is solved in Python; no browser is needed.
80
+
81
+ ## فارسی
82
+
83
+ سرور MCP غیررسمی و فقط‌خواندنی برای سامانه ملی قوانین و مقررات (qavanin.ir): جستجو در عنوان یا متن حدود ۱۶۸ هزار سند، خواندن متن قانون یا یک ماده، تاریخچهٔ اصلاحات هر ماده و مقررات، آرا و نظرات مرتبط. نصب: `claude mcp add qavanin -- uvx qavanin-mcp`
84
+
85
+ ## License
86
+
87
+ MIT. Not affiliated with qavanin.ir or the Iranian government.
@@ -0,0 +1,61 @@
1
+ <!-- mcp-name: io.github.sepehr071/qavanin-mcp -->
2
+
3
+ # qavanin-mcp: Iranian laws and regulations MCP server for AI agents ⚖️
4
+
5
+ **Unofficial, read-only MCP server for [qavanin.ir](https://qavanin.ir) (سامانه ملی قوانین و مقررات جمهوری اسلامی ایران), the national database of Iranian laws, regulations, court votes and legal opinions.**<br>
6
+ Search about 168,000 documents by title or full text, read a law or a single article, see how an article was amended, and follow the regulations and court votes that cite it, all from Claude, Cursor or Copilot.
7
+
8
+ [Quick start](#quick-start) · [For AI agents](#for-ai-agents) · [Tools](#tools) · [Notes](#notes) · [فارسی](#فارسی)
9
+
10
+ ---
11
+
12
+ ## For AI agents
13
+
14
+ - **What:** unofficial MCP server for qavanin.ir. Queries work in Persian (Arabic ي/ك and Persian ی/ک are equivalent); dates are Jalali `YYYY/MM/DD`.
15
+ - **Install:** `claude mcp add qavanin -- uvx qavanin-mcp` (stdio transport, needs [uv](https://docs.astral.sh/uv/getting-started/installation/) and Python 3.10+).
16
+ - **Auth:** none. Optional `QAVANIN_MCP_PROXY` sets an HTTP proxy for requests to qavanin.ir.
17
+ - **Access:** read-only. No login, subscription or purchase calls exist.
18
+ - **Tools:** 7, all prefixed `qv_`. Typical sequence: `qv_search` → `qv_law` (with `article`) → `qv_amendments` → `qv_article_history`.
19
+ - **Use when:** looking up what an Iranian law, regulation, court ruling or legal opinion says, when it was approved and published, whether it was amended or repealed, and what cites it.
20
+ - **Do not use when:** the user needs legal advice or a binding interpretation. The site is a reference database; always say which version and status you read.
21
+ - **Output:** compact JSON. Document ids (`id`, the site's `IDS`) are 20-digit strings: keep them as strings. Output uses Persian letters. Sections marked `repealed` or `amended` are not current text.
22
+ - **Machine-readable:** [`server.json`](server.json), and the catalog of all Iranian MCP servers at [awesome-iran-mcp](https://github.com/sepehr071/awesome-iran-mcp).
23
+
24
+ ## Quick start
25
+
26
+ ```bash
27
+ claude mcp add qavanin -- uvx qavanin-mcp
28
+ ```
29
+
30
+ ```json
31
+ { "mcpServers": { "qavanin": { "command": "uvx", "args": ["qavanin-mcp"] } } }
32
+ ```
33
+
34
+ Example: «ماده ۱۰ قانون مدنی چه می‌گوید؟» → `qv_search(query="قانون مدنی", kind="law")` → `qv_law(ids=..., article="10")`.
35
+
36
+ ## Tools
37
+
38
+ | Tool | What it does |
39
+ |---|---|
40
+ | `qv_search` | Search titles or full text, with filters for kind, approving body, executor, subject, type, status, dates and gazette numbers |
41
+ | `qv_lookup` | Find the ids the search filters take: approving bodies, executing bodies, subjects, document types, statuses, curated lists |
42
+ | `qv_law` | Read the text of a law as sections, one article with its notes, or the text as of a past date |
43
+ | `qv_law_info` | Metadata: type, approver, approval, notification and gazette numbers and dates, last status, executors |
44
+ | `qv_amendments` | Which laws amended this one, which it amends, and which of its articles were amended |
45
+ | `qv_article_history` | Every amendment of one article: type, date, amending law, replacing text |
46
+ | `qv_related` | Regulations, court votes and legal opinions related to a law |
47
+
48
+ ## Notes
49
+
50
+ - The site is slow: 1-15 s per call, the civil code about 15 s on first read (it is cached for 5 minutes, so paging through it is fast).
51
+ - The site sometimes ignores search filters and answers with the whole database; the server detects and retries that.
52
+ - Sorting by date has many ties, so paging a long list can repeat or skip a row: narrow the filters instead of paging deep.
53
+ - A bot-check cookie (ArvanCloud) is solved in Python; no browser is needed.
54
+
55
+ ## فارسی
56
+
57
+ سرور MCP غیررسمی و فقط‌خواندنی برای سامانه ملی قوانین و مقررات (qavanin.ir): جستجو در عنوان یا متن حدود ۱۶۸ هزار سند، خواندن متن قانون یا یک ماده، تاریخچهٔ اصلاحات هر ماده و مقررات، آرا و نظرات مرتبط. نصب: `claude mcp add qavanin -- uvx qavanin-mcp`
58
+
59
+ ## License
60
+
61
+ MIT. Not affiliated with qavanin.ir or the Iranian government.
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "qavanin-mcp"
3
+ version = "0.1.0"
4
+ description = "Unofficial read-only MCP server for qavanin.ir: search Iranian laws and regulations, read their full text, amendments and status."
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", "qavanin", "iran", "law", "legal", "regulations"]
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/qavanin-mcp"
31
+ Issues = "https://github.com/sepehr071/qavanin-mcp/issues"
32
+
33
+ [project.scripts]
34
+ qavanin-mcp = "qavanin_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 qavanin.ir site (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/qavanin-mcp",
4
+ "title": "Qavanin",
5
+ "description": "Unofficial read-only qavanin.ir: search Iranian laws and regulations, read full text and status.",
6
+ "repository": {
7
+ "url": "https://github.com/sepehr071/qavanin-mcp",
8
+ "source": "github"
9
+ },
10
+ "websiteUrl": "https://github.com/sepehr071/qavanin-mcp",
11
+ "version": "0.1.0",
12
+ "packages": [
13
+ {
14
+ "registryType": "pypi",
15
+ "registryBaseUrl": "https://pypi.org",
16
+ "identifier": "qavanin-mcp",
17
+ "version": "0.1.0",
18
+ "runtimeHint": "uvx",
19
+ "transport": {
20
+ "type": "stdio"
21
+ },
22
+ "environmentVariables": [
23
+ {
24
+ "name": "QAVANIN_MCP_PROXY",
25
+ "description": "Optional HTTP proxy URL, or a comma-separated list tried in turn, for qavanin.ir; system proxies are ignored.",
26
+ "isRequired": false,
27
+ "isSecret": false
28
+ }
29
+ ]
30
+ }
31
+ ]
32
+ }
@@ -0,0 +1,5 @@
1
+ """Unofficial MCP server for qavanin.ir (Iran's national laws and regulations portal)."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("qavanin-mcp")
@@ -0,0 +1,212 @@
1
+ """Shared async HTTP client for qavanin.ir.
2
+
3
+ Every tool goes through `get_page` (server-rendered ASP.NET MVC HTML) or `post_json`. `_send` caps concurrency,
4
+ solves the site's ArvanCloud JS cookie challenge, retries timeouts, dropped connections, 429 and 5xx (never 403)
5
+ with jittered backoff, and turns HTTP failures into `ToolError` messages the model can act on. The wire format is a
6
+ Chrome impersonation (curl_cffi).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import os
13
+ import random
14
+ import re
15
+ import time
16
+ from collections.abc import Awaitable, Callable
17
+ from typing import Any
18
+
19
+ import httpx
20
+ from httpx_curl_cffi import AsyncCurlTransport
21
+ from mcp.server.mcpserver.exceptions import ToolError
22
+
23
+ BASE = "https://qavanin.ir"
24
+
25
+ # User-Agent, sec-ch-ua and TLS come from the impersonated Chrome profile (see _get_client).
26
+ HEADERS = {
27
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
28
+ "Accept-Language": "fa-IR,fa;q=0.9,en;q=0.8",
29
+ }
30
+
31
+ MAX_CONCURRENCY = 2
32
+ JITTER = (0.15, 0.6) # seconds before each upstream call
33
+ MAX_RETRIES = 2
34
+ MAX_RETRY_WAIT = 10 # cap for Retry-After and backoff, seconds
35
+ RETRY_DELAY = 0.5
36
+
37
+ _transport: httpx.AsyncBaseTransport | None = None
38
+ _client: httpx.AsyncClient | None = None
39
+ _limit: asyncio.Semaphore | None = None
40
+ _proxy_index = 0
41
+ _cache: dict[Any, tuple[float, Any]] = {}
42
+
43
+
44
+ class ApiError(ToolError):
45
+ """A failed upstream call. `status` is the HTTP status, or None for network errors."""
46
+
47
+ def __init__(self, message: str, status: int | None = None, retry_after: float | None = None):
48
+ super().__init__(message)
49
+ self.status = status
50
+ self.retry_after = retry_after
51
+
52
+
53
+ def set_transport(transport: httpx.AsyncBaseTransport | None) -> None:
54
+ """Swap the transport (tests use httpx.MockTransport). Drops the current client."""
55
+ global _transport, _client, _limit, _proxy_index
56
+ _transport, _client, _limit, _proxy_index = transport, None, None, 0
57
+ _cache.clear()
58
+
59
+
60
+ async def cached(key: Any, ttl: float, make: Callable[[], Awaitable[Any]]) -> Any:
61
+ """Return make()'s result, reused for `ttl` seconds per key. Only for results nobody mutates."""
62
+ hit = _cache.get(key)
63
+ if hit and time.monotonic() - hit[0] < ttl:
64
+ return hit[1]
65
+ value = await make()
66
+ if len(_cache) >= 32: # parsed laws can be large: keep only the newest entries
67
+ _cache.pop(next(iter(_cache)))
68
+ _cache[key] = (time.monotonic(), value)
69
+ return value
70
+
71
+
72
+ def _proxies() -> list[str]:
73
+ """QAVANIN_MCP_PROXY: one proxy URL, or a comma-separated list used one at a time."""
74
+ return [p.strip() for p in os.environ.get("QAVANIN_MCP_PROXY", "").split(",") if p.strip()]
75
+
76
+
77
+ def _get_client() -> tuple[httpx.AsyncClient, asyncio.Semaphore]:
78
+ global _client, _limit
79
+ if _client is None:
80
+ # Direct connections work; a system proxy is only used when the user opts in with QAVANIN_MCP_PROXY.
81
+ proxies = _proxies()
82
+ _client = httpx.AsyncClient(
83
+ transport=_transport
84
+ or AsyncCurlTransport(
85
+ impersonate="chrome",
86
+ default_headers=True,
87
+ proxy=proxies[_proxy_index % len(proxies)] if proxies else None,
88
+ max_connections=2 * MAX_CONCURRENCY,
89
+ ),
90
+ headers=HEADERS,
91
+ timeout=60, # the origin is slow: 1-18 s typical, the civil code about 14 s, rare 40 s hangs
92
+ limits=httpx.Limits(max_connections=2 * MAX_CONCURRENCY),
93
+ follow_redirects=True,
94
+ trust_env=False,
95
+ )
96
+ _limit = asyncio.Semaphore(MAX_CONCURRENCY)
97
+ assert _limit is not None
98
+ return _client, _limit
99
+
100
+
101
+ # --- ArvanCloud JS challenge -------------------------------------------------------------------------------
102
+ # A first visit (or an expired cookie, Max-Age 100 s) gets a 200 page whose script computes two cookies from two
103
+ # JSFuck constants (the page's E(E(x)) is the identity) and reloads. The constants are built only from the atoms
104
+ # below, so they are evaluated here instead of running the page's JavaScript.
105
+ _CHALLENGE = re.compile(r"'value_v1': eval\(\"(.*?)\"\), 'value': eval\(\"(.*?)\"\)", re.S)
106
+ _NUM = r"(?:\+!\+\[\]|\+\[\]|!\+\[\](?:\+!\+\[\])*)" # 1, 0, or n x "!+[]" (n)
107
+ _WORDS = {"(![]+[])": "false", "(!![]+[])": "true", "([][[]]+[])": "undefined"}
108
+
109
+
110
+ def _num(expr: str) -> int:
111
+ return 0 if expr == "+[]" else expr.count("!+[]")
112
+
113
+
114
+ def _fuck(expr: str) -> str:
115
+ """Evaluate a JSFuck string built from digits, false/true/undefined letters, fill/entries and fromCharCode."""
116
+ expr = re.sub(
117
+ rf"(\(!?!?\[\]\+\[\]\)|\(\[\]\[\[\]\]\+\[\]\))\[({_NUM})\]",
118
+ lambda m: repr(_WORDS[m[1]][_num(m[2])]),
119
+ expr,
120
+ )
121
+ expr = expr.replace("([]['fill']+'')[3]", "'c'").replace("([]['entries']()+'')[2]", "'b'")
122
+ expr = re.sub(r"\(\[\]\+\[\]\)\['constructor'\]\['fromCharCode'\]\((\d+)\)", lambda m: repr(chr(int(m[1]))), expr)
123
+ expr = re.sub(rf"\[({_NUM})\]", lambda m: repr(str(_num(m[1]))), expr)
124
+ if left := re.sub(r"'[^']*'|\[\]|\+", "", expr):
125
+ raise ValueError(left[:60])
126
+ return "".join(re.findall(r"'([^']*)'", expr))
127
+
128
+
129
+ def solve_challenge(html: str) -> dict[str, str] | None:
130
+ """The cookies a challenge page sets, or None when `html` is not (or not a solvable) challenge page."""
131
+ m = _CHALLENGE.search(html) if "__arcsjs" in html else None
132
+ if not m:
133
+ return None
134
+ try:
135
+ return {"__arcsjs": _fuck(m[1]), "__arcsjsc": _fuck(m[2])}
136
+ except ValueError:
137
+ return None
138
+
139
+
140
+ async def get_page(path: str, params: dict[str, Any] | None = None) -> tuple[str, str]:
141
+ """GET a site page; returns (final URL after redirects, HTML)."""
142
+ r = await _send("GET", f"{BASE}{path}", params=params)
143
+ return str(r.url), r.content.decode("utf-8", "replace")
144
+
145
+
146
+ async def get_json(path: str, params: dict[str, Any] | None = None) -> Any:
147
+ """GET a JSON lookup endpoint (/Law/getOrgs/, /Law/getTopics/)."""
148
+ r = await _send("GET", f"{BASE}{path}", params=params, headers={"Accept": "application/json, text/plain, */*"})
149
+ try:
150
+ return r.json()
151
+ except ValueError as e:
152
+ raise ApiError(f"qavanin.ir returned a non-JSON response for {path} (HTTP {r.status_code}).") from e
153
+
154
+
155
+ async def _send(method: str, url: str, **kwargs: Any) -> httpx.Response:
156
+ host = httpx.URL(BASE).host
157
+ retries = solved = 0
158
+ while True:
159
+ client, limit = _get_client()
160
+ wait = None
161
+ try:
162
+ async with limit:
163
+ await asyncio.sleep(random.uniform(*JITTER))
164
+ r = await client.request(method, url, **kwargs)
165
+ except httpx.TimeoutException as e:
166
+ err = ApiError(f"{host} did not answer in time. Try again in a moment.")
167
+ err.__cause__ = e
168
+ except httpx.TransportError as e:
169
+ err = ApiError(
170
+ f"Could not reach {host} ({type(e).__name__}). Check the internet connection, or set QAVANIN_MCP_PROXY."
171
+ )
172
+ err.__cause__ = e
173
+ else:
174
+ if r.status_code == 429 or r.status_code >= 500:
175
+ err = ApiError(_status_message(r), r.status_code)
176
+ wait = _retry_after(r)
177
+ else:
178
+ cookies = solve_challenge(r.text) if r.status_code == 200 and len(r.content) < 20_000 else None
179
+ if cookies and solved < 2:
180
+ solved += 1
181
+ for name, value in cookies.items():
182
+ client.cookies.set(name, value, domain=host, path="/")
183
+ continue
184
+ if cookies:
185
+ raise ApiError(f"{host} kept answering with its bot challenge. Try again in a moment.", 403)
186
+ if r.status_code >= 400:
187
+ raise ApiError(_status_message(r), r.status_code)
188
+ return r
189
+ if retries == MAX_RETRIES:
190
+ raise err
191
+ wait = wait if wait is not None else RETRY_DELAY * 2**retries * random.uniform(1, 1.5)
192
+ retries += 1
193
+ await asyncio.sleep(min(wait, MAX_RETRY_WAIT))
194
+
195
+
196
+ def _retry_after(r: httpx.Response) -> float | None:
197
+ """Seconds from a numeric Retry-After on a 429/503, else None (HTTP-date form is ignored)."""
198
+ v = r.headers.get("retry-after", "")
199
+ return float(v) if r.status_code in (429, 503) and v.replace(".", "", 1).isdigit() else None
200
+
201
+
202
+ def _status_message(r: httpx.Response) -> str:
203
+ code = r.status_code
204
+ if code == 403:
205
+ return "qavanin.ir blocked the request (HTTP 403). Turn off VPN/proxy or set QAVANIN_MCP_PROXY."
206
+ if code == 404:
207
+ return "Not found on qavanin.ir (HTTP 404). Check the law id."
208
+ if code == 429:
209
+ return "qavanin.ir is rate limiting requests (HTTP 429). Wait a minute before retrying."
210
+ if code >= 500:
211
+ return f"qavanin.ir had a server error (HTTP {code}). Try again later."
212
+ return f"qavanin.ir rejected the request (HTTP {code})."