on main help sign in
refactor(config): make Resolved{Env,Headers} pure and error-returning
c135266Christian Rocha
internal/agent/tools/mcp/init.go+15−3
@@ -447,8 +447,12 @@ func createTransport(ctx context.Context, m config.MCPConfig, resolver config.Va
447 if strings.TrimSpace(command) == "" {
447 if strings.TrimSpace(command) == "" {
448 return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
448 return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
449 }
449 }
450+ envs, err := m.ResolvedEnv()
451+ if err != nil {
452+ return nil, err
453+ }
450 cmd := exec.CommandContext(ctx, home.Long(command), m.Args...)
454 cmd := exec.CommandContext(ctx, home.Long(command), m.Args...)
451− cmd.Env = append(os.Environ(), m.ResolvedEnv()...)
455+ cmd.Env = append(os.Environ(), envs...)
452 return &mcp.CommandTransport{
456 return &mcp.CommandTransport{
453 Command: cmd,
457 Command: cmd,
454 }, nil
458 }, nil
@@ -456,9 +460,13 @@ func createTransport(ctx context.Context, m config.MCPConfig, resolver config.Va
456 if strings.TrimSpace(m.URL) == "" {
460 if strings.TrimSpace(m.URL) == "" {
457 return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field")
461 return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field")
458 }
462 }
463+ headers, err := m.ResolvedHeaders()
464+ if err != nil {
465+ return nil, err
466+ }
459 client := &http.Client{
467 client := &http.Client{
460 Transport: &headerRoundTripper{
468 Transport: &headerRoundTripper{
461− headers: m.ResolvedHeaders(),
469+ headers: headers,
462 },
470 },
463 }
471 }
464 return &mcp.StreamableClientTransport{
472 return &mcp.StreamableClientTransport{
@@ -469,9 +477,13 @@ func createTransport(ctx context.Context, m config.MCPConfig, resolver config.Va
469 if strings.TrimSpace(m.URL) == "" {
477 if strings.TrimSpace(m.URL) == "" {
470 return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field")
478 return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field")
471 }
479 }
480+ headers, err := m.ResolvedHeaders()
481+ if err != nil {
482+ return nil, err
483+ }
472 client := &http.Client{
484 client := &http.Client{
473 Transport: &headerRoundTripper{
485 Transport: &headerRoundTripper{
474− headers: m.ResolvedHeaders(),
486+ headers: headers,
475 },
487 },
476 }
488 }
477 return &mcp.SSEClientTransport{
489 return &mcp.SSEClientTransport{
internal/config/config.go+52−24
@@ -5,7 +5,6 @@ import (
5 "context"
5 "context"
6 "errors"
6 "errors"
7 "fmt"
7 "fmt"
8− "log/slog"
9 "maps"
8 "maps"
10 "net/http"
9 "net/http"
11 "net/url"
10 "net/url"
@@ -301,25 +300,46 @@ func (l LSPs) Sorted() []LSP {
301 return sorted
300 return sorted
302}
301}
303
302
304−func (l LSPConfig) ResolvedEnv() []string {
303+// ResolvedEnv returns m.Env with every value expanded through the
305− return resolveEnvs(l.Env)
304+// shell resolver. The returned slice is of the form "KEY=value" sorted
306−}
305+// by key so callers get deterministic output; the receiver's Env map is
307−
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.
308−func (m MCPConfig) ResolvedEnv() []string {
312+func (m MCPConfig) ResolvedEnv() ([]string, error) {
309 return resolveEnvs(m.Env)
313 return resolveEnvs(m.Env)
310}
314}
311
315
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.
312−func (m MCPConfig) ResolvedHeaders() map[string]string {
322+func (m MCPConfig) ResolvedHeaders() (map[string]string, error) {
323+ if len(m.Headers) == 0 {
324+ return map[string]string{}, nil
325+ }
313 resolver := NewShellVariableResolver(env.New())
326 resolver := NewShellVariableResolver(env.New())
314− for e, v := range m.Headers {
327+ out := make(map[string]string, len(m.Headers))
315− var err error
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 {
316− m.Headers[e], err = resolver.ResolveValue(v)
336+ v, err := resolver.ResolveValue(m.Headers[k])
317 if err != nil {
337 if err != nil {
318− slog.Error("Error resolving header variable", "error", err, "variable", e, "value", v)
338+ return nil, fmt.Errorf("header %s: %w", k, err)
319− continue
320 }
339 }
340+ out[k] = v
321 }
341 }
322− return m.Headers
342+ return out, nil
323}
343}
324
344
325type Agent struct {
345type Agent struct {
@@ -659,22 +679,30 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
659 return nil
679 return nil
660}
680}
661
681
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.
662−func resolveEnvs(envs map[string]string) []string {
687+func resolveEnvs(envs map[string]string) ([]string, error) {
688+ if len(envs) == 0 {
689+ return nil, nil
690+ }
663 resolver := NewShellVariableResolver(env.New())
691 resolver := NewShellVariableResolver(env.New())
664− for e, v := range envs {
692+ keys := make([]string, 0, len(envs))
665− var err error
693+ for k := range envs {
666− envs[e], err = resolver.ResolveValue(v)
694+ keys = append(keys, k)
667− if err != nil {
668− slog.Error("Error resolving environment variable", "error", err, "variable", e, "value", v)
669− continue
670− }
671 }
695 }
672−
696+ slices.Sort(keys)
673 res := make([]string, 0, len(envs))
697 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+ }
675 res = append(res, fmt.Sprintf("%s=%s", k, v))
703 res = append(res, fmt.Sprintf("%s=%s", k, v))
676 }
704 }
677− return res
705+ return res, nil
678}
706}
679
707
680func ptrValOr[T any](t *T, el T) T {
708func ptrValOr[T any](t *T, el T) T {