From 6291c1de5f0032a9891585358be5a708fba25a1c Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 17 Nov 2025 15:18:34 +0000 Subject: [PATCH 1/2] Add integration tests for vMCP server The tests use real MCP backend servers created with mark3labs/mcp-go SDK to ensure authentic protocol behavior. Test helpers in test/integration/vmcp/helpers/ provide reusable utilities for creating vMCP servers, MCP clients, backend servers, and common test assertions. --- test/integration/vmcp/helpers/backend.go | 240 ++++++++++++++++++ test/integration/vmcp/helpers/mcp_client.go | 235 +++++++++++++++++ test/integration/vmcp/helpers/vmcp_server.go | 206 +++++++++++++++ .../integration/vmcp/vmcp_integration_test.go | 220 ++++++++++++++++ 4 files changed, 901 insertions(+) create mode 100644 test/integration/vmcp/helpers/backend.go create mode 100644 test/integration/vmcp/helpers/mcp_client.go create mode 100644 test/integration/vmcp/helpers/vmcp_server.go create mode 100644 test/integration/vmcp/vmcp_integration_test.go 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..3883ce058 --- /dev/null +++ b/test/integration/vmcp/helpers/vmcp_server.go @@ -0,0 +1,206 @@ +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 && ctx.Err() == nil { + // Only report error if context wasn't cancelled + 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") + }) +} From f3ad1ac0cc2ea3d43d6fa0f916aa5d3d86a6066b Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Tue, 18 Nov 2025 12:04:37 +0000 Subject: [PATCH 2/2] Fix race condition in context cancellation check Use select statement instead of ctx.Err() check to avoid race condition when checking for context cancellation in the goroutine error handler. This follows Go best practices for context handling. Addresses review comment from @yrobla. --- test/integration/vmcp/helpers/vmcp_server.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/integration/vmcp/helpers/vmcp_server.go b/test/integration/vmcp/helpers/vmcp_server.go index 3883ce058..b1be8b014 100644 --- a/test/integration/vmcp/helpers/vmcp_server.go +++ b/test/integration/vmcp/helpers/vmcp_server.go @@ -188,9 +188,13 @@ func NewVMCPServer( // Start server automatically // Use the passed-in context to ensure proper cancellation propagation go func() { - if err := vmcpServer.Start(ctx); err != nil && ctx.Err() == nil { - // Only report error if context wasn't cancelled - tb.Errorf("vMCP server error: %v", err) + if err := vmcpServer.Start(ctx); err != nil { + select { + case <-ctx.Done(): + // Context cancelled, ignore error + default: + tb.Errorf("vMCP server error: %v", err) + } } }()