Ctrl K
ComunidadGuías…

internal/mcpgateway/service_test.go

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

Archivo original ↗Preguntar a KiBot
package mcpgateway

import (
	"context"
	"errors"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"

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

	"github.com/enterpilot/gomodel/config"
	"github.com/enterpilot/gomodel/internal/core"
	"github.com/enterpilot/gomodel/internal/usage"
)

// newTestUpstream serves a real MCP server over streamable HTTP and returns
// its base URL.
func newTestUpstream(t *testing.T, name string, configure func(*mcp.Server)) string {
	t.Helper()
	server := mcp.NewServer(&mcp.Implementation{Name: name, Version: "test"}, &mcp.ServerOptions{
		Instructions: "instructions from " + name,
	})
	if configure != nil {
		configure(server)
	}
	handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil)
	ts := httptest.NewServer(handler)
	t.Cleanup(ts.Close)
	return ts.URL
}

func addEchoTool(name string) func(*mcp.Server) {
	return func(server *mcp.Server) {
		server.AddTool(&mcp.Tool{
			Name:        name,
			Description: "echoes its input",
			InputSchema: map[string]any{"type": "object"},
		}, func(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			return &mcp.CallToolResult{
				Content: []mcp.Content{&mcp.TextContent{Text: "echo:" + string(req.Params.Arguments)}},
			}, nil
		})
	}
}

// recordingUsageLogger captures usage entries written by the gateway.
type recordingUsageLogger struct {
	mu      sync.Mutex
	entries []*usage.UsageEntry
}

func (l *recordingUsageLogger) Write(entry *usage.UsageEntry) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.entries = append(l.entries, entry)
}

func (l *recordingUsageLogger) Config() usage.Config { return usage.Config{Enabled: true} }
func (l *recordingUsageLogger) Close() error         { return nil }

func (l *recordingUsageLogger) all() []*usage.UsageEntry {
	l.mu.Lock()
	defer l.mu.Unlock()
	return append([]*usage.UsageEntry(nil), l.entries...)
}

func testSpec(name, url string, mutate func(*ServerSpec)) ServerSpec {
	spec := ServerSpec{
		Name:        name,
		URL:         url,
		Transport:   config.MCPTransportHTTP,
		Enabled:     true,
		ToolTimeout: 10 * time.Second,
		Managed:     true,
	}
	if mutate != nil {
		mutate(&spec)
	}
	return spec
}

// newTestService builds a Service over the given specs and returns it with a
// gateway HTTP endpoint that mimics GoModel's ingress: bearer auth is assumed
// done; the user path is read from the standard header into the context, and
// /mcp/{server} pins a single upstream.
func newTestService(t *testing.T, usageLogger usage.LoggerInterface, specs ...ServerSpec) (*Service, string) {
	t.Helper()
	configServers := make(map[string]ServerSpec, len(specs))
	for _, spec := range specs {
		configServers[spec.Name] = spec
	}
	return newTestServiceWithOptions(t, Options{
		ConfigServers: configServers,
		UsageLogger:   usageLogger,
	})
}

// newTestServiceWithOptions is newTestService for callers that also need
// stores or virtual servers; the config servers come from opts.
func newTestServiceWithOptions(t *testing.T, opts Options) (*Service, string) {
	t.Helper()
	service, err := NewService(context.Background(), opts)
	require.NoError(t, err)

	t.Cleanup(service.Close)

	gateway := httptest.NewServer(gatewayHandler(service))
	t.Cleanup(gateway.Close)

	waitForConnected(t, service, len(service.Views()))
	return service, gateway.URL
}

// gatewayHandler mimics GoModel's ingress in front of service: bearer auth is
// assumed done; the auth key and user path are read from test headers into
// the context, and /mcp/{server} pins a single upstream.
func gatewayHandler(service *Service) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if authKeyID := r.Header.Get("X-Test-Auth-Key-ID"); authKeyID != "" {
			r = r.WithContext(core.WithAuthKeyID(r.Context(), authKeyID))
		}
		if userPath := r.Header.Get(core.UserPathHeader); userPath != "" {
			r = r.WithContext(core.WithEffectiveUserPath(r.Context(), userPath))
		}
		pinned := ""
		if rest, ok := strings.CutPrefix(r.URL.Path, "/mcp/"); ok {
			pinned = rest
		}
		if err := service.ServeHTTP(w, r, pinned); err != nil {
			status := http.StatusInternalServerError
			if gatewayErr, ok := errors.AsType[*core.GatewayError](err); ok {
				status = gatewayErr.HTTPStatusCode()
			}
			http.Error(w, err.Error(), status)
		}
	}
}

// waitForConnected waits until every enabled server reports a terminal
// status (connected or degraded), so tests do not race the async connect.
func waitForConnected(t *testing.T, service *Service, servers int) {
	t.Helper()
	require.Eventually(t, func() bool {
		views := service.Views()
		settled := 0
		for _, view := range views {
			if !view.Spec.Enabled || view.Status == StatusConnected || view.Status == StatusDegraded {
				settled++
			}
		}
		return len(views) == servers && settled == servers
	}, 10*time.Second, 20*time.Millisecond, "upstreams did not settle")
}

func connectClient(t *testing.T, endpoint string, headers map[string]string) *mcp.ClientSession {
	t.Helper()
	client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "test"}, nil)
	transport := &mcp.StreamableClientTransport{
		Endpoint: endpoint,
		HTTPClient: &http.Client{Transport: &headerRoundTripper{
			base: http.DefaultTransport, headers: headers, origin: requestOrigin(endpoint),
		}},
	}
	session, err := client.Connect(context.Background(), transport, nil)
	require.NoError(t, err, "client connect to %s: %v", endpoint, err)

	t.Cleanup(func() { _ = session.Close() })
	return session
}

const testInitializeBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}`

func rawMCPPost(t *testing.T, endpoint, body string, headers map[string]string) *http.Response {
	t.Helper()
	req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(body))
	require.NoError(t, err)

	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json, text/event-stream")
	for name, value := range headers {
		req.Header.Set(name, value)
	}
	resp, err := http.DefaultClient.Do(req)
	require.NoError(t, err)

	return resp
}

func initializeRawSession(t *testing.T, endpoint string, headers map[string]string) string {
	t.Helper()
	resp := rawMCPPost(t, endpoint, testInitializeBody, headers)
	defer resp.Body.Close()
	require.Equal(t, http.StatusOK, resp.StatusCode)

	sessionID := resp.Header.Get("Mcp-Session-Id")
	require.NotEmpty(t, sessionID)

	return sessionID
}

func rawMCPStatus(t *testing.T, endpoint, body string, headers map[string]string) int {
	t.Helper()
	resp := rawMCPPost(t, endpoint, body, headers)
	defer resp.Body.Close()
	return resp.StatusCode
}

func listToolNames(t *testing.T, session *mcp.ClientSession) []string {
	t.Helper()
	result, err := session.ListTools(context.Background(), nil)
	require.NoError(t, err)

	names := make([]string, 0, len(result.Tools))
	for _, tool := range result.Tools {
		names = append(names, tool.Name)
	}
	return names
}

func TestAggregatedEndpointNamespacesAndRelays(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	betaURL := newTestUpstream(t, "beta", addEchoTool("search"))
	usageLog := &recordingUsageLogger{}
	_, gatewayURL := newTestService(t, usageLog,
		testSpec("alpha", alphaURL, nil),
		testSpec("beta", betaURL, nil),
	)

	session := connectClient(t, gatewayURL+"/mcp", map[string]string{
		core.UserPathHeader:  "/team-a",
		"X-Request-ID":       "req-123",
		"X-Test-Auth-Key-ID": "key-a",
	})

	names := listToolNames(t, session)
	want := []string{"alpha_echo", "beta_search"}
	require.Len(t, names, 2)
	require.Equal(t, want[0], names[0])
	require.Equal(t, want[1], names[1], "ListTools() = %v, want %v", names, want)

	result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
		Name:      "alpha_echo",
		Arguments: map[string]any{"value": 1},
	})
	require.NoError(t, err)
	require.Len(t, result.Content, 1)

	text, ok := result.Content[0].(*mcp.TextContent)
	require.True(t, ok)
	require.True(t, strings.HasPrefix(text.Text, "echo:"), "CallTool content = %#v, want echo:... text", result.Content[0])

	// The tool call must be attributed in the usage pipeline.
	require.Eventually(t, func() bool { return len(usageLog.all()) > 0 }, 2*time.Second, 10*time.Millisecond, "no usage entry was logged")
	entries := usageLog.all()
	require.Len(t, entries, 1)

	entry := entries[0]
	require.Equal(t, "mcp", entry.Provider)
	require.Equal(t, "alpha", entry.ProviderName)
	require.Equal(t, "alpha_echo", entry.Model)
	require.Equal(t, "/team-a", entry.UserPath)
	require.Equal(t, "req-123", entry.RequestID)
	require.Equal(t, "key-a", entry.AuthKeyID)
}

func TestAggregatedEndpointAcceptsUniqueBareToolName(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	betaURL := newTestUpstream(t, "beta", addEchoTool("search"))
	usageLog := &recordingUsageLogger{}
	_, gatewayURL := newTestService(t, usageLog,
		testSpec("alpha", alphaURL, nil),
		testSpec("beta", betaURL, nil),
	)

	session := connectClient(t, gatewayURL+"/mcp", nil)

	// tools/list stays namespaced; only tools/call accepts the bare name.
	names := listToolNames(t, session)
	require.Len(t, names, 2)
	require.Equal(t, "alpha_echo", names[0])
	require.Equal(t, "beta_search", names[1])

	result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
		Name:      "echo",
		Arguments: map[string]any{"value": 1},
	})
	require.NoError(t, err)

	text, ok := result.Content[0].(*mcp.TextContent)
	require.True(t, ok)
	require.True(t, strings.HasPrefix(text.Text, "echo:"), "CallTool(echo) content = %#v, want echo:... text", result.Content[0])

	// Usage accounting stays canonical: the entry records the namespaced name.
	require.Eventually(t, func() bool { return len(usageLog.all()) > 0 }, 2*time.Second, 10*time.Millisecond, "no usage entry was logged")
	entries := usageLog.all()
	require.Len(t, entries, 1)
	require.Equal(t, "alpha_echo", entries[0].Model)
	require.Equal(t, "alpha", entries[0].ProviderName)
}

func TestToolCallLabelResolvesSessionAliases(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	betaURL := newTestUpstream(t, "beta", func(server *mcp.Server) {
		addEchoTool("echo")(server)
		addEchoTool("search")(server)
	})
	gammaURL := newTestUpstream(t, "gamma", addEchoTool("fetch"))
	service, gatewayURL := newTestService(t, nil,
		testSpec("alpha", alphaURL, nil),
		testSpec("beta", betaURL, nil),
		testSpec("gamma", gammaURL, nil),
	)

	aggregated := connectClient(t, gatewayURL+"/mcp", nil).ID()
	assert.Equal(t, "beta_search", service.ToolCallLabel(aggregated, "search", ""))
	assert.Equal(t, "gamma_fetch", service.ToolCallLabel(aggregated, "fetch", ""))
	assert.Equal(t, "echo", service.ToolCallLabel(aggregated, "echo", ""), "ambiguous names stay as sent")
	assert.Equal(t, "alpha_echo", service.ToolCallLabel(aggregated, "alpha_echo", ""))

	discovery := connectClient(t, gatewayURL+"/mcp", map[string]string{ToolDiscoveryHeader: "search"}).ID()
	assert.Equal(t, "gamma_fetch", service.ToolCallLabel(discovery, callToolName, "fetch"), "call_tool resolves to its bare target")
	assert.Equal(t, "alpha_echo", service.ToolCallLabel(discovery, callToolName, "alpha_echo"))
	assert.Equal(t, callToolName, service.ToolCallLabel(discovery, callToolName, ""))

	pinned := connectClient(t, gatewayURL+"/mcp/beta", nil).ID()
	assert.Equal(t, "search", service.ToolCallLabel(pinned, "search", ""), "pinned endpoints use original names")
	assert.Equal(t, callToolName, service.ToolCallLabel(pinned, callToolName, "search"), "without discovery call_tool is an upstream tool name")
	assert.Equal(t, "search", service.ToolCallLabel("unknown-session", "search", ""))
}

func TestAggregatedEndpointRejectsAmbiguousBareToolName(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	betaURL := newTestUpstream(t, "beta", addEchoTool("echo"))
	_, gatewayURL := newTestService(t, nil,
		testSpec("alpha", alphaURL, nil),
		testSpec("beta", betaURL, nil),
	)

	session := connectClient(t, gatewayURL+"/mcp", nil)
	_, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "echo"})
	require.Error(t, err)

	// The namespaced forms keep working.
	for _, name := range []string{"alpha_echo", "beta_echo"} {
		_, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: name})
		require.NoError(t, err)
	}
}

func TestPerServerEndpointKeepsOriginalNames(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	betaURL := newTestUpstream(t, "beta", addEchoTool("search"))
	_, gatewayURL := newTestService(t, nil,
		testSpec("alpha", alphaURL, nil),
		testSpec("beta", betaURL, nil),
	)

	session := connectClient(t, gatewayURL+"/mcp/alpha", nil)
	names := listToolNames(t, session)
	require.Len(t, names, 1)
	require.Equal(t, "echo", names[0])

	result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "echo"})
	require.NoError(t, err)
	require.False(t, result.IsError)
}

func TestUnknownPinnedServerReturns404(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	_, gatewayURL := newTestService(t, nil, testSpec("alpha", alphaURL, nil))

	resp, err := http.Post(gatewayURL+"/mcp/ghost", "application/json", strings.NewReader(`{}`))
	require.NoError(t, err)

	defer resp.Body.Close()
	require.Equal(t, http.StatusNotFound, resp.StatusCode)
}

func TestUserPathVisibilityFiltersServers(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	betaURL := newTestUpstream(t, "beta", addEchoTool("search"))
	_, gatewayURL := newTestService(t, nil,
		testSpec("alpha", alphaURL, nil),
		testSpec("beta", betaURL, func(spec *ServerSpec) {
			spec.UserPaths = []string{"/team-b"}
		}),
	)

	// A /team-a caller must not discover beta's tools.
	teamA := connectClient(t, gatewayURL+"/mcp", map[string]string{core.UserPathHeader: "/team-a"})
	names := listToolNames(t, teamA)
	require.Len(t, names, 1)
	require.Equal(t, "alpha_echo", names[0])
	_, err := teamA.CallTool(context.Background(), &mcp.CallToolParams{Name: "beta_search"})
	require.Error(t, err)

	// A /team-b/dev caller inherits the /team-b subtree scope.
	teamB := connectClient(t, gatewayURL+"/mcp", map[string]string{core.UserPathHeader: "/team-b/dev"})
	names = listToolNames(t, teamB)
	require.Len(t, names, 2)

	// A pinned endpoint for an out-of-scope server is a 404.
	resp, err := http.Post(gatewayURL+"/mcp/beta", "application/json", strings.NewReader(`{}`))
	require.NoError(t, err)

	defer resp.Body.Close()
	require.Equal(t, http.StatusNotFound, resp.StatusCode)
}

func TestScopeHeaderNarrowsVisibleServers(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	betaURL := newTestUpstream(t, "beta", addEchoTool("search"))
	_, gatewayURL := newTestService(t, nil,
		testSpec("alpha", alphaURL, nil),
		testSpec("beta", betaURL, nil),
	)

	session := connectClient(t, gatewayURL+"/mcp", map[string]string{ScopeHeader: "beta, ghost"})
	names := listToolNames(t, session)
	require.Len(t, names, 1)
	require.Equal(t, "beta_search", names[0])
}

func TestToolFiltersHideTools(t *testing.T) {
	url := newTestUpstream(t, "alpha", func(server *mcp.Server) {
		addEchoTool("read")(server)
		addEchoTool("write")(server)
		addEchoTool("admin")(server)
	})
	_, gatewayURL := newTestService(t, nil,
		testSpec("alpha", url, func(spec *ServerSpec) {
			spec.AllowedTools = []string{"read", "write"}
			spec.DisallowedTools = []string{"write"}
		}),
	)

	session := connectClient(t, gatewayURL+"/mcp", nil)
	names := listToolNames(t, session)
	require.Len(t, names, 1)
	require.Equal(t, "alpha_read", names[0])
}

func TestToolFilterEditAppliesInPlaceAndBlocksOpenSessions(t *testing.T) {
	url := newTestUpstream(t, "alpha", func(server *mcp.Server) {
		addEchoTool("read")(server)
		addEchoTool("write")(server)
	})
	spec := testSpec("alpha", url, nil)
	service, gatewayURL := newTestService(t, nil, spec)

	openSession := connectClient(t, gatewayURL+"/mcp", nil)
	require.Equal(t, []string{"alpha_read", "alpha_write"}, listToolNames(t, openSession))

	before, ok := service.manager.get("alpha")
	require.True(t, ok)
	upstreamSession := before.session

	spec.DisallowedTools = []string{"write"}
	service.manager.Apply([]ServerSpec{spec})

	after, ok := service.manager.get("alpha")
	require.True(t, ok)
	assert.Same(t, before, after, "a filter-only edit must not replace the upstream")
	assert.Same(t, upstreamSession, after.session, "a filter-only edit must not redial")
	view := after.view()
	assert.Equal(t, StatusConnected, view.Status)
	assert.Equal(t, 1, view.ToolCount)
	assert.Equal(t, 1, view.ExcludedToolCount)
	assert.Equal(t, []string{"write"}, view.Spec.DisallowedTools)

	service.manager.Apply([]ServerSpec{spec})
	unchanged, ok := service.manager.get("alpha")
	require.True(t, ok)
	assert.Same(t, after, unchanged, "re-applying an identical spec must keep the upstream")

	_, err := openSession.CallTool(context.Background(), &mcp.CallToolParams{Name: "alpha_write"})
	require.Error(t, err, "an excluded tool must not be callable from a session opened before the edit")
	assert.Contains(t, err.Error(), "excluded")

	fresh := connectClient(t, gatewayURL+"/mcp", nil)
	assert.Equal(t, []string{"alpha_read"}, listToolNames(t, fresh))

	catalog, ok := service.Catalog("alpha")
	require.True(t, ok)
	require.Len(t, catalog.Tools, 1)
	assert.Equal(t, "read", catalog.Tools[0].Name)
	require.Len(t, catalog.ExcludedTools, 1)
	assert.Equal(t, "write", catalog.ExcludedTools[0].Name)
}

func TestDisallowedUserPathsHideServers(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	betaURL := newTestUpstream(t, "beta", addEchoTool("search"))
	_, gatewayURL := newTestService(t, nil,
		testSpec("alpha", alphaURL, nil),
		testSpec("beta", betaURL, func(spec *ServerSpec) {
			spec.UserPaths = []string{"/eng"}
			spec.DisallowedUserPaths = []string{"/eng/contractors"}
		}),
	)

	contractor := connectClient(t, gatewayURL+"/mcp", map[string]string{core.UserPathHeader: "/eng/contractors/acme"})
	assert.Equal(t, []string{"alpha_echo"}, listToolNames(t, contractor))

	staff := connectClient(t, gatewayURL+"/mcp", map[string]string{core.UserPathHeader: "/eng/platform"})
	assert.Equal(t, []string{"alpha_echo", "beta_search"}, listToolNames(t, staff))

	req, err := http.NewRequest(http.MethodPost, gatewayURL+"/mcp/beta", strings.NewReader(`{}`))
	require.NoError(t, err)
	req.Header.Set(core.UserPathHeader, "/eng/contractors")
	resp, err := http.DefaultClient.Do(req)
	require.NoError(t, err)
	defer resp.Body.Close()
	assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}

func TestUserPathEditAppliesInPlaceAndReachesOpenSessions(t *testing.T) {
	url := newTestUpstream(t, "alpha", addEchoTool("echo"))
	spec := testSpec("alpha", url, nil)
	service, gatewayURL := newTestService(t, nil, spec)

	session := connectClient(t, gatewayURL+"/mcp", map[string]string{core.UserPathHeader: "/contractors"})
	require.Equal(t, []string{"alpha_echo"}, listToolNames(t, session))

	before, ok := service.manager.get("alpha")
	require.True(t, ok)
	upstreamSession := before.session

	spec.DisallowedUserPaths = []string{"/contractors"}
	service.manager.Apply([]ServerSpec{spec})

	after, ok := service.manager.get("alpha")
	require.True(t, ok)
	assert.Same(t, before, after, "a user-path edit must not replace the upstream")
	assert.Same(t, upstreamSession, after.session, "a user-path edit must not redial")

	_, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "alpha_echo"})
	require.Error(t, err, "a session opened before the exclusion must lose access")
	assert.Contains(t, err.Error(), "not available for this user path")

	other := connectClient(t, gatewayURL+"/mcp", map[string]string{core.UserPathHeader: "/staff"})
	result, err := other.CallTool(context.Background(), &mcp.CallToolParams{Name: "alpha_echo"})
	require.NoError(t, err)
	assert.False(t, result.IsError)
}

func TestAuthorizeSessionFailsClosedWithoutBinding(t *testing.T) {
	url := newTestUpstream(t, "alpha", addEchoTool("echo"))
	service, _ := newTestService(t, nil, testSpec("alpha", url, func(spec *ServerSpec) {
		spec.DisallowedUserPaths = []string{"/contractors"}
	}))

	// A binding removed mid-call (DELETE or expiry) must not be read as a
	// caller without a user path, which the denylist alone would admit.
	err := service.authorizeSessionID("deleted-session", "alpha")
	require.ErrorIs(t, err, ErrServerNotVisible)

	service.bindSession("live", "", "/staff", "", false, nil, nil)
	require.NoError(t, service.authorizeSessionID("live", "alpha"))
	service.bindSession("contractor", "", "/contractors/acme", "", false, nil, nil)
	require.ErrorIs(t, service.authorizeSessionID("contractor", "alpha"), ErrServerNotVisible)
}

func TestSessionBindingRejectsForeignUserPath(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	_, gatewayURL := newTestService(t, nil, testSpec("alpha", alphaURL, nil))

	sessionID := initializeRawSession(t, gatewayURL+"/mcp", map[string]string{
		core.UserPathHeader: "/team-a",
	})
	listBody := `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`
	status := rawMCPStatus(t, gatewayURL+"/mcp", listBody, map[string]string{
		"Mcp-Session-Id":    sessionID,
		core.UserPathHeader: "/team-b",
	})
	require.Equal(t, http.StatusNotFound, status)
}

func TestSessionBindingRejectsForeignAuthKey(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	_, gatewayURL := newTestService(t, nil, testSpec("alpha", alphaURL, nil))

	sessionID := initializeRawSession(t, gatewayURL+"/mcp", map[string]string{"X-Test-Auth-Key-ID": "key-a"})
	listBody := `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`
	status := rawMCPStatus(t, gatewayURL+"/mcp", listBody, map[string]string{
		"Mcp-Session-Id":     sessionID,
		"X-Test-Auth-Key-ID": "key-b",
	})
	require.Equal(t, http.StatusNotFound, status)
}

func TestSessionBindingRejectsDifferentPinnedEndpoint(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	betaURL := newTestUpstream(t, "beta", addEchoTool("search"))
	_, gatewayURL := newTestService(t, nil,
		testSpec("alpha", alphaURL, nil),
		testSpec("beta", betaURL, nil),
	)

	sessionID := initializeRawSession(t, gatewayURL+"/mcp/alpha", nil)
	listBody := `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`
	status := rawMCPStatus(t, gatewayURL+"/mcp/beta", listBody, map[string]string{"Mcp-Session-Id": sessionID})
	require.Equal(t, http.StatusNotFound, status)
}

func TestStreamableHTTPRejectsCrossOriginRequests(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	_, gatewayURL := newTestService(t, nil, testSpec("alpha", alphaURL, nil))

	status := rawMCPStatus(t, gatewayURL+"/mcp", testInitializeBody, map[string]string{
		"Origin": "https://attacker.example",
	})
	require.Equal(t, http.StatusForbidden, status)
}

func TestDownstreamCapabilitiesDoNotPromiseListChanged(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	_, gatewayURL := newTestService(t, nil, testSpec("alpha", alphaURL, nil))

	session := connectClient(t, gatewayURL+"/mcp", nil)
	init := session.InitializeResult()
	require.NotNil(t, init)
	require.NotNil(t, init.Capabilities)
	require.NotNil(t, init.Capabilities.Tools)
	require.False(t, init.Capabilities.Tools.ListChanged)
	require.NotNil(t, init.Capabilities.Prompts)
	require.False(t, init.Capabilities.Prompts.ListChanged)
	require.NotNil(t, init.Capabilities.Resources)
	require.False(t, init.Capabilities.Resources.ListChanged)
}

func TestUpstreamHeadersStayOnConfiguredOrigin(t *testing.T) {
	var redirectedHeader string
	redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		redirectedHeader = r.Header.Get("Authorization")
		w.WriteHeader(http.StatusNoContent)
	}))
	defer redirectTarget.Close()

	var sameOriginHeader string
	origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/redirect" {
			http.Redirect(w, r, redirectTarget.URL, http.StatusTemporaryRedirect)
			return
		}
		sameOriginHeader = r.Header.Get("Authorization")
		w.WriteHeader(http.StatusNoContent)
	}))
	defer origin.Close()

	u := newUpstream(ServerSpec{
		Name: "headers", URL: origin.URL, Transport: "http", Enabled: true,
		Headers: map[string]string{"Authorization": "Bearer upstream-secret"},
	}, http.DefaultClient)
	client := u.dialClient(&connectProbe{}, context.Background())
	resp, err := client.Get(origin.URL + "/same-origin")
	require.NoError(t, err)

	_ = resp.Body.Close()
	resp, err = client.Get(origin.URL + "/redirect")
	require.NoError(t, err)

	_ = resp.Body.Close()
	require.Equal(t, "Bearer upstream-secret", sameOriginHeader)
	require.Empty(t, redirectedHeader)
}

func TestDegradedUpstreamKeepsCatalogAndReportsError(t *testing.T) {
	upstream := mcp.NewServer(&mcp.Implementation{Name: "alpha", Version: "test"}, nil)
	addEchoTool("echo")(upstream)
	handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return upstream }, nil)
	ts := httptest.NewServer(handler)

	service, gatewayURL := newTestService(t, nil, testSpec("alpha", ts.URL, nil))

	// Kill the upstream, then force a reconnect: the server must degrade but
	// keep its last known catalog (stale carry-forward, not empty-connected).
	// The gateway still holds the upstream's standalone SSE stream, so drop
	// live connections before Close (which waits for outstanding requests).
	ts.CloseClientConnections()
	ts.Close()
	_, err := service.Reconnect(context.Background(), "alpha")
	require.Error(t, err)

	views := service.Views()
	require.Len(t, views, 1)
	require.Equal(t, StatusDegraded, views[0].Status)
	require.Equal(t, 1, views[0].ToolCount)
	require.NotEmpty(t, views[0].LastError)

	// New sessions still see the stale catalog; calling the tool fails with a
	// JSON-RPC error, never a fabricated result.
	session := connectClient(t, gatewayURL+"/mcp", nil)
	names := listToolNames(t, session)
	require.Len(t, names, 1)
	require.Equal(t, "alpha_echo", names[0])
	_, err = session.CallTool(context.Background(), &mcp.CallToolParams{Name: "alpha_echo"})
	require.Error(t, err)
}

// TestDegradedUpstreamRecoversOnReprobe covers the background re-probe path:
// after the upstream dies and comes back on the same address, a plain refresh
// (what the maintenance loop runs) must recover without a manual reconnect.
func TestDegradedUpstreamRecoversOnReprobe(t *testing.T) {
	upstream := mcp.NewServer(&mcp.Implementation{Name: "alpha", Version: "test"}, nil)
	addEchoTool("echo")(upstream)
	handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return upstream }, nil)

	listener, err := net.Listen("tcp", "127.0.0.1:0")
	require.NoError(t, err)

	addr := listener.Addr().String()
	ts1 := &httptest.Server{Listener: listener, Config: &http.Server{Handler: handler}}
	ts1.Start()

	service, _ := newTestService(t, nil, testSpec("alpha", "http://"+addr, nil))
	u, ok := service.manager.get("alpha")
	require.True(t, ok)

	// Kill the upstream and drive one failed refresh (the re-probe path).
	ts1.CloseClientConnections()
	ts1.Close()
	require.Error(t, u.refresh(context.Background()))
	_, status := u.snapshot()
	require.Equal(t, StatusDegraded, status)

	// Resurrect the upstream on the same address; the port was just freed by
	// this process, so rebinding may need a brief retry.
	var listener2 net.Listener
	require.Eventuallyf(t, func() bool {
		listener2, err = net.Listen("tcp", addr)
		return err == nil
	}, 5*time.Second, 50*time.Millisecond, "re-listen on %s", addr)

	ts2 := &httptest.Server{Listener: listener2, Config: &http.Server{Handler: handler}}
	ts2.Start()
	t.Cleanup(func() {
		ts2.CloseClientConnections()
		ts2.Close()
	})
	// The next refresh (as the maintenance ticker would run it) must redial
	// from scratch and flip the server back to connected.
	err = u.refresh(context.Background())
	require.NoError(t, err)

	view := u.view()
	require.Equal(t, StatusConnected, view.Status)
	require.Equal(t, 1, view.ToolCount, "view after recovery = %+v, want connected with 1 tool", view)
}

// TestRemovedUpstreamRefusesRedial covers the reconcile race: a background
// refresh racing an Apply that removed the server must not dial and leak an
// untracked session.
func TestRemovedUpstreamRefusesRedial(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	service, _ := newTestService(t, nil, testSpec("alpha", alphaURL, nil))

	u, ok := service.manager.get("alpha")
	require.True(t, ok)

	// Reconcile away the server, then run the stale refresh a background
	// goroutine could still be holding.
	service.manager.Apply(nil)
	err := u.refresh(context.Background())
	require.NoError(t, err)

	u.stateMu.Lock()
	session := u.session
	u.stateMu.Unlock()
	require.Nil(t, session)
	_, err = u.callTool(context.Background(), "echo", nil)
	require.Error(t, err)
}

// TestCloseDuringDialDiscardsSession covers the dial-vs-close race: close()
// deliberately does not wait for an in-flight dial, so a dial completing
// after disposal must discard its fresh session instead of storing (and
// leaking) it.
func TestCloseDuringDialDiscardsSession(t *testing.T) {
	upstream := mcp.NewServer(&mcp.Implementation{Name: "alpha", Version: "test"}, nil)
	addEchoTool("echo")(upstream)
	inner := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return upstream }, nil)

	// Gate the first request so the dial is reliably in flight when close runs.
	dialEntered := make(chan struct{})
	releaseDial := make(chan struct{})
	var gateOnce sync.Once
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gateOnce.Do(func() {
			close(dialEntered)
			<-releaseDial
		})
		inner.ServeHTTP(w, r)
	}))
	t.Cleanup(func() {
		ts.CloseClientConnections()
		ts.Close()
	})

	u := newUpstream(testSpec("alpha", ts.URL, nil), nil)

	refreshDone := make(chan error, 1)
	go func() { refreshDone <- u.refresh(context.Background()) }()

	<-dialEntered
	u.close()
	close(releaseDial)

	require.Error(t, <-refreshDone)

	u.stateMu.Lock()
	session := u.session
	u.stateMu.Unlock()
	require.Nil(t, session)
}

func TestCloseCancelsActiveDownstreamRequests(t *testing.T) {
	service, err := NewService(context.Background(), Options{})
	require.NoError(t, err)

	requestStarted := make(chan struct{})
	requestDone := make(chan error, 1)
	service.handler = http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
		close(requestStarted)
		<-r.Context().Done()
	})
	go func() {
		req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
		requestDone <- service.ServeHTTP(httptest.NewRecorder(), req, "")
	}()

	select {
	case <-requestStarted:
	case <-time.After(time.Second):
		t.Fatal("downstream request did not start")
	}

	service.Close()
	service.Close() // Close remains safe when the subsystem owner closes again.

	select {
	case err := <-requestDone:
		require.NoError(t, err)
	case <-time.After(time.Second):
		t.Fatal("Close did not cancel the downstream request")
	}
}

func TestInstructionsComposeFromUpstreams(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	_, gatewayURL := newTestService(t, nil, testSpec("alpha", alphaURL, nil))

	session := connectClient(t, gatewayURL+"/mcp", nil)
	init := session.InitializeResult()
	require.NotNil(t, init)
	require.Contains(t, init.Instructions, "instructions from alpha")
	require.Contains(t, init.Instructions, "GoModel MCP gateway")
}

func TestPromptsAndResourcesRelay(t *testing.T) {
	url := newTestUpstream(t, "alpha", func(server *mcp.Server) {
		addEchoTool("echo")(server)
		server.AddPrompt(&mcp.Prompt{Name: "greet", Description: "greeting"}, func(_ context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
			return &mcp.GetPromptResult{
				Messages: []*mcp.PromptMessage{{
					Role:    "user",
					Content: &mcp.TextContent{Text: "hello from " + req.Params.Name},
				}},
			}, nil
		})
		server.AddResource(&mcp.Resource{
			URI:      "file:///alpha/readme",
			Name:     "readme",
			MIMEType: "text/plain",
		}, func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
			return &mcp.ReadResourceResult{
				Contents: []*mcp.ResourceContents{{URI: req.Params.URI, MIMEType: "text/plain", Text: "readme body"}},
			}, nil
		})
	})
	_, gatewayURL := newTestService(t, nil, testSpec("alpha", url, nil))

	session := connectClient(t, gatewayURL+"/mcp", nil)

	prompts, err := session.ListPrompts(context.Background(), nil)
	require.NoError(t, err)
	require.Len(t, prompts.Prompts, 1)
	require.Equal(t, "alpha_greet", prompts.Prompts[0].Name)

	prompt, err := session.GetPrompt(context.Background(), &mcp.GetPromptParams{Name: "alpha_greet"})
	require.NoError(t, err)

	text, ok := prompt.Messages[0].Content.(*mcp.TextContent)
	require.True(t, ok)
	require.Equal(t, "hello from greet", text.Text, "GetPrompt content = %#v, want original prompt name forwarded", prompt.Messages[0].Content)

	resources, err := session.ListResources(context.Background(), nil)
	require.NoError(t, err)
	require.Len(t, resources.Resources, 1)
	require.Equal(t, "file:///alpha/readme", resources.Resources[0].URI)

	read, err := session.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: "file:///alpha/readme"})
	require.NoError(t, err)
	require.Len(t, read.Contents, 1)
	require.Equal(t, "readme body", read.Contents[0].Text)
}

func TestUpstreamToolErrorRelaysVerbatim(t *testing.T) {
	url := newTestUpstream(t, "alpha", func(server *mcp.Server) {
		server.AddTool(&mcp.Tool{
			Name:        "fail",
			InputSchema: map[string]any{"type": "object"},
		}, func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
			return &mcp.CallToolResult{
				IsError: true,
				Content: []mcp.Content{&mcp.TextContent{Text: "tool blew up"}},
			}, nil
		})
	})
	_, gatewayURL := newTestService(t, nil, testSpec("alpha", url, nil))

	session := connectClient(t, gatewayURL+"/mcp", nil)
	result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "alpha_fail"})
	require.NoError(t, err)
	require.True(t, result.IsError)
}

func TestDisabledServerIsInvisible(t *testing.T) {
	alphaURL := newTestUpstream(t, "alpha", addEchoTool("echo"))
	betaURL := newTestUpstream(t, "beta", addEchoTool("search"))
	_, gatewayURL := newTestService(t, nil,
		testSpec("alpha", alphaURL, nil),
		testSpec("beta", betaURL, func(spec *ServerSpec) { spec.Enabled = false }),
	)

	session := connectClient(t, gatewayURL+"/mcp", nil)
	names := listToolNames(t, session)
	require.Len(t, names, 1)
	require.Equal(t, "alpha_echo", names[0])
}