feat(phase5): MCP server, CalDAV, mail-to-task, Gmail settings and an AI layer

The MCP server speaks JSON-RPC over Streamable HTTP with the six tools
the brief names, authenticated by a personal API token — so a connector
reaches exactly what its owner reaches and there is no second identity
to reason about. A failed tool call returns a tool error, not a protocol
error: a model can recover from the first and can do nothing with the
second.

CalDAV serves VTODO, two-way, so Apple Reminders and Thunderbird can
read, create and complete tasks. Clients authenticate with Basic using
an API token as the password, never the account password — a client
stores what it is given, and what it is given should not be able to sign
in to the app. Completed tasks stay in the collection: a client that
stops seeing one treats it as deleted and drops its own record of having
done it.

The AI layer is one interface with adapters for Anthropic, OpenAI,
Google and any OpenAI-compatible endpoint. That last one is why the
abstraction exists: an integration that only spoke to a hosted API would
be useless to somebody running their own models. Every feature degrades
to "off" when nothing is configured, and the stored API key is never
returned to the settings page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 07:53:53 +02:00
co-authored by Claude Opus 5
parent 4058ff0533
commit 6b19ed062d
12 changed files with 2892 additions and 0 deletions
+32
View File
@@ -18,6 +18,7 @@ import (
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/mail"
"github.com/kristianwind/verdande/internal/mcp"
"github.com/kristianwind/verdande/internal/realtime"
"github.com/kristianwind/verdande/internal/store"
)
@@ -29,6 +30,7 @@ type Server struct {
web fs.FS
mail *mail.Sender
hub *realtime.Hub
mcp *mcp.Server
// Password guessing is the attack this application is actually exposed to, so
// the endpoints that check a secret are limited separately and more tightly
@@ -54,6 +56,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
resetLimiter: newLimiter(5, time.Hour),
}
s.csp = contentSecurityPolicy(scriptHashes(web))
s.mcp = s.buildMCP()
r := chi.NewRouter()
r.Use(middleware.RequestID)
@@ -71,6 +74,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// client cannot log in, so the token in the path is the whole credential.
r.Get("/ics/{token}", s.handleICSFeed)
// CalDAV, outside /api/v1 because clients do well-known-path discovery
// against the root and authenticate with Basic rather than a session.
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
// Inbound mail. Delivered by the mail server rather than by a browser, and
// authenticated by the token in the recipient address.
r.Post("/inbound/mail", s.handleInboundMail)
r.Route("/caldav", s.caldavRoutes)
r.Route("/api/v1", func(r chi.Router) {
r.Use(s.requireCSRF)
@@ -119,6 +131,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/ws", s.handleWebSocket)
// The MCP endpoint. Reachable with a personal API token, so a connector
// sees exactly what its owner sees.
r.Post("/mcp", s.handleMCP)
r.Get("/today", s.handleToday)
r.Get("/filters/preview", s.handlePreviewFilter)
@@ -155,6 +171,22 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
})
r.Post("/feed/rotate", s.handleRotateFeed)
r.Get("/mail-address", s.handleGetMailAddress)
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
r.Route("/ai", func(r chi.Router) {
r.Get("/settings", s.handleGetAISettings)
r.Put("/settings", s.handleSetAISettings)
r.Post("/summary", s.handleAISummary)
r.Post("/tasks/{taskID}/split", s.handleAISplit)
})
r.Route("/gmail", func(r chi.Router) {
r.Get("/", s.handleGetGmail)
r.Put("/", s.handleSetGmail)
r.Delete("/", s.handleDisconnectGmail)
})
r.Route("/import", func(r chi.Router) {
r.Post("/todoist", s.handleImportTodoist)
r.Post("/csv", s.handleImportCSV)