verdande could not be added to Claude at all. The documented setup — POST /api/v1/mcp with an Authorization header — cannot be configured from Claude's custom-connector dialog, which asks for a name, a URL and OAuth client credentials and has no field for a bearer token. The instructions described something the interface does not offer. Worse, the obvious guess failed silently in the most misleading way available: /mcp was not a route, so the SPA fallback answered 200 with a page of HTML. The connector reported a successful connection and then failed to parse the response, which reads as a broken client rather than a missing endpoint. So /mcp exists now and takes its credential in the query string, exactly as the calendar feed does and for the same reason: a client that cannot be told to send a header means the address is the credential. The request logger records paths without their query strings, so the key does not reach the log. It is deliberately outside /api/v1 and accepts the key and nothing else. Reading a session cookie here would make it a state-changing POST outside the CSRF check — a cross-site request that acts as whoever is signed in — and there is a test that fails if that ever becomes true. The API path is unchanged for anything that can send a header. The tokens page now shows the connector address next to the token it belongs to, since that is the one moment the token exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
452 lines
15 KiB
Go
452 lines
15 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"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."),
|
|
"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, "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"`
|
|
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
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
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))
|
|
return map[string]any{
|
|
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
|
"due_date": task.DueDate, "project_id": task.ProjectID,
|
|
}, 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
|
|
}
|
|
if params.ProjectID != nil {
|
|
if _, err := store.RequireProjectRole(ctx, s.db, *params.ProjectID, userID, store.RoleEditor); 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))
|
|
|
|
return map[string]any{
|
|
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
|
"due_date": task.DueDate, "labels": task.Labels,
|
|
}, 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
|
|
}
|