Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 29 additions & 15 deletions internal/cli/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,22 @@ func NewContext(cfg *config.Config, env *output.Envelope, logger *output.Logger)
}
}

// Client returns the SDK client, creating it on first use.
// It merges credentials from config and auth store.
func (c *Context) Client() (*sdk.Client, error) {
if c.client != nil {
return c.client, nil
}

account := c.Config.Account
email := c.Config.Email
apiKey := c.Config.APIKey
// Credentials returns the resolved (account, email, apiKey) triplet,
// merging values from config (flags/env/files) with the auth store. It
// returns the same AuthError/UserError shapes that Client() would.
//
// This is useful for code paths that need credentials but don't need
// the full SDK client — e.g. shelling out to a child process that
// reads them from environment variables.
func (c *Context) Credentials() (account, email, apiKey string, err error) {
account = c.Config.Account
email = c.Config.Email
apiKey = c.Config.APIKey

// Fill gaps from auth store (look up by account name if known)
if account == "" || email == "" || apiKey == "" {
creds, err := auth.LoadByAccount(account)
if err != nil {
return nil, &output.AuthError{
creds, loadErr := auth.LoadByAccount(account)
if loadErr != nil {
return "", "", "", &output.AuthError{
Message: "Not logged in",
Hint: "Authenticate first:\n" +
" dhq auth login (interactive)\n" +
Expand All @@ -68,11 +68,25 @@ func (c *Context) Client() (*sdk.Client, error) {
}

if account == "" {
return nil, &output.UserError{
return "", "", "", &output.UserError{
Message: "Account not configured",
Hint: "Set via --account flag, DEPLOYHQ_ACCOUNT env var, or 'dhq config set account <name>'",
}
}
return account, email, apiKey, nil
}

// Client returns the SDK client, creating it on first use.
// It merges credentials from config and auth store.
func (c *Context) Client() (*sdk.Client, error) {
if c.client != nil {
return c.client, nil
}

account, email, apiKey, err := c.Credentials()
if err != nil {
return nil, err
}

opts := []sdk.Option{}
agent := harness.AgentInfo{}
Expand Down
56 changes: 55 additions & 1 deletion internal/commands/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/deployhq/deployhq-cli/internal/output"
"github.com/manifoldco/promptui"
Expand Down Expand Up @@ -69,14 +70,27 @@ The MCP server binary is searched in:
}
}

// The MCP server reads credentials from DEPLOYHQ_ACCOUNT / DEPLOYHQ_EMAIL /
// DEPLOYHQ_API_KEY and exits with status 1 if any is missing. Inject the
// resolved CLI credentials so users who logged in via keyring (the common
// case) don't have to re-export them just to start the server.
account, email, apiKey, err := cliCtx.Credentials()
if err != nil {
return err
}

cmdArgs := append(bin.args, args...)
cliCtx.Logger.Write("Starting MCP server: %s %v", bin.path, cmdArgs)

c := exec.Command(bin.path, cmdArgs...)
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
c.Env = os.Environ()
c.Env = mergeEnv(os.Environ(), map[string]string{
"DEPLOYHQ_ACCOUNT": account,
"DEPLOYHQ_EMAIL": email,
"DEPLOYHQ_API_KEY": apiKey,
})

if err := c.Run(); err != nil {
return &output.InternalError{Message: "MCP server exited", Cause: err}
Expand All @@ -86,6 +100,46 @@ The MCP server binary is searched in:
}
}

// mergeEnv returns env with each key in overrides set to its value, only when
// the key is not already present with a non-empty value. An exported-but-empty
// variable (e.g. `DEPLOYHQ_API_KEY=`) is treated as missing and dropped from
// the result — otherwise the MCP server would still crash at startup because
// empty required vars fail its validation just like absent ones, and a stale
// empty entry could shadow the override on platforms that take the first
// occurrence.
//
// User-set non-empty values are preserved, so power users can still point the
// server at a different account for one session.
func mergeEnv(env []string, overrides map[string]string) []string {
out := make([]string, 0, len(env)+len(overrides))
occupied := make(map[string]struct{}, len(env))
for _, kv := range env {
i := strings.Index(kv, "=")
if i <= 0 {
out = append(out, kv)
continue
}
key, val := kv[:i], kv[i+1:]
if val == "" {
if _, willOverride := overrides[key]; willOverride {
continue
}
}
out = append(out, kv)
occupied[key] = struct{}{}
}
for k, v := range overrides {
if _, ok := occupied[k]; ok {
continue
}
if v == "" {
continue
}
out = append(out, k+"="+v)
}
return out
}

func findMCPBinary() *mcpBinary {
// 1. In PATH
if p, err := exec.LookPath("deployhq-mcp-server"); err == nil {
Expand Down
67 changes: 67 additions & 0 deletions internal/commands/mcp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package commands

import (
"sort"
"strings"
"testing"
)

func TestMergeEnv(t *testing.T) {
tests := []struct {
name string
env []string
overrides map[string]string
wantHas []string
wantAbsent []string
}{
{
name: "adds missing keys",
env: []string{"PATH=/usr/bin", "HOME=/root"},
overrides: map[string]string{"DEPLOYHQ_ACCOUNT": "acme", "DEPLOYHQ_EMAIL": "a@b"},
wantHas: []string{"PATH=/usr/bin", "HOME=/root", "DEPLOYHQ_ACCOUNT=acme", "DEPLOYHQ_EMAIL=a@b"},
},
{
name: "preserves user-set keys",
env: []string{"DEPLOYHQ_ACCOUNT=other", "PATH=/usr/bin"},
overrides: map[string]string{"DEPLOYHQ_ACCOUNT": "acme", "DEPLOYHQ_EMAIL": "a@b"},
wantHas: []string{"DEPLOYHQ_ACCOUNT=other", "DEPLOYHQ_EMAIL=a@b", "PATH=/usr/bin"},
wantAbsent: []string{"DEPLOYHQ_ACCOUNT=acme"},
},
{
name: "skips empty override values",
env: []string{"PATH=/usr/bin"},
overrides: map[string]string{"DEPLOYHQ_API_KEY": ""},
wantHas: []string{"PATH=/usr/bin"},
wantAbsent: []string{"DEPLOYHQ_API_KEY="},
},
{
name: "empty exported key is treated as missing and dropped",
env: []string{"DEPLOYHQ_API_KEY=", "PATH=/usr/bin"},
overrides: map[string]string{"DEPLOYHQ_API_KEY": "realkey"},
wantHas: []string{"DEPLOYHQ_API_KEY=realkey", "PATH=/usr/bin"},
},
{
name: "empty exported key for unrelated var is preserved",
env: []string{"OTHER_THING=", "PATH=/usr/bin"},
overrides: map[string]string{"DEPLOYHQ_API_KEY": "realkey"},
wantHas: []string{"OTHER_THING=", "DEPLOYHQ_API_KEY=realkey", "PATH=/usr/bin"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mergeEnv(tt.env, tt.overrides)
sort.Strings(got)
joined := strings.Join(got, "\n")
for _, want := range tt.wantHas {
if !strings.Contains(joined, want) {
t.Errorf("expected env to contain %q, got %v", want, got)
}
}
for _, absent := range tt.wantAbsent {
if strings.Contains(joined, absent+"\n") || strings.HasSuffix(joined, absent) {
t.Errorf("expected env NOT to contain %q, got %v", absent, got)
}
}
})
}
}
Loading