Merge pull request #53 from hobbyistlabs-coder/feature/agent-mcp-personas-3414193578461185744

feat(agent): allow assigning MCP servers to specific personas
This commit is contained in:
hobbyistlabs-coder 2026-03-18 14:18:19 -04:00 committed by GitHub
commit c81d489899
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 299 additions and 9 deletions

View file

@ -26,5 +26,9 @@ func NewAgentCommand() *cobra.Command {
cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key")
cmd.Flags().StringVarP(&model, "model", "", "", "Model to use")
cmd.AddCommand(NewCreateCommand())
cmd.AddCommand(NewAssignMCPCommand())
cmd.AddCommand(NewRemoveMCPCommand())
return cmd
}

View file

@ -0,0 +1,129 @@
package agent
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/spf13/cobra"
"jane/cmd/picoclaw/internal"
"jane/pkg/config"
)
func NewCreateCommand() *cobra.Command {
var (
name string
workspace string
sysPrompt string
model string
interactive bool
)
cmd := &cobra.Command{
Use: "create",
Short: "Create a new agent persona",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if !interactive && name == "" {
return fmt.Errorf("name is required when not in interactive mode")
}
return createAgentCmd(name, workspace, sysPrompt, model, interactive)
},
}
cmd.Flags().StringVarP(&name, "name", "n", "", "Agent name")
cmd.Flags().StringVarP(&workspace, "workspace", "w", "", "Workspace path")
cmd.Flags().StringVarP(&sysPrompt, "system-prompt", "p", "", "System prompt / instructions")
cmd.Flags().StringVarP(&model, "model", "m", "", "Model configuration (primary)")
cmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Interactive mode")
return cmd
}
func createAgentCmd(name, workspace, sysPrompt, model string, interactive bool) error {
cfg, err := internal.LoadConfig()
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
if interactive {
fmt.Printf("%s Creating new agent persona...\n\n", internal.Logo)
reader := bufio.NewReader(os.Stdin)
if name == "" {
fmt.Print("Agent Name: ")
nameInput, _ := reader.ReadString('\n')
nameInput = strings.TrimSpace(nameInput)
if nameInput != "" {
name = nameInput
}
}
if workspace == "" {
fmt.Printf("Workspace path (default: ~/.picoclaw/workspace/%s): ", strings.ToLower(strings.ReplaceAll(name, " ", "_")))
workspaceInput, _ := reader.ReadString('\n')
workspaceInput = strings.TrimSpace(workspaceInput)
if workspaceInput != "" {
workspace = workspaceInput
}
}
if sysPrompt == "" {
fmt.Print("System Prompt (optional): ")
sysPromptInput, _ := reader.ReadString('\n')
sysPromptInput = strings.TrimSpace(sysPromptInput)
if sysPromptInput != "" {
sysPrompt = sysPromptInput
}
}
}
if name == "" {
return fmt.Errorf("agent name is required")
}
id := strings.ToLower(strings.ReplaceAll(name, " ", "-"))
id = strings.ReplaceAll(id, "_", "-")
if id == "" {
id = uuid.New().String()[:8]
}
if workspace == "" {
var homePath string
if picoclawHome := os.Getenv("PICOCLAW_HOME"); picoclawHome != "" {
homePath = picoclawHome
} else {
userHome, _ := os.UserHomeDir()
homePath = filepath.Join(userHome, ".picoclaw")
}
workspace = filepath.Join(homePath, "workspace", strings.ToLower(strings.ReplaceAll(name, " ", "_")))
}
var modelCfg *config.AgentModelConfig
if model != "" {
modelCfg = &config.AgentModelConfig{Primary: model}
}
newAgent := config.AgentConfig{
ID: id,
Name: name,
Workspace: workspace,
SystemPrompt: sysPrompt,
Model: modelCfg,
}
cfg.Agents.List = append(cfg.Agents.List, newAgent)
if err := config.SaveConfig(internal.GetConfigPath(), cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("\n✅ Successfully created agent persona '%s' with ID '%s'\n", name, id)
return nil
}

View file

@ -0,0 +1,113 @@
package agent
import (
"fmt"
"github.com/spf13/cobra"
"jane/cmd/picoclaw/internal"
"jane/pkg/config"
)
func NewAssignMCPCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "assign-mcp <agent-id> <mcp-name>",
Short: "Assign an MCP server to an agent persona",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return assignMCPCmd(args[0], args[1])
},
}
return cmd
}
func assignMCPCmd(agentID, mcpName string) error {
cfg, err := internal.LoadConfig()
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
found := false
for i, a := range cfg.Agents.List {
if a.ID == agentID {
found = true
// Check if already assigned
for _, m := range a.MCPServers {
if m == mcpName {
fmt.Printf("⚠️ MCP server '%s' is already assigned to agent '%s'\n", mcpName, agentID)
return nil
}
}
cfg.Agents.List[i].MCPServers = append(cfg.Agents.List[i].MCPServers, mcpName)
break
}
}
if !found {
return fmt.Errorf("agent with ID '%s' not found", agentID)
}
if err := config.SaveConfig(internal.GetConfigPath(), cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("✅ Successfully assigned MCP server '%s' to agent '%s'\n", mcpName, agentID)
return nil
}
func NewRemoveMCPCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "remove-mcp <agent-id> <mcp-name>",
Short: "Remove an MCP server from an agent persona",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return removeMCPCmd(args[0], args[1])
},
}
return cmd
}
func removeMCPCmd(agentID, mcpName string) error {
cfg, err := internal.LoadConfig()
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
found := false
for i, a := range cfg.Agents.List {
if a.ID == agentID {
found = true
newMCPs := make([]string, 0, len(a.MCPServers))
removed := false
for _, m := range a.MCPServers {
if m == mcpName {
removed = true
continue
}
newMCPs = append(newMCPs, m)
}
if !removed {
fmt.Printf("⚠️ MCP server '%s' is not assigned to agent '%s'\n", mcpName, agentID)
return nil
}
cfg.Agents.List[i].MCPServers = newMCPs
break
}
}
if !found {
return fmt.Errorf("agent with ID '%s' not found", agentID)
}
if err := config.SaveConfig(internal.GetConfigPath(), cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("✅ Successfully removed MCP server '%s' from agent '%s'\n", mcpName, agentID)
return nil
}

View file

@ -39,6 +39,7 @@ type AgentInstance struct {
Tools *tools.ToolRegistry
Subagents *config.SubagentsConfig
SkillsFilter []string
MCPServers []string
Candidates []providers.FallbackCandidate
// Router is non-nil when model routing is configured and the light model
@ -122,12 +123,14 @@ func NewAgentInstance(
agentName := ""
var subagents *config.SubagentsConfig
var skillsFilter []string
var mcpServers []string
if agentCfg != nil {
agentID = routing.NormalizeAgentID(agentCfg.ID)
agentName = agentCfg.Name
subagents = agentCfg.Subagents
skillsFilter = agentCfg.Skills
mcpServers = agentCfg.MCPServers
}
maxIter := defaults.MaxToolIterations
@ -246,6 +249,7 @@ func NewAgentInstance(
Tools: toolsRegistry,
Subagents: subagents,
SkillsFilter: skillsFilter,
MCPServers: mcpServers,
Candidates: candidates,
Router: router,
LightCandidates: lightCandidates,

View file

@ -102,6 +102,20 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
continue
}
// Check if this agent explicitly restricts MCP servers
if agent != nil {
allowed := false
for _, allowedServer := range agent.MCPServers {
if allowedServer == serverName {
allowed = true
break
}
}
if !allowed {
continue // Skip registering this tool for this agent
}
}
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
if al.cfg.Tools.MCP.Discovery.Enabled {

View file

@ -47,13 +47,15 @@ func (m AgentModelConfig) MarshalJSON() ([]byte, error) {
}
type AgentConfig struct {
ID string `json:"id"`
Default bool `json:"default,omitempty"`
Name string `json:"name,omitempty"`
Workspace string `json:"workspace,omitempty"`
Model *AgentModelConfig `json:"model,omitempty"`
Skills []string `json:"skills,omitempty"`
Subagents *SubagentsConfig `json:"subagents,omitempty"`
ID string `json:"id"`
Default bool `json:"default,omitempty"`
Name string `json:"name,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
Workspace string `json:"workspace,omitempty"`
Model *AgentModelConfig `json:"model,omitempty"`
Skills []string `json:"skills,omitempty"`
MCPServers []string `json:"mcp_servers,omitempty"`
Subagents *SubagentsConfig `json:"subagents,omitempty"`
}
type SubagentsConfig struct {

View file

@ -181,8 +181,8 @@ func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) {
t.Fatalf("unmarshal: %v", err)
}
if len(cfg.Agents.List) != 1 {
t.Errorf("agents.list should have default clinician agent for backward compat, got %d", len(cfg.Agents.List))
if len(cfg.Agents.List) != 5 {
t.Errorf("agents.list should have default agents for backward compat, got %d", len(cfg.Agents.List))
}
if len(cfg.Bindings) != 0 {
t.Errorf("bindings should be empty, got %d", len(cfg.Bindings))

View file

@ -42,6 +42,30 @@ func DefaultConfig() *Config {
Name: "Medical Persona",
Workspace: filepath.Join(homePath, "Obsidian_Vault", "Patients"),
},
{
ID: "coding",
Name: "Coding Persona",
Workspace: filepath.Join(homePath, "workspace", "code"),
MCPServers: []string{"github", "bash"},
},
{
ID: "google",
Name: "Google Persona",
Workspace: filepath.Join(homePath, "workspace", "google"),
MCPServers: []string{"gmail", "calendar"},
},
{
ID: "communications",
Name: "Communications Persona",
Workspace: filepath.Join(homePath, "workspace", "communications"),
MCPServers: []string{"slack", "discord"},
},
{
ID: "financial",
Name: "Financial Persona",
Workspace: filepath.Join(homePath, "workspace", "finance"),
MCPServers: []string{"alpaca"},
},
},
},
Bindings: []AgentBinding{},