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