set_token.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package mcp
  6
  7import (
  8	"errors"
  9	"fmt"
 10	"io"
 11
 12	"git.secluded.site/lune/internal/config"
 13	"git.secluded.site/lune/internal/mcp/auth"
 14	"git.secluded.site/lune/internal/ui"
 15	"github.com/charmbracelet/huh"
 16	"github.com/spf13/cobra"
 17)
 18
 19var (
 20	errTokenMismatch = errors.New("tokens do not match")
 21	errTokenEmpty    = errors.New("token cannot be empty")
 22)
 23
 24var setTokenCmd = &cobra.Command{
 25	Use:   "set-token",
 26	Short: "Set authentication token for the MCP server",
 27	Long: `Set a Bearer token for authenticating MCP server requests.
 28
 29When a token hash is configured, SSE and HTTP transports require clients
 30to provide the token via the Authorization header:
 31
 32  Authorization: Bearer <token>
 33
 34The token is hashed using argon2id before being stored in the config file.
 35Only the hash is stored, not the plaintext token.
 36
 37Note: stdio transport does not use authentication (local processes only).`,
 38	RunE: runSetToken,
 39}
 40
 41func init() {
 42	Cmd.AddCommand(setTokenCmd)
 43}
 44
 45func runSetToken(cmd *cobra.Command, _ []string) error {
 46	out := cmd.OutOrStdout()
 47
 48	cfg, err := loadOrCreateConfig()
 49	if err != nil {
 50		return err
 51	}
 52
 53	if cfg.MCP.TokenHash != "" {
 54		proceed, confirmErr := confirmTokenReplace(out)
 55		if confirmErr != nil {
 56			return confirmErr
 57		}
 58
 59		if !proceed {
 60			return nil
 61		}
 62	}
 63
 64	token, err := promptForMCPToken()
 65	if err != nil {
 66		if errors.Is(err, huh.ErrUserAborted) {
 67			return nil
 68		}
 69
 70		return err
 71	}
 72
 73	hash, err := auth.Hash(token)
 74	if err != nil {
 75		return fmt.Errorf("hashing token: %w", err)
 76	}
 77
 78	cfg.MCP.TokenHash = hash
 79
 80	if err := cfg.Save(); err != nil {
 81		return fmt.Errorf("saving config: %w", err)
 82	}
 83
 84	fmt.Fprintln(out, ui.Success.Render("MCP authentication token configured."))
 85	fmt.Fprintln(out, "Restart the MCP server for changes to take effect.")
 86
 87	return nil
 88}
 89
 90func loadOrCreateConfig() (*config.Config, error) {
 91	cfg, err := config.Load()
 92	if err != nil {
 93		if errors.Is(err, config.ErrNotFound) {
 94			return &config.Config{}, nil
 95		}
 96
 97		return nil, fmt.Errorf("loading config: %w", err)
 98	}
 99
100	return cfg, nil
101}
102
103func confirmTokenReplace(out io.Writer) (bool, error) {
104	fmt.Fprintln(out, ui.Warning.Render("An authentication token is already configured."))
105	fmt.Fprintln(out, "Setting a new token will replace the existing one.")
106	fmt.Fprintln(out)
107
108	var proceed bool
109
110	err := huh.NewConfirm().
111		Title("Replace existing token?").
112		Affirmative("Yes").
113		Negative("No").
114		Value(&proceed).
115		Run()
116	if err != nil {
117		if errors.Is(err, huh.ErrUserAborted) {
118			return false, nil
119		}
120
121		return false, err
122	}
123
124	return proceed, nil
125}
126
127func promptForMCPToken() (string, error) {
128	var token, confirm string
129
130	err := huh.NewForm(
131		huh.NewGroup(
132			huh.NewInput().
133				Title("MCP Authentication Token").
134				Description("Enter a token to protect your MCP server.").
135				EchoMode(huh.EchoModePassword).
136				Value(&token).
137				Validate(func(input string) error {
138					if input == "" {
139						return errTokenEmpty
140					}
141
142					return nil
143				}),
144			huh.NewInput().
145				Title("Confirm Token").
146				Description("Re-enter the token to confirm.").
147				EchoMode(huh.EchoModePassword).
148				Value(&confirm).
149				Validate(func(input string) error {
150					if input != token {
151						return errTokenMismatch
152					}
153
154					return nil
155				}),
156		),
157	).Run()
158	if err != nil {
159		return "", err
160	}
161
162	return token, nil
163}