vibecore 0.2.0a1__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.

Potentially problematic release.


This version of vibecore might be problematic. Click here for more details.

Files changed (63) hide show
  1. vibecore/__init__.py +0 -0
  2. vibecore/agents/default.py +79 -0
  3. vibecore/agents/prompts.py +12 -0
  4. vibecore/agents/task_agent.py +66 -0
  5. vibecore/cli.py +131 -0
  6. vibecore/context.py +24 -0
  7. vibecore/handlers/__init__.py +5 -0
  8. vibecore/handlers/stream_handler.py +231 -0
  9. vibecore/main.py +506 -0
  10. vibecore/main.tcss +0 -0
  11. vibecore/mcp/__init__.py +6 -0
  12. vibecore/mcp/manager.py +167 -0
  13. vibecore/mcp/server_wrapper.py +109 -0
  14. vibecore/models/__init__.py +5 -0
  15. vibecore/models/anthropic.py +239 -0
  16. vibecore/prompts/common_system_prompt.txt +64 -0
  17. vibecore/py.typed +0 -0
  18. vibecore/session/__init__.py +5 -0
  19. vibecore/session/file_lock.py +127 -0
  20. vibecore/session/jsonl_session.py +236 -0
  21. vibecore/session/loader.py +193 -0
  22. vibecore/session/path_utils.py +81 -0
  23. vibecore/settings.py +161 -0
  24. vibecore/tools/__init__.py +1 -0
  25. vibecore/tools/base.py +27 -0
  26. vibecore/tools/file/__init__.py +5 -0
  27. vibecore/tools/file/executor.py +282 -0
  28. vibecore/tools/file/tools.py +184 -0
  29. vibecore/tools/file/utils.py +78 -0
  30. vibecore/tools/python/__init__.py +1 -0
  31. vibecore/tools/python/backends/__init__.py +1 -0
  32. vibecore/tools/python/backends/terminal_backend.py +58 -0
  33. vibecore/tools/python/helpers.py +80 -0
  34. vibecore/tools/python/manager.py +208 -0
  35. vibecore/tools/python/tools.py +27 -0
  36. vibecore/tools/shell/__init__.py +5 -0
  37. vibecore/tools/shell/executor.py +223 -0
  38. vibecore/tools/shell/tools.py +156 -0
  39. vibecore/tools/task/__init__.py +5 -0
  40. vibecore/tools/task/executor.py +51 -0
  41. vibecore/tools/task/tools.py +51 -0
  42. vibecore/tools/todo/__init__.py +1 -0
  43. vibecore/tools/todo/manager.py +31 -0
  44. vibecore/tools/todo/models.py +36 -0
  45. vibecore/tools/todo/tools.py +111 -0
  46. vibecore/utils/__init__.py +5 -0
  47. vibecore/utils/text.py +28 -0
  48. vibecore/widgets/core.py +332 -0
  49. vibecore/widgets/core.tcss +63 -0
  50. vibecore/widgets/expandable.py +121 -0
  51. vibecore/widgets/expandable.tcss +69 -0
  52. vibecore/widgets/info.py +25 -0
  53. vibecore/widgets/info.tcss +17 -0
  54. vibecore/widgets/messages.py +232 -0
  55. vibecore/widgets/messages.tcss +85 -0
  56. vibecore/widgets/tool_message_factory.py +121 -0
  57. vibecore/widgets/tool_messages.py +483 -0
  58. vibecore/widgets/tool_messages.tcss +289 -0
  59. vibecore-0.2.0a1.dist-info/METADATA +407 -0
  60. vibecore-0.2.0a1.dist-info/RECORD +63 -0
  61. vibecore-0.2.0a1.dist-info/WHEEL +4 -0
  62. vibecore-0.2.0a1.dist-info/entry_points.txt +2 -0
  63. vibecore-0.2.0a1.dist-info/licenses/LICENSE +21 -0
vibecore/main.py ADDED
@@ -0,0 +1,506 @@
1
+ import asyncio
2
+ import traceback
3
+ from collections import deque
4
+ from typing import ClassVar, Literal
5
+
6
+ from agents import (
7
+ Agent,
8
+ ModelSettings,
9
+ Runner,
10
+ RunResultStreaming,
11
+ StreamEvent,
12
+ TResponseInputItem,
13
+ )
14
+ from openai.types import Reasoning
15
+ from openai.types.responses.response_output_message import Content
16
+ from textual import log, work
17
+ from textual.app import App, ComposeResult
18
+ from textual.binding import Binding
19
+ from textual.reactive import reactive
20
+ from textual.widgets import Header
21
+ from textual.worker import Worker
22
+
23
+ from vibecore.context import VibecoreContext
24
+ from vibecore.handlers import AgentStreamHandler
25
+ from vibecore.session import JSONLSession
26
+ from vibecore.session.loader import SessionLoader
27
+ from vibecore.settings import settings
28
+ from vibecore.utils.text import TextExtractor
29
+ from vibecore.widgets.core import AppFooter, MainScroll, MyTextArea
30
+ from vibecore.widgets.info import Welcome
31
+ from vibecore.widgets.messages import AgentMessage, BaseMessage, MessageStatus, SystemMessage, UserMessage
32
+
33
+ AgentStatus = Literal["idle", "running"]
34
+
35
+
36
+ def detect_reasoning_effort(prompt: str) -> Literal["low", "medium", "high"] | None:
37
+ """Detect reasoning effort level from user prompt keywords.
38
+
39
+ Args:
40
+ prompt: User input text
41
+
42
+ Returns:
43
+ Reasoning effort level or None if no keywords detected
44
+ """
45
+ prompt_lower = prompt.lower()
46
+
47
+ # Check for highest priority keywords first
48
+ if "ultrathink" in prompt_lower:
49
+ return "high"
50
+ elif "think hard" in prompt_lower:
51
+ return "medium"
52
+ elif "think" in prompt_lower:
53
+ return "low"
54
+
55
+ return None
56
+
57
+
58
+ class VibecoreApp(App):
59
+ """A Textual app to manage stopwatches."""
60
+
61
+ CSS_PATH: ClassVar = [
62
+ "widgets/core.tcss",
63
+ "widgets/messages.tcss",
64
+ "widgets/tool_messages.tcss",
65
+ "widgets/expandable.tcss",
66
+ "widgets/info.tcss",
67
+ "main.tcss",
68
+ ]
69
+ BINDINGS: ClassVar = [
70
+ ("ctrl+shift+d", "toggle_dark", "Toggle dark mode"),
71
+ Binding("escape", "cancel_agent", "Cancel agent", show=False),
72
+ Binding("ctrl+d", "exit_confirm", "Exit", show=False),
73
+ ]
74
+
75
+ agent_status = reactive[AgentStatus]("idle")
76
+ _exit_confirmation_active = False
77
+ _exit_confirmation_task: asyncio.Task | None = None
78
+
79
+ def __init__(
80
+ self,
81
+ context: VibecoreContext,
82
+ agent: Agent,
83
+ session_id: str | None = None,
84
+ print_mode: bool = False,
85
+ ) -> None:
86
+ """Initialize the Vibecore app with context and agent.
87
+
88
+ Args:
89
+ context: The VibecoreContext instance
90
+ agent: The Agent instance to use
91
+ session_id: Optional session ID to load existing session
92
+ print_mode: Whether to run in print mode (useful for pipes)
93
+ """
94
+ self.context = context
95
+ self.context.app = self # Set the app reference in context
96
+ self.agent = agent
97
+ self.input_items: list[TResponseInputItem] = []
98
+ self.current_result: RunResultStreaming | None = None
99
+ self.current_worker: Worker[None] | None = None
100
+ self._session_id_provided = session_id is not None # Track if continuing session
101
+ self.print_mode = print_mode
102
+ self.message_queue: deque[str] = deque() # Queue for user messages
103
+
104
+ # Initialize session based on settings
105
+ if settings.session.storage_type == "jsonl":
106
+ if session_id is None:
107
+ # Generate a new session ID based on current date/time
108
+ import datetime
109
+
110
+ session_id = f"chat-{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}"
111
+
112
+ self.session = JSONLSession(
113
+ session_id=session_id,
114
+ project_path=None, # Will use current working directory
115
+ base_dir=settings.session.base_dir,
116
+ )
117
+ else:
118
+ raise NotImplementedError("SQLite session support will be added later")
119
+
120
+ super().__init__()
121
+
122
+ def compose(self) -> ComposeResult:
123
+ """Create child widgets for the app."""
124
+ yield Header()
125
+ yield AppFooter()
126
+ with MainScroll(id="messages"):
127
+ yield Welcome()
128
+
129
+ async def on_mount(self) -> None:
130
+ """Called when the app is mounted."""
131
+ # Connect to MCP servers if configured
132
+ if self.context.mcp_manager:
133
+ try:
134
+ await self.context.mcp_manager.connect()
135
+ log(f"Connected to {len(self.context.mcp_manager.servers)} MCP servers")
136
+ except Exception as e:
137
+ log(f"Failed to connect to MCP servers: {e}")
138
+ # Continue without MCP servers rather than crashing
139
+
140
+ # Load session history if we're continuing from a previous session
141
+ if self._session_id_provided:
142
+ await self.load_session_history()
143
+
144
+ async def on_unmount(self) -> None:
145
+ """Called when the app is being unmounted (shutdown)."""
146
+ # Cleanup MCP servers during unmount
147
+ if self.context.mcp_manager:
148
+ try:
149
+ log("Disconnecting from MCP servers...")
150
+ await self.context.mcp_manager.disconnect()
151
+ log("Disconnected from MCP servers")
152
+ except Exception as e:
153
+ log(f"Error disconnecting from MCP servers during unmount: {e}")
154
+
155
+ def extract_text_from_content(self, content: list[Content]) -> str:
156
+ """Extract text from various content formats."""
157
+ return TextExtractor.extract_from_content(content)
158
+
159
+ async def add_message(self, message: BaseMessage) -> None:
160
+ """Add a message widget to the main scroll area.
161
+
162
+ Args:
163
+ message: The message to add
164
+ """
165
+ main_scroll = self.query_one("#messages", MainScroll)
166
+ await main_scroll.mount(message)
167
+
168
+ async def handle_agent_message(self, message: BaseMessage) -> None:
169
+ """Add a message widget to the main scroll area."""
170
+ await self.add_message(message)
171
+
172
+ async def handle_agent_update(self, new_agent: Agent) -> None:
173
+ """Handle agent updates."""
174
+ log(f"Agent updated: {new_agent.name}")
175
+ self.agent = new_agent
176
+
177
+ async def handle_agent_error(self, error: Exception) -> None:
178
+ """Handle errors during streaming."""
179
+ log(f"Error during agent response: {type(error).__name__}: {error!s}")
180
+
181
+ # Create an error message for the user
182
+ error_msg = f"❌ Error: {type(error).__name__}"
183
+ if str(error):
184
+ error_msg += f"\n\n{error!s}"
185
+
186
+ error_msg += f"\n\n```\n{traceback.format_exc()}\n```"
187
+
188
+ # Display the error to the user
189
+ # TODO(serialx): Use a dedicated error message widget
190
+ error_agent_msg = AgentMessage(error_msg, status=MessageStatus.ERROR)
191
+ await self.add_message(error_agent_msg)
192
+
193
+ async def handle_agent_finished(self) -> None:
194
+ """Handle when the agent has finished processing."""
195
+ # Remove the last agent message if it is still executing (which means the agent run was cancelled)
196
+ main_scroll = self.query_one("#messages", MainScroll)
197
+ try:
198
+ last_message = main_scroll.query_one("BaseMessage:last-child", BaseMessage)
199
+ if last_message.status == MessageStatus.EXECUTING:
200
+ last_message.remove()
201
+ except Exception:
202
+ # No messages to clean up
203
+ pass
204
+
205
+ async def load_session_history(self) -> None:
206
+ """Load and display messages from session history."""
207
+ loader = SessionLoader(self.session)
208
+ messages = await loader.load_history()
209
+
210
+ # Remove Welcome widget if we have messages
211
+ if messages:
212
+ welcome = self.query_one("#messages").query("Welcome")
213
+ if welcome:
214
+ welcome.first().remove()
215
+
216
+ # Add all messages to the UI
217
+ for message in messages:
218
+ await self.add_message(message)
219
+
220
+ def watch_agent_status(self, _old_status: AgentStatus, new_status: AgentStatus) -> None:
221
+ """React to agent_status changes."""
222
+ footer = self.query_one(AppFooter)
223
+ if new_status == "running":
224
+ footer.show_loading()
225
+ else:
226
+ footer.hide_loading()
227
+
228
+ async def on_my_text_area_user_message(self, event: MyTextArea.UserMessage) -> None:
229
+ """Handle user messages from the text area."""
230
+ if event.text:
231
+ # Check for special commands
232
+ text_strip = event.text.strip()
233
+ if text_strip == "/clear":
234
+ await self.handle_clear_command()
235
+ return
236
+ elif text_strip == "/help":
237
+ help_text = "Available commands:\n"
238
+ help_text += "• /clear - Clear the current session and start a new one\n"
239
+ help_text += "• /help - Show this help message\n\n"
240
+ help_text += "Keyboard shortcuts:\n"
241
+ help_text += "• Esc - Cancel current agent operation\n"
242
+ help_text += "• Ctrl+Shift+D - Toggle dark/light mode\n"
243
+ help_text += "• Up/Down arrows - Navigate message history\n"
244
+ await self.add_message(SystemMessage(help_text))
245
+ return
246
+
247
+ user_message = UserMessage(event.text)
248
+ await self.add_message(user_message)
249
+ user_message.scroll_visible()
250
+
251
+ # If agent is running, queue the message
252
+ if self.agent_status == "running":
253
+ self.message_queue.append(event.text)
254
+ log(f"Message queued: {event.text}")
255
+ footer = self.query_one(AppFooter)
256
+ # Update the loading message to show queued messages
257
+ queued_count = len(self.message_queue)
258
+ footer.show_loading(
259
+ status="Generating…", metadata=f"{queued_count} message{'s' if queued_count > 1 else ''} queued"
260
+ )
261
+ else:
262
+ # Detect reasoning effort from prompt keywords
263
+ detected_effort = detect_reasoning_effort(event.text)
264
+ reasoning_effort = detected_effort or settings.reasoning_effort
265
+
266
+ # Create agent with appropriate reasoning effort
267
+ agent_to_use = self.agent
268
+ if reasoning_effort is not None:
269
+ # Create a copy of the agent with updated model settings
270
+ current_settings = self.agent.model_settings or ModelSettings()
271
+ new_reasoning = Reasoning(effort=reasoning_effort, summary="auto")
272
+ updated_settings = ModelSettings(
273
+ include_usage=current_settings.include_usage,
274
+ reasoning=new_reasoning,
275
+ )
276
+ agent_to_use = Agent[VibecoreContext](
277
+ name=self.agent.name,
278
+ handoff_description=self.agent.handoff_description,
279
+ instructions=self.agent.instructions,
280
+ tools=self.agent.tools,
281
+ model=self.agent.model,
282
+ model_settings=updated_settings,
283
+ handoffs=self.agent.handoffs,
284
+ mcp_servers=self.agent.mcp_servers,
285
+ )
286
+
287
+ # Process the message immediately
288
+ result = Runner.run_streamed(
289
+ agent_to_use,
290
+ input=event.text, # Pass string directly when using session
291
+ context=self.context,
292
+ max_turns=settings.max_turns,
293
+ session=self.session,
294
+ )
295
+
296
+ self.current_worker = self.handle_streamed_response(result)
297
+
298
+ @work(exclusive=True)
299
+ async def handle_streamed_response(self, result: RunResultStreaming) -> None:
300
+ self.agent_status = "running"
301
+ self.current_result = result
302
+
303
+ self.agent_stream_handler = AgentStreamHandler(self)
304
+ await self.agent_stream_handler.process_stream(result)
305
+
306
+ used = result.context_wrapper.usage.total_tokens
307
+ max_ctx = self._get_model_context_window()
308
+ log(f"Context usage: {used} / {max_ctx} total tokens")
309
+ self.context.context_fullness = min(1.0, float(used) / float(max_ctx))
310
+ footer = self.query_one(AppFooter)
311
+ footer.set_context_progress(self.context.context_fullness)
312
+
313
+ self.agent_status = "idle"
314
+ self.current_result = None
315
+ self.current_worker = None
316
+
317
+ await self.process_message_queue()
318
+
319
+ async def process_message_queue(self) -> None:
320
+ """Process any messages that were queued while the agent was running."""
321
+ if self.message_queue:
322
+ # Get the next message from the queue
323
+ next_message = self.message_queue.popleft()
324
+ log(f"Processing queued message: {next_message}")
325
+
326
+ # Process the message
327
+ result = Runner.run_streamed(
328
+ self.agent,
329
+ input=next_message,
330
+ context=self.context,
331
+ max_turns=settings.max_turns,
332
+ session=self.session,
333
+ )
334
+
335
+ self.current_worker = self.handle_streamed_response(result)
336
+
337
+ def on_click(self) -> None:
338
+ self.query_one("#input-textarea").focus()
339
+
340
+ def _get_model_context_window(self) -> int:
341
+ from vibecore.settings import settings
342
+
343
+ model_name = settings.default_model
344
+ log(f"Getting context window for model: {model_name}")
345
+ return 200000
346
+
347
+ def action_toggle_dark(self) -> None:
348
+ """An action to toggle dark mode."""
349
+ self.theme = "textual-dark" if self.theme == "textual-light" else "textual-light"
350
+
351
+ def action_cancel_agent(self) -> None:
352
+ """Cancel the current agent run."""
353
+ if self.agent_status == "running":
354
+ log("Cancelling agent run")
355
+ if self.current_result:
356
+ self.current_result.cancel()
357
+ if self.current_worker:
358
+ self.current_worker.cancel()
359
+
360
+ async def action_exit_confirm(self) -> None:
361
+ """Handle Ctrl-D press for exit confirmation."""
362
+ if self._exit_confirmation_active:
363
+ # Second Ctrl-D within the timeframe - exit the app
364
+ self.exit()
365
+ else:
366
+ # First Ctrl-D - show confirmation message
367
+ self._exit_confirmation_active = True
368
+
369
+ # Cancel any existing confirmation task
370
+ if self._exit_confirmation_task and not self._exit_confirmation_task.done():
371
+ self._exit_confirmation_task.cancel()
372
+
373
+ # Show confirmation message
374
+ confirmation_msg = SystemMessage("Press Ctrl-D again to exit")
375
+ await self.add_message(confirmation_msg)
376
+
377
+ # Start the 1-second timer
378
+ self._exit_confirmation_task = asyncio.create_task(self._reset_exit_confirmation(confirmation_msg))
379
+
380
+ async def _reset_exit_confirmation(self, confirmation_msg: SystemMessage) -> None:
381
+ """Reset exit confirmation after 1 second and remove the message."""
382
+ try:
383
+ # Wait for 1 second
384
+ await asyncio.sleep(1.0)
385
+
386
+ # Reset confirmation state
387
+ self._exit_confirmation_active = False
388
+
389
+ # Remove the confirmation message
390
+ confirmation_msg.remove()
391
+ except asyncio.CancelledError:
392
+ # Task was cancelled (new Ctrl-D pressed)
393
+ pass
394
+
395
+ async def run_print(self, prompt: str | None = None) -> str:
396
+ """Run the agent and return the raw output for printing.
397
+
398
+ Args:
399
+ prompt: Optional prompt text. If not provided, reads from stdin.
400
+
401
+ Returns:
402
+ The agent's text output as a string
403
+ """
404
+ import sys
405
+
406
+ # Use provided prompt or read from stdin
407
+ input_text = prompt.strip() if prompt else sys.stdin.read().strip()
408
+
409
+ if not input_text:
410
+ return ""
411
+
412
+ # Import needed event types
413
+ from agents import RawResponsesStreamEvent
414
+ from openai.types.responses import ResponseTextDeltaEvent
415
+
416
+ if self.context.mcp_manager:
417
+ await self.context.mcp_manager.connect()
418
+
419
+ # Run the agent
420
+ result = Runner.run_streamed(
421
+ self.agent,
422
+ input=input_text,
423
+ context=self.context,
424
+ max_turns=settings.max_turns,
425
+ session=self.session,
426
+ )
427
+
428
+ # Collect all agent text output
429
+ agent_output = ""
430
+
431
+ async for event in result.stream_events():
432
+ # Handle text output from agent
433
+ match event:
434
+ case RawResponsesStreamEvent(data=data):
435
+ match data:
436
+ case ResponseTextDeltaEvent(delta=delta) if delta:
437
+ agent_output += delta
438
+
439
+ if self.context.mcp_manager:
440
+ await self.context.mcp_manager.disconnect()
441
+
442
+ return agent_output.strip()
443
+
444
+ async def handle_task_tool_event(self, tool_name: str, tool_call_id: str, event: StreamEvent) -> None:
445
+ """Handle streaming events from task tool sub-agents.
446
+
447
+ Args:
448
+ tool_name: Name of the tool (e.g., "task")
449
+ tool_call_id: Unique identifier for this tool call
450
+ event: The streaming event from the sub-agent
451
+
452
+ Note: The main app receives this event from the agent's task tool handler.
453
+ """
454
+ await self.agent_stream_handler.handle_task_tool_event(tool_name, tool_call_id, event)
455
+
456
+ async def handle_clear_command(self) -> None:
457
+ """Handle the /clear command to create a new session and clear the UI."""
458
+ log("Clearing session and creating new session")
459
+
460
+ # Cancel any running agent
461
+ if self.agent_status == "running":
462
+ self.action_cancel_agent()
463
+
464
+ # Clear message queue
465
+ self.message_queue.clear()
466
+
467
+ # Generate a new session ID
468
+ import datetime
469
+
470
+ new_session_id = f"chat-{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}"
471
+
472
+ # Create new session
473
+ if settings.session.storage_type == "jsonl":
474
+ self.session = JSONLSession(
475
+ session_id=new_session_id,
476
+ project_path=None, # Will use current working directory
477
+ base_dir=settings.session.base_dir,
478
+ )
479
+ else:
480
+ raise NotImplementedError("SQLite session support will be added later")
481
+
482
+ # Reset context state
483
+ self.context.reset_state()
484
+
485
+ # Clear input items
486
+ self.input_items.clear()
487
+
488
+ # Clear the UI - remove all messages and add welcome back
489
+ main_scroll = self.query_one("#messages", MainScroll)
490
+
491
+ # Remove all existing messages
492
+ for message in main_scroll.query("BaseMessage"):
493
+ message.remove()
494
+
495
+ # Remove welcome if it exists
496
+ for welcome in main_scroll.query("Welcome"):
497
+ welcome.remove()
498
+
499
+ # Add welcome widget back
500
+ await main_scroll.mount(Welcome())
501
+
502
+ # Show system message to confirm the clear operation
503
+ system_message = SystemMessage(f"✨ Session cleared! Started new session: {new_session_id}")
504
+ await main_scroll.mount(system_message)
505
+
506
+ log(f"New session created: {new_session_id}")
vibecore/main.tcss ADDED
File without changes
@@ -0,0 +1,6 @@
1
+ """MCP (Model Context Protocol) integration for Vibecore."""
2
+
3
+ from .manager import MCPManager
4
+ from .server_wrapper import NameOverridingMCPServer
5
+
6
+ __all__ = ["MCPManager", "NameOverridingMCPServer"]
@@ -0,0 +1,167 @@
1
+ """MCP server management for Vibecore."""
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from agents import Tool
6
+ from agents.mcp import (
7
+ MCPServer,
8
+ MCPServerSse,
9
+ MCPServerSseParams,
10
+ MCPServerStdio,
11
+ MCPServerStdioParams,
12
+ MCPServerStreamableHttp,
13
+ MCPServerStreamableHttpParams,
14
+ MCPUtil,
15
+ create_static_tool_filter,
16
+ )
17
+ from agents.run_context import RunContextWrapper
18
+ from textual import log
19
+
20
+ from vibecore.settings import MCPServerConfig
21
+
22
+ from .server_wrapper import NameOverridingMCPServer
23
+
24
+ if TYPE_CHECKING:
25
+ from agents import AgentBase
26
+
27
+
28
+ class MCPManager:
29
+ """Manages MCP server connections and tool discovery."""
30
+
31
+ def __init__(self, server_configs: list[MCPServerConfig]):
32
+ """Initialize the MCP manager.
33
+
34
+ Args:
35
+ server_configs: List of MCP server configurations.
36
+ """
37
+ self.server_configs = server_configs
38
+ self.servers: list[MCPServer] = []
39
+ self._connected = False
40
+ self._server_contexts: list[Any] = [] # Store context managers
41
+
42
+ # Create servers immediately and wrap them
43
+ for config in self.server_configs:
44
+ actual_server = self._create_server(config)
45
+ # Wrap the server to override tool names
46
+ wrapped_server = NameOverridingMCPServer(actual_server)
47
+ self.servers.append(wrapped_server)
48
+
49
+ async def connect(self) -> None:
50
+ """Connect to all configured MCP servers."""
51
+ if self._connected:
52
+ return
53
+
54
+ for server in self.servers:
55
+ await server.connect()
56
+
57
+ self._connected = True
58
+
59
+ async def disconnect(self) -> None:
60
+ """Disconnect from all MCP servers."""
61
+ if not self._connected:
62
+ return
63
+
64
+ # Disconnect all servers sequentially to avoid anyio cancel scope issues
65
+ # anyio doesn't allow cancel scopes to be exited in a different task
66
+ for server in self.servers:
67
+ try:
68
+ log(f"Disconnecting from MCP server: {server.name}")
69
+ # Give each server 3 seconds to cleanup gracefully
70
+ # await asyncio.wait_for(server.cleanup(), timeout=3.0)
71
+ await server.cleanup()
72
+ log(f"Disconnected from MCP server: {server.name}")
73
+ except TimeoutError:
74
+ log(f"Timeout disconnecting from MCP server: {server.name}")
75
+ except Exception as e:
76
+ log(f"Error disconnecting from MCP server {server.name}: {e}")
77
+
78
+ # Clear the servers list to prevent any further operations
79
+ # self.servers.clear()
80
+ self._connected = False
81
+
82
+ async def get_tools(self, run_context: RunContextWrapper[Any], agent: "AgentBase") -> list[Tool]:
83
+ """Get all tools from connected MCP servers.
84
+
85
+ Args:
86
+ run_context: The current run context.
87
+ agent: The agent requesting tools.
88
+
89
+ Returns:
90
+ List of tools from all connected MCP servers.
91
+ """
92
+ if not self._connected:
93
+ await self.connect()
94
+
95
+ # Get all tools using MCPUtil which handles the wrapped servers
96
+ return await MCPUtil.get_all_function_tools(
97
+ servers=self.servers,
98
+ convert_schemas_to_strict=True,
99
+ run_context=run_context,
100
+ agent=agent,
101
+ )
102
+
103
+ def _create_server(self, config: MCPServerConfig) -> MCPServer:
104
+ """Create an MCP server instance from configuration.
105
+
106
+ Args:
107
+ config: MCP server configuration.
108
+
109
+ Returns:
110
+ Configured MCP server instance.
111
+ """
112
+ tool_filter = create_static_tool_filter(
113
+ allowed_tool_names=config.allowed_tools,
114
+ blocked_tool_names=config.blocked_tools,
115
+ )
116
+
117
+ if config.type == "stdio":
118
+ if not config.command:
119
+ raise ValueError(f"stdio server '{config.name}' requires a command")
120
+
121
+ return MCPServerStdio(
122
+ name=config.name,
123
+ params=MCPServerStdioParams(
124
+ command=config.command,
125
+ args=config.args,
126
+ env=config.env,
127
+ ),
128
+ cache_tools_list=config.cache_tools,
129
+ client_session_timeout_seconds=config.timeout_seconds,
130
+ tool_filter=tool_filter,
131
+ )
132
+
133
+ elif config.type == "sse":
134
+ if not config.url:
135
+ raise ValueError(f"SSE server '{config.name}' requires a URL")
136
+
137
+ return MCPServerSse(
138
+ name=config.name,
139
+ params=MCPServerSseParams(url=config.url),
140
+ cache_tools_list=config.cache_tools,
141
+ client_session_timeout_seconds=config.timeout_seconds,
142
+ tool_filter=tool_filter,
143
+ )
144
+
145
+ elif config.type == "http":
146
+ if not config.url:
147
+ raise ValueError(f"HTTP server '{config.name}' requires a URL")
148
+
149
+ return MCPServerStreamableHttp(
150
+ name=config.name,
151
+ params=MCPServerStreamableHttpParams(url=config.url),
152
+ cache_tools_list=config.cache_tools,
153
+ client_session_timeout_seconds=config.timeout_seconds,
154
+ tool_filter=tool_filter,
155
+ )
156
+
157
+ else:
158
+ raise ValueError(f"Unknown MCP server type: {config.type}")
159
+
160
+ async def __aenter__(self) -> "MCPManager":
161
+ """Enter async context manager."""
162
+ await self.connect()
163
+ return self
164
+
165
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
166
+ """Exit async context manager."""
167
+ await self.disconnect()