#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# oct/mcp/config.py
"""
Purpose
-------
Configuration loader for the ``oct-mcp`` server. Reads
``~/.oct-mcp/config.json`` (user-level trusted config), merges with
environment variable overrides, and exposes a :class:`McpConfig`
dataclass consumed by every layer of the six-layer pipeline.
Responsibilities
----------------
- Define the canonical :class:`McpConfig` schema with safe defaults.
- Implement :func:`load_config` which locates, validates, and returns a
populated ``McpConfig``.
- Honour ``OCT_MCP_SAFE_MODE`` and ``OCT_TRUST_REPO_CONFIG`` env vars.
- Never read or trust project-level config (``debug_config.json``,
``.octrc.json``) unless ``trust_repo_config`` is explicitly enabled.
- Apply a 64 KB file-size guard (matching OI-414) to the user config.
Diagnostics
-----------
Domain: MCP
Levels:
L1 — errors: config file read failures, schema errors
L2 — lifecycle: config loaded, safe-mode active
L4 — deep trace: resolved field values
Contracts
---------
- This module does not import ``click``, the MCP SDK, or any other
``oct.mcp.*`` module (no cycles).
- :func:`load_config` always returns a fully-populated ``McpConfig``
even if the file is missing — defaults are safe.
- Config values coming from JSON are validated and clamped so callers
never receive out-of-range integers.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
try:
from oc_diagnostics import _dbg as _real_dbg
def _dbg(*args, **kwargs) -> None:
_real_dbg(*args, **kwargs)
except ImportError: # pragma: no cover
def _dbg(*args, **kwargs) -> None:
return None
# ---------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------
#: Maximum size for ``~/.oct-mcp/config.json``. Matches OI-414 (.octrc.json
#: cap) applied to the user config as defence-in-depth.
_CONFIG_MAX_BYTES: int = 64 * 1024 # 64 KB
#: Default location for the user-level MCP server config.
_DEFAULT_CONFIG_PATH: Path = Path.home() / ".oct-mcp" / "config.json"
#: Valid profile names. See blueprint §5.3.
VALID_MCP_PROFILES: tuple[str, ...] = ("default", "strict", "airgapped")
#: Minimum / maximum allowed values for integer config fields.
_RATE_LIMIT_MIN: int = 1
_RATE_LIMIT_MAX: int = 1000
_TIMEOUT_MIN: int = 5
_TIMEOUT_MAX: int = 300
_TIMEOUT_TEST_MAX: int = 600
_MAX_OUTPUT_MIN: int = 1024 # 1 KB
_MAX_OUTPUT_MAX: int = 10 * 1024 * 1024 # 10 MB
_MAX_JOBS_MIN: int = 1
_MAX_JOBS_MAX: int = 16
_MEMORY_LIMIT_MIN: int = 0 # 0 = disabled
_MEMORY_LIMIT_MAX: int = 8192 # MB
_METRICS_PORT_MIN: int = 1
_METRICS_PORT_MAX: int = 65535
#: Valid sandbox backend names. "auto" picks best available for the platform.
VALID_SANDBOX_BACKENDS: tuple[str, ...] = ("auto", "native", "bubblewrap", "sandbox_exec")
# ---------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------
[docs]
@dataclass
class McpConfig:
"""Runtime configuration for the ``oct-mcp`` server.
All fields have safe, conservative defaults that work without any
config file present. Fields loaded from JSON are clamped to the
valid ranges defined by the module-level constants.
``trust_repo_config`` defaults to ``False`` — repo-level config
(``debug_config.json``, ``.octrc.json``) is untrusted by default per
blueprint §8.1. Set to ``True`` via ``OCT_TRUST_REPO_CONFIG=1`` or
the config file field ``trust_repo_config: true``.
"""
profile: str = "default"
"""Active security profile: ``"default"``, ``"strict"``, or ``"airgapped"``."""
rate_limit_per_minute: int = 30
"""Maximum tool calls per session per minute (Gateway rate limiter)."""
timeout_default_seconds: int = 60
"""Per-tool subprocess timeout in seconds (except ``oct test``)."""
timeout_test_seconds: int = 300
"""Subprocess timeout for ``oct test`` (pytest may need longer)."""
max_output_bytes: int = 1_048_576 # 1 MB
"""Hard output-size cap per tool call. Output exceeding this is truncated."""
max_jobs: int = 4
"""Maximum ``--jobs`` value forwarded to tools that support parallelism."""
auto_approve_read_tools: bool = True
"""If True, read-only tools execute without confirmation."""
dry_run_default_for_writes: bool = True
"""If True, write tools default to dry-run mode (Phase 5B guard)."""
redaction_always_on: bool = False
"""Force redaction even when not in production mode."""
audit_log_path: str = str(Path.home() / ".oct-mcp" / "audit.log")
"""Path to the JSONL audit log. Rotation files go in the same directory."""
audit_log_max_files: int = 20
"""Maximum number of rotated audit log files to keep."""
audit_log_max_size_mb: float = 5.0
"""Rotation threshold per audit file, in megabytes."""
trust_repo_config: bool = False
"""Whether to load and trust repo-level config (``.octrc.json``)."""
safe_mode: bool = False
"""Hard-override: all tools return error; set via ``OCT_MCP_SAFE_MODE=1``."""
sandbox_backend: str = "auto"
"""OS-level sandbox backend: ``"auto"``, ``"native"``, ``"bubblewrap"``,
or ``"sandbox_exec"``. ``"auto"`` picks the best available backend for
the current platform, falling back to ``"native"`` if no enhanced backend
is found. Set via ``OCT_MCP_SANDBOX`` env var."""
memory_limit_mb: int = 512
"""Per-subprocess memory limit in megabytes (POSIX only; 0 = disabled).
On Windows this field is accepted but has no effect — use Docker for
memory enforcement on Windows. Range: 0–8192."""
metrics_port: int | None = None
"""If set, starts a Prometheus ``/metrics`` HTTP server on this port
in a background thread. Requires ``oct[metrics]`` (prometheus-client).
``None`` (default) disables metrics collection."""
policy_source: str | None = None
"""If set, load additional policy overrides from this source at startup.
Accepts a local filesystem path or an ``https://`` URL. Overrides are
merged on top of the base config. Any load failure falls back to
built-in defaults silently."""
# ---------------------------------------------------------------------
# Loader
# ---------------------------------------------------------------------
[docs]
def load_config(path: Path | None = None) -> McpConfig:
"""Load and return a :class:`McpConfig`.
Resolution order:
1. Read ``~/.oct-mcp/config.json`` (or *path* if supplied).
2. Validate and clamp JSON field values.
3. Apply environment variable overrides:
- ``OCT_MCP_SAFE_MODE=1`` → ``safe_mode = True``
- ``OCT_TRUST_REPO_CONFIG=1`` → ``trust_repo_config = True``
- ``OCT_MCP_PROFILE=<name>`` → ``profile = <name>``
4. Return a fully-populated ``McpConfig``.
Never raises. Any read or parse failure falls back to the default
config and logs an error at level 1.
"""
func = "load_config"
cfg_path = path or _DEFAULT_CONFIG_PATH
config = McpConfig()
if cfg_path.exists():
try:
size = cfg_path.stat().st_size
if size > _CONFIG_MAX_BYTES:
_dbg(
"MCP", func,
f"SYSTEM_ERROR: config file too large ({size} bytes > "
f"{_CONFIG_MAX_BYTES}); using defaults",
1,
)
else:
raw = json.loads(cfg_path.read_text(encoding="utf-8"))
if isinstance(raw, dict):
config = _apply_json(config, raw)
_dbg("MCP", func, f"loaded config from {cfg_path}", 2)
else:
_dbg(
"MCP", func,
"SYSTEM_ERROR: config file is not a JSON object; using defaults",
1,
)
except (OSError, json.JSONDecodeError, ValueError) as exc:
_dbg("MCP", func, f"SYSTEM_ERROR: cannot read config: {exc}", 1)
else:
_dbg("MCP", func, f"no config file at {cfg_path}; using defaults", 4)
# Environment variable overrides (always trusted — set by user/CI).
if os.environ.get("OCT_MCP_SAFE_MODE", "").strip() == "1":
config.safe_mode = True
_dbg("MCP", func, "safe_mode=True (OCT_MCP_SAFE_MODE=1)", 2)
if os.environ.get("OCT_TRUST_REPO_CONFIG", "").strip() == "1":
config.trust_repo_config = True
_dbg("MCP", func, "trust_repo_config=True (OCT_TRUST_REPO_CONFIG=1)", 2)
profile_env = os.environ.get("OCT_MCP_PROFILE", "").strip()
if profile_env in VALID_MCP_PROFILES:
config.profile = profile_env
_dbg("MCP", func, f"profile={config.profile} (OCT_MCP_PROFILE)", 2)
sandbox_env = os.environ.get("OCT_MCP_SANDBOX", "").strip()
if sandbox_env in VALID_SANDBOX_BACKENDS:
config.sandbox_backend = sandbox_env
_dbg("MCP", func, f"sandbox_backend={config.sandbox_backend} (OCT_MCP_SANDBOX)", 2)
return config
def _apply_json(base: McpConfig, data: dict) -> McpConfig:
"""Return a new :class:`McpConfig` with fields from *data* merged in.
Only known fields are applied; unknown keys are silently ignored
(the config file is user-controlled but we don't crash on new keys).
Field values are clamped to the valid ranges defined by the module
constants. Booleans must be actual JSON booleans, not strings.
"""
def _clamp(val: int | float, lo: int | float, hi: int | float):
return max(lo, min(hi, val))
fields: dict = {}
if "profile" in data and data["profile"] in VALID_MCP_PROFILES:
fields["profile"] = data["profile"]
if "rate_limit_per_minute" in data:
v = data["rate_limit_per_minute"]
if isinstance(v, int):
fields["rate_limit_per_minute"] = _clamp(v, _RATE_LIMIT_MIN, _RATE_LIMIT_MAX)
if "timeout_default_seconds" in data:
v = data["timeout_default_seconds"]
if isinstance(v, int):
fields["timeout_default_seconds"] = _clamp(v, _TIMEOUT_MIN, _TIMEOUT_MAX)
if "timeout_test_seconds" in data:
v = data["timeout_test_seconds"]
if isinstance(v, int):
fields["timeout_test_seconds"] = _clamp(v, _TIMEOUT_MIN, _TIMEOUT_TEST_MAX)
if "max_output_bytes" in data:
v = data["max_output_bytes"]
if isinstance(v, int):
fields["max_output_bytes"] = _clamp(v, _MAX_OUTPUT_MIN, _MAX_OUTPUT_MAX)
if "max_jobs" in data:
v = data["max_jobs"]
if isinstance(v, int):
fields["max_jobs"] = _clamp(v, _MAX_JOBS_MIN, _MAX_JOBS_MAX)
for bool_field in (
"auto_approve_read_tools",
"dry_run_default_for_writes",
"redaction_always_on",
"trust_repo_config",
):
if bool_field in data and isinstance(data[bool_field], bool):
fields[bool_field] = data[bool_field]
if "audit_log_path" in data and isinstance(data["audit_log_path"], str):
fields["audit_log_path"] = data["audit_log_path"]
if "audit_log_max_files" in data:
v = data["audit_log_max_files"]
if isinstance(v, int) and v >= 1:
fields["audit_log_max_files"] = min(v, 100)
if "audit_log_max_size_mb" in data:
v = data["audit_log_max_size_mb"]
if isinstance(v, (int, float)) and v > 0:
fields["audit_log_max_size_mb"] = min(float(v), 100.0)
if "sandbox_backend" in data and data["sandbox_backend"] in VALID_SANDBOX_BACKENDS:
fields["sandbox_backend"] = data["sandbox_backend"]
if "memory_limit_mb" in data:
v = data["memory_limit_mb"]
if isinstance(v, int):
fields["memory_limit_mb"] = _clamp(v, _MEMORY_LIMIT_MIN, _MEMORY_LIMIT_MAX)
if "metrics_port" in data:
v = data["metrics_port"]
if v is None:
fields["metrics_port"] = None
elif isinstance(v, int):
fields["metrics_port"] = _clamp(v, _METRICS_PORT_MIN, _METRICS_PORT_MAX)
if "policy_source" in data:
v = data["policy_source"]
if v is None or isinstance(v, str):
fields["policy_source"] = v or None
from dataclasses import replace as _replace
return _replace(base, **fields)