Skip to content

Commit bb3528c

Browse files
author
root
committed
fix: fall back when get_check_runs lacks Checks API access
Hosted MCP tokens often lack checks:read even when other PR read methods succeed. Retry via workflow runs and commit statuses on 403, and return clearer permission guidance when all sources are denied. Closes #2381
1 parent 5f73549 commit bb3528c

5 files changed

Lines changed: 426 additions & 72 deletions

File tree

pkg/github/check_runs.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
10+
"github.com/google/go-github/v87/github"
11+
"github.com/modelcontextprotocol/go-sdk/mcp"
12+
13+
ghErrors "github.com/github/github-mcp-server/pkg/errors"
14+
"github.com/github/github-mcp-server/pkg/utils"
15+
)
16+
17+
const (
18+
checkRunsSourceChecksAPI = "checks_api"
19+
checkRunsSourceWorkflowRuns = "workflow_runs"
20+
checkRunsSourceCommitStatuses = "commit_statuses"
21+
)
22+
23+
func isAccessDenied(resp *github.Response) bool {
24+
return resp != nil && resp.StatusCode == http.StatusForbidden
25+
}
26+
27+
func checkRunsAccessErrMsg(base, owner, repo string) string {
28+
return fmt.Sprintf("%s. Check runs require the Checks API (checks:read for GitHub Apps, repo scope for classic PATs). "+
29+
"When using hosted MCP, the GitHub App installation must include Checks: Read permission. "+
30+
"Fallbacks using workflow runs and commit statuses were also unavailable for %s/%s.",
31+
base, owner, repo)
32+
}
33+
34+
func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) {
35+
headSHA, errResult, err := getPullRequestHeadSHA(ctx, client, owner, repo, pullNumber)
36+
if errResult != nil || err != nil {
37+
return errResult, err
38+
}
39+
40+
result, resp, err := fetchCheckRunsFromChecksAPI(ctx, client, owner, repo, headSHA, pagination)
41+
if err == nil {
42+
return marshalCheckRunsResult(result)
43+
}
44+
if !isAccessDenied(resp) {
45+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get check runs", resp, err), nil
46+
}
47+
48+
// Checks API is unavailable (common on hosted MCP without checks:read). Try fallbacks.
49+
if fallback, fbResp, fbErr := fetchCheckRunsFromWorkflowRuns(ctx, client, owner, repo, headSHA, pagination); fbErr == nil {
50+
return marshalCheckRunsResult(fallback)
51+
} else if fbResp != nil && !isAccessDenied(fbResp) {
52+
_ = fbResp.Body.Close()
53+
}
54+
55+
if fallback, fbResp, fbErr := fetchCheckRunsFromCommitStatuses(ctx, client, owner, repo, headSHA, pagination); fbErr == nil {
56+
return marshalCheckRunsResult(fallback)
57+
} else if fbResp != nil {
58+
_ = fbResp.Body.Close()
59+
}
60+
61+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
62+
checkRunsAccessErrMsg("failed to get check runs", owner, repo),
63+
resp,
64+
err,
65+
), nil
66+
}
67+
68+
func getPullRequestHeadSHA(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (string, *mcp.CallToolResult, error) {
69+
pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber)
70+
if err != nil {
71+
return "", ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request", resp, err), nil
72+
}
73+
defer resp.Body.Close()
74+
75+
if resp.StatusCode != http.StatusOK {
76+
body, readErr := readResponseBody(resp)
77+
if readErr != nil {
78+
return "", nil, readErr
79+
}
80+
return "", ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil
81+
}
82+
83+
return pr.GetHead().GetSHA(), nil, nil
84+
}
85+
86+
func fetchCheckRunsFromChecksAPI(ctx context.Context, client *github.Client, owner, repo, headSHA string, pagination PaginationParams) (MinimalCheckRunsResult, *github.Response, error) {
87+
opts := &github.ListCheckRunsOptions{
88+
ListOptions: github.ListOptions{
89+
PerPage: pagination.PerPage,
90+
Page: pagination.Page,
91+
},
92+
}
93+
94+
checkRuns, resp, err := client.Checks.ListCheckRunsForRef(ctx, owner, repo, headSHA, opts)
95+
if err != nil {
96+
return MinimalCheckRunsResult{}, resp, err
97+
}
98+
defer resp.Body.Close()
99+
100+
if resp.StatusCode != http.StatusOK {
101+
body, readErr := readResponseBody(resp)
102+
if readErr != nil {
103+
return MinimalCheckRunsResult{}, resp, readErr
104+
}
105+
return MinimalCheckRunsResult{}, resp, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
106+
}
107+
108+
minimalCheckRuns := make([]MinimalCheckRun, 0, len(checkRuns.CheckRuns))
109+
for _, checkRun := range checkRuns.CheckRuns {
110+
minimalCheckRuns = append(minimalCheckRuns, convertToMinimalCheckRun(checkRun))
111+
}
112+
113+
return MinimalCheckRunsResult{
114+
TotalCount: checkRuns.GetTotal(),
115+
CheckRuns: minimalCheckRuns,
116+
Source: checkRunsSourceChecksAPI,
117+
}, resp, nil
118+
}
119+
120+
func fetchCheckRunsFromWorkflowRuns(ctx context.Context, client *github.Client, owner, repo, headSHA string, pagination PaginationParams) (MinimalCheckRunsResult, *github.Response, error) {
121+
opts := &github.ListWorkflowRunsOptions{
122+
HeadSHA: headSHA,
123+
ListOptions: github.ListOptions{
124+
PerPage: pagination.PerPage,
125+
Page: pagination.Page,
126+
},
127+
}
128+
129+
runs, resp, err := client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, opts)
130+
if err != nil {
131+
return MinimalCheckRunsResult{}, resp, err
132+
}
133+
defer resp.Body.Close()
134+
135+
if resp.StatusCode != http.StatusOK {
136+
body, readErr := readResponseBody(resp)
137+
if readErr != nil {
138+
return MinimalCheckRunsResult{}, resp, readErr
139+
}
140+
return MinimalCheckRunsResult{}, resp, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
141+
}
142+
143+
minimalCheckRuns := make([]MinimalCheckRun, 0, len(runs.WorkflowRuns))
144+
for _, run := range runs.WorkflowRuns {
145+
minimalCheckRuns = append(minimalCheckRuns, convertWorkflowRunToMinimalCheckRun(run))
146+
}
147+
148+
return MinimalCheckRunsResult{
149+
TotalCount: runs.GetTotalCount(),
150+
CheckRuns: minimalCheckRuns,
151+
Source: checkRunsSourceWorkflowRuns,
152+
}, resp, nil
153+
}
154+
155+
func fetchCheckRunsFromCommitStatuses(ctx context.Context, client *github.Client, owner, repo, headSHA string, pagination PaginationParams) (MinimalCheckRunsResult, *github.Response, error) {
156+
opts := &github.ListOptions{
157+
PerPage: pagination.PerPage,
158+
Page: pagination.Page,
159+
}
160+
161+
statuses, resp, err := client.Repositories.ListStatuses(ctx, owner, repo, headSHA, opts)
162+
if err != nil {
163+
return MinimalCheckRunsResult{}, resp, err
164+
}
165+
defer resp.Body.Close()
166+
167+
if resp.StatusCode != http.StatusOK {
168+
body, readErr := readResponseBody(resp)
169+
if readErr != nil {
170+
return MinimalCheckRunsResult{}, resp, readErr
171+
}
172+
return MinimalCheckRunsResult{}, resp, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
173+
}
174+
175+
minimalCheckRuns := make([]MinimalCheckRun, 0, len(statuses))
176+
for _, status := range statuses {
177+
minimalCheckRuns = append(minimalCheckRuns, convertCommitStatusToMinimalCheckRun(status))
178+
}
179+
180+
return MinimalCheckRunsResult{
181+
TotalCount: len(minimalCheckRuns),
182+
CheckRuns: minimalCheckRuns,
183+
Source: checkRunsSourceCommitStatuses,
184+
}, resp, nil
185+
}
186+
187+
func marshalCheckRunsResult(result MinimalCheckRunsResult) (*mcp.CallToolResult, error) {
188+
r, err := json.Marshal(result)
189+
if err != nil {
190+
return nil, fmt.Errorf("failed to marshal response: %w", err)
191+
}
192+
return utils.NewToolResultText(string(r)), nil
193+
}
194+
195+
func readResponseBody(resp *github.Response) ([]byte, error) {
196+
body, err := io.ReadAll(resp.Body)
197+
if err != nil {
198+
return nil, fmt.Errorf("failed to read response body: %w", err)
199+
}
200+
return body, nil
201+
}

pkg/github/check_runs_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package github
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/google/go-github/v87/github"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func Test_convertWorkflowRunToMinimalCheckRun(t *testing.T) {
12+
startedAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
13+
updatedAt := time.Date(2026, 1, 2, 3, 10, 0, 0, time.UTC)
14+
15+
run := &github.WorkflowRun{
16+
ID: github.Ptr(int64(42)),
17+
Name: github.Ptr("CI"),
18+
Status: github.Ptr("completed"),
19+
Conclusion: github.Ptr("failure"),
20+
HTMLURL: github.Ptr("https://github.com/o/r/actions/runs/42"),
21+
RunStartedAt: &github.Timestamp{Time: startedAt},
22+
UpdatedAt: &github.Timestamp{Time: updatedAt},
23+
}
24+
25+
result := convertWorkflowRunToMinimalCheckRun(run)
26+
27+
assert.Equal(t, int64(42), result.ID)
28+
assert.Equal(t, "CI", result.Name)
29+
assert.Equal(t, "completed", result.Status)
30+
assert.Equal(t, "failure", result.Conclusion)
31+
assert.Equal(t, "https://github.com/o/r/actions/runs/42", result.HTMLURL)
32+
assert.Equal(t, "2026-01-02T03:04:05Z", result.StartedAt)
33+
assert.Equal(t, "2026-01-02T03:10:00Z", result.CompletedAt)
34+
}
35+
36+
func Test_convertCommitStatusToMinimalCheckRun(t *testing.T) {
37+
createdAt := time.Date(2026, 2, 1, 12, 0, 0, 0, time.UTC)
38+
updatedAt := time.Date(2026, 2, 1, 12, 5, 0, 0, time.UTC)
39+
40+
status := &github.RepoStatus{
41+
ID: github.Ptr(int64(9)),
42+
Context: github.Ptr("ci/build"),
43+
State: github.Ptr("success"),
44+
TargetURL: github.Ptr("https://ci.example.com/build/9"),
45+
CreatedAt: &github.Timestamp{Time: createdAt},
46+
UpdatedAt: &github.Timestamp{Time: updatedAt},
47+
}
48+
49+
result := convertCommitStatusToMinimalCheckRun(status)
50+
51+
assert.Equal(t, int64(9), result.ID)
52+
assert.Equal(t, "ci/build", result.Name)
53+
assert.Equal(t, "completed", result.Status)
54+
assert.Equal(t, "success", result.Conclusion)
55+
assert.Equal(t, "https://ci.example.com/build/9", result.DetailsURL)
56+
}
57+
58+
func Test_convertCommitStatusToMinimalCheckRun_pending(t *testing.T) {
59+
status := &github.RepoStatus{
60+
ID: github.Ptr(int64(1)),
61+
Context: github.Ptr("ci/build"),
62+
State: github.Ptr("pending"),
63+
}
64+
65+
result := convertCommitStatusToMinimalCheckRun(status)
66+
67+
assert.Equal(t, "in_progress", result.Status)
68+
assert.Empty(t, result.Conclusion)
69+
}

pkg/github/minimal_types.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1665,6 +1665,8 @@ type MinimalCheckRun struct {
16651665
type MinimalCheckRunsResult struct {
16661666
TotalCount int `json:"total_count"`
16671667
CheckRuns []MinimalCheckRun `json:"check_runs"`
1668+
// Source indicates which API provided the data: "checks_api", "workflow_runs", or "commit_statuses".
1669+
Source string `json:"source,omitempty"`
16681670
}
16691671

16701672
// convertToMinimalCheckRun converts a GitHub API CheckRun to MinimalCheckRun
@@ -1688,6 +1690,56 @@ func convertToMinimalCheckRun(checkRun *github.CheckRun) MinimalCheckRun {
16881690
return minimalCheckRun
16891691
}
16901692

1693+
func convertWorkflowRunToMinimalCheckRun(run *github.WorkflowRun) MinimalCheckRun {
1694+
status := run.GetStatus()
1695+
conclusion := run.GetConclusion()
1696+
1697+
minimalCheckRun := MinimalCheckRun{
1698+
ID: run.GetID(),
1699+
Name: run.GetName(),
1700+
Status: status,
1701+
Conclusion: conclusion,
1702+
HTMLURL: run.GetHTMLURL(),
1703+
DetailsURL: run.GetHTMLURL(),
1704+
}
1705+
1706+
if run.RunStartedAt != nil {
1707+
minimalCheckRun.StartedAt = run.RunStartedAt.Format("2006-01-02T15:04:05Z")
1708+
}
1709+
if run.UpdatedAt != nil && status == "completed" {
1710+
minimalCheckRun.CompletedAt = run.UpdatedAt.Format("2006-01-02T15:04:05Z")
1711+
}
1712+
1713+
return minimalCheckRun
1714+
}
1715+
1716+
func convertCommitStatusToMinimalCheckRun(status *github.RepoStatus) MinimalCheckRun {
1717+
state := status.GetState()
1718+
conclusion := state
1719+
checkStatus := "completed"
1720+
if state == "pending" {
1721+
checkStatus = "in_progress"
1722+
conclusion = ""
1723+
}
1724+
1725+
minimalCheckRun := MinimalCheckRun{
1726+
ID: status.GetID(),
1727+
Name: status.GetContext(),
1728+
Status: checkStatus,
1729+
Conclusion: conclusion,
1730+
DetailsURL: status.GetTargetURL(),
1731+
}
1732+
1733+
if status.CreatedAt != nil {
1734+
minimalCheckRun.StartedAt = status.CreatedAt.Format("2006-01-02T15:04:05Z")
1735+
}
1736+
if status.UpdatedAt != nil {
1737+
minimalCheckRun.CompletedAt = status.UpdatedAt.Format("2006-01-02T15:04:05Z")
1738+
}
1739+
1740+
return minimalCheckRun
1741+
}
1742+
16911743
func convertToMinimalReviewThreadsResponse(query reviewThreadsQuery) MinimalReviewThreadsResponse {
16921744
threads := query.Repository.PullRequest.ReviewThreads
16931745

0 commit comments

Comments
 (0)