From 7df5377a841875f16197d5b57b618399716ffacf Mon Sep 17 00:00:00 2001 From: Denis Gukov Date: Sat, 30 Aug 2025 21:30:44 +0500 Subject: [PATCH] Add MCP server and router --- README.md | 15 ++++++ api/api_test.go | 1 + api/mcp/mcp_test.go | 110 +++++++++++++++++++++++++++++++++++++++++ api/mcp/router.go | 15 ++++++ api/router.go | 7 +++ cli/cmd/root.go | 14 ++++++ cli/cmd/server.go | 10 +++- services/mcp/server.go | 92 ++++++++++++++++++++++++++++++++++ util/config.go | 3 ++ 9 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 api/mcp/mcp_test.go create mode 100644 api/mcp/router.go create mode 100644 services/mcp/server.go diff --git a/README.md b/README.md index f9b76037d..7f9924f39 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,21 @@ For more installation options, visit our [Installation page](https://semaphoreui * [API Reference](https://semaphoreui.com/api-docs) * [Postman Collection](https://www.postman.com/semaphoreui) +## MCP Server + +Semaphore includes an optional [Model Context Protocol](https://modelcontextprotocol.io) server that allows tools to interact with your projects programmatically. Enable it with: + +``` +semaphore server --mcp-enabled +``` + +The server exposes a WebSocket endpoint at `/mcp/ws` where clients can perform operations such as listing projects or triggering tasks: + +``` +{"command":"handshake"} +{"command":"list_projects"} +``` + ## Awesome Semaphore A curated list of awesome things related to Semaphore UI. diff --git a/api/api_test.go b/api/api_test.go index 0b183b5db..30105abf4 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -28,6 +28,7 @@ func TestApiPing(t *testing.T) { nil, nil, nil, + nil, ) r.ServeHTTP(rr, req) diff --git a/api/mcp/mcp_test.go b/api/mcp/mcp_test.go new file mode 100644 index 000000000..e6e26138c --- /dev/null +++ b/api/mcp/mcp_test.go @@ -0,0 +1,110 @@ +package mcp + +import ( + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/gorilla/websocket" + + "github.com/semaphoreui/semaphore/db" + mcpservice "github.com/semaphoreui/semaphore/services/mcp" +) + +type mockProjectStore struct { + projects []db.Project +} + +func (m *mockProjectStore) GetAllProjects() ([]db.Project, error) { + return m.projects, nil +} + +type mockTaskPool struct{} + +func (m *mockTaskPool) AddTask(task db.Task, userID *int, username string, projectID int, needAlias bool) (db.Task, error) { + task.ID = 1 + return task, nil +} + +func setupServer(t *testing.T) (*websocket.Conn, func()) { + store := &mockProjectStore{projects: []db.Project{{ID: 1, Name: "demo"}}} + pool := &mockTaskPool{} + srv := mcpservice.NewServer(store, pool) + r := mux.NewRouter() + Route(r, srv) + ts := httptest.NewServer(r) + wsURL := "ws" + ts.URL[len("http"):len(ts.URL)] + "/mcp/ws" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + cleanup := func() { conn.Close(); ts.Close() } + return conn, cleanup +} + +func TestHandshakeAndListProjects(t *testing.T) { + conn, cleanup := setupServer(t) + defer cleanup() + + if err := conn.WriteJSON(map[string]string{"command": "handshake"}); err != nil { + t.Fatalf("write handshake: %v", err) + } + var resp map[string]interface{} + if err := conn.ReadJSON(&resp); err != nil { + t.Fatalf("read handshake: %v", err) + } + if resp["status"] != "ok" { + t.Fatalf("handshake failed: %v", resp) + } + + if err := conn.WriteJSON(map[string]string{"command": "list_projects"}); err != nil { + t.Fatalf("write list: %v", err) + } + var list struct { + Projects []db.Project `json:"projects"` + } + if err := conn.ReadJSON(&list); err != nil { + t.Fatalf("read list: %v", err) + } + if len(list.Projects) != 1 || list.Projects[0].Name != "demo" { + t.Fatalf("unexpected projects: %+v", list.Projects) + } +} + +func TestTriggerTask(t *testing.T) { + conn, cleanup := setupServer(t) + defer cleanup() + _ = conn.WriteJSON(map[string]string{"command": "handshake"}) + _ = conn.ReadJSON(&map[string]interface{}{}) + + if err := conn.WriteJSON(map[string]interface{}{"command": "trigger_task", "project_id": 1, "template_id": 2}); err != nil { + t.Fatalf("write trigger: %v", err) + } + var resp struct { + TaskID int `json:"task_id"` + } + if err := conn.ReadJSON(&resp); err != nil { + t.Fatalf("read trigger: %v", err) + } + if resp.TaskID != 1 { + t.Fatalf("unexpected task id: %d", resp.TaskID) + } +} + +func TestUnknownCommand(t *testing.T) { + conn, cleanup := setupServer(t) + defer cleanup() + _ = conn.WriteJSON(map[string]string{"command": "handshake"}) + _ = conn.ReadJSON(&map[string]interface{}{}) + + if err := conn.WriteJSON(map[string]string{"command": "bad"}); err != nil { + t.Fatalf("write bad: %v", err) + } + var resp map[string]interface{} + if err := conn.ReadJSON(&resp); err != nil { + t.Fatalf("read bad: %v", err) + } + if resp["error"] != "unknown_command" { + t.Fatalf("unexpected response: %v", resp) + } +} diff --git a/api/mcp/router.go b/api/mcp/router.go new file mode 100644 index 000000000..02269686d --- /dev/null +++ b/api/mcp/router.go @@ -0,0 +1,15 @@ +package mcp + +import ( + "github.com/gorilla/mux" + mcpservice "github.com/semaphoreui/semaphore/services/mcp" +) + +// Route mounts MCP handlers under /mcp. +func Route(r *mux.Router, srv *mcpservice.Server) { + if srv == nil { + return + } + sub := r.PathPrefix("/mcp").Subrouter() + sub.HandleFunc("/ws", srv.ServeWS).Methods("GET", "HEAD") +} diff --git a/api/router.go b/api/router.go index 73fb18ad5..a93138144 100644 --- a/api/router.go +++ b/api/router.go @@ -15,6 +15,7 @@ import ( proApi "github.com/semaphoreui/semaphore/pro/api" proProjects "github.com/semaphoreui/semaphore/pro/api/projects" proFeatures "github.com/semaphoreui/semaphore/pro/pkg/features" + mcpService "github.com/semaphoreui/semaphore/services/mcp" "github.com/semaphoreui/semaphore/services/server" taskServices "github.com/semaphoreui/semaphore/services/tasks" @@ -26,6 +27,7 @@ import ( "github.com/gorilla/mux" "github.com/semaphoreui/semaphore/api/helpers" + mcpapi "github.com/semaphoreui/semaphore/api/mcp" "github.com/semaphoreui/semaphore/api/projects" "github.com/semaphoreui/semaphore/api/sockets" "github.com/semaphoreui/semaphore/api/tasks" @@ -95,6 +97,7 @@ func Route( accessKeyService server.AccessKeyService, environmentService server.EnvironmentService, subscriptionService pro_interfaces.SubscriptionService, + mcpServer *mcpService.Server, ) *mux.Router { projectController := &projects.ProjectController{ProjectService: projectService} @@ -506,6 +509,10 @@ func Route( projectIntegrationsAPI.HandleFunc("/{integration_id}/values/{value_id}", projects.DeleteIntegrationExtractValue).Methods("DELETE") projectIntegrationsAPI.HandleFunc("/{integration_id}/values/{value_id}/refs", projects.GetIntegrationExtractValueRefs).Methods("GET") + if mcpServer != nil { + mcpapi.Route(r, mcpServer) + } + if os.Getenv("DEBUG") == "1" { defer debugPrintRoutes(r) } diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 0d5e5d23c..7f8867dab 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -10,6 +10,8 @@ import ( "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/services/server" + mcpService "github.com/semaphoreui/semaphore/services/mcp" + "github.com/gorilla/handlers" "github.com/semaphoreui/semaphore/api" "github.com/semaphoreui/semaphore/api/sockets" @@ -73,6 +75,12 @@ func Execute() { func runService() { store := createStore("root") + + util.Config.MCPEnabled = serverFlags.mcpEnabled + if serverFlags.mcpPort != "" { + util.Config.MCPPort = serverFlags.mcpPort + } + state := proTasks.NewTaskStateStore() terraformStore := proFactory.NewTerraformStore(store) ansibleTaskRepo := proFactory.NewAnsibleTaskRepository(store) @@ -131,6 +139,11 @@ func runService() { go schedulePool.Run() go taskPool.Run() + var mcpServer *mcpService.Server + if util.Config.MCPEnabled { + mcpServer = mcpService.NewServer(store, &taskPool) + } + route := api.Route( store, terraformStore, @@ -144,6 +157,7 @@ func runService() { accessKeyService, environmentService, subscriptionService, + mcpServer, ) route.Use(func(next http.Handler) http.Handler { diff --git a/cli/cmd/server.go b/cli/cmd/server.go index b9c23e4d4..258a29015 100644 --- a/cli/cmd/server.go +++ b/cli/cmd/server.go @@ -1,13 +1,21 @@ package cmd import ( - "github.com/spf13/cobra" "net/http" "strings" + + "github.com/spf13/cobra" ) +var serverFlags struct { + mcpEnabled bool + mcpPort string +} + func init() { rootCmd.AddCommand(serverCmd) + serverCmd.PersistentFlags().BoolVar(&serverFlags.mcpEnabled, "mcp-enabled", false, "Enable MCP server") + serverCmd.PersistentFlags().StringVar(&serverFlags.mcpPort, "mcp-port", "", "Port for MCP server") } var serverCmd = &cobra.Command{ diff --git a/services/mcp/server.go b/services/mcp/server.go new file mode 100644 index 000000000..f389c94cc --- /dev/null +++ b/services/mcp/server.go @@ -0,0 +1,92 @@ +package mcp + +import ( + "net/http" + + "github.com/gorilla/websocket" + "github.com/semaphoreui/semaphore/db" +) + +// ProjectService defines the methods required to list projects. +type ProjectService interface { + GetAllProjects() ([]db.Project, error) +} + +// TaskService defines the methods required to trigger tasks. +type TaskService interface { + AddTask(task db.Task, userID *int, username string, projectID int, needAlias bool) (db.Task, error) +} + +// Server implements a minimal Model Context Protocol server. +type Server struct { + projects ProjectService + tasks TaskService +} + +// NewServer creates a new MCP server. +func NewServer(projects ProjectService, tasks TaskService) *Server { + return &Server{projects: projects, tasks: tasks} +} + +// request represents a client command. +type request struct { + Command string `json:"command"` + ProjectID int `json:"project_id,omitempty"` + TemplateID int `json:"template_id,omitempty"` +} + +// response is sent back to the client. +type response struct { + Command string `json:"command,omitempty"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + Projects []db.Project `json:"projects,omitempty"` + TaskID int `json:"task_id,omitempty"` +} + +var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + +// ServeWS upgrades the connection to WebSocket and handles MCP commands. +func (s *Server) ServeWS(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer conn.Close() + + handshaked := false + for { + var req request + if err := conn.ReadJSON(&req); err != nil { + return + } + + if !handshaked && req.Command != "handshake" { + _ = conn.WriteJSON(response{Error: "handshake_required"}) + continue + } + + switch req.Command { + case "handshake": + handshaked = true + _ = conn.WriteJSON(response{Command: "handshake", Status: "ok"}) + case "list_projects": + projects, err := s.projects.GetAllProjects() + if err != nil { + _ = conn.WriteJSON(response{Command: "list_projects", Error: err.Error()}) + continue + } + _ = conn.WriteJSON(response{Command: "list_projects", Projects: projects}) + case "trigger_task": + task, err := s.tasks.AddTask(db.Task{TemplateID: req.TemplateID}, nil, "", req.ProjectID, false) + if err != nil { + _ = conn.WriteJSON(response{Command: "trigger_task", Error: err.Error()}) + continue + } + _ = conn.WriteJSON(response{Command: "trigger_task", TaskID: task.ID, Status: "queued"}) + default: + _ = conn.WriteJSON(response{Error: "unknown_command"}) + } + } +} diff --git a/util/config.go b/util/config.go index d12774a63..9e4a99b9c 100644 --- a/util/config.go +++ b/util/config.go @@ -299,6 +299,9 @@ type ConfigType struct { UseRemoteRunner bool `json:"use_remote_runner,omitempty" env:"SEMAPHORE_USE_REMOTE_RUNNER"` + MCPEnabled bool `json:"mcp_enabled,omitempty" env:"SEMAPHORE_MCP_ENABLED"` + MCPPort string `json:"mcp_port,omitempty" env:"SEMAPHORE_MCP_PORT"` + IntegrationAlias string `json:"global_integration_alias,omitempty" env:"SEMAPHORE_INTEGRATION_ALIAS"` Apps map[string]App `json:"apps,omitempty" env:"SEMAPHORE_APPS"`