mcp-rule34 1.0.0 → 2.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/CHANGELOG.md +88 -0
- package/LICENSE +1 -1
- package/README.md +61 -0
- package/dist/index.d.ts +0 -0
- package/dist/index.js +36 -26
- package/dist/index.js.map +1 -1
- package/dist/rule34/client.d.ts +0 -0
- package/dist/rule34/client.js +23 -17
- package/dist/rule34/client.js.map +1 -1
- package/package.json +11 -9
- package/server.json +2 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 2.0.0
|
|
4
|
+
|
|
5
|
+
- **This server now needs node 24 or later.** Node 20 reached its end of
|
|
6
|
+
support on 2026-04-30 and node 22 is no longer what this code is built and
|
|
7
|
+
typed against. That is what makes this a major version: an install on an
|
|
8
|
+
older node is refused rather than left to fail somewhere later.
|
|
9
|
+
- **A container image is published for each version**, on ghcr, for amd64 and
|
|
10
|
+
arm64. The readme carries the configuration that runs it.
|
|
11
|
+
- The published package carries its changelog, and the entry point it declares
|
|
12
|
+
for the package root now publishes its types.
|
|
13
|
+
|
|
14
|
+
All notable changes to this project are documented here. The format follows
|
|
15
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
16
|
+
follows [semantic versioning](https://semver.org/spec/v2.0.0.html).
|
|
17
|
+
|
|
18
|
+
## [1.1.0] - 2026-08-24
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Every error carries the error that led to it as its `cause`. A caller still
|
|
23
|
+
reads what this server could not do; a maintainer reading a stack finds the
|
|
24
|
+
parse or URL failure underneath.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- The lint configuration matches the one the rest of the collection runs, which
|
|
29
|
+
is sixty-three rules wider. Reading two repositories the same way is worth
|
|
30
|
+
more than either set of rules.
|
|
31
|
+
- Shutdown closes the server without discarding the promise, and leaves the
|
|
32
|
+
process on either path.
|
|
33
|
+
|
|
34
|
+
## [1.0.0] - 2026-08-24
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- `find_tags`: ask rule34.xxx which tag names begin with a piece of text, and
|
|
39
|
+
how many posts each one carries. The route needs no credentials, so a server
|
|
40
|
+
with none configured can still say how a tag is spelled.
|
|
41
|
+
- A search that finds nothing because a required tag does not exist now offers
|
|
42
|
+
the names the site does hold that begin like it.
|
|
43
|
+
- A page beyond the end of a search says how many pages the search fills and
|
|
44
|
+
that the page asked for lies past them, rather than serving an empty list with
|
|
45
|
+
nothing said.
|
|
46
|
+
- A search whose results outrun the page ceiling says so, and names raising
|
|
47
|
+
`limit` as the way to reach the rest.
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- A search row leads with the tags the search asked for, so a post matching on a
|
|
52
|
+
name the site lists late still shows why it is there. Only names the post
|
|
53
|
+
carries are lifted.
|
|
54
|
+
- `created_at` is described as the date rule34.xxx took the post in. The site
|
|
55
|
+
imported much of its older catalogue in bulk, so thousands of posts share one
|
|
56
|
+
day in November 2010, and reading that as a publication date states something
|
|
57
|
+
about the work that the data says about the upload.
|
|
58
|
+
- The `sort` argument describes all four orders it accepts, `updated` and
|
|
59
|
+
`random` included.
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- A link carrying `id` twice with different values is refused instead of
|
|
64
|
+
answering about the first of the two.
|
|
65
|
+
- A link whose `id` is not a usable number says so, rather than reporting that
|
|
66
|
+
the link carries no id at all.
|
|
67
|
+
- A tag written with spaces alone is refused for being empty, without quoting a
|
|
68
|
+
length it does not exceed.
|
|
69
|
+
|
|
70
|
+
## [0.1.0] - 2026-08-23
|
|
71
|
+
|
|
72
|
+
First release.
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
|
|
76
|
+
- `search_posts`: search rule34.xxx posts by one tag or by several at once, with
|
|
77
|
+
alternatives and exclusions as their own arguments, a media type, a rating, a
|
|
78
|
+
sort, and pagination. A row shows the first twelve tags of a post and states
|
|
79
|
+
how many it carries in all.
|
|
80
|
+
- `get_post`: read one post named by its id or by a link to its page, with every
|
|
81
|
+
tag typed and counted, the uploader, the comment count and the publication
|
|
82
|
+
date. The post is read from both formats the API publishes, because neither
|
|
83
|
+
carries all of that on its own.
|
|
84
|
+
- Credentials read from `RULE34_USER_ID` and `RULE34_API_KEY`. A server without
|
|
85
|
+
them starts, publishes its tools, and answers each call by naming what to set.
|
|
86
|
+
- An empty search of several tags names the tags the site does not hold.
|
|
87
|
+
- The reading layer published on its own under the `./client` subpath, with the
|
|
88
|
+
same pacing, cache and error taxonomy and no protocol attached.
|
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -42,6 +42,36 @@ claude mcp add rule34 --env RULE34_USER_ID=your-id --env RULE34_API_KEY=your-key
|
|
|
42
42
|
}
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
### With Docker
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"rule34": {
|
|
51
|
+
"command": "docker",
|
|
52
|
+
"args": [
|
|
53
|
+
"run",
|
|
54
|
+
"-i",
|
|
55
|
+
"--rm",
|
|
56
|
+
"-e",
|
|
57
|
+
"RULE34_USER_ID",
|
|
58
|
+
"-e",
|
|
59
|
+
"RULE34_API_KEY",
|
|
60
|
+
"ghcr.io/smeet666/mcp-rule34:2.0.0"
|
|
61
|
+
],
|
|
62
|
+
"env": {
|
|
63
|
+
"RULE34_USER_ID": "your-key",
|
|
64
|
+
"RULE34_API_KEY": "your-key"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`-i` keeps stdin open, which is where the protocol travels, and no `-t` is
|
|
72
|
+
passed: a TTY rewrites the stream and breaks it. The container needs outbound
|
|
73
|
+
HTTPS to `rule34.xxx` and `api.rule34.xxx`, and nothing else: no volume, no port, and the keys travel through the environment rather than inside the image.
|
|
74
|
+
|
|
45
75
|
Put this in your **user** configuration rather than in a project file: a
|
|
46
76
|
`.mcp.json` at the root of a repository travels with the repository, and the key
|
|
47
77
|
travels with it.
|
|
@@ -262,6 +292,37 @@ claude mcp add rule34 --env RULE34_USER_ID=votre-id --env RULE34_API_KEY=votre-c
|
|
|
262
292
|
}
|
|
263
293
|
```
|
|
264
294
|
|
|
295
|
+
### Avec Docker
|
|
296
|
+
|
|
297
|
+
```json
|
|
298
|
+
{
|
|
299
|
+
"mcpServers": {
|
|
300
|
+
"rule34": {
|
|
301
|
+
"command": "docker",
|
|
302
|
+
"args": [
|
|
303
|
+
"run",
|
|
304
|
+
"-i",
|
|
305
|
+
"--rm",
|
|
306
|
+
"-e",
|
|
307
|
+
"RULE34_USER_ID",
|
|
308
|
+
"-e",
|
|
309
|
+
"RULE34_API_KEY",
|
|
310
|
+
"ghcr.io/smeet666/mcp-rule34:2.0.0"
|
|
311
|
+
],
|
|
312
|
+
"env": {
|
|
313
|
+
"RULE34_USER_ID": "your-key",
|
|
314
|
+
"RULE34_API_KEY": "your-key"
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
`-i` garde l'entrée standard ouverte, qui est le canal du protocole, et aucun
|
|
322
|
+
`-t` n'est passé : un terminal réécrit le flux et le casse. Le conteneur a besoin
|
|
323
|
+
d'un accès HTTPS sortant vers `rule34.xxx` et `api.rule34.xxx`, et de rien d'autre :
|
|
324
|
+
aucun volume, aucun port, et les clés voyagent par l'environnement plutôt que dans l'image.
|
|
325
|
+
|
|
265
326
|
À placer dans votre configuration **utilisateur** plutôt que dans un fichier de
|
|
266
327
|
projet : un `.mcp.json` à la racine d'un dépôt part avec le dépôt, et la clé
|
|
267
328
|
part avec lui.
|
package/dist/index.d.ts
CHANGED
|
File without changes
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
+
import process2 from "process";
|
|
4
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
6
|
|
|
7
|
+
// src/config.ts
|
|
8
|
+
import process from "process";
|
|
9
|
+
|
|
6
10
|
// src/version.ts
|
|
7
|
-
var PKG_VERSION = "
|
|
11
|
+
var PKG_VERSION = "2.0.0";
|
|
8
12
|
var REPO_URL = "https://github.com/smeet666/mcp-rule34";
|
|
9
13
|
|
|
10
14
|
// src/config.ts
|
|
@@ -125,8 +129,8 @@ import { z as z6 } from "zod";
|
|
|
125
129
|
|
|
126
130
|
// src/errors.ts
|
|
127
131
|
var Rule34Error = class extends Error {
|
|
128
|
-
constructor(code, text, details = {}) {
|
|
129
|
-
super(`[${code}] ${text}
|
|
132
|
+
constructor(code, text, details = {}, options) {
|
|
133
|
+
super(`[${code}] ${text}`, options);
|
|
130
134
|
this.code = code;
|
|
131
135
|
this.details = details;
|
|
132
136
|
this.name = "Rule34Error";
|
|
@@ -135,8 +139,8 @@ var Rule34Error = class extends Error {
|
|
|
135
139
|
details;
|
|
136
140
|
};
|
|
137
141
|
var ISSUES_URL = "https://github.com/smeet666/mcp-rule34/issues";
|
|
138
|
-
function invalidInput(message, hint) {
|
|
139
|
-
return new Rule34Error("invalid_input", message, hint ? { hint } : {});
|
|
142
|
+
function invalidInput(message, hint, cause) {
|
|
143
|
+
return new Rule34Error("invalid_input", message, hint ? { hint } : {}, { cause });
|
|
140
144
|
}
|
|
141
145
|
function missingCredentials(url) {
|
|
142
146
|
return new Rule34Error(
|
|
@@ -167,11 +171,12 @@ function rateLimited(url, retryAfterMs) {
|
|
|
167
171
|
}
|
|
168
172
|
);
|
|
169
173
|
}
|
|
170
|
-
function parseFailure(url, what) {
|
|
174
|
+
function parseFailure(url, what, cause) {
|
|
171
175
|
return new Rule34Error(
|
|
172
176
|
"parse_failure",
|
|
173
177
|
`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}` }
|
|
178
|
+
{ url, hint: `Please report this, with the query you used, at ${ISSUES_URL}` },
|
|
179
|
+
{ cause }
|
|
175
180
|
);
|
|
176
181
|
}
|
|
177
182
|
function upstreamFailure(url, message) {
|
|
@@ -400,10 +405,11 @@ function readPostPageUrl(raw) {
|
|
|
400
405
|
let parsed;
|
|
401
406
|
try {
|
|
402
407
|
parsed = new URL(raw.trim());
|
|
403
|
-
} catch {
|
|
408
|
+
} catch (error) {
|
|
404
409
|
throw invalidInput(
|
|
405
410
|
`"${raw}" is not a URL.`,
|
|
406
|
-
"Pass a link to a post's page, such as https://rule34.xxx/index.php?page=post&s=view&id=2195419"
|
|
411
|
+
"Pass a link to a post's page, such as https://rule34.xxx/index.php?page=post&s=view&id=2195419",
|
|
412
|
+
error
|
|
407
413
|
);
|
|
408
414
|
}
|
|
409
415
|
const host = parsed.hostname.toLowerCase();
|
|
@@ -588,6 +594,8 @@ function asTransportError(error, safeUrl) {
|
|
|
588
594
|
}
|
|
589
595
|
|
|
590
596
|
// src/rule34/parsers/postDetail.ts
|
|
597
|
+
var AUTHENTICATION = /authenticat/i;
|
|
598
|
+
var WHITESPACE = /\s+/;
|
|
591
599
|
var TYPES = {
|
|
592
600
|
tag: "general",
|
|
593
601
|
artist: "artist",
|
|
@@ -603,11 +611,11 @@ function parsePostDetail(body, url, id) {
|
|
|
603
611
|
let parsed;
|
|
604
612
|
try {
|
|
605
613
|
parsed = JSON.parse(body);
|
|
606
|
-
} catch {
|
|
607
|
-
throw parseFailure(safeUrl, "the response is not JSON");
|
|
614
|
+
} catch (error) {
|
|
615
|
+
throw parseFailure(safeUrl, "the response is not JSON", error);
|
|
608
616
|
}
|
|
609
617
|
if (typeof parsed === "string") {
|
|
610
|
-
if (
|
|
618
|
+
if (AUTHENTICATION.test(parsed)) {
|
|
611
619
|
throw missingCredentials(safeUrl);
|
|
612
620
|
}
|
|
613
621
|
throw upstreamFailure(safeUrl, parsed);
|
|
@@ -626,7 +634,7 @@ function parsePostDetail(body, url, id) {
|
|
|
626
634
|
}
|
|
627
635
|
function readPost(raw, askedFor) {
|
|
628
636
|
const id = readInteger(raw.id) ?? askedFor;
|
|
629
|
-
const tags = readString(raw.tags).split(
|
|
637
|
+
const tags = readString(raw.tags).split(WHITESPACE).filter((tag) => tag !== "");
|
|
630
638
|
return {
|
|
631
639
|
id,
|
|
632
640
|
postUrl: buildPostPageUrl(id),
|
|
@@ -699,8 +707,8 @@ function parseTagSuggestions(body, url) {
|
|
|
699
707
|
let parsed;
|
|
700
708
|
try {
|
|
701
709
|
parsed = JSON.parse(body);
|
|
702
|
-
} catch {
|
|
703
|
-
throw parseFailure(safeUrl, "the response is not JSON");
|
|
710
|
+
} catch (error) {
|
|
711
|
+
throw parseFailure(safeUrl, "the response is not JSON", error);
|
|
704
712
|
}
|
|
705
713
|
if (!Array.isArray(parsed)) {
|
|
706
714
|
throw parseFailure(safeUrl, "the response is not a list of suggestions");
|
|
@@ -726,6 +734,7 @@ function parseTagSuggestions(body, url) {
|
|
|
726
734
|
|
|
727
735
|
// src/rule34/parsers/document.ts
|
|
728
736
|
import { XMLParser } from "fast-xml-parser";
|
|
737
|
+
var AUTHENTICATION2 = /authenticat/i;
|
|
729
738
|
var parser = new XMLParser({
|
|
730
739
|
ignoreAttributes: false,
|
|
731
740
|
attributeNamePrefix: "@_",
|
|
@@ -751,7 +760,7 @@ function readDocument(xml, safeUrl) {
|
|
|
751
760
|
const failure = parsed.error;
|
|
752
761
|
if (failure !== void 0) {
|
|
753
762
|
const message = typeof failure === "string" ? failure : String(failure);
|
|
754
|
-
if (
|
|
763
|
+
if (AUTHENTICATION2.test(message)) {
|
|
755
764
|
throw missingCredentials(safeUrl);
|
|
756
765
|
}
|
|
757
766
|
throw upstreamFailure(safeUrl, message);
|
|
@@ -778,6 +787,7 @@ function readInteger2(value) {
|
|
|
778
787
|
}
|
|
779
788
|
|
|
780
789
|
// src/rule34/parsers/posts.ts
|
|
790
|
+
var WHITESPACE2 = /\s+/;
|
|
781
791
|
var RATINGS = {
|
|
782
792
|
e: "explicit",
|
|
783
793
|
q: "questionable",
|
|
@@ -817,7 +827,7 @@ function readPost2(attributes) {
|
|
|
817
827
|
// An unknown letter is carried as it came: naming it would state a rating
|
|
818
828
|
// the site never wrote.
|
|
819
829
|
rating: RATINGS[rating] ?? rating,
|
|
820
|
-
tags: (attributes["@_tags"] ?? "").split(
|
|
830
|
+
tags: (attributes["@_tags"] ?? "").split(WHITESPACE2).filter((tag) => tag !== ""),
|
|
821
831
|
source: readText2(attributes["@_source"]),
|
|
822
832
|
// The attribute is present and empty on a post with no parent, and 0 is a
|
|
823
833
|
// post id that belongs to somebody else.
|
|
@@ -989,7 +999,7 @@ var Rule34Client = class {
|
|
|
989
999
|
config: this.deps.config,
|
|
990
1000
|
limiter: this.limiter,
|
|
991
1001
|
logger: this.deps.logger,
|
|
992
|
-
fetchImpl: this.deps.fetchImpl
|
|
1002
|
+
...this.deps.fetchImpl ? { fetchImpl: this.deps.fetchImpl } : {}
|
|
993
1003
|
});
|
|
994
1004
|
this.cache.set(key, body);
|
|
995
1005
|
return { body, cached: false };
|
|
@@ -1390,10 +1400,10 @@ var searchPostsOutputShape = {
|
|
|
1390
1400
|
function toSearch(args) {
|
|
1391
1401
|
return {
|
|
1392
1402
|
tags: args.tags,
|
|
1393
|
-
anyOf: args.any_of,
|
|
1394
|
-
exclude: args.exclude,
|
|
1403
|
+
...args.any_of ? { anyOf: args.any_of } : {},
|
|
1404
|
+
...args.exclude ? { exclude: args.exclude } : {},
|
|
1395
1405
|
mediaType: args.media_type,
|
|
1396
|
-
rating: args.rating,
|
|
1406
|
+
...args.rating ? { rating: args.rating } : {},
|
|
1397
1407
|
sort: args.sort,
|
|
1398
1408
|
limit: args.limit,
|
|
1399
1409
|
page: args.page
|
|
@@ -1587,16 +1597,16 @@ async function main() {
|
|
|
1587
1597
|
const transport = new StdioServerTransport();
|
|
1588
1598
|
await server.connect(transport);
|
|
1589
1599
|
const shutdown = () => {
|
|
1590
|
-
|
|
1600
|
+
server.close().catch(() => void 0).finally(() => process2.exit(0));
|
|
1591
1601
|
};
|
|
1592
|
-
|
|
1593
|
-
|
|
1602
|
+
process2.on("SIGINT", shutdown);
|
|
1603
|
+
process2.on("SIGTERM", shutdown);
|
|
1594
1604
|
}
|
|
1595
1605
|
main().catch((error) => {
|
|
1596
|
-
|
|
1606
|
+
process2.stderr.write(
|
|
1597
1607
|
`[mcp-rule34] fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}
|
|
1598
1608
|
`
|
|
1599
1609
|
);
|
|
1600
|
-
|
|
1610
|
+
process2.exit(1);
|
|
1601
1611
|
});
|
|
1602
1612
|
//# sourceMappingURL=index.js.map
|