diff --git a/go.mod b/go.mod index 790d9ee98a..3e1916b428 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang/protobuf v1.5.4 github.com/google/go-github/v84 v84.0.0 + github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 @@ -24,6 +25,7 @@ require ( github.com/markbates/goth v1.81.0 github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/mapstructure v1.4.3 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/nulab/autog v0.11.0 github.com/pierrecomputer/sdk/packages/code-storage-go v0.8.0 github.com/playwright-community/playwright-go v0.5200.1 @@ -65,6 +67,9 @@ require ( github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // 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.61.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect @@ -112,7 +117,7 @@ require ( github.com/spf13/afero v1.6.0 // indirect github.com/spf13/cast v1.4.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/pflag v1.0.6 github.com/subosito/gotenv v1.2.0 // indirect go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 diff --git a/go.sum b/go.sum index 8265186b6d..72fab5d00c 100644 --- a/go.sum +++ b/go.sum @@ -269,6 +269,8 @@ github.com/google/go-github/v84 v84.0.0/go.mod h1:WwYL1z1ajRdlaPszjVu/47x1L0PXuk github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= 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/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -434,6 +436,8 @@ github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:F github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs= github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +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/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= @@ -497,6 +501,10 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -536,6 +544,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +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.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -899,6 +909,8 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/mcp-demo/canvas.yaml b/mcp-demo/canvas.yaml new file mode 100644 index 0000000000..fab06547de --- /dev/null +++ b/mcp-demo/canvas.yaml @@ -0,0 +1,44 @@ +apiVersion: v1 +kind: Canvas +metadata: + name: "Deploy Gate (MCP demo)" + description: "Webhook-triggered deploy gate: business hours, then human approval, then deploy. Built by an AI agent through the SuperPlane MCP server." +spec: + nodes: + - id: "trigger-deploy" + name: "Deploy requested" + type: "TYPE_TRIGGER" + component: "webhook" + configuration: {} + - id: "gate-hours" + name: "Only during work hours" + type: "TYPE_ACTION" + component: "timeGate" + configuration: + days: ["monday", "tuesday", "wednesday", "thursday", "friday"] + timeRange: "09:00 - 17:00" + timezone: "1" + - id: "gate-approval" + name: "Require approval" + type: "TYPE_ACTION" + component: "approval" + configuration: + items: + - type: "anyone" + - id: "do-deploy" + name: "Run deploy" + type: "TYPE_ACTION" + component: "http" + configuration: + method: "POST" + url: "https://example.com/deploy" + edges: + - sourceId: "trigger-deploy" + targetId: "gate-hours" + channel: "default" + - sourceId: "gate-hours" + targetId: "gate-approval" + channel: "default" + - sourceId: "gate-approval" + targetId: "do-deploy" + channel: "approved" diff --git a/pkg/cli/commands/canvases/create.go b/pkg/cli/commands/canvases/create.go index 2d849e1a3d..645ada48e6 100644 --- a/pkg/cli/commands/canvases/create.go +++ b/pkg/cli/commands/canvases/create.go @@ -130,6 +130,7 @@ func validateAndPrintCreateResponse( canvas := *resp.Canvas resource := models.CanvasResourceFromCanvas(canvas) + resource.URL = BuildCanvasURL(ctx, canvas.Metadata.GetOrganizationId(), canvas.Metadata.GetId()) if !ctx.Renderer.IsText() { return ctx.Renderer.Render(resource) } diff --git a/pkg/cli/commands/canvases/models/canvas.go b/pkg/cli/commands/canvases/models/canvas.go index b9e516bdcb..7a16f8f2b4 100644 --- a/pkg/cli/commands/canvases/models/canvas.go +++ b/pkg/cli/commands/canvases/models/canvas.go @@ -17,6 +17,10 @@ type Canvas struct { Metadata *openapi_client.CanvasesCanvasMetadata `json:"metadata" yaml:"metadata"` Spec *openapi_client.CanvasesCanvasSpec `json:"spec,omitempty" yaml:"spec,omitempty"` AutoLayout *openapi_client.CanvasesCanvasAutoLayout `json:"autoLayout,omitempty" yaml:"autoLayout,omitempty"` + // URL is the canonical web URL for the canvas. It is computed on output + // (e.g. by the create command) and omitted from YAML so round-tripping a + // canvas file stays clean. + URL string `json:"url,omitempty" yaml:"-"` } func ParseCanvas(raw []byte) (*Canvas, error) { diff --git a/pkg/cli/commands/mcp/execute.go b/pkg/cli/commands/mcp/execute.go new file mode 100644 index 0000000000..7bdd27a58b --- /dev/null +++ b/pkg/cli/commands/mcp/execute.go @@ -0,0 +1,89 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// makeHandler returns an MCP tool handler that executes the mapped CLI command +// by shelling out to this same binary with `--output json`. +func makeHandler(self string, path []string) mcpsdk.ToolHandler { + return func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + args := map[string]any{} + if raw := req.Params.Arguments; len(raw) > 0 { + if err := json.Unmarshal(raw, &args); err != nil { + return errorResult(fmt.Sprintf("invalid arguments: %v", err)), nil + } + } + + cmdArgs := append([]string{}, path...) + + // Positional args come right after the command path. + if pos, ok := args["args"]; ok { + if list, ok := pos.([]any); ok { + for _, item := range list { + cmdArgs = append(cmdArgs, fmt.Sprint(item)) + } + } + } + + // Remaining keys map to flags. + for k, v := range args { + if k == "args" { + continue + } + switch vv := v.(type) { + case bool: + if vv { + cmdArgs = append(cmdArgs, "--"+k) + } + case []any: + for _, item := range vv { + cmdArgs = append(cmdArgs, "--"+k, fmt.Sprint(item)) + } + default: + cmdArgs = append(cmdArgs, "--"+k, fmt.Sprint(vv)) + } + } + + // Always return structured output to the agent. + cmdArgs = append(cmdArgs, "--output", "json") + + var stdout, stderr bytes.Buffer + c := exec.CommandContext(ctx, self, cmdArgs...) + c.Stdout = &stdout + c.Stderr = &stderr + + if err := c.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return errorResult(msg), nil + } + + out := stdout.String() + if strings.TrimSpace(out) == "" { + out = "(command completed successfully with no output)" + } + + return &mcpsdk.CallToolResult{ + Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: out}}, + }, nil + } +} + +// errorResult reports a failed tool call back to the agent without failing the +// MCP request itself. +func errorResult(msg string) *mcpsdk.CallToolResult { + return &mcpsdk.CallToolResult{ + IsError: true, + Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: msg}}, + } +} diff --git a/pkg/cli/commands/mcp/root.go b/pkg/cli/commands/mcp/root.go new file mode 100644 index 0000000000..75dba3abd4 --- /dev/null +++ b/pkg/cli/commands/mcp/root.go @@ -0,0 +1,42 @@ +package mcp + +import ( + "github.com/spf13/cobra" + "github.com/superplanehq/superplane/pkg/cli/core" +) + +const ( + serverName = "superplane" + serverVersion = "0.1.0" +) + +// NewCommand returns the `superplane mcp` command. It runs a Model Context +// Protocol (MCP) server over stdio that auto-exposes every SuperPlane CLI +// command as an MCP tool, so AI coding agents (Claude Code, Codex) can drive +// SuperPlane directly. +// +// The options argument is accepted for symmetry with the other command groups +// but is unused: tools execute by shelling out to this same binary, reusing the +// CLI's existing context/auth on disk. +func NewCommand(_ core.BindOptions) *cobra.Command { + var readOnly bool + + cmd := &cobra.Command{ + Use: "mcp", + Short: "Run an MCP server exposing the SuperPlane CLI to AI coding agents", + Long: "Starts a Model Context Protocol (MCP) server over stdio.\n\n" + + "Every SuperPlane CLI command is auto-exposed as an MCP tool, so agents like\n" + + "Claude Code and Codex can list canvases, create workflows, inspect executions,\n" + + "and more by calling tools instead of memorizing the CLI.\n\n" + + "Add it to Claude Code with:\n" + + " claude mcp add superplane -- superplane mcp", + RunE: func(cmd *cobra.Command, args []string) error { + return runServer(cmd, readOnly) + }, + } + + cmd.Flags().BoolVar(&readOnly, "read-only", false, + "expose only read-only tools (list/get/describe/show/...)") + + return cmd +} diff --git a/pkg/cli/commands/mcp/server.go b/pkg/cli/commands/mcp/server.go new file mode 100644 index 0000000000..90d607d679 --- /dev/null +++ b/pkg/cli/commands/mcp/server.go @@ -0,0 +1,37 @@ +package mcp + +import ( + "context" + "fmt" + "os" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" +) + +// runServer builds the MCP server from the live command tree and serves it over +// stdio until the transport closes. +func runServer(cmd *cobra.Command, readOnly bool) error { + self, err := os.Executable() + if err != nil { + return fmt.Errorf("cannot locate the superplane binary: %w", err) + } + + server := mcpsdk.NewServer( + &mcpsdk.Implementation{Name: serverName, Version: serverVersion}, + nil, + ) + + // cmd.Root() is the fully assembled CLI tree at run time, so we avoid an + // import cycle with the parent cli package. + for _, t := range collectTools(cmd.Root(), readOnly) { + server.AddTool(t.tool, makeHandler(self, t.path)) + } + + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + + return server.Run(ctx, &mcpsdk.StdioTransport{}) +} diff --git a/pkg/cli/commands/mcp/tools.go b/pkg/cli/commands/mcp/tools.go new file mode 100644 index 0000000000..b2a5981549 --- /dev/null +++ b/pkg/cli/commands/mcp/tools.go @@ -0,0 +1,140 @@ +package mcp + +import ( + "strings" + + "github.com/google/jsonschema-go/jsonschema" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// toolSpec pairs a generated MCP tool with the CLI command path it maps to. +type toolSpec struct { + tool *mcpsdk.Tool + path []string +} + +// commands we never expose as tools. +var skippedCommands = map[string]bool{ + "mcp": true, // don't expose the server itself + "help": true, + "completion": true, +} + +// global/persistent flags we hide from every tool (managed by the server, not +// the agent). +var hiddenFlags = map[string]bool{ + "output": true, // forced to json by the executor + "verbose": true, + "config": true, + "help": true, +} + +// collectTools walks the command tree and returns one tool per runnable leaf. +func collectTools(root *cobra.Command, readOnly bool) []toolSpec { + var tools []toolSpec + + var walk func(c *cobra.Command, path []string) + walk = func(c *cobra.Command, path []string) { + for _, sub := range c.Commands() { + if sub.Hidden || skippedCommands[sub.Name()] { + continue + } + + childPath := append(append([]string{}, path...), sub.Name()) + + if sub.Runnable() && (!readOnly || isReadOnly(sub.Name())) { + tools = append(tools, buildTool(sub, childPath)) + } + + if sub.HasAvailableSubCommands() { + walk(sub, childPath) + } + } + } + walk(root, nil) + + return tools +} + +func isReadOnly(name string) bool { + switch name { + case "list", "get", "describe", "show", "tree", "whoami", "version", "active": + return true + } + return strings.HasPrefix(name, "list") +} + +func buildTool(cmd *cobra.Command, path []string) toolSpec { + props := map[string]*jsonschema.Schema{} + seen := map[string]bool{} + + addFlag := func(f *pflag.Flag) { + if f.Hidden || seen[f.Name] || hiddenFlags[f.Name] { + return + } + seen[f.Name] = true + props[f.Name] = schemaForFlag(f) + } + cmd.LocalFlags().VisitAll(addFlag) + cmd.InheritedFlags().VisitAll(addFlag) + + // Expose positional args when the command takes any. + if strings.Contains(cmd.Use, "<") || strings.Contains(cmd.Use, "[") || cmd.Args != nil { + props["args"] = &jsonschema.Schema{ + Type: "array", + Description: "Positional arguments for the command, in order (e.g. an ID or name).", + Items: &jsonschema.Schema{Type: "string"}, + } + } + + desc := cmd.Short + if cmd.Long != "" { + desc = cmd.Long + } + + return toolSpec{ + tool: &mcpsdk.Tool{ + Name: toolName(path), + Description: desc, + InputSchema: &jsonschema.Schema{Type: "object", Properties: props}, + }, + path: path, + } +} + +// toolName joins the command path with underscores and sanitizes it to the +// characters MCP tool names allow. +func toolName(path []string) string { + raw := strings.Join(path, "_") + var b strings.Builder + for _, r := range raw { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_': + b.WriteRune(r) + default: + b.WriteRune('_') + } + } + return b.String() +} + +func schemaForFlag(f *pflag.Flag) *jsonschema.Schema { + s := &jsonschema.Schema{Description: f.Usage} + switch f.Value.Type() { + case "bool": + s.Type = "boolean" + case "int", "int8", "int16", "int32", "int64", + "uint", "uint8", "uint16", "uint32", "uint64", "count": + s.Type = "integer" + case "float32", "float64": + s.Type = "number" + case "stringArray", "stringSlice", "intSlice", "boolSlice": + s.Type = "array" + s.Items = &jsonschema.Schema{Type: "string"} + default: + s.Type = "string" + } + return s +} diff --git a/pkg/cli/root.go b/pkg/cli/root.go index b8c4fa5cb9..7ecaec7c20 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -17,6 +17,7 @@ import ( groups "github.com/superplanehq/superplane/pkg/cli/commands/groups" index "github.com/superplanehq/superplane/pkg/cli/commands/index" integrations "github.com/superplanehq/superplane/pkg/cli/commands/integrations" + mcpcmd "github.com/superplanehq/superplane/pkg/cli/commands/mcp" members "github.com/superplanehq/superplane/pkg/cli/commands/members" organizations "github.com/superplanehq/superplane/pkg/cli/commands/organizations" queue "github.com/superplanehq/superplane/pkg/cli/commands/queue" @@ -66,6 +67,7 @@ func init() { RootCmd.AddCommand(index.NewCommand(options)) RootCmd.AddCommand(integrations.NewCommand(options)) RootCmd.AddCommand(members.NewCommand(options)) + RootCmd.AddCommand(mcpcmd.NewCommand(options)) RootCmd.AddCommand(organizations.NewCommand(options)) RootCmd.AddCommand(queue.NewCommand(options)) RootCmd.AddCommand(roles.NewCommand(options))