Files
wehub-resource-sync e04ed9c211
CF: Deploy Dev Docs / deploy (push) Waiting to run
Sync Labels / build (push) Waiting to run
tests / unit tests (macos-latest) (push) Waiting to run
tests / unit tests (ubuntu-latest) (push) Waiting to run
tests / unit tests (windows-latest) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:32:45 +08:00

574 lines
19 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 tests
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"strings"
"testing"
"github.com/google/uuid"
"github.com/googleapis/mcp-toolbox/internal/server/mcp/jsonrpc"
v20251125 "github.com/googleapis/mcp-toolbox/internal/server/mcp/v20251125"
)
// RunRequest is a helper function to send HTTP requests and return the response
func RunRequest(t *testing.T, method, url string, body io.Reader, headers map[string]string) (*http.Response, []byte) {
req, err := http.NewRequest(method, url, body)
if err != nil {
t.Fatalf("unable to create request: %s", err)
}
req.Header.Set("Content-type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("unable to send request: %s", err)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("unable to read request body: %s", err)
}
defer resp.Body.Close()
return resp, respBody
}
// RunInitialize runs the initialize lifecycle for mcp to set up client-server connection
func RunInitialize(t *testing.T, protocolVersion string) string {
url := "http://127.0.0.1:5000/mcp"
initializeRequestBody := map[string]any{
"jsonrpc": "2.0",
"id": "mcp-initialize",
"method": "initialize",
"params": map[string]any{
"protocolVersion": protocolVersion,
},
}
reqMarshal, err := json.Marshal(initializeRequestBody)
if err != nil {
t.Fatalf("unexpected error during marshaling of body")
}
resp, _ := RunRequest(t, http.MethodPost, url, bytes.NewBuffer(reqMarshal), nil)
if resp.StatusCode != 200 {
t.Fatalf("response status code is not 200")
}
if contentType := resp.Header.Get("Content-type"); contentType != "application/json" {
t.Fatalf("unexpected content-type header: want %s, got %s", "application/json", contentType)
}
sessionId := resp.Header.Get("Mcp-Session-Id")
header := map[string]string{}
if sessionId != "" {
header["Mcp-Session-Id"] = sessionId
}
initializeNotificationBody := map[string]any{
"jsonrpc": "2.0",
"method": "notifications/initialized",
}
notiMarshal, err := json.Marshal(initializeNotificationBody)
if err != nil {
t.Fatalf("unexpected error during marshaling of notifications body")
}
_, _ = RunRequest(t, http.MethodPost, url, bytes.NewBuffer(notiMarshal), header)
return sessionId
}
// NewMCPRequestHeader takes custom headers and appends headers required for MCP.
func NewMCPRequestHeader(t *testing.T, customHeaders map[string]string) map[string]string {
headers := make(map[string]string)
for k, v := range customHeaders {
headers[k] = v
}
headers["Content-Type"] = "application/json"
headers["MCP-Protocol-Version"] = v20251125.PROTOCOL_VERSION
return headers
}
// InvokeMCPTool is a transparent, native JSON-RPC execution harness for tests.
func InvokeMCPTool(t *testing.T, toolName string, arguments map[string]any, requestHeader map[string]string) (int, *MCPCallToolResponse, error) {
headers := NewMCPRequestHeader(t, requestHeader)
req := NewMCPCallToolRequest(uuid.New().String(), toolName, arguments)
reqBody, err := json.Marshal(req)
if err != nil {
t.Fatalf("error marshalling request body: %v", err)
}
resp, respBody := RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/mcp", bytes.NewBuffer(reqBody), headers)
var mcpResp MCPCallToolResponse
if err := json.Unmarshal(respBody, &mcpResp); err != nil {
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, nil, fmt.Errorf("%s", string(respBody))
}
t.Fatalf("error parsing mcp response body: %v\nraw body: %s", err, string(respBody))
}
return resp.StatusCode, &mcpResp, nil
}
// getMCPResultText safely extracts the text from content blocks, unmarshaling them if they are valid JSON.
//
// TODO: For tests that need to strictly validate the exact schema or structure of the output,
// consider avoiding this helper and instead unmarshal the raw JSON directly into expected Go structs for comparison.
func getMCPResultText(t *testing.T, resp *MCPCallToolResponse) []any {
if len(resp.Result.Content) == 0 {
return []any{}
}
var res []any
for _, content := range resp.Result.Content {
var item any
if err := json.Unmarshal([]byte(content.Text), &item); err != nil {
res = append(res, content.Text)
} else {
if slice, ok := item.([]any); ok {
res = append(res, slice...)
} else {
res = append(res, item)
}
}
}
if res == nil {
return []any{}
}
return res
}
// GetMCPToolsList is a JSON-RPC harness that fetches the tools/list registry.
func GetMCPToolsList(t *testing.T, requestHeader map[string]string) (int, []any, error) {
headers := NewMCPRequestHeader(t, requestHeader)
req := MCPListToolsRequest{
Jsonrpc: jsonrpc.JSONRPC_VERSION,
Id: uuid.New().String(),
Method: v20251125.TOOLS_LIST,
}
reqBody, err := json.Marshal(req)
if err != nil {
t.Fatalf("error marshalling tools/list request body: %v", err)
}
resp, respBody := RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/mcp", bytes.NewBuffer(reqBody), headers)
var mcpResp jsonrpc.JSONRPCResponse
if err := json.Unmarshal(respBody, &mcpResp); err != nil {
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, nil, fmt.Errorf("%s", string(respBody))
}
t.Fatalf("error parsing tools/list response: %v\nraw body: %s", err, string(respBody))
}
resultMap, ok := mcpResp.Result.(map[string]any)
if !ok {
t.Fatalf("tools/list result is not a map: %v", mcpResp.Result)
}
toolsList, ok := resultMap["tools"].([]any)
if !ok {
t.Fatalf("tools/list did not contain tools array: %v", resultMap)
}
return resp.StatusCode, toolsList, nil
}
// AssertMCPError asserts that the response contains an error covering the expected message.
func AssertMCPError(t *testing.T, mcpResp *MCPCallToolResponse, wantErrMsg string) {
t.Helper()
var errText string
if mcpResp.Error != nil {
errText = mcpResp.Error.Message
} else if mcpResp.Result.IsError {
for _, content := range mcpResp.Result.Content {
if content.Type == "text" {
errText += content.Text
}
}
} else {
t.Fatalf("expected error containing %q, but got success result: %v", wantErrMsg, mcpResp.Result)
}
if !strings.Contains(errText, wantErrMsg) {
t.Fatalf("expected error text containing %q, got %q", wantErrMsg, errText)
}
}
// RunMCPToolsListMethod calls tools/list and verifies that the returned tools match the expected list.
func RunMCPToolsListMethod(t *testing.T, expectedOutput []MCPToolManifest) {
t.Helper()
statusCodeList, toolsList, errList := GetMCPToolsList(t, nil)
if errList != nil {
t.Fatalf("native error executing tools/list: %s", errList)
}
if statusCodeList != http.StatusOK {
t.Fatalf("expected status 200 for tools/list, got %d", statusCodeList)
}
// Unmarshal toolsList into []MCPToolManifest
toolsJSON, err := json.Marshal(toolsList)
if err != nil {
t.Fatalf("error marshalling tools list: %v", err)
}
var actualTools []MCPToolManifest
if err := json.Unmarshal(toolsJSON, &actualTools); err != nil {
t.Fatalf("error unmarshalling tools into MCPToolManifest: %v", err)
}
if len(actualTools) != len(expectedOutput) {
t.Fatalf("expected %d tools, got %d. Actual tools: %+v", len(expectedOutput), len(actualTools), actualTools)
}
for _, expected := range expectedOutput {
found := false
for _, actual := range actualTools {
if actual.Name == expected.Name {
found = true
// Use reflect.DeepEqual to check all fields (description, parameters, etc.)
if !reflect.DeepEqual(actual, expected) {
t.Fatalf("tool %s mismatch:\nwant: %+v\ngot: %+v", expected.Name, expected, actual)
}
break
}
}
if !found {
t.Fatalf("tool %s was not found in the tools/list registry", expected.Name)
}
}
}
// RunMCPCustomToolCallMethod invokes a tool and compares the result with expected output.
func RunMCPCustomToolCallMethod(t *testing.T, toolName string, arguments map[string]any, want string) {
t.Helper()
statusCode, mcpResp, err := InvokeMCPTool(t, toolName, arguments, nil)
if err != nil {
t.Fatalf("native error executing %s: %s", toolName, err)
}
if statusCode != http.StatusOK {
t.Fatalf("expected status 200, got %d", statusCode)
}
if mcpResp.Result.IsError {
t.Fatalf("%s returned error result: %v", toolName, mcpResp.Result)
}
got := getMCPResultText(t, mcpResp)
gotBytes, _ := json.Marshal(got)
gotStr := string(gotBytes)
if !strings.Contains(gotStr, want) {
t.Fatalf(`expected %q to contain %q`, gotStr, want)
}
}
// RunMCPToolInvokeTest runs the tool invoke test cases over MCP protocol.
func RunMCPToolInvokeTest(t *testing.T, select1Want string, options ...InvokeTestOption) {
t.Helper()
// Resolve options using existing InvokeTestOption and InvokeTestConfig from option.go
configs := &InvokeTestConfig{
myToolId3NameAliceWant: "[{\"id\":1,\"name\":\"Alice\"},{\"id\":3,\"name\":\"Sid\"}]",
myToolById4Want: "[{\"id\":4,\"name\":null}]",
myArrayToolWant: "[{\"id\":1,\"name\":\"Alice\"},{\"id\":3,\"name\":\"Sid\"}]",
nullWant: "[null]",
supportOptionalNullParam: true,
supportArrayParam: true,
supportClientAuth: false,
supportSelect1Want: true,
supportSelect1Auth: true,
}
for _, option := range options {
option(configs)
}
invokeTcs := []struct {
name string
toolName string
args map[string]any
headers map[string]string
enabled bool
wantResult string // for success cases
wantError string // for failure cases
}{
{
name: "invoke my-simple-tool",
toolName: "my-simple-tool",
args: map[string]any{},
enabled: configs.supportSelect1Want,
wantResult: select1Want,
},
{
name: "invoke my-tool",
toolName: "my-tool",
args: map[string]any{"id": 3, "name": "Alice"},
enabled: true,
wantResult: configs.myToolId3NameAliceWant,
},
{
name: "invoke my-tool-by-id with nil response",
toolName: "my-tool-by-id",
args: map[string]any{"id": 4},
enabled: true,
wantResult: configs.myToolById4Want,
},
{
name: "invoke my-tool-by-name with nil response",
toolName: "my-tool-by-name",
args: map[string]any{},
enabled: configs.supportOptionalNullParam,
wantResult: configs.nullWant,
},
{
name: "Invoke my-tool without parameters",
toolName: "my-tool",
args: map[string]any{},
enabled: true,
wantError: `parameter "id" is required`,
},
{
name: "Invoke my-tool with insufficient parameters",
toolName: "my-tool",
args: map[string]any{"id": 1},
enabled: true,
wantError: `parameter "name" is required`,
},
}
for _, tc := range invokeTcs {
t.Run(tc.name, func(t *testing.T) {
if !tc.enabled {
t.Skip("skipping disabled test case")
}
statusCode, mcpResp, err := InvokeMCPTool(t, tc.toolName, tc.args, tc.headers)
if err != nil {
t.Fatalf("native error executing %s: %s", tc.toolName, err)
}
if statusCode != http.StatusOK {
t.Fatalf("expected status 200, got %d", statusCode)
}
if tc.wantError != "" {
AssertMCPError(t, mcpResp, tc.wantError)
return
}
if mcpResp.Result.IsError {
t.Fatalf("%s returned error result: %v", tc.toolName, mcpResp.Result)
}
got := getMCPResultText(t, mcpResp)
gotBytes, _ := json.Marshal(got)
gotStr := string(gotBytes)
if !strings.Contains(gotStr, tc.wantResult) {
t.Fatalf(`expected %q to contain %q`, gotStr, tc.wantResult)
}
})
}
}
// GetBaseMCPExpectedTools returns the MCP manifests for the base tools loaded by GetToolsConfig.
func GetBaseMCPExpectedTools() []MCPToolManifest {
return []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: "Tool to test invocation with params.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "integer", "description": "user ID"},
"name": map[string]any{"type": "string", "description": "user name"},
},
"required": []any{"id", "name"},
},
},
{
Name: "my-tool-by-id",
Description: "Tool to test invocation with params.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{"id": map[string]any{"type": "integer", "description": "user ID"}},
"required": []any{"id"},
},
},
{
Name: "my-tool-by-name",
Description: "Tool to test invocation with params.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{"name": map[string]any{"type": "string", "description": "user name"}},
"required": []any{},
},
},
{
Name: "my-array-tool",
Description: "Tool to test invocation with array params.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"idArray": map[string]any{"type": "array", "description": "ID array", "items": map[string]any{"type": "integer", "description": "ID"}},
"nameArray": map[string]any{"type": "array", "description": "user name array", "items": map[string]any{"type": "string", "description": "user name"}},
},
"required": []any{"idArray", "nameArray"},
},
},
{
Name: "my-auth-tool",
Description: "Tool to test authenticated parameters.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{"email": map[string]any{"type": "string", "description": "user email"}},
"required": []any{"email"},
},
},
{
Name: "my-auth-required-tool",
Description: "Tool to test auth required invocation.",
InputSchema: map[string]any{"type": "object", "properties": map[string]any{}, "required": []any{}},
},
{
Name: "my-fail-tool",
Description: "Tool to test statement with incorrect syntax.",
InputSchema: map[string]any{"type": "object", "properties": map[string]any{}, "required": []any{}},
},
}
}
// GetExecuteSQLMCPExpectedTools returns the MCP manifests for the tools loaded by AddExecuteSqlConfig.
func GetExecuteSQLMCPExpectedTools() []MCPToolManifest {
return []MCPToolManifest{
{
Name: "my-exec-sql-tool",
Description: "Tool to execute sql",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{"sql": map[string]any{"type": "string", "description": "The sql to execute."}},
"required": []any{"sql"},
},
},
{
Name: "my-auth-exec-sql-tool",
Description: "Tool to execute sql",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{"sql": map[string]any{"type": "string", "description": "The sql to execute."}},
"required": []any{"sql"},
},
},
}
}
// GetTemplateParamMCPExpectedTools returns the MCP manifests for the tools loaded by AddTemplateParamConfig.
func GetTemplateParamMCPExpectedTools() []MCPToolManifest {
return []MCPToolManifest{
{
Name: "create-table-templateParams-tool",
Description: "Create table tool with template parameters",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"tableName": map[string]any{"type": "string", "description": "some description"},
"columns": map[string]any{"type": "array", "description": "The columns to create", "items": map[string]any{"type": "string", "description": "A column name that will be created"}},
},
"required": []any{"tableName", "columns"},
},
},
{
Name: "insert-table-templateParams-tool",
Description: "Insert tool with template parameters",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"tableName": map[string]any{"type": "string", "description": "some description"},
"columns": map[string]any{"type": "array", "description": "The columns to insert into", "items": map[string]any{"type": "string", "description": "A column name that will be returned from the query."}},
"values": map[string]any{"type": "string", "description": "The values to insert as a comma separated string"},
},
"required": []any{"tableName", "columns", "values"},
},
},
{
Name: "select-templateParams-tool",
Description: "Create table tool with template parameters",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{"tableName": map[string]any{"type": "string", "description": "some description"}},
"required": []any{"tableName"},
},
},
{
Name: "select-templateParams-combined-tool",
Description: "Create table tool with template parameters",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "integer", "description": "the id of the user"},
"tableName": map[string]any{"type": "string", "description": "some description"},
},
"required": []any{"id", "tableName"},
},
},
{
Name: "select-fields-templateParams-tool",
Description: "Create table tool with template parameters",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"tableName": map[string]any{"type": "string", "description": "some description"},
"fields": map[string]any{"type": "array", "description": "The fields to select from", "items": map[string]any{"type": "string", "description": "A field that will be returned from the query."}},
},
"required": []any{"tableName", "fields"},
},
},
{
Name: "select-filter-templateParams-combined-tool",
Description: "Create table tool with template parameters",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string", "description": "the name of the user"},
"tableName": map[string]any{"type": "string", "description": "some description"},
"columnFilter": map[string]any{"type": "string", "description": "some description"},
},
"required": []any{"name", "tableName", "columnFilter"},
},
},
{
Name: "drop-table-templateParams-tool",
Description: "Drop table tool with template parameters",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{"tableName": map[string]any{"type": "string", "description": "some description"}},
"required": []any{"tableName"},
},
},
}
}