ripperdoc 0.2.3__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 (76) 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 +35 -15
  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 +523 -396
  17. ripperdoc/cli/ui/tool_renderers.py +298 -0
  18. ripperdoc/core/agents.py +172 -4
  19. ripperdoc/core/config.py +130 -6
  20. ripperdoc/core/default_tools.py +13 -2
  21. ripperdoc/core/permissions.py +20 -14
  22. ripperdoc/core/providers/__init__.py +31 -15
  23. ripperdoc/core/providers/anthropic.py +122 -8
  24. ripperdoc/core/providers/base.py +93 -15
  25. ripperdoc/core/providers/gemini.py +539 -96
  26. ripperdoc/core/providers/openai.py +371 -26
  27. ripperdoc/core/query.py +301 -62
  28. ripperdoc/core/query_utils.py +51 -7
  29. ripperdoc/core/skills.py +295 -0
  30. ripperdoc/core/system_prompt.py +79 -67
  31. ripperdoc/core/tool.py +15 -6
  32. ripperdoc/sdk/client.py +14 -1
  33. ripperdoc/tools/ask_user_question_tool.py +431 -0
  34. ripperdoc/tools/background_shell.py +82 -26
  35. ripperdoc/tools/bash_tool.py +356 -209
  36. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  37. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  38. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  39. ripperdoc/tools/file_edit_tool.py +53 -10
  40. ripperdoc/tools/file_read_tool.py +17 -7
  41. ripperdoc/tools/file_write_tool.py +49 -13
  42. ripperdoc/tools/glob_tool.py +10 -9
  43. ripperdoc/tools/grep_tool.py +182 -51
  44. ripperdoc/tools/ls_tool.py +6 -6
  45. ripperdoc/tools/mcp_tools.py +172 -413
  46. ripperdoc/tools/multi_edit_tool.py +49 -9
  47. ripperdoc/tools/notebook_edit_tool.py +57 -13
  48. ripperdoc/tools/skill_tool.py +205 -0
  49. ripperdoc/tools/task_tool.py +91 -9
  50. ripperdoc/tools/todo_tool.py +12 -12
  51. ripperdoc/tools/tool_search_tool.py +5 -6
  52. ripperdoc/utils/coerce.py +34 -0
  53. ripperdoc/utils/context_length_errors.py +252 -0
  54. ripperdoc/utils/file_watch.py +5 -4
  55. ripperdoc/utils/json_utils.py +4 -4
  56. ripperdoc/utils/log.py +3 -3
  57. ripperdoc/utils/mcp.py +82 -22
  58. ripperdoc/utils/memory.py +9 -6
  59. ripperdoc/utils/message_compaction.py +19 -16
  60. ripperdoc/utils/messages.py +73 -8
  61. ripperdoc/utils/path_ignore.py +677 -0
  62. ripperdoc/utils/permissions/__init__.py +7 -1
  63. ripperdoc/utils/permissions/path_validation_utils.py +5 -3
  64. ripperdoc/utils/permissions/shell_command_validation.py +496 -18
  65. ripperdoc/utils/prompt.py +1 -1
  66. ripperdoc/utils/safe_get_cwd.py +5 -2
  67. ripperdoc/utils/session_history.py +38 -19
  68. ripperdoc/utils/todo.py +6 -2
  69. ripperdoc/utils/token_estimation.py +34 -0
  70. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +14 -1
  71. ripperdoc-0.2.5.dist-info/RECORD +107 -0
  72. ripperdoc-0.2.3.dist-info/RECORD +0 -95
  73. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
  74. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
  75. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
  76. {ripperdoc-0.2.3.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,97 +25,113 @@ 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
  )
30
+ from ripperdoc.utils.token_estimation import estimate_tokens
33
31
 
34
32
 
35
33
  logger = get_logger()
36
34
 
37
35
  try:
38
36
  import mcp.types as mcp_types # type: ignore
39
- except Exception: # pragma: no cover - SDK may be missing at runtime
37
+ except (ImportError, ModuleNotFoundError): # pragma: no cover - SDK may be missing at runtime
40
38
  mcp_types = None # type: ignore[assignment]
41
- logger.exception("[mcp_tools] MCP SDK unavailable during import")
39
+ logger.debug("[mcp_tools] MCP SDK unavailable during import")
42
40
 
41
+ DEFAULT_MAX_MCP_OUTPUT_TOKENS = 25_000
42
+ MIN_MCP_OUTPUT_TOKENS = 1_000
43
+ DEFAULT_MCP_WARNING_FRACTION = 0.8
43
44
 
44
- def _content_block_to_text(block: Any) -> str:
45
- block_type = getattr(block, "type", None) or (
46
- block.get("type") if isinstance(block, dict) else None
47
- )
48
- if block_type == "text":
49
- return str(getattr(block, "text", None) or block.get("text", ""))
50
- if block_type == "resource":
51
- resource = getattr(block, "resource", None) or block.get("resource")
52
- prefix = "resource"
53
- if isinstance(resource, dict):
54
- uri = resource.get("uri") or ""
55
- text = resource.get("text") or ""
56
- blob = resource.get("blob")
57
- if text:
58
- return f"[Resource {uri}] {text}"
59
- if blob:
60
- return f"[Resource {uri}] (binary content {len(str(blob))} chars)"
61
- if hasattr(resource, "uri"):
62
- uri = getattr(resource, "uri", "")
63
- text = getattr(resource, "text", None)
64
- blob = getattr(resource, "blob", None)
65
- if text:
66
- return f"[Resource {uri}] {text}"
67
- if blob:
68
- return f"[Resource {uri}] (binary content {len(str(blob))} chars)"
69
- return prefix
70
- if block_type == "resource_link":
71
- uri = getattr(block, "uri", None) or (block.get("uri") if isinstance(block, dict) else None)
72
- return f"[Resource link] {uri}" if uri else "[Resource link]"
73
- if block_type == "image":
74
- mime = getattr(block, "mimeType", None) or (
75
- block.get("mimeType") if isinstance(block, dict) else None
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
+
90
+ def _get_mcp_token_limits() -> tuple[int, int]:
91
+ """Compute warning and hard limits for MCP output size."""
92
+ max_tokens = os.getenv("RIPPERDOC_MCP_MAX_OUTPUT_TOKENS")
93
+ try:
94
+ max_tokens_int = int(max_tokens) if max_tokens else DEFAULT_MAX_MCP_OUTPUT_TOKENS
95
+ except (TypeError, ValueError):
96
+ max_tokens_int = DEFAULT_MAX_MCP_OUTPUT_TOKENS
97
+ max_tokens_int = max(MIN_MCP_OUTPUT_TOKENS, max_tokens_int)
98
+
99
+ warn_env = os.getenv("RIPPERDOC_MCP_WARNING_TOKENS")
100
+ try:
101
+ warn_tokens_int = (
102
+ int(warn_env) if warn_env else int(max_tokens_int * DEFAULT_MCP_WARNING_FRACTION)
76
103
  )
77
- return f"[Image content {mime or ''}]".strip()
78
- if block_type == "audio":
79
- mime = getattr(block, "mimeType", None) or (
80
- block.get("mimeType") if isinstance(block, dict) else None
104
+ except (TypeError, ValueError):
105
+ warn_tokens_int = int(max_tokens_int * DEFAULT_MCP_WARNING_FRACTION)
106
+ warn_tokens_int = max(MIN_MCP_OUTPUT_TOKENS, min(warn_tokens_int, max_tokens_int))
107
+ return warn_tokens_int, max_tokens_int
108
+
109
+
110
+ def _evaluate_mcp_output_size(
111
+ result_text: Optional[str],
112
+ server_name: str,
113
+ tool_name: str,
114
+ ) -> tuple[Optional[str], Optional[str], int]:
115
+ """Return (warning, error, token_estimate) for an MCP result text."""
116
+ warn_tokens, max_tokens = _get_mcp_token_limits()
117
+ token_estimate = estimate_tokens(result_text or "")
118
+
119
+ if token_estimate > max_tokens:
120
+ error_text = (
121
+ f"MCP response from {server_name}:{tool_name} is ~{token_estimate:,} tokens, "
122
+ f"which exceeds the configured limit of {max_tokens}. "
123
+ "Refine the request (pagination/filtering) or raise RIPPERDOC_MCP_MAX_OUTPUT_TOKENS."
81
124
  )
82
- return f"[Audio content {mime or ''}]".strip()
83
- return str(block)
84
-
85
-
86
- def _render_content_blocks(blocks: List[Any]) -> str:
87
- if not blocks:
88
- return ""
89
- parts = [_content_block_to_text(block) for block in blocks]
90
- return "\n".join([p for p in parts if p])
91
-
92
-
93
- def _normalize_content_block(block: Any) -> Any:
94
- """Convert MCP content blocks to JSON-serializable structures."""
95
- if isinstance(block, dict):
96
- return block
97
- result: Dict[str, Any] = {}
98
- for attr in (
99
- "type",
100
- "text",
101
- "mimeType",
102
- "data",
103
- "name",
104
- "uri",
105
- "description",
106
- "resource",
107
- "blob",
108
- ):
109
- if hasattr(block, attr):
110
- result[attr] = getattr(block, attr)
111
- if result:
112
- return result
113
- return str(block)
114
-
115
-
116
- def _normalize_content_blocks(blocks: Optional[List[Any]]) -> Optional[List[Any]]:
117
- if not blocks:
118
- return None
119
- return [_normalize_content_block(block) for block in blocks]
125
+ return None, error_text, token_estimate
126
+
127
+ warning_text = None
128
+ if result_text and token_estimate >= warn_tokens:
129
+ line_count = result_text.count("\n") + 1
130
+ warning_text = (
131
+ f"WARNING: Large MCP response (~{token_estimate:,} tokens, {line_count:,} lines). "
132
+ "This can fill the context quickly; consider pagination or filters."
133
+ )
134
+ return warning_text, None, token_estimate
120
135
 
121
136
 
122
137
  class ListMcpServersInput(BaseModel):
@@ -131,7 +146,7 @@ class ListMcpServersOutput(BaseModel):
131
146
  servers: List[dict]
132
147
 
133
148
 
134
- class ListMcpServersTool(Tool[ListMcpServersInput, ListMcpServersOutput]):
149
+ class ListMcpServersTool(BaseMcpTool, Tool[ListMcpServersInput, ListMcpServersOutput]):
135
150
  """List configured MCP servers and their tools."""
136
151
 
137
152
  @property
@@ -145,19 +160,10 @@ class ListMcpServersTool(Tool[ListMcpServersInput, ListMcpServersOutput]):
145
160
  def input_schema(self) -> type[ListMcpServersInput]:
146
161
  return ListMcpServersInput
147
162
 
148
- async def prompt(self, safe_mode: bool = False) -> str:
163
+ async def prompt(self, _safe_mode: bool = False) -> str:
149
164
  servers = await load_mcp_servers_async()
150
165
  return format_mcp_instructions(servers)
151
166
 
152
- def is_read_only(self) -> bool:
153
- return True
154
-
155
- def is_concurrency_safe(self) -> bool:
156
- return True
157
-
158
- def needs_permissions(self, input_data: Optional[ListMcpServersInput] = None) -> bool:
159
- return False
160
-
161
167
  def render_result_for_assistant(self, output: ListMcpServersOutput) -> str:
162
168
  if not output.servers:
163
169
  return "No MCP servers configured."
@@ -171,14 +177,14 @@ class ListMcpServersTool(Tool[ListMcpServersInput, ListMcpServersOutput]):
171
177
  return "\n".join(lines)
172
178
 
173
179
  def render_tool_use_message(
174
- self, input_data: ListMcpServersInput, verbose: bool = False
180
+ self, input_data: ListMcpServersInput, _verbose: bool = False
175
181
  ) -> str:
176
182
  return f"List MCP servers{f' for {input_data.server}' if input_data.server else ''}"
177
183
 
178
184
  async def call(
179
185
  self,
180
186
  input_data: ListMcpServersInput,
181
- context: ToolUseContext,
187
+ _context: ToolUseContext,
182
188
  ) -> AsyncGenerator[ToolOutput, None]:
183
189
  runtime = await ensure_mcp_runtime()
184
190
  servers: List[McpServerInfo] = runtime.servers
@@ -217,7 +223,7 @@ class ListMcpResourcesOutput(BaseModel):
217
223
  resources: List[dict]
218
224
 
219
225
 
220
- class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
226
+ class ListMcpResourcesTool(BaseMcpTool, Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
221
227
  """List resources exposed by MCP servers."""
222
228
 
223
229
  @property
@@ -237,7 +243,7 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
237
243
  def input_schema(self) -> type[ListMcpResourcesInput]:
238
244
  return ListMcpResourcesInput
239
245
 
240
- async def prompt(self, safe_mode: bool = False) -> str:
246
+ async def prompt(self, _safe_mode: bool = False) -> str:
241
247
  return (
242
248
  "List available resources from configured MCP servers.\n"
243
249
  "Each returned resource will include all standard MCP resource fields plus a 'server' field\n"
@@ -247,24 +253,11 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
247
253
  " resources from all servers will be returned."
248
254
  )
249
255
 
250
- def is_read_only(self) -> bool:
251
- return True
252
-
253
- def is_concurrency_safe(self) -> bool:
254
- return True
255
-
256
- def needs_permissions(self, input_data: Optional[ListMcpResourcesInput] = None) -> bool:
257
- return False
258
-
259
256
  async def validate_input(
260
- self, input_data: ListMcpResourcesInput, context: Optional[ToolUseContext] = None
257
+ self, input_data: ListMcpResourcesInput, _context: Optional[ToolUseContext] = None
261
258
  ) -> ValidationResult:
262
- runtime = await ensure_mcp_runtime()
263
- server_names = {s.name for s in runtime.servers}
264
- if input_data.server and input_data.server not in server_names:
265
- return ValidationResult(
266
- result=False, message=f"Unknown MCP server '{input_data.server}'."
267
- )
259
+ if input_data.server:
260
+ return await self.validate_server_exists(input_data.server)
268
261
  return ValidationResult(result=True)
269
262
 
270
263
  def render_result_for_assistant(self, output: ListMcpResourcesOutput) -> str:
@@ -272,19 +265,22 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
272
265
  return "No MCP resources found."
273
266
  try:
274
267
  return json.dumps(output.resources, indent=2, ensure_ascii=False)
275
- except Exception:
276
- 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
+ )
277
273
  return str(output.resources)
278
274
 
279
275
  def render_tool_use_message(
280
- self, input_data: ListMcpResourcesInput, verbose: bool = False
276
+ self, input_data: ListMcpResourcesInput, _verbose: bool = False
281
277
  ) -> str:
282
278
  return f"List MCP resources{f' for {input_data.server}' if input_data.server else ''}"
283
279
 
284
280
  async def call(
285
281
  self,
286
282
  input_data: ListMcpResourcesInput,
287
- context: ToolUseContext,
283
+ _context: ToolUseContext,
288
284
  ) -> AsyncGenerator[ToolOutput, None]:
289
285
  runtime = await ensure_mcp_runtime()
290
286
  servers = runtime.servers
@@ -314,10 +310,12 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
314
310
  )
315
311
  for res in response.resources
316
312
  ]
317
- except Exception as exc: # pragma: no cover - runtime errors
318
- logger.exception(
319
- "Failed to fetch resources from MCP server",
320
- 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},
321
319
  )
322
320
  fetched = []
323
321
 
@@ -370,21 +368,12 @@ class ReadMcpResourceOutput(BaseModel):
370
368
  uri: str
371
369
  content: Optional[str] = None
372
370
  contents: List[ResourceContentPart] = Field(default_factory=list)
373
-
374
-
375
- class McpToolCallOutput(BaseModel):
376
- """Standardized output for MCP tool calls."""
377
-
378
- server: str
379
- tool: str
380
- content: Optional[str] = None
381
- text: Optional[str] = None
382
- content_blocks: Optional[List[Any]] = None
383
- structured_content: Optional[dict] = None
371
+ token_estimate: Optional[int] = None
372
+ warning: Optional[str] = None
384
373
  is_error: bool = False
385
374
 
386
375
 
387
- class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
376
+ class ReadMcpResourceTool(BaseMcpTool, Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
388
377
  """Read a resource defined in MCP configuration."""
389
378
 
390
379
  @property
@@ -405,7 +394,7 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
405
394
  def input_schema(self) -> type[ReadMcpResourceInput]:
406
395
  return ReadMcpResourceInput
407
396
 
408
- async def prompt(self, safe_mode: bool = False) -> str:
397
+ async def prompt(self, _safe_mode: bool = False) -> str:
409
398
  return (
410
399
  "Reads a specific resource from an MCP server, identified by server name and resource URI.\n\n"
411
400
  "Parameters:\n"
@@ -414,24 +403,16 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
414
403
  "- save_blobs (optional): If true, write binary content to a temporary file and include the path"
415
404
  )
416
405
 
417
- def is_read_only(self) -> bool:
418
- return True
419
-
420
- def is_concurrency_safe(self) -> bool:
421
- return True
422
-
423
- def needs_permissions(self, input_data: Optional[ReadMcpResourceInput] = None) -> bool:
424
- return False
425
-
426
406
  async def validate_input(
427
- self, input_data: ReadMcpResourceInput, context: Optional[ToolUseContext] = None
407
+ self, input_data: ReadMcpResourceInput, _context: Optional[ToolUseContext] = None
428
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
429
415
  runtime = await ensure_mcp_runtime()
430
- server_names = {s.name for s in runtime.servers}
431
- if input_data.server not in server_names:
432
- return ValidationResult(
433
- result=False, message=f"Unknown MCP server '{input_data.server}'."
434
- )
435
416
  resource = find_mcp_resource(runtime.servers, input_data.server, input_data.uri)
436
417
  if not resource:
437
418
  return ValidationResult(
@@ -455,14 +436,14 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
455
436
  return output.content
456
437
 
457
438
  def render_tool_use_message(
458
- self, input_data: ReadMcpResourceInput, verbose: bool = False
439
+ self, input_data: ReadMcpResourceInput, _verbose: bool = False
459
440
  ) -> str:
460
441
  return f"Read MCP resource {input_data.uri} from {input_data.server}"
461
442
 
462
443
  async def call(
463
444
  self,
464
445
  input_data: ReadMcpResourceInput,
465
- context: ToolUseContext,
446
+ _context: ToolUseContext,
466
447
  ) -> AsyncGenerator[ToolOutput, None]:
467
448
  runtime = await ensure_mcp_runtime()
468
449
  session = runtime.sessions.get(input_data.server) if runtime else None
@@ -498,9 +479,10 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
498
479
  base64_data = blob_data
499
480
  try:
500
481
  raw_bytes = base64.b64decode(blob_data)
501
- except Exception:
502
- logger.exception(
503
- "[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,
504
486
  extra={"server": input_data.server, "uri": input_data.uri},
505
487
  )
506
488
  raw_bytes = None
@@ -529,10 +511,12 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
529
511
  )
530
512
  text_parts = [p.text for p in parts if p.text]
531
513
  content_text = "\n".join([p for p in text_parts if p]) or None
532
- except Exception as exc: # pragma: no cover - runtime errors
533
- logger.exception(
534
- "Error reading MCP resource",
535
- 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},
536
520
  )
537
521
  content_text = f"Error reading MCP resource: {exc}"
538
522
  else:
@@ -552,280 +536,55 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
552
536
  read_result: Any = ReadMcpResourceOutput(
553
537
  server=input_data.server, uri=input_data.uri, content=content_text, contents=parts
554
538
  )
555
- yield ToolResult(
556
- data=read_result,
557
- result_for_assistant=self.render_result_for_assistant(read_result), # type: ignore[arg-type]
539
+ assistant_text = self.render_result_for_assistant(read_result) # type: ignore[arg-type]
540
+ warning_text, error_text, token_estimate = _evaluate_mcp_output_size(
541
+ assistant_text, input_data.server, f"resource:{input_data.uri}"
558
542
  )
559
543
 
560
-
561
- def _sanitize_name(name: str) -> str:
562
- return "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in name)
563
-
564
-
565
- def _create_dynamic_input_model(schema: Optional[Dict[str, Any]]) -> type[BaseModel]:
566
- raw_schema = schema if isinstance(schema, dict) else {"type": "object"}
567
- raw_schema = raw_schema or {"type": "object"}
568
-
569
- class DynamicMcpInput(BaseModel):
570
- model_config = ConfigDict(extra="allow")
571
-
572
- @classmethod
573
- def model_json_schema(cls, *args: Any, **kwargs: Any) -> Dict[str, Any]:
574
- return raw_schema
575
-
576
- DynamicMcpInput.__name__ = (
577
- f"McpInput_{abs(hash(json.dumps(raw_schema, sort_keys=True, default=str))) % 10_000_000}"
578
- )
579
- return DynamicMcpInput
580
-
581
-
582
- def _annotation_flag(tool_info: Any, key: str) -> bool:
583
- annotations = getattr(tool_info, "annotations", {}) or {}
584
- if hasattr(annotations, "get"):
585
- try:
586
- return bool(annotations.get(key, False))
587
- except Exception:
588
- logger.debug("[mcp_tools] Failed to read annotation flag", exc_info=True)
589
- return False
590
- return False
591
-
592
-
593
- def _render_mcp_tool_result_for_assistant(output: McpToolCallOutput) -> str:
594
- if output.text or output.content:
595
- return output.text or output.content or ""
596
- if output.is_error:
597
- return "MCP tool call failed."
598
- return f"MCP tool '{output.tool}' returned no content."
599
-
600
-
601
- class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
602
- """Runtime wrapper for an MCP tool exposed by a connected server."""
603
-
604
- is_mcp = True
605
-
606
- def __init__(self, server_name: str, tool_info: Any, project_path: Path) -> None:
607
- self.server_name = server_name
608
- self.tool_info = tool_info
609
- self.project_path = project_path
610
- self._input_model = _create_dynamic_input_model(getattr(tool_info, "input_schema", None))
611
- self._name = f"mcp__{_sanitize_name(server_name)}__{_sanitize_name(tool_info.name)}"
612
- self._user_facing = (
613
- f"{server_name} - {getattr(tool_info, 'description', '') or tool_info.name} (MCP)"
614
- )
615
-
616
- @property
617
- def name(self) -> str:
618
- return self._name
619
-
620
- async def description(self) -> str:
621
- desc = getattr(self.tool_info, "description", "") or ""
622
- schema = getattr(self.tool_info, "input_schema", None)
623
- schema_snippet = json.dumps(schema, indent=2) if schema else ""
624
- if schema_snippet:
625
- schema_snippet = (
626
- schema_snippet if len(schema_snippet) < 800 else schema_snippet[:800] + "..."
627
- )
628
- return f"{desc}\n\n[MCP tool]\nServer: {self.server_name}\nTool: {self.tool_info.name}\nInput schema:\n{schema_snippet}"
629
- return f"{desc}\n\n[MCP tool]\nServer: {self.server_name}\nTool: {self.tool_info.name}"
630
-
631
- @property
632
- def input_schema(self) -> type[BaseModel]:
633
- return self._input_model
634
-
635
- async def prompt(self, safe_mode: bool = False) -> str:
636
- return await self.description()
637
-
638
- def is_read_only(self) -> bool:
639
- return _annotation_flag(self.tool_info, "readOnlyHint")
640
-
641
- def is_concurrency_safe(self) -> bool:
642
- return self.is_read_only()
643
-
644
- def is_destructive(self) -> bool:
645
- return _annotation_flag(self.tool_info, "destructiveHint")
646
-
647
- def is_open_world(self) -> bool:
648
- return _annotation_flag(self.tool_info, "openWorldHint")
649
-
650
- def defer_loading(self) -> bool:
651
- """Avoid loading all MCP tools into the initial context."""
652
- return True
653
-
654
- def needs_permissions(self, input_data: Optional[BaseModel] = None) -> bool:
655
- return not self.is_read_only()
656
-
657
- def render_result_for_assistant(self, output: McpToolCallOutput) -> str:
658
- return _render_mcp_tool_result_for_assistant(output)
659
-
660
- def render_tool_use_message(self, input_data: BaseModel, verbose: bool = False) -> str:
661
- args = input_data.model_dump(exclude_none=True)
662
- arg_preview = json.dumps(args) if verbose and args else ""
663
- suffix = f" with args {arg_preview}" if arg_preview else ""
664
- return f"MCP {self.server_name}:{self.tool_info.name}{suffix}"
665
-
666
- def user_facing_name(self) -> str:
667
- return self._user_facing
668
-
669
- async def call(
670
- self,
671
- input_data: BaseModel,
672
- context: ToolUseContext,
673
- ) -> AsyncGenerator[ToolOutput, None]:
674
- runtime = await ensure_mcp_runtime(self.project_path)
675
- session = runtime.sessions.get(self.server_name) if runtime else None
676
- if not session:
677
- result = McpToolCallOutput(
678
- server=self.server_name,
679
- tool=self.tool_info.name,
544
+ if error_text:
545
+ limited_result = ReadMcpResourceOutput(
546
+ server=input_data.server,
547
+ uri=input_data.uri,
680
548
  content=None,
681
- text=None,
682
- content_blocks=None,
683
- structured_content=None,
549
+ contents=[],
550
+ token_estimate=token_estimate,
551
+ warning=None,
684
552
  is_error=True,
685
553
  )
686
- yield ToolResult(
687
- data=result,
688
- result_for_assistant=f"MCP server '{self.server_name}' is not connected.",
689
- )
554
+ yield ToolResult(data=limited_result, result_for_assistant=error_text)
690
555
  return
691
556
 
692
- try:
693
- args = input_data.model_dump(exclude_none=True)
694
- call_result = await session.call_tool(
695
- self.tool_info.name,
696
- args or {},
697
- )
698
- raw_blocks = getattr(call_result, "content", None)
699
- content_blocks = _normalize_content_blocks(raw_blocks)
700
- content_text = _render_content_blocks(content_blocks) if content_blocks else None
701
- structured = (
702
- call_result.structuredContent if hasattr(call_result, "structuredContent") else None
703
- )
704
- assistant_text = content_text
705
- if structured:
706
- assistant_text = (assistant_text + "\n" if assistant_text else "") + json.dumps(
707
- structured, indent=2
708
- )
709
- output = McpToolCallOutput(
710
- server=self.server_name,
711
- tool=self.tool_info.name,
712
- content=assistant_text or None,
713
- text=content_text,
714
- content_blocks=content_blocks,
715
- structured_content=structured,
716
- is_error=getattr(call_result, "isError", False),
717
- )
718
- yield ToolResult(
719
- data=output,
720
- result_for_assistant=self.render_result_for_assistant(output),
721
- )
722
- except Exception as exc: # pragma: no cover - runtime errors
723
- output = McpToolCallOutput(
724
- server=self.server_name,
725
- tool=self.tool_info.name,
726
- content=None,
727
- text=None,
728
- content_blocks=None,
729
- structured_content=None,
730
- is_error=True,
731
- )
732
- logger.exception(
733
- "Error calling MCP tool",
734
- extra={
735
- "server": self.server_name,
736
- "tool": self.tool_info.name,
737
- "error": str(exc),
738
- },
739
- )
740
- yield ToolResult(
741
- data=output,
742
- result_for_assistant=f"Error calling MCP tool '{self.tool_info.name}' on '{self.server_name}': {exc}",
743
- )
744
-
745
-
746
- def _build_dynamic_mcp_tools(runtime: Optional[Any]) -> List[DynamicMcpTool]:
747
- if not runtime or not getattr(runtime, "servers", None):
748
- return []
749
- tools: List[DynamicMcpTool] = []
750
- for server in runtime.servers:
751
- if getattr(server, "status", "") != "connected":
752
- continue
753
- if not getattr(server, "tools", None):
754
- continue
755
- for tool in server.tools:
756
- tools.append(
757
- DynamicMcpTool(server.name, tool, getattr(runtime, "project_path", Path.cwd()))
758
- )
759
- return tools
760
-
761
-
762
- def load_dynamic_mcp_tools_sync(project_path: Optional[Path] = None) -> List[DynamicMcpTool]:
763
- """Best-effort synchronous loader for MCP tools."""
764
- runtime = get_existing_mcp_runtime()
765
- if runtime and not getattr(runtime, "_closed", False):
766
- return _build_dynamic_mcp_tools(runtime)
767
-
768
- try:
769
- loop = asyncio.get_running_loop()
770
- if loop.is_running():
771
- return []
772
- except RuntimeError:
773
- pass
774
-
775
- async def _load_and_cleanup() -> List[DynamicMcpTool]:
776
- runtime = await ensure_mcp_runtime(project_path)
777
- try:
778
- return _build_dynamic_mcp_tools(runtime)
779
- finally:
780
- # Close the runtime inside the same event loop to avoid asyncgen
781
- # shutdown errors when asyncio.run tears down the loop.
782
- await shutdown_mcp_runtime()
783
-
784
- try:
785
- return asyncio.run(_load_and_cleanup())
786
- except Exception as exc: # pragma: no cover - SDK/runtime failures
787
- logger.exception(
788
- "Failed to initialize MCP runtime for dynamic tools (sync)",
789
- extra={"error": str(exc)},
557
+ annotated_result = read_result.model_copy(
558
+ update={"token_estimate": token_estimate, "warning": warning_text}
790
559
  )
791
- return []
792
560
 
561
+ final_text = assistant_text or ""
562
+ if not final_text and warning_text:
563
+ final_text = warning_text
793
564
 
794
- async def load_dynamic_mcp_tools_async(project_path: Optional[Path] = None) -> List[DynamicMcpTool]:
795
- """Async loader for MCP tools when already in an event loop."""
796
- try:
797
- runtime = await ensure_mcp_runtime(project_path)
798
- except Exception as exc: # pragma: no cover - SDK/runtime failures
799
- logger.exception(
800
- "Failed to initialize MCP runtime for dynamic tools (async)",
801
- extra={"error": str(exc)},
565
+ yield ToolResult(
566
+ data=annotated_result,
567
+ result_for_assistant=final_text, # type: ignore[arg-type]
802
568
  )
803
- return []
804
- return _build_dynamic_mcp_tools(runtime)
805
-
806
569
 
807
- def merge_tools_with_dynamic(base_tools: List[Any], dynamic_tools: List[Any]) -> List[Any]:
808
- """Merge dynamic MCP tools into the existing tool list and rebuild the Task tool."""
809
- from ripperdoc.tools.task_tool import TaskTool # Local import to avoid cycles
810
-
811
- base_without_task = [tool for tool in base_tools if getattr(tool, "name", None) != "Task"]
812
- existing_names = {getattr(tool, "name", None) for tool in base_without_task}
813
-
814
- for tool in dynamic_tools:
815
- if getattr(tool, "name", None) in existing_names:
816
- continue
817
- base_without_task.append(tool)
818
- existing_names.add(getattr(tool, "name", None))
819
-
820
- task_tool = TaskTool(lambda: base_without_task)
821
- return base_without_task + [task_tool]
822
570
 
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
+ )
823
581
 
824
582
  __all__ = [
825
583
  "ListMcpServersTool",
826
584
  "ListMcpResourcesTool",
827
585
  "ReadMcpResourceTool",
828
586
  "DynamicMcpTool",
587
+ "McpToolCallOutput",
829
588
  "load_dynamic_mcp_tools_async",
830
589
  "load_dynamic_mcp_tools_sync",
831
590
  "merge_tools_with_dynamic",