summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/hexaimcp/run.go64
-rw-r--r--internal/hexaimcp/run_test.go133
2 files changed, 123 insertions, 74 deletions
diff --git a/internal/hexaimcp/run.go b/internal/hexaimcp/run.go
index b6fed8c..eb4fe83 100644
--- a/internal/hexaimcp/run.go
+++ b/internal/hexaimcp/run.go
@@ -15,6 +15,15 @@ import (
"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
@@ -34,16 +43,19 @@ func defaultServerFactory(r io.Reader, w io.Writer, logger *log.Logger, store pr
return mcp.NewServer(r, w, logger, store, syncer)
}
-// Run starts the MCP server with the given configuration.
+// 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, stdin io.Reader, stdout io.Writer, stderr io.Writer) error {
- return RunWithFactory(logPath, configPath, stdin, stdout, stderr, defaultServerFactory)
+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,
@@ -63,10 +75,16 @@ func RunWithFactory(
logger.Printf("hexai-mcp-server starting")
logger.Printf("WARNING: hexai-mcp-server is DEPRECATED and experimental - not actively maintained")
- // Load configuration
+ // Load configuration and apply CLI overrides
cfg := loadConfig(logger, configPath)
+ applyOverrides(&cfg, overrides)
+
+ return runServer(cfg, logger, stdin, stdout, factory)
+}
- // Determine prompts directory
+// 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)
@@ -127,15 +145,27 @@ func loadConfig(logger *log.Logger, configPath string) appconfig.App {
return appconfig.LoadWithOptions(logger, opts)
}
-// getPromptsDir determines the prompts directory from config or environment.
-// Precedence: CLI flag (via config) > env var > config file > default XDG location.
-func getPromptsDir(cfg appconfig.App) (string, error) {
- // Check environment variable first
- if envDir := strings.TrimSpace(os.Getenv("HEXAI_MCP_PROMPTS_DIR")); envDir != "" {
- return expandPath(envDir)
+// 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
+ }
+}
- // Check config file
+// 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)
}
@@ -182,7 +212,8 @@ func createSyncer(cfg appconfig.App, logger *log.Logger) (*slashcommands.Syncer,
}
// RunBackfill performs a one-time sync of all prompts and exits.
-func RunBackfill(logPath, configPath string) error {
+// 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)
@@ -195,7 +226,9 @@ func RunBackfill(logPath, configPath string) error {
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 == "" {
@@ -203,6 +236,11 @@ func RunBackfill(logPath, configPath string) error {
}
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)
diff --git a/internal/hexaimcp/run_test.go b/internal/hexaimcp/run_test.go
index 7883efd..dac6542 100644
--- a/internal/hexaimcp/run_test.go
+++ b/internal/hexaimcp/run_test.go
@@ -62,11 +62,11 @@ func TestFullProtocolFlow(t *testing.T) {
// Run server in background (it will read from inBuf and write to outBuf)
go func() {
- // Override prompts dir via environment
- t.Setenv("HEXAI_MCP_PROMPTS_DIR", tmpDir)
+ // Pass prompts dir via overrides instead of environment variable
+ overrides := MCPOverrides{PromptsDir: tmpDir}
// Note: This will hang waiting for more input, which is expected
- _ = RunWithFactory("", "", inBuf, outBuf, errBuf, serverFactory)
+ _ = RunWithFactory("", "", overrides, inBuf, outBuf, errBuf, serverFactory)
}()
// Give server time to process
@@ -94,25 +94,16 @@ func writeJSONRPC(t *testing.T, w io.Writer, req map[string]any) {
func TestGetPromptsDir(t *testing.T) {
tests := []struct {
name string
- envVar string
cfgValue string
wantMatch string
}{
{
- name: "environment variable takes precedence",
- envVar: "/custom/prompts",
- cfgValue: "/config/prompts",
- wantMatch: "/custom/prompts",
- },
- {
- name: "config file used when no env",
- envVar: "",
+ name: "config value used",
cfgValue: "/config/prompts",
wantMatch: "/config/prompts",
},
{
name: "uses default XDG location",
- envVar: "",
cfgValue: "",
wantMatch: ".local/hexai/data/prompts",
},
@@ -120,17 +111,10 @@ func TestGetPromptsDir(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- // Setup environment
- oldEnv := os.Getenv("HEXAI_MCP_PROMPTS_DIR")
- defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldEnv)
- os.Setenv("HEXAI_MCP_PROMPTS_DIR", tt.envVar)
-
- // Create config
cfg := appconfig.App{
MCPPromptsDir: tt.cfgValue,
}
- // Test
result, err := getPromptsDir(cfg)
if err != nil {
t.Fatalf("getPromptsDir() error = %v", err)
@@ -294,12 +278,10 @@ func TestRun(t *testing.T) {
outBuf := &bytes.Buffer{}
errBuf := &bytes.Buffer{}
- // Set prompts dir environment variable
- oldEnv := os.Getenv("HEXAI_MCP_PROMPTS_DIR")
- defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldEnv)
- os.Setenv("HEXAI_MCP_PROMPTS_DIR", tmpDir)
+ // Pass prompts dir via overrides instead of environment variable
+ overrides := MCPOverrides{PromptsDir: tmpDir}
- err := RunWithFactory(logPath, "", inBuf, outBuf, errBuf, mockFactory)
+ err := RunWithFactory(logPath, "", overrides, inBuf, outBuf, errBuf, mockFactory)
if err != nil {
t.Fatalf("RunWithFactory() error = %v", err)
}
@@ -327,12 +309,10 @@ func TestRunWithFactory_ServerError(t *testing.T) {
outBuf := &bytes.Buffer{}
errBuf := &bytes.Buffer{}
- // Set prompts dir environment variable
- oldEnv := os.Getenv("HEXAI_MCP_PROMPTS_DIR")
- defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldEnv)
- os.Setenv("HEXAI_MCP_PROMPTS_DIR", tmpDir)
+ // Pass prompts dir via overrides instead of environment variable
+ overrides := MCPOverrides{PromptsDir: tmpDir}
- err := RunWithFactory(logPath, "", inBuf, outBuf, errBuf, mockFactory)
+ err := RunWithFactory(logPath, "", overrides, inBuf, outBuf, errBuf, mockFactory)
if err == nil {
t.Fatal("RunWithFactory() expected error, got nil")
}
@@ -351,7 +331,7 @@ func TestRunWithFactory_LoggerError(t *testing.T) {
return &mockServerRunner{}
}
- err := RunWithFactory(badLogPath, "", &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}, mockFactory)
+ err := RunWithFactory(badLogPath, "", MCPOverrides{}, &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}, mockFactory)
if err == nil {
t.Fatal("expected error for invalid log path, got nil")
}
@@ -369,12 +349,11 @@ func TestRunWithFactory_StderrLogger(t *testing.T) {
return &mockServerRunner{}
}
- oldEnv := os.Getenv("HEXAI_MCP_PROMPTS_DIR")
- defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldEnv)
- os.Setenv("HEXAI_MCP_PROMPTS_DIR", tmpDir)
+ // Pass prompts dir via overrides instead of environment variable
+ overrides := MCPOverrides{PromptsDir: tmpDir}
// Empty logPath causes logger to write to stderr (no file to close)
- err := RunWithFactory("", "", &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}, mockFactory)
+ err := RunWithFactory("", "", overrides, &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}, mockFactory)
if err != nil {
t.Fatalf("RunWithFactory() error = %v", err)
}
@@ -387,13 +366,12 @@ func TestRun_CallsDefaultFactory(t *testing.T) {
tmpDir := t.TempDir()
logPath := filepath.Join(tmpDir, "test.log")
- oldEnv := os.Getenv("HEXAI_MCP_PROMPTS_DIR")
- defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldEnv)
- os.Setenv("HEXAI_MCP_PROMPTS_DIR", tmpDir)
+ // Pass prompts dir via overrides instead of environment variable
+ overrides := MCPOverrides{PromptsDir: tmpDir}
// Run with empty stdin — the real server hits EOF and exits cleanly.
// This exercises the full Run -> RunWithFactory -> defaultServerFactory path.
- err := Run(logPath, "", &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
+ err := Run(logPath, "", overrides, &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
// The server may return nil or an error depending on how it handles EOF;
// the important thing is that Run() itself does not panic.
_ = err
@@ -427,10 +405,6 @@ func TestSetupLogger_WhitespacePath(t *testing.T) {
// TestGetPromptsDir_XDGDataHome verifies getPromptsDir uses XDG_DATA_HOME
// when set (covers the branch where XDG_DATA_HOME is non-empty).
func TestGetPromptsDir_XDGDataHome(t *testing.T) {
- oldPrompts := os.Getenv("HEXAI_MCP_PROMPTS_DIR")
- defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldPrompts)
- os.Setenv("HEXAI_MCP_PROMPTS_DIR", "")
-
oldXDG := os.Getenv("XDG_DATA_HOME")
defer os.Setenv("XDG_DATA_HOME", oldXDG)
os.Setenv("XDG_DATA_HOME", "/custom/xdg/data")
@@ -449,10 +423,6 @@ func TestGetPromptsDir_XDGDataHome(t *testing.T) {
// TestGetPromptsDir_TildeInConfig verifies tilde expansion for config path.
func TestGetPromptsDir_TildeInConfig(t *testing.T) {
- oldPrompts := os.Getenv("HEXAI_MCP_PROMPTS_DIR")
- defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldPrompts)
- os.Setenv("HEXAI_MCP_PROMPTS_DIR", "")
-
cfg := appconfig.App{
MCPPromptsDir: "~/my-prompts",
}
@@ -538,11 +508,6 @@ func TestRunBackfill_FullHappyPath(t *testing.T) {
t.Fatalf("cannot create prompts dir: %v", err)
}
- // Set environment to control prompts and slash command directories
- oldPrompts := os.Getenv("HEXAI_MCP_PROMPTS_DIR")
- defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldPrompts)
- os.Setenv("HEXAI_MCP_PROMPTS_DIR", promptsDir)
-
// Write a config file with [mcp] section that sets the slash command dir
cfgContent := fmt.Sprintf("[mcp]\nslashcommand_dir = %q\nslashcommand_sync = true\n", cmdDir)
cfgPath := filepath.Join(tmpDir, "config.toml")
@@ -550,9 +515,12 @@ func TestRunBackfill_FullHappyPath(t *testing.T) {
t.Fatalf("cannot write config: %v", err)
}
+ // Pass prompts dir via overrides instead of environment variable
+ overrides := MCPOverrides{PromptsDir: promptsDir}
+
// RunBackfill should succeed: config sets MCPSlashCommandDir, prompts
// dir exists, and SyncAll on an empty store is a no-op.
- err := RunBackfill(logPath, cfgPath)
+ err := RunBackfill(logPath, cfgPath, overrides)
if err != nil {
t.Fatalf("RunBackfill() error = %v", err)
}
@@ -578,7 +546,7 @@ func TestRunBackfill_CreateSyncerError(t *testing.T) {
t.Fatalf("cannot write config: %v", err)
}
- err := RunBackfill(logPath, cfgPath)
+ err := RunBackfill(logPath, cfgPath, MCPOverrides{})
if err == nil {
t.Fatal("expected error for invalid slash command dir, got nil")
}
@@ -598,18 +566,17 @@ func TestRunBackfill_StderrLogger(t *testing.T) {
t.Fatalf("cannot create prompts dir: %v", err)
}
- oldPrompts := os.Getenv("HEXAI_MCP_PROMPTS_DIR")
- defer os.Setenv("HEXAI_MCP_PROMPTS_DIR", oldPrompts)
- os.Setenv("HEXAI_MCP_PROMPTS_DIR", promptsDir)
-
cfgContent := fmt.Sprintf("[mcp]\nslashcommand_dir = %q\n", cmdDir)
cfgPath := filepath.Join(tmpDir, "config.toml")
if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil {
t.Fatalf("cannot write config: %v", err)
}
+ // Pass prompts dir via overrides instead of environment variable
+ overrides := MCPOverrides{PromptsDir: promptsDir}
+
// Empty logPath — logger writes to stderr, defer close is a no-op
- err := RunBackfill("", cfgPath)
+ err := RunBackfill("", cfgPath, overrides)
if err != nil {
t.Fatalf("RunBackfill() error = %v", err)
}
@@ -618,7 +585,7 @@ func TestRunBackfill_StderrLogger(t *testing.T) {
// TestRunBackfill_LoggerError verifies RunBackfill returns an error when
// the log path is invalid.
func TestRunBackfill_LoggerError(t *testing.T) {
- err := RunBackfill("/dev/null/impossible/test.log", "")
+ err := RunBackfill("/dev/null/impossible/test.log", "", MCPOverrides{})
if err == nil {
t.Fatal("expected error for invalid log path, got nil")
}
@@ -646,7 +613,7 @@ func TestRunBackfill_NoCmdDir(t *testing.T) {
defer os.Setenv("HEXAI_MCP_SLASHCOMMAND_DIR", oldEnv)
os.Setenv("HEXAI_MCP_SLASHCOMMAND_DIR", "")
- err := RunBackfill(logPath, emptyCfgPath)
+ err := RunBackfill(logPath, emptyCfgPath, MCPOverrides{})
if err == nil {
t.Fatal("expected error for empty slash command dir, got nil")
}
@@ -654,3 +621,47 @@ func TestRunBackfill_NoCmdDir(t *testing.T) {
t.Errorf("error = %v, want to contain 'commands directory not configured'", err)
}
}
+
+// TestApplyOverrides verifies that MCPOverrides are correctly applied to config.
+func TestApplyOverrides(t *testing.T) {
+ t.Run("applies all overrides", func(t *testing.T) {
+ cfg := appconfig.App{}
+ overrides := MCPOverrides{
+ PromptsDir: "/custom/prompts",
+ SlashCommandSync: true,
+ SlashCommandDir: "/custom/cmds",
+ }
+ applyOverrides(&cfg, overrides)
+
+ if cfg.MCPPromptsDir != "/custom/prompts" {
+ t.Errorf("MCPPromptsDir = %q, want /custom/prompts", cfg.MCPPromptsDir)
+ }
+ if !cfg.MCPSlashCommandSync {
+ t.Error("MCPSlashCommandSync = false, want true")
+ }
+ if cfg.MCPSlashCommandDir != "/custom/cmds" {
+ t.Errorf("MCPSlashCommandDir = %q, want /custom/cmds", cfg.MCPSlashCommandDir)
+ }
+ })
+
+ t.Run("does not overwrite with zero values", func(t *testing.T) {
+ cfg := appconfig.App{
+ MCPPromptsDir: "/existing/prompts",
+ MCPSlashCommandSync: true,
+ MCPSlashCommandDir: "/existing/cmds",
+ }
+ overrides := MCPOverrides{} // all zero values
+ applyOverrides(&cfg, overrides)
+
+ if cfg.MCPPromptsDir != "/existing/prompts" {
+ t.Errorf("MCPPromptsDir = %q, want /existing/prompts", cfg.MCPPromptsDir)
+ }
+ // SlashCommandSync false doesn't overwrite existing true
+ if !cfg.MCPSlashCommandSync {
+ t.Error("MCPSlashCommandSync should remain true")
+ }
+ if cfg.MCPSlashCommandDir != "/existing/cmds" {
+ t.Errorf("MCPSlashCommandDir = %q, want /existing/cmds", cfg.MCPSlashCommandDir)
+ }
+ })
+}