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