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
2 changes: 1 addition & 1 deletion docker/pyproject.deps.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "mcp-plex"
version = "0.26.41"
version = "0.26.43"
requires-python = ">=3.11,<3.13"
dependencies = [
"fastmcp>=2.11.2",
Expand Down
10 changes: 10 additions & 0 deletions mcp_plex/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
"""mcp-plex package."""

from __future__ import annotations

import warnings

warnings.filterwarnings(
"ignore",
message=".*'mcp_plex\\.loader' found in sys.modules after import of package 'mcp_plex'.*",
category=RuntimeWarning,
)

__all__ = []
35 changes: 24 additions & 11 deletions mcp_plex/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,33 @@
class Settings(BaseSettings):
"""Application configuration settings."""

qdrant_url: str | None = Field(default=None, env="QDRANT_URL")
qdrant_api_key: str | None = Field(default=None, env="QDRANT_API_KEY")
qdrant_host: str | None = Field(default=None, env="QDRANT_HOST")
qdrant_port: int = Field(default=6333, env="QDRANT_PORT")
qdrant_grpc_port: int = Field(default=6334, env="QDRANT_GRPC_PORT")
qdrant_prefer_grpc: bool = Field(default=False, env="QDRANT_PREFER_GRPC")
qdrant_https: bool | None = Field(default=None, env="QDRANT_HTTPS")
qdrant_url: str | None = Field(
default=None, validation_alias="QDRANT_URL"
)
qdrant_api_key: str | None = Field(
default=None, validation_alias="QDRANT_API_KEY"
)
qdrant_host: str | None = Field(
default=None, validation_alias="QDRANT_HOST"
)
qdrant_port: int = Field(default=6333, validation_alias="QDRANT_PORT")
qdrant_grpc_port: int = Field(
default=6334, validation_alias="QDRANT_GRPC_PORT"
)
qdrant_prefer_grpc: bool = Field(
default=False, validation_alias="QDRANT_PREFER_GRPC"
)
qdrant_https: bool | None = Field(
default=None, validation_alias="QDRANT_HTTPS"
)
dense_model: str = Field(
default="BAAI/bge-small-en-v1.5", env="DENSE_MODEL"
default="BAAI/bge-small-en-v1.5", validation_alias="DENSE_MODEL"
)
sparse_model: str = Field(
default="Qdrant/bm42-all-minilm-l6-v2-attentions", env="SPARSE_MODEL"
default="Qdrant/bm42-all-minilm-l6-v2-attentions",
validation_alias="SPARSE_MODEL",
)
cache_size: int = Field(default=128, env="CACHE_SIZE")
use_reranker: bool = Field(default=True, env="USE_RERANKER")
cache_size: int = Field(default=128, validation_alias="CACHE_SIZE")
use_reranker: bool = Field(default=True, validation_alias="USE_RERANKER")

model_config = SettingsConfigDict(case_sensitive=False)
176 changes: 75 additions & 101 deletions mcp_plex/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
from __future__ import annotations

import asyncio
import inspect
import json
import logging
import sys
import warnings
from collections import deque
from pathlib import Path
from typing import AsyncIterator, Awaitable, Iterable, List, Optional, Sequence, TypeVar
Expand Down Expand Up @@ -39,6 +41,12 @@
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

warnings.filterwarnings(
"ignore",
message=".*'mcp_plex\\.loader' found in sys.modules after import of package 'mcp_plex'.*",
category=RuntimeWarning,
)

T = TypeVar("T")

_imdb_cache: IMDbCache | None = None
Expand All @@ -59,6 +67,15 @@ def _require_positive(value: int, *, name: str) -> int:
return value


def _is_local_qdrant(client: AsyncQdrantClient) -> bool:
"""Return ``True`` if *client* targets an in-process Qdrant instance."""

inner = getattr(client, "_client", None)
return bool(inner) and inner.__class__.__module__.startswith(
"qdrant_client.local"
)


class _IMDbRetryQueue(asyncio.Queue[str]):
"""Queue that tracks items in a deque for safe serialization."""

Expand Down Expand Up @@ -102,12 +119,24 @@ def snapshot(self) -> list[str]:
}


def _close_coroutines(tasks: Sequence[Awaitable[object]]) -> None:
"""Close coroutine objects to avoid unawaited warnings."""

for task in tasks:
if inspect.iscoroutine(task):
task.close()


async def _iter_gather_in_batches(
tasks: Sequence[Awaitable[T]], batch_size: int
) -> AsyncIterator[T]:
"""Yield results from awaitable tasks in fixed-size batches."""

_require_positive(batch_size, name="batch_size")
try:
_require_positive(batch_size, name="batch_size")
except ValueError:
_close_coroutines(tasks)
raise

total = len(tasks)
for i in range(0, total, batch_size):
Expand Down Expand Up @@ -314,112 +343,57 @@ async def _ensure_collection(
if not created_collection:
return

suppress_payload_warning = _is_local_qdrant(client)

async def _create_index(
field_name: str,
field_schema: models.PayloadSchemaType | models.TextIndexParams,
) -> None:
if suppress_payload_warning:
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message="Payload indexes have no effect in the local Qdrant.*",
category=UserWarning,
)
await client.create_payload_index(
collection_name=collection_name,
field_name=field_name,
field_schema=field_schema,
)
else:
await client.create_payload_index(
collection_name=collection_name,
field_name=field_name,
field_schema=field_schema,
)

text_index = models.TextIndexParams(
type=models.PayloadSchemaType.TEXT,
tokenizer=models.TokenizerType.WORD,
min_token_len=2,
lowercase=True,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="title",
field_schema=text_index,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="type",
field_schema=models.PayloadSchemaType.KEYWORD,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="year",
field_schema=models.PayloadSchemaType.INTEGER,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="added_at",
field_schema=models.PayloadSchemaType.INTEGER,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="actors",
field_schema=models.PayloadSchemaType.KEYWORD,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="directors",
field_schema=models.PayloadSchemaType.KEYWORD,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="writers",
field_schema=models.PayloadSchemaType.KEYWORD,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="genres",
field_schema=models.PayloadSchemaType.KEYWORD,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="show_title",
field_schema=models.PayloadSchemaType.KEYWORD,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="season_number",
field_schema=models.PayloadSchemaType.INTEGER,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="episode_number",
field_schema=models.PayloadSchemaType.INTEGER,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="collections",
field_schema=models.PayloadSchemaType.KEYWORD,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="summary",
field_schema=text_index,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="overview",
field_schema=text_index,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="plot",
field_schema=text_index,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="tagline",
field_schema=text_index,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="reviews",
field_schema=text_index,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="data.plex.rating_key",
field_schema=models.PayloadSchemaType.KEYWORD,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="data.imdb.id",
field_schema=models.PayloadSchemaType.KEYWORD,
)
await client.create_payload_index(
collection_name=collection_name,
field_name="data.tmdb.id",
field_schema=models.PayloadSchemaType.INTEGER,
)
await _create_index("title", text_index)
await _create_index("type", models.PayloadSchemaType.KEYWORD)
await _create_index("year", models.PayloadSchemaType.INTEGER)
await _create_index("added_at", models.PayloadSchemaType.INTEGER)
await _create_index("actors", models.PayloadSchemaType.KEYWORD)
await _create_index("directors", models.PayloadSchemaType.KEYWORD)
await _create_index("writers", models.PayloadSchemaType.KEYWORD)
await _create_index("genres", models.PayloadSchemaType.KEYWORD)
await _create_index("show_title", models.PayloadSchemaType.KEYWORD)
await _create_index("season_number", models.PayloadSchemaType.INTEGER)
await _create_index("episode_number", models.PayloadSchemaType.INTEGER)
await _create_index("collections", models.PayloadSchemaType.KEYWORD)
await _create_index("summary", text_index)
await _create_index("overview", text_index)
await _create_index("plot", text_index)
await _create_index("tagline", text_index)
await _create_index("reviews", text_index)
await _create_index("data.plex.rating_key", models.PayloadSchemaType.KEYWORD)
await _create_index("data.imdb.id", models.PayloadSchemaType.KEYWORD)
await _create_index("data.tmdb.id", models.PayloadSchemaType.INTEGER)


async def _fetch_tmdb_movie(
Expand Down
9 changes: 6 additions & 3 deletions mcp_plex/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,14 +651,17 @@ async def recommend_media(
record = records[0]
if record is None:
return []
recs = await server.qdrant_client.recommend(
rec_query = models.RecommendQuery(
recommend=models.RecommendInput(positive=[record.id])
)
response = await server.qdrant_client.query_points(
collection_name="media-items",
positive=[record.id],
query=rec_query,
limit=limit,
with_payload=True,
using="dense",
)
return [_flatten_payload(r.payload) for r in recs]
return [_flatten_payload(r.payload) for r in response.points]


@server.tool("new-movies")
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "mcp-plex"
version = "0.26.41"
version = "0.26.43"

description = "Plex-Oriented Model Context Protocol Server"
requires-python = ">=3.11,<3.13"
Expand Down
9 changes: 7 additions & 2 deletions tests/test_loader_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ async def fake_run(*args, **kwargs):

def test_loader_script_entrypoint(monkeypatch):
monkeypatch.setattr(sys, "argv", ["loader", "--help"])
with pytest.raises(SystemExit) as exc:
runpy.run_module("mcp_plex.loader", run_name="__main__")
module = sys.modules.pop("mcp_plex.loader", None)
try:
with pytest.raises(SystemExit) as exc:
runpy.run_module("mcp_plex.loader", run_name="__main__")
finally:
if module is not None:
sys.modules["mcp_plex.loader"] = module
assert exc.value.code == 0
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.