diff --git a/cmd/serve/serve.go b/cmd/serve/serve.go index 11bfd49..3fb531a 100644 --- a/cmd/serve/serve.go +++ b/cmd/serve/serve.go @@ -61,5 +61,7 @@ func runServeMCP(f *cmdutil.Factory) error { fmt.Fprintf(f.IOStreams.ErrOut, "Warning: tool registration failed: %v\n", err) } + registerStaticTools(server, f) + return server.Run(context.Background(), &mcp.StdioTransport{}) } diff --git a/cmd/serve/tools.go b/cmd/serve/tools.go index 40a6e6c..d5ae677 100644 --- a/cmd/serve/tools.go +++ b/cmd/serve/tools.go @@ -128,3 +128,327 @@ func MakeToolHandler(f *cmdutil.Factory, actionType string) mcp.ToolHandler { }, nil } } + +// getStringArg extracts a string value from the args map. +func getStringArg(args map[string]any, key string) string { + if v, ok := args[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// makeStaticHandler creates a handler for a static tool that calls the +// KeeperHub API directly with a configurable HTTP method and URL pattern. +func makeStaticHandler( + f *cmdutil.Factory, + method string, + buildURL func(args map[string]any, baseURL string) string, + buildBody func(args map[string]any) ([]byte, error), +) mcp.ToolHandler { + return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args map[string]any + if req.Params.Arguments != nil { + if unmarshalErr := json.Unmarshal(req.Params.Arguments, &args); unmarshalErr != nil { + return nil, fmt.Errorf("unmarshaling arguments: %w", unmarshalErr) + } + } + if args == nil { + args = make(map[string]any) + } + + client, err := f.HTTPClient() + if err != nil { + return nil, fmt.Errorf("creating HTTP client: %w", err) + } + + cfg, err := f.Config() + if err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + + baseURL := khhttp.BuildBaseURL(cmdutil.ResolveHost(nil, cfg)) + targetURL := buildURL(args, baseURL) + + var body io.Reader + if buildBody != nil { + bodyBytes, bodyErr := buildBody(args) + if bodyErr != nil { + return nil, fmt.Errorf("building request body: %w", bodyErr) + } + body = bytes.NewReader(bodyBytes) + } + + httpReq, err := client.NewRequest(method, targetURL, body) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + if body != nil { + httpReq.Header.Set("Content-Type", "application/json") + } + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("executing request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + + if resp.StatusCode >= 400 { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(respBody))}, + }, + IsError: true, + }, nil + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(respBody)}, + }, + }, nil + } +} + +// registerStaticTools registers workflow management and execution tools that +// call KeeperHub API endpoints directly (not via /api/execute/). +func registerStaticTools(server *mcp.Server, f *cmdutil.Factory) { + // workflow_list -- GET /api/workflows + server.AddTool(&mcp.Tool{ + Name: "workflow_list", + Description: "List all workflows in the current organization", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "limit": map[string]any{ + "type": "string", + "description": "Maximum number of workflows to return", + }, + }, + }, + }, makeStaticHandler(f, http.MethodGet, func(args map[string]any, baseURL string) string { + u := baseURL + "/api/workflows" + if limit := getStringArg(args, "limit"); limit != "" { + u += "?limit=" + limit + } + return u + }, nil)) + + // workflow_get -- GET /api/workflows/{id} + server.AddTool(&mcp.Tool{ + Name: "workflow_get", + Description: "Get a workflow by ID, including its nodes and edges", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "workflow_id": map[string]any{ + "type": "string", + "description": "The workflow ID", + }, + }, + "required": []string{"workflow_id"}, + }, + }, makeStaticHandler(f, http.MethodGet, func(args map[string]any, baseURL string) string { + return baseURL + "/api/workflows/" + getStringArg(args, "workflow_id") + }, nil)) + + // workflow_create -- POST /api/workflows/create + server.AddTool(&mcp.Tool{ + Name: "workflow_create", + Description: "Create a new workflow", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + "description": "Workflow name", + }, + "description": map[string]any{ + "type": "string", + "description": "Workflow description", + }, + "nodes": map[string]any{ + "type": "string", + "description": "JSON string of the nodes array", + }, + "edges": map[string]any{ + "type": "string", + "description": "JSON string of the edges array", + }, + }, + "required": []string{"name"}, + }, + }, makeStaticHandler(f, http.MethodPost, func(args map[string]any, baseURL string) string { + return baseURL + "/api/workflows/create" + }, func(args map[string]any) ([]byte, error) { + body := map[string]any{ + "name": getStringArg(args, "name"), + } + if desc := getStringArg(args, "description"); desc != "" { + body["description"] = desc + } + if nodesStr := getStringArg(args, "nodes"); nodesStr != "" { + var nodes []interface{} + if err := json.Unmarshal([]byte(nodesStr), &nodes); err != nil { + return nil, fmt.Errorf("parsing nodes JSON: %w", err) + } + body["nodes"] = nodes + } + if edgesStr := getStringArg(args, "edges"); edgesStr != "" { + var edges []interface{} + if err := json.Unmarshal([]byte(edgesStr), &edges); err != nil { + return nil, fmt.Errorf("parsing edges JSON: %w", err) + } + body["edges"] = edges + } + return json.Marshal(body) + })) + + // workflow_update -- PATCH /api/workflows/{id} + server.AddTool(&mcp.Tool{ + Name: "workflow_update", + Description: "Update an existing workflow", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "workflow_id": map[string]any{ + "type": "string", + "description": "The workflow ID to update", + }, + "name": map[string]any{ + "type": "string", + "description": "New workflow name", + }, + "description": map[string]any{ + "type": "string", + "description": "New workflow description", + }, + "nodes": map[string]any{ + "type": "string", + "description": "JSON string of the nodes array", + }, + "edges": map[string]any{ + "type": "string", + "description": "JSON string of the edges array", + }, + }, + "required": []string{"workflow_id"}, + }, + }, makeStaticHandler(f, http.MethodPatch, func(args map[string]any, baseURL string) string { + return baseURL + "/api/workflows/" + getStringArg(args, "workflow_id") + }, func(args map[string]any) ([]byte, error) { + body := make(map[string]any) + if name := getStringArg(args, "name"); name != "" { + body["name"] = name + } + if desc := getStringArg(args, "description"); desc != "" { + body["description"] = desc + } + if nodesStr := getStringArg(args, "nodes"); nodesStr != "" { + var nodes []interface{} + if err := json.Unmarshal([]byte(nodesStr), &nodes); err != nil { + return nil, fmt.Errorf("parsing nodes JSON: %w", err) + } + body["nodes"] = nodes + } + if edgesStr := getStringArg(args, "edges"); edgesStr != "" { + var edges []interface{} + if err := json.Unmarshal([]byte(edgesStr), &edges); err != nil { + return nil, fmt.Errorf("parsing edges JSON: %w", err) + } + body["edges"] = edges + } + return json.Marshal(body) + })) + + // workflow_delete -- DELETE /api/workflows/{id} + server.AddTool(&mcp.Tool{ + Name: "workflow_delete", + Description: "Delete a workflow by ID", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "workflow_id": map[string]any{ + "type": "string", + "description": "The workflow ID to delete", + }, + }, + "required": []string{"workflow_id"}, + }, + }, makeStaticHandler(f, http.MethodDelete, func(args map[string]any, baseURL string) string { + return baseURL + "/api/workflows/" + getStringArg(args, "workflow_id") + }, nil)) + + // workflow_execute -- POST /api/workflow/{id}/execute + server.AddTool(&mcp.Tool{ + Name: "workflow_execute", + Description: "Execute a workflow by ID", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "workflow_id": map[string]any{ + "type": "string", + "description": "The workflow ID to execute", + }, + "input": map[string]any{ + "type": "string", + "description": "JSON string of input data for the execution", + }, + }, + "required": []string{"workflow_id"}, + }, + }, makeStaticHandler(f, http.MethodPost, func(args map[string]any, baseURL string) string { + return baseURL + "/api/workflow/" + getStringArg(args, "workflow_id") + "/execute" + }, func(args map[string]any) ([]byte, error) { + if inputStr := getStringArg(args, "input"); inputStr != "" { + var input map[string]interface{} + if err := json.Unmarshal([]byte(inputStr), &input); err != nil { + return nil, fmt.Errorf("parsing input JSON: %w", err) + } + return json.Marshal(input) + } + return []byte("{}"), nil + })) + + // execution_status -- GET /api/workflows/executions/{id}/status + server.AddTool(&mcp.Tool{ + Name: "execution_status", + Description: "Get the status of a workflow execution", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "execution_id": map[string]any{ + "type": "string", + "description": "The execution ID to check", + }, + }, + "required": []string{"execution_id"}, + }, + }, makeStaticHandler(f, http.MethodGet, func(args map[string]any, baseURL string) string { + return baseURL + "/api/workflows/executions/" + getStringArg(args, "execution_id") + "/status" + }, nil)) + + // execution_logs -- GET /api/workflows/executions/{id}/logs + server.AddTool(&mcp.Tool{ + Name: "execution_logs", + Description: "Get the logs for a workflow execution", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "execution_id": map[string]any{ + "type": "string", + "description": "The execution ID to get logs for", + }, + }, + "required": []string{"execution_id"}, + }, + }, makeStaticHandler(f, http.MethodGet, func(args map[string]any, baseURL string) string { + return baseURL + "/api/workflows/executions/" + getStringArg(args, "execution_id") + "/logs" + }, nil)) +} diff --git a/cmd/workflow/create.go b/cmd/workflow/create.go new file mode 100644 index 0000000..28cbe12 --- /dev/null +++ b/cmd/workflow/create.go @@ -0,0 +1,171 @@ +package workflow + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/jedib0t/go-pretty/v6/table" + khhttp "github.com/keeperhub/cli/internal/http" + "github.com/keeperhub/cli/internal/output" + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +type createRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Nodes []interface{} `json:"nodes"` + Edges []interface{} `json:"edges"` +} + +type createResponse struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"createdAt"` +} + +func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a workflow", + Aliases: []string{"new"}, + Args: cobra.NoArgs, + Example: ` # Create an empty workflow + kh wf create --name "My Workflow" + + # Create with nodes from a JSON file + kh wf create --name "DeFi Monitor" --nodes-file workflow.json + + # Create with inline JSON nodes + kh wf create --name "Test" --nodes '[{"id":"t1","type":"trigger","position":{"x":0,"y":0},"data":{"type":"trigger","config":{"triggerType":"Manual"}}}]'`, + RunE: func(cmd *cobra.Command, args []string) error { + name, err := cmd.Flags().GetString("name") + if err != nil { + return err + } + if name == "" { + return cmdutil.FlagError{Err: fmt.Errorf("--name is required")} + } + + description, err := cmd.Flags().GetString("description") + if err != nil { + return err + } + + nodesFile, err := cmd.Flags().GetString("nodes-file") + if err != nil { + return err + } + + nodesInline, err := cmd.Flags().GetString("nodes") + if err != nil { + return err + } + + edgesInline, err := cmd.Flags().GetString("edges") + if err != nil { + return err + } + + body := createRequest{ + Name: name, + Description: description, + Nodes: []interface{}{}, + Edges: []interface{}{}, + } + + // Load nodes/edges from file if provided + if nodesFile != "" { + fileData, readErr := os.ReadFile(nodesFile) + if readErr != nil { + return fmt.Errorf("reading nodes file: %w", readErr) + } + + var fileContent struct { + Nodes []interface{} `json:"nodes"` + Edges []interface{} `json:"edges"` + } + if unmarshalErr := json.Unmarshal(fileData, &fileContent); unmarshalErr != nil { + return fmt.Errorf("parsing nodes file: %w", unmarshalErr) + } + body.Nodes = fileContent.Nodes + body.Edges = fileContent.Edges + } + + // Inline nodes override file + if nodesInline != "" { + var nodes []interface{} + if unmarshalErr := json.Unmarshal([]byte(nodesInline), &nodes); unmarshalErr != nil { + return fmt.Errorf("parsing --nodes JSON: %w", unmarshalErr) + } + body.Nodes = nodes + } + + // Inline edges override file + if edgesInline != "" { + var edges []interface{} + if unmarshalErr := json.Unmarshal([]byte(edgesInline), &edges); unmarshalErr != nil { + return fmt.Errorf("parsing --edges JSON: %w", unmarshalErr) + } + body.Edges = edges + } + + client, err := f.HTTPClient() + if err != nil { + return err + } + cfg, err := f.Config() + if err != nil { + return err + } + host := cmdutil.ResolveHost(cmd, cfg) + + bodyBytes, err := json.Marshal(body) + if err != nil { + return err + } + + url := khhttp.BuildBaseURL(host) + "/api/workflows/create" + req, err := client.NewRequest(http.MethodPost, url, bytes.NewReader(bodyBytes)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("HTTP 401: unauthorized, run 'kh auth login' first") + } + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return khhttp.NewAPIError(resp) + } + + var result createResponse + if decodeErr := json.NewDecoder(resp.Body).Decode(&result); decodeErr != nil { + return fmt.Errorf("decoding create response: %w", decodeErr) + } + + p := output.NewPrinter(f.IOStreams, cmd) + return p.PrintData(result, func(tw table.Writer) { + fmt.Fprintf(f.IOStreams.Out, "Created workflow: %s (%s)\n", result.Name, result.ID) + tw.Render() + }) + }, + } + + cmd.Flags().String("name", "", "Workflow name (required)") + cmd.Flags().String("description", "", "Workflow description") + cmd.Flags().String("nodes-file", "", "Path to JSON file with nodes and edges") + cmd.Flags().String("nodes", "", "Inline JSON array of nodes") + cmd.Flags().String("edges", "", "Inline JSON array of edges") + + return cmd +} diff --git a/cmd/workflow/delete.go b/cmd/workflow/delete.go new file mode 100644 index 0000000..47698c4 --- /dev/null +++ b/cmd/workflow/delete.go @@ -0,0 +1,95 @@ +package workflow + +import ( + "bufio" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/jedib0t/go-pretty/v6/table" + khhttp "github.com/keeperhub/cli/internal/http" + "github.com/keeperhub/cli/internal/output" + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewDeleteCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a workflow", + Aliases: []string{"rm"}, + Args: cobra.ExactArgs(1), + Example: ` # Delete a workflow (will prompt for confirmation) + kh wf delete abc123 + + # Delete without prompting + kh wf delete abc123 --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + workflowID := args[0] + + yes, err := cmd.Flags().GetBool("yes") + if err != nil { + yes = false + } + + if !yes && f.IOStreams.IsTerminal() { + fmt.Fprintf(f.IOStreams.Out, "Delete workflow %s? This cannot be undone. (y/N) ", workflowID) + scanner := bufio.NewScanner(f.IOStreams.In) + if scanner.Scan() { + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + return cmdutil.CancelError{Err: fmt.Errorf("delete cancelled")} + } + } + } + + client, err := f.HTTPClient() + if err != nil { + return err + } + cfg, err := f.Config() + if err != nil { + return err + } + host := cmdutil.ResolveHost(cmd, cfg) + + url := khhttp.BuildBaseURL(host) + "/api/workflows/" + workflowID + req, err := client.NewRequest(http.MethodDelete, url, nil) + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("cannot delete workflow %s: it may have existing runs that prevent deletion", workflowID) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return cmdutil.NotFoundError{Err: fmt.Errorf("workflow %q not found", workflowID)} + } + if resp.StatusCode == http.StatusInternalServerError { + return fmt.Errorf("cannot delete workflow %s: workflow has existing runs that prevent deletion", workflowID) + } + if resp.StatusCode != http.StatusOK { + return khhttp.NewAPIError(resp) + } + + var result map[string]interface{} + if decodeErr := json.NewDecoder(resp.Body).Decode(&result); decodeErr != nil { + return fmt.Errorf("decoding delete response: %w", decodeErr) + } + + p := output.NewPrinter(f.IOStreams, cmd) + return p.PrintData(result, func(tw table.Writer) { + fmt.Fprintf(f.IOStreams.Out, "Workflow %s deleted\n", workflowID) + tw.Render() + }) + }, + } + + cmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/cmd/workflow/update.go b/cmd/workflow/update.go new file mode 100644 index 0000000..d5532cb --- /dev/null +++ b/cmd/workflow/update.go @@ -0,0 +1,129 @@ +package workflow + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/jedib0t/go-pretty/v6/table" + khhttp "github.com/keeperhub/cli/internal/http" + "github.com/keeperhub/cli/internal/output" + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +type updateRequest struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Nodes []interface{} `json:"nodes,omitempty"` + Edges []interface{} `json:"edges,omitempty"` +} + +func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a workflow", + Args: cobra.ExactArgs(1), + Example: ` # Update workflow name + kh wf update abc123 --name "New Name" + + # Update nodes from file + kh wf update abc123 --nodes-file workflow.json`, + RunE: func(cmd *cobra.Command, args []string) error { + workflowID := args[0] + + name, err := cmd.Flags().GetString("name") + if err != nil { + return err + } + description, err := cmd.Flags().GetString("description") + if err != nil { + return err + } + nodesFile, err := cmd.Flags().GetString("nodes-file") + if err != nil { + return err + } + + body := updateRequest{} + + if name != "" { + body.Name = name + } + if description != "" { + body.Description = description + } + + if nodesFile != "" { + fileData, readErr := os.ReadFile(nodesFile) + if readErr != nil { + return fmt.Errorf("reading nodes file: %w", readErr) + } + + var fileContent struct { + Nodes []interface{} `json:"nodes"` + Edges []interface{} `json:"edges"` + } + if unmarshalErr := json.Unmarshal(fileData, &fileContent); unmarshalErr != nil { + return fmt.Errorf("parsing nodes file: %w", unmarshalErr) + } + body.Nodes = fileContent.Nodes + body.Edges = fileContent.Edges + } + + client, err := f.HTTPClient() + if err != nil { + return err + } + cfg, err := f.Config() + if err != nil { + return err + } + host := cmdutil.ResolveHost(cmd, cfg) + + bodyBytes, err := json.Marshal(body) + if err != nil { + return err + } + + url := khhttp.BuildBaseURL(host) + "/api/workflows/" + workflowID + req, err := client.NewRequest(http.MethodPatch, url, bytes.NewReader(bodyBytes)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return cmdutil.NotFoundError{Err: fmt.Errorf("workflow %q not found", workflowID)} + } + if resp.StatusCode != http.StatusOK { + return khhttp.NewAPIError(resp) + } + + var result map[string]interface{} + if decodeErr := json.NewDecoder(resp.Body).Decode(&result); decodeErr != nil { + return fmt.Errorf("decoding update response: %w", decodeErr) + } + + p := output.NewPrinter(f.IOStreams, cmd) + return p.PrintData(result, func(tw table.Writer) { + fmt.Fprintf(f.IOStreams.Out, "Workflow %s updated\n", workflowID) + tw.Render() + }) + }, + } + + cmd.Flags().String("name", "", "New workflow name") + cmd.Flags().String("description", "", "New workflow description") + cmd.Flags().String("nodes-file", "", "Path to JSON file with nodes and edges") + + return cmd +} diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index 95f104b..3d2d175 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -23,6 +23,9 @@ func NewWorkflowCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(NewListCmd(f)) cmd.AddCommand(NewRunCmd(f)) cmd.AddCommand(NewGetCmd(f)) + cmd.AddCommand(NewCreateCmd(f)) + cmd.AddCommand(NewDeleteCmd(f)) + cmd.AddCommand(NewUpdateCmd(f)) cmd.AddCommand(NewGoLiveCmd(f)) cmd.AddCommand(NewPauseCmd(f)) diff --git a/cmd/workflow/workflow_test.go b/cmd/workflow/workflow_test.go index 6fd708a..7009b11 100644 --- a/cmd/workflow/workflow_test.go +++ b/cmd/workflow/workflow_test.go @@ -19,11 +19,11 @@ func newTestFactory() *cmdutil.Factory { } } -func TestWorkflowCmdHas5Subcommands(t *testing.T) { +func TestWorkflowCmdHas8Subcommands(t *testing.T) { f := newTestFactory() wfCmd := workflow.NewWorkflowCmd(f) cmds := wfCmd.Commands() - assert.Equal(t, 5, len(cmds), "expected 5 subcommands: list, run, get, go-live, pause") + assert.Equal(t, 8, len(cmds), "expected 8 subcommands: list, run, get, go-live, pause, create, delete, update") } func TestWorkflowCmdHasAlias(t *testing.T) {