mirror of
https://github.com/Monadical-SAS/cubbi.git
synced 2025-12-20 12:19:07 +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]")
|
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")
|
@mcp_app.command("inspector")
|
||||||
def run_mcp_inspector(
|
def run_mcp_inspector(
|
||||||
client_port: int = typer.Option(
|
client_port: int = typer.Option(
|
||||||
|
|||||||
@@ -448,8 +448,22 @@ class ProviderConfigurator:
|
|||||||
if mcp_configs:
|
if mcp_configs:
|
||||||
for mcp_config in mcp_configs:
|
for mcp_config in mcp_configs:
|
||||||
name = mcp_config.get("name", "unknown")
|
name = mcp_config.get("name", "unknown")
|
||||||
|
mcp_type = mcp_config.get("type", "unknown")
|
||||||
is_default = " (default)" if name in default_mcps else ""
|
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:
|
else:
|
||||||
console.print(" (no MCP servers configured)")
|
console.print(" (no MCP servers configured)")
|
||||||
|
|
||||||
@@ -530,6 +544,7 @@ class ProviderConfigurator:
|
|||||||
mcp_type = questionary.select(
|
mcp_type = questionary.select(
|
||||||
"Select MCP server type:",
|
"Select MCP server type:",
|
||||||
choices=[
|
choices=[
|
||||||
|
"Local MCP (stdio-based command)",
|
||||||
"Remote MCP (URL-based)",
|
"Remote MCP (URL-based)",
|
||||||
"Docker MCP (containerized)",
|
"Docker MCP (containerized)",
|
||||||
"Proxy MCP (proxy + base image)",
|
"Proxy MCP (proxy + base image)",
|
||||||
@@ -539,7 +554,9 @@ class ProviderConfigurator:
|
|||||||
if mcp_type is None:
|
if mcp_type is None:
|
||||||
return
|
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()
|
self._add_remote_mcp()
|
||||||
elif "Docker MCP" in mcp_type:
|
elif "Docker MCP" in mcp_type:
|
||||||
self._add_docker_mcp()
|
self._add_docker_mcp()
|
||||||
@@ -729,6 +746,75 @@ class ProviderConfigurator:
|
|||||||
|
|
||||||
console.print(f"[green]Added Proxy MCP server '{name}'[/green]")
|
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:
|
def _edit_mcp_server(self, server_name: str) -> None:
|
||||||
"""Edit an existing MCP server."""
|
"""Edit an existing MCP server."""
|
||||||
mcp_config = self.user_config.get_mcp_configuration(server_name)
|
mcp_config = self.user_config.get_mcp_configuration(server_name)
|
||||||
@@ -754,7 +840,11 @@ class ProviderConfigurator:
|
|||||||
if choice == "View configuration":
|
if choice == "View configuration":
|
||||||
console.print("\n[bold]MCP server configuration:[/bold]")
|
console.print("\n[bold]MCP server configuration:[/bold]")
|
||||||
for key, value in mcp_config.items():
|
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}:")
|
console.print(f" {key}:")
|
||||||
for sub_key, sub_value in value.items():
|
for sub_key, sub_value in value.items():
|
||||||
console.print(f" {sub_key}: {sub_value}")
|
console.print(f" {sub_key}: {sub_value}")
|
||||||
|
|||||||
@@ -184,6 +184,24 @@ class CrushPlugin(ToolPlugin):
|
|||||||
"transport": {"type": "sse", "url": mcp.url},
|
"transport": {"type": "sse", "url": mcp.url},
|
||||||
"enabled": True,
|
"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"]:
|
elif mcp.type in ["docker", "proxy"]:
|
||||||
if mcp.name and mcp.host:
|
if mcp.name and mcp.host:
|
||||||
mcp_port = mcp.port or 8080
|
mcp_port = mcp.port or 8080
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ class MCPConfig(BaseModel):
|
|||||||
port: int | None = None
|
port: int | None = None
|
||||||
url: str | None = None
|
url: str | None = None
|
||||||
headers: dict[str, str] | None = None
|
headers: dict[str, str] | None = None
|
||||||
|
command: str | None = None
|
||||||
|
args: list[str] = []
|
||||||
|
env: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
class DefaultsConfig(BaseModel):
|
class DefaultsConfig(BaseModel):
|
||||||
|
|||||||
@@ -202,6 +202,21 @@ class GoosePlugin(ToolPlugin):
|
|||||||
"uri": mcp.url,
|
"uri": mcp.url,
|
||||||
"envs": {},
|
"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"]:
|
elif mcp.type in ["docker", "proxy"]:
|
||||||
if mcp.name and mcp.host:
|
if mcp.name and mcp.host:
|
||||||
mcp_port = mcp.port or 8080
|
mcp_port = mcp.port or 8080
|
||||||
|
|||||||
@@ -209,6 +209,25 @@ class OpencodePlugin(ToolPlugin):
|
|||||||
"type": "remote",
|
"type": "remote",
|
||||||
"url": mcp.url,
|
"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"]:
|
elif mcp.type in ["docker", "proxy"]:
|
||||||
if mcp.name and mcp.host:
|
if mcp.name and mcp.host:
|
||||||
mcp_port: int = mcp.port or 8080
|
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
|
import docker
|
||||||
from docker.errors import DockerException, ImageNotFound, NotFound
|
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
|
from .user_config import UserConfigManager
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -250,6 +250,56 @@ class MCPManager:
|
|||||||
|
|
||||||
return mcp_config
|
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:
|
def remove_mcp(self, name: str) -> bool:
|
||||||
"""Remove an MCP server configuration.
|
"""Remove an MCP server configuration.
|
||||||
|
|
||||||
@@ -359,6 +409,14 @@ class MCPManager:
|
|||||||
"type": "remote",
|
"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":
|
elif mcp_type == "docker":
|
||||||
# Pull the image if needed
|
# Pull the image if needed
|
||||||
try:
|
try:
|
||||||
@@ -637,8 +695,8 @@ ENTRYPOINT ["/entrypoint.sh"]
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Remote MCPs don't have containers to stop
|
# Remote and Local MCPs don't have containers to stop
|
||||||
if mcp_config.get("type") == "remote":
|
if mcp_config.get("type") in ["remote", "local"]:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Get the container name
|
# Get the container name
|
||||||
@@ -677,12 +735,12 @@ ENTRYPOINT ["/entrypoint.sh"]
|
|||||||
if not mcp_config:
|
if not mcp_config:
|
||||||
raise ValueError(f"MCP server '{name}' not found")
|
raise ValueError(f"MCP server '{name}' not found")
|
||||||
|
|
||||||
# Remote MCPs don't have containers to restart
|
# Remote and Local MCPs don't have containers to restart
|
||||||
if mcp_config.get("type") == "remote":
|
if mcp_config.get("type") in ["remote", "local"]:
|
||||||
return {
|
return {
|
||||||
"status": "not_applicable",
|
"status": "not_applicable",
|
||||||
"name": name,
|
"name": name,
|
||||||
"type": "remote",
|
"type": mcp_config.get("type"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get the container name
|
# Get the container name
|
||||||
@@ -723,6 +781,16 @@ ENTRYPOINT ["/entrypoint.sh"]
|
|||||||
"url": mcp_config.get("url"),
|
"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
|
# Get the container name
|
||||||
container_name = self.get_mcp_container_name(name)
|
container_name = self.get_mcp_container_name(name)
|
||||||
|
|
||||||
@@ -794,9 +862,11 @@ ENTRYPOINT ["/entrypoint.sh"]
|
|||||||
if not mcp_config:
|
if not mcp_config:
|
||||||
raise ValueError(f"MCP server '{name}' not found")
|
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":
|
if mcp_config.get("type") == "remote":
|
||||||
return "Remote MCPs don't have local logs"
|
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
|
# Get the container name
|
||||||
container_name = self.get_mcp_container_name(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
|
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):
|
class MCPContainer(BaseModel):
|
||||||
|
|||||||
Reference in New Issue
Block a user