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,894 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
MCP client implementation for connecting to MCP servers. This module provides enhanced MCP client
|
|
4
|
+
functionality to connect with MCP servers and integrate external tools into the MassGen workflow.
|
|
5
|
+
"""
|
|
6
|
+
import asyncio
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
|
|
12
|
+
|
|
13
|
+
from mcp import ClientSession, StdioServerParameters
|
|
14
|
+
from mcp import types as mcp_types
|
|
15
|
+
from mcp.client.stdio import get_default_environment, stdio_client
|
|
16
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
17
|
+
|
|
18
|
+
from ..logger_config import logger
|
|
19
|
+
from .circuit_breaker import MCPCircuitBreaker
|
|
20
|
+
from .config_validator import MCPConfigValidator
|
|
21
|
+
from .exceptions import (
|
|
22
|
+
MCPConnectionError,
|
|
23
|
+
MCPError,
|
|
24
|
+
MCPServerError,
|
|
25
|
+
MCPTimeoutError,
|
|
26
|
+
MCPValidationError,
|
|
27
|
+
)
|
|
28
|
+
from .security import (
|
|
29
|
+
prepare_command,
|
|
30
|
+
sanitize_tool_name,
|
|
31
|
+
substitute_env_variables,
|
|
32
|
+
validate_tool_arguments,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConnectionState(Enum):
|
|
37
|
+
"""Connection state for MCP clients."""
|
|
38
|
+
|
|
39
|
+
DISCONNECTED = "disconnected"
|
|
40
|
+
CONNECTING = "connecting"
|
|
41
|
+
CONNECTED = "connected"
|
|
42
|
+
DISCONNECTING = "disconnecting"
|
|
43
|
+
FAILED = "failed"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Hook types reference: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-python#hook-types
|
|
47
|
+
class HookType(Enum):
|
|
48
|
+
"""Available hook types for MCP tool execution."""
|
|
49
|
+
|
|
50
|
+
PRE_TOOL_USE = "PreToolUse"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _ensure_timedelta(value: Union[int, float, timedelta], default_seconds: float) -> timedelta:
|
|
54
|
+
"""
|
|
55
|
+
Ensure a value is converted to timedelta for consistent timeout handling.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
MCPValidationError: If value is invalid
|
|
59
|
+
"""
|
|
60
|
+
if isinstance(value, timedelta):
|
|
61
|
+
if value.total_seconds() <= 0:
|
|
62
|
+
raise MCPValidationError(
|
|
63
|
+
f"Timeout must be positive, got {value.total_seconds()} seconds",
|
|
64
|
+
field="timeout",
|
|
65
|
+
value=value.total_seconds(),
|
|
66
|
+
)
|
|
67
|
+
return value
|
|
68
|
+
elif isinstance(value, (int, float)):
|
|
69
|
+
if value <= 0:
|
|
70
|
+
raise MCPValidationError(
|
|
71
|
+
f"Timeout must be positive, got {value} seconds",
|
|
72
|
+
field="timeout",
|
|
73
|
+
value=value,
|
|
74
|
+
)
|
|
75
|
+
return timedelta(seconds=value)
|
|
76
|
+
else:
|
|
77
|
+
logger.warning(f"Invalid timeout value {value}, using default {default_seconds}s")
|
|
78
|
+
return timedelta(seconds=default_seconds)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class _ServerClient:
|
|
83
|
+
"""Internal container for per-server state."""
|
|
84
|
+
|
|
85
|
+
session: Optional[ClientSession] = None
|
|
86
|
+
manager_task: Optional[asyncio.Task] = None
|
|
87
|
+
connected_event: asyncio.Event = None
|
|
88
|
+
disconnect_event: asyncio.Event = None
|
|
89
|
+
connection_lock: asyncio.Lock = None
|
|
90
|
+
connection_state: ConnectionState = ConnectionState.DISCONNECTED
|
|
91
|
+
initialized: bool = False
|
|
92
|
+
|
|
93
|
+
def __post_init__(self):
|
|
94
|
+
if self.connected_event is None:
|
|
95
|
+
self.connected_event = asyncio.Event()
|
|
96
|
+
if self.disconnect_event is None:
|
|
97
|
+
self.disconnect_event = asyncio.Event()
|
|
98
|
+
if self.connection_lock is None:
|
|
99
|
+
self.connection_lock = asyncio.Lock()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class MCPClient:
|
|
103
|
+
"""
|
|
104
|
+
Unified MCP client for communicating with single or multiple MCP servers.
|
|
105
|
+
Provides improved security, error handling, and async context management.
|
|
106
|
+
|
|
107
|
+
Accepts a list of server configurations and automatically handles:
|
|
108
|
+
- Consistent tool naming: Always uses prefixed names (mcp__server__tool)
|
|
109
|
+
- Circuit breaker protection for all servers
|
|
110
|
+
- Parallel connection for multi-server scenarios
|
|
111
|
+
- Sequential connection for single-server scenarios
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
server_configs: List[Dict[str, Any]],
|
|
117
|
+
*,
|
|
118
|
+
timeout_seconds: int = 30,
|
|
119
|
+
allowed_tools: Optional[List[str]] = None,
|
|
120
|
+
exclude_tools: Optional[List[str]] = None,
|
|
121
|
+
status_callback: Optional[Callable[[str, Dict[str, Any]], Awaitable[None]]] = None,
|
|
122
|
+
hooks: Optional[Dict[HookType, List[Callable[[str, Dict[str, Any]], Awaitable[bool]]]]] = None,
|
|
123
|
+
):
|
|
124
|
+
"""
|
|
125
|
+
Initialize MCP client.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
server_configs: List of server configuration dicts (always a list, even for single server)
|
|
129
|
+
timeout_seconds: Timeout for operations in seconds
|
|
130
|
+
allowed_tools: Optional list of tool names to include (if None, includes all)
|
|
131
|
+
exclude_tools: Optional list of tool names to exclude (if None, excludes none)
|
|
132
|
+
status_callback: Optional async callback for status updates
|
|
133
|
+
hooks: Optional dict mapping hook types to lists of hook functions
|
|
134
|
+
"""
|
|
135
|
+
# Validate all server configs
|
|
136
|
+
self._server_configs = [MCPConfigValidator.validate_server_config(config) for config in server_configs]
|
|
137
|
+
|
|
138
|
+
# Set name to first server's name for backward compatibility
|
|
139
|
+
self.name = self._server_configs[0]["name"]
|
|
140
|
+
|
|
141
|
+
self.timeout_seconds = timeout_seconds
|
|
142
|
+
self.allowed_tools = allowed_tools
|
|
143
|
+
self.exclude_tools = exclude_tools
|
|
144
|
+
self.status_callback = status_callback
|
|
145
|
+
self.hooks = hooks or {}
|
|
146
|
+
|
|
147
|
+
# Initialize circuit breaker for ALL scenarios
|
|
148
|
+
self._circuit_breaker = MCPCircuitBreaker()
|
|
149
|
+
|
|
150
|
+
# Per-server tracking
|
|
151
|
+
self._server_clients: Dict[str, _ServerClient] = {}
|
|
152
|
+
for config in self._server_configs:
|
|
153
|
+
self._server_clients[config["name"]] = _ServerClient()
|
|
154
|
+
|
|
155
|
+
# Unified registry for tools
|
|
156
|
+
self.tools: Dict[str, mcp_types.Tool] = {}
|
|
157
|
+
self._tool_to_server: Dict[str, str] = {}
|
|
158
|
+
|
|
159
|
+
# Connection management
|
|
160
|
+
self._initialized = False
|
|
161
|
+
self._cleanup_done = False
|
|
162
|
+
self._cleanup_lock = asyncio.Lock()
|
|
163
|
+
self._context_managed = False
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def session(self) -> Optional[ClientSession]:
|
|
167
|
+
"""Return first server's session for backward compatibility."""
|
|
168
|
+
if self._server_configs:
|
|
169
|
+
first_server_name = self._server_configs[0]["name"]
|
|
170
|
+
server_client = self._server_clients.get(first_server_name)
|
|
171
|
+
if server_client:
|
|
172
|
+
return server_client.session
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def _get_server_session(self, server_name: str) -> ClientSession:
|
|
176
|
+
"""Get session for server, raising error if not connected."""
|
|
177
|
+
server_client = self._server_clients.get(server_name)
|
|
178
|
+
if not server_client or not server_client.session:
|
|
179
|
+
raise MCPConnectionError(
|
|
180
|
+
f"Server '{server_name}' not connected",
|
|
181
|
+
server_name=server_name,
|
|
182
|
+
)
|
|
183
|
+
return server_client.session
|
|
184
|
+
|
|
185
|
+
async def connect(self) -> None:
|
|
186
|
+
"""Connect to MCP server(s) and discover capabilities with circuit breaker integration."""
|
|
187
|
+
if self._initialized:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
logger.info(f"Connecting to {len(self._server_configs)} MCP server(s)...")
|
|
191
|
+
|
|
192
|
+
# Send connecting status if callback is available
|
|
193
|
+
if self.status_callback:
|
|
194
|
+
await self.status_callback(
|
|
195
|
+
"connecting",
|
|
196
|
+
{
|
|
197
|
+
"message": f"Connecting to {len(self._server_configs)} MCP server(s)",
|
|
198
|
+
"server_count": len(self._server_configs),
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if len(self._server_configs) > 1:
|
|
203
|
+
# Multi-server: connect in parallel
|
|
204
|
+
await self._connect_all_parallel()
|
|
205
|
+
else:
|
|
206
|
+
# Single-server: connect sequentially
|
|
207
|
+
await self._connect_single()
|
|
208
|
+
|
|
209
|
+
# Only mark as initialized if at least one server connected successfully
|
|
210
|
+
self._initialized = any(sc.initialized for sc in self._server_clients.values())
|
|
211
|
+
|
|
212
|
+
# Count successful and failed connections
|
|
213
|
+
successful_count = len([sc for sc in self._server_clients.values() if sc.initialized])
|
|
214
|
+
failed_count = len(self._server_configs) - successful_count
|
|
215
|
+
|
|
216
|
+
# Send connection summary status if callback is available
|
|
217
|
+
if self.status_callback:
|
|
218
|
+
await self.status_callback(
|
|
219
|
+
"connection_summary",
|
|
220
|
+
{
|
|
221
|
+
"message": f"Connected to {successful_count}/{len(self._server_configs)} server(s)" + (f" ({failed_count} failed)" if failed_count > 0 else ""),
|
|
222
|
+
"successful_count": successful_count,
|
|
223
|
+
"failed_count": failed_count,
|
|
224
|
+
"total_count": len(self._server_configs),
|
|
225
|
+
"tools_count": len(self.tools),
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async def _connect_server(self, server_name: str, config: Dict[str, Any]) -> bool:
|
|
230
|
+
"""Connect to a single server with circuit breaker integration.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
True on success, False on failure
|
|
234
|
+
"""
|
|
235
|
+
server_client = self._server_clients[server_name]
|
|
236
|
+
|
|
237
|
+
async with server_client.connection_lock:
|
|
238
|
+
# Check circuit breaker
|
|
239
|
+
if self._circuit_breaker.should_skip_server(server_name):
|
|
240
|
+
logger.warning(f"Skipping server {server_name} due to circuit breaker")
|
|
241
|
+
server_client.connection_state = ConnectionState.FAILED
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
server_client.connection_state = ConnectionState.CONNECTING
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
# Start background manager task
|
|
248
|
+
server_client.manager_task = asyncio.create_task(
|
|
249
|
+
self._run_manager(server_name, config),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Wait for connection
|
|
253
|
+
await asyncio.wait_for(server_client.connected_event.wait(), timeout=30.0)
|
|
254
|
+
|
|
255
|
+
if not server_client.initialized or server_client.connection_state != ConnectionState.CONNECTED:
|
|
256
|
+
raise MCPConnectionError(f"Failed to connect to {server_name}")
|
|
257
|
+
|
|
258
|
+
# Record success
|
|
259
|
+
self._circuit_breaker.record_success(server_name)
|
|
260
|
+
logger.info(f"✅ MCP server '{server_name}' connected successfully!")
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
except Exception as e:
|
|
264
|
+
self._circuit_breaker.record_failure(server_name)
|
|
265
|
+
server_client.connection_state = ConnectionState.FAILED
|
|
266
|
+
logger.error(f"Failed to connect to {server_name}: {e}")
|
|
267
|
+
|
|
268
|
+
# Cleanup manager task to prevent resource leak
|
|
269
|
+
if server_client.manager_task and not server_client.manager_task.done():
|
|
270
|
+
server_client.disconnect_event.set()
|
|
271
|
+
try:
|
|
272
|
+
await asyncio.wait_for(server_client.manager_task, timeout=5.0)
|
|
273
|
+
except asyncio.TimeoutError:
|
|
274
|
+
logger.warning(f"Manager task for {server_name} didn't shutdown gracefully, cancelling")
|
|
275
|
+
server_client.manager_task.cancel()
|
|
276
|
+
try:
|
|
277
|
+
await server_client.manager_task
|
|
278
|
+
except asyncio.CancelledError:
|
|
279
|
+
pass
|
|
280
|
+
except Exception as cleanup_error:
|
|
281
|
+
logger.error(f"Error cleaning up manager task for {server_name}: {cleanup_error}")
|
|
282
|
+
finally:
|
|
283
|
+
server_client.manager_task = None
|
|
284
|
+
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
async def _connect_single(self) -> None:
|
|
288
|
+
"""Connect to single server."""
|
|
289
|
+
config = self._server_configs[0]
|
|
290
|
+
server_name = config["name"]
|
|
291
|
+
|
|
292
|
+
success = await self._connect_server(server_name, config)
|
|
293
|
+
if not success:
|
|
294
|
+
raise MCPConnectionError(f"Failed to connect to {server_name}")
|
|
295
|
+
|
|
296
|
+
async def _connect_all_parallel(self) -> None:
|
|
297
|
+
"""Connect to all servers in parallel."""
|
|
298
|
+
tasks = [self._connect_server(c["name"], c) for c in self._server_configs]
|
|
299
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
300
|
+
|
|
301
|
+
# Log results
|
|
302
|
+
successful = sum(1 for r in results if r is True)
|
|
303
|
+
logger.info(f"Connected to {successful}/{len(self._server_configs)} servers")
|
|
304
|
+
|
|
305
|
+
def _create_transport_context(self, config: Dict[str, Any]):
|
|
306
|
+
"""Create the appropriate transport context manager based on config."""
|
|
307
|
+
transport_type = config.get("type", "stdio")
|
|
308
|
+
server_name = config["name"]
|
|
309
|
+
|
|
310
|
+
if transport_type == "stdio":
|
|
311
|
+
command = config.get("command", [])
|
|
312
|
+
args = config.get("args", [])
|
|
313
|
+
|
|
314
|
+
logger.debug(f"Setting up stdio transport for {server_name}: command={command}, args={args}")
|
|
315
|
+
|
|
316
|
+
# Handle command preparation
|
|
317
|
+
if isinstance(command, str):
|
|
318
|
+
full_command = prepare_command(command)
|
|
319
|
+
if args:
|
|
320
|
+
full_command.extend(args)
|
|
321
|
+
elif isinstance(command, list):
|
|
322
|
+
full_command = command + (args or [])
|
|
323
|
+
else:
|
|
324
|
+
full_command = args or []
|
|
325
|
+
|
|
326
|
+
if not full_command:
|
|
327
|
+
raise MCPConnectionError(f"No command specified for stdio transport in {server_name}")
|
|
328
|
+
|
|
329
|
+
# Merge provided env with system env
|
|
330
|
+
env = config.get("env", {})
|
|
331
|
+
if env:
|
|
332
|
+
env = {**get_default_environment(), **env}
|
|
333
|
+
else:
|
|
334
|
+
env = get_default_environment()
|
|
335
|
+
|
|
336
|
+
# Perform environment variable substitution for args
|
|
337
|
+
substituted_args = []
|
|
338
|
+
for arg in full_command[1:] if len(full_command) > 1 else []:
|
|
339
|
+
if isinstance(arg, str):
|
|
340
|
+
try:
|
|
341
|
+
substituted_args.append(substitute_env_variables(arg))
|
|
342
|
+
except ValueError as e:
|
|
343
|
+
raise MCPConnectionError(f"Environment variable substitution failed in args: {e}", server_name=server_name) from e
|
|
344
|
+
else:
|
|
345
|
+
substituted_args.append(arg)
|
|
346
|
+
|
|
347
|
+
# Perform environment variable substitution for env dict
|
|
348
|
+
for key, value in list(env.items()):
|
|
349
|
+
if isinstance(value, str):
|
|
350
|
+
try:
|
|
351
|
+
env[key] = substitute_env_variables(value)
|
|
352
|
+
except ValueError as e:
|
|
353
|
+
raise MCPConnectionError(f"Environment variable substitution failed for {key}: {e}", server_name=server_name) from e
|
|
354
|
+
|
|
355
|
+
# Extract cwd if provided in config
|
|
356
|
+
cwd = config.get("cwd")
|
|
357
|
+
|
|
358
|
+
server_params = StdioServerParameters(
|
|
359
|
+
command=full_command[0],
|
|
360
|
+
args=substituted_args,
|
|
361
|
+
env=env,
|
|
362
|
+
cwd=cwd,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Open errlog file to redirect MCP server stderr output
|
|
366
|
+
from ..logger_config import get_log_session_dir
|
|
367
|
+
|
|
368
|
+
log_dir = get_log_session_dir()
|
|
369
|
+
errlog_path = log_dir / f"mcp_{server_name}_stderr.log"
|
|
370
|
+
errlog_file = open(errlog_path, "w", encoding="utf-8")
|
|
371
|
+
|
|
372
|
+
# Store errlog file handle for cleanup
|
|
373
|
+
if not hasattr(self, "_errlog_files"):
|
|
374
|
+
self._errlog_files = {}
|
|
375
|
+
self._errlog_files[server_name] = errlog_file
|
|
376
|
+
|
|
377
|
+
return stdio_client(server_params, errlog=errlog_file)
|
|
378
|
+
|
|
379
|
+
elif transport_type == "streamable-http":
|
|
380
|
+
url = config["url"]
|
|
381
|
+
headers = config.get("headers", {})
|
|
382
|
+
|
|
383
|
+
# Perform environment variable substitution for headers
|
|
384
|
+
substituted_headers = {}
|
|
385
|
+
for key, value in headers.items():
|
|
386
|
+
if isinstance(value, str):
|
|
387
|
+
try:
|
|
388
|
+
substituted_headers[key] = substitute_env_variables(value)
|
|
389
|
+
except ValueError as e:
|
|
390
|
+
raise MCPConnectionError(f"Environment variable substitution failed in header {key}: {e}", server_name=server_name) from e
|
|
391
|
+
else:
|
|
392
|
+
substituted_headers[key] = value
|
|
393
|
+
|
|
394
|
+
timeout_raw = config.get("timeout", self.timeout_seconds)
|
|
395
|
+
http_read_timeout_raw = config.get("http_read_timeout", 60 * 5)
|
|
396
|
+
|
|
397
|
+
timeout = _ensure_timedelta(timeout_raw, self.timeout_seconds)
|
|
398
|
+
http_read_timeout = _ensure_timedelta(http_read_timeout_raw, 60 * 5)
|
|
399
|
+
|
|
400
|
+
return streamablehttp_client(
|
|
401
|
+
url=url,
|
|
402
|
+
headers=substituted_headers,
|
|
403
|
+
timeout=timeout,
|
|
404
|
+
sse_read_timeout=http_read_timeout,
|
|
405
|
+
)
|
|
406
|
+
else:
|
|
407
|
+
raise MCPConnectionError(f"Unsupported transport type: {transport_type}")
|
|
408
|
+
|
|
409
|
+
async def _run_manager(self, server_name: str, config: Dict[str, Any]) -> None:
|
|
410
|
+
"""Background task that owns the transport and session contexts for a server."""
|
|
411
|
+
server_client = self._server_clients[server_name]
|
|
412
|
+
connection_successful = False
|
|
413
|
+
|
|
414
|
+
try:
|
|
415
|
+
transport_ctx = self._create_transport_context(config)
|
|
416
|
+
|
|
417
|
+
async with transport_ctx as session_params:
|
|
418
|
+
read, write = session_params[0:2]
|
|
419
|
+
|
|
420
|
+
session_timeout_timedelta = _ensure_timedelta(self.timeout_seconds, 30.0)
|
|
421
|
+
|
|
422
|
+
async with ClientSession(read, write, read_timeout_seconds=session_timeout_timedelta) as session:
|
|
423
|
+
# Initialize and expose session
|
|
424
|
+
server_client.session = session
|
|
425
|
+
await session.initialize()
|
|
426
|
+
await self._discover_capabilities(server_name, config)
|
|
427
|
+
server_client.initialized = True
|
|
428
|
+
server_client.connection_state = ConnectionState.CONNECTED
|
|
429
|
+
connection_successful = True
|
|
430
|
+
server_client.connected_event.set()
|
|
431
|
+
|
|
432
|
+
logger.info(f"✅ MCP server '{server_name}' connected successfully!")
|
|
433
|
+
|
|
434
|
+
# Send connected status if callback is available
|
|
435
|
+
if self.status_callback:
|
|
436
|
+
await self.status_callback(
|
|
437
|
+
"connected",
|
|
438
|
+
{
|
|
439
|
+
"server": server_name,
|
|
440
|
+
"message": f"Server '{server_name}' ready",
|
|
441
|
+
},
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# Wait until disconnect is requested
|
|
445
|
+
await server_client.disconnect_event.wait()
|
|
446
|
+
|
|
447
|
+
except Exception as e:
|
|
448
|
+
logger.error(f"MCP manager error for {server_name}: {e}", exc_info=True)
|
|
449
|
+
|
|
450
|
+
if self.status_callback:
|
|
451
|
+
await self.status_callback(
|
|
452
|
+
"error",
|
|
453
|
+
{
|
|
454
|
+
"server": server_name,
|
|
455
|
+
"message": f"Failed to connect to MCP server '{server_name}': {e}",
|
|
456
|
+
"error": str(e),
|
|
457
|
+
},
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
if not server_client.connected_event.is_set():
|
|
461
|
+
server_client.connected_event.set()
|
|
462
|
+
finally:
|
|
463
|
+
# Clear session state
|
|
464
|
+
server_client.initialized = False
|
|
465
|
+
server_client.session = None
|
|
466
|
+
if not connection_successful:
|
|
467
|
+
server_client.connection_state = ConnectionState.FAILED
|
|
468
|
+
if not server_client.connected_event.is_set():
|
|
469
|
+
server_client.connected_event.set()
|
|
470
|
+
else:
|
|
471
|
+
server_client.connection_state = ConnectionState.DISCONNECTED
|
|
472
|
+
|
|
473
|
+
async def _discover_capabilities(self, server_name: str, config: Dict[str, Any]) -> None:
|
|
474
|
+
"""Discover server capabilities (tools, resources, prompts) with name prefixing for multi-server."""
|
|
475
|
+
logger.debug(f"Discovering capabilities for {server_name}")
|
|
476
|
+
|
|
477
|
+
session = self._get_server_session(server_name)
|
|
478
|
+
|
|
479
|
+
try:
|
|
480
|
+
# Combine backend-level and per-server tool filtering
|
|
481
|
+
server_exclude = config.get("exclude_tools", [])
|
|
482
|
+
combined_exclude = list(set((self.exclude_tools or []) + server_exclude))
|
|
483
|
+
|
|
484
|
+
server_allowed = config.get("allowed_tools")
|
|
485
|
+
combined_allowed = server_allowed if server_allowed is not None else self.allowed_tools
|
|
486
|
+
|
|
487
|
+
# List tools
|
|
488
|
+
available_tools = await session.list_tools()
|
|
489
|
+
tools_list = getattr(available_tools, "tools", []) if available_tools else []
|
|
490
|
+
|
|
491
|
+
for tool in tools_list:
|
|
492
|
+
if combined_exclude and tool.name in combined_exclude:
|
|
493
|
+
continue
|
|
494
|
+
if combined_allowed is None or tool.name in combined_allowed:
|
|
495
|
+
# Always apply name prefixing for consistency
|
|
496
|
+
prefixed_name = sanitize_tool_name(tool.name, server_name)
|
|
497
|
+
|
|
498
|
+
self.tools[prefixed_name] = tool
|
|
499
|
+
self._tool_to_server[prefixed_name] = server_name
|
|
500
|
+
|
|
501
|
+
logger.info(f"Discovered capabilities for {server_name}: " f"{len([t for t, s in self._tool_to_server.items() if s == server_name])} tools")
|
|
502
|
+
|
|
503
|
+
except Exception as e:
|
|
504
|
+
logger.error(f"Failed to discover server capabilities for {server_name}: {e}", exc_info=True)
|
|
505
|
+
raise MCPConnectionError(f"Failed to discover server capabilities: {e}") from e
|
|
506
|
+
|
|
507
|
+
async def disconnect(self) -> None:
|
|
508
|
+
"""Disconnect from all MCP servers."""
|
|
509
|
+
if not self._initialized:
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
# Disconnect all servers (works for single or multiple)
|
|
513
|
+
tasks = [self._disconnect_one(name, client) for name, client in self._server_clients.items() if client.connection_state != ConnectionState.DISCONNECTED]
|
|
514
|
+
|
|
515
|
+
if tasks:
|
|
516
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
517
|
+
|
|
518
|
+
self._initialized = False
|
|
519
|
+
|
|
520
|
+
async def _disconnect_one(self, server_name: str, server_client: _ServerClient) -> None:
|
|
521
|
+
"""Disconnect a single server."""
|
|
522
|
+
server_client.connection_state = ConnectionState.DISCONNECTING
|
|
523
|
+
|
|
524
|
+
if server_client.manager_task and not server_client.manager_task.done():
|
|
525
|
+
server_client.disconnect_event.set()
|
|
526
|
+
try:
|
|
527
|
+
await asyncio.wait_for(server_client.manager_task, timeout=5.0)
|
|
528
|
+
except asyncio.TimeoutError:
|
|
529
|
+
logger.warning(f"Manager task for {server_name} didn't shutdown gracefully, cancelling")
|
|
530
|
+
server_client.manager_task.cancel()
|
|
531
|
+
try:
|
|
532
|
+
await server_client.manager_task
|
|
533
|
+
except asyncio.CancelledError:
|
|
534
|
+
logger.debug(f"Manager task for {server_name} cancelled successfully")
|
|
535
|
+
except Exception as e:
|
|
536
|
+
logger.error(f"Error during manager task shutdown for {server_name}: {e}")
|
|
537
|
+
finally:
|
|
538
|
+
server_client.manager_task = None
|
|
539
|
+
|
|
540
|
+
server_client.initialized = False
|
|
541
|
+
server_client.connection_state = ConnectionState.DISCONNECTED
|
|
542
|
+
|
|
543
|
+
async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
|
|
544
|
+
"""
|
|
545
|
+
Call an MCP tool with validation and timeout handling.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
tool_name: Name of the tool to call (always prefixed as mcp__server__toolname)
|
|
549
|
+
arguments: Tool arguments
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
Tool execution result
|
|
553
|
+
|
|
554
|
+
Raises:
|
|
555
|
+
MCPError: If tool is not available
|
|
556
|
+
MCPConnectionError: If no active session
|
|
557
|
+
MCPValidationError: If arguments are invalid
|
|
558
|
+
MCPTimeoutError: If tool call times out
|
|
559
|
+
MCPServerError: If tool execution fails
|
|
560
|
+
"""
|
|
561
|
+
if tool_name not in self.tools:
|
|
562
|
+
available_tools = list(self.tools.keys())
|
|
563
|
+
raise MCPError(
|
|
564
|
+
f"Tool '{tool_name}' not available",
|
|
565
|
+
context={"available_tools": available_tools, "total": len(available_tools)},
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
# Validate tool arguments
|
|
569
|
+
try:
|
|
570
|
+
validated_arguments = validate_tool_arguments(arguments)
|
|
571
|
+
except ValueError as e:
|
|
572
|
+
raise MCPValidationError(
|
|
573
|
+
f"Invalid tool arguments: {e}",
|
|
574
|
+
field="arguments",
|
|
575
|
+
value=arguments,
|
|
576
|
+
context={"tool_name": tool_name},
|
|
577
|
+
) from e
|
|
578
|
+
|
|
579
|
+
# Execute pre-tool hooks
|
|
580
|
+
pre_tool_hooks = self.hooks.get(HookType.PRE_TOOL_USE, [])
|
|
581
|
+
for hook in pre_tool_hooks:
|
|
582
|
+
try:
|
|
583
|
+
allowed = await hook(tool_name, validated_arguments)
|
|
584
|
+
if not allowed:
|
|
585
|
+
raise MCPValidationError(
|
|
586
|
+
"Tool call blocked by pre-tool hook",
|
|
587
|
+
field="tool_name",
|
|
588
|
+
value=tool_name,
|
|
589
|
+
context={"arguments": validated_arguments},
|
|
590
|
+
)
|
|
591
|
+
except Exception as e:
|
|
592
|
+
if isinstance(e, MCPValidationError):
|
|
593
|
+
raise
|
|
594
|
+
logger.warning(f"Pre-tool hook error for {tool_name}: {e}", exc_info=True)
|
|
595
|
+
|
|
596
|
+
# Extract server name from prefixed tool name (always prefixed)
|
|
597
|
+
server_name = self._tool_to_server.get(tool_name)
|
|
598
|
+
if not server_name:
|
|
599
|
+
raise MCPError(f"Tool '{tool_name}' not mapped to any server")
|
|
600
|
+
|
|
601
|
+
# Extract original tool name (remove prefix - always prefixed)
|
|
602
|
+
original_tool_name = tool_name[len(f"mcp__{server_name}__") :]
|
|
603
|
+
|
|
604
|
+
session = self._get_server_session(server_name)
|
|
605
|
+
|
|
606
|
+
logger.debug(f"Calling tool {original_tool_name} on {server_name} with arguments: {validated_arguments}")
|
|
607
|
+
|
|
608
|
+
# Send tool call start status if callback is available
|
|
609
|
+
if self.status_callback:
|
|
610
|
+
await self.status_callback(
|
|
611
|
+
"tool_call_start",
|
|
612
|
+
{
|
|
613
|
+
"server": server_name,
|
|
614
|
+
"tool": original_tool_name,
|
|
615
|
+
"message": f"Calling tool '{original_tool_name}' on server '{server_name}'",
|
|
616
|
+
"arguments": validated_arguments,
|
|
617
|
+
},
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
# Add timeout to tool calls
|
|
622
|
+
result = await asyncio.wait_for(
|
|
623
|
+
session.call_tool(original_tool_name, validated_arguments),
|
|
624
|
+
timeout=self.timeout_seconds,
|
|
625
|
+
)
|
|
626
|
+
logger.debug(f"Tool {original_tool_name} completed successfully on {server_name}")
|
|
627
|
+
|
|
628
|
+
# Send tool call success status if callback is available
|
|
629
|
+
if self.status_callback:
|
|
630
|
+
await self.status_callback(
|
|
631
|
+
"tool_call_success",
|
|
632
|
+
{
|
|
633
|
+
"server": server_name,
|
|
634
|
+
"tool": original_tool_name,
|
|
635
|
+
"message": f"Tool '{original_tool_name}' executed successfully",
|
|
636
|
+
},
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
return result
|
|
640
|
+
|
|
641
|
+
except asyncio.TimeoutError:
|
|
642
|
+
if self.status_callback:
|
|
643
|
+
await self.status_callback(
|
|
644
|
+
"tool_call_timeout",
|
|
645
|
+
{
|
|
646
|
+
"server": server_name,
|
|
647
|
+
"tool": original_tool_name,
|
|
648
|
+
"message": f"Tool '{original_tool_name}' timed out after {self.timeout_seconds} seconds",
|
|
649
|
+
"timeout": self.timeout_seconds,
|
|
650
|
+
},
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
# Record failure with circuit breaker
|
|
654
|
+
self._circuit_breaker.record_failure(server_name)
|
|
655
|
+
|
|
656
|
+
raise MCPTimeoutError(
|
|
657
|
+
f"Tool call timed out after {self.timeout_seconds} seconds",
|
|
658
|
+
timeout_seconds=self.timeout_seconds,
|
|
659
|
+
operation=f"call_tool({original_tool_name})",
|
|
660
|
+
context={"tool_name": original_tool_name, "server_name": server_name},
|
|
661
|
+
)
|
|
662
|
+
except Exception as e:
|
|
663
|
+
logger.error(f"Tool call failed for {original_tool_name} on {server_name}: {e}", exc_info=True)
|
|
664
|
+
|
|
665
|
+
# Record failure with circuit breaker
|
|
666
|
+
self._circuit_breaker.record_failure(server_name)
|
|
667
|
+
|
|
668
|
+
if self.status_callback:
|
|
669
|
+
await self.status_callback(
|
|
670
|
+
"tool_call_error",
|
|
671
|
+
{
|
|
672
|
+
"server": server_name,
|
|
673
|
+
"tool": original_tool_name,
|
|
674
|
+
"message": f"Tool '{original_tool_name}' failed: {e}",
|
|
675
|
+
"error": str(e),
|
|
676
|
+
},
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
raise MCPServerError(
|
|
680
|
+
f"Tool call failed: {e}",
|
|
681
|
+
server_name=server_name,
|
|
682
|
+
context={"tool_name": original_tool_name, "arguments": validated_arguments},
|
|
683
|
+
) from e
|
|
684
|
+
|
|
685
|
+
def get_available_tools(self) -> List[str]:
|
|
686
|
+
"""Get list of available tool names."""
|
|
687
|
+
return list(self.tools.keys())
|
|
688
|
+
|
|
689
|
+
def is_connected(self) -> bool:
|
|
690
|
+
"""Check if any servers are connected."""
|
|
691
|
+
return self._initialized and any(sc.initialized for sc in self._server_clients.values())
|
|
692
|
+
|
|
693
|
+
def get_server_names(self) -> List[str]:
|
|
694
|
+
"""Get list of connected server names."""
|
|
695
|
+
return [name for name, sc in self._server_clients.items() if sc.initialized]
|
|
696
|
+
|
|
697
|
+
def get_active_sessions(self) -> List[ClientSession]:
|
|
698
|
+
"""Return active MCP ClientSession objects for all connected servers."""
|
|
699
|
+
sessions = []
|
|
700
|
+
for server_client in self._server_clients.values():
|
|
701
|
+
if server_client.session is not None and server_client.initialized:
|
|
702
|
+
sessions.append(server_client.session)
|
|
703
|
+
return sessions
|
|
704
|
+
|
|
705
|
+
async def health_check_all(self) -> Dict[str, bool]:
|
|
706
|
+
"""
|
|
707
|
+
Perform health check on all connected MCP servers.
|
|
708
|
+
|
|
709
|
+
Returns:
|
|
710
|
+
Dictionary mapping server names to health status
|
|
711
|
+
"""
|
|
712
|
+
health_status = {}
|
|
713
|
+
|
|
714
|
+
for server_name, server_client in self._server_clients.items():
|
|
715
|
+
if not server_client.initialized or not server_client.session:
|
|
716
|
+
health_status[server_name] = False
|
|
717
|
+
continue
|
|
718
|
+
|
|
719
|
+
try:
|
|
720
|
+
await server_client.session.list_tools()
|
|
721
|
+
health_status[server_name] = True
|
|
722
|
+
except Exception as e:
|
|
723
|
+
logger.warning(f"Health check failed for {server_name}: {e}")
|
|
724
|
+
health_status[server_name] = False
|
|
725
|
+
|
|
726
|
+
return health_status
|
|
727
|
+
|
|
728
|
+
async def health_check(self) -> bool:
|
|
729
|
+
"""
|
|
730
|
+
Perform a health check on all servers.
|
|
731
|
+
|
|
732
|
+
Returns:
|
|
733
|
+
True if all connected servers are healthy, False otherwise
|
|
734
|
+
"""
|
|
735
|
+
health_status = await self.health_check_all()
|
|
736
|
+
return all(health_status.values()) if health_status else False
|
|
737
|
+
|
|
738
|
+
async def _reconnect_failed_servers(self, max_retries: int = 3) -> Dict[str, bool]:
|
|
739
|
+
"""
|
|
740
|
+
Attempt to reconnect any failed servers with circuit breaker integration.
|
|
741
|
+
|
|
742
|
+
Args:
|
|
743
|
+
max_retries: Maximum number of reconnection attempts per server
|
|
744
|
+
|
|
745
|
+
Returns:
|
|
746
|
+
Dictionary mapping server names to reconnection success status
|
|
747
|
+
"""
|
|
748
|
+
health_status = await self.health_check_all()
|
|
749
|
+
reconnect_results = {}
|
|
750
|
+
|
|
751
|
+
for server_name, is_healthy in health_status.items():
|
|
752
|
+
if not is_healthy:
|
|
753
|
+
# Check circuit breaker before reconnecting
|
|
754
|
+
if self._circuit_breaker.should_skip_server(server_name):
|
|
755
|
+
logger.warning(f"Skipping reconnection for {server_name} due to circuit breaker")
|
|
756
|
+
reconnect_results[server_name] = False
|
|
757
|
+
continue
|
|
758
|
+
|
|
759
|
+
logger.info(f"Attempting to reconnect failed server: {server_name}")
|
|
760
|
+
|
|
761
|
+
# Find the config for this server
|
|
762
|
+
config = next((c for c in self._server_configs if c["name"] == server_name), None)
|
|
763
|
+
if not config:
|
|
764
|
+
reconnect_results[server_name] = False
|
|
765
|
+
continue
|
|
766
|
+
|
|
767
|
+
success = False
|
|
768
|
+
for attempt in range(max_retries):
|
|
769
|
+
try:
|
|
770
|
+
if attempt > 0:
|
|
771
|
+
await asyncio.sleep(1.0 * (2**attempt)) # Exponential backoff
|
|
772
|
+
|
|
773
|
+
# Disconnect first
|
|
774
|
+
server_client = self._server_clients[server_name]
|
|
775
|
+
await self._disconnect_one(server_name, server_client)
|
|
776
|
+
|
|
777
|
+
# Reconnect
|
|
778
|
+
server_client.connected_event = asyncio.Event()
|
|
779
|
+
server_client.disconnect_event = asyncio.Event()
|
|
780
|
+
server_client.manager_task = asyncio.create_task(
|
|
781
|
+
self._run_manager(server_name, config),
|
|
782
|
+
)
|
|
783
|
+
await asyncio.wait_for(server_client.connected_event.wait(), timeout=30.0)
|
|
784
|
+
|
|
785
|
+
if server_client.initialized:
|
|
786
|
+
self._circuit_breaker.record_success(server_name)
|
|
787
|
+
success = True
|
|
788
|
+
logger.info(f"Successfully reconnected server: {server_name}")
|
|
789
|
+
break
|
|
790
|
+
except Exception as e:
|
|
791
|
+
logger.warning(f"Reconnection attempt {attempt + 1} failed for {server_name}: {e}")
|
|
792
|
+
self._circuit_breaker.record_failure(server_name)
|
|
793
|
+
|
|
794
|
+
reconnect_results[server_name] = success
|
|
795
|
+
else:
|
|
796
|
+
reconnect_results[server_name] = True
|
|
797
|
+
|
|
798
|
+
return reconnect_results
|
|
799
|
+
|
|
800
|
+
async def reconnect(self, max_retries: int = 3) -> bool:
|
|
801
|
+
"""
|
|
802
|
+
Attempt to reconnect all servers with circuit breaker integration.
|
|
803
|
+
|
|
804
|
+
Args:
|
|
805
|
+
max_retries: Maximum number of reconnection attempts
|
|
806
|
+
Uses exponential backoff between retries: 2s, 4s, 8s, 16s...
|
|
807
|
+
|
|
808
|
+
Returns:
|
|
809
|
+
True if all reconnections successful, False otherwise
|
|
810
|
+
"""
|
|
811
|
+
results = await self._reconnect_failed_servers(max_retries)
|
|
812
|
+
return all(results.values()) if results else False
|
|
813
|
+
|
|
814
|
+
async def _cleanup(self) -> None:
|
|
815
|
+
"""Comprehensive cleanup of all resources."""
|
|
816
|
+
async with self._cleanup_lock:
|
|
817
|
+
if self._cleanup_done:
|
|
818
|
+
return
|
|
819
|
+
|
|
820
|
+
logger.debug("Starting cleanup for MCPClient")
|
|
821
|
+
|
|
822
|
+
try:
|
|
823
|
+
# Disconnect all servers
|
|
824
|
+
await self.disconnect()
|
|
825
|
+
|
|
826
|
+
# Close errlog files
|
|
827
|
+
if hasattr(self, "_errlog_files"):
|
|
828
|
+
for server_name, errlog_file in self._errlog_files.items():
|
|
829
|
+
try:
|
|
830
|
+
errlog_file.close()
|
|
831
|
+
except Exception as e:
|
|
832
|
+
logger.debug(f"Error closing errlog file for {server_name}: {e}")
|
|
833
|
+
self._errlog_files.clear()
|
|
834
|
+
|
|
835
|
+
# Clear all references
|
|
836
|
+
self.tools.clear()
|
|
837
|
+
self._tool_to_server.clear()
|
|
838
|
+
|
|
839
|
+
self._cleanup_done = True
|
|
840
|
+
logger.debug("Cleanup completed for MCPClient")
|
|
841
|
+
|
|
842
|
+
except Exception as e:
|
|
843
|
+
logger.error(f"Error during cleanup: {e}")
|
|
844
|
+
raise
|
|
845
|
+
|
|
846
|
+
async def __aenter__(self) -> "MCPClient":
|
|
847
|
+
"""Async context manager entry."""
|
|
848
|
+
self._context_managed = True
|
|
849
|
+
await self.connect()
|
|
850
|
+
return self
|
|
851
|
+
|
|
852
|
+
async def __aexit__(
|
|
853
|
+
self,
|
|
854
|
+
_exc_type: Optional[type],
|
|
855
|
+
_exc_val: Optional[BaseException],
|
|
856
|
+
_exc_tb: Optional[TracebackType],
|
|
857
|
+
) -> None:
|
|
858
|
+
"""Async context manager exit."""
|
|
859
|
+
try:
|
|
860
|
+
await self._cleanup()
|
|
861
|
+
except Exception as e:
|
|
862
|
+
logger.error(f"Error during context manager cleanup: {e}")
|
|
863
|
+
finally:
|
|
864
|
+
self._context_managed = False
|
|
865
|
+
|
|
866
|
+
@classmethod
|
|
867
|
+
async def create_and_connect(
|
|
868
|
+
cls,
|
|
869
|
+
server_configs: List[Dict[str, Any]],
|
|
870
|
+
*,
|
|
871
|
+
timeout_seconds: int = 30,
|
|
872
|
+
allowed_tools: Optional[List[str]] = None,
|
|
873
|
+
exclude_tools: Optional[List[str]] = None,
|
|
874
|
+
) -> "MCPClient":
|
|
875
|
+
"""
|
|
876
|
+
Create and connect MCP client in one step.
|
|
877
|
+
|
|
878
|
+
Args:
|
|
879
|
+
server_configs: List of server configuration dictionaries
|
|
880
|
+
timeout_seconds: Timeout for operations in seconds
|
|
881
|
+
allowed_tools: Optional list of tool names to include
|
|
882
|
+
exclude_tools: Optional list of tool names to exclude
|
|
883
|
+
|
|
884
|
+
Returns:
|
|
885
|
+
Connected MCPClient instance
|
|
886
|
+
"""
|
|
887
|
+
client = cls(
|
|
888
|
+
server_configs,
|
|
889
|
+
timeout_seconds=timeout_seconds,
|
|
890
|
+
allowed_tools=allowed_tools,
|
|
891
|
+
exclude_tools=exclude_tools,
|
|
892
|
+
)
|
|
893
|
+
await client.connect()
|
|
894
|
+
return client
|