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/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"
|