mcp_resolved_url_test.go

 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 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.
82type stubResolver struct {
83	err error
84}
85
86func (s stubResolver) ResolveValue(v string) (string, error) {
87	return "", s.err
88}