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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/__main__.py +0 -5
- ripperdoc/cli/cli.py +37 -16
- ripperdoc/cli/commands/__init__.py +2 -0
- ripperdoc/cli/commands/agents_cmd.py +12 -9
- ripperdoc/cli/commands/compact_cmd.py +7 -3
- ripperdoc/cli/commands/context_cmd.py +33 -13
- ripperdoc/cli/commands/doctor_cmd.py +27 -14
- ripperdoc/cli/commands/exit_cmd.py +1 -1
- ripperdoc/cli/commands/mcp_cmd.py +13 -8
- ripperdoc/cli/commands/memory_cmd.py +5 -5
- ripperdoc/cli/commands/models_cmd.py +47 -16
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +1 -2
- ripperdoc/cli/commands/tasks_cmd.py +24 -13
- ripperdoc/cli/ui/rich_ui.py +500 -406
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/agents.py +17 -9
- ripperdoc/core/config.py +130 -6
- ripperdoc/core/default_tools.py +7 -2
- ripperdoc/core/permissions.py +20 -14
- ripperdoc/core/providers/anthropic.py +107 -4
- ripperdoc/core/providers/base.py +33 -4
- ripperdoc/core/providers/gemini.py +169 -50
- ripperdoc/core/providers/openai.py +257 -23
- ripperdoc/core/query.py +294 -61
- ripperdoc/core/query_utils.py +50 -6
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +13 -7
- ripperdoc/core/tool.py +8 -6
- ripperdoc/sdk/client.py +14 -1
- ripperdoc/tools/ask_user_question_tool.py +20 -22
- ripperdoc/tools/background_shell.py +19 -13
- ripperdoc/tools/bash_tool.py +356 -209
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +5 -2
- ripperdoc/tools/exit_plan_mode_tool.py +6 -3
- ripperdoc/tools/file_edit_tool.py +53 -10
- ripperdoc/tools/file_read_tool.py +17 -7
- ripperdoc/tools/file_write_tool.py +49 -13
- ripperdoc/tools/glob_tool.py +10 -9
- ripperdoc/tools/grep_tool.py +182 -51
- ripperdoc/tools/ls_tool.py +6 -6
- ripperdoc/tools/mcp_tools.py +106 -456
- ripperdoc/tools/multi_edit_tool.py +49 -9
- ripperdoc/tools/notebook_edit_tool.py +57 -13
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +7 -8
- ripperdoc/tools/todo_tool.py +12 -12
- ripperdoc/tools/tool_search_tool.py +5 -6
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/file_watch.py +5 -4
- ripperdoc/utils/json_utils.py +4 -4
- ripperdoc/utils/log.py +3 -3
- ripperdoc/utils/mcp.py +36 -15
- ripperdoc/utils/memory.py +9 -6
- ripperdoc/utils/message_compaction.py +16 -11
- ripperdoc/utils/messages.py +73 -8
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/permissions/__init__.py +7 -1
- ripperdoc/utils/permissions/path_validation_utils.py +5 -3
- ripperdoc/utils/permissions/shell_command_validation.py +496 -18
- ripperdoc/utils/prompt.py +1 -1
- ripperdoc/utils/safe_get_cwd.py +5 -2
- ripperdoc/utils/session_history.py +38 -19
- ripperdoc/utils/todo.py +6 -2
- ripperdoc/utils/token_estimation.py +4 -3
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +12 -1
- ripperdoc-0.2.5.dist-info/RECORD +107 -0
- ripperdoc-0.2.4.dist-info/RECORD +0 -99
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""Dynamic MCP tool wrapper for runtime MCP server tools.
|
|
2
|
+
|
|
3
|
+
This module provides the DynamicMcpTool class that wraps MCP server tools
|
|
4
|
+
at runtime, along with helper functions for loading and merging these tools.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, AsyncGenerator, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict
|
|
15
|
+
|
|
16
|
+
from ripperdoc.core.tool import (
|
|
17
|
+
Tool,
|
|
18
|
+
ToolUseContext,
|
|
19
|
+
ToolResult,
|
|
20
|
+
ToolOutput,
|
|
21
|
+
)
|
|
22
|
+
from ripperdoc.utils.log import get_logger
|
|
23
|
+
from ripperdoc.utils.mcp import (
|
|
24
|
+
ensure_mcp_runtime,
|
|
25
|
+
get_existing_mcp_runtime,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = get_logger()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class McpToolCallOutput(BaseModel):
|
|
32
|
+
"""Standardized output for MCP tool calls."""
|
|
33
|
+
|
|
34
|
+
server: str
|
|
35
|
+
tool: str
|
|
36
|
+
content: Optional[str] = None
|
|
37
|
+
text: Optional[str] = None
|
|
38
|
+
content_blocks: Optional[List[Any]] = None
|
|
39
|
+
structured_content: Optional[dict] = None
|
|
40
|
+
is_error: bool = False
|
|
41
|
+
token_estimate: Optional[int] = None
|
|
42
|
+
warning: Optional[str] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sanitize_name(name: str) -> str:
|
|
46
|
+
"""Sanitize a name for use in tool identifiers."""
|
|
47
|
+
return "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in name)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _create_dynamic_input_model(schema: Optional[Dict[str, Any]]) -> type[BaseModel]:
|
|
51
|
+
"""Create a dynamic Pydantic model from a JSON schema."""
|
|
52
|
+
raw_schema = schema if isinstance(schema, dict) else {"type": "object"}
|
|
53
|
+
raw_schema = raw_schema or {"type": "object"}
|
|
54
|
+
|
|
55
|
+
class DynamicMcpInput(BaseModel):
|
|
56
|
+
model_config = ConfigDict(extra="allow")
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def model_json_schema(cls, *_args: Any, **_kwargs: Any) -> Dict[str, Any]:
|
|
60
|
+
return raw_schema
|
|
61
|
+
|
|
62
|
+
DynamicMcpInput.__name__ = (
|
|
63
|
+
f"McpInput_{abs(hash(json.dumps(raw_schema, sort_keys=True, default=str))) % 10_000_000}"
|
|
64
|
+
)
|
|
65
|
+
return DynamicMcpInput
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _annotation_flag(tool_info: Any, key: str) -> bool:
|
|
69
|
+
"""Extract a boolean flag from tool annotations."""
|
|
70
|
+
annotations = getattr(tool_info, "annotations", {}) or {}
|
|
71
|
+
if hasattr(annotations, "get"):
|
|
72
|
+
try:
|
|
73
|
+
return bool(annotations.get(key, False))
|
|
74
|
+
except (AttributeError, TypeError, KeyError) as exc:
|
|
75
|
+
logger.debug(
|
|
76
|
+
"[mcp_tools] Failed to read annotation flag: %s: %s",
|
|
77
|
+
type(exc).__name__, exc,
|
|
78
|
+
)
|
|
79
|
+
return False
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _render_mcp_tool_result_for_assistant(output: McpToolCallOutput) -> str:
|
|
84
|
+
"""Render MCP tool output for the assistant."""
|
|
85
|
+
if output.text or output.content:
|
|
86
|
+
return output.text or output.content or ""
|
|
87
|
+
if output.is_error:
|
|
88
|
+
return "MCP tool call failed."
|
|
89
|
+
return f"MCP tool '{output.tool}' returned no content."
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _content_block_to_text(block: Any) -> str:
|
|
93
|
+
"""Convert a content block to text representation."""
|
|
94
|
+
block_type = getattr(block, "type", None) or (
|
|
95
|
+
block.get("type") if isinstance(block, dict) else None
|
|
96
|
+
)
|
|
97
|
+
if block_type == "text":
|
|
98
|
+
return str(getattr(block, "text", None) or block.get("text", ""))
|
|
99
|
+
if block_type == "resource":
|
|
100
|
+
resource = getattr(block, "resource", None) or block.get("resource")
|
|
101
|
+
prefix = "resource"
|
|
102
|
+
if isinstance(resource, dict):
|
|
103
|
+
uri = resource.get("uri") or ""
|
|
104
|
+
text = resource.get("text") or ""
|
|
105
|
+
blob = resource.get("blob")
|
|
106
|
+
if text:
|
|
107
|
+
return f"[Resource {uri}] {text}"
|
|
108
|
+
if blob:
|
|
109
|
+
return f"[Resource {uri}] (binary content {len(str(blob))} chars)"
|
|
110
|
+
if hasattr(resource, "uri"):
|
|
111
|
+
uri = getattr(resource, "uri", "")
|
|
112
|
+
text = getattr(resource, "text", None)
|
|
113
|
+
blob = getattr(resource, "blob", None)
|
|
114
|
+
if text:
|
|
115
|
+
return f"[Resource {uri}] {text}"
|
|
116
|
+
if blob:
|
|
117
|
+
return f"[Resource {uri}] (binary content {len(str(blob))} chars)"
|
|
118
|
+
return prefix
|
|
119
|
+
if block_type == "resource_link":
|
|
120
|
+
uri = getattr(block, "uri", None) or (block.get("uri") if isinstance(block, dict) else None)
|
|
121
|
+
return f"[Resource link] {uri}" if uri else "[Resource link]"
|
|
122
|
+
if block_type == "image":
|
|
123
|
+
mime = getattr(block, "mimeType", None) or (
|
|
124
|
+
block.get("mimeType") if isinstance(block, dict) else None
|
|
125
|
+
)
|
|
126
|
+
return f"[Image content {mime or ''}]".strip()
|
|
127
|
+
if block_type == "audio":
|
|
128
|
+
mime = getattr(block, "mimeType", None) or (
|
|
129
|
+
block.get("mimeType") if isinstance(block, dict) else None
|
|
130
|
+
)
|
|
131
|
+
return f"[Audio content {mime or ''}]".strip()
|
|
132
|
+
return str(block)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _render_content_blocks(blocks: List[Any]) -> str:
|
|
136
|
+
"""Render multiple content blocks to text."""
|
|
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
|
+
"""Normalize a list of content blocks."""
|
|
168
|
+
if not blocks:
|
|
169
|
+
return None
|
|
170
|
+
return [_normalize_content_block(block) for block in blocks]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
|
|
174
|
+
"""Runtime wrapper for an MCP tool exposed by a connected server."""
|
|
175
|
+
|
|
176
|
+
is_mcp = True
|
|
177
|
+
|
|
178
|
+
def __init__(self, server_name: str, tool_info: Any, project_path: Path) -> None:
|
|
179
|
+
self.server_name = server_name
|
|
180
|
+
self.tool_info = tool_info
|
|
181
|
+
self.project_path = project_path
|
|
182
|
+
self._input_model = _create_dynamic_input_model(getattr(tool_info, "input_schema", None))
|
|
183
|
+
self._name = f"mcp__{_sanitize_name(server_name)}__{_sanitize_name(tool_info.name)}"
|
|
184
|
+
self._user_facing = (
|
|
185
|
+
f"{server_name} - {getattr(tool_info, 'description', '') or tool_info.name} (MCP)"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def name(self) -> str:
|
|
190
|
+
return self._name
|
|
191
|
+
|
|
192
|
+
async def description(self) -> str:
|
|
193
|
+
desc = getattr(self.tool_info, "description", "") or ""
|
|
194
|
+
schema = getattr(self.tool_info, "input_schema", None)
|
|
195
|
+
schema_snippet = json.dumps(schema, indent=2) if schema else ""
|
|
196
|
+
if schema_snippet:
|
|
197
|
+
schema_snippet = (
|
|
198
|
+
schema_snippet if len(schema_snippet) < 800 else schema_snippet[:800] + "..."
|
|
199
|
+
)
|
|
200
|
+
return f"{desc}\n\n[MCP tool]\nServer: {self.server_name}\nTool: {self.tool_info.name}\nInput schema:\n{schema_snippet}"
|
|
201
|
+
return f"{desc}\n\n[MCP tool]\nServer: {self.server_name}\nTool: {self.tool_info.name}"
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def input_schema(self) -> type[BaseModel]:
|
|
205
|
+
return self._input_model
|
|
206
|
+
|
|
207
|
+
async def prompt(self, _safe_mode: bool = False) -> str:
|
|
208
|
+
return await self.description()
|
|
209
|
+
|
|
210
|
+
def is_read_only(self) -> bool:
|
|
211
|
+
return _annotation_flag(self.tool_info, "readOnlyHint")
|
|
212
|
+
|
|
213
|
+
def is_concurrency_safe(self) -> bool:
|
|
214
|
+
return self.is_read_only()
|
|
215
|
+
|
|
216
|
+
def is_destructive(self) -> bool:
|
|
217
|
+
return _annotation_flag(self.tool_info, "destructiveHint")
|
|
218
|
+
|
|
219
|
+
def is_open_world(self) -> bool:
|
|
220
|
+
return _annotation_flag(self.tool_info, "openWorldHint")
|
|
221
|
+
|
|
222
|
+
def defer_loading(self) -> bool:
|
|
223
|
+
"""Avoid loading all MCP tools into the initial context."""
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
def needs_permissions(self, _input_data: Optional[BaseModel] = None) -> bool:
|
|
227
|
+
return not self.is_read_only()
|
|
228
|
+
|
|
229
|
+
def render_result_for_assistant(self, output: McpToolCallOutput) -> str:
|
|
230
|
+
return _render_mcp_tool_result_for_assistant(output)
|
|
231
|
+
|
|
232
|
+
def render_tool_use_message(self, input_data: BaseModel, verbose: bool = False) -> str:
|
|
233
|
+
args = input_data.model_dump(exclude_none=True)
|
|
234
|
+
arg_preview = json.dumps(args) if verbose and args else ""
|
|
235
|
+
suffix = f" with args {arg_preview}" if arg_preview else ""
|
|
236
|
+
return f"MCP {self.server_name}:{self.tool_info.name}{suffix}"
|
|
237
|
+
|
|
238
|
+
def user_facing_name(self) -> str:
|
|
239
|
+
return self._user_facing
|
|
240
|
+
|
|
241
|
+
async def call(
|
|
242
|
+
self,
|
|
243
|
+
input_data: BaseModel,
|
|
244
|
+
_context: ToolUseContext,
|
|
245
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
246
|
+
from ripperdoc.tools.mcp_tools import _evaluate_mcp_output_size
|
|
247
|
+
|
|
248
|
+
runtime = await ensure_mcp_runtime(self.project_path)
|
|
249
|
+
session = runtime.sessions.get(self.server_name) if runtime else None
|
|
250
|
+
if not session:
|
|
251
|
+
result = McpToolCallOutput(
|
|
252
|
+
server=self.server_name,
|
|
253
|
+
tool=self.tool_info.name,
|
|
254
|
+
content=None,
|
|
255
|
+
text=None,
|
|
256
|
+
content_blocks=None,
|
|
257
|
+
structured_content=None,
|
|
258
|
+
is_error=True,
|
|
259
|
+
)
|
|
260
|
+
yield ToolResult(
|
|
261
|
+
data=result,
|
|
262
|
+
result_for_assistant=f"MCP server '{self.server_name}' is not connected.",
|
|
263
|
+
)
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
args = input_data.model_dump(exclude_none=True)
|
|
268
|
+
call_result = await session.call_tool(
|
|
269
|
+
self.tool_info.name,
|
|
270
|
+
args or {},
|
|
271
|
+
)
|
|
272
|
+
raw_blocks = getattr(call_result, "content", None)
|
|
273
|
+
content_blocks = _normalize_content_blocks(raw_blocks)
|
|
274
|
+
content_text = _render_content_blocks(content_blocks) if content_blocks else None
|
|
275
|
+
structured = (
|
|
276
|
+
call_result.structuredContent if hasattr(call_result, "structuredContent") else None
|
|
277
|
+
)
|
|
278
|
+
assistant_text = content_text
|
|
279
|
+
if structured:
|
|
280
|
+
assistant_text = (assistant_text + "\n" if assistant_text else "") + json.dumps(
|
|
281
|
+
structured, indent=2
|
|
282
|
+
)
|
|
283
|
+
output = McpToolCallOutput(
|
|
284
|
+
server=self.server_name,
|
|
285
|
+
tool=self.tool_info.name,
|
|
286
|
+
content=assistant_text or None,
|
|
287
|
+
text=content_text,
|
|
288
|
+
content_blocks=content_blocks,
|
|
289
|
+
structured_content=structured,
|
|
290
|
+
is_error=getattr(call_result, "isError", False),
|
|
291
|
+
)
|
|
292
|
+
base_result_text = self.render_result_for_assistant(output)
|
|
293
|
+
warning_text, error_text, token_estimate = _evaluate_mcp_output_size(
|
|
294
|
+
base_result_text, self.server_name, self.tool_info.name
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if error_text:
|
|
298
|
+
limited_output = McpToolCallOutput(
|
|
299
|
+
server=self.server_name,
|
|
300
|
+
tool=self.tool_info.name,
|
|
301
|
+
content=None,
|
|
302
|
+
text=None,
|
|
303
|
+
content_blocks=None,
|
|
304
|
+
structured_content=None,
|
|
305
|
+
is_error=True,
|
|
306
|
+
token_estimate=token_estimate,
|
|
307
|
+
warning=None,
|
|
308
|
+
)
|
|
309
|
+
yield ToolResult(data=limited_output, result_for_assistant=error_text)
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
annotated_output = output.model_copy(
|
|
313
|
+
update={"token_estimate": token_estimate, "warning": warning_text}
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
final_text = base_result_text or ""
|
|
317
|
+
if not final_text and warning_text:
|
|
318
|
+
final_text = warning_text
|
|
319
|
+
|
|
320
|
+
yield ToolResult(
|
|
321
|
+
data=annotated_output,
|
|
322
|
+
result_for_assistant=final_text,
|
|
323
|
+
)
|
|
324
|
+
except (OSError, RuntimeError, ConnectionError, ValueError, KeyError, TypeError) as exc: # pragma: no cover - runtime errors
|
|
325
|
+
output = McpToolCallOutput(
|
|
326
|
+
server=self.server_name,
|
|
327
|
+
tool=self.tool_info.name,
|
|
328
|
+
content=None,
|
|
329
|
+
text=None,
|
|
330
|
+
content_blocks=None,
|
|
331
|
+
structured_content=None,
|
|
332
|
+
is_error=True,
|
|
333
|
+
)
|
|
334
|
+
logger.warning(
|
|
335
|
+
"Error calling MCP tool: %s: %s",
|
|
336
|
+
type(exc).__name__, exc,
|
|
337
|
+
extra={
|
|
338
|
+
"server": self.server_name,
|
|
339
|
+
"tool": self.tool_info.name,
|
|
340
|
+
},
|
|
341
|
+
)
|
|
342
|
+
yield ToolResult(
|
|
343
|
+
data=output,
|
|
344
|
+
result_for_assistant=f"Error calling MCP tool '{self.tool_info.name}' on '{self.server_name}': {exc}",
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _build_dynamic_mcp_tools(runtime: Optional[Any]) -> List[DynamicMcpTool]:
|
|
349
|
+
"""Build DynamicMcpTool instances from a runtime's connected servers."""
|
|
350
|
+
if not runtime or not getattr(runtime, "servers", None):
|
|
351
|
+
return []
|
|
352
|
+
tools: List[DynamicMcpTool] = []
|
|
353
|
+
for server in runtime.servers:
|
|
354
|
+
if getattr(server, "status", "") != "connected":
|
|
355
|
+
continue
|
|
356
|
+
if not getattr(server, "tools", None):
|
|
357
|
+
continue
|
|
358
|
+
for tool in server.tools:
|
|
359
|
+
tools.append(
|
|
360
|
+
DynamicMcpTool(server.name, tool, getattr(runtime, "project_path", Path.cwd()))
|
|
361
|
+
)
|
|
362
|
+
return tools
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def load_dynamic_mcp_tools_sync(project_path: Optional[Path] = None) -> List[DynamicMcpTool]:
|
|
366
|
+
"""Best-effort synchronous loader for MCP tools."""
|
|
367
|
+
runtime = get_existing_mcp_runtime()
|
|
368
|
+
if runtime and not getattr(runtime, "_closed", False):
|
|
369
|
+
return _build_dynamic_mcp_tools(runtime)
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
loop = asyncio.get_running_loop()
|
|
373
|
+
if loop.is_running():
|
|
374
|
+
return []
|
|
375
|
+
except RuntimeError:
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
async def _load_and_keep() -> List[DynamicMcpTool]:
|
|
379
|
+
runtime = await ensure_mcp_runtime(project_path)
|
|
380
|
+
return _build_dynamic_mcp_tools(runtime)
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
return asyncio.run(_load_and_keep())
|
|
384
|
+
except (OSError, RuntimeError, ConnectionError, ValueError) as exc: # pragma: no cover - SDK/runtime failures
|
|
385
|
+
logger.warning(
|
|
386
|
+
"Failed to initialize MCP runtime for dynamic tools (sync): %s: %s",
|
|
387
|
+
type(exc).__name__, exc,
|
|
388
|
+
)
|
|
389
|
+
return []
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
async def load_dynamic_mcp_tools_async(project_path: Optional[Path] = None) -> List[DynamicMcpTool]:
|
|
393
|
+
"""Async loader for MCP tools when already in an event loop."""
|
|
394
|
+
try:
|
|
395
|
+
runtime = await ensure_mcp_runtime(project_path)
|
|
396
|
+
except (OSError, RuntimeError, ConnectionError, ValueError) as exc: # pragma: no cover - SDK/runtime failures
|
|
397
|
+
logger.warning(
|
|
398
|
+
"Failed to initialize MCP runtime for dynamic tools (async): %s: %s",
|
|
399
|
+
type(exc).__name__, exc,
|
|
400
|
+
)
|
|
401
|
+
return []
|
|
402
|
+
return _build_dynamic_mcp_tools(runtime)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def merge_tools_with_dynamic(base_tools: List[Any], dynamic_tools: List[Any]) -> List[Any]:
|
|
406
|
+
"""Merge dynamic MCP tools into the existing tool list and rebuild the Task tool."""
|
|
407
|
+
from ripperdoc.tools.task_tool import TaskTool # Local import to avoid cycles
|
|
408
|
+
|
|
409
|
+
base_without_task = [tool for tool in base_tools if getattr(tool, "name", None) != "Task"]
|
|
410
|
+
existing_names = {getattr(tool, "name", None) for tool in base_without_task}
|
|
411
|
+
|
|
412
|
+
for tool in dynamic_tools:
|
|
413
|
+
if getattr(tool, "name", None) in existing_names:
|
|
414
|
+
continue
|
|
415
|
+
base_without_task.append(tool)
|
|
416
|
+
existing_names.add(getattr(tool, "name", None))
|
|
417
|
+
|
|
418
|
+
task_tool = TaskTool(lambda: base_without_task)
|
|
419
|
+
return base_without_task + [task_tool]
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
__all__ = [
|
|
423
|
+
"DynamicMcpTool",
|
|
424
|
+
"McpToolCallOutput",
|
|
425
|
+
"load_dynamic_mcp_tools_async",
|
|
426
|
+
"load_dynamic_mcp_tools_sync",
|
|
427
|
+
"merge_tools_with_dynamic",
|
|
428
|
+
]
|
|
@@ -164,7 +164,8 @@ class EnterPlanModeTool(Tool[EnterPlanModeToolInput, EnterPlanModeToolOutput]):
|
|
|
164
164
|
return True
|
|
165
165
|
|
|
166
166
|
def needs_permissions(
|
|
167
|
-
self,
|
|
167
|
+
self,
|
|
168
|
+
input_data: Optional[EnterPlanModeToolInput] = None, # noqa: ARG002
|
|
168
169
|
) -> bool:
|
|
169
170
|
return True
|
|
170
171
|
|
|
@@ -188,7 +189,9 @@ class EnterPlanModeTool(Tool[EnterPlanModeToolInput, EnterPlanModeToolOutput]):
|
|
|
188
189
|
return f"{output.message}\n\n{PLAN_MODE_INSTRUCTIONS}"
|
|
189
190
|
|
|
190
191
|
def render_tool_use_message(
|
|
191
|
-
self,
|
|
192
|
+
self,
|
|
193
|
+
input_data: EnterPlanModeToolInput,
|
|
194
|
+
verbose: bool = False, # noqa: ARG002
|
|
192
195
|
) -> str:
|
|
193
196
|
"""Render the tool use message for display."""
|
|
194
197
|
return "Requesting to enter plan mode"
|
|
@@ -97,7 +97,8 @@ class ExitPlanModeTool(Tool[ExitPlanModeToolInput, ExitPlanModeToolOutput]):
|
|
|
97
97
|
return True
|
|
98
98
|
|
|
99
99
|
def needs_permissions(
|
|
100
|
-
self,
|
|
100
|
+
self,
|
|
101
|
+
input_data: Optional[ExitPlanModeToolInput] = None, # noqa: ARG002
|
|
101
102
|
) -> bool:
|
|
102
103
|
return True
|
|
103
104
|
|
|
@@ -119,7 +120,9 @@ class ExitPlanModeTool(Tool[ExitPlanModeToolInput, ExitPlanModeToolOutput]):
|
|
|
119
120
|
return f"Exit plan mode and start coding now. Plan:\n{output.plan}"
|
|
120
121
|
|
|
121
122
|
def render_tool_use_message(
|
|
122
|
-
self,
|
|
123
|
+
self,
|
|
124
|
+
input_data: ExitPlanModeToolInput,
|
|
125
|
+
verbose: bool = False, # noqa: ARG002
|
|
123
126
|
) -> str:
|
|
124
127
|
"""Render the tool use message for display."""
|
|
125
128
|
plan = input_data.plan
|
|
@@ -136,7 +139,7 @@ class ExitPlanModeTool(Tool[ExitPlanModeToolInput, ExitPlanModeToolOutput]):
|
|
|
136
139
|
if context.on_exit_plan_mode:
|
|
137
140
|
try:
|
|
138
141
|
context.on_exit_plan_mode()
|
|
139
|
-
except
|
|
142
|
+
except (RuntimeError, ValueError, TypeError):
|
|
140
143
|
logger.debug("[exit_plan_mode_tool] Failed to call on_exit_plan_mode")
|
|
141
144
|
|
|
142
145
|
is_agent = bool(context.agent_id)
|
|
@@ -4,6 +4,7 @@ Allows the AI to edit files by replacing text.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import os
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
from typing import AsyncGenerator, List, Optional
|
|
8
9
|
from pydantic import BaseModel, Field
|
|
9
10
|
|
|
@@ -17,6 +18,7 @@ from ripperdoc.core.tool import (
|
|
|
17
18
|
)
|
|
18
19
|
from ripperdoc.utils.log import get_logger
|
|
19
20
|
from ripperdoc.utils.file_watch import record_snapshot
|
|
21
|
+
from ripperdoc.utils.path_ignore import check_path_for_tool
|
|
20
22
|
|
|
21
23
|
logger = get_logger()
|
|
22
24
|
|
|
@@ -108,20 +110,59 @@ match exactly (including whitespace and indentation)."""
|
|
|
108
110
|
) -> ValidationResult:
|
|
109
111
|
# Check if file exists
|
|
110
112
|
if not os.path.exists(input_data.file_path):
|
|
111
|
-
return ValidationResult(
|
|
113
|
+
return ValidationResult(
|
|
114
|
+
result=False,
|
|
115
|
+
message=f"File not found: {input_data.file_path}",
|
|
116
|
+
error_code=1,
|
|
117
|
+
)
|
|
112
118
|
|
|
113
119
|
# Check if it's a file
|
|
114
120
|
if not os.path.isfile(input_data.file_path):
|
|
115
121
|
return ValidationResult(
|
|
116
|
-
result=False,
|
|
122
|
+
result=False,
|
|
123
|
+
message=f"Path is not a file: {input_data.file_path}",
|
|
124
|
+
error_code=2,
|
|
117
125
|
)
|
|
118
126
|
|
|
119
127
|
# Check that old_string and new_string are different
|
|
120
128
|
if input_data.old_string == input_data.new_string:
|
|
121
129
|
return ValidationResult(
|
|
122
|
-
result=False,
|
|
130
|
+
result=False,
|
|
131
|
+
message="old_string and new_string must be different",
|
|
132
|
+
error_code=3,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Check if file has been read before editing
|
|
136
|
+
file_state_cache = getattr(context, "file_state_cache", {}) if context else {}
|
|
137
|
+
file_path = os.path.abspath(input_data.file_path)
|
|
138
|
+
file_snapshot = file_state_cache.get(file_path)
|
|
139
|
+
|
|
140
|
+
if not file_snapshot:
|
|
141
|
+
return ValidationResult(
|
|
142
|
+
result=False,
|
|
143
|
+
message="File has not been read yet. Read it first before editing.",
|
|
144
|
+
error_code=4,
|
|
123
145
|
)
|
|
124
146
|
|
|
147
|
+
# Check if file has been modified since it was read
|
|
148
|
+
try:
|
|
149
|
+
current_mtime = os.path.getmtime(file_path)
|
|
150
|
+
if current_mtime > file_snapshot.timestamp:
|
|
151
|
+
return ValidationResult(
|
|
152
|
+
result=False,
|
|
153
|
+
message="File has been modified since read, either by the user or by a linter. "
|
|
154
|
+
"Read it again before attempting to edit it.",
|
|
155
|
+
error_code=5,
|
|
156
|
+
)
|
|
157
|
+
except OSError:
|
|
158
|
+
pass # File mtime check failed, proceed anyway
|
|
159
|
+
|
|
160
|
+
# Check if path is ignored (warning for edit operations)
|
|
161
|
+
file_path_obj = Path(file_path)
|
|
162
|
+
should_proceed, warning_msg = check_path_for_tool(file_path_obj, tool_name="Edit", warn_only=True)
|
|
163
|
+
if warning_msg:
|
|
164
|
+
logger.warning("[file_edit_tool] %s", warning_msg)
|
|
165
|
+
|
|
125
166
|
return ValidationResult(result=True)
|
|
126
167
|
|
|
127
168
|
def render_result_for_assistant(self, output: FileEditToolOutput) -> str:
|
|
@@ -192,9 +233,10 @@ match exactly (including whitespace and indentation)."""
|
|
|
192
233
|
new_content,
|
|
193
234
|
getattr(context, "file_state_cache", {}),
|
|
194
235
|
)
|
|
195
|
-
except
|
|
196
|
-
logger.
|
|
197
|
-
"[file_edit_tool] Failed to record file snapshot",
|
|
236
|
+
except (OSError, IOError, RuntimeError) as exc:
|
|
237
|
+
logger.warning(
|
|
238
|
+
"[file_edit_tool] Failed to record file snapshot: %s: %s",
|
|
239
|
+
type(exc).__name__, exc,
|
|
198
240
|
extra={"file_path": input_data.file_path},
|
|
199
241
|
)
|
|
200
242
|
|
|
@@ -283,10 +325,11 @@ match exactly (including whitespace and indentation)."""
|
|
|
283
325
|
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
284
326
|
)
|
|
285
327
|
|
|
286
|
-
except
|
|
287
|
-
logger.
|
|
288
|
-
"[file_edit_tool] Error editing file",
|
|
289
|
-
|
|
328
|
+
except (OSError, IOError, PermissionError, UnicodeDecodeError, ValueError) as e:
|
|
329
|
+
logger.warning(
|
|
330
|
+
"[file_edit_tool] Error editing file: %s: %s",
|
|
331
|
+
type(e).__name__, e,
|
|
332
|
+
extra={"file_path": input_data.file_path},
|
|
290
333
|
)
|
|
291
334
|
error_output = FileEditToolOutput(
|
|
292
335
|
file_path=input_data.file_path,
|
|
@@ -4,6 +4,7 @@ Allows the AI to read file contents.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import os
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
from typing import AsyncGenerator, List, Optional
|
|
8
9
|
from pydantic import BaseModel, Field
|
|
9
10
|
|
|
@@ -17,6 +18,7 @@ from ripperdoc.core.tool import (
|
|
|
17
18
|
)
|
|
18
19
|
from ripperdoc.utils.log import get_logger
|
|
19
20
|
from ripperdoc.utils.file_watch import record_snapshot
|
|
21
|
+
from ripperdoc.utils.path_ignore import check_path_for_tool, is_path_ignored
|
|
20
22
|
|
|
21
23
|
logger = get_logger()
|
|
22
24
|
|
|
@@ -102,6 +104,12 @@ and limit to read only a portion of the file."""
|
|
|
102
104
|
result=False, message=f"Path is not a file: {input_data.file_path}"
|
|
103
105
|
)
|
|
104
106
|
|
|
107
|
+
# Check if path is ignored (warning only for read operations)
|
|
108
|
+
file_path = Path(input_data.file_path)
|
|
109
|
+
should_proceed, warning_msg = check_path_for_tool(file_path, tool_name="Read", warn_only=True)
|
|
110
|
+
if warning_msg:
|
|
111
|
+
logger.info("[file_read_tool] %s", warning_msg)
|
|
112
|
+
|
|
105
113
|
return ValidationResult(result=True)
|
|
106
114
|
|
|
107
115
|
def render_result_for_assistant(self, output: FileReadToolOutput) -> str:
|
|
@@ -153,9 +161,10 @@ and limit to read only a portion of the file."""
|
|
|
153
161
|
offset=offset,
|
|
154
162
|
limit=limit,
|
|
155
163
|
)
|
|
156
|
-
except
|
|
157
|
-
logger.
|
|
158
|
-
"[file_read_tool] Failed to record file snapshot",
|
|
164
|
+
except (OSError, IOError, RuntimeError) as exc:
|
|
165
|
+
logger.warning(
|
|
166
|
+
"[file_read_tool] Failed to record file snapshot: %s: %s",
|
|
167
|
+
type(exc).__name__, exc,
|
|
159
168
|
extra={"file_path": input_data.file_path},
|
|
160
169
|
)
|
|
161
170
|
|
|
@@ -171,10 +180,11 @@ and limit to read only a portion of the file."""
|
|
|
171
180
|
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
172
181
|
)
|
|
173
182
|
|
|
174
|
-
except
|
|
175
|
-
logger.
|
|
176
|
-
"[file_read_tool] Error reading file",
|
|
177
|
-
|
|
183
|
+
except (OSError, IOError, UnicodeDecodeError, ValueError) as e:
|
|
184
|
+
logger.warning(
|
|
185
|
+
"[file_read_tool] Error reading file: %s: %s",
|
|
186
|
+
type(e).__name__, e,
|
|
187
|
+
extra={"file_path": input_data.file_path},
|
|
178
188
|
)
|
|
179
189
|
# Create an error output
|
|
180
190
|
error_output = FileReadToolOutput(
|