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:
commit
d471c9b695
36 changed files with 6000 additions and 0 deletions
37
src/tools/get-credits.ts
Normal file
37
src/tools/get-credits.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import type { TorrentClawClient } from "../api-client.js";
|
||||
import { ApiError } from "../api-client.js";
|
||||
import { formatCredits } from "../formatters/credits.js";
|
||||
|
||||
export function registerGetCredits(
|
||||
server: McpServer,
|
||||
client: TorrentClawClient,
|
||||
): void {
|
||||
server.tool(
|
||||
"get_credits",
|
||||
"Get the director and top 10 cast members (with character names) for a movie or TV show. Use when the user asks about actors, cast, director, or 'who is in' a title. Requires content_id from search_content results.",
|
||||
{
|
||||
content_id: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(999_999_999, "Content ID out of valid range")
|
||||
.describe(
|
||||
"Numeric content ID from search_content results (the 'Content ID' field). Example: 42",
|
||||
),
|
||||
},
|
||||
async (params) => {
|
||||
try {
|
||||
const data = await client.getCredits(params.content_id);
|
||||
return { content: [{ type: "text", text: formatCredits(data) }] };
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof ApiError
|
||||
? `TorrentClaw API error (${error.status}): ${error.message}`
|
||||
: `Request failed: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
return { content: [{ type: "text", text: message }], isError: true };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
47
src/tools/get-popular.ts
Normal file
47
src/tools/get-popular.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import type { TorrentClawClient } from "../api-client.js";
|
||||
import { ApiError } from "../api-client.js";
|
||||
import { formatPopularResults } from "../formatters/content.js";
|
||||
|
||||
export function registerGetPopular(
|
||||
server: McpServer,
|
||||
client: TorrentClawClient,
|
||||
): void {
|
||||
server.tool(
|
||||
"get_popular",
|
||||
"Get trending movies and TV shows ranked by user click count. Use when the user asks for recommendations, trending titles, or 'what's popular'. Returns a paginated list with title, year, type, ratings, and content_id. Note: results do NOT include torrents — to get torrents for a title, call search_content with its name.",
|
||||
{
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(24)
|
||||
.optional()
|
||||
.describe("Number of items (default: 10)"),
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Page number (default: 1)"),
|
||||
},
|
||||
async (params) => {
|
||||
try {
|
||||
const data = await client.getPopular(
|
||||
params.limit ?? 10,
|
||||
params.page,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: formatPopularResults(data) }],
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof ApiError
|
||||
? `TorrentClaw API error (${error.status}): ${error.message}`
|
||||
: `Request failed: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
return { content: [{ type: "text", text: message }], isError: true };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
47
src/tools/get-recent.ts
Normal file
47
src/tools/get-recent.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import type { TorrentClawClient } from "../api-client.js";
|
||||
import { ApiError } from "../api-client.js";
|
||||
import { formatRecentResults } from "../formatters/content.js";
|
||||
|
||||
export function registerGetRecent(
|
||||
server: McpServer,
|
||||
client: TorrentClawClient,
|
||||
): void {
|
||||
server.tool(
|
||||
"get_recent",
|
||||
"Get the most recently added movies and TV shows, sorted by addition date. Use when the user asks 'what's new', 'latest additions', or 'recently added'. Returns a paginated list with title, year, type, ratings, date added, and content_id. Note: results do NOT include torrents — to get torrents for a title, call search_content with its name.",
|
||||
{
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(24)
|
||||
.optional()
|
||||
.describe("Number of items (default: 10)"),
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("Page number (default: 1)"),
|
||||
},
|
||||
async (params) => {
|
||||
try {
|
||||
const data = await client.getRecent(
|
||||
params.limit ?? 10,
|
||||
params.page,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: formatRecentResults(data) }],
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof ApiError
|
||||
? `TorrentClaw API error (${error.status}): ${error.message}`
|
||||
: `Request failed: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
return { content: [{ type: "text", text: message }], isError: true };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
32
src/tools/get-torrent-url.ts
Normal file
32
src/tools/get-torrent-url.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import type { TorrentClawClient } from "../api-client.js";
|
||||
|
||||
export function registerGetTorrentUrl(
|
||||
server: McpServer,
|
||||
client: TorrentClawClient,
|
||||
): void {
|
||||
server.tool(
|
||||
"get_torrent_url",
|
||||
"Get a direct .torrent file download URL from an info_hash. Use when the user specifically wants a .torrent file rather than a magnet link (magnet links are already in search_content results). Returns a single URL the user can open in their browser or torrent client.",
|
||||
{
|
||||
info_hash: z
|
||||
.string()
|
||||
.regex(/^[a-fA-F0-9]{40}$/)
|
||||
.describe(
|
||||
"40-character hex torrent info_hash from search_content results (e.g. 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2')",
|
||||
),
|
||||
},
|
||||
async (params) => {
|
||||
const url = client.getTorrentDownloadUrl(params.info_hash.toLowerCase());
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Download .torrent file: ${url}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
52
src/tools/get-watch-providers.ts
Normal file
52
src/tools/get-watch-providers.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import type { TorrentClawClient } from "../api-client.js";
|
||||
import { ApiError } from "../api-client.js";
|
||||
import { formatWatchProviders } from "../formatters/providers.js";
|
||||
|
||||
export function registerGetWatchProviders(
|
||||
server: McpServer,
|
||||
client: TorrentClawClient,
|
||||
): void {
|
||||
server.tool(
|
||||
"get_watch_providers",
|
||||
"Check where a movie or TV show is available to stream, rent, or buy (Netflix, Disney+, Amazon Prime, etc.) in a specific country. Requires content_id from search_content results. Note: if you passed country to search_content, streaming info is already in those results — use this tool only for a different country or to get more detail. Returns grouped providers: Stream (subscription), Free, Rent, Buy.",
|
||||
{
|
||||
content_id: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(999_999_999, "Content ID out of valid range")
|
||||
.describe(
|
||||
"Numeric content ID from search_content results (the 'Content ID' field). Example: 42",
|
||||
),
|
||||
country: z
|
||||
.string()
|
||||
.regex(
|
||||
/^[A-Z]{2}$/,
|
||||
"Must be uppercase 2-letter ISO 3166-1 country code",
|
||||
)
|
||||
.default("US")
|
||||
.describe(
|
||||
"ISO 3166-1 country code (e.g. US, ES, GB, DE). Default: US",
|
||||
),
|
||||
},
|
||||
async (params) => {
|
||||
try {
|
||||
const data = await client.getWatchProviders(
|
||||
params.content_id,
|
||||
params.country,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: formatWatchProviders(data) }],
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof ApiError
|
||||
? `TorrentClaw API error (${error.status}): ${error.message}`
|
||||
: `Request failed: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
return { content: [{ type: "text", text: message }], isError: true };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
124
src/tools/search-content.ts
Normal file
124
src/tools/search-content.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import type { TorrentClawClient } from "../api-client.js";
|
||||
import { ApiError } from "../api-client.js";
|
||||
import { formatSearchResults } from "../formatters/content.js";
|
||||
|
||||
export function registerSearchContent(
|
||||
server: McpServer,
|
||||
client: TorrentClawClient,
|
||||
): void {
|
||||
server.tool(
|
||||
"search_content",
|
||||
"Search for movies and TV shows by title, genre, year, rating, or quality. Returns matching content with metadata (title, year, genres, IMDb/TMDB ratings) and torrent download options (magnet links, quality, seeders, file size). This is the primary tool — use it first when a user asks to find, download, or learn about a movie or TV show. Results include a content_id needed by get_watch_providers and get_credits.",
|
||||
{
|
||||
query: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.refine(
|
||||
(q) => !/[\x00-\x08\x0B-\x0C\x0E-\x1F]/.test(q),
|
||||
"Query contains invalid control characters",
|
||||
)
|
||||
.describe(
|
||||
"Search query — typically a movie or TV show title (e.g. 'The Matrix', 'Breaking Bad'). Supports partial matches.",
|
||||
),
|
||||
type: z
|
||||
.enum(["movie", "show"])
|
||||
.optional()
|
||||
.describe("Filter by content type: 'movie' or 'show'"),
|
||||
genre: z
|
||||
.string()
|
||||
.max(50)
|
||||
.regex(
|
||||
/^[a-zA-Z\s&-]+$/,
|
||||
"Genre must contain only letters, spaces, ampersands, and hyphens",
|
||||
)
|
||||
.optional()
|
||||
.describe(
|
||||
"Filter by genre name. Common values: Action, Adventure, Animation, Comedy, Crime, Documentary, Drama, Family, Fantasy, History, Horror, Music, Mystery, Romance, Science Fiction, Thriller, War, Western",
|
||||
),
|
||||
year_min: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe("Minimum release year (e.g. 2020)"),
|
||||
year_max: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe("Maximum release year (e.g. 2025)"),
|
||||
min_rating: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(10)
|
||||
.optional()
|
||||
.describe(
|
||||
"Minimum IMDb rating (0-10). Example: 7 for well-rated content",
|
||||
),
|
||||
quality: z
|
||||
.enum(["480p", "720p", "1080p", "2160p"])
|
||||
.optional()
|
||||
.describe("Filter torrents by resolution"),
|
||||
language: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"ISO 639-1 language code to filter torrents (e.g. 'en' for English, 'es' for Spanish, 'fr' for French). Lowercase 2-letter code.",
|
||||
),
|
||||
sort: z
|
||||
.enum(["relevance", "seeders", "year", "rating", "added"])
|
||||
.default("relevance")
|
||||
.describe("Sort order for results"),
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe("Page number (default: 1)"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(20)
|
||||
.optional()
|
||||
.describe("Results per page (default: 10, max: 20)"),
|
||||
country: z
|
||||
.string()
|
||||
.regex(
|
||||
/^[A-Z]{2}$/,
|
||||
"Must be uppercase 2-letter ISO 3166-1 country code",
|
||||
)
|
||||
.optional()
|
||||
.describe(
|
||||
"ISO 3166-1 country code for streaming availability (e.g. US, ES, GB, DE). If provided, results include which streaming services offer each title. If omitted, no streaming data is returned.",
|
||||
),
|
||||
},
|
||||
async (params) => {
|
||||
try {
|
||||
const data = await client.search({
|
||||
query: params.query,
|
||||
type: params.type,
|
||||
genre: params.genre,
|
||||
year_min: params.year_min,
|
||||
year_max: params.year_max,
|
||||
min_rating: params.min_rating,
|
||||
quality: params.quality,
|
||||
language: params.language,
|
||||
sort: params.sort,
|
||||
page: params.page,
|
||||
limit: params.limit ?? 10,
|
||||
country: params.country,
|
||||
});
|
||||
return { content: [{ type: "text", text: formatSearchResults(data) }] };
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof ApiError
|
||||
? `TorrentClaw API error (${error.status}): ${error.message}`
|
||||
: `Request failed: ${error instanceof Error ? error.message : "Unknown error"}`;
|
||||
return { content: [{ type: "text", text: message }], isError: true };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue