Skip to content

Commit 77e10c4

Browse files
committed
test: introduce native MCP json-rpc harness
1 parent 163bba2 commit 77e10c4

File tree

2 files changed

+216
-0
lines changed

2 files changed

+216
-0
lines changed

tests/mcp_execute.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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+
"io"
22+
"net/http"
23+
"strings"
24+
"testing"
25+
26+
"github.com/google/uuid"
27+
28+
"github.com/googleapis/genai-toolbox/internal/server/mcp/jsonrpc"
29+
v20250618 "github.com/googleapis/genai-toolbox/internal/server/mcp/v20250618"
30+
)
31+
32+
// NewMCPRequestHeader takes custom headers and append 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"] = v20250618.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, *jsonrpc.JSONRPCResponse, error) {
68+
headers := NewMCPRequestHeader(t, requestHeader)
69+
70+
req := MCPListToolsRequest{
71+
Jsonrpc: jsonrpc.JSONRPC_VERSION,
72+
Id: uuid.New().String(),
73+
Method: v20250618.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+
return resp.StatusCode, &mcpResp, nil
91+
}
92+
93+
// ExecuteMCPToolCall is a helper function to send HTTP requests to MCP endpoint and return the response
94+
func ExecuteMCPToolCall(t *testing.T, toolName string, arguments map[string]any, requestHeader map[string]string) (int, string, error) {
95+
headers := NewMCPRequestHeader(t, requestHeader)
96+
97+
req := NewMCPCallToolRequest(uuid.New().String(), toolName, arguments)
98+
reqBody, err := json.Marshal(req)
99+
if err != nil {
100+
t.Fatalf("error marshalling request body: %v", err)
101+
}
102+
103+
resp, respBody := RunRequest(t, http.MethodPost, "http://127.0.0.1:5000/mcp", bytes.NewBuffer(reqBody), headers)
104+
105+
var mcpResp MCPCallToolResponse
106+
if err := json.Unmarshal(respBody, &mcpResp); err != nil {
107+
// If unmarshal fails on error HTTP code, bubble the exact string payload as error rather than crashing
108+
if resp.StatusCode != http.StatusOK {
109+
return resp.StatusCode, "", fmt.Errorf("%s", string(respBody))
110+
}
111+
t.Fatalf("error parsing mcp response body: %v\nraw body: %s", err, string(respBody))
112+
}
113+
if mcpResp.Error != nil {
114+
return resp.StatusCode, "", fmt.Errorf("%s", mcpResp.Error.Message)
115+
}
116+
117+
if mcpResp.Result.IsError {
118+
// If it's an application-level MCP tool failure, map it as an error text
119+
var errText string
120+
for _, c := range mcpResp.Result.Content {
121+
if c.Type == "text" {
122+
errText += c.Text
123+
}
124+
}
125+
return resp.StatusCode, strings.TrimSpace(errText), nil
126+
}
127+
if len(mcpResp.Result.Content) == 0 {
128+
return resp.StatusCode, "null", nil
129+
}
130+
131+
var textBlocks []string
132+
for _, c := range mcpResp.Result.Content {
133+
if c.Type == "text" {
134+
textBlocks = append(textBlocks, strings.TrimSpace(c.Text))
135+
}
136+
}
137+
138+
if len(textBlocks) == 0 {
139+
return resp.StatusCode, "null", nil
140+
}
141+
if len(textBlocks) == 1 {
142+
return resp.StatusCode, textBlocks[0], nil
143+
}
144+
145+
// For legacy assertions: if multiple blocks are returned and they look like JSON, wrap them into a JSON array
146+
first := textBlocks[0]
147+
if strings.HasPrefix(first, "{") || strings.HasPrefix(first, "[") || strings.HasPrefix(first, "\"") {
148+
return resp.StatusCode, "[" + strings.Join(textBlocks, ",") + "]", nil
149+
}
150+
151+
return resp.StatusCode, strings.Join(textBlocks, "\n"), nil
152+
}
153+
154+
// InterceptLegacyDo intercepts a hardcoded HTTP request destined for /api/tool/.../invoke,
155+
// dynamically converts it into a local ExecuteMCPToolCall, and wraps the
156+
// MCP string (or JSON-RPC logic error) inside a standard Go *http.Response.
157+
func InterceptLegacyDo(t *testing.T, req *http.Request) (*http.Response, error) {
158+
// If the request is natively meant for the modern /mcp endpoint, pass it through directly!
159+
if strings.HasPrefix(req.URL.Path, "/mcp") {
160+
return http.DefaultClient.Do(req)
161+
}
162+
163+
pathParts := strings.Split(req.URL.Path, "/")
164+
// e.g., /api/tool/cloud-gda-query/invoke -> length 5, tool is pathParts[3]
165+
if len(pathParts) < 4 || pathParts[2] != "tool" {
166+
t.Fatalf("InterceptLegacyDo: invalid or unsupported legacy URL path %s", req.URL.Path)
167+
}
168+
toolName := pathParts[3]
169+
170+
var reqBodyBytes []byte
171+
var err error
172+
if req.Body != nil {
173+
reqBodyBytes, err = io.ReadAll(req.Body)
174+
if err != nil {
175+
t.Fatalf("InterceptLegacyDo: failed to read body: %v", err)
176+
}
177+
req.Body = io.NopCloser(bytes.NewReader(reqBodyBytes))
178+
}
179+
180+
var args map[string]any
181+
if len(reqBodyBytes) > 0 {
182+
if err := json.Unmarshal(reqBodyBytes, &args); err != nil {
183+
t.Fatalf("InterceptLegacyDo: failed to unmarshal body to map[string]any: %v", err)
184+
}
185+
}
186+
187+
headers := map[string]string{}
188+
for k, v := range req.Header {
189+
if len(v) > 0 {
190+
headers[k] = v[0]
191+
}
192+
}
193+
194+
statusCode, resultStr, err := ExecuteMCPToolCall(t, toolName, args, headers)
195+
196+
var mockPayload []byte
197+
if err != nil {
198+
mockPayload = []byte(fmt.Sprintf(`{"error":%q}`, err.Error()))
199+
} else if statusCode != http.StatusOK {
200+
mockPayload = []byte(fmt.Sprintf(`{"error":%q}`, resultStr))
201+
} else {
202+
mockPayload = []byte(fmt.Sprintf(`{"result":%q}`, resultStr))
203+
}
204+
205+
return &http.Response{
206+
StatusCode: statusCode,
207+
Body: io.NopCloser(bytes.NewReader(mockPayload)),
208+
}, nil
209+
}

tests/mcp_types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ type CallToolParams struct {
2525
Arguments map[string]any `json:"arguments,omitempty"`
2626
}
2727

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+
2835
// MCPCallToolRequest encapsulates the standard JSON-RPC request format targeting tools/call
2936
type MCPCallToolRequest struct {
3037
Jsonrpc string `json:"jsonrpc"`

0 commit comments

Comments
 (0)