Skip to content

Commit e23db86

Browse files
committed
Preserve GHES API compatibility
1 parent d124c6b commit e23db86

14 files changed

Lines changed: 215 additions & 32 deletions

File tree

internal/ghmcp/oauth_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,7 @@ func TestCreateGitHubClientsTokenProvider(t *testing.T) {
602602

603603
do()
604604
assert.Equal(t, "", gotAuth, "no auth header before authorization")
605-
assert.Equal(t, headers.GitHubAPIVersion, gotAPIVersion)
605+
assert.Equal(t, headers.GitHubEnterpriseServerAPIVersion, gotAPIVersion)
606606

607607
current = "oauth-token"
608608
do()

internal/githubapp/githubapp.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"time"
2323

2424
"github.com/github/github-mcp-server/pkg/http/headers"
25+
"github.com/github/github-mcp-server/pkg/http/transport"
2526
"golang.org/x/oauth2"
2627
)
2728

@@ -143,7 +144,7 @@ func (s *installationTokenSource) Token() (*oauth2.Token, error) {
143144
}
144145
req.Header.Set(headers.AuthorizationHeader, "Bearer "+jwt)
145146
req.Header.Set(headers.AcceptHeader, "application/vnd.github+json")
146-
req.Header.Set(headers.GitHubAPIVersionHeader, headers.GitHubAPIVersion)
147+
transport.SetGitHubAPIVersionHeader(req)
147148

148149
resp, err := s.httpClient.Do(req)
149150
if err != nil {

internal/githubapp/githubapp_test.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"encoding/json"
1313
"encoding/pem"
1414
"fmt"
15+
"io"
1516
"log/slog"
1617
"net/http"
1718
"net/http/httptest"
@@ -25,6 +26,12 @@ import (
2526
"github.com/stretchr/testify/require"
2627
)
2728

29+
type roundTripFunc func(*http.Request) (*http.Response, error)
30+
31+
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
32+
return f(req)
33+
}
34+
2835
func newTestKey(t *testing.T) *rsa.PrivateKey {
2936
t.Helper()
3037
key, err := rsa.GenerateKey(rand.Reader, 2048)
@@ -156,7 +163,7 @@ func installationServer(t *testing.T, pub *rsa.PublicKey, token string, expiresA
156163
calls.Add(1)
157164
assert.Equal(t, http.MethodPost, r.Method)
158165
assert.Equal(t, "/app/installations/456/access_tokens", r.URL.Path)
159-
assert.Equal(t, headers.GitHubAPIVersion, r.Header.Get(headers.GitHubAPIVersionHeader))
166+
assert.Equal(t, headers.GitHubEnterpriseServerAPIVersion, r.Header.Get(headers.GitHubAPIVersionHeader))
160167

161168
authz := r.Header.Get("Authorization")
162169
require.True(t, strings.HasPrefix(authz, "Bearer "), "must send the app JWT as a bearer token")
@@ -187,6 +194,34 @@ func newTestTokenSource(t *testing.T, cfg Config, client *http.Client) *installa
187194
return newInstallationTokenSource(cfg, privateKey, client)
188195
}
189196

197+
func TestInstallationTokenSourceSetsAPIVersionForGitHubCloud(t *testing.T) {
198+
key := newTestKey(t)
199+
expiresAt := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
200+
201+
for _, baseURL := range []string{"https://api.github.com", "https://api.example.ghe.com"} {
202+
t.Run(baseURL, func(t *testing.T) {
203+
var gotVersion string
204+
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
205+
gotVersion = req.Header.Get(headers.GitHubAPIVersionHeader)
206+
body := fmt.Sprintf(`{"token":"ghs_test","expires_at":%q}`, expiresAt)
207+
return &http.Response{
208+
StatusCode: http.StatusCreated,
209+
Status: "201 Created",
210+
Header: make(http.Header),
211+
Body: io.NopCloser(strings.NewReader(body)),
212+
Request: req,
213+
}, nil
214+
})}
215+
source := newTestTokenSource(t, newTestConfig(key, baseURL), client)
216+
217+
token, err := source.Token()
218+
require.NoError(t, err)
219+
assert.Equal(t, "ghs_test", token.AccessToken)
220+
assert.Equal(t, headers.GitHubAPIVersion, gotVersion)
221+
})
222+
}
223+
}
224+
190225
func TestProviderFetchesToken(t *testing.T) {
191226
key := newTestKey(t)
192227
srv, calls := installationServer(t, &key.PublicKey, "ghs_fresh", time.Now().Add(time.Hour))

pkg/github/__toolsnaps__/search_issues.snap

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
"user",
2323
"author_association",
2424
"labels",
25-
"assignee",
2625
"assignees",
2726
"milestone",
2827
"comments",

pkg/github/__toolsnaps__/search_pull_requests.snap

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
"user",
2323
"author_association",
2424
"labels",
25-
"assignee",
2625
"assignees",
2726
"milestone",
2827
"comments",

pkg/github/dependencies_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func testExporters() observability.Exporters {
3636
return obs
3737
}
3838

39-
func TestRequestDepsGetClientSetsAPIVersion(t *testing.T) {
39+
func TestRequestDepsGetClientPreservesGHESAPIVersion(t *testing.T) {
4040
t.Parallel()
4141

4242
var gotVersion string
@@ -60,7 +60,7 @@ func TestRequestDepsGetClientSetsAPIVersion(t *testing.T) {
6060
require.NoError(t, err)
6161
defer resp.Body.Close()
6262

63-
assert.Equal(t, headers.GitHubAPIVersion, gotVersion)
63+
assert.Equal(t, headers.GitHubEnterpriseServerAPIVersion, gotVersion)
6464
}
6565

6666
func TestIsFeatureEnabled_WithEnabledFlag(t *testing.T) {

pkg/github/minimal_types.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ var listReleasesItemFieldEnum = []any{
7575
// the main lever for shrinking large result sets.
7676
var searchIssuesItemFieldEnum = []any{
7777
"number", "title", "body", "state", "state_reason", "draft", "locked",
78-
"html_url", "user", "author_association", "labels", "assignee", "assignees",
78+
"html_url", "user", "author_association", "labels", "assignees",
7979
"milestone", "comments", "reactions", "created_at", "updated_at", "closed_at",
8080
"closed_by", "type", "repository_url", "pull_request", "field_values",
8181
}
@@ -87,7 +87,7 @@ var searchIssuesItemFieldEnum = []any{
8787
// the main lever for shrinking large result sets.
8888
var searchPullRequestsItemFieldEnum = []any{
8989
"number", "title", "body", "state", "state_reason", "draft", "locked",
90-
"html_url", "user", "author_association", "labels", "assignee", "assignees",
90+
"html_url", "user", "author_association", "labels", "assignees",
9191
"milestone", "comments", "reactions", "created_at", "updated_at", "closed_at",
9292
"closed_by", "pull_request", "repository_url",
9393
}

pkg/http/headers/headers.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ const (
5353
GraphQLFeaturesHeader = "GraphQL-Features"
5454
// GitHubAPIVersionHeader is the header used to specify the GitHub API version.
5555
GitHubAPIVersionHeader = "X-GitHub-Api-Version"
56-
// GitHubAPIVersion is the GitHub REST API version used by this server.
56+
// GitHubAPIVersion is the GitHub REST API version used for GitHub.com and
57+
// GitHub Enterprise Cloud requests.
5758
GitHubAPIVersion = "2026-03-10"
59+
// GitHubEnterpriseServerAPIVersion is the compatibility version used for
60+
// GitHub Enterprise Server requests.
61+
GitHubEnterpriseServerAPIVersion = "2022-11-28"
5862
)

pkg/http/transport/api_version.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,38 @@ import (
44
"net/http"
55

66
"github.com/github/github-mcp-server/pkg/http/headers"
7+
"github.com/github/github-mcp-server/pkg/utils"
78
)
89

9-
// APIVersionTransport sets the GitHub REST API version on every request.
10+
// APIVersionTransport sets the GitHub REST API version on requests to
11+
// GitHub.com and GitHub Enterprise Cloud.
1012
type APIVersionTransport struct {
1113
Transport http.RoundTripper
1214
}
1315

16+
// SetGitHubAPIVersionHeader selects the REST API version supported by the
17+
// target deployment. GitHub Enterprise Server releases support API versions
18+
// independently, so they retain the established compatibility version.
19+
func SetGitHubAPIVersionHeader(req *http.Request) {
20+
if req == nil || req.URL == nil {
21+
return
22+
}
23+
24+
hostType, err := utils.ParseHostType(req.URL.String())
25+
if err != nil {
26+
return
27+
}
28+
29+
if req.Header == nil {
30+
req.Header = make(http.Header)
31+
}
32+
version := headers.GitHubAPIVersion
33+
if hostType == utils.HostTypeGHES {
34+
version = headers.GitHubEnterpriseServerAPIVersion
35+
}
36+
req.Header.Set(headers.GitHubAPIVersionHeader, version)
37+
}
38+
1439
// RoundTrip implements http.RoundTripper.
1540
func (t *APIVersionTransport) RoundTrip(req *http.Request) (*http.Response, error) {
1641
underlying := t.Transport
@@ -19,6 +44,6 @@ func (t *APIVersionTransport) RoundTrip(req *http.Request) (*http.Response, erro
1944
}
2045

2146
req = req.Clone(req.Context())
22-
req.Header.Set(headers.GitHubAPIVersionHeader, headers.GitHubAPIVersion)
47+
SetGitHubAPIVersionHeader(req)
2348
return underlying.RoundTrip(req)
2449
}

pkg/http/transport/api_version_test.go

Lines changed: 74 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,91 @@ package transport
22

33
import (
44
"net/http"
5-
"net/http/httptest"
65
"testing"
76

87
"github.com/github/github-mcp-server/pkg/http/headers"
98
"github.com/stretchr/testify/assert"
109
"github.com/stretchr/testify/require"
1110
)
1211

12+
type roundTripFunc func(*http.Request) (*http.Response, error)
13+
14+
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
15+
return f(req)
16+
}
17+
1318
func TestAPIVersionTransport(t *testing.T) {
1419
t.Parallel()
1520

16-
var gotVersion string
17-
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18-
gotVersion = r.Header.Get(headers.GitHubAPIVersionHeader)
19-
w.WriteHeader(http.StatusOK)
20-
}))
21-
defer server.Close()
21+
tests := []struct {
22+
name string
23+
url string
24+
existingVersion string
25+
wantVersion string
26+
}{
27+
{
28+
name: "GitHub.com overrides the default version",
29+
url: "https://api.github.com/repos/octo-org/octo-repo",
30+
existingVersion: headers.GitHubEnterpriseServerAPIVersion,
31+
wantVersion: headers.GitHubAPIVersion,
32+
},
33+
{
34+
name: "GitHub Enterprise Cloud sets the new version",
35+
url: "https://api.example.ghe.com/repos/octo-org/octo-repo",
36+
wantVersion: headers.GitHubAPIVersion,
37+
},
38+
{
39+
name: "GitHub Enterprise Server pins the compatibility version",
40+
url: "https://github.example.com/api/v3/repos/octo-org/octo-repo",
41+
existingVersion: headers.GitHubAPIVersion,
42+
wantVersion: headers.GitHubEnterpriseServerAPIVersion,
43+
},
44+
{
45+
name: "GitHub Enterprise Server sets the compatibility version",
46+
url: "https://github.example.com/api/v3/repos/octo-org/octo-repo",
47+
wantVersion: headers.GitHubEnterpriseServerAPIVersion,
48+
},
49+
{
50+
name: "host classification is case insensitive",
51+
url: "https://API.GITHUB.COM/repos/octo-org/octo-repo",
52+
wantVersion: headers.GitHubAPIVersion,
53+
},
54+
{
55+
name: "lookalike domain is treated as GitHub Enterprise Server",
56+
url: "https://api.github.com.example.org/api/v3/",
57+
wantVersion: headers.GitHubEnterpriseServerAPIVersion,
58+
},
59+
}
60+
61+
for _, tt := range tests {
62+
t.Run(tt.name, func(t *testing.T) {
63+
t.Parallel()
64+
65+
var gotVersion string
66+
underlying := roundTripFunc(func(req *http.Request) (*http.Response, error) {
67+
gotVersion = req.Header.Get(headers.GitHubAPIVersionHeader)
68+
return &http.Response{
69+
StatusCode: http.StatusOK,
70+
Header: make(http.Header),
71+
Body: http.NoBody,
72+
Request: req,
73+
}, nil
74+
})
2275

23-
req, err := http.NewRequest(http.MethodGet, server.URL, nil)
24-
require.NoError(t, err)
25-
req.Header.Set(headers.GitHubAPIVersionHeader, "2022-11-28")
76+
req, err := http.NewRequest(http.MethodGet, tt.url, nil)
77+
require.NoError(t, err)
78+
if tt.existingVersion != "" {
79+
req.Header.Set(headers.GitHubAPIVersionHeader, tt.existingVersion)
80+
} else {
81+
req.Header = nil
82+
}
2683

27-
resp, err := (&APIVersionTransport{}).RoundTrip(req)
28-
require.NoError(t, err)
29-
defer resp.Body.Close()
84+
resp, err := (&APIVersionTransport{Transport: underlying}).RoundTrip(req)
85+
require.NoError(t, err)
86+
defer resp.Body.Close()
3087

31-
assert.Equal(t, headers.GitHubAPIVersion, gotVersion)
32-
assert.Equal(t, "2022-11-28", req.Header.Get(headers.GitHubAPIVersionHeader))
88+
assert.Equal(t, tt.wantVersion, gotVersion)
89+
assert.Equal(t, tt.existingVersion, req.Header.Get(headers.GitHubAPIVersionHeader), "the original request must not be mutated")
90+
})
91+
}
3392
}

0 commit comments

Comments
 (0)