internal/mcpgateway/service_test.go
Rama main · réplica de lectura del repositorio original.
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])
}