diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index cfb68be4..11d09a2d 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 @@ -84,6 +81,8 @@ 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"), } return ghmcp.RunStdioServer(stdioServerConfig) }, @@ -109,6 +108,8 @@ 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)") // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) @@ -123,6 +124,8 @@ 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")) // Add subcommands rootCmd.AddCommand(stdioCmd) diff --git a/docs/auth-design.md b/docs/auth-design.md new file mode 100644 index 00000000..6b7fea40 --- /dev/null +++ b/docs/auth-design.md @@ -0,0 +1,321 @@ +# OAuth Device Flow Authentication Design + +## Overview + +This document describes the implementation of OAuth Device Flow authentication for the GitHub MCP Server's stdio transport. The design enables users to authenticate without pre-configuring tokens, making setup significantly simpler. + +## Problem Statement + +Currently, users must: +1. Generate a Personal Access Token (PAT) manually on GitHub +2. Configure the token in their MCP host's configuration (often in plain text) +3. Manage token rotation manually + +This creates friction for new users and security concerns around token storage. + +## Proposed Solution + +When the server starts without a `GITHUB_PERSONAL_ACCESS_TOKEN`, instead of failing, it starts in "unauthenticated mode" with only authentication tools available. Users authenticate through MCP tool calls: + +1. **`auth_login`** - Initiates device flow, returns verification URL and user code +2. **`auth_verify`** - Completes the flow after user authorizes in browser + +Once authenticated, the token is held in memory for the session and all regular tools become available. + +## User Experience + +### Before (Current) +```jsonc +{ + "githubz": { + "command": "docker", + "args": ["run", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" // User must create PAT first + } + } +} +``` + +### After (New) +```jsonc +{ + "github": { + "command": "docker", + "args": ["run", "--rm", "-i", "ghcr.io/github/github-mcp-server", "stdio", "--toolsets=all"] + // No token needed! User authenticates via tool call + } +} +``` + +### Authentication Flow (User Perspective) + +1. User asks agent: "Create an issue on my repo" +2. Agent calls `auth_login` tool +3. Tool returns: + ``` + To authenticate, visit: https://github.com/login/device + Enter code: ABCD-1234 + + After authorizing, use the auth_verify tool to complete login. + ``` +4. User opens browser, enters code, clicks "Authorize" +5. Agent calls `auth_verify` tool +6. Tool returns: "Successfully authenticated as @username" +7. Agent proceeds with original request using now-available tools + +## Technical Design + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ MCP Server │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Auth State │───▶│ Tool Filter │───▶│ GitHub Clients │ │ +│ │ Manager │ │ │ │ (lazy init) │ │ +│ └──────────────┘ └──────────────┘ └──────────────────┘ │ +│ │ │ │ +│ │ token │ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Device Flow │ │ REST/GraphQL │ │ +│ │ Handler │ │ Clients │ │ +│ └──────────────┘ └──────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### State Machine + +``` +┌─────────────────┐ +│ UNAUTHENTICATED │ ◀──────────────────────────────┐ +│ │ │ +│ Tools: auth_* │ │ +└────────┬────────┘ │ + │ auth_login() │ + ▼ │ +┌─────────────────┐ │ +│ PENDING_AUTH │ │ +│ │──── timeout/error ─────────────▶│ +│ Tools: auth_* │ │ +└────────┬────────┘ │ + │ auth_verify() success │ + ▼ │ +┌─────────────────┐ │ +│ AUTHENTICATED │ │ +│ │──── token invalid ─────────────▶│ +│ Tools: all │ │ +└─────────────────┘ +``` + +### Host URL Derivation + +For different GitHub products, device flow endpoints are derived from the configured host: + +| Product | Host Config | Device Code Endpoint | +|---------|-------------|---------------------| +| github.com | (default) | `https://github.com/login/device/code` | +| GHEC | `https://tenant.ghe.com` | `https://tenant.ghe.com/login/device/code` | +| GHES | `https://github.example.com` | `https://github.example.com/login/device/code` | + +### OAuth App Requirements + +The device flow requires an OAuth App. Options: +1. **GitHub-provided OAuth App** (recommended) - We register a public OAuth App for this purpose +2. **User-provided OAuth App** - Via `--oauth-client-id` flag for enterprise scenarios + +Default OAuth App scopes (matching `gh` CLI minimal scopes): +- `repo` - Full control of private repositories +- `read:org` - Read org membership +- `gist` - Create gists + +### Key Components + +#### 1. Auth State Manager (`pkg/github/auth_state.go`) + +```go +type AuthState struct { + mu sync.RWMutex + token string + deviceCode *DeviceCodeResponse + pollInterval time.Duration + expiresAt time.Time +} + +func (a *AuthState) IsAuthenticated() bool +func (a *AuthState) GetToken() string +func (a *AuthState) StartDeviceFlow(ctx context.Context, host apiHost, clientID string) (*DeviceCodeResponse, error) +func (a *AuthState) CompleteDeviceFlow(ctx context.Context) (string, error) +``` + +#### 2. Auth Tools (`pkg/github/auth_tools.go`) + +```go +// auth_login tool - initiates device flow +func AuthLogin(ctx context.Context) (*AuthLoginResult, error) + +// auth_verify tool - completes device flow +func AuthVerify(ctx context.Context) (*AuthVerifyResult, error) +``` + +#### 3. Dynamic Tool Registration + +When unauthenticated, only auth tools are registered. After successful auth: +1. Initialize GitHub clients with new token +2. Register all configured toolsets +3. Send `tools/list_changed` notification to client + +### Docker Considerations + +With `--rm` containers: +- Token lives only in memory for the session duration +- User re-authenticates each time container starts +- This is acceptable UX since device flow is quick (~30 seconds) + +For persistent auth (optional future enhancement): +- Mount a config volume: `-v ~/.config/github-mcp-server:/config` +- Server stores encrypted token in volume +- Requires user opt-in for security + +### Security Considerations + +1. **Token never in config** - Token obtained at runtime, never written to disk (in --rm mode) +2. **Short-lived session** - Token only valid for container lifetime +3. **Principle of least privilege** - Request minimal scopes +4. **PKCE** - Use PKCE extension for additional security (if supported) +5. **User verification** - User explicitly authorizes in browser with full visibility + +### Error Handling + +| Scenario | Behavior | +|----------|----------| +| Device flow timeout | Return error, user can retry `auth_login` | +| User denies authorization | Return error explaining denial | +| Network issues during poll | Retry with backoff, eventually timeout | +| Invalid client ID | Clear error message with setup instructions | +| Token expires mid-session | Return 401-like error, prompt re-auth via tools | + +## Implementation Plan + +### Phase 1: Core Auth Flow +1. Add `pkg/github/auth_state.go` - Auth state management +2. Add `pkg/github/auth_tools.go` - Auth tool implementations +3. Modify `internal/ghmcp/server.go` - Support unauthenticated startup +4. Add device flow endpoint derivation for all host types + +### Phase 2: Dynamic Tool Registration +1. Implement `tools/list_changed` notification after auth +2. Add tool filtering based on auth state +3. Update inventory to support dynamic registration + +### Phase 3: Polish & Documentation +1. Add comprehensive error messages +2. Update README with new usage +3. Add integration tests +4. Document OAuth App setup for enterprises + +## Usage Documentation + +### Quick Start (New Users) + +```jsonc +// VS Code settings.json or mcp.json +{ + "servers": { + "github": { + "command": "docker", + "args": ["run", "--rm", "-i", "ghcr.io/github/github-mcp-server", "stdio"], + "type": "stdio" + } + } +} +``` + +Then just ask your AI assistant to do something with GitHub - it will guide you through authentication! + +### Native Installation + +```bash +# Install +go install github.com/github/github-mcp-server/cmd/github-mcp-server@latest + +# Run (will prompt for auth on first GitHub operation) +github-mcp-server stdio +``` + +### Enterprise (GHES/GHEC) + +```jsonc +{ + "servers": { + "github": { + "command": "github-mcp-server", + "args": ["stdio", "--gh-host", "https://github.mycompany.com"], + "type": "stdio" + } + } +} +``` + +### With Pre-configured Token (Legacy/CI) + +```jsonc +{ + "servers": { + "github": { + "command": "github-mcp-server", + "args": ["stdio"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxx" + }, + "type": "stdio" + } + } +} +``` + +## Open Questions + +1. **OAuth App ownership** - Should GitHub provide a first-party OAuth App, or require users to create their own? +2. **Token refresh** - Should we support refresh tokens for longer sessions, or is re-auth acceptable? +3. **Scope customization** - Should users be able to request additional scopes via tool parameters? +4. **Persistent storage** - Should we support optional persistent token storage for non-Docker installs? + +## Appendix: Device Flow Sequence + +```mermaid +sequenceDiagram + participant User + participant Agent as AI Agent + participant MCP as MCP Server + participant GH as GitHub + + User->>Agent: "Create issue on my repo" + Agent->>MCP: tools/list + MCP-->>Agent: [auth_login, auth_verify] + + Agent->>MCP: tools/call auth_login + MCP->>GH: POST /login/device/code + GH-->>MCP: device_code, user_code, verification_uri + MCP-->>Agent: "Visit github.com/login/device, enter ABCD-1234" + + Agent->>User: "Please visit github.com/login/device and enter code ABCD-1234" + User->>GH: Opens browser, enters code, authorizes + + Agent->>MCP: tools/call auth_verify + MCP->>GH: POST /login/oauth/access_token (polling) + GH-->>MCP: access_token + MCP->>MCP: Initialize GitHub clients + MCP-->>Agent: notifications/tools/list_changed + MCP-->>Agent: "Authenticated as @username" + + Agent->>MCP: tools/list + MCP-->>Agent: [all tools now available] + Agent->>MCP: tools/call create_issue + MCP-->>Agent: Issue created! + Agent->>User: "Done! Created issue #123" +``` diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 9859e2e9..bd159233 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -67,6 +67,13 @@ 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 } // githubClients holds all the GitHub API clients created for a server instance. @@ -265,6 +272,136 @@ 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. +// After successful authentication via the auth tools, call OnAuthenticated to initialize +// GitHub clients and register all other tools. +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, nil) + + // Create the MCP server with capabilities advertised for dynamic tool registration + serverOpts := &mcp.ServerOptions{ + Instructions: "GitHub MCP Server - Authentication Required\n\nYou are not currently authenticated with GitHub. Use the auth_login tool to start the authentication process, then auth_verify to complete it.", + 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) + } + }) + + // Resolve enabled toolsets + enabledToolsets := resolveEnabledToolsets(authenticatedCfg) + + // Build and register the tool/resource/prompt inventory + inv := github.NewInventory(cfg.Translator). + WithDeprecatedAliases(github.DeprecatedToolAliases). + WithReadOnly(cfg.ReadOnly). + WithToolsets(enabledToolsets). + WithTools(github.CleanTools(cfg.EnabledTools)). + WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)). + Build() + + // Log how many tools we're about to register + availableTools := inv.AvailableTools(ctx) + if cfg.Logger != nil { + cfg.Logger.Info("registering tools after authentication", "count", len(availableTools)) + } + + // Register all GitHub tools/resources/prompts + inv.RegisterAll(ctx, ghServer, deps) + + // Register dynamic toolset management tools if enabled + if cfg.DynamicToolsets { + registerDynamicTools(ghServer, inv, deps, cfg.Translator) + } + + if cfg.Logger != nil { + cfg.Logger.Info("authentication complete, tools registered", "toolCount", len(availableTools)) + } + + return nil + }, + OnAuthComplete: func() { + // Remove auth_login tool now that authentication is complete + 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 +449,13 @@ 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 } // RunStdioServer is not concurrent safe. @@ -338,23 +482,52 @@ 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, + // 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 00000000..6abf6d2b --- /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 00000000..347f3269 --- /dev/null +++ b/pkg/github/auth.go @@ -0,0 +1,400 @@ +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) + if err != nil || u.Hostname() == "" { + // Fallback: treat as hostname directly (shouldn't happen with scheme added) + return OAuthHost{ + DeviceCodeURL: fmt.Sprintf("https://%s/login/device/code", hostname), + TokenURL: fmt.Sprintf("https://%s/login/oauth/access_token", hostname), + Hostname: hostname, + } + } + + // 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. +// This OAuth App is registered by GitHub for use with this server. +// 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. +// currently a testing app. +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 { + result := "" + for i, s := range scopes { + if i > 0 { + result += " " + } + result += s + } + return result +} diff --git a/pkg/github/auth_test.go b/pkg/github/auth_test.go new file mode 100644 index 00000000..75432c5b --- /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 00000000..aced38d7 --- /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 most likely: +- Create and manage repositories +- Work with issues and pull requests +- Access your organizations and teams +- And much more, depending on configuration (mention the success and new tools to the user! + +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 00000000..69c3d012 --- /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) +}