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,249 @@
|
|
|
1
|
+
"""Python Entrypoint Discovery Source.
|
|
2
|
+
|
|
3
|
+
Discovers MCP providers from Python package entry points.
|
|
4
|
+
Uses the standard entry_points mechanism from importlib.metadata.
|
|
5
|
+
|
|
6
|
+
Entry Point Group: mcp.providers
|
|
7
|
+
|
|
8
|
+
Example pyproject.toml:
|
|
9
|
+
[project.entry-points."mcp.providers"]
|
|
10
|
+
my_provider = "my_package.mcp_server:create_server"
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from mcp_hangar.domain.discovery.discovered_provider import DiscoveredProvider
|
|
16
|
+
from mcp_hangar.domain.discovery.discovery_source import DiscoveryMode, DiscoverySource
|
|
17
|
+
|
|
18
|
+
from ...logging_config import get_logger
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
# Import metadata handling
|
|
23
|
+
try:
|
|
24
|
+
from importlib.metadata import entry_points, EntryPoint
|
|
25
|
+
|
|
26
|
+
METADATA_AVAILABLE = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
try:
|
|
29
|
+
from importlib_metadata import entry_points, EntryPoint
|
|
30
|
+
|
|
31
|
+
METADATA_AVAILABLE = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
METADATA_AVAILABLE = False
|
|
34
|
+
logger.debug("importlib.metadata not available, EntrypointDiscoverySource unavailable")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EntrypointDiscoverySource(DiscoverySource):
|
|
38
|
+
"""Discover MCP providers from Python package entry points.
|
|
39
|
+
|
|
40
|
+
Scans installed Python packages for entry points in the specified group.
|
|
41
|
+
Each entry point should reference a factory function that creates an MCP server.
|
|
42
|
+
|
|
43
|
+
Entry Point Format:
|
|
44
|
+
The entry point value should be a module path to a factory function.
|
|
45
|
+
The function can optionally return configuration metadata.
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
# In pyproject.toml
|
|
49
|
+
[project.entry-points."mcp.providers"]
|
|
50
|
+
my_tools = "my_package.server:create_server"
|
|
51
|
+
|
|
52
|
+
# In my_package/server.py
|
|
53
|
+
def create_server():
|
|
54
|
+
'''Returns MCP server configuration.'''
|
|
55
|
+
return {
|
|
56
|
+
"name": "my-tools",
|
|
57
|
+
"mode": "subprocess",
|
|
58
|
+
"command": ["python", "-m", "my_package.server"],
|
|
59
|
+
"metadata": {"version": "1.0.0"}
|
|
60
|
+
}
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
DEFAULT_GROUP = "mcp.providers"
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
group: str = DEFAULT_GROUP,
|
|
68
|
+
mode: DiscoveryMode = DiscoveryMode.ADDITIVE,
|
|
69
|
+
default_ttl: int = 90,
|
|
70
|
+
):
|
|
71
|
+
"""Initialize entrypoint discovery source.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
group: Entry point group name (default: mcp.providers)
|
|
75
|
+
mode: Discovery mode (default: additive)
|
|
76
|
+
default_ttl: Default TTL for discovered providers
|
|
77
|
+
"""
|
|
78
|
+
super().__init__(mode)
|
|
79
|
+
|
|
80
|
+
if not METADATA_AVAILABLE:
|
|
81
|
+
raise ImportError(
|
|
82
|
+
"importlib.metadata is required for EntrypointDiscoverySource. "
|
|
83
|
+
"Install with: pip install importlib-metadata"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
self.group = group
|
|
87
|
+
self.default_ttl = default_ttl
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def source_type(self) -> str:
|
|
91
|
+
return "entrypoint"
|
|
92
|
+
|
|
93
|
+
async def discover(self) -> List[DiscoveredProvider]:
|
|
94
|
+
"""Discover providers from Python entry points.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
List of discovered providers
|
|
98
|
+
"""
|
|
99
|
+
providers = []
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
# Get entry points for our group
|
|
103
|
+
eps = entry_points()
|
|
104
|
+
|
|
105
|
+
# Handle different Python versions
|
|
106
|
+
if hasattr(eps, "select"):
|
|
107
|
+
# Python 3.10+
|
|
108
|
+
group_eps = eps.select(group=self.group)
|
|
109
|
+
elif hasattr(eps, "get"):
|
|
110
|
+
# Python 3.9
|
|
111
|
+
group_eps = eps.get(self.group, [])
|
|
112
|
+
else:
|
|
113
|
+
# Fallback
|
|
114
|
+
group_eps = getattr(eps, self.group, [])
|
|
115
|
+
|
|
116
|
+
for ep in group_eps:
|
|
117
|
+
try:
|
|
118
|
+
provider = await self._load_entrypoint(ep)
|
|
119
|
+
if provider:
|
|
120
|
+
providers.append(provider)
|
|
121
|
+
await self.on_provider_discovered(provider)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.error(f"Failed to load entry point {ep.name}: {e}")
|
|
124
|
+
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.error(f"Entry point discovery failed: {e}")
|
|
127
|
+
raise
|
|
128
|
+
|
|
129
|
+
logger.debug(f"Entrypoint discovery found {len(providers)} providers")
|
|
130
|
+
return providers
|
|
131
|
+
|
|
132
|
+
async def _load_entrypoint(self, ep: EntryPoint) -> Optional[DiscoveredProvider]:
|
|
133
|
+
"""Load and parse an entry point.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
ep: Entry point to load
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
DiscoveredProvider or None if invalid
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
# Load the entry point
|
|
143
|
+
factory = ep.load()
|
|
144
|
+
|
|
145
|
+
# Call factory if it's callable
|
|
146
|
+
if callable(factory):
|
|
147
|
+
try:
|
|
148
|
+
config = factory()
|
|
149
|
+
except Exception as e:
|
|
150
|
+
logger.warning(f"Entry point {ep.name} factory failed: {e}")
|
|
151
|
+
config = None
|
|
152
|
+
else:
|
|
153
|
+
config = factory
|
|
154
|
+
|
|
155
|
+
# Build provider from config or defaults
|
|
156
|
+
return self._build_provider(ep, config)
|
|
157
|
+
|
|
158
|
+
except Exception as e:
|
|
159
|
+
logger.error(f"Error loading entry point {ep.name}: {e}")
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def _build_provider(self, ep: EntryPoint, config: Optional[Dict[str, Any]]) -> DiscoveredProvider:
|
|
163
|
+
"""Build provider from entry point and optional config.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
ep: Entry point
|
|
167
|
+
config: Optional configuration from factory
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
DiscoveredProvider instance
|
|
171
|
+
"""
|
|
172
|
+
config = config or {}
|
|
173
|
+
|
|
174
|
+
# Extract name (prefer config, fall back to entry point name)
|
|
175
|
+
name = config.get("name", ep.name)
|
|
176
|
+
|
|
177
|
+
# Determine mode
|
|
178
|
+
mode = config.get("mode", "subprocess")
|
|
179
|
+
|
|
180
|
+
# Build connection info
|
|
181
|
+
connection_info = {}
|
|
182
|
+
|
|
183
|
+
if mode in ("subprocess", "stdio"):
|
|
184
|
+
# Default command is to run as module
|
|
185
|
+
command = config.get("command")
|
|
186
|
+
if not command:
|
|
187
|
+
# Parse entry point value to get module
|
|
188
|
+
module_path = ep.value.rsplit(":", 1)[0] if ":" in ep.value else ep.value
|
|
189
|
+
command = ["python", "-m", module_path]
|
|
190
|
+
connection_info["command"] = command
|
|
191
|
+
|
|
192
|
+
if "env" in config:
|
|
193
|
+
connection_info["env"] = config["env"]
|
|
194
|
+
|
|
195
|
+
elif mode in ("http", "sse", "remote"):
|
|
196
|
+
connection_info["host"] = config.get("host", "localhost")
|
|
197
|
+
connection_info["port"] = config.get("port", 8080)
|
|
198
|
+
connection_info["health_path"] = config.get("health_path", "/health")
|
|
199
|
+
|
|
200
|
+
# Copy additional connection settings
|
|
201
|
+
for key in ("timeout", "endpoint"):
|
|
202
|
+
if key in config:
|
|
203
|
+
connection_info[key] = config[key]
|
|
204
|
+
|
|
205
|
+
# Build metadata
|
|
206
|
+
metadata = {
|
|
207
|
+
"entrypoint_name": ep.name,
|
|
208
|
+
"entrypoint_value": ep.value,
|
|
209
|
+
"entrypoint_group": self.group,
|
|
210
|
+
**config.get("metadata", {}),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
# Add package info if available
|
|
214
|
+
if hasattr(ep, "dist") and ep.dist:
|
|
215
|
+
metadata["package_name"] = ep.dist.name
|
|
216
|
+
metadata["package_version"] = ep.dist.version
|
|
217
|
+
|
|
218
|
+
ttl = config.get("ttl", self.default_ttl)
|
|
219
|
+
|
|
220
|
+
return DiscoveredProvider.create(
|
|
221
|
+
name=name,
|
|
222
|
+
source_type=self.source_type,
|
|
223
|
+
mode=mode,
|
|
224
|
+
connection_info=connection_info,
|
|
225
|
+
metadata=metadata,
|
|
226
|
+
ttl_seconds=ttl,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async def health_check(self) -> bool:
|
|
230
|
+
"""Check if entry points can be read.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
True if metadata system is available
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
# Just check that we can access entry_points
|
|
237
|
+
entry_points()
|
|
238
|
+
return True
|
|
239
|
+
except Exception as e:
|
|
240
|
+
logger.warning(f"Entrypoint health check failed: {e}")
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
async def start(self) -> None:
|
|
244
|
+
"""Start the entrypoint discovery source."""
|
|
245
|
+
logger.info(f"Entrypoint discovery source started (group={self.group})")
|
|
246
|
+
|
|
247
|
+
async def stop(self) -> None:
|
|
248
|
+
"""Stop the entrypoint discovery source."""
|
|
249
|
+
logger.info("Entrypoint discovery source stopped")
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Filesystem Discovery Source.
|
|
2
|
+
|
|
3
|
+
Discovers MCP providers from YAML files in a directory.
|
|
4
|
+
Supports file watching for automatic updates.
|
|
5
|
+
|
|
6
|
+
Default Path: /etc/mcp-hangar/providers.d/*.yaml
|
|
7
|
+
|
|
8
|
+
Example Provider File:
|
|
9
|
+
# /etc/mcp-hangar/providers.d/custom-tool.yaml
|
|
10
|
+
name: custom-tool
|
|
11
|
+
enabled: true
|
|
12
|
+
mode: subprocess
|
|
13
|
+
|
|
14
|
+
connection:
|
|
15
|
+
command: python
|
|
16
|
+
args:
|
|
17
|
+
- -m
|
|
18
|
+
- my_custom_mcp_server
|
|
19
|
+
env:
|
|
20
|
+
LOG_LEVEL: INFO
|
|
21
|
+
|
|
22
|
+
metadata:
|
|
23
|
+
owner: platform-team
|
|
24
|
+
version: "1.2.0"
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Dict, List, Optional
|
|
30
|
+
|
|
31
|
+
from mcp_hangar.domain.discovery.discovered_provider import DiscoveredProvider
|
|
32
|
+
from mcp_hangar.domain.discovery.discovery_source import DiscoveryMode, DiscoverySource
|
|
33
|
+
|
|
34
|
+
from ...logging_config import get_logger
|
|
35
|
+
|
|
36
|
+
logger = get_logger(__name__)
|
|
37
|
+
|
|
38
|
+
# Optional dependencies
|
|
39
|
+
try:
|
|
40
|
+
import yaml
|
|
41
|
+
|
|
42
|
+
YAML_AVAILABLE = True
|
|
43
|
+
except ImportError:
|
|
44
|
+
YAML_AVAILABLE = False
|
|
45
|
+
logger.debug("PyYAML package not installed, FilesystemDiscoverySource unavailable")
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
from watchdog.events import FileSystemEventHandler
|
|
49
|
+
from watchdog.observers import Observer
|
|
50
|
+
|
|
51
|
+
WATCHDOG_AVAILABLE = True
|
|
52
|
+
except ImportError:
|
|
53
|
+
WATCHDOG_AVAILABLE = False
|
|
54
|
+
logger.debug("watchdog package not installed, file watching unavailable")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FilesystemDiscoverySource(DiscoverySource):
|
|
58
|
+
"""Discover MCP providers from YAML files in a directory.
|
|
59
|
+
|
|
60
|
+
Scans a directory for YAML files containing provider definitions.
|
|
61
|
+
Optionally watches for file changes using inotify/fsevents.
|
|
62
|
+
|
|
63
|
+
File Format:
|
|
64
|
+
name: provider-name
|
|
65
|
+
enabled: true
|
|
66
|
+
mode: subprocess|http|sse
|
|
67
|
+
|
|
68
|
+
connection:
|
|
69
|
+
command: [python, -m, my_server]
|
|
70
|
+
# or
|
|
71
|
+
host: localhost
|
|
72
|
+
port: 8080
|
|
73
|
+
|
|
74
|
+
metadata:
|
|
75
|
+
key: value
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
DEFAULT_PATH = "/etc/mcp-hangar/providers.d/"
|
|
79
|
+
DEFAULT_PATTERN = "*.yaml"
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
path: Optional[str] = None,
|
|
84
|
+
pattern: str = DEFAULT_PATTERN,
|
|
85
|
+
mode: DiscoveryMode = DiscoveryMode.ADDITIVE,
|
|
86
|
+
watch: bool = True,
|
|
87
|
+
default_ttl: int = 90,
|
|
88
|
+
):
|
|
89
|
+
"""Initialize filesystem discovery source.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
path: Directory path to scan (default: /etc/mcp-hangar/providers.d/)
|
|
93
|
+
pattern: Glob pattern for files (default: *.yaml)
|
|
94
|
+
mode: Discovery mode (default: additive)
|
|
95
|
+
watch: Enable file watching (requires watchdog)
|
|
96
|
+
default_ttl: Default TTL for discovered providers
|
|
97
|
+
"""
|
|
98
|
+
super().__init__(mode)
|
|
99
|
+
|
|
100
|
+
if not YAML_AVAILABLE:
|
|
101
|
+
raise ImportError(
|
|
102
|
+
"PyYAML package is required for FilesystemDiscoverySource. Install with: pip install pyyaml"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
self.path = Path(path or self.DEFAULT_PATH)
|
|
106
|
+
self.pattern = pattern
|
|
107
|
+
self.watch = watch and WATCHDOG_AVAILABLE
|
|
108
|
+
self.default_ttl = default_ttl
|
|
109
|
+
|
|
110
|
+
self._observer: Optional[Observer] = None
|
|
111
|
+
self._event_handler: Optional[FileSystemEventHandler] = None
|
|
112
|
+
self._cached_providers: Dict[str, DiscoveredProvider] = {}
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def source_type(self) -> str:
|
|
116
|
+
return "filesystem"
|
|
117
|
+
|
|
118
|
+
async def discover(self) -> List[DiscoveredProvider]:
|
|
119
|
+
"""Discover providers from YAML files.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
List of discovered providers
|
|
123
|
+
"""
|
|
124
|
+
providers = []
|
|
125
|
+
|
|
126
|
+
if not self.path.exists():
|
|
127
|
+
logger.warning(f"Discovery path does not exist: {self.path}")
|
|
128
|
+
return providers
|
|
129
|
+
|
|
130
|
+
if not self.path.is_dir():
|
|
131
|
+
logger.warning(f"Discovery path is not a directory: {self.path}")
|
|
132
|
+
return providers
|
|
133
|
+
|
|
134
|
+
# Also check for .yml extension
|
|
135
|
+
patterns = [self.pattern]
|
|
136
|
+
if self.pattern == "*.yaml":
|
|
137
|
+
patterns.append("*.yml")
|
|
138
|
+
|
|
139
|
+
seen_files = set()
|
|
140
|
+
for pattern in patterns:
|
|
141
|
+
for file_path in self.path.glob(pattern):
|
|
142
|
+
if file_path in seen_files:
|
|
143
|
+
continue
|
|
144
|
+
seen_files.add(file_path)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
provider = self._parse_file(file_path)
|
|
148
|
+
if provider:
|
|
149
|
+
providers.append(provider)
|
|
150
|
+
self._cached_providers[provider.name] = provider
|
|
151
|
+
await self.on_provider_discovered(provider)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.error(f"Failed to parse {file_path}: {e}")
|
|
154
|
+
|
|
155
|
+
logger.debug(f"Filesystem discovery found {len(providers)} providers")
|
|
156
|
+
return providers
|
|
157
|
+
|
|
158
|
+
def _parse_file(self, file_path: Path) -> Optional[DiscoveredProvider]:
|
|
159
|
+
"""Parse YAML file into DiscoveredProvider.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
file_path: Path to YAML file
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
DiscoveredProvider or None if disabled/invalid
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
with open(file_path) as f:
|
|
169
|
+
data = yaml.safe_load(f)
|
|
170
|
+
except yaml.YAMLError as e:
|
|
171
|
+
# Multi-document YAML or invalid YAML - skip silently
|
|
172
|
+
logger.debug(f"Skipping non-provider YAML file {file_path}: {e}")
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
if not data:
|
|
176
|
+
logger.debug(f"Empty file: {file_path}")
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
if not isinstance(data, dict):
|
|
180
|
+
logger.debug(f"Not a dict, skipping: {file_path}")
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
# Check if this looks like a provider definition (must have name or mode)
|
|
184
|
+
# Skip files that look like docker-compose or k8s manifests
|
|
185
|
+
if "services" in data or "apiVersion" in data or "kind" in data:
|
|
186
|
+
logger.debug(f"Skipping non-provider file (docker-compose or k8s): {file_path}")
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
if not data.get("enabled", True):
|
|
190
|
+
logger.debug(f"Provider disabled in {file_path}")
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
# Must have either 'name' or 'mode' to be considered a provider
|
|
194
|
+
if "name" not in data and "mode" not in data and "connection" not in data:
|
|
195
|
+
logger.debug(f"Not a provider definition, skipping: {file_path}")
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
name = data.get("name", file_path.stem)
|
|
199
|
+
mode = data.get("mode", "subprocess")
|
|
200
|
+
ttl = data.get("ttl", self.default_ttl)
|
|
201
|
+
|
|
202
|
+
# Parse connection info
|
|
203
|
+
connection = data.get("connection", {})
|
|
204
|
+
connection_info = self._parse_connection(connection, mode)
|
|
205
|
+
|
|
206
|
+
# Parse metadata
|
|
207
|
+
metadata = {
|
|
208
|
+
"file_path": str(file_path),
|
|
209
|
+
"file_name": file_path.name,
|
|
210
|
+
**data.get("metadata", {}),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return DiscoveredProvider.create(
|
|
214
|
+
name=name,
|
|
215
|
+
source_type=self.source_type,
|
|
216
|
+
mode=mode,
|
|
217
|
+
connection_info=connection_info,
|
|
218
|
+
metadata=metadata,
|
|
219
|
+
ttl_seconds=ttl,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def _parse_connection(self, connection: Dict[str, Any], mode: str) -> Dict[str, Any]:
|
|
223
|
+
"""Parse connection configuration.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
connection: Connection dict from YAML
|
|
227
|
+
mode: Provider mode
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Normalized connection info
|
|
231
|
+
"""
|
|
232
|
+
result = {}
|
|
233
|
+
|
|
234
|
+
if mode in ("subprocess", "stdio"):
|
|
235
|
+
# Command-based connection
|
|
236
|
+
command = connection.get("command")
|
|
237
|
+
args = connection.get("args", [])
|
|
238
|
+
|
|
239
|
+
if isinstance(command, list):
|
|
240
|
+
result["command"] = command
|
|
241
|
+
elif isinstance(command, str):
|
|
242
|
+
result["command"] = [command] + args
|
|
243
|
+
|
|
244
|
+
if "env" in connection:
|
|
245
|
+
result["env"] = connection["env"]
|
|
246
|
+
|
|
247
|
+
elif mode in ("http", "sse", "remote"):
|
|
248
|
+
# Network-based connection
|
|
249
|
+
result["host"] = connection.get("host", "localhost")
|
|
250
|
+
result["port"] = int(connection.get("port", 8080))
|
|
251
|
+
result["health_path"] = connection.get("health_path", "/health")
|
|
252
|
+
|
|
253
|
+
if "endpoint" in connection:
|
|
254
|
+
result["endpoint"] = connection["endpoint"]
|
|
255
|
+
|
|
256
|
+
# Copy any additional fields
|
|
257
|
+
for key in ("timeout", "retry_count", "retry_delay"):
|
|
258
|
+
if key in connection:
|
|
259
|
+
result[key] = connection[key]
|
|
260
|
+
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
async def health_check(self) -> bool:
|
|
264
|
+
"""Check if discovery path exists and is readable.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
True if path is accessible
|
|
268
|
+
"""
|
|
269
|
+
try:
|
|
270
|
+
return self.path.exists() and self.path.is_dir()
|
|
271
|
+
except Exception as e:
|
|
272
|
+
logger.warning(f"Filesystem health check failed: {e}")
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
async def start(self) -> None:
|
|
276
|
+
"""Start the filesystem discovery source and file watcher."""
|
|
277
|
+
if not self.watch or not WATCHDOG_AVAILABLE:
|
|
278
|
+
logger.info("Filesystem discovery source started (watching disabled)")
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
if not self.path.exists():
|
|
282
|
+
logger.warning(f"Cannot start watcher, path does not exist: {self.path}")
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
self._event_handler = _FileChangeHandler(self)
|
|
287
|
+
self._observer = Observer()
|
|
288
|
+
self._observer.schedule(self._event_handler, str(self.path), recursive=False)
|
|
289
|
+
self._observer.start()
|
|
290
|
+
logger.info(f"Filesystem discovery source started (watching {self.path})")
|
|
291
|
+
except Exception as e:
|
|
292
|
+
logger.error(f"Failed to start file watcher: {e}")
|
|
293
|
+
|
|
294
|
+
async def stop(self) -> None:
|
|
295
|
+
"""Stop the filesystem discovery source and file watcher."""
|
|
296
|
+
if self._observer:
|
|
297
|
+
try:
|
|
298
|
+
self._observer.stop()
|
|
299
|
+
self._observer.join(timeout=5)
|
|
300
|
+
except Exception as e:
|
|
301
|
+
logger.warning(f"Error stopping file watcher: {e}")
|
|
302
|
+
finally:
|
|
303
|
+
self._observer = None
|
|
304
|
+
self._event_handler = None
|
|
305
|
+
|
|
306
|
+
logger.info("Filesystem discovery source stopped")
|
|
307
|
+
|
|
308
|
+
async def _handle_file_change(self, file_path: Path, event_type: str) -> None:
|
|
309
|
+
"""Handle file system events.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
file_path: Changed file path
|
|
313
|
+
event_type: Type of change (created, modified, deleted)
|
|
314
|
+
"""
|
|
315
|
+
if file_path.suffix not in (".yaml", ".yml"):
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
if event_type == "deleted":
|
|
319
|
+
# Find provider by file path and notify loss
|
|
320
|
+
for name, provider in list(self._cached_providers.items()):
|
|
321
|
+
if provider.metadata.get("file_path") == str(file_path):
|
|
322
|
+
del self._cached_providers[name]
|
|
323
|
+
await self.on_provider_lost(name)
|
|
324
|
+
break
|
|
325
|
+
else:
|
|
326
|
+
# Created or modified - re-parse
|
|
327
|
+
try:
|
|
328
|
+
new_provider = self._parse_file(file_path)
|
|
329
|
+
if new_provider:
|
|
330
|
+
old_provider = self._cached_providers.get(new_provider.name)
|
|
331
|
+
if old_provider and old_provider.fingerprint != new_provider.fingerprint:
|
|
332
|
+
await self.on_provider_changed(old_provider, new_provider)
|
|
333
|
+
elif not old_provider:
|
|
334
|
+
await self.on_provider_discovered(new_provider)
|
|
335
|
+
self._cached_providers[new_provider.name] = new_provider
|
|
336
|
+
except Exception as e:
|
|
337
|
+
logger.error(f"Error parsing changed file {file_path}: {e}")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# Only define _FileChangeHandler when watchdog is available
|
|
341
|
+
if WATCHDOG_AVAILABLE:
|
|
342
|
+
|
|
343
|
+
class _FileChangeHandler(FileSystemEventHandler):
|
|
344
|
+
"""Watchdog event handler for file changes."""
|
|
345
|
+
|
|
346
|
+
def __init__(self, source: FilesystemDiscoverySource):
|
|
347
|
+
self.source = source
|
|
348
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
349
|
+
|
|
350
|
+
def _get_loop(self) -> asyncio.AbstractEventLoop:
|
|
351
|
+
if self._loop is None or self._loop.is_closed():
|
|
352
|
+
try:
|
|
353
|
+
self._loop = asyncio.get_running_loop()
|
|
354
|
+
except RuntimeError:
|
|
355
|
+
self._loop = asyncio.new_event_loop()
|
|
356
|
+
return self._loop
|
|
357
|
+
|
|
358
|
+
def _schedule_async(self, coro):
|
|
359
|
+
"""Schedule async coroutine from sync context."""
|
|
360
|
+
loop = self._get_loop()
|
|
361
|
+
if loop.is_running():
|
|
362
|
+
asyncio.run_coroutine_threadsafe(coro, loop)
|
|
363
|
+
else:
|
|
364
|
+
loop.run_until_complete(coro)
|
|
365
|
+
|
|
366
|
+
def on_created(self, event):
|
|
367
|
+
if not event.is_directory:
|
|
368
|
+
self._schedule_async(self.source._handle_file_change(Path(event.src_path), "created"))
|
|
369
|
+
|
|
370
|
+
def on_modified(self, event):
|
|
371
|
+
if not event.is_directory:
|
|
372
|
+
self._schedule_async(self.source._handle_file_change(Path(event.src_path), "modified"))
|
|
373
|
+
|
|
374
|
+
def on_deleted(self, event):
|
|
375
|
+
if not event.is_directory:
|
|
376
|
+
self._schedule_async(self.source._handle_file_change(Path(event.src_path), "deleted"))
|
|
377
|
+
|
|
378
|
+
# Make handler available when watchdog is installed
|
|
379
|
+
FileChangeHandler = _FileChangeHandler
|
|
380
|
+
else:
|
|
381
|
+
# Stub for when watchdog is not available
|
|
382
|
+
_FileChangeHandler = None
|
|
383
|
+
FileChangeHandler = None
|