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 return mcpServer
181}
182
183// LunataskCreateTaskRequest represents the request payload for creating a task in Lunatask
184type LunataskCreateTaskRequest struct {
185 AreaID string `json:"area_id"`
186 GoalID string `json:"goal_id,omitempty"`
187 Name string `json:"name" validate:"max=100"`
188 Note string `json:"note,omitempty"`
189 Status string `json:"status,omitempty" validate:"oneof=later next started waiting completed"`
190 Motivation string `json:"motivation,omitempty" validate:"oneof=must should want unknown"`
191 Estimate int `json:"estimate,omitempty" validate:"min=0,max=720"`
192 Priority int `json:"priority,omitempty" validate:"min=-2,max=2"`
193 ScheduledOn string `json:"scheduled_on,omitempty"`
194 CompletedAt string `json:"completed_at,omitempty"`
195 Source string `json:"source,omitempty"`
196}
197
198// LunataskCreateTaskResponse represents the response from Lunatask API when creating a task
199type LunataskCreateTaskResponse struct {
200 Task struct {
201 ID string `json:"id"`
202 } `json:"task"`
203}
204
205func reportMCPError(msg string) (*mcp.CallToolResult, error) {
206 return &mcp.CallToolResult{
207 IsError: true,
208 Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
209 }, nil
210}
211
212// handleCreateTask handles the creation of a task in Lunatask
213func handleCreateTask(
214 ctx context.Context,
215 request mcp.CallToolRequest,
216 config *Config,
217) (*mcp.CallToolResult, error) {
218 arguments := request.Params.Arguments
219
220 payload := LunataskCreateTaskRequest{
221 Source: "lmcps",
222 }
223 argBytes, err := json.Marshal(arguments)
224 if err != nil {
225 return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
226 }
227 if err := json.Unmarshal(argBytes, &payload); err != nil {
228 return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
229 }
230
231 // Validate the struct before sending
232 validate := validator.New()
233 if err := validate.Struct(payload); err != nil {
234 var invalidValidationError *validator.InvalidValidationError
235 if errors.As(err, &invalidValidationError) {
236 return reportMCPError(fmt.Sprintf("Invalid validation error: %v", err))
237 }
238 var validationErrs validator.ValidationErrors
239 if errors.As(err, &validationErrs) {
240 var msgBuilder strings.Builder
241 msgBuilder.WriteString("task validation failed:")
242 for _, e := range validationErrs {
243 fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value())
244 }
245 return reportMCPError(msgBuilder.String())
246 }
247 return reportMCPError(fmt.Sprintf("Validation error: %v", err))
248 }
249
250 // Convert the payload to JSON
251 payloadBytes, err := json.Marshal(payload)
252 if err != nil {
253 return reportMCPError(fmt.Sprintf("Failed to marshal payload: %v", err))
254 }
255
256 // Create the HTTP request
257 req, err := http.NewRequestWithContext(
258 ctx,
259 "POST",
260 "https://api.lunatask.app/v1/tasks",
261 bytes.NewBuffer(payloadBytes),
262 )
263 if err != nil {
264 return reportMCPError(fmt.Sprintf("Failed to create HTTP request: %v", err))
265 }
266
267 // Set the required headers
268 req.Header.Set("Content-Type", "application/json")
269 req.Header.Set("Authorization", "bearer "+config.AccessToken)
270
271 // Send the request
272 client := &http.Client{}
273 resp, err := client.Do(req)
274 if err != nil {
275 return reportMCPError(fmt.Sprintf("Failed to send HTTP request: %v", err))
276 }
277 defer resp.Body.Close()
278
279 // Handle duplicate task (204 No Content)
280 if resp.StatusCode == http.StatusNoContent {
281 return &mcp.CallToolResult{
282 Content: []mcp.Content{
283 mcp.TextContent{
284 Type: "text",
285 Text: "Task already exists (not an error).",
286 },
287 },
288 }, nil
289 }
290
291 // Check for error responses
292 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
293 respBody, _ := io.ReadAll(resp.Body)
294 return &mcp.CallToolResult{
295 IsError: true,
296 Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}},
297 }, nil
298 }
299
300 // Parse the response
301 var response LunataskCreateTaskResponse
302
303 respBody, err := io.ReadAll(resp.Body)
304 if err != nil {
305 return reportMCPError(fmt.Sprintf("Failed to read response body: %v", err))
306 }
307
308 err = json.Unmarshal(respBody, &response)
309 if err != nil {
310 return reportMCPError(fmt.Sprintf("Failed to parse response: %v", err))
311 }
312
313 // Return success result
314 return &mcp.CallToolResult{
315 Content: []mcp.Content{
316 mcp.TextContent{
317 Type: "text",
318 Text: fmt.Sprint("Task created successfully.", response.Task.ID),
319 },
320 },
321 }, nil
322}
323
324func createDefaultConfigFile(configPath string) {
325 defaultConfig := Config{
326 Server: ServerConfig{
327 Host: "localhost",
328 Port: 8080,
329 },
330 AccessToken: "",
331 Areas: []Area{{
332 Name: "Example Area",
333 ID: "area-id-placeholder",
334 }},
335 Goals: []Goal{{
336 Name: "Example Goal",
337 ID: "goal-id-placeholder",
338 }},
339 }
340 file, err := os.Create(configPath)
341 if err != nil {
342 log.Fatalf("Failed to create default config at %s: %v", configPath, err)
343 }
344 defer file.Close()
345 if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
346 log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
347 }
348 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)
349 os.Exit(1)
350}