diff --git a/docker/pyproject.deps.toml b/docker/pyproject.deps.toml index d7e65cd..4263c0f 100644 --- a/docker/pyproject.deps.toml +++ b/docker/pyproject.deps.toml @@ -1,6 +1,6 @@ [project] name = "mcp-plex" -version = "1.0.24" +version = "2.0.0" requires-python = ">=3.11,<3.13" dependencies = [ "fastmcp>=2.11.2", diff --git a/mcp_plex/server/__init__.py b/mcp_plex/server/__init__.py index a995eda..29e5e4f 100644 --- a/mcp_plex/server/__init__.py +++ b/mcp_plex/server/__init__.py @@ -1,16 +1,13 @@ """FastMCP server exposing Plex metadata tools.""" from __future__ import annotations -import argparse import asyncio import importlib.metadata import inspect import json import logging -import os import uuid -from dataclasses import dataclass -from typing import Annotated, Any, Callable, Mapping, Sequence, cast +from typing import Annotated, Any, Callable, Mapping, Sequence, TYPE_CHECKING, cast from typing import NotRequired, TypedDict from fastapi import FastAPI @@ -1568,97 +1565,36 @@ async def _rest_resource(request: Request, _uri_template=uri, _resource=resource _register_rest_endpoints() -@dataclass -class RunConfig: - """Runtime configuration for FastMCP transport servers.""" - host: str | None = None - port: int | None = None - path: str | None = None - def to_kwargs(self) -> dict[str, object]: - """Return keyword arguments compatible with ``FastMCP.run``.""" +def main(argv: list[str] | None = None) -> None: + """Entry point retained for backwards compatibility.""" - kwargs: dict[str, object] = {} - if self.host is not None: - kwargs["host"] = self.host - if self.port is not None: - kwargs["port"] = self.port - if self.path: - kwargs["path"] = self.path - return kwargs + from .cli import main as cli_main + cli_main(argv) -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") - parser.add_argument( - "--dense-model", - default=server.settings.dense_model, - help="Dense embedding model name (env: DENSE_MODEL)", - ) - parser.add_argument( - "--sparse-model", - default=server.settings.sparse_model, - help="Sparse embedding model name (env: SPARSE_MODEL)", - ) - args = parser.parse_args(argv) - - env_transport = os.getenv("MCP_TRANSPORT") - env_host = os.getenv("MCP_HOST") if os.getenv("MCP_HOST") is not None else os.getenv("MCP_BIND") - env_port = os.getenv("MCP_PORT") - env_mount = os.getenv("MCP_MOUNT") - - transport = env_transport or args.transport - valid_transports = {"stdio", "sse", "streamable-http"} - if transport not in valid_transports: - parser.error( - "transport must be one of stdio, sse, or streamable-http (via --transport or MCP_TRANSPORT)" - ) - host = env_host or args.bind - port: int | None - if env_port is not None: - try: - port = int(env_port) - except ValueError: - parser.error("MCP_PORT must be an integer") - else: - port = args.port - - mount = env_mount or args.mount - - if transport != "stdio": - if host is None or port is None: - parser.error( - "--bind/--port or MCP_HOST/MCP_PORT are required when transport is not stdio" - ) - if transport == "stdio" and mount: - parser.error("--mount or MCP_MOUNT is not allowed when transport is stdio") +if __name__ == "__main__": + main() - run_config = RunConfig() - if transport != "stdio": - if host is not None: - run_config.host = host - if port is not None: - run_config.port = port - if mount: - run_config.path = mount - server.settings.dense_model = args.dense_model - server.settings.sparse_model = args.sparse_model +if TYPE_CHECKING: + from .cli import RunConfig as RunConfig - server.run(transport=transport, **run_config.to_kwargs()) +def __getattr__(name: str) -> Any: + if name == "RunConfig": + from .cli import RunConfig as _RunConfig -if __name__ == "__main__": - main() + return _RunConfig + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [ + "PlexServer", + "server", + "settings", + "main", + "RunConfig", +] diff --git a/mcp_plex/server/cli.py b/mcp_plex/server/cli.py new file mode 100644 index 0000000..9ec13b6 --- /dev/null +++ b/mcp_plex/server/cli.py @@ -0,0 +1,112 @@ +"""Command line interface for :mod:`mcp_plex.server`.""" + +from __future__ import annotations + +import argparse +import os +from dataclasses import dataclass + +from . import PlexServer, server, settings + + +plex_server: PlexServer = server + + +@dataclass +class RunConfig: + """Runtime configuration for FastMCP transport servers.""" + + host: str | None = None + port: int | None = None + path: str | None = None + + def to_kwargs(self) -> dict[str, object]: + """Return keyword arguments compatible with ``FastMCP.run``.""" + + kwargs: dict[str, object] = {} + if self.host is not None: + kwargs["host"] = self.host + if self.port is not None: + kwargs["port"] = self.port + if self.path: + kwargs["path"] = self.path + return kwargs + + +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") + parser.add_argument( + "--dense-model", + default=settings.dense_model, + help="Dense embedding model name (env: DENSE_MODEL)", + ) + parser.add_argument( + "--sparse-model", + default=settings.sparse_model, + help="Sparse embedding model name (env: SPARSE_MODEL)", + ) + args = parser.parse_args(argv) + + env_transport = os.getenv("MCP_TRANSPORT") + env_host = ( + os.getenv("MCP_HOST") + if os.getenv("MCP_HOST") is not None + else os.getenv("MCP_BIND") + ) + env_port = os.getenv("MCP_PORT") + env_mount = os.getenv("MCP_MOUNT") + + transport = env_transport or args.transport + valid_transports = {"stdio", "sse", "streamable-http"} + if transport not in valid_transports: + parser.error( + "transport must be one of stdio, sse, or streamable-http (via --transport or MCP_TRANSPORT)" + ) + + host = env_host or args.bind + port: int | None + if env_port is not None: + try: + port = int(env_port) + except ValueError: + parser.error("MCP_PORT must be an integer") + else: + port = args.port + + mount = env_mount or args.mount + + if transport != "stdio": + if host is None or port is None: + parser.error( + "--bind/--port or MCP_HOST/MCP_PORT are required when transport is not stdio" + ) + if transport == "stdio" and mount: + parser.error("--mount or MCP_MOUNT is not allowed when transport is stdio") + + run_config = RunConfig() + if transport != "stdio": + if host is not None: + run_config.host = host + if port is not None: + run_config.port = port + if mount: + run_config.path = mount + + settings.dense_model = args.dense_model + settings.sparse_model = args.sparse_model + + plex_server.run(transport=transport, **run_config.to_kwargs()) + + +__all__ = ["RunConfig", "main", "server", "PlexServer", "plex_server", "settings"] diff --git a/pyproject.toml b/pyproject.toml index b0c0def..0a990e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mcp-plex" -version = "1.0.24" +version = "2.0.0" description = "Plex-Oriented Model Context Protocol Server" requires-python = ">=3.11,<3.13" @@ -29,7 +29,7 @@ dev = [ [project.scripts] load-data = "mcp_plex.loader.cli:main" -mcp-server = "mcp_plex.server:main" +mcp-server = "mcp_plex.server.cli:main" [tool.setuptools.packages.find] include = ["mcp_plex*"] diff --git a/tests/test_server.py b/tests/test_server.py index 27312ce..049d1f7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -545,7 +545,7 @@ def test_normalize_identifier_scalar_inputs(): def test_run_config_to_kwargs(): - module = importlib.import_module("mcp_plex.server") + module = importlib.import_module("mcp_plex.server.cli") config = module.RunConfig() assert config.to_kwargs() == {} diff --git a/tests/test_server_cli.py b/tests/test_server_cli.py index 2c27c57..479062f 100644 --- a/tests/test_server_cli.py +++ b/tests/test_server_cli.py @@ -4,7 +4,8 @@ import importlib import pytest -from mcp_plex import server +from mcp_plex import server as server_package +from mcp_plex.server import cli as server @pytest.fixture(scope="module", autouse=True) @@ -54,12 +55,14 @@ def test_env_model_overrides(monkeypatch): monkeypatch.setenv("DENSE_MODEL", "foo") monkeypatch.setenv("SPARSE_MODEL", "bar") asyncio.run(server.server.close()) + importlib.reload(server_package) importlib.reload(server) assert server.settings.dense_model == "foo" assert server.settings.sparse_model == "bar" # reload to reset globals asyncio.run(server.server.close()) + importlib.reload(server_package) importlib.reload(server) @@ -103,3 +106,9 @@ def test_env_invalid_port(monkeypatch): monkeypatch.setenv("MCP_PORT", "not-a-port") with pytest.raises(SystemExit): server.main([]) + + +def test_run_config_reexport(): + from mcp_plex.server import RunConfig as ExportedRunConfig + + assert ExportedRunConfig is server.RunConfig diff --git a/uv.lock b/uv.lock index 138a40d..b2bdd45 100644 --- a/uv.lock +++ b/uv.lock @@ -730,7 +730,7 @@ wheels = [ [[package]] name = "mcp-plex" -version = "1.0.24" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "fastapi" },