From 21e410634864c20981b2e38fa8f89acd38ba52a6 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 20 Jul 2026 18:47:12 +0100 Subject: [PATCH 1/2] feat: Expose the assistant tool set over an MCP server FlatRun can now serve its assistant's full tool set to external MCP clients, so an IDE, desktop app, or another agent can drive the server through the same capabilities the built-in assistant has. The same tools back both front doors. Every MCP call is authenticated and runs as the calling actor, gated by the same per-tool permission and protected-mode checks the assistant enforces, so a caller can do exactly what their access allows and no more. The endpoint is off unless enabled in config. --- config.example.yml | 5 ++ go.mod | 8 ++- go.sum | 16 ++++- internal/api/mcp_server.go | 73 +++++++++++++++++++++ internal/api/mcp_server_test.go | 113 ++++++++++++++++++++++++++++++++ internal/api/server.go | 12 ++++ pkg/config/config.go | 8 +++ 7 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 internal/api/mcp_server.go create mode 100644 internal/api/mcp_server_test.go diff --git a/config.example.yml b/config.example.yml index 2d4acd3..0964b86 100644 --- a/config.example.yml +++ b/config.example.yml @@ -126,6 +126,11 @@ ai: api_key: your-gemini-api-key-here model: gemini-2.5-flash timeout: 1m0s +mcp: + # Exposes the assistant's tool set to external MCP clients at /api/mcp. Every + # call is authenticated and permission-gated exactly as the assistant is. + # Off unless enabled. + enabled: false backup: # Remote destinations that backups are mirrored to after the local copy is # written. The local copy under /.flatrun/backups always diff --git a/go.mod b/go.mod index 92bff46..94791b3 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.10.1 github.com/go-sql-driver/mysql v1.9.3 - github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/lib/pq v1.10.9 @@ -30,6 +30,7 @@ require ( github.com/testcontainers/testcontainers-go v0.41.0 github.com/testcontainers/testcontainers-go/modules/compose v0.41.0 github.com/whilesmartgo/agents v0.1.0 + github.com/whilesmartgo/mcp v0.2.0 go.opentelemetry.io/otel v1.41.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 @@ -110,6 +111,7 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -146,6 +148,7 @@ require ( github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect + github.com/modelcontextprotocol/go-sdk v1.6.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/morikuni/aec v1.1.0 // indirect @@ -162,6 +165,8 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shirou/gopsutil/v4 v4.26.2 // indirect github.com/sigstore/sigstore v1.10.4 // indirect @@ -181,6 +186,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect diff --git a/go.sum b/go.sum index da6510b..70b9e08 100644 --- a/go.sum +++ b/go.sum @@ -255,8 +255,8 @@ github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -272,6 +272,8 @@ github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE= github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= @@ -375,6 +377,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -438,6 +442,10 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+x github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/secure-systems-lab/go-securesystemslib v0.9.1 h1:nZZaNz4DiERIQguNy0cL5qTdn9lR8XKHf4RUyG1Sx3g= github.com/secure-systems-lab/go-securesystemslib v0.9.1/go.mod h1:np53YzT0zXGMv6x4iEWc9Z59uR+x+ndLwCLqPYpLXVU= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= @@ -512,8 +520,12 @@ github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CP github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/whilesmartgo/agents v0.1.0 h1:AX7Q5e6BY2Fb/1J7prtZgXSx9bsD8GRSDsmfTQ8kgxs= github.com/whilesmartgo/agents v0.1.0/go.mod h1:MTuXbfen/M5GGphQk3kggnO52i8thPBf3RfywfuxOOM= +github.com/whilesmartgo/mcp v0.2.0 h1:oXEI2dagW9rFuRvK4U87iLPd7pxZ08RdAi1HSy8hyrY= +github.com/whilesmartgo/mcp v0.2.0/go.mod h1:GD0Gg3H8rJOSfCQ0o68kXjOg5Kv3QqkAHRwtV4L+xKk= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= diff --git a/internal/api/mcp_server.go b/internal/api/mcp_server.go new file mode 100644 index 0000000..c829ade --- /dev/null +++ b/internal/api/mcp_server.go @@ -0,0 +1,73 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/flatrun/agent/internal/ai" + "github.com/flatrun/agent/internal/auth" + "github.com/flatrun/agent/internal/contextkeys" + "github.com/flatrun/agent/pkg/version" + "github.com/gin-gonic/gin" + "github.com/whilesmartgo/agents" + agentsmcp "github.com/whilesmartgo/mcp" +) + +// mcpActorKey carries the request's authenticated actor into the MCP handler's +// per-request registry callback, which only sees the raw *http.Request. +type mcpActorKey struct{} + +// newMCPHandler builds the HTTP handler that serves the assistant tool set over +// MCP. The registry is resolved per request, so every call is authorized as the +// actor on that request rather than whoever opened the session. +func (s *Server) newMCPHandler() http.Handler { + return agentsmcp.StreamableHTTPHandler("flatrun", version.Get().Version, func(r *http.Request) *agents.Registry { + actor, _ := r.Context().Value(mcpActorKey{}).(*auth.ActorContext) + return s.mcpToolRegistry(actor) + }) +} + +// mcpHTTP adapts the gin request to the MCP handler, passing the actor the auth +// middleware already resolved so tool handlers can gate on it. +func (s *Server) mcpHTTP(c *gin.Context) { + ctx := context.WithValue(c.Request.Context(), mcpActorKey{}, auth.GetActorFromContext(c)) + s.mcpHandler.ServeHTTP(c.Writer, c.Request.WithContext(ctx)) +} + +// mcpToolRegistry wraps every assistant tool (built-in and plugin-contributed) +// as an agents.Tool bound to actor. Each handler runs through runAITool, the +// same dispatch the assistant uses, so authorization, protected-mode gating, +// plugin routing, and output truncation are identical. +func (s *Server) mcpToolRegistry(actor *auth.ActorContext) *agents.Registry { + specs := s.aiToolSpecs() + tools := make([]agents.Tool, 0, len(specs)) + for _, spec := range specs { + spec := spec + tools = append(tools, agents.Tool{ + Name: spec.Name, + Description: spec.Description, + Parameters: spec.Parameters, + Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { + gc := toolGinContext(ctx, actor) + // runAITool returns tool errors as content (prefixed "Error: "), + // matching what the assistant sees, so an MCP client's model can + // read the failure and adapt rather than the call faulting. + return s.runAITool(gc, "", ai.ToolCall{Name: spec.Name, Arguments: string(raw)}), nil + }, + }) + } + return agents.NewRegistry(tools...) +} + +// toolGinContext builds the minimal gin.Context the tool dispatch expects: a +// request carrying ctx (for per-tool timeouts) and the actor for permission +// checks. A nil actor means auth is disabled, as elsewhere in the API. +func toolGinContext(ctx context.Context, actor *auth.ActorContext) *gin.Context { + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "/", nil) + gc := &gin.Context{Request: req} + if actor != nil { + gc.Set(contextkeys.Actor, actor) + } + return gc +} diff --git a/internal/api/mcp_server_test.go b/internal/api/mcp_server_test.go new file mode 100644 index 0000000..068ce49 --- /dev/null +++ b/internal/api/mcp_server_test.go @@ -0,0 +1,113 @@ +package api + +import ( + "context" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/flatrun/agent/internal/ai" + "github.com/flatrun/agent/internal/auth" + "github.com/flatrun/agent/internal/docker" + "github.com/flatrun/agent/internal/files" + "github.com/flatrun/agent/internal/networks" + "github.com/flatrun/agent/internal/plan" + "github.com/flatrun/agent/internal/proxy" + "github.com/flatrun/agent/pkg/config" + "github.com/flatrun/agent/pkg/models" + "github.com/gin-gonic/gin" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func setupMCPTestServer(t *testing.T) (*Server, string, *httptest.Server) { + t.Helper() + gin.SetMode(gin.TestMode) + + tmpDir := t.TempDir() + cfg := &config.Config{ + DeploymentsPath: tmpDir, + Auth: config.AuthConfig{Enabled: false}, + Infrastructure: config.InfrastructureConfig{DefaultProxyNetwork: "proxy"}, + Nginx: config.NginxConfig{ConfigPath: filepath.Join(tmpDir, "nginx", "conf.d")}, + Cleanup: config.CleanupConfig{Timeout: 2 * time.Minute}, + Plans: config.PlansConfig{TTL: time.Hour, RetentionDays: 30}, + MCP: config.MCPConfig{Enabled: true}, + } + + s := &Server{ + config: cfg, + router: gin.New(), + manager: docker.NewManager(tmpDir), + networksManager: networks.NewManager(), + authMiddleware: auth.NewMiddleware(&cfg.Auth), + proxyOrchestrator: proxy.NewOrchestrator(cfg), + planStore: plan.NewStore(tmpDir), + aiSessions: ai.NewSessionStore(tmpDir), + filesManager: files.NewManager(tmpDir), + } + s.mcpHandler = s.newMCPHandler() + s.setupRoutes() + + ts := httptest.NewServer(s.router) + t.Cleanup(ts.Close) + return s, tmpDir, ts +} + +// TestMCPServerExposesToolsOverHTTP drives the MCP server through the real HTTP +// boundary with a real MCP client, the way an external client reaches it. +func TestMCPServerExposesToolsOverHTTP(t *testing.T) { + _, _, ts := setupMCPTestServer(t) + + ctx := context.Background() + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil) + cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: ts.URL + "/api/mcp"}, nil) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = cs.Close() }) + + tools, err := cs.ListTools(ctx, nil) + if err != nil { + t.Fatalf("list tools: %v", err) + } + var found bool + for _, tool := range tools.Tools { + if tool.Name == "get_instance_info" { + found = true + } + } + if !found { + t.Fatal("expected a built-in assistant tool to be advertised over MCP") + } + + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "get_instance_info"}) + if err != nil { + t.Fatalf("call tool: %v", err) + } + if text := res.Content[0].(*mcp.TextContent).Text; !strings.Contains(text, "Hostname:") { + t.Errorf("unexpected tool output %q", text) + } +} + +// TestMCPRegistryEnforcesActorPermissions proves the caller's actor flows +// through the bridge into the same per-tool gating the assistant uses: a viewer +// cannot drive a mutating tool. +func TestMCPRegistryEnforcesActorPermissions(t *testing.T) { + s, tmpDir, _ := setupMCPTestServer(t) + createTestDeployment(t, tmpDir, "myapp", &models.ServiceMetadata{Name: "myapp"}) + + viewer := &auth.ActorContext{Role: auth.RoleViewer, User: &auth.User{ID: 1, Username: "v"}} + tool, ok := s.mcpToolRegistry(viewer).Get("control_deployment") + if !ok { + t.Fatal("control_deployment should be present") + } + out, err := tool.Handler(context.Background(), []byte(`{"deployment":"myapp","action":"restart"}`)) + if err != nil { + t.Fatalf("handler returned a transport error: %v", err) + } + if !strings.Contains(out, "write access") { + t.Errorf("a viewer must be denied a mutating tool, got %q", out) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index c7c0fd0..f777a10 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -101,6 +101,7 @@ type Server struct { planStore *plan.Store aiProvider ai.Provider aiSessions *ai.SessionStore + mcpHandler http.Handler jobs *jobRegistry // runDeploymentAction runs a deployment action and streams each output @@ -347,6 +348,10 @@ func New(cfg *config.Config, configPath string) *Server { s.runDeploymentAction = s.defaultRunDeploymentAction s.runServiceAction = s.defaultRunServiceAction + if cfg.MCP.Enabled { + s.mcpHandler = s.newMCPHandler() + } + s.planStore.StartPruneLoop(context.Background(), time.Hour, time.Duration(cfg.Plans.RetentionDays)*24*time.Hour) if provider, aiErr := ai.New(&cfg.AI); aiErr == nil { @@ -500,6 +505,13 @@ func (s *Server) setupRoutes() { protected.POST("/ai/analyze", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.aiAssistSystem) protected.POST("/deployments/:name/ai/analyze", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.aiAssistDeployment) + // MCP server: the same tool set the assistant uses, exposed to + // external MCP clients. Each tool self-gates on the caller's + // permissions, so the route only requires authentication. + if s.mcpHandler != nil { + protected.Any("/mcp", s.mcpHTTP) + } + // Interactive AI sessions (agentic tool loop) protected.POST("/ai/sessions", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.createAISession) protected.GET("/ai/sessions", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.listAISessions) diff --git a/pkg/config/config.go b/pkg/config/config.go index d6b189e..5f05151 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -41,10 +41,18 @@ type Config struct { Cleanup CleanupConfig `yaml:"cleanup"` Plans PlansConfig `yaml:"plans"` AI AIConfig `yaml:"ai"` + MCP MCPConfig `yaml:"mcp"` Files FilesConfig `yaml:"files"` Backup BackupConfig `yaml:"backup"` } +// MCPConfig controls the built-in MCP server that exposes the assistant's tool +// set to external MCP clients. Every call is authenticated and permission-gated +// exactly as the assistant is, so it is off unless deliberately enabled. +type MCPConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` +} + type FilesConfig struct { // Pointer so an explicit false survives reloads; nil means "use default" (true). ShowHidden *bool `yaml:"show_hidden" json:"show_hidden"` From 3228b06062e503f39b4dd4f066649fff4521d2bb Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 20 Jul 2026 18:59:20 +0100 Subject: [PATCH 2/2] feat: Let the MCP server toggle at runtime Enabling or disabling the MCP server now takes effect immediately when the setting is saved, without restarting the agent, so it can be flipped from the settings UI. While disabled, the endpoint refuses calls rather than being absent. --- internal/api/config_handlers.go | 3 +++ internal/api/mcp_server.go | 7 ++++++- internal/api/mcp_server_test.go | 17 +++++++++++++++++ internal/api/server.go | 13 ++++++------- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index 4a5c999..9b97ed0 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -136,6 +136,9 @@ func (s *Server) runtimeAppliers() map[string]func(*Server) error { "backup.destinations": func(srv *Server) error { return srv.applyBackupDestinations() }, + // The MCP handler reads the flag per request, so the change is already + // live once saved; this entry marks it as apply-without-restart. + "mcp.enabled": func(*Server) error { return nil }, "ai.enabled": rebuildAIProvider, "ai.base_url": rebuildAIProvider, "ai.api_key": rebuildAIProvider, diff --git a/internal/api/mcp_server.go b/internal/api/mcp_server.go index c829ade..390f73e 100644 --- a/internal/api/mcp_server.go +++ b/internal/api/mcp_server.go @@ -29,8 +29,13 @@ func (s *Server) newMCPHandler() http.Handler { } // mcpHTTP adapts the gin request to the MCP handler, passing the actor the auth -// middleware already resolved so tool handlers can gate on it. +// middleware already resolved so tool handlers can gate on it. The route is +// always mounted; the flag is checked here so it can toggle at runtime. func (s *Server) mcpHTTP(c *gin.Context) { + if !s.config.MCP.Enabled { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "the MCP server is disabled"}) + return + } ctx := context.WithValue(c.Request.Context(), mcpActorKey{}, auth.GetActorFromContext(c)) s.mcpHandler.ServeHTTP(c.Writer, c.Request.WithContext(ctx)) } diff --git a/internal/api/mcp_server_test.go b/internal/api/mcp_server_test.go index 068ce49..67a8de1 100644 --- a/internal/api/mcp_server_test.go +++ b/internal/api/mcp_server_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "net/http" "net/http/httptest" "path/filepath" "strings" @@ -91,6 +92,22 @@ func TestMCPServerExposesToolsOverHTTP(t *testing.T) { } } +// TestMCPServerRefusesWhenDisabled proves the route stays mounted but rejects +// calls once mcp.enabled flips off, so the toggle takes effect without a restart. +func TestMCPServerRefusesWhenDisabled(t *testing.T) { + s, _, ts := setupMCPTestServer(t) + s.config.MCP.Enabled = false + + resp, err := http.Post(ts.URL+"/api/mcp", "application/json", strings.NewReader("{}")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusServiceUnavailable { + t.Errorf("disabled MCP endpoint status = %d, want 503", resp.StatusCode) + } +} + // TestMCPRegistryEnforcesActorPermissions proves the caller's actor flows // through the bridge into the same per-tool gating the assistant uses: a viewer // cannot drive a mutating tool. diff --git a/internal/api/server.go b/internal/api/server.go index f777a10..37b7ce6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -348,9 +348,9 @@ func New(cfg *config.Config, configPath string) *Server { s.runDeploymentAction = s.defaultRunDeploymentAction s.runServiceAction = s.defaultRunServiceAction - if cfg.MCP.Enabled { - s.mcpHandler = s.newMCPHandler() - } + // Built unconditionally: it is stateless and starts nothing, so requests are + // gated on the live config flag instead, letting mcp.enabled toggle at runtime. + s.mcpHandler = s.newMCPHandler() s.planStore.StartPruneLoop(context.Background(), time.Hour, time.Duration(cfg.Plans.RetentionDays)*24*time.Hour) @@ -507,10 +507,9 @@ func (s *Server) setupRoutes() { // MCP server: the same tool set the assistant uses, exposed to // external MCP clients. Each tool self-gates on the caller's - // permissions, so the route only requires authentication. - if s.mcpHandler != nil { - protected.Any("/mcp", s.mcpHTTP) - } + // permissions, so the route only requires authentication. The + // handler itself refuses calls while mcp.enabled is off. + protected.Any("/mcp", s.mcpHTTP) // Interactive AI sessions (agentic tool loop) protected.POST("/ai/sessions", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.createAISession)