github/github-mcp-server · error
installation token response did not contain an expiry
Error message
installation token response did not contain an expiry
What it means
Second guard inside decodeBlameCursor: the value decoded successfully as base64url but does not start with the blameCursorPrefix constant "blame-range:". The server therefore recognizes the bytes as base64 but not as one of its own blame cursors. Cursors from other tools or versions use different internal formats and fail here.
Source
Thrown at internal/githubapp/githubapp.go:172
snippet, readErr := io.ReadAll(io.LimitReader(resp.Body, 512))
if readErr != nil {
return nil, fmt.Errorf("installation token request failed: %s (reading response: %w)", resp.Status, readErr)
}
return nil, fmt.Errorf("installation token request failed: %s: %s", resp.Status, strings.TrimSpace(string(snippet)))
}
var body struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("decoding installation token response: %w", err)
}
if body.Token == "" {
return nil, errors.New("installation token response did not contain a token")
}
if body.ExpiresAt.IsZero() {
return nil, errors.New("installation token response did not contain an expiry")
}
return &oauth2.Token{
AccessToken: body.Token,
TokenType: "token",
Expiry: body.ExpiresAt.Add(-refreshBuffer),
}, nil
}
// Provider caches and refreshes GitHub App installation access tokens.
type Provider struct {
source oauth2.TokenSource
logger *slog.Logger
mu sync.Mutex
errLogged bool
}
func NewProvider(cfg Config, logger *slog.Logger) (*Provider, error) {View on GitHub (pinned to 0ea1f775a7)
Solutions
- Use only the pageInfo/nextCursor value returned by a prior get_file_blame call on the same server version
- Build cursors with the exact format: base64url("blame-range:"+offset)
- Reset pagination with after:"" when switching tools or after a server upgrade
- Keep per-tool cursor state instead of a shared cursor variable
Example fix
// before: GraphQL cursor from a different tool
{"after":"Y3Vyc29yOnYyOpHOAA=="}
// after: blame cursor from the previous get_file_blame page
{"after":"YmxhbWUtcmFuZ2U6MTAw"} Defensive patterns
Strategy: validation
Validate before calling
func validBlameCursorPrefix(s string) bool {
if s == "" {
return true
}
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return false
}
return strings.HasPrefix(string(b), "blame-range:")
} Type guard
func isInvalidCursorError(err error) bool {
return err != nil && strings.Contains(err.Error(), "after cursor is invalid")
} Try / catch
res, err := callGetFileBlame(ctx, args)
if isInvalidCursorError(err) {
// wrong cursor source (e.g. a GraphQL cursor from another tool): reset
args["after"] = ""
res, err = callGetFileBlame(ctx, args)
} Prevention
- Keep one cursor variable per tool - never share cursors between get_file_blame and list tools
- Reset stored cursors after upgrading github-mcp-server
- Only feed back values the same tool's pageInfo returned
- Empty string is the only valid 'start over' cursor
When it happens
Trigger: Reusing a GitHub GraphQL page cursor (base64 of "cursor:...") from list tools as get_file_blame's after; base64-encoding a bare integer without the prefix; cursors minted by a github-mcp-server version that used a different prefix.
Common situations: Generic pagination helpers that share one cursor variable across tools; copy-pasting cursors between sessions/tools; upgrading the server across a cursor format change while keeping stale cursor state.
Related errors
- OAuth callback listener could not bind
- installation token response did not contain a token
- perPage value %d exceeds maximum of 100
- perPage value %d cannot be negative
- authorization prompt has expired
AI-assisted analysis of github/github-mcp-server@0ea1f775a7 (2026-08-15).
Data as JSON: /api/errors/2f353d9a8093a87d.
Report an issue: GitHub.