feat: add response cache and compact output mode

Response cache (api-client.ts):
- In-memory TTL cache (5 min default) avoids duplicate API calls
  when LLMs repeat the same tool call within a conversation.
- Cache key = full URL with params; only 200 OK responses are cached.
- Exposed via client.cache for manual clear() if needed.
- Configurable TTL via TorrentClawClient constructor.

Compact mode (search_content tool):
- New compact boolean parameter (default: false).
- When true, magnet links use short form magnet:?xt=urn:btih:{hash}
  (~60 chars) instead of full magnets with trackers (~300 chars).
- Short magnets are still valid and clickable (DHT peer discovery).
- Also generates a magnet from info_hash even when API returns null.
- Saves ~10K chars per typical search (5 torrents x 10 results).

Tests: 100 total (15 new), all passing.
This commit is contained in:
Deivid Soto 2026-02-09 17:57:11 +01:00
parent e011c0f63e
commit bf459740fe
5 changed files with 349 additions and 9 deletions

View file

@ -26,6 +26,44 @@ export class ApiError extends Error {
}
}
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
const DEFAULT_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export class ResponseCache {
private store = new Map<string, CacheEntry<unknown>>();
private ttl: number;
constructor(ttl = DEFAULT_CACHE_TTL) {
this.ttl = ttl;
}
get<T>(key: string): T | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return undefined;
}
return entry.data as T;
}
set<T>(key: string, data: T): void {
this.store.set(key, { data, expiresAt: Date.now() + this.ttl });
}
clear(): void {
this.store.clear();
}
get size(): number {
return this.store.size;
}
}
export interface SearchParams {
query: string;
type?: string;
@ -44,10 +82,12 @@ export interface SearchParams {
export class TorrentClawClient {
private baseUrl: string;
private userAgent: string;
readonly cache: ResponseCache;
constructor() {
constructor(cacheTtl?: number) {
this.baseUrl = config.apiUrl;
this.userAgent = `torrentclaw-mcp/${config.version}`;
this.cache = new ResponseCache(cacheTtl);
}
private async request<T>(
@ -63,6 +103,10 @@ export class TorrentClawClient {
}
}
const cacheKey = url.toString();
const cached = this.cache.get<T>(cacheKey);
if (cached !== undefined) return cached;
const response = await fetch(url.toString(), {
headers: {
"User-Agent": this.userAgent,
@ -83,7 +127,9 @@ export class TorrentClawClient {
throw new ApiError(response.status, body);
}
return response.json() as Promise<T>;
const data = (await response.json()) as T;
this.cache.set(cacheKey, data);
return data;
}
async search(params: SearchParams): Promise<SearchResponse> {

View file

@ -29,7 +29,7 @@ function truncate(text: string, max: number): string {
return text.slice(0, max - 3) + "...";
}
function formatTorrent(t: TorrentInfo): string {
function formatTorrent(t: TorrentInfo, compact?: boolean): string {
const parts: string[] = [];
if (t.quality) parts.push(t.quality);
if (t.sourceType) parts.push(t.sourceType);
@ -43,11 +43,24 @@ function formatTorrent(t: TorrentInfo): string {
let line = ` - ${label} (${size}) | ${seeds}`;
if (score) line += ` | ${score}`;
line += `\n Info hash: ${t.infoHash}`;
if (t.magnetUrl) line += `\n Magnet: ${t.magnetUrl}`;
if (compact) {
// Short magnet (hash only, no trackers) — still clickable, saves ~200 chars per torrent
line += `\n Magnet: magnet:?xt=urn:btih:${t.infoHash}`;
} else if (t.magnetUrl) {
line += `\n Magnet: ${t.magnetUrl}`;
}
return line;
}
function formatResult(r: SearchResult, index: number): string {
export interface FormatOptions {
compact?: boolean;
}
function formatResult(
r: SearchResult,
index: number,
opts?: FormatOptions,
): string {
const lines: string[] = [];
const yearStr = r.year ? ` (${r.year})` : "";
lines.push(`${index}. ${r.title}${yearStr} [${r.contentType}]`);
@ -65,7 +78,7 @@ function formatResult(r: SearchResult, index: number): string {
.slice(0, 5);
lines.push(` Torrents (${r.torrents.length} total, top ${top.length}):`);
for (const t of top) {
lines.push(formatTorrent(t));
lines.push(formatTorrent(t, opts?.compact));
}
} else {
lines.push(" No torrents available");
@ -92,13 +105,16 @@ function formatResult(r: SearchResult, index: number): string {
return lines.join("\n");
}
export function formatSearchResults(data: SearchResponse): string {
export function formatSearchResults(
data: SearchResponse,
opts?: FormatOptions,
): 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));
const results = data.results.map((r, i) => formatResult(r, i + 1, opts));
return [header, "", ...results].join("\n");
}

View file

@ -94,6 +94,12 @@ export function registerSearchContent(
.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.",
),
compact: z
.boolean()
.default(false)
.describe(
"When true, returns shorter magnet links (hash only, no trackers) to reduce output size. Magnets are still clickable. Recommended for large result sets or when context window is limited.",
),
},
async (params) => {
try {
@ -111,7 +117,14 @@ export function registerSearchContent(
limit: params.limit ?? 10,
country: params.country,
});
return { content: [{ type: "text", text: formatSearchResults(data) }] };
return {
content: [
{
type: "text",
text: formatSearchResults(data, { compact: params.compact }),
},
],
};
} catch (error) {
const message =
error instanceof ApiError