Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func TestApiPing(t *testing.T) {
nil,
nil,
nil,
nil,
)

r.ServeHTTP(rr, req)
Expand Down
110 changes: 110 additions & 0 deletions api/mcp/mcp_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
15 changes: 15 additions & 0 deletions api/mcp/router.go
Original file line number Diff line number Diff line change
@@ -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")
}
7 changes: 7 additions & 0 deletions api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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)
Comment on lines +512 to +513

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P0] Gate MCP websocket behind authentication

The new MCP route is mounted directly on the root router without any of the existing authentication or store middlewares, so /mcp/ws is publicly reachable by anyone who can hit the server. Because services/mcp/server.go exposes operations like list_projects and trigger_task using real services, an unauthenticated client can enumerate projects and enqueue tasks. This bypasses all permission checks and effectively gives remote callers full task execution rights; the handler should be placed under the authenticated API subrouter or otherwise enforce authentication before accepting commands.

Useful? React with 👍 / 👎.

}

if os.Getenv("DEBUG") == "1" {
defer debugPrintRoutes(r)
}
Expand Down
14 changes: 14 additions & 0 deletions cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -144,6 +157,7 @@ func runService() {
accessKeyService,
environmentService,
subscriptionService,
mcpServer,
)

route.Use(func(next http.Handler) http.Handler {
Expand Down
10 changes: 9 additions & 1 deletion cli/cmd/server.go
Original file line number Diff line number Diff line change
@@ -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{
Expand Down
92 changes: 92 additions & 0 deletions services/mcp/server.go
Original file line number Diff line number Diff line change
@@ -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"})
}
}
}
3 changes: 3 additions & 0 deletions util/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading