diff --git a/cli/README.md b/cli/README.md index 14783941..4c10034e 100644 --- a/cli/README.md +++ b/cli/README.md @@ -70,6 +70,11 @@ openlabs range destroy test-range - `openlabs config show` - Show current configuration - `openlabs config set ` - Set configuration value +### MCP (Model Context Protocol) +- `openlabs mcp start` - Start MCP server for AI assistant integration +- `openlabs mcp status` - Check MCP server status and connectivity +- `openlabs mcp tools` - List available MCP tools + ## Global Flags - `--format` - Output format (table, json, yaml) diff --git a/cli/cmd/auth/register.go b/cli/cmd/auth/register.go index 4a36ff13..40fa19b7 100644 --- a/cli/cmd/auth/register.go +++ b/cli/cmd/auth/register.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/cobra" + "github.com/OpenLabsHQ/OpenLabs/cli/internal/logger" "github.com/OpenLabsHQ/OpenLabs/cli/internal/progress" "github.com/OpenLabsHQ/OpenLabs/cli/internal/utils" ) @@ -66,7 +67,7 @@ func runRegister(cmd *cobra.Command, name, email, password string) error { } if cmd.Flag("password").Changed { - fmt.Println("Password provided via flag - skipping confirmation") + logger.Info("Password provided via flag - skipping confirmation") } else { confirmPassword, err := utils.PromptPassword("Confirm password") if err != nil { diff --git a/cli/cmd/mcp/mcp.go b/cli/cmd/mcp/mcp.go new file mode 100644 index 00000000..b4a03df9 --- /dev/null +++ b/cli/cmd/mcp/mcp.go @@ -0,0 +1,26 @@ +package mcp + +import ( + "github.com/spf13/cobra" + + "github.com/OpenLabsHQ/OpenLabs/cli/internal/config" +) + +var globalConfig *config.Config + +func NewMCPCommand() *cobra.Command { + mcpCmd := &cobra.Command{ + Use: "mcp", + Short: "Manage Model Context Protocol server", + } + + mcpCmd.AddCommand(newStartCommand()) + mcpCmd.AddCommand(newToolsCommand()) + mcpCmd.AddCommand(newStatusCommand()) + + return mcpCmd +} + +func SetGlobalConfig(cfg *config.Config) { + globalConfig = cfg +} \ No newline at end of file diff --git a/cli/cmd/mcp/start.go b/cli/cmd/mcp/start.go new file mode 100644 index 00000000..09c4c59d --- /dev/null +++ b/cli/cmd/mcp/start.go @@ -0,0 +1,84 @@ +package mcp + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/spf13/cobra" + + "github.com/OpenLabsHQ/OpenLabs/cli/internal/logger" + "github.com/OpenLabsHQ/OpenLabs/cli/internal/mcp" +) + +var ( + transport string + port int + debug bool +) + +func newStartCommand() *cobra.Command { + startCmd := &cobra.Command{ + Use: "start", + Short: "Start the MCP server", + Long: "Start the Model Context Protocol server for AI assistant integration.", + RunE: runStartCommand, + } + + startCmd.Flags().StringVar(&transport, "transport", "sse", "Transport mode: stdio or sse") + startCmd.Flags().IntVar(&port, "port", 8080, "Port for SSE transport") + startCmd.Flags().BoolVar(&debug, "debug", false, "Enable debug logging") + + return startCmd +} + +func runStartCommand(cmd *cobra.Command, args []string) error { + if debug { + logger.SetDebug(true) + logger.Info("Debug logging enabled") + } + + if transport != "stdio" && transport != "sse" { + return fmt.Errorf("invalid transport: %s (must be 'stdio' or 'sse')", transport) + } + + if transport == "sse" && (port < 1 || port > 65535) { + return fmt.Errorf("invalid port: %d (must be between 1 and 65535)", port) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-sigChan + logger.Info("Received shutdown signal, shutting down gracefully...") + cancel() + }() + + server, err := mcp.NewServer(globalConfig, debug) + if err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } + + logger.Info("Starting OpenLabs MCP server with %s transport", transport) + + switch transport { + case "stdio": + if err := server.RunStdio(ctx); err != nil && err != context.Canceled { + return fmt.Errorf("failed to run stdio server: %w", err) + } + case "sse": + addr := fmt.Sprintf(":%d", port) + logger.Info("MCP server listening on %s", addr) + if err := server.RunSSE(ctx, addr); err != nil && err != context.Canceled { + return fmt.Errorf("failed to run SSE server: %w", err) + } + } + + return nil +} diff --git a/cli/cmd/mcp/status.go b/cli/cmd/mcp/status.go new file mode 100644 index 00000000..2d9be9bd --- /dev/null +++ b/cli/cmd/mcp/status.go @@ -0,0 +1,70 @@ +package mcp + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/OpenLabsHQ/OpenLabs/cli/internal/client" + "github.com/OpenLabsHQ/OpenLabs/cli/internal/logger" +) + +func newStatusCommand() *cobra.Command { + statusCmd := &cobra.Command{ + Use: "status", + Short: "Check MCP server status and connectivity", + Long: "Check MCP server prerequisites and API connectivity.", + RunE: runStatusCommand, + } + + return statusCmd +} + +func runStatusCommand(cmd *cobra.Command, args []string) error { + fmt.Println("OpenLabs MCP Server Status Check") + + if globalConfig == nil { + logger.Failure("No configuration loaded") + return fmt.Errorf("configuration not loaded") + } + logger.Success("Configuration loaded") + + logger.Success("API URL: %s", globalConfig.APIURL) + + if globalConfig.AuthToken == "" { + logger.Failure("No auth token found") + logger.Notice("Run 'openlabs auth login' to authenticate, or use the 'login' MCP tool") + return nil + } + logger.Success("Authentication token present") + + apiClient := client.New(globalConfig) + if err := apiClient.Ping(); err != nil { + logger.Failure("API connectivity failed (%v)", err) + logger.Debug("API ping failed: %v", err) + + logger.Notice("Troubleshooting steps:") + logger.Notice(" 1. Check your network connection") + logger.Notice(" 2. Verify API URL with 'openlabs config show'") + logger.Notice(" 3. Re-authenticate with 'openlabs auth login'") + return nil + } + logger.Success("API connectivity verified") + + userInfo, err := apiClient.GetUserInfo() + if err != nil { + logger.Failure("Failed to get user info (%v)", err) + logger.Notice("Your token may be expired. Run 'openlabs auth login' to re-authenticate, or use the 'login' MCP tool") + return nil + } + logger.Success("User info: %s (%s)", userInfo.Name, userInfo.Email) + + logger.Success("MCP server is ready to start!") + + fmt.Println("\nQuick start commands:") + fmt.Println(" openlabs mcp start # Start with stdio transport") + fmt.Println(" openlabs mcp start --debug # Start with debug logging") + fmt.Println(" openlabs mcp tools # List available tools") + + return nil +} diff --git a/cli/cmd/mcp/tools.go b/cli/cmd/mcp/tools.go new file mode 100644 index 00000000..be8c60dd --- /dev/null +++ b/cli/cmd/mcp/tools.go @@ -0,0 +1,37 @@ +package mcp + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/OpenLabsHQ/OpenLabs/cli/internal/mcp" + "github.com/OpenLabsHQ/OpenLabs/cli/internal/output" +) + +func newToolsCommand() *cobra.Command { + toolsCmd := &cobra.Command{ + Use: "tools", + Short: "List available MCP tools", + Long: "List all Model Context Protocol tools available to AI assistants.", + RunE: runToolsCommand, + } + + return toolsCmd +} + +func runToolsCommand(cmd *cobra.Command, args []string) error { + tools := mcp.GetAllTools() + + if globalConfig.OutputFormat == "table" || globalConfig.OutputFormat == "" { + if err := output.DisplayMCPTools(tools); err != nil { + return fmt.Errorf("failed to display tools: %w", err) + } + } else { + if err := output.Display(tools, globalConfig.OutputFormat); err != nil { + return fmt.Errorf("failed to display tools: %w", err) + } + } + + return nil +} \ No newline at end of file diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 3a18145b..0e97ece7 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -10,6 +10,7 @@ import ( "github.com/OpenLabsHQ/OpenLabs/cli/cmd/auth" "github.com/OpenLabsHQ/OpenLabs/cli/cmd/blueprints" "github.com/OpenLabsHQ/OpenLabs/cli/cmd/config" + "github.com/OpenLabsHQ/OpenLabs/cli/cmd/mcp" "github.com/OpenLabsHQ/OpenLabs/cli/cmd/ranges" internalConfig "github.com/OpenLabsHQ/OpenLabs/cli/internal/config" "github.com/OpenLabsHQ/OpenLabs/cli/internal/logger" @@ -96,6 +97,7 @@ func addSubcommands() { rootCmd.AddCommand(ranges.NewRangeCommand()) rootCmd.AddCommand(blueprints.NewBlueprintsCommand()) rootCmd.AddCommand(config.NewConfigCommand()) + rootCmd.AddCommand(mcp.NewMCPCommand()) } func initializeGlobalConfig() error { @@ -133,6 +135,7 @@ func applyGlobalFlags() { auth.SetGlobalConfig(globalConfig) ranges.SetGlobalConfig(globalConfig) blueprints.SetGlobalConfig(globalConfig) + mcp.SetGlobalConfig(globalConfig) } func loadConfigFromPath(path string) (*internalConfig.Config, error) { diff --git a/cli/go.mod b/cli/go.mod index e356e26e..7a48f6a0 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -4,6 +4,7 @@ go 1.24 require ( github.com/aws/aws-sdk-go-v2/config v1.29.17 + github.com/mark3labs/mcp-go v0.34.0 github.com/olekukonko/tablewriter v0.0.5 github.com/spf13/cobra v1.8.1 golang.org/x/term v0.32.0 @@ -23,11 +24,14 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect github.com/aws/smithy-go v1.22.4 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kr/pretty v0.3.1 // indirect - github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/sys v0.33.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/cli/go.sum b/cli/go.sum index fdb7940d..4c015191 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -25,7 +25,14 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zg github.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw= github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -35,20 +42,32 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mark3labs/mcp-go v0.34.0 h1:eWy7WBGvhk6EyAAyVzivTCprE52iXJwNtvHV6Cv3bR0= +github.com/mark3labs/mcp-go v0.34.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= diff --git a/cli/internal/logger/logger.go b/cli/internal/logger/logger.go index 9249f0db..ea54098b 100644 --- a/cli/internal/logger/logger.go +++ b/cli/internal/logger/logger.go @@ -1,6 +1,7 @@ package logger import ( + "fmt" "log" "os" ) @@ -80,3 +81,21 @@ func Warnf(format string, args ...interface{}) { func Errorf(format string, args ...interface{}) { Error(format, args...) } + +// Status logging functions with consistent indicators +// These use stdout for status messages and stderr for errors + +// Success logs a success message with [+] indicator +func Success(format string, args ...interface{}) { + fmt.Printf("[+] "+format+"\n", args...) +} + +// Failure logs a failure message with [-] indicator +func Failure(format string, args ...interface{}) { + fmt.Printf("[-] "+format+"\n", args...) +} + +// Notice logs a notice/warning message with [!] indicator +func Notice(format string, args ...interface{}) { + fmt.Printf("[!] "+format+"\n", args...) +} diff --git a/cli/internal/mcp/handlers.go b/cli/internal/mcp/handlers.go new file mode 100644 index 00000000..f2727e90 --- /dev/null +++ b/cli/internal/mcp/handlers.go @@ -0,0 +1,466 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/mark3labs/mcp-go/mcp" + + "github.com/OpenLabsHQ/OpenLabs/cli/internal/client" +) + +func (s *Server) checkAuthAndReturnError() *mcp.CallToolResult { + if !s.client.IsAuthenticated() { + if s.reloadConfigIfChanged() { + if s.client.IsAuthenticated() { + return nil + } + } + + return mcp.NewToolResultError("Not authenticated. Please use 'openlabs auth login' or use the login tool with your email and password to authenticate.") + } + return nil +} + +func (s *Server) handleListRanges(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if authErr := s.checkAuthAndReturnError(); authErr != nil { + return authErr, nil + } + ranges, err := s.client.ListRanges() + if err != nil { + return mcp.NewToolResultError("Failed to list ranges: " + err.Error()), nil + } + + data, err := json.MarshalIndent(ranges, "", " ") + if err != nil { + return mcp.NewToolResultError("Failed to format response"), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Found %d deployed ranges:\n\n%s", len(ranges), string(data))), nil +} + +func (s *Server) handleGetRangeDetails(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if authErr := s.checkAuthAndReturnError(); authErr != nil { + return authErr, nil + } + rangeID, err := requireInt(request, "range_id") + if err != nil { + return mcp.NewToolResultError("Missing or invalid range_id parameter"), nil + } + + details, err := s.client.GetRange(rangeID) + if err != nil { + return mcp.NewToolResultError("Failed to get range details: " + err.Error()), nil + } + + data, err := json.MarshalIndent(details, "", " ") + if err != nil { + return mcp.NewToolResultError("Failed to format response"), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Range %d details:\n\n%s", rangeID, string(data))), nil +} + +func (s *Server) handleGetRangeKey(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if authErr := s.checkAuthAndReturnError(); authErr != nil { + return authErr, nil + } + rangeID, err := requireInt(request, "range_id") + if err != nil { + return mcp.NewToolResultError("Missing or invalid range_id parameter"), nil + } + + keyResponse, err := s.client.GetRangeKey(rangeID) + if err != nil { + return mcp.NewToolResultError("Failed to get range key: " + err.Error()), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("SSH private key for range %d:\n\n%s", rangeID, keyResponse.RangePrivateKey)), nil +} + +func (s *Server) handleListBlueprints(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if authErr := s.checkAuthAndReturnError(); authErr != nil { + return authErr, nil + } + blueprints, err := s.client.ListBlueprintRanges() + if err != nil { + return mcp.NewToolResultError("Failed to list blueprints: " + err.Error()), nil + } + + data, err := json.MarshalIndent(blueprints, "", " ") + if err != nil { + return mcp.NewToolResultError("Failed to format response"), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Found %d available blueprints:\n\n%s", len(blueprints), string(data))), nil +} + +func (s *Server) handleGetBlueprintDetails(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if authErr := s.checkAuthAndReturnError(); authErr != nil { + return authErr, nil + } + blueprintID, err := requireInt(request, "blueprint_id") + if err != nil { + return mcp.NewToolResultError("Missing or invalid blueprint_id parameter"), nil + } + + details, err := s.client.GetBlueprintRange(blueprintID) + if err != nil { + return mcp.NewToolResultError("Failed to get blueprint details: " + err.Error()), nil + } + + data, err := json.MarshalIndent(details, "", " ") + if err != nil { + return mcp.NewToolResultError("Failed to format response"), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Blueprint %d details:\n\n%s", blueprintID, string(data))), nil +} + +func (s *Server) handleCheckJobStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if authErr := s.checkAuthAndReturnError(); authErr != nil { + return authErr, nil + } + jobID, err := requireString(request, "job_id") + if err != nil { + return mcp.NewToolResultError("Missing or invalid job_id parameter"), nil + } + + status, err := s.client.GetJob(jobID) + if err != nil { + return mcp.NewToolResultError("Failed to check job status: " + err.Error()), nil + } + + data, err := json.MarshalIndent(status, "", " ") + if err != nil { + return mcp.NewToolResultError("Failed to format response"), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Job %s status:\n\n%s", jobID, string(data))), nil +} + +func (s *Server) handleGetUserInfo(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if authErr := s.checkAuthAndReturnError(); authErr != nil { + return authErr, nil + } + userInfo, err := s.client.GetUserInfo() + if err != nil { + return mcp.NewToolResultError("Failed to get user info: " + err.Error()), nil + } + + isAdmin := userInfo.Admin + + adminStatus := "Standard User" + capabilities := "Can list/deploy ranges, manage own resources and credentials" + if isAdmin { + adminStatus = "Administrator" + capabilities = "Full platform access including blueprint creation/deletion" + } + + data, err := json.MarshalIndent(userInfo, "", " ") + if err != nil { + return mcp.NewToolResultError("Failed to format response"), nil + } + + return mcp.NewToolResultText(fmt.Sprintf(`Current user information: + +%s + +Role: %s +Capabilities: %s + +Authentication status: Authenticated and ready for OpenLabs operations`, + string(data), adminStatus, capabilities)), nil +} + +func (s *Server) handleDeployRange(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if authErr := s.checkAuthAndReturnError(); authErr != nil { + return authErr, nil + } + name, err := requireString(request, "name") + if err != nil { + return mcp.NewToolResultError("Missing or invalid name parameter"), nil + } + + blueprintID, err := requireInt(request, "blueprint_id") + if err != nil { + return mcp.NewToolResultError("Missing or invalid blueprint_id parameter"), nil + } + + region, err := requireString(request, "region") + if err != nil { + return mcp.NewToolResultError("Missing or invalid region parameter"), nil + } + + description := getStringOptional(request, "description") + + deployRequest := &client.DeployRangeRequest{ + Name: name, + BlueprintID: blueprintID, + Region: region, + Description: description, + } + + result, err := s.client.DeployRange(deployRequest) + if err != nil { + return mcp.NewToolResultError("Failed to deploy range: " + err.Error()), nil + } + + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return mcp.NewToolResultError("Failed to format response"), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Successfully deployed range '%s':\n\n%s", name, string(data))), nil +} + +func (s *Server) handleDestroyRange(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if authErr := s.checkAuthAndReturnError(); authErr != nil { + return authErr, nil + } + rangeID, err := requireInt(request, "range_id") + if err != nil { + return mcp.NewToolResultError("Missing or invalid range_id parameter"), nil + } + + confirm, err := requireBool(request, "confirm") + if err != nil { + return mcp.NewToolResultError("Missing or invalid confirm parameter - must be true to confirm destruction"), nil + } + + if !confirm { + return mcp.NewToolResultError("Destruction not confirmed - confirm parameter must be true"), nil + } + + result, err := s.client.DeleteRange(rangeID) + if err != nil { + return mcp.NewToolResultError("Failed to destroy range: " + err.Error()), nil + } + + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return mcp.NewToolResultError("Failed to format response"), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Successfully initiated destruction of range %d:\n\n%s", rangeID, string(data))), nil +} + +func (s *Server) handleCreateBlueprint(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if authErr := s.checkAuthAndReturnError(); authErr != nil { + return authErr, nil + } + args := request.GetArguments() + blueprintData, ok := args["blueprint"] + if !ok { + return mcp.NewToolResultError("Missing blueprint parameter"), nil + } + + data, err := json.Marshal(blueprintData) + if err != nil { + return mcp.NewToolResultError("Invalid blueprint data format"), nil + } + + var blueprint map[string]interface{} + if err := json.Unmarshal(data, &blueprint); err != nil { + return mcp.NewToolResultError("Invalid blueprint JSON format"), nil + } + + result, err := s.client.CreateBlueprintRange(blueprint) + if err != nil { + return mcp.NewToolResultError("Failed to create blueprint: " + err.Error()), nil + } + + resultData, err := json.MarshalIndent(result, "", " ") + if err != nil { + return mcp.NewToolResultError("Failed to format response"), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Successfully created blueprint:\n\n%s", string(resultData))), nil +} + +func (s *Server) handleDeleteBlueprint(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if authErr := s.checkAuthAndReturnError(); authErr != nil { + return authErr, nil + } + blueprintID, err := requireInt(request, "blueprint_id") + if err != nil { + return mcp.NewToolResultError("Missing or invalid blueprint_id parameter"), nil + } + + confirm, err := requireBool(request, "confirm") + if err != nil { + return mcp.NewToolResultError("Missing or invalid confirm parameter - must be true to confirm deletion"), nil + } + + if !confirm { + return mcp.NewToolResultError("Deletion not confirmed - confirm parameter must be true"), nil + } + + err = s.client.DeleteBlueprintRange(blueprintID) + if err != nil { + return mcp.NewToolResultError("Failed to delete blueprint: " + err.Error()), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Successfully deleted blueprint %d", blueprintID)), nil +} + +func (s *Server) handleUpdateAWSSecrets(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if authErr := s.checkAuthAndReturnError(); authErr != nil { + return authErr, nil + } + accessKey, err := requireString(request, "aws_access_key") + if err != nil { + return mcp.NewToolResultError("Missing or invalid aws_access_key parameter"), nil + } + + secretKey, err := requireString(request, "aws_secret_key") + if err != nil { + return mcp.NewToolResultError("Missing or invalid aws_secret_key parameter"), nil + } + + err = s.client.UpdateAWSSecrets(accessKey, secretKey) + if err != nil { + return mcp.NewToolResultError("Failed to update AWS secrets: " + err.Error()), nil + } + + return mcp.NewToolResultText("Successfully updated AWS credentials"), nil +} + +func (s *Server) handleUpdateAzureSecrets(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if authErr := s.checkAuthAndReturnError(); authErr != nil { + return authErr, nil + } + clientID, err := requireString(request, "azure_client_id") + if err != nil { + return mcp.NewToolResultError("Missing or invalid azure_client_id parameter"), nil + } + + clientSecret, err := requireString(request, "azure_client_secret") + if err != nil { + return mcp.NewToolResultError("Missing or invalid azure_client_secret parameter"), nil + } + + tenantID, err := requireString(request, "azure_tenant_id") + if err != nil { + return mcp.NewToolResultError("Missing or invalid azure_tenant_id parameter"), nil + } + + subscriptionID, err := requireString(request, "azure_subscription_id") + if err != nil { + return mcp.NewToolResultError("Missing or invalid azure_subscription_id parameter"), nil + } + + err = s.client.UpdateAzureSecrets(clientID, clientSecret, tenantID, subscriptionID) + if err != nil { + return mcp.NewToolResultError("Failed to update Azure secrets: " + err.Error()), nil + } + + return mcp.NewToolResultText("Successfully updated Azure credentials"), nil +} + +func (s *Server) handleLogin(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + email, err := requireString(request, "email") + if err != nil { + return mcp.NewToolResultError("Missing or invalid email parameter"), nil + } + + password, err := requireString(request, "password") + if err != nil { + return mcp.NewToolResultError("Missing or invalid password parameter"), nil + } + + err = s.client.Login(email, password) + if err != nil { + return mcp.NewToolResultError("Login failed: " + err.Error()), nil + } + + userInfo, err := s.client.GetUserInfo() + if err != nil { + return mcp.NewToolResultText("Successfully logged in to OpenLabs, but failed to get user details"), nil + } + + isAdmin := userInfo.Admin + + adminStatus := "Standard User" + capabilities := "You can list/deploy ranges, manage your own resources and credentials" + if isAdmin { + adminStatus = "Administrator" + capabilities = "You have full platform access including blueprint creation/deletion" + } + + return mcp.NewToolResultText(fmt.Sprintf(`Successfully logged in to OpenLabs! + +User Details: +- Name: %s +- Email: %s +- Role: %s + +%s + +All OpenLabs MCP tools are now available for use. The authentication state is now updated for this session.`, + userInfo.Name, userInfo.Email, adminStatus, capabilities)), nil +} + +func requireString(request mcp.CallToolRequest, key string) (string, error) { + args := request.GetArguments() + value, ok := args[key] + if !ok { + return "", fmt.Errorf("missing parameter: %s", key) + } + + if str, ok := value.(string); ok { + return str, nil + } + return "", fmt.Errorf("parameter %s is not a string", key) +} + +func requireInt(request mcp.CallToolRequest, key string) (int, error) { + args := request.GetArguments() + value, ok := args[key] + if !ok { + return 0, fmt.Errorf("missing parameter: %s", key) + } + + switch v := value.(type) { + case int: + return v, nil + case float64: + return int(v), nil + case string: + return strconv.Atoi(v) + default: + return 0, fmt.Errorf("parameter %s is not an integer", key) + } +} + +func requireBool(request mcp.CallToolRequest, key string) (bool, error) { + args := request.GetArguments() + value, ok := args[key] + if !ok { + return false, fmt.Errorf("missing parameter: %s", key) + } + + switch v := value.(type) { + case bool: + return v, nil + case string: + return strconv.ParseBool(v) + default: + return false, fmt.Errorf("parameter %s is not a boolean", key) + } +} + +func getStringOptional(request mcp.CallToolRequest, key string) string { + args := request.GetArguments() + value, ok := args[key] + if !ok { + return "" + } + + if str, ok := value.(string); ok { + return str + } + return "" +} \ No newline at end of file diff --git a/cli/internal/mcp/prompts/capabilities.go b/cli/internal/mcp/prompts/capabilities.go new file mode 100644 index 00000000..9c373343 --- /dev/null +++ b/cli/internal/mcp/prompts/capabilities.go @@ -0,0 +1,21 @@ +package prompts + +const AdminCapabilities = `[+] **Administrator Access**: Full platform capabilities +- Can create and delete blueprints +- Can deploy and destroy ranges +- Can manage all cloud credentials +- Has access to all platform features +- Can perform administrative operations` + +const StandardCapabilities = `[+] **Standard User Access**: Core platform capabilities +- Can list and deploy ranges from existing blueprints +- Can destroy own deployed ranges +- Can manage own cloud credentials +- Can access most platform features +- May have limited blueprint creation capabilities` + +const UnauthenticatedCapabilities = `[-] **Not Authenticated**: No access to OpenLabs resources +- Cannot list or manage ranges +- Cannot access blueprints +- Cannot manage cloud credentials +- Must authenticate first using 'openlabs auth login' or the MCP login tool` \ No newline at end of file diff --git a/cli/internal/mcp/prompts/instructions.go b/cli/internal/mcp/prompts/instructions.go new file mode 100644 index 00000000..c5824b9a --- /dev/null +++ b/cli/internal/mcp/prompts/instructions.go @@ -0,0 +1,67 @@ +package prompts + +const MainInstructionsTemplate = `# OpenLabs MCP Server Instructions + +You are working with the OpenLabs MCP server, which provides access to a cloud-based cybersecurity training and lab deployment platform. + +## Current User Status +%s + +## What is OpenLabs? +OpenLabs allows users to: +- Deploy virtual security training ranges in the cloud (AWS, Azure) +- Access pre-built cybersecurity lab blueprints and scenarios +- Create custom training environments and share them +- Manage cloud credentials and deployments +- Track deployment status and costs + +## CRITICAL Authentication Requirements +[!] **IMPORTANT**: Nearly all OpenLabs operations require authentication. +- Users must be logged in to list, deploy, or manage ranges +- Users must be logged in to access blueprints and create new ones +- Users must be logged in to manage cloud credentials +- Only the 'login' tool and basic status checks work without authentication + +## Authentication Best Practices +1. **PREFERRED METHOD**: Always guide users to use CLI authentication first: + - Ask user to run 'openlabs auth login' in their terminal + - This is the most secure method as credentials don't pass through the LLM + - Then retry the requested operation + +2. **Alternative method** (use only if CLI method fails): + - WARN the user: "For security, 'openlabs auth login' is preferred. MCP login sends credentials through the LLM." + - Only proceed if user explicitly confirms they want to use MCP login + - Call the 'login' tool with email and password parameters + +## User Capabilities +%s + +## Available Operations +**Range Management:** +- List deployed ranges and get details +- Deploy new ranges from blueprints +- Destroy ranges (irreversible - requires confirmation) +- Get SSH keys for accessing deployed ranges + +**Blueprint Management:** +- List available blueprints +- Get blueprint details and specifications +- Create new blueprints from JSON (admin/advanced users) +- Delete blueprints (irreversible - requires confirmation) + +**Cloud Integration:** +- Update AWS credentials (access key, secret key) +- Update Azure credentials (client ID, secret, tenant, subscription) +- Deploy ranges to specific cloud regions + +**Job Monitoring:** +- Check status of deployment/destruction jobs +- Monitor long-running operations + +## Best Practices for LLM Behavior +1. **Always check authentication first** - Use 'get_user_info' or check for auth errors +2. **Guide users to CLI login** - 'openlabs auth login' is the preferred method +3. **Confirm destructive operations** - Ranges and blueprints cannot be recovered once deleted +4. **Provide clear feedback** - Users need to know the status of long-running deployments +5. **Be security conscious** - Warn about credential handling and destructive operations +6. **Handle auth errors properly** - When you get auth errors, guide users to login via CLI first` diff --git a/cli/internal/mcp/server.go b/cli/internal/mcp/server.go new file mode 100644 index 00000000..45eba098 --- /dev/null +++ b/cli/internal/mcp/server.go @@ -0,0 +1,219 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/OpenLabsHQ/OpenLabs/cli/internal/client" + "github.com/OpenLabsHQ/OpenLabs/cli/internal/config" + "github.com/OpenLabsHQ/OpenLabs/cli/internal/logger" + "github.com/OpenLabsHQ/OpenLabs/cli/internal/mcp/prompts" +) + + +type Server struct { + mcpServer *server.MCPServer + client *client.Client + config *config.Config + debug bool +} + +func NewServer(cfg *config.Config, debug bool) (*Server, error) { + if cfg == nil { + return nil, fmt.Errorf("configuration is required") + } + + apiClient := client.New(cfg) + + s := &Server{ + client: apiClient, + config: cfg, + debug: debug, + } + + mcpServer := server.NewMCPServer( + "OpenLabs MCP Server", + "1.0.0", + server.WithToolCapabilities(false), + server.WithRecovery(), + server.WithInstructions(s.getOpenLabsInstructions()), + ) + + s.mcpServer = mcpServer + + if err := s.registerTools(); err != nil { + return nil, fmt.Errorf("failed to register tools: %w", err) + } + + return s, nil +} + +func (s *Server) reloadConfigIfChanged() bool { + newConfig, err := config.Load() + if err != nil { + if s.debug { + logger.Debug("Failed to reload config: %v", err) + } + return false + } + + if newConfig.AuthToken != s.config.AuthToken { + if s.debug { + logger.Debug("Auth token changed, updating client configuration") + } + + s.config = newConfig + s.client = client.New(newConfig) + + return true + } + + return false +} + +func (s *Server) RunStdio(ctx context.Context) error { + if s.debug { + logger.Debug("Starting MCP server with stdio transport") + } + + errChan := make(chan error, 1) + go func() { + errChan <- server.ServeStdio(s.mcpServer) + }() + + select { + case err := <-errChan: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *Server) RunSSE(ctx context.Context, addr string) error { + if s.debug { + logger.Debug("Starting MCP server with SSE transport on %s", addr) + } + + port := ":8080" + if addr != "" { + port = addr + } + + sseServer := server.NewSSEServer( + s.mcpServer, + server.WithSSEEndpoint("/sse"), + server.WithMessageEndpoint("/message"), + server.WithBaseURL(fmt.Sprintf("http://localhost%s", port)), + server.WithUseFullURLForMessageEndpoint(true), + ) + + logger.Info("SSE server listening on %s with endpoints /sse and /message", port) + + errChan := make(chan error, 1) + go func() { + errChan <- sseServer.Start(port) + }() + + select { + case err := <-errChan: + return err + case <-ctx.Done(): + if err := sseServer.Shutdown(context.Background()); err != nil { + logger.Error("Error during SSE server shutdown: %v", err) + } + return ctx.Err() + } +} + +func (s *Server) registerTools() error { + tools := GetAllTools() + + for _, tool := range tools { + handler := s.createToolHandler(tool.Name) + s.mcpServer.AddTool(tool, handler) + } + + return nil +} + +func (s *Server) getOpenLabsInstructions() string { + isAuthenticated := s.client.IsAuthenticated() + var userContext string + var isAdmin bool + + if isAuthenticated { + userInfo, err := s.client.GetUserInfo() + if err != nil { + userContext = "[!] Authentication token present but failed to get user info - token may be expired" + } else { + isAdmin = userInfo.Admin + + adminStatus := "Standard User" + if isAdmin { + adminStatus = "Administrator" + } + + userContext = fmt.Sprintf("[+] Authenticated as: %s (%s) - Role: %s", + userInfo.Name, userInfo.Email, adminStatus) + } + } else { + userContext = "[-] Not authenticated - user must login to access OpenLabs resources" + } + + return fmt.Sprintf(prompts.MainInstructionsTemplate, + userContext, s.getUserCapabilitiesText(isAuthenticated, isAdmin)) +} + +func (s *Server) getUserCapabilitiesText(isAuthenticated, isAdmin bool) string { + if !isAuthenticated { + return prompts.UnauthenticatedCapabilities + } + + if isAdmin { + return prompts.AdminCapabilities + } + + return prompts.StandardCapabilities +} + +func (s *Server) createToolHandler(toolName string) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + logger.Debug("MCP tool invoked: %s", toolName) + + switch toolName { + case "list_ranges": + return s.handleListRanges(ctx, request) + case "get_range_details": + return s.handleGetRangeDetails(ctx, request) + case "get_range_key": + return s.handleGetRangeKey(ctx, request) + case "list_blueprints": + return s.handleListBlueprints(ctx, request) + case "get_blueprint_details": + return s.handleGetBlueprintDetails(ctx, request) + case "check_job_status": + return s.handleCheckJobStatus(ctx, request) + case "get_user_info": + return s.handleGetUserInfo(ctx, request) + case "deploy_range": + return s.handleDeployRange(ctx, request) + case "destroy_range": + return s.handleDestroyRange(ctx, request) + case "create_blueprint": + return s.handleCreateBlueprint(ctx, request) + case "delete_blueprint": + return s.handleDeleteBlueprint(ctx, request) + case "update_aws_secrets": + return s.handleUpdateAWSSecrets(ctx, request) + case "update_azure_secrets": + return s.handleUpdateAzureSecrets(ctx, request) + case "login": + return s.handleLogin(ctx, request) + default: + return mcp.NewToolResultError(fmt.Sprintf("Unknown tool: %s", toolName)), nil + } + } +} \ No newline at end of file diff --git a/cli/internal/mcp/tools.go b/cli/internal/mcp/tools.go new file mode 100644 index 00000000..ba844db2 --- /dev/null +++ b/cli/internal/mcp/tools.go @@ -0,0 +1,189 @@ +package mcp + +import "github.com/mark3labs/mcp-go/mcp" + +func GetAllTools() []mcp.Tool { + return []mcp.Tool{ + mcp.NewTool("list_ranges", + mcp.WithDescription("List all deployed ranges for the authenticated user"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(true), + ), + mcp.NewTool("get_range_details", + mcp.WithDescription("Get detailed information about a specific deployed range"), + mcp.WithNumber("range_id", + mcp.Required(), + mcp.Description("The ID of the range to get details for"), + ), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(true), + ), + mcp.NewTool("get_range_key", + mcp.WithDescription("Get the SSH private key for a specific deployed range"), + mcp.WithNumber("range_id", + mcp.Required(), + mcp.Description("The ID of the range to get the SSH key for"), + ), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(true), + ), + mcp.NewTool("list_blueprints", + mcp.WithDescription("List all available blueprints for the authenticated user"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(true), + ), + mcp.NewTool("get_blueprint_details", + mcp.WithDescription("Get detailed information about a specific blueprint"), + mcp.WithNumber("blueprint_id", + mcp.Required(), + mcp.Description("The ID of the blueprint to get details for"), + ), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(true), + ), + mcp.NewTool("check_job_status", + mcp.WithDescription("Check the status of a specific job"), + mcp.WithString("job_id", + mcp.Required(), + mcp.Description("The ARQ job ID to check status for"), + ), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(true), + ), + mcp.NewTool("get_user_info", + mcp.WithDescription("Get information about the currently authenticated user"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(true), + ), + mcp.NewTool("deploy_range", + mcp.WithDescription("Deploy a new range from a blueprint"), + mcp.WithString("name", + mcp.Required(), + mcp.Description("Name for the new range"), + ), + mcp.WithString("description", + mcp.Description("Optional description for the range"), + ), + mcp.WithNumber("blueprint_id", + mcp.Required(), + mcp.Description("The ID of the blueprint to deploy"), + ), + mcp.WithString("region", + mcp.Required(), + mcp.Description("OpenLabs supported region (us_east_1 or us_east_2)"), + ), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + mcp.WithOpenWorldHintAnnotation(true), + ), + mcp.NewTool("destroy_range", + mcp.WithDescription("Destroy a deployed range (this action is irreversible)"), + mcp.WithNumber("range_id", + mcp.Required(), + mcp.Description("The ID of the range to destroy"), + ), + mcp.WithBoolean("confirm", + mcp.Required(), + mcp.Description("Must be true to confirm destruction"), + ), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(true), + ), + mcp.NewTool("create_blueprint", + mcp.WithDescription("Create a new blueprint from JSON specification"), + mcp.WithObject("blueprint", + mcp.Required(), + mcp.Description("Complete blueprint specification in JSON format"), + ), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(false), + mcp.WithOpenWorldHintAnnotation(true), + ), + mcp.NewTool("delete_blueprint", + mcp.WithDescription("Delete a blueprint (this action is irreversible)"), + mcp.WithNumber("blueprint_id", + mcp.Required(), + mcp.Description("The ID of the blueprint to delete"), + ), + mcp.WithBoolean("confirm", + mcp.Required(), + mcp.Description("Must be true to confirm deletion"), + ), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(true), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(true), + ), + mcp.NewTool("update_aws_secrets", + mcp.WithDescription("Update AWS credentials for cloud deployments"), + mcp.WithString("aws_access_key", + mcp.Required(), + mcp.Description("Access key for AWS account"), + ), + mcp.WithString("aws_secret_key", + mcp.Required(), + mcp.Description("Secret key for AWS account"), + ), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(false), + ), + mcp.NewTool("update_azure_secrets", + mcp.WithDescription("Update Azure credentials for cloud deployments"), + mcp.WithString("azure_client_id", + mcp.Required(), + mcp.Description("Client ID for Azure"), + ), + mcp.WithString("azure_client_secret", + mcp.Required(), + mcp.Description("Client secret for Azure"), + ), + mcp.WithString("azure_tenant_id", + mcp.Required(), + mcp.Description("Tenant ID for Azure"), + ), + mcp.WithString("azure_subscription_id", + mcp.Required(), + mcp.Description("Subscription ID for Azure"), + ), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(false), + ), + mcp.NewTool("login", + mcp.WithDescription("Login to OpenLabs with email and password"), + mcp.WithString("email", + mcp.Required(), + mcp.Description("Email address for authentication"), + ), + mcp.WithString("password", + mcp.Required(), + mcp.Description("Password for authentication"), + ), + mcp.WithReadOnlyHintAnnotation(false), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(false), + ), + } +} diff --git a/cli/internal/mcp/types.go b/cli/internal/mcp/types.go new file mode 100644 index 00000000..ebae1228 --- /dev/null +++ b/cli/internal/mcp/types.go @@ -0,0 +1,145 @@ +package mcp + +import "encoding/json" + + +type Request struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type Response struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Result interface{} `json:"result,omitempty"` +} + +type ErrorResponse struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Error *Error `json:"error"` +} + +type Error struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +const ( + ErrorCodeParseError = -32700 + ErrorCodeInvalidRequest = -32600 + ErrorCodeMethodNotFound = -32601 + ErrorCodeInvalidParams = -32602 + ErrorCodeInternalError = -32603 +) + +type ServerInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type ClientInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type InitializeParams struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities ClientCapabilities `json:"capabilities"` + ClientInfo ClientInfo `json:"clientInfo"` + Meta map[string]interface{} `json:"meta,omitempty"` +} + +type InitializeResult struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities ServerCapabilities `json:"capabilities"` + ServerInfo ServerInfo `json:"serverInfo"` + Meta map[string]interface{} `json:"meta,omitempty"` +} + +type ClientCapabilities struct { + Roots *RootsCapability `json:"roots,omitempty"` + Sampling *SamplingCapability `json:"sampling,omitempty"` + Experimental map[string]interface{} `json:"experimental,omitempty"` +} + +type ServerCapabilities struct { + Logging *LoggingCapability `json:"logging,omitempty"` + Prompts *PromptsCapability `json:"prompts,omitempty"` + Resources *ResourcesCapability `json:"resources,omitempty"` + Tools *ToolsCapability `json:"tools,omitempty"` + Experimental map[string]interface{} `json:"experimental,omitempty"` +} + +type RootsCapability struct { + ListChanged bool `json:"listChanged,omitempty"` +} + +type SamplingCapability struct{} + +type LoggingCapability struct{} + +type PromptsCapability struct { + ListChanged bool `json:"listChanged,omitempty"` +} + +type ResourcesCapability struct { + Subscribe bool `json:"subscribe,omitempty"` + ListChanged bool `json:"listChanged,omitempty"` +} + +type ToolsCapability struct { + ListChanged bool `json:"listChanged,omitempty"` +} + +type Tool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema map[string]interface{} `json:"inputSchema"` +} + +type ToolsListResult struct { + Tools []Tool `json:"tools"` +} + +type CallToolParams struct { + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments,omitempty"` +} + +type CallToolResult struct { + Content []Content `json:"content"` + IsError bool `json:"isError,omitempty"` +} + +type Content struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +type LoggingLevel string + +const ( + LoggingLevelDebug LoggingLevel = "debug" + LoggingLevelInfo LoggingLevel = "info" + LoggingLevelNotice LoggingLevel = "notice" + LoggingLevelWarning LoggingLevel = "warning" + LoggingLevelError LoggingLevel = "error" + LoggingLevelCritical LoggingLevel = "critical" + LoggingLevelAlert LoggingLevel = "alert" + LoggingLevelEmergency LoggingLevel = "emergency" +) + +type SetLevelParams struct { + Level LoggingLevel `json:"level"` +} + +type LoggingMessageParams struct { + Level LoggingLevel `json:"level"` + Data interface{} `json:"data"` + Logger string `json:"logger,omitempty"` +} \ No newline at end of file diff --git a/cli/internal/output/table.go b/cli/internal/output/table.go index a138c0af..11659917 100644 --- a/cli/internal/output/table.go +++ b/cli/internal/output/table.go @@ -162,9 +162,9 @@ func formatFieldValue(val reflect.Value) string { return val.String() case reflect.Bool: if val.Bool() { - return "✓" + return "true" } - return "✗" + return "false" case reflect.Slice: if val.Len() == 0 { return "" @@ -187,3 +187,82 @@ func formatFieldValue(val reflect.Value) string { return fmt.Sprintf("%v", val.Interface()) } } + +// DisplayMCPTools displays MCP tools in a table format, excluding inputSchema and annotations +func DisplayMCPTools(tools interface{}) error { + output, err := formatMCPToolsAsTable(tools) + if err != nil { + return err + } + fmt.Print(output) + return nil +} + +func formatMCPToolsAsTable(data interface{}) (string, error) { + if data == nil { + return "", nil + } + + val := reflect.ValueOf(data) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + if val.Kind() != reflect.Slice { + return "", fmt.Errorf("expected slice of tools") + } + + if val.Len() == 0 { + return "No tools available\n", nil + } + + var buf strings.Builder + table := tablewriter.NewWriter(&buf) + + table.SetHeader([]string{"Name", "Description"}) + table.SetColWidth(80) + table.SetRowLine(true) + table.SetAutoWrapText(false) + + for i := 0; i < val.Len(); i++ { + item := val.Index(i) + if item.Kind() == reflect.Ptr { + item = item.Elem() + } + + if item.Kind() != reflect.Struct { + continue + } + + name := getStructFieldValue(item, "name") + description := getStructFieldValue(item, "description") + + table.Append([]string{name, description}) + } + + table.Render() + return buf.String(), nil +} + +func getStructFieldValue(val reflect.Value, fieldName string) string { + typ := val.Type() + for i := 0; i < val.NumField(); i++ { + field := typ.Field(i) + if !field.IsExported() { + continue + } + + jsonTag := field.Tag.Get("json") + if jsonTag != "" { + tagName := strings.Split(jsonTag, ",")[0] + if tagName == fieldName { + return formatFieldValue(val.Field(i)) + } + } + + if strings.EqualFold(field.Name, fieldName) { + return formatFieldValue(val.Field(i)) + } + } + return "" +}