|
| 1 | +// Package helpers provides test utilities for vMCP integration tests. |
| 2 | +package helpers |
| 3 | + |
| 4 | +import ( |
| 5 | + "context" |
| 6 | + "net/http" |
| 7 | + "net/http/httptest" |
| 8 | + "testing" |
| 9 | + |
| 10 | + "github.com/mark3labs/mcp-go/mcp" |
| 11 | + "github.com/mark3labs/mcp-go/server" |
| 12 | +) |
| 13 | + |
| 14 | +// BackendTool defines a tool for MCP backend servers. |
| 15 | +// It provides a simplified interface for creating tools with handlers in tests. |
| 16 | +// |
| 17 | +// The handler function receives a context and arguments map, and returns a string |
| 18 | +// result. The result should typically be valid JSON matching the tool's output schema. |
| 19 | +type BackendTool struct { |
| 20 | + // Name is the unique identifier for the tool |
| 21 | + Name string |
| 22 | + |
| 23 | + // Description explains what the tool does |
| 24 | + Description string |
| 25 | + |
| 26 | + // InputSchema defines the expected input structure using JSON Schema. |
| 27 | + // The schema validates the arguments passed to the tool. |
| 28 | + InputSchema mcp.ToolInputSchema |
| 29 | + |
| 30 | + // Handler processes tool calls and returns results. |
| 31 | + // The handler receives the tool arguments as a map and should return |
| 32 | + // a string representation of the result (typically JSON). |
| 33 | + Handler func(ctx context.Context, args map[string]any) string |
| 34 | +} |
| 35 | + |
| 36 | +// NewBackendTool creates a new BackendTool with sensible defaults. |
| 37 | +// The default InputSchema is an empty object schema that accepts any properties. |
| 38 | +// |
| 39 | +// Example: |
| 40 | +// |
| 41 | +// tool := testkit.NewBackendTool( |
| 42 | +// "create_issue", |
| 43 | +// "Create a GitHub issue", |
| 44 | +// func(ctx context.Context, args map[string]any) string { |
| 45 | +// title := args["title"].(string) |
| 46 | +// return fmt.Sprintf(`{"issue_id": 123, "title": %q}`, title) |
| 47 | +// }, |
| 48 | +// ) |
| 49 | +func NewBackendTool(name, description string, handler func(ctx context.Context, args map[string]any) string) BackendTool { |
| 50 | + return BackendTool{ |
| 51 | + Name: name, |
| 52 | + Description: description, |
| 53 | + InputSchema: mcp.ToolInputSchema{ |
| 54 | + Type: "object", |
| 55 | + Properties: map[string]any{}, |
| 56 | + }, |
| 57 | + Handler: handler, |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +// contextKey is a private type for context keys to avoid collisions. |
| 62 | +type contextKey string |
| 63 | + |
| 64 | +// httpHeadersContextKey is the context key for storing HTTP headers. |
| 65 | +const httpHeadersContextKey contextKey = "http-headers" |
| 66 | + |
| 67 | +// GetHTTPHeadersFromContext retrieves HTTP headers from the context. |
| 68 | +// Returns nil if headers are not present in the context. |
| 69 | +func GetHTTPHeadersFromContext(ctx context.Context) http.Header { |
| 70 | + headers, _ := ctx.Value(httpHeadersContextKey).(http.Header) |
| 71 | + return headers |
| 72 | +} |
| 73 | + |
| 74 | +// BackendServerOption is a functional option for configuring a backend server. |
| 75 | +type BackendServerOption func(*backendServerConfig) |
| 76 | + |
| 77 | +// backendServerConfig holds configuration for creating a backend server. |
| 78 | +type backendServerConfig struct { |
| 79 | + serverName string |
| 80 | + serverVersion string |
| 81 | + endpointPath string |
| 82 | + withTools bool |
| 83 | + withResources bool |
| 84 | + withPrompts bool |
| 85 | + captureHeaders bool |
| 86 | + httpContextFunc server.HTTPContextFunc |
| 87 | +} |
| 88 | + |
| 89 | +// WithBackendName sets the backend server name. |
| 90 | +// This name is reported in the server's initialize response. |
| 91 | +// |
| 92 | +// Default: "test-backend" |
| 93 | +func WithBackendName(name string) BackendServerOption { |
| 94 | + return func(c *backendServerConfig) { |
| 95 | + c.serverName = name |
| 96 | + } |
| 97 | +} |
| 98 | + |
| 99 | +// WithCaptureHeaders enables capturing HTTP request headers in the context. |
| 100 | +// When enabled, tool handlers can access request headers via GetHTTPHeadersFromContext(ctx). |
| 101 | +// This is useful for testing authentication header injection. |
| 102 | +// |
| 103 | +// Default: false |
| 104 | +func WithCaptureHeaders() BackendServerOption { |
| 105 | + return func(c *backendServerConfig) { |
| 106 | + c.captureHeaders = true |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +// CreateBackendServer creates an MCP backend server using the mark3labs/mcp-go SDK. |
| 111 | +// It returns an *httptest.Server ready to accept streamable-HTTP connections. |
| 112 | +// |
| 113 | +// The server automatically registers all provided tools with proper closure handling |
| 114 | +// to avoid common Go loop variable capture bugs. Each tool's handler is invoked when |
| 115 | +// the tool is called via the MCP protocol. |
| 116 | +// |
| 117 | +// The server uses the streamable-HTTP transport, which is compatible with ToolHive's |
| 118 | +// vMCP server and supports both streaming and non-streaming requests. |
| 119 | +// |
| 120 | +// The returned httptest.Server should be closed after use with defer server.Close(). |
| 121 | +// |
| 122 | +// Example: |
| 123 | +// |
| 124 | +// // Create a simple echo tool |
| 125 | +// echoTool := testkit.NewBackendTool( |
| 126 | +// "echo", |
| 127 | +// "Echo back the input message", |
| 128 | +// func(ctx context.Context, args map[string]any) string { |
| 129 | +// msg := args["message"].(string) |
| 130 | +// return fmt.Sprintf(`{"echoed": %q}`, msg) |
| 131 | +// }, |
| 132 | +// ) |
| 133 | +// |
| 134 | +// // Start backend server |
| 135 | +// backend := testkit.CreateBackendServer(t, []BackendTool{echoTool}, |
| 136 | +// testkit.WithBackendName("echo-server"), |
| 137 | +// testkit.WithBackendEndpoint("/mcp"), |
| 138 | +// ) |
| 139 | +// defer backend.Close() |
| 140 | +// |
| 141 | +// // Use backend URL to connect MCP client |
| 142 | +// client := testkit.NewMCPClient(ctx, t, backend.URL+"/mcp") |
| 143 | +// defer client.Close() |
| 144 | +func CreateBackendServer(tb testing.TB, tools []BackendTool, opts ...BackendServerOption) *httptest.Server { |
| 145 | + tb.Helper() |
| 146 | + |
| 147 | + // Apply default configuration |
| 148 | + config := &backendServerConfig{ |
| 149 | + serverName: "test-backend", |
| 150 | + serverVersion: "1.0.0", |
| 151 | + endpointPath: "/mcp", |
| 152 | + withTools: true, |
| 153 | + withResources: false, |
| 154 | + withPrompts: false, |
| 155 | + captureHeaders: false, |
| 156 | + httpContextFunc: nil, |
| 157 | + } |
| 158 | + |
| 159 | + // Apply functional options |
| 160 | + for _, opt := range opts { |
| 161 | + opt(config) |
| 162 | + } |
| 163 | + |
| 164 | + // If captureHeaders is enabled and no custom httpContextFunc is set, use default header capture |
| 165 | + if config.captureHeaders && config.httpContextFunc == nil { |
| 166 | + config.httpContextFunc = func(ctx context.Context, r *http.Request) context.Context { |
| 167 | + // Clone headers to avoid concurrent map access issues |
| 168 | + headers := make(http.Header, len(r.Header)) |
| 169 | + for k, v := range r.Header { |
| 170 | + headers[k] = v |
| 171 | + } |
| 172 | + return context.WithValue(ctx, httpHeadersContextKey, headers) |
| 173 | + } |
| 174 | + } |
| 175 | + |
| 176 | + // Create MCP server with configured capabilities |
| 177 | + mcpServer := server.NewMCPServer( |
| 178 | + config.serverName, |
| 179 | + config.serverVersion, |
| 180 | + server.WithToolCapabilities(config.withTools), |
| 181 | + server.WithResourceCapabilities(config.withResources, config.withResources), |
| 182 | + server.WithPromptCapabilities(config.withPrompts), |
| 183 | + ) |
| 184 | + |
| 185 | + // Register tools with proper closure handling to avoid loop variable capture |
| 186 | + for i := range tools { |
| 187 | + tool := tools[i] // Capture loop variable for closure |
| 188 | + mcpServer.AddTool( |
| 189 | + mcp.Tool{ |
| 190 | + Name: tool.Name, |
| 191 | + Description: tool.Description, |
| 192 | + InputSchema: tool.InputSchema, |
| 193 | + }, |
| 194 | + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| 195 | + // Extract arguments from request, defaulting to empty map |
| 196 | + args, ok := req.Params.Arguments.(map[string]any) |
| 197 | + if !ok { |
| 198 | + args = make(map[string]any) |
| 199 | + } |
| 200 | + |
| 201 | + // Invoke the tool handler |
| 202 | + result := tool.Handler(ctx, args) |
| 203 | + |
| 204 | + // Return successful result with text content |
| 205 | + return &mcp.CallToolResult{ |
| 206 | + Content: []mcp.Content{ |
| 207 | + mcp.NewTextContent(result), |
| 208 | + }, |
| 209 | + }, nil |
| 210 | + }, |
| 211 | + ) |
| 212 | + } |
| 213 | + |
| 214 | + // Create streamable HTTP server with configured endpoint |
| 215 | + streamableOpts := []server.StreamableHTTPOption{ |
| 216 | + server.WithEndpointPath(config.endpointPath), |
| 217 | + } |
| 218 | + |
| 219 | + // Add HTTP context function if configured |
| 220 | + if config.httpContextFunc != nil { |
| 221 | + streamableOpts = append(streamableOpts, server.WithHTTPContextFunc(config.httpContextFunc)) |
| 222 | + } |
| 223 | + |
| 224 | + streamableServer := server.NewStreamableHTTPServer( |
| 225 | + mcpServer, |
| 226 | + streamableOpts..., |
| 227 | + ) |
| 228 | + |
| 229 | + // Start HTTP test server |
| 230 | + httpServer := httptest.NewServer(streamableServer) |
| 231 | + |
| 232 | + tb.Logf("Created MCP backend server %q (v%s) at %s%s", |
| 233 | + config.serverName, |
| 234 | + config.serverVersion, |
| 235 | + httpServer.URL, |
| 236 | + config.endpointPath, |
| 237 | + ) |
| 238 | + |
| 239 | + return httpServer |
| 240 | +} |
0 commit comments