diff --git a/README.md b/README.md index ce6eb81cb..0d4b2ca39 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Built for developers who want to connect their AI tools to GitHub context and ca The remote GitHub MCP Server is hosted by GitHub and provides the easiest method for getting up and running. If your MCP host does not support remote MCP servers, don't worry! You can use the [local version of the GitHub MCP Server](https://github.com/github/github-mcp-server?tab=readme-ov-file#local-github-mcp-server) instead. +> **Note:** The remote server does not support OAuth device flow authentication. For OAuth authentication, use the [local GitHub MCP Server](#local-github-mcp-server) with the [OAuth authentication guide](/docs/oauth-authentication.md). + ### Prerequisites 1. A compatible MCP host with remote server support (VS Code 1.101+, Claude Desktop, Cursor, Windsurf, etc.) @@ -130,10 +132,57 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to ### 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 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)). +**For OAuth Device Flow Authentication (Recommended):** +1. Docker installed and running (or build from source) +2. A web browser to complete authentication +3. Network access to GitHub.com (or your GitHub Enterprise instance) + +**For Personal Access Token (PAT) Authentication:** +1. Docker installed and running (or build from source) +2. [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate permissions + +> **💡 Tip**: New users should try [OAuth device flow authentication](/docs/oauth-authentication.md) first - it requires no pre-configuration! Simply start the server without a token and authenticate through your browser. See the [authentication guide](/docs/oauth-authentication.md) for detailed instructions. + +### Authentication Methods + +The local GitHub MCP Server supports two authentication methods: + +#### 1. OAuth Device Flow (Recommended for Interactive Use) + +No pre-configuration needed! Start the server without a token: + +```json +{ + "github": { + "command": "docker", + "args": ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server", "stdio"] + } +} +``` + +The server will guide you through browser-based authentication when you first use it. [Learn more in the OAuth authentication guide](/docs/oauth-authentication.md). + +#### 2. Personal Access Token (For Automation & Offline Use) + +Create a [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) and configure it: + +```json +{ + "github": { + "command": "docker", + "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_your_token_here" + } + } +} +``` + +See the [OAuth vs PAT comparison](/docs/oauth-authentication.md#comparison-with-pat-authentication) to choose the best method for your use case. + +### Personal Access Token Configuration + +If you choose to use a Personal Access Token, 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 diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index cfb68be4e..3f6a6935e 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -1,7 +1,6 @@ package main import ( - "errors" "fmt" "os" "strings" @@ -32,10 +31,8 @@ var ( Short: "Start stdio server", Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`, RunE: func(_ *cobra.Command, _ []string) error { + // Token is optional - if not provided, server starts in auth mode token := viper.GetString("personal_access_token") - if token == "" { - return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set") - } // If you're wondering why we're not using viper.GetStringSlice("toolsets"), // it's because viper doesn't handle comma-separated values correctly for env @@ -68,6 +65,14 @@ var ( } } + // Parse OAuth scopes (similar to toolsets) + var oauthScopes []string + if viper.IsSet("oauth-scopes") { + if err := viper.UnmarshalKey("oauth-scopes", &oauthScopes); err != nil { + return fmt.Errorf("failed to unmarshal oauth-scopes: %w", err) + } + } + ttl := viper.GetDuration("repo-access-cache-ttl") stdioServerConfig := ghmcp.StdioServerConfig{ Version: version, @@ -84,6 +89,9 @@ var ( ContentWindowSize: viper.GetInt("content-window-size"), LockdownMode: viper.GetBool("lockdown-mode"), RepoAccessCacheTTL: &ttl, + OAuthClientID: viper.GetString("oauth-client-id"), + OAuthClientSecret: viper.GetString("oauth-client-secret"), + OAuthScopes: oauthScopes, } return ghmcp.RunStdioServer(stdioServerConfig) }, @@ -109,6 +117,9 @@ func init() { rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size") rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") + rootCmd.PersistentFlags().String("oauth-client-id", "", "OAuth App client ID for device flow authentication (optional, uses default if not provided)") + rootCmd.PersistentFlags().String("oauth-client-secret", "", "OAuth App client secret for device flow authentication (optional, for confidential clients)") + rootCmd.PersistentFlags().StringSlice("oauth-scopes", nil, "Comma-separated list of OAuth scopes to request during device flow authentication (optional, uses default if not provided)") // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) @@ -123,6 +134,9 @@ func init() { _ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size")) _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) + _ = viper.BindPFlag("oauth-client-id", rootCmd.PersistentFlags().Lookup("oauth-client-id")) + _ = viper.BindPFlag("oauth-client-secret", rootCmd.PersistentFlags().Lookup("oauth-client-secret")) + _ = viper.BindPFlag("oauth-scopes", rootCmd.PersistentFlags().Lookup("oauth-scopes")) // Add subcommands rootCmd.AddCommand(stdioCmd) diff --git a/docs/oauth-authentication.md b/docs/oauth-authentication.md new file mode 100644 index 000000000..ade5580b9 --- /dev/null +++ b/docs/oauth-authentication.md @@ -0,0 +1,570 @@ +# OAuth Device Flow Authentication + +The GitHub MCP Server supports OAuth device flow authentication as an alternative to Personal Access Tokens (PATs). This provides a streamlined authentication experience where users can authenticate directly through the MCP server without pre-configuring tokens. + +## Table of Contents + +- [Overview](#overview) +- [How It Works](#how-it-works) +- [Getting Started](#getting-started) +- [Configuration](#configuration) + - [Using Default OAuth App](#using-default-oauth-app) + - [Using Custom OAuth Apps](#using-custom-oauth-apps) + - [Using GitHub Apps](#using-github-apps) + - [GitHub Enterprise Server (GHES)](#github-enterprise-server-ghes) + - [GitHub Enterprise Cloud (GHEC)](#github-enterprise-cloud-ghec) +- [CLI Flags](#cli-flags) +- [Environment Variables](#environment-variables) +- [Scopes and Permissions](#scopes-and-permissions) +- [Security Considerations](#security-considerations) +- [Troubleshooting](#troubleshooting) +- [Comparison with PAT Authentication](#comparison-with-pat-authentication) + +## Overview + +OAuth device flow authentication allows users to authenticate with GitHub by: +1. Starting the authentication process through the MCP server +2. Receiving a user code and verification URL +3. Visiting the URL in a browser and entering the code +4. Automatically completing authentication once approved + +This method eliminates the need to manually create and configure Personal Access Tokens, making it easier for users to get started with the GitHub MCP Server. + +The device flow authentication works with both **OAuth Apps** and **GitHub Apps**. OAuth Apps use scope-based permissions that can be customized via the `--oauth-scopes` flag, while GitHub Apps use fine-grained permissions that are controlled by the app's configuration in GitHub settings. + +## How It Works + +The OAuth device flow follows these steps: + +1. **User requests authentication**: When the server starts without a token, only the `auth_login` tool is available. The user (or their AI agent) calls this tool to initiate authentication. + +2. **Server requests device code**: The server requests a device code from GitHub's OAuth device flow endpoint (`https://github.com/login/device/code` or your enterprise equivalent). + +3. **User receives verification URL**: The server displays a verification URL and user code to the user via MCP's URL elicitation feature (if supported by the client). + +4. **User authorizes in browser**: The user opens the verification URL in their browser, enters the code, and authorizes the application. + +5. **Server polls for token**: While the user is authorizing, the server polls GitHub's token endpoint (`https://github.com/login/oauth/access_token`) until the user completes authorization or the request expires. + +6. **Authentication completes**: Once the user authorizes the app, the server receives an access token and automatically registers all GitHub tools. + +The entire process is handled by a single `auth_login` tool call that blocks until authentication completes or fails. + +## Getting Started + +### Quick Start (No Configuration Needed) + +The simplest way to use OAuth authentication is to start the server without providing a token: + +**Docker:** +```json +{ + "github": { + "command": "docker", + "args": ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server", "stdio"] + } +} +``` + +**Binary:** +```json +{ + "github": { + "command": "/path/to/github-mcp-server", + "args": ["stdio"] + } +} +``` + +When the server starts without a `GITHUB_PERSONAL_ACCESS_TOKEN`, it will automatically enter authentication mode. Your AI agent can then call the `auth_login` tool to initiate the OAuth flow. + +### Backward Compatibility + +OAuth authentication is completely optional. If you provide a `GITHUB_PERSONAL_ACCESS_TOKEN`, the server will use it and skip OAuth authentication entirely. This means existing configurations continue to work without modification. + +```json +{ + "github": { + "command": "docker", + "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_your_token_here" + } + } +} +``` + +## Configuration + +### Using Default OAuth App + +The GitHub MCP Server includes a default OAuth App registered and managed by GitHub. This OAuth App works with: +- GitHub.com (https://github.com) +- GitHub Enterprise Cloud with data residency (ghe.com) +- GitHub Enterprise Server (GHES) 3.0+ + +**No additional configuration is required** to use the default OAuth App. Simply omit the PAT token when starting the server. + +The default OAuth App client ID is embedded in the source code (which is safe per OAuth 2.0 specifications for public clients) and requires no client secret. + +### Using Custom OAuth Apps + +For enterprise scenarios, you may want to use your own OAuth App. This is useful when: +- You want to customize the app name and branding +- You need to restrict access to specific organizations +- You want to use a confidential client (with client secret) +- Your organization's policies require using organization-owned OAuth Apps + +#### Creating a Custom OAuth App + +1. **Navigate to OAuth App settings:** + - For personal apps: https://github.com/settings/developers + - For organization apps: https://github.com/organizations/YOUR_ORG/settings/applications + +2. **Create a new OAuth App:** + - Click "New OAuth App" + - **Application name**: "GitHub MCP Server (Custom)" + - **Homepage URL**: https://github.com/github/github-mcp-server (or your fork) + - **Authorization callback URL**: Not used for device flow, but required. Use: `http://localhost:8080/callback` + - **Enable Device Flow**: Make sure this is checked + +3. **Configure the scopes**: The OAuth App will request the scopes defined in the server (see [Scopes and Permissions](#scopes-and-permissions)). + +4. **Get your credentials:** + - **Client ID**: Copy the client ID (e.g., `Ov23liAbcdefg1234567`) + - **Client Secret** (optional): Generate a client secret if you want to use a confidential client + +#### Using Your Custom OAuth App + +Provide the client ID (and optionally client secret) using CLI flags or environment variables: + +**CLI Flags:** +```json +{ + "github": { + "command": "/path/to/github-mcp-server", + "args": [ + "stdio", + "--oauth-client-id", "Ov23liYourClientID", + "--oauth-client-secret", "your_client_secret_if_needed" + ] + } +} +``` + +**Environment Variables:** +```json +{ + "github": { + "command": "docker", + "args": ["run", "-i", "--rm", "-e", "GITHUB_OAUTH_CLIENT_ID", "-e", "GITHUB_OAUTH_CLIENT_SECRET", "ghcr.io/github/github-mcp-server"], + "env": { + "GITHUB_OAUTH_CLIENT_ID": "Ov23liYourClientID", + "GITHUB_OAUTH_CLIENT_SECRET": "your_client_secret_if_needed" + } + } +} +``` + +### Using GitHub Apps + +The GitHub MCP Server also supports authentication via GitHub Apps using the device flow. GitHub Apps provide more granular permissions and better security controls compared to OAuth Apps. + +**Key Differences:** + +- **Permissions Model**: GitHub Apps use fine-grained permissions instead of OAuth scopes. The `--oauth-scopes` flag does not apply when using GitHub Apps, as permissions are controlled by the GitHub App's configuration in GitHub's settings. +- **App-Controlled Access**: When authenticating via a GitHub App, the available repositories, organizations, and resources are determined by the app's installation and permissions, not by the scopes requested during authentication. +- **Installation-Based**: GitHub Apps must be installed on organizations or repositories before users can authenticate through them. + +#### Creating a GitHub App + +1. **Navigate to GitHub App settings:** + - For personal apps: https://github.com/settings/apps + - For organization apps: https://github.com/organizations/YOUR_ORG/settings/apps + +2. **Create a new GitHub App:** + - Click "New GitHub App" + - **GitHub App name**: "GitHub MCP Server (Custom)" + - **Homepage URL**: https://github.com/github/github-mcp-server + - **Callback URL**: Not used for device flow, but required. Use: `http://localhost:8080/callback` + - **Request user authorization (OAuth) during installation**: Uncheck this + - **Enable Device Flow**: Make sure this is checked + - **Webhook**: Can be set to inactive if not needed + +3. **Configure permissions**: Set the repository and organization permissions based on your needs (equivalent to the scopes in OAuth Apps). + +4. **Get your credentials:** + - **Client ID**: Copy the client ID from the app settings + - **Client Secret**: Generate a client secret if needed + +5. **Install the GitHub App**: Install the app on the organizations or repositories where you want to use it. + +#### Using Your GitHub App + +Use the same `--oauth-client-id` and `--oauth-client-secret` flags with your GitHub App's credentials: + +```bash +github-mcp-server stdio --oauth-client-id Iv1.your_github_app_client_id +``` + +**Note**: When using GitHub Apps, the `--oauth-scopes` flag is ignored. Access and permissions are controlled by the GitHub App's configuration and installation settings. + +### GitHub Enterprise Server (GHES) + +To use OAuth device flow with GitHub Enterprise Server: + +1. **Ensure GHES supports device flow**: Device flow is available in GHES 3.0 and later. + +2. **Create an OAuth App** on your GHES instance: + - Navigate to: `https://YOUR_GHES_HOSTNAME/settings/developers` + - Follow the steps in [Creating a Custom OAuth App](#creating-a-custom-oauth-app) + +3. **Configure the server** with your GHES hostname: + +```json +{ + "github": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "GITHUB_HOST", + "-e", "GITHUB_OAUTH_CLIENT_ID", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_HOST": "https://github.yourcompany.com", + "GITHUB_OAUTH_CLIENT_ID": "your_ghes_oauth_client_id" + } + } +} +``` + +**Important**: Always prefix GHES hostnames with `https://` to ensure proper URL construction. + +#### Example: Full GHES Configuration + +```json +{ + "github": { + "command": "/path/to/github-mcp-server", + "args": ["stdio", "--gh-host", "https://github.yourcompany.com", "--oauth-client-id", "Ov23liGHESClientID"] + } +} +``` + +### GitHub Enterprise Cloud (GHEC) + +GitHub Enterprise Cloud with data residency (ghe.com) works with the default OAuth App. However, you may want to create a custom OAuth App for organization-specific branding or policies. + +1. **Create an OAuth App** on your GHEC organization: + - Navigate to: `https://YOUR_SUBDOMAIN.ghe.com/settings/developers` or your organization settings + - Follow the steps in [Creating a Custom OAuth App](#creating-a-custom-oauth-app) + +2. **Configure the server** with your GHEC hostname: + +```json +{ + "github": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "GITHUB_HOST", + "-e", "GITHUB_OAUTH_CLIENT_ID", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_HOST": "https://yourcompany.ghe.com", + "GITHUB_OAUTH_CLIENT_ID": "your_ghec_oauth_client_id" + } + } +} +``` + +## CLI Flags + +The GitHub MCP Server provides the following CLI flags for OAuth configuration: + +| Flag | Description | Default | Example | +|------|-------------|---------|---------| +| `--oauth-client-id` | OAuth App client ID for device flow authentication | Default GitHub MCP OAuth App | `--oauth-client-id Ov23liYourClientID` | +| `--oauth-client-secret` | OAuth App client secret (optional, for confidential clients) | None | `--oauth-client-secret your_client_secret` | +| `--oauth-scopes` | Comma-separated list of OAuth scopes to request | Default scopes (see below) | `--oauth-scopes repo,read:org,gist` | +| `--gh-host` | GitHub hostname for API requests and OAuth endpoints | `github.com` | `--gh-host https://github.yourcompany.com` | + +### Usage Examples + +**Minimal configuration (uses defaults):** +```bash +github-mcp-server stdio +``` + +**Custom OAuth App:** +```bash +github-mcp-server stdio --oauth-client-id Ov23liYourClientID +``` + +**Limiting scopes (minimal permissions):** +```bash +github-mcp-server stdio --oauth-scopes repo,read:org,gist +``` + +**GHES with custom OAuth App:** +```bash +github-mcp-server stdio --gh-host https://github.yourcompany.com --oauth-client-id Ov23liGHESClientID +``` + +**Confidential client with secret:** +```bash +github-mcp-server stdio --oauth-client-id Ov23liYourClientID --oauth-client-secret your_secret +``` + +**Custom scopes with custom OAuth App:** +```bash +github-mcp-server stdio --oauth-client-id Ov23liYourClientID --oauth-scopes repo,read:org,user:email +``` + +## Environment Variables + +All CLI flags can also be configured via environment variables with the `GITHUB_` prefix: + +| Environment Variable | Equivalent CLI Flag | Example | +|---------------------|---------------------|---------| +| `GITHUB_OAUTH_CLIENT_ID` | `--oauth-client-id` | `export GITHUB_OAUTH_CLIENT_ID=Ov23liYourClientID` | +| `GITHUB_OAUTH_CLIENT_SECRET` | `--oauth-client-secret` | `export GITHUB_OAUTH_CLIENT_SECRET=your_secret` | +| `GITHUB_OAUTH_SCOPES` | `--oauth-scopes` | `export GITHUB_OAUTH_SCOPES=repo,read:org,gist` | +| `GITHUB_HOST` | `--gh-host` | `export GITHUB_HOST=https://github.yourcompany.com` | +| `GITHUB_PERSONAL_ACCESS_TOKEN` | N/A (disables OAuth) | `export GITHUB_PERSONAL_ACCESS_TOKEN=ghp_token` | + +**Note**: If `GITHUB_PERSONAL_ACCESS_TOKEN` is set, the server will use PAT authentication and skip OAuth device flow entirely. + +### Environment Variable Priority + +The server uses the following priority for configuration: +1. CLI flags (highest priority) +2. Environment variables +3. Default values (lowest priority) + +## Scopes and Permissions + +The OAuth device flow requests the following scopes by default (defined in `DefaultOAuthScopes`): + +| Scope | Description | Required For | +|-------|-------------|--------------| +| `repo` | Full control of private repositories | Repository operations, issues, PRs | +| `repo:status` | Access commit status | CI/CD workflow monitoring | +| `repo_deployment` | Access deployment status | Deployment operations | +| `public_repo` | Access public repositories | Public repository operations | +| `gist` | Create and manage gists | Gist operations | +| `notifications` | Access notifications | Notification tools | +| `user` | Read and write user profile data | User information | +| `user:email` | Access user email addresses | User contact information | +| `user:follow` | Follow and unfollow users | Social features | +| `read:org` | Read organization data | Organization and team access | +| `read:gpg_key` | Read GPG keys | Signature verification | +| `project` | Read and write project data | Project board management | + +These scopes form a superset of the `gh` CLI minimal scopes (`repo`, `read:org`, `gist`) to support all GitHub MCP tools while following least-privilege principles. + +### Customizing Scopes + +You can customize the requested scopes using the `--oauth-scopes` CLI flag or `GITHUB_OAUTH_SCOPES` environment variable: + +**CLI Flag:** +```bash +github-mcp-server stdio --oauth-scopes repo,read:org,gist +``` + +**Environment Variable:** +```bash +export GITHUB_OAUTH_SCOPES=repo,read:org,user:email +github-mcp-server stdio +``` + +**Docker with environment variable:** +```json +{ + "github": { + "command": "docker", + "args": ["run", "-i", "--rm", "-e", "GITHUB_OAUTH_SCOPES", "ghcr.io/github/github-mcp-server"], + "env": { + "GITHUB_OAUTH_SCOPES": "repo,read:org,gist" + } + } +} +``` + +#### Minimal Recommended Scopes + +For basic functionality, you can use a minimal set of scopes: + +```bash +--oauth-scopes repo,read:org,gist +``` + +This provides: +- `repo`: Access to repositories, issues, and PRs +- `read:org`: Read organization and team information +- `gist`: Manage gists + +**Note**: Some MCP tools may not function properly with reduced scopes. Review the scopes table above to understand which scopes are required for specific functionality. + +#### Common Scope Combinations + +**Read-only operations:** +```bash +--oauth-scopes repo,read:org,read:user +``` + +**Full repository access with notifications:** +```bash +--oauth-scopes repo,read:org,notifications,user:email +``` + +**Enterprise with minimal scopes:** +```bash +--oauth-scopes repo,read:org,read:gpg_key +``` + +## Security Considerations + +### Token Storage + +- **In-memory only**: Access tokens obtained via OAuth device flow are stored only in memory and are never written to disk. +- **Ephemeral sessions**: Tokens are lost when the server process terminates (e.g., when a Docker container is stopped with `--rm`). +- **No persistent storage**: This design prioritizes security over convenience, making it ideal for short-lived sessions. + +### Public vs. Confidential Clients + +- **Public clients**: The default OAuth App is a public client (no client secret). The client ID can be safely embedded in source code per OAuth 2.0 specifications. +- **Confidential clients**: If you provide a client secret via `--oauth-client-secret`, the app becomes a confidential client with additional security but requires secure secret storage. + +### User Authorization + +- **Explicit consent**: Users must explicitly authorize the OAuth App in their browser, providing full visibility into what access is being granted. +- **Revocable access**: Users can revoke access at any time from their GitHub settings: https://github.com/settings/applications + +### Best Practices + +1. **Use organization-owned OAuth Apps** for enterprise deployments to maintain control +2. **Regularly review authorized applications** in GitHub settings +3. **Use confidential clients** (with secrets) only when you can securely store the secret +4. **Prefer ephemeral tokens** (OAuth) over long-lived PATs when possible +5. **Enable organization policies** to restrict which OAuth Apps can access your organization's data + +## Troubleshooting + +### Common Issues + +#### "Device code expired" + +**Cause**: The user didn't complete authorization within the expiration time (typically 15 minutes). + +**Solution**: Call `auth_login` again to start a new authentication flow. + +#### "Authorization was denied by the user" + +**Cause**: The user clicked "Cancel" or "Deny" during the authorization flow. + +**Solution**: Call `auth_login` again and ensure the user completes the authorization. + +#### "Failed to start authentication" + +**Possible causes**: +- Network connectivity issues +- Invalid OAuth App configuration +- GitHub service issues + +**Solutions**: +- Check network connectivity to GitHub +- Verify your OAuth client ID is correct +- Check GitHub's status page: https://www.githubstatus.com + +#### OAuth not working with GHES + +**Possible causes**: +- GHES version doesn't support device flow (requires GHES 3.0+) +- OAuth App not properly configured on GHES +- Incorrect hostname format + +**Solutions**: +- Verify GHES version: `https://YOUR_GHES_HOSTNAME/api/v3/meta` +- Ensure OAuth App has device flow enabled in GHES settings +- Use full URL with scheme: `--gh-host https://github.yourcompany.com` + +#### "Elicitation failed" message in logs + +**Cause**: The MCP client doesn't support URL elicitation (an optional MCP feature). + +**Effect**: The authentication flow still works, but the user won't see a clickable link. They'll need to manually copy/paste the verification URL. + +**Solution**: No action needed - this is expected for some MCP clients. + +### Debug Logging + +Enable debug logging to troubleshoot authentication issues: + +```json +{ + "github": { + "command": "/path/to/github-mcp-server", + "args": ["stdio", "--log-file", "/tmp/github-mcp-server.log"] + } +} +``` + +Check the log file for detailed information about the authentication flow. + +## Comparison with PAT Authentication + +| Feature | OAuth Device Flow | Personal Access Token | +|---------|------------------|---------------------| +| **Initial Setup** | No pre-configuration needed | Must manually create token | +| **Token Storage** | In-memory only | Stored in config files | +| **Token Lifetime** | Session-based (ephemeral) | Long-lived (until revoked) | +| **Security** | Explicit browser authorization | Token visible in config | +| **User Experience** | Interactive flow | Copy/paste token | +| **Enterprise Control** | Organization OAuth App policies | Token-level restrictions | +| **Offline Use** | Requires initial online auth | Works offline after setup | +| **Multiple Clients** | Each client needs separate auth | Same token can be reused | + +### When to Use Each Method + +**Use OAuth Device Flow when:** +- You want the simplest setup experience +- You're using Docker with `--rm` (ephemeral containers) +- You prefer not storing tokens in config files +- Your organization uses OAuth App policies + +**Use PAT when:** +- You need offline access after initial setup +- You want to reuse the same token across multiple MCP clients +- You need long-lived credentials for automation +- You're using an environment where interactive authentication isn't possible + +### Migration Between Methods + +You can switch between authentication methods at any time: + +**From PAT to OAuth**: Simply remove the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable from your configuration and restart the server. + +**From OAuth to PAT**: Add `GITHUB_PERSONAL_ACCESS_TOKEN` to your configuration with your token. The server will use PAT authentication and skip OAuth entirely. + +## Additional Resources + +- [OAuth 2.0 Device Authorization Grant (RFC 8628)](https://datatracker.ietf.org/doc/html/rfc8628) +- [GitHub OAuth Apps Documentation](https://docs.github.com/en/apps/oauth-apps) +- [GitHub Device Flow Documentation](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow) +- [Creating a Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) +- [GitHub MCP Server Documentation](https://github.com/github/github-mcp-server) + +## Need Help? + +If you encounter issues not covered in this guide: + +1. Check the [GitHub Discussions](https://github.com/github/github-mcp-server/discussions) +2. Search [existing issues](https://github.com/github/github-mcp-server/issues) +3. [Open a new issue](https://github.com/github/github-mcp-server/issues/new) with: + - Your server configuration (redact sensitive data) + - Error messages or unexpected behavior + - Debug logs (if available) + - GitHub environment (github.com, GHES, GHEC) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 9859e2e9b..7fe526869 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -25,6 +25,9 @@ import ( "github.com/shurcooL/githubv4" ) +// MCPServerConfig contains all configuration needed to create an MCP server. +// This config struct is used by both NewMCPServer and NewUnauthenticatedMCPServer +// to ensure consistent configuration across authentication modes. type MCPServerConfig struct { // Version of the server Version string @@ -67,6 +70,17 @@ type MCPServerConfig struct { Logger *slog.Logger // RepoAccessTTL overrides the default TTL for repository access cache entries. RepoAccessTTL *time.Duration + + // OAuthClientID is the OAuth App client ID for device flow authentication. + // If empty, the default GitHub MCP Server OAuth App is used. + OAuthClientID string + + // OAuthClientSecret is the OAuth App client secret (optional, for confidential clients). + OAuthClientSecret string + + // OAuthScopes is a list of OAuth scopes to request during device flow authentication. + // If empty, the default scopes defined in DefaultOAuthScopes are used. + OAuthScopes []string } // githubClients holds all the GitHub API clients created for a server instance. @@ -147,6 +161,17 @@ func resolveEnabledToolsets(cfg MCPServerConfig) []string { return nil } +// NewMCPServer creates a fully initialized MCP server with GitHub API access. +// This constructor is used when a token is available at startup (PAT authentication). +// For OAuth device flow authentication without a pre-configured token, use NewUnauthenticatedMCPServer instead. +// +// The server creation involves several steps: +// 1. Parse API host configuration and create GitHub clients +// 2. Resolve which toolsets to enable +// 3. Create MCP server with appropriate capabilities +// 4. Add middleware for error handling, user agents, and dependency injection +// 5. Build and register the tool/resource/prompt inventory +// 6. Optionally register dynamic toolset management tools func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { apiHost, err := parseAPIHost(cfg.Host) if err != nil { @@ -265,6 +290,145 @@ func createFeatureChecker(enabledFeatures []string) inventory.FeatureFlagChecker } } +// UnauthenticatedServerResult contains the server and components needed to complete +// authentication after the server is running. +type UnauthenticatedServerResult struct { + Server *mcp.Server + AuthManager *github.AuthManager +} + +// NewUnauthenticatedMCPServer creates an MCP server with only authentication tools available. +// This constructor is used for OAuth device flow when no token is available at startup. +// After successful authentication via the auth tools, the OnAuthenticated callback +// initializes GitHub clients and registers all other tools dynamically. +// +// Architecture note: This shares significant setup logic with NewMCPServer. The duplication +// exists because the two modes have different initialization timing: +// - NewMCPServer: All setup happens at construction time (clients + tools) +// - NewUnauthenticatedMCPServer: Setup happens in two phases (auth tools first, then clients + tools after auth) +// +// Future improvement: Consider extracting common setup logic into shared helper functions +// to reduce duplication while maintaining the two-phase initialization pattern. +func NewUnauthenticatedMCPServer(cfg MCPServerConfig) (*UnauthenticatedServerResult, error) { + // Create OAuth host from the configured GitHub host + oauthHost := github.NewOAuthHostFromAPIHost(cfg.Host) + + // Create auth manager + authManager := github.NewAuthManager(oauthHost, cfg.OAuthClientID, cfg.OAuthClientSecret, cfg.OAuthScopes) + + // Build the tool inventory once before forking - this ensures tool filters are applied once + enabledToolsets := resolveEnabledToolsets(cfg) + inventory := github.NewInventory(cfg.Translator). + WithDeprecatedAliases(github.DeprecatedToolAliases). + WithReadOnly(cfg.ReadOnly). + WithToolsets(enabledToolsets). + WithTools(github.CleanTools(cfg.EnabledTools)). + WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)). + Build() + + if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 { + fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", ")) + } + + // Create the MCP server with capabilities advertised for dynamic tool registration + serverOpts := &mcp.ServerOptions{ + Logger: cfg.Logger, + // Advertise capabilities since tools will be added after auth + Capabilities: &mcp.ServerCapabilities{ + Tools: &mcp.ToolCapabilities{ListChanged: true}, + Resources: &mcp.ResourceCapabilities{ListChanged: true}, + Prompts: &mcp.PromptCapabilities{ListChanged: true}, + }, + } + + ghServer := github.NewServer(cfg.Version, serverOpts) + + // Add error context middleware + ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext) + + // Create auth tool dependencies with a callback for when auth completes + authDeps := github.AuthToolDependencies{ + AuthManager: authManager, + T: cfg.Translator, + Server: ghServer, + Logger: cfg.Logger, + OnAuthenticated: func(ctx context.Context, token string) error { + // Create API host for GitHub clients + apiHost, err := parseAPIHost(cfg.Host) + if err != nil { + return fmt.Errorf("failed to parse API host: %w", err) + } + + // Create a new config with the token + authenticatedCfg := cfg + authenticatedCfg.Token = token + + // Create GitHub clients + clients, err := createGitHubClients(authenticatedCfg, apiHost) + if err != nil { + return fmt.Errorf("failed to create GitHub clients: %w", err) + } + + // Add user agent middleware + ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(authenticatedCfg, clients.rest, clients.gqlHTTP)) + + // Create dependencies for tool handlers + deps := github.NewBaseDeps( + clients.rest, + clients.gql, + clients.raw, + clients.repoAccess, + cfg.Translator, + github.FeatureFlags{LockdownMode: cfg.LockdownMode}, + cfg.ContentWindowSize, + ) + + // Inject dependencies into context for all tool handlers + ghServer.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + return next(github.ContextWithDeps(ctx, deps), method, req) + } + }) + + // Register all GitHub tools/resources/prompts using the pre-built inventory + inventory.RegisterAll(ctx, ghServer, deps) + + // Register dynamic toolset management tools if enabled + if cfg.DynamicToolsets { + registerDynamicTools(ghServer, inventory, deps, cfg.Translator) + } + + if cfg.Logger != nil { + availableTools := inventory.AvailableTools(ctx) + cfg.Logger.Info("authentication complete, tools registered", "toolCount", len(availableTools)) + } + + return nil + }, + OnAuthComplete: func() { + // Remove auth tools after authentication completes. + // Note: This manual removal ensures auth tools don't remain available after login. + // The auth_login tool is removed by name here to keep the tool list clean. + // Future improvement: Consider using toolset-based filtering to automatically + // exclude auth toolset after authentication, removing the need for manual cleanup. + ghServer.RemoveTools("auth_login") + if cfg.Logger != nil { + cfg.Logger.Info("auth tools removed after successful authentication") + } + }, + } + + // Register only auth tools + for _, tool := range github.AuthTools(cfg.Translator) { + tool.RegisterFunc(ghServer, authDeps) + } + + return &UnauthenticatedServerResult{ + Server: ghServer, + AuthManager: authManager, + }, nil +} + type StdioServerConfig struct { // Version of the server Version string @@ -312,6 +476,17 @@ type StdioServerConfig struct { // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. RepoAccessCacheTTL *time.Duration + + // OAuthClientID is the OAuth App client ID for device flow authentication. + // If empty, the default GitHub MCP Server OAuth App is used. + OAuthClientID string + + // OAuthClientSecret is the OAuth App client secret (optional, for confidential clients). + OAuthClientSecret string + + // OAuthScopes is a list of OAuth scopes to request during device flow authentication. + // If empty, the default scopes defined in DefaultOAuthScopes are used. + OAuthScopes []string } // RunStdioServer is not concurrent safe. @@ -338,23 +513,53 @@ func RunStdioServer(cfg StdioServerConfig) error { logger := slog.New(slogHandler) logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) - ghServer, err := NewMCPServer(MCPServerConfig{ - Version: cfg.Version, - Host: cfg.Host, - Token: cfg.Token, - EnabledToolsets: cfg.EnabledToolsets, - EnabledTools: cfg.EnabledTools, - EnabledFeatures: cfg.EnabledFeatures, - DynamicToolsets: cfg.DynamicToolsets, - ReadOnly: cfg.ReadOnly, - Translator: t, - ContentWindowSize: cfg.ContentWindowSize, - LockdownMode: cfg.LockdownMode, - Logger: logger, - RepoAccessTTL: cfg.RepoAccessCacheTTL, - }) - if err != nil { - return fmt.Errorf("failed to create MCP server: %w", err) + var ghServer *mcp.Server + + // If no token is provided, start in unauthenticated mode with only auth tools + if cfg.Token == "" { + logger.Info("no token provided, starting in unauthenticated mode with auth tools") + result, err := NewUnauthenticatedMCPServer(MCPServerConfig{ + Version: cfg.Version, + Host: cfg.Host, + Translator: t, + Logger: logger, + OAuthClientID: cfg.OAuthClientID, + OAuthClientSecret: cfg.OAuthClientSecret, + OAuthScopes: cfg.OAuthScopes, + // Pass config for use after authentication + EnabledToolsets: cfg.EnabledToolsets, + EnabledTools: cfg.EnabledTools, + EnabledFeatures: cfg.EnabledFeatures, + DynamicToolsets: cfg.DynamicToolsets, + ReadOnly: cfg.ReadOnly, + ContentWindowSize: cfg.ContentWindowSize, + LockdownMode: cfg.LockdownMode, + RepoAccessTTL: cfg.RepoAccessCacheTTL, + }) + if err != nil { + return fmt.Errorf("failed to create unauthenticated MCP server: %w", err) + } + ghServer = result.Server + } else { + var err error + ghServer, err = NewMCPServer(MCPServerConfig{ + Version: cfg.Version, + Host: cfg.Host, + Token: cfg.Token, + EnabledToolsets: cfg.EnabledToolsets, + EnabledTools: cfg.EnabledTools, + EnabledFeatures: cfg.EnabledFeatures, + DynamicToolsets: cfg.DynamicToolsets, + ReadOnly: cfg.ReadOnly, + Translator: t, + ContentWindowSize: cfg.ContentWindowSize, + LockdownMode: cfg.LockdownMode, + Logger: logger, + RepoAccessTTL: cfg.RepoAccessCacheTTL, + }) + if err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } } if cfg.ExportTranslations { diff --git a/pkg/github/__toolsnaps__/auth_login.snap b/pkg/github/__toolsnaps__/auth_login.snap new file mode 100644 index 000000000..6abf6d2b2 --- /dev/null +++ b/pkg/github/__toolsnaps__/auth_login.snap @@ -0,0 +1,11 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Login to GitHub" + }, + "description": "Initiate GitHub authentication using OAuth device flow. This will provide a URL and code that you can use to authenticate with GitHub. After visiting the URL and entering the code, authentication will complete automatically.", + "inputSchema": { + "type": "object" + }, + "name": "auth_login" +} \ No newline at end of file diff --git a/pkg/github/auth.go b/pkg/github/auth.go new file mode 100644 index 000000000..3de5e69b4 --- /dev/null +++ b/pkg/github/auth.go @@ -0,0 +1,396 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// AuthState represents the current authentication state of the server. +type AuthState int + +const ( + // AuthStateUnauthenticated means no token is available. + AuthStateUnauthenticated AuthState = iota + // AuthStatePending means device flow has been initiated, waiting for user. + AuthStatePending + // AuthStateAuthenticated means a valid token is available. + AuthStateAuthenticated +) + +// DeviceCodeResponse represents the response from GitHub's device code endpoint. +type DeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// TokenResponse represents the response from GitHub's token endpoint. +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + Error string `json:"error,omitempty"` + ErrorDesc string `json:"error_description,omitempty"` +} + +// AuthManager manages authentication state for the MCP server. +// It handles the OAuth device flow and token storage. +type AuthManager struct { + mu sync.RWMutex + + state AuthState + token string + deviceCode *DeviceCodeResponse + expiresAt time.Time + clientID string + clientSecret string + scopes []string + + // Host configuration for deriving OAuth endpoints + host OAuthHost +} + +// OAuthHost contains the OAuth endpoints for a GitHub host. +type OAuthHost struct { + DeviceCodeURL string + TokenURL string + Hostname string +} + +// NewOAuthHostFromAPIHost creates OAuth endpoints from the API host configuration. +func NewOAuthHostFromAPIHost(hostname string) OAuthHost { + if hostname == "" || hostname == "github.com" || hostname == "https://github.com" || hostname == "https://api.github.com" { + return OAuthHost{ + DeviceCodeURL: "https://github.com/login/device/code", + TokenURL: "https://github.com/login/oauth/access_token", + Hostname: "github.com", + } + } + + // If the hostname doesn't have a scheme, add https:// + if !strings.HasPrefix(hostname, "http://") && !strings.HasPrefix(hostname, "https://") { + hostname = "https://" + hostname + } + + // Parse the hostname to extract the base + u, err := url.Parse(hostname) + var host string + if err != nil || u.Hostname() == "" { + // Fallback: strip scheme if it was added, use original hostname + host = strings.TrimPrefix(hostname, "https://") + host = strings.TrimPrefix(host, "http://") + return OAuthHost{ + DeviceCodeURL: fmt.Sprintf("https://%s/login/device/code", host), + TokenURL: fmt.Sprintf("https://%s/login/oauth/access_token", host), + Hostname: host, + } + } + + // For GHEC (ghe.com) and GHES, OAuth endpoints are on the main host + host = u.Hostname() + scheme := u.Scheme + if scheme == "" { + scheme = "https" + } + + return OAuthHost{ + DeviceCodeURL: fmt.Sprintf("%s://%s/login/device/code", scheme, host), + TokenURL: fmt.Sprintf("%s://%s/login/oauth/access_token", scheme, host), + Hostname: host, + } +} + +// DefaultOAuthClientID is the OAuth App client ID for the GitHub MCP Server. +// TODO: This is currently a testing OAuth App. An official GitHub-managed OAuth App +// will be registered before production release. +// The client ID is safe to embed in source code per OAuth 2.0 spec for public clients. +// Users can override this with --oauth-client-id for enterprise scenarios. +const DefaultOAuthClientID = "Ov23ctTMsnT9LTRdBYYM" + +// DefaultOAuthScopes are the standard scopes needed for complete MCP functionality. +var DefaultOAuthScopes = []string{ + "gist", + "notifications", + "public_repo", + "repo", + "repo:status", + "repo_deployment", + "user", + "user:email", + "user:follow", + "read:gpg_key", + "read:org", + "project", +} + +// NewAuthManager creates a new AuthManager. +func NewAuthManager(host OAuthHost, clientID, clientSecret string, scopes []string) *AuthManager { + if clientID == "" { + clientID = DefaultOAuthClientID + } + if len(scopes) == 0 { + scopes = DefaultOAuthScopes + } + + return &AuthManager{ + state: AuthStateUnauthenticated, + host: host, + clientID: clientID, + clientSecret: clientSecret, + scopes: scopes, + } +} + +// NewAuthManagerWithToken creates an AuthManager that is already authenticated. +func NewAuthManagerWithToken(token string) *AuthManager { + return &AuthManager{ + state: AuthStateAuthenticated, + token: token, + } +} + +// State returns the current authentication state. +func (a *AuthManager) State() AuthState { + a.mu.RLock() + defer a.mu.RUnlock() + return a.state +} + +// Token returns the current access token, or empty string if not authenticated. +func (a *AuthManager) Token() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.token +} + +// IsAuthenticated returns true if a valid token is available. +func (a *AuthManager) IsAuthenticated() bool { + return a.State() == AuthStateAuthenticated +} + +// StartDeviceFlow initiates the OAuth device authorization flow. +// Returns the device code response containing the user code and verification URL. +func (a *AuthManager) StartDeviceFlow(ctx context.Context) (*DeviceCodeResponse, error) { + a.mu.Lock() + defer a.mu.Unlock() + + if a.state == AuthStateAuthenticated { + return nil, fmt.Errorf("already authenticated") + } + + // Build the request + data := url.Values{} + data.Set("client_id", a.clientID) + data.Set("scope", joinScopes(a.scopes)) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.host.DeviceCodeURL, bytes.NewBufferString(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create device code request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to request device code: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read device code response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("device code request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var deviceResp DeviceCodeResponse + if err := json.Unmarshal(body, &deviceResp); err != nil { + return nil, fmt.Errorf("failed to parse device code response: %w", err) + } + + // Store the device code and update state + a.deviceCode = &deviceResp + a.expiresAt = time.Now().Add(time.Duration(deviceResp.ExpiresIn) * time.Second) + a.state = AuthStatePending + + return &deviceResp, nil +} + +// CompleteDeviceFlow polls for the access token after the user has authorized. +// This should be called after StartDeviceFlow and after the user has entered the code. +func (a *AuthManager) CompleteDeviceFlow(ctx context.Context) error { + return a.CompleteDeviceFlowWithProgress(ctx, nil) +} + +// ProgressCallback is called during polling to report progress. +// elapsed is seconds since polling started, total is the expiry time in seconds. +type ProgressCallback func(elapsed, total int, message string) + +// CompleteDeviceFlowWithProgress polls for the access token with progress updates. +// The onProgress callback is called periodically during polling. +func (a *AuthManager) CompleteDeviceFlowWithProgress(ctx context.Context, onProgress ProgressCallback) error { + a.mu.Lock() + deviceCode := a.deviceCode + expiresAt := a.expiresAt + a.mu.Unlock() + + if deviceCode == nil { + return fmt.Errorf("no pending device flow - call StartDeviceFlow first") + } + + if time.Now().After(expiresAt) { + a.mu.Lock() + a.state = AuthStateUnauthenticated + a.deviceCode = nil + a.mu.Unlock() + return fmt.Errorf("device code expired - please start a new login flow") + } + + // Poll for the token + interval := time.Duration(deviceCode.Interval) * time.Second + if interval < 5*time.Second { + interval = 5 * time.Second // Minimum poll interval per RFC 8628 + } + + startTime := time.Now() + totalSeconds := deviceCode.ExpiresIn + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + // Report progress before polling + if onProgress != nil { + elapsed := int(time.Since(startTime).Seconds()) + onProgress(elapsed, totalSeconds, "⏳ Waiting for authorization...") + } + + token, err := a.pollForToken(ctx, deviceCode.DeviceCode) + if err != nil { + // Check for specific error types + if err.Error() == "authorization_pending" { + continue // Keep polling + } + if err.Error() == "slow_down" { + // Increase interval by 5 seconds per RFC 8628 + interval += 5 * time.Second + ticker.Reset(interval) + continue + } + // Other errors are terminal + a.mu.Lock() + a.state = AuthStateUnauthenticated + a.deviceCode = nil + a.mu.Unlock() + return err + } + + // Success! Store the token + a.mu.Lock() + a.token = token + a.state = AuthStateAuthenticated + a.deviceCode = nil + a.mu.Unlock() + + return nil + } + } +} + +// pollForToken makes a single request to the token endpoint. +func (a *AuthManager) pollForToken(ctx context.Context, deviceCode string) (string, error) { + data := url.Values{} + data.Set("client_id", a.clientID) + data.Set("device_code", deviceCode) + data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + + // Add client secret if provided (for confidential clients) + if a.clientSecret != "" { + data.Set("client_secret", a.clientSecret) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.host.TokenURL, bytes.NewBufferString(data.Encode())) + if err != nil { + return "", fmt.Errorf("failed to create token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to request token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read token response: %w", err) + } + + var tokenResp TokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", fmt.Errorf("failed to parse token response: %w", err) + } + + // Check for OAuth errors + if tokenResp.Error != "" { + switch tokenResp.Error { + case "authorization_pending": + return "", fmt.Errorf("authorization_pending") + case "slow_down": + return "", fmt.Errorf("slow_down") + case "expired_token": + return "", fmt.Errorf("device code expired - please start a new login flow") + case "access_denied": + return "", fmt.Errorf("authorization was denied by the user") + default: + return "", fmt.Errorf("OAuth error: %s - %s", tokenResp.Error, tokenResp.ErrorDesc) + } + } + + if tokenResp.AccessToken == "" { + return "", fmt.Errorf("no access token in response") + } + + return tokenResp.AccessToken, nil +} + +// Reset clears any pending authentication state. +func (a *AuthManager) Reset() { + a.mu.Lock() + defer a.mu.Unlock() + + if a.state == AuthStatePending { + a.state = AuthStateUnauthenticated + a.deviceCode = nil + } +} + +// SetToken directly sets the authentication token (for testing or migration). +func (a *AuthManager) SetToken(token string) { + a.mu.Lock() + defer a.mu.Unlock() + a.token = token + a.state = AuthStateAuthenticated + a.deviceCode = nil +} + +func joinScopes(scopes []string) string { + return strings.Join(scopes, " ") +} diff --git a/pkg/github/auth_test.go b/pkg/github/auth_test.go new file mode 100644 index 000000000..75432c5b4 --- /dev/null +++ b/pkg/github/auth_test.go @@ -0,0 +1,279 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewOAuthHostFromAPIHost(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + apiHost string + expectedHostname string + expectedDevice string + expectedToken string + }{ + { + name: "github.com (empty host)", + apiHost: "", + expectedHostname: "github.com", + expectedDevice: "https://github.com/login/device/code", + expectedToken: "https://github.com/login/oauth/access_token", + }, + { + name: "github.com (explicit)", + apiHost: "github.com", + expectedHostname: "github.com", + expectedDevice: "https://github.com/login/device/code", + expectedToken: "https://github.com/login/oauth/access_token", + }, + { + name: "GHES without scheme", + apiHost: "github.enterprise.com", + expectedHostname: "github.enterprise.com", + expectedDevice: "https://github.enterprise.com/login/device/code", + expectedToken: "https://github.enterprise.com/login/oauth/access_token", + }, + { + name: "GHES with https scheme", + apiHost: "https://github.enterprise.com", + expectedHostname: "github.enterprise.com", + expectedDevice: "https://github.enterprise.com/login/device/code", + expectedToken: "https://github.enterprise.com/login/oauth/access_token", + }, + { + name: "GHEC tenant", + apiHost: "company.ghe.com", + expectedHostname: "company.ghe.com", + expectedDevice: "https://company.ghe.com/login/device/code", + expectedToken: "https://company.ghe.com/login/oauth/access_token", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + host := NewOAuthHostFromAPIHost(tc.apiHost) + assert.Equal(t, tc.expectedHostname, host.Hostname) + assert.Equal(t, tc.expectedDevice, host.DeviceCodeURL) + assert.Equal(t, tc.expectedToken, host.TokenURL) + }) + } +} + +func TestAuthManager_StateTransitions(t *testing.T) { + t.Parallel() + + host := NewOAuthHostFromAPIHost("") + authMgr := NewAuthManager(host, "test-client-id", "", nil) + + // Initial state should be unauthenticated + assert.Equal(t, AuthStateUnauthenticated, authMgr.State()) + assert.False(t, authMgr.IsAuthenticated()) + assert.Empty(t, authMgr.Token()) + + // Cannot call Reset when not pending + authMgr.Reset() + assert.Equal(t, AuthStateUnauthenticated, authMgr.State()) +} + +func TestAuthManager_StartDeviceFlow(t *testing.T) { + t.Parallel() + + // Create a mock OAuth server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/login/device/code" { + w.Header().Set("Content-Type", "application/json") + resp := map[string]interface{}{ + "device_code": "test-device-code", + "user_code": "ABCD-1234", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 5, + } + _ = json.NewEncoder(w).Encode(resp) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + host := OAuthHost{ + Hostname: "test.example.com", + DeviceCodeURL: server.URL + "/login/device/code", + TokenURL: server.URL + "/login/oauth/access_token", + } + + authMgr := NewAuthManager(host, "test-client-id", "", nil) + + // Start the device flow + deviceResp, err := authMgr.StartDeviceFlow(context.Background()) + require.NoError(t, err) + assert.Equal(t, "test-device-code", deviceResp.DeviceCode) + assert.Equal(t, "ABCD-1234", deviceResp.UserCode) + assert.Equal(t, "https://github.com/login/device", deviceResp.VerificationURI) + + // State should now be pending + assert.Equal(t, AuthStatePending, authMgr.State()) +} + +func TestAuthManager_CompleteDeviceFlow_Success(t *testing.T) { + t.Parallel() + + // Track poll attempts + pollCount := 0 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/login/device/code" { + w.Header().Set("Content-Type", "application/json") + resp := map[string]interface{}{ + "device_code": "test-device-code", + "user_code": "ABCD-1234", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 1, // Short interval for test + } + _ = json.NewEncoder(w).Encode(resp) + return + } + if r.URL.Path == "/login/oauth/access_token" { + pollCount++ + w.Header().Set("Content-Type", "application/json") + if pollCount < 2 { + // First poll returns pending + resp := map[string]interface{}{ + "error": "authorization_pending", + } + _ = json.NewEncoder(w).Encode(resp) + } else { + // Second poll returns token + resp := map[string]interface{}{ + "access_token": "gho_test_token_12345", + "token_type": "bearer", + "scope": "repo,read:org", + } + _ = json.NewEncoder(w).Encode(resp) + } + return + } + http.NotFound(w, r) + })) + defer server.Close() + + host := OAuthHost{ + Hostname: "test.example.com", + DeviceCodeURL: server.URL + "/login/device/code", + TokenURL: server.URL + "/login/oauth/access_token", + } + + authMgr := NewAuthManager(host, "test-client-id", "", nil) + + // Start the device flow + _, err := authMgr.StartDeviceFlow(context.Background()) + require.NoError(t, err) + + // Complete the flow + err = authMgr.CompleteDeviceFlow(context.Background()) + require.NoError(t, err) + + // Should now be authenticated + assert.Equal(t, AuthStateAuthenticated, authMgr.State()) + assert.True(t, authMgr.IsAuthenticated()) + assert.Equal(t, "gho_test_token_12345", authMgr.Token()) +} + +func TestAuthManager_CompleteDeviceFlow_AccessDenied(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/login/device/code" { + w.Header().Set("Content-Type", "application/json") + resp := map[string]interface{}{ + "device_code": "test-device-code", + "user_code": "ABCD-1234", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + return + } + if r.URL.Path == "/login/oauth/access_token" { + w.Header().Set("Content-Type", "application/json") + resp := map[string]interface{}{ + "error": "access_denied", + "error_description": "The user has denied your request.", + } + _ = json.NewEncoder(w).Encode(resp) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + host := OAuthHost{ + Hostname: "test.example.com", + DeviceCodeURL: server.URL + "/login/device/code", + TokenURL: server.URL + "/login/oauth/access_token", + } + + authMgr := NewAuthManager(host, "test-client-id", "", nil) + + // Start the device flow + _, err := authMgr.StartDeviceFlow(context.Background()) + require.NoError(t, err) + + // Complete the flow - should fail with access denied + err = authMgr.CompleteDeviceFlow(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "denied") + + // Should be back to unauthenticated + assert.Equal(t, AuthStateUnauthenticated, authMgr.State()) +} + +func TestAuthManager_Reset(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/login/device/code" { + w.Header().Set("Content-Type", "application/json") + resp := map[string]interface{}{ + "device_code": "test-device-code", + "user_code": "ABCD-1234", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + host := OAuthHost{ + Hostname: "test.example.com", + DeviceCodeURL: server.URL + "/login/device/code", + TokenURL: server.URL + "/login/oauth/access_token", + } + + authMgr := NewAuthManager(host, "test-client-id", "", nil) + + // Start the device flow + _, err := authMgr.StartDeviceFlow(context.Background()) + require.NoError(t, err) + assert.Equal(t, AuthStatePending, authMgr.State()) + + // Reset should clear the pending state + authMgr.Reset() + assert.Equal(t, AuthStateUnauthenticated, authMgr.State()) +} diff --git a/pkg/github/auth_tools.go b/pkg/github/auth_tools.go new file mode 100644 index 000000000..841ef4bc5 --- /dev/null +++ b/pkg/github/auth_tools.go @@ -0,0 +1,176 @@ +package github + +import ( + "context" + "fmt" + "log/slog" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// AuthToolset is the toolset for authentication tools. +// This is a special toolset that's only available when unauthenticated. +var ToolsetMetadataAuth = inventory.ToolsetMetadata{ + ID: "auth", + Description: "Authentication tools for logging into GitHub", + Icon: "key", +} + +// AuthToolDependencies contains dependencies for auth tools. +type AuthToolDependencies struct { + AuthManager *AuthManager + T translations.TranslationHelperFunc + // Server is the MCP server, used to access sessions for notifications + Server *mcp.Server + // Logger for debug logging + Logger *slog.Logger + // OnAuthenticated is called when authentication completes successfully. + // It should initialize GitHub clients and register tools. + OnAuthenticated func(ctx context.Context, token string) error + // OnAuthComplete is called after authentication flow completes (success or failure). + // It can be used to clean up auth tools after they're no longer needed. + OnAuthComplete func() +} + +// AuthTools returns the authentication tools. +// These are available when the server starts without a token. +func AuthTools(t translations.TranslationHelperFunc) []inventory.ServerTool { + return []inventory.ServerTool{ + AuthLogin(t), + } +} + +// AuthLogin creates a tool that initiates the OAuth device flow. +// It uses URL elicitation to show the user the authorization URL and code, +// then blocks while polling until the user completes authorization. +func AuthLogin(t translations.TranslationHelperFunc) inventory.ServerTool { + return inventory.ServerTool{ + Tool: mcp.Tool{ + Name: "auth_login", + Description: t("auth_login_description", "Initiate GitHub authentication using OAuth device flow. This will provide a URL and code that you can use to authenticate with GitHub. After visiting the URL and entering the code, authentication will complete automatically."), + Annotations: &mcp.ToolAnnotations{ + Title: t("auth_login_title", "Login to GitHub"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{}, + }, + }, + Toolset: ToolsetMetadataAuth, + HandlerFunc: func(deps any) mcp.ToolHandler { + return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + authDeps := deps.(AuthToolDependencies) + authMgr := authDeps.AuthManager + + if authMgr.IsAuthenticated() { + return utils.NewToolResultText("Already authenticated with GitHub."), nil + } + + // Reset any pending flow before starting a new one + authMgr.Reset() + + deviceResp, err := authMgr.StartDeviceFlow(ctx) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("Failed to start authentication: %v", err)), nil + } + + if authDeps.Logger != nil { + authDeps.Logger.Info("starting auth flow", "expiresIn", deviceResp.ExpiresIn) + } + + // Use URL elicitation to show the auth URL to the user + // This creates a nice UI in the client for the user to click + elicitResult, err := req.Session.Elicit(ctx, &mcp.ElicitParams{ + Mode: "url", + Message: fmt.Sprintf("🔐 GitHub Authentication\n\nEnter code: %s", deviceResp.UserCode), + URL: deviceResp.VerificationURI, + }) + if err != nil { + if authDeps.Logger != nil { + authDeps.Logger.Error("elicitation failed", "error", err) + } + // Elicitation not supported or failed - fall back to polling + return pollAndComplete(ctx, req.Session, authDeps, authMgr, deviceResp) + } + + // Check if user cancelled + if elicitResult.Action == "cancel" || elicitResult.Action == "decline" { + authMgr.Reset() + return utils.NewToolResultText("Authentication cancelled."), nil + } + + // User clicked the link - now poll for completion with progress + return pollAndComplete(ctx, req.Session, authDeps, authMgr, deviceResp) + } + }, + } +} + +// pollAndComplete polls for the auth token and completes the flow. +// It sends progress notifications during polling so the user knows it's working. +func pollAndComplete(ctx context.Context, session *mcp.ServerSession, authDeps AuthToolDependencies, authMgr *AuthManager, _ *DeviceCodeResponse) (*mcp.CallToolResult, error) { + // Poll for the token with progress updates + err := authMgr.CompleteDeviceFlowWithProgress(ctx, func(elapsed, total int, _ string) { + if authDeps.Logger != nil { + authDeps.Logger.Debug("auth polling", "elapsed", elapsed, "total", total) + } + // Send progress notification so user sees we're waiting + if session != nil { + _ = session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: "auth-polling", + Progress: float64(elapsed), + Total: float64(total), + Message: "⏳ Waiting for GitHub authorization...", + }) + } + }) + if err != nil { + if authDeps.Logger != nil { + authDeps.Logger.Error("auth polling failed", "error", err) + } + return utils.NewToolResultError(fmt.Sprintf("Authentication failed: %v", err)), nil + } + + if authDeps.Logger != nil { + authDeps.Logger.Info("auth polling succeeded, registering tools") + } + + // Call the OnAuthenticated callback to initialize clients and register tools + if authDeps.OnAuthenticated != nil { + if err := authDeps.OnAuthenticated(ctx, authMgr.Token()); err != nil { + if authDeps.Logger != nil { + authDeps.Logger.Error("failed to initialize after auth", "error", err) + } + return nil, fmt.Errorf("authentication succeeded but failed to initialize: %w", err) + } + } + + // Send a user-visible notification about successful authentication + if session != nil { + _ = session.Log(ctx, &mcp.LoggingMessageParams{ + Level: "notice", + Logger: "github-mcp-server", + Data: "✅ Successfully authenticated with GitHub! All GitHub tools are now available.", + }) + } + + // Clean up auth tools now that we're authenticated + if authDeps.OnAuthComplete != nil { + authDeps.OnAuthComplete() + } + + return utils.NewToolResultText(`✅ Successfully authenticated with GitHub! + +All GitHub tools are now available. You can now: +- Create and manage repositories +- Work with issues and pull requests +- Access your organizations and teams +- And much more, depending on your GitHub configuration + +Call get_me to see who you're logged in as.`), nil +} diff --git a/pkg/github/auth_tools_test.go b/pkg/github/auth_tools_test.go new file mode 100644 index 000000000..69c3d0122 --- /dev/null +++ b/pkg/github/auth_tools_test.go @@ -0,0 +1,32 @@ +package github + +import ( + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthLogin(t *testing.T) { + t.Parallel() + + // Verify tool definition + serverTool := AuthLogin(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "auth_login", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.True(t, tool.Annotations.ReadOnlyHint, "auth_login tool should be read-only") +} + +func TestAuthTools(t *testing.T) { + t.Parallel() + + tools := AuthTools(translations.NullTranslationHelper) + require.Len(t, tools, 1) + + assert.Equal(t, "auth_login", tools[0].Tool.Name) +}