feat: implement OAuth device flow authentication
When the server starts without a GITHUB_PERSONAL_ACCESS_TOKEN, it now starts in 'unauthenticated mode' with only an auth_login tool available. The auth_login tool: - Initiates the OAuth device flow with GitHub - Uses MCP URL elicitation to show the verification URL and user code - Polls for completion while showing progress notifications - Upon success, dynamically registers all configured GitHub tools This enables a much simpler setup experience - users no longer need to pre-configure a PAT. They can simply start the server and authenticate interactively when prompted. Key changes: - New AuthManager in pkg/github/auth.go handles device flow state - New auth_login tool in pkg/github/auth_tools.go - NewUnauthenticatedMCPServer in internal/ghmcp/server.go for token-less startup - CLI flags --oauth-client-id and --oauth-client-secret for enterprise scenarios - Support for github.com, GHES, and GHEC hosts The token is held in memory for the session duration - no persistent storage, which is ideal for Docker --rm workflows. Closes #132
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
+190
-17
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user