mcp-unknowncheatz 0.3.0 → 0.3.2

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.
@@ -1,7 +1,9 @@
1
+ import { withBrowserSession } from "../browser.js";
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { z } from "zod";
3
4
  import { fetchHtml } from "../crawl.js";
4
5
  import { parseThread } from "../parsers/thread.js";
6
+ import { getForumIndex } from "../forum-index.js";
5
7
  import type { ThreadPost } from "../types.js";
6
8
 
7
9
  const MAX_PAGES = 50;
@@ -13,11 +15,11 @@ function buildPageUrl(baseUrl: string, page: number): string {
13
15
  return url.toString();
14
16
  }
15
17
 
16
- async function fetchImageAsBase64(url: string): Promise<{ data: string; mimeType: string } | null> {
18
+ async function fetchImageAsBase64(url: string, deadlineAt: number): Promise<{ data: string; mimeType: string } | null> {
17
19
  try {
18
20
  const res = await fetch(url, {
19
21
  headers: { "User-Agent": "Mozilla/5.0" },
20
- signal: AbortSignal.timeout(8_000),
22
+ signal: AbortSignal.timeout(Math.max(1, Math.min(8_000, deadlineAt - Date.now()))),
21
23
  });
22
24
  if (!res.ok) return null;
23
25
 
@@ -36,7 +38,7 @@ async function fetchImageAsBase64(url: string): Promise<{ data: string; mimeType
36
38
  export function registerGetThread(server: McpServer): void {
37
39
  server.tool(
38
40
  "get_thread",
39
- "Get a forum thread. Use latest_pages for recent posts in long-running threads; the default reads only the linked page.",
41
+ "Read the source UnknownCheats thread behind a game hacking or offset claim. Use latest_pages for recent posts in long-running threads; the default reads only the linked page.",
40
42
  {
41
43
  url: z.string().url().describe("Thread URL"),
42
44
  fetch_all_pages: z
@@ -57,9 +59,11 @@ export function registerGetThread(server: McpServer): void {
57
59
  .default(false)
58
60
  .describe("If true, fetches post images and returns them as viewable image content (max 10 images)"),
59
61
  },
60
- async ({ url, fetch_all_pages, latest_pages, include_images }) => {
62
+ { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
63
+ async ({ url, fetch_all_pages, latest_pages, include_images }) => withBrowserSession(async () => {
61
64
  try {
62
- const firstHtml = await fetchHtml(url);
65
+ const deadlineAt = Date.now() + 45_000;
66
+ const firstHtml = await fetchHtml(url, { deadlineAt });
63
67
  const pageParam = Number(new URL(url).searchParams.get("page"));
64
68
  const requestedPage = Number.isInteger(pageParam) && pageParam > 0 ? pageParam : 1;
65
69
  const firstPage = parseThread(firstHtml, url, requestedPage);
@@ -72,20 +76,42 @@ export function registerGetThread(server: McpServer): void {
72
76
  : [Math.min(requestedPage, totalPages)];
73
77
 
74
78
  const allPosts: ThreadPost[] = [];
79
+ const pagesFetched: number[] = [];
80
+ let timeBudgetReached = false;
75
81
  for (const pageNum of pagesToFetch) {
82
+ if (pageNum !== requestedPage && Date.now() >= deadlineAt) {
83
+ timeBudgetReached = true;
84
+ break;
85
+ }
76
86
  const pageUrl = buildPageUrl(url, pageNum);
77
- const html = pageNum === requestedPage ? firstHtml : await fetchHtml(pageUrl);
78
- allPosts.push(...parseThread(html, pageUrl, pageNum).posts);
87
+ let html: string;
88
+ try {
89
+ html = pageNum === requestedPage ? firstHtml : await fetchHtml(pageUrl, { deadlineAt });
90
+ } catch (error) {
91
+ if (Date.now() < deadlineAt) throw error;
92
+ timeBudgetReached = true;
93
+ break;
94
+ }
95
+ const parsed = parseThread(html, pageUrl, pageNum);
96
+ allPosts.push(...parsed.posts);
97
+ getForumIndex().recordThreadPage(parsed, pageNum);
98
+ pagesFetched.push(pageNum);
79
99
  console.error(`[get-thread] Fetched page ${pageNum}/${totalPages}`);
80
100
  }
101
+ if (pagesFetched.length === 0 && firstPage.posts.length > 0) {
102
+ allPosts.push(...firstPage.posts);
103
+ pagesFetched.push(requestedPage);
104
+ getForumIndex().recordThreadPage(firstPage, requestedPage);
105
+ }
81
106
 
82
107
  const result = {
83
108
  title: firstPage.title,
84
109
  posts: allPosts,
85
- currentPage: pagesToFetch.at(-1),
86
- pagesFetched: pagesToFetch,
110
+ currentPage: pagesFetched.at(-1),
111
+ pagesFetched,
87
112
  totalPages,
88
113
  url,
114
+ timeBudgetReached,
89
115
  ...(fetch_all_pages && !latest_pages && totalPages > MAX_PAGES
90
116
  ? { note: `Capped at ${MAX_PAGES} pages (thread has ${totalPages} total)` }
91
117
  : {}),
@@ -95,7 +121,7 @@ export function registerGetThread(server: McpServer): void {
95
121
  const content: Array<
96
122
  | { type: "text"; text: string }
97
123
  | { type: "image"; data: string; mimeType: string }
98
- > = [{ type: "text", text: JSON.stringify(result) }];
124
+ > = [];
99
125
 
100
126
  if (include_images) {
101
127
  // Collect all unique image URLs from all posts
@@ -116,7 +142,11 @@ export function registerGetThread(server: McpServer): void {
116
142
  console.error(`[get-thread] Fetching ${imageUrls.length} images...`);
117
143
 
118
144
  for (const imgUrl of imageUrls) {
119
- const img = await fetchImageAsBase64(imgUrl);
145
+ if (Date.now() >= deadlineAt) {
146
+ result.timeBudgetReached = true;
147
+ break;
148
+ }
149
+ const img = await fetchImageAsBase64(imgUrl, deadlineAt);
120
150
  if (img) {
121
151
  content.push({ type: "image", data: img.data, mimeType: img.mimeType });
122
152
  console.error(`[get-thread] Fetched image: ${imgUrl}`);
@@ -124,6 +154,9 @@ export function registerGetThread(server: McpServer): void {
124
154
  }
125
155
  }
126
156
 
157
+ if (include_images && Date.now() >= deadlineAt) result.timeBudgetReached = true;
158
+ content.unshift({ type: "text", text: JSON.stringify(result) });
159
+
127
160
  return { content };
128
161
  } catch (err) {
129
162
  const message = err instanceof Error ? err.message : String(err);
@@ -132,6 +165,6 @@ export function registerGetThread(server: McpServer): void {
132
165
  isError: true,
133
166
  };
134
167
  }
135
- }
168
+ })
136
169
  );
137
170
  }
@@ -1,3 +1,4 @@
1
+ import { withBrowserSession } from "../browser.js";
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { z } from "zod";
3
4
  import { load } from "cheerio";
@@ -32,7 +33,7 @@ export function registerGetUserReputation(server: McpServer): void {
32
33
  .optional()
33
34
  .describe("Numeric UC user ID (alternative to profile_url)"),
34
35
  },
35
- async ({ profile_url, user_id }) => {
36
+ async ({ profile_url, user_id }) => withBrowserSession(async () => {
36
37
  try {
37
38
  const url = profile_url ?? (user_id
38
39
  ? `https://www.unknowncheats.me/forum/members/${user_id}.html`
@@ -120,6 +121,6 @@ export function registerGetUserReputation(server: McpServer): void {
120
121
  isError: true,
121
122
  };
122
123
  }
123
- }
124
+ })
124
125
  );
125
126
  }
@@ -1,3 +1,4 @@
1
+ import { withBrowserSession } from "../browser.js";
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { z } from "zod";
3
4
  import { fetchHtml } from "../crawl.js";
@@ -12,7 +13,7 @@ export function registerListSubforums(server: McpServer): void {
12
13
  limit: z.number().int().min(1).max(500).optional().default(100).describe("Max subforums returned (default 100)"),
13
14
  refresh: z.boolean().optional().default(false).describe("Fetch the forum index again instead of using the local directory"),
14
15
  },
15
- async ({ query, limit, refresh }) => {
16
+ async ({ query, limit, refresh }) => withBrowserSession(async () => {
16
17
  try {
17
18
  const cached = refresh ? null : await readForumCatalog();
18
19
  const catalog = cached ?? await saveForumCatalog(await fetchHtml(FORUM_INDEX, { bypassCache: refresh }));
@@ -54,6 +55,6 @@ export function registerListSubforums(server: McpServer): void {
54
55
  isError: true,
55
56
  };
56
57
  }
57
- }
58
+ })
58
59
  );
59
60
  }
@@ -1,3 +1,4 @@
1
+ import { withBrowserSession } from "../browser.js";
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { z } from "zod";
3
4
  import { load } from "cheerio";
@@ -15,7 +16,7 @@ export function registerLogin(server: McpServer): void {
15
16
  username: z.string().describe("UnknownCheats username"),
16
17
  password: z.string().describe("UnknownCheats password"),
17
18
  },
18
- async ({ username, password }) => {
19
+ async ({ username, password }) => withBrowserSession(async () => {
19
20
  try {
20
21
  const { page } = await navigateWithRetry(UC_LOGIN);
21
22
 
@@ -66,6 +67,6 @@ export function registerLogin(server: McpServer): void {
66
67
  isError: true,
67
68
  };
68
69
  }
69
- }
70
+ })
70
71
  );
71
72
  }
@@ -1,3 +1,4 @@
1
+ import { withBrowserSession } from "../browser.js";
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { z } from "zod";
3
4
  import { load } from "cheerio";
@@ -6,13 +7,24 @@ import { fetchHtml } from "../crawl.js";
6
7
  import { isLoggedIn } from "../auth.js";
7
8
  import { searchViaSubforums } from "../search-fallback.js";
8
9
  import { filterThreads, parseThreadList } from "../parsers/thread-list.js";
10
+ import { FORUM_INDEX, readForumCatalog, saveForumCatalog } from "../forum-catalog.js";
11
+ import { getForumIndex } from "../forum-index.js";
9
12
 
10
13
  const UC_HOME = "https://www.unknowncheats.me/forum/";
11
14
  const UC_SEARCH = "https://www.unknowncheats.me/forum/search.php";
12
15
 
13
16
  async function runFallbackSearch(query: string) {
14
17
  console.error(`[search] Not logged in — scanning UC subforums for "${query}"`);
15
- const { results, scannedSubforums } = await searchViaSubforums(query, (url) => fetchHtml(url));
18
+ const catalog = await readForumCatalog() ?? await saveForumCatalog(await fetchHtml(FORUM_INDEX));
19
+ const { results, scannedSubforums } = await searchViaSubforums(query, async (url) => {
20
+ const html = await fetchHtml(url);
21
+ const slug = new URL(url).pathname.match(/^\/forum\/([a-z0-9-]+)\/$/)?.[1];
22
+ if (slug) {
23
+ const entries = parseThreadList(html);
24
+ if (entries.length > 0) getForumIndex().recordListing(slug, 1, entries);
25
+ }
26
+ return html;
27
+ }, catalog.subforums);
16
28
 
17
29
  return {
18
30
  count: results.length,
@@ -29,7 +41,7 @@ async function runFallbackSearch(query: string) {
29
41
  export function registerSearchForum(server: McpServer): void {
30
42
  server.tool(
31
43
  "search_forum",
32
- "Search the UnknownCheats forum. Uses advanced search when logged in, scans relevant subforums as a guest, or browses a named subforum.",
44
+ "Use for live UnknownCheats evidence when a user asks about game hacking, cheats, anti-cheat, reversing, offsets, or a forum thread. Uses advanced search when logged in, scans relevant subforums as a guest, or browses a named subforum.",
33
45
  {
34
46
  query: z.string().optional().default("").describe("Search query string; optional when browsing a subforum"),
35
47
  subforum: z.string().optional().describe("Subforum slug to browse directly (e.g. 'apex-legends')"),
@@ -37,7 +49,8 @@ export function registerSearchForum(server: McpServer): void {
37
49
  sort_by: z.enum(["relevancy", "lastpost", "replycount", "views", "threadstart"]).optional().default("relevancy").describe("Sort results by"),
38
50
  search_user: z.string().optional().describe("Filter by thread author username"),
39
51
  },
40
- async ({ query, subforum, title_only, sort_by, search_user }) => {
52
+ { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
53
+ async ({ query, subforum, title_only, sort_by, search_user }) => withBrowserSession(async () => {
41
54
  try {
42
55
  if (!subforum && !query.trim()) {
43
56
  return {
@@ -49,7 +62,9 @@ export function registerSearchForum(server: McpServer): void {
49
62
  if (subforum) {
50
63
  const url = `https://www.unknowncheats.me/forum/${subforum}/`;
51
64
  const html = await fetchHtml(url);
52
- const results = filterThreads(parseThreadList(html), { query, includeSticky: true });
65
+ const entries = parseThreadList(html);
66
+ if (entries.length > 0) getForumIndex().recordListing(subforum, 1, entries);
67
+ const results = filterThreads(entries, { query, includeSticky: true });
53
68
  return {
54
69
  content: [{ type: "text", text: JSON.stringify({ count: results.length, subforum, results }) }],
55
70
  };
@@ -107,6 +122,7 @@ export function registerSearchForum(server: McpServer): void {
107
122
 
108
123
  const html = await page.content();
109
124
  const results = parseThreadList(html);
125
+ if (results.length > 0) getForumIndex().recordSearchResults(results);
110
126
  const $ = load(html);
111
127
  const pageTitle = $("title").text().trim();
112
128
 
@@ -141,6 +157,6 @@ export function registerSearchForum(server: McpServer): void {
141
157
  };
142
158
  }
143
159
  }
144
- }
160
+ })
145
161
  );
146
162
  }