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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

# mcp-plex

`mcp-plex` is a [Model Context Protocol](https://github.com/modelcontextprotocol) server and data
loader for Plex. It ingests Plex metadata into [Qdrant](https://qdrant.tech/) and exposes
search and recommendation tools that LLM agents can call.
`mcp-plex` turns your Plex library into a searchable vector database that LLM agents can query.
It ingests Plex metadata into [Qdrant](https://qdrant.tech/) and exposes search and recommendation
tools through the [Model Context Protocol](https://github.com/modelcontextprotocol).

## Features
- Load Plex libraries into a Qdrant vector database.
Expand Down Expand Up @@ -34,10 +34,14 @@ uv run load-data --continuous --delay 600
```

### Run the MCP Server
Start the FastMCP server to expose Plex tools:
Start the FastMCP server over stdio (default):
```bash
uv run python -m mcp_plex.server
```
Expose the server over SSE on port 8000:
```bash
uv run python -m mcp_plex.server --transport sse --bind 0.0.0.0 --port 8000 --mount /mcp
```

## Docker
A Dockerfile builds a GPU-enabled image based on
Expand All @@ -50,6 +54,22 @@ docker run --rm --gpus all mcp-plex --sample-dir /data
Use `--continuous` and `--delay` flags with `docker run` to keep the loader
running in a loop.

## Docker Compose
The included `docker-compose.yml` launches both Qdrant and the MCP server.

1. Set `PLEX_URL`, `PLEX_TOKEN`, and `TMDB_API_KEY` in your environment (or a `.env` file).
2. Start the services:
```bash
docker compose up --build
```
3. (Optional) Load sample data into Qdrant:
```bash
docker compose run --rm mcp-plex uv run load-data --sample-dir sample-data
```

The server will connect to the `qdrant` service at `http://qdrant:6333` and
expose an SSE endpoint at `http://localhost:8000/mcp`.

## Development
Run linting and tests through `uv`:
```bash
Expand Down
19 changes: 19 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
version: "3.9"

services:
qdrant:
image: qdrant/qdrant
ports:
- "6333:6333"
mcp-plex:
build: .
environment:
PLEX_URL: ${PLEX_URL}
PLEX_TOKEN: ${PLEX_TOKEN}
TMDB_API_KEY: ${TMDB_API_KEY}
QDRANT_URL: http://qdrant:6333
depends_on:
- qdrant
command: uv run python -m mcp_plex.server --transport sse --bind 0.0.0.0 --port 8000 --mount /mcp
ports:
- "8000:8000"
32 changes: 31 additions & 1 deletion mcp_plex/server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""FastMCP server exposing Plex metadata tools."""
from __future__ import annotations

import argparse
import asyncio
import json
import os
Expand Down Expand Up @@ -327,5 +328,34 @@ async def media_background(
return art


def main(argv: list[str] | None = None) -> None:
"""CLI entrypoint for running the MCP server."""
parser = argparse.ArgumentParser(description="Run the MCP server")
parser.add_argument("--bind", help="Host address to bind to")
parser.add_argument("--port", type=int, help="Port to listen on")
parser.add_argument(
"--transport",
choices=["stdio", "sse", "streamable-http"],
default="stdio",
help="Transport protocol to use",
)
parser.add_argument("--mount", help="Mount path for HTTP transports")
args = parser.parse_args(argv)

if args.transport != "stdio":
if not args.bind or not args.port:
parser.error("--bind and --port are required when transport is not stdio")
if args.transport == "stdio" and args.mount:
parser.error("--mount is not allowed when transport is stdio")

run_kwargs: dict[str, Any] = {}
if args.transport != "stdio":
run_kwargs.update({"host": args.bind, "port": args.port})
if args.mount:
run_kwargs["path"] = args.mount

server.run(transport=args.transport, **run_kwargs)


if __name__ == "__main__":
server.run()
main()
15 changes: 15 additions & 0 deletions tests/test_loader_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ def test_extract_external_ids():
assert ids.tmdb == "603"


def test_extract_external_ids_missing_values():
item = types.SimpleNamespace(guids=None)
ids = _extract_external_ids(item)
assert ids.imdb is None
assert ids.tmdb is None


def test_load_from_sample_returns_items():
sample_dir = Path(__file__).resolve().parents[1] / "sample-data"
items = _load_from_sample(sample_dir)
Expand Down Expand Up @@ -61,6 +68,14 @@ def test_build_plex_item_handles_full_metadata():
assert item.actors[0].role == "Neo"


def test_build_plex_item_missing_metadata_defaults():
raw = types.SimpleNamespace(ratingKey="1", guid="g", type="movie", title="T")
item = _build_plex_item(raw)
assert item.directors == []
assert item.writers == []
assert item.actors == []


def test_fetch_functions_success_and_failure():
async def imdb_mock(request):
if "good" in str(request.url):
Expand Down
29 changes: 29 additions & 0 deletions tests/test_server_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from unittest.mock import patch

import pytest

from mcp_plex import server


def test_main_stdio_runs():
with patch.object(server.server, "run") as mock_run:
server.main([])
mock_run.assert_called_once_with(transport="stdio")


def test_main_requires_bind_and_port_for_http():
with pytest.raises(SystemExit):
server.main(["--transport", "sse", "--bind", "0.0.0.0"])
with pytest.raises(SystemExit):
server.main(["--transport", "sse", "--port", "8000"])


def test_main_mount_disallowed_for_stdio():
with pytest.raises(SystemExit):
server.main(["--mount", "/mcp"])


def test_main_http_with_mount_runs():
with patch.object(server.server, "run") as mock_run:
server.main(["--transport", "sse", "--bind", "0.0.0.0", "--port", "8000", "--mount", "/mcp"])
mock_run.assert_called_once_with(transport="sse", host="0.0.0.0", port=8000, path="/mcp")