Prøven, der skal holde udgaverne adskilt, stod grøn gennem to merges, mens
34 opgaveruter blev betjent af noter-udgaven. Den fejlede ikke: den
sammenligner routeren med en nedskreven liste, og listen var skrevet ud
fra de ruter, nogen havde tænkt på. Den er en DRIFT-kontrol, og den kan
ikke være en fuldstændighedskontrol. Det står nu i prøven selv.
Lukket: CalDAV-træet (8 ruter, alle læser eller skriver en VTODO),
.well-known/caldav (en påstand om at der er en CalDAV-server), ICS-feedet
og de to ruter til dets token, de fire opgave-eksporter, Todoist- og
CSV-importen, skabelonerne, sektionerne, de seks AI-opgaveruter og
indbakke-krogen. Plus fem af ni MCP-værktøjer — endepunktet er der
stadig, værktøjslisten er kortere.
Tre blev afgjort ved at læse frem for ved navnet: en skabelon laver
opgaver (CreateProjectFromTemplate), `section_id` står på `tasks` og
findes ikke på `notes`, og add_comment hænger på en opgave.
Men den dyre halvdel var fladen. En vandring gennem hver side, der kan
nås i noter-udgaven, med konsollen og svarene aflæst, fandt SYV brud —
fire af dem fra den første port:
sidebjælken GET /filters 404 ved hver navigation, ædt af .catch()
sidebjælken GET /labels 404 ved hver navigation, ædt af .catch()
notifikationer GET /ai/plan 404, stille
notesbogens side GET /tasks kastede → status = 'failed'
notesbogens side GET /sections kastede → samme fejlskærm
indstillinger GET /templates toast med fejlbesked på skærmen
integrationer GET /feed 404, stille
Den fjerde er den, der har kostet noget: /projekt/{id} er notesbogens
side, og kaldene samles i et Promise.all, så én 404 tog hele siden med.
Den har vist en fejlskærm frem for noter siden porten blev sat. Ingen
prøve åbnede en notesbog i den udgave, og .catch(() => {}) sørgede for,
at resten ikke sagde noget. "Fladen følger udgaven" var sandt om menuen
og falsk om hentningerne.
Vagten for opgaver står i butikken frem for ved kaldestederne: der var
tre, og den fjerde blev glemt — hvilket er præcis sådan, den slap
igennem.
Og rettelsen lavede selv en fejl, som vandringen fangede: da opgavekaldet
holdt op med at fejle, kom en uendelig effekt-løkke frem, som 404'eren
havde holdt låg på. En ubetinget tilskrivning til app.tasks får
projektsidens effekt til at kalde tilbage. 404'eren var låget på en
løkke, ikke bare støj.
Også: -timeout 20m i begge CI-workflows. internal/httpapi tog 664s her og
498s i CI på #95, mod Go's standardgrænse på 600 — og når den krydses, er
beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, som
læser som en deadlock i koden frem for som en suite, der blev for stor.
watchForTrouble udskilt til web/e2e/trouble.js, så udgave-suiten kan bruge
den samme.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
676 lines
24 KiB
Go
676 lines
24 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 {
|
|
// Udgavens eget navn: det er sådan, instansen står i klientens
|
|
// forbindelsesliste.
|
|
m := mcp.NewServer(s.cfg.ProductSlug(), "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)
|
|
|
|
// De fem opgaveværktøjer. Udeladt i noter-udgaven af samme grund som ruterne:
|
|
// et værktøj, en model kan kalde, er en flade på instansen, og et værktøj der
|
|
// svarer "der er ingen opgaver" er værre end et, der ikke findes — modellen
|
|
// prøver det, får et tomt svar og fortæller brugeren, at der ikke er noget at
|
|
// lave. add_comment er med her: en kommentar hænger på en opgave.
|
|
if !s.cfg.NotesOnly() {
|
|
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)
|
|
}
|
|
|
|
s.registerNoteTools(m)
|
|
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
|
|
}
|
|
|
|
// --- notes ---------------------------------------------------------------------
|
|
|
|
// registerNoteTools adds the note half of the MCP surface.
|
|
//
|
|
// Separate from the task tools for one reason worth stating: this is how a note
|
|
// gets in from somewhere else. Apple Notes has no export worth the name, but a
|
|
// model running on the machine can read Notes.app and write what it finds through
|
|
// these — one note at a time, with the text as Markdown. That is a migration
|
|
// nobody has to build a parser for.
|
|
func (s *Server) registerNoteTools(m *mcp.Server) {
|
|
m.Register(mcp.Tool{
|
|
Name: "search_notes",
|
|
Description: "Find notes by text. Matches title and body, and is generous about " +
|
|
"Danish spelling — searching \"gron\" finds \"grøn\", and the other way round. " +
|
|
"With no query it returns the most recent notes. This is the tool for " +
|
|
"\"what did I write about the tax return\" or \"find my note on the kitchen\".",
|
|
InputSchema: mcp.Schema(map[string]any{
|
|
"text": mcp.Str("Words to look for in the title and body."),
|
|
"limit": mcp.Int("How many to return. Defaults to 25, maximum 100."),
|
|
}),
|
|
}, s.mcpSearchNotes)
|
|
|
|
m.Register(mcp.Tool{
|
|
Name: "create_note",
|
|
Description: "Write a note. The body is Markdown and is stored as typed — it is " +
|
|
"also exactly what an export produces, so nothing is converted and nothing " +
|
|
"is lost. Two things in the text are read out of it and become links: " +
|
|
"`#Projekt` files the note against that project, so it appears on the " +
|
|
"project's page without anybody filing it there, and `[[Another note]]` " +
|
|
"links to a note by its title. A third, `@Navn`, shares the note with that " +
|
|
"person and tells them — so only write one when the note is meant for them. " +
|
|
"Use this to bring notes in from elsewhere — " +
|
|
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
|
InputSchema: mcp.Schema(map[string]any{
|
|
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
|
|
"project_id": mcp.Str("File it under a project by id. Usually unnecessary — a #tag in the body does the same and is what a person would type."),
|
|
"pinned": mcp.Bool("Keep it at the top of the list."),
|
|
}),
|
|
}, s.mcpCreateNote)
|
|
|
|
m.Register(mcp.Tool{
|
|
Name: "update_note",
|
|
Description: "Change a note. Only the fields given are changed. Sending `body` " +
|
|
"replaces the whole text, so read the note first if you mean to add to it — " +
|
|
"and the links are read again from the new text, so a #tag removed is a link " +
|
|
"removed.",
|
|
InputSchema: mcp.Schema(map[string]any{
|
|
"note_id": mcp.Str("Which note."),
|
|
"body": mcp.Str("The whole new text, as Markdown. The title follows its first line."),
|
|
"pinned": mcp.Bool("Pin it or unpin it."),
|
|
}),
|
|
}, s.mcpUpdateNote)
|
|
}
|
|
|
|
func (s *Server) mcpSearchNotes(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
|
var params struct {
|
|
Text string `json:"text"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
_ = json.Unmarshal(args, ¶ms)
|
|
|
|
limit := params.Limit
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 25
|
|
}
|
|
|
|
var notes []store.Note
|
|
var err error
|
|
if strings.TrimSpace(params.Text) == "" {
|
|
notes, err = s.db.LooseNotes(ctx, userID)
|
|
} else {
|
|
notes, err = s.db.SearchNotes(ctx, params.Text, limit)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The same filter the HTTP surface applies, for the same reason: a search must
|
|
// not become a way to read what you cannot open.
|
|
out := make([]map[string]any, 0, len(notes))
|
|
for _, n := range notes {
|
|
if !s.mcpMayRead(ctx, &n, userID) {
|
|
continue
|
|
}
|
|
out = append(out, map[string]any{
|
|
"id": n.ID, "title": n.Title, "body": n.Body,
|
|
"project_id": n.ProjectID, "updated_at": n.UpdatedAt,
|
|
})
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return map[string]any{"notes": out, "count": len(out)}, nil
|
|
}
|
|
|
|
func (s *Server) mcpMayRead(ctx context.Context, n *store.Note, userID string) bool {
|
|
if n.ProjectID == "" {
|
|
return n.CreatedBy == userID
|
|
}
|
|
_, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleViewer)
|
|
return err == nil
|
|
}
|
|
|
|
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
|
var params struct {
|
|
Body string `json:"body"`
|
|
ProjectID string `json:"project_id"`
|
|
Pinned bool `json:"pinned"`
|
|
}
|
|
_ = json.Unmarshal(args, ¶ms)
|
|
|
|
n := &store.Note{
|
|
CreatedBy: userID,
|
|
Body: params.Body,
|
|
Pinned: params.Pinned,
|
|
}
|
|
if params.ProjectID != "" {
|
|
if _, err := store.RequireProjectRole(ctx, s.db, params.ProjectID, userID, store.RoleEditor); err != nil {
|
|
return nil, err
|
|
}
|
|
n.ProjectID = params.ProjectID
|
|
}
|
|
if err := s.db.SaveNote(ctx, n); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The links are reported back rather than left silent. A note whose #tag was
|
|
// misspelt looks identical to one whose tag landed, and in a chat nobody sees
|
|
// the row that was written.
|
|
return map[string]any{
|
|
"id": n.ID, "title": n.Title, "links": n.Links,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
|
var params struct {
|
|
NoteID string `json:"note_id"`
|
|
Body *string `json:"body"`
|
|
Pinned *bool `json:"pinned"`
|
|
}
|
|
_ = json.Unmarshal(args, ¶ms)
|
|
|
|
n, err := s.db.Note(ctx, params.NoteID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if n == nil || !s.mcpMayRead(ctx, n, userID) {
|
|
return nil, fmt.Errorf("no such note")
|
|
}
|
|
if n.ProjectID != "" {
|
|
if _, err := store.RequireProjectRole(ctx, s.db, n.ProjectID, userID, store.RoleEditor); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if params.Body != nil {
|
|
n.Body = *params.Body
|
|
}
|
|
if params.Pinned != nil {
|
|
n.Pinned = *params.Pinned
|
|
}
|
|
if err := s.db.SaveNote(ctx, n); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"id": n.ID, "title": n.Title, "links": n.Links}, nil
|
|
}
|