on main help sign in
feat(config): resolve MCP url through shell expansion
f716457Christian Rocha
internal/agent/tools/mcp/init.go+12−4
@@ -461,7 +461,11 @@ func createTransport(ctx context.Context, m config.MCPConfig, resolver config.Va
461 Command: cmd,
461 Command: cmd,
462 }, nil
462 }, nil
463 case config.MCPHttp:
463 case config.MCPHttp:
464+ url, err := m.ResolvedURL(resolver)
465+ if err != nil {
466+ return nil, err
467+ }
464− if strings.TrimSpace(m.URL) == "" {
468+ if strings.TrimSpace(url) == "" {
465 return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field")
469 return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field")
466 }
470 }
467 headers, err := m.ResolvedHeaders(resolver)
471 headers, err := m.ResolvedHeaders(resolver)
@@ -474,11 +478,15 @@ func createTransport(ctx context.Context, m config.MCPConfig, resolver config.Va
474 },
478 },
475 }
479 }
476 return &mcp.StreamableClientTransport{
480 return &mcp.StreamableClientTransport{
477− Endpoint: m.URL,
481+ Endpoint: url,
478 HTTPClient: client,
482 HTTPClient: client,
479 }, nil
483 }, nil
480 case config.MCPSSE:
484 case config.MCPSSE:
485+ url, err := m.ResolvedURL(resolver)
486+ if err != nil {
487+ return nil, err
488+ }
481− if strings.TrimSpace(m.URL) == "" {
489+ if strings.TrimSpace(url) == "" {
482 return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field")
490 return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field")
483 }
491 }
484 headers, err := m.ResolvedHeaders(resolver)
492 headers, err := m.ResolvedHeaders(resolver)
@@ -491,7 +499,7 @@ func createTransport(ctx context.Context, m config.MCPConfig, resolver config.Va
491 },
499 },
492 }
500 }
493 return &mcp.SSEClientTransport{
501 return &mcp.SSEClientTransport{
494− Endpoint: m.URL,
502+ Endpoint: url,
495 HTTPClient: client,
503 HTTPClient: client,
496 }, nil
504 }, nil
497 default:
505 default:
internal/agent/tools/mcp/init_test.go+96
@@ -4,6 +4,8 @@ import (
4 "context"
4 "context"
5 "testing"
5 "testing"
6
6
7+ "github.com/charmbracelet/crush/internal/config"
8+ "github.com/charmbracelet/crush/internal/env"
7 "github.com/modelcontextprotocol/go-sdk/mcp"
9 "github.com/modelcontextprotocol/go-sdk/mcp"
8 "github.com/stretchr/testify/require"
10 "github.com/stretchr/testify/require"
9 "go.uber.org/goleak"
11 "go.uber.org/goleak"
@@ -36,3 +38,97 @@ func TestMCPSession_CancelOnClose(t *testing.T) {
36 // After Close, the context must be cancelled.
38 // After Close, the context must be cancelled.
37 require.ErrorIs(t, ctx.Err(), context.Canceled)
39 require.ErrorIs(t, ctx.Err(), context.Canceled)
38}
40}
41+
42+// TestCreateTransport_URLResolution pins that m.URL goes through the
43+// same resolver seam as command, args, env, and headers. Covers both
44+// the HTTP and SSE branches, success and failure, so a regression in
45+// ResolvedURL wiring is caught at the transport layer rather than only
46+// at the config layer.
47+func TestCreateTransport_URLResolution(t *testing.T) {
48+ t.Parallel()
49+
50+ shell := config.NewShellVariableResolver(env.NewFromMap(map[string]string{
51+ "MCP_HOST": "mcp.example.com",
52+ }))
53+
54+ t.Run("http success expands $VAR", func(t *testing.T) {
55+ t.Parallel()
56+ m := config.MCPConfig{
57+ Type: config.MCPHttp,
58+ URL: "https://$MCP_HOST/api",
59+ }
60+ tr, err := createTransport(t.Context(), m, shell)
61+ require.NoError(t, err)
62+ require.NotNil(t, tr)
63+ sct, ok := tr.(*mcp.StreamableClientTransport)
64+ require.True(t, ok, "expected StreamableClientTransport, got %T", tr)
65+ require.Equal(t, "https://mcp.example.com/api", sct.Endpoint)
66+ })
67+
68+ t.Run("sse success expands $(cmd)", func(t *testing.T) {
69+ t.Parallel()
70+ m := config.MCPConfig{
71+ Type: config.MCPSSE,
72+ URL: "https://$(echo mcp.example.com)/events",
73+ }
74+ tr, err := createTransport(t.Context(), m, shell)
75+ require.NoError(t, err)
76+ sse, ok := tr.(*mcp.SSEClientTransport)
77+ require.True(t, ok, "expected SSEClientTransport, got %T", tr)
78+ require.Equal(t, "https://mcp.example.com/events", sse.Endpoint)
79+ })
80+
81+ t.Run("http unset var surfaces error, no transport created", func(t *testing.T) {
82+ t.Parallel()
83+ m := config.MCPConfig{
84+ Type: config.MCPHttp,
85+ URL: "https://$MCP_MISSING_HOST/api",
86+ }
87+ tr, err := createTransport(t.Context(), m, shell)
88+ require.Error(t, err)
89+ require.Nil(t, tr)
90+ require.Contains(t, err.Error(), "url:")
91+ require.Contains(t, err.Error(), "$MCP_MISSING_HOST")
92+ })
93+
94+ t.Run("sse failing $(cmd) surfaces error, no transport created", func(t *testing.T) {
95+ t.Parallel()
96+ m := config.MCPConfig{
97+ Type: config.MCPSSE,
98+ URL: "https://$(false)/events",
99+ }
100+ tr, err := createTransport(t.Context(), m, shell)
101+ require.Error(t, err)
102+ require.Nil(t, tr)
103+ require.Contains(t, err.Error(), "url:")
104+ require.Contains(t, err.Error(), "$(false)")
105+ })
106+
107+ t.Run("http empty-after-resolve still fails the non-empty guard", func(t *testing.T) {
108+ t.Parallel()
109+ // ${MCP_EMPTY:-} resolves to the empty string (no error),
110+ // then the existing TrimSpace guard in createTransport must
111+ // reject it so we never spawn a transport against "".
112+ m := config.MCPConfig{
113+ Type: config.MCPHttp,
114+ URL: "${MCP_EMPTY:-}",
115+ }
116+ tr, err := createTransport(t.Context(), m, shell)
117+ require.Error(t, err)
118+ require.Nil(t, tr)
119+ require.Contains(t, err.Error(), "non-empty 'url'")
120+ })
121+
122+ t.Run("identity resolver round-trips template verbatim", func(t *testing.T) {
123+ t.Parallel()
124+ // Client mode forwards the template to the server; no local
125+ // expansion, no error on unset vars.
126+ tmpl := "https://$MCP_MISSING_HOST/api"
127+ m := config.MCPConfig{Type: config.MCPHttp, URL: tmpl}
128+ tr, err := createTransport(t.Context(), m, config.IdentityResolver())
129+ require.NoError(t, err)
130+ sct, ok := tr.(*mcp.StreamableClientTransport)
131+ require.True(t, ok)
132+ require.Equal(t, tmpl, sct.Endpoint)
133+ })
134+}
internal/config/config.go+22
@@ -339,6 +339,28 @@ func (m MCPConfig) ResolvedArgs(r VariableResolver) ([]string, error) {
339 return out, nil
339 return out, nil
340}
340}
341
341
342+// ResolvedURL returns m.URL expanded through the given resolver. The
343+// receiver is not mutated. Errors from the resolver are already
344+// sanitized by ResolveValue and are wrapped with %w for errors.Is/As.
345+//
346+// URLs run through the same shell-expansion pipeline as the other
347+// fields, so a literal '$' (e.g. OData query strings containing
348+// $filter/$select) must be escaped as '\$' or '${DOLLAR:-$}' to avoid
349+// being interpreted as a variable reference. Same constraint already
350+// applies to command, args, env, and headers.
351+//
352+// See ResolvedEnv for guidance on picking a resolver.
353+func (m MCPConfig) ResolvedURL(r VariableResolver) (string, error) {
354+ if m.URL == "" {
355+ return "", nil
356+ }
357+ v, err := r.ResolveValue(m.URL)
358+ if err != nil {
359+ return "", fmt.Errorf("url: %w", err)
360+ }
361+ return v, nil
362+}
363+
342// ResolvedHeaders returns m.Headers with every value expanded through
364// ResolvedHeaders returns m.Headers with every value expanded through
343// the given resolver. A fresh map is allocated; m.Headers is never
365// the given resolver. A fresh map is allocated; m.Headers is never
344// mutated. On the first resolution failure it returns nil and an error
366// mutated. On the first resolution failure it returns nil and an error
internal/config/mcp_resolved_url_test.gonew file+88
@@ -0,0 +1,88 @@
1+package config
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/charmbracelet/crush/internal/env"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestMCPConfig_ResolvedURL(t *testing.T) {
12+ t.Parallel()
13+
14+ t.Run("empty url short-circuits without calling resolver", func(t *testing.T) {
15+ t.Parallel()
16+ m := MCPConfig{Type: MCPHttp}
17+ got, err := m.ResolvedURL(stubResolver{err: errors.New("should not be called")})
18+ require.NoError(t, err)
19+ require.Empty(t, got)
20+ })
21+
22+ t.Run("literal url passes through unchanged", func(t *testing.T) {
23+ t.Parallel()
24+ m := MCPConfig{Type: MCPHttp, URL: "https://mcp.example.com/api"}
25+ got, err := m.ResolvedURL(NewShellVariableResolver(env.NewFromMap(nil)))
26+ require.NoError(t, err)
27+ require.Equal(t, "https://mcp.example.com/api", got)
28+ })
29+
30+ t.Run("expands $VAR with shell resolver", func(t *testing.T) {
31+ t.Parallel()
32+ m := MCPConfig{Type: MCPHttp, URL: "https://$MCP_HOST/api"}
33+ r := NewShellVariableResolver(env.NewFromMap(map[string]string{"MCP_HOST": "mcp.example.com"}))
34+ got, err := m.ResolvedURL(r)
35+ require.NoError(t, err)
36+ require.Equal(t, "https://mcp.example.com/api", got)
37+ })
38+
39+ t.Run("expands $(cmd) with shell resolver", func(t *testing.T) {
40+ t.Parallel()
41+ m := MCPConfig{Type: MCPSSE, URL: "https://$(echo mcp.example.com)/events"}
42+ got, err := m.ResolvedURL(NewShellVariableResolver(env.NewFromMap(nil)))
43+ require.NoError(t, err)
44+ require.Equal(t, "https://mcp.example.com/events", got)
45+ })
46+
47+ t.Run("unset var is an error wrapping the template", func(t *testing.T) {
48+ t.Parallel()
49+ m := MCPConfig{Type: MCPHttp, URL: "https://$MCP_MISSING_HOST/api"}
50+ _, err := m.ResolvedURL(NewShellVariableResolver(env.NewFromMap(nil)))
51+ require.Error(t, err)
52+ require.Contains(t, err.Error(), "url:")
53+ require.Contains(t, err.Error(), "$MCP_MISSING_HOST")
54+ require.Contains(t, err.Error(), "unbound")
55+ })
56+
57+ t.Run("failing command substitution is an error", func(t *testing.T) {
58+ t.Parallel()
59+ m := MCPConfig{Type: MCPHttp, URL: "https://$(false)/api"}
60+ _, err := m.ResolvedURL(NewShellVariableResolver(env.NewFromMap(nil)))
61+ require.Error(t, err)
62+ require.Contains(t, err.Error(), "url:")
63+ require.Contains(t, err.Error(), "$(false)")
64+ })
65+
66+ t.Run("identity resolver round-trips template verbatim", func(t *testing.T) {
67+ t.Parallel()
68+ // In client mode expansion happens server-side; the client must
69+ // forward the template without touching it and without erroring
70+ // on unset vars.
71+ tmpl := "https://$MCP_HOST/$(vault read -f url)"
72+ m := MCPConfig{Type: MCPHttp, URL: tmpl}
73+ got, err := m.ResolvedURL(IdentityResolver())
74+ require.NoError(t, err)
75+ require.Equal(t, tmpl, got)
76+ })
77+}
78+
79+// stubResolver returns ("", err) for every call. Paired with a non-nil
80+// err the empty-URL test asserts ResolvedURL short-circuits before
81+// reaching ResolveValue: if it didn't, the test would fail with err.
82+type stubResolver struct {
83+ err error
84+}
85+
86+func (s stubResolver) ResolveValue(v string) (string, error) {
87+ return "", s.err
88+}