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/LICENSE +21 -0
- package/README.md +410 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1435 -0
- package/dist/index.js.map +1 -0
- package/dist/rule34/client.d.ts +221 -0
- package/dist/rule34/client.js +811 -0
- package/dist/rule34/client.js.map +1 -0
- package/package.json +84 -0
- package/server.json +56 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/errors.ts","../../src/rule34/cache.ts","../../src/rule34/rateLimiter.ts","../../src/rule34/urls.ts","../../src/rule34/http.ts","../../src/rule34/parsers/postDetail.ts","../../src/rule34/parsers/document.ts","../../src/rule34/parsers/posts.ts","../../src/rule34/parsers/tags.ts","../../src/rule34/client.ts"],"sourcesContent":["/**\n * Error taxonomy surfaced to the calling model.\n *\n * A caller branches on the code that opens the message, so the code has to say\n * where the fault is. The distinction that matters most on this route is\n * between a document this server could not read and an account it was not given\n * the credentials for: the site answers both with HTTP 200, and only one of\n * them is fixed by editing a configuration file.\n */\n\nexport type ErrorCode =\n | \"not_found\"\n | \"invalid_input\"\n | \"rate_limited\"\n | \"parse_failure\"\n | \"network_error\"\n | \"timeout\";\n\nexport interface ErrorDetails {\n url?: string;\n status?: number;\n retryAfterMs?: number;\n hint?: string;\n}\n\nexport class Rule34Error extends Error {\n constructor(\n readonly code: ErrorCode,\n text: string,\n readonly details: ErrorDetails = {},\n ) {\n super(`[${code}] ${text}`);\n this.name = \"Rule34Error\";\n }\n}\n\nconst ISSUES_URL = \"https://github.com/smeet666/mcp-rule34/issues\";\n\nexport function invalidInput(message: string, hint?: string): Rule34Error {\n return new Rule34Error(\"invalid_input\", message, hint ? { hint } : {});\n}\n\n/**\n * The credentials are missing or refused.\n *\n * This is `invalid_input` rather than a failure of the site: the request was\n * well formed and the site answered it, and what is missing lives in the\n * caller's own configuration.\n */\nexport function missingCredentials(url: string): Rule34Error {\n return new Rule34Error(\n \"invalid_input\",\n \"rule34.xxx answered that this request carries no valid API credentials.\",\n {\n url,\n hint:\n \"Set RULE34_USER_ID and RULE34_API_KEY in the env block of this server's entry in your MCP client \" +\n \"configuration. Both are shown under 'API Access Credentials' at \" +\n \"https://rule34.xxx/index.php?page=account&s=options once you are signed in. The site issues one key \" +\n \"per person, so use your own rather than sharing one.\",\n },\n );\n}\n\nexport function notFound(url: string): Rule34Error {\n return new Rule34Error(\"not_found\", \"rule34.xxx has nothing at this address.\", {\n url,\n status: 404,\n hint: \"Check the post id. Ids come from a search; they are not stable to guess by hand.\",\n });\n}\n\n/**\n * The site is being asked too often.\n *\n * This never becomes an empty result. rule34.xxx limits its rate without\n * publishing one, and a refusal rendered as zero posts reads as \"the site holds\n * nothing on this tag\", which is the one thing a caller must not be told here.\n */\nexport function rateLimited(url: string, retryAfterMs: number): Rule34Error {\n return new Rule34Error(\n \"rate_limited\",\n \"rule34.xxx is refusing requests from this client for now (HTTP 429). \" +\n \"This does NOT mean the search found nothing.\",\n {\n url,\n status: 429,\n retryAfterMs,\n hint:\n \"Wait before asking again. If it keeps happening, raise RULE34_MIN_INTERVAL_MS in your MCP client \" +\n \"configuration to slow this server down.\",\n },\n );\n}\n\nexport function parseFailure(url: string, what: string): Rule34Error {\n return new Rule34Error(\n \"parse_failure\",\n `The request was answered, but not with the document this route publishes (${what}).`,\n { url, hint: `Please report this, with the query you used, at ${ISSUES_URL}` },\n );\n}\n\n/** The site answered with a failure of its own, in the shape its API documents. */\nexport function upstreamFailure(url: string, message: string): Rule34Error {\n return new Rule34Error(\"network_error\", `rule34.xxx reported a failure: ${message}`, {\n url,\n hint: \"This is the site's own message. Waiting and asking again is usually the only remedy.\",\n });\n}\n","/**\n * In-memory TTL and LRU cache.\n *\n * Nothing is written to disk. This exists so that the same question asked twice\n * inside one conversation does not reach rule34.xxx twice, which matters more\n * here than elsewhere: the site limits its rate without publishing one, and\n * every request spent on a repeat is a request unavailable to a new question.\n *\n * A Map iterates in insertion order, so re-inserting on every hit is enough to\n * make the first key the least recently used one.\n */\n\ninterface Entry<V> {\n value: V;\n expiresAt: number;\n}\n\nexport class TtlLruCache<V> {\n private readonly store = new Map<string, Entry<V>>();\n\n constructor(\n private readonly maxEntries: number,\n private readonly ttlMs: number,\n ) {}\n\n get(key: string): V | undefined {\n const entry = this.store.get(key);\n if (!entry) {\n return undefined;\n }\n if (Date.now() >= entry.expiresAt) {\n this.store.delete(key);\n return undefined;\n }\n this.store.delete(key);\n this.store.set(key, entry);\n return entry.value;\n }\n\n set(key: string, value: V): void {\n if (this.maxEntries <= 0 || this.ttlMs <= 0) {\n return;\n }\n this.store.delete(key);\n this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });\n while (this.store.size > this.maxEntries) {\n const oldest = this.store.keys().next();\n if (oldest.done) {\n break;\n }\n this.store.delete(oldest.value);\n }\n }\n\n /** The keys currently held, in least recently used order. */\n keys(): string[] {\n return [...this.store.keys()];\n }\n\n get size(): number {\n return this.store.size;\n }\n\n clear(): void {\n this.store.clear();\n }\n}\n","/**\n * Serial request queue with an adaptive minimum interval.\n *\n * rule34.xxx limits its rate without publishing what the rate is, and answers a\n * burst with HTTP 429. Requests therefore run one at a time with a floor on the\n * gap between starts. When the site does push back, the interval doubles and\n * then decays as requests succeed, which recovers faster than a fixed delay and\n * behaves better than asking at a constant rate the site never agreed to.\n */\n\nexport interface RateLimiterOptions {\n minIntervalMs: number;\n maxIntervalMs?: number;\n}\n\nconst DEFAULT_MAX_INTERVAL_MS = 30_000;\n\nexport class RateLimiter {\n private readonly baseIntervalMs: number;\n private readonly maxIntervalMs: number;\n private intervalMs: number;\n /** Tail of the queue: each task chains onto the previous one. */\n private tail: Promise<unknown> = Promise.resolve();\n private lastStart = 0;\n\n constructor(options: RateLimiterOptions) {\n this.baseIntervalMs = Math.max(0, options.minIntervalMs);\n this.maxIntervalMs = Math.max(\n this.baseIntervalMs,\n options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS,\n );\n this.intervalMs = this.baseIntervalMs;\n }\n\n get currentIntervalMs(): number {\n return this.intervalMs;\n }\n\n /** Queue a task. Tasks run in call order, one at a time. */\n schedule<T>(task: () => Promise<T>): Promise<T> {\n const run = this.tail.then(async () => task());\n // The queue must keep draining even when a task rejects.\n this.tail = run.then(\n () => undefined,\n () => undefined,\n );\n return run;\n }\n\n /**\n * Wait for this request's slot, then claim it.\n *\n * Called once per upstream request rather than once per task, because a task\n * runs a whole retry chain: stamping only its start would let the next task\n * follow the chain's last request with no gap at all.\n */\n async beforeRequest(): Promise<void> {\n await this.waitForSlot();\n this.lastStart = Date.now();\n }\n\n /** Called after the site answers that it is being asked too often. */\n penalize(): void {\n const next = this.intervalMs === 0 ? 500 : this.intervalMs * 2;\n this.intervalMs = Math.min(this.maxIntervalMs, next);\n }\n\n /** Called after a success, so one push-back does not slow things down forever. */\n relax(): void {\n this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));\n }\n\n private async waitForSlot(): Promise<void> {\n if (this.intervalMs === 0 || this.lastStart === 0) {\n return;\n }\n const elapsed = Date.now() - this.lastStart;\n // Clamped to the interval: a clock stepped backwards would otherwise make\n // this wait for the size of the step, and the queue is serial, so every\n // pending request would wait behind it.\n const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);\n if (remaining > 0) {\n await sleep(remaining);\n }\n }\n}\n\nexport function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n","/**\n * URL construction for rule34.xxx, and the one place a tag becomes a query.\n *\n * Everything a caller supplies passes through here, which is why the refusals\n * live here too: a tag is one token, and a token that carries a metatag or a\n * negation would set a filter the answer then reports as something else.\n */\n\nimport { invalidInput } from \"../errors.js\";\nimport type { MediaType, Rating, SortOrder } from \"../types.js\";\n\nexport const API_URL = \"https://api.rule34.xxx/index.php\";\nexport const SITE_URL = \"https://rule34.xxx/index.php\";\n\n/** The site refuses more than this in one request, and caps silently below it. */\nexport const MAX_LIMIT = 1000;\n\n/**\n * How each media type is expressed in the site's own tags.\n *\n * The site holds the distinction as tags, so it does the filtering and the\n * total it returns counts what was asked for. `animated` covers the GIFs, which\n * carry that tag without carrying `video`.\n */\nconst MEDIA_TOKENS: Record<MediaType, string[]> = {\n image: [\"-video\", \"-animated\"],\n animated: [\"animated\", \"-video\"],\n video: [\"video\"],\n any: [],\n};\n\nconst SORT_TOKENS: Record<SortOrder, string> = {\n score: \"sort:score:desc\",\n id: \"sort:id:desc\",\n updated: \"sort:updated:desc\",\n random: \"sort:random\",\n};\n\nexport interface Credentials {\n apiKey: string;\n userId: string;\n}\n\nexport interface PostSearch {\n /** Tags a post must all carry. */\n tags: string[];\n /** Tags a post must carry at least one of. */\n anyOf?: string[];\n /** Tags a post must not carry. */\n exclude?: string[];\n mediaType?: MediaType;\n rating?: Rating;\n sort?: SortOrder;\n limit: number;\n /** Counted from 1, the way a caller counts. */\n page: number;\n}\n\n/**\n * How many tags one list may hold.\n *\n * The site accepts more, and a search of a dozen tags answers normally. The\n * bound is here because every tag is one more term to diagnose when a search\n * comes back empty, and a request whose address runs to kilobytes is one no\n * error message can usefully quote.\n */\nexport const MAX_TAGS = 10;\n\n/**\n * Write a name the way rule34.xxx spells a tag.\n *\n * The site stores a tag as one token, joining with underscores what a person\n * says as words, so \"asuka langley sohryu\" and \"asuka_langley_sohryu\" are the\n * same request. Punctuation inside the name is part of it: the series tag is\n * `ranma_1/2`, slash included.\n */\nexport function normalizeTag(raw: string): string {\n const trimmed = raw.trim();\n if (trimmed === \"\") {\n throw invalidInput(\n \"A tag is required.\",\n \"Pass the tag to search for, such as 'black hair' or 'asuka langley sohryu'.\",\n );\n }\n\n if (trimmed.startsWith(\"-\")) {\n throw invalidInput(\n `\"${trimmed}\" starts with '-', which means \"without this tag\" on rule34.xxx.`,\n \"This tool searches for a tag. Pass the tag itself, without the leading dash.\",\n );\n }\n\n if (trimmed.includes(\":\")) {\n throw invalidInput(\n `\"${trimmed}\" carries a ':', which rule34.xxx reads as a filter rather than as a tag.`,\n \"Filters are separate arguments here: use 'rating', 'sort' and 'media_type'.\",\n );\n }\n\n return trimmed.replace(/\\s+/g, \"_\").toLowerCase();\n}\n\n/** Normalize a list of tags, refusing a list longer than one search carries. */\nfunction normalizeList(raw: string[] | undefined, argument: string): string[] {\n const tags = (raw ?? []).map(normalizeTag);\n if (tags.length > MAX_TAGS) {\n throw invalidInput(\n `'${argument}' holds ${tags.length} tags, and this tool searches at most ${MAX_TAGS} at a time.`,\n \"Search on the tags that narrow the most, then look through the results.\",\n );\n }\n return tags;\n}\n\nexport interface NormalizedTags {\n required: string[];\n alternatives: string[];\n excluded: string[];\n}\n\n/**\n * The three lists as rule34.xxx spells them.\n *\n * An answer states the tags it searched for, and taking them back out of the\n * query string would state the site's grammar — its braces and its tilde — as\n * though they were tags.\n */\nexport function normalizeSearch(search: PostSearch): NormalizedTags {\n const required = normalizeList(search.tags, \"tags\");\n if (required.length === 0) {\n throw invalidInput(\n \"At least one tag is required.\",\n \"Pass the tags to search for, such as ['asuka langley sohryu', 'black hair'].\",\n );\n }\n\n const alternatives = normalizeList(search.anyOf, \"any_of\");\n const excluded = normalizeList(search.exclude, \"exclude\");\n\n // A tag both required and excluded matches nothing whatever the site holds,\n // and answering that with an empty result reads as an absence on the site's\n // side rather than a contradiction in the question.\n const contradiction = required.find((tag) => excluded.includes(tag));\n if (contradiction) {\n throw invalidInput(\n `'${contradiction}' is both required and excluded, so no post can match.`,\n \"Remove it from one of the two lists.\",\n );\n }\n\n return { required, alternatives, excluded };\n}\n\n/** The value of the site's `tags` parameter: the tags, then the filters. */\nexport function buildSearchQuery(search: PostSearch): string {\n const { required, alternatives, excluded } = normalizeSearch(search);\n\n const tokens = [...required];\n if (alternatives.length === 1) {\n // A group of one is the same request as the tag itself, and the braces\n // would suggest a choice the search does not make.\n tokens.push(...alternatives);\n } else if (alternatives.length > 1) {\n // The braces and the spaces inside them are the site's own grammar for a\n // choice; without them the tilde is read as part of a tag name.\n tokens.push(`( ${alternatives.join(\" ~ \")} )`);\n }\n\n tokens.push(...excluded.map((tag) => `-${tag}`), ...MEDIA_TOKENS[search.mediaType ?? \"any\"]);\n if (search.rating) {\n tokens.push(`rating:${search.rating}`);\n }\n if (search.sort) {\n tokens.push(SORT_TOKENS[search.sort]);\n }\n return tokens.join(\" \");\n}\n\nexport function buildPostSearchUrl(search: PostSearch, credentials: Credentials): string {\n if (!Number.isInteger(search.limit) || search.limit < 1 || search.limit > MAX_LIMIT) {\n throw invalidInput(\n `limit must be a whole number between 1 and ${MAX_LIMIT}, and ${search.limit} is not.`,\n `rule34.xxx serves at most ${MAX_LIMIT} posts in one request.`,\n );\n }\n if (!Number.isInteger(search.page) || search.page < 1) {\n throw invalidInput(`page must be a whole number from 1 upwards, and ${search.page} is not.`);\n }\n\n const query = encodeURIComponent(buildSearchQuery(search));\n // `pid` on this route is a page number the site multiplies by the limit. The\n // same name means an absolute offset on the site's own pages, so the two are\n // reconciled here and nowhere else.\n const pid = search.page - 1;\n\n return (\n `${API_URL}?page=dapi&s=post&q=index` +\n `&tags=${query}&limit=${search.limit}&pid=${pid}` +\n `&api_key=${encodeURIComponent(credentials.apiKey)}` +\n `&user_id=${encodeURIComponent(credentials.userId)}`\n );\n}\n\n/**\n * Ask the tag route whether one exact name exists.\n *\n * A search of several tags that comes back empty says nothing about which tag\n * emptied it, and a misspelled name looks exactly like a combination the site\n * genuinely has nothing for. This is what tells the two apart.\n */\nexport function buildTagLookupUrl(tag: string, credentials: Credentials): string {\n return (\n `${API_URL}?page=dapi&s=tag&q=index&name=${encodeURIComponent(normalizeTag(tag))}` +\n `&api_key=${encodeURIComponent(credentials.apiKey)}` +\n `&user_id=${encodeURIComponent(credentials.userId)}`\n );\n}\n\n/**\n * The id inside a link to a post's page.\n *\n * The URL is read and never fetched: this server builds its own request from\n * the id it finds. Refusing a foreign host is still right, because a link to\n * somewhere else names no post this site holds.\n */\nexport function readPostPageUrl(raw: string): number {\n let parsed: URL;\n try {\n parsed = new URL(raw.trim());\n } catch {\n throw invalidInput(\n `\"${raw}\" is not a URL.`,\n \"Pass a link to a post's page, such as https://rule34.xxx/index.php?page=post&s=view&id=2195419\",\n );\n }\n\n const host = parsed.hostname.toLowerCase();\n // The site serves its pages on the bare host and its media from subdomains it\n // adds to over time, so the suffix is what stays true.\n if (host !== \"rule34.xxx\" && !host.endsWith(\".rule34.xxx\")) {\n throw invalidInput(\n `\"${raw}\" is not a rule34.xxx address.`,\n \"This tool reads posts on rule34.xxx. A link elsewhere names no post it holds.\",\n );\n }\n\n if (parsed.searchParams.get(\"page\") !== \"post\" || parsed.searchParams.get(\"s\") !== \"view\") {\n throw invalidInput(\n `\"${raw}\" is a rule34.xxx address, but not a post's page.`,\n \"A post's page looks like https://rule34.xxx/index.php?page=post&s=view&id=2195419\",\n );\n }\n\n const id = Number(parsed.searchParams.get(\"id\"));\n if (!Number.isInteger(id) || id < 1) {\n throw invalidInput(`\"${raw}\" carries no post id.`);\n }\n return id;\n}\n\n/** The one post an `id` and a `url` name, refusing a pair that disagrees. */\nexport function resolvePostRef(ref: { id?: number; url?: string }): number {\n const fromUrl = ref.url === undefined ? null : readPostPageUrl(ref.url);\n const fromId = ref.id;\n\n if (fromId !== undefined) {\n if (!Number.isInteger(fromId) || fromId < 1) {\n throw invalidInput(`id must be a whole number from 1 upwards, and ${fromId} is not.`);\n }\n // Preferring one would be a coin flip on which of the caller's two\n // arguments was the mistake, and the answer would carry no trace of it.\n if (fromUrl !== null && fromUrl !== fromId) {\n throw invalidInput(\n `'id' names post ${fromId} and 'url' names post ${fromUrl}, so they cannot both be read.`,\n \"Pass one of the two, or make them agree.\",\n );\n }\n return fromId;\n }\n\n if (fromUrl !== null) {\n return fromUrl;\n }\n\n throw invalidInput(\n \"Either 'id' or 'url' must be given.\",\n \"Pass the numeric post id, or a link to the post's page on rule34.xxx.\",\n );\n}\n\n/**\n * A post is read from two addresses, because neither format carries everything.\n *\n * The XML states the publication date and the uploader's numeric id. The JSON\n * states the uploader's name, the number of comments, and a type and a count\n * for every tag. A fiche that read one of them would leave out what the other\n * one holds.\n */\nexport function buildPostByIdXmlUrl(id: number, credentials: Credentials): string {\n return `${API_URL}?page=dapi&s=post&q=index&id=${id}${credentialsQuery(credentials)}`;\n}\n\nexport function buildPostByIdJsonUrl(id: number, credentials: Credentials): string {\n return `${API_URL}?page=dapi&s=post&q=index&id=${id}&json=1&fields=tag_info${credentialsQuery(credentials)}`;\n}\n\nfunction credentialsQuery(credentials: Credentials): string {\n return (\n `&api_key=${encodeURIComponent(credentials.apiKey)}` +\n `&user_id=${encodeURIComponent(credentials.userId)}`\n );\n}\n\nexport function buildPostPageUrl(id: number): string {\n return `${SITE_URL}?page=post&s=view&id=${id}`;\n}\n\n/**\n * Strip the key out of a URL before anything shows it.\n *\n * An error names the address it failed on, a model reads that text, and the\n * conversation it lands in outlives the request. The user id stays readable:\n * it names the account without opening it, which keeps a support question\n * answerable.\n */\nexport function redactCredentials(url: string): string {\n return url.replace(/([?&]api_key=)[^&]*/gi, \"$1REDACTED\");\n}\n","/**\n * HTTP layer: one GET of the API route, classified, with backoff.\n *\n * Two conditions decide the shape of this file. rule34.xxx refuses a client\n * that asks too often with HTTP 429, and it answers a request carrying no valid\n * credentials with HTTP 200 and an error document. So a status of 200 is not\n * proof of an answer, and a refusal must never reach a caller as an empty\n * result — the document itself is classified further up, by the parser.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport { Rule34Error, notFound, rateLimited } from \"../errors.js\";\nimport { type RateLimiter, sleep } from \"./rateLimiter.js\";\nimport { redactCredentials } from \"./urls.js\";\n\nconst BACKOFF_BASE_MS = 2000;\nconst BACKOFF_FACTOR = 2;\nconst BACKOFF_MAX_MS = 30_000;\n\n/** Exponential backoff with jitter, so parallel clients do not resynchronise. */\nexport function backoffDelay(attempt: number, random: () => number = Math.random): number {\n const uncapped = BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt;\n const capped = Math.min(BACKOFF_MAX_MS, uncapped);\n return Math.round(capped * (0.5 + random() * 0.5));\n}\n\nexport interface HttpDeps {\n config: Config;\n limiter: RateLimiter;\n logger: Logger;\n fetchImpl?: typeof fetch;\n}\n\n/**\n * Fetch one API document, retrying what is worth retrying.\n *\n * The whole retry chain, backoff waits included, runs inside a single limiter\n * slot. Waiting outside it would let a queued request slip into the same\n * refusal window the current one is backing away from.\n */\nexport async function fetchXml(url: string, deps: HttpDeps): Promise<string> {\n const { config, limiter, logger } = deps;\n const doFetch = deps.fetchImpl ?? fetch;\n // Every message and every log line names the address without the key in it.\n const safeUrl = redactCredentials(url);\n\n return await limiter.schedule(async () => {\n let lastError: Rule34Error | undefined;\n\n // Set when the site says how long to stay away, replacing our own guess for\n // the next attempt. Applied here rather than where it is read, so no wait\n // is ever served after the last attempt, when nobody would use it.\n let askedWaitMs: number | null = null;\n\n for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {\n if (attempt > 0) {\n const delay = Math.min(askedWaitMs ?? backoffDelay(attempt - 1), BACKOFF_MAX_MS);\n askedWaitMs = null;\n logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${safeUrl}`);\n await sleep(delay);\n }\n\n let status: number;\n let body: string;\n let retryAfterMs: number | null = null;\n try {\n await limiter.beforeRequest();\n const response = await doFetch(url, {\n headers: {\n \"User-Agent\": config.userAgent,\n Accept: \"application/xml,text/xml;q=0.9,*/*;q=0.8\",\n },\n redirect: \"follow\",\n signal: AbortSignal.timeout(config.timeoutMs),\n });\n status = response.status;\n retryAfterMs = parseRetryAfter(response.headers.get(\"retry-after\"));\n body = await response.text();\n } catch (error) {\n lastError = asTransportError(error, safeUrl);\n logger.debug(`${lastError.code} for ${safeUrl}: ${lastError.message}`);\n continue;\n }\n\n if (status === 429) {\n limiter.penalize();\n logger.info(`rate limited on ${safeUrl}, interval now ${limiter.currentIntervalMs}ms`);\n askedWaitMs = retryAfterMs;\n lastError = rateLimited(safeUrl, retryAfterMs ?? backoffDelay(attempt));\n continue;\n }\n\n // Retrying this would only repeat the same answer.\n if (status === 404) {\n throw notFound(safeUrl);\n }\n\n if (status >= 500) {\n lastError = new Rule34Error(\"network_error\", `rule34.xxx returned HTTP ${status}.`, {\n url: safeUrl,\n status,\n });\n continue;\n }\n\n if (status >= 400) {\n throw new Rule34Error(\"network_error\", `rule34.xxx refused the request (HTTP ${status}).`, {\n url: safeUrl,\n status,\n });\n }\n\n limiter.relax();\n return body;\n }\n\n throw (\n lastError ??\n new Rule34Error(\"network_error\", `Could not fetch ${safeUrl}.`, {\n url: safeUrl,\n })\n );\n });\n}\n\n/** `Retry-After` carries either seconds or an HTTP date. */\nfunction parseRetryAfter(raw: string | null): number | null {\n if (!raw) {\n return null;\n }\n const seconds = Number(raw.trim());\n if (Number.isFinite(seconds) && seconds >= 0) {\n return Math.round(seconds * 1000);\n }\n const when = Date.parse(raw);\n if (Number.isNaN(when)) {\n return null;\n }\n return Math.max(0, when - Date.now());\n}\n\nfunction asTransportError(error: unknown, safeUrl: string): Rule34Error {\n if (error instanceof Rule34Error) {\n return error;\n }\n const name = error instanceof Error ? error.name : \"\";\n if (name === \"TimeoutError\" || name === \"AbortError\") {\n return new Rule34Error(\"timeout\", \"rule34.xxx did not answer in time.\", {\n url: safeUrl,\n hint: \"Raise RULE34_TIMEOUT_MS if this happens often on a slow connection.\",\n });\n }\n const message = error instanceof Error ? error.message : String(error);\n return new Rule34Error(\"network_error\", `Could not reach rule34.xxx: ${message}`, {\n url: safeUrl,\n });\n}\n","/**\n * Reading one post from the JSON route.\n *\n * This format carries three things the XML does not: the uploader by name, the\n * number of comments, and a type and a post count for every tag. It leaves out\n * the publication date, which is why a fiche reads both formats rather than\n * choosing one.\n */\n\nimport { missingCredentials, notFound, parseFailure, upstreamFailure } from \"../../errors.js\";\nimport type { PostDetail, TagOnPost } from \"../../types.js\";\nimport { buildPostPageUrl, redactCredentials } from \"../urls.js\";\n\n/** The words this route writes where the tag route writes a number. */\nconst TYPES: Record<string, string> = {\n tag: \"general\",\n artist: \"artist\",\n copyright: \"copyright\",\n character: \"character\",\n metadata: \"metadata\",\n};\n\ninterface RawTagInfo {\n tag?: unknown;\n type?: unknown;\n count?: unknown;\n}\n\nexport function parsePostDetail(body: string, url: string, id: number): PostDetail {\n const safeUrl = redactCredentials(url);\n\n // A request naming an id the site does not hold is answered with an empty\n // body, the same zero bytes a truncated read produces. Asking for one post\n // makes the two the same answer: there is no such post to report.\n if (body.trim() === \"\") {\n throw notFound(safeUrl);\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(body);\n } catch {\n throw parseFailure(safeUrl, \"the response is not JSON\");\n }\n\n // An unauthenticated request is answered with HTTP 200 and a bare string,\n // which is a fault in the caller's configuration rather than in the site.\n if (typeof parsed === \"string\") {\n if (/authenticat/i.test(parsed)) {\n throw missingCredentials(safeUrl);\n }\n throw upstreamFailure(safeUrl, parsed);\n }\n\n if (!Array.isArray(parsed)) {\n throw parseFailure(safeUrl, \"the response is not a list of posts\");\n }\n const [raw] = parsed;\n if (raw === undefined) {\n throw notFound(safeUrl);\n }\n if (raw === null || typeof raw !== \"object\") {\n throw parseFailure(safeUrl, \"the first entry is not a post\");\n }\n\n return readPost(raw as Record<string, unknown>, id);\n}\n\nfunction readPost(raw: Record<string, unknown>, askedFor: number): PostDetail {\n const id = readInteger(raw.id) ?? askedFor;\n const tags = readString(raw.tags)\n .split(/\\s+/)\n .filter((tag) => tag !== \"\");\n\n return {\n id,\n postUrl: buildPostPageUrl(id),\n fileUrl: readString(raw.file_url),\n sampleUrl: readText(raw.sample_url),\n previewUrl: readText(raw.preview_url),\n width: readInteger(raw.width) ?? 0,\n height: readInteger(raw.height) ?? 0,\n md5: readString(raw.hash),\n score: readInteger(raw.score) ?? 0,\n rating: readString(raw.rating),\n tags,\n tagDetails: readTagDetails(raw.tag_info),\n source: readText(raw.source),\n // This route writes 0 where the XML writes an empty string, and 0 is a post\n // id that belongs to somebody else.\n parentId: nonZero(readInteger(raw.parent_id)),\n owner: readText(raw.owner),\n commentCount: readInteger(raw.comment_count),\n status: readString(raw.status),\n hasNotes: raw.has_notes === true,\n changedAtUnix: readInteger(raw.change),\n };\n}\n\nfunction readTagDetails(value: unknown): TagOnPost[] {\n if (!Array.isArray(value)) {\n return [];\n }\n return value.map((entry) => {\n const info = (entry ?? {}) as RawTagInfo;\n const type = readString(info.type);\n return {\n name: readString(info.tag),\n // A word this site has not documented is carried as it came: naming it\n // something else would state a kind the site never wrote.\n type: TYPES[type] ?? type,\n postCount: readInteger(info.count) ?? 0,\n };\n });\n}\n\nfunction nonZero(value: number | null): number | null {\n return value === null || value === 0 ? null : value;\n}\n\nfunction readString(value: unknown): string {\n return typeof value === \"string\" ? value : \"\";\n}\n\nfunction readText(value: unknown): string | null {\n const trimmed = readString(value).trim();\n return trimmed === \"\" ? null : trimmed;\n}\n\nfunction readInteger(value: unknown): number | null {\n if (typeof value === \"number\") {\n return Number.isFinite(value) ? value : null;\n }\n const trimmed = readString(value).trim();\n if (trimmed === \"\") {\n return null;\n }\n const parsed = Number(trimmed);\n return Number.isFinite(parsed) ? parsed : null;\n}\n","/**\n * Turning one API answer into a document, and classifying the answers that are\n * not one.\n *\n * Every route of this API answers a request carrying no valid credentials with\n * HTTP 200 and an `<error>` document, so the transport cannot tell that apart\n * from a result. It is told apart here, once, for every route.\n */\n\nimport { XMLParser } from \"fast-xml-parser\";\nimport { missingCredentials, parseFailure, upstreamFailure } from \"../../errors.js\";\n\nexport type Attributes = Record<string, string | undefined>;\n\nconst parser = new XMLParser({\n ignoreAttributes: false,\n attributeNamePrefix: \"@_\",\n // Attribute values are converted one field at a time by each parser, so that\n // an empty `parent_id` stays empty instead of becoming a number.\n parseAttributeValue: false,\n trimValues: true,\n});\n\nexport function readDocument(xml: string, safeUrl: string): Record<string, unknown> {\n if (xml.trim() === \"\") {\n throw parseFailure(safeUrl, \"the response was empty\");\n }\n\n let document: unknown;\n try {\n document = parser.parse(xml);\n } catch (error) {\n throw parseFailure(safeUrl, error instanceof Error ? error.message : \"unreadable XML\");\n }\n\n if (document === null || typeof document !== \"object\") {\n throw parseFailure(safeUrl, \"the response is not an XML document\");\n }\n\n const parsed = document as Record<string, unknown>;\n const failure = parsed.error;\n if (failure !== undefined) {\n const message = typeof failure === \"string\" ? failure : String(failure);\n // A fault in the caller's configuration rather than in the site.\n if (/authenticat/i.test(message)) {\n throw missingCredentials(safeUrl);\n }\n throw upstreamFailure(safeUrl, message);\n }\n\n return parsed;\n}\n\n/** A lone element parses to an object, several to an array, none to nothing. */\nexport function toArray(value: unknown): Attributes[] {\n if (value === undefined || value === null) {\n return [];\n }\n return (Array.isArray(value) ? value : [value]) as Attributes[];\n}\n\nexport function readText(value: string | undefined): string | null {\n const trimmed = value?.trim() ?? \"\";\n return trimmed === \"\" ? null : trimmed;\n}\n\nexport function readInteger(value: string | undefined): number | null {\n const trimmed = value?.trim() ?? \"\";\n if (trimmed === \"\") {\n return null;\n }\n const parsed = Number(trimmed);\n return Number.isFinite(parsed) ? parsed : null;\n}\n","/**\n * Reading the `<posts>` document the API answers a search with.\n *\n * This route is read as XML rather than as JSON for one reason: the XML states\n * the total and states an empty result as `count=\"0\"`, where the JSON answers a\n * search that found nothing with an empty body — the same zero bytes a\n * truncated read produces. A total that cannot be read is unknown here, never\n * zero.\n */\n\nimport { parseFailure } from \"../../errors.js\";\nimport type { PostList, Rule34Post } from \"../../types.js\";\nimport { buildPostPageUrl, redactCredentials } from \"../urls.js\";\nimport { type Attributes, readDocument, readInteger, readText, toArray } from \"./document.js\";\n\n/** The letters this route writes where the site writes a word. */\nconst RATINGS: Record<string, string> = {\n e: \"explicit\",\n q: \"questionable\",\n s: \"safe\",\n};\n\nexport function parsePostList(xml: string, url: string): PostList {\n const safeUrl = redactCredentials(url);\n const document = readDocument(xml, safeUrl);\n\n const posts = document.posts;\n if (posts === undefined || posts === null || typeof posts !== \"object\") {\n throw parseFailure(safeUrl, \"no <posts> element\");\n }\n\n const attributes = posts as Attributes & { post?: unknown };\n const total = readInteger(attributes[\"@_count\"]);\n if (total === null) {\n throw parseFailure(safeUrl, \"the <posts> element carries no count\");\n }\n\n return {\n total,\n offset: readInteger(attributes[\"@_offset\"]) ?? 0,\n posts: toArray(attributes.post).map(readPost),\n };\n}\n\nfunction readPost(attributes: Attributes): Rule34Post {\n const id = readInteger(attributes[\"@_id\"]) ?? 0;\n const rating = attributes[\"@_rating\"]?.trim() ?? \"\";\n\n return {\n id,\n postUrl: buildPostPageUrl(id),\n fileUrl: attributes[\"@_file_url\"] ?? \"\",\n sampleUrl: readText(attributes[\"@_sample_url\"]),\n previewUrl: readText(attributes[\"@_preview_url\"]),\n width: readInteger(attributes[\"@_width\"]) ?? 0,\n height: readInteger(attributes[\"@_height\"]) ?? 0,\n md5: attributes[\"@_md5\"] ?? \"\",\n score: readInteger(attributes[\"@_score\"]) ?? 0,\n // An unknown letter is carried as it came: naming it would state a rating\n // the site never wrote.\n rating: RATINGS[rating] ?? rating,\n tags: (attributes[\"@_tags\"] ?? \"\").split(/\\s+/).filter((tag) => tag !== \"\"),\n source: readText(attributes[\"@_source\"]),\n // The attribute is present and empty on a post with no parent, and 0 is a\n // post id that belongs to somebody else.\n parentId: readInteger(attributes[\"@_parent_id\"]),\n creatorId: readInteger(attributes[\"@_creator_id\"]),\n status: attributes[\"@_status\"] ?? \"\",\n hasChildren: attributes[\"@_has_children\"] === \"true\",\n hasComments: attributes[\"@_has_comments\"] === \"true\",\n hasNotes: attributes[\"@_has_notes\"] === \"true\",\n createdAt: readDate(attributes[\"@_created_at\"]),\n changedAtUnix: readInteger(attributes[\"@_change\"]),\n };\n}\n\n/**\n * The publication date, which this route prints in words rather than as a\n * stamp. A date that cannot be read is unknown: the post's `change` stamp is\n * the date of the last edit, and standing in for the other one would date an\n * old post to whenever somebody last retagged it.\n */\nfunction readDate(value: string | undefined): string | null {\n const trimmed = value?.trim() ?? \"\";\n if (trimmed === \"\") {\n return null;\n }\n const parsed = Date.parse(trimmed);\n return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();\n}\n","/**\n * Reading the `<tags>` document, which says whether a name is a tag at all.\n *\n * This is what a search of several tags needs when it finds nothing: a\n * misspelled name and a combination the site genuinely holds nothing for\n * produce the same empty result, and only this route separates them.\n */\n\nimport { parseFailure } from \"../../errors.js\";\nimport type { TagRef, TagType } from \"../../types.js\";\nimport { redactCredentials } from \"../urls.js\";\nimport { type Attributes, readDocument, readInteger, toArray } from \"./document.js\";\n\n/**\n * How the site numbers the kinds of tag.\n *\n * Read off the site itself rather than assumed: `black_hair` answers 0,\n * `jall_boint` 1, `prince_of_tennis` 3, `asuka_langley_sohryu` 4 and\n * `crossover` 5.\n */\nconst TYPES: Record<number, TagType> = {\n 0: \"general\",\n 1: \"artist\",\n 3: \"copyright\",\n 4: \"character\",\n 5: \"metadata\",\n};\n\nexport function parseTagList(xml: string, url: string): TagRef[] {\n const safeUrl = redactCredentials(url);\n const document = readDocument(xml, safeUrl);\n\n const tags = document.tags;\n if (tags !== undefined && tags !== null && typeof tags === \"object\") {\n return toArray((tags as { tag?: unknown }).tag).map(readTag);\n }\n\n // A name the site holds nothing for comes back under a root element named in\n // the singular and carrying nothing. Reading that as a broken document would\n // turn \"no such tag\" into \"the site is unreadable\", which is the opposite of\n // what this route is asked.\n const lone = document.tag;\n if (lone !== undefined && lone !== null && typeof lone === \"object\") {\n const attributes = lone as Attributes;\n return attributes[\"@_name\"] === undefined ? [] : [readTag(attributes)];\n }\n\n throw parseFailure(safeUrl, \"no <tags> element\");\n}\n\nfunction readTag(attributes: Attributes): TagRef {\n const typeCode = readInteger(attributes[\"@_type\"]);\n return {\n id: readInteger(attributes[\"@_id\"]) ?? 0,\n name: attributes[\"@_name\"] ?? \"\",\n postCount: readInteger(attributes[\"@_count\"]) ?? 0,\n // A number this site has not documented is left unnamed: calling it\n // something would state a kind the site never wrote.\n type: (typeCode === null ? undefined : TYPES[typeCode]) ?? \"unknown\",\n typeCode,\n ambiguous: attributes[\"@_ambiguous\"] === \"true\",\n };\n}\n","/**\n * The reading layer: requests, pacing, cache, and the shapes they produce.\n *\n * Nothing here imports the MCP SDK, so this file is also what the package\n * publishes under its `./client` entry point: a program can read rule34.xxx\n * with the same pacing and the same error taxonomy without a protocol attached.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport { invalidInput } from \"../errors.js\";\nimport type { PostDetail, PostList, TagRef } from \"../types.js\";\nimport { TtlLruCache } from \"./cache.js\";\nimport { fetchXml } from \"./http.js\";\nimport { parsePostDetail } from \"./parsers/postDetail.js\";\nimport { parsePostList } from \"./parsers/posts.js\";\nimport { parseTagList } from \"./parsers/tags.js\";\nimport { RateLimiter } from \"./rateLimiter.js\";\nimport {\n buildPostByIdJsonUrl,\n buildPostByIdXmlUrl,\n buildPostSearchUrl,\n buildTagLookupUrl,\n normalizeTag,\n type Credentials,\n type PostSearch,\n redactCredentials,\n resolvePostRef,\n} from \"./urls.js\";\n\n/** Every read says whether it reached the site or answered from memory. */\nexport interface Read<T> {\n data: T;\n cached: boolean;\n}\n\nexport interface Rule34ClientDeps {\n config: Config;\n logger: Logger;\n fetchImpl?: typeof fetch;\n}\n\nexport class Rule34Client {\n private readonly limiter: RateLimiter;\n private readonly cache: TtlLruCache<string>;\n\n constructor(private readonly deps: Rule34ClientDeps) {\n this.limiter = new RateLimiter({ minIntervalMs: deps.config.minIntervalMs });\n this.cache = new TtlLruCache(deps.config.cacheMaxEntries, deps.config.cacheTtlMs);\n }\n\n /** The addresses currently held, for a diagnostic to look at. */\n cacheKeys(): string[] {\n return this.cache.keys();\n }\n\n async searchPosts(search: PostSearch): Promise<Read<PostList>> {\n const url = buildPostSearchUrl(search, this.credentials());\n const { body, cached } = await this.read(url);\n return { data: parsePostList(body, url), cached };\n }\n\n /**\n * One post, read from both formats the API publishes.\n *\n * Neither format carries everything: the publication date and the uploader's\n * numeric id come from the XML, and the uploader's name, the comment count\n * and the tag types come from the JSON. Reading one of them would leave out\n * what the other holds, so a fiche costs two reads of the site.\n */\n async getPost(ref: { id?: number; url?: string }): Promise<Read<PostDetail>> {\n const credentials = this.credentials();\n const id = resolvePostRef(ref);\n\n const jsonUrl = buildPostByIdJsonUrl(id, credentials);\n const json = await this.read(jsonUrl);\n // Reading the JSON first means a post the site no longer holds is reported\n // as absent before a second request is spent on it.\n const detail = parsePostDetail(json.body, jsonUrl, id);\n\n const xmlUrl = buildPostByIdXmlUrl(id, credentials);\n const xml = await this.read(xmlUrl);\n const [fromXml] = parsePostList(xml.body, xmlUrl).posts;\n\n return {\n data: {\n ...detail,\n createdAt: fromXml?.createdAt ?? null,\n creatorId: fromXml?.creatorId ?? null,\n hasComments: fromXml?.hasComments ?? (detail.commentCount ?? 0) > 0,\n hasChildren: fromXml?.hasChildren ?? false,\n },\n cached: json.cached && xml.cached,\n };\n }\n\n /** The tag under this exact name, or nothing when the site holds no such tag. */\n async lookupTag(name: string): Promise<Read<TagRef | null>> {\n const url = buildTagLookupUrl(name, this.credentials());\n const { body, cached } = await this.read(url);\n const [tag] = parseTagList(body, url);\n return { data: tag ?? null, cached };\n }\n\n /**\n * Which of these names the site does not hold as a tag.\n *\n * A search of several tags that finds nothing says nothing about which tag\n * emptied it, and a misspelled name is indistinguishable from a combination\n * the site genuinely has nothing for. Each distinct name costs one request,\n * which is why this is worth spending only once a search has come back empty.\n */\n async findUnknownTags(names: string[]): Promise<string[]> {\n const distinct = [...new Set(names.map(normalizeTag))];\n const unknown: string[] = [];\n for (const name of distinct) {\n const { data } = await this.lookupTag(name);\n if (data === null) {\n unknown.push(name);\n }\n }\n return unknown;\n }\n\n private credentials(): Credentials {\n const credentials = this.deps.config.credentials;\n if (!credentials) {\n throw invalidInput(\n \"This server has no rule34.xxx API credentials, so it cannot ask the site anything.\",\n \"Set RULE34_USER_ID and RULE34_API_KEY in the env block of this server's entry in your MCP client \" +\n \"configuration. Both are shown under 'API Access Credentials' at \" +\n \"https://rule34.xxx/index.php?page=account&s=options once you are signed in. The site issues one \" +\n \"key per person, so use your own rather than sharing one.\",\n );\n }\n return credentials;\n }\n\n /**\n * Fetch one address, remembering the answer under it.\n *\n * The key is the address without the key in it: two searches differ by their\n * query and never by their credentials, and holding the secret in a second\n * place buys nothing.\n */\n private async read(url: string): Promise<{ body: string; cached: boolean }> {\n const key = redactCredentials(url);\n const remembered = this.cache.get(key);\n if (remembered !== undefined) {\n this.deps.logger.debug(`cache hit for ${key}`);\n return { body: remembered, cached: true };\n }\n\n const body = await fetchXml(url, {\n config: this.deps.config,\n limiter: this.limiter,\n logger: this.deps.logger,\n fetchImpl: this.deps.fetchImpl,\n });\n this.cache.set(key, body);\n return { body, cached: false };\n }\n}\n"],"mappings":"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","names":["readText","readInteger","readInteger","readPost","readText","TYPES","readInteger"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-rule34",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for rule34.xxx. Search posts by tag through the site's own API.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "smeet666",
|
|
8
|
+
"homepage": "https://github.com/smeet666/mcp-rule34#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/smeet666/mcp-rule34.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/smeet666/mcp-rule34/issues"
|
|
15
|
+
},
|
|
16
|
+
"funding": {
|
|
17
|
+
"type": "buymeacoffee",
|
|
18
|
+
"url": "https://buymeacoffee.com/smeet666"
|
|
19
|
+
},
|
|
20
|
+
"mcpName": "io.github.smeet666/mcp-rule34",
|
|
21
|
+
"bin": {
|
|
22
|
+
"mcp-rule34": "dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"main": "dist/index.js",
|
|
25
|
+
"types": "dist/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./client": {
|
|
32
|
+
"types": "./dist/rule34/client.d.ts",
|
|
33
|
+
"import": "./dist/rule34/client.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"server.json"
|
|
41
|
+
],
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=20.18.1"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsup",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"test": "vitest run test/unit",
|
|
49
|
+
"test:live": "vitest run test/live --testTimeout=60000",
|
|
50
|
+
"format": "biome check --write && prettier --write \"**/*.{md,yml}\"",
|
|
51
|
+
"check": "biome ci && prettier --check \"**/*.{md,yml}\"",
|
|
52
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build",
|
|
53
|
+
"start": "node dist/index.js",
|
|
54
|
+
"inspector": "npx @modelcontextprotocol/inspector node dist/index.js"
|
|
55
|
+
},
|
|
56
|
+
"keywords": [
|
|
57
|
+
"mcp",
|
|
58
|
+
"modelcontextprotocol",
|
|
59
|
+
"model-context-protocol",
|
|
60
|
+
"mcp-server",
|
|
61
|
+
"rule34",
|
|
62
|
+
"booru",
|
|
63
|
+
"tags",
|
|
64
|
+
"images",
|
|
65
|
+
"search",
|
|
66
|
+
"claude",
|
|
67
|
+
"cursor",
|
|
68
|
+
"llm",
|
|
69
|
+
"ai"
|
|
70
|
+
],
|
|
71
|
+
"dependencies": {
|
|
72
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
73
|
+
"fast-xml-parser": "^5.2.5",
|
|
74
|
+
"zod": "^4.4.3"
|
|
75
|
+
},
|
|
76
|
+
"devDependencies": {
|
|
77
|
+
"@biomejs/biome": "2.5.9",
|
|
78
|
+
"@types/node": "^22.10.2",
|
|
79
|
+
"prettier": "3.9.6",
|
|
80
|
+
"tsup": "^8.5.1",
|
|
81
|
+
"typescript": "^5.7.2",
|
|
82
|
+
"vitest": "^4.1.10"
|
|
83
|
+
}
|
|
84
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.smeet666/mcp-rule34",
|
|
4
|
+
"description": "Search rule34.xxx posts by tag through the site's own API. Needs a rule34.xxx API key.",
|
|
5
|
+
"websiteUrl": "https://github.com/smeet666/mcp-rule34#readme",
|
|
6
|
+
"icons": [
|
|
7
|
+
{
|
|
8
|
+
"src": "https://raw.githubusercontent.com/smeet666/mcp-rule34/main/assets/icon-128.png",
|
|
9
|
+
"mimeType": "image/png",
|
|
10
|
+
"sizes": ["128x128"]
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"src": "https://raw.githubusercontent.com/smeet666/mcp-rule34/main/assets/icon-512.png",
|
|
14
|
+
"mimeType": "image/png",
|
|
15
|
+
"sizes": ["512x512"]
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"url": "https://github.com/smeet666/mcp-rule34",
|
|
20
|
+
"source": "github"
|
|
21
|
+
},
|
|
22
|
+
"version": "0.1.0",
|
|
23
|
+
"packages": [
|
|
24
|
+
{
|
|
25
|
+
"registryType": "npm",
|
|
26
|
+
"identifier": "mcp-rule34",
|
|
27
|
+
"version": "0.1.0",
|
|
28
|
+
"transport": {
|
|
29
|
+
"type": "stdio"
|
|
30
|
+
},
|
|
31
|
+
"environmentVariables": [
|
|
32
|
+
{
|
|
33
|
+
"name": "RULE34_USER_ID",
|
|
34
|
+
"description": "Numeric rule34.xxx account id, shown under API Access Credentials.",
|
|
35
|
+
"isRequired": true
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "RULE34_API_KEY",
|
|
39
|
+
"description": "rule34.xxx API key, shown under API Access Credentials. One per person.",
|
|
40
|
+
"isRequired": true,
|
|
41
|
+
"isSecret": true
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "RULE34_MIN_INTERVAL_MS",
|
|
45
|
+
"description": "Minimum milliseconds between requests. Default 1000, and never lower.",
|
|
46
|
+
"isRequired": false
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "RULE34_LOG_LEVEL",
|
|
50
|
+
"description": "silent, error, info or debug. Default error.",
|
|
51
|
+
"isRequired": false
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|