Skip to content

Commit f2bcfcf

Browse files
feat(auth): add GitHub App server-to-server authentication for stdio
Add non-interactive GitHub App installation authentication to the stdio server, so headless deployments (CI, Kubernetes, background agents) can authenticate without a browser, device code, or elicitation. This is the outstanding follow-up tracked in #1333: OAuth login shipped the interactive user-to-server flows, but PEM-based server-to-server auth was still needed to remove the interactive requirement. The new internal/githubapp package signs a short-lived RS256 JWT with the app's private key, exchanges it for an installation access token, and refreshes it transparently before expiry. It exposes a Provider whose AccessToken method mirrors oauth.Manager so it plugs into the existing BearerAuthTransport token provider. Only the standard library and golang.org/x/oauth2 are used. The private key is injected safely: a file path (GITHUB_APP_PRIVATE_KEY_PATH, preferred — mountable as a secret and kept off argv and out of the environment) or an inline GITHUB_APP_PRIVATE_KEY env var. There is intentionally no flag for the key contents, which would otherwise leak via the process command line. App auth is mutually exclusive with a PAT and with OAuth login. A loud startup warning and a dedicated docs page (docs/github-app-auth.md, with Docker and Kubernetes examples) cover the security considerations: this injects a high-privilege credential alongside the agent and is not recommended without an independent security review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8cd03c0 commit f2bcfcf

8 files changed

Lines changed: 995 additions & 24 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,8 @@ 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.
315+
314316
**Or authenticate with a Personal Access Token.** Set `GITHUB_PERSONAL_ACCESS_TOKEN` instead (it takes precedence over OAuth):
315317

316318
```json

cmd/github-mcp-server/main.go

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

33
import (
4+
"context"
45
"errors"
56
"fmt"
67
"os"
@@ -9,10 +10,12 @@ import (
910

1011
"github.com/github/github-mcp-server/internal/buildinfo"
1112
"github.com/github/github-mcp-server/internal/ghmcp"
13+
"github.com/github/github-mcp-server/internal/githubapp"
1214
"github.com/github/github-mcp-server/internal/oauth"
1315
"github.com/github/github-mcp-server/pkg/github"
1416
ghhttp "github.com/github/github-mcp-server/pkg/http"
1517
ghoauth "github.com/github/github-mcp-server/pkg/http/oauth"
18+
"github.com/github/github-mcp-server/pkg/utils"
1619
"github.com/spf13/cobra"
1720
"github.com/spf13/pflag"
1821
"github.com/spf13/viper"
@@ -37,6 +40,17 @@ var (
3740
Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`,
3841
RunE: func(_ *cobra.Command, _ []string) error {
3942
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.
48+
appID := viper.GetString("app-id")
49+
appInstallationID := viper.GetString("app-installation-id")
50+
appPrivateKeyPath := viper.GetString("app-private-key-path")
51+
appPrivateKeyInline := viper.GetString("app-private-key")
52+
appAuthRequested := appID != "" || appInstallationID != "" || appPrivateKeyPath != "" || appPrivateKeyInline != ""
53+
4054
oauthClientID := viper.GetString("oauth-client-id")
4155
oauthClientSecret := viper.GetString("oauth-client-secret")
4256
// Fall back to the build-time baked-in client (official releases) when none is
@@ -45,13 +59,20 @@ var (
4559
// --oauth-client-id. Recognizing the host via NormalizeHost means an explicit
4660
// GITHUB_HOST=github.com (or api.github.com) still counts as the default and keeps
4761
// zero-config login working. The secret tracks the id, so an explicitly provided
48-
// id with no secret never picks up the baked-in secret.
49-
if oauthClientID == "" && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" {
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.
64+
if oauthClientID == "" && !appAuthRequested && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" {
5065
oauthClientID = buildinfo.OAuthClientID
5166
oauthClientSecret = buildinfo.OAuthClientSecret
5267
}
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")
68+
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")
70+
}
71+
if appAuthRequested && token != "" {
72+
return errors.New("GitHub App authentication and GITHUB_PERSONAL_ACCESS_TOKEN are mutually exclusive: set only one")
73+
}
74+
if appAuthRequested && oauthClientID != "" {
75+
return errors.New("GitHub App authentication and OAuth login (--oauth-client-id) are mutually exclusive: set only one")
5576
}
5677

5778
// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
@@ -116,7 +137,8 @@ var (
116137
// client. The requested scopes default to the full supported set
117138
// (which filters out no tools); an explicit, narrower --oauth-scopes
118139
// both narrows the grant and hides tools needing other scopes.
119-
if token == "" {
140+
// Skipped for GitHub App auth, which sources tokens non-interactively.
141+
if token == "" && !appAuthRequested {
120142
scopes := ghoauth.SupportedScopes
121143
if viper.IsSet("oauth-scopes") {
122144
if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil {
@@ -134,6 +156,17 @@ var (
134156
stdioServerConfig.OAuthScopes = scopes
135157
}
136158

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).
162+
if appAuthRequested {
163+
appConfig, err := buildAppAuthConfig(appID, appInstallationID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host"))
164+
if err != nil {
165+
return err
166+
}
167+
stdioServerConfig.AppAuth = appConfig
168+
}
169+
137170
return ghmcp.RunStdioServer(stdioServerConfig)
138171
},
139172
}
@@ -230,6 +263,15 @@ func init() {
230263
stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Comma-separated OAuth scopes to request; also filters tools to those scopes. Defaults to the full supported set")
231264
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")
232265

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.
271+
stdioCmd.Flags().String("app-id", "", "GitHub App ID or client ID, enabling non-interactive server-to-server authentication")
272+
stdioCmd.Flags().String("app-installation-id", "", "GitHub App installation ID to mint installation access tokens for")
273+
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")
274+
233275
// HTTP-specific flags
234276
httpCmd.Flags().Int("port", 8082, "HTTP server port")
235277
httpCmd.Flags().String("listen-host", "", "Host the HTTP server binds to (e.g. 127.0.0.1). Empty binds to all interfaces.")
@@ -256,6 +298,9 @@ func init() {
256298
_ = viper.BindPFlag("oauth-client-secret", stdioCmd.Flags().Lookup("oauth-client-secret"))
257299
_ = viper.BindPFlag("oauth-scopes", stdioCmd.Flags().Lookup("oauth-scopes"))
258300
_ = viper.BindPFlag("oauth-callback-port", stdioCmd.Flags().Lookup("oauth-callback-port"))
301+
_ = viper.BindPFlag("app-id", stdioCmd.Flags().Lookup("app-id"))
302+
_ = viper.BindPFlag("app-installation-id", stdioCmd.Flags().Lookup("app-installation-id"))
303+
_ = viper.BindPFlag("app-private-key-path", stdioCmd.Flags().Lookup("app-private-key-path"))
259304
_ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port"))
260305
_ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host"))
261306
_ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url"))
@@ -281,6 +326,60 @@ func main() {
281326
}
282327
}
283328

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) {
334+
keyBytes, err := loadAppPrivateKey(keyPath, keyInline)
335+
if err != nil {
336+
return nil, err
337+
}
338+
privateKey, err := githubapp.ParsePrivateKey(keyBytes)
339+
if err != nil {
340+
return nil, fmt.Errorf("invalid GitHub App private key: %w", err)
341+
}
342+
343+
apiHost, err := utils.NewAPIHost(host)
344+
if err != nil {
345+
return nil, fmt.Errorf("failed to parse host for GitHub App authentication: %w", err)
346+
}
347+
restURL, err := apiHost.BaseRESTURL(context.Background())
348+
if err != nil {
349+
return nil, fmt.Errorf("failed to resolve REST URL for GitHub App authentication: %w", err)
350+
}
351+
352+
cfg := &githubapp.Config{
353+
AppID: appID,
354+
InstallationID: installationID,
355+
PrivateKey: privateKey,
356+
BaseRESTURL: restURL.String(),
357+
}
358+
if err := cfg.Validate(); err != nil {
359+
return nil, err
360+
}
361+
return cfg, nil
362+
}
363+
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.
368+
func loadAppPrivateKey(path, inline string) ([]byte, error) {
369+
switch {
370+
case path != "":
371+
data, err := os.ReadFile(path) //#nosec G304 -- operator-supplied path to their own key
372+
if err != nil {
373+
return nil, fmt.Errorf("reading GitHub App private key file: %w", err)
374+
}
375+
return data, nil
376+
case inline != "":
377+
return []byte(strings.ReplaceAll(inline, `\n`, "\n")), nil
378+
default:
379+
return nil, errors.New("GitHub App authentication requires a private key: set GITHUB_APP_PRIVATE_KEY_PATH (preferred) or GITHUB_APP_PRIVATE_KEY")
380+
}
381+
}
382+
284383
func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
285384
from := []string{"_"}
286385
to := "-"

0 commit comments

Comments
 (0)