From 814a9badd8f29e1bc975367222b6c5b28c763e98 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Tue, 7 Oct 2025 00:25:14 -0600 Subject: [PATCH 1/4] refactor(server): extract cli entrypoint module --- mcp_plex/server/__init__.py | 92 +------------------------------ mcp_plex/server/cli.py | 105 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_server.py | 2 +- tests/test_server_cli.py | 5 +- 5 files changed, 112 insertions(+), 94 deletions(-) create mode 100644 mcp_plex/server/cli.py diff --git a/mcp_plex/server/__init__.py b/mcp_plex/server/__init__.py index a995eda..00a5fc0 100644 --- a/mcp_plex/server/__init__.py +++ b/mcp_plex/server/__init__.py @@ -1,15 +1,12 @@ """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 NotRequired, TypedDict @@ -1568,96 +1565,9 @@ 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``.""" - - 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=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") - - 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 - server.run(transport=transport, **run_config.to_kwargs()) +from .cli import main if __name__ == "__main__": diff --git a/mcp_plex/server/cli.py b/mcp_plex/server/cli.py new file mode 100644 index 0000000..24554b6 --- /dev/null +++ b/mcp_plex/server/cli.py @@ -0,0 +1,105 @@ +"""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..fd40812 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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..84f4ce7 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) From 5af8c97f05c184e70e7e09f0a50b4f9f3f4182e9 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Tue, 7 Oct 2025 00:31:57 -0600 Subject: [PATCH 2/4] style: format server cli modules --- mcp_plex/server/__init__.py | 7 ++++++- mcp_plex/server/cli.py | 11 +++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/mcp_plex/server/__init__.py b/mcp_plex/server/__init__.py index 00a5fc0..37a5b3b 100644 --- a/mcp_plex/server/__init__.py +++ b/mcp_plex/server/__init__.py @@ -1567,7 +1567,12 @@ async def _rest_resource(request: Request, _uri_template=uri, _resource=resource -from .cli import main +def main(argv: list[str] | None = None) -> None: + """Entry point retained for backwards compatibility.""" + + from .cli import main as cli_main + + cli_main(argv) if __name__ == "__main__": diff --git a/mcp_plex/server/cli.py b/mcp_plex/server/cli.py index 24554b6..9ec13b6 100644 --- a/mcp_plex/server/cli.py +++ b/mcp_plex/server/cli.py @@ -1,4 +1,5 @@ """Command line interface for :mod:`mcp_plex.server`.""" + from __future__ import annotations import argparse @@ -58,7 +59,11 @@ def main(argv: list[str] | None = None) -> None: 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_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") @@ -83,7 +88,9 @@ def main(argv: list[str] | None = None) -> None: 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") + 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") From 0c6e7e6c7b1fcde226c8aea24b9c5e87c76d8180 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Tue, 7 Oct 2025 00:41:25 -0600 Subject: [PATCH 3/4] fix: restore server RunConfig export --- mcp_plex/server/__init__.py | 23 ++++++++++++++++++++++- tests/test_server_cli.py | 6 ++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/mcp_plex/server/__init__.py b/mcp_plex/server/__init__.py index 37a5b3b..29e5e4f 100644 --- a/mcp_plex/server/__init__.py +++ b/mcp_plex/server/__init__.py @@ -7,7 +7,7 @@ import json import logging import uuid -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 @@ -1577,3 +1577,24 @@ def main(argv: list[str] | None = None) -> None: if __name__ == "__main__": main() + + +if TYPE_CHECKING: + from .cli import RunConfig as RunConfig + + +def __getattr__(name: str) -> Any: + if name == "RunConfig": + from .cli import RunConfig as _RunConfig + + return _RunConfig + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [ + "PlexServer", + "server", + "settings", + "main", + "RunConfig", +] diff --git a/tests/test_server_cli.py b/tests/test_server_cli.py index 84f4ce7..479062f 100644 --- a/tests/test_server_cli.py +++ b/tests/test_server_cli.py @@ -106,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 From b7ec7e527305efbc009d02ab700c99f9d4548d7b Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Tue, 7 Oct 2025 00:47:30 -0600 Subject: [PATCH 4/4] chore(release): 2.0.0 --- docker/pyproject.deps.toml | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/pyproject.toml b/pyproject.toml index fd40812..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" 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" },