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,105 @@
|
|
|
1
|
+
"""Tool catalog value object for providers."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class ToolSchema:
|
|
9
|
+
"""
|
|
10
|
+
Schema for a tool provided by a provider.
|
|
11
|
+
|
|
12
|
+
Immutable value object containing tool metadata.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
name: str
|
|
16
|
+
description: str
|
|
17
|
+
input_schema: Dict[str, Any]
|
|
18
|
+
output_schema: Optional[Dict[str, Any]] = None
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict:
|
|
21
|
+
"""Convert to dictionary representation."""
|
|
22
|
+
result = {
|
|
23
|
+
"name": self.name,
|
|
24
|
+
"description": self.description,
|
|
25
|
+
"inputSchema": self.input_schema,
|
|
26
|
+
}
|
|
27
|
+
if self.output_schema is not None:
|
|
28
|
+
result["outputSchema"] = self.output_schema
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ToolCatalog:
|
|
33
|
+
"""
|
|
34
|
+
Catalog of tools provided by a provider.
|
|
35
|
+
|
|
36
|
+
This is a mutable collection that can be updated when tools are
|
|
37
|
+
discovered or refreshed. Thread safety is handled by the aggregate.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, tools: Optional[Dict[str, ToolSchema]] = None):
|
|
41
|
+
self._tools: Dict[str, ToolSchema] = dict(tools or {})
|
|
42
|
+
|
|
43
|
+
def has(self, tool_name: str) -> bool:
|
|
44
|
+
"""Check if a tool exists in the catalog."""
|
|
45
|
+
return tool_name in self._tools
|
|
46
|
+
|
|
47
|
+
def get(self, tool_name: str) -> Optional[ToolSchema]:
|
|
48
|
+
"""Get a tool schema by name."""
|
|
49
|
+
return self._tools.get(tool_name)
|
|
50
|
+
|
|
51
|
+
def list_names(self) -> List[str]:
|
|
52
|
+
"""Get list of all tool names."""
|
|
53
|
+
return list(self._tools.keys())
|
|
54
|
+
|
|
55
|
+
def list_tools(self) -> List[ToolSchema]:
|
|
56
|
+
"""Get list of all tool schemas."""
|
|
57
|
+
return list(self._tools.values())
|
|
58
|
+
|
|
59
|
+
def count(self) -> int:
|
|
60
|
+
"""Get number of tools in catalog."""
|
|
61
|
+
return len(self._tools)
|
|
62
|
+
|
|
63
|
+
def add(self, tool: ToolSchema) -> None:
|
|
64
|
+
"""Add or update a tool in the catalog."""
|
|
65
|
+
self._tools[tool.name] = tool
|
|
66
|
+
|
|
67
|
+
def remove(self, tool_name: str) -> bool:
|
|
68
|
+
"""Remove a tool from the catalog. Returns True if removed."""
|
|
69
|
+
if tool_name in self._tools:
|
|
70
|
+
del self._tools[tool_name]
|
|
71
|
+
return True
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
def clear(self) -> None:
|
|
75
|
+
"""Remove all tools from the catalog."""
|
|
76
|
+
self._tools.clear()
|
|
77
|
+
|
|
78
|
+
def update_from_list(self, tool_list: List[dict]) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Update catalog from a list of tool dictionaries.
|
|
81
|
+
|
|
82
|
+
This is typically used when refreshing tools from a provider response.
|
|
83
|
+
"""
|
|
84
|
+
self._tools.clear()
|
|
85
|
+
for t in tool_list:
|
|
86
|
+
tool = ToolSchema(
|
|
87
|
+
name=t["name"],
|
|
88
|
+
description=t.get("description", ""),
|
|
89
|
+
input_schema=t.get("inputSchema", {}),
|
|
90
|
+
output_schema=t.get("outputSchema"),
|
|
91
|
+
)
|
|
92
|
+
self._tools[tool.name] = tool
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> Dict[str, ToolSchema]:
|
|
95
|
+
"""Get a copy of the internal tools dictionary."""
|
|
96
|
+
return dict(self._tools)
|
|
97
|
+
|
|
98
|
+
def __contains__(self, tool_name: str) -> bool:
|
|
99
|
+
return tool_name in self._tools
|
|
100
|
+
|
|
101
|
+
def __len__(self) -> int:
|
|
102
|
+
return len(self._tools)
|
|
103
|
+
|
|
104
|
+
def __iter__(self):
|
|
105
|
+
return iter(self._tools.values())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Domain policies for MCP Hangar.
|
|
2
|
+
|
|
3
|
+
Policies encapsulate domain rules and classification logic that can be
|
|
4
|
+
applied across different contexts without coupling to specific aggregates.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .provider_health import (
|
|
8
|
+
classify_provider_health,
|
|
9
|
+
classify_provider_health_from_provider,
|
|
10
|
+
ProviderHealthClassification,
|
|
11
|
+
to_health_status_string,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ProviderHealthClassification",
|
|
16
|
+
"classify_provider_health",
|
|
17
|
+
"classify_provider_health_from_provider",
|
|
18
|
+
"to_health_status_string",
|
|
19
|
+
]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Provider health classification policy.
|
|
2
|
+
|
|
3
|
+
This module centralizes the logic that maps a Provider's state + health tracker
|
|
4
|
+
signals into a user-facing health classification.
|
|
5
|
+
|
|
6
|
+
Why this exists:
|
|
7
|
+
- Avoids duplicating "health status" mapping logic across query handlers / APIs.
|
|
8
|
+
- Keeps interpretation of state and failures as a domain-level policy.
|
|
9
|
+
- Allows the policy to evolve without touching CQRS read mapping.
|
|
10
|
+
|
|
11
|
+
This policy is intentionally small and pure (no I/O, no imports from infrastructure).
|
|
12
|
+
|
|
13
|
+
Usage (typical):
|
|
14
|
+
from mcp_hangar.domain.policies.provider_health import classify_provider_health
|
|
15
|
+
|
|
16
|
+
health_status = classify_provider_health(
|
|
17
|
+
state=provider.state,
|
|
18
|
+
consecutive_failures=provider.health.consecutive_failures,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
Or, if you already have a HealthTracker-like object:
|
|
22
|
+
health_status = classify_provider_health_from_provider(provider)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Any, Protocol
|
|
29
|
+
|
|
30
|
+
from ..value_objects import HealthStatus, ProviderState
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _HealthView(Protocol):
|
|
34
|
+
"""Minimal health-tracker view required by the policy.
|
|
35
|
+
|
|
36
|
+
Defines the interface for accessing health metrics from any
|
|
37
|
+
health-tracker-like object.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def consecutive_failures(self) -> int:
|
|
42
|
+
"""Get the count of consecutive failures."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _ProviderView(Protocol):
|
|
47
|
+
"""Minimal provider view required by the policy.
|
|
48
|
+
|
|
49
|
+
Defines the interface for accessing provider state and health
|
|
50
|
+
from any provider-like object.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def state(self) -> ProviderState:
|
|
55
|
+
"""Get the current provider state."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def health(self) -> _HealthView:
|
|
60
|
+
"""Get the health tracker view."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _normalize_state(state: Any) -> ProviderState:
|
|
65
|
+
"""Convert a loose/legacy state representation to ProviderState."""
|
|
66
|
+
if isinstance(state, ProviderState):
|
|
67
|
+
return state
|
|
68
|
+
|
|
69
|
+
# Some call sites may pass enum-like objects with `.value`
|
|
70
|
+
value = getattr(state, "value", None)
|
|
71
|
+
if value is not None:
|
|
72
|
+
state_str = str(value).lower()
|
|
73
|
+
else:
|
|
74
|
+
state_str = str(state).lower()
|
|
75
|
+
|
|
76
|
+
for s in ProviderState:
|
|
77
|
+
if s.value == state_str:
|
|
78
|
+
return s
|
|
79
|
+
|
|
80
|
+
# If unknown, treat as DEAD from a health classification standpoint
|
|
81
|
+
# (conservative default).
|
|
82
|
+
return ProviderState.DEAD
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class ProviderHealthClassification:
|
|
87
|
+
"""Result of applying the classification policy."""
|
|
88
|
+
|
|
89
|
+
status: HealthStatus
|
|
90
|
+
reason: str
|
|
91
|
+
consecutive_failures: int
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> dict:
|
|
94
|
+
return {
|
|
95
|
+
"status": str(self.status),
|
|
96
|
+
"reason": self.reason,
|
|
97
|
+
"consecutive_failures": self.consecutive_failures,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def classify_provider_health(
|
|
102
|
+
*,
|
|
103
|
+
state: Any,
|
|
104
|
+
consecutive_failures: int = 0,
|
|
105
|
+
) -> ProviderHealthClassification:
|
|
106
|
+
"""Classify provider health from state and failure count.
|
|
107
|
+
|
|
108
|
+
Rules (current):
|
|
109
|
+
- READY + 0 failures -> HEALTHY
|
|
110
|
+
- READY + >0 failures -> DEGRADED
|
|
111
|
+
- DEGRADED -> DEGRADED
|
|
112
|
+
- DEAD -> UNHEALTHY
|
|
113
|
+
- COLD / INITIALIZING -> UNKNOWN
|
|
114
|
+
|
|
115
|
+
Notes:
|
|
116
|
+
- This is a *classification*, not the same as "can accept requests".
|
|
117
|
+
That rule is handled by ProviderState.can_accept_requests and other domain logic.
|
|
118
|
+
"""
|
|
119
|
+
st = _normalize_state(state)
|
|
120
|
+
failures = int(consecutive_failures or 0)
|
|
121
|
+
|
|
122
|
+
if st == ProviderState.READY:
|
|
123
|
+
if failures <= 0:
|
|
124
|
+
return ProviderHealthClassification(
|
|
125
|
+
status=HealthStatus.HEALTHY,
|
|
126
|
+
reason="ready_no_failures",
|
|
127
|
+
consecutive_failures=failures,
|
|
128
|
+
)
|
|
129
|
+
return ProviderHealthClassification(
|
|
130
|
+
status=HealthStatus.DEGRADED,
|
|
131
|
+
reason="ready_with_failures",
|
|
132
|
+
consecutive_failures=failures,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if st == ProviderState.DEGRADED:
|
|
136
|
+
return ProviderHealthClassification(
|
|
137
|
+
status=HealthStatus.DEGRADED,
|
|
138
|
+
reason="provider_state_degraded",
|
|
139
|
+
consecutive_failures=failures,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if st == ProviderState.DEAD:
|
|
143
|
+
return ProviderHealthClassification(
|
|
144
|
+
status=HealthStatus.UNHEALTHY,
|
|
145
|
+
reason="provider_state_dead",
|
|
146
|
+
consecutive_failures=failures,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if st in (ProviderState.COLD, ProviderState.INITIALIZING):
|
|
150
|
+
return ProviderHealthClassification(
|
|
151
|
+
status=HealthStatus.UNKNOWN,
|
|
152
|
+
reason=f"provider_state_{st.value}",
|
|
153
|
+
consecutive_failures=failures,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Fallback (shouldn't happen due to normalization)
|
|
157
|
+
return ProviderHealthClassification(
|
|
158
|
+
status=HealthStatus.UNKNOWN,
|
|
159
|
+
reason="unknown_state",
|
|
160
|
+
consecutive_failures=failures,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def classify_provider_health_from_provider(
|
|
165
|
+
provider: _ProviderView,
|
|
166
|
+
) -> ProviderHealthClassification:
|
|
167
|
+
"""Convenience wrapper to classify health from a provider-like object."""
|
|
168
|
+
return classify_provider_health(
|
|
169
|
+
state=provider.state,
|
|
170
|
+
consecutive_failures=provider.health.consecutive_failures,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def to_health_status_string(
|
|
175
|
+
*,
|
|
176
|
+
state: Any,
|
|
177
|
+
consecutive_failures: int = 0,
|
|
178
|
+
) -> str:
|
|
179
|
+
"""Legacy helper: return the `HealthStatus.value` string.
|
|
180
|
+
|
|
181
|
+
This exists to minimize changes in read model mapping code while still routing
|
|
182
|
+
logic through a single policy.
|
|
183
|
+
"""
|
|
184
|
+
return classify_provider_health(
|
|
185
|
+
state=state,
|
|
186
|
+
consecutive_failures=consecutive_failures,
|
|
187
|
+
).status.value
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Repository interfaces for provider storage abstraction.
|
|
3
|
+
|
|
4
|
+
The Repository pattern separates domain logic from data access logic,
|
|
5
|
+
allowing the persistence mechanism to change without affecting business code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
import threading
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
# Type alias for provider-like objects (Provider aggregate)
|
|
13
|
+
ProviderLike = Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IProviderRepository(ABC):
|
|
17
|
+
"""Abstract interface for provider storage.
|
|
18
|
+
|
|
19
|
+
This interface defines the contract for storing and retrieving providers,
|
|
20
|
+
allowing different implementations (in-memory, database, etc.) without
|
|
21
|
+
changing business logic.
|
|
22
|
+
|
|
23
|
+
Stores Provider aggregates.
|
|
24
|
+
|
|
25
|
+
Thread-safety is guaranteed by implementations.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def add(self, provider_id: str, provider: ProviderLike) -> None:
|
|
30
|
+
"""Add or update a provider in the repository.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
provider_id: Unique provider identifier
|
|
34
|
+
provider: Provider aggregate instance to store
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ValueError: If provider_id is empty or invalid
|
|
38
|
+
"""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def get(self, provider_id: str) -> Optional[ProviderLike]:
|
|
43
|
+
"""Retrieve a provider by ID.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
provider_id: Provider identifier to look up
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Provider if found, None otherwise
|
|
50
|
+
"""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def exists(self, provider_id: str) -> bool:
|
|
55
|
+
"""Check if a provider exists in the repository.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
provider_id: Provider identifier to check
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
True if provider exists, False otherwise
|
|
62
|
+
"""
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def remove(self, provider_id: str) -> bool:
|
|
67
|
+
"""Remove a provider from the repository.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
provider_id: Provider identifier to remove
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
True if provider was removed, False if not found
|
|
74
|
+
"""
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def get_all(self) -> Dict[str, ProviderLike]:
|
|
79
|
+
"""Get all providers as a dictionary.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Dictionary mapping provider_id -> Provider
|
|
83
|
+
Returns a copy to prevent external modifications
|
|
84
|
+
"""
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
def get_all_ids(self) -> List[str]:
|
|
89
|
+
"""Get all provider IDs.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
List of provider identifiers
|
|
93
|
+
"""
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
def count(self) -> int:
|
|
98
|
+
"""Get the total number of providers.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Number of providers in the repository
|
|
102
|
+
"""
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
def clear(self) -> None:
|
|
107
|
+
"""Remove all providers from the repository.
|
|
108
|
+
|
|
109
|
+
This is primarily for testing and cleanup.
|
|
110
|
+
"""
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class InMemoryProviderRepository(IProviderRepository):
|
|
115
|
+
"""In-memory implementation of provider repository.
|
|
116
|
+
|
|
117
|
+
This implementation stores providers in a dictionary with thread-safe
|
|
118
|
+
access using a read-write lock pattern.
|
|
119
|
+
|
|
120
|
+
Stores Provider aggregates.
|
|
121
|
+
|
|
122
|
+
Thread Safety:
|
|
123
|
+
- All operations are protected by a lock
|
|
124
|
+
- get_all() returns a snapshot copy
|
|
125
|
+
- Safe for concurrent access from multiple threads
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self):
|
|
129
|
+
"""Initialize empty in-memory repository."""
|
|
130
|
+
self._providers: Dict[str, ProviderLike] = {}
|
|
131
|
+
self._lock = threading.RLock()
|
|
132
|
+
|
|
133
|
+
def add(self, provider_id: str, provider: ProviderLike) -> None:
|
|
134
|
+
"""Add or update a provider in the repository.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
provider_id: Unique provider identifier
|
|
138
|
+
provider: Provider aggregate instance to store
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ValueError: If provider_id is empty
|
|
142
|
+
"""
|
|
143
|
+
if not provider_id:
|
|
144
|
+
raise ValueError("Provider ID cannot be empty")
|
|
145
|
+
|
|
146
|
+
with self._lock:
|
|
147
|
+
self._providers[provider_id] = provider
|
|
148
|
+
|
|
149
|
+
def get(self, provider_id: str) -> Optional[ProviderLike]:
|
|
150
|
+
"""Retrieve a provider by ID.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
provider_id: Provider identifier to look up
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Provider if found, None otherwise
|
|
157
|
+
"""
|
|
158
|
+
with self._lock:
|
|
159
|
+
return self._providers.get(provider_id)
|
|
160
|
+
|
|
161
|
+
def exists(self, provider_id: str) -> bool:
|
|
162
|
+
"""Check if a provider exists in the repository.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
provider_id: Provider identifier to check
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
True if provider exists, False otherwise
|
|
169
|
+
"""
|
|
170
|
+
with self._lock:
|
|
171
|
+
return provider_id in self._providers
|
|
172
|
+
|
|
173
|
+
def remove(self, provider_id: str) -> bool:
|
|
174
|
+
"""Remove a provider from the repository.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
provider_id: Provider identifier to remove
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True if provider was removed, False if not found
|
|
181
|
+
"""
|
|
182
|
+
with self._lock:
|
|
183
|
+
if provider_id in self._providers:
|
|
184
|
+
del self._providers[provider_id]
|
|
185
|
+
return True
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
def get_all(self) -> Dict[str, ProviderLike]:
|
|
189
|
+
"""Get all providers as a dictionary.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Dictionary mapping provider_id -> Provider
|
|
193
|
+
Returns a copy to prevent external modifications
|
|
194
|
+
"""
|
|
195
|
+
with self._lock:
|
|
196
|
+
return dict(self._providers)
|
|
197
|
+
|
|
198
|
+
def get_all_ids(self) -> List[str]:
|
|
199
|
+
"""Get all provider IDs.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
List of provider identifiers
|
|
203
|
+
"""
|
|
204
|
+
with self._lock:
|
|
205
|
+
return list(self._providers.keys())
|
|
206
|
+
|
|
207
|
+
def count(self) -> int:
|
|
208
|
+
"""Get the total number of providers.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Number of providers in the repository
|
|
212
|
+
"""
|
|
213
|
+
with self._lock:
|
|
214
|
+
return len(self._providers)
|
|
215
|
+
|
|
216
|
+
def clear(self) -> None:
|
|
217
|
+
"""Remove all providers from the repository.
|
|
218
|
+
|
|
219
|
+
This is primarily for testing and cleanup.
|
|
220
|
+
"""
|
|
221
|
+
with self._lock:
|
|
222
|
+
self._providers.clear()
|
|
223
|
+
|
|
224
|
+
def __contains__(self, provider_id: str) -> bool:
|
|
225
|
+
"""Support 'in' operator for checking existence.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
provider_id: Provider identifier to check
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
True if provider exists, False otherwise
|
|
232
|
+
"""
|
|
233
|
+
return self.exists(provider_id)
|
|
234
|
+
|
|
235
|
+
def __len__(self) -> int:
|
|
236
|
+
"""Support len() function.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Number of providers in the repository
|
|
240
|
+
"""
|
|
241
|
+
return self.count()
|
|
242
|
+
|
|
243
|
+
def __repr__(self) -> str:
|
|
244
|
+
"""String representation for debugging.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
String showing repository type and provider count
|
|
248
|
+
"""
|
|
249
|
+
return f"InMemoryProviderRepository(providers={self.count()})"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Security module for the MCP Registry.
|
|
3
|
+
|
|
4
|
+
Provides security primitives including:
|
|
5
|
+
- Input validation and sanitization
|
|
6
|
+
- Command injection prevention
|
|
7
|
+
- Rate limiting
|
|
8
|
+
- Secrets management
|
|
9
|
+
- Security audit logging utilities
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .input_validator import (
|
|
13
|
+
InputValidator,
|
|
14
|
+
validate_arguments,
|
|
15
|
+
validate_command,
|
|
16
|
+
validate_docker_image,
|
|
17
|
+
validate_environment_variables,
|
|
18
|
+
validate_provider_id,
|
|
19
|
+
validate_timeout,
|
|
20
|
+
validate_tool_name,
|
|
21
|
+
ValidationResult,
|
|
22
|
+
)
|
|
23
|
+
from .rate_limiter import InMemoryRateLimiter, RateLimitConfig, RateLimiter, RateLimitResult
|
|
24
|
+
from .roles import (
|
|
25
|
+
BUILTIN_ROLES,
|
|
26
|
+
get_builtin_role,
|
|
27
|
+
get_permission,
|
|
28
|
+
list_builtin_roles,
|
|
29
|
+
list_permissions,
|
|
30
|
+
PERMISSIONS,
|
|
31
|
+
ROLE_ADMIN,
|
|
32
|
+
ROLE_AUDITOR,
|
|
33
|
+
ROLE_DEVELOPER,
|
|
34
|
+
ROLE_PROVIDER_ADMIN,
|
|
35
|
+
ROLE_VIEWER,
|
|
36
|
+
)
|
|
37
|
+
from .sanitizer import (
|
|
38
|
+
sanitize_command_argument,
|
|
39
|
+
sanitize_environment_value,
|
|
40
|
+
sanitize_log_message,
|
|
41
|
+
sanitize_path,
|
|
42
|
+
Sanitizer,
|
|
43
|
+
)
|
|
44
|
+
from .secrets import is_sensitive_key, mask_sensitive_value, SecretsMask, SecureEnvironment
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
# Input Validation
|
|
48
|
+
"InputValidator",
|
|
49
|
+
"ValidationResult",
|
|
50
|
+
"validate_provider_id",
|
|
51
|
+
"validate_tool_name",
|
|
52
|
+
"validate_arguments",
|
|
53
|
+
"validate_timeout",
|
|
54
|
+
"validate_command",
|
|
55
|
+
"validate_docker_image",
|
|
56
|
+
"validate_environment_variables",
|
|
57
|
+
# Sanitization
|
|
58
|
+
"Sanitizer",
|
|
59
|
+
"sanitize_command_argument",
|
|
60
|
+
"sanitize_environment_value",
|
|
61
|
+
"sanitize_log_message",
|
|
62
|
+
"sanitize_path",
|
|
63
|
+
# Rate Limiting
|
|
64
|
+
"RateLimiter",
|
|
65
|
+
"RateLimitConfig",
|
|
66
|
+
"InMemoryRateLimiter",
|
|
67
|
+
"RateLimitResult",
|
|
68
|
+
# Secrets
|
|
69
|
+
"SecretsMask",
|
|
70
|
+
"SecureEnvironment",
|
|
71
|
+
"is_sensitive_key",
|
|
72
|
+
"mask_sensitive_value",
|
|
73
|
+
# Roles & Permissions
|
|
74
|
+
"BUILTIN_ROLES",
|
|
75
|
+
"PERMISSIONS",
|
|
76
|
+
"ROLE_ADMIN",
|
|
77
|
+
"ROLE_DEVELOPER",
|
|
78
|
+
"ROLE_PROVIDER_ADMIN",
|
|
79
|
+
"ROLE_VIEWER",
|
|
80
|
+
"ROLE_AUDITOR",
|
|
81
|
+
"get_builtin_role",
|
|
82
|
+
"get_permission",
|
|
83
|
+
"list_builtin_roles",
|
|
84
|
+
"list_permissions",
|
|
85
|
+
]
|