server.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package mcp
  6
  7import (
  8	"context"
  9	"fmt"
 10	"log/slog"
 11
 12	"github.com/mark3labs/mcp-go/mcp"
 13	"github.com/mark3labs/mcp-go/server"
 14
 15	"git.sr.ht/~amolith/planning-mcp-server/internal/config"
 16	"git.sr.ht/~amolith/planning-mcp-server/internal/planning"
 17)
 18
 19// Server wraps the MCP server and implements planning tools
 20type Server struct {
 21	config  *config.Config
 22	logger  *slog.Logger
 23	planner *planning.Manager
 24	server  *server.MCPServer
 25}
 26
 27// New creates a new MCP server
 28func New(cfg *config.Config, logger *slog.Logger, planner *planning.Manager) (*Server, error) {
 29	if cfg == nil {
 30		return nil, fmt.Errorf("config cannot be nil")
 31	}
 32	if logger == nil {
 33		return nil, fmt.Errorf("logger cannot be nil")
 34	}
 35	if planner == nil {
 36		return nil, fmt.Errorf("planner cannot be nil")
 37	}
 38
 39	s := &Server{
 40		config:  cfg,
 41		logger:  logger,
 42		planner: planner,
 43	}
 44
 45	// Create MCP server
 46	mcpServer := server.NewMCPServer(
 47		"planning-mcp-server",
 48		"1.0.0",
 49		server.WithToolCapabilities(true),
 50	)
 51
 52	// Register tools
 53	s.registerTools(mcpServer)
 54
 55	s.server = mcpServer
 56	return s, nil
 57}
 58
 59// registerTools registers all planning tools
 60func (s *Server) registerTools(mcpServer *server.MCPServer) {
 61	// Register update_goal tool
 62	updateGoalTool := mcp.NewTool("update_goal",
 63		mcp.WithDescription("Set or update the overarching goal for your planning session"),
 64		mcp.WithString("goal",
 65			mcp.Required(),
 66			mcp.Description("The goal text to set"),
 67		),
 68	)
 69	mcpServer.AddTool(updateGoalTool, s.handleUpdateGoal)
 70
 71	// Register add_tasks tool
 72	addTasksTool := mcp.NewTool("add_tasks",
 73		mcp.WithDescription("Add one or more tasks to work on"),
 74		mcp.WithArray("tasks",
 75			mcp.Required(),
 76			mcp.Description("Array of tasks to add"),
 77			mcp.Items(map[string]any{
 78				"type": "object",
 79				"properties": map[string]any{
 80					"title": map[string]any{
 81						"type":        "string",
 82						"description": "Task title",
 83					},
 84					"description": map[string]any{
 85						"type":        "string",
 86						"description": "Task description (optional)",
 87					},
 88				},
 89				"required": []string{"title"},
 90			}),
 91		),
 92	)
 93	mcpServer.AddTool(addTasksTool, s.handleAddTasks)
 94
 95	// Register get_tasks tool
 96	getTasksTool := mcp.NewTool("get_tasks",
 97		mcp.WithDescription("Get current task list with status indicators"),
 98	)
 99	mcpServer.AddTool(getTasksTool, s.handleGetTasks)
100
101	// Register update_task_status tool
102	updateTaskStatusTool := mcp.NewTool("update_task_status",
103		mcp.WithDescription("Update the status of a specific task"),
104		mcp.WithString("task_id",
105			mcp.Required(),
106			mcp.Description("The task ID to update"),
107		),
108		mcp.WithString("status",
109			mcp.Required(),
110			mcp.Description("New status: pending, in_progress, completed, or failed"),
111		),
112	)
113	mcpServer.AddTool(updateTaskStatusTool, s.handleUpdateTaskStatus)
114}
115
116// handleUpdateGoal handles the update_goal tool call
117func (s *Server) handleUpdateGoal(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
118	s.logger.Info("Received update_goal tool call")
119
120	// Extract parameters
121	arguments := request.GetArguments()
122	goal, ok := arguments["goal"].(string)
123	if !ok || goal == "" {
124		return &mcp.CallToolResult{
125			Content: []mcp.Content{
126				mcp.TextContent{
127					Type: "text",
128					Text: "Error: goal parameter is required and must be a string",
129				},
130			},
131			IsError: true,
132		}, nil
133	}
134
135	// Update goal
136	if err := s.planner.UpdateGoal(goal); err != nil {
137		s.logger.Error("Failed to update goal", "error", err)
138		return &mcp.CallToolResult{
139			Content: []mcp.Content{
140				mcp.TextContent{
141					Type: "text",
142					Text: fmt.Sprintf("Error updating goal: %v", err),
143				},
144			},
145			IsError: true,
146		}, nil
147	}
148
149	response := fmt.Sprintf("Goal \"%s\" saved! You probably want to add one or more tasks now.", goal)
150	return &mcp.CallToolResult{
151		Content: []mcp.Content{
152			mcp.TextContent{
153				Type: "text",
154				Text: response,
155			},
156		},
157	}, nil
158}
159
160// handleAddTasks handles the add_tasks tool call
161func (s *Server) handleAddTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
162	s.logger.Info("Received add_tasks tool call")
163
164	// Extract parameters
165	arguments := request.GetArguments()
166	tasksRaw, ok := arguments["tasks"]
167	if !ok {
168		return &mcp.CallToolResult{
169			Content: []mcp.Content{
170				mcp.TextContent{
171					Type: "text",
172					Text: "Error: tasks parameter is required",
173				},
174			},
175			IsError: true,
176		}, nil
177	}
178
179	// Convert to slice of interfaces
180	tasksSlice, ok := tasksRaw.([]any)
181	if !ok {
182		return &mcp.CallToolResult{
183			Content: []mcp.Content{
184				mcp.TextContent{
185					Type: "text",
186					Text: "Error: tasks parameter must be an array",
187				},
188			},
189			IsError: true,
190		}, nil
191	}
192
193	// Parse tasks
194	tasks := make([]planning.TaskInput, 0, len(tasksSlice))
195	for _, taskRaw := range tasksSlice {
196		taskMap, ok := taskRaw.(map[string]any)
197		if !ok {
198			return &mcp.CallToolResult{
199				Content: []mcp.Content{
200					mcp.TextContent{
201						Type: "text",
202						Text: "Error: each task must be an object",
203					},
204				},
205				IsError: true,
206			}, nil
207		}
208
209		title, ok := taskMap["title"].(string)
210		if !ok || title == "" {
211			return &mcp.CallToolResult{
212				Content: []mcp.Content{
213					mcp.TextContent{
214						Type: "text",
215						Text: "Error: each task must have a non-empty title",
216					},
217				},
218				IsError: true,
219			}, nil
220		}
221
222		description, _ := taskMap["description"].(string)
223
224		tasks = append(tasks, planning.TaskInput{
225			Title:       title,
226			Description: description,
227		})
228	}
229
230	// Add tasks
231	if err := s.planner.AddTasks(tasks); err != nil {
232		s.logger.Error("Failed to add tasks", "error", err)
233		return &mcp.CallToolResult{
234			Content: []mcp.Content{
235				mcp.TextContent{
236					Type: "text",
237					Text: fmt.Sprintf("Error adding tasks: %v", err),
238				},
239			},
240			IsError: true,
241		}, nil
242	}
243
244	// Get current goal for reminder
245	goal := s.planner.GetGoal()
246	goalText := "your planning session"
247	if goal != nil {
248		goalText = fmt.Sprintf("\"%s\"", goal.Text)
249	}
250
251	response := fmt.Sprintf("Tasks added successfully! Get started on your first one once you're ready, and call `get_tasks` frequently to remind yourself where you are in the process. Reminder that your overarching goal is %s.", goalText)
252	return &mcp.CallToolResult{
253		Content: []mcp.Content{
254			mcp.TextContent{
255				Type: "text",
256				Text: response,
257			},
258		},
259	}, nil
260}
261
262// handleGetTasks handles the get_tasks tool call
263func (s *Server) handleGetTasks(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
264	s.logger.Info("Received get_tasks tool call")
265
266	taskList := s.planner.GetTasks()
267
268	return &mcp.CallToolResult{
269		Content: []mcp.Content{
270			mcp.TextContent{
271				Type: "text",
272				Text: taskList,
273			},
274		},
275	}, nil
276}
277
278// handleUpdateTaskStatus handles the update_task_status tool call
279func (s *Server) handleUpdateTaskStatus(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
280	s.logger.Info("Received update_task_status tool call")
281
282	// Extract parameters
283	arguments := request.GetArguments()
284	taskID, ok := arguments["task_id"].(string)
285	if !ok || taskID == "" {
286		return &mcp.CallToolResult{
287			Content: []mcp.Content{
288				mcp.TextContent{
289					Type: "text",
290					Text: "Error: task_id parameter is required and must be a string",
291				},
292			},
293			IsError: true,
294		}, nil
295	}
296
297	statusStr, ok := arguments["status"].(string)
298	if !ok || statusStr == "" {
299		return &mcp.CallToolResult{
300			Content: []mcp.Content{
301				mcp.TextContent{
302					Type: "text",
303					Text: "Error: status parameter is required and must be a string",
304				},
305			},
306			IsError: true,
307		}, nil
308	}
309
310	// Parse status
311	status := planning.ParseStatus(statusStr)
312
313	// Update task status
314	if err := s.planner.UpdateTaskStatus(taskID, status); err != nil {
315		s.logger.Error("Failed to update task status", "error", err)
316		return &mcp.CallToolResult{
317			Content: []mcp.Content{
318				mcp.TextContent{
319					Type: "text",
320					Text: fmt.Sprintf("Error updating task status: %v", err),
321				},
322			},
323			IsError: true,
324		}, nil
325	}
326
327	// Return full task list
328	taskList := s.planner.GetTasks()
329	return &mcp.CallToolResult{
330		Content: []mcp.Content{
331			mcp.TextContent{
332				Type: "text",
333				Text: taskList,
334			},
335		},
336	}, nil
337}
338
339// GetServer returns the underlying MCP server
340func (s *Server) GetServer() *server.MCPServer {
341	return s.server
342}