Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion pkg/errors/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions pkg/errors/error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <script>secret-script</script>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, "<script>")
assert.NotContains(t, text, "\u202e")
assertContextHasError(t, ctx, wrappedErr)
})

t.Run("ordinary validation errors retain resource field and code", func(t *testing.T) {
ctx := ContextWithGitHubErrors(context.Background())

originalErr := &github.ErrorResponse{
Response: &http.Response{StatusCode: http.StatusUnprocessableEntity},
Message: "Validation Failed",
Errors: []github.Error{
{
Resource: "Repository",
Field: "name",
Code: "invalid",
},
},
}

result := NewGitHubAPIErrorResponse(ctx, "API call failed", nil, originalErr)

text := requireErrorText(t, result)
assert.Equal(t, "API call failed: Validation Failed\nRepository.name (invalid)", text)
})

t.Run("top-level validation message is useful without nested errors", func(t *testing.T) {
ctx := ContextWithGitHubErrors(context.Background())

originalErr := &github.ErrorResponse{
Response: &http.Response{StatusCode: http.StatusUnprocessableEntity},
Message: "Reference already exists",
}

result := NewGitHubAPIErrorResponse(ctx, "failed to create branch", nil, originalErr)

text := requireErrorText(t, result)
assert.Equal(t, "failed to create branch: Reference already exists", text)
})

t.Run("non-422 ErrorResponse preserves the existing error contract", func(t *testing.T) {
ctx := ContextWithGitHubErrors(context.Background())

originalErr := &github.ErrorResponse{
Response: &http.Response{StatusCode: http.StatusConflict},
Message: "Conflict",
Errors: []github.Error{
{Message: "Changes must be made through a pull request."},
},
}

result := NewGitHubAPIErrorResponse(ctx, "API call failed", nil, originalErr)

text := requireErrorText(t, result)
assert.Equal(t, "API call failed: "+originalErr.Error(), text)
})
}
38 changes: 37 additions & 1 deletion pkg/github/repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,7 @@ func Test_CreateBranch(t *testing.T) {
expectError bool
expectedRef *github.Reference
expectedErrMsg string
unexpectedErrs []string
}{
{
name: "successful branch creation with from_branch",
Expand Down Expand Up @@ -1096,7 +1097,39 @@ func Test_CreateBranch(t *testing.T) {
"from_branch": "main",
},
expectError: true,
expectedErrMsg: "failed to create branch",
expectedErrMsg: "Reference already exists",
unexpectedErrs: []string{"422", "http://", "https://"},
},
{
name: "create branch surfaces ruleset validation details",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockSourceRef),
"GET /repos/owner/repo/git/ref/heads/main": mockResponse(t, http.StatusOK, mockSourceRef),
PostReposGitRefsByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{
"message": "Validation Failed",
"documentation_url": "https://docs.github.com/rest/git/refs#create-a-reference",
"errors": [
{
"resource": "GitRef",
"field": "ref",
"code": "custom",
"message": "ref name does not match the required pattern 'feature/*'"
}
]
}`))
},
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"branch": "hotfix",
"from_branch": "main",
},
expectError: true,
expectedErrMsg: "ref name does not match the required pattern 'feature/*'",
unexpectedErrs: []string{"422", "https://docs.github.com"},
},
}

Expand All @@ -1121,6 +1154,9 @@ func Test_CreateBranch(t *testing.T) {
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
for _, unexpectedErr := range tc.unexpectedErrs {
assert.NotContains(t, errorContent.Text, unexpectedErr)
}
return
}

Expand Down
Loading