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
+166
-5
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user