mcp-rule34 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1435 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // src/version.ts
7
+ var PKG_VERSION = "0.1.0";
8
+ var REPO_URL = "https://github.com/smeet666/mcp-rule34";
9
+
10
+ // src/config.ts
11
+ var DEFAULT_USER_AGENT = `mcp-rule34/${PKG_VERSION} (+${REPO_URL})`;
12
+ var DEFAULTS = {
13
+ minIntervalMs: 1e3,
14
+ timeoutMs: 2e4,
15
+ maxRetries: 3,
16
+ cacheTtlMs: 5 * 60 * 1e3,
17
+ cacheMaxEntries: 300,
18
+ logLevel: "error"
19
+ };
20
+ var MIN_ALLOWED_INTERVAL_MS = 1e3;
21
+ var LOG_LEVELS = ["silent", "error", "info", "debug"];
22
+ function warn(message) {
23
+ process.stderr.write(`[mcp-rule34] ${message}
24
+ `);
25
+ }
26
+ function readNumber(name, env, range) {
27
+ const raw = env[name];
28
+ if (raw === void 0 || raw.trim() === "") {
29
+ return range.fallback;
30
+ }
31
+ const parsed = Number(raw);
32
+ if (!Number.isFinite(parsed)) {
33
+ warn(`${name}="${raw}" is not a number, using ${range.fallback}`);
34
+ return range.fallback;
35
+ }
36
+ const clamped = Math.min(range.max, Math.max(range.min, Math.round(parsed)));
37
+ if (clamped !== Math.round(parsed)) {
38
+ warn(`${name}=${raw} is out of range, clamped to ${clamped}`);
39
+ }
40
+ return clamped;
41
+ }
42
+ function readInterval(env) {
43
+ const raw = env.RULE34_MIN_INTERVAL_MS;
44
+ if (raw === void 0 || raw.trim() === "") {
45
+ return DEFAULTS.minIntervalMs;
46
+ }
47
+ const parsed = Number(raw);
48
+ if (!Number.isFinite(parsed)) {
49
+ warn(`RULE34_MIN_INTERVAL_MS="${raw}" is not a number, using ${DEFAULTS.minIntervalMs}ms`);
50
+ return DEFAULTS.minIntervalMs;
51
+ }
52
+ const rounded = Math.round(parsed);
53
+ if (rounded < MIN_ALLOWED_INTERVAL_MS) {
54
+ warn(
55
+ `RULE34_MIN_INTERVAL_MS=${raw} is below the ${MIN_ALLOWED_INTERVAL_MS}ms floor and was ignored; using ${DEFAULTS.minIntervalMs}ms. This floor keeps the client from hammering rule34.xxx.`
56
+ );
57
+ return DEFAULTS.minIntervalMs;
58
+ }
59
+ return Math.min(6e4, rounded);
60
+ }
61
+ function readCredentials(env) {
62
+ const userId = env.RULE34_USER_ID?.trim() ?? "";
63
+ const apiKey = env.RULE34_API_KEY?.trim() ?? "";
64
+ if (userId === "" || apiKey === "") {
65
+ return null;
66
+ }
67
+ return { userId, apiKey };
68
+ }
69
+ function loadConfig(env = process.env) {
70
+ const rawUserAgent = env.RULE34_USER_AGENT?.trim();
71
+ const rawLogLevel = env.RULE34_LOG_LEVEL?.trim().toLowerCase();
72
+ let logLevel = DEFAULTS.logLevel;
73
+ if (rawLogLevel) {
74
+ if (LOG_LEVELS.includes(rawLogLevel)) {
75
+ logLevel = rawLogLevel;
76
+ } else {
77
+ warn(`RULE34_LOG_LEVEL="${rawLogLevel}" is unknown, using "${DEFAULTS.logLevel}"`);
78
+ }
79
+ }
80
+ return {
81
+ userAgent: rawUserAgent || DEFAULT_USER_AGENT,
82
+ credentials: readCredentials(env),
83
+ minIntervalMs: readInterval(env),
84
+ timeoutMs: readNumber("RULE34_TIMEOUT_MS", env, {
85
+ min: 1e3,
86
+ max: 12e4,
87
+ fallback: DEFAULTS.timeoutMs
88
+ }),
89
+ maxRetries: readNumber("RULE34_MAX_RETRIES", env, {
90
+ min: 0,
91
+ max: 10,
92
+ fallback: DEFAULTS.maxRetries
93
+ }),
94
+ cacheTtlMs: readNumber("RULE34_CACHE_TTL_MS", env, {
95
+ min: 0,
96
+ max: 24 * 60 * 60 * 1e3,
97
+ fallback: DEFAULTS.cacheTtlMs
98
+ }),
99
+ cacheMaxEntries: readNumber("RULE34_CACHE_MAX_ENTRIES", env, {
100
+ min: 0,
101
+ max: 1e4,
102
+ fallback: DEFAULTS.cacheMaxEntries
103
+ }),
104
+ logLevel
105
+ };
106
+ }
107
+ var LEVEL_RANK = { silent: 0, error: 1, info: 2, debug: 3 };
108
+ function createLogger(level) {
109
+ const emit = (at, message) => {
110
+ if (LEVEL_RANK[level] >= LEVEL_RANK[at]) {
111
+ process.stderr.write(`[mcp-rule34] ${message}
112
+ `);
113
+ }
114
+ };
115
+ return {
116
+ error: (message) => emit("error", message),
117
+ info: (message) => emit("info", message),
118
+ debug: (message) => emit("debug", message)
119
+ };
120
+ }
121
+
122
+ // src/server.ts
123
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
124
+ import { z as z5 } from "zod";
125
+
126
+ // src/errors.ts
127
+ var Rule34Error = class extends Error {
128
+ constructor(code, text, details = {}) {
129
+ super(`[${code}] ${text}`);
130
+ this.code = code;
131
+ this.details = details;
132
+ this.name = "Rule34Error";
133
+ }
134
+ code;
135
+ details;
136
+ };
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 } : {});
140
+ }
141
+ function missingCredentials(url) {
142
+ return new Rule34Error(
143
+ "invalid_input",
144
+ "rule34.xxx answered that this request carries no valid API credentials.",
145
+ {
146
+ url,
147
+ hint: "Set RULE34_USER_ID and RULE34_API_KEY in the env block of this server's entry in your MCP client configuration. Both are shown under 'API Access Credentials' at https://rule34.xxx/index.php?page=account&s=options once you are signed in. The site issues one key per person, so use your own rather than sharing one."
148
+ }
149
+ );
150
+ }
151
+ function notFound(url) {
152
+ return new Rule34Error("not_found", "rule34.xxx has nothing at this address.", {
153
+ url,
154
+ status: 404,
155
+ hint: "Check the post id. Ids come from a search; they are not stable to guess by hand."
156
+ });
157
+ }
158
+ function rateLimited(url, retryAfterMs) {
159
+ return new Rule34Error(
160
+ "rate_limited",
161
+ "rule34.xxx is refusing requests from this client for now (HTTP 429). This does NOT mean the search found nothing.",
162
+ {
163
+ url,
164
+ status: 429,
165
+ retryAfterMs,
166
+ hint: "Wait before asking again. If it keeps happening, raise RULE34_MIN_INTERVAL_MS in your MCP client configuration to slow this server down."
167
+ }
168
+ );
169
+ }
170
+ function parseFailure(url, what) {
171
+ return new Rule34Error(
172
+ "parse_failure",
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}` }
175
+ );
176
+ }
177
+ function upstreamFailure(url, message) {
178
+ return new Rule34Error("network_error", `rule34.xxx reported a failure: ${message}`, {
179
+ url,
180
+ hint: "This is the site's own message. Waiting and asking again is usually the only remedy."
181
+ });
182
+ }
183
+
184
+ // src/rule34/cache.ts
185
+ var TtlLruCache = class {
186
+ constructor(maxEntries, ttlMs) {
187
+ this.maxEntries = maxEntries;
188
+ this.ttlMs = ttlMs;
189
+ }
190
+ maxEntries;
191
+ ttlMs;
192
+ store = /* @__PURE__ */ new Map();
193
+ get(key) {
194
+ const entry = this.store.get(key);
195
+ if (!entry) {
196
+ return void 0;
197
+ }
198
+ if (Date.now() >= entry.expiresAt) {
199
+ this.store.delete(key);
200
+ return void 0;
201
+ }
202
+ this.store.delete(key);
203
+ this.store.set(key, entry);
204
+ return entry.value;
205
+ }
206
+ set(key, value) {
207
+ if (this.maxEntries <= 0 || this.ttlMs <= 0) {
208
+ return;
209
+ }
210
+ this.store.delete(key);
211
+ this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
212
+ while (this.store.size > this.maxEntries) {
213
+ const oldest = this.store.keys().next();
214
+ if (oldest.done) {
215
+ break;
216
+ }
217
+ this.store.delete(oldest.value);
218
+ }
219
+ }
220
+ /** The keys currently held, in least recently used order. */
221
+ keys() {
222
+ return [...this.store.keys()];
223
+ }
224
+ get size() {
225
+ return this.store.size;
226
+ }
227
+ clear() {
228
+ this.store.clear();
229
+ }
230
+ };
231
+
232
+ // src/rule34/rateLimiter.ts
233
+ var DEFAULT_MAX_INTERVAL_MS = 3e4;
234
+ var RateLimiter = class {
235
+ baseIntervalMs;
236
+ maxIntervalMs;
237
+ intervalMs;
238
+ /** Tail of the queue: each task chains onto the previous one. */
239
+ tail = Promise.resolve();
240
+ lastStart = 0;
241
+ constructor(options) {
242
+ this.baseIntervalMs = Math.max(0, options.minIntervalMs);
243
+ this.maxIntervalMs = Math.max(
244
+ this.baseIntervalMs,
245
+ options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS
246
+ );
247
+ this.intervalMs = this.baseIntervalMs;
248
+ }
249
+ get currentIntervalMs() {
250
+ return this.intervalMs;
251
+ }
252
+ /** Queue a task. Tasks run in call order, one at a time. */
253
+ schedule(task) {
254
+ const run = this.tail.then(async () => task());
255
+ this.tail = run.then(
256
+ () => void 0,
257
+ () => void 0
258
+ );
259
+ return run;
260
+ }
261
+ /**
262
+ * Wait for this request's slot, then claim it.
263
+ *
264
+ * Called once per upstream request rather than once per task, because a task
265
+ * runs a whole retry chain: stamping only its start would let the next task
266
+ * follow the chain's last request with no gap at all.
267
+ */
268
+ async beforeRequest() {
269
+ await this.waitForSlot();
270
+ this.lastStart = Date.now();
271
+ }
272
+ /** Called after the site answers that it is being asked too often. */
273
+ penalize() {
274
+ const next = this.intervalMs === 0 ? 500 : this.intervalMs * 2;
275
+ this.intervalMs = Math.min(this.maxIntervalMs, next);
276
+ }
277
+ /** Called after a success, so one push-back does not slow things down forever. */
278
+ relax() {
279
+ this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));
280
+ }
281
+ async waitForSlot() {
282
+ if (this.intervalMs === 0 || this.lastStart === 0) {
283
+ return;
284
+ }
285
+ const elapsed = Date.now() - this.lastStart;
286
+ const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);
287
+ if (remaining > 0) {
288
+ await sleep(remaining);
289
+ }
290
+ }
291
+ };
292
+ function sleep(ms) {
293
+ return new Promise((resolve) => setTimeout(resolve, ms));
294
+ }
295
+
296
+ // src/rule34/urls.ts
297
+ var API_URL = "https://api.rule34.xxx/index.php";
298
+ var SITE_URL = "https://rule34.xxx/index.php";
299
+ var MAX_LIMIT = 1e3;
300
+ var MEDIA_TOKENS = {
301
+ image: ["-video", "-animated"],
302
+ animated: ["animated", "-video"],
303
+ video: ["video"],
304
+ any: []
305
+ };
306
+ var SORT_TOKENS = {
307
+ score: "sort:score:desc",
308
+ id: "sort:id:desc",
309
+ updated: "sort:updated:desc",
310
+ random: "sort:random"
311
+ };
312
+ var MAX_TAGS = 10;
313
+ function normalizeTag(raw) {
314
+ const trimmed = raw.trim();
315
+ if (trimmed === "") {
316
+ throw invalidInput(
317
+ "A tag is required.",
318
+ "Pass the tag to search for, such as 'black hair' or 'asuka langley sohryu'."
319
+ );
320
+ }
321
+ if (trimmed.startsWith("-")) {
322
+ throw invalidInput(
323
+ `"${trimmed}" starts with '-', which means "without this tag" on rule34.xxx.`,
324
+ "This tool searches for a tag. Pass the tag itself, without the leading dash."
325
+ );
326
+ }
327
+ if (trimmed.includes(":")) {
328
+ throw invalidInput(
329
+ `"${trimmed}" carries a ':', which rule34.xxx reads as a filter rather than as a tag.`,
330
+ "Filters are separate arguments here: use 'rating', 'sort' and 'media_type'."
331
+ );
332
+ }
333
+ return trimmed.replace(/\s+/g, "_").toLowerCase();
334
+ }
335
+ function normalizeList(raw, argument) {
336
+ const tags = (raw ?? []).map(normalizeTag);
337
+ if (tags.length > MAX_TAGS) {
338
+ throw invalidInput(
339
+ `'${argument}' holds ${tags.length} tags, and this tool searches at most ${MAX_TAGS} at a time.`,
340
+ "Search on the tags that narrow the most, then look through the results."
341
+ );
342
+ }
343
+ return tags;
344
+ }
345
+ function normalizeSearch(search) {
346
+ const required = normalizeList(search.tags, "tags");
347
+ if (required.length === 0) {
348
+ throw invalidInput(
349
+ "At least one tag is required.",
350
+ "Pass the tags to search for, such as ['asuka langley sohryu', 'black hair']."
351
+ );
352
+ }
353
+ const alternatives = normalizeList(search.anyOf, "any_of");
354
+ const excluded = normalizeList(search.exclude, "exclude");
355
+ const contradiction = required.find((tag) => excluded.includes(tag));
356
+ if (contradiction) {
357
+ throw invalidInput(
358
+ `'${contradiction}' is both required and excluded, so no post can match.`,
359
+ "Remove it from one of the two lists."
360
+ );
361
+ }
362
+ return { required, alternatives, excluded };
363
+ }
364
+ function buildSearchQuery(search) {
365
+ const { required, alternatives, excluded } = normalizeSearch(search);
366
+ const tokens = [...required];
367
+ if (alternatives.length === 1) {
368
+ tokens.push(...alternatives);
369
+ } else if (alternatives.length > 1) {
370
+ tokens.push(`( ${alternatives.join(" ~ ")} )`);
371
+ }
372
+ tokens.push(...excluded.map((tag) => `-${tag}`), ...MEDIA_TOKENS[search.mediaType ?? "any"]);
373
+ if (search.rating) {
374
+ tokens.push(`rating:${search.rating}`);
375
+ }
376
+ if (search.sort) {
377
+ tokens.push(SORT_TOKENS[search.sort]);
378
+ }
379
+ return tokens.join(" ");
380
+ }
381
+ function buildPostSearchUrl(search, credentials) {
382
+ if (!Number.isInteger(search.limit) || search.limit < 1 || search.limit > MAX_LIMIT) {
383
+ throw invalidInput(
384
+ `limit must be a whole number between 1 and ${MAX_LIMIT}, and ${search.limit} is not.`,
385
+ `rule34.xxx serves at most ${MAX_LIMIT} posts in one request.`
386
+ );
387
+ }
388
+ if (!Number.isInteger(search.page) || search.page < 1) {
389
+ throw invalidInput(`page must be a whole number from 1 upwards, and ${search.page} is not.`);
390
+ }
391
+ const query = encodeURIComponent(buildSearchQuery(search));
392
+ const pid = search.page - 1;
393
+ return `${API_URL}?page=dapi&s=post&q=index&tags=${query}&limit=${search.limit}&pid=${pid}&api_key=${encodeURIComponent(credentials.apiKey)}&user_id=${encodeURIComponent(credentials.userId)}`;
394
+ }
395
+ function buildTagLookupUrl(tag, credentials) {
396
+ return `${API_URL}?page=dapi&s=tag&q=index&name=${encodeURIComponent(normalizeTag(tag))}&api_key=${encodeURIComponent(credentials.apiKey)}&user_id=${encodeURIComponent(credentials.userId)}`;
397
+ }
398
+ function readPostPageUrl(raw) {
399
+ let parsed;
400
+ try {
401
+ parsed = new URL(raw.trim());
402
+ } catch {
403
+ throw invalidInput(
404
+ `"${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"
406
+ );
407
+ }
408
+ const host = parsed.hostname.toLowerCase();
409
+ if (host !== "rule34.xxx" && !host.endsWith(".rule34.xxx")) {
410
+ throw invalidInput(
411
+ `"${raw}" is not a rule34.xxx address.`,
412
+ "This tool reads posts on rule34.xxx. A link elsewhere names no post it holds."
413
+ );
414
+ }
415
+ if (parsed.searchParams.get("page") !== "post" || parsed.searchParams.get("s") !== "view") {
416
+ throw invalidInput(
417
+ `"${raw}" is a rule34.xxx address, but not a post's page.`,
418
+ "A post's page looks like https://rule34.xxx/index.php?page=post&s=view&id=2195419"
419
+ );
420
+ }
421
+ const id = Number(parsed.searchParams.get("id"));
422
+ if (!Number.isInteger(id) || id < 1) {
423
+ throw invalidInput(`"${raw}" carries no post id.`);
424
+ }
425
+ return id;
426
+ }
427
+ function resolvePostRef(ref) {
428
+ const fromUrl = ref.url === void 0 ? null : readPostPageUrl(ref.url);
429
+ const fromId = ref.id;
430
+ if (fromId !== void 0) {
431
+ if (!Number.isInteger(fromId) || fromId < 1) {
432
+ throw invalidInput(`id must be a whole number from 1 upwards, and ${fromId} is not.`);
433
+ }
434
+ if (fromUrl !== null && fromUrl !== fromId) {
435
+ throw invalidInput(
436
+ `'id' names post ${fromId} and 'url' names post ${fromUrl}, so they cannot both be read.`,
437
+ "Pass one of the two, or make them agree."
438
+ );
439
+ }
440
+ return fromId;
441
+ }
442
+ if (fromUrl !== null) {
443
+ return fromUrl;
444
+ }
445
+ throw invalidInput(
446
+ "Either 'id' or 'url' must be given.",
447
+ "Pass the numeric post id, or a link to the post's page on rule34.xxx."
448
+ );
449
+ }
450
+ function buildPostByIdXmlUrl(id, credentials) {
451
+ return `${API_URL}?page=dapi&s=post&q=index&id=${id}${credentialsQuery(credentials)}`;
452
+ }
453
+ function buildPostByIdJsonUrl(id, credentials) {
454
+ return `${API_URL}?page=dapi&s=post&q=index&id=${id}&json=1&fields=tag_info${credentialsQuery(credentials)}`;
455
+ }
456
+ function credentialsQuery(credentials) {
457
+ return `&api_key=${encodeURIComponent(credentials.apiKey)}&user_id=${encodeURIComponent(credentials.userId)}`;
458
+ }
459
+ function buildPostPageUrl(id) {
460
+ return `${SITE_URL}?page=post&s=view&id=${id}`;
461
+ }
462
+ function redactCredentials(url) {
463
+ return url.replace(/([?&]api_key=)[^&]*/gi, "$1REDACTED");
464
+ }
465
+
466
+ // src/rule34/http.ts
467
+ var BACKOFF_BASE_MS = 2e3;
468
+ var BACKOFF_FACTOR = 2;
469
+ var BACKOFF_MAX_MS = 3e4;
470
+ function backoffDelay(attempt, random = Math.random) {
471
+ const uncapped = BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt;
472
+ const capped = Math.min(BACKOFF_MAX_MS, uncapped);
473
+ return Math.round(capped * (0.5 + random() * 0.5));
474
+ }
475
+ async function fetchXml(url, deps) {
476
+ const { config, limiter, logger } = deps;
477
+ const doFetch = deps.fetchImpl ?? fetch;
478
+ const safeUrl = redactCredentials(url);
479
+ return await limiter.schedule(async () => {
480
+ let lastError;
481
+ let askedWaitMs = null;
482
+ for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
483
+ if (attempt > 0) {
484
+ const delay = Math.min(askedWaitMs ?? backoffDelay(attempt - 1), BACKOFF_MAX_MS);
485
+ askedWaitMs = null;
486
+ logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${safeUrl}`);
487
+ await sleep(delay);
488
+ }
489
+ let status;
490
+ let body;
491
+ let retryAfterMs = null;
492
+ try {
493
+ await limiter.beforeRequest();
494
+ const response = await doFetch(url, {
495
+ headers: {
496
+ "User-Agent": config.userAgent,
497
+ Accept: "application/xml,text/xml;q=0.9,*/*;q=0.8"
498
+ },
499
+ redirect: "follow",
500
+ signal: AbortSignal.timeout(config.timeoutMs)
501
+ });
502
+ status = response.status;
503
+ retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
504
+ body = await response.text();
505
+ } catch (error) {
506
+ lastError = asTransportError(error, safeUrl);
507
+ logger.debug(`${lastError.code} for ${safeUrl}: ${lastError.message}`);
508
+ continue;
509
+ }
510
+ if (status === 429) {
511
+ limiter.penalize();
512
+ logger.info(`rate limited on ${safeUrl}, interval now ${limiter.currentIntervalMs}ms`);
513
+ askedWaitMs = retryAfterMs;
514
+ lastError = rateLimited(safeUrl, retryAfterMs ?? backoffDelay(attempt));
515
+ continue;
516
+ }
517
+ if (status === 404) {
518
+ throw notFound(safeUrl);
519
+ }
520
+ if (status >= 500) {
521
+ lastError = new Rule34Error("network_error", `rule34.xxx returned HTTP ${status}.`, {
522
+ url: safeUrl,
523
+ status
524
+ });
525
+ continue;
526
+ }
527
+ if (status >= 400) {
528
+ throw new Rule34Error("network_error", `rule34.xxx refused the request (HTTP ${status}).`, {
529
+ url: safeUrl,
530
+ status
531
+ });
532
+ }
533
+ limiter.relax();
534
+ return body;
535
+ }
536
+ throw lastError ?? new Rule34Error("network_error", `Could not fetch ${safeUrl}.`, {
537
+ url: safeUrl
538
+ });
539
+ });
540
+ }
541
+ function parseRetryAfter(raw) {
542
+ if (!raw) {
543
+ return null;
544
+ }
545
+ const seconds = Number(raw.trim());
546
+ if (Number.isFinite(seconds) && seconds >= 0) {
547
+ return Math.round(seconds * 1e3);
548
+ }
549
+ const when = Date.parse(raw);
550
+ if (Number.isNaN(when)) {
551
+ return null;
552
+ }
553
+ return Math.max(0, when - Date.now());
554
+ }
555
+ function asTransportError(error, safeUrl) {
556
+ if (error instanceof Rule34Error) {
557
+ return error;
558
+ }
559
+ const name = error instanceof Error ? error.name : "";
560
+ if (name === "TimeoutError" || name === "AbortError") {
561
+ return new Rule34Error("timeout", "rule34.xxx did not answer in time.", {
562
+ url: safeUrl,
563
+ hint: "Raise RULE34_TIMEOUT_MS if this happens often on a slow connection."
564
+ });
565
+ }
566
+ const message = error instanceof Error ? error.message : String(error);
567
+ return new Rule34Error("network_error", `Could not reach rule34.xxx: ${message}`, {
568
+ url: safeUrl
569
+ });
570
+ }
571
+
572
+ // src/rule34/parsers/postDetail.ts
573
+ var TYPES = {
574
+ tag: "general",
575
+ artist: "artist",
576
+ copyright: "copyright",
577
+ character: "character",
578
+ metadata: "metadata"
579
+ };
580
+ function parsePostDetail(body, url, id) {
581
+ const safeUrl = redactCredentials(url);
582
+ if (body.trim() === "") {
583
+ throw notFound(safeUrl);
584
+ }
585
+ let parsed;
586
+ try {
587
+ parsed = JSON.parse(body);
588
+ } catch {
589
+ throw parseFailure(safeUrl, "the response is not JSON");
590
+ }
591
+ if (typeof parsed === "string") {
592
+ if (/authenticat/i.test(parsed)) {
593
+ throw missingCredentials(safeUrl);
594
+ }
595
+ throw upstreamFailure(safeUrl, parsed);
596
+ }
597
+ if (!Array.isArray(parsed)) {
598
+ throw parseFailure(safeUrl, "the response is not a list of posts");
599
+ }
600
+ const [raw] = parsed;
601
+ if (raw === void 0) {
602
+ throw notFound(safeUrl);
603
+ }
604
+ if (raw === null || typeof raw !== "object") {
605
+ throw parseFailure(safeUrl, "the first entry is not a post");
606
+ }
607
+ return readPost(raw, id);
608
+ }
609
+ function readPost(raw, askedFor) {
610
+ const id = readInteger(raw.id) ?? askedFor;
611
+ const tags = readString(raw.tags).split(/\s+/).filter((tag) => tag !== "");
612
+ return {
613
+ id,
614
+ postUrl: buildPostPageUrl(id),
615
+ fileUrl: readString(raw.file_url),
616
+ sampleUrl: readText(raw.sample_url),
617
+ previewUrl: readText(raw.preview_url),
618
+ width: readInteger(raw.width) ?? 0,
619
+ height: readInteger(raw.height) ?? 0,
620
+ md5: readString(raw.hash),
621
+ score: readInteger(raw.score) ?? 0,
622
+ rating: readString(raw.rating),
623
+ tags,
624
+ tagDetails: readTagDetails(raw.tag_info),
625
+ source: readText(raw.source),
626
+ // This route writes 0 where the XML writes an empty string, and 0 is a post
627
+ // id that belongs to somebody else.
628
+ parentId: nonZero(readInteger(raw.parent_id)),
629
+ owner: readText(raw.owner),
630
+ commentCount: readInteger(raw.comment_count),
631
+ status: readString(raw.status),
632
+ hasNotes: raw.has_notes === true,
633
+ changedAtUnix: readInteger(raw.change)
634
+ };
635
+ }
636
+ function readTagDetails(value) {
637
+ if (!Array.isArray(value)) {
638
+ return [];
639
+ }
640
+ return value.map((entry) => {
641
+ const info = entry ?? {};
642
+ const type = readString(info.type);
643
+ return {
644
+ name: readString(info.tag),
645
+ // A word this site has not documented is carried as it came: naming it
646
+ // something else would state a kind the site never wrote.
647
+ type: TYPES[type] ?? type,
648
+ postCount: readInteger(info.count) ?? 0
649
+ };
650
+ });
651
+ }
652
+ function nonZero(value) {
653
+ return value === null || value === 0 ? null : value;
654
+ }
655
+ function readString(value) {
656
+ return typeof value === "string" ? value : "";
657
+ }
658
+ function readText(value) {
659
+ const trimmed = readString(value).trim();
660
+ return trimmed === "" ? null : trimmed;
661
+ }
662
+ function readInteger(value) {
663
+ if (typeof value === "number") {
664
+ return Number.isFinite(value) ? value : null;
665
+ }
666
+ const trimmed = readString(value).trim();
667
+ if (trimmed === "") {
668
+ return null;
669
+ }
670
+ const parsed = Number(trimmed);
671
+ return Number.isFinite(parsed) ? parsed : null;
672
+ }
673
+
674
+ // src/rule34/parsers/document.ts
675
+ import { XMLParser } from "fast-xml-parser";
676
+ var parser = new XMLParser({
677
+ ignoreAttributes: false,
678
+ attributeNamePrefix: "@_",
679
+ // Attribute values are converted one field at a time by each parser, so that
680
+ // an empty `parent_id` stays empty instead of becoming a number.
681
+ parseAttributeValue: false,
682
+ trimValues: true
683
+ });
684
+ function readDocument(xml, safeUrl) {
685
+ if (xml.trim() === "") {
686
+ throw parseFailure(safeUrl, "the response was empty");
687
+ }
688
+ let document;
689
+ try {
690
+ document = parser.parse(xml);
691
+ } catch (error) {
692
+ throw parseFailure(safeUrl, error instanceof Error ? error.message : "unreadable XML");
693
+ }
694
+ if (document === null || typeof document !== "object") {
695
+ throw parseFailure(safeUrl, "the response is not an XML document");
696
+ }
697
+ const parsed = document;
698
+ const failure = parsed.error;
699
+ if (failure !== void 0) {
700
+ const message = typeof failure === "string" ? failure : String(failure);
701
+ if (/authenticat/i.test(message)) {
702
+ throw missingCredentials(safeUrl);
703
+ }
704
+ throw upstreamFailure(safeUrl, message);
705
+ }
706
+ return parsed;
707
+ }
708
+ function toArray(value) {
709
+ if (value === void 0 || value === null) {
710
+ return [];
711
+ }
712
+ return Array.isArray(value) ? value : [value];
713
+ }
714
+ function readText2(value) {
715
+ const trimmed = value?.trim() ?? "";
716
+ return trimmed === "" ? null : trimmed;
717
+ }
718
+ function readInteger2(value) {
719
+ const trimmed = value?.trim() ?? "";
720
+ if (trimmed === "") {
721
+ return null;
722
+ }
723
+ const parsed = Number(trimmed);
724
+ return Number.isFinite(parsed) ? parsed : null;
725
+ }
726
+
727
+ // src/rule34/parsers/posts.ts
728
+ var RATINGS = {
729
+ e: "explicit",
730
+ q: "questionable",
731
+ s: "safe"
732
+ };
733
+ function parsePostList(xml, url) {
734
+ const safeUrl = redactCredentials(url);
735
+ const document = readDocument(xml, safeUrl);
736
+ const posts = document.posts;
737
+ if (posts === void 0 || posts === null || typeof posts !== "object") {
738
+ throw parseFailure(safeUrl, "no <posts> element");
739
+ }
740
+ const attributes = posts;
741
+ const total = readInteger2(attributes["@_count"]);
742
+ if (total === null) {
743
+ throw parseFailure(safeUrl, "the <posts> element carries no count");
744
+ }
745
+ return {
746
+ total,
747
+ offset: readInteger2(attributes["@_offset"]) ?? 0,
748
+ posts: toArray(attributes.post).map(readPost2)
749
+ };
750
+ }
751
+ function readPost2(attributes) {
752
+ const id = readInteger2(attributes["@_id"]) ?? 0;
753
+ const rating = attributes["@_rating"]?.trim() ?? "";
754
+ return {
755
+ id,
756
+ postUrl: buildPostPageUrl(id),
757
+ fileUrl: attributes["@_file_url"] ?? "",
758
+ sampleUrl: readText2(attributes["@_sample_url"]),
759
+ previewUrl: readText2(attributes["@_preview_url"]),
760
+ width: readInteger2(attributes["@_width"]) ?? 0,
761
+ height: readInteger2(attributes["@_height"]) ?? 0,
762
+ md5: attributes["@_md5"] ?? "",
763
+ score: readInteger2(attributes["@_score"]) ?? 0,
764
+ // An unknown letter is carried as it came: naming it would state a rating
765
+ // the site never wrote.
766
+ rating: RATINGS[rating] ?? rating,
767
+ tags: (attributes["@_tags"] ?? "").split(/\s+/).filter((tag) => tag !== ""),
768
+ source: readText2(attributes["@_source"]),
769
+ // The attribute is present and empty on a post with no parent, and 0 is a
770
+ // post id that belongs to somebody else.
771
+ parentId: readInteger2(attributes["@_parent_id"]),
772
+ creatorId: readInteger2(attributes["@_creator_id"]),
773
+ status: attributes["@_status"] ?? "",
774
+ hasChildren: attributes["@_has_children"] === "true",
775
+ hasComments: attributes["@_has_comments"] === "true",
776
+ hasNotes: attributes["@_has_notes"] === "true",
777
+ createdAt: readDate(attributes["@_created_at"]),
778
+ changedAtUnix: readInteger2(attributes["@_change"])
779
+ };
780
+ }
781
+ function readDate(value) {
782
+ const trimmed = value?.trim() ?? "";
783
+ if (trimmed === "") {
784
+ return null;
785
+ }
786
+ const parsed = Date.parse(trimmed);
787
+ return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
788
+ }
789
+
790
+ // src/rule34/parsers/tags.ts
791
+ var TYPES2 = {
792
+ 0: "general",
793
+ 1: "artist",
794
+ 3: "copyright",
795
+ 4: "character",
796
+ 5: "metadata"
797
+ };
798
+ function parseTagList(xml, url) {
799
+ const safeUrl = redactCredentials(url);
800
+ const document = readDocument(xml, safeUrl);
801
+ const tags = document.tags;
802
+ if (tags !== void 0 && tags !== null && typeof tags === "object") {
803
+ return toArray(tags.tag).map(readTag);
804
+ }
805
+ const lone = document.tag;
806
+ if (lone !== void 0 && lone !== null && typeof lone === "object") {
807
+ const attributes = lone;
808
+ return attributes["@_name"] === void 0 ? [] : [readTag(attributes)];
809
+ }
810
+ throw parseFailure(safeUrl, "no <tags> element");
811
+ }
812
+ function readTag(attributes) {
813
+ const typeCode = readInteger2(attributes["@_type"]);
814
+ return {
815
+ id: readInteger2(attributes["@_id"]) ?? 0,
816
+ name: attributes["@_name"] ?? "",
817
+ postCount: readInteger2(attributes["@_count"]) ?? 0,
818
+ // A number this site has not documented is left unnamed: calling it
819
+ // something would state a kind the site never wrote.
820
+ type: (typeCode === null ? void 0 : TYPES2[typeCode]) ?? "unknown",
821
+ typeCode,
822
+ ambiguous: attributes["@_ambiguous"] === "true"
823
+ };
824
+ }
825
+
826
+ // src/rule34/client.ts
827
+ var Rule34Client = class {
828
+ constructor(deps) {
829
+ this.deps = deps;
830
+ this.limiter = new RateLimiter({ minIntervalMs: deps.config.minIntervalMs });
831
+ this.cache = new TtlLruCache(deps.config.cacheMaxEntries, deps.config.cacheTtlMs);
832
+ }
833
+ deps;
834
+ limiter;
835
+ cache;
836
+ /** The addresses currently held, for a diagnostic to look at. */
837
+ cacheKeys() {
838
+ return this.cache.keys();
839
+ }
840
+ async searchPosts(search) {
841
+ const url = buildPostSearchUrl(search, this.credentials());
842
+ const { body, cached } = await this.read(url);
843
+ return { data: parsePostList(body, url), cached };
844
+ }
845
+ /**
846
+ * One post, read from both formats the API publishes.
847
+ *
848
+ * Neither format carries everything: the publication date and the uploader's
849
+ * numeric id come from the XML, and the uploader's name, the comment count
850
+ * and the tag types come from the JSON. Reading one of them would leave out
851
+ * what the other holds, so a fiche costs two reads of the site.
852
+ */
853
+ async getPost(ref) {
854
+ const credentials = this.credentials();
855
+ const id = resolvePostRef(ref);
856
+ const jsonUrl = buildPostByIdJsonUrl(id, credentials);
857
+ const json = await this.read(jsonUrl);
858
+ const detail = parsePostDetail(json.body, jsonUrl, id);
859
+ const xmlUrl = buildPostByIdXmlUrl(id, credentials);
860
+ const xml = await this.read(xmlUrl);
861
+ const [fromXml] = parsePostList(xml.body, xmlUrl).posts;
862
+ return {
863
+ data: {
864
+ ...detail,
865
+ createdAt: fromXml?.createdAt ?? null,
866
+ creatorId: fromXml?.creatorId ?? null,
867
+ hasComments: fromXml?.hasComments ?? (detail.commentCount ?? 0) > 0,
868
+ hasChildren: fromXml?.hasChildren ?? false
869
+ },
870
+ cached: json.cached && xml.cached
871
+ };
872
+ }
873
+ /** The tag under this exact name, or nothing when the site holds no such tag. */
874
+ async lookupTag(name) {
875
+ const url = buildTagLookupUrl(name, this.credentials());
876
+ const { body, cached } = await this.read(url);
877
+ const [tag] = parseTagList(body, url);
878
+ return { data: tag ?? null, cached };
879
+ }
880
+ /**
881
+ * Which of these names the site does not hold as a tag.
882
+ *
883
+ * A search of several tags that finds nothing says nothing about which tag
884
+ * emptied it, and a misspelled name is indistinguishable from a combination
885
+ * the site genuinely has nothing for. Each distinct name costs one request,
886
+ * which is why this is worth spending only once a search has come back empty.
887
+ */
888
+ async findUnknownTags(names) {
889
+ const distinct = [...new Set(names.map(normalizeTag))];
890
+ const unknown = [];
891
+ for (const name of distinct) {
892
+ const { data } = await this.lookupTag(name);
893
+ if (data === null) {
894
+ unknown.push(name);
895
+ }
896
+ }
897
+ return unknown;
898
+ }
899
+ credentials() {
900
+ const credentials = this.deps.config.credentials;
901
+ if (!credentials) {
902
+ throw invalidInput(
903
+ "This server has no rule34.xxx API credentials, so it cannot ask the site anything.",
904
+ "Set RULE34_USER_ID and RULE34_API_KEY in the env block of this server's entry in your MCP client configuration. Both are shown under 'API Access Credentials' at https://rule34.xxx/index.php?page=account&s=options once you are signed in. The site issues one key per person, so use your own rather than sharing one."
905
+ );
906
+ }
907
+ return credentials;
908
+ }
909
+ /**
910
+ * Fetch one address, remembering the answer under it.
911
+ *
912
+ * The key is the address without the key in it: two searches differ by their
913
+ * query and never by their credentials, and holding the secret in a second
914
+ * place buys nothing.
915
+ */
916
+ async read(url) {
917
+ const key = redactCredentials(url);
918
+ const remembered = this.cache.get(key);
919
+ if (remembered !== void 0) {
920
+ this.deps.logger.debug(`cache hit for ${key}`);
921
+ return { body: remembered, cached: true };
922
+ }
923
+ const body = await fetchXml(url, {
924
+ config: this.deps.config,
925
+ limiter: this.limiter,
926
+ logger: this.deps.logger,
927
+ fetchImpl: this.deps.fetchImpl
928
+ });
929
+ this.cache.set(key, body);
930
+ return { body, cached: false };
931
+ }
932
+ };
933
+
934
+ // src/tools/getPost.ts
935
+ import { z as z3 } from "zod";
936
+
937
+ // src/tools/arguments.ts
938
+ import { z } from "zod";
939
+ var INVALID_INPUT = "invalid_input";
940
+ var CODE = `[${INVALID_INPUT}]`;
941
+ function choice(argument, options, what) {
942
+ const error = `${CODE} ${argument} takes one of ${options.join(", ")}. ${what}`;
943
+ return z.enum(options, { error });
944
+ }
945
+ function wholeNumber(argument, min, max) {
946
+ const error = `${CODE} ${argument} takes a whole number from ${min} to ${max}.`;
947
+ return z.number({ error }).int(error).min(min, error).max(max, error);
948
+ }
949
+ 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);
952
+ }
953
+ function tagNames(argument, least, most) {
954
+ const error = `${CODE} ${argument} takes between ${least} and ${most} tag names.`;
955
+ return z.array(tagName(argument, 120), { error }).min(least, error).max(most, error);
956
+ }
957
+ function strictInput(shape) {
958
+ const declared = Object.keys(shape);
959
+ return z.strictObject(shape, {
960
+ error: (issue) => issue.code === "unrecognized_keys" ? unknownArgumentMessage(issue.keys, declared) : void 0
961
+ });
962
+ }
963
+ function unknownArgumentMessage(keys, declared) {
964
+ const named = keys.map((key) => {
965
+ const near = nearestArgument(key, declared);
966
+ return near ? `'${key}' (did you mean '${near}'?)` : `'${key}'`;
967
+ }).join(", ");
968
+ return `[${INVALID_INPUT}] Unknown ${keys.length > 1 ? "arguments" : "argument"} ${named}. This tool takes: ${declared.join(", ")}.`;
969
+ }
970
+ function nearestArgument(key, declared) {
971
+ const flatten = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
972
+ const flat = flatten(key);
973
+ if (flat.length === 0) {
974
+ return void 0;
975
+ }
976
+ const sameName = declared.find((name) => flatten(name) === flat);
977
+ if (sameName) {
978
+ return sameName;
979
+ }
980
+ const overlapping = declared.find((name) => {
981
+ const other = flatten(name);
982
+ const [shorter, longer] = other.length < flat.length ? [other, flat] : [flat, other];
983
+ return shorter.length >= 3 && (longer.startsWith(shorter) || longer.endsWith(shorter));
984
+ });
985
+ if (overlapping) {
986
+ return overlapping;
987
+ }
988
+ let closest;
989
+ let shortest = Number.POSITIVE_INFINITY;
990
+ for (const name of declared) {
991
+ const distance = editDistance(flat, flatten(name));
992
+ if (distance < shortest) {
993
+ shortest = distance;
994
+ closest = name;
995
+ }
996
+ }
997
+ return shortest <= Math.max(1, Math.floor(flat.length / 3)) ? closest : void 0;
998
+ }
999
+ function editDistance(left, right) {
1000
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
1001
+ let distance = right.length;
1002
+ for (let row = 1; row <= left.length; row += 1) {
1003
+ const current = [row];
1004
+ let diagonal = row - 1;
1005
+ let leftward = row;
1006
+ for (const [column, above] of previous.entries()) {
1007
+ if (column === 0) {
1008
+ continue;
1009
+ }
1010
+ const substitution = diagonal + (left[row - 1] === right[column - 1] ? 0 : 1);
1011
+ const cell = Math.min(substitution, above + 1, leftward + 1);
1012
+ current.push(cell);
1013
+ diagonal = above;
1014
+ leftward = cell;
1015
+ }
1016
+ previous = current;
1017
+ distance = leftward;
1018
+ }
1019
+ return distance;
1020
+ }
1021
+
1022
+ // src/tools/shared.ts
1023
+ import { z as z2 } from "zod";
1024
+ var MAX_TEXT_MIRROR_CHARS = 2e3;
1025
+ var MAX_PAGE = 200;
1026
+ var TAG_PREVIEW = 12;
1027
+ var postSchema = z2.object({
1028
+ id: z2.number().int(),
1029
+ post_url: z2.string().describe("The rule34.xxx page for this post."),
1030
+ file_url: z2.string().describe("The image or video itself, on the site's CDN."),
1031
+ preview_url: z2.string().nullable().describe("Thumbnail, when the site published one."),
1032
+ sample_url: z2.string().nullable().describe("Reduced copy, when the site published one."),
1033
+ width: z2.number().int(),
1034
+ height: z2.number().int(),
1035
+ score: z2.number().int().describe("The site's own score, updated once a day."),
1036
+ rating: z2.string().describe("As the site holds it: 'explicit' or 'questionable'."),
1037
+ tags: z2.array(z2.string()).describe("The first tags of the post, at most 12 of them."),
1038
+ tags_total: z2.number().int().describe("How many tags the post carries in all."),
1039
+ 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."),
1041
+ has_comments: z2.boolean(),
1042
+ parent_id: z2.number().int().nullable()
1043
+ });
1044
+ function toPostOut(post) {
1045
+ return {
1046
+ id: post.id,
1047
+ post_url: post.postUrl,
1048
+ file_url: post.fileUrl,
1049
+ preview_url: post.previewUrl,
1050
+ sample_url: post.sampleUrl,
1051
+ width: post.width,
1052
+ height: post.height,
1053
+ score: post.score,
1054
+ rating: post.rating,
1055
+ tags: post.tags.slice(0, TAG_PREVIEW),
1056
+ tags_total: post.tags.length,
1057
+ source: post.source,
1058
+ created_at: post.createdAt,
1059
+ has_comments: post.hasComments,
1060
+ parent_id: post.parentId
1061
+ };
1062
+ }
1063
+ function indentMarkerLines(body) {
1064
+ return body.replace(/^(Note:)/gm, " $1");
1065
+ }
1066
+ function ok(structured, text, notes = []) {
1067
+ const trailer = notes.map((note) => `Note: ${note}`).join("\n");
1068
+ const budget = MAX_TEXT_MIRROR_CHARS - (trailer ? trailer.length + 2 : 0);
1069
+ const body = truncate(indentMarkerLines(text), Math.max(0, budget));
1070
+ return {
1071
+ content: [{ type: "text", text: trailer ? `${body}
1072
+
1073
+ ${trailer}` : body }],
1074
+ structuredContent: structured
1075
+ };
1076
+ }
1077
+ function toToolError(error) {
1078
+ const known = error instanceof Rule34Error ? error : new Rule34Error("network_error", error instanceof Error ? error.message : String(error));
1079
+ const lines = [known.message];
1080
+ if (known.details.hint) {
1081
+ lines.push(`Hint: ${known.details.hint}`);
1082
+ }
1083
+ if (known.details.url) {
1084
+ lines.push(`URL: ${known.details.url}`);
1085
+ }
1086
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
1087
+ }
1088
+ function truncate(text, maxChars) {
1089
+ if (text.length <= maxChars) {
1090
+ return text;
1091
+ }
1092
+ return `${text.slice(0, maxChars - 1).trimEnd()}\u2026`;
1093
+ }
1094
+ function renderPostList(posts) {
1095
+ return posts.map((post, index) => {
1096
+ const head = `${index + 1}. #${post.id} \xB7 ${post.width}\xD7${post.height} \xB7 score ${post.score} \xB7 ${post.rating}`;
1097
+ return `${head}
1098
+ ${post.post_url}
1099
+ ${post.file_url}`;
1100
+ }).join("\n");
1101
+ }
1102
+
1103
+ // src/tools/getPost.ts
1104
+ var getPostDescription = [
1105
+ "Read one rule34.xxx post, named by its id or by a link to its page.",
1106
+ "Returns every tag the post carries, each with its kind (character, copyright, artist, general or",
1107
+ "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.",
1109
+ "Use this after a search, whose rows show only the first few tags of each post."
1110
+ ].join(" ");
1111
+ var getPostInput = strictInput({
1112
+ 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(
1114
+ "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
+ )
1116
+ });
1117
+ 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.")
1133
+ })
1134
+ ),
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())
1145
+ };
1146
+ async function runGetPost(client, args) {
1147
+ try {
1148
+ const { data, cached } = await client.getPost(args);
1149
+ const notes = cached ? ["Served from this server's short-lived in-memory cache."] : [];
1150
+ const structured = {
1151
+ id: data.id,
1152
+ post_url: data.postUrl,
1153
+ file_url: data.fileUrl,
1154
+ preview_url: data.previewUrl,
1155
+ sample_url: data.sampleUrl,
1156
+ width: data.width,
1157
+ height: data.height,
1158
+ md5: data.md5,
1159
+ score: data.score,
1160
+ rating: data.rating,
1161
+ tags: data.tagDetails.map((tag) => ({
1162
+ name: tag.name,
1163
+ type: tag.type,
1164
+ post_count: tag.postCount
1165
+ })),
1166
+ source: data.source,
1167
+ parent_id: data.parentId,
1168
+ owner: data.owner,
1169
+ comment_count: data.commentCount,
1170
+ created_at: data.createdAt ?? null,
1171
+ changed_at: toIso(data.changedAtUnix),
1172
+ status: data.status,
1173
+ has_notes: data.hasNotes,
1174
+ source_site: "rule34.xxx",
1175
+ notes
1176
+ };
1177
+ return ok(structured, render(structured), notes);
1178
+ } catch (error) {
1179
+ return toToolError(error);
1180
+ }
1181
+ }
1182
+ function toIso(unixSeconds) {
1183
+ return unixSeconds === null ? null : new Date(unixSeconds * 1e3).toISOString();
1184
+ }
1185
+ var NAMED_FIRST = ["character", "copyright", "artist"];
1186
+ function render(post) {
1187
+ const lines = [
1188
+ `Post #${post.id} \xB7 ${post.width}\xD7${post.height} \xB7 score ${post.score} \xB7 ${post.rating}`,
1189
+ ` ${post.post_url}`,
1190
+ ` ${post.file_url}`
1191
+ ];
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}` : ""}`);
1194
+ if (post.comment_count !== null) {
1195
+ lines.push(` ${post.comment_count} comment(s)`);
1196
+ }
1197
+ for (const kind of NAMED_FIRST) {
1198
+ const named = post.tags.filter((tag) => tag.type === kind).map((tag) => tag.name);
1199
+ if (named.length > 0) {
1200
+ lines.push(` ${kind}: ${named.join(", ")}`);
1201
+ }
1202
+ }
1203
+ const rest = post.tags.filter((tag) => !NAMED_FIRST.includes(tag.type)).map((tag) => tag.name);
1204
+ lines.push(` ${post.tags.length} tag(s): ${rest.join(" ")}`);
1205
+ return lines.join("\n");
1206
+ }
1207
+
1208
+ // src/tools/searchPosts.ts
1209
+ import { z as z4 } from "zod";
1210
+ var searchPostsDescription = [
1211
+ "Search rule34.xxx for posts carrying a tag, or several tags at once.",
1212
+ "Tags are written as one token, so a name said as words is joined by underscores:",
1213
+ "'asuka langley sohryu' and 'ranma 1/2' are single tags, and this tool joins them for you.",
1214
+ "Returns each post's page, its image or video URL, dimensions, score and rating, alongside the",
1215
+ `number of posts the whole search matches. A row shows the first ${TAG_PREVIEW} of a post's tags and`,
1216
+ "says how many it has; call get_post for the whole list with a kind for every tag.",
1217
+ "Use 'media_type' to ask for still images, animations or video; the site classifies these itself.",
1218
+ "When a search finds nothing, the answer names the tags rule34.xxx does not hold, or says how many",
1219
+ "posts the tags match once the restrictions are dropped."
1220
+ ].join(" ");
1221
+ var searchPostsInput = strictInput({
1222
+ tags: tagNames("tags", 1, MAX_TAGS).describe(
1223
+ "Tags a post must all carry, such as ['asuka langley sohryu', 'black hair']."
1224
+ ),
1225
+ any_of: tagNames("any_of", 1, MAX_TAGS).optional().describe("Tags a post must carry at least one of."),
1226
+ exclude: tagNames("exclude", 1, MAX_TAGS).optional().describe("Tags a post must not carry, such as ['monochrome'] to leave out black and white."),
1227
+ media_type: choice(
1228
+ "media_type",
1229
+ ["image", "animated", "video", "any"],
1230
+ "The site states this in its own tags."
1231
+ ).default("any").describe(
1232
+ "'image' for stills, 'animated' for GIFs, 'video' for MP4. The site states this in its own tags, so the classification is as good as the tagging."
1233
+ ),
1234
+ rating: choice(
1235
+ "rating",
1236
+ ["questionable", "explicit"],
1237
+ "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
+ ).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."),
1240
+ limit: wholeNumber("limit", 1, 100).default(20),
1241
+ page: wholeNumber("page", 1, MAX_PAGE).default(1)
1242
+ });
1243
+ 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())
1255
+ };
1256
+ function toSearch(args) {
1257
+ return {
1258
+ tags: args.tags,
1259
+ anyOf: args.any_of,
1260
+ exclude: args.exclude,
1261
+ mediaType: args.media_type,
1262
+ rating: args.rating,
1263
+ sort: args.sort,
1264
+ limit: args.limit,
1265
+ page: args.page
1266
+ };
1267
+ }
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 `
1278
+
1279
+ rule34.xxx holds more, and page ${MAX_PAGE} is as far as this tool reads. Narrow the search instead.`;
1280
+ }
1281
+ var quoted = (tags) => tags.map((tag) => `'${tag}'`).join(", ");
1282
+ function restrictionsIn(args) {
1283
+ const named = [];
1284
+ if ((args.any_of ?? []).length > 0) {
1285
+ named.push("any_of");
1286
+ }
1287
+ if ((args.exclude ?? []).length > 0) {
1288
+ named.push("exclude");
1289
+ }
1290
+ if (args.media_type !== "any") {
1291
+ named.push("media_type");
1292
+ }
1293
+ if (args.rating) {
1294
+ named.push("rating");
1295
+ }
1296
+ return named;
1297
+ }
1298
+ async function explainEmptySearch(client, args) {
1299
+ const alternatives = args.any_of ?? [];
1300
+ const unknownRequired = await client.findUnknownTags(args.tags);
1301
+ const unknownAlternatives = alternatives.length > 0 ? await client.findUnknownTags(alternatives) : [];
1302
+ if (unknownRequired.length > 0) {
1303
+ 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.`
1305
+ ];
1306
+ }
1307
+ if (alternatives.length > 0 && unknownAlternatives.length === alternatives.length) {
1308
+ return [
1309
+ `rule34.xxx holds none of the alternatives ${quoted(unknownAlternatives)}, so no post could carry one of them.`
1310
+ ];
1311
+ }
1312
+ const restrictions = restrictionsIn(args);
1313
+ if (restrictions.length > 0) {
1314
+ const { data } = await client.searchPosts({ tags: args.tags, limit: 1, page: 1 });
1315
+ if (data.total > 0) {
1316
+ return [
1317
+ `The tags alone match ${data.total.toLocaleString("en-US")} post(s). What emptied this search is the restriction on ${restrictions.join(", ")}.`
1318
+ ];
1319
+ }
1320
+ }
1321
+ return ["Every tag this search requires exists on rule34.xxx; no post carries them together."];
1322
+ }
1323
+ async function runSearchPosts(client, args) {
1324
+ try {
1325
+ const search = toSearch(args);
1326
+ const spelled = normalizeSearch(search);
1327
+ const query = buildSearchQuery(search);
1328
+ const { data, cached } = await client.searchPosts(search);
1329
+ const notes = [];
1330
+ if (cached) {
1331
+ notes.push("Served from this server's short-lived in-memory cache.");
1332
+ }
1333
+ const posts = data.posts.map(toPostOut);
1334
+ const trimmed = posts.filter((post) => post.tags_total > post.tags.length).length;
1335
+ if (trimmed > 0) {
1336
+ notes.push(
1337
+ `${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
+ );
1339
+ }
1340
+ const hasMore = args.page * args.limit < data.total;
1341
+ if (data.total === 0) {
1342
+ notes.push(...await explainEmptySearch(client, args));
1343
+ }
1344
+ const structured = {
1345
+ tags: spelled.required,
1346
+ any_of: spelled.alternatives,
1347
+ exclude: spelled.excluded,
1348
+ query,
1349
+ total: data.total,
1350
+ page: args.page,
1351
+ posts,
1352
+ has_more: hasMore,
1353
+ next_page: hasMore && args.page < MAX_PAGE ? args.page + 1 : null,
1354
+ source: "rule34.xxx",
1355
+ notes
1356
+ };
1357
+ 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
+ const body = posts.length > 0 ? `
1359
+ ${renderPostList(posts)}` : "";
1360
+ return ok(structured, `${header}${body}${whatFollowsThisPage(hasMore, args.page)}`, notes);
1361
+ } catch (error) {
1362
+ return toToolError(error);
1363
+ }
1364
+ }
1365
+
1366
+ // src/server.ts
1367
+ var READ_ONLY = {
1368
+ readOnlyHint: true,
1369
+ destructiveHint: false,
1370
+ idempotentHint: true,
1371
+ openWorldHint: true
1372
+ };
1373
+ function createServer(options = {}) {
1374
+ const config = options.config ?? loadConfig();
1375
+ const logger = options.logger ?? createLogger(config.logLevel);
1376
+ const client = new Rule34Client({
1377
+ config,
1378
+ logger,
1379
+ ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
1380
+ });
1381
+ const server = new McpServer(
1382
+ { name: "mcp-rule34", version: PKG_VERSION },
1383
+ {
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."
1385
+ }
1386
+ );
1387
+ server.registerTool(
1388
+ "search_posts",
1389
+ {
1390
+ title: "Search posts by tag",
1391
+ description: searchPostsDescription,
1392
+ inputSchema: searchPostsInput,
1393
+ outputSchema: z5.object(searchPostsOutputShape),
1394
+ annotations: READ_ONLY
1395
+ },
1396
+ async (args) => runSearchPosts(client, args)
1397
+ );
1398
+ server.registerTool(
1399
+ "get_post",
1400
+ {
1401
+ title: "Read one post",
1402
+ description: getPostDescription,
1403
+ inputSchema: getPostInput,
1404
+ outputSchema: z5.object(getPostOutputShape),
1405
+ annotations: READ_ONLY
1406
+ },
1407
+ async (args) => runGetPost(client, args)
1408
+ );
1409
+ logger.info(
1410
+ `ready: user-agent="${config.userAgent}", min interval ${config.minIntervalMs}ms, cache ${config.cacheTtlMs}ms, credentials ${config.credentials ? "set" : "missing"}`
1411
+ );
1412
+ return server;
1413
+ }
1414
+
1415
+ // src/index.ts
1416
+ async function main() {
1417
+ const config = loadConfig();
1418
+ const logger = createLogger(config.logLevel);
1419
+ const server = createServer({ config, logger });
1420
+ const transport = new StdioServerTransport();
1421
+ await server.connect(transport);
1422
+ const shutdown = () => {
1423
+ void server.close().finally(() => process.exit(0));
1424
+ };
1425
+ process.on("SIGINT", shutdown);
1426
+ process.on("SIGTERM", shutdown);
1427
+ }
1428
+ main().catch((error) => {
1429
+ process.stderr.write(
1430
+ `[mcp-rule34] fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}
1431
+ `
1432
+ );
1433
+ process.exit(1);
1434
+ });
1435
+ //# sourceMappingURL=index.js.map