diff --git a/README.md b/README.md index 10d987a262..004e852069 100644 --- a/README.md +++ b/README.md @@ -183,9 +183,14 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to 1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed. 2. Once Docker is installed, you will also need to ensure Docker is running. The Docker image is available at `ghcr.io/github/github-mcp-server`. The image is public; if you get errors on pull, you may have an expired token and need to `docker logout ghcr.io`. 3. **Authentication.** On github.com you don't need to create anything up front — the one-click buttons above log you in with OAuth on first use (a browser-based flow; the token is kept in memory only). The Docker buttons publish a fixed callback port (`127.0.0.1:8085`) so the container's login callback is reachable. See **[Local Server OAuth Login](docs/oauth-login.md)** for how it works, headless/device-code fallback, and bringing your own OAuth or GitHub App (required for GitHub Enterprise Server and `ghe.com`). - Prefer a token? You can still authenticate with a [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) by setting `GITHUB_PERSONAL_ACCESS_TOKEN` instead (it takes precedence over OAuth). The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)). + **Using GitHub App Authentication.** Alternatively, you can run the server as a GitHub App installation. This is recommended for automated environments or enterprise deployments. Set the following environment variables (or pass the corresponding CLI flags): + - `GITHUB_APP_ID` (or `--app-id`): The GitHub App ID. + - `GITHUB_APP_INSTALLATION_ID` (or `--app-installation-id`): The App installation ID. + - `GITHUB_APP_PRIVATE_KEY` (or `--app-private-key`): The RSA private key PEM block string. + - `GITHUB_APP_PRIVATE_KEY_PATH` (or `--app-private-key-path`): Alternatively, the file path to the RSA private key PEM file. +
Handling PATs Securely ### Environment Variables (Recommended) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 231b0cf2c3..7289fd8d1b 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -13,9 +13,11 @@ import ( "github.com/github/github-mcp-server/pkg/github" ghhttp "github.com/github/github-mcp-server/pkg/http" ghoauth "github.com/github/github-mcp-server/pkg/http/oauth" + "github.com/github/github-mcp-server/pkg/utils" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" + "golang.org/x/oauth2" ) // These variables are set by the build process using ldflags. @@ -39,6 +41,22 @@ var ( token := viper.GetString("personal_access_token") oauthClientID := viper.GetString("oauth-client-id") oauthClientSecret := viper.GetString("oauth-client-secret") + + // Check GitHub App configuration + appID := viper.GetInt64("app-id") + appInstallationID := viper.GetInt64("app-installation-id") + appPrivateKey := viper.GetString("app-private-key") + appPrivateKeyPath := viper.GetString("app-private-key-path") + + var appTokenSource oauth2.TokenSource + if appID != 0 && appInstallationID != 0 { + var err error + appTokenSource, err = utils.NewGitHubAppTokenSource(appID, appInstallationID, []byte(appPrivateKey), appPrivateKeyPath, viper.GetString("host")) + if err != nil { + return err + } + } + // Fall back to the build-time baked-in client (official releases) when none is // configured explicitly. The baked-in app is registered on github.com, so it is // only applied to the default host; GHES/ghe.com users must bring their own @@ -50,8 +68,8 @@ var ( oauthClientID = buildinfo.OAuthClientID oauthClientSecret = buildinfo.OAuthClientSecret } - if token == "" && oauthClientID == "" { - return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, or pass --oauth-client-id to log in via OAuth") + if token == "" && oauthClientID == "" && appTokenSource == nil { + return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, pass --oauth-client-id to log in via OAuth, or configure GitHub App credentials") } // If you're wondering why we're not using viper.GetStringSlice("toolsets"), @@ -110,13 +128,14 @@ var ( InsidersMode: viper.GetBool("insiders"), ExcludeTools: excludeTools, RepoAccessCacheTTL: &ttl, + GitHubAppTokenSource: appTokenSource, } - // When no static token is provided, log in via OAuth using the given + // When no static token or GitHub App TokenSource is set, log in via OAuth using the given // client. The requested scopes default to the full supported set // (which filters out no tools); an explicit, narrower --oauth-scopes // both narrows the grant and hides tools needing other scopes. - if token == "" { + if token == "" && appTokenSource == nil { scopes := ghoauth.SupportedScopes if viper.IsSet("oauth-scopes") { if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil { @@ -172,6 +191,21 @@ var ( } } + // Check GitHub App configuration for HTTP mode + appID := viper.GetInt64("app-id") + appInstallationID := viper.GetInt64("app-installation-id") + appPrivateKey := viper.GetString("app-private-key") + appPrivateKeyPath := viper.GetString("app-private-key-path") + + var appTokenSource oauth2.TokenSource + if appID != 0 && appInstallationID != 0 { + var err error + appTokenSource, err = utils.NewGitHubAppTokenSource(appID, appInstallationID, []byte(appPrivateKey), appPrivateKeyPath, viper.GetString("host")) + if err != nil { + return err + } + } + ttl := viper.GetDuration("repo-access-cache-ttl") httpConfig := ghhttp.ServerConfig{ Version: version, @@ -194,6 +228,7 @@ var ( EnabledFeatures: enabledFeatures, InsidersMode: viper.GetBool("insiders"), TrustProxyHeaders: viper.GetBool("trust-proxy-headers"), + GitHubAppTokenSource: appTokenSource, } return ghhttp.RunHTTPServer(httpConfig) @@ -222,6 +257,12 @@ func init() { rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") + // GitHub App flags + rootCmd.PersistentFlags().Int64("app-id", 0, "GitHub App ID") + rootCmd.PersistentFlags().Int64("app-installation-id", 0, "GitHub App Installation ID") + rootCmd.PersistentFlags().String("app-private-key", "", "GitHub App Private Key PEM content") + rootCmd.PersistentFlags().String("app-private-key-path", "", "Path to GitHub App Private Key PEM file") + // stdio-specific OAuth flags. Provide --oauth-client-id (instead of a token) // to log in via the browser-based OAuth flow on first use. Works for both // OAuth Apps and GitHub Apps. @@ -252,6 +293,10 @@ func init() { _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) _ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) + _ = viper.BindPFlag("app-id", rootCmd.PersistentFlags().Lookup("app-id")) + _ = viper.BindPFlag("app-installation-id", rootCmd.PersistentFlags().Lookup("app-installation-id")) + _ = viper.BindPFlag("app-private-key", rootCmd.PersistentFlags().Lookup("app-private-key")) + _ = viper.BindPFlag("app-private-key-path", rootCmd.PersistentFlags().Lookup("app-private-key-path")) _ = viper.BindPFlag("oauth-client-id", stdioCmd.Flags().Lookup("oauth-client-id")) _ = viper.BindPFlag("oauth-client-secret", stdioCmd.Flags().Lookup("oauth-client-secret")) _ = viper.BindPFlag("oauth-scopes", stdioCmd.Flags().Lookup("oauth-scopes")) diff --git a/go.mod b/go.mod index d553a10509..121ad536d1 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.25.0 require ( github.com/go-chi/chi/v5 v5.3.1 + github.com/go-resty/resty/v2 v2.17.2 github.com/go-viper/mapstructure/v2 v2.5.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-github/v89 v89.0.0 github.com/google/jsonschema-go v0.4.3 github.com/josephburnett/jd/v2 v2.5.0 diff --git a/go.sum b/go.sum index 4aa9805e04..8e2d5587fa 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 1e0611d648..a158295d65 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -28,6 +28,7 @@ import ( gogithub "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" + "golang.org/x/oauth2" ) // githubClients holds all the GitHub API clients created for a server instance. @@ -257,6 +258,9 @@ type StdioServerConfig struct { // are hidden. The default set is the full supported list, which hides // nothing; an explicit, narrower list filters accordingly. OAuthScopes []string + + // GitHubAppTokenSource, when non-nil, supplies the GitHub App installation token. + GitHubAppTokenSource oauth2.TokenSource } // RunStdioServer is not concurrent safe. @@ -318,6 +322,14 @@ func RunStdioServer(cfg StdioServerConfig) error { if cfg.OAuthManager != nil { tokenProvider = cfg.OAuthManager.AccessToken toolHandlerMiddleware = append(toolHandlerMiddleware, createOAuthToolMiddleware(cfg.OAuthManager, logger)) + } else if cfg.GitHubAppTokenSource != nil { + tokenProvider = func() string { + tok, err := cfg.GitHubAppTokenSource.Token() + if err != nil { + return "" + } + return tok.AccessToken + } } ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{ diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index 49b6f6315a..66c2a07dce 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -21,6 +21,7 @@ import ( gogithub "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" + "golang.org/x/oauth2" ) // depsContextKey is the context key for ToolDependencies. @@ -267,12 +268,13 @@ func NewToolFromHandler( type RequestDeps struct { // Static dependencies - apiHosts utils.APIHostResolver - version string - lockdownMode bool - RepoAccessOpts []lockdown.RepoAccessOption - T translations.TranslationHelperFunc - ContentWindowSize int + apiHosts utils.APIHostResolver + version string + lockdownMode bool + RepoAccessOpts []lockdown.RepoAccessOption + T translations.TranslationHelperFunc + ContentWindowSize int + gitHubAppTokenSource oauth2.TokenSource // Feature flag checker for runtime checks featureChecker inventory.FeatureFlagChecker @@ -291,27 +293,41 @@ func NewRequestDeps( contentWindowSize int, featureChecker inventory.FeatureFlagChecker, obsv observability.Exporters, + gitHubAppTokenSource oauth2.TokenSource, ) *RequestDeps { return &RequestDeps{ - apiHosts: apiHosts, - version: version, - lockdownMode: lockdownMode, - RepoAccessOpts: repoAccessOpts, - T: t, - ContentWindowSize: contentWindowSize, - featureChecker: featureChecker, - obsv: obsv, + apiHosts: apiHosts, + version: version, + lockdownMode: lockdownMode, + RepoAccessOpts: repoAccessOpts, + T: t, + ContentWindowSize: contentWindowSize, + featureChecker: featureChecker, + obsv: obsv, + gitHubAppTokenSource: gitHubAppTokenSource, } } +func (d *RequestDeps) resolveToken(ctx context.Context) (string, error) { + if tokenInfo, ok := ghcontext.GetTokenInfo(ctx); ok { + return tokenInfo.Token, nil + } + if d.gitHubAppTokenSource != nil { + tok, err := d.gitHubAppTokenSource.Token() + if err != nil { + return "", fmt.Errorf("failed to get default token from GitHub App source: %w", err) + } + return tok.AccessToken, nil + } + return "", fmt.Errorf("no token info in context and no default token source configured") +} + // GetClient implements ToolDependencies. func (d *RequestDeps) GetClient(ctx context.Context) (*gogithub.Client, error) { - // extract the token from the context - tokenInfo, ok := ghcontext.GetTokenInfo(ctx) - if !ok { - return nil, fmt.Errorf("no token info in context") + token, err := d.resolveToken(ctx) + if err != nil { + return nil, err } - token := tokenInfo.Token baseRestURL, err := d.apiHosts.BaseRESTURL(ctx) if err != nil { @@ -336,12 +352,15 @@ func (d *RequestDeps) GetClient(ctx context.Context) (*gogithub.Client, error) { // GetGQLClient implements ToolDependencies. func (d *RequestDeps) GetGQLClient(ctx context.Context) (*githubv4.Client, error) { - // extract the token from the context - tokenInfo, ok := ghcontext.GetTokenInfo(ctx) - if !ok { - return nil, fmt.Errorf("no token info in context") + token, err := d.resolveToken(ctx) + if err != nil { + return nil, err + } + + graphqlURL, err := d.apiHosts.GraphqlURL(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GraphQL URL: %w", err) } - token := tokenInfo.Token // Construct GraphQL client // We use NewEnterpriseClient unconditionally since we already parsed the API host @@ -356,11 +375,6 @@ func (d *RequestDeps) GetGQLClient(ctx context.Context) (*githubv4.Client, error }, } - graphqlURL, err := d.apiHosts.GraphqlURL(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GraphQL URL: %w", err) - } - gqlClient := githubv4.NewEnterpriseClient(graphqlURL.String(), gqlHTTPClient) return gqlClient, nil } diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index e701441c1e..4325aa5636 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -358,7 +358,7 @@ func Test_ResolveFieldNamesToIDs_Success(t *testing.T) { assert.Equal(t, []int64{100, 200}, ids) } -// Field and single-select option name matching is case-insensitive so agents passing lowercase +// Field and single-select option name matching is case-insensitive so agents passing lowercase // names like "status" or "in progress" resolve to "Status" and "In Progress" respectively. func Test_ResolveProjectFieldByName_CaseInsensitive(t *testing.T) { mocked := githubv4mock.NewMockedHTTPClient( diff --git a/pkg/http/server.go b/pkg/http/server.go index 36d3e111bc..86b2abfd1a 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -25,6 +25,7 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-chi/chi/v5" + "golang.org/x/oauth2" ) type ServerConfig struct { @@ -100,6 +101,9 @@ type ServerConfig struct { // InsidersMode expands to the curated set of feature flags enabled for insiders. InsidersMode bool + + // GitHubAppTokenSource, when non-nil, supplies the GitHub App installation token. + GitHubAppTokenSource oauth2.TokenSource } func RunHTTPServer(cfg ServerConfig) error { @@ -153,6 +157,7 @@ func RunHTTPServer(cfg ServerConfig) error { cfg.ContentWindowSize, featureChecker, obs, + cfg.GitHubAppTokenSource, ) // Initialize the global tool scope map diff --git a/pkg/utils/github_app.go b/pkg/utils/github_app.go new file mode 100644 index 0000000000..d4c7fa54c7 --- /dev/null +++ b/pkg/utils/github_app.go @@ -0,0 +1,168 @@ +package utils //nolint:revive // utils is the established package name in this repository + +import ( + "context" + "crypto/rsa" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-resty/resty/v2" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/oauth2" +) + +type githubAppTokenSource struct { + appID int64 + installationID int64 + privateKey *rsa.PrivateKey + baseURL string + client *resty.Client + mu sync.Mutex + cachedToken *oauth2.Token +} + +// NewGitHubAppTokenSource creates a new token source for GitHub App installation access tokens. +// It parses the privateKey PEM bytes or reads them from privateKeyPath, and normalizes the host for Enterprise Server. +func NewGitHubAppTokenSource(appID, installationID int64, privateKey []byte, privateKeyPath string, host string) (oauth2.TokenSource, error) { + var keyBytes []byte + var err error + switch { + case len(privateKey) > 0: + keyBytes = privateKey + case privateKeyPath != "": + keyBytes, err = os.ReadFile(privateKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to read private key file: %w", err) + } + default: + return nil, fmt.Errorf("either app-private-key or app-private-key-path must be provided") + } + + parsedKey, err := jwt.ParseRSAPrivateKeyFromPEM(keyBytes) + if err != nil { + return nil, fmt.Errorf("invalid app-private-key: %w", err) + } + + // Normalize host to baseURL (independent from internal/oauth to avoid circular dependency) + baseURL := "https://api.github.com" + if host != "" && host != "github.com" && host != "api.github.com" && host != "https://github.com" && host != "https://api.github.com" { + baseURL = host + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + baseURL = "https://" + baseURL + } + baseURL = strings.TrimSuffix(baseURL, "/") + + // Strip 'api.' prefix if present to normalize domain (e.g. api.github.example.com -> github.example.com) + baseURL = strings.Replace(baseURL, "https://api.", "https://", 1) + baseURL = strings.Replace(baseURL, "http://api.", "http://", 1) + + // Normalize Enterprise Server API endpoint to end with /api/v3 + if !strings.HasSuffix(baseURL, "/api/v3") { + baseURL += "/api/v3" + } + } + + // Initialize resty client with 15s timeout and automatic 3-retry for 5xx/429/connection issues + restyClient := resty.New(). + SetTimeout(15 * time.Second). + SetRetryCount(3). + SetRetryWaitTime(1 * time.Second). + SetRetryMaxWaitTime(3 * time.Second). + AddRetryCondition( + func(r *resty.Response, err error) bool { + // Retry on network errors, HTTP 5xx Server Errors, or HTTP 429 Too Many Requests + return err != nil || r.StatusCode() >= 500 || r.StatusCode() == http.StatusTooManyRequests + }, + ) + + return &githubAppTokenSource{ + appID: appID, + installationID: installationID, + privateKey: parsedKey, + baseURL: baseURL, + client: restyClient, + }, nil +} + +// Token returns a cached token if valid, otherwise requests a new one from GitHub. +func (ts *githubAppTokenSource) Token() (*oauth2.Token, error) { + ts.mu.Lock() + defer ts.mu.Unlock() + + // If token is still valid (with 1 minute buffer to be safe), return it + if ts.cachedToken != nil && ts.cachedToken.Expiry.After(time.Now().Add(1*time.Minute)) { + return ts.cachedToken, nil + } + + jwtToken, err := ts.generateJWT() + if err != nil { + return nil, fmt.Errorf("generating app JWT: %w", err) + } + + token, err := ts.requestInstallationToken(jwtToken) + if err != nil { + return nil, fmt.Errorf("requesting installation token: %w", err) + } + + ts.cachedToken = token + return token, nil +} + +func (ts *githubAppTokenSource) generateJWT() (string, error) { + now := time.Now() + claims := jwt.RegisteredClaims{ + Issuer: strconv.FormatInt(ts.appID, 10), + IssuedAt: jwt.NewNumericDate(now.Add(-60 * time.Second)), // clock drift skew + ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signedToken, err := token.SignedString(ts.privateKey) + if err != nil { + return "", fmt.Errorf("signing JWT: %w", err) + } + + return signedToken, nil +} + +func (ts *githubAppTokenSource) requestInstallationToken(jwtToken string) (*oauth2.Token, error) { + url := fmt.Sprintf("%s/app/installations/%d/access_tokens", ts.baseURL, ts.installationID) + + var result struct { + Token string `json:"token"` + ExpiresAt string `json:"expires_at"` + } + + resp, err := ts.client.R(). + SetContext(context.Background()). + SetHeader("Authorization", "Bearer "+jwtToken). + SetHeader("Accept", "application/vnd.github+json"). + SetHeader("User-Agent", "github-mcp-server"). + SetBody(map[string]any{}). + SetResult(&result). + Post(url) + + if err != nil { + return nil, fmt.Errorf("http request failed: %w", err) + } + + if resp.StatusCode() != http.StatusCreated { + return nil, fmt.Errorf("unexpected status code: %d, response: %s", resp.StatusCode(), resp.String()) + } + + expiry, err := time.Parse(time.RFC3339, result.ExpiresAt) + if err != nil { + return nil, fmt.Errorf("parsing token expiry: %w", err) + } + + return &oauth2.Token{ + AccessToken: result.Token, + TokenType: "Bearer", + Expiry: expiry, + }, nil +} diff --git a/pkg/utils/github_app_test.go b/pkg/utils/github_app_test.go new file mode 100644 index 0000000000..f7db7974fe --- /dev/null +++ b/pkg/utils/github_app_test.go @@ -0,0 +1,82 @@ +package utils + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// generateTestPrivateKeyPEM generates a temporary 2048-bit RSA key for testing +func generateTestPrivateKeyPEM(t *testing.T) []byte { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + pemBlock := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + } + return pem.EncodeToMemory(pemBlock) +} + +func TestNewGitHubAppTokenSource_Errors(t *testing.T) { + // Test when no key is provided + _, err := NewGitHubAppTokenSource(12345, 67890, nil, "", "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "must be provided") + + // Test invalid PEM key + _, err = NewGitHubAppTokenSource(12345, 67890, []byte("invalid-key"), "", "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid app-private-key") +} + +func TestGitHubAppTokenSource_Token(t *testing.T) { + keyPEM := generateTestPrivateKeyPEM(t) + + // Create a test HTTP server to mock GitHub App installation token endpoint + serverCalled := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request path + assert.Equal(t, "/api/v3/app/installations/67890/access_tokens", r.URL.Path) + assert.Equal(t, "POST", r.Method) + + // Verify auth header + authHeader := r.Header.Get("Authorization") + assert.Contains(t, authHeader, "Bearer ") + + // Verify other headers + assert.Equal(t, "application/vnd.github+json", r.Header.Get("Accept")) + assert.Equal(t, "github-mcp-server", r.Header.Get("User-Agent")) + + serverCalled++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"token": "test-installation-token", "expires_at": "2026-07-19T02:00:00Z"}`) + })) + defer ts.Close() + + // Initialize the TokenSource with our mock server URL as host + tokenSource, err := NewGitHubAppTokenSource(12345, 67890, keyPEM, "", ts.URL) + require.NoError(t, err) + + // Get Token first time (should trigger HTTP request) + tok, err := tokenSource.Token() + require.NoError(t, err) + assert.Equal(t, "test-installation-token", tok.AccessToken) + assert.Equal(t, 1, serverCalled) + + // Get Token second time (should use cache, so serverCalled stays 1) + tok2, err := tokenSource.Token() + require.NoError(t, err) + assert.Equal(t, "test-installation-token", tok2.AccessToken) + assert.Equal(t, 1, serverCalled) +}