mcp-mesh 0.5.4__py3-none-any.whl → 0.5.5__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 CHANGED
@@ -31,7 +31,7 @@ from .engine.decorator_registry import (
31
31
  get_decorator_stats,
32
32
  )
33
33
 
34
- __version__ = "0.5.4"
34
+ __version__ = "0.5.5"
35
35
 
36
36
  # Store reference to runtime processor if initialized
37
37
  _runtime_processor = None
@@ -59,7 +59,10 @@ def initialize_runtime():
59
59
 
60
60
 
61
61
  # Auto-initialize runtime if enabled
62
- if os.getenv("MCP_MESH_ENABLED", "true").lower() == "true":
62
+ if (
63
+ os.getenv("MCP_MESH_ENABLED", "true").lower() == "true"
64
+ and os.getenv("MCP_MESH_AUTO_RUN", "true").lower() == "true"
65
+ ):
63
66
  # Use debounced initialization instead of immediate MCP startup
64
67
  # This allows the system to determine MCP vs API pipeline based on decorators
65
68
  try:
@@ -55,6 +55,15 @@ class DecoratorRegistry:
55
55
 
56
56
  # Registry for new decorator types (extensibility)
57
57
  _custom_decorators: dict[str, dict[str, DecoratedFunction]] = {}
58
+
59
+ # Immediate uvicorn server storage (for preventing shutdown state)
60
+ _immediate_uvicorn_server: Optional[dict[str, Any]] = None
61
+
62
+ # FastMCP lifespan storage (for proper integration with FastAPI)
63
+ _fastmcp_lifespan: Optional[Any] = None
64
+
65
+ # FastMCP HTTP app storage (the same app instance whose lifespan was extracted)
66
+ _fastmcp_http_app: Optional[Any] = None
58
67
 
59
68
  @classmethod
60
69
  def register_mesh_agent(cls, func: Callable, metadata: dict[str, Any]) -> None:
@@ -508,6 +517,92 @@ class DecoratorRegistry:
508
517
 
509
518
  return {}
510
519
 
520
+ @classmethod
521
+ def store_immediate_uvicorn_server(cls, server_info: dict[str, Any]) -> None:
522
+ """
523
+ Store reference to immediate uvicorn server started in decorator.
524
+
525
+ Args:
526
+ server_info: Dictionary containing server information:
527
+ - 'app': FastAPI app instance
528
+ - 'host': Server host
529
+ - 'port': Server port
530
+ - 'thread': Thread object
531
+ - Any other relevant server metadata
532
+ """
533
+ cls._immediate_uvicorn_server = server_info
534
+ logger.debug(f"🔄 REGISTRY: Stored immediate uvicorn server reference: {server_info.get('host')}:{server_info.get('port')}")
535
+
536
+ @classmethod
537
+ def get_immediate_uvicorn_server(cls) -> Optional[dict[str, Any]]:
538
+ """
539
+ Get stored immediate uvicorn server reference.
540
+
541
+ Returns:
542
+ Server info dict if available, None otherwise
543
+ """
544
+ return cls._immediate_uvicorn_server
545
+
546
+ @classmethod
547
+ def clear_immediate_uvicorn_server(cls) -> None:
548
+ """Clear stored immediate uvicorn server reference."""
549
+ cls._immediate_uvicorn_server = None
550
+ logger.debug("🔄 REGISTRY: Cleared immediate uvicorn server reference")
551
+
552
+ @classmethod
553
+ def store_fastmcp_lifespan(cls, lifespan: Any) -> None:
554
+ """
555
+ Store FastMCP lifespan for integration with FastAPI.
556
+
557
+ Args:
558
+ lifespan: FastMCP lifespan function
559
+ """
560
+ cls._fastmcp_lifespan = lifespan
561
+ logger.debug("🔄 REGISTRY: Stored FastMCP lifespan for FastAPI integration")
562
+
563
+ @classmethod
564
+ def get_fastmcp_lifespan(cls) -> Optional[Any]:
565
+ """
566
+ Get stored FastMCP lifespan.
567
+
568
+ Returns:
569
+ FastMCP lifespan if available, None otherwise
570
+ """
571
+ return cls._fastmcp_lifespan
572
+
573
+ @classmethod
574
+ def clear_fastmcp_lifespan(cls) -> None:
575
+ """Clear stored FastMCP lifespan reference."""
576
+ cls._fastmcp_lifespan = None
577
+ logger.debug("🔄 REGISTRY: Cleared FastMCP lifespan reference")
578
+
579
+ @classmethod
580
+ def store_fastmcp_http_app(cls, http_app: Any) -> None:
581
+ """
582
+ Store FastMCP HTTP app (the same instance whose lifespan was extracted).
583
+
584
+ Args:
585
+ http_app: FastMCP HTTP app instance
586
+ """
587
+ cls._fastmcp_http_app = http_app
588
+ logger.debug("🔄 REGISTRY: Stored FastMCP HTTP app for mounting")
589
+
590
+ @classmethod
591
+ def get_fastmcp_http_app(cls) -> Optional[Any]:
592
+ """
593
+ Get stored FastMCP HTTP app.
594
+
595
+ Returns:
596
+ FastMCP HTTP app if available, None otherwise
597
+ """
598
+ return cls._fastmcp_http_app
599
+
600
+ @classmethod
601
+ def clear_fastmcp_http_app(cls) -> None:
602
+ """Clear stored FastMCP HTTP app reference."""
603
+ cls._fastmcp_http_app = None
604
+ logger.debug("🔄 REGISTRY: Cleared FastMCP HTTP app reference")
605
+
511
606
 
512
607
  # Convenience functions for external access
513
608
  def get_all_mesh_agents() -> dict[str, DecoratedFunction]:
@@ -12,7 +12,6 @@ from typing import Any, Optional
12
12
  from ..shared.content_extractor import ContentExtractor
13
13
  from ..shared.sse_parser import SSEParser
14
14
  from .async_mcp_client import AsyncMCPClient
15
- from .threading_utils import ThreadingUtils
16
15
 
17
16
  logger = logging.getLogger(__name__)
18
17
 
@@ -48,13 +47,24 @@ class MCPClientProxy:
48
47
  )
49
48
 
50
49
  def _run_async(self, coro):
51
- """Convert async coroutine to sync call avoiding atexit bug.
50
+ """Convert async coroutine to sync call."""
52
51
 
53
- Uses shared ThreadingUtils for consistent DNS-safe threading behavior.
54
- """
55
- return ThreadingUtils.run_sync_from_async(
56
- coro, timeout=60.0, context_name=f"MCPClient.{self.function_name}"
57
- )
52
+ try:
53
+ # Try to get existing event loop
54
+ loop = asyncio.get_event_loop()
55
+ if loop.is_running():
56
+ # We're in an async context, need to run in thread
57
+ import concurrent.futures
58
+
59
+ with concurrent.futures.ThreadPoolExecutor() as executor:
60
+ future = executor.submit(asyncio.run, coro)
61
+ return future.result()
62
+ else:
63
+ # No running loop, safe to use loop.run_until_complete
64
+ return loop.run_until_complete(coro)
65
+ except RuntimeError:
66
+ # No event loop exists, create new one
67
+ return asyncio.run(coro)
58
68
 
59
69
  def __call__(self, **kwargs) -> Any:
60
70
  """Callable interface for dependency injection.
@@ -10,8 +10,6 @@ import uuid
10
10
  from collections.abc import AsyncIterator
11
11
  from typing import Any, Optional
12
12
 
13
- from .threading_utils import ThreadingUtils
14
-
15
13
  logger = logging.getLogger(__name__)
16
14
 
17
15
 
@@ -58,11 +56,27 @@ class UnifiedMCPProxy:
58
56
  f"🔧 UnifiedMCPProxy initialized with kwargs: {self.kwargs_config}"
59
57
  )
60
58
 
59
+ def _is_ip_address(self, hostname: str) -> bool:
60
+ """Check if hostname is an IP address vs DNS name.
61
+
62
+ Args:
63
+ hostname: Hostname to check
64
+
65
+ Returns:
66
+ True if IP address, False if DNS name
67
+ """
68
+ import ipaddress
69
+ try:
70
+ ipaddress.ip_address(hostname)
71
+ return True
72
+ except ValueError:
73
+ return False
74
+
61
75
  def _create_fastmcp_client(self, endpoint: str):
62
- """Create FastMCP client with automatic trace header injection.
76
+ """Create FastMCP client with DNS detection for threading conflict avoidance.
63
77
 
64
- This method automatically detects trace context and adds distributed tracing
65
- headers when available, while maintaining full backward compatibility.
78
+ This method detects DNS names vs IP addresses and forces HTTP fallback for DNS names
79
+ to avoid FastMCP client threading conflicts in containerized environments.
66
80
 
67
81
  Args:
68
82
  endpoint: MCP endpoint URL
@@ -71,6 +85,14 @@ class UnifiedMCPProxy:
71
85
  FastMCP Client instance with or without trace headers
72
86
  """
73
87
  try:
88
+ # Extract hostname from endpoint URL for DNS detection
89
+ from urllib.parse import urlparse
90
+ parsed = urlparse(endpoint)
91
+ hostname = parsed.hostname or parsed.netloc.split(':')[0]
92
+
93
+ # DNS resolution works perfectly with FastMCP - no need to force HTTP fallback
94
+ self.logger.debug(f"✅ Using FastMCP client for endpoint: {hostname}")
95
+
74
96
  from fastmcp import Client
75
97
  from fastmcp.client.transports import StreamableHttpTransport
76
98
 
@@ -85,16 +107,14 @@ class UnifiedMCPProxy:
85
107
  # Create standard client when no trace context available
86
108
  return Client(endpoint)
87
109
 
88
- except ImportError:
89
- # If StreamableHttpTransport not available, fall back to standard client
90
- from fastmcp import Client
91
-
92
- return Client(endpoint)
110
+ except ImportError as e:
111
+ # DNS names or FastMCP not available - this will trigger HTTP fallback
112
+ self.logger.debug(f"🔄 FastMCP client unavailable: {e}")
113
+ raise # Re-raise to trigger _fallback_http_call
93
114
  except Exception as e:
94
- # Any other error, fall back to standard client
95
- from fastmcp import Client
96
-
97
- return Client(endpoint)
115
+ # Any other error - this will trigger HTTP fallback
116
+ self.logger.debug(f"🔄 FastMCP client error: {e}")
117
+ raise ImportError(f"FastMCP client failed: {e}") # Convert to ImportError to trigger fallback
98
118
 
99
119
  def _get_trace_headers(self) -> dict[str, str]:
100
120
  """Extract trace headers from current context for distributed tracing.
@@ -341,6 +361,7 @@ class UnifiedMCPProxy:
341
361
  """
342
362
  import time
343
363
 
364
+
344
365
  start_time = time.time()
345
366
 
346
367
  try:
@@ -350,6 +371,7 @@ class UnifiedMCPProxy:
350
371
 
351
372
  # Create client with automatic trace header injection
352
373
  client_instance = self._create_fastmcp_client(mcp_endpoint)
374
+
353
375
  async with client_instance as client:
354
376
 
355
377
  # Use FastMCP's call_tool which returns CallToolResult object
@@ -394,7 +416,8 @@ class UnifiedMCPProxy:
394
416
  self.logger.warning(f"FastMCP Client failed: {e}, falling back to HTTP")
395
417
  # Try HTTP fallback
396
418
  try:
397
- return await self._fallback_http_call(name, arguments)
419
+ result = await self._fallback_http_call(name, arguments)
420
+ return result
398
421
  except Exception as fallback_error:
399
422
  raise RuntimeError(
400
423
  f"Tool call to '{name}' failed: {e}, fallback also failed: {fallback_error}"
@@ -831,15 +854,6 @@ class EnhancedUnifiedMCPProxy(UnifiedMCPProxy):
831
854
  f"auth_required: {self.auth_required}"
832
855
  )
833
856
 
834
- def __call__(self, **kwargs) -> Any:
835
- """Synchronous callable interface for dependency injection.
836
-
837
- This method provides a sync interface for dependency injection.
838
- Returns the actual result, not a coroutine.
839
- """
840
- # Use the enhanced tool call with proper sync-to-async bridging
841
- return self.call_tool_auto(self.function_name, kwargs)
842
-
843
857
  async def call_tool_enhanced(self, name: str, arguments: dict = None) -> Any:
844
858
  """Enhanced tool call with retry logic and custom configuration."""
845
859
  last_exception = None
@@ -869,19 +883,8 @@ class EnhancedUnifiedMCPProxy(UnifiedMCPProxy):
869
883
  raise last_exception
870
884
 
871
885
  def call_tool_auto(self, name: str, arguments: dict = None) -> Any:
872
- """Automatically choose streaming vs non-streaming based on configuration.
873
-
874
- Uses shared ThreadingUtils for consistent DNS-safe threading behavior.
875
- """
876
- # Create coroutine function based on streaming capability
877
- def coro_func():
878
- if self.streaming_capable:
879
- return self.call_tool_streaming(name, arguments)
880
- else:
881
- return self.call_tool_enhanced(name, arguments)
882
-
883
- return ThreadingUtils.run_sync_from_async(
884
- coro_func,
885
- timeout=self.timeout or 60.0,
886
- context_name=f"UnifiedMCPProxy.{name}",
887
- )
886
+ """Automatically choose streaming vs non-streaming based on configuration."""
887
+ if self.streaming_capable:
888
+ return self.call_tool_streaming(name, arguments)
889
+ else:
890
+ return self.call_tool_enhanced(name, arguments)
@@ -1,8 +1,8 @@
1
- import gc
2
1
  import logging
3
- from typing import Any, Dict, List, Optional, Tuple
2
+ from typing import Any, Dict, List
4
3
 
5
4
  from ..shared import PipelineResult, PipelineStatus, PipelineStep
5
+ from ...shared.server_discovery import ServerDiscoveryUtil
6
6
 
7
7
 
8
8
  class FastAPIAppDiscoveryStep(PipelineStep):
@@ -39,8 +39,8 @@ class FastAPIAppDiscoveryStep(PipelineStep):
39
39
  self.logger.info("⚠️ No @mesh.route decorators found to process")
40
40
  return result
41
41
 
42
- # Discover FastAPI instances
43
- fastapi_apps = self._discover_fastapi_instances()
42
+ # Discover FastAPI instances using shared utility
43
+ fastapi_apps = ServerDiscoveryUtil.discover_fastapi_instances()
44
44
 
45
45
  if not fastapi_apps:
46
46
  # This is not necessarily an error - user might be using FastAPI differently
@@ -90,169 +90,6 @@ class FastAPIAppDiscoveryStep(PipelineStep):
90
90
 
91
91
  return result
92
92
 
93
- def _discover_fastapi_instances(self) -> Dict[str, Dict[str, Any]]:
94
- """
95
- Discover FastAPI application instances in the Python runtime.
96
-
97
- Uses intelligent deduplication to handle standard uvicorn patterns where
98
- the same app might be imported multiple times (e.g., "module:app" pattern).
99
-
100
- Returns:
101
- Dict mapping app_id -> app_info where app_info contains:
102
- - 'instance': The FastAPI app instance
103
- - 'title': App title from FastAPI
104
- - 'routes': List of route information
105
- - 'module': Module where app was found
106
- """
107
- fastapi_apps = {}
108
- seen_apps = {} # For deduplication: title -> app_info
109
-
110
- try:
111
- # Import FastAPI here to avoid dependency if not used
112
- from fastapi import FastAPI
113
- except ImportError:
114
- self.logger.warning("FastAPI not installed - cannot discover FastAPI apps")
115
- return {}
116
-
117
- # Scan garbage collector for FastAPI instances
118
- candidate_apps = []
119
- for obj in gc.get_objects():
120
- if isinstance(obj, FastAPI):
121
- candidate_apps.append(obj)
122
-
123
- # Deduplicate apps with identical configurations
124
- for obj in candidate_apps:
125
- try:
126
- title = getattr(obj, "title", "FastAPI App")
127
- version = getattr(obj, "version", "unknown")
128
- routes = self._extract_route_info(obj)
129
- route_count = len(routes)
130
-
131
- # Create a signature for deduplication
132
- app_signature = (title, version, route_count)
133
-
134
- # Check if we've seen an identical app
135
- if app_signature in seen_apps:
136
- existing_app = seen_apps[app_signature]
137
- # Compare route details to ensure they're truly identical
138
- existing_routes = existing_app["routes"]
139
-
140
- if self._routes_are_identical(routes, existing_routes):
141
- self.logger.debug(
142
- f"Skipping duplicate FastAPI app: '{title}' (same title, version, and routes)"
143
- )
144
- continue # Skip this duplicate
145
-
146
- # This is a unique app, add it
147
- app_id = f"app_{id(obj)}"
148
- app_info = {
149
- "instance": obj,
150
- "title": title,
151
- "version": version,
152
- "routes": routes,
153
- "module": self._get_app_module(obj),
154
- "object_id": id(obj),
155
- "router_routes_count": len(obj.router.routes) if hasattr(obj, 'router') else 0,
156
- }
157
-
158
- fastapi_apps[app_id] = app_info
159
- seen_apps[app_signature] = app_info
160
-
161
- self.logger.debug(
162
- f"Found FastAPI app: '{title}' (module: {app_info['module']}) with "
163
- f"{len(routes)} routes"
164
- )
165
-
166
- except Exception as e:
167
- self.logger.warning(f"Error analyzing FastAPI app: {e}")
168
- continue
169
-
170
- return fastapi_apps
171
-
172
- def _routes_are_identical(self, routes1: List[Dict[str, Any]], routes2: List[Dict[str, Any]]) -> bool:
173
- """
174
- Compare two route lists to see if they're identical.
175
-
176
- Args:
177
- routes1: First route list
178
- routes2: Second route list
179
-
180
- Returns:
181
- True if routes are identical, False otherwise
182
- """
183
- if len(routes1) != len(routes2):
184
- return False
185
-
186
- # Create comparable signatures for each route
187
- def route_signature(route):
188
- return (
189
- tuple(sorted(route.get('methods', []))), # Sort methods for consistent comparison
190
- route.get('path', ''),
191
- route.get('endpoint_name', '')
192
- )
193
-
194
- # Sort routes by signature for consistent comparison
195
- sig1 = sorted([route_signature(r) for r in routes1])
196
- sig2 = sorted([route_signature(r) for r in routes2])
197
-
198
- return sig1 == sig2
199
-
200
- def _extract_route_info(self, app) -> List[Dict[str, Any]]:
201
- """
202
- Extract route information from FastAPI app without modifying it.
203
-
204
- Args:
205
- app: FastAPI application instance
206
-
207
- Returns:
208
- List of route information dictionaries
209
- """
210
- routes = []
211
-
212
- try:
213
- for route in app.router.routes:
214
- if hasattr(route, 'endpoint') and hasattr(route, 'path'):
215
- route_info = {
216
- "path": route.path,
217
- "methods": list(route.methods) if hasattr(route, 'methods') else [],
218
- "endpoint": route.endpoint,
219
- "endpoint_name": getattr(route.endpoint, '__name__', 'unknown'),
220
- "has_mesh_route": hasattr(route.endpoint, '_mesh_route_metadata'),
221
- }
222
- routes.append(route_info)
223
-
224
- except Exception as e:
225
- self.logger.warning(f"Error extracting route info: {e}")
226
-
227
- return routes
228
-
229
- def _get_app_module(self, app) -> Optional[str]:
230
- """
231
- Try to determine which module the FastAPI app belongs to.
232
-
233
- Args:
234
- app: FastAPI application instance
235
-
236
- Returns:
237
- Module name or None if unknown
238
- """
239
- try:
240
- # Try to get module from the app's stack frame when it was created
241
- # This is best-effort - may not always work
242
- import inspect
243
-
244
- frame = inspect.currentframe()
245
- while frame:
246
- frame_globals = frame.f_globals
247
- for name, obj in frame_globals.items():
248
- if obj is app:
249
- return frame_globals.get('__name__', 'unknown')
250
- frame = frame.f_back
251
-
252
- except Exception:
253
- pass
254
-
255
- return None
256
93
 
257
94
  def _map_routes_to_apps(
258
95
  self,
@@ -43,6 +43,8 @@ class HeartbeatOrchestrator:
43
43
  self._heartbeat_count += 1
44
44
 
45
45
  try:
46
+
47
+
46
48
  # Prepare heartbeat context with validation
47
49
  heartbeat_context = self._prepare_heartbeat_context(agent_id, context)
48
50
 
@@ -65,10 +67,12 @@ class HeartbeatOrchestrator:
65
67
  import asyncio
66
68
 
67
69
  try:
70
+
68
71
  result = await asyncio.wait_for(
69
72
  self.pipeline.execute_heartbeat_cycle(heartbeat_context),
70
73
  timeout=30.0,
71
74
  )
75
+
72
76
  except TimeoutError:
73
77
  self.logger.error(
74
78
  f"❌ Heartbeat #{self._heartbeat_count} timed out after 30 seconds"
@@ -13,6 +13,7 @@ from .heartbeat_loop import HeartbeatLoopStep
13
13
  from .heartbeat_preparation import HeartbeatPreparationStep
14
14
  from .startup_orchestrator import (
15
15
  MeshOrchestrator,
16
+ clear_debounce_coordinator,
16
17
  get_debounce_coordinator,
17
18
  get_global_orchestrator,
18
19
  start_runtime,
@@ -28,6 +29,7 @@ __all__ = [
28
29
  "HeartbeatPreparationStep",
29
30
  "MeshOrchestrator",
30
31
  "StartupPipeline",
32
+ "clear_debounce_coordinator",
31
33
  "get_global_orchestrator",
32
34
  "get_debounce_coordinator",
33
35
  "start_runtime",