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.
- mcp_hangar/__init__.py +139 -0
- mcp_hangar/application/__init__.py +1 -0
- mcp_hangar/application/commands/__init__.py +67 -0
- mcp_hangar/application/commands/auth_commands.py +118 -0
- mcp_hangar/application/commands/auth_handlers.py +296 -0
- mcp_hangar/application/commands/commands.py +59 -0
- mcp_hangar/application/commands/handlers.py +189 -0
- mcp_hangar/application/discovery/__init__.py +21 -0
- mcp_hangar/application/discovery/discovery_metrics.py +283 -0
- mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
- mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
- mcp_hangar/application/discovery/security_validator.py +414 -0
- mcp_hangar/application/event_handlers/__init__.py +50 -0
- mcp_hangar/application/event_handlers/alert_handler.py +191 -0
- mcp_hangar/application/event_handlers/audit_handler.py +203 -0
- mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
- mcp_hangar/application/event_handlers/logging_handler.py +69 -0
- mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
- mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
- mcp_hangar/application/event_handlers/security_handler.py +604 -0
- mcp_hangar/application/mcp/tooling.py +158 -0
- mcp_hangar/application/ports/__init__.py +9 -0
- mcp_hangar/application/ports/observability.py +237 -0
- mcp_hangar/application/queries/__init__.py +52 -0
- mcp_hangar/application/queries/auth_handlers.py +237 -0
- mcp_hangar/application/queries/auth_queries.py +118 -0
- mcp_hangar/application/queries/handlers.py +227 -0
- mcp_hangar/application/read_models/__init__.py +11 -0
- mcp_hangar/application/read_models/provider_views.py +139 -0
- mcp_hangar/application/sagas/__init__.py +11 -0
- mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
- mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
- mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
- mcp_hangar/application/services/__init__.py +9 -0
- mcp_hangar/application/services/provider_service.py +208 -0
- mcp_hangar/application/services/traced_provider_service.py +211 -0
- mcp_hangar/bootstrap/runtime.py +328 -0
- mcp_hangar/context.py +178 -0
- mcp_hangar/domain/__init__.py +117 -0
- mcp_hangar/domain/contracts/__init__.py +57 -0
- mcp_hangar/domain/contracts/authentication.py +225 -0
- mcp_hangar/domain/contracts/authorization.py +229 -0
- mcp_hangar/domain/contracts/event_store.py +178 -0
- mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
- mcp_hangar/domain/contracts/persistence.py +383 -0
- mcp_hangar/domain/contracts/provider_runtime.py +146 -0
- mcp_hangar/domain/discovery/__init__.py +20 -0
- mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
- mcp_hangar/domain/discovery/discovered_provider.py +185 -0
- mcp_hangar/domain/discovery/discovery_service.py +412 -0
- mcp_hangar/domain/discovery/discovery_source.py +192 -0
- mcp_hangar/domain/events.py +433 -0
- mcp_hangar/domain/exceptions.py +525 -0
- mcp_hangar/domain/model/__init__.py +70 -0
- mcp_hangar/domain/model/aggregate.py +58 -0
- mcp_hangar/domain/model/circuit_breaker.py +152 -0
- mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
- mcp_hangar/domain/model/event_sourced_provider.py +423 -0
- mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
- mcp_hangar/domain/model/health_tracker.py +183 -0
- mcp_hangar/domain/model/load_balancer.py +185 -0
- mcp_hangar/domain/model/provider.py +810 -0
- mcp_hangar/domain/model/provider_group.py +656 -0
- mcp_hangar/domain/model/tool_catalog.py +105 -0
- mcp_hangar/domain/policies/__init__.py +19 -0
- mcp_hangar/domain/policies/provider_health.py +187 -0
- mcp_hangar/domain/repository.py +249 -0
- mcp_hangar/domain/security/__init__.py +85 -0
- mcp_hangar/domain/security/input_validator.py +710 -0
- mcp_hangar/domain/security/rate_limiter.py +387 -0
- mcp_hangar/domain/security/roles.py +237 -0
- mcp_hangar/domain/security/sanitizer.py +387 -0
- mcp_hangar/domain/security/secrets.py +501 -0
- mcp_hangar/domain/services/__init__.py +20 -0
- mcp_hangar/domain/services/audit_service.py +376 -0
- mcp_hangar/domain/services/image_builder.py +328 -0
- mcp_hangar/domain/services/provider_launcher.py +1046 -0
- mcp_hangar/domain/value_objects.py +1138 -0
- mcp_hangar/errors.py +818 -0
- mcp_hangar/fastmcp_server.py +1105 -0
- mcp_hangar/gc.py +134 -0
- mcp_hangar/infrastructure/__init__.py +79 -0
- mcp_hangar/infrastructure/async_executor.py +133 -0
- mcp_hangar/infrastructure/auth/__init__.py +37 -0
- mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
- mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
- mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
- mcp_hangar/infrastructure/auth/middleware.py +340 -0
- mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
- mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
- mcp_hangar/infrastructure/auth/projections.py +366 -0
- mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
- mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
- mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
- mcp_hangar/infrastructure/command_bus.py +112 -0
- mcp_hangar/infrastructure/discovery/__init__.py +110 -0
- mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
- mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
- mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
- mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
- mcp_hangar/infrastructure/event_bus.py +260 -0
- mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
- mcp_hangar/infrastructure/event_store.py +396 -0
- mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
- mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
- mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
- mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
- mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
- mcp_hangar/infrastructure/metrics_publisher.py +36 -0
- mcp_hangar/infrastructure/observability/__init__.py +10 -0
- mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
- mcp_hangar/infrastructure/persistence/__init__.py +33 -0
- mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
- mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
- mcp_hangar/infrastructure/persistence/database.py +333 -0
- mcp_hangar/infrastructure/persistence/database_common.py +330 -0
- mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
- mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
- mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
- mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
- mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
- mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
- mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
- mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
- mcp_hangar/infrastructure/query_bus.py +153 -0
- mcp_hangar/infrastructure/saga_manager.py +401 -0
- mcp_hangar/logging_config.py +209 -0
- mcp_hangar/metrics.py +1007 -0
- mcp_hangar/models.py +31 -0
- mcp_hangar/observability/__init__.py +54 -0
- mcp_hangar/observability/health.py +487 -0
- mcp_hangar/observability/metrics.py +319 -0
- mcp_hangar/observability/tracing.py +433 -0
- mcp_hangar/progress.py +542 -0
- mcp_hangar/retry.py +613 -0
- mcp_hangar/server/__init__.py +120 -0
- mcp_hangar/server/__main__.py +6 -0
- mcp_hangar/server/auth_bootstrap.py +340 -0
- mcp_hangar/server/auth_cli.py +335 -0
- mcp_hangar/server/auth_config.py +305 -0
- mcp_hangar/server/bootstrap.py +735 -0
- mcp_hangar/server/cli.py +161 -0
- mcp_hangar/server/config.py +224 -0
- mcp_hangar/server/context.py +215 -0
- mcp_hangar/server/http_auth_middleware.py +165 -0
- mcp_hangar/server/lifecycle.py +467 -0
- mcp_hangar/server/state.py +117 -0
- mcp_hangar/server/tools/__init__.py +16 -0
- mcp_hangar/server/tools/discovery.py +186 -0
- mcp_hangar/server/tools/groups.py +75 -0
- mcp_hangar/server/tools/health.py +301 -0
- mcp_hangar/server/tools/provider.py +939 -0
- mcp_hangar/server/tools/registry.py +320 -0
- mcp_hangar/server/validation.py +113 -0
- mcp_hangar/stdio_client.py +229 -0
- mcp_hangar-0.2.0.dist-info/METADATA +347 -0
- mcp_hangar-0.2.0.dist-info/RECORD +160 -0
- mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
- mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|