diff --git a/README.md b/README.md index 37810ec..e1f83bd 100644 --- a/README.md +++ b/README.md @@ -545,24 +545,39 @@ docker run -d \ ghcr.io/electather/seerr-cli:latest ``` -### Available tools (43) - -| Category | Tools | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------ | -| Search & Discovery | `search_multi`, `search_discover_movies`, `search_discover_tv`, `search_trending` | -| Movies | `movies_get`, `movies_recommendations`, `movies_similar`, `movies_ratings` | -| TV Shows | `tv_get`, `tv_season`, `tv_recommendations`, `tv_similar`, `tv_ratings` | -| Requests | `request_list`, `request_get`, `request_create`, `request_approve`, `request_decline`, `request_delete`, `request_count` | -| Media | `media_list`, `media_status_update` | -| Issues | `issue_list`, `issue_get`, `issue_create`, `issue_status_update`, `issue_count` | -| Users | `users_list`, `users_get`, `users_quota` | -| People | `person_get`, `person_credits` | -| Collections | `collection_get` | -| Services | `service_radarr_list`, `service_sonarr_list` | -| Settings | `settings_about`, `settings_jobs_list`, `settings_jobs_run` | -| Watchlist | `watchlist_add`, `watchlist_remove` | -| Blocklist | `blocklist_list`, `blocklist_add`, `blocklist_remove` | -| System | `status_system` | +### Available tools (52) + +| Category | Tools | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| Search & Discovery | `search_multi`, `search_discover_movies`, `search_discover_tv`, `search_trending`, `search_company`, `search_keyword` | +| Movies | `movies_get`, `movies_recommendations`, `movies_similar`, `movies_ratings`, `movies_ratings_combined` | +| TV Shows | `tv_get`, `tv_season`, `tv_recommendations`, `tv_similar`, `tv_ratings` | +| Requests | `request_list`, `request_get`, `request_create`, `request_approve`, `request_decline`, `request_delete`, `request_count`, `request_retry` | +| Media | `media_list`, `media_status_update` | +| Issues | `issue_list`, `issue_get`, `issue_create`, `issue_status_update`, `issue_count` | +| Auth | `auth_me` | +| Users | `users_list`, `users_get`, `users_quota`, `users_update` | +| People | `person_get`, `person_credits` | +| Collections | `collection_get` | +| Services | `service_radarr_list`, `service_sonarr_list` | +| Settings | `settings_about`, `settings_jobs_list`, `settings_jobs_run`, `settings_jobs_cancel`, `settings_jobs_schedule` | +| Watchlist | `watchlist_add`, `watchlist_remove` | +| Blocklist | `blocklist_list`, `blocklist_add`, `blocklist_remove` | +| System | `status_system` | + +### Available resources (9) + +| URI | Description | +| ---------------------------- | -------------------------------------------------------------- | +| `seerr://genres/movies` | TMDB movie genre ID to name map | +| `seerr://genres/tv` | TMDB TV genre ID to name map | +| `seerr://languages` | ISO language codes and English names supported by TMDB | +| `seerr://regions` | ISO region codes and English names supported by TMDB | +| `seerr://certifications/movies` | Content ratings by country for movies (G, PG, R, etc.) | +| `seerr://certifications/tv` | Content ratings by country for TV shows | +| `seerr://services/radarr` | Configured Radarr instances with quality profiles and root folders | +| `seerr://services/sonarr` | Configured Sonarr instances with quality profiles and root folders | +| `seerr://system/about` | Seerr version, commit hash, and update availability | ## Supported Platforms diff --git a/cmd/mcp/inventory.go b/cmd/mcp/inventory.go new file mode 100644 index 0000000..da4d5a4 --- /dev/null +++ b/cmd/mcp/inventory.go @@ -0,0 +1,10 @@ +package mcp + +// Inventory counts must match the registrations in registerXxx functions. +// serve.go references these so log lines stay accurate. Tests assert them +// to catch drift when tools are added or removed. +const ( + MCPToolCount = 52 + MCPResourceCount = 9 + MCPPromptCount = 6 +) diff --git a/cmd/mcp/prompts.go b/cmd/mcp/prompts.go index 6a5457c..5a1608d 100644 --- a/cmd/mcp/prompts.go +++ b/cmd/mcp/prompts.go @@ -136,7 +136,7 @@ The user's preferences: %q Follow these steps: 1. Read the seerr://genres/movies and seerr://genres/tv resources to find genre IDs that match the user's preferences. -2. Based on the media type preference (%q), call search_discover_movies or search_discover_tv with a relevant genre filter. +2. Based on the media type preference (%q), call search_discover_movies or search_discover_tv with relevant filters (e.g. genre, language, sortBy, or date range) using the genre IDs from the resources above. 3. Also call search_trending to supplement with trending content. 4. Present a curated list of 5-8 recommendations, including title, year, genre, and whether each is already Available in Seerr (check mediaInfo.status == 5). 5. Ask the user if they would like to request any of the shown titles.`, @@ -225,7 +225,6 @@ Follow these steps: - 5: Other 4. If no description was provided, ask the user to describe the problem briefly. 5. Call issue_create with: - - mediaType: the type from search results - mediaId: the TMDB ID - issueType: the number selected above (1-5) - message: the problem description diff --git a/cmd/mcp/resources.go b/cmd/mcp/resources.go index b09d947..306c316 100644 --- a/cmd/mcp/resources.go +++ b/cmd/mcp/resources.go @@ -190,38 +190,6 @@ func CertificationsTVResourceHandler() server.ResourceHandlerFunc { } } -// WatchProvidersMoviesResourceHandler returns a resource handler for movie streaming providers. -func WatchProvidersMoviesResourceHandler() server.ResourceHandlerFunc { - return func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - client := newAPIClientWithKey(apiKeyFromContext(ctx)) - res, _, err := client.OtherAPI.WatchprovidersMoviesGet(ctx).Execute() - if err != nil { - return nil, err - } - data, err := json.MarshalIndent(res, "", " ") - if err != nil { - return nil, err - } - return textResource(req.Params.URI, data), nil - } -} - -// WatchProvidersTVResourceHandler returns a resource handler for TV streaming providers. -func WatchProvidersTVResourceHandler() server.ResourceHandlerFunc { - return func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - client := newAPIClientWithKey(apiKeyFromContext(ctx)) - res, _, err := client.OtherAPI.WatchprovidersTvGet(ctx).Execute() - if err != nil { - return nil, err - } - data, err := json.MarshalIndent(res, "", " ") - if err != nil { - return nil, err - } - return textResource(req.Params.URI, data), nil - } -} - // RadarrServicesResourceHandler returns a resource handler that lists all configured // Radarr instances enriched with per-instance quality profiles and root folders. func RadarrServicesResourceHandler() server.ResourceHandlerFunc { diff --git a/cmd/mcp/serve.go b/cmd/mcp/serve.go index c4c7653..685f08a 100644 --- a/cmd/mcp/serve.go +++ b/cmd/mcp/serve.go @@ -130,7 +130,7 @@ func runServe(_ *cobra.Command, args []string) error { mcpLog.Info("starting MCP server", "transport", "stdio", "seerr_api", seerServer, - "tools", 46, "resources", 9, "prompts", 6, + "tools", MCPToolCount, "resources", MCPResourceCount, "prompts", MCPPromptCount, ) mcpLog.Debug("stdio transport ready, waiting for MCP client on stdin") return server.ServeStdio(s) @@ -150,7 +150,7 @@ func runServe(_ *cobra.Command, args []string) error { "transport", "http", "endpoint", endpoint, "seerr_api", seerServer, - "tools", 46, "resources", 9, "prompts", 6, + "tools", MCPToolCount, "resources", MCPResourceCount, "prompts", MCPPromptCount, "tls", tlsCert != "", "auth_token", authToken != "", "cors", cors, diff --git a/cmd/mcp/tools_issues.go b/cmd/mcp/tools_issues.go index ed324d9..a9f723b 100644 --- a/cmd/mcp/tools_issues.go +++ b/cmd/mcp/tools_issues.go @@ -40,7 +40,7 @@ func registerIssueTools(s *server.MCPServer) { mcp.WithDestructiveHintAnnotation(false), mcp.WithReadOnlyHintAnnotation(false), mcp.WithIdempotentHintAnnotation(false), - mcp.WithNumber("issueType", mcp.Required(), mcp.Description("Issue type (1=Video, 2=Audio, 3=Subtitle, 4=Other)")), + mcp.WithNumber("issueType", mcp.Required(), mcp.Description("Issue type (1=Video, 2=Audio, 3=Subtitle, 4=Wrong content, 5=Other)")), mcp.WithString("message", mcp.Required(), mcp.Description("Issue message")), mcp.WithNumber("mediaId", mcp.Required(), mcp.Description("Media ID")), ), diff --git a/cmd/mcp/tools_movies.go b/cmd/mcp/tools_movies.go index a80ab08..44712e0 100644 --- a/cmd/mcp/tools_movies.go +++ b/cmd/mcp/tools_movies.go @@ -54,6 +54,17 @@ func registerMoviesTools(s *server.MCPServer) { ), MoviesRatingsHandler(), ) + + s.AddTool( + mcp.NewTool("movies_ratings_combined", + mcp.WithDescription("Get combined RT and IMDB ratings for a given movie"), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithNumber("movieId", mcp.Required(), mcp.Description("TMDB movie ID")), + ), + MoviesRatingsCombinedHandler(), + ) } func MoviesGetHandler() server.ToolHandlerFunc { @@ -121,6 +132,25 @@ func MoviesSimilarHandler() server.ToolHandlerFunc { } } +func MoviesRatingsCombinedHandler() server.ToolHandlerFunc { + return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + movieId, err := req.RequireInt("movieId") + if err != nil { + return nil, err + } + client := newAPIClientWithKey(apiKeyFromContext(callCtx)) + res, _, err := client.MoviesAPI.MovieMovieIdRatingscombinedGet(callCtx, float32(movieId)).Execute() + if err != nil { + return apiToolError("MovieMovieIdRatingscombinedGet failed", err) + } + b, err := json.Marshal(res) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(string(b)), nil + } +} + func MoviesRatingsHandler() server.ToolHandlerFunc { return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { movieId, err := req.RequireInt("movieId") diff --git a/cmd/mcp/tools_search.go b/cmd/mcp/tools_search.go index 6dd7d8a..82174a2 100644 --- a/cmd/mcp/tools_search.go +++ b/cmd/mcp/tools_search.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "encoding/json" "fmt" "net/url" "strconv" @@ -34,6 +35,16 @@ func registerSearchTools(s *server.MCPServer) { mcp.WithDestructiveHintAnnotation(false), mcp.WithIdempotentHintAnnotation(true), mcp.WithNumber("page", mcp.Description("Page number")), + mcp.WithString("genre", mcp.Description("Genre ID (use seerr://genres/movies resource for IDs)")), + mcp.WithString("studio", mcp.Description("Studio/production company ID")), + mcp.WithString("language", mcp.Description("ISO 639-1 language code, e.g. 'en'")), + mcp.WithString("sortBy", mcp.Description("Sort order, e.g. 'popularity.desc', 'vote_average.desc'")), + mcp.WithString("primaryReleaseDateGte", mcp.Description("Minimum release date (YYYY-MM-DD)")), + mcp.WithString("primaryReleaseDateLte", mcp.Description("Maximum release date (YYYY-MM-DD)")), + mcp.WithNumber("voteAverageGte", mcp.Description("Minimum vote average (0-10)")), + mcp.WithNumber("voteAverageLte", mcp.Description("Maximum vote average (0-10)")), + mcp.WithNumber("withRuntimeGte", mcp.Description("Minimum runtime in minutes")), + mcp.WithNumber("withRuntimeLte", mcp.Description("Maximum runtime in minutes")), ), SearchDiscoverMoviesHandler(), ) @@ -46,6 +57,16 @@ func registerSearchTools(s *server.MCPServer) { mcp.WithDestructiveHintAnnotation(false), mcp.WithIdempotentHintAnnotation(true), mcp.WithNumber("page", mcp.Description("Page number")), + mcp.WithString("genre", mcp.Description("Genre ID (use seerr://genres/tv resource for IDs)")), + mcp.WithString("network", mcp.Description("Network ID")), + mcp.WithString("language", mcp.Description("ISO 639-1 language code, e.g. 'en'")), + mcp.WithString("sortBy", mcp.Description("Sort order, e.g. 'popularity.desc', 'vote_average.desc'")), + mcp.WithString("firstAirDateGte", mcp.Description("Minimum first air date (YYYY-MM-DD)")), + mcp.WithString("firstAirDateLte", mcp.Description("Maximum first air date (YYYY-MM-DD)")), + mcp.WithNumber("voteAverageGte", mcp.Description("Minimum vote average (0-10)")), + mcp.WithNumber("voteAverageLte", mcp.Description("Maximum vote average (0-10)")), + mcp.WithNumber("withRuntimeGte", mcp.Description("Minimum runtime in minutes")), + mcp.WithNumber("withRuntimeLte", mcp.Description("Maximum runtime in minutes")), ), SearchDiscoverTVHandler(), ) @@ -61,6 +82,32 @@ func registerSearchTools(s *server.MCPServer) { ), SearchTrendingHandler(), ) + + s.AddTool( + mcp.NewTool("search_company", + mcp.WithDescription("Search for production companies"), + mcp.WithOpenWorldHintAnnotation(true), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithString("query", mcp.Required(), mcp.Description("Search query")), + mcp.WithNumber("page", mcp.Description("Page number")), + ), + SearchCompanyHandler(), + ) + + s.AddTool( + mcp.NewTool("search_keyword", + mcp.WithDescription("Search for TMDB keywords"), + mcp.WithOpenWorldHintAnnotation(true), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithString("query", mcp.Required(), mcp.Description("Search query")), + mcp.WithNumber("page", mcp.Description("Page number")), + ), + SearchKeywordHandler(), + ) } func SearchMultiHandler() server.ToolHandlerFunc { @@ -107,6 +154,16 @@ func SearchDiscoverMoviesHandler() server.ToolHandlerFunc { if page := req.GetFloat("page", 0); page > 0 { params.Set("page", strconv.Itoa(int(page))) } + for _, key := range []string{"genre", "studio", "language", "sortBy", "primaryReleaseDateGte", "primaryReleaseDateLte"} { + if v := req.GetString(key, ""); v != "" { + params.Set(key, v) + } + } + for _, key := range []string{"voteAverageGte", "voteAverageLte", "withRuntimeGte", "withRuntimeLte"} { + if v := req.GetFloat(key, 0); v != 0 { + params.Set(key, strconv.FormatFloat(v, 'f', -1, 64)) + } + } b, err := apiutil.RawGet(callCtx, client, "/discover/movies", params) if err != nil { return apiToolError("DiscoverMoviesGet failed", err) @@ -131,6 +188,16 @@ func SearchDiscoverTVHandler() server.ToolHandlerFunc { if page := req.GetFloat("page", 0); page > 0 { params.Set("page", strconv.Itoa(int(page))) } + for _, key := range []string{"genre", "network", "language", "sortBy", "firstAirDateGte", "firstAirDateLte"} { + if v := req.GetString(key, ""); v != "" { + params.Set(key, v) + } + } + for _, key := range []string{"voteAverageGte", "voteAverageLte", "withRuntimeGte", "withRuntimeLte"} { + if v := req.GetFloat(key, 0); v != 0 { + params.Set(key, strconv.FormatFloat(v, 'f', -1, 64)) + } + } b, err := apiutil.RawGet(callCtx, client, "/discover/tv", params) if err != nil { return apiToolError("DiscoverTvGet failed", err) @@ -143,6 +210,52 @@ func SearchDiscoverTVHandler() server.ToolHandlerFunc { } } +func SearchCompanyHandler() server.ToolHandlerFunc { + return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query := req.GetString("query", "") + if query == "" { + return nil, fmt.Errorf("query is required") + } + client := newAPIClientWithKey(apiKeyFromContext(callCtx)) + r := client.SearchAPI.SearchCompanyGet(callCtx).Query(query) + if page := req.GetFloat("page", 0); page > 0 { + r = r.Page(float32(page)) + } + res, _, err := r.Execute() + if err != nil { + return apiToolError("SearchCompanyGet failed", err) + } + b, err := json.Marshal(res) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(string(b)), nil + } +} + +func SearchKeywordHandler() server.ToolHandlerFunc { + return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query := req.GetString("query", "") + if query == "" { + return nil, fmt.Errorf("query is required") + } + client := newAPIClientWithKey(apiKeyFromContext(callCtx)) + r := client.SearchAPI.SearchKeywordGet(callCtx).Query(query) + if page := req.GetFloat("page", 0); page > 0 { + r = r.Page(float32(page)) + } + res, _, err := r.Execute() + if err != nil { + return apiToolError("SearchKeywordGet failed", err) + } + b, err := json.Marshal(res) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(string(b)), nil + } +} + func SearchTrendingHandler() server.ToolHandlerFunc { return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { client := newAPIClientWithKey(apiKeyFromContext(callCtx)) diff --git a/cmd/mcp/tools_settings.go b/cmd/mcp/tools_settings.go index fabf909..4d02d1d 100644 --- a/cmd/mcp/tools_settings.go +++ b/cmd/mcp/tools_settings.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" + api "seerr-cli/pkg/api" + "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) @@ -39,6 +41,29 @@ func registerSettingsTools(s *server.MCPServer) { ), SettingsJobsRunHandler(), ) + + s.AddTool( + mcp.NewTool("settings_jobs_cancel", + mcp.WithDescription("Cancel a running scheduled job"), + mcp.WithString("jobId", mcp.Required(), mcp.Description("Job ID")), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + ), + SettingsJobsCancelHandler(), + ) + + s.AddTool( + mcp.NewTool("settings_jobs_schedule", + mcp.WithDescription("Update the cron schedule for a scheduled job"), + mcp.WithString("jobId", mcp.Required(), mcp.Description("Job ID")), + mcp.WithString("schedule", mcp.Required(), mcp.Description("Cron expression for the new schedule")), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + ), + SettingsJobsScheduleHandler(), + ) } func SettingsAboutHandler() server.ToolHandlerFunc { @@ -71,6 +96,51 @@ func SettingsJobsListHandler() server.ToolHandlerFunc { } } +func SettingsJobsCancelHandler() server.ToolHandlerFunc { + return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + jobId, err := req.RequireString("jobId") + if err != nil { + return nil, err + } + client := newAPIClientWithKey(apiKeyFromContext(callCtx)) + res, _, err := client.SettingsAPI.SettingsJobsJobIdCancelPost(callCtx, jobId).Execute() + if err != nil { + return apiToolError("SettingsJobsJobIdCancelPost failed", err) + } + b, err := json.Marshal(res) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(string(b)), nil + } +} + +func SettingsJobsScheduleHandler() server.ToolHandlerFunc { + return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + jobId, err := req.RequireString("jobId") + if err != nil { + return nil, err + } + schedule, err := req.RequireString("schedule") + if err != nil { + return nil, err + } + body := api.SettingsJobsJobIdSchedulePostRequest{ + Schedule: &schedule, + } + client := newAPIClientWithKey(apiKeyFromContext(callCtx)) + res, _, err := client.SettingsAPI.SettingsJobsJobIdSchedulePost(callCtx, jobId).SettingsJobsJobIdSchedulePostRequest(body).Execute() + if err != nil { + return apiToolError("SettingsJobsJobIdSchedulePost failed", err) + } + b, err := json.Marshal(res) + if err != nil { + return nil, err + } + return mcp.NewToolResultText(string(b)), nil + } +} + func SettingsJobsRunHandler() server.ToolHandlerFunc { return func(callCtx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { jobId, err := req.RequireString("jobId") diff --git a/tests/mcp_discover_test.go b/tests/mcp_discover_test.go new file mode 100644 index 0000000..fff3192 --- /dev/null +++ b/tests/mcp_discover_test.go @@ -0,0 +1,130 @@ +package tests + +import ( + "net/http" + "testing" + + cmdmcp "seerr-cli/cmd/mcp" + + "github.com/stretchr/testify/assert" +) + +func TestSearchDiscoverMoviesGenreFilter(t *testing.T) { + var capturedQuery string + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + switch r.URL.Path { + case "/api/v1/discover/movies": + capturedQuery = r.URL.RawQuery + w.Write([]byte(`{"page":1,"totalPages":1,"totalResults":0,"results":[]}`)) + case "/api/v1/genres/movie": + w.Write([]byte(`[]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SearchDiscoverMoviesHandler() + callTool(t, handler, map[string]any{"genre": "18"}) + + assert.Contains(t, capturedQuery, "genre=18") +} + +func TestSearchDiscoverMoviesSortBy(t *testing.T) { + var capturedQuery string + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + switch r.URL.Path { + case "/api/v1/discover/movies": + capturedQuery = r.URL.RawQuery + w.Write([]byte(`{"page":1,"totalPages":1,"totalResults":0,"results":[]}`)) + case "/api/v1/genres/movie": + w.Write([]byte(`[]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SearchDiscoverMoviesHandler() + callTool(t, handler, map[string]any{"sortBy": "popularity.desc"}) + + assert.Contains(t, capturedQuery, "sortBy=popularity.desc") +} + +func TestSearchDiscoverMoviesDateRange(t *testing.T) { + var capturedQuery string + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + switch r.URL.Path { + case "/api/v1/discover/movies": + capturedQuery = r.URL.RawQuery + w.Write([]byte(`{"page":1,"totalPages":1,"totalResults":0,"results":[]}`)) + case "/api/v1/genres/movie": + w.Write([]byte(`[]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SearchDiscoverMoviesHandler() + callTool(t, handler, map[string]any{"primaryReleaseDateGte": "2023-01-01"}) + + assert.Contains(t, capturedQuery, "primaryReleaseDateGte=2023-01-01") +} + +func TestSearchDiscoverTVNetworkFilter(t *testing.T) { + var capturedQuery string + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + switch r.URL.Path { + case "/api/v1/discover/tv": + capturedQuery = r.URL.RawQuery + w.Write([]byte(`{"page":1,"totalPages":1,"totalResults":0,"results":[]}`)) + case "/api/v1/genres/tv": + w.Write([]byte(`[]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SearchDiscoverTVHandler() + callTool(t, handler, map[string]any{"network": "1"}) + + assert.Contains(t, capturedQuery, "network=1") +} + +func TestSearchDiscoverTVLanguageFilter(t *testing.T) { + var capturedQuery string + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + switch r.URL.Path { + case "/api/v1/discover/tv": + capturedQuery = r.URL.RawQuery + w.Write([]byte(`{"page":1,"totalPages":1,"totalResults":0,"results":[]}`)) + case "/api/v1/genres/tv": + w.Write([]byte(`[]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SearchDiscoverTVHandler() + callTool(t, handler, map[string]any{"language": "fr"}) + + assert.Contains(t, capturedQuery, "language=fr") +} diff --git a/tests/mcp_inventory_test.go b/tests/mcp_inventory_test.go new file mode 100644 index 0000000..835727e --- /dev/null +++ b/tests/mcp_inventory_test.go @@ -0,0 +1,21 @@ +package tests + +import ( + "testing" + + cmdmcp "seerr-cli/cmd/mcp" +) + +func TestMCPInventoryConstants(t *testing.T) { + // Verifies that the constants in inventory.go match the expected counts. + // When adding a new tool/resource/prompt, update inventory.go too. + if cmdmcp.MCPToolCount != 52 { + t.Errorf("MCPToolCount = %d, want 52", cmdmcp.MCPToolCount) + } + if cmdmcp.MCPResourceCount != 9 { + t.Errorf("MCPResourceCount = %d, want 9", cmdmcp.MCPResourceCount) + } + if cmdmcp.MCPPromptCount != 6 { + t.Errorf("MCPPromptCount = %d, want 6", cmdmcp.MCPPromptCount) + } +} diff --git a/tests/mcp_movies_ratings_combined_test.go b/tests/mcp_movies_ratings_combined_test.go new file mode 100644 index 0000000..de8cea8 --- /dev/null +++ b/tests/mcp_movies_ratings_combined_test.go @@ -0,0 +1,32 @@ +package tests + +import ( + "net/http" + "strings" + "testing" + + cmdmcp "seerr-cli/cmd/mcp" + + "github.com/stretchr/testify/assert" +) + +func TestMoviesRatingsCombinedHandler(t *testing.T) { + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.HasPrefix(r.URL.Path, "/api/v1/movie/") && strings.HasSuffix(r.URL.Path, "/ratingscombined") { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"rt":{"criticsScore":94,"criticsRating":"Certified Fresh"},"imdb":{"id":"tt0816692","rating":8.7}}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + defer cleanup() + _ = ts + + handler := cmdmcp.MoviesRatingsCombinedHandler() + result := callTool(t, handler, map[string]any{"movieId": float64(157336)}) + text := resultText(t, result) + + assert.Contains(t, text, "rt") + assert.Contains(t, text, "imdb") +} diff --git a/tests/mcp_prompts_test.go b/tests/mcp_prompts_test.go index 50710d9..70d7eee 100644 --- a/tests/mcp_prompts_test.go +++ b/tests/mcp_prompts_test.go @@ -135,3 +135,13 @@ func TestMCPAdminOverviewPrompt(t *testing.T) { assert.Contains(t, text, "issue_count") assert.Contains(t, text, "settings_jobs_list") } + +func TestReportIssuePromptNoMediaTypeArg(t *testing.T) { + result := callPrompt(t, cmdmcp.ReportIssuePromptHandler(), map[string]string{ + "media_title": "Inception", + }) + + text := promptText(t, result) + // issue_create has no mediaType parameter; the prompt must not instruct the AI to pass it. + assert.NotContains(t, text, "- mediaType: the type from search results") +} diff --git a/tests/mcp_search_company_test.go b/tests/mcp_search_company_test.go new file mode 100644 index 0000000..8edd57d --- /dev/null +++ b/tests/mcp_search_company_test.go @@ -0,0 +1,50 @@ +package tests + +import ( + "net/http" + "testing" + + cmdmcp "seerr-cli/cmd/mcp" + + "github.com/stretchr/testify/assert" +) + +func TestSearchCompanyHandler(t *testing.T) { + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/search/company" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"page":1,"totalPages":1,"totalResults":1,"results":[{"id":1,"name":"Warner Bros."}]}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SearchCompanyHandler() + result := callTool(t, handler, map[string]any{"query": "Warner"}) + text := resultText(t, result) + + assert.Contains(t, text, "Warner Bros.") +} + +func TestSearchKeywordHandler(t *testing.T) { + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/v1/search/keyword" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"page":1,"totalPages":1,"totalResults":1,"results":[{"id":878,"name":"science fiction"}]}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SearchKeywordHandler() + result := callTool(t, handler, map[string]any{"query": "sci-fi"}) + text := resultText(t, result) + + assert.Contains(t, text, "science fiction") +} diff --git a/tests/mcp_settings_jobs_test.go b/tests/mcp_settings_jobs_test.go new file mode 100644 index 0000000..b9250c5 --- /dev/null +++ b/tests/mcp_settings_jobs_test.go @@ -0,0 +1,51 @@ +package tests + +import ( + "net/http" + "strings" + "testing" + + cmdmcp "seerr-cli/cmd/mcp" + + "github.com/stretchr/testify/assert" +) + +func TestSettingsJobsCancelHandler(t *testing.T) { + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/cancel") { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"plex-sync","name":"Plex Sync","type":"process","running":false}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SettingsJobsCancelHandler() + result := callTool(t, handler, map[string]any{"jobId": "plex-sync"}) + text := resultText(t, result) + + assert.Contains(t, text, "plex-sync") +} + +func TestSettingsJobsScheduleHandler(t *testing.T) { + ts, cleanup := newMCPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/schedule") { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":"plex-sync","name":"Plex Sync","type":"process","running":false}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + defer cleanup() + _ = ts + + handler := cmdmcp.SettingsJobsScheduleHandler() + result := callTool(t, handler, map[string]any{"jobId": "plex-sync", "schedule": "0 */5 * * *"}) + text := resultText(t, result) + + assert.Contains(t, text, "plex-sync") +}