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.
Files changed (121) hide show
  1. mycode/__init__.py +0 -0
  2. mycode/adapters/__init__.py +21 -0
  3. mycode/adapters/jsonl.py +692 -0
  4. mycode/agent/__init__.py +25 -0
  5. mycode/agent/events.py +111 -0
  6. mycode/agent/outcome.py +103 -0
  7. mycode/agent/progress.py +373 -0
  8. mycode/agent/runner.py +1481 -0
  9. mycode/application/__init__.py +38 -0
  10. mycode/application/agent_session.py +367 -0
  11. mycode/application/events.py +59 -0
  12. mycode/application/runtime.py +211 -0
  13. mycode/application/sessions.py +180 -0
  14. mycode/cli.py +840 -0
  15. mycode/config.py +355 -0
  16. mycode/context/__init__.py +1 -0
  17. mycode/context/artifacts.py +672 -0
  18. mycode/context/budget.py +752 -0
  19. mycode/context/builder.py +112 -0
  20. mycode/context/compact.py +795 -0
  21. mycode/context/tool_result_format.py +199 -0
  22. mycode/context/tool_result_retention.py +261 -0
  23. mycode/conversation.py +78 -0
  24. mycode/error_handling.py +481 -0
  25. mycode/event_format.py +147 -0
  26. mycode/instructions.py +285 -0
  27. mycode/llm.py +771 -0
  28. mycode/mcp/__init__.py +41 -0
  29. mycode/mcp/client.py +44 -0
  30. mycode/mcp/config.py +207 -0
  31. mycode/mcp/errors.py +302 -0
  32. mycode/mcp/manager.py +339 -0
  33. mycode/mcp/models.py +20 -0
  34. mycode/mcp/result_adapter.py +58 -0
  35. mycode/mcp/tool_adapter.py +145 -0
  36. mycode/mcp/trust.py +313 -0
  37. mycode/memory.py +570 -0
  38. mycode/memory_context.py +245 -0
  39. mycode/messages.py +63 -0
  40. mycode/observability.py +28 -0
  41. mycode/permissions.py +262 -0
  42. mycode/persistence/__init__.py +1 -0
  43. mycode/persistence/filesystem.py +291 -0
  44. mycode/persistence/project_storage.py +208 -0
  45. mycode/persistence/session_lock.py +138 -0
  46. mycode/persistence/session_store.py +503 -0
  47. mycode/presentation/__init__.py +1 -0
  48. mycode/presentation/cli/__init__.py +14 -0
  49. mycode/presentation/cli/confirmer.py +116 -0
  50. mycode/presentation/cli/mcp_trust.py +61 -0
  51. mycode/presentation/cli/presenter.py +320 -0
  52. mycode/presentation/cli/session_menu.py +146 -0
  53. mycode/presentation/cli/subagent_observer.py +124 -0
  54. mycode/presentation/command_format.py +90 -0
  55. mycode/presentation/commands.py +95 -0
  56. mycode/presentation/tui/__init__.py +6 -0
  57. mycode/presentation/tui/app.py +1351 -0
  58. mycode/presentation/tui/interactions.py +253 -0
  59. mycode/presentation/tui/presenter.py +266 -0
  60. mycode/presentation/tui/screens.py +305 -0
  61. mycode/presentation/tui/widgets.py +214 -0
  62. mycode/project.py +22 -0
  63. mycode/prompts.py +181 -0
  64. mycode/reasoning.py +40 -0
  65. mycode/session.py +86 -0
  66. mycode/skills/__init__.py +27 -0
  67. mycode/skills/builtin/database-recovery/SKILL.md +138 -0
  68. mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
  69. mycode/skills/registry.py +295 -0
  70. mycode/skills/state.py +68 -0
  71. mycode/subagents/__init__.py +1 -0
  72. mycode/subagents/audit.py +212 -0
  73. mycode/subagents/concurrency.py +124 -0
  74. mycode/subagents/contracts.py +421 -0
  75. mycode/subagents/delegate.py +80 -0
  76. mycode/subagents/delegation.py +128 -0
  77. mycode/subagents/lifecycle.py +86 -0
  78. mycode/subagents/limits.py +7 -0
  79. mycode/subagents/observability.py +150 -0
  80. mycode/subagents/persistence.py +152 -0
  81. mycode/subagents/profiles.py +184 -0
  82. mycode/subagents/prompts.py +67 -0
  83. mycode/subagents/results.py +178 -0
  84. mycode/subagents/runtime.py +528 -0
  85. mycode/subagents/snapshots.py +211 -0
  86. mycode/subagents/tool_batch.py +260 -0
  87. mycode/tools/__init__.py +81 -0
  88. mycode/tools/base.py +222 -0
  89. mycode/tools/bounds.py +14 -0
  90. mycode/tools/command_executor.py +167 -0
  91. mycode/tools/command_output.py +166 -0
  92. mycode/tools/command_risk.py +596 -0
  93. mycode/tools/defaults.py +59 -0
  94. mycode/tools/edit_file.py +524 -0
  95. mycode/tools/file_mutation.py +30 -0
  96. mycode/tools/glob.py +247 -0
  97. mycode/tools/grep.py +324 -0
  98. mycode/tools/ignore.py +122 -0
  99. mycode/tools/inspect_changes.py +269 -0
  100. mycode/tools/load_skill.py +92 -0
  101. mycode/tools/memory.py +264 -0
  102. mycode/tools/path_permissions.py +78 -0
  103. mycode/tools/patterns.py +48 -0
  104. mycode/tools/permission_metadata.py +27 -0
  105. mycode/tools/process_tree.py +166 -0
  106. mycode/tools/read_file.py +242 -0
  107. mycode/tools/read_skill_resource.py +93 -0
  108. mycode/tools/registry.py +279 -0
  109. mycode/tools/run_command.py +237 -0
  110. mycode/tools/run_skill_script.py +206 -0
  111. mycode/tools/run_validation.py +107 -0
  112. mycode/tools/submit_result.py +93 -0
  113. mycode/tools/text.py +15 -0
  114. mycode/tools/validation_command.py +377 -0
  115. mycode/tools/workspace.py +33 -0
  116. mycode/tools/write_file.py +169 -0
  117. mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
  118. mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
  119. mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  120. mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  121. 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__)