ripperdoc 0.2.4__py3-none-any.whl → 0.2.5__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 (75) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/__main__.py +0 -5
  3. ripperdoc/cli/cli.py +37 -16
  4. ripperdoc/cli/commands/__init__.py +2 -0
  5. ripperdoc/cli/commands/agents_cmd.py +12 -9
  6. ripperdoc/cli/commands/compact_cmd.py +7 -3
  7. ripperdoc/cli/commands/context_cmd.py +33 -13
  8. ripperdoc/cli/commands/doctor_cmd.py +27 -14
  9. ripperdoc/cli/commands/exit_cmd.py +1 -1
  10. ripperdoc/cli/commands/mcp_cmd.py +13 -8
  11. ripperdoc/cli/commands/memory_cmd.py +5 -5
  12. ripperdoc/cli/commands/models_cmd.py +47 -16
  13. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  14. ripperdoc/cli/commands/resume_cmd.py +1 -2
  15. ripperdoc/cli/commands/tasks_cmd.py +24 -13
  16. ripperdoc/cli/ui/rich_ui.py +500 -406
  17. ripperdoc/cli/ui/tool_renderers.py +298 -0
  18. ripperdoc/core/agents.py +17 -9
  19. ripperdoc/core/config.py +130 -6
  20. ripperdoc/core/default_tools.py +7 -2
  21. ripperdoc/core/permissions.py +20 -14
  22. ripperdoc/core/providers/anthropic.py +107 -4
  23. ripperdoc/core/providers/base.py +33 -4
  24. ripperdoc/core/providers/gemini.py +169 -50
  25. ripperdoc/core/providers/openai.py +257 -23
  26. ripperdoc/core/query.py +294 -61
  27. ripperdoc/core/query_utils.py +50 -6
  28. ripperdoc/core/skills.py +295 -0
  29. ripperdoc/core/system_prompt.py +13 -7
  30. ripperdoc/core/tool.py +8 -6
  31. ripperdoc/sdk/client.py +14 -1
  32. ripperdoc/tools/ask_user_question_tool.py +20 -22
  33. ripperdoc/tools/background_shell.py +19 -13
  34. ripperdoc/tools/bash_tool.py +356 -209
  35. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  36. ripperdoc/tools/enter_plan_mode_tool.py +5 -2
  37. ripperdoc/tools/exit_plan_mode_tool.py +6 -3
  38. ripperdoc/tools/file_edit_tool.py +53 -10
  39. ripperdoc/tools/file_read_tool.py +17 -7
  40. ripperdoc/tools/file_write_tool.py +49 -13
  41. ripperdoc/tools/glob_tool.py +10 -9
  42. ripperdoc/tools/grep_tool.py +182 -51
  43. ripperdoc/tools/ls_tool.py +6 -6
  44. ripperdoc/tools/mcp_tools.py +106 -456
  45. ripperdoc/tools/multi_edit_tool.py +49 -9
  46. ripperdoc/tools/notebook_edit_tool.py +57 -13
  47. ripperdoc/tools/skill_tool.py +205 -0
  48. ripperdoc/tools/task_tool.py +7 -8
  49. ripperdoc/tools/todo_tool.py +12 -12
  50. ripperdoc/tools/tool_search_tool.py +5 -6
  51. ripperdoc/utils/coerce.py +34 -0
  52. ripperdoc/utils/context_length_errors.py +252 -0
  53. ripperdoc/utils/file_watch.py +5 -4
  54. ripperdoc/utils/json_utils.py +4 -4
  55. ripperdoc/utils/log.py +3 -3
  56. ripperdoc/utils/mcp.py +36 -15
  57. ripperdoc/utils/memory.py +9 -6
  58. ripperdoc/utils/message_compaction.py +16 -11
  59. ripperdoc/utils/messages.py +73 -8
  60. ripperdoc/utils/path_ignore.py +677 -0
  61. ripperdoc/utils/permissions/__init__.py +7 -1
  62. ripperdoc/utils/permissions/path_validation_utils.py +5 -3
  63. ripperdoc/utils/permissions/shell_command_validation.py +496 -18
  64. ripperdoc/utils/prompt.py +1 -1
  65. ripperdoc/utils/safe_get_cwd.py +5 -2
  66. ripperdoc/utils/session_history.py +38 -19
  67. ripperdoc/utils/todo.py +6 -2
  68. ripperdoc/utils/token_estimation.py +4 -3
  69. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +12 -1
  70. ripperdoc-0.2.5.dist-info/RECORD +107 -0
  71. ripperdoc-0.2.4.dist-info/RECORD +0 -99
  72. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
  73. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
  74. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
  75. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
@@ -2,15 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import asyncio
6
5
  import base64
6
+ import binascii
7
7
  import json
8
8
  import os
9
9
  import tempfile
10
- from pathlib import Path
11
- from typing import Any, AsyncGenerator, Dict, List, Optional
10
+ from typing import Any, AsyncGenerator, List, Optional
12
11
 
13
- from pydantic import BaseModel, Field, ConfigDict
12
+ from pydantic import BaseModel, Field
14
13
 
15
14
  from ripperdoc.core.tool import (
16
15
  Tool,
@@ -26,9 +25,7 @@ from ripperdoc.utils.mcp import (
26
25
  ensure_mcp_runtime,
27
26
  find_mcp_resource,
28
27
  format_mcp_instructions,
29
- get_existing_mcp_runtime,
30
28
  load_mcp_servers_async,
31
- shutdown_mcp_runtime,
32
29
  )
33
30
  from ripperdoc.utils.token_estimation import estimate_tokens
34
31
 
@@ -37,15 +34,59 @@ logger = get_logger()
37
34
 
38
35
  try:
39
36
  import mcp.types as mcp_types # type: ignore
40
- except Exception: # pragma: no cover - SDK may be missing at runtime
37
+ except (ImportError, ModuleNotFoundError): # pragma: no cover - SDK may be missing at runtime
41
38
  mcp_types = None # type: ignore[assignment]
42
- logger.exception("[mcp_tools] MCP SDK unavailable during import")
39
+ logger.debug("[mcp_tools] MCP SDK unavailable during import")
43
40
 
44
41
  DEFAULT_MAX_MCP_OUTPUT_TOKENS = 25_000
45
42
  MIN_MCP_OUTPUT_TOKENS = 1_000
46
43
  DEFAULT_MCP_WARNING_FRACTION = 0.8
47
44
 
48
45
 
46
+ # =============================================================================
47
+ # Base class for MCP tools to reduce code duplication
48
+ # =============================================================================
49
+
50
+ class BaseMcpTool(Tool): # type: ignore[type-arg]
51
+ """Base class for MCP tools with common default implementations.
52
+
53
+ Provides default implementations for common MCP tool behaviors:
54
+ - is_read_only() returns True (MCP tools typically don't modify local state)
55
+ - is_concurrency_safe() returns True (MCP operations can run in parallel)
56
+ - needs_permissions() returns False (MCP tools handle their own auth)
57
+ """
58
+
59
+ def is_read_only(self) -> bool:
60
+ """MCP tools are read-only by default."""
61
+ return True
62
+
63
+ def is_concurrency_safe(self) -> bool:
64
+ """MCP operations can safely run concurrently."""
65
+ return True
66
+
67
+ def needs_permissions(self, input_data: Optional[Any] = None) -> bool:
68
+ """MCP tools don't require additional permissions."""
69
+ return False
70
+
71
+ async def validate_server_exists(self, server_name: str) -> ValidationResult:
72
+ """Validate that a server exists in the MCP runtime.
73
+
74
+ Common validation helper for tools that take a server parameter.
75
+ """
76
+ runtime = await ensure_mcp_runtime()
77
+ server_names = {s.name for s in runtime.servers}
78
+ if server_name not in server_names:
79
+ return ValidationResult(
80
+ result=False, message=f"Unknown MCP server '{server_name}'."
81
+ )
82
+ return ValidationResult(result=True)
83
+
84
+
85
+ # =============================================================================
86
+ # End BaseMcpTool
87
+ # =============================================================================
88
+
89
+
49
90
  def _get_mcp_token_limits() -> tuple[int, int]:
50
91
  """Compute warning and hard limits for MCP output size."""
51
92
  max_tokens = os.getenv("RIPPERDOC_MCP_MAX_OUTPUT_TOKENS")
@@ -57,7 +98,9 @@ def _get_mcp_token_limits() -> tuple[int, int]:
57
98
 
58
99
  warn_env = os.getenv("RIPPERDOC_MCP_WARNING_TOKENS")
59
100
  try:
60
- warn_tokens_int = int(warn_env) if warn_env else int(max_tokens_int * DEFAULT_MCP_WARNING_FRACTION)
101
+ warn_tokens_int = (
102
+ int(warn_env) if warn_env else int(max_tokens_int * DEFAULT_MCP_WARNING_FRACTION)
103
+ )
61
104
  except (TypeError, ValueError):
62
105
  warn_tokens_int = int(max_tokens_int * DEFAULT_MCP_WARNING_FRACTION)
63
106
  warn_tokens_int = max(MIN_MCP_OUTPUT_TOKENS, min(warn_tokens_int, max_tokens_int))
@@ -91,84 +134,6 @@ def _evaluate_mcp_output_size(
91
134
  return warning_text, None, token_estimate
92
135
 
93
136
 
94
- def _content_block_to_text(block: Any) -> str:
95
- block_type = getattr(block, "type", None) or (
96
- block.get("type") if isinstance(block, dict) else None
97
- )
98
- if block_type == "text":
99
- return str(getattr(block, "text", None) or block.get("text", ""))
100
- if block_type == "resource":
101
- resource = getattr(block, "resource", None) or block.get("resource")
102
- prefix = "resource"
103
- if isinstance(resource, dict):
104
- uri = resource.get("uri") or ""
105
- text = resource.get("text") or ""
106
- blob = resource.get("blob")
107
- if text:
108
- return f"[Resource {uri}] {text}"
109
- if blob:
110
- return f"[Resource {uri}] (binary content {len(str(blob))} chars)"
111
- if hasattr(resource, "uri"):
112
- uri = getattr(resource, "uri", "")
113
- text = getattr(resource, "text", None)
114
- blob = getattr(resource, "blob", None)
115
- if text:
116
- return f"[Resource {uri}] {text}"
117
- if blob:
118
- return f"[Resource {uri}] (binary content {len(str(blob))} chars)"
119
- return prefix
120
- if block_type == "resource_link":
121
- uri = getattr(block, "uri", None) or (block.get("uri") if isinstance(block, dict) else None)
122
- return f"[Resource link] {uri}" if uri else "[Resource link]"
123
- if block_type == "image":
124
- mime = getattr(block, "mimeType", None) or (
125
- block.get("mimeType") if isinstance(block, dict) else None
126
- )
127
- return f"[Image content {mime or ''}]".strip()
128
- if block_type == "audio":
129
- mime = getattr(block, "mimeType", None) or (
130
- block.get("mimeType") if isinstance(block, dict) else None
131
- )
132
- return f"[Audio content {mime or ''}]".strip()
133
- return str(block)
134
-
135
-
136
- def _render_content_blocks(blocks: List[Any]) -> str:
137
- if not blocks:
138
- return ""
139
- parts = [_content_block_to_text(block) for block in blocks]
140
- return "\n".join([p for p in parts if p])
141
-
142
-
143
- def _normalize_content_block(block: Any) -> Any:
144
- """Convert MCP content blocks to JSON-serializable structures."""
145
- if isinstance(block, dict):
146
- return block
147
- result: Dict[str, Any] = {}
148
- for attr in (
149
- "type",
150
- "text",
151
- "mimeType",
152
- "data",
153
- "name",
154
- "uri",
155
- "description",
156
- "resource",
157
- "blob",
158
- ):
159
- if hasattr(block, attr):
160
- result[attr] = getattr(block, attr)
161
- if result:
162
- return result
163
- return str(block)
164
-
165
-
166
- def _normalize_content_blocks(blocks: Optional[List[Any]]) -> Optional[List[Any]]:
167
- if not blocks:
168
- return None
169
- return [_normalize_content_block(block) for block in blocks]
170
-
171
-
172
137
  class ListMcpServersInput(BaseModel):
173
138
  """Input for listing MCP servers."""
174
139
 
@@ -181,7 +146,7 @@ class ListMcpServersOutput(BaseModel):
181
146
  servers: List[dict]
182
147
 
183
148
 
184
- class ListMcpServersTool(Tool[ListMcpServersInput, ListMcpServersOutput]):
149
+ class ListMcpServersTool(BaseMcpTool, Tool[ListMcpServersInput, ListMcpServersOutput]):
185
150
  """List configured MCP servers and their tools."""
186
151
 
187
152
  @property
@@ -195,19 +160,10 @@ class ListMcpServersTool(Tool[ListMcpServersInput, ListMcpServersOutput]):
195
160
  def input_schema(self) -> type[ListMcpServersInput]:
196
161
  return ListMcpServersInput
197
162
 
198
- async def prompt(self, safe_mode: bool = False) -> str:
163
+ async def prompt(self, _safe_mode: bool = False) -> str:
199
164
  servers = await load_mcp_servers_async()
200
165
  return format_mcp_instructions(servers)
201
166
 
202
- def is_read_only(self) -> bool:
203
- return True
204
-
205
- def is_concurrency_safe(self) -> bool:
206
- return True
207
-
208
- def needs_permissions(self, input_data: Optional[ListMcpServersInput] = None) -> bool:
209
- return False
210
-
211
167
  def render_result_for_assistant(self, output: ListMcpServersOutput) -> str:
212
168
  if not output.servers:
213
169
  return "No MCP servers configured."
@@ -221,14 +177,14 @@ class ListMcpServersTool(Tool[ListMcpServersInput, ListMcpServersOutput]):
221
177
  return "\n".join(lines)
222
178
 
223
179
  def render_tool_use_message(
224
- self, input_data: ListMcpServersInput, verbose: bool = False
180
+ self, input_data: ListMcpServersInput, _verbose: bool = False
225
181
  ) -> str:
226
182
  return f"List MCP servers{f' for {input_data.server}' if input_data.server else ''}"
227
183
 
228
184
  async def call(
229
185
  self,
230
186
  input_data: ListMcpServersInput,
231
- context: ToolUseContext,
187
+ _context: ToolUseContext,
232
188
  ) -> AsyncGenerator[ToolOutput, None]:
233
189
  runtime = await ensure_mcp_runtime()
234
190
  servers: List[McpServerInfo] = runtime.servers
@@ -267,7 +223,7 @@ class ListMcpResourcesOutput(BaseModel):
267
223
  resources: List[dict]
268
224
 
269
225
 
270
- class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
226
+ class ListMcpResourcesTool(BaseMcpTool, Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
271
227
  """List resources exposed by MCP servers."""
272
228
 
273
229
  @property
@@ -287,7 +243,7 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
287
243
  def input_schema(self) -> type[ListMcpResourcesInput]:
288
244
  return ListMcpResourcesInput
289
245
 
290
- async def prompt(self, safe_mode: bool = False) -> str:
246
+ async def prompt(self, _safe_mode: bool = False) -> str:
291
247
  return (
292
248
  "List available resources from configured MCP servers.\n"
293
249
  "Each returned resource will include all standard MCP resource fields plus a 'server' field\n"
@@ -297,24 +253,11 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
297
253
  " resources from all servers will be returned."
298
254
  )
299
255
 
300
- def is_read_only(self) -> bool:
301
- return True
302
-
303
- def is_concurrency_safe(self) -> bool:
304
- return True
305
-
306
- def needs_permissions(self, input_data: Optional[ListMcpResourcesInput] = None) -> bool:
307
- return False
308
-
309
256
  async def validate_input(
310
- self, input_data: ListMcpResourcesInput, context: Optional[ToolUseContext] = None
257
+ self, input_data: ListMcpResourcesInput, _context: Optional[ToolUseContext] = None
311
258
  ) -> ValidationResult:
312
- runtime = await ensure_mcp_runtime()
313
- server_names = {s.name for s in runtime.servers}
314
- if input_data.server and input_data.server not in server_names:
315
- return ValidationResult(
316
- result=False, message=f"Unknown MCP server '{input_data.server}'."
317
- )
259
+ if input_data.server:
260
+ return await self.validate_server_exists(input_data.server)
318
261
  return ValidationResult(result=True)
319
262
 
320
263
  def render_result_for_assistant(self, output: ListMcpResourcesOutput) -> str:
@@ -322,19 +265,22 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
322
265
  return "No MCP resources found."
323
266
  try:
324
267
  return json.dumps(output.resources, indent=2, ensure_ascii=False)
325
- except Exception:
326
- logger.exception("[mcp_tools] Failed to serialize MCP resources for assistant output")
268
+ except (TypeError, ValueError) as exc:
269
+ logger.warning(
270
+ "[mcp_tools] Failed to serialize MCP resources for assistant output: %s: %s",
271
+ type(exc).__name__, exc,
272
+ )
327
273
  return str(output.resources)
328
274
 
329
275
  def render_tool_use_message(
330
- self, input_data: ListMcpResourcesInput, verbose: bool = False
276
+ self, input_data: ListMcpResourcesInput, _verbose: bool = False
331
277
  ) -> str:
332
278
  return f"List MCP resources{f' for {input_data.server}' if input_data.server else ''}"
333
279
 
334
280
  async def call(
335
281
  self,
336
282
  input_data: ListMcpResourcesInput,
337
- context: ToolUseContext,
283
+ _context: ToolUseContext,
338
284
  ) -> AsyncGenerator[ToolOutput, None]:
339
285
  runtime = await ensure_mcp_runtime()
340
286
  servers = runtime.servers
@@ -364,10 +310,12 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
364
310
  )
365
311
  for res in response.resources
366
312
  ]
367
- except Exception as exc: # pragma: no cover - runtime errors
368
- logger.exception(
369
- "Failed to fetch resources from MCP server",
370
- extra={"server": server.name, "error": str(exc)},
313
+ except (OSError, RuntimeError, ConnectionError, ValueError) as exc:
314
+ # pragma: no cover - runtime errors
315
+ logger.warning(
316
+ "Failed to fetch resources from MCP server: %s: %s",
317
+ type(exc).__name__, exc,
318
+ extra={"server": server.name},
371
319
  )
372
320
  fetched = []
373
321
 
@@ -425,21 +373,7 @@ class ReadMcpResourceOutput(BaseModel):
425
373
  is_error: bool = False
426
374
 
427
375
 
428
- class McpToolCallOutput(BaseModel):
429
- """Standardized output for MCP tool calls."""
430
-
431
- server: str
432
- tool: str
433
- content: Optional[str] = None
434
- text: Optional[str] = None
435
- content_blocks: Optional[List[Any]] = None
436
- structured_content: Optional[dict] = None
437
- is_error: bool = False
438
- token_estimate: Optional[int] = None
439
- warning: Optional[str] = None
440
-
441
-
442
- class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
376
+ class ReadMcpResourceTool(BaseMcpTool, Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
443
377
  """Read a resource defined in MCP configuration."""
444
378
 
445
379
  @property
@@ -460,7 +394,7 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
460
394
  def input_schema(self) -> type[ReadMcpResourceInput]:
461
395
  return ReadMcpResourceInput
462
396
 
463
- async def prompt(self, safe_mode: bool = False) -> str:
397
+ async def prompt(self, _safe_mode: bool = False) -> str:
464
398
  return (
465
399
  "Reads a specific resource from an MCP server, identified by server name and resource URI.\n\n"
466
400
  "Parameters:\n"
@@ -469,24 +403,16 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
469
403
  "- save_blobs (optional): If true, write binary content to a temporary file and include the path"
470
404
  )
471
405
 
472
- def is_read_only(self) -> bool:
473
- return True
474
-
475
- def is_concurrency_safe(self) -> bool:
476
- return True
477
-
478
- def needs_permissions(self, input_data: Optional[ReadMcpResourceInput] = None) -> bool:
479
- return False
480
-
481
406
  async def validate_input(
482
- self, input_data: ReadMcpResourceInput, context: Optional[ToolUseContext] = None
407
+ self, input_data: ReadMcpResourceInput, _context: Optional[ToolUseContext] = None
483
408
  ) -> ValidationResult:
409
+ # First validate the server exists
410
+ server_result = await self.validate_server_exists(input_data.server)
411
+ if not server_result.result:
412
+ return server_result
413
+
414
+ # Then validate the resource exists on that server
484
415
  runtime = await ensure_mcp_runtime()
485
- server_names = {s.name for s in runtime.servers}
486
- if input_data.server not in server_names:
487
- return ValidationResult(
488
- result=False, message=f"Unknown MCP server '{input_data.server}'."
489
- )
490
416
  resource = find_mcp_resource(runtime.servers, input_data.server, input_data.uri)
491
417
  if not resource:
492
418
  return ValidationResult(
@@ -510,14 +436,14 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
510
436
  return output.content
511
437
 
512
438
  def render_tool_use_message(
513
- self, input_data: ReadMcpResourceInput, verbose: bool = False
439
+ self, input_data: ReadMcpResourceInput, _verbose: bool = False
514
440
  ) -> str:
515
441
  return f"Read MCP resource {input_data.uri} from {input_data.server}"
516
442
 
517
443
  async def call(
518
444
  self,
519
445
  input_data: ReadMcpResourceInput,
520
- context: ToolUseContext,
446
+ _context: ToolUseContext,
521
447
  ) -> AsyncGenerator[ToolOutput, None]:
522
448
  runtime = await ensure_mcp_runtime()
523
449
  session = runtime.sessions.get(input_data.server) if runtime else None
@@ -553,9 +479,10 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
553
479
  base64_data = blob_data
554
480
  try:
555
481
  raw_bytes = base64.b64decode(blob_data)
556
- except Exception:
557
- logger.exception(
558
- "[mcp_tools] Failed to decode base64 blob content",
482
+ except (ValueError, binascii.Error) as exc:
483
+ logger.warning(
484
+ "[mcp_tools] Failed to decode base64 blob content: %s: %s",
485
+ type(exc).__name__, exc,
559
486
  extra={"server": input_data.server, "uri": input_data.uri},
560
487
  )
561
488
  raw_bytes = None
@@ -584,10 +511,12 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
584
511
  )
585
512
  text_parts = [p.text for p in parts if p.text]
586
513
  content_text = "\n".join([p for p in text_parts if p]) or None
587
- except Exception as exc: # pragma: no cover - runtime errors
588
- logger.exception(
589
- "Error reading MCP resource",
590
- extra={"server": input_data.server, "uri": input_data.uri, "error": str(exc)},
514
+ except (OSError, RuntimeError, ConnectionError, ValueError, KeyError) as exc:
515
+ # pragma: no cover - runtime errors
516
+ logger.warning(
517
+ "Error reading MCP resource: %s: %s",
518
+ type(exc).__name__, exc,
519
+ extra={"server": input_data.server, "uri": input_data.uri},
591
520
  )
592
521
  content_text = f"Error reading MCP resource: {exc}"
593
522
  else:
@@ -639,302 +568,23 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
639
568
  )
640
569
 
641
570
 
642
- def _sanitize_name(name: str) -> str:
643
- return "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in name)
644
-
645
-
646
- def _create_dynamic_input_model(schema: Optional[Dict[str, Any]]) -> type[BaseModel]:
647
- raw_schema = schema if isinstance(schema, dict) else {"type": "object"}
648
- raw_schema = raw_schema or {"type": "object"}
649
-
650
- class DynamicMcpInput(BaseModel):
651
- model_config = ConfigDict(extra="allow")
652
-
653
- @classmethod
654
- def model_json_schema(cls, *args: Any, **kwargs: Any) -> Dict[str, Any]:
655
- return raw_schema
656
-
657
- DynamicMcpInput.__name__ = (
658
- f"McpInput_{abs(hash(json.dumps(raw_schema, sort_keys=True, default=str))) % 10_000_000}"
659
- )
660
- return DynamicMcpInput
661
-
662
-
663
- def _annotation_flag(tool_info: Any, key: str) -> bool:
664
- annotations = getattr(tool_info, "annotations", {}) or {}
665
- if hasattr(annotations, "get"):
666
- try:
667
- return bool(annotations.get(key, False))
668
- except Exception:
669
- logger.debug("[mcp_tools] Failed to read annotation flag", exc_info=True)
670
- return False
671
- return False
672
-
673
-
674
- def _render_mcp_tool_result_for_assistant(output: McpToolCallOutput) -> str:
675
- if output.text or output.content:
676
- return output.text or output.content or ""
677
- if output.is_error:
678
- return "MCP tool call failed."
679
- return f"MCP tool '{output.tool}' returned no content."
680
-
681
-
682
- class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
683
- """Runtime wrapper for an MCP tool exposed by a connected server."""
684
-
685
- is_mcp = True
686
-
687
- def __init__(self, server_name: str, tool_info: Any, project_path: Path) -> None:
688
- self.server_name = server_name
689
- self.tool_info = tool_info
690
- self.project_path = project_path
691
- self._input_model = _create_dynamic_input_model(getattr(tool_info, "input_schema", None))
692
- self._name = f"mcp__{_sanitize_name(server_name)}__{_sanitize_name(tool_info.name)}"
693
- self._user_facing = (
694
- f"{server_name} - {getattr(tool_info, 'description', '') or tool_info.name} (MCP)"
695
- )
696
-
697
- @property
698
- def name(self) -> str:
699
- return self._name
700
-
701
- async def description(self) -> str:
702
- desc = getattr(self.tool_info, "description", "") or ""
703
- schema = getattr(self.tool_info, "input_schema", None)
704
- schema_snippet = json.dumps(schema, indent=2) if schema else ""
705
- if schema_snippet:
706
- schema_snippet = (
707
- schema_snippet if len(schema_snippet) < 800 else schema_snippet[:800] + "..."
708
- )
709
- return f"{desc}\n\n[MCP tool]\nServer: {self.server_name}\nTool: {self.tool_info.name}\nInput schema:\n{schema_snippet}"
710
- return f"{desc}\n\n[MCP tool]\nServer: {self.server_name}\nTool: {self.tool_info.name}"
711
-
712
- @property
713
- def input_schema(self) -> type[BaseModel]:
714
- return self._input_model
715
-
716
- async def prompt(self, safe_mode: bool = False) -> str:
717
- return await self.description()
718
-
719
- def is_read_only(self) -> bool:
720
- return _annotation_flag(self.tool_info, "readOnlyHint")
721
-
722
- def is_concurrency_safe(self) -> bool:
723
- return self.is_read_only()
724
-
725
- def is_destructive(self) -> bool:
726
- return _annotation_flag(self.tool_info, "destructiveHint")
727
-
728
- def is_open_world(self) -> bool:
729
- return _annotation_flag(self.tool_info, "openWorldHint")
730
-
731
- def defer_loading(self) -> bool:
732
- """Avoid loading all MCP tools into the initial context."""
733
- return True
734
-
735
- def needs_permissions(self, input_data: Optional[BaseModel] = None) -> bool:
736
- return not self.is_read_only()
737
-
738
- def render_result_for_assistant(self, output: McpToolCallOutput) -> str:
739
- return _render_mcp_tool_result_for_assistant(output)
740
-
741
- def render_tool_use_message(self, input_data: BaseModel, verbose: bool = False) -> str:
742
- args = input_data.model_dump(exclude_none=True)
743
- arg_preview = json.dumps(args) if verbose and args else ""
744
- suffix = f" with args {arg_preview}" if arg_preview else ""
745
- return f"MCP {self.server_name}:{self.tool_info.name}{suffix}"
746
-
747
- def user_facing_name(self) -> str:
748
- return self._user_facing
749
-
750
- async def call(
751
- self,
752
- input_data: BaseModel,
753
- context: ToolUseContext,
754
- ) -> AsyncGenerator[ToolOutput, None]:
755
- runtime = await ensure_mcp_runtime(self.project_path)
756
- session = runtime.sessions.get(self.server_name) if runtime else None
757
- if not session:
758
- result = McpToolCallOutput(
759
- server=self.server_name,
760
- tool=self.tool_info.name,
761
- content=None,
762
- text=None,
763
- content_blocks=None,
764
- structured_content=None,
765
- is_error=True,
766
- )
767
- yield ToolResult(
768
- data=result,
769
- result_for_assistant=f"MCP server '{self.server_name}' is not connected.",
770
- )
771
- return
772
-
773
- try:
774
- args = input_data.model_dump(exclude_none=True)
775
- call_result = await session.call_tool(
776
- self.tool_info.name,
777
- args or {},
778
- )
779
- raw_blocks = getattr(call_result, "content", None)
780
- content_blocks = _normalize_content_blocks(raw_blocks)
781
- content_text = _render_content_blocks(content_blocks) if content_blocks else None
782
- structured = (
783
- call_result.structuredContent if hasattr(call_result, "structuredContent") else None
784
- )
785
- assistant_text = content_text
786
- if structured:
787
- assistant_text = (assistant_text + "\n" if assistant_text else "") + json.dumps(
788
- structured, indent=2
789
- )
790
- output = McpToolCallOutput(
791
- server=self.server_name,
792
- tool=self.tool_info.name,
793
- content=assistant_text or None,
794
- text=content_text,
795
- content_blocks=content_blocks,
796
- structured_content=structured,
797
- is_error=getattr(call_result, "isError", False),
798
- )
799
- base_result_text = self.render_result_for_assistant(output)
800
- warning_text, error_text, token_estimate = _evaluate_mcp_output_size(
801
- base_result_text, self.server_name, self.tool_info.name
802
- )
803
-
804
- if error_text:
805
- limited_output = McpToolCallOutput(
806
- server=self.server_name,
807
- tool=self.tool_info.name,
808
- content=None,
809
- text=None,
810
- content_blocks=None,
811
- structured_content=None,
812
- is_error=True,
813
- token_estimate=token_estimate,
814
- warning=None,
815
- )
816
- yield ToolResult(data=limited_output, result_for_assistant=error_text)
817
- return
818
-
819
- annotated_output = output.model_copy(
820
- update={"token_estimate": token_estimate, "warning": warning_text}
821
- )
822
-
823
- final_text = base_result_text or ""
824
- if not final_text and warning_text:
825
- final_text = warning_text
826
-
827
- yield ToolResult(
828
- data=annotated_output,
829
- result_for_assistant=final_text,
830
- )
831
- except Exception as exc: # pragma: no cover - runtime errors
832
- output = McpToolCallOutput(
833
- server=self.server_name,
834
- tool=self.tool_info.name,
835
- content=None,
836
- text=None,
837
- content_blocks=None,
838
- structured_content=None,
839
- is_error=True,
840
- )
841
- logger.exception(
842
- "Error calling MCP tool",
843
- extra={
844
- "server": self.server_name,
845
- "tool": self.tool_info.name,
846
- "error": str(exc),
847
- },
848
- )
849
- yield ToolResult(
850
- data=output,
851
- result_for_assistant=f"Error calling MCP tool '{self.tool_info.name}' on '{self.server_name}': {exc}",
852
- )
853
-
854
-
855
- def _build_dynamic_mcp_tools(runtime: Optional[Any]) -> List[DynamicMcpTool]:
856
- if not runtime or not getattr(runtime, "servers", None):
857
- return []
858
- tools: List[DynamicMcpTool] = []
859
- for server in runtime.servers:
860
- if getattr(server, "status", "") != "connected":
861
- continue
862
- if not getattr(server, "tools", None):
863
- continue
864
- for tool in server.tools:
865
- tools.append(
866
- DynamicMcpTool(server.name, tool, getattr(runtime, "project_path", Path.cwd()))
867
- )
868
- return tools
869
-
870
-
871
- def load_dynamic_mcp_tools_sync(project_path: Optional[Path] = None) -> List[DynamicMcpTool]:
872
- """Best-effort synchronous loader for MCP tools."""
873
- runtime = get_existing_mcp_runtime()
874
- if runtime and not getattr(runtime, "_closed", False):
875
- return _build_dynamic_mcp_tools(runtime)
876
-
877
- try:
878
- loop = asyncio.get_running_loop()
879
- if loop.is_running():
880
- return []
881
- except RuntimeError:
882
- pass
883
-
884
- async def _load_and_cleanup() -> List[DynamicMcpTool]:
885
- runtime = await ensure_mcp_runtime(project_path)
886
- try:
887
- return _build_dynamic_mcp_tools(runtime)
888
- finally:
889
- # Close the runtime inside the same event loop to avoid asyncgen
890
- # shutdown errors when asyncio.run tears down the loop.
891
- await shutdown_mcp_runtime()
892
-
893
- try:
894
- return asyncio.run(_load_and_cleanup())
895
- except Exception as exc: # pragma: no cover - SDK/runtime failures
896
- logger.exception(
897
- "Failed to initialize MCP runtime for dynamic tools (sync)",
898
- extra={"error": str(exc)},
899
- )
900
- return []
901
-
902
-
903
- async def load_dynamic_mcp_tools_async(project_path: Optional[Path] = None) -> List[DynamicMcpTool]:
904
- """Async loader for MCP tools when already in an event loop."""
905
- try:
906
- runtime = await ensure_mcp_runtime(project_path)
907
- except Exception as exc: # pragma: no cover - SDK/runtime failures
908
- logger.exception(
909
- "Failed to initialize MCP runtime for dynamic tools (async)",
910
- extra={"error": str(exc)},
911
- )
912
- return []
913
- return _build_dynamic_mcp_tools(runtime)
914
-
915
-
916
- def merge_tools_with_dynamic(base_tools: List[Any], dynamic_tools: List[Any]) -> List[Any]:
917
- """Merge dynamic MCP tools into the existing tool list and rebuild the Task tool."""
918
- from ripperdoc.tools.task_tool import TaskTool # Local import to avoid cycles
919
-
920
- base_without_task = [tool for tool in base_tools if getattr(tool, "name", None) != "Task"]
921
- existing_names = {getattr(tool, "name", None) for tool in base_without_task}
922
-
923
- for tool in dynamic_tools:
924
- if getattr(tool, "name", None) in existing_names:
925
- continue
926
- base_without_task.append(tool)
927
- existing_names.add(getattr(tool, "name", None))
928
-
929
- task_tool = TaskTool(lambda: base_without_task)
930
- return base_without_task + [task_tool]
931
-
571
+ # Re-export DynamicMcpTool and related functions from the dedicated module
572
+ # for backward compatibility. The imports are placed at the end to avoid
573
+ # circular import issues since dynamic_mcp_tool.py imports _evaluate_mcp_output_size.
574
+ from ripperdoc.tools.dynamic_mcp_tool import ( # noqa: E402
575
+ DynamicMcpTool,
576
+ McpToolCallOutput,
577
+ load_dynamic_mcp_tools_async,
578
+ load_dynamic_mcp_tools_sync,
579
+ merge_tools_with_dynamic,
580
+ )
932
581
 
933
582
  __all__ = [
934
583
  "ListMcpServersTool",
935
584
  "ListMcpResourcesTool",
936
585
  "ReadMcpResourceTool",
937
586
  "DynamicMcpTool",
587
+ "McpToolCallOutput",
938
588
  "load_dynamic_mcp_tools_async",
939
589
  "load_dynamic_mcp_tools_sync",
940
590
  "merge_tools_with_dynamic",