summaryrefslogtreecommitdiff
path: root/internal/hexaimcp
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-12 09:32:26 +0200
committerPaul Buetow <paul@buetow.org>2026-02-12 09:32:26 +0200
commitcfd02d2874992f7e293d5098bd328a495825a8d4 (patch)
treebb241a61ce35c717c16539ab5d4413264514168d /internal/hexaimcp
parent0cd9db181218eaf0fb1ec1cddcd83035d984e94c (diff)
feat: add automatic MCP prompt to slash command syncing
Adds optional syncing of MCP prompts to Markdown slash command files for AI agents that don't yet support MCP prompts (e.g., Cursor IDE). Features: - Syncs prompts on create/update/delete operations - Configurable via TOML config, environment vars, or CLI flags - Backfill support with --sync-all flag - Thread-safe atomic file writes - Non-fatal sync failures (logged but don't break operations) - Comprehensive test coverage (81.1% total) Configuration: - Config: [mcp] slashcommand_sync = true, slashcommand_dir = "~/.cursor/commands" - Env: HEXAI_MCP_SLASHCOMMAND_SYNC, HEXAI_MCP_SLASHCOMMAND_DIR - CLI: --slashcommand-sync, --slashcommand-dir, --sync-all Fixes config merging bug where project config would reset global MCP settings. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal/hexaimcp')
-rw-r--r--internal/hexaimcp/run.go75
-rw-r--r--internal/hexaimcp/run_test.go11
2 files changed, 78 insertions, 8 deletions
diff --git a/internal/hexaimcp/run.go b/internal/hexaimcp/run.go
index 6b28a2a..5f687f1 100644
--- a/internal/hexaimcp/run.go
+++ b/internal/hexaimcp/run.go
@@ -12,6 +12,7 @@ import (
"codeberg.org/snonux/hexai/internal/appconfig"
"codeberg.org/snonux/hexai/internal/mcp"
"codeberg.org/snonux/hexai/internal/promptstore"
+ "codeberg.org/snonux/hexai/internal/slashcommands"
)
// ServerRunner interface allows dependency injection for testing.
@@ -25,11 +26,12 @@ type ServerFactory func(
w io.Writer,
logger *log.Logger,
store promptstore.PromptStore,
+ syncer *slashcommands.Syncer,
) ServerRunner
// defaultServerFactory is the production server factory.
-func defaultServerFactory(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore) ServerRunner {
- return mcp.NewServer(r, w, logger, store)
+func defaultServerFactory(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore, syncer *slashcommands.Syncer) ServerRunner {
+ return mcp.NewServer(r, w, logger, store, syncer)
}
// Run starts the MCP server with the given configuration.
@@ -76,8 +78,14 @@ func RunWithFactory(
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)
+ server := factory(stdin, stdout, logger, store, syncer)
if err := server.Run(); err != nil {
return fmt.Errorf("server error: %w", err)
}
@@ -156,3 +164,64 @@ func expandPath(path string) (string, error) {
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.
+func RunBackfill(logPath, configPath string) 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")
+
+ cfg := loadConfig(logger, configPath)
+
+ // Force enable sync for backfill
+ if cfg.MCPSlashCommandDir == "" {
+ return fmt.Errorf("commands directory not configured (use --slashcommand-dir)")
+ }
+ cfg.MCPSlashCommandSync = true
+
+ 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
+}
diff --git a/internal/hexaimcp/run_test.go b/internal/hexaimcp/run_test.go
index 2adf678..794fa1f 100644
--- a/internal/hexaimcp/run_test.go
+++ b/internal/hexaimcp/run_test.go
@@ -15,6 +15,7 @@ import (
"codeberg.org/snonux/hexai/internal/appconfig"
"codeberg.org/snonux/hexai/internal/mcp"
"codeberg.org/snonux/hexai/internal/promptstore"
+ "codeberg.org/snonux/hexai/internal/slashcommands"
)
// mockServerRunner implements ServerRunner for testing
@@ -34,8 +35,8 @@ func TestFullProtocolFlow(t *testing.T) {
tmpDir := t.TempDir()
// Create test server factory
- serverFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore) ServerRunner {
- return mcp.NewServer(r, w, logger, store)
+ serverFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore, syncer *slashcommands.Syncer) ServerRunner {
+ return mcp.NewServer(r, w, logger, store, syncer)
}
// Setup I/O pipes
@@ -271,7 +272,7 @@ func TestDefaultServerFactory(t *testing.T) {
t.Fatalf("NewJSONLStore() error = %v", err)
}
- server := defaultServerFactory(inBuf, outBuf, logger, store)
+ server := defaultServerFactory(inBuf, outBuf, logger, store, nil)
if server == nil {
t.Fatal("defaultServerFactory() returned nil")
}
@@ -282,7 +283,7 @@ func TestRun(t *testing.T) {
logPath := filepath.Join(tmpDir, "test.log")
// Create a mock server factory that returns immediately
- mockFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore) ServerRunner {
+ mockFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore, syncer *slashcommands.Syncer) ServerRunner {
return &mockServerRunner{
runFunc: func() error {
return nil // Exit immediately
@@ -315,7 +316,7 @@ func TestRunWithFactory_ServerError(t *testing.T) {
logPath := filepath.Join(tmpDir, "test.log")
// Create a mock server factory that returns an error
- mockFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore) ServerRunner {
+ mockFactory := func(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore, syncer *slashcommands.Syncer) ServerRunner {
return &mockServerRunner{
runFunc: func() error {
return fmt.Errorf("mock server error")