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

+1
View File
@@ -8,6 +8,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
### Added
- Administration → Server & Isolation: Schalter „KI-Agenten (MCP) erlauben“. Ausgeschaltet ist der MCP-Endpunkt abgeschaltet, ebenso das Verbinden über claude.ai und ChatGPT; bestehende Connector-Verbindungen ruhen, API-Schlüssel funktionieren per curl weiter. In den API-Schlüssel-Einstellungen verschwindet dann der Reiter „KI-Agenten (MCP)“. Mit `MCP_ENABLED=false` bzw. `true` legt der Betreiber das fest, der Schalter ist dann gesperrt.
- KI-Agenten können über MCP Notizen anlegen und lesen. Dafür gibt es bei API-Schlüsseln und beim Verbinden über claude.ai/ChatGPT zwei neue Rechte unter „Notizen“: **Lesen** (Notizen auflisten, lesen und in der Suche finden) und **Anlegen** (neue Notizen mit Punkten und Unterpunkten, auf Wunsch in einem Ordner). Bestehende Notizen kann ein Agent nicht ändern. Beide Rechte sind standardmäßig aus und gelten nur über MCP, nicht per curl.
## [0.52.0] - 2026-11-01
+10 -3
View File
@@ -49,7 +49,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` |
| GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen |
| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` |
| GET/POST | `/keys` | API-Keys auflisten bzw. anlegen (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`, `tasks:read`, `tasks:write`, `lists:read`, `lists:write`; Standard `["webhook:triage"]`) |
| GET/POST | `/keys` | API-Keys auflisten bzw. anlegen (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`, `tasks:read`, `tasks:write`, `lists:read`, `lists:write`, `notes:read`, `notes:create`; Standard `["webhook:triage"]`) |
| POST | `/mcp` | MCP-Endpunkt für KI-Agenten, siehe unten |
| PATCH | `/keys/{id}` | Name (`name`) und/oder Rechte (`scopes`) ändern; der Schlüssel selbst bleibt gleich |
| DELETE | `/keys/{id}` | API-Key löschen |
@@ -101,7 +101,10 @@ Betreiber können MCP abschalten (`MCP_ENABLED=false` oder Schalter in der Admin
| `add_list_item` | `lists:write` | Eintrag hinzufügen (`listId`, `content`, `category`, `quantity`) |
| `check_list_item` | `lists:write` | Eintrag abhaken (`completed` false = zurück) |
| `update_list_item` | `lists:write` | Text, Kategorie, Menge ändern |
| `search` | ein Lese-Scope | Volltextsuche in Aufgaben und Listeneinträgen (nur die Teile der eigenen Scopes, keine Notizen) |
| `list_notes` | `notes:read` | Notizen ohne Inhalt, zuletzt geänderte zuerst (`folder` samt Unterordnern, `query` im Titel, `limit`) |
| `get_note` | `notes:read` | Eine Notiz; `content` sind die Punkte als eingerückte Markdown-Liste (Tab je Ebene, `- [ ]` für To-dos, Bilder und Aufnahmen als Platzhalter) |
| `create_note` | `notes:create` | Neue Notiz (`title`, `content` als Markdown wie beim Import, `folderPath`, `icon`); in geteilte Ordner nur mit Schreibrecht. Bestehende Notizen ändert kein Werkzeug |
| `search` | ein Lese-Scope | Volltextsuche in Aufgaben, Listeneinträgen und Notizen (nur die Teile der eigenen Scopes); Notizen als `notes` mit `noteId`, `title`, `snippet` |
| `capture_to_inbox` | `webhook:triage` | Gedanken in die Inbox |
Was ein Agent anlegt oder ändert, bekommt eine dauerhafte Herkunft (`origin`, siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft)). `create_task` geht auch mit `webhook:task`, `add_list_item` auch mit `webhook:list`.
@@ -123,7 +126,7 @@ Clients, die keinen `X-API-Key` setzen können, verbinden sich per OAuth 2.1 nac
Die Zustimmungsseite nutzt `GET`/`POST /api/v1/oauth/authorize` (nur mit Anmeldung, nicht mit Schlüsseln).
- **Rechte:** dieselben wie bei Schlüsseln, angeboten werden `tasks:read`, `tasks:write`, `lists:read`, `lists:write` und `webhook:triage`. Fragt der Client nichts Bestimmtes an, sind Lesen und Ändern für Aufgaben und Listen vorbelegt.
- **Rechte:** dieselben wie bei Schlüsseln, angeboten werden `tasks:read`, `tasks:write`, `lists:read`, `lists:write`, `notes:read`, `notes:create` und `webhook:triage`. Fragt der Client nichts Bestimmtes an, sind Lesen und Ändern für Aufgaben und Listen vorbelegt.
- **Verbindung = Eintrag unter `/keys`** mit `kind: "oauth"` und dem Namen „<Client> (OAuth)“. Rechte ändern, umbenennen und löschen wie bei jedem Schlüssel; Herkunft (`origin`) mit diesem Namen. Erneutes Zustimmen desselben Clients verwendet den Eintrag weiter.
- **Tokens:** Code 10 Minuten und einmal gültig, Zugangs-Token (`mindsift_oat_…`) 1 Stunde, Refresh-Token (`mindsift_ort_…`) 90 Tage und bei jeder Erneuerung neu. Wird ein bereits ersetzter Refresh-Token noch einmal benutzt, enden alle Tokens der Verbindung. Zugangs-Tokens gelten nur für `POST /mcp`, nicht für REST oder Webhooks.
- **Verfall:** Eine Verbindung, die `OAUTH_IDLE_DAYS` (Standard 90, `0` = nie) nicht benutzt wurde, wird gelöscht.
@@ -142,6 +145,10 @@ Ein Schlüssel darf nur diese Endpunkte und braucht dafür eines der genannten R
| `lists:read` | Listen lesen | `GET /lists`, `GET /lists/{id}` | `list_lists`, `get_list`, `search` |
| `webhook:list` | Listeneinträge hinzufügen | `POST /lists/{id}/items`, `/webhooks/ingest` mit Ziel `list_item` | `add_list_item` |
| `lists:write` | Listen ändern (schließt Hinzufügen ein) | dazu `PATCH`/`PUT /lists/{id}/items/{itemId}` | dazu `check_list_item`, `update_list_item`, `create_list` |
| `notes:read` | Notizen lesen | – | `list_notes`, `get_note`, `search` |
| `notes:create` | Notizen anlegen | – | `create_note` |
Notizen gibt es nur über MCP; per REST bleiben sie für Schlüssel gesperrt. Notizen tragen keine Herkunft (`origin`).
Die Ids `webhook:…` stammen aus der Zeit, als Schlüssel nur einliefern konnten, und bleiben für bestehende Schlüssel gleich.
+1 -1
View File
@@ -101,7 +101,7 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
- **Einladungscodes:** `INV-` plus 100 Zufallsbits in Base32. Die Registrierung beansprucht den Code, bevor das Konto entsteht (kein doppeltes Einlösen).
- **JWT und Sitzungen:** HS256, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Jede Anmeldung legt eine Zeile in `sessions` an (Gerät, IP, letzte Nutzung); `jti` ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header `X-Session-Token` ein neues für dieselbe Sitzung; das Frontend übernimmt es (`request()` in `lib/api.ts`). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (`internal/api/sessions.go`). Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
- **Logout** beendet die Sitzung des Geräts; `GET /auth/sessions` listet die Geräte, `DELETE /auth/sessions/{id}` meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (`currentPassword`, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält per `X-Session-Token` ein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse.
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key`. Scopes `webhook:*` liefern Daten an (`POST /inbox`, `POST /webhooks/ingest`), die Agenten-Scopes `tasks:read`, `tasks:write`, `lists:read`, `lists:write` gelten nur am MCP-Endpunkt (`POST /mcp`, `internal/api/mcp.go`); jedes Werkzeug prüft seinen Scope, `tools/list` zeigt nur erlaubte. Alle anderen Endpunkte antworten für Keys mit `403`; Notizen sind mit Keys nie erreichbar.
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key`. Scopes `webhook:*` liefern Daten an (`POST /inbox`, `POST /webhooks/ingest`), die Agenten-Scopes `tasks:read`, `tasks:write`, `lists:read`, `lists:write` gelten nur am MCP-Endpunkt (`POST /mcp`, `internal/api/mcp.go`); jedes Werkzeug prüft seinen Scope, `tools/list` zeigt nur erlaubte. Alle anderen Endpunkte antworten für Keys mit `403`. Notizen erreichen Keys nur über MCP (`notes:read`: auflisten, lesen, suchen; `notes:create`: neue anlegen, Inhalt als Markdown über `MarkdownToBlocks`), nie per REST.
- **OAuth für MCP** (`internal/api/oauth.go`, `internal/storage/oauth.go`): MindSift ist mit `BASE_URL` sein eigener OAuth-Server für `/api/v1/mcp` (Discovery nach RFC 9728/8414, dynamische Registrierung, Code-Flow mit PKCE S256, rotierende Refresh-Tokens), damit sich claude.ai und ChatGPT verbinden können. Die Zustimmungsseite ist Teil der App (`/oauth/authorize`, `OAuthConsentView`), weil die Anmeldung im `localStorage` liegt. Eine Zustimmung wird ein Eintrag in `api_keys` (`kind = 'oauth'`); Tokens (`mindsift_oat_`/`mindsift_ort_`) hängen daran und gelten nur am MCP-Endpunkt, Rechte und Herkunft laufen wie bei Schlüsseln. Abgelaufene Tokens und unbenutzte Verbindungen (`OAUTH_IDLE_DAYS`) räumt der stündliche Aufräum-Lauf weg. Im Demo-Modus aus, ebenso wenn MCP abgeschaltet ist (`MCP_ENABLED` bzw. Admin-Schalter).
- **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
@@ -85,7 +85,7 @@ export const OAuthConsentView: React.FC<OAuthConsentViewProps> = ({ user, instan
<h2 className="text-sm font-bold text-main">{info.clientName} möchte auf dein MindSift zugreifen</h2>
<p className="text-xs text-muted mt-0.5">
Angemeldet als <span className="text-main">{user.displayName || user.email}</span>. Freigaben gelten wie
für dich selbst; Notizen und Einstellungen bleiben gesperrt.
für dich selbst; Einstellungen bleiben gesperrt.
</p>
</div>
</div>
@@ -100,9 +100,9 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
}
};
// Switches grouped by tasks, lists and inbox (same layout for a new and an existing key)
// Switches grouped by tasks, lists, notes and inbox (same layout for a new and an existing key)
const renderScopeSwitches = (selected: ApiKeyScope[], toggle: (id: ApiKeyScope) => void, label: string) => (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3" role="group" aria-label={label}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3" role="group" aria-label={label}>
{SCOPE_GROUPS.map(([group, heading]) => (
<div key={group} className="border border-border rounded-card bg-panel px-3 py-1.5">
<p className="text-[11px] text-muted pt-1 pb-0.5 font-semibold">{heading}</p>
@@ -237,9 +237,9 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<h3 className="text-sm font-bold text-main">API-Schlüssel</h3>
<p className="text-xs text-muted">
Ein Schlüssel verbindet Home Assistant, n8n, eigene Skripte oder KI-Agenten (Claude Code, Codex …) mit
MindSift. Was er darf, legst du mit den Schaltern fest; das gilt per curl und per MCP gleich. Notizen und
Einstellungen bleiben für Schlüssel gesperrt. Was ein Schlüssel anlegt oder ändert, trägt seinen Namen als
Badge.
MindSift. Was er darf, legst du mit den Schaltern fest; das gilt per curl und per MCP gleich, nur Notizen
gibt es ausschließlich über MCP. Einstellungen bleiben für Schlüssel gesperrt. Was ein Schlüssel bei Aufgaben
und Listen anlegt oder ändert, trägt seinen Namen als Badge.
</p>
</div>
</div>
@@ -636,7 +636,9 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<code className="font-mono">list_lists</code>, <code className="font-mono">create_list</code>,{' '}
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
<code className="font-mono">check_list_item</code>, <code className="font-mono">update_list_item</code>,{' '}
<code className="font-mono">search</code>, <code className="font-mono">capture_to_inbox</code>. Der Agent sieht
<code className="font-mono">list_notes</code>, <code className="font-mono">get_note</code>,{' '}
<code className="font-mono">create_note</code>, <code className="font-mono">search</code>,{' '}
<code className="font-mono">capture_to_inbox</code>. Der Agent sieht
nur die Werkzeuge, für die der Schlüssel bzw. die Verbindung berechtigt ist; Freigaben gelten wie für dich selbst.
</p>
</div>
+6 -2
View File
@@ -1,8 +1,9 @@
import { ApiKeyScope } from './types';
// What a key may do. The rights hold for every access alike: curl/REST and MCP for AI agents. The ids of
// the delivering rights (webhook:…) stay from the time keys could only deliver data.
export type ScopeGroup = 'tasks' | 'lists' | 'inbox';
// the delivering rights (webhook:…) stay from the time keys could only deliver data. Notes are reachable
// over MCP only.
export type ScopeGroup = 'tasks' | 'lists' | 'notes' | 'inbox';
export const SCOPES: { id: ApiKeyScope; label: string; chip: string; hint: string; group: ScopeGroup }[] = [
{ id: 'tasks:read', label: 'Lesen', chip: 'Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'tasks' },
{ id: 'webhook:task', label: 'Anlegen', chip: 'Aufgaben anlegen', hint: 'Nur neue Aufgaben anlegen', group: 'tasks' },
@@ -10,11 +11,14 @@ export const SCOPES: { id: ApiKeyScope; label: string; chip: string; hint: strin
{ id: 'lists:read', label: 'Lesen', chip: 'Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'lists' },
{ id: 'webhook:list', label: 'Einträge hinzufügen', chip: 'Listeneinträge hinzufügen', hint: 'Nur neue Einträge hinzufügen', group: 'lists' },
{ id: 'lists:write', label: 'Ändern', chip: 'Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'lists' },
{ id: 'notes:read', label: 'Lesen', chip: 'Notizen lesen', hint: 'Notizen auflisten, lesen, suchen (nur MCP)', group: 'notes' },
{ id: 'notes:create', label: 'Anlegen', chip: 'Notizen anlegen', hint: 'Nur neue Notizen anlegen (nur MCP)', group: 'notes' },
{ id: 'webhook:triage', label: 'Inbox füllen', chip: 'Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'inbox' },
];
export const SCOPE_GROUPS: [ScopeGroup, string][] = [
['tasks', '📋 Aufgaben'],
['lists', '🛒 Listen'],
['notes', '🗒️ Notizen'],
['inbox', '📥 Inbox'],
];
export const scopeChip = (id: string) => SCOPES.find((s) => s.id === id)?.chip || id;
+4 -2
View File
@@ -662,7 +662,7 @@ export interface UserTheme {
updatedAt: string;
}
/** What an API key may do: webhook:* delivers data, the agent scopes read and change tasks/lists over MCP. */
/** What an API key may do: webhook:* delivers data, the agent scopes read and change tasks/lists; notes only over MCP. */
export type ApiKeyScope =
| 'webhook:triage'
| 'webhook:task'
@@ -670,7 +670,9 @@ export type ApiKeyScope =
| 'tasks:read'
| 'tasks:write'
| 'lists:read'
| 'lists:write';
| 'lists:write'
| 'notes:read'
| 'notes:create';
export interface APIKey {
id: string;
+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}
+6 -2
View File
@@ -12,7 +12,7 @@ import (
// Rights of an API key. They hold the same over every access: REST (curl, smart home) and the MCP
// endpoint for AI agents. The ids of the first three come from the time they only delivered data
// (webhooks) and stay for existing keys. Notes are never reachable with a key.
// (webhooks) and stay for existing keys. Notes are reachable over MCP only, never over REST.
const (
ScopeTriage = "webhook:triage" // thoughts into the inbox
ScopeTask = "webhook:task" // create tasks
@@ -22,10 +22,14 @@ const (
ScopeTasksWrite = "tasks:write" // create, change, complete and delete tasks
ScopeListsRead = "lists:read" // lists and their items
ScopeListsWrite = "lists:write" // add, change and check off items
ScopeNotesRead = "notes:read" // notes and their bullets (MCP only)
ScopeNotesCreate = "notes:create" // create new notes (MCP only); existing ones stay untouched
)
// AllScopes in display order.
var AllScopes = []string{ScopeTriage, ScopeTask, ScopeList, ScopeTasksRead, ScopeTasksWrite, ScopeListsRead, ScopeListsWrite}
var AllScopes = []string{ScopeTriage, ScopeTask, ScopeList, ScopeTasksRead, ScopeTasksWrite, ScopeListsRead, ScopeListsWrite,
ScopeNotesRead, ScopeNotesCreate}
// NormalizeScopes keeps the known scopes in a fixed order; none at all means triage only.
func NormalizeScopes(scopes []string) []string {
+20
View File
@@ -256,6 +256,26 @@ func (s *Store) getUserFolderMounts(userID string) (map[string]string, map[strin
// matchMountPath checks if folderPath falls within any mounted shared folder.
// Returns (sharedFolderID, relativeSubPath, isMatch).
// CheckCreateInFolder: new notes may go into own folders and into shared folders with write access.
func (s *Store) CheckCreateInFolder(userID, folderPath string) error {
mounts, _, err := s.getUserFolderMounts(userID)
if err != nil {
return err
}
fID, _, ok := matchMountPath(folderPath, mounts)
if !ok {
return nil
}
sf, err := s.GetSharedFolderByID(userID, fID)
if err != nil {
return err
}
if sf.Permission != "owner" && sf.Permission != "write" && !sf.IsOwner {
return fmt.Errorf("im geteilten Ordner „%s“ darfst du nur lesen", sf.Name)
}
return nil
}
func matchMountPath(folderPath string, mounts map[string]string) (string, string, bool) {
clean := filepath.ToSlash(filepath.Clean(strings.TrimSpace(folderPath)))
if clean == "." {