Files
MindSift/internal/api/mcp_test.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

278 lines
12 KiB
Go

package api
import (
"encoding/json"
"fmt"
"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)
}
}
// Agents can create task lists and lists and delete tasks; their entries carry a mark until the user
// changes them.
func TestMCPCreateDeleteAndAgentMark(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"})
call := func(id int, name, args string) map[string]interface{} {
t.Helper()
_, res := rpc(t, router, key.RawKey, fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"tools/call","params":{"name":%q,"arguments":%s}}`, id, name, args))
result, isErr := callResult(t, res)
if isErr {
t.Fatalf("%s failed: %v", name, result)
}
return result["structuredContent"].(map[string]interface{})
}
tl := call(1, "create_task_list", `{"title":"Agentenliste"}`)
tlID, _ := tl["id"].(string)
if tlID == "" || tl["permission"] != "owner" || len(tl["columns"].([]interface{})) == 0 {
t.Fatalf("create_task_list: %v", tl)
}
task := call(2, "create_task", `{"content":"Von Claude","taskListId":"`+tlID+`"}`)
taskID := task["id"].(string)
th, _ := store.GetThoughtByID(claims.UserID, taskID)
if th.AgentMark != "created" || th.TaskListID != tlID {
t.Fatalf("agent task should be marked created: %+v", th)
}
// An agent's change keeps "created"; the user's own change removes the mark
call(3, "update_task", `{"id":"`+taskID+`","priority":"high"}`)
if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "created" {
t.Fatalf("mark after agent edit: %q", th.AgentMark)
}
notes := "selbst ergänzt"
if _, err := srv.updateTask(claims.UserID, taskID, taskPatch{Notes: &notes}); err != nil {
t.Fatal(err)
}
if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "" {
t.Fatalf("user edit should clear the mark, got %q", th.AgentMark)
}
call(4, "complete_task", `{"id":"`+taskID+`"}`)
if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.AgentMark != "edited" {
t.Fatalf("agent edit of a user task should mark edited, got %q", th.AgentMark)
}
call(5, "delete_task", `{"id":"`+taskID+`"}`)
if th, err := store.GetThoughtByID(claims.UserID, taskID); err == nil && th != nil {
t.Fatalf("task should be gone")
}
l := call(6, "create_list", `{"title":"Packliste","listType":"vacation"}`)
listID := l["id"].(string)
if l["listType"] != "vacation" || l["role"] != "owner" {
t.Fatalf("create_list: %v", l)
}
item := call(7, "add_list_item", `{"listId":"`+listID+`","content":"Zahnbürste"}`)
if item["agentMark"] != "created" {
t.Fatalf("agent item should be marked created: %v", item)
}
items, _ := store.GetListItems(listID)
if len(items) != 1 || items[0].AgentMark != "created" {
t.Fatalf("stored item mark: %+v", items)
}
if _, err := srv.setListItemDone(claims.UserID, listID, items[0].ID, true, false); err != nil {
t.Fatal(err)
}
if items, _ = store.GetListItems(listID); items[0].AgentMark != "" {
t.Fatalf("ticking by the user should clear the mark: %q", items[0].AgentMark)
}
// Other people's tasks cannot be deleted
other, _ := store.CreateUser("other@example.com", "hash", "Other", "member")
foreign, _ := store.CreateThoughtWithDetails(other.ID, "Fremd", "normal", "manual", "", "", "todo", "")
_, res := rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"delete_task","arguments":{"id":"`+foreign.ID+`"}}}`)
if _, isErr := callResult(t, res); !isErr {
t.Fatalf("key must not delete foreign tasks")
}
}