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
171 lines
6.2 KiB
Go
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"])
|
|
}
|