@@ -5,7 +5,6 @@ import (
55 "context"
66 "errors"
77 "fmt"
8− "log/slog"
98 "maps"
109 "net/http"
1110 "net/url"
@@ -301,25 +300,46 @@ func (l LSPs) Sorted() []LSP {
301300 return sorted
302301}
303302
304−func (l LSPConfig) ResolvedEnv() []string {
305− return resolveEnvs(l.Env)
306−}
307−
308−func (m MCPConfig) ResolvedEnv() []string {
303+// ResolvedEnv returns m.Env with every value expanded through the
304+// shell resolver. The returned slice is of the form "KEY=value" sorted
305+// by key so callers get deterministic output; the receiver's Env map is
306+// not mutated. On the first resolution failure it returns nil and an
307+// error that identifies the offending key; the inner resolver error is
308+// already sanitized by ResolveValue and is wrapped with %w so
309+// errors.Is/As continues to work. Callers are expected to surface it
310+// (for MCP, via StateError on the status card) rather than silently
311+// spawn the server with an empty credential.
312+func (m MCPConfig) ResolvedEnv() ([]string, error) {
309313 return resolveEnvs(m.Env)
310314}
311315
312−func (m MCPConfig) ResolvedHeaders() map[string]string {
316+// ResolvedHeaders returns m.Headers with every value expanded through
317+// the shell resolver. A fresh map is allocated; m.Headers is never
318+// mutated. On the first resolution failure it returns nil and an error
319+// identifying the offending header name; the inner resolver error is
320+// already sanitized by ResolveValue and is wrapped with %w so
321+// errors.Is/As continues to work.
322+func (m MCPConfig) ResolvedHeaders() (map[string]string, error) {
323+ if len(m.Headers) == 0 {
324+ return map[string]string{}, nil
325+ }
313326 resolver := NewShellVariableResolver(env.New())
314− for e, v := range m.Headers {
315− var err error
316− m.Headers[e], err = resolver.ResolveValue(v)
327+ out := make(map[string]string, len(m.Headers))
328+ // Sort keys so failures are reported deterministically when more
329+ // than one header would fail.
330+ keys := make([]string, 0, len(m.Headers))
331+ for k := range m.Headers {
332+ keys = append(keys, k)
333+ }
334+ slices.Sort(keys)
335+ for _, k := range keys {
336+ v, err := resolver.ResolveValue(m.Headers[k])
317337 if err != nil {
318− slog.Error("Error resolving header variable", "error", err, "variable", e, "value", v)
319− continue
338+ return nil, fmt.Errorf("header %s: %w", k, err)
320339 }
340+ out[k] = v
321341 }
322− return m.Headers
342+ return out, nil
323343}
324344
325345type Agent struct {
@@ -659,22 +679,30 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
659679 return nil
660680}
661681
662−func resolveEnvs(envs map[string]string) []string {
682+// resolveEnvs expands every value in envs through the shell resolver
683+// and returns a fresh "KEY=value" slice sorted by key. The input map is
684+// not mutated. On the first resolution failure it returns nil and an
685+// error identifying the offending variable; the inner resolver error is
686+// already sanitized by ResolveValue and is wrapped with %w.
687+func resolveEnvs(envs map[string]string) ([]string, error) {
688+ if len(envs) == 0 {
689+ return nil, nil
690+ }
663691 resolver := NewShellVariableResolver(env.New())
664− for e, v := range envs {
665− var err error
666− envs[e], err = resolver.ResolveValue(v)
667− if err != nil {
668− slog.Error("Error resolving environment variable", "error", err, "variable", e, "value", v)
669− continue
670− }
692+ keys := make([]string, 0, len(envs))
693+ for k := range envs {
694+ keys = append(keys, k)
671695 }
672−
696+ slices.Sort(keys)
673697 res := make([]string, 0, len(envs))
674− for k, v := range envs {
698+ for _, k := range keys {
699+ v, err := resolver.ResolveValue(envs[k])
700+ if err != nil {
701+ return nil, fmt.Errorf("env %s: %w", k, err)
702+ }
675703 res = append(res, fmt.Sprintf("%s=%s", k, v))
676704 }
677− return res
705+ return res, nil
678706}
679707
680708func ptrValOr[T any](t *T, el T) T {