initial commit

Amolith created

Change summary

go.mod  |  13 +++
go.sum  |  16 ++++
main.go | 202 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 231 insertions(+)

Detailed changes

go.mod 🔗

@@ -0,0 +1,13 @@
+module git.sr.ht/~amolith/lunatask-mcp-server
+
+go 1.24.2
+
+require (
+	github.com/BurntSushi/toml v1.5.0
+	github.com/mark3labs/mcp-go v0.21.1
+)
+
+require (
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+)

go.sum 🔗

@@ -0,0 +1,16 @@
+github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
+github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/mark3labs/mcp-go v0.21.1 h1:7Ek6KPIIbMhEYHRiRIg6K6UAgNZCJaHKQp926MNr6V0=
+github.com/mark3labs/mcp-go v0.21.1/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

main.go 🔗

@@ -0,0 +1,202 @@
+package main
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"os"
+
+	"github.com/BurntSushi/toml"
+	"github.com/mark3labs/mcp-go/mcp"
+	"github.com/mark3labs/mcp-go/server"
+)
+
+// Config holds the application's configuration loaded from TOML
+type Config struct {
+	AccessToken string `toml:"access_token"`
+	AreaID string `toml:"area_id"`
+}
+
+
+// LunataskCreateTaskRequest represents the request payload for creating a task in Lunatask
+type LunataskCreateTaskRequest struct {
+	Name   string `json:"name"`
+	Source string `json:"source"`
+	AreaID string `json:"area_id"`
+}
+
+// LunataskCreateTaskResponse represents the response from Lunatask API when creating a task
+type LunataskCreateTaskResponse struct {
+	Task struct {
+		ID string `json:"id"`
+	} `json:"task"`
+}
+
+func main() {
+	// Determine config path from command-line arguments
+	configPath := "./config.toml"
+	for i, arg := range os.Args {
+		if arg == "-c" || arg == "--config" {
+			if i+1 < len(os.Args) {
+				configPath = os.Args[i+1]
+			}
+		}
+	}
+
+	// Load and decode TOML config
+	var config Config
+	if _, err := toml.DecodeFile(configPath, &config); err != nil {
+		log.Fatalf("Failed to load config file %s: %v", configPath, err)
+	}
+
+	if config.AccessToken == "" || config.AreaID == "" {
+		log.Fatalf("Config file must provide access_token and area_id")
+	}
+
+	mcpServer := NewMCPServer(&config)
+
+	sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL("http://localhost:8080"))
+	log.Printf("SSE server listening on :8080")
+	if err := sseServer.Start(":8080"); err != nil {
+		log.Fatalf("Server error: %v", err)
+	}
+}
+
+func NewMCPServer(config *Config) *server.MCPServer {
+	hooks := &server.Hooks{}
+
+	hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
+		fmt.Printf("beforeAny: %s, %v, %v\n", method, id, message)
+	})
+	hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
+		fmt.Printf("onSuccess: %s, %v, %v, %v\n", method, id, message, result)
+	})
+	hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
+		fmt.Printf("onError: %s, %v, %v, %v\n", method, id, message, err)
+	})
+	hooks.AddBeforeInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest) {
+		fmt.Printf("beforeInitialize: %v, %v\n", id, message)
+	})
+	hooks.AddAfterInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest, result *mcp.InitializeResult) {
+		fmt.Printf("afterInitialize: %v, %v, %v\n", id, message, result)
+	})
+	hooks.AddAfterCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest, result *mcp.CallToolResult) {
+		fmt.Printf("afterCallTool: %v, %v, %v\n", id, message, result)
+	})
+	hooks.AddBeforeCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest) {
+		fmt.Printf("beforeCallTool: %v, %v\n", id, message)
+	})
+
+	mcpServer := server.NewMCPServer(
+		"Lunatask MCP Server",
+		"1.0.0",
+		server.WithHooks(hooks),
+	)
+
+	// Pass config to the handler through closure
+	mcpServer.AddTool(mcp.NewTool("create_task",
+		mcp.WithDescription("Creates a new task"),
+		mcp.WithString("name",
+			mcp.Description("Name of the task"),
+			mcp.Required(),
+		),
+	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+		return handleCreateTask(ctx, request, config)
+	})
+
+	return mcpServer
+}
+
+func handleCreateTask(
+	ctx context.Context,
+	request mcp.CallToolRequest,
+	config *Config,
+) (*mcp.CallToolResult, error) {
+	// Extract the name parameter from the request
+	arguments := request.Params.Arguments
+	name, ok := arguments["name"].(string)
+	if !ok {
+		return nil, fmt.Errorf("invalid value for argument 'name'")
+	}
+
+	// Create the payload for the Lunatask API using our struct
+	payload := LunataskCreateTaskRequest{
+		Name:   name,
+		Source: "lmcps",
+		AreaID: config.AreaID,
+	}
+
+	// Convert the payload to JSON
+	payloadBytes, err := json.Marshal(payload)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal payload: %w", err)
+	}
+
+	// Create the HTTP request
+	req, err := http.NewRequestWithContext(
+		ctx,
+		"POST",
+		"https://api.lunatask.app/v1/tasks",
+		bytes.NewBuffer(payloadBytes),
+	)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
+	}
+
+	// Set the required headers
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Authorization", "bearer " + config.AccessToken)
+
+	// Send the request
+	client := &http.Client{}
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
+	}
+	defer resp.Body.Close()
+
+	// Handle duplicate task (204 No Content)
+	if resp.StatusCode == http.StatusNoContent {
+		return &mcp.CallToolResult{
+			Content: []mcp.Content{
+				mcp.TextContent{
+					Type: "text",
+					Text: "Duplicate task found, no new task created.",
+				},
+			},
+		}, nil
+	}
+
+	// Check for error responses
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		respBody, _ := io.ReadAll(resp.Body)
+		return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
+	}
+
+	// Parse the response
+	var response LunataskCreateTaskResponse
+
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read response body: %w", err)
+	}
+
+	err = json.Unmarshal(respBody, &response)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	// Return success result
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{
+			mcp.TextContent{
+				Type: "text",
+				Text: fmt.Sprintf("Task created successfully! Task ID: %s", response.Task.ID),
+			},
+		},
+	}, nil
+}