Skip to content

Commit ea4e396

Browse files
refactor(auth): isolate GitHub App auth to stdio startup
Keep PEM loading and installation-token provider construction at the CLI leaf, then pass a generic refreshing token provider through the existing HTTP transports. Rebase the feature onto current main and keep the HTTP command unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 646357dd-c89f-4973-9a5c-e6c5fc18818c
1 parent e05a384 commit ea4e396

11 files changed

Lines changed: 183 additions & 441 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ Add one of the following JSON blocks to your IDE's MCP settings.
311311

312312
See **[Local Server OAuth Login](docs/oauth-login.md)** for the native-binary flow (no fixed port needed), the headless/device-code fallback, GitHub Enterprise Server / `ghe.com`, and bringing your own OAuth or GitHub App.
313313

314-
**Running headless (CI, Kubernetes, background agents)?** The stdio server can authenticate as a **GitHub App installation** with no browser, device code, or elicitation — see **[GitHub App Server-to-Server Authentication](docs/github-app-auth.md)**. This injects a high-privilege credential alongside the agent, so read the security guidance there first; it is not recommended without an independent security review.
314+
For non-interactive stdio deployments, see **[GitHub App Authentication](docs/github-app-auth.md)**.
315315

316316
**Or authenticate with a Personal Access Token.** Set `GITHUB_PERSONAL_ACCESS_TOKEN` instead (it takes precedence over OAuth):
317317

cmd/github-mcp-server/main.go

Lines changed: 12 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,6 @@ var (
4040
Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`,
4141
RunE: func(_ *cobra.Command, _ []string) error {
4242
token := viper.GetString("personal_access_token")
43-
44-
// GitHub App server-to-server auth (non-interactive). It is detected
45-
// when any app-* setting is present; a partial configuration yields a
46-
// clear error from the loader/validator below rather than silently
47-
// falling back to another mode.
4843
appID := viper.GetString("app-id")
4944
appInstallationID := viper.GetString("app-installation-id")
5045
appPrivateKeyPath := viper.GetString("app-private-key-path")
@@ -59,14 +54,13 @@ var (
5954
// --oauth-client-id. Recognizing the host via NormalizeHost means an explicit
6055
// GITHUB_HOST=github.com (or api.github.com) still counts as the default and keeps
6156
// zero-config login working. The secret tracks the id, so an explicitly provided
62-
// id with no secret never picks up the baked-in secret. App auth opts out of this
63-
// default so configuring an app never accidentally enables OAuth login too.
57+
// id with no secret never picks up the baked-in secret.
6458
if oauthClientID == "" && !appAuthRequested && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" {
6559
oauthClientID = buildinfo.OAuthClientID
6660
oauthClientSecret = buildinfo.OAuthClientSecret
6761
}
6862
if token == "" && !appAuthRequested && oauthClientID == "" {
69-
return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, configure GitHub App auth (GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID and GITHUB_APP_PRIVATE_KEY_PATH), or pass --oauth-client-id to log in via OAuth")
63+
return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, configure GitHub App auth, or pass --oauth-client-id to log in via OAuth")
7064
}
7165
if appAuthRequested && token != "" {
7266
return errors.New("GitHub App authentication and GITHUB_PERSONAL_ACCESS_TOKEN are mutually exclusive: set only one")
@@ -137,7 +131,6 @@ var (
137131
// client. The requested scopes default to the full supported set
138132
// (which filters out no tools); an explicit, narrower --oauth-scopes
139133
// both narrows the grant and hides tools needing other scopes.
140-
// Skipped for GitHub App auth, which sources tokens non-interactively.
141134
if token == "" && !appAuthRequested {
142135
scopes := ghoauth.SupportedScopes
143136
if viper.IsSet("oauth-scopes") {
@@ -156,15 +149,12 @@ var (
156149
stdioServerConfig.OAuthScopes = scopes
157150
}
158151

159-
// GitHub App server-to-server auth: load and parse the private key,
160-
// then resolve the REST base URL so the server can mint installation
161-
// tokens for the configured host (github.com, GHES, or ghe.com).
162152
if appAuthRequested {
163-
appConfig, err := buildAppAuthConfig(appID, appInstallationID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host"))
153+
tokenProvider, err := newGitHubAppTokenProvider(appID, appInstallationID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host"))
164154
if err != nil {
165155
return err
166156
}
167-
stdioServerConfig.AppAuth = appConfig
157+
stdioServerConfig.TokenProvider = tokenProvider
168158
}
169159

170160
return ghmcp.RunStdioServer(stdioServerConfig)
@@ -263,11 +253,7 @@ func init() {
263253
stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Comma-separated OAuth scopes to request; also filters tools to those scopes. Defaults to the full supported set")
264254
stdioCmd.Flags().Int("oauth-callback-port", 0, "Fixed local port for the OAuth callback server. Defaults to a random port; set a fixed port when mapping it through Docker")
265255

266-
// stdio-specific GitHub App (server-to-server) flags. Provide an app ID,
267-
// installation ID, and private key to authenticate non-interactively — no
268-
// browser, device code, or elicitation. Intended for headless deployments.
269-
// The private key itself has no flag (only GITHUB_APP_PRIVATE_KEY): a flag
270-
// would place the key in the process arguments. Prefer the key file path.
256+
// The private key has no flag because passing it in argv would expose it.
271257
stdioCmd.Flags().String("app-id", "", "GitHub App ID or client ID, enabling non-interactive server-to-server authentication")
272258
stdioCmd.Flags().String("app-installation-id", "", "GitHub App installation ID to mint installation access tokens for")
273259
stdioCmd.Flags().String("app-private-key-path", "", "Path to the GitHub App private key (PEM). Preferred over GITHUB_APP_PRIVATE_KEY: keeps the key off the command line and out of the environment")
@@ -326,19 +312,11 @@ func main() {
326312
}
327313
}
328314

329-
// buildAppAuthConfig assembles the GitHub App server-to-server configuration:
330-
// it loads and parses the private key and resolves the REST base URL for the
331-
// configured host. The private key is read from a file (preferred) or an inline
332-
// environment value; a missing or partial configuration yields a clear error.
333-
func buildAppAuthConfig(appID, installationID, keyPath, keyInline, host string) (*githubapp.Config, error) {
315+
func newGitHubAppTokenProvider(appID, installationID, keyPath, keyInline, host string) (func() string, error) {
334316
keyBytes, err := loadAppPrivateKey(keyPath, keyInline)
335317
if err != nil {
336318
return nil, err
337319
}
338-
privateKey, err := githubapp.ParsePrivateKey(keyBytes)
339-
if err != nil {
340-
return nil, fmt.Errorf("invalid GitHub App private key: %w", err)
341-
}
342320

343321
apiHost, err := utils.NewAPIHost(host)
344322
if err != nil {
@@ -349,22 +327,18 @@ func buildAppAuthConfig(appID, installationID, keyPath, keyInline, host string)
349327
return nil, fmt.Errorf("failed to resolve REST URL for GitHub App authentication: %w", err)
350328
}
351329

352-
cfg := &githubapp.Config{
330+
provider, err := githubapp.NewProvider(githubapp.Config{
353331
AppID: appID,
354332
InstallationID: installationID,
355-
PrivateKey: privateKey,
333+
PrivateKeyPEM: keyBytes,
356334
BaseRESTURL: restURL.String(),
335+
}, nil)
336+
if err != nil {
337+
return nil, fmt.Errorf("failed to configure GitHub App authentication: %w", err)
357338
}
358-
if err := cfg.Validate(); err != nil {
359-
return nil, err
360-
}
361-
return cfg, nil
339+
return provider.AccessToken, nil
362340
}
363341

364-
// loadAppPrivateKey returns the GitHub App private key bytes from a file path
365-
// (preferred — it keeps the key off argv and out of the environment) or from an
366-
// inline value. The inline form tolerates literal "\n" escapes so a PEM survives
367-
// being carried in a single-line environment variable.
368342
func loadAppPrivateKey(path, inline string) ([]byte, error) {
369343
switch {
370344
case path != "":

cmd/github-mcp-server/main_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestLoadAppPrivateKey(t *testing.T) {
13+
t.Run("file", func(t *testing.T) {
14+
path := filepath.Join(t.TempDir(), "app.pem")
15+
require.NoError(t, os.WriteFile(path, []byte("from-file"), 0o600))
16+
17+
key, err := loadAppPrivateKey(path, "from-inline")
18+
require.NoError(t, err)
19+
assert.Equal(t, []byte("from-file"), key)
20+
})
21+
22+
t.Run("inline", func(t *testing.T) {
23+
key, err := loadAppPrivateKey("", `first\nsecond`)
24+
require.NoError(t, err)
25+
assert.Equal(t, []byte("first\nsecond"), key)
26+
})
27+
28+
t.Run("missing", func(t *testing.T) {
29+
_, err := loadAppPrivateKey("", "")
30+
require.Error(t, err)
31+
assert.Contains(t, err.Error(), "private key")
32+
})
33+
}
34+
35+
func TestGitHubAppFlagsAreStdioOnly(t *testing.T) {
36+
assert.NotNil(t, stdioCmd.Flags().Lookup("app-id"))
37+
assert.Nil(t, httpCmd.Flags().Lookup("app-id"))
38+
}

0 commit comments

Comments
 (0)