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/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: `Get status of all configured *arr services. Currently configured: ${configuredServices.map(s => s.displayName).join(', ')}`,
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 all TV series in Sonarr library",
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 all movies in Radarr library",
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: "1.0.0",
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 series = await clients.sonarr.getSeries();
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
- count: series.length,
1003
- series: series.map(s => ({
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
- const queue = await clients.sonarr.getQueue();
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 movies = await clients.radarr.getMovies();
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
- count: movies.length,
1154
- movies: movies.map(m => ({
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
- const queue = await clients.radarr.getQueue();
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
- const queue = await clients.lidarr.getQueue();
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);