feat(mcp): agents can list, read and create notes

New scopes notes:read (list_notes, get_note, notes in search) and
notes:create (create_note from Markdown). MCP only, REST stays closed;
new notes go into shared folders only with write access.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 21:18:26 +01:00
1 parent c6f28aa816
commit a8f6c1b97b
12 files changed
+312 -23

No files matched your search

+87
View File
@@ -355,3 +355,90 @@ func TestMCPSwitchOff(t *testing.T) {
t.Fatalf("the locked switch must not have changed the stored value, got %d", code)
}
}
// Notes over MCP: notes:create only creates, notes:read lists, reads and finds them; REST stays closed.
func TestMCPNotes(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
call := func(key, name, args string) (map[string]interface{}, bool) {
t.Helper()
_, res := rpc(t, router, key, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"`+name+`","arguments":`+args+`}}`)
result, isErr := callResult(t, res)
sc, _ := result["structuredContent"].(map[string]interface{})
return sc, isErr
}
names := func(key string) string {
_, res := rpc(t, router, key, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
return "," + strings.Join(toolNames(res), ",") + ","
}
writer, _ := store.CreateAPIKey(claims.UserID, "Schreiber", []string{"notes:create"})
if n := names(writer.RawKey); n != ",create_note," {
t.Fatalf("notes:create should only offer create_note, got %s", n)
}
note, isErr := call(writer.RawKey, "create_note", `{"title":"Ideen","folderPath":"/Projekte/KI/","content":"- Erster Punkt\n\t- Unterpunkt\n- [ ] Offen"}`)
if isErr || note["folderPath"] != "Projekte/KI" || note["permission"] != "owner" {
t.Fatalf("create_note: %v", note)
}
doc, err := store.GetDocumentByID(claims.UserID, note["id"].(string))
if err != nil || len(doc.Blocks) != 3 || doc.Blocks[1].Level != 1 || doc.Blocks[2].Type != "todo" {
t.Fatalf("bullets not stored as outline: %+v", doc)
}
if _, isErr := call(writer.RawKey, "create_note", `{"title":" "}`); !isErr {
t.Fatal("empty title must be refused")
}
if _, isErr := call(writer.RawKey, "list_notes", `{}`); !isErr {
t.Fatal("notes:create must not read notes")
}
reader, _ := store.CreateAPIKey(claims.UserID, "Leser", []string{"notes:read"})
if n := names(reader.RawKey); !strings.Contains(n, ",list_notes,") || !strings.Contains(n, ",get_note,") ||
!strings.Contains(n, ",search,") || strings.Contains(n, ",create_note,") {
t.Fatalf("notes:read sees the wrong tools: %s", n)
}
_, _ = store.CreateDocument(claims.UserID, "", "Einkauf", "", "", nil, nil, false)
list, _ := call(reader.RawKey, "list_notes", `{"folder":"Projekte/KI"}`)
if items := list["items"].([]interface{}); len(items) != 1 || items[0].(map[string]interface{})["title"] != "Ideen" {
t.Fatalf("folder filter: %v", list)
}
// The folder includes its subfolders; the title filter ignores case
list, _ = call(reader.RawKey, "list_notes", `{"folder":"Projekte","query":"IDEE"}`)
if items := list["items"].([]interface{}); len(items) != 1 {
t.Fatalf("subfolder and title filter: %v", list)
}
got, isErr := call(reader.RawKey, "get_note", `{"id":"`+doc.ID+`"}`)
if isErr || got["content"] != "- Erster Punkt\n\t- Unterpunkt\n- [ ] Offen\n" {
t.Fatalf("get_note: %q", got["content"])
}
found, _ := call(reader.RawKey, "search", `{"query":"Unterpunkt"}`)
if hits := found["notes"].([]interface{}); len(hits) == 0 || hits[0].(map[string]interface{})["noteId"] != doc.ID {
t.Fatalf("search should find the note: %v", found)
}
// Without notes:read the search leaves notes out
taskReader, _ := store.CreateAPIKey(claims.UserID, "Aufgaben", []string{"tasks:read"})
if found, _ := call(taskReader.RawKey, "search", `{"query":"Unterpunkt"}`); len(found["notes"].([]interface{})) != 0 {
t.Fatalf("tasks:read must not find notes: %v", found)
}
// Notes stay closed over REST
if code, _, _ := oauthDo(t, router, http.MethodGet, "/api/v1/documents", reader.RawKey, "", ""); code != http.StatusForbidden {
t.Fatalf("REST must stay closed for notes keys, got %d", code)
}
// A shared folder with read access only takes no new notes
other, _ := store.CreateUser("owner@example.com", "hash", "Owner", "member")
if _, err := store.CreateFolder(other.ID, "Team"); err != nil {
t.Fatal(err)
}
sf, err := store.CreateSharedFolder(other.ID, "Team", "Team")
if err != nil {
t.Fatal(err)
}
if _, err := store.CreateFolderShare(other.ID, sf.ID, "user", claims.UserID, "read"); err != nil {
t.Fatal(err)
}
_, res := rpc(t, router, writer.RawKey, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_note","arguments":{"title":"Rein","folderPath":"Team"}}}`)
if result, isErr := callResult(t, res); !isErr || !strings.Contains(fmt.Sprint(result["content"]), "nur lesen") {
t.Fatalf("read-only shared folder must refuse new notes: %v", result)
}
}