mcp-hangar 0.2.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 (160) hide show
  1. mcp_hangar/__init__.py +139 -0
  2. mcp_hangar/application/__init__.py +1 -0
  3. mcp_hangar/application/commands/__init__.py +67 -0
  4. mcp_hangar/application/commands/auth_commands.py +118 -0
  5. mcp_hangar/application/commands/auth_handlers.py +296 -0
  6. mcp_hangar/application/commands/commands.py +59 -0
  7. mcp_hangar/application/commands/handlers.py +189 -0
  8. mcp_hangar/application/discovery/__init__.py +21 -0
  9. mcp_hangar/application/discovery/discovery_metrics.py +283 -0
  10. mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
  11. mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
  12. mcp_hangar/application/discovery/security_validator.py +414 -0
  13. mcp_hangar/application/event_handlers/__init__.py +50 -0
  14. mcp_hangar/application/event_handlers/alert_handler.py +191 -0
  15. mcp_hangar/application/event_handlers/audit_handler.py +203 -0
  16. mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
  17. mcp_hangar/application/event_handlers/logging_handler.py +69 -0
  18. mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
  19. mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
  20. mcp_hangar/application/event_handlers/security_handler.py +604 -0
  21. mcp_hangar/application/mcp/tooling.py +158 -0
  22. mcp_hangar/application/ports/__init__.py +9 -0
  23. mcp_hangar/application/ports/observability.py +237 -0
  24. mcp_hangar/application/queries/__init__.py +52 -0
  25. mcp_hangar/application/queries/auth_handlers.py +237 -0
  26. mcp_hangar/application/queries/auth_queries.py +118 -0
  27. mcp_hangar/application/queries/handlers.py +227 -0
  28. mcp_hangar/application/read_models/__init__.py +11 -0
  29. mcp_hangar/application/read_models/provider_views.py +139 -0
  30. mcp_hangar/application/sagas/__init__.py +11 -0
  31. mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
  32. mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
  33. mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
  34. mcp_hangar/application/services/__init__.py +9 -0
  35. mcp_hangar/application/services/provider_service.py +208 -0
  36. mcp_hangar/application/services/traced_provider_service.py +211 -0
  37. mcp_hangar/bootstrap/runtime.py +328 -0
  38. mcp_hangar/context.py +178 -0
  39. mcp_hangar/domain/__init__.py +117 -0
  40. mcp_hangar/domain/contracts/__init__.py +57 -0
  41. mcp_hangar/domain/contracts/authentication.py +225 -0
  42. mcp_hangar/domain/contracts/authorization.py +229 -0
  43. mcp_hangar/domain/contracts/event_store.py +178 -0
  44. mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
  45. mcp_hangar/domain/contracts/persistence.py +383 -0
  46. mcp_hangar/domain/contracts/provider_runtime.py +146 -0
  47. mcp_hangar/domain/discovery/__init__.py +20 -0
  48. mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
  49. mcp_hangar/domain/discovery/discovered_provider.py +185 -0
  50. mcp_hangar/domain/discovery/discovery_service.py +412 -0
  51. mcp_hangar/domain/discovery/discovery_source.py +192 -0
  52. mcp_hangar/domain/events.py +433 -0
  53. mcp_hangar/domain/exceptions.py +525 -0
  54. mcp_hangar/domain/model/__init__.py +70 -0
  55. mcp_hangar/domain/model/aggregate.py +58 -0
  56. mcp_hangar/domain/model/circuit_breaker.py +152 -0
  57. mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
  58. mcp_hangar/domain/model/event_sourced_provider.py +423 -0
  59. mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
  60. mcp_hangar/domain/model/health_tracker.py +183 -0
  61. mcp_hangar/domain/model/load_balancer.py +185 -0
  62. mcp_hangar/domain/model/provider.py +810 -0
  63. mcp_hangar/domain/model/provider_group.py +656 -0
  64. mcp_hangar/domain/model/tool_catalog.py +105 -0
  65. mcp_hangar/domain/policies/__init__.py +19 -0
  66. mcp_hangar/domain/policies/provider_health.py +187 -0
  67. mcp_hangar/domain/repository.py +249 -0
  68. mcp_hangar/domain/security/__init__.py +85 -0
  69. mcp_hangar/domain/security/input_validator.py +710 -0
  70. mcp_hangar/domain/security/rate_limiter.py +387 -0
  71. mcp_hangar/domain/security/roles.py +237 -0
  72. mcp_hangar/domain/security/sanitizer.py +387 -0
  73. mcp_hangar/domain/security/secrets.py +501 -0
  74. mcp_hangar/domain/services/__init__.py +20 -0
  75. mcp_hangar/domain/services/audit_service.py +376 -0
  76. mcp_hangar/domain/services/image_builder.py +328 -0
  77. mcp_hangar/domain/services/provider_launcher.py +1046 -0
  78. mcp_hangar/domain/value_objects.py +1138 -0
  79. mcp_hangar/errors.py +818 -0
  80. mcp_hangar/fastmcp_server.py +1105 -0
  81. mcp_hangar/gc.py +134 -0
  82. mcp_hangar/infrastructure/__init__.py +79 -0
  83. mcp_hangar/infrastructure/async_executor.py +133 -0
  84. mcp_hangar/infrastructure/auth/__init__.py +37 -0
  85. mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
  86. mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
  87. mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
  88. mcp_hangar/infrastructure/auth/middleware.py +340 -0
  89. mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
  90. mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
  91. mcp_hangar/infrastructure/auth/projections.py +366 -0
  92. mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
  93. mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
  94. mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
  95. mcp_hangar/infrastructure/command_bus.py +112 -0
  96. mcp_hangar/infrastructure/discovery/__init__.py +110 -0
  97. mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
  98. mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
  99. mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
  100. mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
  101. mcp_hangar/infrastructure/event_bus.py +260 -0
  102. mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
  103. mcp_hangar/infrastructure/event_store.py +396 -0
  104. mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
  105. mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
  106. mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
  107. mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
  108. mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
  109. mcp_hangar/infrastructure/metrics_publisher.py +36 -0
  110. mcp_hangar/infrastructure/observability/__init__.py +10 -0
  111. mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
  112. mcp_hangar/infrastructure/persistence/__init__.py +33 -0
  113. mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
  114. mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
  115. mcp_hangar/infrastructure/persistence/database.py +333 -0
  116. mcp_hangar/infrastructure/persistence/database_common.py +330 -0
  117. mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
  118. mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
  119. mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
  120. mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
  121. mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
  122. mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
  123. mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
  124. mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
  125. mcp_hangar/infrastructure/query_bus.py +153 -0
  126. mcp_hangar/infrastructure/saga_manager.py +401 -0
  127. mcp_hangar/logging_config.py +209 -0
  128. mcp_hangar/metrics.py +1007 -0
  129. mcp_hangar/models.py +31 -0
  130. mcp_hangar/observability/__init__.py +54 -0
  131. mcp_hangar/observability/health.py +487 -0
  132. mcp_hangar/observability/metrics.py +319 -0
  133. mcp_hangar/observability/tracing.py +433 -0
  134. mcp_hangar/progress.py +542 -0
  135. mcp_hangar/retry.py +613 -0
  136. mcp_hangar/server/__init__.py +120 -0
  137. mcp_hangar/server/__main__.py +6 -0
  138. mcp_hangar/server/auth_bootstrap.py +340 -0
  139. mcp_hangar/server/auth_cli.py +335 -0
  140. mcp_hangar/server/auth_config.py +305 -0
  141. mcp_hangar/server/bootstrap.py +735 -0
  142. mcp_hangar/server/cli.py +161 -0
  143. mcp_hangar/server/config.py +224 -0
  144. mcp_hangar/server/context.py +215 -0
  145. mcp_hangar/server/http_auth_middleware.py +165 -0
  146. mcp_hangar/server/lifecycle.py +467 -0
  147. mcp_hangar/server/state.py +117 -0
  148. mcp_hangar/server/tools/__init__.py +16 -0
  149. mcp_hangar/server/tools/discovery.py +186 -0
  150. mcp_hangar/server/tools/groups.py +75 -0
  151. mcp_hangar/server/tools/health.py +301 -0
  152. mcp_hangar/server/tools/provider.py +939 -0
  153. mcp_hangar/server/tools/registry.py +320 -0
  154. mcp_hangar/server/validation.py +113 -0
  155. mcp_hangar/stdio_client.py +229 -0
  156. mcp_hangar-0.2.0.dist-info/METADATA +347 -0
  157. mcp_hangar-0.2.0.dist-info/RECORD +160 -0
  158. mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
  159. mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
  160. mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,112 @@
1
+ """
2
+ Command Bus - dispatches commands to their handlers.
3
+
4
+ Commands represent intent to change the system state.
5
+ Each command has exactly one handler.
6
+
7
+ Note: Command classes are defined in application.commands to maintain
8
+ proper layer separation (infrastructure should not define business commands).
9
+ """
10
+
11
+ from abc import ABC, abstractmethod
12
+ from typing import Any, Dict, Optional, Type, TYPE_CHECKING
13
+
14
+ from mcp_hangar.logging_config import get_logger
15
+
16
+ if TYPE_CHECKING:
17
+ from ..application.commands import Command
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ class CommandHandler(ABC):
23
+ """Base class for command handlers."""
24
+
25
+ @abstractmethod
26
+ def handle(self, command: "Command") -> Any:
27
+ """Handle the command and return result."""
28
+ pass
29
+
30
+
31
+ class CommandBus:
32
+ """
33
+ Dispatches commands to their registered handlers.
34
+
35
+ Each command type can have exactly one handler.
36
+ The bus is responsible for routing commands to the appropriate handler.
37
+ """
38
+
39
+ def __init__(self):
40
+ self._handlers: Dict[Type, CommandHandler] = {}
41
+
42
+ def register(self, command_type: Type, handler: CommandHandler) -> None:
43
+ """
44
+ Register a handler for a command type.
45
+
46
+ Args:
47
+ command_type: The type of command to handle
48
+ handler: The handler instance
49
+
50
+ Raises:
51
+ ValueError: If a handler is already registered for this command type
52
+ """
53
+ if command_type in self._handlers:
54
+ raise ValueError(f"Handler already registered for {command_type.__name__}")
55
+ self._handlers[command_type] = handler
56
+ logger.debug("command_handler_registered", command_type=command_type.__name__)
57
+
58
+ def unregister(self, command_type: Type) -> bool:
59
+ """
60
+ Unregister a handler for a command type.
61
+
62
+ Returns:
63
+ True if handler was removed, False if not found
64
+ """
65
+ if command_type in self._handlers:
66
+ del self._handlers[command_type]
67
+ return True
68
+ return False
69
+
70
+ def send(self, command: "Command") -> Any:
71
+ """
72
+ Send a command to its handler.
73
+
74
+ Args:
75
+ command: The command to execute
76
+
77
+ Returns:
78
+ The result from the handler
79
+
80
+ Raises:
81
+ ValueError: If no handler is registered for this command type
82
+ """
83
+ command_type = type(command)
84
+ handler = self._handlers.get(command_type)
85
+
86
+ if handler is None:
87
+ raise ValueError(f"No handler registered for {command_type.__name__}")
88
+
89
+ logger.debug("command_dispatching", command_type=command_type.__name__)
90
+ return handler.handle(command)
91
+
92
+ def has_handler(self, command_type: Type) -> bool:
93
+ """Check if a handler is registered for the command type."""
94
+ return command_type in self._handlers
95
+
96
+
97
+ # Global command bus instance
98
+ _command_bus: Optional[CommandBus] = None
99
+
100
+
101
+ def get_command_bus() -> CommandBus:
102
+ """Get the global command bus instance."""
103
+ global _command_bus
104
+ if _command_bus is None:
105
+ _command_bus = CommandBus()
106
+ return _command_bus
107
+
108
+
109
+ def reset_command_bus() -> None:
110
+ """Reset the global command bus (for testing)."""
111
+ global _command_bus
112
+ _command_bus = None
@@ -0,0 +1,110 @@
1
+ """Discovery infrastructure module.
2
+
3
+ This module contains infrastructure implementations for provider discovery
4
+ from various sources: Kubernetes, Docker, Filesystem, and Python entrypoints.
5
+
6
+ Note: Each source has optional dependencies. Import errors are handled gracefully.
7
+ """
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ # Lazy imports to handle optional dependencies
12
+ _kubernetes_source = None
13
+ _docker_source = None
14
+ _filesystem_source = None
15
+ _entrypoint_source = None
16
+
17
+
18
+ def _get_kubernetes_source():
19
+ """Get KubernetesDiscoverySource class, importing lazily."""
20
+ global _kubernetes_source
21
+ if _kubernetes_source is None:
22
+ try:
23
+ from .kubernetes_source import KubernetesDiscoverySource
24
+
25
+ _kubernetes_source = KubernetesDiscoverySource
26
+ except ImportError:
27
+ _kubernetes_source = None
28
+ return _kubernetes_source
29
+
30
+
31
+ def _get_docker_source():
32
+ """Get DockerDiscoverySource class, importing lazily."""
33
+ global _docker_source
34
+ if _docker_source is None:
35
+ try:
36
+ from .docker_source import DockerDiscoverySource
37
+
38
+ _docker_source = DockerDiscoverySource
39
+ except ImportError:
40
+ _docker_source = None
41
+ return _docker_source
42
+
43
+
44
+ def _get_filesystem_source():
45
+ """Get FilesystemDiscoverySource class, importing lazily."""
46
+ global _filesystem_source
47
+ if _filesystem_source is None:
48
+ try:
49
+ from .filesystem_source import FilesystemDiscoverySource
50
+
51
+ _filesystem_source = FilesystemDiscoverySource
52
+ except ImportError:
53
+ _filesystem_source = None
54
+ return _filesystem_source
55
+
56
+
57
+ def _get_entrypoint_source():
58
+ """Get EntrypointDiscoverySource class, importing lazily."""
59
+ global _entrypoint_source
60
+ if _entrypoint_source is None:
61
+ try:
62
+ from .entrypoint_source import EntrypointDiscoverySource
63
+
64
+ _entrypoint_source = EntrypointDiscoverySource
65
+ except ImportError:
66
+ _entrypoint_source = None
67
+ return _entrypoint_source
68
+
69
+
70
+ # For type checking and IDE support
71
+ if TYPE_CHECKING:
72
+ from .docker_source import DockerDiscoverySource
73
+ from .entrypoint_source import EntrypointDiscoverySource
74
+ from .filesystem_source import FilesystemDiscoverySource
75
+ from .kubernetes_source import KubernetesDiscoverySource
76
+
77
+
78
+ def __getattr__(name: str):
79
+ """Lazy attribute access for discovery sources."""
80
+ if name == "KubernetesDiscoverySource":
81
+ cls = _get_kubernetes_source()
82
+ if cls is None:
83
+ raise ImportError(
84
+ "KubernetesDiscoverySource requires 'kubernetes' package. Install with: pip install kubernetes"
85
+ )
86
+ return cls
87
+ elif name == "DockerDiscoverySource":
88
+ cls = _get_docker_source()
89
+ if cls is None:
90
+ raise ImportError("DockerDiscoverySource requires 'docker' package. Install with: pip install docker")
91
+ return cls
92
+ elif name == "FilesystemDiscoverySource":
93
+ cls = _get_filesystem_source()
94
+ if cls is None:
95
+ raise ImportError("FilesystemDiscoverySource requires 'pyyaml' package. Install with: pip install pyyaml")
96
+ return cls
97
+ elif name == "EntrypointDiscoverySource":
98
+ cls = _get_entrypoint_source()
99
+ if cls is None:
100
+ raise ImportError("EntrypointDiscoverySource requires 'importlib-metadata' package")
101
+ return cls
102
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
103
+
104
+
105
+ __all__ = [
106
+ "KubernetesDiscoverySource",
107
+ "DockerDiscoverySource",
108
+ "FilesystemDiscoverySource",
109
+ "EntrypointDiscoverySource",
110
+ ]
@@ -0,0 +1,289 @@
1
+ """Docker/Podman Discovery Source.
2
+
3
+ Discovers MCP providers from Docker/Podman containers using labels.
4
+ Uses the same Docker API - works with both Docker and Podman.
5
+
6
+ Socket Detection Order:
7
+ 1. Explicit socket_path parameter
8
+ 2. DOCKER_HOST environment variable
9
+ 3. macOS: ~/.local/share/containers/podman/machine/podman.sock
10
+ 4. macOS: /var/folders/.../podman/podman-machine-default-api.sock
11
+ 5. Linux: /run/user/{uid}/podman/podman.sock (rootless Podman)
12
+ 6. Linux/macOS: /var/run/docker.sock (Docker)
13
+
14
+ Label Reference:
15
+ mcp.hangar.enabled: "true" # Required - enables discovery
16
+ mcp.hangar.name: "my-provider" # Optional - defaults to container name
17
+ mcp.hangar.mode: "container" # Optional - container|http (default: container)
18
+ mcp.hangar.port: "8080" # For http mode only
19
+ mcp.hangar.group: "tools" # Optional - group membership
20
+ mcp.hangar.command: "python app.py" # Optional - override container command
21
+ mcp.hangar.volumes: "/data:/data" # Optional - additional volumes
22
+ """
23
+
24
+ import os
25
+ from pathlib import Path
26
+ import platform
27
+ from typing import List, Optional
28
+
29
+ from mcp_hangar.domain.discovery.discovered_provider import DiscoveredProvider
30
+ from mcp_hangar.domain.discovery.discovery_source import DiscoveryMode, DiscoverySource
31
+
32
+ from ...logging_config import get_logger
33
+
34
+ logger = get_logger(__name__)
35
+
36
+ # Optional Docker dependency (works with Podman too via Docker API compatibility)
37
+ try:
38
+ import docker
39
+ from docker.errors import DockerException
40
+
41
+ DOCKER_AVAILABLE = True
42
+ except ImportError:
43
+ DOCKER_AVAILABLE = False
44
+ DockerException = Exception
45
+ docker = None
46
+
47
+
48
+ # Well-known socket locations
49
+ SOCKET_PATHS = {
50
+ "docker": "/var/run/docker.sock",
51
+ "podman_linux": "/run/user/{uid}/podman/podman.sock",
52
+ "podman_macos_symlink": "~/.local/share/containers/podman/machine/podman.sock",
53
+ "podman_macos_glob": "/var/folders/*/*/T/podman/podman-machine-default-api.sock",
54
+ }
55
+
56
+
57
+ def find_container_socket() -> Optional[str]:
58
+ """Find Docker or Podman socket.
59
+
60
+ Returns:
61
+ Socket path or None if not found
62
+ """
63
+ # 1. Check DOCKER_HOST env var
64
+ docker_host = os.environ.get("DOCKER_HOST")
65
+ if docker_host and docker_host.startswith("unix://"):
66
+ socket_path = docker_host[7:] # Remove "unix://"
67
+ if Path(socket_path).exists():
68
+ return socket_path
69
+
70
+ # 2. Platform-specific detection
71
+ if platform.system() == "Darwin":
72
+ # macOS: Check Podman Machine symlink first
73
+ podman_symlink = Path.home() / ".local/share/containers/podman/machine/podman.sock"
74
+ if podman_symlink.exists():
75
+ try:
76
+ resolved = podman_symlink.resolve()
77
+ if resolved.exists():
78
+ return str(resolved)
79
+ except (OSError, RuntimeError):
80
+ pass
81
+
82
+ # macOS: Search in /var/folders for Podman socket
83
+ import glob
84
+
85
+ for pattern in [
86
+ "/var/folders/*/*/T/podman/podman-machine-default-api.sock",
87
+ "/var/folders/*/*/T/podman/podman-machine-default.sock",
88
+ ]:
89
+ for match in glob.glob(pattern):
90
+ if Path(match).exists():
91
+ return match
92
+
93
+ # 3. Linux: Check Podman rootless socket
94
+ uid = os.getuid()
95
+ podman_socket = f"/run/user/{uid}/podman/podman.sock"
96
+ if Path(podman_socket).exists():
97
+ return podman_socket
98
+
99
+ # 4. Fallback: Docker socket
100
+ if Path(SOCKET_PATHS["docker"]).exists():
101
+ return SOCKET_PATHS["docker"]
102
+
103
+ return None
104
+
105
+
106
+ class DockerDiscoverySource(DiscoverySource):
107
+ """Discover MCP providers from Docker/Podman containers.
108
+
109
+ Works with both Docker and Podman through Docker API compatibility.
110
+ Podman provides Docker-compatible API on its socket.
111
+ """
112
+
113
+ LABEL_PREFIX = "mcp.hangar."
114
+
115
+ def __init__(
116
+ self,
117
+ mode: DiscoveryMode = DiscoveryMode.ADDITIVE,
118
+ socket_path: Optional[str] = None,
119
+ default_ttl: int = 90,
120
+ ):
121
+ """Initialize discovery source.
122
+
123
+ Args:
124
+ mode: Discovery mode (additive or authoritative)
125
+ socket_path: Path to socket (None = auto-detect)
126
+ default_ttl: Default TTL for discovered providers
127
+ """
128
+ super().__init__(mode)
129
+
130
+ if not DOCKER_AVAILABLE:
131
+ raise ImportError("docker package required. Install with: pip install docker")
132
+
133
+ self._socket_path = socket_path
134
+ self._default_ttl = default_ttl
135
+ self._client: Optional[docker.DockerClient] = None
136
+
137
+ def _ensure_client(self) -> None:
138
+ """Ensure Docker client is connected."""
139
+ if self._client is not None:
140
+ return
141
+
142
+ # Find socket
143
+ socket = self._socket_path or find_container_socket()
144
+
145
+ if socket:
146
+ logger.info(f"Connecting to container runtime at: {socket}")
147
+ self._client = docker.DockerClient(base_url=f"unix://{socket}")
148
+ else:
149
+ # Last resort: let docker library figure it out
150
+ logger.info("Using docker.from_env() for container runtime")
151
+ self._client = docker.from_env()
152
+
153
+ @property
154
+ def source_type(self) -> str:
155
+ return "docker"
156
+
157
+ async def discover(self) -> List[DiscoveredProvider]:
158
+ """Discover providers from container labels."""
159
+ self._ensure_client()
160
+ providers = []
161
+
162
+ try:
163
+ # Get all containers with MCP label (including stopped)
164
+ containers = self._client.containers.list(all=True, filters={"label": f"{self.LABEL_PREFIX}enabled=true"})
165
+
166
+ for container in containers:
167
+ provider = self._parse_container(container)
168
+ if provider:
169
+ providers.append(provider)
170
+ await self.on_provider_discovered(provider)
171
+
172
+ logger.debug(f"Discovered {len(providers)} providers from containers")
173
+
174
+ except DockerException as e:
175
+ logger.error(f"Container discovery failed: {e}")
176
+ raise
177
+
178
+ return providers
179
+
180
+ def _parse_container(self, container) -> Optional[DiscoveredProvider]:
181
+ """Parse container into DiscoveredProvider."""
182
+ labels = container.labels or {}
183
+
184
+ # Basic info
185
+ name = labels.get(f"{self.LABEL_PREFIX}name", container.name)
186
+ mode = labels.get(f"{self.LABEL_PREFIX}mode", "container")
187
+
188
+ # Parse read-only setting (default: false for discovered containers)
189
+ read_only_str = labels.get(f"{self.LABEL_PREFIX}read-only", "false").lower()
190
+ read_only = read_only_str in ("true", "1", "yes")
191
+
192
+ # Image info
193
+ image_tags = getattr(container.image, "tags", []) or []
194
+ image = image_tags[0] if image_tags else container.image.id[:12]
195
+
196
+ # Build connection info based on mode
197
+ if mode in ("container", "stdio", "subprocess"):
198
+ # Container mode: MCP Hangar will run this image
199
+ connection_info = {
200
+ "image": image,
201
+ "container_name": container.name,
202
+ "read_only": read_only,
203
+ }
204
+
205
+ # Optional overrides
206
+ if cmd := labels.get(f"{self.LABEL_PREFIX}command"):
207
+ connection_info["command"] = cmd.split()
208
+ if vols := labels.get(f"{self.LABEL_PREFIX}volumes"):
209
+ connection_info["volumes"] = [v.strip() for v in vols.split(",")]
210
+
211
+ mode = "container" # Normalize
212
+
213
+ elif mode in ("http", "sse"):
214
+ # HTTP mode: connect to running container
215
+ ip = self._get_container_ip(container)
216
+ if not ip:
217
+ logger.warning(f"Container {name} has no IP, skipping")
218
+ return None
219
+
220
+ port = int(labels.get(f"{self.LABEL_PREFIX}port", "8080"))
221
+ connection_info = {
222
+ "host": ip,
223
+ "port": port,
224
+ "endpoint": f"http://{ip}:{port}",
225
+ }
226
+ else:
227
+ logger.warning(f"Unknown mode '{mode}' for container {name}")
228
+ return None
229
+
230
+ # Metadata
231
+ metadata = {
232
+ "container_id": container.id[:12],
233
+ "container_name": container.name,
234
+ "image": image,
235
+ "status": container.status,
236
+ "group": labels.get(f"{self.LABEL_PREFIX}group"),
237
+ }
238
+
239
+ return DiscoveredProvider.create(
240
+ name=name,
241
+ source_type=self.source_type,
242
+ mode=mode,
243
+ connection_info=connection_info,
244
+ metadata=metadata,
245
+ ttl_seconds=int(labels.get(f"{self.LABEL_PREFIX}ttl", self._default_ttl)),
246
+ )
247
+
248
+ def _get_container_ip(self, container) -> Optional[str]:
249
+ """Get container IP address from any network."""
250
+ try:
251
+ networks = container.attrs.get("NetworkSettings", {}).get("Networks", {})
252
+ for net_info in networks.values():
253
+ if ip := net_info.get("IPAddress"):
254
+ return ip
255
+ except Exception:
256
+ pass
257
+ return None
258
+
259
+ async def health_check(self) -> bool:
260
+ """Check if container runtime is accessible.
261
+
262
+ Returns:
263
+ True if Docker/Podman is accessible, False otherwise.
264
+ """
265
+ try:
266
+ self._ensure_client()
267
+ self._client.ping()
268
+ return True
269
+ except (OSError, ConnectionError, RuntimeError, TimeoutError) as e:
270
+ logger.warning(f"Container runtime health check failed: {e}")
271
+ return False
272
+ except Exception as e:
273
+ # Docker client can raise various exceptions depending on version
274
+ # Log and return False for any connection-related failure
275
+ logger.warning(f"Container runtime health check failed: {type(e).__name__}: {e}")
276
+ return False
277
+
278
+ async def start(self) -> None:
279
+ """Start discovery source."""
280
+ self._ensure_client()
281
+
282
+ async def stop(self) -> None:
283
+ """Stop discovery source."""
284
+ if self._client:
285
+ try:
286
+ self._client.close()
287
+ except Exception:
288
+ pass
289
+ self._client = None