mcp-mesh 0.5.4__py3-none-any.whl → 0.5.6__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_mesh/__init__.py +5 -2
- _mcp_mesh/engine/decorator_registry.py +95 -0
- _mcp_mesh/engine/mcp_client_proxy.py +17 -7
- _mcp_mesh/engine/unified_mcp_proxy.py +43 -40
- _mcp_mesh/pipeline/api_startup/fastapi_discovery.py +4 -167
- _mcp_mesh/pipeline/mcp_heartbeat/heartbeat_orchestrator.py +4 -0
- _mcp_mesh/pipeline/mcp_heartbeat/lifespan_integration.py +13 -0
- _mcp_mesh/pipeline/mcp_startup/__init__.py +2 -0
- _mcp_mesh/pipeline/mcp_startup/fastapiserver_setup.py +306 -163
- _mcp_mesh/pipeline/mcp_startup/server_discovery.py +164 -0
- _mcp_mesh/pipeline/mcp_startup/startup_orchestrator.py +198 -160
- _mcp_mesh/pipeline/mcp_startup/startup_pipeline.py +7 -4
- _mcp_mesh/pipeline/shared/mesh_pipeline.py +4 -0
- _mcp_mesh/shared/server_discovery.py +312 -0
- _mcp_mesh/shared/simple_shutdown.py +217 -0
- {mcp_mesh-0.5.4.dist-info → mcp_mesh-0.5.6.dist-info}/METADATA +1 -1
- {mcp_mesh-0.5.4.dist-info → mcp_mesh-0.5.6.dist-info}/RECORD +20 -18
- mesh/decorators.py +303 -36
- _mcp_mesh/engine/threading_utils.py +0 -223
- {mcp_mesh-0.5.4.dist-info → mcp_mesh-0.5.6.dist-info}/WHEEL +0 -0
- {mcp_mesh-0.5.4.dist-info → mcp_mesh-0.5.6.dist-info}/licenses/LICENSE +0 -0
|
@@ -72,27 +72,43 @@ class DebounceCoordinator:
|
|
|
72
72
|
f"⏰ Scheduled processing in {self.delay_seconds} seconds"
|
|
73
73
|
)
|
|
74
74
|
|
|
75
|
+
def cleanup(self) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Clean up any pending timers and reset state.
|
|
78
|
+
|
|
79
|
+
This is called during test teardown to prevent background threads
|
|
80
|
+
from interfering with subsequent tests.
|
|
81
|
+
"""
|
|
82
|
+
with self._lock:
|
|
83
|
+
if self._pending_timer is not None:
|
|
84
|
+
self.logger.debug("🧹 Cleaning up pending processing timer")
|
|
85
|
+
self._pending_timer.cancel()
|
|
86
|
+
self._pending_timer = None
|
|
87
|
+
self._orchestrator = None
|
|
88
|
+
|
|
75
89
|
def _determine_pipeline_type(self) -> str:
|
|
76
90
|
"""
|
|
77
91
|
Determine which pipeline to execute based on registered decorators.
|
|
78
|
-
|
|
92
|
+
|
|
79
93
|
Returns:
|
|
80
94
|
"mcp": Only MCP agents/tools found
|
|
81
|
-
"api": Only API routes found
|
|
95
|
+
"api": Only API routes found
|
|
82
96
|
"mixed": Both MCP and API decorators found (throws exception)
|
|
83
97
|
"none": No decorators found
|
|
84
98
|
"""
|
|
85
99
|
from ...engine.decorator_registry import DecoratorRegistry
|
|
86
|
-
|
|
100
|
+
|
|
87
101
|
agents = DecoratorRegistry.get_mesh_agents()
|
|
88
102
|
tools = DecoratorRegistry.get_mesh_tools()
|
|
89
103
|
routes = DecoratorRegistry.get_all_by_type("mesh_route")
|
|
90
|
-
|
|
104
|
+
|
|
91
105
|
has_mcp = len(agents) > 0 or len(tools) > 0
|
|
92
106
|
has_api = len(routes) > 0
|
|
93
|
-
|
|
94
|
-
self.logger.debug(
|
|
95
|
-
|
|
107
|
+
|
|
108
|
+
self.logger.debug(
|
|
109
|
+
f"🔍 Pipeline type detection: MCP={has_mcp} ({len(agents)} agents, {len(tools)} tools), API={has_api} ({len(routes)} routes)"
|
|
110
|
+
)
|
|
111
|
+
|
|
96
112
|
if has_api and has_mcp:
|
|
97
113
|
return "mixed"
|
|
98
114
|
elif has_api:
|
|
@@ -105,6 +121,7 @@ class DebounceCoordinator:
|
|
|
105
121
|
def _execute_processing(self) -> None:
|
|
106
122
|
"""Execute the processing (called by timer)."""
|
|
107
123
|
try:
|
|
124
|
+
|
|
108
125
|
if self._orchestrator is None:
|
|
109
126
|
self.logger.error("❌ No orchestrator set for processing")
|
|
110
127
|
return
|
|
@@ -115,7 +132,7 @@ class DebounceCoordinator:
|
|
|
115
132
|
|
|
116
133
|
# Determine which pipeline to execute
|
|
117
134
|
pipeline_type = self._determine_pipeline_type()
|
|
118
|
-
|
|
135
|
+
|
|
119
136
|
if pipeline_type == "mixed":
|
|
120
137
|
error_msg = (
|
|
121
138
|
"❌ Mixed mode not supported: Cannot use @mesh.route decorators "
|
|
@@ -127,7 +144,7 @@ class DebounceCoordinator:
|
|
|
127
144
|
elif pipeline_type == "none":
|
|
128
145
|
self.logger.warning("⚠️ No decorators found - nothing to process")
|
|
129
146
|
return
|
|
130
|
-
|
|
147
|
+
|
|
131
148
|
# Execute the pipeline using asyncio.run
|
|
132
149
|
import asyncio
|
|
133
150
|
|
|
@@ -139,7 +156,7 @@ class DebounceCoordinator:
|
|
|
139
156
|
|
|
140
157
|
if auto_run_enabled:
|
|
141
158
|
self.logger.info("🔄 Auto-run enabled - using FastAPI natural blocking")
|
|
142
|
-
|
|
159
|
+
|
|
143
160
|
# Execute appropriate pipeline based on type
|
|
144
161
|
if pipeline_type == "mcp":
|
|
145
162
|
# Phase 1: Run async MCP pipeline setup
|
|
@@ -160,12 +177,88 @@ class DebounceCoordinator:
|
|
|
160
177
|
# For API services, ONLY do dependency injection - user controls their FastAPI server
|
|
161
178
|
# Dependency injection is already complete from pipeline execution
|
|
162
179
|
# Optionally start heartbeat in background (non-blocking)
|
|
163
|
-
self._setup_api_heartbeat_background(
|
|
164
|
-
|
|
180
|
+
self._setup_api_heartbeat_background(
|
|
181
|
+
heartbeat_config, pipeline_context
|
|
182
|
+
)
|
|
183
|
+
self.logger.info(
|
|
184
|
+
"✅ API dependency injection complete - user's FastAPI server can now start"
|
|
185
|
+
)
|
|
165
186
|
return # Don't block - let user's uvicorn run
|
|
166
187
|
elif fastapi_app and binding_config:
|
|
167
|
-
# For MCP agents
|
|
168
|
-
self.
|
|
188
|
+
# For MCP agents - use same daemon thread pattern as API apps
|
|
189
|
+
self._setup_mcp_heartbeat_background(
|
|
190
|
+
heartbeat_config, pipeline_context
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Check if server was already reused from immediate uvicorn start
|
|
194
|
+
server_reused = pipeline_context.get("server_reused", False)
|
|
195
|
+
existing_server = pipeline_context.get("existing_server", {})
|
|
196
|
+
|
|
197
|
+
if server_reused:
|
|
198
|
+
# Check server status to determine action
|
|
199
|
+
server_status = existing_server.get("status", "unknown")
|
|
200
|
+
|
|
201
|
+
if server_status == "configured":
|
|
202
|
+
self.logger.info(
|
|
203
|
+
"🔄 CONFIGURED SERVER: Starting configured uvicorn server within pipeline event loop"
|
|
204
|
+
)
|
|
205
|
+
# Start the configured server within this event loop
|
|
206
|
+
server_obj = existing_server.get("server")
|
|
207
|
+
if server_obj:
|
|
208
|
+
self.logger.info(
|
|
209
|
+
"🚀 CONFIGURED SERVER: Starting server.serve() within pipeline context"
|
|
210
|
+
)
|
|
211
|
+
# This runs in the same event loop as the pipeline - no conflict!
|
|
212
|
+
import asyncio
|
|
213
|
+
|
|
214
|
+
# Define async function to run the server
|
|
215
|
+
async def run_configured_server():
|
|
216
|
+
await server_obj.serve()
|
|
217
|
+
|
|
218
|
+
# Run the server within the existing event loop context
|
|
219
|
+
asyncio.run(run_configured_server())
|
|
220
|
+
self.logger.info(
|
|
221
|
+
"✅ CONFIGURED SERVER: Server started successfully"
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
self.logger.error(
|
|
225
|
+
"❌ CONFIGURED SERVER: No server object found, falling back to uvicorn.run()"
|
|
226
|
+
)
|
|
227
|
+
self._start_blocking_fastapi_server(
|
|
228
|
+
fastapi_app, binding_config
|
|
229
|
+
)
|
|
230
|
+
elif server_status == "running":
|
|
231
|
+
self.logger.info(
|
|
232
|
+
"🔄 RUNNING SERVER: Server already running with proper lifecycle, pipeline skipping uvicorn.run()"
|
|
233
|
+
)
|
|
234
|
+
self.logger.info(
|
|
235
|
+
"✅ FastMCP mounted on running server - agent in normal operating state"
|
|
236
|
+
)
|
|
237
|
+
# Server is already running in normal state - no further action needed
|
|
238
|
+
return
|
|
239
|
+
else:
|
|
240
|
+
self.logger.info(
|
|
241
|
+
"🔄 SERVER REUSE: Existing server detected, skipping uvicorn.run()"
|
|
242
|
+
)
|
|
243
|
+
self.logger.info(
|
|
244
|
+
"✅ FastMCP mounted on existing server - agent ready"
|
|
245
|
+
)
|
|
246
|
+
# Keep the process alive but don't start new uvicorn
|
|
247
|
+
# Use a robust keep-alive pattern that doesn't overflow
|
|
248
|
+
import time
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
while True:
|
|
252
|
+
time.sleep(
|
|
253
|
+
3600
|
|
254
|
+
) # Sleep 1 hour at a time instead of infinity
|
|
255
|
+
except KeyboardInterrupt:
|
|
256
|
+
self.logger.info(
|
|
257
|
+
"🛑 Server reuse mode interrupted - shutting down"
|
|
258
|
+
)
|
|
259
|
+
return
|
|
260
|
+
else:
|
|
261
|
+
self._start_blocking_fastapi_server(fastapi_app, binding_config)
|
|
169
262
|
else:
|
|
170
263
|
self.logger.warning(
|
|
171
264
|
"⚠️ Auto-run enabled but no FastAPI app prepared - exiting"
|
|
@@ -173,14 +266,14 @@ class DebounceCoordinator:
|
|
|
173
266
|
else:
|
|
174
267
|
# Single execution mode (for testing/debugging)
|
|
175
268
|
self.logger.info("🏁 Auto-run disabled - single execution mode")
|
|
176
|
-
|
|
269
|
+
|
|
177
270
|
if pipeline_type == "mcp":
|
|
178
271
|
result = asyncio.run(self._orchestrator.process_once())
|
|
179
272
|
elif pipeline_type == "api":
|
|
180
273
|
result = asyncio.run(self._orchestrator.process_api_once())
|
|
181
274
|
else:
|
|
182
275
|
raise RuntimeError(f"Unsupported pipeline type: {pipeline_type}")
|
|
183
|
-
|
|
276
|
+
|
|
184
277
|
self.logger.info("✅ Pipeline execution completed, exiting")
|
|
185
278
|
|
|
186
279
|
except Exception as e:
|
|
@@ -212,10 +305,8 @@ class DebounceCoordinator:
|
|
|
212
305
|
|
|
213
306
|
except KeyboardInterrupt:
|
|
214
307
|
self.logger.info(
|
|
215
|
-
"🔴 Received KeyboardInterrupt,
|
|
308
|
+
"🔴 Received KeyboardInterrupt, shutdown will be handled by FastAPI lifespan"
|
|
216
309
|
)
|
|
217
|
-
# Perform graceful shutdown before exiting
|
|
218
|
-
self._perform_graceful_shutdown()
|
|
219
310
|
except Exception as e:
|
|
220
311
|
self.logger.error(f"❌ FastAPI server error: {e}")
|
|
221
312
|
raise
|
|
@@ -229,41 +320,48 @@ class DebounceCoordinator:
|
|
|
229
320
|
heartbeat_config["context"] = pipeline_context
|
|
230
321
|
service_id = heartbeat_config.get("service_id", "unknown")
|
|
231
322
|
standalone_mode = heartbeat_config.get("standalone_mode", False)
|
|
232
|
-
|
|
323
|
+
|
|
233
324
|
if standalone_mode:
|
|
234
325
|
self.logger.info(
|
|
235
326
|
f"📝 API service '{service_id}' configured in standalone mode - no heartbeat"
|
|
236
327
|
)
|
|
237
328
|
return
|
|
238
|
-
|
|
329
|
+
|
|
239
330
|
self.logger.info(
|
|
240
331
|
f"🔗 Setting up background API heartbeat for service '{service_id}'"
|
|
241
332
|
)
|
|
242
|
-
|
|
333
|
+
|
|
243
334
|
# Import heartbeat functionality
|
|
244
|
-
from ..api_heartbeat.api_lifespan_integration import api_heartbeat_lifespan_task
|
|
245
|
-
import threading
|
|
246
335
|
import asyncio
|
|
247
|
-
|
|
336
|
+
import threading
|
|
337
|
+
|
|
338
|
+
from ..api_heartbeat.api_lifespan_integration import (
|
|
339
|
+
api_heartbeat_lifespan_task,
|
|
340
|
+
)
|
|
341
|
+
|
|
248
342
|
def run_heartbeat():
|
|
249
343
|
"""Run heartbeat in separate thread with its own event loop."""
|
|
250
|
-
self.logger.debug(
|
|
344
|
+
self.logger.debug(
|
|
345
|
+
f"Starting background heartbeat thread for {service_id}"
|
|
346
|
+
)
|
|
251
347
|
try:
|
|
252
348
|
# Create new event loop for this thread
|
|
253
349
|
loop = asyncio.new_event_loop()
|
|
254
350
|
asyncio.set_event_loop(loop)
|
|
255
|
-
|
|
351
|
+
|
|
256
352
|
# Run heartbeat task
|
|
257
|
-
loop.run_until_complete(
|
|
353
|
+
loop.run_until_complete(
|
|
354
|
+
api_heartbeat_lifespan_task(heartbeat_config)
|
|
355
|
+
)
|
|
258
356
|
except Exception as e:
|
|
259
357
|
self.logger.error(f"❌ Background heartbeat error: {e}")
|
|
260
358
|
finally:
|
|
261
359
|
loop.close()
|
|
262
|
-
|
|
360
|
+
|
|
263
361
|
# Start heartbeat in daemon thread (won't prevent process exit)
|
|
264
362
|
heartbeat_thread = threading.Thread(target=run_heartbeat, daemon=True)
|
|
265
363
|
heartbeat_thread.start()
|
|
266
|
-
|
|
364
|
+
|
|
267
365
|
self.logger.info(
|
|
268
366
|
f"💓 Background API heartbeat thread started for service '{service_id}'"
|
|
269
367
|
)
|
|
@@ -272,64 +370,62 @@ class DebounceCoordinator:
|
|
|
272
370
|
self.logger.warning(f"⚠️ Could not setup API heartbeat: {e}")
|
|
273
371
|
# Don't fail - heartbeat is optional for API services
|
|
274
372
|
|
|
275
|
-
def
|
|
276
|
-
|
|
373
|
+
def _setup_mcp_heartbeat_background(
|
|
374
|
+
self, heartbeat_config: dict[str, Any], pipeline_context: dict[str, Any]
|
|
375
|
+
) -> None:
|
|
376
|
+
"""Setup MCP heartbeat to run in background - same pattern as API apps."""
|
|
277
377
|
try:
|
|
278
|
-
#
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
except Exception as e:
|
|
283
|
-
self.logger.error(f"❌ Graceful shutdown failed: {e}")
|
|
378
|
+
# Populate heartbeat context with current pipeline context
|
|
379
|
+
heartbeat_config["context"] = pipeline_context
|
|
380
|
+
agent_id = heartbeat_config.get("agent_id", "unknown")
|
|
381
|
+
standalone_mode = heartbeat_config.get("standalone_mode", False)
|
|
284
382
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
# Get the latest pipeline context from the orchestrator
|
|
289
|
-
if self._orchestrator is None:
|
|
290
|
-
self.logger.warning(
|
|
291
|
-
"🚨 No orchestrator available for graceful shutdown"
|
|
383
|
+
if standalone_mode:
|
|
384
|
+
self.logger.info(
|
|
385
|
+
f"📝 MCP agent '{agent_id}' configured in standalone mode - no heartbeat"
|
|
292
386
|
)
|
|
293
387
|
return
|
|
294
388
|
|
|
295
|
-
|
|
296
|
-
|
|
389
|
+
self.logger.info(
|
|
390
|
+
f"🔗 Setting up background MCP heartbeat for agent '{agent_id}'"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# Import heartbeat functionality
|
|
394
|
+
import asyncio
|
|
395
|
+
import threading
|
|
297
396
|
|
|
298
|
-
|
|
299
|
-
registry_url = pipeline_context.get("registry_url")
|
|
300
|
-
agent_id = pipeline_context.get("agent_id")
|
|
397
|
+
from ..mcp_heartbeat.lifespan_integration import heartbeat_lifespan_task
|
|
301
398
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
399
|
+
def run_heartbeat():
|
|
400
|
+
"""Run heartbeat in separate thread with its own event loop."""
|
|
401
|
+
self.logger.debug(
|
|
402
|
+
f"Starting background heartbeat thread for {agent_id}"
|
|
305
403
|
)
|
|
306
|
-
|
|
404
|
+
try:
|
|
405
|
+
# Create new event loop for this thread
|
|
406
|
+
loop = asyncio.new_event_loop()
|
|
407
|
+
asyncio.set_event_loop(loop)
|
|
307
408
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
409
|
+
# Run heartbeat task
|
|
410
|
+
loop.run_until_complete(heartbeat_lifespan_task(heartbeat_config))
|
|
411
|
+
except Exception as e:
|
|
412
|
+
self.logger.error(f"❌ Background heartbeat error: {e}")
|
|
413
|
+
finally:
|
|
414
|
+
loop.close()
|
|
314
415
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
416
|
+
# Start heartbeat in daemon thread (won't prevent process exit)
|
|
417
|
+
heartbeat_thread = threading.Thread(target=run_heartbeat, daemon=True)
|
|
418
|
+
heartbeat_thread.start()
|
|
318
419
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
self.logger.info(
|
|
323
|
-
f"🏁 Graceful shutdown completed for agent '{agent_id}'"
|
|
324
|
-
)
|
|
325
|
-
else:
|
|
326
|
-
self.logger.warning(
|
|
327
|
-
f"⚠️ Graceful shutdown failed for agent '{agent_id}' - continuing shutdown"
|
|
328
|
-
)
|
|
420
|
+
self.logger.info(
|
|
421
|
+
f"💓 Background MCP heartbeat thread started for agent '{agent_id}'"
|
|
422
|
+
)
|
|
329
423
|
|
|
330
424
|
except Exception as e:
|
|
331
|
-
|
|
332
|
-
|
|
425
|
+
self.logger.warning(f"⚠️ Could not setup MCP heartbeat: {e}")
|
|
426
|
+
# Don't fail - heartbeat is optional for MCP agents
|
|
427
|
+
|
|
428
|
+
# Graceful shutdown is now handled by FastAPI lifespan in simple_shutdown.py
|
|
333
429
|
|
|
334
430
|
def _check_auto_run_enabled(self) -> bool:
|
|
335
431
|
"""Check if auto-run is enabled (defaults to True for persistent service behavior)."""
|
|
@@ -364,6 +460,20 @@ def get_debounce_coordinator() -> DebounceCoordinator:
|
|
|
364
460
|
return _debounce_coordinator
|
|
365
461
|
|
|
366
462
|
|
|
463
|
+
def clear_debounce_coordinator() -> None:
|
|
464
|
+
"""
|
|
465
|
+
Clear the global debounce coordinator and clean up any pending timers.
|
|
466
|
+
|
|
467
|
+
This function is intended for test cleanup to prevent background threads
|
|
468
|
+
from interfering with subsequent tests.
|
|
469
|
+
"""
|
|
470
|
+
global _debounce_coordinator
|
|
471
|
+
|
|
472
|
+
if _debounce_coordinator is not None:
|
|
473
|
+
_debounce_coordinator.cleanup()
|
|
474
|
+
_debounce_coordinator = None
|
|
475
|
+
|
|
476
|
+
|
|
367
477
|
class MeshOrchestrator:
|
|
368
478
|
"""
|
|
369
479
|
Pipeline orchestrator that manages the complete MCP Mesh lifecycle.
|
|
@@ -399,19 +509,19 @@ class MeshOrchestrator:
|
|
|
399
509
|
async def process_api_once(self) -> dict:
|
|
400
510
|
"""
|
|
401
511
|
Execute the API pipeline once for @mesh.route decorators.
|
|
402
|
-
|
|
512
|
+
|
|
403
513
|
This handles FastAPI route integration and dependency injection setup.
|
|
404
514
|
"""
|
|
405
515
|
self.logger.info(f"🚀 Starting API pipeline execution: {self.name}")
|
|
406
|
-
|
|
516
|
+
|
|
407
517
|
try:
|
|
408
518
|
# Import API pipeline here to avoid circular imports
|
|
409
519
|
from ..api_startup import APIPipeline
|
|
410
|
-
|
|
520
|
+
|
|
411
521
|
# Create and execute API pipeline
|
|
412
522
|
api_pipeline = APIPipeline(name=f"{self.name}-api")
|
|
413
523
|
result = await api_pipeline.execute()
|
|
414
|
-
|
|
524
|
+
|
|
415
525
|
# Convert result to dict for return type (same format as MCP pipeline)
|
|
416
526
|
return {
|
|
417
527
|
"status": result.status.value,
|
|
@@ -420,13 +530,13 @@ class MeshOrchestrator:
|
|
|
420
530
|
"context": result.context,
|
|
421
531
|
"timestamp": result.timestamp.isoformat(),
|
|
422
532
|
}
|
|
423
|
-
|
|
533
|
+
|
|
424
534
|
except Exception as e:
|
|
425
535
|
error_msg = f"API pipeline execution failed: {e}"
|
|
426
536
|
self.logger.error(f"❌ {error_msg}")
|
|
427
|
-
|
|
537
|
+
|
|
428
538
|
return {
|
|
429
|
-
"status": "failed",
|
|
539
|
+
"status": "failed",
|
|
430
540
|
"message": error_msg,
|
|
431
541
|
"errors": [str(e)],
|
|
432
542
|
"context": {},
|
|
@@ -529,12 +639,12 @@ def start_runtime() -> None:
|
|
|
529
639
|
"""
|
|
530
640
|
# Configure logging FIRST before any log messages
|
|
531
641
|
from ...shared.logging_config import configure_logging
|
|
642
|
+
|
|
532
643
|
configure_logging()
|
|
533
|
-
|
|
644
|
+
|
|
534
645
|
logger.info("🔧 Starting MCP Mesh runtime with debouncing")
|
|
535
646
|
|
|
536
|
-
#
|
|
537
|
-
_install_signal_handlers()
|
|
647
|
+
# Signal handlers removed - cleanup now handled by FastAPI lifespan
|
|
538
648
|
|
|
539
649
|
# Create orchestrator and set up debouncing
|
|
540
650
|
orchestrator = get_global_orchestrator()
|
|
@@ -551,80 +661,8 @@ def start_runtime() -> None:
|
|
|
551
661
|
# through the debounce coordinator
|
|
552
662
|
|
|
553
663
|
|
|
554
|
-
|
|
555
|
-
"""Install signal handlers for graceful shutdown in main thread."""
|
|
556
|
-
try:
|
|
557
|
-
import signal
|
|
558
|
-
import threading
|
|
559
|
-
|
|
560
|
-
# Only install if we're in the main thread
|
|
561
|
-
if threading.current_thread() is not threading.main_thread():
|
|
562
|
-
logger.debug("🚨 Not in main thread, skipping signal handler installation")
|
|
563
|
-
return
|
|
564
|
-
|
|
565
|
-
def signal_handler(signum, frame):
|
|
566
|
-
logger.info(f"🔴 Received signal {signum}, performing graceful shutdown...")
|
|
567
|
-
|
|
568
|
-
# Get the global orchestrator and perform shutdown
|
|
569
|
-
orchestrator = get_global_orchestrator()
|
|
570
|
-
|
|
571
|
-
# Create a simple sync shutdown using the stored context
|
|
572
|
-
import asyncio
|
|
573
|
-
|
|
574
|
-
try:
|
|
575
|
-
# Try to get pipeline context for graceful shutdown
|
|
576
|
-
pipeline_context = getattr(orchestrator.pipeline, "_last_context", {})
|
|
577
|
-
registry_url = pipeline_context.get("registry_url")
|
|
578
|
-
agent_id = pipeline_context.get("agent_id")
|
|
579
|
-
|
|
580
|
-
if registry_url and agent_id:
|
|
581
|
-
# Perform synchronous graceful shutdown
|
|
582
|
-
logger.info(
|
|
583
|
-
f"🏁 Gracefully unregistering agent '{agent_id}' from {registry_url}"
|
|
584
|
-
)
|
|
664
|
+
# Signal handlers removed - cleanup now handled by FastAPI lifespan in simple_shutdown.py
|
|
585
665
|
|
|
586
|
-
# Import here to avoid circular imports
|
|
587
|
-
from ...generated.mcp_mesh_registry_client.api_client import (
|
|
588
|
-
ApiClient,
|
|
589
|
-
)
|
|
590
|
-
from ...generated.mcp_mesh_registry_client.configuration import (
|
|
591
|
-
Configuration,
|
|
592
|
-
)
|
|
593
|
-
from ...shared.registry_client_wrapper import RegistryClientWrapper
|
|
594
|
-
|
|
595
|
-
config = Configuration(host=registry_url)
|
|
596
|
-
api_client = ApiClient(configuration=config)
|
|
597
|
-
registry_wrapper = RegistryClientWrapper(api_client)
|
|
598
|
-
|
|
599
|
-
# Run the async unregister in a new event loop
|
|
600
|
-
success = asyncio.run(registry_wrapper.unregister_agent(agent_id))
|
|
601
|
-
if success:
|
|
602
|
-
logger.info(f"✅ Agent '{agent_id}' unregistered successfully")
|
|
603
|
-
else:
|
|
604
|
-
logger.warning(
|
|
605
|
-
f"⚠️ Agent '{agent_id}' unregister failed - continuing shutdown"
|
|
606
|
-
)
|
|
607
|
-
else:
|
|
608
|
-
logger.warning(
|
|
609
|
-
f"🚨 Cannot perform graceful shutdown: missing registry_url={registry_url} or agent_id={agent_id}"
|
|
610
|
-
)
|
|
611
|
-
|
|
612
|
-
except Exception as e:
|
|
613
|
-
logger.error(f"❌ Graceful shutdown error: {e} - continuing shutdown")
|
|
614
|
-
|
|
615
|
-
# Exit gracefully
|
|
616
|
-
import sys
|
|
617
|
-
|
|
618
|
-
sys.exit(0)
|
|
619
|
-
|
|
620
|
-
# Register signal handlers for SIGINT (Ctrl+C) and SIGTERM
|
|
621
|
-
signal.signal(signal.SIGINT, signal_handler)
|
|
622
|
-
signal.signal(signal.SIGTERM, signal_handler)
|
|
623
|
-
|
|
624
|
-
logger.info(
|
|
625
|
-
"📡 Signal handlers registered in main thread for graceful shutdown"
|
|
626
|
-
)
|
|
627
666
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
# Continue without signal handlers - graceful shutdown will rely on FastAPI lifespan
|
|
667
|
+
# Minimal signal handlers restored to provide graceful shutdown with DELETE /heartbeats
|
|
668
|
+
# Avoids complex operations that could conflict with DNS resolution in containers
|
|
@@ -17,6 +17,7 @@ from . import (
|
|
|
17
17
|
HeartbeatLoopStep,
|
|
18
18
|
HeartbeatPreparationStep,
|
|
19
19
|
)
|
|
20
|
+
from .server_discovery import ServerDiscoveryStep
|
|
20
21
|
|
|
21
22
|
logger = logging.getLogger(__name__)
|
|
22
23
|
|
|
@@ -29,9 +30,10 @@ class StartupPipeline(MeshPipeline):
|
|
|
29
30
|
1. Decorator collection
|
|
30
31
|
2. Configuration setup
|
|
31
32
|
3. Heartbeat preparation
|
|
32
|
-
4.
|
|
33
|
-
5.
|
|
34
|
-
6.
|
|
33
|
+
4. Server discovery (existing uvicorn servers)
|
|
34
|
+
5. FastMCP server discovery
|
|
35
|
+
6. Heartbeat loop setup
|
|
36
|
+
7. FastAPI server setup
|
|
35
37
|
|
|
36
38
|
Registry connection is handled in the heartbeat pipeline for automatic
|
|
37
39
|
retry behavior. Agents start immediately regardless of registry availability.
|
|
@@ -48,11 +50,12 @@ class StartupPipeline(MeshPipeline):
|
|
|
48
50
|
DecoratorCollectionStep(),
|
|
49
51
|
ConfigurationStep(),
|
|
50
52
|
HeartbeatPreparationStep(), # Prepare heartbeat payload structure
|
|
53
|
+
ServerDiscoveryStep(), # Discover existing uvicorn servers from immediate startup
|
|
51
54
|
FastMCPServerDiscoveryStep(), # Discover user's FastMCP instances
|
|
52
55
|
HeartbeatLoopStep(), # Setup background heartbeat config (handles no registry gracefully)
|
|
53
56
|
FastAPIServerSetupStep(), # Setup FastAPI app with background heartbeat
|
|
54
57
|
# Note: Registry connection is handled in heartbeat pipeline for retry behavior
|
|
55
|
-
# Note: FastAPI server will be started with uvicorn.run() after pipeline
|
|
58
|
+
# Note: FastAPI server will be started with uvicorn.run() after pipeline (or reused if discovered)
|
|
56
59
|
]
|
|
57
60
|
|
|
58
61
|
self.add_steps(steps)
|
|
@@ -73,10 +73,12 @@ class MeshPipeline:
|
|
|
73
73
|
self.logger.info(f"📋 Step {step_num}/{len(self.steps)}: {step.name}")
|
|
74
74
|
|
|
75
75
|
try:
|
|
76
|
+
|
|
76
77
|
# Execute the step with current context
|
|
77
78
|
step_result = await step.execute(self.context)
|
|
78
79
|
executed_steps += 1
|
|
79
80
|
|
|
81
|
+
|
|
80
82
|
# Log step result
|
|
81
83
|
if step_result.is_success():
|
|
82
84
|
self.logger.info(
|
|
@@ -110,6 +112,8 @@ class MeshPipeline:
|
|
|
110
112
|
|
|
111
113
|
except Exception as e:
|
|
112
114
|
executed_steps += 1
|
|
115
|
+
|
|
116
|
+
|
|
113
117
|
error_msg = f"Step '{step.name}' threw exception: {e}"
|
|
114
118
|
overall_result.add_error(error_msg)
|
|
115
119
|
self.logger.error(
|