Skip to main content
Glama
index.ts18.4 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { serviceRegistry } from "./services/registry.js"; import type { ServiceConfig } from "./services/base.js"; import type { SabnzbdConfig } from "./services/downloaders/sabnzbd.js"; import { debugToolTiming } from "./debug.js"; import { metricsCollector } from "./metrics.js"; import { loadConfigFromEnvOnly } from "./config.js"; const tools = [ { name: "list_services", description: "List all configured services and downloaders. Call this first to see available services.", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "system_status", description: "Get system status and health information", inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"], }, }, { name: "queue_list", description: "List items in download queue with status and progress", inputSchema: { type: "object", properties: { service: { type: "string" }, page: { type: "number" }, pageSize: { type: "number" }, }, required: ["service"], }, }, { name: "queue_grab", description: "Force grab/retry download of queued items", inputSchema: { type: "object", properties: { service: { type: "string" }, ids: { type: "array", items: { type: "number" } }, }, required: ["service", "ids"], }, }, { name: "root_folders", description: "List configured root folders and storage information", inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"], }, }, { name: "history_detail", description: "Get download/import history details", inputSchema: { type: "object", properties: { service: { type: "string" }, page: { type: "number" }, pageSize: { type: "number" }, since: { type: "string" }, }, required: ["service"], }, }, { name: "search", description: "Search for media (series/movies) to add", inputSchema: { type: "object", properties: { service: { type: "string" }, query: { type: "string" }, limit: { type: "number" }, }, required: ["service", "query"], }, }, { name: "add_new", description: "Add new media to library", inputSchema: { type: "object", properties: { service: { type: "string" }, title: { type: "string" }, foreignId: { type: "number" }, rootFolderPath: { type: "string" }, qualityProfileId: { type: "number" }, monitored: { type: "boolean" }, }, required: ["service", "title", "foreignId"], }, }, { name: "import_issues", description: "Check for import issues and stuck downloads", inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"], }, }, { name: "quality_profiles", description: "List available quality profiles with recommendations", inputSchema: { type: "object", properties: { service: { type: "string" } }, required: ["service"], }, }, { name: "queue_diagnostics", description: "Analyze and auto-fix stuck queue items", inputSchema: { type: "object", properties: { service: { type: "string" }, autoFix: { type: "boolean" }, }, required: ["service"], }, }, { name: "all_services_diagnostics", description: "Analyze and auto-fix stuck queue items across all services", inputSchema: { type: "object", properties: { autoFix: { type: "boolean" }, }, required: [], }, }, { name: "download_status", description: "Get unified download status across arr services and downloaders", inputSchema: { type: "object", properties: { services: { type: "array", items: { type: "string" } }, includeDownloader: { type: "boolean" }, downloader: { type: "string" }, }, required: [], }, }, { name: "server_metrics", description: "Get server performance metrics and health status", inputSchema: { type: "object", properties: { service: { type: "string" }, detailed: { type: "boolean" }, }, required: [], }, }, ]; const InputSchema = z.object({ service: z.string().optional(), title: z.string().optional(), page: z.number().optional(), pageSize: z.number().optional(), ids: z.array(z.number()).optional(), since: z.string().optional(), limit: z.number().optional(), query: z.string().optional(), foreignId: z.number().optional(), rootFolderPath: z.string().optional(), qualityProfileId: z.number().optional(), monitored: z.boolean().optional(), autoFix: z.boolean().optional(), services: z.array(z.string()).optional(), includeDownloader: z.boolean().optional(), downloader: z.string().optional(), detailed: z.boolean().optional(), }); class ArrMcpServer { private server = new Server({ name: "arr-mcp", version: "0.3.2", }); private config?: { services: Record<string, ServiceConfig>; downloaders?: Record<string, SabnzbdConfig>; }; constructor() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools, })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; const input = InputSchema.parse(args); let result: unknown; // Handle multi-service tools if (name === "list_services") { result = await debugToolTiming(name, "info", async () => { const services = serviceRegistry.getAllNames(); const downloaders = serviceRegistry.getAllDownloaderNames(); return { ok: true, data: { services: services.map((name) => ({ name, type: serviceRegistry .get(name) ?.constructor.name?.replace("Service", "") .toLowerCase() || "unknown", })), downloaders: downloaders.map((name) => ({ name, type: "sabnzbd", })), summary: { totalServices: services.length, totalDownloaders: downloaders.length, }, }, }; }); } else if (name === "all_services_diagnostics") { result = await debugToolTiming(name, "multi", () => this.runAllServicesDiagnostics(input.autoFix ?? true), ); } else if (name === "download_status") { result = await debugToolTiming(name, "multi", () => this.runDownloadStatus(input), ); } else { const service = serviceRegistry.get(input.service || ""); if (!service) { throw new McpError( ErrorCode.InvalidParams, `Unknown service: ${input.service}. Available services: ${serviceRegistry.getAllNames().join(", ")}`, ); } result = await debugToolTiming( name, input.service || "unknown", async () => { switch (name) { case "system_status": return await service.systemStatus(); case "queue_list": return await service.queueList({ page: input.page, pageSize: input.pageSize, }); case "queue_grab": if (!input.ids || input.ids.length === 0) { throw new McpError( ErrorCode.InvalidParams, "Missing or empty ids array", ); } return await service.queueGrab(input.ids); case "root_folders": return await service.rootFolderList(); case "history_detail": return await service.historyDetail({ page: input.page, pageSize: input.pageSize, since: input.since, }); case "search": if (!input.query) { throw new McpError( ErrorCode.InvalidParams, "Missing query parameter", ); } return await service.search(input.query, { limit: input.limit, }); case "add_new": if (!input.title || !input.foreignId) { throw new McpError( ErrorCode.InvalidParams, "Missing required parameters: title and foreignId", ); } return await service.addNew({ title: input.title, foreignId: input.foreignId, rootFolderPath: input.rootFolderPath, qualityProfileId: input.qualityProfileId, monitored: input.monitored, }); case "import_issues": return await service.importIssues(); case "quality_profiles": return await service.listQualityProfiles(); case "queue_diagnostics": return await service.queueDiagnostics(input.autoFix); case "server_metrics": { { if (input.service) { const serviceMetrics = metricsCollector.getServiceMetrics( input.service, ); if (!serviceMetrics) { throw new McpError( ErrorCode.InvalidParams, `No metrics found for service: ${input.service}`, ); } return { ok: true, data: { service: input.service, ...serviceMetrics, health: metricsCollector.getHealthStatus(), }, }; } const summary = metricsCollector.getSummary(); const health = metricsCollector.getHealthStatus(); return { ok: true, data: { ...summary, health, ...(input.detailed && { recentOperations: metricsCollector.getRecentOperations(10), exportedMetrics: metricsCollector.exportMetrics(), }), }, }; } } default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${name}`, ); } }, ); } return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }; }); } private async runAllServicesDiagnostics(autoFix = true) { const allServices = serviceRegistry.getAll(); const serviceResults = []; let totalQueueItems = 0; let totalIssuesFound = 0; let totalFixed = 0; let totalFailed = 0; let totalRequiresManual = 0; for (const service of allServices) { try { const diagnostics = await service.queueDiagnostics(autoFix); if (diagnostics.ok && diagnostics.data) { serviceResults.push(diagnostics.data); totalQueueItems += diagnostics.data.totalQueueItems; totalIssuesFound += diagnostics.data.issuesFound; totalFixed += diagnostics.data.summary.fixed; totalFailed += diagnostics.data.summary.failed; totalRequiresManual += diagnostics.data.summary.requiresManual; } } catch (error) { console.error( `Failed to run diagnostics for ${service.serviceName}:`, error, ); } } return { ok: true, data: { totalServices: allServices.length, servicesScanned: allServices.map((s) => s.serviceName), overallSummary: { totalQueueItems, totalIssuesFound, totalFixed, totalFailed, totalRequiresManual, }, serviceResults, }, }; } private async runDownloadStatus(input: { services?: string[]; includeDownloader?: boolean; downloader?: string; }) { const targetServices = input.services || serviceRegistry.getAllNames(); const includeDownloaderFlag = input.includeDownloader ?? true; const downloaderName = input.downloader || Object.keys(this.config?.downloaders || {})[0]; const serviceResults = []; let totalQueued = 0; let totalDownloading = 0; let totalCompletedPendingImport = 0; // Get arr service data for (const serviceName of targetServices) { const service = serviceRegistry.get(serviceName); if (!service) continue; try { const queueResult = await service.queueList(); if (queueResult.ok && queueResult.data) { const queueData = queueResult.data; const downloading = queueData.items.filter( (item) => item.status.toLowerCase().includes("downloading") || item.status.toLowerCase().includes("grabbing"), ).length; const pending = queueData.items.filter( (item) => item.status.toLowerCase().includes("completed") || item.status.toLowerCase().includes("pending"), ).length; serviceResults.push({ service: serviceName, mediaKind: queueData.mediaKind, total: queueData.total, downloading, pending, }); totalQueued += queueData.total; totalDownloading += downloading; totalCompletedPendingImport += pending; } } catch (error) { console.error(`Failed to get queue data for ${serviceName}:`, error); } } let downloaderData = null; if (includeDownloaderFlag && downloaderName) { const downloader = serviceRegistry.getDownloader(downloaderName); if (downloader) { try { const [statusResult, queueResult] = await Promise.all([ downloader.serverStats(), downloader.queueList(), ]); if ( statusResult.ok && queueResult.ok && statusResult.data && queueResult.data ) { downloaderData = { service: downloaderName, name: statusResult.data.name, version: statusResult.data.version, isHealthy: statusResult.data.isHealthy, paused: statusResult.data.paused, totalSlots: queueResult.data.total, speedKBps: queueResult.data.speedKBps, totalSizeMB: queueResult.data.totalSizeMB, remainingSizeMB: queueResult.data.remainingSizeMB, items: queueResult.data.items.length, }; } } catch (error) { console.error( `Failed to get downloader data for ${downloaderName}:`, error, ); } } } return { ok: true, data: { services: targetServices, totals: { queued: totalQueued, downloading: totalDownloading, completedPendingImport: totalCompletedPendingImport, }, serviceResults, downloader: downloaderData, correlationRatio: downloaderData ? Math.min(1.0, totalQueued / Math.max(1, downloaderData.items)) : null, }, }; } initialize(config: { services: Record<string, ServiceConfig>; downloaders?: Record<string, SabnzbdConfig>; }) { this.config = config; serviceRegistry.clear(); for (const [name, serviceConfig] of Object.entries(config.services)) { try { serviceRegistry.register(name, serviceConfig); console.log(`✅ Registered service: ${name}`); } catch (error) { console.error( `❌ Failed to register service ${name}:`, error instanceof Error ? error.message : error, ); throw error; } } // Register downloaders if (config.downloaders) { for (const [name, downloaderConfig] of Object.entries( config.downloaders, )) { try { serviceRegistry.registerDownloader(name, downloaderConfig); console.log(`✅ Registered downloader: ${name}`); } catch (error) { console.error( `❌ Failed to register downloader ${name}:`, error instanceof Error ? error.message : error, ); throw error; } } } const registeredServices = serviceRegistry.getAllNames(); const registeredDownloaders = serviceRegistry.getAllDownloaderNames(); console.log( `🚀 ARR MCP Server initialized with ${registeredServices.length} services: ${registeredServices.join(", ")}`, ); if (registeredDownloaders.length > 0) { console.log( `📥 Registered ${registeredDownloaders.length} downloaders: ${registeredDownloaders.join(", ")}`, ); } } async run() { await this.server.connect(new StdioServerTransport()); } } async function main() { const server = new ArrMcpServer(); const config = await loadConfigFromEnvOnly(); server.initialize(config); await server.run(); } // Always execute main() - this is an MCP server meant to be run directly main().catch((error) => { console.error("💥 Failed to start ARR MCP Server:", error); process.exit(1); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/thesammykins/FlixBridge'

If you have feedback or need assistance with the MCP directory API, please join our Discord server