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, "