Release / Build and publish image (push) Failing after 12m56s
The panel's watcher reported four 5xx today — twice from one address at 11:49, twice from another at 10:45 — and there is no way to find out what any of them were. verdande logs the real cause of every 500, but on a Rune the log belongs to the container and the container is replaced on every restart. So an operator is told that something broke and given nothing to act on, which is the worst of both: alarming and useless. Every 500 now writes a row: what the handler was attempting in its own words, the error it hit, the method and path, who was asking, and the request id that ties it back to the log line for whoever still has it. The "what" is the part a status code cannot give — "list projects" and "move task" are the difference between a fault you can find and one you can only count. Written best-effort and never checked. This is the path that is already reporting a failure, and a diagnostic that can itself fail the request would turn one broken screen into two. Threading the request through `internal()` and `storeError()` touched 108 call sites across 19 files, which is the price of not having had it there from the start. It is mechanical and the compiler checked all of it. Kept for thirty days by the nightly sweep: long enough to answer "what happened last week", short enough that a fault loop cannot fill the volume the tasks live on. Read at Indstillinger → Fejl, administrators only — it carries paths, account names and the server's own error strings. The empty state says "Ingen fejl registreret. Det er den rigtige tilstand", because a blank panel should not read as a broken one. The test drops a table and asks for the endpoint that reads it: the same code path a disk error takes, and the row that comes back has to name the operation, the account and the request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
499 lines
17 KiB
Go
499 lines
17 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kristianwind/verdande/internal/auth"
|
|
"github.com/kristianwind/verdande/internal/mcp"
|
|
"github.com/kristianwind/verdande/internal/quickadd"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
// buildMCP registers the tools verdande exposes to Claude.
|
|
//
|
|
// The descriptions are written for a model to read. That means saying *when* to
|
|
// reach for a tool, not only what it does — a model choosing between
|
|
// `search_tasks` and `list_projects` is answering "which of these gets me closer",
|
|
// and a description that only names the endpoint gives it nothing to decide with.
|
|
func (s *Server) buildMCP() *mcp.Server {
|
|
m := mcp.NewServer("verdande", "1")
|
|
|
|
m.Register(mcp.Tool{
|
|
Name: "list_projects",
|
|
Description: "List the user's projects, including which are shared and how many people " +
|
|
"can see them. Use this first when the user names a project, to turn that name into an id.",
|
|
InputSchema: mcp.Schema(map[string]any{
|
|
"include_archived": mcp.Bool("Include archived projects. Defaults to false."),
|
|
}),
|
|
}, s.mcpListProjects)
|
|
|
|
m.Register(mcp.Tool{
|
|
Name: "search_tasks",
|
|
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
|
|
"This is the tool for any question about what the user has to do — " +
|
|
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
|
|
"The query accepts the same filter language as the app: \"today & p1\", " +
|
|
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
|
|
InputSchema: mcp.Schema(map[string]any{
|
|
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
|
|
"text": mcp.Str("Free text to search task titles and descriptions for."),
|
|
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
|
|
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
|
|
}),
|
|
}, s.mcpSearchTasks)
|
|
|
|
m.Register(mcp.Tool{
|
|
Name: "create_task",
|
|
Description: "Create a task. Prefer passing the whole thing as natural language in " +
|
|
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
|
|
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
|
|
"and English. Use the explicit fields only when the user was explicit.",
|
|
InputSchema: mcp.Schema(map[string]any{
|
|
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
|
"content": mcp.Str("The task title, if not using `text`."),
|
|
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
|
|
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
|
|
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
|
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
|
"description": mcp.Str("Longer notes on the task."),
|
|
"labels": mcp.StrArray("Label names."),
|
|
}),
|
|
}, s.mcpCreateTask)
|
|
|
|
m.Register(mcp.Tool{
|
|
Name: "update_task",
|
|
Description: "Change an existing task: its title, description, priority, due date or " +
|
|
"project. Only the fields given are changed. To move a due date, pass due_date; " +
|
|
"to clear it, pass an empty string.",
|
|
InputSchema: mcp.Schema(map[string]any{
|
|
"task_id": mcp.Str("The task to change."),
|
|
"content": mcp.Str("A new title."),
|
|
"description": mcp.Str("New notes."),
|
|
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
|
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
|
|
"project_id": mcp.Str("Move the task to another project."),
|
|
"labels": mcp.StrArray("Replace the task's labels."),
|
|
}, "task_id"),
|
|
}, s.mcpUpdateTask)
|
|
|
|
m.Register(mcp.Tool{
|
|
Name: "complete_task",
|
|
Description: "Mark a task done. A repeating task advances to its next occurrence " +
|
|
"instead of closing, and the result says so.",
|
|
InputSchema: mcp.Schema(map[string]any{
|
|
"task_id": mcp.Str("The task to complete."),
|
|
}, "task_id"),
|
|
}, s.mcpCompleteTask)
|
|
|
|
m.Register(mcp.Tool{
|
|
Name: "add_comment",
|
|
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
|
|
InputSchema: mcp.Schema(map[string]any{
|
|
"task_id": mcp.Str("The task to comment on."),
|
|
"body": mcp.Str("What to say."),
|
|
}, "task_id", "body"),
|
|
}, s.mcpAddComment)
|
|
|
|
return m
|
|
}
|
|
|
|
// handleMCP is the Streamable HTTP transport: one POST carries one JSON-RPC
|
|
// message and the response comes back in the body.
|
|
//
|
|
// Authentication is the ordinary API-token path, so a connector reaches exactly
|
|
// what its owner reaches. That is why there is no separate scope model here —
|
|
// a token is a person, and a person's permissions are already decided.
|
|
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, CodeBadRequest, "could not read the request")
|
|
return
|
|
}
|
|
|
|
response := s.mcp.Handle(r.Context(), user.ID, body)
|
|
if response == nil {
|
|
// A notification. The spec says answer with nothing.
|
|
w.WriteHeader(http.StatusAccepted)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// handleMCPWithKey is the same endpoint for a client that can only be given a
|
|
// URL: `POST /mcp?key=vrd_…`.
|
|
//
|
|
// Claude's custom-connector dialog asks for a name, an address and — under
|
|
// advanced settings — an OAuth client id and secret. There is no field for a
|
|
// bearer token, so /api/v1/mcp cannot be configured from it however correct the
|
|
// address is. The calendar feed already carries its credential in the URL for
|
|
// the same reason, and the request logger records the path without the query, so
|
|
// the key does not end up in the log.
|
|
//
|
|
// The key is the *only* thing accepted here. Reading a session cookie instead
|
|
// would make this a state-changing POST outside the CSRF check — which is to say
|
|
// a cross-site request that acts as whoever is signed in.
|
|
func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
|
|
key := r.URL.Query().Get("key")
|
|
if key == "" || !auth.IsAPIToken(key) {
|
|
// 401 rather than 404: unlike a task, the existence of this endpoint is
|
|
// not a secret — it is written in the documentation.
|
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
|
|
"add ?key=<api token> to the URL")
|
|
return
|
|
}
|
|
|
|
user, err := s.db.UserByAPIToken(r.Context(), key)
|
|
if err != nil {
|
|
if !errors.Is(err, store.ErrNotFound) {
|
|
s.internal(w, r, "mcp key", err)
|
|
return
|
|
}
|
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
|
|
return
|
|
}
|
|
|
|
s.handleMCP(w, r.WithContext(withUser(r.Context(), user)))
|
|
}
|
|
|
|
// --- tool implementations ------------------------------------------------------------
|
|
|
|
func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
|
var params struct {
|
|
IncludeArchived bool `json:"include_archived"`
|
|
}
|
|
_ = json.Unmarshal(args, ¶ms)
|
|
|
|
projects, err := s.db.ListProjects(ctx, userID, params.IncludeArchived)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]map[string]any, 0, len(projects))
|
|
for _, p := range projects {
|
|
out = append(out, map[string]any{
|
|
"id": p.ID, "name": p.Name, "is_inbox": p.IsInbox,
|
|
"shared": p.MemberCount > 1, "role": string(p.Role), "view": p.ViewMode,
|
|
})
|
|
}
|
|
return map[string]any{"projects": out}, nil
|
|
}
|
|
|
|
func (s *Server) mcpSearchTasks(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
|
var params struct {
|
|
Query string `json:"query"`
|
|
Text string `json:"text"`
|
|
Limit int `json:"limit"`
|
|
IncludeCompleted bool `json:"include_completed"`
|
|
}
|
|
_ = json.Unmarshal(args, ¶ms)
|
|
|
|
user, err := s.db.UserByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
filter := store.TaskFilter{
|
|
Search: params.Text,
|
|
IncludeCompleted: params.IncludeCompleted,
|
|
Limit: clamp(params.Limit, 50, 200),
|
|
}
|
|
if params.Query != "" {
|
|
compiled, err := parseFilter(params.Query, user)
|
|
if err != nil {
|
|
return nil, mcp.ArgError("that filter could not be read: %v. "+
|
|
"Valid examples: \"today\", \"overdue\", \"today & p1\", \"#Firma & @regnskab\", \"7 days\".", err)
|
|
}
|
|
filter.FilterSQL, filter.FilterArgs = compiled.SQL, compiled.Args
|
|
}
|
|
|
|
tasks, err := s.db.ListTasks(ctx, userID, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
projects, _ := s.db.ListProjects(ctx, userID, true)
|
|
names := map[string]string{}
|
|
for _, p := range projects {
|
|
names[p.ID] = p.Name
|
|
}
|
|
|
|
out := make([]map[string]any, 0, len(tasks))
|
|
for _, t := range tasks {
|
|
entry := map[string]any{
|
|
"id": t.ID, "content": t.Content, "priority": t.Priority,
|
|
"project": names[t.ProjectID], "project_id": t.ProjectID,
|
|
"completed": t.Completed(),
|
|
}
|
|
if t.DueDate != "" {
|
|
entry["due_date"] = t.DueDate
|
|
}
|
|
if t.Description != "" {
|
|
entry["description"] = t.Description
|
|
}
|
|
if len(t.Labels) > 0 {
|
|
entry["labels"] = t.Labels
|
|
}
|
|
if t.RecurrenceRule != "" {
|
|
entry["repeats"] = t.RecurrenceRule
|
|
}
|
|
out = append(out, entry)
|
|
}
|
|
return map[string]any{"tasks": out, "count": len(out)}, nil
|
|
}
|
|
|
|
func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
|
var params struct {
|
|
Text string `json:"text"`
|
|
Content string `json:"content"`
|
|
ProjectID string `json:"project_id"`
|
|
ParentID string `json:"parent_id"`
|
|
DueDate string `json:"due_date"`
|
|
Priority int `json:"priority"`
|
|
Description string `json:"description"`
|
|
Labels []string `json:"labels"`
|
|
}
|
|
_ = json.Unmarshal(args, ¶ms)
|
|
|
|
user, err := s.db.UserByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
task := &store.Task{CreatedBy: userID, Priority: 4}
|
|
labels := params.Labels
|
|
|
|
// Reported below rather than swallowed. "#Dekoration" used to vanish from the
|
|
// title while the task went to the Inbox, with nothing anywhere saying so —
|
|
// invisible in a chat, where nobody sees the row that was written.
|
|
unknownProject := ""
|
|
|
|
if text := strings.TrimSpace(params.Text); text != "" {
|
|
parsed := quickadd.Parse(text, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
|
task.Content = parsed.Content
|
|
task.Priority = parsed.Priority
|
|
task.DueDate = parsed.DueDate
|
|
task.RecurrenceRule = parsed.Recurrence
|
|
if len(labels) == 0 {
|
|
labels = parsed.Labels
|
|
}
|
|
if parsed.Project != "" && params.ProjectID == "" {
|
|
if id, err := s.db.ProjectByName(ctx, userID, parsed.Project); err == nil {
|
|
task.ProjectID = id
|
|
} else {
|
|
unknownProject = parsed.Project
|
|
}
|
|
}
|
|
}
|
|
|
|
if params.Content != "" {
|
|
task.Content = params.Content
|
|
}
|
|
if strings.TrimSpace(task.Content) == "" {
|
|
return nil, mcp.ArgError("there is no task text — pass either `text` or `content`")
|
|
}
|
|
if params.Description != "" {
|
|
task.Description = params.Description
|
|
}
|
|
if params.Priority >= 1 && params.Priority <= 4 {
|
|
task.Priority = params.Priority
|
|
}
|
|
if params.DueDate != "" {
|
|
if _, err := time.Parse("2006-01-02", params.DueDate); err != nil {
|
|
return nil, mcp.ArgError("due_date must look like 2026-03-15, got %q", params.DueDate)
|
|
}
|
|
task.DueDate = params.DueDate
|
|
}
|
|
if params.ProjectID != "" {
|
|
task.ProjectID = params.ProjectID
|
|
}
|
|
|
|
// A sub-task belongs to its parent's project, whatever else was asked for:
|
|
// the two cannot disagree, and the parent is the more specific instruction.
|
|
// Without this the tools could not build a tree at all — an import of a
|
|
// parent with seven children arrived as eight unrelated tasks.
|
|
if params.ParentID != "" {
|
|
parent, err := s.db.GetTask(ctx, params.ParentID, userID)
|
|
if err != nil {
|
|
return nil, mcp.ArgError("no task with id %q to hang this under", params.ParentID)
|
|
}
|
|
if parent.ParentID != "" {
|
|
return nil, mcp.ArgError("sub-tasks are one level deep; %q is already a sub-task", params.ParentID)
|
|
}
|
|
task.ParentID = parent.ID
|
|
task.ProjectID = parent.ProjectID
|
|
}
|
|
|
|
if task.ProjectID == "" {
|
|
if task.ProjectID, err = s.db.InboxID(ctx, userID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if _, err := store.RequireProjectRole(ctx, s.db, task.ProjectID, userID, store.RoleEditor); err != nil {
|
|
return nil, mcp.ArgError("no project with id %q that you can write to", task.ProjectID)
|
|
}
|
|
if err := s.db.CreateTask(ctx, task, labels); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.hub.Publish(task.ProjectID, "task.created", toTaskJSON(*task))
|
|
|
|
out := map[string]any{
|
|
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
|
"due_date": task.DueDate, "project_id": task.ProjectID,
|
|
}
|
|
if task.ParentID != "" {
|
|
out["parent_id"] = task.ParentID
|
|
}
|
|
if unknownProject != "" {
|
|
// Phrased for a model to relay: it says what happened and what to do,
|
|
// so the person is told rather than left with a task in the wrong place.
|
|
out["note"] = fmt.Sprintf(
|
|
"There is no project called %q, so this went to the Inbox. "+
|
|
"Create the project and move it, or say which project you meant.",
|
|
unknownProject)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
|
var params struct {
|
|
TaskID string `json:"task_id"`
|
|
Content *string `json:"content"`
|
|
Description *string `json:"description"`
|
|
Priority *int `json:"priority"`
|
|
DueDate *string `json:"due_date"`
|
|
ProjectID *string `json:"project_id"`
|
|
Labels *[]string `json:"labels"`
|
|
}
|
|
_ = json.Unmarshal(args, ¶ms)
|
|
|
|
if params.TaskID == "" {
|
|
return nil, mcp.ArgError("task_id is required — find one with search_tasks first")
|
|
}
|
|
role, err := store.TaskRole(ctx, s.db, params.TaskID, userID)
|
|
if err != nil || !role.CanEdit() {
|
|
return nil, mcp.ArgError("no task with id %q that you can change", params.TaskID)
|
|
}
|
|
|
|
update := store.TaskUpdate{
|
|
Content: params.Content, Description: params.Description,
|
|
Priority: params.Priority, SectionID: nil,
|
|
}
|
|
if params.Priority != nil && (*params.Priority < 1 || *params.Priority > 4) {
|
|
return nil, mcp.ArgError("priority must be 1, 2, 3 or 4")
|
|
}
|
|
if params.DueDate != nil {
|
|
update.SetDue = true
|
|
if *params.DueDate != "" {
|
|
if _, err := time.Parse("2006-01-02", *params.DueDate); err != nil {
|
|
return nil, mcp.ArgError("due_date must look like 2026-03-15, or be empty to clear it")
|
|
}
|
|
update.DueDate = *params.DueDate
|
|
}
|
|
}
|
|
if params.Labels != nil {
|
|
update.SetLabels, update.Labels = true, *params.Labels
|
|
}
|
|
// Moved before the field update, and through the same helper the REST handler
|
|
// uses. This used to check the caller's rights on the destination and then
|
|
// drop the argument, so the task never moved and the answer still said OK.
|
|
if params.ProjectID != nil {
|
|
if err := s.moveTaskToProject(ctx, params.TaskID, *params.ProjectID, userID); err != nil {
|
|
return nil, mcp.ArgError("no project with id %q that you can write to", *params.ProjectID)
|
|
}
|
|
}
|
|
|
|
if err := s.db.UpdateTask(ctx, params.TaskID, userID, update); err != nil {
|
|
return nil, err
|
|
}
|
|
task, err := s.db.GetTask(ctx, params.TaskID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.hub.Publish(task.ProjectID, "task.updated", toTaskJSON(*task))
|
|
|
|
// project_id is in the answer because it is one of the things that can be
|
|
// changed here: a caller that asked for a move can see whether it happened
|
|
// rather than taking "OK" on trust.
|
|
return map[string]any{
|
|
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
|
"due_date": task.DueDate, "labels": task.Labels,
|
|
"project_id": task.ProjectID,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) mcpCompleteTask(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
|
var params struct {
|
|
TaskID string `json:"task_id"`
|
|
}
|
|
_ = json.Unmarshal(args, ¶ms)
|
|
|
|
if params.TaskID == "" {
|
|
return nil, mcp.ArgError("task_id is required — find one with search_tasks first")
|
|
}
|
|
role, err := store.TaskRole(ctx, s.db, params.TaskID, userID)
|
|
if err != nil || !role.CanEdit() {
|
|
return nil, mcp.ArgError("no task with id %q that you can complete", params.TaskID)
|
|
}
|
|
|
|
result, err := s.db.CompleteTask(ctx, params.TaskID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
task, err := s.db.GetTask(ctx, params.TaskID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.hub.Publish(task.ProjectID, "task.completed", toTaskJSON(*task))
|
|
|
|
out := map[string]any{"id": task.ID, "content": task.Content, "completed": task.Completed()}
|
|
if result.Recurred {
|
|
out["recurred"] = true
|
|
out["next_due"] = result.NextDue
|
|
out["note"] = "This task repeats; it has moved to its next occurrence rather than closing."
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Server) mcpAddComment(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
|
var params struct {
|
|
TaskID string `json:"task_id"`
|
|
Body string `json:"body"`
|
|
}
|
|
_ = json.Unmarshal(args, ¶ms)
|
|
|
|
if params.TaskID == "" || strings.TrimSpace(params.Body) == "" {
|
|
return nil, mcp.ArgError("both task_id and body are required")
|
|
}
|
|
role, err := store.TaskRole(ctx, s.db, params.TaskID, userID)
|
|
if err != nil || !role.CanEdit() {
|
|
return nil, mcp.ArgError("no task with id %q that you can comment on", params.TaskID)
|
|
}
|
|
|
|
comment, err := s.db.CreateComment(ctx, params.TaskID, userID, strings.TrimSpace(params.Body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"id": comment.ID, "task_id": comment.TaskID, "body": comment.Body}, nil
|
|
}
|
|
|
|
func clamp(value, fallback, max int) int {
|
|
if value <= 0 {
|
|
return fallback
|
|
}
|
|
if value > max {
|
|
return max
|
|
}
|
|
return value
|
|
}
|