Detailed changes
@@ -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)
@@ -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
+}
@@ -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
+}
@@ -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()
+}
@@ -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
+}
@@ -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
+}