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)