1package main
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 "net/http"
12 "os"
13 "strings"
14 "time"
15
16 "github.com/go-playground/validator/v10"
17 "github.com/ijt/go-anytime"
18
19 "github.com/BurntSushi/toml"
20 "github.com/mark3labs/mcp-go/mcp"
21 "github.com/mark3labs/mcp-go/server"
22)
23
24// Goal represents a Lunatask goal with its name and ID
25type Goal struct {
26 Name string `toml:"name"`
27 ID string `toml:"id"`
28}
29
30// Area represents a Lunatask area with its name, ID, and its goals
31type Area struct {
32 Name string `toml:"name"`
33 ID string `toml:"id"`
34 Goals []Goal `toml:"goals"`
35}
36
37// Config holds the application's configuration loaded from TOML
38type ServerConfig struct {
39 Host string `toml:"host"`
40 Port int `toml:"port"`
41}
42
43type Config struct {
44 AccessToken string `toml:"access_token"`
45 Areas []Area `toml:"areas"`
46 Server ServerConfig `toml:"server"`
47}
48
49func main() {
50 configPath := "./config.toml"
51 for i, arg := range os.Args {
52 if arg == "-c" || arg == "--config" {
53 if i+1 < len(os.Args) {
54 configPath = os.Args[i+1]
55 }
56 }
57 }
58
59 if _, err := os.Stat(configPath); os.IsNotExist(err) {
60 createDefaultConfigFile(configPath)
61 }
62
63 var config Config
64 if _, err := toml.DecodeFile(configPath, &config); err != nil {
65 log.Fatalf("Failed to load config file %s: %v", configPath, err)
66 }
67
68 if config.AccessToken == "" || len(config.Areas) == 0 {
69 log.Fatalf("Config file must provide access_token and at least one area.")
70 }
71
72 for i, area := range config.Areas {
73 if area.Name == "" || area.ID == "" {
74 log.Fatalf("All areas (areas[%d]) must have both a name and id", i)
75 }
76 for j, goal := range area.Goals {
77 if goal.Name == "" || goal.ID == "" {
78 log.Fatalf("All goals (areas[%d].goals[%d]) must have both a name and id", i, j)
79 }
80 }
81 }
82
83 mcpServer := NewMCPServer(&config)
84
85 baseURL := fmt.Sprintf("http://%s:%d", config.Server.Host, config.Server.Port)
86 sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(baseURL))
87 listenAddr := fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port)
88 log.Printf("SSE server listening on %s (baseURL: %s)", listenAddr, baseURL)
89 if err := sseServer.Start(listenAddr); err != nil {
90 log.Fatalf("Server error: %v", err)
91 }
92}
93
94func NewMCPServer(config *Config) *server.MCPServer {
95 hooks := &server.Hooks{}
96
97 hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
98 fmt.Printf("beforeAny: %s, %v, %v\n", method, id, message)
99 })
100 hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
101 fmt.Printf("onSuccess: %s, %v, %v, %v\n", method, id, message, result)
102 })
103 hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
104 fmt.Printf("onError: %s, %v, %v, %v\n", method, id, message, err)
105 })
106 hooks.AddBeforeInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest) {
107 fmt.Printf("beforeInitialize: %v, %v\n", id, message)
108 })
109 hooks.AddAfterInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest, result *mcp.InitializeResult) {
110 fmt.Printf("afterInitialize: %v, %v, %v\n", id, message, result)
111 })
112 hooks.AddAfterCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest, result *mcp.CallToolResult) {
113 fmt.Printf("afterCallTool: %v, %v, %v\n", id, message, result)
114 })
115 hooks.AddBeforeCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest) {
116 fmt.Printf("beforeCallTool: %v, %v\n", id, message)
117 })
118
119 mcpServer := server.NewMCPServer(
120 "Lunatask MCP Server",
121 "0.1.0",
122 server.WithHooks(hooks),
123 )
124
125 mcpServer.AddTool(mcp.NewTool("get_task_timestamp",
126 mcp.WithDescription("Retrieves the formatted timestamp for a task"),
127 mcp.WithString("natural_language_date",
128 mcp.Description("Natural language date as described by the user, e.g. '1 week', 'tomorrow', 'sunday at 19:00', etc."),
129 mcp.Required(),
130 ),
131 ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
132 natLangDate, ok := request.Params.Arguments["natural_language_date"].(string)
133 if !ok || natLangDate == "" {
134 return reportMCPError("Missing or invalid required argument: natural_language_date")
135 }
136 parsedTime, err := anytime.Parse(natLangDate, time.Now())
137 if err != nil {
138 return reportMCPError(fmt.Sprintf("Could not parse natural language date: %v", err))
139 }
140 return &mcp.CallToolResult{
141 Content: []mcp.Content{
142 mcp.TextContent{
143 Type: "text",
144 Text: parsedTime.Format(time.RFC3339),
145 },
146 },
147 }, nil
148 })
149
150 mcpServer.AddTool(
151 mcp.NewTool(
152 "list_areas_and_goals",
153 mcp.WithDescription("List areas and goals and their IDs."),
154 ),
155 func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
156 var b strings.Builder
157 for _, area := range config.Areas {
158 fmt.Fprintf(&b, "- %s: %s\n", area.Name, area.ID)
159 for _, goal := range area.Goals {
160 fmt.Fprintf(&b, " - %s: %s\n", goal.Name, goal.ID)
161 }
162 }
163 return &mcp.CallToolResult{
164 Content: []mcp.Content{
165 mcp.TextContent{
166 Type: "text",
167 Text: b.String(),
168 },
169 },
170 }, nil
171 },
172 )
173
174 mcpServer.AddTool(mcp.NewTool("create_task",
175 mcp.WithDescription("Creates a new task"),
176 mcp.WithString("area_id",
177 mcp.Description("ID of the area in which to create the task"),
178 mcp.Required(),
179 ),
180 mcp.WithString("goal_id",
181 mcp.Description("ID of the goal, which must belong to the specified area, that the task should be associated with."),
182 ),
183 mcp.WithString("name",
184 mcp.Description("Plain text task name using sentence case."),
185 mcp.Required(),
186 ),
187 mcp.WithString("note",
188 mcp.Description("Note attached to the task, optionally Markdown-formatted"),
189 ),
190 mcp.WithNumber("estimate",
191 mcp.Description("Estimated time to completion in minutes"),
192 ),
193 mcp.WithString("scheduled_on",
194 mcp.Description("Formatted timestamp"),
195 ),
196 ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
197 return handleCreateTask(ctx, request, config)
198 })
199
200 return mcpServer
201}
202
203// LunataskCreateTaskRequest represents the request payload for creating a task in Lunatask
204type LunataskCreateTaskRequest struct {
205 AreaID string `json:"area_id"`
206 GoalID string `json:"goal_id,omitempty" validate:"omitempty"`
207 Name string `json:"name" validate:"max=100"`
208 Note string `json:"note,omitempty" validate:"omitempty"`
209 Status string `json:"status,omitempty" validate:"omitempty,oneof=later next started waiting completed"`
210 Motivation string `json:"motivation,omitempty" validate:"omitempty,oneof=must should want unknown"`
211 Estimate int `json:"estimate,omitempty" validate:"omitempty,min=0,max=720"`
212 Priority int `json:"priority,omitempty" validate:"omitempty,min=-2,max=2"`
213 ScheduledOn string `json:"scheduled_on,omitempty" validate:"omitempty"`
214 CompletedAt string `json:"completed_at,omitempty" validate:"omitempty"`
215 Source string `json:"source,omitempty" validate:"omitempty"`
216}
217
218// LunataskCreateTaskResponse represents the response from Lunatask API when creating a task
219type LunataskCreateTaskResponse struct {
220 Task struct {
221 ID string `json:"id"`
222 } `json:"task"`
223}
224
225func reportMCPError(msg string) (*mcp.CallToolResult, error) {
226 return &mcp.CallToolResult{
227 IsError: true,
228 Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
229 }, nil
230}
231
232// handleCreateTask handles the creation of a task in Lunatask
233func handleCreateTask(
234 ctx context.Context,
235 request mcp.CallToolRequest,
236 config *Config,
237) (*mcp.CallToolResult, error) {
238 arguments := request.Params.Arguments
239
240 areaID, ok := arguments["area_id"].(string)
241 if !ok || areaID == "" {
242 return reportMCPError("Missing or invalid required argument: area_id")
243 }
244
245 var area *Area
246 for i := range config.Areas {
247 if config.Areas[i].ID == areaID {
248 area = &config.Areas[i]
249 break
250 }
251 }
252 if area == nil {
253 return reportMCPError("Area not found for given area_id")
254 }
255
256 if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" {
257 found := false
258 for _, goal := range area.Goals {
259 if goal.ID == goalID {
260 found = true
261 break
262 }
263 }
264 if !found {
265 return reportMCPError("Goal not found in specified area for given goal_id")
266 }
267 }
268
269 var payload LunataskCreateTaskRequest
270 argBytes, err := json.Marshal(arguments)
271 if err != nil {
272 return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
273 }
274 if err := json.Unmarshal(argBytes, &payload); err != nil {
275 return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
276 }
277
278 validate := validator.New()
279 if err := validate.Struct(payload); err != nil {
280 var invalidValidationError *validator.InvalidValidationError
281 if errors.As(err, &invalidValidationError) {
282 return reportMCPError(fmt.Sprintf("Invalid validation error: %v", err))
283 }
284 var validationErrs validator.ValidationErrors
285 if errors.As(err, &validationErrs) {
286 var msgBuilder strings.Builder
287 msgBuilder.WriteString("task validation failed:")
288 for _, e := range validationErrs {
289 fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value())
290 }
291 return reportMCPError(msgBuilder.String())
292 }
293 return reportMCPError(fmt.Sprintf("Validation error: %v", err))
294 }
295
296 payloadBytes, err := json.Marshal(payload)
297 if err != nil {
298 return reportMCPError(fmt.Sprintf("Failed to marshal payload: %v", err))
299 }
300
301 req, err := http.NewRequestWithContext(
302 ctx,
303 "POST",
304 "https://api.lunatask.app/v1/tasks",
305 bytes.NewBuffer(payloadBytes),
306 )
307 if err != nil {
308 return reportMCPError(fmt.Sprintf("Failed to create HTTP request: %v", err))
309 }
310
311 req.Header.Set("Content-Type", "application/json")
312 req.Header.Set("Authorization", "bearer "+config.AccessToken)
313
314 client := &http.Client{}
315 resp, err := client.Do(req)
316 if err != nil {
317 return reportMCPError(fmt.Sprintf("Failed to send HTTP request: %v", err))
318 }
319 defer resp.Body.Close()
320
321 if resp.StatusCode == http.StatusNoContent {
322 return &mcp.CallToolResult{
323 Content: []mcp.Content{
324 mcp.TextContent{
325 Type: "text",
326 Text: "Task already exists (not an error).",
327 },
328 },
329 }, nil
330 }
331
332 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
333 respBody, _ := io.ReadAll(resp.Body)
334 log.Printf("Lunatask API error (status %d): %s", resp.StatusCode, string(respBody))
335 return &mcp.CallToolResult{
336 IsError: true,
337 Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}},
338 }, nil
339 }
340
341 var response LunataskCreateTaskResponse
342
343 respBody, err := io.ReadAll(resp.Body)
344 if err != nil {
345 return reportMCPError(fmt.Sprintf("Failed to read response body: %v", err))
346 }
347
348 err = json.Unmarshal(respBody, &response)
349 if err != nil {
350 return reportMCPError(fmt.Sprintf("Failed to parse response: %v", err))
351 }
352
353 return &mcp.CallToolResult{
354 Content: []mcp.Content{
355 mcp.TextContent{
356 Type: "text",
357 Text: fmt.Sprintf("Task created successfully with ID: %s", response.Task.ID),
358 },
359 },
360 }, nil
361}
362
363func createDefaultConfigFile(configPath string) {
364 defaultConfig := Config{
365 Server: ServerConfig{
366 Host: "localhost",
367 Port: 8080,
368 },
369 AccessToken: "",
370 Areas: []Area{{
371 Name: "Example Area",
372 ID: "area-id-placeholder",
373 Goals: []Goal{{
374 Name: "Example Goal",
375 ID: "goal-id-placeholder",
376 }},
377 }},
378 }
379 file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
380 if err != nil {
381 log.Fatalf("Failed to create default config at %s: %v", configPath, err)
382 }
383 defer file.Close()
384 if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
385 log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
386 }
387 fmt.Printf("A default config has been created at %s.\nPlease edit it to provide your Lunatask access token and correct area/goal IDs, then restart the server.\n", configPath)
388 os.Exit(1)
389}