Skip to content

Commit 0a174d4

Browse files
authored
Merge pull request #18 from KeeperHub/feature/workflow-crud-mcp-tools
feat: add workflow CRUD commands and MCP workflow tools
2 parents 837cb3a + 58e1815 commit 0a174d4

7 files changed

Lines changed: 726 additions & 2 deletions

File tree

cmd/serve/serve.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,7 @@ func runServeMCP(f *cmdutil.Factory) error {
6161
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: tool registration failed: %v\n", err)
6262
}
6363

64+
registerStaticTools(server, f)
65+
6466
return server.Run(context.Background(), &mcp.StdioTransport{})
6567
}

cmd/serve/tools.go

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,327 @@ func MakeToolHandler(f *cmdutil.Factory, actionType string) mcp.ToolHandler {
128128
}, nil
129129
}
130130
}
131+
132+
// getStringArg extracts a string value from the args map.
133+
func getStringArg(args map[string]any, key string) string {
134+
if v, ok := args[key]; ok {
135+
if s, ok := v.(string); ok {
136+
return s
137+
}
138+
}
139+
return ""
140+
}
141+
142+
// makeStaticHandler creates a handler for a static tool that calls the
143+
// KeeperHub API directly with a configurable HTTP method and URL pattern.
144+
func makeStaticHandler(
145+
f *cmdutil.Factory,
146+
method string,
147+
buildURL func(args map[string]any, baseURL string) string,
148+
buildBody func(args map[string]any) ([]byte, error),
149+
) mcp.ToolHandler {
150+
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
151+
var args map[string]any
152+
if req.Params.Arguments != nil {
153+
if unmarshalErr := json.Unmarshal(req.Params.Arguments, &args); unmarshalErr != nil {
154+
return nil, fmt.Errorf("unmarshaling arguments: %w", unmarshalErr)
155+
}
156+
}
157+
if args == nil {
158+
args = make(map[string]any)
159+
}
160+
161+
client, err := f.HTTPClient()
162+
if err != nil {
163+
return nil, fmt.Errorf("creating HTTP client: %w", err)
164+
}
165+
166+
cfg, err := f.Config()
167+
if err != nil {
168+
return nil, fmt.Errorf("reading config: %w", err)
169+
}
170+
171+
baseURL := khhttp.BuildBaseURL(cmdutil.ResolveHost(nil, cfg))
172+
targetURL := buildURL(args, baseURL)
173+
174+
var body io.Reader
175+
if buildBody != nil {
176+
bodyBytes, bodyErr := buildBody(args)
177+
if bodyErr != nil {
178+
return nil, fmt.Errorf("building request body: %w", bodyErr)
179+
}
180+
body = bytes.NewReader(bodyBytes)
181+
}
182+
183+
httpReq, err := client.NewRequest(method, targetURL, body)
184+
if err != nil {
185+
return nil, fmt.Errorf("building request: %w", err)
186+
}
187+
if body != nil {
188+
httpReq.Header.Set("Content-Type", "application/json")
189+
}
190+
191+
resp, err := client.Do(httpReq)
192+
if err != nil {
193+
return nil, fmt.Errorf("executing request: %w", err)
194+
}
195+
defer resp.Body.Close()
196+
197+
respBody, err := io.ReadAll(resp.Body)
198+
if err != nil {
199+
return nil, fmt.Errorf("reading response body: %w", err)
200+
}
201+
202+
if resp.StatusCode >= 400 {
203+
return &mcp.CallToolResult{
204+
Content: []mcp.Content{
205+
&mcp.TextContent{Text: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(respBody))},
206+
},
207+
IsError: true,
208+
}, nil
209+
}
210+
211+
return &mcp.CallToolResult{
212+
Content: []mcp.Content{
213+
&mcp.TextContent{Text: string(respBody)},
214+
},
215+
}, nil
216+
}
217+
}
218+
219+
// registerStaticTools registers workflow management and execution tools that
220+
// call KeeperHub API endpoints directly (not via /api/execute/).
221+
func registerStaticTools(server *mcp.Server, f *cmdutil.Factory) {
222+
// workflow_list -- GET /api/workflows
223+
server.AddTool(&mcp.Tool{
224+
Name: "workflow_list",
225+
Description: "List all workflows in the current organization",
226+
InputSchema: map[string]any{
227+
"type": "object",
228+
"properties": map[string]any{
229+
"limit": map[string]any{
230+
"type": "string",
231+
"description": "Maximum number of workflows to return",
232+
},
233+
},
234+
},
235+
}, makeStaticHandler(f, http.MethodGet, func(args map[string]any, baseURL string) string {
236+
u := baseURL + "/api/workflows"
237+
if limit := getStringArg(args, "limit"); limit != "" {
238+
u += "?limit=" + limit
239+
}
240+
return u
241+
}, nil))
242+
243+
// workflow_get -- GET /api/workflows/{id}
244+
server.AddTool(&mcp.Tool{
245+
Name: "workflow_get",
246+
Description: "Get a workflow by ID, including its nodes and edges",
247+
InputSchema: map[string]any{
248+
"type": "object",
249+
"properties": map[string]any{
250+
"workflow_id": map[string]any{
251+
"type": "string",
252+
"description": "The workflow ID",
253+
},
254+
},
255+
"required": []string{"workflow_id"},
256+
},
257+
}, makeStaticHandler(f, http.MethodGet, func(args map[string]any, baseURL string) string {
258+
return baseURL + "/api/workflows/" + getStringArg(args, "workflow_id")
259+
}, nil))
260+
261+
// workflow_create -- POST /api/workflows/create
262+
server.AddTool(&mcp.Tool{
263+
Name: "workflow_create",
264+
Description: "Create a new workflow",
265+
InputSchema: map[string]any{
266+
"type": "object",
267+
"properties": map[string]any{
268+
"name": map[string]any{
269+
"type": "string",
270+
"description": "Workflow name",
271+
},
272+
"description": map[string]any{
273+
"type": "string",
274+
"description": "Workflow description",
275+
},
276+
"nodes": map[string]any{
277+
"type": "string",
278+
"description": "JSON string of the nodes array",
279+
},
280+
"edges": map[string]any{
281+
"type": "string",
282+
"description": "JSON string of the edges array",
283+
},
284+
},
285+
"required": []string{"name"},
286+
},
287+
}, makeStaticHandler(f, http.MethodPost, func(args map[string]any, baseURL string) string {
288+
return baseURL + "/api/workflows/create"
289+
}, func(args map[string]any) ([]byte, error) {
290+
body := map[string]any{
291+
"name": getStringArg(args, "name"),
292+
}
293+
if desc := getStringArg(args, "description"); desc != "" {
294+
body["description"] = desc
295+
}
296+
if nodesStr := getStringArg(args, "nodes"); nodesStr != "" {
297+
var nodes []interface{}
298+
if err := json.Unmarshal([]byte(nodesStr), &nodes); err != nil {
299+
return nil, fmt.Errorf("parsing nodes JSON: %w", err)
300+
}
301+
body["nodes"] = nodes
302+
}
303+
if edgesStr := getStringArg(args, "edges"); edgesStr != "" {
304+
var edges []interface{}
305+
if err := json.Unmarshal([]byte(edgesStr), &edges); err != nil {
306+
return nil, fmt.Errorf("parsing edges JSON: %w", err)
307+
}
308+
body["edges"] = edges
309+
}
310+
return json.Marshal(body)
311+
}))
312+
313+
// workflow_update -- PATCH /api/workflows/{id}
314+
server.AddTool(&mcp.Tool{
315+
Name: "workflow_update",
316+
Description: "Update an existing workflow",
317+
InputSchema: map[string]any{
318+
"type": "object",
319+
"properties": map[string]any{
320+
"workflow_id": map[string]any{
321+
"type": "string",
322+
"description": "The workflow ID to update",
323+
},
324+
"name": map[string]any{
325+
"type": "string",
326+
"description": "New workflow name",
327+
},
328+
"description": map[string]any{
329+
"type": "string",
330+
"description": "New workflow description",
331+
},
332+
"nodes": map[string]any{
333+
"type": "string",
334+
"description": "JSON string of the nodes array",
335+
},
336+
"edges": map[string]any{
337+
"type": "string",
338+
"description": "JSON string of the edges array",
339+
},
340+
},
341+
"required": []string{"workflow_id"},
342+
},
343+
}, makeStaticHandler(f, http.MethodPatch, func(args map[string]any, baseURL string) string {
344+
return baseURL + "/api/workflows/" + getStringArg(args, "workflow_id")
345+
}, func(args map[string]any) ([]byte, error) {
346+
body := make(map[string]any)
347+
if name := getStringArg(args, "name"); name != "" {
348+
body["name"] = name
349+
}
350+
if desc := getStringArg(args, "description"); desc != "" {
351+
body["description"] = desc
352+
}
353+
if nodesStr := getStringArg(args, "nodes"); nodesStr != "" {
354+
var nodes []interface{}
355+
if err := json.Unmarshal([]byte(nodesStr), &nodes); err != nil {
356+
return nil, fmt.Errorf("parsing nodes JSON: %w", err)
357+
}
358+
body["nodes"] = nodes
359+
}
360+
if edgesStr := getStringArg(args, "edges"); edgesStr != "" {
361+
var edges []interface{}
362+
if err := json.Unmarshal([]byte(edgesStr), &edges); err != nil {
363+
return nil, fmt.Errorf("parsing edges JSON: %w", err)
364+
}
365+
body["edges"] = edges
366+
}
367+
return json.Marshal(body)
368+
}))
369+
370+
// workflow_delete -- DELETE /api/workflows/{id}
371+
server.AddTool(&mcp.Tool{
372+
Name: "workflow_delete",
373+
Description: "Delete a workflow by ID",
374+
InputSchema: map[string]any{
375+
"type": "object",
376+
"properties": map[string]any{
377+
"workflow_id": map[string]any{
378+
"type": "string",
379+
"description": "The workflow ID to delete",
380+
},
381+
},
382+
"required": []string{"workflow_id"},
383+
},
384+
}, makeStaticHandler(f, http.MethodDelete, func(args map[string]any, baseURL string) string {
385+
return baseURL + "/api/workflows/" + getStringArg(args, "workflow_id")
386+
}, nil))
387+
388+
// workflow_execute -- POST /api/workflow/{id}/execute
389+
server.AddTool(&mcp.Tool{
390+
Name: "workflow_execute",
391+
Description: "Execute a workflow by ID",
392+
InputSchema: map[string]any{
393+
"type": "object",
394+
"properties": map[string]any{
395+
"workflow_id": map[string]any{
396+
"type": "string",
397+
"description": "The workflow ID to execute",
398+
},
399+
"input": map[string]any{
400+
"type": "string",
401+
"description": "JSON string of input data for the execution",
402+
},
403+
},
404+
"required": []string{"workflow_id"},
405+
},
406+
}, makeStaticHandler(f, http.MethodPost, func(args map[string]any, baseURL string) string {
407+
return baseURL + "/api/workflow/" + getStringArg(args, "workflow_id") + "/execute"
408+
}, func(args map[string]any) ([]byte, error) {
409+
if inputStr := getStringArg(args, "input"); inputStr != "" {
410+
var input map[string]interface{}
411+
if err := json.Unmarshal([]byte(inputStr), &input); err != nil {
412+
return nil, fmt.Errorf("parsing input JSON: %w", err)
413+
}
414+
return json.Marshal(input)
415+
}
416+
return []byte("{}"), nil
417+
}))
418+
419+
// execution_status -- GET /api/workflows/executions/{id}/status
420+
server.AddTool(&mcp.Tool{
421+
Name: "execution_status",
422+
Description: "Get the status of a workflow execution",
423+
InputSchema: map[string]any{
424+
"type": "object",
425+
"properties": map[string]any{
426+
"execution_id": map[string]any{
427+
"type": "string",
428+
"description": "The execution ID to check",
429+
},
430+
},
431+
"required": []string{"execution_id"},
432+
},
433+
}, makeStaticHandler(f, http.MethodGet, func(args map[string]any, baseURL string) string {
434+
return baseURL + "/api/workflows/executions/" + getStringArg(args, "execution_id") + "/status"
435+
}, nil))
436+
437+
// execution_logs -- GET /api/workflows/executions/{id}/logs
438+
server.AddTool(&mcp.Tool{
439+
Name: "execution_logs",
440+
Description: "Get the logs for a workflow execution",
441+
InputSchema: map[string]any{
442+
"type": "object",
443+
"properties": map[string]any{
444+
"execution_id": map[string]any{
445+
"type": "string",
446+
"description": "The execution ID to get logs for",
447+
},
448+
},
449+
"required": []string{"execution_id"},
450+
},
451+
}, makeStaticHandler(f, http.MethodGet, func(args map[string]any, baseURL string) string {
452+
return baseURL + "/api/workflows/executions/" + getStringArg(args, "execution_id") + "/logs"
453+
}, nil))
454+
}

0 commit comments

Comments
 (0)