Skip to content
Closed
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
218 changes: 218 additions & 0 deletions cmd/mcp_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package cmd

import (
"context"
"errors"
"fmt"
"html/template"
"net"
"net/http"
"sort"
"strings"
"time"

"github.com/samsaffron/term-llm/internal/mcp"
mcpoauth "github.com/samsaffron/term-llm/internal/mcp/oauth"
internalauth "github.com/samsaffron/term-llm/internal/oauth"
"github.com/spf13/cobra"
)

var (
mcpLoginForce bool
mcpLoginNoBrowser bool
mcpLogoutLocal bool
)

var mcpLoginCmd = &cobra.Command{
Use: "login <name>",
Short: "Sign in to a remote MCP server",
Args: cobra.ExactArgs(1),
ValidArgsFunction: MCPServerArgCompletion,
RunE: mcpLogin,
}

var mcpStatusCmd = &cobra.Command{
Use: "status [name]",
Short: "Show MCP transport and authentication status",
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: MCPServerArgCompletion,
RunE: mcpStatus,
}

var mcpLogoutCmd = &cobra.Command{
Use: "logout <name>",
Short: "Revoke and remove an MCP OAuth grant",
Args: cobra.ExactArgs(1),
ValidArgsFunction: MCPServerArgCompletion,
RunE: mcpLogout,
}

func init() {
mcpLoginCmd.Flags().BoolVar(&mcpLoginForce, "force", false, "Sign in again even when the current grant is valid")
mcpLoginCmd.Flags().BoolVar(&mcpLoginNoBrowser, "no-browser", false, "Print the authorization URL without opening a browser")
mcpLogoutCmd.Flags().BoolVar(&mcpLogoutLocal, "local-only", false, "Remove local credentials without remote revocation")
mcpCmd.AddCommand(mcpLoginCmd, mcpStatusCmd, mcpLogoutCmd)
}

func loadMCPAuthManager() (*mcp.Manager, error) {
manager := mcp.NewManager()
if err := manager.LoadConfig(); err != nil {
return nil, fmt.Errorf("load MCP config: %w", err)
}
return manager, nil
}

func mcpLogin(cmd *cobra.Command, args []string) error {
name := args[0]
manager, err := loadMCPAuthManager()
if err != nil {
return err
}
statuses := manager.AuthStatuses()
status, ok := statuses[name]
if !ok {
return fmt.Errorf("unknown MCP server: %s", name)
}
if status.State == mcpoauth.AuthNotNeeded {
return fmt.Errorf("MCP server %s does not use automatic OAuth", name)
}
if status.State == mcpoauth.AuthSignedIn && !mcpLoginForce {
fmt.Fprintln(cmd.OutOrStdout(), "Already signed in")
return nil
}

listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("start OAuth callback listener: %w", err)
}
defer listener.Close()
redirectURL := "http://" + listener.Addr().String() + "/callback"
server := &http.Server{ReadHeaderTimeout: 5 * time.Second}
server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/callback" {
http.NotFound(w, r)
return
}
_, accepted := mcpoauth.DefaultCoordinator().CompleteCallback(
r.URL.Query().Get("state"), r.URL.Query().Get("code"),
r.URL.Query().Get("iss"), r.URL.Query().Get("error"),
)
if !accepted {
http.Error(w, "This authorization callback is invalid, expired, or was already used.", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = template.Must(template.New("done").Parse(`<!doctype html><meta charset="utf-8"><title>Connected</title><main><h1>Connected</h1><p>You can close this window.</p></main>`)).Execute(w, nil)
})
go func() { _ = server.Serve(listener) }()
defer server.Shutdown(context.Background())

startCtx, cancel := context.WithTimeout(cmd.Context(), 45*time.Second)
defer cancel()
flow, err := manager.StartOAuth(startCtx, name, mcp.OAuthStartOptions{RedirectURL: redirectURL, Force: mcpLoginForce})
if err != nil {
return fmt.Errorf("start MCP sign-in: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "Open this URL to authorize %s:\n%s\n", name, flow.AuthorizationURL)
if !mcpLoginNoBrowser {
if err := internalauth.OpenBrowser(flow.AuthorizationURL); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Could not open a browser: %v\n", err)
}
}
fmt.Fprintln(cmd.OutOrStdout(), "Waiting for authorization…")

waitCtx, waitCancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer waitCancel()
completed, err := mcpoauth.DefaultCoordinator().Wait(waitCtx, flow.ID)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("authorization timed out")
}
return fmt.Errorf("wait for authorization: %w", err)
}
switch completed.State {
case mcpoauth.FlowSucceeded:
fmt.Fprintf(cmd.OutOrStdout(), "Signed in to %s\n", name)
return nil
case mcpoauth.FlowCanceled:
return fmt.Errorf("authorization canceled")
case mcpoauth.FlowExpired:
return fmt.Errorf("authorization timed out")
default:
if completed.Error == "" {
return fmt.Errorf("authorization failed")
}
return fmt.Errorf("authorization failed: %s", completed.Error)
}
}

func mcpStatus(cmd *cobra.Command, args []string) error {
manager, err := loadMCPAuthManager()
if err != nil {
return err
}
statuses := manager.AuthStatuses()
names := manager.AvailableServers()
if len(args) == 1 {
if _, ok := statuses[args[0]]; !ok {
return fmt.Errorf("unknown MCP server: %s", args[0])
}
names = []string{args[0]}
}
sort.Strings(names)
for i, name := range names {
cfg := manager.Config().Servers[name]
status := statuses[name]
if i > 0 {
fmt.Fprintln(cmd.OutOrStdout())
}
fmt.Fprintf(cmd.OutOrStdout(), "%s\n transport: %s\n authentication: %s\n", name, cfg.TransportType(), authStateLabel(status.State))
if status.Issuer != "" {
fmt.Fprintf(cmd.OutOrStdout(), " issuer: %s\n", status.Issuer)
}
if len(status.Scopes) > 0 {
fmt.Fprintf(cmd.OutOrStdout(), " scopes: %s\n", strings.Join(status.Scopes, " "))
}
if !status.ExpiresAt.IsZero() {
fmt.Fprintf(cmd.OutOrStdout(), " expires: %s\n", status.ExpiresAt.Local().Format(time.RFC3339))
}
if status.StoragePath != "" {
fmt.Fprintf(cmd.OutOrStdout(), " storage: %s\n", status.StoragePath)
}
}
return nil
}

func authStateLabel(state mcpoauth.AuthState) string {
switch state {
case mcpoauth.AuthNotNeeded:
return "not needed"
case mcpoauth.AuthSignedIn:
return "signed in"
case mcpoauth.AuthExpired:
return "expired (refreshable)"
case mcpoauth.AuthRequired:
return "needs sign-in"
case mcpoauth.AuthWaiting:
return "waiting for browser"
case mcpoauth.AuthRetry:
return "temporary refresh failure (retry)"
default:
return "signed out"
}
}

func mcpLogout(cmd *cobra.Command, args []string) error {
manager, err := loadMCPAuthManager()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
defer cancel()
if err := manager.LogoutOAuth(ctx, args[0], mcpLogoutLocal); err != nil {
return fmt.Errorf("sign out of MCP server: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "Signed out of %s\n", args[0])
return nil
}
21 changes: 21 additions & 0 deletions cmd/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,27 @@ import (
"github.com/spf13/cobra"
)

func TestMCPStatusReportsStaticAuthWithoutSecrets(t *testing.T) {
configHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
cfg := &mcp.Config{Servers: map[string]mcp.ServerConfig{
"static": {Type: "http", URL: "https://mcp.example/mcp", Headers: map[string]string{"Authorization": "Bearer must-not-print"}},
}}
if err := cfg.Save(); err != nil {
t.Fatal(err)
}
var output bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&output)
if err := mcpStatus(cmd, []string{"static"}); err != nil {
t.Fatal(err)
}
got := output.String()
if !strings.Contains(got, "authentication: not needed") || strings.Contains(got, "must-not-print") {
t.Fatalf("status output = %q", got)
}
}

func TestMCPRunArgCompletionDoesNotStartServerOnCacheMiss(t *testing.T) {
configHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
Expand Down
36 changes: 36 additions & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"sync"
Expand Down Expand Up @@ -40,6 +41,7 @@ var (
serveAllowNoAuth bool
serveAuthMode string
serveBasePath string
servePublicURL string
serveTitle string
serveDisableLocationSharing bool
serveCORSOrigins []string
Expand Down Expand Up @@ -155,6 +157,7 @@ func init() {
_ = serveCmd.Flags().MarkHidden("allow-no-auth")
serveCmd.Flags().StringVar(&serveAuthMode, "auth", "bearer", "Auth mode: bearer or none")
serveCmd.Flags().StringVar(&serveBasePath, "base-path", "/ui", "URL prefix the UI uses for session URLs (e.g. /chat)")
serveCmd.Flags().StringVar(&servePublicURL, "public-url", "", "Browser-visible URL for OAuth callbacks (defaults to $TERM_LLM_SERVE_PUBLIC_URL or the authenticated request origin)")
serveCmd.Flags().StringVar(&serveTitle, "title", "", "Override the web UI sidebar title (defaults to agent name or Chat)")
serveCmd.Flags().BoolVar(&serveDisableLocationSharing, "disable-location-sharing", false, "Hide the web UI action for sharing the browser's current location")
serveCmd.Flags().StringArrayVar(&serveCORSOrigins, "cors-origin", nil, "Allowed CORS origin (repeatable, or '*' for all)")
Expand Down Expand Up @@ -387,6 +390,13 @@ func runServeLegacy(parentCtx context.Context, cmd *cobra.Command, args []string
if err != nil {
return err
}
if strings.TrimSpace(servePublicURL) == "" {
servePublicURL = strings.TrimSpace(os.Getenv("TERM_LLM_SERVE_PUBLIC_URL"))
}
servePublicURL, err = normalizeServePublicURL(servePublicURL)
if err != nil {
return fmt.Errorf("invalid serve --public-url: %w", err)
}

resolvedTitle := strings.TrimSpace(serveTitle)
if !cmd.Flags().Changed("title") {
Expand Down Expand Up @@ -747,6 +757,7 @@ func runServeLegacy(parentCtx context.Context, cmd *cobra.Command, args []string
suppressServerTools: serveFilterServerTools,
verbose: serveVerbose,
basePath: serveBasePath,
publicURL: servePublicURL,
uiTitle: resolvedTitle,
locationSharingDisabled: locationSharingDisabled,
sidebarSessions: append([]string(nil), sidebarSessions...),
Expand Down Expand Up @@ -1133,6 +1144,7 @@ type serveServerConfig struct {
suppressServerTools bool
verbose bool
basePath string // e.g. "/ui" or "/chat", always without trailing slash
publicURL string // explicit browser-visible origin + optional prefix for OAuth callbacks
uiTitle string
locationSharingDisabled bool
sidebarSessions []string
Expand Down Expand Up @@ -1246,6 +1258,26 @@ func resolveServeWriteDirs(cliWriteDirs []string, cfg *config.Config) []string {
return out
}

func normalizeServePublicURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
u, err := url.Parse(raw)
if err != nil {
return "", err
}
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
return "", fmt.Errorf("must be an http(s) URL with a host")
}
if u.RawQuery != "" || u.Fragment != "" {
return "", fmt.Errorf("must not contain a query or fragment")
}
u.Path = strings.TrimRight(u.Path, "/")
u.RawPath = ""
return strings.TrimRight(u.String(), "/"), nil
}

// normalizeBasePath validates and normalizes a base-path value.
// It ensures a leading slash, strips trailing slashes, and rejects
// empty or root-only paths (use the default "/ui" instead of "/").
Expand Down Expand Up @@ -1431,6 +1463,10 @@ func (s *serveServer) httpHandler() http.Handler {
inner.HandleFunc("/v1/sidebar", s.auth(s.cors(s.handleSidebar)))
inner.HandleFunc("/v1/events", s.auth(s.cors(s.handleEvents)))
inner.HandleFunc("/v1/events/poll", s.auth(s.cors(s.handleEventPoll)))
inner.HandleFunc("/v1/mcp/oauth/flows/", s.auth(s.cors(s.handleMCPOAuthFlow)))
// OAuth callbacks cannot carry the serve bearer token. A high-entropy,
// single-use SDK state value is the capability checked by this handler.
inner.HandleFunc("/v1/mcp/oauth/callback", s.handleMCPOAuthCallback)
inner.HandleFunc("/v1/sessions/status", s.auth(s.cors(s.handleSessionsStatus)))
inner.HandleFunc("/v1/sessions/search", s.auth(s.cors(s.handleSessionsSearch)))
inner.HandleFunc("/v1/worktrees/diff", s.auth(s.cors(s.handleWorktreeDiff)))
Expand Down
5 changes: 5 additions & 0 deletions cmd/serve_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2040,6 +2040,11 @@ func (s *serveServer) handleSessionByID(w http.ResponseWriter, r *http.Request)
return
}

if serverName, action, ok := parseSessionMCPOAuthSuffix(suffix); ok {
s.handleSessionMCPOAuth(w, r, sessionID, serverName, action)
return
}

if suffix == "mcp" {
if r.Method != http.MethodGet && r.Method != http.MethodPatch {
w.Header().Set("Allow", "GET, PATCH")
Expand Down
18 changes: 18 additions & 0 deletions cmd/serve_mcp_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/samsaffron/term-llm/internal/llm"
"github.com/samsaffron/term-llm/internal/mcp"
mcpoauth "github.com/samsaffron/term-llm/internal/mcp/oauth"
"github.com/samsaffron/term-llm/internal/session"
"github.com/samsaffron/term-llm/internal/tooldiscovery"
)
Expand All @@ -30,6 +31,12 @@ type serveMCPServerView struct {
Deferred int `json:"deferred,omitempty"`
LoadingMode string `json:"loading_mode,omitempty"`
LastRefresh time.Time `json:"last_refresh,omitempty"`
AuthState string `json:"auth_state"`
AuthIssuer string `json:"auth_issuer,omitempty"`
AuthScopes []string `json:"auth_scopes,omitempty"`
AuthExpiresAt time.Time `json:"auth_expires_at,omitempty"`
CanSignIn bool `json:"can_sign_in"`
CanSignOut bool `json:"can_sign_out"`
}

type serveMCPDiscoveryView struct {
Expand Down Expand Up @@ -161,6 +168,7 @@ func buildServeMCPState(manager *mcp.Manager, engine *llm.Engine, mcpSetting, se
enabled := normalizeMCPSelection(append(manager.EnabledServers(), parseServerList(mcpSetting)...))
enabledSet := stringSet(enabled)
states := make(map[string]mcp.ServerState)
authStatuses := manager.AuthStatuses()
for _, state := range manager.GetAllStates() {
states[state.Name] = state
}
Expand Down Expand Up @@ -195,6 +203,16 @@ func buildServeMCPState(manager *mcp.Manager, engine *llm.Engine, mcpSetting, se
if err != nil {
view.Error = err.Error()
}
if authStatus, ok := authStatuses[name]; ok {
view.AuthState = string(authStatus.State)
view.AuthIssuer = authStatus.Issuer
view.AuthScopes = append([]string(nil), authStatus.Scopes...)
view.AuthExpiresAt = authStatus.ExpiresAt
view.CanSignIn = authStatus.CanSignIn
view.CanSignOut = authStatus.CanSignOut
} else {
view.AuthState = string(mcpoauth.AuthNotNeeded)
}
if state, ok := states[name]; ok {
view.LastRefresh = state.LastToolRefresh
if state.RefreshError != nil {
Expand Down
Loading