mcp-mesh 0.4.1__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- _mcp_mesh/__init__.py +14 -3
- _mcp_mesh/engine/async_mcp_client.py +6 -19
- _mcp_mesh/engine/dependency_injector.py +161 -74
- _mcp_mesh/engine/full_mcp_proxy.py +25 -20
- _mcp_mesh/engine/mcp_client_proxy.py +5 -19
- _mcp_mesh/generated/.openapi-generator/FILES +2 -0
- _mcp_mesh/generated/mcp_mesh_registry_client/__init__.py +2 -0
- _mcp_mesh/generated/mcp_mesh_registry_client/api/__init__.py +1 -0
- _mcp_mesh/generated/mcp_mesh_registry_client/api/tracing_api.py +305 -0
- _mcp_mesh/generated/mcp_mesh_registry_client/models/__init__.py +1 -0
- _mcp_mesh/generated/mcp_mesh_registry_client/models/agent_info.py +10 -1
- _mcp_mesh/generated/mcp_mesh_registry_client/models/mesh_agent_registration.py +4 -4
- _mcp_mesh/generated/mcp_mesh_registry_client/models/trace_event.py +108 -0
- _mcp_mesh/pipeline/__init__.py +2 -2
- _mcp_mesh/pipeline/api_heartbeat/__init__.py +16 -0
- _mcp_mesh/pipeline/api_heartbeat/api_dependency_resolution.py +515 -0
- _mcp_mesh/pipeline/api_heartbeat/api_fast_heartbeat_check.py +117 -0
- _mcp_mesh/pipeline/api_heartbeat/api_health_check.py +140 -0
- _mcp_mesh/pipeline/api_heartbeat/api_heartbeat_orchestrator.py +247 -0
- _mcp_mesh/pipeline/api_heartbeat/api_heartbeat_pipeline.py +309 -0
- _mcp_mesh/pipeline/api_heartbeat/api_heartbeat_send.py +332 -0
- _mcp_mesh/pipeline/api_heartbeat/api_lifespan_integration.py +147 -0
- _mcp_mesh/pipeline/api_heartbeat/api_registry_connection.py +97 -0
- _mcp_mesh/pipeline/api_startup/__init__.py +20 -0
- _mcp_mesh/pipeline/api_startup/api_pipeline.py +61 -0
- _mcp_mesh/pipeline/api_startup/api_server_setup.py +292 -0
- _mcp_mesh/pipeline/api_startup/fastapi_discovery.py +302 -0
- _mcp_mesh/pipeline/api_startup/route_collection.py +56 -0
- _mcp_mesh/pipeline/api_startup/route_integration.py +318 -0
- _mcp_mesh/pipeline/{startup → mcp_startup}/fastmcpserver_discovery.py +4 -4
- _mcp_mesh/pipeline/{startup → mcp_startup}/heartbeat_loop.py +1 -1
- _mcp_mesh/pipeline/{startup → mcp_startup}/startup_orchestrator.py +170 -5
- _mcp_mesh/shared/config_resolver.py +0 -3
- _mcp_mesh/shared/logging_config.py +2 -1
- _mcp_mesh/shared/sse_parser.py +217 -0
- {mcp_mesh-0.4.1.dist-info → mcp_mesh-0.5.0.dist-info}/METADATA +1 -1
- {mcp_mesh-0.4.1.dist-info → mcp_mesh-0.5.0.dist-info}/RECORD +55 -37
- mesh/__init__.py +6 -2
- mesh/decorators.py +143 -1
- /_mcp_mesh/pipeline/{heartbeat → mcp_heartbeat}/__init__.py +0 -0
- /_mcp_mesh/pipeline/{heartbeat → mcp_heartbeat}/dependency_resolution.py +0 -0
- /_mcp_mesh/pipeline/{heartbeat → mcp_heartbeat}/fast_heartbeat_check.py +0 -0
- /_mcp_mesh/pipeline/{heartbeat → mcp_heartbeat}/heartbeat_orchestrator.py +0 -0
- /_mcp_mesh/pipeline/{heartbeat → mcp_heartbeat}/heartbeat_pipeline.py +0 -0
- /_mcp_mesh/pipeline/{heartbeat → mcp_heartbeat}/heartbeat_send.py +0 -0
- /_mcp_mesh/pipeline/{heartbeat → mcp_heartbeat}/lifespan_integration.py +0 -0
- /_mcp_mesh/pipeline/{heartbeat → mcp_heartbeat}/registry_connection.py +0 -0
- /_mcp_mesh/pipeline/{startup → mcp_startup}/__init__.py +0 -0
- /_mcp_mesh/pipeline/{startup → mcp_startup}/configuration.py +0 -0
- /_mcp_mesh/pipeline/{startup → mcp_startup}/decorator_collection.py +0 -0
- /_mcp_mesh/pipeline/{startup → mcp_startup}/fastapiserver_setup.py +0 -0
- /_mcp_mesh/pipeline/{startup → mcp_startup}/heartbeat_preparation.py +0 -0
- /_mcp_mesh/pipeline/{startup → mcp_startup}/startup_pipeline.py +0 -0
- {mcp_mesh-0.4.1.dist-info → mcp_mesh-0.5.0.dist-info}/WHEEL +0 -0
- {mcp_mesh-0.4.1.dist-info → mcp_mesh-0.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API dependency resolution step for API heartbeat pipeline.
|
|
3
|
+
|
|
4
|
+
Handles processing dependency resolution from registry response and
|
|
5
|
+
updating the dependency injection system for FastAPI route handlers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ...engine.dependency_injector import get_global_injector
|
|
13
|
+
from ..shared import PipelineResult, PipelineStatus, PipelineStep
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Global state for dependency hash tracking across heartbeat cycles
|
|
18
|
+
_last_api_dependency_hash = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class APIDependencyResolutionStep(PipelineStep):
|
|
22
|
+
"""
|
|
23
|
+
Processes dependency resolution from registry response for API services.
|
|
24
|
+
|
|
25
|
+
Takes the dependencies_resolved data from the heartbeat response
|
|
26
|
+
and updates dependency injection for FastAPI route handlers.
|
|
27
|
+
|
|
28
|
+
Similar to MCP dependency resolution but adapted for:
|
|
29
|
+
- FastAPI route handlers instead of MCP tools
|
|
30
|
+
- Single "api_endpoint_handler" function instead of multiple tools
|
|
31
|
+
- Route-level dependency mapping instead of tool-level mapping
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
super().__init__(
|
|
36
|
+
name="api-dependency-resolution",
|
|
37
|
+
required=False, # Optional - can work without dependencies
|
|
38
|
+
description="Process dependency resolution for API route handlers",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
async def execute(self, context: dict[str, Any]) -> PipelineResult:
|
|
42
|
+
"""Process dependency resolution with hash-based change detection."""
|
|
43
|
+
self.logger.debug("Processing API dependency resolution...")
|
|
44
|
+
|
|
45
|
+
result = PipelineResult(message="API dependency resolution processed")
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
# Get heartbeat response and registry wrapper
|
|
49
|
+
heartbeat_response = context.get("heartbeat_response", {})
|
|
50
|
+
registry_wrapper = context.get("registry_wrapper")
|
|
51
|
+
|
|
52
|
+
if not heartbeat_response or not registry_wrapper:
|
|
53
|
+
result.status = PipelineStatus.SUCCESS
|
|
54
|
+
result.message = (
|
|
55
|
+
"No heartbeat response or registry wrapper - completed successfully"
|
|
56
|
+
)
|
|
57
|
+
self.logger.info("ℹ️ No heartbeat response to process - this is normal for API services")
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
# Use the same hash-based change detection pattern as MCP
|
|
61
|
+
await self.process_heartbeat_response_for_api_rewiring(heartbeat_response)
|
|
62
|
+
|
|
63
|
+
# For context consistency, extract dependency count
|
|
64
|
+
dependencies_resolved = registry_wrapper.parse_tool_dependencies(
|
|
65
|
+
heartbeat_response
|
|
66
|
+
)
|
|
67
|
+
dependency_count = sum(
|
|
68
|
+
len(deps) if isinstance(deps, list) else 0
|
|
69
|
+
for deps in dependencies_resolved.values()
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Store processed dependencies info for context
|
|
73
|
+
result.add_context("dependency_count", dependency_count)
|
|
74
|
+
result.add_context("dependencies_resolved", dependencies_resolved)
|
|
75
|
+
|
|
76
|
+
result.message = "API dependency resolution completed (efficient hash-based)"
|
|
77
|
+
|
|
78
|
+
if dependency_count > 0:
|
|
79
|
+
self.logger.info(f"🔗 Dependencies resolved: {dependency_count} items")
|
|
80
|
+
|
|
81
|
+
# Log function registry status for debugging
|
|
82
|
+
injector = get_global_injector()
|
|
83
|
+
function_count = len(injector._function_registry)
|
|
84
|
+
self.logger.debug(f"🔍 Function registry contains {function_count} functions:")
|
|
85
|
+
for func_id, wrapper_func in injector._function_registry.items():
|
|
86
|
+
original_func = getattr(wrapper_func, '_mesh_original_func', None)
|
|
87
|
+
func_name = original_func.__name__ if original_func else 'unknown'
|
|
88
|
+
dependencies = getattr(wrapper_func, '_mesh_dependencies', [])
|
|
89
|
+
self.logger.debug(f" 📋 {func_id} -> {func_name} (deps: {dependencies})")
|
|
90
|
+
|
|
91
|
+
self.logger.debug("🔗 API dependency resolution step completed using hash-based change detection")
|
|
92
|
+
|
|
93
|
+
except Exception as e:
|
|
94
|
+
result.status = PipelineStatus.FAILED
|
|
95
|
+
result.message = f"API dependency resolution failed: {e}"
|
|
96
|
+
result.add_error(str(e))
|
|
97
|
+
self.logger.error(f"❌ API dependency resolution failed: {e}")
|
|
98
|
+
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
def _extract_dependency_state(
|
|
102
|
+
self, heartbeat_response: dict[str, Any]
|
|
103
|
+
) -> dict[str, dict[str, dict[str, str]]]:
|
|
104
|
+
"""Extract dependency state structure from heartbeat response.
|
|
105
|
+
|
|
106
|
+
For API services, dependencies are typically under a single function
|
|
107
|
+
(usually "api_endpoint_handler") but we still follow the same pattern.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
{function_name: {capability: {endpoint, function_name, status}}}
|
|
111
|
+
"""
|
|
112
|
+
state = {}
|
|
113
|
+
dependencies_resolved = heartbeat_response.get("dependencies_resolved", {})
|
|
114
|
+
|
|
115
|
+
for function_name, dependency_list in dependencies_resolved.items():
|
|
116
|
+
if not isinstance(dependency_list, list):
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
state[function_name] = {}
|
|
120
|
+
for dep_resolution in dependency_list:
|
|
121
|
+
if (
|
|
122
|
+
not isinstance(dep_resolution, dict)
|
|
123
|
+
or "capability" not in dep_resolution
|
|
124
|
+
):
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
capability = dep_resolution["capability"]
|
|
128
|
+
state[function_name][capability] = {
|
|
129
|
+
"endpoint": dep_resolution.get("endpoint", ""),
|
|
130
|
+
"function_name": dep_resolution.get("function_name", ""),
|
|
131
|
+
"status": dep_resolution.get("status", ""),
|
|
132
|
+
"agent_id": dep_resolution.get("agent_id", ""),
|
|
133
|
+
"kwargs": dep_resolution.get("kwargs", {}), # Include kwargs config
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return state
|
|
137
|
+
|
|
138
|
+
def _hash_dependency_state(self, state: dict) -> str:
|
|
139
|
+
"""Create hash of dependency state structure."""
|
|
140
|
+
import hashlib
|
|
141
|
+
|
|
142
|
+
# Convert to sorted JSON string for consistent hashing
|
|
143
|
+
state_json = json.dumps(state, sort_keys=True)
|
|
144
|
+
return hashlib.sha256(state_json.encode()).hexdigest()[
|
|
145
|
+
:16
|
|
146
|
+
] # First 16 chars for readability
|
|
147
|
+
|
|
148
|
+
async def process_heartbeat_response_for_api_rewiring(
|
|
149
|
+
self, heartbeat_response: dict[str, Any]
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Process heartbeat response to update API route dependency injection.
|
|
152
|
+
|
|
153
|
+
Uses hash-based comparison to efficiently detect when ANY dependency changes
|
|
154
|
+
and then updates ALL affected route handlers in one operation.
|
|
155
|
+
|
|
156
|
+
Resilience logic (same as MCP):
|
|
157
|
+
- No response (connection error, 5xx) → Skip entirely (keep existing wiring)
|
|
158
|
+
- 2xx response with empty dependencies → Unwire all dependencies
|
|
159
|
+
- 2xx response with partial dependencies → Update to match registry exactly
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
if not heartbeat_response:
|
|
163
|
+
# No response from registry (connection error, timeout, 5xx)
|
|
164
|
+
# → Skip entirely for resilience (keep existing dependencies)
|
|
165
|
+
self.logger.debug(
|
|
166
|
+
"No heartbeat response - skipping API rewiring for resilience"
|
|
167
|
+
)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# Extract current dependency state structure
|
|
171
|
+
current_state = self._extract_dependency_state(heartbeat_response)
|
|
172
|
+
|
|
173
|
+
# IMPORTANT: Empty state from successful response means "unwire everything"
|
|
174
|
+
# This is different from "no response" which means "keep existing for resilience"
|
|
175
|
+
|
|
176
|
+
# Hash the current state (including empty state)
|
|
177
|
+
current_hash = self._hash_dependency_state(current_state)
|
|
178
|
+
|
|
179
|
+
# Compare with previous state (use global variable with API-specific name)
|
|
180
|
+
global _last_api_dependency_hash
|
|
181
|
+
if current_hash == _last_api_dependency_hash:
|
|
182
|
+
self.logger.debug(
|
|
183
|
+
f"🔄 API dependency state unchanged (hash: {current_hash}), skipping rewiring"
|
|
184
|
+
)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
# State changed - determine what changed
|
|
188
|
+
function_count = len(current_state)
|
|
189
|
+
total_deps = sum(len(deps) for deps in current_state.values())
|
|
190
|
+
|
|
191
|
+
if _last_api_dependency_hash is None:
|
|
192
|
+
if function_count > 0:
|
|
193
|
+
self.logger.info(
|
|
194
|
+
f"🔄 Initial API dependency state detected: {function_count} functions, {total_deps} dependencies"
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
self.logger.info(
|
|
198
|
+
"🔄 Initial API dependency state detected: no dependencies"
|
|
199
|
+
)
|
|
200
|
+
else:
|
|
201
|
+
self.logger.info(
|
|
202
|
+
f"🔄 API dependency state changed (hash: {_last_api_dependency_hash} → {current_hash})"
|
|
203
|
+
)
|
|
204
|
+
if function_count > 0:
|
|
205
|
+
self.logger.info(
|
|
206
|
+
f"🔄 Updating API dependencies for {function_count} functions ({total_deps} total dependencies)"
|
|
207
|
+
)
|
|
208
|
+
else:
|
|
209
|
+
self.logger.info(
|
|
210
|
+
"🔄 Registry reports no API dependencies - unwiring all existing dependencies"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Import here to avoid circular imports
|
|
214
|
+
from ...engine.dependency_injector import get_global_injector
|
|
215
|
+
from ...engine.full_mcp_proxy import EnhancedFullMCPProxy, FullMCPProxy
|
|
216
|
+
from ...engine.mcp_client_proxy import (
|
|
217
|
+
EnhancedMCPClientProxy,
|
|
218
|
+
MCPClientProxy,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
injector = get_global_injector()
|
|
222
|
+
|
|
223
|
+
# Step 1: Collect all capabilities that should exist according to registry
|
|
224
|
+
target_capabilities = set()
|
|
225
|
+
for function_name, dependencies in current_state.items():
|
|
226
|
+
for capability in dependencies.keys():
|
|
227
|
+
target_capabilities.add(capability)
|
|
228
|
+
|
|
229
|
+
# Step 2: Find existing capabilities that need to be removed (unwired)
|
|
230
|
+
# This handles the case where registry stops reporting some dependencies
|
|
231
|
+
existing_capabilities = (
|
|
232
|
+
set(injector._dependencies.keys())
|
|
233
|
+
if hasattr(injector, "_dependencies")
|
|
234
|
+
else set()
|
|
235
|
+
)
|
|
236
|
+
capabilities_to_remove = existing_capabilities - target_capabilities
|
|
237
|
+
|
|
238
|
+
unwired_count = 0
|
|
239
|
+
for capability in capabilities_to_remove:
|
|
240
|
+
await injector.unregister_dependency(capability)
|
|
241
|
+
unwired_count += 1
|
|
242
|
+
self.logger.info(
|
|
243
|
+
f"🗑️ Unwired API dependency '{capability}' (no longer reported by registry)"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Step 3: Apply all dependency updates for capabilities that should exist
|
|
247
|
+
updated_count = 0
|
|
248
|
+
for function_name, dependencies in current_state.items():
|
|
249
|
+
for capability, dep_info in dependencies.items():
|
|
250
|
+
status = dep_info["status"]
|
|
251
|
+
endpoint = dep_info["endpoint"]
|
|
252
|
+
dep_function_name = dep_info["function_name"]
|
|
253
|
+
kwargs_config = dep_info.get("kwargs", {}) # Extract kwargs config
|
|
254
|
+
|
|
255
|
+
if status == "available" and endpoint and dep_function_name:
|
|
256
|
+
# Import here to avoid circular imports
|
|
257
|
+
import os
|
|
258
|
+
|
|
259
|
+
from ...engine.full_mcp_proxy import (
|
|
260
|
+
EnhancedFullMCPProxy,
|
|
261
|
+
FullMCPProxy,
|
|
262
|
+
)
|
|
263
|
+
from ...engine.mcp_client_proxy import (
|
|
264
|
+
EnhancedMCPClientProxy,
|
|
265
|
+
MCPClientProxy,
|
|
266
|
+
)
|
|
267
|
+
from ...engine.self_dependency_proxy import SelfDependencyProxy
|
|
268
|
+
|
|
269
|
+
# Get current agent ID for self-dependency detection
|
|
270
|
+
current_agent_id = None
|
|
271
|
+
try:
|
|
272
|
+
from ...engine.decorator_registry import DecoratorRegistry
|
|
273
|
+
|
|
274
|
+
config = DecoratorRegistry.get_resolved_agent_config()
|
|
275
|
+
current_agent_id = config["agent_id"]
|
|
276
|
+
self.logger.debug(
|
|
277
|
+
f"🔍 Current API service ID from DecoratorRegistry: '{current_agent_id}'"
|
|
278
|
+
)
|
|
279
|
+
except Exception as e:
|
|
280
|
+
# For API services, try environment variable fallback
|
|
281
|
+
current_agent_id = os.getenv("MCP_MESH_AGENT_ID")
|
|
282
|
+
self.logger.debug(
|
|
283
|
+
f"🔍 Current API service ID from environment: '{current_agent_id}' (fallback due to: {e})"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
target_agent_id = dep_info.get("agent_id")
|
|
287
|
+
self.logger.debug(
|
|
288
|
+
f"🔍 Target agent ID from registry: '{target_agent_id}'"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Determine if this is a self-dependency (less common for API services)
|
|
292
|
+
is_self_dependency = (
|
|
293
|
+
current_agent_id
|
|
294
|
+
and target_agent_id
|
|
295
|
+
and current_agent_id == target_agent_id
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
self.logger.debug(
|
|
299
|
+
f"🔍 Self-dependency check for '{capability}': "
|
|
300
|
+
f"current='{current_agent_id}' vs target='{target_agent_id}' "
|
|
301
|
+
f"→ {'SELF' if is_self_dependency else 'CROSS'}-dependency"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if is_self_dependency:
|
|
305
|
+
# Note: Self-dependencies are unusual for API services but we handle them
|
|
306
|
+
self.logger.warning(
|
|
307
|
+
f"⚠️ API SELF-DEPENDENCY detected for '{capability}' - "
|
|
308
|
+
f"this is unusual for API services. Consider refactoring."
|
|
309
|
+
)
|
|
310
|
+
# For API services, we don't have access to original functions in the same way
|
|
311
|
+
# Fall back to HTTP proxy approach
|
|
312
|
+
proxy_type = self._determine_api_proxy_type_for_capability(
|
|
313
|
+
capability, injector
|
|
314
|
+
)
|
|
315
|
+
new_proxy = self._create_proxy_for_api(
|
|
316
|
+
proxy_type, endpoint, dep_function_name, kwargs_config
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
# Create cross-service proxy (the normal case for API services)
|
|
320
|
+
proxy_type = self._determine_api_proxy_type_for_capability(
|
|
321
|
+
capability, injector
|
|
322
|
+
)
|
|
323
|
+
new_proxy = self._create_proxy_for_api(
|
|
324
|
+
proxy_type, endpoint, dep_function_name, kwargs_config
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Update in injector (this will update ALL route handlers that depend on this capability)
|
|
328
|
+
self.logger.debug(f"🔄 Before update: registering {capability} = {type(new_proxy).__name__}")
|
|
329
|
+
await injector.register_dependency(capability, new_proxy)
|
|
330
|
+
updated_count += 1
|
|
331
|
+
|
|
332
|
+
# Log which functions will be affected
|
|
333
|
+
affected_functions = injector._dependency_mapping.get(capability, set())
|
|
334
|
+
self.logger.debug(f"🎯 Functions affected by '{capability}' update: {list(affected_functions)}")
|
|
335
|
+
|
|
336
|
+
self.logger.info(
|
|
337
|
+
f"🔄 Updated API dependency '{capability}' → {endpoint}/{dep_function_name} "
|
|
338
|
+
f"(proxy: {proxy_type})"
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
if status != "available":
|
|
342
|
+
self.logger.debug(
|
|
343
|
+
f"⚠️ API dependency '{capability}' not available: {status}"
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
self.logger.warning(
|
|
347
|
+
f"⚠️ Cannot update API dependency '{capability}': missing endpoint or function_name"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Store new hash for next comparison (use global variable)
|
|
351
|
+
_last_api_dependency_hash = current_hash
|
|
352
|
+
|
|
353
|
+
if unwired_count > 0 and updated_count > 0:
|
|
354
|
+
self.logger.info(
|
|
355
|
+
f"✅ Successfully unwired {unwired_count} and updated {updated_count} API dependencies (state hash: {current_hash})"
|
|
356
|
+
)
|
|
357
|
+
elif unwired_count > 0:
|
|
358
|
+
self.logger.info(
|
|
359
|
+
f"✅ Successfully unwired {unwired_count} API dependencies (state hash: {current_hash})"
|
|
360
|
+
)
|
|
361
|
+
elif updated_count > 0:
|
|
362
|
+
self.logger.info(
|
|
363
|
+
f"✅ Successfully updated {updated_count} API dependencies (state hash: {current_hash})"
|
|
364
|
+
)
|
|
365
|
+
else:
|
|
366
|
+
self.logger.info(
|
|
367
|
+
f"✅ API dependency state synchronized (state hash: {current_hash})"
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
except Exception as e:
|
|
371
|
+
self.logger.error(
|
|
372
|
+
f"❌ Failed to process API heartbeat response for rewiring: {e}"
|
|
373
|
+
)
|
|
374
|
+
# Don't raise - this should not break the heartbeat loop
|
|
375
|
+
|
|
376
|
+
def _determine_api_proxy_type_for_capability(self, capability: str, injector) -> str:
|
|
377
|
+
"""
|
|
378
|
+
Determine which proxy type to use for API route handlers.
|
|
379
|
+
|
|
380
|
+
For API services, we need to check the parameter types used in FastAPI route handlers
|
|
381
|
+
that depend on this capability. This is different from MCP tools because route handlers
|
|
382
|
+
are wrapped differently.
|
|
383
|
+
|
|
384
|
+
Logic:
|
|
385
|
+
1. Check if any API route handlers use McpAgent for this capability
|
|
386
|
+
2. If yes → use FullMCPProxy
|
|
387
|
+
3. Otherwise → use MCPClientProxy (for McpMeshAgent or untyped)
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
capability: The capability name to check
|
|
391
|
+
injector: The dependency injector instance
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
"FullMCPProxy" or "MCPClientProxy"
|
|
395
|
+
"""
|
|
396
|
+
try:
|
|
397
|
+
# Get functions that depend on this capability
|
|
398
|
+
if capability not in injector._dependency_mapping:
|
|
399
|
+
self.logger.debug(
|
|
400
|
+
f"🔍 No API route handlers depend on capability '{capability}', using MCPClientProxy"
|
|
401
|
+
)
|
|
402
|
+
return "MCPClientProxy"
|
|
403
|
+
|
|
404
|
+
affected_function_ids = injector._dependency_mapping[capability]
|
|
405
|
+
|
|
406
|
+
# Scan ALL route handlers to detect ANY McpAgent usage
|
|
407
|
+
mcpagent_functions = []
|
|
408
|
+
mcpmeshagent_functions = []
|
|
409
|
+
|
|
410
|
+
for func_id in affected_function_ids:
|
|
411
|
+
if func_id in injector._function_registry:
|
|
412
|
+
wrapper_func = injector._function_registry[func_id]
|
|
413
|
+
|
|
414
|
+
# Get stored parameter types from wrapper (same pattern as MCP)
|
|
415
|
+
if hasattr(wrapper_func, "_mesh_parameter_types") and hasattr(
|
|
416
|
+
wrapper_func, "_mesh_dependencies"
|
|
417
|
+
):
|
|
418
|
+
parameter_types = wrapper_func._mesh_parameter_types
|
|
419
|
+
dependencies = wrapper_func._mesh_dependencies
|
|
420
|
+
mesh_positions = wrapper_func._mesh_positions
|
|
421
|
+
|
|
422
|
+
# Find which parameter position corresponds to this capability
|
|
423
|
+
for dep_index, dep_name in enumerate(dependencies):
|
|
424
|
+
if dep_name == capability and dep_index < len(
|
|
425
|
+
mesh_positions
|
|
426
|
+
):
|
|
427
|
+
param_position = mesh_positions[dep_index]
|
|
428
|
+
|
|
429
|
+
# Check the parameter type at this position
|
|
430
|
+
if param_position in parameter_types:
|
|
431
|
+
param_type = parameter_types[param_position]
|
|
432
|
+
if param_type == "McpAgent":
|
|
433
|
+
mcpagent_functions.append(func_id)
|
|
434
|
+
elif param_type == "McpMeshAgent":
|
|
435
|
+
mcpmeshagent_functions.append(func_id)
|
|
436
|
+
|
|
437
|
+
# Make deterministic decision based on complete analysis
|
|
438
|
+
if mcpagent_functions:
|
|
439
|
+
self.logger.debug(
|
|
440
|
+
f"🔍 Found McpAgent in API route handlers {mcpagent_functions} for capability '{capability}' → using FullMCPProxy"
|
|
441
|
+
)
|
|
442
|
+
if mcpmeshagent_functions:
|
|
443
|
+
self.logger.info(
|
|
444
|
+
f"ℹ️ API capability '{capability}' used by both McpAgent {mcpagent_functions} and McpMeshAgent {mcpmeshagent_functions} → upgrading ALL to FullMCPProxy"
|
|
445
|
+
)
|
|
446
|
+
return "FullMCPProxy"
|
|
447
|
+
else:
|
|
448
|
+
# Only McpMeshAgent or untyped parameters
|
|
449
|
+
self.logger.debug(
|
|
450
|
+
f"🔍 Only McpMeshAgent/untyped API route handlers {mcpmeshagent_functions} for capability '{capability}' → using MCPClientProxy"
|
|
451
|
+
)
|
|
452
|
+
return "MCPClientProxy"
|
|
453
|
+
|
|
454
|
+
except Exception as e:
|
|
455
|
+
self.logger.warning(
|
|
456
|
+
f"⚠️ Failed to determine proxy type for API capability '{capability}': {e}"
|
|
457
|
+
)
|
|
458
|
+
return "MCPClientProxy" # Safe default
|
|
459
|
+
|
|
460
|
+
def _create_proxy_for_api(
|
|
461
|
+
self, proxy_type: str, endpoint: str, dep_function_name: str, kwargs_config: dict
|
|
462
|
+
):
|
|
463
|
+
"""
|
|
464
|
+
Create the appropriate proxy instance for API route handlers.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
proxy_type: "FullMCPProxy" or "MCPClientProxy"
|
|
468
|
+
endpoint: Target endpoint URL
|
|
469
|
+
dep_function_name: Target function name
|
|
470
|
+
kwargs_config: Additional configuration (timeout, retry, etc.)
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
Proxy instance
|
|
474
|
+
"""
|
|
475
|
+
from ...engine.full_mcp_proxy import EnhancedFullMCPProxy, FullMCPProxy
|
|
476
|
+
from ...engine.mcp_client_proxy import EnhancedMCPClientProxy, MCPClientProxy
|
|
477
|
+
|
|
478
|
+
if proxy_type == "FullMCPProxy":
|
|
479
|
+
# Use enhanced proxy if kwargs available
|
|
480
|
+
if kwargs_config:
|
|
481
|
+
proxy = EnhancedFullMCPProxy(
|
|
482
|
+
endpoint,
|
|
483
|
+
dep_function_name,
|
|
484
|
+
kwargs_config=kwargs_config,
|
|
485
|
+
)
|
|
486
|
+
self.logger.debug(
|
|
487
|
+
f"🔧 Created EnhancedFullMCPProxy for API with kwargs: {kwargs_config}"
|
|
488
|
+
)
|
|
489
|
+
else:
|
|
490
|
+
proxy = FullMCPProxy(
|
|
491
|
+
endpoint,
|
|
492
|
+
dep_function_name,
|
|
493
|
+
kwargs_config=kwargs_config,
|
|
494
|
+
)
|
|
495
|
+
self.logger.debug("🔧 Created FullMCPProxy for API (no kwargs)")
|
|
496
|
+
return proxy
|
|
497
|
+
else:
|
|
498
|
+
# Use enhanced proxy if kwargs available
|
|
499
|
+
if kwargs_config:
|
|
500
|
+
proxy = EnhancedMCPClientProxy(
|
|
501
|
+
endpoint,
|
|
502
|
+
dep_function_name,
|
|
503
|
+
kwargs_config=kwargs_config,
|
|
504
|
+
)
|
|
505
|
+
self.logger.debug(
|
|
506
|
+
f"🔧 Created EnhancedMCPClientProxy for API with kwargs: {kwargs_config}"
|
|
507
|
+
)
|
|
508
|
+
else:
|
|
509
|
+
proxy = MCPClientProxy(
|
|
510
|
+
endpoint,
|
|
511
|
+
dep_function_name,
|
|
512
|
+
kwargs_config=kwargs_config,
|
|
513
|
+
)
|
|
514
|
+
self.logger.debug("🔧 Created MCPClientProxy for API (no kwargs)")
|
|
515
|
+
return proxy
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fast Heartbeat Check Step for API heartbeat pipeline.
|
|
3
|
+
|
|
4
|
+
Performs lightweight HEAD requests to registry for fast topology change detection
|
|
5
|
+
before expensive full POST heartbeat operations for FastAPI services.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ...shared.fast_heartbeat_status import FastHeartbeatStatus, FastHeartbeatStatusUtil
|
|
12
|
+
from ..shared.base_step import PipelineStep
|
|
13
|
+
from ..shared.pipeline_types import PipelineResult
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class APIFastHeartbeatStep(PipelineStep):
|
|
19
|
+
"""
|
|
20
|
+
Fast heartbeat check step for API services optimization and resilience.
|
|
21
|
+
|
|
22
|
+
Performs lightweight HEAD request to registry to check for topology changes
|
|
23
|
+
before deciding whether to execute expensive full POST heartbeat for API services.
|
|
24
|
+
|
|
25
|
+
Stores semantic status in context for pipeline conditional execution.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
super().__init__(
|
|
30
|
+
name="api-fast-heartbeat-check",
|
|
31
|
+
required=True,
|
|
32
|
+
description="Lightweight HEAD request for fast topology change detection (API services)",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
async def execute(self, context: dict[str, Any]) -> PipelineResult:
|
|
36
|
+
"""
|
|
37
|
+
Execute fast heartbeat check for API service and store semantic status.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
context: Pipeline context containing service_id/agent_id and registry_wrapper
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
PipelineResult with fast_heartbeat_status in context
|
|
44
|
+
"""
|
|
45
|
+
self.logger.debug("Starting API fast heartbeat check...")
|
|
46
|
+
|
|
47
|
+
result = PipelineResult(message="API fast heartbeat check completed")
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
# Validate required context - API services use service_id or agent_id
|
|
51
|
+
service_id = context.get("service_id") or context.get("agent_id")
|
|
52
|
+
registry_wrapper = context.get("registry_wrapper")
|
|
53
|
+
|
|
54
|
+
if not service_id:
|
|
55
|
+
raise ValueError("service_id or agent_id is required in context")
|
|
56
|
+
|
|
57
|
+
if not registry_wrapper:
|
|
58
|
+
raise ValueError("registry_wrapper is required in context")
|
|
59
|
+
|
|
60
|
+
self.logger.debug(
|
|
61
|
+
f"🚀 Performing API fast heartbeat check for service '{service_id}'"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Perform fast heartbeat check using same method as MCP agents
|
|
65
|
+
status = await registry_wrapper.check_fast_heartbeat(service_id)
|
|
66
|
+
|
|
67
|
+
# Store semantic status in context
|
|
68
|
+
result.add_context("fast_heartbeat_status", status)
|
|
69
|
+
|
|
70
|
+
# Set appropriate message based on status
|
|
71
|
+
action_description = FastHeartbeatStatusUtil.get_action_description(status)
|
|
72
|
+
result.message = f"API fast heartbeat check: {action_description}"
|
|
73
|
+
|
|
74
|
+
# Log status and action with API-specific messaging
|
|
75
|
+
if status == FastHeartbeatStatus.NO_CHANGES:
|
|
76
|
+
self.logger.info(
|
|
77
|
+
f"✅ API fast heartbeat: No changes detected for service '{service_id}'"
|
|
78
|
+
)
|
|
79
|
+
elif status == FastHeartbeatStatus.TOPOLOGY_CHANGED:
|
|
80
|
+
self.logger.info(
|
|
81
|
+
f"🔄 API fast heartbeat: Topology changed for service '{service_id}' - full refresh needed"
|
|
82
|
+
)
|
|
83
|
+
elif status == FastHeartbeatStatus.AGENT_UNKNOWN:
|
|
84
|
+
self.logger.info(
|
|
85
|
+
f"❓ API fast heartbeat: Service '{service_id}' unknown - re-registration needed"
|
|
86
|
+
)
|
|
87
|
+
elif status == FastHeartbeatStatus.REGISTRY_ERROR:
|
|
88
|
+
self.logger.warning(
|
|
89
|
+
f"⚠️ API fast heartbeat: Registry error for service '{service_id}' - skipping for resilience"
|
|
90
|
+
)
|
|
91
|
+
elif status == FastHeartbeatStatus.NETWORK_ERROR:
|
|
92
|
+
self.logger.warning(
|
|
93
|
+
f"⚠️ API fast heartbeat: Network error for service '{service_id}' - skipping for resilience"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
except Exception as e:
|
|
97
|
+
# Convert any exception to NETWORK_ERROR for resilient handling
|
|
98
|
+
status = FastHeartbeatStatusUtil.from_exception(e)
|
|
99
|
+
result.add_context("fast_heartbeat_status", status)
|
|
100
|
+
|
|
101
|
+
action_description = FastHeartbeatStatusUtil.get_action_description(status)
|
|
102
|
+
result.message = f"API fast heartbeat check: {action_description}"
|
|
103
|
+
|
|
104
|
+
self.logger.warning(
|
|
105
|
+
f"⚠️ API fast heartbeat check failed for service '{service_id}': {e}"
|
|
106
|
+
)
|
|
107
|
+
self.logger.debug(f"Exception details: {e}", exc_info=True)
|
|
108
|
+
|
|
109
|
+
# Step succeeds but sets error status for pipeline decision
|
|
110
|
+
# This ensures pipeline can handle errors gracefully
|
|
111
|
+
|
|
112
|
+
# Always preserve existing context
|
|
113
|
+
for key, value in context.items():
|
|
114
|
+
if key not in result.context:
|
|
115
|
+
result.add_context(key, value)
|
|
116
|
+
|
|
117
|
+
return result
|