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/manager.py ADDED
@@ -0,0 +1,339 @@
1
+ import asyncio
2
+ from contextlib import AsyncExitStack
3
+ from dataclasses import dataclass, field
4
+ import random
5
+ from threading import Event, Thread
6
+ from time import monotonic
7
+
8
+ from mcp import Client
9
+ from mcp.types import CallToolResult
10
+
11
+ from mycode.mcp.client import open_mcp_client
12
+ from mycode.mcp.config import (
13
+ DEFAULT_MCP_SHUTDOWN_TIMEOUT_SECONDS,
14
+ MCPConfig,
15
+ MCPServerConfig,
16
+ )
17
+ from mycode.mcp.errors import (
18
+ classify_mcp_error,
19
+ is_transient_mcp_error,
20
+ safe_error_summary,
21
+ )
22
+ from mycode.mcp.models import MCPServerStatus, MCPShutdownStatus
23
+ from mycode.mcp.tool_adapter import MCPToolAdapter
24
+ from mycode.observability import ObservationSink, emit_observation
25
+
26
+
27
+ MCP_STARTUP_RETRY_MIN_DELAY_SECONDS = 1.0
28
+ MCP_STARTUP_RETRY_MAX_DELAY_SECONDS = 2.0
29
+ MCP_STARTUP_WAIT_SAFETY_MARGIN_SECONDS = 5.0
30
+
31
+
32
+ @dataclass
33
+ class MCPManager:
34
+ config: MCPConfig
35
+ observability_sink: ObservationSink | None = None
36
+ shutdown_timeout: float = DEFAULT_MCP_SHUTDOWN_TIMEOUT_SECONDS
37
+ statuses: tuple[MCPServerStatus, ...] = field(default=(), init=False)
38
+ shutdown_status: MCPShutdownStatus = field(
39
+ default_factory=MCPShutdownStatus, init=False
40
+ )
41
+ tools: tuple[MCPToolAdapter, ...] = field(default=(), init=False)
42
+ _clients: dict[str, Client] = field(default_factory=dict, init=False, repr=False)
43
+ _tool_timeouts: dict[str, float] = field(default_factory=dict, init=False, repr=False)
44
+ _loop: asyncio.AbstractEventLoop | None = field(default=None, init=False, repr=False)
45
+ _stop: asyncio.Event | None = field(default=None, init=False, repr=False)
46
+ _ready: Event = field(default_factory=Event, init=False, repr=False)
47
+ _thread: Thread | None = field(default=None, init=False, repr=False)
48
+ _fatal_error: BaseException | None = field(default=None, init=False, repr=False)
49
+
50
+ def start(self) -> None:
51
+ if self._thread is not None or not self.config.mcp_servers:
52
+ return
53
+ self._ready = Event()
54
+ self._fatal_error = None
55
+ self.statuses = ()
56
+ self.tools = ()
57
+ self.shutdown_status = MCPShutdownStatus()
58
+ self._thread = Thread(target=self._thread_main, name="mycode-mcp", daemon=True)
59
+ self._thread.start()
60
+ wait_seconds = _startup_wait_seconds(self.config)
61
+ if not self._ready.wait(wait_seconds):
62
+ error = TimeoutError()
63
+ self.statuses = tuple(
64
+ MCPServerStatus(
65
+ alias=alias,
66
+ status="failed",
67
+ error_type=type(error).__name__,
68
+ error_summary=safe_error_summary(error),
69
+ )
70
+ for alias in self.config.mcp_servers
71
+ )
72
+ self.close()
73
+ elif self._fatal_error is not None:
74
+ self.statuses = tuple(
75
+ MCPServerStatus(
76
+ alias=alias,
77
+ status="failed",
78
+ error_type=type(self._fatal_error).__name__,
79
+ error_summary=safe_error_summary(self._fatal_error),
80
+ )
81
+ for alias in self.config.mcp_servers
82
+ )
83
+
84
+ def close(self) -> None:
85
+ started = monotonic()
86
+ loop, stop, thread = self._loop, self._stop, self._thread
87
+ if thread is None:
88
+ self.shutdown_status = MCPShutdownStatus(status="completed")
89
+ return
90
+ if loop is not None and stop is not None and thread.is_alive():
91
+ try:
92
+ loop.call_soon_threadsafe(stop.set)
93
+ except RuntimeError:
94
+ pass
95
+ thread.join(timeout=self.shutdown_timeout)
96
+ if thread.is_alive():
97
+ self.shutdown_status = MCPShutdownStatus(
98
+ status="timeout", error="shutdown_timeout"
99
+ )
100
+ emit_observation(
101
+ self.observability_sink,
102
+ "mcp_shutdown",
103
+ {
104
+ "status": "timeout",
105
+ "duration_ms": round((monotonic() - started) * 1000),
106
+ "error_type": "shutdown_timeout",
107
+ },
108
+ )
109
+ return
110
+
111
+ self.shutdown_status = MCPShutdownStatus(status="completed")
112
+ self._thread = None
113
+ self._loop = None
114
+ self._stop = None
115
+ self._clients.clear()
116
+ self._tool_timeouts.clear()
117
+ self.tools = ()
118
+ self._ready = Event()
119
+ emit_observation(
120
+ self.observability_sink,
121
+ "mcp_shutdown",
122
+ {
123
+ "status": "completed",
124
+ "duration_ms": round((monotonic() - started) * 1000),
125
+ "error_type": None,
126
+ },
127
+ )
128
+
129
+ async def call_tool(
130
+ self, server_alias: str, remote_name: str, arguments: dict[str, object]
131
+ ) -> CallToolResult:
132
+ loop = self._loop
133
+ if loop is None or server_alias not in self._clients:
134
+ raise RuntimeError("MCP server is unavailable")
135
+ future = asyncio.run_coroutine_threadsafe(
136
+ self._call_on_runtime(server_alias, remote_name, arguments), loop
137
+ )
138
+ return await asyncio.wrap_future(future)
139
+
140
+ def _thread_main(self) -> None:
141
+ try:
142
+ asyncio.run(self._serve())
143
+ except BaseException as error: # noqa: BLE001 - thread boundary must signal ready
144
+ self._fatal_error = error
145
+ self._ready.set()
146
+
147
+ async def _serve(self) -> None:
148
+ self._loop = asyncio.get_running_loop()
149
+ self._stop = asyncio.Event()
150
+ loop = asyncio.get_running_loop()
151
+ startups: list[asyncio.Future[_ServerStartup]] = []
152
+ tasks: list[asyncio.Task[None]] = []
153
+ for alias, server in self.config.mcp_servers.items():
154
+ startup: asyncio.Future[_ServerStartup] = loop.create_future()
155
+ startups.append(startup)
156
+ tasks.append(
157
+ asyncio.create_task(self._serve_server(alias, server, startup))
158
+ )
159
+ try:
160
+ results = await asyncio.gather(*startups)
161
+ self.statuses = tuple(result.status for result in results)
162
+ self.tools = tuple(
163
+ tool for result in results for tool in result.tools
164
+ )
165
+ self._ready.set()
166
+ await self._stop.wait()
167
+ finally:
168
+ self._stop.set()
169
+ await asyncio.gather(*tasks, return_exceptions=True)
170
+ self._clients.clear()
171
+ self._tool_timeouts.clear()
172
+
173
+ async def _serve_server(
174
+ self,
175
+ alias: str,
176
+ server: MCPServerConfig,
177
+ startup: asyncio.Future["_ServerStartup"],
178
+ ) -> None:
179
+ started = monotonic()
180
+ retry_count = 0
181
+ for attempt in range(1, 3):
182
+ try:
183
+ async with AsyncExitStack() as stack:
184
+ async with asyncio.timeout(server.connect_timeout):
185
+ client = await stack.enter_async_context(
186
+ open_mcp_client(server)
187
+ )
188
+ async with asyncio.timeout(server.tool_timeout):
189
+ discovered = await _discover_all_tools(client)
190
+ remote_names = [tool.name for tool in discovered]
191
+ if len(remote_names) != len(set(remote_names)):
192
+ raise ValueError("MCP server returned duplicate tool names")
193
+ adapters = tuple(
194
+ MCPToolAdapter(alias, tool, self.call_tool)
195
+ for tool in discovered
196
+ )
197
+ self._clients[alias] = client
198
+ self._tool_timeouts[alias] = server.tool_timeout
199
+ result = _ServerStartup(
200
+ status=MCPServerStatus(
201
+ alias=alias,
202
+ status="connected",
203
+ tool_count=len(adapters),
204
+ ),
205
+ tools=adapters,
206
+ )
207
+ self._report_server_start(
208
+ startup,
209
+ result,
210
+ started,
211
+ attempt=attempt,
212
+ retry_count=retry_count,
213
+ recovered_after_retry=retry_count > 0,
214
+ )
215
+ await self._stop.wait()
216
+ return
217
+ except Exception as error: # noqa: BLE001 - isolate one external server
218
+ classified = classify_mcp_error(error)
219
+ if attempt == 1 and is_transient_mcp_error(classified):
220
+ retry_count = 1
221
+ await asyncio.sleep(
222
+ random.uniform(
223
+ MCP_STARTUP_RETRY_MIN_DELAY_SECONDS,
224
+ MCP_STARTUP_RETRY_MAX_DELAY_SECONDS,
225
+ )
226
+ )
227
+ continue
228
+ result = _ServerStartup(
229
+ status=MCPServerStatus(
230
+ alias=alias,
231
+ status="failed",
232
+ error_type=type(error).__name__,
233
+ error_summary=classified.summary,
234
+ )
235
+ )
236
+ self._report_server_start(
237
+ startup,
238
+ result,
239
+ started,
240
+ attempt=attempt,
241
+ retry_count=retry_count,
242
+ recovered_after_retry=False,
243
+ error_category=classified.category,
244
+ )
245
+ return
246
+ finally:
247
+ self._clients.pop(alias, None)
248
+ self._tool_timeouts.pop(alias, None)
249
+
250
+ def _report_server_start(
251
+ self,
252
+ startup: asyncio.Future["_ServerStartup"],
253
+ result: "_ServerStartup",
254
+ started: float,
255
+ *,
256
+ attempt: int,
257
+ retry_count: int,
258
+ recovered_after_retry: bool,
259
+ error_category: str | None = None,
260
+ ) -> None:
261
+ if not startup.done():
262
+ startup.set_result(result)
263
+ emit_observation(
264
+ self.observability_sink,
265
+ "mcp_server_start",
266
+ {
267
+ "server_alias": result.status.alias,
268
+ "status": result.status.status,
269
+ "tool_count": result.status.tool_count,
270
+ "duration_ms": round((monotonic() - started) * 1000),
271
+ "error_type": result.status.error_type,
272
+ "error_category": error_category,
273
+ "attempt": attempt,
274
+ "retry_count": retry_count,
275
+ "recovered_after_retry": recovered_after_retry,
276
+ },
277
+ )
278
+
279
+ async def _call_on_runtime(
280
+ self, alias: str, name: str, arguments: dict[str, object]
281
+ ) -> CallToolResult:
282
+ started = monotonic()
283
+ status = "ok"
284
+ error_type = None
285
+ error_category = None
286
+ root_error_type = None
287
+ try:
288
+ async with asyncio.timeout(self._tool_timeouts[alias]):
289
+ return await self._clients[alias].call_tool(name, arguments)
290
+ except Exception as error:
291
+ status = "error"
292
+ classified = classify_mcp_error(error)
293
+ error_type = type(error).__name__
294
+ error_category = classified.category
295
+ root_error_type = classified.root_error_type
296
+ raise
297
+ finally:
298
+ emit_observation(
299
+ self.observability_sink,
300
+ "mcp_tool_call",
301
+ {
302
+ "server_alias": alias,
303
+ "tool_name": name,
304
+ "status": status,
305
+ "duration_ms": round((monotonic() - started) * 1000),
306
+ "error_type": error_type,
307
+ "root_error_type": root_error_type,
308
+ "error_category": error_category,
309
+ },
310
+ )
311
+
312
+
313
+ async def _discover_all_tools(client: Client) -> list[object]:
314
+ tools: list[object] = []
315
+ cursor: str | None = None
316
+ while True:
317
+ page = await client.list_tools(cursor=cursor, cache_mode="refresh")
318
+ tools.extend(page.tools)
319
+ cursor = page.next_cursor
320
+ if cursor is None:
321
+ return tools
322
+
323
+
324
+ @dataclass(frozen=True)
325
+ class _ServerStartup:
326
+ status: MCPServerStatus
327
+ tools: tuple[MCPToolAdapter, ...] = ()
328
+
329
+
330
+ def _startup_wait_seconds(config: MCPConfig) -> float:
331
+ max_server_timeout = max(
332
+ server.connect_timeout + server.tool_timeout
333
+ for server in config.mcp_servers.values()
334
+ )
335
+ return (
336
+ 2 * max_server_timeout
337
+ + MCP_STARTUP_RETRY_MAX_DELAY_SECONDS
338
+ + MCP_STARTUP_WAIT_SAFETY_MARGIN_SECONDS
339
+ )
mycode/mcp/models.py ADDED
@@ -0,0 +1,20 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal
3
+
4
+ MCPServerStatusValue = Literal["connected", "failed"]
5
+ MCPShutdownStatusValue = Literal["not_started", "completed", "timeout"]
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class MCPServerStatus:
10
+ alias: str
11
+ status: MCPServerStatusValue
12
+ tool_count: int = 0
13
+ error_type: str | None = None
14
+ error_summary: str | None = None
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class MCPShutdownStatus:
19
+ status: MCPShutdownStatusValue = "not_started"
20
+ error: str | None = None
@@ -0,0 +1,58 @@
1
+ import json
2
+
3
+ from mcp.types import (
4
+ AudioContent,
5
+ BlobResourceContents,
6
+ CallToolResult,
7
+ EmbeddedResource,
8
+ ImageContent,
9
+ ResourceLink,
10
+ TextContent,
11
+ TextResourceContents,
12
+ )
13
+
14
+ from mycode.tools.base import ToolResult
15
+
16
+
17
+ def adapt_mcp_result(result: CallToolResult) -> ToolResult:
18
+ parts: list[str] = []
19
+ blocks: list[dict[str, object]] = []
20
+ for block in result.content:
21
+ if isinstance(block, TextContent):
22
+ parts.append(block.text)
23
+ blocks.append({"type": "text"})
24
+ elif isinstance(block, ResourceLink):
25
+ parts.append(f"Resource: {block.uri}" + (f" ({block.description})" if block.description else ""))
26
+ blocks.append(_without_data(block.model_dump(by_alias=True, exclude_none=True)))
27
+ elif isinstance(block, EmbeddedResource):
28
+ resource = block.resource
29
+ if isinstance(resource, TextResourceContents):
30
+ parts.append(resource.text)
31
+ elif isinstance(resource, BlobResourceContents):
32
+ parts.append(f"[embedded binary resource omitted: {resource.uri}]")
33
+ blocks.append(_without_data(block.model_dump(by_alias=True, exclude_none=True)))
34
+ elif isinstance(block, (ImageContent, AudioContent)):
35
+ parts.append(f"[{block.type} content omitted: {block.mime_type}]")
36
+ blocks.append(_without_data(block.model_dump(by_alias=True, exclude_none=True)))
37
+ else: # pragma: no cover - forward-compatible SDK content type
38
+ parts.append(f"[unsupported MCP content omitted: {type(block).__name__}]")
39
+ blocks.append({"type": type(block).__name__})
40
+
41
+ metadata: dict[str, object] = {"mcp_content_blocks": blocks}
42
+ if result.structured_content is not None:
43
+ metadata["structured_content"] = result.structured_content
44
+ if not parts:
45
+ parts.append(json.dumps(result.structured_content, ensure_ascii=False, default=str))
46
+ content = "\n".join(part for part in parts if part != "")
47
+ if result.is_error:
48
+ return ToolResult.failure(content or "MCP tool returned an error", metadata)
49
+ return ToolResult.success(content, metadata)
50
+
51
+
52
+ def _without_data(value: dict[str, object]) -> dict[str, object]:
53
+ value.pop("data", None)
54
+ resource = value.get("resource")
55
+ if isinstance(resource, dict):
56
+ resource.pop("blob", None)
57
+ resource.pop("text", None)
58
+ return value
@@ -0,0 +1,145 @@
1
+ import copy
2
+ import hashlib
3
+ import re
4
+ from collections.abc import Awaitable, Callable
5
+
6
+ from jsonschema import Draft202012Validator, SchemaError
7
+ from jsonschema.validators import validator_for
8
+ from mcp.types import CallToolResult, Tool
9
+
10
+ from mycode.mcp.errors import classify_mcp_error
11
+ from mycode.mcp.result_adapter import adapt_mcp_result
12
+ from mycode.permissions import (
13
+ PermissionDecision,
14
+ PermissionRequest,
15
+ ToolPermissionProfile,
16
+ )
17
+ from mycode.tools.base import BaseTool, ToolArgumentValidationError, ToolResult
18
+
19
+ MCPCall = Callable[[str, str, dict[str, object]], Awaitable[CallToolResult]]
20
+ MAX_REGISTRY_NAME_LENGTH = 64
21
+ _INVALID_REGISTRY_NAME_CHARS = re.compile(r"[^A-Za-z0-9_-]+")
22
+
23
+
24
+ def build_registry_name(server_alias: str, remote_name: str) -> str:
25
+ """Build a bounded model-facing name while preserving remote identity."""
26
+ safe_remote = _INVALID_REGISTRY_NAME_CHARS.sub("-", remote_name).strip("-")
27
+ if not safe_remote:
28
+ safe_remote = "tool"
29
+ base = f"mcp__{server_alias}__{safe_remote}"
30
+ changed = safe_remote != remote_name
31
+ if not changed and len(base) <= MAX_REGISTRY_NAME_LENGTH:
32
+ return base
33
+
34
+ digest = hashlib.sha256(
35
+ f"{server_alias}\0{remote_name}".encode()
36
+ ).hexdigest()[:8]
37
+ suffix = f"__{digest}"
38
+ return f"{base[: MAX_REGISTRY_NAME_LENGTH - len(suffix)]}{suffix}"
39
+
40
+
41
+ class MCPToolAdapter(BaseTool[dict[str, object]]):
42
+ concurrency_safe = False
43
+
44
+ def __init__(self, server_alias: str, remote_tool: Tool, call: MCPCall) -> None:
45
+ self.server_alias = server_alias
46
+ self.remote_name = remote_tool.name
47
+ self.registry_name = build_registry_name(server_alias, remote_tool.name)
48
+ self.name = self.registry_name
49
+ self.description = remote_tool.description or f"MCP tool {remote_tool.name}."
50
+ self._input_schema = copy.deepcopy(remote_tool.input_schema)
51
+ self.output_schema = copy.deepcopy(remote_tool.output_schema)
52
+ self.annotations = (
53
+ {} if remote_tool.annotations is None
54
+ else remote_tool.annotations.model_dump(by_alias=True, exclude_none=True)
55
+ )
56
+ self.capability, self.risk = _permission_profile(self.annotations)
57
+ self._call = call
58
+ try:
59
+ if "$schema" in self._input_schema:
60
+ validator_class = validator_for(self._input_schema)
61
+ else:
62
+ validator_class = Draft202012Validator
63
+ validator_class.check_schema(self._input_schema)
64
+ self._validator = validator_class(self._input_schema)
65
+ except SchemaError as error:
66
+ raise ValueError(f"Invalid inputSchema for {self.name}") from error
67
+
68
+ @property
69
+ def input_schema(self) -> dict[str, object]:
70
+ return copy.deepcopy(self._input_schema)
71
+
72
+ def parse_arguments(self, arguments: dict[str, object]) -> dict[str, object]:
73
+ errors = sorted(self._validator.iter_errors(arguments), key=lambda item: list(item.path))
74
+ if errors:
75
+ raise ToolArgumentValidationError(
76
+ [
77
+ {
78
+ "location": ".".join(str(part) for part in error.path),
79
+ "message": error.message,
80
+ "validator": error.validator,
81
+ }
82
+ for error in errors[:10]
83
+ ]
84
+ )
85
+ return dict(arguments)
86
+
87
+ def arguments_to_dict(
88
+ self, args: dict[str, object]
89
+ ) -> dict[str, object]:
90
+ return dict(args)
91
+
92
+ def get_permission_profile(self) -> ToolPermissionProfile:
93
+ return ToolPermissionProfile(capability=self.capability, risk=self.risk)
94
+
95
+ def build_permission_request(
96
+ self, args: dict[str, object]
97
+ ) -> PermissionRequest:
98
+ return PermissionRequest(
99
+ tool_name=self.name,
100
+ capability=self.capability,
101
+ action=self.remote_name,
102
+ arguments=args,
103
+ description=f"MCP server: {self.server_alias}",
104
+ )
105
+
106
+ async def run_authorized_async(
107
+ self, args: dict[str, object], decision: PermissionDecision
108
+ ) -> ToolResult:
109
+ try:
110
+ result = await self._call(self.server_alias, self.remote_name, args)
111
+ except Exception as error: # noqa: BLE001 - normalize provider failures
112
+ classified = classify_mcp_error(error)
113
+ return ToolResult.failure(
114
+ f"MCP tool call failed: {classified.summary}",
115
+ {
116
+ "server_alias": self.server_alias,
117
+ "tool_name": self.remote_name,
118
+ "error_type": type(error).__name__,
119
+ "root_error_type": classified.root_error_type,
120
+ "error_category": classified.category,
121
+ "retryable": classified.retryable,
122
+ },
123
+ )
124
+ adapted = adapt_mcp_result(result)
125
+ return ToolResult(
126
+ ok=adapted.ok,
127
+ content=adapted.content,
128
+ error=adapted.error,
129
+ metadata={
130
+ "server_alias": self.server_alias,
131
+ "tool_name": self.remote_name,
132
+ **adapted.metadata,
133
+ },
134
+ )
135
+
136
+ def _permission_profile(
137
+ annotations: dict[str, object],
138
+ ) -> tuple[str, str]:
139
+ read_only = annotations.get("readOnlyHint") is True
140
+ destructive = annotations.get("destructiveHint") is True
141
+ if destructive:
142
+ return "write", "high"
143
+ if read_only:
144
+ return "read", "low"
145
+ return "write", "medium"