mirror of
https://github.com/Monadical-SAS/cubbi.git
synced 2025-12-20 04:09:06 +00:00
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:
44
cubbi/cli.py
44
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(
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
84
cubbi/mcp.py
84
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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user