Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 266 additions & 0 deletions cmd/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
package cmd

import (
"bufio"
"fmt"
"os"
"strings"

"github.com/mark3labs/mcphost/internal/auth"
"github.com/spf13/cobra"
)

var authCmd = &cobra.Command{
Use: "auth",
Short: "Manage authentication credentials for AI providers",
Long: `Manage authentication credentials for AI providers.

This command allows you to securely authenticate and manage credentials for various AI providers
using OAuth flows. Stored credentials take precedence over environment variables.

Available providers:
- anthropic: Anthropic Claude API (OAuth)

Examples:
mcphost auth login anthropic
mcphost auth logout anthropic
mcphost auth status`,
}

var authLoginCmd = &cobra.Command{
Use: "login [provider]",
Short: "Authenticate with an AI provider using OAuth",
Long: `Authenticate with an AI provider using OAuth flow.

This will open your browser to complete the OAuth authentication process.
Your credentials will be securely stored and will take precedence over
environment variables when making API calls.

Available providers:
- anthropic: Anthropic Claude API (OAuth)

Example:
mcphost auth login anthropic`,
Args: cobra.ExactArgs(1),
RunE: runAuthLogin,
}

var authLogoutCmd = &cobra.Command{
Use: "logout [provider]",
Short: "Remove stored authentication credentials for a provider",
Long: `Remove stored authentication credentials for an AI provider.

This will delete the stored API key for the specified provider. You will need
to use environment variables or command-line flags for authentication after logout.

Available providers:
- anthropic: Anthropic Claude API

Example:
mcphost auth logout anthropic`,
Args: cobra.ExactArgs(1),
RunE: runAuthLogout,
}

var authStatusCmd = &cobra.Command{
Use: "status",
Short: "Show authentication status for all providers",
Long: `Show the current authentication status for all supported AI providers.

This command displays which providers have stored credentials and when they were created.
It does not display the actual API keys for security reasons.

Example:
mcphost auth status`,
RunE: runAuthStatus,
}

func init() {
authCmd.AddCommand(authLoginCmd)
authCmd.AddCommand(authLogoutCmd)
authCmd.AddCommand(authStatusCmd)
}

func runAuthLogin(cmd *cobra.Command, args []string) error {
provider := strings.ToLower(args[0])

switch provider {
case "anthropic":
return loginAnthropic()
default:
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic", provider)
}
}

func runAuthLogout(cmd *cobra.Command, args []string) error {
provider := strings.ToLower(args[0])

switch provider {
case "anthropic":
return logoutAnthropic()
default:
return fmt.Errorf("unsupported provider: %s. Available providers: anthropic", provider)
}
}

func runAuthStatus(cmd *cobra.Command, args []string) error {
cm, err := auth.NewCredentialManager()
if err != nil {
return fmt.Errorf("failed to initialize credential manager: %w", err)
}

fmt.Println("Authentication Status")
fmt.Println("====================")
fmt.Printf("Credentials file: %s\n\n", cm.GetCredentialsPath())

// Check Anthropic credentials
fmt.Print("Anthropic Claude: ")
if hasAnthropicCreds, err := cm.HasAnthropicCredentials(); err != nil {
fmt.Printf("Error checking credentials: %v\n", err)
} else if hasAnthropicCreds {
if creds, err := cm.GetAnthropicCredentials(); err != nil {
fmt.Printf("Error reading credentials: %v\n", err)
} else {
authType := "API Key"
status := "✓ Authenticated"

if creds.Type == "oauth" {
authType = "OAuth"
if creds.IsExpired() {
status = "⚠️ Token expired (will refresh automatically)"
} else if creds.NeedsRefresh() {
status = "⚠️ Token expires soon (will refresh automatically)"
}
}

fmt.Printf("%s (%s, stored %s)\n", status, authType, creds.CreatedAt.Format("2006-01-02 15:04:05"))
}
} else {
fmt.Println("✗ Not authenticated")
// Check if environment variable is set
if os.Getenv("ANTHROPIC_API_KEY") != "" {
fmt.Println(" (ANTHROPIC_API_KEY environment variable is set)")
}
}

fmt.Println("\nTo authenticate with a provider:")
fmt.Println(" mcphost auth login anthropic")

return nil
}

func loginAnthropic() error {
cm, err := auth.NewCredentialManager()
if err != nil {
return fmt.Errorf("failed to initialize credential manager: %w", err)
}

// Check if already authenticated
if hasAuth, err := cm.HasAnthropicCredentials(); err == nil && hasAuth {
fmt.Print("You are already authenticated with Anthropic. Do you want to re-authenticate? (y/N): ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Authentication cancelled.")
return nil
}
}

// Create OAuth client
client := auth.NewOAuthClient()

// Generate authorization URL
fmt.Println("🔐 Starting OAuth authentication with Anthropic...")
authData, err := client.GetAuthorizationURL()
if err != nil {
return fmt.Errorf("failed to generate authorization URL: %w", err)
}

// Display URL and try to open browser
fmt.Println("\n📱 Opening your browser for authentication...")
fmt.Println("If the browser doesn't open automatically, please visit this URL:")
fmt.Printf("\n%s\n\n", authData.URL)

// Try to open browser
auth.TryOpenBrowser(authData.URL)

// Wait for user to complete OAuth flow
fmt.Println("After authorizing the application, you'll receive an authorization code.")
fmt.Print("Please enter the authorization code: ")

reader := bufio.NewReader(os.Stdin)
code, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read authorization code: %w", err)
}
code = strings.TrimSpace(code)

if code == "" {
return fmt.Errorf("authorization code cannot be empty")
}

// Exchange code for tokens
fmt.Println("\n🔄 Exchanging authorization code for access token...")
creds, err := client.ExchangeCode(code, authData.Verifier)
if err != nil {
return fmt.Errorf("failed to exchange authorization code: %w", err)
}

// Store the credentials
if err := cm.SetOAuthCredentials(creds); err != nil {
return fmt.Errorf("failed to store credentials: %w", err)
}

fmt.Println("✅ Successfully authenticated with Anthropic!")
fmt.Printf("📁 Credentials stored in: %s\n", cm.GetCredentialsPath())
fmt.Println("\n🎉 Your OAuth credentials will now be used for Anthropic API calls.")
fmt.Println("💡 You can check your authentication status with: mcphost auth status")

return nil
}

func logoutAnthropic() error {
cm, err := auth.NewCredentialManager()
if err != nil {
return fmt.Errorf("failed to initialize credential manager: %w", err)
}

// Check if authenticated
hasAuth, err := cm.HasAnthropicCredentials()
if err != nil {
return fmt.Errorf("failed to check authentication status: %w", err)
}

if !hasAuth {
fmt.Println("You are not currently authenticated with Anthropic.")
return nil
}

// Confirm logout
fmt.Print("Are you sure you want to remove your Anthropic credentials? (y/N): ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}

response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Logout cancelled.")
return nil
}

// Remove credentials
if err := cm.RemoveAnthropicCredentials(); err != nil {
return fmt.Errorf("failed to remove credentials: %w", err)
}

fmt.Println("✓ Successfully logged out from Anthropic!")
fmt.Println("You will need to use environment variables or command-line flags for authentication.")

return nil
}
15 changes: 14 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/cloudwego/eino/schema"
"github.com/mark3labs/mcphost/internal/agent"
"github.com/mark3labs/mcphost/internal/auth"
"github.com/mark3labs/mcphost/internal/config"
"github.com/mark3labs/mcphost/internal/models"
"github.com/mark3labs/mcphost/internal/tokens"
Expand Down Expand Up @@ -183,6 +184,9 @@ func init() {
viper.BindPFlag("main-gpu", rootCmd.PersistentFlags().Lookup("main-gpu"))

// Defaults are already set in flag definitions, no need to duplicate in viper

// Add subcommands
rootCmd.AddCommand(authCmd)
}

func runMCPHost(ctx context.Context) error {
Expand Down Expand Up @@ -303,7 +307,16 @@ func runNormalMode(ctx context.Context) error {
if provider != "ollama" {
registry := models.GetGlobalRegistry()
if modelInfo, err := registry.ValidateModel(provider, modelID); err == nil {
usageTracker := ui.NewUsageTracker(modelInfo, provider, 80) // Will be updated with actual width
// Check if OAuth credentials are being used for Anthropic models
isOAuth := false
if provider == "anthropic" {
_, source, err := auth.GetAnthropicAPIKey(viper.GetString("provider-api-key"))
if err == nil && strings.HasPrefix(source, "stored OAuth") {
isOAuth = true
}
}

usageTracker := ui.NewUsageTracker(modelInfo, provider, 80, isOAuth) // Will be updated with actual width
cli.SetUsageTracker(usageTracker)
}
}
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ go 1.23.4
toolchain go1.23.9

require (
github.com/JohannesKaufmann/html-to-markdown v1.6.0
github.com/PuerkitoBio/goquery v1.10.3
github.com/bytedance/sonic v1.13.3
github.com/charmbracelet/huh v0.3.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
Expand All @@ -27,8 +29,6 @@ require (
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.15.0 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
github.com/JohannesKaufmann/html-to-markdown v1.6.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/anthropics/anthropic-sdk-go v0.2.0-alpha.8 // indirect
Expand Down
6 changes: 3 additions & 3 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250605072634-0f875e04269d
github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250605072634-0f875e04269d/go.mod h1:XKWiwOSkHWN23yd14et8A9khSKSYACU0xu4caySDA5U=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down Expand Up @@ -274,8 +273,10 @@ github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtIS
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
Expand All @@ -294,8 +295,6 @@ github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
Expand Down Expand Up @@ -479,6 +478,7 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Expand Down
31 changes: 31 additions & 0 deletions internal/auth/browser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package auth

import (
"fmt"
"os/exec"
"runtime"
)

// OpenBrowser opens the default browser to the specified URL
func OpenBrowser(url string) error {
var err error

switch runtime.GOOS {
case "linux":
err = exec.Command("xdg-open", url).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
default:
err = fmt.Errorf("unsupported platform")
}

return err
}

// TryOpenBrowser attempts to open the browser but doesn't fail if it can't
func TryOpenBrowser(url string) {
// Silently ignore errors - user can still copy/paste the URL
_ = OpenBrowser(url)
}
Loading