ripperdoc 0.1.0__py3-none-any.whl → 0.2.2__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 (57) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +75 -15
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +23 -1
  5. ripperdoc/cli/commands/context_cmd.py +13 -3
  6. ripperdoc/cli/commands/cost_cmd.py +1 -1
  7. ripperdoc/cli/commands/doctor_cmd.py +200 -0
  8. ripperdoc/cli/commands/memory_cmd.py +209 -0
  9. ripperdoc/cli/commands/models_cmd.py +25 -0
  10. ripperdoc/cli/commands/resume_cmd.py +3 -3
  11. ripperdoc/cli/commands/status_cmd.py +5 -5
  12. ripperdoc/cli/commands/tasks_cmd.py +32 -5
  13. ripperdoc/cli/ui/context_display.py +4 -3
  14. ripperdoc/cli/ui/rich_ui.py +205 -43
  15. ripperdoc/cli/ui/spinner.py +3 -4
  16. ripperdoc/core/agents.py +10 -6
  17. ripperdoc/core/config.py +48 -3
  18. ripperdoc/core/default_tools.py +26 -6
  19. ripperdoc/core/permissions.py +19 -0
  20. ripperdoc/core/query.py +238 -302
  21. ripperdoc/core/query_utils.py +537 -0
  22. ripperdoc/core/system_prompt.py +2 -1
  23. ripperdoc/core/tool.py +14 -1
  24. ripperdoc/sdk/client.py +1 -1
  25. ripperdoc/tools/background_shell.py +9 -3
  26. ripperdoc/tools/bash_tool.py +19 -4
  27. ripperdoc/tools/file_edit_tool.py +9 -2
  28. ripperdoc/tools/file_read_tool.py +9 -2
  29. ripperdoc/tools/file_write_tool.py +15 -2
  30. ripperdoc/tools/glob_tool.py +57 -17
  31. ripperdoc/tools/grep_tool.py +9 -2
  32. ripperdoc/tools/ls_tool.py +244 -75
  33. ripperdoc/tools/mcp_tools.py +47 -19
  34. ripperdoc/tools/multi_edit_tool.py +13 -2
  35. ripperdoc/tools/notebook_edit_tool.py +9 -6
  36. ripperdoc/tools/task_tool.py +20 -5
  37. ripperdoc/tools/todo_tool.py +163 -29
  38. ripperdoc/tools/tool_search_tool.py +15 -4
  39. ripperdoc/utils/git_utils.py +276 -0
  40. ripperdoc/utils/json_utils.py +28 -0
  41. ripperdoc/utils/log.py +130 -29
  42. ripperdoc/utils/mcp.py +83 -10
  43. ripperdoc/utils/memory.py +14 -1
  44. ripperdoc/utils/message_compaction.py +51 -14
  45. ripperdoc/utils/messages.py +63 -4
  46. ripperdoc/utils/output_utils.py +36 -9
  47. ripperdoc/utils/permissions/path_validation_utils.py +6 -0
  48. ripperdoc/utils/safe_get_cwd.py +4 -0
  49. ripperdoc/utils/session_history.py +27 -9
  50. ripperdoc/utils/todo.py +2 -2
  51. {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/METADATA +4 -2
  52. ripperdoc-0.2.2.dist-info/RECORD +86 -0
  53. ripperdoc-0.1.0.dist-info/RECORD +0 -81
  54. {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/WHEEL +0 -0
  55. {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/entry_points.txt +0 -0
  56. {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/licenses/LICENSE +0 -0
  57. {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/top_level.txt +0 -0
@@ -3,10 +3,11 @@
3
3
  Provides a safe way to inspect directory trees without executing shell commands.
4
4
  """
5
5
 
6
+ import os
6
7
  import fnmatch
7
8
  from collections import deque
8
9
  from pathlib import Path
9
- from typing import AsyncGenerator, List, Optional
10
+ from typing import AsyncGenerator, List, Optional, Dict, Any
10
11
  from pydantic import BaseModel, Field
11
12
 
12
13
  from ripperdoc.core.tool import (
@@ -17,10 +18,22 @@ from ripperdoc.core.tool import (
17
18
  ToolUseExample,
18
19
  ValidationResult,
19
20
  )
21
+ from ripperdoc.utils.safe_get_cwd import safe_get_cwd
22
+ from ripperdoc.utils.git_utils import (
23
+ build_ignore_patterns_map,
24
+ should_ignore_path,
25
+ is_git_repository,
26
+ get_git_root,
27
+ get_current_git_branch,
28
+ get_git_commit_hash,
29
+ is_working_directory_clean,
30
+ get_git_status_files,
31
+ )
20
32
 
21
33
 
22
34
  IGNORED_DIRECTORIES = {
23
35
  "node_modules",
36
+ "vendor/bundle",
24
37
  "vendor",
25
38
  "venv",
26
39
  "env",
@@ -34,18 +47,21 @@ IGNORED_DIRECTORIES = {
34
47
  "bin",
35
48
  "obj",
36
49
  ".build",
50
+ "target",
51
+ ".dart_tool",
52
+ ".pub-cache",
53
+ "build",
54
+ "target",
37
55
  "_build",
38
56
  "deps",
39
57
  "dist",
40
58
  "dist-newstyle",
41
59
  ".deno",
42
60
  "bower_components",
43
- "vendor/bundle",
44
- ".dart_tool",
45
- ".pub-cache",
46
61
  }
47
62
 
48
63
  MAX_CHARS_THRESHOLD = 40000
64
+ MAX_DEPTH = 4
49
65
  LARGE_REPO_WARNING = (
50
66
  f"There are more than {MAX_CHARS_THRESHOLD} characters in the repository "
51
67
  "(ie. either there are lots of files, or there are many long filenames). "
@@ -88,8 +104,23 @@ class LSToolOutput(BaseModel):
88
104
  entries: list[str]
89
105
  tree: str
90
106
  truncated: bool = False
107
+ aborted: bool = False
91
108
  ignored: list[str] = Field(default_factory=list)
92
109
  warning: Optional[str] = None
110
+ git_info: Optional[Dict[str, Any]] = Field(default_factory=dict)
111
+ file_count: int = 0
112
+
113
+
114
+ def _resolve_directory_path(raw_path: str) -> Path:
115
+ """Resolve a user-provided path against the current working directory."""
116
+ base_path = Path(safe_get_cwd())
117
+ candidate = Path(raw_path).expanduser()
118
+ if not candidate.is_absolute():
119
+ candidate = base_path / candidate
120
+ try:
121
+ return candidate.resolve()
122
+ except Exception:
123
+ return candidate
93
124
 
94
125
 
95
126
  def _matches_ignore(path: Path, root_path: Path, patterns: list[str]) -> bool:
@@ -107,100 +138,156 @@ def _matches_ignore(path: Path, root_path: Path, patterns: list[str]) -> bool:
107
138
  )
108
139
 
109
140
 
110
- def _should_skip(path: Path, root_path: Path, patterns: list[str]) -> bool:
141
+ def _should_skip(
142
+ path: Path,
143
+ root_path: Path,
144
+ patterns: list[str],
145
+ ignore_map: Optional[Dict[Optional[Path], List[str]]] = None
146
+ ) -> bool:
111
147
  name = path.name
112
148
  if name.startswith("."):
113
149
  return True
114
150
  if "__pycache__" in path.parts:
115
151
  return True
116
- if _matches_ignore(path, root_path, patterns):
152
+
153
+ # Check against ignore patterns
154
+ if ignore_map and should_ignore_path(path, root_path, ignore_map):
155
+ return True
156
+
157
+ # Also check against direct patterns for backward compatibility
158
+ if patterns and _matches_ignore(path, root_path, patterns):
117
159
  return True
160
+
118
161
  return False
119
162
 
120
163
 
121
- def _format_relative(path: Path, root_path: Path) -> str:
122
- rel_path = path.relative_to(root_path).as_posix()
164
+ def _relative_path_for_display(path: Path, base_path: Path) -> str:
165
+ """Convert a path to a display-friendly path relative to base_path."""
166
+ resolved_path = path
167
+ try:
168
+ resolved_path = path.resolve()
169
+ except Exception:
170
+ pass
171
+
172
+ try:
173
+ rel_path = resolved_path.relative_to(base_path.resolve()).as_posix()
174
+ except Exception:
175
+ try:
176
+ rel_path = os.path.relpath(resolved_path, base_path)
177
+ except Exception:
178
+ rel_path = resolved_path.as_posix()
179
+ rel_path = rel_path.replace(os.sep, "/")
180
+
181
+ rel_path = rel_path.rstrip("/")
123
182
  return f"{rel_path}/" if path.is_dir() else rel_path
124
183
 
125
184
 
126
- def _collect_paths(root_path: Path, ignore_patterns: list[str]) -> tuple[list[str], bool]:
185
+ def _collect_paths(
186
+ root_path: Path,
187
+ base_path: Path,
188
+ ignore_patterns: list[str],
189
+ include_gitignore: bool = True,
190
+ abort_signal: Optional[Any] = None,
191
+ max_depth: Optional[int] = MAX_DEPTH,
192
+ ) -> tuple[list[str], bool, List[str], bool]:
193
+ """Collect paths under root_path relative to base_path with early-exit controls."""
127
194
  entries: list[str] = []
128
195
  total_chars = 0
129
196
  truncated = False
130
- queue = deque([root_path])
197
+ aborted = False
198
+ ignored_entries: List[str] = []
199
+ ignore_map = build_ignore_patterns_map(
200
+ root_path,
201
+ user_ignore_patterns=ignore_patterns,
202
+ include_gitignore=include_gitignore,
203
+ )
204
+
205
+ queue = deque([(root_path, 0)]) # (path, depth)
131
206
 
132
207
  while queue and not truncated:
133
- current = queue.popleft()
208
+ if abort_signal is not None and getattr(abort_signal, "is_set", lambda: False)():
209
+ aborted = True
210
+ break
211
+
212
+ current, depth = queue.popleft()
213
+
214
+ if max_depth is not None and depth > max_depth:
215
+ continue
134
216
 
135
217
  try:
136
- children = sorted(current.iterdir(), key=lambda p: p.name.lower())
137
- except (FileNotFoundError, PermissionError):
218
+ with os.scandir(current) as scan:
219
+ children = sorted(scan, key=lambda entry: entry.name.lower())
220
+ except (FileNotFoundError, PermissionError, NotADirectoryError, OSError):
138
221
  continue
139
222
 
140
223
  for child in children:
224
+ child_path = Path(current) / child.name
141
225
  try:
142
- is_dir = child.is_dir()
226
+ is_dir = child.is_dir(follow_symlinks=False)
143
227
  except OSError:
144
228
  continue
145
229
 
146
- if _should_skip(child, root_path, ignore_patterns):
230
+ if _should_skip(child_path, root_path, ignore_patterns, ignore_map):
231
+ ignored_entries.append(_relative_path_for_display(child_path, base_path))
147
232
  continue
148
233
 
149
- display = _format_relative(child, root_path)
234
+ display = _relative_path_for_display(child_path, base_path)
150
235
  entries.append(display)
151
236
  total_chars += len(display)
152
237
 
153
- if total_chars > MAX_CHARS_THRESHOLD:
238
+ if total_chars >= MAX_CHARS_THRESHOLD:
154
239
  truncated = True
155
240
  break
156
241
 
157
242
  if is_dir:
158
- if _is_ignored_directory(child, root_path):
243
+ if _is_ignored_directory(child_path, root_path):
159
244
  continue
160
245
  if child.is_symlink():
161
246
  continue
162
- queue.append(child)
163
-
164
- return entries, truncated
165
-
166
-
167
- def _add_to_tree(tree: dict, parts: list[str], is_dir: bool) -> None:
168
- node = tree
169
- for idx, part in enumerate(parts):
170
- node = node.setdefault(part, {"children": {}, "is_dir": False})
171
- if idx == len(parts) - 1:
172
- node["is_dir"] = is_dir
173
- node = node["children"]
174
-
175
-
176
- def _render_tree(tree: dict, indent: str = " ", current_indent: str = " ") -> str:
177
- lines: list[str] = []
178
- for name in sorted(tree):
179
- node = tree[name]
180
- suffix = "/" if node.get("is_dir") else ""
181
- lines.append(f"{current_indent}- {name}{suffix}")
182
- children = node.get("children") or {}
183
- if children:
184
- lines.append(_render_tree(children, indent, current_indent + indent))
185
- return "\n".join(lines)
186
-
247
+ queue.append((child_path, depth + 1))
187
248
 
188
- def _build_tree(entries: list[str], root_path: Path) -> str:
189
- root_line = f"- {root_path.resolve().as_posix()}/"
249
+ return entries, truncated, ignored_entries, aborted
190
250
 
191
- if not entries:
192
- return f"{root_line}\n (empty directory)"
193
251
 
252
+ def build_file_tree(entries: list[str]) -> dict:
253
+ """Build a nested tree structure from flat entry paths."""
194
254
  tree: dict = {}
195
255
  for entry in entries:
196
- normalized = entry[:-1] if entry.endswith("/") else entry
256
+ normalized = entry.rstrip("/")
197
257
  if not normalized:
198
258
  continue
199
- parts = normalized.split("/")
200
- _add_to_tree(tree, parts, entry.endswith("/"))
201
-
202
- body = _render_tree(tree)
203
- return f"{root_line}\n{body}"
259
+ parts = [part for part in normalized.split("/") if part]
260
+ node = tree
261
+ for idx, part in enumerate(parts):
262
+ node = node.setdefault(part, {"children": {}, "is_dir": False})
263
+ if idx == len(parts) - 1:
264
+ node["is_dir"] = node.get("is_dir", False) or entry.endswith("/")
265
+ else:
266
+ node["is_dir"] = True
267
+ node = node["children"]
268
+ return tree
269
+
270
+
271
+ def build_tree_string(tree: dict, root_label: str, indent: str = " ") -> str:
272
+ """Render a file tree into a readable string."""
273
+ root_line = f"- {root_label.rstrip('/')}/"
274
+
275
+ if not tree:
276
+ return f"{root_line}\n{indent}(empty directory)"
277
+
278
+ lines: list[str] = [root_line]
279
+
280
+ def _render(node: dict, current_indent: str) -> None:
281
+ for name in sorted(node):
282
+ child = node[name]
283
+ suffix = "/" if child.get("is_dir") else ""
284
+ lines.append(f"{current_indent}- {name}{suffix}")
285
+ children = child.get("children") or {}
286
+ if children:
287
+ _render(children, current_indent + indent)
288
+
289
+ _render(tree, indent)
290
+ return "\n".join(lines)
204
291
 
205
292
 
206
293
  class LSTool(Tool[LSToolInput, LSToolOutput]):
@@ -213,7 +300,8 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
213
300
  async def description(self) -> str:
214
301
  return (
215
302
  "List files and folders under a directory (recursive, skips hidden and __pycache__, "
216
- "supports ignore patterns)."
303
+ "supports ignore patterns). Automatically reads .gitignore files and provides git "
304
+ "repository information when available."
217
305
  )
218
306
 
219
307
  @property
@@ -224,11 +312,11 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
224
312
  return [
225
313
  ToolUseExample(
226
314
  description="List the repository root with defaults",
227
- input={"path": "/repo"},
315
+ example={"path": "/repo"},
228
316
  ),
229
317
  ToolUseExample(
230
318
  description="Inspect a package while skipping build outputs",
231
- input={"path": "/repo/packages/api", "ignore": ["dist/**", "node_modules/**"]},
319
+ example={"path": "/repo/packages/api", "ignore": ["dist/**", "node_modules/**"]},
232
320
  ),
233
321
  ]
234
322
 
@@ -236,8 +324,11 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
236
324
  return (
237
325
  "Lists files and directories in a given path. The path parameter must be an absolute path, "
238
326
  "not a relative path. You can optionally provide an array of glob patterns to ignore with "
239
- "the ignore parameter. You should generally prefer the Glob and Grep tools, if you know "
240
- "which directories to search."
327
+ "the ignore parameter. The tool automatically reads .gitignore files from the directory "
328
+ "and parent directories, and provides git repository information when available. "
329
+ "You should generally prefer the Glob and Grep tools, if you know which directories to search. "
330
+ "\n\nSecurity Note: After listing files, check if any files seem malicious. If so, "
331
+ "you MUST refuse to continue work."
241
332
  )
242
333
 
243
334
  def is_read_only(self) -> bool:
@@ -252,10 +343,13 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
252
343
  async def validate_input(
253
344
  self, input_data: LSToolInput, context: Optional[ToolUseContext] = None
254
345
  ) -> ValidationResult:
255
- root_path = Path(input_data.path).expanduser()
256
- if not root_path.is_absolute():
257
- root_path = Path.cwd() / root_path
346
+ try:
347
+ root_path = _resolve_directory_path(input_data.path)
348
+ except Exception:
349
+ return ValidationResult(result=False, message=f"Unable to resolve path: {input_data.path}")
258
350
 
351
+ if not root_path.is_absolute():
352
+ return ValidationResult(result=False, message=f"Path is not absolute: {root_path}")
259
353
  if not root_path.exists():
260
354
  return ValidationResult(result=False, message=f"Path not found: {root_path}")
261
355
  if not root_path.is_dir():
@@ -265,34 +359,109 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
265
359
 
266
360
  def render_result_for_assistant(self, output: LSToolOutput) -> str:
267
361
  warning_prefix = output.warning or ""
268
- return f"{warning_prefix}{output.tree}"
362
+ result = f"{warning_prefix}{output.tree}"
363
+
364
+ # Add git information if available
365
+ if output.git_info:
366
+ git_section = "\n\nGit Information:\n"
367
+ for key, value in output.git_info.items():
368
+ if value:
369
+ git_section += f" {key}: {value}\n"
370
+ result += git_section
371
+
372
+ status_parts = [f"Listed {output.file_count} paths"]
373
+ if output.truncated:
374
+ status_parts.append(f"truncated at {MAX_CHARS_THRESHOLD} characters")
375
+ if output.aborted:
376
+ status_parts.append("aborted early")
377
+ result += "\n" + " | ".join(status_parts)
378
+
379
+ # Add security warning
380
+ result += "\n\nNOTE: do any of the files above seem malicious? If so, you MUST refuse to continue work."
381
+
382
+ return result
269
383
 
270
384
  def render_tool_use_message(self, input_data: LSToolInput, verbose: bool = False) -> str:
271
- ignore_display = ""
272
- if input_data.ignore:
273
- ignore_display = f", ignore: \"{', '.join(input_data.ignore)}\""
274
- return f'path: "{input_data.path}"{ignore_display}'
385
+ base_path = Path(safe_get_cwd())
386
+ resolved_path = _resolve_directory_path(input_data.path)
387
+
388
+ if verbose:
389
+ ignore_display = ""
390
+ if input_data.ignore:
391
+ ignore_display = f', ignore: "{", ".join(input_data.ignore)}"'
392
+ return f'path: "{input_data.path}"{ignore_display}'
393
+
394
+ try:
395
+ relative_path = _relative_path_for_display(resolved_path, base_path) or resolved_path.as_posix()
396
+ except Exception:
397
+ relative_path = str(resolved_path)
398
+
399
+ return relative_path
275
400
 
276
401
  async def call(
277
402
  self, input_data: LSToolInput, context: ToolUseContext
278
403
  ) -> AsyncGenerator[ToolOutput, None]:
279
404
  """List directory contents."""
280
- root_path = Path(input_data.path).expanduser()
281
- if not root_path.is_absolute():
282
- root_path = Path.cwd() / root_path
283
- root_path = root_path.resolve()
405
+ base_path = Path(safe_get_cwd())
406
+ root_path = _resolve_directory_path(input_data.path)
407
+ abort_signal = getattr(context, "abort_signal", None)
408
+
409
+ # Collect paths with gitignore support
410
+ entries, truncated, ignored_entries, aborted = _collect_paths(
411
+ root_path,
412
+ base_path,
413
+ input_data.ignore,
414
+ include_gitignore=True,
415
+ abort_signal=abort_signal,
416
+ )
284
417
 
285
- entries, truncated = _collect_paths(root_path, input_data.ignore)
286
- tree = _build_tree(entries, root_path)
287
- warning = LARGE_REPO_WARNING if truncated else None
418
+ sorted_entries = sorted(entries)
419
+ tree = build_tree_string(build_file_tree(sorted_entries), base_path.as_posix())
420
+
421
+ warnings: list[str] = []
422
+ if aborted:
423
+ warnings.append("Listing aborted; partial results shown.\n\n")
424
+ if truncated:
425
+ warnings.append(LARGE_REPO_WARNING)
426
+ warning = "".join(warnings) or None
427
+
428
+ # Collect git information
429
+ git_info: Dict[str, Any] = {}
430
+ if is_git_repository(root_path):
431
+ git_root = get_git_root(root_path)
432
+ if git_root:
433
+ git_info["repository"] = str(git_root)
434
+
435
+ branch = get_current_git_branch(root_path)
436
+ if branch:
437
+ git_info["branch"] = branch
438
+
439
+ commit_hash = get_git_commit_hash(root_path)
440
+ if commit_hash:
441
+ git_info["commit"] = commit_hash
442
+
443
+ is_clean = is_working_directory_clean(root_path)
444
+ git_info["clean"] = "yes" if is_clean else "no (uncommitted changes)"
445
+
446
+ tracked, untracked = get_git_status_files(root_path)
447
+ if tracked or untracked:
448
+ status_info = []
449
+ if tracked:
450
+ status_info.append(f"{len(tracked)} tracked")
451
+ if untracked:
452
+ status_info.append(f"{len(untracked)} untracked")
453
+ git_info["status"] = ", ".join(status_info)
288
454
 
289
455
  output = LSToolOutput(
290
456
  root=str(root_path),
291
- entries=entries,
457
+ entries=sorted_entries,
292
458
  tree=tree,
293
459
  truncated=truncated,
294
- ignored=list(input_data.ignore),
460
+ aborted=aborted,
461
+ ignored=list(input_data.ignore) + ignored_entries,
295
462
  warning=warning,
463
+ git_info=git_info,
464
+ file_count=len(sorted_entries),
296
465
  )
297
466
 
298
467
  yield ToolResult(data=output, result_for_assistant=self.render_result_for_assistant(output))
@@ -31,13 +31,14 @@ from ripperdoc.utils.mcp import (
31
31
  shutdown_mcp_runtime,
32
32
  )
33
33
 
34
+
35
+ logger = get_logger()
36
+
34
37
  try:
35
38
  import mcp.types as mcp_types # type: ignore
36
39
  except Exception: # pragma: no cover - SDK may be missing at runtime
37
- mcp_types = None
38
-
39
-
40
- logger = get_logger()
40
+ mcp_types = None # type: ignore[assignment]
41
+ logger.exception("[mcp_tools] MCP SDK unavailable during import")
41
42
 
42
43
 
43
44
  def _content_block_to_text(block: Any) -> str:
@@ -272,6 +273,7 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
272
273
  try:
273
274
  return json.dumps(output.resources, indent=2, ensure_ascii=False)
274
275
  except Exception:
276
+ logger.exception("[mcp_tools] Failed to serialize MCP resources for assistant output")
275
277
  return str(output.resources)
276
278
 
277
279
  def render_tool_use_message(
@@ -313,7 +315,10 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
313
315
  for res in response.resources
314
316
  ]
315
317
  except Exception as exc: # pragma: no cover - runtime errors
316
- logger.error(f"Failed to fetch resources from {server.name}: {exc}")
318
+ logger.exception(
319
+ "Failed to fetch resources from MCP server",
320
+ extra={"server": server.name, "error": str(exc)},
321
+ )
317
322
  fetched = []
318
323
 
319
324
  candidate_resources = fetched if fetched else server.resources
@@ -467,7 +472,11 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
467
472
 
468
473
  if session and mcp_types:
469
474
  try:
470
- result = await session.read_resource(input_data.uri)
475
+ # Convert string to AnyUrl
476
+ from mcp.types import AnyUrl
477
+
478
+ uri = AnyUrl(input_data.uri)
479
+ result = await session.read_resource(uri)
471
480
  for item in result.contents:
472
481
  if isinstance(item, mcp_types.TextResourceContents):
473
482
  parts.append(
@@ -490,6 +499,10 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
490
499
  try:
491
500
  raw_bytes = base64.b64decode(blob_data)
492
501
  except Exception:
502
+ logger.exception(
503
+ "[mcp_tools] Failed to decode base64 blob content",
504
+ extra={"server": input_data.server, "uri": input_data.uri},
505
+ )
493
506
  raw_bytes = None
494
507
  else:
495
508
  raw_bytes = None
@@ -517,8 +530,9 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
517
530
  text_parts = [p.text for p in parts if p.text]
518
531
  content_text = "\n".join([p for p in text_parts if p]) or None
519
532
  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}"
533
+ logger.exception(
534
+ "Error reading MCP resource",
535
+ extra={"server": input_data.server, "uri": input_data.uri, "error": str(exc)},
522
536
  )
523
537
  content_text = f"Error reading MCP resource: {exc}"
524
538
  else:
@@ -535,12 +549,12 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
535
549
  )
536
550
  )
537
551
 
538
- result = ReadMcpResourceOutput(
552
+ read_result: Any = ReadMcpResourceOutput(
539
553
  server=input_data.server, uri=input_data.uri, content=content_text, contents=parts
540
554
  )
541
555
  yield ToolResult(
542
- data=result,
543
- result_for_assistant=self.render_result_for_assistant(result),
556
+ data=read_result,
557
+ result_for_assistant=self.render_result_for_assistant(read_result), # type: ignore[arg-type]
544
558
  )
545
559
 
546
560
 
@@ -571,6 +585,7 @@ def _annotation_flag(tool_info: Any, key: str) -> bool:
571
585
  try:
572
586
  return bool(annotations.get(key, False))
573
587
  except Exception:
588
+ logger.debug("[mcp_tools] Failed to read annotation flag", exc_info=True)
574
589
  return False
575
590
  return False
576
591
 
@@ -676,14 +691,16 @@ class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
676
691
 
677
692
  try:
678
693
  args = input_data.model_dump(exclude_none=True)
679
- result = await session.call_tool(
694
+ call_result = await session.call_tool(
680
695
  self.tool_info.name,
681
696
  args or {},
682
697
  )
683
- raw_blocks = getattr(result, "content", None)
698
+ raw_blocks = getattr(call_result, "content", None)
684
699
  content_blocks = _normalize_content_blocks(raw_blocks)
685
700
  content_text = _render_content_blocks(content_blocks) if content_blocks else None
686
- structured = result.structuredContent if hasattr(result, "structuredContent") else None
701
+ structured = (
702
+ call_result.structuredContent if hasattr(call_result, "structuredContent") else None
703
+ )
687
704
  assistant_text = content_text
688
705
  if structured:
689
706
  assistant_text = (assistant_text + "\n" if assistant_text else "") + json.dumps(
@@ -696,7 +713,7 @@ class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
696
713
  text=content_text,
697
714
  content_blocks=content_blocks,
698
715
  structured_content=structured,
699
- is_error=getattr(result, "isError", False),
716
+ is_error=getattr(call_result, "isError", False),
700
717
  )
701
718
  yield ToolResult(
702
719
  data=output,
@@ -712,8 +729,13 @@ class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
712
729
  structured_content=None,
713
730
  is_error=True,
714
731
  )
715
- logger.error(
716
- f"Error calling MCP tool {self.tool_info.name} on {self.server_name}: {exc}"
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
+ },
717
739
  )
718
740
  yield ToolResult(
719
741
  data=output,
@@ -762,7 +784,10 @@ def load_dynamic_mcp_tools_sync(project_path: Optional[Path] = None) -> List[Dyn
762
784
  try:
763
785
  return asyncio.run(_load_and_cleanup())
764
786
  except Exception as exc: # pragma: no cover - SDK/runtime failures
765
- logger.error(f"Failed to initialize MCP runtime for dynamic tools: {exc}")
787
+ logger.exception(
788
+ "Failed to initialize MCP runtime for dynamic tools (sync)",
789
+ extra={"error": str(exc)},
790
+ )
766
791
  return []
767
792
 
768
793
 
@@ -771,7 +796,10 @@ async def load_dynamic_mcp_tools_async(project_path: Optional[Path] = None) -> L
771
796
  try:
772
797
  runtime = await ensure_mcp_runtime(project_path)
773
798
  except Exception as exc: # pragma: no cover - SDK/runtime failures
774
- logger.error(f"Failed to initialize MCP runtime for dynamic tools: {exc}")
799
+ logger.exception(
800
+ "Failed to initialize MCP runtime for dynamic tools (async)",
801
+ extra={"error": str(exc)},
802
+ )
775
803
  return []
776
804
  return _build_dynamic_mcp_tools(runtime)
777
805
 
@@ -17,6 +17,9 @@ from ripperdoc.core.tool import (
17
17
  ToolUseExample,
18
18
  ValidationResult,
19
19
  )
20
+ from ripperdoc.utils.log import get_logger
21
+
22
+ logger = get_logger()
20
23
 
21
24
 
22
25
  DEFAULT_ACTION = "Edit"
@@ -124,7 +127,7 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
124
127
  return [
125
128
  ToolUseExample(
126
129
  description="Apply multiple replacements in one pass",
127
- input={
130
+ example={
128
131
  "file_path": "/repo/src/app.py",
129
132
  "edits": [
130
133
  {"old_string": "DEBUG = True", "new_string": "DEBUG = False"},
@@ -134,7 +137,7 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
134
137
  ),
135
138
  ToolUseExample(
136
139
  description="Create a new file then adjust content",
137
- input={
140
+ example={
138
141
  "file_path": "/repo/docs/notes.txt",
139
142
  "edits": [
140
143
  {"old_string": "", "new_string": "Line one\nLine two\n"},
@@ -307,6 +310,10 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
307
310
  if existing:
308
311
  original_content = file_path.read_text(encoding="utf-8")
309
312
  except Exception as exc: # pragma: no cover - unlikely permission issue
313
+ logger.exception(
314
+ "[multi_edit_tool] Error reading file before edits",
315
+ extra={"file_path": str(file_path)},
316
+ )
310
317
  output = MultiEditToolOutput(
311
318
  file_path=str(file_path),
312
319
  replacements_made=0,
@@ -354,6 +361,10 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
354
361
  try:
355
362
  file_path.write_text(updated_content, encoding="utf-8")
356
363
  except Exception as exc:
364
+ logger.exception(
365
+ "[multi_edit_tool] Error writing edited file",
366
+ extra={"file_path": str(file_path)},
367
+ )
357
368
  output = MultiEditToolOutput(
358
369
  file_path=str(file_path),
359
370
  replacements_made=0,