code-puppy 0.0.341__py3-none-any.whl → 0.0.348__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 (26) hide show
  1. code_puppy/agents/base_agent.py +17 -248
  2. code_puppy/agents/event_stream_handler.py +257 -0
  3. code_puppy/cli_runner.py +4 -3
  4. code_puppy/command_line/add_model_menu.py +8 -9
  5. code_puppy/command_line/mcp/catalog_server_installer.py +5 -6
  6. code_puppy/command_line/mcp/custom_server_form.py +54 -19
  7. code_puppy/command_line/mcp/custom_server_installer.py +8 -9
  8. code_puppy/command_line/mcp/handler.py +0 -2
  9. code_puppy/command_line/mcp/help_command.py +1 -5
  10. code_puppy/command_line/mcp/start_command.py +36 -18
  11. code_puppy/command_line/onboarding_slides.py +0 -1
  12. code_puppy/command_line/prompt_toolkit_completion.py +16 -10
  13. code_puppy/command_line/utils.py +54 -0
  14. code_puppy/mcp_/async_lifecycle.py +35 -4
  15. code_puppy/mcp_/managed_server.py +49 -20
  16. code_puppy/mcp_/manager.py +81 -52
  17. code_puppy/messaging/message_queue.py +11 -23
  18. code_puppy/tools/agent_tools.py +66 -13
  19. {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/METADATA +1 -1
  20. {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/RECORD +25 -25
  21. code_puppy/command_line/mcp/add_command.py +0 -170
  22. {code_puppy-0.0.341.data → code_puppy-0.0.348.data}/data/code_puppy/models.json +0 -0
  23. {code_puppy-0.0.341.data → code_puppy-0.0.348.data}/data/code_puppy/models_dev_api.json +0 -0
  24. {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/WHEEL +0 -0
  25. {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/entry_points.txt +0 -0
  26. {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/licenses/LICENSE +0 -0
@@ -7,6 +7,7 @@ MCP servers from the catalog.
7
7
  import os
8
8
  from typing import Dict, Optional
9
9
 
10
+ from code_puppy.command_line.utils import safe_input
10
11
  from code_puppy.messaging import emit_info, emit_success, emit_warning
11
12
 
12
13
  # Helpful hints for common environment variables
@@ -52,7 +53,7 @@ def prompt_for_server_config(manager, server) -> Optional[Dict]:
52
53
  # Get custom name
53
54
  default_name = server.name
54
55
  try:
55
- name_input = input(f" Server name [{default_name}]: ").strip()
56
+ name_input = safe_input(f" Server name [{default_name}]: ")
56
57
  server_name = name_input if name_input else default_name
57
58
  except (KeyboardInterrupt, EOFError):
58
59
  emit_info("")
@@ -63,9 +64,7 @@ def prompt_for_server_config(manager, server) -> Optional[Dict]:
63
64
  existing = find_server_id_by_name(manager, server_name)
64
65
  if existing:
65
66
  try:
66
- override = input(
67
- f" Server '{server_name}' exists. Override? [y/N]: "
68
- ).strip()
67
+ override = safe_input(f" Server '{server_name}' exists. Override? [y/N]: ")
69
68
  if not override.lower().startswith("y"):
70
69
  emit_warning("Installation cancelled")
71
70
  return None
@@ -91,7 +90,7 @@ def prompt_for_server_config(manager, server) -> Optional[Dict]:
91
90
  hint = get_env_var_hint(var)
92
91
  if hint:
93
92
  emit_info(f" {hint}")
94
- value = input(f" Enter {var}: ").strip()
93
+ value = safe_input(f" Enter {var}: ")
95
94
  if value:
96
95
  env_vars[var] = value
97
96
  # Save to config for future use
@@ -119,7 +118,7 @@ def prompt_for_server_config(manager, server) -> Optional[Dict]:
119
118
  prompt_str += " (optional)"
120
119
 
121
120
  try:
122
- value = input(f"{prompt_str}: ").strip()
121
+ value = safe_input(f"{prompt_str}: ")
123
122
  if value:
124
123
  cmd_args[name] = value
125
124
  elif default:
@@ -43,7 +43,7 @@ CUSTOM_SERVER_EXAMPLES = {
43
43
  "type": "http",
44
44
  "url": "http://localhost:8080/mcp",
45
45
  "headers": {
46
- "Authorization": "Bearer YOUR_API_KEY",
46
+ "Authorization": "Bearer $MY_API_KEY",
47
47
  "Content-Type": "application/json"
48
48
  },
49
49
  "timeout": 30
@@ -52,7 +52,7 @@ CUSTOM_SERVER_EXAMPLES = {
52
52
  "type": "sse",
53
53
  "url": "http://localhost:8080/sse",
54
54
  "headers": {
55
- "Authorization": "Bearer YOUR_API_KEY"
55
+ "Authorization": "Bearer $MY_API_KEY"
56
56
  }
57
57
  }""",
58
58
  }
@@ -367,24 +367,59 @@ class CustomServerForm:
367
367
  config_dict = json.loads(self.json_config)
368
368
 
369
369
  try:
370
- server_config = ServerConfig(
371
- id=server_name,
372
- name=server_name,
373
- type=server_type,
374
- enabled=True,
375
- config=config_dict,
376
- )
377
-
378
- # Register with manager
379
- server_id = self.manager.register_server(server_config)
380
-
381
- if not server_id:
382
- self.validation_error = "Failed to register server"
383
- self.status_message = (
384
- "Save failed: Could not register server (name may already exist)"
370
+ # In edit mode, find the existing server and update it
371
+ if self.edit_mode and self.original_name:
372
+ existing_config = self.manager.get_server_by_name(self.original_name)
373
+ if existing_config:
374
+ # Use the existing server's ID for the update
375
+ server_config = ServerConfig(
376
+ id=existing_config.id,
377
+ name=server_name,
378
+ type=server_type,
379
+ enabled=True,
380
+ config=config_dict,
381
+ )
382
+
383
+ # Update the server in the manager
384
+ success = self.manager.update_server(
385
+ existing_config.id, server_config
386
+ )
387
+
388
+ if not success:
389
+ self.validation_error = "Failed to update server"
390
+ self.status_message = "Save failed: Could not update server"
391
+ self.status_is_error = True
392
+ return False
393
+
394
+ server_id = existing_config.id
395
+ else:
396
+ # Original server not found, treat as new registration
397
+ server_config = ServerConfig(
398
+ id=server_name,
399
+ name=server_name,
400
+ type=server_type,
401
+ enabled=True,
402
+ config=config_dict,
403
+ )
404
+ server_id = self.manager.register_server(server_config)
405
+ else:
406
+ # New server - register it
407
+ server_config = ServerConfig(
408
+ id=server_name,
409
+ name=server_name,
410
+ type=server_type,
411
+ enabled=True,
412
+ config=config_dict,
385
413
  )
386
- self.status_is_error = True
387
- return False
414
+
415
+ # Register with manager
416
+ server_id = self.manager.register_server(server_config)
417
+
418
+ if not server_id:
419
+ self.validation_error = "Failed to register server"
420
+ self.status_message = "Save failed: Could not register server (name may already exist)"
421
+ self.status_is_error = True
422
+ return False
388
423
 
389
424
  # Save to mcp_servers.json for persistence
390
425
  if os.path.exists(MCP_SERVERS_FILE):
@@ -7,6 +7,7 @@ custom MCP servers with JSON configuration.
7
7
  import json
8
8
  import os
9
9
 
10
+ from code_puppy.command_line.utils import safe_input
10
11
  from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning
11
12
 
12
13
  # Example configurations for each server type
@@ -24,7 +25,7 @@ CUSTOM_SERVER_EXAMPLES = {
24
25
  "type": "http",
25
26
  "url": "http://localhost:8080/mcp",
26
27
  "headers": {
27
- "Authorization": "Bearer YOUR_API_KEY",
28
+ "Authorization": "Bearer $MY_API_KEY",
28
29
  "Content-Type": "application/json"
29
30
  },
30
31
  "timeout": 30
@@ -33,7 +34,7 @@ CUSTOM_SERVER_EXAMPLES = {
33
34
  "type": "sse",
34
35
  "url": "http://localhost:8080/sse",
35
36
  "headers": {
36
- "Authorization": "Bearer YOUR_API_KEY"
37
+ "Authorization": "Bearer $MY_API_KEY"
37
38
  }
38
39
  }""",
39
40
  }
@@ -58,7 +59,7 @@ def prompt_and_install_custom_server(manager) -> bool:
58
59
 
59
60
  # Get server name
60
61
  try:
61
- server_name = input(" Server name: ").strip()
62
+ server_name = safe_input(" Server name: ")
62
63
  if not server_name:
63
64
  emit_warning("Server name is required")
64
65
  return False
@@ -71,9 +72,7 @@ def prompt_and_install_custom_server(manager) -> bool:
71
72
  existing = find_server_id_by_name(manager, server_name)
72
73
  if existing:
73
74
  try:
74
- override = input(
75
- f" Server '{server_name}' exists. Override? [y/N]: "
76
- ).strip()
75
+ override = safe_input(f" Server '{server_name}' exists. Override? [y/N]: ")
77
76
  if not override.lower().startswith("y"):
78
77
  emit_warning("Cancelled")
79
78
  return False
@@ -89,7 +88,7 @@ def prompt_and_install_custom_server(manager) -> bool:
89
88
  emit_info(" 3. 📡 sse - Server-Sent Events\n")
90
89
 
91
90
  try:
92
- type_choice = input(" Enter choice [1-3]: ").strip()
91
+ type_choice = safe_input(" Enter choice [1-3]: ")
93
92
  except (KeyboardInterrupt, EOFError):
94
93
  emit_info("")
95
94
  emit_warning("Cancelled")
@@ -115,8 +114,8 @@ def prompt_and_install_custom_server(manager) -> bool:
115
114
  empty_count = 0
116
115
  try:
117
116
  while True:
118
- line = input()
119
- if line.strip() == "":
117
+ line = safe_input("")
118
+ if line == "":
120
119
  empty_count += 1
121
120
  if empty_count >= 2:
122
121
  break
@@ -12,7 +12,6 @@ from rich.text import Text
12
12
 
13
13
  from code_puppy.messaging import emit_info
14
14
 
15
- from .add_command import AddCommand
16
15
  from .base import MCPCommandBase
17
16
  from .edit_command import EditCommand
18
17
  from .help_command import HelpCommand
@@ -63,7 +62,6 @@ class MCPCommandHandler(MCPCommandBase):
63
62
  "restart": RestartCommand(),
64
63
  "status": StatusCommand(),
65
64
  "test": TestCommand(),
66
- "add": AddCommand(),
67
65
  "edit": EditCommand(),
68
66
  "remove": RemoveCommand(),
69
67
  "logs": LogsCommand(),
@@ -101,10 +101,6 @@ class HelpCommand(MCPCommandBase):
101
101
  Text("/mcp logs", style="cyan")
102
102
  + Text(" <name> [limit] Show recent events (default limit: 10)")
103
103
  )
104
- help_lines.append(
105
- Text("/mcp add", style="cyan")
106
- + Text(" [json] Add new server (JSON or wizard)")
107
- )
108
104
  help_lines.append(
109
105
  Text("/mcp edit", style="cyan")
110
106
  + Text(" <name> Edit existing server config")
@@ -134,7 +130,7 @@ class HelpCommand(MCPCommandBase):
134
130
  /mcp start-all # Start all servers at once
135
131
  /mcp stop-all # Stop all running servers
136
132
  /mcp edit filesystem # Edit an existing server config
137
- /mcp add {"name": "test", "type": "stdio", "command": "echo"}"""
133
+ /mcp remove filesystem # Remove a server"""
138
134
  help_lines.append(Text(examples_text, style="dim"))
139
135
 
140
136
  # Combine all lines
@@ -3,7 +3,6 @@ MCP Start Command - Starts a specific MCP server.
3
3
  """
4
4
 
5
5
  import logging
6
- import time
7
6
  from typing import List, Optional
8
7
 
9
8
  from rich.text import Text
@@ -23,6 +22,7 @@ class StartCommand(MCPCommandBase):
23
22
  Command handler for starting MCP servers.
24
23
 
25
24
  Starts a specific MCP server by name and reloads the agent.
25
+ The server subprocess starts asynchronously in the background.
26
26
  """
27
27
 
28
28
  def execute(self, args: List[str], group_id: Optional[str] = None) -> None:
@@ -56,31 +56,49 @@ class StartCommand(MCPCommandBase):
56
56
  suggest_similar_servers(self.manager, server_name, group_id=group_id)
57
57
  return
58
58
 
59
- # Start the server (enable and start process)
59
+ # Get server info for better messaging (safely handle missing method)
60
+ server_type = "unknown"
61
+ try:
62
+ if hasattr(self.manager, "get_server_by_name"):
63
+ server_config = self.manager.get_server_by_name(server_name)
64
+ server_type = (
65
+ getattr(server_config, "type", "unknown")
66
+ if server_config
67
+ else "unknown"
68
+ )
69
+ except Exception:
70
+ pass # Default to unknown type if we can't determine it
71
+
72
+ # Start the server (schedules async start in background)
60
73
  success = self.manager.start_server_sync(server_id)
61
74
 
62
75
  if success:
63
- # This and subsequent messages will auto-group with the first message
64
- emit_success(
65
- f"Started server: {server_name}",
66
- message_group=group_id,
67
- )
68
-
69
- # Give async tasks a moment to complete
70
- try:
71
- import asyncio
72
-
73
- asyncio.get_running_loop() # Check if in async context
74
- # If we're in async context, wait a bit for server to start
75
- time.sleep(0.5) # Small delay to let async tasks progress
76
- except RuntimeError:
77
- pass # No async loop, server will start when agent uses it
76
+ if server_type == "stdio":
77
+ # Stdio servers start subprocess asynchronously
78
+ emit_success(
79
+ f"🚀 Starting server: {server_name} (subprocess starting in background)",
80
+ message_group=group_id,
81
+ )
82
+ emit_info(
83
+ Text.from_markup(
84
+ "[dim]Tip: Use /mcp status to check if the server is fully initialized[/dim]"
85
+ ),
86
+ message_group=group_id,
87
+ )
88
+ else:
89
+ # SSE/HTTP servers connect on first use
90
+ emit_success(
91
+ f"✅ Enabled server: {server_name}",
92
+ message_group=group_id,
93
+ )
78
94
 
79
95
  # Reload the agent to pick up the newly enabled server
96
+ # NOTE: We don't block or wait - the server will be ready
97
+ # when the next prompt runs (pydantic-ai handles connection)
80
98
  try:
81
99
  agent = get_current_agent()
82
100
  agent.reload_code_generation_agent()
83
- # Update MCP tool cache immediately so token counts reflect the change
101
+ # Clear MCP tool cache - it will be repopulated on next run
84
102
  agent.update_mcp_tool_cache_sync()
85
103
  emit_info(
86
104
  "Agent reloaded with updated servers",
@@ -122,7 +122,6 @@ def slide_mcp() -> str:
122
122
  content += "[white]Supercharge with external tools![/white]\n\n"
123
123
  content += "[green]Commands:[/green]\n"
124
124
  content += " [cyan]/mcp install[/cyan] Browse catalog\n"
125
- content += " [cyan]/mcp add[/cyan] Add custom server\n"
126
125
  content += " [cyan]/mcp list[/cyan] See your servers\n\n"
127
126
  content += "[yellow]🌟 Popular picks:[/yellow]\n"
128
127
  content += " • GitHub integration\n"
@@ -648,30 +648,36 @@ async def get_input_with_combined_completion(
648
648
  else:
649
649
  event.current_buffer.validate_and_handle()
650
650
 
651
- # Handle bracketed paste (triggered by most terminal Cmd+V / Ctrl+V)
652
- # This is the PRIMARY paste handler - works with Cmd+V on macOS terminals
651
+ # Handle bracketed paste - smart detection for text vs images.
652
+ # Most terminals (Windows included!) send Ctrl+V through bracketed paste.
653
+ # - If there's meaningful text content → paste as text (drag-and-drop file paths, copied text)
654
+ # - If text is empty/whitespace → check for clipboard image (image paste on Windows)
653
655
  @bindings.add(Keys.BracketedPaste)
654
656
  def handle_bracketed_paste(event):
655
- """Handle bracketed paste - works with Cmd+V on macOS terminals."""
656
- # The pasted data is in event.data
657
+ """Handle bracketed paste - smart text vs image detection."""
657
658
  pasted_data = event.data
658
659
 
659
- # Check if clipboard has an image (the pasted text might just be empty or a file path)
660
+ # If we have meaningful text content, paste it (don't check for images)
661
+ # This handles drag-and-drop file paths and normal text paste
662
+ if pasted_data and pasted_data.strip():
663
+ # Normalize Windows line endings to Unix style
664
+ sanitized_data = pasted_data.replace("\r\n", "\n").replace("\r", "\n")
665
+ event.app.current_buffer.insert_text(sanitized_data)
666
+ return
667
+
668
+ # No meaningful text - check if clipboard has an image (Windows image paste!)
660
669
  try:
661
670
  if has_image_in_clipboard():
662
671
  placeholder = capture_clipboard_image_to_pending()
663
672
  if placeholder:
664
673
  event.app.current_buffer.insert_text(placeholder + " ")
665
- # The placeholder itself is visible feedback - no need for extra output
666
- # Use bell for audible feedback (works in most terminals)
667
674
  event.app.output.bell()
668
- return # Don't also paste the text data
675
+ return
669
676
  except Exception:
670
677
  pass
671
678
 
672
- # No image - insert the pasted text as normal, sanitizing Windows newlines
679
+ # Fallback: if there was whitespace-only data, paste it
673
680
  if pasted_data:
674
- # Normalize Windows line endings to Unix style
675
681
  sanitized_data = pasted_data.replace("\r\n", "\n").replace("\r", "\n")
676
682
  event.app.current_buffer.insert_text(sanitized_data)
677
683
 
@@ -37,3 +37,57 @@ def make_directory_table(path: str = None) -> Table:
37
37
  for f in sorted(files):
38
38
  table.add_row("[yellow]file[/yellow]", f"{f}")
39
39
  return table
40
+
41
+
42
+ def _reset_windows_console() -> None:
43
+ """Reset Windows console to normal input mode.
44
+
45
+ After a prompt_toolkit Application exits on Windows, the console can be
46
+ left in a weird state where Enter doesn't work properly. This resets it.
47
+ """
48
+ import sys
49
+
50
+ if sys.platform != "win32":
51
+ return
52
+
53
+ try:
54
+ import ctypes
55
+
56
+ kernel32 = ctypes.windll.kernel32
57
+ # Get handle to stdin
58
+ STD_INPUT_HANDLE = -10
59
+ handle = kernel32.GetStdHandle(STD_INPUT_HANDLE)
60
+
61
+ # Enable line input and echo (normal console mode)
62
+ # ENABLE_LINE_INPUT = 0x0002
63
+ # ENABLE_ECHO_INPUT = 0x0004
64
+ # ENABLE_PROCESSED_INPUT = 0x0001
65
+ NORMAL_MODE = 0x0007 # Line input + echo + processed
66
+ kernel32.SetConsoleMode(handle, NORMAL_MODE)
67
+ except Exception:
68
+ pass # Silently ignore errors - this is best-effort
69
+
70
+
71
+ def safe_input(prompt_text: str = "") -> str:
72
+ """Cross-platform safe input that works after prompt_toolkit Applications.
73
+
74
+ On Windows, raw input() can fail after a prompt_toolkit Application exits
75
+ because the terminal can be left in a weird state. This function resets
76
+ the Windows console mode before calling input().
77
+
78
+ Args:
79
+ prompt_text: The prompt to display to the user
80
+
81
+ Returns:
82
+ The user's input string (stripped)
83
+
84
+ Raises:
85
+ KeyboardInterrupt: If user presses Ctrl+C
86
+ EOFError: If user presses Ctrl+D/Ctrl+Z
87
+ """
88
+ # Reset Windows console to normal mode before reading input
89
+ _reset_windows_console()
90
+
91
+ # Use standard input() - now that console is reset, it should work
92
+ result = input(prompt_text)
93
+ return result.strip() if result else ""
@@ -108,10 +108,17 @@ class AsyncServerLifecycleManager:
108
108
 
109
109
  try:
110
110
  logger.info(f"Starting server lifecycle for {server_id}")
111
+ logger.info(
112
+ f"Server {server_id} _running_count before enter: {getattr(server, '_running_count', 'N/A')}"
113
+ )
111
114
 
112
115
  # Enter the server's context
113
116
  await exit_stack.enter_async_context(server)
114
117
 
118
+ logger.info(
119
+ f"Server {server_id} _running_count after enter: {getattr(server, '_running_count', 'N/A')}"
120
+ )
121
+
115
122
  # Store the managed context
116
123
  async with self._lock:
117
124
  self._servers[server_id] = ManagedServerContext(
@@ -122,26 +129,50 @@ class AsyncServerLifecycleManager:
122
129
  task=asyncio.current_task(),
123
130
  )
124
131
 
125
- logger.info(f"Server {server_id} started successfully")
132
+ logger.info(
133
+ f"Server {server_id} started successfully and stored in _servers"
134
+ )
126
135
 
127
136
  # Keep the task alive until cancelled
137
+ loop_count = 0
128
138
  while True:
129
139
  await asyncio.sleep(1)
140
+ loop_count += 1
130
141
 
131
142
  # Check if server is still running
132
- if not server.is_running:
133
- logger.warning(f"Server {server_id} stopped unexpectedly")
143
+ running_count = getattr(server, "_running_count", "N/A")
144
+ is_running = server.is_running
145
+ logger.debug(
146
+ f"Server {server_id} heartbeat #{loop_count}: "
147
+ f"is_running={is_running}, _running_count={running_count}"
148
+ )
149
+
150
+ if not is_running:
151
+ logger.warning(
152
+ f"Server {server_id} stopped unexpectedly! "
153
+ f"_running_count={running_count}"
154
+ )
134
155
  break
135
156
 
136
157
  except asyncio.CancelledError:
137
158
  logger.info(f"Server {server_id} lifecycle task cancelled")
138
159
  raise
139
160
  except Exception as e:
140
- logger.error(f"Error in server {server_id} lifecycle: {e}")
161
+ logger.error(f"Error in server {server_id} lifecycle: {e}", exc_info=True)
141
162
  finally:
163
+ running_count = getattr(server, "_running_count", "N/A")
164
+ logger.info(
165
+ f"Server {server_id} lifecycle ending, _running_count={running_count}"
166
+ )
167
+
142
168
  # Clean up the context
143
169
  await exit_stack.aclose()
144
170
 
171
+ running_count_after = getattr(server, "_running_count", "N/A")
172
+ logger.info(
173
+ f"Server {server_id} context closed, _running_count={running_count_after}"
174
+ )
175
+
145
176
  # Remove from managed servers
146
177
  async with self._lock:
147
178
  if server_id in self._servers:
@@ -28,6 +28,31 @@ from code_puppy.mcp_.blocking_startup import BlockingMCPServerStdio
28
28
  from code_puppy.messaging import emit_info
29
29
 
30
30
 
31
+ def _expand_env_vars(value: Any) -> Any:
32
+ """
33
+ Recursively expand environment variables in config values.
34
+
35
+ Supports $VAR and ${VAR} syntax. Works with:
36
+ - Strings: expands env vars
37
+ - Dicts: recursively expands all string values
38
+ - Lists: recursively expands all string elements
39
+ - Other types: returned as-is
40
+
41
+ Args:
42
+ value: The value to expand env vars in
43
+
44
+ Returns:
45
+ The value with env vars expanded
46
+ """
47
+ if isinstance(value, str):
48
+ return os.path.expandvars(value)
49
+ elif isinstance(value, dict):
50
+ return {k: _expand_env_vars(v) for k, v in value.items()}
51
+ elif isinstance(value, list):
52
+ return [_expand_env_vars(item) for item in value]
53
+ return value
54
+
55
+
31
56
  class ServerState(Enum):
32
57
  """Enumeration of possible server states."""
33
58
 
@@ -153,9 +178,9 @@ class ManagedMCPServer:
153
178
  if "url" not in config:
154
179
  raise ValueError("SSE server requires 'url' in config")
155
180
 
156
- # Prepare arguments for MCPServerSSE
181
+ # Prepare arguments for MCPServerSSE (expand env vars in URL)
157
182
  sse_kwargs = {
158
- "url": config["url"],
183
+ "url": _expand_env_vars(config["url"]),
159
184
  }
160
185
 
161
186
  # Add optional parameters if provided
@@ -177,23 +202,26 @@ class ManagedMCPServer:
177
202
  if "command" not in config:
178
203
  raise ValueError("Stdio server requires 'command' in config")
179
204
 
180
- # Handle command and arguments
181
- command = config["command"]
205
+ # Handle command and arguments (expand env vars)
206
+ command = _expand_env_vars(config["command"])
182
207
  args = config.get("args", [])
183
208
  if isinstance(args, str):
184
- # If args is a string, split it
185
- args = args.split()
209
+ # If args is a string, split it then expand
210
+ args = [_expand_env_vars(a) for a in args.split()]
211
+ else:
212
+ args = _expand_env_vars(args)
186
213
 
187
214
  # Prepare arguments for MCPServerStdio
188
215
  stdio_kwargs = {"command": command, "args": list(args) if args else []}
189
216
 
190
- # Add optional parameters if provided
217
+ # Add optional parameters if provided (expand env vars in env and cwd)
191
218
  if "env" in config:
192
- stdio_kwargs["env"] = config["env"]
219
+ stdio_kwargs["env"] = _expand_env_vars(config["env"])
193
220
  if "cwd" in config:
194
- stdio_kwargs["cwd"] = config["cwd"]
195
- if "timeout" in config:
196
- stdio_kwargs["timeout"] = config["timeout"]
221
+ stdio_kwargs["cwd"] = _expand_env_vars(config["cwd"])
222
+ # Default timeout of 60s for stdio servers - some servers like Serena take a while to start
223
+ # Users can override this in their config
224
+ stdio_kwargs["timeout"] = config.get("timeout", 60)
197
225
  if "read_timeout" in config:
198
226
  stdio_kwargs["read_timeout"] = config["read_timeout"]
199
227
 
@@ -212,9 +240,9 @@ class ManagedMCPServer:
212
240
  if "url" not in config:
213
241
  raise ValueError("HTTP server requires 'url' in config")
214
242
 
215
- # Prepare arguments for MCPServerStreamableHTTP
243
+ # Prepare arguments for MCPServerStreamableHTTP (expand env vars in URL)
216
244
  http_kwargs = {
217
- "url": config["url"],
245
+ "url": _expand_env_vars(config["url"]),
218
246
  }
219
247
 
220
248
  # Add optional parameters if provided
@@ -223,13 +251,14 @@ class ManagedMCPServer:
223
251
  if "read_timeout" in config:
224
252
  http_kwargs["read_timeout"] = config["read_timeout"]
225
253
 
226
- # Handle http_client vs headers (mutually exclusive)
227
- if "http_client" in config:
228
- # Use provided http_client
229
- http_kwargs["http_client"] = config["http_client"]
230
- elif config.get("headers"):
231
- # Create HTTP client if headers are provided but no client specified
232
- http_kwargs["http_client"] = self._get_http_client()
254
+ # Pass headers directly instead of creating http_client
255
+ # Note: There's a bug in MCP 1.25.0 where passing http_client
256
+ # causes "'_AsyncGeneratorContextManager' object has no attribute 'stream'"
257
+ # The workaround is to pass headers directly and let pydantic-ai
258
+ # create the http_client internally.
259
+ if config.get("headers"):
260
+ # Expand environment variables in headers
261
+ http_kwargs["headers"] = _expand_env_vars(config["headers"])
233
262
 
234
263
  self._pydantic_server = MCPServerStreamableHTTP(
235
264
  **http_kwargs, process_tool_call=process_tool_call