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
}