diff --git a/pkg/errors/error.go b/pkg/errors/error.go
index 205da46c..13b607b4 100644
--- a/pkg/errors/error.go
+++ b/pkg/errors/error.go
@@ -9,6 +9,7 @@ import (
"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"
@@ -192,58 +193,74 @@ func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github
"%s: GitHub secondary rate limit exceeded. Wait before retrying.", message))
}
- return utils.NewToolResultErrorFromErr(message, formattedGitHubAPIError(err))
+ return utils.NewToolResultErrorFromErr(message, formatGitHubValidationError(resp, err))
}
-// formattedGitHubAPIError unwraps a github.ErrorResponse so tool results include
-// nested validation messages (for example repository ruleset violations) instead
-// of go-github's compact 422 dump.
-func formattedGitHubAPIError(err error) error {
+// 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
}
- var parts []string
- switch {
- case ghErr.Response != nil && ghErr.Response.StatusCode != 0 && ghErr.Message != "":
- parts = append(parts, fmt.Sprintf("HTTP %d %s", ghErr.Response.StatusCode, ghErr.Message))
- case ghErr.Response != nil && ghErr.Response.StatusCode != 0:
- parts = append(parts, fmt.Sprintf("HTTP %d", ghErr.Response.StatusCode))
- case ghErr.Message != "":
- parts = append(parts, ghErr.Message)
+ statusCode := 0
+ if ghErr.Response != nil {
+ statusCode = ghErr.Response.StatusCode
+ }
+ if statusCode == 0 && resp != nil {
+ statusCode = resp.StatusCode
+ }
+ if statusCode != http.StatusUnprocessableEntity {
+ return err
}
- for _, item := range ghErr.Errors {
- detail := strings.TrimSpace(item.Message)
- if detail == "" {
- var bits []string
- if item.Resource != "" {
- bits = append(bits, item.Resource)
- }
- if item.Field != "" {
- bits = append(bits, item.Field)
- }
- if item.Code != "" {
- bits = append(bits, item.Code)
- }
- detail = strings.Join(bits, " ")
- }
- if detail != "" {
+ 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 ghErr.DocumentationURL != "" {
- parts = append(parts, "See "+ghErr.DocumentationURL)
- }
-
if len(parts) == 0 {
- return err
+ 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
func NewGitHubGraphQLErrorResponse(ctx context.Context, message string, err error) *mcp.CallToolResult {
graphQLErr := newGitHubGraphQLError(message, err)
diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go
index c16556ea..9938c12d 100644
--- a/pkg/errors/error_test.go
+++ b/pkg/errors/error_test.go
@@ -689,7 +689,53 @@ func TestNewGitHubAPIErrorResponse_RateLimits(t *testing.T) {
}
func TestNewGitHubAPIErrorResponse_ValidationMessages(t *testing.T) {
- t.Run("ruleset ErrorResponse includes nested validation messages", func(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, "