Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit e05a384

Browse filesBrowse 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 d3cd405 commit e05a384
Copy full SHA for e05a384

8 files changed

+995-24Lines changed: 995 additions & 24 deletions

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎README.md‎

Copy file name to clipboardExpand all lines: README.md
+2Lines changed: 2 additions & 0 deletions
  • Display the source diff
  • Display the rich diff
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
Collapse file

‎cmd/github-mcp-server/main.go‎

Copy file name to clipboardExpand all lines: cmd/github-mcp-server/main.go
+104-5Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.