main.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package main
  6
  7import (
  8	"bytes"
  9	"context"
 10	"encoding/json"
 11	"errors"
 12	"fmt"
 13	"io"
 14	"log"
 15	"net/http"
 16	"os"
 17	"strings"
 18	"time"
 19
 20	"github.com/go-playground/validator/v10"
 21	"github.com/ijt/go-anytime"
 22
 23	"github.com/BurntSushi/toml"
 24	"github.com/mark3labs/mcp-go/mcp"
 25	"github.com/mark3labs/mcp-go/server"
 26)
 27
 28// Goal represents a Lunatask goal with its name and ID
 29type Goal struct {
 30	Name string `toml:"name"`
 31	ID   string `toml:"id"`
 32}
 33
 34// Area represents a Lunatask area with its name, ID, and its goals
 35type Area struct {
 36	Name  string `toml:"name"`
 37	ID    string `toml:"id"`
 38	Goals []Goal `toml:"goals"`
 39}
 40
 41// Config holds the application's configuration loaded from TOML
 42type ServerConfig struct {
 43	Host string `toml:"host"`
 44	Port int    `toml:"port"`
 45}
 46
 47type Config struct {
 48	AccessToken string       `toml:"access_token"`
 49	Areas       []Area       `toml:"areas"`
 50	Server      ServerConfig `toml:"server"`
 51}
 52
 53func main() {
 54	configPath := "./config.toml"
 55	for i, arg := range os.Args {
 56		if arg == "-c" || arg == "--config" {
 57			if i+1 < len(os.Args) {
 58				configPath = os.Args[i+1]
 59			}
 60		}
 61	}
 62
 63	if _, err := os.Stat(configPath); os.IsNotExist(err) {
 64		createDefaultConfigFile(configPath)
 65	}
 66
 67	var config Config
 68	if _, err := toml.DecodeFile(configPath, &config); err != nil {
 69		log.Fatalf("Failed to load config file %s: %v", configPath, err)
 70	}
 71
 72	if config.AccessToken == "" || len(config.Areas) == 0 {
 73		log.Fatalf("Config file must provide access_token and at least one area.")
 74	}
 75
 76	for i, area := range config.Areas {
 77		if area.Name == "" || area.ID == "" {
 78			log.Fatalf("All areas (areas[%d]) must have both a name and id", i)
 79		}
 80		for j, goal := range area.Goals {
 81			if goal.Name == "" || goal.ID == "" {
 82				log.Fatalf("All goals (areas[%d].goals[%d]) must have both a name and id", i, j)
 83			}
 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_task_timestamp",
130		mcp.WithDescription("Retrieves the formatted timestamp 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', 'sunday at 19:00', 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(
155		mcp.NewTool(
156			"list_areas_and_goals",
157			mcp.WithDescription("List areas and goals and their IDs."),
158		),
159		func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
160			var b strings.Builder
161			for _, area := range config.Areas {
162				fmt.Fprintf(&b, "- %s: %s\n", area.Name, area.ID)
163				for _, goal := range area.Goals {
164					fmt.Fprintf(&b, "  - %s: %s\n", goal.Name, goal.ID)
165				}
166			}
167			return &mcp.CallToolResult{
168				Content: []mcp.Content{
169					mcp.TextContent{
170						Type: "text",
171						Text: b.String(),
172					},
173				},
174			}, nil
175		},
176	)
177
178	mcpServer.AddTool(mcp.NewTool("create_task",
179		mcp.WithDescription("Creates a new task"),
180		mcp.WithString("area_id",
181			mcp.Description("ID of the area in which to create the task"),
182			mcp.Required(),
183		),
184		mcp.WithString("goal_id",
185			mcp.Description("ID of the goal, which must belong to the specified area, that the task should be associated with."),
186		),
187		mcp.WithString("name",
188			mcp.Description("Plain text task name using sentence case."),
189			mcp.Required(),
190		),
191		mcp.WithString("note",
192			mcp.Description("Note attached to the task, optionally Markdown-formatted"),
193		),
194		mcp.WithNumber("estimate",
195			mcp.Description("Estimated time to completion in minutes"),
196		),
197		mcp.WithString("scheduled_on",
198			mcp.Description("Formatted timestamp"),
199		),
200	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
201		return handleCreateTask(ctx, request, config)
202	})
203
204	return mcpServer
205}
206
207// LunataskCreateTaskRequest represents the request payload for creating a task in Lunatask
208type LunataskCreateTaskRequest struct {
209	AreaID      string `json:"area_id"`
210	GoalID      string `json:"goal_id,omitempty" validate:"omitempty"`
211	Name        string `json:"name" validate:"max=100"`
212	Note        string `json:"note,omitempty" validate:"omitempty"`
213	Status      string `json:"status,omitempty" validate:"omitempty,oneof=later next started waiting completed"`
214	Motivation  string `json:"motivation,omitempty" validate:"omitempty,oneof=must should want unknown"`
215	Estimate    int    `json:"estimate,omitempty" validate:"omitempty,min=0,max=720"`
216	Priority    int    `json:"priority,omitempty" validate:"omitempty,min=-2,max=2"`
217	ScheduledOn string `json:"scheduled_on,omitempty" validate:"omitempty"`
218	CompletedAt string `json:"completed_at,omitempty" validate:"omitempty"`
219	Source      string `json:"source,omitempty" validate:"omitempty"`
220}
221
222// LunataskCreateTaskResponse represents the response from Lunatask API when creating a task
223type LunataskCreateTaskResponse struct {
224	Task struct {
225		ID string `json:"id"`
226	} `json:"task"`
227}
228
229func reportMCPError(msg string) (*mcp.CallToolResult, error) {
230	return &mcp.CallToolResult{
231		IsError: true,
232		Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
233	}, nil
234}
235
236// handleCreateTask handles the creation of a task in Lunatask
237func handleCreateTask(
238	ctx context.Context,
239	request mcp.CallToolRequest,
240	config *Config,
241) (*mcp.CallToolResult, error) {
242	arguments := request.Params.Arguments
243
244	areaID, ok := arguments["area_id"].(string)
245	if !ok || areaID == "" {
246		return reportMCPError("Missing or invalid required argument: area_id")
247	}
248
249	var area *Area
250	for i := range config.Areas {
251		if config.Areas[i].ID == areaID {
252			area = &config.Areas[i]
253			break
254		}
255	}
256	if area == nil {
257		return reportMCPError("Area not found for given area_id")
258	}
259
260	if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" {
261		found := false
262		for _, goal := range area.Goals {
263			if goal.ID == goalID {
264				found = true
265				break
266			}
267		}
268		if !found {
269			return reportMCPError("Goal not found in specified area for given goal_id")
270		}
271	}
272
273	var payload LunataskCreateTaskRequest
274	argBytes, err := json.Marshal(arguments)
275	if err != nil {
276		return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
277	}
278	if err := json.Unmarshal(argBytes, &payload); err != nil {
279		return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
280	}
281
282	validate := validator.New()
283	if err := validate.Struct(payload); err != nil {
284		var invalidValidationError *validator.InvalidValidationError
285		if errors.As(err, &invalidValidationError) {
286			return reportMCPError(fmt.Sprintf("Invalid validation error: %v", err))
287		}
288		var validationErrs validator.ValidationErrors
289		if errors.As(err, &validationErrs) {
290			var msgBuilder strings.Builder
291			msgBuilder.WriteString("task validation failed:")
292			for _, e := range validationErrs {
293				fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value())
294			}
295			return reportMCPError(msgBuilder.String())
296		}
297		return reportMCPError(fmt.Sprintf("Validation error: %v", err))
298	}
299
300	payloadBytes, err := json.Marshal(payload)
301	if err != nil {
302		return reportMCPError(fmt.Sprintf("Failed to marshal payload: %v", err))
303	}
304
305	req, err := http.NewRequestWithContext(
306		ctx,
307		"POST",
308		"https://api.lunatask.app/v1/tasks",
309		bytes.NewBuffer(payloadBytes),
310	)
311	if err != nil {
312		return reportMCPError(fmt.Sprintf("Failed to create HTTP request: %v", err))
313	}
314
315	req.Header.Set("Content-Type", "application/json")
316	req.Header.Set("Authorization", "bearer "+config.AccessToken)
317
318	client := &http.Client{}
319	resp, err := client.Do(req)
320	if err != nil {
321		return reportMCPError(fmt.Sprintf("Failed to send HTTP request: %v", err))
322	}
323	defer resp.Body.Close()
324
325	if resp.StatusCode == http.StatusNoContent {
326		return &mcp.CallToolResult{
327			Content: []mcp.Content{
328				mcp.TextContent{
329					Type: "text",
330					Text: "Task already exists (not an error).",
331				},
332			},
333		}, nil
334	}
335
336	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
337		respBody, _ := io.ReadAll(resp.Body)
338		log.Printf("Lunatask API error (status %d): %s", resp.StatusCode, string(respBody))
339		return &mcp.CallToolResult{
340			IsError: true,
341			Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}},
342		}, nil
343	}
344
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 &mcp.CallToolResult{
358		Content: []mcp.Content{
359			mcp.TextContent{
360				Type: "text",
361				Text: fmt.Sprintf("Task created successfully with ID: %s", response.Task.ID),
362			},
363		},
364	}, nil
365}
366
367func createDefaultConfigFile(configPath string) {
368	defaultConfig := Config{
369		Server: ServerConfig{
370			Host: "localhost",
371			Port: 8080,
372		},
373		AccessToken: "",
374		Areas: []Area{{
375			Name: "Example Area",
376			ID:   "area-id-placeholder",
377			Goals: []Goal{{
378				Name: "Example Goal",
379				ID:   "goal-id-placeholder",
380			}},
381		}},
382	}
383	file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
384	if err != nil {
385		log.Fatalf("Failed to create default config at %s: %v", configPath, err)
386	}
387	defer file.Close()
388	if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
389		log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
390	}
391	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)
392	os.Exit(1)
393}