1package config
2
3import (
4 "errors"
5 "testing"
6
7 "github.com/charmbracelet/crush/internal/env"
8 "github.com/stretchr/testify/require"
9)
10
11func 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 expands to empty under lenient default", func(t *testing.T) {
48 t.Parallel()
49 // Phase 2 defaults to nounset-off: bare $VAR on an unset
50 // variable expands to "" rather than erroring. Here the
51 // host collapses to empty, so the caller sees a malformed
52 // URL rather than a resolver error; that's the expected
53 // trade-off for making $OPTIONAL-style patterns work, and
54 // required-credential callers should use ${VAR:?msg}.
55 m := MCPConfig{Type: MCPHttp, URL: "https://$MCP_MISSING_HOST/api"}
56 got, err := m.ResolvedURL(NewShellVariableResolver(env.NewFromMap(nil)))
57 require.NoError(t, err, "unset var must not error under lenient default")
58 require.Equal(t, "https:///api", got)
59 })
60
61 t.Run("colon-question on unset var errors regardless of toggle", func(t *testing.T) {
62 t.Parallel()
63 // ${VAR:?msg} is the opt-in strictness mechanism; it must
64 // hard-error even with NoUnset off so required credentials
65 // surface at load time instead of shipping empty-host URLs
66 // to the transport layer.
67 m := MCPConfig{Type: MCPHttp, URL: "https://${MCP_MISSING_HOST:?set MCP_MISSING_HOST}/api"}
68 _, err := m.ResolvedURL(NewShellVariableResolver(env.NewFromMap(nil)))
69 require.Error(t, err)
70 require.Contains(t, err.Error(), "url:")
71 require.Contains(t, err.Error(), "set MCP_MISSING_HOST")
72 })
73
74 t.Run("failing command substitution is an error", func(t *testing.T) {
75 t.Parallel()
76 m := MCPConfig{Type: MCPHttp, URL: "https://$(false)/api"}
77 _, err := m.ResolvedURL(NewShellVariableResolver(env.NewFromMap(nil)))
78 require.Error(t, err)
79 require.Contains(t, err.Error(), "url:")
80 require.Contains(t, err.Error(), "$(false)")
81 })
82
83 t.Run("identity resolver round-trips template verbatim", func(t *testing.T) {
84 t.Parallel()
85 // In client mode expansion happens server-side; the client must
86 // forward the template without touching it and without erroring
87 // on unset vars.
88 tmpl := "https://$MCP_HOST/$(vault read -f url)"
89 m := MCPConfig{Type: MCPHttp, URL: tmpl}
90 got, err := m.ResolvedURL(IdentityResolver())
91 require.NoError(t, err)
92 require.Equal(t, tmpl, got)
93 })
94}
95
96// stubResolver returns ("", err) for every call. Paired with a non-nil
97// err the empty-URL test asserts ResolvedURL short-circuits before
98// reaching ResolveValue: if it didn't, the test would fail with err.
99type stubResolver struct {
100 err error
101}
102
103func (s stubResolver) ResolveValue(v string) (string, error) {
104 return "", s.err
105}