mcgl-mcp 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,196 +1,84 @@
1
1
  # mcgl-mcp
2
2
 
3
- MCP-сервер для форума [Minecraft Galaxy](https://forum.minecraft-galaxy.ru/main/). API у форума нет, поэтому сервер ходит на него как браузер: HTML, формы, кука `PHPSESSID`.
3
+ [![npm](https://img.shields.io/npm/v/mcgl-mcp)](https://www.npmjs.com/package/mcgl-mcp)
4
+ [![CI](https://github.com/exi66/mcgl-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/exi66/mcgl-mcp/actions/workflows/ci.yml)
4
5
 
5
- **Текущее состояние:** авторизация игрока и чтение общего форума. Действий от лица игрока пока нет.
6
+ MCP-сервер для [форума Minecraft Galaxy](https://forum.minecraft-galaxy.ru/main/). Подключается к Claude, Cursor и другим AI-ассистентам и даёт им читать форум: разделы, темы, посты, опросы.
6
7
 
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
- ### Экономия токенов
8
+ Что можно попросить у ассистента:
39
9
 
40
- - Описания инструментов и служебные маркеры в тексте — на английском и в ASCII. Контент форума остаётся как есть, на русском.
41
- - Ответы — компактный JSON без отступов.
42
- - Флаги `false` не отдаются (`pinned`, `closed`, `deleted`, `accountDeleted` есть только при `true`); об этом сказано в описаниях инструментов.
10
+ - «Что нового на форуме MCGL?»
11
+ - «Перескажи последние сообщения в теме про Гран-при амфибий»
12
+ - «О чём спорят в разделе Предложения?»
13
+ - «Найди в теме 133931 рецепт крафта и объясни его»
43
14
 
44
- Парсеры в `src/forum/parsers/` общие: списки тем и страницы тем одинаково свёрстаны во всём форуме, включая сообщества (`/htopic/N`, `/hpost/N`).
15
+ ## Установка
45
16
 
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`.
17
+ Нужен [Node.js](https://nodejs.org) 22 или новее. Отдельно ставить ничего не надо: клиент сам скачает и запустит сервер через `npx`.
73
18
 
74
19
  **Claude Code:**
75
20
 
76
21
  ```bash
77
- claude mcp add mcgl -e FORUM_LOGIN=ник -e FORUM_PASSWORD=пароль -- npx -y mcgl-mcp
22
+ claude mcp add mcgl -- npx -y mcgl-mcp
78
23
  ```
79
24
 
80
- **Claude Desktop, Cursor и другие** (`claude_desktop_config.json`, `.cursor/mcp.json` и т.п.):
25
+ **Claude Desktop, Cursor и другие:** добавьте в конфиг MCP-серверов (`claude_desktop_config.json`, `.cursor/mcp.json` и т.п.):
81
26
 
82
27
  ```json
83
28
  {
84
29
  "mcpServers": {
85
30
  "mcgl": {
86
31
  "command": "npx",
87
- "args": ["-y", "mcgl-mcp"],
88
- "env": {
89
- "FORUM_LOGIN": "ник",
90
- "FORUM_PASSWORD": "пароль"
91
- }
32
+ "args": ["-y", "mcgl-mcp"]
92
33
  }
93
34
  }
94
35
  }
95
36
  ```
96
37
 
97
- `FORUM_LOGIN` и `FORUM_PASSWORD` необязательны: без них доступно только чтение.
98
-
99
- Если на Windows клиент не находит `npx`, укажи `"command": "cmd"` и `"args": ["/c", "npx", "-y", "mcgl-mcp"]`.
38
+ На Windows, если клиент не находит `npx`: `"command": "cmd"`, `"args": ["/c", "npx", "-y", "mcgl-mcp"]`.
100
39
 
101
- С кредами сервер логинится сразу при старте. Результат («Authorized as …» или текст ошибки форума) виден в логах MCP-клиента.
40
+ ### Вход под своим аккаунтом (необязательно)
102
41
 
103
- ### Запуск из исходников
42
+ Сейчас все инструменты работают и без входа. Вход понадобится для действий от вашего имени, например чтобы писать посты: это в планах. Настроить его можно уже сейчас.
104
43
 
105
44
  ```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
45
+ claude mcp add mcgl -e FORUM_LOGIN=ник -e FORUM_PASSWORD=пароль -- npx -y mcgl-mcp
135
46
  ```
136
47
 
137
- Эндпоинт: `https://<домен>/public/mcp`, проверка здоровья: `/health`. `FORUM_LOGIN` и `FORUM_PASSWORD` HTTP-режим не читает.
138
-
139
- ## Устройство
48
+ В JSON-конфиге то же самое задаётся через `"env": { "FORUM_LOGIN": "ник", "FORUM_PASSWORD": "пароль" }`.
140
49
 
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
- ```
50
+ Пароль хранится только в конфиге клиента на вашем компьютере и отправляется только на форум. Если войти не получилось, текст ошибки форума виден в логах MCP-сервера в клиенте. У форума есть лимит попыток входа, поэтому после неудачи сервер больше не пытается войти: исправьте пароль и перезапустите клиент.
168
51
 
169
- Добавление инструмента: объявить через `definePublicTool` или `definePlayerTool` и положить в массив в `mcp/tools/index.ts`. В player-инструментах обращения к форуму оборачиваются в `ctx.session.withAuth(...)`, а страницы получаются через `ctx.session.getAuthed(...)`.
52
+ ## Инструменты
170
53
 
171
- ## Тесты
54
+ | Инструмент | Что делает |
55
+ | --------------------- | ------------------------------------------------------------------------------------ |
56
+ | `forum_list_sections` | разделы форума по категориям |
57
+ | `forum_latest_topics` | темы со свежими сообщениями во всех разделах |
58
+ | `forum_list_topics` | темы раздела: закреплённые, закрытые, кто может отвечать |
59
+ | `forum_get_topic` | посты темы по 15 штук; по умолчанию — последние, плюс опрос и кто читает тему сейчас |
60
+ | `forum_get_post` | один пост по ссылке `/post/N` и где он находится в теме |
61
+ | `forum_find_users` | игроки по началу ника |
62
+ | `forum_user_topics` | темы, созданные игроком (по нику или id) |
172
63
 
173
- ```bash
174
- pnpm test # все тесты, без сети (их же гоняет CI)
175
- pnpm test:watch
176
- pnpm test:live # smoke-тесты на настоящем форуме, креды игрока из .env необязательны
177
- pnpm fixtures:update # перекачать страницы форума в test/fixtures
178
- ```
64
+ ## Настройки
179
65
 
180
- | Папка | Что проверяется |
181
- |---|---|
182
- | `test/parsers/` | конвертер bbCode → текст на коротких HTML-фрагментах; парсеры страниц на фикстурах |
183
- | `test/forum/` | куки, редиректы, cp1251, лимитер; логин, перелогин, блокировка после неверного пароля; названия предметов (кеш, лимит запросов) |
184
- | `test/mcp/` | MCP целиком: клиент SDK ↔ сервер (`InMemoryTransport`) ↔ фейковый форум |
185
- | `test/live/` | только `pnpm test:live`: живая разметка всё ещё парсится |
66
+ Переменные окружения задаются в конфиге клиента, все необязательные.
186
67
 
187
- Тесты сети работают против фейкового форума: это локальный http-сервер внутри теста (`test/helpers/fake-forum.ts`). Он отдаёт страницы из `test/fixtures/`, поэтому проверяется настоящий `ForumHttp`, без моков `fetch`.
68
+ | Переменная | По умолчанию | Что это |
69
+ | ------------------------------- | ----------------------------------- | ------------------------------------------ |
70
+ | `FORUM_LOGIN`, `FORUM_PASSWORD` | — | вход под своим аккаунтом |
71
+ | `LOG_LEVEL` | `info` | `debug` — подробные логи для отладки |
72
+ | `FORUM_MIN_INTERVAL_MS` | `300` | минимальная пауза между запросами к форуму |
73
+ | `FORUM_TIMEOUT_MS` | `15000` | таймаут одного запроса к форуму |
74
+ | `FORUM_BASE_URL` | `https://forum.minecraft-galaxy.ru` | адрес форума |
188
75
 
189
- Фикстуры — замороженные копии страниц форума, они лежат в git. Если форум поменял вёрстку, запусти `pnpm fixtures:update` и посмотри, какие тесты упали.
76
+ ## Документация
190
77
 
191
- CI ([.github/workflows/ci.yml](.github/workflows/ci.yml)) на push в `main` и на PR прогоняет typecheck, тесты, сборку и проверку состава пакета. Перед публикацией `prepublishOnly` прогоняет то же самое.
78
+ - [Как сервер работает с форумом](docs/forum.md): вход, пагинация тем, bbCode, предметы, недоступность форума.
79
+ - [Разработка](docs/development.md): запуск из исходников, тесты, релизы, свой HTTP-сервер.
192
80
 
193
- `tsconfig.json` проверяет типы в `src`, тестах и скриптах, а `tsconfig.build.json` собирает только `src` в `dist`.
81
+ Нашли ошибку или форум что-то поменял — [заведите issue](https://github.com/exi66/mcgl-mcp/issues/new/choose).
194
82
 
195
83
  ## Лицензия
196
84
 
@@ -119,7 +119,9 @@ export class ForumHttp {
119
119
  }
120
120
  catch (error) {
121
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 });
122
+ throw new RetryableError(timeout ? `Forum timed out: ${url}` : `Request to forum failed: ${url}`, {
123
+ cause: error,
124
+ });
123
125
  }
124
126
  }
125
127
  }
@@ -16,6 +16,28 @@ export function parseObjectLiteral(raw) {
16
16
  }
17
17
  return Object.keys(result).length ? result : undefined;
18
18
  }
19
+ /**
20
+ * Ответ точного поиска игрока `/object/0/9?q=nick`: `[{'id':'714893','login':'pozziittiivo'}]`.
21
+ * Если игрока нет, форум отвечает `player$0` с id 0 — тогда undefined.
22
+ */
23
+ export function parseUserExact(raw) {
24
+ const obj = parseObjectLiteral(raw.trim().replace(/^\[/, "").replace(/\]$/, ""));
25
+ const id = Number(obj?.id);
26
+ if (!obj?.login || !Number.isInteger(id) || id <= 0)
27
+ return undefined;
28
+ return { id, name: obj.login };
29
+ }
30
+ /**
31
+ * Ответ поиска по началу ника `/object/0/4?q=prefix`: строки `nick|group|id`.
32
+ * Пустые строки (`|guest|`) и удалённые аккаунты (`player$N`) отбрасываются.
33
+ */
34
+ export function parseUserPrefix(raw) {
35
+ return raw
36
+ .split(/\r?\n/)
37
+ .map((line) => line.trim().split("|"))
38
+ .filter(([name, , id]) => name && !/^player\$\d+$/.test(name) && Number(id) > 0)
39
+ .map(([name, group, id]) => ({ id: Number(id), name, group: group || undefined }));
40
+ }
19
41
  /** Маркер предмета в тексте поста, который ставит content.ts и раскрывает ItemResolver. */
20
42
  export const ITEM_TOKEN = /⟦item:(\d+):(\d+)⟧/g;
21
43
  export const itemToken = (id, meta) => `⟦item:${id}:${meta}⟧`;
@@ -24,7 +24,10 @@ export function postHtmlToText($, el) {
24
24
  node.find(".bc_quote").each((_, block) => {
25
25
  const b = $(block);
26
26
  const title = b.children(".bc_quote_title").first();
27
- const who = title.text().replace(/\s*писал\(а\):\s*$/, "").trim();
27
+ const who = title
28
+ .text()
29
+ .replace(/\s*писал\(а\):\s*$/, "")
30
+ .trim();
28
31
  title.remove();
29
32
  b.prepend(`\n[quote${who ? `: ${who}` : ""}]\n`).append("\n[/quote]\n");
30
33
  b.replaceWith(b.contents());
@@ -1,5 +1,5 @@
1
1
  import { ForumError } from "./errors.js";
2
- import { ItemResolver } from "./objects.js";
2
+ import { ItemResolver, parseUserExact, parseUserPrefix } from "./objects.js";
3
3
  import { parseSections, parseTopicList } from "./parsers/topic-list.js";
4
4
  import { parsePostPage, parseTopicPage } from "./parsers/topic.js";
5
5
  import { site } from "./site.js";
@@ -48,6 +48,37 @@ export class ForumReader {
48
48
  async sectionTopics(sectionId, page = 1) {
49
49
  return this.rememberPages(parseTopicList(await this.get(site.section(sectionId, page)), page));
50
50
  }
51
+ /** Игроки, чей ник начинается с prefix (без учёта регистра). */
52
+ async findUsers(prefix, limit = 20) {
53
+ return parseUserPrefix((await this.http.get(site.userPrefix(prefix))).html).slice(0, limit);
54
+ }
55
+ /** Темы, созданные игроком. Игрок задаётся id или точным ником (регистр не важен). */
56
+ async userTopics(user, page = 1) {
57
+ let id = user.id;
58
+ if (id === undefined) {
59
+ const nick = user.nick?.trim() ?? "";
60
+ const found = nick ? parseUserExact((await this.http.get(site.userExact(nick))).html) : undefined;
61
+ if (!found) {
62
+ // Подсказки: ники с тем же началом; при опечатке в середине — по первым 4 буквам.
63
+ let prefix = nick;
64
+ let similar = nick ? await this.findUsers(nick, 10) : [];
65
+ if (similar.length === 0 && nick.length > 4) {
66
+ prefix = nick.slice(0, 4);
67
+ similar = await this.findUsers(prefix, 10);
68
+ }
69
+ throw new ForumError(`Player "${nick}" not found.` +
70
+ (similar.length ? ` Nicks starting with "${prefix}": ${similar.map((u) => u.name).join(", ")}` : ""));
71
+ }
72
+ id = found.id;
73
+ }
74
+ const list = parseTopicList(await this.get(site.userTopics(id, page)), page);
75
+ if (list.topics.length === 0 && page > 1) {
76
+ throw new ForumError(`Player ${id} has fewer than ${page} pages of topics`);
77
+ }
78
+ // Заголовок страницы: «Темы пользователя <ник>».
79
+ const name = list.title?.replace(/^Темы пользователя\s+/, "") ?? user.nick ?? String(id);
80
+ return { ...this.rememberPages(list), user: { id, name } };
81
+ }
51
82
  /** page — от начала темы (с 1); без него — последние pageSize постов. */
52
83
  async topic(topicId, page, pageSize = DEFAULT_PAGE_SIZE) {
53
84
  try {
@@ -21,6 +21,12 @@ export const site = {
21
21
  topic: (topicId, page) => `/topic/${topicId}/${page - 1}`,
22
22
  /** Отдельный пост. */
23
23
  post: (postId) => `/post/${postId}`,
24
+ /** Темы игрока. В URL id игрока + 1 со знаком минус: игрок 714893 → /forum/-714894. */
25
+ userTopics: (userId, page) => `/forum/-${userId + 1}/${page - 1}`,
26
+ /** Точный поиск игрока по нику (без учёта регистра): [{'id':'714893','login':'nick'}], нет такого → player$0. */
27
+ userExact: (nick) => `/object/0/9?q=${encodeURIComponent(nick)}`,
28
+ /** Поиск игроков по началу ника: строки "nick|group|id". */
29
+ userPrefix: (prefix) => `/object/0/4?q=${encodeURIComponent(prefix)}`,
24
30
  /** Страница с формой логина: из неё берутся скрытые поля (form, ref, fcode, recap). */
25
31
  loginPage: () => "/guilogin/",
26
32
  /** Куда отправляется форма логина. Успех: 302 на ref, неудача: 302 на /guilogin/4?f=N. */
@@ -1,4 +1,5 @@
1
1
  import { readTools } from "./public/read.js";
2
+ import { userTools } from "./public/users.js";
2
3
  /**
3
4
  * Реестр инструментов.
4
5
  *
@@ -6,6 +7,6 @@ import { readTools } from "./public/read.js";
6
7
  * `definePlayerTool(...)` и добавить в нужный массив.
7
8
  * Player-инструменты автоматически не попадают в HTTP-режим.
8
9
  */
9
- export const publicTools = [...readTools];
10
+ export const publicTools = [...readTools, ...userTools];
10
11
  export const playerTools = [];
11
12
  //# sourceMappingURL=index.js.map
@@ -9,7 +9,8 @@ const FLAGS_NOTE = " Boolean flags appear only when true.";
9
9
  const page = z.number().int().positive().default(1).describe("1-based page number");
10
10
  const TOPIC_ROW_FIELDS = "Topic fields: pinned; closed (title struck through on the forum, no replies allowed); " +
11
11
  "whoCanReply: all | vip | premium_platinum | moderators (only visible in topic lists, not on the topic page); " +
12
- "pages (topic page count); created/last: author and time." + FLAGS_NOTE;
12
+ "pages (topic page count); created/last: author and time; author.group is the player's privilege (paid or staff role, e.g. vip, platinum, admin); 'girls' is only a nick color anyone can pick, not gender." +
13
+ FLAGS_NOTE;
13
14
  export const listSections = definePublicTool({
14
15
  name: "forum_list_sections",
15
16
  title: "Forum sections",
@@ -22,8 +23,7 @@ export const listSections = definePublicTool({
22
23
  export const latestTopics = definePublicTool({
23
24
  name: "forum_latest_topics",
24
25
  title: "Latest topics",
25
- description: "Recently active topics across all sections, newest first; each row includes its section name. " +
26
- TOPIC_ROW_FIELDS,
26
+ description: "Recently active topics across all sections, newest first; each row includes its section name. " + TOPIC_ROW_FIELDS,
27
27
  inputSchema: { page },
28
28
  annotations: readOnly,
29
29
  handler: async ({ page }, { reader }) => jsonResult(await reader.latestTopics(page)),
@@ -48,12 +48,18 @@ export const getTopic = definePublicTool({
48
48
  "is ceil(N/pageSize). Post numbers (#N) are continuous. Returns title, section, totalPages, totalPosts, " +
49
49
  "posts (number, id, author, postedAt, text, rating; deleted posts are {deleted:true}). The latest view and " +
50
50
  "page 1 also include poll (if any), notices (e.g. 'moved from section') and current readers. " +
51
- "author.accountDeleted: account removed or banned. " +
51
+ "author.accountDeleted: account removed or banned; author.group is the player's privilege (paid or staff role, e.g. vip, platinum, admin); 'girls' is only a nick color anyone can pick, not gender. " +
52
52
  "Post text markup: [quote: nick]..[/quote], [spoiler: title]..[/spoiler], [image: url], [video: url], " +
53
- "[audio: url], [Item name #id] or [item #id] (#id:meta for variants), [craft] grid of item ids with a legend." + FLAGS_NOTE,
53
+ "[audio: url], [Item name #id] or [item #id] (#id:meta for variants), [craft] grid of item ids with a legend." +
54
+ FLAGS_NOTE,
54
55
  inputSchema: {
55
56
  topicId: z.number().int().positive(),
56
- page: z.number().int().positive().optional().describe("1-based page from the topic start; omit for the newest posts"),
57
+ page: z
58
+ .number()
59
+ .int()
60
+ .positive()
61
+ .optional()
62
+ .describe("1-based page from the topic start; omit for the newest posts"),
57
63
  pageSize: z.number().int().min(1).max(50).default(DEFAULT_PAGE_SIZE).describe("Posts per page"),
58
64
  },
59
65
  annotations: readOnly,
@@ -0,0 +1,35 @@
1
+ import { z } from "zod";
2
+ import { ForumError } from "../../../forum/errors.js";
3
+ import { definePublicTool, jsonResult } from "../../define-tool.js";
4
+ const readOnly = { readOnlyHint: true, openWorldHint: true };
5
+ export const findUsers = definePublicTool({
6
+ name: "forum_find_users",
7
+ title: "Find players",
8
+ description: "Players whose nick starts with the given text (case-insensitive): name, id, group. " +
9
+ "group is the player's privilege (paid or staff role, e.g. vip, platinum, admin); 'girls' is only a nick color anyone can pick, not gender.",
10
+ inputSchema: {
11
+ prefix: z.string().trim().min(1).max(32).describe("Beginning of the nick"),
12
+ limit: z.number().int().min(1).max(50).default(20),
13
+ },
14
+ annotations: readOnly,
15
+ handler: async ({ prefix, limit }, { reader }) => jsonResult(await reader.findUsers(prefix, limit)),
16
+ });
17
+ export const userTopics = definePublicTool({
18
+ name: "forum_user_topics",
19
+ title: "Topics by player",
20
+ description: "Topics created by a player, newest first, same row format as forum_list_topics. " +
21
+ "Give nick (exact, case-insensitive) or userId. If the nick is unknown, the error lists similar nicks.",
22
+ inputSchema: {
23
+ nick: z.string().trim().min(1).max(32).optional(),
24
+ userId: z.number().int().positive().optional(),
25
+ page: z.number().int().positive().default(1).describe("1-based page number"),
26
+ },
27
+ annotations: readOnly,
28
+ handler: async ({ nick, userId, page }, { reader }) => {
29
+ if (!nick && !userId)
30
+ throw new ForumError("Pass nick or userId");
31
+ return jsonResult(await reader.userTopics({ id: userId, nick }, page));
32
+ },
33
+ });
34
+ export const userTools = [findUsers, userTopics];
35
+ //# sourceMappingURL=users.js.map
package/package.json CHANGED
@@ -1,12 +1,10 @@
1
1
  {
2
2
  "name": "mcgl-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server for the Minecraft Galaxy (MCGL) game forum: read sections, topics and posts.",
5
5
  "keywords": [
6
6
  "mcp",
7
7
  "model-context-protocol",
8
- "minecraft-galaxy",
9
- "mcgl",
10
8
  "forum",
11
9
  "claude"
12
10
  ],
@@ -48,7 +46,9 @@
48
46
  "test": "vitest run",
49
47
  "test:watch": "vitest",
50
48
  "test:live": "vitest run --config vitest.live.config.ts",
51
- "fixtures:update": "tsx --env-file-if-exists=.env scripts/update-fixtures.ts"
49
+ "fixtures:update": "tsx --env-file-if-exists=.env scripts/update-fixtures.ts",
50
+ "format": "prettier --write .",
51
+ "format:check": "prettier --check ."
52
52
  },
53
53
  "dependencies": {
54
54
  "@modelcontextprotocol/sdk": "^1.32.2",
@@ -60,6 +60,7 @@
60
60
  "@modelcontextprotocol/inspector": "^2.15.0",
61
61
  "@types/express": "^5.0.6",
62
62
  "@types/node": "^26.6.6",
63
+ "prettier": "^3.10.1",
63
64
  "tsx": "^4.23.15",
64
65
  "typescript": "^7.0.2",
65
66
  "vitest": "^5.1.0"