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
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
+ ]