diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 231b0cf2c3..c89ad55931 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "strconv" "strings" "time" @@ -37,6 +38,13 @@ var ( Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`, RunE: func(_ *cobra.Command, _ []string) error { token := viper.GetString("personal_access_token") + + // Parse GitHub App authentication config. + appID, privateKey, installationID, err := parseAppAuthConfig() + if err != nil { + return err + } + useAppAuth := appID != 0 && len(privateKey) > 0 && installationID != 0 oauthClientID := viper.GetString("oauth-client-id") oauthClientSecret := viper.GetString("oauth-client-secret") // Fall back to the build-time baked-in client (official releases) when none is @@ -50,8 +58,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 == "" && !useAppAuth && oauthClientID == "" { + return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, configure GitHub App auth with GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_PATH, or pass --oauth-client-id to log in via OAuth") } // If you're wondering why we're not using viper.GetStringSlice("toolsets"), @@ -110,13 +118,16 @@ var ( InsidersMode: viper.GetBool("insiders"), ExcludeTools: excludeTools, RepoAccessCacheTTL: &ttl, + AppID: appID, + PrivateKey: privateKey, + InstallationID: installationID, } // When no static token is provided, 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 == "" && !useAppAuth { scopes := ghoauth.SupportedScopes if viper.IsSet("oauth-scopes") { if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil { @@ -172,6 +183,11 @@ var ( } } + appID, privateKey, installationID, err := parseAppAuthConfig() + if err != nil { + return err + } + ttl := viper.GetDuration("repo-access-cache-ttl") httpConfig := ghhttp.ServerConfig{ Version: version, @@ -194,6 +210,9 @@ var ( EnabledFeatures: enabledFeatures, InsidersMode: viper.GetBool("insiders"), TrustProxyHeaders: viper.GetBool("trust-proxy-headers"), + AppID: appID, + PrivateKey: privateKey, + InstallationID: installationID, } return ghhttp.RunHTTPServer(httpConfig) @@ -289,3 +308,53 @@ func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName { } return pflag.NormalizedName(name) } + +// parseAppAuthConfig reads GitHub App authentication config from environment variables. +// Returns (0, nil, 0, nil) when no App auth is configured. +func parseAppAuthConfig() (appID int64, privateKey []byte, installationID int64, err error) { + appIDStr := viper.GetString("app_id") + installationIDStr := viper.GetString("app_installation_id") + privateKeyStr := viper.GetString("app_private_key") + privateKeyPath := viper.GetString("app_private_key_path") + + // If none are set, App auth is not configured + if appIDStr == "" && installationIDStr == "" && privateKeyStr == "" && privateKeyPath == "" { + return 0, nil, 0, nil + } + + // If some but not all are set, that's a configuration error + if appIDStr == "" || installationIDStr == "" || (privateKeyStr == "" && privateKeyPath == "") { + return 0, nil, 0, errors.New("incomplete GitHub App auth config: GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_PATH are all required") + } + + if privateKeyStr != "" && privateKeyPath != "" { + return 0, nil, 0, errors.New("GITHUB_APP_PRIVATE_KEY and GITHUB_APP_PRIVATE_KEY_PATH are mutually exclusive") + } + + appID, err = strconv.ParseInt(appIDStr, 10, 64) + if err != nil { + return 0, nil, 0, fmt.Errorf("invalid GITHUB_APP_ID: %w", err) + } + + installationID, err = strconv.ParseInt(installationIDStr, 10, 64) + if err != nil { + return 0, nil, 0, fmt.Errorf("invalid GITHUB_APP_INSTALLATION_ID: %w", err) + } + + if privateKeyStr != "" { + // Environment variables often use literal "\n" instead of actual newlines. + // Only replace when the value has no real newlines to avoid corrupting + // keys that were correctly passed with actual newlines. + if !strings.Contains(privateKeyStr, "\n") { + privateKeyStr = strings.ReplaceAll(privateKeyStr, `\n`, "\n") + } + privateKey = []byte(privateKeyStr) + } else { + privateKey, err = os.ReadFile(privateKeyPath) + if err != nil { + return 0, nil, 0, fmt.Errorf("failed to read private key from %s: %w", privateKeyPath, err) + } + } + + return appID, privateKey, installationID, nil +} diff --git a/docs/github-app-auth.md b/docs/github-app-auth.md new file mode 100644 index 0000000000..ff6f9d2c23 --- /dev/null +++ b/docs/github-app-auth.md @@ -0,0 +1,195 @@ +# Local Server GitHub App Authentication + +The local GitHub MCP Server can authenticate directly as a GitHub App +installation instead of a Personal Access Token (PAT) or browser-based OAuth +login. This works for both the `stdio` and `http` commands. The server signs a +short-lived JWT with your app's private key, exchanges it for an installation +access token, and refreshes that installation token automatically before it +expires. + +> This guide covers installation authentication using `GITHUB_APP_ID`, +> `GITHUB_APP_INSTALLATION_ID`, and a private key. For browser-based user login +> via OAuth, see [Local Server OAuth Login](oauth-login.md). The remote server +> has its own auth model; see [Remote Server](remote-server.md). + +## Contents + +- [How it works](#how-it-works) +- [GitHub App setup](#github-app-setup) +- [Quick start](#quick-start) +- [Configuration reference](#configuration-reference) +- [Running in Docker](#running-in-docker) +- [Using the `http` command](#using-the-http-command) +- [GitHub Enterprise Server and ghe.com](#github-enterprise-server-and-ghecom) +- [Troubleshooting](#troubleshooting) + +## How it works + +When all required app environment variables are present, the local server +authenticates as the configured GitHub App installation instead of as a user: + +1. It signs a JWT with the app's private key. +2. It exchanges that JWT for an installation access token. +3. It reuses that installation token for GitHub API calls and refreshes it + automatically before it expires. + +Because this is installation authentication, access is controlled by the app's +granted permissions and repository selection — not by PAT scopes or the OAuth +scopes described in [Local Server OAuth Login](oauth-login.md). No browser +callback, OAuth app, or device-code flow is involved. + +## GitHub App setup + +Before starting the server, create or choose a GitHub App and install it on the +account or organization that owns the repositories you want to access. + +You will need: + +- The **App ID** +- The **Installation ID** +- A **private key** for that app + +Make sure the installation has the repository access and permissions your +workflows need. For example, repository-writing tools need write permissions on +the relevant resources; read-only setups can grant narrower access. + +> GitHub App installation auth does **not** require an OAuth callback URL. If +> you only want installation auth, you do not need to enable device flow or +> browser login in the app settings. + +## Quick start + +**Native binary (`stdio`)** using a private key file: + +```bash +export GITHUB_APP_ID=12345 +export GITHUB_APP_INSTALLATION_ID=67890 +export GITHUB_APP_PRIVATE_KEY_PATH=/path/to/private-key.pem + +github-mcp-server stdio +``` + +Using an inline private key (for hosts that only accept environment variables): + +```bash +export GITHUB_APP_ID=12345 +export GITHUB_APP_INSTALLATION_ID=67890 +export GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----" + +github-mcp-server stdio +``` + +Once these variables are set, `GITHUB_PERSONAL_ACCESS_TOKEN` is not required. + +## Configuration reference + +GitHub App auth is configured with environment variables. The same variables +apply to both `stdio` and `http`. + +| Environment variable | Description | +|----------------------|-------------| +| `GITHUB_APP_ID` | GitHub App ID | +| `GITHUB_APP_INSTALLATION_ID` | Installation ID of the app installation the server should act as | +| `GITHUB_APP_PRIVATE_KEY` | PEM-encoded private key inline. If your environment variable system cannot preserve real newlines, use literal `\n` escapes. | +| `GITHUB_APP_PRIVATE_KEY_PATH` | Path to a PEM private key file | +| `GITHUB_HOST` | Optional GitHub Enterprise Server / `ghe.com` host. Omit for github.com. | + +Rules: + +- Set **exactly one** of `GITHUB_APP_PRIVATE_KEY` or + `GITHUB_APP_PRIVATE_KEY_PATH`. +- `GITHUB_APP_ID`, `GITHUB_APP_INSTALLATION_ID`, and a private key are all + required together. +- If none of these variables are set, the server falls back to PAT or OAuth + auth depending on how you start it. + +## Running in Docker + +GitHub App auth does **not** use the browser callback flow, so Docker does +**not** need the OAuth callback port mapping required by +[Local Server OAuth Login](oauth-login.md). You only need to provide the app +credentials and, if you use a key file, mount it into the container. + +```bash +docker run -i --rm \ + -e GITHUB_APP_ID=12345 \ + -e GITHUB_APP_INSTALLATION_ID=67890 \ + -e GITHUB_APP_PRIVATE_KEY_PATH=/key/private-key.pem \ + -v /path/to/private-key.pem:/key/private-key.pem:ro \ + ghcr.io/github/github-mcp-server +``` + +VS Code (`.vscode/mcp.json`) with an inline private key: + +```json +{ + "servers": { + "github": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "GITHUB_APP_ID", + "-e", "GITHUB_APP_INSTALLATION_ID", + "-e", "GITHUB_APP_PRIVATE_KEY", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_APP_ID": "12345", + "GITHUB_APP_INSTALLATION_ID": "67890", + "GITHUB_APP_PRIVATE_KEY": "-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----" + } + } + } +} +``` + +## Using the `http` command + +The same environment variables work for the local HTTP server: + +```bash +export GITHUB_APP_ID=12345 +export GITHUB_APP_INSTALLATION_ID=67890 +export GITHUB_APP_PRIVATE_KEY_PATH=/path/to/private-key.pem + +github-mcp-server http --port 8082 +``` + +This is useful when you want a long-running local MCP HTTP endpoint but still +want installation-scoped, automatically refreshed credentials. + +## GitHub Enterprise Server and ghe.com + +GitHub App auth works against GitHub Enterprise Server and `ghe.com` too, as +long as: + +- the app is registered on that same host, +- the installation exists on that host, and +- you set `GITHUB_HOST` / `--gh-host` to that host. + +Example: + +```bash +export GITHUB_HOST=https://github.example.com +export GITHUB_APP_ID=12345 +export GITHUB_APP_INSTALLATION_ID=67890 +export GITHUB_APP_PRIVATE_KEY_PATH=/path/to/private-key.pem + +github-mcp-server stdio +``` + +The server derives the REST API base URL from `GITHUB_HOST`, so installation +tokens are requested from the correct instance. + +## Troubleshooting + +- **"incomplete GitHub App auth config"** — set `GITHUB_APP_ID`, + `GITHUB_APP_INSTALLATION_ID`, and one private key source together. +- **"GITHUB_APP_PRIVATE_KEY and GITHUB_APP_PRIVATE_KEY_PATH are mutually + exclusive"** — keep only one. +- **403 or 404 from GitHub** — the app is often not installed on the target + repo/org, or it lacks the needed permissions. +- **Private key parsing or JWT errors** — make sure the private key matches the + configured app and is valid PEM. +- **Unexpected fallback to PAT/OAuth** — confirm the app variables are present + in the actual server process, not just in your shell. diff --git a/docs/oauth-login.md b/docs/oauth-login.md index 16c5dab67e..4c7c8dc115 100644 --- a/docs/oauth-login.md +++ b/docs/oauth-login.md @@ -14,6 +14,10 @@ pass `--oauth-client-id` (see [Bring your own app](#bring-your-own-app)). > OAuth login applies to the **stdio** server only. The remote server and the > `http` command have their own authentication; see > [Remote Server](remote-server.md). +> +> To authenticate directly as a GitHub App installation via `GITHUB_APP_ID`, +> `GITHUB_APP_INSTALLATION_ID`, and a private key, see +> [Local Server GitHub App Authentication](github-app-auth.md). ## Contents diff --git a/go.sum b/go.sum index fbf06018f7..78089f385b 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= 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/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= diff --git a/internal/ghmcp/oauth_test.go b/internal/ghmcp/oauth_test.go index 732d080e40..c67d6eada0 100644 --- a/internal/ghmcp/oauth_test.go +++ b/internal/ghmcp/oauth_test.go @@ -348,6 +348,20 @@ func TestRunStdioServerRejectsTokenAndOAuth(t *testing.T) { assert.Contains(t, err.Error(), "mutually exclusive") } +func TestRunStdioServerRejectsAppAuthAndOAuth(t *testing.T) { + t.Parallel() + + mgr := oauth.NewManager(oauth.NewGitHubConfig("client-id", "", nil, "", 0), discardLogger()) + err := RunStdioServer(StdioServerConfig{ + AppID: 1, + PrivateKey: []byte("not-a-real-key"), + InstallationID: 2, + OAuthManager: mgr, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") +} + // TestCreateGitHubClientsTokenProvider proves the OAuth wiring: when a // TokenProvider is configured the REST client authenticates with the provider's // current token on every request (and never pins a stale one), which is what the @@ -369,7 +383,7 @@ func TestCreateGitHubClientsTokenProvider(t *testing.T) { clients, err := createGitHubClients(github.MCPServerConfig{ Version: "test", TokenProvider: func() string { return current }, - }, apiHost) + }, apiHost, nil) require.NoError(t, err) do := func() { diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 1bf84453c8..46847491eb 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -15,6 +15,7 @@ import ( "github.com/github/github-mcp-server/internal/oauth" "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/github/appauth" "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/lockdown" @@ -41,7 +42,9 @@ type githubClients struct { } // createGitHubClients creates all the GitHub API clients needed by the server. -func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolver) (*githubClients, error) { +// If authTransport is non-nil, it is used for authentication instead of cfg.Token +// or cfg.TokenProvider. +func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolver, authTransport http.RoundTripper) (*githubClients, error) { restURL, err := apiHost.BaseRESTURL(context.Background()) if err != nil { return nil, fmt.Errorf("failed to get base REST URL: %w", err) @@ -62,16 +65,30 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv return nil, fmt.Errorf("failed to get Raw URL: %w", err) } + // Determine the base transport for REST requests. GitHub App auth uses a + // custom transport; otherwise we start from the default transport and layer + // either a static token or a lazy token provider on top. + baseTransport := http.RoundTripper(http.DefaultTransport) + if authTransport != nil { + baseTransport = authTransport + } + // Construct REST client. When a TokenProvider is configured (OAuth), we // authenticate via BearerAuthTransport and skip go-github's WithAuthToken: // 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: baseTransport, Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version), } var restClient *gogithub.Client - if cfg.TokenProvider != nil { + switch { + case authTransport != nil: + restClient, err = gogithub.NewClient( + gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}), + gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), + ) + case cfg.TokenProvider != nil: restClient, err = gogithub.NewClient( gogithub.WithHTTPClient(&http.Client{Transport: &transport.BearerAuthTransport{ Transport: restUATransport, @@ -79,7 +96,7 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv }}), gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), ) - } else { + default: restClient, err = gogithub.NewClient( gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}), gogithub.WithAuthToken(cfg.Token), @@ -92,15 +109,23 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv // Construct GraphQL client // We use NewEnterpriseClient unconditionally since we already parsed the API host - gqlHTTPClient := &http.Client{ - Transport: &transport.BearerAuthTransport{ + var gqlTransport http.RoundTripper + if authTransport != nil { + // Auth transport already sets the Authorization header; addUserAgentsMiddleware + // will wrap the client transport with the session-specific User-Agent after initialize. + gqlTransport = &transport.GraphQLFeaturesTransport{ + Transport: authTransport, + } + } else { + gqlTransport = &transport.BearerAuthTransport{ Transport: &transport.GraphQLFeaturesTransport{ Transport: http.DefaultTransport, }, Token: cfg.Token, TokenProvider: cfg.TokenProvider, - }, + } } + gqlHTTPClient := &http.Client{Transport: gqlTransport} gqlClient := githubv4.NewEnterpriseClient(graphQLURL.String(), gqlHTTPClient) @@ -132,13 +157,13 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv }, nil } -func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Server, error) { +func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig, authTransport http.RoundTripper) (*mcp.Server, error) { apiHost, err := utils.NewAPIHost(cfg.Host) if err != nil { return nil, fmt.Errorf("failed to parse API host: %w", err) } - clients, err := createGitHubClients(cfg, apiHost) + clients, err := createGitHubClients(cfg, apiHost, authTransport) if err != nil { return nil, fmt.Errorf("failed to create GitHub clients: %w", err) } @@ -246,6 +271,13 @@ type StdioServerConfig struct { // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. RepoAccessCacheTTL *time.Duration + // GitHub App authentication (alternative to Token) + // When AppID, PrivateKey, and InstallationID are all set, the server + // authenticates as a GitHub App installation instead of using a PAT. + AppID int64 + PrivateKey []byte + InstallationID int64 + // OAuthManager, when non-nil, enables OAuth 2.1 login for stdio mode. The // server starts without a token and runs the authorization flow on the // first tool call (see createOAuthMiddleware). It is mutually exclusive with @@ -261,12 +293,17 @@ type StdioServerConfig struct { // RunStdioServer is not concurrent safe. func RunStdioServer(cfg StdioServerConfig) error { + useAppAuth := cfg.AppID != 0 && len(cfg.PrivateKey) > 0 && cfg.InstallationID != 0 + // OAuth login and a static token are mutually exclusive: they would // disagree on how the token is sourced (lazy provider vs. static) and on // scope filtering, so reject the ambiguous combination up front. if cfg.OAuthManager != nil && cfg.Token != "" { return fmt.Errorf("OAuthManager and a static Token are mutually exclusive: provide one or the other") } + if cfg.OAuthManager != nil && useAppAuth { + return fmt.Errorf("OAuthManager and GitHub App authentication are mutually exclusive: provide one or the other") + } // Create app context ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -290,12 +327,38 @@ func RunStdioServer(cfg StdioServerConfig) error { logger := slog.New(slogHandler) logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) + // Set up GitHub App authentication transport if configured + var appAuthTransport http.RoundTripper + if useAppAuth { + apiHost, err := utils.NewAPIHost(cfg.Host) + if err != nil { + return fmt.Errorf("failed to parse API host for app auth: %w", err) + } + baseURL, err := apiHost.BaseRESTURL(ctx) + if err != nil { + return fmt.Errorf("failed to get base REST URL for app auth: %w", err) + } + tr, err := appauth.NewTransport(http.DefaultTransport, appauth.Config{ + AppID: cfg.AppID, + PrivateKey: cfg.PrivateKey, + InstallationID: cfg.InstallationID, + BaseURL: baseURL.String(), + }) + if err != nil { + return fmt.Errorf("failed to create GitHub App auth transport: %w", err) + } + appAuthTransport = tr + logger.Info("using GitHub App authentication", "appID", cfg.AppID, "installationID", cfg.InstallationID) + } + // Determine the scope set used to filter tools. Classic PATs expose their // granted scopes via the API; OAuth uses the requested scopes (the default // set hides nothing, a narrower explicit set filters accordingly). Other // token types don't advertise scopes, so filtering is skipped. var tokenScopes []string switch { + case appAuthTransport != nil: + logger.Debug("skipping scope filtering for GitHub App authentication") case strings.HasPrefix(cfg.Token, "ghp_"): fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) if err != nil { @@ -335,7 +398,7 @@ func RunStdioServer(cfg StdioServerConfig) error { RepoAccessTTL: cfg.RepoAccessCacheTTL, TokenScopes: tokenScopes, TokenProvider: tokenProvider, - }) + }, appAuthTransport) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } diff --git a/pkg/github/appauth/appauth.go b/pkg/github/appauth/appauth.go new file mode 100644 index 0000000000..e4ad523bb5 --- /dev/null +++ b/pkg/github/appauth/appauth.go @@ -0,0 +1,218 @@ +package appauth + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// Config holds the configuration for GitHub App authentication. +type Config struct { + // AppID is the GitHub App ID. + AppID int64 + + // PrivateKey is the PEM-encoded RSA private key for the GitHub App. + PrivateKey []byte + + // InstallationID is the installation ID of the GitHub App. + InstallationID int64 + + // BaseURL is the base URL for the GitHub API (e.g., "https://api.github.com"). + // If empty, defaults to "https://api.github.com". + BaseURL string +} + +// Transport is an http.RoundTripper that authenticates requests using +// a GitHub App installation token. It automatically generates JWTs and +// fetches/refreshes installation tokens as needed. +type Transport struct { + config Config + key *rsa.PrivateKey + base http.RoundTripper + + mu sync.RWMutex + token string + exp time.Time +} + +type installationToken struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` +} + +// NewTransport creates a new Transport that authenticates using a GitHub App +// installation token. The transport automatically handles JWT generation and +// installation token refresh. +// The base transport must not inject its own Authorization header, as this +// transport sets it for both installation token requests and API requests. +func NewTransport(base http.RoundTripper, cfg Config) (*Transport, error) { + key, err := parsePrivateKey(cfg.PrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to parse private key: %w", err) + } + if base == nil { + base = http.DefaultTransport + } + if cfg.BaseURL == "" { + cfg.BaseURL = "https://api.github.com" + } + // Why: APIHost.BaseRESTURL returns "https://api.github.com/" (with trailing slash). + // Concatenating that with "/app/installations/..." yields "//app/..." which GitHub returns 404 for. + cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/") + return &Transport{ + config: cfg, + key: key, + base: base, + }, nil +} + +// RoundTrip implements http.RoundTripper. It adds the installation token +// to the Authorization header, refreshing it if necessary. +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + token, err := t.installationToken(req.Context()) + if err != nil { + return nil, fmt.Errorf("failed to get installation token: %w", err) + } + req2 := req.Clone(req.Context()) + req2.Header.Set("Authorization", "Bearer "+token) + return t.base.RoundTrip(req2) +} + +// Token returns the current installation token, refreshing if necessary. +func (t *Transport) Token(ctx context.Context) (string, error) { + return t.installationToken(ctx) +} + +func (t *Transport) installationToken(ctx context.Context) (string, error) { + // Fast path: read lock to check cached token + t.mu.RLock() + if t.token != "" && time.Now().Add(5*time.Minute).Before(t.exp) { + token := t.token + t.mu.RUnlock() + return token, nil + } + t.mu.RUnlock() + + // Slow path: write lock to refresh + t.mu.Lock() + defer t.mu.Unlock() + + // Double-check after acquiring write lock + if t.token != "" && time.Now().Add(5*time.Minute).Before(t.exp) { + return t.token, nil + } + + jwtToken, err := t.generateJWT() + if err != nil { + return "", fmt.Errorf("failed to generate JWT: %w", err) + } + + tok, err := t.fetchInstallationToken(ctx, jwtToken) + if err != nil { + return "", err + } + + t.token = tok.Token + t.exp = tok.ExpiresAt + return t.token, nil +} + +// generateJWT creates a signed JWT for GitHub App authentication using RS256. +func (t *Transport) generateJWT() (string, error) { + now := time.Now() + + header := map[string]string{ + "alg": "RS256", + "typ": "JWT", + } + payload := map[string]any{ + "iat": now.Add(-30 * time.Second).Unix(), // allow 30s clock drift + "exp": now.Add(9 * time.Minute).Unix(), // well within GitHub's 10-minute maximum + "iss": fmt.Sprintf("%d", t.config.AppID), + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return "", fmt.Errorf("failed to marshal JWT header: %w", err) + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal JWT payload: %w", err) + } + + headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) + + signingInput := headerB64 + "." + payloadB64 + hash := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, t.key, crypto.SHA256, hash[:]) + if err != nil { + return "", fmt.Errorf("failed to sign JWT: %w", err) + } + sigB64 := base64.RawURLEncoding.EncodeToString(sig) + + return signingInput + "." + sigB64, nil +} + +func (t *Transport) fetchInstallationToken(ctx context.Context, jwtToken string) (*installationToken, error) { + url := fmt.Sprintf("%s/app/installations/%d/access_tokens", t.config.BaseURL, t.config.InstallationID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+jwtToken) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := t.base.RoundTrip(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch installation token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to create installation token (status %d): %s", resp.StatusCode, body) + } + + var tok installationToken + if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil { + return nil, fmt.Errorf("failed to decode installation token response: %w", err) + } + return &tok, nil +} + +func parsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + switch block.Type { + case "RSA PRIVATE KEY": + return x509.ParsePKCS1PrivateKey(block.Bytes) + case "PRIVATE KEY": + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("expected RSA private key, got %T", key) + } + return rsaKey, nil + default: + return nil, fmt.Errorf("unsupported PEM block type: %s", block.Type) + } +} diff --git a/pkg/github/appauth/appauth_test.go b/pkg/github/appauth/appauth_test.go new file mode 100644 index 0000000000..477a167450 --- /dev/null +++ b/pkg/github/appauth/appauth_test.go @@ -0,0 +1,327 @@ +package appauth + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// verifyJWT parses and verifies a JWT token using the given RSA public key. +func verifyJWT(tokenString string, pubKey *rsa.PublicKey) (map[string]any, error) { + parts := strings.SplitN(tokenString, ".", 3) + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWT: expected 3 parts, got %d", len(parts)) + } + + signingInput := parts[0] + "." + parts[1] + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, fmt.Errorf("failed to decode signature: %w", err) + } + + hash := sha256.Sum256([]byte(signingInput)) + if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, hash[:], sig); err != nil { + return nil, fmt.Errorf("invalid signature: %w", err) + } + + payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode payload: %w", err) + } + var claims map[string]any + if err := json.Unmarshal(payloadJSON, &claims); err != nil { + return nil, fmt.Errorf("failed to unmarshal claims: %w", err) + } + return claims, nil +} + +func generateTestKey(t *testing.T) (*rsa.PrivateKey, []byte) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + return key, pemBytes +} + +func TestParsePrivateKey_PKCS1(t *testing.T) { + _, pemBytes := generateTestKey(t) + key, err := parsePrivateKey(pemBytes) + require.NoError(t, err) + assert.NotNil(t, key) +} + +func TestParsePrivateKey_PKCS8(t *testing.T) { + rsaKey, _ := generateTestKey(t) + pkcs8Bytes, err := x509.MarshalPKCS8PrivateKey(rsaKey) + require.NoError(t, err) + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: pkcs8Bytes, + }) + + key, err := parsePrivateKey(pemBytes) + require.NoError(t, err) + assert.NotNil(t, key) +} + +func TestParsePrivateKey_InvalidPEM(t *testing.T) { + _, err := parsePrivateKey([]byte("not a pem")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to decode PEM block") +} + +func TestParsePrivateKey_UnsupportedType(t *testing.T) { + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: []byte("fake"), + }) + _, err := parsePrivateKey(pemBytes) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported PEM block type") +} + +func TestNewTransport_InvalidKey(t *testing.T) { + _, err := NewTransport(nil, Config{ + AppID: 123, + PrivateKey: []byte("invalid"), + InstallationID: 456, + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse private key") +} + +func TestNewTransport_DefaultBaseURL(t *testing.T) { + _, pemBytes := generateTestKey(t) + tr, err := NewTransport(nil, Config{ + AppID: 123, + PrivateKey: pemBytes, + InstallationID: 456, + }) + require.NoError(t, err) + assert.Equal(t, "https://api.github.com", tr.config.BaseURL) +} + +func TestNewTransport_CustomBaseURL(t *testing.T) { + _, pemBytes := generateTestKey(t) + tr, err := NewTransport(nil, Config{ + AppID: 123, + PrivateKey: pemBytes, + InstallationID: 456, + BaseURL: "https://github.example.com/api/v3", + }) + require.NoError(t, err) + assert.Equal(t, "https://github.example.com/api/v3", tr.config.BaseURL) +} + +func TestNewTransport_TrimsTrailingSlash(t *testing.T) { + _, pemBytes := generateTestKey(t) + tr, err := NewTransport(nil, Config{ + AppID: 123, + PrivateKey: pemBytes, + InstallationID: 456, + BaseURL: "https://api.github.com/", + }) + require.NoError(t, err) + assert.Equal(t, "https://api.github.com", tr.config.BaseURL) +} + +func TestTransport_GenerateJWT(t *testing.T) { + key, pemBytes := generateTestKey(t) + tr, err := NewTransport(nil, Config{ + AppID: 12345, + PrivateKey: pemBytes, + InstallationID: 67890, + }) + require.NoError(t, err) + + jwtToken, err := tr.generateJWT() + require.NoError(t, err) + + claims, err := verifyJWT(jwtToken, &key.PublicKey) + require.NoError(t, err) + + assert.Equal(t, "12345", claims["iss"]) + + iat := int64(claims["iat"].(float64)) + exp := int64(claims["exp"].(float64)) + assert.InDelta(t, time.Now().Unix(), iat, 60) + assert.InDelta(t, time.Now().Add(9*time.Minute).Unix(), exp, 60) +} + +func TestTransport_FetchInstallationToken(t *testing.T) { + key, pemBytes := generateTestKey(t) + installationID := int64(67890) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := fmt.Sprintf("/app/installations/%d/access_tokens", installationID) + assert.Equal(t, expectedPath, r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + authHeader := r.Header.Get("Authorization") + assert.True(t, len(authHeader) > 7) + jwtToken := authHeader[7:] // strip "Bearer " + + _, err := verifyJWT(jwtToken, &key.PublicKey) + assert.NoError(t, err) + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(installationToken{ + Token: "ghs_test_token_123", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + })) + defer server.Close() + + tr, err := NewTransport(server.Client().Transport, Config{ + AppID: 12345, + PrivateKey: pemBytes, + InstallationID: installationID, + BaseURL: server.URL, + }) + require.NoError(t, err) + + token, err := tr.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, "ghs_test_token_123", token) +} + +func TestTransport_TokenCaching(t *testing.T) { + _, pemBytes := generateTestKey(t) + var callCount atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount.Add(1) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(installationToken{ + Token: "ghs_cached_token", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + })) + defer server.Close() + + tr, err := NewTransport(server.Client().Transport, Config{ + AppID: 12345, + PrivateKey: pemBytes, + InstallationID: 67890, + BaseURL: server.URL, + }) + require.NoError(t, err) + + token1, err := tr.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, "ghs_cached_token", token1) + + token2, err := tr.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, "ghs_cached_token", token2) + + assert.Equal(t, int32(1), callCount.Load()) +} + +func TestTransport_TokenRefresh(t *testing.T) { + _, pemBytes := generateTestKey(t) + var callCount atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + count := callCount.Add(1) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(installationToken{ + Token: fmt.Sprintf("ghs_token_%d", count), + ExpiresAt: time.Now().Add(1 * time.Minute), // expires soon, within 5min refresh window + }) + })) + defer server.Close() + + tr, err := NewTransport(server.Client().Transport, Config{ + AppID: 12345, + PrivateKey: pemBytes, + InstallationID: 67890, + BaseURL: server.URL, + }) + require.NoError(t, err) + + token1, err := tr.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, "ghs_token_1", token1) + + // Token expires within 5 minutes, so next call should refresh + token2, err := tr.Token(context.Background()) + require.NoError(t, err) + assert.Equal(t, "ghs_token_2", token2) + assert.Equal(t, int32(2), callCount.Load()) +} + +func TestTransport_RoundTrip(t *testing.T) { + _, pemBytes := generateTestKey(t) + + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/app/installations/67890/access_tokens" { + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(installationToken{ + Token: "ghs_roundtrip_token", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + return + } + assert.Equal(t, "Bearer ghs_roundtrip_token", r.Header.Get("Authorization")) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok": true}`)) + })) + defer tokenServer.Close() + + tr, err := NewTransport(tokenServer.Client().Transport, Config{ + AppID: 12345, + PrivateKey: pemBytes, + InstallationID: 67890, + BaseURL: tokenServer.URL, + }) + require.NoError(t, err) + + client := &http.Client{Transport: tr} + resp, err := client.Get(tokenServer.URL + "/repos/owner/repo") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestTransport_FetchError(t *testing.T) { + _, pemBytes := generateTestKey(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Bad credentials"}`)) + })) + defer server.Close() + + tr, err := NewTransport(server.Client().Transport, Config{ + AppID: 12345, + PrivateKey: pemBytes, + InstallationID: 67890, + BaseURL: server.URL, + }) + require.NoError(t, err) + + _, err = tr.Token(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create installation token") + assert.Contains(t, err.Error(), "Bad credentials") +} diff --git a/pkg/http/handler.go b/pkg/http/handler.go index eca628a47b..72e0dd33c9 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -8,6 +8,7 @@ import ( ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/github/appauth" "github.com/github/github-mcp-server/pkg/http/middleware" "github.com/github/github-mcp-server/pkg/http/oauth" "github.com/github/github-mcp-server/pkg/inventory" @@ -36,6 +37,7 @@ type Handler struct { oauthCfg *oauth.Config scopeFetcher scopes.FetcherInterface schemaCache *mcp.SchemaCache + appAuthTransport *appauth.Transport } type HandlerOptions struct { @@ -44,6 +46,7 @@ type HandlerOptions struct { OAuthConfig *oauth.Config ScopeFetcher scopes.FetcherInterface FeatureChecker inventory.FeatureFlagChecker + AppAuthTransport *appauth.Transport } type HandlerOption func(*HandlerOptions) @@ -54,6 +57,16 @@ func WithScopeFetcher(f scopes.FetcherInterface) HandlerOption { } } +// WithAppAuthTransport configures the handler to authenticate outbound GitHub +// API calls using a GitHub App installation. When set, an incoming request +// without an Authorization header is allowed: the middleware injects the +// installation token derived from this transport into the request context. +func WithAppAuthTransport(t *appauth.Transport) HandlerOption { + return func(o *HandlerOptions) { + o.AppAuthTransport = t + } +} + func WithGitHubMCPServerFactory(f GitHubMCPServerFactoryFunc) HandlerOption { return func(o *HandlerOptions) { o.GitHubMcpServerFactory = f @@ -122,10 +135,14 @@ func NewHTTPMcpHandler( oauthCfg: opts.OAuthConfig, scopeFetcher: scopeFetcher, schemaCache: schemaCache, + appAuthTransport: opts.AppAuthTransport, } } func (h *Handler) RegisterMiddleware(r chi.Router) { + if h.appAuthTransport != nil { + r.Use(middleware.WithGitHubAppToken(h.appAuthTransport, h.logger)) + } r.Use( middleware.ExtractUserToken(h.oauthCfg), middleware.WithRequestConfig, diff --git a/pkg/http/middleware/app_auth.go b/pkg/http/middleware/app_auth.go new file mode 100644 index 0000000000..f641a32151 --- /dev/null +++ b/pkg/http/middleware/app_auth.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "log/slog" + "net/http" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/github/appauth" + "github.com/github/github-mcp-server/pkg/utils" +) + +// WithGitHubAppToken injects a GitHub App installation token into the request +// context when the incoming request does not already carry one. This lets the +// HTTP server authenticate as a GitHub App installation instead of requiring +// every caller to send an Authorization header. +// +// If the request already carries TokenInfo (e.g., an explicit Authorization +// header parsed earlier), this middleware is a no-op so per-request tokens +// take precedence. +// +// Why: the HTTP server's downstream pipeline (ExtractUserToken, +// RequestDeps.GetClient) is built around a per-request bearer token. Rather +// than rewire that pipeline, we synthesize a TokenInfo from the installation +// token so the existing flow works unchanged. +func WithGitHubAppToken(transport *appauth.Transport, logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if _, ok := ghcontext.GetTokenInfo(ctx); ok { + next.ServeHTTP(w, r) + return + } + + token, err := transport.Token(ctx) + if err != nil { + logger.Error("failed to obtain GitHub App installation token", "error", err) + http.Error(w, "failed to obtain GitHub App installation token", http.StatusInternalServerError) + return + } + + ctx = ghcontext.WithTokenInfo(ctx, &ghcontext.TokenInfo{ + Token: token, + TokenType: utils.TokenTypeServerToServerGitHubAppToken, + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/pkg/http/middleware/app_auth_test.go b/pkg/http/middleware/app_auth_test.go new file mode 100644 index 0000000000..81ee12879e --- /dev/null +++ b/pkg/http/middleware/app_auth_test.go @@ -0,0 +1,141 @@ +package middleware + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/github/appauth" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func generateAppKey(t *testing.T) []byte { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + return pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) +} + +func newAppTransport(t *testing.T, baseURL string) *appauth.Transport { + t.Helper() + tr, err := appauth.NewTransport(http.DefaultTransport, appauth.Config{ + AppID: 12345, + PrivateKey: generateAppKey(t), + InstallationID: 67890, + BaseURL: baseURL, + }) + require.NoError(t, err) + return tr +} + +func TestWithGitHubAppToken_InjectsToken(t *testing.T) { + var hits atomic.Int32 + ghAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": "ghs_injected_token", + "expires_at": time.Now().Add(1 * time.Hour), + }) + })) + defer ghAPI.Close() + + tr := newAppTransport(t, ghAPI.URL) + + var captured *ghcontext.TokenInfo + next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + info, _ := ghcontext.GetTokenInfo(r.Context()) + captured = info + }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := WithGitHubAppToken(tr, logger)(next) + + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + require.NotNil(t, captured) + assert.Equal(t, "ghs_injected_token", captured.Token) + assert.Equal(t, utils.TokenTypeServerToServerGitHubAppToken, captured.TokenType) + assert.Equal(t, int32(1), hits.Load()) +} + +func TestWithGitHubAppToken_PreservesExistingTokenInfo(t *testing.T) { + var hits atomic.Int32 + ghAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": "ghs_should_not_be_used", + "expires_at": time.Now().Add(1 * time.Hour), + }) + })) + defer ghAPI.Close() + + tr := newAppTransport(t, ghAPI.URL) + + pre := &ghcontext.TokenInfo{ + Token: "ghp_explicit", + TokenType: utils.TokenTypePersonalAccessToken, + } + + var captured *ghcontext.TokenInfo + next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + info, _ := ghcontext.GetTokenInfo(r.Context()) + captured = info + }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := WithGitHubAppToken(tr, logger)(next) + + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + req = req.WithContext(ghcontext.WithTokenInfo(req.Context(), pre)) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + require.NotNil(t, captured) + assert.Equal(t, "ghp_explicit", captured.Token) + assert.Equal(t, int32(0), hits.Load(), "installation token should not have been fetched") +} + +func TestWithGitHubAppToken_PropagatesFetchError(t *testing.T) { + ghAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer ghAPI.Close() + + tr := newAppTransport(t, ghAPI.URL) + + nextCalled := false + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + nextCalled = true + }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := WithGitHubAppToken(tr, logger)(next) + + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusInternalServerError, rr.Code) + assert.False(t, nextCalled, "next handler must not run when token fetch fails") +} diff --git a/pkg/http/server.go b/pkg/http/server.go index 36d3e111bc..28005c664d 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -15,6 +15,7 @@ import ( ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/github/appauth" "github.com/github/github-mcp-server/pkg/http/middleware" "github.com/github/github-mcp-server/pkg/http/oauth" "github.com/github/github-mcp-server/pkg/inventory" @@ -100,6 +101,14 @@ type ServerConfig struct { // InsidersMode expands to the curated set of feature flags enabled for insiders. InsidersMode bool + + // GitHub App authentication (alternative to per-request bearer tokens). + // When AppID, PrivateKey, and InstallationID are all set, the server + // authenticates outbound GitHub API calls as a GitHub App installation + // instead of requiring an Authorization header on incoming requests. + AppID int64 + PrivateKey []byte + InstallationID int64 } func RunHTTPServer(cfg ServerConfig) error { @@ -174,6 +183,24 @@ func RunHTTPServer(cfg ServerConfig) error { serverOptions = append(serverOptions, WithScopeFetcher(scopeFetcher)) } + if cfg.AppID != 0 && len(cfg.PrivateKey) > 0 && cfg.InstallationID != 0 { + baseURL, err := apiHost.BaseRESTURL(ctx) + if err != nil { + return fmt.Errorf("failed to get base REST URL for app auth: %w", err) + } + appTransport, err := appauth.NewTransport(http.DefaultTransport, appauth.Config{ + AppID: cfg.AppID, + PrivateKey: cfg.PrivateKey, + InstallationID: cfg.InstallationID, + BaseURL: baseURL.String(), + }) + if err != nil { + return fmt.Errorf("failed to create GitHub App auth transport: %w", err) + } + serverOptions = append(serverOptions, WithAppAuthTransport(appTransport)) + logger.Info("using GitHub App authentication", "appID", cfg.AppID, "installationID", cfg.InstallationID) + } + r := chi.NewRouter() handler := NewHTTPMcpHandler(ctx, &cfg, deps, t, logger, apiHost, append(serverOptions, WithFeatureChecker(featureChecker), WithOAuthConfig(oauthCfg))...) oauthHandler, err := oauth.NewAuthHandler(oauthCfg, apiHost)