package api import ( "encoding/json" "errors" "fmt" "net/http" "sort" "strings" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/domain" "github.com/sandrodach/mindsift/internal/storage" ) // MCP (Model Context Protocol) endpoint for AI agents: Streamable HTTP, stateless, JSON-RPC 2.0 over // POST. Authentication runs through authMiddleware (API key with agent scopes or a session); every // tool checks the scope it needs (mcpToolAllowed). There is no server-to-client stream (GET answers // 405), so each request is one JSON response. const mcpPath = "/api/v1/mcp" // Version of the tool set, not of MindSift (the binary does not know its release) const mcpServerVersion = "1" // Protocol versions this server speaks; the newest is the default var mcpProtocolVersions = []string{"2025-06-18", "2025-03-26", "2024-11-05"} type jsonrpcRequest struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id,omitempty"` Method string `json:"method"` Params json.RawMessage `json:"params,omitempty"` } type jsonrpcError struct { Code int `json:"code"` Message string `json:"message"` } type jsonrpcResponse struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` Result interface{} `json:"result,omitempty"` Error *jsonrpcError `json:"error,omitempty"` } const ( rpcParseError = -32700 rpcInvalidRequest = -32600 rpcMethodNotFound = -32601 rpcInvalidParams = -32602 ) // mcpTool is one callable tool: the scope an API key needs for it and the function doing the work. // run returns the structured result; an error becomes a tool error (isError) the agent can read. type mcpTool struct { Name string Description string Scope string Schema map[string]interface{} run func(s *Server, userID string, args json.RawMessage) (interface{}, error) } // mcpToolAllowed: sessions may use every tool, API keys only those of their scopes. search needs any // read scope (its results are filtered to those scopes). func mcpToolAllowed(scopes []string, isKey bool, tool mcpTool) bool { if !isKey { return true } if tool.Scope == "" { return true } has := func(sc string) bool { for _, have := range scopes { if have == sc { return true } } return false } if tool.Name == "search" { return has(storage.ScopeTasksRead) || has(storage.ScopeListsRead) } return has(tool.Scope) } func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) scopes, isKey := r.Context().Value(apiKeyScopesKey{}).([]string) var raw json.RawMessage if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { writeRPC(w, nil, nil, &jsonrpcError{rpcParseError, "Ungültiges JSON"}) return } trimmed := strings.TrimSpace(string(raw)) if strings.HasPrefix(trimmed, "[") { writeRPC(w, nil, nil, &jsonrpcError{rpcInvalidRequest, "Batch-Anfragen werden nicht unterstützt"}) return } var req jsonrpcRequest if err := json.Unmarshal(raw, &req); err != nil || req.Method == "" { writeRPC(w, nil, nil, &jsonrpcError{rpcInvalidRequest, "Ungültige JSON-RPC-Anfrage"}) return } // Notifications (no id) are acknowledged without a body if len(req.ID) == 0 || string(req.ID) == "null" { w.WriteHeader(http.StatusAccepted) return } switch req.Method { case "initialize": var p struct { ProtocolVersion string `json:"protocolVersion"` } _ = json.Unmarshal(req.Params, &p) version := mcpProtocolVersions[0] for _, v := range mcpProtocolVersions { if v == p.ProtocolVersion { version = v } } writeRPC(w, req.ID, map[string]interface{}{ "protocolVersion": version, "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. IDs come from the list tools; never guess them. " + "Entries you create or change are marked for the user until they touch them.", }, nil) case "ping": writeRPC(w, req.ID, map[string]interface{}{}, nil) case "tools/list": tools := []map[string]interface{}{} for _, t := range mcpTools { if !mcpToolAllowed(scopes, isKey, t) { continue } tools = append(tools, map[string]interface{}{ "name": t.Name, "description": t.Description, "inputSchema": t.Schema, }) } writeRPC(w, req.ID, map[string]interface{}{"tools": tools}, nil) case "tools/call": var p struct { Name string `json:"name"` Arguments json.RawMessage `json:"arguments"` } if err := json.Unmarshal(req.Params, &p); err != nil || p.Name == "" { writeRPC(w, req.ID, nil, &jsonrpcError{rpcInvalidParams, "Parameter 'name' fehlt"}) return } tool, ok := mcpToolByName(p.Name) if !ok { writeRPC(w, req.ID, nil, &jsonrpcError{rpcInvalidParams, "Unbekanntes Tool: " + p.Name}) return } if !mcpToolAllowed(scopes, isKey, tool) { writeRPC(w, req.ID, toolError("Der API-Key hat keine Berechtigung für "+tool.Name+" (Scope "+tool.Scope+" fehlt)"), nil) return } if len(p.Arguments) == 0 { p.Arguments = json.RawMessage("{}") } result, err := tool.run(s, userID, p.Arguments) if err != nil { writeRPC(w, req.ID, toolError(err.Error()), nil) return } if tool.Name == "search" && isKey { result = filterSearchForScopes(result, scopes) } writeRPC(w, req.ID, toolResult(result), nil) default: writeRPC(w, req.ID, nil, &jsonrpcError{rpcMethodNotFound, "Methode nicht unterstützt: " + req.Method}) } } // handleMCPNoStream: the endpoint is stateless and has no server-initiated stream (GET) or session to // end (DELETE). func handleMCPNoStream(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Allow", "POST") writeError(w, http.StatusMethodNotAllowed, "Der MCP-Endpunkt nimmt nur POST an (kein Server-Stream)") } func writeRPC(w http.ResponseWriter, id json.RawMessage, result interface{}, rpcErr *jsonrpcError) { if len(id) == 0 { id = json.RawMessage("null") } writeJSON(w, http.StatusOK, jsonrpcResponse{JSONRPC: "2.0", ID: id, Result: result, Error: rpcErr}) } // toolResult: the data as structuredContent plus the same JSON as text for clients without // structured output. func toolResult(data interface{}) map[string]interface{} { text, _ := json.MarshalIndent(data, "", " ") return map[string]interface{}{ "content": []map[string]string{{"type": "text", "text": string(text)}}, "structuredContent": wrapStructured(data), } } // structuredContent must be an object; arrays are wrapped func wrapStructured(data interface{}) interface{} { raw, _ := json.Marshal(data) if strings.HasPrefix(strings.TrimSpace(string(raw)), "[") { return map[string]interface{}{"items": data} } return data } func toolError(msg string) map[string]interface{} { return map[string]interface{}{ "content": []map[string]string{{"type": "text", "text": msg}}, "isError": true, } } // --- tools --- func mcpToolByName(name string) (mcpTool, bool) { for _, t := range mcpTools { if t.Name == name { return t, true } } return mcpTool{}, false } func schema(required []string, props map[string]interface{}) map[string]interface{} { out := map[string]interface{}{"type": "object", "properties": props} if len(required) > 0 { out["required"] = required } return out } func prop(typ, desc string) map[string]interface{} { return map[string]interface{}{"type": typ, "description": desc} } func decodeArgs(args json.RawMessage, into interface{}) error { if err := json.Unmarshal(args, into); err != nil { return errors.New("Ungültige Argumente: " + err.Error()) } return nil } // taskView is what an agent gets per task: the fields it can reason about, no internal bookkeeping. type taskView struct { ID string `json:"id"` Content string `json:"content"` IsCompleted bool `json:"isCompleted"` Priority string `json:"priority"` DueDate string `json:"dueDate,omitempty"` TaskListID string `json:"taskListId,omitempty"` KanbanStatus string `json:"kanbanStatus,omitempty"` Notes string `json:"notes,omitempty"` Tags []string `json:"tags,omitempty"` Subtasks []domain.SubTask `json:"subtasks,omitempty"` Recurrence string `json:"recurrence,omitempty"` CreatedAt string `json:"createdAt"` } 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, } } type taskListView struct { ID string `json:"id"` Title string `json:"title"` Icon string `json:"icon,omitempty"` Permission string `json:"permission"` OpenTasks int `json:"openTasks"` Columns []columnView `json:"columns,omitempty"` } // columnView: a board column, so an agent knows the valid kanbanStatus values of a task list type columnView struct { ID string `json:"id"` Name string `json:"name"` IsDone bool `json:"isDone,omitempty"` } func toTaskListView(l domain.TaskList) taskListView { cols := make([]columnView, 0, len(l.Columns)) for _, c := range l.Columns { cols = append(cols, columnView{ID: c.ID, Name: c.Name, IsDone: c.IsDone}) } permission := l.Permission if permission == "" && l.IsOwner { permission = "owner" } return taskListView{ID: l.ID, Title: l.Title, Icon: l.Icon, Permission: permission, OpenTasks: l.TasksCount, Columns: cols} } type listView struct { ID string `json:"id"` Title string `json:"title"` Icon string `json:"icon,omitempty"` ListType string `json:"listType"` Role string `json:"role"` OpenItems int `json:"openItems"` Items int `json:"items"` } func toListView(l domain.SharedList) listView { open := 0 for _, it := range l.Items { if !it.IsCompleted { open++ } } role := l.Role if l.IsOwner { role = "owner" } return listView{ID: l.ID, Title: l.Title, Icon: l.Icon, ListType: l.ListType, Role: role, OpenItems: open, Items: len(l.Items)} } var mcpTools = []mcpTool{ { Name: "list_task_lists", 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) { lists, err := s.store.GetTaskLists(userID) if err != nil { return nil, err } out := make([]taskListView, 0, len(lists)) for _, l := range lists { out = append(out, toTaskListView(l)) } return out, nil }, }, { Name: "create_task_list", Description: "Create a personal task list (board with the default columns). Returns it with its id and columns.", Scope: storage.ScopeTasksWrite, Schema: schema([]string{"title"}, map[string]interface{}{ "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) { var a struct { Title string `json:"title"` Icon string `json:"icon"` } if err := decodeArgs(args, &a); err != nil { return nil, err } if strings.TrimSpace(a.Title) == "" { return nil, errors.New("'title' darf nicht leer sein") } tl, err := s.store.CreateTaskList(userID, "", "", a.Title, a.Icon, "") if err != nil { return nil, err } s.broadcastTaskListChange(tl.ID, nil, "TASK_LIST_CREATED") tl.IsOwner = true return toTaskListView(*tl), nil }, }, { Name: "list_tasks", Description: "Tasks the user can see. Defaults to open tasks across all lists; filter by taskListId, status (open|done|all) or dueBefore (YYYY-MM-DD, inclusive). Sorted: overdue and due first.", Scope: storage.ScopeTasksRead, Schema: schema(nil, map[string]interface{}{ "taskListId": prop("string", "Only tasks of this task list"), "status": map[string]interface{}{"type": "string", "enum": []string{"open", "done", "all"}, "description": "Default open"}, "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) { var a struct { TaskListID string `json:"taskListId"` Status string `json:"status"` DueBefore string `json:"dueBefore"` Limit int `json:"limit"` } if err := decodeArgs(args, &a); err != nil { return nil, err } tasks, err := s.store.GetTasks(userID, a.TaskListID) if err != nil { return nil, err } if a.Limit <= 0 { a.Limit = 100 } out := make([]taskView, 0) for i := range tasks { t := &tasks[i] switch a.Status { case "done": if !t.IsCompleted { continue } case "all": default: if t.IsCompleted { continue } } if a.DueBefore != "" && (t.DueDate == "" || t.DueDate[:min(10, len(t.DueDate))] > a.DueBefore) { continue } out = append(out, toTaskView(t)) } // Due dates first (earliest on top), then the rest in list order sort.SliceStable(out, func(i, j int) bool { if (out[i].DueDate == "") != (out[j].DueDate == "") { return out[i].DueDate != "" } return out[i].DueDate != "" && out[i].DueDate < out[j].DueDate }) if len(out) > a.Limit { out = out[:a.Limit] } return out, nil }, }, { Name: "get_task", 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) { var a struct { ID string `json:"id"` } if err := decodeArgs(args, &a); err != nil { return nil, err } t, err := s.store.GetThoughtByID(userID, a.ID) if err != nil || t == nil { return nil, errors.New("Aufgabe nicht gefunden") } return toTaskView(t), nil }, }, { 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, 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"), "priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}}, "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) { var a struct { Content string `json:"content"` TaskListID string `json:"taskListId"` Priority string `json:"priority"` DueDate string `json:"dueDate"` Notes string `json:"notes"` } if err := decodeArgs(args, &a); err != nil { return nil, err } 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) if err != nil { return nil, err } return toTaskView(t), nil }, }, { Name: "update_task", Description: "Change fields of a task; only the given fields change. Empty dueDate removes the due date.", Scope: storage.ScopeTasksWrite, Schema: schema([]string{"id"}, map[string]interface{}{ "id": prop("string", "Task id"), "content": prop("string", "New text"), "isCompleted": prop("boolean", "true = done"), "dueDate": prop("string", "YYYY-MM-DD, YYYY-MM-DDTHH:MM or empty"), "priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}}, "notes": prop("string", "Description"), "kanbanStatus": prop("string", "Column id of the task list's board (e.g. todo, in_progress, done)"), "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) { var a struct { ID string `json:"id"` taskPatch } if err := decodeArgs(args, &a); err != nil { return nil, err } a.taskPatch.Assignees, a.taskPatch.Recurrence, a.taskPatch.Subtasks = nil, nil, nil a.taskPatch.byAgent = true t, err := s.updateTask(userID, a.ID, a.taskPatch) if err != nil { return nil, err } return toTaskView(t), nil }, }, { Name: "complete_task", Description: "Check a task off (or reopen it with completed=false). Recurring tasks move to their next date.", Scope: storage.ScopeTasksWrite, Schema: schema([]string{"id"}, map[string]interface{}{ "id": prop("string", "Task id"), "completed": prop("boolean", "Default true"), }), run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) { var a struct { ID string `json:"id"` Completed *bool `json:"completed"` } if err := decodeArgs(args, &a); err != nil { return nil, err } done := a.Completed == nil || *a.Completed t, err := s.updateTask(userID, a.ID, taskPatch{IsCompleted: &done, byAgent: true}) if err != nil { return nil, err } return toTaskView(t), nil }, }, { Name: "delete_task", 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) { var a struct { ID string `json:"id"` } if err := decodeArgs(args, &a); err != nil { return nil, err } if _, err := s.store.GetThoughtByID(userID, a.ID); err != nil { return nil, errors.New("Aufgabe nicht gefunden") } if err := s.deleteTask(userID, a.ID); err != nil { return nil, err } return map[string]interface{}{"id": a.ID, "deleted": true}, nil }, }, { Name: "list_lists", 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) { lists, err := s.store.GetAllListsForUser(userID) if err != nil { return nil, err } out := make([]listView, 0, len(lists)) for _, l := range lists { out = append(out, toListView(l)) } return out, nil }, }, { Name: "create_list", Description: "Create a list for the user: shopping (items grouped by category), checklist or vacation (packing list).", Scope: storage.ScopeListsWrite, Schema: schema([]string{"title"}, map[string]interface{}{ "title": prop("string", "Name of the list"), "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) { var a struct { Title string `json:"title"` Icon string `json:"icon"` ListType string `json:"listType"` } if err := decodeArgs(args, &a); err != nil { return nil, err } if strings.TrimSpace(a.Title) == "" { return nil, errors.New("'title' darf nicht leer sein") } l, err := s.store.CreateList(userID, a.Title, a.Icon, a.ListType) if err != nil { return nil, err } s.wsHub.BroadcastListEvent(l.ID, "LIST_CREATED", l) return toListView(*l), nil }, }, { Name: "get_list", 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) { var a struct { ListID string `json:"listId"` } if err := decodeArgs(args, &a); err != nil { return nil, err } l, err := s.store.GetListByID(userID, a.ListID) if err != nil || l == nil { return nil, errors.New("Liste nicht gefunden") } return map[string]interface{}{"list": toListView(*l), "items": l.Items}, nil }, }, { Name: "add_list_item", Description: "Add an item to a list the user may edit.", Scope: storage.ScopeListsWrite, 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) { var a struct { ListID, Content, Category, Quantity string } if err := decodeArgs(args, &a); err != nil { return nil, err } 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) }, }, { Name: "check_list_item", Description: "Tick an item off a list (or untick it with completed=false).", Scope: storage.ScopeListsWrite, Schema: schema([]string{"listId", "itemId"}, map[string]interface{}{ "listId": prop("string", "List id"), "itemId": prop("string", "Item id from get_list"), "completed": prop("boolean", "Default true"), }), run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) { var a struct { ListID string `json:"listId"` ItemID string `json:"itemId"` Completed *bool `json:"completed"` } if err := decodeArgs(args, &a); err != nil { return nil, err } return s.setListItemDone(userID, a.ListID, a.ItemID, a.Completed == nil || *a.Completed, true) }, }, { Name: "update_list_item", Description: "Change text, category or quantity of a list item; only the given fields change.", Scope: storage.ScopeListsWrite, Schema: schema([]string{"listId", "itemId"}, map[string]interface{}{ "listId": prop("string", "List id"), "itemId": prop("string", "Item id"), "content": prop("string", "New text"), "category": prop("string", "New category"), "quantity": prop("string", "New amount"), }), run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) { var a struct { ListID string `json:"listId"` ItemID string `json:"itemId"` Content *string `json:"content"` Category *string `json:"category"` Quantity *string `json:"quantity"` } if err := decodeArgs(args, &a); err != nil { return nil, err } if err := s.store.EnsureListItemsWritable(userID, a.ListID, a.ItemID); err != nil { return nil, err } item, err := s.store.UpdateListItem(a.ItemID, a.Content, a.Category, a.Quantity) if err != nil { return nil, err } item.AgentMark = s.store.SetListItemAgentMark(a.ItemID, true) s.wsHub.BroadcastListEvent(a.ListID, "ITEM_UPDATED", item) return item, nil }, }, { Name: "search", 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) { var a struct { Query string `json:"query"` } if err := decodeArgs(args, &a); err != nil { return nil, err } res, err := s.store.SearchGlobal(userID, a.Query) if err != nil { return nil, err } tasks := make([]taskView, 0, len(res.Thoughts)) for i := range res.Thoughts { tasks = append(tasks, toTaskView(&res.Thoughts[i])) } return map[string]interface{}{"tasks": tasks, "listItems": res.ListItems}, nil }, }, { Name: "capture_to_inbox", Description: "Drop a thought into the user's inbox for later triage (not a task yet).", Scope: storage.ScopeTriage, Schema: schema([]string{"content"}, map[string]interface{}{ "content": prop("string", "The thought"), "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) { var a struct { Content, Priority, DueDate string } if err := decodeArgs(args, &a); err != nil { return nil, err } if strings.TrimSpace(a.Content) == "" { return nil, errors.New("'content' darf nicht leer sein") } if a.Priority == "" { a.Priority = "normal" } t, err := s.store.CreateThought(userID, a.Content, a.Priority, "agent", a.DueDate) if err != nil { return nil, err } 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 }, }, } // filterSearchForScopes drops the parts of a search result the key may not read. func filterSearchForScopes(result interface{}, scopes []string) interface{} { m, ok := result.(map[string]interface{}) if !ok { return result } has := map[string]bool{} for _, sc := range scopes { has[sc] = true } if !has[storage.ScopeTasksRead] { m["tasks"] = []taskView{} } if !has[storage.ScopeListsRead] { m["listItems"] = []domain.ListItem{} } return m }