Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions internal/server/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ var tool4 = MockTool{
}

var tool5 = MockTool{
Name: "require_client_auth_tool",
Params: []parameters.Parameter{},
requiresClientAuthrorization: true,
Name: "require_client_auth_tool",
Params: []parameters.Parameter{},
requiresClientAuthorization: true,
}

var prompt1 = MockPrompt{
Expand Down
14 changes: 7 additions & 7 deletions internal/server/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ import (

// MockTool is used to mock tools in tests
type MockTool struct {
Name string
Description string
Params []parameters.Parameter
manifest tools.Manifest
unauthorized bool
requiresClientAuthrorization bool
Name string
Description string
Params []parameters.Parameter
manifest tools.Manifest
unauthorized bool
requiresClientAuthorization bool
}

func (t MockTool) Invoke(context.Context, tools.SourceProvider, parameters.ParamValues, tools.AccessToken) (any, util.ToolboxError) {
Expand Down Expand Up @@ -68,7 +68,7 @@ func (t MockTool) Authorized(verifiedAuthServices []string) bool {

func (t MockTool) RequiresClientAuthorization(tools.SourceProvider) (bool, error) {
// defaulted to false
return t.requiresClientAuthrorization, nil
return t.requiresClientAuthorization, nil
}

func (t MockTool) GetParameters() parameters.Parameters {
Expand Down
71 changes: 71 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,3 +476,74 @@ func TestPRMOverride(t *testing.T) {
t.Errorf("expected resource 'https://override.example.com', got '%v'", got["resource"])
}
}

// TestLegacyAPIGone verifies that requests to legacy /api/* endpoints return 410 Gone.
func TestLegacyAPIGone(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Setup Logging and Instrumentation (Using Discard to act as Noop)
testLogger, err := log.NewStdLogger(io.Discard, io.Discard, "info")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
ctx = util.WithLogger(ctx, testLogger)

instrumentation, err := telemetry.CreateTelemetryInstrumentation("0.0.0")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
ctx = util.WithInstrumentation(ctx, instrumentation)

// Configure the server (EnableAPI defaults to false)
addr, port := "127.0.0.1", 5003
cfg := server.ServerConfig{
Version: "0.0.0",
Address: addr,
Port: port,
AllowedHosts: []string{"*"},
}

// Initialize and Start the Server
s, err := server.NewServer(ctx, cfg)
if err != nil {
t.Fatalf("unable to initialize server: %v", err)
}

if err := s.Listen(ctx); err != nil {
t.Fatalf("unable to start listener: %v", err)
}

go func() {
if err := s.Serve(ctx); err != nil && err != http.ErrServerClosed {
fmt.Printf("Server serve error: %v\n", err)
}
}()
defer func() {
if err := s.Shutdown(ctx); err != nil {
t.Errorf("failed to cleanly shutdown server: %v", err)
}
}()

// Perform the request to a legacy endpoint
url := fmt.Sprintf("http://%s:%d/api/tool/list", addr, port)
resp, err := http.Get(url)
if err != nil {
t.Fatalf("error when sending request: %s", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusGone {
t.Fatalf("expected status 410 (Gone), got %d", resp.StatusCode)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("error reading body: %s", err)
}

want := "/api native endpoints are disabled by default. Please use the standard /mcp JSON-RPC endpoint"
if !strings.Contains(string(body), want) {
t.Errorf("expected response body to contain %q, got %q", want, string(body))
}
}
184 changes: 184 additions & 0 deletions tests/mcp_tool.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// 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"
"net/http"
"reflect"
"strings"
"testing"

"github.com/google/uuid"

"github.com/googleapis/genai-toolbox/internal/server/mcp/jsonrpc"
v20251125 "github.com/googleapis/genai-toolbox/internal/server/mcp/v20251125"
)

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

// 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)
}

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)
}
if len(mcpResp.Result.Content) == 0 {
t.Fatalf("%s returned empty content field", toolName)
}
got := mcpResp.Result.Content[0].Text
if !strings.Contains(got, want) {
t.Fatalf(`expected %q to contain %q`, got, want)
}
}
80 changes: 80 additions & 0 deletions tests/mcp_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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 (
"github.com/googleapis/genai-toolbox/internal/server/mcp/jsonrpc"
v20251125 "github.com/googleapis/genai-toolbox/internal/server/mcp/v20251125"
)

// CallToolParams represents the internal payload of an MCP tool call request
type CallToolParams struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments,omitempty"`
}

// MCPListToolsRequest encapsulates the standard JSON-RPC request format targeting tools/list
type MCPListToolsRequest struct {
Jsonrpc string `json:"jsonrpc"`
Id jsonrpc.RequestId `json:"id"`
Method string `json:"method"`
}

// MCPCallToolRequest encapsulates the standard JSON-RPC request format targeting tools/call
type MCPCallToolRequest struct {
Jsonrpc string `json:"jsonrpc"`
Id jsonrpc.RequestId `json:"id"`
Method string `json:"method"`
Params CallToolParams `json:"params"`
}

// MCPCallToolResponse provides a strongly-typed unmarshal target for MCP tool call results,
// bypassing the generic interface{} Result used in the standard jsonrpc.JSONRPCResponse.
type MCPCallToolResponse struct {
Jsonrpc string `json:"jsonrpc"`
Id jsonrpc.RequestId `json:"id"`
Result v20251125.CallToolResult `json:"result,omitempty"`
Error *jsonrpc.Error `json:"error,omitempty"`
}

// NewMCPCallToolRequest is a helper to quickly generate a standard jsonrpc request payload.
func NewMCPCallToolRequest(id jsonrpc.RequestId, toolName string, args map[string]any) MCPCallToolRequest {
return MCPCallToolRequest{
Jsonrpc: jsonrpc.JSONRPC_VERSION,
Id: id,
Method: v20251125.TOOLS_CALL,
Params: CallToolParams{
Name: toolName,
Arguments: args,
},
}
}

// MCPListToolsResponse is a strongly-typed unmarshal target for tools/list results
type MCPListToolsResponse struct {
Jsonrpc string `json:"jsonrpc"`
Id jsonrpc.RequestId `json:"id"`
Result struct {
Tools []MCPToolManifest `json:"tools"`
} `json:"result,omitempty"`
Error *jsonrpc.Error `json:"error,omitempty"`
}

// MCPToolManifest is a copy of tools.McpManifest used for integration testing purposes
type MCPToolManifest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]any `json:"inputSchema,omitempty"`
}
2 changes: 1 addition & 1 deletion tests/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ func StartCmd(ctx context.Context, toolsFile map[string]any, args ...string) (*C
if err != nil {
return nil, nil, fmt.Errorf("unable to write config: %s", err)
}

args = append(args, "--config", path)

ctx, cancel := context.WithCancel(ctx)
Expand Down Expand Up @@ -96,7 +97,6 @@ func StartCmd(ctx context.Context, toolsFile map[string]any, args ...string) (*C
t.err = c.ExecuteContext(ctx)
}()
return t, cleanup, nil

}

// Stop sends the TERM signal to the cmd and returns.
Expand Down
Loading