on main help sign in
feat(config): resolve MCP args and thread resolver through env/headers/args
dbd40d8Christian Rocha
internal/agent/tools/mcp/init.go+8−4
@@ -447,11 +447,15 @@ 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()
450+ args, err := m.ResolvedArgs(resolver)
451 if err != nil {
451 if err != nil {
452 return nil, err
452 return nil, err
453 }
453 }
454+ envs, err := m.ResolvedEnv(resolver)
455+ if err != nil {
456+ return nil, err
457+ }
454− cmd := exec.CommandContext(ctx, home.Long(command), m.Args...)
458+ cmd := exec.CommandContext(ctx, home.Long(command), args...)
455 cmd.Env = append(os.Environ(), envs...)
459 cmd.Env = append(os.Environ(), envs...)
456 return &mcp.CommandTransport{
460 return &mcp.CommandTransport{
457 Command: cmd,
461 Command: cmd,
@@ -460,7 +464,7 @@ func createTransport(ctx context.Context, m config.MCPConfig, resolver config.Va
460 if strings.TrimSpace(m.URL) == "" {
464 if strings.TrimSpace(m.URL) == "" {
461 return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field")
465 return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field")
462 }
466 }
463− headers, err := m.ResolvedHeaders()
467+ headers, err := m.ResolvedHeaders(resolver)
464 if err != nil {
468 if err != nil {
465 return nil, err
469 return nil, err
466 }
470 }
@@ -477,7 +481,7 @@ func createTransport(ctx context.Context, m config.MCPConfig, resolver config.Va
477 if strings.TrimSpace(m.URL) == "" {
481 if strings.TrimSpace(m.URL) == "" {
478 return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field")
482 return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field")
479 }
483 }
480− headers, err := m.ResolvedHeaders()
484+ headers, err := m.ResolvedHeaders(resolver)
481 if err != nil {
485 if err != nil {
482 return nil, err
486 return nil, err
483 }
487 }
internal/config/config.go+38−12
@@ -14,7 +14,6 @@ import (
14
14
15 "charm.land/catwalk/pkg/catwalk"
15 "charm.land/catwalk/pkg/catwalk"
16 "github.com/charmbracelet/crush/internal/csync"
16 "github.com/charmbracelet/crush/internal/csync"
17− "github.com/charmbracelet/crush/internal/env"
18 "github.com/charmbracelet/crush/internal/oauth"
17 "github.com/charmbracelet/crush/internal/oauth"
19 "github.com/charmbracelet/crush/internal/oauth/copilot"
18 "github.com/charmbracelet/crush/internal/oauth/copilot"
20 "github.com/invopop/jsonschema"
19 "github.com/invopop/jsonschema"
@@ -301,7 +300,7 @@ func (l LSPs) Sorted() []LSP {
301}
300}
302
301
303// ResolvedEnv returns m.Env with every value expanded through the
302// ResolvedEnv returns m.Env with every value expanded through the
304−// shell resolver. The returned slice is of the form "KEY=value" sorted
303+// given 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
304// 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
305// not mutated. On the first resolution failure it returns nil and an
307// error that identifies the offending key; the inner resolver error is
306// error that identifies the offending key; the inner resolver error is
@@ -309,21 +308,49 @@ func (l LSPs) Sorted() []LSP {
309// errors.Is/As continues to work. Callers are expected to surface it
308// errors.Is/As continues to work. Callers are expected to surface it
310// (for MCP, via StateError on the status card) rather than silently
309// (for MCP, via StateError on the status card) rather than silently
311// spawn the server with an empty credential.
310// spawn the server with an empty credential.
311+//
312+// The resolver choice matters: in server mode pass the shell resolver
313+// so $VAR / $(cmd) expand; in client mode pass IdentityResolver so the
314+// template is forwarded verbatim and expansion happens on the server.
312−func (m MCPConfig) ResolvedEnv() ([]string, error) {
315+func (m MCPConfig) ResolvedEnv(r VariableResolver) ([]string, error) {
313− return resolveEnvs(m.Env)
316+ return resolveEnvs(m.Env, r)
317+}
318+
319+// ResolvedArgs returns m.Args with every element expanded through the
320+// given resolver. A fresh slice is allocated; m.Args is never mutated.
321+// On the first resolution failure it returns nil and an error
322+// identifying the offending positional index; the inner resolver error
323+// is already sanitized by ResolveValue and is wrapped with %w so
324+// errors.Is/As continues to work.
325+//
326+// See ResolvedEnv for guidance on picking a resolver.
327+func (m MCPConfig) ResolvedArgs(r VariableResolver) ([]string, error) {
328+ if len(m.Args) == 0 {
329+ return nil, nil
330+ }
331+ out := make([]string, len(m.Args))
332+ for i, a := range m.Args {
333+ v, err := r.ResolveValue(a)
334+ if err != nil {
335+ return nil, fmt.Errorf("arg %d: %w", i, err)
336+ }
337+ out[i] = v
338+ }
339+ return out, nil
314}
340}
315
341
316// ResolvedHeaders returns m.Headers with every value expanded through
342// ResolvedHeaders returns m.Headers with every value expanded through
317−// the shell resolver. A fresh map is allocated; m.Headers is never
343+// the given resolver. A fresh map is allocated; m.Headers is never
318// mutated. On the first resolution failure it returns nil and an error
344// mutated. On the first resolution failure it returns nil and an error
319// identifying the offending header name; the inner resolver error is
345// identifying the offending header name; the inner resolver error is
320// already sanitized by ResolveValue and is wrapped with %w so
346// already sanitized by ResolveValue and is wrapped with %w so
321// errors.Is/As continues to work.
347// errors.Is/As continues to work.
348+//
349+// See ResolvedEnv for guidance on picking a resolver.
322−func (m MCPConfig) ResolvedHeaders() (map[string]string, error) {
350+func (m MCPConfig) ResolvedHeaders(r VariableResolver) (map[string]string, error) {
323 if len(m.Headers) == 0 {
351 if len(m.Headers) == 0 {
324 return map[string]string{}, nil
352 return map[string]string{}, nil
325 }
353 }
326− resolver := NewShellVariableResolver(env.New())
327 out := make(map[string]string, len(m.Headers))
354 out := make(map[string]string, len(m.Headers))
328 // Sort keys so failures are reported deterministically when more
355 // Sort keys so failures are reported deterministically when more
329 // than one header would fail.
356 // than one header would fail.
@@ -333,7 +360,7 @@ func (m MCPConfig) ResolvedHeaders() (map[string]string, error) {
333 }
360 }
334 slices.Sort(keys)
361 slices.Sort(keys)
335 for _, k := range keys {
362 for _, k := range keys {
336− v, err := resolver.ResolveValue(m.Headers[k])
363+ v, err := r.ResolveValue(m.Headers[k])
337 if err != nil {
364 if err != nil {
338 return nil, fmt.Errorf("header %s: %w", k, err)
365 return nil, fmt.Errorf("header %s: %w", k, err)
339 }
366 }
@@ -679,16 +706,15 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
679 return nil
706 return nil
680}
707}
681
708
682−// resolveEnvs expands every value in envs through the shell resolver
709+// resolveEnvs expands every value in envs through the given resolver
683// and returns a fresh "KEY=value" slice sorted by key. The input map is
710// 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
711// not mutated. On the first resolution failure it returns nil and an
685// error identifying the offending variable; the inner resolver error is
712// error identifying the offending variable; the inner resolver error is
686// already sanitized by ResolveValue and is wrapped with %w.
713// already sanitized by ResolveValue and is wrapped with %w.
687−func resolveEnvs(envs map[string]string) ([]string, error) {
714+func resolveEnvs(envs map[string]string, r VariableResolver) ([]string, error) {
688 if len(envs) == 0 {
715 if len(envs) == 0 {
689 return nil, nil
716 return nil, nil
690 }
717 }
691− resolver := NewShellVariableResolver(env.New())
692 keys := make([]string, 0, len(envs))
718 keys := make([]string, 0, len(envs))
693 for k := range envs {
719 for k := range envs {
694 keys = append(keys, k)
720 keys = append(keys, k)
@@ -696,7 +722,7 @@ func resolveEnvs(envs map[string]string) ([]string, error) {
696 slices.Sort(keys)
722 slices.Sort(keys)
697 res := make([]string, 0, len(envs))
723 res := make([]string, 0, len(envs))
698 for _, k := range keys {
724 for _, k := range keys {
699− v, err := resolver.ResolveValue(envs[k])
725+ v, err := r.ResolveValue(envs[k])
700 if err != nil {
726 if err != nil {
701 return nil, fmt.Errorf("env %s: %w", k, err)
727 return nil, fmt.Errorf("env %s: %w", k, err)
702 }
728 }