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
217 lines
7 KiB
TypeScript
217 lines
7 KiB
TypeScript
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("");
|
|
}
|
|
});
|
|
});
|