Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 33 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions cmd/mcp/inventory.go
Original file line number Diff line number Diff line change
@@ -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
)
3 changes: 1 addition & 2 deletions cmd/mcp/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
Expand Down Expand Up @@ -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
Expand Down
32 changes: 0 additions & 32 deletions cmd/mcp/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions cmd/mcp/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion cmd/mcp/tools_issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
),
Expand Down
30 changes: 30 additions & 0 deletions cmd/mcp/tools_movies.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
113 changes: 113 additions & 0 deletions cmd/mcp/tools_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mcp

import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
Expand Down Expand Up @@ -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(),
)
Expand All @@ -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(),
)
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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))
Expand Down
Loading
Loading