diff --git a/test/integration/vmcp/helpers/backend.go b/test/integration/vmcp/helpers/backend.go new file mode 100644 index 000000000..3bcfa2a4a --- /dev/null +++ b/test/integration/vmcp/helpers/backend.go @@ -0,0 +1,240 @@ +// Package helpers provides test utilities for vMCP integration tests. +package helpers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// BackendTool defines a tool for MCP backend servers. +// It provides a simplified interface for creating tools with handlers in tests. +// +// The handler function receives a context and arguments map, and returns a string +// result. The result should typically be valid JSON matching the tool's output schema. +type BackendTool struct { + // Name is the unique identifier for the tool + Name string + + // Description explains what the tool does + Description string + + // InputSchema defines the expected input structure using JSON Schema. + // The schema validates the arguments passed to the tool. + InputSchema mcp.ToolInputSchema + + // Handler processes tool calls and returns results. + // The handler receives the tool arguments as a map and should return + // a string representation of the result (typically JSON). + Handler func(ctx context.Context, args map[string]any) string +} + +// NewBackendTool creates a new BackendTool with sensible defaults. +// The default InputSchema is an empty object schema that accepts any properties. +// +// Example: +// +// tool := testkit.NewBackendTool( +// "create_issue", +// "Create a GitHub issue", +// func(ctx context.Context, args map[string]any) string { +// title := args["title"].(string) +// return fmt.Sprintf(`{"issue_id": 123, "title": %q}`, title) +// }, +// ) +func NewBackendTool(name, description string, handler func(ctx context.Context, args map[string]any) string) BackendTool { + return BackendTool{ + Name: name, + Description: description, + InputSchema: mcp.ToolInputSchema{ + Type: "object", + Properties: map[string]any{}, + }, + Handler: handler, + } +} + +// contextKey is a private type for context keys to avoid collisions. +type contextKey string + +// httpHeadersContextKey is the context key for storing HTTP headers. +const httpHeadersContextKey contextKey = "http-headers" + +// GetHTTPHeadersFromContext retrieves HTTP headers from the context. +// Returns nil if headers are not present in the context. +func GetHTTPHeadersFromContext(ctx context.Context) http.Header { + headers, _ := ctx.Value(httpHeadersContextKey).(http.Header) + return headers +} + +// BackendServerOption is a functional option for configuring a backend server. +type BackendServerOption func(*backendServerConfig) + +// backendServerConfig holds configuration for creating a backend server. +type backendServerConfig struct { + serverName string + serverVersion string + endpointPath string + withTools bool + withResources bool + withPrompts bool + captureHeaders bool + httpContextFunc server.HTTPContextFunc +} + +// WithBackendName sets the backend server name. +// This name is reported in the server's initialize response. +// +// Default: "test-backend" +func WithBackendName(name string) BackendServerOption { + return func(c *backendServerConfig) { + c.serverName = name + } +} + +// WithCaptureHeaders enables capturing HTTP request headers in the context. +// When enabled, tool handlers can access request headers via GetHTTPHeadersFromContext(ctx). +// This is useful for testing authentication header injection. +// +// Default: false +func WithCaptureHeaders() BackendServerOption { + return func(c *backendServerConfig) { + c.captureHeaders = true + } +} + +// CreateBackendServer creates an MCP backend server using the mark3labs/mcp-go SDK. +// It returns an *httptest.Server ready to accept streamable-HTTP connections. +// +// The server automatically registers all provided tools with proper closure handling +// to avoid common Go loop variable capture bugs. Each tool's handler is invoked when +// the tool is called via the MCP protocol. +// +// The server uses the streamable-HTTP transport, which is compatible with ToolHive's +// vMCP server and supports both streaming and non-streaming requests. +// +// The returned httptest.Server should be closed after use with defer server.Close(). +// +// Example: +// +// // Create a simple echo tool +// echoTool := testkit.NewBackendTool( +// "echo", +// "Echo back the input message", +// func(ctx context.Context, args map[string]any) string { +// msg := args["message"].(string) +// return fmt.Sprintf(`{"echoed": %q}`, msg) +// }, +// ) +// +// // Start backend server +// backend := testkit.CreateBackendServer(t, []BackendTool{echoTool}, +// testkit.WithBackendName("echo-server"), +// testkit.WithBackendEndpoint("/mcp"), +// ) +// defer backend.Close() +// +// // Use backend URL to connect MCP client +// client := testkit.NewMCPClient(ctx, t, backend.URL+"/mcp") +// defer client.Close() +func CreateBackendServer(tb testing.TB, tools []BackendTool, opts ...BackendServerOption) *httptest.Server { + tb.Helper() + + // Apply default configuration + config := &backendServerConfig{ + serverName: "test-backend", + serverVersion: "1.0.0", + endpointPath: "/mcp", + withTools: true, + withResources: false, + withPrompts: false, + captureHeaders: false, + httpContextFunc: nil, + } + + // Apply functional options + for _, opt := range opts { + opt(config) + } + + // If captureHeaders is enabled and no custom httpContextFunc is set, use default header capture + if config.captureHeaders && config.httpContextFunc == nil { + config.httpContextFunc = func(ctx context.Context, r *http.Request) context.Context { + // Clone headers to avoid concurrent map access issues + headers := make(http.Header, len(r.Header)) + for k, v := range r.Header { + headers[k] = v + } + return context.WithValue(ctx, httpHeadersContextKey, headers) + } + } + + // Create MCP server with configured capabilities + mcpServer := server.NewMCPServer( + config.serverName, + config.serverVersion, + server.WithToolCapabilities(config.withTools), + server.WithResourceCapabilities(config.withResources, config.withResources), + server.WithPromptCapabilities(config.withPrompts), + ) + + // Register tools with proper closure handling to avoid loop variable capture + for i := range tools { + tool := tools[i] // Capture loop variable for closure + mcpServer.AddTool( + mcp.Tool{ + Name: tool.Name, + Description: tool.Description, + InputSchema: tool.InputSchema, + }, + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Extract arguments from request, defaulting to empty map + args, ok := req.Params.Arguments.(map[string]any) + if !ok { + args = make(map[string]any) + } + + // Invoke the tool handler + result := tool.Handler(ctx, args) + + // Return successful result with text content + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent(result), + }, + }, nil + }, + ) + } + + // Create streamable HTTP server with configured endpoint + streamableOpts := []server.StreamableHTTPOption{ + server.WithEndpointPath(config.endpointPath), + } + + // Add HTTP context function if configured + if config.httpContextFunc != nil { + streamableOpts = append(streamableOpts, server.WithHTTPContextFunc(config.httpContextFunc)) + } + + streamableServer := server.NewStreamableHTTPServer( + mcpServer, + streamableOpts..., + ) + + // Start HTTP test server + httpServer := httptest.NewServer(streamableServer) + + tb.Logf("Created MCP backend server %q (v%s) at %s%s", + config.serverName, + config.serverVersion, + httpServer.URL, + config.endpointPath, + ) + + return httpServer +} diff --git a/test/integration/vmcp/helpers/mcp_client.go b/test/integration/vmcp/helpers/mcp_client.go new file mode 100644 index 000000000..10a5a54b4 --- /dev/null +++ b/test/integration/vmcp/helpers/mcp_client.go @@ -0,0 +1,235 @@ +package helpers + +import ( + "context" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// MCPClient wraps the mark3labs MCP client with test-friendly methods. +// It automatically handles initialization and provides semantic assertion helpers +// that integrate with Go's testing.TB interface. +// +// Example usage: +// +// ctx := context.Background() +// mcpClient := helpers.NewMCPClient(ctx, t, serverURL) +// defer mcpClient.Close() +// +// tools := mcpClient.ListTools(ctx) +// toolNames := helpers.GetToolNames(tools) +// assert.Contains(t, toolNames, "create_issue") +type MCPClient struct { + client *client.Client + tb testing.TB +} + +// MCPClientOption is a functional option for configuring an MCPClient. +type MCPClientOption func(*mcpClientConfig) + +// mcpClientConfig holds configuration for creating an MCP client. +type mcpClientConfig struct { + clientName string + clientVersion string +} + +// NewMCPClient creates and initializes a new MCP client for testing. +// It automatically starts the transport and performs the MCP handshake. +// +// The client is configured with sensible defaults suitable for testing: +// - Protocol version: Latest (mcp.LATEST_PROTOCOL_VERSION) +// - Client name: "testkit-client" +// - Client version: "1.0.0" +// - Transport: streamable-http (vMCP only supports streamable-http) +// +// The function fails the test immediately if initialization fails. +// +// Example: +// +// client := helpers.NewMCPClient(ctx, t, "http://localhost:8080/mcp") +// defer client.Close() +// +// tools := client.ListTools(ctx) +// assert.NotEmpty(t, helpers.GetToolNames(tools)) +func NewMCPClient(ctx context.Context, tb testing.TB, serverURL string, opts ...MCPClientOption) *MCPClient { + tb.Helper() + + // Default configuration + config := &mcpClientConfig{ + clientName: "testkit-client", + clientVersion: "1.0.0", + } + + // Apply options + for _, opt := range opts { + opt(config) + } + + // Create streamable-http client (vMCP only supports streamable-http) + mcpClient, err := client.NewStreamableHttpClient(serverURL) + require.NoError(tb, err, "failed to create MCP client with streamable-http transport") + + // Start the transport + err = mcpClient.Start(ctx) + require.NoError(tb, err, "failed to start MCP transport") + + // Initialize the MCP session + initRequest := mcp.InitializeRequest{} + initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initRequest.Params.Capabilities = mcp.ClientCapabilities{} + initRequest.Params.ClientInfo = mcp.Implementation{ + Name: config.clientName, + Version: config.clientVersion, + } + + _, err = mcpClient.Initialize(ctx, initRequest) + require.NoError(tb, err, "failed to initialize MCP session") + + tb.Logf("MCP client initialized successfully: name=%s, version=%s, transport=streamable-http, url=%s", + config.clientName, config.clientVersion, serverURL) + + return &MCPClient{ + client: mcpClient, + tb: tb, + } +} + +// Close closes the MCP client connection. +// This should typically be deferred immediately after client creation. +func (c *MCPClient) Close() error { + c.tb.Helper() + return c.client.Close() +} + +// ListTools lists all available tools from the MCP server. +// The method logs the operation and fails the test if the request fails. +// +// Example: +// +// tools := client.ListTools(ctx) +// toolNames := helpers.GetToolNames(tools) +// assert.Contains(t, toolNames, "expected_tool") +func (c *MCPClient) ListTools(ctx context.Context) *mcp.ListToolsResult { + c.tb.Helper() + + request := mcp.ListToolsRequest{} + result, err := c.client.ListTools(ctx, request) + require.NoError(c.tb, err, "failed to list tools") + + c.tb.Logf("Listed %d tools from MCP server", len(result.Tools)) + return result +} + +// CallTool calls the specified tool with the given arguments. +// The method logs the operation and fails the test if the request fails. +// +// Example: +// +// result := client.CallTool(ctx, "create_issue", map[string]any{ +// "title": "Bug report", +// "body": "Description", +// }) +// text := helpers.AssertToolCallSuccess(t, result) +// assert.Contains(t, text, "issue_id") +func (c *MCPClient) CallTool(ctx context.Context, name string, args map[string]any) *mcp.CallToolResult { + c.tb.Helper() + + request := mcp.CallToolRequest{} + request.Params.Name = name + request.Params.Arguments = args + + result, err := c.client.CallTool(ctx, request) + require.NoError(c.tb, err, "failed to call tool %q", name) + + c.tb.Logf("Called tool %q with %d arguments", name, len(args)) + return result +} + +// GetToolNames extracts tool names from a ListToolsResult. +// This is a convenience function for common test assertions. +// +// Example: +// +// tools := client.ListTools(ctx) +// names := helpers.GetToolNames(tools) +// assert.ElementsMatch(t, []string{"tool1", "tool2"}, names) +func GetToolNames(result *mcp.ListToolsResult) []string { + names := make([]string, 0, len(result.Tools)) + for _, tool := range result.Tools { + names = append(names, tool.Name) + } + return names +} + +// AssertToolCallSuccess asserts that a tool call succeeded (IsError=false) +// and returns the concatenated text content from all content items. +// +// The function uses require assertions, so it will fail the test immediately +// if the tool call was an error. +// +// Example: +// +// result := client.CallTool(ctx, "get_user", map[string]any{"id": 123}) +// text := helpers.AssertToolCallSuccess(t, result) +// assert.Contains(t, text, "username") +func AssertToolCallSuccess(tb testing.TB, result *mcp.CallToolResult) string { + tb.Helper() + + require.NotNil(tb, result, "tool call result should not be nil") + require.False(tb, result.IsError, "tool call should not return an error, got: %v", result.Content) + + var textParts []string + for _, content := range result.Content { + if textContent, ok := mcp.AsTextContent(content); ok { + textParts = append(textParts, textContent.Text) + } + } + + text := strings.Join(textParts, "\n") + tb.Logf("Tool call succeeded with %d content items, total text length: %d", len(result.Content), len(text)) + + return text +} + +// AssertTextContains asserts that text contains all expected substrings. +// This is a variadic helper for checking multiple content expectations in tool results. +// +// The function uses assert (not require), so multiple failures can be reported together. +// +// Example: +// +// text := helpers.AssertToolCallSuccess(t, result) +// helpers.AssertTextContains(t, text, "user_id", "username", "email") +func AssertTextContains(tb testing.TB, text string, expected ...string) { + tb.Helper() + + for _, exp := range expected { + if !assert.Contains(tb, text, exp) { + tb.Logf("Expected substring %q not found in text (length: %d)", exp, len(text)) + } + } +} + +// AssertTextNotContains asserts that text does not contain any of the forbidden substrings. +// This is a variadic helper for checking that certain content is absent from tool results. +// +// The function uses assert (not require), so multiple failures can be reported together. +// +// Example: +// +// text := helpers.AssertToolCallSuccess(t, result) +// helpers.AssertTextNotContains(t, text, "password", "secret", "api_key") +func AssertTextNotContains(tb testing.TB, text string, forbidden ...string) { + tb.Helper() + + for _, forb := range forbidden { + if !assert.NotContains(tb, text, forb) { + tb.Logf("Forbidden substring %q found in text (length: %d)", forb, len(text)) + } + } +} diff --git a/test/integration/vmcp/helpers/vmcp_server.go b/test/integration/vmcp/helpers/vmcp_server.go new file mode 100644 index 000000000..b1be8b014 --- /dev/null +++ b/test/integration/vmcp/helpers/vmcp_server.go @@ -0,0 +1,210 @@ +package helpers + +import ( + "context" + "net" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/env" + vmcptypes "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/pkg/vmcp/aggregator" + vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth" + "github.com/stacklok/toolhive/pkg/vmcp/auth/factory" + "github.com/stacklok/toolhive/pkg/vmcp/auth/strategies" + vmcpclient "github.com/stacklok/toolhive/pkg/vmcp/client" + "github.com/stacklok/toolhive/pkg/vmcp/discovery" + "github.com/stacklok/toolhive/pkg/vmcp/router" + vmcpserver "github.com/stacklok/toolhive/pkg/vmcp/server" +) + +// NewBackend creates a test backend with sensible defaults. +// Use functional options to customize. +func NewBackend(id string, opts ...func(*vmcptypes.Backend)) vmcptypes.Backend { + b := vmcptypes.Backend{ + ID: id, + Name: id, + BaseURL: "http://localhost:8080/mcp", + TransportType: "streamable-http", + HealthStatus: vmcptypes.BackendHealthy, + Metadata: make(map[string]string), + AuthMetadata: make(map[string]any), + } + for _, opt := range opts { + opt(&b) + } + return b +} + +// WithURL sets the backend URL. +func WithURL(url string) func(*vmcptypes.Backend) { + return func(b *vmcptypes.Backend) { + b.BaseURL = url + } +} + +// WithAuth configures authentication. +func WithAuth(strategy string, metadata map[string]any) func(*vmcptypes.Backend) { + return func(b *vmcptypes.Backend) { + b.AuthStrategy = strategy + b.AuthMetadata = metadata + } +} + +// WithMetadata adds a metadata key-value pair. +func WithMetadata(key, value string) func(*vmcptypes.Backend) { + return func(b *vmcptypes.Backend) { + b.Metadata[key] = value + } +} + +// VMCPServerOption is a functional option for configuring a vMCP test server. +type VMCPServerOption func(*vmcpServerConfig) + +// vmcpServerConfig holds configuration for creating a test vMCP server. +type vmcpServerConfig struct { + conflictStrategy string + prefixFormat string +} + +// WithPrefixConflictResolution configures prefix-based conflict resolution. +func WithPrefixConflictResolution(format string) VMCPServerOption { + return func(c *vmcpServerConfig) { + c.conflictStrategy = "prefix" + c.prefixFormat = format + } +} + +// getFreePort returns an available TCP port on localhost. +// This is used for parallel test execution to avoid port conflicts. +func getFreePort(tb testing.TB) int { + tb.Helper() + + // Listen on port 0 to get a random available port + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(tb, err, "failed to get free port") + defer listener.Close() + + // Extract the port number from the listener's address + addr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + tb.Fatalf("failed to get TCP address from listener") + } + return addr.Port +} + +// NewVMCPServer creates a vMCP server for testing with sensible defaults. +// The server is automatically started and will be ready when this function returns. +// Use functional options to customize behavior. +// +// Example: +// +// server := testkit.NewVMCPServer(ctx, t, backends, +// testkit.WithPrefixConflictResolution("{workload}_"), +// ) +// defer server.Shutdown(ctx) +func NewVMCPServer( + ctx context.Context, tb testing.TB, backends []vmcptypes.Backend, opts ...VMCPServerOption, +) *vmcpserver.Server { + tb.Helper() + + // Default configuration + config := &vmcpServerConfig{ + conflictStrategy: "prefix", + prefixFormat: "{workload}_", + } + + // Apply options + for _, opt := range opts { + opt(config) + } + + // Create outgoing auth registry and register strategies used by backends + outgoingRegistry, err := factory.NewOutgoingAuthRegistry(ctx, nil, &env.OSReader{}) + require.NoError(tb, err) + + // Scan backends to determine which strategies need to be registered + // This is needed because we pass nil config to NewOutgoingAuthRegistry (which only registers unauthenticated) + // but backends may use other strategies like header_injection + strategyTypes := make(map[string]struct{}) + for _, backend := range backends { + if backend.AuthStrategy != "" && backend.AuthStrategy != "unauthenticated" { + strategyTypes[backend.AuthStrategy] = struct{}{} + } + } + + // Register additional strategies found in backends + for strategyType := range strategyTypes { + var strategy vmcpauth.Strategy + switch strategyType { + case strategies.StrategyTypeHeaderInjection: + strategy = strategies.NewHeaderInjectionStrategy() + case strategies.StrategyTypeTokenExchange: + strategy = strategies.NewTokenExchangeStrategy(&env.OSReader{}) + default: + tb.Fatalf("unknown auth strategy type: %s", strategyType) + } + + err = outgoingRegistry.RegisterStrategy(strategyType, strategy) + require.NoError(tb, err, "failed to register strategy %s", strategyType) + } + + // Create backend client + backendClient, err := vmcpclient.NewHTTPBackendClient(outgoingRegistry) + require.NoError(tb, err) + + // Create conflict resolver based on strategy + var conflictResolver aggregator.ConflictResolver + switch config.conflictStrategy { + case "prefix": + conflictResolver = aggregator.NewPrefixConflictResolver(config.prefixFormat) + default: + conflictResolver = aggregator.NewPrefixConflictResolver(config.prefixFormat) + } + + // Create aggregator + agg := aggregator.NewDefaultAggregator(backendClient, conflictResolver, nil) + + // Create discovery manager + discoveryMgr, err := discovery.NewManager(agg) + require.NoError(tb, err) + + // Create router + rtr := router.NewDefaultRouter() + + // Create vMCP server with test-specific defaults + vmcpServer, err := vmcpserver.New(&vmcpserver.Config{ + Name: "test-vmcp", + Version: "1.0.0", + Host: "127.0.0.1", + Port: getFreePort(tb), // Get a random available port for parallel test execution + AuthMiddleware: auth.AnonymousMiddleware, + }, rtr, backendClient, discoveryMgr, backends, nil) // nil for workflowDefs in tests + require.NoError(tb, err, "failed to create vMCP server") + + // Start server automatically + // Use the passed-in context to ensure proper cancellation propagation + go func() { + if err := vmcpServer.Start(ctx); err != nil { + select { + case <-ctx.Done(): + // Context cancelled, ignore error + default: + tb.Errorf("vMCP server error: %v", err) + } + } + }() + + // Wait for server to be ready (with 5 second timeout) + select { + case <-vmcpServer.Ready(): + tb.Logf("vMCP server ready at: http://%s/mcp", vmcpServer.Address()) + case <-time.After(5 * time.Second): + tb.Fatal("vMCP server failed to start within 5 seconds") + } + + return vmcpServer +} diff --git a/test/integration/vmcp/vmcp_integration_test.go b/test/integration/vmcp/vmcp_integration_test.go new file mode 100644 index 000000000..d29a1dffc --- /dev/null +++ b/test/integration/vmcp/vmcp_integration_test.go @@ -0,0 +1,220 @@ +package vmcp_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/stacklok/toolhive/pkg/vmcp" + "github.com/stacklok/toolhive/test/integration/vmcp/helpers" +) + +// TestVMCPServer_ConflictResolution verifies that vMCP correctly resolves +// tool name conflicts between backends using prefix-based conflict resolution. +// Subtests share a common vMCP server and client instance for efficiency. +// +//nolint:paralleltest,tparallel // Subtests intentionally sequential - share expensive test fixtures +func TestVMCPServer_ConflictResolution(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // Setup: Create synthetic MCP backend servers + githubServer := helpers.CreateBackendServer(t, []helpers.BackendTool{ + helpers.NewBackendTool("create_issue", "Create a GitHub issue", + func(_ context.Context, _ map[string]any) string { + return `{"source": "github", "issue_id": 123, "url": "https://github.com/org/repo/issues/123"}` + }), + helpers.NewBackendTool("list_repos", "List GitHub repositories", + func(_ context.Context, _ map[string]any) string { + return `{"source": "github", "repos": ["repo1", "repo2", "repo3"]}` + }), + }, helpers.WithBackendName("github-mcp")) + defer githubServer.Close() + + jiraServer := helpers.CreateBackendServer(t, []helpers.BackendTool{ + helpers.NewBackendTool("create_issue", "Create a Jira issue", + func(_ context.Context, _ map[string]any) string { + return `{"source": "jira", "issue_key": "PROJ-456", "url": "https://jira.example.com/browse/PROJ-456"}` + }), + }, helpers.WithBackendName("jira-mcp")) + defer jiraServer.Close() + + // Configure backends pointing to test servers + backends := []vmcp.Backend{ + helpers.NewBackend("github", + helpers.WithURL(githubServer.URL+"/mcp"), + helpers.WithMetadata("group", "test-group"), + ), + helpers.NewBackend("jira", + helpers.WithURL(jiraServer.URL+"/mcp"), + helpers.WithMetadata("group", "test-group"), + ), + } + + // Create vMCP server with prefix conflict resolution + vmcpServer := helpers.NewVMCPServer(ctx, t, backends, + helpers.WithPrefixConflictResolution("{workload}_"), + ) + + // Create and initialize MCP client + vmcpURL := "http://" + vmcpServer.Address() + "/mcp" + client := helpers.NewMCPClient(ctx, t, vmcpURL) + defer client.Close() + + // Run subtests + t.Run("ListTools", func(t *testing.T) { + toolsResp := client.ListTools(ctx) + toolNames := helpers.GetToolNames(toolsResp) + + assert.Len(t, toolNames, 3, "Should have 3 tools after prefix conflict resolution") + assert.Contains(t, toolNames, "github_create_issue") + assert.Contains(t, toolNames, "github_list_repos") + assert.Contains(t, toolNames, "jira_create_issue") + }) + + t.Run("CallGitHubCreateIssue", func(t *testing.T) { + resp := client.CallTool(ctx, "github_create_issue", map[string]any{"title": "Test Issue"}) + text := helpers.AssertToolCallSuccess(t, resp) + helpers.AssertTextContains(t, text, "github", "issue_id") + }) + + t.Run("CallJiraCreateIssue", func(t *testing.T) { + resp := client.CallTool(ctx, "jira_create_issue", map[string]any{"summary": "Test Ticket"}) + text := helpers.AssertToolCallSuccess(t, resp) + helpers.AssertTextContains(t, text, "jira", "issue_key") + }) + + t.Run("CallGitHubListRepos", func(t *testing.T) { + resp := client.CallTool(ctx, "github_list_repos", map[string]any{}) + text := helpers.AssertToolCallSuccess(t, resp) + helpers.AssertTextContains(t, text, "repos") + }) +} + +// TestVMCPServer_TwoBoundaryAuth_HeaderInjection verifies the two-boundary authentication +// model where vMCP injects different auth headers to different backends, and ensures no +// credential leakage occurs between backends. +// Subtests share a common vMCP server and client instance for efficiency. +// +//nolint:paralleltest,tparallel // Subtests intentionally sequential - share expensive test fixtures +func TestVMCPServer_TwoBoundaryAuth_HeaderInjection(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // Setup: Create backend servers that verify auth headers + // GitLab backend expects X-GitLab-Token header + gitlabServer := helpers.CreateBackendServer(t, []helpers.BackendTool{ + helpers.NewBackendTool("list_projects", "List GitLab projects", + func(ctx context.Context, _ map[string]any) string { + // Verify the correct auth header was injected by vMCP + headers := helpers.GetHTTPHeadersFromContext(ctx) + if headers == nil { + return `{"error": "no headers in context"}` + } + + gitlabToken := headers.Get("X-Gitlab-Token") + if gitlabToken == "" { + return `{"error": "missing X-GitLab-Token header", "auth": "failed"}` + } + + if gitlabToken != "secret-123" { + return `{"error": "invalid X-GitLab-Token header", "auth": "failed", "received": "` + gitlabToken + `"}` + } + + // Auth successful + return `{"source": "gitlab", "projects": ["project1", "project2"], "auth": "success"}` + }), + }, + helpers.WithBackendName("gitlab-mcp"), + helpers.WithCaptureHeaders(), // Enable header capture + ) + defer gitlabServer.Close() + + // GitHub backend expects Authorization header + githubServer := helpers.CreateBackendServer(t, []helpers.BackendTool{ + helpers.NewBackendTool("list_repos", "List GitHub repositories", + func(ctx context.Context, _ map[string]any) string { + // Verify the correct auth header was injected by vMCP + headers := helpers.GetHTTPHeadersFromContext(ctx) + if headers == nil { + return `{"error": "no headers in context"}` + } + + authHeader := headers.Get("Authorization") + if authHeader == "" { + return `{"error": "missing Authorization header", "auth": "failed"}` + } + + if authHeader != "Bearer token-456" { + return `{"error": "invalid Authorization header", "auth": "failed", "received": "` + authHeader + `"}` + } + + // Auth successful - also verify GitLab token was NOT leaked + gitlabToken := headers.Get("X-Gitlab-Token") + if gitlabToken != "" { + return `{"error": "credential leakage detected", "auth": "failed", "leaked": "X-GitLab-Token"}` + } + + return `{"source": "github", "repos": ["repo1", "repo2", "repo3"], "auth": "success"}` + }), + }, + helpers.WithBackendName("github-mcp"), + helpers.WithCaptureHeaders(), // Enable header capture + ) + defer githubServer.Close() + + // Configure backends with header_injection auth + backends := []vmcp.Backend{ + helpers.NewBackend("gitlab", + helpers.WithURL(gitlabServer.URL+"/mcp"), + helpers.WithAuth("header_injection", map[string]any{ + "header_name": "X-GitLab-Token", + "header_value": "secret-123", + }), + ), + helpers.NewBackend("github", + helpers.WithURL(githubServer.URL+"/mcp"), + helpers.WithAuth("header_injection", map[string]any{ + "header_name": "Authorization", + "header_value": "Bearer token-456", + }), + ), + } + + // Create vMCP server (uses anonymous incoming auth by default) + vmcpServer := helpers.NewVMCPServer(ctx, t, backends, + helpers.WithPrefixConflictResolution("{workload}_"), + ) + + // Create and initialize MCP client + vmcpURL := "http://" + vmcpServer.Address() + "/mcp" + client := helpers.NewMCPClient(ctx, t, vmcpURL) + defer client.Close() + + // Run subtests + t.Run("ListTools", func(t *testing.T) { + toolsResp := client.ListTools(ctx) + toolNames := helpers.GetToolNames(toolsResp) + + assert.Len(t, toolNames, 2, "Should have 2 tools from both backends") + assert.Contains(t, toolNames, "gitlab_list_projects") + assert.Contains(t, toolNames, "github_list_repos") + }) + + t.Run("CallGitLabListProjects", func(t *testing.T) { + resp := client.CallTool(ctx, "gitlab_list_projects", map[string]any{}) + text := helpers.AssertToolCallSuccess(t, resp) + helpers.AssertTextContains(t, text, "gitlab", "projects", "auth", "success") + helpers.AssertTextNotContains(t, text, "error", "failed") + }) + + t.Run("CallGitHubListRepos", func(t *testing.T) { + resp := client.CallTool(ctx, "github_list_repos", map[string]any{}) + text := helpers.AssertToolCallSuccess(t, resp) + helpers.AssertTextContains(t, text, "github", "repos", "auth", "success") + helpers.AssertTextNotContains(t, text, "error", "failed", "leakage") + }) +}