mcp-rule34 0.1.0 → 1.0.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
@@ -76,10 +76,23 @@ it knows the site will refuse.
76
76
 
77
77
  ## Tools
78
78
 
79
- | Tool | What it does | Key parameters |
80
- | -------------- | ------------------------------------------------ | ---------------------------------------------------------------------------- |
81
- | `search_posts` | Finds posts carrying a tag, or several at once. | `tags`, `any_of`, `exclude`, `media_type`, `rating`, `sort`, `limit`, `page` |
82
- | `get_post` | Reads one post whole, with a kind for every tag. | `id`, `url` |
79
+ | Tool | What it does | Key parameters |
80
+ | -------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------- |
81
+ | `search_posts` | Finds posts carrying a tag, or several at once. | `tags`, `any_of`, `exclude`, `media_type`, `rating`, `sort`, `limit`, `page` |
82
+ | `get_post` | Reads one post whole, with a kind for every tag. | `id`, `url` |
83
+ | `find_tags` | Finds how the site spells a tag, and how many posts carry it. | `query` |
84
+
85
+ ### Finding how a tag is spelled
86
+
87
+ `find_tags` asks rule34.xxx which names begin with a piece of text, and how many
88
+ posts each one carries. It matches from the start of a name, so `kimagure` finds
89
+ `kimagure_orange_road` while a word sitting in the middle of a name finds
90
+ nothing. The site offers at most ten names and the answer says so, since ten
91
+ back means the list was cut rather than exhausted.
92
+
93
+ This is also what a search that found nothing falls back on: when a required tag
94
+ does not exist, the answer names it and offers the names the site does hold that
95
+ begin like it.
83
96
 
84
97
  ### A search names posts, a post is then read whole
85
98
 
@@ -91,12 +104,12 @@ first twelve and states how many the post carries in all.
91
104
  `get_post` reads one post by its id, or by a link to its page, and returns every
92
105
  tag with its kind, character, copyright, artist, general or metadata, and how
93
106
  many posts share it, alongside the uploader, the number of comments and the
94
- publication date. A link is read rather than followed: this server builds its
107
+ date the site took it in. A link is read rather than followed: this server builds its
95
108
  own request from the id it finds in it.
96
109
 
97
110
  Reading one post costs two requests, because neither format the API publishes
98
- carries everything: the publication date comes from one, and the uploader's
99
- name, the comment count and the tag kinds from the other.
111
+ carries everything: the date the site took the post in comes from one, and the
112
+ uploader's name, the comment count and the tag kinds from the other.
100
113
 
101
114
  ### Tags are single tokens
102
115
 
@@ -156,11 +169,13 @@ Every answer carries the total the site counted for the whole search, the page
156
169
  that was served, and the search as it was sent in the site's own language. A
157
170
  post carries its rule34.xxx page, its file, its thumbnail, its dimensions, its
158
171
  score, its rating, its tags, whatever the uploader credited as a source, and the
159
- date it was published.
172
+ date rule34.xxx took it in.
160
173
 
161
- Two dates exist on a post and only one of them is publication: a post first
162
- uploaded in 2016 and retagged last spring carries both, and this server reports
163
- the first.
174
+ A post carries two dates, and neither one is when its subject was made. The
175
+ first is the day the site took the post in, the second the day the post last
176
+ changed, which retagging moves. rule34.xxx imported much of its older catalogue
177
+ in bulk, so thousands of posts share one day in November 2010: a question about
178
+ what came first is answered by the import, not by the work.
164
179
 
165
180
  ## What this server does not do
166
181
 
@@ -281,10 +296,23 @@ une requête dont il sait que le site la refusera.
281
296
 
282
297
  ## Les outils
283
298
 
284
- | Outil | Ce qu'il fait | Paramètres principaux |
285
- | -------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------- |
286
- | `search_posts` | Trouve les posts portant un tag, ou plusieurs. | `tags`, `any_of`, `exclude`, `media_type`, `rating`, `sort`, `limit`, `page` |
287
- | `get_post` | Lit un post en entier, avec le type de chacun de ses tags. | `id`, `url` |
299
+ | Outil | Ce qu'il fait | Paramètres principaux |
300
+ | -------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
301
+ | `search_posts` | Trouve les posts portant un tag, ou plusieurs. | `tags`, `any_of`, `exclude`, `media_type`, `rating`, `sort`, `limit`, `page` |
302
+ | `get_post` | Lit un post en entier, avec le type de chacun de ses tags. | `id`, `url` |
303
+ | `find_tags` | Trouve comment le site écrit un tag, et combien de posts le portent. | `query` |
304
+
305
+ ### Trouver comment un tag s'écrit
306
+
307
+ `find_tags` demande à rule34.xxx quels noms commencent par un texte, et combien
308
+ de posts chacun porte. La correspondance part du début d'un nom, donc `kimagure`
309
+ trouve `kimagure_orange_road` là où un mot situé au milieu d'un nom ne trouve
310
+ rien. Le site propose au plus dix noms et la réponse le dit, puisque dix en
311
+ retour signifie une liste coupée plutôt qu'épuisée.
312
+
313
+ C'est aussi le repli d'une recherche qui n'a rien trouvé : quand un tag requis
314
+ n'existe pas, la réponse le nomme et propose les noms que le site détient et qui
315
+ commencent comme lui.
288
316
 
289
317
  ### La recherche nomme les posts, la fiche les lit en entier
290
318
 
@@ -297,12 +325,13 @@ au total.
297
325
  `get_post` lit un post par son identifiant, ou par un lien vers sa page, et rend
298
326
  chaque tag avec son type, personnage, copyright, artiste, général ou métadonnée,
299
327
  et le nombre de posts qui le partagent, avec le déposant, le nombre de
300
- commentaires et la date de publication. Un lien est lu et non suivi : ce serveur
328
+ commentaires et la date à laquelle le site l'a pris. Un lien est lu et non suivi : ce serveur
301
329
  construit sa propre requête à partir de l'identifiant qu'il y trouve.
302
330
 
303
331
  Lire une fiche coûte deux requêtes, parce qu'aucun des deux formats publiés par
304
- l'API ne porte tout : la date de publication vient de l'un, et le nom du
305
- déposant, le nombre de commentaires et les types de tags viennent de l'autre.
332
+ l'API ne porte tout : la date à laquelle le site a pris le post vient de l'un,
333
+ et le nom du déposant, le nombre de commentaires et les types de tags viennent
334
+ de l'autre.
306
335
 
307
336
  ### Un tag est un seul mot
308
337
 
@@ -357,11 +386,14 @@ Chaque réponse porte le total compté par le site pour la recherche entière, l
357
386
  page servie, et la requête telle qu'elle a été envoyée dans le langage du site.
358
387
  Un post porte sa page rule34.xxx, son fichier, sa vignette, ses dimensions, son
359
388
  score, sa classification, ses tags, la source créditée par le déposant quand il
360
- en a crédité une, et sa date de publication.
361
-
362
- Un post porte deux dates et une seule est la publication : une image déposée en
363
- 2016 et retaguée au printemps dernier porte les deux, et ce serveur rend la
364
- première.
389
+ en a crédité une, et la date à laquelle rule34.xxx l'a pris.
390
+
391
+ Un post porte deux dates, et aucune n'est celle où son sujet a été fait. La
392
+ première est le jour où le site a pris le post, la seconde le jour où il a
393
+ changé pour la dernière fois, que le retaguage déplace. rule34.xxx a importé en
394
+ masse une grande partie de son fonds ancien, donc des milliers de posts portent
395
+ un même jour de novembre 2010 : une question sur ce qui vient en premier est
396
+ répondue par l'import, jamais par l'œuvre.
365
397
 
366
398
  ## Ce que ce serveur ne fait pas
367
399
 
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.0.0";
8
8
  var REPO_URL = "https://github.com/smeet666/mcp-rule34";
9
9
 
10
10
  // src/config.ts
@@ -121,7 +121,7 @@ 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 {
@@ -295,6 +295,7 @@ function sleep(ms) {
295
295
 
296
296
  // src/rule34/urls.ts
297
297
  var API_URL = "https://api.rule34.xxx/index.php";
298
+ var SUGGEST_URL = "https://api.rule34.xxx/autocomplete.php";
298
299
  var SITE_URL = "https://rule34.xxx/index.php";
299
300
  var MAX_LIMIT = 1e3;
300
301
  var MEDIA_TOKENS = {
@@ -418,10 +419,24 @@ function readPostPageUrl(raw) {
418
419
  "A post's page looks like https://rule34.xxx/index.php?page=post&s=view&id=2195419"
419
420
  );
420
421
  }
421
- const id = Number(parsed.searchParams.get("id"));
422
- if (!Number.isInteger(id) || id < 1) {
422
+ const named = [...new Set(parsed.searchParams.getAll("id"))];
423
+ if (named.length > 1) {
424
+ throw invalidInput(
425
+ `"${raw}" names ${named.length} different posts: ${named.join(", ")}.`,
426
+ "Pass a link to one post."
427
+ );
428
+ }
429
+ const written = named[0] ?? "";
430
+ if (written === "") {
423
431
  throw invalidInput(`"${raw}" carries no post id.`);
424
432
  }
433
+ const id = Number(written);
434
+ if (!Number.isInteger(id) || id < 1) {
435
+ throw invalidInput(
436
+ `"${raw}" carries '${written}', which is not a post id.`,
437
+ "A post id is a whole number from 1 upwards."
438
+ );
439
+ }
425
440
  return id;
426
441
  }
427
442
  function resolvePostRef(ref) {
@@ -456,6 +471,9 @@ function buildPostByIdJsonUrl(id, credentials) {
456
471
  function credentialsQuery(credentials) {
457
472
  return `&api_key=${encodeURIComponent(credentials.apiKey)}&user_id=${encodeURIComponent(credentials.userId)}`;
458
473
  }
474
+ function buildTagSuggestUrl(text) {
475
+ return `${SUGGEST_URL}?q=${encodeURIComponent(normalizeTag(text))}`;
476
+ }
459
477
  function buildPostPageUrl(id) {
460
478
  return `${SITE_URL}?page=post&s=view&id=${id}`;
461
479
  }
@@ -671,6 +689,41 @@ function readInteger(value) {
671
689
  return Number.isFinite(parsed) ? parsed : null;
672
690
  }
673
691
 
692
+ // src/rule34/parsers/suggestions.ts
693
+ var COUNT_IN_LABEL = /\((\d+)\)\s*$/;
694
+ function parseTagSuggestions(body, url) {
695
+ const safeUrl = redactCredentials(url);
696
+ if (body.trim() === "") {
697
+ throw parseFailure(safeUrl, "the response was empty");
698
+ }
699
+ let parsed;
700
+ try {
701
+ parsed = JSON.parse(body);
702
+ } catch {
703
+ throw parseFailure(safeUrl, "the response is not JSON");
704
+ }
705
+ if (!Array.isArray(parsed)) {
706
+ throw parseFailure(safeUrl, "the response is not a list of suggestions");
707
+ }
708
+ const suggestions = [];
709
+ for (const entry of parsed) {
710
+ const raw = entry ?? {};
711
+ const name = typeof raw.value === "string" ? raw.value.trim() : "";
712
+ if (name === "") {
713
+ continue;
714
+ }
715
+ const label = typeof raw.label === "string" ? raw.label : "";
716
+ const counted = COUNT_IN_LABEL.exec(label);
717
+ suggestions.push({
718
+ name,
719
+ // Zero is a number of posts. A label the site printed no count into
720
+ // leaves the count unknown.
721
+ postCount: counted?.[1] === void 0 ? null : Number(counted[1])
722
+ });
723
+ }
724
+ return suggestions;
725
+ }
726
+
674
727
  // src/rule34/parsers/document.ts
675
728
  import { XMLParser } from "fast-xml-parser";
676
729
  var parser = new XMLParser({
@@ -870,6 +923,18 @@ var Rule34Client = class {
870
923
  cached: json.cached && xml.cached
871
924
  };
872
925
  }
926
+ /**
927
+ * The names rule34.xxx offers for a piece of text.
928
+ *
929
+ * This route needs no credentials, so none are required to call it: a server
930
+ * with no key configured can still tell a caller how a tag is spelled, which
931
+ * is the one useful thing it can do.
932
+ */
933
+ async findTags(text) {
934
+ const url = buildTagSuggestUrl(text);
935
+ const { body, cached } = await this.read(url);
936
+ return { data: parseTagSuggestions(body, url), cached };
937
+ }
873
938
  /** The tag under this exact name, or nothing when the site holds no such tag. */
874
939
  async lookupTag(name) {
875
940
  const url = buildTagLookupUrl(name, this.credentials());
@@ -931,7 +996,7 @@ var Rule34Client = class {
931
996
  }
932
997
  };
933
998
 
934
- // src/tools/getPost.ts
999
+ // src/tools/findTags.ts
935
1000
  import { z as z3 } from "zod";
936
1001
 
937
1002
  // src/tools/arguments.ts
@@ -947,8 +1012,9 @@ function wholeNumber(argument, min, max) {
947
1012
  return z.number({ error }).int(error).min(min, error).max(max, error);
948
1013
  }
949
1014
  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);
1015
+ const empty = `${CODE} ${argument} takes a tag name. Written empty, spaces alone included, it narrows nothing.`;
1016
+ const long = `${CODE} ${argument} takes a tag name of at most ${most} characters.`;
1017
+ return z.string({ error: empty }).trim().min(1, empty).max(most, long);
952
1018
  }
953
1019
  function tagNames(argument, least, most) {
954
1020
  const error = `${CODE} ${argument} takes between ${least} and ${most} tag names.`;
@@ -1037,11 +1103,18 @@ var postSchema = z2.object({
1037
1103
  tags: z2.array(z2.string()).describe("The first tags of the post, at most 12 of them."),
1038
1104
  tags_total: z2.number().int().describe("How many tags the post carries in all."),
1039
1105
  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."),
1106
+ created_at: z2.string().nullable().describe(
1107
+ "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."
1108
+ ),
1041
1109
  has_comments: z2.boolean(),
1042
1110
  parent_id: z2.number().int().nullable()
1043
1111
  });
1044
- function toPostOut(post) {
1112
+ function previewTags(tags, asked) {
1113
+ const carried = asked.filter((tag) => tags.includes(tag));
1114
+ const rest = tags.filter((tag) => !carried.includes(tag));
1115
+ return [...carried, ...rest].slice(0, TAG_PREVIEW);
1116
+ }
1117
+ function toPostOut(post, asked = []) {
1045
1118
  return {
1046
1119
  id: post.id,
1047
1120
  post_url: post.postUrl,
@@ -1052,7 +1125,7 @@ function toPostOut(post) {
1052
1125
  height: post.height,
1053
1126
  score: post.score,
1054
1127
  rating: post.rating,
1055
- tags: post.tags.slice(0, TAG_PREVIEW),
1128
+ tags: previewTags(post.tags, asked),
1056
1129
  tags_total: post.tags.length,
1057
1130
  source: post.source,
1058
1131
  created_at: post.createdAt,
@@ -1100,48 +1173,107 @@ function renderPostList(posts) {
1100
1173
  }).join("\n");
1101
1174
  }
1102
1175
 
1176
+ // src/tools/findTags.ts
1177
+ var SUGGEST_CAP = 10;
1178
+ var findTagsDescription = [
1179
+ "Find how rule34.xxx spells a tag, and how many posts carry it.",
1180
+ "The site matches from the start of a name, so 'kimagure' finds 'kimagure_orange_road'",
1181
+ "while 'orange road' finds nothing. It offers at most ten names.",
1182
+ "Use this before searching when a name might be spelled differently, and after a search that found",
1183
+ "nothing because a tag does not exist."
1184
+ ].join(" ");
1185
+ var findTagsInput = strictInput({
1186
+ query: tagName("query", 60).describe(
1187
+ "The start of a tag name, such as 'kimagure' or 'prince of tennis'. Spaces are joined with underscores."
1188
+ )
1189
+ });
1190
+ var findTagsOutputShape = {
1191
+ query: z3.string().describe("The text as rule34.xxx would spell it."),
1192
+ tags: z3.array(
1193
+ z3.object({
1194
+ name: z3.string().describe("The name a search takes."),
1195
+ post_count: z3.number().int().nullable().describe("Posts the site credits to it, or nothing when it printed no count.")
1196
+ })
1197
+ ),
1198
+ source: z3.literal("rule34.xxx"),
1199
+ notes: z3.array(z3.string())
1200
+ };
1201
+ async function runFindTags(client, args) {
1202
+ try {
1203
+ const { data, cached } = await client.findTags(args.query);
1204
+ const notes = cached ? ["Served from this server's short-lived in-memory cache."] : [];
1205
+ if (data.length === 0) {
1206
+ notes.push(
1207
+ "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."
1208
+ );
1209
+ } else if (data.length >= SUGGEST_CAP) {
1210
+ notes.push(
1211
+ "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."
1212
+ );
1213
+ }
1214
+ const asked = normalizeTag(args.query);
1215
+ const structured = {
1216
+ query: asked,
1217
+ tags: data.map((tag) => ({ name: tag.name, post_count: tag.postCount })),
1218
+ source: "rule34.xxx",
1219
+ notes
1220
+ };
1221
+ const body = data.length === 0 ? `rule34.xxx offers no tag name beginning with ${asked}.` : `${data.length} name(s) rule34.xxx offers for ${asked}:
1222
+ ${data.map(
1223
+ (tag) => ` ${tag.name}${tag.postCount === null ? "" : ` (${tag.postCount} posts)`}`
1224
+ ).join("\n")}`;
1225
+ return ok(structured, body, notes);
1226
+ } catch (error) {
1227
+ return toToolError(error);
1228
+ }
1229
+ }
1230
+
1103
1231
  // src/tools/getPost.ts
1232
+ import { z as z4 } from "zod";
1104
1233
  var getPostDescription = [
1105
1234
  "Read one rule34.xxx post, named by its id or by a link to its page.",
1106
1235
  "Returns every tag the post carries, each with its kind (character, copyright, artist, general or",
1107
1236
  "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.",
1237
+ "the rating, the uploader, the number of comments, the credited source and the date the site",
1238
+ "took the post in.",
1109
1239
  "Use this after a search, whose rows show only the first few tags of each post."
1110
1240
  ].join(" ");
1111
1241
  var getPostInput = strictInput({
1112
1242
  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(
1243
+ url: z4.string().max(500).optional().describe(
1114
1244
  "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
1245
  )
1116
1246
  });
1117
1247
  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.")
1248
+ id: z4.number().int(),
1249
+ post_url: z4.string(),
1250
+ file_url: z4.string(),
1251
+ preview_url: z4.string().nullable(),
1252
+ sample_url: z4.string().nullable(),
1253
+ width: z4.number().int(),
1254
+ height: z4.number().int(),
1255
+ md5: z4.string(),
1256
+ score: z4.number().int().describe("The site's own score, updated once a day."),
1257
+ rating: z4.string(),
1258
+ tags: z4.array(
1259
+ z4.object({
1260
+ name: z4.string(),
1261
+ type: z4.string().describe("character, copyright, artist, general or metadata."),
1262
+ post_count: z4.number().int().describe("Posts the site credits to this tag.")
1133
1263
  })
1134
1264
  ),
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())
1265
+ source: z4.string().nullable(),
1266
+ parent_id: z4.number().int().nullable(),
1267
+ owner: z4.string().nullable().describe("The account that uploaded the post."),
1268
+ comment_count: z4.number().int().nullable(),
1269
+ created_at: z4.string().nullable().describe(
1270
+ "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."
1271
+ ),
1272
+ changed_at: z4.string().nullable().describe("When the post last changed, ISO 8601."),
1273
+ status: z4.string(),
1274
+ has_notes: z4.boolean(),
1275
+ source_site: z4.literal("rule34.xxx"),
1276
+ notes: z4.array(z4.string())
1145
1277
  };
1146
1278
  async function runGetPost(client, args) {
1147
1279
  try {
@@ -1189,8 +1321,8 @@ function render(post) {
1189
1321
  ` ${post.post_url}`,
1190
1322
  ` ${post.file_url}`
1191
1323
  ];
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}` : ""}`);
1324
+ const added = post.created_at ? post.created_at.slice(0, 10) : "an unstated date";
1325
+ lines.push(` added to rule34.xxx ${added}${post.owner ? ` by ${post.owner}` : ""}`);
1194
1326
  if (post.comment_count !== null) {
1195
1327
  lines.push(` ${post.comment_count} comment(s)`);
1196
1328
  }
@@ -1206,7 +1338,7 @@ function render(post) {
1206
1338
  }
1207
1339
 
1208
1340
  // src/tools/searchPosts.ts
1209
- import { z as z4 } from "zod";
1341
+ import { z as z5 } from "zod";
1210
1342
  var searchPostsDescription = [
1211
1343
  "Search rule34.xxx for posts carrying a tag, or several tags at once.",
1212
1344
  "Tags are written as one token, so a name said as words is joined by underscores:",
@@ -1236,22 +1368,24 @@ var searchPostsInput = strictInput({
1236
1368
  ["questionable", "explicit"],
1237
1369
  "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
1370
  ).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."),
1371
+ sort: choice("sort", ["score", "id", "updated", "random"], "The order the site sorts by.").default("score").describe(
1372
+ "'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."
1373
+ ),
1240
1374
  limit: wholeNumber("limit", 1, 100).default(20),
1241
1375
  page: wholeNumber("page", 1, MAX_PAGE).default(1)
1242
1376
  });
1243
1377
  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())
1378
+ tags: z5.array(z5.string()).describe("The required tags, as rule34.xxx spells them."),
1379
+ any_of: z5.array(z5.string()).describe("The alternatives, as rule34.xxx spells them."),
1380
+ exclude: z5.array(z5.string()).describe("The excluded tags, as rule34.xxx spells them."),
1381
+ query: z5.string().describe("The search as it was sent, in the site's own language."),
1382
+ total: z5.number().int().describe("Posts the whole search matches, as counted by the site."),
1383
+ page: z5.number().int(),
1384
+ posts: z5.array(postSchema),
1385
+ has_more: z5.boolean(),
1386
+ next_page: z5.number().int().nullable(),
1387
+ source: z5.literal("rule34.xxx"),
1388
+ notes: z5.array(z5.string())
1255
1389
  };
1256
1390
  function toSearch(args) {
1257
1391
  return {
@@ -1265,20 +1399,27 @@ function toSearch(args) {
1265
1399
  page: args.page
1266
1400
  };
1267
1401
  }
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 `
1402
+ function lastPageOf(total, limit) {
1403
+ return total === 0 ? 1 : Math.ceil(total / limit);
1404
+ }
1405
+ function whatFollowsThisPage(nextPage) {
1406
+ return nextPage === null ? "" : `
1278
1407
 
1279
- rule34.xxx holds more, and page ${MAX_PAGE} is as far as this tool reads. Narrow the search instead.`;
1408
+ More posts match: call again with page=${nextPage}.`;
1280
1409
  }
1281
1410
  var quoted = (tags) => tags.map((tag) => `'${tag}'`).join(", ");
1411
+ function openingOf(name) {
1412
+ return name.slice(0, Math.max(3, Math.floor(name.length * 0.6)));
1413
+ }
1414
+ async function namesLike(client, unknown) {
1415
+ try {
1416
+ const { data } = await client.findTags(openingOf(unknown));
1417
+ const offered = data.slice(0, 3).map((tag) => `'${tag.name}'${tag.postCount === null ? "" : ` (${tag.postCount} posts)`}`);
1418
+ return offered.length === 0 ? "" : ` It does offer ${offered.join(", ")}.`;
1419
+ } catch {
1420
+ return "";
1421
+ }
1422
+ }
1282
1423
  function restrictionsIn(args) {
1283
1424
  const named = [];
1284
1425
  if ((args.any_of ?? []).length > 0) {
@@ -1299,9 +1440,11 @@ async function explainEmptySearch(client, args) {
1299
1440
  const alternatives = args.any_of ?? [];
1300
1441
  const unknownRequired = await client.findUnknownTags(args.tags);
1301
1442
  const unknownAlternatives = alternatives.length > 0 ? await client.findUnknownTags(alternatives) : [];
1302
- if (unknownRequired.length > 0) {
1443
+ const [firstUnknown] = unknownRequired;
1444
+ if (firstUnknown !== void 0) {
1445
+ const near = await namesLike(client, firstUnknown);
1303
1446
  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.`
1447
+ `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
1448
  ];
1306
1449
  }
1307
1450
  if (alternatives.length > 0 && unknownAlternatives.length === alternatives.length) {
@@ -1330,14 +1473,27 @@ async function runSearchPosts(client, args) {
1330
1473
  if (cached) {
1331
1474
  notes.push("Served from this server's short-lived in-memory cache.");
1332
1475
  }
1333
- const posts = data.posts.map(toPostOut);
1476
+ const asked = [...spelled.required, ...spelled.alternatives];
1477
+ const posts = data.posts.map((post) => toPostOut(post, asked));
1334
1478
  const trimmed = posts.filter((post) => post.tags_total > post.tags.length).length;
1335
1479
  if (trimmed > 0) {
1336
1480
  notes.push(
1337
1481
  `${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
1482
  );
1339
1483
  }
1340
- const hasMore = args.page * args.limit < data.total;
1484
+ const lastPage = lastPageOf(data.total, args.limit);
1485
+ const hasMore = args.page < lastPage;
1486
+ const nextPage = hasMore && args.page < MAX_PAGE ? args.page + 1 : null;
1487
+ if (data.total > 0 && args.page > lastPage) {
1488
+ notes.push(
1489
+ `This search fills ${lastPage} page(s) at limit ${args.limit}, and page ${args.page} lies past the end, so no post is served.`
1490
+ );
1491
+ }
1492
+ if (hasMore && nextPage === null) {
1493
+ notes.push(
1494
+ `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.`
1495
+ );
1496
+ }
1341
1497
  if (data.total === 0) {
1342
1498
  notes.push(...await explainEmptySearch(client, args));
1343
1499
  }
@@ -1350,14 +1506,14 @@ async function runSearchPosts(client, args) {
1350
1506
  page: args.page,
1351
1507
  posts,
1352
1508
  has_more: hasMore,
1353
- next_page: hasMore && args.page < MAX_PAGE ? args.page + 1 : null,
1509
+ next_page: nextPage,
1354
1510
  source: "rule34.xxx",
1355
1511
  notes
1356
1512
  };
1357
1513
  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
1514
  const body = posts.length > 0 ? `
1359
1515
  ${renderPostList(posts)}` : "";
1360
- return ok(structured, `${header}${body}${whatFollowsThisPage(hasMore, args.page)}`, notes);
1516
+ return ok(structured, `${header}${body}${whatFollowsThisPage(nextPage)}`, notes);
1361
1517
  } catch (error) {
1362
1518
  return toToolError(error);
1363
1519
  }
@@ -1381,7 +1537,7 @@ function createServer(options = {}) {
1381
1537
  const server = new McpServer(
1382
1538
  { name: "mcp-rule34", version: PKG_VERSION },
1383
1539
  {
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."
1540
+ 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
1541
  }
1386
1542
  );
1387
1543
  server.registerTool(
@@ -1390,7 +1546,7 @@ function createServer(options = {}) {
1390
1546
  title: "Search posts by tag",
1391
1547
  description: searchPostsDescription,
1392
1548
  inputSchema: searchPostsInput,
1393
- outputSchema: z5.object(searchPostsOutputShape),
1549
+ outputSchema: z6.object(searchPostsOutputShape),
1394
1550
  annotations: READ_ONLY
1395
1551
  },
1396
1552
  async (args) => runSearchPosts(client, args)
@@ -1401,11 +1557,22 @@ function createServer(options = {}) {
1401
1557
  title: "Read one post",
1402
1558
  description: getPostDescription,
1403
1559
  inputSchema: getPostInput,
1404
- outputSchema: z5.object(getPostOutputShape),
1560
+ outputSchema: z6.object(getPostOutputShape),
1405
1561
  annotations: READ_ONLY
1406
1562
  },
1407
1563
  async (args) => runGetPost(client, args)
1408
1564
  );
1565
+ server.registerTool(
1566
+ "find_tags",
1567
+ {
1568
+ title: "Find how a tag is spelled",
1569
+ description: findTagsDescription,
1570
+ inputSchema: findTagsInput,
1571
+ outputSchema: z6.object(findTagsOutputShape),
1572
+ annotations: READ_ONLY
1573
+ },
1574
+ async (args) => runFindTags(client, args)
1575
+ );
1409
1576
  logger.info(
1410
1577
  `ready: user-agent="${config.userAgent}", min interval ${config.minIntervalMs}ms, cache ${config.cacheTtlMs}ms, credentials ${config.credentials ? "set" : "missing"}`
1411
1578
  );