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
+146
View File
@@ -13,6 +13,7 @@ package ics
import (
"fmt"
"strconv"
"strings"
"time"
)
@@ -221,3 +222,148 @@ func Filename(name string) string {
}
return fmt.Sprintf("%s.ics", safe)
}
// --- reading ----------------------------------------------------------------------
// Parsed is what a client's VTODO says, reduced to the fields verdande stores.
type Parsed struct {
UID string
Summary string
Description string
DueDate string
DueDatetime *time.Time
Priority int
Recurrence string
Completed bool
}
// ParseVTODO reads a calendar object sent by a CalDAV client.
//
// Deliberately forgiving: this is the input side of a protocol spoken by Apple
// Reminders, Thunderbird and a long tail of phone apps, each with its own habits
// about folding, parameters and which properties it bothers to send. Anything not
// understood is ignored rather than refused, because refusing a PUT makes a client
// show a sync error and stop — losing the edit the person just made.
func ParseVTODO(body string) (Parsed, error) {
var p Parsed
inTodo := false
for _, line := range unfold(body) {
name, params, value := splitLine(line)
switch strings.ToUpper(name) {
case "BEGIN":
if strings.EqualFold(value, "VTODO") {
inTodo = true
}
case "END":
if strings.EqualFold(value, "VTODO") {
inTodo = false
}
}
if !inTodo {
continue
}
switch strings.ToUpper(name) {
case "UID":
p.UID = value
case "SUMMARY":
p.Summary = unescape(value)
case "DESCRIPTION":
p.Description = unescape(value)
case "RRULE":
p.Recurrence = value
case "STATUS":
p.Completed = strings.EqualFold(value, "COMPLETED")
case "PERCENT-COMPLETE":
if value == "100" {
p.Completed = true
}
case "COMPLETED":
p.Completed = true
case "PRIORITY":
// RFC 5545 runs 1..9 with 0 meaning undefined; verdande runs 1..4 with
// 4 meaning none. The bands here are the inverse of what the writer does.
switch n, _ := strconv.Atoi(value); {
case n == 0:
p.Priority = 4
case n <= 2:
p.Priority = 1
case n <= 5:
p.Priority = 2
case n <= 7:
p.Priority = 3
default:
p.Priority = 4
}
case "DUE":
if strings.Contains(strings.ToUpper(params), "VALUE=DATE") || len(value) == 8 {
if t, err := time.Parse("20060102", value); err == nil {
p.DueDate = t.Format("2006-01-02")
}
continue
}
for _, layout := range []string{"20060102T150405Z", "20060102T150405"} {
if t, err := time.Parse(layout, value); err == nil {
utc := t.UTC()
p.DueDate = utc.Format("2006-01-02")
p.DueDatetime = &utc
break
}
}
}
}
if p.Priority == 0 {
p.Priority = 4
}
if p.Summary == "" && p.UID == "" {
return p, fmt.Errorf("ics: no VTODO found")
}
return p, nil
}
// unfold rejoins continuation lines, which the spec folds at 75 octets by beginning
// the next line with a space or tab.
func unfold(body string) []string {
raw := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n")
var out []string
for _, line := range raw {
if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') && len(out) > 0 {
out[len(out)-1] += line[1:]
continue
}
out = append(out, line)
}
return out
}
// splitLine separates NAME;PARAMS:VALUE. The parameters can themselves contain a
// colon inside quotes, so the split is on the first colon outside quotes.
func splitLine(line string) (name, params, value string) {
inQuotes := false
for i := 0; i < len(line); i++ {
switch line[i] {
case '"':
inQuotes = !inQuotes
case ':':
if inQuotes {
continue
}
left := line[:i]
value = line[i+1:]
if semi := strings.Index(left, ";"); semi >= 0 {
return left[:semi], left[semi+1:], value
}
return left, "", value
}
}
return line, "", ""
}
func unescape(s string) string {
r := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`)
return r.Replace(s)
}