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/LICENSE +0 -0
- package/README.md +55 -23
- package/dist/index.d.ts +0 -0
- package/dist/index.js +252 -83
- package/dist/index.js.map +1 -1
- package/dist/rule34/client.d.ts +31 -3
- package/dist/rule34/client.js +79 -12
- package/dist/rule34/client.js.map +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
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.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
|
|
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
|
|
422
|
-
if (
|
|
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/
|
|
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
|
|
951
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
1136
|
-
parent_id:
|
|
1137
|
-
owner:
|
|
1138
|
-
comment_count:
|
|
1139
|
-
created_at:
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
|
1193
|
-
lines.push(`
|
|
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
|
|
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(
|
|
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:
|
|
1245
|
-
any_of:
|
|
1246
|
-
exclude:
|
|
1247
|
-
query:
|
|
1248
|
-
total:
|
|
1249
|
-
page:
|
|
1250
|
-
posts:
|
|
1251
|
-
has_more:
|
|
1252
|
-
next_page:
|
|
1253
|
-
source:
|
|
1254
|
-
notes:
|
|
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
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
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:
|
|
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:
|
|
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
|
-
|
|
1592
|
+
server.close().catch(() => void 0).finally(() => process.exit(0));
|
|
1424
1593
|
};
|
|
1425
1594
|
process.on("SIGINT", shutdown);
|
|
1426
1595
|
process.on("SIGTERM", shutdown);
|