e04ed9c211
CF: Deploy Dev Docs / deploy (push) Has been cancelled
Sync Labels / build (push) Has been cancelled
tests / unit tests (macos-latest) (push) Has been cancelled
tests / unit tests (windows-latest) (push) Has been cancelled
tests / unit tests (ubuntu-latest) (push) Has been cancelled
816 lines
26 KiB
Go
816 lines
26 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/MicahParks/jwkset"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/googleapis/mcp-toolbox/internal/testutils"
|
|
"github.com/googleapis/mcp-toolbox/internal/util/parameters"
|
|
"github.com/googleapis/mcp-toolbox/tests"
|
|
)
|
|
|
|
var (
|
|
HttpSourceType = "http"
|
|
HttpToolType = "http"
|
|
)
|
|
|
|
// handler function for the test server
|
|
func multiTool(w http.ResponseWriter, r *http.Request) {
|
|
path := r.URL.Path
|
|
path = strings.TrimPrefix(path, "/") // Remove leading slash
|
|
|
|
switch path {
|
|
case "tool0":
|
|
handleTool0(w, r)
|
|
case "tool1":
|
|
handleTool1(w, r)
|
|
case "tool1id":
|
|
handleTool1Id(w, r)
|
|
case "tool1name":
|
|
handleTool1Name(w, r)
|
|
case "tool2":
|
|
handleTool2(w, r)
|
|
case "tool3":
|
|
handleTool3(w, r)
|
|
case "toolQueryTest":
|
|
handleQueryTest(w, r)
|
|
default:
|
|
http.NotFound(w, r) // Return 404 for unknown paths
|
|
}
|
|
}
|
|
|
|
// handleQueryTest simply returns the raw query string it received so the test
|
|
// can verify it's formatted correctly.
|
|
func handleQueryTest(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
errorMessage := fmt.Sprintf("expected GET method but got: %s", string(r.Method))
|
|
http.Error(w, errorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
enc := json.NewEncoder(w)
|
|
enc.SetEscapeHTML(false)
|
|
|
|
err := enc.Encode(r.URL.RawQuery)
|
|
if err != nil {
|
|
http.Error(w, "Failed to write response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
func handleTool0(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
errorMessage := fmt.Sprintf("expected POST method but got: %s", string(r.Method))
|
|
http.Error(w, errorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
response := "hello world"
|
|
err := json.NewEncoder(w).Encode(response)
|
|
if err != nil {
|
|
http.Error(w, "Failed to encode JSON", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
func handleTool1(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
errorMessage := fmt.Sprintf("expected GET method but got: %s", string(r.Method))
|
|
http.Error(w, errorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
var requestBody map[string]interface{}
|
|
bodyBytes, readErr := io.ReadAll(r.Body)
|
|
if readErr != nil {
|
|
http.Error(w, "Bad Request: Failed to read request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
err := json.Unmarshal(bodyBytes, &requestBody)
|
|
if err != nil {
|
|
errorMessage := fmt.Sprintf("Bad Request: Error unmarshalling request body: %s, Raw body: %s", err, string(bodyBytes))
|
|
http.Error(w, errorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
name, ok := requestBody["name"].(string)
|
|
if !ok || name == "" {
|
|
http.Error(w, "Bad Request: Missing or invalid name", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if name == "Alice" {
|
|
response := `[{"id":1,"name":"Alice"},{"id":3,"name":"Sid"}]`
|
|
_, err := w.Write([]byte(response))
|
|
if err != nil {
|
|
http.Error(w, "Failed to write response", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
|
|
func handleTool1Id(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
errorMessage := fmt.Sprintf("expected GET method but got: %s", string(r.Method))
|
|
http.Error(w, errorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
id := r.URL.Query().Get("id")
|
|
if id == "4" {
|
|
response := `[{"id":4,"name":null}]`
|
|
_, err := w.Write([]byte(response))
|
|
if err != nil {
|
|
http.Error(w, "Failed to write response", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
|
|
func handleTool1Name(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
errorMessage := fmt.Sprintf("expected GET method but got: %s", string(r.Method))
|
|
http.Error(w, errorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if !r.URL.Query().Has("name") {
|
|
response := "[]"
|
|
_, err := w.Write([]byte(response))
|
|
if err != nil {
|
|
http.Error(w, "Failed to write response", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
http.Error(w, "Bad Request: Unexpected query parameter 'name'", http.StatusBadRequest)
|
|
}
|
|
|
|
func handleTool2(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
errorMessage := fmt.Sprintf("expected GET method but got: %s", string(r.Method))
|
|
http.Error(w, errorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
email := r.URL.Query().Get("email")
|
|
if email != "" {
|
|
response := `[{"name":"Alice"}]`
|
|
_, err := w.Write([]byte(response))
|
|
if err != nil {
|
|
http.Error(w, "Failed to write response", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
|
|
func handleTool3(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
errorMessage := fmt.Sprintf("expected GET method but got: %s", string(r.Method))
|
|
http.Error(w, errorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
expectedHeaders := map[string]string{
|
|
"Content-Type": "application/json",
|
|
"X-Custom-Header": "example",
|
|
"X-Other-Header": "test",
|
|
}
|
|
for header, expectedValue := range expectedHeaders {
|
|
if r.Header.Get(header) != expectedValue {
|
|
errorMessage := fmt.Sprintf("Bad Request: Missing or incorrect header: %s", header)
|
|
http.Error(w, errorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
expectedQueryParams := map[string][]string{
|
|
"id": []string{"2", "1", "3"},
|
|
"country": []string{"US"},
|
|
}
|
|
query := r.URL.Query()
|
|
for param, expectedValueSlice := range expectedQueryParams {
|
|
values, ok := query[param]
|
|
if ok {
|
|
if !reflect.DeepEqual(expectedValueSlice, values) {
|
|
errorMessage := fmt.Sprintf("Bad Request: Incorrect query parameter: %s, actual: %s", param, query[param])
|
|
http.Error(w, errorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
} else {
|
|
errorMessage := fmt.Sprintf("Bad Request: Missing query parameter: %s, actual: %s", param, query[param])
|
|
http.Error(w, errorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
var requestBody map[string]interface{}
|
|
bodyBytes, readErr := io.ReadAll(r.Body)
|
|
if readErr != nil {
|
|
http.Error(w, "Bad Request: Failed to read request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
err := json.Unmarshal(bodyBytes, &requestBody)
|
|
if err != nil {
|
|
errorMessage := fmt.Sprintf("Bad Request: Error unmarshalling request body: %s, Raw body: %s", err, string(bodyBytes))
|
|
http.Error(w, errorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
expectedBody := map[string]interface{}{
|
|
"place": "zoo",
|
|
"animals": []any{"rabbit", "ostrich", "whale"},
|
|
}
|
|
|
|
if !reflect.DeepEqual(requestBody, expectedBody) {
|
|
errorMessage := fmt.Sprintf("Bad Request: Incorrect request body. Expected: %v, Got: %v", expectedBody, requestBody)
|
|
http.Error(w, errorMessage, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
response := "hello world"
|
|
err = json.NewEncoder(w).Encode(response)
|
|
if err != nil {
|
|
http.Error(w, "Failed to encode JSON", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
func getHTTPToolsConfig(sourceConfig map[string]any, toolType string, jwksURL string) map[string]any {
|
|
otherSourceConfig := make(map[string]any)
|
|
for k, v := range sourceConfig {
|
|
otherSourceConfig[k] = v
|
|
}
|
|
otherSourceConfig["headers"] = map[string]string{"X-Custom-Header": "unexpected", "Content-Type": "application/json"}
|
|
otherSourceConfig["queryParams"] = map[string]any{"id": 1, "name": "Sid"}
|
|
|
|
clientID := tests.ClientId
|
|
if clientID == "" {
|
|
clientID = "test-client-id"
|
|
}
|
|
|
|
toolsFile := map[string]any{
|
|
"sources": map[string]any{
|
|
"my-instance": sourceConfig,
|
|
"other-instance": otherSourceConfig,
|
|
},
|
|
"authServices": map[string]any{
|
|
"my-google-auth": map[string]any{
|
|
"type": "google",
|
|
"clientId": clientID,
|
|
},
|
|
"my-generic-auth": map[string]any{
|
|
"type": "generic",
|
|
"audience": "test-audience",
|
|
"authorizationServer": jwksURL,
|
|
},
|
|
},
|
|
"tools": map[string]any{
|
|
"my-simple-tool": map[string]any{
|
|
"type": toolType,
|
|
"path": "/tool0",
|
|
"method": "POST",
|
|
"source": "my-instance",
|
|
"requestBody": "{}",
|
|
"description": "Simple tool to test end to end functionality.",
|
|
},
|
|
"my-tool": map[string]any{
|
|
"type": toolType,
|
|
"source": "my-instance",
|
|
"method": "GET",
|
|
"path": "/tool1",
|
|
"description": "some description",
|
|
"queryParams": []parameters.Parameter{
|
|
parameters.NewIntParameter("id", "user ID")},
|
|
"bodyParams": []parameters.Parameter{parameters.NewStringParameter("name", "user name")},
|
|
"requestBody": `{
|
|
"age": 36,
|
|
"name": "{{.name}}"
|
|
}
|
|
`,
|
|
"headers": map[string]string{"Content-Type": "application/json"},
|
|
},
|
|
"my-tool-by-id": map[string]any{
|
|
"type": toolType,
|
|
"source": "my-instance",
|
|
"method": "GET",
|
|
"path": "/tool1id",
|
|
"description": "some description",
|
|
"queryParams": []parameters.Parameter{
|
|
parameters.NewIntParameter("id", "user ID")},
|
|
"headers": map[string]string{"Content-Type": "application/json"},
|
|
},
|
|
"my-tool-by-name": map[string]any{
|
|
"type": toolType,
|
|
"source": "my-instance",
|
|
"method": "GET",
|
|
"path": "/tool1name",
|
|
"description": "some description",
|
|
"queryParams": []parameters.Parameter{
|
|
parameters.NewStringParameter("name", "user name", parameters.WithStringRequired(false))},
|
|
"headers": map[string]string{"Content-Type": "application/json"},
|
|
},
|
|
"my-query-param-tool": map[string]any{
|
|
"type": toolType,
|
|
"source": "my-instance",
|
|
"method": "GET",
|
|
"path": "/toolQueryTest",
|
|
"description": "Tool to test optional query parameters.",
|
|
"queryParams": []parameters.Parameter{
|
|
parameters.NewStringParameter("reqId", "required ID", parameters.WithStringRequired(true)),
|
|
parameters.NewStringParameter("page", "optional page number", parameters.WithStringRequired(false)),
|
|
parameters.NewStringParameter("filter", "optional filter string", parameters.WithStringRequired(false)),
|
|
},
|
|
},
|
|
"my-auth-tool": map[string]any{
|
|
"type": toolType,
|
|
"source": "my-instance",
|
|
"method": "GET",
|
|
"path": "/tool2",
|
|
"description": "some description",
|
|
"requestBody": "{}",
|
|
"queryParams": []parameters.Parameter{
|
|
parameters.NewStringParameter("email", "some description", parameters.WithStringAuth(
|
|
[]parameters.ParamAuthService{{Name: "my-google-auth", Field: "email"}})),
|
|
},
|
|
},
|
|
"my-auth-required-tool": map[string]any{
|
|
"type": toolType,
|
|
"source": "my-instance",
|
|
"method": "POST",
|
|
"path": "/tool0",
|
|
"description": "some description",
|
|
"requestBody": "{}",
|
|
"authRequired": []string{"my-google-auth"},
|
|
},
|
|
"my-auth-required-generic-tool": map[string]any{
|
|
"type": toolType,
|
|
"source": "my-instance",
|
|
"method": "POST",
|
|
"path": "/tool0",
|
|
"description": "some description",
|
|
"requestBody": "{}",
|
|
"authRequired": []string{"my-generic-auth"},
|
|
},
|
|
"my-advanced-tool": map[string]any{
|
|
"type": toolType,
|
|
"source": "other-instance",
|
|
"method": "get",
|
|
"path": "/{{.path}}?id=2",
|
|
"description": "some description",
|
|
"headers": map[string]string{
|
|
"X-Custom-Header": "example",
|
|
},
|
|
"pathParams": []parameters.Parameter{
|
|
¶meters.StringParameter{
|
|
CommonParameter: parameters.CommonParameter{Name: "path", Type: "string", Desc: "path param"},
|
|
},
|
|
},
|
|
"queryParams": []parameters.Parameter{
|
|
parameters.NewIntParameter("id", "user ID"), parameters.NewStringParameter("country", "country"),
|
|
},
|
|
"requestBody": `{
|
|
"place": "zoo",
|
|
"animals": {{json .animalArray }}
|
|
}
|
|
`,
|
|
"bodyParams": []parameters.Parameter{parameters.NewArrayParameter("animalArray", "animals in the zoo", parameters.NewStringParameter("animals", "desc"))},
|
|
"headerParams": []parameters.Parameter{parameters.NewStringParameter("X-Other-Header", "custom header")},
|
|
},
|
|
},
|
|
}
|
|
return toolsFile
|
|
}
|
|
|
|
func getMCPHTTPSourceConfig(t *testing.T) map[string]any {
|
|
idToken, err := tests.GetGoogleIdToken(t)
|
|
if err != nil {
|
|
t.Logf("Warning: error getting ID token: %s. Using dummy token.", err)
|
|
idToken = "dummy-token"
|
|
}
|
|
idToken = "Bearer " + idToken
|
|
|
|
return map[string]any{
|
|
"type": HttpSourceType,
|
|
"headers": map[string]string{"Authorization": idToken},
|
|
"allowPrivateNetworks": true,
|
|
}
|
|
}
|
|
|
|
func TestHTTPListTools(t *testing.T) {
|
|
// Start a test server with multiTool handler
|
|
server := httptest.NewServer(http.HandlerFunc(multiTool))
|
|
defer server.Close()
|
|
|
|
sourceConfig := getMCPHTTPSourceConfig(t)
|
|
sourceConfig["baseUrl"] = server.URL
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
|
|
// Set up generic auth mock server (copied from legacy test)
|
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("failed to create RSA private key: %v", err)
|
|
}
|
|
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/.well-known/openid-configuration" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"issuer": "https://example.com",
|
|
"jwks_uri": "http://" + r.Host + "/jwks",
|
|
})
|
|
return
|
|
}
|
|
if r.URL.Path == "/jwks" {
|
|
options := jwkset.JWKOptions{
|
|
Metadata: jwkset.JWKMetadataOptions{
|
|
KID: "test-key-id",
|
|
},
|
|
}
|
|
jwk, _ := jwkset.NewJWKFromKey(privateKey.Public(), options)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"keys": []jwkset.JWKMarshal{jwk.Marshal()},
|
|
})
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer jwksServer.Close()
|
|
|
|
toolsFile := map[string]any{
|
|
"sources": map[string]any{
|
|
"my-instance": sourceConfig,
|
|
},
|
|
"tools": map[string]any{
|
|
"my-simple-tool": map[string]any{
|
|
"type": HttpToolType,
|
|
"path": "/tool0",
|
|
"method": "POST",
|
|
"source": "my-instance",
|
|
"requestBody": "{}",
|
|
"description": "Simple tool to test end to end functionality.",
|
|
},
|
|
"my-tool": map[string]any{
|
|
"type": HttpToolType,
|
|
"source": "my-instance",
|
|
"method": "GET",
|
|
"path": "/tool1",
|
|
"description": "some description",
|
|
"queryParams": []parameters.Parameter{
|
|
parameters.NewIntParameter("id", "user ID")},
|
|
"bodyParams": []parameters.Parameter{parameters.NewStringParameter("name", "user name")},
|
|
"requestBody": `{
|
|
"age": 36,
|
|
"name": "{{.name}}"
|
|
}
|
|
`,
|
|
"headers": map[string]string{"Content-Type": "application/json"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Start the toolbox server.
|
|
cmd, cleanup, err := tests.StartCmd(ctx, toolsFile)
|
|
if err != nil {
|
|
t.Fatalf("command initialization returned an error: %s", err)
|
|
}
|
|
defer cleanup()
|
|
|
|
// Wait for server ready
|
|
waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out)
|
|
if err != nil {
|
|
t.Logf("toolbox command logs: \n%s", out)
|
|
t.Fatalf("toolbox didn't start successfully: %s", err)
|
|
}
|
|
|
|
expectedTools := []tests.MCPToolManifest{
|
|
{
|
|
Name: "my-simple-tool",
|
|
Description: "Simple tool to test end to end functionality.",
|
|
InputSchema: map[string]any{"type": "object", "properties": map[string]any{}, "required": []any{}},
|
|
},
|
|
{
|
|
Name: "my-tool",
|
|
Description: "some description",
|
|
InputSchema: map[string]any{
|
|
"type": "object",
|
|
"required": []any{"id", "name"},
|
|
"properties": map[string]any{
|
|
"id": map[string]any{
|
|
"type": "integer",
|
|
"description": "user ID",
|
|
},
|
|
"name": map[string]any{
|
|
"type": "string",
|
|
"description": "user name",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
tests.RunMCPToolsListMethod(t, expectedTools)
|
|
}
|
|
|
|
func TestHTTPCallTool(t *testing.T) {
|
|
// Start a test server with multiTool handler
|
|
server := httptest.NewServer(http.HandlerFunc(multiTool))
|
|
defer server.Close()
|
|
|
|
sourceConfig := getMCPHTTPSourceConfig(t)
|
|
sourceConfig["baseUrl"] = server.URL
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
|
defer cancel()
|
|
|
|
// Set up generic auth mock server (needed for config generation)
|
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("failed to create RSA private key: %v", err)
|
|
}
|
|
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/.well-known/openid-configuration" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"issuer": "https://example.com",
|
|
"jwks_uri": "http://" + r.Host + "/jwks",
|
|
})
|
|
return
|
|
}
|
|
if r.URL.Path == "/jwks" {
|
|
options := jwkset.JWKOptions{
|
|
Metadata: jwkset.JWKMetadataOptions{
|
|
KID: "test-key-id",
|
|
},
|
|
}
|
|
jwk, _ := jwkset.NewJWKFromKey(privateKey.Public(), options)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"keys": []jwkset.JWKMarshal{jwk.Marshal()},
|
|
})
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
defer jwksServer.Close()
|
|
|
|
toolsFile := getHTTPToolsConfig(sourceConfig, HttpToolType, jwksServer.URL)
|
|
|
|
// Start the toolbox server.
|
|
cmd, cleanup, err := tests.StartCmd(ctx, toolsFile)
|
|
if err != nil {
|
|
t.Fatalf("command initialization returned an error: %s", err)
|
|
}
|
|
defer cleanup()
|
|
|
|
// Wait for server ready
|
|
waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
out, err := testutils.WaitForString(waitCtx, regexp.MustCompile(`Server ready to serve`), cmd.Out)
|
|
if err != nil {
|
|
t.Logf("toolbox command logs: \n%s", out)
|
|
t.Fatalf("toolbox didn't start successfully: %s", err)
|
|
}
|
|
|
|
// Run Generic Auth Tests
|
|
runGenericAuthMCPInvokeTest(t, privateKey)
|
|
|
|
// Run Advanced Tool Tests
|
|
runAdvancedHTTPMCPInvokeTest(t)
|
|
|
|
// Run Query Parameter Tests
|
|
runQueryParamMCPInvokeTest(t)
|
|
|
|
// Run URL Parameter Binding Tests
|
|
runURLParamMCPBindingTest(t)
|
|
|
|
// Use shared helper for standard database tools
|
|
t.Run("use shared RunMCPToolInvokeTest", func(t *testing.T) {
|
|
tests.RunMCPToolInvokeTest(t, `"hello world"`,
|
|
tests.WithMyToolId3NameAliceWant(`{"id":1,"name":"Alice"}`),
|
|
tests.WithMyToolById4Want(`{"id":4,"name":null}`),
|
|
tests.WithNullWant("[]"),
|
|
)
|
|
})
|
|
}
|
|
|
|
func runURLParamMCPBindingTest(t *testing.T) {
|
|
t.Run("tools/list with URL param filters schema", func(t *testing.T) {
|
|
headers := tests.NewMCPRequestHeader(t, nil)
|
|
req := tests.MCPListToolsRequest{
|
|
Jsonrpc: "2.0",
|
|
Id: "1",
|
|
Method: "tools/list",
|
|
}
|
|
reqBody, err := json.Marshal(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal list tools request: %v", err)
|
|
}
|
|
|
|
resp, respBody := tests.RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/mcp?id=4", bytes.NewBuffer(reqBody), headers)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var mcpResp tests.MCPListToolsResponse
|
|
if err := json.Unmarshal(respBody, &mcpResp); err != nil {
|
|
t.Fatalf("failed to parse list tools response: %v\nraw body: %s", err, string(respBody))
|
|
}
|
|
|
|
var myToolByID *tests.MCPToolManifest
|
|
for _, tool := range mcpResp.Result.Tools {
|
|
if tool.Name == "my-tool-by-id" {
|
|
myToolByID = &tool
|
|
break
|
|
}
|
|
}
|
|
|
|
if myToolByID == nil {
|
|
t.Fatalf("tool my-tool-by-id not found in tools list")
|
|
}
|
|
|
|
properties, ok := myToolByID.InputSchema["properties"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("missing or invalid properties in inputSchema: %v", myToolByID.InputSchema)
|
|
}
|
|
|
|
if len(properties) != 0 {
|
|
t.Fatalf("expected properties to be empty (filtered by URL params), got: %v", properties)
|
|
}
|
|
})
|
|
|
|
t.Run("tools/call with URL param injects bound parameter", func(t *testing.T) {
|
|
headers := tests.NewMCPRequestHeader(t, nil)
|
|
// We call "my-tool-by-id" without passing any "id" argument in the JSON-RPC request body,
|
|
// because "id=4" is passed as a URL query param.
|
|
req := tests.NewMCPCallToolRequest("2", "my-tool-by-id", map[string]any{})
|
|
reqBody, err := json.Marshal(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal call tool request: %v", err)
|
|
}
|
|
|
|
resp, respBody := tests.RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/mcp?id=4", bytes.NewBuffer(reqBody), headers)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var mcpResp tests.MCPCallToolResponse
|
|
if err := json.Unmarshal(respBody, &mcpResp); err != nil {
|
|
t.Fatalf("failed to parse call tool response: %v\nraw body: %s", err, string(respBody))
|
|
}
|
|
|
|
if mcpResp.Result.IsError {
|
|
t.Fatalf("expected success, got error: %v", mcpResp.Result)
|
|
}
|
|
|
|
if len(mcpResp.Result.Content) == 0 {
|
|
t.Fatalf("expected content in result, got empty: %v", mcpResp.Result)
|
|
}
|
|
|
|
gotText := mcpResp.Result.Content[0].Text
|
|
wantText := `{"id":4,"name":null}`
|
|
if !strings.Contains(gotText, wantText) {
|
|
t.Fatalf("unexpected response text: got %q, want it to contain %q", gotText, wantText)
|
|
}
|
|
})
|
|
}
|
|
|
|
func runGenericAuthMCPInvokeTest(t *testing.T, privateKey *rsa.PrivateKey) {
|
|
// Generic Auth Success
|
|
t.Run("invoke generic auth tool with valid token", func(t *testing.T) {
|
|
// Generate valid token
|
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
|
"aud": "test-audience",
|
|
"scope": "read:files",
|
|
"sub": "test-user",
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
token.Header["kid"] = "test-key-id"
|
|
signedString, err := token.SignedString(privateKey)
|
|
if err != nil {
|
|
t.Fatalf("failed to sign token: %v", err)
|
|
}
|
|
|
|
headers := map[string]string{"my-generic-auth_token": signedString}
|
|
statusCode, mcpResp, err := tests.InvokeMCPTool(t, "my-auth-required-generic-tool", map[string]any{}, headers)
|
|
if err != nil {
|
|
t.Fatalf("native error executing %s: %s", "my-auth-required-generic-tool", err)
|
|
}
|
|
if statusCode != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", statusCode)
|
|
}
|
|
if mcpResp.Result.IsError {
|
|
t.Fatalf("expected success, got error result: %v", mcpResp.Result)
|
|
}
|
|
})
|
|
|
|
// Auth Failure: Invoke generic auth tool without token
|
|
t.Run("invoke generic auth tool without token", func(t *testing.T) {
|
|
statusCode, _, err := tests.InvokeMCPTool(t, "my-auth-required-generic-tool", map[string]any{}, nil)
|
|
if err != nil {
|
|
t.Fatalf("native error executing %s: %s", "my-auth-required-generic-tool", err)
|
|
}
|
|
if statusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected status 401, got %d", statusCode)
|
|
}
|
|
})
|
|
}
|
|
|
|
func runQueryParamMCPInvokeTest(t *testing.T) {
|
|
// Query Parameter Variations: Tests with optional parameters omitted or nil
|
|
t.Run("invoke query-param-tool optional omitted", func(t *testing.T) {
|
|
arguments := map[string]any{"reqId": "test1"}
|
|
tests.RunMCPCustomToolCallMethod(t, "my-query-param-tool", arguments, `"reqId=test1"`)
|
|
})
|
|
|
|
t.Run("invoke query-param-tool some optional nil", func(t *testing.T) {
|
|
arguments := map[string]any{"reqId": "test2", "page": "5", "filter": nil}
|
|
tests.RunMCPCustomToolCallMethod(t, "my-query-param-tool", arguments, `"page=5\u0026reqId=test2"`) // 'filter' omitted!
|
|
})
|
|
|
|
t.Run("invoke query-param-tool some optional absent", func(t *testing.T) {
|
|
arguments := map[string]any{"reqId": "test2", "page": "5"}
|
|
tests.RunMCPCustomToolCallMethod(t, "my-query-param-tool", arguments, `"page=5\u0026reqId=test2"`) // 'filter' omitted!
|
|
})
|
|
|
|
t.Run("invoke query-param-tool required param nil", func(t *testing.T) {
|
|
statusCode, mcpResp, err := tests.InvokeMCPTool(t, "my-query-param-tool", map[string]any{"reqId": nil, "page": "1"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("native error executing %s: %s", "my-query-param-tool", err)
|
|
}
|
|
if statusCode != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", statusCode)
|
|
}
|
|
tests.AssertMCPError(t, mcpResp, "required")
|
|
})
|
|
}
|
|
|
|
func runAdvancedHTTPMCPInvokeTest(t *testing.T) {
|
|
// Mock Server Error: Invoke tool with parameters that cause the mock server to return 400
|
|
t.Run("invoke my-advanced-tool with wrong params causing mock 400", func(t *testing.T) {
|
|
arguments := map[string]any{
|
|
"animalArray": []any{"rabbit", "ostrich", "whale"},
|
|
"id": 4, // Expected 3 in mock!
|
|
"path": "tool3",
|
|
"country": "US",
|
|
"X-Other-Header": "test",
|
|
}
|
|
statusCode, mcpResp, err := tests.InvokeMCPTool(t, "my-advanced-tool", arguments, nil)
|
|
if err != nil {
|
|
t.Fatalf("native error executing %s: %s", "my-advanced-tool", err)
|
|
}
|
|
if statusCode != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", statusCode)
|
|
}
|
|
tests.AssertMCPError(t, mcpResp, "unexpected status code")
|
|
})
|
|
|
|
// Advanced Tool Success
|
|
t.Run("invoke my-advanced-tool successfully", func(t *testing.T) {
|
|
arguments := map[string]any{
|
|
"animalArray": []any{"rabbit", "ostrich", "whale"},
|
|
"id": 3,
|
|
"path": "tool3",
|
|
"country": "US",
|
|
"X-Other-Header": "test",
|
|
}
|
|
tests.RunMCPCustomToolCallMethod(t, "my-advanced-tool", arguments, `"hello world"`)
|
|
})
|
|
}
|