From e60e825b3cf8a5f01a47967529ab611d27e3414f Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 10:40:17 +0100 Subject: [PATCH] feat(mcp): agents can add, tick, rename and remove subtasks Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 2 + .../src/features/settings/ApiKeysPanel.tsx | 3 +- internal/api/mcp.go | 98 ++++++++++++++++++- internal/api/mcp_test.go | 57 +++++++++++ 4 files changed, 157 insertions(+), 3 deletions(-) diff --git a/Doku/api/rest.md b/Doku/api/rest.md index addcef6..ffffefb 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -94,6 +94,8 @@ Betreiber können MCP abschalten (`MCP_ENABLED=false` oder Schalter in der Admin | `create_task` | `tasks:write` | Aufgabe anlegen (`content`, `taskListId`, `priority`, `dueDate`, `notes`); Quelle `agent` | | `update_task` | `tasks:write` | Felder ändern (`content`, `isCompleted`, `dueDate`, `priority`, `notes`, `kanbanStatus`, `taskListId`, `tags`) | | `complete_task` | `tasks:write` | Abhaken (`completed` false = wieder öffnen) | +| `add_subtask` | `tasks:write` | Unteraufgabe anhängen (`taskId`, `title`) | +| `update_subtask` | `tasks:write` | Unteraufgabe abhaken (`completed`), umbenennen (`title`) oder entfernen (`delete`) | | `delete_task` | `tasks:write` | Aufgabe löschen (z. B. versehentlich angelegte); sie kommt in den Papierkorb der Aufgaben | | `list_lists` | `lists:read` | Geteilte Listen mit offenen Einträgen | | `create_list` | `lists:write` | Liste anlegen (`title`, `icon`, `listType` shopping/checklist/vacation) | diff --git a/frontend/src/features/settings/ApiKeysPanel.tsx b/frontend/src/features/settings/ApiKeysPanel.tsx index 7495147..3a4bcea 100644 --- a/frontend/src/features/settings/ApiKeysPanel.tsx +++ b/frontend/src/features/settings/ApiKeysPanel.tsx @@ -632,7 +632,8 @@ http_headers = { "X-API-Key" = "${exampleToken}" }` Werkzeuge: list_task_lists, create_task_list,{' '} list_tasks, get_task,{' '} create_task, update_task,{' '} - complete_task, delete_task,{' '} + complete_task, add_subtask,{' '} + update_subtask, delete_task,{' '} list_lists, create_list,{' '} get_list, add_list_item,{' '} check_list_item, update_list_item,{' '} diff --git a/internal/api/mcp.go b/internal/api/mcp.go index efcf722..17bc265 100644 --- a/internal/api/mcp.go +++ b/internal/api/mcp.go @@ -7,6 +7,7 @@ import ( "net/http" "sort" "strings" + "time" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/domain" @@ -134,8 +135,9 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) { "capabilities": map[string]interface{}{"tools": map[string]interface{}{}}, "serverInfo": map[string]string{"name": "MindSift", "version": mcpServerVersion}, "instructions": "MindSift is a personal notes, tasks and lists app. Use list_task_lists and list_tasks " + - "to see open tasks, complete_task to check one off, list_lists/get_list for shopping and other " + - "lists and check_list_item to tick items, list_notes/get_note to read notes and create_note for a " + + "to see open tasks, complete_task to check one off, add_subtask/update_subtask for a task's " + + "checklist, list_lists/get_list for shopping and other lists and check_list_item to tick items, " + + "list_notes/get_note to read notes and create_note for a " + "new one. IDs come from the list tools; never guess them. Tasks and list items you create or " + "change show the user a badge with the name of your API key.", }, nil) @@ -604,6 +606,98 @@ var mcpTools = []mcpTool{ return toTaskView(t), nil }, }, + { + Name: "add_subtask", + Description: "Add a subtask (checklist item) to a task. Returns the task with all its subtasks.", + Scope: storage.ScopeTasksWrite, + Schema: schema([]string{"taskId", "title"}, map[string]interface{}{ + "taskId": prop("string", "Task id"), + "title": prop("string", "Subtask text"), + }), + run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) { + var a struct { + TaskID string `json:"taskId"` + Title string `json:"title"` + } + if err := decodeArgs(args, &a); err != nil { + return nil, err + } + title := strings.TrimSpace(a.Title) + if title == "" { + return nil, errors.New("'title' darf nicht leer sein") + } + t, err := s.store.GetThoughtByID(userID, a.TaskID) + if err != nil || t == nil { + return nil, errors.New("Aufgabe nicht gefunden") + } + subs := append(append([]domain.SubTask{}, t.Subtasks...), domain.SubTask{ + ID: domain.NewID("st"), Title: title, CreatedAt: time.Now().UTC().Format(time.RFC3339), + }) + updated, err := s.updateTask(userID, a.TaskID, taskPatch{Subtasks: &subs, actor: actor}) + if err != nil { + return nil, err + } + return toTaskView(updated), nil + }, + }, + { + Name: "update_subtask", + Description: "Tick a subtask off (completed), reopen it (completed=false), rename it (title) or remove it (delete=true).", + Scope: storage.ScopeTasksWrite, + Schema: schema([]string{"taskId", "subtaskId"}, map[string]interface{}{ + "taskId": prop("string", "Task id"), + "subtaskId": prop("string", "Subtask id from get_task"), + "completed": prop("boolean", "true = done"), + "title": prop("string", "New text"), + "delete": prop("boolean", "true = remove the subtask"), + }), + run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) { + var a struct { + TaskID string `json:"taskId"` + SubtaskID string `json:"subtaskId"` + Completed *bool `json:"completed"` + Title *string `json:"title"` + Delete bool `json:"delete"` + } + if err := decodeArgs(args, &a); err != nil { + return nil, err + } + if a.Title != nil && strings.TrimSpace(*a.Title) == "" { + return nil, errors.New("'title' darf nicht leer sein") + } + t, err := s.store.GetThoughtByID(userID, a.TaskID) + if err != nil || t == nil { + return nil, errors.New("Aufgabe nicht gefunden") + } + subs := make([]domain.SubTask, 0, len(t.Subtasks)) + found := false + for _, st := range t.Subtasks { + if st.ID != a.SubtaskID { + subs = append(subs, st) + continue + } + found = true + if a.Delete { + continue + } + if a.Completed != nil { + st.IsCompleted = *a.Completed + } + if a.Title != nil { + st.Title = strings.TrimSpace(*a.Title) + } + subs = append(subs, st) + } + if !found { + return nil, errors.New("Unteraufgabe nicht gefunden") + } + updated, err := s.updateTask(userID, a.TaskID, taskPatch{Subtasks: &subs, actor: actor}) + if err != nil { + return nil, err + } + return toTaskView(updated), nil + }, + }, { Name: "delete_task", Description: "Delete a task, e.g. one created by mistake: it goes to the task trash, where the user can restore it. Prefer complete_task for work that is done.", diff --git a/internal/api/mcp_test.go b/internal/api/mcp_test.go index b27b19b..5fb1257 100644 --- a/internal/api/mcp_test.go +++ b/internal/api/mcp_test.go @@ -289,6 +289,63 @@ func TestMCPCreateDeleteAndOrigin(t *testing.T) { } } +// Agents manage a task's subtasks one at a time; the rest of the checklist stays as it is. +func TestMCPSubtasks(t *testing.T) { + srv, store, token := setupTestServer(t) + router := srv.Router() + claims, _ := auth.ValidateToken(testJWTSecret, token) + key, _ := store.CreateAPIKey(claims.UserID, "Claude Code", []string{"tasks:read", "tasks:write"}) + call := func(id int, name, args string) (map[string]interface{}, bool) { + t.Helper() + _, res := rpc(t, router, key.RawKey, fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"tools/call","params":{"name":%q,"arguments":%s}}`, id, name, args)) + result, isErr := callResult(t, res) + if isErr { + return result, true + } + return result["structuredContent"].(map[string]interface{}), false + } + + task, _ := srv.createTask(claims.UserID, "Umzug", "", "manual", "", "", "", nil) + call(1, "add_subtask", `{"taskId":"`+task.ID+`","title":"Kartons kaufen"}`) + res, isErr := call(2, "add_subtask", `{"taskId":"`+task.ID+`","title":" Strom ummelden "}`) + if isErr { + t.Fatalf("add_subtask: %v", res) + } + subs := res["subtasks"].([]interface{}) + if len(subs) != 2 || subs[1].(map[string]interface{})["title"] != "Strom ummelden" { + t.Fatalf("subtasks after adding: %v", subs) + } + first := subs[0].(map[string]interface{})["id"].(string) + second := subs[1].(map[string]interface{})["id"].(string) + + call(3, "update_subtask", `{"taskId":"`+task.ID+`","subtaskId":"`+first+`","completed":true}`) + call(4, "update_subtask", `{"taskId":"`+task.ID+`","subtaskId":"`+second+`","title":"Strom und Gas ummelden"}`) + th, _ := store.GetThoughtByID(claims.UserID, task.ID) + if len(th.Subtasks) != 2 || !th.Subtasks[0].IsCompleted || th.Subtasks[1].IsCompleted || th.Subtasks[1].Title != "Strom und Gas ummelden" { + t.Fatalf("subtasks after updating: %+v", th.Subtasks) + } + if th.Origin == nil || th.Origin.ChangedVia != "mcp" { + t.Fatalf("subtask change should count as agent change: %+v", th.Origin) + } + + call(5, "update_subtask", `{"taskId":"`+task.ID+`","subtaskId":"`+first+`","delete":true}`) + if th, _ = store.GetThoughtByID(claims.UserID, task.ID); len(th.Subtasks) != 1 || th.Subtasks[0].ID != second { + t.Fatalf("subtasks after deleting: %+v", th.Subtasks) + } + + if _, isErr = call(6, "update_subtask", `{"taskId":"`+task.ID+`","subtaskId":"st_unknown","completed":true}`); !isErr { + t.Fatalf("unknown subtask must fail") + } + if _, isErr = call(7, "add_subtask", `{"taskId":"`+task.ID+`","title":" "}`); !isErr { + t.Fatalf("empty title must fail") + } + other, _ := store.CreateUser("other@example.com", "hash", "Other", "member") + foreign, _ := store.CreateThoughtWithDetails(other.ID, "Fremd", "normal", "manual", "", "", "todo", "") + if _, isErr = call(8, "add_subtask", `{"taskId":"`+foreign.ID+`","title":"Spam"}`); !isErr { + t.Fatalf("key must not change foreign tasks") + } +} + // Operators can switch MCP off: in the admin settings, or fixed by MCP_ENABLED. OAuth goes with it. func TestMCPSwitchOff(t *testing.T) { srv, _, session := setupTestServer(t)