github/github-mcp-server · error

installation token response did not contain a token

Error message

installation token response did not contain a token

What it means

decodeBlameCursor parses the 'after' pagination parameter of get_file_blame. Cursors are opaque values minted by encodeBlameCursor: base64.RawURLEncoding of "blame-range:"+integer offset. This variant fires at the first guard when base64.RawURLEncoding.DecodeString rejects the input, meaning it is not unpadded URL-safe base64 - it contains '+', '/', '=' padding, or non-alphabet characters.

Source

Thrown at internal/githubapp/githubapp.go:169

	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode != http.StatusCreated {
		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

View on GitHub (pinned to 0ea1f775a7)

Solutions

  1. Echo the cursor exactly as returned in the previous get_file_blame response's pageInfo - never re-encode it
  2. If constructing one yourself: base64.RawURLEncoding.EncodeToString([]byte("blame-range:100"))
  3. Send after as the plain JSON string value with no URL encoding or trimming
  4. If pagination state is corrupted, restart from after:"" and re-page

Example fix

// before: standard base64 with '=' padding is rejected by RawURLEncoding
{"owner":"octocat","repo":"Hello-World","path":"README.md","after":"YmxhbWUtcmFuZ2U6MTAw="}

// after: unpadded base64url, exactly as returned by the previous response
{"owner":"octocat","repo":"Hello-World","path":"README.md","after":"YmxhbWUtcmFuZ2U6MTAw"}
Defensive patterns

Strategy: validation

Validate before calling

// Validate the 'after' cursor client-side before calling get_file_blame.
func validBlameCursorEncoding(s string) bool {
	if s == "" {
		return true // first page
	}
	if _, err := base64.RawURLEncoding.DecodeString(s); err != nil {
		return false // not unpadded URL-safe base64
	}
	return true
}

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) {
	// cursor state is corrupt: restart pagination from the first page
	args["after"] = ""
	res, err = callGetFileBlame(ctx, args)
}

Prevention

When it happens

Trigger: Passing a standard-alphabet base64 cursor (with '+', '/', or trailing '='), a truncated or whitespace-padded cursor, a cursor that survived URL-encoding as literal '%xx', or arbitrary free text in the after field.

Common situations: Client re-encodes the returned cursor with std base64 instead of passing it through; JSON layers that escape or trim the value; hand-building cursors instead of echoing the previous page's nextCursor.

Related errors


AI-assisted analysis of github/github-mcp-server@0ea1f775a7 (2026-08-15). Data as JSON: /api/errors/609170d3b7494a58. Report an issue: GitHub.