Detailed changes
@@ -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,
@@ -0,0 +1,113 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}
@@ -0,0 +1,109 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}
@@ -0,0 +1,130 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}