mcp-unknowncheatz 0.3.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.
@@ -0,0 +1,137 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { fetchHtml } from "../crawl.js";
4
+ import { parseThread } from "../parsers/thread.js";
5
+ import type { ThreadPost } from "../types.js";
6
+
7
+ const MAX_PAGES = 50;
8
+ const MAX_IMAGES = 10; // max images to fetch and embed per call
9
+
10
+ function buildPageUrl(baseUrl: string, page: number): string {
11
+ const url = new URL(baseUrl);
12
+ url.searchParams.set("page", String(page));
13
+ return url.toString();
14
+ }
15
+
16
+ async function fetchImageAsBase64(url: string): Promise<{ data: string; mimeType: string } | null> {
17
+ try {
18
+ const res = await fetch(url, {
19
+ headers: { "User-Agent": "Mozilla/5.0" },
20
+ signal: AbortSignal.timeout(8_000),
21
+ });
22
+ if (!res.ok) return null;
23
+
24
+ const contentType = res.headers.get("content-type") ?? "image/png";
25
+ const mimeType = contentType.split(";")[0].trim();
26
+ if (!mimeType.startsWith("image/")) return null;
27
+
28
+ const buffer = await res.arrayBuffer();
29
+ const data = Buffer.from(buffer).toString("base64");
30
+ return { data, mimeType };
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export function registerGetThread(server: McpServer): void {
37
+ server.tool(
38
+ "get_thread",
39
+ "Get a forum thread. Use latest_pages for recent posts in long-running threads; the default reads only the linked page.",
40
+ {
41
+ url: z.string().url().describe("Thread URL"),
42
+ fetch_all_pages: z
43
+ .boolean()
44
+ .optional()
45
+ .default(false)
46
+ .describe("If true, fetches all pages of the thread (max 50 pages)"),
47
+ latest_pages: z
48
+ .number()
49
+ .int()
50
+ .min(1)
51
+ .max(5)
52
+ .optional()
53
+ .describe("Read the last 1-5 pages. Takes precedence over fetch_all_pages."),
54
+ include_images: z
55
+ .boolean()
56
+ .optional()
57
+ .default(false)
58
+ .describe("If true, fetches post images and returns them as viewable image content (max 10 images)"),
59
+ },
60
+ async ({ url, fetch_all_pages, latest_pages, include_images }) => {
61
+ try {
62
+ const firstHtml = await fetchHtml(url);
63
+ const pageParam = Number(new URL(url).searchParams.get("page"));
64
+ const requestedPage = Number.isInteger(pageParam) && pageParam > 0 ? pageParam : 1;
65
+ const firstPage = parseThread(firstHtml, url, requestedPage);
66
+ const totalPages = firstPage.totalPages;
67
+ const pagesToFetch = latest_pages
68
+ ? Array.from({ length: Math.min(latest_pages, totalPages) }, (_, index) =>
69
+ totalPages - Math.min(latest_pages, totalPages) + index + 1)
70
+ : fetch_all_pages
71
+ ? Array.from({ length: Math.min(totalPages, MAX_PAGES) }, (_, index) => index + 1)
72
+ : [Math.min(requestedPage, totalPages)];
73
+
74
+ const allPosts: ThreadPost[] = [];
75
+ for (const pageNum of pagesToFetch) {
76
+ const pageUrl = buildPageUrl(url, pageNum);
77
+ const html = pageNum === requestedPage ? firstHtml : await fetchHtml(pageUrl);
78
+ allPosts.push(...parseThread(html, pageUrl, pageNum).posts);
79
+ console.error(`[get-thread] Fetched page ${pageNum}/${totalPages}`);
80
+ }
81
+
82
+ const result = {
83
+ title: firstPage.title,
84
+ posts: allPosts,
85
+ currentPage: pagesToFetch.at(-1),
86
+ pagesFetched: pagesToFetch,
87
+ totalPages,
88
+ url,
89
+ ...(fetch_all_pages && !latest_pages && totalPages > MAX_PAGES
90
+ ? { note: `Capped at ${MAX_PAGES} pages (thread has ${totalPages} total)` }
91
+ : {}),
92
+ };
93
+
94
+ // Build content array
95
+ const content: Array<
96
+ | { type: "text"; text: string }
97
+ | { type: "image"; data: string; mimeType: string }
98
+ > = [{ type: "text", text: JSON.stringify(result) }];
99
+
100
+ if (include_images) {
101
+ // Collect all unique image URLs from all posts
102
+ const seen = new Set<string>();
103
+ const imageUrls: string[] = [];
104
+
105
+ for (const post of allPosts) {
106
+ for (const imgUrl of post.images) {
107
+ if (!seen.has(imgUrl)) {
108
+ seen.add(imgUrl);
109
+ imageUrls.push(imgUrl);
110
+ }
111
+ if (imageUrls.length >= MAX_IMAGES) break;
112
+ }
113
+ if (imageUrls.length >= MAX_IMAGES) break;
114
+ }
115
+
116
+ console.error(`[get-thread] Fetching ${imageUrls.length} images...`);
117
+
118
+ for (const imgUrl of imageUrls) {
119
+ const img = await fetchImageAsBase64(imgUrl);
120
+ if (img) {
121
+ content.push({ type: "image", data: img.data, mimeType: img.mimeType });
122
+ console.error(`[get-thread] Fetched image: ${imgUrl}`);
123
+ }
124
+ }
125
+ }
126
+
127
+ return { content };
128
+ } catch (err) {
129
+ const message = err instanceof Error ? err.message : String(err);
130
+ return {
131
+ content: [{ type: "text", text: `Error: ${message}` }],
132
+ isError: true,
133
+ };
134
+ }
135
+ }
136
+ );
137
+ }
@@ -0,0 +1,125 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { load } from "cheerio";
4
+ import { fetchHtml } from "../crawl.js";
5
+ import { validateUrl } from "../browser.js";
6
+
7
+ function extractStat(text: string, pattern: RegExp): string | undefined {
8
+ const match = text.match(pattern);
9
+ return match ? match[1].trim() : undefined;
10
+ }
11
+
12
+ function parseNumber(text?: string): number | null {
13
+ if (!text) return null;
14
+ const n = parseInt(text.replace(/,/g, ""), 10);
15
+ return Number.isFinite(n) ? n : null;
16
+ }
17
+
18
+ export function registerGetUserReputation(server: McpServer): void {
19
+ server.tool(
20
+ "get_user_reputation",
21
+ "Get an UnknownCheats user's reputation, rep power, join date, post count, and tier from their profile page.",
22
+ {
23
+ profile_url: z
24
+ .string()
25
+ .url()
26
+ .optional()
27
+ .describe("Full user profile URL, e.g. https://www.unknowncheats.me/forum/members/6719713.html"),
28
+ user_id: z
29
+ .number()
30
+ .int()
31
+ .positive()
32
+ .optional()
33
+ .describe("Numeric UC user ID (alternative to profile_url)"),
34
+ },
35
+ async ({ profile_url, user_id }) => {
36
+ try {
37
+ const url = profile_url ?? (user_id
38
+ ? `https://www.unknowncheats.me/forum/members/${user_id}.html`
39
+ : undefined);
40
+ if (!url) {
41
+ return {
42
+ content: [{ type: "text", text: "Error: provide either profile_url or user_id." }],
43
+ isError: true,
44
+ };
45
+ }
46
+ validateUrl(url);
47
+
48
+ const html = await fetchHtml(url);
49
+ const $ = load(html);
50
+
51
+ const rawTitle = $("title").text().trim();
52
+ const username = $("h1").first().text().trim().split(/\s+/)[0] ||
53
+ rawTitle.replace(/^View Profile:\s*/i, "").split("-")[0].trim();
54
+
55
+ const bodyText = $("body").text();
56
+
57
+ const reputationText = extractStat(bodyText, /Reputation[:\s]+([-\d,]+)/i);
58
+ const repPowerText = extractStat(bodyText, /Rep\s*Power[:\s]+([-\d,]+)/i);
59
+ const postsText = extractStat(bodyText, /(?:Total Posts|Posts)[:\s]+([\d,]+)/i);
60
+ const joinText = extractStat(bodyText, /(?:Join Date|Joined)[:\s]+([\w\s,-]{4,25}?)(?=\s{2,}|\n|$)/i);
61
+ const location = extractStat(bodyText, /Location[:\s]+([^\n]{1,60})/i);
62
+
63
+ const repDot = $("img[src*='reputation_']").first();
64
+ const repDescription = repDot.attr("alt")?.trim();
65
+ const repSrc = repDot.attr("src") ?? "";
66
+ const sign =
67
+ repSrc.includes("_neg") ? "negative" :
68
+ repSrc.includes("_bar") ? "neutral" :
69
+ repSrc.includes("_pos") || repSrc.includes("_highpos") ? "positive" :
70
+ "unknown";
71
+
72
+ const reputation = parseNumber(reputationText);
73
+ const repPower = parseNumber(repPowerText);
74
+ const posts = parseNumber(postsText);
75
+
76
+ let tier: string;
77
+ if (sign === "negative") tier = "negative";
78
+ else if (reputation === null) tier = "unknown";
79
+ else if (reputation >= 5_000) tier = "legend";
80
+ else if (reputation >= 2_000) tier = "excellent";
81
+ else if (reputation >= 1_000) tier = "great";
82
+ else if (reputation >= 500) tier = "good";
83
+ else if (reputation >= 100) tier = "average";
84
+ else if (reputation >= 10) tier = "positive";
85
+ else if (reputation > 0) tier = "novice";
86
+ else tier = "neutral";
87
+
88
+ const trustScore = sign === "negative"
89
+ ? 5
90
+ : reputation === null
91
+ ? 15
92
+ : reputation <= 0
93
+ ? 20
94
+ : Math.max(15, Math.min(100, Math.round(Math.log10(reputation + 1) * 20)));
95
+
96
+ return {
97
+ content: [
98
+ {
99
+ type: "text",
100
+ text: JSON.stringify({
101
+ url,
102
+ username,
103
+ reputation,
104
+ repPower,
105
+ posts,
106
+ joinDate: joinText,
107
+ location,
108
+ tier,
109
+ sign,
110
+ description: repDescription,
111
+ trustScore,
112
+ }),
113
+ },
114
+ ],
115
+ };
116
+ } catch (err) {
117
+ const message = err instanceof Error ? err.message : String(err);
118
+ return {
119
+ content: [{ type: "text", text: `Error: ${message}` }],
120
+ isError: true,
121
+ };
122
+ }
123
+ }
124
+ );
125
+ }
@@ -0,0 +1,59 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { fetchHtml } from "../crawl.js";
4
+ import { FORUM_INDEX, readForumCatalog, saveForumCatalog } from "../forum-catalog.js";
5
+
6
+ export function registerListSubforums(server: McpServer): void {
7
+ server.tool(
8
+ "list_subforums",
9
+ "List game subforum URLs discovered from the live UnknownCheats index. The local directory expires after 24 hours.",
10
+ {
11
+ query: z.string().optional().describe("Optional keyword to filter subforum slugs/labels/descriptions"),
12
+ limit: z.number().int().min(1).max(500).optional().default(100).describe("Max subforums returned (default 100)"),
13
+ refresh: z.boolean().optional().default(false).describe("Fetch the forum index again instead of using the local directory"),
14
+ },
15
+ async ({ query, limit, refresh }) => {
16
+ try {
17
+ const cached = refresh ? null : await readForumCatalog();
18
+ const catalog = cached ?? await saveForumCatalog(await fetchHtml(FORUM_INDEX, { bypassCache: refresh }));
19
+ const all = catalog.subforums;
20
+
21
+ const filtered = query
22
+ ? all.filter((sf) => {
23
+ const haystack = `${sf.slug} ${sf.label} ${sf.description ?? ""}`.toLowerCase();
24
+ return query
25
+ .toLowerCase()
26
+ .split(/\s+/)
27
+ .filter(Boolean)
28
+ .every((term) => haystack.includes(term));
29
+ })
30
+ : all;
31
+
32
+ const capped = filtered.slice(0, limit);
33
+
34
+ return {
35
+ content: [
36
+ {
37
+ type: "text",
38
+ text: JSON.stringify({
39
+ total: all.length,
40
+ matched: filtered.length,
41
+ returned: capped.length,
42
+ source: catalog.source,
43
+ indexedAt: catalog.indexedAt,
44
+ fromCache: Boolean(cached),
45
+ subforums: capped,
46
+ }),
47
+ },
48
+ ],
49
+ };
50
+ } catch (err) {
51
+ const message = err instanceof Error ? err.message : String(err);
52
+ return {
53
+ content: [{ type: "text", text: `Error: ${message}` }],
54
+ isError: true,
55
+ };
56
+ }
57
+ }
58
+ );
59
+ }
@@ -0,0 +1,71 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { load } from "cheerio";
4
+ import { navigateWithRetry } from "../browser.js";
5
+ import { clearCache } from "../crawl.js";
6
+ import { isLoggedIn } from "../auth.js";
7
+
8
+ const UC_LOGIN = "https://www.unknowncheats.me/forum/login.php";
9
+
10
+ export function registerLogin(server: McpServer): void {
11
+ server.tool(
12
+ "login",
13
+ "Log into UnknownCheats with username and password",
14
+ {
15
+ username: z.string().describe("UnknownCheats username"),
16
+ password: z.string().describe("UnknownCheats password"),
17
+ },
18
+ async ({ username, password }) => {
19
+ try {
20
+ const { page } = await navigateWithRetry(UC_LOGIN);
21
+
22
+ // Fill login form — vBulletin field names
23
+ await page.type('input[name="vb_login_username"]', username, { delay: 60 });
24
+ await page.type('input[name="vb_login_password"], input[type="password"]', password, { delay: 60 });
25
+
26
+ // Submit
27
+ await Promise.all([
28
+ page.waitForNavigation({ waitUntil: "networkidle2", timeout: 30_000 }),
29
+ page.click('input[type="submit"], button[type="submit"]'),
30
+ ]);
31
+
32
+ const html = await page.content();
33
+ const $ = load(html);
34
+
35
+ const loggedIn = isLoggedIn(html);
36
+
37
+ if (!loggedIn) {
38
+ // Check for error message
39
+ const error = $(".error, .panel .error, #navbar_notice").first().text().trim();
40
+ return {
41
+ content: [
42
+ {
43
+ type: "text",
44
+ text: JSON.stringify({ success: false, error: error || "Login failed — check credentials" }),
45
+ },
46
+ ],
47
+ isError: true,
48
+ };
49
+ }
50
+
51
+ clearCache();
52
+
53
+ // Extract username from page to confirm
54
+ const welcomeEl = $("#welcomelink, .welcomelink").first();
55
+ const welcomeText = welcomeEl.text().trim();
56
+ const match = welcomeText.match(/Welcome,?\s+(.+)/i);
57
+ const confirmedUsername = match ? match[1].replace(/[!.]+$/, "").trim() : username;
58
+
59
+ return {
60
+ content: [{ type: "text", text: JSON.stringify({ success: true, username: confirmedUsername }) }],
61
+ };
62
+ } catch (err) {
63
+ const message = err instanceof Error ? err.message : String(err);
64
+ return {
65
+ content: [{ type: "text", text: `Error: ${message}` }],
66
+ isError: true,
67
+ };
68
+ }
69
+ }
70
+ );
71
+ }
@@ -0,0 +1,146 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { load } from "cheerio";
4
+ import { navigateWithRetry } from "../browser.js";
5
+ import { fetchHtml } from "../crawl.js";
6
+ import { isLoggedIn } from "../auth.js";
7
+ import { searchViaSubforums } from "../search-fallback.js";
8
+ import { filterThreads, parseThreadList } from "../parsers/thread-list.js";
9
+
10
+ const UC_HOME = "https://www.unknowncheats.me/forum/";
11
+ const UC_SEARCH = "https://www.unknowncheats.me/forum/search.php";
12
+
13
+ async function runFallbackSearch(query: string) {
14
+ console.error(`[search] Not logged in — scanning UC subforums for "${query}"`);
15
+ const { results, scannedSubforums } = await searchViaSubforums(query, (url) => fetchHtml(url));
16
+
17
+ return {
18
+ count: results.length,
19
+ source: "subforum_scan" as const,
20
+ scannedSubforums,
21
+ requiresLoginForNativeSearch: true,
22
+ hint: results.length === 0
23
+ ? "No matches in scanned subforums. Pass subforum (e.g. apex-legends) or use the login tool for full UC search."
24
+ : "Use the login tool for full UC advanced search (filters, sort, author).",
25
+ results,
26
+ };
27
+ }
28
+
29
+ export function registerSearchForum(server: McpServer): void {
30
+ server.tool(
31
+ "search_forum",
32
+ "Search the UnknownCheats forum. Uses advanced search when logged in, scans relevant subforums as a guest, or browses a named subforum.",
33
+ {
34
+ query: z.string().optional().default("").describe("Search query string; optional when browsing a subforum"),
35
+ subforum: z.string().optional().describe("Subforum slug to browse directly (e.g. 'apex-legends')"),
36
+ title_only: z.boolean().optional().default(true).describe("Search only in thread titles (default true, more accurate)"),
37
+ sort_by: z.enum(["relevancy", "lastpost", "replycount", "views", "threadstart"]).optional().default("relevancy").describe("Sort results by"),
38
+ search_user: z.string().optional().describe("Filter by thread author username"),
39
+ },
40
+ async ({ query, subforum, title_only, sort_by, search_user }) => {
41
+ try {
42
+ if (!subforum && !query.trim()) {
43
+ return {
44
+ content: [{ type: "text", text: "Provide query or subforum" }],
45
+ isError: true,
46
+ };
47
+ }
48
+
49
+ if (subforum) {
50
+ const url = `https://www.unknowncheats.me/forum/${subforum}/`;
51
+ const html = await fetchHtml(url);
52
+ const results = filterThreads(parseThreadList(html), { query, includeSticky: true });
53
+ return {
54
+ content: [{ type: "text", text: JSON.stringify({ count: results.length, subforum, results }) }],
55
+ };
56
+ }
57
+
58
+ const homeHtml = await fetchHtml(UC_HOME);
59
+ if (!isLoggedIn(homeHtml)) {
60
+ const payload = await runFallbackSearch(query);
61
+ return {
62
+ content: [{ type: "text", text: JSON.stringify(payload) }],
63
+ };
64
+ }
65
+
66
+ const { page } = await navigateWithRetry(UC_SEARCH);
67
+
68
+ const submitted = await page.evaluate((opts) => {
69
+ const searchForm = document.getElementById("searchform") as HTMLFormElement | null;
70
+ if (!searchForm) return { ok: false, error: "Advanced search form (#searchform) not found" };
71
+
72
+ const queryInput = searchForm.querySelector('input[name="query"][size="35"]') as HTMLInputElement
73
+ ?? searchForm.querySelector('input[name="query"]') as HTMLInputElement;
74
+ if (!queryInput) return { ok: false, error: "Query input not found in form" };
75
+ queryInput.value = opts.query;
76
+
77
+ const titleOnlySelect = searchForm.querySelector('select[name="titleonly"]') as HTMLSelectElement;
78
+ if (titleOnlySelect) {
79
+ titleOnlySelect.value = opts.titleOnly ? "1" : "0";
80
+ }
81
+
82
+ const showThreads = searchForm.querySelector('input[name="showposts"][value="0"]') as HTMLInputElement;
83
+ if (showThreads) showThreads.checked = true;
84
+
85
+ const sortSelect = searchForm.querySelector('select[name="sortby"]') as HTMLSelectElement;
86
+ if (sortSelect) sortSelect.value = opts.sortBy;
87
+
88
+ if (opts.searchUser) {
89
+ const userInput = searchForm.querySelector('input[name="searchuser"]') as HTMLInputElement;
90
+ if (userInput) userInput.value = opts.searchUser;
91
+ }
92
+
93
+ return { ok: true };
94
+ }, { query, titleOnly: title_only, sortBy: sort_by, searchUser: search_user ?? "" });
95
+
96
+ if (!submitted.ok) {
97
+ const payload = await runFallbackSearch(query);
98
+ return {
99
+ content: [{ type: "text", text: JSON.stringify(payload) }],
100
+ };
101
+ }
102
+
103
+ await Promise.all([
104
+ page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 30_000 }),
105
+ page.evaluate(() => (document.getElementById("searchform") as HTMLFormElement).submit()),
106
+ ]);
107
+
108
+ const html = await page.content();
109
+ const results = parseThreadList(html);
110
+ const $ = load(html);
111
+ const pageTitle = $("title").text().trim();
112
+
113
+ const errorText = $(".standard_error, .errorwrap, .blockbody .error").first().text().trim();
114
+ if (errorText) {
115
+ return {
116
+ content: [{ type: "text", text: JSON.stringify({ count: 0, error: errorText, pageTitle }) }],
117
+ };
118
+ }
119
+
120
+ const pageNav = $(".pagenav td.vbmenu_control").first().text().trim();
121
+ const pageMatch = pageNav.match(/Page (\d+) of (\d+)/);
122
+ const pagination = pageMatch ? { currentPage: parseInt(pageMatch[1]), totalPages: parseInt(pageMatch[2]) } : undefined;
123
+
124
+ console.error(`[search] "${query}" → ${results.length} results, page: ${pageTitle}`);
125
+
126
+ return {
127
+ content: [{ type: "text", text: JSON.stringify({ count: results.length, source: "unknowncheats", pageTitle, pagination, results }) }],
128
+ };
129
+ } catch (err) {
130
+ const message = err instanceof Error ? err.message : String(err);
131
+ try {
132
+ const payload = await runFallbackSearch(query);
133
+ return {
134
+ content: [{ type: "text", text: JSON.stringify({ ...payload, nativeSearchError: message }) }],
135
+ };
136
+ } catch (fallbackErr) {
137
+ const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
138
+ return {
139
+ content: [{ type: "text", text: `Error: ${message} (fallback also failed: ${fallbackMessage})` }],
140
+ isError: true,
141
+ };
142
+ }
143
+ }
144
+ }
145
+ );
146
+ }
package/src/types.ts ADDED
@@ -0,0 +1,31 @@
1
+ export interface PostLink {
2
+ text: string;
3
+ url: string;
4
+ }
5
+
6
+ import type { AuthorReputation } from "./parsers/reputation.js";
7
+
8
+ export interface ThreadPost {
9
+ author: string;
10
+ date: string;
11
+ content: string;
12
+ postNumber: number;
13
+ links: PostLink[];
14
+ images: string[];
15
+ reputation?: AuthorReputation;
16
+ }
17
+
18
+ export interface ThreadData {
19
+ title: string;
20
+ posts: ThreadPost[];
21
+ currentPage: number;
22
+ totalPages: number;
23
+ url: string;
24
+ }
25
+
26
+ export interface CodeBlock {
27
+ code: string;
28
+ language: string;
29
+ context?: string;
30
+ postId?: string;
31
+ }