beget-mcp 0.2.0__py3-none-any.whl
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.
- beget_mcp-0.2.0.dist-info/METADATA +133 -0
- beget_mcp-0.2.0.dist-info/RECORD +25 -0
- beget_mcp-0.2.0.dist-info/WHEEL +4 -0
- beget_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- beget_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
- mcp_beget/__init__.py +1 -0
- mcp_beget/__main__.py +3 -0
- mcp_beget/app.py +10 -0
- mcp_beget/client.py +111 -0
- mcp_beget/config.py +31 -0
- mcp_beget/dnsprobe.py +122 -0
- mcp_beget/errors.py +22 -0
- mcp_beget/py.typed +0 -0
- mcp_beget/server.py +52 -0
- mcp_beget/tools/__init__.py +11 -0
- mcp_beget/tools/account.py +56 -0
- mcp_beget/tools/annotations.py +19 -0
- mcp_beget/tools/backup.py +119 -0
- mcp_beget/tools/cron.py +128 -0
- mcp_beget/tools/dns.py +542 -0
- mcp_beget/tools/domains.py +267 -0
- mcp_beget/tools/ftp.py +62 -0
- mcp_beget/tools/mail.py +286 -0
- mcp_beget/tools/mysql.py +104 -0
- mcp_beget/tools/sites.py +94 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: beget-mcp
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Beget hosting MCP server — manage sites, domains, MySQL, FTP, DNS, cron, backups via AI agents
|
|
5
|
+
Project-URL: Homepage, https://badigit.github.io/beget-mcp/
|
|
6
|
+
Project-URL: Repository, https://github.com/badigit/beget-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/badigit/beget-mcp/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: beget,claude,dns,hosting,mcp,model-context-protocol
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
|
|
15
|
+
Classifier: Topic :: System :: Systems Administration
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: dnspython>=2.6
|
|
18
|
+
Requires-Dist: mcp[cli]<2.0.0,>=1.27
|
|
19
|
+
Requires-Dist: python-dotenv
|
|
20
|
+
Requires-Dist: requests
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest-mock>=3.12; extra == 'test'
|
|
23
|
+
Requires-Dist: pytest-timeout>=2.3; extra == 'test'
|
|
24
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
25
|
+
Requires-Dist: responses>=0.25; extra == 'test'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# beget-mcp
|
|
29
|
+
|
|
30
|
+
MCP-сервер для управления сервисами хостера [Beget](https://beget.com/p1211871) из AI-агентов.
|
|
31
|
+
|
|
32
|
+
Построен на [FastMCP](https://github.com/modelcontextprotocol/python-sdk) и покрывает весь [Beget REST API](https://beget.com/p1211871/kb/api/) — 73 инструмента в 10 разделах.
|
|
33
|
+
|
|
34
|
+
> [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) — открытый стандарт для подключения AI-моделей к внешним сервисам.
|
|
35
|
+
|
|
36
|
+
<!-- mcp-name: io.github.badigit/beget-mcp -->
|
|
37
|
+
|
|
38
|
+
## Быстрый старт
|
|
39
|
+
|
|
40
|
+
Из PyPI, без установки в систему:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
claude mcp add beget -s user \
|
|
44
|
+
-e BEGET_API_LOGIN=your_login \
|
|
45
|
+
-e BEGET_API_PASSWORD=your_password \
|
|
46
|
+
-- uvx beget-mcp
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Контейнером (образ по умолчанию поднимает SSE — локальному клиенту нужен stdio):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
docker run -i --rm \
|
|
53
|
+
-e MCP_TRANSPORT=stdio \
|
|
54
|
+
-e BEGET_API_LOGIN=your_login \
|
|
55
|
+
-e BEGET_API_PASSWORD=your_password \
|
|
56
|
+
ghcr.io/badigit/beget-mcp:latest
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Из исходников:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install -e .
|
|
63
|
+
|
|
64
|
+
claude mcp add beget -s user \
|
|
65
|
+
-e BEGET_API_LOGIN=your_login \
|
|
66
|
+
-e BEGET_API_PASSWORD=your_password \
|
|
67
|
+
-- python -m mcp_beget
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Сервер публикуется в [MCP Registry](https://registry.modelcontextprotocol.io) под именем
|
|
71
|
+
`io.github.badigit/beget-mcp`. Страница проекта — <https://badigit.github.io/beget-mcp/>.
|
|
72
|
+
|
|
73
|
+
## Что умеет
|
|
74
|
+
|
|
75
|
+
| Раздел | Инструменты | Примеры операций |
|
|
76
|
+
|--------|------------|-----------------|
|
|
77
|
+
| Сайты | 8 | создание, удаление, привязка доменов, заморозка файлов |
|
|
78
|
+
| Домены | 13 | управление, поддомены, PHP-версии, директивы, проверка доступности |
|
|
79
|
+
| DNS | 8 | A, CNAME, MX, TXT — безопасный merge; частичное обновление через `dns_patch_record`; `dns_verify` сверяет факт у авторитативного NS |
|
|
80
|
+
| MySQL | 6 | базы, доступы с хостов, пароли |
|
|
81
|
+
| FTP | 4 | аккаунты, домашние каталоги |
|
|
82
|
+
| Cron | 7 | задачи, расписание, активация, email-отчёты |
|
|
83
|
+
| Бэкапы | 9 | просмотр содержимого, откат, выгрузка файлов и БД |
|
|
84
|
+
| Почта | 12 | ящики, пароли, спам-фильтр, пересылка, catch-all, one-shot-настройка Яндекс 360 / Mail.ru |
|
|
85
|
+
| Статистика | 4 | нагрузка по сайтам и БД — сводная и детальная |
|
|
86
|
+
| Аккаунт | 2 | тариф, баланс, SSH-доступ |
|
|
87
|
+
|
|
88
|
+
## Документация
|
|
89
|
+
|
|
90
|
+
| Файл | О чём |
|
|
91
|
+
|------|-------|
|
|
92
|
+
| [docs/api-coverage.md](docs/api-coverage.md) | покрытие методов Beget API |
|
|
93
|
+
| [docs/beget-api-gotchas.md](docs/beget-api-gotchas.md) | грабли API: ошибка в success-конверте и прочее |
|
|
94
|
+
| [docs/dns-facts-vs-intent.md](docs/dns-facts-vs-intent.md) | DNS: несозданный поддомен, catch-all зоны, задержка распространения |
|
|
95
|
+
| [docs/new-app-deploy-chain.md](docs/new-app-deploy-chain.md) | цепочка вызовов для нового приложения на shared-хостинге |
|
|
96
|
+
|
|
97
|
+
## Переменные окружения
|
|
98
|
+
|
|
99
|
+
| Переменная | Назначение |
|
|
100
|
+
|-----------|-----------|
|
|
101
|
+
| `BEGET_API_LOGIN` | Логин Beget |
|
|
102
|
+
| `BEGET_API_PASSWORD` | Пароль Beget |
|
|
103
|
+
|
|
104
|
+
## Архитектура
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
src/mcp_beget/
|
|
108
|
+
├── app.py — экземпляр FastMCP
|
|
109
|
+
├── config.py — конфигурация (dataclass + валидация)
|
|
110
|
+
├── client.py — HTTP-клиент с переиспользованием сессии
|
|
111
|
+
├── errors.py — типизированные исключения (Auth / API)
|
|
112
|
+
├── server.py — точка входа, инициализация, логирование
|
|
113
|
+
└── tools/ — инструменты, сгруппированные по разделам API
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Требования
|
|
117
|
+
|
|
118
|
+
- Python >= 3.11
|
|
119
|
+
- mcp[cli]
|
|
120
|
+
- requests
|
|
121
|
+
- python-dotenv
|
|
122
|
+
|
|
123
|
+
## Contributing
|
|
124
|
+
|
|
125
|
+
Issues и pull requests приветствуются. Если нашли баг или хотите предложить улучшение — создайте issue.
|
|
126
|
+
|
|
127
|
+
## Лицензия
|
|
128
|
+
|
|
129
|
+
[MIT](LICENSE)
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
Нет аккаунта на Beget? [Зарегистрируйтесь по ссылке](https://beget.com/p1211871) — вы получите хостинг, а автор проекта небольшой бонус.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
mcp_beget/__init__.py,sha256=U2XUubedaDo1W9M-4i0Ku3Cu_OS0YcJqXitFaNK8SV4,32
|
|
2
|
+
mcp_beget/__main__.py,sha256=7Xk07Z5FqO8nk63pllqVL23Rx3ltoLVB3dQOmt5diKE,42
|
|
3
|
+
mcp_beget/app.py,sha256=9QYqp0tZVx9_jG-QQatkjdevCWy5bD6xo00PInVcyqw,286
|
|
4
|
+
mcp_beget/client.py,sha256=4XaxbM1V7V5cJkHCdijc0kfSZdawf6nUenb3I1uia-I,4000
|
|
5
|
+
mcp_beget/config.py,sha256=WEeNlC0aemPVgX1Bl6A8NPBvyIdCGoMSSDB2JY1xICs,763
|
|
6
|
+
mcp_beget/dnsprobe.py,sha256=skDcb8XRCD7sQ2hFeOYkrHQW2ChEF6PIMTy4_QqhXLA,5237
|
|
7
|
+
mcp_beget/errors.py,sha256=OF9SYa1HWR7VG0thmp9XSHr8uSbSLOw7KvjfCKrd7Tw,869
|
|
8
|
+
mcp_beget/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
mcp_beget/server.py,sha256=F-5GA-KEuAtSoWhPIbCXkRZOFeMNono8f13ebsouOrc,1435
|
|
10
|
+
mcp_beget/tools/__init__.py,sha256=rkG6I3MP-WPyvvVPUCLQY9ztt-jpmbxJUY0r70P3g6M,317
|
|
11
|
+
mcp_beget/tools/account.py,sha256=k5Gr-FpU7LQp069-sanc-JeMTS9VLbOD_pSTcUDS6oQ,2056
|
|
12
|
+
mcp_beget/tools/annotations.py,sha256=UUS05FEiDFJDnmfACCyyFk5nqnunoTz6xkKVpT2Ax7U,361
|
|
13
|
+
mcp_beget/tools/backup.py,sha256=ypnuyLTlbZNioXv3aSRMfuwv7SA0pGdPoDzyuosDe24,4116
|
|
14
|
+
mcp_beget/tools/cron.py,sha256=-5VMZc5V6x0wcwEJI8oIGw09m0251j5XiekZq79a_-k,3823
|
|
15
|
+
mcp_beget/tools/dns.py,sha256=jNDbSYNlCnziXWJztlyLnjpsRyjLCokj8nDHoy8GFU0,24702
|
|
16
|
+
mcp_beget/tools/domains.py,sha256=sXU5C5acYAAfAI6WEW6zgIRD_30tS6UtwJKQ8Maudto,9817
|
|
17
|
+
mcp_beget/tools/ftp.py,sha256=LNlN0H-q5VAIoDweFNoga85A3Vp2kFVNGfwgfOq-BTI,1641
|
|
18
|
+
mcp_beget/tools/mail.py,sha256=dCkPHMjanGXQuyV49lDio00nRsqJsvHoAfXUzik1HKQ,8943
|
|
19
|
+
mcp_beget/tools/mysql.py,sha256=oaUsdi_T5WQd5AVMRpZAj3puPxY7HpyW6AToSKKFTRM,2924
|
|
20
|
+
mcp_beget/tools/sites.py,sha256=UXSFVizaxyGFxbZpG-nxkovkjY3_tDjnoUSzh52IRv4,2896
|
|
21
|
+
beget_mcp-0.2.0.dist-info/METADATA,sha256=fuW4B1x1rUo6JOIIPXKoPM5ggK_-Y-cE-pQOMgXchuA,5944
|
|
22
|
+
beget_mcp-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
23
|
+
beget_mcp-0.2.0.dist-info/entry_points.txt,sha256=pxdroKGdeiFQ3ZjrdZd4z79aevS16hGsDqbTSPwjrJA,52
|
|
24
|
+
beget_mcp-0.2.0.dist-info/licenses/LICENSE,sha256=ypGhcEZ4LiShahUXjxsR890b-iYikO0uEfkPZloEm70,1060
|
|
25
|
+
beget_mcp-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dee
|
|
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.
|
mcp_beget/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Beget hosting MCP server."""
|
mcp_beget/__main__.py
ADDED
mcp_beget/app.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from mcp.server.fastmcp import FastMCP
|
|
2
|
+
|
|
3
|
+
mcp = FastMCP(
|
|
4
|
+
"beget-api",
|
|
5
|
+
instructions=(
|
|
6
|
+
"Beget hosting management server. "
|
|
7
|
+
"Manage sites, domains, MySQL databases, FTP accounts, "
|
|
8
|
+
"DNS records, cron jobs, backups, and mail on Beget shared hosting."
|
|
9
|
+
),
|
|
10
|
+
)
|
mcp_beget/client.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Beget REST API client with session reuse and structured errors."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .config import BegetConfig
|
|
9
|
+
from .errors import BegetAPIError, BegetAuthError
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_client: "BegetClient | None" = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def init_client(config: BegetConfig) -> "BegetClient":
|
|
17
|
+
global _client
|
|
18
|
+
_client = BegetClient(config)
|
|
19
|
+
return _client
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_client() -> "BegetClient":
|
|
23
|
+
if _client is None:
|
|
24
|
+
raise RuntimeError("BegetClient not initialised — call init_client() first")
|
|
25
|
+
return _client
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BegetClient:
|
|
29
|
+
"""Thin wrapper around Beget shared-hosting REST API.
|
|
30
|
+
|
|
31
|
+
All endpoints follow ``POST /api/{section}/{method}`` with:
|
|
32
|
+
- query string: ``output_format=json``
|
|
33
|
+
- form body: ``login``, ``passwd``, and when ``params`` is given
|
|
34
|
+
``input_format=json`` + ``input_data=<json>``.
|
|
35
|
+
|
|
36
|
+
Credentials go in the POST body (not query string) so they don't leak into
|
|
37
|
+
access logs / proxy logs. Log lines here redact ``passwd``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
API_BASE = "https://api.beget.com/api"
|
|
41
|
+
|
|
42
|
+
def __init__(self, config: BegetConfig) -> None:
|
|
43
|
+
self._cfg = config
|
|
44
|
+
self._http = requests.Session()
|
|
45
|
+
|
|
46
|
+
def call(self, section: str, method: str, params: dict | None = None) -> dict:
|
|
47
|
+
"""Execute an API method and return the parsed answer.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
BegetAuthError: credentials rejected.
|
|
51
|
+
BegetAPIError: any other API-level failure.
|
|
52
|
+
requests.HTTPError: transport-level failure.
|
|
53
|
+
"""
|
|
54
|
+
endpoint = f"{self.API_BASE}/{section}/{method}"
|
|
55
|
+
|
|
56
|
+
data: dict = {
|
|
57
|
+
"login": self._cfg.login,
|
|
58
|
+
"passwd": self._cfg.password,
|
|
59
|
+
}
|
|
60
|
+
if params is not None:
|
|
61
|
+
data["input_format"] = "json"
|
|
62
|
+
data["input_data"] = json.dumps(params)
|
|
63
|
+
|
|
64
|
+
log.debug("%s/%s params=%s", section, method, params)
|
|
65
|
+
|
|
66
|
+
resp = self._http.post(
|
|
67
|
+
endpoint,
|
|
68
|
+
params={"output_format": "json"},
|
|
69
|
+
data=data,
|
|
70
|
+
timeout=self._cfg.timeout,
|
|
71
|
+
)
|
|
72
|
+
resp.raise_for_status()
|
|
73
|
+
|
|
74
|
+
body: dict = resp.json()
|
|
75
|
+
|
|
76
|
+
if body.get("status") == "success":
|
|
77
|
+
answer = body.get("answer", {})
|
|
78
|
+
# Beget отдаёт HTTP-200 и status:success на конверте, а собственную
|
|
79
|
+
# ошибку метода кладёт внутрь answer (METHOD_FAILED, INVALID_DATA).
|
|
80
|
+
# Без этой ветки error-объект уходит наверх как валидный ответ:
|
|
81
|
+
# вызывающий код видит "успех", а except BegetAPIError не срабатывает.
|
|
82
|
+
if isinstance(answer, dict) and answer.get("status") == "error":
|
|
83
|
+
errs = answer.get("errors") or []
|
|
84
|
+
first = errs[0] if errs and isinstance(errs[0], dict) else {}
|
|
85
|
+
err_text = first.get("error_text") or "Method failed"
|
|
86
|
+
err_code = first.get("error_code", "")
|
|
87
|
+
if err_code == "AUTH_ERROR":
|
|
88
|
+
raise BegetAuthError(err_text, code=err_code, details=body)
|
|
89
|
+
raise BegetAPIError(err_text, code=err_code, details=body)
|
|
90
|
+
return answer
|
|
91
|
+
|
|
92
|
+
err_msg = body.get("error_text", "")
|
|
93
|
+
err_code = body.get("error_code", "")
|
|
94
|
+
|
|
95
|
+
# nested errors (some endpoints wrap errors in answer.errors[])
|
|
96
|
+
if not err_msg and "answer" in body:
|
|
97
|
+
answer = body["answer"]
|
|
98
|
+
if isinstance(answer, dict) and "errors" in answer:
|
|
99
|
+
errs = answer["errors"]
|
|
100
|
+
if errs:
|
|
101
|
+
err_msg = errs[0].get("error_text", "Unknown error")
|
|
102
|
+
err_code = errs[0].get("error_code", err_code)
|
|
103
|
+
|
|
104
|
+
if err_code == "AUTH_ERROR":
|
|
105
|
+
raise BegetAuthError(err_msg or "Bad credentials", code=err_code)
|
|
106
|
+
|
|
107
|
+
raise BegetAPIError(
|
|
108
|
+
err_msg or "Unknown error",
|
|
109
|
+
code=err_code,
|
|
110
|
+
details=body,
|
|
111
|
+
)
|
mcp_beget/config.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
|
|
7
|
+
log = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class BegetConfig:
|
|
12
|
+
login: str
|
|
13
|
+
password: str
|
|
14
|
+
timeout: int = 30
|
|
15
|
+
|
|
16
|
+
def __post_init__(self) -> None:
|
|
17
|
+
if not self.login or not self.password:
|
|
18
|
+
raise ValueError(
|
|
19
|
+
"BEGET_API_LOGIN and BEGET_API_PASSWORD must be set. "
|
|
20
|
+
"Pass them as environment variables or in a .env file."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_config() -> BegetConfig:
|
|
25
|
+
load_dotenv()
|
|
26
|
+
config = BegetConfig(
|
|
27
|
+
login=os.getenv("BEGET_API_LOGIN", ""),
|
|
28
|
+
password=os.getenv("BEGET_API_PASSWORD", ""),
|
|
29
|
+
)
|
|
30
|
+
log.info("Config loaded for user '%s'", config.login)
|
|
31
|
+
return config
|
mcp_beget/dnsprobe.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Опрос авторитативного NS зоны — факт вместо намерения.
|
|
2
|
+
|
|
3
|
+
Beget API отвечает `success` на запись, а `dns/getData` подтверждает её, потому
|
|
4
|
+
что читает тот же источник, куда писал. Мир в этот момент может ещё получать
|
|
5
|
+
старое значение: авторитативный NS обновляется с задержкой в несколько минут.
|
|
6
|
+
Отдельно у зоны бывает catch-all на shared-хостинг, которого в `dns/getData` не
|
|
7
|
+
видно вовсе — имя резолвится, хотя записи в зоне нет.
|
|
8
|
+
|
|
9
|
+
Модуль ходит к авторитативным NS напрямую по UDP, минуя рекурсивный резолвер и
|
|
10
|
+
его кеш. dnspython — обязательная зависимость, но её отсутствие не должно ронять
|
|
11
|
+
DNS-тулы целиком: `probe()` в этом случае возвращает поле ``error``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
DEFAULT_TIMEOUT = 5.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProbeUnavailable(Exception):
|
|
18
|
+
"""Факт получить не удалось — сеть, отсутствие NS или нет dnspython."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _dnspython():
|
|
22
|
+
try:
|
|
23
|
+
import dns.message # noqa: F401
|
|
24
|
+
import dns.query # noqa: F401
|
|
25
|
+
import dns.rcode # noqa: F401
|
|
26
|
+
import dns.rdatatype # noqa: F401
|
|
27
|
+
import dns.resolver # noqa: F401
|
|
28
|
+
except ImportError as e: # pragma: no cover - зависит от окружения
|
|
29
|
+
raise ProbeUnavailable(
|
|
30
|
+
"dnspython is not installed — authoritative DNS probing disabled"
|
|
31
|
+
) from e
|
|
32
|
+
import dns
|
|
33
|
+
|
|
34
|
+
return dns
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def authoritative_nameservers(
|
|
38
|
+
fqdn: str, timeout: float = DEFAULT_TIMEOUT
|
|
39
|
+
) -> tuple[str, list[str]]:
|
|
40
|
+
"""(зона, её NS) — идём вверх по меткам до первой зоны с NS-записями.
|
|
41
|
+
|
|
42
|
+
Для ``sub.site.ru`` NS обычно делегированы на уровне ``site.ru``, поэтому
|
|
43
|
+
запрос по самому поддомену ничего не даёт и подниматься обязательно.
|
|
44
|
+
"""
|
|
45
|
+
dns = _dnspython()
|
|
46
|
+
resolver = dns.resolver.Resolver()
|
|
47
|
+
resolver.timeout = timeout
|
|
48
|
+
resolver.lifetime = timeout
|
|
49
|
+
|
|
50
|
+
labels = fqdn.split(".")
|
|
51
|
+
for i in range(len(labels) - 1):
|
|
52
|
+
zone = ".".join(labels[i:])
|
|
53
|
+
try:
|
|
54
|
+
answer = resolver.resolve(zone, "NS")
|
|
55
|
+
except Exception:
|
|
56
|
+
continue
|
|
57
|
+
servers = sorted({str(r.target).rstrip(".") for r in answer})
|
|
58
|
+
if servers:
|
|
59
|
+
return zone, servers
|
|
60
|
+
raise ProbeUnavailable(f"no NS records found for {fqdn} or any parent zone")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _nameserver_addresses(dns, resolver, name: str) -> list[str]:
|
|
64
|
+
addresses: list[str] = []
|
|
65
|
+
for rdtype in ("A", "AAAA"):
|
|
66
|
+
try:
|
|
67
|
+
answer = resolver.resolve(name, rdtype)
|
|
68
|
+
except Exception:
|
|
69
|
+
continue
|
|
70
|
+
addresses.extend(str(r) for r in answer)
|
|
71
|
+
return addresses
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def query_authoritative(
|
|
75
|
+
fqdn: str, rtype: str = "A", timeout: float = DEFAULT_TIMEOUT
|
|
76
|
+
) -> dict:
|
|
77
|
+
"""Спросить авторитативный NS зоны напрямую. Кеш резолвера не участвует.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ProbeUnavailable: ни один авторитативный NS не ответил.
|
|
81
|
+
"""
|
|
82
|
+
dns = _dnspython()
|
|
83
|
+
zone, nameservers = authoritative_nameservers(fqdn, timeout)
|
|
84
|
+
|
|
85
|
+
resolver = dns.resolver.Resolver()
|
|
86
|
+
resolver.timeout = timeout
|
|
87
|
+
resolver.lifetime = timeout
|
|
88
|
+
|
|
89
|
+
errors: list[str] = []
|
|
90
|
+
for ns in nameservers:
|
|
91
|
+
for address in _nameserver_addresses(dns, resolver, ns):
|
|
92
|
+
try:
|
|
93
|
+
query = dns.message.make_query(fqdn, dns.rdatatype.from_text(rtype))
|
|
94
|
+
response = dns.query.udp(query, address, timeout=timeout)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
errors.append(f"{ns} ({address}): {type(e).__name__}: {e}")
|
|
97
|
+
continue
|
|
98
|
+
values: list[str] = []
|
|
99
|
+
for rrset in response.answer:
|
|
100
|
+
values.extend(str(item).rstrip(".") for item in rrset)
|
|
101
|
+
return {
|
|
102
|
+
"zone": zone,
|
|
103
|
+
"nameserver": ns,
|
|
104
|
+
"nameserver_ip": address,
|
|
105
|
+
"type": rtype.upper(),
|
|
106
|
+
"values": sorted(values),
|
|
107
|
+
"rcode": dns.rcode.to_text(response.rcode()),
|
|
108
|
+
}
|
|
109
|
+
raise ProbeUnavailable(
|
|
110
|
+
f"no authoritative nameserver of {zone} answered for {fqdn}: "
|
|
111
|
+
+ ("; ".join(errors) if errors else "no reachable NS addresses")
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def probe(fqdn: str, rtype: str = "A", timeout: float = DEFAULT_TIMEOUT) -> dict:
|
|
116
|
+
"""query_authoritative, но без исключений — для встраивания в ответ тула."""
|
|
117
|
+
try:
|
|
118
|
+
return query_authoritative(fqdn, rtype, timeout)
|
|
119
|
+
except ProbeUnavailable as e:
|
|
120
|
+
return {"type": rtype.upper(), "error": str(e)}
|
|
121
|
+
except Exception as e: # pragma: no cover - защита от неожиданного из dnspython
|
|
122
|
+
return {"type": rtype.upper(), "error": f"{type(e).__name__}: {e}"}
|
mcp_beget/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class BegetError(Exception):
|
|
2
|
+
"""Base error for Beget API calls."""
|
|
3
|
+
|
|
4
|
+
def __init__(self, message: str, code: str = "", details: dict | None = None):
|
|
5
|
+
self.message = message
|
|
6
|
+
self.code = code
|
|
7
|
+
self.details = details or {}
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
|
|
10
|
+
def __str__(self) -> str:
|
|
11
|
+
# MCP отдаёт наружу только str(exc): без кода агент видит голый текст
|
|
12
|
+
# вроде "Failed to get DNS records" и не может отличить METHOD_FAILED
|
|
13
|
+
# от INVALID_DATA, чтобы выбрать следующий шаг.
|
|
14
|
+
return f"[{self.code}] {self.message}" if self.code else self.message
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BegetAuthError(BegetError):
|
|
18
|
+
"""Authentication failed — wrong login or password."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BegetAPIError(BegetError):
|
|
22
|
+
"""API returned an error response."""
|
mcp_beget/py.typed
ADDED
|
File without changes
|
mcp_beget/server.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .app import mcp
|
|
6
|
+
from .client import init_client
|
|
7
|
+
from .config import load_config
|
|
8
|
+
|
|
9
|
+
# Configure logging to stderr (stdout is reserved for MCP protocol)
|
|
10
|
+
logging.basicConfig(
|
|
11
|
+
level=logging.INFO,
|
|
12
|
+
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
13
|
+
stream=sys.stderr,
|
|
14
|
+
)
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
log.info("Starting Beget MCP server")
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
config = load_config()
|
|
23
|
+
except ValueError as e:
|
|
24
|
+
log.error("Configuration error: %s", e)
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
init_client(config)
|
|
28
|
+
|
|
29
|
+
# Import tools so @mcp.tool() decorators register them
|
|
30
|
+
import mcp_beget.tools # noqa: F401
|
|
31
|
+
|
|
32
|
+
transport = os.getenv("MCP_TRANSPORT", "stdio")
|
|
33
|
+
|
|
34
|
+
if transport in ("sse", "streamable-http"):
|
|
35
|
+
host = os.getenv("MCP_HOST", "0.0.0.0")
|
|
36
|
+
port = int(os.getenv("MCP_PORT", "8322"))
|
|
37
|
+
|
|
38
|
+
mcp.settings.host = host
|
|
39
|
+
mcp.settings.port = port
|
|
40
|
+
mcp.settings.stateless_http = True
|
|
41
|
+
|
|
42
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
43
|
+
|
|
44
|
+
mcp.settings.transport_security = TransportSecuritySettings(
|
|
45
|
+
enable_dns_rebinding_protection=False,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
log.info("Tools registered, starting %s server on %s:%d", transport, host, port)
|
|
49
|
+
mcp.run(transport=transport)
|
|
50
|
+
else:
|
|
51
|
+
log.info("Tools registered, running stdio server")
|
|
52
|
+
mcp.run(transport="stdio")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Import all tool modules so their @mcp.tool() decorators fire."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _json(data: dict) -> str:
|
|
7
|
+
"""Serialize API response for MCP output."""
|
|
8
|
+
return json.dumps(data, ensure_ascii=False, indent=2)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
from . import account, backup, cron, dns, domains, ftp, mail, mysql, sites # noqa: F401, E402
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from ..app import mcp
|
|
2
|
+
from ..client import get_client
|
|
3
|
+
from . import _json
|
|
4
|
+
from .annotations import MUTATING, READ_ONLY
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@mcp.tool(annotations=READ_ONLY)
|
|
8
|
+
def account_info() -> str:
|
|
9
|
+
"""Сведения об аккаунте: баланс, тариф, ресурсы, сервер."""
|
|
10
|
+
return _json(get_client().call("user", "getAccountInfo"))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@mcp.tool(annotations=MUTATING)
|
|
14
|
+
def toggle_ssh(status: int, ftplogin: str = "") -> str:
|
|
15
|
+
"""Переключить SSH-доступ для основного или FTP-аккаунта.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
status: 1 = включить, 0 = выключить
|
|
19
|
+
ftplogin: Логин FTP-аккаунта (если пусто — применяется к основному аккаунту)
|
|
20
|
+
"""
|
|
21
|
+
params: dict = {"status": status}
|
|
22
|
+
if ftplogin:
|
|
23
|
+
params["ftplogin"] = ftplogin
|
|
24
|
+
return _json(get_client().call("user", "toggleSsh", params))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@mcp.tool(annotations=READ_ONLY)
|
|
28
|
+
def stat_site_list_load() -> str:
|
|
29
|
+
"""Средняя нагрузка по всем сайтам за месяц."""
|
|
30
|
+
return _json(get_client().call("stat", "getSitesListLoad"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@mcp.tool(annotations=READ_ONLY)
|
|
34
|
+
def stat_db_list_load() -> str:
|
|
35
|
+
"""Средняя нагрузка по всем базам данных за месяц."""
|
|
36
|
+
return _json(get_client().call("stat", "getDbListLoad"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@mcp.tool(annotations=READ_ONLY)
|
|
40
|
+
def stat_site_load(site_id: int) -> str:
|
|
41
|
+
"""Детальная статистика нагрузки сайта (почасовая и посуточная за месяц).
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
site_id: ID сайта
|
|
45
|
+
"""
|
|
46
|
+
return _json(get_client().call("stat", "getSiteLoad", {"site_id": site_id}))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@mcp.tool(annotations=READ_ONLY)
|
|
50
|
+
def stat_db_load(db_name: str) -> str:
|
|
51
|
+
"""Детальная статистика нагрузки БД: CPU, размер, почасовая и посуточная.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
db_name: Имя базы данных
|
|
55
|
+
"""
|
|
56
|
+
return _json(get_client().call("stat", "getDbLoad", {"db_name": db_name}))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from mcp.types import ToolAnnotations
|
|
2
|
+
|
|
3
|
+
READ_ONLY = ToolAnnotations(
|
|
4
|
+
readOnlyHint=True,
|
|
5
|
+
destructiveHint=False,
|
|
6
|
+
openWorldHint=False,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
MUTATING = ToolAnnotations(
|
|
10
|
+
readOnlyHint=False,
|
|
11
|
+
destructiveHint=False,
|
|
12
|
+
openWorldHint=False,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
DESTRUCTIVE = ToolAnnotations(
|
|
16
|
+
readOnlyHint=False,
|
|
17
|
+
destructiveHint=True,
|
|
18
|
+
openWorldHint=False,
|
|
19
|
+
)
|