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 +55 -23
- package/dist/index.js +239 -72
- package/dist/index.js.map +1 -1
- package/dist/rule34/client.d.ts +31 -3
- package/dist/rule34/client.js +67 -2
- package/dist/rule34/client.js.map +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
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
|
|
80
|
-
| -------------- |
|
|
81
|
-
| `search_posts` | Finds posts carrying a tag, or several at once.
|
|
82
|
-
| `get_post` | Reads one post whole, with a kind for every tag.
|
|
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
|
-
|
|
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
|
|
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
|
|
172
|
+
date rule34.xxx took it in.
|
|
160
173
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
285
|
-
| -------------- |
|
|
286
|
-
| `search_posts` | Trouve les posts portant un tag, ou plusieurs.
|
|
287
|
-
| `get_post` | Lit un post en entier, avec le type de chacun de ses tags.
|
|
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
|
|
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
|
|
305
|
-
déposant, le nombre de commentaires et les types de tags viennent
|
|
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
|
|
361
|
-
|
|
362
|
-
Un post porte deux dates et
|
|
363
|
-
|
|
364
|
-
|
|
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 = "
|
|
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
|
|
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
|
|
422
|
-
if (
|
|
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/
|
|
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
|
|
951
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
1119
|
-
post_url:
|
|
1120
|
-
file_url:
|
|
1121
|
-
preview_url:
|
|
1122
|
-
sample_url:
|
|
1123
|
-
width:
|
|
1124
|
-
height:
|
|
1125
|
-
md5:
|
|
1126
|
-
score:
|
|
1127
|
-
rating:
|
|
1128
|
-
tags:
|
|
1129
|
-
|
|
1130
|
-
name:
|
|
1131
|
-
type:
|
|
1132
|
-
post_count:
|
|
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:
|
|
1136
|
-
parent_id:
|
|
1137
|
-
owner:
|
|
1138
|
-
comment_count:
|
|
1139
|
-
created_at:
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
|
1193
|
-
lines.push(`
|
|
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
|
|
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(
|
|
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:
|
|
1245
|
-
any_of:
|
|
1246
|
-
exclude:
|
|
1247
|
-
query:
|
|
1248
|
-
total:
|
|
1249
|
-
page:
|
|
1250
|
-
posts:
|
|
1251
|
-
has_more:
|
|
1252
|
-
next_page:
|
|
1253
|
-
source:
|
|
1254
|
-
notes:
|
|
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
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
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:
|
|
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:
|
|
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
|
);
|