package mcp import ( "context" "fmt" "net" "net/http" "os" "os/exec" "os/signal" "slices" "strconv" "strings" "syscall" mcpGo "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/spf13/cobra" "github.com/superfly/flyctl/internal/buildinfo" "github.com/superfly/flyctl/internal/command" mcpServer "github.com/superfly/flyctl/internal/command/mcp/server" "github.com/superfly/flyctl/internal/config" "github.com/superfly/flyctl/internal/flag" "github.com/superfly/flyctl/internal/flag/flagnames" ) var COMMANDS = slices.Concat( mcpServer.AppCommands, mcpServer.CertsCommands, mcpServer.IPCommands, mcpServer.LogCommands, mcpServer.MachineCommands, mcpServer.OrgCommands, mcpServer.PlatformCommands, mcpServer.SecretsCommands, mcpServer.StatusCommands, mcpServer.VolumeCommands, ) type contextKey string const authTokenKey contextKey = "authToken" func newServer() *cobra.Command { const ( short = "[experimental] Start a flyctl MCP server" long = short + "\n" usage = "server" ) cmd := command.New(usage, short, long, runServer) cmd.Args = cobra.ExactArgs(0) flag.Add(cmd, flag.Bool{ Name: "inspector", Description: "Launch MCP inspector: a developer tool for testing and debugging MCP servers", Default: false, Shorthand: "i", }, flag.String{ Name: "server", Description: "Name to use for the MCP server in the MCP client configuration", }, flag.StringArray{ Name: "config", Description: "Path to the MCP client configuration file (can be specified multiple times)", }, flag.Bool{ Name: "stream", Description: "Enable HTTP streaming output for MCP commands", }, flag.Bool{ Name: "sse", Description: "Enable Server-Sent Events (SSE) for MCP commands", }, flag.Int{ Name: "port", Description: "Port to run the MCP server on (default is 8080)", Default: 8080, }, flag.String{ Name: flagnames.BindAddr, Shorthand: "b", Default: "127.0.0.1", Description: "Local address to bind to", }, ) for client, name := range McpClients { flag.Add(cmd, flag.Bool{ Name: client, Description: "Add flyctl MCP server to the " + name + " client configuration", }, ) } return cmd } func runServer(ctx context.Context) error { flyctl, err := os.Executable() if err != nil { return fmt.Errorf("failed to find executable: %w", err) } configs, err := ListConfigPaths(ctx, true) if err != nil { return fmt.Errorf("failed to list MCP client configuration paths: %w", err) } stream := flag.GetBool(ctx, "stream") sse := flag.GetBool(ctx, "sse") if flag.GetBool(ctx, "inspector") || len(configs) > 0 { server := flag.GetString(ctx, "server") if server == "" { server = "flyctl" } args := []string{"mcp", "server"} if stream || sse { args = []string{ "mcp", "proxy", "--url", fmt.Sprintf("http://localhost:%d", flag.GetInt(ctx, "port")), } if token := getAccessToken(ctx); token != "" { args = append(args, "--bearer-token", token) } if stream { args = append(args, "--stream") } else { args = append(args, "--sse") } } if len(configs) > 0 { for _, config := range configs { UpdateConfig(ctx, config.Path, config.ConfigName, server, flyctl, args) } } if flag.GetBool(ctx, "inspector") { var process *os.Process // If sse or stream, start flyctl mcp server in the background if stream || sse { args := []string{"mcp", "server", "--port", strconv.Itoa(flag.GetInt(ctx, "port"))} if token := getAccessToken(ctx); token != "" { args = append(args, "--access-token", token) } if stream { args = append(args, "--stream") } else { args = append(args, "--sse") } cmd := exec.Command(flyctl, args...) cmd.Env = os.Environ() cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start flyctl mcp server in background: %w", err) } process = cmd.Process } // Launch MCP inspector args = append([]string{"@modelcontextprotocol/inspector@latest", flyctl}, args...) cmd := exec.Command("npx", args...) cmd.Env = os.Environ() cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("failed to launch MCP inspector: %w", err) } if process != nil { // Attempt to kill the background process after inspector exits if err := process.Kill(); err != nil { fmt.Fprintf(os.Stderr, "failed to kill background flyctl mcp server: %v\n", err) } } } return nil } // Create MCP server srv := server.NewMCPServer( "FlyMCP 🚀", buildinfo.Info().Version.String(), ) // Register commands for _, cmd := range COMMANDS { // Create a tool function for each command tool := func(ctx context.Context, request mcpGo.CallToolRequest) (*mcpGo.CallToolResult, error) { // Extract arguments from the request args := make(map[string]string) argMap, ok := request.Params.Arguments.(map[string]any) if !ok { return nil, fmt.Errorf("invalid arguments: expected map[string]any") } for argName, argValue := range argMap { description, ok := cmd.ToolArgs[argName] if !ok { return nil, fmt.Errorf("unknown argument %s", argName) } if description.Required && argValue == nil { return nil, fmt.Errorf("argument %s is required", argName) } switch description.Type { case "string": if strValue, ok := argValue.(string); ok { args[argName] = strValue } else { return nil, fmt.Errorf("argument %s must be a string", argName) } case "enum": if strValue, ok := argValue.(string); ok { if !slices.Contains(description.Enum, strValue) { return nil, fmt.Errorf("argument %s must be one of %v", argName, description.Enum) } args[argName] = strValue } else { return nil, fmt.Errorf("argument %s must be a string", argName) } case "array": if arrValue, ok := argValue.([]any); ok { if len(arrValue) > 0 { strArr := make([]string, len(arrValue)) for i, v := range arrValue { if str, ok := v.(string); ok { strArr[i] = str } else { return nil, fmt.Errorf("argument %s must be an array of strings", argName) } } args[argName] = strings.Join(strArr, ",") } } else { return nil, fmt.Errorf("argument %s must be an array of strings", argName) } case "hash": if arrValue, ok := argValue.([]any); ok { if len(arrValue) > 0 { strArr := make([]string, len(arrValue)) for i, v := range arrValue { if str, ok := v.(string); ok { // Simple shell escaping: wrap value in single quotes and escape any single quotes inside str = "'" + strings.ReplaceAll(str, "'", "'\\''") + "'" strArr[i] = str } else { return nil, fmt.Errorf("argument %s must be an array of strings", argName) } } args[argName] = strings.Join(strArr, " ") } } else { return nil, fmt.Errorf("argument %s must be an array of strings", argName) } case "number": if numValue, ok := argValue.(float64); ok { args[argName] = strconv.FormatFloat(numValue, 'f', -1, 64) } else { return nil, fmt.Errorf("argument %s must be a number", argName) } case "boolean": if boolValue, ok := argValue.(bool); ok { args[argName] = strconv.FormatBool(boolValue) } else { return nil, fmt.Errorf("argument %s must be a boolean", argName) } default: return nil, fmt.Errorf("unsupported argument type %s for argument %s", description.Type, argName) } } // Check for required arguments and fill in defaults for argName, description := range cmd.ToolArgs { if description.Required { if _, ok := args[argName]; !ok { return nil, fmt.Errorf("missing required argument %s", argName) } } else if description.Default != "" { if _, ok := args[argName]; !ok { args[argName] = description.Default } } } // Call the builder function to get the command arguments cmdArgs, err := cmd.Builder(args) if err != nil { return nil, fmt.Errorf("failed to build command: %w", err) } // Log the command (without the auth token and any secret values) if len(cmdArgs) >= 2 && cmdArgs[0] == "secrets" && cmdArgs[1] == "set" { redactedCmdArgs := append([]string(nil), cmdArgs...) for i, arg := range redactedCmdArgs[2:] { if strings.Contains(arg, "=") { parts := strings.SplitN(arg, "=", 2) redactedCmdArgs[i+2] = parts[0] + "=REDACTED" } } fmt.Fprintf(os.Stderr, "Executing flyctl command: %v\n", redactedCmdArgs) } else { fmt.Fprintf(os.Stderr, "Executing flyctl command: %v\n", cmdArgs) } // If auth token is present in context, add --access-token flag if token, ok := ctx.Value(authTokenKey).(string); ok && token != "" { cmdArgs = append(cmdArgs, "--access-token", token) } // Execute the command execCmd := exec.Command(flyctl, cmdArgs...) output, err := execCmd.CombinedOutput() if err != nil { fmt.Fprintf(os.Stderr, "Error executing flyctl: %v\nOutput: %s\n", err, string(output)) return nil, fmt.Errorf("failed to execute command: %v\nOutput: %s", err, string(output)) } // Return the output as a tool result return mcpGo.NewToolResultText(string(output)), nil } // Register the tool with the server toolOptions := []mcpGo.ToolOption{ mcpGo.WithDescription(cmd.ToolDescription), } for argName, arg := range cmd.ToolArgs { options := []mcpGo.PropertyOption{ mcpGo.Description(arg.Description), } if arg.Required { options = append(options, mcpGo.Required()) } switch arg.Type { case "string": if arg.Default != "" { options = append(options, mcpGo.DefaultString(arg.Default)) } toolOptions = append(toolOptions, mcpGo.WithString(argName, options...)) case "enum": if arg.Default != "" { if slices.Contains(arg.Enum, arg.Default) { options = append(options, mcpGo.DefaultString(arg.Default)) } else { return fmt.Errorf("invalid default value for argument %s: %s is not in enum %v", argName, arg.Default, arg.Enum) } } if len(arg.Enum) > 0 { options = append(options, mcpGo.Enum(arg.Enum...)) } else { return fmt.Errorf("enum argument %s must have at least one value", argName) } toolOptions = append(toolOptions, mcpGo.WithString(argName, options...)) case "array": schema := map[string]any{"type": "string"} options = append(options, mcpGo.Items(schema)) toolOptions = append(toolOptions, mcpGo.WithArray(argName, options...)) case "number": if arg.Default != "" { if defaultValue, err := strconv.ParseFloat(arg.Default, 64); err == nil { options = append(options, mcpGo.DefaultNumber(defaultValue)) } else { return fmt.Errorf("invalid default value for argument %s: %v", argName, err) } } toolOptions = append(toolOptions, mcpGo.WithNumber(argName, options...)) case "boolean": if arg.Default != "" { if defaultValue, err := strconv.ParseBool(arg.Default); err == nil { options = append(options, mcpGo.DefaultBool(defaultValue)) } else { return fmt.Errorf("invalid default value for argument %s: %v", argName, err) } } toolOptions = append(toolOptions, mcpGo.WithBoolean(argName, options...)) case "hash": schema := map[string]any{"type": "string"} options = append(options, mcpGo.Items(schema)) toolOptions = append(toolOptions, mcpGo.WithArray(argName, options...)) default: return fmt.Errorf("unsupported argument type %s for argument %s", arg.Type, argName) } } srv.AddTool( mcpGo.NewTool(cmd.ToolName, toolOptions...), tool, ) } if defaultToken := getAccessToken(ctx); defaultToken != "" { ctx = context.WithValue(ctx, authTokenKey, defaultToken) } if stream || sse { port := flag.GetInt(ctx, "port") var start func(string) error var err error // enable graceful shutdown on signals sigc := make(chan os.Signal, 1) signal.Notify(sigc, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) go func() { <-sigc os.Exit(0) }() // Function to extract the auth token from the request context extractAuthToken := func(ctx context.Context, r *http.Request) context.Context { authHeader := r.Header.Get("Authorization") if authHeader != "" { // Extract the token from the Authorization header token := strings.TrimPrefix(authHeader, "Bearer ") if token != authHeader { // Ensure it was a Bearer token return context.WithValue(ctx, authTokenKey, token) } } return ctx } if stream { fmt.Fprintf(os.Stderr, "Starting flyctl MCP server in streaming mode on port %d...\n", port) httpServer := server.NewStreamableHTTPServer(srv) server.WithHTTPContextFunc(extractAuthToken)(httpServer) start = httpServer.Start } else { fmt.Fprintf(os.Stderr, "Starting flyctl MCP server in SSE mode on port %d...\n", port) sseServer := server.NewSSEServer(srv) server.WithSSEContextFunc(extractAuthToken)(sseServer) start = sseServer.Start } if err = start(net.JoinHostPort(flag.GetString(ctx, flagnames.BindAddr), fmt.Sprintf("%d", port))); err != nil { return fmt.Errorf("Server error: %v", err) } } else { fmt.Fprintf(os.Stderr, "Starting flyctl MCP server...\n") if err := server.ServeStdio(srv); err != nil { return fmt.Errorf("Server error: %v", err) } } return nil } func getAccessToken(ctx context.Context) string { token := flag.GetString(ctx, flagnames.AccessToken) if token == "" { cfg := config.FromContext(ctx) token = cfg.Tokens.GraphQL() } return token }