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
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)
|
opscloud/mcp/mcp_info.py
ADDED
|
@@ -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
|