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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ beget-mcp = mcp_beget.server:main
@@ -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
@@ -0,0 +1,3 @@
1
+ from mcp_beget.server import main
2
+
3
+ main()
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
+ )