Skip to content

Commit 49c3197

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 457f599 commit 49c3197

5 files changed

Lines changed: 436 additions & 72 deletions

File tree

pkg/github/check_runs.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
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+
closeResponse(resp)
48+
49+
// Checks API is unavailable (common on hosted MCP without checks:read). Try fallbacks.
50+
workflowFallback, workflowResp, workflowErr := fetchCheckRunsFromWorkflowRuns(ctx, client, owner, repo, headSHA, pagination)
51+
if workflowErr == nil {
52+
return marshalCheckRunsResult(workflowFallback)
53+
}
54+
if !isAccessDenied(workflowResp) {
55+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get check runs", workflowResp, workflowErr), nil
56+
}
57+
closeResponse(workflowResp)
58+
59+
statusFallback, statusResp, statusErr := fetchCheckRunsFromCommitStatuses(ctx, client, owner, repo, headSHA, pagination)
60+
if statusErr == nil {
61+
return marshalCheckRunsResult(statusFallback)
62+
}
63+
closeResponse(statusResp)
64+
65+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
66+
checkRunsAccessErrMsg("failed to get check runs", owner, repo),
67+
resp,
68+
err,
69+
), nil
70+
}
71+
72+
func getPullRequestHeadSHA(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (string, *mcp.CallToolResult, error) {
73+
pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber)
74+
if err != nil {
75+
return "", ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request", resp, err), nil
76+
}
77+
defer resp.Body.Close()
78+
79+
if resp.StatusCode != http.StatusOK {
80+
body, readErr := readResponseBody(resp)
81+
if readErr != nil {
82+
return "", nil, readErr
83+
}
84+
return "", ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil
85+
}
86+
87+
return pr.GetHead().GetSHA(), nil, nil
88+
}
89+
90+
func fetchCheckRunsFromChecksAPI(ctx context.Context, client *github.Client, owner, repo, headSHA string, pagination PaginationParams) (MinimalCheckRunsResult, *github.Response, error) {
91+
opts := &github.ListCheckRunsOptions{
92+
ListOptions: github.ListOptions{
93+
PerPage: pagination.PerPage,
94+
Page: pagination.Page,
95+
},
96+
}
97+
98+
checkRuns, resp, err := client.Checks.ListCheckRunsForRef(ctx, owner, repo, headSHA, opts)
99+
if err != nil {
100+
return MinimalCheckRunsResult{}, resp, err
101+
}
102+
defer resp.Body.Close()
103+
104+
if resp.StatusCode != http.StatusOK {
105+
body, readErr := readResponseBody(resp)
106+
if readErr != nil {
107+
return MinimalCheckRunsResult{}, resp, readErr
108+
}
109+
return MinimalCheckRunsResult{}, resp, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
110+
}
111+
112+
minimalCheckRuns := make([]MinimalCheckRun, 0, len(checkRuns.CheckRuns))
113+
for _, checkRun := range checkRuns.CheckRuns {
114+
minimalCheckRuns = append(minimalCheckRuns, convertToMinimalCheckRun(checkRun))
115+
}
116+
117+
return MinimalCheckRunsResult{
118+
TotalCount: checkRuns.GetTotal(),
119+
CheckRuns: minimalCheckRuns,
120+
Source: checkRunsSourceChecksAPI,
121+
}, resp, nil
122+
}
123+
124+
func fetchCheckRunsFromWorkflowRuns(ctx context.Context, client *github.Client, owner, repo, headSHA string, pagination PaginationParams) (MinimalCheckRunsResult, *github.Response, error) {
125+
opts := &github.ListWorkflowRunsOptions{
126+
HeadSHA: headSHA,
127+
ListOptions: github.ListOptions{
128+
PerPage: pagination.PerPage,
129+
Page: pagination.Page,
130+
},
131+
}
132+
133+
runs, resp, err := client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, opts)
134+
if err != nil {
135+
return MinimalCheckRunsResult{}, resp, err
136+
}
137+
defer resp.Body.Close()
138+
139+
if resp.StatusCode != http.StatusOK {
140+
body, readErr := readResponseBody(resp)
141+
if readErr != nil {
142+
return MinimalCheckRunsResult{}, resp, readErr
143+
}
144+
return MinimalCheckRunsResult{}, resp, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
145+
}
146+
147+
minimalCheckRuns := make([]MinimalCheckRun, 0, len(runs.WorkflowRuns))
148+
for _, run := range runs.WorkflowRuns {
149+
minimalCheckRuns = append(minimalCheckRuns, convertWorkflowRunToMinimalCheckRun(run))
150+
}
151+
152+
return MinimalCheckRunsResult{
153+
TotalCount: runs.GetTotalCount(),
154+
CheckRuns: minimalCheckRuns,
155+
Source: checkRunsSourceWorkflowRuns,
156+
}, resp, nil
157+
}
158+
159+
func fetchCheckRunsFromCommitStatuses(ctx context.Context, client *github.Client, owner, repo, headSHA string, pagination PaginationParams) (MinimalCheckRunsResult, *github.Response, error) {
160+
opts := &github.ListOptions{
161+
PerPage: pagination.PerPage,
162+
Page: pagination.Page,
163+
}
164+
165+
statuses, resp, err := client.Repositories.ListStatuses(ctx, owner, repo, headSHA, opts)
166+
if err != nil {
167+
return MinimalCheckRunsResult{}, resp, err
168+
}
169+
defer resp.Body.Close()
170+
171+
if resp.StatusCode != http.StatusOK {
172+
body, readErr := readResponseBody(resp)
173+
if readErr != nil {
174+
return MinimalCheckRunsResult{}, resp, readErr
175+
}
176+
return MinimalCheckRunsResult{}, resp, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
177+
}
178+
179+
minimalCheckRuns := make([]MinimalCheckRun, 0, len(statuses))
180+
for _, status := range statuses {
181+
minimalCheckRuns = append(minimalCheckRuns, convertCommitStatusToMinimalCheckRun(status))
182+
}
183+
184+
return MinimalCheckRunsResult{
185+
TotalCount: len(minimalCheckRuns),
186+
CheckRuns: minimalCheckRuns,
187+
Source: checkRunsSourceCommitStatuses,
188+
}, resp, nil
189+
}
190+
191+
func marshalCheckRunsResult(result MinimalCheckRunsResult) (*mcp.CallToolResult, error) {
192+
r, err := json.Marshal(result)
193+
if err != nil {
194+
return nil, fmt.Errorf("failed to marshal response: %w", err)
195+
}
196+
return utils.NewToolResultText(string(r)), nil
197+
}
198+
199+
func closeResponse(resp *github.Response) {
200+
if resp != nil && resp.Body != nil {
201+
_ = resp.Body.Close()
202+
}
203+
}
204+
205+
func readResponseBody(resp *github.Response) ([]byte, error) {
206+
body, err := io.ReadAll(resp.Body)
207+
if err != nil {
208+
return nil, fmt.Errorf("failed to read response body: %w", err)
209+
}
210+
return body, nil
211+
}

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)