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.
Files changed (107) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +20 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +405 -0
  5. ripperdoc/cli/commands/__init__.py +82 -0
  6. ripperdoc/cli/commands/agents_cmd.py +263 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +23 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +144 -0
  12. ripperdoc/cli/commands/cost_cmd.py +82 -0
  13. ripperdoc/cli/commands/doctor_cmd.py +221 -0
  14. ripperdoc/cli/commands/exit_cmd.py +19 -0
  15. ripperdoc/cli/commands/help_cmd.py +20 -0
  16. ripperdoc/cli/commands/mcp_cmd.py +70 -0
  17. ripperdoc/cli/commands/memory_cmd.py +202 -0
  18. ripperdoc/cli/commands/models_cmd.py +413 -0
  19. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  20. ripperdoc/cli/commands/resume_cmd.py +98 -0
  21. ripperdoc/cli/commands/status_cmd.py +167 -0
  22. ripperdoc/cli/commands/tasks_cmd.py +278 -0
  23. ripperdoc/cli/commands/todos_cmd.py +69 -0
  24. ripperdoc/cli/commands/tools_cmd.py +19 -0
  25. ripperdoc/cli/ui/__init__.py +1 -0
  26. ripperdoc/cli/ui/context_display.py +298 -0
  27. ripperdoc/cli/ui/helpers.py +22 -0
  28. ripperdoc/cli/ui/rich_ui.py +1557 -0
  29. ripperdoc/cli/ui/spinner.py +49 -0
  30. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  31. ripperdoc/cli/ui/tool_renderers.py +298 -0
  32. ripperdoc/core/__init__.py +1 -0
  33. ripperdoc/core/agents.py +486 -0
  34. ripperdoc/core/commands.py +33 -0
  35. ripperdoc/core/config.py +559 -0
  36. ripperdoc/core/default_tools.py +88 -0
  37. ripperdoc/core/permissions.py +252 -0
  38. ripperdoc/core/providers/__init__.py +47 -0
  39. ripperdoc/core/providers/anthropic.py +250 -0
  40. ripperdoc/core/providers/base.py +265 -0
  41. ripperdoc/core/providers/gemini.py +615 -0
  42. ripperdoc/core/providers/openai.py +487 -0
  43. ripperdoc/core/query.py +1058 -0
  44. ripperdoc/core/query_utils.py +622 -0
  45. ripperdoc/core/skills.py +295 -0
  46. ripperdoc/core/system_prompt.py +431 -0
  47. ripperdoc/core/tool.py +240 -0
  48. ripperdoc/sdk/__init__.py +9 -0
  49. ripperdoc/sdk/client.py +333 -0
  50. ripperdoc/tools/__init__.py +1 -0
  51. ripperdoc/tools/ask_user_question_tool.py +431 -0
  52. ripperdoc/tools/background_shell.py +389 -0
  53. ripperdoc/tools/bash_output_tool.py +98 -0
  54. ripperdoc/tools/bash_tool.py +1016 -0
  55. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  56. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  57. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  58. ripperdoc/tools/file_edit_tool.py +346 -0
  59. ripperdoc/tools/file_read_tool.py +203 -0
  60. ripperdoc/tools/file_write_tool.py +205 -0
  61. ripperdoc/tools/glob_tool.py +179 -0
  62. ripperdoc/tools/grep_tool.py +370 -0
  63. ripperdoc/tools/kill_bash_tool.py +136 -0
  64. ripperdoc/tools/ls_tool.py +471 -0
  65. ripperdoc/tools/mcp_tools.py +591 -0
  66. ripperdoc/tools/multi_edit_tool.py +456 -0
  67. ripperdoc/tools/notebook_edit_tool.py +386 -0
  68. ripperdoc/tools/skill_tool.py +205 -0
  69. ripperdoc/tools/task_tool.py +379 -0
  70. ripperdoc/tools/todo_tool.py +494 -0
  71. ripperdoc/tools/tool_search_tool.py +380 -0
  72. ripperdoc/utils/__init__.py +1 -0
  73. ripperdoc/utils/bash_constants.py +51 -0
  74. ripperdoc/utils/bash_output_utils.py +43 -0
  75. ripperdoc/utils/coerce.py +34 -0
  76. ripperdoc/utils/context_length_errors.py +252 -0
  77. ripperdoc/utils/exit_code_handlers.py +241 -0
  78. ripperdoc/utils/file_watch.py +135 -0
  79. ripperdoc/utils/git_utils.py +274 -0
  80. ripperdoc/utils/json_utils.py +27 -0
  81. ripperdoc/utils/log.py +176 -0
  82. ripperdoc/utils/mcp.py +560 -0
  83. ripperdoc/utils/memory.py +253 -0
  84. ripperdoc/utils/message_compaction.py +676 -0
  85. ripperdoc/utils/messages.py +519 -0
  86. ripperdoc/utils/output_utils.py +258 -0
  87. ripperdoc/utils/path_ignore.py +677 -0
  88. ripperdoc/utils/path_utils.py +46 -0
  89. ripperdoc/utils/permissions/__init__.py +27 -0
  90. ripperdoc/utils/permissions/path_validation_utils.py +174 -0
  91. ripperdoc/utils/permissions/shell_command_validation.py +552 -0
  92. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  93. ripperdoc/utils/prompt.py +17 -0
  94. ripperdoc/utils/safe_get_cwd.py +31 -0
  95. ripperdoc/utils/sandbox_utils.py +38 -0
  96. ripperdoc/utils/session_history.py +260 -0
  97. ripperdoc/utils/session_usage.py +117 -0
  98. ripperdoc/utils/shell_token_utils.py +95 -0
  99. ripperdoc/utils/shell_utils.py +159 -0
  100. ripperdoc/utils/todo.py +203 -0
  101. ripperdoc/utils/token_estimation.py +34 -0
  102. ripperdoc-0.2.6.dist-info/METADATA +193 -0
  103. ripperdoc-0.2.6.dist-info/RECORD +107 -0
  104. ripperdoc-0.2.6.dist-info/WHEEL +5 -0
  105. ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
  106. ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
  107. 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
+ ]