Files
MindSift/internal/api/handlers_integrations.go
T
sandrodachandClaude Fable 5.1 8faed7a160
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m19s
feat(mcp): MCP server for AI agents, agent scopes on API keys, switches in settings
MindSift now speaks MCP (Streamable HTTP, stateless JSON-RPC) at POST /api/v1/mcp so
Claude Code, Codex, Claude Desktop, Cursor and custom agents can read, create and
complete tasks and keep shared lists. API keys get the scopes tasks:read/write and
lists:read/write; tools/list only shows what the key may call and every tool checks
its scope. Task and list write paths are shared helpers (updateTask, createTask,
addListItem, setListItemDone) used by REST and MCP alike.

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 21:58:44 +01:00

260 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// --- API KEYS & WEBHOOK INGESTION ---
func (s *Server) handleGetAPIKeys(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
keys, err := s.store.GetAPIKeys(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, keys)
}
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
key, err := s.store.CreateAPIKey(userID, req.Name, req.Scopes)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, key)
}
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
keyID := chi.URLParam(r, "id")
if err := s.store.DeleteAPIKey(userID, keyID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// handleWebhookIngest accepts Smarthome / External automation webhooks via ?token=... or X-API-Key or Bearer token
func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
// In the URL only API keys are taken (devices that cannot set headers); a session token there would
// end up in proxy logs as a full login (FLAW-35)
token := r.URL.Query().Get("token")
if token != "" && !strings.HasPrefix(token, "mindsift_sec_") {
writeError(w, http.StatusUnauthorized, "In der URL sind nur API-Keys erlaubt; Sitzungs-Tokens gehören in den Authorization-Header")
return
}
if token == "" {
token = r.Header.Get("X-API-Key")
}
if token == "" && strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
// Same checks as every other request (revoked/outdated sessions, inactive accounts)
user, keyScopes := s.authenticateToken(token)
if user == nil {
writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key")
return
}
if keyScopes != nil {
r = r.WithContext(context.WithValue(r.Context(), apiKeyScopesKey{}, keyScopes))
}
var req struct {
Title string `json:"title"`
Content string `json:"content"`
Text string `json:"text"`
Message string `json:"message"`
Priority string `json:"priority"`
Source string `json:"source"`
Tags []string `json:"tags"`
DueDate string `json:"dueDate"`
DueDate2 string `json:"due_date"`
// Where it goes: "triage" (default, inbox), "task" or "list_item"; "type" is accepted as well
Target string `json:"target"`
Type string `json:"type"`
TaskListID string `json:"taskListId"`
ListID string `json:"listId"`
Category string `json:"category"`
Quantity string `json:"quantity"`
}
bodyBytes, _ := io.ReadAll(r.Body)
if len(bodyBytes) > 0 {
if jsonErr := json.Unmarshal(bodyBytes, &req); jsonErr != nil {
plain := strings.TrimSpace(string(bodyBytes))
if plain != "" {
req.Content = plain
}
}
}
if req.Content == "" {
if req.Text != "" {
req.Content = req.Text
} else if req.Message != "" {
req.Content = req.Message
}
}
dueDate := req.DueDate
if dueDate == "" {
dueDate = req.DueDate2
}
bodyText := strings.TrimSpace(req.Title)
if req.Content != "" {
if bodyText != "" {
bodyText = bodyText + " — " + strings.TrimSpace(req.Content)
} else {
bodyText = strings.TrimSpace(req.Content)
}
}
if bodyText == "" {
writeError(w, http.StatusBadRequest, "Feld 'title', 'content', 'text' oder 'message' ist erforderlich")
return
}
for _, tag := range req.Tags {
cleanTag := strings.TrimPrefix(strings.TrimSpace(tag), "#")
if cleanTag != "" && !strings.Contains(strings.ToLower(bodyText), "#"+strings.ToLower(cleanTag)) {
bodyText += fmt.Sprintf(" #%s", cleanTag)
}
}
target := strings.ToLower(strings.TrimSpace(req.Target))
if target == "" {
target = strings.ToLower(strings.TrimSpace(req.Type))
}
switch target {
case "", "triage", "inbox":
target = "triage"
case "task", "list_item":
default:
writeError(w, http.StatusBadRequest, "Unbekanntes Ziel – erlaubt: triage, task, list_item")
return
}
if !requireDeliveryScope(w, r, target) {
return
}
source := strings.ToLower(strings.TrimSpace(req.Source))
if source == "" {
source = "webhook"
}
if target == "list_item" {
s.ingestListItem(w, user.ID, req.ListID, bodyText, req.Category, req.Quantity)
return
}
if target == "task" {
s.ingestTask(w, user.ID, bodyText, req.Priority, source, dueDate, req.TaskListID)
return
}
if req.Priority == "" {
req.Priority = "high"
}
thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, source, dueDate)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastToUsers(s.taskAudience(thought), "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, map[string]interface{}{
"status": "ingested",
"thought": thought,
})
}
// 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) {
return nil, storage.ErrTaskListReadOnly
}
thought, err := s.store.CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, "todo", notes)
if err != nil {
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
}
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) {
item, err := s.addListItem(userID, listID, content, category, quantity)
if err != nil {
status := http.StatusNotFound
switch {
case errors.Is(err, storage.ErrListReadOnly):
status = http.StatusForbidden
case errors.Is(err, errListIDRequired):
status = http.StatusBadRequest
}
writeError(w, status, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "item": item})
}