At skrive nogens navn i en note *er* at ville have dem til at læse den. Indtil nu var det to handlinger — skriv navnet, åbn panelet, find personen, del — og den anden blev glemt, så noten stod med en besked til en, der ikke kunne se den. Nu deler `@Navn` noten med den, der bliver nævnt, og siger det til dem. Editoren foreslår personer efter `@`, ligesom `#` foreslår projekter. Slået op i navnene frem for tolket ud af teksten. Et navn kan have mellemrum i sig, og et udtryk, der skulle fange dem alle, ville også fange sætningen bagefter: `@Sofie i morgen` ville blive til en person, der hed noget i den retning. Her er der ikke noget at gætte — der står et navn, eller der gør ikke. Det er også dét, der gør det forsvarligt at dele på: adgang må ikke afhænge af, hvor langt et regulært udtryk valgte at læse. En mailadresse er ikke en omtale, det længste navn vinder, og ejefaldets s hører til sætningen. Kun nye omtaler, sammenlignet med teksten før gemningen på samme måde som links: en note gemmes ved hver tastepause, så uden sammenligningen var hver gemning en ny deling og en ny besked om det samme navn. Kun ejerens omtaler deler. En medredaktør må skrive i noten, men ikke give den videre — ellers kunne den, ejeren har delt med, skrive et navn og dermed dele ejerens note med en, ejeren aldrig har hørt om. Det er den samme regel, panelet følger. Læser, ikke medredaktør: en omtale siger "se på det her", og det mindste, der opfylder sætningen, er det rigtige at give. En rolle, ejeren har hævet i panelet, sænkes ikke igen af en omtale. Og én besked pr. handling: den, der lige er blevet nævnt, får "nævnte dig" og ikke også "rettede en note" om den samme gemning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
667 lines
23 KiB
Go
667 lines
23 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)
|
|
|
|
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
|
|
}
|