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