summaryrefslogtreecommitdiff
path: root/internal/hexaimcp/run.go
blob: 74eb47655856142ed37d1fc718887d1c847d90a1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
// Package hexaimcp is the MCP server orchestrator; loads config, sets up store, and runs server.
package hexaimcp

import (
	"fmt"
	"io"
	"log"
	"os"
	"path/filepath"
	"strings"

	"codeberg.org/snonux/hexai/internal/appconfig"
	"codeberg.org/snonux/hexai/internal/mcp"
	"codeberg.org/snonux/hexai/internal/promptstore"
	"codeberg.org/snonux/hexai/internal/slashcommands"
)

// MCPOverrides holds CLI flag values that override config settings.
// These are passed explicitly from the CLI entrypoint instead of using
// environment variables, avoiding the code smell of os.Setenv in production code.
type MCPOverrides struct {
	PromptsDir       string
	SlashCommandSync bool
	SlashCommandDir  string
}

// ServerRunner interface allows dependency injection for testing.
type ServerRunner interface {
	Run() error
}

// ServerFactory creates a server instance (testable).
type ServerFactory func(
	r io.Reader,
	w io.Writer,
	logger *log.Logger,
	store promptstore.PromptStore,
	syncer mcp.SlashCommandSyncer,
) ServerRunner

// defaultServerFactory is the production server factory.
func defaultServerFactory(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore, syncer mcp.SlashCommandSyncer) ServerRunner {
	return mcp.NewServer(r, w, logger, store, syncer)
}

// Run starts the MCP server with the given configuration and overrides.
// This is the main entry point called from cmd/hexai-mcp-server/main.go.
func Run(logPath, configPath string, overrides MCPOverrides, stdin io.Reader, stdout io.Writer, stderr io.Writer) error {
	return RunWithFactory(logPath, configPath, overrides, stdin, stdout, stderr, defaultServerFactory)
}

// RunWithFactory allows test injection of server factory.
// Overrides are applied to the loaded config before use, allowing CLI flags
// to take precedence over config file and environment variable settings.
func RunWithFactory(
	logPath string,
	configPath string,
	overrides MCPOverrides,
	stdin io.Reader,
	stdout io.Writer,
	stderr io.Writer,
	factory ServerFactory,
) error {
	// Setup logger
	logger, err := setupLogger(logPath)
	if err != nil {
		return fmt.Errorf("cannot setup logger: %w", err)
	}
	defer func() {
		if f, ok := logger.Writer().(*os.File); ok && f != os.Stderr {
			f.Close()
		}
	}()

	logger.Printf("hexai-mcp-server starting")
	logger.Printf("WARNING: hexai-mcp-server is DEPRECATED and experimental - not actively maintained")

	// Load configuration and apply CLI overrides
	cfg := loadConfig(logger, configPath)
	applyOverrides(&cfg, overrides)

	return runServer(cfg, logger, stdin, stdout, factory)
}

// runServer creates the prompt store, syncer, and runs the MCP server.
func runServer(cfg appconfig.App, logger *log.Logger, stdin io.Reader, stdout io.Writer, factory ServerFactory) error {
	// Determine prompts directory from config (overrides already applied)
	promptsDir, err := getPromptsDir(cfg)
	if err != nil {
		return fmt.Errorf("cannot determine prompts directory: %w", err)
	}
	logger.Printf("using prompts directory: %s", promptsDir)

	// Create prompt store
	store, err := promptstore.NewJSONLStore(promptsDir)
	if err != nil {
		return fmt.Errorf("cannot create prompt store: %w", err)
	}

	// Create slash command syncer (optional)
	syncer, err := createSyncer(cfg, logger)
	if err != nil {
		return fmt.Errorf("cannot create syncer: %w", err)
	}

	// Create and run server
	server := factory(stdin, stdout, logger, store, syncer)
	if err := server.Run(); err != nil {
		return fmt.Errorf("server error: %w", err)
	}

	logger.Printf("hexai-mcp-server exiting")
	return nil
}

// setupLogger creates a logger that writes to the specified log file.
// If logPath is empty, logs to stderr.
func setupLogger(logPath string) (*log.Logger, error) {
	logPath = strings.TrimSpace(logPath)
	if logPath == "" {
		return log.New(os.Stderr, "mcp ", log.LstdFlags), nil
	}

	// Ensure log directory exists
	logDir := filepath.Dir(logPath)
	if err := os.MkdirAll(logDir, 0o755); err != nil {
		return nil, fmt.Errorf("cannot create log directory: %w", err)
	}

	f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return nil, fmt.Errorf("cannot open log file: %w", err)
	}

	return log.New(f, "mcp ", log.LstdFlags), nil
}

// loadConfig loads the hexai configuration.
// Returns default config if loading fails.
func loadConfig(logger *log.Logger, configPath string) appconfig.App {
	opts := appconfig.LoadOptions{
		ConfigPath: configPath,
		IgnoreEnv:  false,
	}
	return appconfig.LoadWithOptions(logger, opts)
}

// applyOverrides applies CLI flag overrides to the loaded config.
// This replaces the previous approach of using os.Setenv to pass values.
func applyOverrides(cfg *appconfig.App, overrides MCPOverrides) {
	if overrides.PromptsDir != "" {
		cfg.MCPPromptsDir = overrides.PromptsDir
	}
	if overrides.SlashCommandSync {
		cfg.MCPSlashCommandSync = true
	}
	if overrides.SlashCommandDir != "" {
		cfg.MCPSlashCommandDir = overrides.SlashCommandDir
	}
}

// getPromptsDir determines the prompts directory from config.
// Precedence: CLI flag (via overrides applied to config) > env var (via
// applyMCPEnv in config loading) > config file > default XDG location.
// The env var HEXAI_MCP_PROMPTS_DIR is still supported through the config
// loading pipeline in appconfig, not read directly here.
func getPromptsDir(cfg appconfig.App) (string, error) {
	// Check config (which already includes env var and CLI overrides)
	if cfgDir := strings.TrimSpace(cfg.MCPPromptsDir); cfgDir != "" {
		return expandPath(cfgDir)
	}

	// Default: $XDG_DATA_HOME/prompts/ or ~/.local/hexai/data/prompts/
	dataDir := os.Getenv("XDG_DATA_HOME")
	if dataDir == "" {
		home, err := os.UserHomeDir()
		if err != nil {
			return "", fmt.Errorf("cannot find user home directory: %w", err)
		}
		dataDir = filepath.Join(home, ".local", "hexai", "data")
	}

	return filepath.Join(dataDir, "prompts"), nil
}

// expandPath expands ~ to home directory and returns absolute path.
func expandPath(path string) (string, error) {
	if strings.HasPrefix(path, "~/") {
		home, err := os.UserHomeDir()
		if err != nil {
			return "", fmt.Errorf("cannot find user home directory: %w", err)
		}
		path = filepath.Join(home, path[2:])
	}

	return filepath.Abs(path)
}

// createSyncer creates a slash command syncer from config.
// Returns nil syncer if sync is disabled.
func createSyncer(cfg appconfig.App, logger *log.Logger) (*slashcommands.Syncer, error) {
	syncer, err := slashcommands.NewSyncer(cfg)
	if err != nil {
		return nil, err
	}

	if syncer != nil && cfg.MCPSlashCommandSync {
		logger.Printf("slash command sync enabled: %s", cfg.MCPSlashCommandDir)
	}

	return syncer, nil
}

// RunBackfill performs a one-time sync of all prompts and exits.
// Overrides are applied to the loaded config before use.
func RunBackfill(logPath, configPath string, overrides MCPOverrides) error {
	logger, err := setupLogger(logPath)
	if err != nil {
		return fmt.Errorf("cannot setup logger: %w", err)
	}
	defer func() {
		if f, ok := logger.Writer().(*os.File); ok && f != os.Stderr {
			f.Close()
		}
	}()

	logger.Printf("hexai-mcp-server backfill starting")

	// Load configuration and apply CLI overrides
	cfg := loadConfig(logger, configPath)
	applyOverrides(&cfg, overrides)

	// Force enable sync for backfill
	if cfg.MCPSlashCommandDir == "" {
		return fmt.Errorf("commands directory not configured (use --slashcommand-dir)")
	}
	cfg.MCPSlashCommandSync = true

	return executeBackfill(cfg, logger)
}

// executeBackfill creates the syncer, store, and performs the backfill sync.
func executeBackfill(cfg appconfig.App, logger *log.Logger) error {
	syncer, err := createSyncer(cfg, logger)
	if err != nil {
		return fmt.Errorf("cannot create syncer: %w", err)
	}

	promptsDir, err := getPromptsDir(cfg)
	if err != nil {
		return fmt.Errorf("cannot determine prompts directory: %w", err)
	}

	store, err := promptstore.NewJSONLStore(promptsDir)
	if err != nil {
		return fmt.Errorf("cannot create prompt store: %w", err)
	}

	logger.Printf("starting backfill sync...")
	if err := syncer.SyncAll(store); err != nil {
		return fmt.Errorf("backfill failed: %w", err)
	}

	logger.Printf("backfill complete")
	return nil
}