summaryrefslogtreecommitdiff
path: root/internal/mcp
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/mcp
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/mcp')
-rw-r--r--internal/mcp/handlers_test.go2
-rw-r--r--internal/mcp/server.go53
-rw-r--r--internal/mcp/server_test.go12
3 files changed, 59 insertions, 8 deletions
diff --git a/internal/mcp/handlers_test.go b/internal/mcp/handlers_test.go
index 1c74f98..2a4f821 100644
--- a/internal/mcp/handlers_test.go
+++ b/internal/mcp/handlers_test.go
@@ -871,7 +871,7 @@ func TestServer_Run_InvalidJSON(t *testing.T) {
inBuf := &bytes.Buffer{}
outBuf := &bytes.Buffer{}
logger := log.New(io.Discard, "", 0)
- server := NewServer(inBuf, outBuf, logger, store)
+ server := NewServer(inBuf, outBuf, logger, store, nil)
// Write invalid JSON
msg := []byte(`{invalid json}`)
diff --git a/internal/mcp/server.go b/internal/mcp/server.go
index 58de01d..83f75e8 100644
--- a/internal/mcp/server.go
+++ b/internal/mcp/server.go
@@ -13,6 +13,7 @@ import (
"codeberg.org/snonux/hexai/internal"
"codeberg.org/snonux/hexai/internal/promptstore"
+ "codeberg.org/snonux/hexai/internal/slashcommands"
)
// Server implements an MCP server over stdio using JSON-RPC 2.0.
@@ -23,6 +24,7 @@ type Server struct {
outMu sync.Mutex
logger *log.Logger
store promptstore.PromptStore
+ syncer *slashcommands.Syncer
initialized bool
mu sync.RWMutex
@@ -32,12 +34,13 @@ type Server struct {
// NewServer creates a new MCP server with the given store and I/O streams.
// The store provides access to prompts; logger is used for debugging.
-func NewServer(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore) *Server {
+func NewServer(r io.Reader, w io.Writer, logger *log.Logger, store promptstore.PromptStore, syncer *slashcommands.Syncer) *Server {
s := &Server{
in: bufio.NewReader(r),
out: w,
logger: logger,
store: store,
+ syncer: syncer,
}
// Initialize dispatch table
@@ -354,6 +357,14 @@ func (s *Server) handlePromptsCreate(req Request) {
}
s.logger.Printf("created prompt: %s", params.Name)
+
+ // Sync to slash commands if enabled
+ if s.syncer != nil {
+ if err := s.syncer.Sync(prompt, slashcommands.OpCreate); err != nil {
+ s.logger.Printf("slash command sync failed: %v", err)
+ }
+ }
+
s.sendResponse(req.ID, result)
// Notify clients that the prompt list has changed
@@ -453,6 +464,14 @@ func (s *Server) handlePromptsUpdate(req Request) {
}
s.logger.Printf("updated prompt: %s", params.Name)
+
+ // Sync to slash commands if enabled
+ if s.syncer != nil {
+ if err := s.syncer.Sync(existing, slashcommands.OpUpdate); err != nil {
+ s.logger.Printf("slash command sync failed: %v", err)
+ }
+ }
+
s.sendResponse(req.ID, result)
// Notify clients that the prompt list has changed
@@ -531,6 +550,14 @@ func (s *Server) handlePromptsDelete(req Request) {
}
s.logger.Printf("deleted prompt: %s", params.Name)
+
+ // Delete slash command file if enabled
+ if s.syncer != nil {
+ if err := s.syncer.Delete(params.Name); err != nil {
+ s.logger.Printf("slash command sync delete failed: %v", err)
+ }
+ }
+
s.sendResponse(req.ID, result)
// Notify clients that the prompt list has changed
@@ -712,6 +739,14 @@ func (s *Server) callCreatePromptTool(id any, args map[string]interface{}) {
}
s.logger.Printf("created prompt via tool: %s", params.Name)
+
+ // Sync to slash commands if enabled
+ if s.syncer != nil {
+ if err := s.syncer.Sync(prompt, slashcommands.OpCreate); err != nil {
+ s.logger.Printf("slash command sync failed: %v", err)
+ }
+ }
+
s.sendToolSuccess(id, fmt.Sprintf("Successfully created prompt: %s", params.Name))
// Notify clients that the prompt list has changed
@@ -751,6 +786,14 @@ func (s *Server) callUpdatePromptTool(id any, args map[string]interface{}) {
}
s.logger.Printf("updated prompt via tool: %s", params.Name)
+
+ // Sync to slash commands if enabled
+ if s.syncer != nil {
+ if err := s.syncer.Sync(existing, slashcommands.OpUpdate); err != nil {
+ s.logger.Printf("slash command sync failed: %v", err)
+ }
+ }
+
s.sendToolSuccess(id, fmt.Sprintf("Successfully updated prompt: %s", params.Name))
// Notify clients that the prompt list has changed
@@ -774,6 +817,14 @@ func (s *Server) callDeletePromptTool(id any, args map[string]interface{}) {
}
s.logger.Printf("deleted prompt via tool: %s", name)
+
+ // Delete slash command file if enabled
+ if s.syncer != nil {
+ if err := s.syncer.Delete(name); err != nil {
+ s.logger.Printf("slash command sync delete failed: %v", err)
+ }
+ }
+
s.sendToolSuccess(id, fmt.Sprintf("Successfully deleted prompt: %s", name))
// Notify clients that the prompt list has changed
diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go
index 4b43f51..8e3d7b5 100644
--- a/internal/mcp/server_test.go
+++ b/internal/mcp/server_test.go
@@ -54,7 +54,7 @@ func createTestServer(t *testing.T, store promptstore.PromptStore) (*Server, *by
inBuf := &bytes.Buffer{}
outBuf := &bytes.Buffer{}
logger := log.New(io.Discard, "", 0)
- return NewServer(inBuf, outBuf, logger, store), inBuf, outBuf
+ return NewServer(inBuf, outBuf, logger, store, nil), inBuf, outBuf
}
// sendRequest writes a JSON-RPC request as newline-delimited JSON (MCP stdio protocol).
@@ -395,7 +395,7 @@ func TestServer_Run(t *testing.T) {
inBuf := &bytes.Buffer{}
outBuf := &bytes.Buffer{}
logger := log.New(io.Discard, "", 0)
- server := NewServer(inBuf, outBuf, logger, store)
+ server := NewServer(inBuf, outBuf, logger, store, nil)
err := server.Run()
if err != nil {
@@ -408,7 +408,7 @@ func TestServer_Run(t *testing.T) {
inBuf := &bytes.Buffer{}
outBuf := &bytes.Buffer{}
logger := log.New(io.Discard, "", 0)
- server := NewServer(inBuf, outBuf, logger, store)
+ server := NewServer(inBuf, outBuf, logger, store, nil)
// Send initialize request
req := Request{
@@ -465,7 +465,7 @@ func TestServer_ReadMessage(t *testing.T) {
inBuf := &bytes.Buffer{}
outBuf := &bytes.Buffer{}
logger := log.New(io.Discard, "", 0)
- server := NewServer(inBuf, outBuf, logger, store)
+ server := NewServer(inBuf, outBuf, logger, store, nil)
// Write a newline-delimited JSON message (MCP stdio protocol)
msg := `{"jsonrpc":"2.0","id":1,"method":"test"}`
@@ -487,7 +487,7 @@ func TestServer_ReadMessage(t *testing.T) {
inBuf := &bytes.Buffer{}
outBuf := &bytes.Buffer{}
logger := log.New(io.Discard, "", 0)
- server := NewServer(inBuf, outBuf, logger, store)
+ server := NewServer(inBuf, outBuf, logger, store, nil)
// Write empty lines followed by a valid message
msg := `{"jsonrpc":"2.0","id":1,"method":"test"}`
@@ -508,7 +508,7 @@ func TestServer_ReadMessage(t *testing.T) {
inBuf := &bytes.Buffer{}
outBuf := &bytes.Buffer{}
logger := log.New(io.Discard, "", 0)
- server := NewServer(inBuf, outBuf, logger, store)
+ server := NewServer(inBuf, outBuf, logger, store, nil)
_, err := server.readMessage()
if err != io.EOF {