diff --git a/pkg/errors/error.go b/pkg/errors/error.go index cb4e8b1f0f..13b607b405 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -6,8 +6,10 @@ import ( stderrors "errors" "fmt" "net/http" + "strings" "time" + "github.com/github/github-mcp-server/pkg/sanitize" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -191,7 +193,72 @@ func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github "%s: GitHub secondary rate limit exceeded. Wait before retrying.", message)) } - return utils.NewToolResultErrorFromErr(message, err) + return utils.NewToolResultErrorFromErr(message, formatGitHubValidationError(resp, err)) +} + +// formatGitHubValidationError exposes the parsed fields of 422 responses without +// including request or response metadata from the underlying HTTP exchange. +func formatGitHubValidationError(resp *github.Response, err error) error { + var ghErr *github.ErrorResponse + if !stderrors.As(err, &ghErr) { + return err + } + + statusCode := 0 + if ghErr.Response != nil { + statusCode = ghErr.Response.StatusCode + } + if statusCode == 0 && resp != nil { + statusCode = resp.StatusCode + } + if statusCode != http.StatusUnprocessableEntity { + return err + } + + parts := make([]string, 0, len(ghErr.Errors)+1) + if summary := sanitizeGitHubValidationText(ghErr.Message); summary != "" { + parts = append(parts, summary) + } + for _, validationErr := range ghErr.Errors { + if detail := formatGitHubValidationDetail(validationErr); detail != "" { + parts = append(parts, detail) + } + } + + if len(parts) == 0 { + return stderrors.New("GitHub API validation failed") + } + return stderrors.New(strings.Join(parts, "\n")) +} + +func formatGitHubValidationDetail(validationErr github.Error) string { + resource := sanitizeGitHubValidationText(validationErr.Resource) + field := sanitizeGitHubValidationText(validationErr.Field) + code := sanitizeGitHubValidationText(validationErr.Code) + message := sanitizeGitHubValidationText(validationErr.Message) + + location := strings.Trim(strings.Join([]string{resource, field}, "."), ".") + switch { + case location != "" && code != "": + location += " (" + code + ")" + case location == "": + location = code + } + + switch { + case location != "" && message != "": + return location + ": " + message + case message != "": + return message + default: + return location + } +} + +func sanitizeGitHubValidationText(value string) string { + // Tool errors are plain text; keep quoted branch patterns readable. + sanitized := strings.ReplaceAll(sanitize.Sanitize(value), "'", "'") + return strings.Join(strings.Fields(sanitized), " ") } // NewGitHubGraphQLErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index 414b7008f7..9938c12df9 100644 --- a/pkg/errors/error_test.go +++ b/pkg/errors/error_test.go @@ -687,3 +687,103 @@ func TestNewGitHubAPIErrorResponse_RateLimits(t *testing.T) { assert.Contains(t, text, "validation failed") }) } + +func TestNewGitHubAPIErrorResponse_ValidationMessages(t *testing.T) { + t.Run("ruleset ErrorResponse includes sanitized structured validation messages", func(t *testing.T) { + ctx := ContextWithGitHubErrors(context.Background()) + + request, err := http.NewRequest(http.MethodPost, "https://api.github.test/repos/owner/repo/git/refs?private=secret-url-token", nil) + require.NoError(t, err) + request.Header.Set("Authorization", "Bearer secret-request-token") + response := &http.Response{ + StatusCode: http.StatusUnprocessableEntity, + Request: request, + Header: http.Header{"X-Secret": []string{"secret-response-header"}}, + } + + originalErr := &github.ErrorResponse{ + Response: response, + Message: "Validation Failed\u202e", + Errors: []github.Error{ + { + Resource: "GitRef", + Field: "ref", + Code: "custom", + Message: "ref name does not match the required pattern 'feature/*'\u202e", + }, + }, + DocumentationURL: "https://docs.github.test/private?token=secret-doc-token", + } + + wrappedErr := fmt.Errorf("create ref: %w", originalErr) + result := NewGitHubAPIErrorResponse( + ctx, + "failed to create branch", + &github.Response{Response: response}, + wrappedErr, + ) + + text := requireErrorText(t, result) + assert.Equal(t, "failed to create branch: Validation Failed\nGitRef.ref (custom): ref name does not match the required pattern 'feature/*'", text) + assert.NotContains(t, text, "create ref") + assert.NotContains(t, text, "https://") + assert.NotContains(t, text, "secret-") + assert.NotContains(t, text, "Authorization") + assert.NotContains(t, text, "X-Secret") + assert.NotContains(t, text, "