feat(api): one API for curl and MCP, REST reads with keys, origin badge with key name
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s

API keys reach tasks and lists over plain REST too (GET /task-lists, GET /tasks with
status/due filters, PATCH/DELETE /thoughts/{id}, GET /lists, list items), guarded by a route
table; notes and settings stay closed. Rights hold for every access alike (webhook:task also
allows create_task over MCP, tasks:write also task delivery).

Tasks, thoughts and list items record which key created them and which one changed them last
(migration 13 replaces agent_mark; origin stays after the user's own edits and survives the
triage into a list item). OriginBadge shows the key name with a webhook or robot icon, details
on hover and tap, and replaces the Webhook and KI badges everywhere, now also on list items.

Settings: category "API" with rights grouped by tasks, lists and inbox and two tabs
(curl / smart home with a Home Assistant sensor, AI agents over MCP). Docs, help, README and
changelog updated; v0.30.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 10:26:44 +01:00
1 parent 0ca715ba64
commit b883315914
36 files changed
+1119 -563

No files matched your search

+40 -29
View File
@@ -58,8 +58,10 @@ type mcpTool struct {
Name string
Description string
Scope string
Schema map[string]interface{}
run func(s *Server, userID string, args json.RawMessage) (interface{}, error)
// AltScope: a narrower right that allows this tool too (e.g. "create tasks" for create_task)
AltScope string
Schema map[string]interface{}
run func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error)
}
// mcpToolAllowed: sessions may use every tool, API keys only those of their scopes. search needs any
@@ -82,12 +84,17 @@ func mcpToolAllowed(scopes []string, isKey bool, tool mcpTool) bool {
if tool.Name == "search" {
return has(storage.ScopeTasksRead) || has(storage.ScopeListsRead)
}
return has(tool.Scope)
return has(tool.Scope) || (tool.AltScope != "" && has(tool.AltScope))
}
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
scopes, isKey := r.Context().Value(apiKeyScopesKey{}).([]string)
scopes, isKey := keyScopes(r.Context())
// Changes are recorded as made via MCP, with the key if there is one (a login session has none)
actor := actorFrom(r.Context())
if actor == nil {
actor = &apiActor{Via: storage.OriginMCP}
}
var raw json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
@@ -129,7 +136,7 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
"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. IDs come from the list tools; never guess them. " +
"Entries you create or change are marked for the user until they touch them.",
"Entries you create or change show the user a badge with the name of your API key.",
}, nil)
case "ping":
writeRPC(w, req.ID, map[string]interface{}{}, nil)
@@ -167,7 +174,7 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
if len(p.Arguments) == 0 {
p.Arguments = json.RawMessage("{}")
}
result, err := tool.run(s, userID, p.Arguments)
result, err := tool.run(s, userID, actor, p.Arguments)
if err != nil {
writeRPC(w, req.ID, toolError(err.Error()), nil)
return
@@ -265,13 +272,14 @@ type taskView struct {
Subtasks []domain.SubTask `json:"subtasks,omitempty"`
Recurrence string `json:"recurrence,omitempty"`
CreatedAt string `json:"createdAt"`
Origin *domain.Origin `json:"origin,omitempty"`
}
func toTaskView(t *domain.Thought) taskView {
return taskView{
ID: t.ID, Content: t.Content, IsCompleted: t.IsCompleted, Priority: t.Priority, DueDate: t.DueDate,
TaskListID: t.TaskListID, KanbanStatus: t.KanbanStatus, Notes: t.Notes, Tags: t.Tags, Subtasks: t.Subtasks,
Recurrence: t.Recurrence, CreatedAt: t.CreatedAt,
Recurrence: t.Recurrence, CreatedAt: t.CreatedAt, Origin: t.Origin,
}
}
@@ -333,7 +341,7 @@ var mcpTools = []mcpTool{
Description: "All task lists the user can see, with permission (owner/editor/viewer), the number of open tasks and the board columns (valid kanbanStatus values). Tasks without a list live in the personal inbox list (taskListId empty).",
Scope: storage.ScopeTasksRead,
Schema: schema(nil, map[string]interface{}{}),
run: func(s *Server, userID string, _ json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, _ *apiActor, _ json.RawMessage) (interface{}, error) {
lists, err := s.store.GetTaskLists(userID)
if err != nil {
return nil, err
@@ -353,7 +361,7 @@ var mcpTools = []mcpTool{
"title": prop("string", "Name of the task list"),
"icon": prop("string", "One emoji, optional (default 📋)"),
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
Title string `json:"title"`
Icon string `json:"icon"`
@@ -383,7 +391,7 @@ var mcpTools = []mcpTool{
"dueBefore": prop("string", "Only tasks due on or before this date (YYYY-MM-DD); tasks without due date are left out"),
"limit": prop("integer", "Maximum number of tasks (default 100)"),
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
TaskListID string `json:"taskListId"`
Status string `json:"status"`
@@ -437,7 +445,7 @@ var mcpTools = []mcpTool{
Description: "One task with all details.",
Scope: storage.ScopeTasksRead,
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
ID string `json:"id"`
}
@@ -455,6 +463,7 @@ var mcpTools = []mcpTool{
Name: "create_task",
Description: "Create a task, optionally in a task list the user may edit. dueDate as YYYY-MM-DD or YYYY-MM-DDTHH:MM.",
Scope: storage.ScopeTasksWrite,
AltScope: storage.ScopeTask,
Schema: schema([]string{"content"}, map[string]interface{}{
"content": prop("string", "Task text; #hashtags become tags"),
"taskListId": prop("string", "Task list id (from list_task_lists); empty = personal tasks"),
@@ -462,7 +471,7 @@ var mcpTools = []mcpTool{
"dueDate": prop("string", "YYYY-MM-DD or YYYY-MM-DDTHH:MM"),
"notes": prop("string", "Longer description"),
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
Content string `json:"content"`
TaskListID string `json:"taskListId"`
@@ -476,7 +485,7 @@ var mcpTools = []mcpTool{
if strings.TrimSpace(a.Content) == "" {
return nil, errors.New("'content' darf nicht leer sein")
}
t, err := s.createTask(userID, a.Content, a.Priority, "agent", a.DueDate, a.TaskListID, a.Notes)
t, err := s.createTask(userID, a.Content, a.Priority, "agent", a.DueDate, a.TaskListID, a.Notes, actor)
if err != nil {
return nil, err
}
@@ -498,7 +507,7 @@ var mcpTools = []mcpTool{
"taskListId": prop("string", "Move into another task list"),
"tags": map[string]interface{}{"type": "array", "items": map[string]string{"type": "string"}},
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
ID string `json:"id"`
taskPatch
@@ -507,7 +516,7 @@ var mcpTools = []mcpTool{
return nil, err
}
a.taskPatch.Assignees, a.taskPatch.Recurrence, a.taskPatch.Subtasks = nil, nil, nil
a.taskPatch.byAgent = true
a.taskPatch.actor = actor
t, err := s.updateTask(userID, a.ID, a.taskPatch)
if err != nil {
return nil, err
@@ -523,7 +532,7 @@ var mcpTools = []mcpTool{
"id": prop("string", "Task id"),
"completed": prop("boolean", "Default true"),
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
ID string `json:"id"`
Completed *bool `json:"completed"`
@@ -532,7 +541,7 @@ var mcpTools = []mcpTool{
return nil, err
}
done := a.Completed == nil || *a.Completed
t, err := s.updateTask(userID, a.ID, taskPatch{IsCompleted: &done, byAgent: true})
t, err := s.updateTask(userID, a.ID, taskPatch{IsCompleted: &done, actor: actor})
if err != nil {
return nil, err
}
@@ -544,7 +553,7 @@ var mcpTools = []mcpTool{
Description: "Delete a task for good, e.g. one created by mistake. Prefer complete_task for work that is done.",
Scope: storage.ScopeTasksWrite,
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
ID string `json:"id"`
}
@@ -565,7 +574,7 @@ var mcpTools = []mcpTool{
Description: "Shopping lists, checklists and other shared lists the user can see, with open/total item counts.",
Scope: storage.ScopeListsRead,
Schema: schema(nil, map[string]interface{}{}),
run: func(s *Server, userID string, _ json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, _ *apiActor, _ json.RawMessage) (interface{}, error) {
lists, err := s.store.GetAllListsForUser(userID)
if err != nil {
return nil, err
@@ -586,7 +595,7 @@ var mcpTools = []mcpTool{
"icon": prop("string", "One emoji, optional"),
"listType": map[string]interface{}{"type": "string", "enum": []string{"shopping", "checklist", "vacation"}, "description": "Default shopping"},
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
Title string `json:"title"`
Icon string `json:"icon"`
@@ -611,7 +620,7 @@ var mcpTools = []mcpTool{
Description: "One list with all its items (id, content, category, quantity, isCompleted).",
Scope: storage.ScopeListsRead,
Schema: schema([]string{"listId"}, map[string]interface{}{"listId": prop("string", "List id from list_lists")}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
ListID string `json:"listId"`
}
@@ -629,13 +638,14 @@ var mcpTools = []mcpTool{
Name: "add_list_item",
Description: "Add an item to a list the user may edit.",
Scope: storage.ScopeListsWrite,
AltScope: storage.ScopeList,
Schema: schema([]string{"listId", "content"}, map[string]interface{}{
"listId": prop("string", "List id"),
"content": prop("string", "Item text"),
"category": prop("string", "Category/aisle, e.g. Obst"),
"quantity": prop("string", "Amount, e.g. 2 or 500 g"),
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
ListID, Content, Category, Quantity string
}
@@ -645,7 +655,7 @@ var mcpTools = []mcpTool{
if strings.TrimSpace(a.Content) == "" {
return nil, errors.New("'content' darf nicht leer sein")
}
return s.addListItem(userID, a.ListID, a.Content, a.Category, a.Quantity, true)
return s.addListItem(userID, a.ListID, a.Content, a.Category, a.Quantity, actor)
},
},
{
@@ -657,7 +667,7 @@ var mcpTools = []mcpTool{
"itemId": prop("string", "Item id from get_list"),
"completed": prop("boolean", "Default true"),
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
ListID string `json:"listId"`
ItemID string `json:"itemId"`
@@ -666,7 +676,7 @@ var mcpTools = []mcpTool{
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
return s.setListItemDone(userID, a.ListID, a.ItemID, a.Completed == nil || *a.Completed, true)
return s.setListItemDone(userID, a.ListID, a.ItemID, a.Completed == nil || *a.Completed, actor)
},
},
{
@@ -680,7 +690,7 @@ var mcpTools = []mcpTool{
"category": prop("string", "New category"),
"quantity": prop("string", "New amount"),
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
ListID string `json:"listId"`
ItemID string `json:"itemId"`
@@ -698,7 +708,7 @@ var mcpTools = []mcpTool{
if err != nil {
return nil, err
}
item.AgentMark = s.store.SetListItemAgentMark(a.ItemID, true)
s.recordListItemOrigin(item, actor, false)
s.wsHub.BroadcastListEvent(a.ListID, "ITEM_UPDATED", item)
return item, nil
},
@@ -708,7 +718,7 @@ var mcpTools = []mcpTool{
Description: "Full-text search over tasks and list items the user can see (notes are not included for API keys).",
Scope: storage.ScopeTasksRead,
Schema: schema([]string{"query"}, map[string]interface{}{"query": prop("string", "Words to search for")}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
Query string `json:"query"`
}
@@ -735,7 +745,7 @@ var mcpTools = []mcpTool{
"priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}},
"dueDate": prop("string", "YYYY-MM-DD, optional"),
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
Content, Priority, DueDate string
}
@@ -752,6 +762,7 @@ var mcpTools = []mcpTool{
if err != nil {
return nil, err
}
s.recordThoughtOrigin(t, actor, true)
s.wsHub.BroadcastToUsers(s.taskAudience(t), "TASK_CREATED", t)
return map[string]interface{}{"id": t.ID, "content": t.Content, "status": fmt.Sprint(t.Status)}, nil
},