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 = "1.0.24"
version = "2.0.0"
requires-python = ">=3.11,<3.13"
dependencies = [
"fastmcp>=2.11.2",
Expand Down
110 changes: 23 additions & 87 deletions mcp_plex/server/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
]
112 changes: 112 additions & 0 deletions mcp_plex/server/cli.py
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 2 additions & 2 deletions 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 = "1.0.24"
version = "2.0.0"

description = "Plex-Oriented Model Context Protocol Server"
requires-python = ">=3.11,<3.13"
Expand All @@ -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*"]
Expand Down
2 changes: 1 addition & 1 deletion tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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() == {}
Expand Down
11 changes: 10 additions & 1 deletion tests/test_server_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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
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.