feat(mcp): add person CRUD and timeline tools

Amolith created

Assisted-by: Claude Sonnet 4 via Crush

Change summary

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(+)

Detailed changes

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)

internal/mcp/tools/person/create.go 🔗

@@ -0,0 +1,99 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}

internal/mcp/tools/person/delete.go 🔗

@@ -0,0 +1,63 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}

internal/mcp/tools/person/list.go 🔗

@@ -0,0 +1,138 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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()
+}

internal/mcp/tools/person/timeline.go 🔗

@@ -0,0 +1,95 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}

internal/mcp/tools/person/update.go 🔗

@@ -0,0 +1,98 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}