mycode-coding-agent 0.1.0__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.
- mycode/__init__.py +0 -0
- mycode/adapters/__init__.py +21 -0
- mycode/adapters/jsonl.py +692 -0
- mycode/agent/__init__.py +25 -0
- mycode/agent/events.py +111 -0
- mycode/agent/outcome.py +103 -0
- mycode/agent/progress.py +373 -0
- mycode/agent/runner.py +1481 -0
- mycode/application/__init__.py +38 -0
- mycode/application/agent_session.py +367 -0
- mycode/application/events.py +59 -0
- mycode/application/runtime.py +211 -0
- mycode/application/sessions.py +180 -0
- mycode/cli.py +840 -0
- mycode/config.py +355 -0
- mycode/context/__init__.py +1 -0
- mycode/context/artifacts.py +672 -0
- mycode/context/budget.py +752 -0
- mycode/context/builder.py +112 -0
- mycode/context/compact.py +795 -0
- mycode/context/tool_result_format.py +199 -0
- mycode/context/tool_result_retention.py +261 -0
- mycode/conversation.py +78 -0
- mycode/error_handling.py +481 -0
- mycode/event_format.py +147 -0
- mycode/instructions.py +285 -0
- mycode/llm.py +771 -0
- mycode/mcp/__init__.py +41 -0
- mycode/mcp/client.py +44 -0
- mycode/mcp/config.py +207 -0
- mycode/mcp/errors.py +302 -0
- mycode/mcp/manager.py +339 -0
- mycode/mcp/models.py +20 -0
- mycode/mcp/result_adapter.py +58 -0
- mycode/mcp/tool_adapter.py +145 -0
- mycode/mcp/trust.py +313 -0
- mycode/memory.py +570 -0
- mycode/memory_context.py +245 -0
- mycode/messages.py +63 -0
- mycode/observability.py +28 -0
- mycode/permissions.py +262 -0
- mycode/persistence/__init__.py +1 -0
- mycode/persistence/filesystem.py +291 -0
- mycode/persistence/project_storage.py +208 -0
- mycode/persistence/session_lock.py +138 -0
- mycode/persistence/session_store.py +503 -0
- mycode/presentation/__init__.py +1 -0
- mycode/presentation/cli/__init__.py +14 -0
- mycode/presentation/cli/confirmer.py +116 -0
- mycode/presentation/cli/mcp_trust.py +61 -0
- mycode/presentation/cli/presenter.py +320 -0
- mycode/presentation/cli/session_menu.py +146 -0
- mycode/presentation/cli/subagent_observer.py +124 -0
- mycode/presentation/command_format.py +90 -0
- mycode/presentation/commands.py +95 -0
- mycode/presentation/tui/__init__.py +6 -0
- mycode/presentation/tui/app.py +1351 -0
- mycode/presentation/tui/interactions.py +253 -0
- mycode/presentation/tui/presenter.py +266 -0
- mycode/presentation/tui/screens.py +305 -0
- mycode/presentation/tui/widgets.py +214 -0
- mycode/project.py +22 -0
- mycode/prompts.py +181 -0
- mycode/reasoning.py +40 -0
- mycode/session.py +86 -0
- mycode/skills/__init__.py +27 -0
- mycode/skills/builtin/database-recovery/SKILL.md +138 -0
- mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
- mycode/skills/registry.py +295 -0
- mycode/skills/state.py +68 -0
- mycode/subagents/__init__.py +1 -0
- mycode/subagents/audit.py +212 -0
- mycode/subagents/concurrency.py +124 -0
- mycode/subagents/contracts.py +421 -0
- mycode/subagents/delegate.py +80 -0
- mycode/subagents/delegation.py +128 -0
- mycode/subagents/lifecycle.py +86 -0
- mycode/subagents/limits.py +7 -0
- mycode/subagents/observability.py +150 -0
- mycode/subagents/persistence.py +152 -0
- mycode/subagents/profiles.py +184 -0
- mycode/subagents/prompts.py +67 -0
- mycode/subagents/results.py +178 -0
- mycode/subagents/runtime.py +528 -0
- mycode/subagents/snapshots.py +211 -0
- mycode/subagents/tool_batch.py +260 -0
- mycode/tools/__init__.py +81 -0
- mycode/tools/base.py +222 -0
- mycode/tools/bounds.py +14 -0
- mycode/tools/command_executor.py +167 -0
- mycode/tools/command_output.py +166 -0
- mycode/tools/command_risk.py +596 -0
- mycode/tools/defaults.py +59 -0
- mycode/tools/edit_file.py +524 -0
- mycode/tools/file_mutation.py +30 -0
- mycode/tools/glob.py +247 -0
- mycode/tools/grep.py +324 -0
- mycode/tools/ignore.py +122 -0
- mycode/tools/inspect_changes.py +269 -0
- mycode/tools/load_skill.py +92 -0
- mycode/tools/memory.py +264 -0
- mycode/tools/path_permissions.py +78 -0
- mycode/tools/patterns.py +48 -0
- mycode/tools/permission_metadata.py +27 -0
- mycode/tools/process_tree.py +166 -0
- mycode/tools/read_file.py +242 -0
- mycode/tools/read_skill_resource.py +93 -0
- mycode/tools/registry.py +279 -0
- mycode/tools/run_command.py +237 -0
- mycode/tools/run_skill_script.py +206 -0
- mycode/tools/run_validation.py +107 -0
- mycode/tools/submit_result.py +93 -0
- mycode/tools/text.py +15 -0
- mycode/tools/validation_command.py +377 -0
- mycode/tools/workspace.py +33 -0
- mycode/tools/write_file.py +169 -0
- mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
- mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
- mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
mycode/mcp/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from mycode.mcp.config import (
|
|
2
|
+
MCPConfig,
|
|
3
|
+
MCPConfigError,
|
|
4
|
+
MCPLoadedConfig,
|
|
5
|
+
load_mcp_config,
|
|
6
|
+
load_mcp_config_layers,
|
|
7
|
+
)
|
|
8
|
+
from mycode.mcp.errors import safe_error_summary
|
|
9
|
+
from mycode.mcp.manager import MCPManager
|
|
10
|
+
from mycode.mcp.models import MCPServerStatus, MCPShutdownStatus
|
|
11
|
+
from mycode.mcp.tool_adapter import MCPToolAdapter, build_registry_name
|
|
12
|
+
from mycode.mcp.trust import (
|
|
13
|
+
MCPTrustConfirmer,
|
|
14
|
+
MCPTrustRequest,
|
|
15
|
+
MCPTrustResolution,
|
|
16
|
+
MCPTrustServer,
|
|
17
|
+
MCPTrustWarning,
|
|
18
|
+
apply_project_mcp_trust,
|
|
19
|
+
resolve_project_mcp_trust,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"MCPConfig",
|
|
24
|
+
"MCPConfigError",
|
|
25
|
+
"MCPLoadedConfig",
|
|
26
|
+
"MCPManager",
|
|
27
|
+
"MCPServerStatus",
|
|
28
|
+
"MCPShutdownStatus",
|
|
29
|
+
"MCPToolAdapter",
|
|
30
|
+
"MCPTrustConfirmer",
|
|
31
|
+
"MCPTrustRequest",
|
|
32
|
+
"MCPTrustResolution",
|
|
33
|
+
"MCPTrustServer",
|
|
34
|
+
"MCPTrustWarning",
|
|
35
|
+
"apply_project_mcp_trust",
|
|
36
|
+
"build_registry_name",
|
|
37
|
+
"load_mcp_config",
|
|
38
|
+
"load_mcp_config_layers",
|
|
39
|
+
"resolve_project_mcp_trust",
|
|
40
|
+
"safe_error_summary",
|
|
41
|
+
]
|
mycode/mcp/client.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator
|
|
2
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
3
|
+
|
|
4
|
+
import httpx2
|
|
5
|
+
from mcp import Client, StdioServerParameters
|
|
6
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
7
|
+
|
|
8
|
+
from mycode.mcp.config import MCPServerConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@asynccontextmanager
|
|
12
|
+
async def open_mcp_client(config: MCPServerConfig) -> AsyncIterator[Client]:
|
|
13
|
+
async with AsyncExitStack() as stack:
|
|
14
|
+
if config.transport == "stdio":
|
|
15
|
+
client = Client(
|
|
16
|
+
StdioServerParameters(
|
|
17
|
+
command=config.command,
|
|
18
|
+
args=config.args,
|
|
19
|
+
env=config.env or None,
|
|
20
|
+
),
|
|
21
|
+
read_timeout_seconds=config.tool_timeout,
|
|
22
|
+
)
|
|
23
|
+
else:
|
|
24
|
+
http_client = await stack.enter_async_context(
|
|
25
|
+
httpx2.AsyncClient(
|
|
26
|
+
headers=config.headers,
|
|
27
|
+
event_hooks={"response": [_raise_auth_status]},
|
|
28
|
+
follow_redirects=True,
|
|
29
|
+
timeout=config.tool_timeout,
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
transport = streamable_http_client(
|
|
33
|
+
config.url,
|
|
34
|
+
http_client=http_client,
|
|
35
|
+
terminate_on_close=False,
|
|
36
|
+
)
|
|
37
|
+
client = Client(transport, read_timeout_seconds=config.tool_timeout)
|
|
38
|
+
yield await stack.enter_async_context(client)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _raise_auth_status(response: httpx2.Response) -> None:
|
|
42
|
+
"""Preserve 401/403 status without reading or exposing response content."""
|
|
43
|
+
if response.status_code in (401, 403):
|
|
44
|
+
response.raise_for_status()
|
mycode/mcp/config.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import (
|
|
9
|
+
BaseModel,
|
|
10
|
+
ConfigDict,
|
|
11
|
+
Field,
|
|
12
|
+
ValidationError,
|
|
13
|
+
ValidationInfo,
|
|
14
|
+
field_validator,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from mycode.config import (
|
|
18
|
+
MYCODE_CONFIG_DIR_NAME,
|
|
19
|
+
load_layered_environment,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
DEFAULT_MCP_CONFIG_FILE = "mcp.json"
|
|
23
|
+
DEFAULT_MCP_CONNECT_TIMEOUT_SECONDS = 10.0
|
|
24
|
+
DEFAULT_MCP_TOOL_TIMEOUT_SECONDS = 60.0
|
|
25
|
+
DEFAULT_MCP_SHUTDOWN_TIMEOUT_SECONDS = 10.0
|
|
26
|
+
_ALIAS_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
27
|
+
_ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MCPConfigError(ValueError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _ServerBase(BaseModel):
|
|
35
|
+
model_config = ConfigDict(extra="forbid")
|
|
36
|
+
connect_timeout: float = Field(
|
|
37
|
+
default=DEFAULT_MCP_CONNECT_TIMEOUT_SECONDS, gt=0
|
|
38
|
+
)
|
|
39
|
+
tool_timeout: float = Field(default=DEFAULT_MCP_TOOL_TIMEOUT_SECONDS, gt=0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MCPStdioServerConfig(_ServerBase):
|
|
43
|
+
transport: Literal["stdio"]
|
|
44
|
+
command: str = Field(min_length=1)
|
|
45
|
+
args: list[str] = Field(default_factory=list)
|
|
46
|
+
env: dict[str, str] = Field(default_factory=dict, repr=False)
|
|
47
|
+
|
|
48
|
+
@field_validator("command")
|
|
49
|
+
@classmethod
|
|
50
|
+
def _non_blank_command(cls, value: str) -> str:
|
|
51
|
+
if value.strip() == "":
|
|
52
|
+
raise ValueError("command must not be blank")
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MCPStreamableHTTPServerConfig(_ServerBase):
|
|
57
|
+
transport: Literal["streamable_http"]
|
|
58
|
+
url: str = Field(min_length=1)
|
|
59
|
+
headers: dict[str, str] = Field(default_factory=dict, repr=False)
|
|
60
|
+
|
|
61
|
+
@field_validator("url")
|
|
62
|
+
@classmethod
|
|
63
|
+
def _http_url(cls, value: str, info: ValidationInfo) -> str:
|
|
64
|
+
allow_templates = bool(
|
|
65
|
+
info.context and info.context.get("allow_secret_templates")
|
|
66
|
+
)
|
|
67
|
+
if not value.startswith(("http://", "https://")) and not (
|
|
68
|
+
allow_templates and _ENV_PATTERN.search(value)
|
|
69
|
+
):
|
|
70
|
+
raise ValueError("url must use http or https")
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
MCPServerConfig = Annotated[
|
|
75
|
+
MCPStdioServerConfig | MCPStreamableHTTPServerConfig,
|
|
76
|
+
Field(discriminator="transport"),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MCPConfig(BaseModel):
|
|
81
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
82
|
+
mcp_servers: dict[str, MCPServerConfig] = Field(
|
|
83
|
+
default_factory=dict, alias="mcpServers"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@field_validator("mcp_servers")
|
|
87
|
+
@classmethod
|
|
88
|
+
def _valid_aliases(
|
|
89
|
+
cls, value: dict[str, MCPServerConfig]
|
|
90
|
+
) -> dict[str, MCPServerConfig]:
|
|
91
|
+
invalid = [alias for alias in value if _ALIAS_PATTERN.fullmatch(alias) is None]
|
|
92
|
+
if invalid:
|
|
93
|
+
raise ValueError("server aliases must match [A-Za-z0-9_-]{1,64}")
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class MCPLoadedConfig:
|
|
99
|
+
"""Resolved MCP layers plus the unresolved project layer used for trust."""
|
|
100
|
+
|
|
101
|
+
user: MCPConfig = field(repr=False)
|
|
102
|
+
project: MCPConfig = field(repr=False)
|
|
103
|
+
merged: MCPConfig = field(repr=False)
|
|
104
|
+
project_unresolved: MCPConfig = field(repr=False)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def default_mcp_config_file() -> Path:
|
|
108
|
+
return Path.home() / MYCODE_CONFIG_DIR_NAME / DEFAULT_MCP_CONFIG_FILE
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def project_mcp_config_file(workspace_root: str | Path) -> Path:
|
|
112
|
+
return Path(workspace_root) / MYCODE_CONFIG_DIR_NAME / DEFAULT_MCP_CONFIG_FILE
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_mcp_config(
|
|
116
|
+
config_file: str | Path | None = None,
|
|
117
|
+
*,
|
|
118
|
+
env_file: str | Path | None = None,
|
|
119
|
+
environ: Mapping[str, str] | None = None,
|
|
120
|
+
workspace_root: str | Path | None = None,
|
|
121
|
+
) -> MCPConfig:
|
|
122
|
+
return load_mcp_config_layers(
|
|
123
|
+
config_file,
|
|
124
|
+
env_file=env_file,
|
|
125
|
+
environ=environ,
|
|
126
|
+
workspace_root=workspace_root,
|
|
127
|
+
).merged
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load_mcp_config_layers(
|
|
131
|
+
config_file: str | Path | None = None,
|
|
132
|
+
*,
|
|
133
|
+
env_file: str | Path | None = None,
|
|
134
|
+
environ: Mapping[str, str] | None = None,
|
|
135
|
+
workspace_root: str | Path | None = None,
|
|
136
|
+
) -> MCPLoadedConfig:
|
|
137
|
+
user_path = default_mcp_config_file() if config_file is None else Path(config_file)
|
|
138
|
+
user_config = _load_mcp_config_file(user_path)
|
|
139
|
+
project_config = (
|
|
140
|
+
MCPConfig()
|
|
141
|
+
if workspace_root is None
|
|
142
|
+
else _load_mcp_config_file(project_mcp_config_file(workspace_root))
|
|
143
|
+
)
|
|
144
|
+
environment = load_layered_environment(
|
|
145
|
+
env_file,
|
|
146
|
+
workspace_root=workspace_root,
|
|
147
|
+
environ=environ,
|
|
148
|
+
)
|
|
149
|
+
resolved_user_config = _resolve_config_secrets(user_config, environment)
|
|
150
|
+
resolved_project_config = _resolve_config_secrets(project_config, environment)
|
|
151
|
+
return MCPLoadedConfig(
|
|
152
|
+
user=resolved_user_config,
|
|
153
|
+
project=resolved_project_config,
|
|
154
|
+
merged=merge_mcp_configs(resolved_user_config, resolved_project_config),
|
|
155
|
+
project_unresolved=project_config,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def merge_mcp_configs(user: MCPConfig, project: MCPConfig) -> MCPConfig:
|
|
160
|
+
return MCPConfig(
|
|
161
|
+
mcpServers={**user.mcp_servers, **project.mcp_servers}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _resolve_config_secrets(
|
|
166
|
+
config: MCPConfig,
|
|
167
|
+
environment: Mapping[str, str],
|
|
168
|
+
) -> MCPConfig:
|
|
169
|
+
resolved = _resolve_secrets(config.model_dump(by_alias=True), environment)
|
|
170
|
+
try:
|
|
171
|
+
return MCPConfig.model_validate(resolved)
|
|
172
|
+
except ValidationError as error:
|
|
173
|
+
raise MCPConfigError("Invalid MCP config: validation_error") from error
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _load_mcp_config_file(path: Path) -> MCPConfig:
|
|
177
|
+
if not path.exists():
|
|
178
|
+
return MCPConfig()
|
|
179
|
+
try:
|
|
180
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
181
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
182
|
+
raise MCPConfigError(f"Invalid MCP config: {type(error).__name__}") from error
|
|
183
|
+
try:
|
|
184
|
+
return MCPConfig.model_validate(
|
|
185
|
+
payload,
|
|
186
|
+
context={"allow_secret_templates": True},
|
|
187
|
+
)
|
|
188
|
+
except ValidationError as error:
|
|
189
|
+
raise MCPConfigError("Invalid MCP config: validation_error") from error
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _resolve_secrets(value: object, environment: Mapping[str, str | None]) -> object:
|
|
193
|
+
if isinstance(value, dict):
|
|
194
|
+
return {key: _resolve_secrets(item, environment) for key, item in value.items()}
|
|
195
|
+
if isinstance(value, list):
|
|
196
|
+
return [_resolve_secrets(item, environment) for item in value]
|
|
197
|
+
if not isinstance(value, str):
|
|
198
|
+
return value
|
|
199
|
+
|
|
200
|
+
def replace(match: re.Match[str]) -> str:
|
|
201
|
+
name = match.group(1)
|
|
202
|
+
replacement = environment.get(name)
|
|
203
|
+
if replacement is None or replacement == "":
|
|
204
|
+
raise MCPConfigError(f"Missing environment variable: {name}")
|
|
205
|
+
return replacement
|
|
206
|
+
|
|
207
|
+
return _ENV_PATTERN.sub(replace, value)
|
mycode/mcp/errors.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import ssl
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
import httpx2
|
|
7
|
+
from mcp import MCPError
|
|
8
|
+
from mcp_types import CONNECTION_CLOSED, REQUEST_TIMEOUT
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
MCPErrorCategory = Literal[
|
|
12
|
+
"authentication",
|
|
13
|
+
"permission_denied",
|
|
14
|
+
"timeout",
|
|
15
|
+
"connection_error",
|
|
16
|
+
"tls_error",
|
|
17
|
+
"protocol_error",
|
|
18
|
+
"server_unavailable",
|
|
19
|
+
"unknown",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class MCPErrorClassification:
|
|
25
|
+
category: MCPErrorCategory
|
|
26
|
+
summary: str
|
|
27
|
+
retryable: bool | None
|
|
28
|
+
root_error_type: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def classify_mcp_error(error: BaseException) -> MCPErrorClassification:
|
|
32
|
+
"""Classify an MCP failure without exposing provider-controlled text."""
|
|
33
|
+
chain = tuple(_exception_chain(error))
|
|
34
|
+
|
|
35
|
+
http_status = _first_http_status(chain, {401, 403})
|
|
36
|
+
if http_status is not None:
|
|
37
|
+
if http_status == 401:
|
|
38
|
+
return _classification(
|
|
39
|
+
chain,
|
|
40
|
+
category="authentication",
|
|
41
|
+
summary="Authentication failed (401)",
|
|
42
|
+
retryable=False,
|
|
43
|
+
predicate=lambda item: _http_status(item) == 401,
|
|
44
|
+
)
|
|
45
|
+
return _classification(
|
|
46
|
+
chain,
|
|
47
|
+
category="permission_denied",
|
|
48
|
+
summary="Access forbidden (403)",
|
|
49
|
+
retryable=False,
|
|
50
|
+
predicate=lambda item: _http_status(item) == 403,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Transport causes must be checked before MCPError. An MCP client may wrap
|
|
54
|
+
# a httpx2.ConnectError in a generic "connection closed" MCPError.
|
|
55
|
+
if any(isinstance(item, ssl.SSLError) for item in chain):
|
|
56
|
+
return _classification(
|
|
57
|
+
chain,
|
|
58
|
+
category="tls_error",
|
|
59
|
+
summary="TLS connection failed",
|
|
60
|
+
retryable=True,
|
|
61
|
+
predicate=lambda item: isinstance(item, ssl.SSLError),
|
|
62
|
+
)
|
|
63
|
+
if any(
|
|
64
|
+
isinstance(item, (TimeoutError, httpx2.TimeoutException))
|
|
65
|
+
for item in chain
|
|
66
|
+
):
|
|
67
|
+
return _classification(
|
|
68
|
+
chain,
|
|
69
|
+
category="timeout",
|
|
70
|
+
summary="Connection timed out",
|
|
71
|
+
retryable=True,
|
|
72
|
+
predicate=lambda item: isinstance(
|
|
73
|
+
item, (TimeoutError, httpx2.TimeoutException)
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
if any(
|
|
77
|
+
isinstance(item, ConnectionRefusedError)
|
|
78
|
+
for item in chain
|
|
79
|
+
):
|
|
80
|
+
return _classification(
|
|
81
|
+
chain,
|
|
82
|
+
category="connection_error",
|
|
83
|
+
summary="Connection refused",
|
|
84
|
+
retryable=True,
|
|
85
|
+
predicate=lambda item: isinstance(item, ConnectionRefusedError),
|
|
86
|
+
)
|
|
87
|
+
if any(
|
|
88
|
+
isinstance(
|
|
89
|
+
item,
|
|
90
|
+
(
|
|
91
|
+
httpx2.ConnectError,
|
|
92
|
+
ConnectionResetError,
|
|
93
|
+
BrokenPipeError,
|
|
94
|
+
ConnectionError,
|
|
95
|
+
EOFError,
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
for item in chain
|
|
99
|
+
):
|
|
100
|
+
return _classification(
|
|
101
|
+
chain,
|
|
102
|
+
category="connection_error",
|
|
103
|
+
summary="Connection failed",
|
|
104
|
+
retryable=True,
|
|
105
|
+
predicate=lambda item: isinstance(
|
|
106
|
+
item,
|
|
107
|
+
(
|
|
108
|
+
httpx2.ConnectError,
|
|
109
|
+
ConnectionResetError,
|
|
110
|
+
BrokenPipeError,
|
|
111
|
+
ConnectionError,
|
|
112
|
+
EOFError,
|
|
113
|
+
),
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if any(_mcp_error_code(item) == CONNECTION_CLOSED for item in chain):
|
|
118
|
+
return _classification(
|
|
119
|
+
chain,
|
|
120
|
+
category="connection_error",
|
|
121
|
+
summary="Connection closed",
|
|
122
|
+
retryable=True,
|
|
123
|
+
predicate=lambda item: (
|
|
124
|
+
_mcp_error_code(item) == CONNECTION_CLOSED
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
if any(_mcp_error_code(item) == REQUEST_TIMEOUT for item in chain):
|
|
128
|
+
return _classification(
|
|
129
|
+
chain,
|
|
130
|
+
category="timeout",
|
|
131
|
+
summary="Request timed out",
|
|
132
|
+
retryable=True,
|
|
133
|
+
predicate=lambda item: _mcp_error_code(item) == REQUEST_TIMEOUT,
|
|
134
|
+
)
|
|
135
|
+
if any(_is_server_unavailable(item) for item in chain):
|
|
136
|
+
return _classification(
|
|
137
|
+
chain,
|
|
138
|
+
category="server_unavailable",
|
|
139
|
+
summary="MCP server unavailable",
|
|
140
|
+
retryable=False,
|
|
141
|
+
predicate=_is_server_unavailable,
|
|
142
|
+
)
|
|
143
|
+
if any(isinstance(item, PermissionError) for item in chain):
|
|
144
|
+
return _classification(
|
|
145
|
+
chain,
|
|
146
|
+
category="permission_denied",
|
|
147
|
+
summary="Permission denied",
|
|
148
|
+
retryable=False,
|
|
149
|
+
predicate=lambda item: isinstance(item, PermissionError),
|
|
150
|
+
)
|
|
151
|
+
if any(isinstance(item, FileNotFoundError) for item in chain):
|
|
152
|
+
return _classification(
|
|
153
|
+
chain,
|
|
154
|
+
category="unknown",
|
|
155
|
+
summary="Server command not found",
|
|
156
|
+
retryable=False,
|
|
157
|
+
predicate=lambda item: isinstance(item, FileNotFoundError),
|
|
158
|
+
)
|
|
159
|
+
if any(isinstance(item, httpx2.HTTPStatusError) for item in chain):
|
|
160
|
+
status_code = next(
|
|
161
|
+
item.response.status_code
|
|
162
|
+
for item in chain
|
|
163
|
+
if isinstance(item, httpx2.HTTPStatusError)
|
|
164
|
+
)
|
|
165
|
+
if status_code >= 500:
|
|
166
|
+
return _classification(
|
|
167
|
+
chain,
|
|
168
|
+
category="server_unavailable",
|
|
169
|
+
summary=f"HTTP request failed ({status_code})",
|
|
170
|
+
retryable=True,
|
|
171
|
+
predicate=lambda item: (
|
|
172
|
+
isinstance(item, httpx2.HTTPStatusError)
|
|
173
|
+
and item.response.status_code == status_code
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
return _classification(
|
|
177
|
+
chain,
|
|
178
|
+
category="unknown",
|
|
179
|
+
summary=f"HTTP request failed ({status_code})",
|
|
180
|
+
retryable=False,
|
|
181
|
+
predicate=lambda item: isinstance(item, httpx2.HTTPStatusError),
|
|
182
|
+
)
|
|
183
|
+
if any(isinstance(item, MCPError) for item in chain):
|
|
184
|
+
return _classification(
|
|
185
|
+
chain,
|
|
186
|
+
category="protocol_error",
|
|
187
|
+
summary="MCP protocol error",
|
|
188
|
+
retryable=False,
|
|
189
|
+
predicate=lambda item: isinstance(item, MCPError),
|
|
190
|
+
)
|
|
191
|
+
if any(isinstance(item, OSError) for item in chain):
|
|
192
|
+
return _classification(
|
|
193
|
+
chain,
|
|
194
|
+
category="connection_error",
|
|
195
|
+
summary="Server process or transport failed",
|
|
196
|
+
retryable=False,
|
|
197
|
+
predicate=lambda item: isinstance(item, OSError),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return MCPErrorClassification(
|
|
201
|
+
category="unknown",
|
|
202
|
+
summary=type(error).__name__,
|
|
203
|
+
retryable=None,
|
|
204
|
+
root_error_type=_root_error_type(chain, fallback=error),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def safe_error_summary(error: BaseException) -> str:
|
|
209
|
+
"""Return a short safe summary for CLI and startup status output."""
|
|
210
|
+
return classify_mcp_error(error).summary
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def is_transient_mcp_error(classified: MCPErrorClassification) -> bool:
|
|
214
|
+
"""Return whether startup may retry this transport-level failure."""
|
|
215
|
+
return classified.retryable is True and classified.category in {
|
|
216
|
+
"timeout",
|
|
217
|
+
"connection_error",
|
|
218
|
+
"tls_error",
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _classification(
|
|
223
|
+
chain: tuple[BaseException, ...],
|
|
224
|
+
*,
|
|
225
|
+
category: MCPErrorCategory,
|
|
226
|
+
summary: str,
|
|
227
|
+
retryable: bool | None,
|
|
228
|
+
predicate,
|
|
229
|
+
) -> MCPErrorClassification:
|
|
230
|
+
matched = next((item for item in chain if predicate(item)), None)
|
|
231
|
+
return MCPErrorClassification(
|
|
232
|
+
category=category,
|
|
233
|
+
summary=summary,
|
|
234
|
+
retryable=retryable,
|
|
235
|
+
root_error_type=(
|
|
236
|
+
type(matched).__name__
|
|
237
|
+
if matched is not None
|
|
238
|
+
else type(chain[0]).__name__
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _first_http_status(
|
|
244
|
+
chain: tuple[BaseException, ...], status_codes: set[int]
|
|
245
|
+
) -> int | None:
|
|
246
|
+
for item in chain:
|
|
247
|
+
status_code = _http_status(item)
|
|
248
|
+
if status_code in status_codes:
|
|
249
|
+
return status_code
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _http_status(error: BaseException) -> int | None:
|
|
254
|
+
if not isinstance(error, httpx2.HTTPStatusError):
|
|
255
|
+
return None
|
|
256
|
+
return error.response.status_code
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _mcp_error_code(error: BaseException) -> int | None:
|
|
260
|
+
if not isinstance(error, MCPError):
|
|
261
|
+
return None
|
|
262
|
+
code = getattr(error, "code", None)
|
|
263
|
+
if isinstance(code, int) and not isinstance(code, bool):
|
|
264
|
+
return code
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _is_server_unavailable(error: BaseException) -> bool:
|
|
269
|
+
if not isinstance(error, RuntimeError):
|
|
270
|
+
return False
|
|
271
|
+
return str(error).strip().casefold() in {
|
|
272
|
+
"mcp server is unavailable",
|
|
273
|
+
"mcp server unavailable",
|
|
274
|
+
"server unavailable",
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _root_error_type(
|
|
279
|
+
chain: tuple[BaseException, ...], *, fallback: BaseException
|
|
280
|
+
) -> str:
|
|
281
|
+
for item in reversed(chain):
|
|
282
|
+
if not isinstance(item, BaseExceptionGroup):
|
|
283
|
+
return type(item).__name__
|
|
284
|
+
return type(fallback).__name__
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _exception_chain(error: BaseException) -> Iterator[BaseException]:
|
|
288
|
+
"""Walk effective causes and ExceptionGroup members without looping."""
|
|
289
|
+
seen: set[int] = set()
|
|
290
|
+
pending: list[BaseException] = [error]
|
|
291
|
+
while pending:
|
|
292
|
+
current = pending.pop()
|
|
293
|
+
if id(current) in seen:
|
|
294
|
+
continue
|
|
295
|
+
seen.add(id(current))
|
|
296
|
+
yield current
|
|
297
|
+
if isinstance(current, BaseExceptionGroup):
|
|
298
|
+
pending.extend(reversed(current.exceptions))
|
|
299
|
+
if current.__cause__ is not None:
|
|
300
|
+
pending.append(current.__cause__)
|
|
301
|
+
elif not current.__suppress_context__ and current.__context__ is not None:
|
|
302
|
+
pending.append(current.__context__)
|