|
| 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