Skip to content

Commit df19fc2

Browse files
Replace context-based error handling with CallToolResult.SetError
Migrate from storing GitHub API errors in context via middleware to embedding typed errors directly in CallToolResult using the Go SDK v1.3.0 SetError/GetError API. Key changes: - NewGitHubAPIErrorResponse/NewGitHubGraphQLErrorResponse/ NewGitHubRawAPIErrorResponse now use result.SetError() with typed errors instead of storing in context - Add Unwrap() to all error types for errors.As/errors.Is support - Export error constructors (NewGitHubAPIError, etc.) for direct use - Remove context-based error infrastructure (ContextWithGitHubErrors, GetGitHubAPIErrors, GitHubCtxErrors, etc.) - Remove addGitHubAPIErrorToContext middleware from server.go - Remove NewGitHubAPIErrorToCtx/NewGitHubGraphQLErrorToCtx and their callers in repositories_helper.go and actions.go - Update tests to verify SetError/GetError and errors.As extraction - Update error-handling.md documentation Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com>
1 parent fad946f commit df19fc2

File tree

7 files changed

+250
-558
lines changed

7 files changed

+250
-558
lines changed

docs/error-handling.md

Lines changed: 65 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
# Error Handling
22

3-
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.
3+
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.
44

55
## Overview
66

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

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

12-
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.
12+
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.
1313

1414
## Error Types
1515

@@ -36,38 +36,61 @@ type GitHubGraphQLError struct {
3636
}
3737
```
3838

39+
### GitHubRawAPIError
40+
41+
Used for raw HTTP API errors from the GitHub API:
42+
43+
```go
44+
type GitHubRawAPIError struct {
45+
Message string `json:"message"`
46+
Response *http.Response `json:"-"`
47+
Err error `json:"-"`
48+
}
49+
```
50+
3951
## Usage Patterns
4052

4153
### For GitHub REST API Errors
4254

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

4557
```go
4658
return ghErrors.NewGitHubAPIErrorResponse(ctx, message, response, err), nil
4759
```
4860

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

5466
### For GitHub GraphQL API Errors
5567

5668
```go
5769
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, message, err), nil
5870
```
5971

60-
### Context Management
61-
62-
The error handling system uses context to store errors for later inspection:
72+
### For Raw HTTP API Errors
6373

6474
```go
65-
// Initialize context with error tracking
66-
ctx = errors.ContextWithGitHubErrors(ctx)
75+
return ghErrors.NewGitHubRawAPIErrorResponse(ctx, message, response, err), nil
76+
```
77+
78+
### Extracting Errors from Results
6779

68-
// Retrieve errors for inspection (typically in middleware)
69-
apiErrors, err := errors.GetGitHubAPIErrors(ctx)
70-
graphqlErrors, err := errors.GetGitHubGraphQLErrors(ctx)
80+
Consumers (such as middleware or the remote server) can extract typed errors from results:
81+
82+
```go
83+
if err := result.GetError(); err != nil {
84+
var apiErr *errors.GitHubAPIError
85+
if errors.As(err, &apiErr) {
86+
// Access apiErr.Response.StatusCode, apiErr.Message, etc.
87+
}
88+
89+
var gqlErr *errors.GitHubGraphQLError
90+
if errors.As(err, &gqlErr) {
91+
// Access gqlErr.Message, gqlErr.Err, etc.
92+
}
93+
}
7194
```
7295

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

80-
### Context Limitations
81-
82-
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.
83-
84-
### Graceful Error Handling
103+
### Type Safety with SetError/GetError
85104

86-
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.
105+
All GitHub API error types implement the `error` interface with `Unwrap()` support, enabling:
106+
- `errors.As()` to extract the specific error type (e.g., `*GitHubAPIError`)
107+
- `errors.Is()` to check for the underlying cause
108+
- Standard Go error handling patterns
87109

88110
## Benefits
89111

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

95118
## Example Implementation
96119

@@ -102,12 +125,12 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool
102125
if err != nil {
103126
return mcp.NewToolResultError(err.Error()), nil
104127
}
105-
128+
106129
client, err := getClient(ctx)
107130
if err != nil {
108131
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
109132
}
110-
133+
111134
issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber)
112135
if err != nil {
113136
return ghErrors.NewGitHubAPIErrorResponse(ctx,
@@ -116,10 +139,23 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool
116139
err,
117140
), nil
118141
}
119-
142+
120143
return MarshalledTextResult(issue), nil
121144
}
122145
}
123146
```
124147

125-
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.
148+
The error can then be inspected by consumers:
149+
150+
```go
151+
result, err := handler(ctx, request)
152+
if err == nil && result.IsError {
153+
if apiErr := result.GetError(); apiErr != nil {
154+
var ghErr *errors.GitHubAPIError
155+
if errors.As(apiErr, &ghErr) {
156+
log.Printf("GitHub API error: status=%d message=%s",
157+
ghErr.Response.StatusCode, ghErr.Message)
158+
}
159+
}
160+
}
161+
```

internal/ghmcp/server.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"syscall"
1313
"time"
1414

15-
"github.com/github/github-mcp-server/pkg/errors"
1615
"github.com/github/github-mcp-server/pkg/github"
1716
"github.com/github/github-mcp-server/pkg/http/transport"
1817
"github.com/github/github-mcp-server/pkg/inventory"
@@ -298,8 +297,6 @@ func RunStdioServer(cfg StdioServerConfig) error {
298297
in, out = loggedIO, loggedIO
299298
}
300299

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

pkg/errors/error.go

Lines changed: 43 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,20 @@ import (
55
"fmt"
66
"net/http"
77

8-
"github.com/github/github-mcp-server/pkg/utils"
98
"github.com/google/go-github/v82/github"
109
"github.com/modelcontextprotocol/go-sdk/mcp"
1110
)
1211

12+
// GitHubAPIError represents an error from the GitHub REST API.
13+
// Use errors.As to extract this from a CallToolResult.GetError().
1314
type GitHubAPIError struct {
1415
Message string `json:"message"`
1516
Response *github.Response `json:"-"`
1617
Err error `json:"-"`
1718
}
1819

1920
// NewGitHubAPIError creates a new GitHubAPIError with the provided message, response, and error.
20-
func newGitHubAPIError(message string, resp *github.Response, err error) *GitHubAPIError {
21+
func NewGitHubAPIError(message string, resp *github.Response, err error) *GitHubAPIError {
2122
return &GitHubAPIError{
2223
Message: message,
2324
Response: resp,
@@ -29,12 +30,19 @@ func (e *GitHubAPIError) Error() string {
2930
return fmt.Errorf("%s: %w", e.Message, e.Err).Error()
3031
}
3132

33+
func (e *GitHubAPIError) Unwrap() error {
34+
return e.Err
35+
}
36+
37+
// GitHubGraphQLError represents an error from the GitHub GraphQL API.
38+
// Use errors.As to extract this from a CallToolResult.GetError().
3239
type GitHubGraphQLError struct {
3340
Message string `json:"message"`
3441
Err error `json:"-"`
3542
}
3643

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

56+
func (e *GitHubGraphQLError) Unwrap() error {
57+
return e.Err
58+
}
59+
60+
// GitHubRawAPIError represents an error from a raw HTTP GitHub API call.
61+
// Use errors.As to extract this from a CallToolResult.GetError().
4862
type GitHubRawAPIError struct {
4963
Message string `json:"message"`
5064
Response *http.Response `json:"-"`
5165
Err error `json:"-"`
5266
}
5367

54-
func newGitHubRawAPIError(message string, resp *http.Response, err error) *GitHubRawAPIError {
68+
// NewGitHubRawAPIError creates a new GitHubRawAPIError with the provided message, response, and error.
69+
func NewGitHubRawAPIError(message string, resp *http.Response, err error) *GitHubRawAPIError {
5570
return &GitHubRawAPIError{
5671
Message: message,
5772
Response: resp,
@@ -63,126 +78,40 @@ func (e *GitHubRawAPIError) Error() string {
6378
return fmt.Errorf("%s: %w", e.Message, e.Err).Error()
6479
}
6580

66-
type GitHubErrorKey struct{}
67-
type GitHubCtxErrors struct {
68-
api []*GitHubAPIError
69-
graphQL []*GitHubGraphQLError
70-
raw []*GitHubRawAPIError
81+
func (e *GitHubRawAPIError) Unwrap() error {
82+
return e.Err
7183
}
7284

73-
// ContextWithGitHubErrors updates or creates a context with a pointer to GitHub error information (to be used by middleware).
74-
func ContextWithGitHubErrors(ctx context.Context) context.Context {
75-
if ctx == nil {
76-
ctx = context.Background()
77-
}
78-
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
79-
// If the context already has GitHubCtxErrors, we just empty the slices to start fresh
80-
val.api = []*GitHubAPIError{}
81-
val.graphQL = []*GitHubGraphQLError{}
82-
val.raw = []*GitHubRawAPIError{}
83-
} else {
84-
// If not, we create a new GitHubCtxErrors and set it in the context
85-
ctx = context.WithValue(ctx, GitHubErrorKey{}, &GitHubCtxErrors{})
86-
}
87-
88-
return ctx
85+
// NewGitHubAPIErrorResponse returns a CallToolResult with the error set via SetError,
86+
// embedding a typed GitHubAPIError accessible via result.GetError() and errors.As.
87+
func NewGitHubAPIErrorResponse(_ context.Context, message string, resp *github.Response, err error) *mcp.CallToolResult {
88+
apiErr := NewGitHubAPIError(message, resp, err)
89+
var result mcp.CallToolResult
90+
result.SetError(apiErr)
91+
return &result
8992
}
9093

91-
// GetGitHubAPIErrors retrieves the slice of GitHubAPIErrors from the context.
92-
func GetGitHubAPIErrors(ctx context.Context) ([]*GitHubAPIError, error) {
93-
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
94-
return val.api, nil // return the slice of API errors from the context
95-
}
96-
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
94+
// NewGitHubGraphQLErrorResponse returns a CallToolResult with the error set via SetError,
95+
// embedding a typed GitHubGraphQLError accessible via result.GetError() and errors.As.
96+
func NewGitHubGraphQLErrorResponse(_ context.Context, message string, err error) *mcp.CallToolResult {
97+
graphQLErr := NewGitHubGraphQLError(message, err)
98+
var result mcp.CallToolResult
99+
result.SetError(graphQLErr)
100+
return &result
97101
}
98102

99-
// GetGitHubGraphQLErrors retrieves the slice of GitHubGraphQLErrors from the context.
100-
func GetGitHubGraphQLErrors(ctx context.Context) ([]*GitHubGraphQLError, error) {
101-
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
102-
return val.graphQL, nil // return the slice of GraphQL errors from the context
103-
}
104-
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
105-
}
106-
107-
// GetGitHubRawAPIErrors retrieves the slice of GitHubRawAPIErrors from the context.
108-
func GetGitHubRawAPIErrors(ctx context.Context) ([]*GitHubRawAPIError, error) {
109-
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
110-
return val.raw, nil // return the slice of raw API errors from the context
111-
}
112-
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
113-
}
114-
115-
func NewGitHubAPIErrorToCtx(ctx context.Context, message string, resp *github.Response, err error) (context.Context, error) {
116-
apiErr := newGitHubAPIError(message, resp, err)
117-
if ctx != nil {
118-
_, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling
119-
}
120-
return ctx, nil
121-
}
122-
123-
func NewGitHubGraphQLErrorToCtx(ctx context.Context, message string, err error) (context.Context, error) {
124-
graphQLErr := newGitHubGraphQLError(message, err)
125-
if ctx != nil {
126-
_, _ = addGitHubGraphQLErrorToContext(ctx, graphQLErr) // Explicitly ignore error for graceful handling
127-
}
128-
return ctx, nil
129-
}
130-
131-
func addGitHubAPIErrorToContext(ctx context.Context, err *GitHubAPIError) (context.Context, error) {
132-
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
133-
val.api = append(val.api, err) // append the error to the existing slice in the context
134-
return ctx, nil
135-
}
136-
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
137-
}
138-
139-
func addGitHubGraphQLErrorToContext(ctx context.Context, err *GitHubGraphQLError) (context.Context, error) {
140-
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
141-
val.graphQL = append(val.graphQL, err) // append the error to the existing slice in the context
142-
return ctx, nil
143-
}
144-
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
145-
}
146-
147-
func addRawAPIErrorToContext(ctx context.Context, err *GitHubRawAPIError) (context.Context, error) {
148-
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
149-
val.raw = append(val.raw, err)
150-
return ctx, nil
151-
}
152-
153-
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
154-
}
155-
156-
// NewGitHubAPIErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware
157-
func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github.Response, err error) *mcp.CallToolResult {
158-
apiErr := newGitHubAPIError(message, resp, err)
159-
if ctx != nil {
160-
_, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling
161-
}
162-
return utils.NewToolResultErrorFromErr(message, err)
163-
}
164-
165-
// NewGitHubGraphQLErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware
166-
func NewGitHubGraphQLErrorResponse(ctx context.Context, message string, err error) *mcp.CallToolResult {
167-
graphQLErr := newGitHubGraphQLError(message, err)
168-
if ctx != nil {
169-
_, _ = addGitHubGraphQLErrorToContext(ctx, graphQLErr) // Explicitly ignore error for graceful handling
170-
}
171-
return utils.NewToolResultErrorFromErr(message, err)
172-
}
173-
174-
// NewGitHubRawAPIErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware
175-
func NewGitHubRawAPIErrorResponse(ctx context.Context, message string, resp *http.Response, err error) *mcp.CallToolResult {
176-
rawErr := newGitHubRawAPIError(message, resp, err)
177-
if ctx != nil {
178-
_, _ = addRawAPIErrorToContext(ctx, rawErr) // Explicitly ignore error for graceful handling
179-
}
180-
return utils.NewToolResultErrorFromErr(message, err)
103+
// NewGitHubRawAPIErrorResponse returns a CallToolResult with the error set via SetError,
104+
// embedding a typed GitHubRawAPIError accessible via result.GetError() and errors.As.
105+
func NewGitHubRawAPIErrorResponse(_ context.Context, message string, resp *http.Response, err error) *mcp.CallToolResult {
106+
rawErr := NewGitHubRawAPIError(message, resp, err)
107+
var result mcp.CallToolResult
108+
result.SetError(rawErr)
109+
return &result
181110
}
182111

183112
// NewGitHubAPIStatusErrorResponse handles cases where the API call succeeds (err == nil)
184113
// but returns an unexpected HTTP status code. It creates a synthetic error from the
185-
// status code and response body, then records it in context for observability tracking.
114+
// status code and response body, then sets it on the result via SetError.
186115
func NewGitHubAPIStatusErrorResponse(ctx context.Context, message string, resp *github.Response, body []byte) *mcp.CallToolResult {
187116
err := fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
188117
return NewGitHubAPIErrorResponse(ctx, message, resp, err)

0 commit comments

Comments
 (0)