Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

171 lines
6.2 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/graph"
)
// TestCallToolStrict_Success returns the text content with a nil error
// when the tool returns a normal text result.
func TestCallToolStrict_Success(t *testing.T) {
h := newTestHandler(t)
text, err := h.CallToolStrict(context.Background(), "echo", map[string]any{"message": "hello"})
require.NoError(t, err)
assert.Equal(t, "hello", text)
}
// TestCallToolStrict_MissingTool returns an error when the tool name is not
// registered, instead of silently returning an empty string.
func TestCallToolStrict_MissingTool(t *testing.T) {
h := newTestHandler(t)
_, err := h.CallToolStrict(context.Background(), "no-such-tool", nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "no-such-tool")
assert.Contains(t, err.Error(), "not registered")
}
// TestCallToolStrict_ToolErrorResult promotes an MCP IsError=true result to
// a Go error. This is the contract that handleContracts depends on to surface
// 5xx instead of pretending the call succeeded with empty content.
func TestCallToolStrict_ToolErrorResult(t *testing.T) {
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test",
mcpserver.WithToolCapabilities(false),
)
srv.AddTool(
mcp.NewTool("erroring_tool", mcp.WithDescription("returns IsError=true")),
func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return mcp.NewToolResultError("project not found: \"gortex\""), nil
},
)
h := NewHandler(srv, g, "0.0.1-test", zap.NewNop())
text, err := h.CallToolStrict(context.Background(), "erroring_tool", nil)
require.Error(t, err, "IsError=true must be promoted to a Go error")
assert.Contains(t, err.Error(), "project not found")
// The text content is preserved so callers can put it in the response body.
assert.Contains(t, text, "project not found")
}
// TestCallToolStrict_HandlerError surfaces a Go-level handler error.
func TestCallToolStrict_HandlerError(t *testing.T) {
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test",
mcpserver.WithToolCapabilities(false),
)
srv.AddTool(
mcp.NewTool("failing_tool", mcp.WithDescription("returns Go-level error")),
func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return nil, errors.New("upstream blew up")
},
)
h := NewHandler(srv, g, "0.0.1-test", zap.NewNop())
_, err := h.CallToolStrict(context.Background(), "failing_tool", nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "upstream blew up")
}
// TestCallTool_BackwardsCompatible — the legacy best-effort wrapper still
// returns "" on error and never panics. Eval/http_handler.go and similar
// callers depend on this contract.
func TestCallTool_BackwardsCompatible(t *testing.T) {
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test",
mcpserver.WithToolCapabilities(false),
)
srv.AddTool(
mcp.NewTool("erroring_tool", mcp.WithDescription("")),
func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return mcp.NewToolResultError("nope"), nil
},
)
h := NewHandler(srv, g, "0.0.1-test", zap.NewNop())
// IsError=true: legacy CallTool returns the text (best-effort) and
// callers cannot tell it was an error. This is the contract callers
// like eval/http_handler.go rely on; the ones that need to know now
// use CallToolStrict.
got := h.CallTool(context.Background(), "erroring_tool", nil)
assert.Equal(t, "nope", got)
// Missing tool: still returns "".
got = h.CallTool(context.Background(), "no-such-tool", nil)
assert.Empty(t, got)
}
// TestHandleContracts_ToolError_500 verifies the user-visible bug fix: when
// the underlying contracts tool returns an error result (e.g. project not
// found), /v1/contracts must return HTTP 500 with the error in the body, not
// a fake empty 200.
func TestHandleContracts_ToolError_500(t *testing.T) {
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test",
mcpserver.WithToolCapabilities(false),
)
srv.AddTool(
mcp.NewTool("contracts", mcp.WithDescription("contracts stub")),
func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return mcp.NewToolResultError(`project not found: "gortex" (available: )`), nil
},
)
h := NewHandler(srv, g, "0.0.1-test", zap.NewNop())
req := httptest.NewRequest(http.MethodGet, "/v1/contracts", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code,
"tool error must surface as 5xx, not 200 with empty body")
var body map[string]any
require.NoError(t, json.NewDecoder(rec.Body).Decode(&body))
msg, _ := body["message"].(string)
assert.True(t, strings.Contains(msg, "project not found"),
"error body must include the underlying tool message; got: %q", msg)
}
// TestHandleContracts_Success_200 verifies the happy path still works:
// contracts tool returns a valid JSON payload → handler 200s with the
// flattened list.
func TestHandleContracts_Success_200(t *testing.T) {
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test",
mcpserver.WithToolCapabilities(false),
)
srv.AddTool(
mcp.NewTool("contracts", mcp.WithDescription("contracts stub")),
func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
payload := `{"by_repo":{"alpha":{"contracts":{"http":[{"id":"GET /foo","type":"http","role":"provider","symbol_id":"alpha/x.go::H","file_path":"alpha/x.go","line":10,"repo_prefix":"alpha"}]},"total":1}}}`
return mcp.NewToolResultText(payload), nil
},
)
h := NewHandler(srv, g, "0.0.1-test", zap.NewNop())
req := httptest.NewRequest(http.MethodGet, "/v1/contracts", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var body struct {
Contracts []map[string]any `json:"contracts"`
}
require.NoError(t, json.NewDecoder(rec.Body).Decode(&body))
require.Len(t, body.Contracts, 1)
assert.Equal(t, "GET /foo", body.Contracts[0]["id"])
}