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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details><summary><b>Handling PATs Securely</b></summary>

### Environment Variables (Recommended)
Expand Down
53 changes: 49 additions & 4 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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"),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -194,6 +228,7 @@ var (
EnabledFeatures: enabledFeatures,
InsidersMode: viper.GetBool("insiders"),
TrustProxyHeaders: viper.GetBool("trust-proxy-headers"),
GitHubAppTokenSource: appTokenSource,
}

return ghhttp.RunHTTPServer(httpConfig)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"))
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
12 changes: 12 additions & 0 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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{
Expand Down
72 changes: 43 additions & 29 deletions pkg/github/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/github/projects_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions pkg/http/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -153,6 +157,7 @@ func RunHTTPServer(cfg ServerConfig) error {
cfg.ContentWindowSize,
featureChecker,
obs,
cfg.GitHubAppTokenSource,
)

// Initialize the global tool scope map
Expand Down
Loading