mcp-rule34 0.1.0 → 1.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/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
 
6
6
  // src/version.ts
7
- var PKG_VERSION = "0.1.0";
7
+ var PKG_VERSION = "1.1.0";
8
8
  var REPO_URL = "https://github.com/smeet666/mcp-rule34";
9
9
 
10
10
  // src/config.ts
@@ -121,12 +121,12 @@ function createLogger(level) {
121
121
 
122
122
  // src/server.ts
123
123
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
124
- import { z as z5 } from "zod";
124
+ import { z as z6 } from "zod";
125
125
 
126
126
  // src/errors.ts
127
127
  var Rule34Error = class extends Error {
128
- constructor(code, text, details = {}) {
129
- super(`[${code}] ${text}`);
128
+ constructor(code, text, details = {}, options) {
129
+ super(`[${code}] ${text}`, options);
130
130
  this.code = code;
131
131
  this.details = details;
132
132
  this.name = "Rule34Error";
@@ -135,8 +135,8 @@ var Rule34Error = class extends Error {
135
135
  details;
136
136
  };
137
137
  var ISSUES_URL = "https://github.com/smeet666/mcp-rule34/issues";
138
- function invalidInput(message, hint) {
139
- return new Rule34Error("invalid_input", message, hint ? { hint } : {});
138
+ function invalidInput(message, hint, cause) {
139
+ return new Rule34Error("invalid_input", message, hint ? { hint } : {}, { cause });
140
140
  }
141
141
  function missingCredentials(url) {
142
142
  return new Rule34Error(
@@ -167,11 +167,12 @@ function rateLimited(url, retryAfterMs) {
167
167
  }
168
168
  );
169
169
  }
170
- function parseFailure(url, what) {
170
+ function parseFailure(url, what, cause) {
171
171
  return new Rule34Error(
172
172
  "parse_failure",
173
173
  `The request was answered, but not with the document this route publishes (${what}).`,
174
- { url, hint: `Please report this, with the query you used, at ${ISSUES_URL}` }
174
+ { url, hint: `Please report this, with the query you used, at ${ISSUES_URL}` },
175
+ { cause }
175
176
  );
176
177
  }
177
178
  function upstreamFailure(url, message) {
@@ -295,6 +296,7 @@ function sleep(ms) {
295
296
 
296
297
  // src/rule34/urls.ts
297
298
  var API_URL = "https://api.rule34.xxx/index.php";
299
+ var SUGGEST_URL = "https://api.rule34.xxx/autocomplete.php";
298
300
  var SITE_URL = "https://rule34.xxx/index.php";
299
301
  var MAX_LIMIT = 1e3;
300
302
  var MEDIA_TOKENS = {
@@ -399,10 +401,11 @@ function readPostPageUrl(raw) {
399
401
  let parsed;
400
402
  try {
401
403
  parsed = new URL(raw.trim());
402
- } catch {
404
+ } catch (error) {
403
405
  throw invalidInput(
404
406
  `"${raw}" is not a URL.`,
405
- "Pass a link to a post's page, such as https://rule34.xxx/index.php?page=post&s=view&id=2195419"
407
+ "Pass a link to a post's page, such as https://rule34.xxx/index.php?page=post&s=view&id=2195419",
408
+ error
406
409
  );
407
410
  }
408
411
  const host = parsed.hostname.toLowerCase();
@@ -418,10 +421,24 @@ function readPostPageUrl(raw) {
418
421
  "A post's page looks like https://rule34.xxx/index.php?page=post&s=view&id=2195419"
419
422
  );
420
423
  }
421
- const id = Number(parsed.searchParams.get("id"));
422
- if (!Number.isInteger(id) || id < 1) {
424
+ const named = [...new Set(parsed.searchParams.getAll("id"))];
425
+ if (named.length > 1) {
426
+ throw invalidInput(
427
+ `"${raw}" names ${named.length} different posts: ${named.join(", ")}.`,
428
+ "Pass a link to one post."
429
+ );
430
+ }
431
+ const written = named[0] ?? "";
432
+ if (written === "") {
423
433
  throw invalidInput(`"${raw}" carries no post id.`);
424
434
  }
435
+ const id = Number(written);
436
+ if (!Number.isInteger(id) || id < 1) {
437
+ throw invalidInput(
438
+ `"${raw}" carries '${written}', which is not a post id.`,
439
+ "A post id is a whole number from 1 upwards."
440
+ );
441
+ }
425
442
  return id;
426
443
  }
427
444
  function resolvePostRef(ref) {
@@ -456,6 +473,9 @@ function buildPostByIdJsonUrl(id, credentials) {
456
473
  function credentialsQuery(credentials) {
457
474
  return `&api_key=${encodeURIComponent(credentials.apiKey)}&user_id=${encodeURIComponent(credentials.userId)}`;
458
475
  }
476
+ function buildTagSuggestUrl(text) {
477
+ return `${SUGGEST_URL}?q=${encodeURIComponent(normalizeTag(text))}`;
478
+ }
459
479
  function buildPostPageUrl(id) {
460
480
  return `${SITE_URL}?page=post&s=view&id=${id}`;
461
481
  }
@@ -585,8 +605,8 @@ function parsePostDetail(body, url, id) {
585
605
  let parsed;
586
606
  try {
587
607
  parsed = JSON.parse(body);
588
- } catch {
589
- throw parseFailure(safeUrl, "the response is not JSON");
608
+ } catch (error) {
609
+ throw parseFailure(safeUrl, "the response is not JSON", error);
590
610
  }
591
611
  if (typeof parsed === "string") {
592
612
  if (/authenticat/i.test(parsed)) {
@@ -671,6 +691,41 @@ function readInteger(value) {
671
691
  return Number.isFinite(parsed) ? parsed : null;
672
692
  }
673
693
 
694
+ // src/rule34/parsers/suggestions.ts
695
+ var COUNT_IN_LABEL = /\((\d+)\)\s*$/;
696
+ function parseTagSuggestions(body, url) {
697
+ const safeUrl = redactCredentials(url);
698
+ if (body.trim() === "") {
699
+ throw parseFailure(safeUrl, "the response was empty");
700
+ }
701
+ let parsed;
702
+ try {
703
+ parsed = JSON.parse(body);
704
+ } catch (error) {
705
+ throw parseFailure(safeUrl, "the response is not JSON", error);
706
+ }
707
+ if (!Array.isArray(parsed)) {
708
+ throw parseFailure(safeUrl, "the response is not a list of suggestions");
709
+ }
710
+ const suggestions = [];
711
+ for (const entry of parsed) {
712
+ const raw = entry ?? {};
713
+ const name = typeof raw.value === "string" ? raw.value.trim() : "";
714
+ if (name === "") {
715
+ continue;
716
+ }
717
+ const label = typeof raw.label === "string" ? raw.label : "";
718
+ const counted = COUNT_IN_LABEL.exec(label);
719
+ suggestions.push({
720
+ name,
721
+ // Zero is a number of posts. A label the site printed no count into
722
+ // leaves the count unknown.
723
+ postCount: counted?.[1] === void 0 ? null : Number(counted[1])
724
+ });
725
+ }
726
+ return suggestions;
727
+ }
728
+
674
729
  // src/rule34/parsers/document.ts
675
730
  import { XMLParser } from "fast-xml-parser";
676
731
  var parser = new XMLParser({
@@ -870,6 +925,18 @@ var Rule34Client = class {
870
925
  cached: json.cached && xml.cached
871
926
  };
872
927
  }
928
+ /**
929
+ * The names rule34.xxx offers for a piece of text.
930
+ *
931
+ * This route needs no credentials, so none are required to call it: a server
932
+ * with no key configured can still tell a caller how a tag is spelled, which
933
+ * is the one useful thing it can do.
934
+ */
935
+ async findTags(text) {
936
+ const url = buildTagSuggestUrl(text);
937
+ const { body, cached } = await this.read(url);
938
+ return { data: parseTagSuggestions(body, url), cached };
939
+ }
873
940
  /** The tag under this exact name, or nothing when the site holds no such tag. */
874
941
  async lookupTag(name) {
875
942
  const url = buildTagLookupUrl(name, this.credentials());
@@ -931,7 +998,7 @@ var Rule34Client = class {
931
998
  }
932
999
  };
933
1000
 
934
- // src/tools/getPost.ts
1001
+ // src/tools/findTags.ts
935
1002
  import { z as z3 } from "zod";
936
1003
 
937
1004
  // src/tools/arguments.ts
@@ -947,8 +1014,9 @@ function wholeNumber(argument, min, max) {
947
1014
  return z.number({ error }).int(error).min(min, error).max(max, error);
948
1015
  }
949
1016
  function tagName(argument, most) {
950
- const error = `${CODE} ${argument} takes a tag name, at most ${most} characters. Written empty it narrows nothing.`;
951
- return z.string({ error }).trim().min(1, error).max(most, error);
1017
+ const empty = `${CODE} ${argument} takes a tag name. Written empty, spaces alone included, it narrows nothing.`;
1018
+ const long = `${CODE} ${argument} takes a tag name of at most ${most} characters.`;
1019
+ return z.string({ error: empty }).trim().min(1, empty).max(most, long);
952
1020
  }
953
1021
  function tagNames(argument, least, most) {
954
1022
  const error = `${CODE} ${argument} takes between ${least} and ${most} tag names.`;
@@ -1037,11 +1105,18 @@ var postSchema = z2.object({
1037
1105
  tags: z2.array(z2.string()).describe("The first tags of the post, at most 12 of them."),
1038
1106
  tags_total: z2.number().int().describe("How many tags the post carries in all."),
1039
1107
  source: z2.string().nullable().describe("Whatever the uploader credited, when they credited any."),
1040
- created_at: z2.string().nullable().describe("When the post was published, ISO 8601."),
1108
+ created_at: z2.string().nullable().describe(
1109
+ "When the post was added to rule34.xxx, ISO 8601. The site imported much of its older catalogue in bulk, so thousands of posts share one day."
1110
+ ),
1041
1111
  has_comments: z2.boolean(),
1042
1112
  parent_id: z2.number().int().nullable()
1043
1113
  });
1044
- function toPostOut(post) {
1114
+ function previewTags(tags, asked) {
1115
+ const carried = asked.filter((tag) => tags.includes(tag));
1116
+ const rest = tags.filter((tag) => !carried.includes(tag));
1117
+ return [...carried, ...rest].slice(0, TAG_PREVIEW);
1118
+ }
1119
+ function toPostOut(post, asked = []) {
1045
1120
  return {
1046
1121
  id: post.id,
1047
1122
  post_url: post.postUrl,
@@ -1052,7 +1127,7 @@ function toPostOut(post) {
1052
1127
  height: post.height,
1053
1128
  score: post.score,
1054
1129
  rating: post.rating,
1055
- tags: post.tags.slice(0, TAG_PREVIEW),
1130
+ tags: previewTags(post.tags, asked),
1056
1131
  tags_total: post.tags.length,
1057
1132
  source: post.source,
1058
1133
  created_at: post.createdAt,
@@ -1100,48 +1175,107 @@ function renderPostList(posts) {
1100
1175
  }).join("\n");
1101
1176
  }
1102
1177
 
1178
+ // src/tools/findTags.ts
1179
+ var SUGGEST_CAP = 10;
1180
+ var findTagsDescription = [
1181
+ "Find how rule34.xxx spells a tag, and how many posts carry it.",
1182
+ "The site matches from the start of a name, so 'kimagure' finds 'kimagure_orange_road'",
1183
+ "while 'orange road' finds nothing. It offers at most ten names.",
1184
+ "Use this before searching when a name might be spelled differently, and after a search that found",
1185
+ "nothing because a tag does not exist."
1186
+ ].join(" ");
1187
+ var findTagsInput = strictInput({
1188
+ query: tagName("query", 60).describe(
1189
+ "The start of a tag name, such as 'kimagure' or 'prince of tennis'. Spaces are joined with underscores."
1190
+ )
1191
+ });
1192
+ var findTagsOutputShape = {
1193
+ query: z3.string().describe("The text as rule34.xxx would spell it."),
1194
+ tags: z3.array(
1195
+ z3.object({
1196
+ name: z3.string().describe("The name a search takes."),
1197
+ post_count: z3.number().int().nullable().describe("Posts the site credits to it, or nothing when it printed no count.")
1198
+ })
1199
+ ),
1200
+ source: z3.literal("rule34.xxx"),
1201
+ notes: z3.array(z3.string())
1202
+ };
1203
+ async function runFindTags(client, args) {
1204
+ try {
1205
+ const { data, cached } = await client.findTags(args.query);
1206
+ const notes = cached ? ["Served from this server's short-lived in-memory cache."] : [];
1207
+ if (data.length === 0) {
1208
+ notes.push(
1209
+ "rule34.xxx offers no name for this text. It matches from the start of a name, so a word sitting inside a tag finds nothing here; try the words a name begins with."
1210
+ );
1211
+ } else if (data.length >= SUGGEST_CAP) {
1212
+ notes.push(
1213
+ "rule34.xxx caps this list at ten names, and it returned ten, so more may begin the same way. Type more of the name to narrow it."
1214
+ );
1215
+ }
1216
+ const asked = normalizeTag(args.query);
1217
+ const structured = {
1218
+ query: asked,
1219
+ tags: data.map((tag) => ({ name: tag.name, post_count: tag.postCount })),
1220
+ source: "rule34.xxx",
1221
+ notes
1222
+ };
1223
+ const body = data.length === 0 ? `rule34.xxx offers no tag name beginning with ${asked}.` : `${data.length} name(s) rule34.xxx offers for ${asked}:
1224
+ ${data.map(
1225
+ (tag) => ` ${tag.name}${tag.postCount === null ? "" : ` (${tag.postCount} posts)`}`
1226
+ ).join("\n")}`;
1227
+ return ok(structured, body, notes);
1228
+ } catch (error) {
1229
+ return toToolError(error);
1230
+ }
1231
+ }
1232
+
1103
1233
  // src/tools/getPost.ts
1234
+ import { z as z4 } from "zod";
1104
1235
  var getPostDescription = [
1105
1236
  "Read one rule34.xxx post, named by its id or by a link to its page.",
1106
1237
  "Returns every tag the post carries, each with its kind (character, copyright, artist, general or",
1107
1238
  "metadata) and how many posts share it, alongside the image or video, the dimensions, the score,",
1108
- "the rating, the uploader, the number of comments, the credited source and the publication date.",
1239
+ "the rating, the uploader, the number of comments, the credited source and the date the site",
1240
+ "took the post in.",
1109
1241
  "Use this after a search, whose rows show only the first few tags of each post."
1110
1242
  ].join(" ");
1111
1243
  var getPostInput = strictInput({
1112
1244
  id: wholeNumber("id", 1, Number.MAX_SAFE_INTEGER).optional().describe("The post's numeric id, as a search returns it."),
1113
- url: z3.string().max(500).optional().describe(
1245
+ url: z4.string().max(500).optional().describe(
1114
1246
  "A link to the post's page, such as https://rule34.xxx/index.php?page=post&s=view&id=2195419. The link is read rather than followed."
1115
1247
  )
1116
1248
  });
1117
1249
  var getPostOutputShape = {
1118
- id: z3.number().int(),
1119
- post_url: z3.string(),
1120
- file_url: z3.string(),
1121
- preview_url: z3.string().nullable(),
1122
- sample_url: z3.string().nullable(),
1123
- width: z3.number().int(),
1124
- height: z3.number().int(),
1125
- md5: z3.string(),
1126
- score: z3.number().int().describe("The site's own score, updated once a day."),
1127
- rating: z3.string(),
1128
- tags: z3.array(
1129
- z3.object({
1130
- name: z3.string(),
1131
- type: z3.string().describe("character, copyright, artist, general or metadata."),
1132
- post_count: z3.number().int().describe("Posts the site credits to this tag.")
1250
+ id: z4.number().int(),
1251
+ post_url: z4.string(),
1252
+ file_url: z4.string(),
1253
+ preview_url: z4.string().nullable(),
1254
+ sample_url: z4.string().nullable(),
1255
+ width: z4.number().int(),
1256
+ height: z4.number().int(),
1257
+ md5: z4.string(),
1258
+ score: z4.number().int().describe("The site's own score, updated once a day."),
1259
+ rating: z4.string(),
1260
+ tags: z4.array(
1261
+ z4.object({
1262
+ name: z4.string(),
1263
+ type: z4.string().describe("character, copyright, artist, general or metadata."),
1264
+ post_count: z4.number().int().describe("Posts the site credits to this tag.")
1133
1265
  })
1134
1266
  ),
1135
- source: z3.string().nullable(),
1136
- parent_id: z3.number().int().nullable(),
1137
- owner: z3.string().nullable().describe("The account that uploaded the post."),
1138
- comment_count: z3.number().int().nullable(),
1139
- created_at: z3.string().nullable().describe("When the post was published, ISO 8601."),
1140
- changed_at: z3.string().nullable().describe("When the post last changed, ISO 8601."),
1141
- status: z3.string(),
1142
- has_notes: z3.boolean(),
1143
- source_site: z3.literal("rule34.xxx"),
1144
- notes: z3.array(z3.string())
1267
+ source: z4.string().nullable(),
1268
+ parent_id: z4.number().int().nullable(),
1269
+ owner: z4.string().nullable().describe("The account that uploaded the post."),
1270
+ comment_count: z4.number().int().nullable(),
1271
+ created_at: z4.string().nullable().describe(
1272
+ "When the post was added to rule34.xxx, ISO 8601. The site imported much of its older catalogue in bulk, so thousands of posts share one day."
1273
+ ),
1274
+ changed_at: z4.string().nullable().describe("When the post last changed, ISO 8601."),
1275
+ status: z4.string(),
1276
+ has_notes: z4.boolean(),
1277
+ source_site: z4.literal("rule34.xxx"),
1278
+ notes: z4.array(z4.string())
1145
1279
  };
1146
1280
  async function runGetPost(client, args) {
1147
1281
  try {
@@ -1189,8 +1323,8 @@ function render(post) {
1189
1323
  ` ${post.post_url}`,
1190
1324
  ` ${post.file_url}`
1191
1325
  ];
1192
- const posted = post.created_at ? post.created_at.slice(0, 10) : "an unstated date";
1193
- lines.push(` posted ${posted}${post.owner ? ` by ${post.owner}` : ""}`);
1326
+ const added = post.created_at ? post.created_at.slice(0, 10) : "an unstated date";
1327
+ lines.push(` added to rule34.xxx ${added}${post.owner ? ` by ${post.owner}` : ""}`);
1194
1328
  if (post.comment_count !== null) {
1195
1329
  lines.push(` ${post.comment_count} comment(s)`);
1196
1330
  }
@@ -1206,7 +1340,7 @@ function render(post) {
1206
1340
  }
1207
1341
 
1208
1342
  // src/tools/searchPosts.ts
1209
- import { z as z4 } from "zod";
1343
+ import { z as z5 } from "zod";
1210
1344
  var searchPostsDescription = [
1211
1345
  "Search rule34.xxx for posts carrying a tag, or several tags at once.",
1212
1346
  "Tags are written as one token, so a name said as words is joined by underscores:",
@@ -1236,22 +1370,24 @@ var searchPostsInput = strictInput({
1236
1370
  ["questionable", "explicit"],
1237
1371
  "These are the two ratings rule34.xxx holds; any other answers zero posts and no error, which would hand back an absence the site never had."
1238
1372
  ).optional().describe("The two ratings rule34.xxx holds. Leave unset to search both."),
1239
- sort: choice("sort", ["score", "id", "updated", "random"], "The order the site sorts by.").default("score").describe("'score' is the site's own, updated once a day. 'id' is newest first."),
1373
+ sort: choice("sort", ["score", "id", "updated", "random"], "The order the site sorts by.").default("score").describe(
1374
+ "'score' is the site's own tally of votes, recomputed once a day. 'id' is newest first. 'updated' is by when a post last changed, which retagging moves. 'random' draws a different set on each call."
1375
+ ),
1240
1376
  limit: wholeNumber("limit", 1, 100).default(20),
1241
1377
  page: wholeNumber("page", 1, MAX_PAGE).default(1)
1242
1378
  });
1243
1379
  var searchPostsOutputShape = {
1244
- tags: z4.array(z4.string()).describe("The required tags, as rule34.xxx spells them."),
1245
- any_of: z4.array(z4.string()).describe("The alternatives, as rule34.xxx spells them."),
1246
- exclude: z4.array(z4.string()).describe("The excluded tags, as rule34.xxx spells them."),
1247
- query: z4.string().describe("The search as it was sent, in the site's own language."),
1248
- total: z4.number().int().describe("Posts the whole search matches, as counted by the site."),
1249
- page: z4.number().int(),
1250
- posts: z4.array(postSchema),
1251
- has_more: z4.boolean(),
1252
- next_page: z4.number().int().nullable(),
1253
- source: z4.literal("rule34.xxx"),
1254
- notes: z4.array(z4.string())
1380
+ tags: z5.array(z5.string()).describe("The required tags, as rule34.xxx spells them."),
1381
+ any_of: z5.array(z5.string()).describe("The alternatives, as rule34.xxx spells them."),
1382
+ exclude: z5.array(z5.string()).describe("The excluded tags, as rule34.xxx spells them."),
1383
+ query: z5.string().describe("The search as it was sent, in the site's own language."),
1384
+ total: z5.number().int().describe("Posts the whole search matches, as counted by the site."),
1385
+ page: z5.number().int(),
1386
+ posts: z5.array(postSchema),
1387
+ has_more: z5.boolean(),
1388
+ next_page: z5.number().int().nullable(),
1389
+ source: z5.literal("rule34.xxx"),
1390
+ notes: z5.array(z5.string())
1255
1391
  };
1256
1392
  function toSearch(args) {
1257
1393
  return {
@@ -1265,20 +1401,27 @@ function toSearch(args) {
1265
1401
  page: args.page
1266
1402
  };
1267
1403
  }
1268
- function whatFollowsThisPage(hasMore, page) {
1269
- if (!hasMore) {
1270
- return "";
1271
- }
1272
- if (page < MAX_PAGE) {
1273
- return `
1274
-
1275
- More posts match: call again with page=${page + 1}.`;
1276
- }
1277
- return `
1404
+ function lastPageOf(total, limit) {
1405
+ return total === 0 ? 1 : Math.ceil(total / limit);
1406
+ }
1407
+ function whatFollowsThisPage(nextPage) {
1408
+ return nextPage === null ? "" : `
1278
1409
 
1279
- rule34.xxx holds more, and page ${MAX_PAGE} is as far as this tool reads. Narrow the search instead.`;
1410
+ More posts match: call again with page=${nextPage}.`;
1280
1411
  }
1281
1412
  var quoted = (tags) => tags.map((tag) => `'${tag}'`).join(", ");
1413
+ function openingOf(name) {
1414
+ return name.slice(0, Math.max(3, Math.floor(name.length * 0.6)));
1415
+ }
1416
+ async function namesLike(client, unknown) {
1417
+ try {
1418
+ const { data } = await client.findTags(openingOf(unknown));
1419
+ const offered = data.slice(0, 3).map((tag) => `'${tag.name}'${tag.postCount === null ? "" : ` (${tag.postCount} posts)`}`);
1420
+ return offered.length === 0 ? "" : ` It does offer ${offered.join(", ")}.`;
1421
+ } catch {
1422
+ return "";
1423
+ }
1424
+ }
1282
1425
  function restrictionsIn(args) {
1283
1426
  const named = [];
1284
1427
  if ((args.any_of ?? []).length > 0) {
@@ -1299,9 +1442,11 @@ async function explainEmptySearch(client, args) {
1299
1442
  const alternatives = args.any_of ?? [];
1300
1443
  const unknownRequired = await client.findUnknownTags(args.tags);
1301
1444
  const unknownAlternatives = alternatives.length > 0 ? await client.findUnknownTags(alternatives) : [];
1302
- if (unknownRequired.length > 0) {
1445
+ const [firstUnknown] = unknownRequired;
1446
+ if (firstUnknown !== void 0) {
1447
+ const near = await namesLike(client, firstUnknown);
1303
1448
  return [
1304
- `rule34.xxx holds no tag named ${quoted(unknownRequired)}. A tag a search requires and the site does not hold empties that search on its own.`
1449
+ `rule34.xxx holds no tag named ${quoted(unknownRequired)}. A tag a search requires and the site does not hold empties that search on its own.` + near
1305
1450
  ];
1306
1451
  }
1307
1452
  if (alternatives.length > 0 && unknownAlternatives.length === alternatives.length) {
@@ -1330,14 +1475,27 @@ async function runSearchPosts(client, args) {
1330
1475
  if (cached) {
1331
1476
  notes.push("Served from this server's short-lived in-memory cache.");
1332
1477
  }
1333
- const posts = data.posts.map(toPostOut);
1478
+ const asked = [...spelled.required, ...spelled.alternatives];
1479
+ const posts = data.posts.map((post) => toPostOut(post, asked));
1334
1480
  const trimmed = posts.filter((post) => post.tags_total > post.tags.length).length;
1335
1481
  if (trimmed > 0) {
1336
1482
  notes.push(
1337
1483
  `${trimmed} of these posts carry more tags than a row shows. Call get_post with an id for the whole list, with a kind and a post count for every tag.`
1338
1484
  );
1339
1485
  }
1340
- const hasMore = args.page * args.limit < data.total;
1486
+ const lastPage = lastPageOf(data.total, args.limit);
1487
+ const hasMore = args.page < lastPage;
1488
+ const nextPage = hasMore && args.page < MAX_PAGE ? args.page + 1 : null;
1489
+ if (data.total > 0 && args.page > lastPage) {
1490
+ notes.push(
1491
+ `This search fills ${lastPage} page(s) at limit ${args.limit}, and page ${args.page} lies past the end, so no post is served.`
1492
+ );
1493
+ }
1494
+ if (hasMore && nextPage === null) {
1495
+ notes.push(
1496
+ `rule34.xxx holds more posts, and page ${MAX_PAGE} is as far as this tool reads. Raise limit to carry more of them per page, or narrow the search.`
1497
+ );
1498
+ }
1341
1499
  if (data.total === 0) {
1342
1500
  notes.push(...await explainEmptySearch(client, args));
1343
1501
  }
@@ -1350,14 +1508,14 @@ async function runSearchPosts(client, args) {
1350
1508
  page: args.page,
1351
1509
  posts,
1352
1510
  has_more: hasMore,
1353
- next_page: hasMore && args.page < MAX_PAGE ? args.page + 1 : null,
1511
+ next_page: nextPage,
1354
1512
  source: "rule34.xxx",
1355
1513
  notes
1356
1514
  };
1357
1515
  const header = data.total === 0 ? `No post on rule34.xxx matches ${query}.` : `${data.total.toLocaleString("en-US")} post(s) match ${query}. Page ${args.page}:`;
1358
1516
  const body = posts.length > 0 ? `
1359
1517
  ${renderPostList(posts)}` : "";
1360
- return ok(structured, `${header}${body}${whatFollowsThisPage(hasMore, args.page)}`, notes);
1518
+ return ok(structured, `${header}${body}${whatFollowsThisPage(nextPage)}`, notes);
1361
1519
  } catch (error) {
1362
1520
  return toToolError(error);
1363
1521
  }
@@ -1381,7 +1539,7 @@ function createServer(options = {}) {
1381
1539
  const server = new McpServer(
1382
1540
  { name: "mcp-rule34", version: PKG_VERSION },
1383
1541
  {
1384
- instructions: "Tools for searching rule34.xxx by tag, through the site's own API. This server reads and never contributes anything back. It needs credentials: set RULE34_USER_ID and RULE34_API_KEY, both shown under 'API Access Credentials' at https://rule34.xxx/index.php?page=account&s=options once signed in, and note that the site issues one key per person. Tags are single tokens: a name said as words, such as 'asuka langley sohryu', is one tag, and the tools join it for you. The site holds two ratings, 'questionable' and 'explicit'; asking for anything else is refused rather than answered with nothing. Typical flow: search_posts to find posts by tag, then get_post with an id for one post's whole tag list, since a search row shows only its first few tags. A 'rate_limited' error means the site is refusing this client for now, not that a search found nothing. When you show a post to a user, link its rule34.xxx page."
1542
+ instructions: "Tools for searching rule34.xxx by tag, through the site's own API. This server reads and never contributes anything back. It needs credentials: set RULE34_USER_ID and RULE34_API_KEY, both shown under 'API Access Credentials' at https://rule34.xxx/index.php?page=account&s=options once signed in, and note that the site issues one key per person. Tags are single tokens: a name said as words, such as 'asuka langley sohryu', is one tag, and the tools join it for you. The site holds two ratings, 'questionable' and 'explicit'; asking for anything else is refused rather than answered with nothing. Typical flow: search_posts to find posts by tag, then get_post with an id for one post's whole tag list, since a search row shows only its first few tags. When a name might be spelled otherwise, or when a search found nothing because a tag does not exist, find_tags says how the site spells it. A 'rate_limited' error means the site is refusing this client for now, not that a search found nothing. When you show a post to a user, link its rule34.xxx page."
1385
1543
  }
1386
1544
  );
1387
1545
  server.registerTool(
@@ -1390,7 +1548,7 @@ function createServer(options = {}) {
1390
1548
  title: "Search posts by tag",
1391
1549
  description: searchPostsDescription,
1392
1550
  inputSchema: searchPostsInput,
1393
- outputSchema: z5.object(searchPostsOutputShape),
1551
+ outputSchema: z6.object(searchPostsOutputShape),
1394
1552
  annotations: READ_ONLY
1395
1553
  },
1396
1554
  async (args) => runSearchPosts(client, args)
@@ -1401,11 +1559,22 @@ function createServer(options = {}) {
1401
1559
  title: "Read one post",
1402
1560
  description: getPostDescription,
1403
1561
  inputSchema: getPostInput,
1404
- outputSchema: z5.object(getPostOutputShape),
1562
+ outputSchema: z6.object(getPostOutputShape),
1405
1563
  annotations: READ_ONLY
1406
1564
  },
1407
1565
  async (args) => runGetPost(client, args)
1408
1566
  );
1567
+ server.registerTool(
1568
+ "find_tags",
1569
+ {
1570
+ title: "Find how a tag is spelled",
1571
+ description: findTagsDescription,
1572
+ inputSchema: findTagsInput,
1573
+ outputSchema: z6.object(findTagsOutputShape),
1574
+ annotations: READ_ONLY
1575
+ },
1576
+ async (args) => runFindTags(client, args)
1577
+ );
1409
1578
  logger.info(
1410
1579
  `ready: user-agent="${config.userAgent}", min interval ${config.minIntervalMs}ms, cache ${config.cacheTtlMs}ms, credentials ${config.credentials ? "set" : "missing"}`
1411
1580
  );
@@ -1420,7 +1589,7 @@ async function main() {
1420
1589
  const transport = new StdioServerTransport();
1421
1590
  await server.connect(transport);
1422
1591
  const shutdown = () => {
1423
- void server.close().finally(() => process.exit(0));
1592
+ server.close().catch(() => void 0).finally(() => process.exit(0));
1424
1593
  };
1425
1594
  process.on("SIGINT", shutdown);
1426
1595
  process.on("SIGTERM", shutdown);