feat(mcp): MCP server for AI agents, agent scopes on API keys, switches in settings
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m19s

MindSift now speaks MCP (Streamable HTTP, stateless JSON-RPC) at POST /api/v1/mcp so
Claude Code, Codex, Claude Desktop, Cursor and custom agents can read, create and
complete tasks and keep shared lists. API keys get the scopes tasks:read/write and
lists:read/write; tools/list only shows what the key may call and every tool checks
its scope. Task and list write paths are shared helpers (updateTask, createTask,
addListItem, setListItemDone) used by REST and MCP alike.

Settings: scope selection and the SectionPicker (import, restore) use ToggleSwitch
rows instead of checkbox-like buttons; new MCP section with connection snippets for
Claude Code, Codex and JSON configs. Docs, README, help and changelog updated; v0.28.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Fable 5.1 committed 2026-10-31 21:58:44 +01:00
1 parent 5982563579
commit 8faed7a160
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)
}