Skip to content

Commit 336e84c

Browse files
committed
fix(mcp): add lint suppressions for errcheck in OAuth and MCP tests
Add //nolint:errcheck comments to defer Close() calls and other unchecked error returns in test files to satisfy golangci-lint.
1 parent 6218f6a commit 336e84c

File tree

4 files changed

+615
-12
lines changed

4 files changed

+615
-12
lines changed

router-tests/mcp_auth_e2e_test.go

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
package integration
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
"testing"
9+
10+
"github.com/modelcontextprotocol/go-sdk/mcp"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"github.com/wundergraph/cosmo/router-tests/testenv"
14+
"github.com/wundergraph/cosmo/router-tests/testutil"
15+
"github.com/wundergraph/cosmo/router/pkg/config"
16+
)
17+
18+
// authRoundTripper wraps an http.RoundTripper and adds Authorization headers
19+
// It also captures the last HTTP response for error analysis
20+
type authRoundTripper struct {
21+
base http.RoundTripper
22+
token string
23+
lastResponse *http.Response
24+
}
25+
26+
func (a *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
27+
// Clone the request to avoid modifying the original
28+
req = req.Clone(req.Context())
29+
30+
// Add Authorization header if token is set
31+
if a.token != "" {
32+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.token))
33+
}
34+
35+
resp, err := a.base.RoundTrip(req)
36+
// Capture response for error analysis
37+
a.lastResponse = resp
38+
return resp, err
39+
}
40+
41+
// MCPAuthClient wraps the official MCP client with authorization support
42+
type MCPAuthClient struct {
43+
endpoint string
44+
transport *mcp.StreamableClientTransport
45+
roundTripper *authRoundTripper
46+
client *mcp.Client
47+
session *mcp.ClientSession
48+
}
49+
50+
// AuthError represents an HTTP authentication/authorization error
51+
type AuthError struct {
52+
StatusCode int
53+
ErrorCode string
54+
RequiredScopes []string
55+
ResourceMetadataURL string
56+
ErrorDescription string
57+
}
58+
59+
func (e *AuthError) Error() string {
60+
if e.ErrorCode == "insufficient_scope" {
61+
return fmt.Sprintf("HTTP %d: insufficient scope - required scopes: %v", e.StatusCode, e.RequiredScopes)
62+
}
63+
return fmt.Sprintf("HTTP %d: %s - %s", e.StatusCode, e.ErrorCode, e.ErrorDescription)
64+
}
65+
66+
// NewMCPAuthClient creates a new MCP client with authorization support
67+
func NewMCPAuthClient(endpoint string, initialToken string) *MCPAuthClient {
68+
// Create a custom round tripper that adds Authorization headers
69+
roundTripper := &authRoundTripper{
70+
base: http.DefaultTransport,
71+
token: initialToken,
72+
}
73+
74+
// Create HTTP client with custom round tripper
75+
httpClient := &http.Client{
76+
Transport: roundTripper,
77+
}
78+
79+
// Create streamable transport
80+
transport := &mcp.StreamableClientTransport{
81+
Endpoint: endpoint,
82+
HTTPClient: httpClient,
83+
}
84+
85+
// Create MCP client
86+
client := mcp.NewClient(&mcp.Implementation{
87+
Name: "test-client",
88+
Version: "1.0.0",
89+
}, nil)
90+
91+
return &MCPAuthClient{
92+
endpoint: endpoint,
93+
transport: transport,
94+
roundTripper: roundTripper,
95+
client: client,
96+
}
97+
}
98+
99+
// Connect establishes the MCP connection and initializes the session
100+
func (c *MCPAuthClient) Connect(ctx context.Context) error {
101+
session, err := c.client.Connect(ctx, c.transport, nil)
102+
if err != nil {
103+
return fmt.Errorf("failed to connect: %w", err)
104+
}
105+
c.session = session
106+
return nil
107+
}
108+
109+
// SetToken updates the authorization token
110+
// This is the KEY method - it allows changing tokens without reconnecting!
111+
func (c *MCPAuthClient) SetToken(token string) {
112+
c.roundTripper.token = token
113+
}
114+
115+
// CallTool calls an MCP tool
116+
// Returns *AuthError if the request fails due to HTTP 401/403
117+
func (c *MCPAuthClient) CallTool(ctx context.Context, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
118+
params := &mcp.CallToolParams{
119+
Name: toolName,
120+
Arguments: arguments,
121+
}
122+
123+
result, err := c.session.CallTool(ctx, params)
124+
if err != nil {
125+
// Check if this was an HTTP auth error
126+
if authErr := c.checkAuthError(); authErr != nil {
127+
return nil, authErr
128+
}
129+
return nil, err
130+
}
131+
132+
return result, nil
133+
}
134+
135+
// checkAuthError checks if the last HTTP response was an auth error (401/403)
136+
// and returns an AuthError with parsed WWW-Authenticate header information
137+
func (c *MCPAuthClient) checkAuthError() *AuthError {
138+
if c.roundTripper.lastResponse == nil {
139+
return nil
140+
}
141+
142+
resp := c.roundTripper.lastResponse
143+
144+
// Check for 401 Unauthorized or 403 Forbidden
145+
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusForbidden {
146+
return nil
147+
}
148+
149+
// Parse WWW-Authenticate header
150+
authHeader := resp.Header.Get("WWW-Authenticate")
151+
if authHeader == "" {
152+
return &AuthError{
153+
StatusCode: resp.StatusCode,
154+
ErrorCode: "authentication_required",
155+
}
156+
}
157+
158+
params := testutil.ParseWWWAuthenticateParams(authHeader)
159+
160+
authErr := &AuthError{
161+
StatusCode: resp.StatusCode,
162+
ErrorCode: params["error"],
163+
ResourceMetadataURL: params["resource_metadata"],
164+
ErrorDescription: params["error_description"],
165+
}
166+
167+
// Parse required scopes (space-separated)
168+
if scopeStr := params["scope"]; scopeStr != "" {
169+
authErr.RequiredScopes = strings.Fields(scopeStr)
170+
}
171+
172+
return authErr
173+
}
174+
175+
// Close closes the MCP session
176+
func (c *MCPAuthClient) Close() error {
177+
if c.session != nil {
178+
return c.session.Close()
179+
}
180+
return nil
181+
}
182+
183+
// TestMCPAuthorizationWithOfficialSDK demonstrates authorization testing with the official MCP Go SDK
184+
func TestMCPAuthorizationWithOfficialSDK(t *testing.T) {
185+
t.Run("Basic connection with token", func(t *testing.T) {
186+
testenv.Run(t, &testenv.Config{
187+
MCP: config.MCPConfiguration{
188+
Enabled: true,
189+
},
190+
}, func(t *testing.T, xEnv *testenv.Environment) {
191+
ctx := context.Background()
192+
193+
// Create MCP client with initial token
194+
token := "test-token-with-read-scopes"
195+
mcpClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), token)
196+
197+
// Connect and initialize
198+
err := mcpClient.Connect(ctx)
199+
require.NoError(t, err)
200+
defer mcpClient.Close() //nolint:errcheck
201+
202+
t.Logf("✓ Connected to MCP server with token: %s", token[:20]+"...")
203+
204+
// Call a tool
205+
result, err := mcpClient.CallTool(ctx, "execute_operation_my_employees", map[string]any{
206+
"criteria": map[string]any{},
207+
})
208+
209+
// Without authorization configured, this should work
210+
require.NoError(t, err)
211+
require.NotNil(t, result)
212+
t.Logf("✓ Successfully called tool")
213+
})
214+
})
215+
216+
t.Run("Scope upgrade on persistent session", func(t *testing.T) {
217+
// This test demonstrates the KEY concept:
218+
// - Establish session with token1
219+
// - Get "insufficient scopes" error
220+
// - Update token (SetToken)
221+
// - Retry on SAME session with new token
222+
223+
testenv.Run(t, &testenv.Config{
224+
MCP: config.MCPConfiguration{
225+
Enabled: true,
226+
// TODO: Add authorization configuration when implemented
227+
},
228+
}, func(t *testing.T, xEnv *testenv.Environment) {
229+
ctx := context.Background()
230+
231+
// Step 1: Connect with limited token
232+
readToken := "token-with-scope-mcp:tools:read"
233+
mcpClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), readToken)
234+
235+
err := mcpClient.Connect(ctx)
236+
require.NoError(t, err)
237+
defer mcpClient.Close() //nolint:errcheck
238+
239+
t.Logf("✓ Step 1: Connected with read-only token")
240+
t.Logf(" Token: %s", readToken[:30]+"...")
241+
242+
// Step 2: Call read operation (should succeed)
243+
result, err := mcpClient.CallTool(ctx, "execute_operation_my_employees", map[string]any{
244+
"criteria": map[string]any{},
245+
})
246+
require.NoError(t, err)
247+
require.NotNil(t, result)
248+
t.Logf("✓ Step 2: Read operation succeeded")
249+
250+
// Step 3: Try write operation (should fail with insufficient scopes)
251+
// NOTE: This would fail if authorization is configured
252+
_, err = mcpClient.CallTool(ctx, "execute_operation_update_mood", map[string]any{
253+
"employeeID": 1,
254+
"mood": "HAPPY",
255+
})
256+
257+
// Without authorization, this succeeds. With authorization, check for scope error
258+
if err != nil {
259+
t.Logf("✓ Step 3: Write operation failed (expected with auth): %v", err)
260+
261+
// In a real scenario with authorization:
262+
// 1. Parse error to get required scopes
263+
// 2. User goes through OAuth flow
264+
// 3. Get new token with required scopes
265+
266+
// Step 4: Update token on SAME session
267+
writeToken := "token-with-scope-mcp:tools:read,mcp:tools:write"
268+
mcpClient.SetToken(writeToken)
269+
t.Logf("✓ Step 4: Updated token (same session)")
270+
t.Logf(" New Token: %s", writeToken[:30]+"...")
271+
272+
// Step 5: Retry write operation with upgraded token
273+
result, err := mcpClient.CallTool(ctx, "execute_operation_update_mood", map[string]any{
274+
"employeeID": 1,
275+
"mood": "HAPPY",
276+
})
277+
278+
assert.NoError(t, err)
279+
assert.NotNil(t, result)
280+
t.Logf("✓ Step 5: Write operation succeeded with upgraded token")
281+
} else {
282+
t.Logf("✓ Step 3: Write operation succeeded (no authorization configured)")
283+
}
284+
})
285+
})
286+
287+
t.Run("Multiple token changes on same session", func(t *testing.T) {
288+
testenv.Run(t, &testenv.Config{
289+
MCP: config.MCPConfiguration{
290+
Enabled: true,
291+
},
292+
}, func(t *testing.T, xEnv *testenv.Environment) {
293+
ctx := context.Background()
294+
295+
mcpClient := NewMCPAuthClient(xEnv.GetMCPServerAddr(), "initial-token")
296+
err := mcpClient.Connect(ctx)
297+
require.NoError(t, err)
298+
defer mcpClient.Close() //nolint:errcheck
299+
300+
t.Logf("✓ Connected with initial token")
301+
302+
// Simulate multiple scope upgrades
303+
tokens := []string{
304+
"token-with-basic-scopes",
305+
"token-with-read-scopes",
306+
"token-with-write-scopes",
307+
"token-with-admin-scopes",
308+
}
309+
310+
for i, token := range tokens {
311+
mcpClient.SetToken(token)
312+
313+
// Make a call with the new token
314+
result, err := mcpClient.CallTool(ctx, "execute_operation_my_employees", map[string]any{
315+
"criteria": map[string]any{},
316+
})
317+
318+
require.NoError(t, err)
319+
require.NotNil(t, result)
320+
t.Logf("✓ Request %d succeeded with token: %s", i+1, token[:25]+"...")
321+
}
322+
323+
t.Logf("✓ All token changes worked on same session")
324+
})
325+
})
326+
}
327+
328+
// Example_mcpAuthorizationFlow shows how to use the auth client
329+
func Example_mcpAuthorizationFlow() {
330+
ctx := context.Background()
331+
332+
// Create client with initial token
333+
client := NewMCPAuthClient("http://localhost:3000/mcp", "initial-token")
334+
defer client.Close() //nolint:errcheck
335+
336+
// Connect
337+
if err := client.Connect(ctx); err != nil {
338+
panic(err)
339+
}
340+
341+
// Try to call a tool
342+
_, err := client.CallTool(ctx, "some_tool", map[string]any{})
343+
344+
// If we get insufficient scopes error
345+
if err != nil {
346+
// 1. User goes through OAuth flow (not shown)
347+
// 2. Get new token with more scopes
348+
newToken := "token-with-more-scopes"
349+
350+
// 3. Update token on SAME session
351+
client.SetToken(newToken)
352+
353+
// 4. Retry the tool call
354+
_, err = client.CallTool(ctx, "some_tool", map[string]any{})
355+
if err != nil {
356+
panic(err)
357+
}
358+
}
359+
360+
fmt.Println("Success!")
361+
}

0 commit comments

Comments
 (0)