feat(mcp): MCP server for AI agents, agent scopes on API keys, switches in settings
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m19s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m19s
MindSift now speaks MCP (Streamable HTTP, stateless JSON-RPC) at POST /api/v1/mcp so Claude Code, Codex, Claude Desktop, Cursor and custom agents can read, create and complete tasks and keep shared lists. API keys get the scopes tasks:read/write and lists:read/write; tools/list only shows what the key may call and every tool checks its scope. Task and list write paths are shared helpers (updateTask, createTask, addListItem, setListItemDone) used by REST and MCP alike. Settings: scope selection and the SectionPicker (import, restore) use ToggleSwitch rows instead of checkbox-like buttons; new MCP section with connection snippets for Claude Code, Codex and JSON configs. Docs, README, help and changelog updated; v0.28.0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
5982563579
commit
8faed7a160
22 files changed
+1243
-147
No files matched your search
@@ -0,0 +1,677 @@
|
||||
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.",
|
||||
}, 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"`
|
||||
}
|
||||
|
||||
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) and the number of open tasks. 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, taskListView{ID: l.ID, Title: l.Title, Icon: l.Icon, Permission: l.Permission, OpenTasks: l.TasksCount})
|
||||
}
|
||||
return out, 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
|
||||
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})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toTaskView(t), 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: "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)
|
||||
},
|
||||
},
|
||||
{
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user