diff --git a/app/backend/cmd/mcp/main.go b/app/backend/cmd/mcp/main.go new file mode 100644 index 0000000..370cb67 --- /dev/null +++ b/app/backend/cmd/mcp/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + + "containr/internal/mcpserver" +) + +// Injected at build time via -ldflags. +var Version = "dev" + +// containr-mcp exposes the whole Containr API as MCP tools over stdio. +// Configure it in an MCP client with: +// +// command: containr-mcp +// env: CONTAINR_API_URL, CONTAINR_TOKEN (a cnp_ personal access token) +func main() { + apiURL := os.Getenv("CONTAINR_API_URL") + if apiURL == "" { + apiURL = "http://localhost:8080/api/v1" + } + apiURL = strings.TrimSuffix(apiURL, "/") + if !strings.HasSuffix(apiURL, "/api/v1") { + apiURL += "/api/v1" + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := mcpserver.Run(ctx, apiURL, os.Getenv("CONTAINR_TOKEN"), Version); err != nil { + fmt.Fprintf(os.Stderr, "containr-mcp: %v\n", err) + os.Exit(1) + } +} diff --git a/app/backend/go.mod b/app/backend/go.mod index 3879dd2..3fb58ff 100644 --- a/app/backend/go.mod +++ b/app/backend/go.mod @@ -8,11 +8,12 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/go-playground/validator/v10 v10.30.1 github.com/go-redis/redis/v8 v8.11.5 - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/lib/pq v1.10.9 github.com/miekg/dns v1.1.72 + github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/pressly/goose/v3 v3.24.1 github.com/robfig/cron/v3 v3.0.1 github.com/rs/cors v1.10.1 @@ -21,6 +22,7 @@ require ( github.com/sqlc-dev/pqtype v0.3.0 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.57.0 + golang.org/x/term v0.46.0 ) require ( @@ -45,6 +47,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect @@ -63,6 +66,8 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -71,6 +76,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect @@ -84,11 +90,11 @@ require ( golang.org/x/arch v0.3.0 // indirect golang.org/x/mod v0.41.0 // indirect golang.org/x/net v0.59.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/term v0.46.0 // indirect golang.org/x/text v0.42.0 // indirect - golang.org/x/time v0.11.0 // indirect + golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.49.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/app/backend/go.sum b/app/backend/go.sum index 47fb828..ea3684d 100644 --- a/app/backend/go.sum +++ b/app/backend/go.sum @@ -65,11 +65,13 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -105,6 +107,8 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= 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.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= 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= @@ -143,6 +147,10 @@ github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +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/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -178,6 +186,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +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= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= @@ -213,6 +223,8 @@ golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -223,8 +235,8 @@ golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go new file mode 100644 index 0000000..f809f90 --- /dev/null +++ b/app/backend/internal/mcpserver/server.go @@ -0,0 +1,278 @@ +// Package mcpserver exposes the Containr API as MCP tools over stdio. +// It reuses the CLI's thin client — same PAT auth, same endpoints — +// so agents reach everything the UI and CLI do. +package mcpserver + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strings" + + "containr/internal/cli/commands" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// spec describes one MCP tool as an HTTP call. {placeholder} segments in +// Path are filled from arguments; remaining string/number/bool args become +// the JSON body for POST/PUT/PATCH. +type spec struct { + name string + description string + method string + path string + // params are the arguments that fill path placeholders (required). + params []string + // bodyKeys restrict which args go into the request body; when empty all + // non-param args are sent. + bodyKeys []string + // destructive tools refuse unless args["confirm"] == true. + destructive bool +} + +var tools []spec + +func register(s spec) { tools = append(tools, s) } + +func init() { + register(spec{"containr_auth_status", "Show which profile/token is active and the authenticated identity", "GET", "/user/profile", nil, nil, false}) + + // Personal access tokens + register(spec{"containr_tokens_list", "List personal access tokens (metadata only, never values)", "GET", "/user/tokens", nil, nil, false}) + register(spec{"containr_tokens_create", "Create a personal access token. Args: name (required), scope (read|write|admin, default write), expires_in_days (0=never). Returns the raw token once.", "POST", "/user/tokens", nil, []string{"name", "scope", "expires_in_days"}, false}) + register(spec{"containr_tokens_revoke", "Revoke a personal access token by id. Requires confirm:true.", "DELETE", "/user/tokens/{id}", []string{"id"}, nil, true}) + + // Projects + register(spec{"containr_projects_list", "List projects", "GET", "/projects", nil, nil, false}) + register(spec{"containr_projects_get", "Get a project by id", "GET", "/projects/{id}", []string{"id"}, nil, false}) + register(spec{"containr_projects_create", "Create a project. Args: name (required), description.", "POST", "/projects", nil, []string{"name", "description"}, false}) + register(spec{"containr_projects_delete", "Delete a project and all its services. Requires confirm:true.", "DELETE", "/projects/{id}", []string{"id"}, nil, true}) + + // Services + register(spec{"containr_services_list", "List services in a project", "GET", "/projects/{project_id}/services", []string{"project_id"}, nil, false}) + register(spec{"containr_services_get", "Get a service by id", "GET", "/services/{id}", []string{"id"}, nil, false}) + register(spec{"containr_services_create", "Create a service in a project. Args: name (required), type, image, git_repo, git_branch, environment, port, domain, replicas.", "POST", "/projects/{project_id}/services", []string{"project_id"}, nil, false}) + register(spec{"containr_services_delete", "Delete a service. Requires confirm:true.", "DELETE", "/services/{id}", []string{"id"}, nil, true}) + register(spec{"containr_services_start", "Start a service", "POST", "/services/{id}/start", []string{"id"}, nil, false}) + register(spec{"containr_services_stop", "Stop a service", "POST", "/services/{id}/stop", []string{"id"}, nil, false}) + register(spec{"containr_services_restart", "Restart a service", "POST", "/services/{id}/restart", []string{"id"}, nil, false}) + register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false}) + register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false}) + register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false}) + + // Variables + register(spec{"containr_variables_list", "List service environment variables (secrets masked)", "GET", "/services/{id}/variables", []string{"id"}, nil, false}) + register(spec{"containr_variables_set", "Replace all variables on a service. Args: id, variables = [{key, value, is_secret}]. Fetch the current list first and merge — the server replaces wholesale.", "PUT", "/services/{id}/variables", []string{"id"}, []string{"variables"}, false}) + + // Deployments + register(spec{"containr_deploy", "Trigger a deployment for a service. Args: id (service), commit_hash, branch.", "POST", "/services/{id}/deployments", []string{"id"}, []string{"commit_hash", "branch", "trigger"}, false}) + register(spec{"containr_deployments_list", "List recent deployments across services", "GET", "/deployments", nil, nil, false}) + register(spec{"containr_services_deployments", "List deployments for one service", "GET", "/services/{id}/deployments", []string{"id"}, nil, false}) + register(spec{"containr_deployments_get", "Get a deployment", "GET", "/deployments/{id}", []string{"id"}, nil, false}) + register(spec{"containr_deployments_logs", "Get persisted build/runtime logs for a deployment. Args: id, type (all|build|runtime).", "GET", "/deployments/{id}/logs", []string{"id"}, nil, false}) + register(spec{"containr_deployments_rollback", "Roll back to a deployment. Requires confirm:true.", "POST", "/deployments/{id}/rollback", []string{"id"}, nil, true}) + + // Databases + register(spec{"containr_databases_list", "List managed databases", "GET", "/databases", nil, nil, false}) + register(spec{"containr_databases_get", "Get a database (connection URL included)", "GET", "/databases/{id}", []string{"id"}, nil, false}) + register(spec{"containr_databases_create", "Provision a managed database. Args: name, type (postgres|mysql|mariadb|mongodb|redis|dragonfly|clickhouse).", "POST", "/databases", nil, nil, false}) + register(spec{"containr_databases_action", "Start/stop/restart a database. Args: id, action.", "POST", "/databases/{id}/action", []string{"id"}, []string{"action"}, false}) + register(spec{"containr_databases_backup", "Take a manual backup snapshot", "POST", "/databases/{id}/backup", []string{"id"}, nil, false}) + register(spec{"containr_databases_restore", "Restore a database from a backup — overwrites current data. Requires confirm:true.", "POST", "/databases/{id}/restore", []string{"id"}, []string{"backup_id"}, true}) + register(spec{"containr_databases_delete", "Delete a database and its data. Requires confirm:true.", "DELETE", "/databases/{id}", []string{"id"}, nil, true}) + + // Cron + register(spec{"containr_cron_list", "List cron jobs", "GET", "/cron-jobs", nil, nil, false}) + register(spec{"containr_cron_create", "Create a cron job. Args: service_id, schedule (5-field), command, name, timezone.", "POST", "/cron-jobs", nil, nil, false}) + register(spec{"containr_cron_trigger", "Run a cron job now", "POST", "/cron-jobs/{id}/trigger", []string{"id"}, nil, false}) + register(spec{"containr_cron_executions", "List a cron job's execution history", "GET", "/cron-jobs/{id}/executions", []string{"id"}, nil, false}) + register(spec{"containr_cron_delete", "Delete a cron job. Requires confirm:true.", "DELETE", "/cron-jobs/{id}", []string{"id"}, nil, true}) + + // Templates + register(spec{"containr_templates_list", "List templates", "GET", "/templates", nil, nil, false}) + register(spec{"containr_templates_get", "Get a template definition", "GET", "/templates/{id}", []string{"id"}, nil, false}) + register(spec{"containr_templates_deploy", "Deploy a template into a project. Args: id (template), project_id.", "POST", "/templates/{id}/deploy", []string{"id"}, []string{"project_id"}, false}) + + // Notifications + register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false}) + register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false}) + + // Nodes / agent tokens (admin) + register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false}) + register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false}) + register(spec{"containr_agent_tokens_issue", "Issue an agent onboarding token (admin). Args: label.", "POST", "/agent-tokens", nil, []string{"label"}, false}) + register(spec{"containr_agent_tokens_list", "List agent onboarding tokens (admin)", "GET", "/agent-tokens", nil, nil, false}) + + // Scaling / HA / security + register(spec{"containr_scaling_status", "Autoscaler status", "GET", "/scaling/status", nil, nil, false}) + register(spec{"containr_scaling_policies", "List scaling policies", "GET", "/scaling/policies", nil, nil, false}) + register(spec{"containr_scaling_scale", "Manually scale a service. Args: service_id, replicas. (admin)", "POST", "/scaling/services/{service_id}/scale", []string{"service_id"}, []string{"replicas"}, false}) + register(spec{"containr_ha_status", "HA manager status", "GET", "/ha/status", nil, nil, false}) + register(spec{"containr_ha_alerts", "Active HA alerts", "GET", "/ha/alerts/active", nil, nil, false}) + register(spec{"containr_ha_failover", "Trigger a manual failover — reschedules services. Requires confirm:true. (admin)", "POST", "/ha/failover", nil, nil, true}) + register(spec{"containr_security_scan", "Run a security scan. Args: project_id (required), scan_type, service_id.", "POST", "/security/scans", nil, nil, false}) + register(spec{"containr_security_vulnerabilities", "List a project's vulnerabilities", "GET", "/projects/{project_id}/vulnerabilities", []string{"project_id"}, nil, false}) + + // Admin + register(spec{"containr_admin_overview", "Platform overview (admin)", "GET", "/admin/overview", nil, nil, false}) + register(spec{"containr_admin_users", "List all users (admin)", "GET", "/admin/users", nil, nil, false}) + register(spec{"containr_admin_audit_logs", "List audit log entries (admin)", "GET", "/audit-logs", nil, nil, false}) + register(spec{"containr_gateway_services", "List gateway services (admin)", "GET", "/gateway/services", nil, nil, false}) + register(spec{"containr_gateway_keys", "List gateway API keys (admin)", "GET", "/gateway/keys", nil, nil, false}) +} + +// Run starts the stdio MCP server. apiURL/token come from env or config — +// the caller resolves them before calling. +func Run(ctx context.Context, apiURL, token, version string) error { + if token == "" { + return fmt.Errorf("CONTAINR_TOKEN is required (create a cnp_ token under Settings → Personal Access Tokens)") + } + client := &commands.Client{BaseURL: apiURL, Token: token} + + server := mcp.NewServer(&mcp.Implementation{ + Name: "containr", + Version: version, + }, nil) + + for _, s := range tools { + s := s + server.AddTool(&mcp.Tool{ + Name: s.name, + Description: s.description, + InputSchema: schemaFor(s), + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return call(ctx, client, s, req) + }) + } + + return server.Run(ctx, &mcp.StdioTransport{}) +} + +// schemaFor builds a JSON Schema for the tool's arguments: path params + +// body keys + confirm for destructive tools. Properties accept any JSON +// type — the API validates values. +func schemaFor(s spec) map[string]interface{} { + props := map[string]interface{}{} + var required []string + for _, p := range s.params { + props[p] = map[string]interface{}{"type": "string", "description": "path parameter"} + required = append(required, p) + } + bodySrc := s.bodyKeys + if len(bodySrc) == 0 && s.method != "GET" && s.method != "DELETE" { + // Unrestricted body: advertise nothing, accept anything. + } + for _, k := range bodySrc { + props[k] = map[string]interface{}{"description": "request body field"} + } + if s.destructive { + props["confirm"] = map[string]interface{}{"type": "boolean", "description": "must be true — this operation is destructive"} + required = append(required, "confirm") + } + schema := map[string]interface{}{ + "type": "object", + "properties": props, + "additionalProperties": true, + } + if len(required) > 0 { + schema["required"] = required + } + return schema +} + +func call(ctx context.Context, c *commands.Client, s spec, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args map[string]interface{} + if len(req.Params.Arguments) > 0 { + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return errorResult("invalid arguments: " + err.Error()), nil + } + } + if args == nil { + args = map[string]interface{}{} + } + + if s.destructive && args["confirm"] != true { + return errorResult("destructive operation — pass confirm:true to proceed"), nil + } + + path := s.path + for _, p := range s.params { + v, ok := args[p] + if !ok || fmt.Sprint(v) == "" { + return errorResult(fmt.Sprintf("missing required argument %q", p)), nil + } + path = strings.ReplaceAll(path, "{"+p+"}", fmt.Sprint(v)) + } + + var body interface{} + var query []string + for k, v := range args { + if k == "confirm" { + continue + } + inParams := false + for _, p := range s.params { + if k == p { + inParams = true + break + } + } + if inParams { + continue + } + if s.method == "GET" || s.method == "DELETE" { + // Non-path args on reads become query parameters. + query = append(query, url.QueryEscape(k)+"="+url.QueryEscape(fmt.Sprint(v))) + continue + } + if len(s.bodyKeys) > 0 { + allowed := false + for _, b := range s.bodyKeys { + if k == b { + allowed = true + break + } + } + if !allowed { + continue + } + } + if body == nil { + body = map[string]interface{}{} + } + body.(map[string]interface{})[k] = v + } + if len(query) > 0 { + sep := "?" + if strings.Contains(path, "?") { + sep = "&" + } + path += sep + strings.Join(query, "&") + } + + data, err := c.Do(s.method, path, body) + if err != nil { + return errorResult(err.Error()), nil + } + return textResult(data), nil +} + +func textResult(data []byte) *mcp.CallToolResult { + var v interface{} + if json.Unmarshal(data, &v) != nil { + v = string(data) + } + out, _ := json.MarshalIndent(v, "", " ") + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(out)}}, + } +} + +func errorResult(msg string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: msg}}, + } +} diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index a2619bf..a8f4be0 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -69,3 +69,29 @@ containr up --project --image ghcr.io/example/app:latest # image, no buil Set `project_id` on the active profile to omit `--project` (stored in `~/.containr.yaml` under `profiles..project_id`). + +## MCP server + +`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools +over stdio — every command group above maps to a `containr_*` tool +(~60 tools). Destructive tools require the caller to pass +`confirm: true`; results are pretty JSON with `isError` on failures. + +Client configuration (Claude Code, Cursor, Devin, any stdio MCP host): + +```json +{ + "mcpServers": { + "containr": { + "command": "containr-mcp", + "env": { + "CONTAINR_API_URL": "https://containr.example.com", + "CONTAINR_TOKEN": "cnp_..." + } + } + } +} +``` + +Scope the token deliberately: `read` is enough for inspection agents, +`write` for deploy automation, `admin` only for platform operations.