From 9225cfcc87f503c1ea65a53c4f400d90ff7d2fa6 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Sun, 31 Aug 2025 04:44:26 -0600 Subject: [PATCH] feat(server): add CLI transport options --- README.md | 28 ++++++++++++++++++++++++---- docker-compose.yml | 19 +++++++++++++++++++ mcp_plex/server.py | 32 +++++++++++++++++++++++++++++++- tests/test_loader_unit.py | 15 +++++++++++++++ tests/test_server_cli.py | 29 +++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 docker-compose.yml create mode 100644 tests/test_server_cli.py diff --git a/README.md b/README.md index fce1fd7..d409701 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7b47bee --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/mcp_plex/server.py b/mcp_plex/server.py index f6df13f..375be10 100644 --- a/mcp_plex/server.py +++ b/mcp_plex/server.py @@ -1,6 +1,7 @@ """FastMCP server exposing Plex metadata tools.""" from __future__ import annotations +import argparse import asyncio import json import os @@ -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() diff --git a/tests/test_loader_unit.py b/tests/test_loader_unit.py index c6fe106..8686290 100644 --- a/tests/test_loader_unit.py +++ b/tests/test_loader_unit.py @@ -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) @@ -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): diff --git a/tests/test_server_cli.py b/tests/test_server_cli.py new file mode 100644 index 0000000..c7fdff5 --- /dev/null +++ b/tests/test_server_cli.py @@ -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")