Skip to content

Commit 5ee150e

Browse files
committed
feat(auth): add custom github app authentication with automatic token refresh
1 parent 870f3c7 commit 5ee150e

9 files changed

Lines changed: 368 additions & 34 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,14 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to
183183
1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed.
184184
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`.
185185
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`).
186-
187186
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)).
188187

188+
**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):
189+
- `GITHUB_APP_ID` (or `--app-id`): The GitHub App ID.
190+
- `GITHUB_APP_INSTALLATION_ID` (or `--app-installation-id`): The App installation ID.
191+
- `GITHUB_APP_PRIVATE_KEY` (or `--app-private-key`): The RSA private key PEM block string.
192+
- `GITHUB_APP_PRIVATE_KEY_PATH` (or `--app-private-key-path`): Alternatively, the file path to the RSA private key PEM file.
193+
189194
<details><summary><b>Handling PATs Securely</b></summary>
190195

191196
### Environment Variables (Recommended)

cmd/github-mcp-server/main.go

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ import (
1313
"github.com/github/github-mcp-server/pkg/github"
1414
ghhttp "github.com/github/github-mcp-server/pkg/http"
1515
ghoauth "github.com/github/github-mcp-server/pkg/http/oauth"
16+
"github.com/github/github-mcp-server/pkg/utils"
1617
"github.com/spf13/cobra"
1718
"github.com/spf13/pflag"
1819
"github.com/spf13/viper"
20+
"golang.org/x/oauth2"
1921
)
2022

2123
// These variables are set by the build process using ldflags.
@@ -39,6 +41,22 @@ var (
3941
token := viper.GetString("personal_access_token")
4042
oauthClientID := viper.GetString("oauth-client-id")
4143
oauthClientSecret := viper.GetString("oauth-client-secret")
44+
45+
// Check GitHub App configuration
46+
appID := viper.GetInt64("app-id")
47+
appInstallationID := viper.GetInt64("app-installation-id")
48+
appPrivateKey := viper.GetString("app-private-key")
49+
appPrivateKeyPath := viper.GetString("app-private-key-path")
50+
51+
var appTokenSource oauth2.TokenSource
52+
if appID != 0 && appInstallationID != 0 {
53+
var err error
54+
appTokenSource, err = utils.NewGitHubAppTokenSource(appID, appInstallationID, []byte(appPrivateKey), appPrivateKeyPath, viper.GetString("host"))
55+
if err != nil {
56+
return err
57+
}
58+
}
59+
4260
// Fall back to the build-time baked-in client (official releases) when none is
4361
// configured explicitly. The baked-in app is registered on github.com, so it is
4462
// only applied to the default host; GHES/ghe.com users must bring their own
@@ -50,8 +68,8 @@ var (
5068
oauthClientID = buildinfo.OAuthClientID
5169
oauthClientSecret = buildinfo.OAuthClientSecret
5270
}
53-
if token == "" && oauthClientID == "" {
54-
return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, or pass --oauth-client-id to log in via OAuth")
71+
if token == "" && oauthClientID == "" && appTokenSource == nil {
72+
return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, pass --oauth-client-id to log in via OAuth, or configure GitHub App credentials")
5573
}
5674

5775
// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
@@ -110,13 +128,14 @@ var (
110128
InsidersMode: viper.GetBool("insiders"),
111129
ExcludeTools: excludeTools,
112130
RepoAccessCacheTTL: &ttl,
131+
GitHubAppTokenSource: appTokenSource,
113132
}
114133

115-
// When no static token is provided, log in via OAuth using the given
134+
// When no static token or GitHub App TokenSource is set, log in via OAuth using the given
116135
// client. The requested scopes default to the full supported set
117136
// (which filters out no tools); an explicit, narrower --oauth-scopes
118137
// both narrows the grant and hides tools needing other scopes.
119-
if token == "" {
138+
if token == "" && appTokenSource == nil {
120139
scopes := ghoauth.SupportedScopes
121140
if viper.IsSet("oauth-scopes") {
122141
if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil {
@@ -172,6 +191,21 @@ var (
172191
}
173192
}
174193

194+
// Check GitHub App configuration for HTTP mode
195+
appID := viper.GetInt64("app-id")
196+
appInstallationID := viper.GetInt64("app-installation-id")
197+
appPrivateKey := viper.GetString("app-private-key")
198+
appPrivateKeyPath := viper.GetString("app-private-key-path")
199+
200+
var appTokenSource oauth2.TokenSource
201+
if appID != 0 && appInstallationID != 0 {
202+
var err error
203+
appTokenSource, err = utils.NewGitHubAppTokenSource(appID, appInstallationID, []byte(appPrivateKey), appPrivateKeyPath, viper.GetString("host"))
204+
if err != nil {
205+
return err
206+
}
207+
}
208+
175209
ttl := viper.GetDuration("repo-access-cache-ttl")
176210
httpConfig := ghhttp.ServerConfig{
177211
Version: version,
@@ -194,6 +228,7 @@ var (
194228
EnabledFeatures: enabledFeatures,
195229
InsidersMode: viper.GetBool("insiders"),
196230
TrustProxyHeaders: viper.GetBool("trust-proxy-headers"),
231+
GitHubAppTokenSource: appTokenSource,
197232
}
198233

199234
return ghhttp.RunHTTPServer(httpConfig)
@@ -222,6 +257,12 @@ func init() {
222257
rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features")
223258
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")
224259

260+
// GitHub App flags
261+
rootCmd.PersistentFlags().Int64("app-id", 0, "GitHub App ID")
262+
rootCmd.PersistentFlags().Int64("app-installation-id", 0, "GitHub App Installation ID")
263+
rootCmd.PersistentFlags().String("app-private-key", "", "GitHub App Private Key PEM content")
264+
rootCmd.PersistentFlags().String("app-private-key-path", "", "Path to GitHub App Private Key PEM file")
265+
225266
// stdio-specific OAuth flags. Provide --oauth-client-id (instead of a token)
226267
// to log in via the browser-based OAuth flow on first use. Works for both
227268
// OAuth Apps and GitHub Apps.
@@ -252,6 +293,10 @@ func init() {
252293
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
253294
_ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders"))
254295
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
296+
_ = viper.BindPFlag("app-id", rootCmd.PersistentFlags().Lookup("app-id"))
297+
_ = viper.BindPFlag("app-installation-id", rootCmd.PersistentFlags().Lookup("app-installation-id"))
298+
_ = viper.BindPFlag("app-private-key", rootCmd.PersistentFlags().Lookup("app-private-key"))
299+
_ = viper.BindPFlag("app-private-key-path", rootCmd.PersistentFlags().Lookup("app-private-key-path"))
255300
_ = viper.BindPFlag("oauth-client-id", stdioCmd.Flags().Lookup("oauth-client-id"))
256301
_ = viper.BindPFlag("oauth-client-secret", stdioCmd.Flags().Lookup("oauth-client-secret"))
257302
_ = viper.BindPFlag("oauth-scopes", stdioCmd.Flags().Lookup("oauth-scopes"))

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ go 1.25.0
44

55
require (
66
github.com/go-chi/chi/v5 v5.3.1
7+
github.com/go-resty/resty/v2 v2.17.2
78
github.com/go-viper/mapstructure/v2 v2.5.0
9+
github.com/golang-jwt/jwt/v5 v5.3.1
810
github.com/google/go-github/v89 v89.0.0
911
github.com/google/jsonschema-go v0.4.3
1012
github.com/josephburnett/jd/v2 v2.5.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
99
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
1010
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
1111
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
12+
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
13+
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
1214
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
1315
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
1416
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=

internal/ghmcp/server.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
gogithub "github.com/google/go-github/v89/github"
2929
"github.com/modelcontextprotocol/go-sdk/mcp"
3030
"github.com/shurcooL/githubv4"
31+
"golang.org/x/oauth2"
3132
)
3233

3334
// githubClients holds all the GitHub API clients created for a server instance.
@@ -257,6 +258,9 @@ type StdioServerConfig struct {
257258
// are hidden. The default set is the full supported list, which hides
258259
// nothing; an explicit, narrower list filters accordingly.
259260
OAuthScopes []string
261+
262+
// GitHubAppTokenSource, when non-nil, supplies the GitHub App installation token.
263+
GitHubAppTokenSource oauth2.TokenSource
260264
}
261265

262266
// RunStdioServer is not concurrent safe.
@@ -318,6 +322,14 @@ func RunStdioServer(cfg StdioServerConfig) error {
318322
if cfg.OAuthManager != nil {
319323
tokenProvider = cfg.OAuthManager.AccessToken
320324
toolHandlerMiddleware = append(toolHandlerMiddleware, createOAuthToolMiddleware(cfg.OAuthManager, logger))
325+
} else if cfg.GitHubAppTokenSource != nil {
326+
tokenProvider = func() string {
327+
tok, err := cfg.GitHubAppTokenSource.Token()
328+
if err != nil {
329+
return ""
330+
}
331+
return tok.AccessToken
332+
}
321333
}
322334

323335
ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{

pkg/github/dependencies.go

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
gogithub "github.com/google/go-github/v89/github"
2222
"github.com/modelcontextprotocol/go-sdk/mcp"
2323
"github.com/shurcooL/githubv4"
24+
"golang.org/x/oauth2"
2425
)
2526

2627
// depsContextKey is the context key for ToolDependencies.
@@ -267,12 +268,13 @@ func NewToolFromHandler(
267268

268269
type RequestDeps struct {
269270
// Static dependencies
270-
apiHosts utils.APIHostResolver
271-
version string
272-
lockdownMode bool
273-
RepoAccessOpts []lockdown.RepoAccessOption
274-
T translations.TranslationHelperFunc
275-
ContentWindowSize int
271+
apiHosts utils.APIHostResolver
272+
version string
273+
lockdownMode bool
274+
RepoAccessOpts []lockdown.RepoAccessOption
275+
T translations.TranslationHelperFunc
276+
ContentWindowSize int
277+
gitHubAppTokenSource oauth2.TokenSource
276278

277279
// Feature flag checker for runtime checks
278280
featureChecker inventory.FeatureFlagChecker
@@ -291,27 +293,41 @@ func NewRequestDeps(
291293
contentWindowSize int,
292294
featureChecker inventory.FeatureFlagChecker,
293295
obsv observability.Exporters,
296+
gitHubAppTokenSource oauth2.TokenSource,
294297
) *RequestDeps {
295298
return &RequestDeps{
296-
apiHosts: apiHosts,
297-
version: version,
298-
lockdownMode: lockdownMode,
299-
RepoAccessOpts: repoAccessOpts,
300-
T: t,
301-
ContentWindowSize: contentWindowSize,
302-
featureChecker: featureChecker,
303-
obsv: obsv,
299+
apiHosts: apiHosts,
300+
version: version,
301+
lockdownMode: lockdownMode,
302+
RepoAccessOpts: repoAccessOpts,
303+
T: t,
304+
ContentWindowSize: contentWindowSize,
305+
featureChecker: featureChecker,
306+
obsv: obsv,
307+
gitHubAppTokenSource: gitHubAppTokenSource,
304308
}
305309
}
306310

311+
func (d *RequestDeps) resolveToken(ctx context.Context) (string, error) {
312+
if tokenInfo, ok := ghcontext.GetTokenInfo(ctx); ok {
313+
return tokenInfo.Token, nil
314+
}
315+
if d.gitHubAppTokenSource != nil {
316+
tok, err := d.gitHubAppTokenSource.Token()
317+
if err != nil {
318+
return "", fmt.Errorf("failed to get default token from GitHub App source: %w", err)
319+
}
320+
return tok.AccessToken, nil
321+
}
322+
return "", fmt.Errorf("no token info in context and no default token source configured")
323+
}
324+
307325
// GetClient implements ToolDependencies.
308326
func (d *RequestDeps) GetClient(ctx context.Context) (*gogithub.Client, error) {
309-
// extract the token from the context
310-
tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
311-
if !ok {
312-
return nil, fmt.Errorf("no token info in context")
327+
token, err := d.resolveToken(ctx)
328+
if err != nil {
329+
return nil, err
313330
}
314-
token := tokenInfo.Token
315331

316332
baseRestURL, err := d.apiHosts.BaseRESTURL(ctx)
317333
if err != nil {
@@ -336,12 +352,15 @@ func (d *RequestDeps) GetClient(ctx context.Context) (*gogithub.Client, error) {
336352

337353
// GetGQLClient implements ToolDependencies.
338354
func (d *RequestDeps) GetGQLClient(ctx context.Context) (*githubv4.Client, error) {
339-
// extract the token from the context
340-
tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
341-
if !ok {
342-
return nil, fmt.Errorf("no token info in context")
355+
token, err := d.resolveToken(ctx)
356+
if err != nil {
357+
return nil, err
358+
}
359+
360+
graphqlURL, err := d.apiHosts.GraphqlURL(ctx)
361+
if err != nil {
362+
return nil, fmt.Errorf("failed to get GraphQL URL: %w", err)
343363
}
344-
token := tokenInfo.Token
345364

346365
// Construct GraphQL client
347366
// 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
356375
},
357376
}
358377

359-
graphqlURL, err := d.apiHosts.GraphqlURL(ctx)
360-
if err != nil {
361-
return nil, fmt.Errorf("failed to get GraphQL URL: %w", err)
362-
}
363-
364378
gqlClient := githubv4.NewEnterpriseClient(graphqlURL.String(), gqlHTTPClient)
365379
return gqlClient, nil
366380
}

pkg/http/server.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/github/github-mcp-server/pkg/translations"
2626
"github.com/github/github-mcp-server/pkg/utils"
2727
"github.com/go-chi/chi/v5"
28+
"golang.org/x/oauth2"
2829
)
2930

3031
type ServerConfig struct {
@@ -100,6 +101,9 @@ type ServerConfig struct {
100101

101102
// InsidersMode expands to the curated set of feature flags enabled for insiders.
102103
InsidersMode bool
104+
105+
// GitHubAppTokenSource, when non-nil, supplies the GitHub App installation token.
106+
GitHubAppTokenSource oauth2.TokenSource
103107
}
104108

105109
func RunHTTPServer(cfg ServerConfig) error {
@@ -153,6 +157,7 @@ func RunHTTPServer(cfg ServerConfig) error {
153157
cfg.ContentWindowSize,
154158
featureChecker,
155159
obs,
160+
cfg.GitHubAppTokenSource,
156161
)
157162

158163
// Initialize the global tool scope map

0 commit comments

Comments
 (0)