Skip to content

Commit fed13b5

Browse files
fix(errors): safely format GitHub validation failures
Limit structured formatting to HTTP 422 responses, sanitize allowlisted validation fields, and omit request, response, and documentation metadata while preserving other error contracts. Refs #3080 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent b0c9486 commit fed13b5

3 files changed

Lines changed: 128 additions & 52 deletions

File tree

pkg/errors/error.go

Lines changed: 51 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010
"time"
1111

12+
"github.com/github/github-mcp-server/pkg/sanitize"
1213
"github.com/github/github-mcp-server/pkg/utils"
1314
"github.com/google/go-github/v89/github"
1415
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -192,58 +193,74 @@ func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github
192193
"%s: GitHub secondary rate limit exceeded. Wait before retrying.", message))
193194
}
194195

195-
return utils.NewToolResultErrorFromErr(message, formattedGitHubAPIError(err))
196+
return utils.NewToolResultErrorFromErr(message, formatGitHubValidationError(resp, err))
196197
}
197198

198-
// formattedGitHubAPIError unwraps a github.ErrorResponse so tool results include
199-
// nested validation messages (for example repository ruleset violations) instead
200-
// of go-github's compact 422 dump.
201-
func formattedGitHubAPIError(err error) error {
199+
// formatGitHubValidationError exposes the parsed fields of 422 responses without
200+
// including request or response metadata from the underlying HTTP exchange.
201+
func formatGitHubValidationError(resp *github.Response, err error) error {
202202
var ghErr *github.ErrorResponse
203203
if !stderrors.As(err, &ghErr) {
204204
return err
205205
}
206206

207-
var parts []string
208-
switch {
209-
case ghErr.Response != nil && ghErr.Response.StatusCode != 0 && ghErr.Message != "":
210-
parts = append(parts, fmt.Sprintf("HTTP %d %s", ghErr.Response.StatusCode, ghErr.Message))
211-
case ghErr.Response != nil && ghErr.Response.StatusCode != 0:
212-
parts = append(parts, fmt.Sprintf("HTTP %d", ghErr.Response.StatusCode))
213-
case ghErr.Message != "":
214-
parts = append(parts, ghErr.Message)
207+
statusCode := 0
208+
if ghErr.Response != nil {
209+
statusCode = ghErr.Response.StatusCode
210+
}
211+
if statusCode == 0 && resp != nil {
212+
statusCode = resp.StatusCode
213+
}
214+
if statusCode != http.StatusUnprocessableEntity {
215+
return err
215216
}
216217

217-
for _, item := range ghErr.Errors {
218-
detail := strings.TrimSpace(item.Message)
219-
if detail == "" {
220-
var bits []string
221-
if item.Resource != "" {
222-
bits = append(bits, item.Resource)
223-
}
224-
if item.Field != "" {
225-
bits = append(bits, item.Field)
226-
}
227-
if item.Code != "" {
228-
bits = append(bits, item.Code)
229-
}
230-
detail = strings.Join(bits, " ")
231-
}
232-
if detail != "" {
218+
parts := make([]string, 0, len(ghErr.Errors)+1)
219+
if summary := sanitizeGitHubValidationText(ghErr.Message); summary != "" {
220+
parts = append(parts, summary)
221+
}
222+
for _, validationErr := range ghErr.Errors {
223+
if detail := formatGitHubValidationDetail(validationErr); detail != "" {
233224
parts = append(parts, detail)
234225
}
235226
}
236227

237-
if ghErr.DocumentationURL != "" {
238-
parts = append(parts, "See "+ghErr.DocumentationURL)
239-
}
240-
241228
if len(parts) == 0 {
242-
return err
229+
return stderrors.New("GitHub API validation failed")
243230
}
244231
return stderrors.New(strings.Join(parts, "\n"))
245232
}
246233

234+
func formatGitHubValidationDetail(validationErr github.Error) string {
235+
resource := sanitizeGitHubValidationText(validationErr.Resource)
236+
field := sanitizeGitHubValidationText(validationErr.Field)
237+
code := sanitizeGitHubValidationText(validationErr.Code)
238+
message := sanitizeGitHubValidationText(validationErr.Message)
239+
240+
location := strings.Trim(strings.Join([]string{resource, field}, "."), ".")
241+
switch {
242+
case location != "" && code != "":
243+
location += " (" + code + ")"
244+
case location == "":
245+
location = code
246+
}
247+
248+
switch {
249+
case location != "" && message != "":
250+
return location + ": " + message
251+
case message != "":
252+
return message
253+
default:
254+
return location
255+
}
256+
}
257+
258+
func sanitizeGitHubValidationText(value string) string {
259+
// Tool errors are plain text; keep quoted branch patterns readable.
260+
sanitized := strings.ReplaceAll(sanitize.Sanitize(value), "&#39;", "'")
261+
return strings.Join(strings.Fields(sanitized), " ")
262+
}
263+
247264
// NewGitHubGraphQLErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware
248265
func NewGitHubGraphQLErrorResponse(ctx context.Context, message string, err error) *mcp.CallToolResult {
249266
graphQLErr := newGitHubGraphQLError(message, err)

pkg/errors/error_test.go

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -689,48 +689,101 @@ func TestNewGitHubAPIErrorResponse_RateLimits(t *testing.T) {
689689
}
690690

691691
func TestNewGitHubAPIErrorResponse_ValidationMessages(t *testing.T) {
692-
t.Run("ruleset ErrorResponse includes nested validation messages", func(t *testing.T) {
692+
t.Run("ruleset ErrorResponse includes sanitized structured validation messages", func(t *testing.T) {
693693
ctx := ContextWithGitHubErrors(context.Background())
694694

695+
request, err := http.NewRequest(http.MethodPost, "https://api.github.test/repos/owner/repo/git/refs?private=secret-url-token", nil)
696+
require.NoError(t, err)
697+
request.Header.Set("Authorization", "Bearer secret-request-token")
698+
response := &http.Response{
699+
StatusCode: http.StatusUnprocessableEntity,
700+
Request: request,
701+
Header: http.Header{"X-Secret": []string{"secret-response-header"}},
702+
}
703+
695704
originalErr := &github.ErrorResponse{
696-
Response: &http.Response{StatusCode: http.StatusUnprocessableEntity},
697-
Message: "Validation Failed",
705+
Response: response,
706+
Message: "Validation <script>secret-script</script>Failed\u202e",
698707
Errors: []github.Error{
699708
{
700709
Resource: "GitRef",
701710
Field: "ref",
702711
Code: "custom",
703-
Message: "ref name does not match the required pattern 'feature/*'",
712+
Message: "ref name does not match the required pattern 'feature/*'\u202e",
704713
},
705714
},
706-
DocumentationURL: "https://docs.github.com/rest/git/refs#create-a-reference",
715+
DocumentationURL: "https://docs.github.test/private?token=secret-doc-token",
707716
}
708717

709-
result := NewGitHubAPIErrorResponse(ctx, "failed to create branch", nil, originalErr)
718+
wrappedErr := fmt.Errorf("create ref: %w", originalErr)
719+
result := NewGitHubAPIErrorResponse(
720+
ctx,
721+
"failed to create branch",
722+
&github.Response{Response: response},
723+
wrappedErr,
724+
)
710725

711726
text := requireErrorText(t, result)
712-
assert.Contains(t, text, "failed to create branch")
713-
assert.Contains(t, text, "HTTP 422 Validation Failed")
714-
assert.Contains(t, text, "ref name does not match the required pattern 'feature/*'")
715-
assert.Contains(t, text, "See https://docs.github.com/rest/git/refs#create-a-reference")
716-
assert.NotContains(t, text, "Resource:")
727+
assert.Equal(t, "failed to create branch: Validation Failed\nGitRef.ref (custom): ref name does not match the required pattern 'feature/*'", text)
728+
assert.NotContains(t, text, "create ref")
729+
assert.NotContains(t, text, "https://")
730+
assert.NotContains(t, text, "secret-")
731+
assert.NotContains(t, text, "Authorization")
732+
assert.NotContains(t, text, "X-Secret")
733+
assert.NotContains(t, text, "<script>")
734+
assert.NotContains(t, text, "\u202e")
735+
assertContextHasError(t, ctx, wrappedErr)
717736
})
718737

719-
t.Run("wrapped ErrorResponse is still unwrapped", func(t *testing.T) {
738+
t.Run("ordinary validation errors retain resource field and code", func(t *testing.T) {
720739
ctx := ContextWithGitHubErrors(context.Background())
721740

722-
originalErr := fmt.Errorf("create ref: %w", &github.ErrorResponse{
741+
originalErr := &github.ErrorResponse{
723742
Response: &http.Response{StatusCode: http.StatusUnprocessableEntity},
724743
Message: "Validation Failed",
725744
Errors: []github.Error{
726-
{Message: "Changes must be made through a pull request."},
745+
{
746+
Resource: "Repository",
747+
Field: "name",
748+
Code: "invalid",
749+
},
727750
},
728-
})
751+
}
752+
753+
result := NewGitHubAPIErrorResponse(ctx, "API call failed", nil, originalErr)
754+
755+
text := requireErrorText(t, result)
756+
assert.Equal(t, "API call failed: Validation Failed\nRepository.name (invalid)", text)
757+
})
758+
759+
t.Run("top-level validation message is useful without nested errors", func(t *testing.T) {
760+
ctx := ContextWithGitHubErrors(context.Background())
761+
762+
originalErr := &github.ErrorResponse{
763+
Response: &http.Response{StatusCode: http.StatusUnprocessableEntity},
764+
Message: "Reference already exists",
765+
}
729766

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

732769
text := requireErrorText(t, result)
733-
assert.Contains(t, text, "Changes must be made through a pull request.")
734-
assert.NotContains(t, text, "create ref:")
770+
assert.Equal(t, "failed to create branch: Reference already exists", text)
771+
})
772+
773+
t.Run("non-422 ErrorResponse preserves the existing error contract", func(t *testing.T) {
774+
ctx := ContextWithGitHubErrors(context.Background())
775+
776+
originalErr := &github.ErrorResponse{
777+
Response: &http.Response{StatusCode: http.StatusConflict},
778+
Message: "Conflict",
779+
Errors: []github.Error{
780+
{Message: "Changes must be made through a pull request."},
781+
},
782+
}
783+
784+
result := NewGitHubAPIErrorResponse(ctx, "API call failed", nil, originalErr)
785+
786+
text := requireErrorText(t, result)
787+
assert.Equal(t, "API call failed: "+originalErr.Error(), text)
735788
})
736789
}

pkg/github/repositories_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,7 @@ func Test_CreateBranch(t *testing.T) {
10081008
expectError bool
10091009
expectedRef *github.Reference
10101010
expectedErrMsg string
1011+
unexpectedErrs []string
10111012
}{
10121013
{
10131014
name: "successful branch creation with from_branch",
@@ -1096,7 +1097,8 @@ func Test_CreateBranch(t *testing.T) {
10961097
"from_branch": "main",
10971098
},
10981099
expectError: true,
1099-
expectedErrMsg: "failed to create branch",
1100+
expectedErrMsg: "Reference already exists",
1101+
unexpectedErrs: []string{"422", "http://", "https://"},
11001102
},
11011103
{
11021104
name: "create branch surfaces ruleset validation details",
@@ -1127,6 +1129,7 @@ func Test_CreateBranch(t *testing.T) {
11271129
},
11281130
expectError: true,
11291131
expectedErrMsg: "ref name does not match the required pattern 'feature/*'",
1132+
unexpectedErrs: []string{"422", "https://docs.github.com"},
11301133
},
11311134
}
11321135

@@ -1151,6 +1154,9 @@ func Test_CreateBranch(t *testing.T) {
11511154
require.True(t, result.IsError)
11521155
errorContent := getErrorResult(t, result)
11531156
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
1157+
for _, unexpectedErr := range tc.unexpectedErrs {
1158+
assert.NotContains(t, errorContent.Text, unexpectedErr)
1159+
}
11541160
return
11551161
}
11561162

0 commit comments

Comments
 (0)