feat: initial release of torrentclaw-mcp server

MCP (Model Context Protocol) server that wraps the TorrentClaw REST API,
enabling LLMs (Claude Desktop, Claude Code, Cursor, etc.) to search movies
and TV shows with torrent downloads and streaming availability.

## Tools (6)
- search_content: primary search with filters (title, genre, year, rating,
  quality, language, sort). Returns content metadata + torrent magnet links.
- get_popular: trending content ranked by clicks
- get_recent: most recently added content
- get_watch_providers: streaming availability by country (Netflix, Disney+, etc.)
- get_credits: director and top 10 cast members
- get_torrent_url: .torrent file download URL from info_hash

## Resources
- torrentclaw://stats: catalog statistics (content/torrent counts, sources)

## Prompts (4)
- search_movie, search_show, whats_new, where_to_watch

## LLM Usability
- Server description with workflow guidance for tool chaining
- Tool descriptions include trigger phrases, cross-tool references, and
  explicit parameter examples
- Formatted output includes info_hash for tool chaining and call-syntax
  cross-references (e.g. "use with get_watch_providers(content_id=42)")
- Popular/recent output hints to use search_content for torrents
- Error messages include status-specific recovery guidance (400, 404, 429, 5xx)
- Prompts reference tools by name for reliable LLM execution

## Security
- SSRF protection: validates TORRENTCLAW_API_URL against private IPs,
  localhost, link-local, and IPv6 loopback addresses
- Protocol whitelist: only http/https allowed
- Error body sanitization: 4xx truncated to 200 chars, 5xx bodies omitted
- Input validation: control char filter on queries, regex on country codes,
  genre character whitelist, content_id bounds

## Testing
- 85 tests across 13 test files
- Coverage: 99.5% statements, 95.2% branches, 98.1% functions, 99.5% lines
- Vitest v4 with v8 coverage provider, 80% thresholds enforced

## Stack
- TypeScript ESM, Node.js >= 18 (native fetch)
- @modelcontextprotocol/sdk v1.12, zod v3.24
- STDIO transport, runnable via npx torrentclaw-mcp
This commit is contained in:
Deivid Soto 2026-02-09 17:26:23 +01:00
commit d471c9b695
36 changed files with 6000 additions and 0 deletions

217
tests/api-client.test.ts Normal file
View file

@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { TorrentClawClient, ApiError } from "../src/api-client.js";
describe("TorrentClawClient", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = vi.fn();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
function mockFetch(body: unknown, status = 200) {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
);
}
function mockFetchError(body: string, status: number) {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(body, { status }),
);
}
it("builds correct search URL with all parameters", async () => {
mockFetch({ total: 0, page: 1, pageSize: 10, results: [] });
const client = new TorrentClawClient();
await client.search({
query: "inception",
type: "movie",
sort: "seeders",
quality: "1080p",
min_rating: 7,
});
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("q=inception");
expect(calledUrl).toContain("type=movie");
expect(calledUrl).toContain("sort=seeders");
expect(calledUrl).toContain("quality=1080p");
expect(calledUrl).toContain("min_rating=7");
});
it("omits undefined parameters from URL", async () => {
mockFetch({ total: 0, page: 1, pageSize: 10, results: [] });
const client = new TorrentClawClient();
await client.search({ query: "test" });
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("q=test");
expect(calledUrl).not.toContain("type=");
expect(calledUrl).not.toContain("genre=");
});
it("includes correct headers", async () => {
mockFetch({ total: 0, page: 1, pageSize: 10, results: [] });
const client = new TorrentClawClient();
await client.search({ query: "test" });
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const options = fetchMock.mock.calls[0][1] as RequestInit;
const headers = options.headers as Record<string, string>;
expect(headers["Accept"]).toBe("application/json");
expect(headers["X-Search-Source"]).toBe("mcp");
expect(headers["User-Agent"]).toMatch(/^torrentclaw-mcp\//);
});
it("throws ApiError on 400 response", async () => {
mockFetchError("Bad request", 400);
const client = new TorrentClawClient();
await expect(client.search({ query: "test" })).rejects.toThrow(ApiError);
});
it("throws ApiError with rate limit message on 429", async () => {
mockFetchError("Too many requests", 429);
const client = new TorrentClawClient();
try {
await client.search({ query: "test" });
expect.fail("Should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(ApiError);
expect((e as ApiError).message).toContain("Rate limit exceeded");
expect((e as ApiError).status).toBe(429);
}
});
it("constructs torrent download URL", () => {
const client = new TorrentClawClient();
const url = client.getTorrentDownloadUrl("aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e");
expect(url).toBe(
"https://torrentclaw.com/api/v1/torrent/aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
);
});
it("calls correct endpoint for popular", async () => {
mockFetch({ items: [], total: 0, page: 1, pageSize: 10 });
const client = new TorrentClawClient();
await client.getPopular(5, 2);
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/popular");
expect(calledUrl).toContain("limit=5");
expect(calledUrl).toContain("page=2");
});
it("calls correct endpoint for watch providers", async () => {
mockFetch({
contentId: 42,
country: "ES",
providers: { flatrate: [], rent: [], buy: [], free: [] },
attribution: "JustWatch",
});
const client = new TorrentClawClient();
await client.getWatchProviders(42, "ES");
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/content/42/watch-providers");
expect(calledUrl).toContain("country=ES");
});
it("calls correct endpoint for recent", async () => {
mockFetch({ items: [], total: 0, page: 1, pageSize: 10 });
const client = new TorrentClawClient();
await client.getRecent(10, 3);
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/recent");
expect(calledUrl).toContain("limit=10");
expect(calledUrl).toContain("page=3");
});
it("calls correct endpoint for credits", async () => {
mockFetch({ contentId: 7, director: "Nolan", cast: [] });
const client = new TorrentClawClient();
await client.getCredits(7);
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/content/7/credits");
});
it("calls correct endpoint for stats", async () => {
mockFetch({
content: { movies: 100, shows: 50, tmdbEnriched: 80 },
torrents: { total: 1000, withSeeders: 500, bySource: {} },
recentIngestions: [],
});
const client = new TorrentClawClient();
const result = await client.getStats();
const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain("/api/v1/stats");
expect(result.content.movies).toBe(100);
});
it("includes 4xx body truncated to 200 chars", async () => {
const longBody = "x".repeat(300);
mockFetchError(longBody, 422);
const client = new TorrentClawClient();
try {
await client.search({ query: "test" });
expect.fail("Should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(ApiError);
expect((e as ApiError).body.length).toBeLessThanOrEqual(200);
}
});
it("omits body for 5xx responses", async () => {
mockFetchError("Internal server error with stack trace", 500);
const client = new TorrentClawClient();
try {
await client.search({ query: "test" });
expect.fail("Should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(ApiError);
expect((e as ApiError).body).toBe("");
expect((e as ApiError).status).toBe(500);
}
});
it("omits body for 502 responses", async () => {
mockFetchError("Bad gateway details", 502);
const client = new TorrentClawClient();
try {
await client.search({ query: "test" });
expect.fail("Should have thrown");
} catch (e) {
expect(e).toBeInstanceOf(ApiError);
expect((e as ApiError).body).toBe("");
}
});
});

85
tests/config.test.ts Normal file
View file

@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest";
import { validateApiUrl } from "../src/config.js";
describe("validateApiUrl", () => {
it("accepts valid https URL", () => {
expect(validateApiUrl("https://torrentclaw.com")).toBe(
"https://torrentclaw.com",
);
});
it("accepts valid http URL", () => {
expect(validateApiUrl("http://api.example.com")).toBe(
"http://api.example.com",
);
});
it("rejects invalid URL", () => {
expect(() => validateApiUrl("not-a-url")).toThrow("not a valid URL");
});
it("rejects ftp protocol", () => {
expect(() => validateApiUrl("ftp://example.com")).toThrow(
"only http/https",
);
});
it("rejects file protocol", () => {
expect(() => validateApiUrl("file:///etc/passwd")).toThrow(
"only http/https",
);
});
it("rejects localhost", () => {
expect(() => validateApiUrl("http://localhost:3030")).toThrow(
"private/reserved",
);
});
it("rejects 127.0.0.1", () => {
expect(() => validateApiUrl("http://127.0.0.1")).toThrow(
"private/reserved",
);
});
it("rejects 0.0.0.0", () => {
expect(() => validateApiUrl("http://0.0.0.0")).toThrow(
"private/reserved",
);
});
it("rejects 10.x.x.x range", () => {
expect(() => validateApiUrl("http://10.0.0.1")).toThrow(
"private/reserved",
);
});
it("rejects 172.16-31.x.x range", () => {
expect(() => validateApiUrl("http://172.16.0.1")).toThrow(
"private/reserved",
);
expect(() => validateApiUrl("http://172.31.255.255")).toThrow(
"private/reserved",
);
});
it("accepts 172.15.x.x (not private)", () => {
expect(validateApiUrl("http://172.15.0.1")).toBe("http://172.15.0.1");
});
it("rejects 192.168.x.x range", () => {
expect(() => validateApiUrl("http://192.168.1.1")).toThrow(
"private/reserved",
);
});
it("rejects AWS metadata IP (169.254.x.x)", () => {
expect(() => validateApiUrl("http://169.254.169.254")).toThrow(
"private/reserved",
);
});
it("rejects IPv6 loopback ::1", () => {
expect(() => validateApiUrl("http://[::1]")).toThrow("private/reserved");
});
});

View file

@ -0,0 +1,503 @@
import { describe, it, expect } from "vitest";
import {
formatSearchResults,
formatPopularResults,
formatRecentResults,
} from "../../src/formatters/content.js";
import type {
SearchResponse,
PopularResponse,
RecentResponse,
} from "../../src/types.js";
describe("formatSearchResults", () => {
it("formats empty results", () => {
const response: SearchResponse = {
total: 0,
page: 1,
pageSize: 10,
results: [],
};
const text = formatSearchResults(response);
expect(text).toContain("No results found");
});
it("formats a single movie with torrents", () => {
const response: SearchResponse = {
total: 1,
page: 1,
pageSize: 10,
results: [
{
id: 42,
imdbId: "tt1375666",
tmdbId: "27205",
contentType: "movie",
title: "Inception",
titleOriginal: "Inception",
year: 2010,
overview: "A thief who steals corporate secrets through dream-sharing technology.",
posterUrl: "https://image.tmdb.org/t/p/w500/poster.jpg",
backdropUrl: null,
genres: ["Action", "Science Fiction"],
ratingImdb: "8.8",
ratingTmdb: "8.4",
hasTorrents: true,
torrents: [
{
infoHash: "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
quality: "1080p",
codec: "x265",
sourceType: "BluRay",
sizeBytes: "2147483648",
seeders: 847,
leechers: 23,
magnetUrl: "magnet:?xt=urn:btih:aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
source: "yts",
qualityScore: 85,
uploadedAt: "2024-03-15T12:00:00Z",
languages: ["en"],
audioCodec: "aac",
hdrType: null,
releaseGroup: "YTS",
isProper: false,
isRepack: false,
isRemastered: false,
},
],
},
],
};
const text = formatSearchResults(response);
expect(text).toContain("Found 1 results");
expect(text).toContain("Inception (2010) [movie]");
expect(text).toContain("IMDb: 8.8");
expect(text).toContain("TMDB: 8.4");
expect(text).toContain("Action, Science Fiction");
expect(text).toContain("1080p BluRay x265");
expect(text).toContain("2.0 GB");
expect(text).toContain("847 seeders");
expect(text).toContain("Score: 85");
expect(text).toContain("magnet:");
expect(text).toContain("Content ID: 42");
expect(text).toContain("tt1375666");
});
it("caps torrents at 5 and sorts by qualityScore", () => {
const torrents = Array.from({ length: 8 }, (_, i) => ({
infoHash: `${"a".repeat(39)}${i}`,
quality: `${720 + i * 10}p`,
codec: "x264",
sourceType: "WEB-DL",
sizeBytes: "1073741824",
seeders: 100 + i,
leechers: 10,
magnetUrl: `magnet:?xt=urn:btih:${"a".repeat(39)}${i}`,
source: "knaben:test",
qualityScore: i * 10,
uploadedAt: null,
languages: ["en"],
audioCodec: null,
hdrType: null,
releaseGroup: null,
isProper: false,
isRepack: false,
isRemastered: false,
}));
const response: SearchResponse = {
total: 1,
page: 1,
pageSize: 10,
results: [
{
id: 1,
imdbId: null,
tmdbId: null,
contentType: "movie",
title: "Test Movie",
titleOriginal: null,
year: 2024,
overview: null,
posterUrl: null,
backdropUrl: null,
genres: null,
ratingImdb: null,
ratingTmdb: null,
hasTorrents: true,
torrents,
},
],
};
const text = formatSearchResults(response);
expect(text).toContain("8 total, top 5");
// Highest score (70) should appear first
expect(text).toContain("Score: 70");
});
it("formats KB-sized torrent", () => {
const response: SearchResponse = {
total: 1,
page: 1,
pageSize: 10,
results: [
{
id: 1,
imdbId: null,
tmdbId: null,
contentType: "movie",
title: "Small File",
titleOriginal: null,
year: 2024,
overview: null,
posterUrl: null,
backdropUrl: null,
genres: null,
ratingImdb: null,
ratingTmdb: null,
hasTorrents: true,
torrents: [
{
infoHash: "a".repeat(40),
quality: null,
codec: null,
sourceType: null,
sizeBytes: "512000",
seeders: 5,
leechers: 0,
magnetUrl: null,
source: "test",
qualityScore: null,
uploadedAt: null,
languages: [],
audioCodec: null,
hdrType: null,
releaseGroup: null,
isProper: null,
isRepack: null,
isRemastered: null,
},
],
},
],
};
const text = formatSearchResults(response);
expect(text).toContain("500 KB");
expect(text).toContain("Unknown quality");
expect(text).not.toContain("Score:");
});
it("formats MB-sized torrent", () => {
const response: SearchResponse = {
total: 1,
page: 1,
pageSize: 10,
results: [
{
id: 1,
imdbId: null,
tmdbId: null,
contentType: "movie",
title: "Medium File",
titleOriginal: null,
year: 2024,
overview: null,
posterUrl: null,
backdropUrl: null,
genres: null,
ratingImdb: null,
ratingTmdb: null,
hasTorrents: true,
torrents: [
{
infoHash: "b".repeat(40),
quality: "720p",
codec: null,
sourceType: null,
sizeBytes: "524288000",
seeders: 10,
leechers: 1,
magnetUrl: null,
source: "test",
qualityScore: 50,
uploadedAt: null,
languages: [],
audioCodec: null,
hdrType: null,
releaseGroup: null,
isProper: null,
isRepack: null,
isRemastered: null,
},
],
},
],
};
const text = formatSearchResults(response);
expect(text).toContain("500 MB");
});
it("handles null and NaN sizeBytes", () => {
const response: SearchResponse = {
total: 1,
page: 1,
pageSize: 10,
results: [
{
id: 1,
imdbId: null,
tmdbId: null,
contentType: "movie",
title: "No Size",
titleOriginal: null,
year: null,
overview: null,
posterUrl: null,
backdropUrl: null,
genres: null,
ratingImdb: null,
ratingTmdb: null,
hasTorrents: true,
torrents: [
{
infoHash: "c".repeat(40),
quality: "1080p",
codec: null,
sourceType: null,
sizeBytes: null,
seeders: 1,
leechers: 0,
magnetUrl: null,
source: "test",
qualityScore: null,
uploadedAt: null,
languages: [],
audioCodec: null,
hdrType: null,
releaseGroup: null,
isProper: null,
isRepack: null,
isRemastered: null,
},
{
infoHash: "d".repeat(40),
quality: "720p",
codec: null,
sourceType: null,
sizeBytes: "not-a-number",
seeders: 1,
leechers: 0,
magnetUrl: null,
source: "test",
qualityScore: null,
uploadedAt: null,
languages: [],
audioCodec: null,
hdrType: null,
releaseGroup: null,
isProper: null,
isRepack: null,
isRemastered: null,
},
],
},
],
};
const text = formatSearchResults(response);
// Both null and NaN should produce "?"
expect(text).toContain("(?)");
// No year in title
expect(text).toContain("No Size [movie]");
// No ratings
expect(text).toContain("No ratings");
});
it("truncates long overviews", () => {
const longOverview = "A".repeat(300);
const response: SearchResponse = {
total: 1,
page: 1,
pageSize: 10,
results: [
{
id: 1,
imdbId: null,
tmdbId: null,
contentType: "show",
title: "Long Overview",
titleOriginal: null,
year: 2024,
overview: longOverview,
posterUrl: null,
backdropUrl: null,
genres: null,
ratingImdb: null,
ratingTmdb: null,
hasTorrents: false,
torrents: [],
},
],
};
const text = formatSearchResults(response);
expect(text).toContain("...");
expect(text).not.toContain("A".repeat(300));
});
it("shows HDR type in torrent label", () => {
const response: SearchResponse = {
total: 1,
page: 1,
pageSize: 10,
results: [
{
id: 1,
imdbId: null,
tmdbId: null,
contentType: "movie",
title: "HDR Movie",
titleOriginal: null,
year: 2024,
overview: null,
posterUrl: null,
backdropUrl: null,
genres: null,
ratingImdb: "7.0",
ratingTmdb: null,
hasTorrents: true,
torrents: [
{
infoHash: "e".repeat(40),
quality: "2160p",
codec: "x265",
sourceType: "WEB-DL",
sizeBytes: "10737418240",
seeders: 50,
leechers: 2,
magnetUrl: "magnet:?xt=urn:btih:" + "e".repeat(40),
source: "yts",
qualityScore: 95,
uploadedAt: null,
languages: ["en"],
audioCodec: null,
hdrType: "hdr10",
releaseGroup: null,
isProper: null,
isRepack: null,
isRemastered: null,
},
],
},
],
};
const text = formatSearchResults(response);
expect(text).toContain("2160p WEB-DL x265 hdr10");
expect(text).toContain("10.0 GB");
});
it("shows streaming info when available", () => {
const response: SearchResponse = {
total: 1,
page: 1,
pageSize: 10,
results: [
{
id: 1,
imdbId: null,
tmdbId: null,
contentType: "movie",
title: "Streaming Test",
titleOriginal: null,
year: 2024,
overview: null,
posterUrl: null,
backdropUrl: null,
genres: null,
ratingImdb: null,
ratingTmdb: null,
hasTorrents: false,
torrents: [],
streaming: {
flatrate: [
{ providerId: 8, name: "Netflix", logo: null, link: null },
{ providerId: 337, name: "Disney+", logo: null, link: null },
],
rent: [],
buy: [],
free: [{ providerId: 100, name: "Tubi", logo: null, link: null }],
},
},
],
};
const text = formatSearchResults(response);
expect(text).toContain("Stream: Netflix, Disney+");
expect(text).toContain("Free: Tubi");
});
});
describe("formatPopularResults", () => {
it("formats empty results", () => {
const response: PopularResponse = { items: [], total: 0, page: 1, pageSize: 10 };
expect(formatPopularResults(response)).toContain("No popular content");
});
it("formats popular items with click counts", () => {
const response: PopularResponse = {
items: [
{
id: 1,
title: "Popular Movie",
year: 2024,
contentType: "movie",
posterUrl: null,
ratingImdb: "7.5",
ratingTmdb: "7.2",
clickCount: 150,
},
],
total: 1,
page: 1,
pageSize: 10,
};
const text = formatPopularResults(response);
expect(text).toContain("Popular Movie (2024) [movie]");
expect(text).toContain("150 clicks");
expect(text).toContain("ID: 1");
});
});
describe("formatRecentResults", () => {
it("formats empty results", () => {
const response: RecentResponse = { items: [], total: 0, page: 1, pageSize: 10 };
expect(formatRecentResults(response)).toContain("No recent content");
});
it("formats recent items with dates", () => {
const response: RecentResponse = {
items: [
{
id: 5,
title: "New Show",
year: 2025,
contentType: "show",
posterUrl: null,
ratingImdb: null,
ratingTmdb: "6.8",
createdAt: "2025-12-25T10:00:00Z",
},
],
total: 1,
page: 1,
pageSize: 10,
};
const text = formatRecentResults(response);
expect(text).toContain("New Show (2025) [show]");
expect(text).toContain("TMDB: 6.8");
expect(text).toContain("Dec");
expect(text).toContain("ID: 5");
});
});

View file

@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import { formatCredits } from "../../src/formatters/credits.js";
import type { CreditsResponse } from "../../src/types.js";
describe("formatCredits", () => {
it("formats director and cast", () => {
const data: CreditsResponse = {
contentId: 42,
director: "Christopher Nolan",
cast: [
{ name: "Leonardo DiCaprio", character: "Cobb", profileUrl: null },
{ name: "Tom Hardy", character: "Eames", profileUrl: null },
],
};
const text = formatCredits(data);
expect(text).toContain("Credits for content #42");
expect(text).toContain("Director: Christopher Nolan");
expect(text).toContain("Leonardo DiCaprio as Cobb");
expect(text).toContain("Tom Hardy as Eames");
});
it("handles missing director", () => {
const data: CreditsResponse = {
contentId: 10,
director: null,
cast: [{ name: "Actor One", character: "Role", profileUrl: null }],
};
const text = formatCredits(data);
expect(text).not.toContain("Director:");
expect(text).toContain("Actor One as Role");
});
it("handles empty cast", () => {
const data: CreditsResponse = {
contentId: 5,
director: "Some Director",
cast: [],
};
const text = formatCredits(data);
expect(text).toContain("Director: Some Director");
expect(text).toContain("No cast information available");
});
it("handles cast member without character name", () => {
const data: CreditsResponse = {
contentId: 1,
director: null,
cast: [{ name: "Mystery Actor", character: "", profileUrl: null }],
};
const text = formatCredits(data);
expect(text).toContain("- Mystery Actor");
expect(text).not.toContain(" as ");
});
});

View file

@ -0,0 +1,101 @@
import { describe, it, expect } from "vitest";
import { formatWatchProviders } from "../../src/formatters/providers.js";
import type { WatchProvidersResponse } from "../../src/types.js";
describe("formatWatchProviders", () => {
it("formats providers by availability type", () => {
const data: WatchProvidersResponse = {
contentId: 42,
country: "ES",
providers: {
flatrate: [
{ providerId: 8, name: "Netflix", logo: null, link: null, displayPriority: 1 },
{ providerId: 337, name: "Disney+", logo: null, link: null, displayPriority: 2 },
],
rent: [
{ providerId: 2, name: "Apple TV", logo: null, link: null, displayPriority: 1 },
],
buy: [
{ providerId: 3, name: "Google Play", logo: null, link: null, displayPriority: 1 },
],
free: [],
},
attribution: "Watch provider data provided by JustWatch via TMDB.",
};
const text = formatWatchProviders(data);
expect(text).toContain("Watch providers for content #42 in ES");
expect(text).toContain("Stream: Netflix, Disney+");
expect(text).toContain("Rent: Apple TV");
expect(text).toContain("Buy: Google Play");
expect(text).not.toContain("Free:");
expect(text).toContain("JustWatch");
});
it("handles no providers", () => {
const data: WatchProvidersResponse = {
contentId: 10,
country: "US",
providers: { flatrate: [], rent: [], buy: [], free: [] },
attribution: "JustWatch",
};
const text = formatWatchProviders(data);
expect(text).toContain("No watch providers found in US");
});
it("shows VPN suggestion when available", () => {
const data: WatchProvidersResponse = {
contentId: 5,
country: "AR",
providers: { flatrate: [], rent: [], buy: [], free: [] },
vpnSuggestion: {
availableIn: ["US", "ES", "FR"],
affiliateUrl: "https://example.com/vpn",
},
attribution: "JustWatch",
};
const text = formatWatchProviders(data);
expect(text).toContain("Available in other countries: US, ES, FR");
});
it("sorts providers by display priority", () => {
const data: WatchProvidersResponse = {
contentId: 1,
country: "GB",
providers: {
flatrate: [
{ providerId: 2, name: "Second", logo: null, link: null, displayPriority: 20 },
{ providerId: 1, name: "First", logo: null, link: null, displayPriority: 1 },
],
rent: [],
buy: [],
free: [],
},
attribution: "JustWatch",
};
const text = formatWatchProviders(data);
expect(text).toContain("Stream: First, Second");
});
it("shows free providers", () => {
const data: WatchProvidersResponse = {
contentId: 1,
country: "US",
providers: {
flatrate: [],
rent: [],
buy: [],
free: [
{ providerId: 100, name: "Tubi", logo: null, link: null, displayPriority: 1 },
],
},
attribution: "JustWatch",
};
const text = formatWatchProviders(data);
expect(text).toContain("Free: Tubi");
});
});

63
tests/helpers.ts Normal file
View file

@ -0,0 +1,63 @@
import { vi } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
type ToolHandler = (params: Record<string, unknown>) => Promise<{
content: { type: string; text: string }[];
isError?: boolean;
}>;
type ResourceHandler = (uri: URL) => Promise<{
contents: { uri: string; mimeType: string; text: string }[];
}>;
/**
* Creates a mock McpServer that captures registered tool and resource handlers.
*/
export function createMockServer() {
const tools = new Map<string, ToolHandler>();
const resources = new Map<string, ResourceHandler>();
const server = {
tool: vi.fn(
(
name: string,
_descriptionOrSchema: unknown,
_schemaOrHandler: unknown,
handler?: ToolHandler,
) => {
// server.tool(name, description, schema, handler) — 4-arg form
if (typeof handler === "function") {
tools.set(name, handler);
}
// server.tool(name, schema, handler) — 3-arg form
else if (typeof _schemaOrHandler === "function") {
tools.set(name, _schemaOrHandler as ToolHandler);
}
},
),
resource: vi.fn(
(
_name: string,
_uri: string,
_opts: unknown,
handler: ResourceHandler,
) => {
resources.set(_uri, handler);
},
),
} as unknown as McpServer;
return {
server,
getToolHandler(name: string): ToolHandler {
const handler = tools.get(name);
if (!handler) throw new Error(`Tool "${name}" not registered`);
return handler;
},
getResourceHandler(uri: string): ResourceHandler {
const handler = resources.get(uri);
if (!handler) throw new Error(`Resource "${uri}" not registered`);
return handler;
},
};
}

83
tests/prompts.test.ts Normal file
View file

@ -0,0 +1,83 @@
import { describe, it, expect, vi } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerPrompts } from "../src/prompts.js";
type PromptHandler = (
params: Record<string, string>,
) => { messages: { role: string; content: { type: string; text: string } }[] };
describe("registerPrompts", () => {
function createMockServer() {
const prompts = new Map<string, PromptHandler>();
const server = {
prompt: vi.fn(
(
name: string,
_description: string,
_schema: unknown,
handler: PromptHandler,
) => {
prompts.set(name, handler);
},
),
} as unknown as McpServer;
return { server, prompts };
}
it("registers 4 prompts", () => {
const { server, prompts } = createMockServer();
registerPrompts(server);
expect(prompts.size).toBe(4);
expect(prompts.has("search_movie")).toBe(true);
expect(prompts.has("search_show")).toBe(true);
expect(prompts.has("whats_new")).toBe(true);
expect(prompts.has("where_to_watch")).toBe(true);
});
it("search_movie includes title in prompt", () => {
const { server, prompts } = createMockServer();
registerPrompts(server);
const handler = prompts.get("search_movie")!;
const result = handler({ title: "Inception" });
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe("user");
expect(result.messages[0].content.text).toContain("Inception");
expect(result.messages[0].content.text).toContain("search_content");
});
it("search_show includes title in prompt", () => {
const { server, prompts } = createMockServer();
registerPrompts(server);
const handler = prompts.get("search_show")!;
const result = handler({ title: "Breaking Bad" });
expect(result.messages[0].content.text).toContain("Breaking Bad");
});
it("whats_new returns discovery prompt", () => {
const { server, prompts } = createMockServer();
registerPrompts(server);
const handler = prompts.get("whats_new")!;
const result = handler({});
expect(result.messages[0].content.text).toContain("recently added");
});
it("where_to_watch includes country when provided", () => {
const { server, prompts } = createMockServer();
registerPrompts(server);
const handler = prompts.get("where_to_watch")!;
const result = handler({ title: "Dune", country: "ES" });
expect(result.messages[0].content.text).toContain("Dune");
expect(result.messages[0].content.text).toContain("ES");
});
it("where_to_watch works without country", () => {
const { server, prompts } = createMockServer();
registerPrompts(server);
const handler = prompts.get("where_to_watch")!;
const result = handler({ title: "Dune" });
expect(result.messages[0].content.text).toContain("Dune");
expect(result.messages[0].content.text).not.toContain("undefined");
});
});

View file

@ -0,0 +1,60 @@
import { describe, it, expect, vi } from "vitest";
import { createMockServer } from "../helpers.js";
import { registerStatsResource } from "../../src/resources/stats.js";
import { TorrentClawClient } from "../../src/api-client.js";
import type { StatsResponse } from "../../src/types.js";
function createMockClient(overrides: Partial<TorrentClawClient> = {}) {
return {
search: vi.fn(),
getPopular: vi.fn(),
getRecent: vi.fn(),
getWatchProviders: vi.fn(),
getCredits: vi.fn(),
getStats: vi.fn(),
getTorrentDownloadUrl: vi.fn(),
...overrides,
} as unknown as TorrentClawClient;
}
describe("torrentclaw://stats resource", () => {
it("returns stats as JSON", async () => {
const statsData: StatsResponse = {
content: { movies: 5000, shows: 1000, tmdbEnriched: 4500 },
torrents: {
total: 50000,
withSeeders: 30000,
bySource: { yts: 20000, eztv: 15000, "knaben:1337x": 15000 },
},
recentIngestions: [
{
source: "yts",
status: "completed",
startedAt: "2026-02-09T01:00:00Z",
completedAt: "2026-02-09T01:35:00Z",
fetched: 100,
new: 10,
updated: 90,
},
],
};
const client = createMockClient({
getStats: vi.fn().mockResolvedValue(statsData),
});
const { server, getResourceHandler } = createMockServer();
registerStatsResource(server, client);
const handler = getResourceHandler("torrentclaw://stats");
const result = await handler(new URL("torrentclaw://stats"));
expect(result.contents).toHaveLength(1);
expect(result.contents[0].mimeType).toBe("application/json");
expect(result.contents[0].uri).toBe("torrentclaw://stats");
const parsed = JSON.parse(result.contents[0].text);
expect(parsed.content.movies).toBe(5000);
expect(parsed.torrents.total).toBe(50000);
expect(parsed.recentIngestions).toHaveLength(1);
});
});

View file

@ -0,0 +1,97 @@
import { describe, it, expect, vi } from "vitest";
import { createMockServer } from "../helpers.js";
import { registerGetCredits } from "../../src/tools/get-credits.js";
import { TorrentClawClient, ApiError } from "../../src/api-client.js";
function createMockClient(overrides: Partial<TorrentClawClient> = {}) {
return {
search: vi.fn(),
getPopular: vi.fn(),
getRecent: vi.fn(),
getWatchProviders: vi.fn(),
getCredits: vi.fn(),
getStats: vi.fn(),
getTorrentDownloadUrl: vi.fn(),
...overrides,
} as unknown as TorrentClawClient;
}
describe("get_credits tool", () => {
it("returns formatted credits", async () => {
const client = createMockClient({
getCredits: vi.fn().mockResolvedValue({
contentId: 42,
director: "Christopher Nolan",
cast: [
{ name: "Leonardo DiCaprio", character: "Cobb", profileUrl: null },
],
}),
});
const { server, getToolHandler } = createMockServer();
registerGetCredits(server, client);
const handler = getToolHandler("get_credits");
const result = await handler({ content_id: 42 });
expect(result.content[0].text).toContain("Christopher Nolan");
expect(result.content[0].text).toContain("Leonardo DiCaprio");
});
it("passes content_id to client", async () => {
const getCreditsMock = vi.fn().mockResolvedValue({
contentId: 99,
director: null,
cast: [],
});
const client = createMockClient({ getCredits: getCreditsMock });
const { server, getToolHandler } = createMockServer();
registerGetCredits(server, client);
const handler = getToolHandler("get_credits");
await handler({ content_id: 99 });
expect(getCreditsMock).toHaveBeenCalledWith(99);
});
it("returns isError on ApiError", async () => {
const client = createMockClient({
getCredits: vi.fn().mockRejectedValue(new ApiError(503, "TMDB unavailable")),
});
const { server, getToolHandler } = createMockServer();
registerGetCredits(server, client);
const handler = getToolHandler("get_credits");
const result = await handler({ content_id: 1 });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("TorrentClaw API error (503)");
});
it("returns isError on generic error", async () => {
const client = createMockClient({
getCredits: vi.fn().mockRejectedValue(new Error("Boom")),
});
const { server, getToolHandler } = createMockServer();
registerGetCredits(server, client);
const handler = getToolHandler("get_credits");
const result = await handler({ content_id: 1 });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Request failed: Boom");
});
it("handles non-Error throw", async () => {
const client = createMockClient({
getCredits: vi.fn().mockRejectedValue(undefined),
});
const { server, getToolHandler } = createMockServer();
registerGetCredits(server, client);
const handler = getToolHandler("get_credits");
const result = await handler({ content_id: 1 });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Unknown error");
});
});

View file

@ -0,0 +1,108 @@
import { describe, it, expect, vi } from "vitest";
import { createMockServer } from "../helpers.js";
import { registerGetPopular } from "../../src/tools/get-popular.js";
import { TorrentClawClient, ApiError } from "../../src/api-client.js";
function createMockClient(overrides: Partial<TorrentClawClient> = {}) {
return {
search: vi.fn(),
getPopular: vi.fn(),
getRecent: vi.fn(),
getWatchProviders: vi.fn(),
getCredits: vi.fn(),
getStats: vi.fn(),
getTorrentDownloadUrl: vi.fn(),
...overrides,
} as unknown as TorrentClawClient;
}
describe("get_popular tool", () => {
it("returns formatted popular results", async () => {
const client = createMockClient({
getPopular: vi.fn().mockResolvedValue({
items: [
{
id: 1,
title: "Popular Movie",
year: 2024,
contentType: "movie",
posterUrl: null,
ratingImdb: "8.0",
ratingTmdb: "7.5",
clickCount: 200,
},
],
total: 1,
page: 1,
pageSize: 10,
}),
});
const { server, getToolHandler } = createMockServer();
registerGetPopular(server, client);
const handler = getToolHandler("get_popular");
const result = await handler({ limit: 5 });
expect(result.content[0].text).toContain("Popular Movie");
expect(result.content[0].text).toContain("200 clicks");
});
it("defaults limit to 10", async () => {
const getPopularMock = vi.fn().mockResolvedValue({
items: [],
total: 0,
page: 1,
pageSize: 10,
});
const client = createMockClient({ getPopular: getPopularMock });
const { server, getToolHandler } = createMockServer();
registerGetPopular(server, client);
const handler = getToolHandler("get_popular");
await handler({});
expect(getPopularMock).toHaveBeenCalledWith(10, undefined);
});
it("returns isError on ApiError", async () => {
const client = createMockClient({
getPopular: vi.fn().mockRejectedValue(new ApiError(500, "Server error")),
});
const { server, getToolHandler } = createMockServer();
registerGetPopular(server, client);
const handler = getToolHandler("get_popular");
const result = await handler({});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("TorrentClaw API error (500)");
});
it("returns isError on generic error", async () => {
const client = createMockClient({
getPopular: vi.fn().mockRejectedValue(new Error("Timeout")),
});
const { server, getToolHandler } = createMockServer();
registerGetPopular(server, client);
const handler = getToolHandler("get_popular");
const result = await handler({});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Request failed: Timeout");
});
it("handles non-Error throw", async () => {
const client = createMockClient({
getPopular: vi.fn().mockRejectedValue("string error"),
});
const { server, getToolHandler } = createMockServer();
registerGetPopular(server, client);
const handler = getToolHandler("get_popular");
const result = await handler({});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Unknown error");
});
});

View file

@ -0,0 +1,108 @@
import { describe, it, expect, vi } from "vitest";
import { createMockServer } from "../helpers.js";
import { registerGetRecent } from "../../src/tools/get-recent.js";
import { TorrentClawClient, ApiError } from "../../src/api-client.js";
function createMockClient(overrides: Partial<TorrentClawClient> = {}) {
return {
search: vi.fn(),
getPopular: vi.fn(),
getRecent: vi.fn(),
getWatchProviders: vi.fn(),
getCredits: vi.fn(),
getStats: vi.fn(),
getTorrentDownloadUrl: vi.fn(),
...overrides,
} as unknown as TorrentClawClient;
}
describe("get_recent tool", () => {
it("returns formatted recent results", async () => {
const client = createMockClient({
getRecent: vi.fn().mockResolvedValue({
items: [
{
id: 3,
title: "New Show",
year: 2025,
contentType: "show",
posterUrl: null,
ratingImdb: null,
ratingTmdb: "7.0",
createdAt: "2025-12-01T00:00:00Z",
},
],
total: 1,
page: 1,
pageSize: 10,
}),
});
const { server, getToolHandler } = createMockServer();
registerGetRecent(server, client);
const handler = getToolHandler("get_recent");
const result = await handler({});
expect(result.content[0].text).toContain("New Show");
expect(result.content[0].text).toContain("[show]");
});
it("defaults limit to 10", async () => {
const getRecentMock = vi.fn().mockResolvedValue({
items: [],
total: 0,
page: 1,
pageSize: 10,
});
const client = createMockClient({ getRecent: getRecentMock });
const { server, getToolHandler } = createMockServer();
registerGetRecent(server, client);
const handler = getToolHandler("get_recent");
await handler({});
expect(getRecentMock).toHaveBeenCalledWith(10, undefined);
});
it("returns isError on ApiError", async () => {
const client = createMockClient({
getRecent: vi.fn().mockRejectedValue(new ApiError(503, "Unavailable")),
});
const { server, getToolHandler } = createMockServer();
registerGetRecent(server, client);
const handler = getToolHandler("get_recent");
const result = await handler({});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("TorrentClaw API error (503)");
});
it("returns isError on generic error", async () => {
const client = createMockClient({
getRecent: vi.fn().mockRejectedValue(new Error("DNS failure")),
});
const { server, getToolHandler } = createMockServer();
registerGetRecent(server, client);
const handler = getToolHandler("get_recent");
const result = await handler({});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("DNS failure");
});
it("handles non-Error throw", async () => {
const client = createMockClient({
getRecent: vi.fn().mockRejectedValue(42),
});
const { server, getToolHandler } = createMockServer();
registerGetRecent(server, client);
const handler = getToolHandler("get_recent");
const result = await handler({});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Unknown error");
});
});

View file

@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { createMockServer } from "../helpers.js";
import { registerGetTorrentUrl } from "../../src/tools/get-torrent-url.js";
import { TorrentClawClient } from "../../src/api-client.js";
function createMockClient(overrides: Partial<TorrentClawClient> = {}) {
return {
search: vi.fn(),
getPopular: vi.fn(),
getRecent: vi.fn(),
getWatchProviders: vi.fn(),
getCredits: vi.fn(),
getStats: vi.fn(),
getTorrentDownloadUrl: vi
.fn()
.mockImplementation(
(hash: string) =>
`https://torrentclaw.com/api/v1/torrent/${hash}`,
),
...overrides,
} as unknown as TorrentClawClient;
}
describe("get_torrent_url tool", () => {
it("returns download URL for valid info hash", async () => {
const client = createMockClient();
const { server, getToolHandler } = createMockServer();
registerGetTorrentUrl(server, client);
const handler = getToolHandler("get_torrent_url");
const hash = "aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e";
const result = await handler({ info_hash: hash });
expect(result.content[0].text).toContain("Download .torrent file:");
expect(result.content[0].text).toContain(hash);
expect(result.isError).toBeUndefined();
});
it("lowercases the info hash", async () => {
const getTorrentDownloadUrlMock = vi
.fn()
.mockReturnValue("https://torrentclaw.com/api/v1/torrent/abc");
const client = createMockClient({
getTorrentDownloadUrl: getTorrentDownloadUrlMock,
});
const { server, getToolHandler } = createMockServer();
registerGetTorrentUrl(server, client);
const handler = getToolHandler("get_torrent_url");
await handler({
info_hash: "AAF1E71C0A0E3B1C0F1A2B3C4D5E6F7A8B9C0D1E",
});
expect(getTorrentDownloadUrlMock).toHaveBeenCalledWith(
"aaf1e71c0a0e3b1c0f1a2b3c4d5e6f7a8b9c0d1e",
);
});
});

View file

@ -0,0 +1,106 @@
import { describe, it, expect, vi } from "vitest";
import { createMockServer } from "../helpers.js";
import { registerGetWatchProviders } from "../../src/tools/get-watch-providers.js";
import { TorrentClawClient, ApiError } from "../../src/api-client.js";
function createMockClient(overrides: Partial<TorrentClawClient> = {}) {
return {
search: vi.fn(),
getPopular: vi.fn(),
getRecent: vi.fn(),
getWatchProviders: vi.fn(),
getCredits: vi.fn(),
getStats: vi.fn(),
getTorrentDownloadUrl: vi.fn(),
...overrides,
} as unknown as TorrentClawClient;
}
describe("get_watch_providers tool", () => {
it("returns formatted watch providers", async () => {
const client = createMockClient({
getWatchProviders: vi.fn().mockResolvedValue({
contentId: 42,
country: "ES",
providers: {
flatrate: [
{ providerId: 8, name: "Netflix", logo: null, link: null, displayPriority: 1 },
],
rent: [],
buy: [],
free: [],
},
attribution: "JustWatch",
}),
});
const { server, getToolHandler } = createMockServer();
registerGetWatchProviders(server, client);
const handler = getToolHandler("get_watch_providers");
const result = await handler({ content_id: 42, country: "ES" });
expect(result.content[0].text).toContain("Netflix");
expect(result.content[0].text).toContain("content #42");
});
it("passes content_id and country to client", async () => {
const getWatchProvidersMock = vi.fn().mockResolvedValue({
contentId: 10,
country: "US",
providers: { flatrate: [], rent: [], buy: [], free: [] },
attribution: "JustWatch",
});
const client = createMockClient({
getWatchProviders: getWatchProvidersMock,
});
const { server, getToolHandler } = createMockServer();
registerGetWatchProviders(server, client);
const handler = getToolHandler("get_watch_providers");
await handler({ content_id: 10, country: "US" });
expect(getWatchProvidersMock).toHaveBeenCalledWith(10, "US");
});
it("returns isError on ApiError", async () => {
const client = createMockClient({
getWatchProviders: vi.fn().mockRejectedValue(new ApiError(404, "Not found")),
});
const { server, getToolHandler } = createMockServer();
registerGetWatchProviders(server, client);
const handler = getToolHandler("get_watch_providers");
const result = await handler({ content_id: 999, country: "US" });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("TorrentClaw API error (404)");
});
it("returns isError on generic error", async () => {
const client = createMockClient({
getWatchProviders: vi.fn().mockRejectedValue(new Error("Connection refused")),
});
const { server, getToolHandler } = createMockServer();
registerGetWatchProviders(server, client);
const handler = getToolHandler("get_watch_providers");
const result = await handler({ content_id: 1, country: "US" });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Connection refused");
});
it("handles non-Error throw", async () => {
const client = createMockClient({
getWatchProviders: vi.fn().mockRejectedValue(null),
});
const { server, getToolHandler } = createMockServer();
registerGetWatchProviders(server, client);
const handler = getToolHandler("get_watch_providers");
const result = await handler({ content_id: 1, country: "US" });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Unknown error");
});
});

View file

@ -0,0 +1,165 @@
import { describe, it, expect, vi } from "vitest";
import { createMockServer } from "../helpers.js";
import { registerSearchContent } from "../../src/tools/search-content.js";
import { TorrentClawClient, ApiError } from "../../src/api-client.js";
import type { SearchResponse } from "../../src/types.js";
function createMockClient(overrides: Partial<TorrentClawClient> = {}) {
return {
search: vi.fn(),
getPopular: vi.fn(),
getRecent: vi.fn(),
getWatchProviders: vi.fn(),
getCredits: vi.fn(),
getStats: vi.fn(),
getTorrentDownloadUrl: vi.fn(),
...overrides,
} as unknown as TorrentClawClient;
}
describe("search_content tool", () => {
it("returns formatted search results on success", async () => {
const mockResponse: SearchResponse = {
total: 1,
page: 1,
pageSize: 10,
results: [
{
id: 1,
imdbId: "tt1375666",
tmdbId: "27205",
contentType: "movie",
title: "Inception",
titleOriginal: null,
year: 2010,
overview: "A mind-bending thriller",
posterUrl: null,
backdropUrl: null,
genres: ["Action"],
ratingImdb: "8.8",
ratingTmdb: "8.4",
hasTorrents: true,
torrents: [],
},
],
};
const client = createMockClient({
search: vi.fn().mockResolvedValue(mockResponse),
});
const { server, getToolHandler } = createMockServer();
registerSearchContent(server, client);
const handler = getToolHandler("search_content");
const result = await handler({ query: "inception", type: "movie" });
expect(result.isError).toBeUndefined();
expect(result.content[0].text).toContain("Inception");
expect(result.content[0].text).toContain("Found 1 results");
});
it("passes all parameters to client.search", async () => {
const searchMock = vi.fn().mockResolvedValue({
total: 0,
page: 1,
pageSize: 10,
results: [],
});
const client = createMockClient({ search: searchMock });
const { server, getToolHandler } = createMockServer();
registerSearchContent(server, client);
const handler = getToolHandler("search_content");
await handler({
query: "test",
type: "show",
genre: "Drama",
year_min: 2020,
year_max: 2025,
min_rating: 7,
quality: "1080p",
language: "es",
sort: "seeders",
page: 2,
limit: 15,
country: "ES",
});
expect(searchMock).toHaveBeenCalledWith({
query: "test",
type: "show",
genre: "Drama",
year_min: 2020,
year_max: 2025,
min_rating: 7,
quality: "1080p",
language: "es",
sort: "seeders",
page: 2,
limit: 15,
country: "ES",
});
});
it("defaults limit to 10", async () => {
const searchMock = vi.fn().mockResolvedValue({
total: 0,
page: 1,
pageSize: 10,
results: [],
});
const client = createMockClient({ search: searchMock });
const { server, getToolHandler } = createMockServer();
registerSearchContent(server, client);
const handler = getToolHandler("search_content");
await handler({ query: "test" });
expect(searchMock).toHaveBeenCalledWith(
expect.objectContaining({ limit: 10 }),
);
});
it("returns isError on ApiError", async () => {
const client = createMockClient({
search: vi.fn().mockRejectedValue(new ApiError(429, "Rate limited")),
});
const { server, getToolHandler } = createMockServer();
registerSearchContent(server, client);
const handler = getToolHandler("search_content");
const result = await handler({ query: "test" });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("TorrentClaw API error (429)");
expect(result.content[0].text).toContain("Rate limit exceeded");
});
it("returns isError on generic error", async () => {
const client = createMockClient({
search: vi.fn().mockRejectedValue(new Error("Network timeout")),
});
const { server, getToolHandler } = createMockServer();
registerSearchContent(server, client);
const handler = getToolHandler("search_content");
const result = await handler({ query: "test" });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Request failed: Network timeout");
});
it("handles non-Error throw", async () => {
const client = createMockClient({
search: vi.fn().mockRejectedValue("string error"),
});
const { server, getToolHandler } = createMockServer();
registerSearchContent(server, client);
const handler = getToolHandler("search_content");
const result = await handler({ query: "test" });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Unknown error");
});
});