main.go

  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}