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}