diff --git a/cmd/picoclaw/internal/mcp/add.go b/cmd/picoclaw/internal/mcp/add.go index 57e8f5368..87917095e 100644 --- a/cmd/picoclaw/internal/mcp/add.go +++ b/cmd/picoclaw/internal/mcp/add.go @@ -223,6 +223,9 @@ func buildServerConfig(target string, args []string, opts addOptions) (config.MC if err := validateLocalCommandPath(target); err != nil { return config.MCPServerConfig{}, err } + if isLocalCommandPath(command) { + command = expandHomePath(command) + } server.Command = command server.Args = commandArgs diff --git a/cmd/picoclaw/internal/mcp/command_test.go b/cmd/picoclaw/internal/mcp/command_test.go index 6fd8a6f58..9f8d822e4 100644 --- a/cmd/picoclaw/internal/mcp/command_test.go +++ b/cmd/picoclaw/internal/mcp/command_test.go @@ -152,6 +152,28 @@ func TestMCPAddRejectsNonExecutableLocalCommand(t *testing.T) { assert.Contains(t, err.Error(), "not executable") } +func TestMCPAddExpandsHomeInSavedLocalCommand(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + localCmd := filepath.Join(homeDir, "bin", "my-mcp") + require.NoError(t, os.MkdirAll(filepath.Dir(localCmd), 0o755)) + require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o755)) + + tildeCmd := "~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp") + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "local-home", tildeCmd}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["local-home"] + assert.Equal(t, localCmd, server.Command) +} + func TestMCPAddShowsClearErrorForRemoteURLWithoutTransport(t *testing.T) { setupMCPConfigEnv(t) diff --git a/pkg/mcp/manager.go b/pkg/mcp/manager.go index f589f82a9..e28388827 100644 --- a/pkg/mcp/manager.go +++ b/pkg/mcp/manager.go @@ -25,6 +25,24 @@ type headerTransport struct { headers map[string]string } +func expandHomeCommandPath(command string) string { + if command == "" || command[0] != '~' { + return command + } + + home, err := os.UserHomeDir() + if err != nil { + return command + } + if command == "~" { + return home + } + if strings.HasPrefix(command, "~/") || strings.HasPrefix(command, "~\\") { + return filepath.Join(home, command[2:]) + } + return command +} + func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Clone the request to avoid modifying the original req = req.Clone(req.Context()) @@ -324,7 +342,7 @@ func (m *Manager) ConnectServer( "command": cfg.Command, }) // Create command with context - cmd := exec.CommandContext(ctx, cfg.Command, cfg.Args...) + cmd := exec.CommandContext(ctx, expandHomeCommandPath(cfg.Command), cfg.Args...) // Build environment variables with proper override semantics // Use a map to ensure config variables override file variables diff --git a/pkg/mcp/manager_test.go b/pkg/mcp/manager_test.go index f353942ab..fff315655 100644 --- a/pkg/mcp/manager_test.go +++ b/pkg/mcp/manager_test.go @@ -136,6 +136,22 @@ func TestLoadEnvFileNotFound(t *testing.T) { } } +func TestExpandHomeCommandPath(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + want := filepath.Join(homeDir, "bin", "my-mcp") + got := expandHomeCommandPath("~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp")) + if got != want { + t.Fatalf("expandHomeCommandPath() = %q, want %q", got, want) + } + + if got := expandHomeCommandPath("npx"); got != "npx" { + t.Fatalf("expandHomeCommandPath() should leave bare commands unchanged, got %q", got) + } +} + func TestEnvFilePriority(t *testing.T) { // Create a temporary .env file tmpDir := t.TempDir()