Skip to content

Commit b9e27e0

Browse files
committed
test(http): create native MCP tests
1 parent 02b7408 commit b9e27e0

File tree

2 files changed

+417
-0
lines changed

2 files changed

+417
-0
lines changed

tests/http/http_mcp_test.go

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package http
16+
17+
import (
18+
"context"
19+
"crypto/rand"
20+
"crypto/rsa"
21+
"encoding/json"
22+
"net/http"
23+
"net/http/httptest"
24+
"regexp"
25+
"testing"
26+
"time"
27+
28+
"github.com/MicahParks/jwkset"
29+
"github.com/golang-jwt/jwt/v5"
30+
"github.com/googleapis/genai-toolbox/internal/testutils"
31+
"github.com/googleapis/genai-toolbox/tests"
32+
)
33+
34+
func getMCPHTTPSourceConfig(t *testing.T) map[string]any {
35+
idToken, err := tests.GetGoogleIdToken(tests.ClientId)
36+
if err != nil {
37+
t.Logf("Warning: error getting ID token: %s. Using dummy token.", err)
38+
idToken = "dummy-token"
39+
}
40+
idToken = "Bearer " + idToken
41+
42+
return map[string]any{
43+
"type": HttpSourceType,
44+
"headers": map[string]string{"Authorization": idToken},
45+
}
46+
}
47+
48+
func TestHTTPListTools(t *testing.T) {
49+
// Start a test server with multiTool handler
50+
server := httptest.NewServer(http.HandlerFunc(multiTool))
51+
defer server.Close()
52+
53+
sourceConfig := getMCPHTTPSourceConfig(t)
54+
sourceConfig["baseUrl"] = server.URL
55+
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
56+
defer cancel()
57+
58+
// Set up generic auth mock server (copied from legacy test)
59+
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
60+
if err != nil {
61+
t.Fatalf("failed to create RSA private key: %v", err)
62+
}
63+
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
64+
if r.URL.Path == "/.well-known/openid-configuration" {
65+
w.Header().Set("Content-Type", "application/json")
66+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
67+
"issuer": "https://example.com",
68+
"jwks_uri": "http://" + r.Host + "/jwks",
69+
})
70+
return
71+
}
72+
if r.URL.Path == "/jwks" {
73+
options := jwkset.JWKOptions{
74+
Metadata: jwkset.JWKMetadataOptions{
75+
KID: "test-key-id",
76+
},
77+
}
78+
jwk, _ := jwkset.NewJWKFromKey(privateKey.Public(), options)
79+
w.Header().Set("Content-Type", "application/json")
80+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
81+
"keys": []jwkset.JWKMarshal{jwk.Marshal()},
82+
})
83+
return
84+
}
85+
http.NotFound(w, r)
86+
}))
87+
defer jwksServer.Close()
88+
89+
toolsFile := getHTTPToolsConfig(sourceConfig, HttpToolType, jwksServer.URL)
90+
91+
// Start the toolbox server.
92+
cmd, cleanup, err := tests.StartCmd(ctx, toolsFile)
93+
if err != nil {
94+
t.Fatalf("command initialization returned an error: %s", err)
95+
}
96+
defer cleanup()
97+
98+
// Wait for server ready
99+
waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
100+
defer cancel()
101+
out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out)
102+
if err != nil {
103+
t.Logf("toolbox command logs: \n%s", out)
104+
t.Fatalf("toolbox didn't start successfully: %s", err)
105+
}
106+
107+
expectedTools := []tests.MCPToolManifest{
108+
{
109+
Name: "my-simple-tool",
110+
Description: "Simple tool to test end to end functionality.",
111+
InputSchema: map[string]any{"type": "object", "properties": map[string]any{}, "required": []any{}},
112+
},
113+
{
114+
Name: "my-tool",
115+
Description: "some description",
116+
InputSchema: map[string]any{
117+
"type": "object",
118+
"required": []any{"id", "name"},
119+
"properties": map[string]any{
120+
"id": map[string]any{
121+
"type": "integer",
122+
"description": "user ID",
123+
},
124+
"name": map[string]any{
125+
"type": "string",
126+
"description": "user name",
127+
},
128+
},
129+
},
130+
},
131+
}
132+
133+
tests.RunMCPToolsListMethod(t, expectedTools)
134+
}
135+
136+
func TestHTTPCallTool(t *testing.T) {
137+
// Start a test server with multiTool handler
138+
server := httptest.NewServer(http.HandlerFunc(multiTool))
139+
defer server.Close()
140+
141+
sourceConfig := getMCPHTTPSourceConfig(t)
142+
sourceConfig["baseUrl"] = server.URL
143+
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
144+
defer cancel()
145+
146+
// Set up generic auth mock server (needed for config generation)
147+
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
148+
if err != nil {
149+
t.Fatalf("failed to create RSA private key: %v", err)
150+
}
151+
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152+
if r.URL.Path == "/.well-known/openid-configuration" {
153+
w.Header().Set("Content-Type", "application/json")
154+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
155+
"issuer": "https://example.com",
156+
"jwks_uri": "http://" + r.Host + "/jwks",
157+
})
158+
return
159+
}
160+
if r.URL.Path == "/jwks" {
161+
options := jwkset.JWKOptions{
162+
Metadata: jwkset.JWKMetadataOptions{
163+
KID: "test-key-id",
164+
},
165+
}
166+
jwk, _ := jwkset.NewJWKFromKey(privateKey.Public(), options)
167+
w.Header().Set("Content-Type", "application/json")
168+
_ = json.NewEncoder(w).Encode(map[string]interface{}{
169+
"keys": []jwkset.JWKMarshal{jwk.Marshal()},
170+
})
171+
return
172+
}
173+
http.NotFound(w, r)
174+
}))
175+
defer jwksServer.Close()
176+
177+
toolsFile := getHTTPToolsConfig(sourceConfig, HttpToolType, jwksServer.URL)
178+
179+
// Start the toolbox server.
180+
cmd, cleanup, err := tests.StartCmd(ctx, toolsFile)
181+
if err != nil {
182+
t.Fatalf("command initialization returned an error: %s", err)
183+
}
184+
defer cleanup()
185+
186+
// Wait for server ready
187+
waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
188+
defer cancel()
189+
out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out)
190+
if err != nil {
191+
t.Logf("toolbox command logs: \n%s", out)
192+
t.Fatalf("toolbox didn't start successfully: %s", err)
193+
}
194+
195+
// Run Generic Auth Tests
196+
runGenericAuthMCPInvokeTest(t, privateKey)
197+
198+
// Run Advanced Tool Tests
199+
runAdvancedHTTPMCPInvokeTest(t)
200+
201+
// Run Query Parameter Tests
202+
runQueryParamMCPInvokeTest(t)
203+
204+
// Use shared helper for standard database tools
205+
t.Run("use shared RunMCPToolInvokeTest", func(t *testing.T) {
206+
tests.RunMCPToolInvokeTest(t, `"hello world"`,
207+
tests.WithMyToolId3NameAliceWant(`{"id":1,"name":"Alice"}`),
208+
tests.WithMyToolById4Want(`{"id":4,"name":null}`),
209+
)
210+
})
211+
}
212+
213+
func runGenericAuthMCPInvokeTest(t *testing.T, privateKey *rsa.PrivateKey) {
214+
// Generic Auth Success
215+
t.Run("invoke generic auth tool with valid token", func(t *testing.T) {
216+
// Generate valid token
217+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
218+
"aud": "test-audience",
219+
"scope": "read:files",
220+
"sub": "test-user",
221+
"exp": time.Now().Add(time.Hour).Unix(),
222+
})
223+
token.Header["kid"] = "test-key-id"
224+
signedString, err := token.SignedString(privateKey)
225+
if err != nil {
226+
t.Fatalf("failed to sign token: %v", err)
227+
}
228+
229+
headers := map[string]string{"my-generic-auth_token": signedString}
230+
statusCode, mcpResp, err := tests.InvokeMCPTool(t, "my-auth-required-generic-tool", map[string]any{}, headers)
231+
if err != nil {
232+
t.Fatalf("native error executing %s: %s", "my-auth-required-generic-tool", err)
233+
}
234+
if statusCode != http.StatusOK {
235+
t.Fatalf("expected status 200, got %d", statusCode)
236+
}
237+
if mcpResp.Result.IsError {
238+
t.Fatalf("expected success, got error result: %v", mcpResp.Result)
239+
}
240+
})
241+
242+
// Auth Failure: Invoke generic auth tool without token
243+
t.Run("invoke generic auth tool without token", func(t *testing.T) {
244+
statusCode, _, err := tests.InvokeMCPTool(t, "my-auth-required-generic-tool", map[string]any{}, nil)
245+
if err != nil {
246+
t.Fatalf("native error executing %s: %s", "my-auth-required-generic-tool", err)
247+
}
248+
if statusCode != http.StatusUnauthorized {
249+
t.Fatalf("expected status 401, got %d", statusCode)
250+
}
251+
})
252+
}
253+
254+
func runQueryParamMCPInvokeTest(t *testing.T) {
255+
// Query Parameter Variations: Tests with optional parameters omitted or nil
256+
t.Run("invoke query-param-tool optional omitted", func(t *testing.T) {
257+
arguments := map[string]any{"reqId": "test1"}
258+
tests.RunMCPCustomToolCallMethod(t, "my-query-param-tool", arguments, `"reqId=test1"`)
259+
})
260+
261+
t.Run("invoke query-param-tool some optional nil", func(t *testing.T) {
262+
arguments := map[string]any{"reqId": "test2", "page": "5", "filter": nil}
263+
tests.RunMCPCustomToolCallMethod(t, "my-query-param-tool", arguments, `"page=5\u0026reqId=test2"`) // 'filter' omitted!
264+
})
265+
266+
t.Run("invoke query-param-tool some optional absent", func(t *testing.T) {
267+
arguments := map[string]any{"reqId": "test2", "page": "5"}
268+
tests.RunMCPCustomToolCallMethod(t, "my-query-param-tool", arguments, `"page=5\u0026reqId=test2"`) // 'filter' omitted!
269+
})
270+
271+
t.Run("invoke query-param-tool required param nil", func(t *testing.T) {
272+
statusCode, mcpResp, err := tests.InvokeMCPTool(t, "my-query-param-tool", map[string]any{"reqId": nil, "page": "1"}, nil)
273+
if err != nil {
274+
t.Fatalf("native error executing %s: %s", "my-query-param-tool", err)
275+
}
276+
if statusCode != http.StatusOK {
277+
t.Fatalf("expected status 200, got %d", statusCode)
278+
}
279+
tests.AssertMCPError(t, mcpResp, "required")
280+
})
281+
}
282+
283+
func runAdvancedHTTPMCPInvokeTest(t *testing.T) {
284+
// Mock Server Error: Invoke tool with parameters that cause the mock server to return 400
285+
t.Run("invoke my-advanced-tool with wrong params causing mock 400", func(t *testing.T) {
286+
arguments := map[string]any{
287+
"animalArray": []any{"rabbit", "ostrich", "whale"},
288+
"id": 4, // Expected 3 in mock!
289+
"path": "tool3",
290+
"country": "US",
291+
"X-Other-Header": "test",
292+
}
293+
statusCode, mcpResp, err := tests.InvokeMCPTool(t, "my-advanced-tool", arguments, nil)
294+
if err != nil {
295+
t.Fatalf("native error executing %s: %s", "my-advanced-tool", err)
296+
}
297+
if statusCode != http.StatusOK {
298+
t.Fatalf("expected status 200, got %d", statusCode)
299+
}
300+
tests.AssertMCPError(t, mcpResp, "unexpected status code")
301+
})
302+
303+
// Advanced Tool Success
304+
t.Run("invoke my-advanced-tool successfully", func(t *testing.T) {
305+
arguments := map[string]any{
306+
"animalArray": []any{"rabbit", "ostrich", "whale"},
307+
"id": 3,
308+
"path": "tool3",
309+
"country": "US",
310+
"X-Other-Header": "test",
311+
}
312+
tests.RunMCPCustomToolCallMethod(t, "my-advanced-tool", arguments, `"hello world"`)
313+
})
314+
}

0 commit comments

Comments
 (0)