diff --git a/CHANGELOG.md b/CHANGELOG.md index 68990fd..5ce1d5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 2a3c2b2..83769c0 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -1,6 +1,6 @@ # REST-API -Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer `. **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 `. **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 | diff --git a/Doku/architecture.md b/Doku/architecture.md index 2848d8c..8c25189 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -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_`, 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_`, 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. diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index 0126c41..5c06971 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -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 | |---|---|---|---| diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index 5779e7c..4196daa 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -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) diff --git a/README.md b/README.md index 171401a..61eea4e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.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). diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 361e31c..c5a9b4a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index d1720e6..6377b2d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.27.1", + "version": "0.28.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index d10c821..f666ee9 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -492,8 +492,8 @@ export const HelpView: React.FC = ({ 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 = ({ onBack }) => { 'shortcuts', 'automation', 'curl', + 'mcp', + 'ki', + 'agent', + 'claude', + 'codex', ], content: (

- Unter Kontoeinstellungen → Private Webhooks & Export 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 Kontoeinstellungen → Webhooks, KI-Agenten & Export 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. +

+

+ KI-Agenten: MindSift ist ein MCP-Server (POST /api/v1/mcp). 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.

Mit dem Feld "target" wählst du das Ziel: triage (Standard), task (optional mit taskListId) oder list_item (mit listId). Der Simulator in den Einstellungen zeigt passende Beispiele. @@ -656,7 +664,7 @@ export const HelpView: React.FC = ({ onBack }) => { Eigenes Theme: Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als .css-Datei hochladen oder einfügen und „Speichern & aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.

  • - Server-Backups: Hat die Administration automatische Backups eingeschaltet, findest du unter Kontoeinstellungen → Private Webhooks & Export die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben. + Server-Backups: Hat die Administration automatische Backups eingeschaltet, findest du unter Kontoeinstellungen → Webhooks, KI-Agenten & Export die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben.
  • Ohne Internet: 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. diff --git a/frontend/src/features/settings/ApiKeysPanel.tsx b/frontend/src/features/settings/ApiKeysPanel.tsx index 94f02d2..8a4a2ae 100644 --- a/frontend/src/features/settings/ApiKeysPanel.tsx +++ b/frontend/src/features/settings/ApiKeysPanel.tsx @@ -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 = ({ onIngested }) => { const [keys, setKeys] = useState([]); const [newKeyName, setNewKeyName] = useState(''); const [newKeyScopes, setNewKeyScopes] = useState(['webhook:triage']); const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); const [copiedKey, setCopiedKey] = useState(false); + const [mcpClient, setMcpClient] = useState('claude'); // Live Webhook Simulator State const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!'); @@ -140,6 +150,26 @@ export const ApiKeysPanel: React.FC = ({ 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 (
    {/* API keys */} @@ -149,10 +179,10 @@ export const ApiKeysPanel: React.FC = ({ onIngested }) => {
    -

    Webhooks & Ingestion API-Schlüssel

    +

    API-Schlüssel für Webhooks und KI-Agenten

    - 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.

  • @@ -171,27 +201,32 @@ export const ApiKeysPanel: React.FC = ({ onIngested }) => { API-Key erzeugen -
    - Darf einliefern: - {SCOPES.map((sc) => { - const on = newKeyScopes.includes(sc.id); - return ( - - ); - })} +
    + {( + [ + ['webhook', 'Einliefern (Webhooks)', 'POST /api/v1/webhooks/ingest'], + ['agent', 'KI-Agenten (MCP)', 'POST /api/v1/mcp'], + ] as const + ).map(([group, heading, path]) => ( +
    +

    + {heading} + {path} +

    + {SCOPES.filter((sc) => sc.group === group).map((sc) => { + const on = newKeyScopes.includes(sc.id); + return ( +
    +
    + {sc.label} + {sc.hint} +
    + toggleScope(sc.id)} title={sc.label} /> +
    + ); + })} +
    + ))}
    @@ -222,7 +257,12 @@ export const ApiKeysPanel: React.FC = ({ onIngested }) => { .split(',') .filter(Boolean) .map((sc) => ( - + {scopeLabel(sc)} ))} @@ -245,6 +285,56 @@ export const ApiKeysPanel: React.FC = ({ onIngested }) => {
    + {/* AI agents (MCP) */} +
    +
    +
    + +
    +
    +

    KI-Agenten anbinden (MCP)

    +

    + 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. +

    +
    +
    + +
    + {( + [ + ['claude', 'Claude Code'], + ['codex', 'OpenAI Codex'], + ['json', 'Claude Desktop / Cursor'], + ] as const + ).map(([value, label]) => ( + + ))} +
    +
    {mcpSnippet}
    +

    + Werkzeuge: list_task_lists, list_tasks,{' '} + complete_task, create_task,{' '} + update_task, list_lists,{' '} + get_list, add_list_item,{' '} + check_list_item, search,{' '} + capture_to_inbox. 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. +

    +
    + {/* Simulator */}
    diff --git a/frontend/src/features/settings/SectionPicker.tsx b/frontend/src/features/settings/SectionPicker.tsx index ddd7dee..0c7185d 100644 --- a/frontend/src/features/settings/SectionPicker.tsx +++ b/frontend/src/features/settings/SectionPicker.tsx @@ -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 = ({ value, onChange, label = 'Bereiche' }) => ( -
    - {label}: - {DATA_SECTIONS.map((sec) => { - const on = value.includes(sec.id); - return ( - - ); - })} +
    +

    {label}

    +
    + {DATA_SECTIONS.map((sec) => { + const on = value.includes(sec.id); + return ( +
    + {sec.label} + { + const next = on ? value.filter((v) => v !== sec.id) : [...value, sec.id]; + if (next.length > 0) onChange(next); + }} + /> +
    + ); + })} +
    ); diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index cfb86db..bf88213 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -582,7 +582,7 @@ export const UserSettingsView: React.FC = ({

    Kontoeinstellungen

    - Persönliches Profil, Design-Themes, Aufgaben-Alarme, Private Webhooks & Daten-Backup + Persönliches Profil, Design-Themes, Aufgaben-Alarme, Webhooks, KI-Agenten & Daten-Backup

    @@ -651,8 +651,8 @@ export const UserSettingsView: React.FC = ({ >
    -

    Private Webhooks & Export

    -

    API-Keys, Curl & JSON-Backup

    +

    Webhooks, KI-Agenten & Export

    +

    API-Keys, MCP, Curl & JSON-Backup

    @@ -1855,11 +1855,11 @@ export const UserSettingsView: React.FC = ({

    - Private Webhooks (Eingehend) & Daten-Export + Webhooks, KI-Agenten & Daten-Export

    - 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.

    diff --git a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx index 84bbf64..ca2daca 100644 --- a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx +++ b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx @@ -24,18 +24,27 @@ describe('ApiKeysPanel', () => { it('shows the scopes of existing keys as badges', async () => { render(); 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(); - 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(); + 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(); + }); }); diff --git a/frontend/src/features/settings/__tests__/SectionPicker.test.tsx b/frontend/src/features/settings/__tests__/SectionPicker.test.tsx new file mode 100644 index 0000000..ad14a7b --- /dev/null +++ b/frontend/src/features/settings/__tests__/SectionPicker.test.tsx @@ -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(); + 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']); + }); +}); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index c448040..c60ddcf 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; diff --git a/internal/api/handlers_integrations.go b/internal/api/handlers_integrations.go index 31081a4..983be2b 100644 --- a/internal/api/handlers_integrations.go +++ b/internal/api/handlers_integrations.go @@ -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}) } diff --git a/internal/api/handlers_lists.go b/internal/api/handlers_lists.go index 6f61837..bbb8207 100644 --- a/internal/api/handlers_lists.go +++ b/internal/api/handlers_lists.go @@ -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) } diff --git a/internal/api/handlers_thoughts.go b/internal/api/handlers_thoughts.go index 07bbf1c..ca67d51 100644 --- a/internal/api/handlers_thoughts.go +++ b/internal/api/handlers_thoughts.go @@ -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) } diff --git a/internal/api/mcp.go b/internal/api/mcp.go new file mode 100644 index 0000000..7ad9a02 --- /dev/null +++ b/internal/api/mcp.go @@ -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 +} diff --git a/internal/api/mcp_test.go b/internal/api/mcp_test.go new file mode 100644 index 0000000..f4714aa --- /dev/null +++ b/internal/api/mcp_test.go @@ -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) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index a5d609f..1fbc008 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) diff --git a/internal/storage/apikeys.go b/internal/storage/apikeys.go index 435bb59..d1454d5 100644 --- a/internal/storage/apikeys.go +++ b/internal/storage/apikeys.go @@ -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) }