mcgl-mcp 0.1.1 → 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
@@ -58,6 +58,8 @@ claude mcp add mcgl -e FORUM_LOGIN=ник -e FORUM_PASSWORD=пароль -- npx
58
58
  | `forum_list_topics` | темы раздела: закреплённые, закрытые, кто может отвечать |
59
59
  | `forum_get_topic` | посты темы по 15 штук; по умолчанию — последние, плюс опрос и кто читает тему сейчас |
60
60
  | `forum_get_post` | один пост по ссылке `/post/N` и где он находится в теме |
61
+ | `forum_find_users` | игроки по началу ника |
62
+ | `forum_user_topics` | темы, созданные игроком (по нику или id) |
61
63
 
62
64
  ## Настройки
63
65
 
@@ -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}⟧`;
@@ -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,7 @@ 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." +
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
13
  FLAGS_NOTE;
14
14
  export const listSections = definePublicTool({
15
15
  name: "forum_list_sections",
@@ -48,7 +48,7 @@ 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
53
  "[audio: url], [Item name #id] or [item #id] (#id:meta for variants), [craft] grid of item ids with a legend." +
54
54
  FLAGS_NOTE,
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "mcgl-mcp",
3
- "version": "0.1.1",
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",