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,363 @@
1
+ """Adapter from plugin and subagent MCP declarations to MCP configurations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ import json
7
+ import re
8
+ from hashlib import sha256
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from opscloud.plugins.substitution import plugin_environment, substitute_json
13
+ from opscloud.utils.logger import get_logger
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Mapping
17
+ from opscloud.plugins.models import PluginInstance
18
+ from opscloud.subagents.types import SubagentMetadata
19
+
20
+ logger = get_logger(__name__)
21
+
22
+ _MCP_NAME_PART_RE = re.compile(r"[^A-Za-z0-9_-]+")
23
+ _MCP_NAME_PART_LENGTH = 48
24
+
25
+
26
+ def _safe_mcp_name_part(value: str) -> str:
27
+ """Sanitize string to safe MCP identifier part."""
28
+ sanitized = _MCP_NAME_PART_RE.sub("_", value).strip("_")
29
+ if sanitized == value and sanitized and len(sanitized) <= _MCP_NAME_PART_LENGTH:
30
+ return sanitized
31
+ digest = sha256(value.encode()).hexdigest()[:8]
32
+ prefix = sanitized[:_MCP_NAME_PART_LENGTH] or "unnamed"
33
+ return f"{prefix}_{digest}"
34
+
35
+
36
+ def scoped_mcp_server_name(plugin_id: str, server_name: str) -> str:
37
+ """Namespace a plugin-declared MCP server's name under its plugin id.
38
+
39
+ Args:
40
+ plugin_id: Full plugin id in `name@marketplace` form.
41
+ server_name: Unscoped server name from plugin config.
42
+
43
+ Returns:
44
+ Scoped server name safe for MCP session manager.
45
+ """
46
+ plugin_part = _safe_mcp_name_part(plugin_id)
47
+ server_part = _safe_mcp_name_part(server_name)
48
+ return f"plugin__{plugin_part}__{server_part}"
49
+
50
+
51
+ def scoped_subagent_mcp_server_name(subagent_name: str, server_name: str) -> str:
52
+ """Namespace a subagent-declared MCP server under its subagent name.
53
+
54
+ Args:
55
+ subagent_name: Subagent identifier (e.g. 'helm-operator').
56
+ server_name: Declared server name (e.g. 'talkops-helm-mcp-server').
57
+
58
+ Returns:
59
+ Scoped server name: `subagent__{subagent}__{server}`.
60
+ """
61
+ sub_part = _safe_mcp_name_part(subagent_name)
62
+ server_part = _safe_mcp_name_part(server_name)
63
+ return f"subagent__{sub_part}__{server_part}"
64
+
65
+
66
+ def _mcp_server_needs_login(server: object) -> bool:
67
+ """Return whether an MCP server config typically requires interactive login."""
68
+ if not isinstance(server, dict):
69
+ return False
70
+ server_type = server.get("type") or server.get("transport")
71
+ if server_type in {"http", "sse"}:
72
+ return True
73
+ return isinstance(server.get("url"), str)
74
+
75
+
76
+ def _server_map(raw: object) -> dict[str, Any]:
77
+ """Extract the server-name to config map from a decoded MCP document."""
78
+ if not isinstance(raw, dict):
79
+ return {}
80
+ wrapped = raw.get("mcpServers")
81
+ if isinstance(wrapped, dict):
82
+ return dict(wrapped)
83
+ codex_wrapped = raw.get("mcp_servers")
84
+ if isinstance(codex_wrapped, dict):
85
+ return dict(codex_wrapped)
86
+ return dict(raw)
87
+
88
+
89
+ def _try_repair_mcp_json(text: str) -> dict[str, Any] | None:
90
+ """Attempt heuristic repair on slightly malformed MCP JSON files."""
91
+ repaired = re.sub(r'(\})\s*(\"[A-Za-z0-9_-]+\"\s*:)', r'\1,\n\2', text)
92
+ open_braces = repaired.count("{") - repaired.count("}")
93
+ if open_braces > 0:
94
+ repaired += "\n" + ("}" * open_braces)
95
+ try:
96
+ data = json.loads(repaired)
97
+ if isinstance(data, dict):
98
+ return data
99
+ except Exception:
100
+ pass
101
+ return None
102
+
103
+
104
+ def _load_mcp_server_map(path: Path) -> dict[str, Any]:
105
+ """Load an MCP config file and extract its server-name to config map."""
106
+ try:
107
+ content = path.read_text(encoding="utf-8")
108
+ try:
109
+ raw = json.loads(content)
110
+ except json.JSONDecodeError:
111
+ raw = _try_repair_mcp_json(content)
112
+ if raw is None:
113
+ raise
114
+ except (OSError, json.JSONDecodeError) as exc:
115
+ logger.warning("Skipping MCP config %s: %s", path, exc)
116
+ return {}
117
+ return _server_map(raw)
118
+
119
+
120
+ def _plugin_mcp_server_map(plugin: PluginInstance) -> dict[str, Any]:
121
+ """Load a plugin's declared MCP servers without creating runtime state."""
122
+ servers: dict[str, Any] = {}
123
+ mcp_files = getattr(plugin.inventory, "mcp_files", ())
124
+ for path in mcp_files:
125
+ if path.suffix in {".mcpb", ".dxt"}:
126
+ continue
127
+ servers.update(_load_mcp_server_map(path))
128
+ if plugin.manifest and getattr(plugin.manifest, "inline_mcp", None):
129
+ servers.update(_server_map(plugin.manifest.inline_mcp))
130
+ return servers
131
+
132
+
133
+ def plugin_mcp_server_entries(
134
+ plugin: PluginInstance,
135
+ ) -> tuple[tuple[str, str, bool], ...]:
136
+ """List plugin MCP servers as `(label, scoped_name, needs_login)` tuples."""
137
+ servers = _plugin_mcp_server_map(plugin)
138
+ entries: list[tuple[str, str, bool]] = []
139
+ seen: set[str] = set()
140
+ for name, server in servers.items():
141
+ if not isinstance(name, str) or name in seen:
142
+ continue
143
+ seen.add(name)
144
+ entries.append(
145
+ (
146
+ name,
147
+ scoped_mcp_server_name(plugin.plugin_id, name),
148
+ _mcp_server_needs_login(server),
149
+ ),
150
+ )
151
+ return tuple(entries)
152
+
153
+
154
+ def _normalize_server(
155
+ server: object,
156
+ *,
157
+ root: Path,
158
+ data_dir: Path | None = None,
159
+ project_dir: Path | None = None,
160
+ ) -> dict[str, Any]:
161
+ """Normalize server dictionary and substitute path variables."""
162
+ if not isinstance(server, dict):
163
+ return {}
164
+ substituted = substitute_json(
165
+ dict(server),
166
+ plugin_root=root,
167
+ plugin_data=data_dir,
168
+ project_dir=project_dir,
169
+ )
170
+ if isinstance(substituted, dict):
171
+ cwd = substituted.get("cwd")
172
+ if isinstance(cwd, str) and cwd and not Path(cwd).is_absolute():
173
+ substituted["cwd"] = str((root / cwd).resolve())
174
+ env = substituted.get("env")
175
+ plugin_env = plugin_environment(
176
+ plugin_root=root,
177
+ plugin_data=data_dir,
178
+ project_dir=project_dir,
179
+ )
180
+ if isinstance(env, dict):
181
+ substituted["env"] = {**plugin_env, **env}
182
+ else:
183
+ substituted["env"] = plugin_env
184
+ return substituted
185
+ return {}
186
+
187
+
188
+ def plugin_mcp_configs(
189
+ plugins: tuple[PluginInstance, ...] | list[PluginInstance],
190
+ *,
191
+ project_dir: Path | None = None,
192
+ include_subagents: bool = False,
193
+ ) -> list[dict[str, Any]]:
194
+ """Build MCP config layers for enabled plugins.
195
+
196
+ Returns:
197
+ List of server dicts with scoped server names.
198
+ """
199
+ configs: list[dict[str, Any]] = []
200
+ for plugin in plugins:
201
+ if not include_subagents and plugin.is_agent_plugin:
202
+ continue # Dynamic subagent plugins isolate their own MCP servers
203
+
204
+ if plugin.data_dir is not None:
205
+ try:
206
+ plugin.data_dir.mkdir(parents=True, exist_ok=True)
207
+ except OSError:
208
+ logger.warning(
209
+ "Could not create plugin data dir for %s",
210
+ plugin.plugin_id,
211
+ )
212
+ servers = _plugin_mcp_server_map(plugin)
213
+ scoped: dict[str, Any] = {}
214
+ for server_name, server in servers.items():
215
+ if not isinstance(server_name, str):
216
+ continue
217
+ scoped_name = scoped_mcp_server_name(plugin.plugin_id, server_name)
218
+ scoped[scoped_name] = _normalize_server(
219
+ server,
220
+ root=plugin.root,
221
+ data_dir=plugin.data_dir,
222
+ project_dir=project_dir,
223
+ )
224
+ if scoped:
225
+ configs.append(scoped)
226
+ return configs
227
+
228
+
229
+ def subagent_mcp_configs(
230
+ subagent_name: str,
231
+ bundle_dir: Path,
232
+ mcp_files: Sequence[str | Path] | None = None,
233
+ *,
234
+ project_dir: Path | None = None,
235
+ ) -> dict[str, Any]:
236
+ """Build MCP configs for a built-in or custom subagent."""
237
+ if mcp_files is None:
238
+ target_files = [
239
+ candidate
240
+ for candidate in (bundle_dir / ".mcp.json", bundle_dir / "mcp.json")
241
+ if candidate.is_file()
242
+ ]
243
+ else:
244
+ target_files = [
245
+ f if isinstance(f, Path) else (bundle_dir / f) for f in mcp_files
246
+ ]
247
+
248
+ normalized: dict[str, Any] = {}
249
+ data_dir = bundle_dir / ".data"
250
+
251
+ for file_path in target_files:
252
+ raw_servers = _load_mcp_server_map(file_path)
253
+ for name, server in raw_servers.items():
254
+ norm_srv = _normalize_server(
255
+ server,
256
+ root=bundle_dir,
257
+ data_dir=data_dir,
258
+ project_dir=project_dir,
259
+ )
260
+ normalized[name] = norm_srv
261
+
262
+ return normalized
263
+
264
+
265
+ def discover_plugin_mcp_configs(
266
+ *,
267
+ project_dir: Path | None = None,
268
+ include_subagents: bool = False,
269
+ ) -> dict[str, Any]:
270
+ """Discover enabled plugins and return merged MCP server configurations.
271
+
272
+ Args:
273
+ project_dir: Project directory for variable substitution.
274
+ include_subagents: Whether to include MCP servers from agent-based plugins.
275
+
276
+ Returns:
277
+ Dict mapping server names to normalized server configs.
278
+ """
279
+ try:
280
+ from opscloud.plugins.discovery import discover_plugins
281
+
282
+ result = discover_plugins(project_root=project_dir)
283
+ merged: dict[str, Any] = {}
284
+ for layer in plugin_mcp_configs(
285
+ result.plugins,
286
+ project_dir=project_dir,
287
+ include_subagents=include_subagents,
288
+ ):
289
+ merged.update(layer)
290
+ return merged
291
+ except Exception as exc:
292
+ logger.debug("Could not discover plugin MCP configs: %s", exc)
293
+ return {}
294
+
295
+
296
+ def prepare_subagent_mcp(
297
+ subagent_name: str,
298
+ subagent_meta: SubagentMetadata | Mapping[str, Any],
299
+ project_root: Path | None = None,
300
+ ) -> tuple[dict[str, Any], list[Any], list[Any]]:
301
+ """Prepare and preload MCP servers and tools for a subagent.
302
+
303
+ Args:
304
+ subagent_name: Name of the subagent.
305
+ subagent_meta: Subagent metadata dict.
306
+ project_root: Optional project scope root directory.
307
+
308
+ Returns:
309
+ Tuple of (servers_dict, subagent_mcp_tools, sub_mcp_server_infos).
310
+ """
311
+ import asyncio
312
+ import concurrent.futures
313
+ from opscloud.plugins.adapters.agents import get_subagent_bundle_dir
314
+
315
+ servers: dict[str, Any] = {}
316
+ bundle_dir = get_subagent_bundle_dir(subagent_meta)
317
+ mcp_files_raw = subagent_meta.get("mcp_files")
318
+ mcp_files = list(mcp_files_raw) if mcp_files_raw is not None else None
319
+ if bundle_dir and bundle_dir.is_dir():
320
+ servers.update(subagent_mcp_configs(subagent_name, bundle_dir, mcp_files, project_dir=project_root))
321
+
322
+ raw_mcp_cfg = subagent_meta.get("mcp_config")
323
+ if raw_mcp_cfg and isinstance(raw_mcp_cfg, dict):
324
+ servers.update(raw_mcp_cfg.get("mcpServers") or raw_mcp_cfg)
325
+
326
+ if not servers:
327
+ return {}, [], []
328
+
329
+ subagent_mcp_tools: list[Any] = []
330
+ sub_mcp_server_infos: list[Any] = []
331
+ try:
332
+ from opscloud.mcp.preload import get_cached_mcp_server_infos, preload_mcp_metadata
333
+ from opscloud.mcp.session_manager import MCPSessionManager, build_mcp_tools_from_server_infos
334
+
335
+ sub_mcp_manager = MCPSessionManager.get_instance(servers, purge_missing=False)
336
+ sub_mcp_manager.register_servers(servers)
337
+
338
+ cached_infos = get_cached_mcp_server_infos()
339
+ cached_by_name = {info.name: info for info in cached_infos}
340
+ missing_configs = {k: v for k, v in servers.items() if k not in cached_by_name}
341
+
342
+ if missing_configs:
343
+ try:
344
+ loop = asyncio.get_running_loop()
345
+ except RuntimeError:
346
+ loop = None
347
+
348
+ if loop is not None and loop.is_running():
349
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
350
+ new_infos = pool.submit(asyncio.run, preload_mcp_metadata(missing_configs)).result()
351
+ else:
352
+ new_infos = asyncio.run(preload_mcp_metadata(missing_configs))
353
+ cached_infos.extend(new_infos)
354
+ cached_by_name.update({info.name: info for info in new_infos})
355
+
356
+ sub_mcp_server_infos = [cached_by_name[k] for k in servers if k in cached_by_name]
357
+ sub_tools = build_mcp_tools_from_server_infos(sub_mcp_server_infos, sub_mcp_manager)
358
+ if sub_tools:
359
+ subagent_mcp_tools.extend(sub_tools)
360
+ except Exception as exc:
361
+ logger.warning("Could not initialize subagent %s MCP tools: %s", subagent_name, exc)
362
+
363
+ return servers, subagent_mcp_tools, sub_mcp_server_infos
@@ -0,0 +1,132 @@
1
+ """Adapter from discovered plugins to OpsCloud skill sources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, TypeAlias
7
+
8
+ from opscloud.utils.logger import get_logger
9
+
10
+ if TYPE_CHECKING:
11
+ from opscloud.plugins.models import PluginInstance
12
+
13
+ logger = get_logger(__name__)
14
+
15
+ SkillPath: TypeAlias = str
16
+ SkillLabel: TypeAlias = str
17
+ SkillNamespace: TypeAlias = str
18
+ DirectorySkillSource: TypeAlias = tuple[SkillPath, SkillLabel]
19
+ PluginSkillSource: TypeAlias = tuple[SkillPath, SkillLabel, SkillNamespace]
20
+ CodeSkillSource: TypeAlias = DirectorySkillSource | PluginSkillSource
21
+
22
+
23
+ def namespaced_skill_name(
24
+ namespace: SkillNamespace,
25
+ name: str,
26
+ subfolders: tuple[str, ...] = (),
27
+ ) -> str:
28
+ """Qualify a skill name under its plugin namespace."""
29
+ return ":".join((namespace, *subfolders, name)).lower()
30
+
31
+
32
+ def is_agent_plugin(plugin: PluginInstance) -> bool:
33
+ """Return True if the plugin defines agents (agent-based plugin)."""
34
+ return getattr(plugin, "is_agent_plugin", False)
35
+
36
+
37
+ _plugin_has_agents = is_agent_plugin
38
+
39
+
40
+ def plugin_skill_sources(
41
+ plugins: tuple[PluginInstance, ...] | list[PluginInstance],
42
+ *,
43
+ include_subagent_skills: bool = True,
44
+ ) -> list[PluginSkillSource]:
45
+ """Return skill source tuples for plugin skills.
46
+
47
+ When include_subagent_skills is False, agent-based plugins are excluded
48
+ so that their skills remain isolated exclusively to their subagent.
49
+ """
50
+ sources: list[PluginSkillSource] = []
51
+ for plugin in plugins:
52
+ if not include_subagent_skills and _plugin_has_agents(plugin):
53
+ continue
54
+ for path in plugin.inventory.skills:
55
+ source_path = path.parent if path.name == "SKILL.md" else path
56
+ try:
57
+ if not source_path.exists():
58
+ continue
59
+ except OSError:
60
+ logger.warning("Could not inspect plugin skill path %s", source_path)
61
+ continue
62
+ sources.append(
63
+ (
64
+ str(source_path),
65
+ f"Plugin: {plugin.plugin_id}",
66
+ plugin.plugin_id,
67
+ )
68
+ )
69
+ return sources
70
+
71
+
72
+ def plugin_skill_roots(
73
+ plugins: tuple[PluginInstance, ...] | list[PluginInstance],
74
+ *,
75
+ include_subagent_skills: bool = True,
76
+ ) -> list[Path]:
77
+ """Return plugin skill roots for skill-content containment checks."""
78
+ roots: list[Path] = []
79
+ for plugin in plugins:
80
+ if not include_subagent_skills and _plugin_has_agents(plugin):
81
+ continue
82
+ roots.extend(
83
+ path.parent if path.name == "SKILL.md" else path
84
+ for path in plugin.inventory.skills
85
+ )
86
+ return roots
87
+
88
+
89
+ def discover_plugin_skill_state(
90
+ project_root: Path | None = None,
91
+ *,
92
+ include_subagent_skills: bool = True,
93
+ ) -> tuple[tuple[tuple[Path, str], ...], tuple[Path, ...], frozenset[str]]:
94
+ """Discover plugin skill sources, containment roots, and loaded ids."""
95
+ plugin_sources: tuple[tuple[Path, str], ...] = ()
96
+ plugin_roots: tuple[Path, ...] = ()
97
+ plugin_ids: frozenset[str] = frozenset()
98
+ try:
99
+ from opscloud.plugins.discovery import discover_plugins
100
+
101
+ plugins = discover_plugins(project_root=project_root).plugins
102
+ plugin_sources = tuple(
103
+ (Path(path), namespace)
104
+ for path, _label, namespace in plugin_skill_sources(
105
+ plugins, include_subagent_skills=include_subagent_skills
106
+ )
107
+ )
108
+ plugin_roots = tuple(
109
+ plugin_skill_roots(
110
+ plugins, include_subagent_skills=include_subagent_skills
111
+ )
112
+ )
113
+ plugin_ids = frozenset(plugin.plugin_id for plugin in plugins)
114
+ except (OSError, RuntimeError):
115
+ logger.warning("Could not discover plugin skills", exc_info=True)
116
+ return (), (), frozenset()
117
+
118
+ return plugin_sources, plugin_roots, plugin_ids
119
+
120
+
121
+ def discover_plugin_skill_sources_and_roots(
122
+ project_root: Path | None = None,
123
+ *,
124
+ include_subagent_skills: bool = True,
125
+ ) -> tuple[tuple[tuple[Path, str], ...], tuple[Path, ...],]:
126
+ """Discover plugin skill sources and containment roots."""
127
+ plugin_sources, plugin_roots, _plugin_ids = discover_plugin_skill_state(
128
+ project_root=project_root,
129
+ include_subagent_skills=include_subagent_skills,
130
+ )
131
+ return plugin_sources, plugin_roots
132
+