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.
- 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 +35 -15
- 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 +523 -396
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/agents.py +172 -4
- ripperdoc/core/config.py +130 -6
- ripperdoc/core/default_tools.py +13 -2
- ripperdoc/core/permissions.py +20 -14
- ripperdoc/core/providers/__init__.py +31 -15
- ripperdoc/core/providers/anthropic.py +122 -8
- ripperdoc/core/providers/base.py +93 -15
- ripperdoc/core/providers/gemini.py +539 -96
- ripperdoc/core/providers/openai.py +371 -26
- ripperdoc/core/query.py +301 -62
- ripperdoc/core/query_utils.py +51 -7
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +79 -67
- ripperdoc/core/tool.py +15 -6
- ripperdoc/sdk/client.py +14 -1
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +82 -26
- ripperdoc/tools/bash_tool.py +356 -209
- 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 +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 +172 -413
- 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 +91 -9
- 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 +82 -22
- ripperdoc/utils/memory.py +9 -6
- ripperdoc/utils/message_compaction.py +19 -16
- 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 +34 -0
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +14 -1
- ripperdoc-0.2.5.dist-info/RECORD +107 -0
- ripperdoc-0.2.3.dist-info/RECORD +0 -95
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
ripperdoc/tools/mcp_tools.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
37
|
+
except (ImportError, ModuleNotFoundError): # pragma: no cover - SDK may be missing at runtime
|
|
40
38
|
mcp_types = None # type: ignore[assignment]
|
|
41
|
-
logger.
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
257
|
+
self, input_data: ListMcpResourcesInput, _context: Optional[ToolUseContext] = None
|
|
261
258
|
) -> ValidationResult:
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
276
|
-
logger.
|
|
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,
|
|
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
|
-
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
502
|
-
logger.
|
|
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
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
693
|
-
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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",
|