feat(mcp): add lunatask:// resource templates

Amolith created

Add MCP resource templates for reading individual resources:
- lunatask://task/{id} - task metadata
- lunatask://note/{id} - note metadata
- lunatask://person/{id} - person metadata

LLMs can now ReadResource using the same URI format as deep links.

Assisted-by: Claude Sonnet 4 via Crush

Change summary

cmd/mcp/server.go                        |  30 ++++++
internal/mcp/resources/note/handler.go   | 113 ++++++++++++++++++++++
internal/mcp/resources/person/handler.go | 109 +++++++++++++++++++++
internal/mcp/resources/task/handler.go   | 130 ++++++++++++++++++++++++++
4 files changed, 382 insertions(+)

Detailed changes

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,

internal/mcp/resources/note/handler.go 🔗

@@ -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
+}

internal/mcp/resources/person/handler.go 🔗

@@ -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
+}

internal/mcp/resources/task/handler.go 🔗

@@ -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
+}