package errors import ( "context" "encoding/json" stderrors "errors" "fmt" "net/http" "time" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) type GitHubAPIError struct { Message string `json:"message"` Response *github.Response `json:"-"` Err error `json:"-"` } // NewGitHubAPIError creates a new GitHubAPIError with the provided message, response, and error. func newGitHubAPIError(message string, resp *github.Response, err error) *GitHubAPIError { return &GitHubAPIError{ Message: message, Response: resp, Err: err, } } func (e *GitHubAPIError) Error() string { return fmt.Errorf("%s: %w", e.Message, e.Err).Error() } type GitHubGraphQLError struct { Message string `json:"message"` Err error `json:"-"` } func newGitHubGraphQLError(message string, err error) *GitHubGraphQLError { return &GitHubGraphQLError{ Message: message, Err: err, } } func (e *GitHubGraphQLError) Error() string { return fmt.Errorf("%s: %w", e.Message, e.Err).Error() } type GitHubRawAPIError struct { Message string `json:"message"` Response *http.Response `json:"-"` Err error `json:"-"` } func newGitHubRawAPIError(message string, resp *http.Response, err error) *GitHubRawAPIError { return &GitHubRawAPIError{ Message: message, Response: resp, Err: err, } } func (e *GitHubRawAPIError) Error() string { return fmt.Errorf("%s: %w", e.Message, e.Err).Error() } type GitHubErrorKey struct{} type GitHubCtxErrors struct { api []*GitHubAPIError graphQL []*GitHubGraphQLError raw []*GitHubRawAPIError } // ContextWithGitHubErrors updates or creates a context with a pointer to GitHub error information (to be used by middleware). func ContextWithGitHubErrors(ctx context.Context) context.Context { if ctx == nil { ctx = context.Background() } if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok { // If the context already has GitHubCtxErrors, we just empty the slices to start fresh val.api = []*GitHubAPIError{} val.graphQL = []*GitHubGraphQLError{} val.raw = []*GitHubRawAPIError{} } else { // If not, we create a new GitHubCtxErrors and set it in the context ctx = context.WithValue(ctx, GitHubErrorKey{}, &GitHubCtxErrors{}) } return ctx } // GetGitHubAPIErrors retrieves the slice of GitHubAPIErrors from the context. func GetGitHubAPIErrors(ctx context.Context) ([]*GitHubAPIError, error) { if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok { return val.api, nil // return the slice of API errors from the context } return nil, fmt.Errorf("context does not contain GitHubCtxErrors") } // GetGitHubGraphQLErrors retrieves the slice of GitHubGraphQLErrors from the context. func GetGitHubGraphQLErrors(ctx context.Context) ([]*GitHubGraphQLError, error) { if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok { return val.graphQL, nil // return the slice of GraphQL errors from the context } return nil, fmt.Errorf("context does not contain GitHubCtxErrors") } // GetGitHubRawAPIErrors retrieves the slice of GitHubRawAPIErrors from the context. func GetGitHubRawAPIErrors(ctx context.Context) ([]*GitHubRawAPIError, error) { if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok { return val.raw, nil // return the slice of raw API errors from the context } return nil, fmt.Errorf("context does not contain GitHubCtxErrors") } func NewGitHubAPIErrorToCtx(ctx context.Context, message string, resp *github.Response, err error) (context.Context, error) { apiErr := newGitHubAPIError(message, resp, err) if ctx != nil { _, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling } return ctx, nil } func NewGitHubGraphQLErrorToCtx(ctx context.Context, message string, err error) (context.Context, error) { graphQLErr := newGitHubGraphQLError(message, err) if ctx != nil { _, _ = addGitHubGraphQLErrorToContext(ctx, graphQLErr) // Explicitly ignore error for graceful handling } return ctx, nil } func addGitHubAPIErrorToContext(ctx context.Context, err *GitHubAPIError) (context.Context, error) { if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok { val.api = append(val.api, err) // append the error to the existing slice in the context return ctx, nil } return nil, fmt.Errorf("context does not contain GitHubCtxErrors") } func addGitHubGraphQLErrorToContext(ctx context.Context, err *GitHubGraphQLError) (context.Context, error) { if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok { val.graphQL = append(val.graphQL, err) // append the error to the existing slice in the context return ctx, nil } return nil, fmt.Errorf("context does not contain GitHubCtxErrors") } func addRawAPIErrorToContext(ctx context.Context, err *GitHubRawAPIError) (context.Context, error) { if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok { val.raw = append(val.raw, err) return ctx, nil } return nil, fmt.Errorf("context does not contain GitHubCtxErrors") } // NewGitHubAPIErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github.Response, err error) *mcp.CallToolResult { apiErr := newGitHubAPIError(message, resp, err) if ctx != nil { _, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling } var rateLimitErr *github.RateLimitError if stderrors.As(err, &rateLimitErr) { resetTime := rateLimitErr.Rate.Reset.Time if !resetTime.IsZero() { retryIn := time.Until(resetTime).Round(time.Second) if retryIn > 0 { return utils.NewToolResultError(fmt.Sprintf( "%s: GitHub API rate limit exceeded. Retry after %v.", message, retryIn)) } } return utils.NewToolResultError(fmt.Sprintf( "%s: GitHub API rate limit exceeded. Wait before retrying.", message)) } var abuseErr *github.AbuseRateLimitError if stderrors.As(err, &abuseErr) { if abuseErr.RetryAfter != nil { retryAfter := abuseErr.RetryAfter.Round(time.Second) if retryAfter > 0 { return utils.NewToolResultError(fmt.Sprintf( "%s: GitHub secondary rate limit exceeded. Retry after %v.", message, retryAfter)) } } return utils.NewToolResultError(fmt.Sprintf( "%s: GitHub secondary rate limit exceeded. Wait before retrying.", message)) } return utils.NewToolResultErrorFromErr(message, err) } // 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) if ctx != nil { _, _ = addGitHubGraphQLErrorToContext(ctx, graphQLErr) // Explicitly ignore error for graceful handling } return utils.NewToolResultErrorFromErr(message, err) } // NewGitHubRawAPIErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware func NewGitHubRawAPIErrorResponse(ctx context.Context, message string, resp *http.Response, err error) *mcp.CallToolResult { rawErr := newGitHubRawAPIError(message, resp, err) if ctx != nil { _, _ = addRawAPIErrorToContext(ctx, rawErr) // Explicitly ignore error for graceful handling } return utils.NewToolResultErrorFromErr(message, err) } // NewGitHubAPIStatusErrorResponse handles cases where the API call succeeds (err == nil) // but returns an unexpected HTTP status code. It creates a synthetic error from the // status code and response body, then records it in context for observability tracking. func NewGitHubAPIStatusErrorResponse(ctx context.Context, message string, resp *github.Response, body []byte) *mcp.CallToolResult { err := fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) return NewGitHubAPIErrorResponse(ctx, message, resp, err) } // StructuredResolutionError is a machine-readable error returned by name-resolution // helpers (e.g. resolving a project field or single-select option by name). Agents // can parse the JSON body to self-correct without re-prompting. // // Kind values: // - "field_not_found" — no project field matches the supplied name // - "field_ambiguous" — more than one project field shares the supplied name // - "option_not_found" — no option on the resolved single-select field matches // - "option_ambiguous" — duplicate option names on the resolved field // - "item_not_in_project" — the issue/PR exists but is not an item on the project // - "wrong_field_type" — the named field is not the data type the caller expected type StructuredResolutionError struct { Kind string `json:"error"` Name string `json:"name,omitempty"` Field string `json:"field,omitempty"` Candidates []any `json:"candidates,omitempty"` Hint string `json:"hint,omitempty"` } // Error implements the error interface; the message is the JSON body so that the // downstream tool result also carries the structured payload as plain text. func (e *StructuredResolutionError) Error() string { b, err := json.Marshal(e) if err != nil { return fmt.Sprintf(`{"error":%q,"name":%q}`, e.Kind, e.Name) } return string(b) } // NewStructuredResolutionError constructs a StructuredResolutionError. func NewStructuredResolutionError(kind, name, hint string, candidates []any) *StructuredResolutionError { return &StructuredResolutionError{ Kind: kind, Name: name, Hint: hint, Candidates: candidates, } } // NewStructuredResolutionErrorResponse returns an mcp.CallToolResult whose text body // is the JSON-serialised StructuredResolutionError, suitable for agent self-correction. func NewStructuredResolutionErrorResponse(err *StructuredResolutionError) *mcp.CallToolResult { return utils.NewToolResultError(err.Error()) }