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
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:
1 parent
0ca715ba64
commit
b883315914
36 files changed
+1119
-563
No files matched your search
+40
-29
@@ -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
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user