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

+166 -5
View File
@@ -82,7 +82,7 @@ func mcpToolAllowed(scopes []string, isKey bool, tool mcpTool) bool {
return false
}
if tool.Name == "search" {
return has(storage.ScopeTasksRead) || has(storage.ScopeListsRead)
return has(storage.ScopeTasksRead) || has(storage.ScopeListsRead) || has(storage.ScopeNotesRead)
}
return has(tool.Scope) || (tool.AltScope != "" && has(tool.AltScope))
}
@@ -135,8 +135,9 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
"serverInfo": map[string]string{"name": "MindSift", "version": mcpServerVersion},
"instructions": "MindSift is a personal notes, tasks and lists app. Use list_task_lists and list_tasks " +
"to see open tasks, complete_task to check one off, list_lists/get_list for shopping and other " +
"lists and check_list_item to tick items. IDs come from the list tools; never guess them. " +
"Entries you create or change show the user a badge with the name of your API key.",
"lists and check_list_item to tick items, list_notes/get_note to read notes and create_note for a " +
"new one. IDs come from the list tools; never guess them. Tasks and list items you create or " +
"change show the user a badge with the name of your API key.",
}, nil)
case "ping":
writeRPC(w, req.ID, map[string]interface{}{}, nil)
@@ -319,6 +320,53 @@ func toTaskListView(l domain.TaskList) taskListView {
return taskListView{ID: l.ID, Title: l.Title, Icon: l.Icon, Permission: permission, OpenTasks: l.TasksCount, Columns: cols}
}
// noteView: a note without its bullets (list_notes); get_note adds them as Markdown.
type noteView struct {
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
FolderPath string `json:"folderPath,omitempty"`
Permission string `json:"permission"`
Tags []string `json:"tags,omitempty"`
UpdatedAt string `json:"updatedAt"`
Content string `json:"content,omitempty"`
}
func toNoteView(d *domain.Document) noteView {
permission := d.Permission
if d.IsOwner || permission == "" {
permission = "owner"
}
return noteView{ID: d.ID, Title: d.Title, Icon: d.Icon, FolderPath: d.FolderPath, Permission: permission, Tags: d.Tags, UpdatedAt: d.UpdatedAt}
}
// noteMarkdown renders the bullets as an indented Markdown list; images and recordings become a
// placeholder with their caption (the agent cannot open the files).
func noteMarkdown(blocks []domain.DocumentBlock) string {
out := make([]domain.DocumentBlock, len(blocks))
for i, b := range blocks {
if b.Type == "image" || b.Type == "audio" {
label := map[string]string{"image": "Bild", "audio": "Aufnahme"}[b.Type]
if ref, ok := storage.ParseMediaContent(b.Content); ok && ref.Caption != "" {
label += ": " + ref.Caption
}
b.Type, b.Content = "paragraph", "["+label+"]"
}
out[i] = b
}
return storage.BlocksToVaultMarkdown(out)
}
// noteHit: a matching bullet (or title) of a note in the search
type noteHit struct {
NoteID string `json:"noteId"`
Title string `json:"title"`
Snippet string `json:"snippet"`
}
// markTags strips the highlighting of search snippets
var markTags = strings.NewReplacer("<mark>", "", "</mark>", "")
type listView struct {
ID string `json:"id"`
Title string `json:"title"`
@@ -721,9 +769,113 @@ var mcpTools = []mcpTool{
return item, nil
},
},
{
Name: "list_notes",
Description: "Notes the user can see (own and shared), newest change first, without their content. Filter by folder (includes subfolders) or words in the title. Use get_note for the bullets.",
Scope: storage.ScopeNotesRead,
Schema: schema(nil, map[string]interface{}{
"folder": prop("string", "Only notes in this folder or below (e.g. Projekte/MindSift)"),
"query": prop("string", "Only notes whose title contains this text"),
"limit": prop("integer", "Maximum number of notes (default 100)"),
}),
run: func(s *Server, userID string, _ *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
Folder, Query string
Limit int
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
docs, err := s.store.GetDocuments(userID)
if err != nil {
return nil, err
}
folder := strings.Trim(strings.TrimSpace(a.Folder), "/")
query := strings.ToLower(strings.TrimSpace(a.Query))
out := []noteView{}
for i := range docs {
d := &docs[i]
if d.IsArchived {
continue
}
if folder != "" && d.FolderPath != folder && !strings.HasPrefix(d.FolderPath, folder+"/") {
continue
}
if query != "" && !strings.Contains(strings.ToLower(d.Title), query) {
continue
}
out = append(out, toNoteView(d))
}
sort.SliceStable(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt })
if a.Limit <= 0 {
a.Limit = 100
}
if len(out) > a.Limit {
out = out[:a.Limit]
}
return out, nil
},
},
{
Name: "get_note",
Description: "One note with its bullets as an indented Markdown list (one tab per level; - [ ] / - [x] are to-dos).",
Scope: storage.ScopeNotesRead,
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Note id")}),
run: func(s *Server, userID string, _ *apiActor, args json.RawMessage) (interface{}, error) {
var a struct{ ID string }
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
d, err := s.store.GetDocumentByID(userID, a.ID)
if err != nil || d == nil || d.IsArchived {
return nil, errors.New("Notiz nicht gefunden")
}
v := toNoteView(d)
v.Content = noteMarkdown(d.Blocks)
return v, nil
},
},
{
Name: "create_note",
Description: "Create a new note. content is Markdown: a nested list (tab or 2+ spaces per level) becomes nested bullets, - [ ] a to-do, # a heading with the following bullets below it, plain lines become bullets. Existing notes cannot be changed.",
Scope: storage.ScopeNotesCreate,
Schema: schema([]string{"title"}, map[string]interface{}{
"title": prop("string", "Title of the note"),
"content": prop("string", "Bullets as Markdown, optional"),
"folderPath": prop("string", "Folder, e.g. Projekte/MindSift; empty = top level. Missing folders are created."),
"icon": prop("string", "One emoji, optional (default 🗒️)"),
}),
run: func(s *Server, userID string, _ *apiActor, args json.RawMessage) (interface{}, error) {
var a struct {
Title, Content, FolderPath, Icon string
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
if strings.TrimSpace(a.Title) == "" {
return nil, errors.New("'title' darf nicht leer sein")
}
folder := strings.Trim(strings.TrimSpace(a.FolderPath), "/")
if err := s.store.CheckCreateInFolder(userID, folder); err != nil {
return nil, err
}
var blocks []domain.DocumentBlock
if strings.TrimSpace(a.Content) != "" {
blocks = storage.MarkdownToBlocks(a.Content)
}
d, err := s.store.CreateDocument(userID, "", a.Title, a.Icon, folder, blocks, nil, false)
if err != nil {
return nil, err
}
s.broadcastDocToAuthorizedUsers(d.ID, "DOC_CREATED")
v := toNoteView(d)
v.Content = noteMarkdown(d.Blocks)
return v, nil
},
},
{
Name: "search",
Description: "Full-text search over tasks and list items the user can see (notes are not included for API keys).",
Description: "Full-text search over tasks, list items and notes the user can see (each only with the matching read right). Notes come as matching bullets with the note id for get_note.",
Scope: storage.ScopeTasksRead,
Schema: schema([]string{"query"}, map[string]interface{}{"query": prop("string", "Words to search for")}),
run: func(s *Server, userID string, actor *apiActor, args json.RawMessage) (interface{}, error) {
@@ -741,7 +893,13 @@ var mcpTools = []mcpTool{
for i := range res.Thoughts {
tasks = append(tasks, toTaskView(&res.Thoughts[i]))
}
return map[string]interface{}{"tasks": tasks, "listItems": res.ListItems}, nil
notes := []noteHit{}
for _, h := range res.Hits {
if h.Type == "document" {
notes = append(notes, noteHit{NoteID: h.DocID, Title: h.Title, Snippet: markTags.Replace(h.Snippet)})
}
}
return map[string]interface{}{"tasks": tasks, "listItems": res.ListItems, "notes": notes}, nil
},
},
{
@@ -793,5 +951,8 @@ func filterSearchForScopes(result interface{}, scopes []string) interface{} {
if !has[storage.ScopeListsRead] {
m["listItems"] = []domain.ListItem{}
}
if !has[storage.ScopeNotesRead] {
m["notes"] = []noteHit{}
}
return m
}
+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)
}
}
+2 -1
View File
@@ -29,7 +29,8 @@ const (
)
// Rights offered on the consent page; the narrow "create only" ones stay with ordinary keys.
var oauthOfferedScopes = []string{storage.ScopeTasksRead, storage.ScopeTasksWrite, storage.ScopeListsRead, storage.ScopeListsWrite, storage.ScopeTriage}
var oauthOfferedScopes = []string{storage.ScopeTasksRead, storage.ScopeTasksWrite, storage.ScopeListsRead, storage.ScopeListsWrite,
storage.ScopeNotesRead, storage.ScopeNotesCreate, storage.ScopeTriage}
// Preselected when the client asks for nothing in particular (claude.ai usually does not)
var oauthDefaultScopes = []string{storage.ScopeTasksRead, storage.ScopeTasksWrite, storage.ScopeListsRead, storage.ScopeListsWrite}