mcp-arr-server 1.5.4 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -5
- package/dist/arr-client.d.ts +13 -1
- package/dist/arr-client.d.ts.map +1 -1
- package/dist/arr-client.js +32 -2
- package/dist/arr-client.js.map +1 -1
- package/dist/index.js +448 -75
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
package/dist/index.js
CHANGED
|
@@ -13,9 +13,16 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
15
15
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
16
17
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
18
|
+
import { createServer } from "node:http";
|
|
17
19
|
import { SonarrClient, RadarrClient, LidarrClient, ProwlarrClient, } from "./arr-client.js";
|
|
18
20
|
import { trashClient } from "./trash-client.js";
|
|
21
|
+
const SERVER_VERSION = "1.6.2";
|
|
22
|
+
const TRANSPORT_MODE = (process.env.MCP_TRANSPORT || "stdio").toLowerCase();
|
|
23
|
+
const HTTP_HOST = process.env.HOST || "127.0.0.1";
|
|
24
|
+
const HTTP_PORT = Number(process.env.PORT || "3000");
|
|
25
|
+
const HTTP_PATH = process.env.MCP_PATH || "/mcp";
|
|
19
26
|
const services = [
|
|
20
27
|
{ name: 'sonarr', displayName: 'Sonarr (TV)', url: process.env.SONARR_URL, apiKey: process.env.SONARR_API_KEY },
|
|
21
28
|
{ name: 'radarr', displayName: 'Radarr (Movies)', url: process.env.RADARR_URL, apiKey: process.env.RADARR_API_KEY },
|
|
@@ -24,11 +31,6 @@ const services = [
|
|
|
24
31
|
];
|
|
25
32
|
// Check which services are configured
|
|
26
33
|
const configuredServices = services.filter(s => s.url && s.apiKey);
|
|
27
|
-
if (configuredServices.length === 0) {
|
|
28
|
-
console.error("Error: No *arr services configured. Set at least one pair of URL and API_KEY environment variables.");
|
|
29
|
-
console.error("Example: SONARR_URL and SONARR_API_KEY");
|
|
30
|
-
process.exit(1);
|
|
31
|
-
}
|
|
32
34
|
// Initialize clients for configured services
|
|
33
35
|
const clients = {};
|
|
34
36
|
for (const service of configuredServices) {
|
|
@@ -53,13 +55,43 @@ const TOOLS = [
|
|
|
53
55
|
// General tool available for all
|
|
54
56
|
{
|
|
55
57
|
name: "arr_status",
|
|
56
|
-
description:
|
|
58
|
+
description: configuredServices.length > 0
|
|
59
|
+
? `Get status of all configured *arr services. Currently configured: ${configuredServices.map(s => s.displayName).join(', ')}`
|
|
60
|
+
: "Get status of all supported *arr services. No local *arr services are currently configured, but TRaSH reference tools remain available.",
|
|
57
61
|
inputSchema: {
|
|
58
62
|
type: "object",
|
|
59
63
|
properties: {},
|
|
60
64
|
required: [],
|
|
61
65
|
},
|
|
62
66
|
},
|
|
67
|
+
{
|
|
68
|
+
name: "search",
|
|
69
|
+
description: "Search across configured *arr libraries plus TRaSH Guides reference profiles. This is the primary discovery tool for remote MCP clients such as ChatGPT.",
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
query: {
|
|
74
|
+
type: "string",
|
|
75
|
+
description: "Natural-language search query",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
required: ["query"],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "fetch",
|
|
83
|
+
description: "Fetch a specific item returned by search. Accepts an opaque item id from the search tool.",
|
|
84
|
+
inputSchema: {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
id: {
|
|
88
|
+
type: "string",
|
|
89
|
+
description: "Opaque result id returned by search",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
required: ["id"],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
63
95
|
];
|
|
64
96
|
// Configuration review tools for each service
|
|
65
97
|
// These are added dynamically based on configured services
|
|
@@ -134,10 +166,23 @@ if (clients.lidarr)
|
|
|
134
166
|
if (clients.sonarr) {
|
|
135
167
|
TOOLS.push({
|
|
136
168
|
name: "sonarr_get_series",
|
|
137
|
-
description: "Get
|
|
169
|
+
description: "Get TV series from Sonarr library with optional pagination and title filtering. Defaults to limit=25 to avoid very large responses. Use offset to fetch additional pages.",
|
|
138
170
|
inputSchema: {
|
|
139
171
|
type: "object",
|
|
140
|
-
properties: {
|
|
172
|
+
properties: {
|
|
173
|
+
limit: {
|
|
174
|
+
type: "number",
|
|
175
|
+
description: "Maximum number of series to return (default: 25, max: 100)",
|
|
176
|
+
},
|
|
177
|
+
offset: {
|
|
178
|
+
type: "number",
|
|
179
|
+
description: "Number of series to skip before returning results (default: 0)",
|
|
180
|
+
},
|
|
181
|
+
search: {
|
|
182
|
+
type: "string",
|
|
183
|
+
description: "Optional case-insensitive title filter",
|
|
184
|
+
},
|
|
185
|
+
},
|
|
141
186
|
required: [],
|
|
142
187
|
},
|
|
143
188
|
}, {
|
|
@@ -155,10 +200,19 @@ if (clients.sonarr) {
|
|
|
155
200
|
},
|
|
156
201
|
}, {
|
|
157
202
|
name: "sonarr_get_queue",
|
|
158
|
-
description: "Get Sonarr download queue",
|
|
203
|
+
description: "Get Sonarr download queue. Supports pagination with limit and offset.",
|
|
159
204
|
inputSchema: {
|
|
160
205
|
type: "object",
|
|
161
|
-
properties: {
|
|
206
|
+
properties: {
|
|
207
|
+
limit: {
|
|
208
|
+
type: "number",
|
|
209
|
+
description: "Maximum number of queue items to return (default: 25, max: 100)",
|
|
210
|
+
},
|
|
211
|
+
offset: {
|
|
212
|
+
type: "number",
|
|
213
|
+
description: "Number of queue items to skip before returning results (default: 0)",
|
|
214
|
+
},
|
|
215
|
+
},
|
|
162
216
|
required: [],
|
|
163
217
|
},
|
|
164
218
|
}, {
|
|
@@ -218,6 +272,19 @@ if (clients.sonarr) {
|
|
|
218
272
|
},
|
|
219
273
|
required: ["episodeIds"],
|
|
220
274
|
},
|
|
275
|
+
}, {
|
|
276
|
+
name: "sonarr_refresh_series",
|
|
277
|
+
description: "Trigger a metadata refresh for a specific series in Sonarr",
|
|
278
|
+
inputSchema: {
|
|
279
|
+
type: "object",
|
|
280
|
+
properties: {
|
|
281
|
+
seriesId: {
|
|
282
|
+
type: "number",
|
|
283
|
+
description: "Series ID to refresh",
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
required: ["seriesId"],
|
|
287
|
+
},
|
|
221
288
|
}, {
|
|
222
289
|
name: "sonarr_add_series",
|
|
223
290
|
description: "Add a TV series to Sonarr. Use sonarr_search first to find the tvdbId, and sonarr_get_root_folders / sonarr_get_quality_profiles to get valid values for rootFolderPath and qualityProfileId. Use sonarr_get_tags to get valid tag IDs.",
|
|
@@ -262,10 +329,23 @@ if (clients.sonarr) {
|
|
|
262
329
|
if (clients.radarr) {
|
|
263
330
|
TOOLS.push({
|
|
264
331
|
name: "radarr_get_movies",
|
|
265
|
-
description: "Get
|
|
332
|
+
description: "Get movies from Radarr library with optional pagination and title filtering. Defaults to limit=25 to avoid very large responses. Use offset to fetch additional pages.",
|
|
266
333
|
inputSchema: {
|
|
267
334
|
type: "object",
|
|
268
|
-
properties: {
|
|
335
|
+
properties: {
|
|
336
|
+
limit: {
|
|
337
|
+
type: "number",
|
|
338
|
+
description: "Maximum number of movies to return (default: 25, max: 100)",
|
|
339
|
+
},
|
|
340
|
+
offset: {
|
|
341
|
+
type: "number",
|
|
342
|
+
description: "Number of movies to skip before returning results (default: 0)",
|
|
343
|
+
},
|
|
344
|
+
search: {
|
|
345
|
+
type: "string",
|
|
346
|
+
description: "Optional case-insensitive title filter",
|
|
347
|
+
},
|
|
348
|
+
},
|
|
269
349
|
required: [],
|
|
270
350
|
},
|
|
271
351
|
}, {
|
|
@@ -283,10 +363,19 @@ if (clients.radarr) {
|
|
|
283
363
|
},
|
|
284
364
|
}, {
|
|
285
365
|
name: "radarr_get_queue",
|
|
286
|
-
description: "Get Radarr download queue",
|
|
366
|
+
description: "Get Radarr download queue. Supports pagination with limit and offset.",
|
|
287
367
|
inputSchema: {
|
|
288
368
|
type: "object",
|
|
289
|
-
properties: {
|
|
369
|
+
properties: {
|
|
370
|
+
limit: {
|
|
371
|
+
type: "number",
|
|
372
|
+
description: "Maximum number of queue items to return (default: 25, max: 100)",
|
|
373
|
+
},
|
|
374
|
+
offset: {
|
|
375
|
+
type: "number",
|
|
376
|
+
description: "Number of queue items to skip before returning results (default: 0)",
|
|
377
|
+
},
|
|
378
|
+
},
|
|
290
379
|
required: [],
|
|
291
380
|
},
|
|
292
381
|
}, {
|
|
@@ -315,6 +404,19 @@ if (clients.radarr) {
|
|
|
315
404
|
},
|
|
316
405
|
required: ["movieId"],
|
|
317
406
|
},
|
|
407
|
+
}, {
|
|
408
|
+
name: "radarr_refresh_movie",
|
|
409
|
+
description: "Trigger a metadata refresh for a specific movie in Radarr",
|
|
410
|
+
inputSchema: {
|
|
411
|
+
type: "object",
|
|
412
|
+
properties: {
|
|
413
|
+
movieId: {
|
|
414
|
+
type: "number",
|
|
415
|
+
description: "Movie ID to refresh",
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
required: ["movieId"],
|
|
419
|
+
},
|
|
318
420
|
}, {
|
|
319
421
|
name: "radarr_add_movie",
|
|
320
422
|
description: "Add a movie to Radarr. Use radarr_search first to find the tmdbId, and radarr_get_root_folders / radarr_get_quality_profiles to get valid values. Use radarr_get_tags to get valid tag IDs.",
|
|
@@ -381,10 +483,19 @@ if (clients.lidarr) {
|
|
|
381
483
|
},
|
|
382
484
|
}, {
|
|
383
485
|
name: "lidarr_get_queue",
|
|
384
|
-
description: "Get Lidarr download queue",
|
|
486
|
+
description: "Get Lidarr download queue. Supports pagination with limit and offset.",
|
|
385
487
|
inputSchema: {
|
|
386
488
|
type: "object",
|
|
387
|
-
properties: {
|
|
489
|
+
properties: {
|
|
490
|
+
limit: {
|
|
491
|
+
type: "number",
|
|
492
|
+
description: "Maximum number of queue items to return (default: 25, max: 100)",
|
|
493
|
+
},
|
|
494
|
+
offset: {
|
|
495
|
+
type: "number",
|
|
496
|
+
description: "Number of queue items to skip before returning results (default: 0)",
|
|
497
|
+
},
|
|
498
|
+
},
|
|
388
499
|
required: [],
|
|
389
500
|
},
|
|
390
501
|
}, {
|
|
@@ -692,12 +803,188 @@ TOOLS.push({
|
|
|
692
803
|
// Create server instance
|
|
693
804
|
const server = new Server({
|
|
694
805
|
name: "mcp-arr",
|
|
695
|
-
version:
|
|
806
|
+
version: SERVER_VERSION,
|
|
696
807
|
}, {
|
|
697
808
|
capabilities: {
|
|
698
809
|
tools: {},
|
|
699
810
|
},
|
|
700
811
|
});
|
|
812
|
+
function buildResourceUrl(path) {
|
|
813
|
+
return `mcp-arr://${path}`;
|
|
814
|
+
}
|
|
815
|
+
function jsonText(data) {
|
|
816
|
+
return {
|
|
817
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
function textError(message) {
|
|
821
|
+
return {
|
|
822
|
+
content: [{ type: "text", text: message }],
|
|
823
|
+
isError: true,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
async function runUnifiedSearch(query) {
|
|
827
|
+
const results = [];
|
|
828
|
+
const trimmedQuery = query.trim();
|
|
829
|
+
if (trimmedQuery.length === 0) {
|
|
830
|
+
return results;
|
|
831
|
+
}
|
|
832
|
+
const lowerQuery = trimmedQuery.toLowerCase();
|
|
833
|
+
for (const service of ["radarr", "sonarr"]) {
|
|
834
|
+
const profiles = await trashClient.listProfiles(service);
|
|
835
|
+
results.push(...profiles
|
|
836
|
+
.filter((profile) => profile.name.toLowerCase().includes(lowerQuery) ||
|
|
837
|
+
profile.description?.toLowerCase().includes(lowerQuery))
|
|
838
|
+
.slice(0, 8)
|
|
839
|
+
.map((profile) => ({
|
|
840
|
+
id: `trash-profile:${service}:${profile.name}`,
|
|
841
|
+
title: `${profile.name} (${service})`,
|
|
842
|
+
url: buildResourceUrl(`trash/profile/${service}/${encodeURIComponent(profile.name)}`),
|
|
843
|
+
type: "trash_profile",
|
|
844
|
+
service,
|
|
845
|
+
summary: profile.description?.replace(/<br>/g, " "),
|
|
846
|
+
})));
|
|
847
|
+
}
|
|
848
|
+
if (clients.sonarr) {
|
|
849
|
+
const series = await clients.sonarr.searchSeries(trimmedQuery);
|
|
850
|
+
results.push(...series.slice(0, 5).map((item) => ({
|
|
851
|
+
id: `arr:sonarr:series:${item.tvdbId}`,
|
|
852
|
+
title: `${item.title}${item.year ? ` (${item.year})` : ""}`,
|
|
853
|
+
url: buildResourceUrl(`arr/sonarr/series/${item.tvdbId}`),
|
|
854
|
+
type: "series",
|
|
855
|
+
service: "sonarr",
|
|
856
|
+
summary: item.overview?.slice(0, 220),
|
|
857
|
+
})));
|
|
858
|
+
}
|
|
859
|
+
if (clients.radarr) {
|
|
860
|
+
const movies = await clients.radarr.searchMovies(trimmedQuery);
|
|
861
|
+
results.push(...movies.slice(0, 5).map((item) => ({
|
|
862
|
+
id: `arr:radarr:movie:${item.tmdbId}`,
|
|
863
|
+
title: `${item.title}${item.year ? ` (${item.year})` : ""}`,
|
|
864
|
+
url: buildResourceUrl(`arr/radarr/movie/${item.tmdbId}`),
|
|
865
|
+
type: "movie",
|
|
866
|
+
service: "radarr",
|
|
867
|
+
summary: item.overview?.slice(0, 220),
|
|
868
|
+
})));
|
|
869
|
+
}
|
|
870
|
+
if (clients.lidarr) {
|
|
871
|
+
const artists = await clients.lidarr.searchArtists(trimmedQuery);
|
|
872
|
+
results.push(...artists.slice(0, 5).map((item) => ({
|
|
873
|
+
id: `arr:lidarr:artist:${item.foreignArtistId}`,
|
|
874
|
+
title: item.artistName || item.title,
|
|
875
|
+
url: buildResourceUrl(`arr/lidarr/artist/${item.foreignArtistId}`),
|
|
876
|
+
type: "artist",
|
|
877
|
+
service: "lidarr",
|
|
878
|
+
summary: item.overview?.slice(0, 220),
|
|
879
|
+
})));
|
|
880
|
+
}
|
|
881
|
+
return results;
|
|
882
|
+
}
|
|
883
|
+
async function fetchSearchEntry(id) {
|
|
884
|
+
const [kind, service, subtype, rawId] = id.split(":");
|
|
885
|
+
if (kind === "trash-profile" && (service === "radarr" || service === "sonarr")) {
|
|
886
|
+
const profile = await trashClient.getProfile(service, rawId);
|
|
887
|
+
if (!profile) {
|
|
888
|
+
throw new Error(`TRaSH profile '${rawId}' not found for ${service}`);
|
|
889
|
+
}
|
|
890
|
+
return {
|
|
891
|
+
id,
|
|
892
|
+
title: `${profile.name} (${service})`,
|
|
893
|
+
url: buildResourceUrl(`trash/profile/${service}/${encodeURIComponent(profile.name)}`),
|
|
894
|
+
service,
|
|
895
|
+
type: "trash_profile",
|
|
896
|
+
data: {
|
|
897
|
+
name: profile.name,
|
|
898
|
+
description: profile.trash_description?.replace(/<br>/g, "\n"),
|
|
899
|
+
upgradeAllowed: profile.upgradeAllowed,
|
|
900
|
+
cutoff: profile.cutoff,
|
|
901
|
+
minFormatScore: profile.minFormatScore,
|
|
902
|
+
cutoffFormatScore: profile.cutoffFormatScore,
|
|
903
|
+
language: profile.language,
|
|
904
|
+
qualities: profile.items,
|
|
905
|
+
customFormats: Object.entries(profile.formatItems || {}).map(([name, trashId]) => ({
|
|
906
|
+
name,
|
|
907
|
+
trash_id: trashId,
|
|
908
|
+
})),
|
|
909
|
+
},
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
if (kind !== "arr") {
|
|
913
|
+
throw new Error(`Unsupported fetch id '${id}'`);
|
|
914
|
+
}
|
|
915
|
+
if (service === "sonarr" && subtype === "series" && clients.sonarr) {
|
|
916
|
+
const tvdbId = Number(rawId);
|
|
917
|
+
const matches = (await clients.sonarr.searchSeries(rawId)).filter((item) => item.tvdbId === tvdbId);
|
|
918
|
+
return {
|
|
919
|
+
id,
|
|
920
|
+
title: matches[0]?.title || rawId,
|
|
921
|
+
url: buildResourceUrl(`arr/sonarr/series/${rawId}`),
|
|
922
|
+
service,
|
|
923
|
+
type: subtype,
|
|
924
|
+
data: matches.slice(0, 10),
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
if (service === "radarr" && subtype === "movie" && clients.radarr) {
|
|
928
|
+
const tmdbId = Number(rawId);
|
|
929
|
+
const matches = (await clients.radarr.searchMovies(rawId)).filter((item) => item.tmdbId === tmdbId);
|
|
930
|
+
return {
|
|
931
|
+
id,
|
|
932
|
+
title: matches[0]?.title || rawId,
|
|
933
|
+
url: buildResourceUrl(`arr/radarr/movie/${rawId}`),
|
|
934
|
+
service,
|
|
935
|
+
type: subtype,
|
|
936
|
+
data: matches.slice(0, 10),
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
if (service === "lidarr" && subtype === "artist" && clients.lidarr) {
|
|
940
|
+
const matches = (await clients.lidarr.searchArtists(rawId)).filter((item) => item.foreignArtistId === rawId);
|
|
941
|
+
return {
|
|
942
|
+
id,
|
|
943
|
+
title: matches[0]?.artistName || matches[0]?.title || rawId,
|
|
944
|
+
url: buildResourceUrl(`arr/lidarr/artist/${rawId}`),
|
|
945
|
+
service,
|
|
946
|
+
type: subtype,
|
|
947
|
+
data: matches.slice(0, 10),
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
throw new Error(`Unsupported or unavailable fetch target '${id}'`);
|
|
951
|
+
}
|
|
952
|
+
async function getPaginatedQueue(client, args) {
|
|
953
|
+
const limit = Math.min(Math.max(Math.floor(args?.limit ?? 25), 1), 100);
|
|
954
|
+
const offset = Math.max(Math.floor(args?.offset ?? 0), 0);
|
|
955
|
+
const pageSize = 100;
|
|
956
|
+
const records = [];
|
|
957
|
+
let totalRecords = 0;
|
|
958
|
+
let page = 1;
|
|
959
|
+
while (true) {
|
|
960
|
+
const queuePage = await client.getQueue(page, pageSize);
|
|
961
|
+
totalRecords = queuePage.totalRecords;
|
|
962
|
+
records.push(...queuePage.records);
|
|
963
|
+
if (records.length >= totalRecords || queuePage.records.length === 0) {
|
|
964
|
+
break;
|
|
965
|
+
}
|
|
966
|
+
page += 1;
|
|
967
|
+
}
|
|
968
|
+
const items = records.slice(offset, offset + limit).map((q) => ({
|
|
969
|
+
title: q.title,
|
|
970
|
+
status: q.status,
|
|
971
|
+
progress: q.size > 0 ? ((1 - q.sizeleft / q.size) * 100).toFixed(1) + "%" : "unknown",
|
|
972
|
+
timeLeft: q.timeleft,
|
|
973
|
+
downloadClient: q.downloadClient,
|
|
974
|
+
protocol: q.protocol,
|
|
975
|
+
trackedDownloadStatus: q.trackedDownloadStatus,
|
|
976
|
+
trackedDownloadState: q.trackedDownloadState,
|
|
977
|
+
}));
|
|
978
|
+
return {
|
|
979
|
+
total: totalRecords,
|
|
980
|
+
returned: items.length,
|
|
981
|
+
offset,
|
|
982
|
+
limit,
|
|
983
|
+
hasMore: offset + items.length < totalRecords,
|
|
984
|
+
nextOffset: offset + items.length < totalRecords ? offset + items.length : null,
|
|
985
|
+
items,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
701
988
|
// Handle list tools request
|
|
702
989
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
703
990
|
return { tools: TOOLS };
|
|
@@ -707,6 +994,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
707
994
|
const { name, arguments: args } = request.params;
|
|
708
995
|
try {
|
|
709
996
|
switch (name) {
|
|
997
|
+
case "search": {
|
|
998
|
+
const query = args.query;
|
|
999
|
+
const results = await runUnifiedSearch(query);
|
|
1000
|
+
return jsonText({ results });
|
|
1001
|
+
}
|
|
1002
|
+
case "fetch": {
|
|
1003
|
+
const id = args.id;
|
|
1004
|
+
const result = await fetchSearchEntry(id);
|
|
1005
|
+
return jsonText(result);
|
|
1006
|
+
}
|
|
710
1007
|
case "arr_status": {
|
|
711
1008
|
const statuses = {};
|
|
712
1009
|
for (const service of configuredServices) {
|
|
@@ -736,9 +1033,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
736
1033
|
statuses[service.name] = { configured: false };
|
|
737
1034
|
}
|
|
738
1035
|
}
|
|
739
|
-
return
|
|
740
|
-
content: [{ type: "text", text: JSON.stringify(statuses, null, 2) }],
|
|
741
|
-
};
|
|
1036
|
+
return jsonText(statuses);
|
|
742
1037
|
}
|
|
743
1038
|
// Dynamic config tool handlers
|
|
744
1039
|
// Quality Profiles
|
|
@@ -994,13 +1289,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
994
1289
|
case "sonarr_get_series": {
|
|
995
1290
|
if (!clients.sonarr)
|
|
996
1291
|
throw new Error("Sonarr not configured");
|
|
997
|
-
const
|
|
1292
|
+
const { limit = 25, offset = 0, search } = args;
|
|
1293
|
+
const normalizedLimit = Math.max(1, Math.min(limit, 100));
|
|
1294
|
+
const normalizedOffset = Math.max(0, offset);
|
|
1295
|
+
const filter = search?.trim().toLowerCase();
|
|
1296
|
+
const allSeries = await clients.sonarr.getSeries();
|
|
1297
|
+
const filteredSeries = filter
|
|
1298
|
+
? allSeries.filter(s => s.title.toLowerCase().includes(filter))
|
|
1299
|
+
: allSeries;
|
|
1300
|
+
const pagedSeries = filteredSeries.slice(normalizedOffset, normalizedOffset + normalizedLimit);
|
|
998
1301
|
return {
|
|
999
1302
|
content: [{
|
|
1000
1303
|
type: "text",
|
|
1001
1304
|
text: JSON.stringify({
|
|
1002
|
-
|
|
1003
|
-
|
|
1305
|
+
total: allSeries.length,
|
|
1306
|
+
filteredCount: filteredSeries.length,
|
|
1307
|
+
returned: pagedSeries.length,
|
|
1308
|
+
offset: normalizedOffset,
|
|
1309
|
+
limit: normalizedLimit,
|
|
1310
|
+
hasMore: normalizedOffset + normalizedLimit < filteredSeries.length,
|
|
1311
|
+
nextOffset: normalizedOffset + normalizedLimit < filteredSeries.length
|
|
1312
|
+
? normalizedOffset + normalizedLimit
|
|
1313
|
+
: null,
|
|
1314
|
+
search: search ?? null,
|
|
1315
|
+
series: pagedSeries.map(s => ({
|
|
1004
1316
|
id: s.id,
|
|
1005
1317
|
title: s.title,
|
|
1006
1318
|
year: s.year,
|
|
@@ -1038,22 +1350,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1038
1350
|
case "sonarr_get_queue": {
|
|
1039
1351
|
if (!clients.sonarr)
|
|
1040
1352
|
throw new Error("Sonarr not configured");
|
|
1041
|
-
|
|
1042
|
-
return {
|
|
1043
|
-
content: [{
|
|
1044
|
-
type: "text",
|
|
1045
|
-
text: JSON.stringify({
|
|
1046
|
-
totalRecords: queue.totalRecords,
|
|
1047
|
-
items: queue.records.map(q => ({
|
|
1048
|
-
title: q.title,
|
|
1049
|
-
status: q.status,
|
|
1050
|
-
progress: ((1 - q.sizeleft / q.size) * 100).toFixed(1) + '%',
|
|
1051
|
-
timeLeft: q.timeleft,
|
|
1052
|
-
downloadClient: q.downloadClient,
|
|
1053
|
-
})),
|
|
1054
|
-
}, null, 2),
|
|
1055
|
-
}],
|
|
1056
|
-
};
|
|
1353
|
+
return jsonText(await getPaginatedQueue(clients.sonarr, args));
|
|
1057
1354
|
}
|
|
1058
1355
|
case "sonarr_get_calendar": {
|
|
1059
1356
|
if (!clients.sonarr)
|
|
@@ -1121,6 +1418,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1121
1418
|
}],
|
|
1122
1419
|
};
|
|
1123
1420
|
}
|
|
1421
|
+
case "sonarr_refresh_series": {
|
|
1422
|
+
if (!clients.sonarr)
|
|
1423
|
+
throw new Error("Sonarr not configured");
|
|
1424
|
+
const seriesId = args.seriesId;
|
|
1425
|
+
const series = await clients.sonarr.getSeriesById(seriesId);
|
|
1426
|
+
const result = await clients.sonarr.refreshSeries(seriesId);
|
|
1427
|
+
return {
|
|
1428
|
+
content: [{
|
|
1429
|
+
type: "text",
|
|
1430
|
+
text: JSON.stringify({
|
|
1431
|
+
success: true,
|
|
1432
|
+
message: `Refresh triggered for series`,
|
|
1433
|
+
series: {
|
|
1434
|
+
id: series.id,
|
|
1435
|
+
title: series.title,
|
|
1436
|
+
year: series.year,
|
|
1437
|
+
},
|
|
1438
|
+
commandId: result.id,
|
|
1439
|
+
}, null, 2),
|
|
1440
|
+
}],
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1124
1443
|
case "sonarr_add_series": {
|
|
1125
1444
|
if (!clients.sonarr)
|
|
1126
1445
|
throw new Error("Sonarr not configured");
|
|
@@ -1145,13 +1464,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1145
1464
|
case "radarr_get_movies": {
|
|
1146
1465
|
if (!clients.radarr)
|
|
1147
1466
|
throw new Error("Radarr not configured");
|
|
1148
|
-
const
|
|
1467
|
+
const { limit = 25, offset = 0, search } = args;
|
|
1468
|
+
const normalizedLimit = Math.max(1, Math.min(limit, 100));
|
|
1469
|
+
const normalizedOffset = Math.max(0, offset);
|
|
1470
|
+
const filter = search?.trim().toLowerCase();
|
|
1471
|
+
const allMovies = await clients.radarr.getMovies();
|
|
1472
|
+
const filteredMovies = filter
|
|
1473
|
+
? allMovies.filter(m => m.title.toLowerCase().includes(filter))
|
|
1474
|
+
: allMovies;
|
|
1475
|
+
const pagedMovies = filteredMovies.slice(normalizedOffset, normalizedOffset + normalizedLimit);
|
|
1149
1476
|
return {
|
|
1150
1477
|
content: [{
|
|
1151
1478
|
type: "text",
|
|
1152
1479
|
text: JSON.stringify({
|
|
1153
|
-
|
|
1154
|
-
|
|
1480
|
+
total: allMovies.length,
|
|
1481
|
+
filteredCount: filteredMovies.length,
|
|
1482
|
+
returned: pagedMovies.length,
|
|
1483
|
+
offset: normalizedOffset,
|
|
1484
|
+
limit: normalizedLimit,
|
|
1485
|
+
hasMore: normalizedOffset + normalizedLimit < filteredMovies.length,
|
|
1486
|
+
nextOffset: normalizedOffset + normalizedLimit < filteredMovies.length
|
|
1487
|
+
? normalizedOffset + normalizedLimit
|
|
1488
|
+
: null,
|
|
1489
|
+
search: search ?? null,
|
|
1490
|
+
movies: pagedMovies.map(m => ({
|
|
1155
1491
|
id: m.id,
|
|
1156
1492
|
title: m.title,
|
|
1157
1493
|
year: m.year,
|
|
@@ -1189,22 +1525,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1189
1525
|
case "radarr_get_queue": {
|
|
1190
1526
|
if (!clients.radarr)
|
|
1191
1527
|
throw new Error("Radarr not configured");
|
|
1192
|
-
|
|
1193
|
-
return {
|
|
1194
|
-
content: [{
|
|
1195
|
-
type: "text",
|
|
1196
|
-
text: JSON.stringify({
|
|
1197
|
-
totalRecords: queue.totalRecords,
|
|
1198
|
-
items: queue.records.map(q => ({
|
|
1199
|
-
title: q.title,
|
|
1200
|
-
status: q.status,
|
|
1201
|
-
progress: ((1 - q.sizeleft / q.size) * 100).toFixed(1) + '%',
|
|
1202
|
-
timeLeft: q.timeleft,
|
|
1203
|
-
downloadClient: q.downloadClient,
|
|
1204
|
-
})),
|
|
1205
|
-
}, null, 2),
|
|
1206
|
-
}],
|
|
1207
|
-
};
|
|
1528
|
+
return jsonText(await getPaginatedQueue(clients.radarr, args));
|
|
1208
1529
|
}
|
|
1209
1530
|
case "radarr_get_calendar": {
|
|
1210
1531
|
if (!clients.radarr)
|
|
@@ -1233,6 +1554,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1233
1554
|
}],
|
|
1234
1555
|
};
|
|
1235
1556
|
}
|
|
1557
|
+
case "radarr_refresh_movie": {
|
|
1558
|
+
if (!clients.radarr)
|
|
1559
|
+
throw new Error("Radarr not configured");
|
|
1560
|
+
const movieId = args.movieId;
|
|
1561
|
+
const movie = await clients.radarr.getMovieById(movieId);
|
|
1562
|
+
const result = await clients.radarr.refreshMovie(movieId);
|
|
1563
|
+
return {
|
|
1564
|
+
content: [{
|
|
1565
|
+
type: "text",
|
|
1566
|
+
text: JSON.stringify({
|
|
1567
|
+
success: true,
|
|
1568
|
+
message: `Refresh triggered for movie`,
|
|
1569
|
+
movie: {
|
|
1570
|
+
id: movie.id,
|
|
1571
|
+
title: movie.title,
|
|
1572
|
+
year: movie.year,
|
|
1573
|
+
},
|
|
1574
|
+
commandId: result.id,
|
|
1575
|
+
}, null, 2),
|
|
1576
|
+
}],
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1236
1579
|
case "radarr_add_movie": {
|
|
1237
1580
|
if (!clients.radarr)
|
|
1238
1581
|
throw new Error("Radarr not configured");
|
|
@@ -1302,22 +1645,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1302
1645
|
case "lidarr_get_queue": {
|
|
1303
1646
|
if (!clients.lidarr)
|
|
1304
1647
|
throw new Error("Lidarr not configured");
|
|
1305
|
-
|
|
1306
|
-
return {
|
|
1307
|
-
content: [{
|
|
1308
|
-
type: "text",
|
|
1309
|
-
text: JSON.stringify({
|
|
1310
|
-
totalRecords: queue.totalRecords,
|
|
1311
|
-
items: queue.records.map(q => ({
|
|
1312
|
-
title: q.title,
|
|
1313
|
-
status: q.status,
|
|
1314
|
-
progress: ((1 - q.sizeleft / q.size) * 100).toFixed(1) + '%',
|
|
1315
|
-
timeLeft: q.timeleft,
|
|
1316
|
-
downloadClient: q.downloadClient,
|
|
1317
|
-
})),
|
|
1318
|
-
}, null, 2),
|
|
1319
|
-
}],
|
|
1320
|
-
};
|
|
1648
|
+
return jsonText(await getPaginatedQueue(clients.lidarr, args));
|
|
1321
1649
|
}
|
|
1322
1650
|
case "lidarr_get_albums": {
|
|
1323
1651
|
if (!clients.lidarr)
|
|
@@ -1896,11 +2224,56 @@ function formatBytes(bytes) {
|
|
|
1896
2224
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
1897
2225
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
1898
2226
|
}
|
|
2227
|
+
async function startHttpServer() {
|
|
2228
|
+
const transport = new StreamableHTTPServerTransport({
|
|
2229
|
+
sessionIdGenerator: undefined,
|
|
2230
|
+
});
|
|
2231
|
+
await server.connect(transport);
|
|
2232
|
+
const httpServer = createServer(async (req, res) => {
|
|
2233
|
+
if (!req.url) {
|
|
2234
|
+
res.statusCode = 400;
|
|
2235
|
+
res.end("Missing URL");
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
const requestUrl = new URL(req.url, `http://${req.headers.host || `${HTTP_HOST}:${HTTP_PORT}`}`);
|
|
2239
|
+
if (requestUrl.pathname === "/health" && req.method === "GET") {
|
|
2240
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2241
|
+
res.end(JSON.stringify({
|
|
2242
|
+
status: "ok",
|
|
2243
|
+
version: SERVER_VERSION,
|
|
2244
|
+
transport: "http",
|
|
2245
|
+
configuredServices: configuredServices.map((service) => service.name),
|
|
2246
|
+
}));
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
if (requestUrl.pathname !== HTTP_PATH) {
|
|
2250
|
+
res.statusCode = 404;
|
|
2251
|
+
res.end("Not found");
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
try {
|
|
2255
|
+
await transport.handleRequest(req, res);
|
|
2256
|
+
}
|
|
2257
|
+
catch (error) {
|
|
2258
|
+
res.statusCode = 500;
|
|
2259
|
+
res.end(error instanceof Error ? error.message : String(error));
|
|
2260
|
+
}
|
|
2261
|
+
});
|
|
2262
|
+
await new Promise((resolve, reject) => {
|
|
2263
|
+
httpServer.once("error", reject);
|
|
2264
|
+
httpServer.listen(HTTP_PORT, HTTP_HOST, () => resolve());
|
|
2265
|
+
});
|
|
2266
|
+
console.error(`*arr MCP server running over HTTP at http://${HTTP_HOST}:${HTTP_PORT}${HTTP_PATH}`);
|
|
2267
|
+
}
|
|
1899
2268
|
// Start the server
|
|
1900
2269
|
async function main() {
|
|
2270
|
+
if (TRANSPORT_MODE === "http") {
|
|
2271
|
+
await startHttpServer();
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
1901
2274
|
const transport = new StdioServerTransport();
|
|
1902
2275
|
await server.connect(transport);
|
|
1903
|
-
console.error(`*arr MCP server running - configured services: ${configuredServices.map(s => s.name).join(', ')}`);
|
|
2276
|
+
console.error(`*arr MCP server running over stdio - configured services: ${configuredServices.map(s => s.name).join(', ') || 'none (TRaSH-only mode)'}`);
|
|
1904
2277
|
}
|
|
1905
2278
|
main().catch((error) => {
|
|
1906
2279
|
console.error("Fatal error:", error);
|