Ctrl K
ComunidadGuías…

internal/mcpgateway/catalog_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package mcpgateway

import (
	"slices"
	"testing"

	"github.com/modelcontextprotocol/go-sdk/mcp"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestNamespacedName(t *testing.T) {
	t.Parallel()
	full := NamespacedName("github", "create_issue")
	require.Equal(t, "github_create_issue", full)
}

func TestBareToolAliases(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name   string
		owners map[string]string
		want   map[string]string
	}{
		{
			name:   "unique bare names alias",
			owners: map[string]string{"alpha_echo": "alpha", "beta_search": "beta"},
			want:   map[string]string{"echo": "alpha_echo", "search": "beta_search"},
		},
		{
			name:   "ambiguous bare name gets no alias",
			owners: map[string]string{"alpha_echo": "alpha", "beta_echo": "beta"},
			want:   map[string]string{},
		},
		{
			name: "bare name colliding with a namespaced name gets no alias",
			// beta's tool is literally named "alpha_echo"; the namespaced
			// registration must keep winning for that exact name.
			owners: map[string]string{"alpha_echo": "alpha", "beta_alpha_echo": "beta"},
			want:   map[string]string{"echo": "alpha_echo"},
		},
		{
			name: "server names containing the separator stay unambiguous",
			owners: map[string]string{
				"git_hub_sync": "git_hub",
				"git_clone":    "git",
			},
			want: map[string]string{"sync": "git_hub_sync", "clone": "git_clone"},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			got := bareToolAliases(tt.owners)
			require.Equal(t, tt.want, got)
		})
	}
}

func TestFilterTools(t *testing.T) {
	t.Parallel()
	tools := []*mcp.Tool{
		{Name: "write"},
		{Name: "read"},
		{Name: "admin"},
		nil,
		{Name: ""},
	}

	tests := []struct {
		name       string
		allowed    []string
		disallowed []string
		want       []string
	}{
		{name: "no filters keeps all sorted", want: []string{"admin", "read", "write"}},
		{name: "allowlist restricts", allowed: []string{"read", "write"}, want: []string{"read", "write"}},
		{name: "denylist applies after allowlist", allowed: []string{"read", "write"}, disallowed: []string{"write"}, want: []string{"read"}},
		{name: "denylist alone", disallowed: []string{"admin"}, want: []string{"read", "write"}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			filtered := filterTools(discoverTools(tools), tt.allowed, tt.disallowed)
			names := make([]string, 0, len(filtered))
			for _, tool := range filtered {
				names = append(names, tool.Name)
			}
			require.True(t, slices.Equal(names, tt.want), "filterTools() = %v, want %v", names, tt.want)
		})
	}
}

func TestDiscoverToolsNormalizesSchemasForDownstream(t *testing.T) {
	t.Parallel()
	validInput := map[string]any{"type": "object", "properties": map[string]any{"q": map[string]any{"type": "string"}}}
	tools := []*mcp.Tool{
		{Name: "missing"},
		{Name: "invalid", InputSchema: map[string]any{"type": "string"}, OutputSchema: []string{"not", "a", "schema"}},
		{Name: "valid", InputSchema: validInput, OutputSchema: map[string]any{"type": "object"}},
	}

	discovered := discoverTools(tools)
	byName := make(map[string]*mcp.Tool, len(discovered))
	for _, tool := range discovered {
		byName[tool.Name] = tool
	}
	for _, name := range []string{"missing", "invalid", "valid"} {
		require.True(t, isObjectSchema(byName[name].InputSchema), "%s input schema = %#v, want object schema", name, byName[name].InputSchema)
	}
	require.Nil(t, byName["invalid"].OutputSchema)
	require.Equal(t, validInput, byName["valid"].InputSchema)
	require.True(t, isObjectSchema(byName["valid"].OutputSchema), "valid output schema = %#v, want preserved object", byName["valid"].OutputSchema)
}

func TestCatalogWithToolFiltersKeepsDiscoveredTools(t *testing.T) {
	t.Parallel()
	base := &catalog{discovered: discoverTools([]*mcp.Tool{{Name: "write"}, {Name: "read"}})}

	filtered := base.withToolFilters(nil, []string{"write"})
	require.Len(t, filtered.tools, 1)
	assert.Equal(t, "read", filtered.tools[0].Name)
	assert.Equal(t, 1, filtered.excludedToolCount())
	assert.Len(t, filtered.discovered, 2)
	assert.Empty(t, base.tools, "withToolFilters must not mutate the receiver")

	var missing *catalog
	assert.Nil(t, missing.withToolFilters(nil, []string{"write"}))
}

func TestCatalogToolRelaysExplicitSafetyHints(t *testing.T) {
	t.Parallel()
	destructive := true
	tests := []struct {
		name            string
		annotations     *mcp.ToolAnnotations
		wantReadOnly    bool
		wantDestructive bool
	}{
		{name: "unannotated tool has no hints"},
		{name: "read-only hint", annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, wantReadOnly: true},
		{name: "explicit destructive hint", annotations: &mcp.ToolAnnotations{DestructiveHint: &destructive}, wantDestructive: true},
		{name: "read-only wins over destructive", annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, DestructiveHint: &destructive}, wantReadOnly: true},
		{name: "implicit destructive default is not shown", annotations: &mcp.ToolAnnotations{}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			feature := catalogTool(&mcp.Tool{Name: "tool", Annotations: tt.annotations})
			assert.Equal(t, tt.wantReadOnly, feature.ReadOnly)
			assert.Equal(t, tt.wantDestructive, feature.Destructive)
		})
	}
}

func TestServerSpecVisibleTo(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name       string
		userPath   string
		allowed    []string
		disallowed []string
		want       bool
	}{
		{name: "no scope admits everyone", userPath: "/x", want: true},
		{name: "excluded subtree is hidden", userPath: "/contractors/acme", disallowed: []string{"/contractors"}, want: false},
		{name: "sibling of excluded subtree stays visible", userPath: "/staff", disallowed: []string{"/contractors"}, want: true},
		{name: "exclusion wins inside an allowed subtree", userPath: "/eng/contractors", allowed: []string{"/eng"}, disallowed: []string{"/eng/contractors"}, want: false},
		{name: "rest of allowed subtree stays visible", userPath: "/eng/platform", allowed: []string{"/eng"}, disallowed: []string{"/eng/contractors"}, want: true},
		{name: "caller without user path is not in an excluded subtree", userPath: "", disallowed: []string{"/contractors"}, want: true},
		{name: "root exclusion hides from every caller", userPath: "", disallowed: []string{"/"}, want: false},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			spec := ServerSpec{
				UserPaths:           normalizeUserPaths(tt.allowed),
				DisallowedUserPaths: normalizeUserPaths(tt.disallowed),
			}
			assert.Equal(t, tt.want, spec.visibleTo(tt.userPath))
		})
	}
}

func TestUserPathAllowed(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name     string
		userPath string
		allowed  []string
		want     bool
	}{
		{name: "empty allow list admits everyone", userPath: "/x", want: true},
		{name: "root scope admits everyone", userPath: "/x", allowed: []string{"/"}, want: true},
		{name: "exact match", userPath: "/team-a", allowed: []string{"/team-a"}, want: true},
		{name: "subtree match", userPath: "/team-a/dev", allowed: []string{"/team-a"}, want: true},
		{name: "sibling rejected", userPath: "/team-b", allowed: []string{"/team-a"}, want: false},
		{name: "empty user path rejected by scoped list", userPath: "", allowed: []string{"/team-a"}, want: false},
		{name: "prefix is not subtree", userPath: "/team-ab", allowed: []string{"/team-a"}, want: false},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			allowed := normalizeUserPaths(tt.allowed)
			got := userPathAllowed(tt.userPath, allowed)
			require.Equal(t, tt.want, got, "userPathAllowed(%q, %v) = %v, want %v", tt.userPath, tt.allowed, got, tt.want)
		})
	}
}