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.
Files changed (309) hide show
  1. opscloud/__init__.py +5 -0
  2. opscloud/__main__.py +6 -0
  3. opscloud/_constants.py +53 -0
  4. opscloud/_debug.py +145 -0
  5. opscloud/_debug_buffer.py +250 -0
  6. opscloud/_textual_patches.py +367 -0
  7. opscloud/_version.py +15 -0
  8. opscloud/agent/__init__.py +27 -0
  9. opscloud/agent/config.py +168 -0
  10. opscloud/agent/factory.py +1103 -0
  11. opscloud/approval_mode.py +44 -0
  12. opscloud/backend/__init__.py +49 -0
  13. opscloud/backend/composite.py +89 -0
  14. opscloud/backend/local.py +183 -0
  15. opscloud/backend/registry.py +79 -0
  16. opscloud/backend/sandbox_config.py +107 -0
  17. opscloud/backend/sandbox_factory.py +432 -0
  18. opscloud/built_in_skills/remember/SKILL.md +124 -0
  19. opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
  20. opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
  21. opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
  22. opscloud/cli/__init__.py +1 -0
  23. opscloud/cli/commands/__init__.py +15 -0
  24. opscloud/cli/commands/auth.py +45 -0
  25. opscloud/cli/commands/doctor.py +100 -0
  26. opscloud/cli/commands/mcp.py +137 -0
  27. opscloud/cli/commands/threads.py +51 -0
  28. opscloud/cli/interactive.py +154 -0
  29. opscloud/cli/main.py +903 -0
  30. opscloud/cli/non_interactive.py +425 -0
  31. opscloud/cli/server_manager.py +58 -0
  32. opscloud/commands/__init__.py +19 -0
  33. opscloud/commands/_base.py +110 -0
  34. opscloud/commands/_guards.py +52 -0
  35. opscloud/commands/_router.py +146 -0
  36. opscloud/commands/_types.py +36 -0
  37. opscloud/commands/core/__init__.py +47 -0
  38. opscloud/commands/core/auth.py +94 -0
  39. opscloud/commands/core/bug.py +46 -0
  40. opscloud/commands/core/clear.py +106 -0
  41. opscloud/commands/core/cloud.py +185 -0
  42. opscloud/commands/core/compact.py +128 -0
  43. opscloud/commands/core/config_cmd.py +120 -0
  44. opscloud/commands/core/context.py +220 -0
  45. opscloud/commands/core/cost.py +44 -0
  46. opscloud/commands/core/doctor.py +535 -0
  47. opscloud/commands/core/effort.py +108 -0
  48. opscloud/commands/core/exit_cmd.py +41 -0
  49. opscloud/commands/core/fast.py +148 -0
  50. opscloud/commands/core/help_cmd.py +94 -0
  51. opscloud/commands/core/mcp.py +134 -0
  52. opscloud/commands/core/model.py +109 -0
  53. opscloud/commands/core/permissions.py +196 -0
  54. opscloud/commands/core/plugins.py +92 -0
  55. opscloud/commands/core/pool.py +108 -0
  56. opscloud/commands/core/resume.py +97 -0
  57. opscloud/commands/core/skills.py +178 -0
  58. opscloud/commands/power/__init__.py +51 -0
  59. opscloud/commands/power/agents.py +98 -0
  60. opscloud/commands/power/btw.py +85 -0
  61. opscloud/commands/power/copy.py +97 -0
  62. opscloud/commands/power/goal.py +548 -0
  63. opscloud/commands/power/loop.py +295 -0
  64. opscloud/commands/power/memory.py +287 -0
  65. opscloud/commands/power/review.py +152 -0
  66. opscloud/commands/power/rubric.py +287 -0
  67. opscloud/commands/power/runtime.py +321 -0
  68. opscloud/commands/power/skill_creator.py +70 -0
  69. opscloud/commands/power/skill_invoke.py +165 -0
  70. opscloud/commands/power/tasks.py +209 -0
  71. opscloud/commands/power/trace.py +117 -0
  72. opscloud/commands/power/ui_toggles.py +135 -0
  73. opscloud/commands/power/version.py +52 -0
  74. opscloud/commands/registry.py +176 -0
  75. opscloud/config/__init__.py +114 -0
  76. opscloud/config/adapters/__init__.py +5 -0
  77. opscloud/config/adapters/postgres.py +776 -0
  78. opscloud/config/adapters/sqlite.py +769 -0
  79. opscloud/config/aws.py +248 -0
  80. opscloud/config/cloud_profiles.py +437 -0
  81. opscloud/config/env_vars.py +81 -0
  82. opscloud/config/langsmith.py +319 -0
  83. opscloud/config/manifest.py +817 -0
  84. opscloud/config/metadata.py +205 -0
  85. opscloud/config/paths.py +604 -0
  86. opscloud/config/plugins.py +130 -0
  87. opscloud/config/settings.py +834 -0
  88. opscloud/config/store.py +453 -0
  89. opscloud/config/store_factory.py +97 -0
  90. opscloud/config/toml_config.py +562 -0
  91. opscloud/editor.py +173 -0
  92. opscloud/exceptions.py +65 -0
  93. opscloud/file_ops.py +217 -0
  94. opscloud/hooks/__init__.py +139 -0
  95. opscloud/hooks/env.py +44 -0
  96. opscloud/hooks/interrupt.py +121 -0
  97. opscloud/hooks/models/__init__.py +109 -0
  98. opscloud/hooks/models/adapters.py +19 -0
  99. opscloud/hooks/models/config.py +79 -0
  100. opscloud/hooks/models/domain.py +434 -0
  101. opscloud/hooks/models/transport.py +37 -0
  102. opscloud/hooks/tools.py +231 -0
  103. opscloud/input.py +48 -0
  104. opscloud/integrations/__init__.py +80 -0
  105. opscloud/integrations/base.py +153 -0
  106. opscloud/integrations/content_filter.py +119 -0
  107. opscloud/integrations/event_bus.py +480 -0
  108. opscloud/integrations/hooks.py +238 -0
  109. opscloud/integrations/identity.py +181 -0
  110. opscloud/integrations/notifications.py +134 -0
  111. opscloud/integrations/stream_bridge.py +397 -0
  112. opscloud/integrations/thread_store.py +81 -0
  113. opscloud/json_types.py +20 -0
  114. opscloud/mcp/__init__.py +57 -0
  115. opscloud/mcp/config.py +203 -0
  116. opscloud/mcp/discovery.py +229 -0
  117. opscloud/mcp/mcp_info.py +109 -0
  118. opscloud/mcp/middleware.py +102 -0
  119. opscloud/mcp/preload.py +505 -0
  120. opscloud/mcp/raw_config.py +149 -0
  121. opscloud/mcp/semantic_profiler.py +305 -0
  122. opscloud/mcp/session_manager.py +727 -0
  123. opscloud/mcp/trust.py +105 -0
  124. opscloud/memory/__init__.py +27 -0
  125. opscloud/memory/branch.py +61 -0
  126. opscloud/memory/guard.py +19 -0
  127. opscloud/memory/onboarding.py +120 -0
  128. opscloud/memory/registry.py +134 -0
  129. opscloud/memory/store.py +223 -0
  130. opscloud/middleware/__init__.py +106 -0
  131. opscloud/middleware/_repository_bounds.py +459 -0
  132. opscloud/middleware/ask_user.py +152 -0
  133. opscloud/middleware/auto_mode.py +60 -0
  134. opscloud/middleware/auto_mode_hitl.py +2434 -0
  135. opscloud/middleware/compaction.py +566 -0
  136. opscloud/middleware/configurable_model.py +233 -0
  137. opscloud/middleware/cost_tracking.py +1021 -0
  138. opscloud/middleware/glm_stall_recovery.py +109 -0
  139. opscloud/middleware/goal_criteria.py +1234 -0
  140. opscloud/middleware/goal_state_notice.py +712 -0
  141. opscloud/middleware/goal_tools.py +407 -0
  142. opscloud/middleware/headless_mcp_guard.py +88 -0
  143. opscloud/middleware/jev_model_router.py +1018 -0
  144. opscloud/middleware/local_context.py +460 -0
  145. opscloud/middleware/mcp_context.py +151 -0
  146. opscloud/middleware/mcp_middleware.py +146 -0
  147. opscloud/middleware/memory_guard.py +393 -0
  148. opscloud/middleware/model_retry.py +205 -0
  149. opscloud/middleware/registry.py +137 -0
  150. opscloud/middleware/reliable_rubric.py +709 -0
  151. opscloud/middleware/resume_state.py +91 -0
  152. opscloud/middleware/server_hooks.py +1189 -0
  153. opscloud/middleware/shell_allow_list.py +90 -0
  154. opscloud/middleware/skills.py +382 -0
  155. opscloud/middleware/subagent_artifacts.py +84 -0
  156. opscloud/middleware/subagent_telemetry.py +261 -0
  157. opscloud/middleware/subagents.py +243 -0
  158. opscloud/middleware/tool_filter.py +384 -0
  159. opscloud/middleware/unified_system_message.py +62 -0
  160. opscloud/model/__init__.py +37 -0
  161. opscloud/model/config.py +2992 -0
  162. opscloud/model/factory.py +477 -0
  163. opscloud/model/pool.py +459 -0
  164. opscloud/model/reasoning.py +282 -0
  165. opscloud/offload.py +173 -0
  166. opscloud/output.py +64 -0
  167. opscloud/plugins/__init__.py +189 -0
  168. opscloud/plugins/_json.py +22 -0
  169. opscloud/plugins/adapters/__init__.py +13 -0
  170. opscloud/plugins/adapters/agents.py +112 -0
  171. opscloud/plugins/adapters/commands.py +190 -0
  172. opscloud/plugins/adapters/mcp.py +363 -0
  173. opscloud/plugins/adapters/skills.py +132 -0
  174. opscloud/plugins/commands_cli.py +541 -0
  175. opscloud/plugins/discovery.py +512 -0
  176. opscloud/plugins/manifest.py +335 -0
  177. opscloud/plugins/marketplace.py +893 -0
  178. opscloud/plugins/models.py +334 -0
  179. opscloud/plugins/project_plugins.py +317 -0
  180. opscloud/plugins/protocol.py +31 -0
  181. opscloud/plugins/store.py +905 -0
  182. opscloud/plugins/substitution.py +122 -0
  183. opscloud/project_utils.py +145 -0
  184. opscloud/prompts/__init__.py +37 -0
  185. opscloud/prompts/system.py +483 -0
  186. opscloud/prompts/templates/system_prompt.md +166 -0
  187. opscloud/prompts/types.py +54 -0
  188. opscloud/rubrics/__init__.py +42 -0
  189. opscloud/rubrics/evaluator.py +404 -0
  190. opscloud/rubrics/evidence_extractor.py +229 -0
  191. opscloud/rubrics/generator.py +239 -0
  192. opscloud/rubrics/jev_compiler.py +175 -0
  193. opscloud/rubrics/jev_grader.py +297 -0
  194. opscloud/rubrics/middleware.py +8 -0
  195. opscloud/schema/__init__.py +107 -0
  196. opscloud/schema/interrupts.py +981 -0
  197. opscloud/security/__init__.py +91 -0
  198. opscloud/security/approval_mode.py +528 -0
  199. opscloud/security/approval_mode_source.py +230 -0
  200. opscloud/security/cli_ast_evaluator.py +552 -0
  201. opscloud/security/jev_classifier.py +651 -0
  202. opscloud/security/shell_safety.py +340 -0
  203. opscloud/security/unicode_security.py +439 -0
  204. opscloud/security/url_validation.py +160 -0
  205. opscloud/server/__init__.py +13 -0
  206. opscloud/server/_server_config.py +158 -0
  207. opscloud/server/server.py +242 -0
  208. opscloud/server/server_graph.py +93 -0
  209. opscloud/skills/__init__.py +91 -0
  210. opscloud/skills/commands.py +1099 -0
  211. opscloud/skills/invocation.py +200 -0
  212. opscloud/skills/load.py +388 -0
  213. opscloud/skills/loader.py +23 -0
  214. opscloud/skills/merge.py +65 -0
  215. opscloud/skills/registry.py +263 -0
  216. opscloud/skills/sources.py +174 -0
  217. opscloud/skills/trust.py +417 -0
  218. opscloud/state/__init__.py +142 -0
  219. opscloud/state/base.py +154 -0
  220. opscloud/state/cloud_context.py +262 -0
  221. opscloud/state/goal_channels.py +124 -0
  222. opscloud/state/goal_state_limits.py +175 -0
  223. opscloud/state/resume_state.py +58 -0
  224. opscloud/state/service_context.py +281 -0
  225. opscloud/state/session.py +957 -0
  226. opscloud/state/state_migration.py +86 -0
  227. opscloud/subagents/__init__.py +30 -0
  228. opscloud/subagents/loader.py +331 -0
  229. opscloud/subagents/subagents_parser.py +117 -0
  230. opscloud/subagents/types.py +25 -0
  231. opscloud/tools/__init__.py +36 -0
  232. opscloud/tools/catalog.py +37 -0
  233. opscloud/tools/display.py +24 -0
  234. opscloud/tools/fetch_url.py +176 -0
  235. opscloud/tools/goal_tools.py +423 -0
  236. opscloud/tools/registry.py +105 -0
  237. opscloud/tools/thread.py +32 -0
  238. opscloud/tools/web_search.py +215 -0
  239. opscloud/ui/__init__.py +119 -0
  240. opscloud/ui/app.py +5400 -0
  241. opscloud/ui/app.tcss +479 -0
  242. opscloud/ui/clipboard.py +147 -0
  243. opscloud/ui/command_registry.py +482 -0
  244. opscloud/ui/message_store.py +152 -0
  245. opscloud/ui/permission_store.py +360 -0
  246. opscloud/ui/preferences.py +113 -0
  247. opscloud/ui/prompt_manager.py +96 -0
  248. opscloud/ui/remote_client.py +505 -0
  249. opscloud/ui/textual_adapter.py +1269 -0
  250. opscloud/ui/theme.py +728 -0
  251. opscloud/ui/ui_help.py +415 -0
  252. opscloud/ui/widgets/__init__.py +383 -0
  253. opscloud/ui/widgets/_ask_user_types.py +81 -0
  254. opscloud/ui/widgets/_copy_spans.py +52 -0
  255. opscloud/ui/widgets/_inline_prompt.py +290 -0
  256. opscloud/ui/widgets/_js_eval_display.py +139 -0
  257. opscloud/ui/widgets/_links.py +175 -0
  258. opscloud/ui/widgets/_tool_stream.py +308 -0
  259. opscloud/ui/widgets/agent_selector.py +297 -0
  260. opscloud/ui/widgets/approval.py +741 -0
  261. opscloud/ui/widgets/ask_user.py +539 -0
  262. opscloud/ui/widgets/auth.py +695 -0
  263. opscloud/ui/widgets/auth_manager.py +214 -0
  264. opscloud/ui/widgets/auto_mode_notice.py +146 -0
  265. opscloud/ui/widgets/autocomplete.py +709 -0
  266. opscloud/ui/widgets/chat_input.py +805 -0
  267. opscloud/ui/widgets/cloud_selector.py +528 -0
  268. opscloud/ui/widgets/config_manager.py +652 -0
  269. opscloud/ui/widgets/debug_console.py +1269 -0
  270. opscloud/ui/widgets/devops_renderers.py +184 -0
  271. opscloud/ui/widgets/diff.py +237 -0
  272. opscloud/ui/widgets/effort_selector.py +166 -0
  273. opscloud/ui/widgets/goal_review.py +612 -0
  274. opscloud/ui/widgets/goal_status.py +57 -0
  275. opscloud/ui/widgets/infra_panel.py +141 -0
  276. opscloud/ui/widgets/install_confirm.py +114 -0
  277. opscloud/ui/widgets/loading.py +170 -0
  278. opscloud/ui/widgets/mcp_viewer.py +1204 -0
  279. opscloud/ui/widgets/messages.py +4461 -0
  280. opscloud/ui/widgets/model_selector.py +863 -0
  281. opscloud/ui/widgets/notification_center.py +108 -0
  282. opscloud/ui/widgets/notification_settings.py +119 -0
  283. opscloud/ui/widgets/operation_card.py +106 -0
  284. opscloud/ui/widgets/permissions_manager.py +667 -0
  285. opscloud/ui/widgets/plugin_manager.py +1212 -0
  286. opscloud/ui/widgets/pool_selector.py +376 -0
  287. opscloud/ui/widgets/preamble.py +53 -0
  288. opscloud/ui/widgets/skills_viewer.py +432 -0
  289. opscloud/ui/widgets/status.py +1037 -0
  290. opscloud/ui/widgets/subagent_panel.py +927 -0
  291. opscloud/ui/widgets/theme_selector.py +232 -0
  292. opscloud/ui/widgets/thread_selector.py +358 -0
  293. opscloud/ui/widgets/toast.py +31 -0
  294. opscloud/ui/widgets/tool_display.py +325 -0
  295. opscloud/ui/widgets/tool_renderers.py +234 -0
  296. opscloud/ui/widgets/tool_widgets.py +278 -0
  297. opscloud/ui/widgets/welcome.py +421 -0
  298. opscloud/ui/widgets/welcome_popup.py +102 -0
  299. opscloud/utils/__init__.py +24 -0
  300. opscloud/utils/cost_estimation.py +169 -0
  301. opscloud/utils/git.py +390 -0
  302. opscloud/utils/logger.py +571 -0
  303. opscloud/utils/session_stats.py +115 -0
  304. opscloud/utils/startup_error.py +37 -0
  305. talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
  306. talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
  307. talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
  308. talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
  309. 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)