Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/ghmcp/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -576,8 +576,10 @@ func TestCreateGitHubClientsTokenProvider(t *testing.T) {
t.Parallel()

var gotAuth string
var gotAPIVersion string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get(headers.AuthorizationHeader)
gotAPIVersion = r.Header.Get(headers.GitHubAPIVersionHeader)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
Expand All @@ -600,6 +602,7 @@ func TestCreateGitHubClientsTokenProvider(t *testing.T) {

do()
assert.Equal(t, "", gotAuth, "no auth header before authorization")
assert.Equal(t, headers.GitHubEnterpriseServerAPIVersion, gotAPIVersion)

current = "oauth-token"
do()
Expand Down
2 changes: 1 addition & 1 deletion internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
// the latter installs its own round tripper that would pin the static token
// and shadow the dynamic one.
restUATransport := &transport.UserAgentTransport{
Transport: http.DefaultTransport,
Transport: &transport.APIVersionTransport{Transport: http.DefaultTransport},
Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version),
}
var restClient *gogithub.Client
Expand Down
8 changes: 5 additions & 3 deletions internal/githubapp/githubapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import (
"sync"
"time"

"github.com/github/github-mcp-server/pkg/http/headers"
"github.com/github/github-mcp-server/pkg/http/transport"
"golang.org/x/oauth2"
)

Expand Down Expand Up @@ -140,9 +142,9 @@ func (s *installationTokenSource) Token() (*oauth2.Token, error) {
if err != nil {
return nil, fmt.Errorf("creating installation token request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+jwt)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set(headers.AuthorizationHeader, "Bearer "+jwt)
req.Header.Set(headers.AcceptHeader, "application/vnd.github+json")
transport.SetGitHubAPIVersionHeader(req)

resp, err := s.httpClient.Do(req)
if err != nil {
Expand Down
37 changes: 37 additions & 0 deletions internal/githubapp/githubapp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
Expand All @@ -20,10 +21,17 @@ import (
"testing"
"time"

"github.com/github/github-mcp-server/pkg/http/headers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}

func newTestKey(t *testing.T) *rsa.PrivateKey {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
Expand Down Expand Up @@ -155,6 +163,7 @@ func installationServer(t *testing.T, pub *rsa.PublicKey, token string, expiresA
calls.Add(1)
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/app/installations/456/access_tokens", r.URL.Path)
assert.Equal(t, headers.GitHubEnterpriseServerAPIVersion, r.Header.Get(headers.GitHubAPIVersionHeader))

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

func TestInstallationTokenSourceSetsAPIVersionForGitHubCloud(t *testing.T) {
key := newTestKey(t)
expiresAt := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)

for _, baseURL := range []string{"https://api.github.com", "https://api.example.ghe.com"} {
t.Run(baseURL, func(t *testing.T) {
var gotVersion string
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
gotVersion = req.Header.Get(headers.GitHubAPIVersionHeader)
body := fmt.Sprintf(`{"token":"ghs_test","expires_at":%q}`, expiresAt)
return &http.Response{
StatusCode: http.StatusCreated,
Status: "201 Created",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})}
source := newTestTokenSource(t, newTestConfig(key, baseURL), client)

token, err := source.Token()
require.NoError(t, err)
assert.Equal(t, "ghs_test", token.AccessToken)
assert.Equal(t, headers.GitHubAPIVersion, gotVersion)
})
}
}

func TestProviderFetchesToken(t *testing.T) {
key := newTestKey(t)
srv, calls := installationServer(t, &key.PublicKey, "ghs_fresh", time.Now().Add(time.Hour))
Expand Down
1 change: 0 additions & 1 deletion pkg/github/__toolsnaps__/search_issues.snap
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
"user",
"author_association",
"labels",
"assignee",
"assignees",
"milestone",
"comments",
Expand Down
1 change: 0 additions & 1 deletion pkg/github/__toolsnaps__/search_pull_requests.snap
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
"user",
"author_association",
"labels",
"assignee",
"assignees",
"milestone",
"comments",
Expand Down
3 changes: 3 additions & 0 deletions pkg/github/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,9 @@ func (d *RequestDeps) GetClient(ctx context.Context) (*gogithub.Client, error) {

// Construct REST client
restClient, err := gogithub.NewClient(
gogithub.WithHTTPClient(&http.Client{
Transport: &transport.APIVersionTransport{Transport: http.DefaultTransport},
}),
gogithub.WithAuthToken(token),
gogithub.WithUserAgent(fmt.Sprintf("github-mcp-server/%s", d.version)),
gogithub.WithEnterpriseURLs(baseRestURL.String(), uploadURL.String()),
Expand Down
45 changes: 45 additions & 0 deletions pkg/github/dependencies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,65 @@ import (
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"testing"

ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/http/headers"
"github.com/github/github-mcp-server/pkg/observability"
"github.com/github/github-mcp-server/pkg/observability/metrics"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type requestDepsAPIHost struct {
url *url.URL
}

func (h requestDepsAPIHost) BaseRESTURL(context.Context) (*url.URL, error) { return h.url, nil }
func (h requestDepsAPIHost) GraphqlURL(context.Context) (*url.URL, error) { return h.url, nil }
func (h requestDepsAPIHost) UploadURL(context.Context) (*url.URL, error) { return h.url, nil }
func (h requestDepsAPIHost) RawURL(context.Context) (*url.URL, error) { return h.url, nil }
func (h requestDepsAPIHost) AuthorizationServerURL(context.Context) (*url.URL, error) {
return h.url, nil
}

func testExporters() observability.Exporters {
obs, _ := observability.NewExporters(slog.New(slog.DiscardHandler), metrics.NewNoopMetrics())
return obs
}

func TestRequestDepsGetClientPreservesGHESAPIVersion(t *testing.T) {
t.Parallel()

var gotVersion string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotVersion = r.Header.Get(headers.GitHubAPIVersionHeader)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

serverURL, err := url.Parse(server.URL)
require.NoError(t, err)
apiHost := requestDepsAPIHost{url: serverURL}
deps := github.NewRequestDeps(apiHost, "test", false, nil, nil, 0, nil, testExporters())
ctx := ghcontext.WithTokenInfo(context.Background(), &ghcontext.TokenInfo{Token: "test-token"})
client, err := deps.GetClient(ctx)
require.NoError(t, err)

req, err := client.NewRequest(ctx, http.MethodGet, "rate_limit", nil)
require.NoError(t, err)
resp, err := client.Do(req, nil)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, headers.GitHubEnterpriseServerAPIVersion, gotVersion)
}

func TestIsFeatureEnabled_WithEnabledFlag(t *testing.T) {
t.Parallel()

Expand Down
4 changes: 2 additions & 2 deletions pkg/github/minimal_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ var listReleasesItemFieldEnum = []any{
// the main lever for shrinking large result sets.
var searchIssuesItemFieldEnum = []any{
"number", "title", "body", "state", "state_reason", "draft", "locked",
"html_url", "user", "author_association", "labels", "assignee", "assignees",
"html_url", "user", "author_association", "labels", "assignees",
"milestone", "comments", "reactions", "created_at", "updated_at", "closed_at",
"closed_by", "type", "repository_url", "pull_request", "field_values",
}
Expand All @@ -87,7 +87,7 @@ var searchIssuesItemFieldEnum = []any{
// the main lever for shrinking large result sets.
var searchPullRequestsItemFieldEnum = []any{
"number", "title", "body", "state", "state_reason", "draft", "locked",
"html_url", "user", "author_association", "labels", "assignee", "assignees",
"html_url", "user", "author_association", "labels", "assignees",
"milestone", "comments", "reactions", "created_at", "updated_at", "closed_at",
"closed_by", "pull_request", "repository_url",
}
Expand Down
6 changes: 6 additions & 0 deletions pkg/http/headers/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,10 @@ const (
GraphQLFeaturesHeader = "GraphQL-Features"
// GitHubAPIVersionHeader is the header used to specify the GitHub API version.
GitHubAPIVersionHeader = "X-GitHub-Api-Version"
// GitHubAPIVersion is the GitHub REST API version used for GitHub.com and
// GitHub Enterprise Cloud requests.
GitHubAPIVersion = "2026-03-10"
// GitHubEnterpriseServerAPIVersion is the compatibility version used for
// GitHub Enterprise Server requests.
GitHubEnterpriseServerAPIVersion = "2022-11-28"
)
49 changes: 49 additions & 0 deletions pkg/http/transport/api_version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package transport

import (
"net/http"

"github.com/github/github-mcp-server/pkg/http/headers"
"github.com/github/github-mcp-server/pkg/utils"
)

// APIVersionTransport sets the GitHub REST API version on requests to
// GitHub.com and GitHub Enterprise Cloud.
type APIVersionTransport struct {
Transport http.RoundTripper
}

// SetGitHubAPIVersionHeader selects the REST API version supported by the
// target deployment. GitHub Enterprise Server releases support API versions
// independently, so they retain the established compatibility version.
func SetGitHubAPIVersionHeader(req *http.Request) {
if req == nil || req.URL == nil {
return
}

hostType, err := utils.ParseHostType(req.URL.String())
if err != nil {
return
}

if req.Header == nil {
req.Header = make(http.Header)
}
version := headers.GitHubAPIVersion
if hostType == utils.HostTypeGHES {
version = headers.GitHubEnterpriseServerAPIVersion
}
req.Header.Set(headers.GitHubAPIVersionHeader, version)
}

// RoundTrip implements http.RoundTripper.
func (t *APIVersionTransport) RoundTrip(req *http.Request) (*http.Response, error) {
underlying := t.Transport
if underlying == nil {
underlying = http.DefaultTransport
}

req = req.Clone(req.Context())
SetGitHubAPIVersionHeader(req)
return underlying.RoundTrip(req)
}
92 changes: 92 additions & 0 deletions pkg/http/transport/api_version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package transport

import (
"net/http"
"testing"

"github.com/github/github-mcp-server/pkg/http/headers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}

func TestAPIVersionTransport(t *testing.T) {
t.Parallel()

tests := []struct {
name string
url string
existingVersion string
wantVersion string
}{
{
name: "GitHub.com overrides the default version",
url: "https://api.github.com/repos/octo-org/octo-repo",
existingVersion: headers.GitHubEnterpriseServerAPIVersion,
wantVersion: headers.GitHubAPIVersion,
},
{
name: "GitHub Enterprise Cloud sets the new version",
url: "https://api.example.ghe.com/repos/octo-org/octo-repo",
wantVersion: headers.GitHubAPIVersion,
},
{
name: "GitHub Enterprise Server pins the compatibility version",
url: "https://github.example.com/api/v3/repos/octo-org/octo-repo",
existingVersion: headers.GitHubAPIVersion,
wantVersion: headers.GitHubEnterpriseServerAPIVersion,
},
{
name: "GitHub Enterprise Server sets the compatibility version",
url: "https://github.example.com/api/v3/repos/octo-org/octo-repo",
wantVersion: headers.GitHubEnterpriseServerAPIVersion,
},
{
name: "host classification is case insensitive",
url: "https://API.GITHUB.COM/repos/octo-org/octo-repo",
wantVersion: headers.GitHubAPIVersion,
},
{
name: "lookalike domain is treated as GitHub Enterprise Server",
url: "https://api.github.com.example.org/api/v3/",
wantVersion: headers.GitHubEnterpriseServerAPIVersion,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

var gotVersion string
underlying := roundTripFunc(func(req *http.Request) (*http.Response, error) {
gotVersion = req.Header.Get(headers.GitHubAPIVersionHeader)
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}, nil
})

req, err := http.NewRequest(http.MethodGet, tt.url, nil)
require.NoError(t, err)
if tt.existingVersion != "" {
req.Header.Set(headers.GitHubAPIVersionHeader, tt.existingVersion)
} else {
req.Header = nil
}

resp, err := (&APIVersionTransport{Transport: underlying}).RoundTrip(req)
require.NoError(t, err)
defer resp.Body.Close()

assert.Equal(t, tt.wantVersion, gotVersion)
assert.Equal(t, tt.existingVersion, req.Header.Get(headers.GitHubAPIVersionHeader), "the original request must not be mutated")
})
}
}
3 changes: 2 additions & 1 deletion pkg/scopes/fetcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"github.com/github/github-mcp-server/pkg/http/headers"
"github.com/github/github-mcp-server/pkg/http/transport"
"github.com/github/github-mcp-server/pkg/utils"
)

Expand Down Expand Up @@ -81,7 +82,7 @@ func (f *Fetcher) FetchTokenScopes(ctx context.Context, token string) ([]string,

req.Header.Set(headers.AuthorizationHeader, "Bearer "+token)
req.Header.Set(headers.AcceptHeader, "application/vnd.github+json")
req.Header.Set(headers.GitHubAPIVersionHeader, "2022-11-28")
transport.SetGitHubAPIVersionHeader(req)

resp, err := f.client.Do(req)
if err != nil {
Expand Down
Loading