mcp-server-framework 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. mcp_server_factory/__init__.py +11 -0
  2. mcp_server_factory/commands.py +80 -0
  3. mcp_server_factory/factory.py +113 -0
  4. mcp_server_factory/loader.py +10 -0
  5. mcp_server_factory/plugins/__init__.py +1 -0
  6. mcp_server_factory/plugins/logging.py +121 -0
  7. mcp_server_factory/plugins/management.py +52 -0
  8. mcp_server_factory/py.typed +0 -0
  9. mcp_server_factory/tracker.py +8 -0
  10. mcp_server_framework/__init__.py +36 -0
  11. mcp_server_framework/config.py +109 -0
  12. mcp_server_framework/health.py +153 -0
  13. mcp_server_framework/logging.py +60 -0
  14. mcp_server_framework/oauth.py +179 -0
  15. mcp_server_framework/plugins/__init__.py +16 -0
  16. mcp_server_framework/plugins/loader.py +205 -0
  17. mcp_server_framework/plugins/models.py +21 -0
  18. mcp_server_framework/plugins/tracker.py +160 -0
  19. mcp_server_framework/py.typed +0 -0
  20. mcp_server_framework/server.py +116 -0
  21. mcp_server_framework/utils/__init__.py +1 -0
  22. mcp_server_framework-1.1.0.dist-info/METADATA +429 -0
  23. mcp_server_framework-1.1.0.dist-info/RECORD +38 -0
  24. mcp_server_framework-1.1.0.dist-info/WHEEL +5 -0
  25. mcp_server_framework-1.1.0.dist-info/entry_points.txt +3 -0
  26. mcp_server_framework-1.1.0.dist-info/licenses/LICENSE +21 -0
  27. mcp_server_framework-1.1.0.dist-info/top_level.txt +3 -0
  28. mcp_server_proxy/__init__.py +11 -0
  29. mcp_server_proxy/cli.py +54 -0
  30. mcp_server_proxy/client.py +74 -0
  31. mcp_server_proxy/commands.py +21 -0
  32. mcp_server_proxy/management.py +129 -0
  33. mcp_server_proxy/plugins/__init__.py +0 -0
  34. mcp_server_proxy/plugins/management.py +136 -0
  35. mcp_server_proxy/proxy.py +237 -0
  36. mcp_server_proxy/py.typed +0 -0
  37. mcp_server_proxy/serve.py +118 -0
  38. mcp_server_proxy/tool_log.py +104 -0
@@ -0,0 +1,11 @@
1
+ """mcp_server_factory — Builds MCP servers from tool modules.
2
+
3
+ Everything is a plugin. External tools and internal management
4
+ commands use the same interface: register(mcp, config).
5
+ """
6
+
7
+ __version__ = "1.1.0"
8
+
9
+ from .factory import Factory
10
+
11
+ __all__ = ["Factory"]
@@ -0,0 +1,80 @@
1
+ """CLI — Entry point for the MCP Server Factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from mcp_server_framework import load_config, create_server, run_server, start_health_server
11
+ from mcp_server_framework.plugins.loader import add_plugin_dir
12
+ from mcp_server_framework.plugins.tracker import set_log_callback
13
+ from .factory import Factory
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def parse_args() -> argparse.Namespace:
19
+ parser = argparse.ArgumentParser(prog="mcp-factory", description="MCP Server Factory — loads tool modules as plugins.")
20
+ parser.add_argument("--plugins", "-p", nargs="+", default=[], help="Plugin names to load")
21
+ parser.add_argument("--config", "-c", type=Path, default=None, help="YAML config file")
22
+ parser.add_argument("--http", type=int, metavar="PORT", help="HTTP transport on given port")
23
+ parser.add_argument("--health-port", type=int, metavar="PORT", help="Health endpoint port")
24
+ parser.add_argument(
25
+ "--plugin-dir", "-d", type=Path, action="append", default=[],
26
+ help="Additional plugin search directory (can be repeated)",
27
+ )
28
+ return parser.parse_args()
29
+
30
+
31
+ def main() -> None:
32
+ args = parse_args()
33
+ config = load_config(args.config)
34
+ if args.http:
35
+ config["transport"] = "http"
36
+ config["port"] = args.http
37
+ if args.health_port:
38
+ config["health_port"] = args.health_port
39
+ if config.get("server_name") == "MCP Server":
40
+ config["server_name"] = "MCP Factory"
41
+
42
+ from mcp_server_framework import setup_logging
43
+ setup_logging(
44
+ level=config.get("log_level", "INFO"),
45
+ json_format=config.get("log_format") == "json",
46
+ )
47
+
48
+ plugin_names = args.plugins or config.get("plugins_load", [])
49
+ plugins_config = config.get("plugins", {})
50
+ if not plugin_names and isinstance(plugins_config, dict):
51
+ plugin_names = [n for n, c in plugins_config.items() if isinstance(c, dict) and c.get("enabled", True)]
52
+ if not plugin_names:
53
+ print("Error: No plugins specified (use --plugins or config)")
54
+ sys.exit(1)
55
+
56
+ # Set up plugin infrastructure
57
+ add_plugin_dir(Path(__file__).parent.parent.parent / "plugins")
58
+ for plugin_dir in args.plugin_dir:
59
+ add_plugin_dir(plugin_dir.resolve())
60
+ from .plugins.logging import log_settings
61
+ set_log_callback(log_settings.log_call)
62
+
63
+ mcp = create_server(config)
64
+ factory = Factory(mcp, config)
65
+ config["_factory"] = factory
66
+ factory.load_internals()
67
+ loaded = factory.load_externals(plugin_names)
68
+ if not loaded:
69
+ logger.error("No plugins loaded successfully — aborting")
70
+ sys.exit(1)
71
+ logger.info("%d plugin(s) loaded: %s", len(loaded), ", ".join(loaded))
72
+
73
+ if config.get("transport") != "stdio":
74
+ start_health_server(port=config["health_port"], title=f"{config['server_name']} Health")
75
+
76
+ run_server(mcp, config)
77
+
78
+
79
+ if __name__ == "__main__":
80
+ main()
@@ -0,0 +1,113 @@
1
+ """Factory — Core orchestrator for plugin loading and management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import datetime
7
+ from types import ModuleType
8
+ from typing import Any
9
+
10
+ from mcp.server.fastmcp import FastMCP
11
+ from mcp_server_framework.plugins import LoadedPlugin, load_module, find_register, ToolTracker
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class Factory:
17
+ """Plugin-based MCP server factory."""
18
+
19
+ def __init__(self, mcp: FastMCP, config: dict[str, Any]):
20
+ self.mcp = mcp
21
+ self.config = config
22
+ self.plugins: dict[str, LoadedPlugin] = {}
23
+ self._all_tools: set[str] = set()
24
+ self._all_resources: set[str] = set()
25
+ self._all_prompts: set[str] = set()
26
+
27
+ def load_internals(self) -> None:
28
+ """Load internal factory plugins (management, logging)."""
29
+ from .plugins import management, logging as factory_logging
30
+ internal_config = {"_factory": self}
31
+ self._load_module(management, "factory_management", internal_config, internal=True)
32
+ self._load_module(factory_logging, "factory_logging", internal_config, internal=True)
33
+
34
+ def load_externals(self, plugin_names: list[str]) -> list[str]:
35
+ """Load external plugins by name. Returns list of successfully loaded names."""
36
+ loaded = []
37
+ plugins_config = self.config.get("plugins", {})
38
+ for name in plugin_names:
39
+ plugin_config = plugins_config.get(name, {})
40
+ if isinstance(plugin_config, dict) and not plugin_config.get("enabled", True):
41
+ logger.info("Plugin '%s' disabled in config, skipping", name)
42
+ continue
43
+ module = load_module(name, self.config)
44
+ if module is None:
45
+ logger.error("Plugin '%s' not found", name)
46
+ continue
47
+ if self._load_module(module, name, plugin_config, internal=False):
48
+ loaded.append(name)
49
+ return loaded
50
+
51
+ def _load_module(self, module: ModuleType, name: str, plugin_config: dict, internal: bool) -> bool:
52
+ """Load a single module: find register(), track tools, check collisions."""
53
+ register_fn = find_register(module)
54
+ if register_fn is None:
55
+ logger.error("Plugin '%s' has no register(mcp, config) function", name)
56
+ return False
57
+ tracker = ToolTracker(self.mcp)
58
+ try:
59
+ register_fn(tracker, plugin_config)
60
+ except Exception as e:
61
+ logger.error("Plugin '%s' register() failed: %s", name, e)
62
+ return False
63
+ new_tools = tracker.registered_tools
64
+ new_resources = tracker.registered_resources
65
+ new_prompts = tracker.registered_prompts
66
+ collisions = self._all_tools & set(new_tools)
67
+ if collisions:
68
+ for tool_name in collisions:
69
+ owner = self._find_tool_owner(tool_name)
70
+ logger.error("Tool collision: '%s' already in plugin '%s', cannot load '%s'", tool_name, owner, name)
71
+ return False
72
+ res_collisions = self._all_resources & set(new_resources)
73
+ if res_collisions:
74
+ logger.error("Resource collision: %s, cannot load '%s'", res_collisions, name)
75
+ return False
76
+ prompt_collisions = self._all_prompts & set(new_prompts)
77
+ if prompt_collisions:
78
+ logger.error("Prompt collision: %s, cannot load '%s'", prompt_collisions, name)
79
+ return False
80
+ self._all_tools.update(new_tools)
81
+ self._all_resources.update(new_resources)
82
+ self._all_prompts.update(new_prompts)
83
+ self.plugins[name] = LoadedPlugin(
84
+ name=name, module=module, tools=new_tools,
85
+ resources=new_resources, prompts=new_prompts,
86
+ loaded_at=datetime.now(), config=plugin_config, internal=internal,
87
+ )
88
+ label = "internal" if internal else "external"
89
+ logger.info("Plugin '%s' loaded (%s): %d tools", name, label, len(new_tools))
90
+ return True
91
+
92
+ def _find_tool_owner(self, tool_name: str) -> str:
93
+ for plugin in self.plugins.values():
94
+ if tool_name in plugin.tools:
95
+ return plugin.name
96
+ return "unknown"
97
+
98
+ def get_plugin_summary(self) -> dict[str, Any]:
99
+ return {
100
+ "total_tools": len(self._all_tools),
101
+ "total_resources": len(self._all_resources),
102
+ "total_prompts": len(self._all_prompts),
103
+ "total_plugins": len(self.plugins),
104
+ "plugins": {
105
+ name: {
106
+ "tools": p.tools, "tool_count": len(p.tools),
107
+ "resources": p.resources, "resource_count": len(p.resources),
108
+ "prompts": p.prompts, "prompt_count": len(p.prompts),
109
+ "internal": p.internal, "loaded_at": p.loaded_at.isoformat(),
110
+ }
111
+ for name, p in self.plugins.items()
112
+ },
113
+ }
@@ -0,0 +1,10 @@
1
+ """Loader — Re-export from framework for backwards compatibility."""
2
+
3
+ from mcp_server_framework.plugins.loader import (
4
+ load_module,
5
+ find_register,
6
+ set_plugin_dirs,
7
+ add_plugin_dir,
8
+ )
9
+
10
+ __all__ = ["load_module", "find_register", "set_plugin_dirs", "add_plugin_dir"]
@@ -0,0 +1 @@
1
+ """Internal factory plugins."""
@@ -0,0 +1,121 @@
1
+ """Logging plugin — factory__log, factory__transcript."""
2
+
3
+ from __future__ import annotations
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ LOG_DIR = Path.home() / ".mcp_factory"
9
+ DEFAULT_LOG_FILE = LOG_DIR / "factory.log"
10
+ TRANSCRIPT_DIR = LOG_DIR / "transcripts"
11
+ MAX_LOG_SIZE = 5 * 1024 * 1024
12
+ LOG_BACKUP_COUNT = 3
13
+ MAX_TRANSCRIPT_SIZE = 10 * 1024 * 1024
14
+
15
+
16
+ class LogSettings:
17
+ def __init__(self):
18
+ self.log_enabled: bool = False
19
+ self.log_file: Optional[Path] = None
20
+ self.transcript_enabled: bool = False
21
+ self.transcript_file: Optional[Path] = None
22
+
23
+ def set_logging(self, enabled: bool) -> str:
24
+ if enabled:
25
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
26
+ self.log_file = DEFAULT_LOG_FILE
27
+ self.log_enabled = True
28
+ return f"Log ON → {self.log_file}"
29
+ self.log_enabled = False
30
+ self.log_file = None
31
+ return "Log OFF"
32
+
33
+ def start_transcript(self) -> str:
34
+ TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)
35
+ now = datetime.now()
36
+ filename = now.strftime("%Y-%m-%d-%H-%M-%S.md")
37
+ self.transcript_file = TRANSCRIPT_DIR / filename
38
+ self.transcript_enabled = True
39
+ header = f"# MCP Factory Transcript\n**Started:** {now.strftime('%Y-%m-%d %H:%M:%S')}\n\n---\n\n"
40
+ self.transcript_file.write_text(header, encoding="utf-8")
41
+ return f"Transcript ON → {self.transcript_file}"
42
+
43
+ def stop_transcript(self) -> str:
44
+ if not self.transcript_enabled:
45
+ return "No active transcript"
46
+ if self.transcript_file and self.transcript_file.exists():
47
+ with open(self.transcript_file, "a", encoding="utf-8") as f:
48
+ f.write(f"\n---\n**Ended:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
49
+ result = f"Transcript OFF (saved: {self.transcript_file})"
50
+ self.transcript_enabled = False
51
+ self.transcript_file = None
52
+ return result
53
+
54
+ def log_call(self, tool: str, params: dict, result: str, success: bool) -> None:
55
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
56
+ status = "OK" if success else "FAIL"
57
+ if self.log_enabled and self.log_file:
58
+ params_str = str(params)[:100] + ("..." if len(str(params)) > 100 else "")
59
+ try:
60
+ self._rotate_if_needed()
61
+ with open(self.log_file, "a", encoding="utf-8") as f:
62
+ f.write(f"[{timestamp}] {status} {tool} {params_str}\n")
63
+ except Exception:
64
+ pass
65
+ if self.transcript_enabled and self.transcript_file:
66
+ self._write_transcript(timestamp, tool, params, result, success)
67
+
68
+ def _rotate_if_needed(self) -> None:
69
+ if not self.log_file or not self.log_file.exists():
70
+ return
71
+ if self.log_file.stat().st_size < MAX_LOG_SIZE:
72
+ return
73
+ for i in range(LOG_BACKUP_COUNT, 0, -1):
74
+ old = self.log_file.with_suffix(f".log.{i}")
75
+ if old.exists():
76
+ if i == LOG_BACKUP_COUNT:
77
+ old.unlink()
78
+ else:
79
+ old.rename(self.log_file.with_suffix(f".log.{i + 1}"))
80
+ self.log_file.rename(self.log_file.with_suffix(".log.1"))
81
+
82
+ def _write_transcript(self, timestamp, tool, params, result, success) -> None:
83
+ if not self.transcript_file:
84
+ return
85
+ if self.transcript_file.exists() and self.transcript_file.stat().st_size > MAX_TRANSCRIPT_SIZE:
86
+ self.stop_transcript()
87
+ self.start_transcript()
88
+ status_emoji = "✓" if success else "✗"
89
+ params_fmt = "\n".join(f" {k}: {v}" for k, v in params.items()) if params else " (none)"
90
+ result_text = result[:50000] + f"\n\n... (truncated, {len(result)} chars)" if len(result) > 50000 else result
91
+ entry = f"\n## [{timestamp}] {status_emoji} `{tool}`\n\n**Parameters:**\n{params_fmt}\n\n**Result:**\n```\n{result_text}\n```\n\n---\n"
92
+ try:
93
+ with open(self.transcript_file, "a", encoding="utf-8") as f:
94
+ f.write(entry)
95
+ except Exception:
96
+ pass
97
+
98
+
99
+ log_settings = LogSettings()
100
+
101
+
102
+ def register(mcp, config: dict[str, Any]) -> None:
103
+ @mcp.tool()
104
+ def factory__log(mode: str = "status") -> str:
105
+ """Control tool call logging. Args: mode: 'on', 'off', or 'status'"""
106
+ if mode == "on":
107
+ return log_settings.set_logging(True)
108
+ elif mode == "off":
109
+ return log_settings.set_logging(False)
110
+ return f"Log {'ON → ' + str(log_settings.log_file) if log_settings.log_enabled else 'OFF'}"
111
+
112
+ @mcp.tool()
113
+ def factory__transcript(mode: str = "status") -> str:
114
+ """Control full transcript recording. Args: mode: 'on', 'off', or 'status'"""
115
+ if mode == "on":
116
+ if log_settings.transcript_enabled:
117
+ return f"Transcript already active: {log_settings.transcript_file}"
118
+ return log_settings.start_transcript()
119
+ elif mode == "off":
120
+ return log_settings.stop_transcript()
121
+ return f"Transcript {'ON → ' + str(log_settings.transcript_file) if log_settings.transcript_enabled else 'OFF'}"
@@ -0,0 +1,52 @@
1
+ """Management plugin — factory__status, factory__list."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Any
5
+ from mcp_server_factory import __version__
6
+
7
+ _factory = None
8
+
9
+
10
+ def register(mcp, config: dict[str, Any]) -> None:
11
+ global _factory
12
+ _factory = config.get("_factory")
13
+
14
+ @mcp.tool()
15
+ def factory__status() -> str:
16
+ """Show factory status: version, transport, plugins, tools."""
17
+ if _factory is None:
18
+ return "Factory reference not available"
19
+ summary = _factory.get_plugin_summary()
20
+ fc = _factory.config
21
+ lines = [
22
+ f"MCP Factory v{__version__}",
23
+ f" Server: {fc.get('server_name', 'MCP Factory')}",
24
+ f" Transport: {fc.get('transport', 'stdio')}",
25
+ ]
26
+ if fc.get("transport") != "stdio":
27
+ lines.append(f" Port: {fc.get('port', 'n/a')}")
28
+ lines.append(f" Health: {fc.get('health_port', 'n/a')}")
29
+ lines.append(f" Plugins: {summary['total_plugins']} loaded")
30
+ lines.append(f" Tools: {summary['total_tools']} registered")
31
+ from .logging import log_settings
32
+ lines.append(f" Log: {'ON' if log_settings.log_enabled else 'OFF'}")
33
+ lines.append(f" Transcript: {'ON' if log_settings.transcript_enabled else 'OFF'}")
34
+ return "\n".join(lines)
35
+
36
+ @mcp.tool()
37
+ def factory__list() -> str:
38
+ """List loaded plugins and their tools."""
39
+ if _factory is None:
40
+ return "Factory reference not available"
41
+ summary = _factory.get_plugin_summary()
42
+ lines = ["Loaded plugins:\n"]
43
+ for name, info in summary["plugins"].items():
44
+ label = " (internal)" if info["internal"] else ""
45
+ tools_str = ", ".join(info["tools"][:5])
46
+ if len(info["tools"]) > 5:
47
+ tools_str += f", ... (+{len(info['tools']) - 5})"
48
+ lines.append(f" {name:<20} {info['tool_count']:>3} tools{label}")
49
+ lines.append(f" [{tools_str}]")
50
+ lines.append(f" {'─' * 40}")
51
+ lines.append(f" Total: {summary['total_tools']} tools, {summary['total_plugins']} plugins")
52
+ return "\n".join(lines)
File without changes
@@ -0,0 +1,8 @@
1
+ """ToolTracker — Re-export from framework for backwards compatibility.
2
+
3
+ The Factory hooks into the tracker's logging via set_log_callback().
4
+ """
5
+
6
+ from mcp_server_framework.plugins.tracker import ToolTracker, set_log_callback
7
+
8
+ __all__ = ["ToolTracker", "set_log_callback"]
@@ -0,0 +1,36 @@
1
+ """mcp_server_framework — Shared framework for MCP servers.
2
+
3
+ Provides transport, health monitoring, configuration and plugin infrastructure.
4
+ Imported as a library — by standalone MCP servers, the Factory and the Proxy.
5
+
6
+ Public API:
7
+ load_config(path) → Config from YAML + ENV
8
+ create_server(config) → FastMCP instance
9
+ run_server(mcp, config) → Start server (stdio or HTTP)
10
+ start_health_server(port, ...) → Start health thread
11
+ create_health_app(...) → Create health app directly
12
+
13
+ Plugin API (via mcp_server_framework.plugins):
14
+ LoadedPlugin → Plugin record dataclass
15
+ load_module(name, config) → Resolve and import plugin
16
+ find_register(module) → Find register() function
17
+ ToolTracker(mcp) → Proxy that tracks tool registrations
18
+ """
19
+
20
+ __version__ = "1.1.0"
21
+
22
+ from .config import load_config
23
+ from .server import create_server, run_server
24
+ from .health import start_health_server, create_health_app
25
+ from .logging import setup_logging
26
+ from .oauth import IntrospectionTokenVerifier
27
+
28
+ __all__ = [
29
+ "load_config",
30
+ "create_server",
31
+ "run_server",
32
+ "start_health_server",
33
+ "create_health_app",
34
+ "setup_logging",
35
+ "IntrospectionTokenVerifier",
36
+ ]
@@ -0,0 +1,109 @@
1
+ """Configuration — YAML + environment variables.
2
+
3
+ Three-tier merge: Defaults → YAML → ENV (last wins).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import copy
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _parse_bool(value: str) -> bool:
18
+ """Parse bool from ENV string."""
19
+ return value.lower() in ("true", "1", "yes", "on")
20
+
21
+
22
+ # Immutable defaults — mutable values (tools) are
23
+ # deep-copied in load_config() for each call.
24
+ _DEFAULTS: dict[str, Any] = {
25
+ "server_name": "MCP Server",
26
+ "version": "0.1.0",
27
+ "instructions": "",
28
+ "service_type": "generic",
29
+ "log_level": "INFO",
30
+ # Transport
31
+ "host": "0.0.0.0",
32
+ "port": 12201,
33
+ "transport": "stdio",
34
+ # Health
35
+ "health_port": None, # Default: port + 1
36
+ # OAuth (enabled by default for HTTP, ignored for stdio)
37
+ "oauth_enabled": True,
38
+ "oauth_server_url": None,
39
+ "oauth_public_url": None,
40
+ # Tool names (for registry/monitoring)
41
+ "tools": [],
42
+ }
43
+
44
+ # ENV key → (config key, type cast)
45
+ _ENV_MAPPING: dict[str, tuple[str, type]] = {
46
+ "MCP_SERVER_NAME": ("server_name", str),
47
+ "MCP_LOG_LEVEL": ("log_level", str),
48
+ "MCP_HOST": ("host", str),
49
+ "MCP_PORT": ("port", int),
50
+ "MCP_HEALTH_PORT": ("health_port", int),
51
+ "MCP_TRANSPORT": ("transport", str),
52
+ "MCP_OAUTH_ENABLED": ("oauth_enabled", _parse_bool),
53
+ "MCP_OAUTH_SERVER_URL": ("oauth_server_url", str),
54
+ "MCP_PUBLIC_URL": ("oauth_public_url", str),
55
+ }
56
+
57
+
58
+ def load_config(
59
+ config_path: Path | None = None,
60
+ ) -> dict[str, Any]:
61
+ """Load configuration: Defaults → YAML → ENV.
62
+
63
+ Args:
64
+ config_path: Optional path to YAML file.
65
+
66
+ Returns:
67
+ Merged config dict (own copy, safe to mutate).
68
+ """
69
+ config = copy.deepcopy(_DEFAULTS)
70
+
71
+ if config_path and config_path.exists():
72
+ _load_yaml(config, config_path)
73
+
74
+ _apply_env(config)
75
+
76
+ # Computed defaults
77
+ if config["health_port"] is None:
78
+ config["health_port"] = config["port"] + 1
79
+
80
+ return config
81
+
82
+
83
+ def _load_yaml(
84
+ config: dict[str, Any],
85
+ path: Path,
86
+ ) -> None:
87
+ """Load YAML file into config dict."""
88
+ try:
89
+ import yaml
90
+ except ImportError:
91
+ logger.warning("PyYAML not installed, YAML config ignored")
92
+ return
93
+
94
+ with open(path) as f:
95
+ yaml_config = yaml.safe_load(f) or {}
96
+ config.update(yaml_config)
97
+ logger.info("Config loaded: %s", path)
98
+
99
+
100
+ def _apply_env(config: dict[str, Any]) -> None:
101
+ """Apply ENV overrides to config."""
102
+ for env_key, (config_key, cast) in _ENV_MAPPING.items():
103
+ value = os.getenv(env_key)
104
+ if value is None:
105
+ continue
106
+ try:
107
+ config[config_key] = cast(value)
108
+ except (ValueError, TypeError):
109
+ logger.warning("ENV %s=%r: invalid value", env_key, value)