Files
MindSift/internal/api/mcp.go
T
sandrodachandClaude Opus 5.5 ac068248d7
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m21s
feat(mcp): delete tasks, create task lists and lists, agent badge; export/import settings category
New MCP tools delete_task (tasks:write), create_task_list (tasks:write) and create_list
(lists:write); list_task_lists now returns the board columns so agents know the valid
kanbanStatus values. Task deletion is a shared helper (deleteTask) for REST and MCP.

Tasks and list items get agent_mark (migration 12): "created" when an agent adds them via MCP,
"edited" when an agent changes them; a change by the user clears it. The new AgentBadge shows
"KI" / "KI geändert" in task rows, Kanban cards, the task detail, the inbox and lists.

Settings: export and import move out of "Webhooks, KI-Agenten & Export" into their own
category "Export & Import". Docs, help, README and changelog updated; v0.29.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 08:49:10 +01:00

779 lines
26 KiB
Go

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
}