mcp-plex-server 0.1.3__tar.gz
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.
- mcp_plex_server-0.1.3/PKG-INFO +160 -0
- mcp_plex_server-0.1.3/README.md +140 -0
- mcp_plex_server-0.1.3/pyproject.toml +55 -0
- mcp_plex_server-0.1.3/src/plex_mcp/__init__.py +10 -0
- mcp_plex_server-0.1.3/src/plex_mcp/auth.py +45 -0
- mcp_plex_server-0.1.3/src/plex_mcp/client.py +226 -0
- mcp_plex_server-0.1.3/src/plex_mcp/formatting.py +212 -0
- mcp_plex_server-0.1.3/src/plex_mcp/py.typed +0 -0
- mcp_plex_server-0.1.3/src/plex_mcp/server.py +231 -0
- mcp_plex_server-0.1.3/src/plex_mcp/validation.py +14 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-plex-server
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: MCP server for Plex media discovery, search, and library management
|
|
5
|
+
Keywords: mcp,plex,media,model-context-protocol
|
|
6
|
+
Author: Matthew O'Brien
|
|
7
|
+
Author-email: Matthew O'Brien <obrien.mlotwis@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Multimedia :: Video
|
|
14
|
+
Requires-Dist: mcp>=1.27.0,<2
|
|
15
|
+
Requires-Dist: plexapi>=4.16.0
|
|
16
|
+
Requires-Python: >=3.14
|
|
17
|
+
Project-URL: Repository, https://github.com/obrien-matthew/mcp-plex
|
|
18
|
+
Project-URL: Issues, https://github.com/obrien-matthew/mcp-plex/issues
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# mcp-plex
|
|
22
|
+
|
|
23
|
+
MCP server for Plex Media Server, focused on media discovery, search, and library management. 13 granular tools designed for use with Claude and other LLM agents.
|
|
24
|
+
|
|
25
|
+
## Prerequisites
|
|
26
|
+
|
|
27
|
+
- Python 3.14+
|
|
28
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
29
|
+
- A running [Plex Media Server](https://www.plex.tv/)
|
|
30
|
+
- A Plex authentication token
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
### 1. Get Your Plex Token
|
|
35
|
+
|
|
36
|
+
The easiest way to find your Plex token:
|
|
37
|
+
|
|
38
|
+
1. Sign in to [Plex Web App](https://app.plex.tv)
|
|
39
|
+
2. Browse to any media item and click **Get Info** (or **...** > **Get Info**)
|
|
40
|
+
3. Click **View XML** in the bottom-left
|
|
41
|
+
4. In the URL bar, find the `X-Plex-Token=` parameter -- that's your token
|
|
42
|
+
|
|
43
|
+
Alternatively, check the [Plex support article](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) for other methods.
|
|
44
|
+
|
|
45
|
+
### 2. Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
cd mcp-plex
|
|
49
|
+
uv sync
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 3. Configure Environment Variables
|
|
53
|
+
|
|
54
|
+
Set these before running the server:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
export PLEX_SERVER_URL="http://your-plex-server:32400"
|
|
58
|
+
export PLEX_TOKEN="your_plex_token"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 4. Test the Connection
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
uv run mcp-plex
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The server connects to your Plex server on startup and verifies the token. If the connection fails, check that your server URL and token are correct.
|
|
68
|
+
|
|
69
|
+
## Claude Desktop / Claude Code Configuration
|
|
70
|
+
|
|
71
|
+
Add to your MCP server config. If installed from PyPI:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"mcpServers": {
|
|
76
|
+
"plex": {
|
|
77
|
+
"command": "uvx",
|
|
78
|
+
"args": ["mcp-plex"],
|
|
79
|
+
"env": {
|
|
80
|
+
"PLEX_SERVER_URL": "http://your-plex-server:32400",
|
|
81
|
+
"PLEX_TOKEN": "your_plex_token"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Or if running from a local clone:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"mcpServers": {
|
|
93
|
+
"plex": {
|
|
94
|
+
"command": "uv",
|
|
95
|
+
"args": ["--directory", "/path/to/mcp-plex", "run", "mcp-plex"],
|
|
96
|
+
"env": {
|
|
97
|
+
"PLEX_SERVER_URL": "http://your-plex-server:32400",
|
|
98
|
+
"PLEX_TOKEN": "your_plex_token"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Tools
|
|
106
|
+
|
|
107
|
+
### Discovery
|
|
108
|
+
|
|
109
|
+
| Tool | Parameters | Description |
|
|
110
|
+
|------|-----------|-------------|
|
|
111
|
+
| `search_media` | `query`, `media_type=""`, `limit=20` | Search across all libraries. Optional type filter: "movie", "show", "episode", "artist", "album", "track". |
|
|
112
|
+
| `get_recently_added` | `limit=20`, `library_name=""` | Recently added media, optionally filtered to a library. |
|
|
113
|
+
| `get_on_deck` | `limit=10` | Continue watching / next episode list. |
|
|
114
|
+
|
|
115
|
+
### Library Browsing
|
|
116
|
+
|
|
117
|
+
| Tool | Parameters | Description |
|
|
118
|
+
|------|-----------|-------------|
|
|
119
|
+
| `get_libraries` | (none) | List all library sections with types and item counts. |
|
|
120
|
+
| `get_library_contents` | `library_name`, `sort="titleSort"`, `limit=50` | Browse a library. Sort by title, date added, year, or rating. |
|
|
121
|
+
| `get_media_details` | `rating_key` | Full details for one item: summary, genres, cast, directors, ratings. |
|
|
122
|
+
| `get_library_stats` | `library_name=""` | Item counts and stats for one or all libraries. |
|
|
123
|
+
|
|
124
|
+
### Shows
|
|
125
|
+
|
|
126
|
+
| Tool | Parameters | Description |
|
|
127
|
+
|------|-----------|-------------|
|
|
128
|
+
| `get_seasons` | `rating_key` | List seasons for a TV show. |
|
|
129
|
+
| `get_episodes` | `rating_key`, `season_number=0` | List episodes (all or by season). |
|
|
130
|
+
|
|
131
|
+
### Collections & Playlists
|
|
132
|
+
|
|
133
|
+
| Tool | Parameters | Description |
|
|
134
|
+
|------|-----------|-------------|
|
|
135
|
+
| `get_collections` | `library_name` | List collections in a library. |
|
|
136
|
+
| `get_playlists` | (none) | List all playlists on the server. |
|
|
137
|
+
| `get_playlist_items` | `rating_key` | Get items in a playlist. |
|
|
138
|
+
|
|
139
|
+
### Management
|
|
140
|
+
|
|
141
|
+
| Tool | Parameters | Description |
|
|
142
|
+
|------|-----------|-------------|
|
|
143
|
+
| `scan_library` | `library_name` | Trigger a background library scan. |
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
uv run mcp-plex # Run the server
|
|
149
|
+
uv run ruff check src/ # Lint
|
|
150
|
+
uv run ruff format src/ # Format
|
|
151
|
+
uv run pyright src/ # Type check
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Pre-commit Hooks
|
|
155
|
+
|
|
156
|
+
This project uses [lefthook](https://github.com/evilmartians/lefthook) for pre-commit checks. Install with `brew install lefthook` (or see [other install methods](https://github.com/evilmartians/lefthook/blob/master/docs/install.md)), then:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
lefthook install
|
|
160
|
+
```
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# mcp-plex
|
|
2
|
+
|
|
3
|
+
MCP server for Plex Media Server, focused on media discovery, search, and library management. 13 granular tools designed for use with Claude and other LLM agents.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Python 3.14+
|
|
8
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
9
|
+
- A running [Plex Media Server](https://www.plex.tv/)
|
|
10
|
+
- A Plex authentication token
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
### 1. Get Your Plex Token
|
|
15
|
+
|
|
16
|
+
The easiest way to find your Plex token:
|
|
17
|
+
|
|
18
|
+
1. Sign in to [Plex Web App](https://app.plex.tv)
|
|
19
|
+
2. Browse to any media item and click **Get Info** (or **...** > **Get Info**)
|
|
20
|
+
3. Click **View XML** in the bottom-left
|
|
21
|
+
4. In the URL bar, find the `X-Plex-Token=` parameter -- that's your token
|
|
22
|
+
|
|
23
|
+
Alternatively, check the [Plex support article](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) for other methods.
|
|
24
|
+
|
|
25
|
+
### 2. Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cd mcp-plex
|
|
29
|
+
uv sync
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 3. Configure Environment Variables
|
|
33
|
+
|
|
34
|
+
Set these before running the server:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
export PLEX_SERVER_URL="http://your-plex-server:32400"
|
|
38
|
+
export PLEX_TOKEN="your_plex_token"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 4. Test the Connection
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv run mcp-plex
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The server connects to your Plex server on startup and verifies the token. If the connection fails, check that your server URL and token are correct.
|
|
48
|
+
|
|
49
|
+
## Claude Desktop / Claude Code Configuration
|
|
50
|
+
|
|
51
|
+
Add to your MCP server config. If installed from PyPI:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"plex": {
|
|
57
|
+
"command": "uvx",
|
|
58
|
+
"args": ["mcp-plex"],
|
|
59
|
+
"env": {
|
|
60
|
+
"PLEX_SERVER_URL": "http://your-plex-server:32400",
|
|
61
|
+
"PLEX_TOKEN": "your_plex_token"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Or if running from a local clone:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"plex": {
|
|
74
|
+
"command": "uv",
|
|
75
|
+
"args": ["--directory", "/path/to/mcp-plex", "run", "mcp-plex"],
|
|
76
|
+
"env": {
|
|
77
|
+
"PLEX_SERVER_URL": "http://your-plex-server:32400",
|
|
78
|
+
"PLEX_TOKEN": "your_plex_token"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Tools
|
|
86
|
+
|
|
87
|
+
### Discovery
|
|
88
|
+
|
|
89
|
+
| Tool | Parameters | Description |
|
|
90
|
+
|------|-----------|-------------|
|
|
91
|
+
| `search_media` | `query`, `media_type=""`, `limit=20` | Search across all libraries. Optional type filter: "movie", "show", "episode", "artist", "album", "track". |
|
|
92
|
+
| `get_recently_added` | `limit=20`, `library_name=""` | Recently added media, optionally filtered to a library. |
|
|
93
|
+
| `get_on_deck` | `limit=10` | Continue watching / next episode list. |
|
|
94
|
+
|
|
95
|
+
### Library Browsing
|
|
96
|
+
|
|
97
|
+
| Tool | Parameters | Description |
|
|
98
|
+
|------|-----------|-------------|
|
|
99
|
+
| `get_libraries` | (none) | List all library sections with types and item counts. |
|
|
100
|
+
| `get_library_contents` | `library_name`, `sort="titleSort"`, `limit=50` | Browse a library. Sort by title, date added, year, or rating. |
|
|
101
|
+
| `get_media_details` | `rating_key` | Full details for one item: summary, genres, cast, directors, ratings. |
|
|
102
|
+
| `get_library_stats` | `library_name=""` | Item counts and stats for one or all libraries. |
|
|
103
|
+
|
|
104
|
+
### Shows
|
|
105
|
+
|
|
106
|
+
| Tool | Parameters | Description |
|
|
107
|
+
|------|-----------|-------------|
|
|
108
|
+
| `get_seasons` | `rating_key` | List seasons for a TV show. |
|
|
109
|
+
| `get_episodes` | `rating_key`, `season_number=0` | List episodes (all or by season). |
|
|
110
|
+
|
|
111
|
+
### Collections & Playlists
|
|
112
|
+
|
|
113
|
+
| Tool | Parameters | Description |
|
|
114
|
+
|------|-----------|-------------|
|
|
115
|
+
| `get_collections` | `library_name` | List collections in a library. |
|
|
116
|
+
| `get_playlists` | (none) | List all playlists on the server. |
|
|
117
|
+
| `get_playlist_items` | `rating_key` | Get items in a playlist. |
|
|
118
|
+
|
|
119
|
+
### Management
|
|
120
|
+
|
|
121
|
+
| Tool | Parameters | Description |
|
|
122
|
+
|------|-----------|-------------|
|
|
123
|
+
| `scan_library` | `library_name` | Trigger a background library scan. |
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
uv run mcp-plex # Run the server
|
|
129
|
+
uv run ruff check src/ # Lint
|
|
130
|
+
uv run ruff format src/ # Format
|
|
131
|
+
uv run pyright src/ # Type check
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Pre-commit Hooks
|
|
135
|
+
|
|
136
|
+
This project uses [lefthook](https://github.com/evilmartians/lefthook) for pre-commit checks. Install with `brew install lefthook` (or see [other install methods](https://github.com/evilmartians/lefthook/blob/master/docs/install.md)), then:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
lefthook install
|
|
140
|
+
```
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcp-plex-server"
|
|
3
|
+
version = "0.1.3"
|
|
4
|
+
description = "MCP server for Plex media discovery, search, and library management"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Matthew O'Brien", email = "obrien.mlotwis@gmail.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
keywords = ["mcp", "plex", "media", "model-context-protocol"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Multimedia :: Video",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"mcp>=1.27.0,<2",
|
|
21
|
+
"plexapi>=4.16.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Repository = "https://github.com/obrien-matthew/mcp-plex"
|
|
26
|
+
Issues = "https://github.com/obrien-matthew/mcp-plex/issues"
|
|
27
|
+
|
|
28
|
+
[tool.uv.build-backend]
|
|
29
|
+
module-name = "plex_mcp"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["uv_build>=0.11.3,<0.12.0"]
|
|
33
|
+
build-backend = "uv_build"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"ruff>=0.11.0",
|
|
38
|
+
"pyright>=1.1.400",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
target-version = "py314"
|
|
43
|
+
line-length = 88
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
|
|
47
|
+
|
|
48
|
+
[tool.pyright]
|
|
49
|
+
pythonVersion = "3.14"
|
|
50
|
+
typeCheckingMode = "standard"
|
|
51
|
+
venvPath = "."
|
|
52
|
+
venv = ".venv"
|
|
53
|
+
|
|
54
|
+
[project.scripts]
|
|
55
|
+
mcp-plex = "plex_mcp:main"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Plex server connection and authenticated client singleton."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from plexapi.server import PlexServer
|
|
7
|
+
|
|
8
|
+
_server: PlexServer | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_plex_server() -> PlexServer:
|
|
12
|
+
"""Return a cached, authenticated Plex server connection.
|
|
13
|
+
|
|
14
|
+
Reads PLEX_SERVER_URL and PLEX_TOKEN from environment variables.
|
|
15
|
+
|
|
16
|
+
Raises RuntimeError if required env vars are missing or connection fails.
|
|
17
|
+
"""
|
|
18
|
+
global _server
|
|
19
|
+
if _server is not None:
|
|
20
|
+
return _server
|
|
21
|
+
|
|
22
|
+
server_url = os.environ.get("PLEX_SERVER_URL")
|
|
23
|
+
token = os.environ.get("PLEX_TOKEN")
|
|
24
|
+
|
|
25
|
+
if not server_url or not token:
|
|
26
|
+
raise RuntimeError(
|
|
27
|
+
"PLEX_SERVER_URL and PLEX_TOKEN environment variables "
|
|
28
|
+
"are required. See README.md for setup instructions."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Strip trailing slash for consistency
|
|
32
|
+
server_url = server_url.rstrip("/")
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
_server = PlexServer(server_url, token)
|
|
36
|
+
# Verify connection by fetching library sections
|
|
37
|
+
_server.library.sections()
|
|
38
|
+
except Exception as e:
|
|
39
|
+
_server = None
|
|
40
|
+
print(f"Plex connection failed: {e}", file=sys.stderr)
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
"Failed to connect to Plex server. Please check your server URL and token."
|
|
43
|
+
) from e
|
|
44
|
+
|
|
45
|
+
return _server
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Thin wrapper over plexapi with input validation and clean error handling."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import NoReturn
|
|
5
|
+
|
|
6
|
+
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
|
7
|
+
|
|
8
|
+
from .auth import get_plex_server
|
|
9
|
+
from .formatting import (
|
|
10
|
+
format_collection,
|
|
11
|
+
format_library,
|
|
12
|
+
format_media_item,
|
|
13
|
+
format_media_item_detailed,
|
|
14
|
+
format_playlist,
|
|
15
|
+
format_season,
|
|
16
|
+
)
|
|
17
|
+
from .validation import validate_limit, validate_rating_key
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PlexError(Exception):
|
|
21
|
+
"""User-facing Plex API error."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
24
|
+
self.message = message
|
|
25
|
+
self.status_code = status_code
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PlexClient:
|
|
30
|
+
"""Validated, formatted interface to the Plex API."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._plex = get_plex_server()
|
|
34
|
+
|
|
35
|
+
def _handle_error(self, e: Exception, action: str) -> NoReturn:
|
|
36
|
+
msg = f"Plex API error while {action}"
|
|
37
|
+
status_code: int | None = None
|
|
38
|
+
if isinstance(e, NotFound):
|
|
39
|
+
msg = f"Not found while {action}"
|
|
40
|
+
status_code = 404
|
|
41
|
+
elif isinstance(e, Unauthorized):
|
|
42
|
+
msg = f"Unauthorized while {action}"
|
|
43
|
+
status_code = 401
|
|
44
|
+
elif isinstance(e, BadRequest):
|
|
45
|
+
msg = f"Bad request while {action}"
|
|
46
|
+
status_code = 400
|
|
47
|
+
print(f"{msg}: {e}", file=sys.stderr)
|
|
48
|
+
raise PlexError(msg, status_code) from e
|
|
49
|
+
|
|
50
|
+
def _get_library(self, library_name: str):
|
|
51
|
+
"""Get a library section by name."""
|
|
52
|
+
try:
|
|
53
|
+
return self._plex.library.section(library_name)
|
|
54
|
+
except NotFound as e:
|
|
55
|
+
available = [s.title for s in self._plex.library.sections()]
|
|
56
|
+
raise PlexError(
|
|
57
|
+
f"Library '{library_name}' not found. "
|
|
58
|
+
f"Available libraries: {', '.join(available)}"
|
|
59
|
+
) from e
|
|
60
|
+
|
|
61
|
+
# -- Discovery --
|
|
62
|
+
|
|
63
|
+
def search_media(
|
|
64
|
+
self, query: str, media_type: str = "", limit: int = 20
|
|
65
|
+
) -> list[dict]:
|
|
66
|
+
limit = validate_limit(limit)
|
|
67
|
+
try:
|
|
68
|
+
if media_type:
|
|
69
|
+
results = self._plex.search(query, mediatype=media_type, limit=limit)
|
|
70
|
+
else:
|
|
71
|
+
results = self._plex.search(query, limit=limit)
|
|
72
|
+
return [format_media_item(item) for item in results[:limit]]
|
|
73
|
+
except (NotFound, BadRequest, Unauthorized) as e:
|
|
74
|
+
self._handle_error(e, "searching media")
|
|
75
|
+
|
|
76
|
+
def get_recently_added(self, limit: int = 20, library_name: str = "") -> list[dict]:
|
|
77
|
+
limit = validate_limit(limit)
|
|
78
|
+
try:
|
|
79
|
+
if library_name:
|
|
80
|
+
section = self._get_library(library_name)
|
|
81
|
+
items = section.recentlyAdded(maxresults=limit)
|
|
82
|
+
else:
|
|
83
|
+
items = self._plex.library.recentlyAdded(maxresults=limit)
|
|
84
|
+
return [format_media_item(item) for item in items]
|
|
85
|
+
except (NotFound, BadRequest, Unauthorized) as e:
|
|
86
|
+
self._handle_error(e, "fetching recently added")
|
|
87
|
+
|
|
88
|
+
def get_on_deck(self, limit: int = 10) -> list[dict]:
|
|
89
|
+
limit = validate_limit(limit)
|
|
90
|
+
try:
|
|
91
|
+
items = self._plex.library.onDeck(maxresults=limit)
|
|
92
|
+
return [format_media_item(item) for item in items]
|
|
93
|
+
except (NotFound, BadRequest, Unauthorized) as e:
|
|
94
|
+
self._handle_error(e, "fetching on deck")
|
|
95
|
+
|
|
96
|
+
# -- Library Browsing --
|
|
97
|
+
|
|
98
|
+
def get_libraries(self) -> list[dict]:
|
|
99
|
+
try:
|
|
100
|
+
sections = self._plex.library.sections()
|
|
101
|
+
return [format_library(s) for s in sections]
|
|
102
|
+
except (NotFound, BadRequest, Unauthorized) as e:
|
|
103
|
+
self._handle_error(e, "fetching libraries")
|
|
104
|
+
|
|
105
|
+
def get_library_contents(
|
|
106
|
+
self, library_name: str, sort: str = "titleSort", limit: int = 50
|
|
107
|
+
) -> list[dict]:
|
|
108
|
+
limit = validate_limit(limit, max_val=100)
|
|
109
|
+
try:
|
|
110
|
+
section = self._get_library(library_name)
|
|
111
|
+
items = section.all(sort=sort)[:limit]
|
|
112
|
+
return [format_media_item(item) for item in items]
|
|
113
|
+
except (NotFound, BadRequest, Unauthorized) as e:
|
|
114
|
+
self._handle_error(e, "fetching library contents")
|
|
115
|
+
|
|
116
|
+
def get_media_details(self, rating_key: str) -> dict:
|
|
117
|
+
rating_key = validate_rating_key(rating_key)
|
|
118
|
+
try:
|
|
119
|
+
item = self._fetch_item(rating_key, "fetching media details")
|
|
120
|
+
return format_media_item_detailed(item)
|
|
121
|
+
except (NotFound, BadRequest, Unauthorized) as e:
|
|
122
|
+
self._handle_error(e, "fetching media details")
|
|
123
|
+
|
|
124
|
+
def get_library_stats(self, library_name: str = "") -> dict | list[dict]:
|
|
125
|
+
try:
|
|
126
|
+
if library_name:
|
|
127
|
+
section = self._get_library(library_name)
|
|
128
|
+
return {
|
|
129
|
+
"title": section.title,
|
|
130
|
+
"type": section.type,
|
|
131
|
+
"total_items": section.totalSize,
|
|
132
|
+
"total_viewable": section.totalViewSize(),
|
|
133
|
+
}
|
|
134
|
+
sections = self._plex.library.sections()
|
|
135
|
+
return [
|
|
136
|
+
{
|
|
137
|
+
"title": s.title,
|
|
138
|
+
"type": s.type,
|
|
139
|
+
"total_items": s.totalSize,
|
|
140
|
+
}
|
|
141
|
+
for s in sections
|
|
142
|
+
]
|
|
143
|
+
except (NotFound, BadRequest, Unauthorized) as e:
|
|
144
|
+
self._handle_error(e, "fetching library stats")
|
|
145
|
+
|
|
146
|
+
# -- Shows --
|
|
147
|
+
|
|
148
|
+
def _fetch_item(self, rating_key: str, action: str) -> object:
|
|
149
|
+
"""Fetch an item by rating key, raising PlexError if not found."""
|
|
150
|
+
item = self._plex.fetchItem(int(rating_key))
|
|
151
|
+
if item is None:
|
|
152
|
+
raise PlexError(f"Item {rating_key} not found")
|
|
153
|
+
return item
|
|
154
|
+
|
|
155
|
+
def get_seasons(self, rating_key: str) -> list[dict]:
|
|
156
|
+
rating_key = validate_rating_key(rating_key)
|
|
157
|
+
try:
|
|
158
|
+
show = self._fetch_item(rating_key, "fetching seasons")
|
|
159
|
+
if not hasattr(show, "seasons"):
|
|
160
|
+
raise PlexError(
|
|
161
|
+
f"Item {rating_key} is not a TV show"
|
|
162
|
+
f" (type: {getattr(show, 'type', 'unknown')})"
|
|
163
|
+
)
|
|
164
|
+
return [format_season(s) for s in show.seasons()] # type: ignore[union-attr]
|
|
165
|
+
except (NotFound, BadRequest, Unauthorized) as e:
|
|
166
|
+
self._handle_error(e, "fetching seasons")
|
|
167
|
+
|
|
168
|
+
def get_episodes(self, rating_key: str, season_number: int = 0) -> list[dict]:
|
|
169
|
+
rating_key = validate_rating_key(rating_key)
|
|
170
|
+
try:
|
|
171
|
+
item = self._fetch_item(rating_key, "fetching episodes")
|
|
172
|
+
if hasattr(item, "episodes"):
|
|
173
|
+
# It's a show -- get all episodes or filter by season
|
|
174
|
+
if season_number > 0:
|
|
175
|
+
season = item.season(season_number) # type: ignore[union-attr]
|
|
176
|
+
episodes = season.episodes()
|
|
177
|
+
else:
|
|
178
|
+
episodes = item.episodes() # type: ignore[union-attr]
|
|
179
|
+
else:
|
|
180
|
+
raise PlexError(
|
|
181
|
+
f"Item {rating_key} is not a show or season"
|
|
182
|
+
f" (type: {getattr(item, 'type', 'unknown')})"
|
|
183
|
+
)
|
|
184
|
+
return [format_media_item(ep) for ep in episodes]
|
|
185
|
+
except (NotFound, BadRequest, Unauthorized) as e:
|
|
186
|
+
self._handle_error(e, "fetching episodes")
|
|
187
|
+
|
|
188
|
+
# -- Collections & Playlists --
|
|
189
|
+
|
|
190
|
+
def get_collections(self, library_name: str) -> list[dict]:
|
|
191
|
+
try:
|
|
192
|
+
section = self._get_library(library_name)
|
|
193
|
+
collections = section.collections()
|
|
194
|
+
return [format_collection(c) for c in collections]
|
|
195
|
+
except (NotFound, BadRequest, Unauthorized) as e:
|
|
196
|
+
self._handle_error(e, "fetching collections")
|
|
197
|
+
|
|
198
|
+
def get_playlists(self) -> list[dict]:
|
|
199
|
+
try:
|
|
200
|
+
playlists = self._plex.playlists()
|
|
201
|
+
return [format_playlist(p) for p in playlists]
|
|
202
|
+
except (NotFound, BadRequest, Unauthorized) as e:
|
|
203
|
+
self._handle_error(e, "fetching playlists")
|
|
204
|
+
|
|
205
|
+
def get_playlist_items(self, rating_key: str) -> list[dict]:
|
|
206
|
+
rating_key = validate_rating_key(rating_key)
|
|
207
|
+
try:
|
|
208
|
+
playlist = self._fetch_item(rating_key, "fetching playlist items")
|
|
209
|
+
if not hasattr(playlist, "items"):
|
|
210
|
+
raise PlexError(
|
|
211
|
+
f"Item {rating_key} is not a playlist"
|
|
212
|
+
f" (type: {getattr(playlist, 'type', 'unknown')})"
|
|
213
|
+
)
|
|
214
|
+
return [format_media_item(item) for item in playlist.items()] # type: ignore[union-attr]
|
|
215
|
+
except (NotFound, BadRequest, Unauthorized) as e:
|
|
216
|
+
self._handle_error(e, "fetching playlist items")
|
|
217
|
+
|
|
218
|
+
# -- Management --
|
|
219
|
+
|
|
220
|
+
def scan_library(self, library_name: str) -> str:
|
|
221
|
+
try:
|
|
222
|
+
section = self._get_library(library_name)
|
|
223
|
+
section.update()
|
|
224
|
+
return f"Library scan started for '{library_name}'."
|
|
225
|
+
except (NotFound, BadRequest, Unauthorized) as e:
|
|
226
|
+
self._handle_error(e, "scanning library")
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Response formatters that produce clean, LLM-friendly dicts.
|
|
2
|
+
|
|
3
|
+
Only includes fields useful for an LLM -- no images, file paths, or internal IDs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _safe_attr(obj: Any, attr: str, default: Any = None) -> Any:
|
|
11
|
+
"""Get an attribute safely, returning default if missing or None."""
|
|
12
|
+
return getattr(obj, attr, default) or default
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _format_datetime(dt: datetime | None) -> str | None:
|
|
16
|
+
"""Format a datetime as an ISO string, or return None."""
|
|
17
|
+
if dt is None:
|
|
18
|
+
return None
|
|
19
|
+
return dt.isoformat()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _format_duration(ms: int | None) -> str | None:
|
|
23
|
+
"""Format milliseconds as a human-readable duration."""
|
|
24
|
+
if ms is None:
|
|
25
|
+
return None
|
|
26
|
+
total_seconds = ms // 1000
|
|
27
|
+
hours, remainder = divmod(total_seconds, 3600)
|
|
28
|
+
minutes, seconds = divmod(remainder, 60)
|
|
29
|
+
if hours > 0:
|
|
30
|
+
return f"{hours}h {minutes}m"
|
|
31
|
+
return f"{minutes}m {seconds}s"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def format_media_item(item: Any) -> dict:
|
|
35
|
+
"""Format any Plex media item into an LLM-friendly dict.
|
|
36
|
+
|
|
37
|
+
Routes to the appropriate formatter based on item type.
|
|
38
|
+
"""
|
|
39
|
+
item_type = str(_safe_attr(item, "type", "unknown"))
|
|
40
|
+
formatters = {
|
|
41
|
+
"movie": _format_movie,
|
|
42
|
+
"show": _format_show,
|
|
43
|
+
"season": _format_season_item,
|
|
44
|
+
"episode": _format_episode,
|
|
45
|
+
"artist": _format_artist,
|
|
46
|
+
"album": _format_album,
|
|
47
|
+
"track": _format_track,
|
|
48
|
+
}
|
|
49
|
+
formatter = formatters.get(item_type, _format_generic)
|
|
50
|
+
return formatter(item)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def format_media_item_detailed(item: Any) -> dict:
|
|
54
|
+
"""Format a media item with full detail (for get_media_details)."""
|
|
55
|
+
result = format_media_item(item)
|
|
56
|
+
# Add extended fields
|
|
57
|
+
summary = _safe_attr(item, "summary")
|
|
58
|
+
if summary:
|
|
59
|
+
result["summary"] = str(summary)
|
|
60
|
+
|
|
61
|
+
genres = _safe_attr(item, "genres", [])
|
|
62
|
+
if genres:
|
|
63
|
+
result["genres"] = [str(g.tag) for g in genres]
|
|
64
|
+
|
|
65
|
+
roles = _safe_attr(item, "roles", [])
|
|
66
|
+
if roles:
|
|
67
|
+
result["cast"] = [str(r.tag) for r in roles[:10]]
|
|
68
|
+
|
|
69
|
+
directors = _safe_attr(item, "directors", [])
|
|
70
|
+
if directors:
|
|
71
|
+
result["directors"] = [str(d.tag) for d in directors]
|
|
72
|
+
|
|
73
|
+
writers = _safe_attr(item, "writers", [])
|
|
74
|
+
if writers:
|
|
75
|
+
result["writers"] = [str(w.tag) for w in writers]
|
|
76
|
+
|
|
77
|
+
studio = _safe_attr(item, "studio")
|
|
78
|
+
if studio:
|
|
79
|
+
result["studio"] = str(studio)
|
|
80
|
+
|
|
81
|
+
content_rating = _safe_attr(item, "contentRating")
|
|
82
|
+
if content_rating:
|
|
83
|
+
result["content_rating"] = str(content_rating)
|
|
84
|
+
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _format_movie(item: Any) -> dict:
|
|
89
|
+
return {
|
|
90
|
+
"type": "movie",
|
|
91
|
+
"title": str(_safe_attr(item, "title", "")),
|
|
92
|
+
"year": _safe_attr(item, "year"),
|
|
93
|
+
"rating": _safe_attr(item, "rating"),
|
|
94
|
+
"audience_rating": _safe_attr(item, "audienceRating"),
|
|
95
|
+
"duration": _format_duration(_safe_attr(item, "duration")),
|
|
96
|
+
"rating_key": str(_safe_attr(item, "ratingKey", "")),
|
|
97
|
+
"added_at": _format_datetime(_safe_attr(item, "addedAt")),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _format_show(item: Any) -> dict:
|
|
102
|
+
return {
|
|
103
|
+
"type": "show",
|
|
104
|
+
"title": str(_safe_attr(item, "title", "")),
|
|
105
|
+
"year": _safe_attr(item, "year"),
|
|
106
|
+
"rating": _safe_attr(item, "rating"),
|
|
107
|
+
"audience_rating": _safe_attr(item, "audienceRating"),
|
|
108
|
+
"season_count": _safe_attr(item, "childCount"),
|
|
109
|
+
"episode_count": _safe_attr(item, "leafCount"),
|
|
110
|
+
"rating_key": str(_safe_attr(item, "ratingKey", "")),
|
|
111
|
+
"added_at": _format_datetime(_safe_attr(item, "addedAt")),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _format_season_item(item: Any) -> dict:
|
|
116
|
+
return {
|
|
117
|
+
"type": "season",
|
|
118
|
+
"title": str(_safe_attr(item, "title", "")),
|
|
119
|
+
"show_title": str(_safe_attr(item, "parentTitle", "")),
|
|
120
|
+
"season_number": _safe_attr(item, "index"),
|
|
121
|
+
"episode_count": _safe_attr(item, "leafCount"),
|
|
122
|
+
"rating_key": str(_safe_attr(item, "ratingKey", "")),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _format_episode(item: Any) -> dict:
|
|
127
|
+
return {
|
|
128
|
+
"type": "episode",
|
|
129
|
+
"title": str(_safe_attr(item, "title", "")),
|
|
130
|
+
"show_title": str(_safe_attr(item, "grandparentTitle", "")),
|
|
131
|
+
"season_number": _safe_attr(item, "parentIndex"),
|
|
132
|
+
"episode_number": _safe_attr(item, "index"),
|
|
133
|
+
"duration": _format_duration(_safe_attr(item, "duration")),
|
|
134
|
+
"rating_key": str(_safe_attr(item, "ratingKey", "")),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _format_artist(item: Any) -> dict:
|
|
139
|
+
return {
|
|
140
|
+
"type": "artist",
|
|
141
|
+
"title": str(_safe_attr(item, "title", "")),
|
|
142
|
+
"rating_key": str(_safe_attr(item, "ratingKey", "")),
|
|
143
|
+
"added_at": _format_datetime(_safe_attr(item, "addedAt")),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _format_album(item: Any) -> dict:
|
|
148
|
+
return {
|
|
149
|
+
"type": "album",
|
|
150
|
+
"title": str(_safe_attr(item, "title", "")),
|
|
151
|
+
"artist": str(_safe_attr(item, "parentTitle", "")),
|
|
152
|
+
"year": _safe_attr(item, "year"),
|
|
153
|
+
"track_count": _safe_attr(item, "leafCount"),
|
|
154
|
+
"rating_key": str(_safe_attr(item, "ratingKey", "")),
|
|
155
|
+
"added_at": _format_datetime(_safe_attr(item, "addedAt")),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _format_track(item: Any) -> dict:
|
|
160
|
+
return {
|
|
161
|
+
"type": "track",
|
|
162
|
+
"title": str(_safe_attr(item, "title", "")),
|
|
163
|
+
"artist": str(_safe_attr(item, "grandparentTitle", "")),
|
|
164
|
+
"album": str(_safe_attr(item, "parentTitle", "")),
|
|
165
|
+
"track_number": _safe_attr(item, "index"),
|
|
166
|
+
"duration": _format_duration(_safe_attr(item, "duration")),
|
|
167
|
+
"rating_key": str(_safe_attr(item, "ratingKey", "")),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _format_generic(item: Any) -> dict:
|
|
172
|
+
return {
|
|
173
|
+
"type": str(_safe_attr(item, "type", "unknown")),
|
|
174
|
+
"title": str(_safe_attr(item, "title", "")),
|
|
175
|
+
"rating_key": str(_safe_attr(item, "ratingKey", "")),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def format_library(section: Any) -> dict:
|
|
180
|
+
"""Format a library section for listing."""
|
|
181
|
+
return {
|
|
182
|
+
"title": str(_safe_attr(section, "title", "")),
|
|
183
|
+
"type": str(_safe_attr(section, "type", "")),
|
|
184
|
+
"key": str(_safe_attr(section, "key", "")),
|
|
185
|
+
"total_items": _safe_attr(section, "totalSize"),
|
|
186
|
+
"updated_at": _format_datetime(_safe_attr(section, "updatedAt")),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def format_season(season: Any) -> dict:
|
|
191
|
+
"""Format a season for listing."""
|
|
192
|
+
return _format_season_item(season)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def format_playlist(playlist: Any) -> dict:
|
|
196
|
+
"""Format a playlist for listing."""
|
|
197
|
+
return {
|
|
198
|
+
"title": str(_safe_attr(playlist, "title", "")),
|
|
199
|
+
"playlist_type": str(_safe_attr(playlist, "playlistType", "")),
|
|
200
|
+
"item_count": _safe_attr(playlist, "leafCount"),
|
|
201
|
+
"duration": _format_duration(_safe_attr(playlist, "duration")),
|
|
202
|
+
"rating_key": str(_safe_attr(playlist, "ratingKey", "")),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def format_collection(collection: Any) -> dict:
|
|
207
|
+
"""Format a collection for listing."""
|
|
208
|
+
return {
|
|
209
|
+
"title": str(_safe_attr(collection, "title", "")),
|
|
210
|
+
"item_count": _safe_attr(collection, "childCount"),
|
|
211
|
+
"rating_key": str(_safe_attr(collection, "ratingKey", "")),
|
|
212
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""MCP server with Plex tools for media discovery, search, and library management."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
|
|
7
|
+
from .client import PlexClient, PlexError
|
|
8
|
+
|
|
9
|
+
mcp = FastMCP("mcp-plex")
|
|
10
|
+
|
|
11
|
+
_client: PlexClient | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_client() -> PlexClient:
|
|
15
|
+
global _client
|
|
16
|
+
if _client is None:
|
|
17
|
+
_client = PlexClient()
|
|
18
|
+
return _client
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Discovery
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@mcp.tool()
|
|
27
|
+
def search_media(query: str, media_type: str = "", limit: int = 20) -> str:
|
|
28
|
+
"""Search for media across all Plex libraries.
|
|
29
|
+
|
|
30
|
+
Searches movies, shows, music, and other media by title.
|
|
31
|
+
|
|
32
|
+
Optional media_type filter: "movie", "show", "episode", "artist",
|
|
33
|
+
"album", "track".
|
|
34
|
+
|
|
35
|
+
Returns titles, years, ratings, and rating keys for further lookup.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
results = _get_client().search_media(query, media_type, limit)
|
|
39
|
+
return json.dumps(results, indent=2)
|
|
40
|
+
except (PlexError, ValueError) as e:
|
|
41
|
+
return f"Error: {e}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@mcp.tool()
|
|
45
|
+
def get_recently_added(limit: int = 20, library_name: str = "") -> str:
|
|
46
|
+
"""Get recently added media from Plex.
|
|
47
|
+
|
|
48
|
+
Returns the most recently added items across all libraries, or
|
|
49
|
+
filtered to a specific library by name (e.g. "Movies", "TV Shows").
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
results = _get_client().get_recently_added(limit, library_name)
|
|
53
|
+
return json.dumps(results, indent=2)
|
|
54
|
+
except (PlexError, ValueError) as e:
|
|
55
|
+
return f"Error: {e}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@mcp.tool()
|
|
59
|
+
def get_on_deck(limit: int = 10) -> str:
|
|
60
|
+
"""Get the "On Deck" continue-watching list from Plex.
|
|
61
|
+
|
|
62
|
+
Returns items that are partially watched or next episodes in a series.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
results = _get_client().get_on_deck(limit)
|
|
66
|
+
return json.dumps(results, indent=2)
|
|
67
|
+
except (PlexError, ValueError) as e:
|
|
68
|
+
return f"Error: {e}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Library Browsing
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@mcp.tool()
|
|
77
|
+
def get_libraries() -> str:
|
|
78
|
+
"""List all library sections on the Plex server.
|
|
79
|
+
|
|
80
|
+
Returns library names, types (movie, show, artist), item counts, and
|
|
81
|
+
last updated timestamps.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
results = _get_client().get_libraries()
|
|
85
|
+
return json.dumps(results, indent=2)
|
|
86
|
+
except (PlexError, ValueError) as e:
|
|
87
|
+
return f"Error: {e}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@mcp.tool()
|
|
91
|
+
def get_library_contents(
|
|
92
|
+
library_name: str, sort: str = "titleSort", limit: int = 50
|
|
93
|
+
) -> str:
|
|
94
|
+
"""Browse the contents of a specific Plex library.
|
|
95
|
+
|
|
96
|
+
library_name must match exactly (e.g. "Movies", "TV Shows").
|
|
97
|
+
Use get_libraries to see available names.
|
|
98
|
+
|
|
99
|
+
Sort options: "titleSort" (default), "addedAt:desc", "year:desc",
|
|
100
|
+
"rating:desc", "audienceRating:desc".
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
results = _get_client().get_library_contents(library_name, sort, limit)
|
|
104
|
+
return json.dumps(results, indent=2)
|
|
105
|
+
except (PlexError, ValueError) as e:
|
|
106
|
+
return f"Error: {e}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@mcp.tool()
|
|
110
|
+
def get_media_details(rating_key: str) -> str:
|
|
111
|
+
"""Get detailed information about a specific media item.
|
|
112
|
+
|
|
113
|
+
Use the rating_key from search or browse results. Returns full
|
|
114
|
+
details including summary, genres, cast, directors, and ratings.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
result = _get_client().get_media_details(rating_key)
|
|
118
|
+
return json.dumps(result, indent=2)
|
|
119
|
+
except (PlexError, ValueError) as e:
|
|
120
|
+
return f"Error: {e}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@mcp.tool()
|
|
124
|
+
def get_library_stats(library_name: str = "") -> str:
|
|
125
|
+
"""Get statistics for Plex libraries.
|
|
126
|
+
|
|
127
|
+
Without library_name: returns item counts for all libraries.
|
|
128
|
+
With library_name: returns detailed stats for that library.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
result = _get_client().get_library_stats(library_name)
|
|
132
|
+
return json.dumps(result, indent=2)
|
|
133
|
+
except (PlexError, ValueError) as e:
|
|
134
|
+
return f"Error: {e}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Shows
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@mcp.tool()
|
|
143
|
+
def get_seasons(rating_key: str) -> str:
|
|
144
|
+
"""Get the seasons of a TV show.
|
|
145
|
+
|
|
146
|
+
Provide the rating_key of a show (from search or browse). Returns
|
|
147
|
+
season numbers, episode counts, and rating keys.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
results = _get_client().get_seasons(rating_key)
|
|
151
|
+
return json.dumps(results, indent=2)
|
|
152
|
+
except (PlexError, ValueError) as e:
|
|
153
|
+
return f"Error: {e}"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@mcp.tool()
|
|
157
|
+
def get_episodes(rating_key: str, season_number: int = 0) -> str:
|
|
158
|
+
"""Get episodes for a TV show or season.
|
|
159
|
+
|
|
160
|
+
Provide the rating_key of a show. Optionally filter by season_number
|
|
161
|
+
(0 = all seasons). Returns episode titles, numbers, and durations.
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
results = _get_client().get_episodes(rating_key, season_number)
|
|
165
|
+
return json.dumps(results, indent=2)
|
|
166
|
+
except (PlexError, ValueError) as e:
|
|
167
|
+
return f"Error: {e}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Collections & Playlists
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@mcp.tool()
|
|
176
|
+
def get_collections(library_name: str) -> str:
|
|
177
|
+
"""List collections in a Plex library.
|
|
178
|
+
|
|
179
|
+
Returns collection names, item counts, and rating keys.
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
results = _get_client().get_collections(library_name)
|
|
183
|
+
return json.dumps(results, indent=2)
|
|
184
|
+
except (PlexError, ValueError) as e:
|
|
185
|
+
return f"Error: {e}"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@mcp.tool()
|
|
189
|
+
def get_playlists() -> str:
|
|
190
|
+
"""List all playlists on the Plex server.
|
|
191
|
+
|
|
192
|
+
Returns playlist names, types, item counts, and durations.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
results = _get_client().get_playlists()
|
|
196
|
+
return json.dumps(results, indent=2)
|
|
197
|
+
except (PlexError, ValueError) as e:
|
|
198
|
+
return f"Error: {e}"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@mcp.tool()
|
|
202
|
+
def get_playlist_items(rating_key: str) -> str:
|
|
203
|
+
"""Get the items in a Plex playlist.
|
|
204
|
+
|
|
205
|
+
Provide the rating_key of a playlist. Returns the media items
|
|
206
|
+
contained in the playlist.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
results = _get_client().get_playlist_items(rating_key)
|
|
210
|
+
return json.dumps(results, indent=2)
|
|
211
|
+
except (PlexError, ValueError) as e:
|
|
212
|
+
return f"Error: {e}"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Management
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@mcp.tool()
|
|
221
|
+
def scan_library(library_name: str) -> str:
|
|
222
|
+
"""Trigger a library scan on the Plex server.
|
|
223
|
+
|
|
224
|
+
This refreshes the library to pick up new or changed media files.
|
|
225
|
+
The scan runs in the background on the server.
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
result = _get_client().scan_library(library_name)
|
|
229
|
+
return result
|
|
230
|
+
except (PlexError, ValueError) as e:
|
|
231
|
+
return f"Error: {e}"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Input validation helpers for Plex rating keys and pagination parameters."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def validate_rating_key(value: str) -> str:
|
|
5
|
+
"""Validate a Plex rating key (numeric string)."""
|
|
6
|
+
value = value.strip()
|
|
7
|
+
if not value.isdigit():
|
|
8
|
+
raise ValueError(f"Invalid rating key: '{value}'. Expected a numeric string.")
|
|
9
|
+
return value
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_limit(value: int, max_val: int = 50) -> int:
|
|
13
|
+
"""Clamp a limit parameter to the range [1, max_val]."""
|
|
14
|
+
return max(1, min(value, max_val))
|