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; what a key creates or changes keeps its // origin for good, also after the user's own edits. func TestMCPCreateDeleteAndOrigin(t *testing.T) { srv, store, token := setupTestServer(t) router := srv.Router() claims, _ := auth.ValidateToken(testJWTSecret, token) key, _ := store.CreateAPIKey(claims.UserID, "Claude Code", []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.Origin == nil || th.Origin.CreatedVia != "mcp" || th.Origin.CreatedKey != "Claude Code" || th.TaskListID != tlID { t.Fatalf("agent task should carry its origin: %+v", th.Origin) } // The user's own edit keeps the origin notes := "selbst ergänzt" if _, err := srv.updateTask(claims.UserID, taskID, taskPatch{Notes: ¬es}); err != nil { t.Fatal(err) } if th, _ = store.GetThoughtByID(claims.UserID, taskID); th.Origin == nil || th.Origin.CreatedKey != "Claude Code" || th.Origin.ChangedVia != "" { t.Fatalf("user edit must keep the origin and not count as a key change: %+v", th.Origin) } // A task of the user changed by the agent: changed via MCP, with key and time own, _ := srv.createTask(claims.UserID, "Von mir", "", "manual", "", "", "", nil) if own.Origin != nil { t.Fatalf("own task must not have an origin: %+v", own.Origin) } call(3, "complete_task", `{"id":"`+own.ID+`"}`) if th, _ = store.GetThoughtByID(claims.UserID, own.ID); th.Origin == nil || th.Origin.CreatedVia != "" || th.Origin.ChangedVia != "mcp" || th.Origin.ChangedKey != "Claude Code" || th.Origin.ChangedAt == "" { t.Fatalf("agent change of a user task: %+v", th.Origin) } // Renamed or deleted keys show up as such _ = store.DeleteAPIKey(claims.UserID, key.ID) if th, _ = store.GetThoughtByID(claims.UserID, own.ID); th.Origin.ChangedKey != "Gelöschter Schlüssel" { t.Fatalf("deleted key name: %q", th.Origin.ChangedKey) } key, _ = store.CreateAPIKey(claims.UserID, "Claude Code", []string{"tasks:read", "tasks:write", "lists:read", "lists:write"}) 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 o, _ := item["origin"].(map[string]interface{}); o == nil || o["createdVia"] != "mcp" { t.Fatalf("agent item should carry its origin: %v", item) } items, _ := store.GetListItems(listID) if _, err := srv.setListItemDone(claims.UserID, listID, items[0].ID, true, nil); err != nil { t.Fatal(err) } if items, _ = store.GetListItems(listID); items[0].Origin == nil || items[0].Origin.CreatedKey != "Claude Code" { t.Fatalf("ticking by the user keeps the origin: %+v", items[0].Origin) } // 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") } // The narrow right "create tasks" also allows create_task over MCP, but not update_task creator, _ := store.CreateAPIKey(claims.UserID, "Nur anlegen", []string{"webhook:task"}) _, res = rpc(t, router, creator.RawKey, `{"jsonrpc":"2.0","id":9,"method":"tools/list"}`) if names := strings.Join(toolNames(res), ","); !strings.Contains(names, "create_task") || strings.Contains(names, "update_task") { t.Fatalf("webhook:task key tools: %s", names) } }