mcgl-mcp 0.0.0-stage → 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 exi66
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.
package/README.md CHANGED
@@ -1,3 +1,197 @@
1
- # Temporary Holding Version
1
+ # mcgl-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ MCP-сервер для форума [Minecraft Galaxy](https://forum.minecraft-galaxy.ru/main/). API у форума нет, поэтому сервер ходит на него как браузер: HTML, формы, кука `PHPSESSID`.
4
+
5
+ **Текущее состояние:** авторизация игрока и чтение общего форума. Действий от лица игрока пока нет.
6
+
7
+ ## Инструменты
8
+
9
+ Все инструменты публичные: авторизация не нужна, работают в обоих режимах. В локальном режиме с кредами страницы читаются залогиненным.
10
+
11
+ | Инструмент | Страница | Что возвращает |
12
+ |---|---|---|
13
+ | `forum_list_sections` | `/main/` | разделы 1..11 по категориям: описание, число тем и ответов, последнее сообщение |
14
+ | `forum_latest_topics` | `/forum/0/{page}` | последние темы всех разделов, у каждой указан раздел |
15
+ | `forum_list_topics` | `/forum/{id}/{page}` | темы раздела: закреплена, закрыта (зачёркнута), кто может отвечать (`all`/`vip`/`premium_platinum`/`moderators`), автор, ответы, число страниц, последнее сообщение |
16
+ | `forum_get_topic` | `/topic/{id}/{page}` | без `page` — последние 15 постов темы; с `page` — страницы по 15 от начала (`pageSize` 1..50). Всего постов и страниц; в режиме «последние» и на 1-й странице ещё опрос, системные сообщения («перемещено из…») и кто читает сейчас |
17
+ | `forum_get_post` | `/post/{id}` | один пост с номером в теме, его тема и страница `forum_get_topic`, на которой он находится |
18
+
19
+ В инструментах страницы нумеруются с 1, в URL форума — с 0.
20
+
21
+ **Пагинация тем.** Форум показывает на странице столько постов, сколько задано в настройках игрока (у гостя и игроков бывает 15…100). Агенту сто постов за раз дорого, поэтому `forum_get_topic` отдаёт свои страницы фиксированного размера поверх пагинации форума. Номера постов `#N` в теме сквозные, удалённые тоже нумеруются, так что страница `v` — это посты `#(v-1)*15+1 … #v*15`. Без `page` отдаются последние 15 постов: в больших темах обычно нужен конец. Если тема недавно встречалась в списке тем, там уже было число её страниц, и `ForumReader` открывает последнюю страницу форума одним запросом. Если тема с тех пор выросла на новую страницу, это видно по пагинации, и reader идёт обычным путём. Размер страницы форума `ForumReader` узнаёт по первой (или последней) странице темы и перепроверяет по номерам: если игрок поменял настройку, пересчитывает. Разобранные страницы форума кешируются на 60 секунд, чтобы листание темы не скачивало их заново.
22
+
23
+ **Недоступность форума.** GET повторяется до 3 раз при сетевой ошибке, таймауте или 5xx, с паузами 1 и 2 секунды. POST не повторяется: повтор может создать дубль поста. После 2 подряд неудачных запросов срабатывает circuit breaker (`src/forum/health.ts`): 30 секунд запросы на форум не идут, инструменты сразу отвечают «форум не отвечает, повтори через N секунд». Затем проходит один пробный запрос. Состояние видно в `/health` HTTP-режима.
24
+
25
+ Пост содержит номер, id, автора (ник, id, группа, статус, клан со званием, число ответов), дату, текст и рейтинг. Удалённый пост возвращается как `{deleted: true, number, id}`. Если аккаунт автора удалён или заблокирован (на форуме это `player$0`), у автора стоит `accountDeleted: true`.
26
+
27
+ Текст поста получается из отрендеренного bbCode ([справка](https://forum.minecraft-galaxy.ru/wiki/201)):
28
+
29
+ - `[quote: ник] … [/quote]`, `[spoiler: заголовок] … [/spoiler]`;
30
+ - `[image: url]`, `[video: url]`, `[audio: url]`;
31
+ - предметы — `[Золотой блок #41]`, с метадатой — `[Красная шерсть #35:14]`;
32
+ - окна крафта, печи и наковальни — `[craft]`: сетка id предметов (`-` — пустой слот, `->` — результат, `x4` — количество) и легенда `124 = Ажурный камень`;
33
+ - таблицы — ячейками через `|`, списки — с `- `;
34
+ - ссылки — как `текст (url)`, редиректы форума раскрываются.
35
+
36
+ Названия предметов берутся из эндпоинта подсказок форума `/object/{id}/2?d={meta}` (`src/forum/objects.ts`). Кеш живёт весь процесс. За один вызов инструмента делается не больше 25 новых запросов, остальные предметы остаются как `[item #id]`. Тот же эндпоинт отдаёт карточку игрока (тип 1) и сообщества (тип 3).
37
+
38
+ ### Экономия токенов
39
+
40
+ - Описания инструментов и служебные маркеры в тексте — на английском и в ASCII. Контент форума остаётся как есть, на русском.
41
+ - Ответы — компактный JSON без отступов.
42
+ - Флаги `false` не отдаются (`pinned`, `closed`, `deleted`, `accountDeleted` есть только при `true`); об этом сказано в описаниях инструментов.
43
+
44
+ Парсеры в `src/forum/parsers/` общие: списки тем и страницы тем одинаково свёрстаны во всём форуме, включая сообщества (`/htopic/N`, `/hpost/N`).
45
+
46
+ ## Режимы
47
+
48
+ | Режим | Транспорт | Инструменты | Авторизация |
49
+ |---|---|---|---|
50
+ | Локальный | stdio | публичные + от лица игрока | логин и пароль игрока в env клиента |
51
+ | Хост | Streamable HTTP, `POST /public/mcp` | только публичные | нет (пока) |
52
+
53
+ ## Авторизация на форуме
54
+
55
+ 1. `GET /guilogin/`: из формы `form[action="/login.php"]` берутся скрытые поля `form`, `ref`, `fcode`, `recap`.
56
+ 2. `POST /login.php` с этими полями плюс `login` и `pass`.
57
+ - Успех: 302 на `/main/`, в шапке появляется ник (`a.button[href="/profilemain/"] .text`).
58
+ - Неудача: 302 на `/guilogin/4?f=N` с плашкой `table.alert td.caption` («Введенный логин или пароль неверны! Попыток входа осталось: N…»).
59
+ 3. Гостевая страница определяется по кнопке «Войти» в шапке (`a.button[href="/guilogin/"]`). Форма ответа в теме видна и гостю, поэтому по ней судить о логине нельзя.
60
+
61
+ Особенности форума:
62
+
63
+ - **`fcode` обязателен.** Без него вход отклоняется и списывается попытка.
64
+ - **reCAPTCHA v3 сервер не проверяет**, `recap` уходит пустым.
65
+ - **У входов есть лимит попыток.** Поэтому после неудачного логина `PlayerSession` больше не пробует войти, пока процесс не перезапустят.
66
+ - **Протухшая сессия переустанавливается сама.** Если страница пришла гостевой, бросается `SessionExpiredError`, сессия перелогинивается и повторяет операцию один раз.
67
+
68
+ Проверка без MCP (креды из `.env`): `pnpm check:login`. Скрипт логинится, повторно использует сессию и имитирует её протухание. Каждый запуск тратит 2 входа.
69
+
70
+ ## Локальный режим
71
+
72
+ Нужен Node.js 22+. Ставить отдельно ничего не надо: клиент сам запустит пакет через `npx`.
73
+
74
+ **Claude Code:**
75
+
76
+ ```bash
77
+ claude mcp add mcgl -e FORUM_LOGIN=ник -e FORUM_PASSWORD=пароль -- npx -y mcgl-mcp
78
+ ```
79
+
80
+ **Claude Desktop, Cursor и другие** (`claude_desktop_config.json`, `.cursor/mcp.json` и т.п.):
81
+
82
+ ```json
83
+ {
84
+ "mcpServers": {
85
+ "mcgl": {
86
+ "command": "npx",
87
+ "args": ["-y", "mcgl-mcp"],
88
+ "env": {
89
+ "FORUM_LOGIN": "ник",
90
+ "FORUM_PASSWORD": "пароль"
91
+ }
92
+ }
93
+ }
94
+ }
95
+ ```
96
+
97
+ `FORUM_LOGIN` и `FORUM_PASSWORD` необязательны: без них доступно только чтение.
98
+
99
+ Если на Windows клиент не находит `npx`, укажи `"command": "cmd"` и `"args": ["/c", "npx", "-y", "mcgl-mcp"]`.
100
+
101
+ С кредами сервер логинится сразу при старте. Результат («Authorized as …» или текст ошибки форума) виден в логах MCP-клиента.
102
+
103
+ ### Запуск из исходников
104
+
105
+ ```bash
106
+ pnpm install
107
+ pnpm build
108
+ ```
109
+
110
+ В конфиге клиента: `"command": "node"`, `"args": ["<путь>/mcgl-mcp/dist/entry/stdio.js"]`.
111
+
112
+ ## Публикация в npm
113
+
114
+ Публикует GitHub Actions ([.github/workflows/publish.yml](.github/workflows/publish.yml)) по тегу:
115
+
116
+ ```bash
117
+ git tag v0.1.0
118
+ git push origin v0.1.0
119
+ ```
120
+
121
+ - **Версия берётся из тега.** В `package.json` в репозитории лежит заглушка `0.0.0-dev`, менять её руками не нужно.
122
+ - **Пререлизы** (`v1.0.0-beta.1`) публикуются с dist-tag `next`, поэтому `npx mcgl-mcp` продолжает ставить последнюю стабильную версию.
123
+ - **Тег, который не является semver,** останавливает workflow с ошибкой.
124
+ - **Пакет подписывается `--provenance`:** npm показывает, что он собран этим workflow из этого репозитория.
125
+
126
+ Один раз нужно добавить секрет `NPM_TOKEN` в Settings → Secrets and variables → Actions. Токен создаётся на npmjs.com в разделе Access Tokens → Generate New Token → Granular, с правом публикации.
127
+
128
+ Что попадёт в пакет: `npm pack --dry-run`. Идут только `dist/**/*.js` (без `dist/dev`), README и LICENSE.
129
+
130
+ ## Хост
131
+
132
+ ```bash
133
+ cp .env.example .env
134
+ docker compose up -d --build
135
+ ```
136
+
137
+ Эндпоинт: `https://<домен>/public/mcp`, проверка здоровья: `/health`. `FORUM_LOGIN` и `FORUM_PASSWORD` HTTP-режим не читает.
138
+
139
+ ## Устройство
140
+
141
+ ```
142
+ src/
143
+ entry/stdio.ts локальный режим
144
+ entry/http.ts публичный режим
145
+ dev/check-login.ts ручная проверка авторизации
146
+ env.ts конфиг из переменных окружения
147
+ forum/
148
+ site.ts URL форума
149
+ http.ts fetch + куки + ручные редиректы + повторы GET
150
+ health.ts circuit breaker: форум лежит → не ходим 30 с
151
+ cookie-jar.ts
152
+ rate-limiter.ts минимальный интервал между запросами (общий на процесс)
153
+ player-session.ts логин, проверка сессии, перелогин
154
+ reader.ts чтение форума (гостем или через сессию игрока)
155
+ types.ts формы данных, которые отдают инструменты
156
+ parsers/
157
+ common.ts общие хелперы, признаки гостя, пользователи, пагинация
158
+ topic-list.ts главная, /forum/0, /forum/N (и разделы сообществ)
159
+ topic.ts тема и отдельный пост: посты, опрос, шапка
160
+ content.ts HTML поста (bbCode) → текст
161
+ player.ts форма логина
162
+ mcp/
163
+ define-tool.ts definePublicTool / definePlayerTool
164
+ tools/index.ts реестр инструментов
165
+ tools/public/read.ts инструменты чтения
166
+ server.ts сборка McpServer
167
+ ```
168
+
169
+ Добавление инструмента: объявить через `definePublicTool` или `definePlayerTool` и положить в массив в `mcp/tools/index.ts`. В player-инструментах обращения к форуму оборачиваются в `ctx.session.withAuth(...)`, а страницы получаются через `ctx.session.getAuthed(...)`.
170
+
171
+ ## Тесты
172
+
173
+ ```bash
174
+ pnpm test # все тесты, без сети (их же гоняет CI)
175
+ pnpm test:watch
176
+ pnpm test:live # smoke-тесты на настоящем форуме, креды игрока из .env необязательны
177
+ pnpm fixtures:update # перекачать страницы форума в test/fixtures
178
+ ```
179
+
180
+ | Папка | Что проверяется |
181
+ |---|---|
182
+ | `test/parsers/` | конвертер bbCode → текст на коротких HTML-фрагментах; парсеры страниц на фикстурах |
183
+ | `test/forum/` | куки, редиректы, cp1251, лимитер; логин, перелогин, блокировка после неверного пароля; названия предметов (кеш, лимит запросов) |
184
+ | `test/mcp/` | MCP целиком: клиент SDK ↔ сервер (`InMemoryTransport`) ↔ фейковый форум |
185
+ | `test/live/` | только `pnpm test:live`: живая разметка всё ещё парсится |
186
+
187
+ Тесты сети работают против фейкового форума: это локальный http-сервер внутри теста (`test/helpers/fake-forum.ts`). Он отдаёт страницы из `test/fixtures/`, поэтому проверяется настоящий `ForumHttp`, без моков `fetch`.
188
+
189
+ Фикстуры — замороженные копии страниц форума, они лежат в git. Если форум поменял вёрстку, запусти `pnpm fixtures:update` и посмотри, какие тесты упали.
190
+
191
+ CI ([.github/workflows/ci.yml](.github/workflows/ci.yml)) на push в `main` и на PR прогоняет typecheck, тесты, сборку и проверку состава пакета. Перед публикацией `prepublishOnly` прогоняет то же самое.
192
+
193
+ `tsconfig.json` проверяет типы в `src`, тестах и скриптах, а `tsconfig.build.json` собирает только `src` в `dist`.
194
+
195
+ ## Лицензия
196
+
197
+ [MIT](LICENSE)
@@ -0,0 +1,49 @@
1
+ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import { loadForumEnv, loadHttpEnv } from "../env.js";
4
+ import { ForumHttp } from "../forum/http.js";
5
+ import { ForumReader } from "../forum/reader.js";
6
+ import { RateLimiter } from "../forum/rate-limiter.js";
7
+ import { createLogger } from "../logger.js";
8
+ import { createMcpServer, SERVER_INFO } from "../mcp/server.js";
9
+ const logger = createLogger("http");
10
+ const forumEnv = loadForumEnv();
11
+ const httpEnv = loadHttpEnv();
12
+ // Общий анонимный клиент и общий лимитер: все запросы к форуму идут с IP хоста.
13
+ const limiter = new RateLimiter(forumEnv.minIntervalMs);
14
+ const forumHttp = new ForumHttp(forumEnv, limiter);
15
+ const reader = new ForumReader(forumHttp);
16
+ const app = createMcpExpressApp({ host: httpEnv.host, allowedHosts: httpEnv.allowedHosts });
17
+ // Сам сервер жив (ok), форум — по данным circuit breaker (без лишних запросов к нему).
18
+ app.get("/health", (_req, res) => {
19
+ res.json({ ok: true, ...SERVER_INFO, forum: forumHttp.health.status });
20
+ });
21
+ const MCP_PATH = "/public/mcp";
22
+ app.post(MCP_PATH, async (req, res) => {
23
+ const server = createMcpServer({ reader });
24
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
25
+ res.on("close", () => {
26
+ void transport.close();
27
+ void server.close();
28
+ });
29
+ try {
30
+ await server.connect(transport);
31
+ await transport.handleRequest(req, res, req.body);
32
+ }
33
+ catch (error) {
34
+ logger.error("Error handling MCP request", error);
35
+ if (!res.headersSent) {
36
+ res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null });
37
+ }
38
+ }
39
+ });
40
+ // Stateless-режим: ни GET (SSE-стрим), ни DELETE (закрытие сессии) не нужны.
41
+ const methodNotAllowed = (_req, res) => {
42
+ res.status(405).json({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null });
43
+ };
44
+ app.get(MCP_PATH, methodNotAllowed);
45
+ app.delete(MCP_PATH, methodNotAllowed);
46
+ app.listen(httpEnv.port, httpEnv.host, () => {
47
+ logger.info(`Listening on http://${httpEnv.host}:${httpEnv.port}${MCP_PATH} (forum: ${forumEnv.baseUrl})`);
48
+ });
49
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Локальный режим: MCP-клиент запускает процесс и общается через stdin/stdout.
4
+ * Креды игрока берутся из env (FORUM_LOGIN / FORUM_PASSWORD), которые пользователь
5
+ * сам прописывает в конфиге клиента. Без них доступны только публичные инструменты.
6
+ */
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { loadForumEnv, loadPlayerCredentials } from "../env.js";
9
+ import { ForumHttp } from "../forum/http.js";
10
+ import { PlayerSession } from "../forum/player-session.js";
11
+ import { ForumReader } from "../forum/reader.js";
12
+ import { RateLimiter } from "../forum/rate-limiter.js";
13
+ import { createLogger } from "../logger.js";
14
+ import { createMcpServer } from "../mcp/server.js";
15
+ const logger = createLogger("stdio");
16
+ async function main() {
17
+ const forumEnv = loadForumEnv();
18
+ const credentials = loadPlayerCredentials();
19
+ const http = new ForumHttp(forumEnv, new RateLimiter(forumEnv.minIntervalMs));
20
+ if (!credentials) {
21
+ await createMcpServer({ reader: new ForumReader(http) }).connect(new StdioServerTransport());
22
+ logger.info("Started with public tools only (FORUM_LOGIN/FORUM_PASSWORD not set)");
23
+ return;
24
+ }
25
+ const session = new PlayerSession(http, credentials);
26
+ await createMcpServer({ reader: new ForumReader(http, session), session }).connect(new StdioServerTransport());
27
+ logger.info(`Started with player tools for login "${credentials.login}"`);
28
+ // Логинимся сразу, чтобы неверный пароль был виден в логах клиента при старте,
29
+ // а не при первом вызове инструмента. Процесс не падает: ошибку получит и инструмент.
30
+ session.whoami().then((username) => logger.info(`Authorized as ${username}`), (error) => logger.error(`Authorization failed: ${error instanceof Error ? error.message : String(error)}`));
31
+ }
32
+ main().catch((error) => {
33
+ logger.error("Fatal", error);
34
+ process.exit(1);
35
+ });
36
+ //# sourceMappingURL=stdio.js.map
package/dist/env.js ADDED
@@ -0,0 +1,57 @@
1
+ import { DEFAULT_FORUM_URL } from "./forum/site.js";
2
+ function str(name, fallback) {
3
+ const value = process.env[name]?.trim() || fallback;
4
+ if (value === undefined) {
5
+ throw new Error(`Missing required environment variable: ${name}`);
6
+ }
7
+ return value;
8
+ }
9
+ function optional(name) {
10
+ const value = process.env[name]?.trim();
11
+ return value ? value : undefined;
12
+ }
13
+ function int(name, fallback) {
14
+ const raw = optional(name);
15
+ if (raw === undefined)
16
+ return fallback;
17
+ const value = Number(raw);
18
+ if (!Number.isInteger(value) || value < 0) {
19
+ throw new Error(`Environment variable ${name} must be a non-negative integer, got "${raw}"`);
20
+ }
21
+ return value;
22
+ }
23
+ /** Настройки доступа к форуму, общие для обоих режимов. */
24
+ export function loadForumEnv() {
25
+ return {
26
+ baseUrl: str("FORUM_BASE_URL", DEFAULT_FORUM_URL).replace(/\/+$/, ""),
27
+ minIntervalMs: int("FORUM_MIN_INTERVAL_MS", 300),
28
+ timeoutMs: int("FORUM_TIMEOUT_MS", 15000),
29
+ };
30
+ }
31
+ /**
32
+ * Креды игрока для локального режима. Пользователь сам кладёт их в env
33
+ * в конфиге своего MCP-клиента. Если они не заданы, сервер стартует только
34
+ * с публичными инструментами.
35
+ */
36
+ export function loadPlayerCredentials() {
37
+ const login = optional("FORUM_LOGIN");
38
+ const password = optional("FORUM_PASSWORD");
39
+ if (!login && !password)
40
+ return undefined;
41
+ if (!login || !password) {
42
+ throw new Error("FORUM_LOGIN and FORUM_PASSWORD must be set together");
43
+ }
44
+ return { login, password };
45
+ }
46
+ export function loadHttpEnv() {
47
+ const allowedHosts = optional("ALLOWED_HOSTS")
48
+ ?.split(",")
49
+ .map((h) => h.trim())
50
+ .filter(Boolean);
51
+ return {
52
+ host: str("HOST", "0.0.0.0"),
53
+ port: int("PORT", 3000),
54
+ allowedHosts: allowedHosts?.length ? allowedHosts : undefined,
55
+ };
56
+ }
57
+ //# sourceMappingURL=env.js.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Простейшее хранилище кук на один хост форума. Path, domain и secure
3
+ * не учитываются: мы всегда ходим на один и тот же origin.
4
+ */
5
+ export class CookieJar {
6
+ cookies = new Map();
7
+ /** Применяет заголовки Set-Cookie из ответа. */
8
+ absorb(headers) {
9
+ for (const raw of headers.getSetCookie()) {
10
+ const [pair, ...attrs] = raw.split(";");
11
+ const eq = pair.indexOf("=");
12
+ if (eq <= 0)
13
+ continue;
14
+ const name = pair.slice(0, eq).trim();
15
+ const value = pair.slice(eq + 1).trim();
16
+ const expired = attrs.some((a) => {
17
+ const [k, v = ""] = a.split("=").map((s) => s.trim());
18
+ const key = k.toLowerCase();
19
+ if (key === "max-age")
20
+ return Number(v) <= 0;
21
+ if (key === "expires")
22
+ return Date.parse(v) <= Date.now();
23
+ return false;
24
+ });
25
+ if (expired || value === "" || value === "deleted") {
26
+ this.cookies.delete(name);
27
+ }
28
+ else {
29
+ this.cookies.set(name, value);
30
+ }
31
+ }
32
+ }
33
+ header() {
34
+ if (this.cookies.size === 0)
35
+ return undefined;
36
+ return [...this.cookies].map(([k, v]) => `${k}=${v}`).join("; ");
37
+ }
38
+ has(name) {
39
+ return this.cookies.has(name);
40
+ }
41
+ clear() {
42
+ this.cookies.clear();
43
+ }
44
+ }
45
+ //# sourceMappingURL=cookie-jar.js.map
@@ -0,0 +1,38 @@
1
+ /** Ошибка, текст которой можно показать агенту как результат инструмента. */
2
+ export class ForumError extends Error {
3
+ constructor(message, options) {
4
+ super(message, options);
5
+ this.name = "ForumError";
6
+ }
7
+ }
8
+ /** Форум ответил страницей «войдите». Сессия будет переустановлена один раз. */
9
+ export class SessionExpiredError extends ForumError {
10
+ constructor() {
11
+ super("Forum session expired");
12
+ this.name = "SessionExpiredError";
13
+ }
14
+ }
15
+ /** Логин не удался: неверные креды, бан, капча и т.п. */
16
+ export class LoginFailedError extends ForumError {
17
+ constructor(reason) {
18
+ super(`Forum login failed: ${reason}`);
19
+ this.name = "LoginFailedError";
20
+ }
21
+ }
22
+ /** Разметка страницы не совпала с ожиданиями парсера. */
23
+ export class ParseError extends ForumError {
24
+ constructor(what, url) {
25
+ super(`Could not parse ${what} at ${url}. The forum markup may have changed.`);
26
+ this.name = "ParseError";
27
+ }
28
+ }
29
+ /** Форум не отвечает (сработал ForumHealth): запросы временно не отправляются. */
30
+ export class ForumUnavailableError extends ForumError {
31
+ retryInSec;
32
+ constructor(retryInSec) {
33
+ super(`The forum is not responding right now. Try again in ${retryInSec}s.`);
34
+ this.retryInSec = retryInSec;
35
+ this.name = "ForumUnavailableError";
36
+ }
37
+ }
38
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,60 @@
1
+ import { createLogger } from "../logger.js";
2
+ import { ForumUnavailableError } from "./errors.js";
3
+ const logger = createLogger("forum-health");
4
+ /**
5
+ * Circuit breaker для форума. Форум периодически лежит; без этого каждый вызов
6
+ * инструмента ждал бы таймауты всех повторов.
7
+ *
8
+ * - closed: запросы идут как обычно;
9
+ * - open: после `failureThreshold` неудачных запросов подряд форум считается
10
+ * лежащим, запросы сразу получают ForumUnavailableError;
11
+ * - через `cooldownMs` пропускается один пробный запрос (half-open): успех
12
+ * закрывает breaker, неудача снова открывает его на cooldown.
13
+ *
14
+ * Один экземпляр на процесс, общий для всех ForumHttp.
15
+ */
16
+ export class ForumHealth {
17
+ failures = 0;
18
+ openUntil = 0;
19
+ probing = false;
20
+ threshold;
21
+ cooldownMs;
22
+ now;
23
+ constructor(options = {}) {
24
+ this.threshold = options.failureThreshold ?? 2;
25
+ this.cooldownMs = options.cooldownMs ?? 30_000;
26
+ this.now = options.now ?? Date.now;
27
+ }
28
+ /** Бросает ForumUnavailableError, если форум сейчас считается лежащим. */
29
+ check() {
30
+ if (this.failures < this.threshold)
31
+ return;
32
+ const wait = this.openUntil - this.now();
33
+ if (wait > 0 || this.probing) {
34
+ throw new ForumUnavailableError(Math.max(1, Math.ceil(Math.max(wait, 1000) / 1000)));
35
+ }
36
+ // Cooldown прошёл: пропускаем один пробный запрос.
37
+ this.probing = true;
38
+ }
39
+ success() {
40
+ if (this.failures >= this.threshold)
41
+ logger.info("Forum is back up");
42
+ this.failures = 0;
43
+ this.probing = false;
44
+ }
45
+ failure() {
46
+ this.failures++;
47
+ this.probing = false;
48
+ if (this.failures >= this.threshold) {
49
+ if (this.failures === this.threshold)
50
+ logger.warn(`Forum looks down, pausing requests for ${this.cooldownMs / 1000}s`);
51
+ this.openUntil = this.now() + this.cooldownMs;
52
+ }
53
+ }
54
+ get status() {
55
+ if (this.failures < this.threshold)
56
+ return { up: true };
57
+ return { up: false, retryInSec: Math.max(0, Math.ceil((this.openUntil - this.now()) / 1000)) };
58
+ }
59
+ }
60
+ //# sourceMappingURL=health.js.map
@@ -0,0 +1,136 @@
1
+ import { createLogger } from "../logger.js";
2
+ import { CookieJar } from "./cookie-jar.js";
3
+ import { ForumError } from "./errors.js";
4
+ import { ForumHealth } from "./health.js";
5
+ const logger = createLogger("forum-http");
6
+ const USER_AGENT = "mcgl-mcp/0.1 (+MCP forum tools)";
7
+ const MAX_REDIRECTS = 5;
8
+ /** Ответ, после которого GET имеет смысл повторить. */
9
+ class RetryableError extends ForumError {
10
+ }
11
+ /**
12
+ * HTTP-клиент к форуму: свой CookieJar, общий RateLimiter, ручные редиректы
13
+ * (чтобы не терять Set-Cookie из 302 после логина), повторы и circuit breaker.
14
+ *
15
+ * - GET при сетевой ошибке, таймауте или 5xx повторяется (по умолчанию 3 попытки).
16
+ * - POST не повторяется: при обрыве неизвестно, дошёл ли запрос, а повтор
17
+ * отправки поста создал бы дубль.
18
+ * - Если форум лежит (ForumHealth), запрос сразу падает с ForumUnavailableError.
19
+ *
20
+ * Один экземпляр = одна «браузерная сессия». Для анонимного чтения хватает
21
+ * одного общего, для каждого игрока нужен свой.
22
+ */
23
+ export class ForumHttp {
24
+ env;
25
+ limiter;
26
+ jar = new CookieJar();
27
+ health;
28
+ retryDelaysMs;
29
+ constructor(env, limiter, options = {}) {
30
+ this.env = env;
31
+ this.limiter = limiter;
32
+ this.health = options.health ?? new ForumHealth();
33
+ this.retryDelaysMs = options.retryDelaysMs ?? [1000, 2000];
34
+ }
35
+ get(path) {
36
+ return this.withHealth(() => this.withRetries(path, () => this.request(path, { method: "GET" })));
37
+ }
38
+ postForm(path, fields, extraHeaders) {
39
+ const body = new URLSearchParams();
40
+ for (const [k, v] of Object.entries(fields))
41
+ if (v !== undefined)
42
+ body.append(k, String(v));
43
+ return this.withHealth(() => this.request(path, {
44
+ method: "POST",
45
+ headers: { ...extraHeaders, "content-type": "application/x-www-form-urlencoded; charset=utf-8" },
46
+ body: body.toString(),
47
+ }));
48
+ }
49
+ absoluteUrl(path) {
50
+ return new URL(path, this.env.baseUrl + "/").toString();
51
+ }
52
+ async withHealth(run) {
53
+ this.health.check();
54
+ try {
55
+ const page = await run();
56
+ this.health.success();
57
+ return page;
58
+ }
59
+ catch (error) {
60
+ // Форум ответил, но что-то не так (редиректы по кругу и т.п.) — он жив.
61
+ if (error instanceof RetryableError)
62
+ this.health.failure();
63
+ else
64
+ this.health.success();
65
+ throw error;
66
+ }
67
+ }
68
+ async withRetries(path, run) {
69
+ for (let attempt = 0;; attempt++) {
70
+ try {
71
+ return await run();
72
+ }
73
+ catch (error) {
74
+ const delay = this.retryDelaysMs[attempt];
75
+ if (!(error instanceof RetryableError) || delay === undefined)
76
+ throw error;
77
+ logger.warn(`GET ${path} failed (${error.message}), retry ${attempt + 1}/${this.retryDelaysMs.length} in ${delay}ms`);
78
+ await new Promise((r) => setTimeout(r, delay));
79
+ }
80
+ }
81
+ }
82
+ async request(path, init) {
83
+ let url = this.absoluteUrl(path);
84
+ let current = init;
85
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
86
+ const res = await this.limiter.schedule(() => this.fetchOnce(url, current));
87
+ this.jar.absorb(res.headers);
88
+ const location = res.headers.get("location");
89
+ if (res.status >= 300 && res.status < 400 && location) {
90
+ await res.body?.cancel();
91
+ url = new URL(location, url).toString();
92
+ // 301/302/303 после POST браузеры превращают в GET, повторяем это поведение.
93
+ current = res.status === 307 || res.status === 308 ? current : { method: "GET" };
94
+ continue;
95
+ }
96
+ const html = decode(await res.arrayBuffer(), res.headers.get("content-type"));
97
+ logger.debug(`${current.method} ${url} -> ${res.status}${hop ? ` (after ${hop} redirect(s) from ${init.method} ${path})` : ""}`);
98
+ if (res.status >= 500) {
99
+ throw new RetryableError(`Forum returned HTTP ${res.status} for ${url}`);
100
+ }
101
+ return { url, status: res.status, html };
102
+ }
103
+ throw new ForumError(`Too many redirects starting from ${path}`);
104
+ }
105
+ async fetchOnce(url, init) {
106
+ const headers = new Headers(init.headers);
107
+ headers.set("user-agent", USER_AGENT);
108
+ headers.set("accept", "text/html,application/xhtml+xml");
109
+ const cookie = this.jar.header();
110
+ if (cookie)
111
+ headers.set("cookie", cookie);
112
+ try {
113
+ return await fetch(url, {
114
+ ...init,
115
+ headers,
116
+ redirect: "manual",
117
+ signal: AbortSignal.timeout(this.env.timeoutMs),
118
+ });
119
+ }
120
+ catch (error) {
121
+ const timeout = error instanceof DOMException && error.name === "TimeoutError";
122
+ throw new RetryableError(timeout ? `Forum timed out: ${url}` : `Request to forum failed: ${url}`, { cause: error });
123
+ }
124
+ }
125
+ }
126
+ /** Форум отдаёт UTF-8 и пишет charset в заголовке; на всякий случай уважаем его. */
127
+ function decode(body, contentType) {
128
+ const charset = /charset=([^;]+)/i.exec(contentType ?? "")?.[1]?.trim() || "utf-8";
129
+ try {
130
+ return new TextDecoder(charset).decode(body);
131
+ }
132
+ catch {
133
+ return new TextDecoder("utf-8").decode(body);
134
+ }
135
+ }
136
+ //# sourceMappingURL=http.js.map