talkops-opscloud 0.3.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opscloud/__init__.py +5 -0
- opscloud/__main__.py +6 -0
- opscloud/_constants.py +53 -0
- opscloud/_debug.py +145 -0
- opscloud/_debug_buffer.py +250 -0
- opscloud/_textual_patches.py +367 -0
- opscloud/_version.py +15 -0
- opscloud/agent/__init__.py +27 -0
- opscloud/agent/config.py +168 -0
- opscloud/agent/factory.py +1103 -0
- opscloud/approval_mode.py +44 -0
- opscloud/backend/__init__.py +49 -0
- opscloud/backend/composite.py +89 -0
- opscloud/backend/local.py +183 -0
- opscloud/backend/registry.py +79 -0
- opscloud/backend/sandbox_config.py +107 -0
- opscloud/backend/sandbox_factory.py +432 -0
- opscloud/built_in_skills/remember/SKILL.md +124 -0
- opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
- opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
- opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
- opscloud/cli/__init__.py +1 -0
- opscloud/cli/commands/__init__.py +15 -0
- opscloud/cli/commands/auth.py +45 -0
- opscloud/cli/commands/doctor.py +100 -0
- opscloud/cli/commands/mcp.py +137 -0
- opscloud/cli/commands/threads.py +51 -0
- opscloud/cli/interactive.py +154 -0
- opscloud/cli/main.py +903 -0
- opscloud/cli/non_interactive.py +425 -0
- opscloud/cli/server_manager.py +58 -0
- opscloud/commands/__init__.py +19 -0
- opscloud/commands/_base.py +110 -0
- opscloud/commands/_guards.py +52 -0
- opscloud/commands/_router.py +146 -0
- opscloud/commands/_types.py +36 -0
- opscloud/commands/core/__init__.py +47 -0
- opscloud/commands/core/auth.py +94 -0
- opscloud/commands/core/bug.py +46 -0
- opscloud/commands/core/clear.py +106 -0
- opscloud/commands/core/cloud.py +185 -0
- opscloud/commands/core/compact.py +128 -0
- opscloud/commands/core/config_cmd.py +120 -0
- opscloud/commands/core/context.py +220 -0
- opscloud/commands/core/cost.py +44 -0
- opscloud/commands/core/doctor.py +535 -0
- opscloud/commands/core/effort.py +108 -0
- opscloud/commands/core/exit_cmd.py +41 -0
- opscloud/commands/core/fast.py +148 -0
- opscloud/commands/core/help_cmd.py +94 -0
- opscloud/commands/core/mcp.py +134 -0
- opscloud/commands/core/model.py +109 -0
- opscloud/commands/core/permissions.py +196 -0
- opscloud/commands/core/plugins.py +92 -0
- opscloud/commands/core/pool.py +108 -0
- opscloud/commands/core/resume.py +97 -0
- opscloud/commands/core/skills.py +178 -0
- opscloud/commands/power/__init__.py +51 -0
- opscloud/commands/power/agents.py +98 -0
- opscloud/commands/power/btw.py +85 -0
- opscloud/commands/power/copy.py +97 -0
- opscloud/commands/power/goal.py +548 -0
- opscloud/commands/power/loop.py +295 -0
- opscloud/commands/power/memory.py +287 -0
- opscloud/commands/power/review.py +152 -0
- opscloud/commands/power/rubric.py +287 -0
- opscloud/commands/power/runtime.py +321 -0
- opscloud/commands/power/skill_creator.py +70 -0
- opscloud/commands/power/skill_invoke.py +165 -0
- opscloud/commands/power/tasks.py +209 -0
- opscloud/commands/power/trace.py +117 -0
- opscloud/commands/power/ui_toggles.py +135 -0
- opscloud/commands/power/version.py +52 -0
- opscloud/commands/registry.py +176 -0
- opscloud/config/__init__.py +114 -0
- opscloud/config/adapters/__init__.py +5 -0
- opscloud/config/adapters/postgres.py +776 -0
- opscloud/config/adapters/sqlite.py +769 -0
- opscloud/config/aws.py +248 -0
- opscloud/config/cloud_profiles.py +437 -0
- opscloud/config/env_vars.py +81 -0
- opscloud/config/langsmith.py +319 -0
- opscloud/config/manifest.py +817 -0
- opscloud/config/metadata.py +205 -0
- opscloud/config/paths.py +604 -0
- opscloud/config/plugins.py +130 -0
- opscloud/config/settings.py +834 -0
- opscloud/config/store.py +453 -0
- opscloud/config/store_factory.py +97 -0
- opscloud/config/toml_config.py +562 -0
- opscloud/editor.py +173 -0
- opscloud/exceptions.py +65 -0
- opscloud/file_ops.py +217 -0
- opscloud/hooks/__init__.py +139 -0
- opscloud/hooks/env.py +44 -0
- opscloud/hooks/interrupt.py +121 -0
- opscloud/hooks/models/__init__.py +109 -0
- opscloud/hooks/models/adapters.py +19 -0
- opscloud/hooks/models/config.py +79 -0
- opscloud/hooks/models/domain.py +434 -0
- opscloud/hooks/models/transport.py +37 -0
- opscloud/hooks/tools.py +231 -0
- opscloud/input.py +48 -0
- opscloud/integrations/__init__.py +80 -0
- opscloud/integrations/base.py +153 -0
- opscloud/integrations/content_filter.py +119 -0
- opscloud/integrations/event_bus.py +480 -0
- opscloud/integrations/hooks.py +238 -0
- opscloud/integrations/identity.py +181 -0
- opscloud/integrations/notifications.py +134 -0
- opscloud/integrations/stream_bridge.py +397 -0
- opscloud/integrations/thread_store.py +81 -0
- opscloud/json_types.py +20 -0
- opscloud/mcp/__init__.py +57 -0
- opscloud/mcp/config.py +203 -0
- opscloud/mcp/discovery.py +229 -0
- opscloud/mcp/mcp_info.py +109 -0
- opscloud/mcp/middleware.py +102 -0
- opscloud/mcp/preload.py +505 -0
- opscloud/mcp/raw_config.py +149 -0
- opscloud/mcp/semantic_profiler.py +305 -0
- opscloud/mcp/session_manager.py +727 -0
- opscloud/mcp/trust.py +105 -0
- opscloud/memory/__init__.py +27 -0
- opscloud/memory/branch.py +61 -0
- opscloud/memory/guard.py +19 -0
- opscloud/memory/onboarding.py +120 -0
- opscloud/memory/registry.py +134 -0
- opscloud/memory/store.py +223 -0
- opscloud/middleware/__init__.py +106 -0
- opscloud/middleware/_repository_bounds.py +459 -0
- opscloud/middleware/ask_user.py +152 -0
- opscloud/middleware/auto_mode.py +60 -0
- opscloud/middleware/auto_mode_hitl.py +2434 -0
- opscloud/middleware/compaction.py +566 -0
- opscloud/middleware/configurable_model.py +233 -0
- opscloud/middleware/cost_tracking.py +1021 -0
- opscloud/middleware/glm_stall_recovery.py +109 -0
- opscloud/middleware/goal_criteria.py +1234 -0
- opscloud/middleware/goal_state_notice.py +712 -0
- opscloud/middleware/goal_tools.py +407 -0
- opscloud/middleware/headless_mcp_guard.py +88 -0
- opscloud/middleware/jev_model_router.py +1018 -0
- opscloud/middleware/local_context.py +460 -0
- opscloud/middleware/mcp_context.py +151 -0
- opscloud/middleware/mcp_middleware.py +146 -0
- opscloud/middleware/memory_guard.py +393 -0
- opscloud/middleware/model_retry.py +205 -0
- opscloud/middleware/registry.py +137 -0
- opscloud/middleware/reliable_rubric.py +709 -0
- opscloud/middleware/resume_state.py +91 -0
- opscloud/middleware/server_hooks.py +1189 -0
- opscloud/middleware/shell_allow_list.py +90 -0
- opscloud/middleware/skills.py +382 -0
- opscloud/middleware/subagent_artifacts.py +84 -0
- opscloud/middleware/subagent_telemetry.py +261 -0
- opscloud/middleware/subagents.py +243 -0
- opscloud/middleware/tool_filter.py +384 -0
- opscloud/middleware/unified_system_message.py +62 -0
- opscloud/model/__init__.py +37 -0
- opscloud/model/config.py +2992 -0
- opscloud/model/factory.py +477 -0
- opscloud/model/pool.py +459 -0
- opscloud/model/reasoning.py +282 -0
- opscloud/offload.py +173 -0
- opscloud/output.py +64 -0
- opscloud/plugins/__init__.py +189 -0
- opscloud/plugins/_json.py +22 -0
- opscloud/plugins/adapters/__init__.py +13 -0
- opscloud/plugins/adapters/agents.py +112 -0
- opscloud/plugins/adapters/commands.py +190 -0
- opscloud/plugins/adapters/mcp.py +363 -0
- opscloud/plugins/adapters/skills.py +132 -0
- opscloud/plugins/commands_cli.py +541 -0
- opscloud/plugins/discovery.py +512 -0
- opscloud/plugins/manifest.py +335 -0
- opscloud/plugins/marketplace.py +893 -0
- opscloud/plugins/models.py +334 -0
- opscloud/plugins/project_plugins.py +317 -0
- opscloud/plugins/protocol.py +31 -0
- opscloud/plugins/store.py +905 -0
- opscloud/plugins/substitution.py +122 -0
- opscloud/project_utils.py +145 -0
- opscloud/prompts/__init__.py +37 -0
- opscloud/prompts/system.py +483 -0
- opscloud/prompts/templates/system_prompt.md +166 -0
- opscloud/prompts/types.py +54 -0
- opscloud/rubrics/__init__.py +42 -0
- opscloud/rubrics/evaluator.py +404 -0
- opscloud/rubrics/evidence_extractor.py +229 -0
- opscloud/rubrics/generator.py +239 -0
- opscloud/rubrics/jev_compiler.py +175 -0
- opscloud/rubrics/jev_grader.py +297 -0
- opscloud/rubrics/middleware.py +8 -0
- opscloud/schema/__init__.py +107 -0
- opscloud/schema/interrupts.py +981 -0
- opscloud/security/__init__.py +91 -0
- opscloud/security/approval_mode.py +528 -0
- opscloud/security/approval_mode_source.py +230 -0
- opscloud/security/cli_ast_evaluator.py +552 -0
- opscloud/security/jev_classifier.py +651 -0
- opscloud/security/shell_safety.py +340 -0
- opscloud/security/unicode_security.py +439 -0
- opscloud/security/url_validation.py +160 -0
- opscloud/server/__init__.py +13 -0
- opscloud/server/_server_config.py +158 -0
- opscloud/server/server.py +242 -0
- opscloud/server/server_graph.py +93 -0
- opscloud/skills/__init__.py +91 -0
- opscloud/skills/commands.py +1099 -0
- opscloud/skills/invocation.py +200 -0
- opscloud/skills/load.py +388 -0
- opscloud/skills/loader.py +23 -0
- opscloud/skills/merge.py +65 -0
- opscloud/skills/registry.py +263 -0
- opscloud/skills/sources.py +174 -0
- opscloud/skills/trust.py +417 -0
- opscloud/state/__init__.py +142 -0
- opscloud/state/base.py +154 -0
- opscloud/state/cloud_context.py +262 -0
- opscloud/state/goal_channels.py +124 -0
- opscloud/state/goal_state_limits.py +175 -0
- opscloud/state/resume_state.py +58 -0
- opscloud/state/service_context.py +281 -0
- opscloud/state/session.py +957 -0
- opscloud/state/state_migration.py +86 -0
- opscloud/subagents/__init__.py +30 -0
- opscloud/subagents/loader.py +331 -0
- opscloud/subagents/subagents_parser.py +117 -0
- opscloud/subagents/types.py +25 -0
- opscloud/tools/__init__.py +36 -0
- opscloud/tools/catalog.py +37 -0
- opscloud/tools/display.py +24 -0
- opscloud/tools/fetch_url.py +176 -0
- opscloud/tools/goal_tools.py +423 -0
- opscloud/tools/registry.py +105 -0
- opscloud/tools/thread.py +32 -0
- opscloud/tools/web_search.py +215 -0
- opscloud/ui/__init__.py +119 -0
- opscloud/ui/app.py +5400 -0
- opscloud/ui/app.tcss +479 -0
- opscloud/ui/clipboard.py +147 -0
- opscloud/ui/command_registry.py +482 -0
- opscloud/ui/message_store.py +152 -0
- opscloud/ui/permission_store.py +360 -0
- opscloud/ui/preferences.py +113 -0
- opscloud/ui/prompt_manager.py +96 -0
- opscloud/ui/remote_client.py +505 -0
- opscloud/ui/textual_adapter.py +1269 -0
- opscloud/ui/theme.py +728 -0
- opscloud/ui/ui_help.py +415 -0
- opscloud/ui/widgets/__init__.py +383 -0
- opscloud/ui/widgets/_ask_user_types.py +81 -0
- opscloud/ui/widgets/_copy_spans.py +52 -0
- opscloud/ui/widgets/_inline_prompt.py +290 -0
- opscloud/ui/widgets/_js_eval_display.py +139 -0
- opscloud/ui/widgets/_links.py +175 -0
- opscloud/ui/widgets/_tool_stream.py +308 -0
- opscloud/ui/widgets/agent_selector.py +297 -0
- opscloud/ui/widgets/approval.py +741 -0
- opscloud/ui/widgets/ask_user.py +539 -0
- opscloud/ui/widgets/auth.py +695 -0
- opscloud/ui/widgets/auth_manager.py +214 -0
- opscloud/ui/widgets/auto_mode_notice.py +146 -0
- opscloud/ui/widgets/autocomplete.py +709 -0
- opscloud/ui/widgets/chat_input.py +805 -0
- opscloud/ui/widgets/cloud_selector.py +528 -0
- opscloud/ui/widgets/config_manager.py +652 -0
- opscloud/ui/widgets/debug_console.py +1269 -0
- opscloud/ui/widgets/devops_renderers.py +184 -0
- opscloud/ui/widgets/diff.py +237 -0
- opscloud/ui/widgets/effort_selector.py +166 -0
- opscloud/ui/widgets/goal_review.py +612 -0
- opscloud/ui/widgets/goal_status.py +57 -0
- opscloud/ui/widgets/infra_panel.py +141 -0
- opscloud/ui/widgets/install_confirm.py +114 -0
- opscloud/ui/widgets/loading.py +170 -0
- opscloud/ui/widgets/mcp_viewer.py +1204 -0
- opscloud/ui/widgets/messages.py +4461 -0
- opscloud/ui/widgets/model_selector.py +863 -0
- opscloud/ui/widgets/notification_center.py +108 -0
- opscloud/ui/widgets/notification_settings.py +119 -0
- opscloud/ui/widgets/operation_card.py +106 -0
- opscloud/ui/widgets/permissions_manager.py +667 -0
- opscloud/ui/widgets/plugin_manager.py +1212 -0
- opscloud/ui/widgets/pool_selector.py +376 -0
- opscloud/ui/widgets/preamble.py +53 -0
- opscloud/ui/widgets/skills_viewer.py +432 -0
- opscloud/ui/widgets/status.py +1037 -0
- opscloud/ui/widgets/subagent_panel.py +927 -0
- opscloud/ui/widgets/theme_selector.py +232 -0
- opscloud/ui/widgets/thread_selector.py +358 -0
- opscloud/ui/widgets/toast.py +31 -0
- opscloud/ui/widgets/tool_display.py +325 -0
- opscloud/ui/widgets/tool_renderers.py +234 -0
- opscloud/ui/widgets/tool_widgets.py +278 -0
- opscloud/ui/widgets/welcome.py +421 -0
- opscloud/ui/widgets/welcome_popup.py +102 -0
- opscloud/utils/__init__.py +24 -0
- opscloud/utils/cost_estimation.py +169 -0
- opscloud/utils/git.py +390 -0
- opscloud/utils/logger.py +571 -0
- opscloud/utils/session_stats.py +115 -0
- opscloud/utils/startup_error.py +37 -0
- talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
- talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
- talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
- talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
- talkops_opscloud-0.3.0b1.dist-info/licenses/LICENSE +175 -0
|
@@ -0,0 +1,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
|
+
|