198 lines
8.4 KiB
Python
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))
|