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 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}