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.
- qavanin_mcp-0.1.0/.github/workflows/ci.yml +22 -0
- qavanin_mcp-0.1.0/.github/workflows/publish.yml +51 -0
- qavanin_mcp-0.1.0/.gitignore +6 -0
- qavanin_mcp-0.1.0/LICENSE +21 -0
- qavanin_mcp-0.1.0/PKG-INFO +87 -0
- qavanin_mcp-0.1.0/README.md +61 -0
- qavanin_mcp-0.1.0/pyproject.toml +54 -0
- qavanin_mcp-0.1.0/server.json +32 -0
- qavanin_mcp-0.1.0/src/qavanin_mcp/__init__.py +5 -0
- qavanin_mcp-0.1.0/src/qavanin_mcp/http.py +212 -0
- qavanin_mcp-0.1.0/src/qavanin_mcp/law.py +293 -0
- qavanin_mcp-0.1.0/src/qavanin_mcp/registry.py +16 -0
- qavanin_mcp-0.1.0/src/qavanin_mcp/search.py +288 -0
- qavanin_mcp-0.1.0/src/qavanin_mcp/server.py +73 -0
- qavanin_mcp-0.1.0/src/qavanin_mcp/text.py +28 -0
- qavanin_mcp-0.1.0/tests/conftest.py +79 -0
- qavanin_mcp-0.1.0/tests/fixtures/arvan_challenge.html +49 -0
- qavanin_mcp-0.1.0/tests/fixtures/law_attribute_civil.html +281 -0
- qavanin_mcp-0.1.0/tests/fixtures/law_attribute_regulation.html +281 -0
- qavanin_mcp-0.1.0/tests/fixtures/law_relatedindex_excerpt.html +193 -0
- qavanin_mcp-0.1.0/tests/fixtures/law_sectionstatus.html +36 -0
- qavanin_mcp-0.1.0/tests/fixtures/law_statusindex.html +664 -0
- qavanin_mcp-0.1.0/tests/fixtures/law_treetext_circular.html +366 -0
- qavanin_mcp-0.1.0/tests/fixtures/law_treetext_civil_excerpt.html +351 -0
- qavanin_mcp-0.1.0/tests/fixtures/law_treetext_vote.html +362 -0
- qavanin_mcp-0.1.0/tests/fixtures/search_empty.html +53 -0
- qavanin_mcp-0.1.0/tests/fixtures/search_results.html +120 -0
- qavanin_mcp-0.1.0/tests/test_http.py +76 -0
- qavanin_mcp-0.1.0/tests/test_law.py +86 -0
- qavanin_mcp-0.1.0/tests/test_live.py +53 -0
- qavanin_mcp-0.1.0/tests/test_search.py +124 -0
- 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,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,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})."
|