mcp-mesh 0.5.7__py3-none-any.whl → 0.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. _mcp_mesh/__init__.py +1 -1
  2. _mcp_mesh/engine/base_injector.py +171 -0
  3. _mcp_mesh/engine/decorator_registry.py +162 -35
  4. _mcp_mesh/engine/dependency_injector.py +105 -19
  5. _mcp_mesh/engine/http_wrapper.py +5 -22
  6. _mcp_mesh/engine/llm_config.py +45 -0
  7. _mcp_mesh/engine/llm_errors.py +115 -0
  8. _mcp_mesh/engine/mesh_llm_agent.py +626 -0
  9. _mcp_mesh/engine/mesh_llm_agent_injector.py +617 -0
  10. _mcp_mesh/engine/provider_handlers/__init__.py +20 -0
  11. _mcp_mesh/engine/provider_handlers/base_provider_handler.py +122 -0
  12. _mcp_mesh/engine/provider_handlers/claude_handler.py +138 -0
  13. _mcp_mesh/engine/provider_handlers/generic_handler.py +156 -0
  14. _mcp_mesh/engine/provider_handlers/openai_handler.py +163 -0
  15. _mcp_mesh/engine/provider_handlers/provider_handler_registry.py +167 -0
  16. _mcp_mesh/engine/response_parser.py +205 -0
  17. _mcp_mesh/engine/signature_analyzer.py +229 -99
  18. _mcp_mesh/engine/tool_executor.py +169 -0
  19. _mcp_mesh/engine/tool_schema_builder.py +126 -0
  20. _mcp_mesh/engine/unified_mcp_proxy.py +14 -12
  21. _mcp_mesh/generated/.openapi-generator/FILES +7 -0
  22. _mcp_mesh/generated/.openapi-generator-ignore +0 -1
  23. _mcp_mesh/generated/mcp_mesh_registry_client/__init__.py +7 -16
  24. _mcp_mesh/generated/mcp_mesh_registry_client/models/__init__.py +7 -0
  25. _mcp_mesh/generated/mcp_mesh_registry_client/models/agent_info.py +11 -1
  26. _mcp_mesh/generated/mcp_mesh_registry_client/models/dependency_resolution_info.py +108 -0
  27. _mcp_mesh/generated/mcp_mesh_registry_client/models/llm_provider.py +95 -0
  28. _mcp_mesh/generated/mcp_mesh_registry_client/models/llm_tool_filter.py +111 -0
  29. _mcp_mesh/generated/mcp_mesh_registry_client/models/llm_tool_filter_filter_inner.py +141 -0
  30. _mcp_mesh/generated/mcp_mesh_registry_client/models/llm_tool_filter_filter_inner_one_of.py +93 -0
  31. _mcp_mesh/generated/mcp_mesh_registry_client/models/llm_tool_info.py +103 -0
  32. _mcp_mesh/generated/mcp_mesh_registry_client/models/mesh_agent_registration.py +1 -1
  33. _mcp_mesh/generated/mcp_mesh_registry_client/models/mesh_registration_response.py +35 -1
  34. _mcp_mesh/generated/mcp_mesh_registry_client/models/mesh_tool_registration.py +11 -1
  35. _mcp_mesh/generated/mcp_mesh_registry_client/models/resolved_llm_provider.py +112 -0
  36. _mcp_mesh/pipeline/api_heartbeat/api_dependency_resolution.py +9 -72
  37. _mcp_mesh/pipeline/mcp_heartbeat/fast_heartbeat_check.py +3 -3
  38. _mcp_mesh/pipeline/mcp_heartbeat/heartbeat_orchestrator.py +35 -10
  39. _mcp_mesh/pipeline/mcp_heartbeat/heartbeat_pipeline.py +7 -4
  40. _mcp_mesh/pipeline/mcp_heartbeat/llm_tools_resolution.py +260 -0
  41. _mcp_mesh/pipeline/mcp_startup/fastapiserver_setup.py +118 -35
  42. _mcp_mesh/pipeline/mcp_startup/fastmcpserver_discovery.py +8 -1
  43. _mcp_mesh/pipeline/mcp_startup/heartbeat_preparation.py +111 -5
  44. _mcp_mesh/pipeline/mcp_startup/server_discovery.py +77 -48
  45. _mcp_mesh/pipeline/mcp_startup/startup_orchestrator.py +2 -2
  46. _mcp_mesh/pipeline/mcp_startup/startup_pipeline.py +2 -2
  47. _mcp_mesh/shared/health_check_cache.py +246 -0
  48. _mcp_mesh/shared/registry_client_wrapper.py +87 -4
  49. _mcp_mesh/utils/fastmcp_schema_extractor.py +476 -0
  50. {mcp_mesh-0.5.7.dist-info → mcp_mesh-0.6.1.dist-info}/METADATA +1 -1
  51. {mcp_mesh-0.5.7.dist-info → mcp_mesh-0.6.1.dist-info}/RECORD +57 -32
  52. mesh/__init__.py +18 -4
  53. mesh/decorators.py +439 -31
  54. mesh/helpers.py +259 -0
  55. mesh/types.py +197 -97
  56. {mcp_mesh-0.5.7.dist-info → mcp_mesh-0.6.1.dist-info}/WHEEL +0 -0
  57. {mcp_mesh-0.5.7.dist-info → mcp_mesh-0.6.1.dist-info}/licenses/LICENSE +0 -0
mesh/decorators.py CHANGED
@@ -6,7 +6,8 @@ Provides @mesh.tool and @mesh.agent decorators with clean separation of concerns
6
6
 
7
7
  import logging
8
8
  import uuid
9
- from collections.abc import Callable
9
+ from collections.abc import Awaitable, Callable
10
+ from functools import wraps
10
11
  from typing import Any, TypeVar
11
12
 
12
13
  # Import from _mcp_mesh for registry and runtime integration
@@ -32,7 +33,7 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
32
33
  This prevents the DNS threading conflicts by ensuring uvicorn takes control
33
34
  before the script ends and Python enters shutdown state.
34
35
  """
35
- logger.info(
36
+ logger.debug(
36
37
  f"🎯 IMMEDIATE UVICORN: _start_uvicorn_immediately() called with host={http_host}, port={http_port}"
37
38
  )
38
39
 
@@ -42,9 +43,9 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
42
43
  import time
43
44
 
44
45
  import uvicorn
45
- from fastapi import FastAPI
46
+ from fastapi import FastAPI, Response
46
47
 
47
- logger.info(
48
+ logger.debug(
48
49
  "📦 IMMEDIATE UVICORN: Successfully imported uvicorn, FastAPI, threading, asyncio"
49
50
  )
50
51
 
@@ -55,11 +56,11 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
55
56
 
56
57
  fastmcp_lifespan = DecoratorRegistry.get_fastmcp_lifespan()
57
58
  if fastmcp_lifespan:
58
- logger.info(
59
+ logger.debug(
59
60
  "✅ IMMEDIATE UVICORN: Found stored FastMCP lifespan, will integrate with FastAPI"
60
61
  )
61
62
  else:
62
- logger.info(
63
+ logger.debug(
63
64
  "🔍 IMMEDIATE UVICORN: No FastMCP lifespan found, creating basic FastAPI app"
64
65
  )
65
66
  except Exception as e:
@@ -68,20 +69,61 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
68
69
  # Create FastAPI app with FastMCP lifespan if available
69
70
  if fastmcp_lifespan:
70
71
  app = FastAPI(title="MCP Mesh Agent (Starting)", lifespan=fastmcp_lifespan)
71
- logger.info(
72
+ logger.debug(
72
73
  "📦 IMMEDIATE UVICORN: Created FastAPI app with FastMCP lifespan integration"
73
74
  )
74
75
  else:
75
76
  app = FastAPI(title="MCP Mesh Agent (Starting)")
76
- logger.info("📦 IMMEDIATE UVICORN: Created minimal FastAPI app")
77
+ logger.debug("📦 IMMEDIATE UVICORN: Created minimal FastAPI app")
78
+
79
+ # Add health endpoint that can be updated by pipeline
80
+ # Store health check result in a shared location that can be updated
81
+ health_result = {"status": "starting", "message": "Agent is starting"}
77
82
 
78
- # Add basic health endpoint
79
83
  @app.get("/health")
80
- def health():
81
- return {
82
- "status": "immediate_uvicorn",
83
- "message": "MCP Mesh agent started via immediate uvicorn",
84
- }
84
+ @app.head("/health")
85
+ async def health(response: Response):
86
+ """Health check endpoint that supports custom health checks."""
87
+ # Check if a custom health check has been configured
88
+ # The pipeline will update this via DecoratorRegistry
89
+ custom_health = DecoratorRegistry.get_health_check_result()
90
+ health_data = custom_health if custom_health else health_result
91
+
92
+ # Set HTTP status code based on health status
93
+ # K8s expects non-200 status for unhealthy services
94
+ status = health_data.get("status", "starting")
95
+ if status == "healthy":
96
+ response.status_code = 200
97
+ else:
98
+ # Return 503 for unhealthy, degraded, starting, or unknown
99
+ response.status_code = 503
100
+
101
+ return health_data
102
+
103
+ @app.get("/ready")
104
+ @app.head("/ready")
105
+ async def ready(response: Response):
106
+ """Kubernetes readiness probe - service ready to serve traffic."""
107
+ custom_health = DecoratorRegistry.get_health_check_result()
108
+ health_data = custom_health if custom_health else health_result
109
+
110
+ status = health_data.get("status", "starting")
111
+ if status == "healthy":
112
+ response.status_code = 200
113
+ return {"ready": True, "status": status}
114
+ else:
115
+ response.status_code = 503
116
+ return {
117
+ "ready": False,
118
+ "status": status,
119
+ "reason": f"Service is {status}",
120
+ }
121
+
122
+ @app.get("/livez")
123
+ @app.head("/livez")
124
+ async def livez():
125
+ """Kubernetes liveness probe - always returns 200 if app is running."""
126
+ return {"alive": True, "message": "Application is running"}
85
127
 
86
128
  @app.get("/immediate-status")
87
129
  def immediate_status():
@@ -90,17 +132,17 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
90
132
  "message": "This server was started immediately in decorator",
91
133
  }
92
134
 
93
- logger.info("📦 IMMEDIATE UVICORN: Added health endpoints")
135
+ logger.debug("📦 IMMEDIATE UVICORN: Added status endpoints")
94
136
 
95
137
  # Determine port (0 means auto-assign)
96
138
  port = http_port if http_port > 0 else 8080
97
139
 
98
- logger.info(
140
+ logger.debug(
99
141
  f"🚀 IMMEDIATE UVICORN: Starting uvicorn server on {http_host}:{port}"
100
142
  )
101
143
 
102
144
  # Use uvicorn.run() for proper signal handling (enables FastAPI lifespan shutdown)
103
- logger.info(
145
+ logger.debug(
104
146
  "⚡ IMMEDIATE UVICORN: Starting server with uvicorn.run() for proper signal handling"
105
147
  )
106
148
 
@@ -108,7 +150,7 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
108
150
  def run_server():
109
151
  """Run uvicorn server in background thread with proper signal handling."""
110
152
  try:
111
- logger.info(
153
+ logger.debug(
112
154
  f"🌟 IMMEDIATE UVICORN: Starting server on {http_host}:{port}"
113
155
  )
114
156
  # Use uvicorn.run() instead of Server().run() for proper signal handling
@@ -130,7 +172,7 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
130
172
  thread = threading.Thread(target=run_server, daemon=False)
131
173
  thread.start()
132
174
 
133
- logger.info(
175
+ logger.debug(
134
176
  "🔒 IMMEDIATE UVICORN: Server thread started (daemon=False) - can handle signals"
135
177
  )
136
178
 
@@ -151,14 +193,14 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
151
193
 
152
194
  DecoratorRegistry.store_immediate_uvicorn_server(server_info)
153
195
 
154
- logger.info(
196
+ logger.debug(
155
197
  "🔄 IMMEDIATE UVICORN: Server reference stored in DecoratorRegistry BEFORE pipeline starts"
156
198
  )
157
199
 
158
200
  # Give server a moment to start
159
201
  time.sleep(1)
160
202
 
161
- logger.info(
203
+ logger.debug(
162
204
  f"✅ IMMEDIATE UVICORN: Uvicorn server running on {http_host}:{port} (daemon thread)"
163
205
  )
164
206
 
@@ -452,6 +494,8 @@ def agent(
452
494
  enable_http: bool = True,
453
495
  namespace: str = "default",
454
496
  health_interval: int = 5, # Will be overridden by centralized defaults
497
+ health_check: Callable[[], Awaitable[Any]] | None = None,
498
+ health_check_ttl: int = 15,
455
499
  auto_run: bool = True, # Changed to True by default!
456
500
  auto_run_interval: int = 10,
457
501
  **kwargs: Any,
@@ -476,6 +520,10 @@ def agent(
476
520
  Environment variable: MCP_MESH_NAMESPACE (takes precedence)
477
521
  health_interval: Health check interval in seconds (default: 30)
478
522
  Environment variable: MCP_MESH_HEALTH_INTERVAL (takes precedence)
523
+ health_check: Optional async function that returns HealthStatus
524
+ Called before heartbeat and on /health endpoint with TTL caching
525
+ health_check_ttl: Cache TTL for health check results in seconds (default: 15)
526
+ Reduces expensive health check calls by caching results
479
527
  auto_run: Automatically start service and keep process alive (default: True)
480
528
  Environment variable: MCP_MESH_AUTO_RUN (takes precedence)
481
529
  auto_run_interval: Keep-alive heartbeat interval in seconds (default: 10)
@@ -551,6 +599,14 @@ def agent(
551
599
  if auto_run_interval < 1:
552
600
  raise ValueError("auto_run_interval must be at least 1 second")
553
601
 
602
+ if health_check is not None and not callable(health_check):
603
+ raise ValueError("health_check must be a callable (async function)")
604
+
605
+ if not isinstance(health_check_ttl, int):
606
+ raise ValueError("health_check_ttl must be an integer")
607
+ if health_check_ttl < 1:
608
+ raise ValueError("health_check_ttl must be at least 1 second")
609
+
554
610
  # Separate binding host (for uvicorn server) from external host (for registry)
555
611
  from _mcp_mesh.shared.host_resolver import HostResolver
556
612
 
@@ -622,6 +678,8 @@ def agent(
622
678
  "enable_http": final_enable_http,
623
679
  "namespace": final_namespace,
624
680
  "health_interval": final_health_interval,
681
+ "health_check": health_check,
682
+ "health_check_ttl": health_check_ttl,
625
683
  "auto_run": final_auto_run,
626
684
  "auto_run_interval": final_auto_run_interval,
627
685
  "agent_id": agent_id,
@@ -646,7 +704,7 @@ def agent(
646
704
 
647
705
  # Auto-run functionality: start uvicorn immediately to prevent Python shutdown state
648
706
  if final_auto_run:
649
- logger.info(
707
+ logger.debug(
650
708
  f"🚀 AGENT DECORATOR: Auto-run enabled for agent '{name}' - starting uvicorn immediately to prevent shutdown state"
651
709
  )
652
710
 
@@ -654,7 +712,7 @@ def agent(
654
712
  fastmcp_lifespan = None
655
713
  try:
656
714
  # Try to create FastMCP server and extract lifespan
657
- logger.info(
715
+ logger.debug(
658
716
  "🔍 AGENT DECORATOR: Creating FastMCP server for lifespan extraction"
659
717
  )
660
718
 
@@ -666,7 +724,7 @@ def agent(
666
724
  # Look for 'app' attribute (standard FastMCP pattern)
667
725
  if hasattr(current_module, "app"):
668
726
  fastmcp_server = current_module.app
669
- logger.info(
727
+ logger.debug(
670
728
  f"🔍 AGENT DECORATOR: Found FastMCP server: {type(fastmcp_server)}"
671
729
  )
672
730
 
@@ -680,7 +738,7 @@ def agent(
680
738
  )
681
739
  if hasattr(fastmcp_http_app, "lifespan"):
682
740
  fastmcp_lifespan = fastmcp_http_app.lifespan
683
- logger.info(
741
+ logger.debug(
684
742
  "✅ AGENT DECORATOR: Extracted FastMCP lifespan for FastAPI integration"
685
743
  )
686
744
 
@@ -691,7 +749,7 @@ def agent(
691
749
  DecoratorRegistry.store_fastmcp_http_app(
692
750
  fastmcp_http_app
693
751
  )
694
- logger.info(
752
+ logger.debug(
695
753
  "✅ AGENT DECORATOR: Stored FastMCP HTTP app for proper mounting"
696
754
  )
697
755
  else:
@@ -707,7 +765,7 @@ def agent(
707
765
  "⚠️ AGENT DECORATOR: FastMCP server has no http_app method"
708
766
  )
709
767
  else:
710
- logger.info(
768
+ logger.debug(
711
769
  "🔍 AGENT DECORATOR: No FastMCP 'app' found in current module - will handle in pipeline"
712
770
  )
713
771
  else:
@@ -720,12 +778,12 @@ def agent(
720
778
  f"⚠️ AGENT DECORATOR: FastMCP lifespan creation failed: {e}"
721
779
  )
722
780
 
723
- logger.info(
781
+ logger.debug(
724
782
  f"🎯 AGENT DECORATOR: About to call _start_uvicorn_immediately({binding_host}, {final_http_port})"
725
783
  )
726
784
  # Start basic uvicorn server immediately to prevent interpreter shutdown
727
785
  _start_uvicorn_immediately(binding_host, final_http_port)
728
- logger.info(
786
+ logger.debug(
729
787
  "✅ AGENT DECORATOR: _start_uvicorn_immediately() call completed"
730
788
  )
731
789
 
@@ -758,8 +816,8 @@ def route(
758
816
  async def upload_resume(
759
817
  request: Request,
760
818
  file: UploadFile = File(...),
761
- pdf_agent: McpAgent = None, # Injected by MCP Mesh
762
- user_service: McpAgent = None # Injected by MCP Mesh
819
+ pdf_agent: mesh.McpMeshAgent = None, # Injected by MCP Mesh
820
+ user_service: mesh.McpMeshAgent = None # Injected by MCP Mesh
763
821
  ):
764
822
  result = await pdf_agent.extract_text_from_pdf(file)
765
823
  await user_service.update_profile(user_data, result)
@@ -929,3 +987,353 @@ def set_shutdown_context(context: dict[str, Any]):
929
987
  """Set context for graceful shutdown (called from pipeline)."""
930
988
  # Delegate to the shared graceful shutdown manager
931
989
  set_global_shutdown_context(context)
990
+
991
+
992
+ def llm(
993
+ filter: dict[str, Any] | list[dict[str, Any] | str] | str | None = None,
994
+ *,
995
+ filter_mode: str = "all",
996
+ provider: str | dict[str, Any] = "claude",
997
+ model: str | None = None,
998
+ api_key: str | None = None,
999
+ max_iterations: int = 10,
1000
+ system_prompt: str | None = None,
1001
+ system_prompt_file: str | None = None,
1002
+ context_param: str | None = None,
1003
+ **kwargs: Any,
1004
+ ) -> Callable[[T], T]:
1005
+ """
1006
+ LLM agent decorator with automatic agentic loop.
1007
+
1008
+ This decorator enables LLM agents to automatically access mesh tools via
1009
+ dependency injection. The MeshLlmAgent proxy handles the complete agentic loop:
1010
+ - Tool filtering based on filter parameter
1011
+ - LLM API calls (Claude, OpenAI, etc. via LiteLLM)
1012
+ - Tool execution via MCP proxies
1013
+ - Response parsing to Pydantic models
1014
+
1015
+ Configuration Hierarchy (ENV > Decorator):
1016
+ - MESH_LLM_PROVIDER: Override provider
1017
+ - MESH_LLM_MODEL: Override model
1018
+ - ANTHROPIC_API_KEY: Claude API key
1019
+ - OPENAI_API_KEY: OpenAI API key
1020
+ - MESH_LLM_MAX_ITERATIONS: Override max iterations
1021
+
1022
+ Usage:
1023
+ from pydantic import BaseModel
1024
+ import mesh
1025
+
1026
+ class ChatResponse(BaseModel):
1027
+ answer: str
1028
+ confidence: float
1029
+
1030
+ @mesh.llm(
1031
+ filter={"capability": "document", "tags": ["pdf"]},
1032
+ provider="claude",
1033
+ model="claude-3-5-sonnet-20241022"
1034
+ )
1035
+ @mesh.tool(capability="chat")
1036
+ def chat(message: str, llm: mesh.MeshLlmAgent = None) -> ChatResponse:
1037
+ llm.set_system_prompt("You are a helpful assistant.")
1038
+ return llm(message)
1039
+
1040
+ Args:
1041
+ filter: Tool filter (string, dict, or list of mixed)
1042
+ filter_mode: Filter mode ("all", "best_match", "*")
1043
+ provider: LLM provider (string like "claude" for direct LiteLLM, or dict for mesh delegation)
1044
+ Mesh delegation format: {"capability": "llm", "tags": ["claude"], "version": ">=1.0.0"}
1045
+ When dict: Uses mesh DI to resolve provider agent instead of calling LiteLLM directly
1046
+ model: Model name (can be overridden by MESH_LLM_MODEL) - only used with string provider
1047
+ api_key: API key (can be overridden by provider-specific env vars) - only used with string provider
1048
+ max_iterations: Max agentic loop iterations (can be overridden by MESH_LLM_MAX_ITERATIONS)
1049
+ system_prompt: Default system prompt
1050
+ system_prompt_file: Path to Jinja2 template file
1051
+ **kwargs: Additional configuration
1052
+
1053
+ Returns:
1054
+ Decorated function with MeshLlmAgent injection
1055
+
1056
+ Raises:
1057
+ ValueError: If no MeshLlmAgent parameter found
1058
+ UserWarning: If multiple MeshLlmAgent parameters or non-Pydantic return type
1059
+ """
1060
+ import inspect
1061
+ import warnings
1062
+
1063
+ def decorator(func: T) -> T:
1064
+ # Step 1: Resolve configuration with hierarchy (ENV > decorator params)
1065
+ # Phase 1: Detect file:// prefix for template files
1066
+ is_template = False
1067
+ template_path = None
1068
+
1069
+ if system_prompt:
1070
+ # Check for file:// prefix
1071
+ if system_prompt.startswith("file://"):
1072
+ is_template = True
1073
+ template_path = system_prompt[7:] # Strip "file://" prefix
1074
+ # Auto-detect .jinja2 or .j2 extension without file:// prefix
1075
+ elif system_prompt.endswith(".jinja2") or system_prompt.endswith(".j2"):
1076
+ is_template = True
1077
+ template_path = system_prompt
1078
+
1079
+ # Backward compatibility: system_prompt_file (deprecated)
1080
+ if system_prompt_file:
1081
+ logger.warning(
1082
+ f"⚠️ @mesh.llm: 'system_prompt_file' parameter is deprecated. "
1083
+ f"Use 'system_prompt=\"file://{system_prompt_file}\"' instead."
1084
+ )
1085
+ if not is_template: # Only use if system_prompt didn't specify a template
1086
+ is_template = True
1087
+ template_path = system_prompt_file
1088
+
1089
+ # Validate context_param usage
1090
+ if context_param and not is_template:
1091
+ logger.warning(
1092
+ f"⚠️ @mesh.llm: 'context_param' specified for function '{func.__name__}' "
1093
+ f"but system_prompt is not a template (no file:// prefix or .jinja2/.j2 extension). "
1094
+ f"Context parameter will be ignored."
1095
+ )
1096
+
1097
+ # Handle provider config: dict (mesh delegation) or string (direct LiteLLM)
1098
+ # If provider is dict, don't allow env var override (explicit mesh delegation)
1099
+ if isinstance(provider, dict):
1100
+ resolved_provider = provider
1101
+ else:
1102
+ resolved_provider = get_config_value(
1103
+ "MESH_LLM_PROVIDER",
1104
+ override=provider,
1105
+ default="claude",
1106
+ rule=ValidationRule.STRING_RULE,
1107
+ )
1108
+
1109
+ resolved_config = {
1110
+ "filter": filter,
1111
+ "filter_mode": get_config_value(
1112
+ "MESH_LLM_FILTER_MODE",
1113
+ override=filter_mode,
1114
+ default="all",
1115
+ rule=ValidationRule.STRING_RULE,
1116
+ ),
1117
+ "provider": resolved_provider,
1118
+ "model": get_config_value(
1119
+ "MESH_LLM_MODEL",
1120
+ override=model,
1121
+ default=None,
1122
+ rule=ValidationRule.STRING_RULE,
1123
+ ),
1124
+ "api_key": api_key, # Will be resolved from provider-specific env vars later
1125
+ "max_iterations": get_config_value(
1126
+ "MESH_LLM_MAX_ITERATIONS",
1127
+ override=max_iterations,
1128
+ default=10,
1129
+ rule=ValidationRule.NONZERO_RULE,
1130
+ ),
1131
+ "system_prompt": system_prompt,
1132
+ "system_prompt_file": system_prompt_file,
1133
+ # Phase 1: Template metadata
1134
+ "is_template": is_template,
1135
+ "template_path": template_path,
1136
+ "context_param": context_param,
1137
+ }
1138
+ resolved_config.update(kwargs)
1139
+
1140
+ # Step 2: Extract output type from return annotation
1141
+ sig = inspect.signature(func)
1142
+ return_annotation = sig.return_annotation
1143
+
1144
+ output_type = None
1145
+ if return_annotation and return_annotation != inspect.Signature.empty:
1146
+ output_type = return_annotation
1147
+
1148
+ # Warn if not a Pydantic model
1149
+ try:
1150
+ from pydantic import BaseModel
1151
+
1152
+ if not (
1153
+ inspect.isclass(output_type) and issubclass(output_type, BaseModel)
1154
+ ):
1155
+ warnings.warn(
1156
+ f"Function '{func.__name__}' decorated with @mesh.llm should return a Pydantic BaseModel subclass, "
1157
+ f"got {output_type}. This may cause validation errors at runtime.",
1158
+ UserWarning,
1159
+ stacklevel=2,
1160
+ )
1161
+ except ImportError:
1162
+ pass # Pydantic not available, skip validation
1163
+
1164
+ # Step 3: Find MeshLlmAgent parameter
1165
+ from mesh.types import MeshLlmAgent
1166
+
1167
+ llm_params = []
1168
+ for param_name, param in sig.parameters.items():
1169
+ if param.annotation == MeshLlmAgent or (
1170
+ hasattr(param.annotation, "__origin__")
1171
+ and param.annotation.__origin__ == MeshLlmAgent
1172
+ ):
1173
+ llm_params.append(param_name)
1174
+
1175
+ if not llm_params:
1176
+ raise ValueError(
1177
+ f"Function '{func.__name__}' decorated with @mesh.llm must have at least one parameter "
1178
+ f"of type 'mesh.MeshLlmAgent'. Example: def {func.__name__}(..., llm: mesh.MeshLlmAgent = None)"
1179
+ )
1180
+
1181
+ if len(llm_params) > 1:
1182
+ warnings.warn(
1183
+ f"Function '{func.__name__}' has multiple MeshLlmAgent parameters: {llm_params}. "
1184
+ f"Only the first parameter '{llm_params[0]}' will be injected. "
1185
+ f"Additional parameters will be ignored.",
1186
+ UserWarning,
1187
+ stacklevel=2,
1188
+ )
1189
+
1190
+ param_name = llm_params[0]
1191
+
1192
+ # Step 4: Generate unique function ID
1193
+ function_id = f"{func.__name__}_{uuid.uuid4().hex[:8]}"
1194
+
1195
+ # Step 5: Register with DecoratorRegistry
1196
+ DecoratorRegistry.register_mesh_llm(
1197
+ func=func,
1198
+ config=resolved_config,
1199
+ output_type=output_type,
1200
+ param_name=param_name,
1201
+ function_id=function_id,
1202
+ )
1203
+
1204
+ logger.debug(
1205
+ f"@mesh.llm registered: {func.__name__} "
1206
+ f"(provider={resolved_config['provider']}, param={param_name}, filter={filter})"
1207
+ )
1208
+
1209
+ # Step 6: Enhance existing wrapper from @mesh.tool (if present)
1210
+ # or create new wrapper
1211
+ #
1212
+ # This approach:
1213
+ # - Reuses the wrapper created by @mesh.tool (if present)
1214
+ # - Avoids creating multiple wrapper layers
1215
+ # - Ensures FastMCP caches the SAME wrapper instance we update later
1216
+ # - Combines both DI injection and LLM injection in the same wrapper
1217
+
1218
+ # Check if there's an existing wrapper from @mesh.tool
1219
+ mesh_tools = DecoratorRegistry.get_mesh_tools()
1220
+ existing_wrapper = None
1221
+
1222
+ if func.__name__ in mesh_tools:
1223
+ existing_wrapper = mesh_tools[func.__name__].function
1224
+ logger.info(
1225
+ f"🔗 Found existing @mesh.tool wrapper for '{func.__name__}' at {hex(id(existing_wrapper))} - enhancing it"
1226
+ )
1227
+
1228
+ # Trigger debounced processing
1229
+ _trigger_debounced_processing()
1230
+
1231
+ if existing_wrapper:
1232
+ # ENHANCE the existing wrapper with LLM attributes
1233
+ logger.info(
1234
+ f"✨ Enhancing existing wrapper with LLM injection for '{func.__name__}'"
1235
+ )
1236
+
1237
+ # Store the original wrapped function if not already stored
1238
+ if not hasattr(existing_wrapper, "__wrapped__"):
1239
+ existing_wrapper.__wrapped__ = func
1240
+
1241
+ # Store the original call behavior to preserve DI injection
1242
+ original_call = existing_wrapper
1243
+
1244
+ # Create enhanced wrapper that does BOTH DI injection and LLM injection
1245
+ @wraps(func)
1246
+ def combined_injection_wrapper(*args, **kwargs):
1247
+ """Wrapper that injects both MeshLlmAgent and DI parameters."""
1248
+ # Inject LLM parameter if not provided or if it's None
1249
+ if param_name not in kwargs or kwargs.get(param_name) is None:
1250
+ kwargs[param_name] = combined_injection_wrapper._mesh_llm_agent
1251
+ # Then call the original wrapper (which handles DI injection)
1252
+ return original_call(*args, **kwargs)
1253
+
1254
+ # Add LLM metadata attributes to combined wrapper
1255
+ combined_injection_wrapper._mesh_llm_agent = (
1256
+ None # Will be updated during heartbeat
1257
+ )
1258
+ combined_injection_wrapper._mesh_llm_param_name = param_name
1259
+ combined_injection_wrapper._mesh_llm_function_id = function_id
1260
+ combined_injection_wrapper._mesh_llm_config = resolved_config
1261
+ combined_injection_wrapper._mesh_llm_output_type = output_type
1262
+ combined_injection_wrapper.__wrapped__ = func
1263
+
1264
+ # Create update method for heartbeat that updates the COMBINED wrapper
1265
+ def update_llm_agent(agent):
1266
+ combined_injection_wrapper._mesh_llm_agent = agent
1267
+ logger.info(
1268
+ f"🔄 Updated MeshLlmAgent on combined wrapper for {func.__name__} (function_id={function_id})"
1269
+ )
1270
+
1271
+ combined_injection_wrapper._mesh_update_llm_agent = update_llm_agent
1272
+
1273
+ # Copy any other mesh attributes from existing wrapper
1274
+ for attr in dir(existing_wrapper):
1275
+ if attr.startswith("_mesh_") and not hasattr(
1276
+ combined_injection_wrapper, attr
1277
+ ):
1278
+ try:
1279
+ setattr(
1280
+ combined_injection_wrapper,
1281
+ attr,
1282
+ getattr(existing_wrapper, attr),
1283
+ )
1284
+ except AttributeError:
1285
+ pass # Some attributes might not be settable
1286
+
1287
+ # Update DecoratorRegistry with the combined wrapper
1288
+ DecoratorRegistry.update_mesh_llm_function(
1289
+ function_id, combined_injection_wrapper
1290
+ )
1291
+ DecoratorRegistry.update_mesh_tool_function(
1292
+ func.__name__, combined_injection_wrapper
1293
+ )
1294
+
1295
+ logger.info(
1296
+ f"✅ Enhanced wrapper for '{func.__name__}' with combined DI + LLM injection at {hex(id(combined_injection_wrapper))}"
1297
+ )
1298
+
1299
+ # Return the enhanced wrapper
1300
+ return combined_injection_wrapper
1301
+
1302
+ else:
1303
+ # FALLBACK: Create new wrapper if no existing @mesh.tool wrapper found
1304
+ logger.info(
1305
+ f"📝 No existing wrapper found for '{func.__name__}' - creating new LLM wrapper"
1306
+ )
1307
+
1308
+ @wraps(func)
1309
+ def llm_injection_wrapper(*args, **kwargs):
1310
+ """Wrapper that injects MeshLlmAgent parameter."""
1311
+ # Inject llm parameter if not provided or if it's None
1312
+ if param_name not in kwargs or kwargs.get(param_name) is None:
1313
+ kwargs[param_name] = llm_injection_wrapper._mesh_llm_agent
1314
+ return func(*args, **kwargs)
1315
+
1316
+ # Create update method for heartbeat - updates the wrapper, not func
1317
+ def update_llm_agent(agent):
1318
+ llm_injection_wrapper._mesh_llm_agent = agent
1319
+ logger.info(
1320
+ f"🔄 Updated MeshLlmAgent for {func.__name__} (function_id={function_id})"
1321
+ )
1322
+
1323
+ # Copy all metadata attributes to the wrapper
1324
+ llm_injection_wrapper._mesh_llm_agent = None
1325
+ llm_injection_wrapper._mesh_llm_param_name = param_name
1326
+ llm_injection_wrapper._mesh_llm_function_id = function_id
1327
+ llm_injection_wrapper._mesh_llm_config = resolved_config
1328
+ llm_injection_wrapper._mesh_llm_output_type = output_type
1329
+ llm_injection_wrapper._mesh_update_llm_agent = update_llm_agent
1330
+
1331
+ # Update DecoratorRegistry with the wrapper
1332
+ DecoratorRegistry.update_mesh_llm_function(
1333
+ function_id, llm_injection_wrapper
1334
+ )
1335
+
1336
+ # Return the new wrapper
1337
+ return llm_injection_wrapper
1338
+
1339
+ return decorator