zjcode 0.0.1__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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Validation and environment-variable expansion for MCP server config.
|
|
2
|
+
|
|
3
|
+
Resolves `${VAR}` and `${VAR:-default}` references in the supported
|
|
4
|
+
configuration fields (`command`, `url`, `args`, `env`, `headers`) and
|
|
5
|
+
validates their types. A `${VAR:-default}` reference falls back to
|
|
6
|
+
`default` when `VAR` is unset *or* empty (POSIX `:-` semantics).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
|
|
19
|
+
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^{}]*))?\}")
|
|
20
|
+
"""Matches a supported reference: `${VAR}` or `${VAR:-default}`.
|
|
21
|
+
|
|
22
|
+
Group 1 is the variable name; group 2 (present only for the `:-` form) is
|
|
23
|
+
the default. A bare `$VAR` and a literal `$` are intentionally not matched.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
_ENV_BRACE_RE = re.compile(r"\$\{")
|
|
27
|
+
"""Matches a `${` brace-open, used to catch malformed `${...}` references."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _interpolate_env(value: str, *, field: str) -> str:
|
|
31
|
+
"""Expand `${VAR}` / `${VAR:-default}` references in one config string.
|
|
32
|
+
|
|
33
|
+
A bare `$VAR` (no braces) and a literal `$` pass through untouched;
|
|
34
|
+
only the braced forms expand. `${VAR:-default}` uses `default` when
|
|
35
|
+
`VAR` is unset or empty. A `${...}` that does not parse as one of the
|
|
36
|
+
supported forms (e.g. `${VAR-default}` or an unterminated `${VAR`) is
|
|
37
|
+
rejected rather than silently emitted, so a typo cannot inject a
|
|
38
|
+
garbage value into a URL, command, or header.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
value: Raw configuration string.
|
|
42
|
+
field: Fully qualified field path for error messages.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The interpolated string.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
RuntimeError: If a required environment variable is unset, or the
|
|
49
|
+
string contains a malformed `${...}` reference.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def replace(match: re.Match[str]) -> str:
|
|
53
|
+
name = match.group(1)
|
|
54
|
+
default = match.group(2)
|
|
55
|
+
resolved = os.environ.get(name)
|
|
56
|
+
# A non-empty value always wins, for both `${VAR}` and `${VAR:-default}`.
|
|
57
|
+
if resolved:
|
|
58
|
+
return resolved
|
|
59
|
+
# `resolved` is now "" (set but empty) or None (unset).
|
|
60
|
+
if default is not None:
|
|
61
|
+
# `${VAR:-default}`: `:-` falls back for empty *and* unset (POSIX).
|
|
62
|
+
return default
|
|
63
|
+
if resolved is not None:
|
|
64
|
+
# `${VAR}` set to "": no default, so emit the empty value.
|
|
65
|
+
return resolved
|
|
66
|
+
# `${VAR}` unset with no default: the only hard error.
|
|
67
|
+
msg = (
|
|
68
|
+
f"{field} references unset env var {name}. "
|
|
69
|
+
f"Set {name} in the environment or provide a default."
|
|
70
|
+
)
|
|
71
|
+
raise RuntimeError(msg)
|
|
72
|
+
|
|
73
|
+
# Reject any `${` that isn't the start of a well-formed reference. The
|
|
74
|
+
# check is against the raw `value` (not the substituted result) so a
|
|
75
|
+
# resolved value that happens to contain `${` never trips it.
|
|
76
|
+
ref_spans = [match.span() for match in _ENV_REF_RE.finditer(value)]
|
|
77
|
+
for brace in _ENV_BRACE_RE.finditer(value):
|
|
78
|
+
if not any(start <= brace.start() < end for start, end in ref_spans):
|
|
79
|
+
msg = (
|
|
80
|
+
f"{field} contains a malformed '${{...}}' reference. "
|
|
81
|
+
"Use '${VAR}' or '${VAR:-default}'."
|
|
82
|
+
)
|
|
83
|
+
raise RuntimeError(msg)
|
|
84
|
+
|
|
85
|
+
return _ENV_REF_RE.sub(replace, value)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _resolve_string(value: object, *, field: str) -> str:
|
|
89
|
+
"""Validate and interpolate one string field.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
value: Raw field value.
|
|
93
|
+
field: Fully qualified field path for error messages.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The validated and interpolated string.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
TypeError: If the field value is not a string.
|
|
100
|
+
"""
|
|
101
|
+
if not isinstance(value, str):
|
|
102
|
+
msg = f"{field} must be a string, got {type(value).__name__}"
|
|
103
|
+
raise TypeError(msg)
|
|
104
|
+
return _interpolate_env(value, field=field)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _resolve_mapping_values(
|
|
108
|
+
values: Mapping[str, object],
|
|
109
|
+
*,
|
|
110
|
+
field: str,
|
|
111
|
+
) -> dict[str, str]:
|
|
112
|
+
"""Validate and interpolate string values in a mapping field.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
values: Raw mapping values.
|
|
116
|
+
field: Fully qualified field path for error messages.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A new mapping with validated and interpolated values.
|
|
120
|
+
"""
|
|
121
|
+
return {
|
|
122
|
+
name: _resolve_string(value, field=f"{field}.{name}")
|
|
123
|
+
for name, value in values.items()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def resolve_mcp_server_env(
|
|
128
|
+
server_name: str,
|
|
129
|
+
server_config: Mapping[str, object],
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""Resolve `${VAR}` references in one MCP server's supported fields.
|
|
132
|
+
|
|
133
|
+
Interpolates the `command`, `url`, `args`, `env`, and `headers`
|
|
134
|
+
fields (see `_interpolate_env` for the reference syntax); every other
|
|
135
|
+
field is copied through verbatim. The input is not mutated.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
server_name: Server name used in field-specific error messages.
|
|
139
|
+
server_config: Raw server configuration.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
A resolved copy of the server configuration.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
TypeError: If a supported field has the wrong type — a non-string
|
|
146
|
+
scalar value, or `args`/`env`/`headers` with the wrong container
|
|
147
|
+
type.
|
|
148
|
+
RuntimeError: If a required environment variable is unset.
|
|
149
|
+
""" # noqa: DOC502 - `RuntimeError` is raised by `_interpolate_env`
|
|
150
|
+
resolved: dict[str, Any] = copy.deepcopy(dict(server_config))
|
|
151
|
+
prefix = f"mcpServers.{server_name}"
|
|
152
|
+
|
|
153
|
+
for name in ("command", "url"):
|
|
154
|
+
if name in resolved:
|
|
155
|
+
resolved[name] = _resolve_string(resolved[name], field=f"{prefix}.{name}")
|
|
156
|
+
|
|
157
|
+
if "args" in resolved:
|
|
158
|
+
args = resolved["args"]
|
|
159
|
+
if not isinstance(args, list):
|
|
160
|
+
msg = f"{prefix}.args must be a list, got {type(args).__name__}"
|
|
161
|
+
raise TypeError(msg)
|
|
162
|
+
resolved["args"] = [
|
|
163
|
+
_resolve_string(value, field=f"{prefix}.args[{index}]")
|
|
164
|
+
for index, value in enumerate(args)
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
for name in ("env", "headers"):
|
|
168
|
+
if name not in resolved:
|
|
169
|
+
continue
|
|
170
|
+
values = resolved[name]
|
|
171
|
+
if not isinstance(values, dict):
|
|
172
|
+
msg = f"{prefix}.{name} must be a dictionary, got {type(values).__name__}"
|
|
173
|
+
raise TypeError(msg)
|
|
174
|
+
resolved[name] = _resolve_mapping_values(values, field=f"{prefix}.{name}")
|
|
175
|
+
|
|
176
|
+
return resolved
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Persistent store of MCP server names the user has disabled.
|
|
2
|
+
|
|
3
|
+
Disabled servers are skipped at config merge time so their tools never
|
|
4
|
+
reach the agent and no connection is attempted. State lives under
|
|
5
|
+
`[mcp].disabled_servers` in `~/.deepagents/config.toml`, alongside the
|
|
6
|
+
user's other MCP configuration.
|
|
7
|
+
|
|
8
|
+
The store keys on server *name* alone. Two configs that both declare a
|
|
9
|
+
`github` server will both be disabled by a single entry — intentional,
|
|
10
|
+
since the agent cannot distinguish overlapping names at runtime anyway
|
|
11
|
+
(later configs in the merge order win).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import tempfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from deepagents_code.model_config import DEFAULT_CONFIG_PATH as _DEFAULT_CONFIG_PATH
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_SECTION = "mcp"
|
|
28
|
+
_KEY = "disabled_servers"
|
|
29
|
+
_LEGACY_SECTION = "mcp_disabled"
|
|
30
|
+
_LEGACY_KEY = "servers"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _ConfigLoadError(Exception):
|
|
34
|
+
"""Raised when the config exists but cannot be parsed or read.
|
|
35
|
+
|
|
36
|
+
Distinct from "file does not exist" so callers can refuse to
|
|
37
|
+
overwrite a config they could not parse — otherwise a transient
|
|
38
|
+
read error or a hand-edit typo would silently truncate sibling
|
|
39
|
+
sections (e.g. model profiles) on the next write.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load_config(config_path: Path) -> dict[str, Any]:
|
|
44
|
+
"""Read the TOML config file.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
config_path: Path to the TOML config file.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Parsed TOML data, or an empty dict if the file does not exist.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
_ConfigLoadError: If the file exists but cannot be read or parsed.
|
|
54
|
+
"""
|
|
55
|
+
import tomllib
|
|
56
|
+
|
|
57
|
+
if not config_path.exists():
|
|
58
|
+
return {}
|
|
59
|
+
try:
|
|
60
|
+
with config_path.open("rb") as f:
|
|
61
|
+
return tomllib.load(f)
|
|
62
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
63
|
+
logger.warning(
|
|
64
|
+
"Could not read MCP disabled config at %s: %s",
|
|
65
|
+
config_path,
|
|
66
|
+
exc,
|
|
67
|
+
)
|
|
68
|
+
msg = f"could not load {config_path}: {exc}"
|
|
69
|
+
raise _ConfigLoadError(msg) from exc
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _save_config(data: dict[str, Any], config_path: Path) -> bool:
|
|
73
|
+
"""Atomic TOML write.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
`True` on success, `False` on I/O failure.
|
|
77
|
+
"""
|
|
78
|
+
import tomli_w
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
|
|
83
|
+
try:
|
|
84
|
+
with os.fdopen(fd, "wb") as f:
|
|
85
|
+
tomli_w.dump(data, f)
|
|
86
|
+
Path(tmp_path).replace(config_path)
|
|
87
|
+
except BaseException:
|
|
88
|
+
with contextlib.suppress(OSError):
|
|
89
|
+
Path(tmp_path).unlink()
|
|
90
|
+
raise
|
|
91
|
+
except (OSError, ValueError):
|
|
92
|
+
logger.exception("Failed to save config to %s", config_path)
|
|
93
|
+
return False
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _coerce_entries(entries: object) -> set[str] | None:
|
|
98
|
+
"""Return valid server names from a TOML value, or `None` when unset."""
|
|
99
|
+
if not isinstance(entries, list):
|
|
100
|
+
return None
|
|
101
|
+
return {name for name in entries if isinstance(name, str) and name}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _disabled_entries(data: dict[str, Any]) -> set[str]:
|
|
105
|
+
"""Return disabled names from the current config shape with legacy fallback."""
|
|
106
|
+
section = data.get(_SECTION)
|
|
107
|
+
if isinstance(section, dict):
|
|
108
|
+
entries = _coerce_entries(section.get(_KEY))
|
|
109
|
+
if entries is not None:
|
|
110
|
+
return entries
|
|
111
|
+
|
|
112
|
+
legacy_section = data.get(_LEGACY_SECTION)
|
|
113
|
+
if isinstance(legacy_section, dict):
|
|
114
|
+
entries = _coerce_entries(legacy_section.get(_LEGACY_KEY))
|
|
115
|
+
if entries is not None:
|
|
116
|
+
return entries
|
|
117
|
+
|
|
118
|
+
return set()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _remove_legacy_disabled_section(data: dict[str, Any]) -> None:
|
|
122
|
+
"""Drop the old top-level section after writing the folded config shape."""
|
|
123
|
+
legacy_section = data.get(_LEGACY_SECTION)
|
|
124
|
+
if not isinstance(legacy_section, dict):
|
|
125
|
+
data.pop(_LEGACY_SECTION, None)
|
|
126
|
+
return
|
|
127
|
+
legacy_section.pop(_LEGACY_KEY, None)
|
|
128
|
+
if legacy_section:
|
|
129
|
+
data[_LEGACY_SECTION] = legacy_section
|
|
130
|
+
else:
|
|
131
|
+
data.pop(_LEGACY_SECTION, None)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_disabled_servers(*, config_path: Path | None = None) -> set[str]:
|
|
135
|
+
"""Return the set of server names the user has disabled.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
config_path: Override the default config location; intended for tests.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Set of server names. Empty when nothing is disabled or the config
|
|
142
|
+
cannot be read.
|
|
143
|
+
"""
|
|
144
|
+
if config_path is None:
|
|
145
|
+
config_path = _DEFAULT_CONFIG_PATH
|
|
146
|
+
try:
|
|
147
|
+
data = _load_config(config_path)
|
|
148
|
+
except _ConfigLoadError:
|
|
149
|
+
return set()
|
|
150
|
+
return _disabled_entries(data)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_server_disabled(server_name: str, *, config_path: Path | None = None) -> bool:
|
|
154
|
+
"""Return `True` when `server_name` is in the disabled set.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
server_name: MCP server name from `mcpServers` config.
|
|
158
|
+
config_path: Override the default config location; intended for tests.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
`True` when the server is recorded as disabled, `False` otherwise
|
|
162
|
+
(including when the config cannot be read).
|
|
163
|
+
"""
|
|
164
|
+
return server_name in get_disabled_servers(config_path=config_path)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def set_server_disabled(
|
|
168
|
+
server_name: str,
|
|
169
|
+
disabled: bool,
|
|
170
|
+
*,
|
|
171
|
+
config_path: Path | None = None,
|
|
172
|
+
) -> tuple[bool, str | None]:
|
|
173
|
+
"""Add or remove `server_name` from the persistent disabled set.
|
|
174
|
+
|
|
175
|
+
Refuses to write when the existing config cannot be parsed so a
|
|
176
|
+
corrupt or permission-denied file is not silently overwritten —
|
|
177
|
+
that would discard sibling sections such as model profiles.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
server_name: MCP server name from `mcpServers` config.
|
|
181
|
+
disabled: `True` to disable, `False` to re-enable.
|
|
182
|
+
config_path: Override the default config location; intended for tests.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Tuple of `(ok, error_detail)`. `ok` is `True` on success; on
|
|
186
|
+
failure `error_detail` is a short user-facing string suitable
|
|
187
|
+
for a toast.
|
|
188
|
+
"""
|
|
189
|
+
if config_path is None:
|
|
190
|
+
config_path = _DEFAULT_CONFIG_PATH
|
|
191
|
+
try:
|
|
192
|
+
data = _load_config(config_path)
|
|
193
|
+
except _ConfigLoadError as exc:
|
|
194
|
+
return False, str(exc)
|
|
195
|
+
current = _disabled_entries(data)
|
|
196
|
+
previous = set(current)
|
|
197
|
+
if disabled:
|
|
198
|
+
current.add(server_name)
|
|
199
|
+
else:
|
|
200
|
+
current.discard(server_name)
|
|
201
|
+
if current == previous and _LEGACY_SECTION not in data:
|
|
202
|
+
return True, None
|
|
203
|
+
|
|
204
|
+
section = data.get(_SECTION)
|
|
205
|
+
if not isinstance(section, dict):
|
|
206
|
+
section = {}
|
|
207
|
+
section[_KEY] = sorted(current)
|
|
208
|
+
data[_SECTION] = section
|
|
209
|
+
_remove_legacy_disabled_section(data)
|
|
210
|
+
if _save_config(data, config_path):
|
|
211
|
+
return True, None
|
|
212
|
+
return False, f"could not write {config_path}"
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""UI-agnostic helpers for resolving an MCP login target.
|
|
2
|
+
|
|
3
|
+
The MCP login flow historically inlined config discovery, trust gating,
|
|
4
|
+
shape validation, and `print()`-based error reporting. The TUI cannot
|
|
5
|
+
consume those print statements, so this module extracts the same logic
|
|
6
|
+
into pure functions that return structured results (`ConfigResolution`,
|
|
7
|
+
`ServerSelection`) plus a typed `ConfigResolutionError`. Callers decide
|
|
8
|
+
how to render those results.
|
|
9
|
+
|
|
10
|
+
No `print()` calls live in this module. No imports happen at module
|
|
11
|
+
top level beyond `dataclasses`/`typing`/`pathlib` so the CLI fast path
|
|
12
|
+
stays cheap; the actual config loaders are imported inside the
|
|
13
|
+
functions that need them.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from enum import StrEnum
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from deepagents_code.mcp_auth import McpServerSpec
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigErrorKind(StrEnum):
|
|
28
|
+
"""Discriminator for `ConfigResolutionError` reasons.
|
|
29
|
+
|
|
30
|
+
Only `NO_CONFIG_FOUND` maps to exit code 2 in `run_mcp_login`; all
|
|
31
|
+
other kinds map to exit code 1. The TUI surface translates them into
|
|
32
|
+
in-app status messages.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
EXPLICIT_LOAD_FAILED = "explicit_load_failed"
|
|
36
|
+
"""The `--mcp-config` path could not be parsed."""
|
|
37
|
+
|
|
38
|
+
NO_CONFIG_FOUND = "no_config_found"
|
|
39
|
+
"""Auto-discovery returned zero candidate paths."""
|
|
40
|
+
|
|
41
|
+
NO_USABLE_CONFIG = "no_usable_config"
|
|
42
|
+
"""Discovered paths existed but none could be loaded successfully."""
|
|
43
|
+
|
|
44
|
+
UNKNOWN_SERVER = "unknown_server"
|
|
45
|
+
"""The selected server is not present in the resolved config."""
|
|
46
|
+
|
|
47
|
+
INVALID_SERVER_CONFIG = "invalid_server_config"
|
|
48
|
+
"""The selected server's entry failed shape validation."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class ConfigResolutionError:
|
|
53
|
+
"""Structured error returned when a login target cannot be resolved."""
|
|
54
|
+
|
|
55
|
+
kind: ConfigErrorKind
|
|
56
|
+
"""Reason category — callers translate this into UI text or exit codes."""
|
|
57
|
+
|
|
58
|
+
message: str
|
|
59
|
+
"""Plain-text description suitable for direct display to the user."""
|
|
60
|
+
|
|
61
|
+
untrusted_project_paths: tuple[Path, ...] = ()
|
|
62
|
+
"""Project-level configs skipped because the trust store had no match.
|
|
63
|
+
|
|
64
|
+
Populated only when at least one discovered project config was
|
|
65
|
+
skipped during auto-discovery, regardless of `kind`. Callers can
|
|
66
|
+
surface a "skipping untrusted project config" hint alongside the
|
|
67
|
+
primary error.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class ConfigResolution:
|
|
73
|
+
"""Successful resolution of a merged MCP config for login."""
|
|
74
|
+
|
|
75
|
+
config: dict[str, Any]
|
|
76
|
+
"""The merged `mcpServers`-shaped config dict."""
|
|
77
|
+
|
|
78
|
+
used_paths: tuple[Path, ...]
|
|
79
|
+
"""Paths whose contents were merged into `config`, in precedence order."""
|
|
80
|
+
|
|
81
|
+
untrusted_project_paths: tuple[Path, ...] = ()
|
|
82
|
+
"""Project-level configs that were skipped during this resolution."""
|
|
83
|
+
|
|
84
|
+
def __post_init__(self) -> None:
|
|
85
|
+
"""Enforce the non-empty `used_paths` invariant.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
ValueError: If `used_paths` is empty.
|
|
89
|
+
"""
|
|
90
|
+
if not self.used_paths:
|
|
91
|
+
msg = "ConfigResolution must have at least one used path"
|
|
92
|
+
raise ValueError(msg)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def search_label(self) -> str:
|
|
96
|
+
"""Human-readable join of the paths backing this resolution."""
|
|
97
|
+
return ", ".join(str(path) for path in self.used_paths)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class ServerSelection:
|
|
102
|
+
"""Resolved server config plus enough context for error messages."""
|
|
103
|
+
|
|
104
|
+
server_name: str
|
|
105
|
+
"""Selected MCP server name (matches an `mcpServers` key)."""
|
|
106
|
+
|
|
107
|
+
server_config: McpServerSpec
|
|
108
|
+
"""Validated server config payload for `mcp_auth.login`."""
|
|
109
|
+
|
|
110
|
+
search_label: str = ""
|
|
111
|
+
"""Where the config came from — used in not-found errors."""
|
|
112
|
+
|
|
113
|
+
def __post_init__(self) -> None:
|
|
114
|
+
"""Enforce the non-empty `server_name` invariant.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
ValueError: If `server_name` is empty.
|
|
118
|
+
"""
|
|
119
|
+
if not self.server_name:
|
|
120
|
+
msg = "ServerSelection.server_name must not be empty"
|
|
121
|
+
raise ValueError(msg)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def resolve_mcp_config(
|
|
125
|
+
config_path: str | None,
|
|
126
|
+
) -> ConfigResolution | ConfigResolutionError:
|
|
127
|
+
"""Resolve an MCP config dict for login without printing anything.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
config_path: Explicit `--mcp-config` path, or `None` for auto-discovery.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
A `ConfigResolution` on success, or a `ConfigResolutionError`
|
|
134
|
+
describing why no usable config could be assembled.
|
|
135
|
+
"""
|
|
136
|
+
from deepagents_code.mcp_tools import (
|
|
137
|
+
classify_discovered_configs,
|
|
138
|
+
discover_mcp_configs,
|
|
139
|
+
load_mcp_config,
|
|
140
|
+
load_mcp_config_lenient,
|
|
141
|
+
merge_mcp_configs,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if config_path is not None:
|
|
145
|
+
try:
|
|
146
|
+
config = load_mcp_config(config_path)
|
|
147
|
+
except (OSError, TypeError, ValueError, RuntimeError) as exc:
|
|
148
|
+
return ConfigResolutionError(
|
|
149
|
+
kind=ConfigErrorKind.EXPLICIT_LOAD_FAILED,
|
|
150
|
+
message=f"Failed to load MCP config {config_path}: {exc}",
|
|
151
|
+
)
|
|
152
|
+
return ConfigResolution(
|
|
153
|
+
config=config,
|
|
154
|
+
used_paths=(Path(config_path),),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
found = discover_mcp_configs()
|
|
158
|
+
if not found:
|
|
159
|
+
return ConfigResolutionError(
|
|
160
|
+
kind=ConfigErrorKind.NO_CONFIG_FOUND,
|
|
161
|
+
message=(
|
|
162
|
+
"No MCP config file found in any auto-discovered location. "
|
|
163
|
+
"Pass --mcp-config <path>, or run `dcode mcp login --help` "
|
|
164
|
+
"to see the search paths and config format."
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
user_paths, project_paths = classify_discovered_configs(found)
|
|
169
|
+
configs: list[dict[str, Any]] = []
|
|
170
|
+
used_paths: list[Path] = []
|
|
171
|
+
untrusted: tuple[Path, ...] = ()
|
|
172
|
+
|
|
173
|
+
for path in user_paths:
|
|
174
|
+
loaded = load_mcp_config_lenient(path)
|
|
175
|
+
if loaded is not None:
|
|
176
|
+
configs.append(loaded)
|
|
177
|
+
used_paths.append(path)
|
|
178
|
+
|
|
179
|
+
if project_paths:
|
|
180
|
+
from deepagents_code.mcp_trust import (
|
|
181
|
+
compute_config_fingerprint,
|
|
182
|
+
is_project_mcp_trusted,
|
|
183
|
+
)
|
|
184
|
+
from deepagents_code.project_utils import find_project_root
|
|
185
|
+
|
|
186
|
+
project_root = str((find_project_root() or Path.cwd()).resolve())
|
|
187
|
+
fingerprint = compute_config_fingerprint(project_paths)
|
|
188
|
+
if is_project_mcp_trusted(project_root, fingerprint):
|
|
189
|
+
for path in project_paths:
|
|
190
|
+
loaded = load_mcp_config_lenient(path)
|
|
191
|
+
if loaded is not None:
|
|
192
|
+
configs.append(loaded)
|
|
193
|
+
used_paths.append(path)
|
|
194
|
+
else:
|
|
195
|
+
untrusted = tuple(project_paths)
|
|
196
|
+
|
|
197
|
+
if not configs:
|
|
198
|
+
found_paths = ", ".join(str(path) for path in found)
|
|
199
|
+
return ConfigResolutionError(
|
|
200
|
+
kind=ConfigErrorKind.NO_USABLE_CONFIG,
|
|
201
|
+
message=f"No usable MCP config found in: {found_paths}",
|
|
202
|
+
untrusted_project_paths=untrusted,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return ConfigResolution(
|
|
206
|
+
config=merge_mcp_configs(configs),
|
|
207
|
+
used_paths=tuple(used_paths),
|
|
208
|
+
untrusted_project_paths=untrusted,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def select_server(
|
|
213
|
+
resolution: ConfigResolution,
|
|
214
|
+
server: str,
|
|
215
|
+
) -> ServerSelection | ConfigResolutionError:
|
|
216
|
+
"""Pull `server` out of a resolved config and validate its shape.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
resolution: A successful `resolve_mcp_config` result.
|
|
220
|
+
server: Target server name as supplied by the user.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
A `ServerSelection` on success, or a `ConfigResolutionError`
|
|
224
|
+
describing why the server entry is unusable.
|
|
225
|
+
"""
|
|
226
|
+
from deepagents_code.mcp_tools import _validate_server_config
|
|
227
|
+
|
|
228
|
+
servers = resolution.config.get("mcpServers", {})
|
|
229
|
+
if server not in servers:
|
|
230
|
+
return ConfigResolutionError(
|
|
231
|
+
kind=ConfigErrorKind.UNKNOWN_SERVER,
|
|
232
|
+
message=(
|
|
233
|
+
f"Server {server!r} not found in {resolution.search_label}. "
|
|
234
|
+
f"Known servers: {sorted(servers)}"
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
_validate_server_config(server, servers[server])
|
|
240
|
+
except (TypeError, ValueError) as exc:
|
|
241
|
+
return ConfigResolutionError(
|
|
242
|
+
kind=ConfigErrorKind.INVALID_SERVER_CONFIG,
|
|
243
|
+
message=f"Invalid MCP server config for {server!r}: {exc}",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return ServerSelection(
|
|
247
|
+
server_name=server,
|
|
248
|
+
server_config=servers[server],
|
|
249
|
+
search_label=resolution.search_label,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def format_untrusted_project_notice(paths: tuple[Path, ...]) -> str:
|
|
254
|
+
"""Build the CLI-style hint string for skipped untrusted project configs.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
paths: Project configs that were skipped during resolution.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
A single-line user-facing string. Empty when `paths` is empty.
|
|
261
|
+
"""
|
|
262
|
+
if not paths:
|
|
263
|
+
return ""
|
|
264
|
+
skipped = ", ".join(str(path) for path in paths)
|
|
265
|
+
return (
|
|
266
|
+
"Skipping untrusted project MCP config "
|
|
267
|
+
f"(not yet approved or config changed): {skipped}. "
|
|
268
|
+
"Approve it by running `dcode` in this project, or "
|
|
269
|
+
"pass --mcp-config <path> to use it explicitly."
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
__all__ = [
|
|
274
|
+
"ConfigErrorKind",
|
|
275
|
+
"ConfigResolution",
|
|
276
|
+
"ConfigResolutionError",
|
|
277
|
+
"ServerSelection",
|
|
278
|
+
"format_untrusted_project_notice",
|
|
279
|
+
"resolve_mcp_config",
|
|
280
|
+
"select_server",
|
|
281
|
+
]
|