mcp-hangar 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. mcp_hangar/__init__.py +139 -0
  2. mcp_hangar/application/__init__.py +1 -0
  3. mcp_hangar/application/commands/__init__.py +67 -0
  4. mcp_hangar/application/commands/auth_commands.py +118 -0
  5. mcp_hangar/application/commands/auth_handlers.py +296 -0
  6. mcp_hangar/application/commands/commands.py +59 -0
  7. mcp_hangar/application/commands/handlers.py +189 -0
  8. mcp_hangar/application/discovery/__init__.py +21 -0
  9. mcp_hangar/application/discovery/discovery_metrics.py +283 -0
  10. mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
  11. mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
  12. mcp_hangar/application/discovery/security_validator.py +414 -0
  13. mcp_hangar/application/event_handlers/__init__.py +50 -0
  14. mcp_hangar/application/event_handlers/alert_handler.py +191 -0
  15. mcp_hangar/application/event_handlers/audit_handler.py +203 -0
  16. mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
  17. mcp_hangar/application/event_handlers/logging_handler.py +69 -0
  18. mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
  19. mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
  20. mcp_hangar/application/event_handlers/security_handler.py +604 -0
  21. mcp_hangar/application/mcp/tooling.py +158 -0
  22. mcp_hangar/application/ports/__init__.py +9 -0
  23. mcp_hangar/application/ports/observability.py +237 -0
  24. mcp_hangar/application/queries/__init__.py +52 -0
  25. mcp_hangar/application/queries/auth_handlers.py +237 -0
  26. mcp_hangar/application/queries/auth_queries.py +118 -0
  27. mcp_hangar/application/queries/handlers.py +227 -0
  28. mcp_hangar/application/read_models/__init__.py +11 -0
  29. mcp_hangar/application/read_models/provider_views.py +139 -0
  30. mcp_hangar/application/sagas/__init__.py +11 -0
  31. mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
  32. mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
  33. mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
  34. mcp_hangar/application/services/__init__.py +9 -0
  35. mcp_hangar/application/services/provider_service.py +208 -0
  36. mcp_hangar/application/services/traced_provider_service.py +211 -0
  37. mcp_hangar/bootstrap/runtime.py +328 -0
  38. mcp_hangar/context.py +178 -0
  39. mcp_hangar/domain/__init__.py +117 -0
  40. mcp_hangar/domain/contracts/__init__.py +57 -0
  41. mcp_hangar/domain/contracts/authentication.py +225 -0
  42. mcp_hangar/domain/contracts/authorization.py +229 -0
  43. mcp_hangar/domain/contracts/event_store.py +178 -0
  44. mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
  45. mcp_hangar/domain/contracts/persistence.py +383 -0
  46. mcp_hangar/domain/contracts/provider_runtime.py +146 -0
  47. mcp_hangar/domain/discovery/__init__.py +20 -0
  48. mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
  49. mcp_hangar/domain/discovery/discovered_provider.py +185 -0
  50. mcp_hangar/domain/discovery/discovery_service.py +412 -0
  51. mcp_hangar/domain/discovery/discovery_source.py +192 -0
  52. mcp_hangar/domain/events.py +433 -0
  53. mcp_hangar/domain/exceptions.py +525 -0
  54. mcp_hangar/domain/model/__init__.py +70 -0
  55. mcp_hangar/domain/model/aggregate.py +58 -0
  56. mcp_hangar/domain/model/circuit_breaker.py +152 -0
  57. mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
  58. mcp_hangar/domain/model/event_sourced_provider.py +423 -0
  59. mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
  60. mcp_hangar/domain/model/health_tracker.py +183 -0
  61. mcp_hangar/domain/model/load_balancer.py +185 -0
  62. mcp_hangar/domain/model/provider.py +810 -0
  63. mcp_hangar/domain/model/provider_group.py +656 -0
  64. mcp_hangar/domain/model/tool_catalog.py +105 -0
  65. mcp_hangar/domain/policies/__init__.py +19 -0
  66. mcp_hangar/domain/policies/provider_health.py +187 -0
  67. mcp_hangar/domain/repository.py +249 -0
  68. mcp_hangar/domain/security/__init__.py +85 -0
  69. mcp_hangar/domain/security/input_validator.py +710 -0
  70. mcp_hangar/domain/security/rate_limiter.py +387 -0
  71. mcp_hangar/domain/security/roles.py +237 -0
  72. mcp_hangar/domain/security/sanitizer.py +387 -0
  73. mcp_hangar/domain/security/secrets.py +501 -0
  74. mcp_hangar/domain/services/__init__.py +20 -0
  75. mcp_hangar/domain/services/audit_service.py +376 -0
  76. mcp_hangar/domain/services/image_builder.py +328 -0
  77. mcp_hangar/domain/services/provider_launcher.py +1046 -0
  78. mcp_hangar/domain/value_objects.py +1138 -0
  79. mcp_hangar/errors.py +818 -0
  80. mcp_hangar/fastmcp_server.py +1105 -0
  81. mcp_hangar/gc.py +134 -0
  82. mcp_hangar/infrastructure/__init__.py +79 -0
  83. mcp_hangar/infrastructure/async_executor.py +133 -0
  84. mcp_hangar/infrastructure/auth/__init__.py +37 -0
  85. mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
  86. mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
  87. mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
  88. mcp_hangar/infrastructure/auth/middleware.py +340 -0
  89. mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
  90. mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
  91. mcp_hangar/infrastructure/auth/projections.py +366 -0
  92. mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
  93. mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
  94. mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
  95. mcp_hangar/infrastructure/command_bus.py +112 -0
  96. mcp_hangar/infrastructure/discovery/__init__.py +110 -0
  97. mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
  98. mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
  99. mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
  100. mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
  101. mcp_hangar/infrastructure/event_bus.py +260 -0
  102. mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
  103. mcp_hangar/infrastructure/event_store.py +396 -0
  104. mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
  105. mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
  106. mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
  107. mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
  108. mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
  109. mcp_hangar/infrastructure/metrics_publisher.py +36 -0
  110. mcp_hangar/infrastructure/observability/__init__.py +10 -0
  111. mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
  112. mcp_hangar/infrastructure/persistence/__init__.py +33 -0
  113. mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
  114. mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
  115. mcp_hangar/infrastructure/persistence/database.py +333 -0
  116. mcp_hangar/infrastructure/persistence/database_common.py +330 -0
  117. mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
  118. mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
  119. mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
  120. mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
  121. mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
  122. mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
  123. mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
  124. mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
  125. mcp_hangar/infrastructure/query_bus.py +153 -0
  126. mcp_hangar/infrastructure/saga_manager.py +401 -0
  127. mcp_hangar/logging_config.py +209 -0
  128. mcp_hangar/metrics.py +1007 -0
  129. mcp_hangar/models.py +31 -0
  130. mcp_hangar/observability/__init__.py +54 -0
  131. mcp_hangar/observability/health.py +487 -0
  132. mcp_hangar/observability/metrics.py +319 -0
  133. mcp_hangar/observability/tracing.py +433 -0
  134. mcp_hangar/progress.py +542 -0
  135. mcp_hangar/retry.py +613 -0
  136. mcp_hangar/server/__init__.py +120 -0
  137. mcp_hangar/server/__main__.py +6 -0
  138. mcp_hangar/server/auth_bootstrap.py +340 -0
  139. mcp_hangar/server/auth_cli.py +335 -0
  140. mcp_hangar/server/auth_config.py +305 -0
  141. mcp_hangar/server/bootstrap.py +735 -0
  142. mcp_hangar/server/cli.py +161 -0
  143. mcp_hangar/server/config.py +224 -0
  144. mcp_hangar/server/context.py +215 -0
  145. mcp_hangar/server/http_auth_middleware.py +165 -0
  146. mcp_hangar/server/lifecycle.py +467 -0
  147. mcp_hangar/server/state.py +117 -0
  148. mcp_hangar/server/tools/__init__.py +16 -0
  149. mcp_hangar/server/tools/discovery.py +186 -0
  150. mcp_hangar/server/tools/groups.py +75 -0
  151. mcp_hangar/server/tools/health.py +301 -0
  152. mcp_hangar/server/tools/provider.py +939 -0
  153. mcp_hangar/server/tools/registry.py +320 -0
  154. mcp_hangar/server/validation.py +113 -0
  155. mcp_hangar/stdio_client.py +229 -0
  156. mcp_hangar-0.2.0.dist-info/METADATA +347 -0
  157. mcp_hangar-0.2.0.dist-info/RECORD +160 -0
  158. mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
  159. mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
  160. mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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