2026-08-17 07:53:53 +02:00
package httpapi
import (
"context"
"encoding/json"
2026-08-17 14:00:39 +02:00
"errors"
2026-08-17 16:22:36 +02:00
"fmt"
2026-08-17 07:53:53 +02:00
"io"
"net/http"
"strings"
"time"
2026-08-17 14:00:39 +02:00
"github.com/kristianwind/verdande/internal/auth"
2026-08-17 07:53:53 +02:00
"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`." ),
2026-08-17 16:22:36 +02:00
"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." ),
2026-08-17 07:53:53 +02:00
"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 )
2026-08-19 13:33:54 +02:00
s . registerNoteTools ( m )
2026-08-17 07:53:53 +02:00
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 )
}
2026-08-17 14:00:39 +02:00
// 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 ) {
2026-08-17 22:58:02 +02:00
s . internal ( w , r , "mcp key" , err )
2026-08-17 14:00:39 +02:00
return
}
writeError ( w , http . StatusUnauthorized , CodeUnauthorized , "that key is not valid" )
return
}
s . handleMCP ( w , r . WithContext ( withUser ( r . Context (), user )))
}
2026-08-17 07:53:53 +02:00
// --- 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 , & params )
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 , & params )
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"`
2026-08-17 16:22:36 +02:00
ParentID string `json:"parent_id"`
2026-08-17 07:53:53 +02:00
DueDate string `json:"due_date"`
Priority int `json:"priority"`
Description string `json:"description"`
Labels [] string `json:"labels"`
}
_ = json . Unmarshal ( args , & params )
user , err := s . db . UserByID ( ctx , userID )
if err != nil {
return nil , err
}
task := & store . Task { CreatedBy : userID , Priority : 4 }
labels := params . Labels
2026-08-17 16:22:36 +02:00
// 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 := ""
2026-08-17 07:53:53 +02:00
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
2026-08-17 16:22:36 +02:00
} else {
unknownProject = parsed . Project
2026-08-17 07:53:53 +02:00
}
}
}
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
}
2026-08-17 16:22:36 +02:00
// 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
}
2026-08-17 07:53:53 +02:00
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 ))
2026-08-17 16:22:36 +02:00
out := map [ string ] any {
2026-08-17 07:53:53 +02:00
"id" : task . ID , "content" : task . Content , "priority" : task . Priority ,
"due_date" : task . DueDate , "project_id" : task . ProjectID ,
2026-08-17 16:22:36 +02:00
}
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
2026-08-17 07:53:53 +02:00
}
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 , & params )
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
}
2026-08-17 16:22:36 +02:00
// 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.
2026-08-17 07:53:53 +02:00
if params . ProjectID != nil {
2026-08-17 16:22:36 +02:00
if err := s . moveTaskToProject ( ctx , params . TaskID , * params . ProjectID , userID ); err != nil {
2026-08-17 07:53:53 +02:00
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 ))
2026-08-17 16:22:36 +02:00
// 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.
2026-08-17 07:53:53 +02:00
return map [ string ] any {
"id" : task . ID , "content" : task . Content , "priority" : task . Priority ,
"due_date" : task . DueDate , "labels" : task . Labels ,
2026-08-17 16:22:36 +02:00
"project_id" : task . ProjectID ,
2026-08-17 07:53:53 +02:00
}, 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 , & params )
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 , & params )
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
}
2026-08-19 13:33:54 +02:00
// --- 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]]` " +
2026-09-08 12:08:12 +02:00
"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 — " +
2026-08-19 13:33:54 +02:00
"Apple Notes, OneNote, a folder of Markdown — one call per note." ,
InputSchema : mcp . Schema ( map [ string ] any {
2026-08-19 18:36:31 +02:00
"body" : mcp . Str ( "The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links." ),
2026-08-19 13:33:54 +02:00
"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." ),
2026-08-19 18:36:31 +02:00
"body" : mcp . Str ( "The whole new text, as Markdown. The title follows its first line." ),
2026-08-19 13:33:54 +02:00
"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 , & params )
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 , & params )
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 , & params )
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
}