diff --git a/cmd/mcp/mcp.go b/cmd/mcp/mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..e62f75f3445e627959106f29b18cef6734b77409 --- /dev/null +++ b/cmd/mcp/mcp.go @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/cmd/mcp/server.go b/cmd/mcp/server.go new file mode 100644 index 0000000000000000000000000000000000000000..7866f5e736b26a1019232978fe37a8551e8bb6f8 --- /dev/null +++ b/cmd/mcp/server.go @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/cmd/root.go b/cmd/root.go index 408a3584e28114927db43a9b76e7e90d2710a487..1f8fa901d58dc333447d57e005743a65794e7e07 100644 --- a/cmd/root.go +++ b/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, diff --git a/go.mod b/go.mod index 72b8c8ea68d30e219876f959c361fe57bdfa10b2..3880e5f1115675bebdc0f2ec7047948ef2713078 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index f2b088363671d68cfceca3bba9c569515b856280..2c72bcb29411b484e6363f53aa06cb42574387f8 100644 --- a/go.sum +++ b/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= diff --git a/internal/config/config.go b/internal/config/config.go index b0c974a52dc7564a4918367370efceddb82e19e7..88c7be3b5182333cc93d8c839e8d5bc3f06b77d7 100644 --- a/internal/config/config.go +++ b/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() diff --git a/internal/mcp/resources/areas/handler.go b/internal/mcp/resources/areas/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..0d09bdf6dec4025a0c90d8ba356d9c4948db6f0a --- /dev/null +++ b/internal/mcp/resources/areas/handler.go @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/mcp/resources/habits/handler.go b/internal/mcp/resources/habits/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..2376235612ce7286136461688da8f048512a25ac --- /dev/null +++ b/internal/mcp/resources/habits/handler.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/mcp/resources/notebooks/handler.go b/internal/mcp/resources/notebooks/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..d27eb17e9456aa285166697aa236bb8a46b4ce89 --- /dev/null +++ b/internal/mcp/resources/notebooks/handler.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/mcp/shared/errors.go b/internal/mcp/shared/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..211e13f64a41ffdd0523055889e13bb8b0a59af1 --- /dev/null +++ b/internal/mcp/shared/errors.go @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/mcp/shared/types.go b/internal/mcp/shared/types.go new file mode 100644 index 0000000000000000000000000000000000000000..d509d09f44365bcd60d78701e9e704e7752036b4 --- /dev/null +++ b/internal/mcp/shared/types.go @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/mcp/tools/habit/track.go b/internal/mcp/tools/habit/track.go new file mode 100644 index 0000000000000000000000000000000000000000..3947045efedf04f5c44453080d93338762a2140d --- /dev/null +++ b/internal/mcp/tools/habit/track.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/mcp/tools/task/create.go b/internal/mcp/tools/task/create.go new file mode 100644 index 0000000000000000000000000000000000000000..12dabb752fb24dc210086224efb8ecc6c7f0b1be --- /dev/null +++ b/internal/mcp/tools/task/create.go @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/mcp/tools/task/delete.go b/internal/mcp/tools/task/delete.go new file mode 100644 index 0000000000000000000000000000000000000000..dbe1bfc7e00d0ceb1a53a72d13adc5eb4d4abd69 --- /dev/null +++ b/internal/mcp/tools/task/delete.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/mcp/tools/task/list.go b/internal/mcp/tools/task/list.go new file mode 100644 index 0000000000000000000000000000000000000000..c5b1feb479e09eb21688b81a609e4bc3ca52d3ab --- /dev/null +++ b/internal/mcp/tools/task/list.go @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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) +} diff --git a/internal/mcp/tools/task/show.go b/internal/mcp/tools/task/show.go new file mode 100644 index 0000000000000000000000000000000000000000..303e3968a7da2bd4ceeec25a887a4323562ec9f2 --- /dev/null +++ b/internal/mcp/tools/task/show.go @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/mcp/tools/task/update.go b/internal/mcp/tools/task/update.go new file mode 100644 index 0000000000000000000000000000000000000000..c4b2428decf7f50ec98c4928959aa3986f3e7278 --- /dev/null +++ b/internal/mcp/tools/task/update.go @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/mcp/tools/timestamp/handler.go b/internal/mcp/tools/timestamp/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..d6a166052d4c3935f1ca7cd5a0950ddd50988de6 --- /dev/null +++ b/internal/mcp/tools/timestamp/handler.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +}