Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
normalize_vector,
)
from cecli.helpers.skills import SkillsManager
from cecli.mcp.server import LocalServer
from cecli.mcp import LocalServer, McpServerManager
from cecli.repo import ANY_GIT_ERROR
from cecli.tools.utils.registry import ToolRegistry

Expand Down Expand Up @@ -197,14 +197,17 @@ async def initialize_mcp_tools(self):
local_tools = self.get_local_tool_schemas()
if not local_tools:
return

local_server_config = {"name": server_name}
local_server = LocalServer(local_server_config)
if not self.mcp_servers:
self.mcp_servers = []
if not any(isinstance(s, LocalServer) for s in self.mcp_servers):
self.mcp_servers.append(local_server)

if not self.mcp_manager:
self.mcp_manager = McpServerManager()
if not self.mcp_manager.get_server(server_name):
await self.mcp_manager.add_server(local_server)
if not self.mcp_tools:
self.mcp_tools = []

if server_name not in [name for name, _ in self.mcp_tools]:
self.mcp_tools.append((local_server.name, local_tools))

Expand Down Expand Up @@ -245,9 +248,7 @@ async def _execute_local_tool_calls(self, tool_calls_list):
t.get("function", {}).get("name") == norm_tool_name
for t in server_tools
):
server = next(
(s for s in self.mcp_servers if s.name == server_name), None
)
server = self.mcp_manager.get_server(server_name)
if server:
for params in parsed_args_list:
tasks.append(
Expand Down Expand Up @@ -943,7 +944,7 @@ async def _execute_tool_with_registry(self, norm_tool_name, params):
if self.mcp_tools:
for server_name, server_tools in self.mcp_tools:
if any(t.get("function", {}).get("name") == norm_tool_name for t in server_tools):
server = next((s for s in self.mcp_servers if s.name == server_name), None)
server = self.mcp_manager.get_server(server_name)
if server:
return await self._execute_mcp_tool(server, norm_tool_name, params)
else:
Expand Down
27 changes: 16 additions & 11 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
from cecli.io import ConfirmGroup, InputOutput
from cecli.linter import Linter
from cecli.llm import litellm
from cecli.mcp.server import LocalServer
from cecli.mcp import LocalServer
from cecli.models import RETRY_TIMEOUT
from cecli.reasoning_tags import (
REASONING_TAG,
Expand Down Expand Up @@ -138,7 +138,7 @@ class Coder:
chat_language = None
commit_language = None
file_watcher = None
mcp_servers = None
mcp_manager = None
mcp_tools = None
run_one_completed = True
compact_context_completed = True
Expand Down Expand Up @@ -249,8 +249,8 @@ async def create(

if res is not None:
if from_coder:
if from_coder.mcp_servers and kwargs.get("mcp_servers", False):
res.mcp_servers = from_coder.mcp_servers
if from_coder.mcp_manager:
res.mcp_manager = from_coder.mcp_manager
res.mcp_tools = from_coder.mcp_tools

# Transfer TUI app weak reference
Expand Down Expand Up @@ -316,7 +316,7 @@ def __init__(
file_watcher=None,
auto_copy_context=False,
auto_accept_architect=True,
mcp_servers=None,
mcp_manager=None,
enable_context_compaction=False,
context_compaction_max_tokens=None,
context_compaction_summary_tokens=8192,
Expand Down Expand Up @@ -350,7 +350,7 @@ def __init__(
self.args = args

self.num_cache_warming_pings = num_cache_warming_pings
self.mcp_servers = mcp_servers
self.mcp_manager = mcp_manager
self.enable_context_compaction = enable_context_compaction

self.context_compaction_max_tokens = context_compaction_max_tokens
Expand Down Expand Up @@ -2546,7 +2546,7 @@ def _gather_server_tool_calls(self, tool_calls):
and tool_name_from_schema.lower() == tool_call.function.name.lower()
):
# Find the McpServer instance that will be used for communication
for server in self.mcp_servers:
for server in self.mcp_manager:
if server.name == server_name:
if server not in server_tool_calls:
server_tool_calls[server] = []
Expand Down Expand Up @@ -2724,6 +2724,7 @@ async def initialize_mcp_tools(self):
Initialize tools from all configured MCP servers. MCP Servers that fail to be
initialized will not be available to the Coder instance.
"""
# TODO(@gopar): refactor here once we have fully moved over to use the mcp manager
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still valid since this is a minimal PR to get the codebase using the mcp manager

tools = []

async def get_server_tools(server):
Expand All @@ -2734,9 +2735,13 @@ async def get_server_tools(server):
return (server.name, server_tools)

try:
session = await server.connect()
did_connect = await self.mcp_manager.connect_server(server.name)
if not did_connect:
raise Exception("Failed to load tools")

server = self.mcp_manager.get_server(server.name)
server_tools = await experimental_mcp_client.load_mcp_tools(
session=session, format="openai"
session=server.session, format="openai"
)
return (server.name, server_tools)
except Exception as e:
Expand All @@ -2745,11 +2750,11 @@ async def get_server_tools(server):
return None

async def get_all_server_tools():
tasks = [get_server_tools(server) for server in self.mcp_servers]
tasks = [get_server_tools(server) for server in self.mcp_manager]
results = await asyncio.gather(*tasks)
return [result for result in results if result is not None]

if self.mcp_servers:
if self.mcp_manager:
# Retry initialization in case of CancelledError
max_retries = 3
for i in range(max_retries):
Expand Down
8 changes: 0 additions & 8 deletions cecli/commands/exit.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,6 @@ class ExitCommand(BaseCommand):
@classmethod
async def execute(cls, io, coder, args, **kwargs):
"""Execute the exit command with given parameters."""
for server in coder.mcp_servers:
try:
await server.exit_stack.aclose()
except Exception:
pass

await asyncio.sleep(0)

# Check if running in TUI mode - use graceful exit to restore terminal
if hasattr(io, "request_exit"):
io.request_exit()
Expand Down
16 changes: 7 additions & 9 deletions cecli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
from cecli.history import ChatSummary
from cecli.io import InputOutput
from cecli.llm import litellm
from cecli.mcp import load_mcp_servers
from cecli.mcp import McpServerManager, load_mcp_servers
from cecli.models import ModelSettings
from cecli.onboarding import offer_openrouter_oauth, select_default_model
from cecli.repo import ANY_GIT_ERROR, GitRepo
Expand Down Expand Up @@ -976,8 +976,8 @@ def apply_model_overrides(model_name):
mcp_servers = load_mcp_servers(
args.mcp_servers, args.mcp_servers_file, io, args.verbose, args.mcp_transport
)
if not mcp_servers:
mcp_servers = []
mcp_manager = McpServerManager(mcp_servers, io, args.verbose)

coder = await Coder.create(
main_model=main_model,
edit_format=args.edit_format,
Expand Down Expand Up @@ -1013,7 +1013,7 @@ def apply_model_overrides(model_name):
detect_urls=args.detect_urls,
auto_copy_context=args.copy_paste,
auto_accept_architect=args.auto_accept_architect,
mcp_servers=mcp_servers,
mcp_manager=mcp_manager,
add_gitignore_files=args.add_gitignore_files,
enable_context_compaction=args.enable_context_compaction,
context_compaction_max_tokens=args.context_compaction_max_tokens,
Expand Down Expand Up @@ -1267,11 +1267,9 @@ async def graceful_exit(coder=None, exit_code=0):
if coder:
if hasattr(coder, "_autosave_future"):
await coder._autosave_future
for server in coder.mcp_servers:
try:
await server.exit_stack.aclose()
except Exception:
pass

if coder.mcp_manager and coder.mcp_manager.is_connected:
await coder.mcp_manager.disconnect_all()
return exit_code


Expand Down
168 changes: 14 additions & 154 deletions cecli/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,154 +1,14 @@
import json
from pathlib import Path

from cecli.mcp.server import HttpStreamingServer, McpServer, SseServer


def _parse_mcp_servers_from_json_string(json_string, io, verbose=False, mcp_transport="stdio"):
"""Parse MCP servers from a JSON string."""
servers = []

try:
config = json.loads(json_string)
if verbose:
io.tool_output("Loading MCP servers from provided JSON")

if "mcpServers" in config:
for name, server_config in config["mcpServers"].items():
if verbose:
io.tool_output(f"Loading MCP server: {name}")

# Create a server config with name included
server_config["name"] = name
transport = server_config.get("transport", mcp_transport)
if transport == "stdio":
servers.append(McpServer(server_config, io=io, verbose=verbose))
elif transport == "http":
servers.append(HttpStreamingServer(server_config, io=io, verbose=verbose))
elif transport == "sse":
servers.append(SseServer(server_config, io=io, verbose=verbose))

if verbose:
io.tool_output(f"Loaded {len(servers)} MCP servers")
return servers
else:
io.tool_warning("No 'mcpServers' key found in MCP config")
except json.JSONDecodeError:
io.tool_error("Invalid JSON in MCP config")
except Exception as e:
io.tool_error(f"Error loading MCP config: {e}")

return servers


def _resolve_mcp_config_path(file_path, io, verbose=False):
"""Resolve MCP config file path relative to closest cecli.conf.yml, git directory, or CWD."""
if not file_path:
return None

# If the path is absolute or already exists, use it as-is
path = Path(file_path)
if path.is_absolute() or path.exists():
return str(path.resolve())

# Search for the closest cecli.conf.yml in parent directories
current_dir = Path.cwd()
conf_path = None

for parent in [current_dir] + list(current_dir.parents):
conf_file = parent / ".cecli.conf.yml"
if conf_file.exists():
conf_path = parent
break

# If cecli.conf.yml found, try relative to that directory
if conf_path:
resolved_path = conf_path / file_path
if resolved_path.exists():
if verbose:
io.tool_output(f"Resolved MCP config relative to cecli.conf.yml: {resolved_path}")
return str(resolved_path.resolve())

# Try to find git root directory
git_root = None
try:
import git

repo = git.Repo(search_parent_directories=True)
git_root = Path(repo.working_tree_dir)
except (ImportError, git.InvalidGitRepositoryError, FileNotFoundError):
pass

# If git root found, try relative to that directory
if git_root:
resolved_path = git_root / file_path
if resolved_path.exists():
if verbose:
io.tool_output(f"Resolved MCP config relative to git root: {resolved_path}")
return str(resolved_path.resolve())

# Finally, try relative to current working directory
resolved_path = current_dir / file_path
if resolved_path.exists():
if verbose:
io.tool_output(f"Resolved MCP config relative to CWD: {resolved_path}")
return str(resolved_path.resolve())

# If none found, return the original path (will trigger FileNotFoundError)
return str(path.resolve())


def _parse_mcp_servers_from_file(file_path, io, verbose=False, mcp_transport="stdio"):
"""Parse MCP servers from a JSON file."""
# Resolve the file path relative to closest cecli.conf.yml, git directory, or CWD
resolved_file_path = _resolve_mcp_config_path(file_path, io, verbose)

try:
with open(resolved_file_path, "r") as f:
json_string = f.read()

if verbose:
io.tool_output(f"Loading MCP servers from file: {file_path}")

return _parse_mcp_servers_from_json_string(json_string, io, verbose, mcp_transport)

except FileNotFoundError:
io.tool_warning(f"MCP config file not found: {file_path}")
except Exception as e:
io.tool_error(f"Error reading MCP config file: {e}")

return []


def load_mcp_servers(mcp_servers, mcp_servers_file, io, verbose=False, mcp_transport="stdio"):
"""Load MCP servers from a JSON string or file."""
servers = []

# First try to load from the JSON string (preferred)
if mcp_servers:
servers = _parse_mcp_servers_from_json_string(mcp_servers, io, verbose, mcp_transport)
if servers:
return servers

# If JSON string failed or wasn't provided, try the file
if mcp_servers_file:
servers = _parse_mcp_servers_from_file(mcp_servers_file, io, verbose, mcp_transport)

if not servers:
# A default MCP server is actually now necessary for the overall agentic loop
# and a dummy server does suffice for the job
# because I am not smart enough to figure out why
# on coder switch, the agent actually initializes the prompt area twice
# once immediately after input for the old coder
# and immediately again for the new target coder
# which causes a race condition where we are awaiting a coroutine
# that can no longer yield control (somehow?)
# but somehow having to run through the MCP server checks
# allows control to be yielded again somehow
# and I cannot figure out just how that is happening
# and maybe it is actually prompt_toolkit's fault
# but this hack works swimmingly because ???
# so sure! why not
servers = [McpServer(json.loads('{"cecli_default": {}}'), io=io, verbose=verbose)]

return servers
from .manager import McpServerManager
from .server import HttpStreamingServer, LocalServer, McpServer, SseServer
from .utils import find_available_port, generate_pkce_codes, load_mcp_servers

__all__ = [
"McpServerManager",
"McpServer",
"HttpStreamingServer",
"SseServer",
"LocalServer",
"load_mcp_servers",
"find_available_port",
"generate_pkce_codes",
]
Loading
Loading