feat(mcp): add MCP server mode

Amolith created

Adds `lune mcp -t [stdio|sse|http]` to run lune as an MCP server,
replacing the separate lunatask-mcp-server project.

Tools:
- get_timestamp: parse natural language dates
- create_task, update_task, delete_task, list_tasks, show_task
- track_habit

Resources:
- lunatask://areas (areas and goals from config)
- lunatask://habits (habits from config)
- lunatask://notebooks (notebooks from config)

Config:
- [mcp] section with host, port, timezone
- [mcp.tools] section to enable/disable individual tools

Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>

Change summary

cmd/mcp/mcp.go                              | 131 +++++++++++++++
cmd/mcp/server.go                           | 194 +++++++++++++++++++++++
cmd/root.go                                 |   3 
go.mod                                      |   4 
go.sum                                      |  14 +
internal/config/config.go                   | 127 +++++++++++++++
internal/mcp/resources/areas/handler.go     |  93 +++++++++++
internal/mcp/resources/habits/handler.go    |  74 ++++++++
internal/mcp/resources/notebooks/handler.go |  74 ++++++++
internal/mcp/shared/errors.go               |  42 ++++
internal/mcp/shared/types.go                |  84 +++++++++
internal/mcp/tools/habit/track.go           |  94 +++++++++++
internal/mcp/tools/task/create.go           | 182 +++++++++++++++++++++
internal/mcp/tools/task/delete.go           |  55 ++++++
internal/mcp/tools/task/list.go             | 162 +++++++++++++++++++
internal/mcp/tools/task/show.go             | 108 ++++++++++++
internal/mcp/tools/task/update.go           | 193 ++++++++++++++++++++++
internal/mcp/tools/timestamp/handler.go     |  81 +++++++++
18 files changed, 1,715 insertions(+)

Detailed changes

cmd/mcp/mcp.go 🔗

@@ -0,0 +1,131 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package mcp provides the MCP server command for lune.
+package mcp
+
+import (
+	"errors"
+	"fmt"
+
+	"git.secluded.site/lune/internal/client"
+	"git.secluded.site/lune/internal/config"
+	"github.com/spf13/cobra"
+)
+
+// Transport constants.
+const (
+	TransportStdio = "stdio"
+	TransportSSE   = "sse"
+	TransportHTTP  = "http"
+)
+
+var (
+	errUnknownTransport = errors.New("unknown transport; use stdio, sse, or http")
+	errNoToken          = errors.New("no access token; run 'lune init' first")
+)
+
+var (
+	transport string
+	host      string
+	port      int
+)
+
+// Cmd is the mcp command for starting the MCP server.
+var Cmd = &cobra.Command{
+	Use:   "mcp",
+	Short: "Start the MCP server",
+	Long: `Start a Model Context Protocol server for LLM tool integration.
+
+The MCP server exposes Lunatask resources and tools that can be used by
+LLM assistants (like Claude) to interact with your Lunatask data.
+
+Transports:
+  stdio  - Standard input/output (default, for local integrations)
+  sse    - Server-sent events over HTTP
+  http   - Streamable HTTP
+
+Examples:
+  lune mcp                    # Start with stdio (default)
+  lune mcp -t sse             # Start SSE server on configured host:port
+  lune mcp -t sse --port 9000 # Override port`,
+	RunE: runMCP,
+}
+
+func init() {
+	Cmd.Flags().StringVarP(&transport, "transport", "t", "",
+		"Transport type: stdio, sse, http (default: stdio or config)")
+	Cmd.Flags().StringVar(&host, "host", "", "Server host (for sse/http)")
+	Cmd.Flags().IntVar(&port, "port", 0, "Server port (for sse/http)")
+}
+
+func runMCP(cmd *cobra.Command, _ []string) error {
+	cfg, err := loadConfig()
+	if err != nil {
+		return err
+	}
+
+	token, err := client.GetToken()
+	if err != nil {
+		return fmt.Errorf("getting access token: %w", err)
+	}
+
+	if token == "" {
+		return errNoToken
+	}
+
+	mcpServer := newMCPServer(cfg, token)
+
+	effectiveTransport := resolveTransport(cfg)
+
+	switch effectiveTransport {
+	case TransportStdio:
+		return runStdio(mcpServer)
+	case TransportSSE:
+		return runSSE(cmd, mcpServer, cfg)
+	case TransportHTTP:
+		return runHTTP(cmd, mcpServer, cfg)
+	default:
+		return errUnknownTransport
+	}
+}
+
+func loadConfig() (*config.Config, error) {
+	cfg, err := config.Load()
+	if err != nil {
+		if errors.Is(err, config.ErrNotFound) {
+			cfg = &config.Config{}
+		} else {
+			return nil, fmt.Errorf("loading config: %w", err)
+		}
+	}
+
+	cfg.MCP.MCPDefaults()
+
+	return cfg, nil
+}
+
+func resolveTransport(_ *config.Config) string {
+	if transport != "" {
+		return transport
+	}
+
+	return TransportStdio
+}
+
+func resolveHost(cfg *config.Config) string {
+	if host != "" {
+		return host
+	}
+
+	return cfg.MCP.Host
+}
+
+func resolvePort(cfg *config.Config) int {
+	if port != 0 {
+		return port
+	}
+
+	return cfg.MCP.Port
+}

cmd/mcp/server.go 🔗

@@ -0,0 +1,194 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package mcp
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"net/http"
+	"strconv"
+
+	"git.secluded.site/lune/internal/config"
+	"git.secluded.site/lune/internal/mcp/resources/areas"
+	"git.secluded.site/lune/internal/mcp/resources/habits"
+	"git.secluded.site/lune/internal/mcp/resources/notebooks"
+	"git.secluded.site/lune/internal/mcp/shared"
+	"git.secluded.site/lune/internal/mcp/tools/habit"
+	"git.secluded.site/lune/internal/mcp/tools/task"
+	"git.secluded.site/lune/internal/mcp/tools/timestamp"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+	"github.com/spf13/cobra"
+)
+
+var version = "dev"
+
+func newMCPServer(cfg *config.Config, accessToken string) *mcp.Server {
+	mcpServer := mcp.NewServer(
+		&mcp.Implementation{
+			Name:    "lune",
+			Version: version,
+		},
+		nil,
+	)
+
+	areaProviders := toAreaProviders(cfg.Areas)
+	habitProviders := shared.ToHabitProviders(cfg.Habits)
+	notebookProviders := shared.ToNotebookProviders(cfg.Notebooks)
+
+	registerResources(mcpServer, areaProviders, habitProviders, notebookProviders)
+	registerTools(mcpServer, cfg, accessToken, areaProviders, habitProviders)
+
+	return mcpServer
+}
+
+func toAreaProviders(cfgAreas []config.Area) []shared.AreaProvider {
+	providers := make([]shared.AreaProvider, 0, len(cfgAreas))
+
+	for _, area := range cfgAreas {
+		providers = append(providers, shared.AreaProvider{
+			ID:    area.ID,
+			Name:  area.Name,
+			Key:   area.Key,
+			Goals: shared.ToGoalProviders(area.Goals),
+		})
+	}
+
+	return providers
+}
+
+func registerResources(
+	mcpServer *mcp.Server,
+	areaProviders []shared.AreaProvider,
+	habitProviders []shared.HabitProvider,
+	notebookProviders []shared.NotebookProvider,
+) {
+	areasHandler := areas.NewHandler(areaProviders)
+	mcpServer.AddResource(&mcp.Resource{
+		Name:        "areas",
+		URI:         areas.ResourceURI,
+		Description: areas.ResourceDescription,
+		MIMEType:    "application/json",
+	}, areasHandler.HandleRead)
+
+	habitsHandler := habits.NewHandler(habitProviders)
+	mcpServer.AddResource(&mcp.Resource{
+		Name:        "habits",
+		URI:         habits.ResourceURI,
+		Description: habits.ResourceDescription,
+		MIMEType:    "application/json",
+	}, habitsHandler.HandleRead)
+
+	notebooksHandler := notebooks.NewHandler(notebookProviders)
+	mcpServer.AddResource(&mcp.Resource{
+		Name:        "notebooks",
+		URI:         notebooks.ResourceURI,
+		Description: notebooks.ResourceDescription,
+		MIMEType:    "application/json",
+	}, notebooksHandler.HandleRead)
+}
+
+func registerTools(
+	mcpServer *mcp.Server,
+	cfg *config.Config,
+	accessToken string,
+	areaProviders []shared.AreaProvider,
+	habitProviders []shared.HabitProvider,
+) {
+	tools := &cfg.MCP.Tools
+
+	if tools.GetTimestamp {
+		tsHandler := timestamp.NewHandler(cfg.MCP.Timezone)
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        timestamp.ToolName,
+			Description: timestamp.ToolDescription,
+		}, tsHandler.Handle)
+	}
+
+	taskHandler := task.NewHandler(accessToken, areaProviders)
+
+	if tools.CreateTask {
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        task.CreateToolName,
+			Description: task.CreateToolDescription,
+		}, taskHandler.HandleCreate)
+	}
+
+	if tools.UpdateTask {
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        task.UpdateToolName,
+			Description: task.UpdateToolDescription,
+		}, taskHandler.HandleUpdate)
+	}
+
+	if tools.DeleteTask {
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        task.DeleteToolName,
+			Description: task.DeleteToolDescription,
+		}, taskHandler.HandleDelete)
+	}
+
+	if tools.ListTasks {
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        task.ListToolName,
+			Description: task.ListToolDescription,
+		}, taskHandler.HandleList)
+	}
+
+	if tools.ShowTask {
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        task.ShowToolName,
+			Description: task.ShowToolDescription,
+		}, taskHandler.HandleShow)
+	}
+
+	if tools.TrackHabit {
+		habitHandler := habit.NewHandler(accessToken, habitProviders)
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        habit.TrackToolName,
+			Description: habit.TrackToolDescription,
+		}, habitHandler.HandleTrack)
+	}
+}
+
+func runStdio(mcpServer *mcp.Server) error {
+	if err := mcpServer.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
+		return fmt.Errorf("stdio server error: %w", err)
+	}
+
+	return nil
+}
+
+func runSSE(cmd *cobra.Command, mcpServer *mcp.Server, cfg *config.Config) error {
+	hostPort := net.JoinHostPort(resolveHost(cfg), strconv.Itoa(resolvePort(cfg)))
+	handler := mcp.NewSSEHandler(func(_ *http.Request) *mcp.Server {
+		return mcpServer
+	}, nil)
+
+	fmt.Fprintf(cmd.OutOrStdout(), "SSE server listening on %s\n", hostPort)
+
+	//nolint:gosec // MCP SDK controls server lifecycle; timeouts not applicable
+	if err := http.ListenAndServe(hostPort, handler); err != nil {
+		return fmt.Errorf("SSE server error: %w", err)
+	}
+
+	return nil
+}
+
+func runHTTP(cmd *cobra.Command, mcpServer *mcp.Server, cfg *config.Config) error {
+	hostPort := net.JoinHostPort(resolveHost(cfg), strconv.Itoa(resolvePort(cfg)))
+	handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server {
+		return mcpServer
+	}, nil)
+
+	fmt.Fprintf(cmd.OutOrStdout(), "HTTP server listening on %s\n", hostPort)
+
+	//nolint:gosec // MCP SDK controls server lifecycle; timeouts not applicable
+	if err := http.ListenAndServe(hostPort, handler); err != nil {
+		return fmt.Errorf("HTTP server error: %w", err)
+	}
+
+	return nil
+}

cmd/root.go 🔗

@@ -14,6 +14,7 @@ import (
 	"git.secluded.site/lune/cmd/habit"
 	initcmd "git.secluded.site/lune/cmd/init"
 	"git.secluded.site/lune/cmd/journal"
+	"git.secluded.site/lune/cmd/mcp"
 	"git.secluded.site/lune/cmd/note"
 	"git.secluded.site/lune/cmd/person"
 	"git.secluded.site/lune/cmd/task"
@@ -60,6 +61,7 @@ func init() {
 
 	rootCmd.AddCommand(initcmd.Cmd)
 	rootCmd.AddCommand(pingCmd)
+	rootCmd.AddCommand(mcp.Cmd)
 
 	rootCmd.AddCommand(area.Cmd)
 	rootCmd.AddCommand(goal.Cmd)
@@ -77,6 +79,7 @@ func init() {
 // Execute runs the root command with Fang styling.
 func Execute(ctx context.Context, v string) error {
 	version = v
+
 	return fang.Execute(
 		ctx,
 		rootCmd,

go.mod 🔗

@@ -16,6 +16,7 @@ require (
 	github.com/klauspost/lctime v0.1.0
 	github.com/markusmobius/go-dateparser v1.2.4
 	github.com/mattn/go-isatty v0.0.20
+	github.com/modelcontextprotocol/go-sdk v1.2.0
 	github.com/spf13/cobra v1.10.2
 	github.com/zalando/go-keyring v0.2.6
 )
@@ -44,6 +45,7 @@ require (
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 	github.com/godbus/dbus/v5 v5.1.0 // indirect
+	github.com/google/jsonschema-go v0.3.0 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/hablullah/go-hijri v1.0.2 // indirect
 	github.com/hablullah/go-juliandays v1.0.0 // indirect
@@ -66,6 +68,8 @@ require (
 	github.com/tetratelabs/wazero v1.2.1 // indirect
 	github.com/wasilibs/go-re2 v1.3.0 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+	golang.org/x/oauth2 v0.30.0 // indirect
 	golang.org/x/sync v0.19.0 // indirect
 	golang.org/x/sys v0.39.0 // indirect
 	golang.org/x/text v0.32.0 // indirect

go.sum 🔗

@@ -73,6 +73,12 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
 github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
 github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
+github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -101,6 +107,8 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF
 github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
 github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
 github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
+github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
+github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -137,11 +145,15 @@ github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2e
 github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
 github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
 github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
 golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
 golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -150,6 +162,8 @@ golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
 golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
 golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/config/config.go 🔗

@@ -21,11 +21,100 @@ var ErrNotFound = errors.New("config file not found")
 type Config struct {
 	UI        UIConfig   `toml:"ui"`
 	Defaults  Defaults   `toml:"defaults"`
+	MCP       MCPConfig  `toml:"mcp"`
 	Areas     []Area     `toml:"areas"`
 	Notebooks []Notebook `toml:"notebooks"`
 	Habits    []Habit    `toml:"habits"`
 }
 
+// MCPConfig holds MCP server settings.
+type MCPConfig struct {
+	Host     string      `toml:"host"`
+	Port     int         `toml:"port"`
+	Timezone string      `toml:"timezone"`
+	Tools    ToolsConfig `toml:"tools"`
+}
+
+// ToolsConfig controls which MCP tools are enabled.
+// All tools default to enabled when not explicitly set.
+type ToolsConfig struct {
+	GetTimestamp bool `toml:"get_timestamp"`
+
+	CreateTask bool `toml:"create_task"`
+	UpdateTask bool `toml:"update_task"`
+	DeleteTask bool `toml:"delete_task"`
+	ListTasks  bool `toml:"list_tasks"`
+	ShowTask   bool `toml:"show_task"`
+
+	CreateNote bool `toml:"create_note"`
+	UpdateNote bool `toml:"update_note"`
+	DeleteNote bool `toml:"delete_note"`
+	ListNotes  bool `toml:"list_notes"`
+	ShowNote   bool `toml:"show_note"`
+
+	CreatePerson   bool `toml:"create_person"`
+	UpdatePerson   bool `toml:"update_person"`
+	DeletePerson   bool `toml:"delete_person"`
+	ListPeople     bool `toml:"list_people"`
+	ShowPerson     bool `toml:"show_person"`
+	PersonTimeline bool `toml:"person_timeline"`
+
+	TrackHabit bool `toml:"track_habit"`
+	ListHabits bool `toml:"list_habits"`
+
+	CreateJournal bool `toml:"create_journal"`
+}
+
+// MCPDefaults applies default values to MCP config.
+func (c *MCPConfig) MCPDefaults() {
+	if c.Host == "" {
+		c.Host = "localhost"
+	}
+
+	if c.Port == 0 {
+		c.Port = 8080
+	}
+
+	if c.Timezone == "" {
+		c.Timezone = "UTC"
+	}
+
+	c.Tools.ApplyDefaults()
+}
+
+// ApplyDefaults enables all tools if none are explicitly configured.
+//
+//nolint:cyclop // Complexity from repetitive boolean checks; structurally simple.
+func (t *ToolsConfig) ApplyDefaults() {
+	// If all are false (zero value), enable everything
+	if !t.GetTimestamp && !t.CreateTask && !t.UpdateTask && !t.DeleteTask &&
+		!t.ListTasks && !t.ShowTask && !t.CreateNote && !t.UpdateNote &&
+		!t.DeleteNote && !t.ListNotes && !t.ShowNote && !t.CreatePerson &&
+		!t.UpdatePerson && !t.DeletePerson && !t.ListPeople && !t.ShowPerson &&
+		!t.PersonTimeline && !t.TrackHabit && !t.ListHabits && !t.CreateJournal {
+		t.GetTimestamp = true
+		t.CreateTask = true
+		t.UpdateTask = true
+		t.DeleteTask = true
+		t.ListTasks = true
+		t.ShowTask = true
+		t.CreateNote = true
+		t.UpdateNote = true
+		t.DeleteNote = true
+		t.ListNotes = true
+		t.ShowNote = true
+		t.CreatePerson = true
+		t.UpdatePerson = true
+		t.DeletePerson = true
+		t.ListPeople = true
+		t.ShowPerson = true
+		t.PersonTimeline = true
+		t.TrackHabit = true
+		t.ListHabits = true
+		t.CreateJournal = true
+	}
+}
+
 // UIConfig holds user interface preferences.
 type UIConfig struct {
 	Color string `toml:"color"` // "always", "never", "auto"
@@ -38,6 +127,8 @@ type Defaults struct {
 }
 
 // Area represents a Lunatask area of life with its goals.
+//
+//nolint:recvcheck // Value receivers for Keyed interface; pointer receiver for GoalByKey is intentional.
 type Area struct {
 	ID    string `json:"id"    toml:"id"`
 	Name  string `json:"name"  toml:"name"`
@@ -66,6 +157,42 @@ type Habit struct {
 	Key  string `json:"key"  toml:"key"`
 }
 
+// GetID returns the area ID.
+func (a Area) GetID() string { return a.ID }
+
+// GetName returns the area name.
+func (a Area) GetName() string { return a.Name }
+
+// GetKey returns the area key.
+func (a Area) GetKey() string { return a.Key }
+
+// GetID returns the goal ID.
+func (g Goal) GetID() string { return g.ID }
+
+// GetName returns the goal name.
+func (g Goal) GetName() string { return g.Name }
+
+// GetKey returns the goal key.
+func (g Goal) GetKey() string { return g.Key }
+
+// GetID returns the habit ID.
+func (h Habit) GetID() string { return h.ID }
+
+// GetName returns the habit name.
+func (h Habit) GetName() string { return h.Name }
+
+// GetKey returns the habit key.
+func (h Habit) GetKey() string { return h.Key }
+
+// GetID returns the notebook ID.
+func (n Notebook) GetID() string { return n.ID }
+
+// GetName returns the notebook name.
+func (n Notebook) GetName() string { return n.Name }
+
+// GetKey returns the notebook key.
+func (n Notebook) GetKey() string { return n.Key }
+
 // Path returns the path to the config file.
 func Path() (string, error) {
 	configDir, err := os.UserConfigDir()

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

@@ -0,0 +1,93 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package areas provides the MCP resource handler for Lunatask areas and goals.
+package areas
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"git.secluded.site/lune/internal/mcp/shared"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// ResourceURI is the URI for the areas resource.
+const ResourceURI = "lunatask://areas"
+
+// ResourceDescription describes the areas resource for LLMs.
+const ResourceDescription = `Lists all configured Lunatask areas and their goals.
+
+Each area represents a life domain (e.g., Work, Personal, Health) and contains:
+- id: UUID to use when creating tasks in this area
+- name: Human-readable area name
+- key: Short alias for CLI usage
+- goals: List of goals within the area, each with id, name, and key
+
+Use this resource to discover valid area and goal IDs before creating or updating tasks.`
+
+// Handler handles area resource requests.
+type Handler struct {
+	areas []shared.AreaProvider
+}
+
+// NewHandler creates a new areas resource handler.
+func NewHandler(areas []shared.AreaProvider) *Handler {
+	return &Handler{areas: areas}
+}
+
+// areaInfo represents an area in the resource response.
+type areaInfo struct {
+	ID    string     `json:"id"`
+	Name  string     `json:"name"`
+	Key   string     `json:"key"`
+	Goals []goalInfo `json:"goals"`
+}
+
+// goalInfo represents a goal in the resource response.
+type goalInfo struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+	Key  string `json:"key"`
+}
+
+// HandleRead returns the configured areas and goals.
+func (h *Handler) HandleRead(
+	_ context.Context,
+	_ *mcp.ReadResourceRequest,
+) (*mcp.ReadResourceResult, error) {
+	areasInfo := make([]areaInfo, 0, len(h.areas))
+
+	for _, area := range h.areas {
+		goals := make([]goalInfo, 0, len(area.Goals))
+		for _, g := range area.Goals {
+			goals = append(goals, goalInfo{
+				ID:   g.ID,
+				Name: g.Name,
+				Key:  g.Key,
+			})
+		}
+
+		areasInfo = append(areasInfo, areaInfo{
+			ID:    area.ID,
+			Name:  area.Name,
+			Key:   area.Key,
+			Goals: goals,
+		})
+	}
+
+	data, err := json.MarshalIndent(areasInfo, "", "  ")
+	if err != nil {
+		return nil, fmt.Errorf("marshaling areas: %w", err)
+	}
+
+	return &mcp.ReadResourceResult{
+		Contents: []*mcp.ResourceContents{{
+			URI:      ResourceURI,
+			MIMEType: "application/json",
+			Text:     string(data),
+		}},
+	}, nil
+}

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

@@ -0,0 +1,74 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package habits provides the MCP resource handler for Lunatask habits.
+package habits
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"git.secluded.site/lune/internal/mcp/shared"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// ResourceURI is the URI for the habits resource.
+const ResourceURI = "lunatask://habits"
+
+// ResourceDescription describes the habits resource for LLMs.
+const ResourceDescription = `Lists all configured Lunatask habits.
+
+Each habit contains:
+- id: UUID to use when tracking habit completion
+- name: Human-readable habit name
+- key: Short alias for CLI usage
+
+Use this resource to discover valid habit IDs before tracking habit activities.`
+
+// Handler handles habit resource requests.
+type Handler struct {
+	habits []shared.HabitProvider
+}
+
+// NewHandler creates a new habits resource handler.
+func NewHandler(habits []shared.HabitProvider) *Handler {
+	return &Handler{habits: habits}
+}
+
+// habitInfo represents a habit in the resource response.
+type habitInfo struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+	Key  string `json:"key"`
+}
+
+// HandleRead returns the configured habits.
+func (h *Handler) HandleRead(
+	_ context.Context,
+	_ *mcp.ReadResourceRequest,
+) (*mcp.ReadResourceResult, error) {
+	habitsInfo := make([]habitInfo, 0, len(h.habits))
+
+	for _, hab := range h.habits {
+		habitsInfo = append(habitsInfo, habitInfo{
+			ID:   hab.ID,
+			Name: hab.Name,
+			Key:  hab.Key,
+		})
+	}
+
+	data, err := json.MarshalIndent(habitsInfo, "", "  ")
+	if err != nil {
+		return nil, fmt.Errorf("marshaling habits: %w", err)
+	}
+
+	return &mcp.ReadResourceResult{
+		Contents: []*mcp.ResourceContents{{
+			URI:      ResourceURI,
+			MIMEType: "application/json",
+			Text:     string(data),
+		}},
+	}, nil
+}

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

@@ -0,0 +1,74 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package notebooks provides the MCP resource handler for Lunatask notebooks.
+package notebooks
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"git.secluded.site/lune/internal/mcp/shared"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// ResourceURI is the URI for the notebooks resource.
+const ResourceURI = "lunatask://notebooks"
+
+// ResourceDescription describes the notebooks resource for LLMs.
+const ResourceDescription = `Lists all configured Lunatask notebooks.
+
+Each notebook contains:
+- id: UUID to use when creating notes in this notebook
+- name: Human-readable notebook name
+- key: Short alias for CLI usage
+
+Use this resource to discover valid notebook IDs before creating or updating notes.`
+
+// Handler handles notebook resource requests.
+type Handler struct {
+	notebooks []shared.NotebookProvider
+}
+
+// NewHandler creates a new notebooks resource handler.
+func NewHandler(notebooks []shared.NotebookProvider) *Handler {
+	return &Handler{notebooks: notebooks}
+}
+
+// notebookInfo represents a notebook in the resource response.
+type notebookInfo struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+	Key  string `json:"key"`
+}
+
+// HandleRead returns the configured notebooks.
+func (h *Handler) HandleRead(
+	_ context.Context,
+	_ *mcp.ReadResourceRequest,
+) (*mcp.ReadResourceResult, error) {
+	notebooksInfo := make([]notebookInfo, 0, len(h.notebooks))
+
+	for _, nb := range h.notebooks {
+		notebooksInfo = append(notebooksInfo, notebookInfo{
+			ID:   nb.ID,
+			Name: nb.Name,
+			Key:  nb.Key,
+		})
+	}
+
+	data, err := json.MarshalIndent(notebooksInfo, "", "  ")
+	if err != nil {
+		return nil, fmt.Errorf("marshaling notebooks: %w", err)
+	}
+
+	return &mcp.ReadResourceResult{
+		Contents: []*mcp.ResourceContents{{
+			URI:      ResourceURI,
+			MIMEType: "application/json",
+			Text:     string(data),
+		}},
+	}, nil
+}

internal/mcp/shared/errors.go 🔗

@@ -0,0 +1,42 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package shared
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// ErrorResult creates an MCP CallToolResult indicating an error.
+// Use this for user-facing errors (validation failures, API errors, etc.).
+// The Go error return should remain nil per MCP SDK conventions.
+func ErrorResult(msg string) *mcp.CallToolResult {
+	return &mcp.CallToolResult{
+		IsError: true,
+		Content: []mcp.Content{
+			&mcp.TextContent{Text: msg},
+		},
+	}
+}
+
+// Estimate constraints.
+const (
+	MinEstimate = 0
+	MaxEstimate = 720
+)
+
+// ErrInvalidEstimate indicates the estimate is out of range.
+var ErrInvalidEstimate = errors.New("estimate must be between 0 and 720 minutes")
+
+// ValidateEstimate checks that an estimate is within the valid range (0-720 minutes).
+func ValidateEstimate(estimate int) error {
+	if estimate < MinEstimate || estimate > MaxEstimate {
+		return fmt.Errorf("%w: got %d", ErrInvalidEstimate, estimate)
+	}
+
+	return nil
+}

internal/mcp/shared/types.go 🔗

@@ -0,0 +1,84 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package shared provides common types and utilities for MCP resources and tools.
+package shared
+
+// Keyed is an interface for types that have ID, Name, and Key fields.
+type Keyed interface {
+	GetID() string
+	GetName() string
+	GetKey() string
+}
+
+// AreaProvider represents an area with its goals for MCP resources.
+type AreaProvider struct {
+	ID    string
+	Name  string
+	Key   string
+	Goals []GoalProvider
+}
+
+// GoalProvider represents a goal within an area.
+type GoalProvider struct {
+	ID   string
+	Name string
+	Key  string
+}
+
+// HabitProvider represents a habit for MCP resources.
+type HabitProvider struct {
+	ID   string
+	Name string
+	Key  string
+}
+
+// NotebookProvider represents a notebook for MCP resources.
+type NotebookProvider struct {
+	ID   string
+	Name string
+	Key  string
+}
+
+// ToHabitProviders converts a slice of Keyed items to HabitProviders.
+func ToHabitProviders[T Keyed](items []T) []HabitProvider {
+	providers := make([]HabitProvider, 0, len(items))
+	for _, item := range items {
+		providers = append(providers, HabitProvider{
+			ID:   item.GetID(),
+			Name: item.GetName(),
+			Key:  item.GetKey(),
+		})
+	}
+
+	return providers
+}
+
+// ToNotebookProviders converts a slice of Keyed items to NotebookProviders.
+func ToNotebookProviders[T Keyed](items []T) []NotebookProvider {
+	providers := make([]NotebookProvider, 0, len(items))
+	for _, item := range items {
+		providers = append(providers, NotebookProvider{
+			ID:   item.GetID(),
+			Name: item.GetName(),
+			Key:  item.GetKey(),
+		})
+	}
+
+	return providers
+}
+
+// ToGoalProviders converts a slice of Keyed items to GoalProviders.
+func ToGoalProviders[T Keyed](items []T) []GoalProvider {
+	providers := make([]GoalProvider, 0, len(items))
+	for _, item := range items {
+		providers = append(providers, GoalProvider{
+			ID:   item.GetID(),
+			Name: item.GetName(),
+			Key:  item.GetKey(),
+		})
+	}
+
+	return providers
+}

internal/mcp/tools/habit/track.go 🔗

@@ -0,0 +1,94 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package habit provides MCP tools for Lunatask habit operations.
+package habit
+
+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"
+)
+
+// TrackToolName is the name of the track habit tool.
+const TrackToolName = "track_habit"
+
+// TrackToolDescription describes the track habit tool for LLMs.
+const TrackToolDescription = `Records that a habit was performed on a specific date.
+
+Required:
+- habit_id: Habit UUID (get from lunatask://habits resource)
+
+Optional:
+- performed_on: Date performed (YYYY-MM-DD or natural language, default: today)
+
+Use the lunatask://habits resource to discover valid habit IDs.`
+
+// TrackInput is the input schema for tracking a habit.
+type TrackInput struct {
+	HabitID     string  `json:"habit_id"               jsonschema:"required"`
+	PerformedOn *string `json:"performed_on,omitempty"`
+}
+
+// TrackOutput is the output schema for tracking a habit.
+type TrackOutput struct {
+	Success     bool   `json:"success"`
+	HabitID     string `json:"habit_id"`
+	PerformedOn string `json:"performed_on"`
+}
+
+// Handler handles habit-related MCP tool requests.
+type Handler struct {
+	client *lunatask.Client
+	habits []shared.HabitProvider
+}
+
+// NewHandler creates a new habit handler.
+func NewHandler(accessToken string, habits []shared.HabitProvider) *Handler {
+	return &Handler{
+		client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")),
+		habits: habits,
+	}
+}
+
+// HandleTrack records a habit activity.
+//
+//nolint:nilerr // MCP returns errors in result, not Go error.
+func (h *Handler) HandleTrack(
+	ctx context.Context,
+	_ *mcp.CallToolRequest,
+	input TrackInput,
+) (*mcp.CallToolResult, TrackOutput, error) {
+	if err := lunatask.ValidateUUID(input.HabitID); err != nil {
+		return shared.ErrorResult("invalid habit_id: expected UUID"), TrackOutput{}, nil
+	}
+
+	dateStr := ""
+	if input.PerformedOn != nil {
+		dateStr = *input.PerformedOn
+	}
+
+	performedOn, err := dateutil.Parse(dateStr)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), TrackOutput{}, nil
+	}
+
+	req := &lunatask.TrackHabitActivityRequest{
+		PerformedOn: performedOn,
+	}
+
+	_, err = h.client.TrackHabitActivity(ctx, input.HabitID, req)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), TrackOutput{}, nil
+	}
+
+	return nil, TrackOutput{
+		Success:     true,
+		HabitID:     input.HabitID,
+		PerformedOn: performedOn.Format("2006-01-02"),
+	}, nil
+}

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

@@ -0,0 +1,182 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package task provides MCP tools for Lunatask task operations.
+package task
+
+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"
+)
+
+// CreateToolName is the name of the create task tool.
+const CreateToolName = "create_task"
+
+// CreateToolDescription describes the create task tool for LLMs.
+const CreateToolDescription = `Creates a new task in Lunatask.
+
+Required:
+- name: Task title
+
+Optional:
+- area_id: Area UUID (get from lunatask://areas resource)
+- goal_id: Goal UUID (requires area_id; get from lunatask://areas resource)
+- status: later, next, started, waiting (default: later)
+- note: Markdown note/description for the task
+- priority: lowest, low, normal, high, highest
+- estimate: Time estimate in minutes (0-720)
+- motivation: must, should, want
+- important: true/false for Eisenhower matrix
+- urgent: true/false for Eisenhower matrix
+- scheduled_on: Date to schedule (YYYY-MM-DD or natural language)
+
+Returns the created task's ID and deep link.`
+
+// CreateInput is the input schema for creating a task.
+type CreateInput struct {
+	Name        string  `json:"name"                   jsonschema:"required"`
+	AreaID      *string `json:"area_id,omitempty"`
+	GoalID      *string `json:"goal_id,omitempty"`
+	Status      *string `json:"status,omitempty"`
+	Note        *string `json:"note,omitempty"`
+	Priority    *string `json:"priority,omitempty"`
+	Estimate    *int    `json:"estimate,omitempty"`
+	Motivation  *string `json:"motivation,omitempty"`
+	Important   *bool   `json:"important,omitempty"`
+	Urgent      *bool   `json:"urgent,omitempty"`
+	ScheduledOn *string `json:"scheduled_on,omitempty"`
+}
+
+// CreateOutput is the output schema for creating a task.
+type CreateOutput struct {
+	ID       string `json:"id"`
+	DeepLink string `json:"deep_link"`
+}
+
+// Handler handles task-related MCP tool requests.
+type Handler struct {
+	client *lunatask.Client
+	areas  []shared.AreaProvider
+}
+
+// NewHandler creates a new task handler.
+func NewHandler(accessToken string, areas []shared.AreaProvider) *Handler {
+	return &Handler{
+		client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")),
+		areas:  areas,
+	}
+}
+
+// HandleCreate creates a new task.
+//
+//nolint:cyclop,funlen,gocognit,nilerr // MCP error pattern; repetitive field handling.
+func (h *Handler) HandleCreate(
+	ctx context.Context,
+	_ *mcp.CallToolRequest,
+	input CreateInput,
+) (*mcp.CallToolResult, CreateOutput, error) {
+	if input.AreaID != nil {
+		if err := lunatask.ValidateUUID(*input.AreaID); err != nil {
+			return shared.ErrorResult("invalid area_id: expected UUID"), CreateOutput{}, nil
+		}
+	}
+
+	if input.GoalID != nil {
+		if err := lunatask.ValidateUUID(*input.GoalID); err != nil {
+			return shared.ErrorResult("invalid goal_id: expected UUID"), CreateOutput{}, nil
+		}
+	}
+
+	if input.Estimate != nil {
+		if err := shared.ValidateEstimate(*input.Estimate); err != nil {
+			return shared.ErrorResult(err.Error()), CreateOutput{}, nil
+		}
+	}
+
+	builder := h.client.NewTask(input.Name)
+
+	if input.AreaID != nil {
+		builder.InArea(*input.AreaID)
+	}
+
+	if input.GoalID != nil {
+		builder.InGoal(*input.GoalID)
+	}
+
+	if input.Status != nil {
+		status, err := lunatask.ParseTaskStatus(*input.Status)
+		if err != nil {
+			return shared.ErrorResult(err.Error()), CreateOutput{}, nil
+		}
+
+		builder.WithStatus(status)
+	}
+
+	if input.Note != nil {
+		builder.WithNote(*input.Note)
+	}
+
+	if input.Priority != nil {
+		priority, err := lunatask.ParsePriority(*input.Priority)
+		if err != nil {
+			return shared.ErrorResult(err.Error()), CreateOutput{}, nil
+		}
+
+		builder.Priority(priority)
+	}
+
+	if input.Estimate != nil {
+		builder.WithEstimate(*input.Estimate)
+	}
+
+	if input.Motivation != nil {
+		motivation, err := lunatask.ParseMotivation(*input.Motivation)
+		if err != nil {
+			return shared.ErrorResult(err.Error()), CreateOutput{}, nil
+		}
+
+		builder.WithMotivation(motivation)
+	}
+
+	if input.Important != nil {
+		if *input.Important {
+			builder.Important()
+		} else {
+			builder.NotImportant()
+		}
+	}
+
+	if input.Urgent != nil {
+		if *input.Urgent {
+			builder.Urgent()
+		} else {
+			builder.NotUrgent()
+		}
+	}
+
+	if input.ScheduledOn != nil {
+		date, err := dateutil.Parse(*input.ScheduledOn)
+		if err != nil {
+			return shared.ErrorResult(err.Error()), CreateOutput{}, nil
+		}
+
+		builder.ScheduledOn(date)
+	}
+
+	task, err := builder.Create(ctx)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), CreateOutput{}, nil
+	}
+
+	deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
+
+	return nil, CreateOutput{
+		ID:       task.ID,
+		DeepLink: deepLink,
+	}, nil
+}

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

@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package task
+
+import (
+	"context"
+
+	"git.secluded.site/lune/internal/mcp/shared"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// DeleteToolName is the name of the delete task tool.
+const DeleteToolName = "delete_task"
+
+// DeleteToolDescription describes the delete task tool for LLMs.
+const DeleteToolDescription = `Deletes a task from Lunatask.
+
+Required:
+- id: Task UUID or lunatask:// deep link
+
+This action is permanent and cannot be undone.`
+
+// DeleteInput is the input schema for deleting a task.
+type DeleteInput struct {
+	ID string `json:"id" jsonschema:"required"`
+}
+
+// DeleteOutput is the output schema for deleting a task.
+type DeleteOutput struct {
+	Success bool   `json:"success"`
+	ID      string `json:"id"`
+}
+
+// HandleDelete deletes a task.
+func (h *Handler) HandleDelete(
+	ctx context.Context,
+	_ *mcp.CallToolRequest,
+	input DeleteInput,
+) (*mcp.CallToolResult, DeleteOutput, error) {
+	id, err := resolveID(input.ID)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), DeleteOutput{}, nil
+	}
+
+	if _, err := h.client.DeleteTask(ctx, id); err != nil {
+		return shared.ErrorResult(err.Error()), DeleteOutput{}, nil
+	}
+
+	return nil, DeleteOutput{
+		Success: true,
+		ID:      id,
+	}, nil
+}

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

@@ -0,0 +1,162 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package task
+
+import (
+	"context"
+	"time"
+
+	"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 tasks tool.
+const ListToolName = "list_tasks"
+
+// ListToolDescription describes the list tasks tool for LLMs.
+const ListToolDescription = `Lists tasks from Lunatask.
+
+Optional filters:
+- area_id: Filter by area UUID
+- status: Filter by status (later, next, started, waiting, completed)
+- include_completed: Include completed tasks (default: false, only shows today's)
+
+Note: Due to end-to-end encryption, task names and notes are not available.
+Only metadata (ID, status, dates, priority, etc.) is returned.
+
+Returns a list of tasks with their metadata and deep links.`
+
+// ListInput is the input schema for listing tasks.
+type ListInput struct {
+	AreaID           *string `json:"area_id,omitempty"`
+	Status           *string `json:"status,omitempty"`
+	IncludeCompleted *bool   `json:"include_completed,omitempty"`
+}
+
+// ListOutput is the output schema for listing tasks.
+type ListOutput struct {
+	Tasks []Summary `json:"tasks"`
+	Count int       `json:"count"`
+}
+
+// Summary represents a task in list output.
+type Summary struct {
+	ID          string  `json:"id"`
+	DeepLink    string  `json:"deep_link"`
+	Status      *string `json:"status,omitempty"`
+	Priority    *int    `json:"priority,omitempty"`
+	ScheduledOn *string `json:"scheduled_on,omitempty"`
+	CreatedAt   string  `json:"created_at"`
+	AreaID      *string `json:"area_id,omitempty"`
+	GoalID      *string `json:"goal_id,omitempty"`
+}
+
+const hoursPerDay = 24
+
+// HandleList lists tasks.
+//
+//nolint:cyclop,funlen,nilerr // Complexity from field handling; MCP returns errors in result, not Go error.
+func (h *Handler) HandleList(
+	ctx context.Context,
+	_ *mcp.CallToolRequest,
+	input ListInput,
+) (*mcp.CallToolResult, ListOutput, error) {
+	if input.AreaID != nil {
+		if err := lunatask.ValidateUUID(*input.AreaID); err != nil {
+			return shared.ErrorResult("invalid area_id: expected UUID"), ListOutput{}, nil
+		}
+	}
+
+	if input.Status != nil {
+		if _, err := lunatask.ParseTaskStatus(*input.Status); err != nil {
+			return shared.ErrorResult("invalid status: must be later, next, started, waiting, or completed"), ListOutput{}, nil
+		}
+	}
+
+	tasks, err := h.client.ListTasks(ctx, nil)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), ListOutput{}, nil
+	}
+
+	includeCompleted := input.IncludeCompleted != nil && *input.IncludeCompleted
+	today := time.Now().Truncate(hoursPerDay * time.Hour)
+
+	filtered := make([]lunatask.Task, 0, len(tasks))
+
+	for _, task := range tasks {
+		if !matchesFilters(task, input.AreaID, input.Status, includeCompleted, today) {
+			continue
+		}
+
+		filtered = append(filtered, task)
+	}
+
+	summaries := make([]Summary, 0, len(filtered))
+
+	for _, task := range filtered {
+		summary := Summary{
+			ID:        task.ID,
+			CreatedAt: task.CreatedAt.Format(time.RFC3339),
+			AreaID:    task.AreaID,
+			GoalID:    task.GoalID,
+		}
+
+		summary.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
+
+		if task.Status != nil {
+			s := string(*task.Status)
+			summary.Status = &s
+		}
+
+		if task.Priority != nil {
+			p := int(*task.Priority)
+			summary.Priority = &p
+		}
+
+		if task.ScheduledOn != nil {
+			s := task.ScheduledOn.Format("2006-01-02")
+			summary.ScheduledOn = &s
+		}
+
+		summaries = append(summaries, summary)
+	}
+
+	return nil, ListOutput{
+		Tasks: summaries,
+		Count: len(summaries),
+	}, nil
+}
+
+func matchesFilters(
+	task lunatask.Task,
+	areaID, status *string,
+	includeCompleted bool,
+	today time.Time,
+) bool {
+	if areaID != nil && (task.AreaID == nil || *task.AreaID != *areaID) {
+		return false
+	}
+
+	if status != nil {
+		if task.Status == nil || string(*task.Status) != *status {
+			return false
+		}
+	}
+
+	if !includeCompleted && isOldCompleted(task, today) {
+		return false
+	}
+
+	return true
+}
+
+func isOldCompleted(task lunatask.Task, today time.Time) bool {
+	if task.Status == nil || *task.Status != lunatask.StatusCompleted {
+		return false
+	}
+
+	return task.CompletedAt == nil || task.CompletedAt.Before(today)
+}

internal/mcp/tools/task/show.go 🔗

@@ -0,0 +1,108 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package task
+
+import (
+	"context"
+	"time"
+
+	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/mcp/shared"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// ShowToolName is the name of the show task tool.
+const ShowToolName = "show_task"
+
+// ShowToolDescription describes the show task tool for LLMs.
+const ShowToolDescription = `Shows details of a specific task from Lunatask.
+
+Required:
+- id: Task UUID or lunatask:// deep link
+
+Note: Due to end-to-end encryption, task name and note content are not available.
+Only metadata (ID, status, dates, priority, etc.) is returned.`
+
+// ShowInput is the input schema for showing a task.
+type ShowInput struct {
+	ID string `json:"id" jsonschema:"required"`
+}
+
+// ShowOutput is the output schema for showing a task.
+type ShowOutput struct {
+	ID          string  `json:"id"`
+	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"`
+}
+
+// HandleShow shows a task's details.
+func (h *Handler) HandleShow(
+	ctx context.Context,
+	_ *mcp.CallToolRequest,
+	input ShowInput,
+) (*mcp.CallToolResult, ShowOutput, error) {
+	id, err := resolveID(input.ID)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), ShowOutput{}, nil
+	}
+
+	task, err := h.client.GetTask(ctx, id)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), ShowOutput{}, nil
+	}
+
+	output := ShowOutput{
+		ID:        task.ID,
+		CreatedAt: task.CreatedAt.Format(time.RFC3339),
+		UpdatedAt: task.UpdatedAt.Format(time.RFC3339),
+		AreaID:    task.AreaID,
+		GoalID:    task.GoalID,
+	}
+
+	output.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
+
+	if task.Status != nil {
+		s := string(*task.Status)
+		output.Status = &s
+	}
+
+	if task.Priority != nil {
+		p := int(*task.Priority)
+		output.Priority = &p
+	}
+
+	if task.Estimate != nil {
+		output.Estimate = task.Estimate
+	}
+
+	if task.ScheduledOn != nil {
+		s := task.ScheduledOn.Format("2006-01-02")
+		output.ScheduledOn = &s
+	}
+
+	if task.CompletedAt != nil {
+		s := task.CompletedAt.Format(time.RFC3339)
+		output.CompletedAt = &s
+	}
+
+	if task.Eisenhower != nil {
+		important := task.Eisenhower.IsImportant()
+		urgent := task.Eisenhower.IsUrgent()
+		output.Important = &important
+		output.Urgent = &urgent
+	}
+
+	return nil, output, nil
+}

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

@@ -0,0 +1,193 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package task
+
+import (
+	"context"
+	"fmt"
+
+	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/dateutil"
+	"git.secluded.site/lune/internal/mcp/shared"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// UpdateToolName is the name of the update task tool.
+const UpdateToolName = "update_task"
+
+// UpdateToolDescription describes the update task tool for LLMs.
+const UpdateToolDescription = `Updates an existing task in Lunatask.
+
+Required:
+- id: Task UUID or lunatask:// deep link
+
+Optional (only specified fields are updated):
+- name: New task title
+- area_id: Move to area UUID
+- goal_id: Move to goal UUID (requires area_id)
+- status: later, next, started, waiting, completed
+- note: New markdown note (replaces existing)
+- priority: lowest, low, normal, high, highest
+- estimate: Time estimate in minutes (0-720)
+- motivation: must, should, want
+- important: true/false for Eisenhower matrix
+- urgent: true/false for Eisenhower matrix
+- scheduled_on: Date to schedule (YYYY-MM-DD)
+
+Returns the updated task's ID and deep link.`
+
+// UpdateInput is the input schema for updating a task.
+type UpdateInput struct {
+	ID          string  `json:"id"                     jsonschema:"required"`
+	Name        *string `json:"name,omitempty"`
+	AreaID      *string `json:"area_id,omitempty"`
+	GoalID      *string `json:"goal_id,omitempty"`
+	Status      *string `json:"status,omitempty"`
+	Note        *string `json:"note,omitempty"`
+	Priority    *string `json:"priority,omitempty"`
+	Estimate    *int    `json:"estimate,omitempty"`
+	Motivation  *string `json:"motivation,omitempty"`
+	Important   *bool   `json:"important,omitempty"`
+	Urgent      *bool   `json:"urgent,omitempty"`
+	ScheduledOn *string `json:"scheduled_on,omitempty"`
+}
+
+// UpdateOutput is the output schema for updating a task.
+type UpdateOutput struct {
+	ID       string `json:"id"`
+	DeepLink string `json:"deep_link"`
+}
+
+// HandleUpdate updates an existing task.
+//
+//nolint:cyclop,funlen,gocognit,nilerr // MCP error pattern; repetitive field handling.
+func (h *Handler) HandleUpdate(
+	ctx context.Context,
+	_ *mcp.CallToolRequest,
+	input UpdateInput,
+) (*mcp.CallToolResult, UpdateOutput, error) {
+	id, err := resolveID(input.ID)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
+	}
+
+	if input.AreaID != nil {
+		if err := lunatask.ValidateUUID(*input.AreaID); err != nil {
+			return shared.ErrorResult("invalid area_id: expected UUID"), UpdateOutput{}, nil
+		}
+	}
+
+	if input.GoalID != nil {
+		if err := lunatask.ValidateUUID(*input.GoalID); err != nil {
+			return shared.ErrorResult("invalid goal_id: expected UUID"), UpdateOutput{}, nil
+		}
+	}
+
+	if input.Estimate != nil {
+		if err := shared.ValidateEstimate(*input.Estimate); err != nil {
+			return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
+		}
+	}
+
+	builder := h.client.NewTaskUpdate(id)
+
+	if input.Name != nil {
+		builder.Name(*input.Name)
+	}
+
+	if input.AreaID != nil {
+		builder.InArea(*input.AreaID)
+	}
+
+	if input.GoalID != nil {
+		builder.InGoal(*input.GoalID)
+	}
+
+	if input.Status != nil {
+		status, err := lunatask.ParseTaskStatus(*input.Status)
+		if err != nil {
+			return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
+		}
+
+		builder.WithStatus(status)
+	}
+
+	if input.Note != nil {
+		builder.WithNote(*input.Note)
+	}
+
+	if input.Priority != nil {
+		priority, err := lunatask.ParsePriority(*input.Priority)
+		if err != nil {
+			return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
+		}
+
+		builder.Priority(priority)
+	}
+
+	if input.Estimate != nil {
+		builder.WithEstimate(*input.Estimate)
+	}
+
+	if input.Motivation != nil {
+		motivation, err := lunatask.ParseMotivation(*input.Motivation)
+		if err != nil {
+			return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
+		}
+
+		builder.WithMotivation(motivation)
+	}
+
+	if input.Important != nil {
+		if *input.Important {
+			builder.Important()
+		} else {
+			builder.NotImportant()
+		}
+	}
+
+	if input.Urgent != nil {
+		if *input.Urgent {
+			builder.Urgent()
+		} else {
+			builder.NotUrgent()
+		}
+	}
+
+	if input.ScheduledOn != nil {
+		date, err := dateutil.Parse(*input.ScheduledOn)
+		if err != nil {
+			return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
+		}
+
+		builder.ScheduledOn(date)
+	}
+
+	task, err := builder.Update(ctx)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
+	}
+
+	deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
+
+	return nil, UpdateOutput{
+		ID:       task.ID,
+		DeepLink: deepLink,
+	}, nil
+}
+
+// resolveID extracts a UUID from either a raw UUID or a lunatask:// deep link.
+func resolveID(input string) (string, error) {
+	_, id, err := lunatask.ParseDeepLink(input)
+	if err == nil {
+		return id, nil
+	}
+
+	if err := lunatask.ValidateUUID(input); err != nil {
+		return "", fmt.Errorf("invalid ID: %w", err)
+	}
+
+	return input, nil
+}

internal/mcp/tools/timestamp/handler.go 🔗

@@ -0,0 +1,81 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package timestamp provides an MCP tool for parsing natural language dates.
+package timestamp
+
+import (
+	"context"
+	"time"
+
+	"git.secluded.site/lune/internal/dateutil"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// ToolName is the name of this tool.
+const ToolName = "get_timestamp"
+
+// ToolDescription describes the tool for LLMs.
+const ToolDescription = `Parses natural language date/time expressions into RFC3339 timestamps.
+
+Accepts expressions like:
+- "today", "tomorrow", "yesterday"
+- "next Monday", "last Friday"
+- "2 days ago", "in 3 weeks"
+- "March 5", "2024-01-15"
+- "" (empty string returns today)
+
+Returns the timestamp in RFC3339 format (e.g., "2024-01-15T00:00:00Z").
+Use this tool to convert human-readable dates before passing them to task/habit tools.`
+
+// Input is the input schema for the timestamp tool.
+type Input struct {
+	Date string `json:"date"`
+}
+
+// Output is the output schema for the timestamp tool.
+type Output struct {
+	Timestamp string `json:"timestamp"`
+	Date      string `json:"date"`
+}
+
+// Handler handles timestamp tool requests.
+type Handler struct {
+	timezone *time.Location
+}
+
+// NewHandler creates a new timestamp handler with the given timezone.
+func NewHandler(tz string) *Handler {
+	loc, err := time.LoadLocation(tz)
+	if err != nil {
+		loc = time.UTC
+	}
+
+	return &Handler{timezone: loc}
+}
+
+// Handle parses a natural language date and returns an RFC3339 timestamp.
+func (h *Handler) Handle(
+	_ context.Context,
+	_ *mcp.CallToolRequest,
+	input Input,
+) (*mcp.CallToolResult, Output, error) {
+	parsed, err := dateutil.Parse(input.Date)
+	if err != nil {
+		//nolint:nilerr // MCP pattern: user errors in CallToolResult, nil Go error
+		return &mcp.CallToolResult{
+			IsError: true,
+			Content: []mcp.Content{
+				&mcp.TextContent{Text: err.Error()},
+			},
+		}, Output{}, nil
+	}
+
+	t := parsed.In(h.timezone)
+
+	return nil, Output{
+		Timestamp: t.Format(time.RFC3339),
+		Date:      t.Format("2006-01-02"),
+	}, nil
+}