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
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,9 +25,7 @@ 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
|
)
|
|
33
30
|
from ripperdoc.utils.token_estimation import estimate_tokens
|
|
34
31
|
|
|
@@ -37,15 +34,59 @@ logger = get_logger()
|
|
|
37
34
|
|
|
38
35
|
try:
|
|
39
36
|
import mcp.types as mcp_types # type: ignore
|
|
40
|
-
except
|
|
37
|
+
except (ImportError, ModuleNotFoundError): # pragma: no cover - SDK may be missing at runtime
|
|
41
38
|
mcp_types = None # type: ignore[assignment]
|
|
42
|
-
logger.
|
|
39
|
+
logger.debug("[mcp_tools] MCP SDK unavailable during import")
|
|
43
40
|
|
|
44
41
|
DEFAULT_MAX_MCP_OUTPUT_TOKENS = 25_000
|
|
45
42
|
MIN_MCP_OUTPUT_TOKENS = 1_000
|
|
46
43
|
DEFAULT_MCP_WARNING_FRACTION = 0.8
|
|
47
44
|
|
|
48
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
|
+
|
|
49
90
|
def _get_mcp_token_limits() -> tuple[int, int]:
|
|
50
91
|
"""Compute warning and hard limits for MCP output size."""
|
|
51
92
|
max_tokens = os.getenv("RIPPERDOC_MCP_MAX_OUTPUT_TOKENS")
|
|
@@ -57,7 +98,9 @@ def _get_mcp_token_limits() -> tuple[int, int]:
|
|
|
57
98
|
|
|
58
99
|
warn_env = os.getenv("RIPPERDOC_MCP_WARNING_TOKENS")
|
|
59
100
|
try:
|
|
60
|
-
warn_tokens_int =
|
|
101
|
+
warn_tokens_int = (
|
|
102
|
+
int(warn_env) if warn_env else int(max_tokens_int * DEFAULT_MCP_WARNING_FRACTION)
|
|
103
|
+
)
|
|
61
104
|
except (TypeError, ValueError):
|
|
62
105
|
warn_tokens_int = int(max_tokens_int * DEFAULT_MCP_WARNING_FRACTION)
|
|
63
106
|
warn_tokens_int = max(MIN_MCP_OUTPUT_TOKENS, min(warn_tokens_int, max_tokens_int))
|
|
@@ -91,84 +134,6 @@ def _evaluate_mcp_output_size(
|
|
|
91
134
|
return warning_text, None, token_estimate
|
|
92
135
|
|
|
93
136
|
|
|
94
|
-
def _content_block_to_text(block: Any) -> str:
|
|
95
|
-
block_type = getattr(block, "type", None) or (
|
|
96
|
-
block.get("type") if isinstance(block, dict) else None
|
|
97
|
-
)
|
|
98
|
-
if block_type == "text":
|
|
99
|
-
return str(getattr(block, "text", None) or block.get("text", ""))
|
|
100
|
-
if block_type == "resource":
|
|
101
|
-
resource = getattr(block, "resource", None) or block.get("resource")
|
|
102
|
-
prefix = "resource"
|
|
103
|
-
if isinstance(resource, dict):
|
|
104
|
-
uri = resource.get("uri") or ""
|
|
105
|
-
text = resource.get("text") or ""
|
|
106
|
-
blob = resource.get("blob")
|
|
107
|
-
if text:
|
|
108
|
-
return f"[Resource {uri}] {text}"
|
|
109
|
-
if blob:
|
|
110
|
-
return f"[Resource {uri}] (binary content {len(str(blob))} chars)"
|
|
111
|
-
if hasattr(resource, "uri"):
|
|
112
|
-
uri = getattr(resource, "uri", "")
|
|
113
|
-
text = getattr(resource, "text", None)
|
|
114
|
-
blob = getattr(resource, "blob", None)
|
|
115
|
-
if text:
|
|
116
|
-
return f"[Resource {uri}] {text}"
|
|
117
|
-
if blob:
|
|
118
|
-
return f"[Resource {uri}] (binary content {len(str(blob))} chars)"
|
|
119
|
-
return prefix
|
|
120
|
-
if block_type == "resource_link":
|
|
121
|
-
uri = getattr(block, "uri", None) or (block.get("uri") if isinstance(block, dict) else None)
|
|
122
|
-
return f"[Resource link] {uri}" if uri else "[Resource link]"
|
|
123
|
-
if block_type == "image":
|
|
124
|
-
mime = getattr(block, "mimeType", None) or (
|
|
125
|
-
block.get("mimeType") if isinstance(block, dict) else None
|
|
126
|
-
)
|
|
127
|
-
return f"[Image content {mime or ''}]".strip()
|
|
128
|
-
if block_type == "audio":
|
|
129
|
-
mime = getattr(block, "mimeType", None) or (
|
|
130
|
-
block.get("mimeType") if isinstance(block, dict) else None
|
|
131
|
-
)
|
|
132
|
-
return f"[Audio content {mime or ''}]".strip()
|
|
133
|
-
return str(block)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def _render_content_blocks(blocks: List[Any]) -> str:
|
|
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
|
-
if not blocks:
|
|
168
|
-
return None
|
|
169
|
-
return [_normalize_content_block(block) for block in blocks]
|
|
170
|
-
|
|
171
|
-
|
|
172
137
|
class ListMcpServersInput(BaseModel):
|
|
173
138
|
"""Input for listing MCP servers."""
|
|
174
139
|
|
|
@@ -181,7 +146,7 @@ class ListMcpServersOutput(BaseModel):
|
|
|
181
146
|
servers: List[dict]
|
|
182
147
|
|
|
183
148
|
|
|
184
|
-
class ListMcpServersTool(Tool[ListMcpServersInput, ListMcpServersOutput]):
|
|
149
|
+
class ListMcpServersTool(BaseMcpTool, Tool[ListMcpServersInput, ListMcpServersOutput]):
|
|
185
150
|
"""List configured MCP servers and their tools."""
|
|
186
151
|
|
|
187
152
|
@property
|
|
@@ -195,19 +160,10 @@ class ListMcpServersTool(Tool[ListMcpServersInput, ListMcpServersOutput]):
|
|
|
195
160
|
def input_schema(self) -> type[ListMcpServersInput]:
|
|
196
161
|
return ListMcpServersInput
|
|
197
162
|
|
|
198
|
-
async def prompt(self,
|
|
163
|
+
async def prompt(self, _safe_mode: bool = False) -> str:
|
|
199
164
|
servers = await load_mcp_servers_async()
|
|
200
165
|
return format_mcp_instructions(servers)
|
|
201
166
|
|
|
202
|
-
def is_read_only(self) -> bool:
|
|
203
|
-
return True
|
|
204
|
-
|
|
205
|
-
def is_concurrency_safe(self) -> bool:
|
|
206
|
-
return True
|
|
207
|
-
|
|
208
|
-
def needs_permissions(self, input_data: Optional[ListMcpServersInput] = None) -> bool:
|
|
209
|
-
return False
|
|
210
|
-
|
|
211
167
|
def render_result_for_assistant(self, output: ListMcpServersOutput) -> str:
|
|
212
168
|
if not output.servers:
|
|
213
169
|
return "No MCP servers configured."
|
|
@@ -221,14 +177,14 @@ class ListMcpServersTool(Tool[ListMcpServersInput, ListMcpServersOutput]):
|
|
|
221
177
|
return "\n".join(lines)
|
|
222
178
|
|
|
223
179
|
def render_tool_use_message(
|
|
224
|
-
self, input_data: ListMcpServersInput,
|
|
180
|
+
self, input_data: ListMcpServersInput, _verbose: bool = False
|
|
225
181
|
) -> str:
|
|
226
182
|
return f"List MCP servers{f' for {input_data.server}' if input_data.server else ''}"
|
|
227
183
|
|
|
228
184
|
async def call(
|
|
229
185
|
self,
|
|
230
186
|
input_data: ListMcpServersInput,
|
|
231
|
-
|
|
187
|
+
_context: ToolUseContext,
|
|
232
188
|
) -> AsyncGenerator[ToolOutput, None]:
|
|
233
189
|
runtime = await ensure_mcp_runtime()
|
|
234
190
|
servers: List[McpServerInfo] = runtime.servers
|
|
@@ -267,7 +223,7 @@ class ListMcpResourcesOutput(BaseModel):
|
|
|
267
223
|
resources: List[dict]
|
|
268
224
|
|
|
269
225
|
|
|
270
|
-
class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
|
|
226
|
+
class ListMcpResourcesTool(BaseMcpTool, Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
|
|
271
227
|
"""List resources exposed by MCP servers."""
|
|
272
228
|
|
|
273
229
|
@property
|
|
@@ -287,7 +243,7 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
|
|
|
287
243
|
def input_schema(self) -> type[ListMcpResourcesInput]:
|
|
288
244
|
return ListMcpResourcesInput
|
|
289
245
|
|
|
290
|
-
async def prompt(self,
|
|
246
|
+
async def prompt(self, _safe_mode: bool = False) -> str:
|
|
291
247
|
return (
|
|
292
248
|
"List available resources from configured MCP servers.\n"
|
|
293
249
|
"Each returned resource will include all standard MCP resource fields plus a 'server' field\n"
|
|
@@ -297,24 +253,11 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
|
|
|
297
253
|
" resources from all servers will be returned."
|
|
298
254
|
)
|
|
299
255
|
|
|
300
|
-
def is_read_only(self) -> bool:
|
|
301
|
-
return True
|
|
302
|
-
|
|
303
|
-
def is_concurrency_safe(self) -> bool:
|
|
304
|
-
return True
|
|
305
|
-
|
|
306
|
-
def needs_permissions(self, input_data: Optional[ListMcpResourcesInput] = None) -> bool:
|
|
307
|
-
return False
|
|
308
|
-
|
|
309
256
|
async def validate_input(
|
|
310
|
-
self, input_data: ListMcpResourcesInput,
|
|
257
|
+
self, input_data: ListMcpResourcesInput, _context: Optional[ToolUseContext] = None
|
|
311
258
|
) -> ValidationResult:
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if input_data.server and input_data.server not in server_names:
|
|
315
|
-
return ValidationResult(
|
|
316
|
-
result=False, message=f"Unknown MCP server '{input_data.server}'."
|
|
317
|
-
)
|
|
259
|
+
if input_data.server:
|
|
260
|
+
return await self.validate_server_exists(input_data.server)
|
|
318
261
|
return ValidationResult(result=True)
|
|
319
262
|
|
|
320
263
|
def render_result_for_assistant(self, output: ListMcpResourcesOutput) -> str:
|
|
@@ -322,19 +265,22 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
|
|
|
322
265
|
return "No MCP resources found."
|
|
323
266
|
try:
|
|
324
267
|
return json.dumps(output.resources, indent=2, ensure_ascii=False)
|
|
325
|
-
except
|
|
326
|
-
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
|
+
)
|
|
327
273
|
return str(output.resources)
|
|
328
274
|
|
|
329
275
|
def render_tool_use_message(
|
|
330
|
-
self, input_data: ListMcpResourcesInput,
|
|
276
|
+
self, input_data: ListMcpResourcesInput, _verbose: bool = False
|
|
331
277
|
) -> str:
|
|
332
278
|
return f"List MCP resources{f' for {input_data.server}' if input_data.server else ''}"
|
|
333
279
|
|
|
334
280
|
async def call(
|
|
335
281
|
self,
|
|
336
282
|
input_data: ListMcpResourcesInput,
|
|
337
|
-
|
|
283
|
+
_context: ToolUseContext,
|
|
338
284
|
) -> AsyncGenerator[ToolOutput, None]:
|
|
339
285
|
runtime = await ensure_mcp_runtime()
|
|
340
286
|
servers = runtime.servers
|
|
@@ -364,10 +310,12 @@ class ListMcpResourcesTool(Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
|
|
|
364
310
|
)
|
|
365
311
|
for res in response.resources
|
|
366
312
|
]
|
|
367
|
-
except
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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},
|
|
371
319
|
)
|
|
372
320
|
fetched = []
|
|
373
321
|
|
|
@@ -425,21 +373,7 @@ class ReadMcpResourceOutput(BaseModel):
|
|
|
425
373
|
is_error: bool = False
|
|
426
374
|
|
|
427
375
|
|
|
428
|
-
class
|
|
429
|
-
"""Standardized output for MCP tool calls."""
|
|
430
|
-
|
|
431
|
-
server: str
|
|
432
|
-
tool: str
|
|
433
|
-
content: Optional[str] = None
|
|
434
|
-
text: Optional[str] = None
|
|
435
|
-
content_blocks: Optional[List[Any]] = None
|
|
436
|
-
structured_content: Optional[dict] = None
|
|
437
|
-
is_error: bool = False
|
|
438
|
-
token_estimate: Optional[int] = None
|
|
439
|
-
warning: Optional[str] = None
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
|
|
376
|
+
class ReadMcpResourceTool(BaseMcpTool, Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
|
|
443
377
|
"""Read a resource defined in MCP configuration."""
|
|
444
378
|
|
|
445
379
|
@property
|
|
@@ -460,7 +394,7 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
|
|
|
460
394
|
def input_schema(self) -> type[ReadMcpResourceInput]:
|
|
461
395
|
return ReadMcpResourceInput
|
|
462
396
|
|
|
463
|
-
async def prompt(self,
|
|
397
|
+
async def prompt(self, _safe_mode: bool = False) -> str:
|
|
464
398
|
return (
|
|
465
399
|
"Reads a specific resource from an MCP server, identified by server name and resource URI.\n\n"
|
|
466
400
|
"Parameters:\n"
|
|
@@ -469,24 +403,16 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
|
|
|
469
403
|
"- save_blobs (optional): If true, write binary content to a temporary file and include the path"
|
|
470
404
|
)
|
|
471
405
|
|
|
472
|
-
def is_read_only(self) -> bool:
|
|
473
|
-
return True
|
|
474
|
-
|
|
475
|
-
def is_concurrency_safe(self) -> bool:
|
|
476
|
-
return True
|
|
477
|
-
|
|
478
|
-
def needs_permissions(self, input_data: Optional[ReadMcpResourceInput] = None) -> bool:
|
|
479
|
-
return False
|
|
480
|
-
|
|
481
406
|
async def validate_input(
|
|
482
|
-
self, input_data: ReadMcpResourceInput,
|
|
407
|
+
self, input_data: ReadMcpResourceInput, _context: Optional[ToolUseContext] = None
|
|
483
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
|
|
484
415
|
runtime = await ensure_mcp_runtime()
|
|
485
|
-
server_names = {s.name for s in runtime.servers}
|
|
486
|
-
if input_data.server not in server_names:
|
|
487
|
-
return ValidationResult(
|
|
488
|
-
result=False, message=f"Unknown MCP server '{input_data.server}'."
|
|
489
|
-
)
|
|
490
416
|
resource = find_mcp_resource(runtime.servers, input_data.server, input_data.uri)
|
|
491
417
|
if not resource:
|
|
492
418
|
return ValidationResult(
|
|
@@ -510,14 +436,14 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
|
|
|
510
436
|
return output.content
|
|
511
437
|
|
|
512
438
|
def render_tool_use_message(
|
|
513
|
-
self, input_data: ReadMcpResourceInput,
|
|
439
|
+
self, input_data: ReadMcpResourceInput, _verbose: bool = False
|
|
514
440
|
) -> str:
|
|
515
441
|
return f"Read MCP resource {input_data.uri} from {input_data.server}"
|
|
516
442
|
|
|
517
443
|
async def call(
|
|
518
444
|
self,
|
|
519
445
|
input_data: ReadMcpResourceInput,
|
|
520
|
-
|
|
446
|
+
_context: ToolUseContext,
|
|
521
447
|
) -> AsyncGenerator[ToolOutput, None]:
|
|
522
448
|
runtime = await ensure_mcp_runtime()
|
|
523
449
|
session = runtime.sessions.get(input_data.server) if runtime else None
|
|
@@ -553,9 +479,10 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
|
|
|
553
479
|
base64_data = blob_data
|
|
554
480
|
try:
|
|
555
481
|
raw_bytes = base64.b64decode(blob_data)
|
|
556
|
-
except
|
|
557
|
-
logger.
|
|
558
|
-
"[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,
|
|
559
486
|
extra={"server": input_data.server, "uri": input_data.uri},
|
|
560
487
|
)
|
|
561
488
|
raw_bytes = None
|
|
@@ -584,10 +511,12 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
|
|
|
584
511
|
)
|
|
585
512
|
text_parts = [p.text for p in parts if p.text]
|
|
586
513
|
content_text = "\n".join([p for p in text_parts if p]) or None
|
|
587
|
-
except
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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},
|
|
591
520
|
)
|
|
592
521
|
content_text = f"Error reading MCP resource: {exc}"
|
|
593
522
|
else:
|
|
@@ -639,302 +568,23 @@ class ReadMcpResourceTool(Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
|
|
|
639
568
|
)
|
|
640
569
|
|
|
641
570
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
@classmethod
|
|
654
|
-
def model_json_schema(cls, *args: Any, **kwargs: Any) -> Dict[str, Any]:
|
|
655
|
-
return raw_schema
|
|
656
|
-
|
|
657
|
-
DynamicMcpInput.__name__ = (
|
|
658
|
-
f"McpInput_{abs(hash(json.dumps(raw_schema, sort_keys=True, default=str))) % 10_000_000}"
|
|
659
|
-
)
|
|
660
|
-
return DynamicMcpInput
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
def _annotation_flag(tool_info: Any, key: str) -> bool:
|
|
664
|
-
annotations = getattr(tool_info, "annotations", {}) or {}
|
|
665
|
-
if hasattr(annotations, "get"):
|
|
666
|
-
try:
|
|
667
|
-
return bool(annotations.get(key, False))
|
|
668
|
-
except Exception:
|
|
669
|
-
logger.debug("[mcp_tools] Failed to read annotation flag", exc_info=True)
|
|
670
|
-
return False
|
|
671
|
-
return False
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
def _render_mcp_tool_result_for_assistant(output: McpToolCallOutput) -> str:
|
|
675
|
-
if output.text or output.content:
|
|
676
|
-
return output.text or output.content or ""
|
|
677
|
-
if output.is_error:
|
|
678
|
-
return "MCP tool call failed."
|
|
679
|
-
return f"MCP tool '{output.tool}' returned no content."
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
|
|
683
|
-
"""Runtime wrapper for an MCP tool exposed by a connected server."""
|
|
684
|
-
|
|
685
|
-
is_mcp = True
|
|
686
|
-
|
|
687
|
-
def __init__(self, server_name: str, tool_info: Any, project_path: Path) -> None:
|
|
688
|
-
self.server_name = server_name
|
|
689
|
-
self.tool_info = tool_info
|
|
690
|
-
self.project_path = project_path
|
|
691
|
-
self._input_model = _create_dynamic_input_model(getattr(tool_info, "input_schema", None))
|
|
692
|
-
self._name = f"mcp__{_sanitize_name(server_name)}__{_sanitize_name(tool_info.name)}"
|
|
693
|
-
self._user_facing = (
|
|
694
|
-
f"{server_name} - {getattr(tool_info, 'description', '') or tool_info.name} (MCP)"
|
|
695
|
-
)
|
|
696
|
-
|
|
697
|
-
@property
|
|
698
|
-
def name(self) -> str:
|
|
699
|
-
return self._name
|
|
700
|
-
|
|
701
|
-
async def description(self) -> str:
|
|
702
|
-
desc = getattr(self.tool_info, "description", "") or ""
|
|
703
|
-
schema = getattr(self.tool_info, "input_schema", None)
|
|
704
|
-
schema_snippet = json.dumps(schema, indent=2) if schema else ""
|
|
705
|
-
if schema_snippet:
|
|
706
|
-
schema_snippet = (
|
|
707
|
-
schema_snippet if len(schema_snippet) < 800 else schema_snippet[:800] + "..."
|
|
708
|
-
)
|
|
709
|
-
return f"{desc}\n\n[MCP tool]\nServer: {self.server_name}\nTool: {self.tool_info.name}\nInput schema:\n{schema_snippet}"
|
|
710
|
-
return f"{desc}\n\n[MCP tool]\nServer: {self.server_name}\nTool: {self.tool_info.name}"
|
|
711
|
-
|
|
712
|
-
@property
|
|
713
|
-
def input_schema(self) -> type[BaseModel]:
|
|
714
|
-
return self._input_model
|
|
715
|
-
|
|
716
|
-
async def prompt(self, safe_mode: bool = False) -> str:
|
|
717
|
-
return await self.description()
|
|
718
|
-
|
|
719
|
-
def is_read_only(self) -> bool:
|
|
720
|
-
return _annotation_flag(self.tool_info, "readOnlyHint")
|
|
721
|
-
|
|
722
|
-
def is_concurrency_safe(self) -> bool:
|
|
723
|
-
return self.is_read_only()
|
|
724
|
-
|
|
725
|
-
def is_destructive(self) -> bool:
|
|
726
|
-
return _annotation_flag(self.tool_info, "destructiveHint")
|
|
727
|
-
|
|
728
|
-
def is_open_world(self) -> bool:
|
|
729
|
-
return _annotation_flag(self.tool_info, "openWorldHint")
|
|
730
|
-
|
|
731
|
-
def defer_loading(self) -> bool:
|
|
732
|
-
"""Avoid loading all MCP tools into the initial context."""
|
|
733
|
-
return True
|
|
734
|
-
|
|
735
|
-
def needs_permissions(self, input_data: Optional[BaseModel] = None) -> bool:
|
|
736
|
-
return not self.is_read_only()
|
|
737
|
-
|
|
738
|
-
def render_result_for_assistant(self, output: McpToolCallOutput) -> str:
|
|
739
|
-
return _render_mcp_tool_result_for_assistant(output)
|
|
740
|
-
|
|
741
|
-
def render_tool_use_message(self, input_data: BaseModel, verbose: bool = False) -> str:
|
|
742
|
-
args = input_data.model_dump(exclude_none=True)
|
|
743
|
-
arg_preview = json.dumps(args) if verbose and args else ""
|
|
744
|
-
suffix = f" with args {arg_preview}" if arg_preview else ""
|
|
745
|
-
return f"MCP {self.server_name}:{self.tool_info.name}{suffix}"
|
|
746
|
-
|
|
747
|
-
def user_facing_name(self) -> str:
|
|
748
|
-
return self._user_facing
|
|
749
|
-
|
|
750
|
-
async def call(
|
|
751
|
-
self,
|
|
752
|
-
input_data: BaseModel,
|
|
753
|
-
context: ToolUseContext,
|
|
754
|
-
) -> AsyncGenerator[ToolOutput, None]:
|
|
755
|
-
runtime = await ensure_mcp_runtime(self.project_path)
|
|
756
|
-
session = runtime.sessions.get(self.server_name) if runtime else None
|
|
757
|
-
if not session:
|
|
758
|
-
result = McpToolCallOutput(
|
|
759
|
-
server=self.server_name,
|
|
760
|
-
tool=self.tool_info.name,
|
|
761
|
-
content=None,
|
|
762
|
-
text=None,
|
|
763
|
-
content_blocks=None,
|
|
764
|
-
structured_content=None,
|
|
765
|
-
is_error=True,
|
|
766
|
-
)
|
|
767
|
-
yield ToolResult(
|
|
768
|
-
data=result,
|
|
769
|
-
result_for_assistant=f"MCP server '{self.server_name}' is not connected.",
|
|
770
|
-
)
|
|
771
|
-
return
|
|
772
|
-
|
|
773
|
-
try:
|
|
774
|
-
args = input_data.model_dump(exclude_none=True)
|
|
775
|
-
call_result = await session.call_tool(
|
|
776
|
-
self.tool_info.name,
|
|
777
|
-
args or {},
|
|
778
|
-
)
|
|
779
|
-
raw_blocks = getattr(call_result, "content", None)
|
|
780
|
-
content_blocks = _normalize_content_blocks(raw_blocks)
|
|
781
|
-
content_text = _render_content_blocks(content_blocks) if content_blocks else None
|
|
782
|
-
structured = (
|
|
783
|
-
call_result.structuredContent if hasattr(call_result, "structuredContent") else None
|
|
784
|
-
)
|
|
785
|
-
assistant_text = content_text
|
|
786
|
-
if structured:
|
|
787
|
-
assistant_text = (assistant_text + "\n" if assistant_text else "") + json.dumps(
|
|
788
|
-
structured, indent=2
|
|
789
|
-
)
|
|
790
|
-
output = McpToolCallOutput(
|
|
791
|
-
server=self.server_name,
|
|
792
|
-
tool=self.tool_info.name,
|
|
793
|
-
content=assistant_text or None,
|
|
794
|
-
text=content_text,
|
|
795
|
-
content_blocks=content_blocks,
|
|
796
|
-
structured_content=structured,
|
|
797
|
-
is_error=getattr(call_result, "isError", False),
|
|
798
|
-
)
|
|
799
|
-
base_result_text = self.render_result_for_assistant(output)
|
|
800
|
-
warning_text, error_text, token_estimate = _evaluate_mcp_output_size(
|
|
801
|
-
base_result_text, self.server_name, self.tool_info.name
|
|
802
|
-
)
|
|
803
|
-
|
|
804
|
-
if error_text:
|
|
805
|
-
limited_output = McpToolCallOutput(
|
|
806
|
-
server=self.server_name,
|
|
807
|
-
tool=self.tool_info.name,
|
|
808
|
-
content=None,
|
|
809
|
-
text=None,
|
|
810
|
-
content_blocks=None,
|
|
811
|
-
structured_content=None,
|
|
812
|
-
is_error=True,
|
|
813
|
-
token_estimate=token_estimate,
|
|
814
|
-
warning=None,
|
|
815
|
-
)
|
|
816
|
-
yield ToolResult(data=limited_output, result_for_assistant=error_text)
|
|
817
|
-
return
|
|
818
|
-
|
|
819
|
-
annotated_output = output.model_copy(
|
|
820
|
-
update={"token_estimate": token_estimate, "warning": warning_text}
|
|
821
|
-
)
|
|
822
|
-
|
|
823
|
-
final_text = base_result_text or ""
|
|
824
|
-
if not final_text and warning_text:
|
|
825
|
-
final_text = warning_text
|
|
826
|
-
|
|
827
|
-
yield ToolResult(
|
|
828
|
-
data=annotated_output,
|
|
829
|
-
result_for_assistant=final_text,
|
|
830
|
-
)
|
|
831
|
-
except Exception as exc: # pragma: no cover - runtime errors
|
|
832
|
-
output = McpToolCallOutput(
|
|
833
|
-
server=self.server_name,
|
|
834
|
-
tool=self.tool_info.name,
|
|
835
|
-
content=None,
|
|
836
|
-
text=None,
|
|
837
|
-
content_blocks=None,
|
|
838
|
-
structured_content=None,
|
|
839
|
-
is_error=True,
|
|
840
|
-
)
|
|
841
|
-
logger.exception(
|
|
842
|
-
"Error calling MCP tool",
|
|
843
|
-
extra={
|
|
844
|
-
"server": self.server_name,
|
|
845
|
-
"tool": self.tool_info.name,
|
|
846
|
-
"error": str(exc),
|
|
847
|
-
},
|
|
848
|
-
)
|
|
849
|
-
yield ToolResult(
|
|
850
|
-
data=output,
|
|
851
|
-
result_for_assistant=f"Error calling MCP tool '{self.tool_info.name}' on '{self.server_name}': {exc}",
|
|
852
|
-
)
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
def _build_dynamic_mcp_tools(runtime: Optional[Any]) -> List[DynamicMcpTool]:
|
|
856
|
-
if not runtime or not getattr(runtime, "servers", None):
|
|
857
|
-
return []
|
|
858
|
-
tools: List[DynamicMcpTool] = []
|
|
859
|
-
for server in runtime.servers:
|
|
860
|
-
if getattr(server, "status", "") != "connected":
|
|
861
|
-
continue
|
|
862
|
-
if not getattr(server, "tools", None):
|
|
863
|
-
continue
|
|
864
|
-
for tool in server.tools:
|
|
865
|
-
tools.append(
|
|
866
|
-
DynamicMcpTool(server.name, tool, getattr(runtime, "project_path", Path.cwd()))
|
|
867
|
-
)
|
|
868
|
-
return tools
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
def load_dynamic_mcp_tools_sync(project_path: Optional[Path] = None) -> List[DynamicMcpTool]:
|
|
872
|
-
"""Best-effort synchronous loader for MCP tools."""
|
|
873
|
-
runtime = get_existing_mcp_runtime()
|
|
874
|
-
if runtime and not getattr(runtime, "_closed", False):
|
|
875
|
-
return _build_dynamic_mcp_tools(runtime)
|
|
876
|
-
|
|
877
|
-
try:
|
|
878
|
-
loop = asyncio.get_running_loop()
|
|
879
|
-
if loop.is_running():
|
|
880
|
-
return []
|
|
881
|
-
except RuntimeError:
|
|
882
|
-
pass
|
|
883
|
-
|
|
884
|
-
async def _load_and_cleanup() -> List[DynamicMcpTool]:
|
|
885
|
-
runtime = await ensure_mcp_runtime(project_path)
|
|
886
|
-
try:
|
|
887
|
-
return _build_dynamic_mcp_tools(runtime)
|
|
888
|
-
finally:
|
|
889
|
-
# Close the runtime inside the same event loop to avoid asyncgen
|
|
890
|
-
# shutdown errors when asyncio.run tears down the loop.
|
|
891
|
-
await shutdown_mcp_runtime()
|
|
892
|
-
|
|
893
|
-
try:
|
|
894
|
-
return asyncio.run(_load_and_cleanup())
|
|
895
|
-
except Exception as exc: # pragma: no cover - SDK/runtime failures
|
|
896
|
-
logger.exception(
|
|
897
|
-
"Failed to initialize MCP runtime for dynamic tools (sync)",
|
|
898
|
-
extra={"error": str(exc)},
|
|
899
|
-
)
|
|
900
|
-
return []
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
async def load_dynamic_mcp_tools_async(project_path: Optional[Path] = None) -> List[DynamicMcpTool]:
|
|
904
|
-
"""Async loader for MCP tools when already in an event loop."""
|
|
905
|
-
try:
|
|
906
|
-
runtime = await ensure_mcp_runtime(project_path)
|
|
907
|
-
except Exception as exc: # pragma: no cover - SDK/runtime failures
|
|
908
|
-
logger.exception(
|
|
909
|
-
"Failed to initialize MCP runtime for dynamic tools (async)",
|
|
910
|
-
extra={"error": str(exc)},
|
|
911
|
-
)
|
|
912
|
-
return []
|
|
913
|
-
return _build_dynamic_mcp_tools(runtime)
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
def merge_tools_with_dynamic(base_tools: List[Any], dynamic_tools: List[Any]) -> List[Any]:
|
|
917
|
-
"""Merge dynamic MCP tools into the existing tool list and rebuild the Task tool."""
|
|
918
|
-
from ripperdoc.tools.task_tool import TaskTool # Local import to avoid cycles
|
|
919
|
-
|
|
920
|
-
base_without_task = [tool for tool in base_tools if getattr(tool, "name", None) != "Task"]
|
|
921
|
-
existing_names = {getattr(tool, "name", None) for tool in base_without_task}
|
|
922
|
-
|
|
923
|
-
for tool in dynamic_tools:
|
|
924
|
-
if getattr(tool, "name", None) in existing_names:
|
|
925
|
-
continue
|
|
926
|
-
base_without_task.append(tool)
|
|
927
|
-
existing_names.add(getattr(tool, "name", None))
|
|
928
|
-
|
|
929
|
-
task_tool = TaskTool(lambda: base_without_task)
|
|
930
|
-
return base_without_task + [task_tool]
|
|
931
|
-
|
|
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
|
+
)
|
|
932
581
|
|
|
933
582
|
__all__ = [
|
|
934
583
|
"ListMcpServersTool",
|
|
935
584
|
"ListMcpResourcesTool",
|
|
936
585
|
"ReadMcpResourceTool",
|
|
937
586
|
"DynamicMcpTool",
|
|
587
|
+
"McpToolCallOutput",
|
|
938
588
|
"load_dynamic_mcp_tools_async",
|
|
939
589
|
"load_dynamic_mcp_tools_sync",
|
|
940
590
|
"merge_tools_with_dynamic",
|