Files
MindSift/internal/api/mcp_test.go
T
sandrodachandClaude Fable 5.1 8faed7a160
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m19s
feat(mcp): MCP server for AI agents, agent scopes on API keys, switches in settings
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>
2026-10-31 21:58:44 +01:00

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)
}
}