Skip to content

Commit ea98ec8

Browse files
committed
test: refactor integration test framework for full MCP validation
1 parent b5c866c commit ea98ec8

File tree

8 files changed

+1086
-245
lines changed

8 files changed

+1086
-245
lines changed

DEVELOPER.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ tools.
180180
the config defaults, if your source require test suites config updates, please
181181
refer to [config option](./tests/option.go):
182182
183-
1. [RunToolGetTest][tool-get]: tests for the `GET` endpoint that returns the
183+
1. [RunMCPToolInvokeTest][tool-invoke]: tests for the `/mcp` tools/call endpoint that executes the tool.
184184
tool's manifest.
185185
186186
2. [RunToolInvokeTest][tool-call]: tests for tool calling through the native

internal/server/api.go

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -308,29 +308,3 @@ func (rr resultResponse) Render(w http.ResponseWriter, r *http.Request) error {
308308
return nil
309309
}
310310

311-
var _ render.Renderer = &errResponse{} // Renderer interface for managing response payloads.
312-
313-
// newErrResponse is a helper function initializing an ErrResponse
314-
func newErrResponse(err error, code int) *errResponse {
315-
return &errResponse{
316-
Err: err,
317-
HTTPStatusCode: code,
318-
319-
StatusText: http.StatusText(code),
320-
ErrorText: err.Error(),
321-
}
322-
}
323-
324-
// errResponse is the response sent back when an error has been encountered.
325-
type errResponse struct {
326-
Err error `json:"-"` // low-level runtime error
327-
HTTPStatusCode int `json:"-"` // http response status code
328-
329-
StatusText string `json:"status"` // user-level status message
330-
ErrorText string `json:"error,omitempty"` // application-level error message, for debugging
331-
}
332-
333-
func (e *errResponse) Render(w http.ResponseWriter, r *http.Request) error {
334-
render.Status(r, e.HTTPStatusCode)
335-
return nil
336-
}

internal/server/mcp.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,31 @@ import (
4343
"go.opentelemetry.io/otel/trace"
4444
)
4545

46+
// newErrResponse is a helper function initalizing an ErrResponse
47+
func newErrResponse(err error, code int) *errResponse {
48+
return &errResponse{
49+
Err: err,
50+
HTTPStatusCode: code,
51+
52+
StatusText: http.StatusText(code),
53+
ErrorText: err.Error(),
54+
}
55+
}
56+
57+
// errResponse is the response sent back when an error has been encountered.
58+
type errResponse struct {
59+
Err error `json:"-"` // low-level runtime error
60+
HTTPStatusCode int `json:"-"` // http response status code
61+
62+
StatusText string `json:"status"` // user-level status message
63+
ErrorText string `json:"error,omitempty"` // application-level error message, for debugging
64+
}
65+
66+
func (e *errResponse) Render(w http.ResponseWriter, r *http.Request) error {
67+
render.Status(r, e.HTTPStatusCode)
68+
return nil
69+
}
70+
4671
type sseSession struct {
4772
writer http.ResponseWriter
4873
flusher http.Flusher

internal/server/mcp_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,3 +1184,4 @@ func TestSseManagerGetNonExistentSession(t *testing.T) {
11841184
t.Error("expected nil session for non-existent ID")
11851185
}
11861186
}
1187+

tests/auth.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ import (
2626
var ServiceAccountEmail = os.Getenv("SERVICE_ACCOUNT_EMAIL")
2727
var ClientId = os.Getenv("CLIENT_ID")
2828

29+
func init() {
30+
if ClientId == "" {
31+
ClientId = "test-client-id"
32+
}
33+
if ServiceAccountEmail == "" {
34+
ServiceAccountEmail = "test-service-account@example.com"
35+
}
36+
}
37+
2938
// GetGoogleIdToken retrieve and return the Google ID token
3039
func GetGoogleIdToken(audience string) (string, error) {
3140
// For local testing - use gcloud command to print personal ID token

tests/http/http_integration_test.go

Lines changed: 28 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@
1515
package http
1616

1717
import (
18-
"bytes"
18+
1919
"context"
20+
"os"
2021
"encoding/json"
2122
"fmt"
2223
"io"
@@ -325,178 +326,34 @@ func TestHttpToolEndpoints(t *testing.T) {
325326
}
326327

327328
// Run tests
328-
tests.RunToolGetTest(t)
329-
tests.RunToolInvokeTest(t, `"hello world"`, tests.DisableArrayTest())
329+
330+
tests.RunMCPToolInvokeTest(t, `"hello world"`, tests.DisableArrayTest())
330331
runAdvancedHTTPInvokeTest(t)
331332
runQueryParamInvokeTest(t)
332333
}
333334

334-
// runQueryParamInvokeTest runs the tool invoke endpoint for the query param test tool
335335
func runQueryParamInvokeTest(t *testing.T) {
336-
invokeTcs := []struct {
337-
name string
338-
api string
339-
requestBody io.Reader
340-
want string
341-
isErr bool
342-
}{
343-
{
344-
name: "invoke query-param-tool (optional omitted)",
345-
api: "http://127.0.0.1:5000/api/tool/my-query-param-tool/invoke",
346-
requestBody: bytes.NewBuffer([]byte(`{"reqId": "test1"}`)),
347-
want: `"reqId=test1"`,
348-
},
349-
{
350-
name: "invoke query-param-tool (some optional nil)",
351-
api: "http://127.0.0.1:5000/api/tool/my-query-param-tool/invoke",
352-
requestBody: bytes.NewBuffer([]byte(`{"reqId": "test2", "page": "5", "filter": null}`)),
353-
want: `"page=5\u0026reqId=test2"`, // 'filter' omitted
354-
},
355-
{
356-
name: "invoke query-param-tool (some optional absent)",
357-
api: "http://127.0.0.1:5000/api/tool/my-query-param-tool/invoke",
358-
requestBody: bytes.NewBuffer([]byte(`{"reqId": "test2", "page": "5"}`)),
359-
want: `"page=5\u0026reqId=test2"`, // 'filter' omitted
360-
},
361-
{
362-
name: "invoke query-param-tool (required param nil)",
363-
api: "http://127.0.0.1:5000/api/tool/my-query-param-tool/invoke",
364-
requestBody: bytes.NewBuffer([]byte(`{"reqId": null, "page": "1"}`)),
365-
want: `{"error":"parameter \"reqId\" is required"}`,
366-
},
367-
}
368-
for _, tc := range invokeTcs {
369-
t.Run(tc.name, func(t *testing.T) {
370-
// Send Tool invocation request
371-
req, err := http.NewRequest(http.MethodPost, tc.api, tc.requestBody)
372-
if err != nil {
373-
t.Fatalf("unable to create request: %s", err)
374-
}
375-
req.Header.Add("Content-type", "application/json")
376-
377-
resp, err := http.DefaultClient.Do(req)
378-
if err != nil {
379-
t.Fatalf("unable to send request: %s", err)
380-
}
381-
defer resp.Body.Close()
382-
383-
if resp.StatusCode != http.StatusOK {
384-
bodyBytes, _ := io.ReadAll(resp.Body)
385-
t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes))
386-
}
387-
388-
// Check response body
389-
var body map[string]interface{}
390-
err = json.NewDecoder(resp.Body).Decode(&body)
391-
if err != nil {
392-
t.Fatalf("error parsing response body: %v", err)
393-
}
394-
got, ok := body["result"].(string)
395-
if !ok {
396-
bodyBytes, _ := json.Marshal(body)
397-
t.Fatalf("unable to find result in response body, got: %s", string(bodyBytes))
398-
}
399-
400-
if got != tc.want {
401-
t.Fatalf("unexpected value: got %q, want %q", got, tc.want)
402-
}
403-
})
404-
}
336+
t.Run("invoke query-param-tool (optional omitted)", func(t *testing.T) {
337+
tests.RunMCPToolInvokeParametersTest(t, "my-query-param-tool", []byte(`{"reqId": "test1"}`), `"reqId=test1"`)
338+
})
339+
t.Run("invoke query-param-tool (some optional nil)", func(t *testing.T) {
340+
tests.RunMCPToolInvokeParametersTest(t, "my-query-param-tool", []byte(`{"reqId": "test2", "page": "5", "filter": null}`), `"page=5\u0026reqId=test2"`)
341+
})
342+
t.Run("invoke query-param-tool (some optional absent)", func(t *testing.T) {
343+
tests.RunMCPToolInvokeParametersTest(t, "my-query-param-tool", []byte(`{"reqId": "test2", "page": "5"}`), `"page=5\u0026reqId=test2"`)
344+
})
345+
t.Run("invoke query-param-tool (required param nil)", func(t *testing.T) {
346+
tests.RunMCPToolInvokeParametersTest(t, "my-query-param-tool", []byte(`{"reqId": null, "page": "1"}`), `parameter "reqId" is required`)
347+
})
405348
}
406349

407350
func runAdvancedHTTPInvokeTest(t *testing.T) {
408-
// Test HTTP tool invoke endpoint
409-
invokeTcs := []struct {
410-
name string
411-
api string
412-
requestHeader map[string]string
413-
requestBody func() io.Reader
414-
want string
415-
isAgentErr bool
416-
}{
417-
{
418-
name: "invoke my-advanced-tool",
419-
api: "http://127.0.0.1:5000/api/tool/my-advanced-tool/invoke",
420-
requestHeader: map[string]string{},
421-
requestBody: func() io.Reader {
422-
return bytes.NewBuffer([]byte(`{"animalArray": ["rabbit", "ostrich", "whale"], "id": 3, "path": "tool3", "country": "US", "X-Other-Header": "test"}`))
423-
},
424-
want: `"hello world"`,
425-
isAgentErr: false,
426-
},
427-
{
428-
name: "invoke my-advanced-tool with wrong params",
429-
api: "http://127.0.0.1:5000/api/tool/my-advanced-tool/invoke",
430-
requestHeader: map[string]string{},
431-
requestBody: func() io.Reader {
432-
return bytes.NewBuffer([]byte(`{"animalArray": ["rabbit", "ostrich", "whale"], "id": 4, "path": "tool3", "country": "US", "X-Other-Header": "test"}`))
433-
},
434-
want: "error processing request: unexpected status code: 400, response body: Bad Request: Incorrect query parameter: id, actual: [2 1 4]",
435-
isAgentErr: true,
436-
},
437-
}
438-
439-
for _, tc := range invokeTcs {
440-
t.Run(tc.name, func(t *testing.T) {
441-
req, err := http.NewRequest(http.MethodPost, tc.api, tc.requestBody())
442-
if err != nil {
443-
t.Fatalf("unable to create request: %s", err)
444-
}
445-
req.Header.Add("Content-type", "application/json")
446-
for k, v := range tc.requestHeader {
447-
req.Header.Add(k, v)
448-
}
449-
450-
resp, err := http.DefaultClient.Do(req)
451-
if err != nil {
452-
t.Fatalf("unable to send request: %s", err)
453-
}
454-
defer resp.Body.Close()
455-
456-
// As you noted, the toolbox wraps errors in a 200 OK
457-
if resp.StatusCode != http.StatusOK {
458-
bodyBytes, _ := io.ReadAll(resp.Body)
459-
t.Fatalf("expected status 200 from toolbox, got %d: %s", resp.StatusCode, string(bodyBytes))
460-
}
461-
462-
// Decode the response body into a map
463-
var body map[string]any
464-
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
465-
t.Fatalf("failed to decode response: %v", err)
466-
}
467-
468-
if tc.isAgentErr {
469-
resStr, ok := body["result"].(string)
470-
if !ok {
471-
t.Fatalf("expected 'result' field as string in response body, got: %v", body)
472-
}
473-
474-
var resMap map[string]any
475-
if err := json.Unmarshal([]byte(resStr), &resMap); err != nil {
476-
t.Fatalf("failed to unmarshal result string: %v", err)
477-
}
478-
479-
gotErr, ok := resMap["error"].(string)
480-
if !ok {
481-
t.Fatalf("expected 'error' field inside result, got: %v", resMap)
482-
}
483-
484-
if !strings.Contains(gotErr, tc.want) {
485-
t.Fatalf("unexpected error message: got %q, want it to contain %q", gotErr, tc.want)
486-
}
487-
} else {
488-
got, ok := body["result"].(string)
489-
if !ok {
490-
resBytes, _ := json.Marshal(body["result"])
491-
got = string(resBytes)
492-
}
493-
494-
if got != tc.want {
495-
t.Fatalf("unexpected result: got %q, want %q", got, tc.want)
496-
}
497-
}
498-
})
499-
}
351+
t.Run("invoke my-advanced-tool", func(t *testing.T) {
352+
tests.RunMCPToolInvokeParametersTest(t, "my-advanced-tool", []byte(`{"animalArray": ["rabbit", "ostrich", "whale"], "id": 3, "path": "tool3", "country": "US", "X-Other-Header": "test"}`), `"hello world"`)
353+
})
354+
t.Run("invoke my-advanced-tool with wrong params", func(t *testing.T) {
355+
tests.RunMCPToolInvokeParametersTest(t, "my-advanced-tool", []byte(`{"animalArray": ["rabbit", "ostrich", "whale"], "id": 4, "path": "tool3", "country": "US", "X-Other-Header": "test"}`), `unexpected status code: 400`)
356+
})
500357
}
501358

502359
// getHTTPToolsConfig returns a mock HTTP tool's config file
@@ -509,6 +366,11 @@ func getHTTPToolsConfig(sourceConfig map[string]any, toolType string) map[string
509366
otherSourceConfig["headers"] = map[string]string{"X-Custom-Header": "unexpected", "Content-Type": "application/json"}
510367
otherSourceConfig["queryParams"] = map[string]any{"id": 1, "name": "Sid"}
511368

369+
clientId := os.Getenv("CLIENT_ID")
370+
if clientId == "" {
371+
clientId = "test-client-id"
372+
}
373+
512374
toolsFile := map[string]any{
513375
"sources": map[string]any{
514376
"my-instance": sourceConfig,
@@ -517,7 +379,7 @@ func getHTTPToolsConfig(sourceConfig map[string]any, toolType string) map[string
517379
"authServices": map[string]any{
518380
"my-google-auth": map[string]any{
519381
"type": "google",
520-
"clientId": tests.ClientId,
382+
"clientId": clientId,
521383
},
522384
},
523385
"tools": map[string]any{

0 commit comments

Comments
 (0)