Merge dev into main (v0.28.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 28s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 28s
This commit is contained in:
commit
82e777c877
22 files changed
+1243
-147
No files matched your search
+12
-1
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
[](CHANGELOG.md)
|
||||
[](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).
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.27.1",
|
||||
"version": "0.28.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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 & 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.
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user