Skip to content

Commit 52146c5

Browse files
anubhav756Yuan325
andauthored
test(harness): implement MCP JSON-RPC test harness (#2828)
## Overview This PR introduces the native MCP test harness, enabling us to run integration tests using the JSON-RPC protocol over `/mcp`. ## Changes - Added `tests/mcp_execute.go` and `tests/mcp_types.go` as the core harness for invoking MCP tools and asserting responses. - Updated `tests/server.go` to support dynamic flags like `--enable-api`. - Added unit/integration tests in `internal/server/server_test.go` to verify the harness itself. ## Checklist - [x] Ensure the tests and linter pass --------- Co-authored-by: Yuan Teoh <45984206+Yuan325@users.noreply.github.com>
1 parent c3059c2 commit 52146c5

File tree

6 files changed

+346
-11
lines changed

6 files changed

+346
-11
lines changed

internal/server/common_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ var tool4 = MockTool{
6868
}
6969

7070
var tool5 = MockTool{
71-
Name: "require_client_auth_tool",
72-
Params: []parameters.Parameter{},
73-
requiresClientAuthrorization: true,
71+
Name: "require_client_auth_tool",
72+
Params: []parameters.Parameter{},
73+
requiresClientAuthorization: true,
7474
}
7575

7676
var prompt1 = MockPrompt{

internal/server/mocks.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ import (
2727

2828
// MockTool is used to mock tools in tests
2929
type MockTool struct {
30-
Name string
31-
Description string
32-
Params []parameters.Parameter
33-
manifest tools.Manifest
34-
unauthorized bool
35-
requiresClientAuthrorization bool
30+
Name string
31+
Description string
32+
Params []parameters.Parameter
33+
manifest tools.Manifest
34+
unauthorized bool
35+
requiresClientAuthorization bool
3636
}
3737

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

6969
func (t MockTool) RequiresClientAuthorization(tools.SourceProvider) (bool, error) {
7070
// defaulted to false
71-
return t.requiresClientAuthrorization, nil
71+
return t.requiresClientAuthorization, nil
7272
}
7373

7474
func (t MockTool) GetParameters() parameters.Parameters {

internal/server/server_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,3 +476,74 @@ func TestPRMOverride(t *testing.T) {
476476
t.Errorf("expected resource 'https://override.example.com', got '%v'", got["resource"])
477477
}
478478
}
479+
480+
// TestLegacyAPIGone verifies that requests to legacy /api/* endpoints return 410 Gone.
481+
func TestLegacyAPIGone(t *testing.T) {
482+
ctx, cancel := context.WithCancel(context.Background())
483+
defer cancel()
484+
485+
// Setup Logging and Instrumentation (Using Discard to act as Noop)
486+
testLogger, err := log.NewStdLogger(io.Discard, io.Discard, "info")
487+
if err != nil {
488+
t.Fatalf("unexpected error: %s", err)
489+
}
490+
ctx = util.WithLogger(ctx, testLogger)
491+
492+
instrumentation, err := telemetry.CreateTelemetryInstrumentation("0.0.0")
493+
if err != nil {
494+
t.Fatalf("unexpected error: %s", err)
495+
}
496+
ctx = util.WithInstrumentation(ctx, instrumentation)
497+
498+
// Configure the server (EnableAPI defaults to false)
499+
addr, port := "127.0.0.1", 5003
500+
cfg := server.ServerConfig{
501+
Version: "0.0.0",
502+
Address: addr,
503+
Port: port,
504+
AllowedHosts: []string{"*"},
505+
}
506+
507+
// Initialize and Start the Server
508+
s, err := server.NewServer(ctx, cfg)
509+
if err != nil {
510+
t.Fatalf("unable to initialize server: %v", err)
511+
}
512+
513+
if err := s.Listen(ctx); err != nil {
514+
t.Fatalf("unable to start listener: %v", err)
515+
}
516+
517+
go func() {
518+
if err := s.Serve(ctx); err != nil && err != http.ErrServerClosed {
519+
fmt.Printf("Server serve error: %v\n", err)
520+
}
521+
}()
522+
defer func() {
523+
if err := s.Shutdown(ctx); err != nil {
524+
t.Errorf("failed to cleanly shutdown server: %v", err)
525+
}
526+
}()
527+
528+
// Perform the request to a legacy endpoint
529+
url := fmt.Sprintf("http://%s:%d/api/tool/list", addr, port)
530+
resp, err := http.Get(url)
531+
if err != nil {
532+
t.Fatalf("error when sending request: %s", err)
533+
}
534+
defer resp.Body.Close()
535+
536+
if resp.StatusCode != http.StatusGone {
537+
t.Fatalf("expected status 410 (Gone), got %d", resp.StatusCode)
538+
}
539+
540+
body, err := io.ReadAll(resp.Body)
541+
if err != nil {
542+
t.Fatalf("error reading body: %s", err)
543+
}
544+
545+
want := "/api native endpoints are disabled by default. Please use the standard /mcp JSON-RPC endpoint"
546+
if !strings.Contains(string(body), want) {
547+
t.Errorf("expected response body to contain %q, got %q", want, string(body))
548+
}
549+
}

tests/mcp_tool.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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 tests
16+
17+
import (
18+
"bytes"
19+
"encoding/json"
20+
"fmt"
21+
"net/http"
22+
"reflect"
23+
"strings"
24+
"testing"
25+
26+
"github.com/google/uuid"
27+
28+
"github.com/googleapis/genai-toolbox/internal/server/mcp/jsonrpc"
29+
v20251125 "github.com/googleapis/genai-toolbox/internal/server/mcp/v20251125"
30+
)
31+
32+
// NewMCPRequestHeader takes custom headers and appends headers required for MCP.
33+
func NewMCPRequestHeader(t *testing.T, customHeaders map[string]string) map[string]string {
34+
headers := make(map[string]string)
35+
for k, v := range customHeaders {
36+
headers[k] = v
37+
}
38+
headers["Content-Type"] = "application/json"
39+
headers["MCP-Protocol-Version"] = v20251125.PROTOCOL_VERSION
40+
return headers
41+
}
42+
43+
// InvokeMCPTool is a transparent, native JSON-RPC execution harness for tests.
44+
func InvokeMCPTool(t *testing.T, toolName string, arguments map[string]any, requestHeader map[string]string) (int, *MCPCallToolResponse, error) {
45+
headers := NewMCPRequestHeader(t, requestHeader)
46+
47+
req := NewMCPCallToolRequest(uuid.New().String(), toolName, arguments)
48+
reqBody, err := json.Marshal(req)
49+
if err != nil {
50+
t.Fatalf("error marshalling request body: %v", err)
51+
}
52+
53+
resp, respBody := RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/mcp", bytes.NewBuffer(reqBody), headers)
54+
55+
var mcpResp MCPCallToolResponse
56+
if err := json.Unmarshal(respBody, &mcpResp); err != nil {
57+
if resp.StatusCode != http.StatusOK {
58+
return resp.StatusCode, nil, fmt.Errorf("%s", string(respBody))
59+
}
60+
t.Fatalf("error parsing mcp response body: %v\nraw body: %s", err, string(respBody))
61+
}
62+
63+
return resp.StatusCode, &mcpResp, nil
64+
}
65+
66+
// GetMCPToolsList is a JSON-RPC harness that fetches the tools/list registry.
67+
func GetMCPToolsList(t *testing.T, requestHeader map[string]string) (int, []any, error) {
68+
headers := NewMCPRequestHeader(t, requestHeader)
69+
70+
req := MCPListToolsRequest{
71+
Jsonrpc: jsonrpc.JSONRPC_VERSION,
72+
Id: uuid.New().String(),
73+
Method: v20251125.TOOLS_LIST,
74+
}
75+
reqBody, err := json.Marshal(req)
76+
if err != nil {
77+
t.Fatalf("error marshalling tools/list request body: %v", err)
78+
}
79+
80+
resp, respBody := RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/mcp", bytes.NewBuffer(reqBody), headers)
81+
82+
var mcpResp jsonrpc.JSONRPCResponse
83+
if err := json.Unmarshal(respBody, &mcpResp); err != nil {
84+
if resp.StatusCode != http.StatusOK {
85+
return resp.StatusCode, nil, fmt.Errorf("%s", string(respBody))
86+
}
87+
t.Fatalf("error parsing tools/list response: %v\nraw body: %s", err, string(respBody))
88+
}
89+
90+
resultMap, ok := mcpResp.Result.(map[string]any)
91+
if !ok {
92+
t.Fatalf("tools/list result is not a map: %v", mcpResp.Result)
93+
}
94+
95+
toolsList, ok := resultMap["tools"].([]any)
96+
if !ok {
97+
t.Fatalf("tools/list did not contain tools array: %v", resultMap)
98+
}
99+
100+
return resp.StatusCode, toolsList, nil
101+
}
102+
103+
// AssertMCPError asserts that the response contains an error covering the expected message.
104+
func AssertMCPError(t *testing.T, mcpResp *MCPCallToolResponse, wantErrMsg string) {
105+
t.Helper()
106+
var errText string
107+
if mcpResp.Error != nil {
108+
errText = mcpResp.Error.Message
109+
} else if mcpResp.Result.IsError {
110+
for _, content := range mcpResp.Result.Content {
111+
if content.Type == "text" {
112+
errText += content.Text
113+
}
114+
}
115+
} else {
116+
t.Fatalf("expected error containing %q, but got success result: %v", wantErrMsg, mcpResp.Result)
117+
}
118+
119+
if !strings.Contains(errText, wantErrMsg) {
120+
t.Fatalf("expected error text containing %q, got %q", wantErrMsg, errText)
121+
}
122+
}
123+
124+
// RunMCPToolsListMethod calls tools/list and verifies that the returned tools match the expected list.
125+
func RunMCPToolsListMethod(t *testing.T, expectedOutput []MCPToolManifest) {
126+
t.Helper()
127+
statusCodeList, toolsList, errList := GetMCPToolsList(t, nil)
128+
if errList != nil {
129+
t.Fatalf("native error executing tools/list: %s", errList)
130+
}
131+
if statusCodeList != http.StatusOK {
132+
t.Fatalf("expected status 200 for tools/list, got %d", statusCodeList)
133+
}
134+
135+
// Unmarshal toolsList into []MCPToolManifest
136+
toolsJSON, err := json.Marshal(toolsList)
137+
if err != nil {
138+
t.Fatalf("error marshalling tools list: %v", err)
139+
}
140+
141+
var actualTools []MCPToolManifest
142+
if err := json.Unmarshal(toolsJSON, &actualTools); err != nil {
143+
t.Fatalf("error unmarshalling tools into MCPToolManifest: %v", err)
144+
}
145+
146+
for _, expected := range expectedOutput {
147+
found := false
148+
for _, actual := range actualTools {
149+
if actual.Name == expected.Name {
150+
found = true
151+
// Use reflect.DeepEqual to check all fields (description, parameters, etc.)
152+
if !reflect.DeepEqual(actual, expected) {
153+
t.Fatalf("tool %s mismatch:\nwant: %+v\ngot: %+v", expected.Name, expected, actual)
154+
}
155+
break
156+
}
157+
}
158+
if !found {
159+
t.Fatalf("tool %s was not found in the tools/list registry", expected.Name)
160+
}
161+
}
162+
}
163+
164+
// RunMCPCustomToolCallMethod invokes a tool and compares the result with expected output.
165+
func RunMCPCustomToolCallMethod(t *testing.T, toolName string, arguments map[string]any, want string) {
166+
t.Helper()
167+
statusCode, mcpResp, err := InvokeMCPTool(t, toolName, arguments, nil)
168+
if err != nil {
169+
t.Fatalf("native error executing %s: %s", toolName, err)
170+
}
171+
if statusCode != http.StatusOK {
172+
t.Fatalf("expected status 200, got %d", statusCode)
173+
}
174+
if mcpResp.Result.IsError {
175+
t.Fatalf("%s returned error result: %v", toolName, mcpResp.Result)
176+
}
177+
if len(mcpResp.Result.Content) == 0 {
178+
t.Fatalf("%s returned empty content field", toolName)
179+
}
180+
got := mcpResp.Result.Content[0].Text
181+
if !strings.Contains(got, want) {
182+
t.Fatalf(`expected %q to contain %q`, got, want)
183+
}
184+
}

tests/mcp_types.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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 tests
16+
17+
import (
18+
"github.com/googleapis/genai-toolbox/internal/server/mcp/jsonrpc"
19+
v20251125 "github.com/googleapis/genai-toolbox/internal/server/mcp/v20251125"
20+
)
21+
22+
// CallToolParams represents the internal payload of an MCP tool call request
23+
type CallToolParams struct {
24+
Name string `json:"name"`
25+
Arguments map[string]any `json:"arguments,omitempty"`
26+
}
27+
28+
// MCPListToolsRequest encapsulates the standard JSON-RPC request format targeting tools/list
29+
type MCPListToolsRequest struct {
30+
Jsonrpc string `json:"jsonrpc"`
31+
Id jsonrpc.RequestId `json:"id"`
32+
Method string `json:"method"`
33+
}
34+
35+
// MCPCallToolRequest encapsulates the standard JSON-RPC request format targeting tools/call
36+
type MCPCallToolRequest struct {
37+
Jsonrpc string `json:"jsonrpc"`
38+
Id jsonrpc.RequestId `json:"id"`
39+
Method string `json:"method"`
40+
Params CallToolParams `json:"params"`
41+
}
42+
43+
// MCPCallToolResponse provides a strongly-typed unmarshal target for MCP tool call results,
44+
// bypassing the generic interface{} Result used in the standard jsonrpc.JSONRPCResponse.
45+
type MCPCallToolResponse struct {
46+
Jsonrpc string `json:"jsonrpc"`
47+
Id jsonrpc.RequestId `json:"id"`
48+
Result v20251125.CallToolResult `json:"result,omitempty"`
49+
Error *jsonrpc.Error `json:"error,omitempty"`
50+
}
51+
52+
// NewMCPCallToolRequest is a helper to quickly generate a standard jsonrpc request payload.
53+
func NewMCPCallToolRequest(id jsonrpc.RequestId, toolName string, args map[string]any) MCPCallToolRequest {
54+
return MCPCallToolRequest{
55+
Jsonrpc: jsonrpc.JSONRPC_VERSION,
56+
Id: id,
57+
Method: v20251125.TOOLS_CALL,
58+
Params: CallToolParams{
59+
Name: toolName,
60+
Arguments: args,
61+
},
62+
}
63+
}
64+
65+
// MCPListToolsResponse is a strongly-typed unmarshal target for tools/list results
66+
type MCPListToolsResponse struct {
67+
Jsonrpc string `json:"jsonrpc"`
68+
Id jsonrpc.RequestId `json:"id"`
69+
Result struct {
70+
Tools []MCPToolManifest `json:"tools"`
71+
} `json:"result,omitempty"`
72+
Error *jsonrpc.Error `json:"error,omitempty"`
73+
}
74+
75+
// MCPToolManifest is a copy of tools.McpManifest used for integration testing purposes
76+
type MCPToolManifest struct {
77+
Name string `json:"name"`
78+
Description string `json:"description,omitempty"`
79+
InputSchema map[string]any `json:"inputSchema,omitempty"`
80+
}

tests/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ func StartCmd(ctx context.Context, toolsFile map[string]any, args ...string) (*C
6868
if err != nil {
6969
return nil, nil, fmt.Errorf("unable to write config: %s", err)
7070
}
71+
7172
args = append(args, "--config", path)
7273

7374
ctx, cancel := context.WithCancel(ctx)
@@ -96,7 +97,6 @@ func StartCmd(ctx context.Context, toolsFile map[string]any, args ...string) (*C
9697
t.err = c.ExecuteContext(ctx)
9798
}()
9899
return t, cleanup, nil
99-
100100
}
101101

102102
// Stop sends the TERM signal to the cmd and returns.

0 commit comments

Comments
 (0)