Skip to content
Draft
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
94 changes: 65 additions & 29 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# Error Handling

This document describes the error handling patterns used in the GitHub MCP Server, specifically how we handle GitHub API errors and avoid direct use of mcp-go error types.
This document describes the error handling patterns used in the GitHub MCP Server, specifically how we handle GitHub API errors using the MCP SDK's `SetError`/`GetError` mechanism.

## Overview

The GitHub MCP Server implements a custom error handling approach that serves two primary purposes:
The GitHub MCP Server uses the Go SDK's `CallToolResult.SetError()` to embed typed GitHub API errors directly in tool results. This approach enables:

1. **Tool Response Generation**: Return appropriate MCP tool error responses to clients
2. **Middleware Inspection**: Store detailed error information in the request context for middleware analysis
2. **Error Type Inspection**: Consumers can use `result.GetError()` with `errors.As` to extract typed errors for analysis

This dual approach enables better observability and debugging capabilities, particularly for remote server deployments where understanding the nature of failures (rate limiting, authentication, 404s, 500s, etc.) is crucial for validation and monitoring.
This is powered by the Go SDK v1.3.0+ `SetError`/`GetError` methods on `CallToolResult`, which embed a Go `error` in the result alongside the error text content.

## Error Types

Expand All @@ -36,38 +36,61 @@ type GitHubGraphQLError struct {
}
```

### GitHubRawAPIError

Used for raw HTTP API errors from the GitHub API:

```go
type GitHubRawAPIError struct {
Message string `json:"message"`
Response *http.Response `json:"-"`
Err error `json:"-"`
}
```

## Usage Patterns

### For GitHub REST API Errors

Instead of directly returning `mcp.NewToolResultError()`, use:
When a GitHub REST API call fails, use:

```go
return ghErrors.NewGitHubAPIErrorResponse(ctx, message, response, err), nil
```

This function:
- Creates a `GitHubAPIError` with the provided message, response, and error
- Stores the error in the context for middleware inspection
- Returns an appropriate MCP tool error response
- Calls `result.SetError()` to embed the typed error in the tool result
- Returns a `CallToolResult` with `IsError: true` and error text content

### For GitHub GraphQL API Errors

```go
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, message, err), nil
```

### Context Management

The error handling system uses context to store errors for later inspection:
### For Raw HTTP API Errors

```go
// Initialize context with error tracking
ctx = errors.ContextWithGitHubErrors(ctx)
return ghErrors.NewGitHubRawAPIErrorResponse(ctx, message, response, err), nil
```

### Extracting Errors from Results

// Retrieve errors for inspection (typically in middleware)
apiErrors, err := errors.GetGitHubAPIErrors(ctx)
graphqlErrors, err := errors.GetGitHubGraphQLErrors(ctx)
Consumers (such as middleware or the remote server) can extract typed errors from results:

```go
if err := result.GetError(); err != nil {
var apiErr *errors.GitHubAPIError
if errors.As(err, &apiErr) {
// Access apiErr.Response.StatusCode, apiErr.Message, etc.
}

var gqlErr *errors.GitHubGraphQLError
if errors.As(err, &gqlErr) {
// Access gqlErr.Message, gqlErr.Err, etc.
}
}
```

## Design Principles
Expand All @@ -77,20 +100,20 @@ graphqlErrors, err := errors.GetGitHubGraphQLErrors(ctx)
- **User-actionable errors** (authentication failures, rate limits, 404s) should be returned as failed tool calls using the error response functions
- **Developer errors** (JSON marshaling failures, internal logic errors) should be returned as actual Go errors that bubble up through the MCP framework

### Context Limitations

This approach was designed to work around current limitations in mcp-go where context is not propagated through each step of request processing. By storing errors in context values, middleware can inspect them without requiring context propagation.

### Graceful Error Handling
### Type Safety with SetError/GetError

Error storage operations in context are designed to fail gracefully - if context storage fails, the tool will still return an appropriate error response to the client.
All GitHub API error types implement the `error` interface with `Unwrap()` support, enabling:
- `errors.As()` to extract the specific error type (e.g., `*GitHubAPIError`)
- `errors.Is()` to check for the underlying cause
- Standard Go error handling patterns

## Benefits

1. **Observability**: Middleware can inspect the specific types of GitHub API errors occurring
2. **Debugging**: Detailed error information is preserved without exposing potentially sensitive data in logs
3. **Validation**: Remote servers can use error types and HTTP status codes to validate that changes don't break functionality
4. **Privacy**: Error inspection can be done programmatically using `errors.Is` checks without logging PII
1. **Type Safety**: Errors are embedded in the result as typed Go errors, not just strings
2. **Observability**: Middleware can inspect the specific types of GitHub API errors using `errors.As`
3. **Simplicity**: No context-based error storage or middleware setup required
4. **Debugging**: Detailed error information (HTTP status codes, response objects) is preserved
5. **Privacy**: Error inspection can be done programmatically using `errors.Is`/`errors.As` checks

## Example Implementation

Expand All @@ -102,12 +125,12 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}

issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
Expand All @@ -116,10 +139,23 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool
err,
), nil
}

return MarshalledTextResult(issue), nil
}
}
```

This approach ensures that both the client receives an appropriate error response and any middleware can inspect the underlying GitHub API error for monitoring and debugging purposes.
The error can then be inspected by consumers:

```go
result, err := handler(ctx, request)
if err == nil && result.IsError {
if apiErr := result.GetError(); apiErr != nil {
var ghErr *errors.GitHubAPIError
if errors.As(apiErr, &ghErr) {
log.Printf("GitHub API error: status=%d message=%s",
ghErr.Response.StatusCode, ghErr.Message)
}
}
}
```
3 changes: 0 additions & 3 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"syscall"
"time"

"github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/http/transport"
"github.com/github/github-mcp-server/pkg/inventory"
Expand Down Expand Up @@ -298,8 +297,6 @@ func RunStdioServer(cfg StdioServerConfig) error {
in, out = loggedIO, loggedIO
}

// enable GitHub errors in the context
ctx := errors.ContextWithGitHubErrors(ctx)
errC <- ghServer.Run(ctx, &mcp.IOTransport{Reader: in, Writer: out})
}()

Expand Down
157 changes: 43 additions & 114 deletions pkg/errors/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,20 @@ import (
"fmt"
"net/http"

"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/go-github/v82/github"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

// GitHubAPIError represents an error from the GitHub REST API.
// Use errors.As to extract this from a CallToolResult.GetError().
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 {
func NewGitHubAPIError(message string, resp *github.Response, err error) *GitHubAPIError {
return &GitHubAPIError{
Message: message,
Response: resp,
Expand All @@ -29,12 +30,19 @@ func (e *GitHubAPIError) Error() string {
return fmt.Errorf("%s: %w", e.Message, e.Err).Error()
}

func (e *GitHubAPIError) Unwrap() error {
return e.Err
}

// GitHubGraphQLError represents an error from the GitHub GraphQL API.
// Use errors.As to extract this from a CallToolResult.GetError().
type GitHubGraphQLError struct {
Message string `json:"message"`
Err error `json:"-"`
}

func newGitHubGraphQLError(message string, err error) *GitHubGraphQLError {
// NewGitHubGraphQLError creates a new GitHubGraphQLError with the provided message and error.
func NewGitHubGraphQLError(message string, err error) *GitHubGraphQLError {
return &GitHubGraphQLError{
Message: message,
Err: err,
Expand All @@ -45,13 +53,20 @@ func (e *GitHubGraphQLError) Error() string {
return fmt.Errorf("%s: %w", e.Message, e.Err).Error()
}

func (e *GitHubGraphQLError) Unwrap() error {
return e.Err
}

// GitHubRawAPIError represents an error from a raw HTTP GitHub API call.
// Use errors.As to extract this from a CallToolResult.GetError().
type GitHubRawAPIError struct {
Message string `json:"message"`
Response *http.Response `json:"-"`
Err error `json:"-"`
}

func newGitHubRawAPIError(message string, resp *http.Response, err error) *GitHubRawAPIError {
// NewGitHubRawAPIError creates a new GitHubRawAPIError with the provided message, response, and error.
func NewGitHubRawAPIError(message string, resp *http.Response, err error) *GitHubRawAPIError {
return &GitHubRawAPIError{
Message: message,
Response: resp,
Expand All @@ -63,126 +78,40 @@ 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
func (e *GitHubRawAPIError) Unwrap() error {
return e.Err
}

// 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
// NewGitHubAPIErrorResponse returns a CallToolResult with the error set via SetError,
// embedding a typed GitHubAPIError accessible via result.GetError() and errors.As.
func NewGitHubAPIErrorResponse(_ context.Context, message string, resp *github.Response, err error) *mcp.CallToolResult {
apiErr := NewGitHubAPIError(message, resp, err)
var result mcp.CallToolResult
result.SetError(apiErr)
return &result
}

// 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")
// NewGitHubGraphQLErrorResponse returns a CallToolResult with the error set via SetError,
// embedding a typed GitHubGraphQLError accessible via result.GetError() and errors.As.
func NewGitHubGraphQLErrorResponse(_ context.Context, message string, err error) *mcp.CallToolResult {
graphQLErr := NewGitHubGraphQLError(message, err)
var result mcp.CallToolResult
result.SetError(graphQLErr)
return &result
}

// 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
}
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)
// NewGitHubRawAPIErrorResponse returns a CallToolResult with the error set via SetError,
// embedding a typed GitHubRawAPIError accessible via result.GetError() and errors.As.
func NewGitHubRawAPIErrorResponse(_ context.Context, message string, resp *http.Response, err error) *mcp.CallToolResult {
rawErr := NewGitHubRawAPIError(message, resp, err)
var result mcp.CallToolResult
result.SetError(rawErr)
return &result
}

// 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.
// status code and response body, then sets it on the result via SetError.
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)
Expand Down
Loading