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
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
@@ -187,49 +188,72 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// ingestTask creates a task directly (scope webhook:task), optionally in a task list the owner of
|
||||
// the key may write to.
|
||||
func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string) {
|
||||
// createTask creates a task directly (webhook scope webhook:task, MCP tool create_task), optionally in
|
||||
// a task list the user may write to (otherwise storage.ErrTaskListReadOnly).
|
||||
func (s *Server) createTask(userID, content, priority, source, dueDate, taskListID, notes string) (*domain.Thought, error) {
|
||||
if priority == "" {
|
||||
priority = "normal"
|
||||
}
|
||||
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
|
||||
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
||||
return
|
||||
return nil, storage.ErrTaskListReadOnly
|
||||
}
|
||||
thought, err := s.store.CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, "todo", "")
|
||||
thought, err := s.store.CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, "todo", notes)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
task, err := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", "task", "", dueDate, taskListID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task)
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string) {
|
||||
task, err := s.createTask(userID, content, priority, source, dueDate, taskListID, "")
|
||||
if errors.Is(err, storage.ErrTaskListReadOnly) {
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task)
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "task": task})
|
||||
}
|
||||
|
||||
var errListIDRequired = errors.New("Feld 'listId' ist für Listeneinträge erforderlich")
|
||||
|
||||
// addListItem appends an item to a list the user may write to; the error is storage.ErrListReadOnly for
|
||||
// viewers and "not found" for lists the user cannot see.
|
||||
func (s *Server) addListItem(userID, listID, content, category, quantity string) (*domain.ListItem, error) {
|
||||
if strings.TrimSpace(listID) == "" {
|
||||
return nil, errListIDRequired
|
||||
}
|
||||
if err := s.store.EnsureListItemsWritable(userID, listID, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := s.store.CreateListItem(listID, content, category, quantity, 0, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// ingestListItem adds an item to a list (scope webhook:list) the owner of the key may write to.
|
||||
func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string) {
|
||||
if strings.TrimSpace(listID) == "" {
|
||||
writeError(w, http.StatusBadRequest, "Feld 'listId' ist für Listeneinträge erforderlich")
|
||||
return
|
||||
}
|
||||
if err := s.store.EnsureListItemsWritable(userID, listID, ""); err != nil {
|
||||
item, err := s.addListItem(userID, listID, content, category, quantity)
|
||||
if err != nil {
|
||||
status := http.StatusNotFound
|
||||
if errors.Is(err, storage.ErrListReadOnly) {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrListReadOnly):
|
||||
status = http.StatusForbidden
|
||||
case errors.Is(err, errListIDRequired):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
item, err := s.store.CreateListItem(listID, content, category, quantity, 0, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "item": item})
|
||||
}
|
||||
@@ -166,6 +166,25 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
// setListItemDone checks an item off (or back on) and tells the list's viewers. Shared by
|
||||
// PATCH /lists/{id}/items/{itemId} and the MCP tool check_list_item; access is checked first.
|
||||
func (s *Server) setListItemDone(userID, listID, itemID string, done bool) (*domain.ListItem, error) {
|
||||
if err := s.store.EnsureListItemsWritable(userID, listID, itemID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, _ := s.store.GetUserByID(userID)
|
||||
userName := "Mitglied"
|
||||
if user != nil {
|
||||
userName = user.DisplayName
|
||||
}
|
||||
item, err := s.store.ToggleListItem(itemID, done, userID, userName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
@@ -182,19 +201,11 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := s.store.GetUserByID(userID)
|
||||
userName := "Mitglied"
|
||||
if user != nil {
|
||||
userName = user.DisplayName
|
||||
}
|
||||
|
||||
item, err := s.store.ToggleListItem(itemID, req.IsCompleted, userID, userName)
|
||||
item, err := s.setListItemDone(userID, listID, itemID, req.IsCompleted)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
|
||||
@@ -231,46 +231,40 @@ func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
thoughtID := chi.URLParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
Content *string `json:"content"`
|
||||
IsCompleted *bool `json:"isCompleted"`
|
||||
DueDate *string `json:"dueDate"`
|
||||
Priority *string `json:"priority"`
|
||||
TaskListID *string `json:"taskListId"`
|
||||
KanbanStatus *string `json:"kanbanStatus"`
|
||||
Notes *string `json:"notes"`
|
||||
Subtasks *[]domain.SubTask `json:"subtasks"`
|
||||
Tags *[]string `json:"tags"`
|
||||
Assignees *[]string `json:"assignees"`
|
||||
Recurrence *string `json:"recurrence"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
// taskPatch is a partial change of a task; nil fields stay as they are. Shared by PATCH /thoughts/{id}
|
||||
// and the MCP tools.
|
||||
type taskPatch struct {
|
||||
Content *string `json:"content"`
|
||||
IsCompleted *bool `json:"isCompleted"`
|
||||
DueDate *string `json:"dueDate"`
|
||||
Priority *string `json:"priority"`
|
||||
TaskListID *string `json:"taskListId"`
|
||||
KanbanStatus *string `json:"kanbanStatus"`
|
||||
Notes *string `json:"notes"`
|
||||
Subtasks *[]domain.SubTask `json:"subtasks"`
|
||||
Tags *[]string `json:"tags"`
|
||||
Assignees *[]string `json:"assignees"`
|
||||
Recurrence *string `json:"recurrence"`
|
||||
}
|
||||
|
||||
// updateTask applies a patch, keeps a linked note block in step, tells everyone who sees the task
|
||||
// and sends the notifications. Errors come from the store (writeStoreError maps them).
|
||||
func (s *Server) updateTask(userID, thoughtID string, req taskPatch) (*domain.Thought, error) {
|
||||
before, _ := s.store.GetThoughtByID(userID, thoughtID)
|
||||
// The rule is set first, so completing a task in the same request already repeats it
|
||||
if req.Recurrence != nil {
|
||||
if err := s.store.SetTaskRecurrence(userID, thoughtID, strings.TrimSpace(*req.Recurrence)); err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
if req.Assignees != nil {
|
||||
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
updated.Assignees = assignees
|
||||
}
|
||||
@@ -281,6 +275,23 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated)
|
||||
s.notifyTaskChange(userID, before, updated, false)
|
||||
updated.IsWatching = s.store.WatchedTaskIDs(userID)[updated.ID]
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
thoughtID := chi.URLParam(r, "id")
|
||||
|
||||
var req taskPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
updated, err := s.updateTask(userID, thoughtID, req)
|
||||
if err != nil {
|
||||
writeStoreError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
)
|
||||
|
||||
// rpc posts one JSON-RPC request to the MCP endpoint and returns status and decoded body.
|
||||
func rpc(t *testing.T, router http.Handler, token, body string) (int, map[string]interface{}) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, mcpPath, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
if strings.HasPrefix(token, "mindsift_sec_") {
|
||||
req.Header.Set("X-API-Key", token)
|
||||
} else {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]interface{}
|
||||
if rec.Body.Len() > 0 {
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
}
|
||||
return rec.Code, out
|
||||
}
|
||||
|
||||
func toolNames(res map[string]interface{}) []string {
|
||||
names := []string{}
|
||||
result, _ := res["result"].(map[string]interface{})
|
||||
tools, _ := result["tools"].([]interface{})
|
||||
for _, tl := range tools {
|
||||
names = append(names, tl.(map[string]interface{})["name"].(string))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func callResult(t *testing.T, res map[string]interface{}) (map[string]interface{}, bool) {
|
||||
t.Helper()
|
||||
result, ok := res["result"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("no result in %v", res)
|
||||
}
|
||||
isErr, _ := result["isError"].(bool)
|
||||
return result, isErr
|
||||
}
|
||||
|
||||
// Agent scopes decide which tools a key sees and may call; webhook-only keys get nothing to read.
|
||||
func TestMCPToolsFollowScopes(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||
|
||||
code, res := rpc(t, router, token, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("initialize: %d %v", code, res)
|
||||
}
|
||||
if v := res["result"].(map[string]interface{})["protocolVersion"]; v != "2025-03-26" {
|
||||
t.Fatalf("requested protocol version should be echoed, got %v", v)
|
||||
}
|
||||
// Notifications are acknowledged with 202 and no body
|
||||
code, _ = rpc(t, router, token, `{"jsonrpc":"2.0","method":"notifications/initialized"}`)
|
||||
if code != http.StatusAccepted {
|
||||
t.Fatalf("notification should answer 202, got %d", code)
|
||||
}
|
||||
|
||||
// A session sees every tool
|
||||
_, res = rpc(t, router, token, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`)
|
||||
if n := len(toolNames(res)); n != len(mcpTools) {
|
||||
t.Fatalf("session should see all %d tools, got %d", len(mcpTools), n)
|
||||
}
|
||||
|
||||
// Webhook-only key: only capture_to_inbox
|
||||
webhookKey, _ := store.CreateAPIKey(claims.UserID, "HA", nil)
|
||||
_, res = rpc(t, router, webhookKey.RawKey, `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`)
|
||||
if names := toolNames(res); len(names) != 1 || names[0] != "capture_to_inbox" {
|
||||
t.Fatalf("triage key should only see capture_to_inbox, got %v", names)
|
||||
}
|
||||
_, res = rpc(t, router, webhookKey.RawKey, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_tasks","arguments":{}}}`)
|
||||
if _, isErr := callResult(t, res); !isErr {
|
||||
t.Fatalf("triage key must not list tasks: %v", res)
|
||||
}
|
||||
|
||||
// Read-only task key: lists tasks, cannot complete them
|
||||
readKey, _ := store.CreateAPIKey(claims.UserID, "Reader", []string{"tasks:read"})
|
||||
names := strings.Join(toolNames(func() map[string]interface{} { _, r := rpc(t, router, readKey.RawKey, `{"jsonrpc":"2.0","id":5,"method":"tools/list"}`); return r }()), ",")
|
||||
if !strings.Contains(names, "list_tasks") || strings.Contains(names, "complete_task") || strings.Contains(names, "list_lists") {
|
||||
t.Fatalf("tasks:read key sees the wrong tools: %s", names)
|
||||
}
|
||||
// search is offered with any read scope
|
||||
if !strings.Contains(names, "search") {
|
||||
t.Fatalf("search should be available with tasks:read: %s", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Full round trip with a tasks:write / lists:write key: create, list, complete; add and tick list items.
|
||||
func TestMCPTasksAndLists(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||
key, _ := store.CreateAPIKey(claims.UserID, "Agent", []string{"tasks:read", "tasks:write", "lists:read", "lists:write"})
|
||||
|
||||
_, res := rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_task","arguments":{"content":"Filter wechseln #haus","priority":"high","dueDate":"2026-11-15"}}}`)
|
||||
result, isErr := callResult(t, res)
|
||||
if isErr {
|
||||
t.Fatalf("create_task failed: %v", result)
|
||||
}
|
||||
created := result["structuredContent"].(map[string]interface{})
|
||||
taskID, _ := created["id"].(string)
|
||||
if taskID == "" || created["dueDate"] != "2026-11-15" {
|
||||
t.Fatalf("unexpected task: %v", created)
|
||||
}
|
||||
th, _ := store.GetThoughtByID(claims.UserID, taskID)
|
||||
if th == nil || th.Source != "agent" || th.Status != "converted" && th.Status != "kept" {
|
||||
t.Fatalf("task not stored as task: %+v", th)
|
||||
}
|
||||
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_tasks","arguments":{"dueBefore":"2026-11-15"}}}`)
|
||||
result, _ = callResult(t, res)
|
||||
items := result["structuredContent"].(map[string]interface{})["items"].([]interface{})
|
||||
found := false
|
||||
for _, it := range items {
|
||||
if it.(map[string]interface{})["id"] == taskID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("created task missing from list_tasks: %v", items)
|
||||
}
|
||||
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"complete_task","arguments":{"id":"`+taskID+`"}}}`)
|
||||
result, isErr = callResult(t, res)
|
||||
if isErr || result["structuredContent"].(map[string]interface{})["isCompleted"] != true {
|
||||
t.Fatalf("complete_task: %v", result)
|
||||
}
|
||||
// Done tasks drop out of the default listing
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_tasks","arguments":{}}}`)
|
||||
result, _ = callResult(t, res)
|
||||
for _, it := range result["structuredContent"].(map[string]interface{})["items"].([]interface{}) {
|
||||
if it.(map[string]interface{})["id"] == taskID {
|
||||
t.Fatalf("completed task should not be listed as open")
|
||||
}
|
||||
}
|
||||
|
||||
// Lists
|
||||
list, _ := store.CreateList(claims.UserID, "Einkauf", "🛒", "shopping")
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"add_list_item","arguments":{"listId":"`+list.ID+`","content":"Hafermilch","quantity":"2"}}}`)
|
||||
result, isErr = callResult(t, res)
|
||||
if isErr {
|
||||
t.Fatalf("add_list_item: %v", result)
|
||||
}
|
||||
itemID := result["structuredContent"].(map[string]interface{})["id"].(string)
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"check_list_item","arguments":{"listId":"`+list.ID+`","itemId":"`+itemID+`"}}}`)
|
||||
result, isErr = callResult(t, res)
|
||||
if isErr || result["structuredContent"].(map[string]interface{})["isCompleted"] != true {
|
||||
t.Fatalf("check_list_item: %v", result)
|
||||
}
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"get_list","arguments":{"listId":"`+list.ID+`"}}}`)
|
||||
result, _ = callResult(t, res)
|
||||
lv := result["structuredContent"].(map[string]interface{})["list"].(map[string]interface{})
|
||||
if lv["openItems"].(float64) != 0 || lv["items"].(float64) != 1 {
|
||||
t.Fatalf("get_list counts wrong: %v", lv)
|
||||
}
|
||||
|
||||
// Lists of other people stay invisible
|
||||
other, _ := store.CreateUser("other@example.com", "hash", "Other", "member")
|
||||
foreign, _ := store.CreateList(other.ID, "Fremd", "", "shopping")
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"add_list_item","arguments":{"listId":"`+foreign.ID+`","content":"Spam"}}}`)
|
||||
if _, isErr = callResult(t, res); !isErr {
|
||||
t.Fatalf("key must not write into foreign lists")
|
||||
}
|
||||
|
||||
// Unknown method and batch requests are JSON-RPC errors
|
||||
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":9,"method":"resources/list"}`)
|
||||
if res["error"] == nil {
|
||||
t.Fatalf("unknown method should be a JSON-RPC error: %v", res)
|
||||
}
|
||||
_, res = rpc(t, router, key.RawKey, `[{"jsonrpc":"2.0","id":10,"method":"ping"}]`)
|
||||
if res["error"] == nil {
|
||||
t.Fatalf("batches should be refused: %v", res)
|
||||
}
|
||||
// Without a key there is no access at all
|
||||
req := httptest.NewRequest(http.MethodPost, mcpPath, strings.NewReader(`{"jsonrpc":"2.0","id":11,"method":"ping"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous MCP call should be 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
+12
-6
@@ -111,7 +111,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: allowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key", "Mcp-Session-Id", "MCP-Protocol-Version"},
|
||||
ExposedHeaders: []string{"Link", "X-Session-Token"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 300,
|
||||
@@ -279,6 +279,12 @@ func (s *Server) Router() http.Handler {
|
||||
r.Put("/api/v1/teams/{id}/members/{userId}", s.handleUpdateTeamMemberRole)
|
||||
r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember)
|
||||
|
||||
// MCP endpoint for AI agents (API key with agent scopes or a session); no server stream, so
|
||||
// GET and DELETE answer 405 instead of the SPA fallback
|
||||
r.Post(mcpPath, s.handleMCP)
|
||||
r.Get(mcpPath, handleMCPNoStream)
|
||||
r.Delete(mcpPath, handleMCPNoStream)
|
||||
|
||||
// API Keys Management
|
||||
r.Get("/api/v1/keys", s.handleGetAPIKeys)
|
||||
r.Post("/api/v1/keys", s.handleCreateAPIKey)
|
||||
@@ -509,11 +515,11 @@ func requireDeliveryScope(w http.ResponseWriter, r *http.Request, target string)
|
||||
return false
|
||||
}
|
||||
|
||||
// apiKeyAllowed: an API key only delivers data into MindSift (POST /api/v1/inbox; /api/v1/webhooks/ingest
|
||||
// authenticates on its own); what it may deliver is decided by its scopes (requireDeliveryScope).
|
||||
// Everything else needs a login.
|
||||
// apiKeyAllowed: an API key delivers data into MindSift (POST /api/v1/inbox; /api/v1/webhooks/ingest
|
||||
// authenticates on its own) or talks to the MCP endpoint for AI agents; what it may do there is decided
|
||||
// by its scopes (requireDeliveryScope, mcpToolAllowed). Everything else needs a login.
|
||||
func apiKeyAllowed(r *http.Request) bool {
|
||||
return r.Method == http.MethodPost && r.URL.Path == "/api/v1/inbox"
|
||||
return r.Method == http.MethodPost && (r.URL.Path == "/api/v1/inbox" || r.URL.Path == mcpPath)
|
||||
}
|
||||
|
||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
@@ -538,7 +544,7 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
if keyScopes != nil {
|
||||
if !apiKeyAllowed(r) {
|
||||
writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)")
|
||||
writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest) oder den MCP-Endpunkt nutzen (POST /api/v1/mcp)")
|
||||
return
|
||||
}
|
||||
ctx = context.WithValue(ctx, apiKeyScopesKey{}, keyScopes)
|
||||
|
||||
Reference in new issue
Block a user