ripperdoc 0.2.6__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 +3 -0
- ripperdoc/__main__.py +20 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +405 -0
- ripperdoc/cli/commands/__init__.py +82 -0
- ripperdoc/cli/commands/agents_cmd.py +263 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +23 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +144 -0
- ripperdoc/cli/commands/cost_cmd.py +82 -0
- ripperdoc/cli/commands/doctor_cmd.py +221 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +70 -0
- ripperdoc/cli/commands/memory_cmd.py +202 -0
- ripperdoc/cli/commands/models_cmd.py +413 -0
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +98 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +278 -0
- ripperdoc/cli/commands/todos_cmd.py +69 -0
- ripperdoc/cli/commands/tools_cmd.py +19 -0
- ripperdoc/cli/ui/__init__.py +1 -0
- ripperdoc/cli/ui/context_display.py +298 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1557 -0
- ripperdoc/cli/ui/spinner.py +49 -0
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +486 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +559 -0
- ripperdoc/core/default_tools.py +88 -0
- ripperdoc/core/permissions.py +252 -0
- ripperdoc/core/providers/__init__.py +47 -0
- ripperdoc/core/providers/anthropic.py +250 -0
- ripperdoc/core/providers/base.py +265 -0
- ripperdoc/core/providers/gemini.py +615 -0
- ripperdoc/core/providers/openai.py +487 -0
- ripperdoc/core/query.py +1058 -0
- ripperdoc/core/query_utils.py +622 -0
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +431 -0
- ripperdoc/core/tool.py +240 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +333 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +389 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +1016 -0
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +226 -0
- ripperdoc/tools/exit_plan_mode_tool.py +153 -0
- ripperdoc/tools/file_edit_tool.py +346 -0
- ripperdoc/tools/file_read_tool.py +203 -0
- ripperdoc/tools/file_write_tool.py +205 -0
- ripperdoc/tools/glob_tool.py +179 -0
- ripperdoc/tools/grep_tool.py +370 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +471 -0
- ripperdoc/tools/mcp_tools.py +591 -0
- ripperdoc/tools/multi_edit_tool.py +456 -0
- ripperdoc/tools/notebook_edit_tool.py +386 -0
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +379 -0
- ripperdoc/tools/todo_tool.py +494 -0
- ripperdoc/tools/tool_search_tool.py +380 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/file_watch.py +135 -0
- ripperdoc/utils/git_utils.py +274 -0
- ripperdoc/utils/json_utils.py +27 -0
- ripperdoc/utils/log.py +176 -0
- ripperdoc/utils/mcp.py +560 -0
- ripperdoc/utils/memory.py +253 -0
- ripperdoc/utils/message_compaction.py +676 -0
- ripperdoc/utils/messages.py +519 -0
- ripperdoc/utils/output_utils.py +258 -0
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +27 -0
- ripperdoc/utils/permissions/path_validation_utils.py +174 -0
- ripperdoc/utils/permissions/shell_command_validation.py +552 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/safe_get_cwd.py +31 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +260 -0
- ripperdoc/utils/session_usage.py +117 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/shell_utils.py +159 -0
- ripperdoc/utils/todo.py +203 -0
- ripperdoc/utils/token_estimation.py +34 -0
- ripperdoc-0.2.6.dist-info/METADATA +193 -0
- ripperdoc-0.2.6.dist-info/RECORD +107 -0
- ripperdoc-0.2.6.dist-info/WHEEL +5 -0
- ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
- ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.2.6.dist-info/top_level.txt +1 -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
|
+
]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Enter plan mode tool for complex task planning.
|
|
2
|
+
|
|
3
|
+
This tool allows the AI to request entering plan mode for complex tasks
|
|
4
|
+
that require careful exploration and design before implementation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from textwrap import dedent
|
|
10
|
+
from typing import AsyncGenerator, Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from ripperdoc.core.tool import (
|
|
15
|
+
Tool,
|
|
16
|
+
ToolOutput,
|
|
17
|
+
ToolResult,
|
|
18
|
+
ToolUseContext,
|
|
19
|
+
ValidationResult,
|
|
20
|
+
)
|
|
21
|
+
from ripperdoc.utils.log import get_logger
|
|
22
|
+
|
|
23
|
+
logger = get_logger()
|
|
24
|
+
|
|
25
|
+
TOOL_NAME = "EnterPlanMode"
|
|
26
|
+
ASK_USER_QUESTION_TOOL = "AskUserQuestion"
|
|
27
|
+
|
|
28
|
+
ENTER_PLAN_MODE_PROMPT = dedent(
|
|
29
|
+
"""\
|
|
30
|
+
Use this tool when you encounter a complex task that requires careful planning and exploration before implementation. This tool transitions you into plan mode where you can thoroughly explore the codebase and design an implementation approach.
|
|
31
|
+
|
|
32
|
+
## When to Use This Tool
|
|
33
|
+
|
|
34
|
+
Use EnterPlanMode when ANY of these conditions apply:
|
|
35
|
+
|
|
36
|
+
1. **Multiple Valid Approaches**: The task can be solved in several different ways, each with trade-offs
|
|
37
|
+
- Example: "Add caching to the API" - could use Redis, in-memory, file-based, etc.
|
|
38
|
+
- Example: "Improve performance" - many optimization strategies possible
|
|
39
|
+
|
|
40
|
+
2. **Significant Architectural Decisions**: The task requires choosing between architectural patterns
|
|
41
|
+
- Example: "Add real-time updates" - WebSockets vs SSE vs polling
|
|
42
|
+
- Example: "Implement state management" - Redux vs Context vs custom solution
|
|
43
|
+
|
|
44
|
+
3. **Large-Scale Changes**: The task touches many files or systems
|
|
45
|
+
- Example: "Refactor the authentication system"
|
|
46
|
+
- Example: "Migrate from REST to GraphQL"
|
|
47
|
+
|
|
48
|
+
4. **Unclear Requirements**: You need to explore before understanding the full scope
|
|
49
|
+
- Example: "Make the app faster" - need to profile and identify bottlenecks
|
|
50
|
+
- Example: "Fix the bug in checkout" - need to investigate root cause
|
|
51
|
+
|
|
52
|
+
5. **User Input Needed**: You'll need to ask clarifying questions before starting
|
|
53
|
+
- If you would use {ask_tool} to clarify the approach, consider EnterPlanMode instead
|
|
54
|
+
- Plan mode lets you explore first, then present options with context
|
|
55
|
+
|
|
56
|
+
## When NOT to Use This Tool
|
|
57
|
+
|
|
58
|
+
Do NOT use EnterPlanMode for:
|
|
59
|
+
- Simple, straightforward tasks with obvious implementation
|
|
60
|
+
- Small bug fixes where the solution is clear
|
|
61
|
+
- Adding a single function or small feature
|
|
62
|
+
- Tasks you're already confident how to implement
|
|
63
|
+
- Research-only tasks (use the Task tool with explore agent instead)
|
|
64
|
+
|
|
65
|
+
## What Happens in Plan Mode
|
|
66
|
+
|
|
67
|
+
In plan mode, you'll:
|
|
68
|
+
1. Thoroughly explore the codebase using Glob, Grep, and Read tools
|
|
69
|
+
2. Understand existing patterns and architecture
|
|
70
|
+
3. Design an implementation approach
|
|
71
|
+
4. Present your plan to the user for approval
|
|
72
|
+
5. Use {ask_tool} if you need to clarify approaches
|
|
73
|
+
6. Exit plan mode with ExitPlanMode when ready to implement
|
|
74
|
+
|
|
75
|
+
## Examples
|
|
76
|
+
|
|
77
|
+
### GOOD - Use EnterPlanMode:
|
|
78
|
+
User: "Add user authentication to the app"
|
|
79
|
+
- This requires architectural decisions (session vs JWT, where to store tokens, middleware structure)
|
|
80
|
+
|
|
81
|
+
User: "Optimize the database queries"
|
|
82
|
+
- Multiple approaches possible, need to profile first, significant impact
|
|
83
|
+
|
|
84
|
+
User: "Implement dark mode"
|
|
85
|
+
- Architectural decision on theme system, affects many components
|
|
86
|
+
|
|
87
|
+
### BAD - Don't use EnterPlanMode:
|
|
88
|
+
User: "Fix the typo in the README"
|
|
89
|
+
- Straightforward, no planning needed
|
|
90
|
+
|
|
91
|
+
User: "Add a console.log to debug this function"
|
|
92
|
+
- Simple, obvious implementation
|
|
93
|
+
|
|
94
|
+
User: "What files handle routing?"
|
|
95
|
+
- Research task, not implementation planning
|
|
96
|
+
|
|
97
|
+
## Important Notes
|
|
98
|
+
|
|
99
|
+
- This tool REQUIRES user approval - they must consent to entering plan mode
|
|
100
|
+
- Be thoughtful about when to use it - unnecessary plan mode slows down simple tasks
|
|
101
|
+
- If unsure whether to use it, err on the side of starting implementation
|
|
102
|
+
- You can always ask the user "Would you like me to plan this out first?"
|
|
103
|
+
"""
|
|
104
|
+
).format(ask_tool=ASK_USER_QUESTION_TOOL)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
PLAN_MODE_INSTRUCTIONS = dedent(
|
|
108
|
+
"""\
|
|
109
|
+
In plan mode, you should:
|
|
110
|
+
1. Thoroughly explore the codebase to understand existing patterns
|
|
111
|
+
2. Identify similar features and architectural approaches
|
|
112
|
+
3. Consider multiple approaches and their trade-offs
|
|
113
|
+
4. Use AskUserQuestion if you need to clarify the approach
|
|
114
|
+
5. Design a concrete implementation strategy
|
|
115
|
+
6. When ready, use ExitPlanMode to present your plan for approval
|
|
116
|
+
|
|
117
|
+
Remember: DO NOT write or edit any files yet. This is a read-only exploration and planning phase."""
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class EnterPlanModeToolInput(BaseModel):
|
|
122
|
+
"""Input for the EnterPlanMode tool.
|
|
123
|
+
|
|
124
|
+
This tool takes no input parameters - it simply requests to enter plan mode.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class EnterPlanModeToolOutput(BaseModel):
|
|
131
|
+
"""Output from the EnterPlanMode tool."""
|
|
132
|
+
|
|
133
|
+
message: str
|
|
134
|
+
entered: bool = True
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class EnterPlanModeTool(Tool[EnterPlanModeToolInput, EnterPlanModeToolOutput]):
|
|
138
|
+
"""Tool for entering plan mode for complex tasks."""
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def name(self) -> str:
|
|
142
|
+
return TOOL_NAME
|
|
143
|
+
|
|
144
|
+
async def description(self) -> str:
|
|
145
|
+
return (
|
|
146
|
+
"Requests permission to enter plan mode for complex tasks "
|
|
147
|
+
"requiring exploration and design"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def input_schema(self) -> type[EnterPlanModeToolInput]:
|
|
152
|
+
return EnterPlanModeToolInput
|
|
153
|
+
|
|
154
|
+
async def prompt(self, safe_mode: bool = False) -> str: # noqa: ARG002
|
|
155
|
+
return ENTER_PLAN_MODE_PROMPT
|
|
156
|
+
|
|
157
|
+
def user_facing_name(self) -> str:
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
def is_read_only(self) -> bool:
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
def is_concurrency_safe(self) -> bool:
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
def needs_permissions(
|
|
167
|
+
self,
|
|
168
|
+
input_data: Optional[EnterPlanModeToolInput] = None, # noqa: ARG002
|
|
169
|
+
) -> bool:
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
async def validate_input(
|
|
173
|
+
self,
|
|
174
|
+
input_data: EnterPlanModeToolInput,
|
|
175
|
+
context: Optional[ToolUseContext] = None,
|
|
176
|
+
) -> ValidationResult:
|
|
177
|
+
"""Validate that this tool is not being used in an agent context."""
|
|
178
|
+
if context and context.agent_id:
|
|
179
|
+
return ValidationResult(
|
|
180
|
+
result=False,
|
|
181
|
+
message="EnterPlanMode tool cannot be used in agent contexts",
|
|
182
|
+
)
|
|
183
|
+
return ValidationResult(result=True)
|
|
184
|
+
|
|
185
|
+
def render_result_for_assistant(self, output: EnterPlanModeToolOutput) -> str:
|
|
186
|
+
"""Render the tool output for the AI assistant."""
|
|
187
|
+
if not output.entered:
|
|
188
|
+
return "User declined to enter plan mode. Continue with normal implementation."
|
|
189
|
+
return f"{output.message}\n\n{PLAN_MODE_INSTRUCTIONS}"
|
|
190
|
+
|
|
191
|
+
def render_tool_use_message(
|
|
192
|
+
self,
|
|
193
|
+
input_data: EnterPlanModeToolInput,
|
|
194
|
+
verbose: bool = False, # noqa: ARG002
|
|
195
|
+
) -> str:
|
|
196
|
+
"""Render the tool use message for display."""
|
|
197
|
+
return "Requesting to enter plan mode"
|
|
198
|
+
|
|
199
|
+
async def call(
|
|
200
|
+
self,
|
|
201
|
+
input_data: EnterPlanModeToolInput, # noqa: ARG002
|
|
202
|
+
context: ToolUseContext,
|
|
203
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
204
|
+
"""Execute the tool to enter plan mode."""
|
|
205
|
+
if context.agent_id:
|
|
206
|
+
output = EnterPlanModeToolOutput(
|
|
207
|
+
message="EnterPlanMode tool cannot be used in agent contexts",
|
|
208
|
+
entered=False,
|
|
209
|
+
)
|
|
210
|
+
yield ToolResult(
|
|
211
|
+
data=output,
|
|
212
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
213
|
+
)
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
output = EnterPlanModeToolOutput(
|
|
217
|
+
message=(
|
|
218
|
+
"Entered plan mode. You should now focus on exploring "
|
|
219
|
+
"the codebase and designing an implementation approach."
|
|
220
|
+
),
|
|
221
|
+
entered=True,
|
|
222
|
+
)
|
|
223
|
+
yield ToolResult(
|
|
224
|
+
data=output,
|
|
225
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
226
|
+
)
|