massgen 0.0.3__py3-none-any.whl → 0.1.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.
Potentially problematic release.
This version of massgen might be problematic. Click here for more details.
- massgen/__init__.py +142 -8
- massgen/adapters/__init__.py +29 -0
- massgen/adapters/ag2_adapter.py +483 -0
- massgen/adapters/base.py +183 -0
- massgen/adapters/tests/__init__.py +0 -0
- massgen/adapters/tests/test_ag2_adapter.py +439 -0
- massgen/adapters/tests/test_agent_adapter.py +128 -0
- massgen/adapters/utils/__init__.py +2 -0
- massgen/adapters/utils/ag2_utils.py +236 -0
- massgen/adapters/utils/tests/__init__.py +0 -0
- massgen/adapters/utils/tests/test_ag2_utils.py +138 -0
- massgen/agent_config.py +329 -55
- massgen/api_params_handler/__init__.py +10 -0
- massgen/api_params_handler/_api_params_handler_base.py +99 -0
- massgen/api_params_handler/_chat_completions_api_params_handler.py +176 -0
- massgen/api_params_handler/_claude_api_params_handler.py +113 -0
- massgen/api_params_handler/_response_api_params_handler.py +130 -0
- massgen/backend/__init__.py +39 -4
- massgen/backend/azure_openai.py +385 -0
- massgen/backend/base.py +341 -69
- massgen/backend/base_with_mcp.py +1102 -0
- massgen/backend/capabilities.py +386 -0
- massgen/backend/chat_completions.py +577 -130
- massgen/backend/claude.py +1033 -537
- massgen/backend/claude_code.py +1203 -0
- massgen/backend/cli_base.py +209 -0
- massgen/backend/docs/BACKEND_ARCHITECTURE.md +126 -0
- massgen/backend/{CLAUDE_API_RESEARCH.md → docs/CLAUDE_API_RESEARCH.md} +18 -18
- massgen/backend/{GEMINI_API_DOCUMENTATION.md → docs/GEMINI_API_DOCUMENTATION.md} +9 -9
- massgen/backend/docs/Gemini MCP Integration Analysis.md +1050 -0
- massgen/backend/docs/MCP_IMPLEMENTATION_CLAUDE_BACKEND.md +177 -0
- massgen/backend/docs/MCP_INTEGRATION_RESPONSE_BACKEND.md +352 -0
- massgen/backend/docs/OPENAI_GPT5_MODELS.md +211 -0
- massgen/backend/{OPENAI_RESPONSES_API_FORMAT.md → docs/OPENAI_RESPONSE_API_TOOL_CALLS.md} +3 -3
- massgen/backend/docs/OPENAI_response_streaming.md +20654 -0
- massgen/backend/docs/inference_backend.md +257 -0
- massgen/backend/docs/permissions_and_context_files.md +1085 -0
- massgen/backend/external.py +126 -0
- massgen/backend/gemini.py +1850 -241
- massgen/backend/grok.py +40 -156
- massgen/backend/inference.py +156 -0
- massgen/backend/lmstudio.py +171 -0
- massgen/backend/response.py +1095 -322
- massgen/chat_agent.py +131 -113
- massgen/cli.py +1560 -275
- massgen/config_builder.py +2396 -0
- massgen/configs/BACKEND_CONFIGURATION.md +458 -0
- massgen/configs/README.md +559 -216
- massgen/configs/ag2/ag2_case_study.yaml +27 -0
- massgen/configs/ag2/ag2_coder.yaml +34 -0
- massgen/configs/ag2/ag2_coder_case_study.yaml +36 -0
- massgen/configs/ag2/ag2_gemini.yaml +27 -0
- massgen/configs/ag2/ag2_groupchat.yaml +108 -0
- massgen/configs/ag2/ag2_groupchat_gpt.yaml +118 -0
- massgen/configs/ag2/ag2_single_agent.yaml +21 -0
- massgen/configs/basic/multi/fast_timeout_example.yaml +37 -0
- massgen/configs/basic/multi/gemini_4o_claude.yaml +31 -0
- massgen/configs/basic/multi/gemini_gpt5nano_claude.yaml +36 -0
- massgen/configs/{gemini_4o_claude.yaml → basic/multi/geminicode_4o_claude.yaml} +3 -3
- massgen/configs/basic/multi/geminicode_gpt5nano_claude.yaml +36 -0
- massgen/configs/basic/multi/glm_gemini_claude.yaml +25 -0
- massgen/configs/basic/multi/gpt4o_audio_generation.yaml +30 -0
- massgen/configs/basic/multi/gpt4o_image_generation.yaml +31 -0
- massgen/configs/basic/multi/gpt5nano_glm_qwen.yaml +26 -0
- massgen/configs/basic/multi/gpt5nano_image_understanding.yaml +26 -0
- massgen/configs/{three_agents_default.yaml → basic/multi/three_agents_default.yaml} +8 -4
- massgen/configs/basic/multi/three_agents_opensource.yaml +27 -0
- massgen/configs/basic/multi/three_agents_vllm.yaml +20 -0
- massgen/configs/basic/multi/two_agents_gemini.yaml +19 -0
- massgen/configs/{two_agents.yaml → basic/multi/two_agents_gpt5.yaml} +14 -6
- massgen/configs/basic/multi/two_agents_opensource_lmstudio.yaml +31 -0
- massgen/configs/basic/multi/two_qwen_vllm_sglang.yaml +28 -0
- massgen/configs/{single_agent.yaml → basic/single/single_agent.yaml} +1 -1
- massgen/configs/{single_flash2.5.yaml → basic/single/single_flash2.5.yaml} +1 -2
- massgen/configs/basic/single/single_gemini2.5pro.yaml +16 -0
- massgen/configs/basic/single/single_gpt4o_audio_generation.yaml +22 -0
- massgen/configs/basic/single/single_gpt4o_image_generation.yaml +22 -0
- massgen/configs/basic/single/single_gpt4o_video_generation.yaml +24 -0
- massgen/configs/basic/single/single_gpt5nano.yaml +20 -0
- massgen/configs/basic/single/single_gpt5nano_file_search.yaml +18 -0
- massgen/configs/basic/single/single_gpt5nano_image_understanding.yaml +17 -0
- massgen/configs/basic/single/single_gptoss120b.yaml +15 -0
- massgen/configs/basic/single/single_openrouter_audio_understanding.yaml +15 -0
- massgen/configs/basic/single/single_qwen_video_understanding.yaml +15 -0
- massgen/configs/debug/code_execution/command_filtering_blacklist.yaml +29 -0
- massgen/configs/debug/code_execution/command_filtering_whitelist.yaml +28 -0
- massgen/configs/debug/code_execution/docker_verification.yaml +29 -0
- massgen/configs/debug/skip_coordination_test.yaml +27 -0
- massgen/configs/debug/test_sdk_migration.yaml +17 -0
- massgen/configs/docs/DISCORD_MCP_SETUP.md +208 -0
- massgen/configs/docs/TWITTER_MCP_ENESCINAR_SETUP.md +82 -0
- massgen/configs/providers/azure/azure_openai_multi.yaml +21 -0
- massgen/configs/providers/azure/azure_openai_single.yaml +19 -0
- massgen/configs/providers/claude/claude.yaml +14 -0
- massgen/configs/providers/gemini/gemini_gpt5nano.yaml +28 -0
- massgen/configs/providers/local/lmstudio.yaml +11 -0
- massgen/configs/providers/openai/gpt5.yaml +46 -0
- massgen/configs/providers/openai/gpt5_nano.yaml +46 -0
- massgen/configs/providers/others/grok_single_agent.yaml +19 -0
- massgen/configs/providers/others/zai_coding_team.yaml +108 -0
- massgen/configs/providers/others/zai_glm45.yaml +12 -0
- massgen/configs/{creative_team.yaml → teams/creative/creative_team.yaml} +16 -6
- massgen/configs/{travel_planning.yaml → teams/creative/travel_planning.yaml} +16 -6
- massgen/configs/{news_analysis.yaml → teams/research/news_analysis.yaml} +16 -6
- massgen/configs/{research_team.yaml → teams/research/research_team.yaml} +15 -7
- massgen/configs/{technical_analysis.yaml → teams/research/technical_analysis.yaml} +16 -6
- massgen/configs/tools/code-execution/basic_command_execution.yaml +25 -0
- massgen/configs/tools/code-execution/code_execution_use_case_simple.yaml +41 -0
- massgen/configs/tools/code-execution/docker_claude_code.yaml +32 -0
- massgen/configs/tools/code-execution/docker_multi_agent.yaml +32 -0
- massgen/configs/tools/code-execution/docker_simple.yaml +29 -0
- massgen/configs/tools/code-execution/docker_with_resource_limits.yaml +32 -0
- massgen/configs/tools/code-execution/multi_agent_playwright_automation.yaml +57 -0
- massgen/configs/tools/filesystem/cc_gpt5_gemini_filesystem.yaml +34 -0
- massgen/configs/tools/filesystem/claude_code_context_sharing.yaml +68 -0
- massgen/configs/tools/filesystem/claude_code_flash2.5.yaml +43 -0
- massgen/configs/tools/filesystem/claude_code_flash2.5_gptoss.yaml +49 -0
- massgen/configs/tools/filesystem/claude_code_gpt5nano.yaml +31 -0
- massgen/configs/tools/filesystem/claude_code_single.yaml +40 -0
- massgen/configs/tools/filesystem/fs_permissions_test.yaml +87 -0
- massgen/configs/tools/filesystem/gemini_gemini_workspace_cleanup.yaml +54 -0
- massgen/configs/tools/filesystem/gemini_gpt5_filesystem_casestudy.yaml +30 -0
- massgen/configs/tools/filesystem/gemini_gpt5nano_file_context_path.yaml +43 -0
- massgen/configs/tools/filesystem/gemini_gpt5nano_protected_paths.yaml +45 -0
- massgen/configs/tools/filesystem/gpt5mini_cc_fs_context_path.yaml +31 -0
- massgen/configs/tools/filesystem/grok4_gpt5_gemini_filesystem.yaml +32 -0
- massgen/configs/tools/filesystem/multiturn/grok4_gpt5_claude_code_filesystem_multiturn.yaml +58 -0
- massgen/configs/tools/filesystem/multiturn/grok4_gpt5_gemini_filesystem_multiturn.yaml +58 -0
- massgen/configs/tools/filesystem/multiturn/two_claude_code_filesystem_multiturn.yaml +47 -0
- massgen/configs/tools/filesystem/multiturn/two_gemini_flash_filesystem_multiturn.yaml +48 -0
- massgen/configs/tools/mcp/claude_code_discord_mcp_example.yaml +27 -0
- massgen/configs/tools/mcp/claude_code_simple_mcp.yaml +35 -0
- massgen/configs/tools/mcp/claude_code_twitter_mcp_example.yaml +32 -0
- massgen/configs/tools/mcp/claude_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/claude_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/five_agents_travel_mcp_test.yaml +157 -0
- massgen/configs/tools/mcp/five_agents_weather_mcp_test.yaml +103 -0
- massgen/configs/tools/mcp/gemini_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test.yaml +23 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test_sharing.yaml +23 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test_single_agent.yaml +17 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test_with_claude_code.yaml +24 -0
- massgen/configs/tools/mcp/gemini_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/gemini_notion_mcp.yaml +52 -0
- massgen/configs/tools/mcp/gpt5_nano_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/gpt5_nano_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/gpt5mini_claude_code_discord_mcp_example.yaml +38 -0
- massgen/configs/tools/mcp/gpt_oss_mcp_example.yaml +25 -0
- massgen/configs/tools/mcp/gpt_oss_mcp_test.yaml +28 -0
- massgen/configs/tools/mcp/grok3_mini_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/grok3_mini_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/multimcp_gemini.yaml +111 -0
- massgen/configs/tools/mcp/qwen_api_mcp_example.yaml +25 -0
- massgen/configs/tools/mcp/qwen_api_mcp_test.yaml +28 -0
- massgen/configs/tools/mcp/qwen_local_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/qwen_local_mcp_test.yaml +27 -0
- massgen/configs/tools/planning/five_agents_discord_mcp_planning_mode.yaml +140 -0
- massgen/configs/tools/planning/five_agents_filesystem_mcp_planning_mode.yaml +151 -0
- massgen/configs/tools/planning/five_agents_notion_mcp_planning_mode.yaml +151 -0
- massgen/configs/tools/planning/five_agents_twitter_mcp_planning_mode.yaml +155 -0
- massgen/configs/tools/planning/gpt5_mini_case_study_mcp_planning_mode.yaml +73 -0
- massgen/configs/tools/web-search/claude_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/gemini_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/gpt5_mini_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/gpt_oss_streamable_http_test.yaml +44 -0
- massgen/configs/tools/web-search/grok3_mini_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/qwen_api_streamable_http_test.yaml +44 -0
- massgen/configs/tools/web-search/qwen_local_streamable_http_test.yaml +43 -0
- massgen/coordination_tracker.py +708 -0
- massgen/docker/README.md +462 -0
- massgen/filesystem_manager/__init__.py +21 -0
- massgen/filesystem_manager/_base.py +9 -0
- massgen/filesystem_manager/_code_execution_server.py +545 -0
- massgen/filesystem_manager/_docker_manager.py +477 -0
- massgen/filesystem_manager/_file_operation_tracker.py +248 -0
- massgen/filesystem_manager/_filesystem_manager.py +813 -0
- massgen/filesystem_manager/_path_permission_manager.py +1261 -0
- massgen/filesystem_manager/_workspace_tools_server.py +1815 -0
- massgen/formatter/__init__.py +10 -0
- massgen/formatter/_chat_completions_formatter.py +284 -0
- massgen/formatter/_claude_formatter.py +235 -0
- massgen/formatter/_formatter_base.py +156 -0
- massgen/formatter/_response_formatter.py +263 -0
- massgen/frontend/__init__.py +1 -2
- massgen/frontend/coordination_ui.py +471 -286
- massgen/frontend/displays/base_display.py +56 -11
- massgen/frontend/displays/create_coordination_table.py +1956 -0
- massgen/frontend/displays/rich_terminal_display.py +1259 -619
- massgen/frontend/displays/simple_display.py +9 -4
- massgen/frontend/displays/terminal_display.py +27 -68
- massgen/logger_config.py +681 -0
- massgen/mcp_tools/README.md +232 -0
- massgen/mcp_tools/__init__.py +105 -0
- massgen/mcp_tools/backend_utils.py +1035 -0
- massgen/mcp_tools/circuit_breaker.py +195 -0
- massgen/mcp_tools/client.py +894 -0
- massgen/mcp_tools/config_validator.py +138 -0
- massgen/mcp_tools/docs/circuit_breaker.md +646 -0
- massgen/mcp_tools/docs/client.md +950 -0
- massgen/mcp_tools/docs/config_validator.md +478 -0
- massgen/mcp_tools/docs/exceptions.md +1165 -0
- massgen/mcp_tools/docs/security.md +854 -0
- massgen/mcp_tools/exceptions.py +338 -0
- massgen/mcp_tools/hooks.py +212 -0
- massgen/mcp_tools/security.py +780 -0
- massgen/message_templates.py +342 -64
- massgen/orchestrator.py +1515 -241
- massgen/stream_chunk/__init__.py +35 -0
- massgen/stream_chunk/base.py +92 -0
- massgen/stream_chunk/multimodal.py +237 -0
- massgen/stream_chunk/text.py +162 -0
- massgen/tests/mcp_test_server.py +150 -0
- massgen/tests/multi_turn_conversation_design.md +0 -8
- massgen/tests/test_azure_openai_backend.py +156 -0
- massgen/tests/test_backend_capabilities.py +262 -0
- massgen/tests/test_backend_event_loop_all.py +179 -0
- massgen/tests/test_chat_completions_refactor.py +142 -0
- massgen/tests/test_claude_backend.py +15 -28
- massgen/tests/test_claude_code.py +268 -0
- massgen/tests/test_claude_code_context_sharing.py +233 -0
- massgen/tests/test_claude_code_orchestrator.py +175 -0
- massgen/tests/test_cli_backends.py +180 -0
- massgen/tests/test_code_execution.py +679 -0
- massgen/tests/test_external_agent_backend.py +134 -0
- massgen/tests/test_final_presentation_fallback.py +237 -0
- massgen/tests/test_gemini_planning_mode.py +351 -0
- massgen/tests/test_grok_backend.py +7 -10
- massgen/tests/test_http_mcp_server.py +42 -0
- massgen/tests/test_integration_simple.py +198 -0
- massgen/tests/test_mcp_blocking.py +125 -0
- massgen/tests/test_message_context_building.py +29 -47
- massgen/tests/test_orchestrator_final_presentation.py +48 -0
- massgen/tests/test_path_permission_manager.py +2087 -0
- massgen/tests/test_rich_terminal_display.py +14 -13
- massgen/tests/test_timeout.py +133 -0
- massgen/tests/test_v3_3agents.py +11 -12
- massgen/tests/test_v3_simple.py +8 -13
- massgen/tests/test_v3_three_agents.py +11 -18
- massgen/tests/test_v3_two_agents.py +8 -13
- massgen/token_manager/__init__.py +7 -0
- massgen/token_manager/token_manager.py +400 -0
- massgen/utils.py +52 -16
- massgen/v1/agent.py +45 -91
- massgen/v1/agents.py +18 -53
- massgen/v1/backends/gemini.py +50 -153
- massgen/v1/backends/grok.py +21 -54
- massgen/v1/backends/oai.py +39 -111
- massgen/v1/cli.py +36 -93
- massgen/v1/config.py +8 -12
- massgen/v1/logging.py +43 -127
- massgen/v1/main.py +18 -32
- massgen/v1/orchestrator.py +68 -209
- massgen/v1/streaming_display.py +62 -163
- massgen/v1/tools.py +8 -12
- massgen/v1/types.py +9 -23
- massgen/v1/utils.py +5 -23
- massgen-0.1.0.dist-info/METADATA +1245 -0
- massgen-0.1.0.dist-info/RECORD +273 -0
- massgen-0.1.0.dist-info/entry_points.txt +2 -0
- massgen/frontend/logging/__init__.py +0 -9
- massgen/frontend/logging/realtime_logger.py +0 -197
- massgen-0.0.3.dist-info/METADATA +0 -568
- massgen-0.0.3.dist-info/RECORD +0 -76
- massgen-0.0.3.dist-info/entry_points.txt +0 -2
- /massgen/backend/{Function calling openai responses.md → docs/Function calling openai responses.md} +0 -0
- {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/WHEEL +0 -0
- {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/licenses/LICENSE +0 -0
- {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Backend utilities for MCP integration.
|
|
4
|
+
Contains all utilities that backends need for MCP functionality.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import random
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, AsyncGenerator, Awaitable, Callable, Literal
|
|
13
|
+
|
|
14
|
+
from ..logger_config import log_mcp_activity, logger
|
|
15
|
+
|
|
16
|
+
# Module-level constants
|
|
17
|
+
DEFAULT_MAX_RETRIES = 3
|
|
18
|
+
DEFAULT_RETRY_BASE_DELAY = 0.5
|
|
19
|
+
DEFAULT_RETRY_JITTER_MIN = 0.1
|
|
20
|
+
DEFAULT_RETRY_JITTER_MAX = 0.3
|
|
21
|
+
DEFAULT_MESSAGE_HISTORY_LIMIT = 200
|
|
22
|
+
DEFAULT_TIMEOUT_SECONDS = 30
|
|
23
|
+
DEFAULT_CIRCUIT_BREAKER_MAX_FAILURES = 3
|
|
24
|
+
DEFAULT_CIRCUIT_BREAKER_RESET_TIME = 30
|
|
25
|
+
DEFAULT_CIRCUIT_BREAKER_BACKOFF_MULTIPLIER = 2
|
|
26
|
+
DEFAULT_CIRCUIT_BREAKER_MAX_BACKOFF_MULTIPLIER = 8
|
|
27
|
+
|
|
28
|
+
# Import MCP exceptions
|
|
29
|
+
try:
|
|
30
|
+
from .circuit_breaker import CircuitBreakerConfig
|
|
31
|
+
from .client import MCPClient
|
|
32
|
+
from .exceptions import (
|
|
33
|
+
MCPAuthenticationError,
|
|
34
|
+
MCPConfigurationError,
|
|
35
|
+
MCPConnectionError,
|
|
36
|
+
MCPError,
|
|
37
|
+
MCPResourceError,
|
|
38
|
+
MCPServerError,
|
|
39
|
+
MCPTimeoutError,
|
|
40
|
+
MCPValidationError,
|
|
41
|
+
)
|
|
42
|
+
except ImportError:
|
|
43
|
+
MCPError = Exception
|
|
44
|
+
MCPConnectionError = ConnectionError
|
|
45
|
+
MCPTimeoutError = TimeoutError
|
|
46
|
+
MCPServerError = Exception
|
|
47
|
+
MCPValidationError = ValueError
|
|
48
|
+
MCPAuthenticationError = Exception
|
|
49
|
+
MCPResourceError = Exception
|
|
50
|
+
MCPConfigurationError = Exception
|
|
51
|
+
CircuitBreakerConfig = None
|
|
52
|
+
MCPClient = None
|
|
53
|
+
|
|
54
|
+
# Import hook system
|
|
55
|
+
try:
|
|
56
|
+
from .hooks import FunctionHook, HookType
|
|
57
|
+
except ImportError:
|
|
58
|
+
HookType = None
|
|
59
|
+
FunctionHook = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Function:
|
|
63
|
+
"""Enhanced function wrapper for MCP tools across all backend APIs."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
name: str,
|
|
68
|
+
description: str,
|
|
69
|
+
parameters: dict[str, Any],
|
|
70
|
+
entrypoint: Callable[[str], Awaitable[Any]],
|
|
71
|
+
hooks: dict | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
# Validate and sanitize inputs
|
|
74
|
+
self.name = name if name else "unknown_function"
|
|
75
|
+
self.description = description if description and isinstance(description, str) else f"Function: {self.name}"
|
|
76
|
+
self.parameters = parameters if parameters and isinstance(parameters, dict) else {"type": "object", "properties": {}}
|
|
77
|
+
self.entrypoint = entrypoint
|
|
78
|
+
self.hooks = hooks or ({hook_type: [] for hook_type in HookType} if HookType else {})
|
|
79
|
+
|
|
80
|
+
# Context for hook execution
|
|
81
|
+
self._backend_name = None
|
|
82
|
+
self._agent_id = None
|
|
83
|
+
|
|
84
|
+
async def call(self, input_str: str) -> Any:
|
|
85
|
+
"""Call the function with hook integration."""
|
|
86
|
+
# Fast path: no hooks registered
|
|
87
|
+
if not HookType or not self.hooks.get(HookType.PRE_CALL):
|
|
88
|
+
return await self.entrypoint(input_str)
|
|
89
|
+
|
|
90
|
+
# Build context for hooks
|
|
91
|
+
context = {"function_name": self.name, "timestamp": time.time(), "backend": self._backend_name or "unknown", "agent_id": self._agent_id}
|
|
92
|
+
|
|
93
|
+
# Execute PRE_CALL hooks
|
|
94
|
+
modified_args = input_str
|
|
95
|
+
for hook in self.hooks.get(HookType.PRE_CALL, []):
|
|
96
|
+
try:
|
|
97
|
+
hook_result = await hook.execute(function_name=self.name, arguments=modified_args, context=context)
|
|
98
|
+
|
|
99
|
+
# Check if hook blocks execution
|
|
100
|
+
if not hook_result.allowed:
|
|
101
|
+
# Return proper CallToolResult format matching permission_wrapper.py
|
|
102
|
+
reason = hook_result.metadata.get("reason", f"Hook '{hook.name}' blocked function call")
|
|
103
|
+
error_msg = f"Permission denied for tool '{self.name}': {reason}"
|
|
104
|
+
logger.warning(f"🚫 [Function] {error_msg}")
|
|
105
|
+
|
|
106
|
+
# Import MCP types for proper result formatting
|
|
107
|
+
try:
|
|
108
|
+
from mcp import types as mcp_types
|
|
109
|
+
|
|
110
|
+
# Return CallToolResult with error flag - same format as permission_wrapper.py
|
|
111
|
+
return mcp_types.CallToolResult(content=[mcp_types.TextContent(type="text", text=f"Error: {error_msg}")], isError=True)
|
|
112
|
+
except ImportError:
|
|
113
|
+
# Fallback if MCP types not available
|
|
114
|
+
logger.error("MCP types not available, returning string error")
|
|
115
|
+
return f"Error: {error_msg}"
|
|
116
|
+
|
|
117
|
+
# Check if hook modified arguments
|
|
118
|
+
if hook_result.modified_args is not None:
|
|
119
|
+
modified_args = hook_result.modified_args
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.error(f"Hook {hook.name} failed for {self.name}: {e}")
|
|
123
|
+
|
|
124
|
+
# Execute the actual function
|
|
125
|
+
return await self.entrypoint(modified_args)
|
|
126
|
+
|
|
127
|
+
def to_openai_format(self) -> dict[str, Any]:
|
|
128
|
+
"""Convert function to OpenAI Response API format."""
|
|
129
|
+
return {
|
|
130
|
+
"type": "function",
|
|
131
|
+
"name": self.name,
|
|
132
|
+
"description": self.description,
|
|
133
|
+
"parameters": self.parameters,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
def to_chat_completions_format(self) -> dict[str, Any]:
|
|
137
|
+
"""Convert to Chat Completions API format."""
|
|
138
|
+
return {
|
|
139
|
+
"type": "function",
|
|
140
|
+
"function": {
|
|
141
|
+
"name": self.name or "unknown_function",
|
|
142
|
+
"description": self.description or f"Function: {self.name}",
|
|
143
|
+
"parameters": self.parameters or {"type": "object", "properties": {}},
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def to_claude_format(self) -> dict[str, Any]:
|
|
148
|
+
"""Convert to Claude API format."""
|
|
149
|
+
return {
|
|
150
|
+
"name": self.name,
|
|
151
|
+
"description": self.description,
|
|
152
|
+
"input_schema": self.parameters,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
def __repr__(self) -> str:
|
|
156
|
+
"""String representation of Function."""
|
|
157
|
+
return f"Function(name='{self.name}', description='{self.description[:50]}...')"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class MCPErrorHandler:
|
|
161
|
+
"""Standardized MCP error handling utilities."""
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def get_error_details(error: Exception, context: str | None = None, *, log: bool = False) -> tuple[str, str, str]:
|
|
165
|
+
"""Return standardized MCP error info and optionally log.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Tuple of (log_type, user_message, error_category)
|
|
169
|
+
"""
|
|
170
|
+
if isinstance(error, MCPConnectionError):
|
|
171
|
+
details = ("connection error", "MCP connection failed", "connection")
|
|
172
|
+
elif isinstance(error, MCPTimeoutError):
|
|
173
|
+
details = ("timeout error", "MCP session timeout", "timeout")
|
|
174
|
+
elif isinstance(error, MCPServerError):
|
|
175
|
+
details = ("server error", "MCP server error", "server")
|
|
176
|
+
elif isinstance(error, MCPValidationError):
|
|
177
|
+
details = ("validation error", "MCP validation failed", "validation")
|
|
178
|
+
elif isinstance(error, MCPAuthenticationError):
|
|
179
|
+
details = ("authentication error", "MCP authentication failed", "auth")
|
|
180
|
+
elif isinstance(error, MCPResourceError):
|
|
181
|
+
details = ("resource error", "MCP resource unavailable", "resource")
|
|
182
|
+
elif isinstance(error, MCPError):
|
|
183
|
+
details = ("MCP error", "MCP error", "general")
|
|
184
|
+
else:
|
|
185
|
+
details = ("unexpected error", "MCP connection failed", "unknown")
|
|
186
|
+
|
|
187
|
+
if log:
|
|
188
|
+
log_type, user_message, error_category = details
|
|
189
|
+
logger.warning(f"MCP {log_type}: {error}", extra={"context": context or "none"})
|
|
190
|
+
|
|
191
|
+
return details
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def is_transient_error(error: Exception) -> bool:
|
|
195
|
+
"""Determine if an error is transient and should be retried."""
|
|
196
|
+
if isinstance(error, (MCPConnectionError, MCPTimeoutError)):
|
|
197
|
+
return True
|
|
198
|
+
elif isinstance(error, MCPServerError):
|
|
199
|
+
error_str = str(error).lower()
|
|
200
|
+
return any(
|
|
201
|
+
keyword in error_str
|
|
202
|
+
for keyword in [
|
|
203
|
+
"timeout",
|
|
204
|
+
"connection",
|
|
205
|
+
"network",
|
|
206
|
+
"temporary",
|
|
207
|
+
"unavailable",
|
|
208
|
+
"503",
|
|
209
|
+
"502",
|
|
210
|
+
"504",
|
|
211
|
+
"500",
|
|
212
|
+
"retry",
|
|
213
|
+
]
|
|
214
|
+
)
|
|
215
|
+
elif isinstance(error, (ConnectionError, TimeoutError, OSError)):
|
|
216
|
+
return True
|
|
217
|
+
elif isinstance(error, MCPResourceError):
|
|
218
|
+
return True
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def log_error(
|
|
223
|
+
error: Exception,
|
|
224
|
+
context: str,
|
|
225
|
+
level: str = "auto",
|
|
226
|
+
backend_name: str | None = None,
|
|
227
|
+
agent_id: str | None = None,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Log MCP error with appropriate level and context."""
|
|
230
|
+
log_type, user_message, error_category = MCPErrorHandler.get_error_details(error)
|
|
231
|
+
|
|
232
|
+
# Auto-determine level
|
|
233
|
+
if level == "auto":
|
|
234
|
+
level = "warning" if error_category in ["connection", "timeout", "resource"] else "error"
|
|
235
|
+
|
|
236
|
+
# Single log call with level suffix
|
|
237
|
+
log_message = f"MCP {log_type} during {context}: {error}"
|
|
238
|
+
log_mcp_activity(
|
|
239
|
+
backend_name,
|
|
240
|
+
f"error ({level})",
|
|
241
|
+
{"message": log_message},
|
|
242
|
+
agent_id=agent_id,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def get_retry_delay(attempt: int, base_delay: float = DEFAULT_RETRY_BASE_DELAY) -> float:
|
|
247
|
+
"""Calculate retry delay with exponential backoff and jitter."""
|
|
248
|
+
# Exponential backoff
|
|
249
|
+
backoff_delay = base_delay * (2**attempt)
|
|
250
|
+
|
|
251
|
+
# Add jitter
|
|
252
|
+
jitter = random.uniform(DEFAULT_RETRY_JITTER_MIN, DEFAULT_RETRY_JITTER_MAX) * backoff_delay
|
|
253
|
+
|
|
254
|
+
return backoff_delay + jitter
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def is_auth_or_resource_error(error: Exception) -> bool:
|
|
258
|
+
"""Check if error is authentication or resource related (non-retryable)."""
|
|
259
|
+
return isinstance(error, (MCPAuthenticationError, MCPResourceError))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class MCPRetryHandler:
|
|
263
|
+
"""Handles MCP retry logic with user feedback."""
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
async def handle_retry_error(
|
|
267
|
+
error: Exception,
|
|
268
|
+
retry_count: int,
|
|
269
|
+
max_retries: int,
|
|
270
|
+
stream_chunk_class,
|
|
271
|
+
backend_name: str | None = None,
|
|
272
|
+
agent_id: str | None = None,
|
|
273
|
+
) -> tuple[bool, AsyncGenerator]:
|
|
274
|
+
"""Handle MCP retry errors with specific messaging and fallback logic."""
|
|
275
|
+
log_type, user_message, _ = MCPErrorHandler.get_error_details(error)
|
|
276
|
+
|
|
277
|
+
# Log the retry attempt
|
|
278
|
+
log_mcp_activity(
|
|
279
|
+
backend_name,
|
|
280
|
+
f"{log_type} on retry",
|
|
281
|
+
{"attempt": retry_count, "error": str(error)},
|
|
282
|
+
agent_id=agent_id,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Check if we've exhausted retries
|
|
286
|
+
if retry_count >= max_retries:
|
|
287
|
+
|
|
288
|
+
async def error_chunks():
|
|
289
|
+
yield stream_chunk_class(
|
|
290
|
+
type="content",
|
|
291
|
+
content=f"\n⚠️ {user_message} after {max_retries} attempts; falling back to workflow tools\n",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
return False, error_chunks()
|
|
295
|
+
|
|
296
|
+
# Continue retrying
|
|
297
|
+
async def empty_chunks():
|
|
298
|
+
yield
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
return True, empty_chunks()
|
|
302
|
+
|
|
303
|
+
@staticmethod
|
|
304
|
+
async def handle_error_and_fallback(
|
|
305
|
+
error: Exception,
|
|
306
|
+
tool_call_count: int,
|
|
307
|
+
stream_chunk_class,
|
|
308
|
+
backend_name: str | None = None,
|
|
309
|
+
agent_id: str | None = None,
|
|
310
|
+
) -> AsyncGenerator:
|
|
311
|
+
"""Handle MCP errors with specific messaging and fallback to non-MCP tools."""
|
|
312
|
+
log_type, user_message, _ = MCPErrorHandler.get_error_details(error)
|
|
313
|
+
|
|
314
|
+
# Log with specific error type
|
|
315
|
+
log_mcp_activity(
|
|
316
|
+
backend_name,
|
|
317
|
+
"tool call failed",
|
|
318
|
+
{
|
|
319
|
+
"call_number": tool_call_count,
|
|
320
|
+
"error_type": log_type,
|
|
321
|
+
"error": str(error),
|
|
322
|
+
},
|
|
323
|
+
agent_id=agent_id,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Yield user-friendly error message
|
|
327
|
+
yield stream_chunk_class(
|
|
328
|
+
type="content",
|
|
329
|
+
content=f"\n⚠️ {user_message} ({error}); continuing without MCP tools\n",
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class MCPMessageManager:
|
|
334
|
+
"""Message history management utilities for MCP integration."""
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def trim_message_history(messages: list[dict[str, Any]], max_items: int = DEFAULT_MESSAGE_HISTORY_LIMIT) -> list[dict[str, Any]]:
|
|
338
|
+
"""Trim message history to prevent unbounded growth in MCP execution loop."""
|
|
339
|
+
if max_items <= 0 or len(messages) <= max_items:
|
|
340
|
+
return messages
|
|
341
|
+
|
|
342
|
+
preserved = []
|
|
343
|
+
remaining = messages
|
|
344
|
+
|
|
345
|
+
# Preserve system message if it's the first message
|
|
346
|
+
if messages and messages[0].get("role") == "system":
|
|
347
|
+
preserved = [messages[0]]
|
|
348
|
+
remaining = messages[1:]
|
|
349
|
+
|
|
350
|
+
# Keep the most recent items within the limit
|
|
351
|
+
allowed = max_items - len(preserved)
|
|
352
|
+
trimmed_tail = remaining[-allowed:] if allowed > 0 else []
|
|
353
|
+
|
|
354
|
+
result = preserved + trimmed_tail
|
|
355
|
+
|
|
356
|
+
if len(messages) > len(result):
|
|
357
|
+
logger.debug(
|
|
358
|
+
"MCP trimmed message history",
|
|
359
|
+
extra={
|
|
360
|
+
"original_count": len(messages),
|
|
361
|
+
"trimmed_count": len(result),
|
|
362
|
+
"limit": max_items,
|
|
363
|
+
},
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
return result
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class MCPConfigHelper:
|
|
370
|
+
"""MCP configuration management utilities."""
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def extract_tool_filtering_params(config: dict[str, Any]) -> tuple[list | None, list | None]:
|
|
374
|
+
"""Extract allowed_tools and exclude_tools from configuration."""
|
|
375
|
+
allowed_tools = config.get("allowed_tools")
|
|
376
|
+
exclude_tools = config.get("exclude_tools")
|
|
377
|
+
|
|
378
|
+
# Normalize to lists if provided
|
|
379
|
+
if allowed_tools is not None and not isinstance(allowed_tools, list):
|
|
380
|
+
if isinstance(allowed_tools, str):
|
|
381
|
+
allowed_tools = [allowed_tools]
|
|
382
|
+
else:
|
|
383
|
+
logger.warning(
|
|
384
|
+
"MCP invalid allowed_tools type",
|
|
385
|
+
extra={"type": type(allowed_tools).__name__, "action": "ignoring"},
|
|
386
|
+
)
|
|
387
|
+
allowed_tools = None
|
|
388
|
+
|
|
389
|
+
if exclude_tools is not None and not isinstance(exclude_tools, list):
|
|
390
|
+
if isinstance(exclude_tools, str):
|
|
391
|
+
exclude_tools = [exclude_tools]
|
|
392
|
+
else:
|
|
393
|
+
logger.warning(
|
|
394
|
+
"MCP invalid exclude_tools type",
|
|
395
|
+
extra={"type": type(exclude_tools).__name__, "action": "ignoring"},
|
|
396
|
+
)
|
|
397
|
+
exclude_tools = None
|
|
398
|
+
|
|
399
|
+
return allowed_tools, exclude_tools
|
|
400
|
+
|
|
401
|
+
@staticmethod
|
|
402
|
+
def build_circuit_breaker_config(
|
|
403
|
+
transport_type: str = "mcp_tools",
|
|
404
|
+
backend_name: str | None = None,
|
|
405
|
+
agent_id: str | None = None,
|
|
406
|
+
) -> Any | None:
|
|
407
|
+
"""Build circuit breaker configuration for transport type."""
|
|
408
|
+
if CircuitBreakerConfig is None:
|
|
409
|
+
log_mcp_activity(backend_name, "CircuitBreakerConfig unavailable", {}, agent_id=agent_id)
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
# Standard configuration for MCP tools (stdio/streamable-http)
|
|
414
|
+
config = CircuitBreakerConfig(
|
|
415
|
+
max_failures=DEFAULT_CIRCUIT_BREAKER_MAX_FAILURES,
|
|
416
|
+
reset_time_seconds=DEFAULT_CIRCUIT_BREAKER_RESET_TIME,
|
|
417
|
+
backoff_multiplier=DEFAULT_CIRCUIT_BREAKER_BACKOFF_MULTIPLIER,
|
|
418
|
+
max_backoff_multiplier=DEFAULT_CIRCUIT_BREAKER_MAX_BACKOFF_MULTIPLIER,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
log_mcp_activity(
|
|
422
|
+
backend_name,
|
|
423
|
+
"created circuit breaker config",
|
|
424
|
+
{"transport_type": transport_type},
|
|
425
|
+
agent_id=agent_id,
|
|
426
|
+
)
|
|
427
|
+
return config
|
|
428
|
+
except Exception as e:
|
|
429
|
+
log_mcp_activity(
|
|
430
|
+
backend_name,
|
|
431
|
+
"failed to create circuit breaker config",
|
|
432
|
+
{"error": str(e)},
|
|
433
|
+
agent_id=agent_id,
|
|
434
|
+
)
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class MCPCircuitBreakerManager:
|
|
439
|
+
"""Circuit breaker management utilities for MCP integration."""
|
|
440
|
+
|
|
441
|
+
@staticmethod
|
|
442
|
+
def apply_circuit_breaker_filtering(
|
|
443
|
+
servers: list[dict[str, Any]],
|
|
444
|
+
circuit_breaker,
|
|
445
|
+
backend_name: str | None = None,
|
|
446
|
+
agent_id: str | None = None,
|
|
447
|
+
) -> list[dict[str, Any]]:
|
|
448
|
+
"""Apply circuit breaker filtering to servers.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
servers: List of server configurations
|
|
452
|
+
circuit_breaker: Circuit breaker instance
|
|
453
|
+
backend_name: Optional backend name for logging context
|
|
454
|
+
agent_id: Optional agent ID for logging context
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
List of servers that pass circuit breaker filtering
|
|
458
|
+
"""
|
|
459
|
+
if not circuit_breaker:
|
|
460
|
+
return servers
|
|
461
|
+
|
|
462
|
+
filtered_servers = []
|
|
463
|
+
for server in servers:
|
|
464
|
+
server_name = server.get("name", "unnamed")
|
|
465
|
+
if not circuit_breaker.should_skip_server(server_name, agent_id=agent_id):
|
|
466
|
+
filtered_servers.append(server)
|
|
467
|
+
else:
|
|
468
|
+
log_mcp_activity(
|
|
469
|
+
backend_name,
|
|
470
|
+
"circuit breaker skipping server",
|
|
471
|
+
{"server_name": server_name, "reason": "circuit_open"},
|
|
472
|
+
agent_id=agent_id,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
return filtered_servers
|
|
476
|
+
|
|
477
|
+
@staticmethod
|
|
478
|
+
async def record_event(
|
|
479
|
+
servers: list[dict[str, Any]],
|
|
480
|
+
circuit_breaker,
|
|
481
|
+
event: Literal["success", "failure"],
|
|
482
|
+
error_message: str | None = None,
|
|
483
|
+
backend_name: str | None = None,
|
|
484
|
+
agent_id: str | None = None,
|
|
485
|
+
) -> None:
|
|
486
|
+
"""Record circuit breaker events for servers.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
servers: List of server configurations
|
|
490
|
+
event: Event type ("success" or "failure")
|
|
491
|
+
circuit_breaker: Circuit breaker instance
|
|
492
|
+
error_message: Optional error message for failure events
|
|
493
|
+
backend_name: Optional backend name for logging context
|
|
494
|
+
agent_id: Optional agent ID for logging context
|
|
495
|
+
"""
|
|
496
|
+
if not circuit_breaker:
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
count = 0
|
|
500
|
+
for server in servers:
|
|
501
|
+
server_name = server.get("name", "unnamed")
|
|
502
|
+
try:
|
|
503
|
+
if event == "success":
|
|
504
|
+
circuit_breaker.record_success(server_name, agent_id=agent_id)
|
|
505
|
+
else:
|
|
506
|
+
circuit_breaker.record_failure(server_name, agent_id=agent_id)
|
|
507
|
+
count += 1
|
|
508
|
+
except Exception as cb_error:
|
|
509
|
+
log_mcp_activity(
|
|
510
|
+
backend_name,
|
|
511
|
+
"circuit breaker record failed",
|
|
512
|
+
{
|
|
513
|
+
"event": event,
|
|
514
|
+
"server_name": server_name,
|
|
515
|
+
"error": str(cb_error),
|
|
516
|
+
},
|
|
517
|
+
agent_id=agent_id,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
if count > 0:
|
|
521
|
+
if event == "success":
|
|
522
|
+
log_mcp_activity(
|
|
523
|
+
backend_name,
|
|
524
|
+
"circuit breaker recorded success",
|
|
525
|
+
{"server_count": count},
|
|
526
|
+
agent_id=agent_id,
|
|
527
|
+
)
|
|
528
|
+
else:
|
|
529
|
+
log_mcp_activity(
|
|
530
|
+
backend_name,
|
|
531
|
+
"circuit breaker recorded failure",
|
|
532
|
+
{"server_count": count, "error": error_message},
|
|
533
|
+
agent_id=agent_id,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class MCPResourceManager:
|
|
538
|
+
"""Resource management utilities for MCP integration."""
|
|
539
|
+
|
|
540
|
+
@staticmethod
|
|
541
|
+
async def setup_mcp_client(
|
|
542
|
+
servers: list[dict[str, Any]],
|
|
543
|
+
allowed_tools: list[str] | None,
|
|
544
|
+
exclude_tools: list[str] | None,
|
|
545
|
+
circuit_breaker=None,
|
|
546
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
547
|
+
backend_name: str | None = None,
|
|
548
|
+
agent_id: str | None = None,
|
|
549
|
+
) -> Any | None:
|
|
550
|
+
"""Setup MCP client for stdio/streamable-http servers with circuit breaker protection.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
servers: List of server configurations
|
|
554
|
+
allowed_tools: Optional list of allowed tool names
|
|
555
|
+
exclude_tools: Optional list of excluded tool names
|
|
556
|
+
circuit_breaker: Optional circuit breaker for failure tracking
|
|
557
|
+
timeout_seconds: Connection timeout in seconds
|
|
558
|
+
backend_name: Optional backend name for logging context
|
|
559
|
+
agent_id: Optional agent ID for logging context
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
Connected MCPClient or None if setup failed
|
|
563
|
+
"""
|
|
564
|
+
if MCPClient is None:
|
|
565
|
+
log_mcp_activity(
|
|
566
|
+
backend_name,
|
|
567
|
+
"MCPClient unavailable",
|
|
568
|
+
{"functionality": "disabled"},
|
|
569
|
+
agent_id=agent_id,
|
|
570
|
+
)
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
# Normalize and filter servers
|
|
574
|
+
normalized_servers = MCPSetupManager.normalize_mcp_servers(servers, backend_name, agent_id)
|
|
575
|
+
stdio_streamable_servers = MCPSetupManager.separate_stdio_streamable_servers(normalized_servers, backend_name, agent_id)
|
|
576
|
+
|
|
577
|
+
if not stdio_streamable_servers:
|
|
578
|
+
log_mcp_activity(
|
|
579
|
+
backend_name,
|
|
580
|
+
"no stdio/streamable-http servers configured",
|
|
581
|
+
{},
|
|
582
|
+
agent_id=agent_id,
|
|
583
|
+
)
|
|
584
|
+
return None
|
|
585
|
+
|
|
586
|
+
# Apply circuit breaker filtering if available
|
|
587
|
+
if circuit_breaker:
|
|
588
|
+
filtered_servers = MCPCircuitBreakerManager.apply_circuit_breaker_filtering(stdio_streamable_servers, circuit_breaker, backend_name, agent_id)
|
|
589
|
+
else:
|
|
590
|
+
filtered_servers = stdio_streamable_servers
|
|
591
|
+
|
|
592
|
+
if not filtered_servers:
|
|
593
|
+
log_mcp_activity(
|
|
594
|
+
backend_name,
|
|
595
|
+
"all servers filtered by circuit breaker",
|
|
596
|
+
{"transport_types": ["stdio", "streamable-http"]},
|
|
597
|
+
agent_id=agent_id,
|
|
598
|
+
)
|
|
599
|
+
return None
|
|
600
|
+
|
|
601
|
+
# Retry logic with exponential backoff
|
|
602
|
+
max_retries = DEFAULT_MAX_RETRIES
|
|
603
|
+
for retry in range(max_retries):
|
|
604
|
+
try:
|
|
605
|
+
if retry > 0:
|
|
606
|
+
delay = MCPErrorHandler.get_retry_delay(retry - 1)
|
|
607
|
+
log_mcp_activity(
|
|
608
|
+
backend_name,
|
|
609
|
+
"connection retry",
|
|
610
|
+
{
|
|
611
|
+
"attempt": retry,
|
|
612
|
+
"max_retries": max_retries - 1,
|
|
613
|
+
"delay_seconds": delay,
|
|
614
|
+
},
|
|
615
|
+
agent_id=agent_id,
|
|
616
|
+
)
|
|
617
|
+
await asyncio.sleep(delay)
|
|
618
|
+
|
|
619
|
+
client = await MCPClient.create_and_connect(
|
|
620
|
+
filtered_servers,
|
|
621
|
+
timeout_seconds=timeout_seconds,
|
|
622
|
+
allowed_tools=allowed_tools,
|
|
623
|
+
exclude_tools=exclude_tools,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
# Record success in circuit breaker
|
|
627
|
+
if circuit_breaker:
|
|
628
|
+
await MCPCircuitBreakerManager.record_event(
|
|
629
|
+
filtered_servers,
|
|
630
|
+
circuit_breaker,
|
|
631
|
+
"success",
|
|
632
|
+
backend_name=backend_name,
|
|
633
|
+
agent_id=agent_id,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
log_mcp_activity(
|
|
637
|
+
backend_name,
|
|
638
|
+
"connection successful",
|
|
639
|
+
{"attempt": retry + 1},
|
|
640
|
+
agent_id=agent_id,
|
|
641
|
+
)
|
|
642
|
+
return client
|
|
643
|
+
|
|
644
|
+
except (MCPConnectionError, MCPTimeoutError, MCPServerError) as e:
|
|
645
|
+
if retry < max_retries - 1: # Not last attempt
|
|
646
|
+
MCPErrorHandler.log_error(e, f"MCP connection attempt {retry + 1}")
|
|
647
|
+
continue
|
|
648
|
+
|
|
649
|
+
# Record failure and re-raise
|
|
650
|
+
if circuit_breaker:
|
|
651
|
+
await MCPCircuitBreakerManager.record_event(
|
|
652
|
+
filtered_servers,
|
|
653
|
+
circuit_breaker,
|
|
654
|
+
"failure",
|
|
655
|
+
str(e),
|
|
656
|
+
backend_name,
|
|
657
|
+
agent_id,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
log_mcp_activity(
|
|
661
|
+
backend_name,
|
|
662
|
+
"connection failed after retries",
|
|
663
|
+
{"max_retries": max_retries, "error": str(e)},
|
|
664
|
+
agent_id=agent_id,
|
|
665
|
+
)
|
|
666
|
+
return None
|
|
667
|
+
except Exception as e:
|
|
668
|
+
MCPErrorHandler.log_error(
|
|
669
|
+
e,
|
|
670
|
+
f"Unexpected error during MCP connection attempt {retry + 1}",
|
|
671
|
+
"error",
|
|
672
|
+
)
|
|
673
|
+
if retry < max_retries - 1:
|
|
674
|
+
continue
|
|
675
|
+
return None
|
|
676
|
+
|
|
677
|
+
return None
|
|
678
|
+
|
|
679
|
+
@staticmethod
|
|
680
|
+
def convert_tools_to_functions(mcp_client, backend_name: str | None = None, agent_id: str | None = None, hook_manager=None) -> dict[str, Function]:
|
|
681
|
+
"""Convert MCP tools to Function objects with hook support.
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
mcp_client: Connected MCPClient instance
|
|
685
|
+
backend_name: Optional backend name for logging context
|
|
686
|
+
agent_id: Optional agent ID for logging context
|
|
687
|
+
hook_manager: Optional hook manager for function hooks
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
Dictionary mapping tool names to Function objects
|
|
691
|
+
"""
|
|
692
|
+
if not mcp_client or not hasattr(mcp_client, "tools"):
|
|
693
|
+
return {}
|
|
694
|
+
|
|
695
|
+
functions = {}
|
|
696
|
+
hook_mgr = hook_manager # No fallback to global - each agent must provide its own
|
|
697
|
+
|
|
698
|
+
for tool_name, tool in mcp_client.tools.items():
|
|
699
|
+
try:
|
|
700
|
+
# Fix closure bug by using default parameter to capture tool_name
|
|
701
|
+
def create_tool_entrypoint(captured_tool_name: str = tool_name):
|
|
702
|
+
async def tool_entrypoint(input_str: str) -> Any:
|
|
703
|
+
try:
|
|
704
|
+
arguments = json.loads(input_str)
|
|
705
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
706
|
+
log_mcp_activity(
|
|
707
|
+
backend_name,
|
|
708
|
+
"invalid JSON arguments for tool",
|
|
709
|
+
{"tool_name": captured_tool_name, "error": str(e)},
|
|
710
|
+
agent_id=agent_id,
|
|
711
|
+
)
|
|
712
|
+
raise MCPValidationError(
|
|
713
|
+
f"Invalid JSON arguments for tool {captured_tool_name}: {e}",
|
|
714
|
+
field="arguments",
|
|
715
|
+
value=input_str,
|
|
716
|
+
)
|
|
717
|
+
return await mcp_client.call_tool(captured_tool_name, arguments)
|
|
718
|
+
|
|
719
|
+
return tool_entrypoint
|
|
720
|
+
|
|
721
|
+
entrypoint = create_tool_entrypoint()
|
|
722
|
+
|
|
723
|
+
# Validate and sanitize tool description
|
|
724
|
+
description = tool.description
|
|
725
|
+
if description is None or not isinstance(description, str):
|
|
726
|
+
description = f"MCP tool: {tool_name}"
|
|
727
|
+
log_mcp_activity(
|
|
728
|
+
backend_name,
|
|
729
|
+
"tool description sanitized",
|
|
730
|
+
{"tool_name": tool_name, "original": tool.description},
|
|
731
|
+
agent_id=agent_id,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
# Validate and sanitize tool parameters
|
|
735
|
+
parameters = tool.inputSchema
|
|
736
|
+
if parameters is None or not isinstance(parameters, dict):
|
|
737
|
+
parameters = {"type": "object", "properties": {}}
|
|
738
|
+
log_mcp_activity(
|
|
739
|
+
backend_name,
|
|
740
|
+
"tool parameters sanitized",
|
|
741
|
+
{"tool_name": tool_name, "original": tool.inputSchema},
|
|
742
|
+
agent_id=agent_id,
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
# Get hooks for this function
|
|
746
|
+
function_hooks = hook_mgr.get_hooks_for_function(tool_name) if hook_mgr else {}
|
|
747
|
+
|
|
748
|
+
function = Function(
|
|
749
|
+
name=tool_name,
|
|
750
|
+
description=description,
|
|
751
|
+
parameters=parameters,
|
|
752
|
+
entrypoint=entrypoint,
|
|
753
|
+
hooks=function_hooks,
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
# Set backend context
|
|
757
|
+
function._backend_name = backend_name
|
|
758
|
+
function._agent_id = agent_id
|
|
759
|
+
|
|
760
|
+
functions[function.name] = function
|
|
761
|
+
|
|
762
|
+
except Exception as e:
|
|
763
|
+
log_mcp_activity(
|
|
764
|
+
backend_name,
|
|
765
|
+
"failed to register tool",
|
|
766
|
+
{"tool_name": tool_name, "error": str(e)},
|
|
767
|
+
agent_id=agent_id,
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
log_mcp_activity(
|
|
771
|
+
backend_name,
|
|
772
|
+
"registered tools as Function objects",
|
|
773
|
+
{"tool_count": len(functions)},
|
|
774
|
+
agent_id=agent_id,
|
|
775
|
+
)
|
|
776
|
+
return functions
|
|
777
|
+
|
|
778
|
+
@staticmethod
|
|
779
|
+
async def cleanup_mcp_client(client, backend_name: str | None = None, agent_id: str | None = None) -> None:
|
|
780
|
+
"""Clean up MCP client connections.
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
client: MCPClient instance to clean up
|
|
784
|
+
backend_name: Optional backend name for logging context
|
|
785
|
+
agent_id: Optional agent ID for logging context
|
|
786
|
+
"""
|
|
787
|
+
if client:
|
|
788
|
+
try:
|
|
789
|
+
await client.disconnect()
|
|
790
|
+
log_mcp_activity(backend_name, "client cleanup completed", {}, agent_id=agent_id)
|
|
791
|
+
except Exception as e:
|
|
792
|
+
log_mcp_activity(
|
|
793
|
+
backend_name,
|
|
794
|
+
"error during client cleanup",
|
|
795
|
+
{"error": str(e)},
|
|
796
|
+
agent_id=agent_id,
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
@staticmethod
|
|
800
|
+
async def setup_mcp_context_manager(
|
|
801
|
+
backend_instance,
|
|
802
|
+
backend_name: str | None = None,
|
|
803
|
+
agent_id: str | None = None,
|
|
804
|
+
):
|
|
805
|
+
"""Setup MCP tools if configured during context manager entry."""
|
|
806
|
+
if hasattr(backend_instance, "mcp_servers") and backend_instance.mcp_servers and not backend_instance._mcp_initialized:
|
|
807
|
+
try:
|
|
808
|
+
await backend_instance._setup_mcp_tools()
|
|
809
|
+
except Exception as e:
|
|
810
|
+
log_mcp_activity(
|
|
811
|
+
backend_name,
|
|
812
|
+
"setup failed during context entry",
|
|
813
|
+
{"error": str(e)},
|
|
814
|
+
agent_id=agent_id,
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
return backend_instance
|
|
818
|
+
|
|
819
|
+
@staticmethod
|
|
820
|
+
async def cleanup_mcp_context_manager(
|
|
821
|
+
backend_instance,
|
|
822
|
+
logger_instance=None,
|
|
823
|
+
backend_name: str | None = None,
|
|
824
|
+
agent_id: str | None = None,
|
|
825
|
+
) -> None:
|
|
826
|
+
"""Clean up MCP resources during context manager exit."""
|
|
827
|
+
log = logger_instance or logger
|
|
828
|
+
|
|
829
|
+
try:
|
|
830
|
+
if hasattr(backend_instance, "cleanup_mcp"):
|
|
831
|
+
await backend_instance.cleanup_mcp()
|
|
832
|
+
except Exception as e:
|
|
833
|
+
log.error(f"Error during MCP cleanup for backend '{backend_name}': {e}")
|
|
834
|
+
log_mcp_activity(
|
|
835
|
+
backend_name,
|
|
836
|
+
"error during cleanup",
|
|
837
|
+
{"error": str(e)},
|
|
838
|
+
agent_id=agent_id,
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
class MCPSetupManager:
|
|
843
|
+
"""MCP setup and initialization utilities."""
|
|
844
|
+
|
|
845
|
+
@staticmethod
|
|
846
|
+
def normalize_mcp_servers(servers: Any, backend_name: str | None = None, agent_id: str | None = None) -> list[dict[str, Any]]:
|
|
847
|
+
"""Validate and normalize mcp_servers into a list of dicts.
|
|
848
|
+
|
|
849
|
+
Args:
|
|
850
|
+
servers: MCP servers configuration (list, dict, or None)
|
|
851
|
+
backend_name: Optional backend name for logging context
|
|
852
|
+
agent_id: Optional agent ID for logging context
|
|
853
|
+
|
|
854
|
+
Returns:
|
|
855
|
+
Normalized list of server dictionaries
|
|
856
|
+
"""
|
|
857
|
+
if not servers:
|
|
858
|
+
return []
|
|
859
|
+
|
|
860
|
+
# Support both list and dict formats
|
|
861
|
+
if isinstance(servers, dict):
|
|
862
|
+
if "type" in servers:
|
|
863
|
+
servers = [servers]
|
|
864
|
+
else:
|
|
865
|
+
converted = []
|
|
866
|
+
for name, server_config in servers.items():
|
|
867
|
+
if isinstance(server_config, dict):
|
|
868
|
+
server = server_config.copy()
|
|
869
|
+
server["name"] = name
|
|
870
|
+
converted.append(server)
|
|
871
|
+
servers = converted
|
|
872
|
+
|
|
873
|
+
if not isinstance(servers, list):
|
|
874
|
+
log_mcp_activity(
|
|
875
|
+
backend_name,
|
|
876
|
+
"invalid mcp_servers type",
|
|
877
|
+
{"type": type(servers).__name__, "expected": "list or dict"},
|
|
878
|
+
agent_id=agent_id,
|
|
879
|
+
)
|
|
880
|
+
return []
|
|
881
|
+
|
|
882
|
+
normalized = []
|
|
883
|
+
for i, server in enumerate(servers):
|
|
884
|
+
if not isinstance(server, dict):
|
|
885
|
+
log_mcp_activity(
|
|
886
|
+
backend_name,
|
|
887
|
+
"skipping invalid server",
|
|
888
|
+
{"index": i, "server": str(server)},
|
|
889
|
+
agent_id=agent_id,
|
|
890
|
+
)
|
|
891
|
+
continue
|
|
892
|
+
|
|
893
|
+
if "type" not in server:
|
|
894
|
+
log_mcp_activity(
|
|
895
|
+
backend_name,
|
|
896
|
+
"server missing type field",
|
|
897
|
+
{"index": i},
|
|
898
|
+
agent_id=agent_id,
|
|
899
|
+
)
|
|
900
|
+
continue
|
|
901
|
+
|
|
902
|
+
# Add default name if missing
|
|
903
|
+
if "name" not in server:
|
|
904
|
+
server = server.copy()
|
|
905
|
+
server["name"] = f"server_{i}"
|
|
906
|
+
|
|
907
|
+
normalized.append(server)
|
|
908
|
+
|
|
909
|
+
return normalized
|
|
910
|
+
|
|
911
|
+
@staticmethod
|
|
912
|
+
def separate_stdio_streamable_servers(
|
|
913
|
+
servers: list[dict[str, Any]],
|
|
914
|
+
backend_name: str | None = None,
|
|
915
|
+
agent_id: str | None = None,
|
|
916
|
+
) -> list[dict[str, Any]]:
|
|
917
|
+
"""Extract only stdio and streamable-http servers.
|
|
918
|
+
|
|
919
|
+
Args:
|
|
920
|
+
servers: List of server configurations
|
|
921
|
+
backend_name: Optional backend name for logging context
|
|
922
|
+
agent_id: Optional agent ID for logging context
|
|
923
|
+
|
|
924
|
+
Returns:
|
|
925
|
+
List containing only stdio and streamable-http servers
|
|
926
|
+
"""
|
|
927
|
+
stdio_streamable = []
|
|
928
|
+
|
|
929
|
+
for server in servers:
|
|
930
|
+
transport_type = server.get("type", "").lower()
|
|
931
|
+
if transport_type in ["stdio", "streamable-http"]:
|
|
932
|
+
stdio_streamable.append(server)
|
|
933
|
+
|
|
934
|
+
return stdio_streamable
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
class MCPExecutionManager:
|
|
938
|
+
"""MCP function execution utilities with retry logic."""
|
|
939
|
+
|
|
940
|
+
@staticmethod
|
|
941
|
+
async def execute_function_with_retry(
|
|
942
|
+
function_name: str,
|
|
943
|
+
args: dict[str, Any],
|
|
944
|
+
functions: dict[str, Function],
|
|
945
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
946
|
+
stats_callback: Callable | None = None,
|
|
947
|
+
circuit_breaker_callback: Callable | None = None,
|
|
948
|
+
logger_instance=None,
|
|
949
|
+
) -> Any:
|
|
950
|
+
"""Execute MCP function with exponential backoff retry logic.
|
|
951
|
+
|
|
952
|
+
Args:
|
|
953
|
+
function_name: Name of the MCP function to call
|
|
954
|
+
args: Function arguments as dictionary
|
|
955
|
+
functions: Dictionary of available Function objects
|
|
956
|
+
max_retries: Maximum number of retry attempts
|
|
957
|
+
stats_callback: Optional callback for tracking stats (call_count, failures)
|
|
958
|
+
circuit_breaker_callback: Optional callback for circuit breaker events
|
|
959
|
+
logger_instance: Logger instance to use (defaults to module logger)
|
|
960
|
+
|
|
961
|
+
Returns:
|
|
962
|
+
Function result or structured error payload if all retries fail
|
|
963
|
+
"""
|
|
964
|
+
log = logger_instance or logger
|
|
965
|
+
|
|
966
|
+
# Track call attempt
|
|
967
|
+
if stats_callback:
|
|
968
|
+
call_index = await stats_callback("increment_calls")
|
|
969
|
+
else:
|
|
970
|
+
call_index = 1
|
|
971
|
+
|
|
972
|
+
for attempt in range(max_retries + 1):
|
|
973
|
+
try:
|
|
974
|
+
# Convert args to JSON string for the function call
|
|
975
|
+
arguments_json = json.dumps(args)
|
|
976
|
+
|
|
977
|
+
# Execute the MCP function
|
|
978
|
+
result = await functions[function_name].call(arguments_json)
|
|
979
|
+
|
|
980
|
+
# Successful execution
|
|
981
|
+
if attempt > 0:
|
|
982
|
+
log.info(
|
|
983
|
+
"MCP function succeeded on retry",
|
|
984
|
+
extra={
|
|
985
|
+
"function_name": function_name,
|
|
986
|
+
"call_index": call_index,
|
|
987
|
+
"retry_attempt": attempt,
|
|
988
|
+
},
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
return result
|
|
992
|
+
|
|
993
|
+
except Exception as e:
|
|
994
|
+
# Check if this is a non-retryable error
|
|
995
|
+
if MCPErrorHandler.is_auth_or_resource_error(e):
|
|
996
|
+
MCPErrorHandler.log_error(e, f"function call {function_name}")
|
|
997
|
+
if circuit_breaker_callback:
|
|
998
|
+
await circuit_breaker_callback("failure", str(e))
|
|
999
|
+
if stats_callback:
|
|
1000
|
+
await stats_callback("increment_failures")
|
|
1001
|
+
return {
|
|
1002
|
+
"error": str(e),
|
|
1003
|
+
"type": "auth_resource_error",
|
|
1004
|
+
"function": function_name,
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
is_last_attempt = attempt == max_retries
|
|
1008
|
+
|
|
1009
|
+
if MCPErrorHandler.is_transient_error(e) and not is_last_attempt:
|
|
1010
|
+
# Calculate exponential backoff with jitter
|
|
1011
|
+
delay = MCPErrorHandler.get_retry_delay(attempt)
|
|
1012
|
+
|
|
1013
|
+
MCPErrorHandler.log_error(e, f"function call {function_name} (attempt {attempt + 1})")
|
|
1014
|
+
log.info("MCP retrying function call", extra={"delay_seconds": delay})
|
|
1015
|
+
|
|
1016
|
+
await asyncio.sleep(delay)
|
|
1017
|
+
continue
|
|
1018
|
+
else:
|
|
1019
|
+
# Final failure
|
|
1020
|
+
MCPErrorHandler.log_error(e, f"function call {function_name} (final)")
|
|
1021
|
+
if circuit_breaker_callback:
|
|
1022
|
+
await circuit_breaker_callback("failure", str(e))
|
|
1023
|
+
if stats_callback:
|
|
1024
|
+
await stats_callback("increment_failures")
|
|
1025
|
+
|
|
1026
|
+
return {
|
|
1027
|
+
"error": str(e),
|
|
1028
|
+
"type": "execution_error",
|
|
1029
|
+
"function": function_name,
|
|
1030
|
+
}
|
|
1031
|
+
return {
|
|
1032
|
+
"error": "Max retries exceeded",
|
|
1033
|
+
"type": "retry_exhausted",
|
|
1034
|
+
"function": function_name,
|
|
1035
|
+
}
|