Skip to content

Commit c6612a8

Browse files
committed
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.
1 parent 8ce0337 commit c6612a8

File tree

4 files changed

+901
-0
lines changed

4 files changed

+901
-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)