From a1e9167521071ac4cc1a31f26792f2d0572e25f1 Mon Sep 17 00:00:00 2001 From: Amolith Date: Tue, 23 Dec 2025 22:41:01 -0700 Subject: [PATCH] feat(mcp): add person CRUD and timeline tools Assisted-by: Claude Sonnet 4 via Crush --- cmd/mcp/server.go | 45 +++++++++ internal/mcp/tools/person/create.go | 99 ++++++++++++++++++ internal/mcp/tools/person/delete.go | 63 ++++++++++++ internal/mcp/tools/person/list.go | 138 ++++++++++++++++++++++++++ internal/mcp/tools/person/timeline.go | 95 ++++++++++++++++++ internal/mcp/tools/person/update.go | 98 ++++++++++++++++++ 6 files changed, 538 insertions(+) create mode 100644 internal/mcp/tools/person/create.go create mode 100644 internal/mcp/tools/person/delete.go create mode 100644 internal/mcp/tools/person/list.go create mode 100644 internal/mcp/tools/person/timeline.go create mode 100644 internal/mcp/tools/person/update.go diff --git a/cmd/mcp/server.go b/cmd/mcp/server.go index 4ed2cbb4877b9d028be54b6178514e54f1c3921e..e860c8a91d7bc569ae376b2d757d51d6f16f918a 100644 --- a/cmd/mcp/server.go +++ b/cmd/mcp/server.go @@ -22,6 +22,7 @@ import ( "git.secluded.site/lune/internal/mcp/tools/habit" "git.secluded.site/lune/internal/mcp/tools/journal" notetool "git.secluded.site/lune/internal/mcp/tools/note" + persontool "git.secluded.site/lune/internal/mcp/tools/person" "git.secluded.site/lune/internal/mcp/tools/task" "git.secluded.site/lune/internal/mcp/tools/timestamp" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -142,6 +143,7 @@ func registerTools( registerTaskTools(mcpServer, tools, accessToken, areaProviders) registerNoteTools(mcpServer, tools, accessToken, notebookProviders) + registerPersonTools(mcpServer, tools, accessToken) if tools.TrackHabit { habitHandler := habit.NewHandler(accessToken, habitProviders) @@ -241,6 +243,49 @@ func registerNoteTools( } } +func registerPersonTools( + mcpServer *mcp.Server, + tools *config.ToolsConfig, + accessToken string, +) { + personHandler := persontool.NewHandler(accessToken) + + if tools.CreatePerson { + mcp.AddTool(mcpServer, &mcp.Tool{ + Name: persontool.CreateToolName, + Description: persontool.CreateToolDescription, + }, personHandler.HandleCreate) + } + + if tools.UpdatePerson { + mcp.AddTool(mcpServer, &mcp.Tool{ + Name: persontool.UpdateToolName, + Description: persontool.UpdateToolDescription, + }, personHandler.HandleUpdate) + } + + if tools.DeletePerson { + mcp.AddTool(mcpServer, &mcp.Tool{ + Name: persontool.DeleteToolName, + Description: persontool.DeleteToolDescription, + }, personHandler.HandleDelete) + } + + if tools.ListPeople { + mcp.AddTool(mcpServer, &mcp.Tool{ + Name: persontool.ListToolName, + Description: persontool.ListToolDescription, + }, personHandler.HandleList) + } + + if tools.PersonTimeline { + mcp.AddTool(mcpServer, &mcp.Tool{ + Name: persontool.TimelineToolName, + Description: persontool.TimelineToolDescription, + }, personHandler.HandleTimeline) + } +} + func runStdio(mcpServer *mcp.Server) error { if err := mcpServer.Run(context.Background(), &mcp.StdioTransport{}); err != nil { return fmt.Errorf("stdio server error: %w", err) diff --git a/internal/mcp/tools/person/create.go b/internal/mcp/tools/person/create.go new file mode 100644 index 0000000000000000000000000000000000000000..49806be8f8cc76b2265e6789bb2ae3bac105e2cc --- /dev/null +++ b/internal/mcp/tools/person/create.go @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package person provides MCP tools for Lunatask person/relationship operations. +package person + +import ( + "context" + + "git.secluded.site/go-lunatask" + "git.secluded.site/lune/internal/mcp/shared" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// CreateToolName is the name of the create person tool. +const CreateToolName = "create_person" + +// CreateToolDescription describes the create person tool for LLMs. +const CreateToolDescription = `Creates a new person in Lunatask's relationship tracker. + +Required: +- first_name: First name + +Optional: +- last_name: Last name +- relationship: Relationship strength (family, intimate-friends, close-friends, + casual-friends, acquaintances, business-contacts, almost-strangers) + Default: casual-friends +- source: Origin identifier for integrations +- source_id: Source-specific ID (requires source) + +Returns the deep link to the created person.` + +// CreateInput is the input schema for creating a person. +type CreateInput struct { + FirstName string `json:"first_name" jsonschema:"required"` + LastName *string `json:"last_name,omitempty"` + Relationship *string `json:"relationship,omitempty"` + Source *string `json:"source,omitempty"` + SourceID *string `json:"source_id,omitempty"` +} + +// CreateOutput is the output schema for creating a person. +type CreateOutput struct { + DeepLink string `json:"deep_link"` +} + +// Handler handles person-related MCP tool requests. +type Handler struct { + client *lunatask.Client +} + +// NewHandler creates a new person handler. +func NewHandler(accessToken string) *Handler { + return &Handler{ + client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")), + } +} + +// HandleCreate creates a new person. +func (h *Handler) HandleCreate( + ctx context.Context, + _ *mcp.CallToolRequest, + input CreateInput, +) (*mcp.CallToolResult, CreateOutput, error) { + lastName := "" + if input.LastName != nil { + lastName = *input.LastName + } + + builder := h.client.NewPerson(input.FirstName, lastName) + + if input.Relationship != nil { + rel, err := lunatask.ParseRelationshipStrength(*input.Relationship) + if err != nil { + return shared.ErrorResult(err.Error()), CreateOutput{}, nil + } + + builder.WithRelationshipStrength(rel) + } + + if input.Source != nil && input.SourceID != nil { + builder.FromSource(*input.Source, *input.SourceID) + } + + person, err := builder.Create(ctx) + if err != nil { + return shared.ErrorResult(err.Error()), CreateOutput{}, nil + } + + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Person created: " + deepLink, + }}, + }, CreateOutput{DeepLink: deepLink}, nil +} diff --git a/internal/mcp/tools/person/delete.go b/internal/mcp/tools/person/delete.go new file mode 100644 index 0000000000000000000000000000000000000000..83ca0bdf428e17bf70bcf4719193d8aa0b8253c8 --- /dev/null +++ b/internal/mcp/tools/person/delete.go @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package person + +import ( + "context" + + "git.secluded.site/go-lunatask" + "git.secluded.site/lune/internal/mcp/shared" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// DeleteToolName is the name of the delete person tool. +const DeleteToolName = "delete_person" + +// DeleteToolDescription describes the delete person tool for LLMs. +const DeleteToolDescription = `Deletes a person from Lunatask. + +Required: +- id: Person UUID or lunatask://person/... deep link + +This action is permanent and cannot be undone.` + +// DeleteInput is the input schema for deleting a person. +type DeleteInput struct { + ID string `json:"id" jsonschema:"required"` +} + +// DeleteOutput is the output schema for deleting a person. +type DeleteOutput struct { + Success bool `json:"success"` + DeepLink string `json:"deep_link"` +} + +// HandleDelete deletes a person. +func (h *Handler) HandleDelete( + ctx context.Context, + _ *mcp.CallToolRequest, + input DeleteInput, +) (*mcp.CallToolResult, DeleteOutput, error) { + id, err := parseReference(input.ID) + if err != nil { + return shared.ErrorResult(err.Error()), DeleteOutput{}, nil + } + + person, err := h.client.DeletePerson(ctx, id) + if err != nil { + return shared.ErrorResult(err.Error()), DeleteOutput{}, nil + } + + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Person deleted: " + deepLink, + }}, + }, DeleteOutput{ + Success: true, + DeepLink: deepLink, + }, nil +} diff --git a/internal/mcp/tools/person/list.go b/internal/mcp/tools/person/list.go new file mode 100644 index 0000000000000000000000000000000000000000..0f7e2f7d6d7aa26d78b01de5bea43a35cb4fae48 --- /dev/null +++ b/internal/mcp/tools/person/list.go @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package person + +import ( + "context" + "fmt" + "strings" + + "git.secluded.site/go-lunatask" + "git.secluded.site/lune/internal/mcp/shared" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ListToolName is the name of the list people tool. +const ListToolName = "list_people" + +// ListToolDescription describes the list people tool for LLMs. +const ListToolDescription = `Lists people from Lunatask's relationship tracker. + +Optional: +- source: Filter by source identifier +- source_id: Filter by source-specific ID + +Returns person metadata (IDs, relationship strength, created dates). +Use lunatask://person/{id} resource for full person details. + +Note: Due to end-to-end encryption, names are not available +in the list — only metadata is returned.` + +// ListInput is the input schema for listing people. +type ListInput struct { + Source *string `json:"source,omitempty"` + SourceID *string `json:"source_id,omitempty"` +} + +// ListPersonItem represents a person in the list output. +type ListPersonItem struct { + DeepLink string `json:"deep_link"` + RelationshipStrength *string `json:"relationship_strength,omitempty"` + CreatedAt string `json:"created_at"` +} + +// ListOutput is the output schema for listing people. +type ListOutput struct { + People []ListPersonItem `json:"people"` + Count int `json:"count"` +} + +// HandleList lists people. +func (h *Handler) HandleList( + ctx context.Context, + _ *mcp.CallToolRequest, + input ListInput, +) (*mcp.CallToolResult, ListOutput, error) { + opts := buildListOptions(input) + + people, err := h.client.ListPeople(ctx, opts) + if err != nil { + return shared.ErrorResult(err.Error()), ListOutput{}, nil + } + + items := make([]ListPersonItem, 0, len(people)) + + for _, person := range people { + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID) + + item := ListPersonItem{ + DeepLink: deepLink, + CreatedAt: person.CreatedAt.Format("2006-01-02"), + } + + if person.RelationshipStrength != nil { + rel := string(*person.RelationshipStrength) + item.RelationshipStrength = &rel + } + + items = append(items, item) + } + + output := ListOutput{ + People: items, + Count: len(items), + } + + text := formatListText(items) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + }, output, nil +} + +func buildListOptions(input ListInput) *lunatask.ListPeopleOptions { + if input.Source == nil && input.SourceID == nil { + return nil + } + + opts := &lunatask.ListPeopleOptions{} + + if input.Source != nil { + opts.Source = input.Source + } + + if input.SourceID != nil { + opts.SourceID = input.SourceID + } + + return opts +} + +func formatListText(items []ListPersonItem) string { + if len(items) == 0 { + return "No people found" + } + + var text strings.Builder + + text.WriteString(fmt.Sprintf("Found %d person(s):\n", len(items))) + + for _, item := range items { + text.WriteString("- ") + text.WriteString(item.DeepLink) + + if item.RelationshipStrength != nil { + text.WriteString(" (") + text.WriteString(*item.RelationshipStrength) + text.WriteString(")") + } + + text.WriteString("\n") + } + + text.WriteString("\nUse lunatask://person/{id} resource for full details.") + + return text.String() +} diff --git a/internal/mcp/tools/person/timeline.go b/internal/mcp/tools/person/timeline.go new file mode 100644 index 0000000000000000000000000000000000000000..cae58317bd96046450c4bd2b8571a5f58cb84d96 --- /dev/null +++ b/internal/mcp/tools/person/timeline.go @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package person + +import ( + "context" + + "git.secluded.site/go-lunatask" + "git.secluded.site/lune/internal/dateutil" + "git.secluded.site/lune/internal/mcp/shared" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// TimelineToolName is the name of the add timeline note tool. +const TimelineToolName = "add_timeline_note" + +// TimelineToolDescription describes the add timeline note tool for LLMs. +const TimelineToolDescription = `Adds a timeline note to a person's memory timeline in Lunatask. + +Required: +- person_id: Person UUID or lunatask://person/... deep link + +Optional: +- content: Markdown content describing the interaction +- date: Date of interaction (YYYY-MM-DD or natural language, default: today) + +This is append-only — adds to the person's memory timeline. +Great for tracking when you last interacted with someone.` + +// TimelineInput is the input schema for adding a timeline note. +type TimelineInput struct { + PersonID string `json:"person_id" jsonschema:"required"` + Content *string `json:"content,omitempty"` + Date *string `json:"date,omitempty"` +} + +// TimelineOutput is the output schema for adding a timeline note. +type TimelineOutput struct { + Success bool `json:"success"` + PersonDeepLink string `json:"person_deep_link"` + NoteID string `json:"note_id"` +} + +// HandleTimeline adds a timeline note to a person. +func (h *Handler) HandleTimeline( + ctx context.Context, + _ *mcp.CallToolRequest, + input TimelineInput, +) (*mcp.CallToolResult, TimelineOutput, error) { + personID, err := parseReference(input.PersonID) + if err != nil { + return shared.ErrorResult(err.Error()), TimelineOutput{}, nil + } + + builder := h.client.NewTimelineNote(personID) + + if input.Content != nil { + builder.WithContent(*input.Content) + } + + if input.Date != nil { + date, err := dateutil.Parse(*input.Date) + if err != nil { + return shared.ErrorResult(err.Error()), TimelineOutput{}, nil + } + + builder.OnDate(date) + } + + note, err := builder.Create(ctx) + if err != nil { + return shared.ErrorResult(err.Error()), TimelineOutput{}, nil + } + + personDeepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, personID) + + dateStr := "today" + + if input.Date != nil { + date, _ := dateutil.Parse(*input.Date) + dateStr = date.Format("2006-01-02") + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Timeline note added for " + personDeepLink + " on " + dateStr, + }}, + }, TimelineOutput{ + Success: true, + PersonDeepLink: personDeepLink, + NoteID: note.ID, + }, nil +} diff --git a/internal/mcp/tools/person/update.go b/internal/mcp/tools/person/update.go new file mode 100644 index 0000000000000000000000000000000000000000..82dfe71a261d74a9bef6fe802c86c5154a0dc681 --- /dev/null +++ b/internal/mcp/tools/person/update.go @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package person + +import ( + "context" + "fmt" + + "git.secluded.site/go-lunatask" + "git.secluded.site/lune/internal/mcp/shared" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// UpdateToolName is the name of the update person tool. +const UpdateToolName = "update_person" + +// UpdateToolDescription describes the update person tool for LLMs. +const UpdateToolDescription = `Updates an existing person in Lunatask. + +Required: +- id: Person UUID or lunatask://person/... deep link + +Optional: +- first_name: New first name +- last_name: New last name +- relationship: New relationship strength (family, intimate-friends, + close-friends, casual-friends, acquaintances, business-contacts, almost-strangers) + +Only provided fields are modified; other fields remain unchanged.` + +// UpdateInput is the input schema for updating a person. +type UpdateInput struct { + ID string `json:"id" jsonschema:"required"` + FirstName *string `json:"first_name,omitempty"` + LastName *string `json:"last_name,omitempty"` + Relationship *string `json:"relationship,omitempty"` +} + +// UpdateOutput is the output schema for updating a person. +type UpdateOutput struct { + DeepLink string `json:"deep_link"` +} + +// HandleUpdate updates an existing person. +func (h *Handler) HandleUpdate( + ctx context.Context, + _ *mcp.CallToolRequest, + input UpdateInput, +) (*mcp.CallToolResult, UpdateOutput, error) { + id, err := parseReference(input.ID) + if err != nil { + return shared.ErrorResult(err.Error()), UpdateOutput{}, nil + } + + builder := h.client.NewPersonUpdate(id) + + if input.FirstName != nil { + builder.FirstName(*input.FirstName) + } + + if input.LastName != nil { + builder.LastName(*input.LastName) + } + + if input.Relationship != nil { + rel, err := lunatask.ParseRelationshipStrength(*input.Relationship) + if err != nil { + return shared.ErrorResult(err.Error()), UpdateOutput{}, nil + } + + builder.WithRelationshipStrength(rel) + } + + person, err := builder.Update(ctx) + if err != nil { + return shared.ErrorResult(err.Error()), UpdateOutput{}, nil + } + + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Person updated: " + deepLink, + }}, + }, UpdateOutput{DeepLink: deepLink}, nil +} + +// parseReference extracts UUID from either a raw UUID or a lunatask:// deep link. +func parseReference(ref string) (string, error) { + _, id, err := lunatask.ParseReference(ref) + if err != nil { + return "", fmt.Errorf("invalid ID: %w", err) + } + + return id, nil +}