mcp-mesh 0.8.0b9__py3-none-any.whl → 0.9.0b1__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.
mesh/decorators.py CHANGED
@@ -26,6 +26,25 @@ _runtime_processor: Any | None = None
26
26
  _SHARED_AGENT_ID: str | None = None
27
27
 
28
28
 
29
+ def _find_available_port() -> int:
30
+ """
31
+ Find an available port by binding to port 0 and getting the OS-assigned port.
32
+
33
+ This is used when http_port=0 is specified to auto-assign a port.
34
+ Works reliably on all platforms (macOS, Linux, Windows) without external tools.
35
+
36
+ Returns:
37
+ int: An available port number
38
+ """
39
+ import socket
40
+
41
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
42
+ s.bind(("127.0.0.1", 0))
43
+ s.listen(1)
44
+ port = s.getsockname()[1]
45
+ return port
46
+
47
+
29
48
  def _start_uvicorn_immediately(http_host: str, http_port: int):
30
49
  """
31
50
  Start basic uvicorn server immediately to prevent Python interpreter shutdown.
@@ -76,8 +95,75 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
76
95
  app = FastAPI(title="MCP Mesh Agent (Starting)")
77
96
  logger.debug("📦 IMMEDIATE UVICORN: Created minimal FastAPI app")
78
97
 
79
- # Add trace context middleware for distributed tracing BEFORE app starts
80
- # This must be done before uvicorn.run() since middleware can't be added after start
98
+ # Add middleware to strip trace arguments from tool calls BEFORE app starts
99
+ # This must be done unconditionally because meshctl --trace sends trace args
100
+ # regardless of agent's tracing configuration
101
+ try:
102
+ import json as json_module
103
+
104
+ class TraceArgumentStripperMiddleware:
105
+ """Pure ASGI middleware to strip trace arguments from tool calls.
106
+
107
+ This middleware ALWAYS runs to strip _trace_id and _parent_span from
108
+ MCP tool arguments, preventing Pydantic validation errors when
109
+ meshctl --trace is used with agents that don't have tracing enabled.
110
+ """
111
+
112
+ def __init__(self, app):
113
+ self.app = app
114
+
115
+ async def __call__(self, scope, receive, send):
116
+ if scope["type"] != "http":
117
+ await self.app(scope, receive, send)
118
+ return
119
+
120
+ async def receive_with_trace_stripping():
121
+ message = await receive()
122
+ if message["type"] == "http.request":
123
+ body = message.get("body", b"")
124
+ if body:
125
+ try:
126
+ payload = json_module.loads(body.decode("utf-8"))
127
+ if payload.get("method") == "tools/call":
128
+ arguments = payload.get("params", {}).get(
129
+ "arguments", {}
130
+ )
131
+ # Strip trace context fields from arguments
132
+ if (
133
+ "_trace_id" in arguments
134
+ or "_parent_span" in arguments
135
+ ):
136
+ arguments.pop("_trace_id", None)
137
+ arguments.pop("_parent_span", None)
138
+ modified_body = json_module.dumps(
139
+ payload
140
+ ).encode("utf-8")
141
+ logger.debug(
142
+ "[TRACE] Stripped trace fields from arguments"
143
+ )
144
+ return {
145
+ **message,
146
+ "body": modified_body,
147
+ }
148
+ except Exception as e:
149
+ logger.debug(
150
+ f"[TRACE] Failed to process body for stripping: {e}"
151
+ )
152
+ return message
153
+
154
+ await self.app(scope, receive_with_trace_stripping, send)
155
+
156
+ app.add_middleware(TraceArgumentStripperMiddleware)
157
+ logger.debug(
158
+ "📦 IMMEDIATE UVICORN: Added trace argument stripper middleware"
159
+ )
160
+ except Exception as e:
161
+ logger.warning(
162
+ f"⚠️ IMMEDIATE UVICORN: Failed to add trace argument stripper middleware: {e}"
163
+ )
164
+
165
+ # Add trace context middleware for distributed tracing (optional)
166
+ # This handles trace propagation and header injection when tracing is enabled
81
167
  try:
82
168
  import os
83
169
 
@@ -152,11 +238,11 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
152
238
  except Exception as e:
153
239
  logger.warning(f"Failed to set trace context: {e}")
154
240
 
155
- # Create body-modifying receive wrapper to strip trace fields from arguments
156
- # This handles the case where trace context is passed in JSON-RPC arguments
241
+ # Create receive wrapper to extract trace context from arguments
242
+ # Note: Argument stripping is handled by TraceArgumentStripperMiddleware
157
243
  import json as json_module
158
244
 
159
- async def receive_with_trace_stripping():
245
+ async def receive_with_trace_extraction():
160
246
  message = await receive()
161
247
  if message["type"] == "http.request":
162
248
  body = message.get("body", b"")
@@ -207,29 +293,14 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
207
293
  parent_span = (
208
294
  current_trace.parent_span
209
295
  )
296
+ logger.debug(
297
+ f"[TRACE] Extracted trace context from arguments: trace_id={arg_trace_id}"
298
+ )
210
299
  except Exception:
211
300
  pass
212
-
213
- # Strip trace context fields from arguments
214
- if (
215
- "_trace_id" in arguments
216
- or "_parent_span" in arguments
217
- ):
218
- arguments.pop("_trace_id", None)
219
- arguments.pop("_parent_span", None)
220
- modified_body = json_module.dumps(
221
- payload
222
- ).encode("utf-8")
223
- logger.debug(
224
- "[TRACE] Stripped trace fields from arguments"
225
- )
226
- return {
227
- **message,
228
- "body": modified_body,
229
- }
230
301
  except Exception as e:
231
302
  logger.debug(
232
- f"[TRACE] Failed to process body: {e}"
303
+ f"[TRACE] Failed to process body for extraction: {e}"
233
304
  )
234
305
  return message
235
306
 
@@ -249,7 +320,7 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
249
320
  await send(message)
250
321
 
251
322
  await self.app(
252
- scope, receive_with_trace_stripping, send_with_trace_headers
323
+ scope, receive_with_trace_extraction, send_with_trace_headers
253
324
  )
254
325
 
255
326
  app.add_middleware(TraceContextMiddleware)
@@ -306,6 +377,13 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
306
377
  # which is handled upstream in the @mesh.agent decorator
307
378
  port = http_port
308
379
 
380
+ # Handle http_port=0: find an available port BEFORE starting uvicorn
381
+ # This is more reliable than detecting the port after uvicorn starts
382
+ # and works on all platforms (Linux containers don't have lsof installed)
383
+ if port == 0:
384
+ port = _find_available_port()
385
+ logger.info(f"🎯 IMMEDIATE UVICORN: Auto-assigned port {port} for agent")
386
+
309
387
  logger.debug(
310
388
  f"🚀 IMMEDIATE UVICORN: Starting uvicorn server on {http_host}:{port}"
311
389
  )
@@ -370,53 +448,8 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
370
448
  # Give server a moment to start
371
449
  time.sleep(1)
372
450
 
373
- # Detect actual port if port=0 (auto-assign)
374
- actual_port = port
375
- if port == 0:
376
- import socket
377
-
378
- # Try to detect actual port by scanning for listening sockets
379
- try:
380
- import subprocess
381
-
382
- # Use lsof to find the port bound by this process
383
- result = subprocess.run(
384
- ["lsof", "-i", "-P", "-n", f"-p{os.getpid()}"],
385
- capture_output=True,
386
- text=True,
387
- timeout=5,
388
- )
389
- for line in result.stdout.split("\n"):
390
- if "LISTEN" in line and "python" in line.lower():
391
- # Parse port from line like "python 1234 user 5u IPv4 ... TCP *:54321 (LISTEN)"
392
- parts = line.split()
393
- for part in parts:
394
- if ":" in part and "(LISTEN)" not in part:
395
- try:
396
- port_str = part.split(":")[-1]
397
- detected_port = int(port_str)
398
- if detected_port > 0:
399
- actual_port = detected_port
400
- logger.info(
401
- f"🎯 IMMEDIATE UVICORN: Detected auto-assigned port {actual_port}"
402
- )
403
- # Update server_info with actual port
404
- server_info["port"] = actual_port
405
- server_info["requested_port"] = (
406
- 0 # Remember original request
407
- )
408
- break
409
- except (ValueError, IndexError):
410
- pass
411
- if actual_port > 0:
412
- break
413
- except Exception as e:
414
- logger.warning(
415
- f"⚠️ IMMEDIATE UVICORN: Could not detect auto-assigned port: {e}"
416
- )
417
-
418
451
  logger.debug(
419
- f"✅ IMMEDIATE UVICORN: Uvicorn server running on {http_host}:{actual_port} (daemon thread)"
452
+ f"✅ IMMEDIATE UVICORN: Uvicorn server running on {http_host}:{port} (daemon thread)"
420
453
  )
421
454
 
422
455
  # Set up registry context for shutdown cleanup (use defaults initially)
@@ -585,12 +618,25 @@ def tool(
585
618
  raise ValueError("dependency capability must be a string")
586
619
 
587
620
  # Validate optional dependency fields
621
+ # Tags can be strings or arrays of strings (OR alternatives)
622
+ # e.g., ["required", ["python", "typescript"]] = required AND (python OR typescript)
588
623
  dep_tags = dep.get("tags", [])
589
624
  if not isinstance(dep_tags, list):
590
625
  raise ValueError("dependency tags must be a list")
591
626
  for tag in dep_tags:
592
- if not isinstance(tag, str):
593
- raise ValueError("all dependency tags must be strings")
627
+ if isinstance(tag, str):
628
+ continue # Simple tag - OK
629
+ elif isinstance(tag, list):
630
+ # OR alternative - validate inner tags are all strings
631
+ for inner_tag in tag:
632
+ if not isinstance(inner_tag, str):
633
+ raise ValueError(
634
+ "OR alternative tags must be strings"
635
+ )
636
+ else:
637
+ raise ValueError(
638
+ "tags must be strings or arrays of strings (OR alternatives)"
639
+ )
594
640
 
595
641
  dep_version = dep.get("version")
596
642
  if dep_version is not None and not isinstance(dep_version, str):
@@ -708,7 +754,7 @@ def agent(
708
754
  http_port: int = 0,
709
755
  enable_http: bool = True,
710
756
  namespace: str = "default",
711
- health_interval: int = 5, # Will be overridden by centralized defaults
757
+ heartbeat_interval: int = 5,
712
758
  health_check: Callable[[], Awaitable[Any]] | None = None,
713
759
  health_check_ttl: int = 15,
714
760
  auto_run: bool = True, # Changed to True by default!
@@ -733,7 +779,7 @@ def agent(
733
779
  Environment variable: MCP_MESH_HTTP_ENABLED (takes precedence)
734
780
  namespace: Agent namespace (default: "default")
735
781
  Environment variable: MCP_MESH_NAMESPACE (takes precedence)
736
- health_interval: Health check interval in seconds (default: 30)
782
+ heartbeat_interval: Heartbeat interval in seconds (default: 5)
737
783
  Environment variable: MCP_MESH_HEALTH_INTERVAL (takes precedence)
738
784
  health_check: Optional async function that returns HealthStatus
739
785
  Called before heartbeat and on /health endpoint with TTL caching
@@ -750,7 +796,7 @@ def agent(
750
796
  MCP_MESH_HTTP_PORT: Override http_port parameter (integer, 0-65535)
751
797
  MCP_MESH_HTTP_ENABLED: Override enable_http parameter (boolean: true/false)
752
798
  MCP_MESH_NAMESPACE: Override namespace parameter (string)
753
- MCP_MESH_HEALTH_INTERVAL: Override health_interval parameter (integer, ≥1)
799
+ MCP_MESH_HEALTH_INTERVAL: Override heartbeat_interval parameter (integer, ≥1)
754
800
  MCP_MESH_AUTO_RUN: Override auto_run parameter (boolean: true/false)
755
801
  MCP_MESH_AUTO_RUN_INTERVAL: Override auto_run_interval parameter (integer, ≥1)
756
802
 
@@ -801,10 +847,10 @@ def agent(
801
847
  if not isinstance(namespace, str):
802
848
  raise ValueError("namespace must be a string")
803
849
 
804
- if not isinstance(health_interval, int):
805
- raise ValueError("health_interval must be an integer")
806
- if health_interval < 1:
807
- raise ValueError("health_interval must be at least 1 second")
850
+ if not isinstance(heartbeat_interval, int):
851
+ raise ValueError("heartbeat_interval must be an integer")
852
+ if heartbeat_interval < 1:
853
+ raise ValueError("heartbeat_interval must be at least 1 second")
808
854
 
809
855
  if not isinstance(auto_run, bool):
810
856
  raise ValueError("auto_run must be a boolean")
@@ -859,9 +905,9 @@ def agent(
859
905
  # Import centralized defaults
860
906
  from _mcp_mesh.shared.defaults import MeshDefaults
861
907
 
862
- final_health_interval = get_config_value(
908
+ final_heartbeat_interval = get_config_value(
863
909
  "MCP_MESH_HEALTH_INTERVAL",
864
- override=health_interval,
910
+ override=heartbeat_interval,
865
911
  default=MeshDefaults.HEALTH_INTERVAL,
866
912
  rule=ValidationRule.NONZERO_RULE,
867
913
  )
@@ -892,7 +938,7 @@ def agent(
892
938
  "http_port": final_http_port,
893
939
  "enable_http": final_enable_http,
894
940
  "namespace": final_namespace,
895
- "health_interval": final_health_interval,
941
+ "heartbeat_interval": final_heartbeat_interval,
896
942
  "health_check": health_check,
897
943
  "health_check_ttl": health_check_ttl,
898
944
  "auto_run": final_auto_run,
@@ -1063,12 +1109,25 @@ def route(
1063
1109
  raise ValueError("dependency capability must be a string")
1064
1110
 
1065
1111
  # Validate optional dependency fields
1112
+ # Tags can be strings or arrays of strings (OR alternatives)
1113
+ # e.g., ["required", ["python", "typescript"]] = required AND (python OR typescript)
1066
1114
  dep_tags = dep.get("tags", [])
1067
1115
  if not isinstance(dep_tags, list):
1068
1116
  raise ValueError("dependency tags must be a list")
1069
1117
  for tag in dep_tags:
1070
- if not isinstance(tag, str):
1071
- raise ValueError("all dependency tags must be strings")
1118
+ if isinstance(tag, str):
1119
+ continue # Simple tag - OK
1120
+ elif isinstance(tag, list):
1121
+ # OR alternative - validate inner tags are all strings
1122
+ for inner_tag in tag:
1123
+ if not isinstance(inner_tag, str):
1124
+ raise ValueError(
1125
+ "OR alternative tags must be strings"
1126
+ )
1127
+ else:
1128
+ raise ValueError(
1129
+ "tags must be strings or arrays of strings (OR alternatives)"
1130
+ )
1072
1131
 
1073
1132
  dep_version = dep.get("version")
1074
1133
  if dep_version is not None and not isinstance(dep_version, str):
@@ -1354,6 +1413,34 @@ def llm(
1354
1413
  rule=ValidationRule.STRING_RULE,
1355
1414
  )
1356
1415
 
1416
+ # Resolve model with env var override
1417
+ resolved_model = get_config_value(
1418
+ "MESH_LLM_MODEL",
1419
+ override=model,
1420
+ default=None,
1421
+ rule=ValidationRule.STRING_RULE,
1422
+ )
1423
+
1424
+ # Warn about missing configuration parameters
1425
+ if not system_prompt and not system_prompt_file:
1426
+ logger.warning(
1427
+ f"⚠️ @mesh.llm: No 'system_prompt' specified for function '{func.__name__}'. "
1428
+ f"Using default: 'You are a helpful assistant.' "
1429
+ f"Consider adding a custom system_prompt for better results."
1430
+ )
1431
+
1432
+ if isinstance(provider, str) and provider == "claude" and not resolved_model:
1433
+ logger.warning(
1434
+ f"⚠️ @mesh.llm: No 'model' specified for function '{func.__name__}'. "
1435
+ f"The LLM provider will use its default model. "
1436
+ f"Consider specifying a model explicitly (e.g., model='anthropic/claude-sonnet-4-5')."
1437
+ )
1438
+
1439
+ # Use default system prompt if not provided
1440
+ effective_system_prompt = (
1441
+ system_prompt if system_prompt else "You are a helpful assistant."
1442
+ )
1443
+
1357
1444
  resolved_config = {
1358
1445
  "filter": filter,
1359
1446
  "filter_mode": get_config_value(
@@ -1363,12 +1450,7 @@ def llm(
1363
1450
  rule=ValidationRule.STRING_RULE,
1364
1451
  ),
1365
1452
  "provider": resolved_provider,
1366
- "model": get_config_value(
1367
- "MESH_LLM_MODEL",
1368
- override=model,
1369
- default=None,
1370
- rule=ValidationRule.STRING_RULE,
1371
- ),
1453
+ "model": resolved_model,
1372
1454
  "api_key": api_key, # Will be resolved from provider-specific env vars later
1373
1455
  "max_iterations": get_config_value(
1374
1456
  "MESH_LLM_MAX_ITERATIONS",
@@ -1376,7 +1458,7 @@ def llm(
1376
1458
  default=10,
1377
1459
  rule=ValidationRule.NONZERO_RULE,
1378
1460
  ),
1379
- "system_prompt": system_prompt,
1461
+ "system_prompt": effective_system_prompt,
1380
1462
  "system_prompt_file": system_prompt_file,
1381
1463
  # Phase 1: Template metadata
1382
1464
  "is_template": is_template,
mesh/helpers.py CHANGED
@@ -6,7 +6,7 @@ mesh decorators to simplify common patterns like zero-code LLM providers.
6
6
  """
7
7
 
8
8
  import logging
9
- from typing import Any, Dict, List, Optional
9
+ from typing import Any, Optional
10
10
 
11
11
  from _mcp_mesh.shared.logging_config import format_log_value
12
12
 
@@ -214,10 +214,55 @@ def llm_provider(
214
214
  f"(requested by consumer)"
215
215
  )
216
216
 
217
+ # Get vendor handler once - used for both structured output and system prompt formatting
218
+ from _mcp_mesh.engine.provider_handlers import ProviderHandlerRegistry
219
+
220
+ handler = ProviderHandlerRegistry.get_handler(vendor)
221
+
222
+ # Issue #459: Handle output_schema for vendor-specific structured output
223
+ # Use provider handler pattern for vendor-specific behavior
224
+ output_schema = model_params_copy.pop("output_schema", None)
225
+ output_type_name = model_params_copy.pop("output_type_name", None)
226
+
227
+ if output_schema:
228
+ # Include messages so handler can modify system prompt (e.g., HINT mode injection)
229
+ model_params_copy["messages"] = request.messages
230
+ handler.apply_structured_output(
231
+ output_schema, output_type_name, model_params_copy
232
+ )
233
+ # Remove messages to avoid duplication in completion_args
234
+ model_params_copy.pop("messages", None)
235
+ logger.debug(
236
+ f"🎯 Applied {vendor} structured output via handler: "
237
+ f"{output_type_name}"
238
+ )
239
+
240
+ # Use vendor handler to format system prompt when tools are present
241
+ messages = request.messages
242
+ if request.tools:
243
+
244
+ # Find and format system message
245
+ formatted_messages = []
246
+ for msg in messages:
247
+ if msg.get("role") == "system":
248
+ # Format system prompt with vendor-specific instructions
249
+ base_prompt = msg.get("content", "")
250
+ formatted_content = handler.format_system_prompt(
251
+ base_prompt=base_prompt,
252
+ tool_schemas=request.tools,
253
+ output_type=str, # Provider returns raw string
254
+ )
255
+ formatted_messages.append(
256
+ {"role": "system", "content": formatted_content}
257
+ )
258
+ else:
259
+ formatted_messages.append(msg)
260
+ messages = formatted_messages
261
+
217
262
  # Build litellm.completion arguments
218
263
  completion_args: dict[str, Any] = {
219
264
  "model": effective_model,
220
- "messages": request.messages,
265
+ "messages": messages,
221
266
  **litellm_kwargs,
222
267
  }
223
268