ripperdoc 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 (81) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +25 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +317 -0
  5. ripperdoc/cli/commands/__init__.py +76 -0
  6. ripperdoc/cli/commands/agents_cmd.py +234 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +19 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +114 -0
  12. ripperdoc/cli/commands/cost_cmd.py +77 -0
  13. ripperdoc/cli/commands/exit_cmd.py +19 -0
  14. ripperdoc/cli/commands/help_cmd.py +20 -0
  15. ripperdoc/cli/commands/mcp_cmd.py +65 -0
  16. ripperdoc/cli/commands/models_cmd.py +327 -0
  17. ripperdoc/cli/commands/resume_cmd.py +97 -0
  18. ripperdoc/cli/commands/status_cmd.py +167 -0
  19. ripperdoc/cli/commands/tasks_cmd.py +240 -0
  20. ripperdoc/cli/commands/todos_cmd.py +69 -0
  21. ripperdoc/cli/commands/tools_cmd.py +19 -0
  22. ripperdoc/cli/ui/__init__.py +1 -0
  23. ripperdoc/cli/ui/context_display.py +297 -0
  24. ripperdoc/cli/ui/helpers.py +22 -0
  25. ripperdoc/cli/ui/rich_ui.py +1010 -0
  26. ripperdoc/cli/ui/spinner.py +50 -0
  27. ripperdoc/core/__init__.py +1 -0
  28. ripperdoc/core/agents.py +306 -0
  29. ripperdoc/core/commands.py +33 -0
  30. ripperdoc/core/config.py +382 -0
  31. ripperdoc/core/default_tools.py +57 -0
  32. ripperdoc/core/permissions.py +227 -0
  33. ripperdoc/core/query.py +682 -0
  34. ripperdoc/core/system_prompt.py +418 -0
  35. ripperdoc/core/tool.py +214 -0
  36. ripperdoc/sdk/__init__.py +9 -0
  37. ripperdoc/sdk/client.py +309 -0
  38. ripperdoc/tools/__init__.py +1 -0
  39. ripperdoc/tools/background_shell.py +291 -0
  40. ripperdoc/tools/bash_output_tool.py +98 -0
  41. ripperdoc/tools/bash_tool.py +822 -0
  42. ripperdoc/tools/file_edit_tool.py +281 -0
  43. ripperdoc/tools/file_read_tool.py +168 -0
  44. ripperdoc/tools/file_write_tool.py +141 -0
  45. ripperdoc/tools/glob_tool.py +134 -0
  46. ripperdoc/tools/grep_tool.py +232 -0
  47. ripperdoc/tools/kill_bash_tool.py +136 -0
  48. ripperdoc/tools/ls_tool.py +298 -0
  49. ripperdoc/tools/mcp_tools.py +804 -0
  50. ripperdoc/tools/multi_edit_tool.py +393 -0
  51. ripperdoc/tools/notebook_edit_tool.py +325 -0
  52. ripperdoc/tools/task_tool.py +282 -0
  53. ripperdoc/tools/todo_tool.py +362 -0
  54. ripperdoc/tools/tool_search_tool.py +366 -0
  55. ripperdoc/utils/__init__.py +1 -0
  56. ripperdoc/utils/bash_constants.py +51 -0
  57. ripperdoc/utils/bash_output_utils.py +43 -0
  58. ripperdoc/utils/exit_code_handlers.py +241 -0
  59. ripperdoc/utils/log.py +76 -0
  60. ripperdoc/utils/mcp.py +427 -0
  61. ripperdoc/utils/memory.py +239 -0
  62. ripperdoc/utils/message_compaction.py +640 -0
  63. ripperdoc/utils/messages.py +399 -0
  64. ripperdoc/utils/output_utils.py +233 -0
  65. ripperdoc/utils/path_utils.py +46 -0
  66. ripperdoc/utils/permissions/__init__.py +21 -0
  67. ripperdoc/utils/permissions/path_validation_utils.py +165 -0
  68. ripperdoc/utils/permissions/shell_command_validation.py +74 -0
  69. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  70. ripperdoc/utils/safe_get_cwd.py +24 -0
  71. ripperdoc/utils/sandbox_utils.py +38 -0
  72. ripperdoc/utils/session_history.py +223 -0
  73. ripperdoc/utils/session_usage.py +110 -0
  74. ripperdoc/utils/shell_token_utils.py +95 -0
  75. ripperdoc/utils/todo.py +199 -0
  76. ripperdoc-0.1.0.dist-info/METADATA +178 -0
  77. ripperdoc-0.1.0.dist-info/RECORD +81 -0
  78. ripperdoc-0.1.0.dist-info/WHEEL +5 -0
  79. ripperdoc-0.1.0.dist-info/entry_points.txt +3 -0
  80. ripperdoc-0.1.0.dist-info/licenses/LICENSE +53 -0
  81. ripperdoc-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,804 @@
1
+ """MCP-related tools for listing servers, resources, and invoking MCP tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import json
8
+ import os
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Any, AsyncGenerator, Dict, List, Optional
12
+
13
+ from pydantic import BaseModel, Field, ConfigDict
14
+
15
+ from ripperdoc.core.tool import (
16
+ Tool,
17
+ ToolUseContext,
18
+ ToolResult,
19
+ ToolOutput,
20
+ ValidationResult,
21
+ )
22
+ from ripperdoc.utils.log import get_logger
23
+ from ripperdoc.utils.mcp import (
24
+ McpResourceInfo,
25
+ McpServerInfo,
26
+ ensure_mcp_runtime,
27
+ find_mcp_resource,
28
+ format_mcp_instructions,
29
+ get_existing_mcp_runtime,
30
+ load_mcp_servers_async,
31
+ shutdown_mcp_runtime,
32
+ )
33
+
34
+ try:
35
+ import mcp.types as mcp_types # type: ignore
36
+ except Exception: # pragma: no cover - SDK may be missing at runtime
37
+ mcp_types = None
38
+
39
+
40
+ logger = get_logger()
41
+
42
+
43
+ def _content_block_to_text(block: Any) -> str:
44
+ block_type = getattr(block, "type", None) or (
45
+ block.get("type") if isinstance(block, dict) else None
46
+ )
47
+ if block_type == "text":
48
+ return str(getattr(block, "text", None) or block.get("text", ""))
49
+ if block_type == "resource":
50
+ resource = getattr(block, "resource", None) or block.get("resource")
51
+ prefix = "resource"
52
+ if isinstance(resource, dict):
53
+ uri = resource.get("uri") or ""
54
+ text = resource.get("text") or ""
55
+ blob = resource.get("blob")
56
+ if text:
57
+ return f"[Resource {uri}] {text}"
58
+ if blob:
59
+ return f"[Resource {uri}] (binary content {len(str(blob))} chars)"
60
+ if hasattr(resource, "uri"):
61
+ uri = getattr(resource, "uri", "")
62
+ text = getattr(resource, "text", None)
63
+ blob = getattr(resource, "blob", None)
64
+ if text:
65
+ return f"[Resource {uri}] {text}"
66
+ if blob:
67
+ return f"[Resource {uri}] (binary content {len(str(blob))} chars)"
68
+ return prefix
69
+ if block_type == "resource_link":
70
+ uri = getattr(block, "uri", None) or (block.get("uri") if isinstance(block, dict) else None)
71
+ return f"[Resource link] {uri}" if uri else "[Resource link]"
72
+ if block_type == "image":
73
+ mime = getattr(block, "mimeType", None) or (
74
+ block.get("mimeType") if isinstance(block, dict) else None
75
+ )
76
+ return f"[Image content {mime or ''}]".strip()
77
+ if block_type == "audio":
78
+ mime = getattr(block, "mimeType", None) or (
79
+ block.get("mimeType") if isinstance(block, dict) else None
80
+ )
81
+ return f"[Audio content {mime or ''}]".strip()
82
+ return str(block)
83
+
84
+
85
+ def _render_content_blocks(blocks: List[Any]) -> str:
86
+ if not blocks:
87
+ return ""
88
+ parts = [_content_block_to_text(block) for block in blocks]
89
+ return "\n".join([p for p in parts if p])
90
+
91
+
92
+ def _normalize_content_block(block: Any) -> Any:
93
+ """Convert MCP content blocks to JSON-serializable structures."""
94
+ if isinstance(block, dict):
95
+ return block
96
+ result: Dict[str, Any] = {}
97
+ for attr in (
98
+ "type",
99
+ "text",
100
+ "mimeType",
101
+ "data",
102
+ "name",
103
+ "uri",
104
+ "description",
105
+ "resource",
106
+ "blob",
107
+ ):
108
+ if hasattr(block, attr):
109
+ result[attr] = getattr(block, attr)
110
+ if result:
111
+ return result
112
+ return str(block)
113
+
114
+
115
+ def _normalize_content_blocks(blocks: Optional[List[Any]]) -> Optional[List[Any]]:
116
+ if not blocks:
117
+ return None
118
+ return [_normalize_content_block(block) for block in blocks]
119
+
120
+
121
+ class ListMcpServersInput(BaseModel):
122
+ """Input for listing MCP servers."""
123
+
124
+ server: Optional[str] = Field(default=None, description="Optional server name to filter")
125
+
126
+
127
+ class ListMcpServersOutput(BaseModel):
128
+ """Server summary."""
129
+
130
+ servers: List[dict]
131
+
132
+
133
+ class ListMcpServersTool(Tool[ListMcpServersInput, ListMcpServersOutput]):
134
+ """List configured MCP servers and their tools."""
135
+
136
+ @property
137
+ def name(self) -> str:
138
+ return "ListMcpServers"
139
+
140
+ async def description(self) -> str:
141
+ return "List configured MCP servers and their available tools."
142
+
143
+ @property
144
+ def input_schema(self) -> type[ListMcpServersInput]:
145
+ return ListMcpServersInput
146
+
147
+ async def prompt(self, safe_mode: bool = False) -> str:
148
+ servers = await load_mcp_servers_async()
149
+ return format_mcp_instructions(servers)
150
+
151
+ def is_read_only(self) -> bool:
152
+ return True
153
+
154
+ def is_concurrency_safe(self) -> bool:
155
+ return True
156
+
157
+ def needs_permissions(self, input_data: Optional[ListMcpServersInput] = None) -> bool:
158
+ return False
159
+
160
+ def render_result_for_assistant(self, output: ListMcpServersOutput) -> str:
161
+ if not output.servers:
162
+ return "No MCP servers configured."
163
+ lines = ["Configured MCP servers:"]
164
+ for server in output.servers:
165
+ name = server.get("name", "unknown")
166
+ status = server.get("status", "unknown")
167
+ tool_names = server.get("tools", [])
168
+ tool_part = ", ".join(tool_names) if tool_names else "no tools"
169
+ lines.append(f"- {name} ({status}) tools: {tool_part}")
170
+ return "\n".join(lines)
171
+
172
+ def render_tool_use_message(
173
+ self, input_data: ListMcpServersInput, verbose: bool = False
174
+ ) -> str:
175
+ return f"List MCP servers{f' for {input_data.server}' if input_data.server else ''}"
176
+
177
+ async def call(
178
+ self,
179
+ input_data: ListMcpServersInput,
180
+ context: ToolUseContext,
181
+ ) -> AsyncGenerator[ToolOutput, None]:
182
+ runtime = await ensure_mcp_runtime()
183
+ servers: List[McpServerInfo] = runtime.servers
184
+ if input_data.server:
185
+ servers = [s for s in servers if s.name == input_data.server]
186
+
187
+ payload = []
188
+ for server in servers:
189
+ payload.append(
190
+ {
191
+ "name": server.name,
192
+ "status": server.status,
193
+ "command": server.command,
194
+ "args": server.args,
195
+ "tools": [tool.name for tool in server.tools],
196
+ "resources": [resource.uri for resource in server.resources],
197
+ "error": server.error,
198
+ }
199
+ )
200
+
201
+ yield ToolResult(
202
+ data=ListMcpServersOutput(servers=payload),
203
+ result_for_assistant=self.render_result_for_assistant(
204
+ ListMcpServersOutput(servers=payload)
205
+ ),
206
+ )
207
+
208
+
209
+ class ListMcpResourcesInput(BaseModel):
210
+ """Input for listing MCP resources."""
211
+
212
+ server: Optional[str] = Field(default=None, description="Optional server name to filter")
213
+
214
+
215
+ class ListMcpResourcesOutput(BaseModel):
216
+ resources: List[dict]
217
+
218
+
219
+ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
220
+ """List resources exposed by MCP servers."""
221
+
222
+ @property
223
+ def name(self) -> str:
224
+ return "ListMcpResources"
225
+
226
+ async def description(self) -> str:
227
+ return (
228
+ "Lists available resources from configured MCP servers.\n"
229
+ "Each resource object includes a 'server' field indicating which server it's from.\n\n"
230
+ "Usage examples:\n"
231
+ "- List all resources from all servers: `listMcpResources`\n"
232
+ '- List resources from a specific server: `listMcpResources({ server: "myserver" })`'
233
+ )
234
+
235
+ @property
236
+ def input_schema(self) -> type[ListMcpResourcesInput]:
237
+ return ListMcpResourcesInput
238
+
239
+ async def prompt(self, safe_mode: bool = False) -> str:
240
+ return (
241
+ "List available resources from configured MCP servers.\n"
242
+ "Each returned resource will include all standard MCP resource fields plus a 'server' field\n"
243
+ "indicating which server the resource belongs to.\n\n"
244
+ "Parameters:\n"
245
+ "- server (optional): The name of a specific MCP server to get resources from. If not provided,\n"
246
+ " resources from all servers will be returned."
247
+ )
248
+
249
+ def is_read_only(self) -> bool:
250
+ return True
251
+
252
+ def is_concurrency_safe(self) -> bool:
253
+ return True
254
+
255
+ def needs_permissions(self, input_data: Optional[ListMcpResourcesInput] = None) -> bool:
256
+ return False
257
+
258
+ async def validate_input(
259
+ self, input_data: ListMcpResourcesInput, context: Optional[ToolUseContext] = None
260
+ ) -> ValidationResult:
261
+ runtime = await ensure_mcp_runtime()
262
+ server_names = {s.name for s in runtime.servers}
263
+ if input_data.server and input_data.server not in server_names:
264
+ return ValidationResult(
265
+ result=False, message=f"Unknown MCP server '{input_data.server}'."
266
+ )
267
+ return ValidationResult(result=True)
268
+
269
+ def render_result_for_assistant(self, output: ListMcpResourcesOutput) -> str:
270
+ if not output.resources:
271
+ return "No MCP resources found."
272
+ try:
273
+ return json.dumps(output.resources, indent=2, ensure_ascii=False)
274
+ except Exception:
275
+ return str(output.resources)
276
+
277
+ def render_tool_use_message(
278
+ self, input_data: ListMcpResourcesInput, verbose: bool = False
279
+ ) -> str:
280
+ return f"List MCP resources{f' for {input_data.server}' if input_data.server else ''}"
281
+
282
+ async def call(
283
+ self,
284
+ input_data: ListMcpResourcesInput,
285
+ context: ToolUseContext,
286
+ ) -> AsyncGenerator[ToolOutput, None]:
287
+ runtime = await ensure_mcp_runtime()
288
+ servers = runtime.servers
289
+ resources: List[dict] = []
290
+ for server in servers:
291
+ if input_data.server and server.name != input_data.server:
292
+ continue
293
+
294
+ session = runtime.sessions.get(server.name) if runtime else None
295
+ fetched: List[McpResourceInfo] = []
296
+
297
+ if (
298
+ session
299
+ and mcp_types
300
+ and getattr(session, "list_resources", None)
301
+ and server.capabilities.get("resources", False)
302
+ ):
303
+ try:
304
+ response = await session.list_resources()
305
+ fetched = [
306
+ McpResourceInfo(
307
+ uri=str(res.uri),
308
+ name=getattr(res, "name", None),
309
+ description=getattr(res, "description", "") or "",
310
+ mime_type=getattr(res, "mimeType", None),
311
+ size=getattr(res, "size", None),
312
+ )
313
+ for res in response.resources
314
+ ]
315
+ except Exception as exc: # pragma: no cover - runtime errors
316
+ logger.error(f"Failed to fetch resources from {server.name}: {exc}")
317
+ fetched = []
318
+
319
+ candidate_resources = fetched if fetched else server.resources
320
+
321
+ for resource in candidate_resources:
322
+ resources.append(
323
+ {
324
+ "server": server.name,
325
+ "uri": getattr(resource, "uri", None),
326
+ "name": getattr(resource, "name", None),
327
+ "description": getattr(resource, "description", None),
328
+ "mime_type": getattr(resource, "mime_type", None),
329
+ "size": getattr(resource, "size", None),
330
+ }
331
+ )
332
+
333
+ result = ListMcpResourcesOutput(resources=resources)
334
+ yield ToolResult(
335
+ data=result,
336
+ result_for_assistant=self.render_result_for_assistant(result),
337
+ )
338
+
339
+
340
+ class ReadMcpResourceInput(BaseModel):
341
+ """Input for reading a single MCP resource."""
342
+
343
+ server: str = Field(description="Server name")
344
+ uri: str = Field(description="Resource URI")
345
+ save_blobs: bool = Field(
346
+ default=False,
347
+ description="If true, binary resource contents will be written to a temporary file in addition to Base64.",
348
+ )
349
+
350
+
351
+ class ResourceContentPart(BaseModel):
352
+ """Structured representation for resource content blocks."""
353
+
354
+ type: str
355
+ uri: Optional[str] = None
356
+ mime_type: Optional[str] = None
357
+ text: Optional[str] = None
358
+ size: Optional[int] = None
359
+ base64_data: Optional[str] = None
360
+ saved_path: Optional[str] = None
361
+
362
+
363
+ class ReadMcpResourceOutput(BaseModel):
364
+ server: str
365
+ uri: str
366
+ content: Optional[str] = None
367
+ contents: List[ResourceContentPart] = Field(default_factory=list)
368
+
369
+
370
+ class McpToolCallOutput(BaseModel):
371
+ """Standardized output for MCP tool calls."""
372
+
373
+ server: str
374
+ tool: str
375
+ content: Optional[str] = None
376
+ text: Optional[str] = None
377
+ content_blocks: Optional[List[Any]] = None
378
+ structured_content: Optional[dict] = None
379
+ is_error: bool = False
380
+
381
+
382
+ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
383
+ """Read a resource defined in MCP configuration."""
384
+
385
+ @property
386
+ def name(self) -> str:
387
+ return "ReadMcpResource"
388
+
389
+ async def description(self) -> str:
390
+ return (
391
+ "Reads a specific resource from an MCP server.\n"
392
+ "- server: The name of the MCP server to read from\n"
393
+ "- uri: The URI of the resource to read\n"
394
+ "- save_blobs: Save binary content to a temporary file in addition to Base64 output\n\n"
395
+ "Usage examples:\n"
396
+ '- Read a resource from a server: `readMcpResource({ server: "myserver", uri: "my-resource-uri" })`'
397
+ )
398
+
399
+ @property
400
+ def input_schema(self) -> type[ReadMcpResourceInput]:
401
+ return ReadMcpResourceInput
402
+
403
+ async def prompt(self, safe_mode: bool = False) -> str:
404
+ return (
405
+ "Reads a specific resource from an MCP server, identified by server name and resource URI.\n\n"
406
+ "Parameters:\n"
407
+ "- server (required): The name of the MCP server from which to read the resource\n"
408
+ "- uri (required): The URI of the resource to read\n"
409
+ "- save_blobs (optional): If true, write binary content to a temporary file and include the path"
410
+ )
411
+
412
+ def is_read_only(self) -> bool:
413
+ return True
414
+
415
+ def is_concurrency_safe(self) -> bool:
416
+ return True
417
+
418
+ def needs_permissions(self, input_data: Optional[ReadMcpResourceInput] = None) -> bool:
419
+ return False
420
+
421
+ async def validate_input(
422
+ self, input_data: ReadMcpResourceInput, context: Optional[ToolUseContext] = None
423
+ ) -> ValidationResult:
424
+ runtime = await ensure_mcp_runtime()
425
+ server_names = {s.name for s in runtime.servers}
426
+ if input_data.server not in server_names:
427
+ return ValidationResult(
428
+ result=False, message=f"Unknown MCP server '{input_data.server}'."
429
+ )
430
+ resource = find_mcp_resource(runtime.servers, input_data.server, input_data.uri)
431
+ if not resource:
432
+ return ValidationResult(
433
+ result=False,
434
+ message=f"Resource '{input_data.uri}' not found on server '{input_data.server}'.",
435
+ )
436
+ return ValidationResult(result=True)
437
+
438
+ def render_result_for_assistant(self, output: ReadMcpResourceOutput) -> str:
439
+ if output.contents:
440
+ texts = [part.text for part in output.contents if part.text]
441
+ blob_notes = [
442
+ f"[binary {part.mime_type or 'unknown'} {part.size or 'unknown'} bytes{f'; saved to {part.saved_path}' if part.saved_path else ''}]"
443
+ for part in output.contents
444
+ if part.type == "blob"
445
+ ]
446
+ if texts or blob_notes:
447
+ return "\n".join([*texts, *blob_notes]).strip()
448
+ if not output.content:
449
+ return f"MCP resource {output.uri} on {output.server} has no content."
450
+ return output.content
451
+
452
+ def render_tool_use_message(
453
+ self, input_data: ReadMcpResourceInput, verbose: bool = False
454
+ ) -> str:
455
+ return f"Read MCP resource {input_data.uri} from {input_data.server}"
456
+
457
+ async def call(
458
+ self,
459
+ input_data: ReadMcpResourceInput,
460
+ context: ToolUseContext,
461
+ ) -> AsyncGenerator[ToolOutput, None]:
462
+ runtime = await ensure_mcp_runtime()
463
+ session = runtime.sessions.get(input_data.server) if runtime else None
464
+
465
+ content_text = None
466
+ parts: List[ResourceContentPart] = []
467
+
468
+ if session and mcp_types:
469
+ try:
470
+ result = await session.read_resource(input_data.uri)
471
+ for item in result.contents:
472
+ if isinstance(item, mcp_types.TextResourceContents):
473
+ parts.append(
474
+ ResourceContentPart(
475
+ type="text",
476
+ uri=getattr(item, "uri", None),
477
+ mime_type=getattr(item, "mimeType", None),
478
+ text=item.text,
479
+ )
480
+ )
481
+ elif isinstance(item, mcp_types.BlobResourceContents):
482
+ blob_data = getattr(item, "blob", None)
483
+ base64_data = None
484
+ saved_path = None
485
+ if isinstance(blob_data, (bytes, bytearray)):
486
+ base64_data = base64.b64encode(blob_data).decode("ascii")
487
+ raw_bytes = blob_data
488
+ elif isinstance(blob_data, str):
489
+ base64_data = blob_data
490
+ try:
491
+ raw_bytes = base64.b64decode(blob_data)
492
+ except Exception:
493
+ raw_bytes = None
494
+ else:
495
+ raw_bytes = None
496
+
497
+ if input_data.save_blobs and raw_bytes:
498
+ suffix = ""
499
+ mime = getattr(item, "mimeType", None) or ""
500
+ if "/" in mime:
501
+ suffix = "." + mime.split("/")[-1]
502
+ fd, path = tempfile.mkstemp(prefix="ripperdoc_mcp_", suffix=suffix)
503
+ with os.fdopen(fd, "wb") as handle:
504
+ handle.write(raw_bytes)
505
+ saved_path = path
506
+
507
+ parts.append(
508
+ ResourceContentPart(
509
+ type="blob",
510
+ uri=getattr(item, "uri", None),
511
+ mime_type=getattr(item, "mimeType", None),
512
+ size=getattr(item, "size", None),
513
+ base64_data=base64_data,
514
+ saved_path=saved_path,
515
+ )
516
+ )
517
+ text_parts = [p.text for p in parts if p.text]
518
+ content_text = "\n".join([p for p in text_parts if p]) or None
519
+ except Exception as exc: # pragma: no cover - runtime errors
520
+ logger.error(
521
+ f"Error reading MCP resource {input_data.uri} from {input_data.server}: {exc}"
522
+ )
523
+ content_text = f"Error reading MCP resource: {exc}"
524
+ else:
525
+ resource = find_mcp_resource(runtime.servers, input_data.server, input_data.uri)
526
+ content_text = resource.text if resource else None
527
+ if resource:
528
+ parts.append(
529
+ ResourceContentPart(
530
+ type="text",
531
+ uri=resource.uri,
532
+ mime_type=resource.mime_type,
533
+ text=resource.text,
534
+ size=resource.size,
535
+ )
536
+ )
537
+
538
+ result = ReadMcpResourceOutput(
539
+ server=input_data.server, uri=input_data.uri, content=content_text, contents=parts
540
+ )
541
+ yield ToolResult(
542
+ data=result,
543
+ result_for_assistant=self.render_result_for_assistant(result),
544
+ )
545
+
546
+
547
+ def _sanitize_name(name: str) -> str:
548
+ return "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in name)
549
+
550
+
551
+ def _create_dynamic_input_model(schema: Optional[Dict[str, Any]]) -> type[BaseModel]:
552
+ raw_schema = schema if isinstance(schema, dict) else {"type": "object"}
553
+ raw_schema = raw_schema or {"type": "object"}
554
+
555
+ class DynamicMcpInput(BaseModel):
556
+ model_config = ConfigDict(extra="allow")
557
+
558
+ @classmethod
559
+ def model_json_schema(cls, *args: Any, **kwargs: Any) -> Dict[str, Any]:
560
+ return raw_schema
561
+
562
+ DynamicMcpInput.__name__ = (
563
+ f"McpInput_{abs(hash(json.dumps(raw_schema, sort_keys=True, default=str))) % 10_000_000}"
564
+ )
565
+ return DynamicMcpInput
566
+
567
+
568
+ def _annotation_flag(tool_info: Any, key: str) -> bool:
569
+ annotations = getattr(tool_info, "annotations", {}) or {}
570
+ if hasattr(annotations, "get"):
571
+ try:
572
+ return bool(annotations.get(key, False))
573
+ except Exception:
574
+ return False
575
+ return False
576
+
577
+
578
+ def _render_mcp_tool_result_for_assistant(output: McpToolCallOutput) -> str:
579
+ if output.text or output.content:
580
+ return output.text or output.content or ""
581
+ if output.is_error:
582
+ return "MCP tool call failed."
583
+ return f"MCP tool '{output.tool}' returned no content."
584
+
585
+
586
+ class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
587
+ """Runtime wrapper for an MCP tool exposed by a connected server."""
588
+
589
+ is_mcp = True
590
+
591
+ def __init__(self, server_name: str, tool_info: Any, project_path: Path) -> None:
592
+ self.server_name = server_name
593
+ self.tool_info = tool_info
594
+ self.project_path = project_path
595
+ self._input_model = _create_dynamic_input_model(getattr(tool_info, "input_schema", None))
596
+ self._name = f"mcp__{_sanitize_name(server_name)}__{_sanitize_name(tool_info.name)}"
597
+ self._user_facing = (
598
+ f"{server_name} - {getattr(tool_info, 'description', '') or tool_info.name} (MCP)"
599
+ )
600
+
601
+ @property
602
+ def name(self) -> str:
603
+ return self._name
604
+
605
+ async def description(self) -> str:
606
+ desc = getattr(self.tool_info, "description", "") or ""
607
+ schema = getattr(self.tool_info, "input_schema", None)
608
+ schema_snippet = json.dumps(schema, indent=2) if schema else ""
609
+ if schema_snippet:
610
+ schema_snippet = (
611
+ schema_snippet if len(schema_snippet) < 800 else schema_snippet[:800] + "..."
612
+ )
613
+ return f"{desc}\n\n[MCP tool]\nServer: {self.server_name}\nTool: {self.tool_info.name}\nInput schema:\n{schema_snippet}"
614
+ return f"{desc}\n\n[MCP tool]\nServer: {self.server_name}\nTool: {self.tool_info.name}"
615
+
616
+ @property
617
+ def input_schema(self) -> type[BaseModel]:
618
+ return self._input_model
619
+
620
+ async def prompt(self, safe_mode: bool = False) -> str:
621
+ return await self.description()
622
+
623
+ def is_read_only(self) -> bool:
624
+ return _annotation_flag(self.tool_info, "readOnlyHint")
625
+
626
+ def is_concurrency_safe(self) -> bool:
627
+ return self.is_read_only()
628
+
629
+ def is_destructive(self) -> bool:
630
+ return _annotation_flag(self.tool_info, "destructiveHint")
631
+
632
+ def is_open_world(self) -> bool:
633
+ return _annotation_flag(self.tool_info, "openWorldHint")
634
+
635
+ def defer_loading(self) -> bool:
636
+ """Avoid loading all MCP tools into the initial context."""
637
+ return True
638
+
639
+ def needs_permissions(self, input_data: Optional[BaseModel] = None) -> bool:
640
+ return not self.is_read_only()
641
+
642
+ def render_result_for_assistant(self, output: McpToolCallOutput) -> str:
643
+ return _render_mcp_tool_result_for_assistant(output)
644
+
645
+ def render_tool_use_message(self, input_data: BaseModel, verbose: bool = False) -> str:
646
+ args = input_data.model_dump(exclude_none=True)
647
+ arg_preview = json.dumps(args) if verbose and args else ""
648
+ suffix = f" with args {arg_preview}" if arg_preview else ""
649
+ return f"MCP {self.server_name}:{self.tool_info.name}{suffix}"
650
+
651
+ def user_facing_name(self) -> str:
652
+ return self._user_facing
653
+
654
+ async def call(
655
+ self,
656
+ input_data: BaseModel,
657
+ context: ToolUseContext,
658
+ ) -> AsyncGenerator[ToolOutput, None]:
659
+ runtime = await ensure_mcp_runtime(self.project_path)
660
+ session = runtime.sessions.get(self.server_name) if runtime else None
661
+ if not session:
662
+ result = McpToolCallOutput(
663
+ server=self.server_name,
664
+ tool=self.tool_info.name,
665
+ content=None,
666
+ text=None,
667
+ content_blocks=None,
668
+ structured_content=None,
669
+ is_error=True,
670
+ )
671
+ yield ToolResult(
672
+ data=result,
673
+ result_for_assistant=f"MCP server '{self.server_name}' is not connected.",
674
+ )
675
+ return
676
+
677
+ try:
678
+ args = input_data.model_dump(exclude_none=True)
679
+ result = await session.call_tool(
680
+ self.tool_info.name,
681
+ args or {},
682
+ )
683
+ raw_blocks = getattr(result, "content", None)
684
+ content_blocks = _normalize_content_blocks(raw_blocks)
685
+ content_text = _render_content_blocks(content_blocks) if content_blocks else None
686
+ structured = result.structuredContent if hasattr(result, "structuredContent") else None
687
+ assistant_text = content_text
688
+ if structured:
689
+ assistant_text = (assistant_text + "\n" if assistant_text else "") + json.dumps(
690
+ structured, indent=2
691
+ )
692
+ output = McpToolCallOutput(
693
+ server=self.server_name,
694
+ tool=self.tool_info.name,
695
+ content=assistant_text or None,
696
+ text=content_text,
697
+ content_blocks=content_blocks,
698
+ structured_content=structured,
699
+ is_error=getattr(result, "isError", False),
700
+ )
701
+ yield ToolResult(
702
+ data=output,
703
+ result_for_assistant=self.render_result_for_assistant(output),
704
+ )
705
+ except Exception as exc: # pragma: no cover - runtime errors
706
+ output = McpToolCallOutput(
707
+ server=self.server_name,
708
+ tool=self.tool_info.name,
709
+ content=None,
710
+ text=None,
711
+ content_blocks=None,
712
+ structured_content=None,
713
+ is_error=True,
714
+ )
715
+ logger.error(
716
+ f"Error calling MCP tool {self.tool_info.name} on {self.server_name}: {exc}"
717
+ )
718
+ yield ToolResult(
719
+ data=output,
720
+ result_for_assistant=f"Error calling MCP tool '{self.tool_info.name}' on '{self.server_name}': {exc}",
721
+ )
722
+
723
+
724
+ def _build_dynamic_mcp_tools(runtime: Optional[Any]) -> List[DynamicMcpTool]:
725
+ if not runtime or not getattr(runtime, "servers", None):
726
+ return []
727
+ tools: List[DynamicMcpTool] = []
728
+ for server in runtime.servers:
729
+ if getattr(server, "status", "") != "connected":
730
+ continue
731
+ if not getattr(server, "tools", None):
732
+ continue
733
+ for tool in server.tools:
734
+ tools.append(
735
+ DynamicMcpTool(server.name, tool, getattr(runtime, "project_path", Path.cwd()))
736
+ )
737
+ return tools
738
+
739
+
740
+ def load_dynamic_mcp_tools_sync(project_path: Optional[Path] = None) -> List[DynamicMcpTool]:
741
+ """Best-effort synchronous loader for MCP tools."""
742
+ runtime = get_existing_mcp_runtime()
743
+ if runtime and not getattr(runtime, "_closed", False):
744
+ return _build_dynamic_mcp_tools(runtime)
745
+
746
+ try:
747
+ loop = asyncio.get_running_loop()
748
+ if loop.is_running():
749
+ return []
750
+ except RuntimeError:
751
+ pass
752
+
753
+ async def _load_and_cleanup() -> List[DynamicMcpTool]:
754
+ runtime = await ensure_mcp_runtime(project_path)
755
+ try:
756
+ return _build_dynamic_mcp_tools(runtime)
757
+ finally:
758
+ # Close the runtime inside the same event loop to avoid asyncgen
759
+ # shutdown errors when asyncio.run tears down the loop.
760
+ await shutdown_mcp_runtime()
761
+
762
+ try:
763
+ return asyncio.run(_load_and_cleanup())
764
+ except Exception as exc: # pragma: no cover - SDK/runtime failures
765
+ logger.error(f"Failed to initialize MCP runtime for dynamic tools: {exc}")
766
+ return []
767
+
768
+
769
+ async def load_dynamic_mcp_tools_async(project_path: Optional[Path] = None) -> List[DynamicMcpTool]:
770
+ """Async loader for MCP tools when already in an event loop."""
771
+ try:
772
+ runtime = await ensure_mcp_runtime(project_path)
773
+ except Exception as exc: # pragma: no cover - SDK/runtime failures
774
+ logger.error(f"Failed to initialize MCP runtime for dynamic tools: {exc}")
775
+ return []
776
+ return _build_dynamic_mcp_tools(runtime)
777
+
778
+
779
+ def merge_tools_with_dynamic(base_tools: List[Any], dynamic_tools: List[Any]) -> List[Any]:
780
+ """Merge dynamic MCP tools into the existing tool list and rebuild the Task tool."""
781
+ from ripperdoc.tools.task_tool import TaskTool # Local import to avoid cycles
782
+
783
+ base_without_task = [tool for tool in base_tools if getattr(tool, "name", None) != "Task"]
784
+ existing_names = {getattr(tool, "name", None) for tool in base_without_task}
785
+
786
+ for tool in dynamic_tools:
787
+ if getattr(tool, "name", None) in existing_names:
788
+ continue
789
+ base_without_task.append(tool)
790
+ existing_names.add(getattr(tool, "name", None))
791
+
792
+ task_tool = TaskTool(lambda: base_without_task)
793
+ return base_without_task + [task_tool]
794
+
795
+
796
+ __all__ = [
797
+ "ListMcpServersTool",
798
+ "ListMcpResourcesTool",
799
+ "ReadMcpResourceTool",
800
+ "DynamicMcpTool",
801
+ "load_dynamic_mcp_tools_async",
802
+ "load_dynamic_mcp_tools_sync",
803
+ "merge_tools_with_dynamic",
804
+ ]