feat: add local MCP server support

- Add LocalMCP model for stdio-based MCP servers
- Implement add_local_mcp() method in MCPManager
- Add 'mcp add-local' CLI command with args and env support
- Update cubbi_init.py MCPConfig with command, args, env fields
- Add local MCP support in interactive configure tool
- Update image plugins (opencode, goose, crush) to handle local MCPs
  - OpenCode: Maps to "local" type with command array
  - Goose: Maps to "stdio" type with command/args
  - Crush: Maps to "stdio" transport type

Local MCPs run as stdio-based commands inside containers, allowing
users to integrate local MCP servers without containerization.
This commit is contained in:
2025-09-25 16:12:24 -06:00
parent a66843714d
commit b9cffe3008
8 changed files with 278 additions and 11 deletions

View File

@@ -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(

View File

@@ -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}")

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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):