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>
195 lines
8.4 KiB
Go
195 lines
8.4 KiB
Go
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)
|
|
}
|
|
}
|