diff --git a/cmd/mcp/server.go b/cmd/mcp/server.go index 7866f5e736b26a1019232978fe37a8551e8bb6f8..993af4d4136e737ad8421279c5dfa88dfbf359ae 100644 --- a/cmd/mcp/server.go +++ b/cmd/mcp/server.go @@ -14,7 +14,10 @@ import ( "git.secluded.site/lune/internal/config" "git.secluded.site/lune/internal/mcp/resources/areas" "git.secluded.site/lune/internal/mcp/resources/habits" + noters "git.secluded.site/lune/internal/mcp/resources/note" "git.secluded.site/lune/internal/mcp/resources/notebooks" + personrs "git.secluded.site/lune/internal/mcp/resources/person" + taskrs "git.secluded.site/lune/internal/mcp/resources/task" "git.secluded.site/lune/internal/mcp/shared" "git.secluded.site/lune/internal/mcp/tools/habit" "git.secluded.site/lune/internal/mcp/tools/task" @@ -39,6 +42,7 @@ func newMCPServer(cfg *config.Config, accessToken string) *mcp.Server { notebookProviders := shared.ToNotebookProviders(cfg.Notebooks) registerResources(mcpServer, areaProviders, habitProviders, notebookProviders) + registerResourceTemplates(mcpServer, accessToken) registerTools(mcpServer, cfg, accessToken, areaProviders, habitProviders) return mcpServer @@ -90,6 +94,32 @@ func registerResources( }, notebooksHandler.HandleRead) } +func registerResourceTemplates(mcpServer *mcp.Server, accessToken string) { + taskHandler := taskrs.NewHandler(accessToken) + mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{ + Name: "task", + URITemplate: taskrs.ResourceTemplate, + Description: taskrs.ResourceDescription, + MIMEType: "application/json", + }, taskHandler.HandleRead) + + noteHandler := noters.NewHandler(accessToken) + mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{ + Name: "note", + URITemplate: noters.ResourceTemplate, + Description: noters.ResourceDescription, + MIMEType: "application/json", + }, noteHandler.HandleRead) + + personHandler := personrs.NewHandler(accessToken) + mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{ + Name: "person", + URITemplate: personrs.ResourceTemplate, + Description: personrs.ResourceDescription, + MIMEType: "application/json", + }, personHandler.HandleRead) +} + func registerTools( mcpServer *mcp.Server, cfg *config.Config, diff --git a/internal/mcp/resources/note/handler.go b/internal/mcp/resources/note/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..ea52b8f8eac05f69700f223495656684a652631f --- /dev/null +++ b/internal/mcp/resources/note/handler.go @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package note provides the MCP resource handler for individual Lunatask notes. +package note + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "git.secluded.site/go-lunatask" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ResourceTemplate is the URI template for note resources. +const ResourceTemplate = "lunatask://note/{id}" + +// ResourceDescription describes the note resource for LLMs. +const ResourceDescription = `Reads metadata for a specific Lunatask note by ID or deep link. + +Due to end-to-end encryption, note title and content are not available. +Returns metadata including notebook, date, pinned status, and sources.` + +// sourceInfo represents a source reference in the response. +type sourceInfo struct { + Source string `json:"source"` + SourceID string `json:"source_id"` +} + +// noteInfo represents note metadata in the resource response. +type noteInfo struct { + DeepLink string `json:"deep_link"` + NotebookID *string `json:"notebook_id,omitempty"` + DateOn *string `json:"date_on,omitempty"` + Pinned bool `json:"pinned"` + Sources []sourceInfo `json:"sources,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Handler handles note resource requests. +type Handler struct { + client *lunatask.Client +} + +// NewHandler creates a new note resource handler. +func NewHandler(accessToken string) *Handler { + return &Handler{ + client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")), + } +} + +// HandleRead returns metadata for a specific note. +func (h *Handler) HandleRead( + ctx context.Context, + req *mcp.ReadResourceRequest, +) (*mcp.ReadResourceResult, error) { + _, id, err := lunatask.ParseReference(req.Params.URI) + if err != nil { + return nil, fmt.Errorf("invalid URI %q: %w", req.Params.URI, mcp.ResourceNotFoundError(req.Params.URI)) + } + + note, err := h.client.GetNote(ctx, id) + if err != nil { + return nil, fmt.Errorf("fetching note: %w", err) + } + + info := buildNoteInfo(note) + + data, err := json.MarshalIndent(info, "", " ") + if err != nil { + return nil, fmt.Errorf("marshaling note: %w", err) + } + + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: req.Params.URI, + MIMEType: "application/json", + Text: string(data), + }}, + }, nil +} + +func buildNoteInfo(note *lunatask.Note) noteInfo { + info := noteInfo{ + NotebookID: note.NotebookID, + Pinned: note.Pinned, + CreatedAt: note.CreatedAt.Format(time.RFC3339), + UpdatedAt: note.UpdatedAt.Format(time.RFC3339), + } + + info.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID) + + if note.DateOn != nil { + s := note.DateOn.Format("2006-01-02") + info.DateOn = &s + } + + if len(note.Sources) > 0 { + info.Sources = make([]sourceInfo, 0, len(note.Sources)) + for _, src := range note.Sources { + info.Sources = append(info.Sources, sourceInfo{ + Source: src.Source, + SourceID: src.SourceID, + }) + } + } + + return info +} diff --git a/internal/mcp/resources/person/handler.go b/internal/mcp/resources/person/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..39ee6af0c0f499af36d6c6e393065aa97da09d1f --- /dev/null +++ b/internal/mcp/resources/person/handler.go @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package person provides the MCP resource handler for individual Lunatask people. +package person + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "git.secluded.site/go-lunatask" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ResourceTemplate is the URI template for person resources. +const ResourceTemplate = "lunatask://person/{id}" + +// ResourceDescription describes the person resource for LLMs. +const ResourceDescription = `Reads metadata for a specific Lunatask person by ID or deep link. + +Due to end-to-end encryption, person name is not available. +Returns metadata including relationship strength and sources.` + +// sourceInfo represents a source reference in the response. +type sourceInfo struct { + Source string `json:"source"` + SourceID string `json:"source_id"` +} + +// personInfo represents person metadata in the resource response. +type personInfo struct { + DeepLink string `json:"deep_link"` + RelationshipStrength *string `json:"relationship_strength,omitempty"` + Sources []sourceInfo `json:"sources,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Handler handles person resource requests. +type Handler struct { + client *lunatask.Client +} + +// NewHandler creates a new person resource handler. +func NewHandler(accessToken string) *Handler { + return &Handler{ + client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")), + } +} + +// HandleRead returns metadata for a specific person. +func (h *Handler) HandleRead( + ctx context.Context, + req *mcp.ReadResourceRequest, +) (*mcp.ReadResourceResult, error) { + _, id, err := lunatask.ParseReference(req.Params.URI) + if err != nil { + return nil, fmt.Errorf("invalid URI %q: %w", req.Params.URI, mcp.ResourceNotFoundError(req.Params.URI)) + } + + person, err := h.client.GetPerson(ctx, id) + if err != nil { + return nil, fmt.Errorf("fetching person: %w", err) + } + + info := buildPersonInfo(person) + + data, err := json.MarshalIndent(info, "", " ") + if err != nil { + return nil, fmt.Errorf("marshaling person: %w", err) + } + + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: req.Params.URI, + MIMEType: "application/json", + Text: string(data), + }}, + }, nil +} + +func buildPersonInfo(person *lunatask.Person) personInfo { + info := personInfo{ + CreatedAt: person.CreatedAt.Format(time.RFC3339), + UpdatedAt: person.UpdatedAt.Format(time.RFC3339), + } + + info.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID) + + if person.RelationshipStrength != nil { + s := string(*person.RelationshipStrength) + info.RelationshipStrength = &s + } + + if len(person.Sources) > 0 { + info.Sources = make([]sourceInfo, 0, len(person.Sources)) + for _, src := range person.Sources { + info.Sources = append(info.Sources, sourceInfo{ + Source: src.Source, + SourceID: src.SourceID, + }) + } + } + + return info +} diff --git a/internal/mcp/resources/task/handler.go b/internal/mcp/resources/task/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..42e2fe09b779e2e62f59306bbabbde74d0ef8fc7 --- /dev/null +++ b/internal/mcp/resources/task/handler.go @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package task provides the MCP resource handler for individual Lunatask tasks. +package task + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "git.secluded.site/go-lunatask" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ResourceTemplate is the URI template for task resources. +const ResourceTemplate = "lunatask://task/{id}" + +// ResourceDescription describes the task resource for LLMs. +const ResourceDescription = `Reads metadata for a specific Lunatask task by ID or deep link. + +Due to end-to-end encryption, task name and note content are not available. +Returns metadata including status, priority, dates, area, and goal. + +Use list_tasks tool to discover task IDs, then read individual tasks here.` + +// taskInfo represents task metadata in the resource response. +type taskInfo struct { + DeepLink string `json:"deep_link"` + Status *string `json:"status,omitempty"` + Priority *int `json:"priority,omitempty"` + Estimate *int `json:"estimate,omitempty"` + ScheduledOn *string `json:"scheduled_on,omitempty"` + CompletedAt *string `json:"completed_at,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + AreaID *string `json:"area_id,omitempty"` + GoalID *string `json:"goal_id,omitempty"` + Important *bool `json:"important,omitempty"` + Urgent *bool `json:"urgent,omitempty"` +} + +// Handler handles task resource requests. +type Handler struct { + client *lunatask.Client +} + +// NewHandler creates a new task resource handler. +func NewHandler(accessToken string) *Handler { + return &Handler{ + client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")), + } +} + +// HandleRead returns metadata for a specific task. +func (h *Handler) HandleRead( + ctx context.Context, + req *mcp.ReadResourceRequest, +) (*mcp.ReadResourceResult, error) { + _, id, err := lunatask.ParseReference(req.Params.URI) + if err != nil { + return nil, fmt.Errorf("invalid URI %q: %w", req.Params.URI, mcp.ResourceNotFoundError(req.Params.URI)) + } + + task, err := h.client.GetTask(ctx, id) + if err != nil { + return nil, fmt.Errorf("fetching task: %w", err) + } + + info := buildTaskInfo(task) + + data, err := json.MarshalIndent(info, "", " ") + if err != nil { + return nil, fmt.Errorf("marshaling task: %w", err) + } + + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: req.Params.URI, + MIMEType: "application/json", + Text: string(data), + }}, + }, nil +} + +func buildTaskInfo(task *lunatask.Task) taskInfo { + info := taskInfo{ + CreatedAt: task.CreatedAt.Format(time.RFC3339), + UpdatedAt: task.UpdatedAt.Format(time.RFC3339), + AreaID: task.AreaID, + GoalID: task.GoalID, + } + + info.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID) + + if task.Status != nil { + s := string(*task.Status) + info.Status = &s + } + + if task.Priority != nil { + p := int(*task.Priority) + info.Priority = &p + } + + if task.Estimate != nil { + info.Estimate = task.Estimate + } + + if task.ScheduledOn != nil { + s := task.ScheduledOn.Format("2006-01-02") + info.ScheduledOn = &s + } + + if task.CompletedAt != nil { + s := task.CompletedAt.Format(time.RFC3339) + info.CompletedAt = &s + } + + if task.Eisenhower != nil { + important := task.Eisenhower.IsImportant() + urgent := task.Eisenhower.IsUrgent() + info.Important = &important + info.Urgent = &urgent + } + + return info +}