Skip to content

Commit ff6a1ab

Browse files
authored
Add integration tests for vMCP server (#2624)
* 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. * 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.
1 parent bb76dc3 commit ff6a1ab

File tree

4 files changed

+905
-0
lines changed

4 files changed

+905
-0
lines changed
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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

Comments
 (0)