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.
@@ -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(f"🔍 Pipeline type detection: MCP={has_mcp} ({len(agents)} agents, {len(tools)} tools), API={has_api} ({len(routes)} routes)")
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(heartbeat_config, pipeline_context)
164
- self.logger.info("✅ API dependency injection complete - user's FastAPI server can now start")
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 with FastAPI server
168
- self._start_blocking_fastapi_server(fastapi_app, binding_config)
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, performing graceful shutdown..."
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(f"Starting background heartbeat thread for {service_id}")
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(api_heartbeat_lifespan_task(heartbeat_config))
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 _perform_graceful_shutdown(self) -> None:
276
- """Perform graceful shutdown by unregistering from registry."""
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
- # Run graceful shutdown asynchronously
279
- import asyncio
280
-
281
- asyncio.run(self._graceful_shutdown_async())
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
- async def _graceful_shutdown_async(self) -> None:
286
- """Async graceful shutdown implementation."""
287
- try:
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
- # Access the pipeline context through the orchestrator
296
- pipeline_context = getattr(self._orchestrator.pipeline, "_last_context", {})
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
- # Get registry configuration
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
- if not registry_url or not agent_id:
303
- self.logger.warning(
304
- f"🚨 Cannot perform graceful shutdown: missing registry_url={registry_url} or agent_id={agent_id}"
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
- return
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
- # Create registry client for shutdown
309
- from ...generated.mcp_mesh_registry_client.api_client import ApiClient
310
- from ...generated.mcp_mesh_registry_client.configuration import (
311
- Configuration,
312
- )
313
- from ...shared.registry_client_wrapper import RegistryClientWrapper
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
- config = Configuration(host=registry_url)
316
- api_client = ApiClient(configuration=config)
317
- registry_wrapper = RegistryClientWrapper(api_client)
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
- # Perform graceful unregistration
320
- success = await registry_wrapper.unregister_agent(agent_id)
321
- if success:
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
- # Don't fail the shutdown process due to unregistration errors
332
- self.logger.error(f"❌ Graceful shutdown error: {e} - continuing shutdown")
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
- # Install signal handlers in main thread FIRST (before any threading)
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
- def _install_signal_handlers() -> None:
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
- except Exception as e:
629
- logger.warning(f"⚠️ Failed to install signal handlers: {e}")
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. FastMCP server discovery
33
- 5. Heartbeat loop setup
34
- 6. FastAPI server setup
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(