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
opscloud/mcp/config.py ADDED
@@ -0,0 +1,203 @@
1
+ """Validation and environment-variable expansion for MCP server config.
2
+
3
+ Resolves `${VAR}` and `${VAR:-default}` references in supported configuration
4
+ fields (`command`, `url`, `args`, `env`, `headers`) and validates their types.
5
+ A `${VAR:-default}` reference falls back to `default` when `VAR` is unset or empty (POSIX `:-` semantics).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import os
12
+ import re
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Mapping
17
+
18
+ _ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^{}]*))?\}")
19
+ """Matches a supported reference: `${VAR}` or `${VAR:-default}`.
20
+
21
+ Group 1 is the variable name; group 2 (present only for the `:-` form) is
22
+ the default. A bare `$VAR` and a literal `$` are intentionally not matched.
23
+ """
24
+
25
+ _ENV_BRACE_RE = re.compile(r"\$\{")
26
+ """Matches a `${` brace-open, used to catch malformed `${...}` references."""
27
+
28
+
29
+ def _interpolate_env(value: str, *, field: str) -> str:
30
+ """Expand `${VAR}` / `${VAR:-default}` references in one config string.
31
+
32
+ A bare `$VAR` (no braces) and a literal `$` pass through untouched;
33
+ only the braced forms expand. `${VAR:-default}` uses `default` when
34
+ `VAR` is unset or empty. A `${...}` that does not parse as one of the
35
+ supported forms (e.g. `${VAR-default}` or an unterminated `${VAR`) is
36
+ rejected rather than silently emitted, so a typo cannot inject a
37
+ garbage value into a URL, command, or header.
38
+
39
+ Args:
40
+ value: Raw configuration string.
41
+ field: Fully qualified field path for error messages.
42
+
43
+ Returns:
44
+ The interpolated string.
45
+
46
+ Raises:
47
+ RuntimeError: If a required environment variable is unset, or the
48
+ string contains a malformed `${...}` reference.
49
+ """
50
+
51
+ def replace(match: re.Match[str]) -> str:
52
+ """Resolve regex environment variable substitution match.
53
+
54
+ Args:
55
+ match: Regular expression match containing variable name and default.
56
+
57
+ Returns:
58
+ str: Resolved environment variable value or fallback default.
59
+
60
+ Raises:
61
+ RuntimeError: If variable is required and unset without a default.
62
+ """
63
+ name = match.group(1)
64
+ default = match.group(2)
65
+ resolved = os.environ.get(name)
66
+ # A non-empty value always wins, for both `${VAR}` and `${VAR:-default}`.
67
+ if resolved:
68
+ if (resolved.startswith('"') and resolved.endswith('"')) or (
69
+ resolved.startswith("'") and resolved.endswith("'")
70
+ ):
71
+ resolved = resolved[1:-1]
72
+ return resolved
73
+ # `resolved` is now "" (set but empty) or None (unset).
74
+ if default is not None:
75
+ # `${VAR:-default}`: `:-` falls back for empty *and* unset (POSIX).
76
+ if (default.startswith('"') and default.endswith('"')) or (
77
+ default.startswith("'") and default.endswith("'")
78
+ ):
79
+ default = default[1:-1]
80
+ return default
81
+ if resolved is not None:
82
+ # `${VAR}` set to "": no default, so emit the empty value.
83
+ return resolved
84
+ # `${VAR}` unset with no default: the only hard error.
85
+ msg = f"{field} references unset env var {name}. Set {name} in the environment or provide a default."
86
+ raise RuntimeError(msg)
87
+
88
+ # Reject any `${` that isn't the start of a well-formed reference.
89
+ ref_spans = [match.span() for match in _ENV_REF_RE.finditer(value)]
90
+ for brace in _ENV_BRACE_RE.finditer(value):
91
+ if not any(start <= brace.start() < end for start, end in ref_spans):
92
+ msg = f"{field} contains a malformed '${{...}}' reference. Use '${{VAR}}' or '${{VAR:-default}}'."
93
+ raise RuntimeError(msg)
94
+
95
+ return _ENV_REF_RE.sub(replace, value)
96
+
97
+
98
+ def _resolve_string(value: object, *, field: str) -> str:
99
+ """Validate and interpolate one string field."""
100
+ if not isinstance(value, str):
101
+ msg = f"{field} must be a string, got {type(value).__name__}"
102
+ raise TypeError(msg)
103
+ return _interpolate_env(value, field=field)
104
+
105
+
106
+ def _resolve_mapping_values(
107
+ values: Mapping[str, object],
108
+ *,
109
+ field: str,
110
+ ) -> dict[str, str]:
111
+ """Validate and interpolate string values in a mapping field."""
112
+ result: dict[str, str] = {}
113
+ for name, value in values.items():
114
+ resolved_val = _resolve_string(value, field=f"{field}.{name}")
115
+ if field.endswith("headers"):
116
+ resolved_val = resolved_val.strip()
117
+ if (resolved_val.startswith('"') and resolved_val.endswith('"')) or (
118
+ resolved_val.startswith("'") and resolved_val.endswith("'")
119
+ ):
120
+ resolved_val = resolved_val[1:-1].strip()
121
+ result[name] = resolved_val
122
+ return result
123
+
124
+
125
+ def resolve_mcp_server_env(
126
+ server_name: str,
127
+ server_config: Mapping[str, object],
128
+ ) -> dict[str, Any]:
129
+ """Resolve `${VAR}` references in one MCP server's supported fields.
130
+
131
+ Interpolates the `command`, `url`, `args`, `env`, and `headers`
132
+ fields; every other field is copied through verbatim. The input is not mutated.
133
+
134
+ Args:
135
+ server_name: Server name used in field-specific error messages.
136
+ server_config: Raw server configuration.
137
+
138
+ Returns:
139
+ A resolved copy of the server configuration.
140
+
141
+ Raises:
142
+ TypeError: If a supported field has the wrong type — a non-string
143
+ scalar value, or `args`/`env`/`headers` with the wrong container type.
144
+ RuntimeError: If a required environment variable is unset or reference is malformed.
145
+ """
146
+ resolved: dict[str, Any] = copy.deepcopy(dict(server_config))
147
+ prefix = f"mcpServers.{server_name}"
148
+
149
+ for name in ("command", "url"):
150
+ if name in resolved and resolved[name] is not None:
151
+ resolved[name] = _resolve_string(resolved[name], field=f"{prefix}.{name}")
152
+
153
+ if "args" in resolved and resolved["args"] is not None:
154
+ args = resolved["args"]
155
+ if not isinstance(args, (list, tuple)):
156
+ msg = f"{prefix}.args must be a list, got {type(args).__name__}"
157
+ raise TypeError(msg)
158
+ resolved["args"] = [_resolve_string(value, field=f"{prefix}.args[{index}]") for index, value in enumerate(args)]
159
+
160
+ for name in ("env", "headers"):
161
+ if name not in resolved or resolved[name] is None:
162
+ continue
163
+ values = resolved[name]
164
+ if not isinstance(values, dict):
165
+ msg = f"{prefix}.{name} must be a dictionary, got {type(values).__name__}"
166
+ raise TypeError(msg)
167
+ resolved[name] = _resolve_mapping_values(values, field=f"{prefix}.{name}")
168
+
169
+ return resolved
170
+
171
+
172
+ def sync_mcp_env_aliases() -> None:
173
+ """Harmonize environment variable aliases for MCP server compatibility.
174
+
175
+ Ensures bidirectional synchronization between standard cloud/AWS config keys:
176
+ - AWS_REGION <-> AWS_DEFAULT_REGION
177
+ - AWS_PROFILE <-> AWS_DEFAULT_PROFILE
178
+ - KUBECONFIG <-> K8S_KUBECONFIG
179
+ """
180
+ # AWS Region
181
+ region = os.environ.get("AWS_REGION")
182
+ default_region = os.environ.get("AWS_DEFAULT_REGION")
183
+ if region and not default_region:
184
+ os.environ["AWS_DEFAULT_REGION"] = region
185
+ elif default_region and not region:
186
+ os.environ["AWS_REGION"] = default_region
187
+
188
+ # AWS Profile
189
+ profile = os.environ.get("AWS_PROFILE")
190
+ default_profile = os.environ.get("AWS_DEFAULT_PROFILE")
191
+ if profile and not default_profile:
192
+ os.environ["AWS_DEFAULT_PROFILE"] = profile
193
+ elif default_profile and not profile:
194
+ os.environ["AWS_PROFILE"] = default_profile
195
+
196
+ # Kubeconfig
197
+ kube = os.environ.get("KUBECONFIG")
198
+ k8s_kube = os.environ.get("K8S_KUBECONFIG")
199
+ if kube and not k8s_kube:
200
+ os.environ["K8S_KUBECONFIG"] = kube
201
+ elif k8s_kube and not kube:
202
+ os.environ["KUBECONFIG"] = k8s_kube
203
+
@@ -0,0 +1,229 @@
1
+ """Discovers and parses MCP server configs from files with clear precedence.
2
+
3
+ Pure file-based discovery (no database dependency):
4
+ Precedence (lowest to highest):
5
+ 1. Global user configs (~/.agents/mcp.json, ~/.agents/.mcp.json)
6
+ 2. User configs (~/.opscloud/mcp.json, ~/.opscloud/.mcp.json)
7
+ 3. Plugin-declared MCP configs
8
+ 4. Project configs (e.g. {project_root}/.opscloud/mcp.json, {project_root}/.mcp.json)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+ from typing import Any, TypedDict
16
+
17
+ from opscloud.utils.logger import get_logger
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ class MCPServerConfig(TypedDict, total=False):
23
+ """Configuration for a discovered MCP server (command, args, env, transport)."""
24
+
25
+ command: str | None
26
+ args: list[str] | None
27
+ env: dict[str, str] | None
28
+ url: str | None
29
+ transport: str | None
30
+ headers: dict[str, str] | None
31
+ source: str | None
32
+ auth_token_env_var: str | None
33
+ disabled_tools: list[str] | None
34
+ allowed_tools: list[str] | None
35
+ enabled: bool
36
+ trusted: bool
37
+
38
+
39
+ def _load_mcp_json(path: Path) -> dict[str, dict[str, Any]]:
40
+ """Safely load and parse an .mcp.json file into a dictionary of server configs."""
41
+ if not path.is_file():
42
+ return {}
43
+ try:
44
+ with open(path, "r", encoding="utf-8") as f:
45
+ data = json.load(f)
46
+ if not isinstance(data, dict):
47
+ return {}
48
+
49
+ servers = data.get("mcpServers") or data.get("mcp_servers")
50
+ if servers is None and ("command" in data or "url" in data or "serverUrl" in data):
51
+ # Single server definition (fallback)
52
+ servers = {path.stem: data}
53
+ elif servers is None:
54
+ # Check if root dict is already mapping name -> config
55
+ if data and all(
56
+ isinstance(v, dict) and ("command" in v or "url" in v or "serverUrl" in v)
57
+ for v in data.values()
58
+ ):
59
+ servers = data
60
+
61
+ if isinstance(servers, dict):
62
+ return servers
63
+ except Exception as exc:
64
+ logger.warning("Failed to parse MCP config at %s: %s", path, exc)
65
+ return {}
66
+
67
+
68
+ def _parse_server_config(
69
+ name: str, config: dict[str, Any], source_name: str
70
+ ) -> MCPServerConfig:
71
+ """Normalize a raw server config dictionary into MCPServerConfig."""
72
+ url = config.get("url") or config.get("serverUrl")
73
+ command = config.get("command")
74
+ transport = config.get("transport") or config.get("type")
75
+ if not transport:
76
+ transport = ("sse" if "sse" in str(url).lower() else "http") if url else "stdio"
77
+
78
+ # Enabled status
79
+ enabled = True
80
+ if "disabled" in config:
81
+ enabled = not bool(config["disabled"])
82
+ elif "enabled" in config:
83
+ enabled = bool(config["enabled"])
84
+
85
+ raw_disabled = config.get("disabled_tools") or config.get("disabledTools") or []
86
+ raw_allowed = config.get("allowed_tools") or config.get("allowedTools") or []
87
+
88
+ disabled_tools = list(raw_disabled) if isinstance(raw_disabled, (list, tuple)) else []
89
+ allowed_tools = list(raw_allowed) if isinstance(raw_allowed, (list, tuple)) else []
90
+
91
+ trusted = bool(config.get("trusted", source_name != "project"))
92
+
93
+ return MCPServerConfig(
94
+ command=command,
95
+ args=list(config.get("args") or []),
96
+ env=dict(config.get("env") or {}),
97
+ url=url,
98
+ transport=transport,
99
+ headers=dict(config.get("headers") or {}),
100
+ source=source_name,
101
+ auth_token_env_var=config.get("auth_token_env_var"),
102
+ disabled_tools=disabled_tools,
103
+ allowed_tools=allowed_tools,
104
+ enabled=enabled,
105
+ trusted=trusted,
106
+ )
107
+
108
+
109
+ class MCPDiscovery:
110
+ """Discovers and parses MCP server configs from filesystem sources."""
111
+
112
+ def __init__(self, store: Any = None) -> None:
113
+ """Initialize MCPDiscovery.
114
+
115
+ Args:
116
+ store: Kept for backwards compatibility; not used in terminal file-based model.
117
+ """
118
+ self._store = store
119
+
120
+ def discover(self, project_root: Path | None = None) -> dict[str, MCPServerConfig]:
121
+ """Discover and merge MCP server configs across global, user, plugin, and project files.
122
+
123
+ Precedence (lowest to highest):
124
+ 1. Global configs (~/.agents/mcp.json)
125
+ 2. User configs (~/.opscloud/.mcp.json)
126
+ 3. Plugin-declared configs
127
+ 4. Project configs (project root .mcp.json)
128
+ """
129
+ from opscloud.config import paths
130
+
131
+ effective_project_root = project_root or Path.cwd()
132
+
133
+ # Build candidate file list in lowest -> highest precedence order
134
+ candidates: list[tuple[Path, str]] = []
135
+
136
+ # 1. Global user configs (~/.agents/)
137
+ candidates.append((paths.AGENTS_SHARED_DIR / "mcp.json", "global"))
138
+ candidates.append((paths.AGENTS_SHARED_DIR / ".mcp.json", "global"))
139
+
140
+ # 2. User data dir configs (~/.opscloud/)
141
+ candidates.append((paths.DATA_DIR / "mcp.json", "user"))
142
+ candidates.append((paths.GLOBAL_MCP_PATH, "user"))
143
+
144
+ merged_servers: dict[str, MCPServerConfig] = {}
145
+
146
+ # Load global and user configs first
147
+ for path, source_name in candidates:
148
+ servers = _load_mcp_json(path)
149
+ for name, config in servers.items():
150
+ if isinstance(config, dict):
151
+ clean_config = _parse_server_config(name, config, source_name)
152
+ if name not in merged_servers:
153
+ merged_servers[name] = clean_config
154
+ else:
155
+ merged_servers[name] = {**merged_servers[name], **clean_config}
156
+
157
+ # 3. Discover plugin-declared MCP servers
158
+ try:
159
+ from opscloud.plugins.adapters.mcp import discover_plugin_mcp_configs
160
+
161
+ plugin_configs = discover_plugin_mcp_configs(project_dir=effective_project_root)
162
+ if isinstance(plugin_configs, dict):
163
+ for name, config in plugin_configs.items():
164
+ if isinstance(config, dict):
165
+ clean_config = _parse_server_config(name, config, "plugin")
166
+ if name not in merged_servers:
167
+ merged_servers[name] = clean_config
168
+ else:
169
+ merged_servers[name] = {**merged_servers[name], **clean_config}
170
+ except Exception as exc:
171
+ logger.debug("Could not discover plugin MCP configs: %s", exc)
172
+
173
+ # 3b. Local workspace plugins (.mcp.json in plugins/ directories)
174
+ plugins_dir = effective_project_root / "plugins"
175
+ if plugins_dir.is_dir():
176
+ for plugin in sorted(plugins_dir.iterdir()):
177
+ if plugin.is_dir() and not plugin.name.startswith("."):
178
+ from opscloud.plugins.models import is_agent_plugin_dir
179
+
180
+ if is_agent_plugin_dir(plugin):
181
+ continue # Dynamic subagent plugins isolate their own MCP servers
182
+ for candidate in (plugin / ".mcp.json", plugin / "mcp.json"):
183
+ if candidate.is_file():
184
+ for name, config in _load_mcp_json(candidate).items():
185
+ if isinstance(config, dict):
186
+ clean_config = _parse_server_config(
187
+ name, config, f"plugin:{plugin.name}"
188
+ )
189
+ if name not in merged_servers:
190
+ merged_servers[name] = clean_config
191
+ else:
192
+ merged_servers[name] = {**merged_servers[name], **clean_config}
193
+
194
+ # 4. Project configs (lowest to highest project precedence)
195
+ # Note: paths.project_mcp_paths lists [.mcp.json, mcp.json, .opscloud/.mcp.json, .opscloud/mcp.json]
196
+ # Reversing them means .mcp.json in the project root is loaded last and wins.
197
+ project_candidate_paths = list(reversed(paths.project_mcp_paths(effective_project_root)))
198
+ for path in project_candidate_paths:
199
+ servers = _load_mcp_json(path)
200
+ for name, config in servers.items():
201
+ if isinstance(config, dict):
202
+ clean_config = _parse_server_config(name, config, "project")
203
+ if name not in merged_servers:
204
+ merged_servers[name] = clean_config
205
+ else:
206
+ merged_servers[name] = {**merged_servers[name], **clean_config}
207
+
208
+ return merged_servers
209
+
210
+ async def discover_and_sync_async(
211
+ self, project_root: Path | None = None, store: Any = None
212
+ ) -> dict[str, MCPServerConfig]:
213
+ """Asynchronous discover entrypoint for compatibility.
214
+
215
+ Discovers configs from disk and registers them with MCPSessionManager.
216
+ """
217
+ results = self.discover(project_root=project_root)
218
+ try:
219
+ from opscloud.mcp.session_manager import MCPSessionManager
220
+
221
+ MCPSessionManager.get_instance(results, purge_missing=False)
222
+ except Exception as e:
223
+ logger.debug("Failed to sync discovered configs to MCPSessionManager: %s", e)
224
+ return results
225
+
226
+
227
+ def discover_mcp_configs(project_root: Path | None = None) -> dict[str, MCPServerConfig]:
228
+ """Discover active MCP server configs from disk."""
229
+ return MCPDiscovery().discover(project_root)
@@ -0,0 +1,109 @@
1
+ """Data structures for MCP server and tool metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ MCPServerStatus = Literal["ok", "unauthenticated", "error", "disabled", "disconnected"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class MCPToolInfo:
13
+ """Lightweight metadata about a single tool exposed by an MCP server."""
14
+
15
+ name: str
16
+ description: str = ""
17
+ input_schema: dict[str, Any] | None = None
18
+ original_name: str | None = None
19
+
20
+ def to_dict(self) -> dict[str, Any]:
21
+ """Convert MCPToolInfo to a JSON-serializable dictionary.
22
+
23
+ Returns:
24
+ dict[str, Any]: Dictionary containing tool name, description, and input schema.
25
+ """
26
+ res: dict[str, Any] = {
27
+ "name": self.name,
28
+ "description": self.description,
29
+ "input_schema": self.input_schema or {},
30
+ }
31
+ if self.original_name is not None:
32
+ res["original_name"] = self.original_name
33
+ return res
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class MCPServerInfo:
38
+ """Metadata about a configured MCP server, including probed tools."""
39
+
40
+ name: str
41
+ transport: str = "stdio"
42
+ status: MCPServerStatus = "ok"
43
+ error: str | None = None
44
+ tools: tuple[MCPToolInfo, ...] = ()
45
+ enabled: bool = True
46
+ source: str = "project"
47
+ url: str | None = None
48
+ command: str | None = None
49
+ args: tuple[str, ...] = ()
50
+ headers: dict[str, str] = field(default_factory=dict)
51
+ env: dict[str, str] = field(default_factory=dict)
52
+ disabled_tools: tuple[str, ...] = ()
53
+ allowed_tools: tuple[str, ...] = ()
54
+
55
+ @property
56
+ def is_disabled(self) -> bool:
57
+ """Check whether the server is disabled.
58
+
59
+ Returns:
60
+ bool: True if server is disabled.
61
+ """
62
+ return not self.enabled
63
+
64
+ @property
65
+ def tool_count(self) -> int:
66
+ """Count of tools exposed by this server.
67
+
68
+ Returns:
69
+ int: Number of exposed tools.
70
+ """
71
+ return len(self.tools)
72
+
73
+ @property
74
+ def display_target(self) -> str:
75
+ """Human-readable target URL or command line.
76
+
77
+ Returns:
78
+ str: URL for network servers or joined command string for stdio.
79
+ """
80
+ if self.transport in ("http", "sse", "streamable_http"):
81
+ return self.url or ""
82
+ if self.command:
83
+ parts = [self.command, *self.args]
84
+ return " ".join(parts)
85
+ return ""
86
+
87
+ def to_dict(self) -> dict[str, Any]:
88
+ """Serialize server metadata to dictionary format.
89
+
90
+ Returns:
91
+ dict[str, Any]: JSON-compatible dictionary of server properties.
92
+ """
93
+ res: dict[str, Any] = {
94
+ "name": self.name,
95
+ "transport": self.transport,
96
+ "status": self.status,
97
+ "error": self.error,
98
+ "url": self.url,
99
+ "command": self.command,
100
+ "args": list(self.args),
101
+ "tool_count": self.tool_count,
102
+ "tools": [t.to_dict() for t in self.tools],
103
+ "enabled": self.enabled,
104
+ }
105
+ return res
106
+
107
+ def needs_attention(self) -> bool:
108
+ """Return whether this server requires user action."""
109
+ return self.status in {"unauthenticated", "error", "disconnected"}
@@ -0,0 +1,102 @@
1
+ """MCP Discovery Middleware — discovers project-level .mcp.json and binds to domain.
2
+
3
+ Registered as ``mcp_discovery`` in the middleware registry. When included
4
+ in a coordinator's middleware list, it:
5
+ 1. Scans project-level ``.mcp.json`` files
6
+ 2. Merges discovered servers with ``DefaultConfig.MCP_SERVERS``
7
+ 3. Filters by domain binding (e.g. Helm only gets helm-related servers)
8
+ 4. Updates the runtime's MCP tool list
9
+
10
+ Usage in coordinator::
11
+
12
+ custom_mws = get_middleware_registry().build_middlewares([
13
+ "operation_context",
14
+ ("mcp_discovery", {"domain": "helm-operator"}),
15
+ ])
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ from opscloud.middleware.registry import (
23
+ BaseAgentMiddleware,
24
+ register_middleware,
25
+ )
26
+ from opscloud.utils.logger import AgentLogger
27
+
28
+ logger = AgentLogger("MCPDiscoveryMW")
29
+
30
+
31
+ @register_middleware(name="mcp_discovery")
32
+ class MCPDiscoveryMiddleware(BaseAgentMiddleware):
33
+ """Discover project-level .mcp.json and bind MCP servers to domain.
34
+
35
+ When attached to a coordinator, this middleware runs during agent
36
+ initialization to discover additional MCP servers from project-level
37
+ config files and filter them by the coordinator's domain.
38
+
39
+ Args:
40
+ domain: Coordinator domain name (e.g. "helm-operator").
41
+ If None, no domain filtering is applied.
42
+ project_root: Root directory for .mcp.json scanning.
43
+ Defaults to the config's AGENT_PROJECT_ROOT.
44
+ config: Config instance for resolving defaults.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ *,
50
+ domain: str | None = None,
51
+ project_root: str | None = None,
52
+ config: Any = None,
53
+ **kwargs: Any,
54
+ ) -> None:
55
+ """Initialize MCPDiscoveryMiddleware.
56
+
57
+ Args:
58
+ domain: Optional coordinator domain filter.
59
+ project_root: Optional root directory path for discovery.
60
+ config: Optional configuration settings instance.
61
+ **kwargs: Additional keyword arguments.
62
+ """
63
+ super().__init__()
64
+ self._domain = domain
65
+ self._project_root = project_root
66
+ self._config = config
67
+ self._discovered_servers: list[dict[str, Any]] = []
68
+
69
+ def _get_config(self) -> Any:
70
+ """Lazy config resolution."""
71
+ if self._config is None:
72
+ from opscloud.config.settings import get_settings
73
+
74
+ self._config = get_settings()
75
+ return self._config
76
+
77
+ def discover_and_bind(self) -> list[dict[str, Any]]:
78
+ """Run discovery and domain binding, return filtered server list.
79
+
80
+ This is the main entry point. It:
81
+ 1. Discovers project-level .mcp.json servers
82
+ 2. Merges with defaults from config
83
+ 3. Filters by domain binding
84
+
85
+ Returns:
86
+ Filtered MCP server list (dicts in DefaultConfig format).
87
+ """
88
+ from opscloud.mcp.discovery import discover_mcp_configs
89
+
90
+ discovered = discover_mcp_configs()
91
+ merged = [{"name": k, "config": v} for k, v in (discovered.items() if isinstance(discovered, dict) else [])]
92
+ self._discovered_servers = merged
93
+
94
+ if discovered:
95
+ logger.info(f"MCP Discovery: {len(discovered):d} servers discovered")
96
+
97
+ return merged
98
+
99
+ @property
100
+ def discovered_servers(self) -> list[dict[str, Any]]:
101
+ """Return the last discovered and bound server list."""
102
+ return self._discovered_servers