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}