talkops-opscloud 0.3.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opscloud/__init__.py +5 -0
- opscloud/__main__.py +6 -0
- opscloud/_constants.py +53 -0
- opscloud/_debug.py +145 -0
- opscloud/_debug_buffer.py +250 -0
- opscloud/_textual_patches.py +367 -0
- opscloud/_version.py +15 -0
- opscloud/agent/__init__.py +27 -0
- opscloud/agent/config.py +168 -0
- opscloud/agent/factory.py +1103 -0
- opscloud/approval_mode.py +44 -0
- opscloud/backend/__init__.py +49 -0
- opscloud/backend/composite.py +89 -0
- opscloud/backend/local.py +183 -0
- opscloud/backend/registry.py +79 -0
- opscloud/backend/sandbox_config.py +107 -0
- opscloud/backend/sandbox_factory.py +432 -0
- opscloud/built_in_skills/remember/SKILL.md +124 -0
- opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
- opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
- opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
- opscloud/cli/__init__.py +1 -0
- opscloud/cli/commands/__init__.py +15 -0
- opscloud/cli/commands/auth.py +45 -0
- opscloud/cli/commands/doctor.py +100 -0
- opscloud/cli/commands/mcp.py +137 -0
- opscloud/cli/commands/threads.py +51 -0
- opscloud/cli/interactive.py +154 -0
- opscloud/cli/main.py +903 -0
- opscloud/cli/non_interactive.py +425 -0
- opscloud/cli/server_manager.py +58 -0
- opscloud/commands/__init__.py +19 -0
- opscloud/commands/_base.py +110 -0
- opscloud/commands/_guards.py +52 -0
- opscloud/commands/_router.py +146 -0
- opscloud/commands/_types.py +36 -0
- opscloud/commands/core/__init__.py +47 -0
- opscloud/commands/core/auth.py +94 -0
- opscloud/commands/core/bug.py +46 -0
- opscloud/commands/core/clear.py +106 -0
- opscloud/commands/core/cloud.py +185 -0
- opscloud/commands/core/compact.py +128 -0
- opscloud/commands/core/config_cmd.py +120 -0
- opscloud/commands/core/context.py +220 -0
- opscloud/commands/core/cost.py +44 -0
- opscloud/commands/core/doctor.py +535 -0
- opscloud/commands/core/effort.py +108 -0
- opscloud/commands/core/exit_cmd.py +41 -0
- opscloud/commands/core/fast.py +148 -0
- opscloud/commands/core/help_cmd.py +94 -0
- opscloud/commands/core/mcp.py +134 -0
- opscloud/commands/core/model.py +109 -0
- opscloud/commands/core/permissions.py +196 -0
- opscloud/commands/core/plugins.py +92 -0
- opscloud/commands/core/pool.py +108 -0
- opscloud/commands/core/resume.py +97 -0
- opscloud/commands/core/skills.py +178 -0
- opscloud/commands/power/__init__.py +51 -0
- opscloud/commands/power/agents.py +98 -0
- opscloud/commands/power/btw.py +85 -0
- opscloud/commands/power/copy.py +97 -0
- opscloud/commands/power/goal.py +548 -0
- opscloud/commands/power/loop.py +295 -0
- opscloud/commands/power/memory.py +287 -0
- opscloud/commands/power/review.py +152 -0
- opscloud/commands/power/rubric.py +287 -0
- opscloud/commands/power/runtime.py +321 -0
- opscloud/commands/power/skill_creator.py +70 -0
- opscloud/commands/power/skill_invoke.py +165 -0
- opscloud/commands/power/tasks.py +209 -0
- opscloud/commands/power/trace.py +117 -0
- opscloud/commands/power/ui_toggles.py +135 -0
- opscloud/commands/power/version.py +52 -0
- opscloud/commands/registry.py +176 -0
- opscloud/config/__init__.py +114 -0
- opscloud/config/adapters/__init__.py +5 -0
- opscloud/config/adapters/postgres.py +776 -0
- opscloud/config/adapters/sqlite.py +769 -0
- opscloud/config/aws.py +248 -0
- opscloud/config/cloud_profiles.py +437 -0
- opscloud/config/env_vars.py +81 -0
- opscloud/config/langsmith.py +319 -0
- opscloud/config/manifest.py +817 -0
- opscloud/config/metadata.py +205 -0
- opscloud/config/paths.py +604 -0
- opscloud/config/plugins.py +130 -0
- opscloud/config/settings.py +834 -0
- opscloud/config/store.py +453 -0
- opscloud/config/store_factory.py +97 -0
- opscloud/config/toml_config.py +562 -0
- opscloud/editor.py +173 -0
- opscloud/exceptions.py +65 -0
- opscloud/file_ops.py +217 -0
- opscloud/hooks/__init__.py +139 -0
- opscloud/hooks/env.py +44 -0
- opscloud/hooks/interrupt.py +121 -0
- opscloud/hooks/models/__init__.py +109 -0
- opscloud/hooks/models/adapters.py +19 -0
- opscloud/hooks/models/config.py +79 -0
- opscloud/hooks/models/domain.py +434 -0
- opscloud/hooks/models/transport.py +37 -0
- opscloud/hooks/tools.py +231 -0
- opscloud/input.py +48 -0
- opscloud/integrations/__init__.py +80 -0
- opscloud/integrations/base.py +153 -0
- opscloud/integrations/content_filter.py +119 -0
- opscloud/integrations/event_bus.py +480 -0
- opscloud/integrations/hooks.py +238 -0
- opscloud/integrations/identity.py +181 -0
- opscloud/integrations/notifications.py +134 -0
- opscloud/integrations/stream_bridge.py +397 -0
- opscloud/integrations/thread_store.py +81 -0
- opscloud/json_types.py +20 -0
- opscloud/mcp/__init__.py +57 -0
- opscloud/mcp/config.py +203 -0
- opscloud/mcp/discovery.py +229 -0
- opscloud/mcp/mcp_info.py +109 -0
- opscloud/mcp/middleware.py +102 -0
- opscloud/mcp/preload.py +505 -0
- opscloud/mcp/raw_config.py +149 -0
- opscloud/mcp/semantic_profiler.py +305 -0
- opscloud/mcp/session_manager.py +727 -0
- opscloud/mcp/trust.py +105 -0
- opscloud/memory/__init__.py +27 -0
- opscloud/memory/branch.py +61 -0
- opscloud/memory/guard.py +19 -0
- opscloud/memory/onboarding.py +120 -0
- opscloud/memory/registry.py +134 -0
- opscloud/memory/store.py +223 -0
- opscloud/middleware/__init__.py +106 -0
- opscloud/middleware/_repository_bounds.py +459 -0
- opscloud/middleware/ask_user.py +152 -0
- opscloud/middleware/auto_mode.py +60 -0
- opscloud/middleware/auto_mode_hitl.py +2434 -0
- opscloud/middleware/compaction.py +566 -0
- opscloud/middleware/configurable_model.py +233 -0
- opscloud/middleware/cost_tracking.py +1021 -0
- opscloud/middleware/glm_stall_recovery.py +109 -0
- opscloud/middleware/goal_criteria.py +1234 -0
- opscloud/middleware/goal_state_notice.py +712 -0
- opscloud/middleware/goal_tools.py +407 -0
- opscloud/middleware/headless_mcp_guard.py +88 -0
- opscloud/middleware/jev_model_router.py +1018 -0
- opscloud/middleware/local_context.py +460 -0
- opscloud/middleware/mcp_context.py +151 -0
- opscloud/middleware/mcp_middleware.py +146 -0
- opscloud/middleware/memory_guard.py +393 -0
- opscloud/middleware/model_retry.py +205 -0
- opscloud/middleware/registry.py +137 -0
- opscloud/middleware/reliable_rubric.py +709 -0
- opscloud/middleware/resume_state.py +91 -0
- opscloud/middleware/server_hooks.py +1189 -0
- opscloud/middleware/shell_allow_list.py +90 -0
- opscloud/middleware/skills.py +382 -0
- opscloud/middleware/subagent_artifacts.py +84 -0
- opscloud/middleware/subagent_telemetry.py +261 -0
- opscloud/middleware/subagents.py +243 -0
- opscloud/middleware/tool_filter.py +384 -0
- opscloud/middleware/unified_system_message.py +62 -0
- opscloud/model/__init__.py +37 -0
- opscloud/model/config.py +2992 -0
- opscloud/model/factory.py +477 -0
- opscloud/model/pool.py +459 -0
- opscloud/model/reasoning.py +282 -0
- opscloud/offload.py +173 -0
- opscloud/output.py +64 -0
- opscloud/plugins/__init__.py +189 -0
- opscloud/plugins/_json.py +22 -0
- opscloud/plugins/adapters/__init__.py +13 -0
- opscloud/plugins/adapters/agents.py +112 -0
- opscloud/plugins/adapters/commands.py +190 -0
- opscloud/plugins/adapters/mcp.py +363 -0
- opscloud/plugins/adapters/skills.py +132 -0
- opscloud/plugins/commands_cli.py +541 -0
- opscloud/plugins/discovery.py +512 -0
- opscloud/plugins/manifest.py +335 -0
- opscloud/plugins/marketplace.py +893 -0
- opscloud/plugins/models.py +334 -0
- opscloud/plugins/project_plugins.py +317 -0
- opscloud/plugins/protocol.py +31 -0
- opscloud/plugins/store.py +905 -0
- opscloud/plugins/substitution.py +122 -0
- opscloud/project_utils.py +145 -0
- opscloud/prompts/__init__.py +37 -0
- opscloud/prompts/system.py +483 -0
- opscloud/prompts/templates/system_prompt.md +166 -0
- opscloud/prompts/types.py +54 -0
- opscloud/rubrics/__init__.py +42 -0
- opscloud/rubrics/evaluator.py +404 -0
- opscloud/rubrics/evidence_extractor.py +229 -0
- opscloud/rubrics/generator.py +239 -0
- opscloud/rubrics/jev_compiler.py +175 -0
- opscloud/rubrics/jev_grader.py +297 -0
- opscloud/rubrics/middleware.py +8 -0
- opscloud/schema/__init__.py +107 -0
- opscloud/schema/interrupts.py +981 -0
- opscloud/security/__init__.py +91 -0
- opscloud/security/approval_mode.py +528 -0
- opscloud/security/approval_mode_source.py +230 -0
- opscloud/security/cli_ast_evaluator.py +552 -0
- opscloud/security/jev_classifier.py +651 -0
- opscloud/security/shell_safety.py +340 -0
- opscloud/security/unicode_security.py +439 -0
- opscloud/security/url_validation.py +160 -0
- opscloud/server/__init__.py +13 -0
- opscloud/server/_server_config.py +158 -0
- opscloud/server/server.py +242 -0
- opscloud/server/server_graph.py +93 -0
- opscloud/skills/__init__.py +91 -0
- opscloud/skills/commands.py +1099 -0
- opscloud/skills/invocation.py +200 -0
- opscloud/skills/load.py +388 -0
- opscloud/skills/loader.py +23 -0
- opscloud/skills/merge.py +65 -0
- opscloud/skills/registry.py +263 -0
- opscloud/skills/sources.py +174 -0
- opscloud/skills/trust.py +417 -0
- opscloud/state/__init__.py +142 -0
- opscloud/state/base.py +154 -0
- opscloud/state/cloud_context.py +262 -0
- opscloud/state/goal_channels.py +124 -0
- opscloud/state/goal_state_limits.py +175 -0
- opscloud/state/resume_state.py +58 -0
- opscloud/state/service_context.py +281 -0
- opscloud/state/session.py +957 -0
- opscloud/state/state_migration.py +86 -0
- opscloud/subagents/__init__.py +30 -0
- opscloud/subagents/loader.py +331 -0
- opscloud/subagents/subagents_parser.py +117 -0
- opscloud/subagents/types.py +25 -0
- opscloud/tools/__init__.py +36 -0
- opscloud/tools/catalog.py +37 -0
- opscloud/tools/display.py +24 -0
- opscloud/tools/fetch_url.py +176 -0
- opscloud/tools/goal_tools.py +423 -0
- opscloud/tools/registry.py +105 -0
- opscloud/tools/thread.py +32 -0
- opscloud/tools/web_search.py +215 -0
- opscloud/ui/__init__.py +119 -0
- opscloud/ui/app.py +5400 -0
- opscloud/ui/app.tcss +479 -0
- opscloud/ui/clipboard.py +147 -0
- opscloud/ui/command_registry.py +482 -0
- opscloud/ui/message_store.py +152 -0
- opscloud/ui/permission_store.py +360 -0
- opscloud/ui/preferences.py +113 -0
- opscloud/ui/prompt_manager.py +96 -0
- opscloud/ui/remote_client.py +505 -0
- opscloud/ui/textual_adapter.py +1269 -0
- opscloud/ui/theme.py +728 -0
- opscloud/ui/ui_help.py +415 -0
- opscloud/ui/widgets/__init__.py +383 -0
- opscloud/ui/widgets/_ask_user_types.py +81 -0
- opscloud/ui/widgets/_copy_spans.py +52 -0
- opscloud/ui/widgets/_inline_prompt.py +290 -0
- opscloud/ui/widgets/_js_eval_display.py +139 -0
- opscloud/ui/widgets/_links.py +175 -0
- opscloud/ui/widgets/_tool_stream.py +308 -0
- opscloud/ui/widgets/agent_selector.py +297 -0
- opscloud/ui/widgets/approval.py +741 -0
- opscloud/ui/widgets/ask_user.py +539 -0
- opscloud/ui/widgets/auth.py +695 -0
- opscloud/ui/widgets/auth_manager.py +214 -0
- opscloud/ui/widgets/auto_mode_notice.py +146 -0
- opscloud/ui/widgets/autocomplete.py +709 -0
- opscloud/ui/widgets/chat_input.py +805 -0
- opscloud/ui/widgets/cloud_selector.py +528 -0
- opscloud/ui/widgets/config_manager.py +652 -0
- opscloud/ui/widgets/debug_console.py +1269 -0
- opscloud/ui/widgets/devops_renderers.py +184 -0
- opscloud/ui/widgets/diff.py +237 -0
- opscloud/ui/widgets/effort_selector.py +166 -0
- opscloud/ui/widgets/goal_review.py +612 -0
- opscloud/ui/widgets/goal_status.py +57 -0
- opscloud/ui/widgets/infra_panel.py +141 -0
- opscloud/ui/widgets/install_confirm.py +114 -0
- opscloud/ui/widgets/loading.py +170 -0
- opscloud/ui/widgets/mcp_viewer.py +1204 -0
- opscloud/ui/widgets/messages.py +4461 -0
- opscloud/ui/widgets/model_selector.py +863 -0
- opscloud/ui/widgets/notification_center.py +108 -0
- opscloud/ui/widgets/notification_settings.py +119 -0
- opscloud/ui/widgets/operation_card.py +106 -0
- opscloud/ui/widgets/permissions_manager.py +667 -0
- opscloud/ui/widgets/plugin_manager.py +1212 -0
- opscloud/ui/widgets/pool_selector.py +376 -0
- opscloud/ui/widgets/preamble.py +53 -0
- opscloud/ui/widgets/skills_viewer.py +432 -0
- opscloud/ui/widgets/status.py +1037 -0
- opscloud/ui/widgets/subagent_panel.py +927 -0
- opscloud/ui/widgets/theme_selector.py +232 -0
- opscloud/ui/widgets/thread_selector.py +358 -0
- opscloud/ui/widgets/toast.py +31 -0
- opscloud/ui/widgets/tool_display.py +325 -0
- opscloud/ui/widgets/tool_renderers.py +234 -0
- opscloud/ui/widgets/tool_widgets.py +278 -0
- opscloud/ui/widgets/welcome.py +421 -0
- opscloud/ui/widgets/welcome_popup.py +102 -0
- opscloud/utils/__init__.py +24 -0
- opscloud/utils/cost_estimation.py +169 -0
- opscloud/utils/git.py +390 -0
- opscloud/utils/logger.py +571 -0
- opscloud/utils/session_stats.py +115 -0
- opscloud/utils/startup_error.py +37 -0
- talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
- talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
- talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
- talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
- talkops_opscloud-0.3.0b1.dist-info/licenses/LICENSE +175 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
"""MCP Session Manager with connection pooling, normalization, and lifecycle management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from contextlib import AsyncExitStack
|
|
8
|
+
import fnmatch
|
|
9
|
+
import os
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
from langchain_core.tools import BaseTool, StructuredTool, ToolException
|
|
13
|
+
|
|
14
|
+
from opscloud.mcp.config import resolve_mcp_server_env, sync_mcp_env_aliases
|
|
15
|
+
from opscloud.mcp.mcp_info import MCPServerInfo
|
|
16
|
+
from opscloud.utils.logger import get_logger
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
_DEFAULT_CONNECT_TIMEOUT = 30.0
|
|
21
|
+
_CONNECT_TIMEOUT = float(os.environ.get("OPSCLOUD_MCP_CONNECT_TIMEOUT", str(_DEFAULT_CONNECT_TIMEOUT)))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_transient_session_error(exc: BaseException) -> bool:
|
|
25
|
+
"""Return True when exc signals the MCP session transport or stream is dead."""
|
|
26
|
+
try:
|
|
27
|
+
import anyio
|
|
28
|
+
|
|
29
|
+
anyio_excs = (
|
|
30
|
+
anyio.ClosedResourceError,
|
|
31
|
+
anyio.BrokenResourceError,
|
|
32
|
+
anyio.EndOfStream,
|
|
33
|
+
)
|
|
34
|
+
except ImportError:
|
|
35
|
+
anyio_excs = ()
|
|
36
|
+
return isinstance(
|
|
37
|
+
exc,
|
|
38
|
+
(
|
|
39
|
+
*anyio_excs,
|
|
40
|
+
BrokenPipeError,
|
|
41
|
+
ConnectionAbortedError,
|
|
42
|
+
ConnectionResetError,
|
|
43
|
+
EOFError,
|
|
44
|
+
asyncio.IncompleteReadError,
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _normalize_mcp_arguments(arguments: dict[str, Any], input_schema: Any) -> dict[str, Any]:
|
|
50
|
+
"""Normalize MCP tool arguments, stripping empty string values for non-required fields."""
|
|
51
|
+
if not isinstance(input_schema, dict):
|
|
52
|
+
return arguments
|
|
53
|
+
required = set(input_schema.get("required") or ())
|
|
54
|
+
properties = input_schema.get("properties") or {}
|
|
55
|
+
cleaned: dict[str, Any] = {}
|
|
56
|
+
for key, value in arguments.items():
|
|
57
|
+
if value != "" or key in required:
|
|
58
|
+
cleaned[key] = value
|
|
59
|
+
continue
|
|
60
|
+
prop = properties.get(key)
|
|
61
|
+
prop_type = prop.get("type") if isinstance(prop, dict) else None
|
|
62
|
+
is_string_typed = prop_type == "string" or (isinstance(prop_type, list) and "string" in prop_type)
|
|
63
|
+
if isinstance(prop, dict) and not is_string_typed and prop_type is not None:
|
|
64
|
+
cleaned[key] = value
|
|
65
|
+
return cleaned
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _clean_mcp_schema(schema: Any) -> dict[str, Any]:
|
|
69
|
+
"""Clean JSON Schema for compatibility with LLM provider tool schemas (Gemini, Anthropic, OpenAI).
|
|
70
|
+
|
|
71
|
+
Dereferences all `$ref` pointers (such as `#/$defs/...` or `#/definitions/...`)
|
|
72
|
+
using LangChain's `dereference_refs` so that referenced models (like PricingFilter)
|
|
73
|
+
are inlined directly into properties before removing metadata keys.
|
|
74
|
+
"""
|
|
75
|
+
if not isinstance(schema, dict):
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
from langchain_core.utils.json_schema import dereference_refs
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
resolved = dereference_refs(schema)
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
logger.debug("Failed to dereference MCP schema refs: %s", exc)
|
|
84
|
+
resolved = schema
|
|
85
|
+
|
|
86
|
+
cleaned = dict(resolved) if isinstance(resolved, dict) else dict(schema)
|
|
87
|
+
for key in ("$schema", "$id", "$defs", "definitions", "additionalProperties"):
|
|
88
|
+
cleaned.pop(key, None)
|
|
89
|
+
|
|
90
|
+
properties = cleaned.get("properties")
|
|
91
|
+
if isinstance(properties, dict):
|
|
92
|
+
new_props = {}
|
|
93
|
+
for prop_name, prop_val in properties.items():
|
|
94
|
+
if isinstance(prop_val, dict):
|
|
95
|
+
p_clean = dict(prop_val)
|
|
96
|
+
p_clean.pop("additionalProperties", None)
|
|
97
|
+
p_clean.pop("$schema", None)
|
|
98
|
+
new_props[prop_name] = p_clean
|
|
99
|
+
else:
|
|
100
|
+
new_props[prop_name] = prop_val
|
|
101
|
+
cleaned["properties"] = new_props
|
|
102
|
+
|
|
103
|
+
return cleaned
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _filter_tool_name(
|
|
107
|
+
tool_name: str,
|
|
108
|
+
*,
|
|
109
|
+
allowed_tools: list[str] | tuple[str, ...],
|
|
110
|
+
disabled_tools: list[str] | tuple[str, ...],
|
|
111
|
+
) -> bool:
|
|
112
|
+
"""Check whether a tool name matches allowed/disabled rules."""
|
|
113
|
+
if disabled_tools:
|
|
114
|
+
for pat in disabled_tools:
|
|
115
|
+
if pat and (pat == tool_name or fnmatch.fnmatch(tool_name, pat)):
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
if allowed_tools:
|
|
119
|
+
return any(pat == tool_name or fnmatch.fnmatch(tool_name, pat) for pat in allowed_tools if pat)
|
|
120
|
+
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _MCPSessionEntry:
|
|
125
|
+
"""Internal tracking entry for an active MCP session connection."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, session: Any, exit_stack: AsyncExitStack) -> None:
|
|
128
|
+
"""Initialize tracking entry for an active MCP session.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
session: Active MCP client session instance.
|
|
132
|
+
exit_stack: AsyncExitStack managing connection cleanup.
|
|
133
|
+
"""
|
|
134
|
+
self.session = session
|
|
135
|
+
self.exit_stack = exit_stack
|
|
136
|
+
self._cached_tools: list[BaseTool] = []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _enhance_mcp_error_diagnostics(error_text: str) -> str:
|
|
140
|
+
"""Append actionable troubleshooting guidance when encountering cloud, AWS, or connection errors."""
|
|
141
|
+
lower = error_text.lower()
|
|
142
|
+
|
|
143
|
+
# AWS Credential / Authentication Errors
|
|
144
|
+
if any(k in lower for k in ("expiredtoken", "token has expired", "credentials has expired", "token is expired")):
|
|
145
|
+
hint = (
|
|
146
|
+
"\n[Diagnostic Hint: AWS credentials have expired. Refresh your AWS SSO session with `aws sso login` "
|
|
147
|
+
"or export refreshed AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN.]"
|
|
148
|
+
)
|
|
149
|
+
if hint not in error_text:
|
|
150
|
+
return f"{error_text}{hint}"
|
|
151
|
+
|
|
152
|
+
if any(k in lower for k in ("nocredentialserror", "unable to locate credentials")):
|
|
153
|
+
hint = (
|
|
154
|
+
"\n[Diagnostic Hint: No AWS credentials found. Configure credentials via `aws configure` / `aws sso login`, "
|
|
155
|
+
"set AWS_PROFILE, or export AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.]"
|
|
156
|
+
)
|
|
157
|
+
if hint not in error_text:
|
|
158
|
+
return f"{error_text}{hint}"
|
|
159
|
+
|
|
160
|
+
if any(k in lower for k in ("accessdenied", "unauthorizedoperation", "is not authorized to perform")):
|
|
161
|
+
hint = (
|
|
162
|
+
"\n[Diagnostic Hint: AWS IAM Access Denied. Verify that the active AWS IAM principal / role "
|
|
163
|
+
"has the required IAM policies and permissions for this operation.]"
|
|
164
|
+
)
|
|
165
|
+
if hint not in error_text:
|
|
166
|
+
return f"{error_text}{hint}"
|
|
167
|
+
|
|
168
|
+
if any(k in lower for k in ("endpointconnectionerror", "could not connect to the endpoint url")):
|
|
169
|
+
hint = (
|
|
170
|
+
"\n[Diagnostic Hint: Failed to connect to AWS endpoint URL. Check your network connection, "
|
|
171
|
+
"verify AWS_REGION / AWS_DEFAULT_REGION, and verify any VPC endpoint / proxy configurations.]"
|
|
172
|
+
)
|
|
173
|
+
if hint not in error_text:
|
|
174
|
+
return f"{error_text}{hint}"
|
|
175
|
+
|
|
176
|
+
is_cluster_dns = ".svc" in lower or "cluster.local" in lower
|
|
177
|
+
is_name_resolution_failure = any(
|
|
178
|
+
indicator in lower
|
|
179
|
+
for indicator in (
|
|
180
|
+
"errnamenotresolved",
|
|
181
|
+
"nameresolutionerror",
|
|
182
|
+
"nodename nor servname provided",
|
|
183
|
+
"temporary failure in name resolution",
|
|
184
|
+
"name or service not known",
|
|
185
|
+
"cannot connect to",
|
|
186
|
+
"connection refused",
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
if is_cluster_dns or (
|
|
190
|
+
is_name_resolution_failure
|
|
191
|
+
and any(svc in lower for svc in ("argocd", "prometheus", "alertmanager", "loki", "tempo", "traefik", "eks"))
|
|
192
|
+
):
|
|
193
|
+
hint = (
|
|
194
|
+
"\n[Diagnostic Hint: Failed to resolve or connect to target cluster endpoint. "
|
|
195
|
+
"If running opscloud locally outside the cluster/VPC, forward the target service "
|
|
196
|
+
"(e.g., `kubectl port-forward svc/<service-name> -n <namespace> <local_port>:<target_port>`) "
|
|
197
|
+
"or check your VPN/tunnel connection, and configure the URL to point to `http://localhost:<local_port>`.]"
|
|
198
|
+
)
|
|
199
|
+
if hint not in error_text:
|
|
200
|
+
return f"{error_text}{hint}"
|
|
201
|
+
elif "connection closed" in lower or "broken pipe" in lower:
|
|
202
|
+
hint = (
|
|
203
|
+
"\n[Diagnostic Hint: The MCP server process terminated unexpectedly. "
|
|
204
|
+
"Ensure the required CLI command (e.g. uvx, npx, python, docker), dependencies, "
|
|
205
|
+
"and valid credentials are installed and in your system PATH.]"
|
|
206
|
+
)
|
|
207
|
+
if hint not in error_text:
|
|
208
|
+
return f"{error_text}{hint}"
|
|
209
|
+
return error_text
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def create_mcp_connection(resolved_config: Mapping[str, Any]) -> Any:
|
|
213
|
+
"""Instantiate a LangChain MCP connection from resolved server configuration.
|
|
214
|
+
|
|
215
|
+
Consolidates transport resolution (stdio, sse, http/streamable_http) and
|
|
216
|
+
implements ambient environment variable inheritance for StdioConnection processes.
|
|
217
|
+
"""
|
|
218
|
+
from langchain_mcp_adapters.sessions import (
|
|
219
|
+
SSEConnection,
|
|
220
|
+
StdioConnection,
|
|
221
|
+
StreamableHttpConnection,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
transport = str(resolved_config.get("type") or resolved_config.get("transport") or "").lower()
|
|
225
|
+
url = str(resolved_config.get("url") or resolved_config.get("serverUrl") or "")
|
|
226
|
+
headers = dict(resolved_config.get("headers") or {})
|
|
227
|
+
|
|
228
|
+
if not transport or transport == "stdio":
|
|
229
|
+
transport = ("sse" if "sse" in url.lower() else "http") if url else "stdio"
|
|
230
|
+
|
|
231
|
+
if transport in ("http", "streamable_http"):
|
|
232
|
+
return StreamableHttpConnection( # type: ignore[call-arg]
|
|
233
|
+
transport="streamable_http",
|
|
234
|
+
url=url,
|
|
235
|
+
headers=headers,
|
|
236
|
+
)
|
|
237
|
+
elif transport == "sse":
|
|
238
|
+
return SSEConnection( # type: ignore[call-arg]
|
|
239
|
+
transport="sse",
|
|
240
|
+
url=url,
|
|
241
|
+
headers=headers,
|
|
242
|
+
)
|
|
243
|
+
else:
|
|
244
|
+
sync_mcp_env_aliases()
|
|
245
|
+
stdio_env = dict(os.environ)
|
|
246
|
+
raw_env = resolved_config.get("env")
|
|
247
|
+
if isinstance(raw_env, dict):
|
|
248
|
+
stdio_env.update(raw_env)
|
|
249
|
+
raw_args = resolved_config.get("args")
|
|
250
|
+
args_list = list(raw_args) if isinstance(raw_args, (list, tuple)) else []
|
|
251
|
+
conn_kwargs: dict[str, Any] = {
|
|
252
|
+
"transport": "stdio",
|
|
253
|
+
"command": str(resolved_config.get("command") or ""),
|
|
254
|
+
"args": args_list,
|
|
255
|
+
"env": stdio_env,
|
|
256
|
+
}
|
|
257
|
+
if resolved_config.get("cwd"):
|
|
258
|
+
conn_kwargs["cwd"] = str(resolved_config["cwd"])
|
|
259
|
+
return cast("StdioConnection", conn_kwargs)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _build_cached_mcp_tool(
|
|
264
|
+
*,
|
|
265
|
+
mcp_tool: Any,
|
|
266
|
+
server_name: str,
|
|
267
|
+
session_manager: MCPSessionManager,
|
|
268
|
+
name_prefix_sep: str = ":",
|
|
269
|
+
) -> BaseTool:
|
|
270
|
+
from langchain_mcp_adapters.tools import (
|
|
271
|
+
_convert_call_tool_result,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
orig_name = getattr(mcp_tool, "original_name", None)
|
|
275
|
+
if isinstance(orig_name, str) and orig_name:
|
|
276
|
+
original_tool_name = orig_name
|
|
277
|
+
lc_tool_name = str(getattr(mcp_tool, "name", orig_name))
|
|
278
|
+
else:
|
|
279
|
+
raw_tool_name = getattr(mcp_tool, "name", str(mcp_tool))
|
|
280
|
+
if isinstance(raw_tool_name, str) and ":" in raw_tool_name and (not server_name or raw_tool_name.startswith(f"{server_name}:")):
|
|
281
|
+
original_tool_name = raw_tool_name.split(":", 1)[1]
|
|
282
|
+
else:
|
|
283
|
+
original_tool_name = raw_tool_name
|
|
284
|
+
lc_tool_name = f"{server_name}{name_prefix_sep}{original_tool_name}" if server_name else original_tool_name
|
|
285
|
+
description = getattr(mcp_tool, "description", "") or f"MCP tool {original_tool_name} from {server_name}"
|
|
286
|
+
raw_input_schema = getattr(mcp_tool, "inputSchema", None) or getattr(mcp_tool, "input_schema", None) or {}
|
|
287
|
+
input_schema = _clean_mcp_schema(raw_input_schema)
|
|
288
|
+
|
|
289
|
+
metadata: dict[str, Any] = {
|
|
290
|
+
"_opscloud_mcp": True,
|
|
291
|
+
"_mcp_server": server_name,
|
|
292
|
+
"_mcp_original_name": original_tool_name,
|
|
293
|
+
}
|
|
294
|
+
from opscloud.mcp.semantic_profiler import MCPSemanticProfiler
|
|
295
|
+
|
|
296
|
+
profiler = MCPSemanticProfiler.get_instance()
|
|
297
|
+
profile = profiler.get_profile(server_name, original_tool_name)
|
|
298
|
+
if profile is None:
|
|
299
|
+
profile = profiler.heuristic_profile(mcp_tool, server_name=server_name)
|
|
300
|
+
profiler.register_profile(server_name, original_tool_name, profile)
|
|
301
|
+
|
|
302
|
+
metadata["inferred_tier"] = profile.inferred_tier
|
|
303
|
+
metadata["readOnlyHint"] = profile.read_only_hint
|
|
304
|
+
metadata["destructiveHint"] = profile.destructive_hint
|
|
305
|
+
metadata["sensitive_arguments"] = profile.sensitive_arguments
|
|
306
|
+
metadata["justification"] = profile.justification
|
|
307
|
+
|
|
308
|
+
def _handle_tool_error(error: ToolException) -> str:
|
|
309
|
+
raw_msg = str(error) or f"{lc_tool_name} failed with no error detail"
|
|
310
|
+
logger.info("MCP tool %r returned error: %s", lc_tool_name, raw_msg)
|
|
311
|
+
return _enhance_mcp_error_diagnostics(raw_msg)
|
|
312
|
+
|
|
313
|
+
async def coroutine(
|
|
314
|
+
runtime: Any = None,
|
|
315
|
+
**arguments: Any,
|
|
316
|
+
) -> Any:
|
|
317
|
+
"""Execute the remote MCP tool with safety checks and error handling.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
runtime: Optional LangGraph tool runtime context.
|
|
321
|
+
**arguments: Keyword arguments to pass to the MCP tool.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
Any: Tool call result payload.
|
|
325
|
+
"""
|
|
326
|
+
import time
|
|
327
|
+
|
|
328
|
+
start_time = time.perf_counter()
|
|
329
|
+
logger.info(
|
|
330
|
+
"Invoking MCP tool '%s' on server '%s'",
|
|
331
|
+
original_tool_name,
|
|
332
|
+
server_name,
|
|
333
|
+
extra={"mcp_server": server_name, "tool_name": original_tool_name},
|
|
334
|
+
)
|
|
335
|
+
cleaned_args = _normalize_mcp_arguments(arguments, input_schema)
|
|
336
|
+
session = await session_manager.get_session(server_name)
|
|
337
|
+
try:
|
|
338
|
+
result = await session.call_tool(original_tool_name, cleaned_args)
|
|
339
|
+
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit, ToolException):
|
|
340
|
+
raise
|
|
341
|
+
except Exception as exc:
|
|
342
|
+
if not _is_transient_session_error(exc):
|
|
343
|
+
duration_ms = (time.perf_counter() - start_time) * 1000.0
|
|
344
|
+
logger.error(
|
|
345
|
+
"MCP tool '%s' execution failed: %s",
|
|
346
|
+
original_tool_name,
|
|
347
|
+
exc,
|
|
348
|
+
exc_info=True,
|
|
349
|
+
extra={
|
|
350
|
+
"mcp_server": server_name,
|
|
351
|
+
"tool_name": original_tool_name,
|
|
352
|
+
"duration_ms": round(duration_ms, 2),
|
|
353
|
+
},
|
|
354
|
+
)
|
|
355
|
+
msg = f"MCP tool {lc_tool_name!r} failed on server {server_name!r}: {exc}"
|
|
356
|
+
raise ToolException(_enhance_mcp_error_diagnostics(msg)) from None
|
|
357
|
+
|
|
358
|
+
# Transient connection failure handling:
|
|
359
|
+
# Consistent with dcode's connection loss safety, do not automatically replay
|
|
360
|
+
# mutating or destructive operations on a severed socket because the operation
|
|
361
|
+
# may have already taken effect remotely on AWS.
|
|
362
|
+
is_read_only = bool(
|
|
363
|
+
profile.read_only_hint
|
|
364
|
+
or (profile.inferred_tier and getattr(profile.inferred_tier, "value", str(profile.inferred_tier)) == "read_only")
|
|
365
|
+
)
|
|
366
|
+
if not is_read_only:
|
|
367
|
+
await session_manager.invalidate(server_name)
|
|
368
|
+
msg = (
|
|
369
|
+
f"Connection lost while calling MCP tool {lc_tool_name!r}. "
|
|
370
|
+
"The operation may have already been executed by AWS/cloud provider and was not "
|
|
371
|
+
"automatically retried to prevent unintended duplicate side effects. "
|
|
372
|
+
"Please verify the current resource state before trying again."
|
|
373
|
+
)
|
|
374
|
+
logger.warning("MCP connection lost on mutating tool %r; avoiding blind replay", lc_tool_name)
|
|
375
|
+
raise ToolException(_enhance_mcp_error_diagnostics(msg)) from None
|
|
376
|
+
|
|
377
|
+
logger.info("MCP session for %r dead; retrying read-only tool once", server_name, extra={"mcp_server": server_name})
|
|
378
|
+
await session_manager.invalidate(server_name)
|
|
379
|
+
retry_session = await session_manager.get_session(server_name)
|
|
380
|
+
try:
|
|
381
|
+
result = await retry_session.call_tool(original_tool_name, cleaned_args)
|
|
382
|
+
except Exception as retry_exc:
|
|
383
|
+
duration_ms = (time.perf_counter() - start_time) * 1000.0
|
|
384
|
+
logger.error(
|
|
385
|
+
"MCP tool '%s' execution failed: %s",
|
|
386
|
+
original_tool_name,
|
|
387
|
+
retry_exc,
|
|
388
|
+
exc_info=True,
|
|
389
|
+
extra={
|
|
390
|
+
"mcp_server": server_name,
|
|
391
|
+
"tool_name": original_tool_name,
|
|
392
|
+
"duration_ms": round(duration_ms, 2),
|
|
393
|
+
},
|
|
394
|
+
)
|
|
395
|
+
msg = f"MCP tool {lc_tool_name!r} failed after retry on server {server_name!r}: {retry_exc}"
|
|
396
|
+
raise ToolException(_enhance_mcp_error_diagnostics(msg)) from None
|
|
397
|
+
|
|
398
|
+
duration_ms = (time.perf_counter() - start_time) * 1000.0
|
|
399
|
+
logger.info(
|
|
400
|
+
"MCP tool '%s' executed in %.2fms",
|
|
401
|
+
original_tool_name,
|
|
402
|
+
duration_ms,
|
|
403
|
+
extra={
|
|
404
|
+
"mcp_server": server_name,
|
|
405
|
+
"tool_name": original_tool_name,
|
|
406
|
+
"duration_ms": round(duration_ms, 2),
|
|
407
|
+
},
|
|
408
|
+
)
|
|
409
|
+
try:
|
|
410
|
+
converted = _convert_call_tool_result(result)
|
|
411
|
+
if isinstance(converted, tuple) and len(converted) == 2:
|
|
412
|
+
return converted
|
|
413
|
+
return converted, None
|
|
414
|
+
except ToolException:
|
|
415
|
+
# Let ToolException (including _MCPToolExecutionError) propagate to LangChain's handle_tool_error
|
|
416
|
+
raise
|
|
417
|
+
except Exception as exc:
|
|
418
|
+
logger.warning(
|
|
419
|
+
"Fallback formatting for MCP tool '%s' on server '%s': %s",
|
|
420
|
+
original_tool_name,
|
|
421
|
+
server_name,
|
|
422
|
+
exc,
|
|
423
|
+
extra={"mcp_server": server_name, "tool_name": original_tool_name},
|
|
424
|
+
)
|
|
425
|
+
content = getattr(result, "content", None)
|
|
426
|
+
if content is not None:
|
|
427
|
+
return content, None
|
|
428
|
+
return str(result), None
|
|
429
|
+
|
|
430
|
+
return StructuredTool(
|
|
431
|
+
name=lc_tool_name,
|
|
432
|
+
description=description,
|
|
433
|
+
args_schema=input_schema if isinstance(input_schema, dict) else {},
|
|
434
|
+
coroutine=coroutine,
|
|
435
|
+
response_format="content_and_artifact",
|
|
436
|
+
metadata=metadata,
|
|
437
|
+
handle_tool_error=cast("Any", _handle_tool_error),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def build_mcp_tools_from_server_infos(
|
|
442
|
+
server_infos: Sequence[MCPServerInfo],
|
|
443
|
+
session_manager: MCPSessionManager,
|
|
444
|
+
) -> list[BaseTool]:
|
|
445
|
+
"""Build LangChain StructuredTools from preloaded/probed MCPServerInfo list without opening persistent sessions eagerly."""
|
|
446
|
+
tools: list[BaseTool] = []
|
|
447
|
+
for info in server_infos:
|
|
448
|
+
if not info.enabled or info.status != "ok":
|
|
449
|
+
continue
|
|
450
|
+
for t in info.tools:
|
|
451
|
+
tool_obj = _build_cached_mcp_tool(
|
|
452
|
+
mcp_tool=t,
|
|
453
|
+
server_name=info.name,
|
|
454
|
+
session_manager=session_manager,
|
|
455
|
+
name_prefix_sep=":",
|
|
456
|
+
)
|
|
457
|
+
tools.append(tool_obj)
|
|
458
|
+
return tools
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class MCPSessionManager:
|
|
462
|
+
"""Manages connections to active MCP servers with connection pooling."""
|
|
463
|
+
|
|
464
|
+
_instance: MCPSessionManager | None = None
|
|
465
|
+
|
|
466
|
+
@classmethod
|
|
467
|
+
def get_instance(cls, mcp_config: dict[str, Any] | None = None, *args: Any, **kwargs: Any) -> MCPSessionManager:
|
|
468
|
+
"""Retrieve the singleton instance of MCPSessionManager.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
mcp_config: Optional MCP server configuration dictionary.
|
|
472
|
+
*args: Positional arguments for instance creation.
|
|
473
|
+
**kwargs: Keyword arguments for instance creation.
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
MCPSessionManager: The singleton session manager instance.
|
|
477
|
+
"""
|
|
478
|
+
if cls._instance is None:
|
|
479
|
+
cls._instance = cls(mcp_config)
|
|
480
|
+
elif mcp_config is not None:
|
|
481
|
+
cls._instance.register_servers(mcp_config)
|
|
482
|
+
return cls._instance
|
|
483
|
+
|
|
484
|
+
def __init__(self, mcp_config: dict[str, Any] | None = None) -> None:
|
|
485
|
+
"""Initialize MCPSessionManager with server configuration.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
mcp_config: Optional configuration dictionary containing server settings.
|
|
489
|
+
"""
|
|
490
|
+
self._config: dict[str, Any] = mcp_config.get("mcpServers", mcp_config) if mcp_config else {}
|
|
491
|
+
self._sessions: dict[str, _MCPSessionEntry] = {}
|
|
492
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
493
|
+
self._lock = asyncio.Lock()
|
|
494
|
+
self._errors: dict[str, str] = {}
|
|
495
|
+
self._closed = False
|
|
496
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
497
|
+
|
|
498
|
+
def _get_server_lock(self, server_name: str) -> asyncio.Lock:
|
|
499
|
+
if server_name not in self._locks:
|
|
500
|
+
self._locks[server_name] = asyncio.Lock()
|
|
501
|
+
return self._locks[server_name]
|
|
502
|
+
|
|
503
|
+
@property
|
|
504
|
+
def sessions(self) -> dict[str, Any]:
|
|
505
|
+
"""Dictionary of active MCP sessions mapped by server name.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
dict[str, Any]: Mapping of server names to active session instances.
|
|
509
|
+
"""
|
|
510
|
+
return {name: entry.session for name, entry in self._sessions.items()}
|
|
511
|
+
|
|
512
|
+
def register_server(self, name: str, config: Mapping[str, Any]) -> None:
|
|
513
|
+
"""Register or update a single server config without affecting other servers."""
|
|
514
|
+
self._config[name] = dict(config)
|
|
515
|
+
self._closed = False
|
|
516
|
+
|
|
517
|
+
def register_servers(self, servers: Mapping[str, Any] | dict[str, Any]) -> None:
|
|
518
|
+
"""Register or update multiple server configs without affecting other servers."""
|
|
519
|
+
raw_cfg = servers.get("mcpServers", servers) if isinstance(servers, Mapping) else {}
|
|
520
|
+
if isinstance(raw_cfg, dict):
|
|
521
|
+
self._config.update(raw_cfg)
|
|
522
|
+
self._closed = False
|
|
523
|
+
|
|
524
|
+
async def get_session(self, server_name: str) -> Any:
|
|
525
|
+
"""Return a cached session for `server_name`, creating it if needed."""
|
|
526
|
+
entry = self._sessions.get(server_name)
|
|
527
|
+
if entry is not None:
|
|
528
|
+
return entry.session
|
|
529
|
+
|
|
530
|
+
lock = self._get_server_lock(server_name)
|
|
531
|
+
async with lock:
|
|
532
|
+
entry = self._sessions.get(server_name)
|
|
533
|
+
if entry is not None:
|
|
534
|
+
return entry.session
|
|
535
|
+
|
|
536
|
+
srv_config = self._config.get(server_name)
|
|
537
|
+
if not srv_config:
|
|
538
|
+
msg = f"No configuration found for MCP server {server_name!r}"
|
|
539
|
+
raise RuntimeError(msg)
|
|
540
|
+
|
|
541
|
+
await self.connect_server(server_name, srv_config)
|
|
542
|
+
entry = self._sessions.get(server_name)
|
|
543
|
+
if entry is None:
|
|
544
|
+
err = self._errors.get(server_name, "Unknown error")
|
|
545
|
+
msg = f"Failed to create session for MCP server {server_name!r}: {err}"
|
|
546
|
+
raise RuntimeError(msg)
|
|
547
|
+
return entry.session
|
|
548
|
+
|
|
549
|
+
async def invalidate(self, server_name: str) -> None:
|
|
550
|
+
"""Close and evict a cached session."""
|
|
551
|
+
lock = self._get_server_lock(server_name)
|
|
552
|
+
async with lock:
|
|
553
|
+
entry = self._sessions.pop(server_name, None)
|
|
554
|
+
if entry is not None:
|
|
555
|
+
try:
|
|
556
|
+
await entry.exit_stack.aclose()
|
|
557
|
+
except Exception as exc:
|
|
558
|
+
logger.debug("Error closing invalidated session %s: %s", server_name, exc)
|
|
559
|
+
|
|
560
|
+
def remove_server_config(self, server_name: str) -> None:
|
|
561
|
+
"""Remove configuration for a disabled or deleted server."""
|
|
562
|
+
self._config.pop(server_name, None)
|
|
563
|
+
|
|
564
|
+
async def remove_server(self, server_name: str) -> None:
|
|
565
|
+
"""Close any active session and remove configuration for server."""
|
|
566
|
+
await self.invalidate(server_name)
|
|
567
|
+
self.remove_server_config(server_name)
|
|
568
|
+
|
|
569
|
+
def sync_active_configs(self, active_configs: dict[str, Any], purge_missing: bool = False) -> list[str]:
|
|
570
|
+
"""Synchronize active configurations, returning names of removed servers."""
|
|
571
|
+
raw_cfg = active_configs.get("mcpServers", active_configs)
|
|
572
|
+
removed: list[str] = []
|
|
573
|
+
if purge_missing and isinstance(raw_cfg, dict):
|
|
574
|
+
for name in list(self._config.keys()):
|
|
575
|
+
if name not in raw_cfg:
|
|
576
|
+
self._config.pop(name, None)
|
|
577
|
+
removed.append(name)
|
|
578
|
+
if isinstance(raw_cfg, dict):
|
|
579
|
+
self._config.update(raw_cfg)
|
|
580
|
+
|
|
581
|
+
for r_name in removed:
|
|
582
|
+
if r_name in self._sessions:
|
|
583
|
+
try:
|
|
584
|
+
loop = asyncio.get_running_loop()
|
|
585
|
+
task = loop.create_task(self.invalidate(r_name))
|
|
586
|
+
self._background_tasks.add(task)
|
|
587
|
+
task.add_done_callback(self._background_tasks.discard)
|
|
588
|
+
except RuntimeError:
|
|
589
|
+
pass
|
|
590
|
+
return removed
|
|
591
|
+
|
|
592
|
+
def get_tool(self, name: str) -> BaseTool | None:
|
|
593
|
+
"""Find and return a cached LangChain BaseTool matching `name` or suffix."""
|
|
594
|
+
for entry in self._sessions.values():
|
|
595
|
+
for t in entry._cached_tools:
|
|
596
|
+
if t.name == name or t.name.endswith(f":{name}") or t.name.endswith(f"_{name}"):
|
|
597
|
+
return t
|
|
598
|
+
return None
|
|
599
|
+
|
|
600
|
+
async def get_tools(self) -> list[BaseTool]:
|
|
601
|
+
"""Return LangChain BaseTool instances from all enabled connected MCP servers."""
|
|
602
|
+
await self.connect_all()
|
|
603
|
+
all_tools: list[BaseTool] = []
|
|
604
|
+
for entry in self._sessions.values():
|
|
605
|
+
all_tools.extend(entry._cached_tools)
|
|
606
|
+
return all_tools
|
|
607
|
+
|
|
608
|
+
async def close_all(self) -> None:
|
|
609
|
+
"""Close all active sessions and clean up resources with bounded timeout."""
|
|
610
|
+
async with self._lock:
|
|
611
|
+
for name, entry in list(self._sessions.items()):
|
|
612
|
+
try:
|
|
613
|
+
await asyncio.wait_for(entry.exit_stack.aclose(), timeout=5.0)
|
|
614
|
+
except TimeoutError:
|
|
615
|
+
logger.warning("MCP session %s teardown timed out after 5.0s", name)
|
|
616
|
+
except Exception as exc:
|
|
617
|
+
logger.debug("Error closing MCP session %s: %s", name, exc)
|
|
618
|
+
self._sessions.clear()
|
|
619
|
+
self._errors.clear()
|
|
620
|
+
self._closed = True
|
|
621
|
+
|
|
622
|
+
async def cleanup(self) -> None:
|
|
623
|
+
"""Alias to close_all() for lifecycle shutdown."""
|
|
624
|
+
await self.close_all()
|
|
625
|
+
|
|
626
|
+
async def connect_server(self, name: str, config: Mapping[str, Any]) -> bool:
|
|
627
|
+
"""Connect to a single MCP server and initialize its tools."""
|
|
628
|
+
enabled = bool(config.get("enabled", True))
|
|
629
|
+
if not enabled:
|
|
630
|
+
return False
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
resolved_config = resolve_mcp_server_env(name, config)
|
|
634
|
+
except Exception as exc:
|
|
635
|
+
self._errors[name] = str(exc)
|
|
636
|
+
logger.warning("Failed to resolve env for MCP server %s: %s", name, exc)
|
|
637
|
+
return False
|
|
638
|
+
|
|
639
|
+
transport = (
|
|
640
|
+
resolved_config.get("type")
|
|
641
|
+
or resolved_config.get("transport")
|
|
642
|
+
or ("sse" if "sse" in str(resolved_config.get("url", "")).lower() else "http")
|
|
643
|
+
if resolved_config.get("url")
|
|
644
|
+
else "stdio"
|
|
645
|
+
)
|
|
646
|
+
logger.info(
|
|
647
|
+
"Initializing MCP client session for server '%s'",
|
|
648
|
+
name,
|
|
649
|
+
extra={"mcp_server": name, "transport": transport},
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
exit_stack = AsyncExitStack()
|
|
653
|
+
try:
|
|
654
|
+
from langchain_mcp_adapters.sessions import create_session
|
|
655
|
+
|
|
656
|
+
conn = create_mcp_connection(resolved_config)
|
|
657
|
+
|
|
658
|
+
session = await asyncio.wait_for(
|
|
659
|
+
exit_stack.enter_async_context(create_session(conn)),
|
|
660
|
+
timeout=_CONNECT_TIMEOUT,
|
|
661
|
+
)
|
|
662
|
+
await asyncio.wait_for(session.initialize(), timeout=_CONNECT_TIMEOUT)
|
|
663
|
+
|
|
664
|
+
# Discover and wrap tools
|
|
665
|
+
tools_result = await asyncio.wait_for(session.list_tools(), timeout=_CONNECT_TIMEOUT)
|
|
666
|
+
raw_tools = getattr(tools_result, "tools", []) or []
|
|
667
|
+
logger.info(
|
|
668
|
+
"Discovered %d tools from MCP server '%s'",
|
|
669
|
+
len(raw_tools),
|
|
670
|
+
name,
|
|
671
|
+
extra={
|
|
672
|
+
"mcp_server": name,
|
|
673
|
+
"tool_count": len(raw_tools),
|
|
674
|
+
"tools": [getattr(t, "name", str(t)) for t in raw_tools],
|
|
675
|
+
},
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
raw_disabled = resolved_config.get("disabled_tools") or resolved_config.get("disabledTools") or ()
|
|
679
|
+
raw_allowed = resolved_config.get("allowed_tools") or resolved_config.get("allowedTools") or ()
|
|
680
|
+
disabled_tools = tuple(raw_disabled) if isinstance(raw_disabled, (list, tuple)) else ()
|
|
681
|
+
allowed_tools = tuple(raw_allowed) if isinstance(raw_allowed, (list, tuple)) else ()
|
|
682
|
+
|
|
683
|
+
wrapped_tools: list[BaseTool] = []
|
|
684
|
+
for t in raw_tools:
|
|
685
|
+
raw_name = getattr(t, "name", str(t))
|
|
686
|
+
if not _filter_tool_name(
|
|
687
|
+
raw_name,
|
|
688
|
+
allowed_tools=allowed_tools,
|
|
689
|
+
disabled_tools=disabled_tools,
|
|
690
|
+
):
|
|
691
|
+
continue
|
|
692
|
+
|
|
693
|
+
tool_obj = _build_cached_mcp_tool(
|
|
694
|
+
mcp_tool=t,
|
|
695
|
+
server_name=name,
|
|
696
|
+
session_manager=self,
|
|
697
|
+
name_prefix_sep=":",
|
|
698
|
+
)
|
|
699
|
+
wrapped_tools.append(tool_obj)
|
|
700
|
+
|
|
701
|
+
entry = _MCPSessionEntry(session, exit_stack)
|
|
702
|
+
entry._cached_tools = wrapped_tools
|
|
703
|
+
|
|
704
|
+
self._sessions[name] = entry
|
|
705
|
+
self._errors.pop(name, None)
|
|
706
|
+
return True
|
|
707
|
+
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
|
|
708
|
+
raise
|
|
709
|
+
except BaseException as exc:
|
|
710
|
+
try:
|
|
711
|
+
await exit_stack.aclose()
|
|
712
|
+
except BaseException as close_exc:
|
|
713
|
+
logger.debug("Error closing exit stack for MCP server %s: %s", name, close_exc)
|
|
714
|
+
self._errors[name] = str(exc)
|
|
715
|
+
logger.warning("Failed to connect to MCP server %s: %s", name, exc)
|
|
716
|
+
return False
|
|
717
|
+
|
|
718
|
+
async def connect_all(self) -> None:
|
|
719
|
+
"""Connect to all configured enabled MCP servers in parallel."""
|
|
720
|
+
async with self._lock:
|
|
721
|
+
tasks = [
|
|
722
|
+
self.connect_server(name, config)
|
|
723
|
+
for name, config in self._config.items()
|
|
724
|
+
if config.get("enabled", True) and name not in self._sessions
|
|
725
|
+
]
|
|
726
|
+
if tasks:
|
|
727
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|