Merge dev into main (v0.28.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 28s

This commit is contained in:
sandrodach committed 2026-10-31 22:01:44 +01:00
commit 82e777c877
22 files changed
+1243 -147

No files matched your search

+12 -1
View File
@@ -6,6 +6,16 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased]
## [0.28.0] - 2026-10-31
### Added
- KI-Agenten anbinden (MCP): MindSift ist jetzt ein MCP-Server unter `POST /api/v1/mcp`. Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten können offene Aufgaben auslesen, abhaken, anlegen und ändern sowie Einkaufs- und Checklisten lesen, ergänzen und abhaken. Freigaben gelten wie für dich selbst; Notizen bleiben für Agenten unerreichbar.
- API-Keys haben dafür neue Berechtigungen: Aufgaben lesen / ändern, Listen lesen / ändern. Der Agent sieht nur die Werkzeuge, für die sein Key freigegeben ist. Die Einstellungen zeigen den fertigen Befehl für Claude Code, die `config.toml` für Codex und die JSON-Konfiguration für Claude Desktop und Cursor.
### Changed
- Einstellungen: Die Berechtigungen eines API-Keys und die Bereiche beim Import und Wiederherstellen (Notizen, Aufgaben, Listen, Inbox, Themes) sind jetzt Schalter wie im Rest der Einstellungen statt anklickbarer Buttons mit Haken.
- Der Einstellungsbereich heißt jetzt „Webhooks, KI-Agenten & Export“.
## [0.27.1] - 2026-10-31
### Fixed
@@ -544,7 +554,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.27.1...dev
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.28.0...dev
[0.28.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.27.1...v0.28.0
[0.27.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.27.0...v0.27.1
[0.27.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.26.1...v0.27.0
[0.26.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.26.0...v0.26.1
+25 -2
View File
@@ -1,6 +1,6 @@
# REST-API
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern nur Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Anfragekörper sind auf 2 MB begrenzt (Notiz-Sync 8 MB, Importe 101 MB). Im Demo-Modus antworten Endpunkte, die etwas verschicken, Server-Einstellungen ändern oder die gemeinsamen Demo-Konten verändern würden, mit `403` „In der Demo deaktiviert“. Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Mit den Agenten-Scopes `tasks:read`, `tasks:write`, `lists:read`, `lists:write` nutzen sie außerdem den [MCP-Endpunkt](#mcp-für-ki-agenten-post-apiv1mcp). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Anfragekörper sind auf 2 MB begrenzt (Notiz-Sync 8 MB, Importe 101 MB). Im Demo-Modus antworten Endpunkte, die etwas verschicken, Server-Einstellungen ändern oder die gemeinsamen Demo-Konten verändern würden, mit `403` „In der Demo deaktiviert“. Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
## Öffentlich
@@ -49,7 +49,8 @@ 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` (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`; Standard `["webhook:triage"]`) |
| POST | `/mcp` | MCP-Endpunkt für KI-Agenten, siehe unten |
| DELETE | `/keys/{id}` | API-Key löschen |
### Globale Suche (`GET /api/v1/search?q=`)
@@ -77,6 +78,28 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
}
```
### MCP für KI-Agenten (`POST /api/v1/mcp`)
MindSift ist ein [MCP](https://modelcontextprotocol.io)-Server (Streamable HTTP, zustandslos, nur JSON-RPC 2.0 per `POST`; kein Server-Stream, `GET` antwortet `405`, Batches werden abgelehnt). Anmeldung wie überall per `X-API-Key` oder `Authorization: Bearer`; eine Sitzung darf alles, ein API-Key nur die Werkzeuge seiner Scopes, und `tools/list` zeigt ihm nur diese. Protokollversionen `2025-06-18`, `2025-03-26`, `2024-11-05`. Unterstützte Methoden: `initialize`, `ping`, `tools/list`, `tools/call`; Benachrichtigungen antworten `202`. Fehler eines Werkzeugs kommen als `isError`-Ergebnis zurück, damit der Agent sie lesen kann; Ergebnisse stehen als `structuredContent` (Listen als `{items}`) und als JSON-Text.
| Werkzeug | Scope | Zweck |
|---|---|---|
| `list_task_lists` | `tasks:read` | Aufgabenlisten mit `permission` und offenen Aufgaben |
| `list_tasks` | `tasks:read` | Aufgaben (`taskListId`, `status` open/done/all, `dueBefore` JJJJ-MM-TT, `limit`); fällige zuerst |
| `get_task` | `tasks:read` | Eine Aufgabe |
| `create_task` | `tasks:write` | Aufgabe anlegen (`content`, `taskListId`, `priority`, `dueDate`, `notes`); Quelle `agent` |
| `update_task` | `tasks:write` | Felder ändern (`content`, `isCompleted`, `dueDate`, `priority`, `notes`, `kanbanStatus`, `taskListId`, `tags`) |
| `complete_task` | `tasks:write` | Abhaken (`completed` false = wieder öffnen) |
| `list_lists` | `lists:read` | Geteilte Listen mit offenen Einträgen |
| `get_list` | `lists:read` | Liste mit Einträgen |
| `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) |
| `capture_to_inbox` | `webhook:triage` | Gedanken in die Inbox |
Freigaben gelten wie für die Person selbst: Leser-Listen lassen sich nicht ändern, fremde Listen sind unsichtbar. Einrichtung z. B. in Claude Code: `claude mcp add --transport http mindsift https://mindsift.example.com/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`; die Einstellungen zeigen die Varianten für Codex (`config.toml`) und JSON-Konfigurationen (Claude Desktop, Cursor). claude.ai und ChatGPT im Browser verlangen OAuth und werden noch nicht unterstützt.
## Gedanken, Triage, Aufgaben
| Methode | Pfad | Zweck |
+1 -1
View File
@@ -97,7 +97,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` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403`.
- **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.
- **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.
+2 -2
View File
@@ -66,7 +66,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
## Export als Markdown-Archiv
Kontoeinstellungen → Private Webhooks & Export → „Markdown-Archiv herunterladen“ (`GET /export/markdown`, Code: [export_markdown.go](../../internal/storage/export_markdown.go)).
Kontoeinstellungen → Webhooks, KI-Agenten & Export → „Markdown-Archiv herunterladen“ (`GET /export/markdown`, Code: [export_markdown.go](../../internal/storage/export_markdown.go)).
- Ordner und Notizen so, wie der Nutzer sie sieht (eigene Ordner, eingehängte geteilte Ordner, mit ihm geteilte Notizen); leere Ordner bleiben erhalten.
- Jede Notiz: YAML-Frontmatter (`title`, `icon`, `tags`, `created_at`, `updated_at`) und Markdown, das Obsidian direkt darstellt: Überschriften, Trenner, Hinweisboxen (`> [!note]`) und Codeblöcke der obersten Ebene als echte Blöcke, ihre Kinder rücken eine Ebene nach vorn; alles andere als Liste mit einem Tab pro Ebene, To-dos als `- [ ]`.
@@ -75,7 +75,7 @@ Kontoeinstellungen → Private Webhooks & Export → „Markdown-Archiv herunter
## Import
Kontoeinstellungen → Private Webhooks & Export → „Daten importieren“, mit **zwei getrennten Wegen** (Code: [import_markdown.go](../../internal/storage/import_markdown.go), [ImportCard.tsx](../../frontend/src/features/settings/ImportCard.tsx)):
Kontoeinstellungen → Webhooks, KI-Agenten & Export → „Daten importieren“, mit **zwei getrennten Wegen** (Code: [import_markdown.go](../../internal/storage/import_markdown.go), [ImportCard.tsx](../../frontend/src/features/settings/ImportCard.tsx)):
| Weg | Endpunkt | Dateien | Ergebnis |
|---|---|---|---|
+2 -1
View File
@@ -36,8 +36,9 @@ Komponente: [AvatarStack.tsx](../frontend/src/components/AvatarStack.tsx) (nutzt
Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx)
- Ein/Aus-Einstellungen sind immer Schalter, keine Checkbox-Haken (Einrichtung, Admin-Bereich).
- Ein/Aus-Einstellungen sind immer Schalter, keine Checkbox-Haken und keine „angehakten“ Buttons (Einrichtung, Admin-Bereich, Berechtigungen eines API-Keys, Bereiche beim Import und Wiederherstellen → `SectionPicker`).
- Text links (Titel, darunter eine kurze Erklärung in `text-muted`), Schalter rechts.
- Eine Auswahl **einer** Option aus mehreren (Import-Modus, Ecken, Theme-Folgen) bleibt eine Button-Gruppe mit `role="radio"`.
## Kleine Dialoge (Anlegen, Umbenennen)
+2 -1
View File
@@ -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.27.1-16a34a?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/Version-v0.28.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.
@@ -129,6 +129,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
### 🔌 Integrationen
* **Eingehend (API-Key):** `POST /api/v1/webhooks/ingest` legt Gedanken, Aufgaben oder Listeneinträge aus Smarthome, Skripten oder Automationen an.
* **KI-Agenten (MCP):** MindSift ist ein MCP-Server (`POST /api/v1/mcp`, Streamable HTTP). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten lesen offene Aufgaben, haken sie ab, legen neue an und führen Einkaufs- und Checklisten. Pro API-Key schaltbar (lesen/ändern, Aufgaben/Listen); Notizen bleiben außen vor. `claude mcp add --transport http mindsift https://…/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`
* **Ausgehend:** eigener Webhook als Benachrichtigungskanal (JSON per POST), Discord, E-Mail, Push.
* Persönliche API-Keys, Datenexport als JSON, Markdown-Archiv (Obsidian-kompatibel).
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.27.1",
"version": "0.28.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.27.1",
"version": "0.28.0",
"license": "Unlicense",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.27.1",
"version": "0.28.0",
"license": "Unlicense",
"type": "module",
"scripts": {
+12 -4
View File
@@ -492,8 +492,8 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
{
id: 'triage-webhooks',
category: 'triage',
title: 'Externe Erfassung per Webhooks & API',
subtitle: 'Gedanken von Siri, Home Assistant, n8n oder curl einspeisen',
title: 'Webhooks, API & KI-Agenten (MCP)',
subtitle: 'Gedanken von Siri, Home Assistant, n8n oder curl einspeisen; Aufgaben und Listen für Claude Code, Codex & Co.',
icon: Bell,
keywords: [
'webhooks',
@@ -504,11 +504,19 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
'shortcuts',
'automation',
'curl',
'mcp',
'ki',
'agent',
'claude',
'codex',
],
content: (
<div className="space-y-2 text-muted leading-relaxed">
<p>
Unter <em className="text-main">Kontoeinstellungen → Private Webhooks &amp; Export</em> kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du fest, was er einliefern darf: Gedanken in die Inbox, Aufgaben direkt oder Einträge in Listen. Lesen kann ein Schlüssel nie.
Unter <em className="text-main">Kontoeinstellungen → Webhooks, KI-Agenten &amp; Export</em> kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du mit Schaltern fest, was er darf: Gedanken in die Inbox, Aufgaben oder Listeneinträge einliefern (Webhooks) oder für KI-Agenten Aufgaben und Listen lesen und ändern. Notizen erreicht ein Schlüssel nie.
</p>
<p>
<strong className="text-main">KI-Agenten:</strong> MindSift ist ein MCP-Server (<code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/mcp</code>). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten können damit offene Aufgaben auslesen, abhaken und anlegen sowie Einkaufs- und Checklisten führen. Die Einstellungen zeigen den fertigen Befehl bzw. die Konfiguration für den jeweiligen Agenten; der Agent sieht nur die Werkzeuge, für die der Schlüssel freigegeben ist.
</p>
<p>
Mit dem Feld <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>). Der Simulator in den Einstellungen zeigt passende Beispiele.
@@ -656,7 +664,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern &amp; aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
</li>
<li>
<strong className="text-main">Server-Backups:</strong> Hat die Administration automatische Backups eingeschaltet, findest du unter <em>Kontoeinstellungen → Private Webhooks &amp; Export</em> die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben.
<strong className="text-main">Server-Backups:</strong> Hat die Administration automatische Backups eingeschaltet, findest du unter <em>Kontoeinstellungen → Webhooks, KI-Agenten &amp; Export</em> die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben.
</li>
<li>
<strong className="text-main">Ohne Internet:</strong> Als App installiert (oder im Browser) startet MindSift auch offline mit dem zuletzt geladenen Stand. Gedanken erfassen sowie Aufgaben und Listeneinträge abhaken geht weiter; ein Hinweis unten zeigt, wie viele Änderungen warten, und sie werden automatisch übertragen, sobald die Verbindung zurück ist.
+121 -31
View File
@@ -8,31 +8,41 @@ import {
Webhook,
Zap,
Terminal,
Bot,
} from 'lucide-react';
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
interface ApiKeysPanelProps {
onIngested?: () => void;
}
// What a key may deliver; keys never read anything
const SCOPES: { id: ApiKeyScope; label: string; hint: string }[] = [
{ id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox' },
{ id: 'webhook:task', label: '📋 Aufgaben', hint: 'Aufgaben direkt anlegen' },
{ id: 'webhook:list', label: '📝 Listeneinträge', hint: 'Einträge zu Listen hinzufügen' },
// What a key may do: deliver data (webhooks) or read and change tasks and lists for AI agents (MCP)
const SCOPES: { id: ApiKeyScope; label: string; hint: string; group: 'webhook' | 'agent' }[] = [
{ id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'webhook' },
{ id: 'webhook:task', label: '📋 Aufgaben anlegen', hint: 'Aufgaben direkt anlegen', group: 'webhook' },
{ id: 'webhook:list', label: '📝 Listeneinträge anlegen', hint: 'Einträge zu Listen hinzufügen', group: 'webhook' },
{ id: 'tasks:read', label: '📋 Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'agent' },
{ id: 'tasks:write', label: '✅ Aufgaben ändern', hint: 'Aufgaben anlegen, ändern und abhaken', group: 'agent' },
{ id: 'lists:read', label: '📝 Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'agent' },
{ id: 'lists:write', label: '🛒 Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'agent' },
];
const scopeLabel = (id: string) => SCOPES.find((s) => s.id === id)?.label || id;
const isAgentScope = (sc: string) => SCOPES.find((s) => s.id === sc)?.group === 'agent';
type SimTarget = 'triage' | 'task' | 'list_item';
type McpClient = 'claude' | 'codex' | 'json';
// Personal API keys with scopes, a simulator and copyable examples (settings → Private Webhooks)
// Personal API keys with scopes (switches), MCP connection snippets, a webhook simulator and copyable
// examples (settings → Webhooks, KI-Agenten & Export)
export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
const [keys, setKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyScopes, setNewKeyScopes] = useState<ApiKeyScope[]>(['webhook:triage']);
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
const [mcpClient, setMcpClient] = useState<McpClient>('claude');
// Live Webhook Simulator State
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
@@ -140,6 +150,26 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
"tags": ["smarthome", "keller"]
}`;
const mcpUrl = `${baseUrl}/api/v1/mcp`;
const mcpSnippet =
mcpClient === 'claude'
? `claude mcp add --transport http mindsift ${mcpUrl} \\
--header "X-API-Key: ${exampleToken}"`
: mcpClient === 'codex'
? `# ~/.codex/config.toml
[mcp_servers.mindsift]
url = "${mcpUrl}"
http_headers = { "X-API-Key" = "${exampleToken}" }`
: `{
"mcpServers": {
"mindsift": {
"type": "http",
"url": "${mcpUrl}",
"headers": { "X-API-Key": "${exampleToken}" }
}
}
}`;
return (
<div className="space-y-6">
{/* API keys */}
@@ -149,10 +179,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
<Key className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">Webhooks & Ingestion API-Schlüssel</h3>
<h3 className="text-sm font-bold text-main">API-Schlüssel für Webhooks und KI-Agenten</h3>
<p className="text-xs text-muted">
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs mit MindSift. Ein Key kann nur Daten
einliefern, nie lesen; was genau, legst du pro Key fest.
Verbinde Home Assistant, n8n, eigene Skripte oder KI-Agenten (Claude Code, Codex …) mit MindSift. Was ein
Key darf, legst du pro Key fest; Notizen bleiben für Keys immer unerreichbar.
</p>
</div>
</div>
@@ -171,27 +201,32 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
<span>API-Key erzeugen</span>
</button>
</div>
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Berechtigungen des Keys">
<span className="text-xs text-muted">Darf einliefern:</span>
{SCOPES.map((sc) => {
const on = newKeyScopes.includes(sc.id);
return (
<button
key={sc.id}
type="button"
role="checkbox"
aria-checked={on}
title={sc.hint}
onClick={() => toggleScope(sc.id)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs inline-flex items-center gap-1.5 transition-colors ${
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{on && <Check className="w-3.5 h-3.5 text-accent" />}
{sc.label}
</button>
);
})}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3" role="group" aria-label="Berechtigungen des Keys">
{(
[
['webhook', 'Einliefern (Webhooks)', 'POST /api/v1/webhooks/ingest'],
['agent', 'KI-Agenten (MCP)', 'POST /api/v1/mcp'],
] as const
).map(([group, heading, path]) => (
<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 flex flex-wrap justify-between gap-x-2">
<span className="font-semibold">{heading}</span>
<code className="font-mono">{path}</code>
</p>
{SCOPES.filter((sc) => sc.group === group).map((sc) => {
const on = newKeyScopes.includes(sc.id);
return (
<div key={sc.id} className="flex items-center justify-between gap-3 min-h-[44px] sm:min-h-[36px]">
<div className="min-w-0">
<span className="block text-xs text-main">{sc.label}</span>
<span className="block text-[11px] text-muted">{sc.hint}</span>
</div>
<ToggleSwitch checked={on} onChange={() => toggleScope(sc.id)} title={sc.label} />
</div>
);
})}
</div>
))}
</div>
</form>
@@ -222,7 +257,12 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
.split(',')
.filter(Boolean)
.map((sc) => (
<span key={sc} className="px-1.5 py-0.5 rounded-btn border border-border text-[10px] text-muted">
<span
key={sc}
className={`px-1.5 py-0.5 rounded-btn border text-[10px] ${
isAgentScope(sc) ? 'border-accent/40 text-accent' : 'border-border text-muted'
}`}
>
{scopeLabel(sc)}
</span>
))}
@@ -245,6 +285,56 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
</div>
</div>
{/* AI agents (MCP) */}
<div className="pt-4 border-t border-border">
<div className="flex items-center gap-3 mb-3">
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">KI-Agenten anbinden (MCP)</h3>
<p className="text-xs text-muted">
MindSift ist ein MCP-Server: Claude Code, Codex, Claude Desktop, Cursor und eigene Agenten können offene
Aufgaben lesen, abhaken und anlegen sowie Listen führen. Dafür braucht der Key die Agenten-Berechtigungen.
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-2" role="radiogroup" aria-label="Agent">
{(
[
['claude', 'Claude Code'],
['codex', 'OpenAI Codex'],
['json', 'Claude Desktop / Cursor'],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={mcpClient === value}
onClick={() => setMcpClient(value)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
mcpClient === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{label}
</button>
))}
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
<p className="mt-2 text-[11px] text-faint">
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">list_tasks</code>,{' '}
<code className="font-mono">complete_task</code>, <code className="font-mono">create_task</code>,{' '}
<code className="font-mono">update_task</code>, <code className="font-mono">list_lists</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">search</code>,{' '}
<code className="font-mono">capture_to_inbox</code>. Der Agent sieht nur die Werkzeuge, für die der Key
berechtigt ist; Freigaben gelten wie für dich selbst. ChatGPT im Browser und claude.ai brauchen OAuth und
werden noch nicht unterstützt.
</p>
</div>
{/* Simulator */}
<div className="pt-4 border-t border-border">
<div className="flex items-center gap-3 mb-3">
@@ -1,6 +1,6 @@
import React from 'react';
import { Check } from 'lucide-react';
import { DATA_SECTIONS, DataSection } from '../../lib/types';
import { ToggleSwitch } from '../../components/ToggleSwitch';
interface SectionPickerProps {
value: DataSection[];
@@ -8,30 +8,27 @@ interface SectionPickerProps {
label?: string;
}
// Which parts of the own data a restore or import covers; at least one stays selected
// Which parts of the own data a restore or import covers; one switch per section, at least one stays on
export const SectionPicker: React.FC<SectionPickerProps> = ({ value, onChange, label = 'Bereiche' }) => (
<div className="flex flex-wrap items-center gap-1.5" role="group" aria-label={label}>
<span className="text-[11px] text-muted mr-1">{label}:</span>
{DATA_SECTIONS.map((sec) => {
const on = value.includes(sec.id);
return (
<button
key={sec.id}
type="button"
role="checkbox"
aria-checked={on}
onClick={() => {
const next = on ? value.filter((v) => v !== sec.id) : [...value, sec.id];
if (next.length > 0) onChange(next);
}}
className={`h-11 sm:h-7 px-2.5 rounded-btn border text-[11px] inline-flex items-center gap-1 transition-colors ${
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{on && <Check className="w-3 h-3 text-accent" />}
{sec.label}
</button>
);
})}
<div role="group" aria-label={label}>
<p className="text-[11px] text-muted mb-1.5">{label}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 border border-border rounded-card px-3 py-1.5 bg-panel">
{DATA_SECTIONS.map((sec) => {
const on = value.includes(sec.id);
return (
<div key={sec.id} className="flex items-center justify-between gap-3 min-h-[44px] sm:min-h-[36px]">
<span className="text-xs text-main">{sec.label}</span>
<ToggleSwitch
checked={on}
title={sec.label}
onChange={() => {
const next = on ? value.filter((v) => v !== sec.id) : [...value, sec.id];
if (next.length > 0) onChange(next);
}}
/>
</div>
);
})}
</div>
</div>
);
@@ -582,7 +582,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<div>
<h1 className="text-base font-bold text-main tracking-tight">Kontoeinstellungen</h1>
<p className="text-xs text-muted">
Persönliches Profil, Design-Themes, Aufgaben-Alarme, Private Webhooks & Daten-Backup
Persönliches Profil, Design-Themes, Aufgaben-Alarme, Webhooks, KI-Agenten & Daten-Backup
</p>
</div>
</div>
@@ -651,8 +651,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
>
<Key className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Private Webhooks & Export</p>
<p className="text-[10px] text-faint">API-Keys, Curl & JSON-Backup</p>
<p className="font-semibold">Webhooks, KI-Agenten & Export</p>
<p className="text-[10px] text-faint">API-Keys, MCP, Curl & JSON-Backup</p>
</div>
</button>
</aside>
@@ -1855,11 +1855,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
Private Webhooks (Eingehend) & Daten-Export
Webhooks, KI-Agenten & Daten-Export
</h2>
<p className="text-muted mt-0.5">
Erstelle persönliche API-Schlüssel, um aus externen Skripten, Server-Monitoring
oder Discord-Bots automatisch Aufgaben und Gedanken in dein MindSift einzuspeisen.
Erstelle persönliche API-Schlüssel, um aus Skripten, Smarthome oder Bots Aufgaben und Gedanken
einzuspeisen oder KI-Agenten wie Claude Code und Codex an deine Aufgaben und Listen zu lassen.
</p>
</div>
@@ -24,18 +24,27 @@ describe('ApiKeysPanel', () => {
it('shows the scopes of existing keys as badges', async () => {
render(<ApiKeysPanel />);
expect(await screen.findByText('Rauchmelder')).toBeTruthy();
expect(screen.getAllByText('📋 Aufgaben').length).toBeGreaterThan(1); // chip + badge
expect(screen.getAllByText('📋 Aufgaben anlegen').length).toBeGreaterThan(1); // switch label + badge
});
it('creates a key with the chosen scopes and never without one', async () => {
render(<ApiKeysPanel />);
const triage = screen.getByRole('checkbox', { name: /Triage/ });
const triage = screen.getByRole('switch', { name: /Triage/ });
fireEvent.click(triage); // last remaining scope cannot be switched off
expect(triage.getAttribute('aria-checked')).toBe('true');
fireEvent.click(screen.getByRole('checkbox', { name: /Listeneinträge/ }));
fireEvent.click(screen.getByRole('switch', { name: /Listeneinträge anlegen/ }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /API-Key erzeugen/ }));
});
expect(createAPIKey).toHaveBeenCalledWith('Home Assistant', ['webhook:triage', 'webhook:list']);
});
it('offers agent scopes and shows how to connect an MCP client', async () => {
render(<ApiKeysPanel />);
fireEvent.click(screen.getByRole('switch', { name: /Aufgaben ändern/ }));
expect(screen.getByRole('switch', { name: /Aufgaben ändern/ }).getAttribute('aria-checked')).toBe('true');
expect(screen.getByText(/claude mcp add --transport http mindsift/)).toBeTruthy();
fireEvent.click(screen.getByRole('radio', { name: 'OpenAI Codex' }));
expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy();
});
});
@@ -0,0 +1,16 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { SectionPicker } from '../SectionPicker';
describe('SectionPicker', () => {
it('uses switches and keeps at least one section on', () => {
const onChange = vi.fn();
render(<SectionPicker value={['notes']} onChange={onChange} />);
const notes = screen.getByRole('switch', { name: 'Notizen' });
expect(notes.getAttribute('aria-checked')).toBe('true');
fireEvent.click(notes); // last one cannot be switched off
expect(onChange).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('switch', { name: 'Listen' }));
expect(onChange).toHaveBeenCalledWith(['notes', 'lists']);
});
});
+9 -2
View File
@@ -592,8 +592,15 @@ export interface UserTheme {
updatedAt: string;
}
/** What an API key may deliver; keys never read data. */
export type ApiKeyScope = 'webhook:triage' | 'webhook:task' | 'webhook:list';
/** What an API key may do: webhook:* delivers data, the agent scopes read and change tasks/lists over MCP. */
export type ApiKeyScope =
| 'webhook:triage'
| 'webhook:task'
| 'webhook:list'
| 'tasks:read'
| 'tasks:write'
| 'lists:read'
| 'lists:write';
export interface APIKey {
id: string;
+45 -21
View File
@@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
@@ -187,49 +188,72 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
})
}
// ingestTask creates a task directly (scope webhook:task), optionally in a task list the owner of
// the key may write to.
func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string) {
// createTask creates a task directly (webhook scope webhook:task, MCP tool create_task), optionally in
// a task list the user may write to (otherwise storage.ErrTaskListReadOnly).
func (s *Server) createTask(userID, content, priority, source, dueDate, taskListID, notes string) (*domain.Thought, error) {
if priority == "" {
priority = "normal"
}
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
return nil, storage.ErrTaskListReadOnly
}
thought, err := s.store.CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, "todo", "")
thought, err := s.store.CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, "todo", notes)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
return nil, err
}
task, err := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", "task", "", dueDate, taskListID)
if err != nil {
return nil, err
}
s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task)
return task, nil
}
func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string) {
task, err := s.createTask(userID, content, priority, source, dueDate, taskListID, "")
if errors.Is(err, storage.ErrTaskListReadOnly) {
writeError(w, http.StatusForbidden, err.Error())
return
}
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task)
writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "task": task})
}
var errListIDRequired = errors.New("Feld 'listId' ist für Listeneinträge erforderlich")
// addListItem appends an item to a list the user may write to; the error is storage.ErrListReadOnly for
// viewers and "not found" for lists the user cannot see.
func (s *Server) addListItem(userID, listID, content, category, quantity string) (*domain.ListItem, error) {
if strings.TrimSpace(listID) == "" {
return nil, errListIDRequired
}
if err := s.store.EnsureListItemsWritable(userID, listID, ""); err != nil {
return nil, err
}
item, err := s.store.CreateListItem(listID, content, category, quantity, 0, userID)
if err != nil {
return nil, err
}
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
return item, nil
}
// ingestListItem adds an item to a list (scope webhook:list) the owner of the key may write to.
func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string) {
if strings.TrimSpace(listID) == "" {
writeError(w, http.StatusBadRequest, "Feld 'listId' ist für Listeneinträge erforderlich")
return
}
if err := s.store.EnsureListItemsWritable(userID, listID, ""); err != nil {
item, err := s.addListItem(userID, listID, content, category, quantity)
if err != nil {
status := http.StatusNotFound
if errors.Is(err, storage.ErrListReadOnly) {
switch {
case errors.Is(err, storage.ErrListReadOnly):
status = http.StatusForbidden
case errors.Is(err, errListIDRequired):
status = http.StatusBadRequest
}
writeError(w, status, err.Error())
return
}
item, err := s.store.CreateListItem(listID, content, category, quantity, 0, userID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "item": item})
}
+20 -9
View File
@@ -166,6 +166,25 @@ func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, item)
}
// setListItemDone checks an item off (or back on) and tells the list's viewers. Shared by
// PATCH /lists/{id}/items/{itemId} and the MCP tool check_list_item; access is checked first.
func (s *Server) setListItemDone(userID, listID, itemID string, done bool) (*domain.ListItem, error) {
if err := s.store.EnsureListItemsWritable(userID, listID, itemID); err != nil {
return nil, err
}
user, _ := s.store.GetUserByID(userID)
userName := "Mitglied"
if user != nil {
userName = user.DisplayName
}
item, err := s.store.ToggleListItem(itemID, done, userID, userName)
if err != nil {
return nil, err
}
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
return item, nil
}
func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
@@ -182,19 +201,11 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) {
return
}
user, _ := s.store.GetUserByID(userID)
userName := "Mitglied"
if user != nil {
userName = user.DisplayName
}
item, err := s.store.ToggleListItem(itemID, req.IsCompleted, userID, userName)
item, err := s.setListItemDone(userID, listID, itemID, req.IsCompleted)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
writeJSON(w, http.StatusOK, item)
}
+38 -27
View File
@@ -231,46 +231,40 @@ func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
var req struct {
Content *string `json:"content"`
IsCompleted *bool `json:"isCompleted"`
DueDate *string `json:"dueDate"`
Priority *string `json:"priority"`
TaskListID *string `json:"taskListId"`
KanbanStatus *string `json:"kanbanStatus"`
Notes *string `json:"notes"`
Subtasks *[]domain.SubTask `json:"subtasks"`
Tags *[]string `json:"tags"`
Assignees *[]string `json:"assignees"`
Recurrence *string `json:"recurrence"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
// taskPatch is a partial change of a task; nil fields stay as they are. Shared by PATCH /thoughts/{id}
// and the MCP tools.
type taskPatch struct {
Content *string `json:"content"`
IsCompleted *bool `json:"isCompleted"`
DueDate *string `json:"dueDate"`
Priority *string `json:"priority"`
TaskListID *string `json:"taskListId"`
KanbanStatus *string `json:"kanbanStatus"`
Notes *string `json:"notes"`
Subtasks *[]domain.SubTask `json:"subtasks"`
Tags *[]string `json:"tags"`
Assignees *[]string `json:"assignees"`
Recurrence *string `json:"recurrence"`
}
// updateTask applies a patch, keeps a linked note block in step, tells everyone who sees the task
// and sends the notifications. Errors come from the store (writeStoreError maps them).
func (s *Server) updateTask(userID, thoughtID string, req taskPatch) (*domain.Thought, error) {
before, _ := s.store.GetThoughtByID(userID, thoughtID)
// The rule is set first, so completing a task in the same request already repeats it
if req.Recurrence != nil {
if err := s.store.SetTaskRecurrence(userID, thoughtID, strings.TrimSpace(*req.Recurrence)); err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
return nil, err
}
}
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
return nil, err
}
if req.Assignees != nil {
assignees, err := s.store.SetTaskAssignees(userID, thoughtID, *req.Assignees)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
return nil, err
}
updated.Assignees = assignees
}
@@ -281,6 +275,23 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated)
s.notifyTaskChange(userID, before, updated, false)
updated.IsWatching = s.store.WatchedTaskIDs(userID)[updated.ID]
return updated, nil
}
func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
var req taskPatch
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
updated, err := s.updateTask(userID, thoughtID, req)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, updated)
}
+677
View File
@@ -0,0 +1,677 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// MCP (Model Context Protocol) endpoint for AI agents: Streamable HTTP, stateless, JSON-RPC 2.0 over
// POST. Authentication runs through authMiddleware (API key with agent scopes or a session); every
// tool checks the scope it needs (mcpToolAllowed). There is no server-to-client stream (GET answers
// 405), so each request is one JSON response.
const mcpPath = "/api/v1/mcp"
// Version of the tool set, not of MindSift (the binary does not know its release)
const mcpServerVersion = "1"
// Protocol versions this server speaks; the newest is the default
var mcpProtocolVersions = []string{"2025-06-18", "2025-03-26", "2024-11-05"}
type jsonrpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type jsonrpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type jsonrpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result interface{} `json:"result,omitempty"`
Error *jsonrpcError `json:"error,omitempty"`
}
const (
rpcParseError = -32700
rpcInvalidRequest = -32600
rpcMethodNotFound = -32601
rpcInvalidParams = -32602
)
// mcpTool is one callable tool: the scope an API key needs for it and the function doing the work.
// run returns the structured result; an error becomes a tool error (isError) the agent can read.
type mcpTool struct {
Name string
Description string
Scope string
Schema map[string]interface{}
run func(s *Server, userID string, args json.RawMessage) (interface{}, error)
}
// mcpToolAllowed: sessions may use every tool, API keys only those of their scopes. search needs any
// read scope (its results are filtered to those scopes).
func mcpToolAllowed(scopes []string, isKey bool, tool mcpTool) bool {
if !isKey {
return true
}
if tool.Scope == "" {
return true
}
has := func(sc string) bool {
for _, have := range scopes {
if have == sc {
return true
}
}
return false
}
if tool.Name == "search" {
return has(storage.ScopeTasksRead) || has(storage.ScopeListsRead)
}
return has(tool.Scope)
}
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
scopes, isKey := r.Context().Value(apiKeyScopesKey{}).([]string)
var raw json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
writeRPC(w, nil, nil, &jsonrpcError{rpcParseError, "Ungültiges JSON"})
return
}
trimmed := strings.TrimSpace(string(raw))
if strings.HasPrefix(trimmed, "[") {
writeRPC(w, nil, nil, &jsonrpcError{rpcInvalidRequest, "Batch-Anfragen werden nicht unterstützt"})
return
}
var req jsonrpcRequest
if err := json.Unmarshal(raw, &req); err != nil || req.Method == "" {
writeRPC(w, nil, nil, &jsonrpcError{rpcInvalidRequest, "Ungültige JSON-RPC-Anfrage"})
return
}
// Notifications (no id) are acknowledged without a body
if len(req.ID) == 0 || string(req.ID) == "null" {
w.WriteHeader(http.StatusAccepted)
return
}
switch req.Method {
case "initialize":
var p struct {
ProtocolVersion string `json:"protocolVersion"`
}
_ = json.Unmarshal(req.Params, &p)
version := mcpProtocolVersions[0]
for _, v := range mcpProtocolVersions {
if v == p.ProtocolVersion {
version = v
}
}
writeRPC(w, req.ID, map[string]interface{}{
"protocolVersion": version,
"capabilities": map[string]interface{}{"tools": map[string]interface{}{}},
"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.",
}, nil)
case "ping":
writeRPC(w, req.ID, map[string]interface{}{}, nil)
case "tools/list":
tools := []map[string]interface{}{}
for _, t := range mcpTools {
if !mcpToolAllowed(scopes, isKey, t) {
continue
}
tools = append(tools, map[string]interface{}{
"name": t.Name,
"description": t.Description,
"inputSchema": t.Schema,
})
}
writeRPC(w, req.ID, map[string]interface{}{"tools": tools}, nil)
case "tools/call":
var p struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &p); err != nil || p.Name == "" {
writeRPC(w, req.ID, nil, &jsonrpcError{rpcInvalidParams, "Parameter 'name' fehlt"})
return
}
tool, ok := mcpToolByName(p.Name)
if !ok {
writeRPC(w, req.ID, nil, &jsonrpcError{rpcInvalidParams, "Unbekanntes Tool: " + p.Name})
return
}
if !mcpToolAllowed(scopes, isKey, tool) {
writeRPC(w, req.ID, toolError("Der API-Key hat keine Berechtigung für "+tool.Name+" (Scope "+tool.Scope+" fehlt)"), nil)
return
}
if len(p.Arguments) == 0 {
p.Arguments = json.RawMessage("{}")
}
result, err := tool.run(s, userID, p.Arguments)
if err != nil {
writeRPC(w, req.ID, toolError(err.Error()), nil)
return
}
if tool.Name == "search" && isKey {
result = filterSearchForScopes(result, scopes)
}
writeRPC(w, req.ID, toolResult(result), nil)
default:
writeRPC(w, req.ID, nil, &jsonrpcError{rpcMethodNotFound, "Methode nicht unterstützt: " + req.Method})
}
}
// handleMCPNoStream: the endpoint is stateless and has no server-initiated stream (GET) or session to
// end (DELETE).
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)")
}
func writeRPC(w http.ResponseWriter, id json.RawMessage, result interface{}, rpcErr *jsonrpcError) {
if len(id) == 0 {
id = json.RawMessage("null")
}
writeJSON(w, http.StatusOK, jsonrpcResponse{JSONRPC: "2.0", ID: id, Result: result, Error: rpcErr})
}
// toolResult: the data as structuredContent plus the same JSON as text for clients without
// structured output.
func toolResult(data interface{}) map[string]interface{} {
text, _ := json.MarshalIndent(data, "", " ")
return map[string]interface{}{
"content": []map[string]string{{"type": "text", "text": string(text)}},
"structuredContent": wrapStructured(data),
}
}
// structuredContent must be an object; arrays are wrapped
func wrapStructured(data interface{}) interface{} {
raw, _ := json.Marshal(data)
if strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
return map[string]interface{}{"items": data}
}
return data
}
func toolError(msg string) map[string]interface{} {
return map[string]interface{}{
"content": []map[string]string{{"type": "text", "text": msg}},
"isError": true,
}
}
// --- tools ---
func mcpToolByName(name string) (mcpTool, bool) {
for _, t := range mcpTools {
if t.Name == name {
return t, true
}
}
return mcpTool{}, false
}
func schema(required []string, props map[string]interface{}) map[string]interface{} {
out := map[string]interface{}{"type": "object", "properties": props}
if len(required) > 0 {
out["required"] = required
}
return out
}
func prop(typ, desc string) map[string]interface{} {
return map[string]interface{}{"type": typ, "description": desc}
}
func decodeArgs(args json.RawMessage, into interface{}) error {
if err := json.Unmarshal(args, into); err != nil {
return errors.New("Ungültige Argumente: " + err.Error())
}
return nil
}
// taskView is what an agent gets per task: the fields it can reason about, no internal bookkeeping.
type taskView struct {
ID string `json:"id"`
Content string `json:"content"`
IsCompleted bool `json:"isCompleted"`
Priority string `json:"priority"`
DueDate string `json:"dueDate,omitempty"`
TaskListID string `json:"taskListId,omitempty"`
KanbanStatus string `json:"kanbanStatus,omitempty"`
Notes string `json:"notes,omitempty"`
Tags []string `json:"tags,omitempty"`
Subtasks []domain.SubTask `json:"subtasks,omitempty"`
Recurrence string `json:"recurrence,omitempty"`
CreatedAt string `json:"createdAt"`
}
func toTaskView(t *domain.Thought) taskView {
return taskView{
ID: t.ID, Content: t.Content, IsCompleted: t.IsCompleted, Priority: t.Priority, DueDate: t.DueDate,
TaskListID: t.TaskListID, KanbanStatus: t.KanbanStatus, Notes: t.Notes, Tags: t.Tags, Subtasks: t.Subtasks,
Recurrence: t.Recurrence, CreatedAt: t.CreatedAt,
}
}
type taskListView struct {
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Permission string `json:"permission"`
OpenTasks int `json:"openTasks"`
}
type listView struct {
ID string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
ListType string `json:"listType"`
Role string `json:"role"`
OpenItems int `json:"openItems"`
Items int `json:"items"`
}
func toListView(l domain.SharedList) listView {
open := 0
for _, it := range l.Items {
if !it.IsCompleted {
open++
}
}
role := l.Role
if l.IsOwner {
role = "owner"
}
return listView{ID: l.ID, Title: l.Title, Icon: l.Icon, ListType: l.ListType, Role: role, OpenItems: open, Items: len(l.Items)}
}
var mcpTools = []mcpTool{
{
Name: "list_task_lists",
Description: "All task lists the user can see, with permission (owner/editor/viewer) and the number of open tasks. Tasks without a list live in the personal inbox list (taskListId empty).",
Scope: storage.ScopeTasksRead,
Schema: schema(nil, map[string]interface{}{}),
run: func(s *Server, userID string, _ json.RawMessage) (interface{}, error) {
lists, err := s.store.GetTaskLists(userID)
if err != nil {
return nil, err
}
out := make([]taskListView, 0, len(lists))
for _, l := range lists {
out = append(out, taskListView{ID: l.ID, Title: l.Title, Icon: l.Icon, Permission: l.Permission, OpenTasks: l.TasksCount})
}
return out, nil
},
},
{
Name: "list_tasks",
Description: "Tasks the user can see. Defaults to open tasks across all lists; filter by taskListId, status (open|done|all) or dueBefore (YYYY-MM-DD, inclusive). Sorted: overdue and due first.",
Scope: storage.ScopeTasksRead,
Schema: schema(nil, map[string]interface{}{
"taskListId": prop("string", "Only tasks of this task list"),
"status": map[string]interface{}{"type": "string", "enum": []string{"open", "done", "all"}, "description": "Default open"},
"dueBefore": prop("string", "Only tasks due on or before this date (YYYY-MM-DD); tasks without due date are left out"),
"limit": prop("integer", "Maximum number of tasks (default 100)"),
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
var a struct {
TaskListID string `json:"taskListId"`
Status string `json:"status"`
DueBefore string `json:"dueBefore"`
Limit int `json:"limit"`
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
tasks, err := s.store.GetTasks(userID, a.TaskListID)
if err != nil {
return nil, err
}
if a.Limit <= 0 {
a.Limit = 100
}
out := make([]taskView, 0)
for i := range tasks {
t := &tasks[i]
switch a.Status {
case "done":
if !t.IsCompleted {
continue
}
case "all":
default:
if t.IsCompleted {
continue
}
}
if a.DueBefore != "" && (t.DueDate == "" || t.DueDate[:min(10, len(t.DueDate))] > a.DueBefore) {
continue
}
out = append(out, toTaskView(t))
}
// Due dates first (earliest on top), then the rest in list order
sort.SliceStable(out, func(i, j int) bool {
if (out[i].DueDate == "") != (out[j].DueDate == "") {
return out[i].DueDate != ""
}
return out[i].DueDate != "" && out[i].DueDate < out[j].DueDate
})
if len(out) > a.Limit {
out = out[:a.Limit]
}
return out, nil
},
},
{
Name: "get_task",
Description: "One task with all details.",
Scope: storage.ScopeTasksRead,
Schema: schema([]string{"id"}, map[string]interface{}{"id": prop("string", "Task id")}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
var a struct {
ID string `json:"id"`
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
t, err := s.store.GetThoughtByID(userID, a.ID)
if err != nil || t == nil {
return nil, errors.New("Aufgabe nicht gefunden")
}
return toTaskView(t), nil
},
},
{
Name: "create_task",
Description: "Create a task, optionally in a task list the user may edit. dueDate as YYYY-MM-DD or YYYY-MM-DDTHH:MM.",
Scope: storage.ScopeTasksWrite,
Schema: schema([]string{"content"}, map[string]interface{}{
"content": prop("string", "Task text; #hashtags become tags"),
"taskListId": prop("string", "Task list id (from list_task_lists); empty = personal tasks"),
"priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}},
"dueDate": prop("string", "YYYY-MM-DD or YYYY-MM-DDTHH:MM"),
"notes": prop("string", "Longer description"),
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
var a struct {
Content string `json:"content"`
TaskListID string `json:"taskListId"`
Priority string `json:"priority"`
DueDate string `json:"dueDate"`
Notes string `json:"notes"`
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
if strings.TrimSpace(a.Content) == "" {
return nil, errors.New("'content' darf nicht leer sein")
}
t, err := s.createTask(userID, a.Content, a.Priority, "agent", a.DueDate, a.TaskListID, a.Notes)
if err != nil {
return nil, err
}
return toTaskView(t), nil
},
},
{
Name: "update_task",
Description: "Change fields of a task; only the given fields change. Empty dueDate removes the due date.",
Scope: storage.ScopeTasksWrite,
Schema: schema([]string{"id"}, map[string]interface{}{
"id": prop("string", "Task id"),
"content": prop("string", "New text"),
"isCompleted": prop("boolean", "true = done"),
"dueDate": prop("string", "YYYY-MM-DD, YYYY-MM-DDTHH:MM or empty"),
"priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}},
"notes": prop("string", "Description"),
"kanbanStatus": prop("string", "Column id of the task list's board (e.g. todo, in_progress, done)"),
"taskListId": prop("string", "Move into another task list"),
"tags": map[string]interface{}{"type": "array", "items": map[string]string{"type": "string"}},
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
var a struct {
ID string `json:"id"`
taskPatch
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
a.taskPatch.Assignees, a.taskPatch.Recurrence, a.taskPatch.Subtasks = nil, nil, nil
t, err := s.updateTask(userID, a.ID, a.taskPatch)
if err != nil {
return nil, err
}
return toTaskView(t), nil
},
},
{
Name: "complete_task",
Description: "Check a task off (or reopen it with completed=false). Recurring tasks move to their next date.",
Scope: storage.ScopeTasksWrite,
Schema: schema([]string{"id"}, map[string]interface{}{
"id": prop("string", "Task id"),
"completed": prop("boolean", "Default true"),
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
var a struct {
ID string `json:"id"`
Completed *bool `json:"completed"`
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
done := a.Completed == nil || *a.Completed
t, err := s.updateTask(userID, a.ID, taskPatch{IsCompleted: &done})
if err != nil {
return nil, err
}
return toTaskView(t), nil
},
},
{
Name: "list_lists",
Description: "Shopping lists, checklists and other shared lists the user can see, with open/total item counts.",
Scope: storage.ScopeListsRead,
Schema: schema(nil, map[string]interface{}{}),
run: func(s *Server, userID string, _ json.RawMessage) (interface{}, error) {
lists, err := s.store.GetAllListsForUser(userID)
if err != nil {
return nil, err
}
out := make([]listView, 0, len(lists))
for _, l := range lists {
out = append(out, toListView(l))
}
return out, nil
},
},
{
Name: "get_list",
Description: "One list with all its items (id, content, category, quantity, isCompleted).",
Scope: storage.ScopeListsRead,
Schema: schema([]string{"listId"}, map[string]interface{}{"listId": prop("string", "List id from list_lists")}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
var a struct {
ListID string `json:"listId"`
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
l, err := s.store.GetListByID(userID, a.ListID)
if err != nil || l == nil {
return nil, errors.New("Liste nicht gefunden")
}
return map[string]interface{}{"list": toListView(*l), "items": l.Items}, nil
},
},
{
Name: "add_list_item",
Description: "Add an item to a list the user may edit.",
Scope: storage.ScopeListsWrite,
Schema: schema([]string{"listId", "content"}, map[string]interface{}{
"listId": prop("string", "List id"),
"content": prop("string", "Item text"),
"category": prop("string", "Category/aisle, e.g. Obst"),
"quantity": prop("string", "Amount, e.g. 2 or 500 g"),
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
var a struct {
ListID, Content, Category, Quantity string
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
if strings.TrimSpace(a.Content) == "" {
return nil, errors.New("'content' darf nicht leer sein")
}
return s.addListItem(userID, a.ListID, a.Content, a.Category, a.Quantity)
},
},
{
Name: "check_list_item",
Description: "Tick an item off a list (or untick it with completed=false).",
Scope: storage.ScopeListsWrite,
Schema: schema([]string{"listId", "itemId"}, map[string]interface{}{
"listId": prop("string", "List id"),
"itemId": prop("string", "Item id from get_list"),
"completed": prop("boolean", "Default true"),
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
var a struct {
ListID string `json:"listId"`
ItemID string `json:"itemId"`
Completed *bool `json:"completed"`
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
return s.setListItemDone(userID, a.ListID, a.ItemID, a.Completed == nil || *a.Completed)
},
},
{
Name: "update_list_item",
Description: "Change text, category or quantity of a list item; only the given fields change.",
Scope: storage.ScopeListsWrite,
Schema: schema([]string{"listId", "itemId"}, map[string]interface{}{
"listId": prop("string", "List id"),
"itemId": prop("string", "Item id"),
"content": prop("string", "New text"),
"category": prop("string", "New category"),
"quantity": prop("string", "New amount"),
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
var a struct {
ListID string `json:"listId"`
ItemID string `json:"itemId"`
Content *string `json:"content"`
Category *string `json:"category"`
Quantity *string `json:"quantity"`
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
if err := s.store.EnsureListItemsWritable(userID, a.ListID, a.ItemID); err != nil {
return nil, err
}
item, err := s.store.UpdateListItem(a.ItemID, a.Content, a.Category, a.Quantity)
if err != nil {
return nil, err
}
s.wsHub.BroadcastListEvent(a.ListID, "ITEM_UPDATED", item)
return item, nil
},
},
{
Name: "search",
Description: "Full-text search over tasks and list items the user can see (notes are not included for API keys).",
Scope: storage.ScopeTasksRead,
Schema: schema([]string{"query"}, map[string]interface{}{"query": prop("string", "Words to search for")}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
var a struct {
Query string `json:"query"`
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
res, err := s.store.SearchGlobal(userID, a.Query)
if err != nil {
return nil, err
}
tasks := make([]taskView, 0, len(res.Thoughts))
for i := range res.Thoughts {
tasks = append(tasks, toTaskView(&res.Thoughts[i]))
}
return map[string]interface{}{"tasks": tasks, "listItems": res.ListItems}, nil
},
},
{
Name: "capture_to_inbox",
Description: "Drop a thought into the user's inbox for later triage (not a task yet).",
Scope: storage.ScopeTriage,
Schema: schema([]string{"content"}, map[string]interface{}{
"content": prop("string", "The thought"),
"priority": map[string]interface{}{"type": "string", "enum": []string{"low", "normal", "high", "urgent"}},
"dueDate": prop("string", "YYYY-MM-DD, optional"),
}),
run: func(s *Server, userID string, args json.RawMessage) (interface{}, error) {
var a struct {
Content, Priority, DueDate string
}
if err := decodeArgs(args, &a); err != nil {
return nil, err
}
if strings.TrimSpace(a.Content) == "" {
return nil, errors.New("'content' darf nicht leer sein")
}
if a.Priority == "" {
a.Priority = "normal"
}
t, err := s.store.CreateThought(userID, a.Content, a.Priority, "agent", a.DueDate)
if err != nil {
return nil, err
}
s.wsHub.BroadcastToUsers(s.taskAudience(t), "TASK_CREATED", t)
return map[string]interface{}{"id": t.ID, "content": t.Content, "status": fmt.Sprint(t.Status)}, nil
},
},
}
// filterSearchForScopes drops the parts of a search result the key may not read.
func filterSearchForScopes(result interface{}, scopes []string) interface{} {
m, ok := result.(map[string]interface{})
if !ok {
return result
}
has := map[string]bool{}
for _, sc := range scopes {
has[sc] = true
}
if !has[storage.ScopeTasksRead] {
m["tasks"] = []taskView{}
}
if !has[storage.ScopeListsRead] {
m["listItems"] = []domain.ListItem{}
}
return m
}
+194
View File
@@ -0,0 +1,194 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/sandrodach/mindsift/internal/auth"
)
// rpc posts one JSON-RPC request to the MCP endpoint and returns status and decoded body.
func rpc(t *testing.T, router http.Handler, token, body string) (int, map[string]interface{}) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, mcpPath, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
if strings.HasPrefix(token, "mindsift_sec_") {
req.Header.Set("X-API-Key", token)
} else {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var out map[string]interface{}
if rec.Body.Len() > 0 {
_ = json.Unmarshal(rec.Body.Bytes(), &out)
}
return rec.Code, out
}
func toolNames(res map[string]interface{}) []string {
names := []string{}
result, _ := res["result"].(map[string]interface{})
tools, _ := result["tools"].([]interface{})
for _, tl := range tools {
names = append(names, tl.(map[string]interface{})["name"].(string))
}
return names
}
func callResult(t *testing.T, res map[string]interface{}) (map[string]interface{}, bool) {
t.Helper()
result, ok := res["result"].(map[string]interface{})
if !ok {
t.Fatalf("no result in %v", res)
}
isErr, _ := result["isError"].(bool)
return result, isErr
}
// Agent scopes decide which tools a key sees and may call; webhook-only keys get nothing to read.
func TestMCPToolsFollowScopes(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
code, res := rpc(t, router, token, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}`)
if code != http.StatusOK {
t.Fatalf("initialize: %d %v", code, res)
}
if v := res["result"].(map[string]interface{})["protocolVersion"]; v != "2025-03-26" {
t.Fatalf("requested protocol version should be echoed, got %v", v)
}
// Notifications are acknowledged with 202 and no body
code, _ = rpc(t, router, token, `{"jsonrpc":"2.0","method":"notifications/initialized"}`)
if code != http.StatusAccepted {
t.Fatalf("notification should answer 202, got %d", code)
}
// A session sees every tool
_, res = rpc(t, router, token, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`)
if n := len(toolNames(res)); n != len(mcpTools) {
t.Fatalf("session should see all %d tools, got %d", len(mcpTools), n)
}
// Webhook-only key: only capture_to_inbox
webhookKey, _ := store.CreateAPIKey(claims.UserID, "HA", nil)
_, res = rpc(t, router, webhookKey.RawKey, `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`)
if names := toolNames(res); len(names) != 1 || names[0] != "capture_to_inbox" {
t.Fatalf("triage key should only see capture_to_inbox, got %v", names)
}
_, res = rpc(t, router, webhookKey.RawKey, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_tasks","arguments":{}}}`)
if _, isErr := callResult(t, res); !isErr {
t.Fatalf("triage key must not list tasks: %v", res)
}
// Read-only task key: lists tasks, cannot complete them
readKey, _ := store.CreateAPIKey(claims.UserID, "Reader", []string{"tasks:read"})
names := strings.Join(toolNames(func() map[string]interface{} { _, r := rpc(t, router, readKey.RawKey, `{"jsonrpc":"2.0","id":5,"method":"tools/list"}`); return r }()), ",")
if !strings.Contains(names, "list_tasks") || strings.Contains(names, "complete_task") || strings.Contains(names, "list_lists") {
t.Fatalf("tasks:read key sees the wrong tools: %s", names)
}
// search is offered with any read scope
if !strings.Contains(names, "search") {
t.Fatalf("search should be available with tasks:read: %s", names)
}
}
// Full round trip with a tasks:write / lists:write key: create, list, complete; add and tick list items.
func TestMCPTasksAndLists(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
key, _ := store.CreateAPIKey(claims.UserID, "Agent", []string{"tasks:read", "tasks:write", "lists:read", "lists:write"})
_, res := rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_task","arguments":{"content":"Filter wechseln #haus","priority":"high","dueDate":"2026-11-15"}}}`)
result, isErr := callResult(t, res)
if isErr {
t.Fatalf("create_task failed: %v", result)
}
created := result["structuredContent"].(map[string]interface{})
taskID, _ := created["id"].(string)
if taskID == "" || created["dueDate"] != "2026-11-15" {
t.Fatalf("unexpected task: %v", created)
}
th, _ := store.GetThoughtByID(claims.UserID, taskID)
if th == nil || th.Source != "agent" || th.Status != "converted" && th.Status != "kept" {
t.Fatalf("task not stored as task: %+v", th)
}
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_tasks","arguments":{"dueBefore":"2026-11-15"}}}`)
result, _ = callResult(t, res)
items := result["structuredContent"].(map[string]interface{})["items"].([]interface{})
found := false
for _, it := range items {
if it.(map[string]interface{})["id"] == taskID {
found = true
}
}
if !found {
t.Fatalf("created task missing from list_tasks: %v", items)
}
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"complete_task","arguments":{"id":"`+taskID+`"}}}`)
result, isErr = callResult(t, res)
if isErr || result["structuredContent"].(map[string]interface{})["isCompleted"] != true {
t.Fatalf("complete_task: %v", result)
}
// Done tasks drop out of the default listing
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_tasks","arguments":{}}}`)
result, _ = callResult(t, res)
for _, it := range result["structuredContent"].(map[string]interface{})["items"].([]interface{}) {
if it.(map[string]interface{})["id"] == taskID {
t.Fatalf("completed task should not be listed as open")
}
}
// Lists
list, _ := store.CreateList(claims.UserID, "Einkauf", "🛒", "shopping")
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"add_list_item","arguments":{"listId":"`+list.ID+`","content":"Hafermilch","quantity":"2"}}}`)
result, isErr = callResult(t, res)
if isErr {
t.Fatalf("add_list_item: %v", result)
}
itemID := result["structuredContent"].(map[string]interface{})["id"].(string)
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"check_list_item","arguments":{"listId":"`+list.ID+`","itemId":"`+itemID+`"}}}`)
result, isErr = callResult(t, res)
if isErr || result["structuredContent"].(map[string]interface{})["isCompleted"] != true {
t.Fatalf("check_list_item: %v", result)
}
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"get_list","arguments":{"listId":"`+list.ID+`"}}}`)
result, _ = callResult(t, res)
lv := result["structuredContent"].(map[string]interface{})["list"].(map[string]interface{})
if lv["openItems"].(float64) != 0 || lv["items"].(float64) != 1 {
t.Fatalf("get_list counts wrong: %v", lv)
}
// Lists of other people stay invisible
other, _ := store.CreateUser("other@example.com", "hash", "Other", "member")
foreign, _ := store.CreateList(other.ID, "Fremd", "", "shopping")
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"add_list_item","arguments":{"listId":"`+foreign.ID+`","content":"Spam"}}}`)
if _, isErr = callResult(t, res); !isErr {
t.Fatalf("key must not write into foreign lists")
}
// Unknown method and batch requests are JSON-RPC errors
_, res = rpc(t, router, key.RawKey, `{"jsonrpc":"2.0","id":9,"method":"resources/list"}`)
if res["error"] == nil {
t.Fatalf("unknown method should be a JSON-RPC error: %v", res)
}
_, res = rpc(t, router, key.RawKey, `[{"jsonrpc":"2.0","id":10,"method":"ping"}]`)
if res["error"] == nil {
t.Fatalf("batches should be refused: %v", res)
}
// Without a key there is no access at all
req := httptest.NewRequest(http.MethodPost, mcpPath, strings.NewReader(`{"jsonrpc":"2.0","id":11,"method":"ping"}`))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous MCP call should be 401, got %d", rec.Code)
}
}
+12 -6
View File
@@ -111,7 +111,7 @@ func (s *Server) Router() http.Handler {
r.Use(cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key", "Mcp-Session-Id", "MCP-Protocol-Version"},
ExposedHeaders: []string{"Link", "X-Session-Token"},
AllowCredentials: false,
MaxAge: 300,
@@ -279,6 +279,12 @@ func (s *Server) Router() http.Handler {
r.Put("/api/v1/teams/{id}/members/{userId}", s.handleUpdateTeamMemberRole)
r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember)
// MCP endpoint for AI agents (API key with agent scopes or a session); no server stream, so
// GET and DELETE answer 405 instead of the SPA fallback
r.Post(mcpPath, s.handleMCP)
r.Get(mcpPath, handleMCPNoStream)
r.Delete(mcpPath, handleMCPNoStream)
// API Keys Management
r.Get("/api/v1/keys", s.handleGetAPIKeys)
r.Post("/api/v1/keys", s.handleCreateAPIKey)
@@ -509,11 +515,11 @@ func requireDeliveryScope(w http.ResponseWriter, r *http.Request, target string)
return false
}
// apiKeyAllowed: an API key only delivers data into MindSift (POST /api/v1/inbox; /api/v1/webhooks/ingest
// authenticates on its own); what it may deliver is decided by its scopes (requireDeliveryScope).
// Everything else needs a login.
// apiKeyAllowed: an API key delivers data into MindSift (POST /api/v1/inbox; /api/v1/webhooks/ingest
// authenticates on its own) or talks to the MCP endpoint for AI agents; what it may do there is decided
// by its scopes (requireDeliveryScope, mcpToolAllowed). Everything else needs a login.
func apiKeyAllowed(r *http.Request) bool {
return r.Method == http.MethodPost && r.URL.Path == "/api/v1/inbox"
return r.Method == http.MethodPost && (r.URL.Path == "/api/v1/inbox" || r.URL.Path == mcpPath)
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
@@ -538,7 +544,7 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
}
if keyScopes != nil {
if !apiKeyAllowed(r) {
writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)")
writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest) oder den MCP-Endpunkt nutzen (POST /api/v1/mcp)")
return
}
ctx = context.WithValue(ctx, apiKeyScopesKey{}, keyScopes)
+12 -2
View File
@@ -10,13 +10,23 @@ import (
// --- API KEYS & WEBHOOKS ---
// Scopes of an API key: what it may deliver. A key never reads data.
// Scopes of an API key. The webhook scopes deliver data (inbox, /webhooks/ingest); the agent scopes
// let AI agents read and change tasks and lists over the MCP endpoint. Notes are never reachable with
// a key.
const (
ScopeTriage = "webhook:triage" // thoughts into the inbox
ScopeTask = "webhook:task" // tasks directly
ScopeList = "webhook:list" // items into lists
ScopeTasksRead = "tasks:read" // MCP: task lists and tasks
ScopeTasksWrite = "tasks:write" // MCP: create, change and complete tasks
ScopeListsRead = "lists:read" // MCP: lists and their items
ScopeListsWrite = "lists:write" // MCP: add, change and check off items
)
// AllScopes in display order.
var AllScopes = []string{ScopeTriage, ScopeTask, ScopeList, ScopeTasksRead, ScopeTasksWrite, ScopeListsRead, ScopeListsWrite}
// NormalizeScopes keeps the known scopes in a fixed order; none at all means triage only.
func NormalizeScopes(scopes []string) []string {
want := map[string]bool{}
@@ -24,7 +34,7 @@ func NormalizeScopes(scopes []string) []string {
want[strings.ToLower(strings.TrimSpace(sc))] = true
}
out := []string{}
for _, sc := range []string{ScopeTriage, ScopeTask, ScopeList} {
for _, sc := range AllScopes {
if want[sc] {
out = append(out, sc)
}