@@ -2,12 +2,13 @@
//
// SPDX-License-Identifier: AGPL-3.0-or-later
+// lunatask-mcp-server exposes Lunatask to LLMs via the Model Context Protocol.
package main
import (
"context"
- "fmt"
"log"
+ "log/slog"
"net"
"os"
"strconv"
@@ -85,18 +86,18 @@ var version = ""
func main() {
configPath := "./config.toml"
- for i, arg := range os.Args {
+ for argIdx, arg := range os.Args {
switch arg {
case "-v", "--version":
if version == "" {
version = "unknown, build with `just build` or copy/paste the build command from ./justfile"
}
- fmt.Println("lunatask-mcp-server:", version)
+ slog.Info("version", "name", "lunatask-mcp-server", "version", version)
os.Exit(0)
case "-c", "--config":
- if i+1 < len(os.Args) {
- configPath = os.Args[i+1]
+ if argIdx+1 < len(os.Args) {
+ configPath = os.Args[argIdx+1]
}
}
}
@@ -114,14 +115,14 @@ func main() {
log.Fatalf("Config file must provide access_token and at least one area.")
}
- for i, area := range config.Areas {
+ for areaIdx, area := range config.Areas {
if area.Name == "" || area.ID == "" {
- log.Fatalf("All areas (areas[%d]) must have both a name and id", i)
+ log.Fatalf("All areas (areas[%d]) must have both a name and id", areaIdx)
}
- for j, goal := range area.Goals {
+ for goalIdx, goal := range area.Goals {
if goal.Name == "" || goal.ID == "" {
- log.Fatalf("All goals (areas[%d].goals[%d]) must have both a name and id", i, j)
+ log.Fatalf("All goals (areas[%d].goals[%d]) must have both a name and id", areaIdx, goalIdx)
}
}
}
@@ -156,25 +157,25 @@ func NewMCPServer(appConfig *Config) *server.MCPServer {
hooks := &server.Hooks{}
hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
- fmt.Printf("beforeAny: %s, %v, %v\n", method, id, message)
+ slog.Debug("beforeAny", "method", method, "id", id, "message", message)
})
hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
- fmt.Printf("onSuccess: %s, %v, %v, %v\n", method, id, message, result)
+ slog.Debug("onSuccess", "method", method, "id", id, "message", message, "result", result)
})
hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
- fmt.Printf("onError: %s, %v, %v, %v\n", method, id, message, err)
+ slog.Error("onError", "method", method, "id", id, "message", message, "error", err)
})
hooks.AddBeforeInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest) {
- fmt.Printf("beforeInitialize: %v, %v\n", id, message)
+ slog.Debug("beforeInitialize", "id", id, "message", message)
})
hooks.AddAfterInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest, result *mcp.InitializeResult) {
- fmt.Printf("afterInitialize: %v, %v, %v\n", id, message, result)
+ slog.Debug("afterInitialize", "id", id, "message", message, "result", result)
})
hooks.AddAfterCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest, result *mcp.CallToolResult) {
- fmt.Printf("afterCallTool: %v, %v, %v\n", id, message, result)
+ slog.Debug("afterCallTool", "id", id, "message", message, "result", result)
})
hooks.AddBeforeCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest) {
- fmt.Printf("beforeCallTool: %v, %v\n", id, message)
+ slog.Debug("beforeCallTool", "id", id, "message", message)
})
mcpServer := server.NewMCPServer(
@@ -364,12 +365,17 @@ func createDefaultConfigFile(configPath string) {
if err != nil {
log.Fatalf("Failed to create default config at %s: %v", configPath, err)
}
- defer closeFile(file)
if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
+ closeFile(file)
log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
}
- fmt.Printf("A default config has been created at %s.\nPlease edit it to provide your Lunatask access token, correct area/goal IDs, and your timezone (IANA/Olson format, e.g. 'America/New_York'), then restart the server.\n", configPath)
+ closeFile(file)
+
+ slog.Info("default config created",
+ "path", configPath,
+ "next_steps", "edit the config to provide your Lunatask access token, area/goal IDs, and timezone (IANA format, e.g. 'America/New_York'), then restart",
+ )
os.Exit(1)
}
@@ -15,6 +15,12 @@ import (
"github.com/mark3labs/mcp-go/mcp"
)
+// ErrTimezoneNotConfigured is returned when the timezone config value is empty.
+var ErrTimezoneNotConfigured = errors.New(
+ "timezone is not configured; please set the 'timezone' value in your config file " +
+ "(e.g. 'UTC' or 'America/New_York')",
+)
+
// AreaProvider defines the interface for accessing area data.
type AreaProvider interface {
GetName() string
@@ -63,7 +69,7 @@ func reportMCPError(msg string) (*mcp.CallToolResult, error) {
// LoadLocation loads a timezone location string, returning a *time.Location or error.
func LoadLocation(timezone string) (*time.Location, error) {
if timezone == "" {
- return nil, errors.New("timezone is not configured; please set the 'timezone' value in your config file (e.g. 'UTC' or 'America/New_York')")
+ return nil, ErrTimezoneNotConfigured
}
loc, err := time.LoadLocation(timezone)
@@ -75,7 +81,7 @@ func LoadLocation(timezone string) (*time.Location, error) {
}
// HandleGetTimestamp handles the get_timestamp tool call.
-func (h *Handlers) HandleGetTimestamp(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+func (h *Handlers) HandleGetTimestamp(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
natLangDate, ok := request.Params.Arguments["natural_language_date"].(string)
if !ok || natLangDate == "" {
return reportMCPError("Missing or invalid required argument: natural_language_date")
@@ -102,13 +108,13 @@ func (h *Handlers) HandleGetTimestamp(ctx context.Context, request mcp.CallToolR
}
// HandleListAreasAndGoals handles the list_areas_and_goals tool call.
-func (h *Handlers) HandleListAreasAndGoals(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- var b strings.Builder
+func (h *Handlers) HandleListAreasAndGoals(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ var builder strings.Builder
for _, area := range h.config.Areas {
- fmt.Fprintf(&b, "- %s: %s\n", area.GetName(), area.GetID())
+ fmt.Fprintf(&builder, "- %s: %s\n", area.GetName(), area.GetID())
for _, goal := range area.GetGoals() {
- fmt.Fprintf(&b, " - %s: %s\n", goal.GetName(), goal.GetID())
+ fmt.Fprintf(&builder, " - %s: %s\n", goal.GetName(), goal.GetID())
}
}
@@ -116,7 +122,7 @@ func (h *Handlers) HandleListAreasAndGoals(ctx context.Context, request mcp.Call
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
- Text: b.String(),
+ Text: builder.String(),
},
},
}, nil