From 39a04082fccdd9b123aeccc42ce3c60b33e4faa3 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 1 Nov 2026 20:13:53 +0100 Subject: [PATCH 1/5] feat(workspace): bring an extracted note back into the bullet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bullet that is only a link to a note (as left by /extract) gets a "Notiz zurückholen" button. POST /documents/{id}/inline returns the linked note's blocks with fresh ids and trashes the note; the editor puts the title back as text and inserts the blocks as an own edit, so Strg+Z restores the link and the note. Only own notes that no other note links to. Release 0.52.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +- Doku/api/rest.md | 1 + Doku/modules/workspace.md | 1 + README.md | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/features/help/HelpView.tsx | 2 +- .../src/features/workspace/DocumentEditor.tsx | 48 ++++++++- .../src/features/workspace/OutlinerBlock.tsx | 16 +++ .../src/features/workspace/OutlinerTree.tsx | 3 + frontend/src/lib/api.ts | 5 + internal/api/restructure.go | 38 +++++++- internal/api/restructure_test.go | 76 +++++++++++++++ internal/api/server.go | 1 + internal/storage/restructure.go | 97 +++++++++++++++++++ 15 files changed, 293 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b78e1bb..19cfd1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.52.0] - 2026-11-01 + +### Added +- Notizen: Ein ausgelagerter Punkt lässt sich zurückholen. Am Link, der nach `/extract` stehen bleibt, gibt es den Knopf „Notiz zurückholen“: Die Punkte der Notiz kommen wieder als Unterpunkte an diese Stelle, der Link wird wieder zum Text, und die Notiz wandert in den Papierkorb. Strg+Z oder „Rückgängig“ macht das wieder rückgängig. Geht mit eigenen Notizen, die nirgends sonst verlinkt sind. + ## [0.51.0] - 2026-11-01 ### Added @@ -786,7 +791,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.52.0...dev +[0.52.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...v0.52.0 [0.51.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.50.0...v0.51.0 [0.50.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.2...v0.50.0 [0.49.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.1...v0.49.2 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index a50437c..ecd9709 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -211,6 +211,7 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein | GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) | | POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) | | POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) | +| POST | `/documents/{id}/inline` | `{blockId}`: Gegenstück zu `extract` für einen Punkt, der nur aus `[[Notiz]]` (ggf. mit Hashtags) besteht → `{sourceId, content, blocks}`: Text für den Punkt (Titel plus Hashtags) und die Punkte der Notiz mit neuen IDs (`level` 0 = direkt unter dem Punkt). Die verlinkte Notiz kommt in den Papierkorb; rückgängig mit `POST /documents/{sourceId}/restore`. Die Ursprungsnotiz ändert der Editor selbst. `400` kein Link-Punkt, `404` Notiz nicht gefunden, `403` fremde Notiz, `409` noch aus anderen Notizen verlinkt | | POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` | | POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen | | POST | `/documents/{id}/media` | Bild oder Audio zu einer Notiz hochladen (`multipart/form-data`, Feld `file`; Schreibrecht nötig) → `{id, kind, mime, size, url}`. `415` falsches Format, `413` zu groß oder Kontingent voll | diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index b6a5838..d393057 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -18,6 +18,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code` (mit Syntax-Highlighting im VS-Code-Stil), `/callout`, `/divider`, `/extract` (Ast als eigene Notiz auslagern), `/image` (Bilder einfügen/hochladen, Aliase `/bild`, `/foto`), `/audio` (Sprachnotiz/Audiodatei, Alias `/sprachnotiz`), `/diktat` (Spracheingabe mit Live-Mitschreiben, Alias `/dictate`) (Liste in `OutlinerBlock.tsx`). Das Menü öffnet sich nur am Textanfang oder nach einem Leerzeichen und schließt mit `Esc` oder `Backspace`. - **Link-Vorschau:** Besteht ein Bulletpoint ausschließlich aus einer einzelnen `https`-Adresse (oder wurde über `/link` in einem leeren Punkt eingefügt), zeigt MindSift darüber eine interaktive Vorschaukarte (`LinkPreviewCard`, serverseitig via `/link-preview`). ✕ blendet die Karte aus und wandelt die Adresse synchronisiert in `[Titel](url)` um. - **Ast auslagern (`/extract`):** Wandelt den aktuellen Punkt samt Unterpunkten in eine eigenständige Notiz im selben Ordner um. Der Server legt die Notiz an und liefert den Link zurück; der Editor ersetzt den Ast lokal über die Sync-Engine als eigene Operation (Strg+Z nimmt die Auslagerung zurück und legt die neue Notiz via `POST /documents/{id}/extract/undo` in den Papierkorb). +- **Notiz zurückholen:** Besteht ein Punkt nur aus einem Link auf eine Notiz (wie nach `/extract`), zeigt seine Knopfleiste „Notiz zurückholen“. `POST /documents/{id}/inline` liefert die Punkte der verlinkten Notiz und legt sie in den Papierkorb; der Editor setzt den Titel (mit den Hashtags des Links) wieder als Text ein und fügt die Punkte als Unterpunkte ein, vor eventuell schon vorhandenen. Strg+Z oder „Rückgängig“ stellt den Link wieder her und holt die Notiz aus dem Papierkorb. Nur mit eigenen Notizen, und nicht, solange andere Notizen sie noch verlinken. - **Notizen zusammenführen (Merge):** Per Drag & Drop einer Notiz auf eine andere in der Seitenleiste oder über das Drei-Punkte-Menü im Notizkopf („Mit anderer Notiz zusammenführen…“). Die eigene Quellnotiz wird als `h2`-Abschnitt angehängt und in den Papierkorb verschoben; der Hinweis bietet vollständiges Rückgängigmachen (`POST /documents/{id}/merge/undo`). - **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`). - **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen. diff --git a/README.md b/README.md index 185894a..06e7182 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.51.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.52.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b404814..71ae6b2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.51.0", + "version": "0.52.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.51.0", + "version": "0.52.0", "license": "AGPL-3.0-only", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 5d01c1b..0b62350 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.51.0", + "version": "0.52.0", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index e4a2fbf..5f95790 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -207,7 +207,7 @@ export const HelpView: React.FC = ({ onBack }) => {
📤 - /extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]]) + /extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]]; der Knopf „Notiz zurückholen“ am Link macht das wieder rückgängig)
🎙️ diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 03d6441..00fc9b4 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -545,7 +545,21 @@ export const DocumentEditor: React.FC = ({ // The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash const lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null); + // The last note brought back, so undoing it also takes the note out of the trash again + const lastInlineRef = useRef<{ sourceId: string; blockId: string; content: string } | null>(null); const finishUndoExtract = useCallback(() => { + const inlined = lastInlineRef.current; + if (inlined) { + const block = currentBlocksRef.current.find((b) => b.id === inlined.blockId); + if (!block || block.content !== inlined.content) { + lastInlineRef.current = null; + api + .restoreDocument(inlined.sourceId) + .then(() => api.getDocument(inlined.sourceId)) + .then((restored) => onDocCreated?.(restored)) + .catch((err: any) => showNotice(err?.message || 'Notiz konnte nicht wiederhergestellt werden')); + } + } const info = lastExtractRef.current; if (!info) return; const block = currentBlocksRef.current.find((b) => b.id === info.blockId); @@ -555,7 +569,7 @@ export const DocumentEditor: React.FC = ({ .undoExtract(info.newDocId) .then(() => onDocRemoved?.(info.newDocId)) .catch((err: any) => showNotice(err?.message || 'Ausgelagerte Notiz konnte nicht entfernt werden')); - }, [onDocRemoved, showNotice]); + }, [onDocCreated, onDocRemoved, showNotice]); useEffect(() => () => { if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); }, []); @@ -614,6 +628,7 @@ export const DocumentEditor: React.FC = ({ while (end < prev.length && (prev[end].level || 0) > level) end++; applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: link }, ...prev.slice(end)]); } + lastInlineRef.current = null; lastExtractRef.current = { newDocId: created.id, blockId, link }; onDocCreated?.(created); showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, [ @@ -627,6 +642,35 @@ export const DocumentEditor: React.FC = ({ [docId, onOpenDocBlock, onDocCreated, showNotice, handleUndo, applyLocalBlocks] ); + // The way back: a bullet that only links to a note gets that note's bullets below it again, the note + // goes to the trash. Inserted here as an own edit like extracting, so Strg+Z brings the link back. + const handleInlineBlock = useCallback( + async (blockId: string) => { + const engine = engineRef.current; + if (!engine) return; + try { + await engine.syncNow(); + const res = await api.inlineNote(docId, blockId); + (document.activeElement as HTMLElement | null)?.blur?.(); + const prev = currentBlocksRef.current; + const i = prev.findIndex((b) => b.id === blockId); + if (i >= 0) { + const level = prev[i].level || 0; + const inserted = res.blocks.map((b) => ({ ...b, level: level + 1 + (b.level || 0) })); + // Above any bullets added below the link meanwhile + applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: res.content }, ...inserted, ...prev.slice(i + 1)]); + } + lastExtractRef.current = null; + lastInlineRef.current = { sourceId: res.sourceId, blockId, content: res.content }; + onDocRemoved?.(res.sourceId); + showNotice('Notiz zurückgeholt, sie liegt jetzt im Papierkorb', [{ label: 'Rückgängig', run: () => handleUndo() }]); + } catch (err: any) { + showNotice(err?.message || 'Zurückholen fehlgeschlagen'); + } + }, + [docId, onDocRemoved, showNotice, handleUndo, applyLocalBlocks] + ); + // Keep parent sidebar metadata in sync when blocks/tags change useEffect(() => { if (!docRef.current) return; @@ -1363,6 +1407,7 @@ export const DocumentEditor: React.FC = ({ levelOffset={levelOffset} onZoomBlock={setZoomedBlockId} onExtractBlock={canEdit ? handleExtractBlock : undefined} + onInlineBlock={canEdit ? handleInlineBlock : undefined} onUpdateBlock={handleUpdateBlock} onUpdateBlocks={handleUpdateBlocks} onCreateBlock={handleCreateBlock} @@ -1513,6 +1558,7 @@ export const DocumentEditor: React.FC = ({ levelOffset={0} onZoomBlock={setZoomedBlockId} onExtractBlock={canEdit ? handleExtractBlock : undefined} + onInlineBlock={canEdit ? handleInlineBlock : undefined} onUpdateBlock={handleUpdateBlock} onUpdateBlocks={handleUpdateBlocks} onCreateBlock={handleCreateBlock} diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 6ddc0a6..3208ba2 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -24,6 +24,7 @@ import { Mic, AudioLines, FileOutput, + FileInput, Link2, Type, } from 'lucide-react'; @@ -96,6 +97,7 @@ export interface OutlinerBlockProps { onZoomBlock?: (blockId: string) => void; /** Turns this bullet and its children into a note of its own */ onExtractBlock?: (blockId: string) => void; + onInlineBlock?: (blockId: string) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void; onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void; @@ -151,6 +153,7 @@ export const OutlinerBlock: React.FC = ({ levelOffset = 0, onZoomBlock, onExtractBlock, + onInlineBlock, onUpdateBlock, onCreateBlockBelow, onDeleteBlock, @@ -1724,6 +1727,19 @@ export const OutlinerBlock: React.FC = ({ )} + {/* The way back: a bullet that only links to a note gets that note's bullets again */} + {canEdit && onInlineBlock && isNoteLinkOnly(localContent) && !mediaPicker && ( + + )} + {/* Zoom / Focus button */} {onZoomBlock && (
+ {/* AI agents over MCP (and claude.ai / ChatGPT over OAuth) */} +
+
+

KI-Agenten (MCP) erlauben

+

+ Eingeschaltet können Claude Code, Codex & Co. per API-Schlüssel und claude.ai oder ChatGPT als Connector + auf MindSift zugreifen. Ausgeschaltet ist der MCP-Endpunkt samt Connector-Anmeldung abgeschaltet; + bestehende Connector-Verbindungen ruhen, API-Schlüssel funktionieren per curl weiter. + {settings.mcpConfiguredViaEnv && ' Festgelegt per MCP_ENABLED.'} +

+
+ handleSaveSystemSettings({ mcpEnabled })} + /> +
+ {/* Who may register without an invite */} {(() => { const mode = settings.registrationMode || 'closed'; diff --git a/frontend/src/features/settings/ApiKeysPanel.tsx b/frontend/src/features/settings/ApiKeysPanel.tsx index a9d7640..6d735a0 100644 --- a/frontend/src/features/settings/ApiKeysPanel.tsx +++ b/frontend/src/features/settings/ApiKeysPanel.tsx @@ -37,6 +37,8 @@ export const ApiKeysPanel: React.FC = ({ onIngested }) => { const [connectorUrl, setConnectorUrl] = useState(''); const [copiedConnector, setCopiedConnector] = useState(false); const [access, setAccess] = useState('rest'); + // The operator can switch MCP off; then only the curl / smart home access is shown + const [mcpEnabled, setMcpEnabled] = useState(true); // Live Webhook Simulator State const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!'); @@ -59,7 +61,10 @@ export const ApiKeysPanel: React.FC = ({ onIngested }) => { api.getTaskLists().then(setTaskLists).catch(() => {}); api .getAuthStatus() - .then((st) => setConnectorUrl(st.mcpConnectorUrl || '')) + .then((st) => { + setConnectorUrl(st.mcpConnectorUrl || ''); + setMcpEnabled(st.mcpEnabled !== false); + }) .catch(() => {}); }, []); @@ -358,30 +363,32 @@ http_headers = { "X-API-Key" = "${exampleToken}" }` {/* How to connect: one API, two accesses */}
-
- {( - [ - ['rest', 'curl / Smart Home', Terminal], - ['mcp', 'KI-Agenten (MCP)', Bot], - ] as const - ).map(([value, label, Icon]) => ( - - ))} -
+ {mcpEnabled && ( +
+ {( + [ + ['rest', 'curl / Smart Home', Terminal], + ['mcp', 'KI-Agenten (MCP)', Bot], + ] as const + ).map(([value, label, Icon]) => ( + + ))} +
+ )} - {access === 'rest' && ( + {(access === 'rest' || !mcpEnabled) && (
{/* Reading: e.g. a smart home sensor for what is due today */}
@@ -547,7 +554,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
)} - {access === 'mcp' && ( + {access === 'mcp' && mcpEnabled && (
diff --git a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx index 9ebc041..a34d08c 100644 --- a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx +++ b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx @@ -3,13 +3,16 @@ import { render, screen, fireEvent, act, within } from '@testing-library/react'; const createAPIKey = vi.fn(async () => ({ id: 'k1', userId: 'u', name: 'HA', keyPrefix: 'mindsift_sec_ab', scopes: 'webhook:triage,webhook:list', createdAt: '' })); const updateAPIKey = vi.fn(async () => ({})); +const getAuthStatus = vi.fn(async (): Promise<{ mcpConnectorUrl?: string; mcpEnabled?: boolean }> => ({ + mcpConnectorUrl: 'https://ms.example/api/v1/mcp', +})); vi.mock('../../../lib/api', () => ({ api: { getAPIKeys: vi.fn(async () => [ { id: 'k0', userId: 'u', name: 'Rauchmelder', keyPrefix: 'mindsift_sec_xy', scopes: 'webhook:triage,webhook:task', createdAt: '' }, { id: 'k9', userId: 'u', name: 'Claude (OAuth)', keyPrefix: 'OAuth', scopes: 'tasks:read', createdAt: '', kind: 'oauth' }, ]), - getAuthStatus: vi.fn(async () => ({ mcpConnectorUrl: 'https://ms.example/api/v1/mcp' })), + getAuthStatus: (...args: unknown[]) => getAuthStatus(...(args as [])), getAllLists: vi.fn(async () => []), getTaskLists: vi.fn(async () => []), createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])), @@ -59,6 +62,14 @@ describe('ApiKeysPanel', () => { expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy(); }); + it('hides the MCP tab when the operator switched MCP off', async () => { + getAuthStatus.mockResolvedValueOnce({ mcpConnectorUrl: '', mcpEnabled: false }); + render(); + await act(async () => {}); + expect(screen.queryByRole('tab', { name: /KI-Agenten/ })).toBeNull(); + expect(screen.getAllByText(/tasks\?due=today&status=open/).length).toBeGreaterThan(0); + }); + it('marks OAuth connections instead of showing a key prefix', async () => { render(); expect(await screen.findByText('Claude (OAuth)')).toBeTruthy(); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 4b9799e..9976d8c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -17,6 +17,8 @@ export interface AuthStatus { sharingMode?: SharingMode; // "Passwort vergessen?" works (SMTP and BASE_URL configured) passwordReset?: boolean; + // AI agents may connect over MCP (the operator can switch it off) + mcpEnabled?: boolean; // MCP address for claude.ai / ChatGPT connectors (OAuth); empty when the server has no BASE_URL mcpConnectorUrl?: string; // Demo mode with periodic reset: next reset (ISO) and seconds until then @@ -301,6 +303,9 @@ export interface SystemSettings { // People may keep finished and deleted things for good ("nie"); fixed when set via RETENTION_ALLOW_NEVER retentionAllowNever?: boolean; retentionNeverViaEnv?: boolean; + // AI agents may connect over MCP (and claude.ai / ChatGPT over OAuth); fixed when set via MCP_ENABLED + mcpEnabled?: boolean; + mcpConfiguredViaEnv?: boolean; } /** One file of a server backup listing. */ diff --git a/internal/api/handlers_admin.go b/internal/api/handlers_admin.go index b0c0068..a483909 100644 --- a/internal/api/handlers_admin.go +++ b/internal/api/handlers_admin.go @@ -345,6 +345,11 @@ func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.Sys out.RetentionAllowNever = &allow out.RetentionNeverViaEnv = true } + if s.cfg.MCPEnabled != nil { + on := *s.cfg.MCPEnabled + out.MCPEnabled = &on + out.MCPViaEnv = true + } out.EmailLinksAvailable = s.emailLinksAvailable() return &out } @@ -381,6 +386,10 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques settings.RetentionAllowNever = nil // fixed by RETENTION_ALLOW_NEVER, the stored value stays } settings.RetentionNeverViaEnv = false + if s.cfg.MCPEnabled != nil { + settings.MCPEnabled = nil // fixed by MCP_ENABLED, the stored value stays + } + settings.MCPViaEnv = false previousMode := s.store.SharingMode() if err := s.store.UpdateSystemSettings(&settings); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index acb9295..145e404 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -48,6 +48,8 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { "demoMode": s.cfg.DemoMode, "sharingMode": s.store.SharingMode(), "passwordReset": s.passwordResetAvailable(), + // AI agents may connect over MCP (MCP_ENABLED or the admin's switch) + "mcpEnabled": s.mcpEnabled(), // Address to enter as connector in claude.ai / ChatGPT (OAuth); empty while OAuth is off "mcpConnectorUrl": func() string { if s.oauthEnabled() { diff --git a/internal/api/mcp.go b/internal/api/mcp.go index aeb8394..ae5298e 100644 --- a/internal/api/mcp.go +++ b/internal/api/mcp.go @@ -190,6 +190,14 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) { // handleMCPNoStream: the endpoint is stateless and has no server-initiated stream (GET) or session to // end (DELETE). +// mcpEnabled: MCP_ENABLED if set, else the admin's switch in the system settings (default: on). +func (s *Server) mcpEnabled() bool { + if s.cfg.MCPEnabled != nil { + return *s.cfg.MCPEnabled + } + return s.store.MCPEnabledSetting() +} + func handleMCPNoStream(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Allow", "POST") writeError(w, http.StatusMethodNotAllowed, "Der MCP-Endpunkt nimmt nur POST an (kein Server-Stream)") diff --git a/internal/api/mcp_test.go b/internal/api/mcp_test.go index 09b2e3f..9c14810 100644 --- a/internal/api/mcp_test.go +++ b/internal/api/mcp_test.go @@ -288,3 +288,70 @@ func TestMCPCreateDeleteAndOrigin(t *testing.T) { t.Fatalf("webhook:task key tools: %s", names) } } + +// Operators can switch MCP off: in the admin settings, or fixed by MCP_ENABLED. OAuth goes with it. +func TestMCPSwitchOff(t *testing.T) { + srv, _, session := setupTestServer(t) + srv.cfg.BaseURL = "https://ms.example/" + srv.cfg.BaseURLSet = true + router := srv.Router() + const list = `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + status := func() map[string]interface{} { + _, _, st := oauthDo(t, router, http.MethodGet, "/api/v1/auth/status", "", "", "") + return st + } + + if code, _ := rpc(t, router, session, list); code != http.StatusOK { + t.Fatalf("MCP should be on by default, got %d", code) + } + if st := status(); st["mcpEnabled"] != true || st["mcpConnectorUrl"] == "" { + t.Fatalf("status with MCP on: %v", st) + } + + if code, _, _ := oauthDo(t, router, http.MethodPut, "/api/v1/admin/settings", session, "application/json", `{"mcpEnabled":false}`); code != http.StatusOK { + t.Fatalf("switch off: %d", code) + } + if code, _ := rpc(t, router, session, list); code != http.StatusNotFound { + t.Fatalf("MCP switched off must answer 404, got %d", code) + } + code, h, _ := oauthDo(t, router, http.MethodPost, mcpPath, "", "application/json", list) + if code != http.StatusNotFound || h.Get("WWW-Authenticate") != "" { + t.Fatalf("no login hint while MCP is off: %d %q", code, h.Get("WWW-Authenticate")) + } + if code, _, _ := oauthDo(t, router, http.MethodGet, "/.well-known/oauth-authorization-server", "", "", ""); code != http.StatusNotFound { + t.Fatalf("OAuth must be off with MCP, got %d", code) + } + if st := status(); st["mcpEnabled"] != false || st["mcpConnectorUrl"] != "" { + t.Fatalf("status with MCP off: %v", st) + } + // Saving other settings (without the field) leaves the switch as it is + if code, _, _ := oauthDo(t, router, http.MethodPut, "/api/v1/admin/settings", session, "application/json", `{"allowPrivateWebhooks":true}`); code != http.StatusOK { + t.Fatalf("save other settings: %d", code) + } + if code, _ := rpc(t, router, session, list); code != http.StatusNotFound { + t.Fatalf("MCP must stay off, got %d", code) + } + + // MCP_ENABLED wins over the stored switch and locks it + on := true + srv.cfg.MCPEnabled = &on + if code, _ := rpc(t, router, session, list); code != http.StatusOK { + t.Fatalf("MCP_ENABLED=true must win, got %d", code) + } + _, _, adm := oauthDo(t, router, http.MethodGet, "/api/v1/admin/settings", session, "", "") + if adm["mcpEnabled"] != true || adm["mcpConfiguredViaEnv"] != true { + t.Fatalf("admin settings must show the environment's value: %v", adm) + } + off := false + srv.cfg.MCPEnabled = &off + if code, _, _ := oauthDo(t, router, http.MethodPut, "/api/v1/admin/settings", session, "application/json", `{"mcpEnabled":true}`); code != http.StatusOK { + t.Fatalf("save: %d", code) + } + if code, _ := rpc(t, router, session, list); code != http.StatusNotFound { + t.Fatalf("MCP_ENABLED=false must win, got %d", code) + } + srv.cfg.MCPEnabled = nil + if code, _ := rpc(t, router, session, list); code != http.StatusNotFound { + t.Fatalf("the locked switch must not have changed the stored value, got %d", code) + } +} diff --git a/internal/api/oauth.go b/internal/api/oauth.go index 202cb13..aa5f00e 100644 --- a/internal/api/oauth.go +++ b/internal/api/oauth.go @@ -34,7 +34,7 @@ var oauthOfferedScopes = []string{storage.ScopeTasksRead, storage.ScopeTasksWrit // 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} -func (s *Server) oauthEnabled() bool { return s.cfg.BaseURLSet && !s.cfg.DemoMode } +func (s *Server) oauthEnabled() bool { return s.cfg.BaseURLSet && !s.cfg.DemoMode && s.mcpEnabled() } func (s *Server) oauthIssuer() string { return strings.TrimRight(s.cfg.BaseURL, "/") } diff --git a/internal/api/server.go b/internal/api/server.go index 2421874..6b1c087 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -536,6 +536,11 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { } else { token = r.Header.Get("X-API-Key") } + // MCP switched off: the endpoint answers as if it did not exist, with or without login + if r.URL.Path == mcpPath && !s.mcpEnabled() { + writeError(w, http.StatusNotFound, "MCP ist auf diesem Server ausgeschaltet") + return + } user, key := s.authenticateToken(token) if user == nil { // Tells MCP clients where to start the OAuth flow (RFC 9728) diff --git a/internal/config/config.go b/internal/config/config.go index 4335aeb..c720b8a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,6 +60,9 @@ type Config struct { // RetentionAllowNever: RETENTION_ALLOW_NEVER=true/false fixes whether people may keep finished and // deleted things for good; unset (nil) leaves it to the system settings RetentionAllowNever *bool + // MCPEnabled: MCP_ENABLED=true/false fixes whether the MCP endpoint (and OAuth for connectors) is on; + // unset (nil) leaves it to the system settings + MCPEnabled *bool // Speech recognition, configured only by environment (STT_*) Speech SpeechConfig @@ -261,6 +264,7 @@ func Load() *Config { DemoResetMinutes: demoResetMinutes(), TrustedProxies: getEnv("TRUSTED_PROXIES", "private"), RetentionAllowNever: envBoolPtr("RETENTION_ALLOW_NEVER"), + MCPEnabled: envBoolPtr("MCP_ENABLED"), DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false", OAuthIdleDays: envInt("OAUTH_IDLE_DAYS", 90, 0, 36500), SMTPHost: smtpHost, diff --git a/internal/domain/models.go b/internal/domain/models.go index 031d7cf..3c90c32 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -418,6 +418,10 @@ type SystemSettings struct { // update leaves it unchanged. RetentionNeverViaEnv: fixed by RETENTION_ALLOW_NEVER, not editable. RetentionAllowNever *bool `json:"retentionAllowNever,omitempty"` RetentionNeverViaEnv bool `json:"retentionNeverViaEnv,omitempty"` + // MCPEnabled: AI agents may connect over MCP (and claude.ai / ChatGPT over OAuth); nil in an update + // leaves it unchanged. MCPViaEnv: fixed by MCP_ENABLED, not editable. + MCPEnabled *bool `json:"mcpEnabled,omitempty"` + MCPViaEnv bool `json:"mcpConfiguredViaEnv,omitempty"` } // UnmarshalJSON accepts smtpPort as text or number: older clients sent a number, which made the whole diff --git a/internal/storage/system.go b/internal/storage/system.go index b44b3d0..ac15fe0 100644 --- a/internal/storage/system.go +++ b/internal/storage/system.go @@ -179,6 +179,9 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) { case "retention_allow_never": allow := v != "false" settings.RetentionAllowNever = &allow + case "mcp_enabled": + on := v != "false" + settings.MCPEnabled = &on } } } @@ -194,6 +197,10 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) { allow := true settings.RetentionAllowNever = &allow } + if settings.MCPEnabled == nil { + on := true + settings.MCPEnabled = &on + } return settings, nil } @@ -280,5 +287,16 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { _, err = s.db.Exec(`INSERT INTO system_settings (key, value) VALUES ('retention_allow_never', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, strconv.FormatBool(*settings.RetentionAllowNever)) } + if err == nil && settings.MCPEnabled != nil { + _, err = s.db.Exec(`INSERT INTO system_settings (key, value) VALUES ('mcp_enabled', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, strconv.FormatBool(*settings.MCPEnabled)) + } return err } + +// MCPEnabledSetting: the admin's switch for the MCP endpoint in the system settings (default: on). +func (s *Store) MCPEnabledSetting() bool { + var v string + _ = s.db.QueryRow(`SELECT value FROM system_settings WHERE key = 'mcp_enabled'`).Scan(&v) + return v != "false" +} From a8f6c1b97b036fadd9739a2373d3f193c92fd9a2 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 1 Nov 2026 21:18:26 +0100 Subject: [PATCH 3/5] 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 --- CHANGELOG.md | 1 + Doku/api/rest.md | 13 +- Doku/architecture.md | 2 +- .../src/features/auth/OAuthConsentView.tsx | 2 +- .../src/features/settings/ApiKeysPanel.tsx | 14 +- frontend/src/lib/apiScopes.ts | 8 +- frontend/src/lib/types.ts | 6 +- internal/api/mcp.go | 171 +++++++++++++++++- internal/api/mcp_test.go | 87 +++++++++ internal/api/oauth.go | 3 +- internal/storage/apikeys.go | 8 +- internal/storage/documents.go | 20 ++ 12 files changed, 312 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 939dd31..210125d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 736e6b7..addcef6 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -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 „ (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. diff --git a/Doku/architecture.md b/Doku/architecture.md index 8e79d89..070f2bf 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -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_`, 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_`, 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. diff --git a/frontend/src/features/auth/OAuthConsentView.tsx b/frontend/src/features/auth/OAuthConsentView.tsx index e4aff57..e14481a 100644 --- a/frontend/src/features/auth/OAuthConsentView.tsx +++ b/frontend/src/features/auth/OAuthConsentView.tsx @@ -85,7 +85,7 @@ export const OAuthConsentView: React.FC = ({ user, instan

{info.clientName} möchte auf dein MindSift zugreifen

Angemeldet als {user.displayName || user.email}. Freigaben gelten wie - für dich selbst; Notizen und Einstellungen bleiben gesperrt. + für dich selbst; Einstellungen bleiben gesperrt.

diff --git a/frontend/src/features/settings/ApiKeysPanel.tsx b/frontend/src/features/settings/ApiKeysPanel.tsx index 6d735a0..f020352 100644 --- a/frontend/src/features/settings/ApiKeysPanel.tsx +++ b/frontend/src/features/settings/ApiKeysPanel.tsx @@ -100,9 +100,9 @@ export const ApiKeysPanel: React.FC = ({ 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) => ( -
+
{SCOPE_GROUPS.map(([group, heading]) => (

{heading}

@@ -237,9 +237,9 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`

API-Schlüssel

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.

@@ -636,7 +636,9 @@ http_headers = { "X-API-Key" = "${exampleToken}" }` list_lists, create_list,{' '} get_list, add_list_item,{' '} check_list_item, update_list_item,{' '} - search, capture_to_inbox. Der Agent sieht + list_notes, get_note,{' '} + create_note, search,{' '} + capture_to_inbox. Der Agent sieht nur die Werkzeuge, für die der Schlüssel bzw. die Verbindung berechtigt ist; Freigaben gelten wie für dich selbst.

diff --git a/frontend/src/lib/apiScopes.ts b/frontend/src/lib/apiScopes.ts index 33c5391..9bf17f3 100644 --- a/frontend/src/lib/apiScopes.ts +++ b/frontend/src/lib/apiScopes.ts @@ -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; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 9976d8c..f83ccb5 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; diff --git a/internal/api/mcp.go b/internal/api/mcp.go index ae5298e..efcf722 100644 --- a/internal/api/mcp.go +++ b/internal/api/mcp.go @@ -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("", "", "", "") + 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 } diff --git a/internal/api/mcp_test.go b/internal/api/mcp_test.go index 9c14810..b27b19b 100644 --- a/internal/api/mcp_test.go +++ b/internal/api/mcp_test.go @@ -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) + } +} diff --git a/internal/api/oauth.go b/internal/api/oauth.go index aa5f00e..221c2c8 100644 --- a/internal/api/oauth.go +++ b/internal/api/oauth.go @@ -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} diff --git a/internal/storage/apikeys.go b/internal/storage/apikeys.go index 5a348fb..5afd15b 100644 --- a/internal/storage/apikeys.go +++ b/internal/storage/apikeys.go @@ -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 { diff --git a/internal/storage/documents.go b/internal/storage/documents.go index 2af7ff1..109a114 100644 --- a/internal/storage/documents.go +++ b/internal/storage/documents.go @@ -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 == "." { From 78af2d306e0c3a209728ab36c97d6134ab625123 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 1 Nov 2026 21:24:28 +0100 Subject: [PATCH 4/5] feat(settings): hide the notes rights while MCP is off, mention notes in the API intro Co-Authored-By: Claude Opus 5.5 --- frontend/src/features/settings/ApiKeysPanel.tsx | 2 +- frontend/src/features/settings/UserSettingsView.tsx | 3 ++- frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/settings/ApiKeysPanel.tsx b/frontend/src/features/settings/ApiKeysPanel.tsx index f020352..7495147 100644 --- a/frontend/src/features/settings/ApiKeysPanel.tsx +++ b/frontend/src/features/settings/ApiKeysPanel.tsx @@ -103,7 +103,7 @@ export const ApiKeysPanel: React.FC = ({ onIngested }) => { // 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) => (
- {SCOPE_GROUPS.map(([group, heading]) => ( + {SCOPE_GROUPS.filter(([group]) => mcpEnabled || group !== 'notes').map(([group, heading]) => (

{heading}

{SCOPES.filter((sc) => sc.group === group).map((sc) => ( diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 0538663..0910a68 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -1956,7 +1956,8 @@ export const UserSettingsView: React.FC = ({

API

Mit persönlichen API-Schlüsseln lesen und ändern Skripte, dein Smart Home oder KI-Agenten wie Claude Code - und Codex deine Aufgaben, Listen und die Inbox: per curl oder über MCP, mit denselben Rechten. + und Codex deine Aufgaben, Listen und die Inbox: per curl oder über MCP, mit denselben Rechten. Notizen + lesen und anlegen geht nur über MCP.

diff --git a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx index a34d08c..ea67506 100644 --- a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx +++ b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx @@ -67,6 +67,7 @@ describe('ApiKeysPanel', () => { render(); await act(async () => {}); expect(screen.queryByRole('tab', { name: /KI-Agenten/ })).toBeNull(); + expect(screen.queryByRole('switch', { name: 'Notizen lesen' })).toBeNull(); expect(screen.getAllByText(/tasks\?due=today&status=open/).length).toBeGreaterThan(0); }); From 8e3fa51d90531d00128467edd63843f2548548a9 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 1 Nov 2026 21:27:48 +0100 Subject: [PATCH 5/5] chore: release v0.53.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++++- README.md | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 210125d..4940ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.53.0] - 2026-11-01 + ### 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. @@ -795,7 +797,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.52.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.53.0...dev +[0.53.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.52.0...v0.53.0 [0.52.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.51.0...v0.52.0 [0.51.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.50.0...v0.51.0 [0.50.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.2...v0.50.0 diff --git a/README.md b/README.md index af3db33..fc665d8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.52.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.53.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 71ae6b2..dcde0d0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.52.0", + "version": "0.53.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.52.0", + "version": "0.53.0", "license": "AGPL-3.0-only", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 0b62350..438abdc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.52.0", + "version": "0.53.0", "license": "AGPL-3.0-only", "type": "module", "scripts": {