mcp-calculator/src/mcp_calculator/config.py
2026-09-15 20:38:16 +02:00

198 lines
8.4 KiB
Python

"""Runtime configuration.
Every setting can be given as environment variable (prefix ``MCP_CALC_``) or as
CLI flag. CLI flags win over environment variables, which win over defaults.
"""
from __future__ import annotations
import argparse
import os
from dataclasses import dataclass, field, fields
from typing import Literal
ENV_PREFIX = "MCP_CALC_"
Transport = Literal["stdio", "http"]
ExecutorMode = Literal["process", "inline"]
def _csv(value: str) -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()]
def _bool(value: str) -> bool:
normalized = value.strip().lower()
if normalized in ("1", "true", "yes", "on"):
return True
if normalized in ("0", "false", "no", "off"):
return False
raise ValueError("expected true/false")
@dataclass(frozen=True)
class Limits:
"""Guards applied to every computation. Shipped to worker processes."""
max_input_length: int = 5_000
max_output_length: int = 20_000
max_precision: int = 1_000
max_matrix_size: int = 30
max_data_points: int = 1_000_000
max_list_items: int = 10_000
@dataclass(frozen=True)
class Settings:
transport: Transport = "stdio"
# HTTP transport
host: str = "127.0.0.1"
port: int = 8080
path: str = "/mcp"
allowed_hosts: list[str] = field(default_factory=list)
allowed_origins: list[str] = field(default_factory=list)
trusted_proxies: list[str] = field(default_factory=list)
max_request_body_bytes: int = 1_048_576
max_connections: int = 200
metrics: bool = True
# Authentication (HTTP only; stdio never authenticates)
clients_file: str = ""
clients_reload_seconds: float = 5.0
auth_tokens: list[str] = field(default_factory=list)
audit_file: str = ""
# Admin API (separate port, only started when admin tokens are configured)
admin_tokens: list[str] = field(default_factory=list)
admin_host: str = "127.0.0.1"
admin_port: int = 9090
# Execution
executor: ExecutorMode = "process"
workers: int = 2
max_queue: int = 20
max_queue_wait_seconds: float = 30.0
client_max_concurrent: int = 10
timeout_seconds: float = 10.0
max_tasks_per_worker: int = 200
memory_limit_mb: int = 512
limits: Limits = field(default_factory=Limits)
# Logging
log_level: str = "INFO"
log_format: Literal["text", "json"] = "text"
# name -> (parser, help text). Names map to MCP_CALC_<NAME> and --<name-with-dashes>.
_OPTIONS: dict[str, tuple[type | object, str]] = {
"transport": (str, "stdio | http"),
"host": (str, "HTTP bind address (use 0.0.0.0 inside containers)"),
"port": (int, "HTTP port"),
"path": (str, "HTTP path of the MCP endpoint"),
"allowed_hosts": (_csv, "comma-separated Host header values; enables DNS-rebinding protection"),
"allowed_origins": (_csv, "comma-separated Origin header values (browser clients only)"),
"trusted_proxies": (_csv, "comma-separated IPs/CIDRs of proxies whose X-Forwarded-For is trusted (e.g. the OpenShift router)"),
"max_request_body_bytes": (int, "maximum HTTP request body size"),
"max_connections": (int, "concurrent HTTP connections; a new connection that would reach this number is answered with 503"),
"metrics": (_bool, "expose Prometheus metrics at /metrics (HTTP only): true | false"),
"clients_file": (str, "JSON file with API clients (tokens, limits, networks); enables authentication"),
"clients_reload_seconds": (float, "how often the clients file is checked for changes"),
"auth_tokens": (_csv, "static bearer tokens as name:token (comma-separated); simple alternative to clients_file"),
"audit_file": (str, "audit log of client changes (default: audit.log next to clients_file)"),
"admin_tokens": (_csv, "admin API tokens as name:token; the admin API only starts when set"),
"admin_host": (str, "admin API bind address (127.0.0.1 = only via oc port-forward/exec)"),
"admin_port": (int, "admin API port"),
"executor": (str, "process (isolated worker pool, default) | inline (no isolation, dev/tests only)"),
"workers": (int, "number of worker processes"),
"max_queue": (int, "computations that may wait for a free worker; beyond that requests are rejected as busy"),
"max_queue_wait_seconds": (float, "maximum time a computation waits for a free worker before it is rejected as busy"),
"client_max_concurrent": (int, "default limit of concurrent computations per client (0 = unlimited); overridable per client"),
"timeout_seconds": (float, "hard timeout per computation"),
"max_tasks_per_worker": (int, "recycle a worker after N tasks (0 = never)"),
"memory_limit_mb": (int, "extra memory a computation may allocate per worker (Linux only, 0 = off)"),
"max_input_length": (int, "maximum characters per expression"),
"max_output_length": (int, "results longer than this are truncated"),
"max_precision": (int, "maximum significant digits for numeric evaluation"),
"max_matrix_size": (int, "maximum rows/columns of a matrix"),
"max_data_points": (int, "maximum values per statistics data set"),
"max_list_items": (int, "maximum items in other list inputs"),
"log_level": (str, "DEBUG | INFO | WARNING | ERROR"),
"log_format": (str, "text | json"),
}
_LIMIT_FIELDS = {f.name for f in fields(Limits)}
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="mcp-calculator",
description="MCP server with exact math tools (stdio or Streamable HTTP).",
epilog=f"Every option can also be set via environment variable {ENV_PREFIX}<OPTION>, "
f"e.g. {ENV_PREFIX}TRANSPORT=http.",
)
for name, (_, help_text) in _OPTIONS.items():
parser.add_argument(f"--{name.replace('_', '-')}", dest=name, default=None, help=help_text)
return parser
def load_settings(argv: list[str] | None = None, env: dict[str, str] | None = None) -> Settings:
"""Build settings from defaults, environment and CLI arguments."""
env = os.environ if env is None else env
args = vars(_build_parser().parse_args(argv if argv is not None else []))
raw: dict[str, object] = {}
for name, (convert, _) in _OPTIONS.items():
value = args.get(name)
if value is None:
value = env.get(ENV_PREFIX + name.upper())
if value is None:
continue
try:
raw[name] = convert(value) # type: ignore[operator]
except ValueError as exc:
raise SystemExit(f"Invalid value for {name}: {value!r} ({exc})") from exc
limits = Limits(**{k: raw.pop(k) for k in list(raw) if k in _LIMIT_FIELDS})
settings = Settings(limits=limits, **raw) # type: ignore[arg-type]
_validate(settings)
return settings
def _validate(s: Settings) -> None:
errors = []
if s.transport not in ("stdio", "http"):
errors.append(f"transport must be 'stdio' or 'http', got {s.transport!r}")
if s.executor not in ("process", "inline"):
errors.append(f"executor must be 'process' or 'inline', got {s.executor!r}")
if s.log_format not in ("text", "json"):
errors.append(f"log_format must be 'text' or 'json', got {s.log_format!r}")
if not s.path.startswith("/"):
errors.append("path must start with '/'")
if s.workers < 1:
errors.append("workers must be >= 1")
if s.max_queue < 0:
errors.append("max_queue must be >= 0")
if s.max_queue_wait_seconds <= 0:
errors.append("max_queue_wait_seconds must be > 0")
if s.client_max_concurrent < 0:
errors.append("client_max_concurrent must be >= 0")
if s.max_connections < 1:
errors.append("max_connections must be >= 1")
if s.clients_reload_seconds <= 0:
errors.append("clients_reload_seconds must be > 0")
for name in ("auth_tokens", "admin_tokens"):
for entry in getattr(s, name):
label, sep, secret = entry.partition(":")
if not sep or not label or len(secret) < 16:
errors.append(f"{name} entries must look like name:token with a token of at least 16 characters")
break
if s.admin_tokens and s.admin_port == s.port:
errors.append("admin_port must differ from port")
if s.admin_tokens and not s.clients_file:
errors.append("admin_tokens need clients_file (the admin API manages that file)")
if s.timeout_seconds <= 0:
errors.append("timeout_seconds must be > 0")
if errors:
raise SystemExit("Invalid configuration:\n " + "\n ".join(errors))