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/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 new file mode 100644 index 0000000..390f73e --- /dev/null +++ b/internal/api/mcp_server.go @@ -0,0 +1,78 @@ +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. 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)) +} + +// 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..67a8de1 --- /dev/null +++ b/internal/api/mcp_server_test.go @@ -0,0 +1,130 @@ +package api + +import ( + "context" + "net/http" + "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) + } +} + +// 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. +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..37b7ce6 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 + // 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) if provider, aiErr := ai.New(&cfg.AI); aiErr == nil { @@ -500,6 +505,12 @@ 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. 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) 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"`