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

137
src/api-client.ts Normal file
View file

@ -0,0 +1,137 @@
import { config } from "./config.js";
import type {
SearchResponse,
PopularResponse,
RecentResponse,
WatchProvidersResponse,
CreditsResponse,
StatsResponse,
} from "./types.js";
export class ApiError extends Error {
constructor(
public status: number,
public body: string,
) {
const messages: Record<number, string> = {
400: "Bad request — check that all parameters are valid.",
404: "Not found — the requested content ID does not exist. Use search_content to find valid IDs.",
429: "Rate limit exceeded. Wait 10-30 seconds before retrying.",
500: "TorrentClaw server error. Try again in a moment.",
502: "TorrentClaw is temporarily unavailable. Try again in a moment.",
503: "TorrentClaw is under maintenance. Try again later.",
};
super(messages[status] || `API request failed with status ${status}`);
this.name = "ApiError";
}
}
export interface SearchParams {
query: string;
type?: string;
genre?: string;
year_min?: number;
year_max?: number;
min_rating?: number;
quality?: string;
language?: string;
sort?: string;
page?: number;
limit?: number;
country?: string;
}
export class TorrentClawClient {
private baseUrl: string;
private userAgent: string;
constructor() {
this.baseUrl = config.apiUrl;
this.userAgent = `torrentclaw-mcp/${config.version}`;
}
private async request<T>(
path: string,
params?: Record<string, string | number | undefined>,
): Promise<T> {
const url = new URL(path, this.baseUrl);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
}
const response = await fetch(url.toString(), {
headers: {
"User-Agent": this.userAgent,
Accept: "application/json",
"X-Search-Source": "mcp",
},
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
// Only expose body for 4xx (client errors); omit for 5xx (may leak internals)
let body = "";
if (response.status >= 400 && response.status < 500) {
try {
body = (await response.text()).slice(0, 200);
} catch {}
}
throw new ApiError(response.status, body);
}
return response.json() as Promise<T>;
}
async search(params: SearchParams): Promise<SearchResponse> {
return this.request<SearchResponse>("/api/v1/search", {
q: 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,
lang: params.language,
sort: params.sort,
page: params.page,
limit: params.limit,
country: params.country,
});
}
async getPopular(limit?: number, page?: number): Promise<PopularResponse> {
return this.request<PopularResponse>("/api/v1/popular", { limit, page });
}
async getRecent(limit?: number, page?: number): Promise<RecentResponse> {
return this.request<RecentResponse>("/api/v1/recent", { limit, page });
}
async getWatchProviders(
contentId: number,
country: string,
): Promise<WatchProvidersResponse> {
return this.request<WatchProvidersResponse>(
`/api/v1/content/${contentId}/watch-providers`,
{ country },
);
}
async getCredits(contentId: number): Promise<CreditsResponse> {
return this.request<CreditsResponse>(
`/api/v1/content/${contentId}/credits`,
);
}
async getStats(): Promise<StatsResponse> {
return this.request<StatsResponse>("/api/v1/stats");
}
getTorrentDownloadUrl(infoHash: string): string {
return `${this.baseUrl}/api/v1/torrent/${infoHash}`;
}
}

42
src/config.ts Normal file
View file

@ -0,0 +1,42 @@
const PRIVATE_IP_PATTERNS = [
/^localhost$/i,
/^127\.\d+\.\d+\.\d+$/,
/^::1$/,
/^::$/,
/^0\.0\.0\.0$/,
/^10\.\d+\.\d+\.\d+$/,
/^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
/^192\.168\.\d+\.\d+$/,
/^169\.254\.\d+\.\d+$/, // link-local / cloud metadata
];
export function validateApiUrl(raw: string): string {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error(`Invalid TORRENTCLAW_API_URL: not a valid URL`);
}
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error(
`Invalid TORRENTCLAW_API_URL: only http/https protocols allowed`,
);
}
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
if (PRIVATE_IP_PATTERNS.some((re) => re.test(hostname))) {
throw new Error(
`Invalid TORRENTCLAW_API_URL: private/reserved addresses not allowed`,
);
}
return raw;
}
export const config = {
apiUrl: validateApiUrl(
process.env.TORRENTCLAW_API_URL || "https://torrentclaw.com",
),
version: process.env.npm_package_version || "1.0.0",
} as const;

142
src/formatters/content.ts Normal file
View file

@ -0,0 +1,142 @@
import type {
SearchResponse,
SearchResult,
TorrentInfo,
PopularResponse,
PopularItem,
RecentResponse,
RecentItem,
} from "../types.js";
function formatSize(bytes: string | null): string {
if (!bytes) return "?";
const b = parseInt(bytes, 10);
if (isNaN(b)) return "?";
if (b >= 1_073_741_824) return `${(b / 1_073_741_824).toFixed(1)} GB`;
if (b >= 1_048_576) return `${(b / 1_048_576).toFixed(0)} MB`;
return `${(b / 1024).toFixed(0)} KB`;
}
function formatRating(imdb: string | null, tmdb: string | null): string {
const parts: string[] = [];
if (imdb) parts.push(`IMDb: ${imdb}`);
if (tmdb) parts.push(`TMDB: ${tmdb}`);
return parts.length > 0 ? parts.join(" | ") : "No ratings";
}
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, max - 3) + "...";
}
function formatTorrent(t: TorrentInfo): string {
const parts: string[] = [];
if (t.quality) parts.push(t.quality);
if (t.sourceType) parts.push(t.sourceType);
if (t.codec) parts.push(t.codec);
if (t.hdrType) parts.push(t.hdrType);
const label = parts.length > 0 ? parts.join(" ") : "Unknown quality";
const size = formatSize(t.sizeBytes);
const seeds = `${t.seeders} seeders`;
const score = t.qualityScore !== null ? `Score: ${t.qualityScore}` : null;
let line = ` - ${label} (${size}) | ${seeds}`;
if (score) line += ` | ${score}`;
line += `\n Info hash: ${t.infoHash}`;
if (t.magnetUrl) line += `\n Magnet: ${t.magnetUrl}`;
return line;
}
function formatResult(r: SearchResult, index: number): string {
const lines: string[] = [];
const yearStr = r.year ? ` (${r.year})` : "";
lines.push(`${index}. ${r.title}${yearStr} [${r.contentType}]`);
lines.push(` ${formatRating(r.ratingImdb, r.ratingTmdb)}`);
if (r.genres && r.genres.length > 0) {
lines.push(` Genres: ${r.genres.join(", ")}`);
}
if (r.overview) {
lines.push(` ${truncate(r.overview, 200)}`);
}
if (r.torrents.length > 0) {
const top = r.torrents
.sort((a, b) => (b.qualityScore ?? 0) - (a.qualityScore ?? 0))
.slice(0, 5);
lines.push(` Torrents (${r.torrents.length} total, top ${top.length}):`);
for (const t of top) {
lines.push(formatTorrent(t));
}
} else {
lines.push(" No torrents available");
}
if (r.streaming) {
const providers: string[] = [];
if (r.streaming.flatrate.length > 0)
providers.push(
`Stream: ${r.streaming.flatrate.map((p) => p.name).join(", ")}`,
);
if (r.streaming.free.length > 0)
providers.push(
`Free: ${r.streaming.free.map((p) => p.name).join(", ")}`,
);
if (providers.length > 0) lines.push(` ${providers.join(" | ")}`);
}
lines.push(
` Content ID: ${r.id} — use with get_watch_providers(content_id=${r.id}) or get_credits(content_id=${r.id})`,
);
if (r.imdbId) lines.push(` IMDb: ${r.imdbId}`);
return lines.join("\n");
}
export function formatSearchResults(data: SearchResponse): string {
if (data.results.length === 0) {
return "No results found. Try: (1) a shorter or alternate title, (2) removing filters like quality or year, (3) checking spelling. You can also try get_popular or get_recent to browse available content.";
}
const header = `Found ${data.total} results (page ${data.page}, showing ${data.results.length}):`;
const results = data.results.map((r, i) => formatResult(r, i + 1));
return [header, "", ...results].join("\n");
}
function formatPopularItem(item: PopularItem, index: number): string {
const yearStr = item.year ? ` (${item.year})` : "";
const rating = formatRating(item.ratingImdb, item.ratingTmdb);
return `${index}. ${item.title}${yearStr} [${item.contentType}] — ${rating}${item.clickCount} clicks — ID: ${item.id}`;
}
export function formatPopularResults(data: PopularResponse): string {
if (data.items.length === 0) {
return "No popular content found.";
}
const header = `Popular content (${data.total} total, page ${data.page}):`;
const hint = "(Use search_content with a title to get torrents and full details)";
const items = data.items.map((item, i) => formatPopularItem(item, i + 1));
return [header, hint, "", ...items].join("\n");
}
function formatRecentItem(item: RecentItem, index: number): string {
const yearStr = item.year ? ` (${item.year})` : "";
const rating = formatRating(item.ratingImdb, item.ratingTmdb);
const date = new Date(item.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
return `${index}. ${item.title}${yearStr} [${item.contentType}] — ${rating} — Added: ${date} — ID: ${item.id}`;
}
export function formatRecentResults(data: RecentResponse): string {
if (data.items.length === 0) {
return "No recent content found.";
}
const header = `Recently added content (${data.total} total, page ${data.page}):`;
const hint = "(Use search_content with a title to get torrents and full details)";
const items = data.items.map((item, i) => formatRecentItem(item, i + 1));
return [header, hint, "", ...items].join("\n");
}

23
src/formatters/credits.ts Normal file
View file

@ -0,0 +1,23 @@
import type { CreditsResponse } from "../types.js";
export function formatCredits(data: CreditsResponse): string {
const lines: string[] = [];
lines.push(`Credits for content #${data.contentId}:`);
lines.push("");
if (data.director) {
lines.push(` Director: ${data.director}`);
}
if (data.cast.length > 0) {
lines.push(` Cast:`);
for (const member of data.cast) {
const character = member.character ? ` as ${member.character}` : "";
lines.push(` - ${member.name}${character}`);
}
} else {
lines.push(" No cast information available.");
}
return lines.join("\n");
}

View file

@ -0,0 +1,48 @@
import type { WatchProvidersResponse, WatchProviderItem } from "../types.js";
function formatProviderList(
label: string,
providers: WatchProviderItem[],
): string | null {
if (providers.length === 0) return null;
const names = providers
.sort((a, b) => a.displayPriority - b.displayPriority)
.map((p) => p.name)
.join(", ");
return ` ${label}: ${names}`;
}
export function formatWatchProviders(data: WatchProvidersResponse): string {
const lines: string[] = [];
lines.push(
`Watch providers for content #${data.contentId} in ${data.country}:`,
);
lines.push("");
const sections = [
formatProviderList("Stream", data.providers.flatrate),
formatProviderList("Free", data.providers.free),
formatProviderList("Rent", data.providers.rent),
formatProviderList("Buy", data.providers.buy),
].filter(Boolean) as string[];
if (sections.length === 0) {
lines.push(
` No watch providers found in ${data.country}.`,
);
} else {
lines.push(...sections);
}
if (data.vpnSuggestion) {
lines.push("");
lines.push(
` Available in other countries: ${data.vpnSuggestion.availableIn.join(", ")}`,
);
}
lines.push("");
lines.push(data.attribution);
return lines.join("\n");
}

45
src/index.ts Normal file
View file

@ -0,0 +1,45 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { TorrentClawClient } from "./api-client.js";
import { config } from "./config.js";
import { registerSearchContent } from "./tools/search-content.js";
import { registerGetPopular } from "./tools/get-popular.js";
import { registerGetRecent } from "./tools/get-recent.js";
import { registerGetWatchProviders } from "./tools/get-watch-providers.js";
import { registerGetCredits } from "./tools/get-credits.js";
import { registerGetTorrentUrl } from "./tools/get-torrent-url.js";
import { registerStatsResource } from "./resources/stats.js";
import { registerPrompts } from "./prompts.js";
const client = new TorrentClawClient();
const server = new McpServer({
name: "torrentclaw",
version: config.version,
description:
"Search and discover movies and TV shows with torrent downloads, streaming availability, and cast/crew metadata. Start with search_content to find content, then use get_watch_providers or get_credits with the content_id. Use get_popular/get_recent to browse (no torrents — search for a title to get torrents).",
});
// Register tools
registerSearchContent(server, client);
registerGetPopular(server, client);
registerGetRecent(server, client);
registerGetWatchProviders(server, client);
registerGetCredits(server, client);
registerGetTorrentUrl(server, client);
// Register resources
registerStatsResource(server, client);
// Register prompts
registerPrompts(server);
// Graceful shutdown
process.on("SIGTERM", () => process.exit(0));
process.on("SIGINT", () => process.exit(0));
// Connect via STDIO
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("TorrentClaw MCP server running on stdio");

78
src/prompts.ts Normal file
View file

@ -0,0 +1,78 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export function registerPrompts(server: McpServer): void {
server.prompt(
"search_movie",
"Search for a movie by title and get torrent download options",
{ title: z.string().describe("Movie title to search for") },
({ title }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Search for the movie "${title}" using search_content with type="movie". Present the results showing: title, year, ratings, and the top torrents sorted by quality score with their magnet links. If results are found, also call get_watch_providers with the content_id to check streaming availability.`,
},
},
],
}),
);
server.prompt(
"search_show",
"Search for a TV show by title and get torrent download options",
{ title: z.string().describe("TV show title to search for") },
({ title }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Search for the TV show "${title}" using search_content with type="show". Present the results showing: title, year, ratings, and the top torrents sorted by quality score with their magnet links.`,
},
},
],
}),
);
server.prompt(
"whats_new",
"Discover recently added movies and TV shows",
{},
() => ({
messages: [
{
role: "user",
content: {
type: "text",
text: "Use get_recent to show the most recently added movies and TV shows. Present each with its title, year, type (movie/show), and ratings.",
},
},
],
}),
);
server.prompt(
"where_to_watch",
"Find where to watch a movie or TV show via streaming services",
{
title: z.string().describe("Movie or TV show title"),
country: z
.string()
.optional()
.describe("2-letter country code (e.g. US, ES)"),
},
({ title, country }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Search for "${title}" using search_content${country ? ` with country="${country}"` : ' with country="US"'}. Show the streaming availability (which services offer it for subscription, rent, or purchase) and the best torrent download options.`,
},
},
],
}),
);
}

29
src/resources/stats.ts Normal file
View file

@ -0,0 +1,29 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { TorrentClawClient } from "../api-client.js";
export function registerStatsResource(
server: McpServer,
client: TorrentClawClient,
): void {
server.resource(
"stats",
"torrentclaw://stats",
{
description:
"TorrentClaw catalog statistics. Returns JSON with: content counts (movies, shows, TMDB-enriched), torrent counts (total, with seeders, by source), and recent ingestion job history. Useful for understanding catalog coverage and data freshness.",
mimeType: "application/json",
},
async (uri) => {
const stats = await client.getStats();
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(stats, null, 2),
},
],
};
},
);
}

37
src/tools/get-credits.ts Normal file
View 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
View 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
View 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 };
}
},
);
}

View 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}`,
},
],
};
},
);
}

View 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
View 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 };
}
},
);
}

158
src/types.ts Normal file
View file

@ -0,0 +1,158 @@
// Response types mirrored from TorrentClaw API (src/types/api.ts)
export interface TorrentInfo {
infoHash: string;
quality: string | null;
codec: string | null;
sourceType: string | null;
sizeBytes: string | null;
seeders: number;
leechers: number;
magnetUrl: string | null;
source: string;
qualityScore: number | null;
uploadedAt: string | null;
languages: string[];
audioCodec: string | null;
hdrType: string | null;
releaseGroup: string | null;
isProper: boolean | null;
isRepack: boolean | null;
isRemastered: boolean | null;
}
export interface StreamingProviderItem {
providerId: number;
name: string;
logo: string | null;
link: string | null;
}
export interface StreamingInfo {
flatrate: StreamingProviderItem[];
rent: StreamingProviderItem[];
buy: StreamingProviderItem[];
free: StreamingProviderItem[];
}
export interface SearchResult {
id: number;
imdbId: string | null;
tmdbId: string | null;
contentType: string;
title: string;
titleOriginal: string | null;
year: number | null;
overview: string | null;
posterUrl: string | null;
backdropUrl: string | null;
genres: string[] | null;
ratingImdb: string | null;
ratingTmdb: string | null;
hasTorrents: boolean;
torrents: TorrentInfo[];
streaming?: StreamingInfo;
}
export interface SearchResponse {
total: number;
page: number;
pageSize: number;
results: SearchResult[];
}
export interface PopularItem {
id: number;
title: string;
year: number | null;
contentType: string;
posterUrl: string | null;
ratingImdb: string | null;
ratingTmdb: string | null;
clickCount: number;
}
export interface PopularResponse {
items: PopularItem[];
total: number;
page: number;
pageSize: number;
}
export interface RecentItem {
id: number;
title: string;
year: number | null;
contentType: string;
posterUrl: string | null;
ratingImdb: string | null;
ratingTmdb: string | null;
createdAt: string;
}
export interface RecentResponse {
items: RecentItem[];
total: number;
page: number;
pageSize: number;
}
export interface CastMember {
name: string;
character: string;
profileUrl: string | null;
}
export interface CreditsResponse {
contentId: number;
director: string | null;
cast: CastMember[];
}
export interface WatchProviderItem {
providerId: number;
name: string;
logo: string | null;
link: string | null;
displayPriority: number;
}
export interface WatchProviders {
flatrate: WatchProviderItem[];
rent: WatchProviderItem[];
buy: WatchProviderItem[];
free: WatchProviderItem[];
}
export interface WatchProvidersResponse {
contentId: number;
country: string;
providers: WatchProviders;
vpnSuggestion?: {
availableIn: string[];
affiliateUrl: string;
};
attribution: string;
}
export interface StatsResponse {
content: {
movies: number;
shows: number;
tmdbEnriched: number;
};
torrents: {
total: number;
withSeeders: number;
bySource: Record<string, number>;
};
recentIngestions: {
source: string;
status: string;
startedAt: string;
completedAt: string | null;
fetched: number;
new: number;
updated: number;
}[];
}