#!/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 fetch from "node-fetch";
interface OverseerrConfig {
baseUrl: string;
apiKey: string;
}
interface MediaRequest {
id: number;
status: number;
media: {
id: number;
mediaType: string;
tmdbId: number;
tvdbId?: number;
status: number;
createdAt: string;
updatedAt: string;
title?: string;
name?: string;
originalTitle?: string;
};
requestedBy: {
id: number;
displayName: string;
email: string;
};
modifiedBy?: {
id: number;
displayName: string;
email: string;
};
createdAt: string;
updatedAt: string;
type: string;
requestedSeason?: number;
}
interface SearchResult {
id: number;
mediaType: string;
title: string;
name?: string;
originalTitle?: string;
overview: string;
posterPath?: string;
backdropPath?: string;
releaseDate?: string;
firstAirDate?: string;
genreIds: number[];
popularity: number;
voteAverage: number;
voteCount: number;
adult?: boolean;
video?: boolean;
originalLanguage: string;
mediaInfo?: {
id: number;
tmdbId: number;
tvdbId?: number;
status: number;
requests?: MediaRequest[];
};
}
interface SearchResponse {
page: number;
totalPages: number;
totalResults: number;
results: SearchResult[];
}
interface RequestResponse {
pageInfo: {
pages: number;
pageSize: number;
results: number;
page: number;
};
results: MediaRequest[];
}
interface RequestCreationResponse {
id: number;
status: number;
}
interface ApprovalResponse {
status: number;
}
class OverseerrMCPServer {
private config: OverseerrConfig;
private server: Server;
constructor() {
// Konfiguration aus Umgebungsvariablen laden
this.config = {
baseUrl: process.env.OVERSEERR_BASE_URL || "http://localhost:5055",
apiKey: process.env.OVERSEERR_API_KEY || "",
};
if (!this.config.apiKey) {
throw new Error("OVERSEERR_API_KEY environment variable is required");
}
this.server = new Server(
{
name: "overseerr-mcp-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
this.setupToolHandlers();
}
private async makeApiRequest(endpoint: string, options: any = {}) {
const url = `${this.config.baseUrl}/api/v1${endpoint}`;
const headers = {
"X-Api-Key": this.config.apiKey,
"Content-Type": "application/json",
...options.headers,
};
try {
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Prüfen ob die Antwort leer ist (z.B. bei DELETE-Operationen)
const text = await response.text();
if (!text) {
return {}; // Leeres Objekt zurückgeben bei leerer Antwort
}
return JSON.parse(text);
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Overseerr API request failed: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private setupToolHandlers() {
// Tool-Liste bereitstellen
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "search_media",
description: "Search for movies and TV shows in Overseerr",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query for movies or TV shows",
},
page: {
type: "number",
description: "Page number for pagination (default: 1)",
default: 1,
},
},
required: ["query"],
},
},
{
name: "get_requests",
description: "Get all media requests from Overseerr",
inputSchema: {
type: "object",
properties: {
take: {
type: "number",
description: "Number of requests to return (default: 20, max: 50)",
default: 20,
},
skip: {
type: "number",
description: "Number of requests to skip for pagination (default: 0)",
default: 0,
},
filter: {
type: "string",
description: "Filter requests by status",
enum: ["all", "approved", "denied", "pending", "processing", "available"],
default: "all",
},
sort: {
type: "string",
description: "Sort order",
enum: ["added", "modified"],
default: "added",
},
},
},
},
{
name: "request_media",
description: "Request a movie or TV show",
inputSchema: {
type: "object",
properties: {
mediaId: {
type: "number",
description: "TMDB ID of the media to request",
},
mediaType: {
type: "string",
description: "Type of media",
enum: ["movie", "tv"],
},
seasons: {
type: "array",
description: "Seasons to request (for TV shows only)",
items: {
type: "number",
},
},
is4k: {
type: "boolean",
description: "Request 4K version",
default: false,
},
},
required: ["mediaId", "mediaType"],
},
},
{
name: "approve_request",
description: "Approve a pending media request",
inputSchema: {
type: "object",
properties: {
requestId: {
type: "number",
description: "ID of the request to approve",
},
},
required: ["requestId"],
},
},
{
name: "deny_request",
description: "Deny a pending media request",
inputSchema: {
type: "object",
properties: {
requestId: {
type: "number",
description: "ID of the request to deny",
},
reason: {
type: "string",
description: "Reason for denial",
},
},
required: ["requestId"],
},
},
{
name: "delete_request",
description: "Delete a media request",
inputSchema: {
type: "object",
properties: {
requestId: {
type: "number",
description: "ID of the request to delete",
},
},
required: ["requestId"],
},
},
{
name: "get_media_details",
description: "Get detailed information about a specific media item",
inputSchema: {
type: "object",
properties: {
mediaType: {
type: "string",
description: "Type of media",
enum: ["movie", "tv"],
},
mediaId: {
type: "number",
description: "TMDB ID of the media",
},
},
required: ["mediaType", "mediaId"],
},
},
{
name: "get_server_status",
description: "Get Overseerr server status and information",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "get_user_requests",
description: "Get requests for a specific user",
inputSchema: {
type: "object",
properties: {
userId: {
type: "number",
description: "User ID to get requests for",
},
take: {
type: "number",
description: "Number of requests to return (default: 20)",
default: 20,
},
skip: {
type: "number",
description: "Number of requests to skip (default: 0)",
default: 0,
},
},
required: ["userId"],
},
},
],
}));
// Tool-Aufrufe behandeln
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "search_media":
return await this.searchMedia(args as { query: string; page?: number });
case "get_requests":
return await this.getRequests(args as {
take?: number;
skip?: number;
filter?: string;
sort?: string;
});
case "request_media":
return await this.requestMedia(args as {
mediaId: number;
mediaType: string;
seasons?: number[];
is4k?: boolean;
});
case "approve_request":
return await this.approveRequest(args as { requestId: number });
case "deny_request":
return await this.denyRequest(args as { requestId: number; reason?: string });
case "delete_request":
return await this.deleteRequest(args as { requestId: number });
case "get_media_details":
return await this.getMediaDetails(args as {
mediaType: string;
mediaId: number;
});
case "get_server_status":
return await this.getServerStatus();
case "get_user_requests":
return await this.getUserRequests(args as {
userId: number;
take?: number;
skip?: number;
});
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${name}`
);
}
} catch (error) {
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
`Tool execution failed: ${error instanceof Error ? error.message : String(error)}`
);
}
});
}
private async searchMedia(args: { query: string; page?: number }) {
const { query, page = 1 } = args;
const data = await this.makeApiRequest(`/search?query=${encodeURIComponent(query)}&page=${page}`) as SearchResponse;
return {
content: [
{
type: "text",
text: JSON.stringify({
query,
page,
totalPages: data.totalPages,
totalResults: data.totalResults,
results: data.results.map((item: SearchResult) => ({
id: item.id,
mediaType: item.mediaType,
title: item.title || item.name,
overview: item.overview,
releaseDate: item.releaseDate || item.firstAirDate,
voteAverage: item.voteAverage,
posterPath: item.posterPath,
status: item.mediaInfo?.status,
alreadyRequested: item.mediaInfo?.requests && item.mediaInfo.requests.length > 0,
})),
}, null, 2),
},
],
};
}
private async getRequests(args: {
take?: number;
skip?: number;
filter?: string;
sort?: string;
}) {
const { take = 20, skip = 0, filter = "all", sort = "added" } = args;
const endpoint = `/request?take=${take}&skip=${skip}&filter=${filter}&sort=${sort}`;
const data = await this.makeApiRequest(endpoint) as RequestResponse;
return {
content: [
{
type: "text",
text: JSON.stringify({
pageInfo: data.pageInfo,
results: data.results.map((request: MediaRequest) => ({
id: request.id,
status: this.getStatusText(request.status),
mediaType: request.media.mediaType,
mediaTitle: request.media.title || request.media.name || 'Unbekannter Titel',
requestType: request.type,
requestedBy: request.requestedBy.displayName,
createdAt: request.createdAt,
updatedAt: request.updatedAt,
tmdbId: request.media.tmdbId,
})),
}, null, 2),
},
],
};
}
private async requestMedia(args: {
mediaId: number;
mediaType: string;
seasons?: number[];
is4k?: boolean;
}) {
const { mediaId, mediaType, seasons, is4k = false } = args;
const requestData: any = {
mediaId,
mediaType,
is4k,
};
if (mediaType === "tv" && seasons) {
requestData.seasons = seasons;
}
const data = await this.makeApiRequest("/request", {
method: "POST",
body: JSON.stringify(requestData),
}) as RequestCreationResponse;
return {
content: [
{
type: "text",
text: JSON.stringify({
success: true,
requestId: data.id,
status: this.getStatusText(data.status),
message: `Successfully requested ${mediaType} with ID ${mediaId}`,
}, null, 2),
},
],
};
}
private async approveRequest(args: { requestId: number }) {
const { requestId } = args;
const data = await this.makeApiRequest(`/request/${requestId}/approve`, {
method: "POST",
}) as ApprovalResponse;
return {
content: [
{
type: "text",
text: JSON.stringify({
success: true,
requestId,
status: this.getStatusText(data.status),
message: `Request ${requestId} has been approved`,
}, null, 2),
},
],
};
}
private async denyRequest(args: { requestId: number; reason?: string }) {
const { requestId, reason } = args;
const requestData: any = {};
if (reason) {
requestData.reason = reason;
}
const data = await this.makeApiRequest(`/request/${requestId}/decline`, {
method: "POST",
body: JSON.stringify(requestData),
}) as ApprovalResponse;
return {
content: [
{
type: "text",
text: JSON.stringify({
success: true,
requestId,
status: this.getStatusText(data.status),
message: `Request ${requestId} has been denied${reason ? ` with reason: ${reason}` : ""}`,
}, null, 2),
},
],
};
}
private async deleteRequest(args: { requestId: number }) {
const { requestId } = args;
await this.makeApiRequest(`/request/${requestId}`, {
method: "DELETE",
});
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
requestId,
message: `Request ${requestId} wurde gelöscht`
}, null, 2)
}]
};
}
private async getMediaDetails(args: { mediaType: string; mediaId: number }) {
const { mediaType, mediaId } = args;
const data = await this.makeApiRequest(`/${mediaType}/${mediaId}`);
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
};
}
private async getServerStatus() {
const data = await this.makeApiRequest("/status");
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
};
}
private async getUserRequests(args: {
userId: number;
take?: number;
skip?: number;
}) {
const { userId, take = 20, skip = 0 } = args;
const endpoint = `/user/${userId}/requests?take=${take}&skip=${skip}`;
const data = await this.makeApiRequest(endpoint);
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
};
}
private getStatusText(status: number): string {
const statusMap: { [key: number]: string } = {
1: "pending",
2: "approved",
3: "declined",
4: "processing",
5: "available",
};
return statusMap[status] || "unknown";
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error("Overseerr MCP server running on stdio");
}
}
// Server starten
const server = new OverseerrMCPServer();
server.run().catch((error) => {
console.error("Failed to run server:", error);
process.exit(1);
});