Initial commit: Plex MCP server with 6 tools and API passthrough
Some checks failed
Build and Push Docker Image / build (push) Failing after 0s
Some checks failed
Build and Push Docker Image / build (push) Failing after 0s
- get_libraries: List all library sections - search_library: Search for media by title - get_metadata: Get detailed item info by rating key - get_recently_added: Get recently added content - refresh_library: Trigger library scan - plex_api_call: Raw API passthrough for any endpoint - search_api_docs: Search OpenAPI spec for endpoint documentation Includes Docker support and Gitea Actions workflow for container builds.
This commit is contained in:
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Plex MCP Server Configuration
|
||||||
|
|
||||||
|
# Plex Media Server URL (include port, typically 32400)
|
||||||
|
PLEX_URL=http://localhost:32400
|
||||||
|
|
||||||
|
# Plex authentication token
|
||||||
|
# Obtain from: https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/
|
||||||
|
PLEX_TOKEN=your-plex-token-here
|
||||||
|
|
||||||
|
# Unique client identifier for this MCP instance
|
||||||
|
PLEX_CLIENT_ID=plex-mcp-server
|
||||||
|
|
||||||
|
# Server port (optional, defaults to 8000)
|
||||||
|
PORT=8000
|
||||||
55
.gitea/workflows/docker-build.yml
Normal file
55
.gitea/workflows/docker-build.yml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: gitea.ext.ben.io
|
||||||
|
IMAGE_NAME: b3nw/plex-mcp
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=sha,prefix=
|
||||||
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY server.py ./
|
||||||
|
COPY openapi.json ./
|
||||||
|
COPY docs/ ./docs/
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd --create-home --shell /bin/bash appuser && \
|
||||||
|
chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"
|
||||||
|
|
||||||
|
CMD ["python", "server.py"]
|
||||||
151
README.md
Normal file
151
README.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Plex MCP Server
|
||||||
|
|
||||||
|
A lightweight MCP (Model Context Protocol) server for interacting with Plex Media Server.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **5 Specific Tools** for common operations:
|
||||||
|
- `get_libraries` - List all library sections
|
||||||
|
- `search_library` - Search for media by title
|
||||||
|
- `get_metadata` - Get detailed item information
|
||||||
|
- `get_recently_added` - Get recently added content
|
||||||
|
- `refresh_library` - Trigger library scan for new content
|
||||||
|
|
||||||
|
- **API Pass-through** (`plex_api_call`) for accessing any of the 190+ Plex API endpoints
|
||||||
|
|
||||||
|
- **Documentation Resources**:
|
||||||
|
- `plex://api-reference` - Curated API quick reference
|
||||||
|
- `search_api_docs` - Search the full OpenAPI specification
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.11+
|
||||||
|
- Plex Media Server with API access
|
||||||
|
- Plex authentication token ([how to find your token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/))
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
cd plex-mcp-custom
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# Configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your Plex URL and token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Edit `.env` with your Plex settings:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PLEX_URL=http://your-plex-server:32400
|
||||||
|
PLEX_TOKEN=your-plex-token
|
||||||
|
PLEX_CLIENT_ID=plex-mcp-server
|
||||||
|
PORT=8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Direct
|
||||||
|
python server.py
|
||||||
|
|
||||||
|
# Or with uvicorn
|
||||||
|
uvicorn server:app --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and run
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Or build manually
|
||||||
|
docker build -t plex-mcp .
|
||||||
|
docker run -d --env-file .env -p 8000:8000 plex-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### MCP Client Configuration
|
||||||
|
|
||||||
|
Add to your MCP client configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"plex": {
|
||||||
|
"url": "http://localhost:8000/mcp/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Examples
|
||||||
|
|
||||||
|
**List libraries:**
|
||||||
|
```
|
||||||
|
get_libraries()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Search for a movie:**
|
||||||
|
```
|
||||||
|
search_library(query="Inception", limit=5)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Get item details:**
|
||||||
|
```
|
||||||
|
get_metadata(rating_key="12345", include_children=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trigger library scan:**
|
||||||
|
```
|
||||||
|
refresh_library(section_id=1)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Raw API call (mark as watched):**
|
||||||
|
```
|
||||||
|
plex_api_call(
|
||||||
|
endpoint="/:scrobble",
|
||||||
|
params='{"key": "12345", "identifier": "com.plexapp.plugins.library"}'
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Finding Endpoints
|
||||||
|
|
||||||
|
Use `search_api_docs` to find endpoints for specific operations:
|
||||||
|
|
||||||
|
```
|
||||||
|
search_api_docs(query="playlist")
|
||||||
|
search_api_docs(query="transcode")
|
||||||
|
search_api_docs(query="rating")
|
||||||
|
```
|
||||||
|
|
||||||
|
Or access the curated reference via the `plex://api-reference` resource.
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
See [docs/api_reference.md](docs/api_reference.md) for a curated guide to common endpoints.
|
||||||
|
|
||||||
|
The full OpenAPI specification is available in `openapi.json` and can be searched via the `search_api_docs` tool.
|
||||||
|
|
||||||
|
## Health Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
{"status": "ok", "plex_connected": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
2048
api_auth.txt
Normal file
2048
api_auth.txt
Normal file
File diff suppressed because it is too large
Load Diff
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
plex-mcp:
|
||||||
|
build: .
|
||||||
|
container_name: plex-mcp
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8000}:8000"
|
||||||
|
environment:
|
||||||
|
- PLEX_URL=${PLEX_URL}
|
||||||
|
- PLEX_TOKEN=${PLEX_TOKEN}
|
||||||
|
- PLEX_CLIENT_ID=${PLEX_CLIENT_ID:-plex-mcp-server}
|
||||||
|
- PORT=8000
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
366
docs/api_reference.md
Normal file
366
docs/api_reference.md
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
# Plex API Quick Reference
|
||||||
|
|
||||||
|
This document provides a curated reference for the most commonly used Plex Media Server API endpoints. Use this with the `plex_api_call` tool for operations not covered by specific tools.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
All requests require these headers (handled automatically by this MCP server):
|
||||||
|
- `X-Plex-Token`: Authentication token
|
||||||
|
- `X-Plex-Client-Identifier`: Unique client ID
|
||||||
|
- `Accept: application/json`: Request JSON responses
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
Many endpoints support pagination via headers:
|
||||||
|
- `X-Plex-Container-Start`: Starting offset (0-based)
|
||||||
|
- `X-Plex-Container-Size`: Number of items to return
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Libraries
|
||||||
|
|
||||||
|
### List All Libraries
|
||||||
|
```
|
||||||
|
GET /library/sections/all
|
||||||
|
```
|
||||||
|
Returns all library sections (Movies, TV Shows, Music, etc.)
|
||||||
|
|
||||||
|
### Get Library Details
|
||||||
|
```
|
||||||
|
GET /library/sections/{sectionId}
|
||||||
|
```
|
||||||
|
Get details about a specific library section.
|
||||||
|
|
||||||
|
### List Library Contents
|
||||||
|
```
|
||||||
|
GET /library/sections/{sectionId}/all
|
||||||
|
```
|
||||||
|
List all items in a library. Supports filtering and sorting.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- `type`: Filter by type (1=movie, 2=show, 4=episode, 8=artist, 9=album, 10=track)
|
||||||
|
- `sort`: Sort field (e.g., `titleSort`, `addedAt:desc`, `year`)
|
||||||
|
- `X-Plex-Container-Start`: Pagination start
|
||||||
|
- `X-Plex-Container-Size`: Page size
|
||||||
|
|
||||||
|
### Refresh Library (Scan)
|
||||||
|
```
|
||||||
|
POST /library/sections/{sectionId}/refresh
|
||||||
|
```
|
||||||
|
Trigger a library scan for new content.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- `force=1`: Force metadata refresh even if files unchanged
|
||||||
|
- `path`: Restrict scan to specific path
|
||||||
|
|
||||||
|
### Cancel Library Refresh
|
||||||
|
```
|
||||||
|
DELETE /library/sections/{sectionId}/refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
### Get Item Metadata
|
||||||
|
```
|
||||||
|
GET /library/metadata/{ratingKey}
|
||||||
|
```
|
||||||
|
Get detailed metadata for a specific item.
|
||||||
|
|
||||||
|
### Get Children
|
||||||
|
```
|
||||||
|
GET /library/metadata/{ratingKey}/children
|
||||||
|
```
|
||||||
|
Get children of an item (e.g., seasons of a show, episodes of a season).
|
||||||
|
|
||||||
|
### Get All Leaves
|
||||||
|
```
|
||||||
|
GET /library/metadata/{ratingKey}/allLeaves
|
||||||
|
```
|
||||||
|
Get all leaf items (e.g., all episodes of a show).
|
||||||
|
|
||||||
|
### Refresh Item Metadata
|
||||||
|
```
|
||||||
|
POST /library/metadata/{ratingKey}/refresh
|
||||||
|
```
|
||||||
|
Refresh metadata for a specific item.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
### Global Search
|
||||||
|
```
|
||||||
|
GET /hubs/search?query={searchTerm}
|
||||||
|
```
|
||||||
|
Search across all libraries.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- `query`: Search term (required)
|
||||||
|
- `limit`: Max results per type
|
||||||
|
- `sectionId`: Restrict to specific library
|
||||||
|
|
||||||
|
### Voice Search
|
||||||
|
```
|
||||||
|
GET /hubs/search/voice?query={searchTerm}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hubs (Discovery)
|
||||||
|
|
||||||
|
### Get All Hubs
|
||||||
|
```
|
||||||
|
GET /hubs
|
||||||
|
```
|
||||||
|
Get discovery hubs (Continue Watching, Recently Added, etc.)
|
||||||
|
|
||||||
|
### Continue Watching
|
||||||
|
```
|
||||||
|
GET /hubs/continueWatching
|
||||||
|
```
|
||||||
|
|
||||||
|
### Library Hubs
|
||||||
|
```
|
||||||
|
GET /hubs/sections/{sectionId}
|
||||||
|
```
|
||||||
|
Get hubs for a specific library.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Playback Tracking
|
||||||
|
|
||||||
|
### Mark as Played (Scrobble)
|
||||||
|
```
|
||||||
|
GET /:scrobble?key={ratingKey}&identifier=com.plexapp.plugins.library
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mark as Unplayed
|
||||||
|
```
|
||||||
|
GET /:unscrobble?key={ratingKey}&identifier=com.plexapp.plugins.library
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Timeline
|
||||||
|
```
|
||||||
|
GET /:timeline
|
||||||
|
```
|
||||||
|
Report playback progress.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- `ratingKey`: Item key
|
||||||
|
- `key`: Full key path
|
||||||
|
- `state`: playing, paused, stopped
|
||||||
|
- `time`: Current position in ms
|
||||||
|
- `duration`: Total duration in ms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ratings
|
||||||
|
|
||||||
|
### Rate an Item
|
||||||
|
```
|
||||||
|
PUT /:rate?key={ratingKey}&identifier=com.plexapp.plugins.library&rating={value}
|
||||||
|
```
|
||||||
|
Rating value: 0-10 (0 removes rating)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Playlists
|
||||||
|
|
||||||
|
### List Playlists
|
||||||
|
```
|
||||||
|
GET /playlists
|
||||||
|
```
|
||||||
|
Query params:
|
||||||
|
- `playlistType`: audio, video, photo
|
||||||
|
- `smart`: 0 or 1
|
||||||
|
|
||||||
|
### Get Playlist
|
||||||
|
```
|
||||||
|
GET /playlists/{playlistId}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Playlist Items
|
||||||
|
```
|
||||||
|
GET /playlists/{playlistId}/items
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Playlist
|
||||||
|
```
|
||||||
|
POST /playlists
|
||||||
|
```
|
||||||
|
Query params:
|
||||||
|
- `title`: Playlist name
|
||||||
|
- `type`: audio, video, photo
|
||||||
|
- `smart`: 0 or 1
|
||||||
|
- `uri`: Items to add (server://...)
|
||||||
|
|
||||||
|
### Add to Playlist
|
||||||
|
```
|
||||||
|
PUT /playlists/{playlistId}/items?uri={itemUri}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Playlist
|
||||||
|
```
|
||||||
|
DELETE /playlists/{playlistId}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collections
|
||||||
|
|
||||||
|
### List Collections
|
||||||
|
```
|
||||||
|
GET /library/sections/{sectionId}/collections
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Collection Items
|
||||||
|
```
|
||||||
|
GET /library/collections/{collectionId}/items
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add to Collection
|
||||||
|
```
|
||||||
|
PUT /library/collections/{collectionId}/items?uri={itemUri}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove from Collection
|
||||||
|
```
|
||||||
|
DELETE /library/collections/{collectionId}/items/{itemId}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Play Queues
|
||||||
|
|
||||||
|
### Create Play Queue
|
||||||
|
```
|
||||||
|
POST /playQueues
|
||||||
|
```
|
||||||
|
Query params:
|
||||||
|
- `uri`: Source URI
|
||||||
|
- `type`: audio, video, photo
|
||||||
|
- `shuffle`: 0 or 1
|
||||||
|
- `repeat`: 0, 1, 2
|
||||||
|
- `continuous`: 0 or 1
|
||||||
|
|
||||||
|
### Get Play Queue
|
||||||
|
```
|
||||||
|
GET /playQueues/{playQueueId}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server Status
|
||||||
|
|
||||||
|
### Server Identity
|
||||||
|
```
|
||||||
|
GET /identity
|
||||||
|
```
|
||||||
|
Basic server info (machine identifier, version).
|
||||||
|
|
||||||
|
### Server Capabilities
|
||||||
|
```
|
||||||
|
GET /
|
||||||
|
```
|
||||||
|
Full server capabilities and features.
|
||||||
|
|
||||||
|
### Server Preferences
|
||||||
|
```
|
||||||
|
GET /:prefs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Activities
|
||||||
|
|
||||||
|
### List Activities
|
||||||
|
```
|
||||||
|
GET /activities
|
||||||
|
```
|
||||||
|
Get running activities (transcoding, scanning, etc.)
|
||||||
|
|
||||||
|
### Cancel Activity
|
||||||
|
```
|
||||||
|
DELETE /activities/{activityId}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Butler (Scheduled Tasks)
|
||||||
|
|
||||||
|
### Get Butler Status
|
||||||
|
```
|
||||||
|
GET /butler
|
||||||
|
```
|
||||||
|
List scheduled maintenance tasks.
|
||||||
|
|
||||||
|
### Run Butler Task
|
||||||
|
```
|
||||||
|
POST /butler/{taskName}
|
||||||
|
```
|
||||||
|
Task names: BackupDatabase, BuildGracenoteCollections, CheckForUpdates, CleanOldBundles, CleanOldCacheFiles, DeepMediaAnalysis, GenerateAutoTags, GenerateChapterThumbs, GenerateMediaIndexFiles, OptimizeDatabase, RefreshLibraries, RefreshLocalMedia, RefreshPeriodicMetadata, UpgradeMediaAnalysis
|
||||||
|
|
||||||
|
### Stop Butler Task
|
||||||
|
```
|
||||||
|
DELETE /butler/{taskName}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transcoding
|
||||||
|
|
||||||
|
### Get Transcode Sessions
|
||||||
|
```
|
||||||
|
GET /transcode/sessions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop Transcode Session
|
||||||
|
```
|
||||||
|
DELETE /transcode/sessions/{sessionKey}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata Types Reference
|
||||||
|
|
||||||
|
| Type | Number | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| movie | 1 | Movies |
|
||||||
|
| show | 2 | TV Shows |
|
||||||
|
| season | 3 | Seasons |
|
||||||
|
| episode | 4 | Episodes |
|
||||||
|
| trailer | 5 | Trailers |
|
||||||
|
| artist | 8 | Music Artists |
|
||||||
|
| album | 9 | Music Albums |
|
||||||
|
| track | 10 | Music Tracks |
|
||||||
|
| photo | 13 | Photos |
|
||||||
|
| photoalbum | 14 | Photo Albums |
|
||||||
|
| playlist | 15 | Playlists |
|
||||||
|
| collection | 18 | Collections |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Query Parameters
|
||||||
|
|
||||||
|
Many list endpoints accept these query parameters:
|
||||||
|
|
||||||
|
- `type`: Filter by metadata type number
|
||||||
|
- `sort`: Sort field with optional `:asc` or `:desc`
|
||||||
|
- `X-Plex-Container-Start`: Pagination offset
|
||||||
|
- `X-Plex-Container-Size`: Items per page
|
||||||
|
- `includeChildren`: Include child items (1/0)
|
||||||
|
- `includeElements`: Comma-separated list of elements to include
|
||||||
|
- `excludeElements`: Comma-separated list of elements to exclude
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URI Format
|
||||||
|
|
||||||
|
When referencing items for playlists/queues:
|
||||||
|
```
|
||||||
|
server://{machineIdentifier}/com.plexapp.plugins.library/library/metadata/{ratingKey}
|
||||||
|
```
|
||||||
|
|
||||||
|
Get machine identifier from `GET /identity`.
|
||||||
30969
openapi.json
Normal file
30969
openapi.json
Normal file
File diff suppressed because one or more lines are too long
26
pyproject.toml
Normal file
26
pyproject.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[project]
|
||||||
|
name = "plex-mcp"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "MCP server for Plex Media Server API"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastmcp>=2.0.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
"uvicorn>=0.30.0",
|
||||||
|
"starlette>=0.38.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["."]
|
||||||
402
server.py
Normal file
402
server.py
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
"""
|
||||||
|
Plex MCP Server - A lightweight MCP server for Plex Media Server API.
|
||||||
|
|
||||||
|
Follows the Hybrid MCP Light pattern:
|
||||||
|
- 5 specific tools for common operations
|
||||||
|
- 1 API pass-through for full API coverage
|
||||||
|
- Documentation resources for AI agent reference
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.routing import Mount, Route
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
PLEX_URL = os.getenv("PLEX_URL", "http://localhost:32400")
|
||||||
|
PLEX_TOKEN = os.getenv("PLEX_TOKEN", "")
|
||||||
|
PLEX_CLIENT_ID = os.getenv("PLEX_CLIENT_ID", "plex-mcp-server")
|
||||||
|
PORT = int(os.getenv("PORT", "8000"))
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
SCRIPT_DIR = Path(__file__).parent
|
||||||
|
OPENAPI_PATH = SCRIPT_DIR / "openapi.json"
|
||||||
|
API_REFERENCE_PATH = SCRIPT_DIR / "docs" / "api_reference.md"
|
||||||
|
|
||||||
|
|
||||||
|
class PlexClient:
|
||||||
|
"""HTTP client for Plex Media Server API."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, token: str, client_id: str):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.token = token
|
||||||
|
self.client_id = client_id
|
||||||
|
self._client: Optional[httpx.AsyncClient] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def headers(self) -> dict:
|
||||||
|
return {
|
||||||
|
"X-Plex-Token": self.token,
|
||||||
|
"X-Plex-Client-Identifier": self.client_id,
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_client(self) -> httpx.AsyncClient:
|
||||||
|
if self._client is None or self._client.is_closed:
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=self.base_url,
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
if self._client and not self._client.is_closed:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
async def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
params: Optional[dict] = None,
|
||||||
|
body: Optional[dict] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Execute an API request to Plex."""
|
||||||
|
client = await self.get_client()
|
||||||
|
try:
|
||||||
|
response = await client.request(
|
||||||
|
method=method.upper(),
|
||||||
|
url=endpoint,
|
||||||
|
params=params,
|
||||||
|
json=body if body else None,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Handle empty responses
|
||||||
|
if not response.content:
|
||||||
|
return {"status": "ok", "statusCode": response.status_code}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
return {
|
||||||
|
"error": True,
|
||||||
|
"statusCode": e.response.status_code,
|
||||||
|
"message": str(e),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": True, "message": str(e)}
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if connection to Plex is working."""
|
||||||
|
try:
|
||||||
|
result = await self.request("GET", "/identity")
|
||||||
|
return "error" not in result
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize client and MCP server
|
||||||
|
plex_client = PlexClient(PLEX_URL, PLEX_TOKEN, PLEX_CLIENT_ID)
|
||||||
|
mcp = FastMCP(
|
||||||
|
"Plex MCP Server",
|
||||||
|
description="MCP server for interacting with Plex Media Server",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Specific Tools (5 high-value operations)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_libraries() -> str:
|
||||||
|
"""List all Plex library sections (Movies, TV Shows, Music, etc.).
|
||||||
|
|
||||||
|
Returns a list of all libraries with their IDs, names, types, and item counts.
|
||||||
|
Use the section ID from this response with other tools like search_library or refresh_library.
|
||||||
|
"""
|
||||||
|
result = await plex_client.request("GET", "/library/sections/all")
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_library(
|
||||||
|
query: str, limit: int = 10, section_id: Optional[int] = None
|
||||||
|
) -> str:
|
||||||
|
"""Search for media across all libraries or within a specific library.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search term (title, artist, etc.)
|
||||||
|
limit: Maximum results per media type (default: 10)
|
||||||
|
section_id: Optional library section ID to restrict search
|
||||||
|
|
||||||
|
Returns search results grouped by media type (movies, shows, episodes, etc.)
|
||||||
|
"""
|
||||||
|
params = {"query": query, "limit": limit}
|
||||||
|
if section_id is not None:
|
||||||
|
params["sectionId"] = section_id
|
||||||
|
|
||||||
|
result = await plex_client.request("GET", "/hubs/search", params=params)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_metadata(rating_key: str, include_children: bool = False) -> str:
|
||||||
|
"""Get detailed metadata for a specific media item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rating_key: The unique identifier (ratingKey) of the media item
|
||||||
|
include_children: If True, include child items (seasons for shows, episodes for seasons)
|
||||||
|
|
||||||
|
Returns detailed metadata including title, summary, ratings, cast, and more.
|
||||||
|
"""
|
||||||
|
endpoint = f"/library/metadata/{rating_key}"
|
||||||
|
params = {}
|
||||||
|
if include_children:
|
||||||
|
params["includeChildren"] = 1
|
||||||
|
|
||||||
|
result = await plex_client.request("GET", endpoint, params=params)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_recently_added(section_id: Optional[int] = None, limit: int = 20) -> str:
|
||||||
|
"""Get recently added media items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
section_id: Optional library section ID to filter results
|
||||||
|
limit: Maximum number of items to return (default: 20)
|
||||||
|
|
||||||
|
Returns recently added items across all libraries or for a specific library.
|
||||||
|
"""
|
||||||
|
if section_id is not None:
|
||||||
|
# Get recently added for specific library via hubs
|
||||||
|
endpoint = f"/hubs/sections/{section_id}"
|
||||||
|
else:
|
||||||
|
# Get global recently added
|
||||||
|
endpoint = "/hubs"
|
||||||
|
|
||||||
|
params = {"X-Plex-Container-Size": limit}
|
||||||
|
result = await plex_client.request("GET", endpoint, params=params)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def refresh_library(
|
||||||
|
section_id: int,
|
||||||
|
force: bool = False,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Trigger a library scan to detect new or changed media files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
section_id: The library section ID to refresh (get from get_libraries)
|
||||||
|
force: If True, force metadata refresh even if files appear unchanged
|
||||||
|
path: Optional path to restrict the scan to a specific directory
|
||||||
|
|
||||||
|
Returns confirmation of the refresh request.
|
||||||
|
"""
|
||||||
|
endpoint = f"/library/sections/{section_id}/refresh"
|
||||||
|
params = {}
|
||||||
|
if force:
|
||||||
|
params["force"] = 1
|
||||||
|
if path:
|
||||||
|
params["path"] = path
|
||||||
|
|
||||||
|
result = await plex_client.request("POST", endpoint, params=params)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API Pass-through Tool
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def plex_api_call(
|
||||||
|
endpoint: str,
|
||||||
|
method: str = "GET",
|
||||||
|
params: str = "{}",
|
||||||
|
body: str = "{}",
|
||||||
|
) -> str:
|
||||||
|
"""Execute any Plex API call directly.
|
||||||
|
|
||||||
|
This is the escape hatch for accessing any Plex API endpoint not covered
|
||||||
|
by the specific tools. Refer to the 'plex://api-reference' resource or
|
||||||
|
use search_api_docs() to find available endpoints.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: API path (e.g., '/playlists', '/:scrobble', '/library/metadata/123')
|
||||||
|
method: HTTP method (GET, POST, PUT, DELETE)
|
||||||
|
params: JSON string of query parameters (e.g., '{"query": "test", "limit": 10}')
|
||||||
|
body: JSON string of request body for POST/PUT requests
|
||||||
|
|
||||||
|
Returns the API response as JSON.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Mark item watched: plex_api_call('/:scrobble', params='{"key": "12345", "identifier": "com.plexapp.plugins.library"}')
|
||||||
|
- Get playlists: plex_api_call('/playlists')
|
||||||
|
- Rate item: plex_api_call('/:rate', 'PUT', params='{"key": "12345", "identifier": "com.plexapp.plugins.library", "rating": 8}')
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed_params = json.loads(params) if params and params != "{}" else None
|
||||||
|
parsed_body = json.loads(body) if body and body != "{}" else None
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return json.dumps({"error": True, "message": f"Invalid JSON: {e}"})
|
||||||
|
|
||||||
|
result = await plex_client.request(
|
||||||
|
method, endpoint, params=parsed_params, body=parsed_body
|
||||||
|
)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Documentation Tools & Resources
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_api_docs(query: str, limit: int = 20) -> str:
|
||||||
|
"""Search the Plex OpenAPI specification for endpoints matching a query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search term to find in endpoint paths, summaries, or descriptions
|
||||||
|
limit: Maximum number of results to return (default: 20)
|
||||||
|
|
||||||
|
Returns matching endpoints with their methods, summaries, and parameters.
|
||||||
|
"""
|
||||||
|
if not OPENAPI_PATH.exists():
|
||||||
|
return json.dumps({"error": True, "message": "OpenAPI spec not found"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(OPENAPI_PATH) as f:
|
||||||
|
spec = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps(
|
||||||
|
{"error": True, "message": f"Failed to load OpenAPI spec: {e}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
query_lower = query.lower()
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for path, methods in spec.get("paths", {}).items():
|
||||||
|
for method, details in methods.items():
|
||||||
|
if method.startswith("x-"): # Skip OpenAPI extensions
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Search in path, summary, description, and tags
|
||||||
|
searchable = " ".join(
|
||||||
|
[
|
||||||
|
path,
|
||||||
|
details.get("summary", ""),
|
||||||
|
details.get("description", ""),
|
||||||
|
" ".join(details.get("tags", [])),
|
||||||
|
]
|
||||||
|
).lower()
|
||||||
|
|
||||||
|
if query_lower in searchable:
|
||||||
|
# Extract parameter info
|
||||||
|
params = []
|
||||||
|
for param in details.get("parameters", []):
|
||||||
|
params.append(
|
||||||
|
{
|
||||||
|
"name": param.get("name"),
|
||||||
|
"in": param.get("in"),
|
||||||
|
"required": param.get("required", False),
|
||||||
|
"description": param.get("description", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"path": path,
|
||||||
|
"method": method.upper(),
|
||||||
|
"summary": details.get("summary", ""),
|
||||||
|
"description": details.get("description", ""),
|
||||||
|
"tags": details.get("tags", []),
|
||||||
|
"parameters": params,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"query": query,
|
||||||
|
"count": len(results),
|
||||||
|
"results": results,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("plex://api-reference")
|
||||||
|
async def get_api_reference() -> str:
|
||||||
|
"""Get the curated Plex API quick reference documentation.
|
||||||
|
|
||||||
|
This resource provides a human-readable guide to the most commonly used
|
||||||
|
Plex API endpoints, organized by category. Use this to understand how
|
||||||
|
to use the plex_api_call tool for operations not covered by specific tools.
|
||||||
|
"""
|
||||||
|
if not API_REFERENCE_PATH.exists():
|
||||||
|
return "API reference documentation not found."
|
||||||
|
|
||||||
|
return API_REFERENCE_PATH.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Health Check & Application Setup
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def health_check(request):
|
||||||
|
"""Health check endpoint for Docker/orchestration."""
|
||||||
|
is_healthy = await plex_client.health_check()
|
||||||
|
status = "ok" if is_healthy else "degraded"
|
||||||
|
return JSONResponse(
|
||||||
|
{"status": status, "plex_connected": is_healthy},
|
||||||
|
status_code=200 if is_healthy else 503,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> Starlette:
|
||||||
|
"""Create the Starlette application wrapping the MCP server."""
|
||||||
|
mcp_app = mcp.http_app()
|
||||||
|
|
||||||
|
async def lifespan(app):
|
||||||
|
# Startup
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
await plex_client.close()
|
||||||
|
|
||||||
|
return Starlette(
|
||||||
|
routes=[
|
||||||
|
Route("/health", health_check),
|
||||||
|
Mount("/", app=mcp_app),
|
||||||
|
],
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
print(f"Starting Plex MCP Server on port {PORT}")
|
||||||
|
print(f"Plex URL: {PLEX_URL}")
|
||||||
|
print(f"Health check: http://localhost:{PORT}/health")
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||||
Reference in New Issue
Block a user