Skip to content

Commit 1d4792c

Browse files
committed
add app based authentication support
1 parent c7a872b commit 1d4792c

File tree

6 files changed

+430
-27
lines changed

6 files changed

+430
-27
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ automation and interaction capabilities for developers and tools.
1919
3. Lastly you will need to [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new).
2020
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)).
2121

22+
## Authentication
23+
GitHub personal access token can be substituted with GitHub App authentication using the environment variables:
24+
GITHUB_APP_ID + GITHUB_INSTALLATION_ID + GITHUB_PRIVATE_KEY_PEM instead of GITHUB_PERSONAL_ACCESS_TOKEN
25+
26+
1. Create a GitHub App in your account or organization
27+
2. Configure the app with the desired permissions
28+
3. Install the app in your organization (or on specific repositories)
29+
4. Generate and download a private key for the app
30+
4. Note your App ID and Installation ID (found in the app settings)
31+
2232
## Installation
2333

2434
### Usage with VS Code

cmd/github-mcp-server/main.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package main
22

33
import (
4-
"errors"
54
"fmt"
65
"os"
76

@@ -29,9 +28,10 @@ var (
2928
Short: "Start stdio server",
3029
Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`,
3130
RunE: func(_ *cobra.Command, _ []string) error {
32-
token := viper.GetString("personal_access_token")
33-
if token == "" {
34-
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set")
31+
// Validate authentication configuration
32+
authConfig, err := ghmcp.BuildAuthConfig()
33+
if err != nil {
34+
return err
3535
}
3636

3737
// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
@@ -46,7 +46,7 @@ var (
4646
stdioServerConfig := ghmcp.StdioServerConfig{
4747
Version: version,
4848
Host: viper.GetString("host"),
49-
Token: token,
49+
Auth: authConfig,
5050
EnabledToolsets: enabledToolsets,
5151
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
5252
ReadOnly: viper.GetBool("read-only"),
@@ -74,7 +74,12 @@ func init() {
7474
rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file")
7575
rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)")
7676

77-
// Bind flag to viper
77+
// Add GitHub App authentication flags
78+
rootCmd.PersistentFlags().String("app-id", "", "GitHub App ID")
79+
rootCmd.PersistentFlags().String("installation-id", "", "GitHub App Installation ID")
80+
rootCmd.PersistentFlags().String("private-key-pem", "", "GitHub App private key PEM content")
81+
82+
// Bind flags to viper
7883
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
7984
_ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets"))
8085
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))
@@ -83,6 +88,11 @@ func init() {
8388
_ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations"))
8489
_ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host"))
8590

91+
// Bind GitHub App flags to viper
92+
_ = viper.BindPFlag("app_id", rootCmd.PersistentFlags().Lookup("app-id"))
93+
_ = viper.BindPFlag("installation_id", rootCmd.PersistentFlags().Lookup("installation-id"))
94+
_ = viper.BindPFlag("private_key_pem", rootCmd.PersistentFlags().Lookup("private-key-pem"))
95+
8696
// Add subcommands
8797
rootCmd.AddCommand(stdioCmd)
8898
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/github/github-mcp-server
33
go 1.23.7
44

55
require (
6+
github.com/bradleyfalzon/ghinstallation/v2 v2.16.0
67
github.com/google/go-github/v72 v72.0.0
78
github.com/josephburnett/jd v1.9.2
89
github.com/mark3labs/mcp-go v0.30.0
@@ -16,6 +17,8 @@ require (
1617
require (
1718
github.com/go-openapi/jsonpointer v0.19.5 // indirect
1819
github.com/go-openapi/swag v0.21.1 // indirect
20+
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
21+
github.com/google/go-github/v62 v62.0.0 // indirect
1922
github.com/josharian/intern v1.0.0 // indirect
2023
github.com/mailru/easyjson v0.7.7 // indirect
2124
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
github.com/bradleyfalzon/ghinstallation/v2 v2.11.0 h1:R9d0v+iobRHSaE4wKUnXFiZp53AL4ED5MzgEMwGTZag=
2+
github.com/bradleyfalzon/ghinstallation/v2 v2.11.0/go.mod h1:0LWKQwOHewXO/1acI6TtyE0Xc4ObDb2rFN7eHBAG71M=
3+
github.com/bradleyfalzon/ghinstallation/v2 v2.16.0 h1:B91r9bHtXp/+XRgS5aZm6ZzTdz3ahgJYmkt4xZkgDz8=
4+
github.com/bradleyfalzon/ghinstallation/v2 v2.16.0/go.mod h1:OeVe5ggFzoBnmgitZe/A+BqGOnv1DvU/0uiLQi1wutM=
15
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
26
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
37
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -15,9 +19,15 @@ github.com/go-openapi/swag v0.21.1 h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrK
1519
github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
1620
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
1721
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
22+
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
23+
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
24+
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
25+
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
1826
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
27+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
1928
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
2029
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
30+
github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4=
2131
github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30=
2232
github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M=
2333
github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM=

internal/ghmcp/server.go

Lines changed: 164 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ package ghmcp
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"io"
78
"log"
89
"net/http"
910
"net/url"
1011
"os"
1112
"os/signal"
13+
"strconv"
1214
"strings"
1315
"syscall"
1416

17+
"github.com/bradleyfalzon/ghinstallation/v2"
1518
"github.com/github/github-mcp-server/pkg/github"
1619
mcplog "github.com/github/github-mcp-server/pkg/log"
1720
"github.com/github/github-mcp-server/pkg/translations"
@@ -20,17 +23,29 @@ import (
2023
"github.com/mark3labs/mcp-go/server"
2124
"github.com/shurcooL/githubv4"
2225
"github.com/sirupsen/logrus"
26+
"github.com/spf13/viper"
2327
)
2428

29+
// AuthConfig represents authentication configuration
30+
type AuthConfig struct {
31+
// Personal Access Token authentication
32+
Token string
33+
34+
// GitHub App authentication
35+
AppID string
36+
InstallationID string
37+
PrivateKeyPEM string
38+
}
39+
2540
type MCPServerConfig struct {
2641
// Version of the server
2742
Version string
2843

2944
// GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
3045
Host string
3146

32-
// GitHub Token to authenticate with the GitHub API
33-
Token string
47+
// Authentication configuration
48+
Auth AuthConfig
3449

3550
// EnabledToolsets is a list of toolsets to enable
3651
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
@@ -47,37 +62,165 @@ type MCPServerConfig struct {
4762
Translator translations.TranslationHelperFunc
4863
}
4964

65+
// authMethod represents the authentication method being used
66+
type authMethod int
67+
68+
const (
69+
authToken authMethod = iota
70+
authGitHubApp
71+
)
72+
73+
// getAuthMethod determines which authentication method to use based on the config
74+
func (cfg *MCPServerConfig) getAuthMethod() (authMethod, error) {
75+
hasToken := cfg.Auth.Token != ""
76+
hasApp := cfg.Auth.AppID != "" && cfg.Auth.InstallationID != "" && cfg.Auth.PrivateKeyPEM != ""
77+
78+
if hasToken && hasApp {
79+
return 0, fmt.Errorf("cannot specify both token and GitHub App authentication")
80+
}
81+
82+
if !hasToken && !hasApp {
83+
return 0, fmt.Errorf("must specify either token or GitHub App authentication")
84+
}
85+
86+
if hasToken {
87+
return authToken, nil
88+
}
89+
90+
return authGitHubApp, nil
91+
}
92+
93+
// createGitHubAppTransport creates an authenticated transport for GitHub App
94+
func (cfg *MCPServerConfig) createGitHubAppTransport() (http.RoundTripper, error) {
95+
appID, err := strconv.ParseInt(cfg.Auth.AppID, 10, 64)
96+
if err != nil {
97+
return nil, fmt.Errorf("invalid app ID: %w", err)
98+
}
99+
100+
installationID, err := strconv.ParseInt(cfg.Auth.InstallationID, 10, 64)
101+
if err != nil {
102+
return nil, fmt.Errorf("invalid installation ID: %w", err)
103+
}
104+
105+
transport, err := ghinstallation.New(
106+
http.DefaultTransport,
107+
appID,
108+
installationID,
109+
[]byte(cfg.Auth.PrivateKeyPEM),
110+
)
111+
if err != nil {
112+
return nil, fmt.Errorf("failed to create GitHub App transport: %w", err)
113+
}
114+
115+
return transport, nil
116+
}
117+
118+
// BuildAuthConfig creates an AuthConfig based on environment variables and flags
119+
func BuildAuthConfig() (AuthConfig, error) {
120+
var authConfig AuthConfig
121+
122+
// Check for Personal Access Token
123+
token := viper.GetString("personal_access_token")
124+
125+
// Check for GitHub App credentials
126+
appID := viper.GetString("app_id")
127+
installationID := viper.GetString("installation_id")
128+
privateKeyPEM := viper.GetString("private_key_pem")
129+
130+
// Determine authentication method
131+
hasToken := token != ""
132+
hasApp := appID != "" && installationID != "" && privateKeyPEM != ""
133+
134+
if !hasToken && !hasApp {
135+
return authConfig, errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN or GitHub App credentials (GITHUB_APP_ID, GITHUB_INSTALLATION_ID, and GITHUB_PRIVATE_KEY_PEM)")
136+
}
137+
138+
if hasToken && hasApp {
139+
return authConfig, errors.New("cannot specify both personal access token and GitHub App authentication")
140+
}
141+
142+
if hasToken {
143+
authConfig.Token = token
144+
} else {
145+
authConfig.AppID = appID
146+
authConfig.InstallationID = installationID
147+
authConfig.PrivateKeyPEM = privateKeyPEM
148+
}
149+
150+
return authConfig, nil
151+
}
152+
50153
func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
51154
apiHost, err := parseAPIHost(cfg.Host)
52155
if err != nil {
53156
return nil, fmt.Errorf("failed to parse API host: %w", err)
54157
}
55158

159+
authMethod, err := cfg.getAuthMethod()
160+
if err != nil {
161+
return nil, fmt.Errorf("authentication configuration error: %w", err)
162+
}
163+
164+
// Create HTTP client based on authentication method
165+
var httpClient *http.Client
166+
var userAgent string
167+
168+
switch authMethod {
169+
case authToken:
170+
// Use token-based authentication
171+
httpClient = &http.Client{
172+
Transport: &bearerAuthTransport{
173+
transport: http.DefaultTransport,
174+
token: cfg.Auth.Token,
175+
},
176+
}
177+
userAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version)
178+
179+
case authGitHubApp:
180+
// Use GitHub App authentication
181+
transport, err := cfg.createGitHubAppTransport()
182+
if err != nil {
183+
return nil, err
184+
}
185+
186+
httpClient = &http.Client{Transport: transport}
187+
userAgent = fmt.Sprintf("github-mcp-server/%s (GitHub App)", cfg.Version)
188+
}
189+
56190
// Construct our REST client
57-
restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token)
58-
restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version)
191+
var restClient *gogithub.Client
192+
if authMethod == authToken {
193+
restClient = gogithub.NewClient(nil).WithAuthToken(cfg.Auth.Token)
194+
} else {
195+
restClient = gogithub.NewClient(httpClient)
196+
}
197+
198+
restClient.UserAgent = userAgent
59199
restClient.BaseURL = apiHost.baseRESTURL
60200
restClient.UploadURL = apiHost.uploadURL
61201

62202
// Construct our GraphQL client
63-
// We're using NewEnterpriseClient here unconditionally as opposed to NewClient because we already
64-
// did the necessary API host parsing so that github.com will return the correct URL anyway.
65-
gqlHTTPClient := &http.Client{
66-
Transport: &bearerAuthTransport{
67-
transport: http.DefaultTransport,
68-
token: cfg.Token,
69-
},
70-
} // We're going to wrap the Transport later in beforeInit
203+
gqlHTTPClient := &http.Client{Transport: httpClient.Transport}
71204
gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient)
72205

73206
// When a client send an initialize request, update the user agent to include the client info.
74207
beforeInit := func(_ context.Context, _ any, message *mcp.InitializeRequest) {
75-
userAgent := fmt.Sprintf(
76-
"github-mcp-server/%s (%s/%s)",
77-
cfg.Version,
78-
message.Params.ClientInfo.Name,
79-
message.Params.ClientInfo.Version,
80-
)
208+
var userAgent string
209+
if authMethod == authGitHubApp {
210+
userAgent = fmt.Sprintf(
211+
"github-mcp-server/%s (%s/%s) (GitHub App)",
212+
cfg.Version,
213+
message.Params.ClientInfo.Name,
214+
message.Params.ClientInfo.Version,
215+
)
216+
} else {
217+
userAgent = fmt.Sprintf(
218+
"github-mcp-server/%s (%s/%s)",
219+
cfg.Version,
220+
message.Params.ClientInfo.Name,
221+
message.Params.ClientInfo.Version,
222+
)
223+
}
81224

82225
restClient.UserAgent = userAgent
83226

@@ -146,8 +289,8 @@ type StdioServerConfig struct {
146289
// GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
147290
Host string
148291

149-
// GitHub Token to authenticate with the GitHub API
150-
Token string
292+
// Authentication configuration
293+
Auth AuthConfig
151294

152295
// EnabledToolsets is a list of toolsets to enable
153296
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
@@ -182,7 +325,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
182325
ghServer, err := NewMCPServer(MCPServerConfig{
183326
Version: cfg.Version,
184327
Host: cfg.Host,
185-
Token: cfg.Token,
328+
Auth: cfg.Auth,
186329
EnabledToolsets: cfg.EnabledToolsets,
187330
DynamicToolsets: cfg.DynamicToolsets,
188331
ReadOnly: cfg.ReadOnly,

0 commit comments

Comments
 (0)