diff --git a/cubbi/cli.py b/cubbi/cli.py index d170e91..eb25a90 100644 --- a/cubbi/cli.py +++ b/cubbi/cli.py @@ -1786,6 +1786,50 @@ def add_remote_mcp( console.print(f"[red]Error adding remote MCP server: {e}[/red]") +@mcp_app.command("add-local") +def add_local_mcp( + name: str = typer.Argument(..., help="MCP server name"), + command: str = typer.Argument(..., help="Path to executable"), + args: List[str] = typer.Option([], "--args", "-a", help="Command arguments"), + env: List[str] = typer.Option( + [], "--env", "-e", help="Environment variables (format: KEY=VALUE)" + ), + no_default: bool = typer.Option( + False, "--no-default", help="Don't add to default MCPs" + ), +) -> None: + """Add a local MCP server""" + # Parse environment variables + environment = {} + for e in env: + if "=" in e: + key, value = e.split("=", 1) + environment[key] = value + else: + console.print(f"[yellow]Warning: Ignoring invalid env format: {e}[/yellow]") + + try: + with console.status(f"Adding local MCP server '{name}'..."): + mcp_manager.add_local_mcp( + name, + command, + args, + environment, + add_as_default=not no_default, + ) + console.print(f"[green]Added local MCP server '{name}'[/green]") + console.print(f"Command: {command}") + if args: + console.print(f"Arguments: {' '.join(args)}") + if not no_default: + console.print(f"MCP server '{name}' added to defaults") + else: + console.print(f"MCP server '{name}' not added to defaults") + + except Exception as e: + console.print(f"[red]Error adding local MCP server: {e}[/red]") + + @mcp_app.command("inspector") def run_mcp_inspector( client_port: int = typer.Option( diff --git a/cubbi/configure.py b/cubbi/configure.py index c806724..323d591 100644 --- a/cubbi/configure.py +++ b/cubbi/configure.py @@ -448,8 +448,22 @@ class ProviderConfigurator: if mcp_configs: for mcp_config in mcp_configs: name = mcp_config.get("name", "unknown") + mcp_type = mcp_config.get("type", "unknown") is_default = " (default)" if name in default_mcps else "" - console.print(f" - {name}{is_default}") + + # Add additional info for local MCPs + if mcp_type == "local": + command = mcp_config.get("command", "") + args = mcp_config.get("args", []) + if args: + cmd_display = f"{command} {' '.join(args[:2])}" + if len(args) > 2: + cmd_display += "..." + else: + cmd_display = command + console.print(f" - {name} ({mcp_type}: {cmd_display}){is_default}") + else: + console.print(f" - {name} ({mcp_type}){is_default}") else: console.print(" (no MCP servers configured)") @@ -530,6 +544,7 @@ class ProviderConfigurator: mcp_type = questionary.select( "Select MCP server type:", choices=[ + "Local MCP (stdio-based command)", "Remote MCP (URL-based)", "Docker MCP (containerized)", "Proxy MCP (proxy + base image)", @@ -539,7 +554,9 @@ class ProviderConfigurator: if mcp_type is None: return - if "Remote MCP" in mcp_type: + if "Local MCP" in mcp_type: + self._add_local_mcp() + elif "Remote MCP" in mcp_type: self._add_remote_mcp() elif "Docker MCP" in mcp_type: self._add_docker_mcp() @@ -729,6 +746,75 @@ class ProviderConfigurator: console.print(f"[green]Added Proxy MCP server '{name}'[/green]") + def _add_local_mcp(self) -> None: + """Add a local MCP server.""" + name = questionary.text( + "Enter MCP server name:", + validate=lambda n: len(n.strip()) > 0 or "Please enter a name", + ).ask() + + if name is None: + return + + command = questionary.text( + "Enter command path (e.g., 'npx', '/usr/bin/node', 'python'):", + validate=lambda c: len(c.strip()) > 0 or "Please enter a command", + ).ask() + + if command is None: + return + + # Ask for command arguments + args = [] + add_args = questionary.confirm("Add command arguments?").ask() + + if add_args: + console.print( + "[dim]Enter arguments one per line (empty line to finish):[/dim]" + ) + while True: + arg = questionary.text("Argument:").ask() + if not arg or not arg.strip(): + break + args.append(arg.strip()) + + # Ask for environment variables + add_env = questionary.confirm("Add environment variables?").ask() + env = {} + + if add_env: + while True: + env_name = questionary.text( + "Environment variable name (empty to finish):" + ).ask() + if not env_name or not env_name.strip(): + break + + env_value = questionary.text(f"Value for {env_name}:").ask() + if env_value: + env[env_name.strip()] = env_value.strip() + + mcp_config = { + "name": name.strip(), + "type": "local", + "command": command.strip(), + "args": args, + "env": env, + } + + self.user_config.add_mcp_configuration(mcp_config) + + # Ask if it should be a default + make_default = questionary.confirm(f"Add '{name}' to default MCPs?").ask() + if make_default: + self.user_config.add_mcp(name.strip()) + + console.print(f"[green]Added local MCP server '{name}'[/green]") + if args: + console.print(f" Command: {command} {' '.join(args)}") + else: + console.print(f" Command: {command}") + def _edit_mcp_server(self, server_name: str) -> None: """Edit an existing MCP server.""" mcp_config = self.user_config.get_mcp_configuration(server_name) @@ -754,7 +840,11 @@ class ProviderConfigurator: if choice == "View configuration": console.print("\n[bold]MCP server configuration:[/bold]") for key, value in mcp_config.items(): - if isinstance(value, dict) and value: + if key == "args" and isinstance(value, list) and value: + console.print(f" {key}:") + for arg in value: + console.print(f" - {arg}") + elif isinstance(value, dict) and value: console.print(f" {key}:") for sub_key, sub_value in value.items(): console.print(f" {sub_key}: {sub_value}") diff --git a/cubbi/images/crush/crush_plugin.py b/cubbi/images/crush/crush_plugin.py index f38488e..fdf59fe 100644 --- a/cubbi/images/crush/crush_plugin.py +++ b/cubbi/images/crush/crush_plugin.py @@ -184,6 +184,24 @@ class CrushPlugin(ToolPlugin): "transport": {"type": "sse", "url": mcp.url}, "enabled": True, } + elif mcp.type == "local": + if mcp.name and mcp.command: + self.status.log( + f"Adding local MCP server: {mcp.name} - {mcp.command}" + ) + # Crush uses stdio type for local MCPs + transport_config = { + "type": "stdio", + "command": mcp.command, + } + if mcp.args: + transport_config["args"] = mcp.args + if mcp.env: + transport_config["env"] = mcp.env + config_data["mcps"][mcp.name] = { + "transport": transport_config, + "enabled": True, + } elif mcp.type in ["docker", "proxy"]: if mcp.name and mcp.host: mcp_port = mcp.port or 8080 diff --git a/cubbi/images/cubbi_init.py b/cubbi/images/cubbi_init.py index a88933d..1d61419 100755 --- a/cubbi/images/cubbi_init.py +++ b/cubbi/images/cubbi_init.py @@ -56,6 +56,9 @@ class MCPConfig(BaseModel): port: int | None = None url: str | None = None headers: dict[str, str] | None = None + command: str | None = None + args: list[str] = [] + env: dict[str, str] = {} class DefaultsConfig(BaseModel): diff --git a/cubbi/images/goose/goose_plugin.py b/cubbi/images/goose/goose_plugin.py index 3cc0311..3911b95 100644 --- a/cubbi/images/goose/goose_plugin.py +++ b/cubbi/images/goose/goose_plugin.py @@ -202,6 +202,21 @@ class GoosePlugin(ToolPlugin): "uri": mcp.url, "envs": {}, } + elif mcp.type == "local": + if mcp.name and mcp.command: + self.status.log( + f"Adding local MCP extension: {mcp.name} - {mcp.command}" + ) + # Goose uses stdio type for local MCPs + config_data["extensions"][mcp.name] = { + "enabled": True, + "name": mcp.name, + "timeout": 60, + "type": "stdio", + "command": mcp.command, + "args": mcp.args if mcp.args else [], + "envs": mcp.env if mcp.env else {}, + } elif mcp.type in ["docker", "proxy"]: if mcp.name and mcp.host: mcp_port = mcp.port or 8080 diff --git a/cubbi/images/opencode/opencode_plugin.py b/cubbi/images/opencode/opencode_plugin.py index 0066edb..3ad80a4 100644 --- a/cubbi/images/opencode/opencode_plugin.py +++ b/cubbi/images/opencode/opencode_plugin.py @@ -209,6 +209,25 @@ class OpencodePlugin(ToolPlugin): "type": "remote", "url": mcp.url, } + elif mcp.type == "local": + if mcp.name and mcp.command: + self.status.log( + f"Adding local MCP extension: {mcp.name} - {mcp.command}" + ) + # OpenCode expects command as an array with command and args combined + command_array = [mcp.command] + if mcp.args: + command_array.extend(mcp.args) + + mcp_entry: dict[str, str | list[str] | bool | dict[str, str]] = { + "type": "local", + "command": command_array, + "enabled": True, + } + if mcp.env: + # OpenCode expects environment (not env) + mcp_entry["environment"] = mcp.env + config_data["mcp"][mcp.name] = mcp_entry elif mcp.type in ["docker", "proxy"]: if mcp.name and mcp.host: mcp_port: int = mcp.port or 8080 diff --git a/cubbi/mcp.py b/cubbi/mcp.py index 674e1a7..96bd719 100644 --- a/cubbi/mcp.py +++ b/cubbi/mcp.py @@ -10,7 +10,7 @@ from typing import Any, Dict, List, Optional import docker from docker.errors import DockerException, ImageNotFound, NotFound -from .models import DockerMCP, MCPContainer, MCPStatus, ProxyMCP, RemoteMCP +from .models import DockerMCP, LocalMCP, MCPContainer, MCPStatus, ProxyMCP, RemoteMCP from .user_config import UserConfigManager # Configure logging @@ -250,6 +250,56 @@ class MCPManager: return mcp_config + def add_local_mcp( + self, + name: str, + command: str, + args: List[str] = None, + env: Dict[str, str] = None, + add_as_default: bool = True, + ) -> Dict[str, Any]: + """Add a local MCP server. + + Args: + name: Name of the MCP server + command: Path to executable + args: Command arguments + env: Environment variables to set for the command + add_as_default: Whether to add this MCP to the default MCPs list + + Returns: + The MCP configuration dictionary + """ + # Create the Local MCP configuration + local_mcp = LocalMCP( + name=name, + command=command, + args=args or [], + env=env or {}, + ) + + # Add to the configuration + mcps = self.list_mcps() + + # Remove existing MCP with the same name if it exists + mcps = [mcp for mcp in mcps if mcp.get("name") != name] + + # Add the new MCP + mcp_config = local_mcp.model_dump() + mcps.append(mcp_config) + + # Save the configuration + self.config_manager.set("mcps", mcps) + + # Add to default MCPs if requested + if add_as_default: + default_mcps = self.config_manager.get("defaults.mcps", []) + if name not in default_mcps: + default_mcps.append(name) + self.config_manager.set("defaults.mcps", default_mcps) + + return mcp_config + def remove_mcp(self, name: str) -> bool: """Remove an MCP server configuration. @@ -359,6 +409,14 @@ class MCPManager: "type": "remote", } + elif mcp_type == "local": + # Local MCP servers don't need containers + return { + "status": "not_applicable", + "name": name, + "type": "local", + } + elif mcp_type == "docker": # Pull the image if needed try: @@ -637,8 +695,8 @@ ENTRYPOINT ["/entrypoint.sh"] ) return True - # Remote MCPs don't have containers to stop - if mcp_config.get("type") == "remote": + # Remote and Local MCPs don't have containers to stop + if mcp_config.get("type") in ["remote", "local"]: return True # Get the container name @@ -677,12 +735,12 @@ ENTRYPOINT ["/entrypoint.sh"] if not mcp_config: raise ValueError(f"MCP server '{name}' not found") - # Remote MCPs don't have containers to restart - if mcp_config.get("type") == "remote": + # Remote and Local MCPs don't have containers to restart + if mcp_config.get("type") in ["remote", "local"]: return { "status": "not_applicable", "name": name, - "type": "remote", + "type": mcp_config.get("type"), } # Get the container name @@ -723,6 +781,16 @@ ENTRYPOINT ["/entrypoint.sh"] "url": mcp_config.get("url"), } + # Local MCPs don't have containers + if mcp_config.get("type") == "local": + return { + "status": "not_applicable", + "name": name, + "type": "local", + "command": mcp_config.get("command"), + "args": mcp_config.get("args", []), + } + # Get the container name container_name = self.get_mcp_container_name(name) @@ -794,9 +862,11 @@ ENTRYPOINT ["/entrypoint.sh"] if not mcp_config: raise ValueError(f"MCP server '{name}' not found") - # Remote MCPs don't have logs + # Remote and Local MCPs don't have logs if mcp_config.get("type") == "remote": return "Remote MCPs don't have local logs" + if mcp_config.get("type") == "local": + return "Local MCPs don't have container logs" # Get the container name container_name = self.get_mcp_container_name(name) diff --git a/cubbi/models.py b/cubbi/models.py index 94f3193..221c7a3 100644 --- a/cubbi/models.py +++ b/cubbi/models.py @@ -71,7 +71,15 @@ class ProxyMCP(BaseModel): host_port: Optional[int] = None # External port to bind the SSE port to on the host -MCP = Union[RemoteMCP, DockerMCP, ProxyMCP] +class LocalMCP(BaseModel): + name: str + type: str = "local" + command: str # Path to executable + args: List[str] = Field(default_factory=list) # Command arguments + env: Dict[str, str] = Field(default_factory=dict) # Environment variables + + +MCP = Union[RemoteMCP, DockerMCP, ProxyMCP, LocalMCP] class MCPContainer(BaseModel):