diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 4f452aac41..ac667558f2 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -117,6 +117,9 @@ jobs: platforms: linux/amd64,linux/arm64 build-args: | VERSION=${{ github.ref_name }} + secrets: | + oauth_client_id=${{ secrets.OAUTH_CLIENT_ID }} + oauth_client_secret=${{ secrets.OAUTH_CLIENT_SECRET }} # Sign the resulting Docker image digest except on PRs. # This will only write to the public Rekor transparency log when the Docker diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 1004fc2747..32a6bffad9 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -38,6 +38,8 @@ jobs: workdir: . env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }} + OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }} - name: Generate signed build provenance attestations for workflow artifacts uses: actions/attest-build-provenance@v4 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 54f6b9f409..36dfc47bce 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -9,7 +9,7 @@ builds: - env: - CGO_ENABLED=0 ldflags: - - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID={{ .Env.OAUTH_CLIENT_ID }} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret={{ .Env.OAUTH_CLIENT_SECRET }} goos: - linux - windows diff --git a/Dockerfile b/Dockerfile index a4ea1d03b8..4138e6bcf6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,9 +24,14 @@ COPY . . COPY --from=ui-build /app/pkg/github/ui_dist/* ./pkg/github/ui_dist/ # Build the server +# OAuth credentials are injected via build secrets so they are not baked into image history; the values are public in practice but kept out of layers. RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --mount=type=secret,id=oauth_client_id \ + --mount=type=secret,id=oauth_client_secret \ + export OAUTH_CLIENT_ID="$(cat /run/secrets/oauth_client_id 2>/dev/null || echo '')" && \ + export OAUTH_CLIENT_SECRET="$(cat /run/secrets/oauth_client_secret 2>/dev/null || echo '')" && \ + CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ) -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=${OAUTH_CLIENT_ID} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret=${OAUTH_CLIENT_SECRET}" \ -o /bin/github-mcp-server ./cmd/github-mcp-server # Make a stage to run the app diff --git a/README.md b/README.md index 5d6caae6d3..0e44934ca0 100644 --- a/README.md +++ b/README.md @@ -176,14 +176,15 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to ## Local GitHub MCP Server -[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D&quality=insiders) [![Install with Docker in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22github%22%2C%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%7D) +[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-p%22%2C%22127.0.0.1%3A8085%3A8085%22%2C%22-e%22%2C%22GITHUB_OAUTH_CALLBACK_PORT%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_OAUTH_CALLBACK_PORT%22%3A%228085%22%7D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-p%22%2C%22127.0.0.1%3A8085%3A8085%22%2C%22-e%22%2C%22GITHUB_OAUTH_CALLBACK_PORT%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_OAUTH_CALLBACK_PORT%22%3A%228085%22%7D%7D&quality=insiders) [![Install with Docker in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22github%22%2C%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-p%22%2C%22127.0.0.1%3A8085%3A8085%22%2C%22-e%22%2C%22GITHUB_OAUTH_CALLBACK_PORT%3D8085%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%7D) ### Prerequisites 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. Lastly you will need to [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). -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)). +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)).
Handling PATs Securely @@ -281,6 +282,8 @@ Install in GitHub Copilot on other IDEs (JetBrains, Visual Studio, Eclipse, etc. Add the following JSON block to your IDE's MCP settings. +> The examples below authenticate with a Personal Access Token. To log in with OAuth instead (no token to create or store), see **[Local Server OAuth Login](docs/oauth-login.md)** — in Docker it needs a fixed callback port, as the one-click buttons above show. + ```json { "mcp": { diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 604556692c..231b0cf2c3 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -7,9 +7,12 @@ import ( "strings" "time" + "github.com/github/github-mcp-server/internal/buildinfo" "github.com/github/github-mcp-server/internal/ghmcp" + "github.com/github/github-mcp-server/internal/oauth" "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/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -34,8 +37,21 @@ var ( Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`, RunE: func(_ *cobra.Command, _ []string) error { token := viper.GetString("personal_access_token") - if token == "" { - return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set") + oauthClientID := viper.GetString("oauth-client-id") + oauthClientSecret := viper.GetString("oauth-client-secret") + // 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 + // --oauth-client-id. Recognizing the host via NormalizeHost means an explicit + // GITHUB_HOST=github.com (or api.github.com) still counts as the default and keeps + // zero-config login working. The secret tracks the id, so an explicitly provided + // id with no secret never picks up the baked-in secret. + if oauthClientID == "" && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" { + 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 you're wondering why we're not using viper.GetStringSlice("toolsets"), @@ -95,6 +111,29 @@ var ( ExcludeTools: excludeTools, RepoAccessCacheTTL: &ttl, } + + // When no static token is provided, 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 == "" { + scopes := ghoauth.SupportedScopes + if viper.IsSet("oauth-scopes") { + if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil { + return fmt.Errorf("failed to unmarshal oauth-scopes: %w", err) + } + } + oauthConfig := oauth.NewGitHubConfig( + oauthClientID, + oauthClientSecret, + scopes, + viper.GetString("host"), + viper.GetInt("oauth-callback-port"), + ) + stdioServerConfig.OAuthManager = oauth.NewManager(oauthConfig, nil) + stdioServerConfig.OAuthScopes = scopes + } + return ghmcp.RunStdioServer(stdioServerConfig) }, } @@ -183,6 +222,14 @@ 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)") + // 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. + stdioCmd.Flags().String("oauth-client-id", "", "OAuth App or GitHub App client ID, enabling interactive OAuth login when no token is set") + stdioCmd.Flags().String("oauth-client-secret", "", "OAuth client secret, if the app requires one (it is a public, non-confidential credential for distributed clients)") + stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Comma-separated OAuth scopes to request; also filters tools to those scopes. Defaults to the full supported set") + 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") + // HTTP-specific flags httpCmd.Flags().Int("port", 8082, "HTTP server port") httpCmd.Flags().String("listen-host", "", "Host the HTTP server binds to (e.g. 127.0.0.1). Empty binds to all interfaces.") @@ -205,6 +252,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("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")) + _ = viper.BindPFlag("oauth-callback-port", stdioCmd.Flags().Lookup("oauth-callback-port")) _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) _ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host")) _ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url")) diff --git a/docs/oauth-login.md b/docs/oauth-login.md new file mode 100644 index 0000000000..35989be7b6 --- /dev/null +++ b/docs/oauth-login.md @@ -0,0 +1,263 @@ +# Local Server OAuth Login (stdio) + +The local (stdio) GitHub MCP Server can log you in with OAuth instead of a +Personal Access Token (PAT). On first use it walks you through GitHub's +authorization flow in your browser and keeps the resulting token **in memory +only** — nothing is written to disk. + +Official released binaries and the `ghcr.io/github/github-mcp-server` image ship +with a registered GitHub OAuth application baked in, so on **github.com** you can +start the server with no token and no client ID at all. To target a different +host (GitHub Enterprise Server or `ghe.com`), or to use your own application, +pass `--oauth-client-id` (see [Bring your own app](#bring-your-own-app)). + +> OAuth login applies to the **stdio** server only. The remote server and the +> `http` command have their own authentication; see +> [Remote Server](remote-server.md). + +## Contents + +- [How it works](#how-it-works) +- [Quick start](#quick-start) +- [Configuration reference](#configuration-reference) +- [Scope filtering](#scope-filtering) +- [Running in Docker](#running-in-docker) +- [Headless and device-code fallback](#headless-and-device-code-fallback) +- [URL elicitation and the security advisory](#url-elicitation-and-the-security-advisory) +- [Bring your own app](#bring-your-own-app) +- [GitHub Enterprise Server and ghe.com](#github-enterprise-server-and-ghecom) +- [Building from source with baked-in credentials](#building-from-source-with-baked-in-credentials) + +## How it works + +The server prefers the **authorization code flow with PKCE**: it starts a +loopback callback server on your machine, opens GitHub's authorization page, and +exchanges the returned code for a token. PKCE means the client secret is not +required to complete the exchange, which is why a public, distributed client can +ship without a confidential secret. + +To present the authorization URL, the server uses the most secure channel your +MCP client offers, in order: + +1. **Open your browser automatically** (native runs). +2. **URL elicitation** — the client prompts you with the link out of band, so the + URL never enters the model's context. Requires a client that supports MCP + elicitation (e.g. VS Code 1.101+). +3. **A message in the first tool response** — a last resort for clients without + elicitation. This includes a [security advisory](#url-elicitation-and-the-security-advisory). + +If the authorization-code flow can't be used — for example, a container with no +published callback port — the server falls back to the +[device-code flow](#headless-and-device-code-fallback). + +GitHub App tokens that expire are refreshed transparently using the refresh +token, so long-running sessions keep working without re-authorizing. + +## Quick start + +**Native binary (recommended).** Best experience: a random loopback port is +used and your browser opens automatically. On github.com with an official build, +no flags are needed: + +```bash +github-mcp-server stdio +``` + +With your own application: + +```bash +github-mcp-server stdio --oauth-client-id +``` + +VS Code (`.vscode/mcp.json`), using your own app: + +```json +{ + "servers": { + "github": { + "command": "/path/to/github-mcp-server", + "args": ["stdio", "--oauth-client-id", ""] + } + } +} +``` + +For Docker, see [Running in Docker](#running-in-docker) — containers need a fixed +callback port. + +## Configuration reference + +OAuth login is configured with these stdio flags (each has an environment +variable equivalent). Flags apply only to the `stdio` command. + +| Flag | Environment variable | Description | +|------|----------------------|-------------| +| `--oauth-client-id` | `GITHUB_OAUTH_CLIENT_ID` | OAuth App or GitHub App client ID. Enables OAuth login when no token is set. Defaults to the baked-in app on github.com for official builds. | +| `--oauth-client-secret` | `GITHUB_OAUTH_CLIENT_SECRET` | Client secret, **if your app requires one**. For distributed clients this is a public, non-confidential credential. | +| `--oauth-scopes` | `GITHUB_OAUTH_SCOPES` | Comma-separated scopes to request. Also [filters tools](#scope-filtering) to those scopes. Defaults to the full supported set. | +| `--oauth-callback-port` | `GITHUB_OAUTH_CALLBACK_PORT` | Fixed local port for the callback server. Defaults to a random port; set a fixed port when mapping it through Docker. | + +A static token still takes precedence: if `GITHUB_PERSONAL_ACCESS_TOKEN` is set, +the server uses it and skips OAuth entirely. + +## Scope filtering + +The scopes you request determine which tools are exposed. Requesting the full +supported set (the default) hides no tools. Narrowing `--oauth-scopes` both +narrows the token's grant **and** filters out tools that would need a scope you +didn't request, so the tool list reflects what the token can actually do. + +For example, requesting only `repo,read:org` hides tools that require `gist`, +`workflow`, `notifications`, and so on. + +## Running in Docker + +A container can't reach a random loopback port on your host, so Docker OAuth +needs a **fixed** callback port that you publish into the container. Use port +**8085** to match the official app's registered callback URL. + +```bash +docker run -i --rm \ + -p 127.0.0.1:8085:8085 \ + -e GITHUB_OAUTH_CALLBACK_PORT=8085 \ + ghcr.io/github/github-mcp-server +``` + +VS Code (`.vscode/mcp.json`): + +```json +{ + "servers": { + "github": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-p", "127.0.0.1:8085:8085", + "-e", "GITHUB_OAUTH_CALLBACK_PORT", + "ghcr.io/github/github-mcp-server" + ], + "env": { "GITHUB_OAUTH_CALLBACK_PORT": "8085" } + } + } +} +``` + +Because the container can't open your host browser, the authorization URL +arrives via [URL elicitation](#url-elicitation-and-the-security-advisory) or the +tool-response message. After you authorize, your browser hits +`localhost:8085`, which Docker forwards into the container's callback. + +If you bring your own app for Docker, register its callback URL as exactly +`http://localhost:8085/callback`. + +> **Two safety properties to be aware of with a fixed port:** +> +> - **Publish to loopback only** (`-p 127.0.0.1:8085:8085`, not `-p 8085:8085`). +> Inside a container the callback necessarily listens on all interfaces, so a +> plain publish would expose the authorization code to your network. The +> server logs a warning reminding you of this when it binds inside a container. +> - **A busy port is fatal, by design.** With a fixed port, if the server can't +> bind it (another process already holds it), it **stops with an error** rather +> than silently falling back to the device flow. A port you didn't get could +> belong to another user's process positioned to receive the redirect, so the +> server refuses to continue. Free the port or choose a different +> `--oauth-callback-port`. + +## Headless and device-code fallback + +When there's no usable browser or callback — a remote shell, CI, or a container +started without a published port — the server uses GitHub's **device-code +flow**. You'll get a short code and a verification URL to open on any device: + +``` +Visit https://github.com/login/device and enter the code WDJB-MJHT to authorize +the GitHub MCP Server. +``` + +The server polls GitHub until you finish authorizing, then continues. No +callback port is involved, so this works anywhere. + +## URL elicitation and the security advisory + +URL elicitation lets your MCP client present the authorization URL to you +directly, keeping it **out of the model's context** — the model never sees the +link or any code embedded in it. This is the most secure way to hand off the +authorization step. + +If your client doesn't support elicitation, the server falls back to placing the +URL in a tool response and appends a short advisory: + +> Note: your MCP client does not appear to support secure URL elicitation. For +> improved security, consider asking your agent, CLI, or IDE to add it (for +> example, by opening an issue). + +If you see this, your authorization still works — but consider asking your client +vendor to add elicitation support. + +## Bring your own app + +You need your own application when targeting a non-github.com host, or when you'd +rather not use the baked-in app. Either application type works: + +- **[Create an OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)** — + simplest to set up. Grants the scopes you request. +- **[Register a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app)** — + finer-grained, per-resource permissions and short-lived tokens that refresh + automatically. Enable **Device Flow** in the app settings if you want the + [headless fallback](#headless-and-device-code-fallback). + +When registering, set the authorization callback URL: + +- **Native runs** use a random loopback port. For loopback redirects GitHub does + not require the callback port to match, so registering + `http://localhost/callback` is sufficient. +- **Docker / fixed port** must match exactly: register + `http://localhost:8085/callback` (or whichever port you publish). + +Then pass the client ID (and secret, only if your app requires one): + +```bash +github-mcp-server stdio \ + --oauth-client-id \ + --oauth-client-secret +``` + +## GitHub Enterprise Server and ghe.com + +The baked-in app is registered on github.com only, so it is **not** used when you +set a custom host. GitHub Enterprise Server and `ghe.com` (Enterprise Cloud with +data residency) users must **bring their own app** registered on that host and +pass `--oauth-client-id`. + +Set the host with `--gh-host` / `GITHUB_HOST`; the server derives the OAuth +authorization, token, and device endpoints from it, so login is directed at your +instance's authorization server rather than github.com: + +```bash +github-mcp-server stdio \ + --gh-host https://github.example.com \ + --oauth-client-id +``` + +- For GitHub Enterprise Server, prefix the host with `https://`. +- For `ghe.com`, use `https://YOURSUBDOMAIN.ghe.com`. + +Register the app's callback URL on the same host (e.g. +`http://localhost/callback` for native runs, or `http://localhost:8085/callback` +for Docker). + +## Building from source with baked-in credentials + +Official builds embed the default OAuth client via linker flags at build time, so +they are not present in the source tree. To produce your own build with embedded +credentials, set them with `-ldflags`: + +```bash +go build -ldflags "\ + -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID= \ + -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret=" \ + ./cmd/github-mcp-server +``` + +Without these, a source build simply has no baked-in app and expects +`--oauth-client-id` (or a PAT) at runtime. diff --git a/go.mod b/go.mod index 080cdcfd8e..66c7a974ad 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/yosida95/uritemplate/v3 v3.0.2 + golang.org/x/oauth2 v0.35.0 ) require ( @@ -40,7 +41,6 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.38.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.28.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000000..cd5084fa29 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,19 @@ +// Package buildinfo contains variables that are set at build time via ldflags. +// These allow official releases to ship default OAuth credentials so users can +// log in without configuring their own OAuth app. The values are public in +// practice (security relies on PKCE, not on the client secret), but are kept out +// of source and injected at build time. +// +// Example: +// +// go build -ldflags="-X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=xxx" +package buildinfo + +// OAuthClientID is the default OAuth client ID, set at build time. Empty in +// local/dev builds. +var OAuthClientID string + +// OAuthClientSecret is the default OAuth client secret, set at build time. For +// public OAuth clients it is not truly secret per OAuth 2.1 — PKCE provides the +// security — but it is still injected at build time rather than committed. +var OAuthClientSecret string diff --git a/internal/ghmcp/oauth.go b/internal/ghmcp/oauth.go new file mode 100644 index 0000000000..abc6d3d11c --- /dev/null +++ b/internal/ghmcp/oauth.go @@ -0,0 +1,133 @@ +package ghmcp + +import ( + "context" + "crypto/rand" + "fmt" + "log/slog" + + "github.com/github/github-mcp-server/internal/oauth" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// sessionPrompter adapts an MCP server session to oauth.Prompter, presenting +// authorization prompts to the user via elicitation. Keeping the prompt on the +// MCP control channel (rather than a tool result) keeps the authorization URL +// and any session-bound state out of the model's context. +type sessionPrompter struct { + session *mcp.ServerSession +} + +// elicitationCaps returns the client's declared elicitation capabilities, or nil +// if the client did not advertise any. +func (p *sessionPrompter) elicitationCaps() *mcp.ElicitationCapabilities { + params := p.session.InitializeParams() + if params == nil || params.Capabilities == nil { + return nil + } + return params.Capabilities.Elicitation +} + +// CanPromptURL reports whether the client supports URL-mode elicitation. +func (p *sessionPrompter) CanPromptURL() bool { + caps := p.elicitationCaps() + return caps != nil && caps.URL != nil +} + +// PromptURL presents the authorization URL via URL-mode elicitation and blocks +// until the user acknowledges, declines, or ctx is done. +func (p *sessionPrompter) PromptURL(ctx context.Context, prompt oauth.Prompt) error { + res, err := p.session.Elicit(ctx, &mcp.ElicitParams{ + Mode: "url", + Message: prompt.Message, + URL: prompt.URL, + ElicitationID: rand.Text(), + }) + if err != nil { + // The client advertised URL elicitation but the request itself failed: + // classify it as undeliverable (not a user decision) so the flow can fall + // back to a channel that needs no client capability. + return fmt.Errorf("%w: %w", oauth.ErrPromptUnavailable, err) + } + if res.Action != "accept" { + return oauth.ErrPromptDeclined + } + return nil +} + +// CanPromptForm reports whether the client supports form-mode elicitation. The +// SDK treats a client that advertises neither form nor URL capabilities as +// supporting forms, for backward compatibility, so we mirror that here. +func (p *sessionPrompter) CanPromptForm() bool { + caps := p.elicitationCaps() + if caps == nil { + return false + } + return caps.Form != nil || caps.URL == nil +} + +// PromptForm presents a textual acknowledgement (used to display a device code +// when URL elicitation is unavailable) and blocks until the user responds. +func (p *sessionPrompter) PromptForm(ctx context.Context, prompt oauth.Prompt) error { + res, err := p.session.Elicit(ctx, &mcp.ElicitParams{ + Mode: "form", + Message: prompt.Message, + }) + if err != nil { + // As with PromptURL, a delivery failure is undeliverable rather than a + // decline, so the flow can fall back instead of aborting. + return fmt.Errorf("%w: %w", oauth.ErrPromptUnavailable, err) + } + if res.Action != "accept" { + return oauth.ErrPromptDeclined + } + return nil +} + +// oauthAuthenticator is the subset of *oauth.Manager that the middleware needs. +// Depending on the interface (rather than the concrete manager) lets the +// middleware be exercised with a deterministic fake, since driving the real +// manager to its branches would require standing up live GitHub flows. +type oauthAuthenticator interface { + HasToken() bool + Authenticate(ctx context.Context, prompter oauth.Prompter) (*oauth.Outcome, error) +} + +// createOAuthMiddleware returns receiving middleware that authorizes the session +// lazily, on the first tool call. Authorization is deferred until here (rather +// than at startup) because the prompts depend on an initialized session whose +// elicitation capabilities are known. +// +// When a token is already available the call proceeds untouched. Otherwise the +// flow runs: secure channels (browser, URL elicitation) block until the token +// arrives and then the call proceeds; the last-resort channel returns the +// instruction to the user as a tool result and asks them to retry. +func createOAuthMiddleware(mgr oauthAuthenticator, logger *slog.Logger) func(next mcp.MethodHandler) mcp.MethodHandler { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + if method != "tools/call" || mgr.HasToken() { + return next(ctx, method, request) + } + + callReq, ok := request.(*mcp.CallToolRequest) + if !ok { + return next(ctx, method, request) + } + + outcome, err := mgr.Authenticate(ctx, &sessionPrompter{session: callReq.Session}) + if err != nil { + return nil, fmt.Errorf("github authorization failed: %w", err) + } + if outcome != nil && outcome.UserAction != nil { + logger.Info("surfacing github authorization instructions to user") + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: outcome.UserAction.Message}}, + }, nil + } + return next(ctx, method, request) + } + } +} + +// ensure sessionPrompter satisfies the Prompter contract. +var _ oauth.Prompter = (*sessionPrompter)(nil) diff --git a/internal/ghmcp/oauth_test.go b/internal/ghmcp/oauth_test.go new file mode 100644 index 0000000000..732d080e40 --- /dev/null +++ b/internal/ghmcp/oauth_test.go @@ -0,0 +1,391 @@ +package ghmcp + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/github/github-mcp-server/internal/oauth" + "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// probeToolName is the name of the throwaway tool the harness registers; its +// handler runs a probe closure against a sessionPrompter so the adapter can be +// exercised against a real, fully-negotiated server session from the client side. +const probeToolName = "probe" + +// runProbe stands up an in-memory MCP client/server pair, registers a tool whose +// handler runs probe against a sessionPrompter wrapping the live server session, +// and returns the text the probe produced. The client is configured with the +// given capabilities and elicitation handler so the adapter sees a real, +// fully-negotiated session rather than a hand-built fake. +func runProbe( + t *testing.T, + clientCaps *mcp.ClientCapabilities, + elicitationHandler func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error), + probe func(context.Context, *sessionPrompter) string, +) string { + t.Helper() + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + mcp.AddTool(server, &mcp.Tool{Name: probeToolName}, func(ctx context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + text := probe(ctx, &sessionPrompter{session: req.Session}) + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}}, nil, nil + }) + + st, ct := mcp.NewInMemoryTransports() + + ss, err := server.Connect(context.Background(), st, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = ss.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, &mcp.ClientOptions{ + Capabilities: clientCaps, + ElicitationHandler: elicitationHandler, + }) + cs, err := client.Connect(context.Background(), ct, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = cs.Close() }) + + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: probeToolName}) + require.NoError(t, err) + require.Len(t, res.Content, 1) + text, ok := res.Content[0].(*mcp.TextContent) + require.True(t, ok, "probe result should be text content") + return text.Text +} + +func TestSessionPrompterCapabilities(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + caps *mcp.ClientCapabilities + wantURL bool + wantForm bool + }{ + { + name: "no elicitation advertised", + caps: &mcp.ClientCapabilities{}, + wantURL: false, + wantForm: false, + }, + { + name: "url only", + caps: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}}, + wantURL: true, + wantForm: false, + }, + { + name: "form only", + caps: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{Form: &mcp.FormElicitationCapabilities{}}}, + wantURL: false, + wantForm: true, + }, + { + name: "url and form", + caps: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}, Form: &mcp.FormElicitationCapabilities{}}}, + wantURL: true, + wantForm: true, + }, + { + name: "empty elicitation capability implies form for backward compatibility", + caps: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{}}, + wantURL: false, + wantForm: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := runProbe(t, tc.caps, nil, func(_ context.Context, p *sessionPrompter) string { + if p.CanPromptURL() { + if p.CanPromptForm() { + return "url+form" + } + return "url" + } + if p.CanPromptForm() { + return "form" + } + return "none" + }) + + want := "none" + switch { + case tc.wantURL && tc.wantForm: + want = "url+form" + case tc.wantURL: + want = "url" + case tc.wantForm: + want = "form" + } + assert.Equal(t, want, got) + }) + } +} + +func TestSessionPrompterPromptActions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + action string + wantDecline bool + }{ + {name: "accept", action: "accept", wantDecline: false}, + {name: "decline", action: "decline", wantDecline: true}, + {name: "cancel", action: "cancel", wantDecline: true}, + } + + caps := &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{ + URL: &mcp.URLElicitationCapabilities{}, + Form: &mcp.FormElicitationCapabilities{}, + }} + + for _, tc := range tests { + // URL and form modes share the accept/decline mapping; cover both. + for _, mode := range []string{"url", "form"} { + t.Run(tc.name+"/"+mode, func(t *testing.T) { + t.Parallel() + + handler := func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: tc.action}, nil + } + + got := runProbe(t, caps, handler, func(ctx context.Context, p *sessionPrompter) string { + var err error + if mode == "url" { + err = p.PromptURL(ctx, oauth.Prompt{Message: "msg", URL: "https://example.com/auth"}) + } else { + err = p.PromptForm(ctx, oauth.Prompt{Message: "msg"}) + } + if err == nil { + return "ok" + } + if err == oauth.ErrPromptDeclined { + return "declined" + } + return "error: " + err.Error() + }) + + if tc.wantDecline { + assert.Equal(t, "declined", got) + } else { + assert.Equal(t, "ok", got) + } + }) + } + } +} + +// TestSessionPrompterTransportError verifies that a prompt which fails to be +// delivered (the client errors instead of returning an action) is reported as +// ErrPromptUnavailable, not ErrPromptDeclined. The manager relies on this +// distinction to fall back to manual instructions instead of aborting. +func TestSessionPrompterTransportError(t *testing.T) { + t.Parallel() + + caps := &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{ + URL: &mcp.URLElicitationCapabilities{}, + Form: &mcp.FormElicitationCapabilities{}, + }} + + for _, mode := range []string{"url", "form"} { + t.Run(mode, func(t *testing.T) { + t.Parallel() + + handler := func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return nil, errors.New("client cannot deliver elicitation") + } + + got := runProbe(t, caps, handler, func(ctx context.Context, p *sessionPrompter) string { + var err error + if mode == "url" { + err = p.PromptURL(ctx, oauth.Prompt{Message: "msg", URL: "https://example.com/auth"}) + } else { + err = p.PromptForm(ctx, oauth.Prompt{Message: "msg"}) + } + switch { + case err == nil: + return "ok" + case errors.Is(err, oauth.ErrPromptDeclined): + return "declined" + case errors.Is(err, oauth.ErrPromptUnavailable): + return "unavailable" + default: + return "error: " + err.Error() + } + }) + + assert.Equal(t, "unavailable", got, + "a delivery failure must be classified as undeliverable, not a decline") + }) + } +} + +// fakeAuthenticator is a deterministic stand-in for *oauth.Manager that lets the +// middleware be tested at each branch without standing up live GitHub flows. +type fakeAuthenticator struct { + hasToken bool + outcome *oauth.Outcome + err error + authCalls int + lastPrompter oauth.Prompter +} + +func (f *fakeAuthenticator) HasToken() bool { return f.hasToken } + +func (f *fakeAuthenticator) Authenticate(_ context.Context, prompter oauth.Prompter) (*oauth.Outcome, error) { + f.authCalls++ + f.lastPrompter = prompter + return f.outcome, f.err +} + +func TestCreateOAuthMiddleware(t *testing.T) { + t.Parallel() + + const nextText = "handler-ran" + newNext := func(called *bool) mcp.MethodHandler { + return func(_ context.Context, _ string, _ mcp.Request) (mcp.Result, error) { + *called = true + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: nextText}}}, nil + } + } + + t.Run("non tool call passes through without authenticating", func(t *testing.T) { + t.Parallel() + fake := &fakeAuthenticator{hasToken: false} + var called bool + mw := createOAuthMiddleware(fake, discardLogger()) + _, err := mw(newNext(&called))(context.Background(), "initialize", &mcp.InitializeRequest{}) + require.NoError(t, err) + assert.True(t, called, "next should run") + assert.Zero(t, fake.authCalls, "authentication must not run for non tool calls") + }) + + t.Run("existing token short circuits authentication", func(t *testing.T) { + t.Parallel() + fake := &fakeAuthenticator{hasToken: true} + var called bool + mw := createOAuthMiddleware(fake, discardLogger()) + _, err := mw(newNext(&called))(context.Background(), "tools/call", &mcp.CallToolRequest{}) + require.NoError(t, err) + assert.True(t, called, "next should run") + assert.Zero(t, fake.authCalls, "authentication must be skipped when a token already exists") + }) + + t.Run("successful authentication proceeds to handler", func(t *testing.T) { + t.Parallel() + fake := &fakeAuthenticator{hasToken: false, outcome: nil, err: nil} + var called bool + mw := createOAuthMiddleware(fake, discardLogger()) + res, err := mw(newNext(&called))(context.Background(), "tools/call", &mcp.CallToolRequest{}) + require.NoError(t, err) + assert.Equal(t, 1, fake.authCalls) + assert.True(t, called, "next should run once authorized") + callRes, ok := res.(*mcp.CallToolResult) + require.True(t, ok) + require.Len(t, callRes.Content, 1) + assert.Equal(t, nextText, callRes.Content[0].(*mcp.TextContent).Text) + }) + + t.Run("pending user action is surfaced as a tool result", func(t *testing.T) { + t.Parallel() + const message = "Open https://example.com/auth to authorize, then retry." + fake := &fakeAuthenticator{hasToken: false, outcome: &oauth.Outcome{UserAction: &oauth.UserAction{Message: message}}} + var called bool + mw := createOAuthMiddleware(fake, discardLogger()) + res, err := mw(newNext(&called))(context.Background(), "tools/call", &mcp.CallToolRequest{}) + require.NoError(t, err) + assert.False(t, called, "next must not run while the user still needs to authorize") + callRes, ok := res.(*mcp.CallToolResult) + require.True(t, ok) + require.Len(t, callRes.Content, 1) + assert.Equal(t, message, callRes.Content[0].(*mcp.TextContent).Text) + }) + + t.Run("authentication error is returned", func(t *testing.T) { + t.Parallel() + fake := &fakeAuthenticator{hasToken: false, err: assert.AnError} + var called bool + mw := createOAuthMiddleware(fake, discardLogger()) + _, err := mw(newNext(&called))(context.Background(), "tools/call", &mcp.CallToolRequest{}) + require.Error(t, err) + assert.ErrorIs(t, err, assert.AnError) + assert.False(t, called, "next must not run when authentication fails") + }) +} + +// TestRunStdioServerRejectsTokenAndOAuth verifies the mutually-exclusive guard: +// supplying both a static token and an OAuth manager is rejected before the +// server starts, rather than silently preferring one for auth and the other for +// scope filtering. +func TestRunStdioServerRejectsTokenAndOAuth(t *testing.T) { + t.Parallel() + + mgr := oauth.NewManager(oauth.NewGitHubConfig("client-id", "", nil, "", 0), discardLogger()) + err := RunStdioServer(StdioServerConfig{ + Token: "ghp_static", + OAuthManager: mgr, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") +} + +// TestCreateGitHubClientsTokenProvider proves the OAuth wiring: when a +// TokenProvider is configured the REST client authenticates with the provider's +// current token on every request (and never pins a stale one), which is what the +// lazy, refreshing OAuth token depends on. +func TestCreateGitHubClientsTokenProvider(t *testing.T) { + t.Parallel() + + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get(headers.AuthorizationHeader) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + current := "" + apiHost, err := utils.NewAPIHost(server.URL) + require.NoError(t, err) + + clients, err := createGitHubClients(github.MCPServerConfig{ + Version: "test", + TokenProvider: func() string { return current }, + }, apiHost) + require.NoError(t, err) + + do := func() { + resp, err := clients.rest.Client().Get(server.URL) + require.NoError(t, err) + defer resp.Body.Close() + } + + do() + assert.Equal(t, "", gotAuth, "no auth header before authorization") + + current = "oauth-token" + do() + assert.Equal(t, "Bearer oauth-token", gotAuth, "provider token used once available") + + current = "refreshed-token" + do() + assert.Equal(t, "Bearer refreshed-token", gotAuth, "refreshed provider token used") +} diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index a37c4d940d..1bf84453c8 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -12,6 +12,7 @@ import ( "syscall" "time" + "github.com/github/github-mcp-server/internal/oauth" "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/http/transport" @@ -61,16 +62,30 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv return nil, fmt.Errorf("failed to get Raw URL: %w", err) } - // Construct REST client + // Construct REST client. When a TokenProvider is configured (OAuth), we + // authenticate via BearerAuthTransport and skip go-github's WithAuthToken: + // the latter installs its own round tripper that would pin the static token + // and shadow the dynamic one. restUATransport := &transport.UserAgentTransport{ Transport: http.DefaultTransport, Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version), } - restClient, err := gogithub.NewClient( - gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}), - gogithub.WithAuthToken(cfg.Token), - gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), - ) + var restClient *gogithub.Client + if cfg.TokenProvider != nil { + restClient, err = gogithub.NewClient( + gogithub.WithHTTPClient(&http.Client{Transport: &transport.BearerAuthTransport{ + Transport: restUATransport, + TokenProvider: cfg.TokenProvider, + }}), + gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), + ) + } else { + restClient, err = gogithub.NewClient( + gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}), + gogithub.WithAuthToken(cfg.Token), + gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), + ) + } if err != nil { return nil, fmt.Errorf("failed to create REST client: %w", err) } @@ -82,7 +97,8 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv Transport: &transport.GraphQLFeaturesTransport{ Transport: http.DefaultTransport, }, - Token: cfg.Token, + Token: cfg.Token, + TokenProvider: cfg.TokenProvider, }, } @@ -229,10 +245,29 @@ type StdioServerConfig struct { // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. RepoAccessCacheTTL *time.Duration + + // OAuthManager, when non-nil, enables OAuth 2.1 login for stdio mode. The + // server starts without a token and runs the authorization flow on the + // first tool call (see createOAuthMiddleware). It is mutually exclusive with + // a static Token. + OAuthManager *oauth.Manager + + // OAuthScopes are the scopes requested during OAuth login. They double as + // the scope set for tool filtering: tools requiring a scope outside this set + // are hidden. The default set is the full supported list, which hides + // nothing; an explicit, narrower list filters accordingly. + OAuthScopes []string } // RunStdioServer is not concurrent safe. func RunStdioServer(cfg StdioServerConfig) error { + // OAuth login and a static token are mutually exclusive: they would + // disagree on how the token is sourced (lazy provider vs. static) and on + // scope filtering, so reject the ambiguous combination up front. + if cfg.OAuthManager != nil && cfg.Token != "" { + return fmt.Errorf("OAuthManager and a static Token are mutually exclusive: provide one or the other") + } + // Create app context ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -255,11 +290,13 @@ func RunStdioServer(cfg StdioServerConfig) error { logger := slog.New(slogHandler) logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) - // Fetch token scopes for scope-based tool filtering (PAT tokens only) - // Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header. - // Fine-grained PATs and other token types don't support this, so we skip filtering. + // Determine the scope set used to filter tools. Classic PATs expose their + // granted scopes via the API; OAuth uses the requested scopes (the default + // set hides nothing, a narrower explicit set filters accordingly). Other + // token types don't advertise scopes, so filtering is skipped. var tokenScopes []string - if strings.HasPrefix(cfg.Token, "ghp_") { + switch { + case strings.HasPrefix(cfg.Token, "ghp_"): fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) if err != nil { logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) @@ -267,10 +304,20 @@ func RunStdioServer(cfg StdioServerConfig) error { tokenScopes = fetchedScopes logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) } - } else { + case cfg.OAuthManager != nil: + tokenScopes = cfg.OAuthScopes + logger.Info("using requested OAuth scopes for tool filtering", "scopes", tokenScopes) + default: logger.Debug("skipping scope filtering for non-PAT token") } + // For OAuth, the token is resolved lazily: empty until the user authorizes + // on the first tool call, then refreshed for the rest of the session. + var tokenProvider func() string + if cfg.OAuthManager != nil { + tokenProvider = cfg.OAuthManager.AccessToken + } + ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{ Version: cfg.Version, Host: cfg.Host, @@ -287,11 +334,18 @@ func RunStdioServer(cfg StdioServerConfig) error { Logger: logger, RepoAccessTTL: cfg.RepoAccessCacheTTL, TokenScopes: tokenScopes, + TokenProvider: tokenProvider, }) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) } + // With OAuth, intercept tool calls to run the authorization flow on first + // use, before the handler tries to call GitHub with an empty token. + if cfg.OAuthManager != nil { + ghServer.AddReceivingMiddleware(createOAuthMiddleware(cfg.OAuthManager, logger)) + } + if cfg.ExportTranslations { // Once server is initialized, all translations are loaded dumpTranslations() diff --git a/internal/oauth/callback.go b/internal/oauth/callback.go new file mode 100644 index 0000000000..1e643e207d --- /dev/null +++ b/internal/oauth/callback.go @@ -0,0 +1,157 @@ +package oauth + +import ( + "context" + "embed" + "fmt" + "html/template" + "net" + "net/http" + "time" +) + +//go:embed templates/*.html +var templateFS embed.FS + +var ( + errorTemplate = template.Must(template.ParseFS(templateFS, "templates/error.html")) + successTemplate = template.Must(template.ParseFS(templateFS, "templates/success.html")) +) + +// callbackResult is delivered by the callback server once the browser redirect +// arrives. Exactly one of code or err is set. +type callbackResult struct { + code string + err error +} + +// callbackServer is a short-lived local HTTP server that captures the +// authorization code from the OAuth redirect. +type callbackServer struct { + server *http.Server + listener net.Listener + redirect string + results chan callbackResult +} + +// listenCallback binds the local callback listener. +// +// It binds to loopback (127.0.0.1) by default so the callback server is never +// exposed on other interfaces. bindAll is set only inside a container, where +// Docker's published-port DNAT delivers traffic to the container's eth0 rather +// than to loopback; host-side exposure is still constrained by the publish +// (e.g. -p 127.0.0.1:8085:8085). A native run — even with a fixed port — stays +// on loopback. +func listenCallback(port int, bindAll bool) (net.Listener, error) { + host := "127.0.0.1" + if bindAll { + host = "0.0.0.0" + } + addr := fmt.Sprintf("%s:%d", host, port) + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("starting callback listener on %s: %w", addr, err) + } + return listener, nil +} + +// newCallbackServer starts a callback server on listener that validates state +// and reports the result on a buffered channel. The redirect URI always uses +// localhost so it matches the value registered on the OAuth/GitHub App. +func newCallbackServer(listener net.Listener, expectedState string) *callbackServer { + cs := &callbackServer{ + server: &http.Server{ReadHeaderTimeout: 10 * time.Second}, // ReadHeaderTimeout guards against Slowloris. + listener: listener, + redirect: fmt.Sprintf("http://localhost:%d/callback", listener.Addr().(*net.TCPAddr).Port), + results: make(chan callbackResult, 1), + } + cs.server.Handler = cs.handler(expectedState) + + go func() { + if err := cs.server.Serve(listener); err != nil && err != http.ErrServerClosed { + cs.report(callbackResult{err: fmt.Errorf("callback server: %w", err)}) + } + }() + + return cs +} + +// handler renders the callback endpoint. It reports the outcome exactly once and +// always shows the user a friendly page. +func (cs *callbackServer) handler(expectedState string) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + if errCode := q.Get("error"); errCode != "" { + msg := errCode + if desc := q.Get("error_description"); desc != "" { + msg = fmt.Sprintf("%s: %s", errCode, desc) + } + cs.report(callbackResult{err: fmt.Errorf("authorization failed: %s", msg)}) + renderError(w, msg) + return + } + + if q.Get("state") != expectedState { + cs.report(callbackResult{err: fmt.Errorf("state mismatch (possible CSRF)")}) + renderError(w, "state mismatch") + return + } + + code := q.Get("code") + if code == "" { + cs.report(callbackResult{err: fmt.Errorf("no authorization code in callback")}) + renderError(w, "no authorization code received") + return + } + + cs.report(callbackResult{code: code}) + renderSuccess(w) + }) + return mux +} + +// report delivers the first outcome and drops later ones (the channel is +// buffered for one; subsequent redirect retries must not block the handler). +func (cs *callbackServer) report(res callbackResult) { + select { + case cs.results <- res: + default: + } +} + +// wait blocks for the callback outcome or ctx cancellation, then shuts the +// server down. It is safe to call once per server. +func (cs *callbackServer) wait(ctx context.Context) (string, error) { + defer cs.close() + select { + case res := <-cs.results: + return res.code, res.err + case <-ctx.Done(): + return "", ctx.Err() + } +} + +func (cs *callbackServer) close() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = cs.server.Shutdown(shutdownCtx) + _ = cs.listener.Close() +} + +func renderSuccess(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := successTemplate.Execute(w, nil); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + } +} + +// renderError shows the failure page. html/template auto-escapes msg, so a +// hostile error_description cannot inject markup. +func renderError(w http.ResponseWriter, msg string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := errorTemplate.Execute(w, struct{ ErrorMessage string }{ErrorMessage: msg}); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + } +} diff --git a/internal/oauth/callback_test.go b/internal/oauth/callback_test.go new file mode 100644 index 0000000000..45a8fa71c4 --- /dev/null +++ b/internal/oauth/callback_test.go @@ -0,0 +1,92 @@ +package oauth + +import ( + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// serveCallback drives the callback handler with the given query string and +// returns the recorded response and the single reported result. +func serveCallback(t *testing.T, expectedState, query string) (*httptest.ResponseRecorder, callbackResult) { + t.Helper() + cs := &callbackServer{results: make(chan callbackResult, 1)} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/callback?"+query, nil) + + cs.handler(expectedState).ServeHTTP(rec, req) + + select { + case res := <-cs.results: + return rec, res + default: + t.Fatal("handler did not report a result") + return nil, callbackResult{} + } +} + +func TestCallbackHandlerSuccess(t *testing.T) { + rec, res := serveCallback(t, "state123", "code=the-code&state=state123") + + require.NoError(t, res.err) + assert.Equal(t, "the-code", res.code) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Authorization Successful") +} + +func TestCallbackHandlerStateMismatch(t *testing.T) { + rec, res := serveCallback(t, "expected", "code=the-code&state=attacker") + + require.Error(t, res.err) + assert.Empty(t, res.code) + assert.Contains(t, res.err.Error(), "state mismatch") + assert.Contains(t, rec.Body.String(), "state mismatch") +} + +func TestCallbackHandlerMissingCode(t *testing.T) { + _, res := serveCallback(t, "state123", "state=state123") + + require.Error(t, res.err) + assert.Contains(t, res.err.Error(), "no authorization code") +} + +func TestCallbackHandlerOAuthError(t *testing.T) { + _, res := serveCallback(t, "state123", "error=access_denied&error_description=user+said+no") + + require.Error(t, res.err) + assert.Contains(t, res.err.Error(), "access_denied") + assert.Contains(t, res.err.Error(), "user said no") +} + +func TestCallbackHandlerEscapesError(t *testing.T) { + rec, _ := serveCallback(t, "state123", "error=evil&error_description=%3Cscript%3Ealert(1)%3C%2Fscript%3E") + + body := rec.Body.String() + assert.NotContains(t, body, "