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,591 @@
|
|
|
1
|
+
"""MCP-related tools for listing servers, resources, and invoking MCP tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
from typing import Any, AsyncGenerator, List, Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from ripperdoc.core.tool import (
|
|
15
|
+
Tool,
|
|
16
|
+
ToolUseContext,
|
|
17
|
+
ToolResult,
|
|
18
|
+
ToolOutput,
|
|
19
|
+
ValidationResult,
|
|
20
|
+
)
|
|
21
|
+
from ripperdoc.utils.log import get_logger
|
|
22
|
+
from ripperdoc.utils.mcp import (
|
|
23
|
+
McpResourceInfo,
|
|
24
|
+
McpServerInfo,
|
|
25
|
+
ensure_mcp_runtime,
|
|
26
|
+
find_mcp_resource,
|
|
27
|
+
format_mcp_instructions,
|
|
28
|
+
load_mcp_servers_async,
|
|
29
|
+
)
|
|
30
|
+
from ripperdoc.utils.token_estimation import estimate_tokens
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
logger = get_logger()
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
import mcp.types as mcp_types # type: ignore
|
|
37
|
+
except (ImportError, ModuleNotFoundError): # pragma: no cover - SDK may be missing at runtime
|
|
38
|
+
mcp_types = None # type: ignore[assignment]
|
|
39
|
+
logger.debug("[mcp_tools] MCP SDK unavailable during import")
|
|
40
|
+
|
|
41
|
+
DEFAULT_MAX_MCP_OUTPUT_TOKENS = 25_000
|
|
42
|
+
MIN_MCP_OUTPUT_TOKENS = 1_000
|
|
43
|
+
DEFAULT_MCP_WARNING_FRACTION = 0.8
|
|
44
|
+
|
|
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)
|
|
103
|
+
)
|
|
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."
|
|
124
|
+
)
|
|
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
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ListMcpServersInput(BaseModel):
|
|
138
|
+
"""Input for listing MCP servers."""
|
|
139
|
+
|
|
140
|
+
server: Optional[str] = Field(default=None, description="Optional server name to filter")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ListMcpServersOutput(BaseModel):
|
|
144
|
+
"""Server summary."""
|
|
145
|
+
|
|
146
|
+
servers: List[dict]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ListMcpServersTool(BaseMcpTool, Tool[ListMcpServersInput, ListMcpServersOutput]):
|
|
150
|
+
"""List configured MCP servers and their tools."""
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def name(self) -> str:
|
|
154
|
+
return "ListMcpServers"
|
|
155
|
+
|
|
156
|
+
async def description(self) -> str:
|
|
157
|
+
return "List configured MCP servers and their available tools."
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def input_schema(self) -> type[ListMcpServersInput]:
|
|
161
|
+
return ListMcpServersInput
|
|
162
|
+
|
|
163
|
+
async def prompt(self, _safe_mode: bool = False) -> str:
|
|
164
|
+
servers = await load_mcp_servers_async()
|
|
165
|
+
return format_mcp_instructions(servers)
|
|
166
|
+
|
|
167
|
+
def render_result_for_assistant(self, output: ListMcpServersOutput) -> str:
|
|
168
|
+
if not output.servers:
|
|
169
|
+
return "No MCP servers configured."
|
|
170
|
+
lines = ["Configured MCP servers:"]
|
|
171
|
+
for server in output.servers:
|
|
172
|
+
name = server.get("name", "unknown")
|
|
173
|
+
status = server.get("status", "unknown")
|
|
174
|
+
tool_names = server.get("tools", [])
|
|
175
|
+
tool_part = ", ".join(tool_names) if tool_names else "no tools"
|
|
176
|
+
lines.append(f"- {name} ({status}) tools: {tool_part}")
|
|
177
|
+
return "\n".join(lines)
|
|
178
|
+
|
|
179
|
+
def render_tool_use_message(
|
|
180
|
+
self, input_data: ListMcpServersInput, _verbose: bool = False
|
|
181
|
+
) -> str:
|
|
182
|
+
return f"List MCP servers{f' for {input_data.server}' if input_data.server else ''}"
|
|
183
|
+
|
|
184
|
+
async def call(
|
|
185
|
+
self,
|
|
186
|
+
input_data: ListMcpServersInput,
|
|
187
|
+
_context: ToolUseContext,
|
|
188
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
189
|
+
runtime = await ensure_mcp_runtime()
|
|
190
|
+
servers: List[McpServerInfo] = runtime.servers
|
|
191
|
+
if input_data.server:
|
|
192
|
+
servers = [s for s in servers if s.name == input_data.server]
|
|
193
|
+
|
|
194
|
+
payload = []
|
|
195
|
+
for server in servers:
|
|
196
|
+
payload.append(
|
|
197
|
+
{
|
|
198
|
+
"name": server.name,
|
|
199
|
+
"status": server.status,
|
|
200
|
+
"command": server.command,
|
|
201
|
+
"args": server.args,
|
|
202
|
+
"tools": [tool.name for tool in server.tools],
|
|
203
|
+
"resources": [resource.uri for resource in server.resources],
|
|
204
|
+
"error": server.error,
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
yield ToolResult(
|
|
209
|
+
data=ListMcpServersOutput(servers=payload),
|
|
210
|
+
result_for_assistant=self.render_result_for_assistant(
|
|
211
|
+
ListMcpServersOutput(servers=payload)
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class ListMcpResourcesInput(BaseModel):
|
|
217
|
+
"""Input for listing MCP resources."""
|
|
218
|
+
|
|
219
|
+
server: Optional[str] = Field(default=None, description="Optional server name to filter")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class ListMcpResourcesOutput(BaseModel):
|
|
223
|
+
resources: List[dict]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class ListMcpResourcesTool(BaseMcpTool, Tool[ListMcpResourcesInput, ListMcpResourcesOutput]):
|
|
227
|
+
"""List resources exposed by MCP servers."""
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def name(self) -> str:
|
|
231
|
+
return "ListMcpResources"
|
|
232
|
+
|
|
233
|
+
async def description(self) -> str:
|
|
234
|
+
return (
|
|
235
|
+
"Lists available resources from configured MCP servers.\n"
|
|
236
|
+
"Each resource object includes a 'server' field indicating which server it's from.\n\n"
|
|
237
|
+
"Usage examples:\n"
|
|
238
|
+
"- List all resources from all servers: `listMcpResources`\n"
|
|
239
|
+
'- List resources from a specific server: `listMcpResources({ server: "myserver" })`'
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def input_schema(self) -> type[ListMcpResourcesInput]:
|
|
244
|
+
return ListMcpResourcesInput
|
|
245
|
+
|
|
246
|
+
async def prompt(self, _safe_mode: bool = False) -> str:
|
|
247
|
+
return (
|
|
248
|
+
"List available resources from configured MCP servers.\n"
|
|
249
|
+
"Each returned resource will include all standard MCP resource fields plus a 'server' field\n"
|
|
250
|
+
"indicating which server the resource belongs to.\n\n"
|
|
251
|
+
"Parameters:\n"
|
|
252
|
+
"- server (optional): The name of a specific MCP server to get resources from. If not provided,\n"
|
|
253
|
+
" resources from all servers will be returned."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
async def validate_input(
|
|
257
|
+
self, input_data: ListMcpResourcesInput, _context: Optional[ToolUseContext] = None
|
|
258
|
+
) -> ValidationResult:
|
|
259
|
+
if input_data.server:
|
|
260
|
+
return await self.validate_server_exists(input_data.server)
|
|
261
|
+
return ValidationResult(result=True)
|
|
262
|
+
|
|
263
|
+
def render_result_for_assistant(self, output: ListMcpResourcesOutput) -> str:
|
|
264
|
+
if not output.resources:
|
|
265
|
+
return "No MCP resources found."
|
|
266
|
+
try:
|
|
267
|
+
return json.dumps(output.resources, indent=2, ensure_ascii=False)
|
|
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
|
+
)
|
|
273
|
+
return str(output.resources)
|
|
274
|
+
|
|
275
|
+
def render_tool_use_message(
|
|
276
|
+
self, input_data: ListMcpResourcesInput, _verbose: bool = False
|
|
277
|
+
) -> str:
|
|
278
|
+
return f"List MCP resources{f' for {input_data.server}' if input_data.server else ''}"
|
|
279
|
+
|
|
280
|
+
async def call(
|
|
281
|
+
self,
|
|
282
|
+
input_data: ListMcpResourcesInput,
|
|
283
|
+
_context: ToolUseContext,
|
|
284
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
285
|
+
runtime = await ensure_mcp_runtime()
|
|
286
|
+
servers = runtime.servers
|
|
287
|
+
resources: List[dict] = []
|
|
288
|
+
for server in servers:
|
|
289
|
+
if input_data.server and server.name != input_data.server:
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
session = runtime.sessions.get(server.name) if runtime else None
|
|
293
|
+
fetched: List[McpResourceInfo] = []
|
|
294
|
+
|
|
295
|
+
if (
|
|
296
|
+
session
|
|
297
|
+
and mcp_types
|
|
298
|
+
and getattr(session, "list_resources", None)
|
|
299
|
+
and server.capabilities.get("resources", False)
|
|
300
|
+
):
|
|
301
|
+
try:
|
|
302
|
+
response = await session.list_resources()
|
|
303
|
+
fetched = [
|
|
304
|
+
McpResourceInfo(
|
|
305
|
+
uri=str(res.uri),
|
|
306
|
+
name=getattr(res, "name", None),
|
|
307
|
+
description=getattr(res, "description", "") or "",
|
|
308
|
+
mime_type=getattr(res, "mimeType", None),
|
|
309
|
+
size=getattr(res, "size", None),
|
|
310
|
+
)
|
|
311
|
+
for res in response.resources
|
|
312
|
+
]
|
|
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},
|
|
319
|
+
)
|
|
320
|
+
fetched = []
|
|
321
|
+
|
|
322
|
+
candidate_resources = fetched if fetched else server.resources
|
|
323
|
+
|
|
324
|
+
for resource in candidate_resources:
|
|
325
|
+
resources.append(
|
|
326
|
+
{
|
|
327
|
+
"server": server.name,
|
|
328
|
+
"uri": getattr(resource, "uri", None),
|
|
329
|
+
"name": getattr(resource, "name", None),
|
|
330
|
+
"description": getattr(resource, "description", None),
|
|
331
|
+
"mime_type": getattr(resource, "mime_type", None),
|
|
332
|
+
"size": getattr(resource, "size", None),
|
|
333
|
+
}
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
result = ListMcpResourcesOutput(resources=resources)
|
|
337
|
+
yield ToolResult(
|
|
338
|
+
data=result,
|
|
339
|
+
result_for_assistant=self.render_result_for_assistant(result),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class ReadMcpResourceInput(BaseModel):
|
|
344
|
+
"""Input for reading a single MCP resource."""
|
|
345
|
+
|
|
346
|
+
server: str = Field(description="Server name")
|
|
347
|
+
uri: str = Field(description="Resource URI")
|
|
348
|
+
save_blobs: bool = Field(
|
|
349
|
+
default=False,
|
|
350
|
+
description="If true, binary resource contents will be written to a temporary file in addition to Base64.",
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class ResourceContentPart(BaseModel):
|
|
355
|
+
"""Structured representation for resource content blocks."""
|
|
356
|
+
|
|
357
|
+
type: str
|
|
358
|
+
uri: Optional[str] = None
|
|
359
|
+
mime_type: Optional[str] = None
|
|
360
|
+
text: Optional[str] = None
|
|
361
|
+
size: Optional[int] = None
|
|
362
|
+
base64_data: Optional[str] = None
|
|
363
|
+
saved_path: Optional[str] = None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class ReadMcpResourceOutput(BaseModel):
|
|
367
|
+
server: str
|
|
368
|
+
uri: str
|
|
369
|
+
content: Optional[str] = None
|
|
370
|
+
contents: List[ResourceContentPart] = Field(default_factory=list)
|
|
371
|
+
token_estimate: Optional[int] = None
|
|
372
|
+
warning: Optional[str] = None
|
|
373
|
+
is_error: bool = False
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class ReadMcpResourceTool(BaseMcpTool, Tool[ReadMcpResourceInput, ReadMcpResourceOutput]):
|
|
377
|
+
"""Read a resource defined in MCP configuration."""
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def name(self) -> str:
|
|
381
|
+
return "ReadMcpResource"
|
|
382
|
+
|
|
383
|
+
async def description(self) -> str:
|
|
384
|
+
return (
|
|
385
|
+
"Reads a specific resource from an MCP server.\n"
|
|
386
|
+
"- server: The name of the MCP server to read from\n"
|
|
387
|
+
"- uri: The URI of the resource to read\n"
|
|
388
|
+
"- save_blobs: Save binary content to a temporary file in addition to Base64 output\n\n"
|
|
389
|
+
"Usage examples:\n"
|
|
390
|
+
'- Read a resource from a server: `readMcpResource({ server: "myserver", uri: "my-resource-uri" })`'
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
@property
|
|
394
|
+
def input_schema(self) -> type[ReadMcpResourceInput]:
|
|
395
|
+
return ReadMcpResourceInput
|
|
396
|
+
|
|
397
|
+
async def prompt(self, _safe_mode: bool = False) -> str:
|
|
398
|
+
return (
|
|
399
|
+
"Reads a specific resource from an MCP server, identified by server name and resource URI.\n\n"
|
|
400
|
+
"Parameters:\n"
|
|
401
|
+
"- server (required): The name of the MCP server from which to read the resource\n"
|
|
402
|
+
"- uri (required): The URI of the resource to read\n"
|
|
403
|
+
"- save_blobs (optional): If true, write binary content to a temporary file and include the path"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
async def validate_input(
|
|
407
|
+
self, input_data: ReadMcpResourceInput, _context: Optional[ToolUseContext] = None
|
|
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
|
|
415
|
+
runtime = await ensure_mcp_runtime()
|
|
416
|
+
resource = find_mcp_resource(runtime.servers, input_data.server, input_data.uri)
|
|
417
|
+
if not resource:
|
|
418
|
+
return ValidationResult(
|
|
419
|
+
result=False,
|
|
420
|
+
message=f"Resource '{input_data.uri}' not found on server '{input_data.server}'.",
|
|
421
|
+
)
|
|
422
|
+
return ValidationResult(result=True)
|
|
423
|
+
|
|
424
|
+
def render_result_for_assistant(self, output: ReadMcpResourceOutput) -> str:
|
|
425
|
+
if output.contents:
|
|
426
|
+
texts = [part.text for part in output.contents if part.text]
|
|
427
|
+
blob_notes = [
|
|
428
|
+
f"[binary {part.mime_type or 'unknown'} {part.size or 'unknown'} bytes{f'; saved to {part.saved_path}' if part.saved_path else ''}]"
|
|
429
|
+
for part in output.contents
|
|
430
|
+
if part.type == "blob"
|
|
431
|
+
]
|
|
432
|
+
if texts or blob_notes:
|
|
433
|
+
return "\n".join([*texts, *blob_notes]).strip()
|
|
434
|
+
if not output.content:
|
|
435
|
+
return f"MCP resource {output.uri} on {output.server} has no content."
|
|
436
|
+
return output.content
|
|
437
|
+
|
|
438
|
+
def render_tool_use_message(
|
|
439
|
+
self, input_data: ReadMcpResourceInput, _verbose: bool = False
|
|
440
|
+
) -> str:
|
|
441
|
+
return f"Read MCP resource {input_data.uri} from {input_data.server}"
|
|
442
|
+
|
|
443
|
+
async def call(
|
|
444
|
+
self,
|
|
445
|
+
input_data: ReadMcpResourceInput,
|
|
446
|
+
_context: ToolUseContext,
|
|
447
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
448
|
+
runtime = await ensure_mcp_runtime()
|
|
449
|
+
session = runtime.sessions.get(input_data.server) if runtime else None
|
|
450
|
+
|
|
451
|
+
content_text = None
|
|
452
|
+
parts: List[ResourceContentPart] = []
|
|
453
|
+
|
|
454
|
+
if session and mcp_types:
|
|
455
|
+
try:
|
|
456
|
+
# Convert string to AnyUrl
|
|
457
|
+
from mcp.types import AnyUrl
|
|
458
|
+
|
|
459
|
+
uri = AnyUrl(input_data.uri)
|
|
460
|
+
result = await session.read_resource(uri)
|
|
461
|
+
for item in result.contents:
|
|
462
|
+
if isinstance(item, mcp_types.TextResourceContents):
|
|
463
|
+
parts.append(
|
|
464
|
+
ResourceContentPart(
|
|
465
|
+
type="text",
|
|
466
|
+
uri=getattr(item, "uri", None),
|
|
467
|
+
mime_type=getattr(item, "mimeType", None),
|
|
468
|
+
text=item.text,
|
|
469
|
+
)
|
|
470
|
+
)
|
|
471
|
+
elif isinstance(item, mcp_types.BlobResourceContents):
|
|
472
|
+
blob_data = getattr(item, "blob", None)
|
|
473
|
+
base64_data = None
|
|
474
|
+
saved_path = None
|
|
475
|
+
if isinstance(blob_data, (bytes, bytearray)):
|
|
476
|
+
base64_data = base64.b64encode(blob_data).decode("ascii")
|
|
477
|
+
raw_bytes = blob_data
|
|
478
|
+
elif isinstance(blob_data, str):
|
|
479
|
+
base64_data = blob_data
|
|
480
|
+
try:
|
|
481
|
+
raw_bytes = base64.b64decode(blob_data)
|
|
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,
|
|
486
|
+
extra={"server": input_data.server, "uri": input_data.uri},
|
|
487
|
+
)
|
|
488
|
+
raw_bytes = None
|
|
489
|
+
else:
|
|
490
|
+
raw_bytes = None
|
|
491
|
+
|
|
492
|
+
if input_data.save_blobs and raw_bytes:
|
|
493
|
+
suffix = ""
|
|
494
|
+
mime = getattr(item, "mimeType", None) or ""
|
|
495
|
+
if "/" in mime:
|
|
496
|
+
suffix = "." + mime.split("/")[-1]
|
|
497
|
+
fd, path = tempfile.mkstemp(prefix="ripperdoc_mcp_", suffix=suffix)
|
|
498
|
+
with os.fdopen(fd, "wb") as handle:
|
|
499
|
+
handle.write(raw_bytes)
|
|
500
|
+
saved_path = path
|
|
501
|
+
|
|
502
|
+
parts.append(
|
|
503
|
+
ResourceContentPart(
|
|
504
|
+
type="blob",
|
|
505
|
+
uri=getattr(item, "uri", None),
|
|
506
|
+
mime_type=getattr(item, "mimeType", None),
|
|
507
|
+
size=getattr(item, "size", None),
|
|
508
|
+
base64_data=base64_data,
|
|
509
|
+
saved_path=saved_path,
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
text_parts = [p.text for p in parts if p.text]
|
|
513
|
+
content_text = "\n".join([p for p in text_parts if p]) or None
|
|
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},
|
|
520
|
+
)
|
|
521
|
+
content_text = f"Error reading MCP resource: {exc}"
|
|
522
|
+
else:
|
|
523
|
+
resource = find_mcp_resource(runtime.servers, input_data.server, input_data.uri)
|
|
524
|
+
content_text = resource.text if resource else None
|
|
525
|
+
if resource:
|
|
526
|
+
parts.append(
|
|
527
|
+
ResourceContentPart(
|
|
528
|
+
type="text",
|
|
529
|
+
uri=resource.uri,
|
|
530
|
+
mime_type=resource.mime_type,
|
|
531
|
+
text=resource.text,
|
|
532
|
+
size=resource.size,
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
read_result: Any = ReadMcpResourceOutput(
|
|
537
|
+
server=input_data.server, uri=input_data.uri, content=content_text, contents=parts
|
|
538
|
+
)
|
|
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}"
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
if error_text:
|
|
545
|
+
limited_result = ReadMcpResourceOutput(
|
|
546
|
+
server=input_data.server,
|
|
547
|
+
uri=input_data.uri,
|
|
548
|
+
content=None,
|
|
549
|
+
contents=[],
|
|
550
|
+
token_estimate=token_estimate,
|
|
551
|
+
warning=None,
|
|
552
|
+
is_error=True,
|
|
553
|
+
)
|
|
554
|
+
yield ToolResult(data=limited_result, result_for_assistant=error_text)
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
annotated_result = read_result.model_copy(
|
|
558
|
+
update={"token_estimate": token_estimate, "warning": warning_text}
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
final_text = assistant_text or ""
|
|
562
|
+
if not final_text and warning_text:
|
|
563
|
+
final_text = warning_text
|
|
564
|
+
|
|
565
|
+
yield ToolResult(
|
|
566
|
+
data=annotated_result,
|
|
567
|
+
result_for_assistant=final_text, # type: ignore[arg-type]
|
|
568
|
+
)
|
|
569
|
+
|
|
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
|
+
)
|
|
581
|
+
|
|
582
|
+
__all__ = [
|
|
583
|
+
"ListMcpServersTool",
|
|
584
|
+
"ListMcpResourcesTool",
|
|
585
|
+
"ReadMcpResourceTool",
|
|
586
|
+
"DynamicMcpTool",
|
|
587
|
+
"McpToolCallOutput",
|
|
588
|
+
"load_dynamic_mcp_tools_async",
|
|
589
|
+
"load_dynamic_mcp_tools_sync",
|
|
590
|
+
"merge_tools_with_dynamic",
|
|
591
|
+
]
|