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
ripperdoc/utils/mcp.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""MCP configuration loader, connection manager, and prompt helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextvars
|
|
7
|
+
import json
|
|
8
|
+
import shlex
|
|
9
|
+
from contextlib import AsyncExitStack
|
|
10
|
+
from dataclasses import dataclass, field, replace
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from ripperdoc import __version__
|
|
15
|
+
from ripperdoc.utils.log import get_logger
|
|
16
|
+
from ripperdoc.utils.token_estimation import estimate_tokens
|
|
17
|
+
|
|
18
|
+
logger = get_logger()
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import mcp.types as mcp_types # type: ignore[import-not-found]
|
|
22
|
+
from mcp.client.session import ClientSession # type: ignore[import-not-found]
|
|
23
|
+
from mcp.client.sse import sse_client # type: ignore[import-not-found]
|
|
24
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client # type: ignore[import-not-found]
|
|
25
|
+
from mcp.client.streamable_http import streamablehttp_client # type: ignore[import-not-found]
|
|
26
|
+
|
|
27
|
+
MCP_AVAILABLE = True
|
|
28
|
+
except (ImportError, ModuleNotFoundError): # pragma: no cover - handled gracefully at runtime
|
|
29
|
+
MCP_AVAILABLE = False
|
|
30
|
+
ClientSession = object # type: ignore
|
|
31
|
+
mcp_types = None # type: ignore
|
|
32
|
+
logger.debug("[mcp] MCP SDK not available at import time")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class McpToolInfo:
|
|
37
|
+
name: str
|
|
38
|
+
description: str = ""
|
|
39
|
+
input_schema: Optional[Dict[str, Any]] = None
|
|
40
|
+
annotations: Dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class McpResourceInfo:
|
|
45
|
+
uri: str
|
|
46
|
+
name: Optional[str] = None
|
|
47
|
+
description: str = ""
|
|
48
|
+
mime_type: Optional[str] = None
|
|
49
|
+
size: Optional[int] = None
|
|
50
|
+
text: Optional[str] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class McpServerInfo:
|
|
55
|
+
name: str
|
|
56
|
+
type: str = "stdio"
|
|
57
|
+
url: Optional[str] = None
|
|
58
|
+
description: str = ""
|
|
59
|
+
command: Optional[str] = None
|
|
60
|
+
args: List[str] = field(default_factory=list)
|
|
61
|
+
env: Dict[str, str] = field(default_factory=dict)
|
|
62
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
63
|
+
tools: List[McpToolInfo] = field(default_factory=list)
|
|
64
|
+
resources: List[McpResourceInfo] = field(default_factory=list)
|
|
65
|
+
status: str = "configured"
|
|
66
|
+
error: Optional[str] = None
|
|
67
|
+
instructions: Optional[str] = None
|
|
68
|
+
server_version: Optional[str] = None
|
|
69
|
+
capabilities: Dict[str, Any] = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _load_json_file(path: Path) -> Dict[str, Any]:
|
|
73
|
+
if not path.exists():
|
|
74
|
+
return {}
|
|
75
|
+
try:
|
|
76
|
+
data = json.loads(path.read_text())
|
|
77
|
+
if isinstance(data, dict):
|
|
78
|
+
return data
|
|
79
|
+
return {}
|
|
80
|
+
except (OSError, json.JSONDecodeError):
|
|
81
|
+
logger.exception("Failed to load JSON", extra={"path": str(path)})
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _ensure_str_dict(raw: object) -> Dict[str, str]:
|
|
86
|
+
if not isinstance(raw, dict):
|
|
87
|
+
return {}
|
|
88
|
+
result: Dict[str, str] = {}
|
|
89
|
+
for key, value in raw.items():
|
|
90
|
+
try:
|
|
91
|
+
result[str(key)] = str(value)
|
|
92
|
+
except (TypeError, ValueError) as exc:
|
|
93
|
+
logger.warning(
|
|
94
|
+
"[mcp] Failed to coerce env/header value to string: %s: %s",
|
|
95
|
+
type(exc).__name__, exc,
|
|
96
|
+
extra={"key": key},
|
|
97
|
+
)
|
|
98
|
+
continue
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _normalize_command(raw_command: Any, raw_args: Any) -> tuple[Optional[str], List[str]]:
|
|
103
|
+
"""Normalize MCP server command/args.
|
|
104
|
+
|
|
105
|
+
Supports:
|
|
106
|
+
- command as list -> first element is executable, rest are args
|
|
107
|
+
- command as string with spaces -> shlex.split into executable/args (when args empty)
|
|
108
|
+
- command as plain string -> used as-is
|
|
109
|
+
"""
|
|
110
|
+
args: List[str] = []
|
|
111
|
+
if isinstance(raw_args, list):
|
|
112
|
+
args = [str(a) for a in raw_args]
|
|
113
|
+
|
|
114
|
+
# Command provided as list: treat first token as command.
|
|
115
|
+
if isinstance(raw_command, list):
|
|
116
|
+
tokens = [str(t) for t in raw_command if str(t)]
|
|
117
|
+
if not tokens:
|
|
118
|
+
return None, args
|
|
119
|
+
return tokens[0], tokens[1:] + args
|
|
120
|
+
|
|
121
|
+
if not isinstance(raw_command, str):
|
|
122
|
+
return None, args
|
|
123
|
+
|
|
124
|
+
command_str = raw_command.strip()
|
|
125
|
+
if not command_str:
|
|
126
|
+
return None, args
|
|
127
|
+
|
|
128
|
+
if not args and (" " in command_str or "\t" in command_str):
|
|
129
|
+
try:
|
|
130
|
+
tokens = shlex.split(command_str)
|
|
131
|
+
except ValueError:
|
|
132
|
+
tokens = [command_str]
|
|
133
|
+
if tokens:
|
|
134
|
+
return tokens[0], tokens[1:]
|
|
135
|
+
|
|
136
|
+
return command_str, args
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_server(name: str, raw: Dict[str, Any]) -> McpServerInfo:
|
|
140
|
+
server_type = str(raw.get("type") or raw.get("transport") or "").strip().lower()
|
|
141
|
+
command, args = _normalize_command(raw.get("command"), raw.get("args"))
|
|
142
|
+
url = str(raw.get("url") or raw.get("uri") or "").strip() or None
|
|
143
|
+
|
|
144
|
+
if not server_type:
|
|
145
|
+
if url:
|
|
146
|
+
server_type = "sse"
|
|
147
|
+
elif command:
|
|
148
|
+
server_type = "stdio"
|
|
149
|
+
else:
|
|
150
|
+
server_type = "stdio"
|
|
151
|
+
|
|
152
|
+
description = str(raw.get("description") or "")
|
|
153
|
+
env = _ensure_str_dict(raw.get("env"))
|
|
154
|
+
headers = _ensure_str_dict(raw.get("headers"))
|
|
155
|
+
instructions = raw.get("instructions")
|
|
156
|
+
|
|
157
|
+
return McpServerInfo(
|
|
158
|
+
name=name,
|
|
159
|
+
type=server_type,
|
|
160
|
+
url=url,
|
|
161
|
+
description=description,
|
|
162
|
+
command=command,
|
|
163
|
+
args=[str(a) for a in args] if args else [],
|
|
164
|
+
env=env,
|
|
165
|
+
headers=headers,
|
|
166
|
+
instructions=str(instructions) if isinstance(instructions, str) else None,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parse_servers(data: Dict[str, Any]) -> Dict[str, McpServerInfo]:
|
|
171
|
+
servers: Dict[str, McpServerInfo] = {}
|
|
172
|
+
for key in ("servers", "mcpServers"):
|
|
173
|
+
raw_servers = data.get(key)
|
|
174
|
+
if not isinstance(raw_servers, dict):
|
|
175
|
+
continue
|
|
176
|
+
for name, raw in raw_servers.items():
|
|
177
|
+
if not isinstance(raw, dict):
|
|
178
|
+
continue
|
|
179
|
+
server_name = str(name).strip()
|
|
180
|
+
if not server_name:
|
|
181
|
+
continue
|
|
182
|
+
servers[server_name] = _parse_server(server_name, raw)
|
|
183
|
+
return servers
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _load_server_configs(project_path: Optional[Path]) -> Dict[str, McpServerInfo]:
|
|
187
|
+
project_path = project_path or Path.cwd()
|
|
188
|
+
candidates = [
|
|
189
|
+
Path.home() / ".ripperdoc" / "mcp.json",
|
|
190
|
+
Path.home() / ".mcp.json",
|
|
191
|
+
project_path / ".ripperdoc" / "mcp.json",
|
|
192
|
+
project_path / ".mcp.json",
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
merged: Dict[str, McpServerInfo] = {}
|
|
196
|
+
for path in candidates:
|
|
197
|
+
data = _load_json_file(path)
|
|
198
|
+
merged.update(_parse_servers(data))
|
|
199
|
+
logger.debug(
|
|
200
|
+
"[mcp] Loaded MCP server configs",
|
|
201
|
+
extra={
|
|
202
|
+
"project_path": str(project_path),
|
|
203
|
+
"server_count": len(merged),
|
|
204
|
+
"candidates": [str(path) for path in candidates],
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
return merged
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class McpRuntime:
|
|
211
|
+
"""Manages live MCP connections for the current event loop."""
|
|
212
|
+
|
|
213
|
+
def __init__(self, project_path: Path):
|
|
214
|
+
self.project_path = project_path
|
|
215
|
+
self._exit_stack = AsyncExitStack()
|
|
216
|
+
self.sessions: Dict[str, ClientSession] = {}
|
|
217
|
+
self.servers: List[McpServerInfo] = []
|
|
218
|
+
self._closed = False
|
|
219
|
+
|
|
220
|
+
async def connect(self, configs: Dict[str, McpServerInfo]) -> List[McpServerInfo]:
|
|
221
|
+
logger.info(
|
|
222
|
+
"[mcp] Connecting to MCP servers",
|
|
223
|
+
extra={
|
|
224
|
+
"project_path": str(self.project_path),
|
|
225
|
+
"server_count": len(configs),
|
|
226
|
+
"servers": list(configs.keys()),
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
await self._exit_stack.__aenter__()
|
|
230
|
+
if not MCP_AVAILABLE:
|
|
231
|
+
for config in configs.values():
|
|
232
|
+
self.servers.append(
|
|
233
|
+
replace(
|
|
234
|
+
config,
|
|
235
|
+
status="unavailable",
|
|
236
|
+
error="MCP Python SDK not installed; install `mcp[cli]` with Python 3.10+.",
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
return self.servers
|
|
240
|
+
|
|
241
|
+
for config in configs.values():
|
|
242
|
+
self.servers.append(await self._connect_server(config))
|
|
243
|
+
logger.debug(
|
|
244
|
+
"[mcp] MCP connection summary",
|
|
245
|
+
extra={
|
|
246
|
+
"connected": [s.name for s in self.servers if s.status == "connected"],
|
|
247
|
+
"failed": [s.name for s in self.servers if s.status == "failed"],
|
|
248
|
+
"unavailable": [s.name for s in self.servers if s.status == "unavailable"],
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
return self.servers
|
|
252
|
+
|
|
253
|
+
async def _list_roots_callback(self, *_: Any, **__: Any) -> Optional[Any]:
|
|
254
|
+
if not mcp_types:
|
|
255
|
+
return None
|
|
256
|
+
return mcp_types.ListRootsResult(
|
|
257
|
+
roots=[mcp_types.Root(uri=Path(self.project_path).resolve().as_uri())] # type: ignore[arg-type]
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
async def _connect_server(self, config: McpServerInfo) -> McpServerInfo:
|
|
261
|
+
info = replace(config, tools=[], resources=[])
|
|
262
|
+
if not MCP_AVAILABLE or not mcp_types:
|
|
263
|
+
info.status = "unavailable"
|
|
264
|
+
info.error = "MCP Python SDK not installed."
|
|
265
|
+
return info
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
read_stream = None
|
|
269
|
+
write_stream = None
|
|
270
|
+
logger.debug(
|
|
271
|
+
"[mcp] Connecting server",
|
|
272
|
+
extra={
|
|
273
|
+
"server": config.name,
|
|
274
|
+
"type": config.type,
|
|
275
|
+
"command": config.command,
|
|
276
|
+
"url": config.url,
|
|
277
|
+
},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if config.type in ("sse", "sse-ide"):
|
|
281
|
+
if not config.url:
|
|
282
|
+
raise ValueError("SSE MCP server requires a 'url'.")
|
|
283
|
+
read_stream, write_stream = await self._exit_stack.enter_async_context(
|
|
284
|
+
sse_client(config.url, headers=config.headers or None)
|
|
285
|
+
)
|
|
286
|
+
elif config.type in ("http", "streamable-http"):
|
|
287
|
+
if not config.url:
|
|
288
|
+
raise ValueError("HTTP MCP server requires a 'url'.")
|
|
289
|
+
read_stream, write_stream, _ = await self._exit_stack.enter_async_context(
|
|
290
|
+
streamablehttp_client(
|
|
291
|
+
url=config.url,
|
|
292
|
+
headers=config.headers or None,
|
|
293
|
+
terminate_on_close=True,
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
if not config.command:
|
|
298
|
+
raise ValueError("Stdio MCP server requires a 'command'.")
|
|
299
|
+
stdio_params = StdioServerParameters(
|
|
300
|
+
command=config.command,
|
|
301
|
+
args=config.args,
|
|
302
|
+
env=config.env or None,
|
|
303
|
+
cwd=self.project_path,
|
|
304
|
+
)
|
|
305
|
+
read_stream, write_stream = await self._exit_stack.enter_async_context(
|
|
306
|
+
stdio_client(stdio_params)
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if read_stream is None or write_stream is None:
|
|
310
|
+
raise ValueError("Failed to create read/write streams for MCP server")
|
|
311
|
+
|
|
312
|
+
session = await self._exit_stack.enter_async_context(
|
|
313
|
+
ClientSession(
|
|
314
|
+
read_stream,
|
|
315
|
+
write_stream,
|
|
316
|
+
list_roots_callback=self._list_roots_callback, # type: ignore[arg-type]
|
|
317
|
+
client_info=mcp_types.Implementation(name="ripperdoc", version=__version__),
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
init_result = await session.initialize()
|
|
322
|
+
capabilities = session.get_server_capabilities()
|
|
323
|
+
if capabilities is None:
|
|
324
|
+
capabilities = mcp_types.ServerCapabilities()
|
|
325
|
+
|
|
326
|
+
info.status = "connected"
|
|
327
|
+
info.instructions = init_result.instructions or info.instructions
|
|
328
|
+
info.server_version = getattr(init_result.serverInfo, "version", None)
|
|
329
|
+
info.capabilities = (
|
|
330
|
+
capabilities.model_dump() if hasattr(capabilities, "model_dump") else {}
|
|
331
|
+
)
|
|
332
|
+
self.sessions[config.name] = session
|
|
333
|
+
|
|
334
|
+
tools_result = await session.list_tools()
|
|
335
|
+
info.tools = [
|
|
336
|
+
McpToolInfo(
|
|
337
|
+
name=tool.name,
|
|
338
|
+
description=tool.description or "",
|
|
339
|
+
input_schema=tool.inputSchema,
|
|
340
|
+
annotations=(tool.annotations.model_dump() if tool.annotations else {}),
|
|
341
|
+
)
|
|
342
|
+
for tool in tools_result.tools
|
|
343
|
+
]
|
|
344
|
+
|
|
345
|
+
if capabilities and getattr(capabilities, "resources", None):
|
|
346
|
+
resources_result = await session.list_resources()
|
|
347
|
+
info.resources = [
|
|
348
|
+
McpResourceInfo(
|
|
349
|
+
uri=str(resource.uri),
|
|
350
|
+
name=resource.name,
|
|
351
|
+
description=resource.description or "",
|
|
352
|
+
mime_type=resource.mimeType,
|
|
353
|
+
size=resource.size,
|
|
354
|
+
)
|
|
355
|
+
for resource in resources_result.resources
|
|
356
|
+
]
|
|
357
|
+
|
|
358
|
+
logger.info(
|
|
359
|
+
"[mcp] Connected to MCP server",
|
|
360
|
+
extra={
|
|
361
|
+
"server": config.name,
|
|
362
|
+
"status": info.status,
|
|
363
|
+
"tools": len(info.tools),
|
|
364
|
+
"resources": len(info.resources),
|
|
365
|
+
"capabilities": list(info.capabilities.keys()),
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
except (OSError, RuntimeError, ConnectionError, ValueError, TimeoutError) as exc: # pragma: no cover - network/process errors
|
|
369
|
+
logger.warning(
|
|
370
|
+
"Failed to connect to MCP server: %s: %s",
|
|
371
|
+
type(exc).__name__, exc,
|
|
372
|
+
extra={"server": config.name},
|
|
373
|
+
)
|
|
374
|
+
info.status = "failed"
|
|
375
|
+
info.error = str(exc)
|
|
376
|
+
|
|
377
|
+
return info
|
|
378
|
+
|
|
379
|
+
async def aclose(self) -> None:
|
|
380
|
+
if self._closed:
|
|
381
|
+
return
|
|
382
|
+
self._closed = True
|
|
383
|
+
logger.debug(
|
|
384
|
+
"[mcp] Shutting down MCP runtime",
|
|
385
|
+
extra={"project_path": str(self.project_path), "session_count": len(self.sessions)},
|
|
386
|
+
)
|
|
387
|
+
try:
|
|
388
|
+
await self._exit_stack.aclose()
|
|
389
|
+
except BaseException as exc: # pragma: no cover - defensive shutdown
|
|
390
|
+
# Swallow noisy ExceptionGroups from stdio_client cancel scopes during exit.
|
|
391
|
+
logger.debug(
|
|
392
|
+
"[mcp] Suppressed MCP shutdown error",
|
|
393
|
+
extra={"error": str(exc), "project_path": str(self.project_path)},
|
|
394
|
+
)
|
|
395
|
+
finally:
|
|
396
|
+
self.sessions.clear()
|
|
397
|
+
self.servers.clear()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
_runtime_var: contextvars.ContextVar[Optional[McpRuntime]] = contextvars.ContextVar(
|
|
401
|
+
"ripperdoc_mcp_runtime", default=None
|
|
402
|
+
)
|
|
403
|
+
# Fallback for synchronous contexts (e.g., run_until_complete) where contextvars
|
|
404
|
+
# don't propagate values back to the caller.
|
|
405
|
+
_global_runtime: Optional[McpRuntime] = None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _get_runtime() -> Optional[McpRuntime]:
|
|
409
|
+
runtime = _runtime_var.get()
|
|
410
|
+
if runtime:
|
|
411
|
+
return runtime
|
|
412
|
+
return _global_runtime
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def get_existing_mcp_runtime() -> Optional[McpRuntime]:
|
|
416
|
+
"""Return the current MCP runtime if it has already been initialized."""
|
|
417
|
+
return _get_runtime()
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
async def ensure_mcp_runtime(project_path: Optional[Path] = None) -> McpRuntime:
|
|
421
|
+
runtime = _get_runtime()
|
|
422
|
+
project_path = project_path or Path.cwd()
|
|
423
|
+
if runtime and not runtime._closed and runtime.project_path == project_path:
|
|
424
|
+
_runtime_var.set(runtime)
|
|
425
|
+
logger.debug(
|
|
426
|
+
"[mcp] Reusing existing MCP runtime",
|
|
427
|
+
extra={
|
|
428
|
+
"project_path": str(project_path),
|
|
429
|
+
"server_count": len(runtime.servers),
|
|
430
|
+
},
|
|
431
|
+
)
|
|
432
|
+
return runtime
|
|
433
|
+
|
|
434
|
+
if runtime:
|
|
435
|
+
await runtime.aclose()
|
|
436
|
+
|
|
437
|
+
runtime = McpRuntime(project_path)
|
|
438
|
+
logger.debug(
|
|
439
|
+
"[mcp] Creating MCP runtime",
|
|
440
|
+
extra={"project_path": str(project_path)},
|
|
441
|
+
)
|
|
442
|
+
configs = _load_server_configs(project_path)
|
|
443
|
+
await runtime.connect(configs)
|
|
444
|
+
_runtime_var.set(runtime)
|
|
445
|
+
# Keep a module-level reference so sync callers that hop event loops can reuse it.
|
|
446
|
+
global _global_runtime
|
|
447
|
+
_global_runtime = runtime
|
|
448
|
+
return runtime
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
async def shutdown_mcp_runtime() -> None:
|
|
452
|
+
runtime = _get_runtime()
|
|
453
|
+
if not runtime:
|
|
454
|
+
return
|
|
455
|
+
try:
|
|
456
|
+
await runtime.aclose()
|
|
457
|
+
except BaseException as exc: # pragma: no cover - defensive for ExceptionGroup
|
|
458
|
+
logger.debug("[mcp] Suppressed MCP runtime shutdown error", extra={"error": str(exc)})
|
|
459
|
+
_runtime_var.set(None)
|
|
460
|
+
global _global_runtime
|
|
461
|
+
_global_runtime = None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
async def load_mcp_servers_async(project_path: Optional[Path] = None) -> List[McpServerInfo]:
|
|
465
|
+
runtime = await ensure_mcp_runtime(project_path)
|
|
466
|
+
return list(runtime.servers)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _config_only_servers(project_path: Optional[Path]) -> List[McpServerInfo]:
|
|
470
|
+
return list(_load_server_configs(project_path).values())
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def load_mcp_servers(project_path: Optional[Path] = None) -> List[McpServerInfo]:
|
|
474
|
+
"""Synchronous wrapper primarily for legacy call sites."""
|
|
475
|
+
try:
|
|
476
|
+
loop = asyncio.get_running_loop()
|
|
477
|
+
if loop.is_running():
|
|
478
|
+
runtime = _get_runtime()
|
|
479
|
+
if runtime and runtime.servers:
|
|
480
|
+
return list(runtime.servers)
|
|
481
|
+
return _config_only_servers(project_path)
|
|
482
|
+
except RuntimeError:
|
|
483
|
+
pass
|
|
484
|
+
|
|
485
|
+
return asyncio.run(load_mcp_servers_async(project_path))
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def find_mcp_resource(
|
|
489
|
+
servers: List[McpServerInfo],
|
|
490
|
+
server_name: str,
|
|
491
|
+
uri: str,
|
|
492
|
+
) -> Optional[McpResourceInfo]:
|
|
493
|
+
server = next((s for s in servers if s.name == server_name), None)
|
|
494
|
+
if not server:
|
|
495
|
+
return None
|
|
496
|
+
return next((r for r in server.resources if r.uri == uri), None)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _summarize_tools(server: McpServerInfo) -> str:
|
|
500
|
+
if not server.tools:
|
|
501
|
+
return "no tools"
|
|
502
|
+
names = [tool.name for tool in server.tools[:6]]
|
|
503
|
+
suffix = ", ".join(names)
|
|
504
|
+
if len(server.tools) > 6:
|
|
505
|
+
suffix += f", and {len(server.tools) - 6} more"
|
|
506
|
+
return suffix
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def format_mcp_instructions(servers: List[McpServerInfo]) -> str:
|
|
510
|
+
"""Build a concise MCP instruction block for the system prompt."""
|
|
511
|
+
if not servers:
|
|
512
|
+
return ""
|
|
513
|
+
|
|
514
|
+
lines: List[str] = [
|
|
515
|
+
"MCP servers are available. Call tools via CallMcpTool by specifying server, tool, and arguments.",
|
|
516
|
+
"Use ListMcpServers to inspect servers and ListMcpResources/ReadMcpResource when a server exposes resources.",
|
|
517
|
+
]
|
|
518
|
+
|
|
519
|
+
for server in servers:
|
|
520
|
+
status = server.status or "unknown"
|
|
521
|
+
prefix = f"- {server.name} [{status}]"
|
|
522
|
+
if server.url:
|
|
523
|
+
prefix += f" {server.url}"
|
|
524
|
+
lines.append(prefix)
|
|
525
|
+
|
|
526
|
+
if status == "connected":
|
|
527
|
+
if server.instructions:
|
|
528
|
+
trimmed = server.instructions.strip()
|
|
529
|
+
if len(trimmed) > 260:
|
|
530
|
+
trimmed = trimmed[:257] + "..."
|
|
531
|
+
lines.append(f" Instructions: {trimmed}")
|
|
532
|
+
tool_summary = _summarize_tools(server)
|
|
533
|
+
lines.append(f" Tools: {tool_summary}")
|
|
534
|
+
if server.resources:
|
|
535
|
+
lines.append(f" Resources: {len(server.resources)} available")
|
|
536
|
+
elif server.error:
|
|
537
|
+
lines.append(f" Error: {server.error}")
|
|
538
|
+
|
|
539
|
+
return "\n".join(lines)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def estimate_mcp_tokens(servers: List[McpServerInfo]) -> int:
|
|
543
|
+
"""Estimate token usage for MCP instructions."""
|
|
544
|
+
mcp_text = format_mcp_instructions(servers)
|
|
545
|
+
return estimate_tokens(mcp_text)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
__all__ = [
|
|
549
|
+
"McpServerInfo",
|
|
550
|
+
"McpToolInfo",
|
|
551
|
+
"McpResourceInfo",
|
|
552
|
+
"get_existing_mcp_runtime",
|
|
553
|
+
"load_mcp_servers",
|
|
554
|
+
"load_mcp_servers_async",
|
|
555
|
+
"ensure_mcp_runtime",
|
|
556
|
+
"shutdown_mcp_runtime",
|
|
557
|
+
"find_mcp_resource",
|
|
558
|
+
"format_mcp_instructions",
|
|
559
|
+
"estimate_mcp_tokens",
|
|
560
|
+
]
|