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.
@@ -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,10 @@
1
+ """MCP server for Plex media discovery, search, and library management."""
2
+
3
+ from .server import mcp
4
+
5
+
6
+ def main():
7
+ mcp.run(transport="stdio")
8
+
9
+
10
+ __all__ = ["main", "mcp"]
@@ -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))