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		return &mcp.CallToolResult{
335			IsError: true,
336			Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}},
337		}, nil
338	}
339
340	var response LunataskCreateTaskResponse
341
342	respBody, err := io.ReadAll(resp.Body)
343	if err != nil {
344		return reportMCPError(fmt.Sprintf("Failed to read response body: %v", err))
345	}
346
347	err = json.Unmarshal(respBody, &response)
348	if err != nil {
349		return reportMCPError(fmt.Sprintf("Failed to parse response: %v", err))
350	}
351
352	return &mcp.CallToolResult{
353		Content: []mcp.Content{
354			mcp.TextContent{
355				Type: "text",
356				Text: fmt.Sprint("Task created successfully.", response.Task.ID),
357			},
358		},
359	}, nil
360}
361
362func createDefaultConfigFile(configPath string) {
363	defaultConfig := Config{
364		Server: ServerConfig{
365			Host: "localhost",
366			Port: 8080,
367		},
368		AccessToken: "",
369		Areas: []Area{{
370			Name: "Example Area",
371			ID:   "area-id-placeholder",
372			Goals: []Goal{{
373				Name: "Example Goal",
374				ID:   "goal-id-placeholder",
375			}},
376		}},
377	}
378	file, err := os.Create(configPath)
379	if err != nil {
380		log.Fatalf("Failed to create default config at %s: %v", configPath, err)
381	}
382	defer file.Close()
383	if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
384		log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
385	}
386	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)
387	os.Exit(1)
388}