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:
1 parent
c6f28aa816
commit
a8f6c1b97b
12 files changed
+312
-23
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user